feat(p50): chart.js 折線圖視覺化 + Playbook 一鍵啟用/停用
All checks were successful
CD Pipeline / deploy (push) Successful in 2m40s

統帥要求「視覺方格 UI/UX」:raw 表格不夠,加 chart.js 雙圖 + L2 管理。

N-1: ai_calls hourly trend chart.js(雙軸混合)
- 取代原 progress bar 表格
- 折線:呼叫數(藍)+ 錯誤次數(紅)→ 共用左軸
- 柱狀:成本 USD(黃)→ 右軸
- interaction mode index:滑鼠 hover 同時顯示三個指標
- chart.js 4.4.1 CDN 加在 {% block extra_js %}

N-2: budget 30d cost trend stacked bar chart
- 取代原 30d cost trend 表格(max-height 滾動 → 一目瞭然圖)
- 8 個 provider 各自分色
  本地 Ollama(綠系)vs 付費(橘/紫/青系)
- stacked bar:每日總成本一柱,依 provider 堆疊
- tooltip 顯示每個 provider $X.XXXX

N-3: Playbook 一鍵啟用/停用(L2 補強第 7 個)
- 新 POST /observability/playbooks/toggle/<id>
  翻轉 is_active + UPDATE updated_at
- host_health.html playbook 排行表加「切換」欄
- 動態按鈕:啟用顯示「停用」、停用顯示「啟用」
- 對應觀測台直接管理 AutoHeal 庫,不需 SSH 改 DB

L2 一鍵自動化從 6 個 → 7 個入口:
- AutoHeal / AiderHeal / Code Review / Force Throttle(既有)
- Telegram Heal / Throttle(既有)
- Playbook Toggle(Phase 50 新增)

Phase 38→50 累計 15 commits。
觀測台從 raw stats → AI 自動化專業舞台 → 視覺方格 UI 終局。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
OoO
2026-05-04 20:04:13 +08:00
parent 822789c810
commit 87d460e243
4 changed files with 141 additions and 55 deletions

View File

@@ -1511,6 +1511,42 @@ def ppt_audit_trigger_aider_heal():
return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500
@admin_observability_bp.route('/playbooks/toggle/<int:playbook_id>', methods=['POST'])
@login_required
def playbook_toggle(playbook_id: int):
"""Phase 50 N-3一鍵啟用/停用 playbookis_active 翻轉)。
用途:在 host_health 觀測台直接管理 AutoHeal playbook
不需 SSH 188 改 DB。
"""
try:
session = get_session()
try:
row = session.execute(
sa_text("SELECT id, name, is_active FROM playbooks WHERE id = :id"),
{'id': playbook_id},
).fetchone()
if not row:
return jsonify({'ok': False, 'error': f'playbook #{playbook_id} 不存在'}), 404
new_active = not bool(row[2])
session.execute(
sa_text("UPDATE playbooks SET is_active = :a, updated_at = NOW() WHERE id = :id"),
{'a': new_active, 'id': playbook_id},
)
session.commit()
return jsonify({
'ok': True,
'playbook_id': playbook_id,
'name': row[1],
'is_active': new_active,
'message': f'Playbook 「{row[1]}」已{"啟用" if new_active else "停用"}',
})
finally:
session.close()
except Exception as e:
return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500
@admin_observability_bp.route('/host_health/trigger_autoheal', methods=['POST'])
@login_required
def host_health_trigger_autoheal():
@@ -2050,7 +2086,7 @@ def host_health_dashboard():
# playbooks 庫排行success_count + fail_count + 是否 active
pb_rows = s3.execute(
sa_text("""
SELECT name, error_type, action_type, severity_min,
SELECT id, name, error_type, action_type, severity_min,
success_count, fail_count, is_active, cooldown_min
FROM playbooks
ORDER BY (success_count + fail_count) DESC, success_count DESC
@@ -2059,13 +2095,14 @@ def host_health_dashboard():
).fetchall()
playbook_ranking = [
{
'name': r[0], 'error_type': r[1], 'action_type': r[2],
'severity': r[3], 'success': int(r[4] or 0),
'fail': int(r[5] or 0), 'is_active': bool(r[6]),
'cooldown_min': int(r[7] or 0),
'id': int(r[0]),
'name': r[1], 'error_type': r[2], 'action_type': r[3],
'severity': r[4], 'success': int(r[5] or 0),
'fail': int(r[6] or 0), 'is_active': bool(r[7]),
'cooldown_min': int(r[8] or 0),
'success_rate': (
float(r[4] or 0) / float((r[4] or 0) + (r[5] or 0)) * 100
) if ((r[4] or 0) + (r[5] or 0)) > 0 else 0,
float(r[5] or 0) / float((r[5] or 0) + (r[6] or 0)) * 100
) if ((r[5] or 0) + (r[6] or 0)) > 0 else 0,
}
for r in pb_rows
]

View File

@@ -124,40 +124,14 @@
</div>
{% endif %}
<!-- Phase 47 K-2: 24h 每小時呼叫趨勢 -->
<!-- Phase 47 K-2 + Phase 50 N-1: 24h 每小時呼叫趨勢chart.js-->
{% if hourly_trend %}
<div class="card mb-3">
<div class="card-header"><strong><i class="fas fa-chart-area me-2"></i>過去 24h 每小時呼叫趨勢</strong>
<small class="text-muted">每小時 bucket呼叫數 · 成本 · 錯誤</small>
<small class="text-muted">折線:呼叫數 + 錯誤;柱狀:成本 USD</small>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0" style="font-size: 0.85em;">
<thead class="table-light">
<tr>
<th>時段</th><th>呼叫數</th><th>成本 USD</th><th>錯誤</th><th style="width: 50%;">流量分布</th>
</tr>
</thead>
<tbody>
{% set max_calls = (hourly_trend | map(attribute='calls') | max) or 1 %}
{% for h in hourly_trend %}
<tr>
<td><code>{{ h.hour }}</code></td>
<td><strong>{{ "{:,}".format(h.calls) }}</strong></td>
<td>${{ "%.3f"|format(h.cost) }}</td>
<td>
{% if h.errors > 0 %}<span class="text-danger">{{ h.errors }}</span>
{% else %}<small class="text-muted">0</small>{% endif %}
</td>
<td>
<div class="progress" style="height: 8px;">
<div class="progress-bar {% if h.errors > 0 %}bg-warning{% else %}bg-info{% endif %}"
style="width: {{ (h.calls / max_calls * 100) | round | int }}%"></div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="card-body">
<canvas id="hourlyTrendChart" height="80"></canvas>
</div>
</div>
{% endif %}
@@ -292,7 +266,36 @@
</small></p>
</div>
<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
(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 }};
const el = document.getElementById('hourlyTrendChart');
if (!el || !labels.length) return;
new Chart(el, {
data: {
labels: labels,
datasets: [
{ type: 'line', label: '呼叫數', data: calls, borderColor: '#0d6efd', backgroundColor: 'rgba(13,110,253,0.1)', tension: 0.3, fill: true, yAxisID: 'y' },
{ type: 'line', label: '錯誤次數', data: errors, borderColor: '#dc3545', backgroundColor: 'rgba(220,53,69,0.1)', tension: 0.3, yAxisID: 'y' },
{ type: 'bar', label: '成本 USD', data: costs, backgroundColor: 'rgba(255,193,7,0.5)', borderColor: '#ffc107', yAxisID: 'y1' },
]
},
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
scales: {
y: { type: 'linear', position: 'left', beginAtZero: true, title: { display: true, text: '次數' } },
y1: { type: 'linear', position: 'right', beginAtZero: true, grid: { drawOnChartArea: false }, title: { display: true, text: 'USD' } },
}
}
});
})();
async function triggerCodeReview() {
if (!confirm('觸發 Code Review Pipeline\n\n會對最新 commit 跑 5 step 審查Hermes 掃描 → OpenClaw 摘要 → EA 決策 → NemoTron 行動),背景執行。')) return;
try {

View File

@@ -131,27 +131,14 @@
</div>
{% endif %}
<!-- Phase 47 K-3: 30d daily cost trend by provider -->
<!-- Phase 47 K-3 + Phase 50 N-2: 30d cost trend by provider (chart.js stacked) -->
{% if cost_trend_30d %}
<div class="card mb-3">
<div class="card-header"><strong><i class="fas fa-chart-line me-2"></i>過去 30 日每日成本(依 provider</strong>
<small class="text-muted">資料來源ai_calls 每日 SUM(cost_usd) GROUP BY provider</small>
<small class="text-muted">堆疊柱圖:依 provider 分色 · 資料來源 ai_calls 每日 SUM(cost_usd)</small>
</div>
<div class="card-body p-0" style="max-height: 360px; overflow-y: auto;">
<table class="table table-sm mb-0" style="font-size: 0.85em;">
<thead class="table-light" style="position: sticky; top: 0;">
<tr><th>日期</th><th>供應商</th><th class="text-end">成本 USD</th></tr>
</thead>
<tbody>
{% for r in cost_trend_30d %}
<tr>
<td><code>{{ r.date }}</code></td>
<td><span class="badge bg-secondary">{{ r.provider }}</span></td>
<td class="text-end">${{ "%.4f"|format(r.cost) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="card-body">
<canvas id="costTrend30dChart" height="80"></canvas>
</div>
</div>
{% endif %}
@@ -184,7 +171,44 @@
</small></p>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
// Phase 50 N-2: 30d cost trend stacked bar chart
(function() {
const raw = {{ cost_trend_30d | tojson }};
if (!raw || !raw.length) return;
const dateSet = [...new Set(raw.map(r => r.date))].sort();
const providerSet = [...new Set(raw.map(r => r.provider))];
const colors = {
'gcp_ollama': '#28a745', 'ollama_secondary': '#5cb85c', 'ollama_111': '#a3d9a4',
'gemini': '#fd7e14', 'claude': '#6610f2', 'nim': '#0dcaf0',
'openrouter': '#6c757d', 'nim_via_elephant': '#20c997',
};
const datasets = providerSet.map((p, i) => {
const bg = colors[p] || `hsl(${(i*47)%360}, 65%, 55%)`;
return {
label: p,
data: dateSet.map(d => {
const row = raw.find(r => r.date === d && r.provider === p);
return row ? row.cost : 0;
}),
backgroundColor: bg,
};
});
const el = document.getElementById('costTrend30dChart');
if (!el) return;
new Chart(el, {
type: 'bar',
data: { labels: dateSet, datasets: datasets },
options: {
responsive: true,
interaction: { mode: 'index', intersect: false },
plugins: { tooltip: { callbacks: { label: c => `${c.dataset.label}: $${c.parsed.y.toFixed(4)}` } } },
scales: { x: { stacked: true }, y: { stacked: true, beginAtZero: true, title: { display: true, text: 'USD' } } }
}
});
})();
async function forceThrottle() {
if (!confirm('立即重算所有 provider 的 throttle 狀態?\n不等下次每小時 cron')) return;
try {

View File

@@ -370,7 +370,7 @@
<th>嚴重度</th>
<th class="text-end">成功</th><th class="text-end">失敗</th>
<th class="text-end">成功率</th>
<th>狀態</th><th class="text-end">冷卻 min</th>
<th>狀態</th><th class="text-end">冷卻 min</th><th>切換</th>
</tr>
</thead>
<tbody>
@@ -397,6 +397,12 @@
{% endif %}
</td>
<td class="text-end">{{ p.cooldown_min }}</td>
<td>
<button class="btn btn-sm {% if p.is_active %}btn-outline-secondary{% else %}btn-outline-success{% endif %}"
onclick="togglePlaybook({{ p.id }}, {{ p.name|tojson }})">
{% if p.is_active %}<i class="fas fa-pause me-1"></i>停用{% else %}<i class="fas fa-play me-1"></i>啟用{% endif %}
</button>
</td>
</tr>
{% endfor %}
</tbody>
@@ -469,6 +475,22 @@
</div>
<script>
async function togglePlaybook(id, name) {
if (!confirm(`切換 Playbook 「${name}」狀態?`)) return;
try {
const r = await fetch(`/observability/playbooks/toggle/${id}`, {method: 'POST'});
const d = await r.json();
if (d.ok) {
alert(`${d.message}`);
window.location.reload();
} else {
alert('❌ ' + (d.error || '切換失敗'));
}
} catch (e) {
alert('Error: ' + e);
}
}
async function triggerAutoHeal(hostLabel) {
if (!confirm(`觸發 AutoHeal\n\n主機:${hostLabel}\n\n會跑對應 ADR-013 playbookDOCKER_RESTART / SSH_CMD / ALERT_ONLY並寫入 incidents 表。`)) return;
try {