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,162 @@
|
|
|
1
|
+
// Machines tab - machine grid, health checks, worker management
|
|
2
|
+
|
|
3
|
+
import { store } from "../core/state";
|
|
4
|
+
import { escapeHtml } from "../core/helpers";
|
|
5
|
+
import { icon } from "../design/icons";
|
|
6
|
+
import {
|
|
7
|
+
setupMachinesModals,
|
|
8
|
+
updateWorkerCount,
|
|
9
|
+
machineHealthCheckAction,
|
|
10
|
+
confirmMachineRemove,
|
|
11
|
+
} from "../components/modal";
|
|
12
|
+
import * as api from "../core/api";
|
|
13
|
+
import type { FleetState, View, MachineInfo, JoinToken } from "../types/api";
|
|
14
|
+
|
|
15
|
+
function fetchMachinesTab(): void {
|
|
16
|
+
api
|
|
17
|
+
.fetchMachines()
|
|
18
|
+
.then((machines) => {
|
|
19
|
+
store.set("machinesCache", machines);
|
|
20
|
+
renderMachinesTab(machines);
|
|
21
|
+
})
|
|
22
|
+
.catch(() => {});
|
|
23
|
+
|
|
24
|
+
api
|
|
25
|
+
.fetchJoinTokens()
|
|
26
|
+
.then(({ tokens }) => {
|
|
27
|
+
store.set("joinTokensCache", tokens);
|
|
28
|
+
renderJoinTokens(tokens);
|
|
29
|
+
})
|
|
30
|
+
.catch(() => {});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function renderMachinesTab(machines: MachineInfo[]): void {
|
|
34
|
+
const summaryEl = document.getElementById("machines-summary");
|
|
35
|
+
const gridEl = document.getElementById("machines-grid");
|
|
36
|
+
if (!summaryEl || !gridEl) return;
|
|
37
|
+
|
|
38
|
+
summaryEl.innerHTML = renderMachineSummary(machines);
|
|
39
|
+
|
|
40
|
+
if (machines.length === 0) {
|
|
41
|
+
gridEl.innerHTML = `<div class="empty-state">${icon("server", 32)}<p>No machines registered</p></div>`;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
gridEl.innerHTML = machines.map((m) => renderMachineCard(m)).join("");
|
|
46
|
+
|
|
47
|
+
// Wire up action buttons
|
|
48
|
+
gridEl.querySelectorAll(".machine-action-btn").forEach((btn) => {
|
|
49
|
+
btn.addEventListener("click", (e) => {
|
|
50
|
+
e.stopPropagation();
|
|
51
|
+
const action = btn.getAttribute("data-machine-action");
|
|
52
|
+
const name = btn.getAttribute("data-machine-name") || "";
|
|
53
|
+
if (action === "check") machineHealthCheckAction(name);
|
|
54
|
+
else if (action === "remove") confirmMachineRemove(name);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Wire up worker sliders
|
|
59
|
+
gridEl.querySelectorAll(".workers-slider").forEach((slider) => {
|
|
60
|
+
slider.addEventListener("input", (e) => {
|
|
61
|
+
const el = e.target as HTMLInputElement;
|
|
62
|
+
const name = el.getAttribute("data-machine-name") || "";
|
|
63
|
+
updateWorkerCount(name, el.value);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function renderMachineSummary(machines: MachineInfo[]): string {
|
|
69
|
+
let totalMaxWorkers = 0;
|
|
70
|
+
let totalActiveWorkers = 0;
|
|
71
|
+
let onlineCount = 0;
|
|
72
|
+
for (const m of machines) {
|
|
73
|
+
totalMaxWorkers += m.max_workers || 0;
|
|
74
|
+
totalActiveWorkers += m.active_workers || 0;
|
|
75
|
+
if (m.status === "online") onlineCount++;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
`<div class="machines-summary-card"><div class="stat-value">${machines.length}</div><div class="stat-label">Total Machines</div></div>` +
|
|
80
|
+
`<div class="machines-summary-card"><div class="stat-value">${onlineCount}</div><div class="stat-label">Online</div></div>` +
|
|
81
|
+
`<div class="machines-summary-card"><div class="stat-value">${totalActiveWorkers} / ${totalMaxWorkers}</div><div class="stat-label">Active / Max Workers</div></div>`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderMachineCard(machine: MachineInfo): string {
|
|
86
|
+
const name = machine.name || "";
|
|
87
|
+
const host = machine.host || "\u2014";
|
|
88
|
+
const role = machine.role || "worker";
|
|
89
|
+
const status = machine.status || "offline";
|
|
90
|
+
const maxWorkers = machine.max_workers || 4;
|
|
91
|
+
const activeWorkers = machine.active_workers || 0;
|
|
92
|
+
const health = machine.health || {};
|
|
93
|
+
const daemonRunning = health.daemon_running || false;
|
|
94
|
+
const heartbeatCount = health.heartbeat_count || 0;
|
|
95
|
+
const lastHbAge = health.last_heartbeat_s_ago;
|
|
96
|
+
let lastHbText = "\u2014";
|
|
97
|
+
if (typeof lastHbAge === "number" && lastHbAge < 9999) {
|
|
98
|
+
if (lastHbAge < 60) lastHbText = lastHbAge + "s ago";
|
|
99
|
+
else if (lastHbAge < 3600)
|
|
100
|
+
lastHbText = Math.floor(lastHbAge / 60) + "m ago";
|
|
101
|
+
else lastHbText = Math.floor(lastHbAge / 3600) + "h ago";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
`<div class="machine-card" id="machine-card-${escapeHtml(name)}">` +
|
|
106
|
+
`<div class="machine-card-header">` +
|
|
107
|
+
`<span class="presence-dot ${status}"></span>` +
|
|
108
|
+
`<span class="machine-name">${escapeHtml(name)}</span>` +
|
|
109
|
+
`<span class="machine-role">${escapeHtml(role)}</span></div>` +
|
|
110
|
+
`<div class="machine-host">${escapeHtml(host)}</div>` +
|
|
111
|
+
`<div class="machine-workers-section">` +
|
|
112
|
+
`<div class="machine-workers-label-row"><span>Workers</span><span class="workers-count">${activeWorkers} / ${maxWorkers}</span></div>` +
|
|
113
|
+
`<input type="range" class="workers-slider" min="1" max="64" value="${maxWorkers}" data-machine-name="${escapeHtml(name)}" title="Max workers" /></div>` +
|
|
114
|
+
`<div class="machine-health">` +
|
|
115
|
+
`<div class="machine-health-row"><span class="health-label">Daemon</span>` +
|
|
116
|
+
`<span class="health-status ${daemonRunning ? "running" : "stopped"}">${daemonRunning ? "Running" : "Stopped"}</span></div>` +
|
|
117
|
+
`<div class="machine-health-row"><span class="health-label">Heartbeats</span><span class="health-value">${heartbeatCount}</span></div>` +
|
|
118
|
+
`<div class="machine-health-row"><span class="health-label">Last heartbeat</span><span class="health-value">${lastHbText}</span></div></div>` +
|
|
119
|
+
`<div class="machine-card-actions">` +
|
|
120
|
+
`<button class="machine-action-btn" data-machine-action="check" data-machine-name="${escapeHtml(name)}">Check</button>` +
|
|
121
|
+
`<button class="machine-action-btn danger" data-machine-action="remove" data-machine-name="${escapeHtml(name)}">Remove</button></div></div>`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function renderJoinTokens(tokens: JoinToken[]): void {
|
|
126
|
+
const section = document.getElementById("join-tokens-section");
|
|
127
|
+
const list = document.getElementById("join-tokens-list");
|
|
128
|
+
if (!section || !list) return;
|
|
129
|
+
if (!tokens || tokens.length === 0) {
|
|
130
|
+
section.style.display = "none";
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
section.style.display = "";
|
|
134
|
+
|
|
135
|
+
let html = "";
|
|
136
|
+
for (const t of tokens) {
|
|
137
|
+
const label = t.label || "Unlabeled";
|
|
138
|
+
const created = t.created_at
|
|
139
|
+
? new Date(t.created_at).toLocaleDateString()
|
|
140
|
+
: "\u2014";
|
|
141
|
+
const used = t.used ? "Claimed" : "Active";
|
|
142
|
+
const usedClass = t.used ? "c-amber" : "c-green";
|
|
143
|
+
html +=
|
|
144
|
+
`<div class="join-token-row">` +
|
|
145
|
+
`<span class="join-token-label">${escapeHtml(label)}</span>` +
|
|
146
|
+
`<span class="join-token-created">${created}</span>` +
|
|
147
|
+
`<span class="join-token-status ${usedClass}">${used}</span></div>`;
|
|
148
|
+
}
|
|
149
|
+
list.innerHTML = html;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const machinesView: View = {
|
|
153
|
+
init() {
|
|
154
|
+
setupMachinesModals();
|
|
155
|
+
fetchMachinesTab();
|
|
156
|
+
},
|
|
157
|
+
render(_data: FleetState) {
|
|
158
|
+
const cache = store.get("machinesCache");
|
|
159
|
+
if (cache) renderMachinesTab(cache);
|
|
160
|
+
},
|
|
161
|
+
destroy() {},
|
|
162
|
+
};
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
// Metrics tab - success rate, duration, throughput, DORA, costs, stage performance
|
|
2
|
+
|
|
3
|
+
import { store } from "../core/state";
|
|
4
|
+
import {
|
|
5
|
+
escapeHtml,
|
|
6
|
+
fmtNum,
|
|
7
|
+
formatDuration,
|
|
8
|
+
animateValue,
|
|
9
|
+
} from "../core/helpers";
|
|
10
|
+
import { renderSVGDonut } from "../components/charts/donut";
|
|
11
|
+
import { renderSVGBarChart } from "../components/charts/bar";
|
|
12
|
+
import {
|
|
13
|
+
renderSparkline,
|
|
14
|
+
renderSVGLineChart,
|
|
15
|
+
} from "../components/charts/sparkline";
|
|
16
|
+
import { renderDoraGrades } from "../components/charts/pipeline-rail";
|
|
17
|
+
import { STAGES, STAGE_COLORS, STAGE_HEX } from "../design/tokens";
|
|
18
|
+
import * as api from "../core/api";
|
|
19
|
+
import type { FleetState, View, MetricsData } from "../types/api";
|
|
20
|
+
|
|
21
|
+
function fetchMetrics(): void {
|
|
22
|
+
api
|
|
23
|
+
.fetchMetricsHistory()
|
|
24
|
+
.then((data) => {
|
|
25
|
+
store.set("metricsCache", data);
|
|
26
|
+
renderMetrics(data);
|
|
27
|
+
})
|
|
28
|
+
.catch((err) => {
|
|
29
|
+
const el = document.getElementById("metrics-grid");
|
|
30
|
+
if (el)
|
|
31
|
+
el.innerHTML = `<div class="empty-state"><p>Failed to load metrics: ${escapeHtml(String(err))}</p></div>`;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderMetrics(data: MetricsData): void {
|
|
36
|
+
const firstRender = store.get("firstRender");
|
|
37
|
+
|
|
38
|
+
// Success rate donut
|
|
39
|
+
const rate = data.success_rate ?? 0;
|
|
40
|
+
const donutWrap = document.getElementById("metric-donut-wrap");
|
|
41
|
+
if (donutWrap) donutWrap.innerHTML = renderSVGDonut(rate);
|
|
42
|
+
|
|
43
|
+
// Avg duration
|
|
44
|
+
const avgDurEl = document.getElementById("metric-avg-duration");
|
|
45
|
+
if (avgDurEl) avgDurEl.textContent = formatDuration(data.avg_duration_s);
|
|
46
|
+
|
|
47
|
+
// Throughput
|
|
48
|
+
const tp = data.throughput_per_hour ?? 0;
|
|
49
|
+
const tpEl = document.getElementById("metric-throughput");
|
|
50
|
+
if (tpEl) tpEl.textContent = tp.toFixed(2);
|
|
51
|
+
|
|
52
|
+
// Totals
|
|
53
|
+
const totalCompleted = data.total_completed ?? 0;
|
|
54
|
+
const totalFailed = data.total_failed ?? 0;
|
|
55
|
+
const tcEl = document.getElementById("metric-total-completed");
|
|
56
|
+
if (tcEl) {
|
|
57
|
+
if (firstRender && totalCompleted > 0)
|
|
58
|
+
animateValue(tcEl, 0, totalCompleted, 800, "");
|
|
59
|
+
else tcEl.textContent = fmtNum(totalCompleted);
|
|
60
|
+
}
|
|
61
|
+
const failedEl = document.getElementById("metric-total-failed");
|
|
62
|
+
if (failedEl) {
|
|
63
|
+
failedEl.textContent = fmtNum(totalFailed) + " failed";
|
|
64
|
+
failedEl.style.color = totalFailed > 0 ? "var(--rose)" : "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Stage breakdown
|
|
68
|
+
renderStageBreakdown(data.stage_durations || {});
|
|
69
|
+
// Daily chart
|
|
70
|
+
renderDailyChart(data.daily_counts || []);
|
|
71
|
+
// DORA grades
|
|
72
|
+
const doraContainer = document.getElementById("dora-grades-container");
|
|
73
|
+
if (doraContainer && data.dora_grades) {
|
|
74
|
+
doraContainer.innerHTML = renderDoraGrades(
|
|
75
|
+
data.dora_grades as unknown as Record<
|
|
76
|
+
string,
|
|
77
|
+
{ grade: string; value: number; unit: string }
|
|
78
|
+
>,
|
|
79
|
+
);
|
|
80
|
+
doraContainer.style.display = "";
|
|
81
|
+
} else if (doraContainer) {
|
|
82
|
+
doraContainer.style.display = "none";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Cost breakdown/trend
|
|
86
|
+
if (document.getElementById("cost-breakdown-container"))
|
|
87
|
+
renderCostBreakdown();
|
|
88
|
+
if (document.getElementById("cost-trend-container")) renderCostTrend();
|
|
89
|
+
if (document.getElementById("dora-trend-container")) renderDoraTrend();
|
|
90
|
+
if (document.getElementById("stage-performance-container"))
|
|
91
|
+
renderStagePerformance();
|
|
92
|
+
if (document.getElementById("bottleneck-alert-container"))
|
|
93
|
+
renderBottleneckAlert();
|
|
94
|
+
if (document.getElementById("throughput-trend-container"))
|
|
95
|
+
renderThroughputTrend();
|
|
96
|
+
if (document.getElementById("capacity-forecast-container"))
|
|
97
|
+
renderCapacityForecast();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function renderStageBreakdown(stageDurations: Record<string, number>): void {
|
|
101
|
+
const container = document.getElementById("stage-breakdown");
|
|
102
|
+
if (!container) return;
|
|
103
|
+
const keys = Object.keys(stageDurations);
|
|
104
|
+
if (keys.length === 0) {
|
|
105
|
+
container.innerHTML = '<div class="empty-state"><p>No data</p></div>';
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let maxVal = 0;
|
|
110
|
+
for (const k of keys) {
|
|
111
|
+
if (stageDurations[k] > maxVal) maxVal = stageDurations[k];
|
|
112
|
+
}
|
|
113
|
+
if (maxVal === 0) maxVal = 1;
|
|
114
|
+
|
|
115
|
+
let html = "";
|
|
116
|
+
keys.forEach((stage, i) => {
|
|
117
|
+
const val = stageDurations[stage];
|
|
118
|
+
const pct = (val / maxVal) * 100;
|
|
119
|
+
const colorIdx = i % STAGE_COLORS.length;
|
|
120
|
+
html +=
|
|
121
|
+
`<div class="stage-bar-row">` +
|
|
122
|
+
`<span class="stage-bar-label">${escapeHtml(stage)}</span>` +
|
|
123
|
+
`<div class="stage-bar-track-h"><div class="stage-bar-fill-h ${STAGE_COLORS[colorIdx]}" style="width:${pct}%"></div></div>` +
|
|
124
|
+
`<span class="stage-bar-value">${formatDuration(val)}</span></div>`;
|
|
125
|
+
});
|
|
126
|
+
container.innerHTML = html;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function renderDailyChart(dailyCounts: any[]): void {
|
|
130
|
+
const container = document.getElementById("daily-chart");
|
|
131
|
+
if (!container) return;
|
|
132
|
+
if (!dailyCounts || dailyCounts.length === 0) {
|
|
133
|
+
container.innerHTML = '<div class="empty-state"><p>No data</p></div>';
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
container.innerHTML = renderSVGBarChart(dailyCounts);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function renderCostBreakdown(): void {
|
|
140
|
+
const container = document.getElementById("cost-breakdown-container");
|
|
141
|
+
if (!container) return;
|
|
142
|
+
|
|
143
|
+
api
|
|
144
|
+
.fetchCostBreakdown()
|
|
145
|
+
.then((data) => {
|
|
146
|
+
store.set("costBreakdownCache", data as any);
|
|
147
|
+
let html = "";
|
|
148
|
+
|
|
149
|
+
if (data.by_model) {
|
|
150
|
+
html +=
|
|
151
|
+
'<div class="cost-section"><div class="cost-section-label">COST BY MODEL</div>';
|
|
152
|
+
const modelColors: Record<string, string> = {
|
|
153
|
+
opus: "#7c3aed",
|
|
154
|
+
sonnet: "#00d4ff",
|
|
155
|
+
haiku: "#4ade80",
|
|
156
|
+
};
|
|
157
|
+
const models = Object.keys(data.by_model);
|
|
158
|
+
let maxModel = 0;
|
|
159
|
+
models.forEach((m) => {
|
|
160
|
+
if (data.by_model![m] > maxModel) maxModel = data.by_model![m];
|
|
161
|
+
});
|
|
162
|
+
if (maxModel === 0) maxModel = 1;
|
|
163
|
+
models.forEach((m) => {
|
|
164
|
+
const val = data.by_model![m];
|
|
165
|
+
const pct = (val / maxModel) * 100;
|
|
166
|
+
const color = modelColors[m.toLowerCase()] || "#5a6d8a";
|
|
167
|
+
html +=
|
|
168
|
+
`<div class="cost-bar-row"><span class="cost-bar-label">${escapeHtml(m)}</span>` +
|
|
169
|
+
`<div class="cost-bar-track-h"><div class="cost-bar-fill-h" style="width:${pct}%;background:${color}"></div></div>` +
|
|
170
|
+
`<span class="cost-bar-value">$${val.toFixed(2)}</span></div>`;
|
|
171
|
+
});
|
|
172
|
+
html += "</div>";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (data.by_stage) {
|
|
176
|
+
html +=
|
|
177
|
+
'<div class="cost-section"><div class="cost-section-label">COST BY STAGE</div>';
|
|
178
|
+
const stages = Object.keys(data.by_stage);
|
|
179
|
+
let maxStage = 0;
|
|
180
|
+
stages.forEach((s) => {
|
|
181
|
+
if (data.by_stage![s] > maxStage) maxStage = data.by_stage![s];
|
|
182
|
+
});
|
|
183
|
+
if (maxStage === 0) maxStage = 1;
|
|
184
|
+
stages.forEach((s) => {
|
|
185
|
+
const val = data.by_stage![s];
|
|
186
|
+
const pct = (val / maxStage) * 100;
|
|
187
|
+
const barColor =
|
|
188
|
+
(STAGE_HEX as Record<string, string>)[s] || "#5a6d8a";
|
|
189
|
+
html +=
|
|
190
|
+
`<div class="cost-bar-row"><span class="cost-bar-label">${escapeHtml(s)}</span>` +
|
|
191
|
+
`<div class="cost-bar-track-h"><div class="cost-bar-fill-h" style="width:${pct}%;background:${barColor}"></div></div>` +
|
|
192
|
+
`<span class="cost-bar-value">$${val.toFixed(2)}</span></div>`;
|
|
193
|
+
});
|
|
194
|
+
html += "</div>";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (data.by_issue?.length) {
|
|
198
|
+
html +=
|
|
199
|
+
'<div class="cost-section"><div class="cost-section-label">COST PER ISSUE</div>';
|
|
200
|
+
html +=
|
|
201
|
+
'<table class="cost-issue-table"><thead><tr><th>Issue</th><th>Cost</th></tr></thead><tbody>';
|
|
202
|
+
const sorted = [...data.by_issue].sort(
|
|
203
|
+
(a, b) => (b.cost || 0) - (a.cost || 0),
|
|
204
|
+
);
|
|
205
|
+
sorted.forEach((item) => {
|
|
206
|
+
html += `<tr><td>#${item.issue}</td><td>$${(item.cost || 0).toFixed(2)}</td></tr>`;
|
|
207
|
+
});
|
|
208
|
+
html += "</tbody></table></div>";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (data.budget != null && data.spent != null) {
|
|
212
|
+
const budgetPct =
|
|
213
|
+
data.budget > 0 ? Math.min((data.spent / data.budget) * 100, 100) : 0;
|
|
214
|
+
const budgetClass =
|
|
215
|
+
budgetPct >= 80
|
|
216
|
+
? "cost-over"
|
|
217
|
+
: budgetPct >= 60
|
|
218
|
+
? "cost-warn"
|
|
219
|
+
: "cost-ok";
|
|
220
|
+
html +=
|
|
221
|
+
`<div class="cost-section"><div class="cost-section-label">BUDGET UTILIZATION</div>` +
|
|
222
|
+
`<div class="budget-util-bar"><div class="cost-bar-track"><div class="cost-bar-fill ${budgetClass}" style="width:${budgetPct.toFixed(0)}%"></div></div>` +
|
|
223
|
+
`<span class="budget-util-text">$${data.spent.toFixed(2)} / $${data.budget.toFixed(2)} (${budgetPct.toFixed(0)}%)</span></div></div>`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
container.innerHTML =
|
|
227
|
+
html || '<div class="empty-state"><p>No cost data</p></div>';
|
|
228
|
+
})
|
|
229
|
+
.catch((err) => {
|
|
230
|
+
container.innerHTML = `<div class="empty-state"><p>Failed to load: ${escapeHtml(String(err))}</p></div>`;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function renderCostTrend(): void {
|
|
235
|
+
const container = document.getElementById("cost-trend-container");
|
|
236
|
+
if (!container) return;
|
|
237
|
+
api
|
|
238
|
+
.fetchCostTrend()
|
|
239
|
+
.then((data) => {
|
|
240
|
+
const points = data.points || [];
|
|
241
|
+
if (points.length === 0) {
|
|
242
|
+
container.innerHTML =
|
|
243
|
+
'<div class="empty-state"><p>No trend data</p></div>';
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
container.innerHTML = renderSVGLineChart(
|
|
247
|
+
points,
|
|
248
|
+
"cost",
|
|
249
|
+
"#00d4ff",
|
|
250
|
+
300,
|
|
251
|
+
100,
|
|
252
|
+
);
|
|
253
|
+
})
|
|
254
|
+
.catch((err) => {
|
|
255
|
+
container.innerHTML = `<div class="empty-state"><p>Failed to load: ${escapeHtml(String(err))}</p></div>`;
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function renderDoraTrend(): void {
|
|
260
|
+
const container = document.getElementById("dora-trend-container");
|
|
261
|
+
if (!container) return;
|
|
262
|
+
api
|
|
263
|
+
.fetchDoraTrend()
|
|
264
|
+
.then((data) => {
|
|
265
|
+
const metrics = [
|
|
266
|
+
{ key: "deploy_freq", label: "Deploy Freq", color: "#00d4ff" },
|
|
267
|
+
{ key: "lead_time", label: "Lead Time", color: "#0066ff" },
|
|
268
|
+
{ key: "cfr", label: "Change Fail Rate", color: "#f43f5e" },
|
|
269
|
+
{ key: "mttr", label: "MTTR", color: "#4ade80" },
|
|
270
|
+
];
|
|
271
|
+
let html = '<div class="dora-trend-grid">';
|
|
272
|
+
for (const m of metrics) {
|
|
273
|
+
const points = data[m.key] || [];
|
|
274
|
+
html += `<div class="dora-trend-card"><span class="dora-trend-label">${escapeHtml(m.label)}</span>`;
|
|
275
|
+
html +=
|
|
276
|
+
points.length > 0
|
|
277
|
+
? renderSparkline(
|
|
278
|
+
points as Array<number | { value: number }>,
|
|
279
|
+
m.color,
|
|
280
|
+
120,
|
|
281
|
+
30,
|
|
282
|
+
)
|
|
283
|
+
: '<span class="dora-trend-empty">\u2014</span>';
|
|
284
|
+
html += "</div>";
|
|
285
|
+
}
|
|
286
|
+
html += "</div>";
|
|
287
|
+
container.innerHTML = html;
|
|
288
|
+
})
|
|
289
|
+
.catch((err) => {
|
|
290
|
+
container.innerHTML = `<div class="empty-state"><p>Failed to load: ${escapeHtml(String(err))}</p></div>`;
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function renderStagePerformance(): void {
|
|
295
|
+
const container = document.getElementById("stage-performance-container");
|
|
296
|
+
if (!container) return;
|
|
297
|
+
api
|
|
298
|
+
.fetchStagePerformance()
|
|
299
|
+
.then((data) => {
|
|
300
|
+
const stages = data.stages || [];
|
|
301
|
+
if (stages.length === 0) {
|
|
302
|
+
container.innerHTML =
|
|
303
|
+
'<div class="empty-state"><p>No stage performance data</p></div>';
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
let html =
|
|
307
|
+
'<table class="stage-perf-table"><thead><tr><th>Stage</th><th>Avg</th><th>Min</th><th>Max</th><th>Count</th><th>Trend</th></tr></thead><tbody>';
|
|
308
|
+
for (const s of stages) {
|
|
309
|
+
let trendArrow = "";
|
|
310
|
+
if (s.trend_pct != null) {
|
|
311
|
+
if (s.trend_pct > 5)
|
|
312
|
+
trendArrow = `<span class="trend-up">\u2191 ${s.trend_pct.toFixed(0)}%</span>`;
|
|
313
|
+
else if (s.trend_pct < -5)
|
|
314
|
+
trendArrow = `<span class="trend-down">\u2193 ${Math.abs(s.trend_pct).toFixed(0)}%</span>`;
|
|
315
|
+
else trendArrow = '<span class="trend-flat">\u2192</span>';
|
|
316
|
+
}
|
|
317
|
+
html +=
|
|
318
|
+
`<tr><td>${escapeHtml(s.name || s.stage || "")}</td><td>${formatDuration(s.avg_s)}</td><td>${formatDuration(s.min_s)}</td>` +
|
|
319
|
+
`<td>${formatDuration(s.max_s)}</td><td>${s.count || 0}</td><td>${trendArrow}</td></tr>`;
|
|
320
|
+
}
|
|
321
|
+
html += "</tbody></table>";
|
|
322
|
+
container.innerHTML = html;
|
|
323
|
+
})
|
|
324
|
+
.catch((err) => {
|
|
325
|
+
container.innerHTML = `<div class="empty-state"><p>Failed to load: ${escapeHtml(String(err))}</p></div>`;
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function renderBottleneckAlert(): void {
|
|
330
|
+
const container = document.getElementById("bottleneck-alert-container");
|
|
331
|
+
if (!container) return;
|
|
332
|
+
api
|
|
333
|
+
.fetchBottlenecks()
|
|
334
|
+
.then((data) => {
|
|
335
|
+
const bottlenecks = data.bottlenecks || [];
|
|
336
|
+
if (bottlenecks.length === 0) {
|
|
337
|
+
container.innerHTML = "";
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
let html = "";
|
|
341
|
+
for (const b of bottlenecks) {
|
|
342
|
+
if (b.impact === "low") continue;
|
|
343
|
+
const msg =
|
|
344
|
+
escapeHtml(b.stage || "Unknown") +
|
|
345
|
+
" stage averages " +
|
|
346
|
+
formatDuration(b.avgDuration) +
|
|
347
|
+
" (" +
|
|
348
|
+
escapeHtml(b.impact) +
|
|
349
|
+
" impact)";
|
|
350
|
+
const suggestion = b.suggestion
|
|
351
|
+
? `<div class="bottleneck-suggestion">${escapeHtml(b.suggestion)}</div>`
|
|
352
|
+
: "";
|
|
353
|
+
html += `<div class="bottleneck-alert"><span class="bottleneck-icon">\u26A0</span><span class="bottleneck-msg">${msg}</span>${suggestion}</div>`;
|
|
354
|
+
}
|
|
355
|
+
container.innerHTML = html;
|
|
356
|
+
})
|
|
357
|
+
.catch(() => {
|
|
358
|
+
container.innerHTML = "";
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function renderThroughputTrend(): void {
|
|
363
|
+
const container = document.getElementById("throughput-trend-container");
|
|
364
|
+
if (!container) return;
|
|
365
|
+
api
|
|
366
|
+
.fetchThroughputTrend()
|
|
367
|
+
.then((data) => {
|
|
368
|
+
const points = data.points || [];
|
|
369
|
+
if (points.length === 0) {
|
|
370
|
+
container.innerHTML =
|
|
371
|
+
'<div class="empty-state"><p>No throughput data</p></div>';
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
container.innerHTML = renderSVGLineChart(
|
|
375
|
+
points,
|
|
376
|
+
"throughput",
|
|
377
|
+
"#4ade80",
|
|
378
|
+
300,
|
|
379
|
+
100,
|
|
380
|
+
);
|
|
381
|
+
})
|
|
382
|
+
.catch((err) => {
|
|
383
|
+
container.innerHTML = `<div class="empty-state"><p>Failed to load: ${escapeHtml(String(err))}</p></div>`;
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function renderCapacityForecast(): void {
|
|
388
|
+
const container = document.getElementById("capacity-forecast-container");
|
|
389
|
+
if (!container) return;
|
|
390
|
+
api
|
|
391
|
+
.fetchCapacity()
|
|
392
|
+
.then((data) => {
|
|
393
|
+
if (!data.rate && !data.queue_clear_hours) {
|
|
394
|
+
container.innerHTML =
|
|
395
|
+
'<div class="empty-state"><p>No capacity data</p></div>';
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const rate = data.rate != null ? data.rate.toFixed(1) : "?";
|
|
399
|
+
const clearTime =
|
|
400
|
+
data.queue_clear_hours != null
|
|
401
|
+
? data.queue_clear_hours.toFixed(1)
|
|
402
|
+
: "?";
|
|
403
|
+
container.innerHTML = `<div class="capacity-forecast"><span class="capacity-text">At current rate (${rate}/hr), queue will clear in <strong>${clearTime} hours</strong></span></div>`;
|
|
404
|
+
})
|
|
405
|
+
.catch((err) => {
|
|
406
|
+
container.innerHTML = `<div class="empty-state"><p>Failed to load: ${escapeHtml(String(err))}</p></div>`;
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export const metricsView: View = {
|
|
411
|
+
init() {
|
|
412
|
+
fetchMetrics();
|
|
413
|
+
},
|
|
414
|
+
render(_data: FleetState) {
|
|
415
|
+
// Metrics use cached data; don't re-fetch on every WS push
|
|
416
|
+
const cache = store.get("metricsCache");
|
|
417
|
+
if (cache) renderMetrics(cache);
|
|
418
|
+
},
|
|
419
|
+
destroy() {},
|
|
420
|
+
};
|