519 lines
18 KiB
JavaScript
519 lines
18 KiB
JavaScript
// GitTally 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");
|
||
}
|
||
|
||
// ---- shared infrastructure -------------------------------------------------
|
||
|
||
const FETCH_TIMEOUT_MS = 8000;
|
||
const TABLE_POLL_MS = 10000;
|
||
const CURRENT_POLL_MS = 3000;
|
||
const SYSTEM_POLL_MS = 60000;
|
||
|
||
function metaContent(name) {
|
||
const element = document.querySelector(`meta[name="${name}"]`);
|
||
return element ? element.content : "";
|
||
}
|
||
|
||
const controlToken = metaContent("gittally-control-token");
|
||
const giteaRepoUrl = metaContent("gittally-gitea-repo-url");
|
||
|
||
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();
|
||
}
|
||
|
||
async function sendAction(url, method) {
|
||
const response = await fetch(url, {
|
||
method,
|
||
headers: { "X-GitTally-Token": controlToken },
|
||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error("HTTP " + response.status);
|
||
}
|
||
}
|
||
|
||
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 visible again. */
|
||
function startPolling(refresh, intervalMs) {
|
||
let timer = null;
|
||
const tick = () => {
|
||
refresh()
|
||
.then(() => setLiveIndicator(true, "last update " + formatTimestamp(new Date().toISOString())))
|
||
.catch((error) => setLiveIndicator(false, String(error)));
|
||
};
|
||
const start = () => {
|
||
if (timer === null) {
|
||
tick();
|
||
timer = setInterval(tick, intervalMs);
|
||
}
|
||
};
|
||
const stop = () => {
|
||
if (timer !== null) {
|
||
clearInterval(timer);
|
||
timer = null;
|
||
}
|
||
};
|
||
document.addEventListener("visibilitychange", () => (document.hidden ? stop() : start()));
|
||
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;
|
||
}
|
||
|
||
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) {
|
||
const row = document.createElement("tr");
|
||
row.dataset.artifactKey = build.artifactKey || "";
|
||
row.dataset.branch = build.branch;
|
||
row.dataset.startedAt = build.startedAt || "";
|
||
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), build.branch)
|
||
: elem("span", null, build.branch),
|
||
);
|
||
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", "duration-cell", formatDuration(build.durationSeconds));
|
||
durationCell.dataset.label = "Duration";
|
||
row.appendChild(durationCell);
|
||
|
||
const artifactsCell = elem("td");
|
||
artifactsCell.dataset.label = "Artifacts";
|
||
if (build.artifactKey) {
|
||
const artifactLink = elem("a", "artifact-link", "📄");
|
||
artifactLink.href = "/builds/" + encodeURIComponent(build.artifactKey);
|
||
artifactLink.title = "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 (!build.artifactKey && !build.latestGreenUrl) {
|
||
artifactsCell.textContent = "n/a";
|
||
}
|
||
row.appendChild(artifactsCell);
|
||
|
||
const actionsCell = elem("td", "actions-cell");
|
||
const actions = elem("div", "actions");
|
||
if (allowRestart) {
|
||
actions.appendChild(actionButton("↻", "Restart build", null, { action: "restart", branch: build.branch }));
|
||
}
|
||
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";
|
||
|
||
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)));
|
||
}
|
||
|
||
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.status = build.status || "running";
|
||
|
||
const header = elem("header", "build-card-header");
|
||
header.appendChild(statusBadge(build.status || "running"));
|
||
const branchTools = elem("span", "branch link-tools");
|
||
branchTools.appendChild(
|
||
giteaRepoUrl
|
||
? externalLink(giteaRepoUrl + "/src/branch/" + encodeBranchPath(build.branch), build.branch)
|
||
: elem("span", null, build.branch),
|
||
);
|
||
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", "duration-cell running-duration"));
|
||
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 = `/api/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;
|
||
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 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);
|
||
});
|
||
});
|
||
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", metrics.diskTotalGib != null ? formatMetric(metrics.diskTotalGib) + " GiB" : "n/a");
|
||
setText("info-updated", formatTimeOfDay(metrics.timestamp));
|
||
}
|
||
|
||
startPolling(refresh, SYSTEM_POLL_MS);
|
||
}
|
||
|
||
// ---- running-duration ticking ------------------------------------------------
|
||
|
||
function tickRunningDurations() {
|
||
const now = Date.now();
|
||
document.querySelectorAll("[data-started-at]").forEach((element) => {
|
||
const status = element.dataset.status;
|
||
if (status !== "running" && status !== "pending") {
|
||
return;
|
||
}
|
||
const startedAt = new Date(element.dataset.startedAt).getTime();
|
||
const durationCell = element.querySelector(".duration-cell");
|
||
if (durationCell && !Number.isNaN(startedAt)) {
|
||
durationCell.textContent = formatDuration((now - startedAt) / 1000);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---- 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") {
|
||
await sendAction("/api/builds/restart?branch=" + encodeURIComponent(button.dataset.branch), "POST");
|
||
} else if (action === "cancel") {
|
||
await sendAction(`/api/builds/${encodeURIComponent(button.dataset.artifactKey)}/cancel`, "POST");
|
||
} else if (action === "delete") {
|
||
await sendAction("/api/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();
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---- page wiring ---------------------------------------------------------------
|
||
|
||
initBuildsTable();
|
||
initCurrentBuilds();
|
||
initSystemTable();
|
||
initReloadButton();
|
||
setInterval(tickRunningDurations, 1000);
|
||
tickRunningDurations();
|