| <!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"> |
| |
| <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> |
|
|
| |
| <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}; |
| |
| |
| 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; |
| } |
| |
| |
| 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])); } |
| |
| |
| 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}%`:''); |
| } |
| |
| |
| 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(); |
| 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); } |
| |
| |
| 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); |
| |
| |
| 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(); |
| |
| ctx.strokeStyle='rgba(120,160,255,0.18)'; ctx.lineWidth=1; |
| ctx.beginPath(); ctx.arc(cx,cy,R,0,7); ctx.stroke(); |
| |
| |
| 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); |
| } |
| } |
| |
| |
| 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; |
| 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(); |
| }, |
| }; |
| })(); |
| |
| |
| 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> |
|
|