@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,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>
|