@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,485 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Home — the operational dashboard.
|
|
4
|
+
*
|
|
5
|
+
* This is the page someone actually opens Studio for: "what just broke?",
|
|
6
|
+
* "is anything on fire right now?", "what's hot?". It's a thin orchestrator
|
|
7
|
+
* over three data sources:
|
|
8
|
+
*
|
|
9
|
+
* 1. Recent failures — telemetry stream + /_nwire/telemetry/recent
|
|
10
|
+
* 2. Live metrics — derived from events + telemetry over a 60s window
|
|
11
|
+
* 3. Composition+boot — manifest (useCache) + lifecycle records
|
|
12
|
+
*
|
|
13
|
+
* When the wire isn't running (proxy 502 / SSE error), the live panels mark
|
|
14
|
+
* themselves "no live data" and the composition panel still renders from the
|
|
15
|
+
* static manifest.
|
|
16
|
+
*/
|
|
17
|
+
import { computed, onMounted, onUnmounted, ref } from "vue";
|
|
18
|
+
import { useRouter } from "vue-router";
|
|
19
|
+
import {
|
|
20
|
+
Home as HomeIcon,
|
|
21
|
+
AlertTriangle,
|
|
22
|
+
Activity,
|
|
23
|
+
Boxes,
|
|
24
|
+
Flame,
|
|
25
|
+
CheckCircle2,
|
|
26
|
+
WifiOff,
|
|
27
|
+
} from "lucide-vue-next";
|
|
28
|
+
import { useCache } from "@/lib/cache";
|
|
29
|
+
import { PageHeader, EmptyState } from "@/components";
|
|
30
|
+
|
|
31
|
+
// ── Telemetry record subset we care about ─────────────────────────────
|
|
32
|
+
interface BaseRecord {
|
|
33
|
+
readonly kind: string;
|
|
34
|
+
readonly appName?: string;
|
|
35
|
+
readonly ts: string;
|
|
36
|
+
}
|
|
37
|
+
interface FailureRecord extends BaseRecord {
|
|
38
|
+
readonly kind: "action.failed" | "dlq.recorded" | "reaction.failed";
|
|
39
|
+
readonly action?: string;
|
|
40
|
+
readonly sourceEvent?: string;
|
|
41
|
+
readonly error?: { message?: string };
|
|
42
|
+
readonly envelope?: { correlationId?: string };
|
|
43
|
+
}
|
|
44
|
+
interface ActionDispatched extends BaseRecord {
|
|
45
|
+
readonly kind: "action.dispatched";
|
|
46
|
+
readonly action: string;
|
|
47
|
+
readonly envelope?: { correlationId?: string };
|
|
48
|
+
}
|
|
49
|
+
interface ActionFailed extends BaseRecord {
|
|
50
|
+
readonly kind: "action.failed";
|
|
51
|
+
readonly action: string;
|
|
52
|
+
readonly envelope?: { correlationId?: string };
|
|
53
|
+
}
|
|
54
|
+
interface HookStep extends BaseRecord {
|
|
55
|
+
readonly kind: "hook.step";
|
|
56
|
+
readonly hookName: string;
|
|
57
|
+
readonly phase: "start" | "end" | "error";
|
|
58
|
+
}
|
|
59
|
+
interface LifecycleRecord extends BaseRecord {
|
|
60
|
+
readonly kind: "lifecycle";
|
|
61
|
+
readonly event: string;
|
|
62
|
+
readonly payload: { pluginName?: string; durationMs?: number; kind?: "plugin" | "module" };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type TelemetryLike =
|
|
66
|
+
| FailureRecord
|
|
67
|
+
| ActionDispatched
|
|
68
|
+
| ActionFailed
|
|
69
|
+
| HookStep
|
|
70
|
+
| LifecycleRecord
|
|
71
|
+
| BaseRecord;
|
|
72
|
+
|
|
73
|
+
const router = useRouter();
|
|
74
|
+
const { cache } = useCache();
|
|
75
|
+
|
|
76
|
+
// ── Live data buffers ─────────────────────────────────────────────────
|
|
77
|
+
const failures = ref<FailureRecord[]>([]);
|
|
78
|
+
const dispatched = ref<ActionDispatched[]>([]);
|
|
79
|
+
const actionFailed = ref<ActionFailed[]>([]);
|
|
80
|
+
const hookSteps = ref<HookStep[]>([]);
|
|
81
|
+
const lifecycle = ref<LifecycleRecord[]>([]);
|
|
82
|
+
|
|
83
|
+
const telemetryStatus = ref<"connecting" | "open" | "closed" | "error">("connecting");
|
|
84
|
+
const recentLoaded = ref(false);
|
|
85
|
+
const recentFailed = ref(false);
|
|
86
|
+
|
|
87
|
+
let es: EventSource | null = null;
|
|
88
|
+
|
|
89
|
+
// Re-render metrics each second so the rolling window slides smoothly.
|
|
90
|
+
const now = ref(Date.now());
|
|
91
|
+
let nowTimer: ReturnType<typeof setInterval> | null = null;
|
|
92
|
+
|
|
93
|
+
onMounted(() => {
|
|
94
|
+
connect();
|
|
95
|
+
void loadRecent();
|
|
96
|
+
nowTimer = setInterval(() => {
|
|
97
|
+
now.value = Date.now();
|
|
98
|
+
}, 1000);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
onUnmounted(() => {
|
|
102
|
+
es?.close();
|
|
103
|
+
if (nowTimer) clearInterval(nowTimer);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
function connect(): void {
|
|
107
|
+
telemetryStatus.value = "connecting";
|
|
108
|
+
es?.close();
|
|
109
|
+
es = new EventSource("/_nwire/telemetry/stream");
|
|
110
|
+
es.onopen = () => {
|
|
111
|
+
telemetryStatus.value = "open";
|
|
112
|
+
};
|
|
113
|
+
es.onerror = () => {
|
|
114
|
+
telemetryStatus.value = "error";
|
|
115
|
+
};
|
|
116
|
+
es.onmessage = (m) => {
|
|
117
|
+
try {
|
|
118
|
+
const rec = JSON.parse(m.data) as TelemetryLike;
|
|
119
|
+
ingest(rec);
|
|
120
|
+
} catch {
|
|
121
|
+
/* ignore non-JSON */
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function loadRecent(): Promise<void> {
|
|
127
|
+
try {
|
|
128
|
+
const res = await fetch("/_nwire/telemetry/recent?limit=500");
|
|
129
|
+
if (!res.ok) {
|
|
130
|
+
recentFailed.value = true;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const rows = (await res.json()) as TelemetryLike[];
|
|
134
|
+
for (const r of rows) ingest(r);
|
|
135
|
+
recentLoaded.value = true;
|
|
136
|
+
} catch {
|
|
137
|
+
recentFailed.value = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function ingest(rec: TelemetryLike): void {
|
|
142
|
+
switch (rec.kind) {
|
|
143
|
+
case "action.failed":
|
|
144
|
+
case "dlq.recorded":
|
|
145
|
+
case "reaction.failed":
|
|
146
|
+
failures.value.unshift(rec as FailureRecord);
|
|
147
|
+
if (failures.value.length > 200) failures.value.length = 200;
|
|
148
|
+
if (rec.kind === "action.failed") {
|
|
149
|
+
actionFailed.value.push(rec as ActionFailed);
|
|
150
|
+
if (actionFailed.value.length > 2000) actionFailed.value.splice(0, 500);
|
|
151
|
+
}
|
|
152
|
+
break;
|
|
153
|
+
case "action.dispatched":
|
|
154
|
+
dispatched.value.push(rec as ActionDispatched);
|
|
155
|
+
if (dispatched.value.length > 2000) dispatched.value.splice(0, 500);
|
|
156
|
+
break;
|
|
157
|
+
case "hook.step":
|
|
158
|
+
hookSteps.value.push(rec as HookStep);
|
|
159
|
+
if (hookSteps.value.length > 2000) hookSteps.value.splice(0, 500);
|
|
160
|
+
break;
|
|
161
|
+
case "lifecycle":
|
|
162
|
+
lifecycle.value.push(rec as LifecycleRecord);
|
|
163
|
+
if (lifecycle.value.length > 500) lifecycle.value.splice(0, 100);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Section 1: recent failures (last 10) ──────────────────────────────
|
|
169
|
+
const recentFailures = computed(() => failures.value.slice(0, 10));
|
|
170
|
+
|
|
171
|
+
function failureLabel(f: FailureRecord): string {
|
|
172
|
+
return f.action ?? f.sourceEvent ?? f.kind;
|
|
173
|
+
}
|
|
174
|
+
function failureError(f: FailureRecord): string {
|
|
175
|
+
const m = f.error?.message ?? "";
|
|
176
|
+
return m.length > 80 ? `${m.slice(0, 80)}…` : m;
|
|
177
|
+
}
|
|
178
|
+
function openTrace(f: FailureRecord): void {
|
|
179
|
+
const id = f.envelope?.correlationId;
|
|
180
|
+
if (!id) return;
|
|
181
|
+
void router.push({ path: "/trace", query: { correlationId: id } });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Section 2: live metrics (rolling 60s) ─────────────────────────────
|
|
185
|
+
const WINDOW_MS = 60_000;
|
|
186
|
+
|
|
187
|
+
function inWindow(rec: { ts: string }): boolean {
|
|
188
|
+
return now.value - new Date(rec.ts).getTime() <= WINDOW_MS;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const reqPerSec = computed(() => {
|
|
192
|
+
const n = dispatched.value.filter(inWindow).length;
|
|
193
|
+
return (n / 60).toFixed(2);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const errorRate = computed(() => {
|
|
197
|
+
const recentDispatched = dispatched.value.filter(inWindow);
|
|
198
|
+
if (recentDispatched.length === 0) return "0.0";
|
|
199
|
+
const failedCorr = new Set(
|
|
200
|
+
actionFailed.value
|
|
201
|
+
.filter(inWindow)
|
|
202
|
+
.map((f) => f.envelope?.correlationId)
|
|
203
|
+
.filter((x): x is string => !!x),
|
|
204
|
+
);
|
|
205
|
+
let bad = 0;
|
|
206
|
+
for (const d of recentDispatched) {
|
|
207
|
+
const id = d.envelope?.correlationId;
|
|
208
|
+
if (id && failedCorr.has(id)) bad++;
|
|
209
|
+
}
|
|
210
|
+
return ((bad / recentDispatched.length) * 100).toFixed(1);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const hottestHooks = computed(() => {
|
|
214
|
+
const counts = new Map<string, number>();
|
|
215
|
+
for (const s of hookSteps.value) {
|
|
216
|
+
if (s.phase !== "end") continue;
|
|
217
|
+
if (!inWindow(s)) continue;
|
|
218
|
+
counts.set(s.hookName, (counts.get(s.hookName) ?? 0) + 1);
|
|
219
|
+
}
|
|
220
|
+
return [...counts.entries()]
|
|
221
|
+
.sort((a, b) => b[1] - a[1])
|
|
222
|
+
.slice(0, 3)
|
|
223
|
+
.map(([name, count]) => ({ name, count }));
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const hasLiveData = computed(() => telemetryStatus.value === "open" || recentLoaded.value);
|
|
227
|
+
|
|
228
|
+
// ── Section 3: composition + boot phases ──────────────────────────────
|
|
229
|
+
const pluginsBootedRecords = computed(() =>
|
|
230
|
+
lifecycle.value.filter((l) => l.event === "nwire.plugin.booted"),
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const bootSummary = computed(() => {
|
|
234
|
+
const recs = pluginsBootedRecords.value;
|
|
235
|
+
if (recs.length === 0) return null;
|
|
236
|
+
let plugins = 0;
|
|
237
|
+
let modules = 0;
|
|
238
|
+
let totalMs = 0;
|
|
239
|
+
for (const r of recs) {
|
|
240
|
+
totalMs += r.payload.durationMs ?? 0;
|
|
241
|
+
if (r.payload.kind === "module") modules++;
|
|
242
|
+
else plugins++;
|
|
243
|
+
}
|
|
244
|
+
return { total: recs.length, plugins, modules, totalMs };
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const compositionStats = computed(() => {
|
|
248
|
+
if (!cache.value) return null;
|
|
249
|
+
return {
|
|
250
|
+
apps: cache.value.apps.length,
|
|
251
|
+
modules: cache.value.modules.length,
|
|
252
|
+
plugins: cache.value.plugins.length,
|
|
253
|
+
actions: cache.value.actions.length,
|
|
254
|
+
events: cache.value.events.length,
|
|
255
|
+
};
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
function openPluginHook(name: string): void {
|
|
259
|
+
void router.push({ path: "/hooks", query: { name: `plugin.boot:${name}` } });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function shortTime(ts: string): string {
|
|
263
|
+
return new Date(ts).toLocaleTimeString(undefined, { hour12: false });
|
|
264
|
+
}
|
|
265
|
+
</script>
|
|
266
|
+
|
|
267
|
+
<template>
|
|
268
|
+
<div class="p-6 space-y-6" data-testid="home-page">
|
|
269
|
+
<PageHeader
|
|
270
|
+
title="Home"
|
|
271
|
+
:icon="HomeIcon"
|
|
272
|
+
icon-color="text-emerald-400"
|
|
273
|
+
:subtitle="
|
|
274
|
+
hasLiveData
|
|
275
|
+
? 'Live system snapshot — failures, throughput, and what got booted'
|
|
276
|
+
: 'Wire not reporting — showing static composition only'
|
|
277
|
+
"
|
|
278
|
+
/>
|
|
279
|
+
|
|
280
|
+
<!-- Section 1: Recent failures ─────────────────────────────────── -->
|
|
281
|
+
<section data-testid="home-failures">
|
|
282
|
+
<div class="flex items-center justify-between mb-2">
|
|
283
|
+
<h2
|
|
284
|
+
class="text-sm font-medium text-zinc-300 uppercase tracking-wide flex items-center gap-2"
|
|
285
|
+
>
|
|
286
|
+
<AlertTriangle class="w-4 h-4 text-rose-400" />
|
|
287
|
+
Recent failures
|
|
288
|
+
</h2>
|
|
289
|
+
<span class="text-[10px] text-zinc-500 tabular-nums">
|
|
290
|
+
{{ recentFailures.length }} shown · {{ failures.length }} total
|
|
291
|
+
</span>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
<div
|
|
295
|
+
v-if="!hasLiveData && recentFailed"
|
|
296
|
+
class="rounded border border-zinc-800 bg-zinc-900/40 px-4 py-6 text-sm text-zinc-500 flex items-center gap-2"
|
|
297
|
+
data-testid="home-failures-no-live"
|
|
298
|
+
>
|
|
299
|
+
<WifiOff class="w-4 h-4" /> No live data — the wire isn't reporting.
|
|
300
|
+
</div>
|
|
301
|
+
<div
|
|
302
|
+
v-else-if="recentFailures.length === 0"
|
|
303
|
+
class="rounded border border-zinc-800 bg-zinc-900/40 px-4 py-6 text-sm text-zinc-400 flex items-center gap-2"
|
|
304
|
+
data-testid="home-failures-empty"
|
|
305
|
+
>
|
|
306
|
+
<CheckCircle2 class="w-4 h-4 text-emerald-400" />
|
|
307
|
+
No failures in the last 10 minutes
|
|
308
|
+
</div>
|
|
309
|
+
<ul v-else class="rounded border border-zinc-800 divide-y divide-zinc-900">
|
|
310
|
+
<li
|
|
311
|
+
v-for="(f, i) in recentFailures"
|
|
312
|
+
:key="`${f.ts}-${i}`"
|
|
313
|
+
class="px-4 py-2 flex items-center gap-3 hover:bg-zinc-900/40 font-mono text-xs"
|
|
314
|
+
data-testid="home-failure-row"
|
|
315
|
+
>
|
|
316
|
+
<span class="text-zinc-600 tabular-nums w-20 shrink-0">
|
|
317
|
+
{{ shortTime(f.ts) }}
|
|
318
|
+
</span>
|
|
319
|
+
<span
|
|
320
|
+
class="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded shrink-0"
|
|
321
|
+
:class="{
|
|
322
|
+
'bg-rose-950/40 text-rose-300': f.kind === 'action.failed',
|
|
323
|
+
'bg-orange-950/40 text-orange-300': f.kind === 'dlq.recorded',
|
|
324
|
+
'bg-amber-950/40 text-amber-300': f.kind === 'reaction.failed',
|
|
325
|
+
}"
|
|
326
|
+
>{{ f.kind.replace(/\..*$/, "") }}</span
|
|
327
|
+
>
|
|
328
|
+
<span class="text-zinc-100 truncate flex-1">{{ failureLabel(f) }}</span>
|
|
329
|
+
<span class="text-red-400 truncate max-w-[40%]">{{ failureError(f) }}</span>
|
|
330
|
+
<button
|
|
331
|
+
v-if="f.envelope?.correlationId"
|
|
332
|
+
type="button"
|
|
333
|
+
class="text-xs text-emerald-400 hover:text-emerald-300 shrink-0"
|
|
334
|
+
@click="openTrace(f)"
|
|
335
|
+
>
|
|
336
|
+
→ trace
|
|
337
|
+
</button>
|
|
338
|
+
</li>
|
|
339
|
+
</ul>
|
|
340
|
+
</section>
|
|
341
|
+
|
|
342
|
+
<!-- Section 2: Live metrics ────────────────────────────────────── -->
|
|
343
|
+
<section data-testid="home-metrics">
|
|
344
|
+
<div class="flex items-center justify-between mb-2">
|
|
345
|
+
<h2
|
|
346
|
+
class="text-sm font-medium text-zinc-300 uppercase tracking-wide flex items-center gap-2"
|
|
347
|
+
>
|
|
348
|
+
<Activity class="w-4 h-4 text-emerald-400" />
|
|
349
|
+
Live metrics
|
|
350
|
+
<span class="text-[10px] text-zinc-500 normal-case tracking-normal">(rolling 60s)</span>
|
|
351
|
+
</h2>
|
|
352
|
+
<span
|
|
353
|
+
class="text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-zinc-900 text-zinc-400"
|
|
354
|
+
data-testid="home-metrics-status"
|
|
355
|
+
>
|
|
356
|
+
stream {{ telemetryStatus }}
|
|
357
|
+
</span>
|
|
358
|
+
</div>
|
|
359
|
+
|
|
360
|
+
<div
|
|
361
|
+
v-if="!hasLiveData"
|
|
362
|
+
class="rounded border border-zinc-800 bg-zinc-900/40 px-4 py-6 text-sm text-zinc-500 flex items-center gap-2"
|
|
363
|
+
data-testid="home-metrics-no-live"
|
|
364
|
+
>
|
|
365
|
+
<WifiOff class="w-4 h-4" /> No live data — start the wire and metrics will flow here.
|
|
366
|
+
</div>
|
|
367
|
+
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
368
|
+
<div
|
|
369
|
+
class="rounded-lg border border-zinc-800 bg-zinc-900/50 px-4 py-3"
|
|
370
|
+
data-testid="home-metric-rps"
|
|
371
|
+
>
|
|
372
|
+
<div class="text-[11px] uppercase tracking-wide text-zinc-500">req/s</div>
|
|
373
|
+
<div class="text-2xl font-semibold tabular-nums mt-1">{{ reqPerSec }}</div>
|
|
374
|
+
<div class="text-[10px] text-zinc-500 mt-1">actions dispatched ÷ 60</div>
|
|
375
|
+
</div>
|
|
376
|
+
<div
|
|
377
|
+
class="rounded-lg border border-zinc-800 bg-zinc-900/50 px-4 py-3"
|
|
378
|
+
data-testid="home-metric-error-rate"
|
|
379
|
+
>
|
|
380
|
+
<div class="text-[11px] uppercase tracking-wide text-zinc-500">error rate</div>
|
|
381
|
+
<div
|
|
382
|
+
class="text-2xl font-semibold tabular-nums mt-1"
|
|
383
|
+
:class="Number(errorRate) > 0 ? 'text-rose-300' : 'text-emerald-300'"
|
|
384
|
+
>
|
|
385
|
+
{{ errorRate }}%
|
|
386
|
+
</div>
|
|
387
|
+
<div class="text-[10px] text-zinc-500 mt-1">dispatched → failed by correlationId</div>
|
|
388
|
+
</div>
|
|
389
|
+
<div
|
|
390
|
+
class="rounded-lg border border-zinc-800 bg-zinc-900/50 px-4 py-3"
|
|
391
|
+
data-testid="home-metric-hot-hooks"
|
|
392
|
+
>
|
|
393
|
+
<div class="text-[11px] uppercase tracking-wide text-zinc-500 flex items-center gap-1">
|
|
394
|
+
<Flame class="w-3 h-3 text-amber-400" /> hottest hooks
|
|
395
|
+
</div>
|
|
396
|
+
<ul v-if="hottestHooks.length > 0" class="mt-1 space-y-0.5">
|
|
397
|
+
<li
|
|
398
|
+
v-for="h in hottestHooks"
|
|
399
|
+
:key="h.name"
|
|
400
|
+
class="flex items-center justify-between font-mono text-xs"
|
|
401
|
+
>
|
|
402
|
+
<span class="truncate">{{ h.name }}</span>
|
|
403
|
+
<span class="text-zinc-500 tabular-nums">{{ h.count }}</span>
|
|
404
|
+
</li>
|
|
405
|
+
</ul>
|
|
406
|
+
<div v-else class="text-[11px] text-zinc-500 mt-2">No hook activity in window.</div>
|
|
407
|
+
</div>
|
|
408
|
+
</div>
|
|
409
|
+
</section>
|
|
410
|
+
|
|
411
|
+
<!-- Section 3: composition + boot ─────────────────────────────── -->
|
|
412
|
+
<section data-testid="home-composition">
|
|
413
|
+
<div class="flex items-center justify-between mb-2">
|
|
414
|
+
<h2
|
|
415
|
+
class="text-sm font-medium text-zinc-300 uppercase tracking-wide flex items-center gap-2"
|
|
416
|
+
>
|
|
417
|
+
<Boxes class="w-4 h-4 text-cyan-400" />
|
|
418
|
+
Composition
|
|
419
|
+
</h2>
|
|
420
|
+
</div>
|
|
421
|
+
|
|
422
|
+
<EmptyState
|
|
423
|
+
v-if="!cache"
|
|
424
|
+
title="No manifest yet"
|
|
425
|
+
hint="Run `nwire cache` in your project to populate Studio."
|
|
426
|
+
/>
|
|
427
|
+
<div v-else class="space-y-3">
|
|
428
|
+
<div
|
|
429
|
+
v-if="bootSummary"
|
|
430
|
+
class="rounded-lg border border-zinc-800 bg-zinc-900/50 px-4 py-3 text-sm"
|
|
431
|
+
data-testid="home-boot-summary"
|
|
432
|
+
>
|
|
433
|
+
Booted <span class="font-semibold">{{ bootSummary.total }}</span> plugins (<span
|
|
434
|
+
class="tabular-nums"
|
|
435
|
+
>{{ bootSummary.modules }}</span
|
|
436
|
+
>
|
|
437
|
+
modules + <span class="tabular-nums">{{ bootSummary.plugins }}</span> plugins) in
|
|
438
|
+
<span class="tabular-nums">{{ bootSummary.totalMs.toFixed(0) }}</span> ms
|
|
439
|
+
</div>
|
|
440
|
+
<div
|
|
441
|
+
v-else-if="compositionStats"
|
|
442
|
+
class="rounded-lg border border-zinc-800 bg-zinc-900/50 px-4 py-3 text-sm text-zinc-400"
|
|
443
|
+
data-testid="home-boot-static"
|
|
444
|
+
>
|
|
445
|
+
<span class="tabular-nums">{{ compositionStats.apps }}</span> app(s),
|
|
446
|
+
<span class="tabular-nums">{{ compositionStats.modules }}</span> module(s),
|
|
447
|
+
<span class="tabular-nums">{{ compositionStats.plugins }}</span> plugin(s) ·
|
|
448
|
+
<span class="tabular-nums">{{ compositionStats.actions }}</span> actions ·
|
|
449
|
+
<span class="tabular-nums">{{ compositionStats.events }}</span> events
|
|
450
|
+
<div v-if="!hasLiveData" class="text-[11px] text-zinc-500 mt-1">
|
|
451
|
+
Boot timings appear once the wire is running.
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
|
|
455
|
+
<ul
|
|
456
|
+
v-if="pluginsBootedRecords.length > 0"
|
|
457
|
+
class="rounded border border-zinc-800 divide-y divide-zinc-900"
|
|
458
|
+
data-testid="home-boot-list"
|
|
459
|
+
>
|
|
460
|
+
<li
|
|
461
|
+
v-for="(p, i) in pluginsBootedRecords"
|
|
462
|
+
:key="`${p.payload.pluginName ?? 'plugin'}-${i}`"
|
|
463
|
+
class="px-4 py-1.5 flex items-center gap-3 font-mono text-xs hover:bg-zinc-900/40"
|
|
464
|
+
>
|
|
465
|
+
<span
|
|
466
|
+
class="text-[10px] uppercase tracking-wide w-14 shrink-0"
|
|
467
|
+
:class="p.payload.kind === 'module' ? 'text-cyan-400' : 'text-violet-400'"
|
|
468
|
+
>{{ p.payload.kind ?? "plugin" }}</span
|
|
469
|
+
>
|
|
470
|
+
<button
|
|
471
|
+
type="button"
|
|
472
|
+
class="flex-1 text-left text-zinc-100 truncate hover:text-emerald-300"
|
|
473
|
+
@click="openPluginHook(p.payload.pluginName ?? 'unknown')"
|
|
474
|
+
>
|
|
475
|
+
{{ p.payload.pluginName ?? "(unnamed)" }}
|
|
476
|
+
</button>
|
|
477
|
+
<span class="text-zinc-500 tabular-nums shrink-0">
|
|
478
|
+
{{ (p.payload.durationMs ?? 0).toFixed(1) }} ms
|
|
479
|
+
</span>
|
|
480
|
+
</li>
|
|
481
|
+
</ul>
|
|
482
|
+
</div>
|
|
483
|
+
</section>
|
|
484
|
+
</div>
|
|
485
|
+
</template>
|