| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| let allEvents = []; |
| let filteredEvents = []; |
|
|
| |
| const $ = (id) => document.getElementById(id); |
| const scanSelector = $('scanSelector'); |
| const refreshBtn = $('refreshBtn'); |
| const eventFilter = $('eventFilter'); |
| const agentFilter = $('agentFilter'); |
| const searchInput = $('searchInput'); |
| const timeline = $('timeline'); |
| const filteredCount = $('filteredCount'); |
| const detailPanel = $('detailPanel'); |
| const detailTitle = $('detailTitle'); |
| const detailContent = $('detailContent'); |
| const closeDetail = $('closeDetail'); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| async function fetchScanList() { |
| try { |
| const res = await fetch('/api/checkpoints'); |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| const data = await res.json(); |
| return data.files || []; |
| } catch (e) { |
| console.error('[CHECKPOINT] fetchScanList failed:', e); |
| return []; |
| } |
| } |
|
|
| |
| |
| |
| |
| async function fetchScanEvents(filename) { |
| try { |
| const res = await fetch(`/api/checkpoints/${encodeURIComponent(filename)}`); |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| const data = await res.json(); |
| return data.events || []; |
| } catch (e) { |
| console.error('[CHECKPOINT] fetchScanEvents failed:', e); |
| return []; |
| } |
| } |
|
|
| |
| |
| |
|
|
| async function loadScanList() { |
| const files = await fetchScanList(); |
| scanSelector.innerHTML = ''; |
|
|
| if (files.length === 0) { |
| scanSelector.innerHTML = '<option value="">No scans found</option>'; |
| return; |
| } |
|
|
| |
| files.sort((a, b) => (b.modified || '').localeCompare(a.modified || '')); |
|
|
| files.forEach((f, i) => { |
| const opt = document.createElement('option'); |
| opt.value = f.name; |
| |
| const timeStr = f.modified ? f.modified.substring(5, 16).replace('T', ' ') : ''; |
| const label = f.label || f.name; |
| opt.textContent = `${timeStr} โ ${label}`; |
| scanSelector.appendChild(opt); |
| }); |
|
|
| |
| if (files.length > 0) { |
| scanSelector.value = files[0].name; |
| await loadScanEvents(files[0].name); |
| } |
| } |
|
|
| async function loadScanEvents(filename) { |
| |
| allEvents = []; |
| filteredEvents = []; |
| renderTimeline([]); |
| updateStats([]); |
|
|
| if (!filename) return; |
|
|
| |
| allEvents = await fetchScanEvents(filename); |
|
|
| |
| buildAgentFilter(); |
|
|
| |
| applyFilters(); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| const REAL_AGENTS = new Set([ |
| 'scout', 'analyst', 'critic', 'advisor', |
| 'security_guard', 'intel_fusion' |
| ]); |
|
|
| function buildAgentFilter() { |
| |
| const agents = [...new Set(allEvents.map(e => e.agent).filter(Boolean))] |
| .filter(a => REAL_AGENTS.has(a)) |
| .sort(); |
| agentFilter.innerHTML = '<option value="">All Agents</option>'; |
| agents.forEach(a => { |
| const opt = document.createElement('option'); |
| opt.value = a; |
| |
| const displayNames = { |
| scout: 'Scout (CVE ๅตๅฏ)', analyst: 'Analyst (้ขจ้ชๅๆ)', |
| critic: 'Critic (่พจ่ซ)', advisor: 'Advisor (่ฃ่บซๅ ฑๅ)', |
| security_guard: 'Security Guard', intel_fusion: 'Intel Fusion' |
| }; |
| opt.textContent = displayNames[a] || a; |
| agentFilter.appendChild(opt); |
| }); |
| } |
|
|
| function applyFilters() { |
| const eventType = eventFilter.value; |
| const agent = agentFilter.value; |
| const search = (searchInput.value || '').toLowerCase().trim(); |
|
|
| filteredEvents = allEvents.filter(e => { |
| if (eventType && e.event !== eventType) return false; |
| if (agent && e.agent !== agent) return false; |
| if (search) { |
| const haystack = JSON.stringify(e).toLowerCase(); |
| if (!haystack.includes(search)) return false; |
| } |
| return true; |
| }); |
|
|
| renderTimeline(filteredEvents); |
| updateStats(filteredEvents); |
| filteredCount.textContent = `${filteredEvents.length} / ${allEvents.length} events`; |
| } |
|
|
| |
| |
| |
|
|
| function updateStats(events) { |
| |
| const all = allEvents; |
|
|
| |
| $('statTotal').querySelector('.cp-stat-value').textContent = |
| events.length === all.length ? (all.length || 'โ') : `${events.length}`; |
|
|
| |
| const llmCalls = all.filter(e => e.event === 'LLM_CALL').length; |
| $('statLLM').querySelector('.cp-stat-value').textContent = llmCalls || '0'; |
|
|
| |
| const errors = all.filter(e => e.event === 'LLM_ERROR').length; |
| const retries = all.filter(e => e.event === 'LLM_RETRY').length; |
| $('statErrors').querySelector('.cp-stat-value').textContent = `${errors} / ${retries}`; |
|
|
| |
| const scanEnd = all.find(e => e.event === 'SCAN_END'); |
| if (scanEnd && scanEnd.data && scanEnd.data.total_duration_seconds != null) { |
| const dur = scanEnd.data.total_duration_seconds; |
| $('statDuration').querySelector('.cp-stat-value').textContent = |
| dur >= 60 ? `${(dur / 60).toFixed(1)}m` : `${dur.toFixed(1)}s`; |
| } else { |
| $('statDuration').querySelector('.cp-stat-value').textContent = 'โ'; |
| } |
|
|
| |
| const uniqueAgents = new Set(all.map(e => e.agent).filter(a => a && a !== 'pipeline')); |
| $('statAgents').querySelector('.cp-stat-value').textContent = uniqueAgents.size || 'โ'; |
| } |
|
|
| |
| |
| |
|
|
| function renderTimeline(events) { |
| if (events.length === 0) { |
| timeline.innerHTML = ` |
| <div class="cp-empty-state"> |
| <div class="cp-empty-icon">๐ก</div> |
| <div class="cp-empty-text">No events to display</div> |
| </div>`; |
| return; |
| } |
|
|
| const html = events.map(e => { |
| const ts = formatTimestamp(e.ts); |
| const dataPreview = buildDataPreview(e); |
|
|
| return `<div class="cp-event" data-seq="${e.seq}" onclick="showDetail(${e.seq})"> |
| <span class="cp-event-seq">#${e.seq}</span> |
| <span class="cp-event-ts">${ts}</span> |
| <span class="cp-event-type" data-type="${esc(e.event)}">${esc(e.event)}</span> |
| <span class="cp-event-agent">${esc(e.agent)}</span> |
| <span class="cp-event-data">${esc(dataPreview)}</span> |
| </div>`; |
| }).join(''); |
|
|
| timeline.innerHTML = html; |
| } |
|
|
| |
| |
| |
| function formatTimestamp(iso) { |
| if (!iso) return 'โ'; |
| try { |
| const d = new Date(iso); |
| const h = String(d.getHours()).padStart(2, '0'); |
| const m = String(d.getMinutes()).padStart(2, '0'); |
| const s = String(d.getSeconds()).padStart(2, '0'); |
| const ms = String(d.getMilliseconds()).padStart(3, '0'); |
| return `${h}:${m}:${s}.${ms}`; |
| } catch { return iso.substring(11, 23); } |
| } |
|
|
| |
| |
| |
| function buildDataPreview(event) { |
| const d = event.data || {}; |
| switch (event.event) { |
| case 'SCAN_START': return `scan_id=${d.scan_id || ''}`; |
| case 'SCAN_END': return `status=${d.final_status || ''} | ${d.total_duration_seconds || 0}s | ${d.total_checkpoints || 0} events`; |
| case 'STAGE_ENTER': return d.tech_stack_preview ? d.tech_stack_preview.split('\\n')[0].substring(0, 80) : (d.input_preview || `keys=${(d.input_keys || []).join(',')}`); |
| case 'STAGE_EXIT': return `${d.status || 'OK'} | ${d.duration_ms || 0}ms${d.vuln_count != null ? ' | vulns=' + d.vuln_count : ''}${d.risk_score != null ? ' | risk=' + d.risk_score : ''}`; |
| case 'LLM_CALL': return `model=${shortModel(d.model)} | ${d.task_preview || ''}`; |
| case 'LLM_RESULT': return `${d.status || 'OK'} | ${d.duration_ms || 0}ms | ${d.output_length || 0} chars`; |
| case 'LLM_RETRY': return `${shortModel(d.failed_model)} โ retry #${d.retry_count || 0}`; |
| case 'LLM_ERROR': return `${shortModel(d.model)} | ${(d.error || '').substring(0, 60)}`; |
| case 'TOOL_CALL': return `${d.tool_name || '?'} | ${d.status || ''} | input=${(d.input || '').substring(0, 50)}`; |
| case 'HARNESS_CHECK': return `${d.layer || ''} ${d.check_name || ''} โ ${d.result || ''}`; |
| case 'DEGRADATION': return d.reason || ''; |
| default: return JSON.stringify(d).substring(0, 100); |
| } |
| } |
|
|
| |
| function shortModel(m) { |
| if (!m) return '?'; |
| |
| return m.replace(/^[^\/]+\//, '').replace('-instruct', ''); |
| } |
|
|
| |
| |
| |
|
|
| function showDetail(seq) { |
| const event = allEvents.find(e => e.seq === seq); |
| if (!event) return; |
|
|
| detailTitle.textContent = `#${event.seq} โ ${event.event}`; |
| detailContent.innerHTML = buildDetailTable(event); |
| detailPanel.classList.remove('cp-hidden'); |
| } |
|
|
| function hideDetail() { |
| detailPanel.classList.add('cp-hidden'); |
| } |
|
|
| |
| |
| |
| function buildDetailTable(event) { |
| let html = ''; |
|
|
| |
| html += `<div class="cp-detail-section"> |
| <div class="cp-detail-section-title">ๅบๆฌ่ณ่จ</div> |
| <table class="cp-detail-table"> |
| <tr><td class="cp-dt-key">ๅบ่</td><td class="cp-dt-val">#${event.seq}</td></tr> |
| <tr><td class="cp-dt-key">ไบไปถ้กๅ</td><td class="cp-dt-val"><span class="cp-event-type" data-type="${esc(event.event)}">${esc(event.event)}</span></td></tr> |
| <tr><td class="cp-dt-key">Agent</td><td class="cp-dt-val">${esc(event.agent)}</td></tr> |
| <tr><td class="cp-dt-key">ๆ้</td><td class="cp-dt-val">${esc(event.ts)}</td></tr> |
| <tr><td class="cp-dt-key">Scan ID</td><td class="cp-dt-val cp-mono">${esc(event.scan_id)}</td></tr> |
| </table> |
| </div>`; |
|
|
| |
| const d = event.data || {}; |
|
|
| switch (event.event) { |
| case 'SCAN_START': |
| html += renderSection('ๆๆๅๅ', [ |
| ['Scan ID', d.scan_id], |
| ]); |
| break; |
|
|
| case 'SCAN_END': |
| html += renderSection('ๆๆ็ตๆ', [ |
| ['ๆ็ต็ๆ
', d.final_status, d.final_status === 'COMPLETE' ? 'green' : 'red'], |
| ['็ธฝๆ็บๆ้', d.total_duration_seconds != null ? `${d.total_duration_seconds}s (${(d.total_duration_seconds/60).toFixed(1)} min)` : 'โ'], |
| ['็ธฝ Checkpoint ๆธ', d.total_checkpoints], |
| ]); |
| if (d.event_summary) { |
| html += renderSection('ไบไปถ็ตฑ่จ', Object.entries(d.event_summary).map(([k, v]) => [k, v])); |
| } |
| break; |
|
|
| case 'STAGE_ENTER': |
| html += renderSection('Stage ้ฒๅ
ฅ', [ |
| ['่ผธๅ
ฅ Keys', d.input_keys], |
| ['Input Hash', d.input_hash], |
| ]); |
| if (d.tech_stack_preview) { |
| html += renderSection('ๆๆ็ฎๆจ้ ่ฆฝ', [], d.tech_stack_preview); |
| } |
| if (d.packages && d.packages.length > 0) { |
| |
| html += renderSection('ๅทฒ่ๅ็็ฌฌไธๆนๅฅไปถ๏ผๅๅฉ Intel Fusion ๅ Scout ๆฅ่ฉข๏ผ', [ |
| ['ๅฅไปถๆธ้', d.packages.length], |
| ['ๅฅไปถๆธ
ๅฎ', d.packages.join(', ')], |
| ]); |
| } |
| if (d.vuln_count != null) { |
| html += renderSection('ๆผๆด่ณ่จ', [['ๆผๆดๆธ้', d.vuln_count]]); |
| } |
| break; |
|
|
| case 'STAGE_EXIT': |
| html += renderSection('Stage ๅฎๆ', [ |
| ['็ๆ
', d.status, d.status === 'SUCCESS' ? 'green' : (d.status === 'DEGRADED' ? 'orange' : 'red')], |
| ['่ๆ', d.duration_ms != null ? `${d.duration_ms}ms` : 'โ'], |
| ['่ผธๅบ Keys', d.output_keys], |
| ]); |
| if (d.vuln_count != null) html += renderKV('ๆผๆดๆธ้', d.vuln_count); |
| if (d.risk_score != null) html += renderKV('้ขจ้ชๅๆธ', d.risk_score); |
| if (d.scan_path) html += renderKV('ๆๆ่ทฏๅพ', d.scan_path); |
| if (d.degraded != null) html += renderKV('ๆฏๅฆ้็ด', d.degraded ? 'โ ๏ธ ๆฏ' : 'โ
ๅฆ'); |
| if (d.verdict) html += renderKV('่ฃๆฑบ', d.verdict); |
| |
| if (d.packages_used && d.packages_used.length > 0) { |
| html += renderSection('ๅทฒๆไบคๆๆ็ๅฅไปถ๏ผScout๏ผ', [ |
| ['ๅฅไปถๆธ้', d.packages_used.length], |
| ['ๅฅไปถๅ็จฑ', d.packages_used.join(', ')], |
| ]); |
| } |
| if (d.cves_scored != null) html += renderKV('CVEs Scored', d.cves_scored); |
| break; |
|
|
| case 'LLM_CALL': |
| html += renderSection('LLM ๅผๅซ', [ |
| ['ๆจกๅ', d.model], |
| ['Provider', d.provider], |
| ['ไปปๅๆ่ฟฐ', d.task_preview], |
| ]); |
| break; |
|
|
| case 'LLM_RESULT': |
| html += renderSection('LLM ๅๆ', [ |
| ['ๆจกๅ', d.model], |
| ['็ๆ
', d.status, d.status === 'SUCCESS' ? 'green' : 'red'], |
| ['ๅๆ้ทๅบฆ', d.output_length != null ? `${d.output_length} ๅญๅ
` : 'โ'], |
| ['่ๆ', d.duration_ms != null ? `${d.duration_ms}ms (${(d.duration_ms/1000).toFixed(1)}s)` : 'โ'], |
| ]); |
| if (d.thinking) { |
| html += renderSection('LLM ๆ่้็จ๏ผๅ 1000 ๅญๅ
๏ผ', [], d.thinking); |
| } |
| break; |
|
|
| case 'LLM_RETRY': |
| html += renderSection('LLM ้่ฉฆ', [ |
| ['ๅคฑๆๆจกๅ', d.failed_model], |
| ['้่ฉฆๆฌกๆธ', d.retry_count], |
| ['ไธไธๅๆจกๅ', d.next_model], |
| ['้ฏ่ชคๅๅ ', d.error], |
| ]); |
| break; |
|
|
| case 'LLM_ERROR': |
| html += renderSection('LLM ้ฏ่ชค', [ |
| ['ๆจกๅ', d.model], |
| ['้ฏ่ชค่จๆฏ', d.error], |
| ]); |
| break; |
|
|
| case 'TOOL_CALL': |
| html += renderSection('ๅทฅๅ
ทๅผๅซ', [ |
| ['ๅทฅๅ
ทๅ็จฑ', d.tool_name], |
| ['็ๆ
', d.status, d.status === 'SUCCESS' ? 'green' : 'red'], |
| ['่ผธๅ
ฅ', d.input], |
| ['่ผธๅบ', d.output], |
| ]); |
| break; |
|
|
| case 'HARNESS_CHECK': |
| html += renderSection('Harness ๆชขๆฅ', [ |
| ['ไฟ้ๅฑค', d.layer], |
| ['ๆชขๆฅๅ็จฑ', d.check_name], |
| ['็ตๆ', d.result, d.result === 'PASS' ? 'green' : 'orange'], |
| ]); |
| if (d.details) { |
| html += renderSection('่ฉณ็ดฐ่ณๆ', Object.entries(d.details).map(([k, v]) => [k, v])); |
| } |
| break; |
|
|
| case 'DEGRADATION': |
| html += renderSection('้็ดไบไปถ', [ |
| ['ๅๅ ', d.reason], |
| ['ๅฑค็ด', d.level], |
| ]); |
| break; |
|
|
| default: |
| |
| if (Object.keys(d).length > 0) { |
| html += renderSection('ไบไปถ่ณๆ', Object.entries(d).map(([k, v]) => [k, typeof v === 'object' ? JSON.stringify(v) : v])); |
| } |
| } |
|
|
| return html; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| function renderSection(title, rows, codeBlock) { |
| let html = `<div class="cp-detail-section"> |
| <div class="cp-detail-section-title">${esc(title)}</div>`; |
|
|
| if (rows.length > 0) { |
| html += '<table class="cp-detail-table">'; |
| for (const [key, val, color] of rows) { |
| const displayVal = val == null ? 'โ' : String(val); |
| const colorClass = color ? ` cp-dt-${color}` : ''; |
| html += `<tr> |
| <td class="cp-dt-key">${esc(key)}</td> |
| <td class="cp-dt-val${colorClass}">${esc(displayVal)}</td> |
| </tr>`; |
| } |
| html += '</table>'; |
| } |
|
|
| if (codeBlock) { |
| html += `<pre class="cp-detail-code">${esc(String(codeBlock))}</pre>`; |
| } |
|
|
| html += '</div>'; |
| return html; |
| } |
|
|
| |
| function renderKV(key, val) { |
| return `<div class="cp-detail-section"> |
| <table class="cp-detail-table"> |
| <tr><td class="cp-dt-key">${esc(key)}</td><td class="cp-dt-val">${esc(String(val))}</td></tr> |
| </table> |
| </div>`; |
| } |
|
|
| |
| |
| |
| function esc(str) { |
| if (str == null) return ''; |
| const div = document.createElement('div'); |
| div.textContent = String(str); |
| return div.innerHTML; |
| } |
|
|
| |
| |
| |
|
|
| scanSelector.addEventListener('change', () => loadScanEvents(scanSelector.value)); |
| refreshBtn.addEventListener('click', () => loadScanList()); |
| eventFilter.addEventListener('change', applyFilters); |
| agentFilter.addEventListener('change', applyFilters); |
| searchInput.addEventListener('input', applyFilters); |
| closeDetail.addEventListener('click', hideDetail); |
|
|
| |
| document.addEventListener('keydown', (e) => { |
| if (e.key === 'Escape') hideDetail(); |
| }); |
|
|
| |
| |
| |
|
|
| |
| function showToast(msg, type = 'info') { |
| const id = 'cp-toast-' + Date.now(); |
| const colors = { |
| info: 'rgba(0,255,255,0.15)', |
| success: 'rgba(0,230,118,0.15)', |
| warning: 'rgba(255,214,0,0.15)', |
| }; |
| const border = { |
| info: 'rgba(0,255,255,0.3)', |
| success: 'rgba(0,230,118,0.3)', |
| warning: 'rgba(255,214,0,0.3)', |
| }; |
| const toast = document.createElement('div'); |
| toast.id = id; |
| toast.style.cssText = [ |
| 'position:fixed', 'bottom:24px', 'right:24px', 'z-index:9999', |
| 'padding:10px 18px', 'border-radius:6px', |
| `background:${colors[type] || colors.info}`, |
| `border:1px solid ${border[type] || border.info}`, |
| 'color:var(--text-main)', 'font-family:var(--mono)', 'font-size:0.82rem', |
| 'backdrop-filter:blur(8px)', 'box-shadow:0 4px 20px rgba(0,0,0,0.3)', |
| 'animation:slideIn 0.2s ease-out', |
| 'max-width:360px', |
| ].join(';'); |
| toast.textContent = msg; |
| document.body.appendChild(toast); |
| setTimeout(() => toast.remove(), 4000); |
| } |
|
|
| |
| |
| |
| |
| let _lastKnownFilenames = new Set(); |
| let _autoRefreshInterval = null; |
|
|
| async function startAutoRefresh() { |
| if (_autoRefreshInterval) return; |
| _autoRefreshInterval = setInterval(async () => { |
| try { |
| const files = await fetchScanList(); |
| const currentNames = new Set(files.map(f => f.name)); |
| |
| const newFiles = files.filter(f => !_lastKnownFilenames.has(f.name)); |
| if (newFiles.length > 0 && _lastKnownFilenames.size > 0) { |
| |
| await refreshScanListSilently(files); |
| const newest = newFiles[0]; |
| const timeStr = newest.modified ? newest.modified.substring(5, 16).replace('T', ' ') : ''; |
| showToast(`๐ข ๆฐๆๆๅฎๆ๏ผ${timeStr} โ ${newest.label || newest.name}`, 'success'); |
| } |
| _lastKnownFilenames = currentNames; |
| } catch (e) { |
| |
| console.warn('[CHECKPOINT] auto-refresh failed:', e); |
| } |
| }, 15000); |
| console.info('[CHECKPOINT] Auto-refresh started (15s interval)'); |
| } |
|
|
| |
| |
| |
| async function refreshScanListSilently(files) { |
| const currentSelection = scanSelector.value; |
| |
| scanSelector.innerHTML = ''; |
| files.sort((a, b) => (b.modified || '').localeCompare(a.modified || '')); |
| files.forEach(f => { |
| const opt = document.createElement('option'); |
| opt.value = f.name; |
| const timeStr = f.modified ? f.modified.substring(5, 16).replace('T', ' ') : ''; |
| const label = f.label || f.name; |
| opt.textContent = `${timeStr} โ ${label}`; |
| scanSelector.appendChild(opt); |
| }); |
| |
| if (currentSelection && files.some(f => f.name === currentSelection)) { |
| scanSelector.value = currentSelection; |
| } else if (files.length > 0) { |
| scanSelector.value = files[0].name; |
| } |
| } |
|
|
| loadScanList().then(() => { |
| |
| fetchScanList().then(files => { |
| _lastKnownFilenames = new Set(files.map(f => f.name)); |
| }); |
| |
| startAutoRefresh(); |
| }); |
|
|