Download web/live.html from Oppabank/roglag: direct link, hf CLI and curl.
- Browser
- Download file 24.6 kB
-
https://huggingface.co/spaces/Oppabank/roglag/resolve/main/web/live.html
- Command line
-
hf download hf://spaces/Oppabank/roglag/web/live.html
-
curl -L -o live.html https://huggingface.co/spaces/Oppabank/roglag/resolve/main/web/live.html
24.6 kB
| <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=>({'&':'&','<':'<','>':'>','"':'"'}[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> | |