Menu
AkurAI-Build
publicLatest change 30e6b8c347048d64c2c048dfd05a8ded219d37f6 - Host authenticated Git repositories over Smart HTTP with repo host/sync CLI by Ólafur Búi Ólafsson
const $ = (id, root = document) => root.getElementById(id);
const state = { selected: null };
const element = (tag, text, className) => {
const node = document.createElement(tag);
if (text !== undefined) node.textContent = text;
if (className) node.className = className;
return node;
};
const statusBadge = value => {
const node = element("span", value || "unknown", "pt-status");
node.dataset.status = String(value || "unknown").replace(/[^a-z]/g, "");
return node;
}
const when = value => value ? new Date(value * 1000).toLocaleString() : "—";
const shortCommit = value => (value || "pending").slice(0, 10);
const repositoryName = root => root?.dataset.repository || "";
function toast(message, kind = "info") {
let region = $("pt-toasts");
if (!region) {
region = element("div");
region.id = "pt-toasts";
region.className = "pt-toast-region";
region.setAttribute("aria-live", "polite");
document.body.append(region);
}
const item = element("div", undefined, "pt-toast");
item.dataset.kind = kind;
item.setAttribute("role", "status");
const copy = element("span", message);
const close = element("button", "×");
close.type = "button";
close.setAttribute("aria-label", "Dismiss notification");
close.onclick = () => item.remove();
item.append(copy, close);
region.append(item);
window.setTimeout(() => item.remove(), kind === "error" ? 7000 : 4200);
}
async function api(path, options = {}) {
const response = await fetch(path, {
...options,
headers: { ...(options.body ? { "Content-Type": "application/json" } : {}), ...(options.headers || {}) }
});
const body = await response.json().catch(() => ({ ok: false, error: { message: response.statusText } }));
if (!response.ok || !body.ok) throw new Error(body.error?.message || `HTTP ${response.status}`);
return body.data;
}
function queryPath(path, values) {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(values || {})) {
if (value !== undefined && value !== null && value !== "") query.set(key, value);
}
const encoded = query.toString();
return encoded ? `${path}?${encoded}` : path;
}
function setHealth(value = "online") {
const node = $("health");
if (node) node.textContent = value;
}
function startPolling(load) {
const refresh = () => load().catch(error => {
setHealth("unavailable");
toast(error.message, "error");
});
refresh();
window.setInterval(refresh, 5000);
}
function fillRepositorySelect(select, repositories, selected = "") {
if (!select) return;
const value = selected || select.value;
select.replaceChildren();
const all = element("option", "All repositories");
all.value = "";
select.append(all);
for (const repository of repositories) {
const option = element("option", repository.name);
option.value = repository.name;
option.selected = repository.name === value;
select.append(option);
}
if (value) select.value = value;
}
function updateMetrics(data) {
const metrics = {
"m-projects": data.repositories.length,
"m-running": data.runs.filter(run => ["queued", "running", "waiting"].includes(run.status)).length,
"m-passed": data.runs.filter(run => run.status === "succeeded").length,
"m-failed": data.runs.filter(run => ["failed", "interrupted", "canceled"].includes(run.status)).length,
"m-queued": data.queued_runs ?? data.runs.filter(run => run.status === "queued").length
};
for (const [id, value] of Object.entries(metrics)) if ($(id)) $(id).textContent = value;
}
function renderRuns(runs, target, onSelect, { repositoryColumn = true } = {}) {
if (!target) return;
target.replaceChildren();
for (const run of runs) {
const row = element("tr");
row.dataset.id = run.id;
const runCell = element("td");
const link = element("a", `#${run.id}`, "pt-table-link");
link.href = `/app/run/${encodeURIComponent(run.id)}`;
link.onclick = event => { event.preventDefault(); onSelect?.(run.id); };
runCell.append(link);
const repositoryCell = element("td", run.repository);
const statusCell = element("td");
statusCell.append(statusBadge(run.status));
row.append(runCell);
if (repositoryColumn) row.append(repositoryCell);
row.append(statusCell, element("td", run.git_ref), element("td", shortCommit(run.commit_sha), "pt-commit"), element("td", when(run.created_at)));
target.append(row);
}
const empty = target.closest(".pt-panel")?.querySelector(".pt-empty");
if (empty) empty.hidden = runs.length > 0;
}
function renderRunDetail(run, target, controls = {}) {
if (!target) return;
const content = document.createDocumentFragment();
const title = element("div", undefined, "pt-run-title");
const left = element("div");
left.append(element("h3", `#${run.id} ${run.repository}`), element("div", `${run.git_ref} · ${shortCommit(run.commit_sha)}`, "pt-commit"));
title.append(left, statusBadge(run.status));
content.append(title);
if (run.error) content.append(element("p", run.error, "pt-notice pt-error"));
if (run.status === "waiting") content.append(element("p", "A protected environment is waiting for promotion.", "pt-notice"));
for (const job of run.jobs) {
const card = element("section", undefined, "pt-job");
const head = element("div", undefined, "pt-job-head");
const meta = element("div");
meta.append(element("strong", job.name), element("div", [job.executor, job.image, job.platform].filter(Boolean).join(" · "), "pt-job-meta"));
head.append(meta, statusBadge(job.status));
card.append(head);
if (job.logs) card.append(element("pre", job.logs));
if (job.status === "waiting" && job.environment) {
const wrap = element("div", undefined, "pt-panel-body");
const button = element("button", `Promote ${job.environment}`, "pt-button pt-primary");
button.type = "button";
button.onclick = () => runAction(button, async () => {
await api(`/api/runs/${run.id}/promote/${encodeURIComponent(job.environment)}`, { method: "POST", body: "{}" });
toast(`Run #${run.id} promoted to ${job.environment}.`);
await controls.reload?.();
});
wrap.append(button);
card.append(wrap);
}
content.append(card);
}
if (run.artifacts.length) {
content.append(element("h3", "Artifacts"));
const list = element("div", undefined, "pt-artifacts");
for (const artifact of run.artifacts) {
const row = element("div", undefined, "pt-artifact");
const info = element("div");
info.append(element("div", artifact.name), element("div", `${artifact.bytes} bytes · ${artifact.sha256.slice(0, 12)}`, "pt-job-meta"));
const button = element("button", "Download", "pt-button pt-small");
button.type = "button";
button.onclick = () => download(artifact.id, artifact.name);
row.append(info, button);
list.append(row);
}
content.append(list);
}
if (run.deployments.length) {
content.append(element("h3", "Deployments"));
for (const deployment of run.deployments) {
const row = element("div", undefined, "pt-artifact");
row.append(element("span", deployment.environment), statusBadge(deployment.status));
content.append(row);
}
}
target.replaceChildren(content);
if (controls.retry) {
controls.retry.hidden = false;
controls.retry.onclick = () => runAction(controls.retry, async () => {
await api(`/api/runs/${run.id}/retry`, { method: "POST", body: "{}" });
toast(`Retry queued for run #${run.id}.`);
await controls.reload?.();
});
}
}
async function selectRun(id, target, controls = {}) {
state.selected = Number(id);
try {
const run = await api(`/api/runs/${id}`);
renderRunDetail(run, target, controls);
} catch (error) {
toast(error.message, "error");
}
}
async function download(id, name) {
try {
const response = await fetch(`/api/artifacts/${id}`);
if (!response.ok) throw new Error("Download failed");
const link = document.createElement("a");
link.href = URL.createObjectURL(await response.blob());
link.download = name.split("/").pop();
link.click();
window.setTimeout(() => URL.revokeObjectURL(link.href), 1000);
} catch (error) { toast(error.message, "error"); }
}
async function runAction(button, action) {
button.disabled = true;
try { await action(); } catch (error) { toast(error.message, "error"); } finally { button.disabled = false; }
}
async function stateData(values = {}) {
return api(queryPath("/api/state", { limit: 50, ...values }));
}
function wireRepositoryForm(reload) {
const form = $("repository-form");
if (!form) return;
form.onsubmit = event => {
event.preventDefault();
runAction(form.querySelector("button[type=submit]"), async () => {
const repository = await api("/api/repos", { method: "POST", body: JSON.stringify({ name: $("repository-name").value.trim(), url: $("repository-url").value.trim(), branch: $("repository-branch").value.trim() }) });
form.reset();
$("repository-branch").value = "main";
toast(`${repository.name} registered.`);
await reload?.(repository.name);
});
};
}
async function loadDashboard() {
const data = await stateData();
setHealth("online");
updateMetrics(data);
renderRuns(data.runs.slice(0, 12), $("runs"), id => selectRun(id, $("detail"), { retry: $("retry"), reload: loadDashboard }));
if (state.selected) await selectRun(state.selected, $("detail"), { retry: $("retry"), reload: loadDashboard });
}
function wireDashboard() {
wireRepositoryForm(preferred => {
if (preferred) window.location.href = `/app/repositories/${encodeURIComponent(preferred)}`;
});
startPolling(loadDashboard);
}
async function loadRepositories() {
const data = await stateData();
setHealth("online");
const search = $("repository-search")?.value.trim().toLowerCase() || "";
const filtered = data.repositories.filter(repository => [repository.name, repository.default_branch, repository.url, repository.visibility].some(value => value.toLowerCase().includes(search)));
const target = $("repositories-list");
target.replaceChildren();
for (const repository of filtered) {
const row = element("tr");
const name = element("td");
const link = element("a", repository.name); link.href = `/app/repositories/${encodeURIComponent(repository.name)}`;
name.append(link);
const sourceCell = element("td");
const sourceLink = element("a", repository.url);
sourceLink.href = repository.url;
sourceLink.rel = "noreferrer";
const visibility = element("span", repository.visibility, "pt-badge");
visibility.dataset.visibility = repository.visibility;
sourceCell.append(sourceLink);
row.append(name, element("td", repository.default_branch), element("td", undefined), sourceCell, element("td", when(repository.created_at)));
row.children[2].append(visibility);
target.append(row);
}
$("repositories-empty").hidden = filtered.length > 0;
fillRepositorySelect($("run-filter-repo"), data.repositories);
}
function wireRepositories() {
wireRepositoryForm(preferred => {
if (preferred) window.location.href = `/app/repositories/${encodeURIComponent(preferred)}`;
});
$("repository-search")?.addEventListener("input", () => loadRepositories().catch(error => toast(error.message, "error")));
loadRepositories().catch(error => { setHealth("unavailable"); toast(error.message, "error"); });
}
function computeStats(runs) {
if (!runs.length) return { total: 0, rate: "—", last: "—" };
const succeeded = runs.filter(r => r.status === "succeeded").length;
const total = runs.length;
const rate = total ? `${Math.round((succeeded / total) * 100)}%` : "—";
const finished = runs.filter(r => r.finished_at).sort((a, b) => b.finished_at - a.finished_at);
const last = finished.length ? new Date(finished[0].finished_at * 1000).toLocaleString() : "—";
return { total, rate, last };
}
function updateStats(runs) {
const stats = computeStats(runs);
const totalEl = document.getElementById("stat-total");
const rateEl = document.getElementById("stat-rate");
const lastEl = document.getElementById("stat-last");
if (totalEl) totalEl.textContent = stats.total;
if (rateEl) rateEl.textContent = stats.rate;
if (lastEl) lastEl.textContent = stats.last;
}
function fileIcon(kind) {
if (kind === "tree") return '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"/></svg>';
return '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm8.5-.25v2.75c0 .414.336.75.75.75h2.75Z"/></svg>';
}
async function loadRepoTree(repository, reference) {
const listEl = $("repo-file-list");
const commitEl = $("repo-latest-commit");
if (!listEl) return;
try {
const query = reference ? `?ref=${encodeURIComponent(reference)}` : "";
const data = await api(`/api/repos/${encodeURIComponent(repository)}/tree${query}`);
if ($("count-commits")) $("count-commits").textContent = data.counts.commits.toLocaleString();
if ($("count-branches")) $("count-branches").textContent = data.counts.branches;
if ($("count-tags")) $("count-tags").textContent = data.counts.tags;
// Latest commit
if (data.latest_commit) {
const c = data.latest_commit;
commitEl.innerHTML = "";
const author = element("span", c.author, "pt-commit-author");
const msg = element("span", c.message, "pt-commit-msg");
const sha = element("code", c.sha.slice(0, 8), "pt-commit");
const age = element("span", when(c.committed_at), "pt-commit-age");
commitEl.append(author, msg, sha, age);
} else {
commitEl.innerHTML = '<span class="pt-muted">No commits</span>';
}
// File list
listEl.replaceChildren();
for (const entry of data.entries) {
const row = element("div", undefined, "pt-repo-file-row");
const name = element("div", undefined, "pt-repo-file-name");
name.dataset.kind = entry.kind;
name.innerHTML = fileIcon(entry.kind);
name.append(element("span", entry.name));
const msg = element("div", entry.commit_message || "", "pt-repo-file-msg");
const age = element("div", entry.commit_at ? when(entry.commit_at) : "", "pt-repo-file-age");
row.append(name, msg, age);
listEl.append(row);
}
// README
const readmeWrap = $("repo-readme-wrap");
if (data.readme) {
$("repo-readme").textContent = data.readme;
$("repo-readme-name").textContent = data.readme_name || "README";
readmeWrap.hidden = false;
} else if (readmeWrap) {
readmeWrap.hidden = true;
}
} catch (error) {
commitEl.innerHTML = `<span class="pt-muted">Unable to load tree: ${error.message}</span>`;
listEl.replaceChildren();
}
}
async function loadRepositoryDetail() {
const root = document.querySelector(".pt-app-shell[data-repository]");
const repository = repositoryName(root);
const data = await stateData({ repo: repository, limit: 200 });
setHealth("online");
const runs = (data.runs || []).filter(run => run.repository === repository);
renderRuns(runs, $("repository-runs-all"), id => window.location.assign(`/app/run/${encodeURIComponent(id)}`), { repositoryColumn: false });
if ($("repository-runs-all-empty")) $("repository-runs-all-empty").hidden = runs.length > 0;
updateStats(runs);
}
function wireRepositoryDetail() {
const root = document.querySelector(".pt-app-shell[data-repository]");
const repository = repositoryName(root);
const defaultBranch = root.dataset.defaultBranch || "";
// Tab switching
const tabs = document.querySelectorAll(".pt-repo-tabs [role=tab]");
const panels = document.querySelectorAll(".pt-repo-content [role=tabpanel]");
tabs.forEach(tab => {
tab.addEventListener("click", () => {
tabs.forEach(t => t.setAttribute("aria-selected", "false"));
tab.setAttribute("aria-selected", "true");
const target = tab.dataset.tab;
panels.forEach(p => p.hidden = p.dataset.tab !== target);
});
});
document.querySelectorAll("[data-tab-link]").forEach(btn => {
btn.addEventListener("click", e => {
e.preventDefault();
const targetTab = document.querySelector(`.pt-repo-tabs [data-tab="${btn.dataset.tabLink}"]`);
if (targetTab) targetTab.click();
});
});
$("repo-run-quick")?.addEventListener("click", () => $("tab-runs")?.click());
// Branch selector for the code browser — reloads the tree on change
const bsEl = document.querySelector("pt-branch-select");
let branchSelect;
if (bsEl) {
branchSelect = new BranchSelect(bsEl, repository, { branch: defaultBranch, onChange: ref => loadRepoTree(repository, ref) });
}
// Branch selector for the run form
const bsAltEl = document.querySelector("pt-branch-select-alt");
let branchSelectAlt;
if (bsAltEl) branchSelectAlt = new BranchSelect(bsAltEl, repository, { branch: defaultBranch });
const currentBranchAlt = () => branchSelectAlt?.current || defaultBranch;
// Run form
const formEl = $("repository-run-form");
if (formEl) {
formEl.onsubmit = event => {
event.preventDefault();
runAction(formEl.querySelector("button[type=submit]"), async () => {
const body = { git_ref: currentBranchAlt() };
const commitEl = $("commit");
if (commitEl?.value.trim()) body.commit = commitEl.value.trim();
const run = await api(`/api/repos/${encodeURIComponent(repository)}/runs`, { method: "POST", body: JSON.stringify(body) });
toast(`Build queued for ${repository}.`);
window.location.assign(`/app/run/${encodeURIComponent(run.id)}`);
});
};
}
$("repo-clone-copy")?.addEventListener("click", async () => {
const url = $("repo-clone-url")?.textContent;
if (!url) return;
try { await navigator.clipboard.writeText(url); const btn = $("repo-clone-copy"); btn.textContent = "Copied"; setTimeout(() => btn.textContent = "Copy", 2000); }
catch (_) { toast("Failed to copy", "error"); }
});
loadRepoTree(repository, defaultBranch).catch(() => {});
startPolling(loadRepositoryDetail);
}
class BranchSelect {
constructor(container, repository, { branch = "", onChange } = {}) {
this.container = container;
this.repository = repository;
this.current = branch;
this.branches = [];
this.onChange = onChange;
this.filtered = [];
this.activeIndex = -1;
this.panel = null;
this.list = null;
this.filterInput = null;
this.render();
this.fetch();
}
async fetch() {
try {
this.branches = await api(`/api/repos/${encodeURIComponent(this.repository)}/branches`);
if (!this.current) {
const def = this.branches.find(b => b.is_default);
this.current = def ? def.name : (this.branches[0]?.name || "");
}
this.filtered = this.branches;
this.updateTrigger();
} catch (_) {
// git ls-remote may fail; keep current value
}
}
render() {
this.container.className = "pt-branch-select";
this.container.innerHTML = `
<button type="button" class="pt-branch-select__trigger" aria-haspopup="listbox" aria-expanded="false">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/></svg>
<span class="pt-branch-select__label"></span>
<svg class="pt-chevron" width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><path d="M6 8.825a.75.75 0 0 1-.53-.22l-3.5-3.5a.75.75 0 0 1 1.06-1.06L6 7.085l2.97-3.04a.75.75 0 1 1 1.06 1.06l-3.5 3.5a.75.75 0 0 1-.53.22Z"/></svg>
</button>
<div class="pt-branch-select__panel" hidden>
<div class="pt-branch-select__filter"><input type="text" placeholder="Filter branches…" autocomplete="off"></div>
<div class="pt-branch-select__list" role="listbox"></div>
</div>`;
this.label = this.container.querySelector(".pt-branch-select__label");
this.panel = this.container.querySelector(".pt-branch-select__panel");
this.list = this.container.querySelector(".pt-branch-select__list");
this.filterInput = this.container.querySelector(".pt-branch-select__filter input");
this.trigger = this.container.querySelector(".pt-branch-select__trigger");
this.trigger.addEventListener("click", () => this.toggle());
this.filterInput.addEventListener("input", () => this.filter());
this.filterInput.addEventListener("keydown", e => this.onKey(e));
document.addEventListener("click", e => { if (!this.container.contains(e.target)) this.close(); });
this.updateTrigger();
}
updateTrigger() {
if (this.label) this.label.textContent = this.current || "select branch";
}
toggle() {
this.panel.hidden ? this.open() : this.close();
}
open() {
this.filtered = this.branches;
this.activeIndex = this.filtered.findIndex(b => b.name === this.current);
this.renderList();
this.panel.hidden = false;
this.container.setAttribute("open", "");
this.trigger.setAttribute("aria-expanded", "true");
requestAnimationFrame(() => this.filterInput.focus());
}
close() {
this.panel.hidden = true;
this.container.removeAttribute("open");
this.trigger.setAttribute("aria-expanded", "false");
}
filter() {
const q = this.filterInput.value.toLowerCase();
this.filtered = this.branches.filter(b => b.name.toLowerCase().includes(q));
this.activeIndex = 0;
this.renderList();
}
renderList() {
this.list.innerHTML = "";
if (!this.filtered.length) {
this.list.innerHTML = '<div class="pt-branch-select__empty">No branches match</div>';
return;
}
this.filtered.forEach((b, i) => {
const item = document.createElement("button");
item.type = "button";
item.className = "pt-branch-select__item";
if (b.is_default) item.className += " pt-branch-select__item--default";
if (b.name === this.current) item.className += " pt-branch-select__item--active";
item.setAttribute("role", "option");
item.setAttribute("aria-selected", String(b.name === this.current));
item.innerHTML = `<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/></svg>${b.name}`;
item.addEventListener("click", () => this.select(i));
item.addEventListener("mouseenter", () => { this.activeIndex = i; this.highlight(); });
this.list.appendChild(item);
});
}
highlight() {
this.list.querySelectorAll(".pt-branch-select__item").forEach((el, i) => {
el.classList.toggle("pt-branch-select__item--active", i === this.activeIndex && el.getAttribute("aria-selected") !== "true");
});
}
onKey(e) {
if (e.key === "Escape") { this.close(); this.trigger.focus(); return; }
if (e.key === "ArrowDown") { e.preventDefault(); this.activeIndex = Math.min(this.activeIndex + 1, this.filtered.length - 1); this.highlight(); return; }
if (e.key === "ArrowUp") { e.preventDefault(); this.activeIndex = Math.max(this.activeIndex - 1, 0); this.highlight(); return; }
if (e.key === "Enter") { e.preventDefault(); this.select(this.activeIndex); return; }
}
select(index) {
const branch = this.filtered[index];
if (!branch) return;
this.current = branch.name;
this.updateTrigger();
this.renderList();
this.close();
if (this.onChange) this.onChange(branch.name);
this.container.dispatchEvent(new CustomEvent("change", { detail: { branch: branch.name } }));
}
}
function runQueryValues() {
return {
repo: $("run-filter-repo")?.value,
status: $("run-filter-status")?.value,
trigger: $("run-filter-trigger")?.value,
git_ref: $("run-filter-ref")?.value.trim(),
q: $("run-filter-search")?.value.trim()
};
}
async function loadRunsPage() {
const data = await stateData(runQueryValues());
setHealth("online");
fillRepositorySelect($("run-filter-repo"), data.repositories);
const target = $("runs-query-list");
const controls = { retry: $("run-inspector-retry"), reload: loadRunsPage };
renderRuns(data.runs, target, id => {
$("run-inspector-close").hidden = false;
selectRun(id, $("run-inspector"), controls);
});
$("run-query-summary").textContent = `${data.runs.length} result${data.runs.length === 1 ? "" : "s"}`;
$("runs-query-empty").hidden = data.runs.length > 0;
const requested = new URLSearchParams(window.location.search).get("run");
const selected = state.selected || requested;
if (selected) {
$("run-inspector-close").hidden = false;
await selectRun(selected, $("run-inspector"), controls);
}
}
function wireRunsPage() {
const query = new URLSearchParams(window.location.search);
if (query.has("repo")) {
const option = element("option", query.get("repo"));
option.value = query.get("repo");
$("run-filter-repo").append(option);
$("run-filter-repo").value = query.get("repo");
}
for (const [id, key] of [["run-filter-status", "status"], ["run-filter-trigger", "trigger"], ["run-filter-ref", "git_ref"], ["run-filter-search", "q"]]) {
if ($(id) && query.has(key)) $(id).value = query.get(key);
}
$("run-filters")?.addEventListener("submit", event => { event.preventDefault(); state.selected = null; loadRunsPage().catch(error => toast(error.message, "error")); });
$("clear-run-filters")?.addEventListener("click", () => { $("run-filters").reset(); state.selected = null; loadRunsPage().catch(error => toast(error.message, "error")); });
$("run-inspector-close")?.addEventListener("click", () => {
state.selected = null;
$("run-inspector").replaceChildren(element("p", "Select a run to inspect its execution.", "pt-empty"));
$("run-inspector-close").hidden = true;
$("run-inspector-retry").hidden = true;
const url = new URL(window.location.href);
url.searchParams.delete("run");
window.history.replaceState({}, "", url);
});
startPolling(loadRunsPage);
}
async function loadWorkers() {
const data = await stateData();
setHealth("online");
const workers = data.workers || [];
const ready = workers.filter(worker => worker.status === "idle").length;
const running = workers.filter(worker => worker.status === "running").length;
for (const [id, value] of [["worker-total", workers.length], ["worker-ready", ready], ["worker-running", running], ["worker-queued", data.queued_runs ?? 0]]) if ($(id)) $(id).textContent = value;
const target = $("workers-list");
target.replaceChildren();
for (const worker of workers) {
const row = element("tr");
row.dataset.href = "#";
row.style.cursor = "pointer";
row.addEventListener("click", () => showWorkerDetail(worker));
row.append(element("td", worker.id), element("td", undefined), element("td", worker.host), element("td", `${worker.capabilities} · capacity ${worker.capacity}`), element("td", when(worker.heartbeat_at)), element("td", String(worker.completed_runs)));
row.children[1].append(statusBadge(worker.status));
target.append(row);
}
$("workers-empty").hidden = workers.length > 0;
$("workers-updated").textContent = `Updated ${new Date().toLocaleTimeString()}`;
}
function showWorkerDetail(worker) {
const dialog = $("worker-detail");
$("worker-detail-title").textContent = worker.id;
const statusEl = $("worker-detail-status");
statusEl.replaceChildren(statusBadge(worker.status));
$("worker-detail-sub").textContent = `${worker.host} · ${worker.capabilities}`;
const stats = $("worker-detail-stats");
stats.replaceChildren();
const items = [
["Capacity", worker.capacity],
["Completed", worker.completed_runs],
["Heartbeat", when(worker.heartbeat_at)],
];
for (const [label, value] of items) {
const chip = document.createElement("div");
chip.className = "pt-admin-stat";
chip.innerHTML = `<strong>${value}</strong><span>${label}</span>`;
stats.appendChild(chip);
}
dialog.showModal();
}
function wireWorkers() {
$("worker-detail-close")?.addEventListener("click", () => $("worker-detail").close());
$("worker-detail")?.addEventListener("click", e => { if (e.target === $("worker-detail")) $("worker-detail").close(); });
startPolling(loadWorkers);
}
async function loadRunDetail() {
const root = document.querySelector(".pt-app-shell[data-run]");
const id = Number(root?.dataset.run);
if (!id) return;
const data = await api(`/api/runs/${id}`);
setHealth("online");
const target = $("run-detail-body");
const retryBtn = $("run-detail-retry");
const repoLink = $("run-detail-repo");
renderRunDetail(data, target, { retry: retryBtn, reload: loadRunDetail });
if (repoLink) repoLink.href = `/app/repositories/${encodeURIComponent(data.repository)}`;
}
function wireRunDetail() {
startPolling(loadRunDetail);
}
const page = document.body.dataset.page;
if (page === "dashboard") wireDashboard();
if (page === "repositories") wireRepositories();
if (page === "repository-detail") wireRepositoryDetail();
if (page === "runs") wireRunsPage();
if (page === "workers") wireWorkers();
if (page === "run-detail") wireRunDetail();