@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
@@ -0,0 +1,133 @@
1
+ import type { Meta, StoryObj } from "@storybook/vue3-vite";
2
+ import { expect, within } from "storybook/test";
3
+ import { createRouter, createMemoryHistory } from "vue-router";
4
+ import ErrorCard from "./ErrorCard.vue";
5
+
6
+ // A throwaway router so the correlation chip's RouterLink resolves in the
7
+ // iframe instead of rendering as an unresolved element.
8
+ const router = createRouter({
9
+ history: createMemoryHistory(),
10
+ routes: [{ path: "/trace", component: { template: "<div/>" } }],
11
+ });
12
+
13
+ const meta: Meta<typeof ErrorCard> = {
14
+ title: "Errors/ErrorCard",
15
+ component: ErrorCard,
16
+ tags: ["autodocs"],
17
+ parameters: { vueRouter: { router } },
18
+ };
19
+ export default meta;
20
+
21
+ type Story = StoryObj<typeof ErrorCard>;
22
+
23
+ const env = (corr: string) => ({ messageId: "m-1", causationId: "m-1", correlationId: corr });
24
+
25
+ export const DeadLettered: Story = {
26
+ args: {
27
+ record: {
28
+ kind: "dlq.recorded",
29
+ action: "orders.ship",
30
+ attempts: 5,
31
+ ts: "2026-01-01T12:30:00.000Z",
32
+ error: { name: "TypeError", message: "Cannot read properties of undefined (reading 'sku')" },
33
+ envelope: env("c-9f8e21"),
34
+ },
35
+ },
36
+ play: async ({ canvasElement }) => {
37
+ const c = within(canvasElement);
38
+ await expect(c.getByTestId("error-title")).toHaveTextContent("Dead-lettered");
39
+ await expect(c.getByTestId("error-card")).toHaveAttribute("data-severity", "critical");
40
+ await expect(c.getByTestId("error-summary")).toHaveTextContent("orders.ship");
41
+ },
42
+ };
43
+
44
+ export const Declined: Story = {
45
+ args: {
46
+ record: {
47
+ kind: "action.failed",
48
+ action: "billing.charge",
49
+ attempt: 1,
50
+ maxAttempts: 1,
51
+ willRetry: false,
52
+ ts: "2026-01-01T12:31:00.000Z",
53
+ error: { name: "PaymentDeclined", message: "card declined", code: 402 },
54
+ envelope: env("c-aa11bb"),
55
+ },
56
+ },
57
+ play: async ({ canvasElement }) => {
58
+ const c = within(canvasElement);
59
+ await expect(c.getByTestId("error-card")).toHaveAttribute("data-category", "declined");
60
+ await expect(c.getByTestId("error-severity")).toHaveTextContent("Warning");
61
+ },
62
+ };
63
+
64
+ export const Retrying: Story = {
65
+ args: {
66
+ record: {
67
+ kind: "action.failed",
68
+ action: "inventory.reserve",
69
+ attempt: 2,
70
+ maxAttempts: 3,
71
+ willRetry: true,
72
+ ts: "2026-01-01T12:32:00.000Z",
73
+ error: { name: "TimeoutError", message: "upstream timed out" },
74
+ envelope: env("c-cc22dd"),
75
+ },
76
+ },
77
+ play: async ({ canvasElement }) => {
78
+ await expect(within(canvasElement).getByTestId("error-retry")).toHaveTextContent(
79
+ "attempt 2 of 3",
80
+ );
81
+ },
82
+ };
83
+
84
+ export const ExternalCall: Story = {
85
+ args: {
86
+ record: {
87
+ kind: "external.call.failed",
88
+ call: "stripe.charge",
89
+ target: "https://api.stripe.com/v1/charges",
90
+ attempt: 1,
91
+ willRetry: false,
92
+ ts: "2026-01-01T12:33:00.000Z",
93
+ error: { name: "FetchError", message: "ECONNREFUSED" },
94
+ },
95
+ },
96
+ };
97
+
98
+ export const Gallery: Story = {
99
+ render: () => ({
100
+ components: { ErrorCard },
101
+ setup: () => ({
102
+ records: [
103
+ {
104
+ kind: "dlq.recorded",
105
+ action: "orders.ship",
106
+ attempts: 5,
107
+ error: { name: "TypeError", message: "boom" },
108
+ },
109
+ {
110
+ kind: "action.failed",
111
+ action: "billing.charge",
112
+ willRetry: false,
113
+ error: { name: "PaymentDeclined", message: "declined", code: 402 },
114
+ },
115
+ {
116
+ kind: "action.failed",
117
+ action: "inventory.reserve",
118
+ attempt: 2,
119
+ maxAttempts: 3,
120
+ willRetry: true,
121
+ error: { name: "TimeoutError", message: "timed out" },
122
+ },
123
+ {
124
+ kind: "projection.failed",
125
+ projection: "orders.summary",
126
+ event: "OrderPlaced",
127
+ error: { name: "RangeError", message: "bad index" },
128
+ },
129
+ ],
130
+ }),
131
+ template: `<div class="flex flex-col gap-2 max-w-xl"><ErrorCard v-for="(r,i) in records" :key="i" :record="r" :show-correlation="false" /></div>`,
132
+ }),
133
+ };
@@ -0,0 +1,153 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `ErrorCard` — one failure, read like a sentence. Severity stripe + icon, a
4
+ * plain-language title and summary (from `explainError`), the failing unit, the
5
+ * retry/dead-letter state, when it happened, and a correlation chip to jump to
6
+ * the trace. Click selects it (the Errors view opens the RcaPanel). Purely
7
+ * presentational — all the humanising lives in `lib/error-friendly`.
8
+ *
9
+ * <ErrorCard :record="failure" :selected="id === sel" @select="sel = id" />
10
+ */
11
+ import { computed } from "vue";
12
+ import { AlertOctagon, AlertTriangle, Info, Clock, Repeat, Skull } from "lucide-vue-next";
13
+ import { explainError, retryNarrative, type Severity } from "@/lib/error-friendly";
14
+ import { kindVariant } from "@/lib/kind-colors";
15
+ import type { TelemetryRecord } from "@/lib/telemetry";
16
+ import KindBadge from "./KindBadge.vue";
17
+
18
+ const props = withDefaults(
19
+ defineProps<{
20
+ record: TelemetryRecord;
21
+ selected?: boolean;
22
+ /** Show the correlation chip (default true). */
23
+ showCorrelation?: boolean;
24
+ }>(),
25
+ { selected: false, showCorrelation: true },
26
+ );
27
+
28
+ defineEmits<{ (e: "select"): void }>();
29
+
30
+ const friendly = computed(() => explainError(props.record));
31
+ const retry = computed(() => retryNarrative(props.record));
32
+
33
+ const correlation = computed(() => props.record.envelope?.correlationId);
34
+ const shortCorr = computed(() =>
35
+ correlation.value ? (correlation.value.split("-")[0] ?? correlation.value.slice(0, 8)) : null,
36
+ );
37
+
38
+ const when = computed(() => {
39
+ const ts = props.record.ts;
40
+ if (!ts) return null;
41
+ const d = new Date(ts);
42
+ return Number.isNaN(d.getTime()) ? null : d.toLocaleTimeString();
43
+ });
44
+
45
+ const SEVERITY_STYLE: Record<
46
+ Severity,
47
+ { stripe: string; icon: string; chip: string; label: string }
48
+ > = {
49
+ critical: {
50
+ stripe: "bg-rose-500",
51
+ icon: "text-rose-400",
52
+ chip: "bg-rose-500/15 text-rose-300 border-rose-500/30",
53
+ label: "Critical",
54
+ },
55
+ high: {
56
+ stripe: "bg-orange-500",
57
+ icon: "text-orange-400",
58
+ chip: "bg-orange-500/15 text-orange-300 border-orange-500/30",
59
+ label: "High",
60
+ },
61
+ warning: {
62
+ stripe: "bg-amber-500",
63
+ icon: "text-amber-400",
64
+ chip: "bg-amber-500/15 text-amber-300 border-amber-500/30",
65
+ label: "Warning",
66
+ },
67
+ info: {
68
+ stripe: "bg-sky-500",
69
+ icon: "text-sky-400",
70
+ chip: "bg-sky-500/15 text-sky-300 border-sky-500/30",
71
+ label: "Info",
72
+ },
73
+ };
74
+
75
+ const style = computed(() => SEVERITY_STYLE[friendly.value.severity]);
76
+
77
+ const icon = computed(() => {
78
+ if (friendly.value.attempt.terminal) return Skull;
79
+ switch (friendly.value.severity) {
80
+ case "critical":
81
+ return AlertOctagon;
82
+ case "high":
83
+ return AlertTriangle;
84
+ case "warning":
85
+ return AlertTriangle;
86
+ default:
87
+ return Info;
88
+ }
89
+ });
90
+ </script>
91
+
92
+ <template>
93
+ <button
94
+ type="button"
95
+ class="group relative w-full text-left rounded-lg border bg-zinc-950 pl-4 pr-3 py-3 transition-colors hover:border-zinc-700"
96
+ :class="selected ? 'border-zinc-600 bg-zinc-900/60' : 'border-zinc-800'"
97
+ data-testid="error-card"
98
+ :data-severity="friendly.severity"
99
+ :data-category="friendly.category"
100
+ @click="$emit('select')"
101
+ >
102
+ <!-- severity stripe -->
103
+ <span class="absolute inset-y-2 left-0 w-1 rounded-full" :class="style.stripe" />
104
+
105
+ <div class="flex items-start gap-2.5">
106
+ <component :is="icon" class="w-4 h-4 mt-0.5 shrink-0" :class="style.icon" />
107
+ <div class="min-w-0 flex-1">
108
+ <div class="flex items-center gap-2">
109
+ <span class="text-sm font-medium text-zinc-100 truncate" data-testid="error-title">
110
+ {{ friendly.title }}
111
+ </span>
112
+ <span
113
+ class="text-[10px] uppercase tracking-wide rounded border px-1.5 py-0.5 shrink-0"
114
+ :class="style.chip"
115
+ data-testid="error-severity"
116
+ >
117
+ {{ style.label }}
118
+ </span>
119
+ </div>
120
+
121
+ <p class="mt-1 text-xs text-zinc-400 leading-relaxed" data-testid="error-summary">
122
+ {{ friendly.summary }}
123
+ </p>
124
+
125
+ <div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-zinc-500">
126
+ <KindBadge :variant="kindVariant(friendly.subject.kind)">{{
127
+ friendly.subject.kind
128
+ }}</KindBadge>
129
+ <span class="font-mono text-zinc-400 truncate max-w-[16rem]">{{
130
+ friendly.subject.name
131
+ }}</span>
132
+ <span v-if="retry" class="inline-flex items-center gap-1" data-testid="error-retry">
133
+ <Repeat class="w-3 h-3" />
134
+ {{ retry }}
135
+ </span>
136
+ <span v-if="when" class="inline-flex items-center gap-1">
137
+ <Clock class="w-3 h-3" />
138
+ {{ when }}
139
+ </span>
140
+ <RouterLink
141
+ v-if="showCorrelation && shortCorr && correlation"
142
+ :to="{ path: '/trace', query: { correlationId: correlation } }"
143
+ class="ml-auto font-mono text-zinc-500 hover:text-orange-400 transition-colors"
144
+ data-testid="error-correlation"
145
+ @click.stop
146
+ >
147
+ corr {{ shortCorr }} →
148
+ </RouterLink>
149
+ </div>
150
+ </div>
151
+ </div>
152
+ </button>
153
+ </template>
@@ -0,0 +1,48 @@
1
+ import type { Meta, StoryObj } from "@storybook/vue3-vite";
2
+ import GraphCanvas from "./GraphCanvas.vue";
3
+ import type { BcGraph } from "@/lib/bc-graph";
4
+
5
+ const graph: BcGraph = {
6
+ bcs: [
7
+ {
8
+ name: "orders",
9
+ rows: [
10
+ { id: "action:orders.place", kind: "action", name: "orders.place", public: true },
11
+ { id: "event:orders.placed", kind: "event", name: "orders.placed", public: true },
12
+ { id: "query:orders.list", kind: "query", name: "orders.list" },
13
+ ],
14
+ },
15
+ {
16
+ name: "billing",
17
+ rows: [
18
+ { id: "action:billing.charge", kind: "action", name: "billing.charge" },
19
+ { id: "event:billing.charged", kind: "event", name: "billing.charged" },
20
+ ],
21
+ },
22
+ {
23
+ name: "shipping",
24
+ rows: [{ id: "workflow:shipping.dispatch", kind: "workflow", name: "shipping.dispatch" }],
25
+ },
26
+ ],
27
+ edges: [
28
+ { from: "orders", to: "billing", label: "delivers" },
29
+ { from: "billing", to: "shipping", label: "delivers" },
30
+ ],
31
+ };
32
+
33
+ const meta: Meta<typeof GraphCanvas> = {
34
+ title: "Map/GraphCanvas",
35
+ component: GraphCanvas,
36
+ tags: ["autodocs"],
37
+ };
38
+ export default meta;
39
+
40
+ type Story = StoryObj<typeof GraphCanvas>;
41
+
42
+ export const ThreeContexts: Story = {
43
+ render: () => ({
44
+ components: { GraphCanvas },
45
+ setup: () => ({ graph }),
46
+ template: `<div class="h-[560px] w-full bg-zinc-950"><GraphCanvas :graph="graph" /></div>`,
47
+ }),
48
+ };
@@ -0,0 +1,88 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `GraphCanvas` — the Map's hero surface: system-area (bounded-context) cards
4
+ * laid out on a pan/zoom canvas, connected by inter-area flow edges. Thin
5
+ * wrapper over VueFlow — layout is the pure `layoutBcGraph` (unit-tested), each
6
+ * node renders a `BcCard`, and row selection bubbles up as `select`. Per-area
7
+ * live metrics (when given) overlay each card and animate the flows between
8
+ * active areas. Node positions come from the manifest only, so a metrics tick
9
+ * updates cards in place and never moves a card.
10
+ */
11
+ import { computed } from "vue";
12
+ import { VueFlow, type Node, type Edge, MarkerType } from "@vue-flow/core";
13
+ import { Background } from "@vue-flow/background";
14
+ import { Controls } from "@vue-flow/controls";
15
+ import { MiniMap } from "@vue-flow/minimap";
16
+ import type { BcGraph, BcNode, BcRow } from "@/lib/bc-graph";
17
+ import { layoutBcGraph } from "@/lib/bc-graph";
18
+ import type { AppMetrics } from "@/lib/node-metrics";
19
+ import BcCard from "./BcCard.vue";
20
+ import "@vue-flow/core/dist/style.css";
21
+ import "@vue-flow/core/dist/theme-default.css";
22
+ import "@vue-flow/controls/dist/style.css";
23
+ import "@vue-flow/minimap/dist/style.css";
24
+
25
+ const props = defineProps<{
26
+ graph: BcGraph;
27
+ selectedId?: string | null;
28
+ /** app name → live metrics; empty before any run. */
29
+ metrics?: ReadonlyMap<string, AppMetrics>;
30
+ }>();
31
+
32
+ const emit = defineEmits<{ (e: "select", row: BcRow): void }>();
33
+
34
+ const CARD_W = 280;
35
+
36
+ const nodes = computed<Node[]>(() =>
37
+ layoutBcGraph(props.graph, { width: CARD_W, columns: 3, gapX: 64, gapY: 64 }).map((n) => ({
38
+ id: `bc:${n.name}`,
39
+ type: "bc",
40
+ position: { x: n.x, y: n.y },
41
+ data: { bc: { name: n.name, rows: n.rows } as BcNode },
42
+ style: { width: `${n.width}px` },
43
+ })),
44
+ );
45
+
46
+ /** An area is active when it saw any traffic — drives edge animation. */
47
+ function active(app: string): boolean {
48
+ return (props.metrics?.get(app)?.total ?? 0) > 0;
49
+ }
50
+
51
+ const edges = computed<Edge[]>(() =>
52
+ props.graph.edges.map((e) => {
53
+ const live = active(e.from) || active(e.to);
54
+ return {
55
+ id: `${e.from}->${e.to}`,
56
+ source: `bc:${e.from}`,
57
+ target: `bc:${e.to}`,
58
+ label: e.label,
59
+ type: "smoothstep",
60
+ animated: live,
61
+ style: { stroke: "#a78bfa", strokeWidth: live ? 2.5 : 2, opacity: live ? 1 : 0.75 },
62
+ labelStyle: { fontSize: "10px", fill: "#a1a1aa" },
63
+ labelBgStyle: { fill: "#18181b" },
64
+ labelBgPadding: [4, 2] as [number, number],
65
+ labelBgBorderRadius: 4,
66
+ markerEnd: { type: MarkerType.ArrowClosed, color: "#a78bfa" },
67
+ } satisfies Edge;
68
+ }),
69
+ );
70
+ </script>
71
+
72
+ <template>
73
+ <div class="h-full w-full" data-testid="graph-canvas">
74
+ <VueFlow :nodes="nodes" :edges="edges" :fit-view-on-init="true" :min-zoom="0.2" :max-zoom="1.5">
75
+ <template #node-bc="{ data }">
76
+ <BcCard
77
+ :bc="data.bc"
78
+ :selected-id="selectedId"
79
+ :metrics="metrics?.get(data.bc.name)"
80
+ @select="emit('select', $event)"
81
+ />
82
+ </template>
83
+ <Background :pattern-color="'#27272a'" :gap="22" :size="1.4" />
84
+ <MiniMap pannable zoomable class="!bg-zinc-950/80" />
85
+ <Controls />
86
+ </VueFlow>
87
+ </div>
88
+ </template>
@@ -0,0 +1,32 @@
1
+ import type { Meta, StoryObj } from "@storybook/vue3-vite";
2
+ import KpiTile from "./KpiTile.vue";
3
+ import { kindColor } from "@/lib/kind-colors";
4
+
5
+ const meta: Meta<typeof KpiTile> = {
6
+ title: "Map/KpiTile",
7
+ component: KpiTile,
8
+ tags: ["autodocs"],
9
+ };
10
+ export default meta;
11
+
12
+ type Story = StoryObj<typeof KpiTile>;
13
+
14
+ export const Default: Story = {
15
+ args: { label: "Actions", value: 12, sub: "3 public" },
16
+ };
17
+
18
+ export const Strip: Story = {
19
+ render: () => ({
20
+ components: { KpiTile },
21
+ setup: () => ({ kindColor }),
22
+ template: `
23
+ <div class="flex gap-3 bg-zinc-900 p-4">
24
+ <KpiTile label="Apps" :value="2" />
25
+ <KpiTile label="Actions" :value="12" :accent="kindColor('action')" />
26
+ <KpiTile label="Events" :value="8" :accent="kindColor('event')" />
27
+ <KpiTile label="Queries" :value="5" :accent="kindColor('query')" />
28
+ <KpiTile label="Workflows" :value="1" :accent="kindColor('workflow')" />
29
+ </div>
30
+ `,
31
+ }),
32
+ };
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * `KpiTile` — one stat in the Map's header strip: a big value, a label, an
4
+ * optional sub-line, and an optional accent colour for the value (used to
5
+ * tint a count by the kind it represents). Icon goes in the default slot.
6
+ *
7
+ * <KpiTile label="Actions" :value="12" :accent="kindColor('action')" />
8
+ */
9
+ withDefaults(
10
+ defineProps<{
11
+ label: string;
12
+ value: string | number;
13
+ sub?: string;
14
+ /** Hex colour for the value text — defaults to the foreground. */
15
+ accent?: string;
16
+ }>(),
17
+ { accent: undefined },
18
+ );
19
+ </script>
20
+
21
+ <template>
22
+ <div
23
+ class="rounded-lg border border-zinc-800 bg-zinc-950 px-4 py-3 flex flex-col gap-0.5 min-w-[112px]"
24
+ data-testid="kpi-tile"
25
+ >
26
+ <div class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-zinc-500">
27
+ <slot name="icon" />
28
+ {{ label }}
29
+ </div>
30
+ <div
31
+ class="text-2xl font-semibold tabular-nums leading-tight"
32
+ :style="accent ? { color: accent } : undefined"
33
+ data-testid="kpi-value"
34
+ >
35
+ {{ value }}
36
+ </div>
37
+ <div v-if="sub" class="text-[10px] text-zinc-600">{{ sub }}</div>
38
+ </div>
39
+ </template>
@@ -0,0 +1,78 @@
1
+ import type { Meta, StoryObj } from "@storybook/vue3-vite";
2
+ import { expect, userEvent, within } from "storybook/test";
3
+ import LiveTable from "./LiveTable.vue";
4
+ import type { TelemetryRecord } from "@/lib/telemetry";
5
+
6
+ const t = (s: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, s)).toISOString();
7
+ const env = (messageId: string, correlationId: string) => ({
8
+ messageId,
9
+ correlationId,
10
+ causationId: messageId,
11
+ });
12
+
13
+ const rows: TelemetryRecord[] = [
14
+ { kind: "action.dispatched", action: "orders.place", ts: t(0), envelope: env("m1", "c-1a2b") },
15
+ {
16
+ kind: "action.completed",
17
+ action: "orders.place",
18
+ durationMs: 36,
19
+ ts: t(1),
20
+ envelope: env("m2", "c-1a2b"),
21
+ },
22
+ {
23
+ kind: "event.published",
24
+ event: { name: "orders.placed" },
25
+ ts: t(1),
26
+ appName: "orders",
27
+ envelope: env("m3", "c-1a2b"),
28
+ },
29
+ {
30
+ kind: "query.executed",
31
+ query: "orders.by-id",
32
+ durationMs: 4,
33
+ ts: t(2),
34
+ envelope: env("m4", "c-9z8y"),
35
+ },
36
+ {
37
+ kind: "action.failed",
38
+ action: "billing.charge",
39
+ error: { name: "Error", message: "card declined" },
40
+ ts: t(3),
41
+ envelope: env("m5", "c-9z8y"),
42
+ },
43
+ ];
44
+
45
+ const meta: Meta<typeof LiveTable> = {
46
+ title: "Streams/LiveTable",
47
+ component: LiveTable,
48
+ tags: ["autodocs"],
49
+ render: (args) => ({
50
+ components: { LiveTable },
51
+ setup: () => ({ args }),
52
+ template: `<div class="bg-zinc-950 h-[320px] w-[760px]"><LiveTable v-bind="args" /></div>`,
53
+ }),
54
+ };
55
+ export default meta;
56
+
57
+ type Story = StoryObj<typeof LiveTable>;
58
+
59
+ export const Default: Story = {
60
+ args: { rows },
61
+ play: async ({ canvasElement }) => {
62
+ const c = within(canvasElement);
63
+ await expect(c.getAllByTestId("live-row")).toHaveLength(5);
64
+ // Default sort = time desc → the failed billing charge (latest) leads.
65
+ await expect(c.getAllByTestId("live-row")[0]!).toHaveTextContent("billing.charge");
66
+ // Clicking a header re-sorts by subject.
67
+ await userEvent.click(c.getByTestId("col-subject"));
68
+ await expect(c.getAllByTestId("live-row")[0]!).toHaveTextContent("billing.charge");
69
+ },
70
+ };
71
+
72
+ export const Selected: Story = {
73
+ args: { rows, selectedIndex: 1 },
74
+ };
75
+
76
+ export const Empty: Story = {
77
+ args: { rows: [] },
78
+ };