@nwire/studio 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -0
  3. package/components.json +19 -0
  4. package/index.html +12 -0
  5. package/package.json +66 -0
  6. package/src/App.vue +305 -0
  7. package/src/components/EmptyState.stories.ts +53 -0
  8. package/src/components/EmptyState.vue +28 -0
  9. package/src/components/ErrorBoundary.vue +60 -0
  10. package/src/components/FilterInput.stories.ts +32 -0
  11. package/src/components/FilterInput.vue +33 -0
  12. package/src/components/JsonView.stories.ts +38 -0
  13. package/src/components/JsonView.vue +34 -0
  14. package/src/components/KindBadge.stories.ts +72 -0
  15. package/src/components/KindBadge.vue +59 -0
  16. package/src/components/ListRow.stories.ts +56 -0
  17. package/src/components/ListRow.vue +48 -0
  18. package/src/components/MasterDetail.stories.ts +74 -0
  19. package/src/components/MasterDetail.vue +35 -0
  20. package/src/components/MonacoViewer.vue +143 -0
  21. package/src/components/PageHeader.stories.ts +45 -0
  22. package/src/components/PageHeader.vue +46 -0
  23. package/src/components/SchemaNode.vue +208 -0
  24. package/src/components/SchemaTree.vue +65 -0
  25. package/src/components/SourceDrawer.vue +136 -0
  26. package/src/components/SourcePill.vue +103 -0
  27. package/src/components/__tests__/EmptyState.test.ts +28 -0
  28. package/src/components/__tests__/ErrorBoundary.test.ts +52 -0
  29. package/src/components/__tests__/FilterInput.test.ts +38 -0
  30. package/src/components/__tests__/JsonView.test.ts +33 -0
  31. package/src/components/__tests__/KindBadge.test.ts +39 -0
  32. package/src/components/__tests__/ListRow.test.ts +39 -0
  33. package/src/components/__tests__/MasterDetail.test.ts +40 -0
  34. package/src/components/__tests__/PageHeader.test.ts +42 -0
  35. package/src/components/index.ts +17 -0
  36. package/src/components/ui/badge/Badge.vue +17 -0
  37. package/src/components/ui/badge/index.ts +25 -0
  38. package/src/components/ui/button/Button.vue +28 -0
  39. package/src/components/ui/button/index.ts +34 -0
  40. package/src/components/ui/card/Card.vue +14 -0
  41. package/src/components/ui/card/CardContent.vue +14 -0
  42. package/src/components/ui/card/CardDescription.vue +14 -0
  43. package/src/components/ui/card/CardFooter.vue +14 -0
  44. package/src/components/ui/card/CardHeader.vue +14 -0
  45. package/src/components/ui/card/CardTitle.vue +14 -0
  46. package/src/components/ui/card/index.ts +6 -0
  47. package/src/components/ui/dialog/Dialog.vue +15 -0
  48. package/src/components/ui/dialog/DialogClose.vue +12 -0
  49. package/src/components/ui/dialog/DialogContent.vue +47 -0
  50. package/src/components/ui/dialog/DialogDescription.vue +22 -0
  51. package/src/components/ui/dialog/DialogFooter.vue +12 -0
  52. package/src/components/ui/dialog/DialogHeader.vue +14 -0
  53. package/src/components/ui/dialog/DialogScrollContent.vue +60 -0
  54. package/src/components/ui/dialog/DialogTitle.vue +22 -0
  55. package/src/components/ui/dialog/DialogTrigger.vue +12 -0
  56. package/src/components/ui/dialog/index.ts +9 -0
  57. package/src/components/ui/input/Input.vue +32 -0
  58. package/src/components/ui/input/index.ts +1 -0
  59. package/src/components/ui/scroll-area/ScrollArea.vue +22 -0
  60. package/src/components/ui/scroll-area/ScrollBar.vue +32 -0
  61. package/src/components/ui/scroll-area/index.ts +2 -0
  62. package/src/components/ui/separator/Separator.vue +27 -0
  63. package/src/components/ui/separator/index.ts +1 -0
  64. package/src/components/ui/skeleton/Skeleton.vue +14 -0
  65. package/src/components/ui/skeleton/index.ts +1 -0
  66. package/src/components/ui/tabs/Tabs.vue +15 -0
  67. package/src/components/ui/tabs/TabsContent.vue +25 -0
  68. package/src/components/ui/tabs/TabsList.vue +25 -0
  69. package/src/components/ui/tabs/TabsTrigger.vue +29 -0
  70. package/src/components/ui/tabs/index.ts +4 -0
  71. package/src/components/ui/tooltip/Tooltip.vue +15 -0
  72. package/src/components/ui/tooltip/TooltipContent.vue +40 -0
  73. package/src/components/ui/tooltip/TooltipProvider.vue +12 -0
  74. package/src/components/ui/tooltip/TooltipTrigger.vue +12 -0
  75. package/src/components/ui/tooltip/index.ts +4 -0
  76. package/src/composables/useCopy.ts +31 -0
  77. package/src/lib/__tests__/normalize-cache.test.ts +104 -0
  78. package/src/lib/cache.ts +334 -0
  79. package/src/lib/normalize-cache.ts +92 -0
  80. package/src/lib/project-catalog.ts +125 -0
  81. package/src/lib/utils.ts +6 -0
  82. package/src/main.ts +112 -0
  83. package/src/pages/Actions.vue +180 -0
  84. package/src/pages/Commands.vue +262 -0
  85. package/src/pages/Dispatch.vue +431 -0
  86. package/src/pages/Events.vue +166 -0
  87. package/src/pages/Home.stories.ts +47 -0
  88. package/src/pages/Home.vue +485 -0
  89. package/src/pages/Hooks.vue +297 -0
  90. package/src/pages/Live.vue +249 -0
  91. package/src/pages/Modules.vue +174 -0
  92. package/src/pages/Overview.vue +159 -0
  93. package/src/pages/Plugins.stories.ts +44 -0
  94. package/src/pages/Plugins.vue +403 -0
  95. package/src/pages/Projects.vue +272 -0
  96. package/src/pages/Run.vue +479 -0
  97. package/src/pages/Topology.vue +164 -0
  98. package/src/pages/Trace.vue +511 -0
  99. package/src/pages/TraceNode.vue +166 -0
  100. package/src/pages/Workflows.vue +191 -0
  101. package/src/pages/__tests__/Actions.test.ts +98 -0
  102. package/src/pages/__tests__/Home.test.ts +98 -0
  103. package/src/pages/__tests__/Hooks.test.ts +119 -0
  104. package/src/pages/__tests__/Plugins.test.ts +80 -0
  105. package/src/style.css +40 -0
  106. package/tsconfig.json +20 -0
  107. package/vite.config.ts +892 -0
@@ -0,0 +1,479 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Studio-as-runner — the Processes panel.
4
+ *
5
+ * Three columns: topology picker (left), running processes (middle),
6
+ * live stdout for the selected process (right). Drives the `/__nwire/run/*`
7
+ * supervisor surface served by Studio's Vite middleware.
8
+ */
9
+ import { computed, onMounted, onUnmounted, ref, watch } from "vue";
10
+ import {
11
+ Play,
12
+ Square,
13
+ RefreshCw,
14
+ Trash2,
15
+ CircleDot,
16
+ Circle,
17
+ Loader2,
18
+ AlertTriangle,
19
+ CheckCircle2,
20
+ Terminal,
21
+ } from "lucide-vue-next";
22
+
23
+ type ProcessStatus = "idle" | "starting" | "running" | "stopping" | "exited" | "crashed";
24
+
25
+ interface ManagedProcess {
26
+ id: string;
27
+ topology: string;
28
+ port?: number;
29
+ startedAt: string;
30
+ status: ProcessStatus;
31
+ pid?: number;
32
+ exitCode?: number | null;
33
+ signal?: string | null;
34
+ errorMessage?: string;
35
+ }
36
+
37
+ interface LogLine {
38
+ seq: number;
39
+ ts: string;
40
+ stream: "stdout" | "stderr";
41
+ line: string;
42
+ }
43
+
44
+ const topologies = ref<string[]>([]);
45
+ const scripts = ref<Array<{ name: string; command: string }>>([]);
46
+ const scriptBusy = ref<string | null>(null);
47
+ const processes = ref<ManagedProcess[]>([]);
48
+ const selectedId = ref<string | null>(null);
49
+ const startTopology = ref<string>("");
50
+ const startPort = ref<number>(3000);
51
+ const startBusy = ref(false);
52
+ const startError = ref<string | null>(null);
53
+ const autoScroll = ref(true);
54
+ const filterStream = ref<"all" | "stdout" | "stderr">("all");
55
+
56
+ const logs = ref<LogLine[]>([]);
57
+ let es: EventSource | null = null;
58
+ let processesPoll: ReturnType<typeof setInterval> | null = null;
59
+
60
+ async function loadTopologies() {
61
+ try {
62
+ const res = await fetch("/__nwire/run/topologies");
63
+ const body = (await res.json()) as { topologies: string[] };
64
+ topologies.value = body.topologies;
65
+ if (!startTopology.value && body.topologies[0]) startTopology.value = body.topologies[0];
66
+ } catch {
67
+ topologies.value = [];
68
+ }
69
+ }
70
+
71
+ async function loadScripts() {
72
+ try {
73
+ const res = await fetch("/__nwire/run/scripts");
74
+ const body = (await res.json()) as { scripts: Array<{ name: string; command: string }> };
75
+ scripts.value = body.scripts;
76
+ } catch {
77
+ scripts.value = [];
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Display ordering for package.json scripts:
83
+ *
84
+ * 1. `nwire`-prefixed first (alphabetical) — these are the framework's own
85
+ * shorthands (`nwire:dev`, `nwire:cache`, etc.) and operators reach for
86
+ * them most often.
87
+ * 2. Everything else (alphabetical).
88
+ *
89
+ * Computed off `scripts.value` so refresh updates the order without churn.
90
+ */
91
+ const sortedScripts = computed(() => {
92
+ const nwirePrefixed = scripts.value
93
+ .filter((s) => s.name.startsWith("nwire"))
94
+ .sort((a, b) => a.name.localeCompare(b.name));
95
+ const others = scripts.value
96
+ .filter((s) => !s.name.startsWith("nwire"))
97
+ .sort((a, b) => a.name.localeCompare(b.name));
98
+ return [...nwirePrefixed, ...others];
99
+ });
100
+
101
+ async function runScript(name: string) {
102
+ scriptBusy.value = name;
103
+ startError.value = null;
104
+ try {
105
+ const res = await fetch("/__nwire/run/exec-script", {
106
+ method: "POST",
107
+ headers: { "Content-Type": "application/json" },
108
+ body: JSON.stringify({ script: name }),
109
+ });
110
+ const body = (await res.json()) as { process?: ManagedProcess; error?: string };
111
+ if (!res.ok) {
112
+ startError.value = body.error ?? `run failed (${res.status})`;
113
+ return;
114
+ }
115
+ if (body.process) {
116
+ processes.value = [...processes.value, body.process];
117
+ selectedId.value = body.process.id;
118
+ }
119
+ } catch (err) {
120
+ startError.value = (err as Error).message;
121
+ } finally {
122
+ scriptBusy.value = null;
123
+ }
124
+ }
125
+
126
+ async function loadProcesses() {
127
+ try {
128
+ const res = await fetch("/__nwire/run/processes");
129
+ const body = (await res.json()) as { processes: ManagedProcess[] };
130
+ processes.value = body.processes;
131
+ // If selection died, keep the id but show last logs.
132
+ if (selectedId.value && !body.processes.some((p) => p.id === selectedId.value)) {
133
+ selectedId.value = null;
134
+ }
135
+ } catch {
136
+ /* ignore */
137
+ }
138
+ }
139
+
140
+ async function start() {
141
+ if (!startTopology.value) return;
142
+ startBusy.value = true;
143
+ startError.value = null;
144
+ try {
145
+ const res = await fetch("/__nwire/run/start", {
146
+ method: "POST",
147
+ headers: { "Content-Type": "application/json" },
148
+ body: JSON.stringify({ topology: startTopology.value, port: startPort.value }),
149
+ });
150
+ const body = (await res.json()) as { process?: ManagedProcess; error?: string };
151
+ if (!res.ok) {
152
+ startError.value = body.error ?? `start failed (${res.status})`;
153
+ return;
154
+ }
155
+ if (body.process) {
156
+ processes.value = [...processes.value, body.process];
157
+ selectedId.value = body.process.id;
158
+ }
159
+ } catch (err) {
160
+ startError.value = (err as Error).message;
161
+ } finally {
162
+ startBusy.value = false;
163
+ }
164
+ }
165
+
166
+ async function stop(id: string) {
167
+ try {
168
+ await fetch(`/__nwire/run/stop/${id}`, { method: "POST" });
169
+ } catch {
170
+ /* ignore */
171
+ }
172
+ await loadProcesses();
173
+ }
174
+
175
+ async function forget(id: string) {
176
+ try {
177
+ await fetch(`/__nwire/run/forget/${id}`, { method: "POST" });
178
+ if (selectedId.value === id) selectedId.value = null;
179
+ } catch {
180
+ /* ignore */
181
+ }
182
+ await loadProcesses();
183
+ }
184
+
185
+ function connectLogStream(id: string | null) {
186
+ es?.close();
187
+ es = null;
188
+ logs.value = [];
189
+ if (!id) return;
190
+ es = new EventSource(`/__nwire/run/logs/${id}/stream`);
191
+ es.onmessage = (msg) => {
192
+ try {
193
+ const line = JSON.parse(msg.data) as LogLine;
194
+ logs.value.push(line);
195
+ if (logs.value.length > 5000) logs.value.shift();
196
+ } catch {
197
+ /* ignore */
198
+ }
199
+ };
200
+ es.onerror = () => {
201
+ // SSE auto-reconnects; nothing to do.
202
+ };
203
+ }
204
+
205
+ watch(selectedId, (id) => connectLogStream(id));
206
+
207
+ onMounted(() => {
208
+ void loadTopologies();
209
+ void loadScripts();
210
+ void loadProcesses();
211
+ processesPoll = setInterval(loadProcesses, 2000);
212
+ });
213
+
214
+ onUnmounted(() => {
215
+ es?.close();
216
+ if (processesPoll) clearInterval(processesPoll);
217
+ });
218
+
219
+ const filteredLogs = computed(() => {
220
+ if (filterStream.value === "all") return logs.value;
221
+ return logs.value.filter((l) => l.stream === filterStream.value);
222
+ });
223
+
224
+ const selectedProcess = computed(() => processes.value.find((p) => p.id === selectedId.value));
225
+
226
+ /**
227
+ * The process Studio's `/_nwire/*` proxy currently routes to — the most
228
+ * recently started running process. Surface it in the header so the user
229
+ * knows which wire Live / Dispatch / EventStorm are pointing at.
230
+ */
231
+ const activeProcess = computed<ManagedProcess | undefined>(() => {
232
+ const running = processes.value.filter((p) => p.status === "running");
233
+ if (running.length === 0) return undefined;
234
+ return [...running].sort((a, b) => b.startedAt.localeCompare(a.startedAt))[0];
235
+ });
236
+
237
+ function shortId(id: string): string {
238
+ return id.split("-")[0] ?? id.slice(0, 8);
239
+ }
240
+
241
+ function timeAgo(iso: string): string {
242
+ const s = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000));
243
+ if (s < 60) return `${s}s ago`;
244
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
245
+ return `${Math.floor(s / 3600)}h ago`;
246
+ }
247
+ </script>
248
+
249
+ <template>
250
+ <div class="h-full flex">
251
+ <!-- Topology picker -->
252
+ <div class="w-72 border-r border-zinc-800 flex flex-col">
253
+ <div class="border-b border-zinc-800 px-4 py-3">
254
+ <h1 class="font-semibold text-lg tracking-tight">Run</h1>
255
+ <p class="text-xs text-zinc-500 mt-0.5">Spawn a wire from a topology manifest.</p>
256
+ <div v-if="activeProcess" class="mt-2 flex items-center gap-1.5 text-[10px] font-mono">
257
+ <span class="inline-block w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
258
+ <span class="text-emerald-300">/_nwire/* → :{{ activeProcess.port }}</span>
259
+ <span class="text-zinc-600">({{ activeProcess.topology }})</span>
260
+ </div>
261
+ <div v-else class="mt-2 text-[10px] font-mono text-zinc-600">
262
+ /_nwire/* → static fallback
263
+ </div>
264
+ </div>
265
+ <div class="p-4 space-y-3">
266
+ <div>
267
+ <label class="text-[10px] uppercase tracking-wide text-zinc-500">Topology</label>
268
+ <select
269
+ v-model="startTopology"
270
+ class="w-full mt-1 bg-zinc-900 border border-zinc-800 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-zinc-600"
271
+ >
272
+ <option v-for="t in topologies" :key="t" :value="t">{{ t }}</option>
273
+ <option v-if="topologies.length === 0" disabled>No topology files found</option>
274
+ </select>
275
+ </div>
276
+ <div>
277
+ <label class="text-[10px] uppercase tracking-wide text-zinc-500">Port</label>
278
+ <input
279
+ v-model.number="startPort"
280
+ type="number"
281
+ class="w-full mt-1 bg-zinc-900 border border-zinc-800 rounded px-2 py-1.5 text-sm focus:outline-none focus:border-zinc-600"
282
+ />
283
+ </div>
284
+ <button
285
+ class="w-full px-3 py-2 rounded bg-emerald-700 hover:bg-emerald-600 disabled:opacity-50 text-sm font-medium flex items-center justify-center gap-2"
286
+ :disabled="startBusy || !startTopology"
287
+ @click="start"
288
+ >
289
+ <Loader2 v-if="startBusy" class="w-4 h-4 animate-spin" />
290
+ <Play v-else class="w-4 h-4" />
291
+ {{ startBusy ? "Starting…" : "Start" }}
292
+ </button>
293
+ <div
294
+ v-if="startError"
295
+ class="text-xs text-rose-300 bg-rose-950/40 border border-rose-900 rounded p-2 flex items-start gap-2"
296
+ >
297
+ <AlertTriangle class="w-3 h-3 mt-0.5 shrink-0" />
298
+ {{ startError }}
299
+ </div>
300
+ <button
301
+ class="w-full text-[10px] text-zinc-500 hover:text-zinc-300 flex items-center justify-center gap-1"
302
+ @click="loadTopologies"
303
+ >
304
+ <RefreshCw class="w-3 h-3" />
305
+ refresh topologies
306
+ </button>
307
+ </div>
308
+ <!-- package.json scripts — fallback for single-app projects without
309
+ apps/topologies/. Always shown so multi-app projects can also
310
+ use it for one-shot `pnpm test`, `pnpm build`, etc. -->
311
+ <div class="border-t border-zinc-800 px-4 py-3" v-if="scripts.length > 0">
312
+ <div class="flex items-center justify-between mb-2">
313
+ <label class="text-[10px] uppercase tracking-wide text-zinc-500">
314
+ package.json scripts
315
+ </label>
316
+ <button
317
+ class="text-zinc-500 hover:text-zinc-200 p-0.5 rounded"
318
+ @click="loadScripts"
319
+ title="Refresh"
320
+ >
321
+ <RefreshCw class="w-3 h-3" />
322
+ </button>
323
+ </div>
324
+ <div class="space-y-1">
325
+ <button
326
+ v-for="s in sortedScripts"
327
+ :key="s.name"
328
+ class="w-full text-left px-2 py-1.5 rounded hover:bg-zinc-900/70 disabled:opacity-50 flex items-center gap-2 group"
329
+ :disabled="scriptBusy === s.name"
330
+ @click="runScript(s.name)"
331
+ :title="s.command"
332
+ >
333
+ <Loader2 v-if="scriptBusy === s.name" class="w-3 h-3 animate-spin text-emerald-400" />
334
+ <Play v-else class="w-3 h-3 text-zinc-600 group-hover:text-emerald-400" />
335
+ <span class="text-xs font-mono text-zinc-200">{{ s.name }}</span>
336
+ <span class="text-[10px] text-zinc-600 truncate flex-1 text-right">
337
+ {{ s.command }}
338
+ </span>
339
+ </button>
340
+ </div>
341
+ </div>
342
+ </div>
343
+
344
+ <!-- Processes -->
345
+ <div class="w-96 border-r border-zinc-800 flex flex-col">
346
+ <div class="border-b border-zinc-800 px-4 py-3 flex items-center justify-between">
347
+ <div>
348
+ <h2 class="font-medium text-sm tracking-tight">Processes</h2>
349
+ <p class="text-[10px] text-zinc-500">{{ processes.length }} managed</p>
350
+ </div>
351
+ <button
352
+ class="text-zinc-500 hover:text-zinc-200 p-1 rounded"
353
+ @click="loadProcesses"
354
+ title="Refresh"
355
+ >
356
+ <RefreshCw class="w-3.5 h-3.5" />
357
+ </button>
358
+ </div>
359
+ <div class="flex-1 overflow-auto">
360
+ <div v-if="processes.length === 0" class="p-6 text-sm text-zinc-500">
361
+ No processes. Pick a topology and click <span class="text-zinc-300">Start</span>.
362
+ </div>
363
+ <button
364
+ v-for="p in processes"
365
+ :key="p.id"
366
+ class="w-full text-left px-4 py-3 border-b border-zinc-900 hover:bg-zinc-900/50 transition-colors"
367
+ :class="{ 'bg-zinc-900/70': selectedId === p.id }"
368
+ @click="selectedId = p.id"
369
+ >
370
+ <div class="flex items-center justify-between">
371
+ <div class="flex items-center gap-2 min-w-0">
372
+ <CircleDot v-if="p.status === 'running'" class="w-3 h-3 text-emerald-400 shrink-0" />
373
+ <Loader2
374
+ v-else-if="p.status === 'starting' || p.status === 'stopping'"
375
+ class="w-3 h-3 text-amber-400 animate-spin shrink-0"
376
+ />
377
+ <AlertTriangle
378
+ v-else-if="p.status === 'crashed'"
379
+ class="w-3 h-3 text-rose-400 shrink-0"
380
+ />
381
+ <CheckCircle2
382
+ v-else-if="p.status === 'exited'"
383
+ class="w-3 h-3 text-zinc-400 shrink-0"
384
+ />
385
+ <Circle v-else class="w-3 h-3 text-zinc-500 shrink-0" />
386
+ <span class="font-mono text-sm truncate">{{ p.topology }}</span>
387
+ <span v-if="p.port" class="text-[10px] text-zinc-500">:{{ p.port }}</span>
388
+ </div>
389
+ <span class="text-[10px] text-zinc-500">{{ timeAgo(p.startedAt) }}</span>
390
+ </div>
391
+ <div class="text-[10px] text-zinc-500 mt-0.5 font-mono flex items-center gap-2">
392
+ <span class="uppercase">{{ p.status }}</span>
393
+ <span v-if="p.pid">pid {{ p.pid }}</span>
394
+ <span class="text-zinc-600">{{ shortId(p.id) }}</span>
395
+ </div>
396
+ <div v-if="p.errorMessage" class="text-[10px] text-rose-300 mt-1">
397
+ {{ p.errorMessage }}
398
+ </div>
399
+ </button>
400
+ </div>
401
+ </div>
402
+
403
+ <!-- Logs -->
404
+ <div class="flex-1 flex flex-col min-w-0">
405
+ <div class="border-b border-zinc-800 px-4 py-3 flex items-center justify-between gap-3">
406
+ <div class="flex items-center gap-3 min-w-0">
407
+ <Terminal class="w-4 h-4 text-zinc-400 shrink-0" />
408
+ <h2 class="font-medium text-sm tracking-tight truncate">
409
+ <template v-if="selectedProcess">
410
+ {{ selectedProcess.topology }}
411
+ <span class="text-zinc-500 font-mono text-xs">{{ shortId(selectedProcess.id) }}</span>
412
+ </template>
413
+ <template v-else>
414
+ <span class="text-zinc-500">Select a process</span>
415
+ </template>
416
+ </h2>
417
+ </div>
418
+ <div class="flex items-center gap-2">
419
+ <select
420
+ v-model="filterStream"
421
+ class="bg-zinc-900 border border-zinc-800 rounded px-2 py-1 text-xs text-zinc-200"
422
+ >
423
+ <option value="all">all</option>
424
+ <option value="stdout">stdout</option>
425
+ <option value="stderr">stderr</option>
426
+ </select>
427
+ <label class="flex items-center gap-1 text-[10px] text-zinc-400">
428
+ <input type="checkbox" v-model="autoScroll" class="accent-emerald-400" />
429
+ tail
430
+ </label>
431
+ <button
432
+ v-if="
433
+ selectedProcess &&
434
+ (selectedProcess.status === 'running' || selectedProcess.status === 'starting')
435
+ "
436
+ class="text-xs px-2 py-1 rounded bg-rose-900/40 border border-rose-900 hover:bg-rose-900/70 text-rose-200 flex items-center gap-1"
437
+ @click="stop(selectedProcess.id)"
438
+ >
439
+ <Square class="w-3 h-3" /> stop
440
+ </button>
441
+ <button
442
+ v-if="
443
+ selectedProcess &&
444
+ (selectedProcess.status === 'exited' || selectedProcess.status === 'crashed')
445
+ "
446
+ class="text-xs px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 flex items-center gap-1"
447
+ @click="forget(selectedProcess.id)"
448
+ >
449
+ <Trash2 class="w-3 h-3" /> clear
450
+ </button>
451
+ </div>
452
+ </div>
453
+ <div
454
+ ref="(el) => el && autoScroll && (el.scrollTop = el.scrollHeight)"
455
+ class="flex-1 overflow-auto bg-zinc-950 font-mono text-[11px] leading-tight"
456
+ >
457
+ <div v-if="!selectedId" class="p-6 text-zinc-500 text-sm font-sans">
458
+ Pick a process on the left to view its stdout/stderr.
459
+ </div>
460
+ <div v-else-if="filteredLogs.length === 0" class="p-6 text-zinc-500 text-sm font-sans">
461
+ Waiting for output…
462
+ </div>
463
+ <div v-else class="p-3 space-y-0.5">
464
+ <div
465
+ v-for="log in filteredLogs"
466
+ :key="log.seq"
467
+ class="flex gap-2"
468
+ :class="log.stream === 'stderr' ? 'text-rose-200' : 'text-zinc-300'"
469
+ >
470
+ <span class="text-zinc-600 tabular-nums shrink-0">
471
+ {{ new Date(log.ts).toLocaleTimeString() }}
472
+ </span>
473
+ <span class="whitespace-pre-wrap break-all">{{ log.line }}</span>
474
+ </div>
475
+ </div>
476
+ </div>
477
+ </div>
478
+ </div>
479
+ </template>
@@ -0,0 +1,164 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from "vue";
3
+ import { VueFlow, type Node, type Edge, MarkerType } from "@vue-flow/core";
4
+ import { Background } from "@vue-flow/background";
5
+ import { Controls } from "@vue-flow/controls";
6
+ import { useCache } from "@/lib/cache";
7
+ import "@vue-flow/core/dist/style.css";
8
+ import "@vue-flow/core/dist/theme-default.css";
9
+ import "@vue-flow/controls/dist/style.css";
10
+
11
+ const { cache } = useCache();
12
+
13
+ // App-level lane layout: each app gets a row; its modules sit on that row.
14
+ const COLS_PER_ROW = 4;
15
+ const NODE_W = 220;
16
+ const NODE_H = 100;
17
+ const COL_GAP = 60;
18
+ const ROW_GAP = 140;
19
+
20
+ const elements = computed<{ nodes: Node[]; edges: Edge[] }>(() => {
21
+ if (!cache.value) return { nodes: [], edges: [] };
22
+
23
+ const nodes: Node[] = [];
24
+ let y = 40;
25
+
26
+ for (const app of cache.value.apps) {
27
+ // App banner / header
28
+ nodes.push({
29
+ id: `app:${app.name}`,
30
+ position: { x: 20, y },
31
+ type: "default",
32
+ data: { label: app.name },
33
+ style: {
34
+ width: `${COLS_PER_ROW * (NODE_W + COL_GAP) + 80}px`,
35
+ height: "44px",
36
+ background: "rgba(34, 197, 94, 0.10)",
37
+ border: "1px solid rgb(34, 197, 94)",
38
+ color: "rgb(134, 239, 172)",
39
+ fontWeight: "600",
40
+ fontSize: "13px",
41
+ textAlign: "left",
42
+ padding: "10px 16px",
43
+ borderRadius: "8px",
44
+ },
45
+ selectable: false,
46
+ draggable: false,
47
+ });
48
+
49
+ let col = 0;
50
+ let row = 0;
51
+ const moduleY = y + 60;
52
+ for (const moduleName of app.modules) {
53
+ const mod = cache.value.modules.find((m) => m.name === moduleName && m.app === app.name);
54
+ const x = 40 + col * (NODE_W + COL_GAP);
55
+ const ny = moduleY + row * (NODE_H + ROW_GAP * 0.5);
56
+ nodes.push({
57
+ id: `${app.name}:${moduleName}`,
58
+ position: { x, y: ny },
59
+ data: {
60
+ label: moduleName,
61
+ subtitle: mod
62
+ ? `${mod.counts.actions}A · ${mod.counts.events}E · ${mod.counts.actors}@`
63
+ : "",
64
+ },
65
+ style: {
66
+ width: `${NODE_W}px`,
67
+ height: `${NODE_H}px`,
68
+ background: "#18181b",
69
+ border: "1px solid #3f3f46",
70
+ color: "#fafafa",
71
+ padding: "10px",
72
+ borderRadius: "6px",
73
+ },
74
+ type: "default",
75
+ });
76
+ col++;
77
+ if (col >= COLS_PER_ROW) {
78
+ col = 0;
79
+ row++;
80
+ }
81
+ }
82
+ const totalRows = Math.max(1, Math.ceil(app.modules.length / COLS_PER_ROW));
83
+ y = moduleY + totalRows * (NODE_H + ROW_GAP * 0.5) + 30;
84
+ }
85
+
86
+ // Edges: every event-graph edge from producer-module → each consumer-module.
87
+ const edges: Edge[] = [];
88
+ for (const edge of cache.value.graph.events) {
89
+ const sourceId = `${edge.producer.app}:${edge.producer.module}`;
90
+ for (const cons of edge.consumers) {
91
+ const targetId = `${cons.app}:${cons.module}`;
92
+ if (sourceId === targetId) continue; // skip self-loops for readability
93
+ const cross = cons.app !== edge.producer.app;
94
+ edges.push({
95
+ id: `${sourceId}->${targetId}::${edge.event}::${cons.via}`,
96
+ source: sourceId,
97
+ target: targetId,
98
+ label: edge.event,
99
+ type: "smoothstep",
100
+ animated: cross,
101
+ style: { stroke: cross ? "#a78bfa" : "#3f3f46", strokeWidth: cross ? 2 : 1 },
102
+ labelStyle: { fontSize: "10px", fill: "#a1a1aa" },
103
+ labelBgStyle: { fill: "#18181b" },
104
+ labelBgPadding: [4, 2] as [number, number],
105
+ labelBgBorderRadius: 4,
106
+ markerEnd: { type: MarkerType.ArrowClosed, color: cross ? "#a78bfa" : "#71717a" },
107
+ });
108
+ }
109
+ }
110
+
111
+ return { nodes, edges };
112
+ });
113
+
114
+ const showCrossOnly = ref(false);
115
+ const filtered = computed(() => {
116
+ if (!showCrossOnly.value) return elements.value;
117
+ const edges = elements.value.edges.filter((e) => {
118
+ const src = (e.source as string).split(":")[0];
119
+ const tgt = (e.target as string).split(":")[0];
120
+ return src !== tgt;
121
+ });
122
+ return { nodes: elements.value.nodes, edges };
123
+ });
124
+ </script>
125
+
126
+ <template>
127
+ <div v-if="cache" class="h-full flex flex-col">
128
+ <div class="border-b border-zinc-800 px-6 py-3 flex items-center justify-between">
129
+ <div>
130
+ <h1 class="text-lg font-semibold tracking-tight">Topology</h1>
131
+ <p class="text-xs text-zinc-500">
132
+ Apps · bounded contexts · event flows
133
+ <span class="ml-2">
134
+ <span class="inline-block w-3 h-0.5 bg-purple-400 align-middle mr-1"></span>
135
+ cross-service
136
+ </span>
137
+ <span class="ml-3">
138
+ <span class="inline-block w-3 h-0.5 bg-zinc-600 align-middle mr-1"></span>
139
+ in-process
140
+ </span>
141
+ </p>
142
+ </div>
143
+ <label class="flex items-center gap-2 text-xs text-zinc-400">
144
+ <input type="checkbox" v-model="showCrossOnly" class="accent-purple-400" />
145
+ Cross-service only
146
+ </label>
147
+ </div>
148
+ <div class="flex-1">
149
+ <VueFlow
150
+ :nodes="filtered.nodes"
151
+ :edges="filtered.edges"
152
+ :default-viewport="{ x: 0, y: 0, zoom: 0.85 }"
153
+ :min-zoom="0.3"
154
+ :max-zoom="2"
155
+ :fit-view-on-init="true"
156
+ :nodes-draggable="false"
157
+ :nodes-connectable="false"
158
+ >
159
+ <Background pattern-color="#3f3f46" :gap="20" />
160
+ <Controls />
161
+ </VueFlow>
162
+ </div>
163
+ </div>
164
+ </template>