AkurAI Build
Menu

AkurAI-Build

public

Latest 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();