@nwire/studio 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/src/App.vue +62 -56
- package/src/components/BcCard.stories.ts +47 -0
- package/src/components/BcCard.vue +152 -0
- package/src/components/DurationBar.stories.ts +55 -0
- package/src/components/DurationBar.vue +72 -0
- package/src/components/ErrorCard.stories.ts +133 -0
- package/src/components/ErrorCard.vue +153 -0
- package/src/components/GraphCanvas.stories.ts +48 -0
- package/src/components/GraphCanvas.vue +88 -0
- package/src/components/KpiTile.stories.ts +32 -0
- package/src/components/KpiTile.vue +39 -0
- package/src/components/LiveTable.stories.ts +78 -0
- package/src/components/LiveTable.vue +186 -0
- package/src/components/MetadataInspector.stories.ts +53 -0
- package/src/components/MetadataInspector.vue +105 -0
- package/src/components/NodeCard.stories.ts +44 -0
- package/src/components/NodeCard.vue +150 -0
- package/src/components/RcaPanel.stories.ts +95 -0
- package/src/components/RcaPanel.vue +223 -0
- package/src/components/ServiceNode.vue +134 -0
- package/src/components/SourceDrawer.vue +6 -4
- package/src/components/SourcePill.vue +10 -3
- package/src/components/StatusBadge.stories.ts +33 -0
- package/src/components/StatusBadge.vue +54 -0
- package/src/components/Waterfall.stories.ts +85 -0
- package/src/components/Waterfall.vue +53 -0
- package/src/components/WaterfallRow.vue +74 -0
- package/src/components/__tests__/BcCard.test.ts +53 -0
- package/src/components/__tests__/DurationBar.test.ts +31 -0
- package/src/components/__tests__/ErrorCard.test.ts +71 -0
- package/src/components/__tests__/KpiTile.test.ts +23 -0
- package/src/components/__tests__/LiveTable.test.ts +100 -0
- package/src/components/__tests__/MetadataInspector.test.ts +38 -0
- package/src/components/__tests__/NodeCard.test.ts +116 -0
- package/src/components/__tests__/RcaPanel.test.ts +81 -0
- package/src/components/__tests__/StatusBadge.test.ts +23 -0
- package/src/components/__tests__/Waterfall.test.ts +54 -0
- package/src/components/index.ts +13 -0
- package/src/composables/__tests__/composables-context.test.ts +107 -0
- package/src/composables/__tests__/useTelemetry.test.ts +104 -0
- package/src/composables/__tests__/useTelemetryRuns.test.ts +282 -0
- package/src/composables/useDiscovery.ts +73 -0
- package/src/composables/useEndpoints.ts +94 -0
- package/src/composables/useLogTail.ts +51 -0
- package/src/composables/useManifest.ts +43 -0
- package/src/composables/useProcesses.ts +114 -0
- package/src/composables/useProject.ts +34 -0
- package/src/composables/useTelemetry.ts +270 -0
- package/src/lib/__tests__/bc-graph.test.ts +218 -0
- package/src/lib/__tests__/dispatch-form.test.ts +113 -0
- package/src/lib/__tests__/error-friendly.test.ts +198 -0
- package/src/lib/__tests__/home.test.ts +231 -0
- package/src/lib/__tests__/inspect.test.ts +160 -0
- package/src/lib/__tests__/kind-colors.test.ts +59 -0
- package/src/lib/__tests__/live-table.test.ts +194 -0
- package/src/lib/__tests__/manifest-health.test.ts +120 -0
- package/src/lib/__tests__/manifest.test.ts +87 -0
- package/src/lib/__tests__/metadata.test.ts +47 -0
- package/src/lib/__tests__/node-metrics.test.ts +144 -0
- package/src/lib/__tests__/operate.test.ts +97 -0
- package/src/lib/__tests__/pipeline-flow.test.ts +79 -0
- package/src/lib/__tests__/rca.test.ts +124 -0
- package/src/lib/__tests__/telemetry.test.ts +91 -0
- package/src/lib/__tests__/topology-graph.test.ts +331 -0
- package/src/lib/__tests__/topology-view.test.ts +154 -0
- package/src/lib/__tests__/waterfall.test.ts +165 -0
- package/src/lib/bc-graph.ts +298 -0
- package/src/lib/dispatch-form.ts +160 -0
- package/src/lib/error-friendly.ts +288 -0
- package/src/lib/home.ts +191 -0
- package/src/lib/inspect.ts +226 -0
- package/src/lib/kind-colors.ts +132 -0
- package/src/lib/live-table.ts +204 -0
- package/src/lib/manifest-health.ts +71 -0
- package/src/lib/manifest.ts +139 -0
- package/src/lib/metadata.ts +52 -0
- package/src/lib/node-metrics.ts +242 -0
- package/src/lib/operate.ts +114 -0
- package/src/lib/pipeline-flow.ts +120 -0
- package/src/lib/rca.ts +193 -0
- package/src/lib/telemetry.ts +155 -0
- package/src/lib/topology-graph.ts +551 -0
- package/src/lib/topology-view.ts +185 -0
- package/src/lib/waterfall.ts +148 -0
- package/src/main.ts +63 -29
- package/src/pages/Errors.vue +272 -0
- package/src/pages/Home.stories.ts +7 -8
- package/src/pages/Home.vue +255 -540
- package/src/pages/Hooks.stories.ts +44 -0
- package/src/pages/Hooks.vue +165 -164
- package/src/pages/Inspect.vue +240 -0
- package/src/pages/Map.vue +187 -0
- package/src/pages/Operate.vue +74 -0
- package/src/pages/Plugins.stories.ts +1 -1
- package/src/pages/Plugins.vue +174 -238
- package/src/pages/Projects.vue +62 -60
- package/src/pages/Streams.vue +344 -0
- package/src/pages/Topology.vue +318 -136
- package/src/pages/Trace.vue +174 -412
- package/src/pages/__tests__/Home.test.ts +109 -54
- package/src/pages/__tests__/Hooks.test.ts +5 -5
- package/src/pages/__tests__/Inspect.test.ts +111 -0
- package/src/pages/__tests__/Plugins.test.ts +85 -35
- package/src/pages/__tests__/Trace.test.ts +117 -0
- package/src/pages/operate/CommandsPanel.vue +186 -0
- package/src/pages/{Dispatch.vue → operate/DispatchPanel.vue} +100 -203
- package/src/pages/operate/EndpointPicker.vue +56 -0
- package/src/pages/operate/RunPanel.vue +316 -0
- package/src/server/__tests__/nwire-read.test.ts +80 -0
- package/src/server/nwire-read.ts +63 -0
- package/vite.config.ts +220 -2
- package/src/lib/__tests__/normalize-cache.test.ts +0 -105
- package/src/lib/cache.ts +0 -312
- package/src/lib/normalize-cache.ts +0 -92
- package/src/pages/Actions.vue +0 -171
- package/src/pages/Apps.vue +0 -177
- package/src/pages/Commands.vue +0 -262
- package/src/pages/Events.vue +0 -210
- package/src/pages/Live.vue +0 -249
- package/src/pages/Overview.vue +0 -161
- package/src/pages/Projections.vue +0 -148
- package/src/pages/Queries.vue +0 -148
- package/src/pages/Run.vue +0 -618
- package/src/pages/Sinks.vue +0 -124
- package/src/pages/TraceNode.vue +0 -164
- package/src/pages/Workflows.vue +0 -184
- package/src/pages/__tests__/Actions.test.ts +0 -98
- package/src/pages/__tests__/Projections.test.ts +0 -90
- package/src/pages/__tests__/Queries.test.ts +0 -86
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Operate › EndpointPicker — switch the active HTTP front.
|
|
4
|
+
*
|
|
5
|
+
* Fetches `/__nwire/endpoints` and renders a compact selector. In a
|
|
6
|
+
* single-endpoint project the picker collapses to a plain label (one entry,
|
|
7
|
+
* already active — no action needed). In multi-endpoint projects it lets you
|
|
8
|
+
* choose which endpoint fronts the HTTP surface via `/__nwire/endpoints/active`.
|
|
9
|
+
*
|
|
10
|
+
* Hidden entirely when the host reports no endpoints (e.g. standalone Studio
|
|
11
|
+
* where the route returns 501 and the composable yields an empty list).
|
|
12
|
+
*/
|
|
13
|
+
import { computed } from "vue";
|
|
14
|
+
import { Loader2, Radio } from "lucide-vue-next";
|
|
15
|
+
import { useEndpoints } from "@/composables/useEndpoints";
|
|
16
|
+
|
|
17
|
+
const { endpoints, loading, error, switching, setActive } = useEndpoints();
|
|
18
|
+
|
|
19
|
+
/** True when there is more than one endpoint — only then is the picker interactive. */
|
|
20
|
+
const hasChoice = computed(() => endpoints.value.length > 1);
|
|
21
|
+
const activeName = computed(() => endpoints.value.find((e) => e.active)?.name ?? null);
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<!-- Hidden when the host returned no endpoints (standalone Studio / unsupported host). -->
|
|
26
|
+
<div v-if="endpoints.length > 0 || loading" class="flex items-center gap-2 min-w-0">
|
|
27
|
+
<Radio class="w-3.5 h-3.5 text-zinc-500 shrink-0" />
|
|
28
|
+
|
|
29
|
+
<!-- Single endpoint — just a label, no interaction. -->
|
|
30
|
+
<span v-if="!hasChoice && activeName" class="text-xs text-zinc-400 font-mono truncate">
|
|
31
|
+
{{ activeName }}
|
|
32
|
+
</span>
|
|
33
|
+
|
|
34
|
+
<!-- Multi-endpoint — a native select (lean, no custom dropdown needed). -->
|
|
35
|
+
<select
|
|
36
|
+
v-else-if="hasChoice"
|
|
37
|
+
class="bg-zinc-900 border border-zinc-800 rounded px-2 py-1 text-xs font-mono text-zinc-200 focus:outline-none focus:border-zinc-600 disabled:opacity-50 cursor-pointer"
|
|
38
|
+
:disabled="switching"
|
|
39
|
+
:value="activeName"
|
|
40
|
+
data-testid="endpoint-picker-select"
|
|
41
|
+
@change="setActive(($event.target as HTMLSelectElement).value)"
|
|
42
|
+
>
|
|
43
|
+
<option
|
|
44
|
+
v-for="ep in endpoints"
|
|
45
|
+
:key="ep.name"
|
|
46
|
+
:value="ep.name"
|
|
47
|
+
:class="ep.hasHttp ? '' : 'text-zinc-500'"
|
|
48
|
+
>
|
|
49
|
+
{{ ep.name }}{{ ep.hasHttp ? "" : " (no HTTP)" }}
|
|
50
|
+
</option>
|
|
51
|
+
</select>
|
|
52
|
+
|
|
53
|
+
<Loader2 v-if="switching || loading" class="w-3 h-3 animate-spin text-zinc-500 shrink-0" />
|
|
54
|
+
<span v-if="error" class="text-[10px] text-rose-400 truncate">{{ error }}</span>
|
|
55
|
+
</div>
|
|
56
|
+
</template>
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Operate › Run — spawn a wire from a topology manifest or a package script,
|
|
4
|
+
* watch the managed-process table, tail the selected process's stdout.
|
|
5
|
+
*
|
|
6
|
+
* Process lifecycle rides `useProcesses`; the log tail rides `useLogTail`;
|
|
7
|
+
* the maths (env parse, script order, status tone, active wire) live in
|
|
8
|
+
* `lib/operate`. The panel owns only the topology/script catalogs + layout.
|
|
9
|
+
*/
|
|
10
|
+
import { computed, onMounted, ref } from "vue";
|
|
11
|
+
import { Play, Square, RefreshCw, Trash2, Loader2, AlertTriangle, Terminal } from "lucide-vue-next";
|
|
12
|
+
import { StatusBadge, EmptyState } from "@/components";
|
|
13
|
+
import { useProcesses } from "@/composables/useProcesses";
|
|
14
|
+
import { useLogTail } from "@/composables/useLogTail";
|
|
15
|
+
import {
|
|
16
|
+
parseEnvInput,
|
|
17
|
+
sortScripts,
|
|
18
|
+
statusTone,
|
|
19
|
+
shortId,
|
|
20
|
+
timeAgo,
|
|
21
|
+
isActive,
|
|
22
|
+
activeProcess,
|
|
23
|
+
type ScriptEntry,
|
|
24
|
+
} from "@/lib/operate";
|
|
25
|
+
|
|
26
|
+
const { processes, error: procError, load, start, execScript, stop, forget } = useProcesses();
|
|
27
|
+
|
|
28
|
+
const topologies = ref<string[]>([]);
|
|
29
|
+
const scripts = ref<ScriptEntry[]>([]);
|
|
30
|
+
const scriptBusy = ref<string | null>(null);
|
|
31
|
+
const startTopology = ref<string>("");
|
|
32
|
+
const startPort = ref<number>(3000);
|
|
33
|
+
const startBusy = ref(false);
|
|
34
|
+
const envInput = ref<string>("");
|
|
35
|
+
const envOpen = ref(false);
|
|
36
|
+
|
|
37
|
+
const selectedId = ref<string | null>(null);
|
|
38
|
+
const { logs } = useLogTail(selectedId);
|
|
39
|
+
|
|
40
|
+
const sortedScripts = computed(() => sortScripts(scripts.value));
|
|
41
|
+
const selectedProcess = computed(() => processes.value.find((p) => p.id === selectedId.value));
|
|
42
|
+
const active = computed(() => activeProcess(processes.value));
|
|
43
|
+
const envCount = computed(() => Object.keys(parseEnvInput(envInput.value)).length);
|
|
44
|
+
|
|
45
|
+
async function loadCatalogs(): Promise<void> {
|
|
46
|
+
try {
|
|
47
|
+
const [t, s] = await Promise.all([
|
|
48
|
+
fetch("/__nwire/run/topologies").then((r) => r.json()),
|
|
49
|
+
fetch("/__nwire/run/scripts").then((r) => r.json()),
|
|
50
|
+
]);
|
|
51
|
+
topologies.value = (t as { topologies: string[] }).topologies ?? [];
|
|
52
|
+
scripts.value = (s as { scripts: ScriptEntry[] }).scripts ?? [];
|
|
53
|
+
if (!startTopology.value && topologies.value[0]) startTopology.value = topologies.value[0];
|
|
54
|
+
} catch {
|
|
55
|
+
topologies.value = [];
|
|
56
|
+
scripts.value = [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function env(): Record<string, string> | undefined {
|
|
61
|
+
const e = parseEnvInput(envInput.value);
|
|
62
|
+
return Object.keys(e).length ? e : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function onStart(): Promise<void> {
|
|
66
|
+
if (!startTopology.value) return;
|
|
67
|
+
startBusy.value = true;
|
|
68
|
+
const proc = await start({
|
|
69
|
+
topology: startTopology.value,
|
|
70
|
+
port: startPort.value,
|
|
71
|
+
env: env(),
|
|
72
|
+
});
|
|
73
|
+
if (proc) selectedId.value = proc.id;
|
|
74
|
+
startBusy.value = false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function onRunScript(name: string): Promise<void> {
|
|
78
|
+
scriptBusy.value = name;
|
|
79
|
+
const proc = await execScript({
|
|
80
|
+
script: name,
|
|
81
|
+
port: startPort.value > 0 ? startPort.value : undefined,
|
|
82
|
+
env: env(),
|
|
83
|
+
});
|
|
84
|
+
if (proc) selectedId.value = proc.id;
|
|
85
|
+
scriptBusy.value = null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function onForget(id: string): Promise<void> {
|
|
89
|
+
if (selectedId.value === id) selectedId.value = null;
|
|
90
|
+
await forget(id);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
onMounted(loadCatalogs);
|
|
94
|
+
</script>
|
|
95
|
+
|
|
96
|
+
<template>
|
|
97
|
+
<div class="h-full flex" data-testid="run-panel">
|
|
98
|
+
<!-- Launcher -->
|
|
99
|
+
<div class="w-72 border-r border-zinc-800 flex flex-col overflow-auto">
|
|
100
|
+
<div class="border-b border-zinc-800 px-4 py-3">
|
|
101
|
+
<p class="text-xs text-zinc-500">Spawn a wire from a topology or script.</p>
|
|
102
|
+
<div class="mt-2 flex items-center gap-1.5 text-[10px] font-mono">
|
|
103
|
+
<template v-if="active">
|
|
104
|
+
<span class="inline-block w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
|
105
|
+
<span class="text-emerald-300">/_nwire/* → :{{ active.port }}</span>
|
|
106
|
+
<span class="text-zinc-600">({{ active.topology }})</span>
|
|
107
|
+
</template>
|
|
108
|
+
<span v-else class="text-zinc-600">/_nwire/* → static fallback</span>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<div
|
|
113
|
+
v-if="topologies.length === 0 && scripts.length === 0"
|
|
114
|
+
class="p-4 text-xs text-amber-300 flex items-start gap-2 border-b border-zinc-800"
|
|
115
|
+
>
|
|
116
|
+
<AlertTriangle class="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
|
117
|
+
<div>
|
|
118
|
+
<div class="font-medium">No way to start this project</div>
|
|
119
|
+
<div class="text-zinc-500 mt-1">
|
|
120
|
+
Add a <code class="text-zinc-300">dev</code> script to package.json, or an
|
|
121
|
+
<code class="text-zinc-300">apps/topologies/*.topology.ts</code>, then refresh.
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<div v-if="topologies.length > 0" class="p-4 space-y-3 border-b border-zinc-800">
|
|
127
|
+
<div>
|
|
128
|
+
<label class="text-[10px] uppercase tracking-wide text-zinc-500">Topology</label>
|
|
129
|
+
<select
|
|
130
|
+
v-model="startTopology"
|
|
131
|
+
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"
|
|
132
|
+
>
|
|
133
|
+
<option v-for="t in topologies" :key="t" :value="t">{{ t }}</option>
|
|
134
|
+
</select>
|
|
135
|
+
</div>
|
|
136
|
+
<div>
|
|
137
|
+
<label class="text-[10px] uppercase tracking-wide text-zinc-500">Port</label>
|
|
138
|
+
<input
|
|
139
|
+
v-model.number="startPort"
|
|
140
|
+
type="number"
|
|
141
|
+
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"
|
|
142
|
+
/>
|
|
143
|
+
</div>
|
|
144
|
+
<button
|
|
145
|
+
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"
|
|
146
|
+
:disabled="startBusy || !startTopology"
|
|
147
|
+
data-testid="run-start"
|
|
148
|
+
@click="onStart"
|
|
149
|
+
>
|
|
150
|
+
<Loader2 v-if="startBusy" class="w-4 h-4 animate-spin" />
|
|
151
|
+
<Play v-else class="w-4 h-4" />
|
|
152
|
+
{{ startBusy ? "Starting…" : "Start" }}
|
|
153
|
+
</button>
|
|
154
|
+
<div
|
|
155
|
+
v-if="procError"
|
|
156
|
+
class="text-xs text-rose-300 bg-rose-950/40 border border-rose-900 rounded p-2"
|
|
157
|
+
>
|
|
158
|
+
{{ procError }}
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<!-- Env overrides -->
|
|
163
|
+
<div v-if="scripts.length || topologies.length" class="border-b border-zinc-800 px-4 py-3">
|
|
164
|
+
<button
|
|
165
|
+
class="w-full flex items-center justify-between text-[10px] uppercase tracking-wide text-zinc-500 hover:text-zinc-300"
|
|
166
|
+
@click="envOpen = !envOpen"
|
|
167
|
+
>
|
|
168
|
+
<span>env + port overrides</span>
|
|
169
|
+
<span v-if="envCount" class="text-emerald-300 normal-case">{{ envCount }} set</span>
|
|
170
|
+
</button>
|
|
171
|
+
<textarea
|
|
172
|
+
v-if="envOpen"
|
|
173
|
+
v-model="envInput"
|
|
174
|
+
rows="4"
|
|
175
|
+
placeholder="LOG_LEVEL=debug NODE_ENV=development"
|
|
176
|
+
class="w-full mt-2 bg-zinc-900 border border-zinc-800 rounded px-2 py-1.5 text-xs font-mono focus:outline-none focus:border-zinc-600"
|
|
177
|
+
></textarea>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<!-- package.json scripts -->
|
|
181
|
+
<div v-if="scripts.length" class="px-4 py-3">
|
|
182
|
+
<div class="flex items-center justify-between mb-2">
|
|
183
|
+
<label class="text-[10px] uppercase tracking-wide text-zinc-500"
|
|
184
|
+
>package.json scripts</label
|
|
185
|
+
>
|
|
186
|
+
<button class="text-zinc-500 hover:text-zinc-200" title="Refresh" @click="loadCatalogs">
|
|
187
|
+
<RefreshCw class="w-3 h-3" />
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
<div class="space-y-1">
|
|
191
|
+
<button
|
|
192
|
+
v-for="s in sortedScripts"
|
|
193
|
+
:key="s.name"
|
|
194
|
+
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"
|
|
195
|
+
:disabled="scriptBusy === s.name"
|
|
196
|
+
:title="s.command"
|
|
197
|
+
@click="onRunScript(s.name)"
|
|
198
|
+
>
|
|
199
|
+
<Loader2 v-if="scriptBusy === s.name" class="w-3 h-3 animate-spin text-emerald-400" />
|
|
200
|
+
<Play v-else class="w-3 h-3 text-zinc-600 group-hover:text-emerald-400" />
|
|
201
|
+
<span class="text-xs font-mono text-zinc-200">{{ s.name }}</span>
|
|
202
|
+
<span class="text-[10px] text-zinc-600 truncate flex-1 text-right">{{
|
|
203
|
+
s.command
|
|
204
|
+
}}</span>
|
|
205
|
+
</button>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
<!-- Processes -->
|
|
211
|
+
<div class="w-96 border-r border-zinc-800 flex flex-col">
|
|
212
|
+
<div class="border-b border-zinc-800 px-4 py-3 flex items-center justify-between">
|
|
213
|
+
<h2 class="font-medium text-sm tracking-tight">Processes</h2>
|
|
214
|
+
<button class="text-zinc-500 hover:text-zinc-200" title="Refresh" @click="load">
|
|
215
|
+
<RefreshCw class="w-3.5 h-3.5" />
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
218
|
+
<div class="flex-1 overflow-auto">
|
|
219
|
+
<EmptyState
|
|
220
|
+
v-if="processes.length === 0"
|
|
221
|
+
:icon="Play"
|
|
222
|
+
title="No processes"
|
|
223
|
+
hint="Pick a topology or script and start it — the managed process shows up here."
|
|
224
|
+
/>
|
|
225
|
+
<button
|
|
226
|
+
v-for="p in processes"
|
|
227
|
+
:key="p.id"
|
|
228
|
+
class="w-full text-left px-4 py-3 border-b border-zinc-900 hover:bg-zinc-900/50 transition-colors"
|
|
229
|
+
:class="{ 'bg-zinc-900/70': selectedId === p.id }"
|
|
230
|
+
data-testid="process-row"
|
|
231
|
+
@click="selectedId = p.id"
|
|
232
|
+
>
|
|
233
|
+
<div class="flex items-center justify-between gap-2">
|
|
234
|
+
<div class="flex items-center gap-2 min-w-0">
|
|
235
|
+
<StatusBadge
|
|
236
|
+
:status="statusTone(p.status)"
|
|
237
|
+
:label="p.status"
|
|
238
|
+
:pulse="p.status === 'running'"
|
|
239
|
+
/>
|
|
240
|
+
<span class="font-mono text-sm truncate">{{ p.topology }}</span>
|
|
241
|
+
<span v-if="p.port" class="text-[10px] text-zinc-500">:{{ p.port }}</span>
|
|
242
|
+
</div>
|
|
243
|
+
<span class="text-[10px] text-zinc-500 shrink-0">{{
|
|
244
|
+
timeAgo(p.startedAt, Date.now())
|
|
245
|
+
}}</span>
|
|
246
|
+
</div>
|
|
247
|
+
<div class="text-[10px] text-zinc-500 mt-1 font-mono flex items-center gap-2 flex-wrap">
|
|
248
|
+
<span v-if="p.pid">pid {{ p.pid }}</span>
|
|
249
|
+
<span class="text-zinc-600">{{ shortId(p.id) }}</span>
|
|
250
|
+
<span
|
|
251
|
+
v-if="p.source === 'external'"
|
|
252
|
+
class="text-[9px] uppercase tracking-wider px-1 rounded bg-amber-950/40 text-amber-300 border border-amber-900"
|
|
253
|
+
>external</span
|
|
254
|
+
>
|
|
255
|
+
</div>
|
|
256
|
+
<div v-if="p.errorMessage" class="text-[10px] text-rose-300 mt-1">
|
|
257
|
+
{{ p.errorMessage }}
|
|
258
|
+
</div>
|
|
259
|
+
</button>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
<!-- Logs -->
|
|
264
|
+
<div class="flex-1 flex flex-col min-w-0">
|
|
265
|
+
<div class="border-b border-zinc-800 px-4 py-3 flex items-center justify-between gap-3">
|
|
266
|
+
<div class="flex items-center gap-2 min-w-0">
|
|
267
|
+
<Terminal class="w-4 h-4 text-zinc-400 shrink-0" />
|
|
268
|
+
<h2 class="font-medium text-sm tracking-tight truncate">
|
|
269
|
+
<template v-if="selectedProcess">
|
|
270
|
+
{{ selectedProcess.topology }}
|
|
271
|
+
<span class="text-zinc-500 font-mono text-xs">{{ shortId(selectedProcess.id) }}</span>
|
|
272
|
+
</template>
|
|
273
|
+
<span v-else class="text-zinc-500">Select a process</span>
|
|
274
|
+
</h2>
|
|
275
|
+
</div>
|
|
276
|
+
<div v-if="selectedProcess" class="flex items-center gap-2">
|
|
277
|
+
<button
|
|
278
|
+
v-if="isActive(selectedProcess.status)"
|
|
279
|
+
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"
|
|
280
|
+
@click="stop(selectedProcess.id)"
|
|
281
|
+
>
|
|
282
|
+
<Square class="w-3 h-3" /> stop
|
|
283
|
+
</button>
|
|
284
|
+
<button
|
|
285
|
+
v-else
|
|
286
|
+
class="text-xs px-2 py-1 rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-300 flex items-center gap-1"
|
|
287
|
+
@click="onForget(selectedProcess.id)"
|
|
288
|
+
>
|
|
289
|
+
<Trash2 class="w-3 h-3" /> clear
|
|
290
|
+
</button>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
<div class="flex-1 overflow-auto bg-zinc-950 font-mono text-[11px] leading-tight">
|
|
294
|
+
<div v-if="!selectedId" class="p-6 text-zinc-500 text-sm font-sans">
|
|
295
|
+
Pick a process to view its stdout/stderr.
|
|
296
|
+
</div>
|
|
297
|
+
<div v-else-if="logs.length === 0" class="p-6 text-zinc-500 text-sm font-sans">
|
|
298
|
+
Waiting for output…
|
|
299
|
+
</div>
|
|
300
|
+
<div v-else class="p-3 space-y-0.5">
|
|
301
|
+
<div
|
|
302
|
+
v-for="l in logs"
|
|
303
|
+
:key="l.seq"
|
|
304
|
+
class="flex gap-2"
|
|
305
|
+
:class="l.stream === 'stderr' ? 'text-rose-200' : 'text-zinc-300'"
|
|
306
|
+
>
|
|
307
|
+
<span class="text-zinc-600 tabular-nums shrink-0">
|
|
308
|
+
{{ new Date(l.ts).toLocaleTimeString() }}
|
|
309
|
+
</span>
|
|
310
|
+
<span class="whitespace-pre-wrap break-all">{{ l.line }}</span>
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
</template>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
// Telemetry-run readers (listTelemetryRuns / readTelemetryRun) are re-exported
|
|
6
|
+
// from `@nwire/scan` and covered by that package's telemetry-runs test. This
|
|
7
|
+
// file covers nwire-read's own surface: readManifest + foldTopology.
|
|
8
|
+
import { readManifest, foldTopology } from "../nwire-read";
|
|
9
|
+
|
|
10
|
+
// ── fixture helpers ───────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
let tmp: string;
|
|
13
|
+
|
|
14
|
+
function nwireDir(): string {
|
|
15
|
+
return resolve(tmp, ".nwire");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function writeManifest(content: string): void {
|
|
19
|
+
mkdirSync(nwireDir(), { recursive: true });
|
|
20
|
+
writeFileSync(resolve(nwireDir(), "manifest.json"), content, "utf8");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
tmp = resolve(tmpdir(), `nwire-read-test-${process.pid}-${Date.now()}`);
|
|
25
|
+
mkdirSync(tmp, { recursive: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// ── readManifest ─────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
describe("readManifest", () => {
|
|
35
|
+
it("returns null when .nwire/ does not exist", () => {
|
|
36
|
+
expect(readManifest(tmp)).toBeNull();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns null when manifest.json is missing", () => {
|
|
40
|
+
mkdirSync(nwireDir(), { recursive: true });
|
|
41
|
+
expect(readManifest(tmp)).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns the raw JSON string when file is present", () => {
|
|
45
|
+
const raw = JSON.stringify({ apps: [], actions: [] });
|
|
46
|
+
writeManifest(raw);
|
|
47
|
+
expect(readManifest(tmp)).toBe(raw);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("preserves exact bytes — no re-serialisation", () => {
|
|
51
|
+
const raw = '{"apps":[],"note":"trailing-space" }';
|
|
52
|
+
writeManifest(raw);
|
|
53
|
+
expect(readManifest(tmp)).toBe(raw);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// ── foldTopology ─────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
describe("foldTopology", () => {
|
|
60
|
+
it("returns null for null input", () => {
|
|
61
|
+
expect(foldTopology(null, tmp)).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns null for invalid JSON", () => {
|
|
65
|
+
expect(foldTopology("{broken json", tmp)).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("parses and returns the manifest object", () => {
|
|
69
|
+
const obj = { apps: [{ name: "orders" }], actions: [] };
|
|
70
|
+
const result = foldTopology(JSON.stringify(obj), tmp) as typeof obj;
|
|
71
|
+
expect(result).toEqual(obj);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("round-trips through readManifest correctly", () => {
|
|
75
|
+
const obj = { apps: [{ name: "billing" }], actions: [], events: [] };
|
|
76
|
+
writeManifest(JSON.stringify(obj));
|
|
77
|
+
const raw = readManifest(tmp);
|
|
78
|
+
expect(foldTopology(raw, tmp)).toEqual(obj);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, disk-read helpers for the standalone Studio Vite middleware.
|
|
3
|
+
*
|
|
4
|
+
* `readManifest` / `foldTopology` are the Studio-side manifest readers (raw
|
|
5
|
+
* string + parse). The telemetry-run readers are re-exported from `@nwire/scan`
|
|
6
|
+
* (the shared home — see the re-export below), so the CLI dev host and Studio
|
|
7
|
+
* run identical code without either package importing the other.
|
|
8
|
+
*
|
|
9
|
+
* All functions are synchronous, have no side effects beyond filesystem reads,
|
|
10
|
+
* and accept an explicit `cwd` so callers can target any project root without
|
|
11
|
+
* touching process state.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
15
|
+
import { resolve } from "node:path";
|
|
16
|
+
|
|
17
|
+
// Telemetry-run readers live in `@nwire/scan` so the standalone Studio Vite
|
|
18
|
+
// middleware and the CLI dev host share one implementation (both depend on
|
|
19
|
+
// scan; neither imports the other). Re-exported here so the local callers keep
|
|
20
|
+
// a single `nwire-read` import.
|
|
21
|
+
export { listTelemetryRuns, readTelemetryRun, type TelemetryRunMeta } from "@nwire/scan";
|
|
22
|
+
|
|
23
|
+
// ── readManifest ─────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read `.nwire/manifest.json` from `cwd` and return the raw JSON string.
|
|
27
|
+
*
|
|
28
|
+
* Returns `null` when the file does not exist or cannot be read. Callers that
|
|
29
|
+
* need the parsed object can `JSON.parse` the result; returning the raw string
|
|
30
|
+
* lets the Vite middleware forward bytes without a round-trip through JSON.
|
|
31
|
+
*/
|
|
32
|
+
export function readManifest(cwd: string): string | null {
|
|
33
|
+
const manifestPath = resolve(cwd, ".nwire", "manifest.json");
|
|
34
|
+
if (!existsSync(manifestPath)) return null;
|
|
35
|
+
try {
|
|
36
|
+
return readFileSync(manifestPath, "utf8");
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── foldTopology ─────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse the manifest JSON from disk and (in future increments) fold in live
|
|
46
|
+
* runtime topology data from the running app.
|
|
47
|
+
*
|
|
48
|
+
* Today: parses and returns the disk manifest as-is. The "fold" step — merging
|
|
49
|
+
* live handler registrations from a running runtime so the manifest stays
|
|
50
|
+
* accurate during HMR — is reserved for a future increment. The function
|
|
51
|
+
* signature is sealed now so both callers can adopt it without a breaking
|
|
52
|
+
* change later.
|
|
53
|
+
*
|
|
54
|
+
* Returns `null` when no manifest is present or the file is not valid JSON.
|
|
55
|
+
*/
|
|
56
|
+
export function foldTopology(manifest: string | null, _cwd: string): unknown | null {
|
|
57
|
+
if (manifest === null) return null;
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(manifest) as unknown;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|