@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
@@ -0,0 +1,44 @@
1
+ import type { Meta, StoryObj } from "@storybook/vue3-vite";
2
+ import { createRouter, createMemoryHistory } from "vue-router";
3
+ import Hooks from "./Hooks.vue";
4
+
5
+ /**
6
+ * Storybook for the Hooks page. The manifest fetch + telemetry stream fail
7
+ * inside Storybook, so the page degrades to its empty / no-live-tap state —
8
+ * exactly the surface we want to document.
9
+ */
10
+ const router = createRouter({
11
+ history: createMemoryHistory(),
12
+ routes: [
13
+ { path: "/hooks", name: "hooks", component: Hooks },
14
+ { path: "/plugins", name: "plugins", component: { template: "<div/>" } },
15
+ ],
16
+ });
17
+
18
+ const meta: Meta<typeof Hooks> = {
19
+ title: "Pages/Hooks",
20
+ component: Hooks,
21
+ decorators: [
22
+ (story) => ({
23
+ components: { story },
24
+ template: '<div class="h-screen bg-zinc-950 text-zinc-100"><story /></div>',
25
+ }),
26
+ ],
27
+ parameters: {
28
+ layout: "fullscreen",
29
+ vueRouter: { router },
30
+ },
31
+ render: () => ({
32
+ components: { Hooks },
33
+ setup() {
34
+ return {};
35
+ },
36
+ template: "<Hooks />",
37
+ }),
38
+ };
39
+ export default meta;
40
+
41
+ type Story = StoryObj<typeof Hooks>;
42
+
43
+ /** Default: no wire running — empty registry + idle tap. */
44
+ export const NoLiveData: Story = {};
@@ -1,127 +1,108 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
- * Hooks — the unified extension-point primitive every nwire substrate now
4
- * collapses onto. Each row in `cache.hooks` is one named hook with its
5
- * chain + listener counts and the source location of `hook("name")`.
3
+ * Hooks — the unified extension-point primitive every nwire substrate collapses
4
+ * onto. Each row is one named hook from the deep manifest's runtime topology
5
+ * (`topology.hooks`: name + chain/listener counts), read natively via
6
+ * `useManifest`.
6
7
  *
7
- * Detail pane shows steady-state metadata + the live tap stream (last-N
8
- * step observations) for the selected hook, fed by the running wire's
9
- * `/_nwire/events/stream` channel under `kind: "hook.step"`.
8
+ * Detail pane shows steady-state metadata (MetadataInspector) plus the LIVE
9
+ * hook-tap: per-hook fire activity sourced from `hook.step` telemetry
10
+ * (`useTelemetry`), keyed by hook name. A `plugin.boot:<n>` / `plugin.shutdown:<n>`
11
+ * hook cross-links to `/plugins?name=<n>` ("Registered by").
10
12
  */
11
- import { computed, onMounted, onUnmounted, ref, watch } from "vue";
13
+ import { computed, onMounted, ref, watch } from "vue";
12
14
  import { useRoute, useRouter } from "vue-router";
13
- import { useCache, type HookEntry } from "@/lib/cache";
14
15
  import { Activity, Boxes, Puzzle, RefreshCw, Search } from "lucide-vue-next";
15
- import { SourcePill, SourceDrawer } from "@/components";
16
-
17
- interface StepRecord {
18
- readonly kind: "hook.step";
19
- readonly hookName: string;
20
- readonly hookId: string;
21
- readonly runId: string;
22
- readonly parentRunId?: string;
23
- readonly stepId: number;
24
- readonly stepKind: "chain" | "listener";
25
- readonly stepName?: string;
26
- readonly phase: "start" | "end" | "error";
27
- readonly durationMs?: number;
28
- readonly error?: { message?: string };
29
- readonly ts: string;
30
- }
16
+ import { useManifest } from "@/composables/useManifest";
17
+ import { useProject } from "@/composables/useProject";
18
+ import { useTelemetry } from "@/composables/useTelemetry";
19
+ import { hookRegistry, registeringPlugin, liveFireTally, type HookRow } from "@/lib/topology-view";
20
+ import { MetadataInspector, SourcePill, SourceDrawer } from "@/components";
31
21
 
32
22
  const route = useRoute();
33
23
  const router = useRouter();
34
- const { cache } = useCache();
24
+ const { activeCwd } = useProject();
25
+ const { view, isError, error, refetch } = useManifest(activeCwd);
26
+ const { records, status: streamStatus, recent } = useTelemetry(activeCwd);
27
+
35
28
  const filter = ref("");
36
29
  const selected = ref<string | null>(null);
37
- const sourcePreview = ref<{ file: string; line: number; column?: number } | null>(null);
30
+ const sourcePreview = ref<{ file: string; line?: number; column?: number } | null>(null);
31
+
32
+ const hooks = computed<HookRow[]>(() => hookRegistry(view.value));
33
+
34
+ const filteredHooks = computed<HookRow[]>(() => {
35
+ const q = filter.value.toLowerCase();
36
+ return hooks.value.filter((h) => !q || h.name.toLowerCase().includes(q));
37
+ });
38
38
 
39
+ const detail = computed<HookRow | null>(
40
+ () => hooks.value.find((h) => h.id === selected.value) ?? null,
41
+ );
42
+
43
+ // ── Deep-link preselect: /hooks?name=<hookName> → first name match. ───────
39
44
  function applyQueryPreselect(): void {
40
45
  const name = route.query.name;
41
46
  if (typeof name !== "string" || name.length === 0) return;
42
- // Hooks select by id; query is by hook name. First name match wins.
43
- const found = cache.value?.hooks.find((h) => h.name === name);
47
+ const found = hooks.value.find((h) => h.name === name);
44
48
  if (found) selected.value = found.id;
45
49
  }
46
-
47
50
  onMounted(applyQueryPreselect);
48
51
  watch(() => route.query.name, applyQueryPreselect);
49
- watch(() => cache.value, applyQueryPreselect);
52
+ watch(hooks, applyQueryPreselect);
50
53
 
51
- // ── Live tap stream ───────────────────────────────────────────────────
52
- const liveSteps = ref<StepRecord[]>([]);
53
- const streamStatus = ref<"connecting" | "open" | "closed" | "error">("connecting");
54
- let es: EventSource | null = null;
55
-
56
- onMounted(() => {
57
- connect();
58
- });
59
- onUnmounted(() => {
60
- es?.close();
61
- });
54
+ // ── Live hook-tap: fire tally per hook name, from `hook.step` telemetry. ──
55
+ const fireTally = computed(() => liveFireTally(records.value));
62
56
 
63
- function connect(): void {
64
- streamStatus.value = "connecting";
65
- es?.close();
66
- es = new EventSource("/_nwire/telemetry/stream");
67
- es.onopen = () => {
68
- streamStatus.value = "open";
69
- };
70
- es.onerror = () => {
71
- streamStatus.value = "error";
72
- };
73
- es.onmessage = (m) => {
74
- try {
75
- const rec = JSON.parse(m.data) as { kind?: string };
76
- if (rec.kind !== "hook.step") return;
77
- liveSteps.value.push(rec as StepRecord);
78
- if (liveSteps.value.length > 500) liveSteps.value.splice(0, 200);
79
- } catch {
80
- /* ignore non-JSON frames */
81
- }
82
- };
57
+ function firesFor(name: string): number {
58
+ return fireTally.value.get(name)?.fires ?? 0;
83
59
  }
60
+ function lastFiredFor(name: string): string | undefined {
61
+ return fireTally.value.get(name)?.lastTs;
62
+ }
63
+ const totalFires = computed(() => recent("hook.step").length);
84
64
 
85
- const filteredHooks = computed<HookEntry[]>(() => {
86
- if (!cache.value) return [];
87
- const q = filter.value.toLowerCase();
88
- return cache.value.hooks.filter(
89
- (h) => !q || h.name.toLowerCase().includes(q) || h.id.toLowerCase().includes(q),
90
- );
91
- });
92
-
93
- const detail = computed<HookEntry | null>(
94
- () => cache.value?.hooks.find((h) => h.id === selected.value) ?? null,
95
- );
96
-
97
- const detailSteps = computed<StepRecord[]>(() => {
65
+ // The selected hook's recent live steps, newest first.
66
+ const detailSteps = computed(() => {
98
67
  if (!detail.value) return [];
99
- return liveSteps.value
100
- .filter((s) => s.hookName === detail.value!.name)
68
+ const name = detail.value.name;
69
+ return recent("hook.step")
70
+ .filter((r) => (r as Record<string, unknown>).hookName === name)
101
71
  .slice(-50)
102
72
  .reverse();
103
73
  });
104
74
 
105
- /**
106
- * Parse `plugin.boot:<name>` / `plugin.shutdown:<name>` → `<name>`.
107
- * Used by the "Registered by" cross-link to /plugins?name=…
108
- */
109
- const registeringPlugin = computed<string | null>(() => {
110
- const n = detail.value?.name;
111
- if (!n) return null;
112
- for (const prefix of ["plugin.boot:", "plugin.shutdown:"]) {
113
- if (n.startsWith(prefix)) return n.slice(prefix.length);
114
- }
115
- return null;
116
- });
75
+ const registeredBy = computed<string | null>(() => registeringPlugin(detail.value?.name));
117
76
 
118
- watch(detail, () => {
119
- /* allow new selection to redraw */
77
+ const detailMeta = computed(() => {
78
+ if (!detail.value) return null;
79
+ return {
80
+ name: detail.value.name,
81
+ id: detail.value.id,
82
+ chainSteps: detail.value.chain,
83
+ listeners: detail.value.listeners,
84
+ liveFires: firesFor(detail.value.name),
85
+ lastFired: lastFiredFor(detail.value.name) ?? null,
86
+ };
120
87
  });
88
+
89
+ function fmtTime(ts: string | undefined): string {
90
+ if (!ts) return "—";
91
+ const d = new Date(ts);
92
+ return Number.isNaN(d.getTime()) ? "—" : d.toLocaleTimeString(undefined, { hour12: false });
93
+ }
94
+ function stepField(rec: unknown, key: string): string {
95
+ const v = (rec as Record<string, unknown>)[key];
96
+ return v == null ? "" : String(v);
97
+ }
98
+ function durationMsOf(rec: unknown): number | null {
99
+ const v = (rec as Record<string, unknown>).durationMs;
100
+ return typeof v === "number" ? v : null;
101
+ }
121
102
  </script>
122
103
 
123
104
  <template>
124
- <div v-if="cache" class="h-full flex flex-col" data-testid="hooks-page">
105
+ <div class="h-full flex flex-col" data-testid="hooks-page">
125
106
  <!-- Header -->
126
107
  <div class="p-6 pb-3 border-b border-zinc-800 flex items-center justify-between">
127
108
  <div>
@@ -129,13 +110,20 @@ watch(detail, () => {
129
110
  <Boxes class="w-4 h-4 text-orange-400" />
130
111
  <h1 class="text-lg font-medium">Hooks</h1>
131
112
  <span class="text-[10px] text-zinc-500">
132
- {{ filteredHooks.length }} / {{ cache.hooks.length }}
113
+ {{ filteredHooks.length }} / {{ hooks.length }}
133
114
  </span>
134
115
  <span
135
116
  class="ml-2 text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-zinc-900 text-zinc-400"
136
117
  data-testid="stream-status"
137
118
  >
138
- stream {{ streamStatus }}
119
+ tap {{ streamStatus }}
120
+ </span>
121
+ <span
122
+ v-if="totalFires > 0"
123
+ class="text-[9px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-emerald-950 text-emerald-300"
124
+ data-testid="hooks-total-fires"
125
+ >
126
+ {{ totalFires }} live fires
139
127
  </span>
140
128
  </div>
141
129
  <p class="text-xs text-zinc-500 mt-1">
@@ -145,15 +133,20 @@ watch(detail, () => {
145
133
  </div>
146
134
  <button
147
135
  class="p-1 rounded hover:bg-zinc-800 text-zinc-400"
148
- title="Reconnect stream"
149
- @click="connect"
136
+ title="Refetch manifest"
137
+ @click="() => refetch()"
150
138
  >
151
139
  <RefreshCw class="w-3.5 h-3.5" />
152
140
  </button>
153
141
  </div>
154
142
 
143
+ <!-- Error -->
144
+ <div v-if="isError" class="p-6 text-xs text-zinc-500" data-testid="hooks-error">
145
+ {{ error ?? "Couldn't load the manifest. Run `nwire cache` to build it." }}
146
+ </div>
147
+
155
148
  <!-- Master / detail -->
156
- <div class="flex-1 flex min-h-0">
149
+ <div v-else class="flex-1 flex min-h-0">
157
150
  <!-- Master -->
158
151
  <aside class="w-80 border-r border-zinc-800 flex flex-col shrink-0">
159
152
  <div class="px-3 py-2 border-b border-zinc-800">
@@ -162,6 +155,7 @@ watch(detail, () => {
162
155
  <input
163
156
  v-model="filter"
164
157
  placeholder="filter…"
158
+ data-testid="hooks-filter"
165
159
  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"
166
160
  />
167
161
  </div>
@@ -169,12 +163,12 @@ watch(detail, () => {
169
163
 
170
164
  <div v-if="filteredHooks.length === 0" class="p-4 text-xs text-zinc-500">
171
165
  {{
172
- cache.hooks.length === 0
173
- ? "No hooks in cache. Did you rebuild after adding hook() calls?"
166
+ hooks.length === 0
167
+ ? "No hooks in the manifest. Did you rebuild after adding hook() calls?"
174
168
  : "No hooks match the filter."
175
169
  }}
176
170
  </div>
177
- <div v-else class="flex-1 overflow-auto">
171
+ <div v-else class="flex-1 overflow-auto" data-testid="hooks-list">
178
172
  <button
179
173
  v-for="h in filteredHooks"
180
174
  :key="h.id"
@@ -182,7 +176,18 @@ watch(detail, () => {
182
176
  :class="{ 'bg-zinc-900/70': h.id === selected }"
183
177
  @click="selected = h.id"
184
178
  >
185
- <div class="font-mono text-xs text-zinc-100 truncate">{{ h.name }}</div>
179
+ <div class="flex items-center gap-2">
180
+ <span class="font-mono text-xs text-zinc-100 truncate flex-1">{{ h.name }}</span>
181
+ <span
182
+ v-if="firesFor(h.name) > 0"
183
+ class="inline-flex items-center gap-1 text-[10px] text-emerald-400 tabular-nums shrink-0"
184
+ :data-testid="`hook-fires-${h.name}`"
185
+ title="live fires observed"
186
+ >
187
+ <Activity class="w-3 h-3" />
188
+ {{ firesFor(h.name) }}
189
+ </span>
190
+ </div>
186
191
  <div class="text-[10px] text-zinc-500 tabular-nums mt-0.5 flex gap-2">
187
192
  <span>chain {{ h.chain }}</span>
188
193
  <span>·</span>
@@ -195,93 +200,89 @@ watch(detail, () => {
195
200
  <!-- Detail -->
196
201
  <main class="flex-1 flex flex-col min-w-0">
197
202
  <div v-if="!detail" class="p-8 text-sm text-zinc-500 italic">
198
- Select a hook to view its surface + live tap stream.
203
+ Select a hook to view its surface + live tap.
199
204
  </div>
200
205
  <div v-else class="flex-1 flex flex-col overflow-hidden">
201
206
  <div class="px-6 py-5 border-b border-zinc-800">
202
207
  <div class="flex items-center gap-3 flex-wrap">
203
208
  <h2 class="font-mono text-xl">{{ detail.name }}</h2>
204
209
  <span class="text-[10px] text-zinc-500 font-mono">{{ detail.id }}</span>
205
- <span v-if="detail.source" class="ml-auto inline-flex">
206
- <SourcePill :source="detail.source" @click="sourcePreview = detail.source!" />
207
- </span>
208
210
  </div>
209
- <div class="text-[11px] text-zinc-500 mt-2 flex gap-4 tabular-nums">
211
+ <div class="text-[11px] text-zinc-500 mt-2 flex gap-4 items-center tabular-nums">
210
212
  <span>{{ detail.chain }} chain step{{ detail.chain === 1 ? "" : "s" }}</span>
211
213
  <span>{{ detail.listeners }} listener{{ detail.listeners === 1 ? "" : "s" }}</span>
214
+ <SourcePill
215
+ v-if="detail.source"
216
+ :source="detail.source"
217
+ compact
218
+ @click="sourcePreview = detail.source ?? null"
219
+ />
212
220
  </div>
213
- <div v-if="registeringPlugin" class="mt-2 flex items-center gap-2 text-xs">
221
+ <div v-if="registeredBy" class="mt-2 flex items-center gap-2 text-xs">
214
222
  <span class="text-zinc-500">Registered by</span>
215
223
  <button
216
224
  type="button"
217
225
  class="inline-flex items-center gap-1 font-mono text-fuchsia-300 hover:underline"
218
- :data-testid="`plugin-link-${registeringPlugin}`"
219
- @click="router.push({ path: '/plugins', query: { name: registeringPlugin } })"
226
+ :data-testid="`plugin-link-${registeredBy}`"
227
+ @click="router.push({ path: '/plugins', query: { name: registeredBy } })"
220
228
  >
221
229
  <Puzzle class="w-3 h-3" />
222
- {{ registeringPlugin }}
230
+ {{ registeredBy }}
223
231
  </button>
224
232
  </div>
225
233
  </div>
226
234
 
227
- <!-- Live tap stream -->
228
235
  <div class="flex-1 overflow-auto">
229
- <div class="px-6 py-3 border-b border-zinc-900 flex items-center gap-2">
230
- <Activity class="w-3.5 h-3.5 text-emerald-400" />
231
- <h3 class="text-xs uppercase tracking-wide text-zinc-400">Live taps</h3>
232
- <span class="text-[10px] text-zinc-500 tabular-nums">
233
- {{ detailSteps.length }} recent
234
- </span>
235
- </div>
236
- <div
237
- v-if="detailSteps.length === 0"
238
- class="p-6 text-xs text-zinc-500 italic"
239
- data-testid="hooks-empty-tap"
240
- >
241
- No tap data yet. Trigger an action through the running wire and observations will
242
- appear here.
243
- </div>
244
- <ul v-else class="divide-y divide-zinc-900" data-testid="hooks-tap-list">
245
- <li
246
- v-for="(s, i) in detailSteps"
247
- :key="`${s.runId}:${s.stepId}:${s.phase}:${i}`"
248
- class="px-6 py-1.5 flex items-center gap-3 font-mono text-[11px] hover:bg-zinc-900/30"
249
- >
250
- <span class="text-zinc-600 tabular-nums w-20 shrink-0">
251
- {{ new Date(s.ts).toLocaleTimeString(undefined, { hour12: false }) }}
252
- </span>
253
- <span
254
- class="w-12 text-[10px] uppercase tracking-wide shrink-0"
255
- :class="{
256
- 'text-zinc-400': s.phase === 'start',
257
- 'text-emerald-400': s.phase === 'end',
258
- 'text-red-400': s.phase === 'error',
259
- }"
260
- >{{ s.phase }}</span
261
- >
262
- <span
263
- class="w-16 text-[10px] uppercase tracking-wide shrink-0"
264
- :class="s.stepKind === 'chain' ? 'text-cyan-400' : 'text-amber-400'"
265
- >{{ s.stepKind }}</span
266
- >
267
- <span class="text-zinc-200 truncate flex-1">
268
- {{ s.stepName ?? `#${s.stepId}` }}
269
- </span>
270
- <span v-if="s.durationMs !== undefined" class="text-zinc-500 tabular-nums shrink-0">
271
- {{ s.durationMs.toFixed(1) }} ms
236
+ <!-- Steady-state metadata -->
237
+ <section class="border-b border-zinc-900">
238
+ <MetadataInspector :data="detailMeta" label="Hook" />
239
+ </section>
240
+
241
+ <!-- Live hook-tap -->
242
+ <section>
243
+ <div class="px-6 py-3 border-b border-zinc-900 flex items-center gap-2">
244
+ <Activity class="w-3.5 h-3.5 text-emerald-400" />
245
+ <h3 class="text-xs uppercase tracking-wide text-zinc-400">Live tap</h3>
246
+ <span class="text-[10px] text-zinc-500 tabular-nums">
247
+ {{ firesFor(detail.name) }} fire{{ firesFor(detail.name) === 1 ? "" : "s" }} ·
248
+ last {{ fmtTime(lastFiredFor(detail.name)) }}
272
249
  </span>
273
- <span
274
- v-if="s.error"
275
- class="text-red-400 truncate max-w-40 shrink-0"
276
- :title="s.error.message"
250
+ </div>
251
+ <div
252
+ v-if="detailSteps.length === 0"
253
+ class="p-6 text-xs text-zinc-500 italic"
254
+ data-testid="hooks-empty-tap"
255
+ >
256
+ No tap data yet. Trigger an action through the running wire and fire activity will
257
+ appear here.
258
+ </div>
259
+ <ul v-else class="divide-y divide-zinc-900" data-testid="hooks-tap-list">
260
+ <li
261
+ v-for="(s, i) in detailSteps"
262
+ :key="i"
263
+ class="px-6 py-1.5 flex items-center gap-3 font-mono text-[11px] hover:bg-zinc-900/30"
277
264
  >
278
- {{ s.error.message }}
279
- </span>
280
- <span class="text-zinc-600 text-[10px] tabular-nums shrink-0">
281
- run {{ s.runId.split("-")[0] }}
282
- </span>
283
- </li>
284
- </ul>
265
+ <span class="text-zinc-600 tabular-nums w-20 shrink-0">
266
+ {{ fmtTime(stepField(s, "ts")) }}
267
+ </span>
268
+ <span
269
+ class="w-12 text-[10px] uppercase tracking-wide shrink-0"
270
+ :class="{
271
+ 'text-zinc-400': stepField(s, 'phase') === 'start',
272
+ 'text-emerald-400': stepField(s, 'phase') === 'end',
273
+ 'text-red-400': stepField(s, 'phase') === 'error',
274
+ }"
275
+ >{{ stepField(s, "phase") || "step" }}</span
276
+ >
277
+ <span class="text-zinc-200 truncate flex-1">
278
+ {{ stepField(s, "stepName") || `#${stepField(s, "stepId")}` }}
279
+ </span>
280
+ <span v-if="durationMsOf(s) !== null" class="text-zinc-500 tabular-nums shrink-0">
281
+ {{ durationMsOf(s)!.toFixed(1) }} ms
282
+ </span>
283
+ </li>
284
+ </ul>
285
+ </section>
285
286
  </div>
286
287
  </div>
287
288
  </main>