implemented 08-web-ui.md: added Thymeleaf-based web UI: templates for builds, artifacts, current builds, and fragments; static assets for favicon, CSS, and JavaScript
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="GitTally">
|
||||
<rect width="64" height="64" rx="14" fill="#155eef"/>
|
||||
<path d="M17 47V18m0 14h13c7 0 10-4 10-11" fill="none" stroke="#f9fafb" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="17" cy="18" r="5" fill="#DD4901"/>
|
||||
<circle cx="17" cy="47" r="5" fill="#DD4901"/>
|
||||
<circle cx="40" cy="21" r="5" fill="#DD4901"/>
|
||||
<path d="M44 35v14M51 35v14M58 35v14M43 47h16" fill="none" stroke="#f9fafb" stroke-width="4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 564 B |
@@ -0,0 +1,147 @@
|
||||
/* GitTally web UI — loosely ported from the legacy generated pages. */
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f6f8fa;
|
||||
--panel: #ffffff;
|
||||
--text: #1f2937;
|
||||
--muted: #6b7280;
|
||||
--border: #d7dde5;
|
||||
--row: #f9fafb;
|
||||
--link: #155eef;
|
||||
--success-bg: #dcfce7;
|
||||
--success-text: #166534;
|
||||
--failed-bg: #fee2e2;
|
||||
--failed-text: #991b1b;
|
||||
--running-bg: #dbeafe;
|
||||
--running-text: #1d4ed8;
|
||||
--pending-bg: #ede9fe;
|
||||
--pending-text: #5b21b6;
|
||||
--interrupted-bg: #ffedd5;
|
||||
--interrupted-text: #9a3412;
|
||||
--cancelled-bg: #e5e7eb;
|
||||
--cancelled-text: #374151;
|
||||
--unknown-bg: #f3f4f6;
|
||||
--unknown-text: #4b5563;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #111827;
|
||||
--panel: #1f2937;
|
||||
--text: #f3f4f6;
|
||||
--muted: #9ca3af;
|
||||
--border: #374151;
|
||||
--row: #182235;
|
||||
--link: #93c5fd;
|
||||
--success-bg: #12351f;
|
||||
--success-text: #86efac;
|
||||
--failed-bg: #3f1717;
|
||||
--failed-text: #fca5a5;
|
||||
--running-bg: #112c55;
|
||||
--running-text: #93c5fd;
|
||||
--pending-bg: #2e1065;
|
||||
--pending-text: #c4b5fd;
|
||||
--interrupted-bg: #431f0b;
|
||||
--interrupted-text: #fdba74;
|
||||
--cancelled-bg: #374151;
|
||||
--cancelled-text: #d1d5db;
|
||||
--unknown-bg: #374151;
|
||||
--unknown-text: #d1d5db;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
main { width: min(1180px, calc(100% - 32px)); margin: 32px auto; }
|
||||
h1 { display: flex; align-items: center; gap: 10px; margin: 0 0 18px; font-size: 28px; font-weight: 700; }
|
||||
h1 img { width: 32px; height: 32px; flex: none; }
|
||||
h1 .repo-name { color: var(--muted); font-size: 18px; font-weight: 400; align-self: flex-end; }
|
||||
h2 { margin: 20px 0 10px; font-size: 18px; }
|
||||
.title-home { display: inline-flex; flex: none; }
|
||||
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }
|
||||
a { color: var(--link); font-weight: 650; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
/* view toggle nav + live indicator */
|
||||
.view-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 18px; }
|
||||
.view-row-actions { margin-left: auto; }
|
||||
.view-toggle { display: inline-flex; gap: 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--panel); }
|
||||
.view-toggle a, .view-toggle span { display: inline-flex; min-width: 88px; justify-content: center; padding: 7px 12px; font-weight: 700; }
|
||||
.view-toggle span { background: var(--link); color: white; }
|
||||
.view-toggle a { color: var(--link); }
|
||||
.view-toggle a:hover { background: color-mix(in srgb, var(--link) 8%, transparent); text-decoration: none; }
|
||||
|
||||
/* build tables */
|
||||
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); box-shadow: 0 12px 28px rgb(15 23 42 / 0.08); }
|
||||
table { width: 100%; border-collapse: collapse; min-width: 900px; }
|
||||
th, td { padding: 12px 14px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--border); }
|
||||
th { position: sticky; top: 0; background: var(--panel); color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; }
|
||||
tbody tr:nth-child(even) { background: var(--row); }
|
||||
tbody tr:last-child td { border-bottom: 0; }
|
||||
tbody tr:hover { background: color-mix(in srgb, var(--link) 8%, transparent); }
|
||||
tbody.is-stale { opacity: 0.55; }
|
||||
.branch { font-weight: 650; }
|
||||
.duration-cell { white-space: nowrap; }
|
||||
.empty { padding: 28px 14px; color: var(--muted); text-align: center; }
|
||||
|
||||
/* status badges */
|
||||
.status { display: inline-flex; align-items: center; min-width: 72px; justify-content: center; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; text-transform: uppercase; }
|
||||
.status-success { background: var(--success-bg); color: var(--success-text); }
|
||||
.status-failed, .status-error { background: var(--failed-bg); color: var(--failed-text); }
|
||||
.status-running { background: var(--running-bg); color: var(--running-text); }
|
||||
.status-pending { background: var(--pending-bg); color: var(--pending-text); }
|
||||
.status-interrupted { background: var(--interrupted-bg); color: var(--interrupted-text); }
|
||||
.status-cancelled { background: var(--cancelled-bg); color: var(--cancelled-text); }
|
||||
.status-unknown, .status-finished { background: var(--unknown-bg); color: var(--unknown-text); }
|
||||
|
||||
/* copy buttons and links with tools */
|
||||
.link-tools { display: inline-flex; align-items: center; gap: 5px; max-width: 100%; }
|
||||
.copy-button { appearance: none; display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: var(--muted); font: 14px/1 system-ui, sans-serif; cursor: pointer; }
|
||||
.copy-button:hover { border-color: var(--border); background: color-mix(in srgb, var(--link) 8%, transparent); color: var(--link); }
|
||||
.copy-button.is-copied { color: var(--success-text); }
|
||||
.artifact-link { display: inline-flex; align-items: center; justify-content: center; }
|
||||
|
||||
/* row actions */
|
||||
.actions-column, .actions-cell { width: 96px; min-width: 96px; }
|
||||
.actions { display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
|
||||
.action-button { appearance: none; display: inline-flex; align-items: center; justify-content: center; min-width: 30px; height: 30px; padding: 0 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--link); font: 18px/1 system-ui, sans-serif; cursor: pointer; }
|
||||
.action-button:hover { background: color-mix(in srgb, var(--link) 8%, transparent); }
|
||||
.action-button:disabled { color: var(--muted); cursor: default; }
|
||||
.delete-button, .cancel-button { color: var(--failed-text); }
|
||||
.cancel-button { font-size: 13px; font-weight: 700; }
|
||||
|
||||
/* current-build cards */
|
||||
.empty-panel { padding: 28px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); color: var(--muted); text-align: center; }
|
||||
.build-card { margin: 0 0 18px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); box-shadow: 0 12px 28px rgb(15 23 42 / 0.08); overflow: hidden; }
|
||||
.build-card-header { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--border); }
|
||||
.build-card-actions { margin-left: auto; }
|
||||
.build-card-result { padding: 10px 14px; border-bottom: 1px solid var(--border); }
|
||||
.live-log { margin: 0; padding: 12px 14px; max-height: 420px; overflow: auto; background: var(--bg); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
|
||||
/* artifact index */
|
||||
.panel { border: 1px solid var(--border); border-radius: 8px; background: var(--panel); padding: 8px 22px 22px; box-shadow: 0 12px 28px rgb(15 23 42 / 0.08); }
|
||||
.build-facts { list-style: none; margin: 0; padding: 0; }
|
||||
.build-facts li { margin: 0 0 8px; }
|
||||
|
||||
/* footer */
|
||||
.site-footer { width: min(1180px, calc(100% - 32px)); margin: 24px auto 32px; color: var(--muted); font-size: 12px; }
|
||||
|
||||
/* small screens: stack table rows as cards, like legacy */
|
||||
@media (max-width: 680px) {
|
||||
main { margin: 16px auto; }
|
||||
h1 { font-size: 22px; }
|
||||
.view-toggle a, .view-toggle span { min-width: 0; padding: 6px 9px; font-size: 13px; }
|
||||
.table-wrap { overflow-x: visible; border: none; border-radius: 0; background: transparent; box-shadow: none; }
|
||||
table, thead, tbody, tr, td { display: block; }
|
||||
table { min-width: 0; }
|
||||
thead { display: none; }
|
||||
tbody { display: flex; flex-direction: column; gap: 12px; }
|
||||
tbody tr { border: 1px solid var(--border); border-radius: 10px; background: var(--panel); overflow: hidden; box-shadow: 0 2px 8px rgb(15 23 42 / 0.07); }
|
||||
tbody tr:nth-child(even) { background: var(--panel); }
|
||||
td { display: flex; align-items: center; gap: 10px; padding: 10px 14px; }
|
||||
td + td { border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); }
|
||||
td[data-label]::before { content: attr(data-label); width: 90px; flex-shrink: 0; font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--muted); }
|
||||
.actions-cell { justify-content: center; background: color-mix(in srgb, var(--border) 18%, transparent); width: auto; }
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
// 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);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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);
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
|
||||
// ---- 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;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- page wiring ---------------------------------------------------------------
|
||||
|
||||
initBuildsTable();
|
||||
initCurrentBuilds();
|
||||
setInterval(tickRunningDurations, 1000);
|
||||
tickRunningDurations();
|
||||
Reference in New Issue
Block a user