diff --git a/kernel_ai/api/rest.py b/kernel_ai/api/rest.py index 83ef6ef..4512dbf 100644 --- a/kernel_ai/api/rest.py +++ b/kernel_ai/api/rest.py @@ -8,6 +8,7 @@ ("/syscalls-realtime", "syscalls_realtime", h.syscalls_realtime, None), ("/syscall/", "syscall_detail", h.syscall_detail, None), ("/irq/", "irq_detail", h.irq_detail, None), + ("/irq//history", "irq_history", h.irq_history, None), ("/kernel-data", "kernel_data", h.kernel_data, None), ("/io-pulse", "io_pulse", h.io_pulse, None), ("/process-kernel-map", "process_kernel_map", h.process_kernel_map, None), @@ -16,6 +17,8 @@ ("/io-open-files", "io_open_files", h.io_open_files, None), ("/active-connections", "active_connections", h.active_connections, None), ("/traceroute", "traceroute_info", h.traceroute_info, None), + ("/flow-history", "flow_history", h.flow_history, None), + ("/ip-entry", "ip_entry", h.ip_entry, None), ("/network-stack-realtime", "network_stack_realtime", h.network_stack_realtime, None), ("/devices-realtime", "devices_realtime", h.devices_realtime, None), ("/filesystem-blocks", "filesystem_blocks", h.filesystem_blocks, None), diff --git a/kernel_ai/http/api.py b/kernel_ai/http/api.py index 8c4f070..b4d2c78 100644 --- a/kernel_ai/http/api.py +++ b/kernel_ai/http/api.py @@ -5,6 +5,7 @@ io_open_files, io_pulse, irq_detail, + irq_history, kernel_data, kernel_dna, ml_anomalies, @@ -26,6 +27,8 @@ filesystem_blocks, hot_files, isolation_context, + flow_history, + ip_entry, network_stack_realtime, path_walk, traceroute_info, @@ -63,6 +66,8 @@ "devices_realtime", "ext4_anatomy", "ext4_journal", + "flow_history", + "ip_entry", "filesystem_blocks", "hot_files", "path_walk", @@ -87,6 +92,7 @@ "io_open_files", "io_pulse", "irq_detail", + "irq_history", "isolation_context", "kernel_data", "kernel_dna", diff --git a/kernel_ai/http/api_handlers/kernel.py b/kernel_ai/http/api_handlers/kernel.py index ab7c11b..19ef837 100644 --- a/kernel_ai/http/api_handlers/kernel.py +++ b/kernel_ai/http/api_handlers/kernel.py @@ -98,6 +98,20 @@ def _payload(): return api_json(_payload) +def irq_history(irq): + """Lifetime of one interrupt line versus host uptime.""" + + def _payload(): + from kernel_ai.services import irq_anatomy + + anatomy = irq_anatomy.history(irq) + if not anatomy or not anatomy.get("found"): + return {"timestamp": datetime.now().isoformat(), "irq": str(irq), "found": False} + return {"timestamp": datetime.now().isoformat(), **anatomy} + + return api_json(_payload) + + def irq_detail(irq): """What one interrupt line is on this machine. diff --git a/kernel_ai/http/api_handlers/network_system.py b/kernel_ai/http/api_handlers/network_system.py index 9a8ca6d..c17ad78 100644 --- a/kernel_ai/http/api_handlers/network_system.py +++ b/kernel_ai/http/api_handlers/network_system.py @@ -14,6 +14,34 @@ def active_connections(): return api_json(lambda: {"connections": _network_service.get_active_connections()}) +def ip_entry(): + def _payload(): + kind = request.args.get("kind", "").strip() + if kind not in {"neigh", "route"}: + raise ValueError("kind must be neigh or route") + return _network_service.get_ip_entry( + kind=kind, + ip=request.args.get("ip"), + destination=request.args.get("destination"), + gateway=request.args.get("gateway"), + iface=request.args.get("iface"), + ) + + return api_json(_payload, exception_statuses=[(ValueError, 400)]) + + +def flow_history(): + def _payload(): + local = request.args.get("local", "").strip() + remote = request.args.get("remote", "").strip() + proto = request.args.get("proto", "TCP").strip() + if not local or not remote: + raise ValueError("Missing 'local' or 'remote' query parameter") + return _network_service.get_flow_history(local=local, remote=remote, proto=proto) + + return api_json(_payload, exception_statuses=[(ValueError, 400)]) + + def traceroute_info(): def _payload(): state = get_state_container(current_app) diff --git a/kernel_ai/services/irq_anatomy.py b/kernel_ai/services/irq_anatomy.py index 0853f11..8d0f392 100644 --- a/kernel_ai/services/irq_anatomy.py +++ b/kernel_ai/services/irq_anatomy.py @@ -235,6 +235,80 @@ def _chain_for_line(device, chip, vector, symbols, thread): return chain +def _uptime_s(): + try: + with open("/proc/uptime", "r", encoding="utf-8", errors="ignore") as fh: + return float(fh.read().split()[0]) + except (OSError, ValueError, IndexError): + return None + + +def history(irq): + """Biography of one interrupt line: lifetime count versus the host's age. + + The anatomy card is the path into the kernel. This is how that line has + lived since boot — how many times it rang, what share of every interrupt + that is, which CPU took most of them, and the mean rate over uptime. + A wall-clock of the first fire is not something the kernel keeps. + """ + irq = str(irq).strip() + if not re.fullmatch(r"[A-Za-z0-9_-]{1,16}", irq): + return {"found": False, "irq": irq} + + interrupts = read_interrupts() + if irq not in interrupts: + return {"found": False, "irq": irq} + + counts, desc = interrupts[irq] + total = sum(counts) + host_total = sum(sum(row_counts) for row_counts, _ in interrupts.values()) + uptime = _uptime_s() + online = _cpus_online() + per_cpu = counts[:online] if len(counts) >= online else counts + top_cpu = None + top_count = 0 + if per_cpu: + top_cpu = max(range(len(per_cpu)), key=lambda i: per_cpu[i]) + top_count = int(per_cpu[top_cpu]) + + payload = { + "found": True, + "irq": irq, + "label": desc, + "kind": "line" if irq.isdigit() else "aggregate", + "total": total, + "host_total": host_total, + "share": round(total / host_total, 5) if host_total else None, + "uptime_s": uptime, + "lifetime_per_sec": round(total / uptime, 4) if uptime and uptime > 0 else None, + "top_cpu": top_cpu, + "top_cpu_count": top_count, + "top_cpu_share": round(top_count / total, 4) if total else None, + "device": None, + "chip": None, + "softirq": None, + "source": "/proc/interrupts · /proc/uptime", + } + + if irq.isdigit(): + device = _read(f"{_SYS_IRQ}/{irq}/actions") + chip = _read(f"{_SYS_IRQ}/{irq}/chip_name") + payload["device"] = device or None + payload["chip"] = chip or None + vector = vector_for(device or desc, chip) + if vector: + softirqs = read_softirqs() + payload["softirq"] = { + "vector": vector, + "total": softirqs.get(vector), + "basis": "driver class", + } + else: + payload["device"] = desc.lower() if desc else None + + return payload + + def describe(irq): """Everything the card shows about one line, or None if there is no such line.""" irq = str(irq).strip() diff --git a/kernel_ai/services/network.py b/kernel_ai/services/network.py index 685f1e7..d6014ad 100644 --- a/kernel_ai/services/network.py +++ b/kernel_ai/services/network.py @@ -326,15 +326,217 @@ def _parse_ss_detail_metrics(line, details): if bbr: metrics["bbr_bw_mbps"] = round(_rate_to_mbps(bbr.group(1), bbr.group(2)), 4) metrics["bbr_mrtt_ms"] = float(bbr.group(3)) + # skmem:(r,rb,t,tb,...) + skmem = re.search( + r"skmem:\(r(\d+),rb(\d+),t(\d+),tb(\d+)", + details, + ) + if skmem: + metrics["rmem"] = int(skmem.group(1)) + metrics["rcvbuf"] = int(skmem.group(2)) + metrics["wmem"] = int(skmem.group(3)) + metrics["sndbuf"] = int(skmem.group(4)) return metrics +_CC_NAMES = { + "cubic", "bbr", "reno", "vegas", "illinois", "dctcp", "westwood", + "htcp", "yeah", "lp", "veno", "cdg", "nv", "bic", "highspeed", +} + + +def _owner_from_cgroup(path): + """Last systemd unit, not the whole cgroup path.""" + raw = str(path or "").rstrip("/") + if not raw: + return None + last = raw.split("/")[-1] + last = last.replace(".scope", "").replace(".service", "") + if last.startswith("app-gnome-"): + last = last[len("app-gnome-"):] + last = last.split("_")[0].split("-")[0] + elif last.startswith("app-org."): + last = last.split(".")[-1].split("-")[0] + elif last.startswith("app-"): + last = last[4:].split("-")[0] + last = last.strip() + return last[:24] or None + + +def _parse_ss_timer(line): + match = re.search(r"timer:\(([^,]+),([^,]+),(\d+)\)", line) + if not match: + return None + return { + "kind": match.group(1).strip(), + "left": match.group(2).strip(), + "retrans": int(match.group(3)), + } + + +def _int_field(text, name): + match = re.search(rf"\b{name}:(\d+)", text) + if not match: + return None + return int(match.group(1)) + + +def _float_field(text, name): + match = re.search(rf"\b{name}:(\d+(?:\.\d+)?)", text) + if not match: + return None + return float(match.group(1)) + + +def _parse_ss_flow_row(header, details, proto): + """Biography fields from one ss -inoe row. No sock cookie, no cgroup path.""" + parts = header.split() + if proto == "TCP": + if len(parts) < 5: + return None + state = parts[0] + try: + recv_q = int(parts[1]) + send_q = int(parts[2]) + except ValueError: + recv_q = None + send_q = None + local = parts[3] + remote = parts[4] + else: + if len(parts) < 4: + return None + state = "UNCONN" if parts[3] in ("0.0.0.0:*", "*:*", "[::]:*") else "ESTAB" + try: + recv_q = int(parts[0]) + send_q = int(parts[1]) + except ValueError: + recv_q = None + send_q = None + local = parts[2] + remote = parts[3] + + blob = f"{header} {details}" + rtt = None + rtt_var = None + rtt_match = re.search(r"\brtt:(\d+(?:\.\d+)?)/(\d+(?:\.\d+)?)", details) + if rtt_match: + rtt = float(rtt_match.group(1)) + rtt_var = float(rtt_match.group(2)) + retrans_now = None + retrans_total = None + retrans_match = re.search(r"\bretrans:(\d+)/(\d+)", details) + if retrans_match: + retrans_now = int(retrans_match.group(1)) + retrans_total = int(retrans_match.group(2)) + cc = None + for token in details.split(): + name = token.lower() + if name in _CC_NAMES: + cc = name + break + cgroup = None + cg = re.search(r"cgroup:(\S+)", header) + if cg: + cgroup = cg.group(1) + return { + "local": local, + "remote": remote, + "proto": proto, + "state": state, + "recv_q": recv_q, + "send_q": send_q, + "owner": _owner_from_cgroup(cgroup), + "bytes_sent": _int_field(details, "bytes_sent"), + "bytes_acked": _int_field(details, "bytes_acked"), + "bytes_received": _int_field(details, "bytes_received"), + "bytes_retrans": _int_field(details, "bytes_retrans"), + "segs_out": _int_field(details, "segs_out"), + "segs_in": _int_field(details, "segs_in"), + "data_segs_out": _int_field(details, "data_segs_out"), + "data_segs_in": _int_field(details, "data_segs_in"), + "retrans_now": retrans_now, + "retrans_total": retrans_total, + "last_snd_ms": _int_field(details, "lastsnd"), + "last_rcv_ms": _int_field(details, "lastrcv"), + "last_ack_ms": _int_field(details, "lastack"), + "busy_ms": _int_field(details, "busy"), + "rtt_ms": rtt, + "rtt_var_ms": rtt_var, + "min_rtt_ms": _float_field(details, "minrtt"), + "cwnd": _int_field(details, "cwnd"), + "cc": cc, + "timer": _parse_ss_timer(blob), + "source": "ss -tinoe" if proto == "TCP" else "ss -uinoe", + } + + +def _ss_flow_dump(proto): + ss_cmd = resolve_binary("ss") + if not ss_cmd: + return [] + flags = "-tinoe" if proto == "TCP" else "-uinoe" + try: + result = subprocess.run( + [ss_cmd, flags], + capture_output=True, + text=True, + timeout=2, + check=False, + ) + lines = (result.stdout or "").splitlines() + except (subprocess.TimeoutExpired, OSError): + return [] + rows = [] + idx = 0 + while idx < len(lines): + line = lines[idx] + idx += 1 + if not line.strip() or line.startswith("State") or line.startswith("Netid") or line.startswith("Recv-Q"): + continue + extra = [] + while idx < len(lines) and lines[idx][:1].isspace(): + extra.append(lines[idx]) + idx += 1 + row = _parse_ss_flow_row(line, " ".join(extra), proto) + if row: + rows.append(row) + return rows + + +def get_flow_history(local=None, remote=None, proto="TCP"): + """Biography of one 4-tuple from ss: lifetime bytes, last talk, min RTT. + + The kernel does not publish a wall-clock birth time for a socket the way + it does for a pid. What it does keep — bytes, segments, retransmits, + last send/recv, the lowest RTT seen — is the life of this session. + """ + proto = str(proto or "TCP").upper() + if proto not in {"TCP", "UDP"}: + proto = "TCP" + want_local = _endpoint_key(local) + want_remote = _endpoint_key(remote) + if not want_local or not want_remote: + return {"found": False, "error": "local and remote are required", "proto": proto} + for row in _ss_flow_dump(proto): + if _endpoint_key(row.get("local")) == want_local and _endpoint_key(row.get("remote")) == want_remote: + row["found"] = True + return row + return { + "found": False, + "local": local, + "remote": remote, + "proto": proto, + "error": "socket not in ss", + } + + def _get_ss_tcp_metrics(local=None, remote=None): ss_cmd = resolve_binary("ss") if not ss_cmd: return {} try: - result = subprocess.run([ss_cmd, "-tin"], capture_output=True, text=True, timeout=2, check=False) + result = subprocess.run([ss_cmd, "-tmin"], capture_output=True, text=True, timeout=2, check=False) lines = (result.stdout or "").splitlines() except (subprocess.TimeoutExpired, OSError): return {} @@ -345,7 +547,11 @@ def _get_ss_tcp_metrics(local=None, remote=None): for idx, line in enumerate(lines): if not line.strip().startswith("ESTAB"): continue - details = lines[idx + 1] if (idx + 1) < len(lines) else "" + extra = [] + for look in (1, 2): + if idx + look < len(lines) and lines[idx + look][:1].isspace(): + extra.append(lines[idx + look]) + details = " ".join(extra) metrics = _parse_ss_detail_metrics(line, details) if not metrics: continue @@ -529,6 +735,107 @@ def get_ip_layer_map(limit_routes: int = 12, limit_neigh: int = 10) -> dict: } +def _ip_neigh_detail(ip, iface=None): + """used/probes/ref from ``ip -s neigh``, when iproute2 is there. + + /proc/net/arp only has flags. The NUD clock — last confirm, probes — lives + in the neighbour entry itself and ``ip -s`` prints a slice of it. + """ + ip_cmd = resolve_binary("ip") + if not ip_cmd or not ip: + return {} + cmd = [ip_cmd, "-s", "neigh", "show", "to", str(ip)] + if iface: + cmd.extend(["dev", str(iface)]) + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=1, check=False) + text = (result.stdout or "").strip() + except (subprocess.TimeoutExpired, OSError): + return {} + if not text: + return {} + header = text.splitlines()[0] + out = {} + state = None + for token in header.split(): + if token.isupper() and token in { + "REACHABLE", "STALE", "DELAY", "PROBE", "FAILED", + "INCOMPLETE", "PERMANENT", "NOARP", + }: + state = token + break + if state: + out["nud"] = state + used = re.search(r"used\s+(\d+)/(\d+)/(\d+)", text) + if used: + out["used_s"] = int(used.group(1)) + out["confirmed_s"] = int(used.group(2)) + out["updated_s"] = int(used.group(3)) + probes = re.search(r"probes\s+(\d+)", text) + if probes: + out["probes"] = int(probes.group(1)) + ref = re.search(r"ref\s+(\d+)", text) + if ref: + out["ref"] = int(ref.group(1)) + return out + + +def get_ip_entry(kind, ip=None, destination=None, gateway=None, iface=None): + """One FIB row or one neighbour — the object a Network-room door opens.""" + kind = str(kind or "").strip().lower() + if kind not in {"neigh", "route"}: + return {"found": False, "error": "kind must be neigh or route"} + mapping = get_ip_layer_map(limit_routes=32, limit_neigh=32) + if kind == "neigh": + want = _endpoint_key(ip) + if not want: + return {"found": False, "kind": "neigh", "error": "ip is required"} + for row in mapping.get("neigh") or []: + if _endpoint_key(row.get("ip")) != want: + continue + if iface and str(row.get("iface") or "") != str(iface): + continue + extra = _ip_neigh_detail(row.get("ip"), row.get("iface")) + return { + "found": True, + "kind": "neigh", + **row, + **extra, + "source": "/proc/net/arp", + } + return {"found": False, "kind": "neigh", "ip": ip, "iface": iface} + + want_dest = str(destination or "").strip().lower() + want_gw = str(gateway or "").strip().lower() + want_if = str(iface or "").strip() + if not want_dest: + return {"found": False, "kind": "route", "error": "destination is required"} + for row in mapping.get("routes") or []: + dest = str(row.get("destination") or "").strip().lower() + gw = str(row.get("gateway") or "").strip().lower() + if dest != want_dest: + continue + if want_gw and gw != want_gw: + continue + if want_if and str(row.get("iface") or "") != want_if: + continue + hop = None + hop_ip = None if row.get("gateway") in (None, "*", "0.0.0.0") else row.get("gateway") + if hop_ip: + for neigh in mapping.get("neigh") or []: + if _endpoint_key(neigh.get("ip")) == _endpoint_key(hop_ip): + hop = {**neigh, **_ip_neigh_detail(neigh.get("ip"), neigh.get("iface"))} + break + return { + "found": True, + "kind": "route", + **row, + "nexthop": hop, + "source": "/proc/net/route", + } + return {"found": False, "kind": "route", "destination": destination, "gateway": gateway} + + def get_network_stack_realtime(network_stack_prev=None, prefer_local=None, prefer_remote=None): network_stack_prev = _NETWORK_STACK_PREV_DEFAULT if network_stack_prev is None else network_stack_prev now = time.time() @@ -663,6 +970,10 @@ def rate(curr, prev): "rtt_ms": round(float(ss_metrics.get("rtt_ms", 0.0)), 2), "tx_queue": int(ss_metrics.get("tx_queue", 0)), "rx_queue": int(ss_metrics.get("rx_queue", 0)), + "rmem": int(ss_metrics.get("rmem", 0)), + "wmem": int(ss_metrics.get("wmem", 0)), + "rcvbuf": int(ss_metrics.get("rcvbuf", 0)), + "sndbuf": int(ss_metrics.get("sndbuf", 0)), "cc": ss_metrics.get("cc", "unknown"), "delivery_rate_mbps": ss_metrics.get("delivery_rate_mbps", 0.0), "min_rtt_ms": round(float(ss_metrics.get("min_rtt_ms", ss_metrics.get("rtt_ms", 0.0))), 3), diff --git a/kernel_ai/services/process_inspect.py b/kernel_ai/services/process_inspect.py index 270d020..5cabc72 100644 --- a/kernel_ai/services/process_inspect.py +++ b/kernel_ai/services/process_inspect.py @@ -322,9 +322,29 @@ def consume_inode_owners(owner_map, kind): } +def _status_kb(value): + token = str(value or "").split()[0] if value else "" + if token.lstrip("-").isdigit(): + return int(token) + return None + + def _read_status_counters(pid): - """Context-switch and thread counters from world-readable /proc status.""" - out = {"ctx_voluntary": None, "ctx_nonvoluntary": None, "num_threads": None} + """Lifetime counters from world-readable /proc status. + + Context switches and thread count are bare integers. Resident and virtual + sizes come as ``N kB`` — including the high-water marks, which are the + memory biography the kernel actually keeps. + """ + out = { + "ctx_voluntary": None, + "ctx_nonvoluntary": None, + "num_threads": None, + "rss_kb": None, + "rss_peak_kb": None, + "virt_kb": None, + "virt_peak_kb": None, + } try: with open(f"/proc/{pid}/status", "r", encoding="utf-8", errors="ignore") as f: for line in f: @@ -332,19 +352,54 @@ def _read_status_counters(pid): continue key, value = line.split(":", 1) value = value.strip() - if not value.isdigit(): - continue - if key == "voluntary_ctxt_switches": + if key == "voluntary_ctxt_switches" and value.isdigit(): out["ctx_voluntary"] = int(value) - elif key == "nonvoluntary_ctxt_switches": + elif key == "nonvoluntary_ctxt_switches" and value.isdigit(): out["ctx_nonvoluntary"] = int(value) - elif key == "Threads": + elif key == "Threads" and value.isdigit(): out["num_threads"] = int(value) + elif key == "VmRSS": + out["rss_kb"] = _status_kb(value) + elif key == "VmHWM": + out["rss_peak_kb"] = _status_kb(value) + elif key == "VmSize": + out["virt_kb"] = _status_kb(value) + elif key == "VmPeak": + out["virt_peak_kb"] = _status_kb(value) except (OSError, PermissionError): pass return out +def _read_fault_counters(pid): + """Minor and major faults from world-readable /proc//stat. + + After the comm field: minflt, cminflt, majflt, cmajflt. The child + counters are the faults of wait()'d children — useful for a forking + parent, empty for everyone else. + """ + out = {"minflt": None, "majflt": None, "cminflt": None, "cmajflt": None} + try: + with open(f"/proc/{pid}/stat", "r", encoding="utf-8", errors="ignore") as f: + raw = f.read() + except (OSError, PermissionError): + return out + close = raw.rfind(")") + if close == -1: + return out + fields = raw[close + 1:].split() + # 0=state 1=ppid ... 7=minflt 8=cminflt 9=majflt 10=cmajflt + try: + if len(fields) > 10: + out["minflt"] = int(fields[7]) + out["cminflt"] = int(fields[8]) + out["majflt"] = int(fields[9]) + out["cmajflt"] = int(fields[10]) + except ValueError: + pass + return out + + def _read_io_counters(pid): """Byte counters from /proc//io, which only the owner may read.""" try: @@ -390,6 +445,7 @@ def get_process_activity_counters(pid): "cpu_system": cpu_system, } payload.update(_read_status_counters(pid)) + payload.update(_read_fault_counters(pid)) payload.update(_read_io_counters(pid)) payload["io_readable"] = payload["read_bytes"] is not None return payload diff --git a/static/css/main.css b/static/css/main.css index ee7ccfd..01d493d 100755 --- a/static/css/main.css +++ b/static/css/main.css @@ -514,6 +514,38 @@ svg { font-size: 7.5px; letter-spacing: 0.7px; } +.flow-metro-rail { + fill: none; + stroke: rgba(226, 163, 62, 0.16); + stroke-width: 0.9; +} +.flow-metro-track { + fill: none; + stroke: #e2a33e; + stroke-width: 1.35; + stroke-linejoin: round; + stroke-linecap: round; +} +.flow-metro-track.is-branch { + stroke: rgba(226, 163, 62, 0.55); +} +.flow-metro-station { + fill: rgba(9, 12, 16, 0.98); + stroke: #e2a33e; + stroke-width: 1.2; +} +.flow-metro-title { + fill: #e2a33e; + font-family: 'Share Tech Mono', monospace; + font-size: 8.5px; + letter-spacing: 1.1px; +} +.flow-metro-line { + fill: rgba(226, 163, 62, 0.58); + font-family: 'Share Tech Mono', monospace; + font-size: 7px; + letter-spacing: 0.45px; +} /* The state letter of a thread. Amber is a thread the kernel has on a CPU or ready for one; the warm tint is an uninterruptible wait, which is the state worth noticing; everything else is asleep and stays quiet. */ diff --git a/static/js/flow-card.js b/static/js/flow-card.js index 4e803b9..fa13d05 100644 --- a/static/js/flow-card.js +++ b/static/js/flow-card.js @@ -1,12 +1,13 @@ // The card a row in the main-page connection list opens. // -// The list is the index: who is talking to whom. The card is one 4-tuple — -// protocol, state, local end, peer. Traceroute hops sit as lines, not a -// second picture. PATH is the door into the network stack for this flow. +// The list is the index: who is talking to whom. The card is one 4-tuple +// and the official sock graph — socket → sock → rcv/snd queues → nic — +// as an amber metro on the right. Traceroute hops stay lines. PATH is +// the door into the network stack for this flow. // // The map chrome stays. This card uses the kcard frame, same as SOCKETS. const FlowCard = (() => { - const W = 520; + const W = 680; const PAD = 14; const CUT = 15; const HEADER = 25; @@ -14,6 +15,8 @@ const FlowCard = (() => { const ROW_STEP = 16; const FOOTER = 34; const MAX_HOPS = 6; + const METRO_W = 300; + const METRO_H = 232; let openKey = null; let topKeeper = null; @@ -65,6 +68,9 @@ const FlowCard = (() => { svg.selectAll(".flow-card-scrim, .flow-card-layer").remove(); if (topKeeper) topKeeper.stop(); d3.select("body").on("keydown.flowcard", null); + if (window.FlowHistoryCard && typeof window.FlowHistoryCard.close === "function") { + window.FlowHistoryCard.close(); + } window.dispatchEvent(new CustomEvent("kcard-closed")); } @@ -159,7 +165,7 @@ const FlowCard = (() => { }); } - function cardHeight(trace) { + function cardHeight(trace, showFigure) { let h = HEADER + 12 + 10; h += LINE; h += 16 + LINE + LINE; @@ -170,6 +176,7 @@ const FlowCard = (() => { else if (!trace) h += LINE; else h += Math.max(1, hops.length) * ROW_STEP; h += FOOTER; + if (showFigure) h = Math.max(h, HEADER + 18 + METRO_H + FOOTER); return h; } @@ -179,7 +186,8 @@ const FlowCard = (() => { const viewH = (svgNode && svgNode.clientHeight) || window.innerHeight; const cw = (live && layout) ? layout.cw : Math.min(W, viewW - 24); const compact = cw < 420; - const h = cardHeight(trace); + const showFigure = cw >= 520; + const h = cardHeight(trace, showFigure); let x; let y; @@ -259,18 +267,104 @@ const FlowCard = (() => { body.transition().delay(250).duration(180).style("opacity", 1); } - paintBody(body, connection, trace, cw, compact, h); + paintBody(body, connection, trace, cw, compact, showFigure, h); d3.select("body").on("keydown.flowcard", (event) => { - if (event.key === "Escape") close(); + if (event.key !== "Escape") return; + if (window.FlowHistoryCard && FlowHistoryCard.isOpen()) return; + close(); + }); + } + + function metroLeg(from, to) { + const drop = Math.abs(to.y - from.y); + if (!drop) return `M ${from.x} ${from.y} L ${to.x} ${to.y}`; + const turn = to.x - drop; + if (turn <= from.x + 4) { + const midY = from.y + Math.sign(to.y - from.y) * Math.min(drop, Math.abs(to.x - from.x)); + return `M ${from.x} ${from.y} L ${to.x} ${midY} L ${to.x} ${to.y}`; + } + return `M ${from.x} ${from.y} L ${turn} ${from.y} L ${to.x} ${to.y}`; + } + + // Official graph from include/linux/net.h + include/net/sock.h + sk_buff. + // Trunk socket → sock → skc. Two queues hang off sock: rcv and snd. + function drawSocketMetro(g, ox, oy, w) { + const lanes = [oy + 38, oy + 92, oy + 146, oy + 200]; + lanes.forEach((ly) => { + g.append("line") + .attr("class", "flow-metro-rail") + .attr("x1", ox + 6).attr("y1", ly) + .attr("x2", ox + w - 6).attr("y2", ly); + }); + + const at = (lane, t) => ({ + x: ox + 22 + t * (w - 44), + y: lanes[lane], + up: lane % 2 === 0 + }); + + const stations = { + socket: { lane: 0, t: 0.00, title: "SOCKET", lines: ["state type file", "sk ops"] }, + sock: { lane: 1, t: 0.28, title: "SOCK", lines: ["sk_socket", "__sk_common"] }, + skc: { lane: 1, t: 0.72, title: "SKC", lines: ["dport num state", "daddr rcv_saddr"] }, + rcv: { lane: 2, t: 0.42, title: "RCV", lines: ["receive_queue", "next prev"] }, + snd: { lane: 2, t: 0.78, title: "SND", lines: ["write_queue", "next prev"] }, + dev: { lane: 3, t: 0.78, title: "NET_DEVICE", lines: ["name ifindex"] } + }; + + const pos = {}; + Object.keys(stations).forEach((id) => { + pos[id] = at(stations[id].lane, stations[id].t); + }); + + const trunk = [["socket", "sock"], ["sock", "skc"]]; + const branch = [["sock", "rcv"], ["sock", "snd"], ["snd", "dev"]]; + trunk.forEach(([a, b]) => { + g.append("path") + .attr("class", "flow-metro-track") + .attr("d", metroLeg(pos[a], pos[b])); + }); + branch.forEach(([a, b]) => { + g.append("path") + .attr("class", "flow-metro-track is-branch") + .attr("d", metroLeg(pos[a], pos[b])); + }); + + Object.keys(stations).forEach((id) => { + const station = stations[id]; + const p = pos[id]; + g.append("circle") + .attr("class", "flow-metro-station") + .attr("cx", p.x).attr("cy", p.y).attr("r", 3.1); + const lines = station.lines; + const end = station.t > 0.62; + const lx = end ? p.x - 8 : p.x + 8; + const ty = p.up + ? p.y - 16 - Math.max(0, lines.length - 1) * 8 + : p.y + 14; + g.append("text") + .attr("class", "flow-metro-title") + .attr("x", lx).attr("y", ty) + .attr("text-anchor", end ? "end" : "start") + .text(station.title); + lines.forEach((line, i) => { + g.append("text") + .attr("class", "flow-metro-line") + .attr("x", lx).attr("y", ty + 9 + i * 8) + .attr("text-anchor", end ? "end" : "start") + .text(line); + }); }); } - function paintBody(body, connection, trace, cw, compact, h) { + function paintBody(body, connection, trace, cw, compact, showFigure, h) { const proto = String(connection.type || "TCP").toUpperCase(); const state = stateName(connection.state, proto); const local = parseEndpoint(connection.local); const remote = parseEndpoint(connection.remote); const hops = Array.isArray(trace && trace.hops) ? trace.hops.slice(0, MAX_HOPS) : []; + const colX = PAD; + const metroX = showFigure ? cw - PAD - METRO_W : 0; const text = (cls, tx, ty, value, anchorEnd) => body.append("text") .attr("class", cls) @@ -288,6 +382,37 @@ const FlowCard = (() => { .attr("class", "kcard-glyph-dot") .attr("cx", PAD).attr("cy", HEADER / 2).attr("r", 1.6); text("kcard-title", PAD + 12, HEADER / 2 + 3.5, "FLOW"); + if (window.FlowHistoryCard) { + const histX = PAD + 58; + const histW = 52; + const histLabel = text("kcard-signature", histX, HEADER / 2 + 3.5, "HISTORY") + .attr("letter-spacing", 1.2); + const histRule = body.append("line") + .attr("x1", histX).attr("x2", histX + histW) + .attr("y1", HEADER / 2 + 6.5).attr("y2", HEADER / 2 + 6.5) + .attr("stroke", "#e2a33e") + .attr("stroke-width", 1) + .attr("opacity", 0.35); + body.append("rect") + .attr("x", histX - 6).attr("y", 4) + .attr("width", histW + 12).attr("height", 18) + .attr("fill", "transparent") + .style("cursor", "pointer") + .on("mouseenter", () => { + histRule.attr("opacity", 1); + }) + .on("mouseleave", () => { + histRule.attr("opacity", 0.35); + }) + .on("click", (event) => { + event.stopPropagation(); + FlowHistoryCard.open(connection, { + x: (layout && layout.x || 0) + histX + 20, + y: (layout && layout.y || 0) + HEADER / 2, + clearOf: layout ? layout.x + layout.cw + 16 : undefined + }); + }); + } text("kcard-meta", cw - 13, HEADER / 2 + 3.5, `${proto} · ${clip(state, 10)}`, true) .style("fill", "rgba(244, 244, 236, 0.5)"); @@ -295,43 +420,52 @@ const FlowCard = (() => { .attr("class", "kcard-divider") .attr("x1", 0).attr("y1", HEADER).attr("x2", cw).attr("y2", HEADER); + if (showFigure) { + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", metroX - 10).attr("y1", HEADER + 12) + .attr("x2", metroX - 10).attr("y2", h - FOOTER + 4); + const fig = body.append("g").attr("class", "flow-sock-metro"); + drawSocketMetro(fig, metroX, HEADER + 10, METRO_W); + } + let cy = HEADER + 12 + 10; - text("kcard-line", PAD, cy, compact - ? "one 4-tuple is the picture" - : "one 4-tuple is the picture — not the whole stack"); + text("kcard-line", colX, cy, compact + ? "this 4-tuple · official sock" + : "this 4-tuple · official sock body"); cy += LINE; cy += 16; - text("kcard-section", PAD, cy, "LOCAL"); + text("kcard-section", colX, cy, "LOCAL"); cy += LINE; - text("kcard-waiter", PAD, cy, clip(`${local.host}:${local.port}`, compact ? 36 : 52)); + text("kcard-waiter", colX, cy, clip(`${local.host}:${local.port}`, compact ? 36 : 42)); cy += LINE; cy += 16; - text("kcard-section", PAD, cy, "PEER"); + text("kcard-section", colX, cy, "PEER"); cy += LINE; - text("kcard-waiter", PAD, cy, clip(`${remote.host}:${remote.port}`, compact ? 36 : 52)); + text("kcard-waiter", colX, cy, clip(`${remote.host}:${remote.port}`, compact ? 36 : 42)); cy += LINE; cy += 16; - text("kcard-section", PAD, cy, "THE WIRE"); + text("kcard-section", colX, cy, "THE WIRE"); cy += LINE; if (!trace) { - text("kcard-faint", PAD, cy, "TRACEROUTE · LOADING"); + text("kcard-faint", colX, cy, "TRACEROUTE · LOADING"); cy += LINE; } else if (hops.length) { hops.forEach((hop) => { const rtt = hop.rtt_ms === null || hop.rtt_ms === undefined ? "*" : `${Number(hop.rtt_ms).toFixed(1)} ms`; - text("kcard-waiter-dim", PAD, cy + 4, - clip(`${hop.hop}. ${hop.target} ${rtt}`, compact ? 40 : 56)); + text("kcard-waiter-dim", colX, cy + 4, + clip(`${hop.hop}. ${hop.target} ${rtt}`, compact ? 34 : 40)); cy += ROW_STEP; }); } else { - text("kcard-faint", PAD, cy, clip( + text("kcard-faint", colX, cy, clip( (trace && trace.note) || "NO HOPS FROM HERE", - compact ? 40 : 56 + compact ? 34 : 40 )); cy += LINE; } diff --git a/static/js/irq-card.js b/static/js/irq-card.js index 587b74c..730d2e6 100644 --- a/static/js/irq-card.js +++ b/static/js/irq-card.js @@ -27,6 +27,8 @@ const IrqCard = (() => { let openIrq = null; let topKeeper = null; let requestSeq = 0; + let lastLayout = null; + let lastRow = null; const SUBSYSTEM_TINT = { net: "rgba(103, 190, 224, 0.92)", @@ -48,10 +50,15 @@ const IrqCard = (() => { function close() { openIrq = null; + lastLayout = null; + lastRow = null; requestSeq += 1; svg.selectAll(".irq-card-scrim, .irq-card-layer").remove(); if (topKeeper) topKeeper.stop(); d3.select("body").on("keydown.irqcard", null); + if (window.IrqHistoryCard && typeof window.IrqHistoryCard.close === "function") { + window.IrqHistoryCard.close(); + } window.dispatchEvent(new CustomEvent("irq-card-closed")); } @@ -67,6 +74,7 @@ const IrqCard = (() => { } close(); openIrq = irq; + lastRow = row; const seq = ++requestSeq; fetch(`/api/irq/${encodeURIComponent(irq)}`, { cache: "no-store" }) @@ -122,6 +130,7 @@ const IrqCard = (() => { if (x + cw + 16 > viewW) x = Math.max(12, viewW - cw - 16); let y = (anchor && anchor.y ? anchor.y : viewH - h - 40) - 40; y = Math.max(12, Math.min(viewH - h - 12, y)); + lastLayout = { x, y, cw, h }; ensureDossierDefs(); svg.append("rect") @@ -188,7 +197,40 @@ const IrqCard = (() => { const title = data.kind === "aggregate" ? `COUNTER · ${data.irq}` : `IRQ ${data.irq}${data.device ? ` · ${String(data.device).toUpperCase()}` : ""}`; - text("kcard-title", PAD + 12, HEADER / 2 + 3.5, clip(title, 36)); + text("kcard-title", PAD + 12, HEADER / 2 + 3.5, clip(title, window.IrqHistoryCard ? 20 : 36)); + if (window.IrqHistoryCard) { + const metaLabel = subsystem.toUpperCase(); + const histW = 52; + const histX = cw - 13 - Math.max(36, metaLabel.length * 6.8) - 18 - histW; + const histLabel = text("kcard-signature", histX, HEADER / 2 + 3.5, "HISTORY") + .attr("letter-spacing", 1.2); + const histRule = body.append("line") + .attr("x1", histX).attr("x2", histX + histW) + .attr("y1", HEADER / 2 + 6.5).attr("y2", HEADER / 2 + 6.5) + .attr("stroke", "#e2a33e") + .attr("stroke-width", 1) + .attr("opacity", 0.35); + body.append("rect") + .attr("x", histX - 6).attr("y", 4) + .attr("width", histW + 12).attr("height", 18) + .attr("fill", "transparent") + .style("cursor", "pointer") + .on("mouseenter", () => { + histRule.attr("opacity", 1); + }) + .on("mouseleave", () => { + histRule.attr("opacity", 0.35); + }) + .on("click", (event) => { + event.stopPropagation(); + const box = lastLayout || { x: 0, y: 0, cw }; + IrqHistoryCard.open(data.irq, { + x: box.x + histX + 20, + y: box.y + HEADER / 2, + clearOf: box.x + box.cw + 16 + }, lastRow && lastRow.per_sec); + }); + } text("kcard-meta", cw - 13, HEADER / 2 + 3.5, subsystem.toUpperCase(), true).style("fill", tint); body.append("line") .attr("class", "kcard-divider") @@ -345,7 +387,9 @@ const IrqCard = (() => { text("kcard-foot", cw - PAD, h - 10, "/SYS/KERNEL/IRQ · /PROC/INTERRUPTS", true); d3.select("body").on("keydown.irqcard", (event) => { - if (event.key === "Escape") close(); + if (event.key !== "Escape") return; + if (window.IrqHistoryCard && IrqHistoryCard.isOpen()) return; + close(); }); } diff --git a/static/js/main.js b/static/js/main.js index 96b1f9d..32c4b50 100755 --- a/static/js/main.js +++ b/static/js/main.js @@ -1138,6 +1138,46 @@ function renderProcessDossier() { const idBox = { x: stackX, y: stackY, w: cardW, h: idH }; const idCard = dossierCard(layer, idBox, 'PROCESS DOSSIER', `PID ${formatProcessValue(processData.pid)}`); + // HISTORY is a door on the dossier itself — biography of this pid, not + // another tile in RESOURCES and not a second ACTIVITY tape. + if (window.HistoryCard) { + const histX = idBox.x + 148; + const histW = 52; + const histLabel = idCard.append('text') + .attr('x', histX).attr('y', idBox.y + 16) + .attr('font-family', DOSSIER.mono) + .attr('font-size', '9px') + .attr('letter-spacing', '1.2') + .attr('fill', DOSSIER.accent) + .text('HISTORY'); + const histRule = idCard.append('line') + .attr('x1', histX).attr('x2', histX + histW) + .attr('y1', idBox.y + 19).attr('y2', idBox.y + 19) + .attr('stroke', DOSSIER.accent) + .attr('stroke-width', 1) + .attr('opacity', 0.35); + idCard.append('rect') + .attr('x', histX - 6).attr('y', idBox.y + 4) + .attr('width', histW + 12).attr('height', 18) + .attr('fill', 'transparent') + .style('pointer-events', 'all') + .style('cursor', 'pointer') + .on('mouseenter', () => { + histRule.attr('opacity', 1); + }) + .on('mouseleave', () => { + histRule.attr('opacity', 0.35); + }) + .on('click', (event) => { + event.stopPropagation(); + HistoryCard.open(processData.pid, { + x: histX + 20, + y: idBox.y + 16, + clearOf: stackX + cardW + 34 + }); + }); + } + idCard.append('text') .attr('x', idBox.x + 16).attr('y', idBox.y + heroRel) .attr('font-family', DOSSIER.mono) @@ -1695,7 +1735,8 @@ const processModalTopKeeper = createOverlayTopKeeper( function closeOpenKernelCards() { ["MemoryCard", "ThreadsCard", "WaitsCard", "WakeupsCard", "SocketsCard", - "FlowCard", "NamespaceCard", "SyscallCard", "IrqCard", "RunqueueCard"].forEach((name) => { + "FlowCard", "FlowHistoryCard", "NamespaceCard", "SyscallCard", "IrqCard", + "IrqHistoryCard", "RunqueueCard", "HistoryCard", "IpEntryCard"].forEach((name) => { const card = window[name]; if (card && typeof card.close === "function") card.close(); }); diff --git a/static/js/network-stack.js b/static/js/network-stack.js index 1aaf10a..87ae735 100644 --- a/static/js/network-stack.js +++ b/static/js/network-stack.js @@ -1,7 +1,7 @@ // Network Stack Visualization - vertical packet flow through Linux networking layers // Version: 11 — Netfilter morph (syntax-safe) + hooks/conntrack/verdict -debugLog('🌐 network-stack.js v18: Script loading...'); +debugLog('🌐 network-stack.js v18 + socket-body hook: Script loading...'); class NetworkStackVisualization { constructor() { @@ -123,6 +123,7 @@ class NetworkStackVisualization { this.hideOsiTiles = true; // Keep the particle swarm and hero packet off — morph stays as panel ribbons. this.hideVerticalOrbs = true; + this.socketBodyMode = true; this.packetMorphEnabled = false; this.packetMorphPhase = -1; this.packetMorphCtx = null; @@ -258,6 +259,10 @@ class NetworkStackVisualization { this.camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000); this.camera.position.set(0, 1.2, 13.5); this.camera.lookAt(0, 0, 0); + if (this.socketBodyMode) { + this.camera.position.set(0.35, 0.2, 10.6); + this.camera.lookAt(0.2, 0, 0); + } this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); this.renderer.setSize(window.innerWidth, window.innerHeight); @@ -271,10 +276,16 @@ class NetworkStackVisualization { this.scene.add(ambient); this.scene.add(key); - this.createLayerStack(); - this.createPacket(); - this.createFlowParticles(); - this.createOverlayUI(); + if (this.socketBodyMode && typeof this.createSocketBody === 'function') { + this.createSocketBody(); + this.createSocketBodyUI(); + } else { + this.socketBodyMode = false; + this.createLayerStack(); + this.createPacket(); + this.createFlowParticles(); + this.createOverlayUI(); + } this.addExitButton(); this.mouseMoveHandler = (event) => this.onMouseMove(event); @@ -2729,6 +2740,10 @@ class NetworkStackVisualization { if (this.exitButton && this.exitButton.parentNode) { this.exitButton.parentNode.removeChild(this.exitButton); } + if (this.socketBodyMode && document.querySelector('nav.page-nav')) { + this.exitButton = null; + return; + } const exitBtn = document.createElement('button'); exitBtn.textContent = 'EXIT VIEW'; exitBtn.className = 'network-stack-exit-button'; @@ -2937,7 +2952,9 @@ class NetworkStackVisualization { const driverLevel = classify(driverTxQ, 120, 300); const nicLevel = classify(nicErrTotal, 5, 20); - if (this.flowNode) { + if (this.socketBodyMode && typeof this.updateSocketTitle === 'function') { + this.updateSocketTitle(); + } else if (this.flowNode) { if (flow) { const remote = flow.remote || 'remote'; this.flowNode.innerHTML = ''; @@ -3165,7 +3182,15 @@ class NetworkStackVisualization { } fetchTelemetry() { - return window.fetchJson('/api/network-stack-realtime', { cache: 'no-store' }, { + const follow = this.flowFollow || (this.parseFlowFollowFromUrl && this.parseFlowFollowFromUrl()); + let telemetryUrl = '/api/network-stack-realtime'; + if (follow && follow.remote) { + const params = new URLSearchParams(); + params.set('remote', follow.remote); + if (follow.local) params.set('local', follow.local); + telemetryUrl += '?' + params.toString(); + } + return window.fetchJson(telemetryUrl, { cache: 'no-store' }, { timeoutMs: 6000, suppressToast: true, context: 'network-stack-realtime' @@ -3965,6 +3990,7 @@ class NetworkStackVisualization { e.stopPropagation(); const index = Number(el.getAttribute('data-route-index')); this.openIpKernelMorph({ kind: 'route', index }); + this.openIpEntryCard('route', index, el); }); }); root.querySelectorAll('.ns-ip-neigh-row').forEach((el) => { @@ -3972,6 +3998,7 @@ class NetworkStackVisualization { e.stopPropagation(); const index = Number(el.getAttribute('data-neigh-index')); this.openIpKernelMorph({ kind: 'neigh', index }); + this.openIpEntryCard('neigh', index, el); }); }); const back = root.querySelector('.ns-ip-back-puzzle'); @@ -4018,7 +4045,7 @@ class NetworkStackVisualization { `).join(''); const morph = this.ipMorphTarget ? this.buildIpKernelMorphHtml(this.ipMorphTarget) : `
- tip: click a route or neigh row to watch IP → kernel translation + tip: click a route or neigh row — the card is the object, morph is the translation
`; const html = `
@@ -4050,6 +4077,21 @@ class NetworkStackVisualization { } } + openIpEntryCard(kind, index, el) { + if (!window.IpEntryCard || typeof window.IpEntryCard.open !== 'function') return; + const map = this.getIpMapData(); + const rows = kind === 'route' ? (map.routes || []) : (map.neigh || []); + const row = rows[Number(index)]; + if (!row) return; + const host = (this.container || document.body).getBoundingClientRect(); + const box = el && el.getBoundingClientRect ? el.getBoundingClientRect() : host; + window.IpEntryCard.open(kind, row, { + x: box.left - host.left + box.width, + y: box.top - host.top + box.height / 2, + clearOf: box.left - host.left + box.width + 12 + }); + } + openIpKernelMorph(target) { this.freezeIpMapSnapshot(); const next = target || { kind: 'flow' }; @@ -4131,7 +4173,7 @@ class NetworkStackVisualization { const icmpHot = n(icmp.in_errors) + n(icmp.out_errors) + n(icmp.in_dest_unreach) > 0; // Morph ribbon is center-overlay only — never inject into the right panel. const tip = compact - ? '
click route/neigh → IP→kernel morph (center)
' + ? '
click route/neigh → card + morph
' : ''; const flowDiagram = `
@@ -5442,15 +5484,17 @@ class NetworkStackVisualization { if (!this.hideVerticalOrbs) { this.updateFlowParticles(dt); } - this.updateLayerStrips(dt); - this.updatePathHopRig(dt); - this.updatePacketLifecycleUI(); - - // Gentle camera drift for cinematic depth. - const t = now * 0.00025; - this.camera.position.x = Math.sin(t) * 0.9; - this.camera.position.z = 13.2 + Math.cos(t) * 0.35; - this.camera.lookAt(0, 0, 0); + if (!this.socketBodyMode) { + this.updateLayerStrips(dt); + this.updatePathHopRig(dt); + this.updatePacketLifecycleUI(); + const t = now * 0.00025; + this.camera.position.x = Math.sin(t) * 0.9; + this.camera.position.z = 13.2 + Math.cos(t) * 0.35; + this.camera.lookAt(0, 0, 0); + } else if (typeof this.updateSocketBody === 'function') { + this.updateSocketBody(dt); + } // Overlay updates must never block the 3D render: a bug in connector // projection should at worst drop the leader lines, not blank the scene. @@ -5701,6 +5745,9 @@ class NetworkStackVisualization { } deactivate() { + if (window.IpEntryCard && typeof window.IpEntryCard.close === 'function') { + window.IpEntryCard.close(); + } this.isActive = false; this.pathHopPending = null; this.disposePathHopRig();