feat(p54): chart.js 視覺微調 — KPI sparkline + verdict 圓餅 + heal 趨勢
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
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:
@@ -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 = {}
|
||||
|
||||
|
||||
@@ -55,14 +55,54 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 總覽 KPI -->
|
||||
<!-- 總覽 KPI(Phase 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, {
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
<!-- 競品比對失敗統計 -->
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user