@nwire/studio 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/src/App.vue +62 -56
- package/src/components/BcCard.stories.ts +47 -0
- package/src/components/BcCard.vue +152 -0
- package/src/components/DurationBar.stories.ts +55 -0
- package/src/components/DurationBar.vue +72 -0
- package/src/components/ErrorCard.stories.ts +133 -0
- package/src/components/ErrorCard.vue +153 -0
- package/src/components/GraphCanvas.stories.ts +48 -0
- package/src/components/GraphCanvas.vue +88 -0
- package/src/components/KpiTile.stories.ts +32 -0
- package/src/components/KpiTile.vue +39 -0
- package/src/components/LiveTable.stories.ts +78 -0
- package/src/components/LiveTable.vue +186 -0
- package/src/components/MetadataInspector.stories.ts +53 -0
- package/src/components/MetadataInspector.vue +105 -0
- package/src/components/NodeCard.stories.ts +44 -0
- package/src/components/NodeCard.vue +150 -0
- package/src/components/RcaPanel.stories.ts +95 -0
- package/src/components/RcaPanel.vue +223 -0
- package/src/components/ServiceNode.vue +134 -0
- package/src/components/SourceDrawer.vue +6 -4
- package/src/components/SourcePill.vue +10 -3
- package/src/components/StatusBadge.stories.ts +33 -0
- package/src/components/StatusBadge.vue +54 -0
- package/src/components/Waterfall.stories.ts +85 -0
- package/src/components/Waterfall.vue +53 -0
- package/src/components/WaterfallRow.vue +74 -0
- package/src/components/__tests__/BcCard.test.ts +53 -0
- package/src/components/__tests__/DurationBar.test.ts +31 -0
- package/src/components/__tests__/ErrorCard.test.ts +71 -0
- package/src/components/__tests__/KpiTile.test.ts +23 -0
- package/src/components/__tests__/LiveTable.test.ts +100 -0
- package/src/components/__tests__/MetadataInspector.test.ts +38 -0
- package/src/components/__tests__/NodeCard.test.ts +116 -0
- package/src/components/__tests__/RcaPanel.test.ts +81 -0
- package/src/components/__tests__/StatusBadge.test.ts +23 -0
- package/src/components/__tests__/Waterfall.test.ts +54 -0
- package/src/components/index.ts +13 -0
- package/src/composables/__tests__/composables-context.test.ts +107 -0
- package/src/composables/__tests__/useTelemetry.test.ts +104 -0
- package/src/composables/__tests__/useTelemetryRuns.test.ts +282 -0
- package/src/composables/useDiscovery.ts +73 -0
- package/src/composables/useEndpoints.ts +94 -0
- package/src/composables/useLogTail.ts +51 -0
- package/src/composables/useManifest.ts +43 -0
- package/src/composables/useProcesses.ts +114 -0
- package/src/composables/useProject.ts +34 -0
- package/src/composables/useTelemetry.ts +270 -0
- package/src/lib/__tests__/bc-graph.test.ts +218 -0
- package/src/lib/__tests__/dispatch-form.test.ts +113 -0
- package/src/lib/__tests__/error-friendly.test.ts +198 -0
- package/src/lib/__tests__/home.test.ts +231 -0
- package/src/lib/__tests__/inspect.test.ts +160 -0
- package/src/lib/__tests__/kind-colors.test.ts +59 -0
- package/src/lib/__tests__/live-table.test.ts +194 -0
- package/src/lib/__tests__/manifest-health.test.ts +120 -0
- package/src/lib/__tests__/manifest.test.ts +87 -0
- package/src/lib/__tests__/metadata.test.ts +47 -0
- package/src/lib/__tests__/node-metrics.test.ts +144 -0
- package/src/lib/__tests__/operate.test.ts +97 -0
- package/src/lib/__tests__/pipeline-flow.test.ts +79 -0
- package/src/lib/__tests__/rca.test.ts +124 -0
- package/src/lib/__tests__/telemetry.test.ts +91 -0
- package/src/lib/__tests__/topology-graph.test.ts +331 -0
- package/src/lib/__tests__/topology-view.test.ts +154 -0
- package/src/lib/__tests__/waterfall.test.ts +165 -0
- package/src/lib/bc-graph.ts +298 -0
- package/src/lib/dispatch-form.ts +160 -0
- package/src/lib/error-friendly.ts +288 -0
- package/src/lib/home.ts +191 -0
- package/src/lib/inspect.ts +226 -0
- package/src/lib/kind-colors.ts +132 -0
- package/src/lib/live-table.ts +204 -0
- package/src/lib/manifest-health.ts +71 -0
- package/src/lib/manifest.ts +139 -0
- package/src/lib/metadata.ts +52 -0
- package/src/lib/node-metrics.ts +242 -0
- package/src/lib/operate.ts +114 -0
- package/src/lib/pipeline-flow.ts +120 -0
- package/src/lib/rca.ts +193 -0
- package/src/lib/telemetry.ts +155 -0
- package/src/lib/topology-graph.ts +551 -0
- package/src/lib/topology-view.ts +185 -0
- package/src/lib/waterfall.ts +148 -0
- package/src/main.ts +63 -29
- package/src/pages/Errors.vue +272 -0
- package/src/pages/Home.stories.ts +7 -8
- package/src/pages/Home.vue +255 -540
- package/src/pages/Hooks.stories.ts +44 -0
- package/src/pages/Hooks.vue +165 -164
- package/src/pages/Inspect.vue +240 -0
- package/src/pages/Map.vue +187 -0
- package/src/pages/Operate.vue +74 -0
- package/src/pages/Plugins.stories.ts +1 -1
- package/src/pages/Plugins.vue +174 -238
- package/src/pages/Projects.vue +62 -60
- package/src/pages/Streams.vue +344 -0
- package/src/pages/Topology.vue +318 -136
- package/src/pages/Trace.vue +174 -412
- package/src/pages/__tests__/Home.test.ts +109 -54
- package/src/pages/__tests__/Hooks.test.ts +5 -5
- package/src/pages/__tests__/Inspect.test.ts +111 -0
- package/src/pages/__tests__/Plugins.test.ts +85 -35
- package/src/pages/__tests__/Trace.test.ts +117 -0
- package/src/pages/operate/CommandsPanel.vue +186 -0
- package/src/pages/{Dispatch.vue → operate/DispatchPanel.vue} +100 -203
- package/src/pages/operate/EndpointPicker.vue +56 -0
- package/src/pages/operate/RunPanel.vue +316 -0
- package/src/server/__tests__/nwire-read.test.ts +80 -0
- package/src/server/nwire-read.ts +63 -0
- package/vite.config.ts +220 -2
- package/src/lib/__tests__/normalize-cache.test.ts +0 -105
- package/src/lib/cache.ts +0 -312
- package/src/lib/normalize-cache.ts +0 -92
- package/src/pages/Actions.vue +0 -171
- package/src/pages/Apps.vue +0 -177
- package/src/pages/Commands.vue +0 -262
- package/src/pages/Events.vue +0 -210
- package/src/pages/Live.vue +0 -249
- package/src/pages/Overview.vue +0 -161
- package/src/pages/Projections.vue +0 -148
- package/src/pages/Queries.vue +0 -148
- package/src/pages/Run.vue +0 -618
- package/src/pages/Sinks.vue +0 -124
- package/src/pages/TraceNode.vue +0 -164
- package/src/pages/Workflows.vue +0 -184
- package/src/pages/__tests__/Actions.test.ts +0 -98
- package/src/pages/__tests__/Projections.test.ts +0 -90
- package/src/pages/__tests__/Queries.test.ts +0 -86
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { manifestView, type Manifest } from "../manifest";
|
|
3
|
+
import type { TelemetryRecord } from "../telemetry";
|
|
4
|
+
import {
|
|
5
|
+
hookRegistry,
|
|
6
|
+
registeringPlugin,
|
|
7
|
+
pluginRegistry,
|
|
8
|
+
pluginInternals,
|
|
9
|
+
liveFireTally,
|
|
10
|
+
} from "../topology-view";
|
|
11
|
+
|
|
12
|
+
/** Build a tolerant view over a minimal manifest with a given topology + model. */
|
|
13
|
+
function viewOf(topology?: object, model?: object) {
|
|
14
|
+
const manifest = {
|
|
15
|
+
version: 3,
|
|
16
|
+
actions: [],
|
|
17
|
+
events: [],
|
|
18
|
+
model: model ?? { nodes: [{ id: "_seed", kind: "_seed", name: "_seed" }], edges: [] },
|
|
19
|
+
...(topology ? { topology } : {}),
|
|
20
|
+
} as unknown as Manifest;
|
|
21
|
+
return manifestView(manifest);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("topology-view: hookRegistry", () => {
|
|
25
|
+
it("shapes topology.hooks into name-sorted rows with defaulted counts", () => {
|
|
26
|
+
const view = viewOf({
|
|
27
|
+
hooks: [
|
|
28
|
+
{ name: "zeta", chain: 3, listeners: 2 },
|
|
29
|
+
{ name: "alpha" }, // missing counts default to 0
|
|
30
|
+
],
|
|
31
|
+
plugins: [],
|
|
32
|
+
});
|
|
33
|
+
const rows = hookRegistry(view);
|
|
34
|
+
expect(rows.map((r) => r.name)).toEqual(["alpha", "zeta"]);
|
|
35
|
+
expect(rows[0]).toMatchObject({ id: "hook:alpha", chain: 0, listeners: 0 });
|
|
36
|
+
expect(rows[1]).toMatchObject({ chain: 3, listeners: 2 });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("uses the topology hook id when present", () => {
|
|
40
|
+
const view = viewOf({ hooks: [{ name: "h", id: "custom-id" }], plugins: [] });
|
|
41
|
+
expect(hookRegistry(view)[0]!.id).toBe("custom-id");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("carries the hook's source location through to the row (the SourcePill)", () => {
|
|
45
|
+
const source = { file: "/app/orders.ts", line: 12, column: 4 };
|
|
46
|
+
const view = viewOf({ hooks: [{ name: "h", source }], plugins: [] });
|
|
47
|
+
expect(hookRegistry(view)[0]!.source).toEqual(source);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns [] for a null view or a topology-less manifest", () => {
|
|
51
|
+
expect(hookRegistry(null)).toEqual([]);
|
|
52
|
+
expect(hookRegistry(viewOf(undefined))).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("topology-view: registeringPlugin", () => {
|
|
57
|
+
it("extracts the plugin name from boot/shutdown lifecycle hooks", () => {
|
|
58
|
+
expect(registeringPlugin("plugin.boot:auth")).toBe("auth");
|
|
59
|
+
expect(registeringPlugin("plugin.shutdown:storage")).toBe("storage");
|
|
60
|
+
});
|
|
61
|
+
it("returns null for non-lifecycle hooks and empty input", () => {
|
|
62
|
+
expect(registeringPlugin("action.before:x")).toBeNull();
|
|
63
|
+
expect(registeringPlugin("")).toBeNull();
|
|
64
|
+
expect(registeringPlugin(undefined)).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("topology-view: pluginRegistry", () => {
|
|
69
|
+
it("shapes topology.plugins into name-sorted rows", () => {
|
|
70
|
+
const view = viewOf({ hooks: [], plugins: [{ name: "storage" }, { name: "auth" }] });
|
|
71
|
+
const rows = pluginRegistry(view);
|
|
72
|
+
expect(rows.map((r) => r.name)).toEqual(["auth", "storage"]);
|
|
73
|
+
expect(rows[0]!.id).toBe("plugin:auth");
|
|
74
|
+
});
|
|
75
|
+
it("returns [] without a topology", () => {
|
|
76
|
+
expect(pluginRegistry(viewOf(undefined))).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("topology-view: pluginInternals", () => {
|
|
81
|
+
it("collects boot/shutdown lifecycle hooks (boot first)", () => {
|
|
82
|
+
const view = viewOf({
|
|
83
|
+
plugins: [{ name: "auth" }],
|
|
84
|
+
hooks: [
|
|
85
|
+
{ name: "plugin.shutdown:auth", chain: 1, listeners: 0 },
|
|
86
|
+
{ name: "plugin.boot:auth", chain: 2, listeners: 1 },
|
|
87
|
+
{ name: "plugin.boot:other" }, // a different plugin — excluded
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
const internals = pluginInternals(view, "auth")!;
|
|
91
|
+
expect(internals.lifecycleHooks.map((h) => h.phase)).toEqual(["boot", "shutdown"]);
|
|
92
|
+
expect(internals.lifecycleHooks[0]).toMatchObject({
|
|
93
|
+
name: "plugin.boot:auth",
|
|
94
|
+
chain: 2,
|
|
95
|
+
listeners: 1,
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("reads contributes/mounts edges and a same-named capability's ctx-by-kind", () => {
|
|
100
|
+
const model = {
|
|
101
|
+
nodes: [
|
|
102
|
+
{ id: "plugin:auth", kind: "plugin", name: "auth" },
|
|
103
|
+
{ id: "binding:tokenStore", kind: "binding", name: "tokenStore" },
|
|
104
|
+
{ id: "route:GET /me", kind: "route", name: "GET /me" },
|
|
105
|
+
{
|
|
106
|
+
id: "capability:auth",
|
|
107
|
+
kind: "capability",
|
|
108
|
+
name: "auth",
|
|
109
|
+
data: { kinds: ["action"], ctxKeys: ["currentUser", "can"] },
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
edges: [
|
|
113
|
+
{ from: "plugin:auth", to: "binding:tokenStore", type: "contributes" },
|
|
114
|
+
{ from: "plugin:auth", to: "route:GET /me", type: "mounts" },
|
|
115
|
+
],
|
|
116
|
+
};
|
|
117
|
+
const view = viewOf({ plugins: [{ name: "auth" }], hooks: [] }, model);
|
|
118
|
+
const internals = pluginInternals(view, "auth")!;
|
|
119
|
+
expect(internals.contributes).toEqual(["GET /me", "tokenStore"]);
|
|
120
|
+
expect(internals.ctxByKind).toEqual({ action: ["currentUser", "can"] });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("returns null for a null view or no name", () => {
|
|
124
|
+
expect(pluginInternals(null, "auth")).toBeNull();
|
|
125
|
+
expect(pluginInternals(viewOf({ plugins: [], hooks: [] }), null)).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("topology-view: liveFireTally", () => {
|
|
130
|
+
const step = (hookName: string, phase: string, ts: string): TelemetryRecord =>
|
|
131
|
+
({ kind: "hook.step", hookName, phase, ts }) as unknown as TelemetryRecord;
|
|
132
|
+
|
|
133
|
+
it("counts one fire per start phase and tracks the last timestamp", () => {
|
|
134
|
+
const recs = [
|
|
135
|
+
step("h", "start", "2026-01-01T00:00:00Z"),
|
|
136
|
+
step("h", "end", "2026-01-01T00:00:01Z"),
|
|
137
|
+
step("h", "start", "2026-01-01T00:00:02Z"),
|
|
138
|
+
];
|
|
139
|
+
const tally = liveFireTally(recs);
|
|
140
|
+
expect(tally.get("h")).toEqual({ fires: 2, lastTs: "2026-01-01T00:00:02Z" });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("ignores non-hook.step records and records without a hook name", () => {
|
|
144
|
+
const recs = [
|
|
145
|
+
{ kind: "action.completed", ts: "t" } as unknown as TelemetryRecord,
|
|
146
|
+
{ kind: "hook.step", phase: "start", ts: "t" } as unknown as TelemetryRecord,
|
|
147
|
+
];
|
|
148
|
+
expect(liveFireTally(recs).size).toBe(0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns an empty map for no records (graceful idle)", () => {
|
|
152
|
+
expect(liveFireTally([]).size).toBe(0);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { buildWaterfall, criticalPath, recordLabel } from "../waterfall";
|
|
3
|
+
import type { TelemetryRecord, TraceNode } from "../telemetry";
|
|
4
|
+
|
|
5
|
+
function rec(over: Partial<TelemetryRecord> & { kind: string }): TelemetryRecord {
|
|
6
|
+
return over;
|
|
7
|
+
}
|
|
8
|
+
function node(record: TelemetryRecord, children: TraceNode[] = []): TraceNode {
|
|
9
|
+
return { record, children };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const t = (ms: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, 0, ms)).toISOString();
|
|
13
|
+
|
|
14
|
+
describe("recordLabel", () => {
|
|
15
|
+
it("prefers a name/handler field over the kind", () => {
|
|
16
|
+
expect(recordLabel(rec({ kind: "action.completed", name: "orders.place" }))).toBe(
|
|
17
|
+
"orders.place",
|
|
18
|
+
);
|
|
19
|
+
expect(recordLabel(rec({ kind: "event.published", event: "orders.placed" }))).toBe(
|
|
20
|
+
"orders.placed",
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
it("falls back to the kind when no name is carried", () => {
|
|
24
|
+
expect(recordLabel(rec({ kind: "timer.fired" }))).toBe("timer.fired");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("buildWaterfall", () => {
|
|
29
|
+
it("flattens the forest in pre-order with depth", () => {
|
|
30
|
+
const forest = [
|
|
31
|
+
node(
|
|
32
|
+
rec({
|
|
33
|
+
kind: "action.dispatched",
|
|
34
|
+
name: "a",
|
|
35
|
+
envelope: { messageId: "m1", causationId: "m1", correlationId: "c" },
|
|
36
|
+
}),
|
|
37
|
+
[
|
|
38
|
+
node(
|
|
39
|
+
rec({
|
|
40
|
+
kind: "event.published",
|
|
41
|
+
name: "b",
|
|
42
|
+
envelope: { messageId: "m2", causationId: "m1", correlationId: "c" },
|
|
43
|
+
}),
|
|
44
|
+
[
|
|
45
|
+
node(
|
|
46
|
+
rec({
|
|
47
|
+
kind: "listener.fired",
|
|
48
|
+
name: "c",
|
|
49
|
+
envelope: { messageId: "m3", causationId: "m2", correlationId: "c" },
|
|
50
|
+
}),
|
|
51
|
+
),
|
|
52
|
+
],
|
|
53
|
+
),
|
|
54
|
+
],
|
|
55
|
+
),
|
|
56
|
+
];
|
|
57
|
+
const { rows } = buildWaterfall(forest);
|
|
58
|
+
expect(rows.map((r) => r.label)).toEqual(["a", "b", "c"]);
|
|
59
|
+
expect(rows.map((r) => r.depth)).toEqual([0, 1, 2]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("positions bars against the trace span when records carry ts", () => {
|
|
63
|
+
const forest = [
|
|
64
|
+
node(
|
|
65
|
+
rec({
|
|
66
|
+
kind: "action.dispatched",
|
|
67
|
+
ts: t(0),
|
|
68
|
+
durationMs: 100,
|
|
69
|
+
envelope: { messageId: "m1", causationId: "m1", correlationId: "c" },
|
|
70
|
+
}),
|
|
71
|
+
[
|
|
72
|
+
node(
|
|
73
|
+
rec({
|
|
74
|
+
kind: "query.executed",
|
|
75
|
+
ts: t(50),
|
|
76
|
+
durationMs: 50,
|
|
77
|
+
envelope: { messageId: "m2", causationId: "m1", correlationId: "c" },
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
],
|
|
81
|
+
),
|
|
82
|
+
];
|
|
83
|
+
const { rows, spanMs } = buildWaterfall(forest);
|
|
84
|
+
expect(spanMs).toBe(100);
|
|
85
|
+
expect(rows[0]!.offsetPct).toBe(0);
|
|
86
|
+
expect(rows[0]!.widthPct).toBe(100);
|
|
87
|
+
expect(rows[1]!.offsetPct).toBe(50);
|
|
88
|
+
expect(rows[1]!.widthPct).toBe(50);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("clamps a zero-duration span to a visible minimum width", () => {
|
|
92
|
+
const forest = [
|
|
93
|
+
node(
|
|
94
|
+
rec({
|
|
95
|
+
kind: "event.emitted",
|
|
96
|
+
ts: t(0),
|
|
97
|
+
durationMs: 0,
|
|
98
|
+
envelope: { messageId: "m1", causationId: "m1", correlationId: "c" },
|
|
99
|
+
}),
|
|
100
|
+
),
|
|
101
|
+
];
|
|
102
|
+
const { rows } = buildWaterfall(forest);
|
|
103
|
+
expect(rows[0]!.widthPct).toBeGreaterThan(0);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("falls back to full-width bars when no record carries a ts", () => {
|
|
107
|
+
const forest = [
|
|
108
|
+
node(
|
|
109
|
+
rec({
|
|
110
|
+
kind: "action.completed",
|
|
111
|
+
envelope: { messageId: "m1", causationId: "m1", correlationId: "c" },
|
|
112
|
+
}),
|
|
113
|
+
),
|
|
114
|
+
];
|
|
115
|
+
const { rows } = buildWaterfall(forest);
|
|
116
|
+
expect(rows[0]!.offsetPct).toBe(0);
|
|
117
|
+
expect(rows[0]!.widthPct).toBe(100);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("marks failures", () => {
|
|
121
|
+
const forest = [
|
|
122
|
+
node(
|
|
123
|
+
rec({
|
|
124
|
+
kind: "action.failed",
|
|
125
|
+
error: { name: "E", message: "boom" },
|
|
126
|
+
envelope: { messageId: "m1", causationId: "m1", correlationId: "c" },
|
|
127
|
+
}),
|
|
128
|
+
),
|
|
129
|
+
];
|
|
130
|
+
const { rows } = buildWaterfall(forest);
|
|
131
|
+
expect(rows[0]!.failed).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("criticalPath", () => {
|
|
136
|
+
it("selects the longest-duration root→leaf chain", () => {
|
|
137
|
+
const slow = rec({
|
|
138
|
+
kind: "query.executed",
|
|
139
|
+
name: "slow",
|
|
140
|
+
durationMs: 90,
|
|
141
|
+
envelope: { messageId: "m2", causationId: "m1", correlationId: "c" },
|
|
142
|
+
});
|
|
143
|
+
const fast = rec({
|
|
144
|
+
kind: "query.executed",
|
|
145
|
+
name: "fast",
|
|
146
|
+
durationMs: 5,
|
|
147
|
+
envelope: { messageId: "m3", causationId: "m1", correlationId: "c" },
|
|
148
|
+
});
|
|
149
|
+
const root = rec({
|
|
150
|
+
kind: "action.dispatched",
|
|
151
|
+
name: "root",
|
|
152
|
+
durationMs: 10,
|
|
153
|
+
envelope: { messageId: "m1", causationId: "m1", correlationId: "c" },
|
|
154
|
+
});
|
|
155
|
+
const forest = [node(root, [node(fast), node(slow)])];
|
|
156
|
+
|
|
157
|
+
const crit = criticalPath(forest);
|
|
158
|
+
const { rows } = buildWaterfall(forest);
|
|
159
|
+
const byLabel = Object.fromEntries(rows.map((r) => [r.label, r.critical]));
|
|
160
|
+
expect(byLabel.root).toBe(true);
|
|
161
|
+
expect(byLabel.slow).toBe(true);
|
|
162
|
+
expect(byLabel.fast).toBe(false);
|
|
163
|
+
expect(crit.size).toBe(2);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the BC (bounded-context) graph the Map renders — one card per app with
|
|
3
|
+
* its member primitives, plus the inter-BC flow edges.
|
|
4
|
+
*
|
|
5
|
+
* Resilient by design: membership comes from the manifest's per-kind arrays
|
|
6
|
+
* (always present); edges come from the deep `model.edges` when available, else
|
|
7
|
+
* from the flat `graph.events`. So the Map works against both a full deep
|
|
8
|
+
* manifest and an older/partial one — never throws on missing fields.
|
|
9
|
+
*/
|
|
10
|
+
import type { Manifest } from "./manifest";
|
|
11
|
+
|
|
12
|
+
export interface BcRow {
|
|
13
|
+
/** Graph node id (`${kind}:${name}`). */
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly kind: string;
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly public?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface BcNode {
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly rows: BcRow[];
|
|
22
|
+
}
|
|
23
|
+
export interface BcEdge {
|
|
24
|
+
readonly from: string;
|
|
25
|
+
readonly to: string;
|
|
26
|
+
readonly label?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface BcGraph {
|
|
29
|
+
readonly bcs: BcNode[];
|
|
30
|
+
readonly edges: BcEdge[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A positioned BC node — what the canvas lays out. */
|
|
34
|
+
export interface BcLayoutNode extends BcNode {
|
|
35
|
+
readonly x: number;
|
|
36
|
+
readonly y: number;
|
|
37
|
+
readonly width: number;
|
|
38
|
+
readonly height: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A business-rule invariant surfaced from a `validate()` predicate. */
|
|
42
|
+
export interface NodeInvariant {
|
|
43
|
+
readonly rule: string;
|
|
44
|
+
readonly message?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A config module surfaced on an app: its file path and top-level field names. */
|
|
48
|
+
export interface NodeConfigModule {
|
|
49
|
+
readonly file: string;
|
|
50
|
+
readonly keys: readonly string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The full detail for one selected primitive — what NodeCard renders. */
|
|
54
|
+
export interface BcNodeDetail {
|
|
55
|
+
readonly id: string;
|
|
56
|
+
readonly kind: string;
|
|
57
|
+
readonly name: string;
|
|
58
|
+
readonly app?: string;
|
|
59
|
+
readonly description?: string;
|
|
60
|
+
readonly public?: boolean;
|
|
61
|
+
readonly source?: { file: string; line?: number; column?: number };
|
|
62
|
+
/** Input/payload schema when the kind carries one (actions, queries…). */
|
|
63
|
+
readonly schema?: unknown;
|
|
64
|
+
readonly emits?: string[];
|
|
65
|
+
/** Business rules surfaced from `validate()` predicates (actions, actors). */
|
|
66
|
+
readonly invariants?: NodeInvariant[];
|
|
67
|
+
/** Environment variable names this app reads (app nodes only). */
|
|
68
|
+
readonly env?: readonly string[];
|
|
69
|
+
/** Config modules this app exposes (app nodes only). */
|
|
70
|
+
readonly config?: readonly NodeConfigModule[];
|
|
71
|
+
/** Remaining kind-specific fields (states, schedule, subscribesTo, …). */
|
|
72
|
+
readonly extra: Record<string, unknown>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Normalize a raw `config` value to `NodeConfigModule[]` (or undefined). */
|
|
76
|
+
export function toConfigModules(raw: unknown): NodeConfigModule[] | undefined {
|
|
77
|
+
if (!Array.isArray(raw)) return undefined;
|
|
78
|
+
const out: NodeConfigModule[] = [];
|
|
79
|
+
for (const r of raw) {
|
|
80
|
+
if (r && typeof r === "object" && typeof (r as { file?: unknown }).file === "string") {
|
|
81
|
+
const { file, keys } = r as { file: string; keys?: unknown };
|
|
82
|
+
out.push({ file, keys: Array.isArray(keys) ? (keys as string[]) : [] });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out.length ? out : undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Normalize a raw `env` value to a string array (or undefined). */
|
|
89
|
+
export function toEnvVars(raw: unknown): string[] | undefined {
|
|
90
|
+
if (!Array.isArray(raw)) return undefined;
|
|
91
|
+
const out = raw.filter((x): x is string => typeof x === "string");
|
|
92
|
+
return out.length ? out : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Normalize a raw `invariants` value to `NodeInvariant[]` (or undefined). */
|
|
96
|
+
export function toInvariants(raw: unknown): NodeInvariant[] | undefined {
|
|
97
|
+
if (!Array.isArray(raw)) return undefined;
|
|
98
|
+
const out: NodeInvariant[] = [];
|
|
99
|
+
for (const r of raw) {
|
|
100
|
+
if (r && typeof r === "object" && typeof (r as { rule?: unknown }).rule === "string") {
|
|
101
|
+
const { rule, message } = r as { rule: string; message?: unknown };
|
|
102
|
+
out.push(typeof message === "string" ? { rule, message } : { rule });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out.length ? out : undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Per-kind arrays on the manifest → the node kind they map to. */
|
|
109
|
+
const MEMBER_KINDS: ReadonlyArray<readonly [string, string]> = [
|
|
110
|
+
["actions", "action"],
|
|
111
|
+
["queries", "query"],
|
|
112
|
+
["events", "event"],
|
|
113
|
+
["actors", "actor"],
|
|
114
|
+
["workflows", "workflow"],
|
|
115
|
+
["projections", "projection"],
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
type Entry = { name?: unknown; app?: unknown; public?: unknown };
|
|
119
|
+
type RawGraphEdge = { from?: unknown; to?: unknown; via?: unknown; type?: unknown };
|
|
120
|
+
|
|
121
|
+
function arr(m: Record<string, unknown>, key: string): Entry[] {
|
|
122
|
+
const v = m[key];
|
|
123
|
+
return Array.isArray(v) ? (v as Entry[]) : [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** `"action:orders.place"` → `"orders.place"`. */
|
|
127
|
+
function nameOfId(id: string): string {
|
|
128
|
+
const i = id.indexOf(":");
|
|
129
|
+
return i === -1 ? id : id.slice(i + 1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function buildBcGraph(manifest: Manifest | null | undefined): BcGraph {
|
|
133
|
+
if (!manifest) return { bcs: [], edges: [] };
|
|
134
|
+
const m = manifest as unknown as Record<string, unknown>;
|
|
135
|
+
|
|
136
|
+
const appNames = new Set<string>();
|
|
137
|
+
for (const a of arr(m, "apps")) if (typeof a.name === "string") appNames.add(a.name);
|
|
138
|
+
|
|
139
|
+
const nameToApp = new Map<string, string>();
|
|
140
|
+
const rowsByApp = new Map<string, BcRow[]>();
|
|
141
|
+
for (const [key, kind] of MEMBER_KINDS) {
|
|
142
|
+
for (const e of arr(m, key)) {
|
|
143
|
+
if (typeof e.name !== "string") continue;
|
|
144
|
+
const app = typeof e.app === "string" && e.app ? e.app : "(unscoped)";
|
|
145
|
+
appNames.add(app);
|
|
146
|
+
nameToApp.set(e.name, app);
|
|
147
|
+
const rows = rowsByApp.get(app) ?? [];
|
|
148
|
+
rows.push({ id: `${kind}:${e.name}`, kind, name: e.name, public: e.public === true });
|
|
149
|
+
rowsByApp.set(app, rows);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const bcs: BcNode[] = [...appNames]
|
|
154
|
+
.sort()
|
|
155
|
+
.map((name) => ({ name, rows: rowsByApp.get(name) ?? [] }));
|
|
156
|
+
|
|
157
|
+
const edges = new Map<string, BcEdge>();
|
|
158
|
+
const add = (fromApp?: string, toApp?: string, label?: string) => {
|
|
159
|
+
if (fromApp && toApp && fromApp !== toApp)
|
|
160
|
+
edges.set(`${fromApp}->${toApp}`, { from: fromApp, to: toApp, label });
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Prefer the deep model edges; fall back to the flat event graph.
|
|
164
|
+
const model = m.model as { edges?: RawGraphEdge[] } | undefined;
|
|
165
|
+
if (model && Array.isArray(model.edges) && model.edges.length) {
|
|
166
|
+
for (const e of model.edges) {
|
|
167
|
+
if (typeof e.from !== "string" || typeof e.to !== "string") continue;
|
|
168
|
+
add(
|
|
169
|
+
nameToApp.get(nameOfId(e.from)),
|
|
170
|
+
nameToApp.get(nameOfId(e.to)),
|
|
171
|
+
typeof e.type === "string" ? e.type : undefined,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
const graph = m.graph as { events?: RawGraphEdge[] } | undefined;
|
|
176
|
+
for (const e of graph?.events ?? []) {
|
|
177
|
+
if (typeof e.from !== "string" || typeof e.to !== "string") continue;
|
|
178
|
+
add(
|
|
179
|
+
nameToApp.get(e.from),
|
|
180
|
+
nameToApp.get(e.to),
|
|
181
|
+
typeof e.via === "string" ? e.via : undefined,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { bcs, edges: [...edges.values()] };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const KIND_TO_KEY: Record<string, string> = Object.fromEntries(
|
|
190
|
+
MEMBER_KINDS.map(([key, kind]) => [kind, key]),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
/** Fields we surface on their own; everything else lands in `extra`. */
|
|
194
|
+
const KNOWN_FIELDS = new Set([
|
|
195
|
+
"name",
|
|
196
|
+
"app",
|
|
197
|
+
"kind",
|
|
198
|
+
"description",
|
|
199
|
+
"public",
|
|
200
|
+
"source",
|
|
201
|
+
"inputSchema",
|
|
202
|
+
"schema",
|
|
203
|
+
"payloadSchema",
|
|
204
|
+
"emits",
|
|
205
|
+
"invariants",
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Resolve a graph-node id (`"action:orders.place"`) to its full detail by
|
|
210
|
+
* looking the entry up in the manifest's flat per-kind array. Returns null
|
|
211
|
+
* when the kind/name isn't found — callers render an empty detail panel.
|
|
212
|
+
*/
|
|
213
|
+
export function nodeDetail(
|
|
214
|
+
manifest: Manifest | null | undefined,
|
|
215
|
+
id: string | null | undefined,
|
|
216
|
+
): BcNodeDetail | null {
|
|
217
|
+
if (!manifest || !id) return null;
|
|
218
|
+
const sep = id.indexOf(":");
|
|
219
|
+
if (sep === -1) return null;
|
|
220
|
+
const kind = id.slice(0, sep);
|
|
221
|
+
const name = id.slice(sep + 1);
|
|
222
|
+
const key = KIND_TO_KEY[kind];
|
|
223
|
+
if (!key) return null;
|
|
224
|
+
|
|
225
|
+
const m = manifest as unknown as Record<string, unknown>;
|
|
226
|
+
const entry = arr(m, key).find((e) => e.name === name) as Record<string, unknown> | undefined;
|
|
227
|
+
if (!entry) return null;
|
|
228
|
+
|
|
229
|
+
const extra: Record<string, unknown> = {};
|
|
230
|
+
for (const [k, v] of Object.entries(entry)) {
|
|
231
|
+
if (!KNOWN_FIELDS.has(k) && v !== undefined) extra[k] = v;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const emits = Array.isArray(entry.emits) ? (entry.emits as string[]) : undefined;
|
|
235
|
+
const schema = entry.inputSchema ?? entry.schema ?? entry.payloadSchema;
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
id,
|
|
239
|
+
kind,
|
|
240
|
+
name,
|
|
241
|
+
app: typeof entry.app === "string" ? entry.app : undefined,
|
|
242
|
+
description: typeof entry.description === "string" ? entry.description : undefined,
|
|
243
|
+
public: entry.public === true,
|
|
244
|
+
source: entry.source as BcNodeDetail["source"],
|
|
245
|
+
schema,
|
|
246
|
+
emits,
|
|
247
|
+
invariants: toInvariants(entry.invariants),
|
|
248
|
+
extra,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface LayoutOptions {
|
|
253
|
+
readonly columns?: number;
|
|
254
|
+
readonly width?: number;
|
|
255
|
+
readonly gapX?: number;
|
|
256
|
+
readonly gapY?: number;
|
|
257
|
+
readonly rowHeight?: number;
|
|
258
|
+
readonly originX?: number;
|
|
259
|
+
readonly originY?: number;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Pure grid layout for BC cards — deterministic positions so the canvas is
|
|
264
|
+
* stable across renders and the layout is unit-testable without a DOM. Card
|
|
265
|
+
* height scales with member count so tall BCs don't overlap their neighbours.
|
|
266
|
+
*/
|
|
267
|
+
export function layoutBcGraph(graph: BcGraph, opts: LayoutOptions = {}): BcLayoutNode[] {
|
|
268
|
+
const columns = Math.max(1, opts.columns ?? 3);
|
|
269
|
+
const width = opts.width ?? 260;
|
|
270
|
+
const gapX = opts.gapX ?? 48;
|
|
271
|
+
const gapY = opts.gapY ?? 48;
|
|
272
|
+
const rowHeight = opts.rowHeight ?? 20;
|
|
273
|
+
const originX = opts.originX ?? 0;
|
|
274
|
+
const originY = opts.originY ?? 0;
|
|
275
|
+
const headerH = 64;
|
|
276
|
+
|
|
277
|
+
// Row height is driven by the tallest card in each grid row so cards in a
|
|
278
|
+
// row share a baseline and the next row clears the tallest one.
|
|
279
|
+
const heightOf = (n: BcNode) => headerH + Math.max(1, n.rows.length) * rowHeight + 16;
|
|
280
|
+
|
|
281
|
+
const out: BcLayoutNode[] = [];
|
|
282
|
+
let rowTop = originY;
|
|
283
|
+
for (let i = 0; i < graph.bcs.length; i += columns) {
|
|
284
|
+
const rowNodes = graph.bcs.slice(i, i + columns);
|
|
285
|
+
const rowMax = Math.max(...rowNodes.map(heightOf));
|
|
286
|
+
rowNodes.forEach((n, col) => {
|
|
287
|
+
out.push({
|
|
288
|
+
...n,
|
|
289
|
+
x: originX + col * (width + gapX),
|
|
290
|
+
y: rowTop,
|
|
291
|
+
width,
|
|
292
|
+
height: heightOf(n),
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
rowTop += rowMax + gapY;
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|