shipwright-cli 2.2.2 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -11
- package/dashboard/public/index.html +224 -8
- package/dashboard/public/styles.css +1078 -4
- package/dashboard/server.ts +1100 -15
- package/dashboard/src/canvas/interactions.ts +74 -0
- package/dashboard/src/canvas/layout.ts +85 -0
- package/dashboard/src/canvas/overlays.ts +117 -0
- package/dashboard/src/canvas/particles.ts +105 -0
- package/dashboard/src/canvas/renderer.ts +191 -0
- package/dashboard/src/components/charts/bar.ts +54 -0
- package/dashboard/src/components/charts/donut.ts +25 -0
- package/dashboard/src/components/charts/pipeline-rail.ts +105 -0
- package/dashboard/src/components/charts/sparkline.ts +82 -0
- package/dashboard/src/components/header.ts +616 -0
- package/dashboard/src/components/modal.ts +413 -0
- package/dashboard/src/components/terminal.ts +144 -0
- package/dashboard/src/core/api.test.ts +362 -0
- package/dashboard/src/core/api.ts +381 -0
- package/dashboard/src/core/helpers.ts +118 -0
- package/dashboard/src/core/router.test.ts +266 -0
- package/dashboard/src/core/router.ts +190 -0
- package/dashboard/src/core/sse.ts +38 -0
- package/dashboard/src/core/state.test.ts +235 -0
- package/dashboard/src/core/state.ts +150 -0
- package/dashboard/src/core/ws.test.ts +216 -0
- package/dashboard/src/core/ws.ts +143 -0
- package/dashboard/src/design/icons.test.ts +105 -0
- package/dashboard/src/design/icons.ts +131 -0
- package/dashboard/src/design/tokens.test.ts +204 -0
- package/dashboard/src/design/tokens.ts +160 -0
- package/dashboard/src/main.ts +68 -0
- package/dashboard/src/types/api.ts +337 -0
- package/dashboard/src/views/activity.ts +185 -0
- package/dashboard/src/views/agent-cockpit.ts +236 -0
- package/dashboard/src/views/agents.ts +72 -0
- package/dashboard/src/views/fleet-map.ts +299 -0
- package/dashboard/src/views/insights.ts +298 -0
- package/dashboard/src/views/machines.ts +162 -0
- package/dashboard/src/views/metrics.ts +420 -0
- package/dashboard/src/views/overview.ts +409 -0
- package/dashboard/src/views/pipeline-theater.ts +219 -0
- package/dashboard/src/views/pipelines.ts +595 -0
- package/dashboard/src/views/team.ts +362 -0
- package/dashboard/src/views/timeline.ts +389 -0
- package/dashboard/tsconfig.json +21 -0
- package/dashboard/vitest.config.ts +27 -0
- package/docs/AGI-WHATS-NEXT.md +15 -15
- package/package.json +16 -2
- package/scripts/lib/helpers.sh +30 -0
- package/scripts/lib/pipeline-quality-checks.sh +1 -1
- package/scripts/lib/pipeline-stages.sh +59 -0
- package/scripts/sw +86 -167
- package/scripts/sw-activity.sh +1 -1
- package/scripts/sw-adaptive.sh +1 -1
- package/scripts/sw-adversarial.sh +1 -1
- package/scripts/sw-architecture-enforcer.sh +1 -1
- package/scripts/sw-auth.sh +14 -6
- package/scripts/sw-autonomous.sh +230 -13
- package/scripts/sw-changelog.sh +2 -2
- package/scripts/sw-checkpoint.sh +1 -1
- package/scripts/sw-ci.sh +1 -1
- package/scripts/sw-cleanup.sh +1 -1
- package/scripts/sw-code-review.sh +1 -1
- package/scripts/sw-connect.sh +1 -1
- package/scripts/sw-context.sh +1 -1
- package/scripts/sw-cost.sh +1 -1
- package/scripts/sw-daemon.sh +2 -2
- package/scripts/sw-dashboard.sh +1 -1
- package/scripts/sw-db.sh +1 -1
- package/scripts/sw-decompose.sh +1 -1
- package/scripts/sw-deps.sh +1 -1
- package/scripts/sw-developer-simulation.sh +1 -1
- package/scripts/sw-discovery.sh +1 -1
- package/scripts/sw-doc-fleet.sh +1 -1
- package/scripts/sw-docs-agent.sh +1 -1
- package/scripts/sw-docs.sh +1 -1
- package/scripts/sw-doctor.sh +8 -1
- package/scripts/sw-dora.sh +1 -1
- package/scripts/sw-durable.sh +1 -1
- package/scripts/sw-e2e-orchestrator.sh +1 -1
- package/scripts/sw-eventbus.sh +1 -1
- package/scripts/sw-feedback.sh +1 -1
- package/scripts/sw-fix.sh +6 -5
- package/scripts/sw-fleet-discover.sh +1 -1
- package/scripts/sw-fleet-viz.sh +1 -1
- package/scripts/sw-fleet.sh +1 -1
- package/scripts/sw-github-app.sh +5 -2
- package/scripts/sw-github-checks.sh +1 -1
- package/scripts/sw-github-deploy.sh +1 -1
- package/scripts/sw-github-graphql.sh +1 -1
- package/scripts/sw-guild.sh +1 -1
- package/scripts/sw-heartbeat.sh +1 -1
- package/scripts/sw-hygiene.sh +1 -1
- package/scripts/sw-incident.sh +1 -1
- package/scripts/sw-init.sh +112 -9
- package/scripts/sw-instrument.sh +6 -1
- package/scripts/sw-intelligence.sh +5 -1
- package/scripts/sw-jira.sh +1 -1
- package/scripts/sw-launchd.sh +1 -1
- package/scripts/sw-linear.sh +20 -9
- package/scripts/sw-logs.sh +1 -1
- package/scripts/sw-loop.sh +2 -1
- package/scripts/sw-memory.sh +10 -1
- package/scripts/sw-mission-control.sh +1 -1
- package/scripts/sw-model-router.sh +4 -1
- package/scripts/sw-otel.sh +1 -1
- package/scripts/sw-oversight.sh +1 -1
- package/scripts/sw-pipeline-composer.sh +3 -1
- package/scripts/sw-pipeline-vitals.sh +4 -6
- package/scripts/sw-pipeline.sh +4 -1
- package/scripts/sw-pm.sh +5 -2
- package/scripts/sw-pr-lifecycle.sh +1 -1
- package/scripts/sw-predictive.sh +4 -1
- package/scripts/sw-prep.sh +3 -2
- package/scripts/sw-ps.sh +1 -1
- package/scripts/sw-public-dashboard.sh +10 -4
- package/scripts/sw-quality.sh +1 -1
- package/scripts/sw-reaper.sh +1 -1
- package/scripts/sw-recruit.sh +16 -0
- package/scripts/sw-regression.sh +2 -1
- package/scripts/sw-release-manager.sh +1 -1
- package/scripts/sw-release.sh +7 -5
- package/scripts/sw-remote.sh +1 -1
- package/scripts/sw-replay.sh +1 -1
- package/scripts/sw-retro.sh +4 -1
- package/scripts/sw-scale.sh +4 -1
- package/scripts/sw-security-audit.sh +1 -1
- package/scripts/sw-self-optimize.sh +113 -1
- package/scripts/sw-session.sh +1 -1
- package/scripts/sw-setup.sh +1 -1
- package/scripts/sw-standup.sh +2 -1
- package/scripts/sw-status.sh +1 -1
- package/scripts/sw-strategic.sh +2 -1
- package/scripts/sw-stream.sh +1 -1
- package/scripts/sw-swarm.sh +6 -1
- package/scripts/sw-team-stages.sh +1 -1
- package/scripts/sw-templates.sh +1 -1
- package/scripts/sw-testgen.sh +3 -2
- package/scripts/sw-tmux-pipeline.sh +2 -1
- package/scripts/sw-tmux.sh +1 -1
- package/scripts/sw-trace.sh +1 -1
- package/scripts/sw-tracker-jira.sh +1 -0
- package/scripts/sw-tracker-linear.sh +1 -0
- package/scripts/sw-tracker.sh +1 -1
- package/scripts/sw-triage.sh +198 -11
- package/scripts/sw-upgrade.sh +1 -1
- package/scripts/sw-ux.sh +1 -1
- package/scripts/sw-webhook.sh +1 -1
- package/scripts/sw-widgets.sh +2 -2
- package/scripts/sw-worktree.sh +1 -1
- package/dashboard/public/app.js +0 -4422
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// We need to mock the DOM and store before importing the router
|
|
4
|
+
describe("Router", () => {
|
|
5
|
+
let store: any;
|
|
6
|
+
let router: typeof import("./router");
|
|
7
|
+
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
// Reset DOM
|
|
10
|
+
document.body.innerHTML = `
|
|
11
|
+
<div class="tab-btn" data-tab="overview">Overview</div>
|
|
12
|
+
<div class="tab-btn" data-tab="pipelines">Pipelines</div>
|
|
13
|
+
<div class="tab-btn" data-tab="metrics">Metrics</div>
|
|
14
|
+
<div class="tab-btn" data-tab="team">Team</div>
|
|
15
|
+
<div class="tab-panel" id="panel-overview"></div>
|
|
16
|
+
<div class="tab-panel" id="panel-pipelines"></div>
|
|
17
|
+
<div class="tab-panel" id="panel-metrics"></div>
|
|
18
|
+
<div class="tab-panel" id="panel-team"></div>
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
// Reset hash
|
|
22
|
+
location.hash = "";
|
|
23
|
+
|
|
24
|
+
// Fresh imports
|
|
25
|
+
vi.resetModules();
|
|
26
|
+
store = (await import("./state")).store;
|
|
27
|
+
router = await import("./router");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.restoreAllMocks();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("registerView", () => {
|
|
35
|
+
it("registers a view for a tab", () => {
|
|
36
|
+
const mockView = {
|
|
37
|
+
init: vi.fn(),
|
|
38
|
+
render: vi.fn(),
|
|
39
|
+
destroy: vi.fn(),
|
|
40
|
+
};
|
|
41
|
+
router.registerView("overview", mockView);
|
|
42
|
+
|
|
43
|
+
const views = router.getRegisteredViews();
|
|
44
|
+
expect(views.get("overview")).toBe(mockView);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("switchTab", () => {
|
|
49
|
+
it("updates the active tab in the store", () => {
|
|
50
|
+
store.set("activeTab", "overview");
|
|
51
|
+
router.switchTab("pipelines");
|
|
52
|
+
expect(store.get("activeTab")).toBe("pipelines");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("updates the location hash", () => {
|
|
56
|
+
store.set("activeTab", "overview");
|
|
57
|
+
router.switchTab("pipelines");
|
|
58
|
+
expect(location.hash).toBe("#pipelines");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("adds active class to the correct tab button", () => {
|
|
62
|
+
store.set("activeTab", "overview");
|
|
63
|
+
router.switchTab("pipelines");
|
|
64
|
+
|
|
65
|
+
const btns = document.querySelectorAll(".tab-btn");
|
|
66
|
+
const pipelinesBtn = Array.from(btns).find(
|
|
67
|
+
(b) => b.getAttribute("data-tab") === "pipelines",
|
|
68
|
+
);
|
|
69
|
+
const overviewBtn = Array.from(btns).find(
|
|
70
|
+
(b) => b.getAttribute("data-tab") === "overview",
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(pipelinesBtn?.classList.contains("active")).toBe(true);
|
|
74
|
+
expect(overviewBtn?.classList.contains("active")).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("adds active class to the correct panel", () => {
|
|
78
|
+
store.set("activeTab", "overview");
|
|
79
|
+
router.switchTab("pipelines");
|
|
80
|
+
|
|
81
|
+
const pipelinesPanel = document.getElementById("panel-pipelines");
|
|
82
|
+
const overviewPanel = document.getElementById("panel-overview");
|
|
83
|
+
|
|
84
|
+
expect(pipelinesPanel?.classList.contains("active")).toBe(true);
|
|
85
|
+
expect(overviewPanel?.classList.contains("active")).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("does nothing when switching to the current tab", () => {
|
|
89
|
+
store.set("activeTab", "overview");
|
|
90
|
+
const prevHash = location.hash;
|
|
91
|
+
router.switchTab("overview");
|
|
92
|
+
// Should not change anything
|
|
93
|
+
expect(store.get("activeTab")).toBe("overview");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("initializes the view on first switch", () => {
|
|
97
|
+
const mockView = {
|
|
98
|
+
init: vi.fn(),
|
|
99
|
+
render: vi.fn(),
|
|
100
|
+
destroy: vi.fn(),
|
|
101
|
+
};
|
|
102
|
+
router.registerView("metrics", mockView);
|
|
103
|
+
|
|
104
|
+
store.set("activeTab", "overview");
|
|
105
|
+
router.switchTab("metrics");
|
|
106
|
+
|
|
107
|
+
expect(mockView.init).toHaveBeenCalledTimes(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("destroys the previous view on tab switch", () => {
|
|
111
|
+
const overviewView = {
|
|
112
|
+
init: vi.fn(),
|
|
113
|
+
render: vi.fn(),
|
|
114
|
+
destroy: vi.fn(),
|
|
115
|
+
};
|
|
116
|
+
const metricsView = {
|
|
117
|
+
init: vi.fn(),
|
|
118
|
+
render: vi.fn(),
|
|
119
|
+
destroy: vi.fn(),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
router.registerView("overview", overviewView);
|
|
123
|
+
router.registerView("metrics", metricsView);
|
|
124
|
+
|
|
125
|
+
// First, switch to overview so it gets initialized
|
|
126
|
+
store.set("activeTab", "pipelines");
|
|
127
|
+
router.switchTab("overview");
|
|
128
|
+
expect(overviewView.init).toHaveBeenCalledTimes(1);
|
|
129
|
+
|
|
130
|
+
// Now switch to metrics
|
|
131
|
+
router.switchTab("metrics");
|
|
132
|
+
expect(overviewView.destroy).toHaveBeenCalledTimes(1);
|
|
133
|
+
expect(metricsView.init).toHaveBeenCalledTimes(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("renders with fleet state if available", () => {
|
|
137
|
+
const mockView = {
|
|
138
|
+
init: vi.fn(),
|
|
139
|
+
render: vi.fn(),
|
|
140
|
+
destroy: vi.fn(),
|
|
141
|
+
};
|
|
142
|
+
router.registerView("metrics", mockView);
|
|
143
|
+
|
|
144
|
+
const fakeState = { pipelines: [], machines: [] };
|
|
145
|
+
store.set("fleetState", fakeState);
|
|
146
|
+
store.set("activeTab", "overview");
|
|
147
|
+
|
|
148
|
+
router.switchTab("metrics");
|
|
149
|
+
expect(mockView.render).toHaveBeenCalledWith(fakeState);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("error boundaries", () => {
|
|
154
|
+
it("catches init errors and shows error boundary", () => {
|
|
155
|
+
const errorView = {
|
|
156
|
+
init: vi.fn(() => {
|
|
157
|
+
throw new Error("Init failed!");
|
|
158
|
+
}),
|
|
159
|
+
render: vi.fn(),
|
|
160
|
+
destroy: vi.fn(),
|
|
161
|
+
};
|
|
162
|
+
router.registerView("metrics", errorView);
|
|
163
|
+
|
|
164
|
+
// Suppress console.error for this test
|
|
165
|
+
const consoleSpy = vi
|
|
166
|
+
.spyOn(console, "error")
|
|
167
|
+
.mockImplementation(() => {});
|
|
168
|
+
|
|
169
|
+
store.set("activeTab", "overview");
|
|
170
|
+
router.switchTab("metrics");
|
|
171
|
+
|
|
172
|
+
const panel = document.getElementById("panel-metrics");
|
|
173
|
+
const errorBoundary = panel?.querySelector(".tab-error-boundary");
|
|
174
|
+
expect(errorBoundary).toBeTruthy();
|
|
175
|
+
expect(errorBoundary?.textContent).toContain("Init failed!");
|
|
176
|
+
|
|
177
|
+
consoleSpy.mockRestore();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("catches render errors and shows error boundary", () => {
|
|
181
|
+
const errorView = {
|
|
182
|
+
init: vi.fn(),
|
|
183
|
+
render: vi.fn(() => {
|
|
184
|
+
throw new Error("Render exploded!");
|
|
185
|
+
}),
|
|
186
|
+
destroy: vi.fn(),
|
|
187
|
+
};
|
|
188
|
+
router.registerView("metrics", errorView);
|
|
189
|
+
|
|
190
|
+
const consoleSpy = vi
|
|
191
|
+
.spyOn(console, "error")
|
|
192
|
+
.mockImplementation(() => {});
|
|
193
|
+
|
|
194
|
+
store.set("fleetState", { pipelines: [] });
|
|
195
|
+
store.set("activeTab", "overview");
|
|
196
|
+
router.switchTab("metrics");
|
|
197
|
+
|
|
198
|
+
const panel = document.getElementById("panel-metrics");
|
|
199
|
+
const errorBoundary = panel?.querySelector(".tab-error-boundary");
|
|
200
|
+
expect(errorBoundary).toBeTruthy();
|
|
201
|
+
expect(errorBoundary?.textContent).toContain("Render exploded!");
|
|
202
|
+
|
|
203
|
+
consoleSpy.mockRestore();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("does not stack multiple error boundaries", () => {
|
|
207
|
+
const errorView = {
|
|
208
|
+
init: vi.fn(() => {
|
|
209
|
+
throw new Error("Fail");
|
|
210
|
+
}),
|
|
211
|
+
render: vi.fn(),
|
|
212
|
+
destroy: vi.fn(),
|
|
213
|
+
};
|
|
214
|
+
router.registerView("metrics", errorView);
|
|
215
|
+
|
|
216
|
+
const consoleSpy = vi
|
|
217
|
+
.spyOn(console, "error")
|
|
218
|
+
.mockImplementation(() => {});
|
|
219
|
+
|
|
220
|
+
store.set("activeTab", "overview");
|
|
221
|
+
router.switchTab("metrics");
|
|
222
|
+
|
|
223
|
+
// Try to trigger again (should not add second boundary)
|
|
224
|
+
store.set("activeTab", "pipelines");
|
|
225
|
+
router.switchTab("metrics");
|
|
226
|
+
|
|
227
|
+
const panel = document.getElementById("panel-metrics");
|
|
228
|
+
const boundaries = panel?.querySelectorAll(".tab-error-boundary");
|
|
229
|
+
expect(boundaries?.length).toBeLessThanOrEqual(1);
|
|
230
|
+
|
|
231
|
+
consoleSpy.mockRestore();
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("renderActiveView", () => {
|
|
236
|
+
it("renders the current active view with fleet state", () => {
|
|
237
|
+
const mockView = {
|
|
238
|
+
init: vi.fn(),
|
|
239
|
+
render: vi.fn(),
|
|
240
|
+
destroy: vi.fn(),
|
|
241
|
+
};
|
|
242
|
+
router.registerView("overview", mockView);
|
|
243
|
+
|
|
244
|
+
const fakeState = { pipelines: [] };
|
|
245
|
+
store.set("fleetState", fakeState);
|
|
246
|
+
store.set("activeTab", "overview");
|
|
247
|
+
|
|
248
|
+
router.renderActiveView();
|
|
249
|
+
|
|
250
|
+
expect(mockView.render).toHaveBeenCalledWith(fakeState);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("does nothing when no fleet state is available", () => {
|
|
254
|
+
const mockView = {
|
|
255
|
+
init: vi.fn(),
|
|
256
|
+
render: vi.fn(),
|
|
257
|
+
destroy: vi.fn(),
|
|
258
|
+
};
|
|
259
|
+
router.registerView("overview", mockView);
|
|
260
|
+
store.set("activeTab", "overview");
|
|
261
|
+
|
|
262
|
+
router.renderActiveView();
|
|
263
|
+
expect(mockView.render).not.toHaveBeenCalled();
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Tab navigation with hash routing and view lifecycle
|
|
2
|
+
|
|
3
|
+
import { store } from "./state";
|
|
4
|
+
import type { TabId, View, FleetState } from "../types/api";
|
|
5
|
+
|
|
6
|
+
const views = new Map<TabId, View>();
|
|
7
|
+
const initializedViews = new Set<TabId>();
|
|
8
|
+
|
|
9
|
+
const VALID_TABS: TabId[] = [
|
|
10
|
+
"overview",
|
|
11
|
+
"agents",
|
|
12
|
+
"pipelines",
|
|
13
|
+
"timeline",
|
|
14
|
+
"activity",
|
|
15
|
+
"metrics",
|
|
16
|
+
"machines",
|
|
17
|
+
"insights",
|
|
18
|
+
"team",
|
|
19
|
+
"fleet-map",
|
|
20
|
+
"pipeline-theater",
|
|
21
|
+
"agent-cockpit",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
let teamRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
25
|
+
|
|
26
|
+
export function registerView(tabId: TabId, view: View): void {
|
|
27
|
+
views.set(tabId, view);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function switchTab(tab: TabId): void {
|
|
31
|
+
const prev = store.get("activeTab");
|
|
32
|
+
if (prev === tab) return;
|
|
33
|
+
|
|
34
|
+
// Destroy previous view
|
|
35
|
+
const prevView = views.get(prev);
|
|
36
|
+
if (prevView && initializedViews.has(prev)) {
|
|
37
|
+
prevView.destroy();
|
|
38
|
+
initializedViews.delete(prev);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Clear team refresh timer if leaving team tab
|
|
42
|
+
if (prev === "team" && teamRefreshTimer) {
|
|
43
|
+
clearInterval(teamRefreshTimer);
|
|
44
|
+
teamRefreshTimer = null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
store.set("activeTab", tab);
|
|
48
|
+
location.hash = "#" + tab;
|
|
49
|
+
|
|
50
|
+
// Update tab button classes
|
|
51
|
+
const btns = document.querySelectorAll(".tab-btn");
|
|
52
|
+
btns.forEach((btn) => {
|
|
53
|
+
if (btn.getAttribute("data-tab") === tab) {
|
|
54
|
+
btn.classList.add("active");
|
|
55
|
+
} else {
|
|
56
|
+
btn.classList.remove("active");
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Update panel visibility
|
|
61
|
+
const panels = document.querySelectorAll(".tab-panel");
|
|
62
|
+
panels.forEach((panel) => {
|
|
63
|
+
if (panel.id === "panel-" + tab) {
|
|
64
|
+
panel.classList.add("active");
|
|
65
|
+
} else {
|
|
66
|
+
panel.classList.remove("active");
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Initialize the new view with error boundary
|
|
71
|
+
const view = views.get(tab);
|
|
72
|
+
if (view && !initializedViews.has(tab)) {
|
|
73
|
+
try {
|
|
74
|
+
view.init();
|
|
75
|
+
initializedViews.add(tab);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
console.error(`[Error Boundary] Tab "${tab}" init failed:`, err);
|
|
78
|
+
showTabError(tab, err);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Render with current data
|
|
83
|
+
const fleetState = store.get("fleetState");
|
|
84
|
+
if (fleetState && view) {
|
|
85
|
+
try {
|
|
86
|
+
view.render(fleetState);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.error(`[Error Boundary] Tab "${tab}" render failed:`, err);
|
|
89
|
+
showTabError(tab, err);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function renderActiveView(): void {
|
|
95
|
+
const tab = store.get("activeTab");
|
|
96
|
+
const view = views.get(tab);
|
|
97
|
+
const fleetState = store.get("fleetState");
|
|
98
|
+
if (!view || !fleetState) return;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
if (!initializedViews.has(tab)) {
|
|
102
|
+
view.init();
|
|
103
|
+
initializedViews.add(tab);
|
|
104
|
+
}
|
|
105
|
+
view.render(fleetState);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.error(`[Error Boundary] Tab "${tab}" render failed:`, err);
|
|
108
|
+
showTabError(tab, err);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function showTabError(tab: TabId, err: unknown): void {
|
|
113
|
+
const panel = document.getElementById("panel-" + tab);
|
|
114
|
+
if (!panel) return;
|
|
115
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
116
|
+
const existing = panel.querySelector(".tab-error-boundary");
|
|
117
|
+
if (existing) return; // don't stack errors
|
|
118
|
+
const div = document.createElement("div");
|
|
119
|
+
div.className = "tab-error-boundary";
|
|
120
|
+
div.innerHTML =
|
|
121
|
+
`<div class="error-boundary-content">` +
|
|
122
|
+
`<span class="error-boundary-icon">\u26A0</span>` +
|
|
123
|
+
`<div><strong>This tab encountered an error</strong>` +
|
|
124
|
+
`<pre class="error-boundary-msg">${msg.replace(/</g, "<")}</pre></div>` +
|
|
125
|
+
`<button class="btn-sm error-boundary-retry">Retry</button></div>`;
|
|
126
|
+
panel.prepend(div);
|
|
127
|
+
const retryBtn = div.querySelector(".error-boundary-retry");
|
|
128
|
+
if (retryBtn) {
|
|
129
|
+
retryBtn.addEventListener("click", () => {
|
|
130
|
+
div.remove();
|
|
131
|
+
initializedViews.delete(tab);
|
|
132
|
+
const v = views.get(tab);
|
|
133
|
+
if (v) {
|
|
134
|
+
try {
|
|
135
|
+
v.init();
|
|
136
|
+
initializedViews.add(tab);
|
|
137
|
+
const state = store.get("fleetState");
|
|
138
|
+
if (state) v.render(state);
|
|
139
|
+
} catch (retryErr) {
|
|
140
|
+
console.error(
|
|
141
|
+
`[Error Boundary] Retry failed for "${tab}":`,
|
|
142
|
+
retryErr,
|
|
143
|
+
);
|
|
144
|
+
showTabError(tab, retryErr);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function setupRouter(): void {
|
|
152
|
+
// Tab button click handlers
|
|
153
|
+
const btns = document.querySelectorAll(".tab-btn");
|
|
154
|
+
btns.forEach((btn) => {
|
|
155
|
+
btn.addEventListener("click", () => {
|
|
156
|
+
const tab = btn.getAttribute("data-tab") as TabId;
|
|
157
|
+
if (tab) switchTab(tab);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Hash-based routing
|
|
162
|
+
const hash = location.hash.replace("#", "") as TabId;
|
|
163
|
+
if (VALID_TABS.includes(hash)) {
|
|
164
|
+
switchTab(hash);
|
|
165
|
+
} else {
|
|
166
|
+
// Default to overview
|
|
167
|
+
const activeTab = store.get("activeTab");
|
|
168
|
+
const view = views.get(activeTab);
|
|
169
|
+
if (view && !initializedViews.has(activeTab)) {
|
|
170
|
+
view.init();
|
|
171
|
+
initializedViews.add(activeTab);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
window.addEventListener("hashchange", () => {
|
|
176
|
+
const h = location.hash.replace("#", "") as TabId;
|
|
177
|
+
if (VALID_TABS.includes(h) && h !== store.get("activeTab")) {
|
|
178
|
+
switchTab(h);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Subscribe to fleet state changes to re-render active view
|
|
183
|
+
store.subscribe("fleetState", () => {
|
|
184
|
+
renderActiveView();
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function getRegisteredViews(): Map<TabId, View> {
|
|
189
|
+
return views;
|
|
190
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Server-Sent Events client for live log streaming
|
|
2
|
+
|
|
3
|
+
type SSECallback = (data: string) => void;
|
|
4
|
+
|
|
5
|
+
export class SSEClient {
|
|
6
|
+
private eventSource: EventSource | null = null;
|
|
7
|
+
private url: string;
|
|
8
|
+
private onMessage: SSECallback;
|
|
9
|
+
private onError?: () => void;
|
|
10
|
+
|
|
11
|
+
constructor(url: string, onMessage: SSECallback, onError?: () => void) {
|
|
12
|
+
this.url = url;
|
|
13
|
+
this.onMessage = onMessage;
|
|
14
|
+
this.onError = onError;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
connect(): void {
|
|
18
|
+
this.close();
|
|
19
|
+
this.eventSource = new EventSource(this.url);
|
|
20
|
+
this.eventSource.onmessage = (e) => {
|
|
21
|
+
this.onMessage(e.data);
|
|
22
|
+
};
|
|
23
|
+
this.eventSource.onerror = () => {
|
|
24
|
+
if (this.onError) this.onError();
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
close(): void {
|
|
29
|
+
if (this.eventSource) {
|
|
30
|
+
this.eventSource.close();
|
|
31
|
+
this.eventSource = null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
isConnected(): boolean {
|
|
36
|
+
return this.eventSource?.readyState === EventSource.OPEN;
|
|
37
|
+
}
|
|
38
|
+
}
|