perf: 外部化觀測台圖表腳本
All checks were successful
CD Pipeline / deploy (push) Successful in 1m9s

This commit is contained in:
OoO
2026-05-18 09:30:34 +08:00
parent d03a636baa
commit f2aece5b71
11 changed files with 710 additions and 137 deletions

View File

@@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.167"
SYSTEM_VERSION = "V10.168"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -131,19 +131,13 @@
<p class="text-muted mt-3"><small><i class="fas fa-robot me-1"></i>Ollama 優先策略 v5.0 — AI 流量控制塔</small></p>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
(function() {
const labels = {{ hourly_trend | map(attribute='hour') | list | tojson }};
const calls = {{ hourly_trend | map(attribute='calls') | list | tojson }};
const costs = {{ hourly_trend | map(attribute='cost') | list | tojson }};
const errors = {{ hourly_trend | map(attribute='errors') | list | tojson }};
const sparkColors = { calls: '#c96442', cost: '#b8792f', errors: '#b94b45' };
const sparkData = { calls, cost: costs, errors };
document.querySelectorAll('canvas[data-spark]').forEach(el => { const k = el.getAttribute('data-spark'); const data = sparkData[k]; if (!data || !data.length) return; new Chart(el, { type: 'line', data: { labels, datasets: [{ data, borderColor: sparkColors[k], backgroundColor: sparkColors[k] + '24', borderWidth: 1.4, fill: true, tension: .42, pointRadius: 0 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { enabled: false } }, scales: { x: { display: false }, y: { display: false, beginAtZero: true } } } }); });
const el = document.getElementById('hourlyTrendChart'); if (!el || !labels.length) return;
new Chart(el, { data: { labels, datasets: [ { type: 'line', label: '呼叫數', data: calls, borderColor: '#c96442', backgroundColor: 'rgba(201,100,66,.12)', tension: .35, fill: true, yAxisID: 'y' }, { type: 'line', label: '錯誤', data: errors, borderColor: '#b94b45', backgroundColor: 'rgba(185,75,69,.1)', tension: .35, yAxisID: 'y' }, { type: 'bar', label: '成本 USD', data: costs, backgroundColor: 'rgba(184,121,47,.38)', borderColor: '#b8792f', yAxisID: 'y1' } ] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, scales: { y: { beginAtZero: true, title: { display: true, text: '次數' } }, y1: { position: 'right', beginAtZero: true, grid: { drawOnChartArea: false }, title: { display: true, text: 'USD' } } } } });
})();
async function triggerCodeReview() { if (!confirm('觸發程式碼審查管線?\n\n會對最新 commit 跑 5 步驟審查,背景執行。')) return; try { const r = await fetch('/observability/ai_calls/trigger_code_review', {method: 'POST'}); const d = await r.json(); if (d.ok) { alert(`${d.message}\n\n管線 ID: ${d.pipeline_id}\nCommit: ${d.commit_sha}\n變更檔案: ${d.changed_files_count}`); } else { alert('❌ ' + (d.error || '觸發失敗')); } } catch (e) { console.warn('code_review_trigger_failed', e); alert('操作暫時無法完成,請稍後再試或查看系統日誌。'); } }
</script>
{% set ai_calls_payload = {
'labels': hourly_trend | map(attribute='hour') | list,
'calls': hourly_trend | map(attribute='calls') | list,
'costs': hourly_trend | map(attribute='cost') | list,
'errors': hourly_trend | map(attribute='errors') | list
} %}
<template id="obs-ai-calls-data">{{ ai_calls_payload | tojson }}</template>
<script src="{{ url_for('static', filename='js/analysis-chart-theme.js') }}"></script>
<script src="{{ url_for('static', filename='js/observability-charts.js') }}"></script>
{% endblock %}

View File

@@ -86,12 +86,11 @@
<p class="text-muted mt-3"><small><i class="fas fa-robot me-1"></i>Ollama 優先策略 v5.0 — AI 成本治理艙</small></p>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
const providerLabelMap = {gcp_ollama:'主力 Ollama',ollama_secondary:'備援 Ollama',ollama_111:'111 Ollama',gemini:'Gemini',claude:'Claude',nim:'NIM',openrouter:'OpenRouter',nim_via_elephant:'NIM Elephant'};
(function() { const data = {{ provider_cost_month | default([]) | tojson }}; const el = document.getElementById('providerCostPieChart'); if (!el || !data.length) return; const colors = {'gcp_ollama':'#4f8a5b','ollama_secondary':'#7aaa82','ollama_111':'#a3cfa8','gemini':'#b8792f','claude':'#4f6f8f','nim':'#6aa6a6','openrouter':'#8b8077','nim_via_elephant':'#c96442'}; new Chart(el,{type:'doughnut',data:{labels:data.map(d=>providerLabelMap[d.provider]||d.provider),datasets:[{data:data.map(d=>d.cost),backgroundColor:data.map((d,i)=>colors[d.provider]||`hsl(${(i*47)%360},55%,55%)`),borderWidth:1,borderColor:'#fff'}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{font:{size:11}}}}}}); })();
(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 palette = ['#c96442','#b8792f','#4f8a5b','#4f6f8f','#6aa6a6','#8b8077','#a66a4a']; const datasets = providerSet.map((p,i)=>({label:providerLabelMap[p]||p,data:dateSet.map(d=>{const row=raw.find(r=>r.date===d&&r.provider===p);return row?row.cost:0;}),backgroundColor:palette[i%palette.length]})); const el=document.getElementById('costTrend30dChart'); if(!el)return; new Chart(el,{type:'bar',data:{labels:dateSet,datasets},options:{responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},scales:{x:{stacked:true},y:{stacked:true,beginAtZero:true,title:{display:true,text:'USD'}}}}}); })();
async function forceThrottle(){if(!confirm('立即重算所有供應商的節流狀態?'))return;try{const r=await fetch('/observability/budget/force_throttle',{method:'POST'});const d=await r.json();if(d.ok){alert(`✅ 已重算:被節流的供應商 = ${(d.throttled_providers&&d.throttled_providers.length>0)?d.throttled_providers.join(', '):'(無)'}`);window.location.reload();}else{alert('❌ '+(d.error||'重算失敗'));}}catch(e){console.warn('budget_force_throttle_failed',e);alert('操作暫時無法完成,請稍後再試或查看系統日誌。');}}
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||'請稍後再試'));btn.disabled=false;btn.innerHTML='<i class="fas fa-save me-1"></i>儲存';}}catch(e){console.warn('budget_save_failed',e);alert('操作暫時無法完成,請稍後再試或查看系統日誌。');btn.disabled=false;btn.innerHTML='<i class="fas fa-save me-1"></i>儲存';}}
</script>
{% set budget_payload = {
'providerCostMonth': provider_cost_month | default([]),
'costTrend30d': cost_trend_30d
} %}
<template id="obs-budget-data">{{ budget_payload | tojson }}</template>
<script src="{{ url_for('static', filename='js/analysis-chart-theme.js') }}"></script>
<script src="{{ url_for('static', filename='js/observability-charts.js') }}"></script>
{% endblock %}

View File

@@ -703,49 +703,7 @@
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
(function() {
const verdictRows = {{ verdict_stats|tojson }};
const canvas = document.getElementById('verdictPieChart');
if (!canvas || !verdictRows || verdictRows.length === 0) return;
const verdictLabelMap = {
effective: '有效',
success: '成功',
positive: '正向',
backfired: '反效果',
negative: '負向',
failed: '失敗',
neutral: '中性',
pending: '待回收',
inconclusive: '尚未定論',
no_data: '無資料'
};
new Chart(canvas, {
type: 'doughnut',
data: {
labels: verdictRows.map(row => verdictLabelMap[row.verdict] || row.verdict || '未分類'),
datasets: [{
data: verdictRows.map(row => row.count || 0),
backgroundColor: ['#2f8f6b', '#c96442', '#f1b45a', '#6d4b3f', '#d9a06f'],
borderColor: '#fff8ee',
borderWidth: 4,
hoverOffset: 10
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: { usePointStyle: true, boxWidth: 8, color: '#6f564b', font: { weight: 700 } }
}
},
cutout: '66%'
}
});
})();
</script>
<template id="obs-business-verdict-data">{{ verdict_stats | tojson }}</template>
<script src="{{ url_for('static', filename='js/analysis-chart-theme.js') }}"></script>
<script src="{{ url_for('static', filename='js/observability-charts.js') }}"></script>
{% endblock %}

View File

@@ -170,15 +170,10 @@
<p class="text-muted mt-3"><small><i class="fas fa-robot me-1"></i>Ollama 優先策略 v5.0 — 基礎設施生命線</small></p>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
(function() {
const data = {{ aiops_summary.heal_sparkline | default([]) | tojson }};
const el = document.getElementById('healSparkline');
if (!el || !data.length) return;
new Chart(el, { type: 'line', data: { labels: data.map(d => d.date), datasets: [{ data: data.map(d => d.rate), borderColor: '#c96442', backgroundColor: 'rgba(201,100,66,.14)', borderWidth: 2, fill: true, tension: .35, pointRadius: 2 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { display: false }, y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } } } });
})();
async function togglePlaybook(id, name) { if (!confirm(`切換 Playbook 「${name}」狀態?`)) return; try { const r = await fetch(`/observability/playbooks/toggle/${id}`, {method: 'POST'}); const d = await r.json(); if (d.ok) { alert(`${d.message}`); window.location.reload(); } else { alert('❌ ' + (d.error || '切換失敗')); } } catch (e) { console.warn('playbook_toggle_failed', e); alert('操作暫時無法完成,請稍後再試或查看系統日誌。'); } }
async function triggerAutoHeal(hostLabel) { if (!confirm(`觸發 AutoHeal\n\n主機:${hostLabel}`)) return; try { const r = await fetch('/observability/host_health/trigger_autoheal', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({host_label: hostLabel}) }); const d = await r.json(); if (d.ok) { alert(`✅ AutoHeal 已派出\n動作:${d.action || '—'}\n訊息:${d.message || ''}`); window.location.reload(); } else { alert('❌ ' + (d.error || d.message || '觸發失敗')); } } catch (e) { console.warn('host_autoheal_failed', e); alert('操作暫時無法完成,請稍後再試或查看系統日誌。'); } }
</script>
{% set host_health_payload = {
'healSparkline': aiops_summary.heal_sparkline | default([])
} %}
<template id="obs-host-health-data">{{ host_health_payload | tojson }}</template>
<script src="{{ url_for('static', filename='js/analysis-chart-theme.js') }}"></script>
<script src="{{ url_for('static', filename='js/observability-charts.js') }}"></script>
{% endblock %}

View File

@@ -623,45 +623,7 @@
</p>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
(function() {
const data = {{ host_sparkline | tojson }};
document.querySelectorAll('canvas[data-host-sparkline]').forEach(el => {
const label = el.getAttribute('data-host-sparkline');
const sp = data[label];
if (!sp || !sp.hours || !sp.hours.length) return;
new Chart(el, {
type: 'line',
data: {
labels: sp.hours,
datasets: [{
data: sp.uptime_pct,
borderColor: '#c96442',
backgroundColor: 'rgba(201, 100, 66, 0.14)',
borderWidth: 1.8,
fill: true,
tension: 0.42,
pointRadius: 0,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
displayColors: false,
callbacks: { label: c => `${c.label}: ${c.parsed.y.toFixed(0)}% 可用率` }
}
},
scales: {
x: { display: false },
y: { display: false, min: 0, max: 100 }
}
}
});
});
})();
</script>
<template id="obs-host-sparkline-data">{{ host_sparkline | tojson }}</template>
<script src="{{ url_for('static', filename='js/analysis-chart-theme.js') }}"></script>
<script src="{{ url_for('static', filename='js/observability-charts.js') }}"></script>
{% endblock %}

View File

@@ -430,7 +430,7 @@
<p class="text-muted mt-3"><small><i class="fas fa-robot me-1"></i>Ollama 優先策略 v5.0 — PPT 視覺 QA 產線</small></p>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script><script>(function(){const stats={{ audit_30d_stats | default({}) | tojson }};const el=document.getElementById('pptAuditPieChart');if(!el||!stats.total)return;const data=[{label:'通過',value:stats.passed||0,color:'#4f8a5b'},{label:'失敗',value:stats.failed||0,color:'#b8792f'},{label:'錯誤',value:stats.error||0,color:'#b94b45'},{label:'跳過',value:stats.skipped||0,color:'#8b8077'}].filter(d=>d.value>0);if(!data.length)return;new Chart(el,{type:'doughnut',data:{labels:data.map(d=>d.label),datasets:[{data:data.map(d=>d.value),backgroundColor:data.map(d=>d.color),borderWidth:1,borderColor:'#fff'}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{font:{size:12}}}}}});})();
async function triggerAiderHeal(pptxFilename,errorMsg){if(!confirm(`觸發 AiderHeal 自動修復?\n\n檔案:${pptxFilename}\n錯誤:${(errorMsg||'').substring(0,200)}`))return;try{const r=await fetch('/observability/ppt_audit/trigger_aider_heal',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({pptx_filename:pptxFilename,error_msg:errorMsg||''})});const d=await r.json();if(d.ok){alert(`✅ AiderHeal 已派出\n動作:${d.action||'—'}\n訊息:${d.message||''}`);}else{alert('❌ '+(d.error||d.message||'觸發失敗'));}}catch(e){console.warn('ppt_audit_trigger_aider_heal_failed',e);alert('操作暫時無法完成,請稍後再試或查看系統日誌。');}}
</script>
<template id="obs-ppt-audit-data">{{ audit_30d_stats | default({}) | tojson }}</template>
<script src="{{ url_for('static', filename='js/analysis-chart-theme.js') }}"></script>
<script src="{{ url_for('static', filename='js/observability-charts.js') }}"></script>
{% endblock %}

View File

@@ -99,10 +99,7 @@
<p class="text-muted mt-3"><small><i class="fas fa-robot me-1"></i>Ollama 優先策略 v5.0 — RAG 知識晉升閘</small></p>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
(function(){const dist={{ episode_distribution_30d | default({}) | tojson }};const el=document.getElementById('episodeDistChart');if(!el||!Object.keys(dist).length)return;const colorMap={pending:'#8b8077',awaiting_review:'#b8792f',approved:'#4f8a5b',rejected_quality:'#b94b45',rejected_hallucination:'#9f3330',rejected_duplicate:'#c96442',rejected_human:'#8f2925',expired:'#b8aea5'};const labelMap={pending:'待處理',awaiting_review:'待審核',approved:'已晉升',rejected_quality:'品質拒',rejected_hallucination:'幻覺拒',rejected_duplicate:'重複拒',rejected_human:'人工拒',expired:'已過期'};const keys=Object.keys(dist);new Chart(el,{type:'doughnut',data:{labels:keys.map(k=>labelMap[k]||k),datasets:[{data:keys.map(k=>dist[k]),backgroundColor:keys.map(k=>colorMap[k]||'#999'),borderWidth:1,borderColor:'#fff'}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{font:{size:11}}}}}});})();
async function approveEpisode(id,btn){btn.disabled=true;btn.innerHTML='<i class="fas fa-spinner fa-spin"></i> 處理中...';try{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}"]`);card.classList.add('border-success');card.querySelector('.card-footer').innerHTML=`<span class="text-success"><i class="fas fa-check me-1"></i>已晉升 → ai_insights #${d.insight_id}(審核者:${d.approver}</span>`;}else{alert('晉升失敗:'+(d.error||'請稍後再試'));btn.disabled=false;btn.innerHTML='<i class="fas fa-check me-1"></i>通過晉升';}}catch(e){console.warn('promotion_approve_failed',e);alert('操作暫時無法完成,請稍後再試或查看系統日誌。');btn.disabled=false;btn.innerHTML='<i class="fas fa-check me-1"></i>通過晉升';}}
async function rejectEpisode(id,btn){if(!confirm(`拒絕學習片段 #${id}?此筆將永不晉升。`))return;btn.disabled=true;btn.innerHTML='<i class="fas fa-spinner fa-spin"></i> 處理中...';try{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}"]`);card.classList.add('border-danger');card.querySelector('.card-footer').innerHTML='<span class="text-danger"><i class="fas fa-times me-1"></i>已拒絕(人工拒絕)</span>';}else{alert('拒絕失敗:'+(d.error||'請稍後再試'));btn.disabled=false;btn.innerHTML='<i class="fas fa-times me-1"></i>拒絕';}}catch(e){console.warn('promotion_reject_failed',e);alert('操作暫時無法完成,請稍後再試或查看系統日誌。');btn.disabled=false;btn.innerHTML='<i class="fas fa-times me-1"></i>拒絕';}}
</script>
<template id="obs-promotion-review-data">{{ episode_distribution_30d | default({}) | tojson }}</template>
<script src="{{ url_for('static', filename='js/analysis-chart-theme.js') }}"></script>
<script src="{{ url_for('static', filename='js/observability-charts.js') }}"></script>
{% endblock %}

View File

@@ -38,5 +38,7 @@
<p class="text-muted mt-3"><small><i class="fas fa-robot me-1"></i>Ollama 優先策略 v5.0 — AI 品質診斷台</small></p>
</div>
{% if rag_overall_dist %}<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script><script>(function(){const data={{ rag_overall_dist | tojson }};const el=document.getElementById('ragFeedbackPieChart');if(!el||!data.length)return;const colorMap={1:'#b94b45',2:'#c96442',3:'#b8792f',4:'#7aaa82',5:'#4f8a5b'};new Chart(el,{type:'doughnut',data:{labels:data.map(r=>`${r.score}`),datasets:[{data:data.map(r=>r.count),backgroundColor:data.map(r=>colorMap[r.score]||'#8b8077'),borderWidth:1,borderColor:'#fff'}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{font:{size:12}}}}}});})();</script>{% endif %}
<template id="obs-quality-trend-data">{{ rag_overall_dist | default([]) | tojson }}</template>
<script src="{{ url_for('static', filename='js/analysis-chart-theme.js') }}"></script>
<script src="{{ url_for('static', filename='js/observability-charts.js') }}"></script>
{% endblock %}

View File

@@ -39,10 +39,5 @@
</div>
<div class="modal fade" id="hitsModal" tabindex="-1"><div class="modal-dialog modal-lg"><div class="modal-content"><div class="modal-header"><h5 class="modal-title"><i class="fas fa-eye me-2"></i>RAG 命中內容</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body" id="hitsModalBody"><div class="text-center"><i class="fas fa-spinner fa-spin"></i> 載入中...</div></div></div></div></div>
<script>
const insightLabelMap={product_pick:'選品攻擊',price_recommendation:'價格建議',competitor_price:'競品價格',sales_anomaly:'業績異常',budget_strategy:'預算策略',rag_feedback:'RAG 反饋',ppt_audit:'PPT 審核',quality_issue:'品質問題',promotion:'活動促銷',market_signal:'市場訊號',strategy:'策略洞察'};
function insightLabel(value){return insightLabelMap[value]||String(value||'未分類洞察').replaceAll('_',' ');}
async function showHits(queryId){const modalEl=document.getElementById('hitsModal');const body=document.getElementById('hitsModalBody');body.innerHTML='<div class="text-center"><i class="fas fa-spinner fa-spin"></i> 載入中...</div>';const modal=new bootstrap.Modal(modalEl);modal.show();try{const r=await fetch(`/observability/rag_queries/${queryId}/hits`);const d=await r.json();if(!d.ok){body.innerHTML=`<div class="alert alert-danger">❌ ${d.error||'載入失敗'}</div>`;return;}let html=`<div class="mb-3"><small class="text-muted">查詢 #${d.query_id} · 門檻 ${d.threshold} · 命中 ${d.hit_count}</small><div class="p-2 mt-1 obs-modal-preview"><small><strong>查詢內容:</strong></small><br><code>${escapeHtml(d.query_text||'')}</code></div></div>`;if(d.hits.length===0){html+='<div class="alert alert-warning">無命中詳細資料</div>';}else{html+='<h6 class="mb-2">主要命中內容預覽:</h6>';d.hits.forEach(h=>{html+=`<div class="mb-2 p-2 obs-modal-preview"><div class="mb-1"><span class="badge bg-light text-dark me-1">#${h.id}</span><span class="badge bg-info me-1">${escapeHtml(insightLabel(h.insight_type))}</span>${h.period?`<span class="badge bg-secondary me-1">${escapeHtml(h.period)}</span>`:''}${h.product_sku?`<small class="text-muted me-1">SKU: ${escapeHtml(h.product_sku)}</small>`:''}<small class="text-muted">${h.created_at}</small></div><small>${escapeHtml(h.content||'')}${h.content&&h.content.length>=300?'…':''}</small></div>`;});}body.innerHTML=html;}catch(e){console.warn('rag_query_hits_load_failed',e);body.innerHTML='<div class="alert alert-danger">❌ 召回詳情暫時無法載入,請稍後再試或查看系統日誌。</div>';}}
function escapeHtml(s){if(!s)return'';return s.replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
</script>
<script src="{{ url_for('static', filename='js/observability-charts.js') }}"></script>
{% endblock %}

View File

@@ -0,0 +1,671 @@
/* Observability chart rendering and page actions.
* Chart.js is loaded lazily through analysis-chart-theme.js so observability
* pages do not block initial HTML parsing on a chart library.
*/
(function () {
'use strict';
const providerLabelMap = {
gcp_ollama: '主力 Ollama',
ollama_secondary: '備援 Ollama',
ollama_111: '111 Ollama',
gemini: 'Gemini',
claude: 'Claude',
nim: 'NIM',
openrouter: 'OpenRouter',
nim_via_elephant: 'NIM Elephant'
};
function readJson(id, fallback) {
const node = document.getElementById(id);
if (!node) return fallback;
try {
return JSON.parse(node.textContent || 'null') ?? fallback;
} catch (error) {
console.error(`[observability] JSON parse failed: ${id}`, error);
return fallback;
}
}
function loadChartJs() {
if (window.EwoooCChartTheme && window.EwoooCChartTheme.loadChartJs) {
return window.EwoooCChartTheme.loadChartJs();
}
if (window.Chart) return Promise.resolve(window.Chart);
return Promise.reject(new Error('Chart.js loader unavailable'));
}
function runWhenVisible(selector, render) {
const targets = Array.from(document.querySelectorAll(selector));
if (!targets.length) return;
if (!('IntersectionObserver' in window)) {
render();
return;
}
const observer = new IntersectionObserver(entries => {
if (!entries.some(entry => entry.isIntersecting)) return;
observer.disconnect();
render();
}, { rootMargin: '240px 0px' });
targets.forEach(target => observer.observe(target));
}
function mountChart(selector, render) {
runWhenVisible(selector, () => {
loadChartJs()
.then(render)
.catch(error => console.error('[observability] Chart.js 載入失敗:', error));
});
}
function renderOverviewHostSparklines() {
const data = readJson('obs-host-sparkline-data', {});
mountChart('canvas[data-host-sparkline]', () => {
document.querySelectorAll('canvas[data-host-sparkline]').forEach(el => {
if (el.dataset.chartReady === '1') return;
const label = el.getAttribute('data-host-sparkline');
const sp = data[label];
if (!sp || !sp.hours || !sp.hours.length) return;
el.dataset.chartReady = '1';
new Chart(el, {
type: 'line',
data: {
labels: sp.hours,
datasets: [{
data: sp.uptime_pct,
borderColor: '#c96442',
backgroundColor: 'rgba(201, 100, 66, 0.14)',
borderWidth: 1.8,
fill: true,
tension: 0.42,
pointRadius: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
displayColors: false,
callbacks: { label: c => `${c.label}: ${c.parsed.y.toFixed(0)}% 可用率` }
}
},
scales: { x: { display: false }, y: { display: false, min: 0, max: 100 } }
}
});
});
});
}
function renderBusinessVerdict() {
const verdictRows = readJson('obs-business-verdict-data', []);
const canvas = document.getElementById('verdictPieChart');
if (!canvas || !verdictRows.length) return;
const verdictLabelMap = {
effective: '有效',
success: '成功',
positive: '正向',
backfired: '反效果',
negative: '負向',
failed: '失敗',
neutral: '中性',
pending: '待回收',
inconclusive: '尚未定論',
no_data: '無資料'
};
mountChart('#verdictPieChart', () => {
new Chart(canvas, {
type: 'doughnut',
data: {
labels: verdictRows.map(row => verdictLabelMap[row.verdict] || row.verdict || '未分類'),
datasets: [{
data: verdictRows.map(row => row.count || 0),
backgroundColor: ['#2f8f6b', '#c96442', '#f1b45a', '#6d4b3f', '#d9a06f'],
borderColor: '#fff8ee',
borderWidth: 4,
hoverOffset: 10
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: { usePointStyle: true, boxWidth: 8, color: '#6f564b', font: { weight: 700 } }
}
},
cutout: '66%'
}
});
});
}
function renderAiCalls() {
const data = readJson('obs-ai-calls-data', {});
const labels = data.labels || [];
const calls = data.calls || [];
const costs = data.costs || [];
const errors = data.errors || [];
if (!labels.length) return;
mountChart('#hourlyTrendChart, canvas[data-spark]', () => {
const sparkColors = { calls: '#c96442', cost: '#b8792f', errors: '#b94b45' };
const sparkData = { calls, cost: costs, errors };
document.querySelectorAll('canvas[data-spark]').forEach(el => {
if (el.dataset.chartReady === '1') return;
const key = el.getAttribute('data-spark');
const series = sparkData[key];
if (!series || !series.length) return;
el.dataset.chartReady = '1';
new Chart(el, {
type: 'line',
data: {
labels,
datasets: [{
data: series,
borderColor: sparkColors[key],
backgroundColor: `${sparkColors[key]}24`,
borderWidth: 1.4,
fill: true,
tension: 0.42,
pointRadius: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false }, tooltip: { enabled: false } },
scales: { x: { display: false }, y: { display: false, beginAtZero: true } }
}
});
});
const el = document.getElementById('hourlyTrendChart');
if (!el || el.dataset.chartReady === '1') return;
el.dataset.chartReady = '1';
new Chart(el, {
data: {
labels,
datasets: [
{ type: 'line', label: '呼叫數', data: calls, borderColor: '#c96442', backgroundColor: 'rgba(201,100,66,.12)', tension: 0.35, fill: true, yAxisID: 'y' },
{ type: 'line', label: '錯誤', data: errors, borderColor: '#b94b45', backgroundColor: 'rgba(185,75,69,.1)', tension: 0.35, yAxisID: 'y' },
{ type: 'bar', label: '成本 USD', data: costs, backgroundColor: 'rgba(184,121,47,.38)', borderColor: '#b8792f', yAxisID: 'y1' }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
scales: {
y: { beginAtZero: true, title: { display: true, text: '次數' } },
y1: { position: 'right', beginAtZero: true, grid: { drawOnChartArea: false }, title: { display: true, text: 'USD' } }
}
}
});
});
}
function renderBudgetCharts() {
const data = readJson('obs-budget-data', {});
const providerCostMonth = data.providerCostMonth || [];
const costTrend30d = data.costTrend30d || [];
if (providerCostMonth.length) {
mountChart('#providerCostPieChart', () => {
const el = document.getElementById('providerCostPieChart');
if (!el || el.dataset.chartReady === '1') return;
el.dataset.chartReady = '1';
const colors = {
gcp_ollama: '#4f8a5b',
ollama_secondary: '#7aaa82',
ollama_111: '#a3cfa8',
gemini: '#b8792f',
claude: '#4f6f8f',
nim: '#6aa6a6',
openrouter: '#8b8077',
nim_via_elephant: '#c96442'
};
new Chart(el, {
type: 'doughnut',
data: {
labels: providerCostMonth.map(d => providerLabelMap[d.provider] || d.provider),
datasets: [{
data: providerCostMonth.map(d => d.cost),
backgroundColor: providerCostMonth.map((d, i) => colors[d.provider] || `hsl(${(i * 47) % 360},55%,55%)`),
borderWidth: 1,
borderColor: '#fff'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { font: { size: 11 } } } }
}
});
});
}
if (costTrend30d.length) {
mountChart('#costTrend30dChart', () => {
const el = document.getElementById('costTrend30dChart');
if (!el || el.dataset.chartReady === '1') return;
el.dataset.chartReady = '1';
const dateSet = [...new Set(costTrend30d.map(row => row.date))].sort();
const providerSet = [...new Set(costTrend30d.map(row => row.provider))];
const palette = ['#c96442', '#b8792f', '#4f8a5b', '#4f6f8f', '#6aa6a6', '#8b8077', '#a66a4a'];
const datasets = providerSet.map((provider, index) => ({
label: providerLabelMap[provider] || provider,
data: dateSet.map(date => {
const row = costTrend30d.find(item => item.date === date && item.provider === provider);
return row ? row.cost : 0;
}),
backgroundColor: palette[index % palette.length]
}));
new Chart(el, {
type: 'bar',
data: { labels: dateSet, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
scales: { x: { stacked: true }, y: { stacked: true, beginAtZero: true, title: { display: true, text: 'USD' } } }
}
});
});
}
}
function renderHostHealth() {
const data = readJson('obs-host-health-data', {});
const healSparkline = data.healSparkline || [];
if (!healSparkline.length) return;
mountChart('#healSparkline', () => {
const el = document.getElementById('healSparkline');
if (!el || el.dataset.chartReady === '1') return;
el.dataset.chartReady = '1';
new Chart(el, {
type: 'line',
data: {
labels: healSparkline.map(d => d.date),
datasets: [{
data: healSparkline.map(d => d.rate),
borderColor: '#c96442',
backgroundColor: 'rgba(201,100,66,.14)',
borderWidth: 2,
fill: true,
tension: 0.35,
pointRadius: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { x: { display: false }, y: { min: 0, max: 100, ticks: { callback: v => `${v}%` } } }
}
});
});
}
function renderPromotionReview() {
const dist = readJson('obs-promotion-review-data', {});
const keys = Object.keys(dist || {});
if (!keys.length) return;
mountChart('#episodeDistChart', () => {
const el = document.getElementById('episodeDistChart');
if (!el || el.dataset.chartReady === '1') return;
el.dataset.chartReady = '1';
const colorMap = {
pending: '#8b8077',
awaiting_review: '#b8792f',
approved: '#4f8a5b',
rejected_quality: '#b94b45',
rejected_hallucination: '#9f3330',
rejected_duplicate: '#c96442',
rejected_human: '#8f2925',
expired: '#b8aea5'
};
const labelMap = {
pending: '待處理',
awaiting_review: '待審核',
approved: '已晉升',
rejected_quality: '品質拒',
rejected_hallucination: '幻覺拒',
rejected_duplicate: '重複拒',
rejected_human: '人工拒',
expired: '已過期'
};
new Chart(el, {
type: 'doughnut',
data: {
labels: keys.map(k => labelMap[k] || k),
datasets: [{ data: keys.map(k => dist[k]), backgroundColor: keys.map(k => colorMap[k] || '#999'), borderWidth: 1, borderColor: '#fff' }]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { font: { size: 11 } } } }
}
});
});
}
function renderQualityTrend() {
const rows = readJson('obs-quality-trend-data', []);
if (!rows.length) return;
mountChart('#ragFeedbackPieChart', () => {
const el = document.getElementById('ragFeedbackPieChart');
if (!el || el.dataset.chartReady === '1') return;
el.dataset.chartReady = '1';
const colorMap = { 1: '#b94b45', 2: '#c96442', 3: '#b8792f', 4: '#7aaa82', 5: '#4f8a5b' };
new Chart(el, {
type: 'doughnut',
data: {
labels: rows.map(row => `${row.score}`),
datasets: [{ data: rows.map(row => row.count), backgroundColor: rows.map(row => colorMap[row.score] || '#8b8077'), borderWidth: 1, borderColor: '#fff' }]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { font: { size: 12 } } } }
}
});
});
}
function renderPptAudit() {
const stats = readJson('obs-ppt-audit-data', {});
if (!stats.total) return;
mountChart('#pptAuditPieChart', () => {
const el = document.getElementById('pptAuditPieChart');
if (!el || el.dataset.chartReady === '1') return;
el.dataset.chartReady = '1';
const data = [
{ label: '通過', value: stats.passed || 0, color: '#4f8a5b' },
{ label: '失敗', value: stats.failed || 0, color: '#b8792f' },
{ label: '錯誤', value: stats.error || 0, color: '#b94b45' },
{ label: '跳過', value: stats.skipped || 0, color: '#8b8077' }
].filter(item => item.value > 0);
if (!data.length) return;
new Chart(el, {
type: 'doughnut',
data: {
labels: data.map(item => item.label),
datasets: [{ data: data.map(item => item.value), backgroundColor: data.map(item => item.color), borderWidth: 1, borderColor: '#fff' }]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'bottom', labels: { font: { size: 12 } } } }
}
});
});
}
async function postJson(url, options) {
const fetcher = window.fetchWithCSRF || window.fetch.bind(window);
return fetcher(url, options || {});
}
window.triggerCodeReview = async function triggerCodeReview() {
if (!confirm('觸發程式碼審查管線?\n\n會對最新 commit 跑 5 步驟審查,背景執行。')) return;
try {
const response = await postJson('/observability/ai_calls/trigger_code_review', { method: 'POST' });
const data = await response.json();
if (data.ok) {
alert(`${data.message}\n\n管線 ID: ${data.pipeline_id}\nCommit: ${data.commit_sha}\n變更檔案: ${data.changed_files_count}`);
} else {
alert(`${data.error || '觸發失敗'}`);
}
} catch (error) {
console.warn('code_review_trigger_failed', error);
alert('操作暫時無法完成,請稍後再試或查看系統日誌。');
}
};
window.forceThrottle = async function forceThrottle() {
if (!confirm('立即重算所有供應商的節流狀態?')) return;
try {
const response = await postJson('/observability/budget/force_throttle', { method: 'POST' });
const data = await response.json();
if (data.ok) {
const providers = data.throttled_providers && data.throttled_providers.length > 0
? data.throttled_providers.join(', ')
: '(無)';
alert(`✅ 已重算:被節流的供應商 = ${providers}`);
window.location.reload();
} else {
alert(`${data.error || '重算失敗'}`);
}
} catch (error) {
console.warn('budget_force_throttle_failed', error);
alert('操作暫時無法完成,請稍後再試或查看系統日誌。');
}
};
window.saveBudget = 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 button = document.querySelector(`.save-budget-btn[data-budget-id="${id}"]`);
if (!budgetInput || !alertInput || !button) return;
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
const response = await postJson(`/observability/budget/update/${id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
budget_usd: parseFloat(budgetInput.value),
alert_pct: parseInt(alertInput.value, 10)
})
});
const data = await response.json();
if (data.ok) {
button.innerHTML = '<i class="fas fa-check"></i> 已儲存';
setTimeout(() => {
button.innerHTML = '<i class="fas fa-save me-1"></i>儲存';
button.disabled = false;
}, 1500);
} else {
alert(`更新失敗:${data.error || '請稍後再試'}`);
button.disabled = false;
button.innerHTML = '<i class="fas fa-save me-1"></i>儲存';
}
} catch (error) {
console.warn('budget_save_failed', error);
alert('操作暫時無法完成,請稍後再試或查看系統日誌。');
button.disabled = false;
button.innerHTML = '<i class="fas fa-save me-1"></i>儲存';
}
};
window.togglePlaybook = async function togglePlaybook(id, name) {
if (!confirm(`切換 Playbook 「${name}」狀態?`)) return;
try {
const response = await postJson(`/observability/playbooks/toggle/${id}`, { method: 'POST' });
const data = await response.json();
if (data.ok) {
alert(`${data.message}`);
window.location.reload();
} else {
alert(`${data.error || '切換失敗'}`);
}
} catch (error) {
console.warn('playbook_toggle_failed', error);
alert('操作暫時無法完成,請稍後再試或查看系統日誌。');
}
};
window.triggerAutoHeal = async function triggerAutoHeal(hostLabel) {
if (!confirm(`觸發 AutoHeal\n\n主機:${hostLabel}`)) return;
try {
const response = await postJson('/observability/host_health/trigger_autoheal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host_label: hostLabel })
});
const data = await response.json();
if (data.ok) {
alert(`✅ AutoHeal 已派出\n動作:${data.action || '—'}\n訊息:${data.message || ''}`);
window.location.reload();
} else {
alert(`${data.error || data.message || '觸發失敗'}`);
}
} catch (error) {
console.warn('host_autoheal_failed', error);
alert('操作暫時無法完成,請稍後再試或查看系統日誌。');
}
};
window.approveEpisode = async function approveEpisode(id, button) {
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 處理中...';
try {
const response = await postJson(`/observability/promotion_review/approve/${id}`, { method: 'POST' });
const data = await response.json();
if (data.ok) {
const card = document.querySelector(`.episode-card[data-episode-id="${id}"]`);
const footer = card ? card.querySelector('.card-footer') : null;
if (card && footer) {
card.classList.add('border-success');
footer.innerHTML = `<span class="text-success"><i class="fas fa-check me-1"></i>已晉升 → ai_insights #${data.insight_id}(審核者:${data.approver}</span>`;
} else {
window.location.reload();
}
} else {
alert(`晉升失敗:${data.error || '請稍後再試'}`);
button.disabled = false;
button.innerHTML = '<i class="fas fa-check me-1"></i>通過晉升';
}
} catch (error) {
console.warn('promotion_approve_failed', error);
alert('操作暫時無法完成,請稍後再試或查看系統日誌。');
button.disabled = false;
button.innerHTML = '<i class="fas fa-check me-1"></i>通過晉升';
}
};
window.rejectEpisode = async function rejectEpisode(id, button) {
if (!confirm(`拒絕學習片段 #${id}?此筆將永不晉升。`)) return;
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> 處理中...';
try {
const response = await postJson(`/observability/promotion_review/reject/${id}`, { method: 'POST' });
const data = await response.json();
if (data.ok) {
const card = document.querySelector(`.episode-card[data-episode-id="${id}"]`);
const footer = card ? card.querySelector('.card-footer') : null;
if (card && footer) {
card.classList.add('border-danger');
footer.innerHTML = '<span class="text-danger"><i class="fas fa-times me-1"></i>已拒絕(人工拒絕)</span>';
} else {
window.location.reload();
}
} else {
alert(`拒絕失敗:${data.error || '請稍後再試'}`);
button.disabled = false;
button.innerHTML = '<i class="fas fa-times me-1"></i>拒絕';
}
} catch (error) {
console.warn('promotion_reject_failed', error);
alert('操作暫時無法完成,請稍後再試或查看系統日誌。');
button.disabled = false;
button.innerHTML = '<i class="fas fa-times me-1"></i>拒絕';
}
};
window.triggerAiderHeal = async function triggerAiderHeal(pptxFilename, errorMsg) {
if (!confirm(`觸發 AiderHeal 自動修復?\n\n檔案:${pptxFilename}\n錯誤:${(errorMsg || '').substring(0, 200)}`)) return;
try {
const response = await postJson('/observability/ppt_audit/trigger_aider_heal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pptx_filename: pptxFilename, error_msg: errorMsg || '' })
});
const data = await response.json();
if (data.ok) {
alert(`✅ AiderHeal 已派出\n動作:${data.action || '—'}\n訊息:${data.message || ''}`);
} else {
alert(`${data.error || data.message || '觸發失敗'}`);
}
} catch (error) {
console.warn('ppt_audit_trigger_aider_heal_failed', error);
alert('操作暫時無法完成,請稍後再試或查看系統日誌。');
}
};
function escapeHtml(value) {
if (!value) return '';
return String(value).replace(/[&<>"']/g, char => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[char]);
}
function insightLabel(value) {
const insightLabelMap = {
product_pick: '選品攻擊',
price_recommendation: '價格建議',
competitor_price: '競品價格',
sales_anomaly: '業績異常',
budget_strategy: '預算策略',
rag_feedback: 'RAG 反饋',
ppt_audit: 'PPT 審核',
quality_issue: '品質問題',
promotion: '活動促銷',
market_signal: '市場訊號',
strategy: '策略洞察'
};
return insightLabelMap[value] || String(value || '未分類洞察').replaceAll('_', ' ');
}
window.showHits = async function showHits(queryId) {
const modalEl = document.getElementById('hitsModal');
const body = document.getElementById('hitsModalBody');
if (!modalEl || !body) return;
body.innerHTML = '<div class="text-center"><i class="fas fa-spinner fa-spin"></i> 載入中...</div>';
const modal = new bootstrap.Modal(modalEl);
modal.show();
try {
const response = await fetch(`/observability/rag_queries/${queryId}/hits`);
const data = await response.json();
if (!data.ok) {
body.innerHTML = `<div class="alert alert-danger">❌ ${escapeHtml(data.error || '載入失敗')}</div>`;
return;
}
let html = `<div class="mb-3"><small class="text-muted">查詢 #${data.query_id} · 門檻 ${data.threshold} · 命中 ${data.hit_count}</small><div class="p-2 mt-1 obs-modal-preview"><small><strong>查詢內容:</strong></small><br><code>${escapeHtml(data.query_text || '')}</code></div></div>`;
if (!data.hits || data.hits.length === 0) {
html += '<div class="alert alert-warning">無命中詳細資料</div>';
} else {
html += '<h6 class="mb-2">主要命中內容預覽:</h6>';
data.hits.forEach(hit => {
html += `<div class="mb-2 p-2 obs-modal-preview"><div class="mb-1"><span class="badge bg-light text-dark me-1">#${hit.id}</span><span class="badge bg-info me-1">${escapeHtml(insightLabel(hit.insight_type))}</span>${hit.period ? `<span class="badge bg-secondary me-1">${escapeHtml(hit.period)}</span>` : ''}${hit.product_sku ? `<small class="text-muted me-1">SKU: ${escapeHtml(hit.product_sku)}</small>` : ''}<small class="text-muted">${escapeHtml(hit.created_at || '')}</small></div><small>${escapeHtml(hit.content || '')}${hit.content && hit.content.length >= 300 ? '…' : ''}</small></div>`;
});
}
body.innerHTML = html;
} catch (error) {
console.warn('rag_query_hits_load_failed', error);
body.innerHTML = '<div class="alert alert-danger">❌ 召回詳情暫時無法載入,請稍後再試或查看系統日誌。</div>';
}
};
function boot() {
renderOverviewHostSparklines();
renderBusinessVerdict();
renderAiCalls();
renderBudgetCharts();
renderHostHealth();
renderPromotionReview();
renderQualityTrend();
renderPptAudit();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
}
})();