Files
ewoooc/templates/code_review.html
ogt 2e0de960ce
All checks were successful
CD Pipeline / deploy (push) Successful in 1m21s
feat(code-review): 重建為 Post-Deploy AI Agent Pipeline
架構重建:
- 移除 pre-commit hook(本機 commit 不再阻塞)
- 改為 CD 健康檢查通過後自動觸發 webhook

新建 services/code_review_pipeline_service.py:
  5-Step Pipeline(後台 daemon thread)
  Step1 system        讀取部署後變更檔案內容
  Step2 Hermes        程式碼掃描(bugs/security/perf,hermes3:latest)
  Step3 OpenClaw      架構品質評估(Gemini 2.5 Flash)
  Step4 ElephantAlpha 決策協調(severity + auto_fix 裁量)
  Step5 NemoTron      action_plans 寫入 + AiderHeal 觸發
  全程 Telegram 告警(啟動/完成/錯誤)+ ai_insights DB 持久化

重建 routes/code_review_routes.py:
  POST /code-review/api/internal/trigger  CD webhook(X-Internal-Token)
  GET  /code-review/api/status            前端即時 polling
  GET  /code-review/api/history           歷史清單
  GET  /code-review/                      前端儀表板

重建 templates/code_review.html:
  深色儀表板,Pipeline 即時進度 + Severity 分佈 + 問題清單 + EA 決策
  3s polling(running)/ 30s(idle)

.gitea/workflows/cd.yaml:
  健康檢查通過後注入「觸發 AI Code Review」step
  continue-on-error: true(不影響部署結果)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:55:23 +08:00

439 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EwoooC — AI Code Review Dashboard</title>
<style>
:root {
--bg: #0d1117;
--panel: #161b22;
--border: #30363d;
--text: #e6edf3;
--muted: #8b949e;
--red: #f85149;
--orange: #d29922;
--yellow: #e3b341;
--green: #3fb950;
--blue: #58a6ff;
--purple: #bc8cff;
--accent: #e94560;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: -apple-system, 'Segoe UI', sans-serif; font-size: 14px; }
/* ── Layout ─────────────────────────────────────────── */
.topbar { background: var(--panel); border-bottom: 1px solid var(--border); padding: 12px 24px; display: flex; align-items: center; gap: 12px; }
.topbar h1 { font-size: 18px; font-weight: 700; color: var(--accent); }
.topbar .badge { font-size: 11px; padding: 2px 8px; border-radius: 12px; background: #21262d; color: var(--muted); }
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); animation: pulse 1.5s infinite; margin-left: auto; }
.live-dot.idle { background: var(--muted); animation: none; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } }
.layout { display: grid; grid-template-columns: 340px 1fr; gap: 0; height: calc(100vh - 49px); overflow: hidden; }
.sidebar { background: var(--panel); border-right: 1px solid var(--border); overflow-y: auto; padding: 16px; }
.main { overflow-y: auto; padding: 16px; }
/* ── Card ───────────────────────────────────────────── */
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 12px; overflow: hidden; }
.card-header { padding: 10px 14px; border-bottom: 1px solid var(--border); font-weight: 600; font-size: 13px; display: flex; align-items: center; gap: 8px; }
.card-body { padding: 14px; }
/* ── Pipeline Steps ──────────────────────────────────── */
.pipeline { display: flex; flex-direction: column; gap: 6px; }
.step { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 6px; border: 1px solid var(--border); background: #0d1117; transition: border-color .3s; }
.step.running { border-color: var(--blue); background: rgba(88,166,255,.07); }
.step.ok { border-color: var(--green); background: rgba(63,185,80,.06); }
.step.error { border-color: var(--red); background: rgba(248,81,73,.07); }
.step-num { width: 22px; height: 22px; border-radius: 50%; background: #21262d; font-size: 11px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.step.ok .step-num { background: var(--green); color: #000; }
.step.running .step-num { background: var(--blue); color: #000; }
.step.error .step-num { background: var(--red); color: #fff; }
.step-info { flex: 1; min-width: 0; }
.step-name { font-weight: 600; font-size: 13px; }
.step-agent { font-size: 11px; color: var(--purple); }
.step-summary { font-size: 11px; color: var(--muted); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.step-icon { font-size: 16px; }
.spinner { display: inline-block; animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Severity Badges ─────────────────────────────────── */
.sev-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 8px; }
.sev-cell { text-align: center; padding: 10px 6px; border-radius: 6px; border: 1px solid var(--border); }
.sev-cell .num { font-size: 28px; font-weight: 700; line-height: 1; }
.sev-cell .lbl { font-size: 11px; color: var(--muted); margin-top: 4px; }
.sev-critical { border-color: var(--red)!important; } .sev-critical .num { color: var(--red); }
.sev-high { border-color: var(--orange)!important; } .sev-high .num { color: var(--orange); }
.sev-medium { border-color: var(--yellow)!important; } .sev-medium .num { color: var(--yellow); }
.sev-low { border-color: var(--green)!important; } .sev-low .num { color: var(--green); }
/* ── Findings Table ──────────────────────────────────── */
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { padding: 8px 10px; text-align: left; color: var(--muted); font-weight: 600; border-bottom: 1px solid var(--border); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
td { padding: 8px 10px; border-bottom: 1px solid #21262d; vertical-align: top; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(255,255,255,.02); }
.badge-sev { display: inline-block; padding: 2px 7px; border-radius: 10px; font-size: 11px; font-weight: 700; }
.badge-CRITICAL { background: rgba(248,81,73,.2); color: var(--red); }
.badge-HIGH { background: rgba(210,153,34,.2); color: var(--orange); }
.badge-MEDIUM { background: rgba(227,179,65,.2); color: var(--yellow); }
.badge-LOW { background: rgba(63,185,80,.2); color: var(--green); }
.badge-type { display: inline-block; padding: 1px 6px; border-radius: 8px; font-size: 10px; background: #21262d; color: var(--muted); }
code { background: #21262d; padding: 1px 5px; border-radius: 4px; font-size: 12px; color: var(--blue); }
/* ── OpenClaw Report ─────────────────────────────────── */
.report-box { background: #0d1117; border: 1px solid var(--border); border-radius: 6px; padding: 12px; font-size: 13px; line-height: 1.6; }
.report-box b { color: var(--text); }
/* ── EA Decision ─────────────────────────────────────── */
.ea-box { padding: 12px 14px; border-radius: 6px; border-left: 4px solid var(--blue); background: rgba(88,166,255,.06); }
.ea-box.critical { border-left-color: var(--red); background: rgba(248,81,73,.07); }
.ea-box.high { border-left-color: var(--orange); background: rgba(210,153,34,.07); }
.ea-box.medium { border-left-color: var(--yellow); }
.ea-box.low { border-left-color: var(--green); }
.ea-priority { font-size: 20px; font-weight: 700; }
.ea-reasoning { color: var(--muted); font-size: 12px; margin-top: 4px; }
.ea-fix { margin-top: 8px; font-size: 12px; }
/* ── History ─────────────────────────────────────────── */
.hist-item { padding: 10px; border: 1px solid var(--border); border-radius: 6px; margin-bottom: 8px; cursor: pointer; transition: border-color .2s; }
.hist-item:hover { border-color: var(--blue); }
.hist-sha { font-family: monospace; font-size: 12px; color: var(--blue); }
.hist-meta { font-size: 11px; color: var(--muted); margin-top: 3px; }
.hist-sev { display: flex; gap: 6px; margin-top: 5px; }
.hist-sev span { font-size: 11px; padding: 1px 6px; border-radius: 8px; }
/* ── Status Bar ──────────────────────────────────────── */
#statusBar { padding: 10px 14px; border-radius: 6px; margin-bottom: 12px; font-size: 13px; display: none; }
#statusBar.running { background: rgba(88,166,255,.1); border: 1px solid var(--blue); }
#statusBar.completed { background: rgba(63,185,80,.1); border: 1px solid var(--green); }
#statusBar.error { background: rgba(248,81,73,.1); border: 1px solid var(--red); }
#statusBar.skipped { background: rgba(139,148,158,.1); border: 1px solid var(--border); }
/* ── Empty state ─────────────────────────────────────── */
.empty { text-align: center; padding: 40px; color: var(--muted); }
.empty .icon { font-size: 40px; margin-bottom: 10px; }
/* ── Tabs ────────────────────────────────────────────── */
.tabs { display: flex; gap: 4px; margin-bottom: 12px; }
.tab { padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; color: var(--muted); border: 1px solid transparent; }
.tab.active { background: #21262d; color: var(--text); border-color: var(--border); }
.tab-pane { display: none; }
.tab-pane.active { display: block; }
</style>
</head>
<body>
<!-- Top Bar -->
<div class="topbar">
<span>🔍</span>
<h1>AI Code Review</h1>
<span class="badge">EwoooC · Post-Deploy Pipeline</span>
<div id="liveDot" class="live-dot idle" title="Pipeline 狀態"></div>
</div>
<div class="layout">
<!-- ── Sidebar ─────────────────────────────────────────────────── -->
<div class="sidebar">
<!-- Pipeline Steps -->
<div class="card">
<div class="card-header">🤖 Pipeline 進度
<span id="pipelineId" style="font-size:11px;color:var(--muted);margin-left:auto;font-family:monospace"></span>
</div>
<div class="card-body">
<div class="pipeline" id="stepsContainer">
<div class="step" id="step-1"><div class="step-num">1</div><div class="step-info"><div class="step-name">讀取變更檔案</div><div class="step-agent">system</div></div></div>
<div class="step" id="step-2"><div class="step-num">2</div><div class="step-info"><div class="step-name">Hermes 程式碼掃描</div><div class="step-agent">Hermes · hermes3:latest</div></div></div>
<div class="step" id="step-3"><div class="step-num">3</div><div class="step-info"><div class="step-name">OpenClaw 架構評估</div><div class="step-agent">OpenClaw · Gemini 2.5</div></div></div>
<div class="step" id="step-4"><div class="step-num">4</div><div class="step-info"><div class="step-name">Elephant Alpha 決策</div><div class="step-agent">Elephant Alpha · 100B</div></div></div>
<div class="step" id="step-5"><div class="step-num">5</div><div class="step-info"><div class="step-name">NemoTron 行動派遣</div><div class="step-agent">NemoTron · Dispatcher</div></div></div>
</div>
</div>
</div>
<!-- Severity Summary -->
<div class="card">
<div class="card-header">📊 問題嚴重度分佈</div>
<div class="card-body">
<div class="sev-grid">
<div class="sev-cell sev-critical"><div class="num" id="cnt-critical"></div><div class="lbl">🔴 CRITICAL</div></div>
<div class="sev-cell sev-high"><div class="num" id="cnt-high"></div><div class="lbl">🟠 HIGH</div></div>
<div class="sev-cell sev-medium"><div class="num" id="cnt-medium"></div><div class="lbl">🟡 MEDIUM</div></div>
<div class="sev-cell sev-low"><div class="num" id="cnt-low"></div><div class="lbl">🟢 LOW</div></div>
</div>
</div>
</div>
<!-- Commit Info -->
<div class="card">
<div class="card-header">📦 本次部署資訊</div>
<div class="card-body" id="commitInfo" style="font-size:13px;color:var(--muted);line-height:1.8;">
<span style="color:var(--muted)">等待下次部署觸發...</span>
</div>
</div>
<!-- History -->
<div class="card">
<div class="card-header">🕐 歷史記錄</div>
<div class="card-body" style="padding:8px;" id="historyList">
<div class="empty"><div class="icon">📋</div><div>載入中...</div></div>
</div>
</div>
</div>
<!-- ── Main Content ─────────────────────────────────────────────── -->
<div class="main">
<!-- Status Bar -->
<div id="statusBar"></div>
<!-- Tabs -->
<div class="tabs">
<div class="tab active" onclick="switchTab('findings')">⚠️ 問題清單</div>
<div class="tab" onclick="switchTab('openclaw')">💡 OpenClaw 評估</div>
<div class="tab" onclick="switchTab('ea')">🤖 Elephant Alpha 決策</div>
</div>
<!-- Tab: Findings -->
<div class="tab-pane active" id="tab-findings">
<div class="card">
<div class="card-header">⚠️ 問題清單
<span id="findingsCount" style="font-size:11px;color:var(--muted);margin-left:auto"></span>
</div>
<div class="card-body" style="padding:0">
<div id="findingsTable">
<div class="empty"><div class="icon">🔍</div><div>等待 Code Review 完成...</div></div>
</div>
</div>
</div>
</div>
<!-- Tab: OpenClaw -->
<div class="tab-pane" id="tab-openclaw">
<div class="card">
<div class="card-header">💡 OpenClaw 架構品質評估</div>
<div class="card-body">
<div class="report-box" id="openclawReport">
<span style="color:var(--muted)">等待 OpenClaw 分析完成...</span>
</div>
</div>
</div>
</div>
<!-- Tab: EA Decision -->
<div class="tab-pane" id="tab-ea">
<div class="card">
<div class="card-header">🤖 Elephant Alpha 決策協調結果</div>
<div class="card-body">
<div id="eaDecision">
<span style="color:var(--muted)">等待 Elephant Alpha 決策...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
const SEV_ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
let _polling = null;
let _lastPipelineId = null;
// ── Tab switching ──────────────────────────────────────────────────
function switchTab(name) {
document.querySelectorAll('.tab').forEach((t,i) => t.classList.toggle('active', ['findings','openclaw','ea'][i] === name));
document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active'));
document.getElementById('tab-' + name).classList.add('active');
}
// ── Pipeline step rendering ───────────────────────────────────────
function renderSteps(steps, currentStep) {
for (let i = 1; i <= 5; i++) {
const el = document.getElementById('step-' + i);
if (!el) continue;
const info = steps.find(s => s.step === i);
el.className = 'step';
const numEl = el.querySelector('.step-num');
const summEl = el.querySelector('.step-summary') || (() => {
const d = document.createElement('div');
d.className = 'step-summary';
el.querySelector('.step-info').appendChild(d);
return d;
})();
if (info) {
if (info.status === 'ok') { el.classList.add('ok'); numEl.textContent = '✓'; }
else if (info.status === 'error') { el.classList.add('error'); numEl.textContent = '✗'; }
else if (info.status === 'running') { el.classList.add('running'); numEl.innerHTML = '<span class="spinner">⟳</span>'; }
summEl.textContent = info.summary || '';
} else if (i === currentStep) {
el.classList.add('running');
numEl.innerHTML = '<span class="spinner">⟳</span>';
} else {
numEl.textContent = i;
}
}
}
// ── Severity counters ─────────────────────────────────────────────
function renderSeverity(sev) {
['critical','high','medium','low'].forEach(k => {
const el = document.getElementById('cnt-' + k);
if (el) el.textContent = (sev && sev[k] !== undefined) ? sev[k] : '0';
});
}
// ── Findings table ────────────────────────────────────────────────
function renderFindings(findings) {
const el = document.getElementById('findingsTable');
document.getElementById('findingsCount').textContent = findings.length ? `${findings.length}` : '';
if (!findings.length) {
el.innerHTML = '<div class="empty"><div class="icon">✅</div><div>無發現問題</div></div>';
return;
}
const sorted = [...findings].sort((a,b) => (SEV_ORDER[a.severity]||3) - (SEV_ORDER[b.severity]||3));
el.innerHTML = `<table>
<thead><tr><th>嚴重度</th><th>類型</th><th>檔案 / 位置</th><th>問題說明</th><th>修復建議</th></tr></thead>
<tbody>${sorted.map(f => `
<tr>
<td><span class="badge-sev badge-${f.severity}">${f.severity}</span></td>
<td><span class="badge-type">${f.type||'?'}</span></td>
<td><code>${(f.file||'').split('/').pop()}</code><br><span style="font-size:11px;color:var(--muted)">${f.line_hint||''}</span></td>
<td>${escHtml(f.description||'')}</td>
<td style="color:var(--muted)">${escHtml(f.suggestion||'')}</td>
</tr>`).join('')}
</tbody></table>`;
}
// ── OpenClaw report ───────────────────────────────────────────────
function renderOpenClaw(html) {
document.getElementById('openclawReport').innerHTML = html || '<span style="color:var(--muted)">(未取得)</span>';
}
// ── EA Decision ───────────────────────────────────────────────────
function renderEA(ea, autoFix) {
if (!ea || !ea.priority) {
document.getElementById('eaDecision').innerHTML = '<span style="color:var(--muted)">等待決策...</span>';
return;
}
const priColors = { critical: 'var(--red)', high: 'var(--orange)', medium: 'var(--yellow)', low: 'var(--green)' };
const col = priColors[ea.priority] || 'var(--blue)';
const fixFiles = (ea.fix_files||[]).map(f=>`<code>${f}</code>`).join(' ');
document.getElementById('eaDecision').innerHTML = `
<div class="ea-box ${ea.priority}">
<div style="display:flex;align-items:center;gap:10px">
<div class="ea-priority" style="color:${col}">${ea.priority.toUpperCase()}</div>
<div>${autoFix ? '🔧 <b>自動修復已觸發AiderHeal</b>' : (ea.human_review_needed ? '👁 <b>需人工審查</b>' : '✅ <b>無需修復</b>')}</div>
</div>
<div class="ea-reasoning">${escHtml(ea.reasoning||'')}</div>
${fixFiles ? `<div class="ea-fix">修復範圍:${fixFiles}</div>` : ''}
</div>`;
}
// ── Commit info ───────────────────────────────────────────────────
function renderCommitInfo(state) {
const el = document.getElementById('commitInfo');
if (!state.commit_sha) return;
const files = (state.changed_files||[]).slice(0,5).map(f=>`<code>${f.split('/').pop()}</code>`).join(' ');
const more = (state.changed_files||[]).length > 5 ? `<span style="color:var(--muted)">+${state.changed_files.length-5}</span>` : '';
el.innerHTML = `
<div><b>Commit</b> <code>${state.commit_sha.slice(0,8)}</code></div>
<div><b>Branch</b> <code>${state.branch||'?'}</code></div>
<div><b>模式</b> ${state.deploy_type||'sync'}</div>
<div><b>變更</b> ${files} ${more}</div>`;
}
// ── Status bar ────────────────────────────────────────────────────
function renderStatusBar(state) {
const el = document.getElementById('statusBar');
const dot = document.getElementById('liveDot');
if (!state.status) { el.style.display='none'; return; }
el.style.display = 'block';
el.className = state.status;
const msgs = {
running: `⟳ <b>Pipeline 執行中</b> — Step ${state.current_step}/5`,
completed: `✅ <b>Code Review 完成</b> — ${state.message||''}`,
error: `❌ <b>Pipeline 失敗</b> — ${escHtml(state.message||'')}`,
skipped: `⏭ <b>已略過</b> — ${escHtml(state.message||'')}`,
};
el.innerHTML = msgs[state.status] || '';
dot.className = 'live-dot' + (state.status === 'running' ? '' : ' idle');
}
// ── History ───────────────────────────────────────────────────────
function renderHistory(items) {
const el = document.getElementById('historyList');
if (!items.length) { el.innerHTML = '<div class="empty"><div>尚無歷史記錄</div></div>'; return; }
el.innerHTML = items.map(h => {
const sev = h.severity_summary || {};
return `<div class="hist-item">
<div style="display:flex;justify-content:space-between">
<span class="hist-sha">${h.commit_sha}</span>
<span style="font-size:11px;color:var(--muted)">${h.created_at.slice(0,16).replace('T',' ')}</span>
</div>
<div class="hist-meta">🌿 ${h.branch}${(h.changed_files||[]).length} 檔案${h.auto_fix?' • 🔧 已自動修復':''}</div>
<div class="hist-sev">
${sev.critical?`<span style="background:rgba(248,81,73,.2);color:var(--red)">🔴 ${sev.critical}</span>`:''}
${sev.high?`<span style="background:rgba(210,153,34,.2);color:var(--orange)">🟠 ${sev.high}</span>`:''}
${sev.medium?`<span style="background:rgba(227,179,65,.2);color:var(--yellow)">🟡 ${sev.medium}</span>`:''}
${sev.low?`<span style="background:rgba(63,185,80,.2);color:var(--green)">🟢 ${sev.low}</span>`:''}
${!h.total_issues?`<span style="color:var(--green)">✅ 無問題</span>`:''}
</div>
</div>`;
}).join('');
}
// ── Main polling loop ─────────────────────────────────────────────
async function poll() {
try {
const r = await fetch('/code-review/api/status');
const state = await r.json();
if (state.pipeline_id !== _lastPipelineId && state.pipeline_id) {
_lastPipelineId = state.pipeline_id;
}
renderStatusBar(state);
renderSteps(state.steps||[], state.current_step||0);
renderSeverity(state.severity_summary);
renderFindings(state.findings||[]);
renderOpenClaw(state.openclaw_report);
renderEA(state.ea_decision, state.auto_fix_triggered);
renderCommitInfo(state);
document.getElementById('pipelineId').textContent = (state.pipeline_id||'').slice(-14);
// 每 3s 輪詢running/ 30sidle
const interval = state.status === 'running' ? 3000 : 30000;
_polling = setTimeout(poll, interval);
} catch(e) {
_polling = setTimeout(poll, 10000);
}
}
async function loadHistory() {
try {
const r = await fetch('/code-review/api/history?limit=15');
renderHistory(await r.json());
} catch(e) {
document.getElementById('historyList').innerHTML = '<div class="empty"><div>載入失敗</div></div>';
}
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ── Init ──────────────────────────────────────────────────────────
poll();
loadHistory();
setInterval(loadHistory, 60000);
</script>
</body>
</html>