Implementing more advanced feature: audit and more
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect, useCallback } from 'preact/hooks';
|
||||
|
||||
// AuditEvent mirrors the JSON returned by GET /api/v1/audit (internal/audit.Event).
|
||||
interface AuditEvent {
|
||||
time: string;
|
||||
actor: string;
|
||||
actorType: string; // "user" | "system"
|
||||
action: string;
|
||||
ds?: string;
|
||||
signal?: string;
|
||||
value?: string;
|
||||
detail?: string;
|
||||
ip?: string;
|
||||
outcome: string; // "ok" | "error"
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface Props { onClose: () => void; }
|
||||
|
||||
/** Convert an RFC3339/ISO timestamp to a compact local datetime string. */
|
||||
function fmtTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
/** Convert a datetime-local input value (local time) to an RFC3339 string. */
|
||||
function localToRFC3339(v: string): string {
|
||||
if (!v) return '';
|
||||
const d = new Date(v);
|
||||
return isNaN(d.getTime()) ? '' : d.toISOString();
|
||||
}
|
||||
|
||||
export default function AuditViewer({ onClose }: Props) {
|
||||
const [events, setEvents] = useState<AuditEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [user, setUser] = useState('');
|
||||
const [action, setAction] = useState('');
|
||||
const [signal, setSignal] = useState('');
|
||||
const [start, setStart] = useState('');
|
||||
const [end, setEnd] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const q = new URLSearchParams();
|
||||
if (user.trim()) q.set('user', user.trim());
|
||||
if (action) q.set('action', action);
|
||||
if (signal.trim()) q.set('signal', signal.trim());
|
||||
const s = localToRFC3339(start);
|
||||
const e = localToRFC3339(end);
|
||||
if (s) q.set('start', s);
|
||||
if (e) q.set('end', e);
|
||||
const res = await fetch(`/api/v1/audit?${q.toString()}`);
|
||||
if (res.status === 403) throw new Error('You are not permitted to view the audit log.');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
setEvents(Array.isArray(data) ? data : []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setEvents([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user, action, signal, start, end]);
|
||||
|
||||
// Initial load.
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
function resetFilters() {
|
||||
setUser(''); setAction(''); setSignal(''); setStart(''); setEnd('');
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div class="cl-modal audit-modal">
|
||||
<header class="cl-header">
|
||||
<span class="cl-title">Audit log</span>
|
||||
<span class="hint cl-subtitle">Every system-affecting action (signal writes, control-logic and interface changes).</span>
|
||||
<div class="cl-header-actions">
|
||||
<button class="panel-btn" disabled={loading} onClick={load}>{loading ? 'Loading…' : 'Refresh'}</button>
|
||||
<button class="panel-btn" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="audit-filters">
|
||||
<input class="audit-filter" type="text" placeholder="User" value={user}
|
||||
onInput={(e) => setUser((e.target as HTMLInputElement).value)} />
|
||||
<select class="audit-filter" value={action}
|
||||
onChange={(e) => setAction((e.target as HTMLSelectElement).value)}>
|
||||
<option value="">All actions</option>
|
||||
<option value="signal.write">signal.write</option>
|
||||
<option value="interface.create">interface.create</option>
|
||||
<option value="interface.update">interface.update</option>
|
||||
<option value="interface.delete">interface.delete</option>
|
||||
<option value="controllogic.create">controllogic.create</option>
|
||||
<option value="controllogic.update">controllogic.update</option>
|
||||
<option value="controllogic.delete">controllogic.delete</option>
|
||||
</select>
|
||||
<input class="audit-filter" type="text" placeholder="Signal contains…" value={signal}
|
||||
onInput={(e) => setSignal((e.target as HTMLInputElement).value)} />
|
||||
<label class="audit-filter-label">From
|
||||
<input class="audit-filter" type="datetime-local" value={start}
|
||||
onInput={(e) => setStart((e.target as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<label class="audit-filter-label">To
|
||||
<input class="audit-filter" type="datetime-local" value={end}
|
||||
onInput={(e) => setEnd((e.target as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<button class="panel-btn" disabled={loading} onClick={load}>Apply</button>
|
||||
<button class="panel-btn" onClick={resetFilters}>Reset</button>
|
||||
</div>
|
||||
|
||||
{error && <div class="cl-error">{error}</div>}
|
||||
|
||||
<div class="audit-body">
|
||||
{!error && events.length === 0 && !loading && (
|
||||
<div class="hint audit-empty">No matching audit entries.</div>
|
||||
)}
|
||||
{events.length > 0 && (
|
||||
<table class="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Actor</th>
|
||||
<th>Action</th>
|
||||
<th>Source</th>
|
||||
<th>Signal / target</th>
|
||||
<th>Value</th>
|
||||
<th>Detail</th>
|
||||
<th>Client</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((ev, i) => (
|
||||
<tr key={i} class={ev.outcome === 'error' ? 'audit-row-error' : ''}>
|
||||
<td class="audit-time">{fmtTime(ev.time)}</td>
|
||||
<td>
|
||||
<span class={`audit-actor-tag audit-actor-${ev.actorType}`}>{ev.actorType}</span>
|
||||
{' '}{ev.actor || '—'}
|
||||
</td>
|
||||
<td>{ev.action}</td>
|
||||
<td>{ev.ds || '—'}</td>
|
||||
<td class="audit-signal" title={ev.signal}>{ev.signal || '—'}</td>
|
||||
<td class="audit-value" title={ev.value}>{ev.value ?? '—'}</td>
|
||||
<td class="audit-detail" title={ev.detail}>{ev.detail || '—'}</td>
|
||||
<td>{ev.ip || '—'}</td>
|
||||
<td>
|
||||
{ev.outcome === 'error'
|
||||
? <span class="audit-result-err" title={ev.error}>error</span>
|
||||
: <span class="audit-result-ok">ok</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useRef, useEffect } from 'preact/hooks';
|
||||
|
||||
interface Props {
|
||||
mode: 'view' | 'edit';
|
||||
onOpenManual: (section?: string) => void;
|
||||
}
|
||||
|
||||
const TIPS: Record<string, Array<{ text: string; section?: string }>> = {
|
||||
view: [
|
||||
{ text: 'Click any interface in the left panel to load it on the canvas.', section: 'view' },
|
||||
{ text: 'Right-click a widget to view signal info or export data as CSV.', section: 'view' },
|
||||
{ text: 'The ● Live button shows live streaming is active. Use the date pickers to load historical data.', section: 'history' },
|
||||
{ text: 'Click Edit (top-right) to open the drag-and-drop panel builder.', section: 'edit' },
|
||||
{ text: 'Use the Share button on a panel to grant access to users or groups.', section: 'sharing' },
|
||||
{ text: 'The ⚙ Control logic button opens server-side automation that runs even with no panel open.', section: 'control' },
|
||||
{ text: 'The dot in the status chip turns green when the WebSocket is connected.', section: 'view' },
|
||||
],
|
||||
edit: [
|
||||
{ text: 'Drag a signal from the left tree onto the canvas to create a widget.', section: 'edit' },
|
||||
{ text: 'Click a widget to select it, then adjust its properties on the right.', section: 'edit' },
|
||||
{ text: 'Ctrl+click to select multiple widgets; then use the align toolbar.', section: 'edit' },
|
||||
{ text: 'Ctrl+Z to undo, Ctrl+Y to redo. Up to 50 steps are remembered.', section: 'shortcuts' },
|
||||
{ text: 'Use "+ Synthetic" in the signal tree to create computed/filtered signals.', section: 'signals' },
|
||||
{ text: 'Switch to the Logic tab to add interactive behaviour with a node-graph flow editor.', section: 'logic' },
|
||||
{ text: 'Add Local variables for set-points and counters that live inside the panel.', section: 'edit' },
|
||||
{ text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' },
|
||||
],
|
||||
};
|
||||
|
||||
export default function ContextualHelp({ mode, onOpenManual }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tipIndex, setTipIndex] = useState(0);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const tips = TIPS[mode];
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handler(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
// Cycle to a new random tip each time the popover opens
|
||||
function handleOpen() {
|
||||
setTipIndex(t => (t + 1) % tips.length);
|
||||
setOpen(o => !o);
|
||||
}
|
||||
|
||||
const tip = tips[tipIndex];
|
||||
|
||||
return (
|
||||
<div class="ctx-help" ref={ref}>
|
||||
<button
|
||||
class="ctx-help-btn"
|
||||
onClick={handleOpen}
|
||||
title="Contextual help"
|
||||
aria-label="Open contextual help"
|
||||
aria-expanded={open}
|
||||
>
|
||||
?
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div class="ctx-help-popover" role="tooltip">
|
||||
<div class="ctx-help-tip">
|
||||
<span class="ctx-help-icon">💡</span>
|
||||
<span>{tip.text}</span>
|
||||
</div>
|
||||
|
||||
<div class="ctx-help-footer">
|
||||
<button
|
||||
class="ctx-help-nav"
|
||||
onClick={() => setTipIndex(t => (t + 1) % tips.length)}
|
||||
title="Next tip"
|
||||
>
|
||||
Next tip →
|
||||
</button>
|
||||
<button
|
||||
class="ctx-help-link"
|
||||
onClick={() => { setOpen(false); onOpenManual(tip.section); }}
|
||||
>
|
||||
Open manual ↗
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -139,6 +139,16 @@ export default function ControlLogicEditor({ onClose }: Props) {
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCloseConfirm, setShowCloseConfirm] = useState(false);
|
||||
|
||||
// Closing with unsaved changes prompts instead of silently discarding them.
|
||||
function requestClose() {
|
||||
if (dirty) { setShowCloseConfirm(true); return; }
|
||||
onClose();
|
||||
}
|
||||
async function saveAndClose() {
|
||||
if (await saveGraph()) { setShowCloseConfirm(false); onClose(); }
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
try {
|
||||
@@ -180,8 +190,8 @@ export default function ControlLogicEditor({ onClose }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGraph() {
|
||||
if (!graph) return;
|
||||
async function saveGraph(): Promise<boolean> {
|
||||
if (!graph) return false;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -191,8 +201,10 @@ export default function ControlLogicEditor({ onClose }: Props) {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
setDirty(false);
|
||||
await reload();
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(`Save failed: ${err instanceof Error ? err.message : err}`);
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -240,7 +252,7 @@ export default function ControlLogicEditor({ onClose }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) requestClose(); }}>
|
||||
<div class="cl-modal">
|
||||
<header class="cl-header">
|
||||
<span class="cl-title">Control logic</span>
|
||||
@@ -248,7 +260,7 @@ export default function ControlLogicEditor({ onClose }: Props) {
|
||||
<div class="cl-header-actions">
|
||||
{graph && dirty && <span class="cl-dirty">unsaved</span>}
|
||||
{graph && <button class="panel-btn" disabled={busy || !dirty} onClick={saveGraph}>Save</button>}
|
||||
<button class="panel-btn" onClick={onClose}>Close</button>
|
||||
<button class="panel-btn" onClick={requestClose}>Close</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -299,6 +311,21 @@ export default function ControlLogicEditor({ onClose }: Props) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCloseConfirm && (
|
||||
<div class="cl-confirm-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) setShowCloseConfirm(false); }}>
|
||||
<div class="cl-confirm">
|
||||
<div class="cl-confirm-title">Unsaved changes</div>
|
||||
<p class="hint">This graph has unsaved changes. What would you like to do?</p>
|
||||
<div class="cl-confirm-actions">
|
||||
<button class="panel-btn" onClick={() => setShowCloseConfirm(false)}>Cancel</button>
|
||||
<button class="panel-btn" disabled={busy}
|
||||
onClick={() => { setShowCloseConfirm(false); onClose(); }}>Close without saving</button>
|
||||
<button class="panel-btn panel-btn-primary" disabled={busy} onClick={saveAndClose}>Save and close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -341,12 +368,15 @@ function FlowEditor({ graph, onChange }: {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Bare-name write targets are graph-local variables; offer them under 'local'.
|
||||
// Graph-local write targets (bare name, or an explicit "local:" prefix) become
|
||||
// the suggestions offered when the target data source is 'local'.
|
||||
const localNames = Array.from(new Set(
|
||||
nodes.filter(n => n.kind === 'action.write')
|
||||
.map(n => n.params.target ?? '')
|
||||
.map(t => t.startsWith('local:') ? t.slice('local:'.length) : t)
|
||||
.filter(t => t && !t.includes(':'))
|
||||
));
|
||||
// 'srv' is a registered data source, so it already appears in dataSources.
|
||||
const dsOptions = ['local', 'sys', ...dataSources];
|
||||
function signalOptions(ds: string): string[] {
|
||||
if (ds === 'local') return localNames;
|
||||
@@ -702,16 +732,30 @@ function FlowEditor({ graph, onChange }: {
|
||||
onSelect={(newDs) => { loadSignals(newDs); patchParams(selected.id, { target: `${newDs}:` }); }} />
|
||||
</div>
|
||||
<div class="wizard-field">
|
||||
<label>Target signal</label>
|
||||
<SearchableSelect value={name} options={signalOptions(ds)}
|
||||
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
|
||||
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
|
||||
<label>Target {ds === 'local' ? 'variable' : ds === 'srv' ? 'server variable' : 'signal'}</label>
|
||||
{(ds === 'local' || ds === 'srv') ? (
|
||||
<Fragment>
|
||||
<input class="prop-input" list={`clvars-${selected.id}`} value={name}
|
||||
placeholder={ds === 'srv' ? 'server variable name (read by panels)' : 'local variable name'}
|
||||
onInput={(e) => patchParams(selected.id, { target: `${ds}:${(e.target as HTMLInputElement).value}` })} />
|
||||
<datalist id={`clvars-${selected.id}`}>
|
||||
{signalOptions(ds).map(o => <option key={o} value={o} />)}
|
||||
</datalist>
|
||||
<p class="hint">{ds === 'srv'
|
||||
? 'Server variables are visible to interface panels (read-only unless the user has control-logic access).'
|
||||
: 'Local variables live only inside this graph and reset on reload.'}</p>
|
||||
</Fragment>
|
||||
) : (
|
||||
<SearchableSelect value={name} options={signalOptions(ds)}
|
||||
onSelect={(sig) => patchParams(selected.id, { target: `${ds}:${sig}` })}
|
||||
placeholder={ds ? 'Search signals…' : 'Select data source first'} />
|
||||
)}
|
||||
</div>
|
||||
<ExprField label="Value (expression)" value={selected.params.expr ?? ''}
|
||||
onChange={(v) => patchParams(selected.id, { expr: v })}
|
||||
onInsert={(ds2, sig) => insertRef(selected.id, 'expr', ds2, sig)}
|
||||
dsOptions={dsOptions} signalOptions={signalOptions} loadSignals={loadSignals}
|
||||
hint="Write to a data source signal, or a bare name to store a graph-local var. e.g. ({stub:a} - {stub:b}) / {sys:dt}." />
|
||||
hint="Write to a data source signal, a bare local var, or srv:name for a server variable panels can read. e.g. ({stub:a} - {stub:b}) / {sys:dt}." />
|
||||
</Fragment>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -8,7 +8,6 @@ import EditCanvas, { genWidgetId, DEFAULT_SIZES } from './EditCanvas';
|
||||
import PlotPanelCanvas from './PlotPanelCanvas';
|
||||
import LogicEditor from './LogicEditor';
|
||||
import PropertiesPane from './PropertiesPane';
|
||||
import ContextualHelp from './ContextualHelp';
|
||||
import HelpModal from './HelpModal';
|
||||
import ZoomControl from './ZoomControl';
|
||||
import { useAuth, canWrite } from './lib/auth';
|
||||
@@ -626,7 +625,6 @@ export default function EditMode({ initial, onDone }: Props) {
|
||||
|
||||
<div class="toolbar-right">
|
||||
<ZoomControl />
|
||||
<ContextualHelp mode="edit" onOpenManual={openHelp} />
|
||||
<button class="icon-btn help-manual-btn" onClick={() => openHelp('edit')} title="Open user manual">📖</button>
|
||||
<button class="toolbar-btn" onClick={handleImport}>Import</button>
|
||||
<button class="toolbar-btn" onClick={handleExport}>Export</button>
|
||||
|
||||
+107
-5
@@ -403,6 +403,7 @@ function DiagramSignalTree() {
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: 'start', label: 'Getting Started' },
|
||||
{ id: 'tips', label: 'Quick Tips' },
|
||||
{ id: 'view', label: 'View Mode' },
|
||||
{ id: 'edit', label: 'Edit Mode' },
|
||||
{ id: 'widgets', label: 'Widgets' },
|
||||
@@ -411,10 +412,68 @@ const SECTIONS = [
|
||||
{ id: 'logic', label: 'Panel Logic' },
|
||||
{ id: 'control', label: 'Control Logic' },
|
||||
{ id: 'sharing', label: 'Sharing & Access' },
|
||||
{ id: 'audit', label: 'Audit Log' },
|
||||
{ id: 'history', label: 'Historical Data' },
|
||||
{ id: 'shortcuts', label: 'Keyboard Shortcuts' },
|
||||
];
|
||||
|
||||
// Quick tips, grouped by the mode they apply to. Each links to the manual
|
||||
// section that covers it in depth. (Previously surfaced via a floating
|
||||
// contextual-help popover; now consolidated here in the manual.)
|
||||
const TIPS: Record<'view' | 'edit', Array<{ text: string; section: string }>> = {
|
||||
view: [
|
||||
{ text: 'Click any interface in the left panel to load it on the canvas.', section: 'view' },
|
||||
{ text: 'Right-click a widget to view signal info or export data as CSV.', section: 'view' },
|
||||
{ text: 'The ● Live button shows live streaming is active. Use the date pickers to load historical data.', section: 'history' },
|
||||
{ text: 'Click Edit (top-right) to open the drag-and-drop panel builder.', section: 'edit' },
|
||||
{ text: 'Use the Share button on a panel to grant access to users or groups.', section: 'sharing' },
|
||||
{ text: 'The ⚙ Control logic button opens server-side automation that runs even with no panel open.', section: 'control' },
|
||||
{ text: 'The 🛡 Audit button (for permitted users) opens the log of system-affecting actions.', section: 'audit' },
|
||||
{ text: 'The status chip turns green and shows your username when the WebSocket is connected.', section: 'view' },
|
||||
],
|
||||
edit: [
|
||||
{ text: 'Drag a signal from the left tree onto the canvas to create a widget.', section: 'edit' },
|
||||
{ text: 'Click a widget to select it, then adjust its properties on the right.', section: 'edit' },
|
||||
{ text: 'Ctrl+click to select multiple widgets; then use the align toolbar.', section: 'edit' },
|
||||
{ text: 'Ctrl+Z to undo, Ctrl+Y to redo. Up to 50 steps are remembered.', section: 'shortcuts' },
|
||||
{ text: 'Use "+ Synthetic" in the signal tree to create computed/filtered signals.', section: 'signals' },
|
||||
{ text: 'Switch to the Logic tab to add interactive behaviour with a node-graph flow editor.', section: 'logic' },
|
||||
{ text: 'Add Local variables for set-points and counters that live inside the panel.', section: 'edit' },
|
||||
{ text: 'Arrow keys nudge the selected widget by 1 px (Shift for grid size).', section: 'shortcuts' },
|
||||
],
|
||||
};
|
||||
|
||||
function SectionTips({ onNavigate }: { onNavigate: (section: string) => void }) {
|
||||
const groups: Array<['view' | 'edit', string]> = [
|
||||
['view', 'In View mode'],
|
||||
['edit', 'In Edit mode'],
|
||||
];
|
||||
return (
|
||||
<div>
|
||||
<p class="help-lead">
|
||||
A handful of quick pointers to get the most out of uopi. Click any tip to jump to the
|
||||
section that explains it in detail.
|
||||
</p>
|
||||
{groups.map(([mode, title]) => (
|
||||
<div key={mode}>
|
||||
<h3 class="help-h3">{title}</h3>
|
||||
<ul class="help-tips-list">
|
||||
{TIPS[mode].map(tip => (
|
||||
<li key={tip.text}>
|
||||
<button class="help-tip-item" onClick={() => onNavigate(tip.section)}>
|
||||
<span class="help-tip-icon">💡</span>
|
||||
<span class="help-tip-text">{tip.text}</span>
|
||||
<span class="help-tip-go">↗</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionStart() {
|
||||
return (
|
||||
<div>
|
||||
@@ -481,8 +540,10 @@ function SectionView() {
|
||||
|
||||
<h3 class="help-h3">Connection status</h3>
|
||||
<p>
|
||||
The coloured chip in the top-right shows the WebSocket connection state.
|
||||
A red <em>"disconnected"</em> banner appears at the top while reconnecting — live values will resume automatically.
|
||||
The coloured chip in the top-right shows the WebSocket connection state. When connected it
|
||||
reads <em>"connected as <your username>"</em>, so you can confirm the identity your
|
||||
actions are attributed to in the audit log. A red <em>"disconnected"</em> banner appears at
|
||||
the top while reconnecting — live values will resume automatically.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -677,6 +738,44 @@ function SectionSharing() {
|
||||
);
|
||||
}
|
||||
|
||||
function SectionAudit() {
|
||||
return (
|
||||
<div>
|
||||
<p class="help-lead">
|
||||
When the server is configured with <code>[audit] enabled = true</code>, every
|
||||
<strong> system-affecting action</strong> is appended to an append-only log so it can be
|
||||
reviewed later. Permitted users open it with the <strong>🛡 Audit</strong> button in the
|
||||
view-mode toolbar.
|
||||
</p>
|
||||
|
||||
<h3 class="help-h3">What gets recorded</h3>
|
||||
<ul class="help-list">
|
||||
<li><strong>Signal writes</strong> — both user-initiated (set-value widgets, buttons, panel
|
||||
logic) and automated writes from the control-logic engine.</li>
|
||||
<li><strong>Interface changes</strong> — panels created, updated, or deleted.</li>
|
||||
<li><strong>Control-logic changes</strong> — graphs created, updated, or deleted.</li>
|
||||
</ul>
|
||||
<p>Each entry records the time, the actor (username, or the graph name for automated
|
||||
actions), the action, the affected data source / signal and value, the client address, and
|
||||
whether it succeeded.</p>
|
||||
|
||||
<h3 class="help-h3">Viewing the log</h3>
|
||||
<ul class="help-list">
|
||||
<li>The viewer lists the most recent events first.</li>
|
||||
<li>Filter by user, action, signal, or a start/end time range.</li>
|
||||
<li>System (automated) actions are tagged distinctly from user actions.</li>
|
||||
</ul>
|
||||
|
||||
<p class="help-callout">
|
||||
Access to the audit log is gated by an allowlist (<code>audit.readers</code> in the config),
|
||||
following the same model as logic editing: an empty list means every user may view it,
|
||||
otherwise only the listed users or groups can. The 🛡 Audit button is hidden for users who
|
||||
are not permitted.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionWidgets() {
|
||||
return (
|
||||
<div>
|
||||
@@ -858,8 +957,9 @@ function SectionShortcuts() {
|
||||
);
|
||||
}
|
||||
|
||||
const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
|
||||
const SECTION_COMPONENTS: Record<string, (props: { onNavigate: (section: string) => void }) => JSX.Element> = {
|
||||
start: SectionStart,
|
||||
tips: SectionTips,
|
||||
view: SectionView,
|
||||
edit: SectionEdit,
|
||||
widgets: SectionWidgets,
|
||||
@@ -868,6 +968,7 @@ const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
|
||||
logic: SectionLogic,
|
||||
control: SectionControl,
|
||||
sharing: SectionSharing,
|
||||
audit: SectionAudit,
|
||||
history: SectionHistory,
|
||||
shortcuts: SectionShortcuts,
|
||||
};
|
||||
@@ -877,7 +978,8 @@ const SECTION_COMPONENTS: Record<string, () => JSX.Element> = {
|
||||
export default function HelpModal({ initialSection = 'start', onClose }: Props) {
|
||||
const [active, setActive] = useState(initialSection);
|
||||
|
||||
const Content = SECTION_COMPONENTS[active] ?? SectionStart;
|
||||
const Content: (props: { onNavigate: (section: string) => void }) => JSX.Element =
|
||||
SECTION_COMPONENTS[active] ?? SectionStart;
|
||||
|
||||
// Close on backdrop click
|
||||
function handleBackdrop(e: MouseEvent) {
|
||||
@@ -915,7 +1017,7 @@ export default function HelpModal({ initialSection = 'start', onClose }: Props)
|
||||
{/* Content area */}
|
||||
<div class="help-content">
|
||||
<h2 class="help-h2">{SECTIONS.find(s => s.id === active)?.label}</h2>
|
||||
<Content />
|
||||
<Content onNavigate={setActive} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { h, Fragment } from 'preact';
|
||||
import { useState, useEffect, useRef, useMemo } from 'preact/hooks';
|
||||
import type { SignalRef, StateVar } from './lib/types';
|
||||
import SyntheticWizard from './SyntheticWizard';
|
||||
import SyntheticGraphEditor from './SyntheticGraphEditor';
|
||||
import GroupsTree from './GroupsTree';
|
||||
import ChannelFinderModal from './ChannelFinderModal';
|
||||
@@ -491,10 +490,11 @@ export default function SignalTree({ onDragStart, width, panelId, statevars, onS
|
||||
)}
|
||||
|
||||
{showWizard && (
|
||||
<SyntheticWizard
|
||||
currentIfaceId={panelId}
|
||||
<SyntheticGraphEditor
|
||||
create
|
||||
panelId={panelId}
|
||||
onClose={() => setShowWizard(false)}
|
||||
onCreated={loadAllSignals}
|
||||
onSaved={loadAllSignals}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import LuaEditor from './LuaEditor';
|
||||
import SearchableSelect from './SearchableSelect';
|
||||
import type { InputRef, SignalDef } from './lib/types';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
// Interface id the wizard was opened from — used to bind panel-scoped signals.
|
||||
currentIfaceId?: string;
|
||||
}
|
||||
|
||||
interface NodeParam {
|
||||
label: string;
|
||||
key: string;
|
||||
type: 'number' | 'text' | 'lua';
|
||||
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: 'lowpass', label: 'Low-pass Filter', params: [
|
||||
{ label: 'Cutoff freq (Hz)', key: 'freq', type: 'number', default: '1' },
|
||||
{ label: 'Order (1–8)', key: 'order', type: 'number', default: '1' },
|
||||
]},
|
||||
{ type: 'derivative', label: 'Derivative', params: [] },
|
||||
{ type: 'integrate', label: 'Integrate', 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,b,c,d = inputs)', key: 'expr', type: 'text', default: 'a * 1.0' }] },
|
||||
{ type: 'lua', label: 'Lua Script', params: [{ label: 'Script (a,b,c,d = inputs)', key: 'script', type: 'lua', default: 'return a' }] },
|
||||
];
|
||||
|
||||
interface DataSource {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface SignalInfo {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ── Main Wizard ──────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SyntheticWizard({ onClose, onCreated, currentIfaceId }: Props) {
|
||||
const [name, setName] = useState('');
|
||||
const [inputs, setInputs] = useState<InputRef[]>([{ ds: 'stub', signal: 'sine_1hz' }]);
|
||||
const [nodeType, setNodeType] = useState('gain');
|
||||
const [params, setParams] = useState<Record<string, string>>({});
|
||||
// Visibility scope. Panel scope requires an interface to bind to, so when the
|
||||
// wizard is opened outside a saved panel it falls back to 'user'.
|
||||
const [visibility, setVisibility] = useState<'panel' | 'user' | 'global'>(currentIfaceId ? 'panel' : 'user');
|
||||
const [unit, setUnit] = useState('');
|
||||
const [desc, setDesc] = useState('');
|
||||
const [dispLow, setDispLow] = useState('0');
|
||||
const [dispHigh, setDispHigh] = useState('100');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Loaded data
|
||||
const [dataSources, setDataSources] = useState<string[]>([]);
|
||||
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/v1/datasources')
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.then((ds: DataSource[]) => setDataSources(ds.map(d => d.name)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function loadSignals(ds: string) {
|
||||
if (dsSignals[ds]) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/signals?ds=${encodeURIComponent(ds)}`);
|
||||
if (!res.ok) return;
|
||||
const sigs: SignalInfo[] = await res.json();
|
||||
setDsSignals(prev => ({ ...prev, [ds]: sigs.map(s => s.name) }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const nodeDef = NODE_TYPES.find(n => n.type === nodeType)!;
|
||||
|
||||
function getParam(key: string, def: string): string {
|
||||
return params[key] ?? def;
|
||||
}
|
||||
|
||||
function setParam(key: string, val: string) {
|
||||
setParams(p => ({ ...p, [key]: val }));
|
||||
}
|
||||
|
||||
function updateInput(idx: number, patch: Partial<InputRef>) {
|
||||
setInputs(prev => prev.map((inp, i) => {
|
||||
if (i !== idx) return inp;
|
||||
const next = { ...inp, ...patch };
|
||||
if (patch.ds) {
|
||||
next.signal = '';
|
||||
loadSignals(patch.ds);
|
||||
}
|
||||
return next;
|
||||
}));
|
||||
}
|
||||
|
||||
function addInput() {
|
||||
setInputs(prev => [...prev, { ds: dataSources[0] || '', signal: '' }]);
|
||||
}
|
||||
|
||||
function removeInput(idx: number) {
|
||||
setInputs(prev => prev.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!name.trim()) { setError('Name is required'); return; }
|
||||
if (inputs.some(i => !i.ds || !i.signal)) { setError('All inputs must have a data source and signal'); return; }
|
||||
|
||||
const nodeParams: Record<string, any> = {};
|
||||
for (const p of nodeDef.params) {
|
||||
const raw = getParam(p.key, p.default);
|
||||
nodeParams[p.key] = p.type === 'number' ? parseFloat(raw) : raw;
|
||||
}
|
||||
|
||||
const def: SignalDef = {
|
||||
name: name.trim(),
|
||||
inputs,
|
||||
pipeline: [{ type: nodeType, params: nodeParams }],
|
||||
meta: {
|
||||
unit: unit || undefined,
|
||||
description: desc || undefined,
|
||||
displayLow: parseFloat(dispLow) || 0,
|
||||
displayHigh: parseFloat(dispHigh) || 100,
|
||||
},
|
||||
visibility,
|
||||
...(visibility === 'panel' && currentIfaceId ? { panel: currentIfaceId } : {}),
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/v1/synthetic', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(def),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({ error: res.statusText }));
|
||||
throw new Error(j.error ?? res.statusText);
|
||||
}
|
||||
onCreated();
|
||||
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()}>
|
||||
<div class="wizard-header">
|
||||
<span>New Synthetic Signal</span>
|
||||
<button class="icon-btn" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div class="wizard-body">
|
||||
{error && <p class="wizard-error">{error}</p>}
|
||||
|
||||
<div class="wizard-section-title">Signal identity</div>
|
||||
<div class="wizard-field">
|
||||
<label>Name</label>
|
||||
<input class="prop-input" value={name}
|
||||
placeholder="e.g. smoothed_pressure"
|
||||
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
|
||||
</div>
|
||||
|
||||
<div class="wizard-field">
|
||||
<label>Visibility</label>
|
||||
<select class="prop-select" value={visibility}
|
||||
onChange={(e) => setVisibility((e.target as HTMLSelectElement).value as any)}>
|
||||
<option value="panel" disabled={!currentIfaceId}>
|
||||
This panel only{currentIfaceId ? '' : ' (save panel first)'}
|
||||
</option>
|
||||
<option value="user">All my panels</option>
|
||||
<option value="global">All panels (global)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="wizard-section-title">Input signals</div>
|
||||
{inputs.map((inp, idx) => (
|
||||
<div key={idx} class="wizard-field wizard-field-row" style="align-items: flex-end; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<div class="wizard-field" style="flex:1;">
|
||||
<label>Data source</label>
|
||||
<SearchableSelect
|
||||
value={inp.ds}
|
||||
options={dataSources}
|
||||
onSelect={(ds) => updateInput(idx, { ds })}
|
||||
/>
|
||||
</div>
|
||||
<div class="wizard-field" style="flex:2;">
|
||||
<label>Signal</label>
|
||||
<SearchableSelect
|
||||
value={inp.signal}
|
||||
options={dsSignals[inp.ds] || []}
|
||||
onSelect={(signal) => updateInput(idx, { signal })}
|
||||
placeholder={inp.ds ? 'Search signals…' : 'Select data source first'}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="icon-btn"
|
||||
style="margin-bottom: 0.25rem;"
|
||||
title="Remove input"
|
||||
onClick={() => removeInput(idx)}
|
||||
disabled={inputs.length === 1}
|
||||
>✕</button>
|
||||
</div>
|
||||
))}
|
||||
<button class="panel-btn" style="margin-top: 0.5rem;" onClick={addInput}>+ Add input</button>
|
||||
|
||||
<div class="wizard-section-title" style="margin-top: 1.5rem;">Processing</div>
|
||||
<div class="wizard-field">
|
||||
<label>Node type</label>
|
||||
<select class="prop-select" value={nodeType}
|
||||
onChange={(e) => { setNodeType((e.target as HTMLSelectElement).value); setParams({}); }}>
|
||||
{NODE_TYPES.map(n => <option key={n.type} value={n.type}>{n.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{nodeDef.params.map(p => (
|
||||
<div key={p.key} class="wizard-field">
|
||||
<label>{p.label}</label>
|
||||
{p.type === 'lua' ? (
|
||||
<LuaEditor
|
||||
value={getParam(p.key, p.default)}
|
||||
onChange={(v) => setParam(p.key, v)}
|
||||
/>
|
||||
) : (
|
||||
<input class="prop-input" type={p.type === 'number' ? 'number' : 'text'}
|
||||
value={getParam(p.key, p.default)}
|
||||
onInput={(e) => setParam(p.key, (e.target as HTMLInputElement).value)} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div class="wizard-section-title">Metadata (optional)</div>
|
||||
<div class="wizard-field wizard-field-row">
|
||||
<div class="wizard-field">
|
||||
<label>Unit</label>
|
||||
<input class="prop-input" value={unit} placeholder="m/s"
|
||||
onInput={(e) => setUnit((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>
|
||||
<div class="wizard-field">
|
||||
<label>Description</label>
|
||||
<input class="prop-input" value={desc} placeholder="Optional description"
|
||||
onInput={(e) => setDesc((e.target as HTMLInputElement).value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wizard-footer">
|
||||
<button class="toolbar-btn" onClick={onClose}>Cancel</button>
|
||||
<button class="toolbar-btn toolbar-btn-primary" onClick={handleCreate} disabled={saving}>
|
||||
{saving ? 'Creating…' : 'Create Signal'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+20
-8
@@ -7,10 +7,10 @@ import { parseInterface } from './lib/xml';
|
||||
import { newPlotPanel } from './lib/templates';
|
||||
import { useAuth, canWrite } from './lib/auth';
|
||||
import type { Interface } from './lib/types';
|
||||
import ContextualHelp from './ContextualHelp';
|
||||
import HelpModal from './HelpModal';
|
||||
import ZoomControl from './ZoomControl';
|
||||
import ControlLogicEditor from './ControlLogicEditor';
|
||||
import AuditViewer from './AuditViewer';
|
||||
|
||||
interface Props {
|
||||
onEdit?: (iface?: Interface) => void;
|
||||
@@ -33,6 +33,7 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
||||
const [helpSection, setHelpSection] = useState('start');
|
||||
const [showTimeNav, setShowTimeNav] = useState(false);
|
||||
const [showControlLogic, setShowControlLogic] = useState(false);
|
||||
const [showAudit, setShowAudit] = useState(false);
|
||||
const [leftW, setLeftW] = useState(220);
|
||||
const [listCollapsed, setListCollapsed] = useState(false);
|
||||
const me = useAuth();
|
||||
@@ -137,9 +138,13 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
||||
)}
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<div class={`status-chip ${wsStatus}`}>
|
||||
<div
|
||||
class={`status-chip ${wsStatus}`}
|
||||
title={me.user ? `Signed in as ${me.user}${writable ? '' : ' (read-only)'}` : undefined}
|
||||
>
|
||||
<span class="status-dot"></span>
|
||||
{wsStatus === 'connected' && me.user ? `connected as ${me.user}` : wsStatus}
|
||||
{me.user && !writable && ' (read-only)'}
|
||||
</div>
|
||||
<button
|
||||
class={`toolbar-btn${showTimeNav ? ' toolbar-btn-active' : ''}`}
|
||||
@@ -148,7 +153,6 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
||||
>
|
||||
⏱ History
|
||||
</button>
|
||||
<ContextualHelp mode="view" onOpenManual={openHelp} />
|
||||
<button
|
||||
class="icon-btn help-manual-btn"
|
||||
onClick={() => openHelp('start')}
|
||||
@@ -166,6 +170,15 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
||||
⚙ Control logic
|
||||
</button>
|
||||
)}
|
||||
{me.canViewAudit && (
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
onClick={() => setShowAudit(true)}
|
||||
title="View the audit log"
|
||||
>
|
||||
🛡 Audit
|
||||
</button>
|
||||
)}
|
||||
{writable && (
|
||||
<button
|
||||
class="btn-edit"
|
||||
@@ -175,11 +188,6 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
{me.user && (
|
||||
<span class="user-chip" title={`Signed in as ${me.user}${writable ? '' : ' (read-only)'}`}>
|
||||
{me.user}{!writable && ' (read-only)'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -257,6 +265,10 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
|
||||
{showControlLogic && (
|
||||
<ControlLogicEditor onClose={() => setShowControlLogic(false)} />
|
||||
)}
|
||||
|
||||
{showAudit && (
|
||||
<AuditViewer onClose={() => setShowAudit(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ import type { Me, AccessLevel } from './types';
|
||||
|
||||
// Default identity used before /api/v1/me resolves (or if it fails): assume a
|
||||
// trusted LAN user with full access, matching the backend default.
|
||||
export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true };
|
||||
export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true, canViewAudit: true };
|
||||
|
||||
// AuthContext carries the resolved identity + global access level for the
|
||||
// current user throughout the app.
|
||||
@@ -37,6 +37,7 @@ export async function fetchMe(): Promise<Me> {
|
||||
level: (data.level as AccessLevel) ?? 'write',
|
||||
groups: Array.isArray(data.groups) ? data.groups : [],
|
||||
canEditLogic: data.canEditLogic !== false,
|
||||
canViewAudit: data.canViewAudit !== false,
|
||||
};
|
||||
} catch {
|
||||
return DEFAULT_ME;
|
||||
|
||||
@@ -212,6 +212,9 @@ export interface Me {
|
||||
// Whether the user may add/edit panel logic and server-side control logic.
|
||||
// False only when a logic-editors allowlist is configured and excludes them.
|
||||
canEditLogic: boolean;
|
||||
// Whether the user may view the audit log. False only when an audit-readers
|
||||
// allowlist is configured and excludes them, or auditing is disabled.
|
||||
canViewAudit: boolean;
|
||||
}
|
||||
|
||||
// Effective permission a user holds on a single panel or folder.
|
||||
|
||||
+183
-81
@@ -123,6 +123,8 @@ body {
|
||||
color: #94a3b8;
|
||||
border: 1px solid #334155;
|
||||
text-transform: capitalize;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-chip.connected {
|
||||
@@ -165,18 +167,6 @@ body {
|
||||
}
|
||||
|
||||
/* ── User identity chip ──────────────────────────────────────────────────── */
|
||||
.user-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 9999px;
|
||||
background: #1e293b;
|
||||
color: #cbd5e1;
|
||||
border: 1px solid #334155;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Access denied screen ────────────────────────────────────────────────── */
|
||||
.access-denied {
|
||||
display: flex;
|
||||
@@ -2746,90 +2736,50 @@ kbd {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* ── Contextual Help ─────────────────────────────────────────────────────── */
|
||||
/* ── Manual: Quick Tips list ─────────────────────────────────────────────── */
|
||||
|
||||
.ctx-help {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ctx-help-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid #4a5568;
|
||||
background: #1a2236;
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
.help-tips-list {
|
||||
list-style: none;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.ctx-help-btn:hover { border-color: #4a9eff; color: #4a9eff; }
|
||||
|
||||
.ctx-help-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 400;
|
||||
.help-tip-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
width: 280px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.ctx-help-tip {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
border-radius: 6px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
color: #cbd5e1;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.6rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.help-tip-item:hover {
|
||||
border-color: #4a9eff;
|
||||
background: #1e2540;
|
||||
}
|
||||
|
||||
.ctx-help-icon {
|
||||
font-size: 1rem;
|
||||
.help-tip-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.05rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.ctx-help-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-top: 1px solid #2d3748;
|
||||
padding-top: 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.help-tip-text { flex: 1; }
|
||||
|
||||
.ctx-help-nav {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.ctx-help-nav:hover { color: #94a3b8; }
|
||||
|
||||
.ctx-help-link {
|
||||
font-size: 0.75rem;
|
||||
.help-tip-go {
|
||||
flex-shrink: 0;
|
||||
color: #4a9eff;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
.ctx-help-link:hover { text-decoration: underline; }
|
||||
|
||||
|
||||
/* ── Historical time-nav bar (below main toolbar, hidden by default) ──────── */
|
||||
@@ -3446,6 +3396,37 @@ kbd {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cl-confirm-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 650;
|
||||
}
|
||||
|
||||
.cl-confirm {
|
||||
background: #1a1f2e;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 10px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
width: min(440px, 92vw);
|
||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.cl-confirm-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.cl-confirm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.cl-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3455,6 +3436,127 @@ kbd {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Audit log viewer ──────────────────────────────────────────────────── */
|
||||
.audit-modal {
|
||||
width: min(1300px, 98vw);
|
||||
}
|
||||
|
||||
.audit-filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1.25rem;
|
||||
border-bottom: 1px solid #2d3748;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.audit-filter {
|
||||
background: #0f1420;
|
||||
border: 1px solid #2d3748;
|
||||
border-radius: 5px;
|
||||
color: #e2e8f0;
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.audit-filter-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.audit-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.audit-empty {
|
||||
padding: 1.5rem 1.25rem;
|
||||
}
|
||||
|
||||
.audit-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.audit-table th,
|
||||
.audit-table td {
|
||||
text-align: left;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-bottom: 1px solid #232b3a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audit-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #161c2a;
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.audit-table tbody tr:hover {
|
||||
background: #1d2433;
|
||||
}
|
||||
|
||||
.audit-row-error td {
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
}
|
||||
|
||||
.audit-time {
|
||||
color: #94a3b8;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.audit-signal,
|
||||
.audit-detail {
|
||||
max-width: 18rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.audit-value {
|
||||
max-width: 10rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.audit-actor-tag {
|
||||
display: inline-block;
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.audit-actor-user {
|
||||
background: #1e3a5f;
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.audit-actor-system {
|
||||
background: #4a2d6b;
|
||||
color: #d8b4fe;
|
||||
}
|
||||
|
||||
.audit-result-ok {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.audit-result-err {
|
||||
color: #f87171;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cl-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
|
||||
Reference in New Issue
Block a user