feat(portal): M10.2 — workflows node-graph editor
Builds the §3 workflows editor as a client component at
`/[slug]/workflows`. IT_ADMIN only. Full-bleed layout (own
`layout.tsx`) — palette (234px) + canvas (flex) + inspector (286px).
* `src/lib/flow-modules.ts` — TS port of the handoff `FLOW_MODULES`
catalog: 18 modules across Triggers / Scanner / CERTifAI / Logic /
Actions, each with kind-colored monogram, input/output ports, and a
typed settings schema (select / text / num / area / toggle). Helpers
for `nodeH`, `portX/Y`, `defConfig`, `wirePath` (bezier), `seedFlow`
(7-node sample workflow), `modsByCat`. KIND_COLOR token map.
* `src/components/portal/workflows/WorkflowEditor.tsx` — client
component with:
- Palette: collapsible category tree, draggable items, kind-colored
dots and monos.
- Canvas: dotted grid that pans (drag background) and zooms (+/− with
`Maximize2` reset, 0.5–1.6). Floating toolbar = workflow name input
(running pulse on the dot during a test run) + node/link count +
Validate / Save / **Test run** buttons. Save respects the frozen
write-guard; Test run highlights nodes in BFS order from triggers
with animated wires (`.wire.run` keyframes already in globals.css).
- Nodes: 202px cards with kind-bordered monogram + title, first-config
value or `desc` in the body, input ports on left, output ports on
right (multi-output gates labeled PASS/FAIL, etc.). Drag to move,
click to select. Delete/Backspace removes selection.
- Wires: bezier paths via `wirePath`. Drag output port → input port
creates an edge (replaces existing edges into that input). Click to
select. Pending wire shows dashed.
- Inspector: live form against `selNode.config` driven by the module's
settings schema. Per-type fields (select / text / num / area /
toggle). Empty state shows the kind legend; edge selection shows a
delete-link affordance.
- Toasts: inline bottom-right queue with mono status-code footer for
the workflow actions (`workflow.valid`, `workflow.saved`,
`workflow.tested`, `402 → reactivation.requested` when frozen).
* `src/app/[slug]/workflows/layout.tsx` — strips `.content-inner` and
fills `position: absolute; inset: 0` so the editor's 3-column flex
fills the entire content area.
The page returns 200 against `BP_DEV_FIXTURE=admin-acme` with every
flow-* class marker present.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// Workflows is full-bleed — the editor (palette + canvas + inspector)
|
||||||
|
// takes the entire content area, so we strip the standard `.content-inner`
|
||||||
|
// max-width wrapper. The parent `.content` is already `overflow-y: auto`,
|
||||||
|
// so we force `min-height: 0` here to let the editor's own grid fill.
|
||||||
|
export default function WorkflowsLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getPortalSession } from "@/lib/get-session";
|
||||||
|
import { hasOrgRole } from "@/lib/session";
|
||||||
|
import { loadTenantForShell } from "@/lib/portal-data";
|
||||||
|
import { NotAllowed } from "@/components/portal/NotAllowed";
|
||||||
|
import { WorkflowEditor } from "@/components/portal/workflows/WorkflowEditor";
|
||||||
|
|
||||||
|
export default async function WorkflowsPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const session = await getPortalSession();
|
||||||
|
|
||||||
|
if (!session) redirect(`/${slug}/dashboard`);
|
||||||
|
if (!hasOrgRole(session, "IT_ADMIN")) {
|
||||||
|
return <NotAllowed need="IT_ADMIN" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = await loadTenantForShell(slug);
|
||||||
|
if (!t) return null;
|
||||||
|
|
||||||
|
return <WorkflowEditor frozen={t.status === "frozen"} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,818 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Maximize2,
|
||||||
|
Minus,
|
||||||
|
Play,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
defConfig,
|
||||||
|
FLOW_CATS,
|
||||||
|
FLOW_MODULES,
|
||||||
|
KIND_COLOR,
|
||||||
|
NODE_W,
|
||||||
|
modsByCat,
|
||||||
|
nodeH,
|
||||||
|
portX,
|
||||||
|
portY,
|
||||||
|
seedFlow,
|
||||||
|
wirePath,
|
||||||
|
type FlowEdge,
|
||||||
|
type FlowKind,
|
||||||
|
type FlowNode,
|
||||||
|
type FlowSetting,
|
||||||
|
} from "@/lib/flow-modules";
|
||||||
|
|
||||||
|
type Selection =
|
||||||
|
| { type: "node"; id: string }
|
||||||
|
| { type: "edge"; id: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
type DragState =
|
||||||
|
| { mode: "pan"; sx: number; sy: number; px: number; py: number }
|
||||||
|
| { mode: "move"; id: string; ox: number; oy: number }
|
||||||
|
| { mode: "wire"; from: [string, number] }
|
||||||
|
| { mode: "new"; mod: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
type PendingWire = {
|
||||||
|
from: [string, number];
|
||||||
|
x0: number;
|
||||||
|
y0: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Ghost = { x: number; y: number; mod: string };
|
||||||
|
|
||||||
|
type Toast = { id: number; msg: string; code?: string };
|
||||||
|
|
||||||
|
const SEED = seedFlow();
|
||||||
|
|
||||||
|
export function WorkflowEditor({ frozen }: { frozen: boolean }) {
|
||||||
|
const [nodes, setNodes] = useState<FlowNode[]>(SEED.nodes);
|
||||||
|
const [edges, setEdges] = useState<FlowEdge[]>(SEED.edges);
|
||||||
|
const [sel, setSel] = useState<Selection>({ type: "node", id: "n2" });
|
||||||
|
const [pan, setPan] = useState({ x: 22, y: 54 });
|
||||||
|
const [zoom, setZoom] = useState(0.78);
|
||||||
|
const [pending, setPending] = useState<PendingWire | null>(null);
|
||||||
|
const [ghost, setGhost] = useState<Ghost | null>(null);
|
||||||
|
const [active, setActive] = useState<string | null>(null);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [name, setName] = useState("Findings → evidence + notify");
|
||||||
|
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
|
||||||
|
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const dragRef = useRef<DragState>(null);
|
||||||
|
const stateRef = useRef({ pan, zoom });
|
||||||
|
stateRef.current = { pan, zoom };
|
||||||
|
|
||||||
|
const toWorld = useCallback((cx: number, cy: number) => {
|
||||||
|
const wrap = wrapRef.current;
|
||||||
|
if (!wrap) return { x: 0, y: 0 };
|
||||||
|
const r = wrap.getBoundingClientRect();
|
||||||
|
const { pan, zoom } = stateRef.current;
|
||||||
|
return { x: (cx - r.left - pan.x) / zoom, y: (cy - r.top - pan.y) / zoom };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toast = useCallback((msg: string, code?: string) => {
|
||||||
|
const id = Date.now() + Math.floor(Math.random() * 1000);
|
||||||
|
setToasts((ts) => [...ts, { id, msg, code }]);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setToasts((ts) => ts.filter((t) => t.id !== id));
|
||||||
|
}, 3400);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Global mouse handlers for drag-pan, node-move, wire-draw, palette-ghost.
|
||||||
|
useEffect(() => {
|
||||||
|
const move = (e: MouseEvent) => {
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (!d) return;
|
||||||
|
if (d.mode === "pan") {
|
||||||
|
setPan({ x: d.px + (e.clientX - d.sx), y: d.py + (e.clientY - d.sy) });
|
||||||
|
} else if (d.mode === "move") {
|
||||||
|
const w = toWorld(e.clientX, e.clientY);
|
||||||
|
setNodes((ns) =>
|
||||||
|
ns.map((n) =>
|
||||||
|
n.id === d.id
|
||||||
|
? { ...n, x: Math.round(w.x - d.ox), y: Math.round(w.y - d.oy) }
|
||||||
|
: n,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (d.mode === "wire") {
|
||||||
|
const w = toWorld(e.clientX, e.clientY);
|
||||||
|
setPending((p) => (p ? { ...p, x: w.x, y: w.y } : p));
|
||||||
|
} else if (d.mode === "new") {
|
||||||
|
setGhost({ x: e.clientX, y: e.clientY, mod: d.mod });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const up = (e: MouseEvent) => {
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (d && d.mode === "new") {
|
||||||
|
const wrap = wrapRef.current;
|
||||||
|
if (wrap) {
|
||||||
|
const r = wrap.getBoundingClientRect();
|
||||||
|
if (
|
||||||
|
e.clientX > r.left &&
|
||||||
|
e.clientX < r.right &&
|
||||||
|
e.clientY > r.top &&
|
||||||
|
e.clientY < r.bottom
|
||||||
|
) {
|
||||||
|
const w = toWorld(e.clientX, e.clientY);
|
||||||
|
const id = "n" + Date.now().toString(36);
|
||||||
|
const mod = d.mod;
|
||||||
|
setNodes((ns) => [
|
||||||
|
...ns,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
mod,
|
||||||
|
x: Math.round(w.x - NODE_W / 2),
|
||||||
|
y: Math.round(w.y - 28),
|
||||||
|
config: defConfig(mod),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setSel({ type: "node", id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setGhost(null);
|
||||||
|
}
|
||||||
|
if (d && d.mode === "wire") setPending(null);
|
||||||
|
dragRef.current = null;
|
||||||
|
};
|
||||||
|
window.addEventListener("mousemove", move);
|
||||||
|
window.addEventListener("mouseup", up);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("mousemove", move);
|
||||||
|
window.removeEventListener("mouseup", up);
|
||||||
|
};
|
||||||
|
}, [toWorld]);
|
||||||
|
|
||||||
|
const deleteSel = useCallback(() => {
|
||||||
|
setSel((s) => {
|
||||||
|
if (!s) return s;
|
||||||
|
if (s.type === "node") {
|
||||||
|
setNodes((ns) => ns.filter((n) => n.id !== s.id));
|
||||||
|
setEdges((es) => es.filter((e) => e.from[0] !== s.id && e.to[0] !== s.id));
|
||||||
|
} else {
|
||||||
|
setEdges((es) => es.filter((e) => e.id !== s.id));
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Delete/Backspace removes the current selection (unless an input has
|
||||||
|
// focus — we don't want to nuke nodes while someone's typing).
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => {
|
||||||
|
const tag = (document.activeElement as HTMLElement | null)?.tagName;
|
||||||
|
if (
|
||||||
|
(e.key === "Delete" || e.key === "Backspace") &&
|
||||||
|
sel &&
|
||||||
|
tag !== "INPUT" &&
|
||||||
|
tag !== "TEXTAREA" &&
|
||||||
|
tag !== "SELECT"
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
deleteSel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", h);
|
||||||
|
return () => window.removeEventListener("keydown", h);
|
||||||
|
}, [sel, deleteSel]);
|
||||||
|
|
||||||
|
const startWire = (e: React.MouseEvent, nodeId: string, outIdx: number) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const node = nodes.find((n) => n.id === nodeId);
|
||||||
|
if (!node) return;
|
||||||
|
const ox = portX(node, "out");
|
||||||
|
const oy = portY(node, "out")[outIdx];
|
||||||
|
dragRef.current = { mode: "wire", from: [nodeId, outIdx] };
|
||||||
|
setPending({ from: [nodeId, outIdx], x0: ox, y0: oy, x: ox, y: oy });
|
||||||
|
};
|
||||||
|
|
||||||
|
const endWire = (e: React.MouseEvent, nodeId: string, inIdx: number) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (d && d.mode === "wire") {
|
||||||
|
const from = d.from;
|
||||||
|
if (from[0] !== nodeId) {
|
||||||
|
setEdges((es) => [
|
||||||
|
...es.filter((ed) => !(ed.to[0] === nodeId && ed.to[1] === inIdx)),
|
||||||
|
{ id: "e" + Date.now().toString(36), from, to: [nodeId, inIdx] },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
setPending(null);
|
||||||
|
dragRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testRun = () => {
|
||||||
|
if (frozen) {
|
||||||
|
toast("Tenant frozen — re-activate to run", "402 → reactivation.requested");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRunning(true);
|
||||||
|
const incoming: Record<string, number> = {};
|
||||||
|
edges.forEach((e) => {
|
||||||
|
incoming[e.to[0]] = (incoming[e.to[0]] || 0) + 1;
|
||||||
|
});
|
||||||
|
const order: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let frontier = nodes.filter((n) => !incoming[n.id]).map((n) => n.id);
|
||||||
|
while (frontier.length) {
|
||||||
|
const next: string[] = [];
|
||||||
|
frontier.forEach((id) => {
|
||||||
|
if (!seen.has(id)) {
|
||||||
|
seen.add(id);
|
||||||
|
order.push(id);
|
||||||
|
edges.filter((e) => e.from[0] === id).forEach((e) => next.push(e.to[0]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
frontier = next;
|
||||||
|
}
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
if (!seen.has(n.id)) order.push(n.id);
|
||||||
|
});
|
||||||
|
order.forEach((id, i) => window.setTimeout(() => setActive(id), i * 420));
|
||||||
|
window.setTimeout(
|
||||||
|
() => {
|
||||||
|
setRunning(false);
|
||||||
|
setActive(null);
|
||||||
|
toast(
|
||||||
|
`Test run complete · ${nodes.length} nodes · 0 errors`,
|
||||||
|
"workflow.tested",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
order.length * 420 + 700,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selNode = sel?.type === "node" ? nodes.find((n) => n.id === sel.id) ?? null : null;
|
||||||
|
const selMod = selNode ? FLOW_MODULES[selNode.mod] : null;
|
||||||
|
|
||||||
|
const updateConfig = (k: string, v: string | number | boolean) => {
|
||||||
|
if (!selNode) return;
|
||||||
|
setNodes((ns) =>
|
||||||
|
ns.map((n) =>
|
||||||
|
n.id === selNode.id ? { ...n, config: { ...n.config, [k]: v } } : n,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const grid: CSSProperties = useMemo(
|
||||||
|
() => ({
|
||||||
|
backgroundPosition: `${pan.x}px ${pan.y}px`,
|
||||||
|
backgroundSize: `${22 * zoom}px ${22 * zoom}px`,
|
||||||
|
}),
|
||||||
|
[pan.x, pan.y, zoom],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flow">
|
||||||
|
{/* ---- palette ---- */}
|
||||||
|
<aside className="flow-palette">
|
||||||
|
<div className="flow-pal-head">
|
||||||
|
<span className="eyebrow">MODULE LIBRARY</span>
|
||||||
|
<span className="muted mono" style={{ fontSize: 9.5 }}>
|
||||||
|
drag onto canvas
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flow-pal-body">
|
||||||
|
{FLOW_CATS.map((cat) => {
|
||||||
|
const open = !collapsed[cat.id];
|
||||||
|
const items = modsByCat(cat.id);
|
||||||
|
return (
|
||||||
|
<div className="ptree-group" key={cat.id}>
|
||||||
|
<div
|
||||||
|
className="ptree-title"
|
||||||
|
onClick={() =>
|
||||||
|
setCollapsed((c) => ({ ...c, [cat.id]: !c[cat.id] }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{open ? (
|
||||||
|
<ChevronDown size={12} style={{ color: "var(--ink-3)" }} />
|
||||||
|
) : (
|
||||||
|
<ChevronRight size={12} style={{ color: "var(--ink-3)" }} />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="dot"
|
||||||
|
style={{ background: KIND_COLOR[cat.kind] }}
|
||||||
|
/>
|
||||||
|
<span>{cat.label}</span>
|
||||||
|
<span className="ptree-count">{items.length}</span>
|
||||||
|
</div>
|
||||||
|
{open
|
||||||
|
? items.map((m) => (
|
||||||
|
<div
|
||||||
|
className="pitem"
|
||||||
|
key={m.id}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragRef.current = { mode: "new", mod: m.id };
|
||||||
|
setGhost({
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
mod: m.id,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pitem-mono"
|
||||||
|
style={{ color: KIND_COLOR[m.kind as FlowKind] }}
|
||||||
|
>
|
||||||
|
{m.mono}
|
||||||
|
</span>
|
||||||
|
<span className="pitem-name">{m.name}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* ---- canvas ---- */}
|
||||||
|
<div
|
||||||
|
className="flow-canvas-wrap"
|
||||||
|
ref={wrapRef}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
const t = e.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
t === e.currentTarget ||
|
||||||
|
t.classList.contains("flow-grid") ||
|
||||||
|
t.classList.contains("flow-layer") ||
|
||||||
|
t.tagName.toLowerCase() === "svg"
|
||||||
|
) {
|
||||||
|
setSel(null);
|
||||||
|
dragRef.current = {
|
||||||
|
mode: "pan",
|
||||||
|
sx: e.clientX,
|
||||||
|
sy: e.clientY,
|
||||||
|
px: pan.x,
|
||||||
|
py: pan.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flow-grid" style={grid} />
|
||||||
|
|
||||||
|
{/* toolbar */}
|
||||||
|
<div className="flow-toolbar">
|
||||||
|
<div className="ft-name">
|
||||||
|
<span
|
||||||
|
className="dot"
|
||||||
|
style={{ background: running ? "var(--accent)" : "var(--ok)" }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="ft-input"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<span className="ft-meta mono">
|
||||||
|
{nodes.length} nodes · {edges.length} links
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="row" style={{ gap: 7 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() =>
|
||||||
|
toast(
|
||||||
|
"Workflow validated · no cycles · all inputs satisfied",
|
||||||
|
"workflow.valid",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Check size={14} /> Validate
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={"btn btn-sm btn-ghost" + (frozen ? " is-disabled" : "")}
|
||||||
|
onClick={() =>
|
||||||
|
frozen
|
||||||
|
? toast("Tenant frozen — writes blocked", "402 → reactivation.requested")
|
||||||
|
: toast(
|
||||||
|
`Workflow saved · v12 · ${nodes.length} nodes`,
|
||||||
|
"workflow.saved",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Save size={14} /> Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={testRun}
|
||||||
|
>
|
||||||
|
<Play size={14} /> {running ? "Running…" : "Test run"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* zoom controls */}
|
||||||
|
<div className="flow-zoom">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setZoom((z) => Math.min(1.6, +(z + 0.15).toFixed(2)))}
|
||||||
|
aria-label="Zoom in"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
</button>
|
||||||
|
<span className="mono">{Math.round(zoom * 100)}%</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setZoom((z) => Math.max(0.5, +(z - 0.15).toFixed(2)))}
|
||||||
|
aria-label="Zoom out"
|
||||||
|
>
|
||||||
|
<Minus size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setZoom(0.78);
|
||||||
|
setPan({ x: 22, y: 54 });
|
||||||
|
}}
|
||||||
|
title="Reset view"
|
||||||
|
aria-label="Reset view"
|
||||||
|
>
|
||||||
|
<Maximize2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flow-layer"
|
||||||
|
style={{
|
||||||
|
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* wires */}
|
||||||
|
<svg
|
||||||
|
className="flow-wires"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
overflow: "visible",
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{edges.map((e) => {
|
||||||
|
const a = nodes.find((n) => n.id === e.from[0]);
|
||||||
|
const b = nodes.find((n) => n.id === e.to[0]);
|
||||||
|
if (!a || !b) return null;
|
||||||
|
const x1 = portX(a, "out");
|
||||||
|
const y1 = portY(a, "out")[e.from[1]];
|
||||||
|
const x2 = portX(b, "in");
|
||||||
|
const y2 = portY(b, "in")[e.to[1]];
|
||||||
|
const lit = running && active === e.from[0];
|
||||||
|
const selected =
|
||||||
|
sel?.type === "edge" && sel.id === e.id;
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={e.id}
|
||||||
|
className={
|
||||||
|
"wire" +
|
||||||
|
(selected ? " sel" : "") +
|
||||||
|
(lit ? " run" : "")
|
||||||
|
}
|
||||||
|
d={wirePath(x1, y1, x2, y2)}
|
||||||
|
onMouseDown={(ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
setSel({ type: "edge", id: e.id });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{pending ? (
|
||||||
|
<path
|
||||||
|
className="wire pending"
|
||||||
|
d={wirePath(pending.x0, pending.y0, pending.x, pending.y)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* nodes */}
|
||||||
|
{nodes.map((n) => {
|
||||||
|
const m = FLOW_MODULES[n.mod];
|
||||||
|
if (!m) return null;
|
||||||
|
const H = nodeH(m);
|
||||||
|
const insY = portY(n, "in");
|
||||||
|
const outsY = portY(n, "out");
|
||||||
|
const isSel = sel?.type === "node" && sel.id === n.id;
|
||||||
|
const firstSetting = m.settings?.[0];
|
||||||
|
const firstValue =
|
||||||
|
firstSetting && n.config[firstSetting.k] != null
|
||||||
|
? String(n.config[firstSetting.k])
|
||||||
|
: "";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={n.id}
|
||||||
|
className={
|
||||||
|
"fnode" +
|
||||||
|
(isSel ? " sel" : "") +
|
||||||
|
(active === n.id ? " active" : "")
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
left: n.x,
|
||||||
|
top: n.y,
|
||||||
|
width: NODE_W,
|
||||||
|
minHeight: H,
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSel({ type: "node", id: n.id });
|
||||||
|
const w = toWorld(e.clientX, e.clientY);
|
||||||
|
dragRef.current = {
|
||||||
|
mode: "move",
|
||||||
|
id: n.id,
|
||||||
|
ox: w.x - n.x,
|
||||||
|
oy: w.y - n.y,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="fnode-head">
|
||||||
|
<span
|
||||||
|
className="fnode-mono"
|
||||||
|
style={{ borderColor: KIND_COLOR[m.kind] }}
|
||||||
|
>
|
||||||
|
{m.mono}
|
||||||
|
</span>
|
||||||
|
<span className="fnode-title">{m.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="fnode-body">
|
||||||
|
{firstValue.length ? firstValue : m.desc}
|
||||||
|
</div>
|
||||||
|
{m.in.map((lbl, i) => (
|
||||||
|
<div
|
||||||
|
key={"i" + i}
|
||||||
|
className="fport in"
|
||||||
|
style={{ top: insY[i] - n.y - 5.5 }}
|
||||||
|
onMouseUp={(e) => endWire(e, n.id, i)}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{m.in.length > 1 ? (
|
||||||
|
<span className="fport-lbl in">{lbl}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{m.out.map((lbl, i) => (
|
||||||
|
<div
|
||||||
|
key={"o" + i}
|
||||||
|
className={
|
||||||
|
"fport out" +
|
||||||
|
(lbl === "fail" || lbl === "false" ? " neg" : "")
|
||||||
|
}
|
||||||
|
style={{ top: outsY[i] - n.y - 5.5 }}
|
||||||
|
onMouseDown={(e) => startWire(e, n.id, i)}
|
||||||
|
>
|
||||||
|
{m.out.length > 1 ? (
|
||||||
|
<span className="fport-lbl out">{lbl}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- inspector ---- */}
|
||||||
|
<aside className="flow-inspector">
|
||||||
|
{selNode && selMod ? (
|
||||||
|
<>
|
||||||
|
<div className="flow-insp-head">
|
||||||
|
<span
|
||||||
|
className="fnode-mono"
|
||||||
|
style={{ borderColor: KIND_COLOR[selMod.kind] }}
|
||||||
|
>
|
||||||
|
{selMod.mono}
|
||||||
|
</span>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div className="fi-title">{selMod.name}</div>
|
||||||
|
<div className="mono muted" style={{ fontSize: 9.5 }}>
|
||||||
|
{selNode.id} · {selMod.cat}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flow-insp-body">
|
||||||
|
<div className="eyebrow" style={{ marginBottom: 10 }}>
|
||||||
|
SETTINGS
|
||||||
|
</div>
|
||||||
|
<div className="col" style={{ gap: 12 }}>
|
||||||
|
{(selMod.settings || []).map((s) => (
|
||||||
|
<FlowField
|
||||||
|
key={s.k}
|
||||||
|
s={s}
|
||||||
|
value={selNode.config[s.k]}
|
||||||
|
onChange={(v) => updateConfig(s.k, v)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!selMod.settings?.length ? (
|
||||||
|
<div className="muted" style={{ fontSize: 12 }}>
|
||||||
|
No settings for this module.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="divider" style={{ margin: "16px 0" }} />
|
||||||
|
<dl
|
||||||
|
className="dl"
|
||||||
|
style={{ gridTemplateColumns: "max-content 1fr", gap: "7px 14px" }}
|
||||||
|
>
|
||||||
|
<dt>Inputs</dt>
|
||||||
|
<dd className="mono">{selMod.in.length || "—"}</dd>
|
||||||
|
<dt>Outputs</dt>
|
||||||
|
<dd className="mono">{selMod.out.join(", ") || "terminal"}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div className="flow-insp-foot">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost btn-danger"
|
||||||
|
onClick={deleteSel}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} /> Remove node
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : sel?.type === "edge" ? (
|
||||||
|
<div className="flow-insp-empty">
|
||||||
|
<div className="eyebrow" style={{ marginBottom: 8 }}>
|
||||||
|
CONNECTION
|
||||||
|
</div>
|
||||||
|
<p className="muted" style={{ fontSize: 12.5 }}>
|
||||||
|
A data link between two modules. The upstream module's output
|
||||||
|
is passed to the downstream input.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost btn-danger"
|
||||||
|
style={{ marginTop: 14 }}
|
||||||
|
onClick={deleteSel}
|
||||||
|
>
|
||||||
|
<X size={13} /> Delete link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flow-insp-empty">
|
||||||
|
<div className="eyebrow" style={{ marginBottom: 8 }}>
|
||||||
|
INSPECTOR
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="muted"
|
||||||
|
style={{ fontSize: 12.5, lineHeight: 1.55 }}
|
||||||
|
>
|
||||||
|
Select a node to configure it, drag a module from the library to
|
||||||
|
add one, or drag from an output port to wire modules together.
|
||||||
|
</p>
|
||||||
|
<div className="flow-legend">
|
||||||
|
{FLOW_CATS.map((c) => (
|
||||||
|
<span key={c.id} className="fl">
|
||||||
|
<span
|
||||||
|
className="dot"
|
||||||
|
style={{ background: KIND_COLOR[c.kind] }}
|
||||||
|
/>
|
||||||
|
{c.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{ghost ? (
|
||||||
|
<div
|
||||||
|
className="flow-ghost"
|
||||||
|
style={{ left: ghost.x, top: ghost.y }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="fnode-mono"
|
||||||
|
style={{
|
||||||
|
borderColor: KIND_COLOR[FLOW_MODULES[ghost.mod]?.kind ?? "trigger"],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{FLOW_MODULES[ghost.mod]?.mono}
|
||||||
|
</span>
|
||||||
|
{FLOW_MODULES[ghost.mod]?.name}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="toasts">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div key={t.id} className="toast">
|
||||||
|
<div className="col" style={{ gap: 2 }}>
|
||||||
|
<span>{t.msg}</span>
|
||||||
|
{t.code ? <span className="t-code">{t.code}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One inspector field per setting. Type-discriminated so each branch
|
||||||
|
// hands `onChange` a concrete value type.
|
||||||
|
function FlowField({
|
||||||
|
s,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
s: FlowSetting;
|
||||||
|
value: string | number | boolean | undefined;
|
||||||
|
onChange: (v: string | number | boolean) => void;
|
||||||
|
}) {
|
||||||
|
if (s.type === "toggle") {
|
||||||
|
const on = Boolean(value);
|
||||||
|
return (
|
||||||
|
<div className="row between" style={{ padding: "2px 0" }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--ink-3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={"fswitch" + (on ? " on" : "")}
|
||||||
|
onClick={() => onChange(!on)}
|
||||||
|
aria-pressed={on}
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (s.type === "select") {
|
||||||
|
return (
|
||||||
|
<div className="field">
|
||||||
|
<label>{s.label}</label>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={String(value ?? "")}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
>
|
||||||
|
{s.opts.map((o) => (
|
||||||
|
<option key={o} value={o}>
|
||||||
|
{o}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (s.type === "area") {
|
||||||
|
return (
|
||||||
|
<div className="field">
|
||||||
|
<label>{s.label}</label>
|
||||||
|
<textarea
|
||||||
|
className="input mono"
|
||||||
|
rows={2}
|
||||||
|
style={{ resize: "vertical", fontSize: 12 }}
|
||||||
|
value={String(value ?? "")}
|
||||||
|
placeholder={s.ph}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// text / num
|
||||||
|
return (
|
||||||
|
<div className="field">
|
||||||
|
<label>{s.label}</label>
|
||||||
|
<input
|
||||||
|
className="input mono"
|
||||||
|
type={s.type === "num" ? "number" : "text"}
|
||||||
|
step="0.05"
|
||||||
|
value={String(value ?? "")}
|
||||||
|
placeholder={s.ph}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange(s.type === "num" ? parseFloat(e.target.value) : e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
// Module catalog for the Workflows editor — TS port of FLOW_MODULES /
|
||||||
|
// FLOW_CATS from the design handoff's `screens_flow.jsx`. The shapes here
|
||||||
|
// drive the palette tree, the per-node ports, and the inspector form.
|
||||||
|
|
||||||
|
export type FlowKind = "trigger" | "scanner" | "ai" | "logic" | "action";
|
||||||
|
|
||||||
|
export const KIND_COLOR: Record<FlowKind, string> = {
|
||||||
|
trigger: "var(--accent)",
|
||||||
|
scanner: "var(--ink-2)",
|
||||||
|
ai: "var(--ink-2)",
|
||||||
|
logic: "var(--warn)",
|
||||||
|
action: "var(--ok)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlowCat = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: FlowKind;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FLOW_CATS: FlowCat[] = [
|
||||||
|
{ id: "triggers", label: "Triggers", kind: "trigger" },
|
||||||
|
{ id: "scanner", label: "Compliance Scanner", kind: "scanner" },
|
||||||
|
{ id: "certifai", label: "CERTifAI", kind: "ai" },
|
||||||
|
{ id: "logic", label: "Logic", kind: "logic" },
|
||||||
|
{ id: "actions", label: "Actions", kind: "action" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export type FlowSetting =
|
||||||
|
| { k: string; label: string; type: "select"; opts: string[]; def: string; ph?: string }
|
||||||
|
| { k: string; label: string; type: "text"; def: string; ph?: string }
|
||||||
|
| { k: string; label: string; type: "num"; def: number; ph?: string }
|
||||||
|
| { k: string; label: string; type: "area"; def: string; ph?: string }
|
||||||
|
| { k: string; label: string; type: "toggle"; def: boolean; ph?: string };
|
||||||
|
|
||||||
|
export type FlowModule = {
|
||||||
|
name: string;
|
||||||
|
cat: string;
|
||||||
|
kind: FlowKind;
|
||||||
|
mono: string;
|
||||||
|
in: string[];
|
||||||
|
out: string[];
|
||||||
|
desc: string;
|
||||||
|
settings?: FlowSetting[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FLOW_MODULES: Record<string, FlowModule> = {
|
||||||
|
// ---- triggers ----
|
||||||
|
schedule: {
|
||||||
|
name: "On schedule",
|
||||||
|
cat: "triggers",
|
||||||
|
kind: "trigger",
|
||||||
|
mono: "⏱",
|
||||||
|
in: [],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "daily · 02:00",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "cadence",
|
||||||
|
label: "Cadence",
|
||||||
|
type: "select",
|
||||||
|
opts: ["Every hour", "Daily", "Weekly", "Monthly"],
|
||||||
|
def: "Daily",
|
||||||
|
},
|
||||||
|
{ k: "time", label: "At time (UTC)", type: "text", def: "02:00" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"scan-complete": {
|
||||||
|
name: "On scan complete",
|
||||||
|
cat: "triggers",
|
||||||
|
kind: "trigger",
|
||||||
|
mono: "◆",
|
||||||
|
in: [],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "compliance-scanner",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "product",
|
||||||
|
label: "Product",
|
||||||
|
type: "select",
|
||||||
|
opts: ["compliance-scanner", "certifai", "any"],
|
||||||
|
def: "compliance-scanner",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"new-finding": {
|
||||||
|
name: "On new finding",
|
||||||
|
cat: "triggers",
|
||||||
|
kind: "trigger",
|
||||||
|
mono: "▲",
|
||||||
|
in: [],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "severity ≥ Medium",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "minSev",
|
||||||
|
label: "Min severity",
|
||||||
|
type: "select",
|
||||||
|
opts: ["Low", "Medium", "High", "Critical"],
|
||||||
|
def: "Medium",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
webhook: {
|
||||||
|
name: "Webhook",
|
||||||
|
cat: "triggers",
|
||||||
|
kind: "trigger",
|
||||||
|
mono: "↯",
|
||||||
|
in: [],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "POST /hooks/…",
|
||||||
|
settings: [
|
||||||
|
{ k: "path", label: "Path", type: "text", def: "/hooks/ingest" },
|
||||||
|
{ k: "secret", label: "Signing secret", type: "text", def: "whsec_••••" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- scanner ----
|
||||||
|
"run-scan": {
|
||||||
|
name: "Run scan",
|
||||||
|
cat: "scanner",
|
||||||
|
kind: "scanner",
|
||||||
|
mono: "CS",
|
||||||
|
in: ["in"],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "full · cloud + code",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "scope",
|
||||||
|
label: "Scope",
|
||||||
|
type: "select",
|
||||||
|
opts: ["Full", "Cloud only", "Code only", "Delta"],
|
||||||
|
def: "Full",
|
||||||
|
},
|
||||||
|
{ k: "frameworks", label: "Frameworks", type: "text", def: "ISO 27001, BSI C5" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
filter: {
|
||||||
|
name: "Filter findings",
|
||||||
|
cat: "scanner",
|
||||||
|
kind: "scanner",
|
||||||
|
mono: "≡",
|
||||||
|
in: ["in"],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "status = open",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "status",
|
||||||
|
label: "Status",
|
||||||
|
type: "select",
|
||||||
|
opts: ["open", "resolved", "any"],
|
||||||
|
def: "open",
|
||||||
|
},
|
||||||
|
{ k: "control", label: "Control matches", type: "text", def: "", ph: "e.g. ISO 27001 A.8*" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"sev-gate": {
|
||||||
|
name: "Severity gate",
|
||||||
|
cat: "scanner",
|
||||||
|
kind: "scanner",
|
||||||
|
mono: "⊟",
|
||||||
|
in: ["in"],
|
||||||
|
out: ["pass", "fail"],
|
||||||
|
desc: "≥ High",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "threshold",
|
||||||
|
label: "Threshold",
|
||||||
|
type: "select",
|
||||||
|
opts: ["Low", "Medium", "High", "Critical"],
|
||||||
|
def: "High",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"map-control": {
|
||||||
|
name: "Map to control",
|
||||||
|
cat: "scanner",
|
||||||
|
kind: "scanner",
|
||||||
|
mono: "⌖",
|
||||||
|
in: ["in"],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "framework: ISO 27001",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "framework",
|
||||||
|
label: "Framework",
|
||||||
|
type: "select",
|
||||||
|
opts: ["ISO 27001", "BSI C5", "NIS2", "TISAX"],
|
||||||
|
def: "ISO 27001",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- certifai ----
|
||||||
|
"gen-annex": {
|
||||||
|
name: "Generate Annex IV",
|
||||||
|
cat: "certifai",
|
||||||
|
kind: "ai",
|
||||||
|
mono: "Ai",
|
||||||
|
in: ["in"],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "EU AI Act dossier",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "model",
|
||||||
|
label: "Model system",
|
||||||
|
type: "select",
|
||||||
|
opts: ["risk-scorer-v3", "doc-classifier", "all"],
|
||||||
|
def: "risk-scorer-v3",
|
||||||
|
},
|
||||||
|
{ k: "sign", label: "Sign dossier", type: "toggle", def: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"bias-check": {
|
||||||
|
name: "Bias evaluation",
|
||||||
|
cat: "certifai",
|
||||||
|
kind: "ai",
|
||||||
|
mono: "Ai",
|
||||||
|
in: ["in"],
|
||||||
|
out: ["pass", "fail"],
|
||||||
|
desc: "fairness ≥ 0.8",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "metric",
|
||||||
|
label: "Metric",
|
||||||
|
type: "select",
|
||||||
|
opts: ["Demographic parity", "Equalised odds"],
|
||||||
|
def: "Demographic parity",
|
||||||
|
},
|
||||||
|
{ k: "min", label: "Min score", type: "num", def: 0.8 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"model-audit": {
|
||||||
|
name: "Model card audit",
|
||||||
|
cat: "certifai",
|
||||||
|
kind: "ai",
|
||||||
|
mono: "Ai",
|
||||||
|
in: ["in"],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "Annex IV §2",
|
||||||
|
settings: [{ k: "strict", label: "Strict mode", type: "toggle", def: false }],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- logic ----
|
||||||
|
branch: {
|
||||||
|
name: "Branch",
|
||||||
|
cat: "logic",
|
||||||
|
kind: "logic",
|
||||||
|
mono: "⑂",
|
||||||
|
in: ["in"],
|
||||||
|
out: ["true", "false"],
|
||||||
|
desc: "if condition",
|
||||||
|
settings: [
|
||||||
|
{ k: "expr", label: "Condition", type: "text", def: "count > 0", ph: "expression" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
merge: {
|
||||||
|
name: "Merge",
|
||||||
|
cat: "logic",
|
||||||
|
kind: "logic",
|
||||||
|
mono: "⑃",
|
||||||
|
in: ["a", "b"],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "wait all",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "mode",
|
||||||
|
label: "Mode",
|
||||||
|
type: "select",
|
||||||
|
opts: ["Wait all", "First wins"],
|
||||||
|
def: "Wait all",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
delay: {
|
||||||
|
name: "Delay",
|
||||||
|
cat: "logic",
|
||||||
|
kind: "logic",
|
||||||
|
mono: "◴",
|
||||||
|
in: ["in"],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "1 h",
|
||||||
|
settings: [{ k: "amount", label: "Duration", type: "text", def: "1 h" }],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- actions ----
|
||||||
|
"create-evidence": {
|
||||||
|
name: "Create evidence",
|
||||||
|
cat: "actions",
|
||||||
|
kind: "action",
|
||||||
|
mono: "▤",
|
||||||
|
in: ["in"],
|
||||||
|
out: ["out"],
|
||||||
|
desc: "PDF · signed",
|
||||||
|
settings: [
|
||||||
|
{ k: "name", label: "Bundle name", type: "text", def: "Auto-evidence" },
|
||||||
|
{
|
||||||
|
k: "format",
|
||||||
|
label: "Format",
|
||||||
|
type: "select",
|
||||||
|
opts: ["PDF", "JSON", "PDF + JSON"],
|
||||||
|
def: "PDF",
|
||||||
|
},
|
||||||
|
{ k: "sign", label: "Hash-chain sign", type: "toggle", def: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
notify: {
|
||||||
|
name: "Notify",
|
||||||
|
cat: "actions",
|
||||||
|
kind: "action",
|
||||||
|
mono: "✉",
|
||||||
|
in: ["in"],
|
||||||
|
out: [],
|
||||||
|
desc: "Slack · #compliance",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "channel",
|
||||||
|
label: "Channel",
|
||||||
|
type: "select",
|
||||||
|
opts: ["Slack", "Email", "Microsoft Teams"],
|
||||||
|
def: "Slack",
|
||||||
|
},
|
||||||
|
{ k: "target", label: "Target", type: "text", def: "#compliance" },
|
||||||
|
{ k: "msg", label: "Message", type: "area", def: "{{count}} findings need review" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"open-ticket": {
|
||||||
|
name: "Open ticket",
|
||||||
|
cat: "actions",
|
||||||
|
kind: "action",
|
||||||
|
mono: "⊞",
|
||||||
|
in: ["in"],
|
||||||
|
out: [],
|
||||||
|
desc: "Jira · COMP",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "system",
|
||||||
|
label: "System",
|
||||||
|
type: "select",
|
||||||
|
opts: ["Jira", "ServiceNow", "Linear"],
|
||||||
|
def: "Jira",
|
||||||
|
},
|
||||||
|
{ k: "project", label: "Project key", type: "text", def: "COMP" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"export-bundle": {
|
||||||
|
name: "Export bundle",
|
||||||
|
cat: "actions",
|
||||||
|
kind: "action",
|
||||||
|
mono: "⇪",
|
||||||
|
in: ["in"],
|
||||||
|
out: [],
|
||||||
|
desc: "S3 · eu-central",
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
k: "dest",
|
||||||
|
label: "Destination",
|
||||||
|
type: "select",
|
||||||
|
opts: ["S3 (eu-central)", "SFTP", "Download"],
|
||||||
|
def: "S3 (eu-central)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const modsByCat = (cat: string) =>
|
||||||
|
Object.entries(FLOW_MODULES)
|
||||||
|
.filter(([, m]) => m.cat === cat)
|
||||||
|
.map(([id, m]) => ({ id, ...m }));
|
||||||
|
|
||||||
|
export const NODE_W = 202;
|
||||||
|
|
||||||
|
export function nodeH(m: FlowModule): number {
|
||||||
|
return Math.max(58, 36 + Math.max(m.in.length, m.out.length, 1) * 22);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defConfig(modId: string): Record<string, string | number | boolean> {
|
||||||
|
const m = FLOW_MODULES[modId];
|
||||||
|
if (!m) return {};
|
||||||
|
const c: Record<string, string | number | boolean> = {};
|
||||||
|
(m.settings || []).forEach((s) => {
|
||||||
|
c[s.k] = s.def;
|
||||||
|
});
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FlowNode = {
|
||||||
|
id: string;
|
||||||
|
mod: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
config: Record<string, string | number | boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlowEdge = {
|
||||||
|
id: string;
|
||||||
|
from: [string, number];
|
||||||
|
to: [string, number];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function seedFlow(): { nodes: FlowNode[]; edges: FlowEdge[] } {
|
||||||
|
const mk = (id: string, mod: string, x: number, y: number): FlowNode => ({
|
||||||
|
id,
|
||||||
|
mod,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
config: defConfig(mod),
|
||||||
|
});
|
||||||
|
const nodes: FlowNode[] = [
|
||||||
|
mk("n1", "scan-complete", 24, 70),
|
||||||
|
mk("n2", "sev-gate", 270, 86),
|
||||||
|
mk("n3", "create-evidence", 516, 24),
|
||||||
|
mk("n4", "notify", 516, 188),
|
||||||
|
mk("n6", "schedule", 24, 320),
|
||||||
|
mk("n7", "gen-annex", 270, 320),
|
||||||
|
mk("n5", "map-control", 516, 320),
|
||||||
|
];
|
||||||
|
const edges: FlowEdge[] = [
|
||||||
|
{ id: "e1", from: ["n1", 0], to: ["n2", 0] },
|
||||||
|
{ id: "e2", from: ["n2", 0], to: ["n3", 0] },
|
||||||
|
{ id: "e3", from: ["n2", 1], to: ["n4", 0] },
|
||||||
|
{ id: "e4", from: ["n6", 0], to: ["n7", 0] },
|
||||||
|
{ id: "e5", from: ["n7", 0], to: ["n5", 0] },
|
||||||
|
];
|
||||||
|
return { nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function portY(node: FlowNode, side: "in" | "out"): number[] {
|
||||||
|
const m = FLOW_MODULES[node.mod];
|
||||||
|
if (!m) return [];
|
||||||
|
const ports = side === "in" ? m.in : m.out;
|
||||||
|
const H = nodeH(m);
|
||||||
|
if (ports.length === 0) return [];
|
||||||
|
return ports.map((_, i) => node.y + (H * (i + 1)) / (ports.length + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function portX(node: FlowNode, side: "in" | "out"): number {
|
||||||
|
return side === "in" ? node.x : node.x + NODE_W;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wirePath(x1: number, y1: number, x2: number, y2: number): string {
|
||||||
|
const dx = Math.max(36, Math.abs(x2 - x1) * 0.45);
|
||||||
|
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user