Files
ewoooc/web/static/js/observability-charts.js

1123 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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_secondary: '備援建議路徑',
ollama_111: '第三建議路徑',
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, triggerButton) {
if (!confirm(`觸發修復流程?\n\n檔案:${pptxFilename}\n問題:${(errorMsg || '').substring(0, 200)}`)) return;
const statusNode = document.querySelector('[data-ppt-auto-status]');
const originalHtml = triggerButton ? triggerButton.innerHTML : '';
if (triggerButton) {
triggerButton.disabled = true;
triggerButton.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>派工中';
}
if (statusNode) {
statusNode.classList.add('is-working');
statusNode.textContent = `${pptxFilename || 'PPT'} 正在排入背景修復流程。`;
}
try {
const response = await postJson('/observability/ppt_audit/trigger_aider_heal', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pptx_filename: pptxFilename, issue_summary: errorMsg || '' })
});
const data = await response.json();
if (!response.ok || !data.ok) {
throw new Error(data.error || data.message || '觸發失敗');
}
if (triggerButton) {
triggerButton.innerHTML = data.status === 'already_running'
? '<i class="fas fa-clock me-1"></i>執行中'
: '<i class="fas fa-check me-1"></i>已排入';
}
if (statusNode) {
statusNode.classList.remove('is-working');
statusNode.textContent = data.message || '修復流程已排入背景執行,完成後會回報處理結果。';
}
if (window.refreshPptAiderHealStatus) {
window.refreshPptAiderHealStatus();
}
} catch (error) {
console.warn('ppt_audit_trigger_aider_heal_failed', error);
if (triggerButton) {
triggerButton.disabled = false;
triggerButton.innerHTML = originalHtml;
}
if (statusNode) {
statusNode.classList.remove('is-working');
statusNode.textContent = '修復派工失敗,請稍後再試或查看系統日誌。';
}
}
};
function renderPptAiderHealStatus(payload) {
const panel = document.querySelector('[data-ppt-aider-status]');
if (!panel) return;
const jobs = Array.isArray(payload && payload.jobs) ? payload.jobs : [];
const count = Number((payload && payload.active_count) || jobs.length || 0);
panel.classList.toggle('is-empty', count <= 0);
const title = panel.querySelector('[data-ppt-aider-status-title]');
const meta = panel.querySelector('[data-ppt-aider-status-meta]');
const list = panel.querySelector('[data-ppt-aider-job-list]');
if (title) title.textContent = count > 0 ? `修復流程執行中 · ${count}` : '修復流程待命';
if (meta) {
meta.textContent = count > 0
? '修復完成後會回報處理結果。'
: '有問題的審核紀錄可直接一鍵派工。';
}
if (list) {
list.innerHTML = jobs.slice(0, 3).map(job => `
<div class="ppt-aider-job">
<code>${escapeHtml(job.pptx_filename || 'manual')}</code>
<span>${escapeHtml(job.queued_at || '')}</span>
<small>${escapeHtml(job.diagnosis || '')}</small>
</div>
`).join('');
}
}
window.refreshPptAiderHealStatus = async function refreshPptAiderHealStatus() {
try {
const response = await fetch('/observability/ppt_audit/aider_heal_status', {
headers: { 'Accept': 'application/json' }
});
const data = await response.json();
if (!response.ok || !data.ok) return;
renderPptAiderHealStatus(data);
} catch (error) {
console.warn('ppt_audit_aider_heal_status_failed', error);
}
};
function renderPptVisionStatus(payload) {
const panel = document.querySelector('[data-ppt-vision-status]');
if (!panel) return;
const status = (payload && payload.status) || 'unknown';
panel.className = panel.className.replace(/\bis-[a-z_]+\b/g, '').trim();
panel.classList.add(`is-${status}`);
const title = panel.querySelector('[data-ppt-vision-status-title]');
const meta = panel.querySelector('[data-ppt-vision-status-meta]');
const list = panel.querySelector('[data-ppt-vision-status-list]');
if (title) title.textContent = (payload && payload.status_label) || '狀態未知';
if (meta) meta.textContent = (payload && payload.message) || '最近視覺 QA 狀態無法讀取。';
if (!list) return;
const lastRun = payload && payload.last_run;
if (!lastRun) {
list.innerHTML = `
<div class="ppt-vision-job">
<span>尚無紀錄</span>
<strong>待命</strong>
<small>按下「立即視覺 QA」後會在這裡顯示背景任務狀態。</small>
</div>
`;
return;
}
const summary = lastRun.summary || {};
const timestamp = lastRun.finished_at || lastRun.started_at || lastRun.queued_at || '';
const issueText = `${Number(summary.audited_count || 0)} 份 / ${Number(summary.total_issues || 0)} 問題`;
const errorText = Number(summary.error_count || 0) > 0
? `錯誤 ${Number(summary.error_count || 0)}`
: '無執行錯誤';
list.innerHTML = `
<div class="ppt-vision-job">
<span>${escapeHtml(timestamp)}</span>
<strong>${escapeHtml(issueText)}</strong>
<small>${escapeHtml(errorText)}</small>
</div>
`;
}
window.refreshPptVisionStatus = async function refreshPptVisionStatus() {
try {
const response = await fetch('/observability/ppt_audit/vision_status', {
headers: { 'Accept': 'application/json' }
});
const data = await response.json();
if (!response.ok || !data.ok) return;
renderPptVisionStatus(data);
} catch (error) {
console.warn('ppt_vision_status_failed', error);
}
};
function initPptAutoGeneration() {
const panel = document.querySelector('[data-ppt-auto-generation]');
const pageStatus = document.querySelector('[data-ppt-auto-status]');
const previewModal = document.querySelector('[data-ppt-preview-modal]');
const previewFrame = previewModal ? previewModal.querySelector('[data-ppt-preview-frame]') : null;
const previewTitle = previewModal ? previewModal.querySelector('[data-ppt-preview-modal-title]') : null;
const previewFilename = previewModal ? previewModal.querySelector('[data-ppt-preview-filename]') : null;
const previewOpenPage = previewModal ? previewModal.querySelector('[data-ppt-preview-open-page]') : null;
const previewDownload = previewModal ? previewModal.querySelector('[data-ppt-preview-download]') : null;
const previewLoading = previewModal ? previewModal.querySelector('[data-ppt-preview-loading]') : null;
const previewFrameWrap = previewModal ? previewModal.querySelector('.ppt-preview-frame-wrap') : null;
const visionAuditFilenames = readJson('obs-ppt-audit-filenames', []);
if (document.querySelector('[data-ppt-vision-status]') && window.refreshPptVisionStatus) {
window.refreshPptVisionStatus();
}
function closePreviewModal() {
if (!previewModal) return;
previewModal.hidden = true;
previewModal.setAttribute('aria-hidden', 'true');
document.body.classList.remove('ppt-preview-open');
if (previewFrame) {
previewFrame.removeAttribute('src');
}
}
function openPreviewModal(trigger) {
if (!previewModal || !previewFrame) return false;
const pdfUrl = trigger.dataset.pptPreviewPdf || '';
if (!pdfUrl) return false;
const title = trigger.dataset.pptPreviewTitle || '簡報線上預覽';
const filename = trigger.dataset.pptFilename || '';
if (previewTitle) previewTitle.textContent = title;
if (previewFilename) previewFilename.textContent = filename;
if (previewOpenPage) previewOpenPage.href = trigger.dataset.pptPreviewPage || trigger.href || pdfUrl;
if (previewDownload) previewDownload.href = trigger.dataset.pptDownloadUrl || '#';
if (previewLoading) previewLoading.textContent = filename ? `${filename} 正在載入 PDF 預覽` : '正在載入 PDF 預覽';
previewModal.hidden = false;
previewModal.setAttribute('aria-hidden', 'false');
document.body.classList.add('ppt-preview-open');
if (previewFrameWrap) previewFrameWrap.classList.remove('is-loaded');
previewFrame.src = pdfUrl;
return true;
}
if (previewModal) {
if (previewFrame && previewFrame.dataset.boundLoad !== '1') {
previewFrame.dataset.boundLoad = '1';
previewFrame.addEventListener('load', () => {
if (previewFrameWrap) previewFrameWrap.classList.add('is-loaded');
});
}
previewModal.querySelectorAll('[data-ppt-preview-close]').forEach(button => {
if (button.dataset.bound === '1') return;
button.dataset.bound = '1';
button.addEventListener('click', closePreviewModal);
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && !previewModal.hidden) closePreviewModal();
});
}
document.querySelectorAll('[data-ppt-open-preview]').forEach(link => {
if (link.dataset.bound === '1') return;
link.dataset.bound = '1';
link.addEventListener('click', event => {
if (openPreviewModal(link)) {
event.preventDefault();
}
});
});
document.querySelectorAll('[data-ppt-aider-heal]').forEach(button => {
if (button.dataset.bound === '1') return;
button.dataset.bound = '1';
button.addEventListener('click', () => {
window.triggerAiderHeal(button.dataset.pptFilename || '', button.dataset.pptError || '', button);
});
});
async function triggerVisionAudit(button) {
const filenames = Array.isArray(visionAuditFilenames) ? visionAuditFilenames.filter(Boolean) : [];
if (!filenames.length) {
if (pageStatus) pageStatus.textContent = '目前沒有可送進視覺 QA 的 PPTX 檔案。';
return;
}
const buttons = Array.from(document.querySelectorAll('[data-ppt-run-vision]'));
const originalHtml = button ? button.innerHTML : '';
buttons.forEach(item => {
item.disabled = true;
item.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>QA 排入中';
});
if (pageStatus) {
pageStatus.classList.add('is-working');
pageStatus.textContent = `已準備送出 ${filenames.length} 份簡報進行視覺 QA完成後會更新審核紀錄。`;
}
try {
const response = await postJson('/observability/ppt_audit/run_vision', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filenames, max_files: filenames.length })
});
const data = await response.json();
if (!response.ok || !data.ok) {
throw new Error(data.error || data.message || '視覺 QA 送出失敗');
}
buttons.forEach(item => {
item.innerHTML = data.status === 'already_running'
? '<i class="fas fa-clock me-1"></i>QA 執行中'
: '<i class="fas fa-check me-1"></i>QA 已排入';
});
if (pageStatus) {
pageStatus.textContent = data.status === 'already_running'
? '視覺 QA 已在執行中,請稍後重新整理查看審核結果。'
: `視覺 QA 已排入 ${filenames.length} 份簡報;完成後會更新審核結果。`;
}
if (window.refreshPptVisionStatus) {
window.refreshPptVisionStatus();
}
} catch (error) {
console.warn('ppt_vision_audit_queue_failed', error);
buttons.forEach(item => {
item.disabled = false;
item.innerHTML = item === button && originalHtml ? originalHtml : '<i class="fas fa-eye me-1"></i>立即視覺 QA';
});
if (pageStatus) {
pageStatus.classList.remove('is-working');
pageStatus.textContent = '視覺 QA 送出失敗,請稍後再試或查看系統日誌。';
}
}
}
document.querySelectorAll('[data-ppt-run-vision]').forEach(button => {
if (button.dataset.bound === '1') return;
button.dataset.bound = '1';
button.addEventListener('click', () => triggerVisionAudit(button));
});
function markPreviewCacheReady(filename) {
document.querySelectorAll('[data-ppt-preview-state]').forEach(node => {
if (node.dataset.pptPreviewState !== filename) return;
node.classList.remove('status-warn');
node.classList.add('status-blue');
node.textContent = 'PDF 預覽快取已建立';
});
document.querySelectorAll('[data-ppt-prewarm-preview]').forEach(node => {
if (node.dataset.pptFilename !== filename) return;
node.innerHTML = '<i class="fas fa-check me-1"></i>已快取';
node.disabled = true;
});
}
async function prewarmPreview(button, options = {}) {
const filename = button.dataset.pptFilename || '';
if (!filename) return false;
const originalHtml = button.innerHTML;
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>預熱中';
if (!options.quiet && pageStatus) {
pageStatus.classList.add('is-working');
pageStatus.textContent = `${filename} 正在建立 PDF 預覽快取。`;
}
try {
const response = await postJson(`/observability/ppt_audit_file/${encodeURIComponent(filename)}/prewarm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (!response.ok || !data.ok) {
throw new Error(data.error || '預熱失敗');
}
markPreviewCacheReady(filename);
if (!options.quiet && pageStatus) {
pageStatus.classList.remove('is-working');
pageStatus.textContent = data.message || `${filename} 的 PDF 預覽快取已建立。`;
}
return true;
} catch (error) {
console.warn('ppt_preview_prewarm_failed', error);
button.disabled = false;
button.innerHTML = originalHtml;
if (!options.quiet && pageStatus) {
pageStatus.classList.remove('is-working');
pageStatus.textContent = 'PDF 預覽預熱失敗,請稍後再試或直接開啟線上預覽。';
}
return false;
}
}
document.querySelectorAll('[data-ppt-prewarm-preview]').forEach(button => {
if (button.dataset.bound === '1') return;
button.dataset.bound = '1';
button.addEventListener('click', () => prewarmPreview(button));
});
document.querySelectorAll('[data-ppt-prewarm-all]').forEach(button => {
if (button.dataset.bound === '1') return;
button.dataset.bound = '1';
button.addEventListener('click', async () => {
const targets = [];
const seen = new Set();
document.querySelectorAll('[data-ppt-prewarm-preview]').forEach(item => {
const filename = item.dataset.pptFilename || '';
if (!filename || item.disabled || seen.has(filename)) return;
seen.add(filename);
targets.push(item);
});
if (!targets.length) {
button.disabled = true;
button.innerHTML = '<i class="fas fa-check me-1"></i>本頁已快取';
if (pageStatus) {
pageStatus.textContent = '本頁可預覽簡報的 PDF 快取都已建立。';
}
return;
}
const originalHtml = button.innerHTML;
const countBadge = button.querySelector('[data-ppt-prewarm-count]');
button.disabled = true;
let successCount = 0;
let failedCount = 0;
for (let index = 0; index < targets.length; index += 1) {
button.innerHTML = `<i class="fas fa-spinner fa-spin me-1"></i>預熱 ${index + 1}/${targets.length}`;
if (pageStatus) {
pageStatus.classList.add('is-working');
pageStatus.textContent = `正在預熱本頁 PDF 快取:${index + 1}/${targets.length}`;
}
const ok = await prewarmPreview(targets[index], { quiet: true });
if (ok) successCount += 1;
else failedCount += 1;
if (countBadge) {
countBadge.textContent = String(Math.max(targets.length - successCount, 0));
}
}
if (pageStatus) {
pageStatus.classList.remove('is-working');
pageStatus.textContent = failedCount
? `PDF 預熱完成 ${successCount}/${targets.length},仍有 ${failedCount} 份失敗。`
: `本頁 ${successCount} 份 PDF 預覽快取已建立。`;
}
if (failedCount) {
button.disabled = false;
button.innerHTML = originalHtml;
} else {
button.innerHTML = '<i class="fas fa-check me-1"></i>本頁已快取';
}
});
});
if (!panel) return;
const button = panel.querySelector('[data-ppt-generate-missing]');
const status = panel.querySelector('[data-ppt-auto-status]');
const reportTypes = (panel.dataset.reportTypes || '')
.split(',')
.map(value => value.trim())
.filter(Boolean);
async function triggerGeneration(isAuto, selectedReportTypes, triggerButton, labelText, force = false) {
const targetButton = triggerButton || button;
const targetReportTypes = selectedReportTypes || reportTypes;
const originalHtml = targetButton ? targetButton.innerHTML : '';
if (targetButton) {
targetButton.disabled = true;
targetButton.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>排入中';
}
if (status) {
status.classList.add('is-working');
status.textContent = isAuto
? '偵測到本月定義簡報缺漏,已排入背景補齊。'
: `${labelText || '簡報補齊'} 已排入背景產線,完成後重新整理即可看到最新狀態。`;
}
try {
const response = await postJson('/observability/ppt_audit/generate_missing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ report_types: targetReportTypes, force: Boolean(force) })
});
const data = await response.json();
if (status) {
status.textContent = data.message || (data.status === 'queued'
? '已排入背景補齊,請稍後重新整理。'
: `狀態:${data.status || '已送出'}`);
}
if (targetButton) {
targetButton.innerHTML = '<i class="fas fa-check me-1"></i>已排入';
}
} catch (error) {
console.warn('ppt_auto_generation_failed', error);
if (status) {
status.classList.remove('is-working');
status.textContent = '補齊任務送出失敗,請稍後再試或查看系統日誌。';
}
if (targetButton) {
targetButton.disabled = false;
targetButton.innerHTML = originalHtml;
}
}
}
if (button) {
button.addEventListener('click', () => triggerGeneration(false));
}
document.querySelectorAll('[data-ppt-generate-one]').forEach(singleButton => {
if (singleButton.dataset.bound === '1') return;
singleButton.dataset.bound = '1';
singleButton.addEventListener('click', () => {
const reportType = singleButton.dataset.reportType || '';
if (!reportType) return;
const label = singleButton.dataset.reportLabel || reportType;
triggerGeneration(false, [reportType], singleButton, label, true);
});
});
if (panel.dataset.autoStart === 'true') {
const key = `ppt-auto-generation:${new Date().toISOString().slice(0, 10)}`;
let last = 0;
const now = Date.now();
try {
last = Number(window.localStorage.getItem(key) || 0);
} catch (_error) {
last = 0;
}
if (reportTypes.length && (!last || now - last > 6 * 60 * 60 * 1000)) {
try {
window.localStorage.setItem(key, String(now));
} catch (_error) {
// Ignore storage failures; the server-side generation lock still protects the job.
}
triggerGeneration(true);
}
}
}
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: '知識反饋',
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();
initPptAutoGeneration();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot, { once: true });
} else {
boot();
}
})();