The system page showed the disk of the host volume, not the budget the instance can actually fill — on a Hostsharing Managed Webspace that is a group quota, tighter than the volume by an order of magnitude, so the warn/critical highlighting could never fire before a build failed with "Disk quota exceeded". DiskQuota parses `quota -u -g --no-wrap --raw-grace` and picks the tightest of the user quota, the group quota, and the volume itself; SystemMetricsCollector reports whichever binds, resets the disk min/max/avg when the binding source changes, and the system page names the source in its info line. A host without a binding quota (Docker hosts, developer machines) renders exactly as before. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
787 lines
29 KiB
JavaScript
787 lines
29 KiB
JavaScript
// Werkator web UI — polls the JSON API and re-renders table bodies from data.
|
||
// Every fetch has a timeout and failures render an explicit error badge, so the
|
||
// UI can never get stuck on a loading animation (the legacy defect).
|
||
"use strict";
|
||
|
||
// ---- pure helpers ----------------------------------------------------------
|
||
|
||
function formatDuration(totalSeconds) {
|
||
if (totalSeconds == null || Number.isNaN(totalSeconds) || totalSeconds < 0) {
|
||
return "";
|
||
}
|
||
const seconds = Math.floor(totalSeconds);
|
||
const two = (n) => String(n).padStart(2, "0");
|
||
const hours = Math.floor(seconds / 3600);
|
||
const minutes = Math.floor((seconds % 3600) / 60);
|
||
const rest = seconds % 60;
|
||
return hours > 0 ? `${hours}:${two(minutes)}:${two(rest)}` : `${minutes}:${two(rest)}`;
|
||
}
|
||
|
||
function formatTimestamp(iso) {
|
||
if (!iso) {
|
||
return "";
|
||
}
|
||
const date = new Date(iso);
|
||
if (Number.isNaN(date.getTime())) {
|
||
return iso;
|
||
}
|
||
const two = (n) => String(n).padStart(2, "0");
|
||
return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}` +
|
||
` ${two(date.getHours())}:${two(date.getMinutes())}`;
|
||
}
|
||
|
||
function abbrevCommit(commit) {
|
||
return (commit || "").slice(0, 12);
|
||
}
|
||
|
||
/** Two-decimal metric value like `UiFormats.metric`; "n/a" when the source is unavailable. */
|
||
function formatMetric(value) {
|
||
return typeof value === "number" && Number.isFinite(value) ? value.toFixed(2) : "n/a";
|
||
}
|
||
|
||
function formatTimeOfDay(iso) {
|
||
const date = new Date(iso);
|
||
if (!iso || Number.isNaN(date.getTime())) {
|
||
return "n/a";
|
||
}
|
||
const two = (n) => String(n).padStart(2, "0");
|
||
return `${two(date.getHours())}:${two(date.getMinutes())}:${two(date.getSeconds())}`;
|
||
}
|
||
|
||
const KNOWN_STATUSES = new Set(
|
||
["success", "failed", "running", "pending", "interrupted", "cancelled", "unknown", "error", "finished"],
|
||
);
|
||
|
||
function statusCssClass(status) {
|
||
return "status status-" + (KNOWN_STATUSES.has(status) ? status : "unknown");
|
||
}
|
||
|
||
/** Seconds elapsed since `startedAtIso`, or null when it is missing/invalid. */
|
||
function elapsedSeconds(startedAtIso) {
|
||
const startedAt = new Date(startedAtIso || "").getTime();
|
||
return Number.isNaN(startedAt) ? null : (Date.now() - startedAt) / 1000;
|
||
}
|
||
|
||
/**
|
||
* The duration to display for a build, computed at render time so re-rendered
|
||
* rows never show an empty cell that the ticker fills back in (visible flicker):
|
||
* the recorded build time once finished, the live build time (since the build
|
||
* left the queue) while running, and the wait time while pending — the latter
|
||
* is styled italic via `duration-wait` to distinguish it from build time.
|
||
*/
|
||
function displayDurationSeconds(build) {
|
||
if (build.status === "running") {
|
||
return elapsedSeconds(build.runningSince || build.startedAt);
|
||
}
|
||
if (build.status === "pending") {
|
||
return elapsedSeconds(build.startedAt);
|
||
}
|
||
return build.durationSeconds;
|
||
}
|
||
|
||
function durationCellClass(status) {
|
||
return "duration-cell" + (status === "pending" ? " duration-wait" : "");
|
||
}
|
||
|
||
// ---- shared infrastructure -------------------------------------------------
|
||
|
||
const FETCH_TIMEOUT_MS = 8000;
|
||
const TABLE_POLL_MS = 10000;
|
||
const CURRENT_POLL_MS = 3000;
|
||
const SYSTEM_POLL_MS = 60000;
|
||
// resume events (visibilitychange, pageshow, focus) often arrive in pairs
|
||
const RESUME_MIN_GAP_MS = 1000;
|
||
|
||
function metaContent(name) {
|
||
const element = document.querySelector(`meta[name="${name}"]`);
|
||
return element ? element.content : "";
|
||
}
|
||
|
||
const giteaRepoUrl = metaContent("werkator-gitea-repo-url");
|
||
|
||
// Empty with one served repository, `/repos/<name>` with several (ADR 0009). Every
|
||
// path this script builds itself is prefixed with it, so an action triggered on a
|
||
// repository's page acts on that repository — the paths rendered into the DOM
|
||
// (`data-api`, artifact links) already carry it.
|
||
const repoBase = metaContent("werkator-repo-base") || "";
|
||
|
||
/** The API of the repository this page belongs to; `/api` when only one is served. */
|
||
function apiBase() {
|
||
return repoBase ? "/api" + repoBase : "/api";
|
||
}
|
||
|
||
// The control token is deliberately NOT embedded in the pages: reading them is
|
||
// unauthenticated, so anyone could have read it out of the HTML. The operator
|
||
// pastes it once per browser from `.git/werkator/control-token` on the server;
|
||
// it is kept in localStorage and only ever sent as a request header.
|
||
const CONTROL_TOKEN_KEY = "werkator.controlToken";
|
||
|
||
// The key was named after the old product name, so the rename left every browser
|
||
// with a token under a name nothing reads any more — a secret that not even
|
||
// "forget token" can reach. Dropped on load; the token itself is unchanged on the
|
||
// server, so the one re-entry per browser is all the rename costs.
|
||
try {
|
||
window.localStorage.removeItem("gittally.controlToken");
|
||
} catch (error) {
|
||
// localStorage unavailable (private mode, blocked cookies) — nothing stored, nothing to drop
|
||
}
|
||
|
||
function storedControlToken() {
|
||
try {
|
||
return window.localStorage.getItem(CONTROL_TOKEN_KEY) || "";
|
||
} catch (error) {
|
||
return ""; // localStorage unavailable (private mode, blocked cookies)
|
||
}
|
||
}
|
||
|
||
function rememberControlToken(token) {
|
||
try {
|
||
window.localStorage.setItem(CONTROL_TOKEN_KEY, token);
|
||
} catch (error) {
|
||
// not persistable — the token is asked for again on the next action
|
||
}
|
||
}
|
||
|
||
function forgetControlToken() {
|
||
try {
|
||
window.localStorage.removeItem(CONTROL_TOKEN_KEY);
|
||
} catch (error) {
|
||
// nothing to clean up when localStorage is unavailable
|
||
}
|
||
}
|
||
|
||
function askForControlToken() {
|
||
const answer = window.prompt(
|
||
"Control token — the content of .git/werkator/control-token on the Werkator host:",
|
||
"",
|
||
);
|
||
return answer ? answer.trim() : "";
|
||
}
|
||
|
||
async function fetchJson(url) {
|
||
const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
||
if (!response.ok) {
|
||
throw new Error("HTTP " + response.status);
|
||
}
|
||
return response.json();
|
||
}
|
||
|
||
/** A rejected token is dropped and asked for once more, so a stale one is not a dead end. */
|
||
async function sendAction(url, method) {
|
||
let token = storedControlToken() || askForControlToken();
|
||
if (!token) {
|
||
throw new Error("no control token");
|
||
}
|
||
let response = await sendWithToken(url, method, token);
|
||
if (response.status === 403) {
|
||
forgetControlToken();
|
||
token = askForControlToken();
|
||
if (!token) {
|
||
throw new Error("wrong control token");
|
||
}
|
||
response = await sendWithToken(url, method, token);
|
||
}
|
||
if (!response.ok) {
|
||
throw new Error("HTTP " + response.status);
|
||
}
|
||
rememberControlToken(token);
|
||
}
|
||
|
||
function sendWithToken(url, method, token) {
|
||
return fetch(url, {
|
||
method,
|
||
headers: { "X-werkator-Token": token },
|
||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||
});
|
||
}
|
||
|
||
function setLiveIndicator(ok, detail) {
|
||
const indicator = document.getElementById("live-indicator");
|
||
if (!indicator) {
|
||
return;
|
||
}
|
||
indicator.className = ok ? "status status-success" : "status status-error";
|
||
indicator.textContent = ok ? "live" : "error";
|
||
indicator.title = detail || "";
|
||
document.querySelectorAll("tbody").forEach((tbody) => tbody.classList.toggle("is-stale", !ok));
|
||
}
|
||
|
||
// The page's refresh function; actions trigger it for an immediate update.
|
||
let refreshNow = null;
|
||
|
||
/**
|
||
* Polls `refresh`; paused while the tab is hidden, refreshed immediately when the page
|
||
* becomes visible again — the durations of running builds are ticked client-side, so a
|
||
* page returning from the background would otherwise keep counting up a build that has
|
||
* long finished on the server.
|
||
*
|
||
* Resuming re-arms the interval unconditionally instead of only when the page was
|
||
* stopped: a backgrounded page (phone, app switch) may be frozen without ever
|
||
* delivering the hidden event, and its throttled interval then fires whenever the
|
||
* browser feels like it. `pageshow` and `focus` are listened to as well, because not
|
||
* every browser reports a returning page as a visibility change.
|
||
*/
|
||
function startPolling(refresh, intervalMs) {
|
||
let timer = null;
|
||
let lastTickAt = 0;
|
||
const tick = () => {
|
||
lastTickAt = Date.now();
|
||
refresh()
|
||
.then(() => setLiveIndicator(true, "last update " + formatTimestamp(new Date().toISOString())))
|
||
.catch((error) => setLiveIndicator(false, String(error)));
|
||
// separate request, deliberately not chained: whether the watcher is healthy must
|
||
// not depend on this view's refresh, nor delay it
|
||
refreshWatcherBanner();
|
||
};
|
||
const start = () => {
|
||
stop();
|
||
// several resume events can arrive together; one fetch is enough for all of them
|
||
if (Date.now() - lastTickAt >= RESUME_MIN_GAP_MS) {
|
||
tick();
|
||
}
|
||
timer = setInterval(tick, intervalMs);
|
||
};
|
||
const stop = () => {
|
||
if (timer !== null) {
|
||
clearInterval(timer);
|
||
timer = null;
|
||
}
|
||
};
|
||
const resume = () => {
|
||
if (!document.hidden) {
|
||
start();
|
||
}
|
||
};
|
||
document.addEventListener("visibilitychange", () => (document.hidden ? stop() : start()));
|
||
window.addEventListener("pageshow", resume);
|
||
window.addEventListener("focus", resume);
|
||
refreshNow = tick;
|
||
start();
|
||
}
|
||
|
||
// ---- DOM building (textContent only — data can never inject markup) --------
|
||
|
||
function elem(tag, className, text) {
|
||
const element = document.createElement(tag);
|
||
if (className) {
|
||
element.className = className;
|
||
}
|
||
if (text != null) {
|
||
element.textContent = text;
|
||
}
|
||
return element;
|
||
}
|
||
|
||
/**
|
||
* What the watcher's health means for the page in front of the reader, or null while
|
||
* nothing is wrong. Three different failures, one message: what you see is not current.
|
||
*
|
||
* This is not the `live-indicator`, which says whether *this browser* reaches the server.
|
||
* A watcher that cannot fetch leaves the server perfectly reachable and every row stale,
|
||
* which is exactly the outage that went unnoticed for 57 minutes on 2026-08-30.
|
||
*/
|
||
function watcherBannerText(state) {
|
||
const since = state.lastPollAt ? " Last attempt " + formatTimestamp(state.lastPollAt) + "." : "";
|
||
if (state.running === false) {
|
||
return ["watcher stopped", "No branch is being polled; nothing below will change." + since];
|
||
}
|
||
if (state.lastFetchError) {
|
||
return ["origin unreachable", "The list below is not updating." + since + " " + state.lastFetchError];
|
||
}
|
||
if (state.lastPollError) {
|
||
return ["poll cycle failed", "The list below may be incomplete." + since + " " + state.lastPollError];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Never rejects: an unreachable server is the live indicator's business, not the banner's. */
|
||
async function refreshWatcherBanner() {
|
||
const banner = document.getElementById("watcher-banner");
|
||
if (!banner) {
|
||
return;
|
||
}
|
||
let state;
|
||
try {
|
||
state = await fetchJson("/api/watcher");
|
||
} catch (error) {
|
||
// leave the banner as it stands rather than claiming health we could not confirm
|
||
return;
|
||
}
|
||
const text = watcherBannerText(state);
|
||
banner.replaceChildren();
|
||
banner.hidden = text === null;
|
||
if (text) {
|
||
banner.append(elem("strong", null, text[0]), elem("span", null, text[1]));
|
||
}
|
||
}
|
||
|
||
function externalLink(href, text) {
|
||
const anchor = elem("a", null, text);
|
||
anchor.href = href;
|
||
anchor.target = "_blank";
|
||
anchor.rel = "noopener noreferrer";
|
||
return anchor;
|
||
}
|
||
|
||
function copyButton(value, label) {
|
||
const button = elem("button", "copy-button", "⧉");
|
||
button.type = "button";
|
||
button.dataset.copy = value;
|
||
button.title = "Copy " + label;
|
||
button.setAttribute("aria-label", "Copy " + label);
|
||
return button;
|
||
}
|
||
|
||
function statusBadge(status) {
|
||
return elem("span", statusCssClass(status), status);
|
||
}
|
||
|
||
function actionButton(symbol, title, className, dataset) {
|
||
const button = elem("button", "action-button" + (className ? " " + className : ""), symbol);
|
||
button.type = "button";
|
||
button.title = title;
|
||
button.setAttribute("aria-label", title);
|
||
Object.assign(button.dataset, dataset);
|
||
return button;
|
||
}
|
||
|
||
// ---- latest/history table --------------------------------------------------
|
||
|
||
function renderBuildRow(build, allowRestart, restartAtOriginHead) {
|
||
const row = document.createElement("tr");
|
||
const displayName = build.name || build.branch;
|
||
row.dataset.artifactKey = build.artifactKey || "";
|
||
row.dataset.branch = displayName;
|
||
row.dataset.startedAt = build.startedAt || "";
|
||
row.dataset.runningSince = build.runningSince || "";
|
||
row.dataset.status = build.status || "unknown";
|
||
|
||
const statusCell = elem("td");
|
||
statusCell.dataset.label = "Status";
|
||
statusCell.appendChild(statusBadge(build.status || "unknown"));
|
||
row.appendChild(statusCell);
|
||
|
||
const branchCell = elem("td", "branch");
|
||
branchCell.dataset.label = "Branch";
|
||
const branchTools = elem("span", "link-tools");
|
||
branchTools.appendChild(
|
||
giteaRepoUrl
|
||
? externalLink(giteaRepoUrl + "/src/branch/" + encodeBranchPath(build.branch), displayName)
|
||
: elem("span", null, displayName),
|
||
);
|
||
branchTools.appendChild(copyButton(build.branch, "branch name"));
|
||
branchCell.appendChild(branchTools);
|
||
row.appendChild(branchCell);
|
||
|
||
const commitCell = elem("td");
|
||
commitCell.dataset.label = "Commit";
|
||
const commitTools = elem("span", "link-tools");
|
||
const commitCode = elem("code");
|
||
commitCode.appendChild(
|
||
giteaRepoUrl
|
||
? externalLink(giteaRepoUrl + "/commit/" + encodeURIComponent(build.commit), abbrevCommit(build.commit))
|
||
: elem("span", null, abbrevCommit(build.commit)),
|
||
);
|
||
commitTools.appendChild(commitCode);
|
||
commitTools.appendChild(copyButton(build.commit, "full commit ID"));
|
||
commitCell.appendChild(commitTools);
|
||
row.appendChild(commitCell);
|
||
|
||
const startedCell = elem("td", null, formatTimestamp(build.startedAt));
|
||
startedCell.dataset.label = "Started";
|
||
row.appendChild(startedCell);
|
||
|
||
const durationCell = elem("td", durationCellClass(build.status), formatDuration(displayDurationSeconds(build)));
|
||
durationCell.dataset.label = "Duration";
|
||
row.appendChild(durationCell);
|
||
|
||
const artifactsCell = elem("td");
|
||
artifactsCell.dataset.label = "Artifacts";
|
||
const inProgress = build.status === "running" || build.status === "pending";
|
||
if (build.artifactKey) {
|
||
const artifactLink = elem("a", "artifact-link", inProgress ? "⏳" : "📄");
|
||
artifactLink.href = repoBase + "/builds/" + encodeURIComponent(build.artifactKey);
|
||
artifactLink.title = inProgress ? "Open build log — no artifacts yet" : "Open artifacts";
|
||
artifactsCell.appendChild(artifactLink);
|
||
}
|
||
if (build.latestGreenUrl) {
|
||
const permanentLink = elem("a", "artifact-link", "🔗");
|
||
permanentLink.href = build.latestGreenUrl;
|
||
permanentLink.title = "Permanent link: artifacts of the latest green build";
|
||
artifactsCell.appendChild(permanentLink);
|
||
}
|
||
if (inProgress) {
|
||
const liveLink = elem("a", "artifact-link", "📡");
|
||
liveLink.href = "/current";
|
||
liveLink.title = "Watch this build live";
|
||
artifactsCell.appendChild(liveLink);
|
||
}
|
||
if (!build.artifactKey && !build.latestGreenUrl && !inProgress) {
|
||
artifactsCell.textContent = "n/a";
|
||
}
|
||
row.appendChild(artifactsCell);
|
||
|
||
const actionsCell = elem("td", "actions-cell");
|
||
const actions = elem("div", "actions");
|
||
if (allowRestart) {
|
||
// the branches view builds the branch as it is now, the other views repeat a run
|
||
const restartTitle = restartAtOriginHead ? "Build current head" : "Restart build";
|
||
actions.appendChild(
|
||
actionButton("↻", restartTitle, null, {
|
||
action: "restart",
|
||
branch: displayName,
|
||
atOriginHead: restartAtOriginHead ? "true" : "false",
|
||
}),
|
||
);
|
||
}
|
||
if (build.artifactKey) {
|
||
actions.appendChild(
|
||
actionButton("×", "Delete stored build", "delete-button", {
|
||
action: "delete",
|
||
artifactKey: build.artifactKey,
|
||
}),
|
||
);
|
||
}
|
||
actionsCell.appendChild(actions);
|
||
row.appendChild(actionsCell);
|
||
return row;
|
||
}
|
||
|
||
function encodeBranchPath(branch) {
|
||
return (branch || "").split("/").map(encodeURIComponent).join("/");
|
||
}
|
||
|
||
function initBuildsTable() {
|
||
const table = document.getElementById("builds-table");
|
||
if (!table) {
|
||
return;
|
||
}
|
||
const tbody = document.getElementById("build-rows");
|
||
const allowRestart = table.dataset.allowRestart === "true";
|
||
const restartAtOriginHead = table.dataset.restartAtOriginHead === "true";
|
||
|
||
async function refresh() {
|
||
const builds = await fetchJson(table.dataset.api);
|
||
tbody.replaceChildren();
|
||
if (builds.length === 0) {
|
||
const cell = elem("td", "empty", table.dataset.emptyMessage || "No builds recorded yet.");
|
||
cell.colSpan = 7;
|
||
tbody.appendChild(elem("tr")).appendChild(cell);
|
||
return;
|
||
}
|
||
builds.forEach((build) => tbody.appendChild(renderBuildRow(build, allowRestart, restartAtOriginHead)));
|
||
}
|
||
|
||
startPolling(refresh, TABLE_POLL_MS);
|
||
}
|
||
|
||
// ---- current builds with live logs ------------------------------------------
|
||
|
||
function renderBuildCard(build) {
|
||
const card = elem("section", "build-card");
|
||
card.dataset.artifactKey = build.artifactKey;
|
||
card.dataset.startedAt = build.startedAt || "";
|
||
card.dataset.runningSince = build.runningSince || "";
|
||
card.dataset.status = build.status || "running";
|
||
|
||
const header = elem("header", "build-card-header");
|
||
header.appendChild(statusBadge(build.status || "running"));
|
||
const branchTools = elem("span", "branch link-tools");
|
||
const displayName = build.name || build.branch;
|
||
branchTools.appendChild(
|
||
giteaRepoUrl
|
||
? externalLink(giteaRepoUrl + "/src/branch/" + encodeBranchPath(build.branch), displayName)
|
||
: elem("span", null, displayName),
|
||
);
|
||
header.appendChild(branchTools);
|
||
const commitCode = elem("code");
|
||
commitCode.appendChild(
|
||
giteaRepoUrl
|
||
? externalLink(giteaRepoUrl + "/commit/" + encodeURIComponent(build.commit), abbrevCommit(build.commit))
|
||
: elem("span", null, abbrevCommit(build.commit)),
|
||
);
|
||
header.appendChild(commitCode);
|
||
header.appendChild(elem("span", "muted", "started " + formatTimestamp(build.startedAt)));
|
||
header.appendChild(
|
||
elem("span", durationCellClass(build.status) + " running-duration", formatDuration(displayDurationSeconds(build))),
|
||
);
|
||
const cardActions = elem("span", "build-card-actions");
|
||
cardActions.appendChild(
|
||
actionButton("× Cancel", "Cancel build", "cancel-button", {
|
||
action: "cancel",
|
||
artifactKey: build.artifactKey,
|
||
}),
|
||
);
|
||
header.appendChild(cardActions);
|
||
card.appendChild(header);
|
||
card.appendChild(elem("pre", "live-log"));
|
||
return card;
|
||
}
|
||
|
||
/** A build that left the current list is finished — link to its result instead of dropping the card. */
|
||
function markCardFinished(card) {
|
||
if (card.dataset.status === "finished") {
|
||
return;
|
||
}
|
||
card.dataset.status = "finished";
|
||
const badge = card.querySelector(".status");
|
||
badge.className = statusCssClass("finished");
|
||
badge.textContent = "finished";
|
||
card.querySelector(".cancel-button")?.remove();
|
||
const resultNote = elem("p", "build-card-result");
|
||
const resultLink = elem("a", null, "View result and artifacts");
|
||
resultLink.href = "/builds/" + encodeURIComponent(card.dataset.artifactKey);
|
||
resultNote.appendChild(resultLink);
|
||
card.querySelector(".live-log").before(resultNote);
|
||
}
|
||
|
||
function initCurrentBuilds() {
|
||
const container = document.getElementById("current-builds");
|
||
if (!container) {
|
||
return;
|
||
}
|
||
const noCurrent = document.getElementById("no-current");
|
||
const logOffsets = new Map();
|
||
|
||
function cardFor(artifactKey) {
|
||
return container.querySelector(`.build-card[data-artifact-key="${CSS.escape(artifactKey)}"]`);
|
||
}
|
||
|
||
async function appendLogTail(build) {
|
||
const pre = cardFor(build.artifactKey)?.querySelector(".live-log");
|
||
if (!pre) {
|
||
return;
|
||
}
|
||
const offset = logOffsets.get(build.artifactKey) || 0;
|
||
const url = `${apiBase()}/builds/current/${encodeURIComponent(build.artifactKey)}/log?offset=${offset}`;
|
||
const tail = await fetchJson(url);
|
||
logOffsets.set(build.artifactKey, tail.nextOffset);
|
||
if (tail.content) {
|
||
const nearBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;
|
||
pre.append(tail.content);
|
||
if (nearBottom) {
|
||
pre.scrollTop = pre.scrollHeight;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function refresh() {
|
||
const builds = await fetchJson(container.dataset.api);
|
||
const activeKeys = new Set(builds.map((build) => build.artifactKey));
|
||
builds.forEach((build) => {
|
||
let card = cardFor(build.artifactKey);
|
||
if (!card) {
|
||
card = container.appendChild(renderBuildCard(build));
|
||
} else {
|
||
card.dataset.status = build.status;
|
||
card.dataset.runningSince = build.runningSince || card.dataset.runningSince;
|
||
const badge = card.querySelector(".status");
|
||
badge.className = statusCssClass(build.status);
|
||
badge.textContent = build.status;
|
||
}
|
||
});
|
||
container.querySelectorAll(".build-card").forEach((card) => {
|
||
if (!activeKeys.has(card.dataset.artifactKey)) {
|
||
markCardFinished(card);
|
||
}
|
||
});
|
||
if (noCurrent) {
|
||
noCurrent.style.display = container.querySelector(".build-card") ? "none" : "";
|
||
}
|
||
await Promise.all(builds.map(appendLogTail));
|
||
}
|
||
|
||
startPolling(refresh, CURRENT_POLL_MS);
|
||
}
|
||
|
||
// ---- system metrics ----------------------------------------------------------
|
||
|
||
/** The total each utilization metric is compared against for the critical highlighting. */
|
||
const UTILIZATION_TOTALS = { cpuUsed: "cpuCount", ramUsedGib: "ramTotalGib", diskUsedGib: "diskTotalGib" };
|
||
|
||
/** Mirrors UiFormats.diskTotal exactly, since both render the same field from GET /api/system. */
|
||
function formatDiskTotal(metrics) {
|
||
if (metrics.diskTotalGib == null) {
|
||
return "n/a";
|
||
}
|
||
const totalText = formatMetric(metrics.diskTotalGib) + " GiB";
|
||
const source = metrics.diskSource;
|
||
if (!source || source.kind === "volume") {
|
||
return metrics.quotasPresent ? totalText + " (volume, tighter than the quotas)" : totalText;
|
||
}
|
||
const hardLimit = formatMetric(source.hardLimitGib);
|
||
return totalText + " (" + source.kind + " quota " + source.subject + ", hard limit " + hardLimit + " GiB)";
|
||
}
|
||
|
||
/** Same thresholds as UiFormats.utilizationClass: warn from 80% of the total, critical from 90%. */
|
||
function utilizationClass(used, total) {
|
||
if (used == null || total == null || !(total > 0)) {
|
||
return "";
|
||
}
|
||
const ratio = used / total;
|
||
if (ratio >= 0.90) {
|
||
return "metric-crit";
|
||
}
|
||
if (ratio >= 0.80) {
|
||
return "metric-warn";
|
||
}
|
||
return "";
|
||
}
|
||
|
||
/** The metric rows are fixed, so only the cell texts are updated — never rebuilt. */
|
||
function initSystemTable() {
|
||
const table = document.getElementById("system-table");
|
||
if (!table) {
|
||
return;
|
||
}
|
||
|
||
function setText(id, text) {
|
||
const element = document.getElementById(id);
|
||
if (element) {
|
||
element.textContent = text;
|
||
}
|
||
}
|
||
|
||
async function refresh() {
|
||
const metrics = await fetchJson(table.dataset.api);
|
||
table.querySelectorAll("tbody tr[data-metric]").forEach((row) => {
|
||
const aggregate = metrics[row.dataset.metric];
|
||
row.querySelectorAll("[data-field]").forEach((cell) => {
|
||
cell.textContent = formatMetric(aggregate ? aggregate[cell.dataset.field] : null);
|
||
});
|
||
const currentCell = row.querySelector('[data-field="current"]');
|
||
if (currentCell) {
|
||
const totalField = UTILIZATION_TOTALS[row.dataset.metric];
|
||
const cssClass = totalField
|
||
? utilizationClass(aggregate ? aggregate.current : null, metrics[totalField])
|
||
: "";
|
||
currentCell.classList.toggle("metric-warn", cssClass === "metric-warn");
|
||
currentCell.classList.toggle("metric-crit", cssClass === "metric-crit");
|
||
}
|
||
});
|
||
setText("info-cpu-count", metrics.cpuCount != null ? metrics.cpuCount + " cores" : "n/a");
|
||
setText("info-ram-total", metrics.ramTotalGib != null ? formatMetric(metrics.ramTotalGib) + " GiB" : "n/a");
|
||
setText("info-disk-total", formatDiskTotal(metrics));
|
||
setText("info-updated", formatTimeOfDay(metrics.timestamp));
|
||
}
|
||
|
||
startPolling(refresh, SYSTEM_POLL_MS);
|
||
}
|
||
|
||
// ---- running-duration ticking ------------------------------------------------
|
||
|
||
function tickRunningDurations() {
|
||
document.querySelectorAll("[data-started-at]").forEach((element) => {
|
||
const status = element.dataset.status;
|
||
if (status !== "running" && status !== "pending") {
|
||
return;
|
||
}
|
||
// running: live build time since leaving the queue; pending: wait time (italic)
|
||
const basisIso = status === "running"
|
||
? (element.dataset.runningSince || element.dataset.startedAt)
|
||
: element.dataset.startedAt;
|
||
const elapsed = elapsedSeconds(basisIso);
|
||
const durationCell = element.querySelector(".duration-cell");
|
||
if (durationCell && elapsed != null) {
|
||
durationCell.textContent = formatDuration(elapsed);
|
||
durationCell.classList.toggle("duration-wait", status === "pending");
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---- event delegation for copy and action buttons -----------------------------
|
||
|
||
document.addEventListener("click", (event) => {
|
||
const button = event.target.closest(".copy-button");
|
||
if (!button || !navigator.clipboard) {
|
||
return;
|
||
}
|
||
navigator.clipboard.writeText(button.dataset.copy || "").then(() => {
|
||
button.classList.add("is-copied");
|
||
setTimeout(() => button.classList.remove("is-copied"), 1200);
|
||
});
|
||
});
|
||
|
||
document.addEventListener("click", async (event) => {
|
||
const button = event.target.closest("[data-action]");
|
||
if (!button) {
|
||
return;
|
||
}
|
||
const action = button.dataset.action;
|
||
if (action === "delete" && !window.confirm("Delete this build result and its stored artifacts?")) {
|
||
return;
|
||
}
|
||
button.disabled = true;
|
||
try {
|
||
if (action === "restart") {
|
||
const atOriginHead = button.dataset.atOriginHead === "true" ? "&atOriginHead=true" : "";
|
||
await sendAction(apiBase() + "/builds/restart?branch=" + encodeURIComponent(button.dataset.branch) + atOriginHead, "POST");
|
||
} else if (action === "cancel") {
|
||
await sendAction(`${apiBase()}/builds/${encodeURIComponent(button.dataset.artifactKey)}/cancel`, "POST");
|
||
} else if (action === "delete") {
|
||
await sendAction(apiBase() + "/builds/" + encodeURIComponent(button.dataset.artifactKey), "DELETE");
|
||
}
|
||
if (refreshNow) {
|
||
refreshNow();
|
||
}
|
||
} catch (error) {
|
||
setLiveIndicator(false, String(error));
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
});
|
||
|
||
// ---- reload button -------------------------------------------------------------
|
||
|
||
/** Refreshes via the page's poller; pages without one (e.g. artifact index) reload fully. */
|
||
function initReloadButton() {
|
||
const button = document.getElementById("reload-button");
|
||
if (!button) {
|
||
return;
|
||
}
|
||
button.addEventListener("animationend", () => button.classList.remove("is-reloading"));
|
||
button.addEventListener("click", () => {
|
||
button.classList.add("is-reloading");
|
||
if (refreshNow) {
|
||
refreshNow();
|
||
} else {
|
||
window.location.reload();
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---- repository switcher -------------------------------------------------------
|
||
|
||
/**
|
||
* Switching the repository keeps the view: an option's value is the repository root
|
||
* (`/repos/<name>`), and the view this page shows is appended to it. A page that only
|
||
* exists for one repository's build — an artifact index — falls back to that
|
||
* repository's latest builds.
|
||
*/
|
||
function initRepoSelect() {
|
||
const select = document.getElementById("repo-select");
|
||
if (!select) {
|
||
return;
|
||
}
|
||
const path = window.location.pathname;
|
||
const view = repoBase && path.startsWith(repoBase) ? path.slice(repoBase.length) : path;
|
||
const keptView = ["/branches", "/history", "/current"].includes(view) ? view : "";
|
||
select.addEventListener("change", () => {
|
||
window.location.href = select.value + keptView;
|
||
});
|
||
}
|
||
|
||
// ---- page wiring ---------------------------------------------------------------
|
||
|
||
initBuildsTable();
|
||
initCurrentBuilds();
|
||
initSystemTable();
|
||
initReloadButton();
|
||
initRepoSelect();
|
||
// pages with a poller update the banner from their own tick; the static ones ask once
|
||
if (!refreshNow) {
|
||
refreshWatcherBanner();
|
||
}
|
||
setInterval(tickRunningDurations, 1000);
|
||
tickRunningDurations();
|