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
+169
View File
@@ -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>
);
}
-95
View File
@@ -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>
);
}
+54 -10
View File
@@ -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>
);
})()}
-2
View File
@@ -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
View File
@@ -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 &lt;your username&gt;"</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>
+4 -4
View File
@@ -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}
/>
)}
+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>
-279
View File
@@ -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 (18)', 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
View File
@@ -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
View File
@@ -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;
+3
View File
@@ -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
View File
@@ -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;