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.
- 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.ts +381 -0
- package/dashboard/src/core/helpers.ts +118 -0
- package/dashboard/src/core/router.ts +190 -0
- package/dashboard/src/core/sse.ts +38 -0
- package/dashboard/src/core/state.ts +150 -0
- package/dashboard/src/core/ws.ts +143 -0
- package/dashboard/src/design/icons.ts +131 -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/docs/AGI-WHATS-NEXT.md +15 -15
- package/package.json +8 -1
- package/scripts/lib/helpers.sh +30 -0
- package/scripts/lib/pipeline-quality-checks.sh +1 -1
- 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 +1 -1
- 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 +1 -1
- package/scripts/sw-scale.sh +4 -1
- package/scripts/sw-security-audit.sh +1 -1
- package/scripts/sw-self-optimize.sh +15 -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 +1 -1
- 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,616 @@
|
|
|
1
|
+
// Header bar + connection status + user menu + cost ticker
|
|
2
|
+
|
|
3
|
+
import { store } from "../core/state";
|
|
4
|
+
import { escapeHtml, fmtNum } from "../core/helpers";
|
|
5
|
+
import * as api from "../core/api";
|
|
6
|
+
import type { FleetState, DaemonConfig } from "../types/api";
|
|
7
|
+
|
|
8
|
+
let soundEnabled = false;
|
|
9
|
+
let previousPipelineIds: Set<number> = new Set();
|
|
10
|
+
|
|
11
|
+
export function setupHeader(): void {
|
|
12
|
+
fetchUser();
|
|
13
|
+
setupUserMenu();
|
|
14
|
+
setupDaemonControlBar();
|
|
15
|
+
setupEmergencyBrake();
|
|
16
|
+
setupSoundToggle();
|
|
17
|
+
setupThemeToggle();
|
|
18
|
+
setupAmbientIndicator();
|
|
19
|
+
setupNotificationsModal();
|
|
20
|
+
fetchDaemonConfig();
|
|
21
|
+
setInterval(fetchDaemonConfig, 30000);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function setupSoundToggle(): void {
|
|
25
|
+
const headerActions = document.querySelector(".header-actions");
|
|
26
|
+
if (!headerActions) return;
|
|
27
|
+
|
|
28
|
+
const btn = document.createElement("button");
|
|
29
|
+
btn.className = "sound-toggle";
|
|
30
|
+
btn.id = "sound-toggle";
|
|
31
|
+
btn.innerHTML = "\u{1F50A} Sound";
|
|
32
|
+
btn.addEventListener("click", () => {
|
|
33
|
+
soundEnabled = !soundEnabled;
|
|
34
|
+
btn.classList.toggle("active", soundEnabled);
|
|
35
|
+
btn.innerHTML = soundEnabled ? "\u{1F50A} Sound" : "\u{1F507} Mute";
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Insert before user avatar if possible
|
|
39
|
+
const userAvatar = document.getElementById("user-avatar");
|
|
40
|
+
if (userAvatar) {
|
|
41
|
+
headerActions.insertBefore(btn, userAvatar);
|
|
42
|
+
} else {
|
|
43
|
+
headerActions.appendChild(btn);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function setupThemeToggle(): void {
|
|
48
|
+
const headerActions = document.querySelector(".header-actions");
|
|
49
|
+
if (!headerActions) return;
|
|
50
|
+
|
|
51
|
+
const saved = localStorage.getItem("sw-theme");
|
|
52
|
+
if (saved === "light")
|
|
53
|
+
document.documentElement.setAttribute("data-theme", "light");
|
|
54
|
+
|
|
55
|
+
const btn = document.createElement("button");
|
|
56
|
+
btn.className = "theme-toggle";
|
|
57
|
+
btn.id = "theme-toggle";
|
|
58
|
+
const isDark = () =>
|
|
59
|
+
document.documentElement.getAttribute("data-theme") !== "light";
|
|
60
|
+
btn.innerHTML = isDark() ? "\u263E Dark" : "\u2600 Light";
|
|
61
|
+
btn.addEventListener("click", () => {
|
|
62
|
+
if (isDark()) {
|
|
63
|
+
document.documentElement.setAttribute("data-theme", "light");
|
|
64
|
+
localStorage.setItem("sw-theme", "light");
|
|
65
|
+
btn.innerHTML = "\u2600 Light";
|
|
66
|
+
} else {
|
|
67
|
+
document.documentElement.removeAttribute("data-theme");
|
|
68
|
+
localStorage.setItem("sw-theme", "dark");
|
|
69
|
+
btn.innerHTML = "\u263E Dark";
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const soundBtn = document.getElementById("sound-toggle");
|
|
74
|
+
if (soundBtn) {
|
|
75
|
+
headerActions.insertBefore(btn, soundBtn);
|
|
76
|
+
} else {
|
|
77
|
+
const userAvatar = document.getElementById("user-avatar");
|
|
78
|
+
if (userAvatar) headerActions.insertBefore(btn, userAvatar);
|
|
79
|
+
else headerActions.appendChild(btn);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function setupAmbientIndicator(): void {
|
|
84
|
+
const indicator = document.createElement("div");
|
|
85
|
+
indicator.className = "ambient-indicator";
|
|
86
|
+
indicator.id = "ambient-indicator";
|
|
87
|
+
document.body.appendChild(indicator);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function updateAmbientIndicator(data: FleetState): void {
|
|
91
|
+
const indicator = document.getElementById("ambient-indicator");
|
|
92
|
+
if (!indicator) return;
|
|
93
|
+
|
|
94
|
+
const active = data.pipelines?.length || 0;
|
|
95
|
+
const queue = data.queue?.length || 0;
|
|
96
|
+
const anyFailed = data.pipelines?.some((p) => p.status === "failed");
|
|
97
|
+
|
|
98
|
+
if (anyFailed) {
|
|
99
|
+
indicator.className = "ambient-indicator critical";
|
|
100
|
+
} else if (active > 0) {
|
|
101
|
+
indicator.className =
|
|
102
|
+
active > 3 ? "ambient-indicator busy" : "ambient-indicator";
|
|
103
|
+
} else {
|
|
104
|
+
indicator.style.display = "none";
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
indicator.style.display = "";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function detectCompletions(data: FleetState): void {
|
|
111
|
+
const currentIds = new Set(data.pipelines?.map((p) => p.issue) || []);
|
|
112
|
+
|
|
113
|
+
// Check for pipelines that disappeared (completed or failed)
|
|
114
|
+
for (const prevId of previousPipelineIds) {
|
|
115
|
+
if (!currentIds.has(prevId)) {
|
|
116
|
+
// Pipeline completed or failed - trigger visual effect
|
|
117
|
+
const completedEvent = data.events?.find(
|
|
118
|
+
(e) => e.issue === prevId && String(e.type || "").includes("completed"),
|
|
119
|
+
);
|
|
120
|
+
const failedEvent = data.events?.find(
|
|
121
|
+
(e) => e.issue === prevId && String(e.type || "").includes("failed"),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
if (completedEvent && soundEnabled) {
|
|
125
|
+
playCompletionSound();
|
|
126
|
+
} else if (failedEvent && soundEnabled) {
|
|
127
|
+
playFailureSound();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
previousPipelineIds = currentIds;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function playCompletionSound(): void {
|
|
136
|
+
try {
|
|
137
|
+
const audioCtx = new AudioContext();
|
|
138
|
+
const osc = audioCtx.createOscillator();
|
|
139
|
+
const gain = audioCtx.createGain();
|
|
140
|
+
osc.connect(gain);
|
|
141
|
+
gain.connect(audioCtx.destination);
|
|
142
|
+
osc.frequency.setValueAtTime(523.25, audioCtx.currentTime); // C5
|
|
143
|
+
osc.frequency.setValueAtTime(659.25, audioCtx.currentTime + 0.1); // E5
|
|
144
|
+
osc.frequency.setValueAtTime(783.99, audioCtx.currentTime + 0.2); // G5
|
|
145
|
+
gain.gain.setValueAtTime(0.1, audioCtx.currentTime);
|
|
146
|
+
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.5);
|
|
147
|
+
osc.start(audioCtx.currentTime);
|
|
148
|
+
osc.stop(audioCtx.currentTime + 0.5);
|
|
149
|
+
} catch {}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function playFailureSound(): void {
|
|
153
|
+
try {
|
|
154
|
+
const audioCtx = new AudioContext();
|
|
155
|
+
const osc = audioCtx.createOscillator();
|
|
156
|
+
const gain = audioCtx.createGain();
|
|
157
|
+
osc.connect(gain);
|
|
158
|
+
gain.connect(audioCtx.destination);
|
|
159
|
+
osc.type = "sawtooth";
|
|
160
|
+
osc.frequency.setValueAtTime(200, audioCtx.currentTime);
|
|
161
|
+
osc.frequency.setValueAtTime(150, audioCtx.currentTime + 0.2);
|
|
162
|
+
gain.gain.setValueAtTime(0.08, audioCtx.currentTime);
|
|
163
|
+
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.4);
|
|
164
|
+
osc.start(audioCtx.currentTime);
|
|
165
|
+
osc.stop(audioCtx.currentTime + 0.4);
|
|
166
|
+
} catch {}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function applyRoleRestrictions(role: string): void {
|
|
170
|
+
if (role === "viewer") {
|
|
171
|
+
// Hide all action buttons for viewers
|
|
172
|
+
const selectors = [
|
|
173
|
+
"#emergency-brake",
|
|
174
|
+
"#daemon-btn-start",
|
|
175
|
+
"#daemon-btn-stop",
|
|
176
|
+
"#daemon-btn-pause",
|
|
177
|
+
"#daemon-btn-patrol",
|
|
178
|
+
"#btn-add-machine",
|
|
179
|
+
"#btn-join-link",
|
|
180
|
+
"#btn-create-invite",
|
|
181
|
+
".pipeline-checkbox",
|
|
182
|
+
".bulk-actions",
|
|
183
|
+
];
|
|
184
|
+
for (const sel of selectors) {
|
|
185
|
+
document.querySelectorAll(sel).forEach((el) => {
|
|
186
|
+
(el as HTMLElement).style.display = "none";
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function fetchUser(): void {
|
|
193
|
+
api
|
|
194
|
+
.fetchMe()
|
|
195
|
+
.then((user) => {
|
|
196
|
+
store.set("currentUser", user);
|
|
197
|
+
const avatarBtn = document.getElementById("user-avatar");
|
|
198
|
+
const usernameEl = document.getElementById("dropdown-username");
|
|
199
|
+
const initialsEl = document.getElementById("avatar-initials");
|
|
200
|
+
const roleText = user.role ? ` (${user.role})` : "";
|
|
201
|
+
if (usernameEl)
|
|
202
|
+
usernameEl.textContent = escapeHtml(
|
|
203
|
+
(user.username || "User") + roleText,
|
|
204
|
+
);
|
|
205
|
+
if (user.role) applyRoleRestrictions(user.role);
|
|
206
|
+
|
|
207
|
+
if (user.avatarUrl && avatarBtn) {
|
|
208
|
+
const img = document.createElement("img");
|
|
209
|
+
img.src = user.avatarUrl;
|
|
210
|
+
img.alt = escapeHtml(user.username || "User");
|
|
211
|
+
avatarBtn.innerHTML = "";
|
|
212
|
+
avatarBtn.appendChild(img);
|
|
213
|
+
} else if (initialsEl) {
|
|
214
|
+
const name = user.username || "?";
|
|
215
|
+
const parts = name.split(" ");
|
|
216
|
+
const initials =
|
|
217
|
+
parts.length >= 2
|
|
218
|
+
? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
|
219
|
+
: name.substring(0, 2).toUpperCase();
|
|
220
|
+
initialsEl.textContent = initials;
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
.catch(() => {});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function setupUserMenu(): void {
|
|
227
|
+
const avatar = document.getElementById("user-avatar");
|
|
228
|
+
const dropdown = document.getElementById("user-dropdown");
|
|
229
|
+
if (!avatar || !dropdown) return;
|
|
230
|
+
|
|
231
|
+
avatar.addEventListener("click", (e) => {
|
|
232
|
+
e.stopPropagation();
|
|
233
|
+
dropdown.classList.toggle("open");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
document.addEventListener("click", () => {
|
|
237
|
+
dropdown.classList.remove("open");
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function renderCostTicker(data: FleetState): void {
|
|
242
|
+
const cost = data.cost;
|
|
243
|
+
if (!cost) return;
|
|
244
|
+
const el24 = document.getElementById("cost-24h");
|
|
245
|
+
const elBudget = document.getElementById("cost-budget");
|
|
246
|
+
if (el24 && cost.today_spent != null) {
|
|
247
|
+
el24.textContent = "$" + cost.today_spent.toFixed(2);
|
|
248
|
+
}
|
|
249
|
+
if (elBudget && cost.daily_budget != null) {
|
|
250
|
+
const remaining = Math.max(0, cost.daily_budget - cost.today_spent);
|
|
251
|
+
elBudget.textContent = "$" + remaining.toFixed(2) + " remaining";
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function renderAlertBanner(data: FleetState): void {
|
|
256
|
+
const container = document.getElementById("alert-banner");
|
|
257
|
+
if (!container) return;
|
|
258
|
+
|
|
259
|
+
if (store.get("alertDismissed")) {
|
|
260
|
+
container.innerHTML = "";
|
|
261
|
+
container.style.display = "none";
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
api
|
|
266
|
+
.fetchAlerts()
|
|
267
|
+
.then(({ alerts }) => {
|
|
268
|
+
if (!alerts || alerts.length === 0) {
|
|
269
|
+
container.innerHTML = "";
|
|
270
|
+
container.style.display = "none";
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const alert = alerts[0];
|
|
275
|
+
store.set("alertsCache", alerts);
|
|
276
|
+
|
|
277
|
+
const severityClass = "alert-" + (alert.severity || "info");
|
|
278
|
+
let html =
|
|
279
|
+
`<div class="alert-banner-content ${severityClass}">` +
|
|
280
|
+
`<span class="alert-banner-icon">\u26A0</span>` +
|
|
281
|
+
`<span class="alert-banner-msg">${escapeHtml(alert.message || "")}</span>` +
|
|
282
|
+
`<span class="alert-banner-actions">`;
|
|
283
|
+
|
|
284
|
+
if (alert.issue) {
|
|
285
|
+
html += `<button class="alert-action-btn" data-action="view-alert" data-issue="${alert.issue}">View</button>`;
|
|
286
|
+
}
|
|
287
|
+
if (alert.type === "failure_spike") {
|
|
288
|
+
html += `<button class="alert-action-btn btn-abort" data-action="emergency-brake">Emergency Brake</button>`;
|
|
289
|
+
}
|
|
290
|
+
if (alert.type === "stuck_pipeline" && alert.issue) {
|
|
291
|
+
html += `<button class="alert-action-btn btn-abort" data-action="abort-alert" data-issue="${alert.issue}">Abort</button>`;
|
|
292
|
+
html += `<button class="alert-action-btn" data-action="skip-alert" data-issue="${alert.issue}">Skip Stage</button>`;
|
|
293
|
+
}
|
|
294
|
+
html += `<button class="alert-dismiss-btn" data-action="dismiss-alert">\u2715</button>`;
|
|
295
|
+
html += "</span></div>";
|
|
296
|
+
|
|
297
|
+
container.innerHTML = html;
|
|
298
|
+
container.style.display = "";
|
|
299
|
+
|
|
300
|
+
// Wire up alert action buttons
|
|
301
|
+
container.querySelectorAll("[data-action]").forEach((btn) => {
|
|
302
|
+
btn.addEventListener("click", handleAlertAction);
|
|
303
|
+
});
|
|
304
|
+
})
|
|
305
|
+
.catch(() => {
|
|
306
|
+
container.innerHTML = "";
|
|
307
|
+
container.style.display = "none";
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function handleAlertAction(e: Event): void {
|
|
312
|
+
const btn = e.currentTarget as HTMLElement;
|
|
313
|
+
const action = btn.getAttribute("data-action");
|
|
314
|
+
const issue = btn.getAttribute("data-issue");
|
|
315
|
+
|
|
316
|
+
switch (action) {
|
|
317
|
+
case "dismiss-alert":
|
|
318
|
+
store.set("alertDismissed", true);
|
|
319
|
+
const container = document.getElementById("alert-banner");
|
|
320
|
+
if (container) {
|
|
321
|
+
container.innerHTML = "";
|
|
322
|
+
container.style.display = "none";
|
|
323
|
+
}
|
|
324
|
+
setTimeout(() => store.set("alertDismissed", false), 30000);
|
|
325
|
+
break;
|
|
326
|
+
case "emergency-brake":
|
|
327
|
+
const modal = document.getElementById("emergency-modal");
|
|
328
|
+
if (modal) modal.style.display = "";
|
|
329
|
+
break;
|
|
330
|
+
case "abort-alert":
|
|
331
|
+
if (issue) api.sendIntervention(issue, "abort");
|
|
332
|
+
break;
|
|
333
|
+
case "skip-alert":
|
|
334
|
+
if (issue) api.sendIntervention(issue, "skip_stage");
|
|
335
|
+
break;
|
|
336
|
+
case "view-alert":
|
|
337
|
+
if (issue) {
|
|
338
|
+
import("../core/router").then(({ switchTab }) => {
|
|
339
|
+
switchTab("pipelines");
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
break;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function updateEmergencyBrakeVisibility(data: FleetState): void {
|
|
347
|
+
const brakeBtn = document.getElementById("emergency-brake");
|
|
348
|
+
if (!brakeBtn) return;
|
|
349
|
+
const active = data.pipelines ? data.pipelines.length : 0;
|
|
350
|
+
brakeBtn.style.display = active > 0 ? "" : "none";
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function setupEmergencyBrake(): void {
|
|
354
|
+
const brakeBtn = document.getElementById("emergency-brake");
|
|
355
|
+
if (!brakeBtn) return;
|
|
356
|
+
|
|
357
|
+
brakeBtn.addEventListener("click", () => {
|
|
358
|
+
const modal = document.getElementById("emergency-modal");
|
|
359
|
+
if (modal) {
|
|
360
|
+
const fleetState = store.get("fleetState");
|
|
361
|
+
const activeCount = document.getElementById("emergency-active-count");
|
|
362
|
+
const queueCount = document.getElementById("emergency-queue-count");
|
|
363
|
+
if (activeCount)
|
|
364
|
+
activeCount.textContent = String(fleetState?.pipelines?.length || 0);
|
|
365
|
+
if (queueCount)
|
|
366
|
+
queueCount.textContent = String(fleetState?.queue?.length || 0);
|
|
367
|
+
modal.style.display = "";
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
const confirmBtn = document.getElementById("emergency-confirm");
|
|
372
|
+
const cancelBtn = document.getElementById("emergency-cancel");
|
|
373
|
+
const modal = document.getElementById("emergency-modal");
|
|
374
|
+
|
|
375
|
+
if (cancelBtn && modal) {
|
|
376
|
+
cancelBtn.addEventListener("click", () => {
|
|
377
|
+
modal.style.display = "none";
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
if (modal) {
|
|
381
|
+
modal.addEventListener("click", (e) => {
|
|
382
|
+
if (e.target === modal) modal.style.display = "none";
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
if (confirmBtn) {
|
|
386
|
+
confirmBtn.addEventListener("click", () => {
|
|
387
|
+
api
|
|
388
|
+
.emergencyBrake()
|
|
389
|
+
.then(() => {
|
|
390
|
+
if (modal) modal.style.display = "none";
|
|
391
|
+
})
|
|
392
|
+
.catch((err) => {
|
|
393
|
+
console.error("Emergency brake failed:", err);
|
|
394
|
+
if (modal) modal.style.display = "none";
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function fetchDaemonConfig(): void {
|
|
401
|
+
api
|
|
402
|
+
.fetchDaemonConfig()
|
|
403
|
+
.then((data) => {
|
|
404
|
+
store.set("daemonConfig", data);
|
|
405
|
+
updateDaemonControlBar(data);
|
|
406
|
+
})
|
|
407
|
+
.catch(() => {});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function setupDaemonControlBar(): void {
|
|
411
|
+
const startBtn = document.getElementById("daemon-btn-start");
|
|
412
|
+
const stopBtn = document.getElementById("daemon-btn-stop");
|
|
413
|
+
const pauseBtn = document.getElementById("daemon-btn-pause");
|
|
414
|
+
const patrolBtn = document.getElementById("daemon-btn-patrol");
|
|
415
|
+
|
|
416
|
+
if (startBtn) {
|
|
417
|
+
startBtn.addEventListener("click", () => daemonControlAction("start"));
|
|
418
|
+
}
|
|
419
|
+
if (stopBtn) {
|
|
420
|
+
stopBtn.addEventListener("click", () => daemonControlAction("stop"));
|
|
421
|
+
}
|
|
422
|
+
if (pauseBtn) {
|
|
423
|
+
pauseBtn.addEventListener("click", () => {
|
|
424
|
+
const badge = document.getElementById("daemon-status-badge");
|
|
425
|
+
const action = badge?.classList.contains("paused") ? "resume" : "pause";
|
|
426
|
+
daemonControlAction(action);
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
if (patrolBtn) {
|
|
430
|
+
patrolBtn.addEventListener("click", () => daemonControlAction("patrol"));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function daemonControlAction(action: string): Promise<void> {
|
|
435
|
+
const btn = document.getElementById("daemon-btn-" + action);
|
|
436
|
+
if (btn) (btn as HTMLButtonElement).disabled = true;
|
|
437
|
+
try {
|
|
438
|
+
await api.daemonControl(action);
|
|
439
|
+
setTimeout(fetchDaemonConfig, 1000);
|
|
440
|
+
} catch (err) {
|
|
441
|
+
console.error("Daemon control failed:", err);
|
|
442
|
+
} finally {
|
|
443
|
+
if (btn) (btn as HTMLButtonElement).disabled = false;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function setupNotificationsModal(): void {
|
|
448
|
+
const openBtn = document.getElementById("open-notifications");
|
|
449
|
+
const modal = document.getElementById("notifications-modal");
|
|
450
|
+
const closeBtn = document.getElementById("notif-modal-close");
|
|
451
|
+
const addBtn = document.getElementById("notif-add-webhook");
|
|
452
|
+
const testBtn = document.getElementById("notif-test-btn");
|
|
453
|
+
|
|
454
|
+
if (openBtn && modal) {
|
|
455
|
+
openBtn.addEventListener("click", () => {
|
|
456
|
+
modal.style.display = "";
|
|
457
|
+
loadWebhookList();
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
if (closeBtn && modal) {
|
|
461
|
+
closeBtn.addEventListener("click", () => {
|
|
462
|
+
modal.style.display = "none";
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
if (modal) {
|
|
466
|
+
modal.addEventListener("click", (e) => {
|
|
467
|
+
if (e.target === modal) modal.style.display = "none";
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (addBtn) {
|
|
472
|
+
addBtn.addEventListener("click", () => {
|
|
473
|
+
const urlInput = document.getElementById(
|
|
474
|
+
"notif-webhook-url",
|
|
475
|
+
) as HTMLInputElement;
|
|
476
|
+
const labelInput = document.getElementById(
|
|
477
|
+
"notif-webhook-label",
|
|
478
|
+
) as HTMLInputElement;
|
|
479
|
+
const allEvt = (
|
|
480
|
+
document.getElementById("notif-evt-all") as HTMLInputElement
|
|
481
|
+
)?.checked;
|
|
482
|
+
const url = urlInput?.value.trim();
|
|
483
|
+
if (!url) return;
|
|
484
|
+
const events: string[] = [];
|
|
485
|
+
if (allEvt) {
|
|
486
|
+
events.push("all");
|
|
487
|
+
} else {
|
|
488
|
+
if (
|
|
489
|
+
(document.getElementById("notif-evt-completed") as HTMLInputElement)
|
|
490
|
+
?.checked
|
|
491
|
+
)
|
|
492
|
+
events.push("pipeline.completed");
|
|
493
|
+
if (
|
|
494
|
+
(document.getElementById("notif-evt-failed") as HTMLInputElement)
|
|
495
|
+
?.checked
|
|
496
|
+
)
|
|
497
|
+
events.push("pipeline.failed");
|
|
498
|
+
if (
|
|
499
|
+
(document.getElementById("notif-evt-alert") as HTMLInputElement)
|
|
500
|
+
?.checked
|
|
501
|
+
)
|
|
502
|
+
events.push("alert");
|
|
503
|
+
}
|
|
504
|
+
api
|
|
505
|
+
.addWebhook(
|
|
506
|
+
url,
|
|
507
|
+
labelInput?.value.trim() || undefined,
|
|
508
|
+
events.length ? events : undefined,
|
|
509
|
+
)
|
|
510
|
+
.then(() => {
|
|
511
|
+
if (urlInput) urlInput.value = "";
|
|
512
|
+
if (labelInput) labelInput.value = "";
|
|
513
|
+
loadWebhookList();
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (testBtn) {
|
|
519
|
+
testBtn.addEventListener("click", () => {
|
|
520
|
+
testBtn.textContent = "Sending...";
|
|
521
|
+
api
|
|
522
|
+
.testNotification()
|
|
523
|
+
.then(() => {
|
|
524
|
+
testBtn.textContent = "Sent!";
|
|
525
|
+
setTimeout(() => {
|
|
526
|
+
testBtn.textContent = "Send Test";
|
|
527
|
+
}, 2000);
|
|
528
|
+
})
|
|
529
|
+
.catch(() => {
|
|
530
|
+
testBtn.textContent = "Failed";
|
|
531
|
+
setTimeout(() => {
|
|
532
|
+
testBtn.textContent = "Send Test";
|
|
533
|
+
}, 2000);
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function loadWebhookList(): void {
|
|
540
|
+
const container = document.getElementById("notif-webhook-list");
|
|
541
|
+
if (!container) return;
|
|
542
|
+
container.innerHTML = '<div class="empty-state"><p>Loading...</p></div>';
|
|
543
|
+
|
|
544
|
+
api
|
|
545
|
+
.fetchNotificationConfig()
|
|
546
|
+
.then((config) => {
|
|
547
|
+
if (!config.webhooks || config.webhooks.length === 0) {
|
|
548
|
+
container.innerHTML =
|
|
549
|
+
'<div class="empty-state"><p>No webhooks configured</p></div>';
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
let html = "";
|
|
553
|
+
for (const w of config.webhooks) {
|
|
554
|
+
html +=
|
|
555
|
+
`<div class="webhook-item">` +
|
|
556
|
+
`<span class="webhook-label">${escapeHtml(w.label)}</span>` +
|
|
557
|
+
`<span class="webhook-url">${escapeHtml(w.url.substring(0, 50))}${w.url.length > 50 ? "..." : ""}</span>` +
|
|
558
|
+
`<span class="webhook-events">${w.events.join(", ")}</span>` +
|
|
559
|
+
`<button class="btn-sm btn-danger" data-webhook-url="${escapeHtml(w.url)}">Remove</button>` +
|
|
560
|
+
"</div>";
|
|
561
|
+
}
|
|
562
|
+
container.innerHTML = html;
|
|
563
|
+
container.querySelectorAll("[data-webhook-url]").forEach((btn) => {
|
|
564
|
+
btn.addEventListener("click", () => {
|
|
565
|
+
const url = btn.getAttribute("data-webhook-url") || "";
|
|
566
|
+
api.removeWebhook(url).then(() => loadWebhookList());
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
})
|
|
570
|
+
.catch(() => {
|
|
571
|
+
container.innerHTML =
|
|
572
|
+
'<div class="empty-state"><p>Could not load config</p></div>';
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function updateDaemonControlBar(data: DaemonConfig): void {
|
|
577
|
+
const badge = document.getElementById("daemon-status-badge");
|
|
578
|
+
const pauseBtn = document.getElementById("daemon-btn-pause");
|
|
579
|
+
const workersEl = document.getElementById("daemon-info-workers");
|
|
580
|
+
const pollEl = document.getElementById("daemon-info-poll");
|
|
581
|
+
const patrolEl = document.getElementById("daemon-info-patrol");
|
|
582
|
+
const budgetEl = document.getElementById("daemon-info-budget");
|
|
583
|
+
|
|
584
|
+
if (!badge) return;
|
|
585
|
+
|
|
586
|
+
if (data.paused) {
|
|
587
|
+
badge.textContent = "Paused";
|
|
588
|
+
badge.className = "daemon-status-badge paused";
|
|
589
|
+
if (pauseBtn) pauseBtn.textContent = "Resume";
|
|
590
|
+
} else if (data.config?.watch_label) {
|
|
591
|
+
badge.textContent = "Running";
|
|
592
|
+
badge.className = "daemon-status-badge running";
|
|
593
|
+
if (pauseBtn) pauseBtn.textContent = "Pause";
|
|
594
|
+
} else {
|
|
595
|
+
badge.textContent = "Stopped";
|
|
596
|
+
badge.className = "daemon-status-badge stopped";
|
|
597
|
+
if (pauseBtn) pauseBtn.textContent = "Pause";
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (data.config) {
|
|
601
|
+
const cfg = data.config as Record<string, unknown>;
|
|
602
|
+
if (workersEl) workersEl.textContent = String(cfg.max_workers || "-");
|
|
603
|
+
if (pollEl) pollEl.textContent = String(cfg.poll_interval || "-");
|
|
604
|
+
if (patrolEl) {
|
|
605
|
+
const patrol = cfg.patrol as Record<string, unknown> | undefined;
|
|
606
|
+
patrolEl.textContent = String(patrol?.interval || "-");
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (data.budget && budgetEl) {
|
|
611
|
+
const budget = data.budget as Record<string, unknown>;
|
|
612
|
+
const remaining = budget.remaining ?? budget.daily_limit ?? "-";
|
|
613
|
+
budgetEl.textContent =
|
|
614
|
+
typeof remaining === "number" ? remaining.toFixed(2) : String(remaining);
|
|
615
|
+
}
|
|
616
|
+
}
|