@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,403 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Plugins — every plugin AND every module-as-plugin registered against the
|
|
4
|
+
* Nwire app, with live boot/shutdown timing pulled from the lifecycle
|
|
5
|
+
* telemetry stream. The cache emits `plugins.json` at scan time; the wire
|
|
6
|
+
* emits `kind: "lifecycle"` (e.g. `nwire.plugin.booted`) and
|
|
7
|
+
* `kind: "hook.step"` records under `plugin.boot:<name>` /
|
|
8
|
+
* `plugin.shutdown:<name>` at runtime. We fuse the two views.
|
|
9
|
+
*/
|
|
10
|
+
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
|
11
|
+
import { useRoute, useRouter } from "vue-router";
|
|
12
|
+
import { useCache, type PluginEntry, type HookEntry } from "@/lib/cache";
|
|
13
|
+
import { Activity, Anchor, Boxes, Puzzle, RefreshCw, Search } from "lucide-vue-next";
|
|
14
|
+
import { SourcePill, SourceDrawer, KindBadge } from "@/components";
|
|
15
|
+
|
|
16
|
+
// ── Telemetry record shapes (loose — payloads come from the wire) ─────
|
|
17
|
+
interface LifecycleRecord {
|
|
18
|
+
readonly kind: "lifecycle";
|
|
19
|
+
readonly event: string;
|
|
20
|
+
readonly ts: string;
|
|
21
|
+
readonly payload?: { pluginName?: string; durationMs?: number; [k: string]: unknown };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface HookStepRecord {
|
|
25
|
+
readonly kind: "hook.step";
|
|
26
|
+
readonly hookName: string;
|
|
27
|
+
readonly hookId: string;
|
|
28
|
+
readonly runId: string;
|
|
29
|
+
readonly stepId: number;
|
|
30
|
+
readonly stepKind: "chain" | "listener";
|
|
31
|
+
readonly stepName?: string;
|
|
32
|
+
readonly phase: "start" | "end" | "error";
|
|
33
|
+
readonly durationMs?: number;
|
|
34
|
+
readonly ts: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type PluginTelemetry = LifecycleRecord | HookStepRecord;
|
|
38
|
+
|
|
39
|
+
const route = useRoute();
|
|
40
|
+
const router = useRouter();
|
|
41
|
+
const { cache } = useCache();
|
|
42
|
+
const filter = ref("");
|
|
43
|
+
const kindFilter = ref<"all" | "plugin" | "module">("all");
|
|
44
|
+
const selected = ref<string | null>(null);
|
|
45
|
+
const sourcePreview = ref<{ file: string; line: number; column?: number } | null>(null);
|
|
46
|
+
|
|
47
|
+
function applyQueryPreselect(): void {
|
|
48
|
+
const name = route.query.name;
|
|
49
|
+
if (typeof name !== "string" || name.length === 0) return;
|
|
50
|
+
const found = cache.value?.plugins.find((p) => p.name === name);
|
|
51
|
+
if (found) selected.value = `${found.app}::${found.kind}::${found.name}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
onMounted(applyQueryPreselect);
|
|
55
|
+
watch(() => route.query.name, applyQueryPreselect);
|
|
56
|
+
watch(() => cache.value, applyQueryPreselect);
|
|
57
|
+
|
|
58
|
+
// ── Live tap stream ───────────────────────────────────────────────────
|
|
59
|
+
const liveRecords = ref<PluginTelemetry[]>([]);
|
|
60
|
+
const streamStatus = ref<"connecting" | "open" | "closed" | "error">("connecting");
|
|
61
|
+
let es: EventSource | null = null;
|
|
62
|
+
|
|
63
|
+
onMounted(() => {
|
|
64
|
+
connect();
|
|
65
|
+
});
|
|
66
|
+
onUnmounted(() => {
|
|
67
|
+
es?.close();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
function connect(): void {
|
|
71
|
+
streamStatus.value = "connecting";
|
|
72
|
+
es?.close();
|
|
73
|
+
es = new EventSource("/_nwire/telemetry/stream");
|
|
74
|
+
es.onopen = () => {
|
|
75
|
+
streamStatus.value = "open";
|
|
76
|
+
};
|
|
77
|
+
es.onerror = () => {
|
|
78
|
+
streamStatus.value = "error";
|
|
79
|
+
};
|
|
80
|
+
es.onmessage = (m) => {
|
|
81
|
+
try {
|
|
82
|
+
const rec = JSON.parse(m.data) as { kind?: string };
|
|
83
|
+
if (rec.kind === "lifecycle" || rec.kind === "hook.step") {
|
|
84
|
+
liveRecords.value.push(rec as PluginTelemetry);
|
|
85
|
+
if (liveRecords.value.length > 1000) liveRecords.value.splice(0, 500);
|
|
86
|
+
}
|
|
87
|
+
} catch {
|
|
88
|
+
/* ignore non-JSON frames */
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
94
|
+
const key = (p: PluginEntry) => `${p.app}::${p.kind}::${p.name}`;
|
|
95
|
+
|
|
96
|
+
function pluginNameFromHook(hookName: string): string | null {
|
|
97
|
+
const colon = hookName.indexOf(":");
|
|
98
|
+
if (colon === -1) return null;
|
|
99
|
+
const prefix = hookName.slice(0, colon);
|
|
100
|
+
if (prefix !== "plugin.boot" && prefix !== "plugin.shutdown") return null;
|
|
101
|
+
return hookName.slice(colon + 1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function recordPluginName(rec: PluginTelemetry): string | null {
|
|
105
|
+
if (rec.kind === "lifecycle") return rec.payload?.pluginName ?? null;
|
|
106
|
+
return pluginNameFromHook(rec.hookName);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function recordPhase(rec: PluginTelemetry): string {
|
|
110
|
+
if (rec.kind === "lifecycle") {
|
|
111
|
+
// e.g. nwire.plugin.booted → "booted"
|
|
112
|
+
const parts = rec.event.split(".");
|
|
113
|
+
return parts[parts.length - 1] ?? rec.event;
|
|
114
|
+
}
|
|
115
|
+
const colon = rec.hookName.indexOf(":");
|
|
116
|
+
const head = colon === -1 ? rec.hookName : rec.hookName.slice(0, colon);
|
|
117
|
+
return `${head.replace(/^plugin\./, "")} ${rec.phase}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function recordDuration(rec: PluginTelemetry): number | undefined {
|
|
121
|
+
if (rec.kind === "lifecycle") {
|
|
122
|
+
const d = rec.payload?.durationMs;
|
|
123
|
+
return typeof d === "number" ? d : undefined;
|
|
124
|
+
}
|
|
125
|
+
return rec.durationMs;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Last observed boot duration per plugin (lifecycle preferred over hook). */
|
|
129
|
+
const bootDurations = computed<Record<string, number>>(() => {
|
|
130
|
+
const out: Record<string, number> = {};
|
|
131
|
+
for (const rec of liveRecords.value) {
|
|
132
|
+
const name = recordPluginName(rec);
|
|
133
|
+
if (!name) continue;
|
|
134
|
+
if (rec.kind === "lifecycle" && rec.event.endsWith(".booted")) {
|
|
135
|
+
const d = recordDuration(rec);
|
|
136
|
+
if (typeof d === "number") out[name] = d;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ── Filtered list ─────────────────────────────────────────────────────
|
|
143
|
+
const filteredPlugins = computed<PluginEntry[]>(() => {
|
|
144
|
+
if (!cache.value) return [];
|
|
145
|
+
const q = filter.value.toLowerCase();
|
|
146
|
+
return cache.value.plugins.filter((p) => {
|
|
147
|
+
if (kindFilter.value !== "all" && p.kind !== kindFilter.value) return false;
|
|
148
|
+
if (!q) return true;
|
|
149
|
+
return p.name.toLowerCase().includes(q) || p.app.toLowerCase().includes(q);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const detail = computed<PluginEntry | null>(() => {
|
|
154
|
+
if (!cache.value) return null;
|
|
155
|
+
return cache.value.plugins.find((p) => key(p) === selected.value) ?? null;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ── Contributed hooks for the selected plugin ─────────────────────────
|
|
159
|
+
const contributedHooks = computed<HookEntry[]>(() => {
|
|
160
|
+
if (!detail.value || !cache.value) return [];
|
|
161
|
+
const name = detail.value.name;
|
|
162
|
+
const bootName = `plugin.boot:${name}`;
|
|
163
|
+
const downName = `plugin.shutdown:${name}`;
|
|
164
|
+
return cache.value.hooks.filter((h) => h.name === bootName || h.name === downName);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ── Lifecycle timeline for the selected plugin (last 50) ──────────────
|
|
168
|
+
const detailRecords = computed<PluginTelemetry[]>(() => {
|
|
169
|
+
if (!detail.value) return [];
|
|
170
|
+
const name = detail.value.name;
|
|
171
|
+
return liveRecords.value
|
|
172
|
+
.filter((r) => recordPluginName(r) === name)
|
|
173
|
+
.slice(-50)
|
|
174
|
+
.reverse();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
function openHook(hookName: string): void {
|
|
178
|
+
void router.push({ path: "/hooks", query: { name: hookName } });
|
|
179
|
+
}
|
|
180
|
+
</script>
|
|
181
|
+
|
|
182
|
+
<template>
|
|
183
|
+
<div v-if="cache" class="h-full flex flex-col" data-testid="plugins-page">
|
|
184
|
+
<!-- Header -->
|
|
185
|
+
<div class="p-6 pb-3 border-b border-zinc-800 flex items-center justify-between">
|
|
186
|
+
<div>
|
|
187
|
+
<div class="flex items-center gap-2">
|
|
188
|
+
<Puzzle class="w-4 h-4 text-fuchsia-400" />
|
|
189
|
+
<h1 class="text-lg font-medium">Plugins</h1>
|
|
190
|
+
<span class="text-[10px] text-zinc-500">
|
|
191
|
+
{{ filteredPlugins.length }} / {{ cache.plugins.length }}
|
|
192
|
+
</span>
|
|
193
|
+
<span
|
|
194
|
+
class="ml-2 text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-zinc-900 text-zinc-400"
|
|
195
|
+
data-testid="stream-status"
|
|
196
|
+
>
|
|
197
|
+
stream {{ streamStatus }}
|
|
198
|
+
</span>
|
|
199
|
+
</div>
|
|
200
|
+
<p class="text-xs text-zinc-500 mt-1">
|
|
201
|
+
Plugins and modules-as-plugins composed into the app. Boot and shutdown timing streams
|
|
202
|
+
live from the running wire.
|
|
203
|
+
</p>
|
|
204
|
+
</div>
|
|
205
|
+
<button
|
|
206
|
+
class="p-1 rounded hover:bg-zinc-800 text-zinc-400"
|
|
207
|
+
title="Reconnect stream"
|
|
208
|
+
@click="connect"
|
|
209
|
+
>
|
|
210
|
+
<RefreshCw class="w-3.5 h-3.5" />
|
|
211
|
+
</button>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<!-- Empty (no plugins at all) -->
|
|
215
|
+
<div
|
|
216
|
+
v-if="cache.plugins.length === 0"
|
|
217
|
+
class="flex-1 flex items-center justify-center p-12"
|
|
218
|
+
data-testid="plugins-empty"
|
|
219
|
+
>
|
|
220
|
+
<div class="text-center max-w-md">
|
|
221
|
+
<Puzzle class="w-8 h-8 text-zinc-700 mx-auto mb-3" />
|
|
222
|
+
<div class="text-sm text-zinc-400 font-medium">No plugins in cache</div>
|
|
223
|
+
<div class="text-xs text-zinc-500 mt-2">
|
|
224
|
+
Plugins and modules are emitted to <span class="font-mono">.nwire/plugins.json</span> at
|
|
225
|
+
scan time. Run <span class="font-mono">nwire cache</span> after registering one.
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<!-- Master / detail -->
|
|
231
|
+
<div v-else class="flex-1 flex min-h-0">
|
|
232
|
+
<!-- Master -->
|
|
233
|
+
<aside class="w-[280px] border-r border-zinc-800 flex flex-col shrink-0">
|
|
234
|
+
<div class="px-3 py-2 border-b border-zinc-800 space-y-2">
|
|
235
|
+
<div class="relative">
|
|
236
|
+
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500" />
|
|
237
|
+
<input
|
|
238
|
+
v-model="filter"
|
|
239
|
+
placeholder="filter name / app…"
|
|
240
|
+
class="w-full bg-zinc-900 border border-zinc-800 rounded pl-7 pr-2 py-1 text-xs placeholder:text-zinc-600 focus:outline-none focus:border-zinc-600"
|
|
241
|
+
/>
|
|
242
|
+
</div>
|
|
243
|
+
<div class="flex gap-1">
|
|
244
|
+
<button
|
|
245
|
+
v-for="opt in ['all', 'plugin', 'module'] as const"
|
|
246
|
+
:key="opt"
|
|
247
|
+
class="flex-1 px-2 py-0.5 text-[10px] uppercase tracking-wide rounded border transition-colors"
|
|
248
|
+
:class="
|
|
249
|
+
kindFilter === opt
|
|
250
|
+
? 'bg-zinc-800 border-zinc-700 text-zinc-100'
|
|
251
|
+
: 'bg-zinc-950 border-zinc-900 text-zinc-500 hover:text-zinc-300'
|
|
252
|
+
"
|
|
253
|
+
@click="kindFilter = opt"
|
|
254
|
+
>
|
|
255
|
+
{{ opt === "all" ? "All" : opt + "s" }}
|
|
256
|
+
</button>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
<div v-if="filteredPlugins.length === 0" class="p-4 text-xs text-zinc-500">
|
|
261
|
+
No plugins match the filter.
|
|
262
|
+
</div>
|
|
263
|
+
<ul v-else class="flex-1 overflow-auto" data-testid="plugins-list">
|
|
264
|
+
<li v-for="p in filteredPlugins" :key="key(p)">
|
|
265
|
+
<button
|
|
266
|
+
class="w-full text-left px-3 py-2 border-b border-zinc-900 hover:bg-zinc-900/50 transition-colors"
|
|
267
|
+
:class="{ 'bg-zinc-900/70': key(p) === selected }"
|
|
268
|
+
@click="selected = key(p)"
|
|
269
|
+
>
|
|
270
|
+
<div class="flex items-center gap-2">
|
|
271
|
+
<component
|
|
272
|
+
:is="p.kind === 'plugin' ? Anchor : Boxes"
|
|
273
|
+
class="w-3 h-3 shrink-0"
|
|
274
|
+
:class="p.kind === 'plugin' ? 'text-fuchsia-400' : 'text-orange-400'"
|
|
275
|
+
/>
|
|
276
|
+
<span class="font-mono text-xs text-zinc-100 truncate flex-1">{{ p.name }}</span>
|
|
277
|
+
<span
|
|
278
|
+
v-if="bootDurations[p.name] !== undefined"
|
|
279
|
+
class="text-[10px] text-emerald-400 tabular-nums shrink-0"
|
|
280
|
+
title="last observed boot duration"
|
|
281
|
+
>
|
|
282
|
+
{{ bootDurations[p.name]!.toFixed(1) }}ms
|
|
283
|
+
</span>
|
|
284
|
+
</div>
|
|
285
|
+
<div class="text-[10px] text-zinc-500 mt-0.5 pl-5">
|
|
286
|
+
{{ p.app }}
|
|
287
|
+
</div>
|
|
288
|
+
</button>
|
|
289
|
+
</li>
|
|
290
|
+
</ul>
|
|
291
|
+
</aside>
|
|
292
|
+
|
|
293
|
+
<!-- Detail -->
|
|
294
|
+
<main class="flex-1 flex flex-col min-w-0" data-testid="plugins-detail">
|
|
295
|
+
<div v-if="!detail" class="p-8 text-sm text-zinc-500 italic">
|
|
296
|
+
Select a plugin to view its contributed hooks + live lifecycle timeline.
|
|
297
|
+
</div>
|
|
298
|
+
<div v-else class="flex-1 flex flex-col overflow-hidden">
|
|
299
|
+
<!-- Header -->
|
|
300
|
+
<div class="px-6 py-5 border-b border-zinc-800">
|
|
301
|
+
<div class="flex items-center gap-3 flex-wrap">
|
|
302
|
+
<component
|
|
303
|
+
:is="detail.kind === 'plugin' ? Anchor : Boxes"
|
|
304
|
+
class="w-4 h-4"
|
|
305
|
+
:class="detail.kind === 'plugin' ? 'text-fuchsia-400' : 'text-orange-400'"
|
|
306
|
+
/>
|
|
307
|
+
<h2 class="font-mono text-xl">{{ detail.name }}</h2>
|
|
308
|
+
<KindBadge :variant="detail.kind === 'plugin' ? 'info' : 'warning'">
|
|
309
|
+
{{ detail.kind }}
|
|
310
|
+
</KindBadge>
|
|
311
|
+
<span class="text-[10px] text-zinc-500 font-mono">{{ detail.app }}</span>
|
|
312
|
+
<button
|
|
313
|
+
v-if="detail.source"
|
|
314
|
+
type="button"
|
|
315
|
+
class="ml-auto inline-flex items-center"
|
|
316
|
+
@click="sourcePreview = detail.source!"
|
|
317
|
+
>
|
|
318
|
+
<SourcePill :source="detail.source" />
|
|
319
|
+
</button>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
|
|
323
|
+
<div class="flex-1 overflow-auto">
|
|
324
|
+
<!-- Contributed hooks -->
|
|
325
|
+
<section class="px-6 py-4 border-b border-zinc-900">
|
|
326
|
+
<h3 class="text-xs uppercase tracking-wide text-zinc-400 mb-3">
|
|
327
|
+
Contributed hooks
|
|
328
|
+
<span class="text-zinc-600 normal-case tracking-normal ml-1">
|
|
329
|
+
({{ contributedHooks.length }})
|
|
330
|
+
</span>
|
|
331
|
+
</h3>
|
|
332
|
+
<div v-if="contributedHooks.length === 0" class="text-xs text-zinc-500 italic">
|
|
333
|
+
No <span class="font-mono">plugin.boot:{{ detail.name }}</span> or
|
|
334
|
+
<span class="font-mono">plugin.shutdown:{{ detail.name }}</span>
|
|
335
|
+
hooks registered.
|
|
336
|
+
</div>
|
|
337
|
+
<ul v-else class="space-y-1">
|
|
338
|
+
<li
|
|
339
|
+
v-for="h in contributedHooks"
|
|
340
|
+
:key="h.id"
|
|
341
|
+
class="flex items-center gap-3 px-3 py-1.5 rounded hover:bg-zinc-900/50 cursor-pointer"
|
|
342
|
+
:data-testid="`hook-link-${h.name}`"
|
|
343
|
+
@click="openHook(h.name)"
|
|
344
|
+
>
|
|
345
|
+
<Anchor class="w-3 h-3 text-zinc-500 shrink-0" />
|
|
346
|
+
<span class="font-mono text-xs flex-1 truncate">{{ h.name }}</span>
|
|
347
|
+
<span class="text-[10px] text-zinc-500 tabular-nums shrink-0">
|
|
348
|
+
chain {{ h.chain }} · {{ h.listeners }} listener{{
|
|
349
|
+
h.listeners === 1 ? "" : "s"
|
|
350
|
+
}}
|
|
351
|
+
</span>
|
|
352
|
+
</li>
|
|
353
|
+
</ul>
|
|
354
|
+
</section>
|
|
355
|
+
|
|
356
|
+
<!-- Lifecycle timeline -->
|
|
357
|
+
<section class="px-6 py-4">
|
|
358
|
+
<div class="flex items-center gap-2 mb-3">
|
|
359
|
+
<Activity class="w-3.5 h-3.5 text-emerald-400" />
|
|
360
|
+
<h3 class="text-xs uppercase tracking-wide text-zinc-400">Lifecycle timeline</h3>
|
|
361
|
+
<span class="text-[10px] text-zinc-500 tabular-nums">
|
|
362
|
+
{{ detailRecords.length }} recent
|
|
363
|
+
</span>
|
|
364
|
+
</div>
|
|
365
|
+
<div v-if="detailRecords.length === 0" class="text-xs text-zinc-500 italic">
|
|
366
|
+
No lifecycle data yet. Boot or restart the wire and events for
|
|
367
|
+
<span class="font-mono">{{ detail.name }}</span> will stream here.
|
|
368
|
+
</div>
|
|
369
|
+
<ul v-else class="divide-y divide-zinc-900" data-testid="plugins-tap-list">
|
|
370
|
+
<li
|
|
371
|
+
v-for="(r, i) in detailRecords"
|
|
372
|
+
:key="i"
|
|
373
|
+
class="px-3 py-1.5 flex items-center gap-3 font-mono text-[11px] hover:bg-zinc-900/30"
|
|
374
|
+
>
|
|
375
|
+
<span class="text-zinc-600 tabular-nums w-20 shrink-0">
|
|
376
|
+
{{ new Date(r.ts).toLocaleTimeString(undefined, { hour12: false }) }}
|
|
377
|
+
</span>
|
|
378
|
+
<span
|
|
379
|
+
class="text-[10px] uppercase tracking-wide shrink-0"
|
|
380
|
+
:class="{
|
|
381
|
+
'text-emerald-400': r.kind === 'lifecycle',
|
|
382
|
+
'text-cyan-400': r.kind === 'hook.step',
|
|
383
|
+
}"
|
|
384
|
+
>{{ r.kind === "lifecycle" ? "event" : "hook" }}</span
|
|
385
|
+
>
|
|
386
|
+
<span class="text-zinc-200 truncate flex-1">{{ recordPhase(r) }}</span>
|
|
387
|
+
<span
|
|
388
|
+
v-if="recordDuration(r) !== undefined"
|
|
389
|
+
class="text-zinc-500 tabular-nums shrink-0"
|
|
390
|
+
>
|
|
391
|
+
{{ recordDuration(r)!.toFixed(1) }} ms
|
|
392
|
+
</span>
|
|
393
|
+
</li>
|
|
394
|
+
</ul>
|
|
395
|
+
</section>
|
|
396
|
+
</div>
|
|
397
|
+
</div>
|
|
398
|
+
</main>
|
|
399
|
+
</div>
|
|
400
|
+
|
|
401
|
+
<SourceDrawer :source="sourcePreview" @close="sourcePreview = null" />
|
|
402
|
+
</div>
|
|
403
|
+
</template>
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Projects catalog — every Nwire project this browser has opened
|
|
4
|
+
* Studio for. Each card shows composition + live running status; click
|
|
5
|
+
* a card to navigate to its Home if it's the current Studio's project,
|
|
6
|
+
* or copy a `cd && nwire studio` snippet for the others.
|
|
7
|
+
*
|
|
8
|
+
* Snapshots live in localStorage under `nwire.projects` (one per cwd).
|
|
9
|
+
* Live status comes from `/__nwire/projects/status` which walks each
|
|
10
|
+
* project's `.nwire/processes/` registry — no per-project Studio needs
|
|
11
|
+
* to be open for the dots to be accurate.
|
|
12
|
+
*/
|
|
13
|
+
import { computed, onMounted, ref } from "vue";
|
|
14
|
+
import { FolderOpen, Folder, Copy, Trash2, Activity, Circle, RefreshCw } from "lucide-vue-next";
|
|
15
|
+
import { useRouter } from "vue-router";
|
|
16
|
+
import {
|
|
17
|
+
loadCatalog,
|
|
18
|
+
forgetProject,
|
|
19
|
+
setActiveProjectCwd,
|
|
20
|
+
type ProjectSnapshot,
|
|
21
|
+
} from "@/lib/project-catalog";
|
|
22
|
+
|
|
23
|
+
interface ProcessRec {
|
|
24
|
+
id: string;
|
|
25
|
+
port?: number;
|
|
26
|
+
pid: number;
|
|
27
|
+
status: string;
|
|
28
|
+
startedAt: string;
|
|
29
|
+
}
|
|
30
|
+
type StatusMap = Record<string, { hasManifest: boolean; processes: ProcessRec[] }>;
|
|
31
|
+
|
|
32
|
+
const router = useRouter();
|
|
33
|
+
const catalog = ref<Record<string, ProjectSnapshot>>({});
|
|
34
|
+
const status = ref<StatusMap>({});
|
|
35
|
+
const currentCwd = ref<string | null>(null);
|
|
36
|
+
const copiedCwd = ref<string | null>(null);
|
|
37
|
+
/** cwd → human label while a per-project `nwire cache` is in flight. */
|
|
38
|
+
const rescanState = ref<Record<string, "running" | "done" | "error">>({});
|
|
39
|
+
|
|
40
|
+
const projects = computed(() => {
|
|
41
|
+
return Object.values(catalog.value).sort((a, b) => b.lastVisited.localeCompare(a.lastVisited));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
async function refresh() {
|
|
45
|
+
catalog.value = loadCatalog();
|
|
46
|
+
try {
|
|
47
|
+
const proj = await fetch("/__nwire/project");
|
|
48
|
+
if (proj.ok) {
|
|
49
|
+
const body = (await proj.json()) as { cwd: string };
|
|
50
|
+
currentCwd.value = body.cwd;
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
/* current project unknown — fine */
|
|
54
|
+
}
|
|
55
|
+
const cwds = Object.keys(catalog.value);
|
|
56
|
+
if (cwds.length === 0) {
|
|
57
|
+
status.value = {};
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch("/__nwire/projects/status", {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { "Content-Type": "application/json" },
|
|
64
|
+
body: JSON.stringify({ cwds }),
|
|
65
|
+
});
|
|
66
|
+
if (res.ok) status.value = (await res.json()) as StatusMap;
|
|
67
|
+
} catch {
|
|
68
|
+
status.value = {};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function copyStartCommand(cwd: string) {
|
|
73
|
+
const cmd = `cd "${cwd}" && nwire studio`;
|
|
74
|
+
void navigator.clipboard.writeText(cmd);
|
|
75
|
+
copiedCwd.value = cwd;
|
|
76
|
+
setTimeout(() => {
|
|
77
|
+
if (copiedCwd.value === cwd) copiedCwd.value = null;
|
|
78
|
+
}, 1500);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function openProject(cwd: string) {
|
|
82
|
+
if (cwd === currentCwd.value) {
|
|
83
|
+
void router.push("/");
|
|
84
|
+
} else {
|
|
85
|
+
// Shape A — set the active project and hard-reload so every page
|
|
86
|
+
// re-fetches against the new cwd. SSE streams + cached state are
|
|
87
|
+
// tied to the previous project, so a fresh load is the clean pivot.
|
|
88
|
+
setActiveProjectCwd(cwd);
|
|
89
|
+
window.location.assign("/");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function forget(cwd: string) {
|
|
94
|
+
forgetProject(cwd);
|
|
95
|
+
catalog.value = loadCatalog();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fire `nwire cache` against this project's cwd via the Studio supervisor.
|
|
100
|
+
* The supervisor runs it in the background; we poll briefly to surface the
|
|
101
|
+
* "done" state. The composition snapshot updates the next time that project
|
|
102
|
+
* is opened (Studio reads the rebuilt manifest on load).
|
|
103
|
+
*/
|
|
104
|
+
async function rescan(cwd: string) {
|
|
105
|
+
rescanState.value = { ...rescanState.value, [cwd]: "running" };
|
|
106
|
+
try {
|
|
107
|
+
const res = await fetch("/__nwire/run/exec", {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: { "Content-Type": "application/json" },
|
|
110
|
+
body: JSON.stringify({ command: "cache", cwd }),
|
|
111
|
+
});
|
|
112
|
+
if (!res.ok) throw new Error(`exec failed: ${res.status}`);
|
|
113
|
+
// Optimistic — supervisor returned 200 means the child spawned;
|
|
114
|
+
// surface the result in the UI for a beat then revert.
|
|
115
|
+
rescanState.value = { ...rescanState.value, [cwd]: "done" };
|
|
116
|
+
setTimeout(() => {
|
|
117
|
+
const { [cwd]: _, ...rest } = rescanState.value;
|
|
118
|
+
void _;
|
|
119
|
+
rescanState.value = rest;
|
|
120
|
+
}, 2_000);
|
|
121
|
+
} catch {
|
|
122
|
+
rescanState.value = { ...rescanState.value, [cwd]: "error" };
|
|
123
|
+
setTimeout(() => {
|
|
124
|
+
const { [cwd]: _, ...rest } = rescanState.value;
|
|
125
|
+
void _;
|
|
126
|
+
rescanState.value = rest;
|
|
127
|
+
}, 3_000);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
onMounted(() => {
|
|
132
|
+
void refresh();
|
|
133
|
+
});
|
|
134
|
+
</script>
|
|
135
|
+
|
|
136
|
+
<template>
|
|
137
|
+
<div class="h-full overflow-auto">
|
|
138
|
+
<div class="max-w-5xl mx-auto px-8 py-6 space-y-6">
|
|
139
|
+
<header class="flex items-center justify-between">
|
|
140
|
+
<div>
|
|
141
|
+
<h1 class="text-xl font-semibold tracking-tight">Projects</h1>
|
|
142
|
+
<p class="text-sm text-zinc-500 mt-1">
|
|
143
|
+
Every Nwire project this browser has opened. Snapshots are stored locally; live status
|
|
144
|
+
reads each project's
|
|
145
|
+
<code class="text-zinc-400">.nwire/processes/</code>.
|
|
146
|
+
</p>
|
|
147
|
+
</div>
|
|
148
|
+
<button
|
|
149
|
+
class="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-1 rounded border border-zinc-800 hover:border-zinc-700"
|
|
150
|
+
@click="refresh"
|
|
151
|
+
>
|
|
152
|
+
Refresh
|
|
153
|
+
</button>
|
|
154
|
+
</header>
|
|
155
|
+
|
|
156
|
+
<div v-if="projects.length === 0" class="text-sm text-zinc-500 py-12 text-center">
|
|
157
|
+
No projects yet. Each time you run
|
|
158
|
+
<code class="text-zinc-300">nwire studio</code> from a project, it gets added here.
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
162
|
+
<article
|
|
163
|
+
v-for="p in projects"
|
|
164
|
+
:key="p.cwd"
|
|
165
|
+
class="border border-zinc-800 rounded-lg p-4 bg-zinc-950/40 hover:border-zinc-700 transition-colors group"
|
|
166
|
+
>
|
|
167
|
+
<div class="flex items-start justify-between gap-3">
|
|
168
|
+
<div class="min-w-0">
|
|
169
|
+
<div class="flex items-center gap-2">
|
|
170
|
+
<FolderOpen v-if="p.cwd === currentCwd" class="w-4 h-4 text-emerald-400 shrink-0" />
|
|
171
|
+
<Folder v-else class="w-4 h-4 text-zinc-500 shrink-0" />
|
|
172
|
+
<h2 class="font-semibold text-sm tracking-tight truncate">
|
|
173
|
+
{{ p.name }}
|
|
174
|
+
</h2>
|
|
175
|
+
<span
|
|
176
|
+
v-if="p.cwd === currentCwd"
|
|
177
|
+
class="text-[10px] uppercase tracking-wider text-emerald-400 px-1.5 py-0.5 rounded bg-emerald-950/40 border border-emerald-900"
|
|
178
|
+
>
|
|
179
|
+
active
|
|
180
|
+
</span>
|
|
181
|
+
</div>
|
|
182
|
+
<div class="text-[11px] font-mono text-zinc-600 mt-1 truncate" :title="p.cwd">
|
|
183
|
+
{{ p.cwd }}
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
<button
|
|
187
|
+
class="opacity-0 group-hover:opacity-100 text-zinc-600 hover:text-rose-400 transition-opacity p-1"
|
|
188
|
+
:title="`Forget ${p.name}`"
|
|
189
|
+
@click="forget(p.cwd)"
|
|
190
|
+
>
|
|
191
|
+
<Trash2 class="w-3.5 h-3.5" />
|
|
192
|
+
</button>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<!-- Composition -->
|
|
196
|
+
<div v-if="p.composition" class="grid grid-cols-4 gap-2 mt-4 text-xs text-zinc-400">
|
|
197
|
+
<div>
|
|
198
|
+
<div class="text-zinc-600 text-[10px] uppercase tracking-wide">apps</div>
|
|
199
|
+
<div>{{ p.composition.apps }}</div>
|
|
200
|
+
</div>
|
|
201
|
+
<div>
|
|
202
|
+
<div class="text-zinc-600 text-[10px] uppercase tracking-wide">modules</div>
|
|
203
|
+
<div>{{ p.composition.modules }}</div>
|
|
204
|
+
</div>
|
|
205
|
+
<div>
|
|
206
|
+
<div class="text-zinc-600 text-[10px] uppercase tracking-wide">actions</div>
|
|
207
|
+
<div>{{ p.composition.actions }}</div>
|
|
208
|
+
</div>
|
|
209
|
+
<div>
|
|
210
|
+
<div class="text-zinc-600 text-[10px] uppercase tracking-wide">events</div>
|
|
211
|
+
<div>{{ p.composition.events }}</div>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
<div v-else class="mt-4 text-[11px] text-zinc-600">No composition snapshot yet.</div>
|
|
215
|
+
|
|
216
|
+
<!-- Running status -->
|
|
217
|
+
<div class="flex items-center justify-between mt-4 pt-3 border-t border-zinc-900">
|
|
218
|
+
<div class="flex items-center gap-2 text-[11px]">
|
|
219
|
+
<template v-if="status[p.cwd]?.processes.length">
|
|
220
|
+
<Activity class="w-3 h-3 text-emerald-400" />
|
|
221
|
+
<span class="text-emerald-300"> {{ status[p.cwd].processes.length }} running </span>
|
|
222
|
+
<span class="text-zinc-600 font-mono">
|
|
223
|
+
({{ status[p.cwd].processes.map((x) => x.port ?? "?").join(", ") }})
|
|
224
|
+
</span>
|
|
225
|
+
</template>
|
|
226
|
+
<template v-else>
|
|
227
|
+
<Circle class="w-3 h-3 text-zinc-700" />
|
|
228
|
+
<span class="text-zinc-600">idle</span>
|
|
229
|
+
</template>
|
|
230
|
+
</div>
|
|
231
|
+
<div class="flex items-center gap-2">
|
|
232
|
+
<button
|
|
233
|
+
class="text-[11px] text-zinc-500 hover:text-zinc-200 flex items-center gap-1.5 px-2 py-1 rounded border border-zinc-800 hover:border-zinc-700 disabled:opacity-50"
|
|
234
|
+
:disabled="rescanState[p.cwd] === 'running'"
|
|
235
|
+
@click="rescan(p.cwd)"
|
|
236
|
+
:title="`Run nwire cache against ${p.cwd}`"
|
|
237
|
+
data-testid="project-rescan"
|
|
238
|
+
>
|
|
239
|
+
<RefreshCw
|
|
240
|
+
class="w-3 h-3"
|
|
241
|
+
:class="{ 'animate-spin': rescanState[p.cwd] === 'running' }"
|
|
242
|
+
/>
|
|
243
|
+
<template v-if="rescanState[p.cwd] === 'running'">Scanning…</template>
|
|
244
|
+
<template v-else-if="rescanState[p.cwd] === 'done'">Rescanned</template>
|
|
245
|
+
<template v-else-if="rescanState[p.cwd] === 'error'">Failed</template>
|
|
246
|
+
<template v-else>Re-scan</template>
|
|
247
|
+
</button>
|
|
248
|
+
<button
|
|
249
|
+
v-if="p.cwd !== currentCwd"
|
|
250
|
+
class="text-[11px] text-zinc-500 hover:text-zinc-200 flex items-center gap-1.5 px-2 py-1 rounded border border-zinc-800 hover:border-zinc-700"
|
|
251
|
+
@click="copyStartCommand(p.cwd)"
|
|
252
|
+
title="Copy `cd … && nwire studio` to clipboard"
|
|
253
|
+
>
|
|
254
|
+
<Copy class="w-3 h-3" />
|
|
255
|
+
{{ copiedCwd === p.cwd ? "Copied!" : "Copy cmd" }}
|
|
256
|
+
</button>
|
|
257
|
+
<button
|
|
258
|
+
class="text-[11px] text-zinc-100 bg-emerald-700 hover:bg-emerald-600 px-2 py-1 rounded"
|
|
259
|
+
@click="openProject(p.cwd)"
|
|
260
|
+
>
|
|
261
|
+
{{ p.cwd === currentCwd ? "Open" : "Switch" }}
|
|
262
|
+
</button>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
<div class="text-[10px] text-zinc-700 mt-2">
|
|
266
|
+
last visited {{ new Date(p.lastVisited).toLocaleString() }}
|
|
267
|
+
</div>
|
|
268
|
+
</article>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</template>
|