Add control logic engine, panel logic dialogs, logic-edit restriction

Introduce server-side control-logic flow graphs (cron/alarm triggers,
Lua blocks) with CRUD endpoints, panel-logic lifecycle triggers and
user-interaction dialog nodes, and a synthetic node-graph editor.

Add an optional logic-editor allowlist (server.logic_editors) gating who
may add/edit panel logic and control logic, surfaced via /api/v1/me and
enforced in the API; hide logic affordances in the UI accordingly.

Update README, example config, and functional/technical specs to cover
all current features (plot panels, panel/control logic, local variables,
access control) and refresh the in-app manual and contextual help.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-06-19 07:27:35 +02:00
parent aba394b84d
commit afefba3184
35 changed files with 4633 additions and 467 deletions
+77 -5
View File
@@ -21,10 +21,60 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
const [loading, setLoading] = useState(true);
const [share, setShare] = useState<{ id: string; name: string } | null>(null);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [dropTarget, setDropTarget] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const dragId = useRef<string | null>(null);
const me = useAuth();
const writable = canWrite(me.level);
// Panels in a folder ('' = root), sorted by their stored order then name.
function panelsIn(folder: string): InterfaceListItem[] {
return interfaces
.filter(i => (i.folder || '') === folder)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || (a.name || a.id).localeCompare(b.name || b.id));
}
// Persist a new ordering for a folder's panels (also moves panels between folders).
async function reorder(folder: string, ids: string[]) {
const res = await fetch('/api/v1/interfaces/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder, ids }),
});
if (res.ok) refresh();
}
function endDrag() {
dragId.current = null;
setDropTarget(null);
}
// Drop dragged panel just before `target` within target's folder.
function dropOnPanel(target: InterfaceListItem, e: DragEvent) {
e.preventDefault();
e.stopPropagation();
const id = dragId.current;
if (!id || id === target.id) { endDrag(); return; }
const dest = target.folder || '';
const ids = panelsIn(dest).map(p => p.id).filter(pid => pid !== id);
const idx = ids.indexOf(target.id);
ids.splice(idx < 0 ? ids.length : idx, 0, id);
reorder(dest, ids);
endDrag();
}
// Drop dragged panel at the end of `folder` ('' = root).
function dropInFolder(folder: string, e: DragEvent) {
e.preventDefault();
e.stopPropagation();
const id = dragId.current;
if (!id) { endDrag(); return; }
const ids = panelsIn(folder).map(p => p.id).filter(pid => pid !== id);
ids.push(id);
reorder(folder, ids);
endDrag();
}
function refresh() {
setLoading(true);
Promise.all([
@@ -39,6 +89,7 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
version: Number(item.version || item.Version || 0),
owner: item.owner ? String(item.owner) : '',
folder: item.folder ? String(item.folder) : '',
order: Number(item.order || 0),
perm: (item.perm as InterfaceListItem['perm']) || 'write',
})).filter((item: InterfaceListItem) => item.id);
setInterfaces(normalized);
@@ -116,8 +167,18 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
}
function renderPanel(item: InterfaceListItem) {
const drag = writable && item.perm === 'write';
return (
<li key={item.id} class="iface-item">
<li
key={item.id}
class={`iface-item${dropTarget === `p:${item.id}` ? ' drop-target' : ''}`}
draggable={drag}
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}>
{item.name || item.id}
{item.perm === 'read' && <span class="iface-badge" title="Read-only">ro</span>}
@@ -136,11 +197,17 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
// Render a folder and everything beneath it (subfolders first, then panels).
function renderFolder(folder: Folder) {
const children = folders.filter(f => f.parent === folder.id);
const panels = interfaces.filter(i => i.folder === folder.id);
const panels = panelsIn(folder.id);
const open = expanded[folder.id] !== false; // default expanded
const canDrop = folder.perm === 'write';
return (
<li key={folder.id} class="iface-folder">
<div class="iface-folder-header">
<div
class={`iface-folder-header${dropTarget === `f:${folder.id}` ? ' drop-target' : ''}`}
onDragOver={canDrop ? (e) => { if (dragId.current) { e.preventDefault(); e.stopPropagation(); setDropTarget(`f:${folder.id}`); } } : undefined}
onDragLeave={() => setDropTarget(t => t === `f:${folder.id}` ? null : t)}
onDrop={canDrop ? (e) => dropInFolder(folder.id, e) : undefined}
>
<span class="iface-folder-name" onClick={() => toggle(folder.id)}>
{open ? '▾' : '▸'} {folder.name}
</span>
@@ -162,7 +229,7 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
}
const rootFolders = folders.filter(f => !f.parent);
const rootPanels = interfaces.filter(i => !i.folder);
const rootPanels = panelsIn('');
return (
<aside class={`panel${collapsed ? ' collapsed' : ''}`} style={!collapsed && width ? `width:${width}px;min-width:${width}px;` : undefined}>
@@ -201,7 +268,12 @@ export default function InterfaceList({ onLoad, onSelect, onEdit, onNewPlot, onE
) : interfaces.length === 0 && folders.length === 0 ? (
<p class="hint">No interfaces yet. Create one or import XML.</p>
) : (
<ul class="iface-list">
<ul
class={`iface-list${dropTarget === 'f:' ? ' drop-target' : ''}`}
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); setDropTarget('f:'); } }}
onDragLeave={() => setDropTarget(t => t === 'f:' ? null : t)}
onDrop={(e) => dropInFolder('', e)}
>
{rootFolders.map(renderFolder)}
{rootPanels.map(renderPanel)}
</ul>