@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.
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,240 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Inspect — the unified per-kind browser. One surface over the deep manifest
4
+ * (`useManifest`, native — no adapter): a kind switcher (what's present +
5
+ * counts) → a filterable node list → the selected node's full detail
6
+ * (`NodeCard`: intent · schema · emits · source) plus its graph relationships.
7
+ *
8
+ * /inspect?kind=action&id=action:moderation.submit-post
9
+ *
10
+ * Replaces the per-kind pages (Actions/Events/Queries/…); those routes
11
+ * redirect here. Resilient: loading / error / empty on every panel.
12
+ */
13
+ import { computed, ref, watch, onMounted } from "vue";
14
+ import { useRoute, useRouter } from "vue-router";
15
+ import { Search, RefreshCw, Send, ArrowUpRight } from "lucide-vue-next";
16
+ import { useManifest } from "@/composables/useManifest";
17
+ import {
18
+ inspectKinds,
19
+ nodesOfKind,
20
+ matchesNode,
21
+ nodeToDetail,
22
+ relationships,
23
+ kindLabel,
24
+ isDispatchable,
25
+ } from "@/lib/inspect";
26
+ import { kindColor } from "@/lib/kind-colors";
27
+ import {
28
+ NodeCard,
29
+ StatusBadge,
30
+ EmptyState,
31
+ SourceDrawer,
32
+ KindBadge,
33
+ FilterInput,
34
+ } from "@/components";
35
+
36
+ const route = useRoute();
37
+ const router = useRouter();
38
+ const { manifest, view, isLoading, isFetching, isError, error, refetch } = useManifest();
39
+
40
+ const kinds = computed(() => inspectKinds(view.value));
41
+
42
+ // Active kind: from the URL, else the first present kind.
43
+ const activeKind = ref<string>((route.query.kind as string) ?? "");
44
+ watch(
45
+ [kinds, () => route.query.kind],
46
+ ([list, urlKind]) => {
47
+ const wanted = (urlKind as string) ?? activeKind.value;
48
+ if (wanted && list.some((k) => k.kind === wanted)) activeKind.value = wanted;
49
+ else if (!list.some((k) => k.kind === activeKind.value)) activeKind.value = list[0]?.kind ?? "";
50
+ },
51
+ { immediate: true },
52
+ );
53
+
54
+ const filter = ref("");
55
+ const nodes = computed(() => nodesOfKind(view.value, activeKind.value));
56
+ const filtered = computed(() => nodes.value.filter((n) => matchesNode(n, filter.value)));
57
+
58
+ const selectedId = ref<string | null>((route.query.id as string) ?? null);
59
+
60
+ // Keep a valid selection as the kind/filter changes.
61
+ watch([activeKind, filtered], () => {
62
+ if (!selectedId.value || !filtered.value.some((n) => n.id === selectedId.value)) {
63
+ selectedId.value = filtered.value[0]?.id ?? null;
64
+ }
65
+ });
66
+ onMounted(() => {
67
+ if (!selectedId.value) selectedId.value = filtered.value[0]?.id ?? null;
68
+ });
69
+
70
+ // Mirror kind + selection into the URL (shareable deep-links).
71
+ watch([activeKind, selectedId], ([kind, id]) => {
72
+ void router.replace({
73
+ query: { ...route.query, kind: kind || undefined, id: id || undefined },
74
+ });
75
+ });
76
+
77
+ const selectedNode = computed(() => filtered.value.find((n) => n.id === selectedId.value) ?? null);
78
+ const detail = computed(() => nodeToDetail(view.value, manifest.value ?? null, selectedNode.value));
79
+ const relations = computed(() => relationships(view.value, selectedId.value));
80
+
81
+ function pickKind(kind: string): void {
82
+ activeKind.value = kind;
83
+ filter.value = "";
84
+ }
85
+ function goRelated(id: string): void {
86
+ const kind = id.slice(0, id.indexOf(":"));
87
+ if (kinds.value.some((k) => k.kind === kind)) {
88
+ activeKind.value = kind;
89
+ selectedId.value = id;
90
+ }
91
+ }
92
+ function dispatchSelected(): void {
93
+ if (detail.value) void router.push({ path: "/dispatch", query: { name: detail.value.name } });
94
+ }
95
+
96
+ const sourcePreview = ref<{ file: string; line?: number; column?: number } | null>(null);
97
+ const health = computed(() => (isFetching.value ? "live" : "ok") as "live" | "ok");
98
+ </script>
99
+
100
+ <template>
101
+ <div class="h-full flex flex-col" data-testid="inspect-page">
102
+ <div class="border-b border-zinc-800 px-4 py-3 flex items-center gap-3">
103
+ <h1 class="font-semibold text-lg">Inspect</h1>
104
+ <StatusBadge :status="health" :label="isFetching ? 'refreshing' : 'manifest'" />
105
+ <button
106
+ class="ml-auto p-1.5 rounded hover:bg-zinc-800 text-zinc-400 hover:text-zinc-100"
107
+ title="Refetch manifest"
108
+ data-testid="inspect-refetch"
109
+ @click="() => refetch()"
110
+ >
111
+ <RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isFetching }" />
112
+ </button>
113
+ </div>
114
+
115
+ <div v-if="isError" class="p-6">
116
+ <EmptyState
117
+ title="Couldn't load the manifest"
118
+ :hint="error ?? 'Run `nwire cache` to build .nwire/manifest.json.'"
119
+ />
120
+ </div>
121
+
122
+ <div v-else class="flex-1 flex min-h-0">
123
+ <!-- Kind switcher -->
124
+ <nav
125
+ class="w-44 shrink-0 border-r border-zinc-800 overflow-y-auto py-2"
126
+ data-testid="kind-rail"
127
+ >
128
+ <button
129
+ v-for="k in kinds"
130
+ :key="k.kind"
131
+ class="w-full flex items-center gap-2 px-3 py-1.5 text-sm transition-colors"
132
+ :class="
133
+ k.kind === activeKind
134
+ ? 'bg-zinc-900 text-zinc-100'
135
+ : 'text-zinc-400 hover:bg-zinc-900/50 hover:text-zinc-200'
136
+ "
137
+ :data-testid="`kind-${k.kind}`"
138
+ @click="pickKind(k.kind)"
139
+ >
140
+ <span class="w-2 h-2 rounded-full shrink-0" :style="{ background: kindColor(k.kind) }" />
141
+ <span class="truncate">{{ k.label }}</span>
142
+ <span class="ml-auto text-[10px] text-zinc-600 tabular-nums">{{ k.count }}</span>
143
+ </button>
144
+ <div v-if="!kinds.length && !isLoading" class="px-3 py-4 text-xs text-zinc-600">
145
+ Nothing in the manifest yet.
146
+ </div>
147
+ </nav>
148
+
149
+ <!-- Node list -->
150
+ <div class="w-72 shrink-0 border-r border-zinc-800 flex flex-col min-h-0">
151
+ <div class="p-2 border-b border-zinc-800">
152
+ <FilterInput
153
+ v-model="filter"
154
+ :placeholder="`filter ${kindLabel(activeKind).toLowerCase()}…`"
155
+ />
156
+ </div>
157
+ <div class="flex-1 overflow-y-auto">
158
+ <button
159
+ v-for="n in filtered"
160
+ :key="n.id"
161
+ class="w-full text-left px-3 py-2 border-b border-zinc-900 transition-colors"
162
+ :class="n.id === selectedId ? 'bg-zinc-900/80' : 'hover:bg-zinc-900/40'"
163
+ data-testid="inspect-row"
164
+ @click="selectedId = n.id"
165
+ >
166
+ <div class="font-mono text-[13px] text-zinc-200 truncate">{{ n.name }}</div>
167
+ <div v-if="n.intent?.description" class="text-[11px] text-zinc-500 truncate">
168
+ {{ n.intent.description }}
169
+ </div>
170
+ </button>
171
+ <div v-if="!filtered.length" class="px-3 py-4 text-xs text-zinc-600">
172
+ {{ nodes.length ? "No matches." : "None of this kind." }}
173
+ </div>
174
+ </div>
175
+ </div>
176
+
177
+ <!-- Detail + relationships -->
178
+ <div class="flex-1 min-w-0 flex flex-col min-h-0" data-testid="inspect-detail">
179
+ <div v-if="detail" class="flex-1 flex flex-col min-h-0">
180
+ <div
181
+ v-if="isDispatchable(detail.kind)"
182
+ class="px-4 py-2 border-b border-zinc-800 flex items-center gap-2"
183
+ >
184
+ <button
185
+ class="inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded bg-zinc-900 border border-zinc-800 text-zinc-300 hover:border-zinc-700"
186
+ data-testid="dispatch-link"
187
+ @click="dispatchSelected"
188
+ >
189
+ <Send class="w-3 h-3" /> Dispatch
190
+ </button>
191
+ </div>
192
+ <div class="flex-1 min-h-0 overflow-hidden flex flex-col">
193
+ <div class="flex-1 min-h-0">
194
+ <NodeCard
195
+ :detail="detail"
196
+ @open-source="sourcePreview = $event"
197
+ @select-event="goRelated(`event:${$event}`)"
198
+ />
199
+ </div>
200
+ <!-- Relationships -->
201
+ <div
202
+ v-if="relations.length"
203
+ class="border-t border-zinc-800 p-4 space-y-3 max-h-64 overflow-y-auto"
204
+ data-testid="relationships"
205
+ >
206
+ <h3 class="text-xs uppercase tracking-wide text-zinc-500">Relationships</h3>
207
+ <div v-for="g in relations" :key="g.label" class="space-y-1">
208
+ <div class="text-[10px] uppercase tracking-wide text-zinc-600">{{ g.label }}</div>
209
+ <div class="flex flex-wrap gap-1.5">
210
+ <button
211
+ v-for="item in g.items"
212
+ :key="item.id"
213
+ class="inline-flex items-center gap-1 font-mono text-[11px] px-2 py-0.5 rounded border border-zinc-800 bg-zinc-900 text-zinc-300 hover:border-zinc-700"
214
+ :data-testid="`relation-${item.id}`"
215
+ @click="goRelated(item.id)"
216
+ >
217
+ <span
218
+ class="w-1.5 h-1.5 rounded-full"
219
+ :style="{ background: kindColor(item.kind) }"
220
+ />
221
+ {{ item.name }}
222
+ <ArrowUpRight class="w-3 h-3 text-zinc-600" />
223
+ </button>
224
+ </div>
225
+ </div>
226
+ </div>
227
+ </div>
228
+ </div>
229
+ <EmptyState
230
+ v-else
231
+ :icon="Search"
232
+ title="Select something to inspect"
233
+ hint="Pick a kind on the left, then a node — its intent, schema, source, and wiring show here."
234
+ />
235
+ </div>
236
+ </div>
237
+
238
+ <SourceDrawer :source="sourcePreview" @close="sourcePreview = null" />
239
+ </div>
240
+ </template>
@@ -0,0 +1,187 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Map — the hero view. Reads the deep manifest natively (`useManifest`) and
4
+ * renders the system as bounded-context cards connected by inter-BC flows
5
+ * (`GraphCanvas`), a KPI strip across the top, and a `NodeCard` detail panel
6
+ * that opens when you select a primitive. Structure only — behaviour lives in
7
+ * Trace/Streams. Resilient: loading / error / empty states, tolerant of a
8
+ * flat or deep manifest (see `buildBcGraph`).
9
+ */
10
+ import { computed, ref } from "vue";
11
+ import { RefreshCw, Boxes } from "lucide-vue-next";
12
+ import { useManifest } from "@/composables/useManifest";
13
+ import { useProject } from "@/composables/useProject";
14
+ import { useTelemetry } from "@/composables/useTelemetry";
15
+ import { buildBcGraph, nodeDetail, type BcRow, type BcNodeDetail } from "@/lib/bc-graph";
16
+ import { kindColor, KIND_LEGEND } from "@/lib/kind-colors";
17
+ import { rollupMetrics, fmtCount } from "@/lib/node-metrics";
18
+ import {
19
+ KpiTile,
20
+ StatusBadge,
21
+ GraphCanvas,
22
+ NodeCard,
23
+ EmptyState,
24
+ SourceDrawer,
25
+ } from "@/components";
26
+
27
+ const { activeCwd } = useProject();
28
+ const { manifest, view, isLoading, isFetching, isError, error, refetch } = useManifest(activeCwd);
29
+
30
+ // Live behaviour feed — overlaid as per-area metrics + flow animation.
31
+ const { records } = useTelemetry(activeCwd);
32
+ const metrics = computed(() => rollupMetrics(records.value));
33
+ const hasRun = computed(() => metrics.value.total > 0);
34
+
35
+ const graph = computed(() => buildBcGraph(manifest.value ?? null));
36
+
37
+ /** KPI strip — totals straight off the manifest's flat arrays. */
38
+ function count(key: string): number {
39
+ const arr = (manifest.value as unknown as Record<string, unknown>)?.[key];
40
+ return Array.isArray(arr) ? arr.length : 0;
41
+ }
42
+ /** Live totals across every area — the system's current pulse. */
43
+ const liveTotals = computed(() => {
44
+ let dispatches = 0;
45
+ let errors = 0;
46
+ for (const m of metrics.value.byApp.values()) {
47
+ dispatches += m.dispatches;
48
+ errors += m.errors;
49
+ }
50
+ return { dispatches, errors };
51
+ });
52
+
53
+ const kpis = computed(() => [
54
+ { label: "Areas", value: graph.value.bcs.length, kind: "app" },
55
+ { label: "Actions", value: count("actions"), kind: "action" },
56
+ { label: "Events", value: count("events"), kind: "event" },
57
+ { label: "Queries", value: count("queries"), kind: "query" },
58
+ { label: "Projections", value: count("projections"), kind: "projection" },
59
+ { label: "Flows", value: graph.value.edges.length, kind: "listener" },
60
+ {
61
+ label: "Dispatches",
62
+ value: fmtCount(liveTotals.value.dispatches),
63
+ kind: "action",
64
+ live: true,
65
+ },
66
+ { label: "Errors", value: fmtCount(liveTotals.value.errors), kind: "workflow", live: true },
67
+ ]);
68
+
69
+ const selectedId = ref<string | null>(null);
70
+ const detail = computed<BcNodeDetail | null>(() =>
71
+ nodeDetail(manifest.value ?? null, selectedId.value),
72
+ );
73
+
74
+ function onSelect(row: BcRow): void {
75
+ selectedId.value = selectedId.value === row.id ? null : row.id;
76
+ }
77
+
78
+ const sourcePreview = ref<{ file: string; line?: number; column?: number } | null>(null);
79
+
80
+ const isEmpty = computed(() => !isLoading.value && graph.value.bcs.length === 0);
81
+ </script>
82
+
83
+ <template>
84
+ <div class="h-full flex flex-col" data-testid="map-page">
85
+ <!-- header + KPI strip -->
86
+ <div class="border-b border-zinc-800 px-6 py-3">
87
+ <div class="flex items-center justify-between">
88
+ <div>
89
+ <h1 class="text-lg font-semibold tracking-tight">Map</h1>
90
+ <p class="text-xs text-zinc-500">Bounded contexts and the flows between them</p>
91
+ </div>
92
+ <div class="flex items-center gap-3">
93
+ <StatusBadge
94
+ :status="hasRun ? 'live' : 'idle'"
95
+ :pulse="hasRun"
96
+ :label="hasRun ? 'Live traffic' : 'Idle'"
97
+ />
98
+ <button
99
+ class="text-zinc-400 hover:text-zinc-100 transition-colors"
100
+ title="Refresh manifest"
101
+ data-testid="map-refresh"
102
+ @click="refetch()"
103
+ >
104
+ <RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isFetching }" />
105
+ </button>
106
+ </div>
107
+ </div>
108
+
109
+ <div v-if="view?.versionMismatch" class="mt-2 text-[11px] text-amber-300">
110
+ Manifest version differs from this Studio build — some fields may be missing.
111
+ </div>
112
+
113
+ <div class="mt-3 flex flex-wrap gap-3">
114
+ <KpiTile
115
+ v-for="k in kpis"
116
+ :key="k.label"
117
+ :label="k.label"
118
+ :value="k.value"
119
+ :accent="kindColor(k.kind)"
120
+ />
121
+ </div>
122
+ </div>
123
+
124
+ <!-- canvas + detail -->
125
+ <div class="flex-1 flex min-h-0">
126
+ <div class="flex-1 relative min-w-0">
127
+ <div
128
+ v-if="isError"
129
+ class="absolute inset-0 flex items-center justify-center p-8 text-center"
130
+ data-testid="map-error"
131
+ >
132
+ <div>
133
+ <p class="text-sm text-rose-300">{{ error }}</p>
134
+ <button
135
+ class="mt-3 text-xs text-zinc-400 hover:text-zinc-100 underline"
136
+ @click="refetch()"
137
+ >
138
+ Retry
139
+ </button>
140
+ </div>
141
+ </div>
142
+ <div v-else-if="isLoading" class="p-6 text-zinc-400 text-sm">Loading manifest…</div>
143
+ <EmptyState
144
+ v-else-if="isEmpty"
145
+ :icon="Boxes"
146
+ title="No bounded contexts yet"
147
+ hint="Once your app registers handlers, its bounded contexts and their flows appear here."
148
+ />
149
+ <template v-else>
150
+ <GraphCanvas
151
+ :graph="graph"
152
+ :selected-id="selectedId"
153
+ :metrics="metrics.byApp"
154
+ @select="onSelect"
155
+ />
156
+ <!-- legend -->
157
+ <div
158
+ class="absolute bottom-3 left-3 flex flex-wrap gap-x-3 gap-y-1 rounded-md border border-zinc-800 bg-zinc-950/90 px-3 py-2"
159
+ >
160
+ <span
161
+ v-for="k in KIND_LEGEND"
162
+ :key="k"
163
+ class="inline-flex items-center gap-1 text-[10px] text-zinc-400"
164
+ >
165
+ <span class="h-1.5 w-1.5 rounded-full" :style="{ background: kindColor(k) }" />
166
+ {{ k }}
167
+ </span>
168
+ </div>
169
+ </template>
170
+ </div>
171
+
172
+ <aside
173
+ v-if="selectedId"
174
+ class="w-96 shrink-0 border-l border-zinc-800 bg-zinc-950"
175
+ data-testid="map-detail"
176
+ >
177
+ <NodeCard
178
+ :detail="detail"
179
+ @open-source="sourcePreview = $event"
180
+ @select-event="selectedId = `event:${$event}`"
181
+ />
182
+ </aside>
183
+ </div>
184
+
185
+ <SourceDrawer :source="sourcePreview" @close="sourcePreview = null" />
186
+ </div>
187
+ </template>
@@ -0,0 +1,74 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Operate — the one place you drive the system: dispatch a handler, run a
4
+ * topology/script, fire a CLI command. Three modes behind `?mode=`; each is a
5
+ * focused panel. Replaces the former Dispatch / Run / Commands pages.
6
+ */
7
+ import { computed } from "vue";
8
+ import { useRoute, useRouter } from "vue-router";
9
+ import { Send, Play, Terminal, Wrench } from "lucide-vue-next";
10
+ import DispatchPanel from "./operate/DispatchPanel.vue";
11
+ import RunPanel from "./operate/RunPanel.vue";
12
+ import CommandsPanel from "./operate/CommandsPanel.vue";
13
+ import EndpointPicker from "./operate/EndpointPicker.vue";
14
+
15
+ type Mode = "dispatch" | "run" | "commands";
16
+
17
+ const route = useRoute();
18
+ const router = useRouter();
19
+
20
+ const tabs: { mode: Mode; label: string; icon: typeof Send }[] = [
21
+ { mode: "dispatch", label: "Dispatch", icon: Send },
22
+ { mode: "run", label: "Run", icon: Play },
23
+ { mode: "commands", label: "Commands", icon: Terminal },
24
+ ];
25
+
26
+ const mode = computed<Mode>(() => {
27
+ const m = route.query.mode;
28
+ return m === "run" || m === "commands" ? m : "dispatch";
29
+ });
30
+
31
+ function select(m: Mode): void {
32
+ if (m === mode.value) return;
33
+ // Drop mode-specific query (e.g. a command `?name=`) when switching tabs.
34
+ void router.replace({ query: { mode: m } });
35
+ }
36
+ </script>
37
+
38
+ <template>
39
+ <div class="h-full flex flex-col" data-testid="operate-page">
40
+ <div class="border-b border-zinc-800 px-4 py-3 flex items-center gap-3">
41
+ <Wrench class="w-5 h-5 text-emerald-400 shrink-0" />
42
+ <h1 class="font-semibold text-lg">Operate</h1>
43
+ <div class="ml-2 flex items-center gap-1" role="tablist">
44
+ <button
45
+ v-for="t in tabs"
46
+ :key="t.mode"
47
+ class="flex items-center gap-1.5 px-3 py-1.5 rounded text-sm transition-colors"
48
+ :class="
49
+ mode === t.mode
50
+ ? 'bg-zinc-800 text-zinc-100'
51
+ : 'text-zinc-400 hover:text-zinc-100 hover:bg-zinc-900'
52
+ "
53
+ role="tab"
54
+ :aria-selected="mode === t.mode"
55
+ :data-testid="`operate-tab-${t.mode}`"
56
+ @click="select(t.mode)"
57
+ >
58
+ <component :is="t.icon" class="w-3.5 h-3.5" />
59
+ {{ t.label }}
60
+ </button>
61
+ </div>
62
+ <!-- Endpoint picker — appears only when the dev host exposes multiple endpoints. -->
63
+ <div class="ml-auto">
64
+ <EndpointPicker />
65
+ </div>
66
+ </div>
67
+
68
+ <div class="flex-1 min-h-0">
69
+ <DispatchPanel v-if="mode === 'dispatch'" />
70
+ <RunPanel v-else-if="mode === 'run'" />
71
+ <CommandsPanel v-else />
72
+ </div>
73
+ </div>
74
+ </template>
@@ -3,7 +3,7 @@ import { createRouter, createMemoryHistory } from "vue-router";
3
3
  import Plugins from "./Plugins.vue";
4
4
 
5
5
  /**
6
- * Storybook for the Plugins page. The manifest fetch + SSE will fail
6
+ * Storybook for the Plugins page. The manifest fetch + telemetry stream fail
7
7
  * inside Storybook, so the page degrades to its empty / no-live state —
8
8
  * which is exactly the surface we want to document.
9
9
  */