roglag / web /live.html
deploy
de-slop UI (Impeccable: 0 anti-patterns) + guaranteed RAG sections
0cb4cc5
Raw
History Blame Contribute Delete
24.6 kB
<!DOCTYPE html>
<html lang="th">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ROGLAG — Realtime Console</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans+Thai:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{
--bg:#0b0f17; --panel:#121826; --panel2:#0f1420; --line:#1f2a3d;
--txt:#e6edf6; --muted:#90a0b8; --accent:#4da3ff; --accent2:#7c5cff;
--info:#6b7c93; --medium:#fbbf24; --high:#fb923c; --critical:#f87171; --ok:#34d399;
--code:#0a0e16;
}
*{box-sizing:border-box}
body{margin:0; background:radial-gradient(1200px 600px at 80% -10%, #15203a 0%, var(--bg) 55%);
background-color:#0b0f17;
color:var(--txt); font-family:"IBM Plex Sans Thai",-apple-system,"Segoe UI",sans-serif; font-size:14px}
a{color:var(--accent); text-decoration:none}
.wrap{max-width:1280px; margin:0 auto; padding:20px 18px 60px}
header{display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:10px; margin-bottom:16px}
header h1{font-size:22px; margin:0}
.links{font-size:13px; color:var(--muted)}
.links a{margin-left:12px}
.status{display:flex; gap:8px; align-items:center; font-size:12px; color:var(--muted)}
.dot{width:9px; height:9px; border-radius:50%; background:var(--critical); box-shadow:0 0 8px var(--critical)}
.dot.on{background:var(--ok); box-shadow:0 0 8px var(--ok)}
.grid{display:grid; grid-template-columns:340px 1fr; gap:16px}
@media(max-width:920px){.grid{grid-template-columns:1fr}}
.panel{border:1px solid var(--line); border-radius:14px; background:var(--panel); padding:16px}
.panel h2{font-size:14px; margin:0 0 12px; color:var(--accent); letter-spacing:.3px; text-transform:uppercase}
label{display:block; font-size:12px; color:var(--muted); margin:10px 0 4px}
input,select{width:100%; background:var(--panel2); border:1px solid var(--line); color:var(--txt);
border-radius:8px; padding:8px 10px; font-size:13px}
.row{display:flex; gap:8px}
.row > *{flex:1}
button{cursor:pointer; border:1px solid var(--line); background:#16213a; color:var(--txt);
border-radius:9px; padding:9px 12px; font-size:13px; font-weight:600; margin-top:10px; width:100%; transition:.15s}
button:hover{border-color:var(--accent); background:#1b2b4a}
button.primary{background:linear-gradient(135deg,#2563eb,#4da3ff); background-color:#2563eb; border:none; color:#fff}
button.accent{background:linear-gradient(135deg,#6d28d9,#7c5cff); background-color:#6d28d9; border:none; color:#fff}
button.danger{background:#3a1620; border-color:#5b2330; color:#fca5a5}
button:disabled{opacity:.5; cursor:not-allowed}
.hint{font-size:13px; color:var(--muted); margin-top:6px; line-height:1.5}
.counters{display:grid; grid-template-columns:repeat(4,1fr); gap:8px; margin-bottom:12px}
.counter{border:1px solid var(--line); border-radius:10px; padding:10px; text-align:center; background:var(--panel2)}
.counter .n{font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:20px; font-weight:700}
.counter .l{font-size:10px; color:var(--muted); text-transform:uppercase; letter-spacing:.4px}
.c-crit{color:var(--critical)} .c-high{color:var(--high)} .c-med{color:var(--medium)}
.c-info{color:var(--info)} .c-ok{color:var(--ok)} .c-acc{color:var(--accent)}
.feed{height:62vh; overflow-y:auto; border:1px solid var(--line); border-radius:10px; background:var(--panel2)}
.ev{padding:8px 12px; border-bottom:1px solid #16203250; border-left:3px solid var(--info); animation:flash .6s}
.ev.sev-critical{border-left-color:var(--critical)} .ev.sev-high{border-left-color:var(--high)}
.ev.sev-medium{border-left-color:var(--medium)} .ev.sev-info{border-left-color:var(--info)}
@keyframes flash{from{background:#1b2b4a}to{background:transparent}}
.ev .top{display:flex; gap:8px; align-items:center; flex-wrap:wrap; font-size:12px}
.badge{font-size:10px; padding:1px 7px; border-radius:999px; font-weight:700}
.b-critical{background:#3a1620; color:var(--critical)} .b-high{background:#3a2410; color:var(--high)}
.b-medium{background:#3a3110; color:var(--medium)} .b-info{background:#1a2435; color:var(--info)}
.b-benign{background:#0e2a1e; color:var(--ok)}
.ev .ts{color:var(--muted); font-size:13px}
.ev .msg{font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:13px; color:#c8d6ea; margin-top:3px; word-break:break-all}
.ev .meta{font-size:13px; color:var(--muted); margin-top:2px}
.miss{color:var(--high); font-size:13px}
.tabbar{display:flex; gap:6px; margin-bottom:10px; flex-wrap:wrap}
.tabbar button{width:auto; margin:0; padding:6px 12px; font-size:12px}
.tabbar button.active{border-color:var(--accent); background:#1b2b4a; color:var(--accent)}
.answer{background:var(--code); border:1px solid var(--line); border-radius:10px; padding:14px; font-size:13px; line-height:1.7; white-space:normal}
.answer h2{font-size:14px; color:var(--accent); margin:10px 0 4px; text-transform:none}
.answer code{background:#11203a; padding:1px 5px; border-radius:5px; font-size:12px}
.answer ul{margin:4px 0 4px 18px; padding:0}
.bar{height:8px; background:#16213a; border-radius:6px; overflow:hidden; margin:3px 0}
.bar > i{display:block; height:100%; background:linear-gradient(90deg,#2563eb,#7c5cff)}
.catrow{display:flex; justify-content:space-between; font-size:12px; margin-top:8px}
/* ---- Overview globe ---- */
.globe-wrap{position:relative; height:62vh; min-height:400px; display:flex; align-items:center; justify-content:center;
overflow:hidden; border-radius:14px;
background:radial-gradient(circle at 50% 42%, #101d36 0%, #0a0f1a 62%, #070a12 100%)}
#globe{width:100%; height:100%; display:block}
.globe-hero{position:absolute; left:26px; bottom:24px; pointer-events:none; max-width:64%}
.globe-hero h2{font-size:27px; margin:8px 0 6px; text-transform:none; color:#eaf1fa}
.globe-hero p{font-size:13px; color:var(--muted); margin:0; line-height:1.6}
.badge-live{display:inline-flex; align-items:center; gap:7px; font-size:13px; color:var(--ok);
border:1px solid #1c4b36; background:#0e2a1e99; padding:5px 12px; border-radius:999px; font-weight:700; letter-spacing:.5px}
.badge-live::before{content:''; width:7px; height:7px; border-radius:50%; background:var(--ok);
box-shadow:0 0 8px var(--ok); animation:pulse 1.5s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}}
.glegend{display:flex; gap:16px; margin-top:14px; flex-wrap:wrap; font-size:13px; color:var(--muted)}
.glegend span i{display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:5px; vertical-align:middle}
.globe-stat{position:absolute; top:20px; right:24px; text-align:right; pointer-events:none}
.globe-stat .big{font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:34px; font-weight:800; color:var(--critical); line-height:1; text-shadow:0 0 18px #f8717155}
.globe-stat .lbl{font-size:10px; color:var(--muted); text-transform:uppercase; letter-spacing:1px}
.toast{position:fixed; right:18px; bottom:18px; background:#16213a; border:1px solid var(--accent);
border-radius:10px; padding:12px 16px; font-size:13px; max-width:340px; box-shadow:0 8px 30px #0008; display:none}
.small{font-size:13px; color:var(--muted)}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>🛡️ ROGLAG <span class="small">Realtime Console</span></h1>
<div class="status">
<span class="dot" id="wsDot"></span><span id="wsText">connecting…</span>
<span id="wsClients"></span>
<span class="links">
<a href="/docs-html" target="_blank">Docs</a>
<a href="/plan" target="_blank">Plan</a>
<a href="/health" target="_blank">Health</a>
</span>
</div>
</header>
<div class="grid">
<!-- LEFT: controls -->
<div>
<div class="panel">
<h2>① ส่ง Mock Log (realtime)</h2>
<div class="row">
<div><label>จำนวน burst</label><input id="mockCount" type="number" value="25" min="1" max="500"></div>
<div><label>profile</label>
<select id="mockProfile"><option>mixed</option><option>attack</option><option>realistic</option></select>
</div>
</div>
<button class="primary" onclick="emitMock()">⚡ ส่ง mock burst เดี๋ยวนี้</button>
<div class="row">
<div><label>rate/วินาที</label><input id="streamRate" type="number" value="4" min="1" max="50"></div>
<div><label>ทุกๆ (วิ)</label><input id="streamInterval" type="number" value="1" min="0.2" max="10" step="0.2"></div>
</div>
<button class="accent" id="streamBtn" onclick="toggleStream()">▶ เริ่ม live stream</button>
<div class="hint">ส่ง log จำลองที่ <b>หลากหลายไม่ซ้ำ</b>เข้าระบบจริง ผ่าน pipeline เดียวกับ log จริง → เห็นใน feed ทันที</div>
</div>
<div class="panel" style="margin-top:14px">
<h2>② Generate CSV (ไม่ซ้ำทุกครั้ง)</h2>
<div class="row">
<div><label>จำนวนแถว</label><input id="genCount" type="number" value="500" min="1" max="20000"></div>
<div><label>profile</label>
<select id="genProfile"><option>mixed</option><option>attack</option><option>realistic</option></select>
</div>
</div>
<button onclick="downloadCsv()">⬇ ดาวน์โหลด CSV ใหม่</button>
<div class="hint">สุ่มใหม่ทุกครั้ง (unseeded) — เอาไปทดสอบระบบ upload ด้านล่างได้</div>
</div>
<div class="panel" style="margin-top:14px">
<h2>③ Upload CSV → ตรวจจับ realtime</h2>
<input id="csvFile" type="file" accept=".csv">
<button class="primary" onclick="uploadCsv()">🔍 อัปโหลด + ตรวจจับ</button>
<div class="hint">ระบบจะ classify ทุกแถวจาก <code>message</code> แล้ว push เข้า feed + เทียบกับ label เดิม (ถ้ามี)</div>
<div id="uploadResult" class="small" style="margin-top:8px"></div>
</div>
</div>
<!-- RIGHT: feed + tabs -->
<div class="panel">
<div class="counters">
<div class="counter"><div class="n c-acc" id="cTotal">0</div><div class="l">received</div></div>
<div class="counter"><div class="n c-crit" id="cCrit">0</div><div class="l">critical</div></div>
<div class="counter"><div class="n c-high" id="cHigh">0</div><div class="l">high</div></div>
<div class="counter"><div class="n c-med" id="cRisky">0</div><div class="l">risky</div></div>
</div>
<div class="tabbar">
<button class="active" data-tab="overview" onclick="showTab('overview')">🌍 Overview</button>
<button data-tab="feed" onclick="showTab('feed')">📡 Live Feed</button>
<button data-tab="stats" onclick="showTab('stats')">📊 Stats</button>
<button data-tab="alerts" onclick="showTab('alerts')">🚨 Alerts</button>
<button data-tab="ask" onclick="showTab('ask')">💬 Ask (RAG)</button>
<button onclick="clearFeed()" style="margin-left:auto">🧹 clear</button>
</div>
<div id="tab-overview">
<div class="globe-wrap">
<canvas id="globe"></canvas>
<div class="globe-stat"><div class="big" id="gCrit">0</div><div class="lbl">critical threats</div></div>
<div class="globe-hero">
<span class="badge-live">LIVE</span>
<h2>Realtime Threat Map</h2>
<p>เฝ้าระวัง security log แบบเรียลไทม์ — ทุก event เสี่ยงที่ตรวจจับได้จะปรากฏเป็นสัญญาณบนลูกโลกทันที<br>ส่ง mock / สตรีม / อัปโหลด CSV จากแผงด้านซ้ายเพื่อดูระบบทำงานสด</p>
<div class="glegend">
<span><i style="background:#f87171"></i>critical</span>
<span><i style="background:#fb923c"></i>high</span>
<span><i style="background:#fbbf24"></i>medium</span>
<span><i style="background:#4da3ff"></i>node</span>
</div>
</div>
</div>
</div>
<div id="tab-feed" style="display:none"><div class="feed" id="feed"></div></div>
<div id="tab-stats" style="display:none"></div>
<div id="tab-alerts" style="display:none"></div>
<div id="tab-ask" style="display:none">
<label>ถามเป็นภาษาไทย (เช่น "มี brute force จาก ip ไหน", "สรุป c2 beacon")</label>
<input id="askQ" placeholder="พิมพ์คำถาม แล้วกด Enter" onkeydown="if(event.key==='Enter')doAsk()">
<div class="row">
<label style="flex:0 0 auto"><input type="checkbox" id="useLlm" style="width:auto"> ใช้ LLM (ต้องมี API key)</label>
</div>
<button class="primary" onclick="doAsk()">ถาม</button>
<div class="answer" id="askAnswer" style="margin-top:12px">ยังไม่มีคำถาม</div>
</div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const $ = id => document.getElementById(id);
let counts = {total:0, critical:0, high:0, risky:0};
const SEV_ORDER = {critical:0, high:1, medium:2, info:3};
// ---- WebSocket ----
let ws;
function connectWs(){
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onopen = () => { $('wsDot').classList.add('on'); $('wsText').textContent='connected'; };
ws.onclose = () => { $('wsDot').classList.remove('on'); $('wsText').textContent='disconnected — retry…'; setTimeout(connectWs, 2000); };
ws.onmessage = e => handleMsg(JSON.parse(e.data));
}
function handleMsg(m){
if(m.type === 'hello'){ $('wsClients').textContent = ${m.clients} client`; return; }
if(m.type === 'event'){ addEvent(m.event); return; }
if(m.type === 'summary'){ if(m.kind==='csv') refreshStats(); toast(summaryText(m.summary)); refreshStats(); }
}
function summaryText(s){
let t = `รับ ${s.ingested} log · critical ${s.critical} · risky ${s.risky}`;
if(s.detection_accuracy!=null) t += ` · ความแม่นตรวจจับ ${s.detection_accuracy}%`;
return t;
}
// ---- Feed ----
function addEvent(ev){
counts.total++;
if(ev.severity==='critical') counts.critical++;
if(ev.severity==='high') counts.high++;
if(ev.category && ev.category!=='benign'){ counts.risky++; globe.ping(ev.severity); }
renderCounters();
const gc=$('gCrit'); if(gc) gc.textContent=counts.critical;
const feed = $('feed');
const div = document.createElement('div');
div.className = `ev sev-${ev.severity||'info'}`;
const reasons = Array.isArray(ev.reasons)? ev.reasons.join('; ') : (ev.reasons||'');
const miss = (ev.expected_category && ev.matches_label===false)
? `<span class="miss">⚠ label เดิม=${ev.expected_category}</span>` : '';
div.innerHTML = `
<div class="top">
<span class="badge b-${ev.category==='benign'?'benign':ev.severity}">${ev.label||ev.category||'?'}</span>
<span>${ev.mitre&&ev.mitre!=='-'?'<code>'+ev.mitre+'</code>':''}</span>
<span class="ts">${(ev.ts||'').replace('T',' ')}</span>
<span class="ts">src=${ev.src_ip||'-'} · ${ev.source||''}</span>
${miss}
</div>
<div class="msg">${escapeHtml(ev.message||'')}</div>
<div class="meta">risk ${ev.risk_score??'-'} · ${escapeHtml(reasons)}</div>`;
feed.insertBefore(div, feed.firstChild);
while(feed.childElementCount > 400) feed.removeChild(feed.lastChild);
}
function renderCounters(){
$('cTotal').textContent=counts.total; $('cCrit').textContent=counts.critical;
$('cHigh').textContent=counts.high; $('cRisky').textContent=counts.risky;
}
function clearFeed(){ $('feed').innerHTML=''; counts={total:0,critical:0,high:0,risky:0}; renderCounters(); }
function escapeHtml(s){ return (s||'').replace(/[&<>"]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
// ---- Actions ----
async function emitMock(){
const c=$('mockCount').value, p=$('mockProfile').value;
const r= await fetch(`/api/mock/emit?count=${c}&profile=${p}`, {method:'POST'});
const s= await r.json(); toast('ส่ง mock: '+summaryText(s));
}
let streaming=false;
async function toggleStream(){
if(!streaming){
const rate=$('streamRate').value, iv=$('streamInterval').value, p=$('mockProfile').value;
await fetch(`/api/mock/stream/start?rate=${rate}&interval=${iv}&profile=${p}`, {method:'POST'});
streaming=true; $('streamBtn').textContent='⏹ หยุด live stream'; $('streamBtn').className='danger';
} else {
await fetch('/api/mock/stream/stop', {method:'POST'});
streaming=false; $('streamBtn').textContent='▶ เริ่ม live stream'; $('streamBtn').className='accent';
}
}
function downloadCsv(){
const c=$('genCount').value, p=$('genProfile').value;
window.location = `/api/generate/csv?count=${c}&profile=${p}`;
toast('กำลังดาวน์โหลด CSV ('+c+' แถว, '+p+')');
}
async function uploadCsv(){
const f=$('csvFile').files[0];
if(!f){ toast('เลือกไฟล์ CSV ก่อน'); return; }
const fd=new FormData(); fd.append('file', f);
$('uploadResult').textContent='กำลังประมวลผล…';
const r= await fetch('/api/ingest/csv', {method:'POST', body:fd});
const s= await r.json();
if(s.error){ $('uploadResult').textContent='ผิดพลาด: '+s.error; return; }
$('uploadResult').innerHTML=`✅ ${s.ingested} แถว · critical ${s.critical}`+
(s.detection_accuracy!=null?` · แม่นยำ ${s.detection_accuracy}%`:'');
}
// ---- Tabs ----
function showTab(t){
['overview','feed','stats','alerts','ask'].forEach(x=>$('tab-'+x).style.display = x===t?'block':'none');
document.querySelectorAll('.tabbar button[data-tab]').forEach(b=>b.classList.toggle('active', b.dataset.tab===t));
if(t==='overview') globe.start(); else globe.stop(); // globe lives only on Overview
if(t==='stats') refreshStats(); if(t==='alerts') refreshAlerts();
}
async function refreshStats(){
const s= await (await fetch('/api/stats')).json();
const max=Math.max(1, ...Object.values(s.by_category||{}));
let html=`<div class="catrow small"><span>total <b>${s.total}</b></span><span>risky <b>${s.risky}</b> (${s.risk_rate}%)</span></div>`;
for(const [cat,n] of Object.entries(s.by_category||{}).sort((a,b)=>b[1]-a[1])){
html+=`<div class="catrow"><span>${cat}</span><span>${n}</span></div><div class="bar"><i style="width:${n/max*100}%"></i></div>`;
}
$('tab-stats').innerHTML=html;
}
async function refreshAlerts(){
const s= await (await fetch('/api/alerts?limit=30')).json();
if(!s.alerts.length){ $('tab-alerts').innerHTML='<div class="small">ยังไม่มี critical alert</div>'; return; }
$('tab-alerts').innerHTML=s.alerts.map(a=>`
<div class="ev sev-critical">
<div class="top"><span class="badge b-critical">${a.title}</span>
<code>${a.mitre}</code><span class="ts">×${a.count} · ${(a.last_seen||'').replace('T',' ')}</span></div>
<div class="msg">${escapeHtml(a.message||'')}</div>
<div class="meta">src=${a.src_ip} → dst=${a.dst_ip} · user=${a.user}<br>▶ ${escapeHtml(a.recommended_action||'')}</div>
</div>`).join('');
}
async function doAsk(){
const q=$('askQ').value.trim(); if(!q) return;
$('askAnswer').textContent='กำลังคิด…';
const r= await fetch('/api/ask', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({question:q, use_llm:$('useLlm').checked})});
const d= await r.json();
let note = d.mode==='openrouter'?'(LLM)':'(local)';
if(d.llm_error) note+=' · LLM error, ใช้ local';
$('askAnswer').innerHTML = md(d.answer||'') + `<div class="small" style="margin-top:8px">retrieval=${d.retrieval} ${note}</div>`;
}
function md(t){
return escapeHtml(t)
.replace(/^## (.*)$/gm,'<h2>$1</h2>')
.replace(/\*\*(.+?)\*\*/g,'<b>$1</b>')
.replace(/`([^`]+)`/g,'<code>$1</code>')
.replace(/^- (.*)$/gm,'<li>$1</li>')
.replace(/(<li>.*<\/li>)/s,'<ul>$1</ul>')
.replace(/\n/g,'<br>');
}
let toastT;
function toast(msg){ const t=$('toast'); t.textContent=msg; t.style.display='block';
clearTimeout(toastT); toastT=setTimeout(()=>t.style.display='none', 4000); }
// ---- Canvas globe (self-contained, no external libs) ----
const globe = (() => {
let cv, ctx, raf=null, t=0, W=0, H=0, DPR=1, cx=0, cy=0, R=0, sized=false;
const pts=[], pings=[];
const COL = {critical:[248,113,113], high:[251,146,60], medium:[251,191,36], info:[77,163,255]};
function genPoints(n){
pts.length=0;
const inc=Math.PI*(3-Math.sqrt(5));
for(let i=0;i<n;i++){
const y=1-(i/(n-1))*2, r=Math.sqrt(Math.max(0,1-y*y)), phi=i*inc;
pts.push({x:Math.cos(phi)*r, y:y, z:Math.sin(phi)*r,
node:Math.random()<0.06});
}
}
function resize(){
if(!cv) return;
const rect=cv.getBoundingClientRect();
if(!rect.width) return;
DPR=Math.min(window.devicePixelRatio||1, 2);
W=rect.width; H=rect.height;
cv.width=W*DPR; cv.height=H*DPR;
ctx.setTransform(DPR,0,0,DPR,0,0);
cx=W/2; cy=H/2; R=Math.min(W,H)*0.40; sized=true;
}
function project(p, cos, sin){
const x=p.x*cos - p.z*sin, z=p.x*sin + p.z*cos;
return {sx:cx+x*R, sy:cy+p.y*R, depth:(z+1)/2};
}
function frame(){
if(!sized){ resize(); }
t+=0.0022;
const cos=Math.cos(t), sin=Math.sin(t);
ctx.clearRect(0,0,W,H);
// atmosphere halo
const g=ctx.createRadialGradient(cx,cy,R*0.55, cx,cy,R*1.35);
g.addColorStop(0,'rgba(77,163,255,0.10)');
g.addColorStop(0.7,'rgba(124,92,255,0.06)');
g.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(cx,cy,R*1.35,0,7); ctx.fill();
// limb ring
ctx.strokeStyle='rgba(120,160,255,0.18)'; ctx.lineWidth=1;
ctx.beginPath(); ctx.arc(cx,cy,R,0,7); ctx.stroke();
// points
for(const p of pts){
const pr=project(p,cos,sin);
const a=0.12+pr.depth*0.78;
if(p.node){
ctx.fillStyle=`rgba(120,180,255,${a})`;
ctx.beginPath(); ctx.arc(pr.sx,pr.sy,1.1+pr.depth*1.7,0,7); ctx.fill();
} else {
ctx.fillStyle=`rgba(96,140,210,${a*0.8})`;
const s=0.5+pr.depth*1.3; ctx.fillRect(pr.sx,pr.sy,s,s);
}
}
// pings (threat pulses anchored to a rotating surface point)
for(let i=pings.length-1;i>=0;i--){
const pg=pings[i]; pg.age+=0.016;
if(pg.age>1.6){ pings.splice(i,1); continue; }
const pr=project(pg.p,cos,sin);
if(pr.depth<0.5) continue; // only on the visible hemisphere
const c=COL[pg.sev]||COL.info;
const ring=pg.age*26, al=Math.max(0,1-pg.age/1.6);
ctx.strokeStyle=`rgba(${c[0]},${c[1]},${c[2]},${al*0.9})`; ctx.lineWidth=1.6;
ctx.beginPath(); ctx.arc(pr.sx,pr.sy,ring,0,7); ctx.stroke();
ctx.fillStyle=`rgba(${c[0]},${c[1]},${c[2]},${al})`;
ctx.beginPath(); ctx.arc(pr.sx,pr.sy,2.6,0,7); ctx.fill();
}
raf=requestAnimationFrame(frame);
}
return {
start(){
if(!cv){ cv=$('globe'); if(!cv) return; ctx=cv.getContext('2d');
genPoints(820); window.addEventListener('resize', ()=>{sized=false;}); }
sized=false;
if(!raf) raf=requestAnimationFrame(frame);
},
stop(){ if(raf){ cancelAnimationFrame(raf); raf=null; } },
ping(sev){
if(pts.length===0) return;
const base=pts[(Math.random()*pts.length)|0];
pings.push({p:{x:base.x,y:base.y,z:base.z}, sev:sev||'info', age:0});
if(pings.length>60) pings.shift();
},
};
})();
// ambient activity so the map looks alive before data arrives
setInterval(()=>{ if($('tab-overview').style.display!=='none' && counts.total===0)
globe.ping(['info','medium','high'][(Math.random()*3)|0]); }, 1300);
connectWs(); renderCounters(); showTab('overview');
</script>
</body>
</html>