@nwire/studio 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/package.json +5 -3
  2. package/src/App.vue +62 -56
  3. package/src/components/BcCard.stories.ts +47 -0
  4. package/src/components/BcCard.vue +152 -0
  5. package/src/components/DurationBar.stories.ts +55 -0
  6. package/src/components/DurationBar.vue +72 -0
  7. package/src/components/ErrorCard.stories.ts +133 -0
  8. package/src/components/ErrorCard.vue +153 -0
  9. package/src/components/GraphCanvas.stories.ts +48 -0
  10. package/src/components/GraphCanvas.vue +88 -0
  11. package/src/components/KpiTile.stories.ts +32 -0
  12. package/src/components/KpiTile.vue +39 -0
  13. package/src/components/LiveTable.stories.ts +78 -0
  14. package/src/components/LiveTable.vue +186 -0
  15. package/src/components/MetadataInspector.stories.ts +53 -0
  16. package/src/components/MetadataInspector.vue +105 -0
  17. package/src/components/NodeCard.stories.ts +44 -0
  18. package/src/components/NodeCard.vue +150 -0
  19. package/src/components/RcaPanel.stories.ts +95 -0
  20. package/src/components/RcaPanel.vue +223 -0
  21. package/src/components/ServiceNode.vue +134 -0
  22. package/src/components/SourceDrawer.vue +6 -4
  23. package/src/components/SourcePill.vue +10 -3
  24. package/src/components/StatusBadge.stories.ts +33 -0
  25. package/src/components/StatusBadge.vue +54 -0
  26. package/src/components/Waterfall.stories.ts +85 -0
  27. package/src/components/Waterfall.vue +53 -0
  28. package/src/components/WaterfallRow.vue +74 -0
  29. package/src/components/__tests__/BcCard.test.ts +53 -0
  30. package/src/components/__tests__/DurationBar.test.ts +31 -0
  31. package/src/components/__tests__/ErrorCard.test.ts +71 -0
  32. package/src/components/__tests__/KpiTile.test.ts +23 -0
  33. package/src/components/__tests__/LiveTable.test.ts +100 -0
  34. package/src/components/__tests__/MetadataInspector.test.ts +38 -0
  35. package/src/components/__tests__/NodeCard.test.ts +116 -0
  36. package/src/components/__tests__/RcaPanel.test.ts +81 -0
  37. package/src/components/__tests__/StatusBadge.test.ts +23 -0
  38. package/src/components/__tests__/Waterfall.test.ts +54 -0
  39. package/src/components/index.ts +13 -0
  40. package/src/composables/__tests__/composables-context.test.ts +107 -0
  41. package/src/composables/__tests__/useTelemetry.test.ts +104 -0
  42. package/src/composables/__tests__/useTelemetryRuns.test.ts +282 -0
  43. package/src/composables/useDiscovery.ts +73 -0
  44. package/src/composables/useEndpoints.ts +94 -0
  45. package/src/composables/useLogTail.ts +51 -0
  46. package/src/composables/useManifest.ts +43 -0
  47. package/src/composables/useProcesses.ts +114 -0
  48. package/src/composables/useProject.ts +34 -0
  49. package/src/composables/useTelemetry.ts +270 -0
  50. package/src/lib/__tests__/bc-graph.test.ts +218 -0
  51. package/src/lib/__tests__/dispatch-form.test.ts +113 -0
  52. package/src/lib/__tests__/error-friendly.test.ts +198 -0
  53. package/src/lib/__tests__/home.test.ts +231 -0
  54. package/src/lib/__tests__/inspect.test.ts +160 -0
  55. package/src/lib/__tests__/kind-colors.test.ts +59 -0
  56. package/src/lib/__tests__/live-table.test.ts +194 -0
  57. package/src/lib/__tests__/manifest-health.test.ts +120 -0
  58. package/src/lib/__tests__/manifest.test.ts +87 -0
  59. package/src/lib/__tests__/metadata.test.ts +47 -0
  60. package/src/lib/__tests__/node-metrics.test.ts +144 -0
  61. package/src/lib/__tests__/operate.test.ts +97 -0
  62. package/src/lib/__tests__/pipeline-flow.test.ts +79 -0
  63. package/src/lib/__tests__/rca.test.ts +124 -0
  64. package/src/lib/__tests__/telemetry.test.ts +91 -0
  65. package/src/lib/__tests__/topology-graph.test.ts +331 -0
  66. package/src/lib/__tests__/topology-view.test.ts +154 -0
  67. package/src/lib/__tests__/waterfall.test.ts +165 -0
  68. package/src/lib/bc-graph.ts +298 -0
  69. package/src/lib/dispatch-form.ts +160 -0
  70. package/src/lib/error-friendly.ts +288 -0
  71. package/src/lib/home.ts +191 -0
  72. package/src/lib/inspect.ts +226 -0
  73. package/src/lib/kind-colors.ts +132 -0
  74. package/src/lib/live-table.ts +204 -0
  75. package/src/lib/manifest-health.ts +71 -0
  76. package/src/lib/manifest.ts +139 -0
  77. package/src/lib/metadata.ts +52 -0
  78. package/src/lib/node-metrics.ts +242 -0
  79. package/src/lib/operate.ts +114 -0
  80. package/src/lib/pipeline-flow.ts +120 -0
  81. package/src/lib/rca.ts +193 -0
  82. package/src/lib/telemetry.ts +155 -0
  83. package/src/lib/topology-graph.ts +551 -0
  84. package/src/lib/topology-view.ts +185 -0
  85. package/src/lib/waterfall.ts +148 -0
  86. package/src/main.ts +63 -29
  87. package/src/pages/Errors.vue +272 -0
  88. package/src/pages/Home.stories.ts +7 -8
  89. package/src/pages/Home.vue +255 -540
  90. package/src/pages/Hooks.stories.ts +44 -0
  91. package/src/pages/Hooks.vue +165 -164
  92. package/src/pages/Inspect.vue +240 -0
  93. package/src/pages/Map.vue +187 -0
  94. package/src/pages/Operate.vue +74 -0
  95. package/src/pages/Plugins.stories.ts +1 -1
  96. package/src/pages/Plugins.vue +174 -238
  97. package/src/pages/Projects.vue +62 -60
  98. package/src/pages/Streams.vue +344 -0
  99. package/src/pages/Topology.vue +318 -136
  100. package/src/pages/Trace.vue +174 -412
  101. package/src/pages/__tests__/Home.test.ts +109 -54
  102. package/src/pages/__tests__/Hooks.test.ts +5 -5
  103. package/src/pages/__tests__/Inspect.test.ts +111 -0
  104. package/src/pages/__tests__/Plugins.test.ts +85 -35
  105. package/src/pages/__tests__/Trace.test.ts +117 -0
  106. package/src/pages/operate/CommandsPanel.vue +186 -0
  107. package/src/pages/{Dispatch.vue → operate/DispatchPanel.vue} +100 -203
  108. package/src/pages/operate/EndpointPicker.vue +56 -0
  109. package/src/pages/operate/RunPanel.vue +316 -0
  110. package/src/server/__tests__/nwire-read.test.ts +80 -0
  111. package/src/server/nwire-read.ts +63 -0
  112. package/vite.config.ts +220 -2
  113. package/src/lib/__tests__/normalize-cache.test.ts +0 -105
  114. package/src/lib/cache.ts +0 -312
  115. package/src/lib/normalize-cache.ts +0 -92
  116. package/src/pages/Actions.vue +0 -171
  117. package/src/pages/Apps.vue +0 -177
  118. package/src/pages/Commands.vue +0 -262
  119. package/src/pages/Events.vue +0 -210
  120. package/src/pages/Live.vue +0 -249
  121. package/src/pages/Overview.vue +0 -161
  122. package/src/pages/Projections.vue +0 -148
  123. package/src/pages/Queries.vue +0 -148
  124. package/src/pages/Run.vue +0 -618
  125. package/src/pages/Sinks.vue +0 -124
  126. package/src/pages/TraceNode.vue +0 -164
  127. package/src/pages/Workflows.vue +0 -184
  128. package/src/pages/__tests__/Actions.test.ts +0 -98
  129. package/src/pages/__tests__/Projections.test.ts +0 -90
  130. package/src/pages/__tests__/Queries.test.ts +0 -86
@@ -1,175 +1,82 @@
1
1
  <script setup lang="ts">
2
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.
3
+ * Plugins — every plugin composed into the app, read natively from the deep
4
+ * manifest's runtime topology (`topology.plugins`) via `useManifest`.
5
+ *
6
+ * Detail pane shows the plugin's INTERNALS Inspect can't carry: its
7
+ * boot/shutdown lifecycle hooks (`plugin.boot:<n>` / `plugin.shutdown:<n>` from
8
+ * `topology.hooks`), what it contributes (`contributes` / `mounts` graph edges),
9
+ * a same-named capability's ctx-by-kind, and a live lifecycle timeline from
10
+ * `hook.step` telemetry (`useTelemetry`). A `/plugins?name=<n>` deep-link
11
+ * preselects; each lifecycle hook cross-links to `/hooks?name=<hookName>`.
9
12
  */
10
- import { computed, onMounted, onUnmounted, ref, watch } from "vue";
13
+ import { computed, onMounted, ref, watch } from "vue";
11
14
  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;
15
+ import { Activity, Anchor, Puzzle, RefreshCw, Search } from "lucide-vue-next";
16
+ import { useManifest } from "@/composables/useManifest";
17
+ import { useProject } from "@/composables/useProject";
18
+ import { useTelemetry } from "@/composables/useTelemetry";
19
+ import {
20
+ pluginRegistry,
21
+ pluginInternals,
22
+ liveFireTally,
23
+ type PluginRow,
24
+ } from "@/lib/topology-view";
38
25
 
39
26
  const route = useRoute();
40
27
  const router = useRouter();
41
- const { cache } = useCache();
28
+ const { activeCwd } = useProject();
29
+ const { view, isError, error, refetch } = useManifest(activeCwd);
30
+ const { records, status: streamStatus, recent } = useTelemetry(activeCwd);
31
+
42
32
  const filter = ref("");
43
- const kindFilter = ref<"all" | "plugin" | "module">("all");
44
33
  const selected = ref<string | null>(null);
45
- const sourcePreview = ref<{ file: string; line: number; column?: number } | null>(null);
46
34
 
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);
35
+ const plugins = computed<PluginRow[]>(() => pluginRegistry(view.value));
57
36
 
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();
37
+ const filteredPlugins = computed<PluginRow[]>(() => {
38
+ const q = filter.value.toLowerCase();
39
+ return plugins.value.filter((p) => !q || p.name.toLowerCase().includes(q));
68
40
  });
69
41
 
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}`;
42
+ const detail = computed<PluginRow | null>(
43
+ () => plugins.value.find((p) => p.id === selected.value) ?? null,
44
+ );
95
45
 
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
- }
46
+ const internals = computed(() => pluginInternals(view.value, detail.value?.name));
47
+ const ctxByKindEntries = computed(() => Object.entries(internals.value?.ctxByKind ?? {}));
103
48
 
104
- function recordPluginName(rec: PluginTelemetry): string | null {
105
- if (rec.kind === "lifecycle") return rec.payload?.pluginName ?? null;
106
- return pluginNameFromHook(rec.hookName);
49
+ // ── Deep-link preselect: /plugins?name=<n>. ───────────────────────────────
50
+ function applyQueryPreselect(): void {
51
+ const name = route.query.name;
52
+ if (typeof name !== "string" || name.length === 0) return;
53
+ const found = plugins.value.find((p) => p.name === name);
54
+ if (found) selected.value = found.id;
107
55
  }
56
+ onMounted(applyQueryPreselect);
57
+ watch(() => route.query.name, applyQueryPreselect);
58
+ watch(plugins, applyQueryPreselect);
108
59
 
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
- }
60
+ // ── Live lifecycle: fires per plugin, from `plugin.boot/shutdown` hook.step. ─
61
+ const fireTally = computed(() => liveFireTally(records.value));
119
62
 
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;
63
+ function pluginFires(name: string): number {
64
+ let n = 0;
65
+ n += fireTally.value.get(`plugin.boot:${name}`)?.fires ?? 0;
66
+ n += fireTally.value.get(`plugin.shutdown:${name}`)?.fires ?? 0;
67
+ return n;
126
68
  }
127
69
 
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[]>(() => {
70
+ // The selected plugin's recent lifecycle steps, newest first.
71
+ const detailSteps = computed(() => {
169
72
  if (!detail.value) return [];
170
- const name = detail.value.name;
171
- return liveRecords.value
172
- .filter((r) => recordPluginName(r) === name)
73
+ const boot = `plugin.boot:${detail.value.name}`;
74
+ const down = `plugin.shutdown:${detail.value.name}`;
75
+ return recent("hook.step")
76
+ .filter((r) => {
77
+ const hn = (r as Record<string, unknown>).hookName;
78
+ return hn === boot || hn === down;
79
+ })
173
80
  .slice(-50)
174
81
  .reverse();
175
82
  });
@@ -177,10 +84,28 @@ const detailRecords = computed<PluginTelemetry[]>(() => {
177
84
  function openHook(hookName: string): void {
178
85
  void router.push({ path: "/hooks", query: { name: hookName } });
179
86
  }
87
+ function fmtTime(ts: unknown): string {
88
+ if (typeof ts !== "string") return "—";
89
+ const d = new Date(ts);
90
+ return Number.isNaN(d.getTime()) ? "—" : d.toLocaleTimeString(undefined, { hour12: false });
91
+ }
92
+ function stepField(rec: unknown, key: string): string {
93
+ const v = (rec as Record<string, unknown>)[key];
94
+ return v == null ? "" : String(v);
95
+ }
96
+ function lifecyclePhase(rec: unknown): string {
97
+ const hn = stepField(rec, "hookName");
98
+ const head = hn.startsWith("plugin.boot") ? "boot" : "shutdown";
99
+ return `${head} ${stepField(rec, "phase")}`.trim();
100
+ }
101
+ function durationMsOf(rec: unknown): number | null {
102
+ const v = (rec as Record<string, unknown>).durationMs;
103
+ return typeof v === "number" ? v : null;
104
+ }
180
105
  </script>
181
106
 
182
107
  <template>
183
- <div v-if="cache" class="h-full flex flex-col" data-testid="plugins-page">
108
+ <div class="h-full flex flex-col" data-testid="plugins-page">
184
109
  <!-- Header -->
185
110
  <div class="p-6 pb-3 border-b border-zinc-800 flex items-center justify-between">
186
111
  <div>
@@ -188,41 +113,46 @@ function openHook(hookName: string): void {
188
113
  <Puzzle class="w-4 h-4 text-fuchsia-400" />
189
114
  <h1 class="text-lg font-medium">Plugins</h1>
190
115
  <span class="text-[10px] text-zinc-500">
191
- {{ filteredPlugins.length }} / {{ cache.plugins.length }}
116
+ {{ filteredPlugins.length }} / {{ plugins.length }}
192
117
  </span>
193
118
  <span
194
119
  class="ml-2 text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-zinc-900 text-zinc-400"
195
120
  data-testid="stream-status"
196
121
  >
197
- stream {{ streamStatus }}
122
+ tap {{ streamStatus }}
198
123
  </span>
199
124
  </div>
200
125
  <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.
126
+ Plugins composed into the app what each contributes, its boot/shutdown lifecycle, and
127
+ live timing from the running wire.
203
128
  </p>
204
129
  </div>
205
130
  <button
206
131
  class="p-1 rounded hover:bg-zinc-800 text-zinc-400"
207
- title="Reconnect stream"
208
- @click="connect"
132
+ title="Refetch manifest"
133
+ @click="() => refetch()"
209
134
  >
210
135
  <RefreshCw class="w-3.5 h-3.5" />
211
136
  </button>
212
137
  </div>
213
138
 
139
+ <!-- Error -->
140
+ <div v-if="isError" class="p-6 text-xs text-zinc-500" data-testid="plugins-error">
141
+ {{ error ?? "Couldn't load the manifest. Run `nwire cache` to build it." }}
142
+ </div>
143
+
214
144
  <!-- Empty (no plugins at all) -->
215
145
  <div
216
- v-if="cache.plugins.length === 0"
146
+ v-else-if="plugins.length === 0"
217
147
  class="flex-1 flex items-center justify-center p-12"
218
148
  data-testid="plugins-empty"
219
149
  >
220
150
  <div class="text-center max-w-md">
221
151
  <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>
152
+ <div class="text-sm text-zinc-400 font-medium">No plugins in the manifest</div>
223
153
  <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.
154
+ The runtime topology is captured at scan time. Run
155
+ <span class="font-mono">nwire cache</span> after registering a plugin.
226
156
  </div>
227
157
  </div>
228
158
  </div>
@@ -231,60 +161,41 @@ function openHook(hookName: string): void {
231
161
  <div v-else class="flex-1 flex min-h-0">
232
162
  <!-- Master -->
233
163
  <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">
164
+ <div class="px-3 py-2 border-b border-zinc-800">
235
165
  <div class="relative">
236
166
  <Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500" />
237
167
  <input
238
168
  v-model="filter"
239
- placeholder="filter name / app…"
169
+ placeholder="filter name…"
170
+ data-testid="plugins-filter"
240
171
  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
172
  />
242
173
  </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
174
  </div>
259
175
 
260
176
  <div v-if="filteredPlugins.length === 0" class="p-4 text-xs text-zinc-500">
261
177
  No plugins match the filter.
262
178
  </div>
263
179
  <ul v-else class="flex-1 overflow-auto" data-testid="plugins-list">
264
- <li v-for="p in filteredPlugins" :key="key(p)">
180
+ <li v-for="p in filteredPlugins" :key="p.id">
265
181
  <button
266
182
  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)"
183
+ :class="{ 'bg-zinc-900/70': p.id === selected }"
184
+ @click="selected = p.id"
269
185
  >
270
186
  <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
- />
187
+ <Anchor class="w-3 h-3 shrink-0 text-fuchsia-400" />
276
188
  <span class="font-mono text-xs text-zinc-100 truncate flex-1">{{ p.name }}</span>
277
189
  <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"
190
+ v-if="pluginFires(p.name) > 0"
191
+ class="inline-flex items-center gap-1 text-[10px] text-emerald-400 tabular-nums shrink-0"
192
+ :data-testid="`plugin-fires-${p.name}`"
193
+ title="live lifecycle fires observed"
281
194
  >
282
- {{ bootDurations[p.name]!.toFixed(1) }}ms
195
+ <Activity class="w-3 h-3" />
196
+ {{ pluginFires(p.name) }}
283
197
  </span>
284
198
  </div>
285
- <div class="text-[10px] text-zinc-500 mt-0.5 pl-5">
286
- {{ p.app }}
287
- </div>
288
199
  </button>
289
200
  </li>
290
201
  </ul>
@@ -292,52 +203,49 @@ function openHook(hookName: string): void {
292
203
 
293
204
  <!-- Detail -->
294
205
  <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.
206
+ <div v-if="!detail || !internals" class="p-8 text-sm text-zinc-500 italic">
207
+ Select a plugin to view its internals + live lifecycle.
297
208
  </div>
298
209
  <div v-else class="flex-1 flex flex-col overflow-hidden">
299
210
  <!-- Header -->
300
211
  <div class="px-6 py-5 border-b border-zinc-800">
301
212
  <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
- />
213
+ <Anchor class="w-4 h-4 text-fuchsia-400" />
307
214
  <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
- <span v-if="detail.source" class="ml-auto inline-flex">
313
- <SourcePill :source="detail.source" @click="sourcePreview = detail.source!" />
314
- </span>
215
+ <span class="text-[10px] text-zinc-500 font-mono">{{ detail.id }}</span>
315
216
  </div>
316
217
  </div>
317
218
 
318
219
  <div class="flex-1 overflow-auto">
319
- <!-- Contributed hooks -->
220
+ <!-- Lifecycle hooks -->
320
221
  <section class="px-6 py-4 border-b border-zinc-900">
321
222
  <h3 class="text-xs uppercase tracking-wide text-zinc-400 mb-3">
322
- Contributed hooks
223
+ Lifecycle hooks
323
224
  <span class="text-zinc-600 normal-case tracking-normal ml-1">
324
- ({{ contributedHooks.length }})
225
+ ({{ internals.lifecycleHooks.length }})
325
226
  </span>
326
227
  </h3>
327
- <div v-if="contributedHooks.length === 0" class="text-xs text-zinc-500 italic">
228
+ <div
229
+ v-if="internals.lifecycleHooks.length === 0"
230
+ class="text-xs text-zinc-500 italic"
231
+ >
328
232
  No <span class="font-mono">plugin.boot:{{ detail.name }}</span> or
329
233
  <span class="font-mono">plugin.shutdown:{{ detail.name }}</span>
330
234
  hooks registered.
331
235
  </div>
332
236
  <ul v-else class="space-y-1">
333
237
  <li
334
- v-for="h in contributedHooks"
335
- :key="h.id"
238
+ v-for="h in internals.lifecycleHooks"
239
+ :key="h.name"
336
240
  class="flex items-center gap-3 px-3 py-1.5 rounded hover:bg-zinc-900/50 cursor-pointer"
337
241
  :data-testid="`hook-link-${h.name}`"
338
242
  @click="openHook(h.name)"
339
243
  >
340
- <Anchor class="w-3 h-3 text-zinc-500 shrink-0" />
244
+ <span
245
+ class="text-[10px] uppercase tracking-wide shrink-0 w-16"
246
+ :class="h.phase === 'boot' ? 'text-emerald-400' : 'text-amber-400'"
247
+ >{{ h.phase }}</span
248
+ >
341
249
  <span class="font-mono text-xs flex-1 truncate">{{ h.name }}</span>
342
250
  <span class="text-[10px] text-zinc-500 tabular-nums shrink-0">
343
251
  chain {{ h.chain }} · {{ h.listeners }} listener{{
@@ -348,42 +256,72 @@ function openHook(hookName: string): void {
348
256
  </ul>
349
257
  </section>
350
258
 
351
- <!-- Lifecycle timeline -->
259
+ <!-- Contributes -->
260
+ <section class="px-6 py-4 border-b border-zinc-900">
261
+ <h3 class="text-xs uppercase tracking-wide text-zinc-400 mb-3">
262
+ Contributes
263
+ <span class="text-zinc-600 normal-case tracking-normal ml-1">
264
+ ({{ internals.contributes.length }})
265
+ </span>
266
+ </h3>
267
+ <div v-if="internals.contributes.length === 0" class="text-xs text-zinc-500 italic">
268
+ No attributed contributions in the topology.
269
+ </div>
270
+ <div v-else class="flex flex-wrap gap-1.5" data-testid="plugins-contributes">
271
+ <span
272
+ v-for="c in internals.contributes"
273
+ :key="c"
274
+ class="font-mono text-[11px] px-2 py-0.5 rounded border border-zinc-800 bg-zinc-900 text-zinc-300"
275
+ >
276
+ {{ c }}
277
+ </span>
278
+ </div>
279
+ </section>
280
+
281
+ <!-- ctx-by-kind -->
282
+ <section v-if="ctxByKindEntries.length" class="px-6 py-4 border-b border-zinc-900">
283
+ <h3 class="text-xs uppercase tracking-wide text-zinc-400 mb-3">Ctx by kind</h3>
284
+ <div class="space-y-2" data-testid="plugins-ctx-by-kind">
285
+ <div v-for="[kind, keys] in ctxByKindEntries" :key="kind" class="flex gap-2">
286
+ <span class="font-mono text-[11px] text-zinc-500 w-20 shrink-0">{{ kind }}</span>
287
+ <div class="flex flex-wrap gap-1">
288
+ <span
289
+ v-for="k in keys"
290
+ :key="k"
291
+ class="font-mono text-[10px] px-1.5 py-0.5 rounded bg-zinc-900 text-cyan-300"
292
+ >
293
+ {{ k }}
294
+ </span>
295
+ </div>
296
+ </div>
297
+ </div>
298
+ </section>
299
+
300
+ <!-- Live lifecycle timeline -->
352
301
  <section class="px-6 py-4">
353
302
  <div class="flex items-center gap-2 mb-3">
354
303
  <Activity class="w-3.5 h-3.5 text-emerald-400" />
355
- <h3 class="text-xs uppercase tracking-wide text-zinc-400">Lifecycle timeline</h3>
304
+ <h3 class="text-xs uppercase tracking-wide text-zinc-400">Live lifecycle</h3>
356
305
  <span class="text-[10px] text-zinc-500 tabular-nums">
357
- {{ detailRecords.length }} recent
306
+ {{ detailSteps.length }} recent
358
307
  </span>
359
308
  </div>
360
- <div v-if="detailRecords.length === 0" class="text-xs text-zinc-500 italic">
361
- No lifecycle data yet. Boot or restart the wire and events for
309
+ <div v-if="detailSteps.length === 0" class="text-xs text-zinc-500 italic">
310
+ No lifecycle data yet. Boot or restart the wire and steps for
362
311
  <span class="font-mono">{{ detail.name }}</span> will stream here.
363
312
  </div>
364
313
  <ul v-else class="divide-y divide-zinc-900" data-testid="plugins-tap-list">
365
314
  <li
366
- v-for="(r, i) in detailRecords"
315
+ v-for="(r, i) in detailSteps"
367
316
  :key="i"
368
317
  class="px-3 py-1.5 flex items-center gap-3 font-mono text-[11px] hover:bg-zinc-900/30"
369
318
  >
370
319
  <span class="text-zinc-600 tabular-nums w-20 shrink-0">
371
- {{ new Date(r.ts).toLocaleTimeString(undefined, { hour12: false }) }}
320
+ {{ fmtTime(stepField(r, "ts")) }}
372
321
  </span>
373
- <span
374
- class="text-[10px] uppercase tracking-wide shrink-0"
375
- :class="{
376
- 'text-emerald-400': r.kind === 'lifecycle',
377
- 'text-cyan-400': r.kind === 'hook.step',
378
- }"
379
- >{{ r.kind === "lifecycle" ? "event" : "hook" }}</span
380
- >
381
- <span class="text-zinc-200 truncate flex-1">{{ recordPhase(r) }}</span>
382
- <span
383
- v-if="recordDuration(r) !== undefined"
384
- class="text-zinc-500 tabular-nums shrink-0"
385
- >
386
- {{ recordDuration(r)!.toFixed(1) }} ms
322
+ <span class="text-zinc-200 truncate flex-1">{{ lifecyclePhase(r) }}</span>
323
+ <span v-if="durationMsOf(r) !== null" class="text-zinc-500 tabular-nums shrink-0">
324
+ {{ durationMsOf(r)!.toFixed(1) }} ms
387
325
  </span>
388
326
  </li>
389
327
  </ul>
@@ -392,7 +330,5 @@ function openHook(hookName: string): void {
392
330
  </div>
393
331
  </main>
394
332
  </div>
395
-
396
- <SourceDrawer :source="sourcePreview" @close="sourcePreview = null" />
397
333
  </div>
398
334
  </template>