315 lines
12 KiB
HTML
315 lines
12 KiB
HTML
{% extends 'base.html' %}
|
||
|
||
{% block title %}AI 自動化 Smoke Dashboard - WOOO TECH{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<style>
|
||
.smoke-hero {
|
||
background: radial-gradient(circle at top left, rgba(42, 82, 152, 0.24), transparent 36%),
|
||
linear-gradient(135deg, #10243f 0%, #1e3c72 48%, #2a5298 100%);
|
||
color: #fff;
|
||
border-radius: 22px;
|
||
padding: 28px;
|
||
box-shadow: 0 18px 40px rgba(16, 36, 63, 0.22);
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.smoke-hero::after {
|
||
content: "";
|
||
position: absolute;
|
||
width: 280px;
|
||
height: 280px;
|
||
right: -90px;
|
||
top: -90px;
|
||
border-radius: 999px;
|
||
border: 42px solid rgba(255, 255, 255, 0.08);
|
||
}
|
||
|
||
.smoke-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 13px;
|
||
border-radius: 999px;
|
||
background: rgba(255, 255, 255, 0.14);
|
||
backdrop-filter: blur(10px);
|
||
font-size: 13px;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
.smoke-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||
gap: 18px;
|
||
}
|
||
|
||
.smoke-card {
|
||
border: 0;
|
||
border-radius: 18px;
|
||
box-shadow: 0 10px 28px rgba(30, 60, 114, 0.12);
|
||
overflow: hidden;
|
||
transition: transform .22s ease, box-shadow .22s ease;
|
||
}
|
||
|
||
.smoke-card:hover {
|
||
transform: translateY(-4px);
|
||
box-shadow: 0 16px 36px rgba(30, 60, 114, 0.18);
|
||
}
|
||
|
||
.status-badge {
|
||
border-radius: 999px;
|
||
padding: 6px 11px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
.status-ok { background: #dcfce7; color: #166534; }
|
||
.status-warning { background: #fef3c7; color: #92400e; }
|
||
.status-critical { background: #fee2e2; color: #991b1b; }
|
||
|
||
.detail-box {
|
||
background: #f8fafc;
|
||
border-radius: 12px;
|
||
padding: 12px;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
font-size: 12px;
|
||
color: #334155;
|
||
max-height: 160px;
|
||
overflow: auto;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.trend-strip {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(14px, 1fr));
|
||
gap: 6px;
|
||
align-items: end;
|
||
}
|
||
|
||
.trend-dot {
|
||
height: 34px;
|
||
border-radius: 8px;
|
||
opacity: .92;
|
||
}
|
||
|
||
.trend-dot.status-ok { background: #22c55e; }
|
||
.trend-dot.status-warning { background: #f59e0b; }
|
||
.trend-dot.status-critical { background: #ef4444; }
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="smoke-hero mb-4">
|
||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 position-relative" style="z-index: 1;">
|
||
<div>
|
||
<span class="smoke-pill mb-3"><i class="fas fa-robot"></i> FOUR-AGENT CONTROL PLANE</span>
|
||
<h1 class="fw-bold mb-2">AI 自動化 Smoke Dashboard</h1>
|
||
<p class="mb-0 text-white-50">快速確認 EventRouter、AutoHeal、NemoTron、OpenClaw 與 ElephantAlpha 的閉環狀態。</p>
|
||
</div>
|
||
<div class="text-lg-end">
|
||
<div id="overallStatus" class="h4 fw-bold mb-2">讀取中...</div>
|
||
<div class="text-white-50">版本 {{ system_version }}</div>
|
||
<button id="refreshBtn" class="btn btn-light mt-3">
|
||
<i class="fas fa-sync-alt me-2"></i>重新檢查
|
||
</button>
|
||
<a class="btn btn-outline-light mt-3 ms-lg-2" href="/api/ai-automation/smoke/history/export">
|
||
<i class="fas fa-file-export me-2"></i>匯出 JSONL
|
||
</a>
|
||
<button id="clearHistoryBtn" class="btn btn-outline-warning mt-3 ms-lg-2">
|
||
<i class="fas fa-broom me-2"></i>清理趨勢
|
||
</button>
|
||
<button id="sendSummaryBtn" class="btn btn-outline-info mt-3 ms-lg-2">
|
||
<i class="fas fa-paper-plane me-2"></i>推播摘要
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row g-3 mb-4" id="summaryCards">
|
||
<div class="col-md-3"><div class="card smoke-card"><div class="card-body"><div class="text-muted small">OK</div><div class="h3 mb-0" id="okCount">-</div></div></div></div>
|
||
<div class="col-md-3"><div class="card smoke-card"><div class="card-body"><div class="text-muted small">Warning</div><div class="h3 mb-0" id="warningCount">-</div></div></div></div>
|
||
<div class="col-md-3"><div class="card smoke-card"><div class="card-body"><div class="text-muted small">Critical</div><div class="h3 mb-0" id="criticalCount">-</div></div></div></div>
|
||
<div class="col-md-3"><div class="card smoke-card"><div class="card-body"><div class="text-muted small">Generated</div><div class="fs-6 fw-semibold" id="generatedAt">-</div></div></div></div>
|
||
</div>
|
||
|
||
<div class="card smoke-card mb-4">
|
||
<div class="card-body">
|
||
<div class="d-flex flex-column flex-md-row justify-content-between gap-2 mb-3">
|
||
<div>
|
||
<h5 class="mb-1">最近 Smoke 趨勢</h5>
|
||
<div class="text-muted small">每次 API 快檢會保存一筆精簡紀錄,保留最近 200 筆。</div>
|
||
</div>
|
||
<div class="text-muted small" id="historySummary">等待資料...</div>
|
||
</div>
|
||
<div class="trend-strip" id="trendStrip"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card smoke-card mb-4">
|
||
<div class="card-body">
|
||
<div class="d-flex flex-column flex-md-row justify-content-between gap-2 mb-3">
|
||
<div>
|
||
<h5 class="mb-1">每日摘要</h5>
|
||
<div class="text-muted small">依最近保存紀錄彙整,方便快速觀察是否有連續 warning / critical。</div>
|
||
</div>
|
||
</div>
|
||
<div class="table-responsive">
|
||
<table class="table align-middle mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th>日期</th>
|
||
<th>OK</th>
|
||
<th>Warning</th>
|
||
<th>Critical</th>
|
||
<th>Total</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="dailySummaryRows">
|
||
<tr><td colspan="5" class="text-muted">等待資料...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="smoke-grid" id="checkGrid"></div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script>
|
||
function badge(status) {
|
||
const label = {ok: 'OK', warning: 'WARNING', critical: 'CRITICAL'}[status] || status;
|
||
return `<span class="status-badge status-${status}">${label}</span>`;
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? '').replace(/[&<>"']/g, char => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
}[char]));
|
||
}
|
||
|
||
function renderSmoke(data) {
|
||
document.getElementById('overallStatus').innerHTML = badge(data.status);
|
||
document.getElementById('okCount').textContent = data.summary.ok ?? 0;
|
||
document.getElementById('warningCount').textContent = data.summary.warning ?? 0;
|
||
document.getElementById('criticalCount').textContent = data.summary.critical ?? 0;
|
||
document.getElementById('generatedAt').textContent = data.generated_at || '-';
|
||
renderTrend(data.history || {});
|
||
|
||
const grid = document.getElementById('checkGrid');
|
||
grid.innerHTML = data.checks.map(item => `
|
||
<div class="card smoke-card">
|
||
<div class="card-body">
|
||
<div class="d-flex justify-content-between align-items-start gap-2 mb-3">
|
||
<h5 class="mb-0">${escapeHtml(item.name)}</h5>
|
||
${badge(item.status)}
|
||
</div>
|
||
<p class="text-muted">${escapeHtml(item.summary)}</p>
|
||
<div class="detail-box">${escapeHtml(JSON.stringify(item.details || {}, null, 2))}</div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function renderTrend(history) {
|
||
const recent = history.recent || [];
|
||
const counts = history.counts || {ok: 0, warning: 0, critical: 0};
|
||
document.getElementById('historySummary').textContent =
|
||
`OK ${counts.ok || 0} / Warning ${counts.warning || 0} / Critical ${counts.critical || 0}`;
|
||
|
||
const strip = document.getElementById('trendStrip');
|
||
if (!recent.length) {
|
||
strip.innerHTML = '<div class="text-muted small">尚無歷史紀錄。</div>';
|
||
return;
|
||
}
|
||
strip.innerHTML = recent.map(item => `
|
||
<div class="trend-dot status-${item.status || 'critical'}"
|
||
title="${escapeHtml((item.generated_at || '-') + ' · ' + (item.status || 'unknown'))}">
|
||
</div>
|
||
`).join('');
|
||
renderDailySummary(history.daily || []);
|
||
}
|
||
|
||
function renderDailySummary(rows) {
|
||
const body = document.getElementById('dailySummaryRows');
|
||
if (!rows.length) {
|
||
body.innerHTML = '<tr><td colspan="5" class="text-muted">尚無每日摘要。</td></tr>';
|
||
return;
|
||
}
|
||
body.innerHTML = rows.slice().reverse().map(row => `
|
||
<tr>
|
||
<td>${escapeHtml(row.date)}</td>
|
||
<td class="text-success fw-semibold">${row.ok || 0}</td>
|
||
<td class="text-warning fw-semibold">${row.warning || 0}</td>
|
||
<td class="text-danger fw-semibold">${row.critical || 0}</td>
|
||
<td>${row.total || 0}</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
async function loadSmoke() {
|
||
const btn = document.getElementById('refreshBtn');
|
||
btn.disabled = true;
|
||
btn.querySelector('i').classList.add('fa-spin');
|
||
try {
|
||
const res = await fetchWithCSRF('/api/ai-automation/smoke');
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
renderSmoke(await res.json());
|
||
} catch (err) {
|
||
document.getElementById('overallStatus').innerHTML = badge('critical');
|
||
document.getElementById('checkGrid').innerHTML = `
|
||
<div class="card smoke-card">
|
||
<div class="card-body">
|
||
<h5>Smoke API</h5>
|
||
<p class="text-danger">讀取失敗:${escapeHtml(err.message)}</p>
|
||
</div>
|
||
</div>`;
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.querySelector('i').classList.remove('fa-spin');
|
||
}
|
||
}
|
||
|
||
document.getElementById('refreshBtn').addEventListener('click', loadSmoke);
|
||
document.getElementById('clearHistoryBtn').addEventListener('click', async () => {
|
||
if (!confirm('確定要清理 AI Smoke 趨勢紀錄?這只會刪除本頁 JSONL history,不會影響 DB 或事件資料。')) {
|
||
return;
|
||
}
|
||
const btn = document.getElementById('clearHistoryBtn');
|
||
btn.disabled = true;
|
||
try {
|
||
const res = await fetchWithCSRF('/api/ai-automation/smoke/history/clear', {method: 'POST'});
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const data = await res.json();
|
||
showToast(`已清理 ${data.cleared || 0} 筆 Smoke 趨勢紀錄`, 'success');
|
||
await loadSmoke();
|
||
} catch (err) {
|
||
showToast(`清理失敗:${err.message}`, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
document.getElementById('sendSummaryBtn').addEventListener('click', async () => {
|
||
const btn = document.getElementById('sendSummaryBtn');
|
||
btn.disabled = true;
|
||
try {
|
||
const res = await fetchWithCSRF('/api/ai-automation/smoke/daily-summary/send', {method: 'POST'});
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error((data.telegram?.errors || []).join(', ') || `HTTP ${res.status}`);
|
||
showToast(`AI Smoke 每日摘要已推播:sent=${data.telegram?.sent || 0}`, 'success');
|
||
} catch (err) {
|
||
showToast(`推播失敗:${err.message}`, 'error');
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
loadSmoke();
|
||
</script>
|
||
{% endblock %}
|