This commit is contained in:
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/observability-charts.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
671
web/static/js/observability-charts.js
Normal file
671
web/static/js/observability-charts.js
Normal 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 => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
})[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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user