@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.
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
@@ -1,599 +1,314 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
- * Home — the operational dashboard.
3
+ * Home — the discovery dashboard. The page you open Studio to: which projects
4
+ * are around, which are running, and what's happening in the one you're on.
4
5
  *
5
- * This is the page someone actually opens Studio for: "what just broke?",
6
- * "is anything on fire right now?", "what's hot?". It's a thin orchestrator
7
- * over three data sources:
6
+ * Native data only:
7
+ * - `useDiscovery` the discovered-projects grid (catalog + live health poll)
8
+ * - `useProject` → the active project + a no-reload switch
9
+ * - `useManifest` → the active project's composition (quick-stats strip)
10
+ * - `useTelemetry` → the active project's recent activity + error count
8
11
  *
9
- * 1. Recent failures — telemetry stream + /_nwire/telemetry/recent
10
- * 2. Live metrics — derived from events + telemetry over a 60s window
11
- * 3. Composition+boot — manifest (useCache) + lifecycle records
12
- *
13
- * When the wire isn't running (proxy 502 / SSE error), the live panels mark
14
- * themselves "no live data" and the composition panel still renders from the
15
- * static manifest.
12
+ * Every panel degrades on its own: a project whose manifest isn't generated yet
13
+ * shows pending stats, an unreachable project still lists, and the wire being
14
+ * down just empties the activity feed.
16
15
  */
17
- import { computed, onMounted, onUnmounted, ref } from "vue";
16
+ import { computed } from "vue";
18
17
  import { useRouter } from "vue-router";
18
+ import { RouterLink } from "vue-router";
19
+ import { Home as HomeIcon, FolderSearch, Activity, Map, Radio, Inbox } from "lucide-vue-next";
20
+ import { PageHeader, EmptyState, KpiTile, StatusBadge, KindBadge } from "@/components";
21
+ import { useDiscovery } from "@/composables/useDiscovery";
22
+ import { useProject } from "@/composables/useProject";
23
+ import { useManifest } from "@/composables/useManifest";
24
+ import { useTelemetry } from "@/composables/useTelemetry";
25
+ import { kindColor, recordColorKey } from "@/lib/kind-colors";
26
+ import { projectSlug } from "@/lib/project-catalog";
19
27
  import {
20
- Home as HomeIcon,
21
- AlertTriangle,
22
- Activity,
23
- Boxes,
24
- Flame,
25
- CheckCircle2,
26
- WifiOff,
27
- Cigarette,
28
- } from "lucide-vue-next";
29
- import { useCache } from "@/lib/cache";
30
- import { PageHeader, EmptyState } from "@/components";
31
-
32
- // ── Telemetry record subset we care about ─────────────────────────────
33
- interface BaseRecord {
34
- readonly kind: string;
35
- readonly appName?: string;
36
- readonly ts: string;
37
- }
38
- interface FailureRecord extends BaseRecord {
39
- readonly kind: "action.failed" | "dlq.recorded" | "reaction.failed";
40
- readonly action?: string;
41
- readonly sourceEvent?: string;
42
- readonly error?: { message?: string };
43
- readonly envelope?: { correlationId?: string };
44
- }
45
- interface ActionDispatched extends BaseRecord {
46
- readonly kind: "action.dispatched";
47
- readonly action: string;
48
- readonly envelope?: { correlationId?: string };
49
- }
50
- interface ActionFailed extends BaseRecord {
51
- readonly kind: "action.failed";
52
- readonly action: string;
53
- readonly envelope?: { correlationId?: string };
54
- }
55
- interface HookStep extends BaseRecord {
56
- readonly kind: "hook.step";
57
- readonly hookName: string;
58
- readonly phase: "start" | "end" | "error";
59
- }
60
- interface LifecycleRecord extends BaseRecord {
61
- readonly kind: "lifecycle";
62
- readonly event: string;
63
- readonly payload: { pluginName?: string; durationMs?: number; kind?: "plugin" | "module" };
64
- }
65
-
66
- type TelemetryLike =
67
- | FailureRecord
68
- | ActionDispatched
69
- | ActionFailed
70
- | HookStep
71
- | LifecycleRecord
72
- | BaseRecord;
28
+ sortProjects,
29
+ projectHealth,
30
+ healthBadge,
31
+ healthLabel,
32
+ projectPort,
33
+ projectStats,
34
+ statsArePending,
35
+ quickStats,
36
+ countErrors,
37
+ recentActivity,
38
+ type ProjectStats,
39
+ } from "@/lib/home";
73
40
 
74
41
  const router = useRouter();
75
- const { cache } = useCache();
76
-
77
- // ── Live data buffers ─────────────────────────────────────────────────
78
- const failures = ref<FailureRecord[]>([]);
79
- const dispatched = ref<ActionDispatched[]>([]);
80
- const actionFailed = ref<ActionFailed[]>([]);
81
- const hookSteps = ref<HookStep[]>([]);
82
- const lifecycle = ref<LifecycleRecord[]>([]);
83
-
84
- const telemetryStatus = ref<"connecting" | "open" | "closed" | "error">("connecting");
85
- const recentLoaded = ref(false);
86
- const recentFailed = ref(false);
87
-
88
- let es: EventSource | null = null;
89
-
90
- // Re-render metrics each second so the rolling window slides smoothly.
91
- const now = ref(Date.now());
92
- let nowTimer: ReturnType<typeof setInterval> | null = null;
93
-
94
- onMounted(() => {
95
- connect();
96
- void loadRecent();
97
- nowTimer = setInterval(() => {
98
- now.value = Date.now();
99
- }, 1000);
100
- });
101
-
102
- onUnmounted(() => {
103
- es?.close();
104
- if (nowTimer) clearInterval(nowTimer);
105
- });
106
-
107
- function connect(): void {
108
- telemetryStatus.value = "connecting";
109
- es?.close();
110
- es = new EventSource("/_nwire/telemetry/stream");
111
- es.onopen = () => {
112
- telemetryStatus.value = "open";
113
- };
114
- es.onerror = () => {
115
- telemetryStatus.value = "error";
116
- };
117
- es.onmessage = (m) => {
118
- try {
119
- const rec = JSON.parse(m.data) as TelemetryLike;
120
- ingest(rec);
121
- } catch {
122
- /* ignore non-JSON */
123
- }
124
- };
125
- }
126
-
127
- async function loadRecent(): Promise<void> {
128
- try {
129
- const res = await fetch("/_nwire/telemetry/recent?limit=500");
130
- if (!res.ok) {
131
- recentFailed.value = true;
132
- return;
133
- }
134
- const rows = (await res.json()) as TelemetryLike[];
135
- for (const r of rows) ingest(r);
136
- recentLoaded.value = true;
137
- } catch {
138
- recentFailed.value = true;
139
- }
140
- }
141
-
142
- function ingest(rec: TelemetryLike): void {
143
- switch (rec.kind) {
144
- case "action.failed":
145
- case "dlq.recorded":
146
- case "reaction.failed":
147
- failures.value.unshift(rec as FailureRecord);
148
- if (failures.value.length > 200) failures.value.length = 200;
149
- if (rec.kind === "action.failed") {
150
- actionFailed.value.push(rec as ActionFailed);
151
- if (actionFailed.value.length > 2000) actionFailed.value.splice(0, 500);
152
- }
153
- break;
154
- case "action.dispatched":
155
- dispatched.value.push(rec as ActionDispatched);
156
- if (dispatched.value.length > 2000) dispatched.value.splice(0, 500);
157
- break;
158
- case "hook.step":
159
- hookSteps.value.push(rec as HookStep);
160
- if (hookSteps.value.length > 2000) hookSteps.value.splice(0, 500);
161
- break;
162
- case "lifecycle":
163
- lifecycle.value.push(rec as LifecycleRecord);
164
- if (lifecycle.value.length > 500) lifecycle.value.splice(0, 100);
165
- break;
166
- }
167
- }
168
42
 
169
- // ── Section 1: recent failures (last 10) ──────────────────────────────
170
- const recentFailures = computed(() => failures.value.slice(0, 10));
171
-
172
- function failureLabel(f: FailureRecord): string {
173
- return f.action ?? f.sourceEvent ?? f.kind;
174
- }
175
- function failureError(f: FailureRecord): string {
176
- const m = f.error?.message ?? "";
177
- return m.length > 80 ? `${m.slice(0, 80)}…` : m;
178
- }
179
- function openTrace(f: FailureRecord): void {
180
- const id = f.envelope?.correlationId;
181
- if (!id) return;
182
- void router.push({ path: "/trace", query: { correlationId: id } });
43
+ const { projects, runningCount, isLoading, isError, refetch } = useDiscovery();
44
+ const { activeCwd, activeName, switchTo } = useProject();
45
+ const { view } = useManifest(activeCwd);
46
+ const { records } = useTelemetry(activeCwd, { limit: 500 });
47
+
48
+ // ── Discovered projects grid ──────────────────────────────────────────
49
+ const sorted = computed(() => sortProjects(projects.value));
50
+
51
+ // ── Active project quick stats ────────────────────────────────────────
52
+ const errorCount = computed(() => countErrors(records.value));
53
+ const stats = computed(() => quickStats(view.value, errorCount.value));
54
+
55
+ // ── Recent activity ───────────────────────────────────────────────────
56
+ const activity = computed(() => recentActivity(records.value, 8));
57
+
58
+ const statRows: ReadonlyArray<readonly [keyof ProjectStats, string]> = [
59
+ ["apps", "apps"],
60
+ ["plugins", "plugins"],
61
+ ["actions", "actions"],
62
+ ["events", "events"],
63
+ ["resolvers", "resolvers"],
64
+ ["workflows", "workflows"],
65
+ ];
66
+
67
+ /** Switch the active project (no reload) then jump into one of its pages. */
68
+ function jumpInto(cwd: string, page: "map" | "operate" | "streams"): void {
69
+ if (cwd !== activeCwd.value) switchTo(cwd);
70
+ const slug = projectSlug({ cwd, name: nameFor(cwd) });
71
+ void router.push(`/projects/${slug}/${page}`);
183
72
  }
184
73
 
185
- // ── Section 2: live metrics (rolling 60s) ─────────────────────────────
186
- const WINDOW_MS = 60_000;
187
-
188
- function inWindow(rec: { ts: string }): boolean {
189
- return now.value - new Date(rec.ts).getTime() <= WINDOW_MS;
74
+ function nameFor(cwd: string): string {
75
+ return projects.value.find((p) => p.cwd === cwd)?.snapshot.name ?? cwd;
190
76
  }
191
77
 
192
- const reqPerSec = computed(() => {
193
- const n = dispatched.value.filter(inWindow).length;
194
- return (n / 60).toFixed(2);
195
- });
196
-
197
- const errorRate = computed(() => {
198
- const recentDispatched = dispatched.value.filter(inWindow);
199
- if (recentDispatched.length === 0) return "0.0";
200
- const failedCorr = new Set(
201
- actionFailed.value
202
- .filter(inWindow)
203
- .map((f) => f.envelope?.correlationId)
204
- .filter((x): x is string => !!x),
205
- );
206
- let bad = 0;
207
- for (const d of recentDispatched) {
208
- const id = d.envelope?.correlationId;
209
- if (id && failedCorr.has(id)) bad++;
210
- }
211
- return ((bad / recentDispatched.length) * 100).toFixed(1);
212
- });
213
-
214
- const hottestHooks = computed(() => {
215
- const counts = new Map<string, number>();
216
- for (const s of hookSteps.value) {
217
- if (s.phase !== "end") continue;
218
- if (!inWindow(s)) continue;
219
- counts.set(s.hookName, (counts.get(s.hookName) ?? 0) + 1);
220
- }
221
- return [...counts.entries()]
222
- .sort((a, b) => b[1] - a[1])
223
- .slice(0, 3)
224
- .map(([name, count]) => ({ name, count }));
225
- });
226
-
227
- const hasLiveData = computed(() => telemetryStatus.value === "open" || recentLoaded.value);
228
-
229
- // ── Section 3: composition + boot phases ──────────────────────────────
230
- const pluginsBootedRecords = computed(() =>
231
- lifecycle.value.filter((l) => l.event === "nwire.plugin.booted"),
232
- );
233
-
234
- const bootSummary = computed(() => {
235
- const recs = pluginsBootedRecords.value;
236
- if (recs.length === 0) return null;
237
- let plugins = 0;
238
- let totalMs = 0;
239
- for (const r of recs) {
240
- totalMs += r.payload.durationMs ?? 0;
241
- plugins++;
242
- }
243
- return { total: recs.length, plugins, totalMs };
244
- });
245
-
246
- const compositionStats = computed(() => {
247
- if (!cache.value) return null;
248
- return {
249
- apps: cache.value.apps.length,
250
- plugins: cache.value.plugins.length,
251
- actions: cache.value.actions.length,
252
- events: cache.value.events.length,
253
- sinks: cache.value.sinks?.length ?? 0,
254
- };
255
- });
256
-
257
- function openPluginHook(name: string): void {
258
- void router.push({ path: "/hooks", query: { name: `plugin.boot:${name}` } });
259
- }
260
-
261
- function shortTime(ts: string): string {
78
+ function shortTime(ts?: string): string {
79
+ if (!ts) return "";
262
80
  return new Date(ts).toLocaleTimeString(undefined, { hour12: false });
263
81
  }
264
82
 
265
- // ─── Smoke ────────────────────────────────────────────────────────────
266
- //
267
- // Generate sample dispatches against the live wire so the Stream, Trace,
268
- // Home metrics, and Run logs panels actually have something to render
269
- // the first time a user opens Studio. We walk the action manifest, build
270
- // a best-effort payload from each action's inputSchema (filling required
271
- // fields with type-appropriate defaults), and POST `/_nwire/dispatch` for
272
- // each one a few times.
273
- //
274
- // All work is fire-and-forget — failures count as "smoke" too (they show
275
- // up on the failures panel). The button only renders when the cache has
276
- // actions and the live wire seems reachable.
277
-
278
- const smokeBusy = ref(false);
279
- const smokeError = ref<string | null>(null);
280
- const smokeResult = ref<{ fired: number; failed: number } | null>(null);
281
-
282
- function sampleFromSchema(schema: unknown): unknown {
283
- if (!schema || typeof schema !== "object") return {};
284
- const s = schema as {
285
- type?: string;
286
- properties?: Record<string, unknown>;
287
- required?: string[];
288
- enum?: unknown[];
289
- };
290
- if (s.enum && Array.isArray(s.enum) && s.enum.length > 0) return s.enum[0];
291
- switch (s.type) {
292
- case "string":
293
- return "sample";
294
- case "number":
295
- return 1;
296
- case "boolean":
297
- return true;
298
- case "array":
299
- return [];
300
- case "object": {
301
- const out: Record<string, unknown> = {};
302
- const props = s.properties ?? {};
303
- const required = new Set(s.required ?? []);
304
- for (const [key, raw] of Object.entries(props)) {
305
- // Always include required; otherwise skip — best-effort.
306
- if (required.has(key)) out[key] = sampleFromSchema(raw);
307
- }
308
- return out;
309
- }
310
- default:
311
- return null;
312
- }
313
- }
314
-
315
- async function runSmoke(): Promise<void> {
316
- if (!cache.value) return;
317
- smokeBusy.value = true;
318
- smokeError.value = null;
319
- smokeResult.value = null;
320
- let fired = 0;
321
- let failed = 0;
322
- try {
323
- const actions = cache.value.actions;
324
- if (actions.length === 0) {
325
- smokeError.value = "No actions in the manifest — nothing to dispatch.";
326
- return;
327
- }
328
- // Fire each action once with sample input; for the first one fire
329
- // twice so there's at least one repeat in the stream.
330
- for (let i = 0; i < actions.length; i++) {
331
- const a = actions[i]!;
332
- const input = sampleFromSchema(a.inputSchema);
333
- const repeats = i === 0 ? 2 : 1;
334
- for (let r = 0; r < repeats; r++) {
335
- try {
336
- const res = await fetch("/_nwire/dispatch", {
337
- method: "POST",
338
- headers: { "content-type": "application/json" },
339
- body: JSON.stringify({ handler: a.name, input }),
340
- });
341
- if (res.ok) fired++;
342
- else failed++;
343
- } catch {
344
- failed++;
345
- }
346
- }
347
- }
348
- smokeResult.value = { fired, failed };
349
- } catch (err) {
350
- smokeError.value = (err as Error).message;
351
- } finally {
352
- smokeBusy.value = false;
83
+ function openActivity(correlationId?: string): void {
84
+ if (!correlationId) {
85
+ void router.push("/streams");
86
+ return;
353
87
  }
88
+ void router.push({ path: "/trace", query: { correlationId } });
354
89
  }
355
90
  </script>
356
91
 
357
92
  <template>
358
- <div class="p-6 space-y-6" data-testid="home-page">
359
- <div class="flex items-start justify-between gap-6">
93
+ <div class="h-full overflow-auto" data-testid="home-page">
94
+ <div class="max-w-6xl mx-auto px-6 py-6 space-y-8">
360
95
  <PageHeader
361
96
  title="Home"
362
97
  :icon="HomeIcon"
363
98
  icon-color="text-emerald-400"
364
99
  :subtitle="
365
- hasLiveData
366
- ? 'Live system snapshot failures, throughput, and what got booted'
367
- : 'Wire not reporting showing static composition only'
100
+ activeName
101
+ ? `Discovery dashboard${runningCount} running · active: ${activeName}`
102
+ : 'Discovery dashboarddiscovered projects and live activity'
368
103
  "
369
104
  />
370
- <div class="flex flex-col items-end gap-1.5 pt-1" data-testid="home-smoke">
371
- <button
372
- type="button"
373
- class="inline-flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-900 hover:bg-zinc-800 px-3 py-1.5 text-xs font-medium text-zinc-200 transition-colors disabled:opacity-50"
374
- :disabled="smokeBusy || !cache || cache.actions.length === 0"
375
- :title="
376
- !cache || cache.actions.length === 0
377
- ? 'No actions in the manifest yet.'
378
- : 'Fire a sample dispatch against every action so the live panels populate.'
379
- "
380
- @click="runSmoke"
381
- >
382
- <Cigarette class="w-3.5 h-3.5 text-amber-400" />
383
- {{ smokeBusy ? "Smoking…" : "Smoke" }}
384
- </button>
385
- <div v-if="smokeResult" class="text-[10px] text-zinc-500 tabular-nums">
386
- fired {{ smokeResult.fired }} · failed {{ smokeResult.failed }}
387
- </div>
388
- <div v-if="smokeError" class="text-[10px] text-rose-300 max-w-[200px] text-right">
389
- {{ smokeError }}
390
- </div>
391
- </div>
392
- </div>
393
-
394
- <!-- Section 1: Recent failures ─────────────────────────────────── -->
395
- <section data-testid="home-failures">
396
- <div class="flex items-center justify-between mb-2">
397
- <h2
398
- class="text-sm font-medium text-zinc-300 uppercase tracking-wide flex items-center gap-2"
399
- >
400
- <AlertTriangle class="w-4 h-4 text-rose-400" />
401
- Recent failures
402
- </h2>
403
- <span class="text-[10px] text-zinc-500 tabular-nums">
404
- {{ recentFailures.length }} shown · {{ failures.length }} total
405
- </span>
406
- </div>
407
105
 
408
- <div
409
- v-if="!hasLiveData && recentFailed"
410
- class="rounded border border-zinc-800 bg-zinc-900/40 px-4 py-6 text-sm text-zinc-500 flex items-center gap-2"
411
- data-testid="home-failures-no-live"
412
- >
413
- <WifiOff class="w-4 h-4" /> No live data — the wire isn't reporting.
414
- </div>
415
- <div
416
- v-else-if="recentFailures.length === 0"
417
- class="rounded border border-zinc-800 bg-zinc-900/40 px-4 py-6 text-sm text-zinc-400 flex items-center gap-2"
418
- data-testid="home-failures-empty"
419
- >
420
- <CheckCircle2 class="w-4 h-4 text-emerald-400" />
421
- No failures in the last 10 minutes
422
- </div>
423
- <ul v-else class="rounded border border-zinc-800 divide-y divide-zinc-900">
424
- <li
425
- v-for="(f, i) in recentFailures"
426
- :key="`${f.ts}-${i}`"
427
- class="px-4 py-2 flex items-center gap-3 hover:bg-zinc-900/40 font-mono text-xs"
428
- data-testid="home-failure-row"
429
- >
430
- <span class="text-zinc-600 tabular-nums w-20 shrink-0">
431
- {{ shortTime(f.ts) }}
432
- </span>
433
- <span
434
- class="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded shrink-0"
435
- :class="{
436
- 'bg-rose-950/40 text-rose-300': f.kind === 'action.failed',
437
- 'bg-orange-950/40 text-orange-300': f.kind === 'dlq.recorded',
438
- 'bg-amber-950/40 text-amber-300': f.kind === 'reaction.failed',
439
- }"
440
- >{{ f.kind.replace(/\..*$/, "") }}</span
441
- >
442
- <span class="text-zinc-100 truncate flex-1">{{ failureLabel(f) }}</span>
443
- <span class="text-red-400 truncate max-w-[40%]">{{ failureError(f) }}</span>
444
- <button
445
- v-if="f.envelope?.correlationId"
446
- type="button"
447
- class="text-xs text-emerald-400 hover:text-emerald-300 shrink-0"
448
- @click="openTrace(f)"
106
+ <!-- Discovered projects ───────────────────────────────────────── -->
107
+ <section data-testid="home-projects">
108
+ <div class="flex items-center justify-between mb-3">
109
+ <h2
110
+ class="text-sm font-medium text-zinc-300 uppercase tracking-wide flex items-center gap-2"
449
111
  >
450
- trace
451
- </button>
452
- </li>
453
- </ul>
454
- </section>
455
-
456
- <!-- Section 2: Live metrics ────────────────────────────────────── -->
457
- <section data-testid="home-metrics">
458
- <div class="flex items-center justify-between mb-2">
459
- <h2
460
- class="text-sm font-medium text-zinc-300 uppercase tracking-wide flex items-center gap-2"
461
- >
462
- <Activity class="w-4 h-4 text-emerald-400" />
463
- Live metrics
464
- <span class="text-[10px] text-zinc-500 normal-case tracking-normal">(rolling 60s)</span>
465
- </h2>
466
- <span
467
- class="text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-zinc-900 text-zinc-400"
468
- data-testid="home-metrics-status"
469
- >
470
- stream {{ telemetryStatus }}
471
- </span>
472
- </div>
112
+ <FolderSearch class="w-4 h-4 text-emerald-400" />
113
+ Discovered projects
114
+ <span class="text-[10px] text-zinc-500 normal-case tracking-normal tabular-nums">
115
+ {{ sorted.length }} known · {{ runningCount }} running
116
+ </span>
117
+ </h2>
118
+ <div class="flex items-center gap-2">
119
+ <RouterLink
120
+ to="/projects"
121
+ class="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-1 rounded border border-zinc-800 hover:border-zinc-700"
122
+ data-testid="home-all-projects"
123
+ >
124
+ All projects
125
+ </RouterLink>
126
+ <button
127
+ class="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-1 rounded border border-zinc-800 hover:border-zinc-700"
128
+ data-testid="home-refresh"
129
+ @click="refetch()"
130
+ >
131
+ Refresh
132
+ </button>
133
+ </div>
134
+ </div>
473
135
 
474
- <div
475
- v-if="!hasLiveData"
476
- class="rounded border border-zinc-800 bg-zinc-900/40 px-4 py-6 text-sm text-zinc-500 flex items-center gap-2"
477
- data-testid="home-metrics-no-live"
478
- >
479
- <WifiOff class="w-4 h-4" /> No live data — start the wire and metrics will flow here.
480
- </div>
481
- <div v-else class="grid grid-cols-1 md:grid-cols-3 gap-3">
482
136
  <div
483
- class="rounded-lg border border-zinc-800 bg-zinc-900/50 px-4 py-3"
484
- data-testid="home-metric-rps"
137
+ v-if="isLoading && sorted.length === 0"
138
+ class="rounded border border-zinc-800 bg-zinc-900/40 px-4 py-8 text-sm text-zinc-500"
139
+ data-testid="home-projects-loading"
485
140
  >
486
- <div class="text-[11px] uppercase tracking-wide text-zinc-500">req/s</div>
487
- <div class="text-2xl font-semibold tabular-nums mt-1">{{ reqPerSec }}</div>
488
- <div class="text-[10px] text-zinc-500 mt-1">actions dispatched ÷ 60</div>
141
+ Discovering projects…
489
142
  </div>
143
+
490
144
  <div
491
- class="rounded-lg border border-zinc-800 bg-zinc-900/50 px-4 py-3"
492
- data-testid="home-metric-error-rate"
145
+ v-else-if="isError && sorted.length === 0"
146
+ class="rounded border border-rose-900/60 bg-rose-950/20 px-4 py-6 text-sm text-rose-300"
147
+ data-testid="home-projects-error"
493
148
  >
494
- <div class="text-[11px] uppercase tracking-wide text-zinc-500">error rate</div>
495
- <div
496
- class="text-2xl font-semibold tabular-nums mt-1"
497
- :class="Number(errorRate) > 0 ? 'text-rose-300' : 'text-emerald-300'"
498
- >
499
- {{ errorRate }}%
500
- </div>
501
- <div class="text-[10px] text-zinc-500 mt-1">dispatched → failed by correlationId</div>
149
+ Couldn't reach the discovery endpoint.
150
+ <button class="underline ml-1 hover:text-rose-200" @click="refetch()">Retry</button>
502
151
  </div>
503
- <div
504
- class="rounded-lg border border-zinc-800 bg-zinc-900/50 px-4 py-3"
505
- data-testid="home-metric-hot-hooks"
506
- >
507
- <div class="text-[11px] uppercase tracking-wide text-zinc-500 flex items-center gap-1">
508
- <Flame class="w-3 h-3 text-amber-400" /> hottest hooks
509
- </div>
510
- <ul v-if="hottestHooks.length > 0" class="mt-1 space-y-0.5">
511
- <li
512
- v-for="h in hottestHooks"
513
- :key="h.name"
514
- class="flex items-center justify-between font-mono text-xs"
152
+
153
+ <EmptyState
154
+ v-else-if="sorted.length === 0"
155
+ :icon="FolderSearch"
156
+ title="No projects discovered yet"
157
+ hint="Start one with `nwire dev` — it registers itself and shows up here."
158
+ data-testid="home-projects-empty"
159
+ />
160
+
161
+ <div v-else class="grid grid-cols-1 md:grid-cols-2 gap-3" data-testid="home-projects-grid">
162
+ <article
163
+ v-for="p in sorted"
164
+ :key="p.cwd"
165
+ class="border rounded-lg p-4 bg-zinc-950/40 transition-colors"
166
+ :class="p.active ? 'border-emerald-800/70' : 'border-zinc-800 hover:border-zinc-700'"
167
+ data-testid="home-project-card"
168
+ :data-cwd="p.cwd"
169
+ >
170
+ <div class="flex items-start justify-between gap-3">
171
+ <div class="min-w-0">
172
+ <div class="flex items-center gap-2">
173
+ <h3
174
+ class="font-semibold text-sm tracking-tight truncate"
175
+ data-testid="home-project-name"
176
+ >
177
+ {{ p.snapshot.name }}
178
+ </h3>
179
+ <span
180
+ v-if="p.active"
181
+ class="text-[10px] uppercase tracking-wider text-emerald-400 px-1.5 py-0.5 rounded bg-emerald-950/40 border border-emerald-900"
182
+ >
183
+ active
184
+ </span>
185
+ </div>
186
+ <div class="text-[11px] font-mono text-zinc-600 mt-0.5 truncate" :title="p.cwd">
187
+ {{ p.cwd }}
188
+ </div>
189
+ </div>
190
+ <StatusBadge
191
+ :status="healthBadge(projectHealth(p))"
192
+ :pulse="projectHealth(p) === 'running'"
193
+ :label="
194
+ projectHealth(p) === 'running' && projectPort(p)
195
+ ? `Running :${projectPort(p)}`
196
+ : healthLabel(projectHealth(p))
197
+ "
198
+ data-testid="home-project-health"
199
+ />
200
+ </div>
201
+
202
+ <!-- Quick stats from the project's snapshot -->
203
+ <div
204
+ v-if="statsArePending(p)"
205
+ class="mt-4 text-[11px] text-zinc-600"
206
+ data-testid="home-project-stats-pending"
515
207
  >
516
- <span class="truncate">{{ h.name }}</span>
517
- <span class="text-zinc-500 tabular-nums">{{ h.count }}</span>
518
- </li>
519
- </ul>
520
- <div v-else class="text-[11px] text-zinc-500 mt-2">No hook activity in window.</div>
208
+ No composition yet — open it or run `nwire cache`.
209
+ </div>
210
+ <dl
211
+ v-else
212
+ class="grid grid-cols-3 gap-y-2 gap-x-3 mt-4"
213
+ data-testid="home-project-stats"
214
+ >
215
+ <div v-for="[key, label] in statRows" :key="key">
216
+ <dt class="text-zinc-600 text-[10px] uppercase tracking-wide">{{ label }}</dt>
217
+ <dd
218
+ class="text-sm tabular-nums"
219
+ :style="{ color: kindColor(key.replace(/s$/, '')) }"
220
+ >
221
+ {{ projectStats(p)[key] }}
222
+ </dd>
223
+ </div>
224
+ </dl>
225
+
226
+ <!-- Jump-in actions -->
227
+ <div class="flex items-center gap-2 mt-4 pt-3 border-t border-zinc-900">
228
+ <button
229
+ class="text-[11px] text-zinc-300 hover:text-zinc-100 flex items-center gap-1.5 px-2 py-1 rounded border border-zinc-800 hover:border-zinc-700"
230
+ data-testid="home-jump-map"
231
+ @click="jumpInto(p.cwd, 'map')"
232
+ >
233
+ <Map class="w-3 h-3" /> Map
234
+ </button>
235
+ <button
236
+ class="text-[11px] text-zinc-300 hover:text-zinc-100 flex items-center gap-1.5 px-2 py-1 rounded border border-zinc-800 hover:border-zinc-700"
237
+ data-testid="home-jump-operate"
238
+ @click="jumpInto(p.cwd, 'operate')"
239
+ >
240
+ <Inbox class="w-3 h-3" /> Operate
241
+ </button>
242
+ <button
243
+ class="text-[11px] text-zinc-300 hover:text-zinc-100 flex items-center gap-1.5 px-2 py-1 rounded border border-zinc-800 hover:border-zinc-700"
244
+ data-testid="home-jump-streams"
245
+ @click="jumpInto(p.cwd, 'streams')"
246
+ >
247
+ <Radio class="w-3 h-3" /> Streams
248
+ </button>
249
+ </div>
250
+ </article>
521
251
  </div>
522
- </div>
523
- </section>
252
+ </section>
524
253
 
525
- <!-- Section 3: composition + boot ─────────────────────────────── -->
526
- <section data-testid="home-composition">
527
- <div class="flex items-center justify-between mb-2">
254
+ <!-- Active project quick stats ─────────────────────────────────── -->
255
+ <section v-if="activeCwd" data-testid="home-quickstats">
528
256
  <h2
529
- class="text-sm font-medium text-zinc-300 uppercase tracking-wide flex items-center gap-2"
257
+ class="text-sm font-medium text-zinc-300 uppercase tracking-wide flex items-center gap-2 mb-3"
530
258
  >
531
- <Boxes class="w-4 h-4 text-cyan-400" />
532
- Composition
259
+ <Activity class="w-4 h-4 text-emerald-400" />
260
+ {{ activeName }} — at a glance
533
261
  </h2>
534
- </div>
262
+ <div class="flex flex-wrap gap-3">
263
+ <KpiTile
264
+ v-for="s in stats"
265
+ :key="s.label"
266
+ :label="s.label"
267
+ :value="s.value"
268
+ :accent="s.label === 'Errors' && s.value > 0 ? '#fb7185' : kindColor(s.kind)"
269
+ />
270
+ </div>
271
+ </section>
535
272
 
536
- <EmptyState
537
- v-if="!cache"
538
- title="No manifest yet"
539
- hint="Run `nwire cache` in your project to populate Studio."
540
- />
541
- <div v-else class="space-y-3">
542
- <div
543
- v-if="bootSummary"
544
- class="rounded-lg border border-zinc-800 bg-zinc-900/50 px-4 py-3 text-sm"
545
- data-testid="home-boot-summary"
273
+ <!-- Recent activity ─────────────────────────────────────────────── -->
274
+ <section v-if="activeCwd" data-testid="home-activity">
275
+ <h2
276
+ class="text-sm font-medium text-zinc-300 uppercase tracking-wide flex items-center gap-2 mb-3"
546
277
  >
547
- Booted <span class="font-semibold">{{ bootSummary.total }}</span> plugins (<span
548
- class="tabular-nums"
549
- >{{ bootSummary.plugins }}</span
550
- >
551
- plugins recorded) in
552
- <span class="tabular-nums">{{ bootSummary.totalMs.toFixed(0) }}</span> ms
553
- </div>
278
+ <Radio class="w-4 h-4 text-cyan-400" />
279
+ Recent activity
280
+ </h2>
281
+
554
282
  <div
555
- v-else-if="compositionStats"
556
- class="rounded-lg border border-zinc-800 bg-zinc-900/50 px-4 py-3 text-sm text-zinc-400"
557
- data-testid="home-boot-static"
283
+ v-if="activity.length === 0"
284
+ class="rounded border border-zinc-800 bg-zinc-900/40 px-4 py-6 text-sm text-zinc-500"
285
+ data-testid="home-activity-empty"
558
286
  >
559
- <span class="tabular-nums">{{ compositionStats.apps }}</span> app(s),
560
- <span class="tabular-nums">{{ compositionStats.plugins }}</span> plugin(s) ·
561
- <span class="tabular-nums">{{ compositionStats.actions }}</span> actions ·
562
- <span class="tabular-nums">{{ compositionStats.events }}</span> events ·
563
- <span class="tabular-nums">{{ compositionStats.sinks }}</span> sinks
564
- <div v-if="!hasLiveData" class="text-[11px] text-zinc-500 mt-1">
565
- Boot timings appear once the wire is running.
566
- </div>
287
+ No activity yet dispatch something or start the wire.
567
288
  </div>
568
-
569
289
  <ul
570
- v-if="pluginsBootedRecords.length > 0"
290
+ v-else
571
291
  class="rounded border border-zinc-800 divide-y divide-zinc-900"
572
- data-testid="home-boot-list"
292
+ data-testid="home-activity-list"
573
293
  >
574
294
  <li
575
- v-for="(p, i) in pluginsBootedRecords"
576
- :key="`${p.payload.pluginName ?? 'plugin'}-${i}`"
577
- class="px-4 py-1.5 flex items-center gap-3 font-mono text-xs hover:bg-zinc-900/40"
295
+ v-for="(a, i) in activity"
296
+ :key="`${a.ts}-${i}`"
297
+ class="px-4 py-2 flex items-center gap-3 hover:bg-zinc-900/40 font-mono text-xs cursor-pointer"
298
+ data-testid="home-activity-row"
299
+ @click="openActivity(a.correlationId)"
578
300
  >
579
- <span
580
- class="text-[10px] uppercase tracking-wide w-14 shrink-0"
581
- :class="p.payload.kind === 'module' ? 'text-cyan-400' : 'text-violet-400'"
582
- >{{ p.payload.kind ?? "plugin" }}</span
583
- >
584
- <button
585
- type="button"
586
- class="flex-1 text-left text-zinc-100 truncate hover:text-emerald-300"
587
- @click="openPluginHook(p.payload.pluginName ?? 'unknown')"
588
- >
589
- {{ p.payload.pluginName ?? "(unnamed)" }}
590
- </button>
591
- <span class="text-zinc-500 tabular-nums shrink-0">
592
- {{ (p.payload.durationMs ?? 0).toFixed(1) }} ms
301
+ <span class="text-zinc-600 tabular-nums w-20 shrink-0">{{ shortTime(a.ts) }}</span>
302
+ <KindBadge :style="{ color: kindColor(recordColorKey(a.kind)) }">
303
+ {{ a.kind }}
304
+ </KindBadge>
305
+ <span class="flex-1 truncate" :class="a.failed ? 'text-rose-300' : 'text-zinc-100'">
306
+ {{ a.label }}
593
307
  </span>
308
+ <span v-if="a.correlationId" class="text-emerald-400 shrink-0">→ trace</span>
594
309
  </li>
595
310
  </ul>
596
- </div>
597
- </section>
311
+ </section>
312
+ </div>
598
313
  </div>
599
314
  </template>