feat(p50): chart.js 折線圖視覺化 + Playbook 一鍵啟用/停用
All checks were successful
CD Pipeline / deploy (push) Successful in 2m40s
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:
@@ -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:一鍵啟用/停用 playbook(is_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
|
||||
]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 playbook(DOCKER_RESTART / SSH_CMD / ALERT_ONLY)並寫入 incidents 表。`)) return;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user