@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.
- package/package.json +5 -3
- package/src/App.vue +62 -56
- package/src/components/BcCard.stories.ts +47 -0
- package/src/components/BcCard.vue +152 -0
- package/src/components/DurationBar.stories.ts +55 -0
- package/src/components/DurationBar.vue +72 -0
- package/src/components/ErrorCard.stories.ts +133 -0
- package/src/components/ErrorCard.vue +153 -0
- package/src/components/GraphCanvas.stories.ts +48 -0
- package/src/components/GraphCanvas.vue +88 -0
- package/src/components/KpiTile.stories.ts +32 -0
- package/src/components/KpiTile.vue +39 -0
- package/src/components/LiveTable.stories.ts +78 -0
- package/src/components/LiveTable.vue +186 -0
- package/src/components/MetadataInspector.stories.ts +53 -0
- package/src/components/MetadataInspector.vue +105 -0
- package/src/components/NodeCard.stories.ts +44 -0
- package/src/components/NodeCard.vue +150 -0
- package/src/components/RcaPanel.stories.ts +95 -0
- package/src/components/RcaPanel.vue +223 -0
- package/src/components/ServiceNode.vue +134 -0
- package/src/components/SourceDrawer.vue +6 -4
- package/src/components/SourcePill.vue +10 -3
- package/src/components/StatusBadge.stories.ts +33 -0
- package/src/components/StatusBadge.vue +54 -0
- package/src/components/Waterfall.stories.ts +85 -0
- package/src/components/Waterfall.vue +53 -0
- package/src/components/WaterfallRow.vue +74 -0
- package/src/components/__tests__/BcCard.test.ts +53 -0
- package/src/components/__tests__/DurationBar.test.ts +31 -0
- package/src/components/__tests__/ErrorCard.test.ts +71 -0
- package/src/components/__tests__/KpiTile.test.ts +23 -0
- package/src/components/__tests__/LiveTable.test.ts +100 -0
- package/src/components/__tests__/MetadataInspector.test.ts +38 -0
- package/src/components/__tests__/NodeCard.test.ts +116 -0
- package/src/components/__tests__/RcaPanel.test.ts +81 -0
- package/src/components/__tests__/StatusBadge.test.ts +23 -0
- package/src/components/__tests__/Waterfall.test.ts +54 -0
- package/src/components/index.ts +13 -0
- package/src/composables/__tests__/composables-context.test.ts +107 -0
- package/src/composables/__tests__/useTelemetry.test.ts +104 -0
- package/src/composables/__tests__/useTelemetryRuns.test.ts +282 -0
- package/src/composables/useDiscovery.ts +73 -0
- package/src/composables/useEndpoints.ts +94 -0
- package/src/composables/useLogTail.ts +51 -0
- package/src/composables/useManifest.ts +43 -0
- package/src/composables/useProcesses.ts +114 -0
- package/src/composables/useProject.ts +34 -0
- package/src/composables/useTelemetry.ts +270 -0
- package/src/lib/__tests__/bc-graph.test.ts +218 -0
- package/src/lib/__tests__/dispatch-form.test.ts +113 -0
- package/src/lib/__tests__/error-friendly.test.ts +198 -0
- package/src/lib/__tests__/home.test.ts +231 -0
- package/src/lib/__tests__/inspect.test.ts +160 -0
- package/src/lib/__tests__/kind-colors.test.ts +59 -0
- package/src/lib/__tests__/live-table.test.ts +194 -0
- package/src/lib/__tests__/manifest-health.test.ts +120 -0
- package/src/lib/__tests__/manifest.test.ts +87 -0
- package/src/lib/__tests__/metadata.test.ts +47 -0
- package/src/lib/__tests__/node-metrics.test.ts +144 -0
- package/src/lib/__tests__/operate.test.ts +97 -0
- package/src/lib/__tests__/pipeline-flow.test.ts +79 -0
- package/src/lib/__tests__/rca.test.ts +124 -0
- package/src/lib/__tests__/telemetry.test.ts +91 -0
- package/src/lib/__tests__/topology-graph.test.ts +331 -0
- package/src/lib/__tests__/topology-view.test.ts +154 -0
- package/src/lib/__tests__/waterfall.test.ts +165 -0
- package/src/lib/bc-graph.ts +298 -0
- package/src/lib/dispatch-form.ts +160 -0
- package/src/lib/error-friendly.ts +288 -0
- package/src/lib/home.ts +191 -0
- package/src/lib/inspect.ts +226 -0
- package/src/lib/kind-colors.ts +132 -0
- package/src/lib/live-table.ts +204 -0
- package/src/lib/manifest-health.ts +71 -0
- package/src/lib/manifest.ts +139 -0
- package/src/lib/metadata.ts +52 -0
- package/src/lib/node-metrics.ts +242 -0
- package/src/lib/operate.ts +114 -0
- package/src/lib/pipeline-flow.ts +120 -0
- package/src/lib/rca.ts +193 -0
- package/src/lib/telemetry.ts +155 -0
- package/src/lib/topology-graph.ts +551 -0
- package/src/lib/topology-view.ts +185 -0
- package/src/lib/waterfall.ts +148 -0
- package/src/main.ts +63 -29
- package/src/pages/Errors.vue +272 -0
- package/src/pages/Home.stories.ts +7 -8
- package/src/pages/Home.vue +255 -540
- package/src/pages/Hooks.stories.ts +44 -0
- package/src/pages/Hooks.vue +165 -164
- package/src/pages/Inspect.vue +240 -0
- package/src/pages/Map.vue +187 -0
- package/src/pages/Operate.vue +74 -0
- package/src/pages/Plugins.stories.ts +1 -1
- package/src/pages/Plugins.vue +174 -238
- package/src/pages/Projects.vue +62 -60
- package/src/pages/Streams.vue +344 -0
- package/src/pages/Topology.vue +318 -136
- package/src/pages/Trace.vue +174 -412
- package/src/pages/__tests__/Home.test.ts +109 -54
- package/src/pages/__tests__/Hooks.test.ts +5 -5
- package/src/pages/__tests__/Inspect.test.ts +111 -0
- package/src/pages/__tests__/Plugins.test.ts +85 -35
- package/src/pages/__tests__/Trace.test.ts +117 -0
- package/src/pages/operate/CommandsPanel.vue +186 -0
- package/src/pages/{Dispatch.vue → operate/DispatchPanel.vue} +100 -203
- package/src/pages/operate/EndpointPicker.vue +56 -0
- package/src/pages/operate/RunPanel.vue +316 -0
- package/src/server/__tests__/nwire-read.test.ts +80 -0
- package/src/server/nwire-read.ts +63 -0
- package/vite.config.ts +220 -2
- package/src/lib/__tests__/normalize-cache.test.ts +0 -105
- package/src/lib/cache.ts +0 -312
- package/src/lib/normalize-cache.ts +0 -92
- package/src/pages/Actions.vue +0 -171
- package/src/pages/Apps.vue +0 -177
- package/src/pages/Commands.vue +0 -262
- package/src/pages/Events.vue +0 -210
- package/src/pages/Live.vue +0 -249
- package/src/pages/Overview.vue +0 -161
- package/src/pages/Projections.vue +0 -148
- package/src/pages/Queries.vue +0 -148
- package/src/pages/Run.vue +0 -618
- package/src/pages/Sinks.vue +0 -124
- package/src/pages/TraceNode.vue +0 -164
- package/src/pages/Workflows.vue +0 -184
- package/src/pages/__tests__/Actions.test.ts +0 -98
- package/src/pages/__tests__/Projections.test.ts +0 -90
- 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.
|
|
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.
|
|
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
|
-
|
|
34
|
-
Search,
|
|
35
|
-
Terminal,
|
|
28
|
+
Bug,
|
|
36
29
|
} from "lucide-vue-next";
|
|
37
|
-
import {
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
173
|
-
watch([project,
|
|
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
|
-
|
|
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
|
|
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:
|
|
222
|
+
composition: v
|
|
201
223
|
? {
|
|
202
|
-
apps:
|
|
203
|
-
plugins:
|
|
204
|
-
actions:
|
|
205
|
-
events:
|
|
206
|
-
resolvers:
|
|
207
|
-
workflows:
|
|
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: "/
|
|
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: "/
|
|
252
|
-
{ to: "/
|
|
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: "/
|
|
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="
|
|
373
|
-
<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="
|
|
385
|
+
@click="refetch()"
|
|
378
386
|
>
|
|
379
387
|
<RefreshCw class="w-3 h-3" />
|
|
380
388
|
</button>
|
|
381
389
|
</div>
|
|
382
|
-
<div v-if="
|
|
383
|
-
built {{ new Date(
|
|
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">
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
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>
|