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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user