diff --git a/index.html b/index.html index 1a357ff..2cc82d3 100755 --- a/index.html +++ b/index.html @@ -37,7 +37,7 @@ - + @@ -91,8 +91,8 @@

Linux Kernel Ring 0 Visualization

- - + + @@ -101,7 +101,8 @@

Linux Kernel Ring 0 Visualization

- + + @@ -114,7 +115,7 @@

Linux Kernel Ring 0 Visualization

console.error('❌ RightSemicircleMenuManager class NOT loaded!'); } - + diff --git a/kernel_ai/api/rest.py b/kernel_ai/api/rest.py index 016fc3b..35c92af 100644 --- a/kernel_ai/api/rest.py +++ b/kernel_ai/api/rest.py @@ -31,6 +31,7 @@ ("/ml-anomalies", "ml_anomalies", h.ml_anomalies, None), ("/ml-drift", "ml_drift", h.ml_drift, None), ("/crypto-realtime", "crypto_realtime", h.crypto_realtime, None), + ("/crypto-aes-demo", "crypto_aes_demo", h.crypto_aes_demo, None), ("/security-realtime", "security_realtime", h.security_realtime, None), ("/processes-realtime", "processes_realtime", h.processes_realtime, None), ("/frontend-logs", "ingest_frontend_logs", h.ingest_frontend_logs, ["POST", "OPTIONS"]), diff --git a/kernel_ai/http/api.py b/kernel_ai/http/api.py index 61486a2..3f009a5 100644 --- a/kernel_ai/http/api.py +++ b/kernel_ai/http/api.py @@ -36,6 +36,7 @@ processes_realtime, ) from kernel_ai.http.api_handlers.security_logs import ( + crypto_aes_demo, crypto_realtime, ingest_frontend_logs, security_realtime, @@ -43,6 +44,7 @@ __all__ = [ "active_connections", + "crypto_aes_demo", "crypto_realtime", "devices_realtime", "filesystem_blocks", diff --git a/kernel_ai/http/api_handlers/security_logs.py b/kernel_ai/http/api_handlers/security_logs.py index 0fcdbe5..eec91d8 100644 --- a/kernel_ai/http/api_handlers/security_logs.py +++ b/kernel_ai/http/api_handlers/security_logs.py @@ -27,6 +27,10 @@ def crypto_realtime(): ) +def crypto_aes_demo(): + return api_json(lambda: _crypto_security_service.collect_aes_demo()) + + def security_realtime(): state = get_state_container(current_app) return api_json( diff --git a/kernel_ai/services/core_observability.py b/kernel_ai/services/core_observability.py index 2383b3d..ee5552a 100644 --- a/kernel_ai/services/core_observability.py +++ b/kernel_ai/services/core_observability.py @@ -13,8 +13,8 @@ logger = logging.getLogger(__name__) -# Cached counters for per-second I/O pulse deltas (vmstat + disk_io). -_IO_PULSE_PREV = {"ts": None, "vmstat": {}, "disk": None} +# Cached counters for per-second I/O pulse deltas (vmstat + disk_io + net + irq). +_IO_PULSE_PREV = {"ts": None, "vmstat": {}, "disk": None, "net": None, "intr": None} def get_system_info(): @@ -180,6 +180,18 @@ def _read_vmstat(): return out +def _read_intr_total(): + """Total hardware interrupts serviced since boot (from /proc/stat).""" + try: + with open("/proc/stat", "r", encoding="utf-8", errors="ignore") as f: + for line in f: + if line.startswith("intr "): + return int(line.split()[1]) + except (OSError, ValueError, IndexError): + pass + return 0 + + def _io_pulse_zero(): return { "pgfault_per_sec": 0, @@ -190,6 +202,8 @@ def _io_pulse_zero(): "disk_write_mb_s": 0.0, "disk_read_iops": 0, "disk_write_iops": 0, + "net_mb_s": 0.0, + "intr_per_sec": 0, } @@ -208,16 +222,25 @@ def get_io_pulse(): disk = psutil.disk_io_counters() except (psutil.Error, OSError): disk = None + try: + net = psutil.net_io_counters() + except (psutil.Error, OSError): + net = None + intr = _read_intr_total() prev = _IO_PULSE_PREV prev_ts = prev.get("ts") prev_vm = prev.get("vmstat") or {} prev_disk = prev.get("disk") + prev_net = prev.get("net") + prev_intr = prev.get("intr") # Update cache for next call. _IO_PULSE_PREV["ts"] = now _IO_PULSE_PREV["vmstat"] = vmstat _IO_PULSE_PREV["disk"] = disk + _IO_PULSE_PREV["net"] = net + _IO_PULSE_PREV["intr"] = intr if prev_ts is None: return _io_pulse_zero() @@ -245,6 +268,13 @@ def vm_rate(key): result["disk_read_iops"] = max(0, int((disk.read_count - prev_disk.read_count) / dt)) result["disk_write_iops"] = max(0, int((disk.write_count - prev_disk.write_count) / dt)) + if net is not None and prev_net is not None: + net_delta = (net.bytes_sent - prev_net.bytes_sent) + (net.bytes_recv - prev_net.bytes_recv) + result["net_mb_s"] = round(max(0.0, net_delta / dt) / (1024 * 1024), 3) + + if prev_intr is not None: + result["intr_per_sec"] = max(0, int((intr - prev_intr) / dt)) + return result except (OSError, ValueError, psutil.Error) as exc: log_event( diff --git a/kernel_ai/services/crypto/crypto_pipeline.py b/kernel_ai/services/crypto/crypto_pipeline.py index 7d3ca1d..c851f5b 100644 --- a/kernel_ai/services/crypto/crypto_pipeline.py +++ b/kernel_ai/services/crypto/crypto_pipeline.py @@ -20,6 +20,8 @@ def collect_crypto_realtime(crypto_prev, callbacks=None, psutil_module=None, log infer_tls_terminator_fn = callbacks.get("infer_tls_terminator", lambda _name, _port, _proto, _listeners: "n/a") collect_algorithm_competition_fn = callbacks.get("collect_algorithm_competition", lambda _algo: {"request": "AES", "implementations": [], "selected": None}) parse_proc_crypto_entries_fn = callbacks.get("parse_proc_crypto_entries", lambda: []) + read_cpu_crypto_flags_fn = callbacks.get("read_cpu_crypto_flags", lambda: {"detected": [], "display": [], "aes_ni": False, "source": "unavailable"}) + read_kernel_crypto_ops_fn = callbacks.get("read_kernel_crypto_ops", lambda: {"available": False}) collect_kernel_crypto_clients_fn = callbacks.get("collect_kernel_crypto_clients", lambda _items: []) collect_hw_offload_status_fn = callbacks.get("collect_hw_offload_status", lambda _entries, _comps: []) collect_sync_async_queue_fn = callbacks.get("collect_sync_async_queue", lambda _items: {}) @@ -185,6 +187,19 @@ def collect_crypto_realtime(crypto_prev, callbacks=None, psutil_module=None, log else: ops_per_sec = round(active_flows * 90, 2) + # Real network throughput (proxy for encrypted traffic volume on a TLS host). + try: + net_counters = psutil_module.net_io_counters() + net_bytes_now = int(getattr(net_counters, "bytes_sent", 0) or 0) + int(getattr(net_counters, "bytes_recv", 0) or 0) + except Exception: # noqa: BLE001 - psutil backends vary across kernels + net_bytes_now = 0 + prev_net_bytes = crypto_prev.get("net_bytes") + if prev_ts and prev_net_bytes is not None and net_bytes_now: + net_mb_s = round(max(net_bytes_now - int(prev_net_bytes), 0) / max(now - prev_ts, 0.001) / (1024.0 * 1024.0), 3) + else: + net_mb_s = 0.0 + crypto_prev["net_bytes"] = net_bytes_now + unique_processes = [] for item in items: p = item["process"] @@ -297,6 +312,156 @@ def _zone(zone_id, label, active, status, evidence, strength=0.5): ), ] + # --- Real CPU crypto flags --------------------------------------------- + cpu_flags = read_cpu_crypto_flags_fn() or {} + + # --- Real kernel crypto operations (Tier 3, kprobe collector) ---------- + kernel_ops = read_kernel_crypto_ops_fn() or {"available": False} + kernel_ops_available = bool(kernel_ops.get("available")) + kernel_totals = kernel_ops.get("totals") or {} + kernel_by_driver = kernel_ops.get("by_driver") or [] + + # --- Selected kernel crypto algorithms (from /proc/crypto competition) -- + tls_sessions = sum(1 for i in items if i.get("protocol") == "TLS") + family_labels = {"aes": "AES", "sha": "SHA-2", "chacha20": "ChaCha20"} + + def _norm_driver(name): + # Normalise driver names so registry-selected wrappers match observed leaf + # drivers, e.g. "cryptd(__xts-aes-aesni)" and "__xts-aes-aesni" -> "xts-aes-aesni". + core = str(name or "").lower().replace("cryptd(", "").replace(")", "").strip() + return core.lstrip("_") + + observed_ops_norm = {} + for row in kernel_by_driver: + drv = _norm_driver(row.get("driver")) + if drv: + observed_ops_norm[drv] = observed_ops_norm.get(drv, 0.0) + float(row.get("ops_per_sec") or 0.0) + + def _observed_for(driver_name): + norm = _norm_driver(driver_name) + if not norm: + return 0.0 + total = 0.0 + for obs_name, obs_ops in observed_ops_norm.items(): + if norm == obs_name or norm in obs_name or obs_name in norm: + total += obs_ops + return total + + active_algorithms = [] + for family_key in ("aes", "sha", "chacha20"): + comp = algorithm_competitions.get(family_key) or {} + selected = comp.get("selected") or {} + driver_name = str(selected.get("name") or "n/a") + observed = _observed_for(driver_name) if driver_name != "n/a" else 0.0 + if driver_name == "n/a": + status = "idle" + elif observed > 0: + status = "executing" + else: + status = "selected" + active_algorithms.append( + { + "family": family_labels.get(family_key, family_key.upper()), + "driver": driver_name, + "priority": selected.get("priority"), + "source": str(selected.get("source") or "kernel"), + "status": status, + "observed_ops_per_sec": round(observed, 1) if observed else 0.0, + } + ) + + # --- Kernel crypto metrics (real / procfs-derived) --------------------- + entropy_bits = int((entropy_cloud or {}).get("entropy_pool_bits") or 0) + entropy_size = int((entropy_cloud or {}).get("entropy_pool_size_bits") or 256) or 256 + entropy_pct = round(100.0 * entropy_bits / entropy_size, 1) + crng_state = str((entropy_cloud or {}).get("crng_state") or "unknown") + rng_health = "good" if crng_state.lower() in {"ready", "initialized", "crng_ready"} else "warming" + hw_offload_rows = crypto_stage1.get("hw_offload") or [] + aes_engine_active = any( + "aes-ni" in str(row.get("engine", "")).lower() and row.get("status") == "active" + for row in hw_offload_rows + ) + if bool(cpu_flags.get("aes_ni")): + aes_ni_status = "active" if aes_engine_active else "available" + else: + aes_ni_status = "n/a" + queue_meta = crypto_stage1.get("sync_async") or {} + # Prefer real kernel-measured throughput/latency/ops when the collector is live. + if kernel_ops_available: + kernel_ops_per_sec = float(kernel_totals.get("ops_per_sec") or 0.0) + kernel_mb_s = float(kernel_totals.get("mb_per_sec") or 0.0) + kernel_lat_us = kernel_totals.get("avg_lat_us") + crypto_latency_ms = round(float(kernel_lat_us) / 1000.0, 4) if kernel_lat_us is not None else queue_meta.get("queue_latency_ms_est") + else: + kernel_ops_per_sec = None + kernel_mb_s = None + crypto_latency_ms = queue_meta.get("queue_latency_ms_est") + crypto_metrics = { + "entropy_pool_bits": entropy_bits, + "entropy_pool_size_bits": entropy_size, + "entropy_pct": entropy_pct, + "crng_state": crng_state, + "rng_health": rng_health, + "aes_ni_status": aes_ni_status, + "aes_ni_available": bool(cpu_flags.get("aes_ni")), + "latency_ms": crypto_latency_ms, + "net_mb_s": net_mb_s, + "ops_per_sec": ops_per_sec, + "tls_sessions": tls_sessions, + "active_flows": active_flows, + # Real kernel crypto (None when collector is not running) + "kernel_ops_available": kernel_ops_available, + "kernel_ops_per_sec": kernel_ops_per_sec, + "kernel_mb_s": kernel_mb_s, + } + + # --- Rolling, change-driven crypto event log --------------------------- + def _now_hms(): + return datetime.now().strftime("%H:%M:%S") + + prev_selected = crypto_prev.get("selected_drivers") or {} + current_selected = {a["family"]: a["driver"] for a in active_algorithms if a["driver"] != "n/a"} + new_events = [] + for family_name, driver_name in current_selected.items(): + if prev_selected.get(family_name) != driver_name: + new_events.append({"ts": _now_hms(), "tag": family_name.lower(), "msg": f"{driver_name}: selected"}) + crypto_prev["selected_drivers"] = current_selected + + if crypto_prev.get("crng_state_seen") != crng_state: + new_events.append({"ts": _now_hms(), "tag": "random", "msg": f"crng -> {crng_state} ({entropy_bits}/{entropy_size}b)"}) + crypto_prev["crng_state_seen"] = crng_state + + if prev_ts and active_flows != prev_flows: + delta_flows = active_flows - prev_flows + sign = "+" if delta_flows > 0 else "" + new_events.append({"ts": _now_hms(), "tag": "flows", "msg": f"crypto actors {sign}{delta_flows} -> {active_flows}"}) + + if kernel_ops_available and kernel_by_driver: + top_kernel = kernel_by_driver[0] + top_key = f"{top_kernel.get('op')}|{top_kernel.get('driver')}" + if crypto_prev.get("top_kernel_op") != top_key: + new_events.append({ + "ts": _now_hms(), + "tag": "kops", + "msg": f"{top_kernel.get('driver')} {top_kernel.get('op')} @ {top_kernel.get('ops_per_sec')}/s", + }) + crypto_prev["top_kernel_op"] = top_key + + active_engines = [str(row.get("engine")) for row in hw_offload_rows if row.get("status") == "active"] + prev_engines = crypto_prev.get("hw_engines_seen") + if prev_engines is None or set(active_engines) != set(prev_engines): + if active_engines: + new_events.append({"ts": _now_hms(), "tag": "offload", "msg": f"hw: {', '.join(active_engines)}"[:44]}) + crypto_prev["hw_engines_seen"] = active_engines + + prev_log = list(crypto_prev.get("event_log") or []) + if new_events: + prev_log = (new_events + prev_log)[:40] + if not prev_log: + prev_log = [{"ts": _now_hms(), "tag": "crypto", "msg": "crypto telemetry online"}] + crypto_prev["event_log"] = prev_log + event_log = prev_log[:12] + return { "items": items[:24], "processes": unique_processes[:16], @@ -304,6 +469,12 @@ def _zone(zone_id, label, active, status, evidence, strength=0.5): "runtime_sources": runtime_sources, "meta": { "ops_per_sec": ops_per_sec, + "net_mb_s": net_mb_s, + "cpu_flags": cpu_flags, + "crypto_metrics": crypto_metrics, + "active_algorithms": active_algorithms, + "kernel_ops": kernel_ops, + "event_log": event_log, "tls_sessions": sum(1 for i in items if i.get("protocol") == "TLS"), "active_flows": active_flows, "unknown_pid_flows": int(unknown_pid_flows), diff --git a/kernel_ai/services/crypto/helpers.py b/kernel_ai/services/crypto/helpers.py index 01936af..dd2799a 100644 --- a/kernel_ai/services/crypto/helpers.py +++ b/kernel_ai/services/crypto/helpers.py @@ -2,8 +2,44 @@ from __future__ import annotations +import json +import os +import time from pathlib import Path +KERNEL_OPS_PATH = os.environ.get("CRYPTO_OPS_OUT", "/run/kernel-ai/crypto_ops.json") +KERNEL_OPS_MAX_AGE_S = 15.0 + + +def read_kernel_crypto_ops(path=None, max_age_s=KERNEL_OPS_MAX_AGE_S): + """Read the real kernel crypto operations snapshot written by the kprobe collector. + + Returns a dict with ``available`` flag; when the collector is not running (or the + snapshot is stale) ``available`` is False and callers fall back to heuristics. + """ + snapshot_path = path or KERNEL_OPS_PATH + try: + raw_mtime = os.path.getmtime(snapshot_path) + except OSError: + return {"available": False, "reason": "no-collector"} + + age = max(0.0, time.time() - raw_mtime) + if age > max_age_s: + return {"available": False, "reason": "stale", "age_s": round(age, 1)} + + try: + with open(snapshot_path, "r", encoding="utf-8", errors="ignore") as fh: + data = json.load(fh) + except (OSError, ValueError): + return {"available": False, "reason": "unreadable"} + + if not isinstance(data, dict): + return {"available": False, "reason": "malformed"} + + data["available"] = True + data["age_s"] = round(age, 1) + return data + def _read_text(path): try: @@ -76,6 +112,56 @@ def infer_tls_terminator(process_name, local_port, protocol, tls_listener_names) return "upstream-or-external-lb" +_CPU_CRYPTO_FLAGS = [ + "aes", + "pclmulqdq", + "sha_ni", + "vaes", + "vpclmulqdq", + "gfni", + "avx", + "avx2", + "avx512f", + "rdrand", + "rdseed", + # ARM feature names (lower-cased by the kernel in /proc/cpuinfo Features) + "aes", + "pmull", + "sha1", + "sha2", +] + + +def read_cpu_crypto_flags(): + """Read crypto-relevant CPU flags from /proc/cpuinfo (x86 'flags' or ARM 'Features').""" + text = _read_text("/proc/cpuinfo") + flags_line = "" + for line in text.splitlines(): + low = line.lower() + if (low.startswith("flags") or low.startswith("features")) and ":" in line: + flags_line = line.split(":", 1)[1] + break + present = set(flags_line.lower().split()) + detected = [] + seen = set() + for flag in _CPU_CRYPTO_FLAGS: + if flag in seen: + continue + seen.add(flag) + if flag in present: + detected.append(flag) + display = [f.upper().replace("_", "-") for f in detected] + return { + "detected": detected, + "display": display, + "aes_ni": "aes" in present, + "pclmulqdq": "pclmulqdq" in present or "pmull" in present, + "sha_ni": "sha_ni" in present or "sha2" in present or "sha256" in present, + "vaes": "vaes" in present, + "source": "procfs" if flags_line else "unavailable", + } + + def parse_proc_crypto_entries(): """Parse /proc/crypto into a list of dict entries.""" entries = [] diff --git a/kernel_ai/services/crypto_security.py b/kernel_ai/services/crypto_security.py index 4222467..8a514ad 100644 --- a/kernel_ai/services/crypto_security.py +++ b/kernel_ai/services/crypto_security.py @@ -9,6 +9,7 @@ import psutil +from kernel_ai.services.crypto import aes_math as _aes_math from kernel_ai.services.crypto import crypto_pipeline as _crypto_pipeline from kernel_ai.services.crypto import entropy as _entropy_service from kernel_ai.services.crypto import helpers as _helpers @@ -21,6 +22,8 @@ "is_likely_crypto_actor", "infer_tls_terminator", "parse_proc_crypto_entries", + "read_cpu_crypto_flags", + "read_kernel_crypto_ops", "collect_algorithm_competition", "collect_kernel_crypto_clients", "collect_sync_async_queue", @@ -33,6 +36,7 @@ "collect_entropy_cloud_status", "collect_crypto_realtime", "collect_security_realtime", + "collect_aes_demo", ] # Re-export helpers for backward compatibility and callback injection. @@ -40,6 +44,8 @@ is_likely_crypto_actor = _helpers.is_likely_crypto_actor infer_tls_terminator = _helpers.infer_tls_terminator parse_proc_crypto_entries = _helpers.parse_proc_crypto_entries +read_cpu_crypto_flags = _helpers.read_cpu_crypto_flags +read_kernel_crypto_ops = _helpers.read_kernel_crypto_ops collect_algorithm_competition = _helpers.collect_algorithm_competition collect_kernel_crypto_clients = _helpers.collect_kernel_crypto_clients collect_sync_async_queue = _helpers.collect_sync_async_queue @@ -76,6 +82,8 @@ def collect_crypto_realtime(crypto_prev, entropy_prev=None, callbacks=None): "infer_tls_terminator": infer_tls_terminator, "collect_algorithm_competition": collect_algorithm_competition, "parse_proc_crypto_entries": parse_proc_crypto_entries, + "read_cpu_crypto_flags": read_cpu_crypto_flags, + "read_kernel_crypto_ops": read_kernel_crypto_ops, "collect_kernel_crypto_clients": collect_kernel_crypto_clients, "collect_hw_offload_status": collect_hw_offload_status, "collect_sync_async_queue": collect_sync_async_queue, @@ -95,6 +103,11 @@ def collect_crypto_realtime(crypto_prev, entropy_prev=None, callbacks=None): ) +def collect_aes_demo(): + """Real AES-128 internals on demo vectors for the crypto visualisation.""" + return _aes_math.build_aes_demo() + + def collect_security_realtime(security_prev): return _security_pipeline.collect_security_realtime( security_prev=security_prev, diff --git a/static/css/main.css b/static/css/main.css index 1ea760e..d029458 100755 --- a/static/css/main.css +++ b/static/css/main.css @@ -185,20 +185,50 @@ svg { max-width: 240px; box-shadow: 0 6px 22px rgba(0, 0, 0, 0.35); } -.ns-hud-head { - font-size: 10px; - letter-spacing: 1.6px; - color: #ffffff; - padding-bottom: 5px; +.ns-hud-top { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 10px; + padding-bottom: 6px; margin-bottom: 6px; border-bottom: 1px solid rgba(120, 126, 138, 0.35); } +.ns-hud-over { + font-size: 7.5px; + letter-spacing: 1.8px; + color: #7f8794; +} +.ns-hud-name { + font-size: 13px; + letter-spacing: 1.6px; + color: #ffffff; + line-height: 1.15; +} +.ns-hud-pill { + flex: none; + margin-top: 2px; + font-size: 7.5px; + letter-spacing: 1px; + padding: 2px 7px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.22); + color: #aeb4c0; + white-space: nowrap; +} +.ns-hud-pill.is-iso { + background: rgba(88, 182, 216, 0.16); + border-color: rgba(88, 182, 216, 0.7); + color: #7fd0ea; +} .ns-hud-desc { font-size: 9.5px; line-height: 1.35; color: #aab0bc; margin-bottom: 7px; } +.ns-hud-rows { margin: 6px 0; } .ns-hud-row { display: flex; justify-content: space-between; @@ -221,12 +251,14 @@ svg { background: rgba(88, 182, 216, 0.85); } .ns-hud-foot { - margin-top: 4px; + display: flex; + justify-content: space-between; + margin-top: 5px; font-size: 8px; letter-spacing: 1.2px; color: #7f8794; - text-align: right; } +.ns-hud-hint { color: #7fd0ea; } /* Marker for namespace cells that contain real isolation (unique_count > 1) */ .ns-isolated-marker { @@ -253,6 +285,38 @@ svg { font-size: 10px; letter-spacing: 1.5px; } +.ns-tree-pill rect { + fill: rgba(255, 255, 255, 0.08); + stroke: rgba(255, 255, 255, 0.24); + stroke-width: 1; +} +.ns-tree-pill text { + fill: #aeb4c0; + font-family: 'Share Tech Mono', monospace; + font-size: 7.5px; + letter-spacing: 1px; +} +.ns-tree-pill.is-iso rect { + fill: rgba(88, 182, 216, 0.18); + stroke: rgba(88, 182, 216, 0.7); +} +.ns-tree-pill.is-iso text { fill: #7fd0ea; } +.ns-tree-close { + fill: #8b909c; + font-family: 'Share Tech Mono', monospace; + font-size: 15px; +} +.ns-tree-close:hover { fill: #ffffff; } +.ns-tree-track { fill: rgba(255, 255, 255, 0.12); } +.ns-tree-track-fill { fill: rgba(150, 156, 168, 0.6); } +.ns-tree-track-fill.is-iso { fill: rgba(88, 182, 216, 0.85); } +.ns-tree-divider { stroke: rgba(120, 126, 138, 0.3); stroke-width: 1; } +.ns-tree-foot { + fill: rgba(160, 166, 178, 0.7); + font-family: 'Share Tech Mono', monospace; + font-size: 7.5px; + letter-spacing: 0.8px; +} .ns-tree-link { stroke: rgba(150, 156, 168, 0.5); stroke-width: 1; fill: none; } .ns-tree-root rect { fill: rgba(88, 182, 216, 0.9); @@ -277,6 +341,11 @@ svg { font-size: 9px; letter-spacing: 1px; } +.ns-tree-branch.is-iso rect { + fill: rgba(88, 182, 216, 0.14); + stroke: rgba(88, 182, 216, 0.6); +} +.ns-tree-branch.is-iso text { fill: #dff0f7; } .ns-tree-leaf rect { fill: rgba(255, 255, 255, 0.045); stroke: rgba(120, 126, 138, 0.42); @@ -288,6 +357,7 @@ svg { font-size: 8.5px; letter-spacing: 0.3px; } +.ns-tree-leaf.is-iso rect { stroke: rgba(88, 182, 216, 0.4); } /* Bezier curve styles - matching connection lines */ .bezier-curve { diff --git a/static/js/aes-ref.js b/static/js/aes-ref.js new file mode 100644 index 0000000..e0fd709 --- /dev/null +++ b/static/js/aes-ref.js @@ -0,0 +1,198 @@ +/* + * aes-ref.js - a compact, real AES-128 reference implementation for the crypto + * visualisation. It captures every intermediate state (per round AND per + * operation: SubBytes / ShiftRows / MixColumns / AddRoundKey) so the UI can + * animate the algorithm's internals and recompute the avalanche effect live + * when a user flips input bits. + * + * This is a teaching/visualisation tool: it runs on demo vectors chosen by us. + * Mirrors the server-side kernel_ai/services/crypto/aes_math.py (verified + * against FIPS-197: key 000102..0f, pt 00112233..eeff -> 69c4e0d8..c55a). + */ +(function () { + 'use strict'; + + const SBOX = [ + 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, + 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, + 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, + 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, + 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, + 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, + 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, + 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, + 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, + 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, + 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, + 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, + 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, + 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, + 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, + 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16 + ]; + const RCON = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36]; + + function xtime(a) { + a <<= 1; + if (a & 0x100) a ^= 0x11b; + return a & 0xff; + } + + function gmul(a, b) { + let r = 0; + for (let i = 0; i < 8; i += 1) { + if (b & 1) r ^= a; + b >>= 1; + a = xtime(a); + } + return r & 0xff; + } + + function expandKey(key) { + const words = []; + for (let i = 0; i < 4; i += 1) { + words.push([key[4 * i], key[4 * i + 1], key[4 * i + 2], key[4 * i + 3]]); + } + for (let i = 4; i < 44; i += 1) { + let t = words[i - 1].slice(); + if (i % 4 === 0) { + t = [t[1], t[2], t[3], t[0]]; + t = t.map((b) => SBOX[b]); + t[0] ^= RCON[i / 4 - 1]; + } + words.push(words[i - 4].map((v, j) => v ^ t[j])); + } + const rks = []; + for (let r = 0; r < 11; r += 1) { + const rk = []; + for (let c = 0; c < 4; c += 1) rk.push(...words[4 * r + c]); + rks.push(rk); + } + return rks; + } + + function addRoundKey(s, rk) { return s.map((v, i) => v ^ rk[i]); } + function subBytes(s) { return s.map((b) => SBOX[b]); } + + function shiftRows(s) { + const o = new Array(16); + for (let r = 0; r < 4; r += 1) { + for (let c = 0; c < 4; c += 1) o[r + 4 * c] = s[r + 4 * ((c + r) % 4)]; + } + return o; + } + + function mixColumns(s) { + const o = new Array(16); + for (let c = 0; c < 4; c += 1) { + const col = [s[4 * c], s[4 * c + 1], s[4 * c + 2], s[4 * c + 3]]; + o[4 * c + 0] = gmul(col[0], 2) ^ gmul(col[1], 3) ^ col[2] ^ col[3]; + o[4 * c + 1] = col[0] ^ gmul(col[1], 2) ^ gmul(col[2], 3) ^ col[3]; + o[4 * c + 2] = col[0] ^ col[1] ^ gmul(col[2], 2) ^ gmul(col[3], 3); + o[4 * c + 3] = gmul(col[0], 3) ^ col[1] ^ col[2] ^ gmul(col[3], 2); + } + return o; + } + + // Returns per-round states (after each AddRoundKey) plus a per-operation + // breakdown for every round. + function encryptTrace(pt, key) { + const rks = expandKey(key); + let state = addRoundKey(pt.slice(), rks[0]); + const roundStates = [state.slice()]; // index 0 = after initial AddRoundKey + const ops = []; + for (let r = 1; r <= 10; r += 1) { + const input = state.slice(); + const sb = subBytes(input); + const sr = shiftRows(sb); + const hasMix = r < 10; + const mc = hasMix ? mixColumns(sr) : sr.slice(); + const ark = addRoundKey(hasMix ? mc : sr, rks[r]); + ops.push({ + round: r, + input, + subBytes: sb, + shiftRows: sr, + mixColumns: mc, + addRoundKey: ark, + roundKey: rks[r].slice(), + hasMix + }); + state = ark; + roundStates.push(state.slice()); + } + return { roundStates, ops, ciphertext: state.slice(), roundKeys: rks }; + } + + function hexToBytes(h) { + const b = []; + const s = String(h || '').replace(/[^0-9a-fA-F]/g, ''); + for (let i = 0; i + 1 < s.length; i += 2) b.push(parseInt(s.substr(i, 2), 16)); + return b; + } + + function bytesToHex(b) { return b.map((x) => (x & 0xff).toString(16).padStart(2, '0')).join(''); } + + function popcount(x) { + x &= 0xff; + let c = 0; + while (x) { c += x & 1; x >>= 1; } + return c; + } + + // Multiplication in GF(2^128) as defined by NIST SP 800-38D (GCM). Blocks are + // 16 bytes, bit 0 is the MSB of byte 0. Reduction polynomial is + // x^128 + x^7 + x^2 + x + 1, represented by R = 0xe1 followed by zero bytes. + function gfmul128(X, Y) { + const Z = new Array(16).fill(0); + const V = Y.slice(0, 16); + for (let i = 0; i < 128; i += 1) { + const bit = (X[i >> 3] >> (7 - (i & 7))) & 1; + if (bit) for (let j = 0; j < 16; j += 1) Z[j] ^= V[j]; + const lsb = V[15] & 1; + for (let j = 15; j > 0; j -= 1) V[j] = ((V[j] >> 1) | ((V[j - 1] & 1) << 7)) & 0xff; + V[0] = (V[0] >> 1) & 0xff; + if (lsb) V[0] ^= 0xe1; + } + return Z; + } + + // GHASH_H(blocks): Y_0 = 0; Y_i = (Y_{i-1} XOR block_i) . H. Returns the list + // of intermediate Y states (including Y_0) so the UI can animate accumulation. + function ghashSteps(H, blocks) { + let Y = new Array(16).fill(0); + const states = [Y.slice()]; + blocks.forEach((blk) => { + const xored = Y.map((v, j) => v ^ (blk[j] & 0xff)); + Y = gfmul128(xored, H); + states.push(Y.slice()); + }); + return states; + } + + // 64-bit big-endian length (in bits) as an 8-byte array (values fit in 32 bits here). + function be64(nBits) { + const out = new Array(8).fill(0); + let v = nBits >>> 0; + for (let j = 7; j >= 4; j -= 1) { out[j] = v & 0xff; v = Math.floor(v / 256); } + return out; + } + + window.AESRef = { + SBOX, + RCON, + gmul, + expandKey, + subBytes, + shiftRows, + mixColumns, + addRoundKey, + encryptTrace, + hexToBytes, + bytesToHex, + popcount, + gfmul128, + ghashSteps, + be64 + }; +})(); diff --git a/static/js/crypto-belt.js b/static/js/crypto-belt.js index 10c027f..b9079af 100644 --- a/static/js/crypto-belt.js +++ b/static/js/crypto-belt.js @@ -24,7 +24,7 @@ class CryptoSubsystemVisualization { this.selectedClientFilters = new Set(); this.selectedRequesterFilter = null; this.selectedImplementationClassFilter = null; - this.activeCryptoView = 'LIVE_FLOW'; + this.activeCryptoView = 'LINEAR_ANALYSIS'; this.titleNode = null; this.subtitleNode = null; this.viewToggleNode = null; @@ -242,8 +242,8 @@ class CryptoSubsystemVisualization { updateCryptoViewToggle() { if (!this.viewToggleNode) return; const views = [ - ['LIVE_FLOW', 'LIVE FLOW'], - ['LINEAR_ANALYSIS', 'LINEAR ANALYSIS'] + ['LINEAR_ANALYSIS', 'AES INTERNALS'], + ['LIVE_FLOW', 'LIVE FLOW'] ]; this.viewToggleNode.innerHTML = ''; views.forEach(([id, label]) => { @@ -1938,9 +1938,807 @@ class CryptoSubsystemVisualization { }; } + computeAesLive(aes) { + // Live avalanche for the CURRENT user-selected input difference, computed + // in the browser with the real AES-128 so bit-flips update instantly. + if (!aes || !window.AESRef) return null; + const R = window.AESRef; + const pt = R.hexToBytes(aes.demo_vectors.plaintext); + const key = R.hexToBytes(aes.demo_vectors.key); + if (pt.length !== 16 || key.length !== 16) return null; + if (!Array.isArray(this.aesInputDiff) || this.aesInputDiff.length !== 16) { + this.aesInputDiff = new Array(16).fill(0); + this.aesInputDiff[0] = 0x80; // default: flip MSB of byte 0 (matches backend demo) + } + const traceA = R.encryptTrace(pt, key); + // Self-check against the verified backend ciphertext; degrade gracefully. + if (R.bytesToHex(traceA.ciphertext) !== String(aes.demo_vectors.ciphertext)) { + return null; + } + const diff = this.aesInputDiff; + const ptB = pt.map((v, i) => v ^ diff[i]); + const traceB = R.encryptTrace(ptB, key); + const rounds = traceA.roundStates.length; + const grids = []; + const curve = []; + for (let r = 0; r < rounds; r += 1) { + const g = []; + let h = 0; + for (let i = 0; i < 16; i += 1) { + const pc = R.popcount(traceA.roundStates[r][i] ^ traceB.roundStates[r][i]); + g.push(pc); + h += pc; + } + grids.push(g); + curve.push(h); + } + const flippedBits = diff.reduce((s, v) => s + R.popcount(v), 0); + return { + pt, key, diff, traceA, traceB, grids, curve, rounds, flippedBits, + curvePct: curve.map((h) => Math.round((1000 * h) / 128) / 10) + }; + } + + toggleAesInputBit(byteIndex) { + if (!Array.isArray(this.aesInputDiff) || this.aesInputDiff.length !== 16) { + this.aesInputDiff = new Array(16).fill(0); + } + // Toggle the most-significant bit of the clicked byte's difference mask. + this.aesInputDiff[byteIndex] ^= 0x80; + if (this.lastPayload) this.renderFlowMap(this.lastPayload); + } + + resetAesInputDiff() { + this.aesInputDiff = new Array(16).fill(0); + this.aesInputDiff[0] = 0x80; + if (this.lastPayload) this.renderFlowMap(this.lastPayload); + } + + showTip(lines, event) { + if (!this.hoverCard) return; + this.hoverCard.textContent = lines.join('\n'); + this.hoverCard.style.display = 'block'; + this.positionHoverCard(event); + } + + openAesOpsOverlay(round) { + this.aesOverlay = 'ops'; + this.aesOpsRound = Math.max(1, Math.min(10, round)); + this.aesOpsClock = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); + this.renderAesOpsOverlay(); + } + + closeAesOpsOverlay() { + this.aesOverlay = null; + this.aesOpsRound = null; + if (this.svg) this.svg.selectAll('.aes-ops-overlay').remove(); + if (this._aesOpsRaf) { + cancelAnimationFrame(this._aesOpsRaf); + this._aesOpsRaf = null; + } + } + + _aesOverlayShell(titleText, subtitleText) { + // Shared modal shell used by the key-schedule and mode overlays. + const width = window.innerWidth; + const height = window.innerHeight; + this.svg.selectAll('.aes-ops-overlay').remove(); + const ov = this.svg.append('g').attr('class', 'aes-ops-overlay').style('cursor', 'default'); + ov.append('rect').attr('x', 0).attr('y', 0).attr('width', width).attr('height', height) + .style('fill', 'rgba(4, 7, 12, 0.72)').style('cursor', 'pointer') + .on('click', () => this.closeAesOpsOverlay()); + const panelW = Math.min(1180, Math.max(680, width * 0.82)); + const panelH = Math.min(560, Math.max(380, height * 0.66)); + const px = (width - panelW) / 2; + const py = (height - panelH) / 2; + const box = ov.append('g'); + box.append('rect').attr('x', px).attr('y', py).attr('width', panelW).attr('height', panelH).attr('rx', 10) + .style('fill', 'rgba(6, 10, 16, 0.96)').style('stroke', 'rgba(150, 180, 220, 0.5)').style('stroke-width', 1.2); + box.append('text').attr('x', px + 22).attr('y', py + 30) + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '14px') + .style('letter-spacing', '0.6px').style('fill', '#e6edf8').text(titleText); + box.append('text').attr('x', px + 22).attr('y', py + 48) + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '9.5px') + .style('fill', '#8fa0b8').text(subtitleText); + const closeG = box.append('g').style('cursor', 'pointer').on('click', () => this.closeAesOpsOverlay()); + closeG.append('circle').attr('cx', px + panelW - 24).attr('cy', py + 24).attr('r', 11) + .style('fill', 'rgba(255,120,90,0.14)').style('stroke', 'rgba(255,140,110,0.6)'); + closeG.append('text').attr('x', px + panelW - 24).attr('y', py + 28).attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '12px').style('fill', '#ffb59a').text('x'); + return { box, px, py, panelW, panelH }; + } + + openKeyScheduleOverlay(pinWord = null) { + this.aesOverlay = 'keysched'; + // When opened from an AddRoundKey ⊕XX tag, pin the highlight to that word + // (and its two parents) instead of running the auto-sweep. + this._aesKeySchedPin = (Number.isInteger(pinWord) && pinWord >= 0 && pinWord < 44) ? pinWord : null; + this.aesOpsClock = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); + this.renderKeyScheduleOverlay(); + } + + renderKeyScheduleOverlay() { + if (!this.svg || this.aesOverlay !== 'keysched' || !this.aesDemo || !window.AESRef) return; + const R = window.AESRef; + const key = R.hexToBytes(this.aesDemo.demo_vectors.key); + if (key.length !== 16) return; + + // Recompute the 44 words exactly as the key schedule does, capturing the + // RotWord / SubWord / Rcon derivation for every 4th word. + const words = []; + const derivations = []; + for (let i = 0; i < 4; i += 1) words.push([key[4 * i], key[4 * i + 1], key[4 * i + 2], key[4 * i + 3]]); + for (let i = 4; i < 44; i += 1) { + const prev = words[i - 1].slice(); + let t = prev.slice(); + let rot = null; let sub = null; let rcon = null; + if (i % 4 === 0) { + rot = [t[1], t[2], t[3], t[0]]; + sub = rot.map((b) => R.SBOX[b]); + rcon = R.RCON[i / 4 - 1]; + t = sub.slice(); + t[0] ^= rcon; + } + const w = words[i - 4].map((v, j) => v ^ t[j]); + words.push(w); + derivations.push({ i, prev, rot, sub, rcon, base: words[i - 4], out: w, special: i % 4 === 0 }); + } + + const pin = Number.isInteger(this._aesKeySchedPin) ? this._aesKeySchedPin : null; + const shell = this._aesOverlayShell( + 'AES-128 KEY SCHEDULE · 16-BYTE KEY -> 11 ROUND KEYS (44 WORDS)', + pin !== null + ? `linked from AddRoundKey: word w${pin} = K${Math.floor(pin / 4)}[col ${pin % 4}] · shown with its parents w${pin - 4} and w${pin - 1} · click grid to resume sweep` + : 'each word = word[i-4] XOR word[i-1]; every 4th word first passes RotWord -> SubWord -> XOR Rcon (highlighted)' + ); + const { box, px, py, panelW, panelH } = shell; + const gridTop = py + 66; + const gridH = panelH - 150; + const colW = (panelW - 44) / 11; // 11 round keys + const wordH = gridH / 4; // 4 words per round key + const cellW = colW / 4; // 4 bytes per word + + this._aesKeySchedGeom = { words, derivations, box, px, py, panelW, gridTop, colW, wordH, cellW }; + + // Column (round-key) headers. + for (let rk = 0; rk < 11; rk += 1) { + box.append('text').attr('x', px + 22 + rk * colW + colW / 2).attr('y', gridTop - 8).attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '8px') + .style('fill', rk === 0 ? '#ffcf9a' : '#8fa0b8').text(rk === 0 ? 'KEY' : `K${rk}`); + } + + const wordCells = []; + for (let wi = 0; wi < 44; wi += 1) { + const rk = Math.floor(wi / 4); + const wr = wi % 4; + const wx = px + 22 + rk * colW; + const wy = gridTop + wr * wordH; + const isSpecial = wi >= 4 && wi % 4 === 0; + const cells = []; + for (let bidx = 0; bidx < 4; bidx += 1) { + const rect = box.append('rect') + .attr('x', wx + bidx * cellW + 1).attr('y', wy + 1) + .attr('width', cellW - 2).attr('height', wordH - 3).attr('rx', 2) + .style('fill', 'rgba(20,30,45,0.7)') + .style('stroke', isSpecial ? 'rgba(255,170,110,0.5)' : 'rgba(140,165,200,0.22)') + .style('stroke-width', isSpecial ? 0.9 : 0.6); + const label = box.append('text') + .attr('x', wx + bidx * cellW + cellW / 2).attr('y', wy + wordH / 2 + 2).attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace') + .style('font-size', `${Math.max(6, Math.min(9, cellW * 0.32))}px`) + .style('fill', '#dbe6f5').style('pointer-events', 'none') + .text((words[wi][bidx] & 0xff).toString(16).padStart(2, '0')); + cells.push({ rect, label, val: words[wi][bidx] & 0xff }); + } + // Per-word click target: pin/inspect this word (click the pinned word to resume sweep). + box.append('rect') + .attr('x', wx + 1).attr('y', wy + 1).attr('width', colW - 2).attr('height', wordH - 3) + .style('fill', 'transparent').style('cursor', 'pointer') + .on('click', () => this.openKeyScheduleOverlay(this._aesKeySchedPin === wi ? null : wi)); + wordCells.push({ wi, cells, isSpecial }); + } + this._aesKeySchedCells = wordCells; + + // Derivation caption area (updates with the animated cursor). + this._aesKeySchedCaption = box.append('text') + .attr('x', px + 22).attr('y', py + panelH - 22) + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '9px') + .style('fill', '#a9c2e6').text(''); + + this._startKeySchedAnim(); + } + + _startKeySchedAnim() { + if (this._aesOpsRaf) cancelAnimationFrame(this._aesOpsRaf); + const cells = this._aesKeySchedCells; + const geom = this._aesKeySchedGeom; + if (!cells || !geom) return; + const stepMs = 320; + const frame = () => { + if (this.aesOverlay !== 'keysched' || !this.svg || this.svg.selectAll('.aes-ops-overlay').empty()) { + this._aesOpsRaf = null; + return; + } + const now = ((typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now()); + const elapsed = now - (this.aesOpsClock || now); + // A cursor sweeps word by word (4..43); when pinned it freezes on the + // linked word so its derivation stays visible. + const pin = Number.isInteger(this._aesKeySchedPin) ? this._aesKeySchedPin : null; + const cursor = pin !== null ? pin : (4 + Math.floor(elapsed / stepMs) % 40); + const parents = pin !== null ? new Set([pin - 4, pin - 1]) : new Set(); + cells.forEach((wc) => { + const isCursor = wc.wi === cursor; + const isParent = parents.has(wc.wi); + const revealed = pin !== null ? true : (wc.wi <= cursor || wc.wi < 4); + wc.cells.forEach((c) => { + c.rect.style('opacity', revealed ? 1 : 0.18); + c.label.style('opacity', revealed ? 1 : (pin !== null ? 0.5 : 0.18)); + if (isCursor) { + const pulse = 0.5 + 0.5 * Math.sin(elapsed * 0.012); + c.rect.style('fill', wc.isSpecial ? 'rgba(255,150,90,0.5)' : 'rgba(90,130,190,0.5)') + .style('stroke', wc.isSpecial ? '#ff9a55' : '#8fdcff').style('stroke-width', 1.4 + pulse); + } else if (isParent) { + c.rect.style('fill', 'rgba(90,180,140,0.35)') + .style('stroke', '#8effc8').style('stroke-width', 1.2); + } else { + c.rect.style('fill', wc.wi < 4 ? 'rgba(60,50,30,0.55)' : 'rgba(20,30,45,0.7)') + .style('stroke', wc.isSpecial ? 'rgba(255,170,110,0.5)' : 'rgba(140,165,200,0.22)') + .style('stroke-width', wc.isSpecial ? 0.9 : 0.6); + if (pin !== null && revealed) c.rect.style('opacity', 0.4); + } + }); + }); + const der = geom.derivations[cursor - 4]; + if (der && this._aesKeySchedCaption) { + const hex = (arr) => arr.map((b) => (b & 0xff).toString(16).padStart(2, '0')).join(' '); + if (der.special) { + this._aesKeySchedCaption.style('fill', '#ffcf9a').text( + `w${der.i}: RotWord(${hex(der.prev)})=${hex(der.rot)} · SubWord=${hex(der.sub)} · XOR Rcon(${der.rcon.toString(16).padStart(2, '0')}) · XOR w${der.i - 4}(${hex(der.base)}) = ${hex(der.out)}` + ); + } else { + this._aesKeySchedCaption.style('fill', '#a9c2e6').text( + `w${der.i} = w${der.i - 4}(${hex(der.base)}) XOR w${der.i - 1}(${hex(der.prev)}) = ${hex(der.out)}` + ); + } + } + this._aesOpsRaf = requestAnimationFrame(frame); + }; + this._aesOpsRaf = requestAnimationFrame(frame); + } + + openModeOverlay(mode) { + this.aesOverlay = 'mode'; + this.aesModeKind = mode || 'CTR'; + this.aesOpsClock = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now(); + this.renderModeOverlay(); + } + + renderModeOverlay() { + if (!this.svg || this.aesOverlay !== 'mode' || !this.aesDemo || !window.AESRef) return; + const R = window.AESRef; + const key = R.hexToBytes(this.aesDemo.demo_vectors.key); + if (key.length !== 16) return; + const isGcm = this.aesModeKind === 'GCM'; + + const shell = this._aesOverlayShell( + isGcm ? 'AES-GCM · COUNTER MODE + GHASH AUTHENTICATION' : 'AES-CTR · BLOCK CIPHER -> KEYSTREAM', + isGcm + ? 'AES encrypts counter blocks -> keystream XOR plaintext; ciphertext + AAD feed GHASH -> authentication tag' + : 'AES encrypts an incrementing counter to make a keystream; keystream XOR plaintext = ciphertext (a stream cipher)' + ); + const { box, px, py, panelW, panelH } = shell; + + // Mode switch buttons inside the overlay. + [['CTR', px + panelW - 210], ['GCM', px + panelW - 150]].forEach(([m, bx]) => { + const on = (m === this.aesModeKind); + const g = box.append('g').style('cursor', 'pointer').on('click', () => this.openModeOverlay(m)); + g.append('rect').attr('x', bx).attr('y', py + 14).attr('width', 52).attr('height', 20).attr('rx', 4) + .style('fill', on ? 'rgba(38,63,98,0.95)' : 'rgba(7,11,17,0.82)') + .style('stroke', on ? 'rgba(128,190,255,0.86)' : 'rgba(122,145,176,0.32)'); + g.append('text').attr('x', bx + 26).attr('y', py + 28).attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '9px') + .style('fill', on ? '#d8eaff' : '#9dafc5').text(m); + }); + + // Build N counter blocks: nonce(12) || counter(4). Encrypt each with real AES. + const nBlocks = 4; + const nonce = R.hexToBytes('00112233445566778899aabb'); + const laneY = py + 92; + // Reserve room at the bottom for the real GHASH accumulation chain (GCM). + const ghashReserve = isGcm ? 132 : 34; + const laneH = (py + panelH - 26 - ghashReserve - laneY) / nBlocks; + const colCtr = px + 40; + const colCipher = px + 40 + (panelW - 80) * 0.20; + const colKs = px + 40 + (panelW - 80) * 0.52; + const colPt = px + 40 + (panelW - 80) * 0.72; + const colOut = px + 40 + (panelW - 80) * 0.9; + + box.append('text').attr('x', colCtr).attr('y', laneY - 12).style('font-family', 'Share Tech Mono, monospace').style('font-size', '8px').style('fill', '#8fdcff').text('COUNTER BLOCK'); + box.append('text').attr('x', colCipher).attr('y', laneY - 12).style('font-family', 'Share Tech Mono, monospace').style('font-size', '8px').style('fill', '#c9b6ff').text('AES_K( · )'); + box.append('text').attr('x', colKs).attr('y', laneY - 12).style('font-family', 'Share Tech Mono, monospace').style('font-size', '8px').style('fill', '#ff9a55').text('KEYSTREAM'); + box.append('text').attr('x', colPt).attr('y', laneY - 12).style('font-family', 'Share Tech Mono, monospace').style('font-size', '8px').style('fill', '#9fb1c8').text('⊕ PLAINTEXT'); + box.append('text').attr('x', colOut).attr('y', laneY - 12).style('font-family', 'Share Tech Mono, monospace').style('font-size', '8px').style('fill', '#8effc8').text('= CIPHERTEXT'); + + const lanes = []; + for (let b = 0; b < nBlocks; b += 1) { + const ctr = nonce.concat([0, 0, 0, b + 1]); + const ks = R.encryptTrace(ctr, key).ciphertext; + const pt = []; + for (let i = 0; i < 16; i += 1) pt.push((0x40 + b * 16 + i) & 0xff); + const ct = ks.map((v, i) => v ^ pt[i]); + const y = laneY + b * laneH + laneH / 2; + const chip = (x, bytes, color, w) => { + const g = box.append('g'); + g.append('rect').attr('x', x).attr('y', y - 9).attr('width', w).attr('height', 18).attr('rx', 3) + .style('fill', 'rgba(14,20,30,0.9)').style('stroke', color).style('stroke-width', 0.8); + const t = g.append('text').attr('x', x + w / 2).attr('y', y + 3).attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '7px').style('fill', color) + .text(bytes.slice(0, 4).map((v) => (v & 0xff).toString(16).padStart(2, '0')).join('') + '…'); + return { g, rect: g.select('rect'), text: t }; + }; + const ctrChip = chip(colCtr, ctr, '#8fdcff', (panelW - 80) * 0.17); + const ksChip = chip(colKs, ks, '#ff9a55', (panelW - 80) * 0.17); + const ptChip = chip(colPt, pt, '#9fb1c8', (panelW - 80) * 0.15); + const outChip = chip(colOut, ct, '#8effc8', (panelW - 80) * 0.1); + box.append('text').attr('x', colCipher + (panelW - 80) * 0.06).attr('y', y + 3).attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '11px').style('fill', '#c9b6ff').text('AES'); + box.append('text').attr('x', colKs - 10).attr('y', y + 3).attr('text-anchor', 'middle').style('fill', '#6f8296').style('font-size', '10px').text('→'); + box.append('text').attr('x', colOut - 8).attr('y', y + 3).attr('text-anchor', 'middle').style('fill', '#6f8296').style('font-size', '10px').text('='); + lanes.push({ b, y, ct, ctrChip, ksChip, ptChip, outChip }); + } + + // Real GHASH authentication (GCM only): H = AES_K(0^128); the tag folds + // AAD, every ciphertext block and a length block through GF(2^128), then + // masks with AES_K(J0). All arithmetic is the genuine NIST SP 800-38D GHASH. + this._aesGhash = null; + if (isGcm) { + const H = R.encryptTrace(new Array(16).fill(0), key).ciphertext; + const aad = R.hexToBytes('6b65726e656c2d61693a67636d2001'); // "kernel-ai:gcm " + 0x01 (15 bytes) + while (aad.length < 16) aad.push(0); + const cBlocks = lanes.map((ln) => ln.ct); + const lenBlock = R.be64(16 * 8).concat(R.be64(cBlocks.length * 16 * 8)); // len(A) || len(C) in bits + const absorbed = [{ label: 'AAD', block: aad, kind: 'aad' }] + .concat(cBlocks.map((blk, i) => ({ label: `C${i + 1}`, block: blk, kind: 'ct' }))) + .concat([{ label: 'LEN', block: lenBlock, kind: 'len' }]); + const yStates = R.ghashSteps(H, absorbed.map((a) => a.block)); // Y0..Y6 + const S = yStates[yStates.length - 1]; + const ekj0 = R.encryptTrace(nonce.concat([0, 0, 0, 1]), key).ciphertext; // AES_K(J0) + const tag = S.map((v, j) => v ^ ekj0[j]); + + const gTop = py + panelH - ghashReserve + 4; + const hex = (b, n = 16) => b.slice(0, n).map((v) => (v & 0xff).toString(16).padStart(2, '0')).join(''); + box.append('text').attr('x', px + 22).attr('y', gTop + 2) + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '9px').style('fill', '#d9c2ff') + .text(`GHASH over GF(2^128) · H = AES_K(0) = ${hex(H, 8)}… · Y_i = (Y_{i-1} ⊕ block_i) · H`); + + // Accumulation chain: Y0 -> (⊕AAD ×H) -> Y1 -> ... -> S -> (⊕ E(J0)) -> TAG + const chainY = gTop + 34; + const nSteps = absorbed.length; + const usableW = panelW - 44; + const stepW = usableW / (nSteps + 1); + const nodeW = Math.min(stepW - 10, 86); + const stepNodes = []; + const yColor = '#c9b6ff'; + const drawChip = (cx, label, valHex, color, sub) => { + const g = box.append('g'); + g.append('rect').attr('x', cx - nodeW / 2).attr('y', chainY - 12).attr('width', nodeW).attr('height', 24).attr('rx', 3) + .style('fill', 'rgba(14,20,30,0.92)').style('stroke', color).style('stroke-width', 0.9); + g.append('text').attr('x', cx).attr('y', chainY - 2).attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '7px').style('fill', color).text(label); + g.append('text').attr('x', cx).attr('y', chainY + 8).attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '6.5px').style('fill', '#9fb1c8').text(valHex + '…'); + if (sub) { + g.append('text').attr('x', cx).attr('y', chainY + 22).attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '6px').style('fill', color).text(sub); + } + return g; + }; + // Y0 node + let prevX = px + 22 + stepW * 0.5; + drawChip(prevX, 'Y0 = 0', hex(yStates[0], 6), '#6f8296'); + for (let s = 0; s < nSteps; s += 1) { + const cx = px + 22 + stepW * (s + 1.5); + // transition annotation + box.append('text').attr('x', (prevX + cx) / 2).attr('y', chainY - 16).attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '6.5px').style('fill', '#8fa0b8') + .text(`⊕${absorbed[s].label} ×H`); + box.append('text').attr('x', (prevX + cx) / 2).attr('y', chainY + 4).attr('text-anchor', 'middle') + .style('fill', '#5f6f82').style('font-size', '9px').text('→'); + const isLast = s === nSteps - 1; + const g = drawChip(cx, isLast ? 'S (Σ)' : `Y${s + 1}`, hex(yStates[s + 1], 6), yColor); + stepNodes.push({ g, rect: g.select('rect'), step: s }); + prevX = cx; + } + // Tag node + const tagX = Math.min(px + panelW - 24 - nodeW / 2, prevX + stepW); + box.append('text').attr('x', (prevX + tagX) / 2).attr('y', chainY - 16).attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '6.5px').style('fill', '#ffcf9a') + .text('⊕ E(J0)'); + const tagG = drawChip(tagX, 'TAG', hex(tag, 8), '#8effc8', '128-bit auth'); + tagG.select('rect').style('stroke-width', 1.4).style('fill', 'rgba(20,40,32,0.92)'); + + this._aesGhash = { stepNodes, tagRect: tagG.select('rect'), nSteps }; + } + + this._aesModeLanes = lanes; + this._startModeAnim(); + } + + _startModeAnim() { + if (this._aesOpsRaf) cancelAnimationFrame(this._aesOpsRaf); + const lanes = this._aesModeLanes; + if (!lanes) return; + const stepMs = 900; + const frame = () => { + if (this.aesOverlay !== 'mode' || !this.svg || this.svg.selectAll('.aes-ops-overlay').empty()) { + this._aesOpsRaf = null; + return; + } + const now = ((typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now()); + const elapsed = now - (this.aesOpsClock || now); + const active = Math.floor(elapsed / stepMs) % lanes.length; + lanes.forEach((ln) => { + const on = ln.b === active; + const pulse = 0.6 + 0.4 * Math.sin(elapsed * 0.01); + [ln.ctrChip, ln.ksChip, ln.ptChip, ln.outChip].forEach((ch) => { + ch.rect.style('stroke-width', on ? 1.6 : 0.8).style('opacity', on ? 1 : 0.55); + }); + ln.ksChip.rect.style('filter', on ? 'url(#crypto-line-glow)' : null).style('stroke-opacity', on ? pulse : 0.6); + }); + // Walk the GHASH accumulation chain (GCM): highlight each block being + // absorbed in turn, then flash the final tag. + const gh = this._aesGhash; + if (gh && gh.stepNodes) { + const total = gh.nSteps + 1; // +1 for the tag flash + const ghActive = Math.floor(elapsed / 700) % total; + const pulse = 0.6 + 0.4 * Math.sin(elapsed * 0.012); + gh.stepNodes.forEach((n) => { + const on = n.step === ghActive; + const done = n.step < ghActive; + n.rect.style('stroke-width', on ? 1.8 : 0.9) + .style('opacity', on ? 1 : (done ? 0.9 : 0.4)) + .style('filter', on ? 'url(#crypto-line-glow)' : null) + .style('stroke-opacity', on ? pulse : 0.7); + }); + const tagOn = ghActive === gh.nSteps; + gh.tagRect.style('filter', tagOn ? 'url(#crypto-line-glow)' : null) + .style('stroke-width', tagOn ? 2 : 1.4) + .style('stroke-opacity', tagOn ? pulse : 1); + } + this._aesOpsRaf = requestAnimationFrame(frame); + }; + this._aesOpsRaf = requestAnimationFrame(frame); + } + + renderAesOpsOverlay() { + if (!this.svg || !this.aesOpsRound || !this.aesDemo || !window.AESRef) return; + const R = window.AESRef; + const aes = this.aesDemo; + const pt = R.hexToBytes(aes.demo_vectors.plaintext); + const key = R.hexToBytes(aes.demo_vectors.key); + const trace = R.encryptTrace(pt, key); + const round = Math.max(1, Math.min(10, this.aesOpsRound)); + const op = trace.ops[round - 1]; + if (!op) return; + + const width = window.innerWidth; + const height = window.innerHeight; + + // Stages of one AES round, each transforming a 4x4 state. + const stages = [ + { key: 'SubBytes', from: op.input, to: op.subBytes, accent: '#ff9a55', + note: 'byte substitution via S-box (confusion)' }, + { key: 'ShiftRows', from: op.subBytes, to: op.shiftRows, accent: '#8fdcff', + note: 'cyclic row shifts (inter-column diffusion)' } + ]; + if (op.hasMix) { + stages.push({ key: 'MixColumns', from: op.shiftRows, to: op.mixColumns, accent: '#c9b6ff', + note: 'GF(2^8) column mixing (intra-column diffusion)' }); + } + stages.push({ key: 'AddRoundKey', from: op.hasMix ? op.mixColumns : op.shiftRows, to: op.addRoundKey, + accent: '#8effc8', note: 'XOR with round key (key mixing)' }); + + this.svg.selectAll('.aes-ops-overlay').remove(); + const ov = this.svg.append('g').attr('class', 'aes-ops-overlay').style('cursor', 'default'); + + // Scrim (click to close). + ov.append('rect') + .attr('x', 0).attr('y', 0).attr('width', width).attr('height', height) + .style('fill', 'rgba(4, 7, 12, 0.72)') + .style('cursor', 'pointer') + .on('click', () => this.closeAesOpsOverlay()); + + const panelW = Math.min(1120, Math.max(640, width * 0.78)); + const panelH = Math.min(520, Math.max(360, height * 0.62)); + const px = (width - panelW) / 2; + const py = (height - panelH) / 2; + const box = ov.append('g'); + box.append('rect') + .attr('x', px).attr('y', py).attr('width', panelW).attr('height', panelH).attr('rx', 10) + .style('fill', 'rgba(6, 10, 16, 0.96)') + .style('stroke', 'rgba(150, 180, 220, 0.5)') + .style('stroke-width', 1.2); + box.append('rect') + .attr('x', px).attr('y', py).attr('width', panelW).attr('height', panelH).attr('rx', 10) + .style('fill', 'none').style('pointer-events', 'none'); + + box.append('text').attr('x', px + 22).attr('y', py + 30) + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '14px') + .style('letter-spacing', '0.6px').style('fill', '#e6edf8') + .text(`AES-128 · ROUND ${round} OF 10 · OPERATION LAYERS`); + box.append('text').attr('x', px + 22).attr('y', py + 48) + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '9.5px') + .style('fill', '#8fa0b8') + .text('hover a SubBytes cell to see the S-box lookup · use < / > or the pips to step rounds · click backdrop to close'); + + // Round navigation: prev / next arrows. + const navArrow = (cxp, label, target) => { + const enabled = target >= 1 && target <= 10; + const g = box.append('g').style('cursor', enabled ? 'pointer' : 'default') + .on('click', enabled ? () => this.openAesOpsOverlay(target) : null); + g.append('circle').attr('cx', cxp).attr('cy', py + 24).attr('r', 11) + .style('fill', enabled ? 'rgba(90,130,180,0.18)' : 'rgba(60,70,85,0.12)') + .style('stroke', enabled ? 'rgba(140,180,230,0.6)' : 'rgba(110,125,145,0.3)'); + g.append('text').attr('x', cxp).attr('y', py + 28).attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '12px') + .style('fill', enabled ? '#cfe1f7' : '#5f6b7d').text(label); + return g; + }; + navArrow(px + panelW - 96, '<', round - 1); + navArrow(px + panelW - 68, '>', round + 1); + + // Close affordance. + const closeG = box.append('g').style('cursor', 'pointer').on('click', () => this.closeAesOpsOverlay()); + closeG.append('circle').attr('cx', px + panelW - 24).attr('cy', py + 24).attr('r', 11) + .style('fill', 'rgba(255,120,90,0.14)').style('stroke', 'rgba(255,140,110,0.6)'); + closeG.append('text').attr('x', px + panelW - 24).attr('y', py + 28).attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '12px').style('fill', '#ffb59a').text('x'); + + // Round pips: jump to any of the 10 rounds. + const pipsG = box.append('g'); + const pipW = 15; + const pipsTotalW = pipW * 10; + const pipStartX = px + panelW - 130 - pipsTotalW; + for (let r = 1; r <= 10; r += 1) { + const isCur = r === round; + const pg = pipsG.append('g').style('cursor', 'pointer').on('click', () => this.openAesOpsOverlay(r)); + pg.append('rect').attr('x', pipStartX + (r - 1) * pipW).attr('y', py + 18).attr('width', pipW - 3).attr('height', 12).attr('rx', 2) + .style('fill', isCur ? 'rgba(143,220,255,0.9)' : 'rgba(120,145,180,0.22)') + .style('stroke', isCur ? '#8fdcff' : 'rgba(140,165,200,0.35)').style('stroke-width', 0.7); + pg.append('text').attr('x', pipStartX + (r - 1) * pipW + (pipW - 3) / 2).attr('y', py + 27).attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '7px') + .style('fill', isCur ? '#06121f' : '#9fb1c8').style('pointer-events', 'none').text(r); + } + + const nStages = stages.length; + const contentY = py + 74; + const contentH = panelH - 118; + const colGap = 18; + const colW = (panelW - 44 - colGap * (nStages - 1)) / nStages; + const gridCells = 4; + const cellSize = Math.min((colW - 28) / gridCells, (contentH - 60) / gridCells); + const gridW = cellSize * gridCells; + + // Precompute per-stage geometry and store for the animation loop. + const stageGeom = stages.map((st, si) => { + const cx0 = px + 22 + si * (colW + colGap); + const gx = cx0 + (colW - gridW) / 2; + const gy = contentY + 30; + return { st, si, cx0, gx, gy }; + }); + + const cellCenter = (gx, gy, col, row) => ({ + x: gx + col * cellSize + cellSize / 2, + y: gy + row * cellSize + cellSize / 2 + }); + + stageGeom.forEach((sg) => { + const { st, cx0, gx, gy } = sg; + box.append('text').attr('x', cx0 + colW / 2).attr('y', contentY + 12).attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '11px') + .style('fill', st.accent).text(st.key); + box.append('text').attr('x', cx0 + colW / 2).attr('y', gy + gridW + 26).attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '8px') + .style('fill', '#7f90a6').text(st.note); + // Cells (16 bytes). Column-major AES index = row + 4*col. + const cells = []; + for (let col = 0; col < 4; col += 1) { + for (let row = 0; row < 4; row += 1) { + const idx = row + 4 * col; + const cellG = box.append('g'); + const rect = cellG.append('rect') + .attr('x', gx + col * cellSize + 1.5).attr('y', gy + row * cellSize + 1.5) + .attr('width', cellSize - 3).attr('height', cellSize - 3).attr('rx', 3) + .style('stroke', 'rgba(140,165,200,0.28)').style('stroke-width', 0.7); + const label = cellG.append('text') + .attr('x', gx + col * cellSize + cellSize / 2).attr('y', gy + row * cellSize + cellSize / 2 + 3) + .attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace') + .style('font-size', `${Math.max(7, Math.min(11, cellSize * 0.3))}px`) + .style('fill', '#dbe6f5').style('pointer-events', 'none'); + cells.push({ idx, col, row, rect, label }); + } + } + sg.cells = cells; + + // Per-operation decoration layer (modulated by the animation loop). + const decor = box.append('g').style('opacity', 0.35); + sg.decor = decor; + + if (st.key === 'ShiftRows') { + // Each row r is cyclically shifted left by r bytes. + for (let row = 1; row < 4; row += 1) { + const yc = gy + row * cellSize + cellSize / 2; + const xEnd = gx + gridW - cellSize * 0.3; + const xStart = xEnd - row * cellSize; + decor.append('line').attr('x1', xEnd).attr('y1', yc).attr('x2', xStart).attr('y2', yc) + .style('stroke', st.accent).style('stroke-width', 1.4); + decor.append('path') + .attr('d', `M${xStart + 6},${yc - 4} L${xStart},${yc} L${xStart + 6},${yc + 4}`) + .style('fill', 'none').style('stroke', st.accent).style('stroke-width', 1.4); + decor.append('text').attr('x', xEnd + 4).attr('y', yc + 3) + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '8px') + .style('fill', st.accent).text(`«${row}`); + } + } else if (st.key === 'MixColumns') { + // Every output byte in a column depends on all 4 input bytes of that column. + for (let col = 0; col < 4; col += 1) { + const xc = gx + col * cellSize + cellSize / 2; + decor.append('line').attr('x1', xc).attr('y1', gy + cellSize * 0.35).attr('x2', xc).attr('y2', gy + gridW - cellSize * 0.35) + .style('stroke', st.accent).style('stroke-width', 1.2).style('stroke-dasharray', '3,2'); + for (let row = 0; row < 4; row += 1) { + const cc = cellCenter(gx, gy, col, row); + decor.append('circle').attr('cx', xc).attr('cy', cc.y).attr('r', 2.2).style('fill', st.accent); + } + } + decor.append('text').attr('x', gx + gridW / 2).attr('y', gy - 4).attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '7.5px') + .style('fill', st.accent).text('× [2 3 1 1] per column'); + } else if (st.key === 'AddRoundKey' && op.roundKey) { + // Show the round-key byte XORed into each cell (corner tag). Each + // column corresponds to key-schedule word (4*round + col) — click a + // cell to open the key schedule with that word pinned. + for (let col = 0; col < 4; col += 1) { + const linkedWord = 4 * round + col; + for (let row = 0; row < 4; row += 1) { + const idx = row + 4 * col; + decor.append('text') + .attr('x', gx + col * cellSize + 4).attr('y', gy + row * cellSize + 11) + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '6.5px') + .style('fill', st.accent).style('pointer-events', 'none') + .text(`⊕${(op.roundKey[idx] & 0xff).toString(16).padStart(2, '0')}`); + } + // Column-wide click target -> key schedule word for this column. + box.append('rect') + .attr('x', gx + col * cellSize + 1.5).attr('y', gy + 1.5) + .attr('width', cellSize - 3).attr('height', cellSize * 4 - 3) + .style('fill', 'transparent').style('cursor', 'pointer') + .on('click', () => this.openKeyScheduleOverlay(linkedWord)) + .on('mouseenter', (event) => this.showTip([ + `AddRoundKey column ${col}`, + `round key K${round} = key-schedule words w${4 * round}..w${4 * round + 3}`, + `this column XORs word w${linkedWord}`, + `click -> open key schedule (word pinned)` + ], event)) + .on('mouseleave', () => this.hideHoverCard()); + } + } + + // S-box hover exploration on the SubBytes stage. + if (st.key === 'SubBytes') { + sg.cells.forEach((c) => { + const inV = op.input[c.idx] & 0xff; + const outV = window.AESRef.SBOX[inV] & 0xff; + c.rect.style('cursor', 'help') + .on('mouseenter', (event) => { + c.rect.style('stroke', '#ffffff').style('stroke-width', 1.4); + this.showTip([ + `S-box lookup (SubBytes)`, + `in : 0x${inV.toString(16).padStart(2, '0')} (row ${(inV >> 4).toString(16)}, col ${(inV & 0xf).toString(16)})`, + `out : 0x${outV.toString(16).padStart(2, '0')}`, + `nonlinear substitution -> confusion` + ], event); + }) + .on('mouseleave', () => { + c.rect.style('stroke', 'rgba(140,165,200,0.28)').style('stroke-width', 0.7); + this.hideHoverCard(); + }); + }); + } + }); + + // Flow arrows between stages. + stageGeom.forEach((sg, i) => { + if (i === 0) return; + const prev = stageGeom[i - 1]; + const ax = prev.cx0 + colW - 4; + const ay = contentY + 30 + gridW / 2; + box.append('text').attr('x', (ax + sg.cx0) / 2).attr('y', ay + 4).attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '14px') + .style('fill', 'rgba(150,175,210,0.5)').text('>'); + }); + + this._aesOpsGeom = stageGeom; + this._startAesOpsAnim(); + } + + _startAesOpsAnim() { + if (this._aesOpsRaf) cancelAnimationFrame(this._aesOpsRaf); + const R = window.AESRef; + const geom = this._aesOpsGeom; + if (!geom || !R) return; + const stagePeriod = 1500; // ms per stage highlight + const heat = (v) => { + const x = Math.max(0, Math.min(1, v)); + if (x < 0.001) return '#12202f'; + if (x < 0.25) return '#3b4c8f'; + if (x < 0.5) return '#7b40d8'; + if (x < 0.75) return '#e05274'; + return '#ff8a42'; + }; + const frame = () => { + if (!this.aesOpsRound || !this.svg || this.svg.selectAll('.aes-ops-overlay').empty()) { + this._aesOpsRaf = null; + return; + } + const now = ((typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now()); + const elapsed = now - (this.aesOpsClock || now); + const nStages = geom.length; + const activeStage = Math.floor(elapsed / stagePeriod) % nStages; + const local = (elapsed % stagePeriod) / stagePeriod; // 0..1 within active stage + geom.forEach((sg, si) => { + const active = si === activeStage; + const morph = active ? local : (si < activeStage ? 1 : 0); + sg.cells.forEach((c) => { + const fromV = sg.st.from[c.idx] & 0xff; + const toV = sg.st.to[c.idx] & 0xff; + const changed = fromV !== toV; + // value shown: flips at the midpoint of the active stage morph + const shown = (morph >= 0.5) ? toV : fromV; + c.label.text(shown.toString(16).padStart(2, '0')); + const changedBits = R.popcount(fromV ^ toV) / 8; + let fill = 'rgba(20,30,45,0.6)'; + let stroke = 'rgba(140,165,200,0.28)'; + if (active) { + const pulse = 0.5 + 0.5 * Math.sin(elapsed * 0.012 + c.idx); + fill = changed ? heat(0.3 + changedBits * 0.7) : 'rgba(40,55,80,0.6)'; + stroke = sg.st.accent; + c.rect.style('opacity', 0.7 + 0.3 * (changed ? pulse : 0.4)); + } else if (si < activeStage) { + fill = changed ? heat(0.2 + changedBits * 0.5) : 'rgba(28,38,56,0.5)'; + c.rect.style('opacity', 0.85); + } else { + c.rect.style('opacity', 0.35); + } + c.rect.style('fill', fill).style('stroke', stroke) + .style('stroke-width', active ? 1.3 : 0.7); + }); + if (sg.decor) { + const decorOpacity = active + ? (0.6 + 0.4 * Math.abs(Math.sin(elapsed * 0.006))) + : (si < activeStage ? 0.4 : 0.16); + sg.decor.style('opacity', decorOpacity); + } + }); + this._aesOpsRaf = requestAnimationFrame(frame); + }; + this._aesOpsRaf = requestAnimationFrame(frame); + } + drawLinearAnalysisDashboard(layer, payload, width, height, tickId) { const model = this.buildLinearAnalysisModel(payload); + const aes = (this.aesDemo && this.aesDemo.diffusion) ? this.aesDemo : null; + const aesLive = this.computeAesLive(aes); const items = Array.isArray(payload?.items) ? payload.items : []; + const metaAll = payload?.meta || {}; + const cpuFlags = metaAll.cpu_flags || {}; + const cryptoMetrics = metaAll.crypto_metrics || {}; + const activeAlgorithms = Array.isArray(metaAll.active_algorithms) ? metaAll.active_algorithms : []; + const kernelOps = metaAll.kernel_ops || {}; + const kernelOpsAvail = !!kernelOps.available; + const kernelTopOp = (Array.isArray(kernelOps.by_driver) && kernelOps.by_driver.length) ? kernelOps.by_driver[0] : null; + const eventLog = Array.isArray(metaAll.event_log) ? metaAll.event_log : []; + const entropyCloud = metaAll.entropy_cloud || {}; const runtimeSources = Array.isArray(payload?.runtime_sources) ? payload.runtime_sources : (Array.isArray(payload?.meta?.runtime_sources) ? payload.meta.runtime_sources : []); @@ -2049,7 +2847,26 @@ class CryptoSubsystemVisualization { .style('font-family', 'Share Tech Mono, monospace') .style('font-size', '10px') .style('fill', '#9cabc0') - .text(`${algLabel} - ${model.rounds} ROUNDS - LINEAR APPROXIMATION TRACKING`); + .text(aes + ? `${algLabel} - ${aes.rounds} ROUNDS - REFERENCE COMPUTATION (DEMO VECTORS)` + : `${algLabel} - ${model.rounds} ROUNDS - LINEAR APPROXIMATION TRACKING`); + + if (aes) { + const explore = [ + ['KEY SCHEDULE', () => this.openKeyScheduleOverlay()], + ['GCM / CTR MODE', () => this.openModeOverlay(this.aesModeKind || 'CTR')] + ]; + explore.forEach(([label, onClick], idx) => { + const bw = 118; + const bx = margin + 4 + idx * (bw + 8); + const g = layer.append('g').style('cursor', 'pointer').on('click', onClick); + g.append('rect').attr('x', bx).attr('y', controlsY - 16).attr('width', bw).attr('height', 22).attr('rx', 4) + .style('fill', 'rgba(24, 40, 62, 0.9)').style('stroke', 'rgba(128, 190, 255, 0.6)').style('stroke-width', 1); + g.append('text').attr('x', bx + bw / 2).attr('y', controlsY - 1).attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '9px') + .style('fill', '#cfe3ff').text(label); + }); + } this.algorithmModes.forEach((mode, idx) => { const isActive = mode === model.request; @@ -2108,7 +2925,9 @@ class CryptoSubsystemVisualization { ['protocol', primary.protocol || 'CRYPTO API'], ['algorithm', primary.algorithm || `${model.request}-GCM/SHA256`], ['kernel path', model.selectedDriver], - ['cpu flags', model.selectedDriver.includes('aes') ? 'AES-NI, PCLMULQDQ' : 'generic/simd'] + ['cpu flags', (Array.isArray(cpuFlags.display) && cpuFlags.display.length) + ? cpuFlags.display.join(', ') + : (model.selectedDriver.includes('aes') ? 'AES-NI, PCLMULQDQ' : 'generic/simd')] ].forEach(([k, v], idx) => { ctx.append('text') .attr('x', margin + 14) @@ -2147,6 +2966,16 @@ class CryptoSubsystemVisualization { .style('stroke', 'rgba(122, 150, 190, 0.48)'); } }); + if (kernelOpsAvail && kernelTopOp) { + flow.append('text') + .attr('x', margin + leftW * 0.5) + .attr('y', dataFlowY + dataFlowH - 8) + .attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace') + .style('font-size', '7.5px') + .style('fill', '#9dffca') + .text(`live: ${String(kernelTopOp.op || '')} ${Math.round(Number(kernelTopOp.ops_per_sec) || 0)}/s`); + } const map = panel(centerX, topY, centerW, topH, 'BIT CORRELATION MAP (LINEAR APPROXIMATION)'); const mapLeft = centerX + 72; @@ -2171,6 +3000,7 @@ class CryptoSubsystemVisualization { layerXs.forEach((x, idx) => { const bias = model.bestTrail[idx]?.bias || model.baseBias; const color = heatColor((bias / Math.max(model.maxBias, 0.001)) - 0.45); + const depLayer = aes ? (aes.diffusion.dependency_layers[idx] || null) : null; map.append('text').attr('x', x).attr('y', topY + 46).attr('text-anchor', 'middle').style('font-family', 'Share Tech Mono, monospace').style('font-size', '8.5px').style('fill', '#b7c3d3').text(idx % 3 === 0 ? `R${idx}` : (idx % 3 === 1 ? `M${idx}` : `K${idx}`)); const roundRect = map.append('rect') .attr('x', x - 17) @@ -2199,18 +3029,25 @@ class CryptoSubsystemVisualization { }); for (let row = 0; row < bitRows; row += 1) { for (let col = 0; col < 4; col += 1) { - const v = Math.sin((model.seed + idx * 11 + row * 7 + col) * 0.22); + const depVal = depLayer ? Number(depLayer[row][col]) : null; + const v = (depVal != null) ? (depVal * 1.4 - 0.45) : Math.sin((model.seed + idx * 11 + row * 7 + col) * 0.22); + const cellOpacity = (depVal != null) ? (0.2 + depVal * 0.72) : (0.28 + Math.abs(v) * 0.5); const cellRect = map.append('rect') .attr('x', x - 13 + col * 7) .attr('y', bitY(row) - 5) .attr('width', 4) .attr('height', 10) .style('fill', heatColor(v)) - .style('opacity', 0.28 + Math.abs(v) * 0.5) + .style('opacity', cellOpacity) .style('cursor', 'crosshair') .on('mouseenter', (event) => { cellRect.style('opacity', 1).style('stroke', '#ffffff').style('stroke-width', 0.5); - showAnalysisTip([ + showAnalysisTip((depVal != null) ? [ + `diffusion cell : round ${idx}`, + `in byte-group : ${row} -> out byte-group : ${col}`, + `influence : ${(depVal * 100).toFixed(1)}%`, + `source : real AES-128 (demo vectors)` + ] : [ `LAT cell : R${idx} / bit ${row}.${col}`, `mask value : ${v >= 0 ? '+' : ''}${v.toFixed(4)}`, `bias class : ${Math.abs(v) > 0.72 ? 'hot approximation' : 'low signal'}`, @@ -2219,7 +3056,7 @@ class CryptoSubsystemVisualization { }) .on('mousemove', (event) => this.positionHoverCard(event)) .on('mouseleave', () => { - cellRect.style('opacity', 0.28 + Math.abs(v) * 0.5).style('stroke', 'none'); + cellRect.style('opacity', cellOpacity).style('stroke', 'none'); this.hideHoverCard(); }); } @@ -2252,7 +3089,21 @@ class CryptoSubsystemVisualization { const biasPanelY = topY + infoH + 8; const biasPanelH = Math.max(64, topH - infoH - 8); const info = panel(centerX + centerW + gap, topY, rightW, infoH, 'LINEAR APPROXIMATION INFO'); - [ + const infoLines = aes ? (() => { + const t0 = (aes.lat.top && aes.lat.top[0]) ? aes.lat.top[0] : { in_mask: 0, out_mask: 0, bias: aes.lat.max_bias }; + const b = aes.lat.max_bias; + return [ + `S-box linear approx:`, + `P[a.x = b.S(x)] = ${(0.5 + Math.abs(b)).toFixed(6)}`, + `max bias: ${b >= 0 ? '+' : ''}${b.toFixed(6)}`, + `correlation: ${(aes.lat.max_correlation).toFixed(6)}`, + `best masks (hex):`, + `a (in) : 0x${Number(t0.in_mask).toString(16).padStart(2, '0')}`, + `b (out): 0x${Number(t0.out_mask).toString(16).padStart(2, '0')}`, + `#approx |bias|=${aes.lat.max_abs_lat}/256`, + `source: real S-box` + ]; + })() : [ `approximation:`, `P[L(P,K) = L(C)] = ${(0.5 + model.maxBias).toFixed(7)}`, `bias: +${model.maxBias.toFixed(7)}`, @@ -2262,7 +3113,8 @@ class CryptoSubsystemVisualization { `C: 0x${((model.seed * 17) & 0xffff).toString(16).padStart(4, '0')}`, `rounds: ${model.rounds}/${model.rounds}`, `quality: good` - ].forEach((line, idx) => { + ]; + infoLines.forEach((line, idx) => { info.append('text') .attr('x', centerX + centerW + gap + 12) .attr('y', topY + 42 + idx * 11) @@ -2271,38 +3123,138 @@ class CryptoSubsystemVisualization { .style('fill', idx === 2 || idx === 8 ? '#8effc8' : '#b3bfd0') .text(line); }); - const biasPanel = panel(centerX + centerW + gap, biasPanelY, rightW, biasPanelH, 'BIAS OVER ROUNDS'); + const biasPanel = panel(centerX + centerW + gap, biasPanelY, rightW, biasPanelH, aes ? 'AVALANCHE OVER ROUNDS' : 'BIAS OVER ROUNDS', aes ? '% OF STATE BITS FLIPPED' : ''); const chartX = centerX + centerW + gap + 34; const chartY = biasPanelY + 42; const chartW = rightW - 58; const chartH = Math.max(24, biasPanelH - 56); biasPanel.append('line').attr('x1', chartX).attr('x2', chartX + chartW).attr('y1', chartY + chartH / 2).attr('y2', chartY + chartH / 2).style('stroke', 'rgba(116, 138, 170, 0.28)'); const bp = d3.path(); - model.bestTrail.forEach((step, idx) => { - const x = chartX + (chartW / Math.max(1, model.bestTrail.length - 1)) * idx; - const y = chartY + chartH * 0.5 - Math.sin(idx * 0.8 + model.seed) * chartH * 0.2 - (step.bias / model.maxBias) * chartH * 0.28; - if (idx === 0) bp.moveTo(x, y); - else bp.lineTo(x, y); - biasPanel.append('circle').attr('cx', x).attr('cy', y).attr('r', 2).style('fill', idx > model.bestTrail.length * 0.55 ? '#ff9a55' : '#9d55ff'); - }); - biasPanel.append('path').attr('d', bp.toString()).style('fill', 'none').style('stroke', '#ff8655').style('stroke-width', 1.5); + if (aes) { + const curve = (aesLive ? aesLive.curvePct : aes.diffusion.avg_curve_pct) || []; + // baseline (50%) reference line + biasPanel.append('line').attr('x1', chartX).attr('x2', chartX + chartW) + .attr('y1', chartY + chartH * 0.1).attr('y2', chartY + chartH * 0.1) + .style('stroke', 'rgba(141, 220, 255, 0.35)').style('stroke-dasharray', '2,2'); + curve.forEach((pct, idx) => { + const x = chartX + (chartW / Math.max(1, curve.length - 1)) * idx; + const y = chartY + chartH - (Math.max(0, Math.min(100, pct)) / 100) * chartH * 1.8; + if (idx === 0) bp.moveTo(x, y); + else bp.lineTo(x, y); + biasPanel.append('circle').attr('cx', x).attr('cy', y).attr('r', 2).style('fill', pct >= 45 ? '#8effc8' : '#ff9a55'); + }); + biasPanel.append('path').attr('d', bp.toString()).style('fill', 'none').style('stroke', '#8effc8').style('stroke-width', 1.5); + } else { + model.bestTrail.forEach((step, idx) => { + const x = chartX + (chartW / Math.max(1, model.bestTrail.length - 1)) * idx; + const y = chartY + chartH * 0.5 - Math.sin(idx * 0.8 + model.seed) * chartH * 0.2 - (step.bias / model.maxBias) * chartH * 0.28; + if (idx === 0) bp.moveTo(x, y); + else bp.lineTo(x, y); + biasPanel.append('circle').attr('cx', x).attr('cy', y).attr('r', 2).style('fill', idx > model.bestTrail.length * 0.55 ? '#ff9a55' : '#9d55ff'); + }); + biasPanel.append('path').attr('d', bp.toString()).style('fill', 'none').style('stroke', '#ff8655').style('stroke-width', 1.5); + } const diffW = Math.max(320, width * 0.33); const keyW = Math.max(320, width * 0.33); const entropyW = width - margin * 2 - diffW - keyW - gap * 2; - const diff = panel(margin, midY, diffW, midH, 'DIFFUSION & AVALANCHE VISUALIZATION', 'FLIP 1 BIT IN PLAINTEXT -> OBSERVE PROPAGATION'); + const diffSubtitle = aesLive + ? `CLICK INPUT BYTES TO FLIP BITS · ${aesLive.flippedBits} FLIPPED -> ${aesLive.curve[aesLive.curve.length - 1]}/128 · CLICK A ROUND FOR ITS OPERATIONS` + : (aes ? `FLIP 1 BIT IN PLAINTEXT -> ${aes.diffusion.avalanche_curve[aes.diffusion.avalanche_curve.length - 1]}/128 BITS CHANGED` : 'FLIP 1 BIT IN PLAINTEXT -> OBSERVE PROPAGATION'); + const diff = panel(margin, midY, diffW, midH, 'DIFFUSION & AVALANCHE VISUALIZATION', diffSubtitle); const cell = Math.max(5, Math.min(10, (diffW - 58) / 38)); - for (let round = 0; round < Math.min(10, model.rounds + 1); round += 1) { + const roundCap = Math.min(10, model.rounds + 1); + const pulse = 0.5 + 0.5 * Math.sin(this.activeAnimationTick * 0.18); + for (let round = 0; round < roundCap; round += 1) { const gx = margin + 24 + (round % 5) * ((diffW - 48) / 5); const gy = midY + 54 + Math.floor(round / 5) * ((midH - 78) / 2); - diff.append('text').attr('x', gx).attr('y', gy - 8).style('font-family', 'Share Tech Mono, monospace').style('font-size', '7.5px').style('fill', '#8fa0b8').text(`ROUND ${round}`); - for (let a = 0; a < 6; a += 1) { - for (let b = 0; b < 6; b += 1) { - const v = Math.abs(Math.sin((model.seed + round * 13 + a * 5 + b) * 0.2)); - diff.append('circle').attr('cx', gx + b * cell * 1.6).attr('cy', gy + a * cell * 1.45).attr('r', cell * 0.42).style('fill', heatColor(v - 0.35)).style('opacity', 0.25 + v * 0.65); + const grid = aesLive ? (aesLive.grids[round] || null) : (aes ? (aes.diffusion.avalanche_grids[round] || null) : null); + const changed = grid ? grid.reduce((s, v) => s + (v > 0 ? 1 : 0), 0) : 0; + const isInput = round === 0; + const canInspect = aesLive && round >= 1; + const labelTxt = grid ? (isInput ? `INPUT Δ · ${changed}/16` : `R${round} · ${changed}/16${canInspect ? ' ›' : ''}`) : `ROUND ${round}`; + if (canInspect) { + // Transparent hit rect: only registers clicks on painted glyphs. + diff.append('rect').attr('x', gx - 4).attr('y', gy - 18).attr('width', 78).attr('height', 16) + .style('fill', 'transparent').style('cursor', 'pointer') + .on('click', () => this.openAesOpsOverlay(round)); + } + diff.append('text').attr('x', gx).attr('y', gy - 8).style('font-family', 'Share Tech Mono, monospace').style('font-size', '7.5px') + .style('fill', isInput ? '#ffcf9a' : (canInspect ? '#a9c2e6' : '#8fa0b8')) + .style('cursor', canInspect ? 'pointer' : 'default') + .style('pointer-events', 'none') + .text(labelTxt); + if (grid) { + // 4x4 AES state rendered as "balls"; radius/heat = bits flipped in that byte (0..8). + const stepX = cell * 2.0; + const stepY = cell * 1.8; + for (let a = 0; a < 4; a += 1) { + for (let b = 0; b < 4; b += 1) { + const idx = a + 4 * b; + const bits = Number(grid[idx]) || 0; + const v = bits / 8; + const cxp = gx + b * stepX + cell * 0.85; + const cyp = gy + a * stepY + cell * 0.8; + const on = bits > 0; + const rBall = on ? (cell * 0.42 + v * cell * 0.5) : cell * 0.3; + const ballOpacity = on ? (0.5 + v * 0.5) * (isInput ? 1 : (0.7 + 0.3 * pulse)) : 0.4; + const ball = diff.append('circle') + .attr('cx', cxp).attr('cy', cyp).attr('r', rBall) + .style('fill', on ? heatColor(v * 1.3 - 0.25) : 'rgba(120,140,170,0.14)') + .style('stroke', on ? 'rgba(255,190,130,0.6)' : 'rgba(120,140,170,0.28)') + .style('stroke-width', on ? 0.7 : 0.5) + .style('opacity', ballOpacity); + // Forgiving transparent hit target so the tiny balls are + // easy to click (the visible circle can be < 3px radius). + const addHit = (handler, enter, leave) => { + diff.append('circle') + .attr('cx', cxp).attr('cy', cyp).attr('r', Math.max(cell, rBall + 3)) + .style('fill', 'transparent') + .style('cursor', 'pointer') + .on('click', handler) + .on('mouseenter', enter || null) + .on('mouseleave', leave || null); + }; + if (isInput && aesLive) { + addHit( + () => this.toggleAesInputBit(idx), + (event) => { + ball.style('stroke', '#ffffff').style('stroke-width', 1.2); + showAnalysisTip([ + `input byte : ${idx} (row ${a}, col ${b})`, + `flip state : ${on ? 'FLIPPED (1 bit)' : 'unchanged'}`, + `action : click to toggle a bit`, + `then watch : difference diffuses across rounds` + ], event); + }, + () => { + ball.style('stroke', on ? 'rgba(255,190,130,0.6)' : 'rgba(120,140,170,0.28)').style('stroke-width', on ? 0.7 : 0.5); + this.hideHoverCard(); + } + ); + } else if (canInspect) { + addHit(() => this.openAesOpsOverlay(round)); + } + } + } + } else { + for (let a = 0; a < 6; a += 1) { + for (let b = 0; b < 6; b += 1) { + const v = Math.abs(Math.sin((model.seed + round * 13 + a * 5 + b) * 0.2)); + diff.append('circle').attr('cx', gx + b * cell * 1.6).attr('cy', gy + a * cell * 1.45).attr('r', cell * 0.42).style('fill', heatColor(v - 0.35)).style('opacity', 0.25 + v * 0.65); + } } } } + if (aesLive) { + const resetG = diff.append('g').style('cursor', 'pointer').on('click', () => this.resetAesInputDiff()); + resetG.append('rect').attr('x', margin + diffW - 78).attr('y', midY + 6).attr('width', 66).attr('height', 16) + .style('fill', 'transparent'); + resetG.append('text').attr('x', margin + diffW - 14).attr('y', midY + 16).attr('text-anchor', 'end') + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '8px') + .style('pointer-events', 'none') + .style('fill', '#9fb6d4').text('[ reset Δ ]'); + } const keyX = margin + diffW + gap; const key = panel(keyX, midY, keyW, midH, 'KEY HYPOTHESIS SPACE (RANKING)'); @@ -2314,6 +3266,61 @@ class CryptoSubsystemVisualization { x: kcx + keyW * 0.23, y: kcy - midH * 0.18 }; + if (aes) { + const kr = aes.key_recovery; + const ranking = Array.isArray(kr.ranking) ? kr.ranking : []; + const maxAbs = Math.max(0.01, ...ranking.map((r) => Math.abs(r.corr))); + const sx0 = keyX + 26; + const sw = keyW - 48; + const syTop = midY + 44; + const syBot = midY + midH - 62; + const sh = Math.max(20, syBot - syTop); + key.append('line').attr('x1', sx0).attr('x2', sx0 + sw).attr('y1', syBot).attr('y2', syBot) + .style('stroke', 'rgba(116,138,170,0.35)'); + key.append('text').attr('x', sx0).attr('y', syTop - 6) + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '7.5px') + .style('fill', '#8fa0b8').text('|correlation| per key guess (0..255)'); + let bx = sx0; + let by = syBot; + ranking.forEach((r) => { + const px = sx0 + (Number(r.guess) / 255) * sw; + const py = syBot - (Math.abs(Number(r.corr)) / maxAbs) * sh; + const isTrue = Number(r.guess) === Number(kr.true_key); + if (isTrue) { bx = px; by = py; } + key.append('line').attr('x1', px).attr('x2', px).attr('y1', syBot).attr('y2', py) + .style('stroke', isTrue ? 'rgba(255,173,122,0.6)' : 'rgba(126,168,255,0.28)').style('stroke-width', isTrue ? 1.4 : 0.8); + key.append('circle').attr('cx', px).attr('cy', py).attr('r', isTrue ? 4 : 2) + .style('fill', isTrue ? '#ffad7a' : '#7ea8ff').style('opacity', isTrue ? 1 : 0.62) + .style('filter', isTrue ? 'url(#crypto-line-glow)' : null); + }); + key.append('text').attr('x', bx + 8).attr('y', by - 8) + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '8.5px') + .style('fill', '#ffcf9a').text(`true key ${kr.true_key_hex} · rank #${kr.true_rank}`); + + // Convergence curve: true-key rank vs number of observed messages. + const conv = Array.isArray(kr.convergence) ? kr.convergence : []; + const cx0 = keyX + 26; + const cw = keyW - 48; + const cyBot = midY + midH - 16; + const chH = 22; + key.append('text').attr('x', cx0).attr('y', cyBot - chH - 4) + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '7.5px') + .style('fill', '#8fa0b8').text('true-key rank vs messages (converges to #1)'); + const cp = d3.path(); + conv.forEach((c, i) => { + const px = cx0 + (cw / Math.max(1, conv.length - 1)) * i; + const frac = Math.min(1, (Number(c.true_rank) - 1) / 255); + const py = (cyBot - chH) + frac * chH; + if (i === 0) cp.moveTo(px, py); else cp.lineTo(px, py); + key.append('circle').attr('cx', px).attr('cy', py).attr('r', Number(c.true_rank) === 1 ? 2.6 : 1.6) + .style('fill', Number(c.true_rank) === 1 ? '#8effc8' : '#ff9a55'); + key.append('text').attr('x', px).attr('y', cyBot + 2).attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '6px') + .style('fill', '#6f8098').text(c.n); + }); + key.append('path').attr('d', cp.toString()).style('fill', 'none').style('stroke', '#8effc8').style('stroke-width', 1.2); + } + if (!aes) { for (let i = 0; i < 420; i += 1) { const h = this.hashText(`${model.seed}-key-${i}`); const angle = h * 0.018; @@ -2403,6 +3410,7 @@ class CryptoSubsystemVisualization { .style('stroke-opacity', 0.72); key.append('text').attr('x', bestKey.x + 12).attr('y', bestKey.y - 16).style('font-family', 'Share Tech Mono, monospace').style('font-size', '9px').style('fill', '#ffad7a').text('best hypothesis'); key.append('text').attr('x', bestKey.x + 12).attr('y', bestKey.y - 2).style('font-family', 'Share Tech Mono, monospace').style('font-size', '8.5px').style('fill', '#ffcf9a').text(`rank #1 | bias +${model.maxBias.toFixed(4)}`); + } const entX = keyX + keyW + gap; const ent = panel(entX, midY, entropyW, midH, 'ENTROPY COLLAPSE (SPIRAL VIEW)', 'FIBONACCI SPIRAL - ENTROPY REDUCTION OVER ROUNDS'); @@ -2421,6 +3429,18 @@ class CryptoSubsystemVisualization { for (let r = 0; r < 7; r += 1) { ent.append('circle').attr('cx', ecx).attr('cy', ecy).attr('r', 10 + r * Math.min(entropyW, midH) * 0.055).style('fill', 'none').style('stroke', r < 2 ? '#ff7a44' : '#5968d8').style('stroke-opacity', 0.32); } + const entPoolBits = Number(cryptoMetrics.entropy_pool_bits ?? entropyCloud.entropy_pool_bits ?? 0); + const entPoolSize = Number(cryptoMetrics.entropy_pool_size_bits ?? entropyCloud.entropy_pool_size_bits ?? 256) || 256; + const entPct = Math.max(0, Math.min(1, entPoolBits / entPoolSize)); + const entMaxR = 10 + 6 * Math.min(entropyW, midH) * 0.055; + ent.append('circle') + .attr('cx', ecx).attr('cy', ecy) + .attr('r', 8 + entPct * (entMaxR - 8)) + .style('fill', 'none') + .style('stroke', entPct > 0.5 ? '#8effc8' : '#ffcf7a') + .style('stroke-width', 2) + .style('stroke-opacity', 0.9) + .style('filter', 'url(#crypto-line-glow)'); [128, 96, 64, 32].forEach((bits, idx) => { ent.append('text') .attr('x', entX + entropyW - 48) @@ -2435,8 +3455,8 @@ class CryptoSubsystemVisualization { .attr('y', midY + midH - 16) .style('font-family', 'Share Tech Mono, monospace') .style('font-size', '8.5px') - .style('fill', '#9dafc5') - .text(`entropy slope: ${(model.correlationDecay * 100).toFixed(0)}% | rounds ${model.rounds}`); + .style('fill', entPct > 0.5 ? '#8effc8' : '#ffcf7a') + .text(`live pool: ${Math.round(entPct * 100)}% (${entPoolBits}/${entPoolSize}b) | crng ${String(cryptoMetrics.crng_state || entropyCloud.crng_state || 'n/a')}`); const bottomPanels = [ [margin, bottomY, width * 0.56 - margin, bottomH, 'KERNEL CRYPTO METRICS'], @@ -2444,14 +3464,38 @@ class CryptoSubsystemVisualization { [width * 0.78 + gap * 2, bottomY, width * 0.22 - margin * 2, bottomH, 'EVENT LOG (CRYPTO)'] ]; const metrics = panel(...bottomPanels[0]); + const mPoolBits = Number(cryptoMetrics.entropy_pool_bits ?? entropyCloud.entropy_pool_bits ?? 0); + const mPoolSize = Number(cryptoMetrics.entropy_pool_size_bits ?? entropyCloud.entropy_pool_size_bits ?? 256) || 256; + const mRngHealth = cryptoMetrics.rng_health || (String(entropyCloud.crng_state || '').toLowerCase() === 'ready' ? 'good' : 'warming'); + const mAesNi = cryptoMetrics.aes_ni_status || (cpuFlags.aes_ni ? 'available' : 'n/a'); + const mLatency = (cryptoMetrics.latency_ms != null) ? `${Number(cryptoMetrics.latency_ms).toFixed(2)} ms` : 'n/a'; + const mNet = (cryptoMetrics.net_mb_s != null) ? `${Number(cryptoMetrics.net_mb_s).toFixed(2)} MB/s` : 'n/a'; + const mOps = (cryptoMetrics.ops_per_sec ?? metaAll.ops_per_sec); + const kOpsAvail = !!cryptoMetrics.kernel_ops_available; + const kOps = cryptoMetrics.kernel_ops_per_sec; + const kMb = cryptoMetrics.kernel_mb_s; const metricItems = [ - ['entropy pool', '256/256 bits', '#8fdcff'], - ['rng health', 'good', '#8effc8'], - ['aes-ni usage', `${Math.round(model.confidence * 100)}%`, '#9dffca'], - ['avg latency', `${(model.maxBias * 100).toFixed(2)} us`, '#d6e3f4'], - ['throughput', `${(items.length * 0.7 + 1.8).toFixed(2)} GB/s`, '#d6e3f4'], - ['bias alert', model.maxBias > 0.06 ? 'watch' : 'none', '#ffcf8d'] + ['entropy pool', `${mPoolBits}/${mPoolSize} b`, '#8fdcff'], + ['rng health', mRngHealth, mRngHealth === 'good' ? '#8effc8' : '#ffcf8d'], + ['aes-ni', mAesNi, mAesNi === 'active' ? '#9dffca' : (mAesNi === 'available' ? '#8fdcff' : '#d6e3f4')], + ['crypto latency', mLatency, kOpsAvail ? '#9dffca' : '#d6e3f4'], + [kOpsAvail ? 'kernel throughput' : 'net throughput', + (kOpsAvail && kMb != null) ? `${Number(kMb).toFixed(2)} MB/s` : mNet, + kOpsAvail ? '#9dffca' : '#d6e3f4'], + [kOpsAvail ? 'kernel ops/s' : 'crypto ops/s', + (kOpsAvail && kOps != null) ? `${Number(kOps).toFixed(0)}` : ((mOps != null) ? `${Number(mOps).toFixed(0)}` : 'n/a'), + '#ffcf8d'] ]; + const metricsPanelX = bottomPanels[0][0]; + const metricsPanelW = bottomPanels[0][2]; + metrics.append('text') + .attr('x', metricsPanelX + metricsPanelW - 12) + .attr('y', bottomY + 15) + .attr('text-anchor', 'end') + .style('font-family', 'Share Tech Mono, monospace') + .style('font-size', '8px') + .style('fill', kOpsAvail ? '#9dffca' : '#8fa0b8') + .text(kOpsAvail ? 'live: kprobe (kernel)' : 'source: heuristic'); metricItems.forEach((m, idx) => { const x = margin + 14 + idx * ((width * 0.56 - 44) / metricItems.length); metrics.append('text').attr('x', x).attr('y', bottomY + 38).style('font-family', 'Share Tech Mono, monospace').style('font-size', '8.5px').style('fill', '#8fa0b8').text(m[0]); @@ -2459,20 +3503,64 @@ class CryptoSubsystemVisualization { spark(metrics, x, bottomY + 66, 58, Math.max(12, bottomH - 78), model.seed + idx * 9, m[2]); }); const algos = panel(...bottomPanels[1]); - ['AES-GCM', 'ChaCha20-Poly1305', 'SHA256'].forEach((name, idx) => { - const isSelected = name.toLowerCase().includes(model.request.toLowerCase().replace('20', '')); - algos.append('text').attr('x', width * 0.56 + gap + 14).attr('y', bottomY + 42 + idx * 24).style('font-family', 'Share Tech Mono, monospace').style('font-size', '9px').style('fill', '#d2dce9').text(name); - algos.append('text').attr('x', width * 0.56 + gap + width * 0.15).attr('y', bottomY + 42 + idx * 24).style('font-family', 'Share Tech Mono, monospace').style('font-size', '9px').style('fill', isSelected ? '#ffcf7a' : '#8effc8').text(isSelected ? 'selected' : 'active'); + const algoBaseX = width * 0.56 + gap; + const algoPanelW = width * 0.22; + const algoRows = activeAlgorithms.length + ? activeAlgorithms.slice(0, 3) + : [ + { family: 'AES', driver: 'aesni-intel', status: 'selected', source: 'kernel' }, + { family: 'ChaCha20', driver: 'chacha20-neon', status: 'selected', source: 'kernel' }, + { family: 'SHA-2', driver: 'sha256-avx2', status: 'selected', source: 'kernel' } + ]; + algoRows.forEach((a, idx) => { + const rowY = bottomY + 40 + idx * Math.max(26, (bottomH - 46) / algoRows.length); + const isReal = String(a.source || '') === 'kernel'; + algos.append('text').attr('x', algoBaseX + 14).attr('y', rowY) + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '9.5px').style('fill', '#d2dce9') + .text(String(a.family || '').slice(0, 12)); + algos.append('text').attr('x', algoBaseX + 14).attr('y', rowY + 12) + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '8px') + .style('fill', isReal ? '#86e0c0' : '#8fa0b8') + .text(String(a.driver || 'n/a').slice(0, 22)); + const isExecuting = a.status === 'executing'; + const statusLabel = isExecuting + ? `${Math.round(Number(a.observed_ops_per_sec) || 0)}/s` + : String(a.status || 'active'); + const statusColor = isExecuting ? '#9dffca' : (a.status === 'selected' ? '#ffcf7a' : '#8effc8'); + algos.append('text').attr('x', algoBaseX + algoPanelW - 12).attr('y', rowY).attr('text-anchor', 'end') + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '8px') + .style('fill', statusColor) + .text(statusLabel); + if (isExecuting) { + algos.append('text').attr('x', algoBaseX + algoPanelW - 12).attr('y', rowY + 11).attr('text-anchor', 'end') + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '7px').style('fill', '#6fae92') + .text('executing'); + } }); const log = panel(...bottomPanels[2]); - [ - `crypto_req ${model.request.toLowerCase()}: init`, - `${model.selectedDriver}: setkey`, - `best trail locked bias=${model.maxBias.toFixed(5)}`, - `runtime sources score=${runtimeQuality}`, - `${model.request.toLowerCase()} done (${(model.maxBias * 100).toFixed(2)}us)` - ].forEach((line, idx) => { - log.append('text').attr('x', width * 0.78 + gap * 2 + 12).attr('y', bottomY + 40 + idx * 17).style('font-family', 'Share Tech Mono, monospace').style('font-size', '8.5px').style('fill', '#9fb0c7').text(`[${idx + 19}:21:3${idx}] ${line}`.slice(0, 42)); + const logBaseX = width * 0.78 + gap * 2; + const tagColor = (tag) => { + const t = String(tag || '').toLowerCase(); + if (t === 'random') return '#8fdcff'; + if (t === 'flows') return '#ffcf7a'; + if (t === 'offload') return '#9dffca'; + if (t === 'aes' || t === 'sha-2' || t === 'chacha20') return '#c9b6ff'; + return '#9fb0c7'; + }; + const logRows = eventLog.length + ? eventLog.slice(0, 8) + : [ + { ts: '', tag: 'crypto', msg: 'crypto telemetry online' }, + { ts: '', tag: model.request.toLowerCase(), msg: `${model.selectedDriver}: selected` } + ]; + const logStep = Math.max(13, Math.min(16, (bottomH - 30) / Math.max(logRows.length, 1))); + logRows.forEach((e, idx) => { + const prefix = e.ts ? `[${e.ts}] ` : ''; + const line = `${prefix}${e.tag ? e.tag + ': ' : ''}${e.msg || ''}`; + log.append('text').attr('x', logBaseX + 12).attr('y', bottomY + 38 + idx * logStep) + .style('font-family', 'Share Tech Mono, monospace').style('font-size', '8px') + .style('fill', tagColor(e.tag)) + .text(line.slice(0, 46)); }); } @@ -2715,6 +3803,7 @@ class CryptoSubsystemVisualization { this.lastLinearAnalysisRenderAt = Date.now(); } else { this.linearAnalysisRendered = false; + if (this.aesOverlay) this.closeAesOpsOverlay(); } const width = window.innerWidth; @@ -2726,6 +3815,7 @@ class CryptoSubsystemVisualization { this.drawGrid(layer, width, height); if (this.activeCryptoView === 'LINEAR_ANALYSIS') { this.drawLinearAnalysisView(layer, payload, width, height, tickId); + if (this.aesOverlay) this.svg.select('.aes-ops-overlay').raise(); return; } this.drawProtocolLegend(layer); @@ -3104,6 +4194,33 @@ class CryptoSubsystemVisualization { }; } + fetchAesDemo() { + // Real AES-128 internals on demo vectors. All AES interactivity (bit + // flipping, round overlays, key schedule, modes) is gated on this data, + // so it must load reliably. We deliberately avoid 'force-cache': a stale + // cached error from an earlier session (before this endpoint existed) + // would otherwise permanently disable interactivity. On failure we clear + // the in-flight flag so the telemetry loop can retry until it succeeds. + if (this.aesDemo || this.aesDemoRequested) return; + this.aesDemoRequested = true; + window.fetchJson('/api/crypto-aes-demo', { cache: 'no-store' }, { + timeoutMs: 8000, + suppressToast: true, + context: 'crypto-aes-demo' + }) + .then((data) => { + if (!data || data.error || !data.demo_vectors) { + this.aesDemoRequested = false; + return; + } + this.aesDemo = data; + if (this.isActive && this.lastPayload) { + this.renderFlowMap(this.lastPayload); + } + }) + .catch(() => { this.aesDemoRequested = false; }); + } + fetchTelemetry() { return window.fetchJson('/api/crypto-realtime', { cache: 'no-store' }, { timeoutMs: 6000, @@ -3158,10 +4275,15 @@ class CryptoSubsystemVisualization { this.container.style.pointerEvents = 'auto'; this.fetchTelemetry(); + this.fetchAesDemo(); if (this.telemetryInterval) clearInterval(this.telemetryInterval); this.telemetryInterval = setInterval(() => { - if (this.isActive) this.fetchTelemetry(); + if (!this.isActive) return; + this.fetchTelemetry(); + // Retry the (one-shot) AES demo load until it succeeds; without it + // the AES INTERNALS view stays in its non-interactive fallback. + if (!this.aesDemo) this.fetchAesDemo(); }, 1200); } @@ -3169,6 +4291,7 @@ class CryptoSubsystemVisualization { this.isActive = false; this.activeAnimationTick += 1; this.hideHoverCard(); + if (this.aesOverlay) this.closeAesOpsOverlay(); if (this.telemetryInterval) { clearInterval(this.telemetryInterval); diff --git a/static/js/flow-ui.js b/static/js/flow-ui.js index 904baba..c146d4f 100644 --- a/static/js/flow-ui.js +++ b/static/js/flow-ui.js @@ -62,6 +62,104 @@ function drawBezierDecor(width, height, yBase) { .attr("stroke-width", 0.9) .attr("stroke-linecap", "round"); + // Data-track milestones along the lower rail (reference "genome/timeline" + // read): named kernel I/O path stages with live throughput. Amber marks the + // busiest layer(s), driven by /api/io-pulse. + const AMBER = "198, 120, 28"; + const INK = "45, 45, 45"; + const trackHalf = originalRailHalfWidth - 24; + + const fmtRate = (v, unit) => { + if (unit === "mb") { + if (v >= 1) return v.toFixed(1) + " M/s"; + if (v > 0) return Math.max(1, Math.round(v * 1024)) + " K/s"; + return "idle"; + } + if (v >= 10000) return (v / 1000).toFixed(0) + "k/s"; + if (v >= 1000) return (v / 1000).toFixed(1) + "k/s"; + if (v > 0) return v + "/s"; + return "idle"; + }; + + const stages = [ + { label: "NET", unit: "mb", scaleMax: 50, value: (m) => m.net_mb_s || 0 }, + { label: "BLOCK", unit: "mb", scaleMax: 80, value: (m) => (m.disk_read_mb_s || 0) + (m.disk_write_mb_s || 0) }, + { label: "MM", unit: "cnt", scaleMax: 50000, value: (m) => m.pgfault_per_sec || 0 }, + { label: "SWAP", unit: "cnt", scaleMax: 5000, value: (m) => (m.pswpin_per_sec || 0) + (m.pswpout_per_sec || 0) }, + { label: "IRQ", unit: "cnt", scaleMax: 25000, value: (m) => m.intr_per_sec || 0 } + ]; + + const trackNodes = stages.map((st, i) => { + const t = stages.length === 1 ? 0.5 : i / (stages.length - 1); + const x = centerX - trackHalf + t * (trackHalf * 2); + + const stem = decorGroup.append("line") + .attr("x1", x).attr("y1", lowerRailY) + .attr("x2", x).attr("y2", lowerRailY - 9) + .attr("stroke", `rgba(${INK}, 0.3)`) + .attr("stroke-width", 0.8) + .attr("stroke-linecap", "round"); + + const halo = decorGroup.append("circle") + .attr("cx", x).attr("cy", lowerRailY).attr("r", 5) + .attr("fill", "none") + .attr("stroke", `rgba(${AMBER}, 0.35)`) + .attr("stroke-width", 0.8) + .attr("opacity", 0); + + const dot = decorGroup.append("circle") + .attr("cx", x).attr("cy", lowerRailY).attr("r", 1.8) + .attr("fill", `rgba(${INK}, 0.5)`); + + const value = decorGroup.append("text") + .attr("x", x).attr("y", lowerRailY - 12) + .attr("text-anchor", "middle") + .style("font-family", "Share Tech Mono, monospace") + .style("font-size", "6.5px") + .style("letter-spacing", "0.3px") + .style("fill", "rgba(60, 60, 60, 0.5)") + .text("—"); + + const label = decorGroup.append("text") + .attr("x", x).attr("y", lowerRailY - 22) + .attr("text-anchor", "middle") + .style("font-family", "Share Tech Mono, monospace") + .style("font-size", "7px") + .style("letter-spacing", "1px") + .style("fill", "rgba(55, 55, 55, 0.55)") + .text(st.label); + + return { st, x, stem, halo, dot, value, label }; + }); + + const refreshIoTrack = () => { + const p = window.fetchJson + ? window.fetchJson("/api/io-pulse", { cache: "no-store" }, { timeoutMs: 5000, retries: 0, context: "io-track" }) + : fetch("/api/io-pulse", { cache: "no-store" }).then((r) => r.json()); + Promise.resolve(p).then((metrics) => { + if (!metrics) return; + const rows = trackNodes.map((n) => { + const raw = Math.max(0, Number(n.st.value(metrics)) || 0); + return { n, raw, intensity: Math.min(1, raw / n.st.scaleMax) }; + }); + const maxI = rows.reduce((m, r) => Math.max(m, r.intensity), 0); + rows.forEach((r) => { + const hot = maxI > 0 && r.raw > 0 && r.intensity >= maxI * 0.7; + r.n.dot.attr("r", hot ? 2.6 : 1.8).attr("fill", hot ? `rgb(${AMBER})` : `rgba(${INK}, 0.5)`); + r.n.halo.attr("opacity", hot ? 1 : 0); + r.n.stem.attr("stroke", hot ? `rgba(${AMBER}, 0.55)` : `rgba(${INK}, 0.3)`); + r.n.label.style("fill", hot ? `rgba(${AMBER}, 0.95)` : "rgba(55, 55, 55, 0.55)"); + r.n.value + .style("fill", hot ? `rgba(${AMBER}, 0.95)` : "rgba(60, 60, 60, 0.5)") + .text(fmtRate(r.raw, r.n.st.unit)); + }); + }).catch(() => {}); + }; + + if (window.__ioTrackTimer) clearInterval(window.__ioTrackTimer); + refreshIoTrack(); + window.__ioTrackTimer = setInterval(refreshIoTrack, 3000); + // Marker dots and tiny ticks (echoing the diegetic control language). const markers = 9; for (let i = 0; i < markers; i++) { diff --git a/static/js/isolation-ui.js b/static/js/isolation-ui.js index 080a86a..15c0ee4 100644 --- a/static/js/isolation-ui.js +++ b/static/js/isolation-ui.js @@ -108,8 +108,6 @@ function drawNamespaceShell(centerX, centerY, namespaces) { cells.forEach(c => { c.segment.attr('opacity', 1); c.halo.attr('opacity', 0); - c.label.attr('opacity', 1).style('fill', `rgb(${INK})`); - if (c.count) c.count.attr('opacity', 1); }); }; @@ -118,10 +116,6 @@ function drawNamespaceShell(centerX, centerY, namespaces) { const focused = j === idx; c.segment.attr('opacity', focused ? 1 : 0.22); c.halo.attr('opacity', focused ? 1 : 0); - c.label - .attr('opacity', focused ? 1 : 0.22) - .style('fill', focused ? '#1f2228' : `rgb(${INK})`); - if (c.count) c.count.attr('opacity', focused ? 1 : 0.22); }); }; @@ -157,6 +151,7 @@ function drawNamespaceShell(centerX, centerY, namespaces) { // (containers / sandboxes) — the most security-relevant signal. const isolated = !!ns.isolated || Number(ns.unique_count || 0) > 1; const ACCENT = '88, 182, 216'; + const kind = NS_KIND[ns.id] || 'nsproxy'; // Soft focus halo behind the segment (hidden until hover). const halo = shellGroup.append('path') @@ -181,35 +176,6 @@ function drawNamespaceShell(centerX, centerY, namespaces) { .style('cursor', 'pointer'); const mid = (startAngle + endAngle) / 2; - const labelR = ringOuter - 13; - const lx = centerX + Math.cos(mid - Math.PI / 2) * labelR; - const ly = centerY + Math.sin(mid - Math.PI / 2) * labelR; - const label = shellGroup.append('text') - .attr('x', lx) - .attr('y', ly) - .attr('text-anchor', 'middle') - .style('font-family', 'Share Tech Mono, monospace') - .style('font-size', '8.5px') - .style('letter-spacing', '1.2px') - .style('fill', `rgb(${INK})`) - .style('pointer-events', 'none') - .text(nsName.toUpperCase()); - - // Always-visible live count near the inner edge. - const countR = ringInner + 12; - const cx = centerX + Math.cos(mid - Math.PI / 2) * countR; - const cy = centerY + Math.sin(mid - Math.PI / 2) * countR; - const count = shellGroup.append('text') - .attr('x', cx) - .attr('y', cy) - .attr('text-anchor', 'middle') - .style('font-family', 'Share Tech Mono, monospace') - .style('font-size', '9px') - .style('font-weight', '600') - .style('letter-spacing', '0.3px') - .style('fill', isolated ? `rgb(${ACCENT})` : `rgba(${INK}, 0.72)`) - .style('pointer-events', 'none') - .text(String(ns.unique_count || 0)); // Pulsing marker flags cells that actually contain isolation. if (isolated) { @@ -224,7 +190,7 @@ function drawNamespaceShell(centerX, centerY, namespaces) { } const idx = cells.length; - cells.push({ segment, halo, label, count }); + cells.push({ segment, halo }); segment .on('mouseenter', (event) => { @@ -236,13 +202,22 @@ function drawNamespaceShell(centerX, centerY, namespaces) { .style('left', `${event.pageX + 14}px`) .style('top', `${event.pageY - 10}px`) .html(` -
NAMESPACE · ${nsName.toUpperCase()}
+
+
+
NAMESPACE
+
${nsName.toUpperCase()}
+
+
${isolated ? 'ISOLATED' : 'SINGLE'}
+
${meta.isolates || 'Resource isolation'}
-
UNIQUE${ns.unique_count || 0}
-
DOMINANT${ns.dominant_count || 0} procs
-
INODE${ns.dominant_inode || 'n/a'}
+
+
WORLDS${ns.unique_count || 0}
+
DOMINANT${ns.dominant_count || 0} procs
+
INODE${ns.dominant_inode || 'n/a'}
+
VIA${kind}
+
-
ACTIVITY ${Math.round(activity * 100)}%
+
ACTIVITY ${Math.round(activity * 100)}%click to unfold ▸
`); }) .on('mousemove', (event) => { @@ -307,6 +282,7 @@ function expandNamespaceTree(ns, meta, nsName, cx, cy, mid) { // Build the tree: identity + live isolated worlds + kernel facets. const kind = NS_KIND[ns.id] || 'nsproxy'; + const isolated = !!ns.isolated || Number(ns.unique_count || 0) > 1; const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s); const worlds = Array.isArray(ns.worlds) ? ns.worlds : []; const worldLeaves = worlds.length @@ -328,21 +304,22 @@ function expandNamespaceTree(ns, meta, nsName, cx, cy, mid) { // Panel geometry (local content coords). const rowH = 24; - const headerH = 26; + const headerH = 40; + const footerH = 16; const padX = 14; const col0 = padX; // root chip - const rootW = 54; - const col1 = padX + 74; // branch chips - const branchW = 96; - const col2 = padX + 190; // leaf chips - const leafW = 150; + const rootW = 56; + const col1 = padX + 78; // branch chips + const branchW = 100; + const col2 = padX + 196; // leaf chips + const leafW = 152; const panelW = col2 + leafW + padX; const totalLeaves = branches.reduce((s, b) => s + b.leaves.length, 0); - const panelH = headerH + 10 + totalLeaves * rowH + 12; + const contentTop = headerH + 8; + const panelH = contentTop + totalLeaves * rowH + footerH; // Assign rows: leaves stack, branch = mean of its leaves, root = mean of branches. let row = 0; - const contentTop = headerH + 10; const laidBranches = branches.map((br) => { const leafYs = br.leaves.map(() => contentTop + (row++) * rowH + rowH / 2); const by = leafYs.reduce((s, v) => s + v, 0) / leafYs.length; @@ -380,15 +357,32 @@ function expandNamespaceTree(ns, meta, nsName, cx, cy, mid) { .attr('class', 'ns-tree-frame') .attr('width', panelW).attr('height', panelH).attr('rx', 4) .attr('transform', `translate(0, ${panelH / 2}) scale(1, 0.02)`) - .transition().delay(160).duration(220).ease(d3.easeCubicOut) + .transition().delay(140).duration(220).ease(d3.easeCubicOut) .attr('transform', 'translate(0,0) scale(1,1)'); - panel.append('text') + // Header: name + status pill + activity track + close affordance. + const header = panel.append('g').attr('class', 'ns-tree-header').style('opacity', 0); + header.transition().delay(280).duration(200).style('opacity', 1); + + header.append('text') .attr('class', 'ns-tree-title') - .attr('x', padX).attr('y', 17) - .style('opacity', 0) - .text(`NAMESPACE · ${nsName.toUpperCase()}`) - .transition().delay(300).duration(200).style('opacity', 1); + .attr('x', padX).attr('y', 16) + .text(`NAMESPACE · ${nsName.toUpperCase()}`); + + const pct = Math.round((ns.activity || 0) * 100); + const trackX = padX, trackY = 26, trackW = panelW - padX * 2; + header.append('rect') + .attr('class', 'ns-tree-track') + .attr('x', trackX).attr('y', trackY).attr('width', trackW).attr('height', 3).attr('rx', 1.5); + header.append('rect') + .attr('class', isolated ? 'ns-tree-track-fill is-iso' : 'ns-tree-track-fill') + .attr('x', trackX).attr('y', trackY).attr('width', 0).attr('height', 3).attr('rx', 1.5) + .transition().delay(340).duration(320).ease(d3.easeCubicOut) + .attr('width', Math.max(2, trackW * (pct / 100))); + + header.append('line') + .attr('class', 'ns-tree-divider') + .attr('x1', padX).attr('y1', headerH - 4).attr('x2', panelW - padX).attr('y2', headerH - 4); const linksG = panel.append('g').attr('class', 'ns-tree-links'); const nodesG = panel.append('g').attr('class', 'ns-tree-nodes'); @@ -397,13 +391,15 @@ function expandNamespaceTree(ns, meta, nsName, cx, cy, mid) { const mx = (sx + tx) / 2; return `M ${sx},${sy} H ${mx} V ${ty} H ${tx}`; }; + // Links draw like a circuit trace (stroke-dashoffset), timed to the chips. const addLink = (sx, sy, tx, ty, delay) => { - linksG.append('path') + const path = linksG.append('path') .attr('class', 'ns-tree-link') - .attr('d', elbow(sx, sy, tx, ty)) - .style('opacity', 0) - .transition().delay(delay).duration(200) - .style('opacity', 1); + .attr('d', elbow(sx, sy, tx, ty)); + const len = path.node().getTotalLength(); + path.attr('stroke-dasharray', len).attr('stroke-dashoffset', len) + .transition().delay(delay).duration(260).ease(d3.easeCubicOut) + .attr('stroke-dashoffset', 0); }; const addChip = (fromX, fromY, x, y, w, label, cls, delay, title) => { const g = nodesG.append('g') @@ -416,7 +412,7 @@ function expandNamespaceTree(ns, meta, nsName, cx, cy, mid) { g.append('rect') .attr('x', 0).attr('y', -9).attr('width', w).attr('height', 18).attr('rx', 3); g.append('text') - .attr('x', 8).attr('y', 4) + .attr('x', 9).attr('y', 4) .text(label); g.transition().delay(delay).duration(260).ease(d3.easeBackOut.overshoot(1.5)) .attr('transform', `translate(${x}, ${y}) scale(1)`) @@ -424,20 +420,23 @@ function expandNamespaceTree(ns, meta, nsName, cx, cy, mid) { return g; }; - // Root chip. - addChip(col0, rootY, col0, rootY, rootW, nsName.toUpperCase(), 'ns-tree-root', 120); + // Root chip (accent when the namespace holds real isolation). + addChip(col0, rootY, col0, rootY, rootW, nsName.toUpperCase(), isolated ? 'ns-tree-root is-iso' : 'ns-tree-root', 120); // Branches + leaves. let leafSeq = 0; laidBranches.forEach((br, bi) => { const branchDelay = 260 + bi * 90; + const isWorlds = /^WORLDS/.test(br.label); + const branchCls = (isWorlds && isolated) ? 'ns-tree-branch is-iso' : 'ns-tree-branch'; addLink(col0 + rootW, rootY, col1, br.by, branchDelay - 40); - addChip(col0 + rootW, rootY, col1, br.by, branchW, br.label, 'ns-tree-branch', branchDelay); + addChip(col0 + rootW, rootY, col1, br.by, branchW, br.label, branchCls, branchDelay); br.leaves.forEach((lf, li) => { const leafDelay = 430 + leafSeq * 55; leafSeq += 1; + const leafCls = (isWorlds && isolated) ? 'ns-tree-leaf is-iso' : 'ns-tree-leaf'; addLink(col1 + branchW, br.by, col2, br.leafYs[li], leafDelay - 40); - addChip(col1 + branchW, br.by, col2, br.leafYs[li], leafW, lf.label, 'ns-tree-leaf', leafDelay, lf.title); + addChip(col1 + branchW, br.by, col2, br.leafYs[li], leafW, lf.label, leafCls, leafDelay, lf.title); }); }); diff --git a/static/js/kernel-tape.js b/static/js/kernel-tape.js new file mode 100644 index 0000000..5c682a6 --- /dev/null +++ b/static/js/kernel-tape.js @@ -0,0 +1,542 @@ +// Kernel Activity Tape — a live, self-contained console drawer that streams +// real kernel activity (syscall concurrency deltas + network rates) as a +// scrolling event feed. Pure HTML overlay; does not touch the SVG layout. +(function initKernelTape() { + if (window.KernelTape) return; + + const POLL_MS = 1400; + const MAX_ROWS = 80; + const MAX_NEW_PER_TICK = 6; + + const TAGS = { + network_stack: { text: 'NET', color: 'rgba(103, 190, 224, 0.95)' }, + file_system: { text: 'FS', color: 'rgba(200, 206, 214, 0.95)' }, + process_scheduler: { text: 'SCHED', color: 'rgba(167, 200, 120, 0.95)' }, + memory_management: { text: 'MEM', color: 'rgba(186, 166, 220, 0.95)' } + }; + const ERR_COLOR = 'rgba(232, 96, 104, 0.98)'; + const WARN_COLOR = 'rgba(230, 193, 90, 0.95)'; + + function tagForSyscall(name) { + const n = String(name || '').toLowerCase(); + if (/(socket|connect|accept|recv|send|poll|epoll|select)/.test(n)) return TAGS.network_stack; + if (/(open|close|read|write|stat|lseek|fsync|rename|unlink|mkdir|rmdir|getdents|chmod|chown|mount)/.test(n)) return TAGS.file_system; + if (/(mmap|munmap|mprotect|brk|madvise|mlock|shm)/.test(n)) return TAGS.memory_management; + return TAGS.process_scheduler; + } + + function parseCount(value) { + const digits = String(value === undefined || value === null ? '' : value).replace(/[^\d]/g, ''); + return digits ? parseInt(digits, 10) : 0; + } + + function timeStamp() { + const d = new Date(); + const p = (n, w = 2) => String(n).padStart(w, '0'); + return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`; + } + + async function getJson(url) { + if (typeof window.fetchJson === 'function') { + return window.fetchJson(url, { cache: 'no-store' }, { timeoutMs: 5000, retries: 0, context: 'kernel-tape' }); + } + const res = await fetch(url, { cache: 'no-store' }); + return res.json(); + } + + const state = { + open: false, + paused: false, + prevSyscalls: new Map(), + firstSyscallSample: true, + prevConns: new Set(), + firstConnSample: true, + prevPids: new Map(), + firstProcSample: true, + tickIndex: 0, + timer: null, + rowCount: 0, + eventsSinceCore: 0, + eventsThisSecond: 0, + epsWindowStart: Date.now(), + eps: 0 + }; + + const el = {}; + + function injectStyles() { + if (document.getElementById('kernel-tape-styles')) return; + const style = document.createElement('style'); + style.id = 'kernel-tape-styles'; + style.textContent = ` +@keyframes ktape-blink { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } } +@keyframes ktape-rowin { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } } +@keyframes ktape-flash { 0% { background: rgba(232,96,104,0.28); } 100% { background: transparent; } } +#kernel-tape { font-family: 'JetBrains Mono','SFMono-Regular',Menlo,Consolas,monospace; } +#kernel-tape ::-webkit-scrollbar { width: 7px; } +#kernel-tape ::-webkit-scrollbar-thumb { background: rgba(120,150,170,0.35); border-radius: 4px; } +.ktape-row { animation: ktape-rowin 180ms ease-out; } +.ktape-row.err { animation: ktape-rowin 180ms ease-out, ktape-flash 900ms ease-out; } +`; + document.head.appendChild(style); + } + + function buildDom() { + // Toggle pill (visible when drawer is closed). + const toggle = document.createElement('button'); + toggle.id = 'kernel-tape-toggle'; + Object.assign(toggle.style, { + position: 'fixed', right: '16px', top: '150px', zIndex: '9001', + display: 'none', alignItems: 'center', gap: '7px', + padding: '6px 12px', cursor: 'pointer', + background: 'rgba(12,16,20,0.86)', color: '#bcd3de', + border: '1px solid rgba(103,190,224,0.4)', borderRadius: '6px', + font: '600 10px/1 monospace', letterSpacing: '1.5px', textTransform: 'uppercase', + backdropFilter: 'blur(4px)' + }); + toggle.innerHTML = ' ACTIVITY'; + toggle.addEventListener('click', () => api.setOpen(true)); + + // Drawer. + const root = document.createElement('div'); + root.id = 'kernel-tape'; + Object.assign(root.style, { + position: 'fixed', top: '0', right: '0', bottom: '0', width: '330px', + zIndex: '9000', display: 'flex', flexDirection: 'column', + background: 'linear-gradient(180deg, rgba(10,12,16,0.95) 0%, rgba(9,11,15,0.93) 100%)', + borderLeft: '1px solid rgba(103,190,224,0.28)', + boxShadow: '-8px 0 28px rgba(0,0,0,0.35)', + color: '#c5d0da', backdropFilter: 'blur(6px)', + transform: 'translateX(0)', transition: 'transform 260ms ease', + overflow: 'hidden' + }); + + // Header. + const header = document.createElement('div'); + Object.assign(header.style, { + display: 'flex', alignItems: 'center', gap: '8px', + padding: '11px 12px 9px', borderBottom: '1px solid rgba(120,150,170,0.16)' + }); + const dot = document.createElement('span'); + Object.assign(dot.style, { width: '7px', height: '7px', borderRadius: '50%', background: '#67c8e0', boxShadow: '0 0 6px rgba(103,200,224,0.8)', animation: 'ktape-blink 1.4s infinite' }); + const title = document.createElement('span'); + title.textContent = 'KERNEL ACTIVITY'; + Object.assign(title.style, { font: '700 11px/1 monospace', letterSpacing: '2px', color: '#9fd2e4' }); + const live = document.createElement('span'); + live.textContent = 'live'; + Object.assign(live.style, { font: '10px/1 monospace', letterSpacing: '1px', color: '#5d7886' }); + const spacer = document.createElement('span'); + spacer.style.flex = '1'; + const close = document.createElement('button'); + close.textContent = '×'; + Object.assign(close.style, { cursor: 'pointer', background: 'transparent', border: 'none', color: '#7f97a4', font: '16px/1 monospace', padding: '0 2px' }); + close.addEventListener('click', () => api.setOpen(false)); + header.append(dot, title, live, spacer, close); + + // Controls strip. + const controls = document.createElement('div'); + Object.assign(controls.style, { + display: 'flex', alignItems: 'center', gap: '10px', + padding: '6px 12px', borderBottom: '1px solid rgba(120,150,170,0.1)', + font: '9.5px/1 monospace', letterSpacing: '0.5px', color: '#6f8895' + }); + const pause = document.createElement('button'); + pause.textContent = 'PAUSE'; + Object.assign(pause.style, { cursor: 'pointer', background: 'rgba(120,150,170,0.12)', border: '1px solid rgba(120,150,170,0.25)', color: '#9fb3bf', font: '600 9px/1 monospace', letterSpacing: '1px', padding: '4px 8px', borderRadius: '4px' }); + pause.addEventListener('click', () => api.setPaused(!state.paused)); + const eps = document.createElement('span'); + eps.textContent = '0 ev/s'; + const ctrlSpacer = document.createElement('span'); + ctrlSpacer.style.flex = '1'; + const legend = document.createElement('span'); + legend.innerHTML = 'NET FS MEM SCHED'; + controls.append(pause, ctrlSpacer, eps, legend); + + // Body (newest on top). + const body = document.createElement('div'); + Object.assign(body.style, { flex: '1', overflowY: 'auto', overflowX: 'hidden', padding: '4px 0 10px' }); + + root.append(header, controls, body); + document.body.append(toggle, root); + + el.toggle = toggle; + el.root = root; + el.body = body; + el.pauseBtn = pause; + el.eps = eps; + } + + function pushRow(ev) { + if (!el.body) return; + const row = document.createElement('div'); + row.className = 'ktape-row' + (ev.level === 'err' ? ' err' : ''); + Object.assign(row.style, { + display: 'flex', alignItems: 'baseline', gap: '8px', + padding: '2px 12px', whiteSpace: 'nowrap', fontSize: '11px', lineHeight: '15px', + borderLeft: ev.level === 'err' ? `2px solid ${ERR_COLOR}` : '2px solid transparent' + }); + + const t = document.createElement('span'); + t.textContent = ev.ts; + Object.assign(t.style, { color: '#5a7280', flex: '0 0 auto', fontSize: '10px' }); + + const sym = document.createElement('span'); + sym.textContent = ev.sym || '·'; + Object.assign(sym.style, { color: ev.symColor || '#6f8895', flex: '0 0 auto', width: '10px', textAlign: 'center' }); + + const name = document.createElement('span'); + name.textContent = ev.name; + Object.assign(name.style, { color: ev.level === 'err' ? ERR_COLOR : '#d6e0e8', flex: '0 0 auto', fontWeight: ev.level === 'err' ? '700' : '500' }); + + const tag = document.createElement('span'); + tag.textContent = ev.tagText; + Object.assign(tag.style, { color: ev.tagColor, flex: '0 0 auto', fontSize: '9px', letterSpacing: '0.5px' }); + + const detail = document.createElement('span'); + detail.textContent = ev.detail || ''; + Object.assign(detail.style, { color: '#6f8895', flex: '1 1 auto', overflow: 'hidden', textOverflow: 'ellipsis', textAlign: 'right', fontSize: '10px' }); + + row.append(t, sym, name, tag, detail); + el.body.prepend(row); + state.rowCount += 1; + state.eventsThisSecond += 1; + state.eventsSinceCore += 1; + + while (state.rowCount > MAX_ROWS && el.body.lastChild) { + el.body.removeChild(el.body.lastChild); + state.rowCount -= 1; + } + } + + function updateEps() { + const now = Date.now(); + const dt = now - state.epsWindowStart; + if (dt >= 1000) { + state.eps = Math.round((state.eventsThisSecond * 1000) / dt); + state.eventsThisSecond = 0; + state.epsWindowStart = now; + if (el.eps) el.eps.textContent = `${state.eps} ev/s`; + } + } + + async function tickSyscalls() { + let data; + try { + data = await getJson('/api/syscalls-realtime'); + } catch (e) { + return; + } + const list = Array.isArray(data) ? data : (data && Array.isArray(data.syscalls) ? data.syscalls : []); + if (!list.length) return; + + const current = new Map(); + const candidates = []; + list.forEach((entry) => { + const nm = entry && entry.name ? String(entry.name) : ''; + if (!nm) return; + const c = parseCount(entry.count); + current.set(nm, c); + const prev = state.prevSyscalls.has(nm) ? state.prevSyscalls.get(nm) : null; + const isNew = prev === null; + const delta = isNew ? 0 : c - prev; + if (!state.firstSyscallSample && (delta !== 0 || isNew)) { + candidates.push({ nm, c, delta, isNew }); + } + }); + + if (state.firstSyscallSample) { + state.prevSyscalls = current; + state.firstSyscallSample = false; + return; + } + state.prevSyscalls = current; + + candidates.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)); + let emitted = candidates.slice(0, MAX_NEW_PER_TICK); + + // Heartbeat: never let the tape go fully silent. + if (!emitted.length) { + const top = [...current.entries()].sort((a, b) => b[1] - a[1])[0]; + if (top) emitted = [{ nm: top[0], c: top[1], delta: 0, isNew: false, heartbeat: true }]; + } + + emitted.forEach((c) => { + const tag = tagForSyscall(c.nm); + const up = c.delta > 0; + const sym = c.isNew ? '✦' : (c.delta > 0 ? '▲' : (c.delta < 0 ? '▼' : '·')); + const symColor = c.isNew ? '#9fd2e4' : (up ? 'rgba(167,200,120,0.95)' : (c.delta < 0 ? '#7f97a4' : '#566a76')); + const detail = c.delta !== 0 + ? `${c.delta > 0 ? '+' : ''}${c.delta} → ${c.c} proc` + : `${c.c} proc`; + pushRow({ + ts: timeStamp(), + sym, symColor, + name: c.nm.toUpperCase(), + tagText: tag.text, tagColor: tag.color, + detail, + level: c.heartbeat ? 'dim' : 'normal' + }); + }); + } + + async function tickNetwork() { + let data; + try { + data = await getJson('/api/network-stack-realtime'); + } catch (e) { + return; + } + const m = data && data.layer_metrics ? data.layer_metrics : null; + if (!m) return; + + const retrans = m.tcp_udp && m.tcp_udp.retrans_per_sec ? m.tcp_udp.retrans_per_sec : 0; + const ipDrop = m.ip && m.ip.drop_per_sec ? m.ip.drop_per_sec : 0; + const ifDrop = m.driver && m.driver.drops_per_sec ? m.driver.drops_per_sec : 0; + const pktIn = m.ip && m.ip.in_packets_per_sec ? m.ip.in_packets_per_sec : 0; + const pktOut = m.ip && m.ip.out_packets_per_sec ? m.ip.out_packets_per_sec : 0; + const pkts = pktIn + pktOut; + + if (retrans > 0) { + pushRow({ ts: timeStamp(), sym: '!', symColor: ERR_COLOR, name: 'TCP RETRANSMIT', tagText: 'NET', tagColor: TAGS.network_stack.color, detail: `${retrans}/s`, level: 'err' }); + } + if (ipDrop > 0) { + pushRow({ ts: timeStamp(), sym: '!', symColor: ERR_COLOR, name: 'IP DROP', tagText: 'NET', tagColor: TAGS.network_stack.color, detail: `${ipDrop}/s`, level: 'err' }); + } + if (ifDrop > 0) { + pushRow({ ts: timeStamp(), sym: '!', symColor: ERR_COLOR, name: 'NIC DROP', tagText: 'NET', tagColor: TAGS.network_stack.color, detail: `${ifDrop}/s`, level: 'err' }); + } + if (pkts > 0) { + const fmt = pkts >= 1000 ? `${(pkts / 1000).toFixed(1)}k pkt/s` : `${Math.round(pkts)} pkt/s`; + pushRow({ ts: timeStamp(), sym: '⇅', symColor: TAGS.network_stack.color, name: 'ip flow', tagText: 'NET', tagColor: TAGS.network_stack.color, detail: fmt, level: 'normal' }); + } + } + + async function tickConnections() { + let data; + try { + data = await getJson('/api/active-connections'); + } catch (e) { + return; + } + const list = data && Array.isArray(data.connections) ? data.connections : []; + const current = new Set(); + const fresh = []; + list.forEach((c) => { + if (!c || !c.remote) return; + const remoteIp = String(c.remote).split(':')[0]; + // Only real, established remote peers (skip listen/loopback/wildcard). + if (c.state && c.state !== '01') return; + if (!remoteIp || remoteIp === '127.0.0.1' || remoteIp === '0.0.0.0') return; + const key = `${c.local}>${c.remote}`; + current.add(key); + if (!state.firstConnSample && !state.prevConns.has(key)) { + fresh.push(c); + } + }); + + const closed = []; + if (!state.firstConnSample) { + state.prevConns.forEach((key) => { + if (!current.has(key)) closed.push(key); + }); + } + + state.prevConns = current; + if (state.firstConnSample) { + state.firstConnSample = false; + return; + } + + fresh.slice(0, 3).forEach((c) => { + pushRow({ + ts: timeStamp(), sym: '→', symColor: 'rgba(167,200,120,0.95)', + name: `${c.local} → ${c.remote}`, tagText: 'NET', tagColor: TAGS.network_stack.color, + detail: 'ESTAB', level: 'normal' + }); + }); + closed.slice(0, 2).forEach((key) => { + pushRow({ + ts: timeStamp(), sym: '×', symColor: '#7f97a4', + name: key.replace('>', ' × '), tagText: 'NET', tagColor: TAGS.network_stack.color, + detail: 'CLOSE', level: 'normal' + }); + }); + } + + async function tickProcesses() { + let data; + try { + data = await getJson('/api/processes-detailed'); + } catch (e) { + return; + } + const list = data && Array.isArray(data.processes) ? data.processes : []; + if (!list.length) return; + + const current = new Map(); + const spawned = []; + list.forEach((p) => { + if (!p || p.pid === undefined || p.pid === null) return; + const pid = p.pid; + const nm = p.name || 'process'; + current.set(pid, nm); + if (!state.firstProcSample && !state.prevPids.has(pid)) { + spawned.push({ pid, nm }); + } + }); + + const exited = []; + if (!state.firstProcSample) { + state.prevPids.forEach((nm, pid) => { + if (!current.has(pid)) exited.push({ pid, nm }); + }); + } + + state.prevPids = current; + if (state.firstProcSample) { + state.firstProcSample = false; + return; + } + + spawned.slice(0, 4).forEach((p) => { + pushRow({ + ts: timeStamp(), sym: '✦', symColor: 'rgba(167,200,120,0.95)', + name: `exec ${p.nm}`, tagText: 'SCHED', tagColor: TAGS.process_scheduler.color, + detail: `pid ${p.pid}`, level: 'normal' + }); + pulseNode(p.pid, 'rgba(167, 200, 120, 0.95)'); + }); + exited.slice(0, 3).forEach((p) => { + pushRow({ + ts: timeStamp(), sym: '⊝', symColor: '#7f97a4', + name: `exit ${p.nm}`, tagText: 'SCHED', tagColor: TAGS.process_scheduler.color, + detail: `pid ${p.pid}`, level: 'normal' + }); + pulseNode(p.pid, 'rgba(127, 151, 164, 0.9)'); + }); + } + + function fmtRate(n) { + return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${Math.round(n)}`; + } + + async function tickIoPulse() { + let d; + try { + d = await getJson('/api/io-pulse'); + } catch (e) { + return; + } + if (!d) return; + + const pf = d.pgfault_per_sec || 0; + const maj = d.pgmajfault_per_sec || 0; + const swin = d.pswpin_per_sec || 0; + const swout = d.pswpout_per_sec || 0; + const rmb = d.disk_read_mb_s || 0; + const wmb = d.disk_write_mb_s || 0; + const riops = d.disk_read_iops || 0; + const wiops = d.disk_write_iops || 0; + + if (pf > 50) { + pushRow({ ts: timeStamp(), sym: '·', symColor: TAGS.memory_management.color, name: 'page faults', tagText: 'MEM', tagColor: TAGS.memory_management.color, detail: `${fmtRate(pf)}/s`, level: 'normal' }); + } + if (maj > 0) { + pushRow({ ts: timeStamp(), sym: '▲', symColor: WARN_COLOR, name: 'major fault', tagText: 'MEM', tagColor: TAGS.memory_management.color, detail: `${maj}/s`, level: 'normal' }); + } + if (swin > 0) { + pushRow({ ts: timeStamp(), sym: '↧', symColor: WARN_COLOR, name: 'swap in', tagText: 'MEM', tagColor: TAGS.memory_management.color, detail: `${fmtRate(swin)}/s`, level: 'normal' }); + } + if (swout > 0) { + pushRow({ ts: timeStamp(), sym: '↥', symColor: WARN_COLOR, name: 'swap out', tagText: 'MEM', tagColor: TAGS.memory_management.color, detail: `${fmtRate(swout)}/s`, level: 'normal' }); + } + if (rmb > 0.05) { + pushRow({ ts: timeStamp(), sym: '◀', symColor: TAGS.file_system.color, name: 'block read', tagText: 'FS', tagColor: TAGS.file_system.color, detail: `${rmb.toFixed(2)} MB/s · ${riops} iops`, level: 'normal' }); + } + if (wmb > 0.05) { + pushRow({ ts: timeStamp(), sym: '▶', symColor: TAGS.file_system.color, name: 'block write', tagText: 'FS', tagColor: TAGS.file_system.color, detail: `${wmb.toFixed(2)} MB/s · ${wiops} iops`, level: 'normal' }); + } + } + + // ---- Map linkage: transient ripples on the SVG kernel map (uses global d3) ---- + function pulseNode(pid, color) { + if (typeof d3 === 'undefined' || pid === undefined || pid === null) return; + const node = d3.select(`.process-node-group[data-pid="${pid}"] circle.process-node`); + if (node.empty()) return; + const cx = parseFloat(node.attr('cx')); + const cy = parseFloat(node.attr('cy')); + if (!isFinite(cx) || !isFinite(cy)) return; + const ring = d3.select('svg').append('circle') + .attr('cx', cx).attr('cy', cy).attr('r', 4) + .attr('fill', 'none').attr('stroke', color).attr('stroke-width', 1.6) + .attr('opacity', 0.9).style('pointer-events', 'none'); + ring.transition().duration(900).ease(d3.easeCubicOut) + .attr('r', 26).attr('stroke-width', 0.3).attr('opacity', 0) + .on('end', () => ring.remove()); + } + + function pulseCore(intensity) { + if (typeof d3 === 'undefined' || intensity <= 0) return; + const core = d3.select('.central-circle'); + if (core.empty()) return; + const cx = parseFloat(core.attr('cx')); + const cy = parseFloat(core.attr('cy')); + if (!isFinite(cx) || !isFinite(cy)) return; + const amp = Math.max(0, Math.min(1, intensity / 8)); + const ring = d3.select('svg').append('circle') + .attr('cx', cx).attr('cy', cy).attr('r', 56) + .attr('fill', 'none') + .attr('stroke', `rgba(103, 190, 224, ${(0.16 + amp * 0.24).toFixed(2)})`) + .attr('stroke-width', 1).style('pointer-events', 'none'); + ring.transition().duration(1100).ease(d3.easeCubicOut) + .attr('r', 80 + amp * 60).attr('stroke-width', 0.2).attr('opacity', 0) + .on('end', () => ring.remove()); + } + + function tick() { + if (state.paused || !state.open) return; + const i = state.tickIndex++; + // Core "breath" reflects activity accumulated since the previous tick. + pulseCore(state.eventsSinceCore); + state.eventsSinceCore = 0; + tickSyscalls(); + if (i % 2 === 0) { tickNetwork(); tickIoPulse(); } + else { tickConnections(); } + if (i % 3 === 2) tickProcesses(); + updateEps(); + } + + const api = { + setOpen(open) { + state.open = !!open; + if (el.root) el.root.style.transform = open ? 'translateX(0)' : 'translateX(100%)'; + if (el.toggle) el.toggle.style.display = open ? 'none' : 'inline-flex'; + }, + setPaused(paused) { + state.paused = !!paused; + if (el.pauseBtn) { + el.pauseBtn.textContent = paused ? 'RESUME' : 'PAUSE'; + el.pauseBtn.style.color = paused ? '#e8c15a' : '#9fb3bf'; + } + } + }; + window.KernelTape = api; + + function start() { + if (typeof isMobileLayout === 'function' && isMobileLayout()) return; + injectStyles(); + buildDom(); + // Hidden by default: only the ACTIVITY pill shows until the user opens it. + api.setOpen(false); + state.timer = setInterval(tick, POLL_MS); + // Prime quickly so the feed comes alive without waiting a full interval + // once it's opened (tick() is a no-op while the drawer is closed). + setTimeout(tick, 250); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start); + } else { + start(); + } +})(); diff --git a/static/js/nginx_files.js b/static/js/nginx_files.js new file mode 100755 index 0000000..4bf241e --- /dev/null +++ b/static/js/nginx_files.js @@ -0,0 +1,577 @@ +// Nginx Files Manager for Bezier Curves +class NginxFilesManager { + constructor() { + this.files = []; + this.updateInterval = null; + this.highlightedPid = null; + this.procFilesActive = false; + } + + // Initialize nginx files visualization + init() { + debugLog('🔧 Initializing NginxFilesManager...'); + this.updateFiles(); + this.startAutoUpdate(10000); // Update every 10 seconds + } + + // Update files data + async updateFiles() { + try { + debugLog('📁 Fetching I/O open files...'); + const response = await fetch('/api/io-open-files?limit=40'); + const data = await response.json(); + + debugLog('📁 Received nginx files:', data); + + if (data.files && data.files.length > 0) { + this.files = data.files; + debugLog('🎨 Rendering files on curves...'); + this.renderFilesOnCurves(); + } else { + debugLog('⚠️ No nginx files found'); + } + } catch (error) { + console.error('Error fetching nginx files:', error); + } + } + + // Render open files as markers docked on the KERNEL I/O LAYER rail. + // Files are placed by activity: busiest near the crest (left), decreasing + // toward the right — mirroring the decaying whisker wave above the rail. + renderFilesOnCurves() { + const svg = d3.select('svg'); + if (svg.empty()) { + console.error('❌ SVG element not found!'); + return; + } + + // Clear previous markers/labels. + d3.selectAll('.file-label').remove(); + d3.selectAll('.file-label-bg').remove(); + d3.selectAll('.file-endpoint').remove(); + d3.selectAll('.file-icon').remove(); + d3.selectAll('[class^="file-group-"]').remove(); + d3.selectAll('.io-file-layer').remove(); + + const geom = window.__ioLayerGeometry; + if (!geom) { + debugLog('⚠️ I/O layer geometry not ready, skipping file markers'); + return; + } + + const files = (this.files || []).slice(0, 18); + if (!files.length) return; + + const layer = svg.append('g').attr('class', 'io-file-layer'); + const { surfaceY, railLeft, railRight } = geom; + const margin = 34; + const span = (railRight - railLeft) - margin * 2; + const maxActivity = Math.max(...files.map(f => Number(f.activity) || 1), 1); + const labelCount = Math.min(5, files.length); + + files.forEach((file, index) => { + const t = files.length <= 1 ? 0 : index / (files.length - 1); + const x = railLeft + margin + t * span; + const fileType = file.type || 'other'; + const color = this.getFileTypeColor(fileType); + const activity = Number(file.activity) || 1; + const norm = activity / maxActivity; + const r = 2.4 + norm * 4.6; + + const fileGroup = layer.append('g') + .attr('class', `file-group-${index} io-file-marker`) + .attr('data-file-index', index) + .attr('data-file-type', fileType) + .attr('data-pids', (file.pids || []).join(',')) + .style('cursor', 'pointer'); + + // Short stem tying the file marker to the rail surface. + fileGroup.append('line') + .attr('x1', x) + .attr('y1', surfaceY) + .attr('x2', x) + .attr('y2', surfaceY - (4 + norm * 10)) + .attr('stroke', color) + .attr('stroke-width', 0.8) + .attr('opacity', 0.55); + + // Refined sensor node (ring + core) with a radar ping on the busiest. + this.appendFileNode(fileGroup, x, surfaceY, color, r, index < labelCount); + + // Label the top files (path tail + owning process) below the rail. + if (index < labelCount) { + const labelY = surfaceY + 14 + (index % 2) * 11; + fileGroup.append('text') + .attr('class', 'file-label') + .attr('x', x) + .attr('y', labelY) + .attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace') + .style('font-size', '7.5px') + .style('letter-spacing', '0.4px') + .style('fill', 'rgba(48, 48, 48, 0.78)') + .text(this.getShortFileName(file.path)); + + if (file.process) { + fileGroup.append('text') + .attr('class', 'file-label') + .attr('x', x) + .attr('y', labelY + 8) + .attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace') + .style('font-size', '6.5px') + .style('fill', color) + .text(`${file.process}${file.process_count > 1 ? ' ×' + file.process_count : ''}`); + } + } + + fileGroup + .on('mouseover', () => this.showTooltip(file, x, surfaceY)) + .on('mouseout', () => this.hideTooltip()); + }); + + // Re-apply selection state after a re-render (auto-update keeps it sticky). + if (this.procFilesActive) { + // Accurate process lane is drawn separately; just keep the base wave dim. + d3.selectAll('.io-file-marker').interrupt().style('opacity', 0.1); + } else if (this.highlightedPid !== null && this.highlightedPid !== undefined) { + this.applyProcessHighlight(); + } + } + + // Frontend mirror of the backend file-type classifier. + classifyPath(path) { + const low = String(path || '').toLowerCase(); + if (low.includes('/var/log/') || low.endsWith('.log')) return 'log'; + if (low.includes('/etc/') || /\.(conf|cfg|ini|ya?ml|json|toml)$/.test(low)) return 'config'; + if (low.endsWith('.so') || low.includes('.so.') || low.includes('/lib/') || low.includes('/lib64/')) return 'lib'; + if (low.includes('/dev/')) return 'device'; + if (/\.(db|sqlite3?)$/.test(low) || low.includes('/var/lib/')) return 'data'; + return 'other'; + } + + // A small instrument-style node: solid core + concentric ring that emits a + // radar ping (expand + fade) instead of a plain swelling dot. + appendFileNode(group, x, y, color, r, doPing) { + if (doPing) { + const ping = group.append('circle') + .attr('cx', x).attr('cy', y).attr('r', r + 1.5) + .attr('fill', 'none') + .attr('stroke', color) + .attr('stroke-width', 1) + .attr('opacity', 0.7); + const emit = () => { + ping.attr('r', r + 1.5).attr('opacity', 0.7) + .transition().duration(1500).ease(d3.easeQuadOut) + .attr('r', r + 9).attr('opacity', 0) + .on('end', emit); + }; + emit(); + } + + group.append('circle') + .attr('cx', x).attr('cy', y).attr('r', r + 1.6) + .attr('fill', 'none') + .attr('stroke', color) + .attr('stroke-width', 0.8) + .attr('opacity', 0.55); + + const core = group.append('circle') + .attr('cx', x).attr('cy', y).attr('r', r) + .attr('class', 'file-endpoint') + .attr('fill', color) + .attr('stroke', 'rgba(247, 247, 240, 0.95)') + .attr('stroke-width', 0.8) + .attr('opacity', 0.96); + + return core; + } + + // Highlight the open files held by a selected process; dim the rest. + highlightProcessFiles(pid) { + this.highlightedPid = (pid === undefined || pid === null) ? null : Number(pid); + this.applyProcessHighlight(); + } + + clearProcessHighlight() { + this.highlightedPid = null; + this.procFilesActive = false; + d3.selectAll('.io-proc-file-layer').remove(); + d3.selectAll('.io-file-marker') + .interrupt() + .transition().duration(200) + .style('opacity', 1); + d3.selectAll('.io-file-layer .file-endpoint') + .transition().duration(200) + .attr('stroke', 'rgba(245, 245, 238, 0.85)') + .attr('stroke-width', 1); + d3.selectAll('.io-file-selected-count').remove(); + } + + // Render the exact open files of a selected process as a bright lane on the + // rail (from /api/process//fds), dimming the system-wide base wave. + // This is the accurate counterpart to the quick data-pids highlight. + showProcessFiles(pid, fdsData) { + const geom = window.__ioLayerGeometry; + if (!geom || !fdsData) return; + + this.highlightedPid = Number(pid); + this.procFilesActive = true; + + // Merge regular open files and file-type descriptors, de-duplicated by path. + const files = []; + const seen = new Set(); + (fdsData.open_files || []).forEach((f) => { + if (f && f.path && !seen.has(f.path)) { + seen.add(f.path); + files.push({ path: f.path, fd: f.fd }); + } + }); + (fdsData.descriptors || []).forEach((d) => { + if (d && d.type === 'file' && d.target && !seen.has(d.target)) { + seen.add(d.target); + files.push({ path: d.target, fd: d.fd }); + } + }); + + const svg = d3.select('svg'); + // Dim the system-wide wave; the process lane takes focus. + d3.selectAll('.io-file-marker').interrupt().transition().duration(220).style('opacity', 0.1); + d3.selectAll('.io-proc-file-layer').remove(); + + const layer = svg.append('g').attr('class', 'io-proc-file-layer'); + const { surfaceY, railLeft, railRight } = geom; + const margin = 34; + const span = (railRight - railLeft) - margin * 2; + + const list = files.slice(0, 22); + const labelCount = Math.min(8, list.length); + + list.forEach((file, i) => { + const t = list.length <= 1 ? 0.5 : i / (list.length - 1); + const x = railLeft + margin + t * span; + const type = this.classifyPath(file.path); + const color = this.getFileTypeColor(type); + + const group = layer.append('g') + .attr('class', 'io-proc-file-marker') + .attr('data-file-type', type) + .style('cursor', 'pointer'); + + group.append('line') + .attr('x1', x).attr('y1', surfaceY) + .attr('x2', x).attr('y2', surfaceY - 10) + .attr('stroke', color) + .attr('stroke-width', 0.9) + .attr('opacity', 0.72); + + // Refined sensor node (ring + core) with a radar ping on the labelled set. + this.appendFileNode(group, x, surfaceY, color, 3.6, i < labelCount); + + if (i < labelCount) { + const labelY = surfaceY + 14 + (i % 2) * 11; + group.append('text') + .attr('class', 'file-label') + .attr('x', x).attr('y', labelY) + .attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace') + .style('font-size', '7.5px') + .style('letter-spacing', '0.4px') + .style('fill', 'rgba(48, 48, 48, 0.82)') + .text(this.getShortFileName(file.path)); + + if (file.fd !== undefined && file.fd !== null) { + group.append('text') + .attr('class', 'file-label') + .attr('x', x).attr('y', labelY + 8) + .attr('text-anchor', 'middle') + .style('font-family', 'Share Tech Mono, monospace') + .style('font-size', '6.5px') + .style('fill', color) + .text(`fd ${file.fd}`); + } + } + + group + .on('mouseover', () => this.showTooltip({ ...file, type, pid }, x, surfaceY)) + .on('mouseout', () => this.hideTooltip()); + }); + + layer.append('text') + .attr('class', 'io-file-selected-count') + .attr('x', railLeft) + .attr('y', surfaceY - 16) + .style('font-family', 'Share Tech Mono, monospace') + .style('font-size', '7.5px') + .style('letter-spacing', '0.6px') + .style('fill', 'rgba(48, 48, 48, 0.82)') + .text(`PID ${pid} · ${list.length} OPEN FILE${list.length === 1 ? '' : 'S'}`); + } + + applyProcessHighlight() { + const pid = this.highlightedPid; + d3.selectAll('.io-file-selected-count').remove(); + if (pid === null || pid === undefined) { + this.clearProcessHighlight(); + return; + } + let matches = 0; + d3.selectAll('.io-file-marker').each(function () { + const group = d3.select(this); + const pidsAttr = group.attr('data-pids') || ''; + const pids = pidsAttr ? pidsAttr.split(',').map(Number) : []; + const isOwned = pids.includes(pid); + if (isOwned) matches += 1; + group.interrupt().transition().duration(220) + .style('opacity', isOwned ? 1 : 0.16); + group.select('.file-endpoint') + .transition().duration(220) + .attr('stroke', isOwned ? 'rgba(247, 247, 240, 0.98)' : 'rgba(245, 245, 238, 0.85)') + .attr('stroke-width', isOwned ? 1.8 : 1); + }); + + // Compact readout near the rail showing how many top files this process holds. + const geom = window.__ioLayerGeometry; + if (geom) { + d3.select('svg').select('.io-file-layer').append('text') + .attr('class', 'io-file-selected-count') + .attr('x', geom.railLeft) + .attr('y', geom.surfaceY - 16) + .style('font-family', 'Share Tech Mono, monospace') + .style('font-size', '7.5px') + .style('letter-spacing', '0.6px') + .style('fill', 'rgba(48, 48, 48, 0.78)') + .text(`PID ${pid} · ${matches} HOT FILE${matches === 1 ? '' : 'S'}`); + } + } + + // Get color based on file type (monochrome scene with subtle accents). + getFileTypeColor(type) { + const colors = { + 'config': 'rgba(88, 142, 196, 0.92)', // cool blue — configuration + 'log': 'rgba(206, 96, 84, 0.92)', // warm red — logs + 'lib': 'rgba(140, 120, 188, 0.9)', // violet — shared libraries + 'data': 'rgba(96, 170, 132, 0.9)', // green — databases/state + 'device': 'rgba(196, 158, 70, 0.9)', // amber — device nodes + 'other': 'rgba(120, 120, 120, 0.9)' // gray — regular files + }; + return colors[type] || colors['other']; + } + + // Highlight associated Bezier curve + highlightCurve(curveIndex) { + if (curveIndex !== undefined) { + const curves = d3.selectAll('.bezier-curve').nodes(); + if (curves[curveIndex]) { + d3.select(curves[curveIndex]) + .transition() + .duration(200) + .attr("stroke", "#4A90E2") + .attr("stroke-width", 2) + .attr("opacity", 0.8); + } + } + } + + // Unhighlight Bezier curve + unhighlightCurve(curveIndex) { + if (curveIndex !== undefined) { + const curves = d3.selectAll('.bezier-curve').nodes(); + if (curves[curveIndex]) { + d3.select(curves[curveIndex]) + .transition() + .duration(200) + .attr("stroke", "rgba(60, 60, 60, 0.3)") + .attr("stroke-width", 0.8) + .attr("opacity", 0.3); + } + } + } + + // Calculate positions for file labels - attach to end points of Bezier curves + calculateLabelPositionsOnCurves(numFiles, bezierCurves, height) { + const positions = []; + + if (bezierCurves.length === 0) { + // Fallback to old method if no curves available + return this.calculateLabelPositions(numFiles, window.innerWidth / 2, height); + } + + // Select curves from the center area (where files should be attached) + const centerCurves = []; + const width = window.innerWidth; + const centerX = width / 2; + + bezierCurves.forEach((curve, index) => { + const path = d3.select(curve); + const pathData = path.attr('d'); + if (pathData) { + // Extract START point from path (first coordinates after M) + // Path format: M startX,startY C ... + const startMatches = pathData.match(/M([\d.]+),([\d.]+)/); + if (startMatches) { + const startX = parseFloat(startMatches[1]); + const startY = parseFloat(startMatches[2]); + + // Use curves that start near the bottom (where files should be) + // Files should be at the bottom (start of curves) + if (startY > height - 250 && startY < height - 150) { + centerCurves.push({ index, startX, startY }); + } + } + } + }); + + // Sort by X position to distribute evenly + centerCurves.sort((a, b) => a.endX - b.endX); + + // Select curves evenly distributed + const selectedCurves = []; + if (centerCurves.length > 0) { + const step = Math.max(1, Math.floor(centerCurves.length / numFiles)); + for (let i = 0; i < numFiles && i * step < centerCurves.length; i++) { + selectedCurves.push(centerCurves[i * step]); + } + } + + // Create positions from selected curves - use START points (bottom of curves) + selectedCurves.forEach((curve, i) => { + positions.push({ + x: curve.startX, + y: curve.startY, + curveIndex: curve.index + }); + }); + + // Fill remaining positions if needed (at bottom of screen) + while (positions.length < numFiles) { + const baseY = height - 200; // Bottom where curves start + const spacing = 120; + const offset = (positions.length - (numFiles - 1) / 2) * spacing; + positions.push({ + x: centerX + offset, + y: baseY + (positions.length % 2) * 15, + curveIndex: undefined + }); + } + + return positions.slice(0, numFiles); + } + + // Fallback method for calculating positions + calculateLabelPositions(numFiles, centerX, height) { + const positions = []; + const baseY = height - 140; // Above the curves + const spacing = 120; // Space between labels + + for (let i = 0; i < numFiles; i++) { + const offset = (i - (numFiles - 1) / 2) * spacing; + positions.push({ + x: centerX + offset, + y: baseY + (i % 2) * 15 // Slight vertical offset for better readability + }); + } + + return positions; + } + + // Get short file name for display + getShortFileName(fullPath) { + const parts = fullPath.split('/'); + if (parts.length <= 2) { + return fullPath; + } + + // Show last two parts of path + const lastTwo = parts.slice(-2); + return lastTwo.join('/'); + } + + // Show tooltip with file information (styled to match the process hover). + showTooltip(file, x, y) { + d3.selectAll('.tooltip').remove(); + + const tooltip = d3.select("body") + .append("div") + .attr("class", "tooltip") + .style("position", "absolute") + .style("background", "rgba(0, 0, 0, 0.9)") + .style("color", "white") + .style("padding", "10px") + .style("border-radius", "4px") + .style("font-size", "12px") + .style("font-family", "Share Tech Mono, monospace") + .style("pointer-events", "none") + .style("z-index", "1000") + .style("max-width", "320px") + .style("opacity", 0); + + const fileType = file.type || this.classifyPath(file.path); + const typeLabel = fileType.charAt(0).toUpperCase() + fileType.slice(1); + + let html = ` + File: ${this.getShortFileName(file.path)}
+ Type: ${typeLabel}
+ Path: ${file.path}
+ `; + if (file.process) { + html += `Process: ${file.process}${file.process_count > 1 ? ' \u00d7' + file.process_count : ''}
`; + } + if (file.pid !== undefined && file.pid !== null) { + html += `PID: ${file.pid}
`; + } + if (file.fd !== undefined && file.fd !== null) { + html += `FD: ${file.fd}
`; + } + if (file.activity !== undefined && file.activity !== null) { + html += `Open handles: ${file.activity}
`; + } + tooltip.html(html); + + tooltip.transition().duration(200).style("opacity", 1); + + const tooltipRect = tooltip.node().getBoundingClientRect(); + const left = Math.min(x + 14, window.innerWidth - tooltipRect.width - 10); + const top = Math.max(y - tooltipRect.height - 12, 10); + tooltip.style("left", left + "px").style("top", top + "px"); + } + + // Hide tooltip + hideTooltip() { + d3.selectAll(".tooltip").remove(); + } + + // Start auto update + startAutoUpdate(intervalMs = 10000) { + if (this.updateInterval) { + clearInterval(this.updateInterval); + } + this.updateInterval = setInterval(() => { + this.updateFiles(); + }, intervalMs); + } + + // Stop auto update + stopAutoUpdate() { + if (this.updateInterval) { + clearInterval(this.updateInterval); + this.updateInterval = null; + } + } + + // Cleanup + destroy() { + this.stopAutoUpdate(); + d3.selectAll('.file-label').remove(); + d3.selectAll('.file-label-bg').remove(); + d3.selectAll('.file-endpoint').remove(); + d3.selectAll('.file-icon').remove(); + d3.selectAll('[class^="file-group-"]').remove(); + d3.selectAll('.tooltip').remove(); + } +} + +// Make it globally available for browser +if (typeof window !== 'undefined') { + window.NginxFilesManager = NginxFilesManager; +} diff --git a/templates/linux-crypto-subsystem.html b/templates/linux-crypto-subsystem.html index edd955e..3d251c3 100644 --- a/templates/linux-crypto-subsystem.html +++ b/templates/linux-crypto-subsystem.html @@ -139,7 +139,8 @@

Linux Crypto Kernel Visualization

- + +