feat: Gitea 自动代码审查服务

基于 OpenCodeReview 的 webhook 服务:监听 Gitea 的 push 与 Pull Request 事件,
调用 OCR 审查 diff,把结果发布回 Gitea,并按阻断阈值决定是否自动合并。

主要能力:
- Push / PR 事件触发,支持分支 glob 过滤与 PR-only / push-only 范围
- PR 内联评论(按 diff 行号定位)、汇总评论、Issue 生命周期、提交状态
- 可配置阻断阈值(严重级别 / 类别 / 任意意见)
- 无阻断问题时自动合并,审查覆盖不完整时拒绝合并
- 内置 Web 后台:仓库配置、任务日志、失败重跑、连通性自检
- SQLite 持久化,worker 重启回收卡死任务,失败自动重试

实现为独立服务而非 Gitea Action:本机 act_runner 指向的实例不可达,
且后台配置与任务历史需要独立进程承载。
This commit is contained in:
2026-09-20 12:09:46 +08:00
commit ed172bf369
18 changed files with 3451 additions and 0 deletions
+426
View File
@@ -0,0 +1,426 @@
"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) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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 = '<tr><td colspan="7" class="empty">还没有配置仓库。点右上角「新增仓库」开始,然后在 Gitea 仓库里添加 Webhook。</td></tr>';
return;
}
tbody.innerHTML = state.repos.map((r) => `
<tr>
<td><strong>${esc(r.owner)}/${esc(r.name)}</strong><br><span class="tag muted">${esc(r.base_branch)}</span></td>
<td><code>${esc(r.branch_patterns)}</code></td>
<td>${esc(scopeLabel(r.review_scope))}</td>
<td>${r.enabled ? '<span class="tag ok">启用</span>' : '<span class="tag muted">停用</span>'}</td>
<td>${r.auto_merge ? `<span class="tag warn">${esc(r.merge_method)}</span>` : '<span class="tag muted">否</span>'}</td>
<td><code>${esc(r.block_severity || "—")}</code></td>
<td>
<button data-edit="${r.id}">编辑</button>
<button data-run="${r.id}">立即审查</button>
<button class="danger" data-del="${r.id}">删除</button>
</td>
</tr>`).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 = '<tr><td colspan="10" class="empty">暂无任务。仓库收到 push 或 Pull Request 后会出现在这里。</td></tr>';
return;
}
tbody.innerHTML = state.jobs.map((j) => `
<tr>
<td>${j.id}</td>
<td>${esc(j.owner)}/${esc(j.name)}</td>
<td><code>${esc(j.ref_name)}</code>${j.pr_number ? ` <span class="tag muted">PR #${j.pr_number}</span>` : ""}</td>
<td><code>${esc(String(j.to_sha).slice(0, 10))}</code></td>
<td><span class="tag ${STATUS_TAG[j.status] || "muted"}">${esc(j.status)}</span>${j.phase ? ` <span class="tag muted">${esc(j.phase)}</span>` : ""}</td>
<td>${j.findings ?? 0}</td>
<td>${j.blocking ? `<span class="tag err">${j.blocking}</span>` : "0"}</td>
<td>${j.merged ? '<span class="tag ok">已合并</span>' : ""}</td>
<td>${fmtTime(j.started_at || j.created_at)}</td>
<td><button data-log="${j.id}">日志</button> <button data-retry="${j.id}">重跑</button></td>
</tr>`).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);
+192
View File
@@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Gitea 代码审查</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ctext y='14' font-size='14'%3E%F0%9F%94%8D%3C/text%3E%3C/svg%3E" />
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<header>
<h1>Gitea 代码审查</h1>
<div class="right"><div class="status" id="status">加载中…</div><button class="linkish hidden" id="sign-out">清除访问 Token</button></div>
</header>
<nav>
<button class="tab active" data-tab="repos">仓库</button>
<button class="tab" data-tab="jobs">任务</button>
<button class="tab" data-tab="settings">设置</button>
</nav>
<main>
<div id="banner" class="banner hidden"></div>
<section id="tab-repos" class="panel active">
<div class="row">
<h2>已配置仓库</h2>
<button id="add-repo">新增仓库</button>
</div>
<table id="repos-table">
<thead>
<tr>
<th>仓库</th><th>分支过滤</th><th>范围</th><th>启用</th>
<th>自动合并</th><th>阻断级别</th><th></th>
</tr>
</thead>
<tbody></tbody>
</table>
</section>
<section id="tab-jobs" class="panel">
<div class="row">
<h2>审查任务</h2>
<button id="refresh-jobs">刷新</button>
</div>
<table id="jobs-table">
<thead>
<tr><th>#</th><th>仓库</th><th>分支</th><th>提交</th><th>状态</th>
<th>意见</th><th>阻断</th><th>合并</th><th>开始</th><th></th></tr>
</thead>
<tbody></tbody>
</table>
<pre id="job-log" class="log hidden"></pre>
</section>
<section id="tab-settings" class="panel">
<h2>全局设置</h2>
<form id="settings-form">
<fieldset>
<legend>Gitea</legend>
<label>API 地址 <input name="giteaUrl" placeholder="http://127.0.0.1:80" /></label>
<label>管理员 Token <input name="giteaToken" type="password" placeholder="留空表示不修改" /></label>
<label>Webhook 密钥 <input name="webhookSecret" type="password" placeholder="留空表示不修改" /></label>
<label>后台访问 Token <input name="adminToken" type="password" placeholder="留空表示不修改" /></label>
<p class="hint">Webhook 地址:<code id="hook-url"></code>(在仓库 Settings → Webhooks 中添加,密钥填上面这一项)</p>
</fieldset>
<fieldset>
<legend>LLM</legend>
<label>接口地址 <input name="llmUrl" placeholder="https://api.openai.com/v1" /></label>
<label>API Key <input name="llmToken" type="password" placeholder="留空表示不修改" /></label>
<label>模型 <input name="llmModel" placeholder="gpt-5.5" /></label>
<label>协议 <input name="llmProtocol" placeholder="openai / anthropic" /></label>
<label>自定义认证头 <input name="llmAuthHeader" placeholder="留空使用默认" /></label>
<label>附加请求头 <input name="llmExtraHeaders" placeholder="K=V,K=V" /></label>
<label>审查规则文件 <input name="rulePath" placeholder="/etc/gitea-codereview/rule.json" /></label>
</fieldset>
<div class="actions">
<button type="submit">保存</button>
<button type="button" id="test-gitea">测试 Gitea</button>
<button type="button" id="test-llm">测试 LLM</button>
</div>
<pre id="settings-result" class="log hidden"></pre>
</form>
</section>
</main>
<div id="token-gate" class="modal hidden">
<div class="modal-body narrow">
<h2>需要访问 Token</h2>
<p class="hint">该服务设置了 <code>CR_ADMIN_TOKEN</code>,请输入后才能管理仓库与任务。</p>
<form id="token-form">
<label>访问 Token
<input name="token" type="password" autocomplete="current-password" required />
</label>
<div class="actions">
<button type="submit">进入</button>
</div>
<pre id="token-error" class="log hidden"></pre>
</form>
</div>
</div>
<div id="manual-modal" class="modal hidden">
<div class="modal-body narrow">
<h2>立即审查</h2>
<form id="manual-form">
<input type="hidden" name="repo_id" />
<p class="hint" id="manual-repo"></p>
<label>分支 <input name="ref" placeholder="main" required /></label>
<label>提交 SHA <input name="sha" placeholder="40 位 commit hash" required /></label>
<label>基准分支 <input name="base_ref" placeholder="main" /></label>
<label>关联 Pull Request 编号(可选) <input name="pr_number" type="number" min="1" /></label>
<div class="actions">
<button type="submit">加入队列</button>
<button type="button" id="manual-close">取消</button>
</div>
<pre id="manual-result" class="log hidden"></pre>
</form>
</div>
</div>
<div id="modal" class="modal hidden">
<div class="modal-body">
<h2 id="modal-title">仓库配置</h2>
<form id="repo-form">
<input type="hidden" name="id" />
<div class="grid">
<label>所有者 <input name="owner" required /></label>
<label>仓库名 <input name="name" required /></label>
<label>目标分支 <input name="base_branch" placeholder="main" /></label>
<label>监听分支模式 <input name="branch_patterns" placeholder="*, release/*" /></label>
<label>审查范围
<select name="review_scope">
<option value="both">Push 与 PR 都审查</option>
<option value="pr">仅 Pull Request</option>
<option value="push">仅分支 Push</option>
</select>
</label>
<label>发布方式
<select name="publish_mode">
<option value="inline">PR 内联评论</option>
<option value="issue-only">仅 Issue</option>
</select>
</label>
<label>阻断级别 <input name="block_severity" placeholder="critical,high" /></label>
<label>阻断类别 <input name="block_categories" placeholder="security" /></label>
<label>Issue 标签 <input name="issue_labels" placeholder="code-review" /></label>
<label>最大意见数 <input name="max_comments" type="number" min="1" max="200" /></label>
<label>并发数 <input name="concurrency" type="number" min="1" max="16" /></label>
<label>排除路径 <input name="excludes" placeholder="**/dist/**,**/*.lock" /></label>
<label>仓库专用 Gitea Token <input name="gitea_token" type="password" placeholder="留空使用全局" /></label>
<label>仓库专用 LLM Key <input name="llm_token" type="password" placeholder="留空使用全局" /></label>
<label>LLM 模型 <input name="llm_model" placeholder="留空使用全局" /></label>
<label>审查背景 <input name="background_template" placeholder="补充业务上下文" /></label>
</div>
<div class="checks">
<label><input type="checkbox" name="enabled" /> 启用</label>
<label><input type="checkbox" name="create_issue" /> 发现问题时创建 Issue</label>
<label><input type="checkbox" name="auto_merge" /> 无阻断问题时自动合并</label>
<label><input type="checkbox" name="delete_branch" /> 合并后删除分支</label>
<label><input type="checkbox" name="fail_on_findings" /> 有意见即判定失败</label>
</div>
<div class="grid">
<label>自动合并方式
<select name="auto_merge_mode">
<option value="when_checks_succeed">等待检查通过后合并</option>
<option value="immediate">立即合并</option>
</select>
</label>
<label>合并方式
<select name="merge_method">
<option value="squash">squash</option>
<option value="merge">merge</option>
<option value="rebase">rebase</option>
<option value="rebase-merge">rebase-merge</option>
<option value="fast-forward-only">fast-forward-only</option>
</select>
</label>
</div>
<div class="actions">
<button type="submit">保存</button>
<button type="button" id="discover">测试连接</button>
<button type="button" id="modal-close">取消</button>
</div>
<pre id="repo-result" class="log hidden"></pre>
</form>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>
+89
View File
@@ -0,0 +1,89 @@
:root {
--bg: #0f1115;
--panel: #171a21;
--panel-2: #1e222b;
--border: #2a2f3a;
--text: #e6e9ef;
--muted: #99a2b3;
--accent: #4c8dff;
--ok: #3fb950;
--warn: #d29922;
--err: #f85149;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.5 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
}
header {
display: flex; align-items: center; justify-content: space-between;
padding: 16px 24px; border-bottom: 1px solid var(--border); background: var(--panel);
}
h1 { font-size: 18px; margin: 0; }
h2 { font-size: 15px; margin: 0 0 12px; }
.status { color: var(--muted); font-size: 13px; }
nav { display: flex; gap: 4px; padding: 12px 24px 0; }
.tab {
background: transparent; border: 1px solid transparent; color: var(--muted);
padding: 8px 14px; border-radius: 8px 8px 0 0; cursor: pointer; font-size: 14px;
}
.tab.active { background: var(--panel); border-color: var(--border); border-bottom-color: var(--panel); color: var(--text); }
main { padding: 0 24px 48px; }
.panel { display: none; background: var(--panel); border: 1px solid var(--border); border-radius: 0 8px 8px 8px; padding: 20px; }
.panel.active { display: block; }
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
button {
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
padding: 7px 14px; border-radius: 6px; cursor: pointer; font-size: 13px;
}
button:hover { border-color: var(--accent); }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.danger:hover { border-color: var(--err); color: var(--err); }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
th { color: var(--muted); font-weight: 500; }
tr:hover td { background: rgba(255,255,255,0.02); }
code { background: var(--panel-2); padding: 1px 5px; border-radius: 4px; font-size: 12px; }
.tag { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 12px; border: 1px solid var(--border); }
.tag.ok { color: var(--ok); border-color: var(--ok); }
.tag.err { color: var(--err); border-color: var(--err); }
.tag.warn { color: var(--warn); border-color: var(--warn); }
.tag.muted { color: var(--muted); }
fieldset { border: 1px solid var(--border); border-radius: 8px; margin: 0 0 18px; padding: 16px; }
legend { color: var(--muted); padding: 0 6px; }
label { display: block; margin-bottom: 10px; color: var(--muted); font-size: 13px; }
input, select {
display: block; width: 100%; margin-top: 4px; padding: 7px 9px;
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
color: var(--text); font-size: 13px;
}
.checks { display: flex; flex-wrap: wrap; gap: 16px; margin: 8px 0 14px; }
.checks label { display: flex; align-items: center; gap: 6px; margin: 0; }
.checks input { width: auto; margin: 0; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 0 16px; }
.actions { display: flex; gap: 8px; margin-top: 10px; }
.hint { color: var(--muted); font-size: 12px; margin: 6px 0 0; }
.log {
background: #0a0c10; border: 1px solid var(--border); border-radius: 6px;
padding: 12px; font-size: 12px; overflow: auto; max-height: 420px; white-space: pre-wrap;
}
.modal { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: flex-start; justify-content: center; padding: 40px 16px; overflow: auto; z-index: 20; }
.modal-body { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 22px; width: min(900px, 100%); }
.modal-body.narrow { width: min(460px, 100%); }
/* Keep this last: it has to beat the display rules above. */
.hidden { display: none !important; }
.banner {
margin: 0 0 16px; padding: 10px 14px; border-radius: 8px; font-size: 13px;
border: 1px solid var(--err); color: var(--err); background: rgba(248,81,73,0.08);
}
.banner.ok { border-color: var(--ok); color: var(--ok); background: rgba(63,185,80,0.08); }
.empty { color: var(--muted); padding: 18px 4px; }
header .right { display: flex; align-items: center; gap: 12px; }
.linkish {
background: none; border: none; color: var(--muted); cursor: pointer;
font-size: 13px; text-decoration: underline; padding: 0;
}
.linkish:hover { color: var(--accent); }