750 lines
28 KiB
JavaScript
750 lines
28 KiB
JavaScript
/* 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 initPptAutoGeneration() {
|
||
const panel = document.querySelector('[data-ppt-auto-generation]');
|
||
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 || '');
|
||
});
|
||
});
|
||
|
||
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) {
|
||
if (button) {
|
||
button.disabled = true;
|
||
button.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>補齊中';
|
||
}
|
||
if (status) {
|
||
status.classList.add('is-working');
|
||
status.textContent = isAuto
|
||
? '偵測到本月定義簡報缺漏,已排入背景補齊。'
|
||
: '已排入背景補齊,產出完成後重新整理即可看到最新檔案。';
|
||
}
|
||
|
||
try {
|
||
const response = await postJson('/observability/ppt_audit/generate_missing', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ report_types: reportTypes })
|
||
});
|
||
const data = await response.json();
|
||
if (status) {
|
||
status.textContent = data.message || (data.status === 'queued'
|
||
? '已排入背景補齊,請稍後重新整理。'
|
||
: `狀態:${data.status || '已送出'}`);
|
||
}
|
||
} catch (error) {
|
||
console.warn('ppt_auto_generation_failed', error);
|
||
if (status) {
|
||
status.classList.remove('is-working');
|
||
status.textContent = '補齊任務送出失敗,請稍後再試或查看系統日誌。';
|
||
}
|
||
if (button) button.disabled = false;
|
||
}
|
||
}
|
||
|
||
if (button) {
|
||
button.addEventListener('click', () => triggerGeneration(false));
|
||
}
|
||
|
||
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 => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
})[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();
|
||
initPptAutoGeneration();
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', boot, { once: true });
|
||
} else {
|
||
boot();
|
||
}
|
||
})();
|