@nwire/studio 0.12.1 → 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.
- 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
|
@@ -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
|
+
};
|