@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,180 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref, watch } from "vue";
3
+ import { useRoute, useRouter } from "vue-router";
4
+ import { useCache } from "@/lib/cache";
5
+ import { Search, Zap, Shield, Globe, Lock, Anchor, Activity } from "lucide-vue-next";
6
+ import { SchemaTree, SourcePill, SourceDrawer } from "@/components";
7
+
8
+ const route = useRoute();
9
+ const router = useRouter();
10
+ const { cache } = useCache();
11
+ const filter = ref("");
12
+ const selected = ref<string | null>(null);
13
+ const sourcePreview = ref<{ file: string; line: number; column?: number } | null>(null);
14
+
15
+ function applyQueryPreselect(): void {
16
+ const name = route.query.name;
17
+ if (typeof name === "string" && name.length > 0) {
18
+ selected.value = name;
19
+ }
20
+ }
21
+
22
+ onMounted(applyQueryPreselect);
23
+ watch(() => route.query.name, applyQueryPreselect);
24
+
25
+ const filtered = computed(() => {
26
+ if (!cache.value) return [];
27
+ const q = filter.value.toLowerCase();
28
+ return cache.value.actions.filter(
29
+ (a) =>
30
+ !q ||
31
+ a.name.toLowerCase().includes(q) ||
32
+ (a.description ?? "").toLowerCase().includes(q) ||
33
+ a.module.toLowerCase().includes(q) ||
34
+ a.app.toLowerCase().includes(q),
35
+ );
36
+ });
37
+
38
+ const detail = computed(() => filtered.value.find((a) => a.name === selected.value) ?? null);
39
+ </script>
40
+
41
+ <template>
42
+ <div v-if="cache" class="h-full flex">
43
+ <div class="w-2/5 border-r border-zinc-800 flex flex-col">
44
+ <div class="border-b border-zinc-800 px-4 py-3">
45
+ <h1 class="text-lg font-semibold tracking-tight">Actions</h1>
46
+ <div class="relative mt-2">
47
+ <Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500" />
48
+ <input
49
+ v-model="filter"
50
+ placeholder="filter by name, module, app, description…"
51
+ class="w-full bg-zinc-900 border border-zinc-800 rounded pl-7 pr-2 py-1.5 text-sm placeholder:text-zinc-600 focus:outline-none focus:border-zinc-600"
52
+ />
53
+ </div>
54
+ <div class="text-[10px] text-zinc-500 mt-1">
55
+ {{ filtered.length }} / {{ cache.actions.length }}
56
+ </div>
57
+ </div>
58
+ <div class="flex-1 overflow-auto">
59
+ <button
60
+ v-for="a in filtered"
61
+ :key="`${a.app}::${a.name}`"
62
+ class="w-full text-left px-4 py-2.5 border-b border-zinc-900 hover:bg-zinc-900/50 transition-colors"
63
+ :class="{ 'bg-zinc-900': selected === a.name }"
64
+ @click="selected = a.name"
65
+ >
66
+ <div class="flex items-center justify-between">
67
+ <div class="flex items-center gap-2 min-w-0">
68
+ <Zap class="w-3 h-3 text-amber-400 shrink-0" />
69
+ <span class="font-mono text-sm truncate">{{ a.name }}</span>
70
+ </div>
71
+ <div class="flex items-center gap-1 shrink-0">
72
+ <Shield v-if="a.policy" class="w-3 h-3 text-blue-400" />
73
+ <component
74
+ :is="a.public ? Globe : Lock"
75
+ class="w-3 h-3"
76
+ :class="a.public ? 'text-emerald-400' : 'text-zinc-500'"
77
+ :title="
78
+ a.public ? 'public — other modules may dispatch' : 'private — module-internal'
79
+ "
80
+ />
81
+ <span class="text-[10px] text-zinc-500">{{ a.app }}</span>
82
+ </div>
83
+ </div>
84
+ <div v-if="a.description" class="text-xs text-zinc-500 mt-1 line-clamp-2 ml-5">
85
+ {{ a.description }}
86
+ </div>
87
+ </button>
88
+ </div>
89
+ </div>
90
+
91
+ <div class="flex-1 overflow-auto">
92
+ <div v-if="!detail" class="p-6 text-zinc-500 text-sm">
93
+ Select an action to view its schema and metadata.
94
+ </div>
95
+ <div v-else class="p-6 space-y-5">
96
+ <div>
97
+ <div class="text-[10px] uppercase tracking-wide text-zinc-500">
98
+ {{ detail.app }} · {{ detail.module }}
99
+ </div>
100
+ <h2 class="font-mono text-xl mt-1">{{ detail.name }}</h2>
101
+ <p v-if="detail.description" class="text-sm text-zinc-400 mt-2 max-w-2xl">
102
+ {{ detail.description }}
103
+ </p>
104
+ </div>
105
+
106
+ <div class="flex flex-wrap gap-2">
107
+ <span
108
+ v-if="detail.hasInlineHandler"
109
+ class="text-[10px] uppercase tracking-wide px-2 py-0.5 rounded bg-emerald-950/50 border border-emerald-900 text-emerald-300"
110
+ >
111
+ inline handler
112
+ </span>
113
+ <span
114
+ v-if="detail.retry"
115
+ class="text-[10px] uppercase tracking-wide px-2 py-0.5 rounded bg-amber-950/50 border border-amber-900 text-amber-300"
116
+ >
117
+ retry
118
+ </span>
119
+ <span
120
+ v-if="detail.policy"
121
+ class="text-[10px] uppercase tracking-wide px-2 py-0.5 rounded bg-blue-950/50 border border-blue-900 text-blue-300"
122
+ >
123
+ policy: {{ detail.policy }}
124
+ </span>
125
+ </div>
126
+
127
+ <div v-if="detail.persona || detail.journeyStep" class="text-xs space-y-1 text-zinc-400">
128
+ <div v-if="detail.persona" class="flex items-center gap-2">
129
+ <span class="text-zinc-500 w-24">Persona</span>
130
+ <span class="font-mono">{{ detail.persona }}</span>
131
+ </div>
132
+ <div v-if="detail.journeyStep" class="flex items-center gap-2">
133
+ <span class="text-zinc-500 w-24">Journey step</span>
134
+ <span class="font-mono">{{ detail.journeyStep }}</span>
135
+ </div>
136
+ </div>
137
+
138
+ <div v-if="detail.source" class="flex items-center gap-2">
139
+ <button
140
+ type="button"
141
+ class="inline-flex items-center"
142
+ @click="sourcePreview = detail.source!"
143
+ >
144
+ <SourcePill :source="detail.source" />
145
+ </button>
146
+ </div>
147
+
148
+ <SchemaTree :schema="detail.schema" label="Input schema" />
149
+
150
+ <div class="flex flex-wrap gap-2">
151
+ <button
152
+ type="button"
153
+ class="inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded border border-zinc-800 bg-zinc-900/50 hover:bg-zinc-800/70 text-zinc-300"
154
+ :data-testid="`hooks-link-${detail.name}`"
155
+ @click="
156
+ router.push({ path: '/hooks', query: { name: `action.before:${detail.name}` } })
157
+ "
158
+ >
159
+ <Anchor class="w-3 h-3 text-zinc-500" />
160
+ View hooks
161
+ </button>
162
+ <button
163
+ type="button"
164
+ class="inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded border border-zinc-800 bg-zinc-900/50 hover:bg-zinc-800/70 text-zinc-300"
165
+ :data-testid="`trace-link-${detail.name}`"
166
+ @click="router.push({ path: '/trace', query: { action: detail.name } })"
167
+ >
168
+ <Activity class="w-3 h-3 text-zinc-500" />
169
+ Recent traces of this
170
+ </button>
171
+ </div>
172
+
173
+ <div class="text-xs text-zinc-500">
174
+ Use the Try page for form-from-schema dispatch against the live runtime.
175
+ </div>
176
+ </div>
177
+ </div>
178
+ <SourceDrawer :source="sourcePreview" @close="sourcePreview = null" />
179
+ </div>
180
+ </template>
@@ -0,0 +1,262 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Commands panel — fires nwire CLI commands via the supervisor and
4
+ * tails their stdout/stderr. Shares the same `/__nwire/run/*` surface
5
+ * the Run page uses for topologies; the only difference is the start
6
+ * payload (`{ command, args }` vs `{ topology, port }`).
7
+ */
8
+ import { computed, onMounted, onUnmounted, ref, watch } from "vue";
9
+ import { useRoute } from "vue-router";
10
+ import {
11
+ Play,
12
+ Square,
13
+ RefreshCw,
14
+ Trash2,
15
+ Terminal,
16
+ CircleDot,
17
+ Loader2,
18
+ AlertTriangle,
19
+ CheckCircle2,
20
+ } from "lucide-vue-next";
21
+
22
+ interface CommandEntry {
23
+ name: string;
24
+ description: string;
25
+ }
26
+
27
+ interface ManagedProcess {
28
+ id: string;
29
+ topology: string;
30
+ startedAt: string;
31
+ status: "idle" | "starting" | "running" | "stopping" | "exited" | "crashed";
32
+ pid?: number;
33
+ exitCode?: number | null;
34
+ signal?: string | null;
35
+ errorMessage?: string;
36
+ }
37
+
38
+ interface LogLine {
39
+ seq: number;
40
+ ts: string;
41
+ stream: "stdout" | "stderr";
42
+ line: string;
43
+ }
44
+
45
+ const route = useRoute();
46
+ const commands = ref<CommandEntry[]>([]);
47
+ const processes = ref<ManagedProcess[]>([]);
48
+ const selectedId = ref<string | null>(null);
49
+ /** Preselected command name from `?name=…` — highlights the row. */
50
+ const selectedCommand = ref<string | null>(null);
51
+ const argsInput = ref("");
52
+ const startBusy = ref(false);
53
+ const startError = ref<string | null>(null);
54
+ const logs = ref<LogLine[]>([]);
55
+ let es: EventSource | null = null;
56
+ let processesPoll: ReturnType<typeof setInterval> | null = null;
57
+
58
+ function applyQueryPreselect(): void {
59
+ const name = route.query.name;
60
+ selectedCommand.value = typeof name === "string" && name.length > 0 ? name : null;
61
+ }
62
+ watch(() => route.query.name, applyQueryPreselect);
63
+
64
+ async function loadCommands() {
65
+ const res = await fetch("/__nwire/run/commands");
66
+ const data = (await res.json()) as { commands: CommandEntry[] };
67
+ commands.value = data.commands;
68
+ }
69
+
70
+ async function loadProcesses() {
71
+ const res = await fetch("/__nwire/run/processes");
72
+ const data = (await res.json()) as { processes: ManagedProcess[] };
73
+ // Filter to processes our panel started (topology label begins with "nwire ").
74
+ processes.value = data.processes.filter((p) => p.topology.startsWith("nwire "));
75
+ }
76
+
77
+ async function exec(name: string) {
78
+ startBusy.value = true;
79
+ startError.value = null;
80
+ try {
81
+ const argv = argsInput.value.trim().split(/\s+/).filter(Boolean);
82
+ const res = await fetch("/__nwire/run/exec", {
83
+ method: "POST",
84
+ headers: { "content-type": "application/json" },
85
+ body: JSON.stringify({ command: name, args: argv }),
86
+ });
87
+ if (!res.ok) {
88
+ const data = (await res.json()) as { error?: string };
89
+ startError.value = data.error ?? `HTTP ${res.status}`;
90
+ return;
91
+ }
92
+ await loadProcesses();
93
+ } finally {
94
+ startBusy.value = false;
95
+ }
96
+ }
97
+
98
+ async function stop(id: string) {
99
+ await fetch(`/__nwire/run/stop/${encodeURIComponent(id)}`, { method: "POST" });
100
+ await loadProcesses();
101
+ }
102
+
103
+ async function forget(id: string) {
104
+ await fetch(`/__nwire/run/forget/${encodeURIComponent(id)}`, { method: "POST" });
105
+ if (selectedId.value === id) selectedId.value = null;
106
+ await loadProcesses();
107
+ }
108
+
109
+ function statusIcon(s: ManagedProcess["status"]) {
110
+ switch (s) {
111
+ case "running":
112
+ return { icon: CircleDot, color: "text-emerald-400" };
113
+ case "starting":
114
+ return { icon: Loader2, color: "text-amber-400 animate-spin" };
115
+ case "stopping":
116
+ return { icon: Loader2, color: "text-amber-400 animate-spin" };
117
+ case "exited":
118
+ return { icon: CheckCircle2, color: "text-zinc-500" };
119
+ case "crashed":
120
+ return { icon: AlertTriangle, color: "text-red-400" };
121
+ case "idle":
122
+ return { icon: Terminal, color: "text-zinc-500" };
123
+ }
124
+ }
125
+
126
+ watch(selectedId, (id) => {
127
+ if (es) {
128
+ es.close();
129
+ es = null;
130
+ }
131
+ logs.value = [];
132
+ if (!id) return;
133
+ es = new EventSource(`/__nwire/run/logs/${encodeURIComponent(id)}/stream`);
134
+ es.onmessage = (ev) => {
135
+ try {
136
+ const line = JSON.parse(ev.data) as LogLine;
137
+ logs.value = [...logs.value.slice(-1999), line];
138
+ } catch {
139
+ // ignore non-JSON keepalive comments
140
+ }
141
+ };
142
+ });
143
+
144
+ onMounted(async () => {
145
+ applyQueryPreselect();
146
+ await Promise.all([loadCommands(), loadProcesses()]);
147
+ processesPoll = setInterval(loadProcesses, 1500);
148
+ });
149
+
150
+ onUnmounted(() => {
151
+ if (es) es.close();
152
+ if (processesPoll) clearInterval(processesPoll);
153
+ });
154
+
155
+ const selectedProc = computed(() => processes.value.find((p) => p.id === selectedId.value) ?? null);
156
+ </script>
157
+
158
+ <template>
159
+ <div class="h-full flex">
160
+ <!-- Left: command picker -->
161
+ <div class="w-1/3 border-r border-zinc-800 flex flex-col">
162
+ <div class="border-b border-zinc-800 px-4 py-3">
163
+ <h1 class="text-lg font-semibold tracking-tight">Commands</h1>
164
+ <div class="mt-2">
165
+ <input
166
+ v-model="argsInput"
167
+ placeholder="extra args (optional, e.g. `units` for `nwire test units`)"
168
+ class="w-full bg-zinc-900 border border-zinc-800 rounded px-2 py-1.5 text-xs placeholder:text-zinc-600 focus:outline-none focus:border-zinc-600"
169
+ />
170
+ </div>
171
+ <div v-if="startError" class="text-xs text-red-400 mt-2">{{ startError }}</div>
172
+ </div>
173
+ <div class="flex-1 overflow-auto">
174
+ <button
175
+ v-for="c in commands"
176
+ :key="c.name"
177
+ class="w-full text-left px-4 py-2.5 border-b border-zinc-900 hover:bg-zinc-900/50 transition-colors"
178
+ :class="{ 'bg-zinc-900': selectedCommand === c.name }"
179
+ :data-testid="`command-row-${c.name}`"
180
+ :disabled="startBusy"
181
+ @click="exec(c.name)"
182
+ >
183
+ <div class="flex items-center gap-2">
184
+ <Play class="w-3 h-3 text-emerald-400 shrink-0" />
185
+ <span class="font-mono text-sm">nwire {{ c.name }}</span>
186
+ </div>
187
+ <div class="text-xs text-zinc-500 mt-1 ml-5">{{ c.description }}</div>
188
+ </button>
189
+ </div>
190
+ </div>
191
+
192
+ <!-- Middle: running processes -->
193
+ <div class="w-1/3 border-r border-zinc-800 flex flex-col">
194
+ <div class="border-b border-zinc-800 px-4 py-3 flex items-center justify-between">
195
+ <h2 class="text-sm font-semibold tracking-tight">Active</h2>
196
+ <button class="text-xs text-zinc-500 hover:text-zinc-300" @click="loadProcesses">
197
+ <RefreshCw class="w-3 h-3 inline" />
198
+ </button>
199
+ </div>
200
+ <div class="flex-1 overflow-auto">
201
+ <div v-if="processes.length === 0" class="p-4 text-xs text-zinc-500">
202
+ Click a command on the left to launch it.
203
+ </div>
204
+ <button
205
+ v-for="p in processes"
206
+ :key="p.id"
207
+ class="w-full text-left px-4 py-2 border-b border-zinc-900 hover:bg-zinc-900/50"
208
+ :class="{ 'bg-zinc-900': selectedId === p.id }"
209
+ @click="selectedId = p.id"
210
+ >
211
+ <div class="flex items-center gap-2">
212
+ <component
213
+ :is="statusIcon(p.status).icon"
214
+ class="w-3 h-3 shrink-0"
215
+ :class="statusIcon(p.status).color"
216
+ />
217
+ <span class="font-mono text-sm truncate">{{ p.topology }}</span>
218
+ </div>
219
+ <div class="text-[10px] text-zinc-500 mt-1 ml-5">
220
+ {{ p.status }} · pid {{ p.pid ?? "—" }} · started {{ p.startedAt.slice(11, 19) }}
221
+ </div>
222
+ </button>
223
+ </div>
224
+ </div>
225
+
226
+ <!-- Right: live stdout -->
227
+ <div class="flex-1 flex flex-col">
228
+ <div class="border-b border-zinc-800 px-4 py-3 flex items-center justify-between">
229
+ <h2 class="text-sm font-semibold tracking-tight font-mono">
230
+ {{ selectedProc ? selectedProc.topology : "Output" }}
231
+ </h2>
232
+ <div v-if="selectedProc" class="flex items-center gap-2">
233
+ <button
234
+ v-if="selectedProc.status === 'running' || selectedProc.status === 'starting'"
235
+ class="text-xs text-zinc-400 hover:text-red-400"
236
+ @click="stop(selectedProc.id)"
237
+ >
238
+ <Square class="w-3 h-3 inline" /> stop
239
+ </button>
240
+ <button
241
+ class="text-xs text-zinc-400 hover:text-zinc-300"
242
+ @click="forget(selectedProc.id)"
243
+ >
244
+ <Trash2 class="w-3 h-3 inline" /> forget
245
+ </button>
246
+ </div>
247
+ </div>
248
+ <div class="flex-1 overflow-auto bg-zinc-950 font-mono text-xs p-4">
249
+ <div v-if="logs.length === 0" class="text-zinc-600">
250
+ Select a process to view its stdout.
251
+ </div>
252
+ <div
253
+ v-for="l in logs"
254
+ :key="l.seq"
255
+ :class="l.stream === 'stderr' ? 'text-red-400' : 'text-zinc-300'"
256
+ >
257
+ {{ l.line }}
258
+ </div>
259
+ </div>
260
+ </div>
261
+ </div>
262
+ </template>