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:
co-authored by
Claude Opus 4.7
parent
91a655b6df
commit
a03aa0a4c4
@@ -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