@nwire/studio 0.12.1 → 0.13.1

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.
Files changed (130) hide show
  1. package/package.json +5 -3
  2. package/src/App.vue +62 -56
  3. package/src/components/BcCard.stories.ts +47 -0
  4. package/src/components/BcCard.vue +152 -0
  5. package/src/components/DurationBar.stories.ts +55 -0
  6. package/src/components/DurationBar.vue +72 -0
  7. package/src/components/ErrorCard.stories.ts +133 -0
  8. package/src/components/ErrorCard.vue +153 -0
  9. package/src/components/GraphCanvas.stories.ts +48 -0
  10. package/src/components/GraphCanvas.vue +88 -0
  11. package/src/components/KpiTile.stories.ts +32 -0
  12. package/src/components/KpiTile.vue +39 -0
  13. package/src/components/LiveTable.stories.ts +78 -0
  14. package/src/components/LiveTable.vue +186 -0
  15. package/src/components/MetadataInspector.stories.ts +53 -0
  16. package/src/components/MetadataInspector.vue +105 -0
  17. package/src/components/NodeCard.stories.ts +44 -0
  18. package/src/components/NodeCard.vue +150 -0
  19. package/src/components/RcaPanel.stories.ts +95 -0
  20. package/src/components/RcaPanel.vue +223 -0
  21. package/src/components/ServiceNode.vue +134 -0
  22. package/src/components/SourceDrawer.vue +6 -4
  23. package/src/components/SourcePill.vue +10 -3
  24. package/src/components/StatusBadge.stories.ts +33 -0
  25. package/src/components/StatusBadge.vue +54 -0
  26. package/src/components/Waterfall.stories.ts +85 -0
  27. package/src/components/Waterfall.vue +53 -0
  28. package/src/components/WaterfallRow.vue +74 -0
  29. package/src/components/__tests__/BcCard.test.ts +53 -0
  30. package/src/components/__tests__/DurationBar.test.ts +31 -0
  31. package/src/components/__tests__/ErrorCard.test.ts +71 -0
  32. package/src/components/__tests__/KpiTile.test.ts +23 -0
  33. package/src/components/__tests__/LiveTable.test.ts +100 -0
  34. package/src/components/__tests__/MetadataInspector.test.ts +38 -0
  35. package/src/components/__tests__/NodeCard.test.ts +116 -0
  36. package/src/components/__tests__/RcaPanel.test.ts +81 -0
  37. package/src/components/__tests__/StatusBadge.test.ts +23 -0
  38. package/src/components/__tests__/Waterfall.test.ts +54 -0
  39. package/src/components/index.ts +13 -0
  40. package/src/composables/__tests__/composables-context.test.ts +107 -0
  41. package/src/composables/__tests__/useTelemetry.test.ts +104 -0
  42. package/src/composables/__tests__/useTelemetryRuns.test.ts +282 -0
  43. package/src/composables/useDiscovery.ts +73 -0
  44. package/src/composables/useEndpoints.ts +94 -0
  45. package/src/composables/useLogTail.ts +51 -0
  46. package/src/composables/useManifest.ts +43 -0
  47. package/src/composables/useProcesses.ts +114 -0
  48. package/src/composables/useProject.ts +34 -0
  49. package/src/composables/useTelemetry.ts +270 -0
  50. package/src/lib/__tests__/bc-graph.test.ts +218 -0
  51. package/src/lib/__tests__/dispatch-form.test.ts +113 -0
  52. package/src/lib/__tests__/error-friendly.test.ts +198 -0
  53. package/src/lib/__tests__/home.test.ts +231 -0
  54. package/src/lib/__tests__/inspect.test.ts +160 -0
  55. package/src/lib/__tests__/kind-colors.test.ts +59 -0
  56. package/src/lib/__tests__/live-table.test.ts +194 -0
  57. package/src/lib/__tests__/manifest-health.test.ts +120 -0
  58. package/src/lib/__tests__/manifest.test.ts +87 -0
  59. package/src/lib/__tests__/metadata.test.ts +47 -0
  60. package/src/lib/__tests__/node-metrics.test.ts +144 -0
  61. package/src/lib/__tests__/operate.test.ts +97 -0
  62. package/src/lib/__tests__/pipeline-flow.test.ts +79 -0
  63. package/src/lib/__tests__/rca.test.ts +124 -0
  64. package/src/lib/__tests__/telemetry.test.ts +91 -0
  65. package/src/lib/__tests__/topology-graph.test.ts +331 -0
  66. package/src/lib/__tests__/topology-view.test.ts +154 -0
  67. package/src/lib/__tests__/waterfall.test.ts +165 -0
  68. package/src/lib/bc-graph.ts +298 -0
  69. package/src/lib/dispatch-form.ts +160 -0
  70. package/src/lib/error-friendly.ts +288 -0
  71. package/src/lib/home.ts +191 -0
  72. package/src/lib/inspect.ts +226 -0
  73. package/src/lib/kind-colors.ts +132 -0
  74. package/src/lib/live-table.ts +204 -0
  75. package/src/lib/manifest-health.ts +71 -0
  76. package/src/lib/manifest.ts +139 -0
  77. package/src/lib/metadata.ts +52 -0
  78. package/src/lib/node-metrics.ts +242 -0
  79. package/src/lib/operate.ts +114 -0
  80. package/src/lib/pipeline-flow.ts +120 -0
  81. package/src/lib/rca.ts +193 -0
  82. package/src/lib/telemetry.ts +155 -0
  83. package/src/lib/topology-graph.ts +551 -0
  84. package/src/lib/topology-view.ts +185 -0
  85. package/src/lib/waterfall.ts +148 -0
  86. package/src/main.ts +63 -29
  87. package/src/pages/Errors.vue +272 -0
  88. package/src/pages/Home.stories.ts +7 -8
  89. package/src/pages/Home.vue +255 -540
  90. package/src/pages/Hooks.stories.ts +44 -0
  91. package/src/pages/Hooks.vue +165 -164
  92. package/src/pages/Inspect.vue +240 -0
  93. package/src/pages/Map.vue +187 -0
  94. package/src/pages/Operate.vue +74 -0
  95. package/src/pages/Plugins.stories.ts +1 -1
  96. package/src/pages/Plugins.vue +174 -238
  97. package/src/pages/Projects.vue +62 -60
  98. package/src/pages/Streams.vue +344 -0
  99. package/src/pages/Topology.vue +318 -136
  100. package/src/pages/Trace.vue +174 -412
  101. package/src/pages/__tests__/Home.test.ts +109 -54
  102. package/src/pages/__tests__/Hooks.test.ts +5 -5
  103. package/src/pages/__tests__/Inspect.test.ts +111 -0
  104. package/src/pages/__tests__/Plugins.test.ts +85 -35
  105. package/src/pages/__tests__/Trace.test.ts +117 -0
  106. package/src/pages/operate/CommandsPanel.vue +186 -0
  107. package/src/pages/{Dispatch.vue → operate/DispatchPanel.vue} +100 -203
  108. package/src/pages/operate/EndpointPicker.vue +56 -0
  109. package/src/pages/operate/RunPanel.vue +316 -0
  110. package/src/server/__tests__/nwire-read.test.ts +80 -0
  111. package/src/server/nwire-read.ts +63 -0
  112. package/vite.config.ts +220 -2
  113. package/src/lib/__tests__/normalize-cache.test.ts +0 -105
  114. package/src/lib/cache.ts +0 -312
  115. package/src/lib/normalize-cache.ts +0 -92
  116. package/src/pages/Actions.vue +0 -171
  117. package/src/pages/Apps.vue +0 -177
  118. package/src/pages/Commands.vue +0 -262
  119. package/src/pages/Events.vue +0 -210
  120. package/src/pages/Live.vue +0 -249
  121. package/src/pages/Overview.vue +0 -161
  122. package/src/pages/Projections.vue +0 -148
  123. package/src/pages/Queries.vue +0 -148
  124. package/src/pages/Run.vue +0 -618
  125. package/src/pages/Sinks.vue +0 -124
  126. package/src/pages/TraceNode.vue +0 -164
  127. package/src/pages/Workflows.vue +0 -184
  128. package/src/pages/__tests__/Actions.test.ts +0 -98
  129. package/src/pages/__tests__/Projections.test.ts +0 -90
  130. package/src/pages/__tests__/Queries.test.ts +0 -86
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Native manifest access — pure selectors over `@nwire/scan`'s `Manifest`.
3
+ *
4
+ * Studio reads the deep manifest directly. Types are `import type` from
5
+ * `@nwire/scan` — erased at build, so `typescript` never
6
+ * enters the browser bundle. Selectors are pure so they unit-test without a DOM.
7
+ */
8
+ import type { Manifest, ManifestModel, GraphNode, GraphEdge, EdgeType } from "@nwire/scan";
9
+
10
+ export type { Manifest, ManifestModel, GraphNode, GraphEdge, EdgeType };
11
+
12
+ /** The manifest version this Studio build understands. */
13
+ export const SUPPORTED_MANIFEST_VERSION = 3;
14
+
15
+ /**
16
+ * A normalized view over a manifest — tolerant of missing/old fields. Never
17
+ * throws on a partial manifest; callers render empty states instead.
18
+ */
19
+ export interface ManifestView {
20
+ readonly manifest: Manifest;
21
+ readonly model: ManifestModel;
22
+ /** True when the manifest version differs from what this build understands. */
23
+ readonly versionMismatch: boolean;
24
+ /** Nodes of one graph kind (e.g. "action", "app", "capability"). */
25
+ byKind(kind: string): GraphNode[];
26
+ /** A node by id (`"${kind}:${name}"`). */
27
+ node(id: string): GraphNode | undefined;
28
+ /** Edges touching a node — `dir` filters to outgoing/incoming/both. */
29
+ edgesOf(id: string, dir?: "out" | "in" | "both"): GraphEdge[];
30
+ /** Edges of a given type. */
31
+ edgesByType(type: EdgeType): GraphEdge[];
32
+ }
33
+
34
+ /**
35
+ * Flat per-kind array key → graph node kind. Used to synthesize a model when a
36
+ * manifest predates the deep `model` (resilience: older/partial manifests still
37
+ * browse in Inspect). Mirrors `@nwire/scan`'s graph kinds.
38
+ */
39
+ const FLAT_KIND: ReadonlyArray<readonly [string, string]> = [
40
+ ["apps", "app"],
41
+ ["actions", "action"],
42
+ ["events", "event"],
43
+ ["actors", "actor"],
44
+ ["projections", "projection"],
45
+ ["queries", "query"],
46
+ ["resolvers", "resolver"],
47
+ ["routes", "route"],
48
+ ["workflows", "workflow"],
49
+ ["externalCalls", "externalCall"],
50
+ ["inboundWebhooks", "inboundWebhook"],
51
+ ["outboxes", "outbox"],
52
+ ["inboxes", "inbox"],
53
+ ["crons", "cron"],
54
+ ["commands", "command"],
55
+ ["hooks", "hook"],
56
+ ["plugins", "plugin"],
57
+ ["sinks", "sinkStage"],
58
+ ["bindings", "binding"],
59
+ ["resources", "resource"],
60
+ ["errors", "error"],
61
+ ];
62
+
63
+ const NODE_DISPLAY_FIELDS = new Set(["name", "app", "description", "public", "source"]);
64
+
65
+ /**
66
+ * Build a node+edge model from a manifest's flat per-kind arrays — the fallback
67
+ * for manifests without a deep `model`. Nodes cover every present kind; edges
68
+ * cover the statically-derivable ones (action→event `emits`, query→projection
69
+ * `reads`). Pure.
70
+ */
71
+ export function synthesizeModel(manifest: Manifest): ManifestModel {
72
+ const m = manifest as unknown as Record<string, unknown>;
73
+ const nodes: GraphNode[] = [];
74
+ const edges: GraphEdge[] = [];
75
+ const seen = new Set<string>();
76
+
77
+ for (const [key, kind] of FLAT_KIND) {
78
+ const list = m[key];
79
+ if (!Array.isArray(list)) continue;
80
+ for (const raw of list as Record<string, unknown>[]) {
81
+ const name =
82
+ typeof raw.name === "string" ? raw.name : typeof raw.id === "string" ? raw.id : undefined;
83
+ if (!name) continue;
84
+ const id = `${kind}:${name}`;
85
+ if (seen.has(id)) continue;
86
+ seen.add(id);
87
+
88
+ const data: Record<string, unknown> = {};
89
+ for (const [k, v] of Object.entries(raw)) {
90
+ if (!NODE_DISPLAY_FIELDS.has(k) && v !== undefined) data[k] = v;
91
+ }
92
+ nodes.push({
93
+ id,
94
+ kind,
95
+ name,
96
+ source: raw.source as GraphNode["source"],
97
+ intent: {
98
+ description: typeof raw.description === "string" ? raw.description : undefined,
99
+ public: raw.public === true,
100
+ },
101
+ data,
102
+ });
103
+
104
+ if (kind === "action" && Array.isArray(raw.emits)) {
105
+ for (const ev of raw.emits as string[])
106
+ edges.push({ from: id, to: `event:${ev}`, type: "emits" });
107
+ }
108
+ if (kind === "query" && typeof raw.projection === "string") {
109
+ edges.push({ from: id, to: `projection:${raw.projection}`, type: "reads" });
110
+ }
111
+ }
112
+ }
113
+ return { nodes, edges };
114
+ }
115
+
116
+ /** Build a tolerant view over a raw manifest object. */
117
+ export function manifestView(manifest: Manifest): ManifestView {
118
+ const model: ManifestModel =
119
+ manifest.model && Array.isArray(manifest.model.nodes) && manifest.model.nodes.length
120
+ ? manifest.model
121
+ : synthesizeModel(manifest);
122
+ const nodes = model.nodes ?? [];
123
+ const edges = model.edges ?? [];
124
+ const byId = new Map<string, GraphNode>(nodes.map((n) => [n.id, n]));
125
+ const version = (manifest as { version?: number }).version;
126
+
127
+ return {
128
+ manifest,
129
+ model,
130
+ versionMismatch: typeof version === "number" && version !== SUPPORTED_MANIFEST_VERSION,
131
+ byKind: (kind) => nodes.filter((n) => n.kind === kind),
132
+ node: (id) => byId.get(id),
133
+ edgesOf: (id, dir = "both") =>
134
+ edges.filter((e) =>
135
+ dir === "out" ? e.from === id : dir === "in" ? e.to === id : e.from === id || e.to === id,
136
+ ),
137
+ edgesByType: (type) => edges.filter((e) => e.type === type),
138
+ };
139
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Metadata flattening — turn a nested record (envelope, payload, error) into a
3
+ * flat list of dot-notation `key → value` entries for the inspector. Pure +
4
+ * unit-tested; the component renders + copies what this returns.
5
+ */
6
+
7
+ /** A primitive leaf value the inspector can render + copy. */
8
+ export type MetaValue = string | number | boolean | null;
9
+
10
+ /** One flattened entry — `a.b.c` → value, with the value's JS type. */
11
+ export interface MetaEntry {
12
+ readonly path: string;
13
+ readonly value: MetaValue;
14
+ readonly type: "string" | "number" | "boolean" | "null";
15
+ }
16
+
17
+ function typeOf(v: MetaValue): MetaEntry["type"] {
18
+ if (v === null) return "null";
19
+ if (typeof v === "number") return "number";
20
+ if (typeof v === "boolean") return "boolean";
21
+ return "string";
22
+ }
23
+
24
+ /**
25
+ * Flatten an arbitrary value into dot-notation leaf entries. Objects recurse by
26
+ * key; arrays recurse by index (`items.0.id`). Primitives become a single
27
+ * entry. `undefined` and functions are dropped. Cycles are guarded.
28
+ */
29
+ export function flattenMetadata(input: unknown, prefix = ""): MetaEntry[] {
30
+ return walk(input, prefix, new WeakSet());
31
+ }
32
+
33
+ function walk(value: unknown, prefix: string, seen: WeakSet<object>): MetaEntry[] {
34
+ if (value === null) return [{ path: prefix || "value", value: null, type: "null" }];
35
+ if (value === undefined || typeof value === "function") return [];
36
+ if (typeof value !== "object") {
37
+ const v = value as MetaValue;
38
+ return [{ path: prefix || "value", value: v, type: typeOf(v) }];
39
+ }
40
+ if (seen.has(value as object))
41
+ return [{ path: prefix || "value", value: "[circular]", type: "string" }];
42
+ seen.add(value as object);
43
+
44
+ const out: MetaEntry[] = [];
45
+ const entries: [string, unknown][] = Array.isArray(value)
46
+ ? value.map((v, i) => [String(i), v])
47
+ : Object.entries(value as Record<string, unknown>);
48
+ for (const [k, v] of entries) {
49
+ out.push(...walk(v, prefix ? `${prefix}.${k}` : k, seen));
50
+ }
51
+ return out;
52
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Node metrics — fold the live telemetry feed into per-primitive and per-app
3
+ * rollups the Topology and Map canvases overlay on their nodes.
4
+ *
5
+ * Pure + framework-free so it unit-tests without a DOM. The runtime + forge
6
+ * push records keyed by *which* primitive each is about under different field
7
+ * names (`action` / `query` / `projection` / `event` / `actor` / `workflow` /
8
+ * `call`); `nodeIdOf` normalizes any record to the graph node id
9
+ * (`"${kind}:${name}"`) the manifest uses, so a record lights up exactly the
10
+ * node it belongs to. `appName` carries the owning app for the Map rollup.
11
+ *
12
+ * Metrics are derived, not stored: same records in → same metrics out, so a
13
+ * telemetry refresh updates counts in place without moving any node.
14
+ */
15
+ import type { TelemetryRecord } from "./telemetry";
16
+ import { isFailure } from "./telemetry";
17
+
18
+ /** Per-node rollup overlaid on a Topology wiring node (or a Map BC row). */
19
+ export interface NodeMetrics {
20
+ /** Total records seen for this node (throughput). */
21
+ readonly count: number;
22
+ /** Of those, how many were failures. */
23
+ readonly errors: number;
24
+ /** Median duration in ms, when any record carried one (else undefined). */
25
+ readonly p50?: number;
26
+ /** ISO timestamp of the most recent record. */
27
+ readonly lastTs?: string;
28
+ }
29
+
30
+ /** Per-app rollup overlaid on a Map bounded-context card. */
31
+ export interface AppMetrics {
32
+ /** Dispatches (actions + queries) attributed to this app. */
33
+ readonly dispatches: number;
34
+ /** Failure records attributed to this app. */
35
+ readonly errors: number;
36
+ /** Total records (any kind) — drives the "live" pulse. */
37
+ readonly total: number;
38
+ /** ISO timestamp of the most recent record for this app. */
39
+ readonly lastTs?: string;
40
+ }
41
+
42
+ /** Recent-activity windows feeding edge animation + counts. */
43
+ export interface MetricsRollup {
44
+ /** node id → metrics. */
45
+ readonly byNode: ReadonlyMap<string, NodeMetrics>;
46
+ /** app name → metrics. */
47
+ readonly byApp: ReadonlyMap<string, AppMetrics>;
48
+ /** Total records folded — `0` means "no run yet" (neutral placeholders). */
49
+ readonly total: number;
50
+ }
51
+
52
+ const EMPTY: MetricsRollup = { byNode: new Map(), byApp: new Map(), total: 0 };
53
+
54
+ /** Kinds the Map counts as a "dispatch" (the everyday request count). */
55
+ const DISPATCH_KINDS: ReadonlySet<string> = new Set(["action.dispatched", "query.executed"]);
56
+
57
+ function str(v: unknown): string | undefined {
58
+ return typeof v === "string" && v ? v : undefined;
59
+ }
60
+
61
+ /**
62
+ * The graph node id (`"${kind}:${name}"`) a telemetry record is about, or
63
+ * `null` when the record names no primitive (stage / hook records). Reads the
64
+ * kind-specific name field the runtime/forge stamp:
65
+ * action.* → `action` query.* → `query` projection.* → `projection`
66
+ * event.* / listener.* → `event` (string name, or `event.name` object)
67
+ * actor.* / timer.* → `actor` reaction.* → `workflow`
68
+ * external.call.* → `externalCall:<call>`
69
+ */
70
+ export function nodeIdOf(rec: TelemetryRecord): string | null {
71
+ const base = rec.kind.split(".")[0] ?? rec.kind;
72
+ switch (base) {
73
+ case "action": {
74
+ // dlq.recorded also carries `action`; routed here only via its own kind.
75
+ const name = str(rec.action);
76
+ return name ? `action:${name}` : null;
77
+ }
78
+ case "query": {
79
+ const name = str(rec.query);
80
+ return name ? `query:${name}` : null;
81
+ }
82
+ case "projection": {
83
+ const name = str(rec.projection);
84
+ return name ? `projection:${name}` : null;
85
+ }
86
+ case "actor":
87
+ case "timer": {
88
+ const name = str(rec.actor);
89
+ return name ? `actor:${name}` : null;
90
+ }
91
+ case "reaction": {
92
+ const name = str(rec.workflow);
93
+ return name ? `workflow:${name}` : null;
94
+ }
95
+ case "dlq": {
96
+ const name = str(rec.action);
97
+ return name ? `action:${name}` : null;
98
+ }
99
+ case "event":
100
+ case "listener": {
101
+ // `event` is a string (event.emitted / listener.fired) or an
102
+ // EventMessage object (event.published / .deduped) carrying its name
103
+ // under `eventName` (forge's EventMessage) or `name`.
104
+ const ev = rec.event;
105
+ const obj = ev as { eventName?: unknown; name?: unknown } | undefined;
106
+ const name = str(ev) ?? str(obj?.eventName) ?? str(obj?.name);
107
+ return name ? `event:${name}` : null;
108
+ }
109
+ case "external": {
110
+ const name = str(rec.call);
111
+ return name ? `externalCall:${name}` : null;
112
+ }
113
+ default:
114
+ return null;
115
+ }
116
+ }
117
+
118
+ /** Median of a numeric list (sorted copy). Empty → undefined. */
119
+ function median(ns: number[]): number | undefined {
120
+ if (ns.length === 0) return undefined;
121
+ const s = [...ns].sort((a, b) => a - b);
122
+ const mid = Math.floor(s.length / 2);
123
+ return s.length % 2 ? s[mid]! : Math.round((s[mid - 1]! + s[mid]!) / 2);
124
+ }
125
+
126
+ type NodeAccum = { count: number; errors: number; durations: number[]; lastTs?: string };
127
+ type AppAccum = { dispatches: number; errors: number; total: number; lastTs?: string };
128
+
129
+ function laterTs(a: string | undefined, b: string | undefined): string | undefined {
130
+ if (!a) return b;
131
+ if (!b) return a;
132
+ return a >= b ? a : b;
133
+ }
134
+
135
+ /**
136
+ * Fold a telemetry record list into per-node + per-app metrics. Deterministic:
137
+ * the same records always yield the same rollup, so the canvas updates counts
138
+ * without re-laying-out. Tolerant of unknown kinds (they just don't attribute).
139
+ */
140
+ export function rollupMetrics(records: readonly TelemetryRecord[]): MetricsRollup {
141
+ if (records.length === 0) return EMPTY;
142
+
143
+ const nodeAcc = new Map<string, NodeAccum>();
144
+ const appAcc = new Map<string, AppAccum>();
145
+
146
+ for (const rec of records) {
147
+ const failed = isFailure(rec);
148
+ const ts = str(rec.ts);
149
+
150
+ const id = nodeIdOf(rec);
151
+ if (id) {
152
+ let n = nodeAcc.get(id);
153
+ if (!n) {
154
+ n = { count: 0, errors: 0, durations: [] };
155
+ nodeAcc.set(id, n);
156
+ }
157
+ n.count++;
158
+ if (failed) n.errors++;
159
+ if (typeof rec.durationMs === "number") n.durations.push(rec.durationMs);
160
+ n.lastTs = laterTs(n.lastTs, ts);
161
+ }
162
+
163
+ const app = str(rec.appName);
164
+ if (app) {
165
+ let a = appAcc.get(app);
166
+ if (!a) {
167
+ a = { dispatches: 0, errors: 0, total: 0 };
168
+ appAcc.set(app, a);
169
+ }
170
+ a.total++;
171
+ if (DISPATCH_KINDS.has(rec.kind)) a.dispatches++;
172
+ if (failed) a.errors++;
173
+ a.lastTs = laterTs(a.lastTs, ts);
174
+ }
175
+ }
176
+
177
+ const byNode = new Map<string, NodeMetrics>();
178
+ for (const [id, n] of nodeAcc) {
179
+ byNode.set(id, {
180
+ count: n.count,
181
+ errors: n.errors,
182
+ p50: median(n.durations),
183
+ lastTs: n.lastTs,
184
+ });
185
+ }
186
+
187
+ const byApp = new Map<string, AppMetrics>();
188
+ for (const [name, a] of appAcc) {
189
+ byApp.set(name, {
190
+ dispatches: a.dispatches,
191
+ errors: a.errors,
192
+ total: a.total,
193
+ lastTs: a.lastTs,
194
+ });
195
+ }
196
+
197
+ return { byNode, byApp, total: records.length };
198
+ }
199
+
200
+ /**
201
+ * Per-edge activity for the wiring graph: an edge is "active" when both its
202
+ * endpoints saw a record within the recency window, so the canvas can animate
203
+ * live edges and label them with the lighter end's throughput. Keyed by the
204
+ * `"${from}=>${to}"` ids the wiring graph mints.
205
+ */
206
+ export interface EdgeActivity {
207
+ /** Records attributed to the edge's destination node (its throughput). */
208
+ readonly volume: number;
209
+ /** True when the destination saw a record this window — animate it. */
210
+ readonly active: boolean;
211
+ }
212
+
213
+ /**
214
+ * Resolve per-edge activity from the node rollup: an edge carries the volume
215
+ * of its target node and is active when that node has any throughput. Pure;
216
+ * derived entirely from `byNode` so it never thrashes the layout.
217
+ */
218
+ export function edgeActivity(
219
+ byNode: ReadonlyMap<string, NodeMetrics>,
220
+ edges: ReadonlyArray<{ id: string; from: string; to: string }>,
221
+ ): Map<string, EdgeActivity> {
222
+ const out = new Map<string, EdgeActivity>();
223
+ for (const e of edges) {
224
+ const m = byNode.get(e.to);
225
+ const volume = m?.count ?? 0;
226
+ out.set(e.id, { volume, active: volume > 0 });
227
+ }
228
+ return out;
229
+ }
230
+
231
+ /** Format a metric count for display — a dim em dash before any run. */
232
+ export function fmtCount(n: number | undefined): string {
233
+ return n == null || n === 0 ? "—" : String(n);
234
+ }
235
+
236
+ /** Format a p50 latency — "—" when none yet, "<1ms" / "12ms" / "1.2s" otherwise. */
237
+ export function fmtLatency(ms: number | undefined): string {
238
+ if (ms == null) return "—";
239
+ if (ms < 1) return "<1ms";
240
+ if (ms < 1000) return `${Math.round(ms)}ms`;
241
+ return `${(ms / 1000).toFixed(1)}s`;
242
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Operate — pure helpers behind the Run + Commands panels.
3
+ *
4
+ * Process lifecycle, env parsing, script ordering, and status presentation. No
5
+ * DOM, no fetch — the panels own I/O; this owns the maths so it unit-tests
6
+ * cleanly.
7
+ */
8
+
9
+ export type ProcessStatus = "idle" | "starting" | "running" | "stopping" | "exited" | "crashed";
10
+
11
+ export interface ManagedProcess {
12
+ id: string;
13
+ topology: string;
14
+ port?: number;
15
+ startedAt: string;
16
+ status: ProcessStatus;
17
+ pid?: number;
18
+ exitCode?: number | null;
19
+ signal?: string | null;
20
+ errorMessage?: string;
21
+ /** "studio" — spawned here; "external" — discovered from .nwire/processes. */
22
+ source?: "studio" | "external";
23
+ env?: Record<string, string>;
24
+ }
25
+
26
+ export interface LogLine {
27
+ seq: number;
28
+ ts: string;
29
+ stream: "stdout" | "stderr";
30
+ line: string;
31
+ }
32
+
33
+ export interface ScriptEntry {
34
+ name: string;
35
+ command: string;
36
+ }
37
+
38
+ /**
39
+ * Parse a `KEY=value` block (one per line) into an env object. Skips blanks +
40
+ * `#` comments + malformed keys; strips matching surrounding quotes.
41
+ */
42
+ export function parseEnvInput(raw: string): Record<string, string> {
43
+ const out: Record<string, string> = {};
44
+ for (const line of raw.split(/\r?\n/)) {
45
+ const trimmed = line.trim();
46
+ if (!trimmed || trimmed.startsWith("#")) continue;
47
+ const eq = trimmed.indexOf("=");
48
+ if (eq < 1) continue;
49
+ const k = trimmed.slice(0, eq).trim();
50
+ const v = trimmed.slice(eq + 1).trim();
51
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) continue;
52
+ const dequoted =
53
+ (v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))
54
+ ? v.slice(1, -1)
55
+ : v;
56
+ out[k] = dequoted;
57
+ }
58
+ return out;
59
+ }
60
+
61
+ /**
62
+ * package.json scripts ordered for the picker: `nwire`-prefixed first
63
+ * (framework shorthands operators reach for most), then the rest — each group
64
+ * alphabetical.
65
+ */
66
+ export function sortScripts(scripts: readonly ScriptEntry[]): ScriptEntry[] {
67
+ const byName = (a: ScriptEntry, b: ScriptEntry) => a.name.localeCompare(b.name);
68
+ const nwire = scripts.filter((s) => s.name.startsWith("nwire")).sort(byName);
69
+ const others = scripts.filter((s) => !s.name.startsWith("nwire")).sort(byName);
70
+ return [...nwire, ...others];
71
+ }
72
+
73
+ /** StatusBadge tone for a process status. */
74
+ export type StatusTone = "live" | "warn" | "error" | "idle";
75
+
76
+ export function statusTone(status: ProcessStatus): StatusTone {
77
+ switch (status) {
78
+ case "running":
79
+ return "live";
80
+ case "starting":
81
+ case "stopping":
82
+ return "warn";
83
+ case "crashed":
84
+ return "error";
85
+ case "exited":
86
+ case "idle":
87
+ return "idle";
88
+ }
89
+ }
90
+
91
+ /** Short, stable process id for display (first uuid segment). */
92
+ export function shortId(id: string): string {
93
+ return id.split("-")[0] ?? id.slice(0, 8);
94
+ }
95
+
96
+ /** Human "Ns/Nm/Nh ago" from an ISO timestamp; `now` injected for testability. */
97
+ export function timeAgo(iso: string, now: number): string {
98
+ const s = Math.max(0, Math.floor((now - new Date(iso).getTime()) / 1000));
99
+ if (s < 60) return `${s}s ago`;
100
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
101
+ return `${Math.floor(s / 3600)}h ago`;
102
+ }
103
+
104
+ /** A process is live (running) or transitioning (starting/stopping). */
105
+ export function isActive(status: ProcessStatus): boolean {
106
+ return status === "running" || status === "starting" || status === "stopping";
107
+ }
108
+
109
+ /** The wire `/_nwire/*` routes to — the most-recently-started running process. */
110
+ export function activeProcess(processes: readonly ManagedProcess[]): ManagedProcess | undefined {
111
+ const running = processes.filter((p) => p.status === "running");
112
+ if (running.length === 0) return undefined;
113
+ return [...running].sort((a, b) => b.startedAt.localeCompare(a.startedAt))[0];
114
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Pipeline flow — turns raw `source.stage` / `sink.stage` telemetry records
3
+ * into the inbound and outbound stage rows for one correlation, so the Trace
4
+ * view can draw the full flow: inbound pipeline → execute/fold (the event
5
+ * tree) → outbound pipeline.
6
+ *
7
+ * Pure + framework-free so it unit-tests without a DOM. The server's inspect
8
+ * endpoint streams every telemetry kind; this just selects the two stage
9
+ * kinds, scopes them to a correlation, and orders them the way they ran
10
+ * (position early → middle → terminal, then by timestamp within a position).
11
+ */
12
+
13
+ /** A `source.stage` telemetry record (inbound pipeline). */
14
+ export interface SourceStageRecord {
15
+ kind: "source.stage";
16
+ stage: string;
17
+ position: StagePosition;
18
+ stageKind?: string;
19
+ message?: { name?: string; kind?: "command" | "event" };
20
+ correlationId?: string;
21
+ tenant?: string;
22
+ shortCircuited: boolean;
23
+ durationMs: number;
24
+ appName: string;
25
+ ts: string;
26
+ }
27
+
28
+ /** A `sink.stage` telemetry record (outbound pipeline). */
29
+ export interface SinkStageRecord {
30
+ kind: "sink.stage";
31
+ stage: string;
32
+ position: StagePosition;
33
+ stageKind?: string;
34
+ event: string;
35
+ correlationId?: string;
36
+ tenant?: string;
37
+ shortCircuited: boolean;
38
+ durationMs: number;
39
+ appName: string;
40
+ ts: string;
41
+ }
42
+
43
+ export type StagePosition = "early" | "middle" | "terminal";
44
+ export type StageRecord = SourceStageRecord | SinkStageRecord;
45
+
46
+ /** One row in the rendered pipeline. */
47
+ export interface StageRow {
48
+ direction: "inbound" | "outbound";
49
+ stage: string;
50
+ position: StagePosition;
51
+ stageKind?: string;
52
+ /** Inbound: the message name; outbound: the event name. */
53
+ label: string;
54
+ shortCircuited: boolean;
55
+ durationMs: number;
56
+ appName: string;
57
+ ts: string;
58
+ }
59
+
60
+ export interface PipelineFlow {
61
+ inbound: StageRow[];
62
+ outbound: StageRow[];
63
+ }
64
+
65
+ const POSITION_ORDER: Record<StagePosition, number> = { early: 0, middle: 1, terminal: 2 };
66
+
67
+ /** True for the two stage-telemetry kinds this view cares about. */
68
+ export function isStageRecord(rec: { kind?: string }): rec is StageRecord {
69
+ return rec.kind === "source.stage" || rec.kind === "sink.stage";
70
+ }
71
+
72
+ function order(a: StageRow, b: StageRow): number {
73
+ const byPos = POSITION_ORDER[a.position] - POSITION_ORDER[b.position];
74
+ return byPos !== 0 ? byPos : a.ts.localeCompare(b.ts);
75
+ }
76
+
77
+ /**
78
+ * Build the inbound + outbound stage rows for one correlation. Records with
79
+ * no correlationId are excluded (they can't be tied to a trace). When
80
+ * `correlationId` is undefined, returns empty flows.
81
+ */
82
+ export function pipelineFlow(
83
+ records: readonly StageRecord[],
84
+ correlationId: string | undefined,
85
+ ): PipelineFlow {
86
+ if (!correlationId) return { inbound: [], outbound: [] };
87
+ const inbound: StageRow[] = [];
88
+ const outbound: StageRow[] = [];
89
+ for (const rec of records) {
90
+ if (rec.correlationId !== correlationId) continue;
91
+ if (rec.kind === "source.stage") {
92
+ inbound.push({
93
+ direction: "inbound",
94
+ stage: rec.stage,
95
+ position: rec.position,
96
+ stageKind: rec.stageKind,
97
+ label: rec.message?.name ?? "",
98
+ shortCircuited: rec.shortCircuited,
99
+ durationMs: rec.durationMs,
100
+ appName: rec.appName,
101
+ ts: rec.ts,
102
+ });
103
+ } else {
104
+ outbound.push({
105
+ direction: "outbound",
106
+ stage: rec.stage,
107
+ position: rec.position,
108
+ stageKind: rec.stageKind,
109
+ label: rec.event,
110
+ shortCircuited: rec.shortCircuited,
111
+ durationMs: rec.durationMs,
112
+ appName: rec.appName,
113
+ ts: rec.ts,
114
+ });
115
+ }
116
+ }
117
+ inbound.sort(order);
118
+ outbound.sort(order);
119
+ return { inbound, outbound };
120
+ }