Implementing more advanced feature: audit and more

This commit is contained in:
Martino Ferrari
2026-06-19 14:17:46 +02:00
parent 8f6dbcba49
commit 901b87d407
31 changed files with 1669 additions and 569 deletions
+138 -39
View File
@@ -8,7 +8,12 @@ interface DataSource { name: string; }
interface SignalInfo { name: string; }
interface Props {
name: string;
// Existing signal name (edit mode). Omitted/empty in create mode.
name?: string;
// When true the editor starts blank and POSTs a new signal on save.
create?: boolean;
// Interface id used to bind panel-scoped signals when creating.
panelId?: string;
onClose: () => void;
onSaved: () => void;
}
@@ -170,7 +175,7 @@ function nodeSummary(n: GNode): string {
}
}
export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props) {
export default function SyntheticGraphEditor({ name, create, panelId, onClose, onSaved }: Props) {
const [def, setDef] = useState<SignalDef | null>(null);
const [graph, setGraph] = useState<Graph>({ nodes: [], wires: [] });
const [selected, setSelected] = useState<string | null>(null);
@@ -179,6 +184,15 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
// Identity fields — editable only when creating a new signal.
const [newName, setNewName] = useState('');
const [visibility, setVisibility] = useState<'panel' | 'user' | 'global'>(panelId ? 'panel' : 'user');
const [unit, setUnit] = useState('');
const [desc, setDesc] = useState('');
const [dispLow, setDispLow] = useState('0');
const [dispHigh, setDispHigh] = useState('100');
const sigName = create ? newName.trim() : (name ?? '');
const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
@@ -215,17 +229,28 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
}
useEffect(() => {
fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`)
if (create) {
const blank: SignalDef = { name: '', inputs: [], pipeline: [], meta: {} };
setDef(blank);
setGraph(buildInitial(blank));
setLoading(false);
return;
}
fetch(`/api/v1/synthetic/${encodeURIComponent(name ?? '')}`)
.then(r => r.ok ? r.json() : Promise.reject(r.statusText))
.then((d: SignalDef) => {
setDef(d);
setUnit(d.meta?.unit ?? '');
setDesc(d.meta?.description ?? '');
setDispLow(String(d.meta?.displayLow ?? '0'));
setDispHigh(String(d.meta?.displayHigh ?? '100'));
const g = buildInitial(d);
setGraph(g);
g.nodes.forEach(n => { if (n.kind === 'source' && n.ds) loadSignals(n.ds); });
})
.catch(e => setError(String(e)))
.finally(() => setLoading(false));
}, [name]);
}, [name, create]);
// ── History ────────────────────────────────────────────────────────────────
function pushUndo() {
@@ -282,7 +307,8 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
});
}
function moveNode(id: string, x: number, y: number, record: boolean) {
commit({ nodes: graph.nodes.map(n => (n.id === id ? { ...n, x, y } : n)), wires: graph.wires }, record);
const g = graphRef.current;
commit({ nodes: g.nodes.map(n => (n.id === id ? { ...n, x, y } : n)), wires: g.wires }, record);
}
function deleteNode(id: string) {
const n = graph.nodes.find(x => x.id === id);
@@ -314,48 +340,52 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
const p = toCanvas(e);
dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y, pushed: false };
setSelected(node.id); setSelectedWire(null);
window.addEventListener('mousemove', onNodeDragMove);
window.addEventListener('mouseup', onNodeDragUp);
}
function onNodeDragMove(e: MouseEvent) {
const d = dragNode.current;
if (!d) return;
const p = toCanvas(e);
const record = !d.pushed;
d.pushed = true;
moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy), record);
}
function onNodeDragUp() {
dragNode.current = null;
window.removeEventListener('mousemove', onNodeDragMove);
window.removeEventListener('mouseup', onNodeDragUp);
}
function startWire(e: MouseEvent, node: GNode) {
e.stopPropagation();
const p = toCanvas(e);
setPendingWire({ from: node.id, x: p.x, y: p.y });
window.addEventListener('mousemove', onWireMove);
window.addEventListener('mouseup', onWireUp);
}
function onWireMove(e: MouseEvent) {
const cur = pendingRef.current;
if (!cur) return;
const p = toCanvas(e);
setPendingWire({ ...cur, x: p.x, y: p.y });
}
function endWire() {
pendingRef.current = null;
window.removeEventListener('mousemove', onWireMove);
window.removeEventListener('mouseup', onWireUp);
setPendingWire(null);
}
function onWireUp() { endWire(); }
function finishWire(target: GNode) {
const cur = pendingRef.current;
if (cur && hasInput(target.kind)) addWire(cur.from, target.id);
endWire();
}
// Single mount-time pointer handler. Reads live drag/wire state from refs so it
// never goes stale across re-renders — fixes the "can't drop a dragged node" bug.
useEffect(() => {
function onMove(e: MouseEvent) {
const d = dragNode.current;
if (d) {
const p = toCanvas(e);
const record = !d.pushed;
d.pushed = true;
moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy), record);
return;
}
const cur = pendingRef.current;
if (cur) {
const p = toCanvas(e);
setPendingWire({ ...cur, x: p.x, y: p.y });
}
}
function onUp() {
dragNode.current = null;
if (pendingRef.current) endWire();
}
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, []);
// ── Palette drag-and-drop ──────────────────────────────────────────────────
const DRAG_MIME = 'application/x-uopi-synth-op';
function onCanvasDragOver(e: DragEvent) {
@@ -396,16 +426,39 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
async function handleSave() {
if (!def) return;
if (create && !sigName) { setError('Enter a name for the new signal.'); return; }
if (compiled.error) { setError(compiled.error); return; }
setSaving(true);
setError('');
try {
const body = { ...def, inputs: compiled.inputs, pipeline: compiled.pipeline, ds: undefined, signal: undefined };
const res = await fetch(`/api/v1/synthetic/${encodeURIComponent(name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const meta = {
...def.meta,
unit: unit || undefined,
description: desc || undefined,
displayLow: parseFloat(dispLow),
displayHigh: parseFloat(dispHigh),
};
const body: any = {
...def,
name: sigName,
inputs: compiled.inputs,
pipeline: compiled.pipeline,
meta,
ds: undefined,
signal: undefined,
};
if (create) {
body.visibility = visibility;
if (visibility === 'panel' && panelId) body.panel = panelId;
}
const res = await fetch(
create ? '/api/v1/synthetic' : `/api/v1/synthetic/${encodeURIComponent(sigName)}`,
{
method: create ? 'POST' : '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);
@@ -428,7 +481,7 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
<div class="wizard-backdrop" onClick={onClose}>
<div class="synth-graph" onClick={(e) => e.stopPropagation()}>
<div class="wizard-header">
<span>Synthetic Signal {name}</span>
<span>Synthetic Signal{create ? ' — new' : `${name}`}</span>
<button class="icon-btn" onClick={onClose}></button>
</div>
@@ -515,7 +568,53 @@ export default function SyntheticGraphEditor({ name, onClose, onSaved }: Props)
</div>
<div class="flow-inspector">
{!sel && <div class="hint">Select a node to edit it, or add one from the palette.</div>}
{!sel && (
<Fragment>
<div class="wizard-section-title">Signal</div>
{create ? (
<Fragment>
<div class="wizard-field">
<label>Name</label>
<input class="prop-input" type="text" value={newName}
placeholder="my_signal"
onInput={(e) => setNewName((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Visibility</label>
<select class="prop-input" value={visibility}
onChange={(e) => setVisibility((e.target as HTMLSelectElement).value as any)}>
{panelId && <option value="panel">This panel only</option>}
<option value="user">My signals</option>
<option value="global">Global (all users)</option>
</select>
</div>
</Fragment>
) : (
<p class="hint">Editing <b>{name}</b>.</p>
)}
<div class="wizard-field">
<label>Unit</label>
<input class="prop-input" type="text" value={unit}
onInput={(e) => setUnit((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Description</label>
<input class="prop-input" type="text" value={desc}
onInput={(e) => setDesc((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Display low</label>
<input class="prop-input" type="number" value={dispLow}
onInput={(e) => setDispLow((e.target as HTMLInputElement).value)} />
</div>
<div class="wizard-field">
<label>Display high</label>
<input class="prop-input" type="number" value={dispHigh}
onInput={(e) => setDispHigh((e.target as HTMLInputElement).value)} />
</div>
<div class="flow-palette-hint hint">Select a node to edit it, or add one from the palette.</div>
</Fragment>
)}
{sel?.kind === 'source' && (
<Fragment>