AkurAI Build
Menu

AkurAI-Build

public

Latest change e3c871a2465047ecb760490386b73968ce20f3c6 - Complete Build UI audit wave (AKURAI-BUILD-2..9) by Ólafur Búi Ólafsson

const $ = (id, root = document) => root.getElementById(id);

const element = (tag, text, className) => {
  const node = document.createElement(tag);
  if (text !== undefined) node.textContent = text;
  if (className) node.className = className;
  return node;
};
const svgElement = tag => document.createElementNS("http://www.w3.org/2000/svg", tag);

const labelRow = (row, labels) => labels.forEach((label, index) => {
  if (row.children[index]) row.children[index].dataset.label = label;
});

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, { repositoryColumn = true, durationColumn = false } = {}) {
  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)}`;
    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"));
    if (durationColumn) {
      const seconds = run.started_at && run.finished_at ? Math.max(0, run.finished_at - run.started_at) : null;
      row.append(element("td", seconds === null ? "—" : `${seconds}s`));
    }
    row.append(element("td", when(run.created_at)));
    labelRow(row, repositoryColumn
      ? ["Run", "Repository", "Status", "Ref", "Commit", "Created"]
      : ["Run", "Status", "Ref", "Commit", ...(durationColumn ? ["Duration"] : []), "Created"]);
    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"));
  run.jobs.forEach((job, index) => {
    const card = element("section", undefined, "pt-job");
    const head = element("div", undefined, "pt-job-head");
    const meta = element("div");
    const jobTitle = element("strong", job.name);
    jobTitle.id = `run-${run.id}-job-${index + 1}`;
    meta.append(jobTitle, 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) {
      const logs = element("pre", job.logs);
      logs.tabIndex = 0;
      logs.setAttribute("role", "region");
      logs.setAttribute("aria-label", `${job.name} logs, ${job.status}`);
      card.append(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 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"));
}

function wireDashboard() {
  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, "pt-table-link"); 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);
    labelRow(row, ["Repository", "Default branch", "Visibility", "Source", "Created"]);
    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>';
}

function contributorInitials(name) {
  return name.trim().split(/\s+/).slice(0, 2).map(part => part[0]).join("").toUpperCase();
}

function renderRepoSidebar(data) {
  const description = typeof data.description === "string" ? data.description.trim() : "";
  const descriptionEl = $("repo-about-description");
  if (descriptionEl) {
    descriptionEl.textContent = description || "No description provided.";
    descriptionEl.classList.toggle("pt-repo-empty-state", !description);
  }

  const recentCommits = Array.isArray(data.recent_commits) ? data.recent_commits : [];
  const recentSection = $("repo-recent-commits-section");
  const recentList = $("repo-recent-commits");
  recentList?.replaceChildren();
  for (const commit of recentCommits) {
    const item = element("li", undefined, "pt-repo-commit-item");
    const link = element("a", undefined, "pt-repo-commit-link");
    link.href = codeBrowserHref(repositoryName(document.querySelector(".pt-app-shell[data-repository]")), commit.sha);
    link.setAttribute("aria-label", `Browse repository at commit ${String(commit.sha || "").slice(0, 8)}`);
    const message = element("p", commit.message || "No commit message", "pt-repo-commit-message");
    const meta = element("div", undefined, "pt-repo-commit-meta");
    meta.append(
      element("span", commit.author || "Unknown author"),
      element("code", String(commit.sha || "").slice(0, 8), "pt-commit"),
      element("time", when(commit.committed_at))
    );
    link.append(message, meta);
    item.append(link);
    recentList?.append(item);
  }
  if (recentSection) recentSection.hidden = recentCommits.length === 0;

  const contributors = (Array.isArray(data.contributors) ? data.contributors : [])
    .filter(contributor => typeof contributor.name === "string" && contributor.name.trim());
  const contributorsSection = $("repo-contributors-section");
  const contributorsList = $("repo-contributors");
  contributorsList?.replaceChildren();
  for (const contributor of contributors) {
    const name = contributor.name.trim();
    const commitCount = Number(contributor.commit_count);
    const item = element("li", undefined, "pt-repo-contributor");
    const avatar = element("span", contributorInitials(name), "pt-repo-contributor-avatar");
    avatar.setAttribute("role", "img");
    avatar.setAttribute("aria-label", `${name} initials`);
    avatar.title = `${name} contributor avatar`;
    const details = element("span", undefined, "pt-repo-contributor-details");
    details.append(
      element("strong", name),
      element("small", `${Number.isFinite(commitCount) ? commitCount.toLocaleString() : "—"} ${commitCount === 1 ? "commit" : "commits"}`)
    );
    item.title = `${name} — ${Number.isFinite(commitCount) ? commitCount.toLocaleString() : "unknown"} ${commitCount === 1 ? "commit" : "commits"}`;
    item.append(avatar, details);
    contributorsList?.append(item);
  }
  if (contributorsSection) contributorsSection.hidden = contributors.length === 0;

  const languages = (Array.isArray(data.languages) ? data.languages : [])
    .filter(language => typeof language.name === "string" && language.name.trim() && Number.isFinite(Number(language.percentage)) && Number(language.percentage) > 0);
  const languagesSection = $("repo-languages-section");
  const languageBar = $("repo-language-bar");
  const languagesList = $("repo-languages");
  const languageLabels = [];
  const languageSvg = languages.length && languageBar ? svgElement("svg") : null;
  let languageOffset = 0;
  if (languageSvg) {
    languageSvg.setAttribute("viewBox", "0 0 100 8");
    languageSvg.setAttribute("preserveAspectRatio", "none");
    languageSvg.setAttribute("aria-hidden", "true");
    languageBar.replaceChildren(languageSvg);
  } else {
    languageBar?.replaceChildren();
  }
  languagesList?.replaceChildren();
  for (const language of languages) {
    const percentage = Math.min(100, Math.max(0, Number(language.percentage)));
    const percentageLabel = percentage.toLocaleString(undefined, { maximumFractionDigits: 1 });
    const color = /^#[0-9a-f]{6}$/i.test(language.color) ? language.color : "#6c6c6c";
    const label = `${language.name} ${percentageLabel}%`;
    languageLabels.push(label);
    if (languageSvg) {
      const segment = svgElement("rect");
      segment.setAttribute("x", String(languageOffset));
      segment.setAttribute("width", String(percentage));
      segment.setAttribute("height", "8");
      segment.setAttribute("fill", color);
      languageSvg.append(segment);
      languageOffset += percentage;
    }

    const item = element("li");
    const name = element("span", undefined, "pt-repo-language-name");
    const dot = svgElement("svg");
    dot.classList.add("pt-repo-language-dot");
    dot.setAttribute("viewBox", "0 0 8 8");
    dot.setAttribute("aria-hidden", "true");
    const circle = svgElement("circle");
    circle.setAttribute("cx", "4");
    circle.setAttribute("cy", "4");
    circle.setAttribute("r", "4");
    circle.setAttribute("fill", color);
    dot.append(circle);
    name.append(dot, element("span", language.name));
    item.append(name, element("strong", `${percentageLabel}%`));
    languagesList?.append(item);
  }
  if (languageBar) languageBar.setAttribute("aria-label", languageLabels.join(", "));
  if (languagesSection) languagesSection.hidden = languages.length === 0;
}

function codeBrowserHref(repository, reference, path = "") {
  const segments = path.split("/").filter(Boolean).map(segment => encodeURIComponent(segment)).join("/");
  return `/app/repositories/${encodeURIComponent(repository)}/tree/${encodeURIComponent(reference)}${segments ? `/${segments}` : ""}`;
}

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;
    renderRepoSidebar(data);

    commitEl?.replaceChildren();
    if (data.latest_commit) {
      const c = data.latest_commit;
      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?.append(element("span", "No commits", "pt-muted"));
    }

    listEl.replaceChildren();
    for (const entry of data.entries) {
      const row = element("a", undefined, "pt-repo-file-row");
      row.href = codeBrowserHref(repository, data.reference, data.path ? `${data.path}/${entry.name}` : entry.name);
      row.setAttribute("aria-label", `Open ${entry.kind} ${entry.name}`);
      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);
    }

    const readmeLink = $("repo-about-readme-link");
    const hasReadme = typeof data.readme === "string" && data.readme.length > 0;
    if (readmeLink) {
      readmeLink.hidden = !hasReadme;
      readmeLink.href = hasReadme
        ? codeBrowserHref(repository, data.reference, data.path ? `${data.path}/${data.readme_name || "README"}` : (data.readme_name || "README"))
        : "#";
    }
  } catch (error) {
    commitEl?.replaceChildren(element("span", `Unable to load tree: ${error.message}`, "pt-muted"));
    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"), { repositoryColumn: false, durationColumn: true });
  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() {
  const query = new URLSearchParams(window.location.search);
  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(),
    page: Math.max(1, Number.parseInt(query.get("page") || "1", 10) || 1)
  };
}

function syncRunsUrl(page) {
  const url = new URL(window.location.href);
  const values = runQueryValues();
  for (const key of ["repo", "status", "trigger", "git_ref", "q"]) {
    if (values[key]) url.searchParams.set(key, values[key]);
    else url.searchParams.delete(key);
  }
  if (page > 1) url.searchParams.set("page", String(page));
  else url.searchParams.delete("page");
  window.history.replaceState({}, "", url);
}

async function loadRunsPage() {
  const values = runQueryValues();
  const page = values.page;
  const request = { ...values, limit: 50, offset: (page - 1) * 50 };
  delete request.page;
  const data = await stateData(request);
  setHealth("online");
  fillRepositorySelect($("run-filter-repo"), data.repositories, values.repo);
  renderRuns(data.runs, $("runs-query-list"));
  const total = Number(data.run_total || 0);
  const first = total === 0 ? 0 : (page - 1) * 50 + 1;
  const last = Math.min(page * 50, total);
  $("run-query-summary").textContent = `${total.toLocaleString()} result${total === 1 ? "" : "s"}`;
  $("run-query-range").textContent = total ? ` · Showing ${first}–${last}` : "";
  $("run-page-status").textContent = `Page ${page} of ${Math.max(1, Math.ceil(total / 50))}`;
  $("run-page-newer").disabled = page <= 1;
  $("run-page-older").disabled = last >= total;
  $("runs-query-empty").hidden = data.runs.length > 0;
}


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(); syncRunsUrl(1); loadRunsPage().catch(error => toast(error.message, "error")); });
  $("clear-run-filters")?.addEventListener("click", () => { $("run-filters").reset(); syncRunsUrl(1); loadRunsPage().catch(error => toast(error.message, "error")); });
  $("run-page-newer")?.addEventListener("click", () => { const page = runQueryValues().page; syncRunsUrl(Math.max(1, page - 1)); loadRunsPage().catch(error => toast(error.message, "error")); });
  $("run-page-older")?.addEventListener("click", () => { const page = runQueryValues().page; syncRunsUrl(page + 1); loadRunsPage().catch(error => toast(error.message, "error")); });
  startPolling(() => runQueryValues().page === 1 ? loadRunsPage() : Promise.resolve());
}

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");
    const workerCell = element("td");
    const button = element("button", worker.id, "pt-table-link pt-worker-button");
    button.type = "button";
    button.dataset.workerId = worker.id;
    button.setAttribute("aria-haspopup", "dialog");
    button.onclick = () => showWorkerDetail(worker);
    workerCell.append(button);
    row.append(workerCell, 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));
    labelRow(row, ["Worker", "Status", "Host", "Capabilities", "Heartbeat", "Completed"]);
    target.append(row);
  }
  $("workers-empty").hidden = workers.length > 0;
  $("workers-updated").textContent = `Updated ${new Date().toLocaleTimeString()}`;
}

function showWorkerDetail(worker) {
  const dialog = $("worker-detail");
  dialog.dataset.returnFocus = worker.id;
  $("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 = element("div", undefined, "pt-admin-stat");
    chip.append(element("strong", String(value)), element("span", label));
    stats.append(chip);
  }
  dialog.showModal();
}

function wireWorkers() {
  const dialog = $("worker-detail");
  $("worker-detail-close")?.addEventListener("click", () => dialog.close());
  dialog?.addEventListener("click", event => { if (event.target === dialog) dialog.close(); });
  dialog?.addEventListener("close", () => {
    const trigger = [...document.querySelectorAll("[data-worker-id]")].find(button => button.dataset.workerId === dialog.dataset.returnFocus);
    trigger?.focus();
  });
  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 });
  target.setAttribute("aria-busy", "false");
  const summary = `Run #${data.id} details loaded with ${data.jobs.length} job${data.jobs.length === 1 ? "" : "s"}.`;
  if ($("run-detail-status")?.textContent !== summary) $("run-detail-status").textContent = summary;
  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();