Implemented admin pane + user permission

This commit is contained in:
Martino Ferrari
2026-06-22 17:49:14 +02:00
parent 73fcbe7b28
commit ac24011487
67 changed files with 5925 additions and 411 deletions
+487
View File
@@ -0,0 +1,487 @@
import { h } from 'preact';
import { useState, useEffect, useCallback, useMemo } from 'preact/hooks';
// ── Types (mirror internal/access AccessSnapshot + the /admin/stats payload) ──
interface MemberInfo { user: string; role: string; }
interface GroupInfo {
name: string;
parent: string;
members: MemberInfo[];
}
interface UserInfo {
name: string;
effectiveRole: string;
roles: Record<string, string>; // group → role token
}
interface AccessSnapshot {
defaultUser: string;
publicGroup: string;
roles: string[]; // role ladder, low → high
configured: boolean;
users: UserInfo[];
groups: GroupInfo[];
}
interface ServerStats {
uptimeSeconds: number;
wsConnections: number;
observedSignals: number;
msgIn: number;
msgOut: number;
writeOps: number;
historyReqs: number;
goroutines: number;
memAllocBytes: number;
memSysBytes: number;
heapInuseBytes: number;
loadAvg: number[] | null;
dataSources: string[];
}
interface Props { onClose: () => void; }
const msg = (err: unknown): string => (err instanceof Error ? err.message : String(err));
async function apiJSON<T = any>(url: string, opts?: RequestInit): Promise<T | null> {
const res = await fetch(url, opts);
if (!res.ok && res.status !== 204) {
let m = `HTTP ${res.status}`;
try { const e = await res.json(); if (e && e.error) m = e.error; } catch { /* ignore */ }
throw new Error(m);
}
if (res.status === 204) return null;
return res.json();
}
const jsonPut = (body: any): RequestInit => ({ method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const jsonPost = (body: any): RequestInit => ({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const ROLE_LABELS: Record<string, string> = {
viewer: 'Viewer',
operator: 'Operator',
logiceditor: 'Logic editor',
auditor: 'Auditor',
admin: 'Admin',
};
const roleLabel = (r: string): string => ROLE_LABELS[r] ?? r;
/** Order group names so children follow their parent, and compute nesting depth. */
function orderGroups(groups: GroupInfo[]): { group: GroupInfo; depth: number }[] {
const byParent = new Map<string, GroupInfo[]>();
for (const g of groups) {
const key = g.parent || '';
(byParent.get(key) ?? byParent.set(key, []).get(key)!).push(g);
}
for (const list of byParent.values()) list.sort((a, b) => a.name.localeCompare(b.name));
const out: { group: GroupInfo; depth: number }[] = [];
const names = new Set(groups.map(g => g.name));
const walk = (parent: string, depth: number) => {
for (const g of byParent.get(parent) ?? []) {
out.push({ group: g, depth });
walk(g.name, depth + 1);
}
};
walk('', 0);
// Orphans (parent missing) rendered at root.
for (const g of groups) {
if (g.parent && !names.has(g.parent) && !out.some(o => o.group.name === g.name)) {
out.push({ group: g, depth: 0 });
}
}
return out;
}
export default function AdminPane({ onClose }: Props) {
const [tab, setTab] = useState<'users' | 'groups' | 'stats'>('users');
const [snap, setSnap] = useState<AccessSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setError(null);
try {
setSnap(await apiJSON<AccessSnapshot>('/api/v1/admin/access'));
} catch (err) { setError(msg(err)); }
}, []);
useEffect(() => { load(); }, [load]);
return (
<div class="cl-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="cl-modal cl-modal-full">
<header class="cl-header">
<span class="cl-title">Admin</span>
<span class="hint cl-subtitle">Manage role-based access through group memberships, and view live server statistics.</span>
<div class="cl-header-actions">
<button class="panel-btn" onClick={onClose}>Close</button>
</div>
</header>
<div class="cfg-tabs">
<button class={`cfg-tab${tab === 'users' ? ' cfg-tab-active' : ''}`} onClick={() => setTab('users')}>Users</button>
<button class={`cfg-tab${tab === 'groups' ? ' cfg-tab-active' : ''}`} onClick={() => setTab('groups')}>Groups</button>
<button class={`cfg-tab${tab === 'stats' ? ' cfg-tab-active' : ''}`} onClick={() => setTab('stats')}>Server stats</button>
</div>
{error && <div class="cl-error">{error}</div>}
{snap && !snap.configured && tab !== 'stats' && (
<div class="hint admin-banner">
No roles assigned yet access is fully open (everyone is admin). Assigning any role
switches to strict mode where unlisted users are read-only viewers.
</div>
)}
{tab === 'users' && snap && <UsersManager snap={snap} onSnap={setSnap} onError={setError} />}
{tab === 'groups' && snap && <GroupsManager snap={snap} onSnap={setSnap} onError={setError} />}
{tab === 'stats' && <StatsView onError={setError} />}
</div>
</div>
);
}
interface ManagerProps {
snap: AccessSnapshot;
onSnap: (s: AccessSnapshot) => void;
onError: (e: string | null) => void;
}
// ── Users ─────────────────────────────────────────────────────────────────────
function UsersManager({ snap, onSnap, onError }: ManagerProps) {
const [selected, setSelected] = useState<string | null>(null);
// Draft maps group → role token for the selected user (only groups with a role).
const [draft, setDraft] = useState<Record<string, string> | null>(null);
const [newName, setNewName] = useState('');
const [busy, setBusy] = useState(false);
const ordered = useMemo(() => orderGroups(snap.groups), [snap.groups]);
function select(name: string) {
const u = snap.users.find(x => x.name === name);
setSelected(name);
setDraft({ ...(u?.roles ?? {}) });
}
function addUser() {
const n = newName.trim();
if (!n) return;
setNewName('');
select(n);
}
const setGroupRole = (group: string, role: string) => setDraft(d => {
const next = { ...(d ?? {}) };
if (role === '') delete next[group];
else next[group] = role;
return next;
});
async function save() {
if (!draft || !selected) return;
setBusy(true);
onError(null);
try {
const updated = await apiJSON<AccessSnapshot>(
`/api/v1/admin/users/${encodeURIComponent(selected)}`, jsonPut({ roles: draft }));
if (updated) onSnap(updated);
} catch (err) { onError(msg(err)); }
finally { setBusy(false); }
}
const effective = selected ? snap.users.find(u => u.name === selected)?.effectiveRole : undefined;
return (
<div class="cl-body">
<div class="cl-list">
<div class="cl-list-head">
<input class="audit-filter" type="text" placeholder="Add user…" value={newName}
onInput={(e) => setNewName((e.target as HTMLInputElement).value)}
onKeyDown={(e) => { if (e.key === 'Enter') addUser(); }} />
<button class="panel-btn" onClick={addUser}>Add</button>
</div>
{snap.users.length === 0 && <div class="hint cl-list-empty">No users with explicit roles. Everyone is a viewer of the public group.</div>}
{snap.users.map(u => (
<div key={u.name} class={`cl-list-item${selected === u.name ? ' cl-list-item-active' : ''}`} onClick={() => select(u.name)}>
<span class="cl-list-name" title={u.name}>{u.name}</span>
<span class={`admin-role-tag admin-role-${u.effectiveRole}`}>{roleLabel(u.effectiveRole)}</span>
</div>
))}
</div>
<div class="cl-editor-wrap">
{!draft && <div class="hint admin-empty">Select a user, or add one, to assign roles per group.</div>}
{draft && (
<div class="admin-editor">
<h3 class="admin-editor-title">
{selected}
{effective && <span class={`admin-role-tag admin-role-${effective}`}>{roleLabel(effective)}</span>}
</h3>
<p class="hint">Effective access is the highest role across all groups. Every user is at least a viewer via the public group.</p>
<label class="admin-field-label">Role per group</label>
<div class="admin-role-table">
{ordered.map(({ group, depth }) => (
<div key={group.name} class="admin-role-row">
<span class="admin-role-group" style={{ paddingLeft: `${depth * 1.1}rem` }}>
{depth > 0 && <span class="admin-tree-mark"> </span>}
{group.name}
{group.name === snap.publicGroup && <span class="hint"> (everyone)</span>}
</span>
<select class="prop-select admin-role-select"
value={draft[group.name] ?? ''}
onChange={(e) => setGroupRole(group.name, (e.target as HTMLSelectElement).value)}>
<option value=""> none </option>
{snap.roles.map(r => <option key={r} value={r}>{roleLabel(r)}</option>)}
</select>
</div>
))}
</div>
<div class="admin-editor-actions">
<button class="panel-btn" disabled={busy} onClick={save}>{busy ? 'Saving…' : 'Save'}</button>
</div>
</div>
)}
</div>
</div>
);
}
// ── Groups ──────────────────────────────────────────────────────────────────
function descendantsOf(groups: GroupInfo[], name: string): Set<string> {
const out = new Set<string>();
const walk = (parent: string) => {
for (const g of groups) {
if (g.parent === parent && !out.has(g.name)) {
out.add(g.name);
walk(g.name);
}
}
};
walk(name);
return out;
}
function GroupsManager({ snap, onSnap, onError }: ManagerProps) {
const [selected, setSelected] = useState<string | null>(null);
const [name, setName] = useState('');
const [parent, setParent] = useState('');
const [members, setMembers] = useState<MemberInfo[]>([]);
const [newMember, setNewMember] = useState('');
const [newGroup, setNewGroup] = useState('');
const [busy, setBusy] = useState(false);
const ordered = useMemo(() => orderGroups(snap.groups), [snap.groups]);
const isPublic = selected === snap.publicGroup;
function select(gname: string) {
const g = snap.groups.find(x => x.name === gname);
if (!g) return;
setSelected(gname);
setName(g.name);
setParent(g.parent);
setMembers(g.members.map(m => ({ ...m })));
setNewMember('');
}
// Parent options exclude the group itself and its descendants (cycle guard) and public.
const parentOptions = useMemo(() => {
if (!selected) return [];
const blocked = descendantsOf(snap.groups, selected);
blocked.add(selected);
return snap.groups.filter(g => !blocked.has(g.name)).map(g => g.name);
}, [snap.groups, selected]);
async function create() {
const n = newGroup.trim();
if (!n) return;
setBusy(true);
onError(null);
try {
const updated = await apiJSON<AccessSnapshot>('/api/v1/admin/groups', jsonPost({ name: n }));
if (updated) onSnap(updated);
setNewGroup('');
} catch (err) { onError(msg(err)); }
finally { setBusy(false); }
}
function addMember() {
const u = newMember.trim();
if (!u || members.some(m => m.user === u)) { setNewMember(''); return; }
setMembers(ms => [...ms, { user: u, role: 'operator' }]);
setNewMember('');
}
const setMemberRole = (user: string, role: string) =>
setMembers(ms => ms.map(m => m.user === user ? { ...m, role } : m));
const removeMember = (user: string) => setMembers(ms => ms.filter(m => m.user !== user));
async function save() {
if (!selected) return;
setBusy(true);
onError(null);
try {
const memberMap: Record<string, string> = {};
for (const m of members) memberMap[m.user] = m.role;
const body = { name: name.trim() || selected, parent: isPublic ? '' : parent, members: memberMap };
const updated = await apiJSON<AccessSnapshot>(
`/api/v1/admin/groups/${encodeURIComponent(selected)}`, jsonPut(body));
if (updated) { onSnap(updated); setSelected(body.name); }
} catch (err) { onError(msg(err)); }
finally { setBusy(false); }
}
async function remove() {
if (!selected || isPublic || !confirm(`Delete group "${selected}"? Members keep their other groups.`)) return;
setBusy(true);
onError(null);
try {
const updated = await apiJSON<AccessSnapshot>(
`/api/v1/admin/groups/${encodeURIComponent(selected)}`, { method: 'DELETE' });
if (updated) onSnap(updated);
setSelected(null);
} catch (err) { onError(msg(err)); }
finally { setBusy(false); }
}
return (
<div class="cl-body">
<div class="cl-list">
<div class="cl-list-head">
<input class="audit-filter" type="text" placeholder="New group…" value={newGroup}
onInput={(e) => setNewGroup((e.target as HTMLInputElement).value)}
onKeyDown={(e) => { if (e.key === 'Enter') create(); }} />
<button class="panel-btn" disabled={busy} onClick={create}>Create</button>
</div>
{ordered.map(({ group, depth }) => (
<div key={group.name} class={`cl-list-item${selected === group.name ? ' cl-list-item-active' : ''}`} onClick={() => select(group.name)}>
<span class="cl-list-name" title={group.name} style={{ paddingLeft: `${depth * 0.9}rem` }}>
{depth > 0 && <span class="admin-tree-mark"> </span>}
{group.name}
</span>
<span class="hint">{group.members.length}</span>
</div>
))}
</div>
<div class="cl-editor-wrap">
{!selected && <div class="hint admin-empty">Select a group, or create one, to manage its parent and member roles.</div>}
{selected && (
<div class="admin-editor">
<label class="admin-field-label">Name</label>
<input class="admin-input" type="text" value={name} disabled={isPublic}
onInput={(e) => setName((e.target as HTMLInputElement).value)} />
{isPublic && <p class="hint">The built-in public group cannot be renamed, reparented or deleted; every user is an implicit viewer member.</p>}
<label class="admin-field-label">Parent group (nesting)</label>
<select class="prop-select admin-input" value={parent} disabled={isPublic}
onChange={(e) => setParent((e.target as HTMLSelectElement).value)}>
<option value=""> none (top level) </option>
{parentOptions.map(p => <option key={p} value={p}>{p}</option>)}
</select>
{!isPublic && <p class="hint">Members of a parent group inherit their role on this group too, unless overridden here.</p>}
<label class="admin-field-label">Members</label>
<div class="admin-member-add">
<input class="admin-input" type="text" placeholder="Add member…" value={newMember}
onInput={(e) => setNewMember((e.target as HTMLInputElement).value)}
onKeyDown={(e) => { if (e.key === 'Enter') addMember(); }} />
<button class="panel-btn" onClick={addMember}>Add</button>
</div>
{members.length === 0 && <div class="hint">No explicit members.</div>}
<div class="admin-role-table">
{members.map(m => (
<div key={m.user} class="admin-role-row">
<span class="admin-role-group">{m.user}</span>
<select class="prop-select admin-role-select" value={m.role}
onChange={(e) => setMemberRole(m.user, (e.target as HTMLSelectElement).value)}>
{snap.roles.map(r => <option key={r} value={r}>{roleLabel(r)}</option>)}
</select>
<button class="admin-row-remove" title="Remove member" onClick={() => removeMember(m.user)}></button>
</div>
))}
</div>
<div class="admin-editor-actions">
<button class="panel-btn" disabled={busy} onClick={save}>{busy ? 'Saving…' : 'Save'}</button>
{!isPublic && <button class="panel-btn" disabled={busy} onClick={remove}>Delete</button>}
</div>
</div>
)}
</div>
</div>
);
}
// ── Server stats ─────────────────────────────────────────────────────────────
function fmtUptime(s: number): string {
s = Math.floor(s);
const d = Math.floor(s / 86400); s -= d * 86400;
const h = Math.floor(s / 3600); s -= h * 3600;
const m = Math.floor(s / 60); s -= m * 60;
const parts: string[] = [];
if (d) parts.push(`${d}d`);
if (h || d) parts.push(`${h}h`);
parts.push(`${m}m`, `${s}s`);
return parts.join(' ');
}
const fmtMB = (b: number): string => `${(b / (1024 * 1024)).toFixed(1)} MB`;
function StatsView({ onError }: { onError: (e: string | null) => void }) {
const [stats, setStats] = useState<ServerStats | null>(null);
useEffect(() => {
let live = true;
const load = async () => {
try {
const s = await apiJSON<ServerStats>('/api/v1/admin/stats');
if (live && s) setStats(s);
} catch (err) { if (live) onError(msg(err)); }
};
load();
const t = setInterval(load, 2000);
return () => { live = false; clearInterval(t); };
}, [onError]);
if (!stats) return <div class="cl-body"><div class="hint admin-empty">Loading</div></div>;
const rows: [string, string][] = [
['Uptime', fmtUptime(stats.uptimeSeconds)],
['WebSocket connections', String(stats.wsConnections)],
['Observed signals', String(stats.observedSignals)],
['Messages in', String(stats.msgIn)],
['Messages out', String(stats.msgOut)],
['Signal writes', String(stats.writeOps)],
['History requests', String(stats.historyReqs)],
['Goroutines', String(stats.goroutines)],
['Memory allocated', fmtMB(stats.memAllocBytes)],
['Memory from OS', fmtMB(stats.memSysBytes)],
['Heap in use', fmtMB(stats.heapInuseBytes)],
['Load average', stats.loadAvg ? stats.loadAvg.map(n => n.toFixed(2)).join(' / ') : '—'],
];
return (
<div class="cl-body">
<div class="admin-stats">
<table class="audit-table admin-stats-table">
<tbody>
{rows.map(([k, v]) => (
<tr key={k}><th class="admin-stat-key">{k}</th><td class="admin-stat-val">{v}</td></tr>
))}
</tbody>
</table>
<div class="admin-stats-ds">
<h4 class="admin-field-label">Data sources ({stats.dataSources.length})</h4>
<div class="admin-ds-list">
{stats.dataSources.map(d => <span key={d} class="admin-ds-tag">{d}</span>)}
</div>
</div>
</div>
</div>
);
}
+52 -1
View File
@@ -18,10 +18,13 @@ import PlotWidget from './widgets/PlotWidget';
import ImageWidget from './widgets/ImageWidget';
import LinkWidget from './widgets/LinkWidget';
import ConfigSelect from './widgets/ConfigSelect';
import Container from './widgets/Container';
import TableWidget from './widgets/TableWidget';
import ContextMenu from './ContextMenu';
import InfoPanel from './InfoPanel';
import SplitLayout from './SplitLayout';
import LogicDialogs from './LogicDialogs';
import { centreInside, widgetTab, tabPanes } from './lib/containers';
const COMPONENTS: Record<string, any> = {
textview: TextView,
@@ -38,8 +41,18 @@ const COMPONENTS: Record<string, any> = {
image: ImageWidget,
link: LinkWidget,
configselect: ConfigSelect,
container: Container,
table: TableWidget,
};
// Container panes are decorative frames placed behind other widgets; render them
// first so they paint underneath.
function renderOrder(widgets: Widget[]): Widget[] {
const containers = widgets.filter(w => w.type === 'container');
const rest = widgets.filter(w => w.type !== 'container');
return [...containers, ...rest];
}
interface CtxState {
visible: boolean;
x: number;
@@ -84,6 +97,26 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
visible: false, x: 0, y: 0, signal: null,
});
const [infoSignal, setInfoSignal] = useState<SignalRef | null>(null);
// View-mode collapse state for collapsible container panes (ephemeral, by id).
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
// View-mode active-tab state for tabbed container panes (ephemeral, by id).
const [activeTabs, setActiveTabs] = useState<Map<string, number>>(() => new Map());
function toggleCollapse(id: string) {
setCollapsed(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function selectTab(id: string, i: number) {
setActiveTabs(prev => {
const next = new Map(prev);
next.set(id, i);
return next;
});
}
// Instantiate this panel's local state variables from their initial values.
useEffect(() => {
@@ -157,10 +190,24 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
);
}
// Collapsible panes currently in the collapsed state — their contents are hidden.
const collapsedPanes = iface.widgets.filter(
w => w.type === 'container' && w.options['collapsible'] === 'true' && collapsed.has(w.id),
);
// Tabbed panes — widgets inside that aren't on the active tab are hidden.
const tPanes = tabPanes(iface.widgets);
return (
<div class="canvas-container">
<div class="canvas-area canvas-view-bare" style={`width:${iface.w}px;height:${iface.h}px;`}>
{iface.widgets.map(widget => {
{renderOrder(iface.widgets).map(widget => {
// Hide widgets that fall inside a currently-collapsed container pane.
const hidden = collapsedPanes.some(c => c.id !== widget.id && centreInside(widget, c));
// Hide widgets inside a tabbed pane that aren't on its active tab.
const tabHidden = tPanes.some(c =>
c.id !== widget.id && centreInside(widget, c) && widgetTab(widget) !== (activeTabs.get(c.id) ?? 0),
);
if (hidden || tabHidden) return null;
const Comp = COMPONENTS[widget.type];
const inner = Comp
? <Comp
@@ -168,6 +215,10 @@ export default function Canvas({ iface, onNavigate, timeRange }: Props) {
onContextMenu={(e: MouseEvent) => onCtxMenu(e, widget)}
onNavigate={onNavigate}
timeRange={timeRange}
collapsed={collapsed.has(widget.id)}
onToggleCollapse={() => toggleCollapse(widget.id)}
activeTab={activeTabs.get(widget.id) ?? 0}
onSelectTab={(i: number) => selectTab(widget.id, i)}
/>
: (
<div
+111 -6
View File
@@ -46,7 +46,7 @@ interface ConfigInstance {
values: Record<string, any>;
}
interface Meta { id: string; name: string; version: number; setId?: string; }
interface Meta { id: string; name: string; version: number; setId?: string; enabled?: boolean; }
// ConfigRule mirrors internal/confmgr.ConfigRule: CUE validation/transformation
// logic bound to a set, run when instances of that set are saved.
@@ -55,6 +55,7 @@ interface ConfigRule {
name: string;
setId: string;
description?: string;
enabled?: boolean;
version: number;
tag?: string;
owner?: string;
@@ -65,6 +66,14 @@ interface ConfigRule {
interface RuleViolation { rule?: string; path?: string; message: string; }
interface RuleResult { ok: boolean; violations?: RuleViolation[]; transformed?: Record<string, any>; compileError?: string; }
// Snapshot / preview payloads mirror the backend /config/rules/preview response.
interface SnapshotEntry { key: string; ds: string; signal: string; value?: any; ok: boolean; error?: string; }
interface SnapshotResult { setId: string; entries: SnapshotEntry[]; captured: number; failed: number; }
interface RulePreview { snapshot: SnapshotResult; result: RuleResult; }
// A rule with no explicit enabled flag (legacy) is treated as enabled.
const ruleEnabled = (r: { enabled?: boolean }) => r.enabled !== false;
interface ApplyEntry { key: string; ds: string; signal: string; value?: any; ok: boolean; skipped?: boolean; error?: string; }
interface ApplyResult { instanceId: string; setId: string; entries: ApplyEntry[]; applied: number; failed: number; skipped: number; }
@@ -795,6 +804,7 @@ function ParamEditor({ param, allOptions, onOpenSignals, signalMeta, onChange, o
function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null) => void }) {
const [instances, setInstances] = useState<Meta[]>([]);
const [sets, setSets] = useState<Meta[]>([]);
const [setFilter, setSetFilter] = useState<string>('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const hist = useUndo<ConfigInstance>();
const working = hist.present;
@@ -974,6 +984,7 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard |
}
const setName = (id: string) => sets.find(s => s.id === id)?.name ?? id;
const visibleInstances = instances.filter(s => !setFilter || s.setId === setFilter);
return (
<div class="cl-body">
@@ -983,9 +994,19 @@ function InstancesManager({ registerGuard }: { registerGuard: (fn: CloseGuard |
<button class="panel-btn" disabled={busy || sets.length === 0} title="Snapshot a set's current live signal values into a new instance" onClick={() => setShowSnap(true)}> Snapshot</button>
<button class="panel-btn" disabled={busy || sets.length === 0} onClick={() => setShowNew(true)}>+ New</button>
</div>
{sets.length > 1 && (
<div class="cl-list-filter">
<select class="prop-select" value={setFilter} title="Filter instances by config set"
onChange={(e) => setSetFilter((e.target as HTMLSelectElement).value)}>
<option value="">All sets</option>
{sets.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
)}
{sets.length === 0 && <div class="hint cl-list-empty">Create a config set first.</div>}
{sets.length > 0 && instances.length === 0 && <div class="hint cl-list-empty">No instances yet.</div>}
{instances.map(s => (
{sets.length > 0 && instances.length > 0 && visibleInstances.length === 0 && <div class="hint cl-list-empty">No instances in this set.</div>}
{visibleInstances.map(s => (
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}`} onClick={() => select(s.id)}>
<span class="cl-list-name" title={s.name}>{s.name || '(unnamed)'}</span>
<span class="cfg-badge">v{s.version}</span>
@@ -1446,6 +1467,8 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [check, setCheck] = useState<RuleResult | null>(null);
const [setFilter, setSetFilter] = useState<string>('');
const [preview, setPreview] = useState<RulePreview | null>(null);
const [showNew, setShowNew] = useState(false);
const [viewingVersion, setViewingVersion] = useState<number | null>(null);
const [verReload, setVerReload] = useState(0);
@@ -1469,9 +1492,20 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
return apiJSON<ConfigSet>(`/api/v1/config/sets/${encodeURIComponent(setId)}`);
}
async function runPreview() {
if (!working) return;
setBusy(true); setError(null);
try {
const res = await apiJSON<RulePreview>('/api/v1/config/rules/preview',
jsonPost({ setId: working.setId, source: working.source }));
setPreview(res);
} catch (err) { setError(`Preview failed: ${msg(err)}`); }
finally { setBusy(false); }
}
async function select(id: string) {
if (dirty && !confirm('Discard unsaved changes?')) return;
setError(null); setCheck(null);
setError(null); setCheck(null); setPreview(null);
try {
const rule = await apiJSON<ConfigRule>(`/api/v1/config/rules/${encodeURIComponent(id)}`);
const set = await loadSet(rule!.setId);
@@ -1485,7 +1519,7 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
async function createRule(name: string, setId: string) {
setBusy(true); setError(null);
try {
const body = { name, setId, source: '// CUE rule: constrain or derive parameter values.\n' };
const body = { name, setId, enabled: true, source: '// CUE rule: constrain or derive parameter values.\n' };
const created = await apiJSON<ConfigRule>('/api/v1/config/rules', jsonPost(body));
const set = await loadSet(created!.setId);
await reload();
@@ -1573,6 +1607,7 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
const completions = boundSet
? boundSet.parameters.flatMap(p => [p.key, `${p.ds}:${p.signal}`]).filter(Boolean)
: [];
const visibleRules = rules.filter(s => !setFilter || s.setId === setFilter);
return (
<div class="cl-body">
@@ -1581,11 +1616,22 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
<span>Rules</span>
<button class="panel-btn" disabled={busy || sets.length === 0} onClick={() => setShowNew(true)}>+ New</button>
</div>
{sets.length > 1 && (
<div class="cl-list-filter">
<select class="prop-select" value={setFilter} title="Filter rules by config set"
onChange={(e) => setSetFilter((e.target as HTMLSelectElement).value)}>
<option value="">All sets</option>
{sets.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
)}
{sets.length === 0 && <div class="hint cl-list-empty">Create a config set first.</div>}
{sets.length > 0 && rules.length === 0 && <div class="hint cl-list-empty">No rules yet.</div>}
{rules.map(s => (
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}`} onClick={() => select(s.id)}>
{sets.length > 0 && rules.length > 0 && visibleRules.length === 0 && <div class="hint cl-list-empty">No rules in this set.</div>}
{visibleRules.map(s => (
<div key={s.id} class={`cl-list-item${selectedId === s.id ? ' cl-list-item-active' : ''}${ruleEnabled(s) ? '' : ' cfg-rule-disabled'}`} onClick={() => select(s.id)}>
<span class="cl-list-name" title={s.name}>{s.name || '(unnamed)'}</span>
{!ruleEnabled(s) && <span class="hint cfg-rule-off" title="Disabled — not run on save/apply">off</span>}
{s.setId && <span class="hint cfg-rule-set" title="Bound set">{setName(s.setId)}</span>}
<span class="cfg-badge">v{s.version}</span>
<button class="cl-mini-btn" title="Delete" onClick={(e) => { e.stopPropagation(); remove(s.id); }}></button>
@@ -1605,6 +1651,7 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
{dirty && <span class="cl-dirty">unsaved</span>}
<button class="panel-btn" disabled={!hist.canUndo} title="Undo (Ctrl+Z)" onClick={undo}></button>
<button class="panel-btn" disabled={!hist.canRedo} title="Redo (Ctrl+Shift+Z)" onClick={redo}></button>
<button class="panel-btn" disabled={busy} title="Run this rule against a live snapshot of the set's signals (nothing is stored)" onClick={runPreview}> Preview</button>
<button class="panel-btn panel-btn-primary" disabled={busy || !dirty} onClick={save}>Save</button>
</div>
@@ -1621,6 +1668,14 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
<input class="prop-input" value={working.description ?? ''} placeholder="(optional)"
onInput={(e) => patch({ description: (e.target as HTMLInputElement).value })} />
</div>
<div class="cfg-field cfg-field-enabled">
<label>Enabled</label>
<label class="cfg-rule-enable" title="When enabled, this rule runs every time an instance of the bound set is saved or applied">
<input type="checkbox" checked={ruleEnabled(working)}
onChange={(e) => patch({ enabled: (e.target as HTMLInputElement).checked })} />
<span>{ruleEnabled(working) ? 'Runs on save/apply' : 'Disabled (not run)'}</span>
</label>
</div>
</div>
<div class="cfg-section-head"><span>CUE source</span></div>
@@ -1635,6 +1690,8 @@ function RulesManager({ registerGuard }: { registerGuard: (fn: CloseGuard | null
<RuleCheckView result={check} />
{preview && <RulePreviewView preview={preview} onClose={() => setPreview(null)} />}
<VersionPanel kind="rules" id={working.id}
viewing={viewingVersion} dirty={dirty} reloadKey={verReload}
onView={viewVersion}
@@ -1697,6 +1754,54 @@ function RuleCheckView({ result }: { result: RuleResult | null }) {
);
}
// RulePreviewView shows the outcome of running a rule against a live snapshot of
// the bound set's signals: the captured input values on the left and the
// processed output (input merged with rule-derived values) on the right, plus
// any compile error / violations. Nothing is persisted.
function RulePreviewView({ preview, onClose }: { preview: RulePreview; onClose: () => void }) {
const input: Record<string, any> = {};
for (const e of preview.snapshot.entries) if (e.ok) input[e.key] = e.value;
const output = { ...input, ...(preview.result.transformed ?? {}) };
const failed = preview.snapshot.entries.filter(e => !e.ok);
return (
<div class="cfg-rule-preview">
<div class="cfg-rule-preview-head">
<b>Live preview</b>
<span class="hint">{preview.snapshot.captured} captured · {preview.snapshot.failed} failed</span>
<button class="cl-mini-btn" title="Close preview" onClick={onClose}></button>
</div>
{preview.result.compileError && (
<div class="cfg-rule-check cfg-rule-check-err"><b>Compile error:</b> {preview.result.compileError}</div>
)}
{!preview.result.compileError && !preview.result.ok && (
<div class="cfg-rule-check cfg-rule-check-err">
<b>{(preview.result.violations ?? []).length} violation(s):</b>
<ul class="cfg-rule-violations">
{(preview.result.violations ?? []).map((v, i) => (
<li key={i}>{v.path ? <code>{v.path}</code> : null} {v.message}</li>
))}
</ul>
</div>
)}
{failed.length > 0 && (
<div class="hint cfg-rule-preview-failed">
Could not read: {failed.map(e => `${e.key} (${e.error})`).join(', ')}
</div>
)}
<div class="cfg-rule-preview-cols">
<div class="cfg-rule-preview-col">
<div class="hint">Snapshot input</div>
<pre class="cfg-json">{JSON.stringify(input, null, 2)}</pre>
</div>
<div class="cfg-rule-preview-col">
<div class="hint">Processed output{preview.result.ok ? '' : ' (would be rejected)'}</div>
<pre class="cfg-json">{JSON.stringify(output, null, 2)}</pre>
</div>
</div>
</div>
);
}
function NewRuleDialog({ sets, busy, onCancel, onCreate }: {
sets: Meta[]; busy: boolean; onCancel: () => void; onCreate: (name: string, setId: string) => void;
}) {
+347 -26
View File
@@ -4,6 +4,10 @@ import SignalPicker, { SignalOption } from './SignalPicker';
import LuaEditor from './LuaEditor';
import { checkExpr } from './lib/expr';
import { VersionTree, DiffViewer } from './VersionHistory';
import { groupBounds, groupContaining, genGroupId, pruneGroups, type NodeGroup, type Rect } from './lib/nodeGroups';
import { useFlowZoom, type Box } from './lib/flowZoom';
import { formatBadge, badgeTitle, nodeDebugClass, type DebugSnapshot } from './lib/flowDebug';
import { wsClient } from './lib/ws';
// ── Types (mirror internal/controllogic/model.go) ────────────────────────────
@@ -41,6 +45,7 @@ interface CLGraph {
enabled: boolean;
nodes: CLNode[];
wires: CLWire[];
groups?: NodeGroup[];
}
interface DataSource { name: string; }
@@ -59,6 +64,8 @@ const REM = (() => {
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
})();
const NODE_W = 11.5 * REM;
const CANVAS_W = 125 * REM; // logical canvas size (matches .flow-canvas-inner)
const CANVAS_H = 87.5 * REM;
const PORT_TOP = 2 * REM;
const PORT_GAP = 1.375 * REM;
const PORT_R = 0.375 * REM;
@@ -67,6 +74,11 @@ const PORT_R = 0.375 * REM;
// offsets so wires meet the port centers, not their top edges.
const BORDER = 1;
const BORDER_TOP = 3;
// Group frame geometry.
const GROUP_PAD = 0.65 * REM;
const GROUP_HEADER = 1.65 * REM;
const GROUP_BOX_W = NODE_W;
const GROUP_BOX_H = GROUP_HEADER + 1.1 * REM;
interface PaletteEntry { kind: CLNodeKind; label: string; params: Record<string, string>; }
@@ -354,7 +366,7 @@ export default function ControlLogicEditor({ onClose }: Props) {
</div>
<div class="cl-editor-main">
<FlowEditor graph={graph}
onChange={(g) => { setGraph({ ...graph, nodes: g.nodes, wires: g.wires }); setDirty(true); }} />
onChange={(g) => { setGraph({ ...graph, nodes: g.nodes, wires: g.wires, groups: g.groups }); setDirty(true); }} />
{showVersions && (
<div class="ver-pane">
<div class="ver-pane-head">Version history</div>
@@ -405,24 +417,85 @@ export default function ControlLogicEditor({ onClose }: Props) {
function FlowEditor({ graph, onChange }: {
graph: CLGraph;
onChange: (g: { nodes: CLNode[]; wires: CLWire[] }) => void;
onChange: (g: { nodes: CLNode[]; wires: CLWire[]; groups?: NodeGroup[] }) => void;
}) {
const nodes = graph.nodes;
const wires = graph.wires;
const groups = graph.groups ?? [];
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const [selectedWire, setSelectedWire] = useState<number | null>(null);
const [selSet, setSelSet] = useState<Set<string>>(new Set());
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
const [configInstances, setConfigInstances] = useState<{ id: string; name: string }[]>([]);
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
const canvasRef = useRef<HTMLDivElement>(null);
const dragNode = useRef<{ id: string; dx: number; dy: number } | null>(null);
const zoomRef = useRef(1);
const dragNode = useRef<
{ ids: string[]; ox: number; oy: number; starts: Map<string, { x: number; y: number }>; pushed: boolean } | null
>(null);
const [pendingWire, setPendingWire] = useState<{ from: string; port: string; x: number; y: number } | null>(null);
const pendingRef = useRef(pendingWire);
pendingRef.current = pendingWire;
// Undo/redo history + clipboard, scoped to this graph. `graphRef` always holds
// the latest {nodes,wires,groups} so the ref-based stacks read a stable value
// across renders. Snapshots are pushed right before each discrete edit.
type Snapshot = { nodes: CLNode[]; wires: CLWire[]; groups: NodeGroup[] };
const graphRef = useRef<Snapshot>({ nodes, wires, groups });
graphRef.current = { nodes, wires, groups };
const undoStack = useRef<Snapshot[]>([]);
const redoStack = useRef<Snapshot[]>([]);
const clipboard = useRef<{ nodes: CLNode[]; wires: CLWire[] } | null>(null);
const [, setHistTick] = useState(0); // re-render so toolbar enabled-state updates
const bump = () => setHistTick(t => t + 1);
const canUndo = undoStack.current.length > 0;
const canRedo = redoStack.current.length > 0;
// Live debug: observe the running graph ('live') or dry-run the unsaved edits
// ('simulate'). Node events arrive over the WS; debugStates holds the latest
// value/timestamp per node and debugSnap is the decayed view the nodes render.
const [debug, setDebug] = useState(false);
const [debugMode, setDebugMode] = useState<'live' | 'simulate'>('live');
const [debugSnap, setDebugSnap] = useState<DebugSnapshot>(new Map());
const debugStates = useRef<Map<string, { value: number; hasValue: boolean; ts: number }>>(new Map());
// Debug session: register the node-event listener, (live) subscribe to the
// running graph, and rebuild the rendered snapshot on a 250 ms tick so node
// highlights fade ~0.8 s after a node last fired. The simulate subscription is
// handled by the separate effect below so it can re-send on each edit.
useEffect(() => {
if (!debug || !graph) return;
debugStates.current = new Map();
setDebugSnap(new Map());
const off = wsClient.onDebugNode((ev) => {
debugStates.current.set(ev.nodeId, { value: ev.value, hasValue: ev.hasValue, ts: Date.now() });
});
if (debugMode === 'live') wsClient.debugSubscribeLive(graph.id);
const iv = setInterval(() => {
const now = Date.now();
const snap: DebugSnapshot = new Map();
for (const [id, s] of debugStates.current) {
snap.set(id, { value: s.hasValue ? s.value : undefined, type: 'scalar', active: now - s.ts < 800 });
}
setDebugSnap(snap);
}, 250);
return () => { off(); wsClient.debugUnsubscribe(); clearInterval(iv); };
}, [debug, debugMode, graph?.id]);
// Simulate mode: (re)compile the unsaved graph server-side on toggle and after
// each edit (debounced) so the sandbox always reflects what's on screen.
useEffect(() => {
if (!debug || debugMode !== 'simulate' || !graph) return;
const t = setTimeout(() => {
wsClient.debugSubscribeSimulate({ id: graph.id, name: graph.name, nodes, wires, groups });
}, 400);
return () => clearTimeout(t);
}, [debug, debugMode, nodes, wires, groups, graph?.id]);
useEffect(() => {
fetch('/api/v1/datasources')
.then(r => r.ok ? r.json() : [])
@@ -482,9 +555,46 @@ function FlowEditor({ graph, onChange }: {
if (selected?.kind === 'action.write') loadSignals(splitRef(selected.params.target ?? '').ds);
}, [selectedNode, selected?.kind]);
function emit(next: { nodes: CLNode[]; wires: CLWire[] }) { onChange(next); }
function setNodes(next: CLNode[]) { emit({ nodes: next, wires }); }
function setWires(next: CLWire[]) { emit({ nodes, wires: next }); }
// ── History ────────────────────────────────────────────────────────────────
// Push the current graph onto the undo stack (clearing redo). Call right
// before a discrete edit; for continuous gestures (node drag) call once.
function pushUndo() {
undoStack.current = [...undoStack.current.slice(-49), graphRef.current];
redoStack.current = [];
bump();
}
function undo() {
if (undoStack.current.length === 0) return;
const prev = undoStack.current[undoStack.current.length - 1];
redoStack.current = [graphRef.current, ...redoStack.current];
undoStack.current = undoStack.current.slice(0, -1);
setSelectedNode(null); setSelectedWire(null); setSelectedGroup(null); setSelSet(new Set());
onChange(prev);
bump();
}
function redo() {
if (redoStack.current.length === 0) return;
const next = redoStack.current[0];
undoStack.current = [...undoStack.current, graphRef.current];
redoStack.current = redoStack.current.slice(1);
setSelectedNode(null); setSelectedWire(null); setSelectedGroup(null); setSelSet(new Set());
onChange(next);
bump();
}
// Apply a graph change, recording an undo entry unless `record` is false (used
// for the intermediate frames of a node drag, which record once on first move).
function emit(next: { nodes?: CLNode[]; wires?: CLWire[]; groups?: NodeGroup[] }, record = true) {
if (record) pushUndo();
onChange({
nodes: next.nodes ?? nodes,
wires: next.wires ?? wires,
groups: next.groups ?? groups,
});
}
function setNodes(next: CLNode[], record = true) { emit({ nodes: next }, record); }
function setWires(next: CLWire[]) { emit({ wires: next }); }
function setGroups(next: NodeGroup[]) { emit({ groups: next }); }
function addNode(entry: PaletteEntry, x?: number, y?: number) {
const node: CLNode = {
@@ -494,19 +604,49 @@ function FlowEditor({ graph, onChange }: {
y: y ?? 40 + (nodes.length % 5) * 30,
params: { ...entry.params },
};
emit({ nodes: [...nodes, node], wires });
emit({ nodes: [...nodes, node] });
setSelectedNode(node.id);
setSelectedWire(null);
setSelSet(new Set([node.id]));
setSelectedGroup(null);
}
function patchParams(id: string, patch: Record<string, string>) {
setNodes(nodes.map(n => (n.id === id ? { ...n, params: { ...n.params, ...patch } } : n)));
}
function moveNode(id: string, x: number, y: number) {
setNodes(nodes.map(n => (n.id === id ? { ...n, x, y } : n)));
function moveNodes(pos: Map<string, { x: number; y: number }>, record = false) {
setNodes(nodes.map(n => (pos.has(n.id) ? { ...n, ...pos.get(n.id)! } : n)), record);
}
function deleteNode(id: string) {
emit({ nodes: nodes.filter(n => n.id !== id), wires: wires.filter(w => w.from !== id && w.to !== id) });
const nextNodes = nodes.filter(n => n.id !== id);
emit({
nodes: nextNodes,
wires: wires.filter(w => w.from !== id && w.to !== id),
groups: pruneGroups(groups, new Set(nextNodes.map(n => n.id))),
});
if (selectedNode === id) setSelectedNode(null);
if (selSet.has(id)) { const s = new Set(selSet); s.delete(id); setSelSet(s); }
}
// ── Groups ──────────────────────────────────────────────────────────────────
function groupSelection() {
if (selSet.size < 1) return;
const members = nodes.filter(n => selSet.has(n.id)).map(n => n.id);
if (members.length < 1) return;
const g: NodeGroup = { id: genGroupId(), label: 'Group', members, collapsed: false };
setGroups([...groups, g]);
setSelectedGroup(g.id);
setSelectedNode(null);
setSelectedWire(null);
}
function ungroup(gid: string) {
setGroups(groups.filter(g => g.id !== gid));
if (selectedGroup === gid) setSelectedGroup(null);
}
function toggleCollapse(gid: string) {
setGroups(groups.map(g => (g.id === gid ? { ...g, collapsed: !g.collapsed } : g)));
}
function renameGroup(gid: string, label: string) {
setGroups(groups.map(g => (g.id === gid ? { ...g, label } : g)));
}
function addWire(from: string, port: string, to: string) {
if (from === to) return;
@@ -518,26 +658,89 @@ function FlowEditor({ graph, onChange }: {
if (selectedWire === idx) setSelectedWire(null);
}
// ── Clipboard ────────────────────────────────────────────────────────────
// Copy the current selection (multi-select aware) plus any wires fully
// internal to it, with params deep-cloned.
function copySelection() {
const cur = graphRef.current;
const ids = selSet.size > 0 ? selSet : (selectedNode ? new Set([selectedNode]) : new Set<string>());
if (ids.size === 0) return;
const picked = cur.nodes.filter(n => ids.has(n.id));
if (picked.length === 0) return;
const innerWires = cur.wires.filter(w => ids.has(w.from) && ids.has(w.to));
clipboard.current = {
nodes: picked.map(n => ({ ...n, params: { ...n.params } })),
wires: innerWires.map(w => ({ ...w })),
};
}
function pasteClipboard() {
const clip = clipboard.current;
if (!clip || clip.nodes.length === 0) return;
const idMap = new Map<string, string>();
const cur = graphRef.current;
const newNodes = clip.nodes.map(n => {
const id = genId();
idMap.set(n.id, id);
return { ...n, id, x: n.x + 30, y: n.y + 30, params: { ...n.params } };
});
const newWires = clip.wires
.filter(w => idMap.has(w.from) && idMap.has(w.to))
.map(w => ({ ...w, from: idMap.get(w.from)!, to: idMap.get(w.to)! }));
emit({ nodes: [...cur.nodes, ...newNodes], wires: [...cur.wires, ...newWires] });
setSelSet(new Set(newNodes.map(n => n.id)));
setSelectedNode(newNodes.length === 1 ? newNodes[0].id : null);
setSelectedWire(null);
setSelectedGroup(null);
}
function toCanvas(e: MouseEvent): { x: number; y: number } {
const el = canvasRef.current!;
const rect = el.getBoundingClientRect();
return { x: e.clientX - rect.left + el.scrollLeft, y: e.clientY - rect.top + el.scrollTop };
const z = zoomRef.current;
return { x: (e.clientX - rect.left + el.scrollLeft) / z, y: (e.clientY - rect.top + el.scrollTop) / z };
}
function startNodeDrag(e: MouseEvent, node: CLNode) {
e.stopPropagation();
function beginDrag(e: MouseEvent, ids: string[]) {
const p = toCanvas(e);
dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y };
setSelectedNode(node.id);
setSelectedWire(null);
const starts = new Map<string, { x: number; y: number }>();
for (const id of ids) {
const n = nodes.find(x => x.id === id);
if (n) starts.set(id, { x: n.x, y: n.y });
}
dragNode.current = { ids: [...starts.keys()], ox: p.x, oy: p.y, starts, pushed: false };
window.addEventListener('mousemove', onNodeDragMove);
window.addEventListener('mouseup', onNodeDragUp);
}
function startNodeDrag(e: MouseEvent, node: CLNode) {
e.stopPropagation();
if (e.shiftKey) {
const s = new Set(selSet);
if (s.has(node.id)) s.delete(node.id); else s.add(node.id);
setSelSet(s);
setSelectedNode(node.id);
setSelectedWire(null);
setSelectedGroup(null);
return;
}
setSelectedWire(null);
setSelectedGroup(null);
const ids = selSet.has(node.id) && selSet.size > 1 ? [...selSet] : [node.id];
if (!(selSet.has(node.id) && selSet.size > 1)) setSelSet(new Set([node.id]));
setSelectedNode(node.id);
beginDrag(e, ids);
}
function onNodeDragMove(e: MouseEvent) {
const d = dragNode.current;
if (!d) return;
const p = toCanvas(e);
moveNode(d.id, Math.max(0, p.x - d.dx), Math.max(0, p.y - d.dy));
// Record a single undo entry per drag (on the first move, so a plain
// click-to-select doesn't create a spurious history step).
const record = !d.pushed;
d.pushed = true;
const dx = p.x - d.ox, dy = p.y - d.oy;
const pos = new Map<string, { x: number; y: number }>();
for (const [id, s] of d.starts) pos.set(id, { x: Math.max(0, s.x + dx), y: Math.max(0, s.y + dy) });
moveNodes(pos, record);
}
function onNodeDragUp() {
dragNode.current = null;
@@ -592,13 +795,20 @@ function FlowEditor({ graph, onChange }: {
function onKey(e: KeyboardEvent) {
const t = e.target as Element;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return;
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
if (mod && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
if (mod && e.key.toLowerCase() === 'c') { e.preventDefault(); copySelection(); return; }
if (mod && e.key.toLowerCase() === 'v') { e.preventDefault(); pasteClipboard(); return; }
if (e.key.toLowerCase() === 'g' && selSet.size >= 1) { e.preventDefault(); groupSelection(); return; }
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
if (selectedWire !== null) deleteWire(selectedWire);
if (selectedGroup !== null) ungroup(selectedGroup);
else if (selectedWire !== null) deleteWire(selectedWire);
else if (selectedNode) deleteNode(selectedNode);
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [selectedNode, selectedWire, nodes, wires]);
}, [selectedNode, selectedWire, selectedGroup, selSet, nodes, wires, groups]);
const byId = new Map(nodes.map(n => [n.id, n]));
@@ -607,9 +817,68 @@ function FlowEditor({ graph, onChange }: {
patchParams(id, { [field]: `${cur}{${ds}:${sig}}` });
}
// ── Group geometry (for the current render) ─────────────────────────────────
const rectOf = (id: string): Rect | null => {
const n = byId.get(id);
return n ? { x: n.x, y: n.y, w: NODE_W, h: nodeHeight(n.kind) } : null;
};
const groupGeom = groups.map(g => {
const bounds = groupBounds(g.members, rectOf, GROUP_PAD, GROUP_HEADER);
const box: Rect | null = bounds ? { x: bounds.x, y: bounds.y, w: GROUP_BOX_W, h: GROUP_BOX_H } : null;
return { g, bounds, box };
});
const hidden = new Set<string>();
const boxByGroup = new Map<string, Rect>();
for (const gg of groupGeom) if (gg.g.collapsed && gg.box) {
boxByGroup.set(gg.g.id, gg.box);
for (const m of gg.g.members) hidden.add(m);
}
const collapsedBoxOf = (id: string): Rect | null => {
const g = groupContaining(groups, id);
return g && g.collapsed ? (boxByGroup.get(g.id) ?? null) : null;
};
const getRects = (): Box[] => nodes.map(n => ({ x: n.x, y: n.y, w: NODE_W, h: nodeHeight(n.kind) }));
const { zoom, zoomIn, zoomOut, zoomHome, zoomFit, innerStyle, zoomStyle } = useFlowZoom(canvasRef, zoomRef, CANVAS_W, CANVAS_H, getRects);
const resolveOut = (n: CLNode, port: string) => {
const box = collapsedBoxOf(n.id);
return box ? { x: box.x + box.w, y: box.y + box.h / 2 } : outAnchor(n, port);
};
const resolveIn = (n: CLNode) => {
const box = collapsedBoxOf(n.id);
return box ? { x: box.x, y: box.y + box.h / 2 } : inAnchor(n);
};
const wireHidden = (from: string, to: string) => {
const gf = groupContaining(groups, from), gt = groupContaining(groups, to);
return !!gf && gf === gt && gf.collapsed;
};
return (
<div class="flow-editor">
<div class="flow-palette">
<div class="flow-palette-toolbar">
<button class="toolbar-btn" disabled={!canUndo} onClick={undo} title="Undo (Ctrl+Z)"></button>
<button class="toolbar-btn" disabled={!canRedo} onClick={redo} title="Redo (Ctrl+Shift+Z)"></button>
<button class="toolbar-btn" disabled={selSet.size < 1} onClick={groupSelection}
title="Group selected nodes (G) — Shift+click nodes to multi-select"></button>
<button class={`toolbar-btn${debug ? ' flow-debug-on' : ''}`} onClick={() => setDebug(d => !d)}
title={debugMode === 'live'
? 'Live debug — observe the running graph\u2019s node values'
: 'Live debug — dry-run the unsaved edits (no real writes)'}></button>
{debug && (
<button class="toolbar-btn flow-debug-mode" onClick={() => setDebugMode(m => m === 'live' ? 'simulate' : 'live')}
title="Switch between observing the running graph (Live) and dry-running the unsaved edits (Simulate)">
{debugMode === 'live' ? 'Live' : 'Sim'}
</button>
)}
</div>
<div class="flow-palette-toolbar">
<button class="toolbar-btn" onClick={zoomOut} title="Zoom out"></button>
<button class="toolbar-btn flow-zoom-pct" onClick={zoomHome} title="Reset zoom (100%)">{Math.round(zoom * 100)}%</button>
<button class="toolbar-btn" onClick={zoomIn} title="Zoom in"></button>
<button class="toolbar-btn" onClick={zoomFit} title="Fit graph to view"></button>
</div>
<div class="flow-palette-title">Triggers</div>
{PALETTE.filter(e => category(e.kind) === 'trigger').map(paletteBtn)}
<div class="flow-palette-title">Logic</div>
@@ -623,17 +892,60 @@ function FlowEditor({ graph, onChange }: {
</div>
<div class="flow-canvas" ref={canvasRef}
onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); }}
onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); setSelectedGroup(null); setSelSet(new Set()); }}
onDragOver={onCanvasDragOver}
onDrop={onCanvasDrop}>
<div class="flow-canvas-inner">
<div class="flow-canvas-inner" style={innerStyle}>
<div class="flow-canvas-zoom" style={zoomStyle}>
{groupGeom.map(({ g, bounds, box }) => {
if (!bounds) return null;
const sel = selectedGroup === g.id;
if (g.collapsed && box) {
return (
<div key={g.id}
class={`flow-group-box${sel ? ' flow-group-selected' : ''}`}
style={`left:${box.x}px; top:${box.y}px; width:${box.w}px; height:${box.h}px;`}
onMouseDown={(e) => { e.stopPropagation(); setSelectedGroup(g.id); setSelectedNode(null); setSelectedWire(null); beginDrag(e, g.members); }}>
<div class="flow-group-header">
<button class="flow-group-caret" title="Expand"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); toggleCollapse(g.id); }}></button>
<input class="flow-group-label" value={g.label}
onMouseDown={(e) => e.stopPropagation()}
onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} />
</div>
<div class="flow-group-count hint">{g.members.length} node{g.members.length === 1 ? '' : 's'}</div>
</div>
);
}
return (
<div key={g.id}
class={`flow-group-frame${sel ? ' flow-group-selected' : ''}`}
style={`left:${bounds.x}px; top:${bounds.y}px; width:${bounds.w}px; height:${bounds.h}px;`}
onMouseDown={(e) => { e.stopPropagation(); setSelectedGroup(g.id); setSelectedNode(null); setSelectedWire(null); beginDrag(e, g.members); }}>
<div class="flow-group-header">
<button class="flow-group-caret" title="Collapse"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); toggleCollapse(g.id); }}></button>
<input class="flow-group-label" value={g.label}
onMouseDown={(e) => e.stopPropagation()}
onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} />
<button class="flow-group-del" title="Ungroup"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); ungroup(g.id); }}></button>
</div>
</div>
);
})}
<svg class="flow-wires">
{wires.map((w, idx) => {
const a = byId.get(w.from);
const b = byId.get(w.to);
if (!a || !b) return null;
const p1 = outAnchor(a, w.fromPort ?? 'out');
const p2 = inAnchor(b);
if (wireHidden(w.from, w.to)) return null;
const p1 = resolveOut(a, w.fromPort ?? 'out');
const p2 = resolveIn(b);
return (
<path key={idx}
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
@@ -649,12 +961,19 @@ function FlowEditor({ graph, onChange }: {
})()}
</svg>
{nodes.map(node => (
{nodes.filter(n => !hidden.has(n.id)).map(node => {
const dbg = debug ? debugSnap.get(node.id) : undefined;
const firable = debug && node.kind.startsWith('trigger.');
const grouped = groupContaining(groups, node.id) ? ' flow-node-grouped' : '';
return (
<div key={node.id}
class={`flow-node flow-node-${category(node.kind)}${selectedNode === node.id ? ' flow-node-selected' : ''}`}
class={`flow-node flow-node-${category(node.kind)}${selectedNode === node.id ? ' flow-node-selected' : ''}${selSet.has(node.id) && selSet.size > 1 ? ' flow-node-multi' : ''}${dbg ? ' ' + nodeDebugClass(dbg) : ''}${firable ? ' flow-node-firable' : ''}${grouped}`}
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeHeight(node.kind)}px;`}
title={firable ? 'Double-click to force this trigger to fire' : undefined}
onMouseDown={(e) => startNodeDrag(e, node)}
onDblClick={() => { if (firable) wsClient.debugFireTrigger(node.id); }}
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
{dbg && <span class="flow-node-badge" title={badgeTitle(dbg)}>{formatBadge(dbg)}</span>}
<div class="flow-node-header">
<span class="flow-node-title">{KIND_LABEL[node.kind]}</span>
<button class="flow-node-del" title="Delete node"
@@ -681,7 +1000,9 @@ function FlowEditor({ graph, onChange }: {
</Fragment>
))}
</div>
))}
);
})}
</div>
</div>
</div>
+62 -8
View File
@@ -14,15 +14,28 @@ import Button from './widgets/Button';
import PlotWidget from './widgets/PlotWidget';
import ImageWidget from './widgets/ImageWidget';
import LinkWidget from './widgets/LinkWidget';
import Container from './widgets/Container';
import TableWidget from './widgets/TableWidget';
import WidgetTypePicker from './WidgetTypePicker';
import { centreInside, widgetTab, tabPanes, withContainedWidgets } from './lib/containers';
const COMPONENTS: Record<string, any> = {
textview: TextView, textlabel: TextLabel, gauge: Gauge,
barh: BarH, barv: BarV, led: Led, multiled: MultiLed,
setvalue: SetValue, toggle: Toggle, button: Button, plot: PlotWidget,
image: ImageWidget, link: LinkWidget,
image: ImageWidget, link: LinkWidget, container: Container,
table: TableWidget,
};
// Container panes are decorative frames that other widgets sit over, so they
// must paint behind everything else. Render them first (their relative order
// preserved) regardless of their position in the widget array.
function renderOrder(widgets: Widget[]): Widget[] {
const containers = widgets.filter(w => w.type === 'container');
const rest = widgets.filter(w => w.type !== 'container');
return [...containers, ...rest];
}
export const DEFAULT_SIZES: Record<string, [number, number]> = {
textview: [200, 50],
textlabel: [150, 36],
@@ -38,10 +51,12 @@ export const DEFAULT_SIZES: Record<string, [number, number]> = {
image: [200, 150],
link: [140, 50],
configselect: [220, 50],
container: [320, 220],
table: [280, 160],
};
// Widget types that support multiple signals (signal can be appended)
const MULTI_SIGNAL_TYPES = new Set(['plot', 'multiled']);
const MULTI_SIGNAL_TYPES = new Set(['plot', 'multiled', 'table']);
// Widget types that have exactly one signal (signal can be replaced)
const SINGLE_SIGNAL_TYPES = new Set(['textview', 'gauge', 'barh', 'barv', 'led', 'setvalue', 'toggle', 'button']);
@@ -134,6 +149,17 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
const [signalDropMenu, setSignalDropMenu] = useState<SignalDropMenuState | null>(null);
// Which tab is being edited per tabbed container (ephemeral). Widgets not on
// the active tab are hidden so each tab's content can be laid out separately.
const [activeTabs, setActiveTabs] = useState<Map<string, number>>(() => new Map());
function selectTab(id: string, i: number) {
setActiveTabs(prev => {
const next = new Map(prev);
next.set(id, i);
return next;
});
}
useEffect(() => {
function onMouseMove(e: MouseEvent) {
const grid = snapGridRef.current;
@@ -250,8 +276,10 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
const { cx, cy } = getCanvasPos(e);
// Check if drop lands on an existing widget
const hit = iface.widgets.find(w =>
// Check if drop lands on an existing widget. Ignore container panes so a
// signal dropped onto a widget that overlaps a pane still targets the widget
// (and a drop on bare pane area creates a new widget there).
const hit = iface.widgets.filter(w => w.type !== 'container').find(w =>
cx >= w.x && cx <= w.x + w.w && cy >= w.y && cy <= w.y + w.h
);
@@ -305,6 +333,12 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
signals: [picker.signal],
options: {},
};
// If dropped inside a tabbed container, assign it to the active tab so it
// shows up alongside that tab's other content.
if (type !== 'container') {
const host = tPanes.find(c => centreInside(newWidget, c));
if (host) newWidget.options.tab = String(activeTabs.get(host.id) ?? 0);
}
onChange([...iface.widgets, newWidget]);
onSelect([newWidget.id]);
setPicker(p => ({ ...p, visible: false }));
@@ -327,7 +361,11 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
if (ctrl && isSelected) return;
const movers = nextIds.includes(widget.id) ? nextIds : [widget.id];
// Dragging a container also drags the widgets sitting on it (snapshotted now).
const movers = withContainedWidgets(
nextIds.includes(widget.id) ? nextIds : [widget.id],
iface.widgets,
);
dragRef.current = {
startMouseX: e.clientX,
startMouseY: e.clientY,
@@ -357,6 +395,15 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
onSelect(selectedIds.filter(s => s !== id));
}
// A widget is hidden in the editor when it sits inside a tabbed container but
// belongs to a tab other than the one currently being edited.
const tPanes = tabPanes(iface.widgets);
function tabHidden(widget: Widget): boolean {
return tPanes.some(c =>
c.id !== widget.id && centreInside(widget, c) && widgetTab(widget) !== (activeTabs.get(c.id) ?? 0),
);
}
return (
<div
class="edit-canvas-container"
@@ -367,10 +414,16 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
>
<div class="canvas-area" style={`width:${iface.w}px;height:${iface.h}px;`}>
{(iface.widgets || []).map(widget => {
{renderOrder(iface.widgets || []).map(widget => {
if (tabHidden(widget)) return null;
const Comp = COMPONENTS[widget.type];
return Comp
? <Comp key={widget.id} widget={widget} />
? <Comp
key={widget.id}
widget={widget}
activeTab={activeTabs.get(widget.id) ?? 0}
onSelectTab={(i: number) => selectTab(widget.id, i)}
/>
: (
<div key={widget.id} class="unknown-widget"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}>
@@ -379,7 +432,8 @@ export default function EditCanvas({ iface, selectedIds, onSelect, onChange, sna
);
})}
{(iface.widgets || []).map(widget => {
{renderOrder(iface.widgets || []).map(widget => {
if (tabHidden(widget)) return null;
const isSelected = selectedIds.includes(widget.id);
return (
+89 -1
View File
@@ -11,6 +11,7 @@ import PropertiesPane from './PropertiesPane';
import HelpModal from './HelpModal';
import ZoomControl from './ZoomControl';
import { useAuth, canWrite } from './lib/auth';
import { withContainedWidgets } from './lib/containers';
import { VersionTree, DiffViewer } from './VersionHistory';
interface Props {
@@ -22,6 +23,31 @@ function blankInterface(): Interface {
return { id: '', name: 'New Interface', version: 1, w: 1200, h: 800, widgets: [] };
}
// Rename a widget id everywhere it is referenced so logic actions and plot
// layouts keep pointing at the same widget after the user customizes its id.
function renameInLayout(l: PlotLayout, oldId: string, newId: string): PlotLayout {
if (l.type === 'leaf') return l.widget === oldId ? { ...l, widget: newId } : l;
return { ...l, a: renameInLayout(l.a, oldId, newId), b: renameInLayout(l.b, oldId, newId) };
}
function renameWidgetId(f: Interface, oldId: string, newId: string): Interface {
const next: Interface = {
...f,
widgets: (f.widgets || []).map(w => (w.id === oldId ? { ...w, id: newId } : w)),
};
if (f.layout) next.layout = renameInLayout(f.layout, oldId, newId);
if (f.logic) {
next.logic = {
...f.logic,
nodes: f.logic.nodes.map(n =>
n.kind === 'action.widget' && n.params.widget === oldId
? { ...n, params: { ...n.params, widget: newId } }
: n),
};
}
return next;
}
// ── Align / distribute helpers ──────────────────────────────────────────────
function alignWidgets(widgets: Widget[], ids: string[], mode: string): Widget[] {
@@ -77,6 +103,8 @@ const STATIC_WIDGET_TYPES = [
{ type: 'image', label: 'Image' },
{ type: 'link', label: 'Link' },
{ type: 'configselect', label: 'Config Selector' },
{ type: 'container', label: 'Container Pane' },
{ type: 'table', label: 'Table' },
];
export default function EditMode({ initial, onDone }: Props) {
@@ -90,6 +118,7 @@ export default function EditMode({ initial, onDone }: Props) {
const [snapGrid, setSnapGrid] = useState(10);
const [showSnapGrid, setShowSnapGrid] = useState(true);
const [showInsertMenu, setShowInsertMenu] = useState(false);
const [showGroupMenu, setShowGroupMenu] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [helpSection, setHelpSection] = useState('edit');
const [leftW, setLeftW] = useState(220);
@@ -195,6 +224,13 @@ export default function EditMode({ initial, onDone }: Props) {
setDirty(true);
}, [pushUndo]);
const handleWidgetRename = useCallback((oldId: string, newId: string) => {
pushUndo();
setIface(f => renameWidgetId(f, oldId, newId));
setSelectedIds(ids => ids.map(id => id === oldId ? newId : id));
setDirty(true);
}, [pushUndo]);
const handleIfaceChange = useCallback((updated: Interface) => {
setIface(updated);
setDirty(true);
@@ -284,8 +320,10 @@ export default function EditMode({ initial, onDone }: Props) {
const step = e.shiftKey ? (snapGrid || 10) * 2 : (snapGrid || 1);
const dx = e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0;
const dy = e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0;
// Nudging a container also nudges the widgets sitting on it.
const movers = withContainedWidgets(selectedIds, curWidgets);
handleWidgetsChange(curWidgets.map(w =>
selectedIds.includes(w.id) ? { ...w, x: w.x + dx, y: w.y + dy } : w
movers.includes(w.id) ? { ...w, x: w.x + dx, y: w.y + dy } : w
));
}
}, [selectedIds, snapGrid, undo, redo, handleWidgetsChange, openHelp, pushUndo, iface.kind, centerTab]);
@@ -307,6 +345,43 @@ export default function EditMode({ initial, onDone }: Props) {
handleWidgetsChange(distributed);
}, [iface.widgets, selectedIds, handleWidgetsChange]);
// ── Group selection into a container ────────────────────────────────────────
// Wrap the selected widgets in a new container sized to their bounding box.
// The container is appended so it paints behind the grouped widgets (the
// canvas always renders containers first); membership is geometric, so simply
// enclosing the widgets places them inside. Extra top inset leaves room for
// the title / tab bar above the grouped content.
const groupIntoContainer = useCallback((variant: 'pane' | 'tabs') => {
setShowGroupMenu(false);
const widgets = iface.widgets || [];
const sel = widgets.filter(w => selectedIds.includes(w.id));
if (sel.length < 2) return;
const minX = Math.min(...sel.map(w => w.x));
const minY = Math.min(...sel.map(w => w.y));
const maxX = Math.max(...sel.map(w => w.x + w.w));
const maxY = Math.max(...sel.map(w => w.y + w.h));
const PAD = 12;
const TOP = 36; // room for the title / tab bar above the grouped widgets
const container: Widget = {
id: genWidgetId(),
type: 'container',
x: minX - PAD,
y: minY - TOP,
w: (maxX - minX) + PAD * 2,
h: (maxY - minY) + TOP + PAD,
signals: [],
options: variant === 'tabs'
? { variant: 'tabs', tabs: 'Tab 1,Tab 2', bg: 'true' }
: { variant: 'pane', title: 'Group', collapsible: 'false', bg: 'true' },
};
// In tab mode, place the grouped widgets on the first tab so they show by default.
const rest = variant === 'tabs'
? widgets.map(w => selectedIds.includes(w.id) ? { ...w, options: { ...w.options, tab: '0' } } : w)
: widgets;
handleWidgetsChange([...rest, container]);
setSelectedIds([container.id]);
}, [iface.widgets, selectedIds, handleWidgetsChange]);
// ── Insert static widget ───────────────────────────────────────────────────
const insertWidget = useCallback((type: string) => {
@@ -324,6 +399,8 @@ export default function EditMode({ initial, onDone }: Props) {
type === 'textlabel' ? { label: 'Label' } :
type === 'button' ? { label: 'Button', mode: 'oneshot' } :
type === 'configselect' ? { label: 'Config', set: '', output: '', instances: '' } :
type === 'container' ? { title: 'Pane', collapsible: 'false', bg: 'true' } :
type === 'table' ? { columns: 'name,value,unit', header: 'true' } :
{},
};
handleWidgetsChange([...(iface.widgets || []), newWidget]);
@@ -565,6 +642,16 @@ export default function EditMode({ initial, onDone }: Props) {
<button class="toolbar-btn icon-only" onClick={() => doDistribute('v')} title="Distribute V"></button>
</div>
)}
<span class="toolbar-sep" />
<div class="toolbar-dropdown">
<button class="toolbar-btn" onClick={() => setShowGroupMenu(m => !m)} title="Group selection into a container"> Group </button>
{showGroupMenu && (
<div class="toolbar-dropdown-menu" onClick={() => setShowGroupMenu(false)}>
<button class="ctx-item" onClick={() => groupIntoContainer('pane')}>Into Pane</button>
<button class="ctx-item" onClick={() => groupIntoContainer('tabs')}>Into Tabs</button>
</div>
)}
</div>
</div>
)}
@@ -659,6 +746,7 @@ export default function EditMode({ initial, onDone }: Props) {
multiCount={multiSelected ? selectedIds.length : 0}
iface={iface}
onChange={handleWidgetChange}
onRenameId={handleWidgetRename}
onIfaceChange={handleIfaceChange}
width={rightW}
/>
+30 -3
View File
@@ -15,6 +15,30 @@ interface Props {
onToggleCollapse: () => void;
}
// Monochrome (currentColor) panel-kind glyph. Inline SVG avoids font-dependent
// emoji that may render in colour or as a missing-glyph box.
function KindIcon({ kind }: { kind?: 'panel' | 'plot' }) {
const plot = kind === 'plot';
return (
<span class="iface-item-icon" title={plot ? 'Plot panel' : 'HMI panel'}>
{plot ? (
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M2.5 2.5 v11 h11" />
<path d="M4.5 11 L7 7.5 L9 9.5 L13 4.5" />
</svg>
) : (
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2" y="2.5" width="12" height="11" rx="1.5" />
<path d="M4.5 6 h7" />
<circle cx="6" cy="6" r="1.2" fill="currentColor" stroke="none" />
<path d="M4.5 10 h7" />
<circle cx="10" cy="10" r="1.2" fill="currentColor" stroke="none" />
</svg>
)}
</span>
);
}
export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onEditId, width, collapsed, onToggleCollapse }: Props) {
const [interfaces, setInterfaces] = useState<InterfaceListItem[]>([]);
const [folders, setFolders] = useState<Folder[]>([]);
@@ -87,6 +111,7 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
id: String(item.id || item.ID || ''),
name: String(item.name || item.Name || ''),
version: Number(item.version || item.Version || 0),
kind: item.kind === 'plot' ? ('plot' as const) : undefined,
owner: item.owner ? String(item.owner) : '',
folder: item.folder ? String(item.folder) : '',
order: Number(item.order || 0),
@@ -171,19 +196,21 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
return (
<li
key={item.id}
class={`iface-item${dropTarget === `p:${item.id}` ? ' drop-target' : ''}`}
class={`iface-item iface-item-clickable${dropTarget === `p:${item.id}` ? ' drop-target' : ''}`}
draggable={drag}
onClick={() => onSelect?.(item.id)}
onDragStart={drag ? (e) => { dragId.current = item.id; e.dataTransfer!.effectAllowed = 'move'; } : undefined}
onDragEnd={endDrag}
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.stopPropagation(); setDropTarget(`p:${item.id}`); } }}
onDragLeave={() => setDropTarget(t => t === `p:${item.id}` ? null : t)}
onDrop={(e) => dropOnPanel(item, e)}
>
<span class="iface-item-name" onClick={() => onSelect?.(item.id)} title={item.owner ? `Owner: ${item.owner}` : undefined}>
<span class="iface-item-name" title={item.owner ? `Owner: ${item.owner}` : undefined}>
<KindIcon kind={item.kind} />
{item.name || item.id}
{item.perm === 'read' && <span class="iface-badge" title="Read-only">ro</span>}
</span>
<div class="iface-item-actions">
<div class="iface-item-actions" onClick={(e) => e.stopPropagation()}>
{item.perm === 'write' && <button class="icon-btn" title="Edit" onClick={() => onEditId?.(item.id)}></button>}
<button class="icon-btn" title="Open fullscreen in new tab" onClick={() => handleFullscreen(item.id)}></button>
{writable && <button class="icon-btn" title="Clone" onClick={() => handleClone(item.id)}></button>}
+239 -24
View File
@@ -3,6 +3,10 @@ import { useState, useEffect, useRef } from 'preact/hooks';
import SignalPicker, { SignalOption } from './SignalPicker';
import { checkExpr } from './lib/expr';
import type { LogicGraph, LogicNode, LogicNodeKind, LogicWire, StateVar, Widget } from './lib/types';
import { groupBounds, groupContaining, genGroupId, pruneGroups, type NodeGroup, type Rect } from './lib/nodeGroups';
import { useFlowZoom, type Box } from './lib/flowZoom';
import { LogicEngine } from './lib/logic';
import { useFlowDebug, formatBadge, badgeTitle, nodeDebugClass, type DebugSnapshot } from './lib/flowDebug';
interface DataSource { name: string; }
interface SignalInfo { name: string; }
@@ -34,6 +38,8 @@ const REM = (() => {
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
})();
const NODE_W = 11.5 * REM; // node width
const CANVAS_W = 125 * REM; // logical canvas size (matches .flow-canvas-inner)
const CANVAS_H = 87.5 * REM;
const PORT_TOP = 2 * REM; // y of the first port row, relative to node top
const PORT_GAP = 1.375 * REM; // vertical spacing between stacked output ports
const PORT_R = 0.375 * REM; // port radius (half the 0.75rem dot)
@@ -42,6 +48,11 @@ const PORT_R = 0.375 * REM; // port radius (half the 0.75rem dot)
// offsets so wires meet the port centers, not their top edges.
const BORDER = 1;
const BORDER_TOP = 3;
// Group frame geometry.
const GROUP_PAD = 0.65 * REM; // padding around member nodes
const GROUP_HEADER = 1.65 * REM; // height of the group title bar
const GROUP_BOX_W = NODE_W; // collapsed-box width
const GROUP_BOX_H = GROUP_HEADER + 1.1 * REM; // collapsed-box height
interface PaletteEntry {
kind: LogicNodeKind;
@@ -147,6 +158,12 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const [selectedWire, setSelectedWire] = useState<number | null>(null);
// Multi-selection (Shift+click) used to build groups; selectedNode stays the
// "primary" node shown in the inspector.
const [selSet, setSelSet] = useState<Set<string>>(new Set());
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
const groups = graph.groups ?? [];
const [dataSources, setDataSources] = useState<string[]>([]);
const [dsSignals, setDsSignals] = useState<Record<string, string[]>>({});
@@ -154,7 +171,10 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
const canvasRef = useRef<HTMLDivElement>(null);
const dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null);
const zoomRef = useRef(1);
const dragNode = useRef<
{ ids: string[]; ox: number; oy: number; starts: Map<string, { x: number; y: number }>; pushed: boolean } | null
>(null);
const [pendingWire, setPendingWire] = useState<{ from: string; port: string; x: number; y: number } | null>(null);
const pendingRef = useRef(pendingWire);
pendingRef.current = pendingWire;
@@ -172,6 +192,12 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
const canUndo = undoStack.current.length > 0;
const canRedo = redoStack.current.length > 0;
// Live / debug mode: a throwaway dry-run LogicEngine evaluates the edited graph
// against live signals with all side effects (writes, config, dialogs)
// suppressed, so each node lights with its value without touching production.
const [debug, setDebug] = useState(false);
const dbgEngine = useRef<LogicEngine | null>(null);
useEffect(() => {
fetch('/api/v1/datasources')
.then(r => r.ok ? r.json() : [])
@@ -284,6 +310,8 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
undoStack.current = undoStack.current.slice(0, -1);
setSelectedNode(null);
setSelectedWire(null);
setSelectedGroup(null);
setSelSet(new Set());
onChange(prev);
bump();
}
@@ -305,8 +333,9 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
if (record) pushUndo();
onChange(next);
}
function setNodes(next: LogicNode[], record = true) { setGraph({ nodes: next, wires }, record); }
function setWires(next: LogicWire[]) { setGraph({ nodes, wires: next }); }
function setNodes(next: LogicNode[], record = true) { setGraph({ nodes: next, wires, ...(groups.length ? { groups } : {}) }, record); }
function setWires(next: LogicWire[]) { setGraph({ nodes, wires: next, ...(groups.length ? { groups } : {}) }); }
function setGroups(next: NodeGroup[], record = true) { setGraph({ nodes, wires, groups: next }, record); }
function addNode(entry: PaletteEntry, x?: number, y?: number) {
const node: LogicNode = {
@@ -316,23 +345,52 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
y: y ?? 40 + (nodes.length % 5) * 30,
params: { ...entry.params },
};
setGraph({ nodes: [...nodes, node], wires });
setGraph({ nodes: [...nodes, node], wires, ...(groups.length ? { groups } : {}) });
setSelectedNode(node.id);
setSelectedWire(null);
setSelSet(new Set([node.id]));
setSelectedGroup(null);
}
function patchParams(id: string, patch: Record<string, string>) {
setNodes(nodes.map(n => (n.id === id ? { ...n, params: { ...n.params, ...patch } } : n)));
}
function moveNode(id: string, x: number, y: number, record = false) {
setNodes(nodes.map(n => (n.id === id ? { ...n, x, y } : n)), record);
// Move several nodes at once (group / multi-select drag). `pos` maps id→{x,y}.
function moveNodes(pos: Map<string, { x: number; y: number }>, record = false) {
setNodes(nodes.map(n => (pos.has(n.id) ? { ...n, ...pos.get(n.id)! } : n)), record);
}
// ── Groups ──────────────────────────────────────────────────────────────────
function groupSelection() {
if (selSet.size < 1) return;
const members = nodes.filter(n => selSet.has(n.id)).map(n => n.id);
if (members.length < 1) return;
const g: NodeGroup = { id: genGroupId(), label: 'Group', members, collapsed: false };
setGroups([...groups, g]);
setSelectedGroup(g.id);
setSelectedNode(null);
setSelectedWire(null);
}
function ungroup(gid: string) {
setGroups(groups.filter(g => g.id !== gid));
if (selectedGroup === gid) setSelectedGroup(null);
}
function toggleCollapse(gid: string) {
setGroups(groups.map(g => (g.id === gid ? { ...g, collapsed: !g.collapsed } : g)));
}
function renameGroup(gid: string, label: string) {
setGroups(groups.map(g => (g.id === gid ? { ...g, label } : g)), false);
}
function deleteNode(id: string) {
const nextNodes = nodes.filter(n => n.id !== id);
const nextGroups = pruneGroups(groups, new Set(nextNodes.map(n => n.id)));
setGraph({
nodes: nodes.filter(n => n.id !== id),
nodes: nextNodes,
wires: wires.filter(w => w.from !== id && w.to !== id),
...(nextGroups.length ? { groups: nextGroups } : {}),
});
if (selectedNode === id) setSelectedNode(null);
if (selSet.has(id)) { const s = new Set(selSet); s.delete(id); setSelSet(s); }
}
function addWire(from: string, port: string, to: string) {
if (from === to) return;
@@ -365,7 +423,7 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
const newWires = clip.wires
.filter(w => idMap.has(w.from) && idMap.has(w.to))
.map(w => ({ ...w, from: idMap.get(w.from)!, to: idMap.get(w.to)! }));
setGraph({ nodes: [...cur.nodes, ...newNodes], wires: [...cur.wires, ...newWires] });
setGraph({ nodes: [...cur.nodes, ...newNodes], wires: [...cur.wires, ...newWires], ...((cur.groups?.length) ? { groups: cur.groups } : {}) });
if (newNodes.length === 1) { setSelectedNode(newNodes[0].id); setSelectedWire(null); }
}
@@ -373,19 +431,44 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
function toCanvas(e: MouseEvent): { x: number; y: number } {
const el = canvasRef.current!;
const rect = el.getBoundingClientRect();
return { x: e.clientX - rect.left + el.scrollLeft, y: e.clientY - rect.top + el.scrollTop };
const z = zoomRef.current;
return { x: (e.clientX - rect.left + el.scrollLeft) / z, y: (e.clientY - rect.top + el.scrollTop) / z };
}
// ── Node dragging ──────────────────────────────────────────────────────────
function startNodeDrag(e: MouseEvent, node: LogicNode) {
e.stopPropagation();
// Begin dragging `ids` (one node, a multi-selection, or a whole group). All
// members move together, keeping their relative offsets.
function beginDrag(e: MouseEvent, ids: string[]) {
const p = toCanvas(e);
dragNode.current = { id: node.id, dx: p.x - node.x, dy: p.y - node.y, pushed: false };
setSelectedNode(node.id);
setSelectedWire(null);
const starts = new Map<string, { x: number; y: number }>();
for (const id of ids) {
const n = nodes.find(x => x.id === id);
if (n) starts.set(id, { x: n.x, y: n.y });
}
dragNode.current = { ids: [...starts.keys()], ox: p.x, oy: p.y, starts, pushed: false };
window.addEventListener('mousemove', onNodeDragMove);
window.addEventListener('mouseup', onNodeDragUp);
}
function startNodeDrag(e: MouseEvent, node: LogicNode) {
e.stopPropagation();
// Shift+click toggles the node in the multi-selection without dragging.
if (e.shiftKey) {
const s = new Set(selSet);
if (s.has(node.id)) s.delete(node.id); else s.add(node.id);
setSelSet(s);
setSelectedNode(node.id);
setSelectedWire(null);
setSelectedGroup(null);
return;
}
setSelectedWire(null);
setSelectedGroup(null);
// Drag the whole multi-selection if this node is part of it, else just it.
const ids = selSet.has(node.id) && selSet.size > 1 ? [...selSet] : [node.id];
if (!(selSet.has(node.id) && selSet.size > 1)) setSelSet(new Set([node.id]));
setSelectedNode(node.id);
beginDrag(e, ids);
}
function onNodeDragMove(e: MouseEvent) {
const d = dragNode.current;
if (!d) return;
@@ -394,7 +477,10 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
// click-to-select doesn't create a spurious history step).
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);
const dx = p.x - d.ox, dy = p.y - d.oy;
const pos = new Map<string, { x: number; y: number }>();
for (const [id, s] of d.starts) pos.set(id, { x: Math.max(0, s.x + dx), y: Math.max(0, s.y + dy) });
moveNodes(pos, record);
}
function onNodeDragUp() {
dragNode.current = null;
@@ -466,13 +552,16 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
if (mod && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
if (mod && e.key.toLowerCase() === 'c') { e.preventDefault(); copySelection(); return; }
if (mod && e.key.toLowerCase() === 'v') { e.preventDefault(); pasteClipboard(); return; }
// 'g' groups the current multi-selection (Ctrl+G or plain g).
if (e.key.toLowerCase() === 'g' && selSet.size >= 1) { e.preventDefault(); groupSelection(); return; }
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
if (selectedWire !== null) deleteWire(selectedWire);
if (selectedGroup !== null) ungroup(selectedGroup);
else if (selectedWire !== null) deleteWire(selectedWire);
else if (selectedNode) deleteNode(selectedNode);
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [selectedNode, selectedWire, nodes, wires]);
}, [selectedNode, selectedWire, selectedGroup, selSet, nodes, wires, groups]);
const byId = new Map(nodes.map(n => [n.id, n]));
@@ -488,12 +577,85 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
patchParams(id, { [field]: `${cur}{${ds}:${sig}}` });
}
// ── Group geometry (for the current render) ─────────────────────────────────
const rectOf = (id: string): Rect | null => {
const n = byId.get(id);
return n ? { x: n.x, y: n.y, w: NODE_W, h: nodeHeight(n.kind) } : null;
};
const groupGeom = groups.map(g => {
const bounds = groupBounds(g.members, rectOf, GROUP_PAD, GROUP_HEADER);
const box: Rect | null = bounds ? { x: bounds.x, y: bounds.y, w: GROUP_BOX_W, h: GROUP_BOX_H } : null;
return { g, bounds, box };
});
const hidden = new Set<string>();
const boxByGroup = new Map<string, Rect>();
for (const gg of groupGeom) if (gg.g.collapsed && gg.box) {
boxByGroup.set(gg.g.id, gg.box);
for (const m of gg.g.members) hidden.add(m);
}
const collapsedBoxOf = (id: string): Rect | null => {
const g = groupContaining(groups, id);
return g && g.collapsed ? (boxByGroup.get(g.id) ?? null) : null;
};
const getRects = (): Box[] => graph.nodes.map(n => ({ x: n.x, y: n.y, w: NODE_W, h: nodeHeight(n.kind) }));
const { zoom, zoomIn, zoomOut, zoomHome, zoomFit, innerStyle, zoomStyle } = useFlowZoom(canvasRef, zoomRef, CANVAS_W, CANVAS_H, getRects);
// Create/tear down the dry-run engine as debug mode toggles.
useEffect(() => {
if (!debug) return;
const eng = new LogicEngine();
eng.setDryRun(true);
eng.load(graphRef.current);
dbgEngine.current = eng;
return () => { eng.clear(); dbgEngine.current = null; };
}, [debug]);
// Reload the dry-run engine when the edited graph changes (debounced so rapid
// edits settle before re-running triggers).
useEffect(() => {
if (!debug || !dbgEngine.current) return;
const h = setTimeout(() => dbgEngine.current?.load(graphRef.current), 300);
return () => clearTimeout(h);
}, [debug, graph]);
// Poll the dry-run engine's per-node state into the shared overlay.
const debugSnap: DebugSnapshot = useFlowDebug(debug, async () => {
const eng = dbgEngine.current;
if (!eng) return null;
const snap: DebugSnapshot = new Map();
for (const [id, s] of eng.getDebug()) snap.set(id, { value: s.value, active: s.active });
return snap;
});
const resolveOut = (n: LogicNode, port: string) => {
const box = collapsedBoxOf(n.id);
return box ? { x: box.x + box.w, y: box.y + box.h / 2 } : outAnchor(n, port);
};
const resolveIn = (n: LogicNode) => {
const box = collapsedBoxOf(n.id);
return box ? { x: box.x, y: box.y + box.h / 2 } : inAnchor(n);
};
// A wire is hidden when both ends collapse into the same group.
const wireHidden = (from: string, to: string) => {
const gf = groupContaining(groups, from), gt = groupContaining(groups, to);
return !!gf && gf === gt && gf.collapsed;
};
return (
<div class="flow-editor">
<div class="flow-palette">
<div class="flow-palette-toolbar">
<button class="toolbar-btn" disabled={!canUndo} onClick={undo} title="Undo (Ctrl+Z)"></button>
<button class="toolbar-btn" disabled={!canRedo} onClick={redo} title="Redo (Ctrl+Shift+Z)"></button>
<button class="toolbar-btn" disabled={selSet.size < 1} onClick={groupSelection}
title="Group selected nodes (G) — Shift+click nodes to multi-select"></button>
<button class={`toolbar-btn${debug ? ' flow-debug-on' : ''}`} onClick={() => setDebug(d => !d)}
title="Live debug — dry-run the flow and show each node's value (no real writes)"></button>
</div>
<div class="flow-palette-toolbar">
<button class="toolbar-btn" onClick={zoomOut} title="Zoom out"></button>
<button class="toolbar-btn flow-zoom-pct" onClick={zoomHome} title="Reset zoom (100%)">{Math.round(zoom * 100)}%</button>
<button class="toolbar-btn" onClick={zoomIn} title="Zoom in"></button>
<button class="toolbar-btn" onClick={zoomFit} title="Fit graph to view"></button>
</div>
<div class="flow-palette-title">Triggers</div>
{PALETTE.filter(e => category(e.kind) === 'trigger').map(paletteBtn)}
@@ -514,17 +676,61 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
</div>
<div class="flow-canvas" ref={canvasRef}
onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); }}
onMouseDown={() => { setSelectedNode(null); setSelectedWire(null); setSelectedGroup(null); setSelSet(new Set()); }}
onDragOver={onCanvasDragOver}
onDrop={onCanvasDrop}>
<div class="flow-canvas-inner">
<div class="flow-canvas-inner" style={innerStyle}>
<div class="flow-canvas-zoom" style={zoomStyle}>
{/* Group frames (behind nodes). Collapsed groups render a compact box. */}
{groupGeom.map(({ g, bounds, box }) => {
if (!bounds) return null;
const sel = selectedGroup === g.id;
if (g.collapsed && box) {
return (
<div key={g.id}
class={`flow-group-box${sel ? ' flow-group-selected' : ''}`}
style={`left:${box.x}px; top:${box.y}px; width:${box.w}px; height:${box.h}px;`}
onMouseDown={(e) => { e.stopPropagation(); setSelectedGroup(g.id); setSelectedNode(null); setSelectedWire(null); beginDrag(e, g.members); }}>
<div class="flow-group-header">
<button class="flow-group-caret" title="Expand"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); toggleCollapse(g.id); }}></button>
<input class="flow-group-label" value={g.label}
onMouseDown={(e) => e.stopPropagation()}
onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} />
</div>
<div class="flow-group-count hint">{g.members.length} node{g.members.length === 1 ? '' : 's'}</div>
</div>
);
}
return (
<div key={g.id}
class={`flow-group-frame${sel ? ' flow-group-selected' : ''}`}
style={`left:${bounds.x}px; top:${bounds.y}px; width:${bounds.w}px; height:${bounds.h}px;`}
onMouseDown={(e) => { e.stopPropagation(); setSelectedGroup(g.id); setSelectedNode(null); setSelectedWire(null); beginDrag(e, g.members); }}>
<div class="flow-group-header">
<button class="flow-group-caret" title="Collapse"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); toggleCollapse(g.id); }}></button>
<input class="flow-group-label" value={g.label}
onMouseDown={(e) => e.stopPropagation()}
onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} />
<button class="flow-group-del" title="Ungroup"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); ungroup(g.id); }}></button>
</div>
</div>
);
})}
<svg class="flow-wires">
{wires.map((w, idx) => {
const a = byId.get(w.from);
const b = byId.get(w.to);
if (!a || !b) return null;
const p1 = outAnchor(a, w.fromPort ?? 'out');
const p2 = inAnchor(b);
if (wireHidden(w.from, w.to)) return null;
const p1 = resolveOut(a, w.fromPort ?? 'out');
const p2 = resolveIn(b);
return (
<path key={idx}
class={`flow-wire${selectedWire === idx ? ' flow-wire-selected' : ''}`}
@@ -540,12 +746,19 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
})()}
</svg>
{nodes.map(node => (
{nodes.filter(n => !hidden.has(n.id)).map(node => {
const dbg = debug ? debugSnap.get(node.id) : undefined;
const firable = debug && node.kind.startsWith('trigger.');
const grouped = groupContaining(groups, node.id) ? ' flow-node-grouped' : '';
return (
<div key={node.id}
class={`flow-node flow-node-${category(node.kind)}${selectedNode === node.id ? ' flow-node-selected' : ''}`}
class={`flow-node flow-node-${category(node.kind)}${selectedNode === node.id ? ' flow-node-selected' : ''}${selSet.has(node.id) && selSet.size > 1 ? ' flow-node-multi' : ''}${dbg ? ' ' + nodeDebugClass(dbg) : ''}${firable ? ' flow-node-firable' : ''}${grouped}`}
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeHeight(node.kind)}px;`}
title={firable ? 'Double-click to force this trigger to fire' : undefined}
onMouseDown={(e) => startNodeDrag(e, node)}
onDblClick={() => { if (firable) dbgEngine.current?.fireTrigger(node.id); }}
onMouseUp={() => { if (pendingRef.current) finishWire(node); }}>
{dbg && <span class={`flow-node-badge${dbg.approx ? ' approx' : ''}${dbg.error ? ' error' : ''}`} title={badgeTitle(dbg)}>{(dbg.approx ? '~' : '') + formatBadge(dbg)}</span>}
<div class="flow-node-header">
<span class="flow-node-title">{KIND_LABEL[node.kind]}</span>
<button class="flow-node-del" title="Delete node"
@@ -572,7 +785,9 @@ export default function LogicEditor({ graph, onChange, widgets = [], statevars,
</Fragment>
))}
</div>
))}
);
})}
</div>
</div>
</div>
+161 -3
View File
@@ -1,12 +1,14 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import type { Widget, Interface } from './lib/types';
import { containingTabPane, tabLabels } from './lib/containers';
interface Props {
selected: Widget | null;
multiCount: number;
iface: Interface;
onChange: (updated: Widget) => void;
onRenameId: (oldId: string, newId: string) => void;
onIfaceChange: (updated: Interface) => void;
width?: number;
}
@@ -44,6 +46,37 @@ function TextInput({ value, onCommit }: { value: string; onCommit: (v: string) =
);
}
// Like TextInput, but for the widget id: rejects invalid (empty/duplicate)
// commits and reverts the field to the current id when `onRename` returns false.
function IdInput({ value, onRename }: { value: string; onRename: (v: string) => boolean }) {
const [local, setLocal] = useState(value || '');
useEffect(() => {
setLocal(value || '');
}, [value]);
function commit() {
const v = local.trim();
if (v === value) { setLocal(value); return; }
if (!onRename(v)) setLocal(value); // rejected — revert to the current id
}
return (
<input
class="prop-input"
value={local}
onInput={(e) => setLocal((e.target as HTMLInputElement).value)}
onChange={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commit();
(e.target as HTMLInputElement).blur();
}
}}
/>
);
}
// Widgets that show units from signal metadata (can be overridden)
const UNIT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue']);
// Widgets where the user can set a numeric format string
@@ -51,9 +84,9 @@ const FORMAT_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv']);
// Widgets with a signal-based label (can be overridden)
const LABEL_WIDGETS = new Set(['textview', 'gauge', 'barh', 'barv', 'setvalue', 'led', 'multiled', 'toggle']);
// Widgets with multiple signals
const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled']);
const MULTI_SIGNAL_WIDGETS = new Set(['plot', 'multiled', 'table']);
export default function PropertiesPane({ selected, multiCount, iface, onChange, onIfaceChange, width }: Props) {
export default function PropertiesPane({ selected, multiCount, iface, onChange, onRenameId, onIfaceChange, width }: Props) {
const [collapsed, setCollapsed] = useState(false);
const [configSets, setConfigSets] = useState<{ id: string; name: string }[]>([]);
@@ -77,6 +110,17 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
onChange({ ...selected, [key]: value } as Widget);
}
// Apply a custom widget id. Rejects empty or duplicate ids (returns false so
// the field reverts); on success the rename propagates to logic/plot refs.
function renameId(newId: string): boolean {
if (!selected) return false;
if (!newId) return false;
if (newId === selected.id) return false;
if ((iface.widgets || []).some(x => x.id === newId)) return false;
onRenameId(selected.id, newId);
return true;
}
function removeSignal(idx: number) {
if (!selected) return;
const signals = selected.signals.filter((_, i) => i !== idx);
@@ -135,6 +179,11 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
<div class="props-section" key={w.id}>
<div class="props-section-title">{w.type}</div>
{/* Widget id — editable so logic/plot refs can use a friendly name */}
<Field label="Widget ID">
<IdInput value={w.id} onRename={renameId} />
</Field>
{/* Position / size */}
<Field label="X">
<input class="prop-input prop-input-num" type="number" value={w.x}
@@ -180,7 +229,7 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
{/* Label: button and textlabel use it as display text;
signal widgets use it as header label */}
{w.type !== 'image' && w.type !== 'link' && (
{w.type !== 'image' && w.type !== 'link' && w.type !== 'container' && w.type !== 'table' && (
<Field label={LABEL_WIDGETS.has(w.type) ? 'Label override' : 'Label'}>
{LABEL_WIDGETS.has(w.type) ? (
<div class="prop-field-col">
@@ -428,6 +477,115 @@ export default function PropertiesPane({ selected, multiCount, iface, onChange,
</Field>
</div>
)}
{w.type === 'container' && (
<div>
<Field label="Variant">
<select class="prop-select" value={w.options['variant'] ?? 'pane'}
onChange={(e) => setOpt('variant', (e.target as HTMLSelectElement).value)}>
<option value="pane">Pane</option>
<option value="tabs">Tabs</option>
</select>
</Field>
<Field label="Accent">
<TextInput value={w.options['accent'] ?? '#3d4f6e'} onCommit={(v) => setOpt('accent', v)} />
</Field>
<Field label="Background">
<select class="prop-select" value={w.options['bg'] ?? 'true'}
onChange={(e) => setOpt('bg', (e.target as HTMLSelectElement).value)}>
<option value="true">Filled</option>
<option value="false">None</option>
</select>
</Field>
{(w.options['variant'] ?? 'pane') === 'tabs' ? (
<Field label="Tabs">
<div class="prop-field-col">
<TextInput value={w.options['tabs'] ?? 'Tab 1,Tab 2'} onCommit={(v) => setOpt('tabs', v)} />
<span class="prop-hint">Comma-separated tab names. Assign each widget to a tab via its "Tab" field.</span>
</div>
</Field>
) : (
<div>
<Field label="Title">
<TextInput value={w.options['title'] ?? ''} onCommit={(v) => setOpt('title', v)} />
</Field>
<Field label="Collapsible">
<select class="prop-select" value={w.options['collapsible'] ?? 'false'}
onChange={(e) => setOpt('collapsible', (e.target as HTMLSelectElement).value)}>
<option value="false">No</option>
<option value="true">Yes</option>
</select>
</Field>
</div>
)}
</div>
)}
{w.type === 'table' && (() => {
const allCols = ['name', 'value', 'unit', 'quality', 'time'];
const colLabels: Record<string, string> = {
name: 'Name', value: 'Value', unit: 'Unit', quality: 'Status', time: 'Time',
};
const cur = (w.options['columns'] ?? 'name,value,unit')
.split(',').map(s => s.trim()).filter(Boolean);
const toggleCol = (c: string) => {
const next = cur.includes(c)
? cur.filter(x => x !== c)
: allCols.filter(x => cur.includes(x) || x === c);
setOpt('columns', next.join(','));
};
return (
<div>
<Field label="Title">
<TextInput value={w.options['title'] ?? ''} onCommit={(v) => setOpt('title', v)} />
</Field>
<Field label="Columns">
<div class="prop-field-col">
{allCols.map(c => (
<label key={c} class="prop-check">
<input type="checkbox" checked={cur.includes(c)} onChange={() => toggleCol(c)} />
{colLabels[c]}
</label>
))}
</div>
</Field>
<Field label="Header row">
<select class="prop-select" value={w.options['header'] ?? 'true'}
onChange={(e) => setOpt('header', (e.target as HTMLSelectElement).value)}>
<option value="true">Show</option>
<option value="false">Hide</option>
</select>
</Field>
<Field label="Value format">
<div class="prop-field-col">
<TextInput value={w.options['format'] ?? ''} onCommit={(v) => setOpt('format', v)} />
<span class="prop-hint">3f · 2e · 4g · empty=auto</span>
</div>
</Field>
<Field label="Row labels">
<div class="prop-field-col">
<TextInput value={w.options['labels'] ?? ''} onCommit={(v) => setOpt('labels', v)} />
<span class="prop-hint">Comma-separated, one per signal. Empty = signal name.</span>
</div>
</Field>
</div>
);
})()}
{/* Tab assignment for a widget sitting inside a tabbed container. */}
{w.type !== 'container' && (() => {
const host = containingTabPane(w, iface.widgets);
if (!host) return null;
const tabs = tabLabels(host);
return (
<Field label="Tab">
<select class="prop-select" value={w.options['tab'] ?? '0'}
onChange={(e) => setOpt('tab', (e.target as HTMLSelectElement).value)}>
{tabs.map((t, i) => <option key={i} value={String(i)}>{t}</option>)}
</select>
</Field>
);
})()}
</div>
)}
+260 -22
View File
@@ -5,6 +5,9 @@ import LuaEditor from './LuaEditor';
import type { SignalDef, SynthGraph, SynthGraphNode } from './lib/types';
import { inferNodeTypes, portAccept, typesCompatible, SynthType } from './lib/synthTypes';
import { VersionTree, DiffViewer } from './VersionHistory';
import { groupBounds, groupContaining, genGroupId, pruneGroups, type NodeGroup, type Rect } from './lib/nodeGroups';
import { useFlowZoom, type Box } from './lib/flowZoom';
import { useFlowDebug, formatBadge, badgeTitle, nodeDebugClass, type DebugSnapshot } from './lib/flowDebug';
interface DataSource { name: string; }
interface SignalInfo { name: string; type?: string; }
@@ -91,7 +94,7 @@ interface GNode {
}
// A wire terminates on a specific input port (index) of the target node.
interface GWire { from: string; to: string; toPort: number; }
interface Graph { nodes: GNode[]; wires: GWire[]; }
interface Graph { nodes: GNode[]; wires: GWire[]; groups?: NodeGroup[]; }
// ── Geometry (rem-based, like the logic editor) ─────────────────────────────
const REM = (() => {
@@ -99,6 +102,9 @@ const REM = (() => {
return parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
})();
const NODE_W = 10 * REM;
// Logical canvas size (matches .flow-canvas-inner in styles.css); zoom scales it.
const CANVAS_W = 125 * REM;
const CANVAS_H = 87.5 * REM;
const PORT_TOP = 1.7 * REM; // y of the first input/output port row
const PORT_GAP = 1.25 * REM; // vertical spacing between stacked input ports
const PORT_R = 0.375 * REM;
@@ -108,6 +114,11 @@ const PORT_R = 0.375 * REM;
// offsets or they land at the top edge of the port circle instead of its center.
const BORDER = 1;
const BORDER_TOP = 3;
// ── Group geometry (node grouping / collapsing) ─────────────────────────────
const GROUP_PAD = 0.65 * REM; // padding around member nodes
const GROUP_HEADER = 1.65 * REM; // height of the group title bar
const GROUP_BOX_W = NODE_W; // collapsed-box width
const GROUP_BOX_H = GROUP_HEADER + 1.1 * REM; // collapsed-box height
function genId(): string { return 'g_' + Math.random().toString(36).slice(2, 9); }
function hasOutput(k: NodeKind): boolean { return k !== 'output'; }
@@ -163,7 +174,8 @@ function buildInitial(def: SignalDef): Graph {
for (const n of def.graph.nodes) {
(n.inputs ?? []).forEach((from, port) => wires.push({ from, to: n.id, toPort: port }));
}
return { nodes, wires };
const groups = def.graph.groups;
return { nodes, wires, ...(groups && groups.length ? { groups: groups.map(g => ({ ...g, members: [...g.members] })) } : {}) };
}
// Legacy linear layout: sources stacked left, pipeline a row of op nodes, output right.
@@ -212,7 +224,8 @@ function compile(g: Graph): SynthGraph {
if (n.kind === 'output') return { id: n.id, kind: 'output', inputs, x: n.x, y: n.y };
return { id: n.id, kind: 'op', op: n.op, params: n.params, inputs, x: n.x, y: n.y };
});
return { nodes, output: output?.id ?? '' };
const groups = pruneGroups(g.groups, new Set(g.nodes.map(n => n.id)));
return { nodes, output: output?.id ?? '', ...(groups.length ? { groups } : {}) };
}
// Detect a cycle in the graph (Kahn count over input edges).
@@ -308,6 +321,9 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
const [graph, setGraph] = useState<Graph>({ nodes: [], wires: [] });
const [selected, setSelected] = useState<string | null>(null);
const [selectedWire, setSelectedWire] = useState<number | null>(null);
// Multi-selection (Shift+click) and the selected group, for node grouping.
const [selSet, setSelSet] = useState<Set<string>>(new Set());
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
@@ -333,7 +349,8 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
const [dsSignals, setDsSignals] = useState<Record<string, SignalInfo[]>>({});
const canvasRef = useRef<HTMLDivElement>(null);
const dragNode = useRef<{ id: string; dx: number; dy: number; pushed: boolean } | null>(null);
const zoomRef = useRef(1);
const dragNode = useRef<{ ids: string[]; ox: number; oy: number; starts: Map<string, { x: number; y: number }>; pushed: boolean } | null>(null);
const [pendingWire, setPendingWire] = useState<{ from: string; x: number; y: number } | null>(null);
const pendingRef = useRef(pendingWire);
pendingRef.current = pendingWire;
@@ -342,6 +359,8 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
graphRef.current = graph;
const undoStack = useRef<Graph[]>([]);
const redoStack = useRef<Graph[]>([]);
const clipboard = useRef<{ nodes: GNode[]; wires: GWire[] } | null>(null);
const [debug, setDebug] = useState(false);
const [, setTick] = useState(0);
const bump = () => setTick(t => t + 1);
const canUndo = undoStack.current.length > 0;
@@ -427,16 +446,20 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
undoStack.current = [...undoStack.current.slice(-49), graphRef.current];
redoStack.current = [];
}
// Commit a new graph. When `next.groups` is left undefined the current groups
// are carried over, so ordinary node/wire edits never lose grouping; group
// mutators pass an explicit (possibly empty) list to change it.
function commit(next: Graph, record = true) {
if (record) pushUndo();
setGraph(next);
const groups = next.groups !== undefined ? next.groups : graphRef.current.groups;
setGraph({ nodes: next.nodes, wires: next.wires, ...(groups && groups.length ? { groups } : {}) });
}
function undo() {
if (undoStack.current.length === 0) return;
const prev = undoStack.current[undoStack.current.length - 1];
redoStack.current = [graphRef.current, ...redoStack.current];
undoStack.current = undoStack.current.slice(0, -1);
setSelected(null); setSelectedWire(null);
setSelected(null); setSelectedWire(null); setSelSet(new Set()); setSelectedGroup(null);
setGraph(prev); bump();
}
function redo() {
@@ -444,7 +467,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
const next = redoStack.current[0];
undoStack.current = [...undoStack.current, graphRef.current];
redoStack.current = redoStack.current.slice(1);
setSelected(null); setSelectedWire(null);
setSelected(null); setSelectedWire(null); setSelSet(new Set()); setSelectedGroup(null);
setGraph(next); bump();
}
@@ -527,15 +550,79 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
.map(w => (w.to === id && w.toPort > idx ? { ...w, toPort: w.toPort - 1 } : w));
commit({ nodes, wires });
}
function moveNode(id: string, x: number, y: number, record: boolean) {
// Move several nodes at once (group / multi-select drag). `pos` maps id→{x,y}.
function moveNodes(pos: Map<string, { x: number; y: number }>, record: boolean) {
const g = graphRef.current;
commit({ nodes: g.nodes.map(n => (n.id === id ? { ...n, x, y } : n)), wires: g.wires }, record);
commit({ nodes: g.nodes.map(n => (pos.has(n.id) ? { ...n, ...pos.get(n.id)! } : n)), wires: g.wires }, record);
}
function deleteNode(id: string) {
const n = graph.nodes.find(x => x.id === id);
if (!n || n.kind === 'output') return; // the output node is permanent
commit({ nodes: graph.nodes.filter(x => x.id !== id), wires: graph.wires.filter(w => w.from !== id && w.to !== id) });
const nextNodes = graph.nodes.filter(x => x.id !== id);
const nextGroups = pruneGroups(graph.groups, new Set(nextNodes.map(nn => nn.id)));
commit({ nodes: nextNodes, wires: graph.wires.filter(w => w.from !== id && w.to !== id), groups: nextGroups });
if (selected === id) setSelected(null);
if (selSet.has(id)) { const s = new Set(selSet); s.delete(id); setSelSet(s); }
}
// ── Clipboard ────────────────────────────────────────────────────────────
// Copy the current selection (multi-select aware) plus any wires internal to
// it, with params deep-cloned. The permanent output node is never copied.
function copySelection() {
const cur = graphRef.current;
const ids = selSet.size > 0 ? selSet : (selected ? new Set([selected]) : new Set<string>());
const picked = cur.nodes.filter(n => ids.has(n.id) && n.kind !== 'output');
if (picked.length === 0) return;
const pickedIds = new Set(picked.map(n => n.id));
const innerWires = cur.wires.filter(w => pickedIds.has(w.from) && pickedIds.has(w.to));
clipboard.current = {
nodes: picked.map(n => ({ ...n, params: n.params ? JSON.parse(JSON.stringify(n.params)) : undefined })),
wires: innerWires.map(w => ({ ...w })),
};
}
function pasteClipboard() {
const clip = clipboard.current;
if (!clip || clip.nodes.length === 0) return;
const idMap = new Map<string, string>();
const cur = graphRef.current;
const newNodes = clip.nodes.map(n => {
const id = genId();
idMap.set(n.id, id);
return { ...n, id, x: n.x + 1.5 * REM, y: n.y + 1.5 * REM, params: n.params ? JSON.parse(JSON.stringify(n.params)) : undefined };
});
const newWires = clip.wires
.filter(w => idMap.has(w.from) && idMap.has(w.to))
.map(w => ({ ...w, from: idMap.get(w.from)!, to: idMap.get(w.to)! }));
for (const n of newNodes) if (n.kind === 'source' && n.ds) loadSignals(n.ds);
commit({ nodes: [...cur.nodes, ...newNodes], wires: [...cur.wires, ...newWires] });
setSelSet(new Set(newNodes.map(n => n.id)));
setSelected(newNodes.length === 1 ? newNodes[0].id : null);
setSelectedWire(null);
setSelectedGroup(null);
}
// ── Groups ──────────────────────────────────────────────────────────────────
function setGroups(next: NodeGroup[], record = true) {
const g = graphRef.current;
commit({ nodes: g.nodes, wires: g.wires, groups: next }, record);
}
function groupSelection() {
if (selSet.size < 1) return;
const members = graph.nodes.filter(n => selSet.has(n.id)).map(n => n.id);
if (members.length < 1) return;
const grp: NodeGroup = { id: genGroupId(), label: 'Group', members, collapsed: false };
setGroups([...(graph.groups ?? []), grp]);
setSelectedGroup(grp.id); setSelected(null); setSelectedWire(null);
}
function ungroup(gid: string) {
setGroups((graph.groups ?? []).filter(g => g.id !== gid));
if (selectedGroup === gid) setSelectedGroup(null);
}
function toggleCollapse(gid: string) {
setGroups((graph.groups ?? []).map(g => (g.id === gid ? { ...g, collapsed: !g.collapsed } : g)));
}
function renameGroup(gid: string, label: string) {
setGroups((graph.groups ?? []).map(g => (g.id === gid ? { ...g, label } : g)), false);
}
function addWire(from: string, to: string, toPort: number) {
if (from === to) return;
@@ -572,13 +659,35 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
function toCanvas(e: MouseEvent) {
const el = canvasRef.current!;
const rect = el.getBoundingClientRect();
return { x: e.clientX - rect.left + el.scrollLeft, y: e.clientY - rect.top + el.scrollTop };
const z = zoomRef.current;
return { x: (e.clientX - rect.left + el.scrollLeft) / z, y: (e.clientY - rect.top + el.scrollTop) / z };
}
// Begin dragging `ids` (one node, a multi-selection, or a whole group). All
// members move together, keeping their relative offsets.
function beginDrag(e: MouseEvent, ids: string[]) {
const p = toCanvas(e);
const starts = new Map<string, { x: number; y: number }>();
for (const id of ids) {
const n = graphRef.current.nodes.find(x => x.id === id);
if (n) starts.set(id, { x: n.x, y: n.y });
}
dragNode.current = { ids: [...starts.keys()], ox: p.x, oy: p.y, starts, pushed: false };
}
function startNodeDrag(e: MouseEvent, node: GNode) {
e.stopPropagation();
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);
// Shift+click toggles the node in the multi-selection without dragging.
if (e.shiftKey) {
const s = new Set(selSet);
if (s.has(node.id)) s.delete(node.id); else s.add(node.id);
setSelSet(s); setSelected(node.id); setSelectedWire(null); setSelectedGroup(null);
return;
}
setSelectedWire(null); setSelectedGroup(null);
// Drag the whole multi-selection if this node is part of it, else just it.
const ids = selSet.has(node.id) && selSet.size > 1 ? [...selSet] : [node.id];
if (!(selSet.has(node.id) && selSet.size > 1)) setSelSet(new Set([node.id]));
setSelected(node.id);
beginDrag(e, ids);
}
function startWire(e: MouseEvent, node: GNode) {
e.stopPropagation();
@@ -604,7 +713,10 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
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);
const dx = p.x - d.ox, dy = p.y - d.oy;
const pos = new Map<string, { x: number; y: number }>();
for (const [id, s] of d.starts) pos.set(id, { x: Math.max(0, s.x + dx), y: Math.max(0, s.y + dy) });
moveNodes(pos, record);
return;
}
const cur = pendingRef.current;
@@ -651,22 +763,86 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
if (mod && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { e.preventDefault(); redo(); return; }
if (mod && e.key.toLowerCase() === 'c') { e.preventDefault(); copySelection(); return; }
if (mod && e.key.toLowerCase() === 'v') { e.preventDefault(); pasteClipboard(); return; }
if (mod) return;
if (e.key === 'Escape') { if (hud) closeHud(); return; }
if (e.key.toLowerCase() === 's') { e.preventDefault(); openHud('signal'); return; }
if (e.key.toLowerCase() === 'n') { e.preventDefault(); openHud('node'); return; }
// 'g' groups the current multi-selection.
if (e.key.toLowerCase() === 'g' && selSet.size >= 1) { e.preventDefault(); groupSelection(); return; }
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
if (selectedWire !== null) deleteWire(selectedWire);
if (selectedGroup !== null) ungroup(selectedGroup);
else if (selectedWire !== null) deleteWire(selectedWire);
else if (selected) deleteNode(selected);
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [selected, selectedWire, graph, hud]);
}, [selected, selectedWire, selectedGroup, selSet, graph, hud]);
const byId = new Map(graph.nodes.map(n => [n.id, n]));
const groups = graph.groups ?? [];
const sel = graph.nodes.find(n => n.id === selected) ?? null;
const { errors: nodeErrors, first: validationError } = validate(graph);
// ── Group geometry (for the current render) ─────────────────────────────────
const rectOf = (id: string): Rect | null => {
const n = byId.get(id);
return n ? { x: n.x, y: n.y, w: NODE_W, h: nodeMinHeight(n, graph.wires) } : null;
};
const groupGeom = groups.map(g => {
const bounds = groupBounds(g.members, rectOf, GROUP_PAD, GROUP_HEADER);
const box: Rect | null = bounds ? { x: bounds.x, y: bounds.y, w: GROUP_BOX_W, h: GROUP_BOX_H } : null;
return { g, bounds, box };
});
const hiddenNodes = new Set<string>();
const boxByGroup = new Map<string, Rect>();
for (const gg of groupGeom) if (gg.g.collapsed && gg.box) {
boxByGroup.set(gg.g.id, gg.box);
for (const m of gg.g.members) hiddenNodes.add(m);
}
const collapsedBoxOf = (id: string): Rect | null => {
const g = groupContaining(groups, id);
return g && g.collapsed ? (boxByGroup.get(g.id) ?? null) : null;
};
const resolveOut = (n: GNode) => {
const box = collapsedBoxOf(n.id);
return box ? { x: box.x + box.w, y: box.y + box.h / 2 } : outAnchor(n);
};
const resolveIn = (n: GNode, port: number) => {
const box = collapsedBoxOf(n.id);
return box ? { x: box.x, y: box.y + box.h / 2 } : inAnchor(n, port);
};
// A wire is hidden when both ends collapse into the same group.
const wireHidden = (from: string, to: string) => {
const gf = groupContaining(groups, from), gt = groupContaining(groups, to);
return !!gf && gf === gt && gf.collapsed;
};
// ── Pan / zoom ───────────────────────────────────────────────────────────────
const getRects = (): Box[] => graph.nodes.map(n => ({ x: n.x, y: n.y, w: NODE_W, h: nodeMinHeight(n, graph.wires) }));
const { zoom, zoomIn, zoomOut, zoomHome, zoomFit, innerStyle, zoomStyle } =
useFlowZoom(canvasRef, zoomRef, CANVAS_W, CANVAS_H, getRects);
// ── Live / debug mode ────────────────────────────────────────────────────────
// Polls the server with the current (unsaved) graph; each node lights up with
// its computed value. Stateful DSP nodes are flagged "approx" (single-shot eval).
const debugSnap: DebugSnapshot = useFlowDebug(debug, async () => {
const res = await fetch('/api/v1/synthetic/trace', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ graph: compile(graphRef.current) }),
});
if (!res.ok) return null;
const j = await res.json();
const snap: DebugSnapshot = new Map();
for (const [id, n] of Object.entries(j.nodes ?? {})) {
const node = n as { value: any; type?: 'scalar' | 'array'; approx?: boolean };
snap.set(id, { value: node.value, type: node.type, approx: node.approx, active: node.value != null });
}
return snap;
});
// Static type propagation: infer each node's output type (scalar/array) to
// colour wires and surface type-incompatible wirings as node errors. Mirrors
// the backend dsp.OpOutputType rules (see lib/synthTypes.ts).
@@ -802,6 +978,16 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
<div class="flow-palette-toolbar">
<button class="toolbar-btn" disabled={!canUndo} onClick={undo} title="Undo (Ctrl+Z)"></button>
<button class="toolbar-btn" disabled={!canRedo} onClick={redo} title="Redo (Ctrl+Shift+Z)"></button>
<button class="toolbar-btn" disabled={selSet.size < 1} onClick={groupSelection}
title="Group selected nodes (G) — Shift+click nodes to multi-select"></button>
<button class={`toolbar-btn${debug ? ' flow-debug-on' : ''}`} onClick={() => setDebug(d => !d)}
title="Live debug — evaluate the graph and show each node's value"></button>
</div>
<div class="flow-palette-toolbar">
<button class="toolbar-btn" onClick={zoomOut} title="Zoom out"></button>
<button class="toolbar-btn flow-zoom-pct" onClick={zoomHome} title="Reset zoom (100%)">{Math.round(zoom * 100)}%</button>
<button class="toolbar-btn" onClick={zoomIn} title="Zoom in"></button>
<button class="toolbar-btn" onClick={zoomFit} title="Fit graph to view"></button>
</div>
<div class="flow-palette-title">Inputs</div>
<button class="flow-palette-btn flow-palette-trigger" onClick={addSource}>+ Signal</button>
@@ -820,15 +1006,59 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
</div>
<div class="flow-canvas" ref={canvasRef}
onMouseDown={() => { setSelected(null); setSelectedWire(null); }}
onMouseDown={() => { setSelected(null); setSelectedWire(null); setSelectedGroup(null); setSelSet(new Set()); }}
onDragOver={onCanvasDragOver}
onDrop={onCanvasDrop}>
<div class="flow-canvas-inner">
<div class="flow-canvas-inner" style={innerStyle}>
<div class="flow-canvas-zoom" style={zoomStyle}>
{/* Group frames (behind nodes). Collapsed groups render a compact box. */}
{groupGeom.map(({ g, bounds, box }) => {
if (!bounds) return null;
const gsel = selectedGroup === g.id;
if (g.collapsed && box) {
return (
<div key={g.id}
class={`flow-group-box${gsel ? ' flow-group-selected' : ''}`}
style={`left:${box.x}px; top:${box.y}px; width:${box.w}px; height:${box.h}px;`}
onMouseDown={(e) => { e.stopPropagation(); setSelectedGroup(g.id); setSelected(null); setSelectedWire(null); beginDrag(e, g.members); }}>
<div class="flow-group-header">
<button class="flow-group-caret" title="Expand"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); toggleCollapse(g.id); }}>▸</button>
<input class="flow-group-label" value={g.label}
onMouseDown={(e) => e.stopPropagation()}
onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} />
</div>
<div class="flow-group-count hint">{g.members.length} node{g.members.length === 1 ? '' : 's'}</div>
</div>
);
}
return (
<div key={g.id}
class={`flow-group-frame${gsel ? ' flow-group-selected' : ''}`}
style={`left:${bounds.x}px; top:${bounds.y}px; width:${bounds.w}px; height:${bounds.h}px;`}
onMouseDown={(e) => { e.stopPropagation(); setSelectedGroup(g.id); setSelected(null); setSelectedWire(null); beginDrag(e, g.members); }}>
<div class="flow-group-header">
<button class="flow-group-caret" title="Collapse"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); toggleCollapse(g.id); }}>▾</button>
<input class="flow-group-label" value={g.label}
onMouseDown={(e) => e.stopPropagation()}
onInput={(e) => renameGroup(g.id, (e.target as HTMLInputElement).value)} />
<button class="flow-group-del" title="Ungroup"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); ungroup(g.id); }}>✕</button>
</div>
</div>
);
})}
<svg class="flow-wires">
{graph.wires.map((w, idx) => {
const a = byId.get(w.from); const b = byId.get(w.to);
if (!a || !b) return null;
const p1 = outAnchor(a); const p2 = inAnchor(b, w.toPort);
if (wireHidden(w.from, w.to)) return null;
const p1 = resolveOut(a); const p2 = resolveIn(b, w.toPort);
const tClass = nodeTypes.get(w.from) === 'array' ? ' synth-wire-array' : ' synth-wire-scalar';
return (
<path key={idx}
@@ -845,15 +1075,22 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
})()}
</svg>
{graph.nodes.map(node => {
{graph.nodes.filter(n => !hiddenNodes.has(n.id)).map(node => {
const nIn = inPortCount(node, graph.wires);
const dbg = debug ? debugSnap.get(node.id) : undefined;
const grouped = groupContaining(groups, node.id) ? ' flow-node-grouped' : '';
return (
<div key={node.id}
class={nodeClass(node)}
class={`${nodeClass(node)}${selSet.has(node.id) && selSet.size > 1 ? ' flow-node-multi' : ''}${dbg ? ' ' + nodeDebugClass(dbg) : ''}${grouped}`}
title={nodeErrors.get(node.id) || undefined}
style={`left:${node.x}px; top:${node.y}px; width:${NODE_W}px; min-height:${nodeMinHeight(node, graph.wires)}px;`}
onMouseDown={(e) => startNodeDrag(e, node)}
onMouseUp={() => { if (pendingRef.current) finishWire(node, firstFreePort(node)); }}>
{dbg && (
<span class={`flow-node-badge${dbg.approx ? ' approx' : ''}`} title={badgeTitle(dbg)}>
{(dbg.approx ? '~' : '') + formatBadge(dbg)}
</span>
)}
<div class="flow-node-header">
<span class="flow-node-title">{node.kind === 'source' ? 'Signal' : node.kind === 'output' ? 'Output' : opLabel(node.op ?? '')}</span>
{node.kind !== 'output' && (
@@ -887,6 +1124,7 @@ export default function SyntheticGraphEditor({ name, create, panelId, onClose, o
</div>
);
})}
</div>
</div>
</div>
+9 -1
View File
@@ -12,6 +12,7 @@ import ZoomControl from './ZoomControl';
import ControlLogicEditor from './ControlLogicEditor';
import AuditViewer from './AuditViewer';
import ConfigManager from './ConfigManager';
import AdminPane from './AdminPane';
interface Props {
onEdit?: (iface?: Interface) => void;
@@ -36,13 +37,14 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
const [showControlLogic, setShowControlLogic] = useState(false);
const [showAudit, setShowAudit] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [showAdmin, setShowAdmin] = useState(false);
const [toolsOpen, setToolsOpen] = useState(false);
const [leftW, setLeftW] = useState(220);
const [listCollapsed, setListCollapsed] = useState(false);
const me = useAuth();
const writable = canWrite(me.level);
// Advanced tools collapsed into the Tools dropdown (shown only if ≥1 available).
const hasTools = (writable && me.canEditLogic) || me.canViewAudit || writable;
const hasTools = (writable && me.canEditLogic) || me.canViewAudit || writable || me.canAdmin;
function startResize(e: MouseEvent) {
e.preventDefault();
@@ -187,6 +189,9 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
{writable && (
<button class="ctx-item" onClick={() => setShowConfig(true)}>🗂 Config manager</button>
)}
{me.canAdmin && (
<button class="ctx-item" onClick={() => setShowAdmin(true)}>{'\u{1F464}\u{FE0E}'} Admin</button>
)}
</div>
)}
</div>
@@ -303,6 +308,9 @@ export default function ViewMode({ onEdit, initialInterface, onView }: Props) {
{showConfig && (
<ConfigManager onClose={() => setShowConfig(false)} />
)}
{showAdmin && (
<AdminPane onClose={() => setShowAdmin(false)} />
)}
</div>
);
}
+1
View File
@@ -18,6 +18,7 @@ const WIDGET_TYPES = [
{ type: 'toggle', label: 'Toggle Switch', desc: 'On/off switch control' },
{ type: 'button', label: 'Button', desc: 'Send command on click' },
{ type: 'plot', label: 'Plot', desc: 'Time-series / charts' },
{ type: 'table', label: 'Table', desc: 'Multi-signal value table' },
{ type: 'textlabel', label: 'Label', desc: 'Static text label' },
{ type: 'image', label: 'Image', desc: 'Image from URL' },
{ type: 'link', label: 'Link', desc: 'Navigate to interface' },
+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, canViewAudit: true };
export const DEFAULT_ME: Me = { user: '', level: 'write', groups: [], canEditLogic: true, canViewAudit: true, canAdmin: true };
// AuthContext carries the resolved identity + global access level for the
// current user throughout the app.
@@ -38,6 +38,7 @@ export async function fetchMe(): Promise<Me> {
groups: Array.isArray(data.groups) ? data.groups : [],
canEditLogic: data.canEditLogic !== false,
canViewAudit: data.canViewAudit !== false,
canAdmin: data.canAdmin !== false,
};
} catch {
return DEFAULT_ME;
+56
View File
@@ -0,0 +1,56 @@
import type { Widget } from './types';
// A widget is "inside" a container when its centre falls within the container's
// bounds. Containers are decorative frames that do not re-parent widgets, so
// membership is purely geometric.
export function centreInside(w: Widget, c: Widget): boolean {
const cx = w.x + w.w / 2, cy = w.y + w.h / 2;
return cx >= c.x && cx <= c.x + c.w && cy >= c.y && cy <= c.y + c.h;
}
// Expand a set of widget ids to also include every widget geometrically inside
// any container among them — transitively, so a moved container carries nested
// containers and their contents too. Used when starting a move (drag or arrow
// nudge) so dragging a container drags the widgets sitting on it. Membership is
// snapshotted by the caller at move start; widgets are never re-parented.
export function withContainedWidgets(ids: string[], widgets: Widget[]): string[] {
const byId = new Map(widgets.map(w => [w.id, w]));
const result = new Set(ids);
let changed = true;
while (changed) {
changed = false;
for (const id of [...result]) {
const c = byId.get(id);
if (!c || c.type !== 'container') continue;
for (const w of widgets) {
if (w.id !== c.id && !result.has(w.id) && centreInside(w, c)) {
result.add(w.id);
changed = true;
}
}
}
}
return [...result];
}
// The tab index a widget is assigned to (option 'tab', default 0).
export function widgetTab(w: Widget): number {
return parseInt(w.options['tab'] ?? '0', 10) || 0;
}
// Container panes operating in tabbed mode.
export function tabPanes(widgets: Widget[]): Widget[] {
return widgets.filter(w => w.type === 'container' && w.options['variant'] === 'tabs');
}
// The tabbed container that geometrically contains this widget, if any.
export function containingTabPane(w: Widget, widgets: Widget[]): Widget | null {
return tabPanes(widgets).find(c => c.id !== w.id && centreInside(w, c)) ?? null;
}
// Parse the comma-separated tab labels of a tabbed container (never empty).
export function tabLabels(c: Widget): string[] {
const t = (c.options['tabs'] ?? 'Tab 1,Tab 2')
.split(',').map(s => s.trim()).filter(s => s !== '');
return t.length ? t : ['Tab 1'];
}
+91
View File
@@ -0,0 +1,91 @@
// Shared live/debug helpers for the three visual node-graph editors (LogicEditor,
// ControlLogicEditor, SyntheticGraphEditor). When an editor's "Debug" toggle is on,
// the graph is evaluated online and each node shows a small value badge plus an
// "active" highlight, refreshed at human speed (~0.5 s). The per-node values come
// from different backends per editor (synthetic = server trace endpoint, panel =
// a client-side dry-run engine, control = a server WS push), but they all funnel
// into the same `DebugSnapshot` shape and the same rendering helpers below.
import { useState, useEffect, useRef } from 'preact/hooks';
/** Live state for a single node while debug mode is on. */
export interface NodeDebugState {
value?: any; // last computed value (scalar number, array, bool, string)
type?: 'scalar' | 'array';
approx?: boolean; // value is a rough single-shot estimate (stateful DSP nodes)
error?: string; // evaluation error for this node
active?: boolean; // node fired / produced a value this tick
}
/** Per-node debug state, keyed by node id. */
export type DebugSnapshot = Map<string, NodeDebugState>;
/** Round-trips a value into a compact label for the on-node badge. */
export function formatBadge(s: NodeDebugState | undefined): string {
if (!s) return '';
if (s.error) return '!';
const v = s.value;
if (v == null) return '—';
if (Array.isArray(v)) return `[${v.length}]`;
if (typeof v === 'number') {
if (!isFinite(v)) return String(v);
if (Number.isInteger(v)) return String(v);
return v.toPrecision(4).replace(/\.?0+$/, '');
}
if (typeof v === 'boolean') return v ? 'true' : 'false';
const str = String(v);
return str.length > 10 ? str.slice(0, 9) + '…' : str;
}
/** Full, untruncated value for the badge's `title` tooltip. */
export function badgeTitle(s: NodeDebugState | undefined): string {
if (!s) return '';
if (s.error) return s.error;
const v = s.value;
if (Array.isArray(v)) return `[${v.map(n => (typeof n === 'number' ? n.toPrecision(6) : String(n))).join(', ')}]`;
return v == null ? '' : String(v);
}
/** Extra class(es) for a node element given its current debug state. */
export function nodeDebugClass(s: NodeDebugState | undefined): string {
if (!s) return '';
if (s.error) return 'flow-node-debug-error';
if (s.active) return 'flow-node-active';
return '';
}
/**
* Polls `fetcher` every `intervalMs` while `enabled`, returning the latest
* snapshot (empty map when disabled). Used by editors whose backend is a
* request/response trace (synthetic) or any pull-based source. Overlapping
* polls are prevented; a poll that resolves after the hook is disabled/unmounted
* is discarded.
*/
export function useFlowDebug(
enabled: boolean,
fetcher: () => Promise<DebugSnapshot | null>,
intervalMs = 500,
): DebugSnapshot {
const [snap, setSnap] = useState<DebugSnapshot>(() => new Map());
const fetcherRef = useRef(fetcher);
fetcherRef.current = fetcher;
useEffect(() => {
if (!enabled) { setSnap(new Map()); return; }
let alive = true;
let inFlight = false;
const tick = async () => {
if (inFlight) return;
inFlight = true;
try {
const next = await fetcherRef.current();
if (alive && next) setSnap(next);
} catch { /* transient; next tick retries */ }
finally { inFlight = false; }
};
tick();
const h = setInterval(tick, intervalMs);
return () => { alive = false; clearInterval(h); };
}, [enabled, intervalMs]);
return snap;
}
+96
View File
@@ -0,0 +1,96 @@
// Shared pan/zoom helpers for the three visual node-graph editors (LogicEditor,
// ControlLogicEditor, SyntheticGraphEditor). Zoom is implemented as a CSS
// `transform: scale()` on an inner "zoom layer"; the outer `.flow-canvas-inner`
// is sized to the scaled dimensions so the scroll container reports the right
// scrollable area. Node coordinates stay in unscaled ("logical") space — the
// editors' `toCanvas` divides pointer offsets by the current zoom.
import { useState, useRef, useEffect, useCallback } from 'preact/hooks';
export const ZOOM_MIN = 0.3;
export const ZOOM_MAX = 2.5;
export const ZOOM_FACTOR = 1.25; // multiplier per zoom-in / -out step
export const clampZoom = (z: number) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
export interface Box { x: number; y: number; w: number; h: number; }
/** Axis-aligned bounding box over a set of rectangles (null when empty). */
export function contentBounds(rects: Box[]): Box | null {
if (rects.length === 0) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const r of rects) {
minX = Math.min(minX, r.x); minY = Math.min(minY, r.y);
maxX = Math.max(maxX, r.x + r.w); maxY = Math.max(maxY, r.y + r.h);
}
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}
interface ZoomScroll { zoom: number; scrollX: number; scrollY: number; }
/** New zoom + scroll that keeps the viewport centre fixed while scaling. */
export function zoomAnchored(z0: number, factor: number, scrollX: number, scrollY: number, vw: number, vh: number): ZoomScroll {
const z1 = clampZoom(z0 * factor);
const cx = (scrollX + vw / 2) / z0;
const cy = (scrollY + vh / 2) / z0;
return { zoom: z1, scrollX: Math.max(0, cx * z1 - vw / 2), scrollY: Math.max(0, cy * z1 - vh / 2) };
}
/** Zoom + scroll so `content` is centred and fits within the viewport. */
export function zoomToFit(content: Box, vw: number, vh: number, pad = 48): ZoomScroll {
const z = clampZoom(Math.min(vw / (content.w + 2 * pad), vh / (content.h + 2 * pad)));
return {
zoom: z,
scrollX: Math.max(0, (content.x + content.w / 2) * z - vw / 2),
scrollY: Math.max(0, (content.y + content.h / 2) * z - vh / 2),
};
}
/**
* Zoom controller shared by all three editors. `zoomRef` is kept in sync with
* the live zoom so each editor's `toCanvas` can read it without stale closures.
* `getRects` returns the current node rectangles (logical coords) for fit.
*/
export function useFlowZoom(
canvasRef: { current: HTMLDivElement | null },
zoomRef: { current: number },
baseW: number,
baseH: number,
getRects: () => Box[],
) {
const [zoom, setZoom] = useState(1);
zoomRef.current = zoom;
// Scroll target applied after the zoomed layout commits.
const pending = useRef<{ x: number; y: number } | null>(null);
useEffect(() => {
const el = canvasRef.current;
if (el && pending.current) {
el.scrollLeft = pending.current.x;
el.scrollTop = pending.current.y;
pending.current = null;
}
}, [zoom]);
const apply = (r: ZoomScroll) => { pending.current = { x: r.scrollX, y: r.scrollY }; setZoom(r.zoom); };
const zoomIn = useCallback(() => {
const el = canvasRef.current; if (!el) return;
apply(zoomAnchored(zoomRef.current, ZOOM_FACTOR, el.scrollLeft, el.scrollTop, el.clientWidth, el.clientHeight));
}, []);
const zoomOut = useCallback(() => {
const el = canvasRef.current; if (!el) return;
apply(zoomAnchored(zoomRef.current, 1 / ZOOM_FACTOR, el.scrollLeft, el.scrollTop, el.clientWidth, el.clientHeight));
}, []);
const zoomHome = useCallback(() => { apply({ zoom: 1, scrollX: 0, scrollY: 0 }); }, []);
const zoomFit = useCallback(() => {
const el = canvasRef.current; if (!el) return;
const b = contentBounds(getRects());
if (!b) { apply({ zoom: 1, scrollX: 0, scrollY: 0 }); return; }
apply(zoomToFit(b, el.clientWidth, el.clientHeight));
}, [getRects]);
// Outer sizer reserves the scaled scroll area; inner layer carries the scale.
const innerStyle = `width:${baseW * zoom}px; height:${baseH * zoom}px;`;
const zoomStyle = `width:${baseW}px; height:${baseH}px; transform: scale(${zoom}); transform-origin: 0 0;`;
return { zoom, zoomIn, zoomOut, zoomHome, zoomFit, innerStyle, zoomStyle };
}
+71 -18
View File
@@ -204,7 +204,7 @@ interface WireOut { to: string; port: string }
// expression resolver that exposes the system signals {sys:time}/{sys:dt}.
interface RunCtx { firedTrigger: string; steps: { n: number }; dt: number; resolve: Resolver }
class LogicEngine {
export class LogicEngine {
private graph: LogicGraph = { nodes: [], wires: [] };
private byId = new Map<string, LogicNode>();
// Outgoing wires grouped by source node id.
@@ -227,6 +227,37 @@ class LogicEngine {
private lastFire = new Map<string, number>();
private cleanups: Array<() => void> = [];
// Dry-run / debug mode: when true the engine performs no external side effects
// (signal writes, config mutations, CSV exports, widget commands, dialogs) and
// instead records each node's last value/firing time so an editor overlay can
// visualise the flow without touching production. Used by a throwaway engine
// instance spun up by the Logic editor; the view-mode singleton leaves it off.
private dryRun = false;
private debugStates = new Map<string, { value: any; ts: number }>();
/** Enable/disable dry-run mode. Must be set before load(). */
setDryRun(on: boolean): void { this.dryRun = on; }
// Record that a node fired this tick, optionally capturing its computed value.
private markActive(id: string, value?: any): void {
if (!this.dryRun) return;
const prev = this.debugStates.get(id);
this.debugStates.set(id, { value: value !== undefined ? value : prev?.value, ts: Date.now() });
}
/** Per-node debug snapshot: a node is "active" if it fired within ~0.8 s. */
getDebug(): Map<string, { value: any; active: boolean }> {
const now = Date.now();
const out = new Map<string, { value: any; active: boolean }>();
for (const [id, s] of this.debugStates) out.set(id, { value: s.value, active: now - s.ts < 800 });
return out;
}
// Suppress signal writes in dry-run; otherwise forward to the live client.
private dryWrite(ref: SignalRef, val: any): void {
if (!this.dryRun) wsClient.write(ref, val);
}
// Resolve an expression reference. Two built-in system signals are served
// synthetically (never subscribed): {sys:time} = current epoch seconds,
// {sys:dt} = seconds since the firing trigger last fired. Everything else
@@ -320,9 +351,11 @@ class LogicEngine {
this.watchers = new Map();
this.arrays = new Map();
this.lastFire = new Map();
this.debugStates = new Map();
// Restore every widget to its default state so reopening a panel (whose
// widget ids are stable) does not inherit disabled/hidden/paused flags.
resetWidgetCmds();
// widget ids are stable) does not inherit disabled/hidden/paused flags. The
// dry-run editor engine must not touch the real widget command stores.
if (!this.dryRun) resetWidgetCmds();
}
/** Fire every button-trigger node whose `name` param matches. */
@@ -435,12 +468,22 @@ class LogicEngine {
// ── Execution ───────────────────────────────────────────────────────────────
/** Manually fire a trigger node (debug mode: double-click to force a flow
* run). No-op if the id isn't a trigger node. */
fireTrigger(nodeId: string): boolean {
const node = this.byId.get(nodeId);
if (!node || !node.kind.startsWith('trigger.')) return false;
this.activate(nodeId);
return true;
}
// A trigger activated: walk its outgoing 'out' wires.
private activate(triggerId: string): void {
const now = Date.now();
const last = this.lastFire.get(triggerId);
const dt = last === undefined ? 0 : (now - last) / 1000;
this.lastFire.set(triggerId, now);
this.markActive(triggerId);
const ctx: RunCtx = {
firedTrigger: triggerId,
steps: { n: 0 },
@@ -462,15 +505,20 @@ class LogicEngine {
if (ctx.steps.n++ > MAX_STEPS) return;
const node = this.byId.get(nodeId);
if (!node) return;
this.markActive(node.id);
switch (node.kind) {
case 'gate.and':
if (this.gateSatisfied(node.id, ctx.firedTrigger)) await this.follow(node.id, 'out', ctx);
case 'gate.and': {
const ok = this.gateSatisfied(node.id, ctx.firedTrigger);
this.markActive(node.id, ok);
if (ok) await this.follow(node.id, 'out', ctx);
return;
}
case 'flow.if': {
const branch = evalBool(node.params.cond ?? '', ctx.resolve) ? 'then' : 'else';
await this.follow(node.id, branch, ctx);
const pass = evalBool(node.params.cond ?? '', ctx.resolve);
this.markActive(node.id, pass);
await this.follow(node.id, pass ? 'then' : 'else', ctx);
return;
}
@@ -493,14 +541,15 @@ class LogicEngine {
case 'action.write': {
const ref = parseRef(node.params.target ?? '');
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
if (ref && !isNaN(val)) wsClient.write(ref, val);
this.markActive(node.id, val);
if (ref && !isNaN(val)) this.dryWrite(ref, val);
await this.follow(node.id, 'out', ctx);
return;
}
case 'action.config.apply': {
const id = this.resolveInstanceId(node);
if (id) {
if (id && !this.dryRun) {
try {
await fetch(`/api/v1/config/instances/${encodeURIComponent(id)}/apply`, { method: 'POST' });
} catch {}
@@ -513,9 +562,9 @@ class LogicEngine {
const id = this.resolveInstanceId(node);
const key = (node.params.key ?? '').trim();
const ref = parseRef(node.params.target ?? '');
if (id && key && ref) {
if (id && key && ref && !this.dryRun) {
const val = await readConfigParam(id, key);
if (val !== null && !isNaN(val)) wsClient.write(ref, val);
if (val !== null && !isNaN(val)) this.dryWrite(ref, val);
}
await this.follow(node.id, 'out', ctx);
return;
@@ -525,7 +574,7 @@ class LogicEngine {
const id = this.resolveInstanceId(node);
const key = (node.params.key ?? '').trim();
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
if (id && key && !isNaN(val)) {
if (id && key && !isNaN(val) && !this.dryRun) {
await writeConfigParam(id, key, val);
}
await this.follow(node.id, 'out', ctx);
@@ -537,9 +586,9 @@ class LogicEngine {
const name = (node.params.name ?? '').trim() || 'auto';
const fromId = (node.params.from ?? '').trim();
const target = parseRef(node.params.target ?? '');
if (setId) {
if (setId && !this.dryRun) {
const newId = await createConfigInstance(setId, name, fromId);
if (newId && target) wsClient.write(target, newId);
if (newId && target) this.dryWrite(target, newId);
}
await this.follow(node.id, 'out', ctx);
return;
@@ -549,9 +598,9 @@ class LogicEngine {
const setId = (node.params.set ?? '').trim();
const name = (node.params.name ?? '').trim();
const target = parseRef(node.params.target ?? '');
if (setId) {
if (setId && !this.dryRun) {
const newId = await snapshotConfigSet(setId, name);
if (newId && target) wsClient.write(target, newId);
if (newId && target) this.dryWrite(target, newId);
}
await this.follow(node.id, 'out', ctx);
return;
@@ -565,6 +614,7 @@ class LogicEngine {
case 'action.accumulate': {
const name = (node.params.array ?? '').trim();
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
this.markActive(node.id, val);
if (name && !isNaN(val)) {
const arr = this.arrays.get(name) ?? this.arrays.set(name, []).get(name)!;
arr.push({ t: Date.now(), v: val });
@@ -574,7 +624,7 @@ class LogicEngine {
}
case 'action.export': {
this.exportArrays(this.exportColumns(node), node.params.align ?? 'common', node.params.filename ?? '');
if (!this.dryRun) this.exportArrays(this.exportColumns(node), node.params.align ?? 'common', node.params.filename ?? '');
await this.follow(node.id, 'out', ctx);
return;
}
@@ -589,6 +639,7 @@ class LogicEngine {
case 'action.log': {
const val = evalExpr(node.params.expr ?? '', ctx.resolve);
const label = (node.params.label ?? '').trim();
this.markActive(node.id, val);
console.log(`[logic]${label ? ' ' + label + ':' : ''}`, val);
await this.follow(node.id, 'out', ctx);
return;
@@ -596,7 +647,7 @@ class LogicEngine {
case 'action.widget': {
const wid = (node.params.widget ?? '').trim();
if (wid) {
if (wid && !this.dryRun) {
switch (node.params.op ?? '') {
case 'enable': setWidgetDisabled(wid, false); break;
case 'disable': setWidgetDisabled(wid, true); break;
@@ -613,6 +664,7 @@ class LogicEngine {
case 'action.dialog.info':
case 'action.dialog.error': {
if (this.dryRun) { await this.follow(node.id, 'out', ctx); return; }
const specs = this.dialogFieldSpecs(node);
await this.showDialog({
kind: node.kind === 'action.dialog.error' ? 'error' : 'info',
@@ -625,6 +677,7 @@ class LogicEngine {
}
case 'action.dialog.setpoint': {
if (this.dryRun) { await this.follow(node.id, 'out', ctx); return; }
const specs = this.dialogFieldSpecs(node);
const result = await this.showDialog({
kind: 'setpoint',
+71
View File
@@ -0,0 +1,71 @@
// Node grouping & collapsing — shared model + geometry used by all three visual
// node editors (LogicEditor, ControlLogicEditor, SyntheticGraphEditor).
//
// A group is purely a cosmetic, editor-side annotation: it references member
// node ids, carries an optional label, and can be collapsed to hide its members
// behind a compact box. Groups never change how a graph evaluates — the backends
// store and round-trip them but ignore them when compiling/running the graph.
export interface NodeGroup {
id: string;
label: string;
members: string[]; // ids of member nodes
collapsed: boolean;
}
export interface Rect { x: number; y: number; w: number; h: number; }
export function genGroupId(): string {
return 'grp_' + Math.random().toString(36).slice(2, 9);
}
// The group (if any) that a node belongs to. A node lives in at most one group.
export function groupContaining(
groups: NodeGroup[] | undefined,
nodeId: string,
): NodeGroup | undefined {
return (groups ?? []).find(g => g.members.includes(nodeId));
}
// Drop ids that no longer exist (deleted nodes) and discard groups left empty.
export function pruneGroups(
groups: NodeGroup[] | undefined,
existingIds: Set<string>,
): NodeGroup[] {
return (groups ?? [])
.map(g => ({ ...g, members: g.members.filter(id => existingIds.has(id)) }))
.filter(g => g.members.length > 0);
}
// Axis-aligned bounding box around the member node rects, expanded by `pad` on
// every side and by `header` extra pixels on top (room for the title bar).
// Returns null when no member resolves to a rect.
export function groupBounds(
members: string[],
rectOf: (id: string) => Rect | null,
pad: number,
header: number,
): Rect | null {
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const id of members) {
const r = rectOf(id);
if (!r) continue;
if (r.x < minX) minX = r.x;
if (r.y < minY) minY = r.y;
if (r.x + r.w > maxX) maxX = r.x + r.w;
if (r.y + r.h > maxY) maxY = r.y + r.h;
}
if (!isFinite(minX)) return null;
return {
x: minX - pad,
y: minY - pad - header,
w: (maxX - minX) + pad * 2,
h: (maxY - minY) + pad * 2 + header,
};
}
// The compact box shown in place of a collapsed group, anchored at the group's
// top-left corner.
export function collapsedRect(bounds: Rect, width: number, height: number): Rect {
return { x: bounds.x, y: bounds.y, w: width, h: height };
}
+11
View File
@@ -1,3 +1,6 @@
import type { NodeGroup } from './nodeGroups';
export type { NodeGroup };
// Core signal reference: identifies a signal by datasource + name
export interface SignalRef {
ds: string;
@@ -181,6 +184,7 @@ export interface LogicWire {
export interface LogicGraph {
nodes: LogicNode[];
wires: LogicWire[];
groups?: NodeGroup[];
}
// A complete HMI interface definition
@@ -220,6 +224,10 @@ export interface Me {
// 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;
// Whether the user may use the admin pane (manage users/groups/access and view
// server statistics). False only when an admins allowlist is configured and
// excludes them.
canAdmin: boolean;
}
// Effective permission a user holds on a single panel or folder.
@@ -262,6 +270,8 @@ export interface InterfaceListItem {
id: string;
name: string;
version: number;
// 'plot' for split-layout plot panels; absent/'panel' for free-form panels.
kind?: 'panel' | 'plot';
owner?: string;
folder?: string;
order?: number;
@@ -298,6 +308,7 @@ export interface SynthGraphNode {
export interface SynthGraph {
nodes: SynthGraphNode[];
output: string;
groups?: NodeGroup[];
}
export interface SignalDef {
+65
View File
@@ -12,6 +12,25 @@ interface SubscriberEntry {
onMeta: (m: SignalMeta) => void;
}
// A control-logic node-execution event pushed by the server while a debug
// session is active (live observation or dry-run simulate).
export interface DebugNodeEvent {
graphId: string;
nodeId: string;
value: number;
hasValue: boolean;
ts: number;
}
// A control-logic graph payload sent for dry-run simulation.
export interface DebugGraph {
id?: string;
name?: string;
nodes: any[];
wires: any[];
groups?: any[];
}
// ── WsClient ─────────────────────────────────────────────────────────────────
class WsClient {
@@ -35,6 +54,8 @@ class WsClient {
private subscribers = new Map<string, Set<SubscriberEntry>>();
// pending history request callbacks keyed by "ds\0name"
private histCallbacks = new Map<string, Array<(pts: HistoryPoint[]) => void>>();
// single control-logic debug listener (the editor currently watching)
private debugListener: ((ev: DebugNodeEvent) => void) | null = null;
connect(url: string): void {
this.url = url;
@@ -174,6 +195,17 @@ class WsClient {
});
break;
}
case 'debugNode': {
// Server-side control-logic node execution (live or simulate).
this.debugListener?.({
graphId: String(msg.graphId ?? ''),
nodeId: String(msg.nodeId ?? ''),
value: typeof msg.value === 'number' ? msg.value : 0,
hasValue: !!msg.hasValue,
ts: typeof msg.ts === 'number' ? msg.ts : Date.now(),
});
break;
}
case 'error':
// Resolve any pending history callbacks with empty data on error
if (key && this.histCallbacks.has(key)) {
@@ -289,6 +321,39 @@ class WsClient {
sendDialogResponse(id: string, value: number | null): void {
this._send({ type: 'dialogResponse', id, value });
}
// ── Control-logic debug ──────────────────────────────────────────────────
/** Register the listener that receives node-execution events. Returns a
* cleanup that clears it (only one editor watches at a time). */
onDebugNode(cb: (ev: DebugNodeEvent) => void): () => void {
this.debugListener = cb;
return () => {
if (this.debugListener === cb) this.debugListener = null;
};
}
/** Observe the running, enabled control-logic graph identified by graphId. */
debugSubscribeLive(graphId: string): void {
this._send({ type: 'debugSubscribe', mode: 'live', graphId });
}
/** Dry-run an unsaved control-logic graph in a server sandbox (no real
* writes); node events come back as debugNode messages. */
debugSubscribeSimulate(graph: DebugGraph): void {
this._send({ type: 'debugSubscribe', mode: 'simulate', graph });
}
/** Stop the current debug session (tears down any simulate sandbox). */
debugUnsubscribe(): void {
this._send({ type: 'debugUnsubscribe' });
}
/** Force a trigger node of the current debug session (live or simulate) to
* fire now. nodeId must be a trigger node in the graph being debugged. */
debugFireTrigger(nodeId: string): void {
this._send({ type: 'fireTrigger', nodeId });
}
}
// Singleton instance
+26 -2
View File
@@ -27,6 +27,14 @@ function serializeLogic(graph: LogicGraph, lines: string[]): void {
? ` port="${xmlEsc(wire.fromPort)}"` : '';
lines.push(` <wire from="${xmlEsc(wire.from)}"${portAttr} to="${xmlEsc(wire.to)}"/>`);
}
for (const group of graph.groups ?? []) {
const collAttr = group.collapsed ? ` collapsed="1"` : '';
lines.push(` <group id="${xmlEsc(group.id)}" label="${xmlEsc(group.label)}"${collAttr}>`);
for (const member of group.members) {
lines.push(` <member id="${xmlEsc(member)}"/>`);
}
lines.push(` </group>`);
}
lines.push(` </logic>`);
}
@@ -119,9 +127,25 @@ function parseLayout(el: Element): PlotLayout {
function parseLogic(logicEl: Element): LogicGraph {
const nodes: LogicGraph['nodes'] = [];
const wires: LogicGraph['wires'] = [];
const groups: NonNullable<LogicGraph['groups']> = [];
for (const el of Array.from(logicEl.children)) {
if (el.tagName === 'node') {
if (el.tagName === 'group') {
const id = el.getAttribute('id') ?? '';
if (!id) continue;
const members: string[] = [];
for (const child of Array.from(el.children)) {
if (child.tagName !== 'member') continue;
const mid = child.getAttribute('id');
if (mid) members.push(mid);
}
groups.push({
id,
label: el.getAttribute('label') ?? '',
members,
collapsed: el.getAttribute('collapsed') === '1',
});
} else if (el.tagName === 'node') {
const id = el.getAttribute('id') ?? '';
if (!id) continue;
const params: Record<string, string> = {};
@@ -146,7 +170,7 @@ function parseLogic(logicEl: Element): LogicGraph {
}
}
return { nodes, wires };
return { nodes, wires, ...(groups.length ? { groups } : {}) };
}
export function parseInterface(xml: string): Interface {
+539 -3
View File
@@ -408,7 +408,8 @@ body {
.canvas-view-bare .multiled-widget,
.canvas-view-bare .setvalue,
.canvas-view-bare .toggle-widget,
.canvas-view-bare .plot-widget {
.canvas-view-bare .plot-widget,
.canvas-view-bare .table-widget {
background: transparent;
border-color: transparent;
box-shadow: none;
@@ -457,6 +458,178 @@ body {
font-family: ui-monospace, monospace;
}
/* ── Container pane (decorative grouping frame) ────────────────────────── */
.container-pane {
position: absolute;
box-sizing: border-box;
background: rgba(30, 37, 53, 0.45);
border: 1px solid #3d4f6e;
border-radius: 6px;
}
.container-pane-nobg {
background: transparent;
}
.container-pane-title {
display: flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 8px;
border-bottom: 1px solid #3d4f6e;
box-sizing: border-box;
}
.container-pane-label {
color: #cbd5e1;
font-size: 0.8rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.container-collapse-btn {
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
font-size: 0.7rem;
line-height: 1;
padding: 0 2px;
}
.container-collapse-btn:hover {
color: #e2e8f0;
}
/* Tab bar sits above the edit-mode widget overlay (z-index 10) so its tabs
stay clickable in the editor; the rest of the pane is still draggable. */
.container-tab-bar {
position: relative;
z-index: 11;
display: flex;
align-items: stretch;
gap: 2px;
height: 28px;
padding: 0 4px;
border-bottom: 1px solid #3d4f6e;
box-sizing: border-box;
overflow: hidden;
}
.container-tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
color: #94a3b8;
cursor: pointer;
font-size: 0.78rem;
padding: 0 10px;
white-space: nowrap;
}
.container-tab:hover {
color: #cbd5e1;
}
.container-tab-active {
color: #e2e8f0;
font-weight: 600;
}
/* ── Table widget ──────────────────────────────────────────────────────── */
.table-widget {
position: absolute;
display: flex;
flex-direction: column;
background: #1a1f2e;
border: 1px solid #2d3748;
border-radius: 4px;
box-sizing: border-box;
overflow: hidden;
}
.tw-title {
padding: 4px 8px;
font-size: 0.8rem;
font-weight: 600;
color: #cbd5e1;
border-bottom: 1px solid #2d3748;
flex-shrink: 0;
}
.tw-scroll {
flex: 1;
overflow: auto;
}
.tw-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.tw-table th {
position: sticky;
top: 0;
background: #232a3b;
color: #94a3b8;
font-weight: 600;
text-align: left;
padding: 3px 8px;
border-bottom: 1px solid #2d3748;
white-space: nowrap;
}
.tw-table td {
padding: 3px 8px;
border-bottom: 1px solid #232a3b;
white-space: nowrap;
}
.tw-table tbody tr:hover {
background: #1e2535;
}
.tw-name {
color: #cbd5e1;
overflow: hidden;
text-overflow: ellipsis;
max-width: 0;
}
.tw-value {
font-family: ui-monospace, Consolas, monospace;
color: #e2e8f0;
text-align: right;
}
.tw-unit { color: #64748b; }
.tw-time { color: #64748b; font-variant-numeric: tabular-nums; }
.tw-quality { text-align: center; width: 1px; }
.tw-empty {
padding: 10px 8px;
color: #475569;
font-style: italic;
text-align: center;
}
/* ── Properties: checkbox list ─────────────────────────────────────────── */
.prop-check {
display: flex;
align-items: center;
gap: 6px;
color: #cbd5e1;
font-size: 0.8rem;
cursor: pointer;
}
/* ── Context Menu ──────────────────────────────────────────────────────── */
.ctx-menu {
@@ -1408,10 +1581,22 @@ body {
.flow-palette-toolbar {
display: flex;
gap: 0.35rem;
gap: 0.25rem;
margin-bottom: 0.15rem;
}
.flow-palette-toolbar .toolbar-btn { flex: 1; }
/* Equal-width buttons that may shrink below their content; the wide horizontal
padding of .toolbar-btn would otherwise overflow the fixed-width palette and
clip the last (fit-zoom) button. The slightly smaller font keeps the labels
readable once the buttons are squeezed. */
.flow-palette-toolbar .toolbar-btn { flex: 1 1 0; min-width: 0; padding-left: 0; padding-right: 0; font-size: 0.75rem; }
/* The zoom-value button shows "100%" and needs more room than the single-glyph
icon buttons next to it, otherwise the percentage overflows / clips. */
.flow-palette-toolbar .toolbar-btn.flow-zoom-pct { flex: 1.9 1 0; font-variant-numeric: tabular-nums; }
/* Live/debug toggle — green when active to signal the graph is being evaluated. */
.toolbar-btn.flow-debug-on { background: #047857; color: #ecfdf5; }
.toolbar-btn.flow-debug-on:hover { background: #059669; }
/* Live/Simulate mode sub-toggle shows a short word, so give it room to read. */
.flow-palette-toolbar .toolbar-btn.flow-debug-mode { flex: 1.6 1 0; }
.flow-palette-title {
font-size: 0.7rem;
@@ -1459,6 +1644,13 @@ body {
height: 87.5rem;
}
/* Zoom layer: scaled via CSS transform; the parent .flow-canvas-inner is sized
to the scaled dimensions so the scroll container reports the right area. */
.flow-canvas-zoom {
position: relative;
transform-origin: 0 0;
}
.flow-wires {
position: absolute;
top: 0; left: 0;
@@ -1499,9 +1691,104 @@ body {
.flow-node-flow { border-top: 3px solid #10b981; }
.flow-node-action { border-top: 3px solid #3b82f6; }
.flow-node-selected { border-color: #3b82f6; box-shadow: 0 0 0 2px #3b82f680; }
.flow-node-multi { border-color: #38bdf8; box-shadow: 0 0 0 2px #38bdf855; }
.flow-node-error { border-color: #ef4444; box-shadow: 0 0 0 2px #ef444455; }
.flow-node-error.flow-node-selected { box-shadow: 0 0 0 2px #ef4444aa; }
/* Live/debug mode (shared by all three editors). A node that produced a value
this tick gets a soft green pulse; a node whose evaluation errored gets an
amber tint distinct from the red .flow-node-error (which means invalid wiring). */
.flow-node-active { border-color: #10b981; box-shadow: 0 0 0 2px #10b98155; animation: flow-node-pulse 1s ease-in-out infinite; }
.flow-node-debug-error { border-color: #f59e0b; box-shadow: 0 0 0 2px #f59e0b55; }
@keyframes flow-node-pulse {
0%, 100% { box-shadow: 0 0 0 2px #10b98140; }
50% { box-shadow: 0 0 0 3px #10b98180; }
}
/* In debug mode, trigger nodes can be double-clicked to force a flow run. */
.flow-node-firable { cursor: pointer; }
.flow-node-firable:hover { border-color: #38bdf8; box-shadow: 0 0 0 2px #38bdf855; }
/* On-node value chip, pinned to the node's top-right corner. */
.flow-node-badge {
position: absolute;
top: -0.6rem;
right: -0.3rem;
max-width: 5rem;
padding: 0.05rem 0.35rem;
border-radius: 0.6rem;
background: #0b3b2e;
border: 1px solid #10b981;
color: #d1fae5;
font-size: 0.62rem;
font-variant-numeric: tabular-nums;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
z-index: 2;
}
.flow-node-badge.approx { background: #3b2f0b; border-color: #f59e0b; color: #fde68a; font-style: italic; }
.flow-node-badge.error { background: #3b0b0b; border-color: #ef4444; color: #fecaca; }
/* Wire carrying live data this tick — animated dashes flowing source→target. */
.flow-wire-active { stroke: #10b981; stroke-dasharray: 6 4; animation: flow-wire-dash 0.6s linear infinite; }
@keyframes flow-wire-dash { to { stroke-dashoffset: -20; } }
/* Node groups — a labelled frame drawn around member nodes; collapses to a box.
An opened group (and its members) is lifted above loose nodes via z-index so
its header/border are never obscured by unrelated nodes drawn on top: the
frame sits at z-index 1 (above loose nodes), and its member nodes get
.flow-node-grouped at z-index 2 so they still draw above their own frame and
stay interactive. */
.flow-group-frame {
position: absolute;
border: 1.5px dashed #475569;
border-radius: 8px;
background: #94a3b80f;
cursor: move;
user-select: none;
z-index: 1;
}
.flow-group-box {
position: absolute;
border: 1.5px solid #475569;
border-radius: 8px;
background: #161b27;
box-shadow: 0 2px 6px #0006;
cursor: move;
user-select: none;
overflow: hidden;
z-index: 1;
}
.flow-node-grouped { z-index: 2; }
.flow-group-selected { border-color: #3b82f6; box-shadow: 0 0 0 2px #3b82f655; }
.flow-group-header {
display: flex;
align-items: center;
gap: 0.25rem;
height: 1.65rem;
padding: 0 0.35rem;
}
.flow-group-caret {
background: none; border: none; color: #94a3b8;
cursor: pointer; font-size: 0.75rem; padding: 0 0.15rem; line-height: 1;
}
.flow-group-caret:hover { color: #e2e8f0; }
.flow-group-label {
flex: 1; min-width: 0;
background: transparent; border: none; outline: none;
color: #cbd5e1; font-size: 0.8rem; font-weight: 600;
}
.flow-group-label:focus { color: #fff; }
.flow-group-del {
background: none; border: none; color: #64748b;
cursor: pointer; font-size: 0.7rem; padding: 0 0.15rem; line-height: 1;
}
.flow-group-del:hover { color: #ef4444; }
.flow-group-count { padding: 0.1rem 0.6rem 0.35rem; font-size: 0.7rem; }
.flow-node-header {
display: flex;
align-items: center;
@@ -2574,6 +2861,66 @@ body {
margin: 0 4px;
}
/* Disabled rule: dim the list row and tag it with an "off" badge. */
.cl-list-item.cfg-rule-disabled .cl-list-name { opacity: 0.55; }
.cfg-rule-off {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #f0a868;
border: 1px solid #6b4a2e;
border-radius: 3px;
padding: 0 4px;
margin-right: 6px;
}
/* Enabled toggle in the rule editor param row. */
.cfg-field-enabled { min-width: 11em; }
.cfg-rule-enable {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
cursor: pointer;
}
.cfg-rule-enable input { cursor: pointer; }
/* Live preview panel (run rule against a live snapshot). */
.cfg-rule-preview {
margin-top: 8px;
padding: 8px 10px;
border: 1px solid #2a3550;
border-radius: 4px;
background: #111726;
}
.cfg-rule-preview-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.cfg-rule-preview-head .cl-mini-btn { margin-left: auto; }
.cfg-rule-preview-failed { margin: 4px 0; color: #f0a868; }
.cfg-rule-preview-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.cfg-rule-preview-col { min-width: 0; }
.cfg-json {
margin: 2px 0 0;
padding: 6px 8px;
background: #0b0f1a;
border: 1px solid #1f2940;
border-radius: 4px;
font-family: ui-monospace, monospace;
font-size: 11px;
line-height: 1.45;
max-height: 16em;
overflow: auto;
white-space: pre;
}
/* wizard is already wide enough and resizable — no extra :has() rule needed */
/* ── Synthetic pipeline ─────────────────────────────────────────────────────── */
@@ -2755,6 +3102,14 @@ body {
cursor: pointer;
}
.iface-item-icon {
display: inline-flex;
align-items: center;
margin-right: 0.4rem;
vertical-align: -2px;
opacity: 0.7;
}
.iface-item-actions {
display: none;
gap: 2px;
@@ -4084,15 +4439,21 @@ kbd {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #2d3748;
font-weight: 600;
color: #94a3b8;
font-size: 0.85rem;
}
.cl-list-head .audit-filter { flex: 1 1 auto; min-width: 0; }
.cl-list-head .panel-btn { flex: 0 0 auto; }
.cl-list-empty { padding: 0.75rem; font-size: 0.8rem; }
.cl-list-filter { padding: 0.4rem 0.5rem; border-bottom: 1px solid #2d3748; }
.cl-list-filter .prop-select { width: 100%; }
.cl-list-item {
display: flex;
align-items: center;
@@ -4143,6 +4504,181 @@ kbd {
font-size: 0.85rem;
}
/* ── Admin pane ─────────────────────────────────────────────────────────── */
.admin-empty {
padding: 1.5rem;
font-size: 0.85rem;
}
.admin-editor {
padding: 1rem 1.25rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.admin-editor-title {
margin: 0 0 0.5rem;
font-size: 1rem;
color: #e2e8f0;
}
.admin-field-label {
margin-top: 0.6rem;
font-size: 0.78rem;
font-weight: 600;
color: #94a3b8;
}
.admin-input {
background: #0f1521;
border: 1px solid #2d3748;
border-radius: 4px;
color: #e2e8f0;
padding: 0.4rem 0.55rem;
font-size: 0.82rem;
font-family: inherit;
}
.admin-editor-actions {
margin-top: 1rem;
display: flex;
gap: 0.6rem;
}
.admin-banner {
margin: 0.5rem 1rem 0;
padding: 0.55rem 0.75rem;
border: 1px solid rgba(234, 179, 8, 0.35);
background: rgba(234, 179, 8, 0.1);
border-radius: 4px;
font-size: 0.78rem;
line-height: 1.4;
}
/* Effective/per-group role pill, coloured along the ladder. */
.admin-role-tag {
margin-left: auto;
font-size: 0.68rem;
padding: 0.05rem 0.4rem;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: nowrap;
}
.admin-role-viewer { background: rgba(148, 163, 184, 0.18); color: #cbd5e1; }
.admin-role-operator { background: rgba(34, 197, 94, 0.18); color: #4ade80; }
.admin-role-logiceditor { background: rgba(56, 189, 248, 0.18); color: #38bdf8; }
.admin-role-auditor { background: rgba(168, 85, 247, 0.18); color: #c084fc; }
.admin-role-admin { background: rgba(234, 88, 12, 0.2); color: #fb923c; }
/* Per-group role assignment table (users & group-member editors). */
.admin-role-table {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-top: 0.35rem;
}
.admin-role-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.admin-role-group {
flex: 1 1 auto;
min-width: 0;
font-size: 0.82rem;
color: #cbd5e1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-tree-mark {
color: #64748b;
}
.admin-role-select {
flex: 0 0 auto;
min-width: 9rem;
}
.admin-member-add {
display: flex;
gap: 0.4rem;
margin-top: 0.35rem;
}
.admin-member-add .admin-input {
flex: 1 1 auto;
min-width: 0;
}
.admin-member-add .panel-btn {
flex: 0 0 auto;
}
.admin-row-remove {
flex: 0 0 auto;
background: none;
border: none;
color: #94a3b8;
cursor: pointer;
font-size: 0.8rem;
padding: 0.2rem 0.35rem;
border-radius: 3px;
}
.admin-row-remove:hover {
color: #f87171;
background: rgba(220, 38, 38, 0.12);
}
.admin-stats {
padding: 1rem 1.25rem;
overflow-y: auto;
}
.admin-stats-table {
max-width: 32rem;
}
.admin-stat-key {
color: #94a3b8;
font-weight: 600;
width: 14rem;
}
.admin-stat-val {
color: #e2e8f0;
font-variant-numeric: tabular-nums;
}
.admin-stats-ds {
margin-top: 1.25rem;
}
.admin-ds-list {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.4rem;
}
.admin-ds-tag {
background: #1d2433;
border: 1px solid #2d3748;
border-radius: 3px;
padding: 0.15rem 0.5rem;
font-size: 0.78rem;
color: #cbd5e1;
}
.cl-graph-bar {
display: flex;
align-items: center;
+84
View File
@@ -0,0 +1,84 @@
import { h } from 'preact';
import type { Widget } from '../lib/types';
import { tabLabels } from '../lib/containers';
interface Props {
widget: Widget;
onContextMenu?: (e: MouseEvent) => void;
// View-mode collapse state: supplied (with a toggle) only by the live Canvas.
// In the editor these are undefined, so the pane always renders expanded.
collapsed?: boolean;
onToggleCollapse?: () => void;
// Tabbed-mode active tab + selector. Supplied by both Canvas (view) and
// EditCanvas (edit) so tabs can be switched in either mode.
activeTab?: number;
onSelectTab?: (i: number) => void;
}
const TITLE_H = 24;
// A purely decorative grouping frame placed behind other widgets on the
// free-form canvas (it does not re-parent them). Two variants:
// - 'pane': a titled / collapsible frame; collapsing in view mode hides the
// widgets geometrically inside it and shrinks the pane to its title bar.
// - 'tabs': a tab bar; only widgets assigned to the active tab are shown.
export default function Container({ widget, onContextMenu, collapsed, onToggleCollapse, activeTab, onSelectTab }: Props) {
const o = widget.options;
const variant = o['variant'] === 'tabs' ? 'tabs' : 'pane';
const showBg = o['bg'] !== 'false';
const accent = o['accent'] || '#3d4f6e';
if (variant === 'tabs') {
const tabs = tabLabels(widget);
const active = Math.min(Math.max(activeTab ?? 0, 0), tabs.length - 1);
return (
<div
class={`container-pane${showBg ? '' : ' container-pane-nobg'}`}
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;border-color:${accent};`}
onContextMenu={onContextMenu}
>
<div class="container-tab-bar" style={`border-color:${accent};`}>
{tabs.map((t, i) => (
<button
key={i}
class={`container-tab${i === active ? ' container-tab-active' : ''}`}
style={i === active ? `border-bottom-color:${accent};` : ''}
onClick={(e) => { e.stopPropagation(); onSelectTab && onSelectTab(i); }}
onMouseDown={(e) => e.stopPropagation()}
>{t}</button>
))}
</div>
</div>
);
}
const title = o['title'] ?? '';
const collapsible = o['collapsible'] === 'true';
// Collapse only applies in view mode (onToggleCollapse present) and when enabled.
const canCollapse = collapsible && !!onToggleCollapse;
const isCollapsed = canCollapse && !!collapsed;
const height = isCollapsed ? TITLE_H : widget.h;
const hasTitleBar = title !== '' || canCollapse;
return (
<div
class={`container-pane${showBg && !isCollapsed ? '' : ' container-pane-nobg'}`}
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${height}px;border-color:${accent};`}
onContextMenu={onContextMenu}
>
{hasTitleBar && (
<div class="container-pane-title" style={`border-color:${accent};`}>
{canCollapse && (
<button
class="container-collapse-btn"
title={isCollapsed ? 'Expand' : 'Collapse'}
onClick={(e) => { e.stopPropagation(); onToggleCollapse!(); }}
onMouseDown={(e) => e.stopPropagation()}
>{isCollapsed ? '▸' : '▾'}</button>
)}
<span class="container-pane-label">{title}</span>
</div>
)}
</div>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { getSignalStore, getMetaStore } from '../lib/stores';
import { formatValue } from '../lib/format';
import type { Widget, SignalRef, SignalValue, SignalMeta } from '../lib/types';
const DEFAULT_VALUE: SignalValue = { value: null, quality: 'unknown', ts: null };
const COLS = ['name', 'value', 'unit', 'quality', 'time'] as const;
type Col = typeof COLS[number];
const COL_LABELS: Record<Col, string> = {
name: 'Name', value: 'Value', unit: 'Unit', quality: 'Status', time: 'Time',
};
function qualityColor(q: string): string {
switch (q) {
case 'good': return '#4ade80';
case 'uncertain': return '#fbbf24';
case 'bad': return '#f87171';
default: return '#6b7280';
}
}
function isCol(c: string): c is Col {
return (COLS as readonly string[]).includes(c);
}
interface RowProps {
sig: SignalRef;
label: string;
cols: Col[];
fmt: string;
unitOverride: string;
}
// One subscribed row. Each row owns its own value/meta subscription so the
// table can carry an arbitrary number of unrelated signals.
function TableRow({ sig, label, cols, fmt, unitOverride }: RowProps) {
const [sv, setSv] = useState<SignalValue>(DEFAULT_VALUE);
const [meta, setMeta] = useState<SignalMeta | null>(null);
useEffect(() => {
const uv = getSignalStore(sig).subscribe(setSv);
const um = getMetaStore(sig).subscribe(setMeta);
return () => { uv(); um(); };
}, [sig.ds, sig.name]);
function valStr(): string {
const v = sv.value;
if (v === null || v === undefined) return '---';
if (typeof v === 'number') return formatValue(v, fmt);
if (Array.isArray(v)) return `[${v.length}]`;
return String(v);
}
const unit = unitOverride === 'none' ? '' : (unitOverride || meta?.unit || '');
const time = sv.ts ? new Date(sv.ts).toLocaleTimeString() : '';
return (
<tr>
{cols.map(c => {
switch (c) {
case 'name':
return <td key={c} class="tw-name" title={`${sig.ds}:${sig.name}`}>{label}</td>;
case 'value':
return <td key={c} class="tw-value">{valStr()}</td>;
case 'unit':
return <td key={c} class="tw-unit">{unit}</td>;
case 'quality':
return (
<td key={c} class="tw-quality">
<span class="quality-dot" style={`background:${qualityColor(sv.quality)};`} title={`Quality: ${sv.quality}`} />
</td>
);
case 'time':
return <td key={c} class="tw-time">{time}</td>;
}
})}
</tr>
);
}
interface Props { widget: Widget; onContextMenu?: (e: MouseEvent) => void; }
// A tabular multi-signal readout: each bound signal is one row, with a
// configurable set of columns (name / value / unit / status / time).
export default function TableWidget({ widget, onContextMenu }: Props) {
const o = widget.options;
const parsed = (o['columns'] ?? 'name,value,unit')
.split(',').map(s => s.trim()).filter(isCol);
const cols: Col[] = parsed.length ? parsed : ['name', 'value'];
const showHeader = o['header'] !== 'false';
const title = o['title'] ?? '';
const fmt = o['format'] ?? '';
const unitOverride = o['unit'] ?? '';
const labels = (o['labels'] ?? '').split(',');
return (
<div
class="table-widget"
style={`left:${widget.x}px;top:${widget.y}px;width:${widget.w}px;height:${widget.h}px;`}
onContextMenu={onContextMenu}
>
{title && <div class="tw-title">{title}</div>}
<div class="tw-scroll">
<table class="tw-table">
{showHeader && (
<thead>
<tr>{cols.map(c => <th key={c} class={`tw-th-${c}`}>{COL_LABELS[c]}</th>)}</tr>
</thead>
)}
<tbody>
{widget.signals.length === 0 ? (
<tr><td class="tw-empty" colSpan={cols.length}>No signals drop signals here</td></tr>
) : (
widget.signals.map((s, i) => (
<TableRow
key={`${s.ds}:${s.name}`}
sig={s}
label={labels[i]?.trim() || s.name}
cols={cols}
fmt={fmt}
unitOverride={unitOverride}
/>
))
)}
</tbody>
</table>
</div>
</div>
);
}