@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.
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/components.json +19 -0
- package/index.html +12 -0
- package/package.json +66 -0
- package/src/App.vue +305 -0
- package/src/components/EmptyState.stories.ts +53 -0
- package/src/components/EmptyState.vue +28 -0
- package/src/components/ErrorBoundary.vue +60 -0
- package/src/components/FilterInput.stories.ts +32 -0
- package/src/components/FilterInput.vue +33 -0
- package/src/components/JsonView.stories.ts +38 -0
- package/src/components/JsonView.vue +34 -0
- package/src/components/KindBadge.stories.ts +72 -0
- package/src/components/KindBadge.vue +59 -0
- package/src/components/ListRow.stories.ts +56 -0
- package/src/components/ListRow.vue +48 -0
- package/src/components/MasterDetail.stories.ts +74 -0
- package/src/components/MasterDetail.vue +35 -0
- package/src/components/MonacoViewer.vue +143 -0
- package/src/components/PageHeader.stories.ts +45 -0
- package/src/components/PageHeader.vue +46 -0
- package/src/components/SchemaNode.vue +208 -0
- package/src/components/SchemaTree.vue +65 -0
- package/src/components/SourceDrawer.vue +136 -0
- package/src/components/SourcePill.vue +103 -0
- package/src/components/__tests__/EmptyState.test.ts +28 -0
- package/src/components/__tests__/ErrorBoundary.test.ts +52 -0
- package/src/components/__tests__/FilterInput.test.ts +38 -0
- package/src/components/__tests__/JsonView.test.ts +33 -0
- package/src/components/__tests__/KindBadge.test.ts +39 -0
- package/src/components/__tests__/ListRow.test.ts +39 -0
- package/src/components/__tests__/MasterDetail.test.ts +40 -0
- package/src/components/__tests__/PageHeader.test.ts +42 -0
- package/src/components/index.ts +17 -0
- package/src/components/ui/badge/Badge.vue +17 -0
- package/src/components/ui/badge/index.ts +25 -0
- package/src/components/ui/button/Button.vue +28 -0
- package/src/components/ui/button/index.ts +34 -0
- package/src/components/ui/card/Card.vue +14 -0
- package/src/components/ui/card/CardContent.vue +14 -0
- package/src/components/ui/card/CardDescription.vue +14 -0
- package/src/components/ui/card/CardFooter.vue +14 -0
- package/src/components/ui/card/CardHeader.vue +14 -0
- package/src/components/ui/card/CardTitle.vue +14 -0
- package/src/components/ui/card/index.ts +6 -0
- package/src/components/ui/dialog/Dialog.vue +15 -0
- package/src/components/ui/dialog/DialogClose.vue +12 -0
- package/src/components/ui/dialog/DialogContent.vue +47 -0
- package/src/components/ui/dialog/DialogDescription.vue +22 -0
- package/src/components/ui/dialog/DialogFooter.vue +12 -0
- package/src/components/ui/dialog/DialogHeader.vue +14 -0
- package/src/components/ui/dialog/DialogScrollContent.vue +60 -0
- package/src/components/ui/dialog/DialogTitle.vue +22 -0
- package/src/components/ui/dialog/DialogTrigger.vue +12 -0
- package/src/components/ui/dialog/index.ts +9 -0
- package/src/components/ui/input/Input.vue +32 -0
- package/src/components/ui/input/index.ts +1 -0
- package/src/components/ui/scroll-area/ScrollArea.vue +22 -0
- package/src/components/ui/scroll-area/ScrollBar.vue +32 -0
- package/src/components/ui/scroll-area/index.ts +2 -0
- package/src/components/ui/separator/Separator.vue +27 -0
- package/src/components/ui/separator/index.ts +1 -0
- package/src/components/ui/skeleton/Skeleton.vue +14 -0
- package/src/components/ui/skeleton/index.ts +1 -0
- package/src/components/ui/tabs/Tabs.vue +15 -0
- package/src/components/ui/tabs/TabsContent.vue +25 -0
- package/src/components/ui/tabs/TabsList.vue +25 -0
- package/src/components/ui/tabs/TabsTrigger.vue +29 -0
- package/src/components/ui/tabs/index.ts +4 -0
- package/src/components/ui/tooltip/Tooltip.vue +15 -0
- package/src/components/ui/tooltip/TooltipContent.vue +40 -0
- package/src/components/ui/tooltip/TooltipProvider.vue +12 -0
- package/src/components/ui/tooltip/TooltipTrigger.vue +12 -0
- package/src/components/ui/tooltip/index.ts +4 -0
- package/src/composables/useCopy.ts +31 -0
- package/src/lib/__tests__/normalize-cache.test.ts +104 -0
- package/src/lib/cache.ts +334 -0
- package/src/lib/normalize-cache.ts +92 -0
- package/src/lib/project-catalog.ts +125 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.ts +112 -0
- package/src/pages/Actions.vue +180 -0
- package/src/pages/Commands.vue +262 -0
- package/src/pages/Dispatch.vue +431 -0
- package/src/pages/Events.vue +166 -0
- package/src/pages/Home.stories.ts +47 -0
- package/src/pages/Home.vue +485 -0
- package/src/pages/Hooks.vue +297 -0
- package/src/pages/Live.vue +249 -0
- package/src/pages/Modules.vue +174 -0
- package/src/pages/Overview.vue +159 -0
- package/src/pages/Plugins.stories.ts +44 -0
- package/src/pages/Plugins.vue +403 -0
- package/src/pages/Projects.vue +272 -0
- package/src/pages/Run.vue +479 -0
- package/src/pages/Topology.vue +164 -0
- package/src/pages/Trace.vue +511 -0
- package/src/pages/TraceNode.vue +166 -0
- package/src/pages/Workflows.vue +191 -0
- package/src/pages/__tests__/Actions.test.ts +98 -0
- package/src/pages/__tests__/Home.test.ts +98 -0
- package/src/pages/__tests__/Hooks.test.ts +119 -0
- package/src/pages/__tests__/Plugins.test.ts +80 -0
- package/src/style.css +40 -0
- package/tsconfig.json +20 -0
- 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>
|