feat(p54): chart.js 視覺微調 — KPI sparkline + verdict 圓餅 + heal 趨勢
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

R-1: ai_calls KPI 卡片加 24h sparkline
- 呼叫次數卡片下加 24px 高 mini line chart(藍)
- 成本卡片下加 sparkline(黃)
- 錯誤次數卡片下加 sparkline(紅)
- Token / 平均耗時 / RAG 命中卡片改顯示「平均 tk/call」「cache 命中數」「RAG 命中率%」
- 整排 KPI 從乾巴巴數字 → 含 24h 趨勢視覺
- 共用 chart.js dataset,無新 query

R-2: business_intel verdict 改 doughnut + 表格雙視角
- 取代原 col-md-3 卡片網格
- 左圓餅:effective(綠)/backfired(紅)/neutral(灰) 視覺比例
- 右表格:4 欄(verdict/筆數/佔比/平均 Δ)含正負色
- 與 quality_trend RAG pie chart 視覺風格統一

R-3: host_health AIOps card 加 7d 自癒成功率 sparkline
- routes/admin_observability_routes.py 新加 heal_daily query
  date_trunc('day') GROUP BY 7 天每日 success rate
- AIOps 7d card 底部加 80px 高 line chart
- Y 軸 0-100% / X 軸 7 天日期
- tooltip 顯示「ok/total 成功 (rate%)」

chart.js 視覺化從 4 個 → 7 個:
hourly trend / 30d stacked / 三主機 sparkline / RAG doughnut /
KPI sparkline × 3 / verdict doughnut / heal trend

Phase 38→54 累計 19 commits / 10 觀測頁 + topbar indicator / 7 chart.js。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
OoO
2026-05-05 01:13:31 +08:00
parent 118f10701b
commit 90e8366a8d
4 changed files with 200 additions and 28 deletions

View File

@@ -2347,6 +2347,27 @@ def host_health_dashboard():
float(heal_rows[1] or 0) / float(heal_rows[0]) * 100
) if heal_rows[0] else 0,
}
# Phase 54 R-3: heal 7d daily success rate sparkline
heal_daily = _session2.execute(
sa_text("""
SELECT date_trunc('day', created_at)::date AS d,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE result = 'success') AS ok
FROM heal_logs
WHERE created_at >= NOW() - INTERVAL '7 days'
GROUP BY d ORDER BY d ASC
"""),
).fetchall()
aiops_summary['heal_sparkline'] = [
{
'date': r[0].strftime('%m-%d') if r[0] else '',
'total': int(r[1] or 0),
'ok': int(r[2] or 0),
'rate': (float(r[2] or 0) / float(r[1]) * 100) if r[1] else 0,
}
for r in heal_daily
]
except Exception:
aiops_summary = {}

View File

@@ -55,14 +55,54 @@
</div>
</form>
<!-- 總覽 KPI -->
<!-- 總覽 KPIPhase 54: 加 sparkline -->
<div class="row g-2 mb-3">
<div class="col-lg-2 col-md-4 col-sm-6"><div class="card p-2"><small>呼叫次數</small><h4>{{ "{:,}".format(summary.total_calls or 0) }}</h4></div></div>
<div class="col-lg-2 col-md-4 col-sm-6"><div class="card p-2"><small>Token 用量</small><h4>{{ "{:,}".format(summary.total_tokens or 0) }}</h4></div></div>
<div class="col-lg-2 col-md-4 col-sm-6"><div class="card p-2"><small>成本 (USD)</small><h4>${{ "%.2f"|format(summary.total_cost or 0) }}</h4></div></div>
<div class="col-lg-2 col-md-4 col-sm-6"><div class="card p-2"><small>平均耗時</small><h4>{{ summary.avg_duration or 0 }} ms</h4></div></div>
<div class="col-lg-2 col-md-4 col-sm-6"><div class="card p-2"><small>RAG 命中</small><h4 class="text-success">{{ summary.rag_hits or 0 }}</h4></div></div>
<div class="col-lg-2 col-md-4 col-sm-6"><div class="card p-2"><small>錯誤次數</small><h4 class="{% if (summary.error_calls or 0) > 0 %}text-danger{% endif %}">{{ summary.error_calls or 0 }}</h4></div></div>
<div class="col-lg-2 col-md-4 col-sm-6">
<div class="card p-2">
<small>呼叫次數</small>
<h4 class="mb-0">{{ "{:,}".format(summary.total_calls or 0) }}</h4>
{% if hourly_trend %}<canvas data-spark="calls" height="24" style="max-height: 24px;"></canvas>{% endif %}
</div>
</div>
<div class="col-lg-2 col-md-4 col-sm-6">
<div class="card p-2">
<small>Token 用量</small>
<h4 class="mb-0">{{ "{:,}".format(summary.total_tokens or 0) }}</h4>
<small class="text-muted">{{ (summary.total_tokens or 0) // (summary.total_calls or 1) }} tk/call 平均</small>
</div>
</div>
<div class="col-lg-2 col-md-4 col-sm-6">
<div class="card p-2">
<small>成本 (USD)</small>
<h4 class="mb-0">${{ "%.2f"|format(summary.total_cost or 0) }}</h4>
{% if hourly_trend %}<canvas data-spark="cost" height="24" style="max-height: 24px;"></canvas>{% endif %}
</div>
</div>
<div class="col-lg-2 col-md-4 col-sm-6">
<div class="card p-2">
<small>平均耗時</small>
<h4 class="mb-0">{{ summary.avg_duration or 0 }} ms</h4>
<small class="text-muted">{{ summary.cache_hits or 0 }} 次 cache 命中</small>
</div>
</div>
<div class="col-lg-2 col-md-4 col-sm-6">
<div class="card p-2">
<small>RAG 命中</small>
<h4 class="mb-0 text-success">{{ summary.rag_hits or 0 }}</h4>
<small class="text-muted">
{% if (summary.total_calls or 0) > 0 %}
{{ "%.1f"|format((summary.rag_hits or 0) / summary.total_calls * 100) }}%
{% else %}—{% endif %}
</small>
</div>
</div>
<div class="col-lg-2 col-md-4 col-sm-6">
<div class="card p-2">
<small>錯誤次數</small>
<h4 class="mb-0 {% if (summary.error_calls or 0) > 0 %}text-danger{% endif %}">{{ summary.error_calls or 0 }}</h4>
{% if hourly_trend %}<canvas data-spark="errors" height="24" style="max-height: 24px;"></canvas>{% endif %}
</div>
</div>
</div>
<!-- Phase 39 D-3: caller × RAG × MCP 編排矩陣 -->
@@ -268,12 +308,33 @@
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
// Phase 50 N-1: hourly trend chart
// Phase 50 N-1 + Phase 54 R-1: hourly trend + KPI sparklines
(function() {
const labels = {{ hourly_trend | map(attribute='hour') | list | tojson }};
const calls = {{ hourly_trend | map(attribute='calls') | list | tojson }};
const costs = {{ hourly_trend | map(attribute='cost') | list | tojson }};
const errors = {{ hourly_trend | map(attribute='errors') | list | tojson }};
// Phase 54: KPI 卡片 sparkline
const sparkColors = { calls: '#0d6efd', cost: '#ffc107', errors: '#dc3545' };
const sparkData = { calls: calls, cost: costs, errors: errors };
document.querySelectorAll('canvas[data-spark]').forEach(el => {
const k = el.getAttribute('data-spark');
const data = sparkData[k];
if (!data || !data.length) return;
new Chart(el, {
type: 'line',
data: {
labels: labels,
datasets: [{ data: data, borderColor: sparkColors[k], backgroundColor: sparkColors[k] + '33', borderWidth: 1, fill: true, tension: 0.4, pointRadius: 0 }]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false }, tooltip: { enabled: false } },
scales: { x: { display: false }, y: { display: false, beginAtZero: true } }
}
});
});
const el = document.getElementById('hourlyTrendChart');
if (!el || !labels.length) return;
new Chart(el, {

View File

@@ -202,7 +202,7 @@
</div>
{% endif %}
<!-- Verdict 統計 -->
<!-- Verdict 統計Phase 54 R-2: doughnut + 表格雙視角) -->
{% if verdict_stats %}
<div class="card mb-3">
<div class="card-header">
@@ -210,29 +210,77 @@
<small class="text-muted">{{ days }} 日 · AI 動作的實際成效閉環</small>
</div>
<div class="card-body">
<div class="row g-2">
{% set total_v = (verdict_stats | sum(attribute='count')) or 1 %}
{% for v in verdict_stats %}
<div class="col-md-3 col-sm-6">
<div class="border rounded p-2 text-center"
style="border-left-width: 4px !important;
border-left-color: {% if v.verdict == 'effective' %}#198754
{% elif v.verdict == 'backfired' %}#dc3545
{% else %}#6c757d{% endif %} !important;">
<small class="text-muted d-block">
{% if v.verdict == 'effective' %}<i class="fas fa-check-circle text-success"></i> 有效
{% elif v.verdict == 'backfired' %}<i class="fas fa-times-circle text-danger"></i> 適得其反
{% elif v.verdict == 'neutral' %}<i class="fas fa-minus text-secondary"></i> 無變化
{% else %}{{ v.verdict }}{% endif %}
</small>
<strong style="font-size: 1.4em;">{{ v.count }}</strong>
<small class="d-block text-muted">{{ "%.0f"|format(v.count / total_v * 100) }}% · Δ {{ "%+.1f"|format(v.avg_delta) }}</small>
</div>
<div class="row g-2 align-items-center">
<div class="col-md-5">
<canvas id="verdictPieChart" height="180"></canvas>
</div>
<div class="col-md-7">
<table class="table table-sm mb-0" style="font-size: 0.9em;">
<thead class="table-light">
<tr>
<th>Verdict</th>
<th class="text-end">筆數</th>
<th class="text-end">佔比</th>
<th class="text-end">平均 Δ</th>
</tr>
</thead>
<tbody>
{% set total_v = (verdict_stats | sum(attribute='count')) or 1 %}
{% for v in verdict_stats %}
<tr>
<td>
{% if v.verdict == 'effective' %}<i class="fas fa-check-circle text-success me-1"></i>有效
{% elif v.verdict == 'backfired' %}<i class="fas fa-times-circle text-danger me-1"></i>適得其反
{% elif v.verdict == 'neutral' %}<i class="fas fa-minus text-secondary me-1"></i>無變化
{% else %}{{ v.verdict }}{% endif %}
</td>
<td class="text-end"><strong>{{ v.count }}</strong></td>
<td class="text-end">{{ "%.0f"|format(v.count / total_v * 100) }}%</td>
<td class="text-end">
<span class="{% if v.avg_delta > 0 %}text-success{% elif v.avg_delta < 0 %}text-danger{% endif %}">
{{ "%+.1f"|format(v.avg_delta) }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endfor %}
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
(function() {
const data = {{ verdict_stats | tojson }};
const el = document.getElementById('verdictPieChart');
if (!el || !data.length) return;
const colorMap = { effective: '#198754', backfired: '#dc3545', neutral: '#6c757d' };
const labelMap = { effective: '有效', backfired: '適得其反', neutral: '無變化' };
new Chart(el, {
type: 'doughnut',
data: {
labels: data.map(d => labelMap[d.verdict] || d.verdict),
datasets: [{
data: data.map(d => d.count),
backgroundColor: data.map(d => colorMap[d.verdict] || '#adb5bd'),
borderWidth: 1, borderColor: '#fff',
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { position: 'right', labels: { font: { size: 12 } } },
tooltip: { callbacks: { label: c => {
const total = data.reduce((a,b)=>a+b.count, 0);
return `${c.label}: ${c.parsed} 筆 (${(c.parsed/total*100).toFixed(1)}%)`;
}}}
}
}
});
})();
</script>
{% endif %}
<!-- 競品比對失敗統計 -->

View File

@@ -179,6 +179,14 @@
7d 共 {{ aiops_summary.heals_total }} 次自癒嘗試
(成功 {{ aiops_summary.heals_success }} · 失敗 {{ aiops_summary.heals_failed }}
</div>
{% if aiops_summary.heal_sparkline %}
<div class="mt-3" style="height: 80px;">
<canvas id="healSparkline"></canvas>
</div>
<div class="text-end small text-muted">
7 日每日自癒成功率趨勢
</div>
{% endif %}
</div>
</div>
{% endif %}
@@ -474,7 +482,41 @@
</small></p>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
// Phase 54 R-3: heal 7d sparkline
(function() {
const data = {{ aiops_summary.heal_sparkline | default([]) | tojson }};
const el = document.getElementById('healSparkline');
if (!el || !data.length) return;
new Chart(el, {
type: 'line',
data: {
labels: data.map(d => d.date),
datasets: [{
label: '成功率 %',
data: data.map(d => d.rate),
borderColor: '#0d6efd',
backgroundColor: 'rgba(13,110,253,0.15)',
borderWidth: 2, fill: true, tension: 0.3, pointRadius: 3,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false }, tooltip: {
callbacks: { label: c => {
const d = data[c.dataIndex];
return `${d.ok}/${d.total} 成功 (${d.rate.toFixed(0)}%)`;
}}
}},
scales: {
x: { ticks: { font: { size: 10 } } },
y: { min: 0, max: 100, ticks: { callback: v => v + '%', font: { size: 10 } } }
}
}
});
})();
async function togglePlaybook(id, name) {
if (!confirm(`切換 Playbook 「${name}」狀態?`)) return;
try {