shipwright-cli 2.2.1 → 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 (156) hide show
  1. package/README.md +19 -19
  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-PLATFORM-PLAN.md +5 -5
  41. package/docs/AGI-WHATS-NEXT.md +19 -16
  42. package/docs/README.md +2 -0
  43. package/package.json +8 -1
  44. package/scripts/check-version-consistency.sh +72 -0
  45. package/scripts/lib/daemon-adaptive.sh +610 -0
  46. package/scripts/lib/daemon-dispatch.sh +489 -0
  47. package/scripts/lib/daemon-failure.sh +387 -0
  48. package/scripts/lib/daemon-patrol.sh +1113 -0
  49. package/scripts/lib/daemon-poll.sh +1202 -0
  50. package/scripts/lib/daemon-state.sh +550 -0
  51. package/scripts/lib/daemon-triage.sh +490 -0
  52. package/scripts/lib/helpers.sh +81 -0
  53. package/scripts/lib/pipeline-intelligence.sh +0 -6
  54. package/scripts/lib/pipeline-quality-checks.sh +3 -1
  55. package/scripts/lib/pipeline-stages.sh +20 -0
  56. package/scripts/sw +109 -168
  57. package/scripts/sw-activity.sh +1 -1
  58. package/scripts/sw-adaptive.sh +2 -2
  59. package/scripts/sw-adversarial.sh +1 -1
  60. package/scripts/sw-architecture-enforcer.sh +1 -1
  61. package/scripts/sw-auth.sh +14 -6
  62. package/scripts/sw-autonomous.sh +1 -1
  63. package/scripts/sw-changelog.sh +2 -2
  64. package/scripts/sw-checkpoint.sh +1 -1
  65. package/scripts/sw-ci.sh +1 -1
  66. package/scripts/sw-cleanup.sh +1 -1
  67. package/scripts/sw-code-review.sh +1 -1
  68. package/scripts/sw-connect.sh +1 -1
  69. package/scripts/sw-context.sh +1 -1
  70. package/scripts/sw-cost.sh +1 -1
  71. package/scripts/sw-daemon.sh +53 -4817
  72. package/scripts/sw-dashboard.sh +1 -1
  73. package/scripts/sw-db.sh +1 -1
  74. package/scripts/sw-decompose.sh +1 -1
  75. package/scripts/sw-deps.sh +1 -1
  76. package/scripts/sw-developer-simulation.sh +1 -1
  77. package/scripts/sw-discovery.sh +1 -1
  78. package/scripts/sw-doc-fleet.sh +1 -1
  79. package/scripts/sw-docs-agent.sh +1 -1
  80. package/scripts/sw-docs.sh +1 -1
  81. package/scripts/sw-doctor.sh +49 -1
  82. package/scripts/sw-dora.sh +1 -1
  83. package/scripts/sw-durable.sh +1 -1
  84. package/scripts/sw-e2e-orchestrator.sh +1 -1
  85. package/scripts/sw-eventbus.sh +1 -1
  86. package/scripts/sw-feedback.sh +1 -1
  87. package/scripts/sw-fix.sh +6 -5
  88. package/scripts/sw-fleet-discover.sh +1 -1
  89. package/scripts/sw-fleet-viz.sh +3 -3
  90. package/scripts/sw-fleet.sh +1 -1
  91. package/scripts/sw-github-app.sh +5 -2
  92. package/scripts/sw-github-checks.sh +1 -1
  93. package/scripts/sw-github-deploy.sh +1 -1
  94. package/scripts/sw-github-graphql.sh +1 -1
  95. package/scripts/sw-guild.sh +1 -1
  96. package/scripts/sw-heartbeat.sh +1 -1
  97. package/scripts/sw-hygiene.sh +1 -1
  98. package/scripts/sw-incident.sh +1 -1
  99. package/scripts/sw-init.sh +112 -9
  100. package/scripts/sw-instrument.sh +6 -1
  101. package/scripts/sw-intelligence.sh +5 -1
  102. package/scripts/sw-jira.sh +1 -1
  103. package/scripts/sw-launchd.sh +1 -1
  104. package/scripts/sw-linear.sh +20 -9
  105. package/scripts/sw-logs.sh +1 -1
  106. package/scripts/sw-loop.sh +2 -1
  107. package/scripts/sw-memory.sh +10 -1
  108. package/scripts/sw-mission-control.sh +1 -1
  109. package/scripts/sw-model-router.sh +4 -1
  110. package/scripts/sw-otel.sh +4 -4
  111. package/scripts/sw-oversight.sh +1 -1
  112. package/scripts/sw-pipeline-composer.sh +3 -1
  113. package/scripts/sw-pipeline-vitals.sh +4 -6
  114. package/scripts/sw-pipeline.sh +19 -56
  115. package/scripts/sw-pipeline.sh.mock +7 -0
  116. package/scripts/sw-pm.sh +5 -2
  117. package/scripts/sw-pr-lifecycle.sh +1 -1
  118. package/scripts/sw-predictive.sh +4 -1
  119. package/scripts/sw-prep.sh +3 -2
  120. package/scripts/sw-ps.sh +1 -1
  121. package/scripts/sw-public-dashboard.sh +10 -4
  122. package/scripts/sw-quality.sh +1 -1
  123. package/scripts/sw-reaper.sh +1 -1
  124. package/scripts/sw-recruit.sh +25 -1
  125. package/scripts/sw-regression.sh +2 -1
  126. package/scripts/sw-release-manager.sh +1 -1
  127. package/scripts/sw-release.sh +7 -5
  128. package/scripts/sw-remote.sh +1 -1
  129. package/scripts/sw-replay.sh +1 -1
  130. package/scripts/sw-retro.sh +1 -1
  131. package/scripts/sw-scale.sh +11 -5
  132. package/scripts/sw-security-audit.sh +1 -1
  133. package/scripts/sw-self-optimize.sh +172 -7
  134. package/scripts/sw-session.sh +1 -1
  135. package/scripts/sw-setup.sh +1 -1
  136. package/scripts/sw-standup.sh +4 -3
  137. package/scripts/sw-status.sh +1 -1
  138. package/scripts/sw-strategic.sh +2 -1
  139. package/scripts/sw-stream.sh +8 -2
  140. package/scripts/sw-swarm.sh +12 -10
  141. package/scripts/sw-team-stages.sh +1 -1
  142. package/scripts/sw-templates.sh +1 -1
  143. package/scripts/sw-testgen.sh +3 -2
  144. package/scripts/sw-tmux-pipeline.sh +2 -1
  145. package/scripts/sw-tmux.sh +1 -1
  146. package/scripts/sw-trace.sh +1 -1
  147. package/scripts/sw-tracker-jira.sh +1 -0
  148. package/scripts/sw-tracker-linear.sh +1 -0
  149. package/scripts/sw-tracker.sh +24 -6
  150. package/scripts/sw-triage.sh +1 -1
  151. package/scripts/sw-upgrade.sh +1 -1
  152. package/scripts/sw-ux.sh +1 -1
  153. package/scripts/sw-webhook.sh +1 -1
  154. package/scripts/sw-widgets.sh +2 -2
  155. package/scripts/sw-worktree.sh +1 -1
  156. package/dashboard/public/app.js +0 -4422
@@ -0,0 +1,236 @@
1
+ // Agent Cockpit - full-screen agent view with live terminal, CPU/memory sparklines, self-healing ring
2
+
3
+ import { store } from "../core/state";
4
+ import { escapeHtml, formatDuration, fmtNum } from "../core/helpers";
5
+ import { icon } from "../design/icons";
6
+ import { colors } from "../design/tokens";
7
+ import { renderSparkline } from "../components/charts/sparkline";
8
+ import { renderSVGDonut } from "../components/charts/donut";
9
+ import { LiveTerminal } from "../components/terminal";
10
+ import { SSEClient } from "../core/sse";
11
+ import * as api from "../core/api";
12
+ import type { FleetState, View, PipelineInfo } from "../types/api";
13
+
14
+ let terminal: LiveTerminal | null = null;
15
+ let sseClient: SSEClient | null = null;
16
+ let selectedAgent: number | null = null;
17
+ let cpuHistory: number[] = [];
18
+ let memHistory: number[] = [];
19
+
20
+ function renderCockpit(data: FleetState): void {
21
+ const container = document.getElementById("panel-agent-cockpit");
22
+ if (!container) return;
23
+
24
+ const pipelines = data.pipelines || [];
25
+ if (pipelines.length === 0 && !selectedAgent) {
26
+ container.innerHTML = `<div class="empty-state">${icon("cpu", 48)}<p>No active agents</p></div>`;
27
+ return;
28
+ }
29
+
30
+ // Agent selector bar
31
+ let html = '<div class="cockpit-layout">';
32
+ html += '<div class="cockpit-agent-bar">';
33
+ for (const p of pipelines) {
34
+ const isSelected = selectedAgent === p.issue;
35
+ const statusDot = p.status === "failed" ? "offline" : "online";
36
+ html +=
37
+ `<button class="cockpit-agent-btn${isSelected ? " selected" : ""}" data-issue="${p.issue}">` +
38
+ `<span class="presence-dot ${statusDot}"></span>#${p.issue}</button>`;
39
+ }
40
+ html += "</div>";
41
+
42
+ if (selectedAgent) {
43
+ const pipeline = pipelines.find((p) => p.issue === selectedAgent);
44
+ if (pipeline) {
45
+ html += '<div class="cockpit-main">';
46
+
47
+ // Top metrics row
48
+ html += '<div class="cockpit-metrics">';
49
+
50
+ // CPU sparkline
51
+ html += '<div class="cockpit-metric-card">';
52
+ html += `<div class="cockpit-metric-header">${icon("cpu", 16)} CPU</div>`;
53
+ html += `<div class="cockpit-metric-chart">${cpuHistory.length > 1 ? renderSparkline(cpuHistory, colors.accent.cyan, 160, 40) : '<span class="text-muted">\u2014</span>'}</div>`;
54
+ html += "</div>";
55
+
56
+ // Memory sparkline
57
+ html += '<div class="cockpit-metric-card">';
58
+ html += `<div class="cockpit-metric-header">${icon("memory-stick", 16)} Memory</div>`;
59
+ html += `<div class="cockpit-metric-chart">${memHistory.length > 1 ? renderSparkline(memHistory, colors.accent.purple, 160, 40) : '<span class="text-muted">\u2014</span>'}</div>`;
60
+ html += "</div>";
61
+
62
+ // Self-healing ring
63
+ const healthPct =
64
+ pipeline.status === "failed"
65
+ ? 0
66
+ : Math.min(
67
+ 100,
68
+ ((pipeline.iteration || 0) / (pipeline.maxIterations || 20)) *
69
+ 100,
70
+ );
71
+ html += '<div class="cockpit-metric-card">';
72
+ html += `<div class="cockpit-metric-header">${icon("shield-alert", 16)} Health</div>`;
73
+ html += `<div class="cockpit-metric-chart">${renderSVGDonut(100 - healthPct)}</div>`;
74
+ html += "</div>";
75
+
76
+ // Stage + status
77
+ html += '<div class="cockpit-metric-card">';
78
+ html += `<div class="cockpit-metric-header">${icon("activity", 16)} Status</div>`;
79
+ html += '<div class="cockpit-status-info">';
80
+ html += `<div>Stage: <strong>${escapeHtml(pipeline.stage)}</strong></div>`;
81
+ html += `<div>Iteration: ${pipeline.iteration || 0}/${pipeline.maxIterations || 20}</div>`;
82
+ html += `<div>Elapsed: ${formatDuration(pipeline.elapsed_s)}</div>`;
83
+ if (pipeline.linesWritten != null)
84
+ html += `<div>Lines: ${fmtNum(pipeline.linesWritten)}</div>`;
85
+ html += "</div></div>";
86
+
87
+ html += "</div>"; // cockpit-metrics
88
+
89
+ // Live Changes panel
90
+ html += '<div class="cockpit-changes" id="cockpit-changes">';
91
+ html += `<div class="cockpit-changes-header">${icon("file-diff", 16)} Files Changed <button class="btn-sm" id="cockpit-refresh-diff">Refresh</button></div>`;
92
+ html +=
93
+ '<div class="cockpit-changes-body" id="cockpit-changes-body"></div>';
94
+ html += "</div>";
95
+
96
+ // Live terminal
97
+ html +=
98
+ '<div class="cockpit-terminal" id="cockpit-terminal-container"></div>';
99
+
100
+ html += "</div>"; // cockpit-main
101
+ } else {
102
+ html += `<div class="empty-state"><p>Agent #${selectedAgent} no longer active</p></div>`;
103
+ }
104
+ } else {
105
+ html += `<div class="empty-state">${icon("terminal", 32)}<p>Select an agent to monitor</p></div>`;
106
+ }
107
+
108
+ html += "</div>";
109
+ container.innerHTML = html;
110
+
111
+ // Wire up agent selector
112
+ container.querySelectorAll(".cockpit-agent-btn").forEach((btn) => {
113
+ btn.addEventListener("click", () => {
114
+ const issue = parseInt(btn.getAttribute("data-issue") || "0", 10);
115
+ if (issue) selectAgent(issue, data);
116
+ });
117
+ });
118
+
119
+ // Initialize terminal
120
+ if (selectedAgent) {
121
+ const termContainer = document.getElementById("cockpit-terminal-container");
122
+ if (termContainer) {
123
+ terminal = new LiveTerminal(termContainer);
124
+ connectAgentStream(selectedAgent);
125
+ }
126
+
127
+ // Load live changes
128
+ loadCockpitChanges(selectedAgent);
129
+ const refreshBtn = document.getElementById("cockpit-refresh-diff");
130
+ if (refreshBtn) {
131
+ refreshBtn.addEventListener("click", () => {
132
+ if (selectedAgent) loadCockpitChanges(selectedAgent);
133
+ });
134
+ }
135
+
136
+ // Update resource histories from agent heartbeat data
137
+ const agents = data.agents || [];
138
+ const agentInfo = agents.find((a) => a.issue === selectedAgent);
139
+ if (agentInfo) {
140
+ if (agentInfo.cpu_pct != null) {
141
+ cpuHistory.push(agentInfo.cpu_pct);
142
+ if (cpuHistory.length > 60) cpuHistory.shift();
143
+ }
144
+ if (agentInfo.memory_mb != null) {
145
+ memHistory.push(agentInfo.memory_mb);
146
+ if (memHistory.length > 60) memHistory.shift();
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ function selectAgent(issue: number, data: FleetState): void {
153
+ if (sseClient) sseClient.close();
154
+ if (terminal) terminal.destroy();
155
+ selectedAgent = issue;
156
+ cpuHistory = [];
157
+ memHistory = [];
158
+ renderCockpit(data);
159
+ }
160
+
161
+ function loadCockpitChanges(issue: number): void {
162
+ const body = document.getElementById("cockpit-changes-body");
163
+ if (!body) return;
164
+ body.innerHTML = '<div class="empty-state"><p>Loading...</p></div>';
165
+
166
+ api
167
+ .fetchPipelineFiles(issue)
168
+ .then((data) => {
169
+ const files = data.files || [];
170
+ if (files.length === 0) {
171
+ body.innerHTML = '<div class="empty-state"><p>No changes yet</p></div>';
172
+ return;
173
+ }
174
+ let html = "";
175
+ for (const f of files) {
176
+ const statusCls =
177
+ f.status === "added"
178
+ ? "file-added"
179
+ : f.status === "deleted"
180
+ ? "file-deleted"
181
+ : "file-modified";
182
+ const statusChar =
183
+ f.status === "added" ? "A" : f.status === "deleted" ? "D" : "M";
184
+ html += `<div class="changes-file-item ${statusCls}"><span class="file-status">${statusChar}</span><span class="file-path">${escapeHtml(f.path)}</span></div>`;
185
+ }
186
+ body.innerHTML = html;
187
+ })
188
+ .catch(() => {
189
+ body.innerHTML =
190
+ '<div class="empty-state"><p>Could not load changes</p></div>';
191
+ });
192
+ }
193
+
194
+ function connectAgentStream(issue: number): void {
195
+ if (sseClient) sseClient.close();
196
+
197
+ sseClient = new SSEClient(
198
+ `/api/logs/${issue}/stream`,
199
+ (data) => {
200
+ if (terminal) terminal.append(data);
201
+ },
202
+ () => {
203
+ api
204
+ .fetchLogs(issue)
205
+ .then((data) => {
206
+ if (terminal) terminal.append(data.content || "No logs available");
207
+ })
208
+ .catch(() => {
209
+ if (terminal) terminal.append("Failed to load logs");
210
+ });
211
+ },
212
+ );
213
+ sseClient.connect();
214
+ }
215
+
216
+ export const agentCockpitView: View = {
217
+ init() {},
218
+
219
+ render(data: FleetState) {
220
+ renderCockpit(data);
221
+ },
222
+
223
+ destroy() {
224
+ if (sseClient) {
225
+ sseClient.close();
226
+ sseClient = null;
227
+ }
228
+ if (terminal) {
229
+ terminal.destroy();
230
+ terminal = null;
231
+ }
232
+ selectedAgent = null;
233
+ cpuHistory = [];
234
+ memHistory = [];
235
+ },
236
+ };
@@ -0,0 +1,72 @@
1
+ // Agents tab - agent cards grid with intervention controls
2
+
3
+ import { escapeHtml, formatDuration } from "../core/helpers";
4
+ import { icon } from "../design/icons";
5
+ import { openInterventionModal, confirmAbort } from "../components/modal";
6
+ import * as api from "../core/api";
7
+ import type { FleetState, View, PipelineInfo } from "../types/api";
8
+
9
+ function renderAgentsTab(data: FleetState): void {
10
+ const container = document.getElementById("agents-grid");
11
+ if (!container) return;
12
+
13
+ const pipelines = data.pipelines || [];
14
+ if (pipelines.length === 0) {
15
+ container.innerHTML = `<div class="empty-state">${icon("users", 32)}<p>No active agents</p></div>`;
16
+ return;
17
+ }
18
+
19
+ let html = "";
20
+ for (const p of pipelines) {
21
+ const statusClass = p.status === "failed" ? "agent-failed" : "agent-active";
22
+ html +=
23
+ `<div class="agent-card ${statusClass}">` +
24
+ `<div class="agent-card-header">` +
25
+ `<span class="agent-issue">#${p.issue}</span>` +
26
+ `<span class="agent-title">${escapeHtml(p.title)}</span></div>` +
27
+ `<div class="agent-card-body">` +
28
+ `<div class="agent-info-row"><span class="agent-info-label">${icon("activity", 14)} Stage</span>` +
29
+ `<span class="agent-info-value">${escapeHtml(p.stage)}</span></div>` +
30
+ `<div class="agent-info-row"><span class="agent-info-label">${icon("timer", 14)} Elapsed</span>` +
31
+ `<span class="agent-info-value">${formatDuration(p.elapsed_s)}</span></div>` +
32
+ `<div class="agent-info-row"><span class="agent-info-label">${icon("refresh-cw", 14)} Iteration</span>` +
33
+ `<span class="agent-info-value">${p.iteration || 0}/${p.maxIterations || 20}</span></div>` +
34
+ `</div>` +
35
+ `<div class="agent-card-actions">` +
36
+ `<button class="agent-action-btn" data-action="message" data-issue="${p.issue}">${icon("message-square", 14)} Message</button>` +
37
+ `<button class="agent-action-btn" data-action="pause" data-issue="${p.issue}">${icon("pause", 14)} Pause</button>` +
38
+ `<button class="agent-action-btn danger" data-action="abort" data-issue="${p.issue}">${icon("square", 14)} Abort</button>` +
39
+ `</div></div>`;
40
+ }
41
+ container.innerHTML = html;
42
+
43
+ // Wire up action buttons
44
+ container.querySelectorAll(".agent-action-btn").forEach((btn) => {
45
+ btn.addEventListener("click", (e) => {
46
+ e.stopPropagation();
47
+ const action = btn.getAttribute("data-action");
48
+ const issue = parseInt(btn.getAttribute("data-issue") || "0", 10);
49
+ if (!issue) return;
50
+
51
+ switch (action) {
52
+ case "message":
53
+ openInterventionModal(issue);
54
+ break;
55
+ case "pause":
56
+ api.sendIntervention(issue, "pause");
57
+ break;
58
+ case "abort":
59
+ confirmAbort(issue);
60
+ break;
61
+ }
62
+ });
63
+ });
64
+ }
65
+
66
+ export const agentsView: View = {
67
+ init() {},
68
+ render(data: FleetState) {
69
+ renderAgentsTab(data);
70
+ },
71
+ destroy() {},
72
+ };
@@ -0,0 +1,299 @@
1
+ // Fleet Map - Canvas2D topology with stage columns, animated pipeline particles
2
+
3
+ import { store } from "../core/state";
4
+ import { colors, STAGES, STAGE_HEX, STAGE_SHORT } from "../design/tokens";
5
+ import { formatDuration } from "../core/helpers";
6
+ import {
7
+ CanvasRenderer,
8
+ drawText,
9
+ drawCircle,
10
+ drawRoundRect,
11
+ type CanvasScene,
12
+ } from "../canvas/renderer";
13
+ import {
14
+ computeLayout,
15
+ type LayoutNode,
16
+ type StageColumn,
17
+ } from "../canvas/layout";
18
+ import { ParticleSystem } from "../canvas/particles";
19
+ import { hitTestNode, ZoomPan } from "../canvas/interactions";
20
+ import { drawTooltip, drawStageLabel } from "../canvas/overlays";
21
+ import * as api from "../core/api";
22
+ import type { FleetState, View } from "../types/api";
23
+
24
+ let renderer: CanvasRenderer | null = null;
25
+ let scene: FleetMapScene | null = null;
26
+
27
+ class FleetMapScene implements CanvasScene {
28
+ nodes: LayoutNode[] = [];
29
+ columns: StageColumn[] = [];
30
+ particles = new ParticleSystem();
31
+ zoomPan = new ZoomPan();
32
+ hoveredNode: LayoutNode | null = null;
33
+ predictions: Record<
34
+ number,
35
+ { eta_s?: number; success_probability?: number; estimated_cost?: number }
36
+ > = {};
37
+ time = 0;
38
+ width = 0;
39
+ height = 0;
40
+
41
+ updateData(data: FleetState): void {
42
+ if (!data.pipelines) return;
43
+ const { nodes, columns } = computeLayout(
44
+ data.pipelines,
45
+ this.width,
46
+ this.height,
47
+ );
48
+
49
+ // Smooth transition: find matching nodes and lerp
50
+ for (const newNode of nodes) {
51
+ const existing = this.nodes.find((n) => n.issue === newNode.issue);
52
+ if (existing) {
53
+ newNode.x = existing.x;
54
+ newNode.y = existing.y;
55
+ }
56
+ }
57
+
58
+ this.nodes = nodes;
59
+ this.columns = columns;
60
+
61
+ // Fetch predictions for active pipelines
62
+ for (const p of data.pipelines) {
63
+ if (!this.predictions[p.issue]) {
64
+ api.fetchPredictions(p.issue).then((pred) => {
65
+ this.predictions[p.issue] = pred;
66
+ });
67
+ }
68
+ }
69
+ }
70
+
71
+ update(dt: number): void {
72
+ this.time += dt;
73
+
74
+ // Smooth node movement
75
+ for (const node of this.nodes) {
76
+ const dx = node.targetX - node.x;
77
+ const dy = node.targetY - node.y;
78
+ node.x += dx * 5 * dt;
79
+ node.y += dy * 5 * dt;
80
+ }
81
+
82
+ // Emit trail particles for active nodes
83
+ for (const node of this.nodes) {
84
+ if (node.status === "active" || node.status === "running") {
85
+ if (Math.random() < 0.3) {
86
+ this.particles.emit(
87
+ node.x + (Math.random() - 0.5) * node.radius,
88
+ node.y + (Math.random() - 0.5) * node.radius,
89
+ "trail",
90
+ node.color,
91
+ 1,
92
+ );
93
+ }
94
+ }
95
+ }
96
+
97
+ // Ambient particles along columns
98
+ if (Math.random() < 0.05) {
99
+ const col = this.columns[Math.floor(Math.random() * this.columns.length)];
100
+ if (col) {
101
+ this.particles.emit(
102
+ col.x + Math.random() * col.width,
103
+ Math.random() * this.height,
104
+ "ambient",
105
+ col.color,
106
+ 1,
107
+ );
108
+ }
109
+ }
110
+
111
+ this.particles.update(dt);
112
+ }
113
+
114
+ draw(ctx: CanvasRenderingContext2D, width: number, height: number): void {
115
+ // Background
116
+ ctx.fillStyle = colors.bg.abyss;
117
+ ctx.fillRect(0, 0, width, height);
118
+
119
+ ctx.save();
120
+ this.zoomPan.apply(ctx);
121
+
122
+ // Stage columns
123
+ for (const col of this.columns) {
124
+ // Column separator
125
+ ctx.fillStyle = colors.bg.deep;
126
+ ctx.fillRect(col.x, 0, col.width, height);
127
+
128
+ // Column border
129
+ ctx.strokeStyle = colors.bg.foam + "40";
130
+ ctx.lineWidth = 1;
131
+ ctx.beginPath();
132
+ ctx.moveTo(col.x, 0);
133
+ ctx.lineTo(col.x, height);
134
+ ctx.stroke();
135
+
136
+ // Stage label at top
137
+ drawStageLabel(ctx, col.name, col.x + col.width / 2, 20, col.color);
138
+ }
139
+
140
+ // Connection lines between pipeline stages
141
+ for (const node of this.nodes) {
142
+ if (node.stageIndex > 0) {
143
+ const prevCol = this.columns[node.stageIndex - 1];
144
+ if (prevCol) {
145
+ const fromX = prevCol.x + prevCol.width / 2;
146
+ ctx.globalAlpha = 0.15;
147
+ ctx.strokeStyle = node.color;
148
+ ctx.lineWidth = 1;
149
+ ctx.setLineDash([4, 4]);
150
+ ctx.beginPath();
151
+ ctx.moveTo(fromX, node.y);
152
+ ctx.lineTo(node.x, node.y);
153
+ ctx.stroke();
154
+ ctx.setLineDash([]);
155
+ ctx.globalAlpha = 1;
156
+ }
157
+ }
158
+ }
159
+
160
+ // Particles (behind nodes)
161
+ this.particles.draw(ctx);
162
+
163
+ // Pipeline nodes
164
+ for (const node of this.nodes) {
165
+ const isHovered = this.hoveredNode === node;
166
+ const r = isHovered ? node.radius * 1.2 : node.radius;
167
+
168
+ // Glow
169
+ if (node.status !== "failed") {
170
+ ctx.globalAlpha = 0.2 + 0.1 * Math.sin(this.time * 2 + node.issue);
171
+ drawCircle(ctx, node.x, node.y, r + 6, undefined, node.color, 2);
172
+ ctx.globalAlpha = 1;
173
+ }
174
+
175
+ // Node circle
176
+ drawCircle(ctx, node.x, node.y, r, node.color);
177
+
178
+ // Issue number
179
+ drawText(ctx, "#" + node.issue, node.x, node.y - 4, {
180
+ font: "monoSm",
181
+ color: colors.bg.abyss,
182
+ align: "center",
183
+ baseline: "middle",
184
+ });
185
+
186
+ // Progress arc
187
+ if (node.progress > 0 && node.progress < 1) {
188
+ ctx.beginPath();
189
+ ctx.arc(
190
+ node.x,
191
+ node.y,
192
+ r + 3,
193
+ -Math.PI / 2,
194
+ -Math.PI / 2 + node.progress * Math.PI * 2,
195
+ );
196
+ ctx.strokeStyle = colors.accent.cyan;
197
+ ctx.lineWidth = 2;
198
+ ctx.stroke();
199
+ }
200
+ }
201
+
202
+ ctx.restore();
203
+
204
+ // Tooltip (drawn in screen space)
205
+ if (this.hoveredNode) {
206
+ drawTooltip(
207
+ ctx,
208
+ this.hoveredNode,
209
+ this.predictions[this.hoveredNode.issue],
210
+ );
211
+ }
212
+
213
+ // HUD: pipeline count
214
+ drawText(
215
+ ctx,
216
+ `${this.nodes.length} active pipeline${this.nodes.length !== 1 ? "s" : ""}`,
217
+ 16,
218
+ height - 30,
219
+ {
220
+ font: "caption",
221
+ color: colors.text.muted,
222
+ },
223
+ );
224
+ drawText(ctx, `${this.particles.count} particles`, 16, height - 16, {
225
+ font: "tiny",
226
+ color: colors.text.muted,
227
+ });
228
+ }
229
+
230
+ onResize(width: number, height: number): void {
231
+ this.width = width;
232
+ this.height = height;
233
+ // Recompute layout
234
+ const data = store.get("fleetState");
235
+ if (data?.pipelines) {
236
+ const { nodes, columns } = computeLayout(data.pipelines, width, height);
237
+ this.nodes = nodes;
238
+ this.columns = columns;
239
+ }
240
+ }
241
+
242
+ onMouseMove(x: number, y: number): void {
243
+ const world = this.zoomPan.screenToWorld(x, y);
244
+ this.hoveredNode = hitTestNode(this.nodes, world.x, world.y);
245
+ if (renderer) {
246
+ renderer.getCanvas().style.cursor = this.hoveredNode
247
+ ? "pointer"
248
+ : "default";
249
+ }
250
+ }
251
+
252
+ onMouseClick(x: number, y: number): void {
253
+ const world = this.zoomPan.screenToWorld(x, y);
254
+ const node = hitTestNode(this.nodes, world.x, world.y);
255
+ if (node) {
256
+ this.particles.burstAt(node.x, node.y, node.color);
257
+ import("../core/router").then(({ switchTab }) => {
258
+ switchTab("pipelines");
259
+ import("./pipelines").then(({ fetchPipelineDetail }) => {
260
+ fetchPipelineDetail(node.issue);
261
+ });
262
+ });
263
+ }
264
+ }
265
+
266
+ onMouseWheel(delta: number): void {
267
+ this.zoomPan.zoom(delta, this.width / 2, this.height / 2);
268
+ }
269
+ }
270
+
271
+ export const fleetMapView: View = {
272
+ init() {
273
+ const container = document.getElementById("panel-fleet-map");
274
+ if (!container) return;
275
+
276
+ container.innerHTML =
277
+ '<div class="fleet-map-canvas" style="width:100%;height:calc(100vh - 160px);position:relative;"></div>';
278
+ const canvasContainer = container.querySelector(
279
+ ".fleet-map-canvas",
280
+ ) as HTMLElement;
281
+
282
+ renderer = new CanvasRenderer(canvasContainer);
283
+ scene = new FleetMapScene();
284
+ renderer.setScene(scene);
285
+ renderer.start();
286
+ },
287
+
288
+ render(data: FleetState) {
289
+ if (scene) scene.updateData(data);
290
+ },
291
+
292
+ destroy() {
293
+ if (renderer) {
294
+ renderer.destroy();
295
+ renderer = null;
296
+ }
297
+ scene = null;
298
+ },
299
+ };