@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
@@ -1,164 +1,346 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
- * Topology — apps + plugins + event flow.
3
+ * Topology — the app's live service map, not a single rectangle.
4
4
  *
5
- * Each app is a node. Its installed plugins are listed inside the node;
6
- * its outbound sinks are listed below. Edges are event-flow edges from
7
- * the `graph.events` cache: producer-appconsumer-app per event,
8
- * coloured for cross-app vs in-app.
5
+ * The Wiring tab draws the real pipeline left→right: sources (routes / webhooks
6
+ * / crons) handlers (actions / queries) effects (events / external calls /
7
+ * workflows / actors / projections) sinks. Each node is a rich `ServiceNode`
8
+ * card (kind icon, title, status dot, live metrics), accent-coloured by kind and
9
+ * positioned by the pure layered layout in `topology-graph`. Telemetry overlays
10
+ * per-node throughput / errors / p50 and animates the edges that carry traffic;
11
+ * before any run the metrics show neutral placeholders. Clicking a node opens
12
+ * the shared `NodeCard` detail. The Endpoints tab is the "what's exposed" view.
13
+ *
14
+ * Stability: node positions come from the manifest only (deterministic layout),
15
+ * so a telemetry refresh updates counts in place and never moves a node.
9
16
  */
10
17
  import { computed, ref } from "vue";
11
- import { VueFlow, type Node, type Edge, MarkerType } from "@vue-flow/core";
18
+ import { VueFlow, type Node, type Edge, MarkerType, Position } from "@vue-flow/core";
12
19
  import { Background } from "@vue-flow/background";
13
20
  import { Controls } from "@vue-flow/controls";
14
- import { useCache } from "@/lib/cache";
21
+ import { MiniMap } from "@vue-flow/minimap";
22
+ import { Network } from "lucide-vue-next";
23
+ import { useManifest } from "@/composables/useManifest";
24
+ import { useProject } from "@/composables/useProject";
25
+ import { useTelemetry } from "@/composables/useTelemetry";
26
+ import {
27
+ buildWiringGraph,
28
+ layoutWiringGraph,
29
+ buildEndpoints,
30
+ type WiringLayer,
31
+ } from "@/lib/topology-graph";
32
+ import { nodeDetail, type BcNodeDetail } from "@/lib/bc-graph";
33
+ import { kindColor, KIND_LEGEND } from "@/lib/kind-colors";
34
+ import { rollupMetrics, edgeActivity } from "@/lib/node-metrics";
35
+ import { NodeCard, EmptyState, SourceDrawer, ServiceNode } from "@/components";
15
36
  import "@vue-flow/core/dist/style.css";
16
37
  import "@vue-flow/core/dist/theme-default.css";
17
38
  import "@vue-flow/controls/dist/style.css";
39
+ import "@vue-flow/minimap/dist/style.css";
18
40
 
19
- const { cache } = useCache();
20
-
21
- const NODE_W = 260;
22
- const NODE_GAP_X = 80;
23
- const NODE_GAP_Y = 80;
24
- const COLS = 3;
25
-
26
- const elements = computed<{ nodes: Node[]; edges: Edge[] }>(() => {
27
- if (!cache.value) return { nodes: [], edges: [] };
28
- const nodes: Node[] = [];
29
-
30
- for (let i = 0; i < cache.value.apps.length; i++) {
31
- const app = cache.value.apps[i]!;
32
- const pluginsForApp = cache.value.plugins.filter((p) => p.app === app.name);
33
- const sinksForApp = cache.value.sinks?.filter((s) => s.app === app.name) ?? [];
34
- const actionsCount = cache.value.actions.filter((a) => a.app === app.name).length;
35
- const eventsCount = cache.value.events.filter((e) => e.app === app.name).length;
36
- const projectionsCount = cache.value.projections.filter((p) => p.app === app.name).length;
37
-
38
- const col = i % COLS;
39
- const row = Math.floor(i / COLS);
40
- const height = 110 + pluginsForApp.length * 18 + sinksForApp.length * 18;
41
-
42
- nodes.push({
43
- id: `app:${app.name}`,
44
- position: { x: 40 + col * (NODE_W + NODE_GAP_X), y: 40 + row * (height + NODE_GAP_Y) },
45
- type: "default",
46
- data: {
47
- label: app.name,
48
- plugins: pluginsForApp.map((p) => p.name),
49
- sinks: sinksForApp.map((s) => `${s.position} · ${s.kind ?? s.name}`),
50
- actionsCount,
51
- eventsCount,
52
- projectionsCount,
53
- },
54
- style: {
55
- width: `${NODE_W}px`,
56
- height: `${height}px`,
57
- background: "#0a0a0a",
58
- border: "1px solid rgb(34, 197, 94)",
59
- color: "#e4e4e7",
60
- borderRadius: "8px",
61
- padding: "0",
62
- },
63
- });
64
- }
65
-
66
- // Edges from the event graph: producer-app → consumer-app.
67
- const edges: Edge[] = [];
68
- const eventLog = cache.value.graph?.events ?? [];
69
- for (const edge of eventLog) {
70
- const producer = (edge as { producer?: { app?: string } }).producer;
71
- const consumers = (edge as { consumers?: { app?: string; via?: string }[] }).consumers ?? [];
72
- const sourceAppName = producer?.app;
73
- if (!sourceAppName) continue;
74
- const sourceId = `app:${sourceAppName}`;
75
- for (const cons of consumers) {
76
- if (!cons.app) continue;
77
- const targetId = `app:${cons.app}`;
78
- if (sourceId === targetId) continue;
79
- const eventName = (edge as { event?: string }).event ?? "";
80
- edges.push({
81
- id: `${sourceId}->${targetId}::${eventName}::${cons.via ?? "via"}`,
82
- source: sourceId,
83
- target: targetId,
84
- label: eventName,
41
+ const { activeCwd } = useProject();
42
+ const { manifest, view, isLoading } = useManifest(activeCwd);
43
+
44
+ // Live behaviour feed — overlaid as per-node metrics + edge animation.
45
+ const { records } = useTelemetry(activeCwd);
46
+ const metrics = computed(() => rollupMetrics(records.value));
47
+ const hasRun = computed(() => metrics.value.total > 0);
48
+
49
+ const tab = ref<"wiring" | "endpoints">("wiring");
50
+
51
+ /** The full wiring graph, plus the apps present (for the per-app filter). */
52
+ const fullGraph = computed(() => buildWiringGraph(view.value));
53
+ const apps = computed(() => fullGraph.value.apps);
54
+
55
+ /** When more than one app, scope the canvas to one at a time so the wiring reads. */
56
+ const appFilter = ref<string | null>(null);
57
+ const effectiveApp = computed(() =>
58
+ appFilter.value && apps.value.includes(appFilter.value)
59
+ ? appFilter.value
60
+ : apps.value.length === 1
61
+ ? apps.value[0]!
62
+ : null,
63
+ );
64
+ const graph = computed(() =>
65
+ effectiveApp.value ? buildWiringGraph(view.value, effectiveApp.value) : fullGraph.value,
66
+ );
67
+
68
+ const LAYER_LABEL: Record<WiringLayer, string> = {
69
+ source: "Sources",
70
+ handler: "Handlers",
71
+ effect: "Effects",
72
+ sink: "Sinks",
73
+ };
74
+
75
+ /**
76
+ * Node positions — from the manifest ONLY. This array's identity changes only
77
+ * when the wiring graph changes (manifest / app filter), so a telemetry tick
78
+ * never re-seeds VueFlow's positions: metrics ride the `data` object instead.
79
+ */
80
+ const nodes = computed<Node[]>(() => {
81
+ const laid = layoutWiringGraph(graph.value, {
82
+ nodeWidth: 196,
83
+ nodeHeight: 84,
84
+ laneGap: 130,
85
+ rowGap: 34,
86
+ });
87
+ return laid.map((n) => ({
88
+ id: n.id,
89
+ type: "service",
90
+ position: { x: n.x, y: n.y },
91
+ sourcePosition: Position.Right,
92
+ targetPosition: Position.Left,
93
+ data: { kind: n.kind, name: n.name, layer: n.layer, public: n.public },
94
+ style: { width: `${n.width}px` },
95
+ }));
96
+ });
97
+
98
+ const edges = computed<Edge[]>(() => {
99
+ const present = new Set(nodes.value.map((n) => n.id));
100
+ const live = edgeActivity(
101
+ metrics.value.byNode,
102
+ graph.value.edges.map((e) => ({ id: e.id, from: e.from, to: e.to })),
103
+ );
104
+ return graph.value.edges
105
+ .filter((e) => present.has(e.from) && present.has(e.to))
106
+ .map((e) => {
107
+ const act = live.get(e.id);
108
+ const color = e.back ? "#71717a" : "#a78bfa";
109
+ const volume = act?.volume ?? 0;
110
+ const label = volume > 0 ? `${e.type} · ${volume}` : e.type;
111
+ return {
112
+ id: e.id,
113
+ source: e.from,
114
+ target: e.to,
115
+ label,
85
116
  type: "smoothstep",
86
- animated: true,
87
- style: { stroke: "#a78bfa", strokeWidth: 2 },
88
- labelStyle: { fontSize: "10px", fill: "#a1a1aa" },
117
+ animated: act?.active ?? false,
118
+ style: {
119
+ stroke: color,
120
+ strokeWidth: act?.active ? 2.5 : 1.5,
121
+ strokeDasharray: e.back ? "4 3" : undefined,
122
+ opacity: act?.active ? 1 : 0.7,
123
+ },
124
+ labelStyle: { fontSize: "9px", fill: act?.active ? "#e4e4e7" : "#a1a1aa" },
89
125
  labelBgStyle: { fill: "#18181b" },
90
- labelBgPadding: [4, 2] as [number, number],
91
- labelBgBorderRadius: 4,
92
- markerEnd: { type: MarkerType.ArrowClosed, color: "#a78bfa" },
93
- });
94
- }
95
- }
96
-
97
- return { nodes, edges };
126
+ labelBgPadding: [3, 1] as [number, number],
127
+ labelBgBorderRadius: 3,
128
+ markerEnd: { type: MarkerType.ArrowClosed, color },
129
+ } satisfies Edge;
130
+ });
98
131
  });
99
132
 
100
- const showCrossOnly = ref(false);
101
- const filtered = computed(() => {
102
- if (!showCrossOnly.value) return elements.value;
103
- const edges = elements.value.edges.filter((e) => e.source !== e.target);
104
- return { nodes: elements.value.nodes, edges };
133
+ /** Lane headers drawn above the canvas (only lanes that hold nodes). */
134
+ const lanes = computed<WiringLayer[]>(() => {
135
+ const present = new Set(graph.value.nodes.map((n) => n.layer));
136
+ return (["source", "handler", "effect", "sink"] as WiringLayer[]).filter((l) => present.has(l));
105
137
  });
138
+
139
+ // Detail panel — reuses the Map's NodeCard + nodeDetail resolver.
140
+ const selectedId = ref<string | null>(null);
141
+ const detail = computed<BcNodeDetail | null>(() =>
142
+ nodeDetail(manifest.value ?? null, selectedId.value),
143
+ );
144
+ function onNodeClick(id: string): void {
145
+ selectedId.value = selectedId.value === id ? null : id;
146
+ }
147
+ const sourcePreview = ref<{ file: string; line?: number; column?: number } | null>(null);
148
+
149
+ const endpoints = computed(() => buildEndpoints(view.value));
150
+
151
+ const isEmpty = computed(() => !isLoading.value && fullGraph.value.nodes.length === 0);
152
+
153
+ /** MiniMap node tint — accent by kind so the overview reads at a glance. */
154
+ function miniColor(node: { data?: { kind?: string } }): string {
155
+ return kindColor(node.data?.kind);
156
+ }
106
157
  </script>
107
158
 
108
159
  <template>
109
- <div v-if="cache" class="h-full flex flex-col" data-testid="topology-page">
110
- <div class="border-b border-zinc-800 px-6 py-3 flex items-center justify-between">
111
- <div>
112
- <h1 class="text-lg font-semibold tracking-tight">Topology</h1>
113
- <p class="text-xs text-zinc-500">
114
- Apps · plugins · sinks · cross-app event flows
115
- <span class="ml-2">
116
- <span class="inline-block w-3 h-0.5 bg-purple-400 align-middle mr-1"></span>
117
- cross-app
160
+ <div class="h-full flex flex-col" data-testid="topology-page">
161
+ <div class="border-b border-zinc-800 px-6 py-3">
162
+ <div class="flex items-center justify-between">
163
+ <div>
164
+ <h1 class="text-lg font-semibold tracking-tight">Topology</h1>
165
+ <p class="text-xs text-zinc-500">
166
+ How the app is wired: source → handler → effect → sink
167
+ </p>
168
+ </div>
169
+ <div class="flex items-center gap-3">
170
+ <span
171
+ class="inline-flex items-center gap-1.5 text-[11px]"
172
+ :class="hasRun ? 'text-emerald-300' : 'text-zinc-500'"
173
+ data-testid="topology-live"
174
+ >
175
+ <span
176
+ class="h-2 w-2 rounded-full"
177
+ :class="hasRun ? 'bg-emerald-400 animate-pulse' : 'bg-zinc-600'"
178
+ />
179
+ {{ hasRun ? "Live" : "Idle" }}
180
+ </span>
181
+ <select
182
+ v-if="apps.length > 1 && tab === 'wiring'"
183
+ v-model="appFilter"
184
+ class="bg-zinc-900 border border-zinc-800 rounded text-xs text-zinc-300 px-2 py-1"
185
+ data-testid="topology-app-filter"
186
+ >
187
+ <option v-for="a in apps" :key="a" :value="a">{{ a }}</option>
188
+ </select>
189
+ <div class="flex rounded border border-zinc-800 overflow-hidden text-xs">
190
+ <button
191
+ class="px-3 py-1"
192
+ :class="tab === 'wiring' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400'"
193
+ data-testid="topology-tab-wiring"
194
+ @click="tab = 'wiring'"
195
+ >
196
+ Wiring
197
+ </button>
198
+ <button
199
+ class="px-3 py-1 border-l border-zinc-800"
200
+ :class="tab === 'endpoints' ? 'bg-zinc-800 text-zinc-100' : 'text-zinc-400'"
201
+ data-testid="topology-tab-endpoints"
202
+ @click="tab = 'endpoints'"
203
+ >
204
+ Endpoints
205
+ </button>
206
+ </div>
207
+ </div>
208
+ </div>
209
+ </div>
210
+
211
+ <div v-if="isLoading" class="flex-1 grid place-items-center text-sm text-zinc-500">
212
+ Loading manifest…
213
+ </div>
214
+ <EmptyState
215
+ v-else-if="isEmpty"
216
+ :icon="Network"
217
+ title="No wiring discovered yet"
218
+ hint="Run nwire cache to build the manifest, then this maps the app's source → handler → effect → sink pipeline."
219
+ data-testid="topology-empty"
220
+ />
221
+
222
+ <!-- WIRING TAB -->
223
+ <div v-else-if="tab === 'wiring'" class="flex-1 flex min-h-0">
224
+ <div class="flex-1 relative min-w-0">
225
+ <!-- lane headers -->
226
+ <div
227
+ class="absolute top-0 left-0 right-0 z-10 flex pointer-events-none px-6 pt-2"
228
+ data-testid="topology-lanes"
229
+ >
230
+ <div
231
+ v-for="l in lanes"
232
+ :key="l"
233
+ class="flex-1 text-center text-[10px] uppercase tracking-wider text-zinc-600 font-medium"
234
+ >
235
+ {{ LAYER_LABEL[l] }}
236
+ </div>
237
+ </div>
238
+
239
+ <VueFlow
240
+ :nodes="nodes"
241
+ :edges="edges"
242
+ :fit-view-on-init="true"
243
+ :min-zoom="0.2"
244
+ :max-zoom="1.5"
245
+ :default-edge-options="{ type: 'smoothstep' }"
246
+ @node-click="onNodeClick($event.node.id)"
247
+ >
248
+ <template #node-service="{ data, id }">
249
+ <ServiceNode
250
+ :id="id"
251
+ :kind="data.kind"
252
+ :name="data.name"
253
+ :public="data.public"
254
+ :selected="selectedId === id"
255
+ :metrics="metrics.byNode.get(id)"
256
+ />
257
+ </template>
258
+ <Background :pattern-color="'#27272a'" :gap="22" :size="1.4" />
259
+ <MiniMap pannable zoomable :node-color="miniColor" class="!bg-zinc-950/80" />
260
+ <Controls />
261
+ </VueFlow>
262
+
263
+ <!-- kind legend -->
264
+ <div
265
+ 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 backdrop-blur"
266
+ data-testid="topology-legend"
267
+ >
268
+ <span
269
+ v-for="k in KIND_LEGEND"
270
+ :key="k"
271
+ class="inline-flex items-center gap-1 text-[10px] text-zinc-400"
272
+ >
273
+ <span class="h-1.5 w-1.5 rounded-full" :style="{ background: kindColor(k) }" />
274
+ {{ k }}
118
275
  </span>
119
- </p>
276
+ </div>
120
277
  </div>
121
- <label class="flex items-center gap-2 text-xs text-zinc-400">
122
- <input v-model="showCrossOnly" type="checkbox" class="accent-purple-400" />
123
- Cross-app only
124
- </label>
278
+
279
+ <aside
280
+ v-if="selectedId"
281
+ class="w-96 shrink-0 border-l border-zinc-800 bg-zinc-950"
282
+ data-testid="topology-detail"
283
+ >
284
+ <NodeCard
285
+ :detail="detail"
286
+ @open-source="sourcePreview = $event"
287
+ @select-event="selectedId = `event:${$event}`"
288
+ />
289
+ </aside>
125
290
  </div>
126
291
 
127
- <div class="flex-1 relative">
128
- <VueFlow
129
- :nodes="filtered.nodes"
130
- :edges="filtered.edges"
131
- :fit-view-on-init="true"
132
- :min-zoom="0.2"
292
+ <!-- ENDPOINTS TAB -->
293
+ <div v-else class="flex-1 overflow-y-auto p-6" data-testid="topology-endpoints">
294
+ <div
295
+ v-if="endpoints.length === 0"
296
+ class="text-sm text-zinc-500 grid place-items-center h-full"
297
+ data-testid="endpoints-empty"
133
298
  >
134
- <template #node-default="props">
135
- <div class="px-3 py-2 text-left h-full flex flex-col gap-1">
136
- <div class="font-mono text-sm text-emerald-300">{{ props.data.label }}</div>
137
- <div class="text-[10px] text-zinc-500">
138
- {{ props.data.actionsCount }}A · {{ props.data.eventsCount }}E ·
139
- {{ props.data.projectionsCount }}P
140
- </div>
141
- <div v-if="props.data.plugins.length > 0" class="text-[10px] text-zinc-400 mt-1">
142
- <div class="uppercase tracking-wider text-zinc-600">Plugins</div>
143
- <div
144
- v-for="p in props.data.plugins"
145
- :key="p"
146
- class="font-mono truncate text-zinc-300"
299
+ No routes exposed — this app has no HTTP surface.
300
+ </div>
301
+ <table v-else class="w-full text-sm">
302
+ <thead>
303
+ <tr
304
+ class="text-left text-[10px] uppercase tracking-wider text-zinc-600 border-b border-zinc-800"
305
+ >
306
+ <th class="py-2 pr-4 font-medium">Method</th>
307
+ <th class="py-2 pr-4 font-medium">Path</th>
308
+ <th class="py-2 pr-4 font-medium">Handler</th>
309
+ <th class="py-2 font-medium">App</th>
310
+ </tr>
311
+ </thead>
312
+ <tbody>
313
+ <tr
314
+ v-for="ep in endpoints"
315
+ :key="ep.id"
316
+ class="border-b border-zinc-900 hover:bg-zinc-900/40"
317
+ data-testid="endpoint-row"
318
+ >
319
+ <td class="py-2 pr-4">
320
+ <span class="font-mono text-[11px] text-emerald-300">{{ ep.method }}</span>
321
+ </td>
322
+ <td class="py-2 pr-4 font-mono text-[12px] text-zinc-200">{{ ep.path }}</td>
323
+ <td class="py-2 pr-4">
324
+ <button
325
+ v-if="ep.handler"
326
+ type="button"
327
+ class="font-mono text-[11px] text-violet-300 hover:underline"
328
+ data-testid="endpoint-handler"
329
+ @click="
330
+ tab = 'wiring';
331
+ selectedId = ep.handler!.id;
332
+ "
147
333
  >
148
- {{ p }}
149
- </div>
150
- </div>
151
- <div v-if="props.data.sinks.length > 0" class="text-[10px] mt-1">
152
- <div class="uppercase tracking-wider text-zinc-600">Sinks</div>
153
- <div v-for="s in props.data.sinks" :key="s" class="font-mono truncate text-amber-200">
154
- {{ s }}
155
- </div>
156
- </div>
157
- </div>
158
- </template>
159
- <Background pattern-color="#27272a" />
160
- <Controls />
161
- </VueFlow>
334
+ {{ ep.handler.name }}
335
+ </button>
336
+ <span v-else class="text-[11px] text-zinc-600">—</span>
337
+ </td>
338
+ <td class="py-2 text-[11px] text-zinc-500">{{ ep.app }}</td>
339
+ </tr>
340
+ </tbody>
341
+ </table>
162
342
  </div>
343
+
344
+ <SourceDrawer :source="sourcePreview" @close="sourcePreview = null" />
163
345
  </div>
164
346
  </template>