shipwright-cli 2.2.2 → 2.3.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 (143) hide show
  1. package/README.md +12 -11
  2. package/dashboard/public/index.html +224 -8
  3. package/dashboard/public/styles.css +1078 -4
  4. package/dashboard/server.ts +1100 -15
  5. package/dashboard/src/canvas/interactions.ts +74 -0
  6. package/dashboard/src/canvas/layout.ts +85 -0
  7. package/dashboard/src/canvas/overlays.ts +117 -0
  8. package/dashboard/src/canvas/particles.ts +105 -0
  9. package/dashboard/src/canvas/renderer.ts +191 -0
  10. package/dashboard/src/components/charts/bar.ts +54 -0
  11. package/dashboard/src/components/charts/donut.ts +25 -0
  12. package/dashboard/src/components/charts/pipeline-rail.ts +105 -0
  13. package/dashboard/src/components/charts/sparkline.ts +82 -0
  14. package/dashboard/src/components/header.ts +616 -0
  15. package/dashboard/src/components/modal.ts +413 -0
  16. package/dashboard/src/components/terminal.ts +144 -0
  17. package/dashboard/src/core/api.ts +381 -0
  18. package/dashboard/src/core/helpers.ts +118 -0
  19. package/dashboard/src/core/router.ts +190 -0
  20. package/dashboard/src/core/sse.ts +38 -0
  21. package/dashboard/src/core/state.ts +150 -0
  22. package/dashboard/src/core/ws.ts +143 -0
  23. package/dashboard/src/design/icons.ts +131 -0
  24. package/dashboard/src/design/tokens.ts +160 -0
  25. package/dashboard/src/main.ts +68 -0
  26. package/dashboard/src/types/api.ts +337 -0
  27. package/dashboard/src/views/activity.ts +185 -0
  28. package/dashboard/src/views/agent-cockpit.ts +236 -0
  29. package/dashboard/src/views/agents.ts +72 -0
  30. package/dashboard/src/views/fleet-map.ts +299 -0
  31. package/dashboard/src/views/insights.ts +298 -0
  32. package/dashboard/src/views/machines.ts +162 -0
  33. package/dashboard/src/views/metrics.ts +420 -0
  34. package/dashboard/src/views/overview.ts +409 -0
  35. package/dashboard/src/views/pipeline-theater.ts +219 -0
  36. package/dashboard/src/views/pipelines.ts +595 -0
  37. package/dashboard/src/views/team.ts +362 -0
  38. package/dashboard/src/views/timeline.ts +389 -0
  39. package/dashboard/tsconfig.json +21 -0
  40. package/docs/AGI-WHATS-NEXT.md +15 -15
  41. package/package.json +8 -1
  42. package/scripts/lib/helpers.sh +30 -0
  43. package/scripts/lib/pipeline-quality-checks.sh +1 -1
  44. package/scripts/sw +86 -167
  45. package/scripts/sw-activity.sh +1 -1
  46. package/scripts/sw-adaptive.sh +1 -1
  47. package/scripts/sw-adversarial.sh +1 -1
  48. package/scripts/sw-architecture-enforcer.sh +1 -1
  49. package/scripts/sw-auth.sh +14 -6
  50. package/scripts/sw-autonomous.sh +1 -1
  51. package/scripts/sw-changelog.sh +2 -2
  52. package/scripts/sw-checkpoint.sh +1 -1
  53. package/scripts/sw-ci.sh +1 -1
  54. package/scripts/sw-cleanup.sh +1 -1
  55. package/scripts/sw-code-review.sh +1 -1
  56. package/scripts/sw-connect.sh +1 -1
  57. package/scripts/sw-context.sh +1 -1
  58. package/scripts/sw-cost.sh +1 -1
  59. package/scripts/sw-daemon.sh +2 -2
  60. package/scripts/sw-dashboard.sh +1 -1
  61. package/scripts/sw-db.sh +1 -1
  62. package/scripts/sw-decompose.sh +1 -1
  63. package/scripts/sw-deps.sh +1 -1
  64. package/scripts/sw-developer-simulation.sh +1 -1
  65. package/scripts/sw-discovery.sh +1 -1
  66. package/scripts/sw-doc-fleet.sh +1 -1
  67. package/scripts/sw-docs-agent.sh +1 -1
  68. package/scripts/sw-docs.sh +1 -1
  69. package/scripts/sw-doctor.sh +8 -1
  70. package/scripts/sw-dora.sh +1 -1
  71. package/scripts/sw-durable.sh +1 -1
  72. package/scripts/sw-e2e-orchestrator.sh +1 -1
  73. package/scripts/sw-eventbus.sh +1 -1
  74. package/scripts/sw-feedback.sh +1 -1
  75. package/scripts/sw-fix.sh +6 -5
  76. package/scripts/sw-fleet-discover.sh +1 -1
  77. package/scripts/sw-fleet-viz.sh +1 -1
  78. package/scripts/sw-fleet.sh +1 -1
  79. package/scripts/sw-github-app.sh +5 -2
  80. package/scripts/sw-github-checks.sh +1 -1
  81. package/scripts/sw-github-deploy.sh +1 -1
  82. package/scripts/sw-github-graphql.sh +1 -1
  83. package/scripts/sw-guild.sh +1 -1
  84. package/scripts/sw-heartbeat.sh +1 -1
  85. package/scripts/sw-hygiene.sh +1 -1
  86. package/scripts/sw-incident.sh +1 -1
  87. package/scripts/sw-init.sh +112 -9
  88. package/scripts/sw-instrument.sh +6 -1
  89. package/scripts/sw-intelligence.sh +5 -1
  90. package/scripts/sw-jira.sh +1 -1
  91. package/scripts/sw-launchd.sh +1 -1
  92. package/scripts/sw-linear.sh +20 -9
  93. package/scripts/sw-logs.sh +1 -1
  94. package/scripts/sw-loop.sh +2 -1
  95. package/scripts/sw-memory.sh +10 -1
  96. package/scripts/sw-mission-control.sh +1 -1
  97. package/scripts/sw-model-router.sh +4 -1
  98. package/scripts/sw-otel.sh +1 -1
  99. package/scripts/sw-oversight.sh +1 -1
  100. package/scripts/sw-pipeline-composer.sh +3 -1
  101. package/scripts/sw-pipeline-vitals.sh +4 -6
  102. package/scripts/sw-pipeline.sh +4 -1
  103. package/scripts/sw-pm.sh +5 -2
  104. package/scripts/sw-pr-lifecycle.sh +1 -1
  105. package/scripts/sw-predictive.sh +4 -1
  106. package/scripts/sw-prep.sh +3 -2
  107. package/scripts/sw-ps.sh +1 -1
  108. package/scripts/sw-public-dashboard.sh +10 -4
  109. package/scripts/sw-quality.sh +1 -1
  110. package/scripts/sw-reaper.sh +1 -1
  111. package/scripts/sw-recruit.sh +16 -0
  112. package/scripts/sw-regression.sh +2 -1
  113. package/scripts/sw-release-manager.sh +1 -1
  114. package/scripts/sw-release.sh +7 -5
  115. package/scripts/sw-remote.sh +1 -1
  116. package/scripts/sw-replay.sh +1 -1
  117. package/scripts/sw-retro.sh +1 -1
  118. package/scripts/sw-scale.sh +4 -1
  119. package/scripts/sw-security-audit.sh +1 -1
  120. package/scripts/sw-self-optimize.sh +15 -1
  121. package/scripts/sw-session.sh +1 -1
  122. package/scripts/sw-setup.sh +1 -1
  123. package/scripts/sw-standup.sh +2 -1
  124. package/scripts/sw-status.sh +1 -1
  125. package/scripts/sw-strategic.sh +2 -1
  126. package/scripts/sw-stream.sh +1 -1
  127. package/scripts/sw-swarm.sh +6 -1
  128. package/scripts/sw-team-stages.sh +1 -1
  129. package/scripts/sw-templates.sh +1 -1
  130. package/scripts/sw-testgen.sh +3 -2
  131. package/scripts/sw-tmux-pipeline.sh +2 -1
  132. package/scripts/sw-tmux.sh +1 -1
  133. package/scripts/sw-trace.sh +1 -1
  134. package/scripts/sw-tracker-jira.sh +1 -0
  135. package/scripts/sw-tracker-linear.sh +1 -0
  136. package/scripts/sw-tracker.sh +1 -1
  137. package/scripts/sw-triage.sh +1 -1
  138. package/scripts/sw-upgrade.sh +1 -1
  139. package/scripts/sw-ux.sh +1 -1
  140. package/scripts/sw-webhook.sh +1 -1
  141. package/scripts/sw-widgets.sh +2 -2
  142. package/scripts/sw-worktree.sh +1 -1
  143. package/dashboard/public/app.js +0 -4422
@@ -0,0 +1,381 @@
1
+ // Typed REST client for all dashboard API endpoints
2
+
3
+ import type {
4
+ PipelineDetail,
5
+ MetricsData,
6
+ TimelineEntry,
7
+ MachineInfo,
8
+ JoinToken,
9
+ CostBreakdown,
10
+ DaemonConfig,
11
+ AlertInfo,
12
+ InsightsData,
13
+ HeatmapData,
14
+ TeamData,
15
+ TeamActivityEvent,
16
+ StagePerformance,
17
+ UserInfo,
18
+ } from "../types/api";
19
+
20
+ async function request<T>(url: string, options?: RequestInit): Promise<T> {
21
+ const resp = await fetch(url, options);
22
+ if (!resp.ok) {
23
+ const body = await resp
24
+ .json()
25
+ .catch(() => ({ error: `HTTP ${resp.status}` }));
26
+ throw new Error(body.error || `HTTP ${resp.status}`);
27
+ }
28
+ return resp.json();
29
+ }
30
+
31
+ function post<T>(url: string, body?: unknown): Promise<T> {
32
+ const opts: RequestInit = {
33
+ method: "POST",
34
+ headers: { "Content-Type": "application/json" },
35
+ };
36
+ if (body !== undefined) opts.body = JSON.stringify(body);
37
+ return request<T>(url, opts);
38
+ }
39
+
40
+ function patch<T>(url: string, body: unknown): Promise<T> {
41
+ return request<T>(url, {
42
+ method: "PATCH",
43
+ headers: { "Content-Type": "application/json" },
44
+ body: JSON.stringify(body),
45
+ });
46
+ }
47
+
48
+ function del<T>(url: string): Promise<T> {
49
+ return request<T>(url, {
50
+ method: "DELETE",
51
+ headers: { "Content-Type": "application/json" },
52
+ });
53
+ }
54
+
55
+ // User
56
+ export const fetchMe = () => request<UserInfo>("/api/me");
57
+
58
+ // Pipeline detail
59
+ export const fetchPipelineDetail = (issue: number | string) =>
60
+ request<PipelineDetail>(`/api/pipeline/${encodeURIComponent(issue)}`);
61
+
62
+ // Metrics
63
+ export const fetchMetricsHistory = (period = 30) =>
64
+ request<MetricsData>(`/api/metrics/history?period=${period}`);
65
+
66
+ // Timeline — server returns bare array
67
+ export const fetchTimeline = (range = "24h") =>
68
+ request<TimelineEntry[]>(`/api/timeline?range=${range}`);
69
+
70
+ // Activity
71
+ export const fetchActivity = (params: {
72
+ limit?: number;
73
+ offset?: number;
74
+ type?: string;
75
+ issue?: string;
76
+ }) => {
77
+ const qs = new URLSearchParams();
78
+ if (params.limit) qs.set("limit", String(params.limit));
79
+ if (params.offset) qs.set("offset", String(params.offset));
80
+ if (params.type && params.type !== "all") qs.set("type", params.type);
81
+ if (params.issue) qs.set("issue", params.issue);
82
+ return request<{ events: Array<Record<string, unknown>>; hasMore: boolean }>(
83
+ `/api/activity?${qs}`,
84
+ );
85
+ };
86
+
87
+ // Machines — server returns bare array
88
+ export const fetchMachines = () => request<MachineInfo[]>("/api/machines");
89
+ export const addMachine = (body: Record<string, unknown>) =>
90
+ post<MachineInfo>("/api/machines", body);
91
+ export const updateMachine = (name: string, body: Record<string, unknown>) =>
92
+ patch<MachineInfo>(`/api/machines/${encodeURIComponent(name)}`, body);
93
+ export const removeMachine = (name: string) =>
94
+ del<{ ok: boolean }>(`/api/machines/${encodeURIComponent(name)}`);
95
+ export const machineHealthCheck = (name: string) =>
96
+ post<{ machine: MachineInfo }>(
97
+ `/api/machines/${encodeURIComponent(name)}/health-check`,
98
+ );
99
+
100
+ // Join tokens
101
+ export const fetchJoinTokens = () =>
102
+ request<{ tokens: JoinToken[] }>("/api/join-token");
103
+ export const generateJoinToken = (body: {
104
+ label: string;
105
+ max_workers: number;
106
+ }) => post<{ join_cmd: string }>("/api/join-token", body);
107
+
108
+ // Costs
109
+ export const fetchCostBreakdown = (period = 7) =>
110
+ request<CostBreakdown>(`/api/costs/breakdown?period=${period}`);
111
+ export const fetchCostTrend = (period = 30) =>
112
+ request<{ points: Array<Record<string, number>> }>(
113
+ `/api/costs/trend?period=${period}`,
114
+ );
115
+
116
+ // Daemon
117
+ export const fetchDaemonConfig = () =>
118
+ request<DaemonConfig>("/api/daemon/config");
119
+ export const daemonControl = (action: string) =>
120
+ post<{ ok: boolean }>(`/api/daemon/${action}`);
121
+
122
+ // Alerts
123
+ export const fetchAlerts = () =>
124
+ request<{ alerts: AlertInfo[] }>("/api/alerts");
125
+
126
+ // Emergency brake
127
+ export const emergencyBrake = () =>
128
+ post<{ ok: boolean }>("/api/emergency-brake");
129
+
130
+ // Intervention
131
+ export const sendIntervention = (
132
+ issue: number | string,
133
+ action: string,
134
+ body?: unknown,
135
+ ) => post<{ ok: boolean }>(`/api/intervention/${issue}/${action}`, body);
136
+
137
+ // Insights
138
+ export const fetchPatterns = () =>
139
+ request<{ patterns: InsightsData["patterns"] }>("/api/memory/patterns").catch(
140
+ () => ({ patterns: [] }),
141
+ );
142
+ export const fetchDecisions = () =>
143
+ request<{ decisions: InsightsData["decisions"] }>(
144
+ "/api/memory/decisions",
145
+ ).catch(() => ({ decisions: [] }));
146
+ export const fetchPatrol = () =>
147
+ request<{ findings: InsightsData["patrol"] }>("/api/patrol/recent").catch(
148
+ () => ({ findings: [] }),
149
+ );
150
+ export const fetchHeatmap = () =>
151
+ request<HeatmapData>("/api/metrics/failure-heatmap").catch(() => null);
152
+
153
+ // Artifacts
154
+ export const fetchArtifact = (issue: number | string, type: string) =>
155
+ request<{ content: string }>(
156
+ `/api/artifacts/${encodeURIComponent(issue)}/${encodeURIComponent(type)}`,
157
+ );
158
+
159
+ // GitHub
160
+ export const fetchGitHubStatus = (issue: number | string) =>
161
+ request<Record<string, unknown>>(`/api/github/${encodeURIComponent(issue)}`);
162
+
163
+ // Logs
164
+ export const fetchLogs = (issue: number | string) =>
165
+ request<{ content: string }>(`/api/logs/${encodeURIComponent(issue)}`);
166
+
167
+ // Metrics detail
168
+ export const fetchStagePerformance = (period = 7) =>
169
+ request<{ stages: StagePerformance[] }>(
170
+ `/api/metrics/stage-performance?period=${period}`,
171
+ );
172
+ export const fetchBottlenecks = () =>
173
+ request<{
174
+ bottlenecks: Array<{
175
+ stage: string;
176
+ avgDuration: number;
177
+ impact: string;
178
+ suggestion: string;
179
+ }>;
180
+ }>("/api/metrics/bottlenecks");
181
+ export const fetchThroughputTrend = (period = 30) =>
182
+ request<{ points: Array<Record<string, number>> }>(
183
+ `/api/metrics/throughput-trend?period=${period}`,
184
+ );
185
+ export const fetchCapacity = () =>
186
+ request<{ rate: number; queue_clear_hours: number }>("/api/metrics/capacity");
187
+ export const fetchDoraTrend = (period = 30) =>
188
+ request<Record<string, Array<Record<string, number>>>>(
189
+ `/api/metrics/dora-trend?period=${period}`,
190
+ );
191
+
192
+ // Queue detailed
193
+ export const fetchQueueDetailed = () =>
194
+ request<{ queue: Array<Record<string, unknown>> }>(
195
+ "/api/queue/detailed",
196
+ ).then((d) => ({ items: d.queue || [] }));
197
+
198
+ // Team
199
+ export const fetchTeam = () => request<TeamData>("/api/team");
200
+ export const fetchTeamActivity = () =>
201
+ request<{ events: TeamActivityEvent[] }>("/api/team/activity")
202
+ .then((d) => d.events)
203
+ .catch(() => [] as TeamActivityEvent[]);
204
+
205
+ // Pipeline live changes
206
+ export const fetchPipelineDiff = (issue: number | string) =>
207
+ request<{
208
+ diff: string;
209
+ stats: { files_changed: number; insertions: number; deletions: number };
210
+ worktree: string;
211
+ }>(`/api/pipeline/${encodeURIComponent(issue)}/diff`);
212
+
213
+ export const fetchPipelineFiles = (issue: number | string) =>
214
+ request<{ files: Array<{ path: string; status: string }> }>(
215
+ `/api/pipeline/${encodeURIComponent(issue)}/files`,
216
+ );
217
+
218
+ export const fetchPipelineTestResults = (issue: number | string) =>
219
+ request<Record<string, unknown>>(
220
+ `/api/pipeline/${encodeURIComponent(issue)}/test-results`,
221
+ );
222
+
223
+ // Pipeline reasoning and failures
224
+ export const fetchPipelineReasoning = (issue: number | string) =>
225
+ request<{ reasoning: Array<Record<string, unknown>> }>(
226
+ `/api/pipeline/${encodeURIComponent(issue)}/reasoning`,
227
+ );
228
+
229
+ export const fetchPipelineFailures = (issue: number | string) =>
230
+ request<{ failures: Array<Record<string, unknown>> }>(
231
+ `/api/pipeline/${encodeURIComponent(issue)}/failures`,
232
+ );
233
+
234
+ // Global learnings
235
+ export const fetchGlobalLearnings = () =>
236
+ request<{ learnings: Array<Record<string, unknown>> }>("/api/memory/global");
237
+
238
+ // Team invites
239
+ export const createTeamInvite = (options?: {
240
+ expires_hours?: number;
241
+ max_uses?: number;
242
+ }) =>
243
+ request<{ token: string; url: string; expires_at: string }>(
244
+ "/api/team/invite",
245
+ { method: "POST", body: JSON.stringify(options || {}) },
246
+ );
247
+
248
+ // Linear integration status
249
+ export const fetchLinearStatus = () =>
250
+ request<Record<string, unknown>>("/api/linear/status");
251
+
252
+ // DB debug endpoints
253
+ export const fetchDbEvents = (since = 0, limit = 200) =>
254
+ request<{ events: Array<Record<string, unknown>>; source: string }>(
255
+ `/api/db/events?since=${since}&limit=${limit}`,
256
+ );
257
+
258
+ export const fetchDbJobs = (status?: string) =>
259
+ request<{ jobs: Array<Record<string, unknown>>; source: string }>(
260
+ `/api/db/jobs${status ? `?status=${status}` : ""}`,
261
+ );
262
+
263
+ export const fetchDbCostsToday = () =>
264
+ request<Record<string, unknown>>("/api/db/costs/today");
265
+
266
+ export const fetchDbHeartbeats = () =>
267
+ request<{ heartbeats: Array<Record<string, unknown>>; source: string }>(
268
+ "/api/db/heartbeats",
269
+ );
270
+
271
+ export const fetchDbHealth = () =>
272
+ request<Record<string, unknown>>("/api/db/health");
273
+
274
+ // Machine claim/release
275
+ export const claimIssue = (issue: number, machine: string, repo?: string) =>
276
+ request<{ approved: boolean; claimed_by?: string; error?: string }>(
277
+ "/api/claim",
278
+ {
279
+ method: "POST",
280
+ body: JSON.stringify({ issue, machine, repo }),
281
+ },
282
+ );
283
+
284
+ export const releaseIssue = (issue: number, machine?: string, repo?: string) =>
285
+ request<{ ok: boolean }>("/api/claim/release", {
286
+ method: "POST",
287
+ body: JSON.stringify({ issue, machine, repo }),
288
+ });
289
+
290
+ // Audit log
291
+ export const fetchAuditLog = () =>
292
+ request<{ entries: Array<Record<string, unknown>> }>("/api/audit-log");
293
+
294
+ // Quality gates
295
+ export const fetchQualityGates = () =>
296
+ request<{
297
+ enabled: boolean;
298
+ rules: Array<{
299
+ name: string;
300
+ operator: string;
301
+ threshold: number;
302
+ unit: string;
303
+ }>;
304
+ }>("/api/quality-gates");
305
+
306
+ export const fetchPipelineQuality = (issue: number | string) =>
307
+ request<{
308
+ quality: Record<string, unknown>;
309
+ gates: Record<string, unknown>;
310
+ results: Array<{
311
+ name: string;
312
+ operator: string;
313
+ threshold: number;
314
+ value: unknown;
315
+ passed: boolean;
316
+ }>;
317
+ }>(`/api/pipeline/${encodeURIComponent(issue)}/quality`);
318
+
319
+ // Approval gates
320
+ export const fetchApprovalGates = () =>
321
+ request<{
322
+ enabled: boolean;
323
+ stages: string[];
324
+ pending: Array<{ issue: number; stage: string; requested_at: string }>;
325
+ }>("/api/approval-gates");
326
+
327
+ export const updateApprovalGates = (config: {
328
+ enabled?: boolean;
329
+ stages?: string[];
330
+ }) =>
331
+ request<{ ok: boolean }>("/api/approval-gates", {
332
+ method: "POST",
333
+ body: JSON.stringify(config),
334
+ });
335
+
336
+ export const approveGate = (issue: number, stage?: string) =>
337
+ request<{ ok: boolean }>(`/api/approval-gates/${issue}/approve`, {
338
+ method: "POST",
339
+ body: JSON.stringify({ stage }),
340
+ });
341
+
342
+ export const rejectGate = (issue: number, stage?: string, reason?: string) =>
343
+ request<{ ok: boolean }>(`/api/approval-gates/${issue}/reject`, {
344
+ method: "POST",
345
+ body: JSON.stringify({ stage, reason }),
346
+ });
347
+
348
+ // Notifications
349
+ export const fetchNotificationConfig = () =>
350
+ request<{
351
+ enabled: boolean;
352
+ webhooks: Array<{
353
+ url: string;
354
+ label: string;
355
+ events: string[];
356
+ created_at: string;
357
+ }>;
358
+ }>("/api/notifications/config");
359
+
360
+ export const addWebhook = (url: string, label?: string, events?: string[]) =>
361
+ request<{ ok: boolean }>("/api/notifications/webhook", {
362
+ method: "POST",
363
+ body: JSON.stringify({ url, label, events }),
364
+ });
365
+
366
+ export const removeWebhook = (url: string) =>
367
+ request<{ ok: boolean }>("/api/notifications/webhook", {
368
+ method: "DELETE",
369
+ body: JSON.stringify({ url }),
370
+ });
371
+
372
+ export const testNotification = () =>
373
+ request<{ ok: boolean }>("/api/notifications/test", { method: "POST" });
374
+
375
+ // Predictions (new endpoint, returns graceful defaults if not yet implemented)
376
+ export const fetchPredictions = (issue: number | string) =>
377
+ request<{
378
+ eta_s?: number;
379
+ success_probability?: number;
380
+ estimated_cost?: number;
381
+ }>(`/api/predictions/${encodeURIComponent(issue)}`).catch(() => ({}));
@@ -0,0 +1,118 @@
1
+ // Shared utility helpers
2
+
3
+ export function formatDuration(s: number | null | undefined): string {
4
+ if (s == null) return "\u2014";
5
+ s = Math.floor(s);
6
+ if (s < 60) return s + "s";
7
+ if (s < 3600) return Math.floor(s / 60) + "m " + (s % 60) + "s";
8
+ return Math.floor(s / 3600) + "h " + Math.floor((s % 3600) / 60) + "m";
9
+ }
10
+
11
+ export function formatTime(iso: string | null | undefined): string {
12
+ if (!iso) return "\u2014";
13
+ const d = new Date(iso);
14
+ const h = String(d.getHours()).padStart(2, "0");
15
+ const m = String(d.getMinutes()).padStart(2, "0");
16
+ const s = String(d.getSeconds()).padStart(2, "0");
17
+ return `${h}:${m}:${s}`;
18
+ }
19
+
20
+ export function escapeHtml(str: string | null | undefined): string {
21
+ if (!str) return "";
22
+ return str
23
+ .replace(/&/g, "&amp;")
24
+ .replace(/</g, "&lt;")
25
+ .replace(/>/g, "&gt;")
26
+ .replace(/"/g, "&quot;");
27
+ }
28
+
29
+ export function fmtNum(n: number | null | undefined): string {
30
+ if (n == null) return "0";
31
+ return Number(n).toLocaleString();
32
+ }
33
+
34
+ export function truncate(
35
+ str: string | null | undefined,
36
+ maxLen: number,
37
+ ): string {
38
+ if (!str) return "";
39
+ return str.length > maxLen ? str.substring(0, maxLen) + "\u2026" : str;
40
+ }
41
+
42
+ export function padZero(n: number): string {
43
+ return n < 10 ? "0" + n : "" + n;
44
+ }
45
+
46
+ export function getBadgeClass(typeRaw: string): string {
47
+ if (typeRaw.includes("intervention")) return "intervention";
48
+ if (typeRaw.includes("heartbeat")) return "heartbeat";
49
+ if (typeRaw.includes("recovery") || typeRaw.includes("checkpoint"))
50
+ return "recovery";
51
+ if (typeRaw.includes("remote") || typeRaw.includes("distributed"))
52
+ return "remote";
53
+ if (typeRaw.includes("poll")) return "poll";
54
+ if (typeRaw.includes("spawn")) return "spawn";
55
+ if (typeRaw.includes("started")) return "started";
56
+ if (typeRaw.includes("completed") || typeRaw.includes("reap"))
57
+ return "completed";
58
+ if (typeRaw.includes("failed")) return "failed";
59
+ if (typeRaw.includes("stage")) return "stage";
60
+ if (typeRaw.includes("scale")) return "scale";
61
+ return "default";
62
+ }
63
+
64
+ export function getTypeShort(typeRaw: string): string {
65
+ const parts = String(typeRaw || "unknown").split(".");
66
+ return parts[parts.length - 1];
67
+ }
68
+
69
+ export function animateValue(
70
+ el: HTMLElement | null,
71
+ start: number,
72
+ end: number,
73
+ duration: number,
74
+ suffix = "",
75
+ ): void {
76
+ if (!el) return;
77
+ const diff = end - start;
78
+ if (diff === 0) {
79
+ el.textContent = fmtNum(end) + suffix;
80
+ return;
81
+ }
82
+ let startTime: number | null = null;
83
+ function step(timestamp: number) {
84
+ if (!startTime) startTime = timestamp;
85
+ const progress = Math.min((timestamp - startTime) / duration, 1);
86
+ const current = Math.floor(start + diff * progress);
87
+ el!.textContent = fmtNum(current) + suffix;
88
+ if (progress < 1) requestAnimationFrame(step);
89
+ }
90
+ requestAnimationFrame(step);
91
+ }
92
+
93
+ export function timeAgo(date: Date): string {
94
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
95
+ if (seconds < 60) return seconds + "s ago";
96
+ const minutes = Math.floor(seconds / 60);
97
+ if (minutes < 60) return minutes + "m ago";
98
+ const hours = Math.floor(minutes / 60);
99
+ if (hours < 24) return hours + "h ago";
100
+ return Math.floor(hours / 24) + "d ago";
101
+ }
102
+
103
+ export function formatMarkdown(text: string | null | undefined): string {
104
+ if (!text) return "";
105
+ let escaped = escapeHtml(text);
106
+ escaped = escaped.replace(
107
+ /^#{1,3}\s+(.+)$/gm,
108
+ (_m, content) => "<strong>" + content + "</strong>",
109
+ );
110
+ escaped = escaped.replace(/```[\s\S]*?```/g, (block) => {
111
+ const inner = block.replace(/^```\w*\n?/, "").replace(/\n?```$/, "");
112
+ return '<pre class="artifact-code">' + inner + "</pre>";
113
+ });
114
+ escaped = escaped.replace(/`([^`]+)`/g, "<code>$1</code>");
115
+ escaped = escaped.replace(/^[-*]\s+(.+)$/gm, "<li>$1</li>");
116
+ escaped = escaped.replace(/\n/g, "<br>");
117
+ return escaped;
118
+ }
@@ -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, "&lt;")}</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
+ }