Files
ewoooc/templates/admin/budget.html
OoO 87d460e243
All checks were successful
CD Pipeline / deploy (push) Successful in 2m40s
feat(p50): chart.js 折線圖視覺化 + Playbook 一鍵啟用/停用
統帥要求「視覺方格 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>
2026-05-04 20:04:13 +08:00

259 lines
10 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "ewoooc_base.html" %}
{% block title %}預算控管{% endblock %}
{% block ewooo_content %}
<div class="container-fluid mt-3">
<h2 class="mb-3"><i class="fas fa-wallet me-2"></i>預算控管
<small class="text-muted">ai_call_budgets × 當月實際支出即時對比</small>
</h2>
{% if error %}<div class="alert alert-warning"><strong><i class="fas fa-exclamation-triangle me-1"></i></strong> {{ error }}</div>{% endif %}
<p class="text-muted small">
依 ADR-028 預算 + Phase 20 成本節流:每小時 cron 檢查當月支出,
線性外推月底成本超 110% → 自動節流claude→gemini fallback
手動編輯預算後立即生效(不需重啟)。
</p>
<!-- Phase 39 D-4: 一鍵立即重算 throttle 狀態L2 自動化)-->
<div class="mb-3">
<button class="btn btn-warning btn-sm" onclick="forceThrottle()">
<i class="fas fa-bolt me-1"></i>立即重算節流狀態(不等 cron
</button>
<small class="text-muted ms-2">用途:發現某 provider 飆超 110% 時立即 evaluate毋需等下次每小時 cron。</small>
</div>
<!-- Phase 39 D-4: RAG 自動建議策略 -->
{% if budget_strategies %}
<div class="card mb-3" style="border-left: 4px solid #6f42c1;">
<div class="card-header bg-light">
<strong><i class="fas fa-lightbulb me-2"></i>RAG 自動策略建議</strong>
<small class="text-muted">— 從知識庫 ai_insights 召回過去類似超支情境的應對策略</small>
</div>
<div class="card-body p-2">
<ul class="list-unstyled small mb-0">
{% for s in budget_strategies %}
<li class="mb-2 p-2" style="background: #fafafa; border-radius: 4px;">
<span class="badge bg-info text-dark me-1">{{ s.insight_type }}</span>
<span class="badge bg-secondary me-1">相似度 {{ "%.2f"|format(s.similarity) }}</span>
<span>{{ s.content }}{% if s.content|length >= 240 %}…{% endif %}</span>
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>週期</th><th>供應商</th>
<th class="text-end">已花費 (USD)</th>
<th>預算 (USD)</th><th>警示閾值 %</th>
<th class="text-end">使用率</th><th>狀態</th>
<th>更新時間</th><th>動作</th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr {% if r.throttled %}class="table-danger"{% elif r.ratio >= 0.8 %}class="table-warning"{% endif %}>
<td><span class="badge bg-secondary">{{ r.period }}</span></td>
<td><code>{{ r.provider }}</code></td>
<td class="text-end">${{ "%.2f"|format(r.spent) }}</td>
<td>
<input type="number" step="0.01" min="0.01" value="{{ "%.2f"|format(r.budget_usd) }}"
class="form-control form-control-sm budget-input"
data-budget-id="{{ r.id }}" style="width: 110px;">
</td>
<td>
<input type="number" min="1" max="100" value="{{ r.alert_pct }}"
class="form-control form-control-sm alert-input"
data-budget-id="{{ r.id }}" style="width: 80px;">
</td>
<td class="text-end">
<strong class="{% if r.ratio >= 1.10 %}text-danger{% elif r.ratio >= 0.8 %}text-warning{% else %}text-success{% endif %}">
{{ "%.0f"|format(r.ratio * 100) }}%
</strong>
</td>
<td>
{% if r.throttled %}
<span class="badge bg-danger"><i class="fas fa-exclamation-triangle me-1"></i>已節流</span>
{% elif r.ratio >= 0.8 %}
<span class="badge bg-warning"><i class="fas fa-exclamation me-1"></i>接近上限</span>
{% else %}
<span class="badge bg-success"><i class="fas fa-check me-1"></i>正常</span>
{% endif %}
</td>
<td><small>{{ r.updated_at }}</small></td>
<td>
<button class="btn btn-primary btn-sm save-budget-btn"
data-budget-id="{{ r.id }}" onclick="saveBudget({{ r.id }})">
<i class="fas fa-save me-1"></i>儲存
</button>
</td>
</tr>
{% else %}
<tr><td colspan="9" class="text-center text-muted">無預算資料(需先跑 migrations/025</td></tr>
{% endfor %}
</tbody>
</table>
<!-- Phase 47 K-3: Top 5 cost-burning caller -->
{% if top_cost_callers %}
<div class="card mb-3">
<div class="card-header"><strong><i class="fas fa-fire me-2"></i>當月 Top 5 燒錢呼叫端</strong>
<small class="text-muted">資料來源ai_calls.cost_usdcaller 級彙總)</small>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0" style="font-size: 0.9em;">
<thead class="table-light">
<tr><th>呼叫端</th><th class="text-end">呼叫</th><th class="text-end">Token</th><th class="text-end">成本</th><th>佔比</th></tr>
</thead>
<tbody>
{% set max_cost = (top_cost_callers | map(attribute='cost') | max) or 1 %}
{% for c in top_cost_callers %}
<tr>
<td><code>{{ c.caller }}</code></td>
<td class="text-end">{{ "{:,}".format(c.calls) }}</td>
<td class="text-end">{{ "{:,}".format(c.tokens) }}</td>
<td class="text-end"><strong>${{ "%.2f"|format(c.cost) }}</strong></td>
<td>
<div class="progress" style="height: 6px;">
<div class="progress-bar bg-warning" style="width: {{ (c.cost / max_cost * 100) | round | int }}%"></div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<!-- 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">堆疊柱圖:依 provider 分色 · 資料來源 ai_calls 每日 SUM(cost_usd)</small>
</div>
<div class="card-body">
<canvas id="costTrend30dChart" height="80"></canvas>
</div>
</div>
{% endif %}
<!-- Phase 47 K-3: AI 價格決策 7d -->
{% if price_rec_7d %}
<div class="card mb-3">
<div class="card-header"><strong><i class="fas fa-tag me-2"></i>AI 價格決策 7 日ai_price_recommendations</strong>
<small class="text-muted">展現 AI 編排的商業面產出 — 不只 cost看實際決策數量</small>
</div>
<div class="card-body">
<div class="row g-2">
{% for p in price_rec_7d %}
<div class="col-md-3 col-sm-4">
<div class="border rounded p-2 text-center">
<small class="text-muted d-block">{{ p.strategy }}</small>
<strong style="font-size: 1.4em;">{{ p.count }}</strong>
<small class="d-block text-muted">信心 {{ "%.2f"|format(p.avg_confidence) }}</small>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<p class="text-muted mt-3"><small>
<i class="fas fa-robot me-1"></i>Operation Ollama-First v5.0 / Phase 47 — 預算控管
5 表深挖ai_call_budgets / ai_calls / ai_price_recommendations / ai_insights / cost_throttle_state
</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 {
const r = await fetch('/observability/budget/force_throttle', {method: 'POST'});
const d = await r.json();
if (d.ok) {
const list = (d.throttled_providers && d.throttled_providers.length > 0)
? d.throttled_providers.join(', ') : '(無)';
alert(`✅ 已重算:被節流的 provider = ${list}`);
window.location.reload();
} else {
alert('❌ ' + (d.error || '重算失敗'));
}
} catch (e) {
alert('Error: ' + e);
}
}
async function saveBudget(id) {
const budgetInput = document.querySelector(`.budget-input[data-budget-id="${id}"]`);
const alertInput = document.querySelector(`.alert-input[data-budget-id="${id}"]`);
const btn = document.querySelector(`.save-budget-btn[data-budget-id="${id}"]`);
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
const r = await fetch(`/observability/budget/update/${id}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
budget_usd: parseFloat(budgetInput.value),
alert_pct: parseInt(alertInput.value),
}),
});
const d = await r.json();
if (d.ok) {
btn.innerHTML = '<i class="fas fa-check"></i> 已儲存';
setTimeout(() => { btn.innerHTML = '<i class="fas fa-save me-1"></i>儲存'; btn.disabled = false; }, 1500);
} else {
alert('更新失敗:' + (d.error || 'unknown'));
btn.disabled = false; btn.innerHTML = '<i class="fas fa-save me-1"></i>儲存';
}
} catch (e) {
alert('Error' + e);
btn.disabled = false; btn.innerHTML = '<i class="fas fa-save me-1"></i>儲存';
}
}
</script>
{% endblock %}