working epics ioc and tested
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
|
||||
interface NodeParam {
|
||||
label: string;
|
||||
key: string;
|
||||
type: 'number' | 'text';
|
||||
default: string;
|
||||
}
|
||||
|
||||
const NODE_TYPES: Array<{ type: string; label: string; params: NodeParam[] }> = [
|
||||
{ type: 'gain', label: 'Gain', params: [{ label: 'Factor', key: 'gain', type: 'number', default: '1' }] },
|
||||
{ type: 'offset', label: 'Offset', params: [{ label: 'Offset', key: 'offset', type: 'number', default: '0' }] },
|
||||
{ type: 'moving_average', label: 'Moving Average', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
||||
{ type: 'rms', label: 'RMS', params: [{ label: 'Window (n)', key: 'window', type: 'number', default: '10' }] },
|
||||
{ type: 'derivative', label: 'Derivative', params: [] },
|
||||
{ type: 'clamp', label: 'Clamp', params: [{ label: 'Min', key: 'min', type: 'number', default: '0' }, { label: 'Max', key: 'max', type: 'number', default: '100' }] },
|
||||
{ type: 'expr', label: 'Formula', params: [{ label: 'Expression (a=input)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
|
||||
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a=input)', key: 'script', type: 'text', default: 'return a' }] },
|
||||
];
|
||||
|
||||
interface PipelineNode {
|
||||
type: string;
|
||||
params: Record<string, any>;
|
||||
}
|
||||
|
||||
interface SignalDef {
|
||||
name: string;
|
||||
ds: string;
|
||||
signal: string;
|
||||
pipeline: PipelineNode[];
|
||||
meta: {
|
||||
unit?: string;
|
||||
description?: string;
|
||||
displayLow?: number;
|
||||
displayHigh?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export default function SyntheticEditor({ name, onClose, onSaved }: Props) {
|
||||
const [def, setDef] = useState<SignalDef | null>(null);
|
||||
const [pipeline, setPipeline] = useState<PipelineNode[]>([]);
|
||||
const [addType, setAddType] = useState('gain');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`)
|
||||
.then(r => r.ok ? r.json() : Promise.reject(r.statusText))
|
||||
.then((d: SignalDef) => {
|
||||
setDef(d);
|
||||
setPipeline(d.pipeline ?? []);
|
||||
})
|
||||
.catch(e => setError(String(e)))
|
||||
.finally(() => setLoading(false));
|
||||
}, [name]);
|
||||
|
||||
function nodeLabel(type: string): string {
|
||||
return NODE_TYPES.find(n => n.type === type)?.label ?? type;
|
||||
}
|
||||
|
||||
function nodeParamDefs(type: string): NodeParam[] {
|
||||
return NODE_TYPES.find(n => n.type === type)?.params ?? [];
|
||||
}
|
||||
|
||||
function setNodeParam(idx: number, key: string, val: string) {
|
||||
setPipeline(p => p.map((node, i) => {
|
||||
if (i !== idx) return node;
|
||||
const paramDef = nodeParamDefs(node.type).find(pd => pd.key === key);
|
||||
const typed: any = paramDef?.type === 'number' ? parseFloat(val) : val;
|
||||
return { ...node, params: { ...node.params, [key]: typed } };
|
||||
}));
|
||||
}
|
||||
|
||||
function removeNode(idx: number) {
|
||||
setPipeline(p => p.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function moveNode(idx: number, dir: -1 | 1) {
|
||||
setPipeline(p => {
|
||||
const next = [...p];
|
||||
const target = idx + dir;
|
||||
if (target < 0 || target >= next.length) return next;
|
||||
[next[idx], next[target]] = [next[target], next[idx]];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function addNode() {
|
||||
const nodeDef = NODE_TYPES.find(n => n.type === addType)!;
|
||||
const params: Record<string, any> = {};
|
||||
for (const p of nodeDef.params) {
|
||||
params[p.key] = p.type === 'number' ? parseFloat(p.default) : p.default;
|
||||
}
|
||||
setPipeline(p => [...p, { type: addType, params }]);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!def) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const body = { ...def, pipeline };
|
||||
const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(j.error ?? res.statusText);
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="wizard-backdrop" onClick={onClose}>
|
||||
<div class="wizard" onClick={(e) => e.stopPropagation()} style="max-width:560px;">
|
||||
<div class="wizard-header">
|
||||
<span>Edit Pipeline — {name}</span>
|
||||
<button class="icon-btn" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div class="wizard-body">
|
||||
{error && <p class="wizard-error">{error}</p>}
|
||||
{loading ? (
|
||||
<p class="hint">Loading…</p>
|
||||
) : (
|
||||
<div>
|
||||
<div class="wizard-section-title">Pipeline nodes</div>
|
||||
{pipeline.length === 0 && (
|
||||
<p class="hint" style="padding:0.5rem 0;">No processing nodes — input passes through unchanged.</p>
|
||||
)}
|
||||
{pipeline.map((node, idx) => {
|
||||
const paramDefs = nodeParamDefs(node.type);
|
||||
return (
|
||||
<div key={idx} class="synth-pipeline-node">
|
||||
<div class="synth-node-header">
|
||||
<span class="synth-node-index">{idx + 1}</span>
|
||||
<span class="synth-node-label">{nodeLabel(node.type)}</span>
|
||||
<div class="synth-node-actions">
|
||||
<button class="icon-btn" title="Move up" disabled={idx === 0} onClick={() => moveNode(idx, -1)}>↑</button>
|
||||
<button class="icon-btn" title="Move down" disabled={idx === pipeline.length - 1} onClick={() => moveNode(idx, 1)}>↓</button>
|
||||
<button class="icon-btn" title="Remove node" onClick={() => removeNode(idx)}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
{paramDefs.map(pd => (
|
||||
<div key={pd.key} class="wizard-field wizard-field-inline">
|
||||
<label>{pd.label}</label>
|
||||
<input
|
||||
class="prop-input"
|
||||
type={pd.type === 'number' ? 'number' : 'text'}
|
||||
value={node.params[pd.key] ?? pd.default}
|
||||
onInput={(e) => setNodeParam(idx, pd.key, (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div class="synth-add-node-row">
|
||||
<select class="prop-select" value={addType} onChange={(e) => setAddType((e.target as HTMLSelectElement).value)}>
|
||||
{NODE_TYPES.map(n => <option key={n.type} value={n.type}>{n.label}</option>)}
|
||||
</select>
|
||||
<button class="toolbar-btn" onClick={addNode}>+ Add node</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="wizard-footer">
|
||||
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
|
||||
<button class="toolbar-btn toolbar-btn-primary" onClick={handleSave} disabled={saving || loading}>
|
||||
{saving ? 'Saving…' : 'Save Pipeline'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user