fix(p32): admin URL prefix /admin → /observability — 避開 188 nginx SPA shadow
All checks were successful
CD Pipeline / deploy (push) Successful in 2m25s
All checks were successful
CD Pipeline / deploy (push) Successful in 2m25s
Root cause(curl 實證): prod 188 nginx 對 /admin/* 設 try_files → SPA index.html fallback → Phase 27-31 的 6 個 Flask admin 路由全被 nginx 攔截 → 外部 GET /admin/ai_calls 回 7480 byte 靜態 HTML(同 etag = SPA shell) → 我之前說「6 admin 頁 prod 200」是回了 200,但 body 不是 Flask 渲染 修法: Blueprint url_prefix /admin → /observability → 6 個觀測頁實際生效在 /observability/* 不被 SPA 遮蔽 → SPA frontend 仍擁有 /admin/* 命名空間(不破壞既有前端) 更新範圍: - routes/admin_observability_routes.py: url_prefix + 註解全改 - 6 templates: 所有 href / fetch() 路徑改 /observability/ - tests/test_admin_observability_routes.py: client.get/post 路徑改 - 10/10 smoke tests 仍 PASS 統帥訪問新路徑: http://192.168.0.188/observability/ai_calls http://192.168.0.188/observability/host_health http://192.168.0.188/observability/budget http://192.168.0.188/observability/promotion_review http://192.168.0.188/observability/quality_trend http://192.168.0.188/observability/ppt_audit_history
This commit is contained in:
@@ -5,10 +5,10 @@ routes/admin_observability_routes.py
|
||||
Operation Ollama-First v5.0 / Phase 27 — Admin Observability Dashboard
|
||||
|
||||
提供 admin 介面看戰役累積的觀測資料:
|
||||
/admin/ai_calls — ai_calls 即時查詢(含篩選 / 圖表)
|
||||
/admin/promotion_review — Phase 28 PromotionGate 待審核列表
|
||||
/admin/quality_trend — Phase 25 caller 反饋趨勢
|
||||
/admin/host_health — 三主機 Ollama + MCP 健康度
|
||||
/observability/ai_calls — ai_calls 即時查詢(含篩選 / 圖表)
|
||||
/observability/promotion_review — Phase 28 PromotionGate 待審核列表
|
||||
/observability/quality_trend — Phase 25 caller 反饋趨勢
|
||||
/observability/host_health — 三主機 Ollama + MCP 健康度
|
||||
|
||||
設計原則:
|
||||
- 純讀(除了 promotion approve/reject 是 mutation)
|
||||
@@ -27,12 +27,12 @@ from database.manager import get_session
|
||||
admin_observability_bp = Blueprint(
|
||||
'admin_observability',
|
||||
__name__,
|
||||
url_prefix='/admin',
|
||||
url_prefix='/observability',
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# /admin/ai_calls — Phase 27 主入口
|
||||
# /observability/ai_calls — Phase 27 主入口
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@admin_observability_bp.route('/ai_calls')
|
||||
@@ -154,7 +154,7 @@ def ai_calls_dashboard():
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# /admin/promotion_review — Phase 28 PromotionGate 待審核列表
|
||||
# /observability/promotion_review — Phase 28 PromotionGate 待審核列表
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@admin_observability_bp.route('/promotion_review')
|
||||
@@ -226,7 +226,7 @@ def promotion_review_reject(episode_id: int):
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# /admin/quality_trend — Phase 25 caller 反饋趨勢視覺化
|
||||
# /observability/quality_trend — Phase 25 caller 反饋趨勢視覺化
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@admin_observability_bp.route('/quality_trend')
|
||||
@@ -262,7 +262,7 @@ def quality_trend_dashboard():
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# /admin/budget — Phase 29 預算管理 + 手動 throttle
|
||||
# /observability/budget — Phase 29 預算管理 + 手動 throttle
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@admin_observability_bp.route('/budget')
|
||||
@@ -351,7 +351,7 @@ def budget_update(budget_id: int):
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# /admin/ppt_audit_history — Phase 29 PPT 視覺審核歷史
|
||||
# /observability/ppt_audit_history — Phase 29 PPT 視覺審核歷史
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@admin_observability_bp.route('/ppt_audit_history')
|
||||
@@ -402,7 +402,7 @@ def ppt_audit_history():
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# /admin/host_health — 三主機 + MCP 健康度
|
||||
# /observability/host_health — 三主機 + MCP 健康度
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@admin_observability_bp.route('/host_health')
|
||||
|
||||
@@ -114,11 +114,11 @@
|
||||
|
||||
<p class="text-muted mt-2"><small>
|
||||
🤖 Operation Ollama-First v5.0 / Phase 29 — Admin Observability
|
||||
| <a href="/admin/promotion_review">Promotion Review</a>
|
||||
| <a href="/admin/quality_trend">Quality Trend</a>
|
||||
| <a href="/admin/host_health">Host Health</a>
|
||||
| <a href="/admin/budget">Budget</a>
|
||||
| <a href="/admin/ppt_audit_history">PPT Audit</a>
|
||||
| <a href="/observability/promotion_review">Promotion Review</a>
|
||||
| <a href="/observability/quality_trend">Quality Trend</a>
|
||||
| <a href="/observability/host_health">Host Health</a>
|
||||
| <a href="/observability/budget">Budget</a>
|
||||
| <a href="/observability/ppt_audit_history">PPT Audit</a>
|
||||
</small></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -72,9 +72,9 @@
|
||||
|
||||
<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>
|
||||
| <a href="/observability/ai_calls">AI Calls</a>
|
||||
| <a href="/observability/host_health">Host Health</a>
|
||||
| <a href="/observability/promotion_review">Promotion Review</a>
|
||||
</small></p>
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,7 @@ async function saveBudget(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}`, {
|
||||
const r = await fetch(`/observability/budget/update/${id}`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -114,11 +114,11 @@
|
||||
|
||||
<p class="text-muted mt-3"><small>
|
||||
🤖 Operation Ollama-First v5.0 / Phase 29 — Host Health Dashboard
|
||||
| <a href="/admin/ai_calls">AI Calls</a>
|
||||
| <a href="/admin/promotion_review">Promotion Review</a>
|
||||
| <a href="/admin/quality_trend">Quality Trend</a>
|
||||
| <a href="/admin/budget">Budget</a>
|
||||
| <a href="/admin/ppt_audit_history">PPT Audit</a>
|
||||
| <a href="/observability/ai_calls">AI Calls</a>
|
||||
| <a href="/observability/promotion_review">Promotion Review</a>
|
||||
| <a href="/observability/quality_trend">Quality Trend</a>
|
||||
| <a href="/observability/budget">Budget</a>
|
||||
| <a href="/observability/ppt_audit_history">PPT Audit</a>
|
||||
</small></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -50,9 +50,9 @@
|
||||
|
||||
<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>
|
||||
| <a href="/observability/ai_calls">AI Calls</a>
|
||||
| <a href="/observability/host_health">Host Health</a>
|
||||
| <a href="/observability/budget">Budget</a>
|
||||
</small></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -54,11 +54,11 @@
|
||||
|
||||
<p class="text-muted mt-3"><small>
|
||||
🤖 Operation Ollama-First v5.0 / Phase 29 — PromotionGate Web 審核頁
|
||||
| <a href="/admin/ai_calls">AI Calls</a>
|
||||
| <a href="/admin/quality_trend">Quality Trend</a>
|
||||
| <a href="/admin/host_health">Host Health</a>
|
||||
| <a href="/admin/budget">Budget</a>
|
||||
| <a href="/admin/ppt_audit_history">PPT Audit</a>
|
||||
| <a href="/observability/ai_calls">AI Calls</a>
|
||||
| <a href="/observability/quality_trend">Quality Trend</a>
|
||||
| <a href="/observability/host_health">Host Health</a>
|
||||
| <a href="/observability/budget">Budget</a>
|
||||
| <a href="/observability/ppt_audit_history">PPT Audit</a>
|
||||
</small></p>
|
||||
</div>
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
async function approveEpisode(id, btn) {
|
||||
btn.disabled = true; btn.innerText = '⏳ 處理中...';
|
||||
try {
|
||||
const r = await fetch(`/admin/promotion_review/approve/${id}`, {method: 'POST'});
|
||||
const r = await fetch(`/observability/promotion_review/approve/${id}`, {method: 'POST'});
|
||||
const d = await r.json();
|
||||
if (d.ok) {
|
||||
const card = document.querySelector(`.episode-card[data-episode-id="${id}"]`);
|
||||
@@ -87,7 +87,7 @@ async function rejectEpisode(id, btn) {
|
||||
if (!confirm(`拒絕 Episode #${id}?此筆將永不晉升(保留在 learning_episodes 不刪除)`)) return;
|
||||
btn.disabled = true; btn.innerText = '⏳ 處理中...';
|
||||
try {
|
||||
const r = await fetch(`/admin/promotion_review/reject/${id}`, {method: 'POST'});
|
||||
const r = await fetch(`/observability/promotion_review/reject/${id}`, {method: 'POST'});
|
||||
const d = await r.json();
|
||||
if (d.ok) {
|
||||
const card = document.querySelector(`.episode-card[data-episode-id="${id}"]`);
|
||||
|
||||
@@ -94,11 +94,11 @@
|
||||
|
||||
<p class="text-muted mt-3"><small>
|
||||
🤖 Operation Ollama-First v5.0 / Phase 29 — Caller Quality Trend
|
||||
| <a href="/admin/ai_calls">AI Calls</a>
|
||||
| <a href="/admin/promotion_review">Promotion Review</a>
|
||||
| <a href="/admin/host_health">Host Health</a>
|
||||
| <a href="/admin/budget">Budget</a>
|
||||
| <a href="/admin/ppt_audit_history">PPT Audit</a>
|
||||
| <a href="/observability/ai_calls">AI Calls</a>
|
||||
| <a href="/observability/promotion_review">Promotion Review</a>
|
||||
| <a href="/observability/host_health">Host Health</a>
|
||||
| <a href="/observability/budget">Budget</a>
|
||||
| <a href="/observability/ppt_audit_history">PPT Audit</a>
|
||||
</small></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -60,13 +60,13 @@ def _fake_session(rows_per_query=None):
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# /admin/ai_calls
|
||||
# /observability/ai_calls
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_ai_calls_dashboard_200_empty(client, monkeypatch):
|
||||
from routes import admin_observability_routes as mod
|
||||
monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([]))
|
||||
r = client.get('/admin/ai_calls')
|
||||
r = client.get('/observability/ai_calls')
|
||||
assert r.status_code == 200
|
||||
assert b'AI Calls' in r.data or '\xe5\x88\x86\xe6\x9e\x90'.encode() in r.data or True # 中文標題可選
|
||||
|
||||
@@ -77,40 +77,40 @@ def test_ai_calls_dashboard_db_error_falls_back(client, monkeypatch):
|
||||
bad.execute.side_effect = RuntimeError('DB down')
|
||||
bad.close = MagicMock()
|
||||
monkeypatch.setattr(mod, 'get_session', lambda: bad)
|
||||
r = client.get('/admin/ai_calls')
|
||||
r = client.get('/observability/ai_calls')
|
||||
assert r.status_code == 200 # 失敗安全:仍 render,不 500
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# /admin/promotion_review
|
||||
# /observability/promotion_review
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_promotion_review_200(client, monkeypatch):
|
||||
from routes import admin_observability_routes as mod
|
||||
monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([]))
|
||||
r = client.get('/admin/promotion_review')
|
||||
r = client.get('/observability/promotion_review')
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# /admin/quality_trend
|
||||
# /observability/quality_trend
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_quality_trend_200(client, monkeypatch):
|
||||
from routes import admin_observability_routes as mod
|
||||
monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([]))
|
||||
r = client.get('/admin/quality_trend')
|
||||
r = client.get('/observability/quality_trend')
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# /admin/budget
|
||||
# /observability/budget
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_budget_dashboard_200_empty(client, monkeypatch):
|
||||
from routes import admin_observability_routes as mod
|
||||
monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([]))
|
||||
r = client.get('/admin/budget')
|
||||
r = client.get('/observability/budget')
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ def test_budget_update_rejects_invalid_budget(client, monkeypatch):
|
||||
from routes import admin_observability_routes as mod
|
||||
monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([]))
|
||||
r = client.post(
|
||||
'/admin/budget/update/1',
|
||||
'/observability/budget/update/1',
|
||||
json={'budget_usd': -5, 'alert_pct': 80},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
@@ -129,7 +129,7 @@ def test_budget_update_rejects_invalid_alert(client, monkeypatch):
|
||||
from routes import admin_observability_routes as mod
|
||||
monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([]))
|
||||
r = client.post(
|
||||
'/admin/budget/update/1',
|
||||
'/observability/budget/update/1',
|
||||
json={'budget_usd': 10, 'alert_pct': 999},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
@@ -139,7 +139,7 @@ def test_budget_update_accepts_valid(client, monkeypatch):
|
||||
from routes import admin_observability_routes as mod
|
||||
monkeypatch.setattr(mod, 'get_session', lambda: _fake_session([]))
|
||||
r = client.post(
|
||||
'/admin/budget/update/1',
|
||||
'/observability/budget/update/1',
|
||||
json={'budget_usd': 25.50, 'alert_pct': 80},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
@@ -147,17 +147,17 @@ def test_budget_update_accepts_valid(client, monkeypatch):
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# /admin/ppt_audit_history
|
||||
# /observability/ppt_audit_history
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_ppt_audit_history_200(client):
|
||||
"""無 DB 依賴,純掃 reports/。"""
|
||||
r = client.get('/admin/ppt_audit_history')
|
||||
r = client.get('/observability/ppt_audit_history')
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# /admin/host_health
|
||||
# /observability/host_health
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_host_health_200(client, monkeypatch):
|
||||
@@ -172,5 +172,5 @@ def test_host_health_200(client, monkeypatch):
|
||||
raise _r.exceptions.ConnectionError('mocked')
|
||||
monkeypatch.setattr(_r, 'get', fake_get)
|
||||
|
||||
r = client.get('/admin/host_health')
|
||||
r = client.get('/observability/host_health')
|
||||
assert r.status_code == 200
|
||||
|
||||
Reference in New Issue
Block a user