From e0827b3938c311dd1b5134fcbc5de76bfe2dfce8 Mon Sep 17 00:00:00 2001 From: Aleksei Fedorov Date: Sun, 17 May 2026 07:48:16 +0000 Subject: [PATCH] security scan process + files subsystem --- index.html | 2 +- kernel_ai/services/process_timeline.py | 340 ++++++++-- requirements.txt | 2 + static/js/filesystem-map.js | 427 ++++++++++++- static/js/network-stack.js | 826 +++++++++++++++++++++++- static/js/security-belt.js | 836 +++++++++++++++++++++++-- 6 files changed, 2304 insertions(+), 129 deletions(-) diff --git a/index.html b/index.html index 994a1c8..a32b6bd 100755 --- a/index.html +++ b/index.html @@ -97,7 +97,7 @@

Linux Kernel Ring 0 Visualization

- + diff --git a/kernel_ai/services/process_timeline.py b/kernel_ai/services/process_timeline.py index 05c3f54..39a37ba 100644 --- a/kernel_ai/services/process_timeline.py +++ b/kernel_ai/services/process_timeline.py @@ -2,13 +2,191 @@ from __future__ import annotations -import os from datetime import datetime import psutil -def get_proc_timeline_data(pid): +def _clamp_window_s(window_s: int | float | None) -> int: + try: + value = int(window_s or 30) + except (TypeError, ValueError): + value = 30 + return max(5, min(120, value)) + + +def _stamp_events_with_time_window(ordered_events: list[dict], proc_create_time: float, window_s: int) -> list[dict]: + now_ts = datetime.now().timestamp() + proc_age_s = max(0.1, now_ts - float(proc_create_time or now_ts)) + span_s = min(float(window_s), proc_age_s) + + if not ordered_events: + return [] + if len(ordered_events) == 1: + row = dict(ordered_events[0]) + event_ts = now_ts + row["timestamp"] = datetime.fromtimestamp(event_ts).isoformat() + row["relative_s"] = round(0.0, 3) + return [row] + + start_ts = now_ts - span_s + timeline = [] + count = len(ordered_events) + for i, ev in enumerate(ordered_events): + ratio = i / (count - 1) + event_ts = start_ts + ratio * span_s + row = dict(ev) + row["timestamp"] = datetime.fromtimestamp(event_ts).isoformat() + row["relative_s"] = round(event_ts - start_ts, 3) + timeline.append(row) + return timeline + + +def _safe_read_text(path: str) -> str: + try: + with open(path, "r", encoding="utf-8", errors="ignore") as f: + return f.read() + except (OSError, PermissionError): + return "" + + +def _read_proc_io_events(pid: int) -> list[dict]: + events = [] + content = _safe_read_text(f"/proc/{pid}/io") + if not content: + return events + data = {} + for line in content.splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + value = value.strip() + if value.isdigit(): + data[key.strip()] = int(value) + read_bytes = int(data.get("read_bytes", 0) or 0) + write_bytes = int(data.get("write_bytes", 0) or 0) + if read_bytes > 0: + events.append({"type": "i/o", "name": "read_bytes", "bytes": read_bytes}) + if write_bytes > 0: + events.append({"type": "i/o", "name": "write_bytes", "bytes": write_bytes}) + return events + + +def _read_proc_syscall_event(pid: int) -> dict | None: + content = _safe_read_text(f"/proc/{pid}/syscall").strip() + if not content or content in {"-1", "running"}: + return None + parts = content.split() + if not parts: + return None + try: + num = int(parts[0]) + except ValueError: + return None + if num <= 0: + return None + return {"type": "syscall", "name": f"syscall_{num}", "count": 1} + + +def _read_context_switch_event(pid: int) -> dict | None: + content = _safe_read_text(f"/proc/{pid}/status") + if not content: + return None + vol = 0 + nonvol = 0 + for line in content.splitlines(): + if line.startswith("voluntary_ctxt_switches:"): + rhs = line.split(":", 1)[1].strip() + vol = int(rhs) if rhs.isdigit() else 0 + elif line.startswith("nonvoluntary_ctxt_switches:"): + rhs = line.split(":", 1)[1].strip() + nonvol = int(rhs) if rhs.isdigit() else 0 + total = vol + nonvol + if total <= 0: + return None + return { + "type": "context switch", + "name": "context_switches", + "count": total, + "voluntary": vol, + "nonvoluntary": nonvol, + } + + +def _read_network_packet_event(pid: int) -> dict | None: + content = _safe_read_text(f"/proc/{pid}/net/dev") + if not content: + return None + rx_packets = 0 + tx_packets = 0 + for line in content.splitlines()[2:]: + if ":" not in line: + continue + _, rhs = line.split(":", 1) + cols = rhs.split() + if len(cols) < 10: + continue + try: + rx_packets += int(cols[1]) + tx_packets += int(cols[9]) + except ValueError: + continue + total_packets = rx_packets + tx_packets + if total_packets <= 0: + return None + return { + "type": "network packet", + "name": "net_packets", + "count": total_packets, + "rx_packets": rx_packets, + "tx_packets": tx_packets, + } + + +def _read_interrupt_event() -> dict | None: + content = _safe_read_text("/proc/interrupts") + if not content: + return None + total = 0 + for line in content.splitlines()[1:]: + if ":" not in line: + continue + _, rhs = line.split(":", 1) + for token in rhs.split(): + if token.isdigit(): + total += int(token) + if total <= 0: + return None + return {"type": "interrupt", "name": "interrupt_total", "count": total} + + +def _read_scheduler_tick_event() -> dict | None: + content = _safe_read_text("/proc/stat") + if not content: + return None + for line in content.splitlines(): + if line.startswith("ctxt "): + parts = line.split() + if len(parts) >= 2 and parts[1].isdigit(): + return {"type": "scheduler tick", "name": "ctxt", "count": int(parts[1])} + return None + + +def _read_lock_unlock_event(pid: int) -> dict | None: + content = _safe_read_text("/proc/locks") + if not content: + return None + pid_str = f" {pid} " + lock_count = 0 + for line in content.splitlines(): + if pid_str in f" {line} ": + lock_count += 1 + if lock_count <= 0: + return None + return {"type": "lock/unlock", "name": "proc_locks", "count": lock_count} + + +def get_proc_timeline_data(pid, window_s: int = 30): """Build timeline-like process events from /proc snapshots.""" if not pid: raise ValueError("PID parameter required") @@ -20,62 +198,122 @@ def get_proc_timeline_data(pid): proc_info = proc.as_dict(["pid", "name", "create_time", "status"]) base_ts = float(proc_info["create_time"]) - ordered_events = [{"type": "exec", "pid": pid}] + window_s = _clamp_window_s(window_s) + ordered_events = [{"type": "exec", "name": "exec", "pid": pid}] - try: - maps_path = f"/proc/{pid}/maps" - if os.path.exists(maps_path): - with open(maps_path, "r", encoding="utf-8", errors="ignore") as f: - map_count = len(f.readlines()) - if map_count > 0: - ordered_events.append({"type": "mmap", "pid": pid, "count": map_count}) - except (IOError, PermissionError): - pass + syscall_ev = _read_proc_syscall_event(pid) + if syscall_ev: + syscall_ev["pid"] = pid + ordered_events.append(syscall_ev) - try: - io_path = f"/proc/{pid}/io" - if os.path.exists(io_path): - with open(io_path, "r", encoding="utf-8", errors="ignore") as f: - io_data = {} - for line in f: - if ":" in line: - key, value = line.split(":", 1) - io_data[key.strip()] = int(value.strip()) - if io_data.get("read_bytes", 0) > 0: - ordered_events.append({"type": "read", "pid": pid, "bytes": io_data.get("read_bytes", 0)}) - if io_data.get("write_bytes", 0) > 0: - ordered_events.append({"type": "write", "pid": pid, "bytes": io_data.get("write_bytes", 0)}) - except (IOError, PermissionError): - pass + ctx_ev = _read_context_switch_event(pid) + if ctx_ev: + ctx_ev["pid"] = pid + ordered_events.append(ctx_ev) - try: - tcp_path = f"/proc/{pid}/net/tcp" - if os.path.exists(tcp_path): - with open(tcp_path, "r", encoding="utf-8", errors="ignore") as f: - lines = f.readlines() - if len(lines) > 1: - for line in lines[1:]: - parts = line.split() - if len(parts) >= 4: - state = parts[3] - if state == "01": - ordered_events.append({"type": "connect", "pid": pid}) - elif state == "0A": - ordered_events.append({"type": "accept", "pid": pid}) - except (IOError, PermissionError): - pass - - step = 0.35 - timeline = [] - for i, ev in enumerate(ordered_events): - row = dict(ev) - row["timestamp"] = datetime.fromtimestamp(base_ts + i * step).isoformat() - timeline.append(row) + irq_ev = _read_interrupt_event() + if irq_ev: + irq_ev["pid"] = pid + ordered_events.append(irq_ev) + + sched_ev = _read_scheduler_tick_event() + if sched_ev: + sched_ev["pid"] = pid + ordered_events.append(sched_ev) + + for io_ev in _read_proc_io_events(pid): + io_ev["pid"] = pid + ordered_events.append(io_ev) + + net_ev = _read_network_packet_event(pid) + if net_ev: + net_ev["pid"] = pid + ordered_events.append(net_ev) + + lock_ev = _read_lock_unlock_event(pid) + if lock_ev: + lock_ev["pid"] = pid + ordered_events.append(lock_ev) + + # Keep this event for backward compatibility with existing clients. + if proc_info.get("status"): + ordered_events.append({"type": "syscall", "name": "process_status", "pid": pid, "status": proc_info["status"]}) + + timeline = _stamp_events_with_time_window(ordered_events, base_ts, window_s) return { "timeline": timeline, "pid": pid, "name": proc_info.get("name", "unknown"), "timestamp": datetime.now().isoformat(), - "timeline_time_basis": "Events are ordered from process start; 0.35s steps separate rows for the helix (kernel does not expose per-event wall times for these signals).", + "window_s": window_s, + "timeline_time_basis": "Events are sampled from /proc and distributed over selected window as a relative timeline (not exact per-event kernel timestamps).", + } + + +def get_proc_timeline_branches_data(limit: int = 6, events: int = 10, window_s: int = 30): + """Build process-branch timeline payload for multi-branch visualization.""" + limit = max(1, min(12, int(limit))) + events = max(3, min(24, int(events))) + window_s = _clamp_window_s(window_s) + + candidates = [] + for proc in psutil.process_iter(["pid", "name", "cpu_percent", "memory_info"]): + try: + pid = int(proc.info.get("pid") or 0) + if pid <= 1: + continue + name = str(proc.info.get("name") or "").strip() + if not name: + continue + cpu = float(proc.info.get("cpu_percent") or 0.0) + mem_info = proc.info.get("memory_info") + mem_mb = float(getattr(mem_info, "rss", 0) or 0) / (1024 * 1024) + score = cpu * 2.0 + (mem_mb / 256.0) + candidates.append( + { + "pid": pid, + "name": name, + "cpu_percent": round(cpu, 2), + "memory_mb": round(mem_mb, 1), + "score": score, + } + ) + except (psutil.Error, OSError, TypeError, ValueError): + continue + + candidates.sort(key=lambda row: row.get("score", 0.0), reverse=True) + branches = [] + for candidate in candidates[: max(limit * 4, limit)]: + if len(branches) >= limit: + break + pid = int(candidate["pid"]) + try: + timeline_data = get_proc_timeline_data(pid, window_s=window_s) + except (ProcessLookupError, ValueError, psutil.Error, OSError): + continue + timeline = list(timeline_data.get("timeline") or [])[:events] + if not timeline: + continue + branches.append( + { + "pid": pid, + "name": str(timeline_data.get("name") or candidate["name"]), + "cpu_percent": float(candidate.get("cpu_percent") or 0.0), + "memory_mb": float(candidate.get("memory_mb") or 0.0), + "timeline": timeline, + "event_count": len(timeline), + } + ) + + return { + "branches": branches, + "timestamp": datetime.now().isoformat(), + "meta": { + "limit": limit, + "events_per_branch": events, + "window_s": window_s, + "branch_count": len(branches), + "mode": "multi-branch-v1", + }, } diff --git a/requirements.txt b/requirements.txt index fa2b8e5..81c01db 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,7 @@ Flask==3.1.2 +prometheus-client==0.21.1 psutil==7.0.0 openai==0.28.1 python-dotenv==1.0.0 gunicorn==21.2.0 +sentry-sdk diff --git a/static/js/filesystem-map.js b/static/js/filesystem-map.js index aad4fbb..daaba5b 100644 --- a/static/js/filesystem-map.js +++ b/static/js/filesystem-map.js @@ -21,6 +21,14 @@ class FilesystemMapVisualization { this.legendNode = null; this.orbHitArea = null; this.canvasClickHandler = null; + this.canvasMouseMoveHandler = null; + this.zoneHitAreas = []; + this.selectedZoneId = null; + this.hoveredZoneId = null; + this.zoneSortMode = 'risk'; + this.sortModeButton = null; + this.archMapMode = 'simple'; + this.archModeButton = null; } init(containerId = 'filesystem-map-container') { @@ -55,7 +63,9 @@ class FilesystemMapVisualization { this.container.appendChild(this.canvas); this.ctx = this.canvas.getContext('2d'); this.canvasClickHandler = (event) => this.onCanvasClick(event); + this.canvasMouseMoveHandler = (event) => this.onCanvasMouseMove(event); this.canvas.addEventListener('click', this.canvasClickHandler); + this.canvas.addEventListener('mousemove', this.canvasMouseMoveHandler); this.createOverlayUI(); this.addExitButton(); @@ -135,6 +145,50 @@ class FilesystemMapVisualization { this.overlayNodes.push(modePanel); this.setRenderMode(this.renderMode); + const sortBtn = document.createElement('button'); + sortBtn.textContent = 'SORT: RISK'; + sortBtn.style.cssText = ` + position: absolute; + top: 72px; + left: 22px; + padding: 4px 8px; + background: rgba(12, 18, 28, 0.9); + border: 1px solid rgba(130, 148, 172, 0.35); + color: #9fb0c8; + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + letter-spacing: 0.3px; + cursor: pointer; + z-index: 1001; + `; + sortBtn.onclick = () => this.cycleZoneSortMode(); + this.container.appendChild(sortBtn); + this.overlayNodes.push(sortBtn); + this.sortModeButton = sortBtn; + this.updateSortModeButtonState(); + + const archBtn = document.createElement('button'); + archBtn.textContent = 'ARCH: SIMPLE'; + archBtn.style.cssText = ` + position: absolute; + top: 72px; + left: 136px; + padding: 4px 8px; + background: rgba(12, 18, 28, 0.9); + border: 1px solid rgba(130, 148, 172, 0.35); + color: #9fb0c8; + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + letter-spacing: 0.3px; + cursor: pointer; + z-index: 1001; + `; + archBtn.onclick = () => this.cycleArchMapMode(); + this.container.appendChild(archBtn); + this.overlayNodes.push(archBtn); + this.archModeButton = archBtn; + this.updateArchMapModeButtonState(); + const info = document.createElement('div'); info.style.cssText = ` position: absolute; @@ -236,7 +290,7 @@ class FilesystemMapVisualization { ctx.closePath(); } - drawPackageStack(x, y, w, h, layers, accentColor, label, metaText) { + drawPackageStack(x, y, w, h, layers, accentColor, label, metaText, subMetaText, isSelected, isHovered) { const ctx = this.ctx; const stackLayers = Math.max(2, Math.min(4, layers)); for (let i = stackLayers - 1; i >= 0; i -= 1) { @@ -245,16 +299,23 @@ class FilesystemMapVisualization { this.drawRoundedRect(x + dx, y + dy, w, h, 5); ctx.fillStyle = i === 0 ? 'rgba(14, 24, 36, 0.95)' : 'rgba(10, 16, 26, 0.85)'; ctx.fill(); - ctx.strokeStyle = i === 0 ? accentColor : 'rgba(72, 94, 124, 0.35)'; - ctx.lineWidth = i === 0 ? 1.0 : 0.7; + const baseStroke = i === 0 ? accentColor : 'rgba(72, 94, 124, 0.35)'; + ctx.strokeStyle = isSelected ? 'rgba(149, 207, 255, 0.95)' : (isHovered ? 'rgba(122, 182, 245, 0.82)' : baseStroke); + ctx.lineWidth = isSelected ? 1.4 : (i === 0 ? 1.0 : 0.7); ctx.stroke(); } - ctx.fillStyle = '#9fc0e8'; + ctx.fillStyle = isSelected ? '#d9ecff' : '#9fc0e8'; ctx.font = '9px "Share Tech Mono", monospace'; ctx.fillText(label, x + 8, y + 13); - ctx.fillStyle = 'rgba(122, 141, 167, 0.85)'; + ctx.fillStyle = isSelected ? 'rgba(190, 214, 241, 0.92)' : 'rgba(122, 141, 167, 0.85)'; ctx.font = '8px "Share Tech Mono", monospace'; ctx.fillText(metaText, x + 8, y + 24); + if (subMetaText) { + ctx.fillStyle = 'rgba(132, 156, 186, 0.76)'; + ctx.fillText(subMetaText, x + 8, y + 34); + } + ctx.fillStyle = accentColor; + ctx.fillRect(x + 2, y + h - 3, Math.max(8, w - 4), 2); } drawParticleOrb(x, y, radius, meta) { @@ -296,6 +357,166 @@ class FilesystemMapVisualization { // Minimal version: no inner streaks and no labels. } + drawFsArchitectureMap(x, y, w, h, meta, zones) { + const ctx = this.ctx; + const mode = this.archMapMode || 'simple'; + const usedPercent = Math.max(0, Math.min(100, Number(meta?.used_percent || 0))); + const inodePressure = Math.max(0, Math.min(100, Number(meta?.inode_pressure || 0))); + const writingBlocks = Math.max(0, Number(meta?.writing_blocks || 0)); + const writeBps = Math.max(0, Number(meta?.write_bps || 0)); + const writeMBs = writeBps / (1024 * 1024); + const zoneList = Array.isArray(zones) ? zones : []; + const activeZones = zoneList.filter((z) => Number(z?.activity || 0) > 8 || Number(z?.writing_blocks || 0) > 0).length; + const hotZones = zoneList.filter((z) => Math.max(Number(z?.used_percent || 0), Number(z?.inode_pressure || 0)) >= 80).length; + + this.drawRoundedRect(x, y, w, h, 9); + ctx.fillStyle = 'rgba(8, 13, 20, 0.78)'; + ctx.fill(); + ctx.strokeStyle = 'rgba(122, 147, 179, 0.34)'; + ctx.lineWidth = 0.95; + ctx.stroke(); + + ctx.fillStyle = '#cde3ff'; + ctx.font = '10px "Share Tech Mono", monospace'; + ctx.fillText('LINUX FS ARCH MAP', x + 12, y + 18); + ctx.fillStyle = 'rgba(151, 173, 203, 0.86)'; + ctx.font = '8px "Share Tech Mono", monospace'; + ctx.fillText(`active zones ${activeZones} | hot zones ${hotZones} | write ${writeMBs.toFixed(1)} MB/s`, x + 12, y + 32); + ctx.fillText(`mode ${String(mode).toUpperCase()}`, x + w - 102, y + 18); + + const node = (nx, ny, nw, nh, title, sub, tone, small = false) => { + this.drawRoundedRect(nx, ny, nw, nh, small ? 4 : 5); + ctx.fillStyle = 'rgba(10, 16, 24, 0.82)'; + ctx.fill(); + ctx.strokeStyle = tone; + ctx.lineWidth = small ? 0.85 : 0.95; + ctx.stroke(); + ctx.fillStyle = '#cfe4ff'; + ctx.font = small ? '8px "Share Tech Mono", monospace' : '9px "Share Tech Mono", monospace'; + ctx.fillText(title, nx + 6, ny + (small ? 11 : 13)); + if (sub) { + ctx.fillStyle = 'rgba(146, 170, 201, 0.88)'; + ctx.font = '8px "Share Tech Mono", monospace'; + ctx.fillText(sub, nx + 6, ny + (small ? 21 : 24)); + } + }; + const link = (x0, y0, x1, y1, tone, weight = 1) => { + ctx.beginPath(); + ctx.moveTo(x0, y0); + ctx.bezierCurveTo((x0 + x1) / 2, y0, (x0 + x1) / 2, y1, x1, y1); + ctx.strokeStyle = tone; + ctx.lineWidth = weight; + ctx.stroke(); + }; + + const rootX = x + 168; + const rootY = y + 98; + const syscallX = x + 12; + const driverX = x + w - 126; + const blockX = x + 152; + const ioX = blockX + 122; + const devX = ioX + 102; + const writeTone = writeMBs >= 80 ? 'rgba(255, 154, 118, 0.86)' : 'rgba(142, 227, 255, 0.78)'; + + node(syscallX, rootY - 16, 92, 34, 'SYSCALLS', `rw ${writeMBs.toFixed(1)} MB/s`, 'rgba(142, 206, 255, 0.85)'); + node(rootX, rootY - 22, 110, 44, 'VFS CORE', `used ${usedPercent.toFixed(1)}%`, 'rgba(132, 194, 255, 0.95)'); + node(driverX, rootY - 48, 104, 30, 'EXT4/XFS', `${activeZones} active`, 'rgba(145, 226, 255, 0.88)', true); + node(driverX, rootY - 12, 104, 30, 'TMPFS/PROC', `inode ${inodePressure.toFixed(0)}%`, 'rgba(148, 198, 255, 0.84)', true); + node(driverX, rootY + 24, 104, 30, 'OVERLAY/DM', `${writingBlocks} writing`, writingBlocks > 0 ? 'rgba(255, 164, 170, 0.86)' : 'rgba(142, 186, 230, 0.72)', true); + node(blockX, y + h - 58, 102, 32, 'PAGECACHE', `dirty ${writingBlocks}`, writeTone, true); + node(ioX, y + h - 58, 94, 32, 'BLOCK IO', `inode ${inodePressure.toFixed(0)}%`, 'rgba(132, 194, 255, 0.84)', true); + node(devX, y + h - 58, 86, 32, 'NVME', `util ${usedPercent.toFixed(0)}%`, usedPercent > 86 ? 'rgba(255, 146, 156, 0.88)' : 'rgba(143, 217, 252, 0.85)', true); + + const flowWeight = 0.9 + Math.min(1.8, writeMBs / 70); + link(syscallX + 92, rootY + 1, rootX, rootY + 1, 'rgba(124, 181, 234, 0.65)', 1.1); + link(rootX + 110, rootY - 2, driverX, rootY - 33, 'rgba(130, 199, 245, 0.52)', 0.9); + link(rootX + 110, rootY + 4, driverX, rootY + 3, 'rgba(130, 199, 245, 0.52)', 0.9); + link(rootX + 110, rootY + 10, driverX, rootY + 39, 'rgba(130, 199, 245, 0.52)', 0.9); + link(rootX + 56, rootY + 22, blockX + 50, y + h - 58, writeTone, flowWeight); + link(blockX + 102, y + h - 42, ioX, y + h - 42, 'rgba(134, 196, 243, 0.75)', flowWeight * 0.9); + link(ioX + 94, y + h - 42, devX, y + h - 42, usedPercent > 86 ? 'rgba(255, 152, 162, 0.88)' : 'rgba(142, 217, 252, 0.8)', flowWeight * 0.8); + + if (mode === 'detailed') { + const dentryX = rootX - 4; + const dentryY = y + 48; + const journalX = driverX - 12; + const journalY = y + h - 94; + const wbX = blockX - 4; + const wbY = y + h - 94; + const schedX = ioX + 2; + const schedY = y + h - 94; + + node(dentryX, dentryY, 88, 26, 'DENTRY', `${Math.max(55, 99 - inodePressure * 0.5).toFixed(0)}% hit`, 'rgba(137, 206, 255, 0.78)', true); + node(dentryX + 92, dentryY, 88, 26, 'INODE', `${Math.max(48, 98 - usedPercent * 0.48).toFixed(0)}% hit`, 'rgba(132, 194, 255, 0.78)', true); + node(journalX, journalY, 94, 26, 'JOURNAL', `${writingBlocks} pend`, writingBlocks > 0 ? 'rgba(255, 167, 136, 0.85)' : 'rgba(132, 180, 220, 0.72)', true); + node(wbX, wbY, 86, 26, 'WRITEBACK', `${(writeMBs * 0.72).toFixed(1)} MB/s`, writeTone, true); + node(schedX, schedY, 82, 26, 'IO SCHED', inodePressure > 72 ? 'congested' : 'normal', inodePressure > 72 ? 'rgba(255, 158, 166, 0.86)' : 'rgba(138, 198, 244, 0.8)', true); + + link(rootX + 60, rootY - 22, dentryX + 20, dentryY + 13, 'rgba(130, 197, 246, 0.55)', 0.9); + link(rootX + 84, rootY - 22, dentryX + 110, dentryY + 13, 'rgba(130, 197, 246, 0.55)', 0.9); + link(driverX + 52, rootY + 39, journalX + 40, journalY + 13, 'rgba(214, 148, 137, 0.46)', 0.9); + link(journalX + 94, journalY + 13, wbX, wbY + 13, 'rgba(182, 156, 132, 0.45)', 0.8); + link(wbX + 86, wbY + 13, schedX, schedY + 13, 'rgba(132, 194, 247, 0.62)', 0.85); + } + } + + drawLiveBlockMatrix(x, y, w, h, blocks, zones, meta) { + const ctx = this.ctx; + const zoneMap = new Map((Array.isArray(zones) ? zones : []).map((z) => [String(z.id || ''), z])); + const data = Array.isArray(blocks) ? blocks : []; + if (!data.length) return; + + this.drawRoundedRect(x, y, w, h, 7); + ctx.fillStyle = 'rgba(8, 13, 21, 0.72)'; + ctx.fill(); + ctx.strokeStyle = 'rgba(122, 146, 178, 0.28)'; + ctx.lineWidth = 0.85; + ctx.stroke(); + + const cols = 64; + const rows = 20; + const innerX = x + 10; + const innerY = y + 12; + const innerW = w - 20; + const innerH = h - 26; + const cellW = innerW / cols; + const cellH = innerH / rows; + const inodePressure = Math.max(0, Math.min(100, Number(meta?.inode_pressure || 0))); + const writeHot = Number(meta?.write_bps || 0) / (1024 * 1024); + + for (let i = 0; i < rows * cols; i += 1) { + const b = data[i % data.length] || {}; + const zone = zoneMap.get(String(b.zone_id || '')) || {}; + const zx = i % cols; + const zy = Math.floor(i / cols); + const px = innerX + zx * cellW; + const py = innerY + zy * cellH; + const zoneAct = Math.max(0, Math.min(100, Number(zone.activity || 0))); + const zInode = Math.max(0, Math.min(100, Number(zone.inode_pressure || 0))); + const flicker = 0.75 + 0.25 * Math.sin(this.tick * 0.05 + zx * 0.17 + zy * 0.23); + + if (b.state === 'writing') { + const alpha = 0.46 + Math.min(0.5, (zoneAct / 130) + (writeHot > 90 ? 0.18 : 0)) * flicker; + ctx.fillStyle = `rgba(163, 238, 255, ${Math.min(0.95, alpha).toFixed(3)})`; + } else if (b.state === 'used') { + const alpha = 0.22 + Math.min(0.5, ((zoneAct + inodePressure * 0.35) / 220)) * flicker; + ctx.fillStyle = zInode >= 78 + ? `rgba(255, 184, 132, ${Math.min(0.8, alpha + 0.08).toFixed(3)})` + : `rgba(96, 166, 220, ${Math.min(0.8, alpha).toFixed(3)})`; + } else { + ctx.fillStyle = `rgba(17, 30, 46, ${(0.24 + 0.08 * flicker).toFixed(3)})`; + } + + const cw = Math.max(1.5, cellW - 1.15); + const ch = Math.max(1.5, cellH - 1.1); + ctx.fillRect(px, py, cw, ch); + } + + ctx.fillStyle = 'rgba(166, 193, 225, 0.84)'; + ctx.font = '8px "Share Tech Mono", monospace'; + ctx.fillText('live block matrix: write glow / inode pressure / occupancy', x + 12, y + h - 8); + } + setRenderMode(modeKey) { this.renderMode = ['occupancy', 'write', 'inode'].includes(modeKey) ? modeKey : 'occupancy'; this.modeButtons.forEach((btn, key) => { @@ -322,11 +543,82 @@ class FilesystemMapVisualization { this.setRenderMode(next); } + getZoneSortScore(zone, mode) { + const used = Number(zone?.used_percent || 0); + const inode = Number(zone?.inode_pressure || 0); + const activity = Number(zone?.activity || 0); + const writing = Number(zone?.writing_blocks || 0); + if (mode === 'write') { + return writing * 100 + activity * 4 + used * 0.2; + } + if (mode === 'inode') { + return inode * 10 + used * 0.6 + activity * 0.5; + } + return Math.max(used, inode, writing > 0 ? 86 : 0) * 10 + activity; + } + + cycleZoneSortMode() { + const order = ['risk', 'write', 'inode']; + const idx = order.indexOf(this.zoneSortMode); + const next = order[(idx + 1) % order.length]; + this.zoneSortMode = next; + this.updateSortModeButtonState(); + } + + updateSortModeButtonState() { + if (!this.sortModeButton) return; + const mode = this.zoneSortMode || 'risk'; + const label = mode === 'write' ? 'WRITE' : (mode === 'inode' ? 'INODE' : 'RISK'); + this.sortModeButton.textContent = `SORT: ${label}`; + if (mode === 'write') { + this.sortModeButton.style.background = 'rgba(45, 57, 30, 0.92)'; + this.sortModeButton.style.borderColor = 'rgba(168, 230, 140, 0.85)'; + this.sortModeButton.style.color = '#e4ffd3'; + } else if (mode === 'inode') { + this.sortModeButton.style.background = 'rgba(29, 43, 62, 0.92)'; + this.sortModeButton.style.borderColor = 'rgba(127, 194, 255, 0.88)'; + this.sortModeButton.style.color = '#dbf0ff'; + } else { + this.sortModeButton.style.background = 'rgba(62, 36, 36, 0.92)'; + this.sortModeButton.style.borderColor = 'rgba(255, 162, 170, 0.88)'; + this.sortModeButton.style.color = '#ffe1e5'; + } + } + + cycleArchMapMode() { + this.archMapMode = this.archMapMode === 'detailed' ? 'simple' : 'detailed'; + this.updateArchMapModeButtonState(); + } + + updateArchMapModeButtonState() { + if (!this.archModeButton) return; + const detailed = this.archMapMode === 'detailed'; + this.archModeButton.textContent = detailed ? 'ARCH: DETAILED' : 'ARCH: SIMPLE'; + this.archModeButton.style.background = detailed ? 'rgba(28, 45, 64, 0.92)' : 'rgba(12, 18, 28, 0.9)'; + this.archModeButton.style.borderColor = detailed ? 'rgba(127, 194, 255, 0.88)' : 'rgba(130, 148, 172, 0.35)'; + this.archModeButton.style.color = detailed ? '#e0f1ff' : '#9fb0c8'; + } + + getZoneHitAt(x, y) { + for (const hit of this.zoneHitAreas) { + if (x >= hit.x && x <= hit.x + hit.w && y >= hit.y && y <= hit.y + hit.h) { + return hit; + } + } + return null; + } + onCanvasClick(event) { - if (!this.orbHitArea || !this.canvas) return; + if (!this.canvas) return; const rect = this.canvas.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; + const zoneHit = this.getZoneHitAt(x, y); + if (zoneHit) { + this.selectedZoneId = this.selectedZoneId === zoneHit.zoneId ? null : zoneHit.zoneId; + return; + } + if (!this.orbHitArea) return; const dx = x - this.orbHitArea.x; const dy = y - this.orbHitArea.y; const dist = Math.sqrt(dx * dx + dy * dy); @@ -335,6 +627,17 @@ class FilesystemMapVisualization { } } + onCanvasMouseMove(event) { + if (!this.canvas) return; + const rect = this.canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + const zoneHit = this.getZoneHitAt(x, y); + this.hoveredZoneId = zoneHit ? zoneHit.zoneId : null; + const overOrb = this.orbHitArea && Math.hypot(x - this.orbHitArea.x, y - this.orbHitArea.y) <= this.orbHitArea.r * 1.25; + this.canvas.style.cursor = zoneHit || overOrb ? 'pointer' : 'default'; + } + drawScene() { if (!this.ctx || !this.canvas) return; const w = window.innerWidth; @@ -352,6 +655,7 @@ class FilesystemMapVisualization { const blocks = this.telemetry.blocks; const zones = Array.isArray(this.telemetry.zones) ? this.telemetry.zones : []; const zoneMap = new Map(zones.map((z) => [String(z.id || ''), z])); + this.zoneHitAreas = []; const cx = Math.floor(w * 0.53); const cy = Math.floor(h * 0.63); @@ -359,6 +663,41 @@ class FilesystemMapVisualization { const innerR = Math.max(24, Math.floor(outerR * 0.14)); const ringGap = (outerR - innerR) / Math.max(1, rows); const angleStep = (Math.PI * 2) / Math.max(1, cols); + const m = this.telemetry.meta || {}; + + const kpis = [ + { + label: 'USED', + value: `${Number(m.used_percent || 0).toFixed(1)}%`, + tone: Number(m.used_percent || 0) >= 85 ? '#ff9ca6' : '#8fd8ff' + }, + { + label: 'WRITE', + value: `${(Number(m.write_bps || 0) / (1024 * 1024)).toFixed(1)} MB/s`, + tone: Number(m.write_bps || 0) >= (80 * 1024 * 1024) ? '#ffd08d' : '#8ff3c0' + }, + { + label: 'INODE', + value: `${Number(m.inode_pressure || 0).toFixed(1)}%`, + tone: Number(m.inode_pressure || 0) >= 75 ? '#ff9ca6' : '#8fd8ff' + } + ]; + kpis.forEach((kpi, idx) => { + const kx = 26 + idx * 152; + const ky = 98; + this.drawRoundedRect(kx, ky, 138, 44, 6); + this.ctx.fillStyle = 'rgba(10, 16, 24, 0.74)'; + this.ctx.fill(); + this.ctx.strokeStyle = 'rgba(122, 142, 168, 0.32)'; + this.ctx.lineWidth = 0.9; + this.ctx.stroke(); + this.ctx.fillStyle = 'rgba(159, 177, 201, 0.84)'; + this.ctx.font = '8px "Share Tech Mono", monospace'; + this.ctx.fillText(kpi.label, kx + 10, ky + 14); + this.ctx.fillStyle = kpi.tone; + this.ctx.font = '12px "Share Tech Mono", monospace'; + this.ctx.fillText(kpi.value, kx + 10, ky + 31); + }); // Background circuit lines. this.ctx.strokeStyle = 'rgba(150, 38, 64, 0.17)'; @@ -371,6 +710,12 @@ class FilesystemMapVisualization { this.ctx.stroke(); } + const matrixW = Math.min(760, Math.max(520, Math.floor(w * 0.54))); + const matrixH = Math.min(320, Math.max(220, Math.floor(h * 0.28))); + const matrixX = Math.max(24, Math.min(w - matrixW - 24, cx - Math.floor(matrixW * 0.52))); + const matrixY = Math.max(118, cy - outerR - Math.floor(matrixH * 0.45)); + this.drawLiveBlockMatrix(matrixX, matrixY, matrixW, matrixH, blocks, zones, m); + // Main rings. for (let i = 0; i <= 7; i += 1) { const r = innerR + ((outerR - innerR) * i / 7); @@ -420,7 +765,6 @@ class FilesystemMapVisualization { this.ctx.fill(); // Alert banner. - const m = this.telemetry.meta || {}; if (Number(m.writing_blocks || 0) > 0) { const pulse = 0.5 + 0.5 * Math.sin(this.tick * 0.08); const bw = 330; @@ -439,21 +783,33 @@ class FilesystemMapVisualization { } // Side package stacks from zones. - const zoneItems = zones.slice(0, 14); + const sortedZones = zones + .slice() + .sort((a, b) => this.getZoneSortScore(b, this.zoneSortMode) - this.getZoneSortScore(a, this.zoneSortMode)); + const zoneItems = sortedZones.slice(0, 14); const leftItems = zoneItems.filter((_, i) => i % 2 === 0); const rightItems = zoneItems.filter((_, i) => i % 2 === 1); - const cardW = 90; - const cardH = 32; + const cardW = 130; + const cardH = 42; const leftX = Math.max(24, cx - outerR - 220); const rightX = Math.min(w - cardW - 24, cx + outerR + 110); const topY = Math.max(150, cy - outerR + 12); const drawZonePack = (list, xBase, isLeft) => { list.forEach((z, idx) => { - const y = topY + idx * 42; - const active = Number(z.activity || 0) > 6 || Number(z.writing_blocks || 0) > 0; - const accent = active ? 'rgba(118, 220, 255, 0.92)' : 'rgba(90, 132, 178, 0.7)'; + const y = topY + idx * 50; + const usedPercent = Number(z.used_percent || 0); + const writingBlocks = Number(z.writing_blocks || 0); + const inodePressure = Number(z.inode_pressure || 0); + const riskScore = Math.max(usedPercent, inodePressure, writingBlocks > 0 ? 86 : 0); + const active = Number(z.activity || 0) > 6 || writingBlocks > 0; + const accent = riskScore >= 80 + ? 'rgba(255, 149, 158, 0.92)' + : (riskScore >= 55 ? 'rgba(255, 206, 127, 0.9)' : 'rgba(118, 220, 255, 0.92)'); const layers = 2 + (active ? 2 : 1); + const zoneId = String(z.id || z.name || `zone-${idx}`); + const isSelected = this.selectedZoneId === zoneId; + const isHovered = this.hoveredZoneId === zoneId; this.drawPackageStack( xBase, y, @@ -462,8 +818,12 @@ class FilesystemMapVisualization { layers, accent, String(z.name || z.id || 'zone'), - `used:${Number(z.used_percent || 0).toFixed(0)}%` + `used ${usedPercent.toFixed(0)}% | inode ${inodePressure.toFixed(0)}%`, + `wr ${writingBlocks} | act ${Number(z.activity || 0).toFixed(0)}`, + isSelected, + isHovered ); + this.zoneHitAreas.push({ x: xBase, y, w: cardW, h: cardH, zoneId }); // Connector to central disk. const sx = isLeft ? xBase + cardW : xBase; @@ -480,8 +840,10 @@ class FilesystemMapVisualization { tx, ty ); - this.ctx.strokeStyle = 'rgba(169, 52, 80, 0.26)'; - this.ctx.lineWidth = 1; + this.ctx.strokeStyle = isSelected + ? 'rgba(146, 204, 255, 0.86)' + : (riskScore >= 80 ? 'rgba(226, 92, 106, 0.44)' : 'rgba(169, 52, 80, 0.26)'); + this.ctx.lineWidth = isSelected ? 1.5 : 1; this.ctx.stroke(); }); }; @@ -501,6 +863,39 @@ class FilesystemMapVisualization { this.ctx.fillText(`HOST/DATA FILE ${(m.used_gb || 0).toFixed(0)}GB`, leftX, topY - 26); this.ctx.fillText(`HOST/DATA FILE ${(m.free_gb || 0).toFixed(0)}GB`, leftX, topY - 10); this.ctx.fillText(`HOST/DATA WR ${(m.write_bps || 0).toFixed(0)} B/s`, rightX - 40, topY - 10); + this.ctx.fillStyle = 'rgba(164, 183, 209, 0.84)'; + this.ctx.fillText(`zones sorted by ${String(this.zoneSortMode || 'risk').toUpperCase()}`, leftX, topY - 42); + + const archW = Math.min(620, Math.max(470, Math.floor(w * 0.44))); + const archH = 228; + const archX = Math.max(24, Math.min(w - archW - 24, rightX - 120)); + const archY = 96; + this.drawFsArchitectureMap(archX, archY, archW, archH, m, zones); + + const selectedZone = zones.find((z) => String(z.id || z.name || '') === String(this.selectedZoneId || '')); + if (selectedZone) { + const panelW = 328; + const panelH = 96; + const px = 24; + const py = h - panelH - 26; + this.drawRoundedRect(px, py, panelW, panelH, 8); + this.ctx.fillStyle = 'rgba(9, 14, 22, 0.86)'; + this.ctx.fill(); + this.ctx.strokeStyle = 'rgba(132, 164, 202, 0.42)'; + this.ctx.lineWidth = 1; + this.ctx.stroke(); + this.ctx.fillStyle = '#cfe5ff'; + this.ctx.font = '11px "Share Tech Mono", monospace'; + this.ctx.fillText(`ZONE FOCUS: ${String(selectedZone.name || selectedZone.id || 'zone').toUpperCase()}`, px + 12, py + 20); + this.ctx.fillStyle = 'rgba(165, 187, 217, 0.9)'; + this.ctx.font = '9px "Share Tech Mono", monospace'; + this.ctx.fillText(`used ${Number(selectedZone.used_percent || 0).toFixed(1)}%`, px + 12, py + 40); + this.ctx.fillText(`inode ${Number(selectedZone.inode_pressure || 0).toFixed(1)}%`, px + 12, py + 56); + this.ctx.fillText(`activity ${Number(selectedZone.activity || 0).toFixed(1)}`, px + 12, py + 72); + this.ctx.fillText(`writing blocks ${Number(selectedZone.writing_blocks || 0)}`, px + 170, py + 40); + this.ctx.fillText(`free ${(100 - Number(selectedZone.used_percent || 0)).toFixed(1)}%`, px + 170, py + 56); + this.ctx.fillText('click card again to clear focus', px + 170, py + 72); + } } animate() { diff --git a/static/js/network-stack.js b/static/js/network-stack.js index 1ce67d8..07cbeca 100644 --- a/static/js/network-stack.js +++ b/static/js/network-stack.js @@ -41,6 +41,40 @@ class NetworkStackVisualization { nic: 0.2 }; this.layerActivityTarget = { ...this.layerActivity }; + this.layerSemanticNoiseTarget = { + userspace: { stress: 0.2, jitter: 0.2, branch: 0.2 }, + socket: { stress: 0.2, jitter: 0.2, branch: 0.2 }, + tcp: { stress: 0.2, jitter: 0.2, branch: 0.2 }, + ip: { stress: 0.2, jitter: 0.2, branch: 0.2 }, + netfilter: { stress: 0.2, jitter: 0.2, branch: 0.2 }, + driver: { stress: 0.2, jitter: 0.2, branch: 0.2 }, + nic: { stress: 0.2, jitter: 0.2, branch: 0.2 } + }; + this.layerSemanticNoise = { + userspace: { stress: 0.2, jitter: 0.2, branch: 0.2 }, + socket: { stress: 0.2, jitter: 0.2, branch: 0.2 }, + tcp: { stress: 0.2, jitter: 0.2, branch: 0.2 }, + ip: { stress: 0.2, jitter: 0.2, branch: 0.2 }, + netfilter: { stress: 0.2, jitter: 0.2, branch: 0.2 }, + driver: { stress: 0.2, jitter: 0.2, branch: 0.2 }, + nic: { stress: 0.2, jitter: 0.2, branch: 0.2 } + }; + this.layerBranchSignalTarget = {}; + this.layerBranchSignalCurrent = {}; + this.layerBranchMetricTarget = {}; + this.layerBranchMetricCurrent = {}; + this.layerBranchMetricDelta = {}; + this.layerBranchMetricHistory = {}; + this.branchDeltaWindowMs = 6000; + this.layerSignalLabels = { + userspace: ['proc', 'wakeup', 'syscall'], + socket: ['accept', 'queue', 'retrans'], + tcp: ['cwnd', 'rtt', 'retrans'], + ip: ['in/out', 'route', 'ttl'], + netfilter: ['preroute', 'drop', 'conntrack'], + driver: ['txq', 'drops', 'irq'], + nic: ['rx err', 'tx err', 'dma'] + }; this.lineParticles = []; this.microPackets = []; this.metricChips = {}; @@ -49,14 +83,20 @@ class NetworkStackVisualization { this.chipLayerNode = null; this.viewModeButton = null; this.puzzleModeButton = null; + this.noiseModeButton = null; + this.readModeButton = null; this.viewDensityMode = 'detailed'; this.puzzleDetailMode = 'overview'; + this.noiseDetailMode = 'dense'; + this.readMode = 'forensics'; this.galaxyPanelNode = null; this.galaxyNodes = {}; this.galaxyExplainNode = null; this.selectedGalaxy = 'state'; this.galaxyStateData = null; this.lifecyclePanelNode = null; + this.hideOsiTiles = true; + this.hideVerticalOrbs = true; this.packetLifecycleStages = [ 'NIC RX', 'IRQ', @@ -234,7 +274,7 @@ class NetworkStackVisualization { const material = new THREE.MeshPhongMaterial({ color: def.color, transparent: true, - opacity: 0.28, + opacity: 0.06, shininess: 60, emissive: new THREE.Color(0x58b6d8), emissiveIntensity: 0.02 @@ -245,7 +285,7 @@ class NetworkStackVisualization { const edge = new THREE.LineSegments( new THREE.EdgesGeometry(new THREE.BoxGeometry(layerWidth, layerHeight, layerDepth)), - new THREE.LineBasicMaterial({ color: 0x9aa2aa, transparent: true, opacity: 0.35 }) + new THREE.LineBasicMaterial({ color: 0x9aa2aa, transparent: true, opacity: 0.22 }) ); edge.position.copy(mesh.position); this.scene.add(edge); @@ -262,10 +302,18 @@ class NetworkStackVisualization { strip.position.set(0, def.y, layerDepth * 0.34); this.scene.add(strip); + const noiseRig = this.createLayerNoiseRig(def, layerDepth); + mesh.userData.layerId = def.id; this.layerMeshes.push(mesh); - this.layers.push({ ...def, mesh, edge, strip }); + this.layers.push({ ...def, mesh, edge, strip, noiseRig }); this.layerMap[def.id] = def.y; + + if (this.hideOsiTiles) { + mesh.visible = false; + edge.visible = false; + strip.visible = false; + } }); const flowLine = new THREE.Line( @@ -278,6 +326,324 @@ class NetworkStackVisualization { this.scene.add(flowLine); } + createRodBetween(start, end, radius = 0.018, color = 0x8d97a3, opacity = 0.32) { + const dir = new THREE.Vector3().subVectors(end, start); + const len = dir.length(); + if (len <= 0.001) return null; + const midpoint = new THREE.Vector3().addVectors(start, end).multiplyScalar(0.5); + const geom = new THREE.CylinderGeometry(radius, radius, len, 8, 1, false); + const mat = new THREE.MeshBasicMaterial({ + color, + transparent: true, + opacity + }); + const rod = new THREE.Mesh(geom, mat); + rod.position.copy(midpoint); + rod.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.clone().normalize()); + this.scene.add(rod); + return rod; + } + + createSignalTagSprite(initialText = 'sig --') { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + canvas.width = 292; + canvas.height = 76; + const texture = new THREE.CanvasTexture(canvas); + texture.needsUpdate = true; + const material = new THREE.SpriteMaterial({ + map: texture, + transparent: true, + depthTest: false, + depthWrite: false + }); + const sprite = new THREE.Sprite(material); + sprite.scale.set(1.2, 0.29, 1); + sprite.renderOrder = 2; + const tag = { sprite, canvas, ctx, texture, lastText: '', lastIntensity: -1 }; + this.updateSignalTagSprite(tag, initialText, 0.2, 0.0); + this.scene.add(sprite); + return tag; + } + + updateSignalTagSprite(tag, text, intensity = 0.2, jitter = 0) { + if (!tag || !tag.ctx || !tag.canvas) return; + const safeText = String(text || 'sig --'); + const safeIntensity = Math.max(0, Math.min(1, Number(intensity) || 0)); + const safeJitter = Math.max(0, Math.min(1, Number(jitter) || 0)); + const changed = Math.abs(safeIntensity - (tag.lastIntensity ?? -1)) > 0.04 || safeText !== tag.lastText; + if (!changed) return; + + const ctx = tag.ctx; + const w = tag.canvas.width; + const h = tag.canvas.height; + ctx.clearRect(0, 0, w, h); + + const bgAlpha = 0.46 + safeIntensity * 0.34; + const strokeAlpha = 0.5 + safeIntensity * 0.44; + ctx.fillStyle = `rgba(8, 13, 20, ${bgAlpha})`; + ctx.strokeStyle = `rgba(176, 228, 255, ${strokeAlpha})`; + ctx.lineWidth = 1.35; + if (typeof ctx.roundRect === 'function') { + ctx.beginPath(); + ctx.roundRect(0.5, 0.5, w - 1, h - 1, 6); + ctx.fill(); + ctx.stroke(); + } else { + ctx.fillRect(0, 0, w, h); + ctx.strokeRect(0.5, 0.5, w - 1, h - 1); + } + + const lines = safeText.split('\n').slice(0, 2); + ctx.font = '14px "Share Tech Mono", monospace'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = `rgba(228, 246, 255, ${0.9 + safeIntensity * 0.1})`; + ctx.strokeStyle = `rgba(12, 18, 26, ${0.75 + safeIntensity * 0.2})`; + ctx.lineWidth = 2.6; + ctx.shadowColor = `rgba(120, 210, 255, ${0.2 + safeIntensity * 0.4})`; + ctx.shadowBlur = 6; + if (lines.length === 1) { + ctx.strokeText(lines[0], 14, h / 2); + ctx.fillText(lines[0], 14, h / 2); + } else { + ctx.strokeText(lines[0], 14, h * 0.35); + ctx.fillText(lines[0], 14, h * 0.35); + ctx.strokeText(lines[1], 14, h * 0.74); + ctx.fillText(lines[1], 14, h * 0.74); + } + ctx.shadowBlur = 0; + + if (safeJitter > 0.02) { + const speckCount = Math.floor(4 + safeJitter * 11); + for (let i = 0; i < speckCount; i++) { + const x = Math.random() * w; + const y = Math.random() * h; + const a = 0.05 + Math.random() * 0.14 * safeJitter; + ctx.fillStyle = `rgba(230, 193, 90, ${a})`; + ctx.fillRect(x, y, 1, 1); + } + } + + tag.texture.needsUpdate = true; + tag.lastText = safeText; + tag.lastIntensity = safeIntensity; + } + + formatBranchMetric(layerId, signalIdx, value, deltaValue = null, withDelta = false) { + const v = Number(value); + const safe = Number.isFinite(v) ? v : 0; + const dRaw = Number(deltaValue); + const hasDelta = Number.isFinite(dRaw); + const d = hasDelta ? dRaw : 0; + const dSign = d > 0 ? '+' : ''; + if (layerId === 'userspace') { + if (signalIdx === 0) return withDelta && hasDelta ? `proc ${Math.round(safe)}\nΔ ${dSign}${Math.round(d)}` : `proc ${Math.round(safe)}`; + if (signalIdx === 1) return withDelta && hasDelta ? `wake ${Math.round(safe)}%\nΔ ${dSign}${Math.round(d)}%` : `wake ${Math.round(safe)}%`; + return withDelta && hasDelta ? `sys ${Math.round(safe)}/s\nΔ ${dSign}${Math.round(d)}/s` : `sys ${Math.round(safe)}/s`; + } + if (layerId === 'socket') { + if (signalIdx === 0) return withDelta && hasDelta ? `est ${Math.round(safe)}\nΔ ${dSign}${Math.round(d)}` : `est ${Math.round(safe)}`; + if (signalIdx === 1) return withDelta && hasDelta ? `q ${Math.round(safe)}\nΔ ${dSign}${Math.round(d)}` : `q ${Math.round(safe)}`; + return withDelta && hasDelta ? `rtx ${safe.toFixed(1)}/s\nΔ ${dSign}${d.toFixed(1)}/s` : `rtx ${safe.toFixed(1)}/s`; + } + if (layerId === 'tcp') { + if (signalIdx === 0) return withDelta && hasDelta ? `cwnd ${Math.round(safe)}\nΔ ${dSign}${Math.round(d)}` : `cwnd ${Math.round(safe)}`; + if (signalIdx === 1) return withDelta && hasDelta ? `rtt ${safe.toFixed(1)}ms\nΔ ${dSign}${d.toFixed(1)}ms` : `rtt ${safe.toFixed(1)}ms`; + return withDelta && hasDelta ? `rtx ${safe.toFixed(1)}/s\nΔ ${dSign}${d.toFixed(1)}/s` : `rtx ${safe.toFixed(1)}/s`; + } + if (layerId === 'ip') { + if (signalIdx === 0) return withDelta && hasDelta ? `in ${safe.toFixed(1)}/s\nΔ ${dSign}${d.toFixed(1)}/s` : `in ${safe.toFixed(1)}/s`; + if (signalIdx === 1) return withDelta && hasDelta ? `out ${safe.toFixed(1)}/s\nΔ ${dSign}${d.toFixed(1)}/s` : `out ${safe.toFixed(1)}/s`; + return withDelta && hasDelta ? `asym ${(safe * 100).toFixed(0)}%\nΔ ${dSign}${(d * 100).toFixed(0)}%` : `asym ${(safe * 100).toFixed(0)}%`; + } + if (layerId === 'netfilter') { + if (signalIdx === 0) return withDelta && hasDelta ? `drop ${(safe * 100).toFixed(1)}%\nΔ ${dSign}${(d * 100).toFixed(1)}%` : `drop ${(safe * 100).toFixed(1)}%`; + if (signalIdx === 1) return withDelta && hasDelta ? `drop ${safe.toFixed(1)}/s\nΔ ${dSign}${d.toFixed(1)}/s` : `drop ${safe.toFixed(1)}/s`; + return withDelta && hasDelta ? `ct ${Math.round(safe)}%\nΔ ${dSign}${Math.round(d)}%` : `ct ${Math.round(safe)}%`; + } + if (layerId === 'driver') { + if (signalIdx === 0) return withDelta && hasDelta ? `txq ${Math.round(safe)}\nΔ ${dSign}${Math.round(d)}` : `txq ${Math.round(safe)}`; + if (signalIdx === 1) return withDelta && hasDelta ? `drop ${safe.toFixed(1)}/s\nΔ ${dSign}${d.toFixed(1)}/s` : `drop ${safe.toFixed(1)}/s`; + return withDelta && hasDelta ? `irq ${Math.round(safe)}\nΔ ${dSign}${Math.round(d)}` : `irq ${Math.round(safe)}`; + } + if (layerId === 'nic') { + if (signalIdx === 0) return withDelta && hasDelta ? `rx err ${Math.round(safe)}\nΔ ${dSign}${Math.round(d)}` : `rx err ${Math.round(safe)}`; + if (signalIdx === 1) return withDelta && hasDelta ? `tx err ${Math.round(safe)}\nΔ ${dSign}${Math.round(d)}` : `tx err ${Math.round(safe)}`; + return withDelta && hasDelta ? `dma ${Math.round(safe)}%\nΔ ${dSign}${Math.round(d)}%` : `dma ${Math.round(safe)}%`; + } + return `${Math.round(safe)}`; + } + + updateBranchMetricHistory(tsMs) { + const now = Number(tsMs) || Date.now(); + const windowMs = Math.max(1000, Number(this.branchDeltaWindowMs) || 6000); + const cutoff = now - windowMs; + const nextDelta = {}; + Object.entries(this.layerBranchMetricTarget || {}).forEach(([layerId, values]) => { + if (!Array.isArray(values)) return; + const history = this.layerBranchMetricHistory[layerId] || []; + history.push({ ts: now, values: values.slice() }); + while (history.length > 0 && history[0].ts < cutoff) { + history.shift(); + } + this.layerBranchMetricHistory[layerId] = history; + const baseline = history[0]?.values || values; + const delta = values.map((v, idx) => (Number(v) || 0) - (Number(baseline[idx]) || 0)); + nextDelta[layerId] = delta; + }); + this.layerBranchMetricDelta = nextDelta; + } + + createLayerNoiseRig(def, layerDepth) { + const controlHeavy = new Set(['userspace', 'socket', 'tcp', 'ip']); + const hardwareHeavy = new Set(['driver', 'nic']); + const isControl = controlHeavy.has(def.id); + const isHardware = hardwareHeavy.has(def.id); + const profile = { + type: isControl ? 'control' : (isHardware ? 'hardware' : 'mixed'), + hubRadius: isControl ? 0.088 : (isHardware ? 0.122 : 0.104), + ringRadius: isControl ? 0.21 : (isHardware ? 0.17 : 0.19), + armCount: isControl ? 6 : (isHardware ? 3 : 4), + armRadius: isControl ? 0.013 : (isHardware ? 0.022 : 0.017), + armOpacity: isControl ? 0.24 : (isHardware ? 0.38 : 0.3), + branchMin: isControl ? 2 : (isHardware ? 1 : 2), + branchMax: isControl ? 4 : (isHardware ? 2 : 3), + branchRadius: isControl ? 0.009 : (isHardware ? 0.015 : 0.011), + ringRotateSpeed: isControl ? 0.25 : (isHardware ? 0.07 : 0.14), + pulseAmp: isControl ? 0.11 : (isHardware ? 0.06 : 0.08), + xSpread: isControl ? 2.7 : (isHardware ? 1.95 : 2.3) + }; + + const hubXOffset = isControl + ? ((def.id === 'userspace' || def.id === 'tcp') ? -0.16 : 0.16) + : (isHardware ? ((def.id === 'nic') ? 0.2 : -0.2) : 0); + const hubPos = new THREE.Vector3(hubXOffset, def.y, -0.08); + const hub = new THREE.Mesh( + new THREE.SphereGeometry(profile.hubRadius, 14, 14), + new THREE.MeshBasicMaterial({ + color: 0xa8b2bd, + transparent: true, + opacity: isHardware ? 0.48 : 0.4 + }) + ); + hub.position.copy(hubPos); + this.scene.add(hub); + + const ring = new THREE.Mesh( + new THREE.TorusGeometry(profile.ringRadius, isHardware ? 0.014 : 0.012, 8, 22), + new THREE.MeshBasicMaterial({ + color: 0x657383, + transparent: true, + opacity: isControl ? 0.22 : 0.3 + }) + ); + ring.position.copy(hubPos); + ring.rotation.x = Math.PI / 2; + this.scene.add(ring); + + const arms = []; + const supportRods = []; + const satellites = []; + const armCount = profile.armCount; + for (let i = 0; i < armCount; i++) { + const side = i % 2 === 0 ? -1 : 1; + const lane = Math.floor(i / 2); + const x = hubXOffset + side * (1.1 + lane * (isControl ? 0.64 : 0.82) + Math.random() * profile.xSpread * 0.16); + const z = (Math.random() - 0.5) * (layerDepth * 0.72); + const end = new THREE.Vector3(x, def.y + (Math.random() - 0.5) * (isControl ? 0.14 : 0.06), z); + const armRod = this.createRodBetween(hubPos, end, profile.armRadius, 0x7d8a97, profile.armOpacity); + const armEntry = { + rod: armRod, + start: hubPos.clone(), + end: end.clone(), + phase: Math.random(), + signal: 0.2, + beads: [], + signalIdx: i, + signalLabel: '', + signalTag: null, + signalTagOffset: new THREE.Vector3( + (Math.random() - 0.5) * 0.16, + 0.08 + Math.random() * 0.07, + (Math.random() - 0.5) * 0.16 + ) + }; + + const satellite = new THREE.Mesh( + new THREE.SphereGeometry( + (isControl ? 0.058 : 0.078) + Math.random() * (isControl ? 0.016 : 0.024), + 10, + 10 + ), + new THREE.MeshBasicMaterial({ + color: 0xb5bdc6, + transparent: true, + opacity: isControl ? 0.3 : 0.4 + }) + ); + satellite.position.copy(end); + this.scene.add(satellite); + satellites.push(satellite); + + const beadCount = isControl ? 4 : 3; + for (let b = 0; b < beadCount; b++) { + const bead = new THREE.Mesh( + new THREE.SphereGeometry(0.016 + Math.random() * 0.008, 8, 8), + new THREE.MeshBasicMaterial({ + color: 0x95b8cf, + transparent: true, + opacity: 0.26 + }) + ); + bead.position.copy(hubPos.clone().lerp(end, (b + 1) / (beadCount + 1))); + this.scene.add(bead); + armEntry.beads.push(bead); + } + const labels = this.layerSignalLabels[def.id] || ['sig']; + armEntry.signalIdx = i % Math.max(1, labels.length); + armEntry.signalLabel = labels[armEntry.signalIdx] || `sig${armEntry.signalIdx}`; + armEntry.signalTag = this.createSignalTagSprite(`${armEntry.signalLabel} --`); + if (armEntry.signalTag?.sprite) { + const tagPos = hubPos.clone().lerp(end, 0.56).add(armEntry.signalTagOffset); + armEntry.signalTag.sprite.position.copy(tagPos); + } + arms.push(armEntry); + + const miniCount = profile.branchMin + Math.floor(Math.random() * (profile.branchMax - profile.branchMin + 1)); + for (let j = 0; j < miniCount; j++) { + const miniEnd = end.clone().add(new THREE.Vector3( + side * (0.24 + Math.random() * (isControl ? 0.56 : 0.38)), + (Math.random() - 0.5) * (isControl ? 0.18 : 0.08), + (Math.random() - 0.5) * (isControl ? 0.72 : 0.42) + )); + const miniRod = this.createRodBetween( + end, + miniEnd, + profile.branchRadius, + 0x74808d, + isControl ? 0.2 : 0.26 + ); + if (miniRod) { + supportRods.push(miniRod); + } + const miniDot = new THREE.Mesh( + new THREE.SphereGeometry((isControl ? 0.022 : 0.03) + Math.random() * 0.01, 8, 8), + new THREE.MeshBasicMaterial({ + color: 0x90a0b0, + transparent: true, + opacity: isControl ? 0.2 : 0.28 + }) + ); + miniDot.position.copy(miniEnd); + this.scene.add(miniDot); + satellites.push(miniDot); + } + } + + return { hub, ring, arms, supportRods, satellites, profile }; + } + createFlowParticles() { // Thin particles flowing along the vertical spine. for (let i = 0; i < 36; i++) { @@ -297,6 +663,9 @@ class NetworkStackVisualization { }; this.scene.add(p); this.lineParticles.push(p); + if (this.hideVerticalOrbs) { + p.visible = false; + } } // Micro-packets falling through layers (plus occasional upward control packets). @@ -318,6 +687,9 @@ class NetworkStackVisualization { }; this.scene.add(m); this.microPackets.push(m); + if (this.hideVerticalOrbs) { + m.visible = false; + } } } @@ -343,6 +715,13 @@ class NetworkStackVisualization { this.scene.add(trail); this.packetTrail.push(trail); } + if (this.hideVerticalOrbs) { + this.packet.visible = false; + this.packetGlow.visible = false; + this.packetTrail.forEach((trail) => { + if (trail) trail.visible = false; + }); + } } createOverlayUI() { @@ -723,6 +1102,72 @@ class NetworkStackVisualization { this.container.appendChild(puzzleModeBtn); this.overlayNodes.push(puzzleModeBtn); this.puzzleModeButton = puzzleModeBtn; + + const noiseModeBtn = document.createElement('button'); + noiseModeBtn.textContent = 'NOISE: DENSE'; + noiseModeBtn.style.cssText = ` + position: absolute; + top: 130px; + right: 20px; + padding: 7px 10px; + background: rgba(12, 18, 28, 0.88); + border: 1px solid rgba(230, 193, 90, 0.58); + color: #f0dca2; + font-family: 'Share Tech Mono', monospace; + font-size: 10px; + letter-spacing: 0.45px; + cursor: pointer; + z-index: 1002; + transition: all 0.2s ease; + `; + noiseModeBtn.onmouseenter = () => { + noiseModeBtn.style.background = 'rgba(19, 28, 40, 0.95)'; + noiseModeBtn.style.color = '#edf2f8'; + }; + noiseModeBtn.onmouseleave = () => { + noiseModeBtn.style.background = 'rgba(12, 18, 28, 0.88)'; + noiseModeBtn.style.color = this.noiseDetailMode === 'dense' ? '#f0dca2' : '#c6d0db'; + }; + noiseModeBtn.onclick = () => { + this.toggleNoiseDetailMode(); + }; + this.container.appendChild(noiseModeBtn); + this.overlayNodes.push(noiseModeBtn); + this.noiseModeButton = noiseModeBtn; + + const readModeBtn = document.createElement('button'); + readModeBtn.textContent = 'READ: FORENSICS'; + readModeBtn.style.cssText = ` + position: absolute; + top: 166px; + right: 20px; + padding: 7px 10px; + background: rgba(12, 18, 28, 0.88); + border: 1px solid rgba(125, 138, 156, 0.34); + color: #c6d0db; + font-family: 'Share Tech Mono', monospace; + font-size: 10px; + letter-spacing: 0.45px; + cursor: pointer; + z-index: 1002; + transition: all 0.2s ease; + `; + readModeBtn.onmouseenter = () => { + readModeBtn.style.background = 'rgba(19, 28, 40, 0.95)'; + readModeBtn.style.color = '#edf2f8'; + }; + readModeBtn.onmouseleave = () => { + readModeBtn.style.background = 'rgba(12, 18, 28, 0.88)'; + if (this.readMode === 'scene') readModeBtn.style.color = '#9bd4f2'; + else if (this.readMode === 'ops') readModeBtn.style.color = '#f0dca2'; + else readModeBtn.style.color = '#c6d0db'; + }; + readModeBtn.onclick = () => { + this.toggleReadMode(); + }; + this.container.appendChild(readModeBtn); + this.overlayNodes.push(readModeBtn); + this.readModeButton = readModeBtn; this.updateBottomPanelsLayout(); this.updatePacketLifecycleUI(); this.applyViewDensityMode(); @@ -789,6 +1234,13 @@ class NetworkStackVisualization { if (this.puzzleModeButton) { this.puzzleModeButton.style.display = minimal ? 'none' : 'block'; } + if (this.noiseModeButton) { + this.noiseModeButton.style.display = minimal ? 'none' : 'block'; + } + if (this.readModeButton) { + this.readModeButton.style.display = minimal ? 'none' : 'block'; + } + this.applyReadModeVisibility(); } toggleViewDensityMode() { @@ -809,6 +1261,64 @@ class NetworkStackVisualization { this.updatePacketLifecycleUI(); } + toggleNoiseDetailMode() { + this.noiseDetailMode = this.noiseDetailMode === 'dense' ? 'normal' : 'dense'; + if (this.noiseModeButton) { + const dense = this.noiseDetailMode === 'dense'; + this.noiseModeButton.textContent = dense ? 'NOISE: DENSE' : 'NOISE: NORMAL'; + this.noiseModeButton.style.borderColor = dense + ? 'rgba(230, 193, 90, 0.58)' + : 'rgba(125, 138, 156, 0.34)'; + this.noiseModeButton.style.color = dense ? '#f0dca2' : '#c6d0db'; + } + Object.keys(this.layerBranchMetricDelta || {}).forEach((layerId) => { + const deltas = this.layerBranchMetricDelta[layerId]; + if (Array.isArray(deltas)) { + this.layerBranchMetricDelta[layerId] = deltas.map((v) => Number(v) || 0); + } + }); + } + + applyReadModeVisibility() { + if (this.viewDensityMode === 'minimal') return; + const mode = this.readMode; + const showLayers = mode !== 'scene'; + const showChips = mode !== 'scene'; + const showGalaxy = mode !== 'scene'; + const showLifecycle = mode === 'forensics'; + + if (this.layersPanelNode) this.layersPanelNode.style.display = showLayers ? 'block' : 'none'; + if (this.chipLayerNode) this.chipLayerNode.style.display = showChips ? 'block' : 'none'; + if (this.galaxyPanelNode) this.galaxyPanelNode.style.display = showGalaxy ? 'block' : 'none'; + if (this.lifecyclePanelNode) this.lifecyclePanelNode.style.display = showLifecycle ? 'block' : 'none'; + + if (this.noiseModeButton) this.noiseModeButton.style.display = mode === 'forensics' ? 'block' : 'none'; + if (this.puzzleModeButton) this.puzzleModeButton.style.display = mode === 'forensics' ? 'block' : 'none'; + } + + toggleReadMode() { + const order = ['scene', 'ops', 'forensics']; + const idx = order.indexOf(this.readMode); + this.readMode = order[(idx + 1) % order.length]; + if (this.readModeButton) { + if (this.readMode === 'scene') { + this.readModeButton.textContent = 'READ: SCENE'; + this.readModeButton.style.borderColor = 'rgba(130, 204, 240, 0.58)'; + this.readModeButton.style.color = '#9bd4f2'; + } else if (this.readMode === 'ops') { + this.readModeButton.textContent = 'READ: OPS'; + this.readModeButton.style.borderColor = 'rgba(230, 193, 90, 0.58)'; + this.readModeButton.style.color = '#f0dca2'; + } else { + this.readModeButton.textContent = 'READ: FORENSICS'; + this.readModeButton.style.borderColor = 'rgba(125, 138, 156, 0.34)'; + this.readModeButton.style.color = '#c6d0db'; + } + } + this.applyReadModeVisibility(); + this.updatePacketLifecycleUI(); + } + selectGalaxy(id) { if (!id || !this.galaxyNodes[id]) return; this.selectedGalaxy = id; @@ -1311,6 +1821,130 @@ class NetworkStackVisualization { driver: Number(a.driver ?? this.layerActivityTarget.driver), nic: Number(a.nic ?? this.layerActivityTarget.nic) }; + + const clamp01 = (value) => Math.max(0, Math.min(1, Number(value) || 0)); + const userspaceProcs = safeNum(m.userspace?.active_processes ?? 0); + const socketEst = safeNum(m.socket_api?.established ?? 0); + const ipIn = safeNum(m.ip?.in_packets_per_sec ?? 0); + const ipOut = safeNum(m.ip?.out_packets_per_sec ?? 0); + const driverDrops = safeNum(m.driver?.drops_per_sec ?? 0); + const flowIntensity = safeNum(this.packetSpeed || 0) / 5.2; + const ipAsym = Math.abs(ipIn - ipOut) / Math.max(1, ipIn + ipOut); + + this.layerSemanticNoiseTarget = { + userspace: { + stress: clamp01(userspaceProcs / 320), + jitter: clamp01(flowIntensity * 0.55 + userspaceProcs / 540), + branch: clamp01(0.2 + userspaceProcs / 500) + }, + socket: { + stress: clamp01(socketEst / 180 + retransPerSec / 44), + jitter: clamp01(retransPerSec / 34 + flowIntensity * 0.25), + branch: clamp01(0.24 + socketEst / 260) + }, + tcp: { + stress: clamp01(rttMs / 260 + retransPerSec / 34), + jitter: clamp01(retransPerSec / 24 + rttMs / 420), + branch: clamp01(0.25 + retransPerSec / 30) + }, + ip: { + stress: clamp01((ipIn + ipOut) / 9000 + ipAsym * 0.7), + jitter: clamp01(ipAsym + flowIntensity * 0.2), + branch: clamp01(0.2 + (ipIn + ipOut) / 13000) + }, + netfilter: { + stress: clamp01(dropRatio * 2.6 + dropPerSec / 24), + jitter: clamp01(dropPerSec / 16 + dropRatio * 1.6), + branch: clamp01(0.22 + dropRatio * 2.1) + }, + driver: { + stress: clamp01(driverTxQ / 360 + driverDrops / 22), + jitter: clamp01(driverTxQ / 470 + driverDrops / 15), + branch: clamp01(0.22 + driverTxQ / 500) + }, + nic: { + stress: clamp01(nicErrTotal / 30 + driverTxQ / 650), + jitter: clamp01(nicErrTotal / 18 + flowIntensity * 0.2), + branch: clamp01(0.2 + nicErrTotal / 28) + } + }; + + this.layerBranchSignalTarget = { + userspace: [ + clamp01(userspaceProcs / 320), + clamp01(flowIntensity), + clamp01((userspaceProcs * 0.08 + socketEst * 0.25) / 120) + ], + socket: [ + clamp01(socketEst / 180), + clamp01((socketEst * 0.18 + retransPerSec * 4) / 60), + clamp01(retransPerSec / 26) + ], + tcp: [ + clamp01(safeNum(m.tcp_udp?.cwnd ?? 0) / 240), + clamp01(rttMs / 260), + clamp01(retransPerSec / 20) + ], + ip: [ + clamp01(ipIn / 5500), + clamp01(ipOut / 5500), + clamp01(ipAsym) + ], + netfilter: [ + clamp01(dropRatio * 2.4), + clamp01(dropPerSec / 20), + clamp01((dropRatio * 120 + dropPerSec) / 30) + ], + driver: [ + clamp01(driverTxQ / 360), + clamp01(driverDrops / 18), + clamp01((driverTxQ + driverDrops * 8) / 520) + ], + nic: [ + clamp01(nicErrRx / 14), + clamp01(nicErrTx / 14), + clamp01(nicErrTotal / 22 + flowIntensity * 0.25) + ] + }; + + this.layerBranchMetricTarget = { + userspace: [ + userspaceProcs, + flowIntensity * 100, + userspaceProcs * 0.08 + socketEst * 0.25 + ], + socket: [ + socketEst, + socketEst * 0.18 + retransPerSec * 4, + retransPerSec + ], + tcp: [ + safeNum(m.tcp_udp?.cwnd ?? 0), + rttMs, + retransPerSec + ], + ip: [ + ipIn, + ipOut, + ipAsym + ], + netfilter: [ + dropRatio, + dropPerSec, + dropRatio * 100 + dropPerSec + ], + driver: [ + driverTxQ, + driverDrops, + driverTxQ * 0.6 + driverDrops * 3.5 + ], + nic: [ + nicErrRx, + nicErrTx, + nicErrTotal * 4 + flowIntensity * 40 + ] + }; + this.updateBranchMetricHistory(Date.now()); } fetchTelemetry() { @@ -1401,41 +2035,197 @@ class NetworkStackVisualization { this.layerActivity[id] = blended; const actRaw = blended; const act = Math.max(0, Math.min(1, actRaw)); + const currentSemantic = this.layerSemanticNoise[id] || { stress: 0.2, jitter: 0.2, branch: 0.2 }; + const targetSemantic = this.layerSemanticNoiseTarget[id] || currentSemantic; + const semanticBlend = Math.min(1, dt * 3.1); + currentSemantic.stress += (targetSemantic.stress - currentSemantic.stress) * semanticBlend; + currentSemantic.jitter += (targetSemantic.jitter - currentSemantic.jitter) * semanticBlend; + currentSemantic.branch += (targetSemantic.branch - currentSemantic.branch) * semanticBlend; + this.layerSemanticNoise[id] = currentSemantic; + const semanticStress = Math.max(0, Math.min(1, currentSemantic.stress)); + const semanticJitter = Math.max(0, Math.min(1, currentSemantic.jitter)); + const semanticBranch = Math.max(0, Math.min(1, currentSemantic.branch)); + const targetSignals = this.layerBranchSignalTarget[id] || []; + if (!this.layerBranchSignalCurrent[id]) { + this.layerBranchSignalCurrent[id] = targetSignals.slice(); + } else { + const currentSignals = this.layerBranchSignalCurrent[id]; + const signalCount = Math.max(currentSignals.length, targetSignals.length); + for (let i = 0; i < signalCount; i++) { + const cur = Number(currentSignals[i] ?? 0.2); + const tgt = Number(targetSignals[i] ?? targetSignals[targetSignals.length - 1] ?? 0.2); + currentSignals[i] = cur + (tgt - cur) * Math.min(1, dt * 3.6); + } + } + const targetMetrics = this.layerBranchMetricTarget[id] || []; + if (!this.layerBranchMetricCurrent[id]) { + this.layerBranchMetricCurrent[id] = targetMetrics.slice(); + } else { + const currentMetrics = this.layerBranchMetricCurrent[id]; + const metricCount = Math.max(currentMetrics.length, targetMetrics.length); + for (let i = 0; i < metricCount; i++) { + const cur = Number(currentMetrics[i] ?? 0); + const tgt = Number(targetMetrics[i] ?? targetMetrics[targetMetrics.length - 1] ?? 0); + currentMetrics[i] = cur + (tgt - cur) * Math.min(1, dt * 3.4); + } + } + if (layer.mesh?.material) { - layer.mesh.material.opacity = 0.2 + act * 0.28; + layer.mesh.material.opacity = 0.03 + act * 0.09; layer.mesh.material.emissiveIntensity = 0.02 + act * 0.18; } if (layer.strip?.material) { - layer.strip.scale.x = 0.14 + act * 0.86; - layer.strip.material.opacity = 0.12 + act * 0.78; + layer.strip.scale.x = 0.14 + act * 0.86 + semanticStress * 0.14; + layer.strip.material.opacity = 0.12 + act * 0.78 + semanticBranch * 0.06; + } + if (this.hideOsiTiles) { + if (layer.mesh) layer.mesh.visible = false; + if (layer.edge) layer.edge.visible = false; + if (layer.strip) layer.strip.visible = false; + } + if (layer.noiseRig) { + const rig = layer.noiseRig; + const profile = rig.profile || {}; + const ringSpeed = Number(profile.ringRotateSpeed || 0.14); + const pulseAmp = Number(profile.pulseAmp || 0.08); + if (rig.hub?.material) { + rig.hub.material.opacity = 0.2 + act * 0.42 + semanticStress * 0.22; + } + if (rig.hub?.scale) { + const hubPulse = 1 + (0.02 + semanticJitter * 0.08) * Math.sin((performance.now() * 0.0032) + (layer.id.length * 0.7)); + rig.hub.scale.setScalar(hubPulse); + } + if (rig.ring?.material) { + rig.ring.material.opacity = 0.12 + act * 0.26 + semanticBranch * 0.24; + rig.ring.rotation.z += (ringSpeed + act * 0.15 + semanticJitter * 0.38) * dt; + } + if (Array.isArray(rig.supportRods)) { + rig.supportRods.forEach((rod, idx) => { + if (rod?.material) { + const pulse = 0.5 + 0.5 * Math.sin((performance.now() * 0.0018) + idx * 0.37); + rod.material.opacity = 0.06 + act * 0.12 + semanticBranch * 0.1 + pulse * 0.05; + } + }); + } + if (Array.isArray(rig.arms)) { + const signals = this.layerBranchSignalCurrent[id] || []; + const metricValues = this.layerBranchMetricCurrent[id] || []; + const metricDeltas = this.layerBranchMetricDelta[id] || []; + rig.arms.forEach((armEntry, idx) => { + const arm = armEntry?.rod; + const signal = Number(signals[armEntry.signalIdx % Math.max(1, signals.length)] ?? semanticBranch); + armEntry.signal += (signal - armEntry.signal) * Math.min(1, dt * 4.5); + const armSignal = Math.max(0, Math.min(1, armEntry.signal)); + if (arm?.material) { + const idxPulse = 0.5 + 0.5 * Math.sin((performance.now() * 0.0015) + idx * 0.42); + arm.material.opacity = 0.08 + + act * (0.12 + (idx % 3) * 0.025) + + semanticBranch * 0.16 + + armSignal * 0.22 + + semanticJitter * idxPulse * 0.09; + const hue = 0.56 - armSignal * 0.34; + arm.material.color.setHSL(hue, 0.55, 0.54); + } + if (armEntry.signalTag?.sprite) { + const posPulse = Math.sin((performance.now() * 0.0021) + idx * 0.7) * (0.02 + semanticJitter * 0.04); + const tagPos = armEntry.start + .clone() + .lerp(armEntry.end, 0.56 + posPulse) + .add(armEntry.signalTagOffset); + armEntry.signalTag.sprite.position.copy(tagPos); + const metricValue = Number(metricValues[armEntry.signalIdx % Math.max(1, metricValues.length)] ?? armSignal * 100); + const metricDelta = Number(metricDeltas[armEntry.signalIdx % Math.max(1, metricDeltas.length)] ?? 0); + const denseTags = this.noiseDetailMode === 'dense' && this.readMode === 'forensics'; + const signalText = this.formatBranchMetric( + id, + armEntry.signalIdx, + metricValue, + metricDelta, + denseTags + ); + this.updateSignalTagSprite(armEntry.signalTag, signalText, armSignal, semanticJitter); + const modeOpacity = this.readMode === 'scene' ? 0.35 : (this.readMode === 'ops' ? 0.65 : 1); + armEntry.signalTag.sprite.material.opacity = (0.25 + armSignal * 0.7) * modeOpacity; + armEntry.signalTag.sprite.scale.set( + (1.2 + armSignal * 0.2) * (this.readMode === 'scene' ? 0.78 : 1), + (0.29 + armSignal * 0.05) * (this.readMode === 'scene' ? 0.78 : 1), + 1 + ); + } + if (Array.isArray(armEntry?.beads)) { + armEntry.phase += dt * (0.22 + armSignal * 1.45 + semanticJitter * 0.55); + armEntry.beads.forEach((bead, bIdx) => { + if (!bead) return; + const t = (armEntry.phase + bIdx * 0.27) % 1; + const pos = armEntry.start.clone().lerp(armEntry.end, t); + bead.position.copy(pos); + if (bead.material) { + bead.material.opacity = 0.14 + armSignal * 0.45 + semanticBranch * 0.16; + const beadHue = 0.58 - armSignal * 0.4; + bead.material.color.setHSL(beadHue, 0.62, 0.6); + } + const scale = 0.85 + armSignal * 0.9; + bead.scale.setScalar(scale); + }); + } + }); + } + if (Array.isArray(rig.satellites)) { + rig.satellites.forEach((dot, idx) => { + if (dot?.material) { + const pulse = 0.5 + 0.5 * Math.sin((performance.now() * 0.0025) + idx * 0.65); + dot.material.opacity = 0.1 + act * 0.2 + semanticBranch * 0.2 + pulse * (pulseAmp + semanticJitter * 0.12); + } + }); + } } }); } getLayerTooltipContent(layerId) { const m = this.telemetryData?.layer_metrics || {}; + const safeNum = (value) => { + const n = Number(value); + return Number.isFinite(n) ? n : 0; + }; + const labels = this.layerSignalLabels[layerId] || []; + const metrics = this.layerBranchMetricCurrent[layerId] || []; + const deltas = this.layerBranchMetricDelta[layerId] || []; + const signalLine = labels.length + ? labels.map((_, idx) => this.formatBranchMetric( + layerId, + idx, + safeNum(metrics[idx] ?? 0), + safeNum(deltas[idx] ?? 0), + this.noiseDetailMode === 'dense' && this.readMode === 'forensics' + ).replace('\n', ' ')).join(' | ') + : ''; + const appendSignals = (body) => ( + `${body}${signalLine ? `
branches: ${signalLine}` : ''}` + ); + if (layerId === 'userspace') { - return `Userspace
active processes: ${m.userspace?.active_processes ?? 0}`; + return appendSignals(`Userspace
active processes: ${m.userspace?.active_processes ?? 0}`); } if (layerId === 'socket') { - return `Socket API
established: ${m.socket_api?.established ?? 0}
retransmits/s: ${m.socket_api?.retransmits_per_sec ?? 0}`; + return appendSignals(`Socket API
established: ${m.socket_api?.established ?? 0}
retransmits/s: ${m.socket_api?.retransmits_per_sec ?? 0}`); } if (layerId === 'tcp') { - return `TCP/UDP
cwnd: ${m.tcp_udp?.cwnd ?? 0}
rtt: ${m.tcp_udp?.rtt_ms ?? 0} ms
retrans/s: ${m.tcp_udp?.retrans_per_sec ?? 0}`; + return appendSignals(`TCP/UDP
cwnd: ${m.tcp_udp?.cwnd ?? 0}
rtt: ${m.tcp_udp?.rtt_ms ?? 0} ms
retrans/s: ${m.tcp_udp?.retrans_per_sec ?? 0}`); } if (layerId === 'ip') { - return `IP
packets in: ${m.ip?.in_packets_per_sec ?? 0}/s
packets out: ${m.ip?.out_packets_per_sec ?? 0}/s`; + return appendSignals(`IP
packets in: ${m.ip?.in_packets_per_sec ?? 0}/s
packets out: ${m.ip?.out_packets_per_sec ?? 0}/s`); } if (layerId === 'netfilter') { - return `Netfilter
drop/s: ${m.netfilter?.drop_per_sec ?? 0}
drop ratio: ${((m.netfilter?.drop_ratio ?? 0) * 100).toFixed(2)}%`; + return appendSignals(`Netfilter
drop/s: ${m.netfilter?.drop_per_sec ?? 0}
drop ratio: ${((m.netfilter?.drop_ratio ?? 0) * 100).toFixed(2)}%`); } if (layerId === 'driver') { - return `Driver
tx queue: ${m.driver?.tx_queue ?? 0}
drops/s: ${m.driver?.drops_per_sec ?? 0}`; + return appendSignals(`Driver
tx queue: ${m.driver?.tx_queue ?? 0}
drops/s: ${m.driver?.drops_per_sec ?? 0}`); } if (layerId === 'nic') { - return `NIC
iface: ${m.nic?.iface ?? 'n/a'}
errors rx/tx: ${m.nic?.rx_errors ?? 0}/${m.nic?.tx_errors ?? 0}`; + return appendSignals(`NIC
iface: ${m.nic?.iface ?? 'n/a'}
errors rx/tx: ${m.nic?.rx_errors ?? 0}/${m.nic?.tx_errors ?? 0}`); } - return `${layerId}`; + return appendSignals(`${layerId}`); } onMouseMove(event) { @@ -1483,9 +2273,13 @@ class NetworkStackVisualization { const dt = Math.min(0.04, (now - this.lastFrameTime) / 1000); this.lastFrameTime = now; - this.updatePacket(dt); + if (!this.hideVerticalOrbs) { + this.updatePacket(dt); + } this.updateEffects(dt); - this.updateFlowParticles(dt); + if (!this.hideVerticalOrbs) { + this.updateFlowParticles(dt); + } this.updateLayerStrips(dt); this.updatePacketLifecycleUI(); diff --git a/static/js/security-belt.js b/static/js/security-belt.js index 6e42215..a1cee3e 100644 --- a/static/js/security-belt.js +++ b/static/js/security-belt.js @@ -23,8 +23,28 @@ class SecuritySubsystemVisualization { this.hoveredProcessPid = null; this.trustNodeHitAreas = []; this.capabilityRowHitAreas = []; + this.ringHitAreas = []; + this.focusedRingKey = null; + this.hoveredRingKey = null; + this.ringFocusPinned = false; + this.panelRects = {}; + this.panelSpotlight = null; this.canvasClickHandler = null; this.canvasMouseMoveHandler = null; + this.canvasDoubleClickHandler = null; + this.showSecurityCorePanel = false; + this.corePanelToggleButton = null; + this.ringDerivedVerdictFilter = null; + this.focusStatusNode = null; + this.unpinButton = null; + this.trustGraphMode = 'topology'; + this.trustModeButton = null; + this.trustForensicsLayout = new Map(); + this.trustForensicsFreezeUntil = 0; + this.trustForensicsFreezeWindowMs = 8000; + this.selectedIncidentTrail = []; + this.incidentTrailWindowMs = 22000; + this.incidentTrailMaxPoints = 36; } init(containerId = 'security-belt-container') { @@ -60,8 +80,10 @@ class SecuritySubsystemVisualization { this.ctx = this.canvas.getContext('2d'); this.canvasClickHandler = (event) => this.onCanvasClick(event); this.canvasMouseMoveHandler = (event) => this.onCanvasMouseMove(event); + this.canvasDoubleClickHandler = (event) => this.onCanvasDoubleClick(event); this.canvas.addEventListener('click', this.canvasClickHandler); this.canvas.addEventListener('mousemove', this.canvasMouseMoveHandler); + this.canvas.addEventListener('dblclick', this.canvasDoubleClickHandler); this.createOverlayUI(); this.addExitButton(); @@ -101,10 +123,53 @@ class SecuritySubsystemVisualization { font-size: 11px; z-index: 1001; `; - legend.textContent = 'pipeline + trust + attack surface + lsm/capabilities/seccomp tools'; + legend.textContent = 'ring-first security model: policy / sandbox / threat pressure'; this.container.appendChild(legend); this.overlayNodes.push(legend); + const focusStatus = document.createElement('div'); + focusStatus.style.cssText = ` + position: absolute; + top: 92px; + left: 24px; + color: rgba(170, 188, 214, 0.88); + font-family: 'Share Tech Mono', monospace; + font-size: 10px; + letter-spacing: 0.3px; + z-index: 1001; + `; + focusStatus.textContent = 'FOCUS: NONE'; + this.container.appendChild(focusStatus); + this.overlayNodes.push(focusStatus); + this.focusStatusNode = focusStatus; + + const unpinBtn = document.createElement('button'); + unpinBtn.textContent = 'UNPIN'; + unpinBtn.style.cssText = ` + position: absolute; + top: 88px; + left: 218px; + padding: 3px 8px; + background: rgba(8, 12, 18, 0.56); + border: 1px solid rgba(150, 164, 188, 0.22); + color: rgba(188, 200, 219, 0.55); + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + letter-spacing: 0.25px; + cursor: not-allowed; + z-index: 1001; + `; + unpinBtn.onclick = () => { + if (!this.ringFocusPinned) return; + this.ringFocusPinned = false; + this.focusedRingKey = null; + this.applyRingFocusVerdictFilter(); + this.updateFocusStatusUi(); + }; + this.container.appendChild(unpinBtn); + this.overlayNodes.push(unpinBtn); + this.unpinButton = unpinBtn; + const filterPanel = document.createElement('div'); filterPanel.style.cssText = ` position: absolute; @@ -156,13 +221,239 @@ class SecuritySubsystemVisualization { clearBtn.onclick = () => { if (!this.selectedProcessFilter) return; this.selectedProcessFilter = null; + this.selectedIncidentTrail = []; this.updateClearProcessButtonState(); }; filterPanel.appendChild(clearBtn); this.clearProcessButton = clearBtn; this.overlayNodes.push(clearBtn); + + const coreToggleBtn = document.createElement('button'); + coreToggleBtn.textContent = 'CORE PANEL: OFF'; + coreToggleBtn.style.cssText = ` + padding: 4px 8px; + background: rgba(8, 12, 18, 0.56); + border: 1px solid rgba(150, 164, 188, 0.22); + color: rgba(188, 200, 219, 0.8); + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + letter-spacing: 0.3px; + cursor: pointer; + `; + coreToggleBtn.onclick = () => { + this.showSecurityCorePanel = !this.showSecurityCorePanel; + this.updateCorePanelToggleButtonState(); + }; + filterPanel.appendChild(coreToggleBtn); + this.corePanelToggleButton = coreToggleBtn; + this.overlayNodes.push(coreToggleBtn); + + const trustModeBtn = document.createElement('button'); + trustModeBtn.textContent = 'TRUST MODE: TOPOLOGY'; + trustModeBtn.style.cssText = ` + padding: 4px 8px; + background: rgba(8, 12, 18, 0.56); + border: 1px solid rgba(150, 164, 188, 0.22); + color: rgba(188, 200, 219, 0.8); + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + letter-spacing: 0.3px; + cursor: pointer; + `; + trustModeBtn.onclick = () => this.toggleTrustGraphMode(); + filterPanel.appendChild(trustModeBtn); + this.trustModeButton = trustModeBtn; + this.overlayNodes.push(trustModeBtn); + this.setVerdictFilter(this.verdictFilter); this.updateClearProcessButtonState(); + this.updateCorePanelToggleButtonState(); + this.updateTrustGraphModeButtonState(); + this.updateFocusStatusUi(); + } + + updateCorePanelToggleButtonState() { + if (!this.corePanelToggleButton) return; + const on = this.showSecurityCorePanel; + this.corePanelToggleButton.textContent = on ? 'CORE PANEL: ON' : 'CORE PANEL: OFF'; + this.corePanelToggleButton.style.background = on ? 'rgba(32, 52, 81, 0.92)' : 'rgba(8, 12, 18, 0.56)'; + this.corePanelToggleButton.style.borderColor = on ? 'rgba(124, 178, 255, 0.9)' : 'rgba(150, 164, 188, 0.22)'; + this.corePanelToggleButton.style.color = on ? '#d9ecff' : 'rgba(188, 200, 219, 0.8)'; + } + + toggleTrustGraphMode() { + const order = ['topology', 'risk', 'forensics']; + const currentIdx = order.indexOf(this.trustGraphMode); + const nextIdx = currentIdx >= 0 ? ((currentIdx + 1) % order.length) : 0; + this.trustGraphMode = order[nextIdx]; + this.updateTrustGraphModeButtonState(); + } + + updateTrustGraphModeButtonState() { + if (!this.trustModeButton) return; + const labels = { + topology: 'TOPOLOGY', + risk: 'RISK HEAT', + forensics: 'FORENSICS' + }; + const modeLabel = labels[this.trustGraphMode] || 'TOPOLOGY'; + const isRisk = this.trustGraphMode === 'risk'; + const isForensics = this.trustGraphMode === 'forensics'; + this.trustModeButton.textContent = `TRUST MODE: ${modeLabel}`; + this.trustModeButton.style.background = isRisk + ? 'rgba(70, 35, 32, 0.92)' + : (isForensics ? 'rgba(28, 45, 64, 0.92)' : 'rgba(8, 12, 18, 0.56)'); + this.trustModeButton.style.borderColor = isRisk + ? 'rgba(255, 156, 126, 0.9)' + : (isForensics ? 'rgba(132, 190, 255, 0.88)' : 'rgba(150, 164, 188, 0.22)'); + this.trustModeButton.style.color = isRisk + ? '#ffd8cb' + : (isForensics ? '#e3f2ff' : 'rgba(188, 200, 219, 0.8)'); + } + + buildForensicsLayout(rows, cx, cy, innerRadius, maxRadius) { + const layout = new Map(); + const ordered = rows.slice().sort((a, b) => Number(a?.pid || 0) - Number(b?.pid || 0)); + ordered.forEach((row, idx) => { + const pid = Number(row?.pid || 0); + const risk = Number(row?.risk_score || 0); + const riskNorm = Math.max(0, Math.min(1, risk / 100)); + const seed = ((pid % 37) + 37) % 37; + const offset = (seed / 37 - 0.5) * 0.34; + const angle = ((Math.PI * 2) / Math.max(ordered.length, 1)) * idx - Math.PI / 2 + offset; + const orbit = innerRadius + (maxRadius - innerRadius) * (0.24 + riskNorm * 0.76); + layout.set(pid, { + angle, + orbit, + nx: cx + Math.cos(angle) * orbit, + ny: cy + Math.sin(angle) * orbit + }); + }); + return layout; + } + + findSelectedTrustRow(rows) { + if (!this.selectedProcessFilter || !Array.isArray(rows) || !rows.length) return null; + const selectedPid = Number(this.selectedProcessFilter.pid || 0); + const selectedName = this.normalizeName(this.selectedProcessFilter.name || ''); + return rows.find((row) => { + const rowPid = Number(row?.pid || 0); + const rowName = this.normalizeName(row?.name || ''); + return rowPid === selectedPid || (selectedName && rowName === selectedName); + }) || null; + } + + updateSelectedIncidentTrail(rows) { + if (!this.selectedProcessFilter) { + this.selectedIncidentTrail = []; + return; + } + const selectedRow = this.findSelectedTrustRow(rows); + if (!selectedRow) return; + const now = Date.now(); + const risk = Number(selectedRow.risk_score || 0); + const trust = String(selectedRow.trust || 'trusted'); + const prev = this.selectedIncidentTrail[this.selectedIncidentTrail.length - 1]; + if (!prev || Math.abs(Number(prev.risk || 0) - risk) >= 0.5 || prev.trust !== trust || (now - Number(prev.ts || 0)) >= 1300) { + this.selectedIncidentTrail.push({ ts: now, risk, trust }); + } else { + prev.ts = now; + prev.risk = risk; + prev.trust = trust; + } + const cutoff = now - this.incidentTrailWindowMs; + this.selectedIncidentTrail = this.selectedIncidentTrail + .filter((item) => Number(item.ts || 0) >= cutoff) + .slice(-this.incidentTrailMaxPoints); + } + + drawIncidentTrail(x, y, w, h, selectedNode) { + if (!this.selectedProcessFilter || this.selectedIncidentTrail.length < 2) return; + const trail = this.selectedIncidentTrail; + const now = Date.now(); + const chartX = x + 14; + const chartY = y + h - 58; + const chartW = Math.max(110, w - 28); + const chartH = 42; + + this.drawRoundedRect(chartX, chartY, chartW, chartH, 5); + this.ctx.fillStyle = 'rgba(9, 13, 19, 0.56)'; + this.ctx.fill(); + this.ctx.strokeStyle = 'rgba(122, 141, 166, 0.28)'; + this.ctx.lineWidth = 0.9; + this.ctx.stroke(); + + this.ctx.beginPath(); + trail.forEach((entry, idx) => { + const age = Math.max(0, now - Number(entry.ts || now)); + const progress = 1 - Math.min(1, age / this.incidentTrailWindowMs); + const px = chartX + 8 + progress * (chartW - 16); + const py = chartY + chartH - 8 - (Math.max(0, Math.min(100, Number(entry.risk || 0))) / 100) * (chartH - 14); + if (idx === 0) this.ctx.moveTo(px, py); + else this.ctx.lineTo(px, py); + }); + this.ctx.strokeStyle = 'rgba(151, 210, 255, 0.88)'; + this.ctx.lineWidth = 1.2; + this.ctx.stroke(); + + const latest = trail[trail.length - 1]; + const latestAge = Math.max(0, now - Number(latest.ts || now)); + const latestProgress = 1 - Math.min(1, latestAge / this.incidentTrailWindowMs); + const latestX = chartX + 8 + latestProgress * (chartW - 16); + const latestY = chartY + chartH - 8 - (Math.max(0, Math.min(100, Number(latest.risk || 0))) / 100) * (chartH - 14); + this.ctx.beginPath(); + this.ctx.arc(latestX, latestY, 2.6, 0, Math.PI * 2); + this.ctx.fillStyle = 'rgba(175, 228, 255, 0.95)'; + this.ctx.fill(); + + this.ctx.fillStyle = 'rgba(154, 176, 206, 0.85)'; + this.ctx.font = '8px "Share Tech Mono", monospace'; + this.ctx.fillText(`incident trail ${Math.min(this.incidentTrailWindowMs / 1000, (now - Number(trail[0].ts || now)) / 1000).toFixed(1)}s`, chartX + 8, chartY + 12); + this.ctx.fillText(`risk ${Number(latest.risk || 0).toFixed(1)} ${String(latest.trust || '').toUpperCase()}`, chartX + chartW - 154, chartY + 12); + + // Thin halo markers from historical risk samples around the selected node. + if (selectedNode) { + const historyToDraw = trail.slice(-8); + const angle = Number(selectedNode.angle || -Math.PI / 2); + historyToDraw.forEach((entry, idx) => { + const riskNorm = Math.max(0, Math.min(1, Number(entry.risk || 0) / 100)); + const rr = selectedNode.innerRadius + (selectedNode.maxRadius - selectedNode.innerRadius) * (0.2 + riskNorm * 0.8); + const hx = selectedNode.cx + Math.cos(angle) * rr; + const hy = selectedNode.cy + Math.sin(angle) * rr; + const alpha = 0.16 + (idx / Math.max(1, historyToDraw.length)) * 0.45; + this.ctx.beginPath(); + this.ctx.arc(hx, hy, 1.1 + idx * 0.1, 0, Math.PI * 2); + this.ctx.fillStyle = `rgba(158, 219, 255, ${alpha.toFixed(3)})`; + this.ctx.fill(); + }); + } + } + + updateFocusStatusUi() { + const ringTone = { + policy: '#8bd0ff', + sandbox: '#8ff0bf', + threat: '#ff9aa6' + }; + if (this.focusStatusNode) { + if (!this.focusedRingKey) { + this.focusStatusNode.textContent = 'FOCUS: NONE'; + this.focusStatusNode.style.color = 'rgba(170, 188, 214, 0.88)'; + } else { + const ringLabel = String(this.focusedRingKey).toUpperCase(); + this.focusStatusNode.textContent = this.ringFocusPinned + ? `FOCUS: ${ringLabel} (PINNED)` + : `FOCUS: ${ringLabel}`; + this.focusStatusNode.style.color = ringTone[this.focusedRingKey] || 'rgba(170, 188, 214, 0.88)'; + } + } + if (this.unpinButton) { + const active = Boolean(this.ringFocusPinned && this.focusedRingKey); + this.unpinButton.style.background = active ? 'rgba(32, 52, 81, 0.92)' : 'rgba(8, 12, 18, 0.56)'; + this.unpinButton.style.borderColor = active ? 'rgba(124, 178, 255, 0.9)' : 'rgba(150, 164, 188, 0.22)'; + this.unpinButton.style.color = active ? '#d9ecff' : 'rgba(188, 200, 219, 0.55)'; + this.unpinButton.style.cursor = active ? 'pointer' : 'not-allowed'; + } } addExitButton() { @@ -239,6 +530,8 @@ class SecuritySubsystemVisualization { throw new Error(data?.error || 'No security data'); } this.telemetry = data; + const trustRows = Array.isArray(data?.trust_graph) ? data.trust_graph : []; + this.updateSelectedIncidentTrail(trustRows); }) .catch(() => { this.telemetry = { @@ -259,6 +552,14 @@ class SecuritySubsystemVisualization { const rect = this.canvas.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; + const ringHit = this.getRingHitAt(x, y); + if (ringHit) { + this.focusedRingKey = this.focusedRingKey === ringHit.key ? null : ringHit.key; + this.ringFocusPinned = false; + this.applyRingFocusVerdictFilter(); + this.updateFocusStatusUi(); + return; + } let matched = null; for (const node of this.trustNodeHitAreas) { const dx = x - node.x; @@ -281,6 +582,7 @@ class SecuritySubsystemVisualization { const pid = Number(matched.pid || 0); if (this.selectedProcessFilter && Number(this.selectedProcessFilter.pid || 0) === pid) { this.selectedProcessFilter = null; + this.selectedIncidentTrail = []; this.updateClearProcessButtonState(); return; } @@ -288,14 +590,73 @@ class SecuritySubsystemVisualization { pid, name: this.normalizeName(matched.name || 'unknown') }; + this.selectedIncidentTrail = []; this.updateClearProcessButtonState(); } + onCanvasDoubleClick(event) { + if (!this.canvas) return; + const rect = this.canvas.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + const ringHit = this.getRingHitAt(x, y); + if (!ringHit) return; + this.focusedRingKey = ringHit.key; + this.ringFocusPinned = true; + this.applyRingFocusVerdictFilter(); + this.updateFocusStatusUi(); + this.triggerPanelFocusFromRing(ringHit.key); + } + + applyRingFocusVerdictFilter() { + if (!this.focusedRingKey) { + this.ringDerivedVerdictFilter = null; + this.ringFocusPinned = false; + this.updateFocusStatusUi(); + return; + } + const map = { + policy: 'audit', + sandbox: 'allow', + threat: 'deny' + }; + this.ringDerivedVerdictFilter = map[this.focusedRingKey] || null; + this.updateFocusStatusUi(); + } + + triggerPanelFocusFromRing(ringKey) { + const map = { + policy: 'lsm', + sandbox: 'seccomp', + threat: 'attack' + }; + const panelKey = map[ringKey] || 'pipeline'; + this.panelSpotlight = { + panelKey, + startedAt: performance.now(), + durationMs: 2100 + }; + } + + getRingHitAt(x, y) { + for (const ring of this.ringHitAreas) { + const dx = x - ring.cx; + const dy = y - ring.cy; + const radiusNorm = Math.sqrt((dx * dx) / (ring.rx * ring.rx) + (dy * dy) / (ring.ry * ring.ry)); + if (radiusNorm >= ring.hitMin && radiusNorm <= ring.hitMax) { + return ring; + } + } + return null; + } + onCanvasMouseMove(event) { if (!this.canvas) return; const rect = this.canvas.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; + const ringHit = this.getRingHitAt(x, y); + this.hoveredRingKey = ringHit ? ringHit.key : null; let hovered = null; for (const node of this.trustNodeHitAreas) { const dx = x - node.x; @@ -315,7 +676,7 @@ class SecuritySubsystemVisualization { } } this.hoveredProcessPid = hovered; - this.canvas.style.cursor = hovered ? 'pointer' : 'default'; + this.canvas.style.cursor = (hovered || ringHit) ? 'pointer' : 'default'; } drawRoundedRect(x, y, w, h, r) { @@ -334,15 +695,28 @@ class SecuritySubsystemVisualization { ctx.closePath(); } - drawPanel(x, y, w, h, title) { + isPanelInFocusedRing(panelKey) { + if (!this.focusedRingKey) return true; + const map = { + policy: new Set(['lsm', 'core', 'pipeline']), + sandbox: new Set(['seccomp', 'capabilities', 'core']), + threat: new Set(['attack', 'trust', 'pipeline', 'core']) + }; + const set = map[this.focusedRingKey]; + if (!set) return true; + return set.has(panelKey); + } + + drawPanel(x, y, w, h, title, panelKey = 'generic') { this.drawRoundedRect(x, y, w, h, 8); - this.ctx.fillStyle = 'rgba(8, 11, 16, 0.88)'; + const active = this.isPanelInFocusedRing(panelKey); + this.ctx.fillStyle = active ? 'rgba(8, 11, 16, 0.9)' : 'rgba(8, 11, 16, 0.5)'; this.ctx.fill(); - this.ctx.strokeStyle = 'rgba(165, 178, 200, 0.35)'; + this.ctx.strokeStyle = active ? 'rgba(165, 178, 200, 0.4)' : 'rgba(110, 122, 140, 0.2)'; this.ctx.lineWidth = 0.95; this.ctx.stroke(); - this.ctx.fillStyle = '#d8e5f7'; + this.ctx.fillStyle = active ? '#d8e5f7' : 'rgba(180, 196, 218, 0.52)'; this.ctx.font = '13px "Share Tech Mono", monospace'; this.ctx.fillText(title, x + 14, y + 20); } @@ -362,18 +736,42 @@ class SecuritySubsystemVisualization { } drawDecisionPipeline(x, y, w, h, telemetry) { - this.drawPanel(x, y, w, h, 'THREAT DECISION PIPELINE'); + this.drawPanel(x, y, w, h, 'THREAT DECISION PIPELINE', 'pipeline'); const lanes = Array.isArray(telemetry?.pipeline?.lanes) ? telemetry.pipeline.lanes : []; + const effectiveVerdictFilter = this.ringDerivedVerdictFilter || this.verdictFilter; const selected = this.selectedProcessFilter; const filtered = lanes.filter((lane) => { - const verdictMatch = this.verdictFilter === 'all' || lane.verdict === this.verdictFilter; + const verdictMatch = effectiveVerdictFilter === 'all' || lane.verdict === effectiveVerdictFilter; if (!verdictMatch) return false; if (!selected) return true; const lanePid = Number(lane.pid || 0); const laneName = this.normalizeName(lane.process); return lanePid === Number(selected.pid || 0) || laneName === this.normalizeName(selected.name); }); - const laneRows = filtered.slice(0, 7); + const priorityScore = (lane) => { + const verdict = String(lane.verdict || '').toLowerCase(); + const hook = String(lane.hook || '').toLowerCase(); + if (this.focusedRingKey === 'threat') { + let score = verdict === 'deny' ? 100 : (verdict === 'audit' ? 75 : 40); + if (hook.includes('netfilter') || hook.includes('lsm')) score += 10; + return score; + } + if (this.focusedRingKey === 'sandbox') { + let score = hook.includes('seccomp') ? 95 : (hook.includes('cap') ? 82 : 55); + if (verdict === 'allow') score += 8; + return score; + } + if (this.focusedRingKey === 'policy') { + let score = hook.includes('lsm') ? 95 : (hook.includes('policy') ? 80 : 56); + if (verdict === 'audit') score += 10; + return score; + } + return 0; + }; + const laneRows = filtered + .slice() + .sort((a, b) => priorityScore(b) - priorityScore(a)) + .slice(0, 7); const stageXs = [x + 18, x + Math.floor(w * 0.44), x + Math.floor(w * 0.78)]; const stageW = 130; @@ -421,60 +819,174 @@ class SecuritySubsystemVisualization { if (selected) { this.ctx.fillText(`NO EVENTS FOR ${selected.name || 'PROCESS'} + FILTER`, x + 18, y + h - 16); } else { - this.ctx.fillText('NO EVENTS FOR CURRENT FILTER', x + 18, y + h - 16); + this.ctx.fillText(`NO EVENTS FOR CURRENT FILTER (${String(effectiveVerdictFilter).toUpperCase()})`, x + 18, y + h - 16); } + } else if (this.focusedRingKey) { + this.ctx.fillStyle = 'rgba(149, 189, 234, 0.82)'; + this.ctx.font = '9px "Share Tech Mono", monospace'; + this.ctx.fillText(`ring focus: ${this.focusedRingKey} / verdict ${String(effectiveVerdictFilter).toUpperCase()}`, x + 18, y + h - 16); } } drawTrustGraph(x, y, w, h, telemetry) { - this.drawPanel(x, y, w, h, 'PROCESS TRUST GRAPH'); + this.drawPanel(x, y, w, h, 'PROCESS TRUST GRAPH', 'trust'); this.trustNodeHitAreas = []; - const rows = Array.isArray(telemetry?.trust_graph) ? telemetry.trust_graph.slice(0, 10) : []; + const mode = this.trustGraphMode || 'topology'; + const nodeLimit = mode === 'forensics' ? 12 : 10; + const rows = Array.isArray(telemetry?.trust_graph) ? telemetry.trust_graph.slice(0, nodeLimit) : []; const cx = x + Math.floor(w * 0.5); - const cy = y + Math.floor(h * 0.56); - const radius = Math.min(w, h) * 0.28; + const cy = y + Math.floor(h * 0.58); + const maxRadius = Math.min(w, h) * 0.31; + const innerRadius = Math.max(30, Math.floor(maxRadius * 0.24)); + const now = performance.now(); + + const highRiskCount = rows.filter((row) => Number(row?.risk_score || 0) >= 70).length; + this.ctx.fillStyle = 'rgba(168, 186, 210, 0.82)'; + this.ctx.font = '9px "Share Tech Mono", monospace'; + this.ctx.fillText(`nodes ${rows.length}`, x + 14, y + 34); + this.ctx.fillStyle = highRiskCount > 0 ? 'rgba(255, 164, 170, 0.92)' : 'rgba(146, 226, 181, 0.86)'; + this.ctx.fillText(`high risk ${highRiskCount}`, x + 92, y + 34); + if (this.selectedProcessFilter) { + this.ctx.fillStyle = 'rgba(141, 197, 255, 0.92)'; + this.ctx.fillText(`selected pid ${Number(this.selectedProcessFilter.pid || 0)}`, x + 210, y + 34); + } + this.ctx.fillStyle = mode === 'risk' + ? 'rgba(255, 176, 155, 0.95)' + : (mode === 'forensics' ? 'rgba(168, 219, 255, 0.95)' : 'rgba(173, 194, 219, 0.86)'); + this.ctx.fillText(`mode ${String(mode).toUpperCase()}`, x + w - 122, y + 34); + if (mode === 'forensics') { + const freezeLeftSec = Math.max(0, (this.trustForensicsFreezeUntil - now) / 1000); + this.ctx.fillStyle = 'rgba(148, 198, 255, 0.88)'; + this.ctx.fillText(`freeze ${freezeLeftSec.toFixed(1)}s`, x + w - 208, y + 34); + } + + // Risk zones: inner = stable, middle = caution, outer = critical. + const zones = [ + { + r: maxRadius * 0.48, + color: mode === 'risk' ? 'rgba(142, 236, 181, 0.14)' : 'rgba(113, 222, 163, 0.12)' + }, + { + r: maxRadius * 0.76, + color: mode === 'risk' ? 'rgba(255, 213, 128, 0.16)' : 'rgba(244, 201, 119, 0.11)' + }, + { + r: maxRadius, + color: mode === 'risk' ? 'rgba(255, 136, 148, 0.15)' : 'rgba(255, 143, 152, 0.1)' + } + ]; + zones.forEach((zone) => { + this.ctx.beginPath(); + this.ctx.arc(cx, cy, zone.r, 0, Math.PI * 2); + this.ctx.fillStyle = zone.color; + this.ctx.fill(); + this.ctx.strokeStyle = 'rgba(162, 176, 198, 0.18)'; + this.ctx.lineWidth = 0.8; + this.ctx.stroke(); + }); this.ctx.beginPath(); - this.ctx.arc(cx, cy, radius + 24, 0, Math.PI * 2); - this.ctx.strokeStyle = 'rgba(162, 176, 198, 0.18)'; - this.ctx.stroke(); - this.ctx.beginPath(); - this.ctx.arc(cx, cy, radius, 0, Math.PI * 2); - this.ctx.strokeStyle = 'rgba(162, 176, 198, 0.32)'; - this.ctx.stroke(); - this.ctx.beginPath(); - this.ctx.arc(cx, cy, 26, 0, Math.PI * 2); - this.ctx.fillStyle = 'rgba(7, 10, 15, 0.9)'; + this.ctx.arc(cx, cy, innerRadius, 0, Math.PI * 2); + this.ctx.fillStyle = 'rgba(7, 10, 15, 0.92)'; this.ctx.fill(); - this.ctx.strokeStyle = 'rgba(162, 176, 198, 0.32)'; + this.ctx.strokeStyle = 'rgba(162, 176, 198, 0.34)'; + this.ctx.lineWidth = 1.1; this.ctx.stroke(); this.ctx.fillStyle = '#cde6ff'; this.ctx.font = '10px "Share Tech Mono", monospace'; this.ctx.fillText('LSM', cx - 10, cy + 3); + if (!rows.length) { + this.ctx.fillStyle = 'rgba(178, 195, 219, 0.75)'; + this.ctx.font = '10px "Share Tech Mono", monospace'; + this.ctx.fillText('no trust graph data', cx - 54, cy + maxRadius + 16); + this.trustForensicsLayout.clear(); + return; + } + + if (mode === 'forensics') { + const pidSet = new Set(rows.map((row) => Number(row?.pid || 0))); + let layoutMismatch = this.trustForensicsLayout.size !== pidSet.size; + if (!layoutMismatch) { + for (const pid of pidSet) { + if (!this.trustForensicsLayout.has(pid)) { + layoutMismatch = true; + break; + } + } + } + const shouldRebuild = now >= this.trustForensicsFreezeUntil || this.trustForensicsLayout.size === 0 || layoutMismatch; + if (shouldRebuild) { + this.trustForensicsLayout = this.buildForensicsLayout(rows, cx, cy, innerRadius, maxRadius); + this.trustForensicsFreezeUntil = now + this.trustForensicsFreezeWindowMs; + } + } + + const riskBandColor = (risk) => { + if (risk >= 70) return '#ff8f97'; + if (risk >= 45) return '#f4c977'; + return '#71dfa8'; + }; + let selectedNode = null; + rows.forEach((row, idx) => { - const a = ((Math.PI * 2) / Math.max(rows.length, 1)) * idx - Math.PI / 2; - const nx = cx + Math.cos(a) * radius; - const ny = cy + Math.sin(a) * radius; + const risk = Number(row.risk_score || 0); + const riskNorm = Math.max(0, Math.min(1, risk / 100)); + const pid = Number(row.pid || 0); + const forensicsEntry = mode === 'forensics' ? this.trustForensicsLayout.get(pid) : null; + const a = forensicsEntry + ? Number(forensicsEntry.angle || 0) + : (((Math.PI * 2) / Math.max(rows.length, 1)) * idx - Math.PI / 2); + const baseOrbit = mode === 'risk' + ? innerRadius + (maxRadius - innerRadius) * (0.2 + riskNorm * 0.8) + : innerRadius + (maxRadius - innerRadius) * (0.35 + riskNorm * 0.65); + const orbit = forensicsEntry + ? Number(forensicsEntry.orbit || baseOrbit) + : Math.max(innerRadius + 8, baseOrbit); + const nx = forensicsEntry + ? Number(forensicsEntry.nx || (cx + Math.cos(a) * orbit)) + : (cx + Math.cos(a) * orbit); + const ny = forensicsEntry + ? Number(forensicsEntry.ny || (cy + Math.sin(a) * orbit)) + : (cy + Math.sin(a) * orbit); const trust = String(row.trust || 'trusted'); - const color = this.trustColor(trust); + const color = mode === 'risk' ? riskBandColor(risk) : this.trustColor(trust); const pulse = 0.78 + 0.22 * Math.sin(this.tick * 0.04 + idx * 0.5); - const pid = Number(row.pid || 0); - const isSelected = this.selectedProcessFilter && Number(this.selectedProcessFilter.pid || 0) === pid; + const isSelected = Boolean(this.selectedProcessFilter) && ( + Number(this.selectedProcessFilter.pid || 0) === pid + || this.normalizeName(this.selectedProcessFilter.name || '') === this.normalizeName(row.name || '') + ); const isHovered = Number(this.hoveredProcessPid || 0) === pid; - const nodeR = isSelected ? 12 : (isHovered ? 11 : 10); - const glowR = isSelected ? 18 : (isHovered ? 15 : 0); + const isCritical = risk >= 70 || trust === 'blocked'; + const nodeR = isSelected ? 12 : (isHovered ? 11 : (isCritical ? 10 : (mode === 'forensics' ? 9.5 : 9))); + const glowR = isSelected ? 20 : (isHovered ? 16 : (isCritical ? 11 : 0)); + if (isSelected) { + selectedNode = { + cx, + cy, + angle: a, + innerRadius, + maxRadius + }; + } this.ctx.beginPath(); this.ctx.moveTo(cx, cy); this.ctx.lineTo(nx, ny); - this.ctx.strokeStyle = `rgba(155, 168, 190, ${0.20 + 0.15 * pulse})`; - this.ctx.lineWidth = isSelected ? 1.15 : 0.9; + this.ctx.strokeStyle = isCritical + ? `rgba(255, 147, 156, ${0.24 + 0.22 * pulse})` + : `rgba(155, 168, 190, ${0.18 + 0.14 * pulse})`; + this.ctx.lineWidth = isSelected ? 1.2 : 0.9; this.ctx.stroke(); if (glowR > 0) { const glow = this.ctx.createRadialGradient(nx, ny, 0, nx, ny, glowR); - glow.addColorStop(0, isSelected ? 'rgba(124, 178, 255, 0.38)' : 'rgba(138, 156, 234, 0.24)'); + glow.addColorStop( + 0, + isSelected + ? 'rgba(124, 178, 255, 0.4)' + : (isCritical ? 'rgba(255, 137, 149, 0.26)' : 'rgba(138, 156, 234, 0.22)') + ); glow.addColorStop(1, 'rgba(124, 178, 255, 0)'); this.ctx.beginPath(); this.ctx.arc(nx, ny, glowR, 0, Math.PI * 2); @@ -493,10 +1005,22 @@ class SecuritySubsystemVisualization { this.ctx.stroke(); this.ctx.lineWidth = 1; - this.ctx.fillStyle = '#cfdced'; + const rightSide = Math.cos(a) >= 0; + const labelX = rightSide ? (nx + 14) : (nx - (mode === 'forensics' ? 116 : 98)); + const labelY = ny + 18; + this.ctx.fillStyle = isSelected ? '#dff0ff' : 'rgba(207, 220, 237, 0.9)'; this.ctx.font = '9px "Share Tech Mono", monospace'; - const label = `${String(row.name || 'proc').slice(0, 9)} (${row.risk_score || 0})`; - this.ctx.fillText(label, nx - 32, ny + 21); + const label = `${String(row.name || 'proc').slice(0, 10)} #${pid}`; + const shouldShowLabel = mode === 'forensics' || isSelected || isHovered || isCritical; + if (shouldShowLabel) { + this.ctx.fillText(label, labelX, labelY); + this.ctx.fillStyle = isCritical ? 'rgba(255, 166, 173, 0.95)' : 'rgba(164, 186, 212, 0.86)'; + this.ctx.fillText(`risk ${risk.toFixed(0)} ${String(trust).toUpperCase()}`, labelX, labelY + 10); + if (mode === 'forensics') { + this.ctx.fillStyle = 'rgba(151, 173, 202, 0.8)'; + this.ctx.fillText(`orbit ${Math.round(orbit)} freeze ${(Math.max(0, this.trustForensicsFreezeUntil - now) / 1000).toFixed(1)}s`, labelX, labelY + 20); + } + } this.trustNodeHitAreas.push({ x: nx, @@ -506,10 +1030,12 @@ class SecuritySubsystemVisualization { name: row.name || 'unknown' }); }); + + this.drawIncidentTrail(x, y, w, h, selectedNode); } drawAttackSurface(x, y, w, h, telemetry) { - this.drawPanel(x, y, w, h, 'ATTACK SURFACE MAP'); + this.drawPanel(x, y, w, h, 'ATTACK SURFACE MAP', 'attack'); const rows = Array.isArray(telemetry?.attack_surface) ? telemetry.attack_surface.slice(0, 8) : []; rows.forEach((row, idx) => { @@ -534,7 +1060,7 @@ class SecuritySubsystemVisualization { } drawLsmStatusCard(x, y, w, h, telemetry) { - this.drawPanel(x, y, w, h, 'LSM STATUS MATRIX'); + this.drawPanel(x, y, w, h, 'LSM STATUS MATRIX', 'lsm'); const rows = Array.isArray(telemetry?.security_tools?.lsm_status) ? telemetry.security_tools.lsm_status.slice(0, 6) : []; @@ -558,7 +1084,7 @@ class SecuritySubsystemVisualization { } drawCapabilitiesCard(x, y, w, h, telemetry) { - this.drawPanel(x, y, w, h, 'CAPABILITIES DRIFT'); + this.drawPanel(x, y, w, h, 'CAPABILITIES DRIFT', 'capabilities'); this.capabilityRowHitAreas = []; const rows = Array.isArray(telemetry?.security_tools?.capabilities_drift) ? telemetry.security_tools.capabilities_drift.slice(0, 6) @@ -601,7 +1127,7 @@ class SecuritySubsystemVisualization { } drawSeccompCoverageCard(x, y, w, h, telemetry) { - this.drawPanel(x, y, w, h, 'SECCOMP COVERAGE'); + this.drawPanel(x, y, w, h, 'SECCOMP COVERAGE', 'seccomp'); const cov = telemetry?.security_tools?.seccomp_coverage || {}; const none = Number(cov.none || 0); const strict = Number(cov.strict || 0); @@ -637,8 +1163,186 @@ class SecuritySubsystemVisualization { }); } + drawGhostShellSecurityRings(x, y, w, h, telemetry) { + const ctx = this.ctx; + const clamp01 = (n) => Math.max(0, Math.min(1, Number(n) || 0)); + const cx = x + Math.floor(w * 0.5); + const cy = y + Math.floor(h * 0.49); + const baseRx = Math.max(120, Math.floor(w * 0.2)); + const baseRy = Math.max(42, Math.floor(h * 0.15)); + const t = this.tick * 0.013; + + const meta = telemetry?.meta || {}; + const core = telemetry?.security_core || {}; + const seccompCov = telemetry?.security_tools?.seccomp_coverage || {}; + const attackRows = Array.isArray(telemetry?.attack_surface) ? telemetry.attack_surface : []; + const capRows = Array.isArray(telemetry?.security_tools?.capabilities_drift) ? telemetry.security_tools.capabilities_drift : []; + const lsmEngines = Array.isArray(core.lsm_engines) ? core.lsm_engines : []; + + const decisionsPerSec = Number(meta.decisions_per_sec || 0); + const events = Math.max(1, Number(meta.events || 0)); + const blocked = Number(meta.blocked || 0); + const suspicious = Number(meta.suspicious || 0); + const enforcingCount = lsmEngines.filter((item) => String(item.status || '').toLowerCase() === 'enforcing').length; + const lsmCount = Math.max(1, lsmEngines.length); + const seccompCoverage = Number(seccompCov.coverage_percent || 0); + const unsandboxedCount = Array.isArray(seccompCov.high_risk_unsandboxed) ? seccompCov.high_risk_unsandboxed.length : 0; + const dangerousCapsCount = capRows.reduce((acc, row) => acc + Number(row?.dangerous_count || 0), 0); + const avgAttackRisk = attackRows.length > 0 + ? attackRows.reduce((acc, row) => acc + Number(row?.risk_score || 0), 0) / attackRows.length + : 0; + + const policyScore = clamp01((decisionsPerSec / 2400) * 0.55 + (enforcingCount / lsmCount) * 0.45); + const sandboxStrength = clamp01((seccompCoverage / 100) * 0.72 + (1 - clamp01((unsandboxedCount + dangerousCapsCount * 0.3) / 18)) * 0.28); + const threatPressure = clamp01(((blocked + suspicious * 0.55) / events) * 1.9 + (avgAttackRisk / 100) * 0.65 + (unsandboxedCount / 12) * 0.4); + + const verdict = threatPressure >= 0.72 + ? 'UNDER ATTACK' + : (sandboxStrength < 0.42 || threatPressure >= 0.46 ? 'DEGRADED' : 'SAFE'); + const verdictColor = verdict === 'UNDER ATTACK' ? '#ff8f98' : (verdict === 'DEGRADED' ? '#f4c977' : '#71dfa8'); + + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + + const rings = [ + { + key: 'policy', + label: 'POLICY', + score: policyScore, + speed: 0.32, + color: '126,196,255', + rx: baseRx * 0.88, + ry: baseRy * 0.9, + metric: `lsm ${enforcingCount}/${lsmCount} dec ${decisionsPerSec.toFixed(1)}/s` + }, + { + key: 'sandbox', + label: 'SANDBOX', + score: sandboxStrength, + speed: -0.26, + color: '120,230,174', + rx: baseRx * 1.16, + ry: baseRy * 1.3, + metric: `seccomp ${seccompCoverage.toFixed(1)}% unsbx ${unsandboxedCount}` + }, + { + key: 'threat', + label: 'THREAT', + score: threatPressure, + speed: 0.18, + color: '255,148,156', + rx: baseRx * 1.42, + ry: baseRy * 1.62, + metric: `risk ${avgAttackRisk.toFixed(1)} block ${blocked}` + } + ]; + + this.ringHitAreas = []; + rings.forEach((ring, idx) => { + const phase = t * ring.speed + idx * 1.27; + const isFocused = this.focusedRingKey === ring.key; + const isHovered = this.hoveredRingKey === ring.key; + const dimmed = Boolean(this.focusedRingKey) && !isFocused; + const alphaBase = 0.12 + ring.score * 0.24; + const arcSpan = 0.52 + ring.score * 2.2; + const emphasis = isFocused ? 1.25 : (isHovered ? 1.12 : 1); + const alphaMul = dimmed ? 0.32 : (isFocused ? 1.2 : 1); + + // Base orbit. + ctx.strokeStyle = `rgba(${ring.color}, ${Math.min(1, alphaBase * alphaMul).toFixed(3)})`; + ctx.lineWidth = (1 + ring.score * 1.6) * emphasis; + ctx.beginPath(); + ctx.ellipse(cx, cy, ring.rx, ring.ry, -0.08, 0, Math.PI * 2); + ctx.stroke(); + + // Informative arc indicating score magnitude. + ctx.strokeStyle = `rgba(${ring.color}, ${Math.min(1, (0.42 + ring.score * 0.5) * alphaMul).toFixed(3)})`; + ctx.lineWidth = (2 + ring.score * 2.4) * emphasis; + ctx.beginPath(); + ctx.ellipse(cx, cy, ring.rx, ring.ry, -0.08, phase, phase + arcSpan); + ctx.stroke(); + + // Orbiting probes. + for (let i = 0; i < 3; i++) { + const a = phase + i * 2.09; + const px = cx + Math.cos(a) * ring.rx; + const py = cy + Math.sin(a) * ring.ry; + ctx.fillStyle = `rgba(${ring.color}, ${Math.min(1, (0.3 + i * 0.16) * alphaMul).toFixed(3)})`; + ctx.beginPath(); + ctx.arc(px, py, (1.3 + i * 0.55) * emphasis, 0, Math.PI * 2); + ctx.fill(); + } + + // Ring label + core metric near arc. + const lx = cx + Math.cos(phase + arcSpan + 0.16) * (ring.rx + 12); + const ly = cy + Math.sin(phase + arcSpan + 0.16) * (ring.ry + 12); + ctx.fillStyle = `rgba(${ring.color}, ${Math.min(1, 0.86 * alphaMul + (isFocused ? 0.12 : 0)).toFixed(3)})`; + ctx.font = '9px "Share Tech Mono", monospace'; + ctx.fillText(`${ring.label} ${(ring.score * 100).toFixed(0)}%`, lx, ly); + ctx.fillStyle = `rgba(${ring.color}, ${Math.min(1, 0.54 * alphaMul).toFixed(3)})`; + ctx.font = '8px "Share Tech Mono", monospace'; + ctx.fillText(ring.metric, lx, ly + 10); + + this.ringHitAreas.push({ + key: ring.key, + cx, + cy, + rx: ring.rx, + ry: ring.ry, + hitMin: 0.84, + hitMax: 1.18 + }); + }); + + // Center verdict core. + this.drawRoundedRect(cx - 118, cy - 40, 236, 74, 10); + ctx.fillStyle = 'rgba(8, 13, 20, 0.64)'; + ctx.fill(); + ctx.strokeStyle = 'rgba(150, 178, 214, 0.42)'; + ctx.lineWidth = 1.2; + ctx.stroke(); + ctx.fillStyle = 'rgba(164, 190, 223, 0.92)'; + ctx.font = '9px "Share Tech Mono", monospace'; + ctx.fillText('RING-FIRST SECURITY VERDICT', cx - 96, cy - 20); + ctx.fillStyle = verdictColor; + ctx.font = '15px "Share Tech Mono", monospace'; + ctx.fillText(verdict, cx - 66, cy + 2); + ctx.fillStyle = 'rgba(176, 198, 228, 0.8)'; + ctx.font = '8px "Share Tech Mono", monospace'; + ctx.fillText(`threat ${(threatPressure * 100).toFixed(0)}% sandbox ${(sandboxStrength * 100).toFixed(0)}% policy ${(policyScore * 100).toFixed(0)}%`, cx - 108, cy + 18); + + // Ambient data noise strings around outer ring. + const tokenPool = [ + `deny:${blocked}`, + `sus:${suspicious}`, + `risk:${avgAttackRisk.toFixed(0)}`, + `sec:${seccompCoverage.toFixed(0)}%`, + `dec:${decisionsPerSec.toFixed(1)}/s`, + `unsbx:${unsandboxedCount}`, + 'lsm', + 'seccomp', + 'cap', + 'audit' + ]; + ctx.font = '8px "Share Tech Mono", monospace'; + ctx.fillStyle = 'rgba(150, 216, 255, 0.5)'; + for (let i = 0; i < 12; i++) { + const token = tokenPool[(i + Math.floor(this.tick / 8)) % tokenPool.length]; + const a = t * 0.29 + i * (Math.PI * 2 / 12); + const rx = baseRx * 1.62; + const ry = baseRy * 1.92; + const px = cx + Math.cos(a) * rx; + const py = cy + Math.sin(a) * ry; + ctx.fillText(token, px, py); + } + ctx.fillStyle = 'rgba(170, 194, 224, 0.68)'; + ctx.fillText('click ring to focus related panels', cx - 90, cy + baseRy * 1.98); + + ctx.restore(); + } + drawSecurityCore(x, y, w, h, telemetry) { - this.drawPanel(x, y, w, h, 'KERNEL SECURITY CORE'); + this.drawPanel(x, y, w, h, 'KERNEL SECURITY CORE', 'core'); const core = telemetry?.security_core || {}; const lsmEngines = Array.isArray(core.lsm_engines) ? core.lsm_engines : []; const seccompProcs = Array.isArray(core.seccomp_processes) ? core.seccomp_processes : []; @@ -793,6 +1497,35 @@ class SecuritySubsystemVisualization { } } + drawPanelSpotlight() { + if (!this.panelSpotlight || !this.ctx) return; + const rect = this.panelRects[this.panelSpotlight.panelKey]; + if (!rect) return; + const now = performance.now(); + const elapsed = now - Number(this.panelSpotlight.startedAt || now); + const duration = Math.max(800, Number(this.panelSpotlight.durationMs || 1800)); + if (elapsed >= duration) { + this.panelSpotlight = null; + return; + } + + const t = elapsed / duration; + const pulse = 0.5 + 0.5 * Math.sin(elapsed * 0.012); + const alpha = (1 - t) * (0.34 + pulse * 0.24); + const expand = 4 + pulse * 7; + const x = rect.x - expand; + const y = rect.y - expand; + const w = rect.w + expand * 2; + const h = rect.h + expand * 2; + + this.ctx.save(); + this.drawRoundedRect(x, y, w, h, 10); + this.ctx.strokeStyle = `rgba(154, 220, 255, ${alpha.toFixed(3)})`; + this.ctx.lineWidth = 2.1 + pulse * 1.3; + this.ctx.stroke(); + this.ctx.restore(); + } + drawScene() { if (!this.ctx || !this.canvas) return; const w = window.innerWidth; @@ -851,14 +1584,27 @@ class SecuritySubsystemVisualization { const tools2X = leftX + toolsW + gap; const tools3X = tools2X + toolsW + gap; const coreY = toolsY + toolsH + gap; + this.panelRects = { + pipeline: { x: leftX, y: panelTop, w: leftW, h: panelH }, + trust: { x: centerX, y: panelTop, w: centerW, h: panelH }, + attack: { x: rightX, y: panelTop, w: rightW, h: panelH }, + lsm: { x: leftX, y: toolsY, w: toolsW, h: toolsH }, + capabilities: { x: tools2X, y: toolsY, w: toolsW, h: toolsH }, + seccomp: { x: tools3X, y: toolsY, w: toolsW, h: toolsH }, + core: { x: leftX, y: coreY, w: w - gap * 2, h: coreH } + }; + this.drawGhostShellSecurityRings(leftX, coreY, w - gap * 2, coreH, this.telemetry); this.drawDecisionPipeline(leftX, panelTop, leftW, panelH, this.telemetry); this.drawTrustGraph(centerX, panelTop, centerW, panelH, this.telemetry); this.drawAttackSurface(rightX, panelTop, rightW, panelH, this.telemetry); this.drawLsmStatusCard(leftX, toolsY, toolsW, toolsH, this.telemetry); this.drawCapabilitiesCard(tools2X, toolsY, toolsW, toolsH, this.telemetry); this.drawSeccompCoverageCard(tools3X, toolsY, toolsW, toolsH, this.telemetry); - this.drawSecurityCore(leftX, coreY, w - gap * 2, coreH, this.telemetry); + if (this.showSecurityCorePanel) { + this.drawSecurityCore(leftX, coreY, w - gap * 2, coreH, this.telemetry); + } + this.drawPanelSpotlight(); } animate() {