Implementing more advanced feature: audit and more
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user