@nwire/studio 0.12.1 → 0.13.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 (130) hide show
  1. package/package.json +5 -3
  2. package/src/App.vue +62 -56
  3. package/src/components/BcCard.stories.ts +47 -0
  4. package/src/components/BcCard.vue +152 -0
  5. package/src/components/DurationBar.stories.ts +55 -0
  6. package/src/components/DurationBar.vue +72 -0
  7. package/src/components/ErrorCard.stories.ts +133 -0
  8. package/src/components/ErrorCard.vue +153 -0
  9. package/src/components/GraphCanvas.stories.ts +48 -0
  10. package/src/components/GraphCanvas.vue +88 -0
  11. package/src/components/KpiTile.stories.ts +32 -0
  12. package/src/components/KpiTile.vue +39 -0
  13. package/src/components/LiveTable.stories.ts +78 -0
  14. package/src/components/LiveTable.vue +186 -0
  15. package/src/components/MetadataInspector.stories.ts +53 -0
  16. package/src/components/MetadataInspector.vue +105 -0
  17. package/src/components/NodeCard.stories.ts +44 -0
  18. package/src/components/NodeCard.vue +150 -0
  19. package/src/components/RcaPanel.stories.ts +95 -0
  20. package/src/components/RcaPanel.vue +223 -0
  21. package/src/components/ServiceNode.vue +134 -0
  22. package/src/components/SourceDrawer.vue +6 -4
  23. package/src/components/SourcePill.vue +10 -3
  24. package/src/components/StatusBadge.stories.ts +33 -0
  25. package/src/components/StatusBadge.vue +54 -0
  26. package/src/components/Waterfall.stories.ts +85 -0
  27. package/src/components/Waterfall.vue +53 -0
  28. package/src/components/WaterfallRow.vue +74 -0
  29. package/src/components/__tests__/BcCard.test.ts +53 -0
  30. package/src/components/__tests__/DurationBar.test.ts +31 -0
  31. package/src/components/__tests__/ErrorCard.test.ts +71 -0
  32. package/src/components/__tests__/KpiTile.test.ts +23 -0
  33. package/src/components/__tests__/LiveTable.test.ts +100 -0
  34. package/src/components/__tests__/MetadataInspector.test.ts +38 -0
  35. package/src/components/__tests__/NodeCard.test.ts +116 -0
  36. package/src/components/__tests__/RcaPanel.test.ts +81 -0
  37. package/src/components/__tests__/StatusBadge.test.ts +23 -0
  38. package/src/components/__tests__/Waterfall.test.ts +54 -0
  39. package/src/components/index.ts +13 -0
  40. package/src/composables/__tests__/composables-context.test.ts +107 -0
  41. package/src/composables/__tests__/useTelemetry.test.ts +104 -0
  42. package/src/composables/__tests__/useTelemetryRuns.test.ts +282 -0
  43. package/src/composables/useDiscovery.ts +73 -0
  44. package/src/composables/useEndpoints.ts +94 -0
  45. package/src/composables/useLogTail.ts +51 -0
  46. package/src/composables/useManifest.ts +43 -0
  47. package/src/composables/useProcesses.ts +114 -0
  48. package/src/composables/useProject.ts +34 -0
  49. package/src/composables/useTelemetry.ts +270 -0
  50. package/src/lib/__tests__/bc-graph.test.ts +218 -0
  51. package/src/lib/__tests__/dispatch-form.test.ts +113 -0
  52. package/src/lib/__tests__/error-friendly.test.ts +198 -0
  53. package/src/lib/__tests__/home.test.ts +231 -0
  54. package/src/lib/__tests__/inspect.test.ts +160 -0
  55. package/src/lib/__tests__/kind-colors.test.ts +59 -0
  56. package/src/lib/__tests__/live-table.test.ts +194 -0
  57. package/src/lib/__tests__/manifest-health.test.ts +120 -0
  58. package/src/lib/__tests__/manifest.test.ts +87 -0
  59. package/src/lib/__tests__/metadata.test.ts +47 -0
  60. package/src/lib/__tests__/node-metrics.test.ts +144 -0
  61. package/src/lib/__tests__/operate.test.ts +97 -0
  62. package/src/lib/__tests__/pipeline-flow.test.ts +79 -0
  63. package/src/lib/__tests__/rca.test.ts +124 -0
  64. package/src/lib/__tests__/telemetry.test.ts +91 -0
  65. package/src/lib/__tests__/topology-graph.test.ts +331 -0
  66. package/src/lib/__tests__/topology-view.test.ts +154 -0
  67. package/src/lib/__tests__/waterfall.test.ts +165 -0
  68. package/src/lib/bc-graph.ts +298 -0
  69. package/src/lib/dispatch-form.ts +160 -0
  70. package/src/lib/error-friendly.ts +288 -0
  71. package/src/lib/home.ts +191 -0
  72. package/src/lib/inspect.ts +226 -0
  73. package/src/lib/kind-colors.ts +132 -0
  74. package/src/lib/live-table.ts +204 -0
  75. package/src/lib/manifest-health.ts +71 -0
  76. package/src/lib/manifest.ts +139 -0
  77. package/src/lib/metadata.ts +52 -0
  78. package/src/lib/node-metrics.ts +242 -0
  79. package/src/lib/operate.ts +114 -0
  80. package/src/lib/pipeline-flow.ts +120 -0
  81. package/src/lib/rca.ts +193 -0
  82. package/src/lib/telemetry.ts +155 -0
  83. package/src/lib/topology-graph.ts +551 -0
  84. package/src/lib/topology-view.ts +185 -0
  85. package/src/lib/waterfall.ts +148 -0
  86. package/src/main.ts +63 -29
  87. package/src/pages/Errors.vue +272 -0
  88. package/src/pages/Home.stories.ts +7 -8
  89. package/src/pages/Home.vue +255 -540
  90. package/src/pages/Hooks.stories.ts +44 -0
  91. package/src/pages/Hooks.vue +165 -164
  92. package/src/pages/Inspect.vue +240 -0
  93. package/src/pages/Map.vue +187 -0
  94. package/src/pages/Operate.vue +74 -0
  95. package/src/pages/Plugins.stories.ts +1 -1
  96. package/src/pages/Plugins.vue +174 -238
  97. package/src/pages/Projects.vue +62 -60
  98. package/src/pages/Streams.vue +344 -0
  99. package/src/pages/Topology.vue +318 -136
  100. package/src/pages/Trace.vue +174 -412
  101. package/src/pages/__tests__/Home.test.ts +109 -54
  102. package/src/pages/__tests__/Hooks.test.ts +5 -5
  103. package/src/pages/__tests__/Inspect.test.ts +111 -0
  104. package/src/pages/__tests__/Plugins.test.ts +85 -35
  105. package/src/pages/__tests__/Trace.test.ts +117 -0
  106. package/src/pages/operate/CommandsPanel.vue +186 -0
  107. package/src/pages/{Dispatch.vue → operate/DispatchPanel.vue} +100 -203
  108. package/src/pages/operate/EndpointPicker.vue +56 -0
  109. package/src/pages/operate/RunPanel.vue +316 -0
  110. package/src/server/__tests__/nwire-read.test.ts +80 -0
  111. package/src/server/nwire-read.ts +63 -0
  112. package/vite.config.ts +220 -2
  113. package/src/lib/__tests__/normalize-cache.test.ts +0 -105
  114. package/src/lib/cache.ts +0 -312
  115. package/src/lib/normalize-cache.ts +0 -92
  116. package/src/pages/Actions.vue +0 -171
  117. package/src/pages/Apps.vue +0 -177
  118. package/src/pages/Commands.vue +0 -262
  119. package/src/pages/Events.vue +0 -210
  120. package/src/pages/Live.vue +0 -249
  121. package/src/pages/Overview.vue +0 -161
  122. package/src/pages/Projections.vue +0 -148
  123. package/src/pages/Queries.vue +0 -148
  124. package/src/pages/Run.vue +0 -618
  125. package/src/pages/Sinks.vue +0 -124
  126. package/src/pages/TraceNode.vue +0 -164
  127. package/src/pages/Workflows.vue +0 -184
  128. package/src/pages/__tests__/Actions.test.ts +0 -98
  129. package/src/pages/__tests__/Projections.test.ts +0 -90
  130. package/src/pages/__tests__/Queries.test.ts +0 -86
@@ -0,0 +1,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&#10;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
+ }