@nwire/studio 0.9.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 (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -0
  3. package/components.json +19 -0
  4. package/index.html +12 -0
  5. package/package.json +66 -0
  6. package/src/App.vue +305 -0
  7. package/src/components/EmptyState.stories.ts +53 -0
  8. package/src/components/EmptyState.vue +28 -0
  9. package/src/components/ErrorBoundary.vue +60 -0
  10. package/src/components/FilterInput.stories.ts +32 -0
  11. package/src/components/FilterInput.vue +33 -0
  12. package/src/components/JsonView.stories.ts +38 -0
  13. package/src/components/JsonView.vue +34 -0
  14. package/src/components/KindBadge.stories.ts +72 -0
  15. package/src/components/KindBadge.vue +59 -0
  16. package/src/components/ListRow.stories.ts +56 -0
  17. package/src/components/ListRow.vue +48 -0
  18. package/src/components/MasterDetail.stories.ts +74 -0
  19. package/src/components/MasterDetail.vue +35 -0
  20. package/src/components/MonacoViewer.vue +143 -0
  21. package/src/components/PageHeader.stories.ts +45 -0
  22. package/src/components/PageHeader.vue +46 -0
  23. package/src/components/SchemaNode.vue +208 -0
  24. package/src/components/SchemaTree.vue +65 -0
  25. package/src/components/SourceDrawer.vue +136 -0
  26. package/src/components/SourcePill.vue +103 -0
  27. package/src/components/__tests__/EmptyState.test.ts +28 -0
  28. package/src/components/__tests__/ErrorBoundary.test.ts +52 -0
  29. package/src/components/__tests__/FilterInput.test.ts +38 -0
  30. package/src/components/__tests__/JsonView.test.ts +33 -0
  31. package/src/components/__tests__/KindBadge.test.ts +39 -0
  32. package/src/components/__tests__/ListRow.test.ts +39 -0
  33. package/src/components/__tests__/MasterDetail.test.ts +40 -0
  34. package/src/components/__tests__/PageHeader.test.ts +42 -0
  35. package/src/components/index.ts +17 -0
  36. package/src/components/ui/badge/Badge.vue +17 -0
  37. package/src/components/ui/badge/index.ts +25 -0
  38. package/src/components/ui/button/Button.vue +28 -0
  39. package/src/components/ui/button/index.ts +34 -0
  40. package/src/components/ui/card/Card.vue +14 -0
  41. package/src/components/ui/card/CardContent.vue +14 -0
  42. package/src/components/ui/card/CardDescription.vue +14 -0
  43. package/src/components/ui/card/CardFooter.vue +14 -0
  44. package/src/components/ui/card/CardHeader.vue +14 -0
  45. package/src/components/ui/card/CardTitle.vue +14 -0
  46. package/src/components/ui/card/index.ts +6 -0
  47. package/src/components/ui/dialog/Dialog.vue +15 -0
  48. package/src/components/ui/dialog/DialogClose.vue +12 -0
  49. package/src/components/ui/dialog/DialogContent.vue +47 -0
  50. package/src/components/ui/dialog/DialogDescription.vue +22 -0
  51. package/src/components/ui/dialog/DialogFooter.vue +12 -0
  52. package/src/components/ui/dialog/DialogHeader.vue +14 -0
  53. package/src/components/ui/dialog/DialogScrollContent.vue +60 -0
  54. package/src/components/ui/dialog/DialogTitle.vue +22 -0
  55. package/src/components/ui/dialog/DialogTrigger.vue +12 -0
  56. package/src/components/ui/dialog/index.ts +9 -0
  57. package/src/components/ui/input/Input.vue +32 -0
  58. package/src/components/ui/input/index.ts +1 -0
  59. package/src/components/ui/scroll-area/ScrollArea.vue +22 -0
  60. package/src/components/ui/scroll-area/ScrollBar.vue +32 -0
  61. package/src/components/ui/scroll-area/index.ts +2 -0
  62. package/src/components/ui/separator/Separator.vue +27 -0
  63. package/src/components/ui/separator/index.ts +1 -0
  64. package/src/components/ui/skeleton/Skeleton.vue +14 -0
  65. package/src/components/ui/skeleton/index.ts +1 -0
  66. package/src/components/ui/tabs/Tabs.vue +15 -0
  67. package/src/components/ui/tabs/TabsContent.vue +25 -0
  68. package/src/components/ui/tabs/TabsList.vue +25 -0
  69. package/src/components/ui/tabs/TabsTrigger.vue +29 -0
  70. package/src/components/ui/tabs/index.ts +4 -0
  71. package/src/components/ui/tooltip/Tooltip.vue +15 -0
  72. package/src/components/ui/tooltip/TooltipContent.vue +40 -0
  73. package/src/components/ui/tooltip/TooltipProvider.vue +12 -0
  74. package/src/components/ui/tooltip/TooltipTrigger.vue +12 -0
  75. package/src/components/ui/tooltip/index.ts +4 -0
  76. package/src/composables/useCopy.ts +31 -0
  77. package/src/lib/__tests__/normalize-cache.test.ts +104 -0
  78. package/src/lib/cache.ts +334 -0
  79. package/src/lib/normalize-cache.ts +92 -0
  80. package/src/lib/project-catalog.ts +125 -0
  81. package/src/lib/utils.ts +6 -0
  82. package/src/main.ts +112 -0
  83. package/src/pages/Actions.vue +180 -0
  84. package/src/pages/Commands.vue +262 -0
  85. package/src/pages/Dispatch.vue +431 -0
  86. package/src/pages/Events.vue +166 -0
  87. package/src/pages/Home.stories.ts +47 -0
  88. package/src/pages/Home.vue +485 -0
  89. package/src/pages/Hooks.vue +297 -0
  90. package/src/pages/Live.vue +249 -0
  91. package/src/pages/Modules.vue +174 -0
  92. package/src/pages/Overview.vue +159 -0
  93. package/src/pages/Plugins.stories.ts +44 -0
  94. package/src/pages/Plugins.vue +403 -0
  95. package/src/pages/Projects.vue +272 -0
  96. package/src/pages/Run.vue +479 -0
  97. package/src/pages/Topology.vue +164 -0
  98. package/src/pages/Trace.vue +511 -0
  99. package/src/pages/TraceNode.vue +166 -0
  100. package/src/pages/Workflows.vue +191 -0
  101. package/src/pages/__tests__/Actions.test.ts +98 -0
  102. package/src/pages/__tests__/Home.test.ts +98 -0
  103. package/src/pages/__tests__/Hooks.test.ts +119 -0
  104. package/src/pages/__tests__/Plugins.test.ts +80 -0
  105. package/src/style.css +40 -0
  106. package/tsconfig.json +20 -0
  107. package/vite.config.ts +892 -0
@@ -0,0 +1,485 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Home — the operational dashboard.
4
+ *
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:
8
+ *
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.
16
+ */
17
+ import { computed, onMounted, onUnmounted, ref } from "vue";
18
+ import { useRouter } from "vue-router";
19
+ import {
20
+ Home as HomeIcon,
21
+ AlertTriangle,
22
+ Activity,
23
+ Boxes,
24
+ Flame,
25
+ CheckCircle2,
26
+ WifiOff,
27
+ } from "lucide-vue-next";
28
+ import { useCache } from "@/lib/cache";
29
+ import { PageHeader, EmptyState } from "@/components";
30
+
31
+ // ── Telemetry record subset we care about ─────────────────────────────
32
+ interface BaseRecord {
33
+ readonly kind: string;
34
+ readonly appName?: string;
35
+ readonly ts: string;
36
+ }
37
+ interface FailureRecord extends BaseRecord {
38
+ readonly kind: "action.failed" | "dlq.recorded" | "reaction.failed";
39
+ readonly action?: string;
40
+ readonly sourceEvent?: string;
41
+ readonly error?: { message?: string };
42
+ readonly envelope?: { correlationId?: string };
43
+ }
44
+ interface ActionDispatched extends BaseRecord {
45
+ readonly kind: "action.dispatched";
46
+ readonly action: string;
47
+ readonly envelope?: { correlationId?: string };
48
+ }
49
+ interface ActionFailed extends BaseRecord {
50
+ readonly kind: "action.failed";
51
+ readonly action: string;
52
+ readonly envelope?: { correlationId?: string };
53
+ }
54
+ interface HookStep extends BaseRecord {
55
+ readonly kind: "hook.step";
56
+ readonly hookName: string;
57
+ readonly phase: "start" | "end" | "error";
58
+ }
59
+ interface LifecycleRecord extends BaseRecord {
60
+ readonly kind: "lifecycle";
61
+ readonly event: string;
62
+ readonly payload: { pluginName?: string; durationMs?: number; kind?: "plugin" | "module" };
63
+ }
64
+
65
+ type TelemetryLike =
66
+ | FailureRecord
67
+ | ActionDispatched
68
+ | ActionFailed
69
+ | HookStep
70
+ | LifecycleRecord
71
+ | BaseRecord;
72
+
73
+ const router = useRouter();
74
+ const { cache } = useCache();
75
+
76
+ // ── Live data buffers ─────────────────────────────────────────────────
77
+ const failures = ref<FailureRecord[]>([]);
78
+ const dispatched = ref<ActionDispatched[]>([]);
79
+ const actionFailed = ref<ActionFailed[]>([]);
80
+ const hookSteps = ref<HookStep[]>([]);
81
+ const lifecycle = ref<LifecycleRecord[]>([]);
82
+
83
+ const telemetryStatus = ref<"connecting" | "open" | "closed" | "error">("connecting");
84
+ const recentLoaded = ref(false);
85
+ const recentFailed = ref(false);
86
+
87
+ let es: EventSource | null = null;
88
+
89
+ // Re-render metrics each second so the rolling window slides smoothly.
90
+ const now = ref(Date.now());
91
+ let nowTimer: ReturnType<typeof setInterval> | null = null;
92
+
93
+ onMounted(() => {
94
+ connect();
95
+ void loadRecent();
96
+ nowTimer = setInterval(() => {
97
+ now.value = Date.now();
98
+ }, 1000);
99
+ });
100
+
101
+ onUnmounted(() => {
102
+ es?.close();
103
+ if (nowTimer) clearInterval(nowTimer);
104
+ });
105
+
106
+ function connect(): void {
107
+ telemetryStatus.value = "connecting";
108
+ es?.close();
109
+ es = new EventSource("/_nwire/telemetry/stream");
110
+ es.onopen = () => {
111
+ telemetryStatus.value = "open";
112
+ };
113
+ es.onerror = () => {
114
+ telemetryStatus.value = "error";
115
+ };
116
+ es.onmessage = (m) => {
117
+ try {
118
+ const rec = JSON.parse(m.data) as TelemetryLike;
119
+ ingest(rec);
120
+ } catch {
121
+ /* ignore non-JSON */
122
+ }
123
+ };
124
+ }
125
+
126
+ async function loadRecent(): Promise<void> {
127
+ try {
128
+ const res = await fetch("/_nwire/telemetry/recent?limit=500");
129
+ if (!res.ok) {
130
+ recentFailed.value = true;
131
+ return;
132
+ }
133
+ const rows = (await res.json()) as TelemetryLike[];
134
+ for (const r of rows) ingest(r);
135
+ recentLoaded.value = true;
136
+ } catch {
137
+ recentFailed.value = true;
138
+ }
139
+ }
140
+
141
+ function ingest(rec: TelemetryLike): void {
142
+ switch (rec.kind) {
143
+ case "action.failed":
144
+ case "dlq.recorded":
145
+ case "reaction.failed":
146
+ failures.value.unshift(rec as FailureRecord);
147
+ if (failures.value.length > 200) failures.value.length = 200;
148
+ if (rec.kind === "action.failed") {
149
+ actionFailed.value.push(rec as ActionFailed);
150
+ if (actionFailed.value.length > 2000) actionFailed.value.splice(0, 500);
151
+ }
152
+ break;
153
+ case "action.dispatched":
154
+ dispatched.value.push(rec as ActionDispatched);
155
+ if (dispatched.value.length > 2000) dispatched.value.splice(0, 500);
156
+ break;
157
+ case "hook.step":
158
+ hookSteps.value.push(rec as HookStep);
159
+ if (hookSteps.value.length > 2000) hookSteps.value.splice(0, 500);
160
+ break;
161
+ case "lifecycle":
162
+ lifecycle.value.push(rec as LifecycleRecord);
163
+ if (lifecycle.value.length > 500) lifecycle.value.splice(0, 100);
164
+ break;
165
+ }
166
+ }
167
+
168
+ // ── Section 1: recent failures (last 10) ──────────────────────────────
169
+ const recentFailures = computed(() => failures.value.slice(0, 10));
170
+
171
+ function failureLabel(f: FailureRecord): string {
172
+ return f.action ?? f.sourceEvent ?? f.kind;
173
+ }
174
+ function failureError(f: FailureRecord): string {
175
+ const m = f.error?.message ?? "";
176
+ return m.length > 80 ? `${m.slice(0, 80)}…` : m;
177
+ }
178
+ function openTrace(f: FailureRecord): void {
179
+ const id = f.envelope?.correlationId;
180
+ if (!id) return;
181
+ void router.push({ path: "/trace", query: { correlationId: id } });
182
+ }
183
+
184
+ // ── Section 2: live metrics (rolling 60s) ─────────────────────────────
185
+ const WINDOW_MS = 60_000;
186
+
187
+ function inWindow(rec: { ts: string }): boolean {
188
+ return now.value - new Date(rec.ts).getTime() <= WINDOW_MS;
189
+ }
190
+
191
+ const reqPerSec = computed(() => {
192
+ const n = dispatched.value.filter(inWindow).length;
193
+ return (n / 60).toFixed(2);
194
+ });
195
+
196
+ const errorRate = computed(() => {
197
+ const recentDispatched = dispatched.value.filter(inWindow);
198
+ if (recentDispatched.length === 0) return "0.0";
199
+ const failedCorr = new Set(
200
+ actionFailed.value
201
+ .filter(inWindow)
202
+ .map((f) => f.envelope?.correlationId)
203
+ .filter((x): x is string => !!x),
204
+ );
205
+ let bad = 0;
206
+ for (const d of recentDispatched) {
207
+ const id = d.envelope?.correlationId;
208
+ if (id && failedCorr.has(id)) bad++;
209
+ }
210
+ return ((bad / recentDispatched.length) * 100).toFixed(1);
211
+ });
212
+
213
+ const hottestHooks = computed(() => {
214
+ const counts = new Map<string, number>();
215
+ for (const s of hookSteps.value) {
216
+ if (s.phase !== "end") continue;
217
+ if (!inWindow(s)) continue;
218
+ counts.set(s.hookName, (counts.get(s.hookName) ?? 0) + 1);
219
+ }
220
+ return [...counts.entries()]
221
+ .sort((a, b) => b[1] - a[1])
222
+ .slice(0, 3)
223
+ .map(([name, count]) => ({ name, count }));
224
+ });
225
+
226
+ const hasLiveData = computed(() => telemetryStatus.value === "open" || recentLoaded.value);
227
+
228
+ // ── Section 3: composition + boot phases ──────────────────────────────
229
+ const pluginsBootedRecords = computed(() =>
230
+ lifecycle.value.filter((l) => l.event === "nwire.plugin.booted"),
231
+ );
232
+
233
+ const bootSummary = computed(() => {
234
+ const recs = pluginsBootedRecords.value;
235
+ if (recs.length === 0) return null;
236
+ let plugins = 0;
237
+ let modules = 0;
238
+ let totalMs = 0;
239
+ for (const r of recs) {
240
+ totalMs += r.payload.durationMs ?? 0;
241
+ if (r.payload.kind === "module") modules++;
242
+ else plugins++;
243
+ }
244
+ return { total: recs.length, plugins, modules, totalMs };
245
+ });
246
+
247
+ const compositionStats = computed(() => {
248
+ if (!cache.value) return null;
249
+ return {
250
+ apps: cache.value.apps.length,
251
+ modules: cache.value.modules.length,
252
+ plugins: cache.value.plugins.length,
253
+ actions: cache.value.actions.length,
254
+ events: cache.value.events.length,
255
+ };
256
+ });
257
+
258
+ function openPluginHook(name: string): void {
259
+ void router.push({ path: "/hooks", query: { name: `plugin.boot:${name}` } });
260
+ }
261
+
262
+ function shortTime(ts: string): string {
263
+ return new Date(ts).toLocaleTimeString(undefined, { hour12: false });
264
+ }
265
+ </script>
266
+
267
+ <template>
268
+ <div class="p-6 space-y-6" data-testid="home-page">
269
+ <PageHeader
270
+ title="Home"
271
+ :icon="HomeIcon"
272
+ icon-color="text-emerald-400"
273
+ :subtitle="
274
+ hasLiveData
275
+ ? 'Live system snapshot — failures, throughput, and what got booted'
276
+ : 'Wire not reporting — showing static composition only'
277
+ "
278
+ />
279
+
280
+ <!-- Section 1: Recent failures ─────────────────────────────────── -->
281
+ <section data-testid="home-failures">
282
+ <div class="flex items-center justify-between mb-2">
283
+ <h2
284
+ class="text-sm font-medium text-zinc-300 uppercase tracking-wide flex items-center gap-2"
285
+ >
286
+ <AlertTriangle class="w-4 h-4 text-rose-400" />
287
+ Recent failures
288
+ </h2>
289
+ <span class="text-[10px] text-zinc-500 tabular-nums">
290
+ {{ recentFailures.length }} shown · {{ failures.length }} total
291
+ </span>
292
+ </div>
293
+
294
+ <div
295
+ v-if="!hasLiveData && recentFailed"
296
+ class="rounded border border-zinc-800 bg-zinc-900/40 px-4 py-6 text-sm text-zinc-500 flex items-center gap-2"
297
+ data-testid="home-failures-no-live"
298
+ >
299
+ <WifiOff class="w-4 h-4" /> No live data — the wire isn't reporting.
300
+ </div>
301
+ <div
302
+ v-else-if="recentFailures.length === 0"
303
+ class="rounded border border-zinc-800 bg-zinc-900/40 px-4 py-6 text-sm text-zinc-400 flex items-center gap-2"
304
+ data-testid="home-failures-empty"
305
+ >
306
+ <CheckCircle2 class="w-4 h-4 text-emerald-400" />
307
+ No failures in the last 10 minutes
308
+ </div>
309
+ <ul v-else class="rounded border border-zinc-800 divide-y divide-zinc-900">
310
+ <li
311
+ v-for="(f, i) in recentFailures"
312
+ :key="`${f.ts}-${i}`"
313
+ class="px-4 py-2 flex items-center gap-3 hover:bg-zinc-900/40 font-mono text-xs"
314
+ data-testid="home-failure-row"
315
+ >
316
+ <span class="text-zinc-600 tabular-nums w-20 shrink-0">
317
+ {{ shortTime(f.ts) }}
318
+ </span>
319
+ <span
320
+ class="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded shrink-0"
321
+ :class="{
322
+ 'bg-rose-950/40 text-rose-300': f.kind === 'action.failed',
323
+ 'bg-orange-950/40 text-orange-300': f.kind === 'dlq.recorded',
324
+ 'bg-amber-950/40 text-amber-300': f.kind === 'reaction.failed',
325
+ }"
326
+ >{{ f.kind.replace(/\..*$/, "") }}</span
327
+ >
328
+ <span class="text-zinc-100 truncate flex-1">{{ failureLabel(f) }}</span>
329
+ <span class="text-red-400 truncate max-w-[40%]">{{ failureError(f) }}</span>
330
+ <button
331
+ v-if="f.envelope?.correlationId"
332
+ type="button"
333
+ class="text-xs text-emerald-400 hover:text-emerald-300 shrink-0"
334
+ @click="openTrace(f)"
335
+ >
336
+ → trace
337
+ </button>
338
+ </li>
339
+ </ul>
340
+ </section>
341
+
342
+ <!-- Section 2: Live metrics ────────────────────────────────────── -->
343
+ <section data-testid="home-metrics">
344
+ <div class="flex items-center justify-between mb-2">
345
+ <h2
346
+ class="text-sm font-medium text-zinc-300 uppercase tracking-wide flex items-center gap-2"
347
+ >
348
+ <Activity class="w-4 h-4 text-emerald-400" />
349
+ Live metrics
350
+ <span class="text-[10px] text-zinc-500 normal-case tracking-normal">(rolling 60s)</span>
351
+ </h2>
352
+ <span
353
+ class="text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-zinc-900 text-zinc-400"
354
+ data-testid="home-metrics-status"
355
+ >
356
+ stream {{ telemetryStatus }}
357
+ </span>
358
+ </div>
359
+
360
+ <div
361
+ v-if="!hasLiveData"
362
+ class="rounded border border-zinc-800 bg-zinc-900/40 px-4 py-6 text-sm text-zinc-500 flex items-center gap-2"
363
+ data-testid="home-metrics-no-live"
364
+ >
365
+ <WifiOff class="w-4 h-4" /> No live data — start the wire and metrics will flow here.
366
+ </div>
367
+ <div v-else class="grid grid-cols-1 md:grid-cols-3 gap-3">
368
+ <div
369
+ class="rounded-lg border border-zinc-800 bg-zinc-900/50 px-4 py-3"
370
+ data-testid="home-metric-rps"
371
+ >
372
+ <div class="text-[11px] uppercase tracking-wide text-zinc-500">req/s</div>
373
+ <div class="text-2xl font-semibold tabular-nums mt-1">{{ reqPerSec }}</div>
374
+ <div class="text-[10px] text-zinc-500 mt-1">actions dispatched ÷ 60</div>
375
+ </div>
376
+ <div
377
+ class="rounded-lg border border-zinc-800 bg-zinc-900/50 px-4 py-3"
378
+ data-testid="home-metric-error-rate"
379
+ >
380
+ <div class="text-[11px] uppercase tracking-wide text-zinc-500">error rate</div>
381
+ <div
382
+ class="text-2xl font-semibold tabular-nums mt-1"
383
+ :class="Number(errorRate) > 0 ? 'text-rose-300' : 'text-emerald-300'"
384
+ >
385
+ {{ errorRate }}%
386
+ </div>
387
+ <div class="text-[10px] text-zinc-500 mt-1">dispatched → failed by correlationId</div>
388
+ </div>
389
+ <div
390
+ class="rounded-lg border border-zinc-800 bg-zinc-900/50 px-4 py-3"
391
+ data-testid="home-metric-hot-hooks"
392
+ >
393
+ <div class="text-[11px] uppercase tracking-wide text-zinc-500 flex items-center gap-1">
394
+ <Flame class="w-3 h-3 text-amber-400" /> hottest hooks
395
+ </div>
396
+ <ul v-if="hottestHooks.length > 0" class="mt-1 space-y-0.5">
397
+ <li
398
+ v-for="h in hottestHooks"
399
+ :key="h.name"
400
+ class="flex items-center justify-between font-mono text-xs"
401
+ >
402
+ <span class="truncate">{{ h.name }}</span>
403
+ <span class="text-zinc-500 tabular-nums">{{ h.count }}</span>
404
+ </li>
405
+ </ul>
406
+ <div v-else class="text-[11px] text-zinc-500 mt-2">No hook activity in window.</div>
407
+ </div>
408
+ </div>
409
+ </section>
410
+
411
+ <!-- Section 3: composition + boot ─────────────────────────────── -->
412
+ <section data-testid="home-composition">
413
+ <div class="flex items-center justify-between mb-2">
414
+ <h2
415
+ class="text-sm font-medium text-zinc-300 uppercase tracking-wide flex items-center gap-2"
416
+ >
417
+ <Boxes class="w-4 h-4 text-cyan-400" />
418
+ Composition
419
+ </h2>
420
+ </div>
421
+
422
+ <EmptyState
423
+ v-if="!cache"
424
+ title="No manifest yet"
425
+ hint="Run `nwire cache` in your project to populate Studio."
426
+ />
427
+ <div v-else class="space-y-3">
428
+ <div
429
+ v-if="bootSummary"
430
+ class="rounded-lg border border-zinc-800 bg-zinc-900/50 px-4 py-3 text-sm"
431
+ data-testid="home-boot-summary"
432
+ >
433
+ Booted <span class="font-semibold">{{ bootSummary.total }}</span> plugins (<span
434
+ class="tabular-nums"
435
+ >{{ bootSummary.modules }}</span
436
+ >
437
+ modules + <span class="tabular-nums">{{ bootSummary.plugins }}</span> plugins) in
438
+ <span class="tabular-nums">{{ bootSummary.totalMs.toFixed(0) }}</span> ms
439
+ </div>
440
+ <div
441
+ v-else-if="compositionStats"
442
+ class="rounded-lg border border-zinc-800 bg-zinc-900/50 px-4 py-3 text-sm text-zinc-400"
443
+ data-testid="home-boot-static"
444
+ >
445
+ <span class="tabular-nums">{{ compositionStats.apps }}</span> app(s),
446
+ <span class="tabular-nums">{{ compositionStats.modules }}</span> module(s),
447
+ <span class="tabular-nums">{{ compositionStats.plugins }}</span> plugin(s) ·
448
+ <span class="tabular-nums">{{ compositionStats.actions }}</span> actions ·
449
+ <span class="tabular-nums">{{ compositionStats.events }}</span> events
450
+ <div v-if="!hasLiveData" class="text-[11px] text-zinc-500 mt-1">
451
+ Boot timings appear once the wire is running.
452
+ </div>
453
+ </div>
454
+
455
+ <ul
456
+ v-if="pluginsBootedRecords.length > 0"
457
+ class="rounded border border-zinc-800 divide-y divide-zinc-900"
458
+ data-testid="home-boot-list"
459
+ >
460
+ <li
461
+ v-for="(p, i) in pluginsBootedRecords"
462
+ :key="`${p.payload.pluginName ?? 'plugin'}-${i}`"
463
+ class="px-4 py-1.5 flex items-center gap-3 font-mono text-xs hover:bg-zinc-900/40"
464
+ >
465
+ <span
466
+ class="text-[10px] uppercase tracking-wide w-14 shrink-0"
467
+ :class="p.payload.kind === 'module' ? 'text-cyan-400' : 'text-violet-400'"
468
+ >{{ p.payload.kind ?? "plugin" }}</span
469
+ >
470
+ <button
471
+ type="button"
472
+ class="flex-1 text-left text-zinc-100 truncate hover:text-emerald-300"
473
+ @click="openPluginHook(p.payload.pluginName ?? 'unknown')"
474
+ >
475
+ {{ p.payload.pluginName ?? "(unnamed)" }}
476
+ </button>
477
+ <span class="text-zinc-500 tabular-nums shrink-0">
478
+ {{ (p.payload.durationMs ?? 0).toFixed(1) }} ms
479
+ </span>
480
+ </li>
481
+ </ul>
482
+ </div>
483
+ </section>
484
+ </div>
485
+ </template>