Implemented admin pane + user permission
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user