+
+
+
Manage system alerts and notifications
+
+
+
+
+
Track and manage deployments
+
+
+
+
+
Manage workflows and automation
+
+
+
+
+
Manage teams and members
+
+
=17",
+ "react-dom": ">=17"
+ }
+ },
+ "node_modules/@xyflow/system": {
+ "version": "0.0.74",
+ "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz",
+ "integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-drag": "^3.0.7",
+ "@types/d3-interpolate": "^3.0.4",
+ "@types/d3-selection": "^3.0.10",
+ "@types/d3-transition": "^3.0.8",
+ "@types/d3-zoom": "^3.0.8",
+ "d3-drag": "^3.0.0",
+ "d3-interpolate": "^3.0.1",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0"
+ }
+ },
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -2631,6 +2664,12 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/classcat": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
+ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
+ "license": "MIT"
+ },
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -2690,9 +2729,114 @@
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.2.tgz",
"integrity": "sha512-D80T+tiqkd/8B0xNlbstWDG4x6aqVfO52+OlSUNIdkTvmNw0uQpJLeos2J/2XvpyidAFuTPmpad+tUxLndwj6g==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -6443,6 +6587,15 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -6600,6 +6753,34 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
+ },
+ "node_modules/zustand": {
+ "version": "4.5.7",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+ "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index 001ba72..29e0176 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,17 @@
},
"homepage": "https://github.com/OpsOrch/opsorch-console#readme",
"description": "OpsOrch Console is the operator-focused web UI for OpsOrch. It provides a unified interface for browsing and managing incidents, logs, metrics, services, tickets, and AI-powered chat assistance.",
- "keywords": ["opsorch", "console", "ui", "web", "incidents", "logs", "metrics", "observability", "operations"],
+ "keywords": [
+ "opsorch",
+ "console",
+ "ui",
+ "web",
+ "incidents",
+ "logs",
+ "metrics",
+ "observability",
+ "operations"
+ ],
"author": "OpsOrch",
"scripts": {
"dev": "next dev",
diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts
new file mode 100644
index 0000000..cca476f
--- /dev/null
+++ b/tests/workflow.test.ts
@@ -0,0 +1,151 @@
+import assert from "node:assert";
+import test from "node:test";
+import {
+ buildHighlightedEdges,
+ buildHighlightedNodes,
+ buildLevelGroups,
+ computeEdgePaths,
+ getEdgeCounts,
+ getIncomingSources,
+ getOutgoingTargets,
+ normalizeStepStatus,
+ sortStepsForLevel,
+} from "../app/lib/workflow.js";
+import type { OrchestrationStep, StepStatus } from "../app/lib/types.js";
+
+const baseMeta = {};
+
+const step1: OrchestrationStep = {
+ id: "step-1",
+ title: "Start",
+ description: "start",
+ type: "manual",
+ metadata: baseMeta,
+};
+
+const step2: OrchestrationStep = {
+ id: "step-2",
+ title: "Next",
+ description: "next",
+ type: "manual",
+ dependsOn: ["step-1"],
+ metadata: baseMeta,
+};
+
+const step3: OrchestrationStep = {
+ id: "step-3",
+ title: "Sibling",
+ description: "sibling",
+ type: "manual",
+ dependsOn: ["step-1"],
+ metadata: baseMeta,
+};
+
+test("normalizeStepStatus maps backend variants", () => {
+ assert.equal(normalizeStepStatus("completed"), "succeeded");
+ assert.equal(normalizeStepStatus("in_progress"), "running");
+ assert.equal(normalizeStepStatus("pending"), "pending");
+ assert.equal(normalizeStepStatus(undefined), "pending");
+});
+
+test("buildLevelGroups groups steps by dependency depth", () => {
+ const { levelGroups } = buildLevelGroups([step1, step2, step3]);
+ assert.equal(levelGroups.get(0)?.length, 1);
+ assert.equal(levelGroups.get(1)?.length, 2);
+});
+
+test("incoming/outgoing maps reflect dependencies", () => {
+ const outgoing = getOutgoingTargets([step1, step2, step3]);
+ const incoming = getIncomingSources([step1, step2, step3]);
+ assert.deepEqual(outgoing.get("step-1"), ["step-2", "step-3"]);
+ assert.deepEqual(incoming.get("step-2"), ["step-1"]);
+ assert.deepEqual(incoming.get("step-3"), ["step-1"]);
+});
+
+test("edge counts track fan-in and fan-out", () => {
+ const { inCounts, outCounts } = getEdgeCounts([step1, step2, step3]);
+ assert.equal(inCounts.get("step-1"), 0);
+ assert.equal(inCounts.get("step-2"), 1);
+ assert.equal(outCounts.get("step-1"), 2);
+});
+
+test("sortStepsForLevel groups by shared deps and targets", () => {
+ const outgoing = getOutgoingTargets([step1, step2, step3]);
+ const sorted = sortStepsForLevel([step3, step2], outgoing);
+ assert.deepEqual(sorted.map((s) => s.id), ["step-2", "step-3"]);
+});
+
+test("highlight helpers include incoming/outgoing edges and nodes", () => {
+ const outgoing = getOutgoingTargets([step1, step2, step3]);
+ const incoming = getIncomingSources([step1, step2, step3]);
+ const edges = buildHighlightedEdges("step-1", outgoing, incoming);
+ assert(edges.has("step-1-step-2"));
+ assert(edges.has("step-1-step-3"));
+ const nodes = buildHighlightedNodes("step-2", outgoing, incoming);
+ assert(nodes.has("step-1"));
+ assert(nodes.has("step-2"));
+});
+
+test("computeEdgePaths builds trunk + branches for fan-out", () => {
+ const steps = [step1, step2, step3];
+ const stepById = new Map(steps.map((s) => [s.id, s]));
+ const nodeRects = new Map([
+ ["step-1", { left: 100, right: 200, top: 20, bottom: 70 }],
+ ["step-2", { left: 80, right: 180, top: 220, bottom: 270 }],
+ ["step-3", { left: 220, right: 320, top: 220, bottom: 270 }],
+ ]);
+ const statusById = new Map
([
+ ["step-1", "succeeded"],
+ ["step-2", "pending"],
+ ["step-3", "pending"],
+ ]);
+ const edgeCounts = getEdgeCounts(steps);
+ const outgoingTargets = getOutgoingTargets(steps);
+ const paths = computeEdgePaths({
+ steps,
+ stepById,
+ nodeRects,
+ statusById,
+ edgeCounts,
+ outgoingTargets,
+ showStatus: false,
+ highlightedEdges: new Set(),
+ hoveredStepId: null,
+ });
+
+ const ids = paths.map((p) => p.id);
+ assert(ids.includes("step-1-trunk"));
+ assert(ids.includes("step-1-step-2"));
+ assert(ids.includes("step-1-step-3"));
+});
+
+test("computeEdgePaths flags blocked edges", () => {
+ const steps = [step1, step2, step3];
+ const stepById = new Map(steps.map((s) => [s.id, s]));
+ const nodeRects = new Map([
+ ["step-1", { left: 100, right: 200, top: 20, bottom: 70 }],
+ ["step-2", { left: 100, right: 200, top: 220, bottom: 270 }],
+ ["step-3", { left: 120, right: 220, top: 120, bottom: 180 }],
+ ]);
+ const statusById = new Map([
+ ["step-1", "succeeded"],
+ ["step-2", "pending"],
+ ["step-3", "pending"],
+ ]);
+ const edgeCounts = getEdgeCounts(steps);
+ const outgoingTargets = getOutgoingTargets(steps);
+ const paths = computeEdgePaths({
+ steps,
+ stepById,
+ nodeRects,
+ statusById,
+ edgeCounts,
+ outgoingTargets,
+ showStatus: false,
+ highlightedEdges: new Set(),
+ hoveredStepId: null,
+ });
+
+ const blockedEdge = paths.find((p) => p.id === "step-1-step-2");
+ assert.equal(blockedEdge?.blocked, true);
+});