"use strict"; const state = { repos: [], jobs: [], token: "" }; /* ---------------------------------- 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) { const el = document.querySelector(selector); el.textContent = typeof content === "string" ? content : JSON.stringify(content, null, 2); 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", ""); } /* ---------------------------------- 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 === "settings") loadSettings().catch(handleError); }); } /* --------------------------------- health --------------------------------- */ 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}`; } } function handleError(err) { if (err.status === 401) { document.getElementById("sign-out").classList.add("hidden"); showGate("Token 无效或已过期,请重新输入。"); return; } setBanner(err.message); } /* ---------------------------------- repos --------------------------------- */ const REPO_FIELDS = [ "id", "owner", "name", "enabled", "base_branch", "branch_patterns", "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"); const tbody = document.querySelector("#repos-table tbody"); if (state.repos.length === 0) { tbody.innerHTML = '还没有配置仓库。点右上角「新增仓库」开始,然后在 Gitea 仓库里添加 Webhook。'; return; } tbody.innerHTML = state.repos.map((r) => ` ${esc(r.owner)}/${esc(r.name)}
${esc(r.base_branch)} ${esc(r.branch_patterns)} ${esc(scopeLabel(r.review_scope))} ${r.enabled ? '启用' : '停用'} ${r.auto_merge ? `${esc(r.merge_method)}` : '否'} ${esc(r.block_severity || "—")} `).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) 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)); } catch (err) { handleError(err); } }); document.getElementById("add-repo").addEventListener("click", () => openRepo(null)); document.getElementById("refresh-jobs").addEventListener("click", () => loadJobs().catch(handleError)); /* ------------------------------- repo dialog ------------------------------ */ const modal = document.getElementById("modal"); const form = document.getElementById("repo-form"); function openRepo(id) { form.reset(); hide("#repo-result"); const repo = id ? state.repos.find((r) => r.id === id) : null; document.getElementById("modal-title").textContent = repo ? `${repo.owner}/${repo.name}` : "新增仓库"; for (const field of REPO_FIELDS) { const input = form.elements[field]; if (!input) continue; if (!repo) { // Defaults for a brand-new repository. if (field === "enabled" || field === "create_issue") input.checked = true; continue; } if (input.type === "checkbox") input.checked = Boolean(repo[field]); else if (input.type === "password") input.value = ""; else input.value = repo[field] ?? ""; } 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"); }); 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 }); else await api("/repos", { method: "POST", body: data }); modal.classList.add("hidden"); setBanner(""); await loadRepos(); } catch (err) { if (err.status === 401) return handleError(err); show("#repo-result", err.message); } }); document.getElementById("discover").addEventListener("click", async () => { const id = form.elements.id.value; if (!id) return show("#repo-result", "请先保存仓库,再测试连接。"); show("#repo-result", "测试中…"); try { const info = await api(`/repos/${id}/discover`, { method: "POST" }); show("#repo-result", `连接成功\n默认分支:${info.default_branch}\nIssue 功能:${info.has_issues}\nPR 功能:${info.has_pull_requests}\n分支:${info.branches.join(", ")}`); } catch (err) { if (err.status === 401) return handleError(err); show("#repo-result", 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.base_branch || "main"; manualForm.elements.base_ref.value = repo.base_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); } }); /* ---------------------------------- 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", "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);