@nwire/studio 0.12.1 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/package.json +5 -3
  2. package/src/App.vue +62 -56
  3. package/src/components/BcCard.stories.ts +47 -0
  4. package/src/components/BcCard.vue +152 -0
  5. package/src/components/DurationBar.stories.ts +55 -0
  6. package/src/components/DurationBar.vue +72 -0
  7. package/src/components/ErrorCard.stories.ts +133 -0
  8. package/src/components/ErrorCard.vue +153 -0
  9. package/src/components/GraphCanvas.stories.ts +48 -0
  10. package/src/components/GraphCanvas.vue +88 -0
  11. package/src/components/KpiTile.stories.ts +32 -0
  12. package/src/components/KpiTile.vue +39 -0
  13. package/src/components/LiveTable.stories.ts +78 -0
  14. package/src/components/LiveTable.vue +186 -0
  15. package/src/components/MetadataInspector.stories.ts +53 -0
  16. package/src/components/MetadataInspector.vue +105 -0
  17. package/src/components/NodeCard.stories.ts +44 -0
  18. package/src/components/NodeCard.vue +150 -0
  19. package/src/components/RcaPanel.stories.ts +95 -0
  20. package/src/components/RcaPanel.vue +223 -0
  21. package/src/components/ServiceNode.vue +134 -0
  22. package/src/components/SourceDrawer.vue +6 -4
  23. package/src/components/SourcePill.vue +10 -3
  24. package/src/components/StatusBadge.stories.ts +33 -0
  25. package/src/components/StatusBadge.vue +54 -0
  26. package/src/components/Waterfall.stories.ts +85 -0
  27. package/src/components/Waterfall.vue +53 -0
  28. package/src/components/WaterfallRow.vue +74 -0
  29. package/src/components/__tests__/BcCard.test.ts +53 -0
  30. package/src/components/__tests__/DurationBar.test.ts +31 -0
  31. package/src/components/__tests__/ErrorCard.test.ts +71 -0
  32. package/src/components/__tests__/KpiTile.test.ts +23 -0
  33. package/src/components/__tests__/LiveTable.test.ts +100 -0
  34. package/src/components/__tests__/MetadataInspector.test.ts +38 -0
  35. package/src/components/__tests__/NodeCard.test.ts +116 -0
  36. package/src/components/__tests__/RcaPanel.test.ts +81 -0
  37. package/src/components/__tests__/StatusBadge.test.ts +23 -0
  38. package/src/components/__tests__/Waterfall.test.ts +54 -0
  39. package/src/components/index.ts +13 -0
  40. package/src/composables/__tests__/composables-context.test.ts +107 -0
  41. package/src/composables/__tests__/useTelemetry.test.ts +104 -0
  42. package/src/composables/__tests__/useTelemetryRuns.test.ts +282 -0
  43. package/src/composables/useDiscovery.ts +73 -0
  44. package/src/composables/useEndpoints.ts +94 -0
  45. package/src/composables/useLogTail.ts +51 -0
  46. package/src/composables/useManifest.ts +43 -0
  47. package/src/composables/useProcesses.ts +114 -0
  48. package/src/composables/useProject.ts +34 -0
  49. package/src/composables/useTelemetry.ts +270 -0
  50. package/src/lib/__tests__/bc-graph.test.ts +218 -0
  51. package/src/lib/__tests__/dispatch-form.test.ts +113 -0
  52. package/src/lib/__tests__/error-friendly.test.ts +198 -0
  53. package/src/lib/__tests__/home.test.ts +231 -0
  54. package/src/lib/__tests__/inspect.test.ts +160 -0
  55. package/src/lib/__tests__/kind-colors.test.ts +59 -0
  56. package/src/lib/__tests__/live-table.test.ts +194 -0
  57. package/src/lib/__tests__/manifest-health.test.ts +120 -0
  58. package/src/lib/__tests__/manifest.test.ts +87 -0
  59. package/src/lib/__tests__/metadata.test.ts +47 -0
  60. package/src/lib/__tests__/node-metrics.test.ts +144 -0
  61. package/src/lib/__tests__/operate.test.ts +97 -0
  62. package/src/lib/__tests__/pipeline-flow.test.ts +79 -0
  63. package/src/lib/__tests__/rca.test.ts +124 -0
  64. package/src/lib/__tests__/telemetry.test.ts +91 -0
  65. package/src/lib/__tests__/topology-graph.test.ts +331 -0
  66. package/src/lib/__tests__/topology-view.test.ts +154 -0
  67. package/src/lib/__tests__/waterfall.test.ts +165 -0
  68. package/src/lib/bc-graph.ts +298 -0
  69. package/src/lib/dispatch-form.ts +160 -0
  70. package/src/lib/error-friendly.ts +288 -0
  71. package/src/lib/home.ts +191 -0
  72. package/src/lib/inspect.ts +226 -0
  73. package/src/lib/kind-colors.ts +132 -0
  74. package/src/lib/live-table.ts +204 -0
  75. package/src/lib/manifest-health.ts +71 -0
  76. package/src/lib/manifest.ts +139 -0
  77. package/src/lib/metadata.ts +52 -0
  78. package/src/lib/node-metrics.ts +242 -0
  79. package/src/lib/operate.ts +114 -0
  80. package/src/lib/pipeline-flow.ts +120 -0
  81. package/src/lib/rca.ts +193 -0
  82. package/src/lib/telemetry.ts +155 -0
  83. package/src/lib/topology-graph.ts +551 -0
  84. package/src/lib/topology-view.ts +185 -0
  85. package/src/lib/waterfall.ts +148 -0
  86. package/src/main.ts +63 -29
  87. package/src/pages/Errors.vue +272 -0
  88. package/src/pages/Home.stories.ts +7 -8
  89. package/src/pages/Home.vue +255 -540
  90. package/src/pages/Hooks.stories.ts +44 -0
  91. package/src/pages/Hooks.vue +165 -164
  92. package/src/pages/Inspect.vue +240 -0
  93. package/src/pages/Map.vue +187 -0
  94. package/src/pages/Operate.vue +74 -0
  95. package/src/pages/Plugins.stories.ts +1 -1
  96. package/src/pages/Plugins.vue +174 -238
  97. package/src/pages/Projects.vue +62 -60
  98. package/src/pages/Streams.vue +344 -0
  99. package/src/pages/Topology.vue +318 -136
  100. package/src/pages/Trace.vue +174 -412
  101. package/src/pages/__tests__/Home.test.ts +109 -54
  102. package/src/pages/__tests__/Hooks.test.ts +5 -5
  103. package/src/pages/__tests__/Inspect.test.ts +111 -0
  104. package/src/pages/__tests__/Plugins.test.ts +85 -35
  105. package/src/pages/__tests__/Trace.test.ts +117 -0
  106. package/src/pages/operate/CommandsPanel.vue +186 -0
  107. package/src/pages/{Dispatch.vue → operate/DispatchPanel.vue} +100 -203
  108. package/src/pages/operate/EndpointPicker.vue +56 -0
  109. package/src/pages/operate/RunPanel.vue +316 -0
  110. package/src/server/__tests__/nwire-read.test.ts +80 -0
  111. package/src/server/nwire-read.ts +63 -0
  112. package/vite.config.ts +220 -2
  113. package/src/lib/__tests__/normalize-cache.test.ts +0 -105
  114. package/src/lib/cache.ts +0 -312
  115. package/src/lib/normalize-cache.ts +0 -92
  116. package/src/pages/Actions.vue +0 -171
  117. package/src/pages/Apps.vue +0 -177
  118. package/src/pages/Commands.vue +0 -262
  119. package/src/pages/Events.vue +0 -210
  120. package/src/pages/Live.vue +0 -249
  121. package/src/pages/Overview.vue +0 -161
  122. package/src/pages/Projections.vue +0 -148
  123. package/src/pages/Queries.vue +0 -148
  124. package/src/pages/Run.vue +0 -618
  125. package/src/pages/Sinks.vue +0 -124
  126. package/src/pages/TraceNode.vue +0 -164
  127. package/src/pages/Workflows.vue +0 -184
  128. package/src/pages/__tests__/Actions.test.ts +0 -98
  129. package/src/pages/__tests__/Projections.test.ts +0 -90
  130. package/src/pages/__tests__/Queries.test.ts +0 -86
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nwire/studio",
3
- "version": "0.12.1",
3
+ "version": "0.13.1",
4
4
  "description": "Nwire Studio — visual companion for the framework. Vue 3 + Vite + shadcn-vue + VueFlow. System topology, action runner, actor browser, projection viewer, DLQ inspector.",
5
5
  "keywords": [
6
6
  "devtools",
@@ -23,6 +23,7 @@
23
23
  "type": "module",
24
24
  "dependencies": {
25
25
  "@tailwindcss/vite": "^4.0.0",
26
+ "@tanstack/vue-query": "^5.62.0",
26
27
  "@vitejs/plugin-vue": "^5.2.1",
27
28
  "@vue-flow/background": "^1.3.2",
28
29
  "@vue-flow/controls": "^1.1.2",
@@ -40,7 +41,7 @@
40
41
  "vite": "npm:rolldown-vite@latest",
41
42
  "vue": "^3.5.13",
42
43
  "vue-router": "^4.5.0",
43
- "@nwire/supervisor": "0.12.1"
44
+ "@nwire/supervisor": "0.13.1"
44
45
  },
45
46
  "devDependencies": {
46
47
  "@playwright/test": "^1.60.0",
@@ -51,7 +52,8 @@
51
52
  "storybook": "^10.4.0",
52
53
  "typescript": "^5.9.3",
53
54
  "vitest": "^4.1.6",
54
- "vue-tsc": "^2.2.0"
55
+ "vue-tsc": "^2.2.0",
56
+ "@nwire/scan": "0.13.1"
55
57
  },
56
58
  "scripts": {
57
59
  "dev": "vite",
package/src/App.vue CHANGED
@@ -14,15 +14,10 @@ import {
14
14
  import {
15
15
  Home,
16
16
  Network,
17
- Boxes,
18
- Zap,
19
- Radio,
20
17
  GitBranch,
21
18
  Activity,
22
19
  RefreshCw,
23
- Send,
24
20
  Waves,
25
- Play,
26
21
  Workflow,
27
22
  Anchor,
28
23
  Puzzle,
@@ -30,17 +25,42 @@ import {
30
25
  Map,
31
26
  Wrench,
32
27
  Eye,
33
- Database,
34
- Search,
35
- Terminal,
28
+ Bug,
36
29
  } from "lucide-vue-next";
37
- import { useCache } from "@/lib/cache";
30
+ import { useManifest } from "@/composables/useManifest";
31
+ import { useProject } from "@/composables/useProject";
32
+ import { missingFields as detectMissingFields } from "@/lib/manifest-health";
38
33
  import { ErrorBoundary } from "@/components";
39
34
  import { Button } from "@/components/ui/button";
40
35
 
41
36
  const route = useRoute();
42
37
  const router = useRouter();
43
- const { cache, loading, error, missingFields, reload } = useCache();
38
+
39
+ // Native manifest feed — the deep `.nwire/manifest.json` read directly. Drives
40
+ // the footer counts, the built timestamp, the load/error states, and the
41
+ // stale-manifest banner.
42
+ const { activeCwd } = useProject();
43
+ const { manifest, view, isLoading, error, refetch } = useManifest(activeCwd);
44
+
45
+ /**
46
+ * Field paths the manifest is missing vs. the current scanner schema, plus a
47
+ * version mismatch. Drives the stale-manifest banner so an operator on an old
48
+ * `.nwire/manifest.json` is told exactly what to rebuild.
49
+ */
50
+ const missingFields = computed(() => detectMissingFields(manifest.value));
51
+ const isStale = computed(
52
+ () => missingFields.value.length > 0 || (view.value?.versionMismatch ?? false),
53
+ );
54
+
55
+ /**
56
+ * When the manifest was built — a runtime field the scanner stamps onto the
57
+ * JSON but not the typed `Manifest`, so read it tolerantly. `null` until the
58
+ * first fetch lands, which keeps the footer line hidden.
59
+ */
60
+ const builtAt = computed(() => {
61
+ const stamp = (manifest.value as { generatedAt?: unknown } | undefined)?.generatedAt;
62
+ return typeof stamp === "string" ? stamp : null;
63
+ });
44
64
 
45
65
  /**
46
66
  * The active project slug — read from the URL when present, falls back
@@ -89,7 +109,7 @@ const catalog = ref<Record<string, ProjectSnapshot>>({});
89
109
 
90
110
  async function bootProjects() {
91
111
  // Server-side registration happens in main.ts before mount (so the
92
- // cache fetch has an accepted cwd). Here we resolve the active
112
+ // manifest fetch has an accepted cwd). Here we resolve the active
93
113
  // project's metadata + load the catalog into reactive state.
94
114
  catalog.value = loadCatalog();
95
115
  // If the URL carries a /projects/:slug, that's the source of truth —
@@ -169,8 +189,8 @@ onBeforeUnmount(() => {
169
189
  // Whenever the manifest finishes loading (initial fetch + reloads), update
170
190
  // this project's catalog snapshot so /projects shows fresh composition
171
191
  // counts the next time Studio opens. Project metadata might land before
172
- // the cache does, so we watch both.
173
- watch([project, cache], persistSnapshot);
192
+ // the manifest does, so we watch both.
193
+ watch([project, view], persistSnapshot);
174
194
 
175
195
  /**
176
196
  * One-shot URL upgrade — if the user landed on a bare path like
@@ -186,41 +206,32 @@ watch([project, () => route.path], ([_, currentPath]) => {
186
206
  // Don't fight a fresh navigation in flight.
187
207
  const target = currentPath === "/" ? `/projects/${slug}` : `/projects/${slug}${currentPath}`;
188
208
  if (target !== currentPath) {
189
- void router.replace(target);
209
+ // Preserve query + hash — deep-links like /inspect?kind=event must survive
210
+ // the upgrade.
211
+ void router.replace({ path: target, query: route.query, hash: route.hash });
190
212
  }
191
213
  });
192
214
 
193
215
  function persistSnapshot() {
194
216
  if (!project.value) return;
195
- const c = cache.value;
217
+ const v = view.value;
196
218
  upsertCurrent({
197
219
  cwd: project.value.cwd,
198
220
  name: project.value.name,
199
221
  lastVisited: new Date().toISOString(),
200
- composition: c
222
+ composition: v
201
223
  ? {
202
- apps: c.apps.length,
203
- plugins: c.plugins.length,
204
- actions: c.actions.length,
205
- events: c.events.length,
206
- resolvers: c.resolvers?.length ?? 0,
207
- workflows: c.workflows?.length ?? 0,
224
+ apps: v.byKind("app").length,
225
+ plugins: v.byKind("plugin").length,
226
+ actions: v.byKind("action").length,
227
+ events: v.byKind("event").length,
228
+ resolvers: v.byKind("resolver").length,
229
+ workflows: v.byKind("workflow").length,
208
230
  }
209
231
  : undefined,
210
232
  });
211
233
  }
212
234
 
213
- async function rebuildCache() {
214
- // Studio's runner-plugin exposes the CLI surface; "cache" rebuilds .nwire/.
215
- await fetch("/__nwire/run/exec", {
216
- method: "POST",
217
- headers: { "Content-Type": "application/json" },
218
- body: JSON.stringify({ command: "cache" }),
219
- });
220
- // Give the supervisor a beat to write the new manifest, then refresh.
221
- setTimeout(() => void reload(), 800);
222
- }
223
-
224
235
  // Studio nav — three top-level surfaces:
225
236
  //
226
237
  // Map — the live system in motion (architecture, trace, stream)
@@ -239,31 +250,26 @@ const navGroups = [
239
250
  label: "Map",
240
251
  icon: Map,
241
252
  items: [
253
+ { to: "/map", label: "System Map", icon: Map }, // BC cards + inter-context flows (hero)
242
254
  { to: "/topology", label: "Topology", icon: Network }, // app+module shape
243
255
  { to: "/trace", label: "Trace", icon: Workflow }, // causal tree, one correlationId
244
- { to: "/live", label: "Stream", icon: Waves }, // raw live event firehose
256
+ { to: "/errors", label: "Errors", icon: Bug }, // friendly RCA over failures
257
+ { to: "/streams", label: "Streams", icon: Waves }, // live telemetry firehose
245
258
  ],
246
259
  },
247
260
  {
248
261
  label: "Run",
249
262
  icon: Wrench,
250
263
  items: [
251
- { to: "/dispatch", label: "Try", icon: Send }, // form-driven action dispatch
252
- { to: "/run", label: "Processes", icon: Play }, // start/stop processes + logs
253
- { to: "/commands", label: "Commands", icon: Terminal }, // please CLI surface
254
- { to: "/workflows", label: "Workflows", icon: GitBranch }, // workflow defs + runs
264
+ { to: "/operate", label: "Operate", icon: Wrench }, // dispatch · run · commands (one view)
265
+ { to: "/inspect?kind=workflow", label: "Workflows", icon: GitBranch }, // workflow defs
255
266
  ],
256
267
  },
257
268
  {
258
269
  label: "Inspect",
259
270
  icon: Eye,
260
271
  items: [
261
- { to: "/apps", label: "Apps", icon: Boxes }, // bounded contexts one per app
262
- { to: "/actions", label: "Actions", icon: Zap },
263
- { to: "/events", label: "Events", icon: Radio },
264
- { to: "/projections", label: "Projections", icon: Database },
265
- { to: "/queries", label: "Queries", icon: Search },
266
- { to: "/sinks", label: "Sinks", icon: Waves }, // outbound delivery chain
272
+ { to: "/inspect", label: "Browse", icon: Eye }, // unified per-kind browser (native manifest)
267
273
  { to: "/plugins", label: "Plugins", icon: Puzzle },
268
274
  { to: "/hooks", label: "Hooks", icon: Anchor },
269
275
  ],
@@ -369,18 +375,20 @@ const navGroups = [
369
375
  </div>
370
376
  </nav>
371
377
  <div class="border-t border-zinc-800 px-4 py-3 text-xs text-zinc-500 space-y-1">
372
- <div v-if="cache" class="flex items-center justify-between">
373
- <span>{{ cache.apps.length }} apps · {{ cache.plugins.length }} plugins</span>
378
+ <div v-if="view" class="flex items-center justify-between">
379
+ <span
380
+ >{{ view.byKind("app").length }} apps · {{ view.byKind("plugin").length }} plugins</span
381
+ >
374
382
  <button
375
383
  class="text-zinc-400 hover:text-zinc-100 transition-colors"
376
384
  title="Reload manifest"
377
- @click="reload"
385
+ @click="refetch()"
378
386
  >
379
387
  <RefreshCw class="w-3 h-3" />
380
388
  </button>
381
389
  </div>
382
- <div v-if="cache" class="text-zinc-600 text-[10px]">
383
- built {{ new Date(cache.generatedAt).toLocaleString() }}
390
+ <div v-if="builtAt" class="text-zinc-600 text-[10px]">
391
+ built {{ new Date(builtAt).toLocaleString() }}
384
392
  </div>
385
393
  </div>
386
394
  </aside>
@@ -391,16 +399,14 @@ const navGroups = [
391
399
  class="m-6 p-4 rounded bg-red-950/50 border border-red-900 text-red-200"
392
400
  data-testid="cache-error"
393
401
  >
394
- <div class="font-medium mb-1">Cache load error</div>
402
+ <div class="font-medium mb-1">Manifest load error</div>
395
403
  <div class="text-sm">{{ error }}</div>
396
- <Button variant="secondary" size="sm" class="mt-3" @click="rebuildCache">
397
- Rebuild cache
398
- </Button>
404
+ <Button variant="secondary" size="sm" class="mt-3" @click="refetch()"> Retry </Button>
399
405
  </div>
400
- <div v-else-if="loading && !cache" class="p-6 text-zinc-400">Loading manifest…</div>
406
+ <div v-else-if="isLoading && !manifest" class="p-6 text-zinc-400">Loading manifest…</div>
401
407
  <template v-else>
402
408
  <div
403
- v-if="missingFields.length > 0"
409
+ v-if="isStale"
404
410
  class="m-4 p-3 rounded bg-amber-950/30 border border-amber-900 text-amber-200 text-xs flex items-center justify-between gap-3"
405
411
  data-testid="stale-cache-banner"
406
412
  >
@@ -409,7 +415,7 @@ const navGroups = [
409
415
  missingFields.join(", ")
410
416
  }}). Likely built with an older scanner — rebuild for the full picture.
411
417
  </div>
412
- <Button variant="secondary" size="sm" @click="rebuildCache">Rebuild</Button>
418
+ <Button variant="secondary" size="sm" @click="refetch()">Reload</Button>
413
419
  </div>
414
420
  <ErrorBoundary>
415
421
  <RouterView />
@@ -0,0 +1,47 @@
1
+ import type { Meta, StoryObj } from "@storybook/vue3-vite";
2
+ import BcCard from "./BcCard.vue";
3
+ import type { BcNode } from "@/lib/bc-graph";
4
+
5
+ const orders: BcNode = {
6
+ name: "orders",
7
+ rows: [
8
+ { id: "action:orders.place", kind: "action", name: "orders.place", public: true },
9
+ { id: "action:orders.cancel", kind: "action", name: "orders.cancel" },
10
+ { id: "event:orders.placed", kind: "event", name: "orders.placed", public: true },
11
+ { id: "query:orders.list", kind: "query", name: "orders.list" },
12
+ { id: "workflow:orders.fulfil", kind: "workflow", name: "orders.fulfil" },
13
+ { id: "projection:orders.dashboard", kind: "projection", name: "orders.dashboard" },
14
+ ],
15
+ };
16
+
17
+ const meta: Meta<typeof BcCard> = {
18
+ title: "Map/BcCard",
19
+ component: BcCard,
20
+ tags: ["autodocs"],
21
+ };
22
+ export default meta;
23
+
24
+ type Story = StoryObj<typeof BcCard>;
25
+
26
+ export const Default: Story = {
27
+ render: () => ({
28
+ components: { BcCard },
29
+ setup: () => ({ orders }),
30
+ template: `<div class="w-[280px] bg-zinc-900 p-4"><BcCard :bc="orders" /></div>`,
31
+ }),
32
+ };
33
+
34
+ export const WithSelection: Story = {
35
+ render: () => ({
36
+ components: { BcCard },
37
+ setup: () => ({ orders }),
38
+ template: `<div class="w-[280px] bg-zinc-900 p-4"><BcCard :bc="orders" selected-id="event:orders.placed" /></div>`,
39
+ }),
40
+ };
41
+
42
+ export const Empty: Story = {
43
+ render: () => ({
44
+ components: { BcCard },
45
+ template: `<div class="w-[280px] bg-zinc-900 p-4"><BcCard :bc="{ name: 'billing', rows: [] }" /></div>`,
46
+ }),
47
+ };
@@ -0,0 +1,152 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `BcCard` — one bounded context (one area of the system) as a canvas card: a
4
+ * header with the area name, a health dot, and per-kind counts; an optional
5
+ * live metrics row (dispatches · errors · last activity) once telemetry flows;
6
+ * then its member primitives as selectable rows, each colour-keyed by kind. The
7
+ * Map renders these as the nodes of the system canvas; the same component works
8
+ * standalone (lists, Storybook) because it takes a plain `BcNode` and emits
9
+ * `select`. Metrics are optional, so non-Map consumers render unchanged.
10
+ */
11
+ import { computed } from "vue";
12
+ import { Globe } from "lucide-vue-next";
13
+ import type { BcNode, BcRow } from "@/lib/bc-graph";
14
+ import { kindColor } from "@/lib/kind-colors";
15
+ import type { AppMetrics } from "@/lib/node-metrics";
16
+ import { fmtCount } from "@/lib/node-metrics";
17
+
18
+ const props = defineProps<{
19
+ bc: BcNode;
20
+ selectedId?: string | null;
21
+ /** Live per-area metrics, or undefined before any run (neutral placeholders). */
22
+ metrics?: AppMetrics;
23
+ }>();
24
+
25
+ const emit = defineEmits<{ (e: "select", row: BcRow): void }>();
26
+
27
+ /** Per-kind counts, in a stable display order, for the header chips. */
28
+ const KIND_ORDER = ["action", "query", "event", "actor", "workflow", "projection"];
29
+ const counts = computed(() => {
30
+ const by = new Map<string, number>();
31
+ for (const r of props.bc.rows) by.set(r.kind, (by.get(r.kind) ?? 0) + 1);
32
+ return KIND_ORDER.filter((k) => by.has(k)).map((k) => ({ kind: k, n: by.get(k)! }));
33
+ });
34
+
35
+ /** Health: error if any failures, live if any traffic, else idle (pre-run). */
36
+ const health = computed<"idle" | "live" | "error">(() => {
37
+ const m = props.metrics;
38
+ if (!m || m.total === 0) return "idle";
39
+ return m.errors > 0 ? "error" : "live";
40
+ });
41
+ const healthDot = computed(
42
+ () => ({ idle: "bg-zinc-600", live: "bg-emerald-400", error: "bg-rose-400" })[health.value],
43
+ );
44
+
45
+ /** "12:04:31" style local time for the last-activity stamp. */
46
+ const lastActivity = computed(() => {
47
+ const ts = props.metrics?.lastTs;
48
+ if (!ts) return "—";
49
+ const d = new Date(ts);
50
+ return Number.isNaN(d.getTime()) ? "—" : d.toLocaleTimeString();
51
+ });
52
+
53
+ /** Drop the `${app}.` prefix so rows read short inside their own card. */
54
+ function shortName(name: string): string {
55
+ const prefix = `${props.bc.name}.`;
56
+ return name.startsWith(prefix) ? name.slice(prefix.length) : name;
57
+ }
58
+ </script>
59
+
60
+ <template>
61
+ <div
62
+ class="rounded-xl border bg-zinc-950 overflow-hidden flex flex-col w-full shadow-sm transition-colors"
63
+ :class="health === 'error' ? 'border-rose-900/60' : 'border-zinc-800'"
64
+ data-testid="bc-card"
65
+ >
66
+ <div class="px-3 py-2.5 border-b border-zinc-800 bg-zinc-900/50">
67
+ <div class="flex items-center gap-2">
68
+ <span
69
+ class="h-2 w-2 shrink-0 rounded-full"
70
+ :class="[healthDot, health === 'live' ? 'animate-pulse' : '']"
71
+ :data-status="health"
72
+ data-testid="bc-card-health"
73
+ />
74
+ <span class="font-mono text-sm text-zinc-100 truncate flex-1" data-testid="bc-card-name">
75
+ {{ bc.name }}
76
+ </span>
77
+ <span class="text-[10px] text-zinc-600">{{ bc.rows.length }}</span>
78
+ </div>
79
+ <div class="mt-1.5 flex flex-wrap gap-1.5">
80
+ <span
81
+ v-for="c in counts"
82
+ :key="c.kind"
83
+ class="inline-flex items-center gap-1 text-[10px] text-zinc-400"
84
+ >
85
+ <span class="h-1.5 w-1.5 rounded-full" :style="{ background: kindColor(c.kind) }" />
86
+ {{ c.n }} {{ c.kind }}
87
+ </span>
88
+ </div>
89
+
90
+ <!-- live metrics row -->
91
+ <div
92
+ class="mt-2 grid grid-cols-3 gap-1 rounded-md border border-zinc-800/80 bg-zinc-950/60 px-2 py-1.5"
93
+ data-testid="bc-card-metrics"
94
+ >
95
+ <div class="flex flex-col">
96
+ <span
97
+ class="font-mono text-[12px] tabular-nums text-zinc-200"
98
+ data-testid="bc-metric-dispatches"
99
+ >{{ fmtCount(metrics?.dispatches) }}</span
100
+ >
101
+ <span class="text-[8px] uppercase tracking-wide text-zinc-600">dispatches</span>
102
+ </div>
103
+ <div class="flex flex-col">
104
+ <span
105
+ class="font-mono text-[12px] tabular-nums"
106
+ :class="metrics && metrics.errors > 0 ? 'text-rose-300' : 'text-zinc-200'"
107
+ data-testid="bc-metric-errors"
108
+ >{{ fmtCount(metrics?.errors) }}</span
109
+ >
110
+ <span class="text-[8px] uppercase tracking-wide text-zinc-600">errors</span>
111
+ </div>
112
+ <div class="flex flex-col">
113
+ <span
114
+ class="font-mono text-[11px] tabular-nums text-zinc-300"
115
+ data-testid="bc-metric-last"
116
+ >{{ lastActivity }}</span
117
+ >
118
+ <span class="text-[8px] uppercase tracking-wide text-zinc-600">last</span>
119
+ </div>
120
+ </div>
121
+ </div>
122
+
123
+ <div class="flex-1 overflow-y-auto max-h-[280px] py-1">
124
+ <button
125
+ v-for="row in bc.rows"
126
+ :key="row.id"
127
+ type="button"
128
+ class="w-full flex items-center gap-2 px-3 py-1 text-left hover:bg-zinc-900 transition-colors"
129
+ :class="{ 'bg-zinc-900': row.id === selectedId }"
130
+ :data-testid="`bc-row-${row.id}`"
131
+ @click.stop="emit('select', row)"
132
+ >
133
+ <span
134
+ class="h-2 w-2 rounded-full shrink-0"
135
+ :style="{ background: kindColor(row.kind) }"
136
+ :title="row.kind"
137
+ />
138
+ <span class="font-mono text-xs text-zinc-300 truncate flex-1">{{
139
+ shortName(row.name)
140
+ }}</span>
141
+ <Globe
142
+ v-if="row.public"
143
+ class="w-3 h-3 text-emerald-500 shrink-0"
144
+ data-testid="bc-row-public"
145
+ />
146
+ </button>
147
+ <div v-if="bc.rows.length === 0" class="px-3 py-2 text-[10px] text-zinc-600">
148
+ No primitives yet
149
+ </div>
150
+ </div>
151
+ </div>
152
+ </template>
@@ -0,0 +1,55 @@
1
+ import type { Meta, StoryObj } from "@storybook/vue3-vite";
2
+ import { expect, within } from "storybook/test";
3
+ import DurationBar from "./DurationBar.vue";
4
+
5
+ const meta: Meta<typeof DurationBar> = {
6
+ title: "Flow/DurationBar",
7
+ component: DurationBar,
8
+ tags: ["autodocs"],
9
+ };
10
+ export default meta;
11
+
12
+ type Story = StoryObj<typeof DurationBar>;
13
+
14
+ export const UnderSla: Story = {
15
+ args: { ms: 12.4, max: 200, warnMs: 50, dangerMs: 100 },
16
+ play: async ({ canvasElement }) => {
17
+ const c = within(canvasElement);
18
+ await expect(c.getByTestId("duration-value")).toHaveTextContent("12.4ms");
19
+ await expect(c.getByTestId("duration-bar")).toHaveAttribute("data-tone", "ok");
20
+ },
21
+ };
22
+
23
+ export const Warn: Story = {
24
+ args: { ms: 70, max: 200, warnMs: 50, dangerMs: 100 },
25
+ play: async ({ canvasElement }) => {
26
+ await expect(within(canvasElement).getByTestId("duration-bar")).toHaveAttribute(
27
+ "data-tone",
28
+ "warn",
29
+ );
30
+ },
31
+ };
32
+
33
+ export const Danger: Story = {
34
+ args: { ms: 160, max: 200, warnMs: 50, dangerMs: 100 },
35
+ play: async ({ canvasElement }) => {
36
+ await expect(within(canvasElement).getByTestId("duration-bar")).toHaveAttribute(
37
+ "data-tone",
38
+ "danger",
39
+ );
40
+ },
41
+ };
42
+
43
+ export const Scale: Story = {
44
+ render: () => ({
45
+ components: { DurationBar },
46
+ template: `
47
+ <div class="flex flex-col gap-2 bg-zinc-900 p-4 w-80">
48
+ <DurationBar :ms="4" :max="200" :warn-ms="50" :danger-ms="100" />
49
+ <DurationBar :ms="48" :max="200" :warn-ms="50" :danger-ms="100" />
50
+ <DurationBar :ms="92" :max="200" :warn-ms="50" :danger-ms="100" />
51
+ <DurationBar :ms="180" :max="200" :warn-ms="50" :danger-ms="100" />
52
+ </div>
53
+ `,
54
+ }),
55
+ };
@@ -0,0 +1,72 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `DurationBar` — a horizontal bar sized by a duration in ms, coloured against
4
+ * SLA thresholds (green under `warnMs`, amber up to `dangerMs`, red beyond),
5
+ * with a right-aligned `N.Nms` label. Used in lists and the trace inspector to
6
+ * read latency at a glance.
7
+ *
8
+ * <DurationBar :ms="12.4" :max="120" :warn-ms="50" :danger-ms="100" />
9
+ */
10
+ import { computed } from "vue";
11
+
12
+ const props = withDefaults(
13
+ defineProps<{
14
+ /** Duration in milliseconds. */
15
+ ms: number;
16
+ /** Full-scale value the bar fills against. Defaults to `ms` (always full). */
17
+ max?: number;
18
+ /** Amber at/above this. */
19
+ warnMs?: number;
20
+ /** Red at/above this. */
21
+ dangerMs?: number;
22
+ }>(),
23
+ { max: undefined, warnMs: undefined, dangerMs: undefined },
24
+ );
25
+
26
+ const widthPct = computed(() => {
27
+ const scale = props.max && props.max > 0 ? props.max : props.ms || 1;
28
+ return Math.max(2, Math.min(100, (props.ms / scale) * 100));
29
+ });
30
+
31
+ const tone = computed(() => {
32
+ if (props.dangerMs !== undefined && props.ms >= props.dangerMs) return "danger";
33
+ if (props.warnMs !== undefined && props.ms >= props.warnMs) return "warn";
34
+ return "ok";
35
+ });
36
+
37
+ const barClass = computed(
38
+ () =>
39
+ ({
40
+ ok: "bg-emerald-500/70",
41
+ warn: "bg-amber-500/80",
42
+ danger: "bg-rose-500/80",
43
+ })[tone.value],
44
+ );
45
+ const textClass = computed(
46
+ () =>
47
+ ({
48
+ ok: "text-emerald-300",
49
+ warn: "text-amber-300",
50
+ danger: "text-rose-300",
51
+ })[tone.value],
52
+ );
53
+ </script>
54
+
55
+ <template>
56
+ <div class="flex items-center gap-2" data-testid="duration-bar" :data-tone="tone">
57
+ <div class="relative h-1.5 flex-1 rounded-full bg-zinc-800 overflow-hidden">
58
+ <div
59
+ class="absolute inset-y-0 left-0 rounded-full"
60
+ :class="barClass"
61
+ :style="{ width: widthPct + '%' }"
62
+ />
63
+ </div>
64
+ <span
65
+ class="text-[10px] tabular-nums shrink-0 w-14 text-right"
66
+ :class="textClass"
67
+ data-testid="duration-value"
68
+ >
69
+ {{ ms.toFixed(1) }}ms
70
+ </span>
71
+ </div>
72
+ </template>