Files
portal/src/lib/flow-modules.ts
T
Sharang ParnerkarandClaude Opus 4.7 a03aa0a4c4
ci / shared (pull_request) Successful in 11s
ci / test (pull_request) Failing after 4m56s
ci / e2e (pull_request) Has been skipped
ci / image (pull_request) Has been skipped
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>
2026-06-04 15:52:07 +02:00

445 lines
11 KiB
TypeScript

// 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}`;
}