@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.
- package/package.json +5 -3
- package/src/App.vue +62 -56
- package/src/components/BcCard.stories.ts +47 -0
- package/src/components/BcCard.vue +152 -0
- package/src/components/DurationBar.stories.ts +55 -0
- package/src/components/DurationBar.vue +72 -0
- package/src/components/ErrorCard.stories.ts +133 -0
- package/src/components/ErrorCard.vue +153 -0
- package/src/components/GraphCanvas.stories.ts +48 -0
- package/src/components/GraphCanvas.vue +88 -0
- package/src/components/KpiTile.stories.ts +32 -0
- package/src/components/KpiTile.vue +39 -0
- package/src/components/LiveTable.stories.ts +78 -0
- package/src/components/LiveTable.vue +186 -0
- package/src/components/MetadataInspector.stories.ts +53 -0
- package/src/components/MetadataInspector.vue +105 -0
- package/src/components/NodeCard.stories.ts +44 -0
- package/src/components/NodeCard.vue +150 -0
- package/src/components/RcaPanel.stories.ts +95 -0
- package/src/components/RcaPanel.vue +223 -0
- package/src/components/ServiceNode.vue +134 -0
- package/src/components/SourceDrawer.vue +6 -4
- package/src/components/SourcePill.vue +10 -3
- package/src/components/StatusBadge.stories.ts +33 -0
- package/src/components/StatusBadge.vue +54 -0
- package/src/components/Waterfall.stories.ts +85 -0
- package/src/components/Waterfall.vue +53 -0
- package/src/components/WaterfallRow.vue +74 -0
- package/src/components/__tests__/BcCard.test.ts +53 -0
- package/src/components/__tests__/DurationBar.test.ts +31 -0
- package/src/components/__tests__/ErrorCard.test.ts +71 -0
- package/src/components/__tests__/KpiTile.test.ts +23 -0
- package/src/components/__tests__/LiveTable.test.ts +100 -0
- package/src/components/__tests__/MetadataInspector.test.ts +38 -0
- package/src/components/__tests__/NodeCard.test.ts +116 -0
- package/src/components/__tests__/RcaPanel.test.ts +81 -0
- package/src/components/__tests__/StatusBadge.test.ts +23 -0
- package/src/components/__tests__/Waterfall.test.ts +54 -0
- package/src/components/index.ts +13 -0
- package/src/composables/__tests__/composables-context.test.ts +107 -0
- package/src/composables/__tests__/useTelemetry.test.ts +104 -0
- package/src/composables/__tests__/useTelemetryRuns.test.ts +282 -0
- package/src/composables/useDiscovery.ts +73 -0
- package/src/composables/useEndpoints.ts +94 -0
- package/src/composables/useLogTail.ts +51 -0
- package/src/composables/useManifest.ts +43 -0
- package/src/composables/useProcesses.ts +114 -0
- package/src/composables/useProject.ts +34 -0
- package/src/composables/useTelemetry.ts +270 -0
- package/src/lib/__tests__/bc-graph.test.ts +218 -0
- package/src/lib/__tests__/dispatch-form.test.ts +113 -0
- package/src/lib/__tests__/error-friendly.test.ts +198 -0
- package/src/lib/__tests__/home.test.ts +231 -0
- package/src/lib/__tests__/inspect.test.ts +160 -0
- package/src/lib/__tests__/kind-colors.test.ts +59 -0
- package/src/lib/__tests__/live-table.test.ts +194 -0
- package/src/lib/__tests__/manifest-health.test.ts +120 -0
- package/src/lib/__tests__/manifest.test.ts +87 -0
- package/src/lib/__tests__/metadata.test.ts +47 -0
- package/src/lib/__tests__/node-metrics.test.ts +144 -0
- package/src/lib/__tests__/operate.test.ts +97 -0
- package/src/lib/__tests__/pipeline-flow.test.ts +79 -0
- package/src/lib/__tests__/rca.test.ts +124 -0
- package/src/lib/__tests__/telemetry.test.ts +91 -0
- package/src/lib/__tests__/topology-graph.test.ts +331 -0
- package/src/lib/__tests__/topology-view.test.ts +154 -0
- package/src/lib/__tests__/waterfall.test.ts +165 -0
- package/src/lib/bc-graph.ts +298 -0
- package/src/lib/dispatch-form.ts +160 -0
- package/src/lib/error-friendly.ts +288 -0
- package/src/lib/home.ts +191 -0
- package/src/lib/inspect.ts +226 -0
- package/src/lib/kind-colors.ts +132 -0
- package/src/lib/live-table.ts +204 -0
- package/src/lib/manifest-health.ts +71 -0
- package/src/lib/manifest.ts +139 -0
- package/src/lib/metadata.ts +52 -0
- package/src/lib/node-metrics.ts +242 -0
- package/src/lib/operate.ts +114 -0
- package/src/lib/pipeline-flow.ts +120 -0
- package/src/lib/rca.ts +193 -0
- package/src/lib/telemetry.ts +155 -0
- package/src/lib/topology-graph.ts +551 -0
- package/src/lib/topology-view.ts +185 -0
- package/src/lib/waterfall.ts +148 -0
- package/src/main.ts +63 -29
- package/src/pages/Errors.vue +272 -0
- package/src/pages/Home.stories.ts +7 -8
- package/src/pages/Home.vue +255 -540
- package/src/pages/Hooks.stories.ts +44 -0
- package/src/pages/Hooks.vue +165 -164
- package/src/pages/Inspect.vue +240 -0
- package/src/pages/Map.vue +187 -0
- package/src/pages/Operate.vue +74 -0
- package/src/pages/Plugins.stories.ts +1 -1
- package/src/pages/Plugins.vue +174 -238
- package/src/pages/Projects.vue +62 -60
- package/src/pages/Streams.vue +344 -0
- package/src/pages/Topology.vue +318 -136
- package/src/pages/Trace.vue +174 -412
- package/src/pages/__tests__/Home.test.ts +109 -54
- package/src/pages/__tests__/Hooks.test.ts +5 -5
- package/src/pages/__tests__/Inspect.test.ts +111 -0
- package/src/pages/__tests__/Plugins.test.ts +85 -35
- package/src/pages/__tests__/Trace.test.ts +117 -0
- package/src/pages/operate/CommandsPanel.vue +186 -0
- package/src/pages/{Dispatch.vue → operate/DispatchPanel.vue} +100 -203
- package/src/pages/operate/EndpointPicker.vue +56 -0
- package/src/pages/operate/RunPanel.vue +316 -0
- package/src/server/__tests__/nwire-read.test.ts +80 -0
- package/src/server/nwire-read.ts +63 -0
- package/vite.config.ts +220 -2
- package/src/lib/__tests__/normalize-cache.test.ts +0 -105
- package/src/lib/cache.ts +0 -312
- package/src/lib/normalize-cache.ts +0 -92
- package/src/pages/Actions.vue +0 -171
- package/src/pages/Apps.vue +0 -177
- package/src/pages/Commands.vue +0 -262
- package/src/pages/Events.vue +0 -210
- package/src/pages/Live.vue +0 -249
- package/src/pages/Overview.vue +0 -161
- package/src/pages/Projections.vue +0 -148
- package/src/pages/Queries.vue +0 -148
- package/src/pages/Run.vue +0 -618
- package/src/pages/Sinks.vue +0 -124
- package/src/pages/TraceNode.vue +0 -164
- package/src/pages/Workflows.vue +0 -184
- package/src/pages/__tests__/Actions.test.ts +0 -98
- package/src/pages/__tests__/Projections.test.ts +0 -90
- package/src/pages/__tests__/Queries.test.ts +0 -86
package/src/components/index.ts
CHANGED
|
@@ -15,3 +15,16 @@ export { default as SchemaTree } from "./SchemaTree.vue";
|
|
|
15
15
|
export { default as SchemaNode } from "./SchemaNode.vue";
|
|
16
16
|
export { default as MonacoViewer } from "./MonacoViewer.vue";
|
|
17
17
|
export { default as SourceDrawer } from "./SourceDrawer.vue";
|
|
18
|
+
export { default as KpiTile } from "./KpiTile.vue";
|
|
19
|
+
export { default as StatusBadge } from "./StatusBadge.vue";
|
|
20
|
+
export { default as DurationBar } from "./DurationBar.vue";
|
|
21
|
+
export { default as MetadataInspector } from "./MetadataInspector.vue";
|
|
22
|
+
export { default as Waterfall } from "./Waterfall.vue";
|
|
23
|
+
export { default as WaterfallRow } from "./WaterfallRow.vue";
|
|
24
|
+
export { default as BcCard } from "./BcCard.vue";
|
|
25
|
+
export { default as ServiceNode } from "./ServiceNode.vue";
|
|
26
|
+
export { default as NodeCard } from "./NodeCard.vue";
|
|
27
|
+
export { default as GraphCanvas } from "./GraphCanvas.vue";
|
|
28
|
+
export { default as ErrorCard } from "./ErrorCard.vue";
|
|
29
|
+
export { default as RcaPanel } from "./RcaPanel.vue";
|
|
30
|
+
export { default as LiveTable } from "./LiveTable.vue";
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { defineComponent, h } from "vue";
|
|
3
|
+
import { mount, flushPromises } from "@vue/test-utils";
|
|
4
|
+
import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query";
|
|
5
|
+
import { useManifest } from "../useManifest";
|
|
6
|
+
import { useDiscovery } from "../useDiscovery";
|
|
7
|
+
import { useProject } from "../useProject";
|
|
8
|
+
|
|
9
|
+
/** Mount a component that runs `fn()` in setup and capture its return. */
|
|
10
|
+
function withQuery<T>(fn: () => T): { result: T; unmount: () => void } {
|
|
11
|
+
let result!: T;
|
|
12
|
+
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
13
|
+
const wrapper = mount(
|
|
14
|
+
defineComponent({
|
|
15
|
+
setup() {
|
|
16
|
+
result = fn();
|
|
17
|
+
return () => h("div");
|
|
18
|
+
},
|
|
19
|
+
}),
|
|
20
|
+
{ global: { plugins: [[VueQueryPlugin, { queryClient: client }]] } },
|
|
21
|
+
);
|
|
22
|
+
return { result, unmount: () => wrapper.unmount() };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MANIFEST = {
|
|
26
|
+
version: 3,
|
|
27
|
+
model: {
|
|
28
|
+
nodes: [{ id: "action:a", kind: "action", name: "a" }],
|
|
29
|
+
edges: [],
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
localStorage.clear();
|
|
35
|
+
localStorage.setItem("nwire.activeProject", "/proj");
|
|
36
|
+
localStorage.setItem(
|
|
37
|
+
"nwire.projects",
|
|
38
|
+
JSON.stringify({ "/proj": { cwd: "/proj", name: "proj", lastVisited: "2026-01-01" } }),
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
vi.unstubAllGlobals();
|
|
43
|
+
localStorage.clear();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("useManifest", () => {
|
|
47
|
+
it("fetches the manifest and exposes a native view", async () => {
|
|
48
|
+
vi.stubGlobal(
|
|
49
|
+
"fetch",
|
|
50
|
+
vi.fn(async () => ({ ok: true, json: async () => MANIFEST }) as unknown as Response),
|
|
51
|
+
);
|
|
52
|
+
const { result, unmount } = withQuery(() => useManifest());
|
|
53
|
+
await flushPromises();
|
|
54
|
+
expect(result.view.value?.byKind("action").map((n) => n.name)).toEqual(["a"]);
|
|
55
|
+
expect(result.error.value).toBeNull();
|
|
56
|
+
unmount();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("surfaces a friendly error when the fetch fails", async () => {
|
|
60
|
+
vi.stubGlobal(
|
|
61
|
+
"fetch",
|
|
62
|
+
vi.fn(async () => ({ ok: false, status: 404 }) as unknown as Response),
|
|
63
|
+
);
|
|
64
|
+
const { result, unmount } = withQuery(() => useManifest());
|
|
65
|
+
await flushPromises();
|
|
66
|
+
expect(result.error.value).toMatch(/Manifest fetch failed \(404\)/);
|
|
67
|
+
unmount();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("useDiscovery", () => {
|
|
72
|
+
it("merges the catalog with live status + marks the active project", async () => {
|
|
73
|
+
vi.stubGlobal(
|
|
74
|
+
"fetch",
|
|
75
|
+
vi.fn(
|
|
76
|
+
async () =>
|
|
77
|
+
({
|
|
78
|
+
ok: true,
|
|
79
|
+
json: async () => ({ "/proj": { running: true, port: 3000 } }),
|
|
80
|
+
}) as unknown as Response,
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
const { result, unmount } = withQuery(() => useDiscovery({ intervalMs: 999_999 }));
|
|
84
|
+
await flushPromises();
|
|
85
|
+
expect(result.projects.value).toHaveLength(1);
|
|
86
|
+
expect(result.projects.value[0]!.active).toBe(true);
|
|
87
|
+
expect(result.projects.value[0]!.status?.running).toBe(true);
|
|
88
|
+
expect(result.runningCount.value).toBe(1);
|
|
89
|
+
unmount();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("useProject", () => {
|
|
94
|
+
it("reads the active project and switches without reload", async () => {
|
|
95
|
+
vi.stubGlobal(
|
|
96
|
+
"fetch",
|
|
97
|
+
vi.fn(async () => ({ ok: true, json: async () => ({}) }) as unknown as Response),
|
|
98
|
+
);
|
|
99
|
+
const { result, unmount } = withQuery(() => useProject());
|
|
100
|
+
expect(result.activeCwd.value).toBe("/proj");
|
|
101
|
+
expect(result.activeName.value).toBe("proj");
|
|
102
|
+
result.switchTo("/other");
|
|
103
|
+
expect(result.activeCwd.value).toBe("/other");
|
|
104
|
+
expect(localStorage.getItem("nwire.activeProject")).toBe("/other");
|
|
105
|
+
unmount();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useTelemetry — live-mode behavior (SSE tail of /__nwire/telemetry/live).
|
|
3
|
+
* History mode is covered in useTelemetryRuns.test.ts.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect, vi } from "vitest";
|
|
6
|
+
import { effectScope } from "vue";
|
|
7
|
+
import { useTelemetry, type EventSourceLike } from "../useTelemetry";
|
|
8
|
+
|
|
9
|
+
class FakeES implements EventSourceLike {
|
|
10
|
+
onmessage: ((ev: { data: string }) => void) | null = null;
|
|
11
|
+
onerror: ((ev?: unknown) => void) | null = null;
|
|
12
|
+
onopen: ((ev?: unknown) => void) | null = null;
|
|
13
|
+
closed = false;
|
|
14
|
+
listeners: Record<string, Array<(ev: { data: string }) => void>> = {};
|
|
15
|
+
lastUrl: string;
|
|
16
|
+
constructor(url: string) {
|
|
17
|
+
this.lastUrl = url;
|
|
18
|
+
}
|
|
19
|
+
addEventListener(type: string, listener: (ev: { data: string }) => void): void {
|
|
20
|
+
(this.listeners[type] ??= []).push(listener);
|
|
21
|
+
}
|
|
22
|
+
close() {
|
|
23
|
+
this.closed = true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const flush = () => new Promise((r) => setTimeout(r, 5));
|
|
28
|
+
const rec = (kind: string, messageId = "m1", correlationId = "c1") =>
|
|
29
|
+
JSON.stringify({ kind, envelope: { messageId, causationId: messageId, correlationId } });
|
|
30
|
+
|
|
31
|
+
function harness(opts: Parameters<typeof useTelemetry>[1] = {}) {
|
|
32
|
+
const created: FakeES[] = [];
|
|
33
|
+
const factory = (url: string) => {
|
|
34
|
+
const es = new FakeES(url);
|
|
35
|
+
created.push(es);
|
|
36
|
+
return es;
|
|
37
|
+
};
|
|
38
|
+
const fetchFn = vi.fn(
|
|
39
|
+
async () => ({ ok: true, json: async () => ({ runs: [] }) }) as unknown as Response,
|
|
40
|
+
);
|
|
41
|
+
const scope = effectScope();
|
|
42
|
+
const api = scope.run(() =>
|
|
43
|
+
useTelemetry(() => "proj", { eventSourceFactory: factory, fetchFn, reconnectMs: 1, ...opts }),
|
|
44
|
+
)!;
|
|
45
|
+
return { api, created, fetchFn, scope };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("useTelemetry", () => {
|
|
49
|
+
it("opens an SSE connection to /__nwire/telemetry/live and streams records", async () => {
|
|
50
|
+
const { api, created } = harness({ limit: 10 });
|
|
51
|
+
await flush();
|
|
52
|
+
// Live mode opens an SSE — no fetch for backfill (server does the backfill over SSE).
|
|
53
|
+
expect(created).toHaveLength(1);
|
|
54
|
+
expect(created[0]!.lastUrl).toBe("/__nwire/telemetry/live");
|
|
55
|
+
|
|
56
|
+
created[0]!.onopen?.();
|
|
57
|
+
expect(api.status.value).toBe("open");
|
|
58
|
+
|
|
59
|
+
created[0]!.onmessage?.({ data: rec("event.published", "m2") });
|
|
60
|
+
created[0]!.onmessage?.({ data: rec("listener.fired", "m3") });
|
|
61
|
+
expect(api.records.value.map((r) => r.kind)).toEqual(["event.published", "listener.fired"]);
|
|
62
|
+
expect(api.recent("event.published")).toHaveLength(1);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("caps the ring buffer at `limit`", async () => {
|
|
66
|
+
const { api, created } = harness({ limit: 2 });
|
|
67
|
+
await flush();
|
|
68
|
+
for (let i = 0; i < 5; i++) created[0]!.onmessage?.({ data: rec("event.published", `m${i}`) });
|
|
69
|
+
expect(api.records.value).toHaveLength(2);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("reconnects on error (closes the dead source, opens a fresh one)", async () => {
|
|
73
|
+
const { api, created } = harness();
|
|
74
|
+
await flush();
|
|
75
|
+
expect(created).toHaveLength(1);
|
|
76
|
+
created[0]!.onerror?.();
|
|
77
|
+
expect(created[0]!.closed).toBe(true);
|
|
78
|
+
expect(api.status.value).toBe("reconnecting");
|
|
79
|
+
await flush();
|
|
80
|
+
expect(created.length).toBeGreaterThanOrEqual(2); // a new source was opened
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("disconnect() closes the source and stops", async () => {
|
|
84
|
+
const { api, created } = harness();
|
|
85
|
+
await flush();
|
|
86
|
+
api.disconnect();
|
|
87
|
+
expect(created[0]!.closed).toBe(true);
|
|
88
|
+
expect(api.status.value).toBe("closed");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("derives the correlation tree from buffered records", async () => {
|
|
92
|
+
const { api, created } = harness();
|
|
93
|
+
await flush();
|
|
94
|
+
created[0]!.onmessage?.({ data: rec("action.dispatched", "m1") });
|
|
95
|
+
created[0]!.onmessage?.({
|
|
96
|
+
data: JSON.stringify({
|
|
97
|
+
kind: "event.published",
|
|
98
|
+
envelope: { messageId: "m2", causationId: "m1", correlationId: "c1" },
|
|
99
|
+
}),
|
|
100
|
+
});
|
|
101
|
+
expect(api.tree.value).toHaveLength(1);
|
|
102
|
+
expect(api.tree.value[0]!.children).toHaveLength(1);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useTelemetry + useRunList — source-picker behavior.
|
|
3
|
+
*
|
|
4
|
+
* Tests the repointed endpoints (/__nwire/telemetry/live SSE tail + runs API)
|
|
5
|
+
* and the live-vs-history mode switch.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi } from "vitest";
|
|
8
|
+
import { effectScope, nextTick, ref } from "vue";
|
|
9
|
+
import {
|
|
10
|
+
useTelemetry,
|
|
11
|
+
useRunList,
|
|
12
|
+
type EventSourceLike,
|
|
13
|
+
type TelemetryRunMeta,
|
|
14
|
+
RUNS_URL,
|
|
15
|
+
} from "../useTelemetry";
|
|
16
|
+
|
|
17
|
+
// ── Fake EventSource ──────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
class FakeES implements EventSourceLike {
|
|
20
|
+
onmessage: ((ev: { data: string }) => void) | null = null;
|
|
21
|
+
onerror: ((ev?: unknown) => void) | null = null;
|
|
22
|
+
onopen: ((ev?: unknown) => void) | null = null;
|
|
23
|
+
closed = false;
|
|
24
|
+
listeners: Record<string, Array<(ev: { data: string }) => void>> = {};
|
|
25
|
+
lastUrl: string;
|
|
26
|
+
constructor(url: string) {
|
|
27
|
+
this.lastUrl = url;
|
|
28
|
+
}
|
|
29
|
+
addEventListener(type: string, listener: (ev: { data: string }) => void): void {
|
|
30
|
+
(this.listeners[type] ??= []).push(listener);
|
|
31
|
+
}
|
|
32
|
+
emit(type: string, data: unknown): void {
|
|
33
|
+
for (const l of this.listeners[type] ?? []) {
|
|
34
|
+
l({ data: JSON.stringify(data) });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
close(): void {
|
|
38
|
+
this.closed = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const flush = () => new Promise((r) => setTimeout(r, 10));
|
|
43
|
+
|
|
44
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
const REC = (kind: string, n = "1") =>
|
|
47
|
+
JSON.stringify({
|
|
48
|
+
kind,
|
|
49
|
+
envelope: { messageId: `m${n}`, causationId: `m${n}`, correlationId: "c1" },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const RUN_META: TelemetryRunMeta = {
|
|
53
|
+
id: "2026-06-18T20-00-00-000Z-abcdef",
|
|
54
|
+
size: 100,
|
|
55
|
+
mtime: "2026-06-18T20:00:00.000Z",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function makeFetch(runsResp?: TelemetryRunMeta[], runRecords?: unknown[]) {
|
|
59
|
+
return vi.fn(async (url: string) => {
|
|
60
|
+
if (url.startsWith(RUNS_URL + "/")) {
|
|
61
|
+
return {
|
|
62
|
+
ok: true,
|
|
63
|
+
json: async () => ({ id: "2026-06-18T20-00-00-000Z-abcdef", records: runRecords ?? [] }),
|
|
64
|
+
} as unknown as Response;
|
|
65
|
+
}
|
|
66
|
+
if (url === RUNS_URL || url.startsWith(RUNS_URL + "?")) {
|
|
67
|
+
return {
|
|
68
|
+
ok: true,
|
|
69
|
+
json: async () => ({ runs: runsResp ?? [] }),
|
|
70
|
+
} as unknown as Response;
|
|
71
|
+
}
|
|
72
|
+
return { ok: false, status: 404, json: async () => ({}) } as unknown as Response;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function liveTelemetryHarness(opts: Parameters<typeof useTelemetry>[1] = {}) {
|
|
77
|
+
const created: FakeES[] = [];
|
|
78
|
+
const factory = (url: string) => {
|
|
79
|
+
const es = new FakeES(url);
|
|
80
|
+
created.push(es);
|
|
81
|
+
return es;
|
|
82
|
+
};
|
|
83
|
+
const scope = effectScope();
|
|
84
|
+
const api = scope.run(() =>
|
|
85
|
+
useTelemetry(undefined, {
|
|
86
|
+
eventSourceFactory: factory,
|
|
87
|
+
fetchFn: makeFetch(),
|
|
88
|
+
reconnectMs: 1,
|
|
89
|
+
...opts,
|
|
90
|
+
}),
|
|
91
|
+
)!;
|
|
92
|
+
return { api, created, scope };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
describe("useTelemetry — live mode (default)", () => {
|
|
98
|
+
it("opens an SSE connection to /__nwire/telemetry/live", async () => {
|
|
99
|
+
const { api, created, scope } = liveTelemetryHarness();
|
|
100
|
+
await flush();
|
|
101
|
+
expect(created).toHaveLength(1);
|
|
102
|
+
expect(created[0]!.lastUrl).toBe("/__nwire/telemetry/live");
|
|
103
|
+
expect(api.source.value).toBe("live");
|
|
104
|
+
scope.stop();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("pushes default-event records into the ring buffer", async () => {
|
|
108
|
+
const { api, created, scope } = liveTelemetryHarness();
|
|
109
|
+
await flush();
|
|
110
|
+
created[0]!.onmessage?.({ data: REC("action.completed") });
|
|
111
|
+
expect(api.records.value).toHaveLength(1);
|
|
112
|
+
expect(api.records.value[0]!.kind).toBe("action.completed");
|
|
113
|
+
scope.stop();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("stores the runId from the run-meta SSE event", async () => {
|
|
117
|
+
const { api, created, scope } = liveTelemetryHarness();
|
|
118
|
+
await flush();
|
|
119
|
+
created[0]!.emit("run", RUN_META);
|
|
120
|
+
expect(api.currentRunId.value).toBe(RUN_META.id);
|
|
121
|
+
scope.stop();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("reconnects on error", async () => {
|
|
125
|
+
const { api, created, scope } = liveTelemetryHarness();
|
|
126
|
+
await flush();
|
|
127
|
+
created[0]!.onerror?.();
|
|
128
|
+
expect(created[0]!.closed).toBe(true);
|
|
129
|
+
expect(api.status.value).toBe("reconnecting");
|
|
130
|
+
await flush();
|
|
131
|
+
expect(created.length).toBeGreaterThanOrEqual(2);
|
|
132
|
+
scope.stop();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("caps the ring buffer at limit", async () => {
|
|
136
|
+
const { api, created, scope } = liveTelemetryHarness({ limit: 3 });
|
|
137
|
+
await flush();
|
|
138
|
+
for (let i = 0; i < 6; i++)
|
|
139
|
+
created[0]!.onmessage?.({ data: REC("event.published", String(i)) });
|
|
140
|
+
expect(api.records.value).toHaveLength(3);
|
|
141
|
+
scope.stop();
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("useTelemetry — history mode", () => {
|
|
146
|
+
it("fetches run records when source is set to a TelemetryRunMeta", async () => {
|
|
147
|
+
const runRecords = [
|
|
148
|
+
{
|
|
149
|
+
kind: "action.completed",
|
|
150
|
+
envelope: { messageId: "m1", causationId: "m1", correlationId: "c1" },
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
kind: "event.published",
|
|
154
|
+
envelope: { messageId: "m2", causationId: "m1", correlationId: "c1" },
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
const fetchFn = makeFetch([], runRecords);
|
|
158
|
+
const created: FakeES[] = [];
|
|
159
|
+
const scope = effectScope();
|
|
160
|
+
const api = scope.run(() =>
|
|
161
|
+
useTelemetry(undefined, {
|
|
162
|
+
eventSourceFactory: (url) => {
|
|
163
|
+
const es = new FakeES(url);
|
|
164
|
+
created.push(es);
|
|
165
|
+
return es;
|
|
166
|
+
},
|
|
167
|
+
fetchFn,
|
|
168
|
+
reconnectMs: 1,
|
|
169
|
+
}),
|
|
170
|
+
)!;
|
|
171
|
+
|
|
172
|
+
await flush();
|
|
173
|
+
// Switch to history mode.
|
|
174
|
+
api.source.value = RUN_META;
|
|
175
|
+
await flush();
|
|
176
|
+
|
|
177
|
+
expect(fetchFn).toHaveBeenCalledWith(
|
|
178
|
+
expect.stringContaining(`${RUNS_URL}/${encodeURIComponent(RUN_META.id)}`),
|
|
179
|
+
);
|
|
180
|
+
expect(api.records.value).toHaveLength(2);
|
|
181
|
+
expect(api.records.value[0]!.kind).toBe("action.completed");
|
|
182
|
+
expect(api.status.value).toBe("open");
|
|
183
|
+
expect(api.currentRunId.value).toBe(RUN_META.id);
|
|
184
|
+
scope.stop();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("switches back to live mode when source is set to 'live'", async () => {
|
|
188
|
+
const fetchFn = makeFetch([], [{ kind: "hook.step" }]);
|
|
189
|
+
const created: FakeES[] = [];
|
|
190
|
+
const scope = effectScope();
|
|
191
|
+
const api = scope.run(() =>
|
|
192
|
+
useTelemetry(undefined, {
|
|
193
|
+
eventSourceFactory: (url) => {
|
|
194
|
+
const es = new FakeES(url);
|
|
195
|
+
created.push(es);
|
|
196
|
+
return es;
|
|
197
|
+
},
|
|
198
|
+
fetchFn,
|
|
199
|
+
reconnectMs: 1,
|
|
200
|
+
}),
|
|
201
|
+
)!;
|
|
202
|
+
|
|
203
|
+
await flush();
|
|
204
|
+
const liveCount = created.length;
|
|
205
|
+
|
|
206
|
+
// Switch to history.
|
|
207
|
+
api.source.value = RUN_META;
|
|
208
|
+
await flush();
|
|
209
|
+
expect(api.records.value).toHaveLength(1);
|
|
210
|
+
|
|
211
|
+
// Switch back to live.
|
|
212
|
+
api.source.value = "live";
|
|
213
|
+
await flush();
|
|
214
|
+
expect(api.records.value).toHaveLength(0); // cleared on switch
|
|
215
|
+
expect(created.length).toBeGreaterThan(liveCount); // new SSE opened
|
|
216
|
+
scope.stop();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("sets status to closed when history fetch fails", async () => {
|
|
220
|
+
const fetchFn = vi.fn(async () => ({ ok: false, status: 404 }) as unknown as Response);
|
|
221
|
+
const scope = effectScope();
|
|
222
|
+
const api = scope.run(() =>
|
|
223
|
+
useTelemetry(undefined, {
|
|
224
|
+
eventSourceFactory: (url) => new FakeES(url),
|
|
225
|
+
fetchFn,
|
|
226
|
+
reconnectMs: 1,
|
|
227
|
+
autoConnect: false,
|
|
228
|
+
}),
|
|
229
|
+
)!;
|
|
230
|
+
|
|
231
|
+
api.source.value = RUN_META;
|
|
232
|
+
await nextTick();
|
|
233
|
+
api.connect();
|
|
234
|
+
await flush();
|
|
235
|
+
|
|
236
|
+
expect(api.status.value).toBe("closed");
|
|
237
|
+
scope.stop();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("useRunList", () => {
|
|
242
|
+
it("fetches runs on mount and exposes them", async () => {
|
|
243
|
+
const fetchFn = makeFetch([RUN_META]);
|
|
244
|
+
const scope = effectScope();
|
|
245
|
+
const { runs, loading, error } = scope.run(() => useRunList(undefined, { fetchFn }))!;
|
|
246
|
+
|
|
247
|
+
expect(loading.value).toBe(true);
|
|
248
|
+
await flush();
|
|
249
|
+
expect(loading.value).toBe(false);
|
|
250
|
+
expect(error.value).toBeNull();
|
|
251
|
+
expect(runs.value).toHaveLength(1);
|
|
252
|
+
expect(runs.value[0]!.id).toBe(RUN_META.id);
|
|
253
|
+
scope.stop();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("sets error when fetch fails", async () => {
|
|
257
|
+
const fetchFn = vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response);
|
|
258
|
+
const scope = effectScope();
|
|
259
|
+
const { error, loading } = scope.run(() => useRunList(undefined, { fetchFn }))!;
|
|
260
|
+
|
|
261
|
+
await flush();
|
|
262
|
+
expect(loading.value).toBe(false);
|
|
263
|
+
expect(error.value).toMatch(/500/);
|
|
264
|
+
scope.stop();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("re-fetches when project changes", async () => {
|
|
268
|
+
const fetchFn = makeFetch([RUN_META]);
|
|
269
|
+
const proj = ref("proj-a");
|
|
270
|
+
const scope = effectScope();
|
|
271
|
+
scope.run(() => useRunList(proj, { fetchFn }))!;
|
|
272
|
+
|
|
273
|
+
await flush();
|
|
274
|
+
expect(fetchFn).toHaveBeenCalledTimes(1);
|
|
275
|
+
|
|
276
|
+
proj.value = "proj-b";
|
|
277
|
+
await nextTick();
|
|
278
|
+
await flush();
|
|
279
|
+
expect(fetchFn).toHaveBeenCalledTimes(2);
|
|
280
|
+
scope.stop();
|
|
281
|
+
});
|
|
282
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useDiscovery` — discovered projects + live health. Posts the known cwds to
|
|
3
|
+
* `/__nwire/projects/status` on a TanStack poll, merged with the local catalog
|
|
4
|
+
* so a project shows even when its process is down.
|
|
5
|
+
*/
|
|
6
|
+
import { computed } from "vue";
|
|
7
|
+
import { useQuery } from "@tanstack/vue-query";
|
|
8
|
+
import { loadCatalog, getActiveProjectCwd, type ProjectSnapshot } from "../lib/project-catalog";
|
|
9
|
+
|
|
10
|
+
const STATUS_URL = "/__nwire/projects/status";
|
|
11
|
+
|
|
12
|
+
/** Per-project live status from the server (process up? port? pid?). Shape is server-owned. */
|
|
13
|
+
export interface ProjectStatus {
|
|
14
|
+
readonly running?: boolean;
|
|
15
|
+
readonly port?: number;
|
|
16
|
+
readonly pid?: number;
|
|
17
|
+
readonly [k: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
export type StatusMap = Record<string, ProjectStatus>;
|
|
20
|
+
|
|
21
|
+
/** A discovered project: its catalog snapshot + (best-effort) live status. */
|
|
22
|
+
export interface DiscoveredProject {
|
|
23
|
+
readonly cwd: string;
|
|
24
|
+
readonly snapshot: ProjectSnapshot;
|
|
25
|
+
readonly status?: ProjectStatus;
|
|
26
|
+
readonly active: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useDiscovery(options: { intervalMs?: number } = {}) {
|
|
30
|
+
const query = useQuery({
|
|
31
|
+
queryKey: ["discovery"],
|
|
32
|
+
queryFn: async (): Promise<StatusMap> => {
|
|
33
|
+
const cwds = Object.keys(loadCatalog());
|
|
34
|
+
if (cwds.length === 0) return {};
|
|
35
|
+
const res = await fetch(STATUS_URL, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "Content-Type": "application/json" },
|
|
38
|
+
body: JSON.stringify({ cwds }),
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok) return {};
|
|
41
|
+
return (await res.json()) as StatusMap;
|
|
42
|
+
},
|
|
43
|
+
refetchInterval: options.intervalMs ?? 5_000,
|
|
44
|
+
placeholderData: (prev) => prev,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const projects = computed<DiscoveredProject[]>(() => {
|
|
48
|
+
const catalog = loadCatalog();
|
|
49
|
+
const active = getActiveProjectCwd();
|
|
50
|
+
const statuses = query.data.value ?? {};
|
|
51
|
+
return Object.values(catalog)
|
|
52
|
+
.map((snapshot) => ({
|
|
53
|
+
cwd: snapshot.cwd,
|
|
54
|
+
snapshot,
|
|
55
|
+
status: statuses[snapshot.cwd],
|
|
56
|
+
active: snapshot.cwd === active,
|
|
57
|
+
}))
|
|
58
|
+
.sort(
|
|
59
|
+
(a, b) =>
|
|
60
|
+
Number(b.active) - Number(a.active) || a.snapshot.name.localeCompare(b.snapshot.name),
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const runningCount = computed(() => projects.value.filter((p) => p.status?.running).length);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
projects,
|
|
68
|
+
runningCount,
|
|
69
|
+
isLoading: query.isLoading,
|
|
70
|
+
isError: query.isError,
|
|
71
|
+
refetch: query.refetch,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useEndpoints` — reactive endpoint list for the active-front picker.
|
|
3
|
+
*
|
|
4
|
+
* Fetches `/__nwire/endpoints` on mount and provides a `setActive` helper
|
|
5
|
+
* that POSTs `/__nwire/endpoints/active` and refreshes the list.
|
|
6
|
+
*
|
|
7
|
+
* In a single-endpoint project the list has one entry and the picker is
|
|
8
|
+
* a no-op — the call still works, the UI just shows one option already
|
|
9
|
+
* marked active.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { ref, onMounted } from "vue";
|
|
13
|
+
|
|
14
|
+
export const ENDPOINTS_URL = "/__nwire/endpoints";
|
|
15
|
+
export const ENDPOINTS_ACTIVE_URL = "/__nwire/endpoints/active";
|
|
16
|
+
|
|
17
|
+
export interface EndpointInfo {
|
|
18
|
+
/** Endpoint name — from `endpoint("name")`. */
|
|
19
|
+
readonly name: string;
|
|
20
|
+
/** True when this is the currently active HTTP front. */
|
|
21
|
+
readonly active: boolean;
|
|
22
|
+
/** True when this endpoint registered at least one HTTP handler. */
|
|
23
|
+
readonly hasHttp: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface UseEndpointsOptions {
|
|
27
|
+
/** Inject fetch (tests). Default: global `fetch`. */
|
|
28
|
+
readonly fetchFn?: typeof fetch;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function useEndpoints(options: UseEndpointsOptions = {}) {
|
|
32
|
+
const doFetch = options.fetchFn ?? ((...a: Parameters<typeof fetch>) => fetch(...a));
|
|
33
|
+
|
|
34
|
+
const endpoints = ref<EndpointInfo[]>([]);
|
|
35
|
+
const loading = ref(false);
|
|
36
|
+
const error = ref<string | null>(null);
|
|
37
|
+
const switching = ref(false);
|
|
38
|
+
|
|
39
|
+
async function refresh(): Promise<void> {
|
|
40
|
+
loading.value = true;
|
|
41
|
+
error.value = null;
|
|
42
|
+
try {
|
|
43
|
+
const res = await doFetch(ENDPOINTS_URL);
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
// 501 = route not yet supported (standalone Studio or older host).
|
|
46
|
+
// Treat as empty — the picker simply won't appear.
|
|
47
|
+
if (res.status === 501) {
|
|
48
|
+
endpoints.value = [];
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
error.value = `Failed to load endpoints (${res.status})`;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const body = (await res.json()) as { endpoints?: EndpointInfo[] };
|
|
55
|
+
endpoints.value = body.endpoints ?? [];
|
|
56
|
+
} catch (err) {
|
|
57
|
+
error.value = (err as Error).message;
|
|
58
|
+
} finally {
|
|
59
|
+
loading.value = false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Switch the active HTTP front. Refreshes the list after a successful switch.
|
|
65
|
+
* In a single-endpoint project this is a no-op from the server's perspective
|
|
66
|
+
* (you can only select the one endpoint that is already active).
|
|
67
|
+
*/
|
|
68
|
+
async function setActive(name: string): Promise<void> {
|
|
69
|
+
switching.value = true;
|
|
70
|
+
error.value = null;
|
|
71
|
+
try {
|
|
72
|
+
const res = await doFetch(ENDPOINTS_ACTIVE_URL, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: { "Content-Type": "application/json" },
|
|
75
|
+
body: JSON.stringify({ name }),
|
|
76
|
+
});
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
|
79
|
+
error.value = body.error ?? `Switch failed (${res.status})`;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// Refresh to get the updated active flag.
|
|
83
|
+
await refresh();
|
|
84
|
+
} catch (err) {
|
|
85
|
+
error.value = (err as Error).message;
|
|
86
|
+
} finally {
|
|
87
|
+
switching.value = false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
onMounted(refresh);
|
|
92
|
+
|
|
93
|
+
return { endpoints, loading, error, switching, refresh, setActive };
|
|
94
|
+
}
|