"use strict";
const state = { repos: [], jobs: [], records: [], token: "", branches: [] };
/* ---------------------------------- auth ---------------------------------- */
const TOKEN_KEY = "cr-admin-token";
function token() {
return state.token || localStorage.getItem(TOKEN_KEY) || "";
}
function showGate(message) {
const gate = document.getElementById("token-gate");
const err = document.getElementById("token-error");
gate.classList.remove("hidden");
if (message) {
err.textContent = message;
err.classList.remove("hidden");
} else {
err.classList.add("hidden");
}
document.getElementById("token-form").elements.token.focus();
}
function hideGate() {
document.getElementById("token-gate").classList.add("hidden");
}
function setBanner(message, kind) {
const el = document.getElementById("banner");
if (!message) {
el.classList.add("hidden");
return;
}
el.textContent = message;
el.className = `banner${kind === "ok" ? " ok" : ""}`;
}
document.getElementById("token-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
const value = ev.target.elements.token.value.trim();
if (!value) return;
state.token = value;
try {
await api("/repos");
localStorage.setItem(TOKEN_KEY, value);
hideGate();
document.getElementById("sign-out").classList.remove("hidden");
setBanner("");
await refreshAll();
} catch (err) {
state.token = "";
showGate(`Token 无效:${err.message}`);
}
});
document.getElementById("sign-out").addEventListener("click", () => {
state.token = "";
localStorage.removeItem(TOKEN_KEY);
document.getElementById("sign-out").classList.add("hidden");
showGate("");
});
/* ---------------------------------- api ----------------------------------- */
async function api(path, { method = "GET", body } = {}) {
const headers = { Accept: "application/json" };
const t = token();
if (t) headers.Authorization = `Bearer ${t}`;
if (body !== undefined) headers["Content-Type"] = "application/json";
const res = await fetch(`/api${path}`, {
method, headers, body: body === undefined ? undefined : JSON.stringify(body),
});
const textBody = await res.text();
let parsed = null;
try { parsed = textBody ? JSON.parse(textBody) : null; } catch { parsed = textBody; }
if (!res.ok) {
const error = new Error(parsed?.error || `HTTP ${res.status}`);
error.status = res.status;
throw error;
}
return parsed;
}
function show(selector, content, cls) {
const el = document.querySelector(selector);
el.textContent = typeof content === "string" ? content : JSON.stringify(content, null, 2);
if (cls) el.className = cls; else el.className = "log";
el.classList.remove("hidden");
}
function hide(selector) { document.querySelector(selector).classList.add("hidden"); }
function esc(value) {
return String(value ?? "").replace(/[&<>"]/g, (c) => (
{ "&": "&", "<": "<", ">": ">", '"': """ }[c]
));
}
function fmtTime(value) {
if (!value) return "";
return String(value).replace("T", " ").replace("Z", "");
}
function handleError(err) {
if (err.status === 401) {
document.getElementById("sign-out").classList.add("hidden");
showGate("Token 无效或已过期,请重新输入。");
return;
}
setBanner(err.message);
}
/* ---------------------------------- tabs ---------------------------------- */
for (const tab of document.querySelectorAll(".tab")) {
tab.addEventListener("click", () => {
for (const t of document.querySelectorAll(".tab")) t.classList.toggle("active", t === tab);
for (const p of document.querySelectorAll(".panel")) {
p.classList.toggle("active", p.id === `tab-${tab.dataset.tab}`);
}
if (tab.dataset.tab === "jobs") loadJobs().catch(handleError);
if (tab.dataset.tab === "records") loadRecords().catch(handleError);
if (tab.dataset.tab === "settings") loadSettings().catch(handleError);
});
}
async function loadHealth() {
try {
const h = await api("/health");
const q = h.queue || {};
document.getElementById("status").textContent =
`队列 运行${q.running || 0} / 等待${q.queued || 0} · 成功${q.succeeded || 0} · 失败${q.failed || 0}` +
(h.llmModel ? ` · ${h.llmModel}` : "");
} catch (err) {
document.getElementById("status").textContent = `服务异常:${err.message}`;
}
}
/* ---------------------------------- repos --------------------------------- */
const REPO_FIELDS = [
"id", "repo_url", "enabled", "managed_branch", "check_branches", "review_scope",
"gitea_token", "llm_model", "llm_token", "background_template", "excludes",
"publish_mode", "create_issue", "issue_labels", "block_severity", "block_categories",
"fail_on_findings", "auto_merge", "auto_merge_mode", "merge_method", "delete_branch",
"max_comments", "concurrency",
];
async function loadRepos() {
state.repos = await api("/repos");
// The webhook is what makes reviews fire at all, so its state belongs in the
// repository row instead of hiding in Gitea's settings.
await Promise.all(state.repos.map(async (r) => {
try {
r.webhook = await api(`/repos/${r.id}/webhook`);
} catch (err) {
r.webhook = { error: err.message };
}
}));
const tbody = document.querySelector("#repos-table tbody");
if (state.repos.length === 0) {
tbody.innerHTML = '
| 还没有配置仓库。点「从 Gitea 导入」批量选择,或「新增仓库」手动填 Git 地址。 |
';
} else {
tbody.innerHTML = state.repos.map((r) => `
${esc(r.owner)}/${esc(r.name)} ${esc(shortUrl(r.repo_url))} |
${esc(r.managed_branch)} |
${branchTags(r.check_branches, r.managed_branch)} |
${esc(scopeLabel(r.review_scope))} |
${webhookCell(r)} |
${r.enabled ? '启用' : '停用'} |
${r.auto_merge ? `${esc(r.merge_method)}` : '否'} |
${esc(r.block_severity || "—")} |
|
`).join("");
}
syncRecordFilter();
}
function webhookCell(r) {
if (r.webhook?.error) return `未知`;
if (r.webhook?.installed) return '已配置';
return '未配置';
}
function shortUrl(url) {
return String(url || "").replace(/^https?:\/\//, "");
}
function branchTags(list, managed) {
const names = String(list || "").split(",").map((s) => s.trim()).filter(Boolean);
if (names.includes("*")) {
const extra = names.filter((n) => n !== "*");
return `任意分支` +
(extra.length ? ` ${extra.map((n) => `${esc(n)}`).join(" ")}` : "");
}
const own = names.filter((n) => n !== managed);
if (!own.length) return '仅 PR';
return own.map((n) => `${esc(n)}`).join(" ");
}
function scopeLabel(scope) {
return { both: "Push + PR", pr: "仅 PR", push: "仅 Push" }[scope] || scope;
}
document.querySelector("#repos-table").addEventListener("click", async (ev) => {
const target = ev.target.closest("button");
if (!target) return;
try {
if (target.dataset.edit) await openRepo(Number(target.dataset.edit));
if (target.dataset.del) {
if (!confirm("删除该仓库配置?历史任务会一并删除。")) return;
await api(`/repos/${target.dataset.del}`, { method: "DELETE" });
await loadRepos();
}
if (target.dataset.run) openManual(Number(target.dataset.run));
if (target.dataset.records) {
document.querySelector('#record-filter').value = String(target.dataset.records);
document.querySelector('.tab[data-tab="records"]').click();
}
if (target.dataset.hook) {
target.disabled = true;
const label = target.textContent;
target.textContent = "处理中…";
try {
const res = await api(`/repos/${target.dataset.hook}/webhook`, { method: "POST" });
setBanner(`Webhook ${res.created ? "已创建" : "已更新"}:${res.url}`, "ok");
} finally {
target.disabled = false;
target.textContent = label;
await loadRepos();
}
}
} catch (err) {
handleError(err);
}
});
document.getElementById("add-repo").addEventListener("click", () => openRepo(null));
document.getElementById("refresh-jobs").addEventListener("click", () => loadJobs().catch(handleError));
document.getElementById("refresh-records").addEventListener("click", () => loadRecords().catch(handleError));
document.getElementById("record-filter").addEventListener("change", () => loadRecords().catch(handleError));
/* ------------------------------ repo dialog ------------------------------- */
const modal = document.getElementById("modal");
const form = document.getElementById("repo-form");
const branchList = document.getElementById("branch-list");
const ANY_BRANCH = "*";
function allSelected() {
return String(form.elements.check_branches.value || "")
.split(",").map((s) => s.trim()).filter(Boolean);
}
/** Branch names only; the "任意分支" token is not a branch. */
function selectedBranches() {
return allSelected().filter((b) => b !== ANY_BRANCH);
}
function reviewsAnyBranch() {
return allSelected().includes(ANY_BRANCH);
}
function setSelectedBranches(list, { any = reviewsAnyBranch() } = {}) {
const names = list.filter((b) => b && b !== ANY_BRANCH);
form.elements.check_branches.value = (any ? [ANY_BRANCH, ...names] : names).join(",");
}
function renderBranchList() {
const chosen = new Set(selectedBranches());
const managed = form.elements.managed_branch.value.trim();
const any = reviewsAnyBranch();
if (!state.branches.length) {
branchList.innerHTML = '点「加载分支」获取远端分支列表。';
return;
}
// The managed branch is the final target and is always reviewed for pull
// requests, so it is not offered as a checked branch.
const selectable = state.branches.filter((b) => b !== managed);
const parts = [
``,
];
if (selectable.length === 0) {
parts.push('除管理分支外没有其它分支。');
} else {
parts.push(...selectable.map((b) => {
const on = chosen.has(b);
return ``;
}));
}
branchList.innerHTML = parts.join("");
}
branchList.addEventListener("click", (ev) => {
const anyBtn = ev.target.closest("button[data-any]");
if (anyBtn) {
setSelectedBranches(selectedBranches(), { any: !reviewsAnyBranch() });
renderBranchList();
return;
}
const btn = ev.target.closest("button[data-branch]");
if (!btn) return;
const name = btn.dataset.branch;
const chosen = selectedBranches();
const idx = chosen.indexOf(name);
if (idx >= 0) chosen.splice(idx, 1); else chosen.push(name);
setSelectedBranches(chosen);
renderBranchList();
});
async function openRepo(id) {
form.reset();
hide("#repo-result");
state.branches = [];
renderBranchList();
const repo = id ? state.repos.find((r) => r.id === id) : null;
document.getElementById("modal-title").textContent = repo ? `${repo.owner}/${repo.name}` : "新增仓库";
document.getElementById("repo-parsed").textContent = "";
for (const field of REPO_FIELDS) {
const input = form.elements[field];
if (!input) continue;
if (!repo) {
if (field === "enabled" || field === "create_issue") input.checked = true;
if (field === "managed_branch") input.value = "main";
if (field === "check_branches") input.value = "";
continue;
}
if (input.type === "checkbox") input.checked = Boolean(repo[field]);
else if (input.type === "password") input.value = "";
else input.value = repo[field] ?? "";
}
if (repo) {
document.getElementById("repo-parsed").textContent = `${repo.owner}/${repo.name}`;
// Existing repositories get their branch list loaded for the picker.
loadBranches({ silent: true }).catch(() => {});
}
modal.classList.remove("hidden");
}
document.getElementById("modal-close").addEventListener("click", () => modal.classList.add("hidden"));
modal.addEventListener("click", (ev) => { if (ev.target === modal) modal.classList.add("hidden"); });
/** Fetch branch info for whatever git URL is currently in the form. */
async function loadBranches({ silent = false } = {}) {
const repoUrl = form.elements.repo_url.value.trim();
const id = form.elements.id.value;
const hint = document.getElementById("repo-parsed");
if (!repoUrl && !id) {
if (!silent) hint.textContent = "请先填写 Git 地址。";
return;
}
if (!silent) hint.textContent = "正在连接…";
try {
const body = repoUrl ? { repo_url: repoUrl } : undefined;
const info = body
? await api("/repos/branches", { method: "POST", body })
: await api(`/repos/${id}/discover`, { method: "POST" });
state.branches = info.branches || [];
if (!form.elements.managed_branch.value) form.elements.managed_branch.value = info.default_branch || "main";
// Existing repositories keep their saved selection; a fresh one starts
// with no explicit branches checked (only the managed branch is reviewed).
if (form.elements.check_branches.value === "") setSelectedBranches([], { any: false });
renderBranchList();
hint.className = "hint ok";
hint.textContent =
`连接成功:${info.owner}/${info.name} · 默认分支 ${info.default_branch} · 共 ${state.branches.length} 个分支` +
(info.has_issues ? "" : " · 该仓库未启用 Issue");
document.getElementById("clone-source").innerHTML =
state.branches.map((b) => ``).join("");
return info;
} catch (err) {
hint.className = "hint err";
hint.textContent = `连接失败:${err.message}`;
throw err;
}
}
document.getElementById("repo-test").addEventListener("click", () => loadBranches().catch(() => {}));
document.getElementById("branch-reload").addEventListener("click", () => loadBranches().catch(() => {}));
form.elements.repo_url.addEventListener("blur", async () => {
const url = form.elements.repo_url.value.trim();
if (!url) return;
try {
const parsed = await api("/repos/parse", { method: "POST", body: { repo_url: url } });
document.getElementById("repo-parsed").className = "hint";
document.getElementById("repo-parsed").textContent = `识别为:${parsed.owner}/${parsed.name}`;
} catch (err) {
document.getElementById("repo-parsed").className = "hint err";
document.getElementById("repo-parsed").textContent = err.message;
}
});
form.addEventListener("submit", async (ev) => {
ev.preventDefault();
const data = {};
for (const field of REPO_FIELDS) {
const input = form.elements[field];
if (!input) continue;
if (input.type === "checkbox") data[field] = input.checked ? 1 : 0;
else if (input.type === "password") { if (input.value) data[field] = input.value; }
else if (input.value !== "") data[field] = input.value;
}
try {
if (data.id) {
await api(`/repos/${data.id}`, { method: "PATCH", body: data });
modal.classList.add("hidden");
setBanner("");
} else {
const created = await api("/repos", { method: "POST", body: data });
modal.classList.add("hidden");
if (created.webhook?.error) {
setBanner(`仓库已保存,但 Webhook 安装失败:${created.webhook.error}`);
} else if (created.webhook) {
setBanner(`仓库已保存,Webhook ${created.webhook.created ? "已创建" : "已更新"}。`, "ok");
} else {
setBanner("仓库已保存。");
}
}
await loadRepos();
} catch (err) {
if (err.status === 401) return handleError(err);
show("#repo-result", err.message);
}
});
/* ------------------------------ clone branch ------------------------------ */
const cloneModal = document.getElementById("clone-modal");
const cloneForm = document.getElementById("clone-form");
document.getElementById("branch-create").addEventListener("click", () => {
cloneForm.reset();
hide("#clone-result");
cloneForm.elements.new_branch.value = form.elements.managed_branch.value.trim() || "main";
if (!state.branches.length) {
loadBranches().then(() => cloneModal.classList.remove("hidden")).catch(() => {});
return;
}
cloneModal.classList.remove("hidden");
});
document.getElementById("clone-close").addEventListener("click", () => cloneModal.classList.add("hidden"));
cloneModal.addEventListener("click", (ev) => { if (ev.target === cloneModal) cloneModal.classList.add("hidden"); });
cloneForm.addEventListener("submit", async (ev) => {
ev.preventDefault();
const id = form.elements.id.value;
if (!id) return show("#clone-result", "请先保存仓库,再创建分支。");
try {
const res = await api(`/repos/${id}/branches`, {
method: "POST",
body: {
new_branch: cloneForm.elements.new_branch.value.trim(),
from_branch: cloneForm.elements.from_branch.value,
},
});
state.branches = res.branches || [];
form.elements.managed_branch.value = res.repo.managed_branch;
form.elements.check_branches.value = res.repo.check_branches;
renderBranchList();
cloneModal.classList.add("hidden");
show("#repo-result", `已创建分支 ${res.repo.managed_branch}`);
} catch (err) {
show("#clone-result", err.message);
}
});
/* ------------------------------ import dialog ----------------------------- */
const importModal = document.getElementById("import-modal");
document.getElementById("import-repo").addEventListener("click", async () => {
importModal.classList.remove("hidden");
document.getElementById("import-list").innerHTML = '正在拉取仓库列表…
';
try {
state.remoteRepos = await api("/gitea/repos");
renderImportList();
} catch (err) {
document.getElementById("import-list").innerHTML = `拉取失败:${esc(err.message)}
`;
}
});
document.getElementById("import-close").addEventListener("click", () => importModal.classList.add("hidden"));
importModal.addEventListener("click", (ev) => { if (ev.target === importModal) importModal.classList.add("hidden"); });
document.getElementById("import-search").addEventListener("input", renderImportList);
function renderImportList() {
const q = document.getElementById("import-search").value.trim().toLowerCase();
const list = (state.remoteRepos || []).filter((r) => !q || r.full_name.toLowerCase().includes(q));
const box = document.getElementById("import-list");
if (!list.length) {
box.innerHTML = '没有匹配的仓库。
';
return;
}
box.innerHTML = list.map((r) => `
${esc(r.full_name)}
${esc(r.default_branch || "main")}${r.private ? " · 私有" : ""}${r.empty ? " · 空仓库" : ""}${r.archived ? " · 已归档" : ""}${r.description ? ` · ${esc(r.description)}` : ""}
${r.configured
? '已配置'
: ``}
`).join("");
}
document.getElementById("import-list").addEventListener("click", async (ev) => {
const btn = ev.target.closest("button[data-import]");
if (!btn) return;
const fullName = btn.dataset.import;
const remote = (state.remoteRepos || []).find((r) => r.full_name === fullName);
if (!remote) return;
btn.disabled = true;
btn.textContent = "添加中…";
try {
const created = await api("/repos", {
method: "POST",
body: {
repo_url: remote.clone_url,
managed_branch: remote.default_branch || "main",
check_branches: "",
},
});
// Discover immediately so the branch list is populated on first open.
await api(`/repos/${created.id}/discover`, { method: "POST" }).catch(() => {});
remote.configured = true;
renderImportList();
await loadRepos();
setBanner(`已添加 ${fullName}`, "ok");
} catch (err) {
btn.disabled = false;
btn.textContent = "添加";
setBanner(`添加 ${fullName} 失败:${err.message}`);
}
});
/* ------------------------------ manual dialog ----------------------------- */
const manualModal = document.getElementById("manual-modal");
const manualForm = document.getElementById("manual-form");
function openManual(repoId) {
const repo = state.repos.find((r) => r.id === repoId);
if (!repo) return;
manualForm.reset();
hide("#manual-result");
manualForm.elements.repo_id.value = repo.id;
manualForm.elements.ref.value = repo.managed_branch || "main";
manualForm.elements.base_ref.value = repo.managed_branch || "main";
document.getElementById("manual-repo").textContent = `${repo.owner}/${repo.name}`;
manualModal.classList.remove("hidden");
}
document.getElementById("manual-close").addEventListener("click", () => manualModal.classList.add("hidden"));
manualModal.addEventListener("click", (ev) => { if (ev.target === manualModal) manualModal.classList.add("hidden"); });
manualForm.addEventListener("submit", async (ev) => {
ev.preventDefault();
const body = {
repo_id: Number(manualForm.elements.repo_id.value),
ref: manualForm.elements.ref.value.trim(),
sha: manualForm.elements.sha.value.trim(),
base_ref: manualForm.elements.base_ref.value.trim() || undefined,
};
const pr = manualForm.elements.pr_number.value.trim();
if (pr) body.pr_number = Number(pr);
try {
const res = await api("/review", { method: "POST", body });
manualModal.classList.add("hidden");
setBanner(`已加入队列:任务 #${res.jobId}`, "ok");
await loadJobs();
} catch (err) {
if (err.status === 401) return handleError(err);
show("#manual-result", err.message);
}
});
/* -------------------------------- records --------------------------------- */
function syncRecordFilter() {
const sel = document.getElementById("record-filter");
const current = sel.value;
sel.innerHTML = '' +
state.repos.map((r) => ``).join("");
sel.value = current;
}
async function loadRecords() {
const repoId = document.getElementById("record-filter").value;
state.records = await api(`/records${repoId ? `?repo_id=${repoId}` : ""}`);
const box = document.getElementById("records-list");
if (!state.records.length) {
box.innerHTML = '暂无审查记录。仓库收到 push 或 PR 后,这里会出现摘要。
';
return;
}
box.innerHTML = state.records.map(renderRecordCard).join("");
}
function renderRecordCard(r) {
const summaryCls = r.summary_status === "done" ? "" : (r.summary_status === "failed" ? " failed" : " pending");
let summary;
if (r.summary_status === "done") {
summary = esc(r.summary || "");
} else if (r.summary_status === "failed") {
summary = `摘要生成失败:${esc(r.summary_error || "未知原因")}`;
} else if (r.summary_status === "skipped") {
summary = "未生成摘要。";
} else {
summary = "摘要生成中…";
}
return `
${esc(r.owner)}/${esc(r.name)} · ${esc(r.ref_name)}
${esc(r.summary_status)}
${summary}
`;
}
document.getElementById("records-list").addEventListener("click", async (ev) => {
const btn = ev.target.closest("button[data-record]");
if (!btn) return;
try {
const rec = await api(`/records/${btn.dataset.record}`);
showRecord(rec);
} catch (err) {
handleError(err);
}
});
const recordModal = document.getElementById("record-modal");
let currentRecordId = null;
function showRecord(rec) {
currentRecordId = rec.id;
document.getElementById("record-title").textContent =
`审查记录 #${rec.id} · ${rec.repository || ""} · ${rec.ref_name}`;
const params = rec.params || {};
const findings = rec.findings || [];
const rows = [
["提交", `${esc(rec.to_sha)}`],
["对比基准", rec.from_sha ? `${esc(rec.from_sha)}` : "—"],
["触发来源", esc(rec.trigger || "—")],
["Pull Request", rec.pr_number
? `#${rec.pr_number}`
: "—"],
["管理分支", esc(params.managed_branch || "—")],
["PR 目标分支", esc(params.base_ref || "—")],
["审查范围", esc(params.review_scope || "—")],
["阻断阈值", `${esc(params.block_severity || "—")}${params.block_categories ? ` / ${esc(params.block_categories)}` : ""}`],
["发布方式", esc(params.publish_mode || "—")],
["自动合并", params.auto_merge ? `${esc(params.auto_merge_mode || "")} · ${esc(params.merge_method || "")}` : "关闭"],
["排除路径", esc(params.excludes || "—")],
["LLM 模型", esc(rec.llm_model || "—")],
["审查状态", `${esc(rec.llm_status || "—")}${params.review_incomplete ? "(覆盖不完整)" : ""}`],
["消耗 / 耗时", `${rec.tokens_total || 0} tokens · ${esc(rec.elapsed || "—")}`],
["摘要模型", esc(rec.summary_model || "—")],
];
const findingsHtml = findings.length
? findings.map((f) => `
${esc(f.path)}${f.start_line ? `:${f.start_line}${f.end_line && f.end_line !== f.start_line ? `-${f.end_line}` : ""}` : ""} · ${esc(f.severity || "未分级")}/${esc(f.category || "-")}${f.blocking ? " · 阻断" : ""}
${esc(f.content || "")}
`).join("")
: '本次没有产生意见。
';
document.getElementById("record-body").innerHTML = `
审查需求 / 背景
${rec.requirement ? esc(rec.requirement) : "(未填写)"}
AI 摘要
${
rec.summary_status === "done" ? esc(rec.summary || "") : esc(rec.summary_error || "生成中…")
}
运行参数
${rows.map(([k, v]) => `- ${esc(k)}
- ${v}
`).join("")}
审查意见(${findings.length})
${findingsHtml}`;
recordModal.classList.remove("hidden");
}
document.getElementById("record-close").addEventListener("click", () => recordModal.classList.add("hidden"));
recordModal.addEventListener("click", (ev) => { if (ev.target === recordModal) recordModal.classList.add("hidden"); });
document.getElementById("record-resummarise").addEventListener("click", async () => {
if (!currentRecordId) return;
try {
await api(`/records/${currentRecordId}/summarise`, { method: "POST" });
recordModal.classList.add("hidden");
setBanner("已重新排队生成摘要。", "ok");
await loadRecords();
} catch (err) {
handleError(err);
}
});
/* ---------------------------------- jobs ---------------------------------- */
const STATUS_TAG = {
queued: "muted", running: "warn", succeeded: "ok", failed: "err", skipped: "muted",
};
async function loadJobs() {
state.jobs = await api("/jobs?limit=100");
const tbody = document.querySelector("#jobs-table tbody");
if (state.jobs.length === 0) {
tbody.innerHTML = '| 暂无任务。仓库收到 push 或 Pull Request 后会出现在这里。 |
';
return;
}
tbody.innerHTML = state.jobs.map((j) => `
| ${j.id} |
${esc(j.owner)}/${esc(j.name)} |
${esc(j.ref_name)}${j.pr_number ? ` PR #${j.pr_number}` : ""} |
${esc(String(j.to_sha).slice(0, 10))} |
${esc(j.status)}${j.phase ? ` ${esc(j.phase)}` : ""} |
${j.findings ?? 0} |
${j.blocking ? `${j.blocking}` : "0"} |
${j.merged ? '已合并' : ""} |
${fmtTime(j.started_at || j.created_at)} |
|
`).join("");
}
document.querySelector("#jobs-table").addEventListener("click", async (ev) => {
const target = ev.target.closest("button");
if (!target) return;
try {
if (target.dataset.log) {
const job = await api(`/jobs/${target.dataset.log}`);
show("#job-log", job.log || "(无日志)");
}
if (target.dataset.retry) {
await api(`/jobs/${target.dataset.retry}/retry`, { method: "POST" });
await loadJobs();
}
} catch (err) {
handleError(err);
}
});
/* -------------------------------- settings -------------------------------- */
const SETTINGS_FIELDS = [
"giteaUrl", "giteaToken", "webhookSecret", "adminToken", "webhookUrl",
"llmUrl", "llmToken", "llmModel", "llmProtocol", "llmAuthHeader",
"llmExtraHeaders", "rulePath",
];
async function loadSettings() {
const settingsForm = document.getElementById("settings-form");
const s = await api("/settings");
for (const field of SETTINGS_FIELDS) {
const input = settingsForm.elements[field];
if (!input) continue;
if (input.type === "password") input.value = "";
else input.value = s[field] ?? "";
}
document.getElementById("hook-url").textContent = `${location.origin}/webhook/gitea`;
}
document.getElementById("settings-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
const settingsForm = ev.target;
const data = {};
for (const field of SETTINGS_FIELDS) {
const input = settingsForm.elements[field];
if (input && input.value) data[field] = input.value;
}
try {
await api("/settings", { method: "PUT", body: data });
if (data.adminToken) {
state.token = data.adminToken;
localStorage.setItem(TOKEN_KEY, data.adminToken);
}
for (const field of SETTINGS_FIELDS) {
const input = settingsForm.elements[field];
if (input && input.type === "password") input.value = "";
}
show("#settings-result", "已保存。");
await loadHealth();
} catch (err) {
if (err.status === 401) return handleError(err);
show("#settings-result", err.message);
}
});
document.getElementById("test-gitea").addEventListener("click", async () => {
show("#settings-result", "测试中…");
try { show("#settings-result", await api("/gitea/test", { method: "POST" })); }
catch (err) { show("#settings-result", err.message); }
});
document.getElementById("test-llm").addEventListener("click", async () => {
show("#settings-result", "测试中,请稍候…");
try { show("#settings-result", await api("/selftest", { method: "POST" })); }
catch (err) { show("#settings-result", err.message); }
});
/* --------------------------------- startup -------------------------------- */
async function refreshAll() {
await loadRepos();
await loadHealth();
}
async function boot() {
await loadHealth();
if (!token()) {
showGate("");
return;
}
try {
await refreshAll();
document.getElementById("sign-out").classList.remove("hidden");
} catch (err) {
handleError(err);
}
}
boot();
setInterval(loadHealth, 10000);