All checks were successful
CD Pipeline / deploy (push) Successful in 2m23s
承接 Phase 27/28(48b8fda)剩 2 個前端頁:
1. /admin/budget — 預算編輯器
- GET: ai_call_budgets × 當月 spent 即時對比 + throttle 狀態
- POST /admin/budget/update/<id>: AJAX 編輯 budget_usd / alert_pct
- 不需 restart 立即生效(cost_throttle hourly cron 自動讀新值)
- ratio ≥80% 黃 / ≥110% 紅 / throttled 標 ⚠️ THROTTLED
2. /admin/ppt_audit_history — PPT 視覺審核歷史
- 掃 reports/ 過去 7 日 .pptx 檔(檔名/大小/修改時間)
- 顯示 PPT_VISION_ENABLED 狀態(true=daily 22:00 cron 自動跑)
- 手動觸發 SOP 提示(SSH 188 跑單檔審核)
完工里程碑:6 個 admin 頁 + 1 個導覽
- /admin/ai_calls (Phase 27)
- /admin/promotion_review (Phase 27)
- /admin/quality_trend (Phase 28)
- /admin/host_health (Phase 28)
- /admin/budget (Phase 29) ← 新增
- /admin/ppt_audit_history (Phase 29) ← 新增
Operation Ollama-First v5.0 — 前端互補互動系列收官
111 lines
4.1 KiB
HTML
111 lines
4.1 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block title %}Budget Manager{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="container-fluid mt-3">
|
||
<h2 class="mb-3">💰 Budget Manager
|
||
<small class="text-muted">ai_call_budgets × 當月 spent 即時對比</small>
|
||
</h2>
|
||
|
||
{% if error %}<div class="alert alert-warning"><strong>⚠️</strong> {{ error }}</div>{% endif %}
|
||
|
||
<p class="text-muted small">
|
||
依 ADR-028 預算 + Phase 20 cost_throttle:每小時 cron 檢查當月 spent,
|
||
線性外推月底成本超 110% → 自動 throttle(claude→gemini fallback)。
|
||
手動編輯 budget 後立即生效(不需 restart)。
|
||
</p>
|
||
|
||
<table class="table table-hover">
|
||
<thead class="table-light">
|
||
<tr>
|
||
<th>Period</th><th>Provider</th>
|
||
<th class="text-end">Spent (USD)</th>
|
||
<th>Budget (USD)</th><th>Alert %</th>
|
||
<th class="text-end">Ratio</th><th>狀態</th>
|
||
<th>Last Update</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">⚠️ THROTTLED</span>
|
||
{% elif r.ratio >= 0.8 %}
|
||
<span class="badge bg-warning">⚠ 接近上限</span>
|
||
{% else %}
|
||
<span class="badge bg-success">✅ 正常</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 }})">
|
||
💾 儲存
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
{% else %}
|
||
<tr><td colspan="9" class="text-center text-muted">無預算資料(先跑 migrations/025)</td></tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
|
||
<p class="text-muted mt-3"><small>
|
||
🤖 Operation Ollama-First v5.0 / Phase 29 — Budget Manager
|
||
| <a href="/admin/ai_calls">AI Calls</a>
|
||
| <a href="/admin/host_health">Host Health</a>
|
||
| <a href="/admin/promotion_review">Promotion Review</a>
|
||
</small></p>
|
||
</div>
|
||
|
||
<script>
|
||
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.innerText = '⏳';
|
||
try {
|
||
const r = await fetch(`/admin/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.innerText = '✅';
|
||
setTimeout(() => { btn.innerText = '💾 儲存'; btn.disabled = false; }, 1500);
|
||
} else {
|
||
alert('更新失敗: ' + (d.error || 'unknown'));
|
||
btn.disabled = false; btn.innerText = '💾 儲存';
|
||
}
|
||
} catch (e) {
|
||
alert('Error: ' + e);
|
||
btn.disabled = false; btn.innerText = '💾 儲存';
|
||
}
|
||
}
|
||
</script>
|
||
{% endblock %}
|