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>
259 lines
10 KiB
HTML
259 lines
10 KiB
HTML
{% 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_usd(caller 級彙總)</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 %}
|