@nwire/studio 0.12.1 → 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.
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,160 @@
1
+ /**
2
+ * Dispatch form — pure helpers behind the Operate › Dispatch panel.
3
+ *
4
+ * The dispatchable targets come straight from the native manifest's flat
5
+ * arrays (`actions`, `queries`) — these carry the JSON `inputSchema` that the
6
+ * deep `model` action node drops (the model keeps only emits/retry). So this
7
+ * reads the schema-bearing flat arrays rather than the graph nodes.
8
+ *
9
+ * Everything here is pure so it unit-tests without a DOM or a live wire.
10
+ */
11
+ import type { Manifest } from "./manifest";
12
+
13
+ /** A handler the Dispatch panel can invoke via `POST /_nwire/dispatch`. */
14
+ export interface DispatchTarget {
15
+ readonly name: string;
16
+ readonly app: string;
17
+ readonly kind: "action" | "query";
18
+ readonly description?: string;
19
+ /** JSON-schema-shaped input (present for actions; queries usually omit it). */
20
+ readonly inputSchema?: unknown;
21
+ }
22
+
23
+ /** A flat, form-renderable view of one top-level schema property. */
24
+ export interface SchemaField {
25
+ readonly name: string;
26
+ readonly type: string;
27
+ readonly required: boolean;
28
+ readonly default?: unknown;
29
+ readonly enum?: string[];
30
+ }
31
+
32
+ interface RawEntry {
33
+ name?: unknown;
34
+ app?: unknown;
35
+ description?: unknown;
36
+ inputSchema?: unknown;
37
+ }
38
+
39
+ function toTargets(list: unknown, kind: DispatchTarget["kind"]): DispatchTarget[] {
40
+ if (!Array.isArray(list)) return [];
41
+ const out: DispatchTarget[] = [];
42
+ for (const raw of list as RawEntry[]) {
43
+ if (typeof raw?.name !== "string") continue;
44
+ out.push({
45
+ name: raw.name,
46
+ app: typeof raw.app === "string" ? raw.app : "",
47
+ kind,
48
+ description: typeof raw.description === "string" ? raw.description : undefined,
49
+ inputSchema: raw.inputSchema,
50
+ });
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * Every dispatchable handler in the manifest — actions first (they carry input
57
+ * schemas and are the common case), then queries. Tolerant of a partial or old
58
+ * manifest: missing arrays simply contribute nothing.
59
+ */
60
+ export function dispatchTargets(manifest: Manifest | null | undefined): DispatchTarget[] {
61
+ if (!manifest) return [];
62
+ const m = manifest as unknown as { actions?: unknown; queries?: unknown };
63
+ return [...toTargets(m.actions, "action"), ...toTargets(m.queries, "query")];
64
+ }
65
+
66
+ /** Free-text filter over name / app / description (case-insensitive). */
67
+ export function filterTargets(targets: readonly DispatchTarget[], query: string): DispatchTarget[] {
68
+ const q = query.trim().toLowerCase();
69
+ if (!q) return [...targets];
70
+ return targets.filter(
71
+ (t) =>
72
+ t.name.toLowerCase().includes(q) ||
73
+ t.app.toLowerCase().includes(q) ||
74
+ (t.description ?? "").toLowerCase().includes(q),
75
+ );
76
+ }
77
+
78
+ interface JsonSchemaProp {
79
+ type?: string | string[];
80
+ default?: unknown;
81
+ enum?: unknown[];
82
+ format?: string;
83
+ }
84
+
85
+ interface JsonSchema {
86
+ properties?: Record<string, JsonSchemaProp>;
87
+ required?: unknown;
88
+ }
89
+
90
+ function asSchema(schema: unknown): JsonSchema | null {
91
+ if (!schema || typeof schema !== "object") return null;
92
+ const s = schema as JsonSchema;
93
+ return s.properties && typeof s.properties === "object" ? s : null;
94
+ }
95
+
96
+ /** Flatten a JSON schema's top-level properties into form-field rows. */
97
+ export function schemaFields(schema: unknown): SchemaField[] {
98
+ const s = asSchema(schema);
99
+ if (!s) return [];
100
+ const required = new Set<string>(Array.isArray(s.required) ? (s.required as string[]) : []);
101
+ const out: SchemaField[] = [];
102
+ for (const [name, raw] of Object.entries(s.properties ?? {})) {
103
+ const p = raw ?? {};
104
+ const t = Array.isArray(p.type) ? p.type.join(" | ") : (p.type ?? "any");
105
+ out.push({
106
+ name,
107
+ type: t,
108
+ required: required.has(name),
109
+ default: p.default,
110
+ enum: Array.isArray(p.enum) ? p.enum.map(String) : undefined,
111
+ });
112
+ }
113
+ return out;
114
+ }
115
+
116
+ /** Seed a sensible input object from a schema's defaults + types. */
117
+ export function scaffoldInput(schema: unknown): Record<string, unknown> {
118
+ const s = asSchema(schema);
119
+ if (!s) return {};
120
+ const scaffold: Record<string, unknown> = {};
121
+ for (const [key, prop] of Object.entries(s.properties ?? {})) {
122
+ if (prop.default !== undefined) {
123
+ scaffold[key] = prop.default;
124
+ continue;
125
+ }
126
+ const t = Array.isArray(prop.type) ? prop.type[0] : prop.type;
127
+ switch (t) {
128
+ case "string":
129
+ scaffold[key] = "";
130
+ break;
131
+ case "number":
132
+ case "integer":
133
+ scaffold[key] = 0;
134
+ break;
135
+ case "boolean":
136
+ scaffold[key] = false;
137
+ break;
138
+ case "array":
139
+ scaffold[key] = [];
140
+ break;
141
+ case "object":
142
+ scaffold[key] = {};
143
+ break;
144
+ default:
145
+ scaffold[key] = null;
146
+ }
147
+ }
148
+ return scaffold;
149
+ }
150
+
151
+ /** Can this field render as a single inline control (vs. the JSON textarea)? */
152
+ export function isInlineRenderable(field: SchemaField): boolean {
153
+ const t = field.type.split(" | ")[0];
154
+ return t === "string" || t === "number" || t === "integer" || t === "boolean";
155
+ }
156
+
157
+ /** True when every field is a primitive the inline form can render. */
158
+ export function allInlineRenderable(fields: readonly SchemaField[]): boolean {
159
+ return fields.length > 0 && fields.every(isInlineRenderable);
160
+ }
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Friendly errors — turn a raw failure record into something a human reads once
3
+ * and knows what to do about. The runtime stamps machine-shaped failures
4
+ * (`action.failed`, `dlq.recorded`, `external.call.failed`, …) carrying a
5
+ * `SerializedError` + retry counters; this maps the common shapes to a plain
6
+ * title, a one-sentence summary that names the failing unit, an actionable next
7
+ * step, and a severity. Pure + data-driven so it unit-tests without a DOM and
8
+ * the Errors view, the cards, and Storybook all read failures the same way.
9
+ */
10
+ import type { TelemetryRecord } from "./telemetry";
11
+
12
+ /** How loud this failure should read. `critical` = the system gave up. */
13
+ export type Severity = "critical" | "high" | "warning" | "info";
14
+
15
+ /** The failure family we recognised — drives icon + copy. */
16
+ export type ErrorCategory =
17
+ | "validation"
18
+ | "declined"
19
+ | "unauthorized"
20
+ | "not-found"
21
+ | "conflict"
22
+ | "timeout"
23
+ | "dead-letter"
24
+ | "exhausted"
25
+ | "external"
26
+ | "downstream"
27
+ | "unknown";
28
+
29
+ /** The failing unit a record points at (action / projection / call / …). */
30
+ export interface FailureSubject {
31
+ readonly kind: string;
32
+ readonly name: string;
33
+ }
34
+
35
+ /** Normalised retry state across the different failure shapes. */
36
+ export interface AttemptInfo {
37
+ /** The attempt this record represents, when it carries one. */
38
+ readonly attempt?: number;
39
+ /** The ceiling, when declared. */
40
+ readonly maxAttempts?: number;
41
+ /** Total attempts made (DLQ / exhausted records report this). */
42
+ readonly attempts?: number;
43
+ /** The runtime intends to try again. */
44
+ readonly willRetry: boolean;
45
+ /** This failure ended in the dead-letter queue / exhausted retries. */
46
+ readonly terminal: boolean;
47
+ }
48
+
49
+ /** The humanised view of one failure record. */
50
+ export interface FriendlyError {
51
+ readonly title: string;
52
+ readonly summary: string;
53
+ readonly suggestion: string;
54
+ readonly severity: Severity;
55
+ readonly category: ErrorCategory;
56
+ readonly subject: FailureSubject;
57
+ readonly attempt: AttemptInfo;
58
+ /** The raw error message, kept for the evidence/detail surfaces. */
59
+ readonly rawMessage?: string;
60
+ }
61
+
62
+ const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
63
+ const num = (v: unknown): number | undefined => (typeof v === "number" ? v : undefined);
64
+
65
+ /** The unit that failed — read off whichever id field the record carries. */
66
+ export function subjectOf(r: TelemetryRecord): FailureSubject {
67
+ const action = str(r.action);
68
+ if (action) return { kind: "action", name: action };
69
+ const projection = str(r.projection);
70
+ if (projection) return { kind: "projection", name: projection };
71
+ const workflow = str(r.workflow);
72
+ if (workflow) return { kind: "workflow", name: workflow };
73
+ const call = str(r.call);
74
+ if (call) return { kind: "external call", name: call };
75
+ const query = str(r.query);
76
+ if (query) return { kind: "query", name: query };
77
+ const listener = str(r.listener);
78
+ if (listener) return { kind: "listener", name: listener };
79
+ return { kind: "operation", name: str(r.sourceEvent) ?? "an operation" };
80
+ }
81
+
82
+ /** Normalise the various retry counters onto one shape. */
83
+ export function attemptOf(r: TelemetryRecord): AttemptInfo {
84
+ const attempt = num(r.attempt);
85
+ const attempts = num(r.attempts);
86
+ const maxAttempts = num(r.maxAttempts);
87
+ const terminal = r.kind === "dlq.recorded" || r.kind === "reaction.exhausted";
88
+ // `willRetry` is explicit on the retrying shapes; terminal failures never do.
89
+ const willRetry = terminal ? false : r.error == null ? false : Boolean(r.willRetry);
90
+ return { attempt, maxAttempts, attempts, willRetry, terminal };
91
+ }
92
+
93
+ /** A short, human retry line — `attempt 2 of 3, retrying` / `3 attempts, dead-lettered`. */
94
+ export function retryNarrative(r: TelemetryRecord): string | null {
95
+ const a = attemptOf(r);
96
+ if (a.terminal && a.attempts) {
97
+ return `${a.attempts} ${a.attempts === 1 ? "attempt" : "attempts"} — gave up`;
98
+ }
99
+ if (a.attempt && a.maxAttempts) {
100
+ return `attempt ${a.attempt} of ${a.maxAttempts}${a.willRetry ? ", retrying" : ""}`;
101
+ }
102
+ if (a.attempt && a.attempt > 1) return `attempt ${a.attempt}`;
103
+ return null;
104
+ }
105
+
106
+ interface ErrorFacts {
107
+ readonly name?: string;
108
+ readonly message?: string;
109
+ readonly code?: string | number;
110
+ }
111
+
112
+ function errorFacts(r: TelemetryRecord): ErrorFacts {
113
+ const e = r.error;
114
+ const code = e && "code" in e ? (e.code as string | number | undefined) : undefined;
115
+ return { name: e?.name, message: e?.message, code };
116
+ }
117
+
118
+ const has = (haystack: string | undefined, re: RegExp): boolean => !!haystack && re.test(haystack);
119
+
120
+ /**
121
+ * Classify a failure into a known family. Terminal forge outcomes
122
+ * (dead-letter / exhausted) own their headline regardless of the underlying
123
+ * cause — "it gave up" is the story, and the cause still shows in the summary +
124
+ * evidence. Otherwise read the error name/message/code (most specific), then
125
+ * fall back to the remaining record kinds.
126
+ */
127
+ export function categorize(r: TelemetryRecord): ErrorCategory {
128
+ if (r.kind === "dlq.recorded") return "dead-letter";
129
+ if (r.kind === "reaction.exhausted") return "exhausted";
130
+
131
+ const { name, message, code } = errorFacts(r);
132
+ const n = name ?? "";
133
+ const m = message ?? "";
134
+
135
+ if (
136
+ has(n, /zod|validation|invalidinput/i) ||
137
+ has(m, /\b(invalid|required|must be|expected|schema)\b/i)
138
+ )
139
+ return "validation";
140
+ if (code === 401 || code === 403 || has(n, /unauthor|forbidden|denied/i)) return "unauthorized";
141
+ if (code === 404 || has(n, /notfound/i) || has(m, /not found|does not exist|unknown id/i))
142
+ return "not-found";
143
+ if (code === 409 || has(n, /conflict/i) || has(m, /already exists|conflict|duplicate|version/i))
144
+ return "conflict";
145
+ if (
146
+ code === 402 ||
147
+ has(n, /declin|rejected|paymentfailed/i) ||
148
+ has(m, /declin|rejected|insufficient/i)
149
+ )
150
+ return "declined";
151
+ if (has(n, /timeout|aborterror/i) || has(m, /timed? ?out|timeout|deadline|aborted/i))
152
+ return "timeout";
153
+
154
+ switch (r.kind) {
155
+ case "external.call.failed":
156
+ return "external";
157
+ case "projection.failed":
158
+ case "reaction.failed":
159
+ case "event.listener.failed":
160
+ case "enqueue.failed":
161
+ return "downstream";
162
+ default:
163
+ return "unknown";
164
+ }
165
+ }
166
+
167
+ interface CategoryCopy {
168
+ readonly title: string;
169
+ /** `(subject, message) => summary` */
170
+ readonly summary: (s: FailureSubject, m?: string) => string;
171
+ readonly suggestion: string;
172
+ }
173
+
174
+ const COPY: Record<ErrorCategory, CategoryCopy> = {
175
+ validation: {
176
+ title: "Invalid input",
177
+ summary: (s) => `${s.name} rejected its input — the payload didn't match the schema.`,
178
+ suggestion: "Check the caller's payload against the handler's `input` contract.",
179
+ },
180
+ declined: {
181
+ title: "Declined",
182
+ summary: (s, m) => `${s.name} was declined${m ? ` — ${m}` : "."}`,
183
+ suggestion:
184
+ "A business rule said no. This is expected control flow, not a crash — surface it to the caller.",
185
+ },
186
+ unauthorized: {
187
+ title: "Not allowed",
188
+ summary: (s) => `${s.name} ran without permission to do what it tried.`,
189
+ suggestion: "Confirm the caller's auth / tenant scope and the handler's policy.",
190
+ },
191
+ "not-found": {
192
+ title: "Not found",
193
+ summary: (s, m) => `${s.name} couldn't find something it needed${m ? ` — ${m}` : "."}`,
194
+ suggestion:
195
+ "The referenced id may be wrong, deleted, or not yet projected. Check upstream writes.",
196
+ },
197
+ conflict: {
198
+ title: "Conflict",
199
+ summary: (s, m) => `${s.name} hit a conflicting state${m ? ` — ${m}` : "."}`,
200
+ suggestion:
201
+ "Likely a duplicate or a concurrent write. Retry on a fresh read, or make the op idempotent.",
202
+ },
203
+ timeout: {
204
+ title: "Timed out",
205
+ summary: (s) => `${s.name} didn't finish in time.`,
206
+ suggestion:
207
+ "Check the downstream it waits on, or raise the timeout if the work is genuinely slow.",
208
+ },
209
+ "dead-letter": {
210
+ title: "Dead-lettered",
211
+ summary: (s, m) =>
212
+ `${s.name} failed every retry and was parked in the dead-letter queue${m ? ` — ${m}` : "."}`,
213
+ suggestion: "Inspect the payload + root error, fix the cause, then replay from the DLQ.",
214
+ },
215
+ exhausted: {
216
+ title: "Retries exhausted",
217
+ summary: (s, m) => `${s.name} gave up after exhausting its retries${m ? ` — ${m}` : "."}`,
218
+ suggestion:
219
+ "The failure is persistent, not transient. Fix the underlying cause before it retries again.",
220
+ },
221
+ external: {
222
+ title: "External call failed",
223
+ summary: (s, m) => `The external call ${s.name} failed${m ? ` — ${m}` : "."}`,
224
+ suggestion:
225
+ "Check the third-party endpoint's health + credentials. The fault is likely outside the app.",
226
+ },
227
+ downstream: {
228
+ title: "Downstream failed",
229
+ summary: (s, m) => `${s.name} failed while reacting to an event${m ? ` — ${m}` : "."}`,
230
+ suggestion:
231
+ "A projection / reaction couldn't keep up. The read model or saga may now be behind.",
232
+ },
233
+ unknown: {
234
+ title: "Failed",
235
+ summary: (s, m) => `${s.name} threw an unexpected error${m ? ` — ${m}` : "."}`,
236
+ suggestion:
237
+ "Open the evidence tab for the stack + payload, then trace the chain that led here.",
238
+ },
239
+ };
240
+
241
+ const KIND_SEVERITY: Record<string, Severity> = {
242
+ "dlq.recorded": "critical",
243
+ "reaction.exhausted": "critical",
244
+ "projection.failed": "high",
245
+ "event.listener.failed": "high",
246
+ "enqueue.failed": "high",
247
+ };
248
+
249
+ /**
250
+ * Severity for a failure. Terminal shapes are loudest; a failure that will
251
+ * still retry is only a warning (the system hasn't given up yet); validation /
252
+ * declined are expected control-flow, also a warning.
253
+ */
254
+ export function severityOf(r: TelemetryRecord): Severity {
255
+ const a = attemptOf(r);
256
+ const fixed = KIND_SEVERITY[r.kind];
257
+ if (fixed) return fixed;
258
+ if (a.willRetry) return "warning";
259
+ const category = categorize(r);
260
+ if (category === "validation" || category === "declined" || category === "unauthorized")
261
+ return "warning";
262
+ return "high";
263
+ }
264
+
265
+ const SEVERITY_RANK: Record<Severity, number> = { info: 0, warning: 1, high: 2, critical: 3 };
266
+
267
+ /** Pick the louder of two severities. */
268
+ export function maxSeverity(a: Severity, b: Severity): Severity {
269
+ return SEVERITY_RANK[a] >= SEVERITY_RANK[b] ? a : b;
270
+ }
271
+
272
+ /** The full humanised view of one failure record. */
273
+ export function explainError(r: TelemetryRecord): FriendlyError {
274
+ const category = categorize(r);
275
+ const subject = subjectOf(r);
276
+ const facts = errorFacts(r);
277
+ const copy = COPY[category];
278
+ return {
279
+ title: copy.title,
280
+ summary: copy.summary(subject, facts.message),
281
+ suggestion: copy.suggestion,
282
+ severity: severityOf(r),
283
+ category,
284
+ subject,
285
+ attempt: attemptOf(r),
286
+ rawMessage: facts.message,
287
+ };
288
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Pure helpers for the Home dashboard — stat derivation, health classification,
3
+ * project sorting, and activity formatting. Kept DOM-free so they unit-test in
4
+ * isolation; `Home.vue` and its composables stay thin orchestration.
5
+ */
6
+ import type { DiscoveredProject } from "../composables/useDiscovery";
7
+ import type { ManifestView } from "./manifest";
8
+ import type { TelemetryRecord } from "./telemetry";
9
+ import { isFailure } from "./telemetry";
10
+ import { recordColorKey } from "./kind-colors";
11
+
12
+ /**
13
+ * A project's reachability, derived from its live status. The server reports
14
+ * `{ hasManifest, processes }` per cwd (see `/__nwire/projects/status`):
15
+ *
16
+ * - `running` — at least one live process is registered
17
+ * - `on-disk` — no process, but a `.nwire/manifest.json` exists (browsable)
18
+ * - `unreachable` — neither a process nor a manifest (stale catalog entry)
19
+ */
20
+ export type ProjectHealth = "running" | "on-disk" | "unreachable";
21
+
22
+ /** Status fields we actually read off the discovery response (shape is server-owned). */
23
+ interface StatusShape {
24
+ readonly hasManifest?: boolean;
25
+ readonly processes?: ReadonlyArray<{ readonly port?: number }>;
26
+ readonly running?: boolean;
27
+ readonly port?: number;
28
+ }
29
+
30
+ /** Classify a discovered project's reachability from its live status. */
31
+ export function projectHealth(project: DiscoveredProject): ProjectHealth {
32
+ const status = project.status as StatusShape | undefined;
33
+ const procCount = status?.processes?.length ?? 0;
34
+ if (procCount > 0 || status?.running === true) return "running";
35
+ if (status?.hasManifest === true) return "on-disk";
36
+ return "unreachable";
37
+ }
38
+
39
+ /** Map a health to a `StatusBadge` status token. */
40
+ export function healthBadge(health: ProjectHealth): "live" | "idle" | "error" {
41
+ switch (health) {
42
+ case "running":
43
+ return "live";
44
+ case "on-disk":
45
+ return "idle";
46
+ case "unreachable":
47
+ return "error";
48
+ }
49
+ }
50
+
51
+ /** Human label for a health state. */
52
+ export function healthLabel(health: ProjectHealth): string {
53
+ switch (health) {
54
+ case "running":
55
+ return "Running";
56
+ case "on-disk":
57
+ return "On disk";
58
+ case "unreachable":
59
+ return "Unreachable";
60
+ }
61
+ }
62
+
63
+ /** First live port for a running project, if the status carries one. */
64
+ export function projectPort(project: DiscoveredProject): number | undefined {
65
+ const status = project.status as StatusShape | undefined;
66
+ return status?.processes?.find((p) => typeof p.port === "number")?.port ?? status?.port;
67
+ }
68
+
69
+ const HEALTH_RANK: Record<ProjectHealth, number> = {
70
+ running: 0,
71
+ "on-disk": 1,
72
+ unreachable: 2,
73
+ };
74
+
75
+ /**
76
+ * Sort discovered projects for the grid: active first, then by health
77
+ * (running → on-disk → unreachable), then alphabetically. Pure; returns a new
78
+ * array.
79
+ */
80
+ export function sortProjects(projects: readonly DiscoveredProject[]): DiscoveredProject[] {
81
+ return [...projects].sort((a, b) => {
82
+ if (a.active !== b.active) return a.active ? -1 : 1;
83
+ const rank = HEALTH_RANK[projectHealth(a)] - HEALTH_RANK[projectHealth(b)];
84
+ if (rank !== 0) return rank;
85
+ return a.snapshot.name.localeCompare(b.snapshot.name);
86
+ });
87
+ }
88
+
89
+ /** The quick-stat counts shown on a project card, from its catalog snapshot. */
90
+ export interface ProjectStats {
91
+ readonly apps: number;
92
+ readonly plugins: number;
93
+ readonly actions: number;
94
+ readonly events: number;
95
+ readonly resolvers: number;
96
+ readonly workflows: number;
97
+ }
98
+
99
+ /**
100
+ * Composition counts for a project card. Drawn from the catalog snapshot the
101
+ * project's own Studio last wrote — tolerant of an absent snapshot (a freshly
102
+ * discovered project that hasn't been opened yet shows zeros, not a crash).
103
+ */
104
+ export function projectStats(project: DiscoveredProject): ProjectStats {
105
+ const c = project.snapshot.composition;
106
+ return {
107
+ apps: c?.apps ?? 0,
108
+ plugins: c?.plugins ?? 0,
109
+ actions: c?.actions ?? 0,
110
+ events: c?.events ?? 0,
111
+ resolvers: c?.resolvers ?? 0,
112
+ workflows: c?.workflows ?? 0,
113
+ };
114
+ }
115
+
116
+ /** True when a snapshot carries no composition yet (manifest not generated). */
117
+ export function statsArePending(project: DiscoveredProject): boolean {
118
+ return project.snapshot.composition == null;
119
+ }
120
+
121
+ /** One KPI in the active-project quick-stats strip. */
122
+ export interface QuickStat {
123
+ readonly label: string;
124
+ readonly value: number;
125
+ readonly kind: string;
126
+ }
127
+
128
+ /**
129
+ * Quick-stats for the active project's KPI strip, derived from the live
130
+ * manifest view + a telemetry error count. Tolerates a null view (manifest not
131
+ * loaded / empty) by reporting zeros. Pure.
132
+ */
133
+ export function quickStats(view: ManifestView | null, errorCount: number): QuickStat[] {
134
+ const count = (kind: string) => (view ? view.byKind(kind).length : 0);
135
+ const total = view ? view.model.nodes.length : 0;
136
+ return [
137
+ { label: "Nodes", value: total, kind: "app" },
138
+ { label: "Actions", value: count("action"), kind: "action" },
139
+ { label: "Events", value: count("event"), kind: "event" },
140
+ { label: "Projections", value: count("projection"), kind: "projection" },
141
+ { label: "Errors", value: errorCount, kind: "error" },
142
+ ];
143
+ }
144
+
145
+ /** Count failure records in a telemetry buffer (the "errors in window" KPI). */
146
+ export function countErrors(records: readonly TelemetryRecord[]): number {
147
+ let n = 0;
148
+ for (const r of records) if (isFailure(r)) n++;
149
+ return n;
150
+ }
151
+
152
+ /** A compact activity row for the recent-activity feed. */
153
+ export interface ActivityItem {
154
+ readonly kind: string;
155
+ /** Colour key for `kindColor`/`KindBadge` (the dotted kind's family). */
156
+ readonly colorKey: string;
157
+ /** Short human label — the handler/event name where present, else the kind. */
158
+ readonly label: string;
159
+ readonly ts?: string;
160
+ readonly correlationId?: string;
161
+ readonly failed: boolean;
162
+ }
163
+
164
+ /**
165
+ * Pull a stable label out of a telemetry record — the named subject
166
+ * (action/event/handler) when present, falling back to the kind.
167
+ */
168
+ export function activityLabel(rec: TelemetryRecord): string {
169
+ const candidates = ["action", "event", "handler", "name", "query", "actor", "projection"];
170
+ for (const key of candidates) {
171
+ const v = (rec as Record<string, unknown>)[key];
172
+ if (typeof v === "string" && v.length > 0) return v;
173
+ }
174
+ return rec.kind;
175
+ }
176
+
177
+ /**
178
+ * The latest `limit` telemetry records as compact activity items, newest first.
179
+ * The buffer is oldest-first; we take the tail and reverse. Pure.
180
+ */
181
+ export function recentActivity(records: readonly TelemetryRecord[], limit = 8): ActivityItem[] {
182
+ const tail = records.slice(Math.max(0, records.length - limit));
183
+ return tail.reverse().map((rec) => ({
184
+ kind: rec.kind,
185
+ colorKey: recordColorKey(rec.kind),
186
+ label: activityLabel(rec),
187
+ ts: rec.ts,
188
+ correlationId: rec.envelope?.correlationId,
189
+ failed: isFailure(rec),
190
+ }));
191
+ }