feat(p29): 預算管理頁 + PPT vision 歷史頁 — 完成 6 個 admin 觀測頁
All checks were successful
CD Pipeline / deploy (push) Successful in 2m23s
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 — 前端互補互動系列收官
This commit is contained in:
@@ -261,6 +261,146 @@ def quality_trend_dashboard():
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# /admin/budget — Phase 29 預算管理 + 手動 throttle
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@admin_observability_bp.route('/budget')
|
||||
def budget_dashboard():
|
||||
"""ai_call_budgets 編輯 + 當月 spent 即時對比"""
|
||||
from datetime import datetime as _dt
|
||||
today = _dt.now()
|
||||
month_start = _dt(today.year, today.month, 1)
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
budgets = session.execute(
|
||||
sa_text("""
|
||||
SELECT id, period, provider, budget_usd, alert_pct, updated_at
|
||||
FROM ai_call_budgets
|
||||
ORDER BY period, provider NULLS FIRST
|
||||
"""),
|
||||
).fetchall()
|
||||
|
||||
spent_rows = session.execute(
|
||||
sa_text("""
|
||||
SELECT provider, COALESCE(SUM(cost_usd), 0) AS spent
|
||||
FROM ai_calls
|
||||
WHERE called_at >= :ms
|
||||
GROUP BY provider
|
||||
"""),
|
||||
{'ms': month_start},
|
||||
).fetchall()
|
||||
spent_map = {r[0]: float(r[1] or 0) for r in spent_rows}
|
||||
|
||||
# throttle 狀態
|
||||
throttle_state = {}
|
||||
try:
|
||||
from services.cost_throttle_service import get_throttle_state
|
||||
throttle_state = get_throttle_state()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
rows = []
|
||||
for b in budgets:
|
||||
provider = b[2] # 可能 None(全供應商總額)
|
||||
spent = spent_map.get(provider, 0.0) if provider else sum(spent_map.values())
|
||||
budget_usd = float(b[3] or 0)
|
||||
ratio = (spent / budget_usd) if budget_usd > 0 else 0
|
||||
rows.append({
|
||||
'id': b[0], 'period': b[1], 'provider': provider or '(all)',
|
||||
'budget_usd': budget_usd, 'alert_pct': int(b[4] or 80),
|
||||
'spent': spent, 'ratio': ratio,
|
||||
'throttled': throttle_state.get(provider, {}).get('throttled', False) if provider else False,
|
||||
'updated_at': b[5].strftime('%Y-%m-%d %H:%M') if b[5] else '-',
|
||||
})
|
||||
|
||||
return render_template('admin/budget.html', rows=rows, error=None)
|
||||
except Exception as e:
|
||||
return render_template('admin/budget.html', rows=[],
|
||||
error=f'查詢失敗: {type(e).__name__}: {str(e)[:200]}')
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@admin_observability_bp.route('/budget/update/<int:budget_id>', methods=['POST'])
|
||||
def budget_update(budget_id: int):
|
||||
"""更新 budget_usd / alert_pct"""
|
||||
try:
|
||||
new_budget = float(request.json.get('budget_usd'))
|
||||
new_alert = int(request.json.get('alert_pct', 80))
|
||||
if new_budget <= 0 or not (1 <= new_alert <= 100):
|
||||
return jsonify({'ok': False, 'error': 'invalid range'}), 400
|
||||
|
||||
session = get_session()
|
||||
try:
|
||||
session.execute(
|
||||
sa_text("""
|
||||
UPDATE ai_call_budgets
|
||||
SET budget_usd = :b, alert_pct = :a, updated_at = NOW()
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{'b': new_budget, 'a': new_alert, 'id': budget_id},
|
||||
)
|
||||
session.commit()
|
||||
return jsonify({'ok': True})
|
||||
finally:
|
||||
session.close()
|
||||
except Exception as e:
|
||||
return jsonify({'ok': False, 'error': str(e)[:200]}), 500
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# /admin/ppt_audit_history — Phase 29 PPT 視覺審核歷史
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@admin_observability_bp.route('/ppt_audit_history')
|
||||
def ppt_audit_history():
|
||||
"""掃 reports/ 目錄列近 7 日 .pptx 檔 + 即時跑 audit(如已啟用)"""
|
||||
import os
|
||||
reports_dir = 'reports'
|
||||
files = []
|
||||
error = None
|
||||
|
||||
try:
|
||||
if not os.path.isdir(reports_dir):
|
||||
error = f'{reports_dir} 目錄不存在'
|
||||
else:
|
||||
cutoff = __import__('time').time() - 7 * 86400
|
||||
for f in os.listdir(reports_dir):
|
||||
if not f.lower().endswith('.pptx'):
|
||||
continue
|
||||
full = os.path.join(reports_dir, f)
|
||||
try:
|
||||
mtime = os.path.getmtime(full)
|
||||
if mtime >= cutoff:
|
||||
files.append({
|
||||
'name': f,
|
||||
'size_kb': round(os.path.getsize(full) / 1024, 1),
|
||||
'mtime': __import__('datetime').datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M'),
|
||||
'mtime_ts': mtime,
|
||||
})
|
||||
except OSError:
|
||||
continue
|
||||
files.sort(key=lambda x: x['mtime_ts'], reverse=True)
|
||||
except Exception as e:
|
||||
error = f'{type(e).__name__}: {str(e)[:200]}'
|
||||
|
||||
# PPT vision 啟用狀態
|
||||
try:
|
||||
from services.ppt_vision_service import is_ppt_vision_enabled
|
||||
vision_enabled = is_ppt_vision_enabled()
|
||||
except Exception:
|
||||
vision_enabled = False
|
||||
|
||||
return render_template(
|
||||
'admin/ppt_audit_history.html',
|
||||
files=files,
|
||||
vision_enabled=vision_enabled,
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# /admin/host_health — 三主機 + MCP 健康度
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
110
templates/admin/budget.html
Normal file
110
templates/admin/budget.html
Normal file
@@ -0,0 +1,110 @@
|
||||
{% 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 %}
|
||||
58
templates/admin/ppt_audit_history.html
Normal file
58
templates/admin/ppt_audit_history.html
Normal file
@@ -0,0 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}PPT Audit History{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-3">
|
||||
<h2 class="mb-3">🔍 PPT 視覺審核歷史
|
||||
<small class="text-muted">reports/ 過去 7 日 .pptx</small>
|
||||
</h2>
|
||||
|
||||
{% if error %}<div class="alert alert-warning"><strong>⚠️</strong> {{ error }}</div>{% endif %}
|
||||
|
||||
<div class="alert {% if vision_enabled %}alert-success{% else %}alert-secondary{% endif %} small">
|
||||
<strong>PPT_VISION_ENABLED:</strong>
|
||||
{% if vision_enabled %}
|
||||
✅ 已啟用 — daily 22:00 cron 自動跑 minicpm-v 視覺檢查,有 issues 推 Telegram
|
||||
{% else %}
|
||||
⏸ 未啟用 — 設 PPT_VISION_ENABLED=true + 188 安裝 LibreOffice 即生效
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<table class="table table-sm">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>檔名</th><th class="text-end">大小 (KB)</th>
|
||||
<th>修改時間</th><th>動作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in files %}
|
||||
<tr>
|
||||
<td><code>{{ f.name }}</code></td>
|
||||
<td class="text-end">{{ f.size_kb }}</td>
|
||||
<td><small>{{ f.mtime }}</small></td>
|
||||
<td>
|
||||
<small class="text-muted">audit cron 22:00 自動跑</small>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="text-center text-muted">過去 7 日無 PPT 生成</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="text-muted mt-2 small">
|
||||
審核結果:<strong>有 issues 才推 Telegram</strong>(避免靜默無問題洗版)。
|
||||
手動觸發單檔審核需 SSH 188 跑:
|
||||
<code>python3 -c "from services.ppt_vision_service import ppt_vision_service; print(ppt_vision_service.check_ppt_file('reports/xxx.pptx'))"</code>
|
||||
</p>
|
||||
|
||||
<p class="text-muted mt-3"><small>
|
||||
🤖 Operation Ollama-First v5.0 / Phase 29 — PPT Audit History
|
||||
| <a href="/admin/ai_calls">AI Calls</a>
|
||||
| <a href="/admin/host_health">Host Health</a>
|
||||
| <a href="/admin/budget">Budget</a>
|
||||
</small></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user