@nwire/studio 0.12.1 → 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,51 @@
1
+ /**
2
+ * `useLogTail` — tail a managed process's stdout/stderr over SSE.
3
+ *
4
+ * Watches a process-id ref; (re)opens `/__nwire/run/logs/:id/stream` whenever it
5
+ * changes, parses each `LogLine`, and keeps a bounded buffer. The scoped
6
+ * `EventSource` (patched in main.ts) carries `?project=`; SSE reconnects on
7
+ * drop natively. Shared by the Run + Commands panels so the streaming logic
8
+ * lives in one place.
9
+ */
10
+ import { ref, watch, onUnmounted, toValue, type MaybeRefOrGetter } from "vue";
11
+ import type { LogLine } from "@/lib/operate";
12
+
13
+ const MAX_LINES = 5000;
14
+
15
+ export function useLogTail(id: MaybeRefOrGetter<string | null | undefined>) {
16
+ const logs = ref<LogLine[]>([]);
17
+ let es: EventSource | null = null;
18
+
19
+ function close(): void {
20
+ es?.close();
21
+ es = null;
22
+ }
23
+
24
+ function clear(): void {
25
+ logs.value = [];
26
+ }
27
+
28
+ function connect(target: string | null | undefined): void {
29
+ close();
30
+ clear();
31
+ if (!target) return;
32
+ es = new EventSource(`/__nwire/run/logs/${encodeURIComponent(target)}/stream`);
33
+ es.onmessage = (msg) => {
34
+ try {
35
+ const line = JSON.parse(msg.data) as LogLine;
36
+ const next =
37
+ logs.value.length >= MAX_LINES ? logs.value.slice(-(MAX_LINES - 1)) : logs.value.slice();
38
+ next.push(line);
39
+ logs.value = next;
40
+ } catch {
41
+ /* keepalive comment / non-JSON frame — ignore */
42
+ }
43
+ };
44
+ // EventSource auto-reconnects on error; nothing to do.
45
+ }
46
+
47
+ watch(() => toValue(id), connect, { immediate: true });
48
+ onUnmounted(close);
49
+
50
+ return { logs, clear };
51
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `useManifest` — the structure feed. Fetches `.nwire/manifest.json` (the deep
3
+ * manifest) via TanStack Query and exposes a tolerant native view. Re-keys per
4
+ * project so a switch refetches.
5
+ */
6
+ import { computed, toValue, type MaybeRefOrGetter } from "vue";
7
+ import { useQuery } from "@tanstack/vue-query";
8
+ import { manifestView, type Manifest, type ManifestView } from "../lib/manifest";
9
+
10
+ const MANIFEST_URL = "/__nwire/manifest.json";
11
+
12
+ export function useManifest(project?: MaybeRefOrGetter<string | undefined | null>) {
13
+ const key = computed(() => ["manifest", toValue(project) ?? "active"] as const);
14
+
15
+ const query = useQuery({
16
+ queryKey: key,
17
+ queryFn: async (): Promise<Manifest> => {
18
+ // The global fetch shim appends `?project=<active cwd>`.
19
+ const res = await fetch(MANIFEST_URL);
20
+ if (!res.ok) {
21
+ throw new Error(`Manifest fetch failed (${res.status}). Run \`nwire cache\` to build it.`);
22
+ }
23
+ return (await res.json()) as Manifest;
24
+ },
25
+ staleTime: 5_000,
26
+ // keepPreviousData: smooth refresh, no flash while a refetch is in flight.
27
+ placeholderData: (prev) => prev,
28
+ });
29
+
30
+ const view = computed<ManifestView | null>(() =>
31
+ query.data.value ? manifestView(query.data.value) : null,
32
+ );
33
+
34
+ return {
35
+ manifest: query.data,
36
+ view,
37
+ isLoading: query.isLoading,
38
+ isFetching: query.isFetching,
39
+ isError: query.isError,
40
+ error: computed(() => (query.error.value as Error | null)?.message ?? null),
41
+ refetch: query.refetch,
42
+ };
43
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * `useProcesses` — the supervisor's managed-process list + lifecycle.
3
+ *
4
+ * Polls `/__nwire/run/processes` and exposes start/stop/forget plus the two
5
+ * launch verbs (topology `start`, package-script `exec-script`, CLI `exec`).
6
+ * Shared by the Run + Commands panels; the optional `filter` narrows the list
7
+ * (Commands only shows `nwire …` processes it launched).
8
+ */
9
+ import { ref, onMounted, onUnmounted } from "vue";
10
+ import type { ManagedProcess } from "@/lib/operate";
11
+
12
+ interface Options {
13
+ /** Narrow the polled list (e.g. only CLI processes). */
14
+ filter?: (p: ManagedProcess) => boolean;
15
+ /** Poll cadence in ms. Default 2000. */
16
+ intervalMs?: number;
17
+ }
18
+
19
+ export function useProcesses(options: Options = {}) {
20
+ const { filter, intervalMs = 2000 } = options;
21
+ const processes = ref<ManagedProcess[]>([]);
22
+ const error = ref<string | null>(null);
23
+ let poll: ReturnType<typeof setInterval> | null = null;
24
+
25
+ async function load(): Promise<void> {
26
+ try {
27
+ const res = await fetch("/__nwire/run/processes");
28
+ const body = (await res.json()) as { processes: ManagedProcess[] };
29
+ processes.value = filter ? body.processes.filter(filter) : body.processes;
30
+ } catch {
31
+ /* transient — keep the last good list */
32
+ }
33
+ }
34
+
35
+ async function start(payload: {
36
+ topology: string;
37
+ port?: number;
38
+ env?: Record<string, string>;
39
+ }): Promise<ManagedProcess | null> {
40
+ return launch("/__nwire/run/start", payload);
41
+ }
42
+
43
+ async function execScript(payload: {
44
+ script: string;
45
+ port?: number;
46
+ env?: Record<string, string>;
47
+ }): Promise<ManagedProcess | null> {
48
+ return launch("/__nwire/run/exec-script", payload);
49
+ }
50
+
51
+ async function exec(command: string, args: string[] = []): Promise<ManagedProcess | null> {
52
+ return launch("/__nwire/run/exec", { command, args });
53
+ }
54
+
55
+ async function launch(url: string, body: unknown): Promise<ManagedProcess | null> {
56
+ error.value = null;
57
+ try {
58
+ const res = await fetch(url, {
59
+ method: "POST",
60
+ headers: { "Content-Type": "application/json" },
61
+ body: JSON.stringify(body),
62
+ });
63
+ const data = (await res.json()) as {
64
+ process?: ManagedProcess;
65
+ error?: string;
66
+ };
67
+ if (!res.ok) {
68
+ error.value = data.error ?? `request failed (${res.status})`;
69
+ return null;
70
+ }
71
+ if (data.process) {
72
+ processes.value = [...processes.value, data.process];
73
+ return data.process;
74
+ }
75
+ await load();
76
+ return null;
77
+ } catch (err) {
78
+ error.value = (err as Error).message;
79
+ return null;
80
+ }
81
+ }
82
+
83
+ async function stop(id: string): Promise<void> {
84
+ try {
85
+ await fetch(`/__nwire/run/stop/${encodeURIComponent(id)}`, {
86
+ method: "POST",
87
+ });
88
+ } catch {
89
+ /* ignore */
90
+ }
91
+ await load();
92
+ }
93
+
94
+ async function forget(id: string): Promise<void> {
95
+ try {
96
+ await fetch(`/__nwire/run/forget/${encodeURIComponent(id)}`, {
97
+ method: "POST",
98
+ });
99
+ } catch {
100
+ /* ignore */
101
+ }
102
+ await load();
103
+ }
104
+
105
+ onMounted(() => {
106
+ void load();
107
+ poll = setInterval(load, intervalMs);
108
+ });
109
+ onUnmounted(() => {
110
+ if (poll) clearInterval(poll);
111
+ });
112
+
113
+ return { processes, error, load, start, execScript, exec, stop, forget };
114
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `useProject` — the active project + a smooth switch. Switching sets the
3
+ * active cwd (the fetch/EventSource shim scopes to it) and invalidates the
4
+ * per-project queries so they refetch — no hard page reload.
5
+ */
6
+ import { computed, ref } from "vue";
7
+ import { useQueryClient } from "@tanstack/vue-query";
8
+ import { getActiveProjectCwd, setActiveProjectCwd, loadCatalog } from "../lib/project-catalog";
9
+
10
+ export function useProject() {
11
+ // useQueryClient throws without a provider (e.g. a bare unit test); tolerate it.
12
+ let queryClient: ReturnType<typeof useQueryClient> | undefined;
13
+ try {
14
+ queryClient = useQueryClient();
15
+ } catch {
16
+ queryClient = undefined;
17
+ }
18
+
19
+ const activeCwd = ref<string | null>(getActiveProjectCwd());
20
+ const activeName = computed(() => {
21
+ const cwd = activeCwd.value;
22
+ if (!cwd) return null;
23
+ return loadCatalog()[cwd]?.name ?? cwd;
24
+ });
25
+
26
+ function switchTo(cwd: string | null): void {
27
+ setActiveProjectCwd(cwd);
28
+ activeCwd.value = cwd;
29
+ // Refetch everything against the new active cwd — no reload.
30
+ void queryClient?.invalidateQueries();
31
+ }
32
+
33
+ return { activeCwd, activeName, switchTo };
34
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * `useTelemetry` — the behavior feed.
3
+ *
4
+ * Two modes:
5
+ *
6
+ * LIVE — holds an SSE connection to `/__nwire/telemetry/live`, which tails
7
+ * the current (newest) run file and streams appended records.
8
+ * A `run` event from the server backfills the current run on open.
9
+ *
10
+ * HISTORY — fetches a chosen past run's records once via
11
+ * `/__nwire/telemetry/runs/:id` and loads them as a static snapshot.
12
+ *
13
+ * Switching modes (`source`) clears the buffer and reconnects. Both modes share
14
+ * the same typed-record parsing/grouping/tree helpers from `../lib/telemetry`.
15
+ *
16
+ * Side-effecting (EventSource + fetch) but DI'd: tests inject a fake
17
+ * EventSource factory + fetch. The pure parsing/grouping/tree live in
18
+ * `../lib/telemetry`.
19
+ */
20
+ import {
21
+ computed,
22
+ ref,
23
+ shallowRef,
24
+ onScopeDispose,
25
+ toValue,
26
+ watch,
27
+ type MaybeRefOrGetter,
28
+ } from "vue";
29
+ import {
30
+ parseRecord,
31
+ groupByCorrelation,
32
+ buildCorrelationTree,
33
+ isFailure,
34
+ type TelemetryRecord,
35
+ } from "../lib/telemetry";
36
+
37
+ /** The SSE tail of the current (newest) run file. */
38
+ const LIVE_URL = "/__nwire/telemetry/live";
39
+ /** Run list: returns { runs: TelemetryRunMeta[] }. */
40
+ export const RUNS_URL = "/__nwire/telemetry/runs";
41
+
42
+ /** A past run as returned by GET /__nwire/telemetry/runs. */
43
+ export interface TelemetryRunMeta {
44
+ readonly id: string;
45
+ readonly size: number;
46
+ readonly mtime: string;
47
+ }
48
+
49
+ /**
50
+ * The data source for useTelemetry.
51
+ * "live" — stream the current run via SSE.
52
+ * run meta — load a specific past run as a static snapshot.
53
+ */
54
+ export type TelemetrySource = "live" | TelemetryRunMeta;
55
+
56
+ /** Minimal EventSource surface we depend on — lets tests inject a fake. */
57
+ export interface EventSourceLike {
58
+ onmessage: ((ev: { data: string }) => void) | null;
59
+ onerror: ((ev?: unknown) => void) | null;
60
+ onopen: ((ev?: unknown) => void) | null;
61
+ /** Listen for named events (e.g. the `run` meta event). */
62
+ addEventListener?(type: string, listener: (ev: { data: string }) => void): void;
63
+ close(): void;
64
+ }
65
+
66
+ export type ConnectionStatus = "idle" | "connecting" | "open" | "reconnecting" | "closed";
67
+
68
+ export interface UseTelemetryOptions {
69
+ /** Ring-buffer cap. Default 1000. */
70
+ readonly limit?: number;
71
+ /** Connect immediately. Default true. */
72
+ readonly autoConnect?: boolean;
73
+ /** Inject an EventSource (tests). Default `new EventSource(url)`. */
74
+ readonly eventSourceFactory?: (url: string) => EventSourceLike;
75
+ /** Inject fetch (tests). Default global `fetch`. */
76
+ readonly fetchFn?: typeof fetch;
77
+ /** Reconnect backoff in ms (live mode only). Default 1500. */
78
+ readonly reconnectMs?: number;
79
+ }
80
+
81
+ export function useTelemetry(
82
+ project?: MaybeRefOrGetter<string | undefined | null>,
83
+ options: UseTelemetryOptions = {},
84
+ ) {
85
+ const limit = options.limit ?? 1000;
86
+ const reconnectMs = options.reconnectMs ?? 1500;
87
+ const makeSource =
88
+ options.eventSourceFactory ?? ((url) => new EventSource(url) as EventSourceLike);
89
+ const doFetch = options.fetchFn ?? ((...a: Parameters<typeof fetch>) => fetch(...a));
90
+
91
+ const records = shallowRef<TelemetryRecord[]>([]);
92
+ const status = ref<ConnectionStatus>("idle");
93
+ /** The run id that the live SSE stream is tailing (null when none/history). */
94
+ const currentRunId = ref<string | null>(null);
95
+ /**
96
+ * The active source. "live" = streaming; a TelemetryRunMeta = past run
97
+ * snapshot. Callers set this to switch modes.
98
+ */
99
+ const source = ref<TelemetrySource>("live");
100
+
101
+ let es: EventSourceLike | null = null;
102
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
103
+ let disposed = false;
104
+
105
+ function push(rec: TelemetryRecord): void {
106
+ const next = records.value.concat(rec);
107
+ records.value = next.length > limit ? next.slice(next.length - limit) : next;
108
+ }
109
+
110
+ function clear(): void {
111
+ records.value = [];
112
+ currentRunId.value = null;
113
+ }
114
+
115
+ // ── HISTORY mode ────────────────────────────────────────────────────────
116
+
117
+ async function loadRun(run: TelemetryRunMeta): Promise<void> {
118
+ status.value = "connecting";
119
+ try {
120
+ const url = `${RUNS_URL}/${encodeURIComponent(run.id)}`;
121
+ const res = await doFetch(url);
122
+ if (!res.ok) {
123
+ status.value = "closed";
124
+ return;
125
+ }
126
+ const body = (await res.json()) as { records?: unknown[] };
127
+ const parsed = (body.records ?? [])
128
+ .map(parseRecord)
129
+ .filter((r): r is TelemetryRecord => r !== null);
130
+ records.value = parsed.slice(-limit);
131
+ currentRunId.value = run.id;
132
+ status.value = "open";
133
+ } catch {
134
+ status.value = "closed";
135
+ }
136
+ }
137
+
138
+ // ── LIVE mode ────────────────────────────────────────────────────────────
139
+
140
+ function openLive(): void {
141
+ if (disposed) return;
142
+ status.value = status.value === "open" ? "reconnecting" : "connecting";
143
+ const src = makeSource(LIVE_URL);
144
+ es = src;
145
+
146
+ // The server emits a `run` event with the current run meta.
147
+ src.addEventListener?.("run", (ev) => {
148
+ try {
149
+ const meta = JSON.parse(ev.data) as TelemetryRunMeta | null;
150
+ currentRunId.value = meta?.id ?? null;
151
+ } catch {
152
+ // ignore
153
+ }
154
+ });
155
+
156
+ src.onopen = () => {
157
+ status.value = "open";
158
+ };
159
+ src.onmessage = (ev) => {
160
+ // Default `message` events carry telemetry records (backfill + live).
161
+ const rec = parseRecord(ev.data);
162
+ if (rec) push(rec);
163
+ };
164
+ src.onerror = () => {
165
+ if (disposed) return;
166
+ status.value = "reconnecting";
167
+ src.close();
168
+ es = null;
169
+ if (reconnectTimer) clearTimeout(reconnectTimer);
170
+ reconnectTimer = setTimeout(() => openLive(), reconnectMs);
171
+ };
172
+ }
173
+
174
+ // ── Public connect/disconnect ─────────────────────────────────────────────
175
+
176
+ function connect(): void {
177
+ if (disposed) return;
178
+ clear();
179
+ const s = source.value;
180
+ if (s === "live") {
181
+ if (es) return; // already connected
182
+ openLive();
183
+ } else {
184
+ void loadRun(s);
185
+ }
186
+ }
187
+
188
+ function disconnect(): void {
189
+ if (reconnectTimer) clearTimeout(reconnectTimer);
190
+ reconnectTimer = null;
191
+ es?.close();
192
+ es = null;
193
+ status.value = "closed";
194
+ }
195
+
196
+ // Re-key on project change or source change: drop everything + reconnect.
197
+ watch([() => toValue(project), source], () => {
198
+ if (disposed) return;
199
+ disconnect();
200
+ clear();
201
+ disposed = false;
202
+ connect();
203
+ });
204
+
205
+ if (options.autoConnect !== false) connect();
206
+
207
+ onScopeDispose(() => {
208
+ disposed = true;
209
+ disconnect();
210
+ });
211
+
212
+ const byCorrelation = computed(() => groupByCorrelation(records.value));
213
+ const tree = computed(() => buildCorrelationTree(records.value));
214
+ const failures = computed(() => records.value.filter(isFailure));
215
+
216
+ return {
217
+ records,
218
+ status,
219
+ /** The run id currently being tailed (live) or viewed (history). */
220
+ currentRunId,
221
+ /** The active source — set to "live" or a TelemetryRunMeta to switch. */
222
+ source,
223
+ byCorrelation,
224
+ tree,
225
+ failures,
226
+ recent: (kind?: string) =>
227
+ kind ? records.value.filter((r) => r.kind === kind) : records.value,
228
+ connect,
229
+ disconnect,
230
+ clear,
231
+ };
232
+ }
233
+
234
+ /**
235
+ * `useRunList` — reactive list of past telemetry runs for the given project.
236
+ * Fetches once on mount; call `refresh()` to reload.
237
+ */
238
+ export function useRunList(
239
+ project?: MaybeRefOrGetter<string | undefined | null>,
240
+ options: { fetchFn?: typeof fetch } = {},
241
+ ) {
242
+ const doFetch = options.fetchFn ?? ((...a: Parameters<typeof fetch>) => fetch(...a));
243
+ const runs = ref<TelemetryRunMeta[]>([]);
244
+ const loading = ref(false);
245
+ const error = ref<string | null>(null);
246
+
247
+ async function refresh(): Promise<void> {
248
+ loading.value = true;
249
+ error.value = null;
250
+ try {
251
+ const proj = toValue(project);
252
+ const url = proj ? `${RUNS_URL}?project=${encodeURIComponent(proj)}` : RUNS_URL;
253
+ const res = await doFetch(url);
254
+ if (!res.ok) {
255
+ error.value = `Failed to load runs (${res.status})`;
256
+ return;
257
+ }
258
+ const body = (await res.json()) as { runs?: TelemetryRunMeta[] };
259
+ runs.value = body.runs ?? [];
260
+ } catch (err) {
261
+ error.value = (err as Error).message;
262
+ } finally {
263
+ loading.value = false;
264
+ }
265
+ }
266
+
267
+ watch(() => toValue(project), refresh, { immediate: true });
268
+
269
+ return { runs, loading, error, refresh };
270
+ }