diff --git a/gunicorn.conf.py b/gunicorn.conf.py index b096af3..c0f4a25 100644 --- a/gunicorn.conf.py +++ b/gunicorn.conf.py @@ -4,8 +4,16 @@ # rm -rf "$PROMETHEUS_MULTIPROC_DIR" && mkdir -p "$PROMETHEUS_MULTIPROC_DIR" +import os + + def child_exit(server, worker): """Required for prometheus_client multiprocess mode (gunicorn -w N > 1).""" + # Without the directory there are no per-worker metric files to clean up, and + # prometheus_client raises TypeError on the None path rather than saying so — + # once per worker exit, which is every restart. + if not os.environ.get("PROMETHEUS_MULTIPROC_DIR", "").strip(): + return try: from prometheus_client import multiprocess diff --git a/index.html b/index.html index 8e164b9..dc050c7 100755 --- a/index.html +++ b/index.html @@ -37,7 +37,7 @@ - + @@ -87,19 +87,26 @@

Linux Kernel Ring 0 Visualization

- - - + + + + + + + + + + - + - + - + @@ -115,7 +122,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 9c5646c..83ef6ef 100644 --- a/kernel_ai/api/rest.py +++ b/kernel_ai/api/rest.py @@ -25,6 +25,8 @@ ("/path-walk", "path_walk", h.path_walk, None), ("/isolation-context", "isolation_context", h.isolation_context, None), ("/process//threads", "get_process_threads", h.get_process_threads, None), + ("/process//memory", "get_process_memory", h.get_process_memory, None), + ("/process//thread//wait", "get_thread_wait", h.get_thread_wait, None), ("/process//cpu", "get_process_cpu", h.get_process_cpu, None), ("/process//fds", "get_process_fds", h.get_process_fds, None), ("/process//lineage", "get_process_lineage", h.get_process_lineage, None), @@ -44,6 +46,8 @@ ("/security-realtime", "security_realtime", h.security_realtime, None), ("/processes-realtime", "processes_realtime", h.processes_realtime, None), ("/scheduler-pelt", "scheduler_pelt", h.scheduler_pelt, None), + ("/runqueue", "runqueue", h.runqueue, None), + ("/wakeups", "wakeups", h.wakeups, None), ("/frontend-logs", "ingest_frontend_logs", h.ingest_frontend_logs, ["POST", "OPTIONS"]), ("/proc-graph", "proc_graph", h.get_proc_graph, None), ("/process-files", "process_files", h.get_process_files, None), diff --git a/kernel_ai/http/api.py b/kernel_ai/http/api.py index dd808c8..8c4f070 100644 --- a/kernel_ai/http/api.py +++ b/kernel_ai/http/api.py @@ -11,10 +11,12 @@ ml_drift, nginx_files, process_kernel_map, + runqueue, sentry_test, siem_alerts, syscall_detail, syscalls_realtime, + wakeups, ) from kernel_ai.http.api_handlers.network_system import ( active_connections, @@ -39,7 +41,9 @@ get_process_activity, get_process_files, get_process_lineage, + get_process_memory, get_process_threads, + get_thread_wait, get_processes, get_processes_detailed, processes_realtime, @@ -74,7 +78,9 @@ "get_process_activity", "get_process_kernel_map", "get_process_lineage", + "get_process_memory", "get_process_threads", + "get_thread_wait", "get_processes", "get_processes_detailed", "ingest_frontend_logs", @@ -90,6 +96,7 @@ "nginx_files", "process_kernel_map", "processes_realtime", + "runqueue", "scheduler_pelt", "security_realtime", "sentry_test", @@ -97,5 +104,6 @@ "syscall_detail", "syscalls_realtime", "traceroute_info", + "wakeups", ] diff --git a/kernel_ai/http/api_handlers/kernel.py b/kernel_ai/http/api_handlers/kernel.py index 630f42a..ab7c11b 100644 --- a/kernel_ai/http/api_handlers/kernel.py +++ b/kernel_ai/http/api_handlers/kernel.py @@ -14,15 +14,108 @@ def syscalls_realtime(): - return api_json( - lambda: { + def _payload(): + sample = _telemetry.get_syscall_sample() + return { "timestamp": datetime.now().isoformat(), - "syscalls": _telemetry.get_real_system_calls(), + "syscalls": sample.get("syscalls", []), + # How the sample was taken. "self" means the root collector is not + # running and the panel can only see the backend's own processes, + # which the UI has to say out loud rather than pass off as the machine. + "sample": { + "source": sample.get("source"), + "scope": sample.get("scope"), + "tasks_total": sample.get("tasks_total"), + "blocked_total": sample.get("blocked_total"), + "age": sample.get("age"), + }, "cpu_usage": psutil.cpu_percent(interval=1), "memory_usage": psutil.virtual_memory().percent, "system_info": _core_observability_service.get_system_info(), } - ) + + return api_json(_payload) + + +def syscall_detail(name): + """What one syscall is inside this kernel, plus who is parked in it. + + The number, the chain of kernel symbols and the sleeping function all come + from the running machine. A call nobody is parked in right now still has an + answer — the waiter list is simply empty. + """ + + def _payload(): + from collections import Counter + + from kernel_ai.services import syscall_anatomy + + clean = "".join(ch for ch in str(name)[:64] if ch.isalnum() or ch == "_") + sample = _telemetry.get_syscall_sample() + rows = sample.get("syscalls") or [] + row = next((r for r in rows if str(r.get("name", "")).lower() == clean.lower()), None) + + waiters = list(row.get("waiters") or []) if row else [] + numbers = {v: k for k, v in _telemetry.get_syscall_names().items()} + nr = (row or {}).get("nr") + if nr is None: + nr = numbers.get(clean) + subsystem = (row or {}).get("subsystem") or _telemetry.map_syscall_to_subsystem(clean) + + wchans = Counter(w.get("wchan") for w in waiters if w.get("wchan")) + anatomy = syscall_anatomy.describe( + clean, nr=nr, subsystem=subsystem, wchans=wchans.most_common(), sampled=len(waiters) + ) + + return { + "timestamp": datetime.now().isoformat(), + **anatomy, + "count": (row or {}).get("count", 0), + "waiters": waiters, + "sample": {"source": sample.get("source"), "scope": sample.get("scope")}, + } + + return api_json(_payload) + + +def wakeups(): + """Who woke whom, over the window the collector last sampled.""" + + def _payload(): + from kernel_ai.services import wakeups as wakeups_service + return {"timestamp": datetime.now().isoformat(), **wakeups_service.describe()} + return api_json(_payload) + + +def runqueue(): + """Who is competing for a CPU right now, and who would be taken next.""" + + def _payload(): + from kernel_ai.services import runqueue as runqueue_service + + return {"timestamp": datetime.now().isoformat(), **runqueue_service.describe()} + + return api_json(_payload) + + +def irq_detail(irq): + """What one interrupt line is on this machine. + + Identity, affinity, the per-CPU counters and the deferred half, all read + from /sys and /proc. Rates are not part of the answer: the panel measures + them over its polling interval, which is a steadier window than a single + request could sample, and the card uses those. + """ + + def _payload(): + from kernel_ai.services import irq_anatomy + + anatomy = irq_anatomy.describe(irq) + if anatomy is None: + return {"timestamp": datetime.now().isoformat(), "irq": str(irq), "found": False} + return {"timestamp": datetime.now().isoformat(), "found": True, **anatomy} + + return api_json(_payload) def io_pulse(): diff --git a/kernel_ai/http/api_handlers/processes.py b/kernel_ai/http/api_handlers/processes.py index 7afaf5a..153ba19 100644 --- a/kernel_ai/http/api_handlers/processes.py +++ b/kernel_ai/http/api_handlers/processes.py @@ -8,6 +8,9 @@ from kernel_ai.services import process_timeline as _process_timeline_service from kernel_ai.services import processes as _processes_service from kernel_ai.services import scheduler_pelt as _scheduler_pelt_service +from kernel_ai.services import memory as _memory_service +from kernel_ai.services import threads as _threads_service +from kernel_ai.services import waits as _waits_service def get_processes(): @@ -15,7 +18,15 @@ def get_processes(): def get_process_threads(pid): - return api_json(lambda: _process_inspect_service.get_process_threads_info(pid)) + return api_json(lambda: _threads_service.describe(pid)) + + +def get_process_memory(pid): + return api_json(lambda: _memory_service.describe(pid)) + + +def get_thread_wait(pid, tid): + return api_json(lambda: _waits_service.describe(pid, tid)) def get_process_cpu(pid): diff --git a/kernel_ai/services/execution.py b/kernel_ai/services/execution.py index 8dd3af4..57efe63 100644 --- a/kernel_ai/services/execution.py +++ b/kernel_ai/services/execution.py @@ -12,6 +12,61 @@ from kernel_ai.sentry_helpers import capture_exception +_IRQ_DEVICE_CACHE: dict = {} + + +def _irq_vector(device: str, desc: str): + from kernel_ai.services import irq_anatomy + + return irq_anatomy.vector_for(device or desc, desc) + + +def _softirq_symbol(vector: str): + """The kernel function a softirq vector runs, if this kernel exports it.""" + from kernel_ai.services import irq_anatomy + + symbol = irq_anatomy.VECTOR_SYMBOL.get(str(vector).upper()) + if not symbol: + return None + symbols = irq_anatomy.kernel_symbols() + return symbol if (symbols and symbol in symbols) else None + + +def _irq_device(irq: str) -> str: + """The handler name the driver registered for a numbered line. + + /proc/interrupts prints the chip and the trigger before the device, so the + raw description truncates to something useless like "xen-dyn-latee…" in a + narrow panel. /sys/kernel/irq//actions gives the device on its own. + A line's handler is fixed once the driver has probed, so it is read once. + """ + if irq in _IRQ_DEVICE_CACHE: + return _IRQ_DEVICE_CACHE[irq] + device = "" + if irq.isdigit(): + try: + with open(f"/sys/kernel/irq/{irq}/actions", "r", encoding="utf-8", errors="replace") as fh: + device = fh.read().strip() + except OSError: + device = "" + _IRQ_DEVICE_CACHE[irq] = device + return device + + +def _irq_short_label(desc: str) -> str: + """The kernel's description of a lettered counter, minus the obvious word. + + Those rows read "Hypervisor callback interrupts" and "Machine check polls"; + under a panel already titled INTERRUPTS the last word is a waste of the + twenty characters the column has. + """ + text = str(desc or "").strip() + for tail in (" interrupts", " polls", " events"): + if text.lower().endswith(tail): + return text[: -len(tail)] + return text + + def _read_key_value_proc_file(path: str) -> dict: out = {} try: @@ -332,14 +387,22 @@ def get_execution_context_data(syscall_names, map_interrupt_to_subsystem_fn, exe per_sec = max(0.0, (total - prev_total) / dt) top_cpu = int(max(range(len(counts)), key=lambda i: counts[i])) if counts else None + device = _irq_device(irq_name) + vector = _irq_vector(device, desc) irq_rows.append( { "irq": irq_name, "label": desc, + # What the panel prints: the device, when the driver named + # one, and otherwise the kernel's own description. + "device": device or _irq_short_label(desc), "total": int(total), "per_sec": round(per_sec, 2), "top_cpu": top_cpu, - "subsystem": map_interrupt_to_subsystem_fn(desc), + "subsystem": map_interrupt_to_subsystem_fn(device or desc), + # Concluded from the class of the device, never measured; + # the route map that shows it says so on the same line. + "vector": vector, } ) except (IOError, PermissionError): @@ -367,7 +430,16 @@ def get_execution_context_data(syscall_names, map_interrupt_to_subsystem_fn, exe per_sec = 0.0 if dt and prev_total is not None: per_sec = max(0.0, (total - prev_total) / dt) - softirq_rows.append({"name": name, "total": int(total), "per_sec": round(per_sec, 2)}) + softirq_rows.append( + { + "name": name, + "total": int(total), + "per_sec": round(per_sec, 2), + # The function this vector runs, so the route map can end on + # a real kernel symbol instead of a placeholder. + "symbol": _softirq_symbol(name), + } + ) except (IOError, PermissionError): pass diff --git a/kernel_ai/services/irq_anatomy.py b/kernel_ai/services/irq_anatomy.py new file mode 100644 index 0000000..0853f11 --- /dev/null +++ b/kernel_ai/services/irq_anatomy.py @@ -0,0 +1,316 @@ +"""What an interrupt line is on the machine that is running right now. + +The panel at the bottom lists the lines that are firing. This module answers the +next question about one of them: what device registered it, which chip delivers +it, which CPU is allowed to take it and which one actually did, and what runs +afterwards in softirq context. + +The facts come from four places the kernel keeps for exactly this purpose: +``/sys/kernel/irq/`` for the identity of the line, ``/proc/irq/`` for its +affinity, ``/proc/interrupts`` for the per-CPU counters, and ``/proc/softirqs`` +for the deferred half. All four are readable unprivileged, so the hardened +backend needs no help from a collector here. + +One link in the chain is an inference rather than a measurement, and is labelled +as one: the kernel does not record which softirq vector a given line raises, so +that is concluded from the class of the device. The *rate* of the vector is +measured; only the attribution is reasoned. Everything else is either read off +the running kernel or left out. + +Rates are deliberately absent from this module. The panel already computes them +over its polling interval, which is a far steadier window than anything a +one-shot request could sample, so the card takes them from there. +""" + +from __future__ import annotations + +import os +import re + +from .syscall_anatomy import kernel_symbols + +_SYS_IRQ = "/sys/kernel/irq" +_PROC_IRQ = "/proc/irq" + +# How many event channels the hypervisor card lists. Past the sixth the shares +# are rounding error, and the card is a card, not a table. +_MAX_CHANNELS = 6 + +# The function each softirq vector runs. These are the entries of the kernel's +# own softirq_vec table, so the name is fixed by the vector, not guessed; it is +# still confirmed against kallsyms before being shown. +VECTOR_SYMBOL = { + "HI": "tasklet_hi_action", + "TIMER": "run_timer_softirq", + "NET_TX": "net_tx_action", + "NET_RX": "net_rx_action", + "BLOCK": "blk_done_softirq", + "IRQ_POLL": "irq_poll_softirq", + "TASKLET": "tasklet_action", + "SCHED": "run_rebalance_domains", + "HRTIMER": "hrtimer_run_queues", + "RCU": "rcu_core", +} + +# Which vector a line's bottom half lands in, concluded from the driver that +# registered the handler. Ordered longest-match-first so "virtio-blk" is not +# read as a network device by the "vir" of nothing in particular. +_DEVICE_VECTOR = ( + (("nvme", "blkif", "ahci", "scsi", "sata", "virtio-blk", "vblk", "xvd", "mmc"), "BLOCK"), + (("eth", "ens", "enp", "eno", "wlan", "wl", "virtio-net", "ena", "mlx", "ixgbe", "e1000", + "igb", "bnxt", "vif"), "NET_RX"), + (("timer", "hrtimer"), "TIMER"), +) + +# One line of prose per class of device, describing what the interrupt means. +# Only classes where the sentence is true of every member get one; a line whose +# device is not recognised simply goes without. +_DEVICE_SUMMARY = { + "BLOCK": "the storage device reports that a request finished", + "NET_RX": "the network interface reports that a frame arrived", + "TIMER": "the periodic tick that drives scheduling and timers", +} + + +def _read(path): + try: + with open(path, "r", encoding="utf-8", errors="replace") as fh: + return fh.read().strip() + except OSError: + return "" + + +def _cpus_online(): + try: + return os.sysconf("SC_NPROCESSORS_ONLN") + except (ValueError, OSError, AttributeError): + return os.cpu_count() or 1 + + +def read_interrupts(): + """Every row of /proc/interrupts as {name: (per_cpu_counts, description)}. + + Both the numbered device lines and the lettered kernel counters (HYP, MCP, + RES and friends) come back, because the panel shows both and either can be + clicked. + """ + rows = {} + try: + with open("/proc/interrupts", "r", encoding="utf-8", errors="ignore") as fh: + lines = fh.readlines() + except OSError: + return rows + for raw in lines[1:]: + if ":" not in raw: + continue + left, right = raw.split(":", 1) + name = left.strip() + tokens = right.split() + counts = [] + idx = 0 + while idx < len(tokens) and tokens[idx].isdigit(): + counts.append(int(tokens[idx])) + idx += 1 + if not counts: + continue + rows[name] = (counts, " ".join(tokens[idx:]).strip()) + return rows + + +def read_softirqs(): + """Per-vector totals from /proc/softirqs, summed over CPUs.""" + totals = {} + try: + with open("/proc/softirqs", "r", encoding="utf-8", errors="ignore") as fh: + lines = fh.readlines() + except OSError: + return totals + for raw in lines[1:]: + if ":" not in raw: + continue + left, right = raw.split(":", 1) + totals[left.strip().upper()] = sum(int(t) for t in right.split() if t.isdigit()) + return totals + + +def vector_for(device, chip=""): + """The softirq vector this line's bottom half runs in, by device class. + + An inference, not a reading — the kernel keeps no per-line record of it — + so every caller presents the answer as one. None means the device is not + recognised, and then nothing is claimed at all. + """ + haystack = f"{device} {chip}".lower() + for needles, vector in _DEVICE_VECTOR: + if any(needle in haystack for needle in needles): + return vector + return None + + +def _threaded_handler(irq): + """The kthread that runs this handler, when the driver asked for one. + + A threaded IRQ shows up as a task named ``irq/-``. There is no + other index for them, so /proc is walked; it is a click-driven lookup, and + the comm files are one read each. + """ + prefix = f"irq/{irq}-" + try: + entries = os.listdir("/proc") + except OSError: + return None + for entry in entries: + if not entry.isdigit(): + continue + comm = _read(f"/proc/{entry}/comm") + if comm.startswith(prefix): + return {"pid": int(entry), "comm": comm} + return None + + +def _xen_event_channels(interrupts): + """The numbered lines that arrive through the hypervisor callback. + + On a Xen guest every event channel is delivered as one HYP callback, so the + HYP counter is not a device line at all — it is the door the lines below it + come through. That relationship is visible in the arithmetic, and the card + shows it instead of pretending HYP is a device. + """ + children = [] + for name, (counts, desc) in interrupts.items(): + if not name.isdigit(): + continue + chip = _read(f"{_SYS_IRQ}/{name}/chip_name") + if not chip.startswith("xen-"): + continue + total = sum(counts) + # A guest is wired with a dozen channels it never uses — spinlock0, + # floppy, rtc0. A channel that has not fired has brought nothing + # through the door, and a bar of zero says nothing worth its row. + if total: + children.append({"irq": name, "device": _read(f"{_SYS_IRQ}/{name}/actions") or desc, + "total": total}) + children.sort(key=lambda row: row["total"], reverse=True) + return children + + +def _chain_for_line(device, chip, vector, symbols, thread): + """The path from the wire to the deferred work, stage by stage.""" + chain = [] + if chip: + chain.append({"stage": "line", "symbol": chip, "note": "chip", "confirmed": True}) + if device: + chain.append({"stage": "handler", "symbol": device, "note": "from driver", "confirmed": True}) + if vector: + chain.append({ + "stage": "raises", + "symbol": vector, + "note": "by driver class", + "confirmed": True, + "inferred": True, + }) + symbol = VECTOR_SYMBOL.get(vector) + if symbol: + confirmed = symbol in symbols if symbols else False + chain.append({ + "stage": "runs", + "symbol": symbol, + "note": "in kallsyms" if confirmed else "not in kallsyms", + "confirmed": confirmed, + }) + if thread: + chain.append({ + "stage": "thread", + "symbol": f"{thread['comm']} pid {thread['pid']}", + "note": "threaded handler", + "confirmed": True, + }) + else: + chain.append({ + "stage": "thread", + "symbol": "none — handled in softirq", + "note": "", + "confirmed": False, + }) + return chain + + +def describe(irq): + """Everything the card shows about one line, or None if there is no such line.""" + irq = str(irq).strip() + if not re.fullmatch(r"[A-Za-z0-9_-]{1,16}", irq): + return None + + interrupts = read_interrupts() + if irq not in interrupts: + return None + counts, desc = interrupts[irq] + + symbols = kernel_symbols() + softirqs = read_softirqs() + online = _cpus_online() + per_cpu = counts[:online] if len(counts) >= online else counts + + payload = { + "irq": irq, + "label": desc, + "total": sum(counts), + "per_cpu": per_cpu, + "cpus_online": online, + } + + if not irq.isdigit(): + # A lettered row is a kernel-side counter, not a device line. The + # kernel prints its own description, which beats anything invented. + payload.update({ + "kind": "aggregate", + "device": None, + "chip": None, + "summary": desc.lower() if desc else None, + "chain": [], + "affinity": None, + "thread": None, + "softirq": None, + }) + if irq == "HYP": + children = _xen_event_channels(interrupts) + payload["children_total"] = sum(row["total"] for row in children) + payload["children"] = children[:_MAX_CHANNELS] + payload["children_hidden"] = max(0, len(children) - _MAX_CHANNELS) + return payload + + device = _read(f"{_SYS_IRQ}/{irq}/actions") + chip = _read(f"{_SYS_IRQ}/{irq}/chip_name") + vector = vector_for(device or desc, chip) + thread = _threaded_handler(irq) + + payload.update({ + "kind": "line", + "device": device or None, + "chip": chip or None, + "name": _read(f"{_SYS_IRQ}/{irq}/name") or None, + "hwirq": _read(f"{_SYS_IRQ}/{irq}/hwirq") or None, + "type": _read(f"{_SYS_IRQ}/{irq}/type") or None, + "wakeup": _read(f"{_SYS_IRQ}/{irq}/wakeup") or None, + "summary": _DEVICE_SUMMARY.get(vector), + "affinity": { + "allowed": _read(f"{_PROC_IRQ}/{irq}/smp_affinity_list") or None, + "effective": _read(f"{_PROC_IRQ}/{irq}/effective_affinity_list") or None, + }, + "thread": thread, + "chain": _chain_for_line(device, chip, vector, symbols, thread), + }) + + if vector: + symbol = VECTOR_SYMBOL.get(vector) + payload["softirq"] = { + "vector": vector, + "total": softirqs.get(vector), + "symbol": symbol, + "confirmed": bool(symbols) and symbol in symbols, + "basis": "driver class", + } + else: + payload["softirq"] = None + + return payload diff --git a/kernel_ai/services/memory.py b/kernel_ai/services/memory.py new file mode 100644 index 0000000..f4dc89b --- /dev/null +++ b/kernel_ai/services/memory.py @@ -0,0 +1,451 @@ +"""The address space of one process, as far as /proc will say. + +The MEMORY tile of the dossier is a single number: how many megabytes of this +process are resident. This module is what that number is made of. + +``/proc//maps`` is the map itself — every virtual-memory area, its +permissions and, when there is one, the file it is backed by. ``smaps_rollup`` +adds the resident and proportional sizes the map does not carry. The web app +cannot read either file for a foreign process (the kernel gates them with +``PTRACE_MODE_READ_FSCREDS``), so a root collector publishes a summary to +``/run/kernel-ai/maps.json``. The summary names kinds, library basenames and +sizes. It never carries a virtual address: those would slide ASLR for every +process on a public site. + +When the map is readable here (the app's own workers), it is used live. When +it is not, a fresh collector snapshot is used. When neither is there, only +``/proc//status`` remains, and the payload says so rather than filling a +region in from a total. +""" + +from __future__ import annotations + +import json +import os +import re +import time + +PROC = "/proc" +SNAPSHOT = os.environ.get("MAPS_OUT", "/run/kernel-ai/maps.json") +SNAPSHOT_SOCK = os.environ.get("MAPS_SOCK", "/run/kernel-ai/maps.sock") +SNAPSHOT_MAX_AGE_S = 30.0 +MAX_LIBRARIES = 10 +MAX_STACKS = 12 + +MAP_RE = re.compile( + r"^([0-9a-f]+)-([0-9a-f]+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)(?:\s+(.*))?$" +) +STACK_RE = re.compile(r"^\[stack(?::(\d+))?\]$") + + +def _read(path): + try: + with open(path, "r", encoding="utf-8", errors="replace") as fh: + return fh.read() + except OSError: + return None + + +def _kb(text, key): + prefix = f"{key}:" + for line in text.splitlines(): + if line.startswith(prefix): + parts = line.split() + try: + return int(parts[1]) + except (IndexError, ValueError): + return None + return None + + +def _basename(path): + if not path: + return None + return path.rsplit("/", 1)[-1] + + +def classify(path, perms): + """What kind of area this is, from the name the kernel printed. + + A mapping with no permissions is not memory in use: Chromium and friends + reserve terabytes of address space they never touch. Those areas are kept + as their own kind so they do not swallow the rest of the map. + """ + path = path or "" + if perms.startswith("---"): + return "reserved" + if path == "[heap]": + return "heap" + if STACK_RE.match(path): + return "stack" + if path.startswith("[vdso") or path.startswith("[vvar") or path in ( + "[vsyscall]", "[uprobes]", "[sigpage]", "[vectors]", + ): + return "vdso" + if path.startswith("[anon:") or path.startswith("[anon_shmem:") or path == "[vvar_vclock]": + return "anonymous" + if path.startswith("/dev/"): + return "device" + if path.startswith("[") and path.endswith("]"): + return "other" + if path: + name = _basename(path) + if name and (name.endswith(".so") or ".so." in name): + return "library" + if perms.startswith("r-x"): + return "code" + return "file" + return "anonymous" + + +def parse_maps(text): + """Every VMA, classified, with consecutive same-(kind, path) runs merged.""" + rows = [] + for line in text.splitlines(): + found = MAP_RE.match(line) + if not found: + continue + start = int(found.group(1), 16) + end = int(found.group(2), 16) + if end <= start: + continue + perms = found.group(3) + path = (found.group(7) or "").strip() + kind = classify(path, perms) + stack = STACK_RE.match(path) + rows.append({ + "start": start, + "end": end, + "size_kb": (end - start) // 1024, + "perms": perms, + "path": path or None, + "kind": kind, + "tid": int(stack.group(1)) if stack and stack.group(1) else None, + }) + + merged = [] + for row in rows: + prev = merged[-1] if merged else None + if prev and prev["kind"] == row["kind"] and prev["path"] == row["path"] and prev["end"] == row["start"]: + prev["end"] = row["end"] + prev["size_kb"] += row["size_kb"] + continue + merged.append(dict(row)) + return merged + + +def _rollup(text): + if not text: + return None + return { + "rss_kb": _kb(text, "Rss"), + "pss_kb": _kb(text, "Pss"), + "anonymous_kb": _kb(text, "Anonymous"), + "swap_kb": _kb(text, "Swap"), + "shared_kb": (_kb(text, "Shared_Clean") or 0) + (_kb(text, "Shared_Dirty") or 0), + "private_kb": (_kb(text, "Private_Clean") or 0) + (_kb(text, "Private_Dirty") or 0), + } + + +def _publishable(payload): + """Strip anything that would slide ASLR or name a directory on disk. + + Addresses stay inside ``parse_maps`` so adjacent regions can be merged. + Full paths stay there so a library can be told from the executable. What + leaves this function — the API and the world-readable snapshot — is + kinds, basenames and sizes. + """ + out = dict(payload) + exe = out.get("executable") + if exe: + out["executable"] = { + "name": exe.get("name") or _basename(exe.get("path")), + "virtual_kb": exe.get("virtual_kb"), + } + libs = [] + for lib in out.get("libraries") or []: + libs.append({ + "name": lib.get("name") or _basename(lib.get("path")), + "virtual_kb": lib.get("virtual_kb"), + "count": lib.get("count"), + }) + out["libraries"] = libs + return out + + +def _from_regions(pid, comm, regions, rollup, status, maps_available, rollup_available): + kinds = {} + libraries = {} + stacks = [] + executable = None + heap_kb = 0 + for row in regions: + kind = row["kind"] + bucket = kinds.setdefault(kind, {"kind": kind, "virtual_kb": 0, "count": 0}) + bucket["virtual_kb"] += row["size_kb"] + bucket["count"] += 1 + if kind == "library" and row["path"]: + lib = libraries.setdefault(row["path"], { + "path": row["path"], + "name": _basename(row["path"]), + "virtual_kb": 0, + "count": 0, + }) + lib["virtual_kb"] += row["size_kb"] + lib["count"] += 1 + elif kind == "stack": + stacks.append({ + "tid": row["tid"] or pid, + "virtual_kb": row["size_kb"], + "main": row["path"] == "[stack]", + }) + elif kind == "code" and executable is None and row["path"]: + executable = {"path": row["path"], "name": _basename(row["path"])} + elif kind == "heap": + heap_kb += row["size_kb"] + if executable and row["path"] == executable["path"]: + executable["virtual_kb"] = executable.get("virtual_kb", 0) + row["size_kb"] + + lib_rows = sorted(libraries.values(), key=lambda r: -r["virtual_kb"]) + kind_rows = sorted(kinds.values(), key=lambda r: -r["virtual_kb"]) + stacks.sort(key=lambda r: (not r["main"], -r["virtual_kb"])) + + virtual_kb = sum(r["size_kb"] for r in regions) + if not virtual_kb: + virtual_kb = _kb(status, "VmSize") + + return _publishable({ + "pid": pid, + "comm": comm, + "totals": { + "virtual_kb": virtual_kb, + "rss_kb": (rollup or {}).get("rss_kb") or _kb(status, "VmRSS"), + "pss_kb": (rollup or {}).get("pss_kb"), + "swap_kb": (rollup or {}).get("swap_kb") if rollup else _kb(status, "VmSwap"), + "private_kb": (rollup or {}).get("private_kb"), + "shared_kb": (rollup or {}).get("shared_kb"), + "exe_kb": _kb(status, "VmExe"), + "lib_kb": _kb(status, "VmLib"), + "data_kb": _kb(status, "VmData"), + "stack_kb": _kb(status, "VmStk"), + }, + "kinds": kind_rows, + "libraries": lib_rows[:MAX_LIBRARIES], + "library_count": len(lib_rows), + "stacks": stacks[:MAX_STACKS], + "stack_count": len(stacks), + "executable": executable, + "heap_kb": heap_kb or None, + "sources": { + "maps": {"available": maps_available}, + "rollup": {"available": rollup_available}, + "status": {"available": bool(status)}, + }, + }) + + +def _snapshot(path=None, max_age_s=SNAPSHOT_MAX_AGE_S): + snap = path or SNAPSHOT + try: + age = max(0.0, time.time() - os.path.getmtime(snap)) + except OSError: + return None, {"available": False, "reason": "no-collector"} + if age > max_age_s: + return None, {"available": False, "reason": "stale", "age_s": round(age, 1)} + try: + with open(snap, "r", encoding="utf-8", errors="ignore") as fh: + data = json.load(fh) + except (OSError, ValueError): + return None, {"available": False, "reason": "unreadable"} + if not isinstance(data, dict): + return None, {"available": False, "reason": "malformed"} + return data, {"available": True, "age_s": round(age, 1)} + + +def _merge_live_totals(snap_totals, status): + """Resident size is world-readable; keep it current over a 3-second map.""" + out = dict(snap_totals or {}) + live = { + "rss_kb": _kb(status, "VmRSS"), + "swap_kb": _kb(status, "VmSwap"), + "exe_kb": _kb(status, "VmExe"), + "lib_kb": _kb(status, "VmLib"), + "data_kb": _kb(status, "VmData"), + "stack_kb": _kb(status, "VmStk"), + } + for key, value in live.items(): + if value is not None: + out[key] = value + return out + + +def _status_tgid(status, pid): + tgid = _kb(status, "Tgid") + return tgid if tgid else pid + + +def _snapshot_row(data, pid, status): + """The published summary for this pid, or for the thread-group it belongs to.""" + procs = data.get("processes") or {} + row = procs.get(str(pid)) + if isinstance(row, dict): + return row + tgid = _status_tgid(status, pid) + if tgid != pid: + row = procs.get(str(tgid)) + if isinstance(row, dict): + return row + alias = (data.get("threads") or {}).get(str(pid)) + if alias: + row = procs.get(str(alias)) + if isinstance(row, dict): + return row + return None + + +def _from_snapshot(pid, comm, status): + data, meta = _snapshot() + if not data: + return None, meta + row = _snapshot_row(data, pid, status) + if not isinstance(row, dict): + return None, meta + return _publishable({ + "pid": pid, + "comm": comm or row.get("comm"), + "totals": _merge_live_totals(row.get("totals"), status), + "kinds": row.get("kinds") or [], + "libraries": row.get("libraries") or [], + "library_count": row.get("library_count") or 0, + "stacks": row.get("stacks") or [], + "stack_count": row.get("stack_count") or 0, + "executable": row.get("executable"), + "heap_kb": row.get("heap_kb"), + "sources": { + "maps": {"available": True, "via": "collector", "age_s": meta.get("age_s")}, + "rollup": {"available": True, "via": "collector"}, + "status": {"available": bool(status)}, + }, + }), meta + + +def collect_one(pid): + """Public summary of one process, or None if there is no map to publish. + + Used by the root collector. Addresses and directory paths are stripped + before this returns, so the snapshot can be world-readable. + """ + pid = int(pid) + maps_text = _read(f"{PROC}/{pid}/maps") + if maps_text is None: + return None + status = _read(f"{PROC}/{pid}/status") or "" + rollup_text = _read(f"{PROC}/{pid}/smaps_rollup") + comm = _read(f"{PROC}/{pid}/comm") + comm = comm.strip() if comm else None + row = _from_regions( + pid, comm, parse_maps(maps_text), _rollup(rollup_text), + status, True, rollup_text is not None, + ) + if not row.get("kinds"): + return None + return { + "comm": row.get("comm"), + "totals": row.get("totals"), + "kinds": row.get("kinds"), + "libraries": row.get("libraries"), + "library_count": row.get("library_count"), + "stacks": row.get("stacks"), + "stack_count": row.get("stack_count"), + "executable": row.get("executable"), + "heap_kb": row.get("heap_kb"), + } + + +def collect_all(previous=None): + """Every userspace process the caller can see a map for, without addresses. + + ``previous`` keeps the last good summary for a pid whose map just went + empty (a zombie still has VmSize in status). Threads are indexed onto + their thread-group so a click on a tid still finds the map. + """ + processes = {} + threads = {} + previous = previous or {} + try: + names = os.listdir(PROC) + except OSError: + names = [] + for name in names: + if not name.isdigit(): + continue + row = collect_one(int(name)) + if not row: + old = previous.get(name) + status = _read(f"{PROC}/{name}/status") or "" + if old and _kb(status, "VmSize"): + row = old + if not row: + continue + processes[name] = row + try: + tids = os.listdir(f"{PROC}/{name}/task") + except OSError: + tids = [] + for tid in tids: + if tid.isdigit() and tid != name: + threads[tid] = name + return {"ts": time.time(), "processes": processes, "threads": threads} + + +def _ask_collector(pid): + """Wake the root collector for this pid. The answer lands in the snapshot.""" + try: + import socket + sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + try: + sock.settimeout(0.05) + sock.sendto(str(int(pid)).encode("ascii"), SNAPSHOT_SOCK) + finally: + sock.close() + return True + except OSError: + return False + + +def describe(pid): + """The address space of this process, as far as /proc can honestly say.""" + pid = int(pid) + if not os.path.isdir(f"{PROC}/{pid}"): + return {"pid": pid, "error": "no such process"} + + status = _read(f"{PROC}/{pid}/status") or "" + maps_text = _read(f"{PROC}/{pid}/maps") + rollup_text = _read(f"{PROC}/{pid}/smaps_rollup") + comm = _read(f"{PROC}/{pid}/comm") + comm = comm.strip() if comm else None + + # A live map with actual areas wins. An empty file is not a map: some + # kernels open /proc/pid/maps and print nothing when the ptrace check + # fails, and a zombie keeps its status totals after the map is gone. + if maps_text: + return _from_regions( + pid, comm, parse_maps(maps_text), _rollup(rollup_text), + status, True, rollup_text is not None, + ) + + snapped, _meta = _from_snapshot(pid, comm, status) + if snapped: + return snapped + + if _kb(status, "VmSize") and _ask_collector(pid): + for _ in range(6): + time.sleep(0.05) + snapped, _meta = _from_snapshot(pid, comm, status) + if snapped: + return snapped + + return _from_regions( + pid, comm, [], _rollup(rollup_text), + status, bool(maps_text is not None), rollup_text is not None, + ) diff --git a/kernel_ai/services/process_inspect.py b/kernel_ai/services/process_inspect.py index 4285e1b..993b125 100644 --- a/kernel_ai/services/process_inspect.py +++ b/kernel_ai/services/process_inspect.py @@ -296,39 +296,6 @@ def consume_inode_owners(owner_map, kind): } -def get_process_threads_info(pid): - try: - proc = psutil.Process(pid) - threads = proc.threads() - thread_count = proc.num_threads() - - try: - with open(f"/proc/{pid}/status", "r", encoding="utf-8", errors="ignore") as f: - status_data = {} - for line in f: - if ":" in line: - key, value = line.split(":", 1) - status_data[key.strip()] = value.strip() - voluntary_switches = int(status_data.get("voluntary_ctxt_switches", 0)) - nonvoluntary_switches = int(status_data.get("nonvoluntary_ctxt_switches", 0)) - except Exception: - voluntary_switches = 0 - nonvoluntary_switches = 0 - - return { - "pid": pid, - "thread_count": thread_count, - "threads": [{"id": t.id, "user_time": t.user_time, "system_time": t.system_time} for t in threads], - "voluntary_ctxt_switches": voluntary_switches, - "nonvoluntary_ctxt_switches": nonvoluntary_switches, - } - except (psutil.NoSuchProcess, psutil.AccessDenied) as e: - return {"error": str(e)} - except Exception as e: - capture_exception(e, where="services.process_inspect.get_process_threads_info") - return {"error": str(e)} - - 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} diff --git a/kernel_ai/services/runqueue.py b/kernel_ai/services/runqueue.py new file mode 100644 index 0000000..f840bf7 --- /dev/null +++ b/kernel_ai/services/runqueue.py @@ -0,0 +1,227 @@ +"""Who is competing for a CPU right now, and who the kernel would take next. + +A load average is the length of this queue, averaged: every five seconds the +kernel counts the tasks that are runnable or in an uninterruptible wait and +folds that count into three exponential averages. The number says how crowded +the machine has been; it never says who was in the crowd. This service answers +the second question for the instant of the last snapshot. + +The queue itself is read from the sched_debug snapshot rather than from +``/proc``: every row there was printed in one pass over one runqueue, so the +vruntimes, deadlines and eligibility flags are comparable with each other. The +same table assembled from separate ``/proc`` reads would be a collage of +different instants, and a queue is exactly the thing that arithmetic across +instants gets wrong. + +Two limits are stated rather than papered over. Reading debugfs is work, so the +task holding the CPU at the instant of a snapshot is nearly always the collector +itself; that row is marked as the observer instead of being dropped or passed +off as the machine's load. And when runnable tasks sit in different cgroups, the +kernel chooses between the groups before it chooses inside one, using group +deadlines that this file does not print — so the next task is named only when +one queue holds the whole competition. +""" + +from __future__ import annotations + +import json +import os +import time + +PROC = "/proc" +SCHED_DEBUG_SNAPSHOT = os.environ.get("SCHED_DEBUG_OUT", "/run/kernel-ai/sched_debug.json") +SNAPSHOT_MAX_AGE_S = 15.0 + +# Below MAX_RT_PRIO the task is in a real-time class, which is served whole +# before the fair queue is looked at at all. +MAX_RT_PRIO = 100 + +# A queue is short by nature; anything longer is a machine in trouble and the +# card says how many it did not draw. +MAX_ROWS = 14 + + +def _read(path): + try: + with open(path, "r", encoding="utf-8", errors="replace") as fh: + return fh.read() + except OSError: + return "" + + +def _snapshot(path=None, max_age_s=None): + snap = path or SCHED_DEBUG_SNAPSHOT + max_age_s = SNAPSHOT_MAX_AGE_S if max_age_s is None else max_age_s + try: + age = max(0.0, time.time() - os.path.getmtime(snap)) + except OSError: + return None, {"available": False, "reason": "no-collector"} + if age > max_age_s: + return None, {"available": False, "reason": "stale", "age_s": round(age, 1)} + try: + with open(snap, "r", encoding="utf-8", errors="ignore") as fh: + data = json.load(fh) + except (OSError, ValueError): + return None, {"available": False, "reason": "unreadable"} + if not isinstance(data, dict): + return None, {"available": False, "reason": "malformed"} + return data, {"available": True, "age_s": round(age, 1)} + + +def _loadavg(): + """``/proc/loadavg``: the three averages, then runnable/total right now. + + The fourth field is the kernel's own live count, so it is worth keeping + beside the named rows: it is current where the snapshot is a second old. + """ + parts = _read(f"{PROC}/loadavg").split() + out = {"avg1": None, "avg5": None, "avg15": None, "running": None, "total": None} + try: + out["avg1"], out["avg5"], out["avg15"] = (float(x) for x in parts[:3]) + except (IndexError, ValueError): + return out + if len(parts) > 3 and "/" in parts[3]: + running, _, total = parts[3].partition("/") + if running.isdigit() and total.isdigit(): + out["running"], out["total"] = int(running), int(total) + return out + + +def _owner(tid): + """The process a thread belongs to, from its world-readable status.""" + pid = name = None + for line in _read(f"{PROC}/{tid}/status").splitlines(): + key, _, value = line.partition(":") + value = value.strip() + if key == "Name": + name = value + elif key == "Tgid" and value.isdigit(): + pid = int(value) + if pid is not None and name is not None: + break + return pid, name + + +def _unit(cgroup): + """The systemd unit a cgroup path ends in, which is the readable half.""" + path = (cgroup or "").strip() + if not path or path == "/": + return None + last = path.rstrip("/").rsplit("/", 1)[-1] + return last or None + + +def _row(tid, task, observer_tid): + prio = task.get("prio") + rt = isinstance(prio, int) and prio < MAX_RT_PRIO + pid, process = _owner(tid) + row = { + "tid": int(tid), + "pid": pid, + "comm": task.get("comm"), + "process": process, + "cgroup": task.get("cgroup"), + "unit": _unit(task.get("cgroup")), + "cpu": task.get("cpu"), + "current": bool(task.get("current")), + "observer": str(tid) == str(observer_tid), + "eligible": task.get("eligible"), + "vlag_ms": task.get("vlag_ms"), + "switches": task.get("switches"), + "sum_exec_ms": task.get("sum_exec_ms"), + "rt": rt, + } + if isinstance(prio, int): + row["nice"] = prio - 120 + deadline, now = task.get("deadline_v"), task.get("avg_vruntime") + if isinstance(deadline, (int, float)) and isinstance(now, (int, float)): + row["due_ms"] = round(deadline - now, 3) + return row + + +def _order(row): + # The task on the CPU heads its queue; the rest by the deadline the + # scheduler compares, and a row without one goes last rather than first. + due = row.get("due_ms") + return (0 if row["current"] else 1, due if isinstance(due, (int, float)) else 1e18) + + +def _next_in(queue): + """The task this runqueue would take next, when that can be said exactly. + + Inside one cfs_rq the rule is the kernel's own: of the eligible entities, + the earliest virtual deadline wins. Across cgroups the same comparison is + meaningless — each has its own virtual clock — and the group deadlines that + would decide it are not printed, so the question is left open. + """ + waiting = [r for r in queue if not r["current"]] + if not waiting: + return None + if any(r["rt"] for r in waiting): + return {"tid": None, "exact": False, "reason": "real-time task outranks the fair queue"} + groups = {r.get("cgroup") for r in queue} + ready = [r for r in waiting if r.get("eligible") and isinstance(r.get("due_ms"), (int, float))] + if not ready: + return {"tid": None, "exact": False, "reason": "nothing eligible in the snapshot"} + pick = min(ready, key=lambda r: r["due_ms"]) + if len(groups) > 1: + # systemd gives every service its own cgroup, so this is the normal + # case on a modern machine rather than an exotic one. + return {"tid": pick["tid"], "exact": False, + "reason": "the kernel picks between service queues first"} + return {"tid": pick["tid"], "exact": True, "reason": None} + + +def describe(max_rows=MAX_ROWS): + """The runqueues of this machine at the instant of the last snapshot.""" + snap, source = _snapshot() + load = _loadavg() + tasks = (snap or {}).get("tasks") or {} + observer = (snap or {}).get("reader_tid") + + rows, blocked, slice_ms = [], 0, None + for tid, task in tasks.items(): + if not isinstance(task, dict): + continue + state = task.get("state") + if state == "D": + # Not queued for a CPU, but counted by every load average since + # 1993, which is most of why a load can outgrow the processors. + blocked += 1 + continue + if state != "R": + continue + if slice_ms is None and isinstance(task.get("slice_ms"), (int, float)): + slice_ms = task["slice_ms"] + rows.append(_row(tid, task, observer)) + + by_cpu = {} + for row in rows: + by_cpu.setdefault(row["cpu"], []).append(row) + + snap_cpus = (snap or {}).get("cpus") or {} + cpus = [] + for cpu in sorted(by_cpu, key=lambda c: (c is None, c)): + queue = sorted(by_cpu[cpu], key=_order) + meta = snap_cpus.get(str(cpu)) if isinstance(snap_cpus, dict) else None + cpus.append({ + "cpu": cpu, + "nr_running": (meta or {}).get("nr_running"), + "queued": len(queue), + "next": _next_in(queue), + "queue": queue[:max_rows], + "hidden": max(0, len(queue) - max_rows), + }) + + return { + "load": load, + "cpu_count": os.cpu_count() or 1, + # Rows only parse on a kernel that prints eligibility and a deadline, + # so a snapshot with tasks in it is the evidence of EEVDF itself. + "scheduler": {"name": "EEVDF" if tasks else None, "slice_ms": slice_ms}, + "queued": len(rows), + "uninterruptible": blocked, + "observer_tid": int(observer) if str(observer or "").isdigit() else None, + "cpus": cpus, + "source": source, + } diff --git a/kernel_ai/services/syscall_anatomy.py b/kernel_ai/services/syscall_anatomy.py new file mode 100644 index 0000000..407790e --- /dev/null +++ b/kernel_ai/services/syscall_anatomy.py @@ -0,0 +1,523 @@ +"""What a syscall is inside the kernel that is running right now. + +The left panel lists the calls tasks are parked in. This module answers the +next question: what *is* that call. It gives the number the running +architecture assigns it, the prototype userspace calls it with, and the chain +of kernel symbols the call travels through on the way to the function the task +is currently sleeping in. + +Nothing here is guessed at. Every symbol in the chain is confirmed against +``/proc/kallsyms`` of the running kernel, and the sleeping function is whatever +the kernel itself reports in ``/proc//wchan``. Symbol names in kallsyms +are readable unprivileged (only the addresses are zeroed for us, and addresses +are not shown), so this works from the unprivileged backend. + +A call the prototype table does not describe still gets its number, its chain +and its waiters. It just goes without a prototype, which is better than +printing a made-up one. +""" + +from __future__ import annotations + +import json +import os +import platform + +_SYMBOLS_SNAPSHOT = os.environ.get("KSYMS_OUT", "/run/kernel-ai/ksyms.json") +_SYMBOL_CACHE = {"names": None} + +_X86 = ("x86_64", "amd64") +_ARM64 = ("aarch64", "arm64") + +# Only these families of symbols can appear in a syscall chain, so kallsyms is +# filtered down to them once instead of being held in memory whole. +_INTERESTING_PREFIXES = ( + "__x64_sys_", + "__arm64_sys_", + "__se_sys_", + "__do_sys_", + "ksys_", + "do_", + "core_sys_", + "vfs_", + "sys_call_table", + "x64_sys_call", +) + +_ENTRY_SYMBOLS = { + "x86_64": ("entry_SYSCALL_64", "syscall instruction, number in rax"), + "arm64": ("el0t_64_sync_handler", "svc instruction, number in x8"), +} +_DISPATCH_SYMBOLS = { + "x86_64": ("do_syscall_64", "picks the handler by number"), + "arm64": ("invoke_syscall", "picks the handler by number"), +} +_ABI = { + "x86_64": "nr in rax · args in rdi rsi rdx r10 r8 r9", + "arm64": "nr in x8 · args in x0 x1 x2 x3 x4 x5", +} + +# Softirq handlers, kept on the interrupt card's behalf. They are listed here +# rather than in irq_anatomy because this module owns the kallsyms filter and +# has to stay import-free: the root collector loads it by path, outside the +# package, so a relative import would not resolve. A test holds this set and +# irq_anatomy.VECTOR_SYMBOL to each other so the two cannot drift apart. +_SOFTIRQ_SYMBOLS = frozenset({ + "tasklet_hi_action", + "run_timer_softirq", + "net_tx_action", + "net_rx_action", + "blk_done_softirq", + "irq_poll_softirq", + "tasklet_action", + "run_rebalance_domains", + "hrtimer_run_queues", + "rcu_core", +}) + +_ALL_ENTRY_NAMES = frozenset( + [sym for sym, _note in _ENTRY_SYMBOLS.values()] + + [sym for sym, _note in _DISPATCH_SYMBOLS.values()] +) | _SOFTIRQ_SYMBOLS + + +def _arch(): + machine = platform.machine().lower() + if machine in _X86: + return "x86_64" + if machine in _ARM64: + return "arm64" + return machine or "unknown" + + +def _handler_prefix(arch): + if arch == "x86_64": + return "__x64_sys_" + if arch == "arm64": + return "__arm64_sys_" + return "" + + +def _symbols_from_snapshot(): + """The set the root collector published, for when kallsyms is walled off. + + ``ProtectKernelTunables=yes`` closes /proc/kallsyms to the hardened backend, + and that option is worth more than this list is. The collector reads the + table on the app's behalf and writes the filtered names next to its sample. + """ + try: + with open(_SYMBOLS_SNAPSHOT, "r", encoding="utf-8", errors="replace") as fh: + names = json.load(fh) + except (OSError, ValueError): + return frozenset() + if not isinstance(names, list): + return frozenset() + return frozenset(str(name) for name in names) + + +def _symbols_from_kallsyms(): + found = set() + try: + with open("/proc/kallsyms", "r", encoding="utf-8", errors="replace") as fh: + for line in fh: + parts = line.split(None, 2) + if len(parts) < 3: + continue + name = parts[2].strip() + # Module symbols are printed as "name\t[module]"; keep the name. + name = name.split("\t", 1)[0].strip() + if name.startswith(_INTERESTING_PREFIXES) or name in _ALL_ENTRY_NAMES: + found.add(name) + except OSError: + return frozenset() + return frozenset(found) + + +def kernel_symbols(): + """Names of the kernel symbols a syscall chain can be built from. + + Returns an empty set when neither source can be read; the chain then shows + only the stages there is other evidence for. Only a non-empty answer is + kept, so a backend that started before the collector picks the list up on + the next question instead of staying blind until it restarts. + """ + if _SYMBOL_CACHE["names"]: + return _SYMBOL_CACHE["names"] + names = _symbols_from_snapshot() or _symbols_from_kallsyms() + if names: + _SYMBOL_CACHE["names"] = names + return names + + +def publish_symbols(path): + """Write the filtered symbol set for the hardened backend to read.""" + names = sorted(_symbols_from_kallsyms()) + if not names: + return 0 + tmp = f"{path}.tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(names, fh) + os.replace(tmp, path) + os.chmod(path, 0o644) + return len(names) + + +# Prototype, one line of prose, and the file the call is implemented in. Only +# calls a task can actually be found parked in are worth describing; the rest +# never reach this card. ``fd_arg`` marks an argument the collector can resolve +# into the file it points at. +SYSCALL_DOC = { + "read": { + "signature": "ssize_t read(int fd, void *buf, size_t count)", + "summary": "waits for up to count bytes to arrive on a descriptor", + "source": "fs/read_write.c", + "inner": ("ksys_read", "vfs_read"), + "fd_arg": 0, + }, + "pread64": { + "signature": "ssize_t pread64(int fd, void *buf, size_t count, off_t offset)", + "summary": "reads at a fixed offset without moving the file position", + "source": "fs/read_write.c", + "inner": ("ksys_pread64", "vfs_read"), + "fd_arg": 0, + }, + "write": { + "signature": "ssize_t write(int fd, const void *buf, size_t count)", + "summary": "hands bytes to a descriptor and waits for room to take them", + "source": "fs/read_write.c", + "inner": ("ksys_write", "vfs_write"), + "fd_arg": 0, + }, + "pwrite64": { + "signature": "ssize_t pwrite64(int fd, const void *buf, size_t count, off_t offset)", + "summary": "writes at a fixed offset without moving the file position", + "source": "fs/read_write.c", + "inner": ("ksys_pwrite64", "vfs_write"), + "fd_arg": 0, + }, + "readv": { + "signature": "ssize_t readv(int fd, const struct iovec *iov, int iovcnt)", + "summary": "reads into several buffers in one call", + "source": "fs/read_write.c", + "inner": ("do_readv", "vfs_readv"), + "fd_arg": 0, + }, + "writev": { + "signature": "ssize_t writev(int fd, const struct iovec *iov, int iovcnt)", + "summary": "writes several buffers in one call", + "source": "fs/read_write.c", + "inner": ("do_writev", "vfs_writev"), + "fd_arg": 0, + }, + "openat": { + "signature": "int openat(int dirfd, const char *path, int flags, mode_t mode)", + "summary": "resolves a path and opens it, waiting on the filesystem", + "source": "fs/open.c", + "inner": ("do_sys_openat2", "do_filp_open"), + }, + "fsync": { + "signature": "int fsync(int fd)", + "summary": "waits until the file's data reaches the storage device", + "source": "fs/sync.c", + "inner": ("do_fsync", "vfs_fsync"), + "fd_arg": 0, + }, + "fdatasync": { + "signature": "int fdatasync(int fd)", + "summary": "waits for the data, but not the metadata, to reach the device", + "source": "fs/sync.c", + "inner": ("do_fsync", "vfs_fsync"), + "fd_arg": 0, + }, + "flock": { + "signature": "int flock(int fd, int operation)", + "summary": "waits for an advisory lock on an open file", + "source": "fs/locks.c", + "inner": ("do_flock",), + "fd_arg": 0, + }, + "fcntl": { + "signature": "int fcntl(int fd, int cmd, ... /* arg */)", + "summary": "descriptor housekeeping; F_SETLKW waits for a lock", + "source": "fs/fcntl.c", + "inner": ("do_fcntl",), + "fd_arg": 0, + }, + "ioctl": { + "signature": "int ioctl(int fd, unsigned long request, ...)", + "summary": "device-specific request, handled by the driver behind the fd", + "source": "fs/ioctl.c", + "inner": ("do_vfs_ioctl",), + "fd_arg": 0, + }, + "epoll_wait": { + "signature": "int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)", + "summary": "sleeps until one of the watched descriptors is ready", + "source": "fs/eventpoll.c", + "inner": ("do_epoll_wait", "ep_poll"), + "fd_arg": 0, + }, + "epoll_pwait": { + "signature": "int epoll_pwait(int epfd, struct epoll_event *events, int maxevents, int timeout, const sigset_t *sigmask)", + "summary": "epoll_wait with the signal mask swapped for the duration", + "source": "fs/eventpoll.c", + "inner": ("do_epoll_pwait", "do_epoll_wait", "ep_poll"), + "fd_arg": 0, + }, + "poll": { + "signature": "int poll(struct pollfd *fds, nfds_t nfds, int timeout)", + "summary": "sleeps until one of the listed descriptors reports an event", + "source": "fs/select.c", + "inner": ("do_sys_poll",), + }, + "ppoll": { + "signature": "int ppoll(struct pollfd *fds, nfds_t nfds, const struct timespec *tmo, const sigset_t *sigmask)", + "summary": "poll with a nanosecond timeout and a swapped signal mask", + "source": "fs/select.c", + "inner": ("do_sys_poll",), + }, + "select": { + "signature": "int select(int nfds, fd_set *r, fd_set *w, fd_set *e, struct timeval *timeout)", + "summary": "the original readiness wait, over three descriptor sets", + "source": "fs/select.c", + "inner": ("core_sys_select", "do_select"), + }, + "pselect6": { + "signature": "int pselect6(int nfds, fd_set *r, fd_set *w, fd_set *e, struct timespec *tmo, void *sig)", + "summary": "select with a nanosecond timeout and a swapped signal mask", + "source": "fs/select.c", + "inner": ("core_sys_select", "do_select"), + }, + "futex": { + "signature": "long futex(uint32_t *uaddr, int op, uint32_t val, const struct timespec *timeout, ...)", + "summary": "the wait half of every userspace lock: sleeps on an address", + "source": "kernel/futex/", + "inner": ("do_futex", "futex_wait", "futex_wait_queue"), + }, + "accept": { + "signature": "int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen)", + "summary": "sleeps until a connection lands in the listen queue", + "source": "net/socket.c", + "inner": ("__sys_accept4", "do_accept"), + "fd_arg": 0, + }, + "accept4": { + "signature": "int accept4(int sockfd, struct sockaddr *addr, socklen_t *addrlen, int flags)", + "summary": "accept that sets the flags of the new socket in one call", + "source": "net/socket.c", + "inner": ("__sys_accept4", "do_accept"), + "fd_arg": 0, + }, + "connect": { + "signature": "int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen)", + "summary": "waits for the handshake with the other side to finish", + "source": "net/socket.c", + "inner": ("__sys_connect",), + "fd_arg": 0, + }, + "recvfrom": { + "signature": "ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src, socklen_t *addrlen)", + "summary": "waits for a datagram or stream bytes to arrive on a socket", + "source": "net/socket.c", + "inner": ("__sys_recvfrom", "sock_recvmsg"), + "fd_arg": 0, + }, + "recvmsg": { + "signature": "ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags)", + "summary": "receives one message, control data included", + "source": "net/socket.c", + "inner": ("__sys_recvmsg", "sock_recvmsg"), + "fd_arg": 0, + }, + "recvmmsg": { + "signature": "int recvmmsg(int sockfd, struct mmsghdr *msgvec, unsigned vlen, int flags, struct timespec *timeout)", + "summary": "waits for a batch of messages in one call", + "source": "net/socket.c", + "inner": ("__sys_recvmmsg", "sock_recvmsg"), + "fd_arg": 0, + }, + "sendto": { + "signature": "ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dst, socklen_t addrlen)", + "summary": "hands bytes to a socket, waiting for room in its buffer", + "source": "net/socket.c", + "inner": ("__sys_sendto", "sock_sendmsg"), + "fd_arg": 0, + }, + "sendmsg": { + "signature": "ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags)", + "summary": "sends one message, control data included", + "source": "net/socket.c", + "inner": ("__sys_sendmsg", "sock_sendmsg"), + "fd_arg": 0, + }, + "wait4": { + "signature": "pid_t wait4(pid_t pid, int *wstatus, int options, struct rusage *rusage)", + "summary": "sleeps until a child changes state, then reaps it", + "source": "kernel/exit.c", + "inner": ("kernel_wait4", "do_wait"), + }, + "waitid": { + "signature": "int waitid(idtype_t idtype, id_t id, siginfo_t *info, int options, struct rusage *ru)", + "summary": "waits for a child without necessarily reaping it", + "source": "kernel/exit.c", + "inner": ("kernel_waitid", "do_wait"), + }, + "nanosleep": { + "signature": "int nanosleep(const struct timespec *req, struct timespec *rem)", + "summary": "sleeps for a stated interval and nothing else", + "source": "kernel/time/hrtimer.c", + "inner": ("hrtimer_nanosleep", "do_nanosleep"), + }, + "clock_nanosleep": { + "signature": "int clock_nanosleep(clockid_t id, int flags, const struct timespec *req, struct timespec *rem)", + "summary": "sleeps against a named clock, absolute or relative", + "source": "kernel/time/posix-timers.c", + "inner": ("common_nsleep", "hrtimer_nanosleep", "do_nanosleep"), + }, + "rt_sigtimedwait": { + "signature": "int rt_sigtimedwait(const sigset_t *set, siginfo_t *info, const struct timespec *timeout, size_t sigsetsize)", + "summary": "blocks until one of the listed signals is delivered", + "source": "kernel/signal.c", + "inner": ("do_sigtimedwait",), + }, + "rt_sigsuspend": { + "signature": "int rt_sigsuspend(const sigset_t *mask, size_t sigsetsize)", + "summary": "swaps the signal mask and sleeps until a signal arrives", + "source": "kernel/signal.c", + "inner": ("sigsuspend",), + }, + "pause": { + "signature": "int pause(void)", + "summary": "sleeps until any signal arrives", + "source": "kernel/signal.c", + "inner": (), + }, + "io_getevents": { + "signature": "int io_getevents(aio_context_t ctx, long min_nr, long nr, struct io_event *events, struct timespec *timeout)", + "summary": "waits for queued asynchronous I/O to complete", + "source": "fs/aio.c", + "inner": ("do_io_getevents", "read_events"), + }, + "io_uring_enter": { + "signature": "int io_uring_enter(int fd, unsigned to_submit, unsigned min_complete, unsigned flags, const void *arg, size_t argsz)", + "summary": "submits and/or waits on an io_uring ring", + "source": "io_uring/io_uring.c", + "inner": ("io_cqring_wait",), + "fd_arg": 0, + }, + "splice": { + "signature": "ssize_t splice(int fd_in, off_t *off_in, int fd_out, off_t *off_out, size_t len, unsigned flags)", + "summary": "moves bytes between descriptors without copying to userspace", + "source": "fs/splice.c", + "inner": ("do_splice",), + "fd_arg": 0, + }, + "msgrcv": { + "signature": "ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg)", + "summary": "waits for a System V message of the requested type", + "source": "ipc/msg.c", + "inner": ("do_msgrcv",), + }, + "semop": { + "signature": "int semop(int semid, struct sembuf *sops, size_t nsops)", + "summary": "waits on a System V semaphore set", + "source": "ipc/sem.c", + "inner": ("do_semtimedop",), + }, + "mq_timedreceive": { + "signature": "ssize_t mq_timedreceive(mqd_t mqdes, char *msg, size_t len, unsigned *prio, const struct timespec *abs_timeout)", + "summary": "waits for a POSIX message queue to have something in it", + "source": "ipc/mqueue.c", + "inner": ("do_mq_timedreceive",), + }, + "exit_group": { + "signature": "void exit_group(int status)", + "summary": "tears the whole thread group down", + "source": "kernel/exit.c", + "inner": ("do_group_exit", "do_exit"), + }, + "membarrier": { + "signature": "int membarrier(int cmd, unsigned flags, int cpu_id)", + "summary": "orders memory across cores, sometimes by waiting for a grace period", + "source": "kernel/sched/membarrier.c", + "inner": (), + }, +} + + +def describe(name, nr=None, subsystem=None, wchans=None, sampled=None): + """The anatomy of one syscall on the kernel that is running. + + ``wchans`` is the live evidence: an ordered list of ``(symbol, count)`` + pairs taken from ``/proc//wchan`` of the tasks parked in this call. + The dominant one closes the chain, because that is the function they are + actually sleeping in. + + ``sampled`` is how many tasks those counts were taken from. A busy call has + far more waiters than the sample keeps, so the note says "3 of 12 sampled" + rather than implying the tally covers everyone parked in the call. + """ + arch = _arch() + doc = SYSCALL_DOC.get(name, {}) + symbols = kernel_symbols() + chain = [] + + def stage(kind, symbol, note="", confirmed=True): + chain.append({"stage": kind, "symbol": symbol, "note": note, "confirmed": confirmed}) + + # Userspace is the one stage no kernel table can confirm: the call may come + # from a libc wrapper or straight from a syscall instruction. + stage("user", f"{name}()", "userspace", confirmed=False) + + entry_sym, entry_note = _ENTRY_SYMBOLS.get(arch, (None, "")) + if entry_sym and (not symbols or entry_sym in symbols): + stage("entry", entry_sym, entry_note) + + dispatch_sym, dispatch_note = _DISPATCH_SYMBOLS.get(arch, (None, "")) + if dispatch_sym and (not symbols or dispatch_sym in symbols): + note = dispatch_note if nr is None else f"{dispatch_note} ({nr})" + stage("dispatch", dispatch_sym, note) + + handler = f"{_handler_prefix(arch)}{name}" if _handler_prefix(arch) else "" + if handler and handler in symbols: + stage("handler", handler, "the syscall's own entry point") + + seen = {c["symbol"] for c in chain} + for candidate in doc.get("inner", ()): # only the ones this kernel really has + if candidate in symbols and candidate not in seen: + stage("inner", candidate) + seen.add(candidate) + + live = [(str(sym), int(count)) for sym, count in (wchans or []) if sym] + for sym, count in live: + note = f"{count} of {sampled} sampled" if sampled else f"{count} task{'s' if count != 1 else ''}" + if sym in seen: + # Already on the chain — mark that one instead of repeating it. + for entry in chain: + if entry["symbol"] == sym: + entry["stage"] = "sleep" + entry["note"] = note + continue + stage("sleep", sym, note) + seen.add(sym) + + return { + "name": name, + "nr": nr, + "arch": arch, + "abi": _ABI.get(arch, ""), + "subsystem": subsystem, + "signature": doc.get("signature", ""), + "summary": doc.get("summary", ""), + "source": doc.get("source", ""), + "chain": chain, + "symbols_confirmed": bool(symbols), + } + + +def fd_argument(name): + """Index of the argument that is a descriptor, or None.""" + doc = SYSCALL_DOC.get(name) + if not doc: + return None + return doc.get("fd_arg") diff --git a/kernel_ai/services/syscalls.py b/kernel_ai/services/syscalls.py index cb9cb37..be94ac7 100644 --- a/kernel_ai/services/syscalls.py +++ b/kernel_ai/services/syscalls.py @@ -2,8 +2,10 @@ from __future__ import annotations +import json import os import platform +import time from datetime import datetime from kernel_ai.sentry_helpers import capture_exception @@ -12,6 +14,26 @@ # already unreadable, and the row keeps the honest total next to the list. _MAX_WAITERS_PER_SYSCALL = 12 +# Reading /proc//syscall of a foreign task needs ptrace-level access, which +# the backend deliberately does not have. The root collector samples the whole +# machine instead and leaves the result here; see the unit file +# deploy/kernel-ai-syscall-snapshot.service. +_SYSCALLS_SNAPSHOT = os.environ.get("SYSCALLS_OUT", "/run/kernel-ai/syscalls.json") +_SNAPSHOT_MAX_AGE = 6.0 + + +def _is_kernel_thread(pid): + """A kernel thread has no command line, and makes no syscalls. + + Its /proc//syscall still reads, as ``0 0x0 …``, and counting that + invents a row for syscall number 0 with every kthread parked in it. + """ + try: + with open(f"/proc/{pid}/cmdline", "rb") as f: + return not f.read(1) + except OSError: + return True + def _read_comm(pid): """Process name from /proc//comm, or None if it just exited.""" @@ -106,8 +128,80 @@ def _kernel_dna_sockstat_activity_nucleotides(): return result +def read_snapshot(): + """The root collector's system-wide sample, if it is present and fresh. + + A stale file is worth less than nothing here — the panel would claim + processes are parked in calls they left minutes ago — so anything older + than a few sample intervals is discarded and the caller falls back to what + it can see itself. + """ + try: + with open(_SYSCALLS_SNAPSHOT, "r", encoding="utf-8", errors="replace") as fh: + data = json.load(fh) + except (OSError, ValueError): + return None + ts = data.get("ts") + try: + age = time.time() - float(ts) + except (TypeError, ValueError): + return None + if age > _SNAPSHOT_MAX_AGE: + return None + data["age"] = round(age, 2) + return data + + +def get_syscall_sample(syscall_names, map_syscall_to_subsystem_fn, kernel_dna_max_procs, fallback_mock_calls_fn): + """Rows of parked calls together with how honestly they were obtained. + + ``scope`` is the part callers must not lose: ``machine`` means the root + collector answered and the rows cover every task on the box, ``self`` means + only the backend's own processes were readable and the panel is looking at + itself. + """ + snapshot = read_snapshot() + if snapshot and snapshot.get("syscalls"): + # The collector deliberately leaves the subsystem out: it samples, and + # naming things is the app's job. + rows = snapshot["syscalls"] + for row in rows: + row["subsystem"] = map_syscall_to_subsystem_fn(row.get("name", "")) + return { + "syscalls": rows, + "source": "collector", + "scope": "machine", + "tasks_total": snapshot.get("tasks_total"), + "blocked_total": snapshot.get("blocked_total"), + "age": snapshot.get("age"), + } + rows = _sample_visible_processes( + syscall_names, map_syscall_to_subsystem_fn, kernel_dna_max_procs, fallback_mock_calls_fn + ) + return { + "syscalls": rows, + "source": "backend", + "scope": "self", + "tasks_total": None, + "blocked_total": sum(row.get("count", 0) for row in rows if isinstance(row.get("count"), int)), + "age": 0.0, + } + + def get_real_system_calls(syscall_names, map_syscall_to_subsystem_fn, kernel_dna_max_procs, fallback_mock_calls_fn): - """Sample blocked syscalls from /proc or fallback to vm/block/net counters.""" + """Just the rows, for callers that do not care how they were obtained.""" + return get_syscall_sample( + syscall_names, map_syscall_to_subsystem_fn, kernel_dna_max_procs, fallback_mock_calls_fn + )["syscalls"] + + +def _sample_visible_processes(syscall_names, map_syscall_to_subsystem_fn, kernel_dna_max_procs, fallback_mock_calls_fn): + """Sample blocked syscalls from /proc or fallback to vm/block/net counters. + + Without ptrace-level access this only ever sees the backend's own + processes, which is why the collector exists; this is what is left when it + is not running. + """ try: if platform.system() != "Linux": return fallback_mock_calls_fn() @@ -120,11 +214,14 @@ def get_real_system_calls(syscall_names, map_syscall_to_subsystem_fn, kernel_dna sampled = sorted(proc_dirs, key=int)[: min(kernel_dna_max_procs, len(proc_dirs))] syscall_counts = {} syscall_waiters = {} + syscall_numbers = {} for pid in sampled: try: syscall_path = f"/proc/{pid}/syscall" if not os.path.exists(syscall_path): continue + if _is_kernel_thread(pid): + continue with open(syscall_path, "r", encoding="utf-8", errors="replace") as f: line = f.read().strip() if not line or line in ("-1", "running"): @@ -138,6 +235,7 @@ def get_real_system_calls(syscall_names, map_syscall_to_subsystem_fn, kernel_dna continue syscall_name = syscall_names.get(syscall_num, f"syscall_{syscall_num}") syscall_counts[syscall_name] = syscall_counts.get(syscall_name, 0) + 1 + syscall_numbers[syscall_name] = syscall_num # Keep who is parked here: the count is a set of processes, and the # UI opens the dossier of any of them by pid. waiters = syscall_waiters.setdefault(syscall_name, []) @@ -151,6 +249,7 @@ def get_real_system_calls(syscall_names, map_syscall_to_subsystem_fn, kernel_dna for name, count in sorted(syscall_counts.items(), key=lambda x: x[1], reverse=True)[:20]: syscalls.append({ "name": name, + "nr": syscall_numbers.get(name), "count": count, "subsystem": map_syscall_to_subsystem_fn(name), "waiters": syscall_waiters.get(name, []), diff --git a/kernel_ai/services/telemetry_orchestration.py b/kernel_ai/services/telemetry_orchestration.py index 578bbcc..69fc372 100644 --- a/kernel_ai/services/telemetry_orchestration.py +++ b/kernel_ai/services/telemetry_orchestration.py @@ -45,6 +45,15 @@ def get_real_system_calls(): ) +def get_syscall_sample(): + return _syscalls_service.get_syscall_sample( + syscall_names=get_syscall_names(), + map_syscall_to_subsystem_fn=map_syscall_to_subsystem, + kernel_dna_max_procs=KERNEL_DNA_MAX_PROCS, + fallback_mock_calls_fn=get_mock_system_calls, + ) + + def get_kernel_subsystem_status(): return _core_observability_service.get_kernel_subsystem_status() diff --git a/kernel_ai/services/threads.py b/kernel_ai/services/threads.py new file mode 100644 index 0000000..f08bf54 --- /dev/null +++ b/kernel_ai/services/threads.py @@ -0,0 +1,275 @@ +"""Every thread of a process, and what the scheduler owes each one. + +A process owns the memory and the descriptors; the thing Linux actually puts on +a CPU is the thread. ``/proc//task/`` keeps the per-thread half of the +story and most of it is world-readable: the state, the CPU it last ran on, the +time it has spent running and — the number that decides how a machine feels — +the time it has spent runnable while something else held the CPU. + +Two facts are not readable at this privilege and come from the root collectors +instead. The call a thread is parked in needs ptrace-level access to +``/proc//task//syscall``, so it is read out of the syscall snapshot. +The scheduler's own verdict — whether a thread is eligible for the CPU, and how +far its virtual clock has drifted from the fair one — lives in root-only +debugfs, so it is read out of the sched_debug snapshot. When a collector is not +running, those columns are missing rather than guessed, and the payload says +which ones and why. + +That verdict is reported only for threads that are actually on a runqueue. The +kernel prints its task table per CPU rather than per runqueue, so a sleeping +thread appears there too, carrying the vruntime it was frozen at. Measuring +that against the runqueue's current fair clock produces a lag that grows for as +long as the thread sleeps — a kworker idle since boot would be shown as owed +hours of CPU. Lag and eligibility describe a place in a queue, so they are +attached to the threads that hold one and left off the rest. +""" + +from __future__ import annotations + +import json +import os +import time + +PROC = "/proc" + +SYSCALLS_SNAPSHOT = os.environ.get("SYSCALLS_OUT", "/run/kernel-ai/syscalls.json") +TASKS_SNAPSHOT = os.environ.get("TASKS_OUT", "/run/kernel-ai/tasks.json") +SCHED_DEBUG_SNAPSHOT = os.environ.get("SCHED_DEBUG_OUT", "/run/kernel-ai/sched_debug.json") +SNAPSHOT_MAX_AGE_S = 15.0 + +# A card is a card. Everything is counted, this is only how many get a row. +MAX_THREADS = 24 + +# task_state_to_char() in the kernel, spelled out. +STATE_LABEL = { + "R": "on cpu or queued", + "S": "sleeping, wakeable", + "D": "uninterruptible wait", + "T": "stopped", + "t": "stopped by debugger", + "X": "dead", + "Z": "zombie", + "P": "parked", + "I": "idle kernel thread", +} + + +def _read(path): + try: + with open(path, "r", encoding="utf-8", errors="replace") as fh: + return fh.read().strip() + except OSError: + return "" + + +def _snapshot(path, max_age_s=SNAPSHOT_MAX_AGE_S): + """A collector's snapshot, or why it cannot be used.""" + try: + age = max(0.0, time.time() - os.path.getmtime(path)) + except OSError: + return None, {"available": False, "reason": "no-collector"} + if age > max_age_s: + return None, {"available": False, "reason": "stale", "age_s": round(age, 1)} + try: + with open(path, "r", encoding="utf-8", errors="ignore") as fh: + data = json.load(fh) + except (OSError, ValueError): + return None, {"available": False, "reason": "unreadable"} + if not isinstance(data, dict): + return None, {"available": False, "reason": "malformed"} + return data, {"available": True, "age_s": round(age, 1)} + + +def _stat(task_dir): + """State and last CPU from ``stat``. + + The command sits in parentheses and may contain both spaces and brackets, + so every field is counted from the last ``)`` rather than from the start. + """ + line = _read(f"{task_dir}/stat") + if not line: + return {} + _, _, tail = line.rpartition(") ") + fields = tail.split() + if not fields: + return {} + out = {"state": fields[0]} + # Field 39 of the whole line is the CPU the task last ran on; counting from + # the state, which is field 3, it is index 36. + if len(fields) > 36: + try: + out["cpu"] = int(fields[36]) + except ValueError: + pass + return out + + +def _schedstat(task_dir): + """``schedstat`` -> time on the CPU, time queued for it, and turns taken. + + The middle number is the one worth having: nanoseconds this thread spent + runnable while the CPU was busy with something else. + """ + parts = _read(f"{task_dir}/schedstat").split() + try: + return int(parts[0]), int(parts[1]), int(parts[2]) + except (IndexError, ValueError): + return 0, 0, 0 + + +def _switches(task_dir): + """How this thread's turns on the CPU ended: it yielded, or it was taken.""" + vol = forced = None + for line in _read(f"{task_dir}/status").splitlines(): + key, _, value = line.partition(":") + value = value.strip() + if not value.isdigit(): + continue + if key == "voluntary_ctxt_switches": + vol = int(value) + elif key == "nonvoluntary_ctxt_switches": + forced = int(value) + if vol is not None and forced is not None: + break + return vol, forced + + +def _due_ms(sched): + """How far the thread's virtual deadline is from the runqueue's clock. + + EEVDF picks the eligible thread with the earliest deadline, so this is the + number the choice is made on: virtual ms until this thread's turn must have + happened, negative once it is overdue. + """ + deadline = sched.get("deadline_v") + now = sched.get("avg_vruntime") + if isinstance(deadline, (int, float)) and isinstance(now, (int, float)): + return round(deadline - now, 3) + return None + + +def _thread_ids(pid): + try: + return sorted(int(t) for t in os.listdir(f"{PROC}/{pid}/task") if t.isdigit()) + except OSError: + return [] + + +def _sort_key(thread): + # The thread on a CPU first, then whoever has lived the busiest life. + return ( + 0 if thread["state"] == "R" else 1, + -(thread["ran_ms"] + thread["waited_ms"]), + thread["tid"], + ) + + +def describe(pid, max_threads=MAX_THREADS): + """The threads of one process, as the kernel currently sees them.""" + pid = int(pid) + tids = _thread_ids(pid) + if not tids: + return {"pid": pid, "error": "no such process"} + + parked_snap, parked_src = _snapshot(TASKS_SNAPSHOT) + sched_snap, sched_src = _snapshot(SCHED_DEBUG_SNAPSHOT) + scheduled = (sched_snap or {}).get("tasks") or {} + parked = (parked_snap or {}).get("tasks") + if not isinstance(parked, dict): + parked = {} + if parked_src.get("reason") == "no-collector" and _snapshot(SYSCALLS_SNAPSHOT)[1]["available"]: + # The syscall collector is running, but from a build that publishes + # no per-thread index. Different failure, different fix. + parked_src = {"available": False, "reason": "no-thread-index"} + elif parked_src["available"]: + parked_src = {"available": False, "reason": "malformed"} + + threads = [] + leader_vol = leader_forced = None + slice_ms = None + + for tid in tids: + task_dir = f"{PROC}/{pid}/task/{tid}" + stat = _stat(task_dir) + if not stat: + # It exited between the listing and the read. + continue + ran_ns, waited_ns, turns = _schedstat(task_dir) + vol, forced = _switches(task_dir) + if tid == pid: + leader_vol, leader_forced = vol, forced + + row = { + "tid": tid, + "name": _read(f"{task_dir}/comm") or None, + "leader": tid == pid, + "state": stat.get("state", "?"), + "state_label": STATE_LABEL.get(stat.get("state", ""), "unknown"), + "cpu": stat.get("cpu"), + "ran_ms": round(ran_ns / 1e6, 1), + "waited_ms": round(waited_ns / 1e6, 1), + "turns": turns, + "voluntary": vol, + "forced": forced, + } + + call = parked.get(str(tid)) + if isinstance(call, dict): + row["parked_in"] = call.get("name") + row["wchan"] = call.get("wchan") or None + row["fd"] = call.get("fd") + row["fd_target"] = call.get("fd_target") + + sched = scheduled.get(str(tid)) + if isinstance(sched, dict): + prio = sched.get("prio") + if isinstance(prio, int): + row["nice"] = prio - 120 + if slice_ms is None: + slice_ms = sched.get("slice_ms") + if row["state"] == "R": + # Queued right now: the scheduler's numbers are about this + # thread's turn, and the deadline is what EEVDF compares. + row["on_cpu"] = bool(sched.get("current")) + row["eligible"] = sched.get("eligible") + row["vlag_ms"] = sched.get("vlag_ms") + due = _due_ms(sched) + if due is not None: + row["due_ms"] = due + + threads.append(row) + + threads.sort(key=_sort_key) + + totals = { + "ran_ms": round(sum(t["ran_ms"] for t in threads), 1), + "waited_ms": round(sum(t["waited_ms"] for t in threads), 1), + "turns": sum(t["turns"] for t in threads), + "voluntary": sum(t["voluntary"] or 0 for t in threads), + "forced": sum(t["forced"] or 0 for t in threads), + "on_cpu": sum(1 for t in threads if t["state"] == "R"), + "parked": sum(1 for t in threads if t.get("parked_in")), + } + + return { + "pid": pid, + "comm": _read(f"{PROC}/{pid}/comm") or None, + "thread_count": len(threads), + "cpus": os.cpu_count() or 1, + "threads": threads[:max_threads], + "totals": totals, + # The collector only produces rows on a kernel that prints eligibility + # and a deadline per task, so a snapshot with tasks in it is itself the + # evidence that this kernel schedules with EEVDF. Older kernels run CFS, + # which keeps none of those numbers, and the card then says so rather + # than showing a column of blanks. + "scheduler": { + "name": "EEVDF" if scheduled else None, + "slice_ms": slice_ms, + }, + "sources": {"parked_in": parked_src, "scheduler": sched_src}, + # The dossier has always shown the leader's own counters under this + # name; the per-process sums live in "totals" so neither number moves. + "voluntary_ctxt_switches": leader_vol, + "nonvoluntary_ctxt_switches": leader_forced, + } diff --git a/kernel_ai/services/waits.py b/kernel_ai/services/waits.py new file mode 100644 index 0000000..2beb0a9 --- /dev/null +++ b/kernel_ai/services/waits.py @@ -0,0 +1,477 @@ +"""What one thread is waiting for, and who is on the other side of it. + +Most of a machine is asleep, and the interesting question about a sleeping +thread is not that it sleeps but what would have to happen for it to wake. +Three of those answers are reachable from ``/proc``, and they are the three +this module gives. + +**An epoll set.** The commonest wait on any machine, and the only one that says +out loud what it is waiting for: ``/proc//fdinfo/`` lists every +descriptor registered in the set together with the events asked of it. Sockets +among them get their addresses back from ``/proc/net``, which needs no +privilege at all. + +**A futex.** The kernel side of a userspace lock is a single word of the +process's own memory; a thread that cannot take the lock asks the kernel to +park it until that word changes. The address of the word is the first argument +of the call, so two threads waiting on the same address are waiting for the +same thing, and that grouping is exact. What the kernel does *not* publish is +who holds the lock: for an ordinary futex the owner is never recorded anywhere +the kernel can see — userspace took it without telling the kernel, which is the +whole point of the design — and reading the word out of the process's memory +would need the ptrace access this service deliberately does not have. So the +owner is left unnamed, with the threads that could be holding it listed +instead: on a lock this contended, the answer is nearly always the one thread +of the process that is not waiting for it. + +**A pipe.** An anonymous pipe has no name at all, only an inode, and two +processes are talking exactly when they hold descriptors on the same one. The +pairing is therefore a whole-machine question — every descriptor of every +process has to be looked at — which the unprivileged backend cannot do and the +root collector does for it. + +The three answers come from the collectors' snapshots. Where a snapshot is +missing the answer is missing too, and says which one it needed. + +A fourth thing can be said when the wakeup collector has a recent window: who +was *seen* waking this thread. That is not the holder of a lock — the kernel +still does not record one — but it is the thread that last let go, if the +window happened to catch it. The card reports the observation and leaves the +inference to the reader. +""" + +from __future__ import annotations + +import json +import os +import time + +PROC = "/proc" +TASKS_SNAPSHOT = os.environ.get("TASKS_OUT", "/run/kernel-ai/tasks.json") +ENDPOINTS_SNAPSHOT = os.environ.get("ENDPOINTS_OUT", "/run/kernel-ai/endpoints.json") +SNAPSHOT_MAX_AGE_S = 15.0 +MAX_LISTED = 8 + +# include/uapi/linux/futex.h +FUTEX_PRIVATE_FLAG = 128 +FUTEX_CLOCK_REALTIME = 256 +FUTEX_CMD_MASK = ~(FUTEX_PRIVATE_FLAG | FUTEX_CLOCK_REALTIME) + +FUTEX_OPS = { + 0: ("FUTEX_WAIT", "parked until the word changes from the value it expected"), + 1: ("FUTEX_WAKE", "waking whoever waits on the word"), + 3: ("FUTEX_REQUEUE", "moving waiters to another word"), + 4: ("FUTEX_CMP_REQUEUE", "moving waiters to another word"), + 5: ("FUTEX_WAKE_OP", "waking waiters on two words at once"), + 6: ("FUTEX_LOCK_PI", "queued for a priority-inheriting mutex the kernel owns"), + 7: ("FUTEX_UNLOCK_PI", "releasing a priority-inheriting mutex"), + 8: ("FUTEX_TRYLOCK_PI", "trying a priority-inheriting mutex"), + 9: ("FUTEX_WAIT_BITSET", "parked with a deadline — how glibc waits on a condition variable"), + 10: ("FUTEX_WAKE_BITSET", "waking a selected set of waiters"), + 11: ("FUTEX_WAIT_REQUEUE_PI", "parked on a condition variable that hands over a PI mutex"), + 12: ("FUTEX_CMP_REQUEUE_PI", "handing waiters to a PI mutex"), + 13: ("FUTEX_LOCK_PI2", "queued for a priority-inheriting mutex the kernel owns"), +} + +# For these the kernel is the owner of record: the futex word holds the owner's +# tid by definition, because priority inheritance needs to know whom to boost. +PI_OPS = {6, 7, 8, 11, 12, 13} + +STATE_LABEL = { + "R": "on cpu or queued", + "S": "sleeping, wakeable", + "D": "uninterruptible wait", + "T": "stopped", + "t": "stopped by debugger", + "Z": "zombie", + "I": "idle kernel thread", +} + + +def _read(path): + try: + with open(path, "r", encoding="utf-8", errors="replace") as fh: + return fh.read().strip() + except OSError: + return "" + + +def _snapshot(path, max_age_s=None): + max_age_s = SNAPSHOT_MAX_AGE_S if max_age_s is None else max_age_s + try: + age = max(0.0, time.time() - os.path.getmtime(path)) + except OSError: + return None, {"available": False, "reason": "no-collector"} + if age > max_age_s: + return None, {"available": False, "reason": "stale", "age_s": round(age, 1)} + try: + with open(path, "r", encoding="utf-8", errors="ignore") as fh: + data = json.load(fh) + except (OSError, ValueError): + return None, {"available": False, "reason": "unreadable"} + if not isinstance(data, dict): + return None, {"available": False, "reason": "malformed"} + return data, {"available": True, "age_s": round(age, 1)} + + +def _state(tid_dir): + line = _read(f"{tid_dir}/stat") + if not line: + return "" + _, _, tail = line.rpartition(") ") + parts = tail.split() + return parts[0] if parts else "" + + +def _thread_ids(pid): + try: + return sorted(int(t) for t in os.listdir(f"{PROC}/{pid}/task") if t.isdigit()) + except OSError: + return [] + + +def decode_op(raw): + """What kind of futex wait this is, spelled out.""" + if not isinstance(raw, int): + return None + cmd = raw & FUTEX_CMD_MASK + name, means = FUTEX_OPS.get(cmd, (f"FUTEX_{cmd}", "an operation this build does not name")) + return { + "raw": raw, + "cmd": cmd, + "name": name, + "means": means, + "private": bool(raw & FUTEX_PRIVATE_FLAG), + "realtime": bool(raw & FUTEX_CLOCK_REALTIME), + "pi": cmd in PI_OPS, + } + + +def _owner_verdict(op): + """Why the holder of this lock cannot be named.""" + if op and op.get("pi"): + return { + "known": False, + "why": "a PI futex keeps the owner's tid in the word itself, " + "and reading it needs access to the process's memory", + } + return { + "known": False, + "why": "an ordinary futex is taken in userspace without telling the kernel, " + "so no owner is recorded anywhere /proc can reach", + } + + +def _futex(pid, tid, entry, parked): + """The threads waiting on this word, and what can be said about the owner. + + Private futexes are keyed by address inside one address space, so the same + address in another process is a different lock — the grouping never crosses + a process. A shared futex is keyed by the physical page instead and can be + waited on from another address entirely, which is a pairing /proc cannot + make; that limit is reported rather than guessed around. + """ + word = entry.get("word") + op = decode_op(entry.get("op")) + + waiters = [] + for other_tid, other in parked.items(): + if other.get("pid") != pid or other.get("word") != word: + continue + waiters.append({ + "tid": int(other_tid), + "comm": _read(f"{PROC}/{pid}/task/{other_tid}/comm") or None, + "self": int(other_tid) == int(tid), + }) + waiters.sort(key=lambda w: w["tid"]) + + waiting_tids = {w["tid"] for w in waiters} + others = [] + for other_tid in _thread_ids(pid): + if other_tid in waiting_tids: + continue + state = _state(f"{PROC}/{pid}/task/{other_tid}") + other = parked.get(str(other_tid)) or {} + others.append({ + "tid": other_tid, + "comm": _read(f"{PROC}/{pid}/task/{other_tid}/comm") or None, + "state": state, + "state_label": STATE_LABEL.get(state, "unknown"), + "parked_in": other.get("name"), + }) + + # Whoever holds the lock is one of the threads not waiting for it, and in a + # process with four threads that is nearly an answer. In one with a hundred + # it is nothing at all, so the set is offered two ways: in full while it is + # small, and otherwise narrowed to the threads that can be executing this + # instant. A sleeping thread can hold a lock too — that is exactly what a + # lock held across a blocking call looks like — so nothing is ruled out, + # only counted. + running = [row for row in others if row["state"] == "R"] + candidates = { + "total": len(others), + "running": running[:MAX_LISTED], + "sample": others[:MAX_LISTED], + } + + return { + "kind": "futex", + "word": word, + "op": op, + "expected": entry.get("val"), + "waiters": waiters[:MAX_LISTED], + "waiter_count": len(waiters), + "owner": _owner_verdict(op), + "candidates": candidates, + "scope": "this process only" if (op or {}).get("private") else "may reach other processes", + } + + +def _pipe(pid, entry, endpoints): + """The far end of an anonymous pipe, found by matching inodes.""" + target = entry.get("fd_target") or "" + if not target.startswith("pipe:["): + return None + inode = target[6:-1] + pipes = (endpoints or {}).get("pipes") or {} + ends = pipes.get(inode) or {} + readers = ends.get("readers") or [] + writers = ends.get("writers") or [] + + fd = entry.get("fd") + reading = any(r.get("pid") == pid and r.get("fd") == fd for r in readers) + writing = any(w.get("pid") == pid and w.get("fd") == fd for w in writers) + direction = "reading" if reading else ("writing" if writing else None) + + far = writers if reading else readers + near = readers if reading else writers + return { + "kind": "pipe", + "inode": inode, + "fd": fd, + "direction": direction, + "other_end": [r for r in far if not (r.get("pid") == pid and r.get("fd") == fd)], + "same_end": [r for r in near if not (r.get("pid") == pid and r.get("fd") == fd)], + } + + +def _hex_addr(field): + """``0100007F:1F90`` as the kernel writes it — bytes reversed, port in hex.""" + try: + raw, _, port = field.partition(":") + port = int(port, 16) + except ValueError: + return None + if len(raw) == 8: + octets = [int(raw[i:i + 2], 16) for i in range(0, 8, 2)][::-1] + return f"{'.'.join(str(o) for o in octets)}:{port}" + if len(raw) == 32: + # An IPv6 address here is only ever shown as its port and family; the + # full form adds nothing a reader of this card wants. + return f"[::]:{port}" + return None + + +def socket_labels(): + """Socket inode to something a person can read, from world-readable files.""" + labels = {} + for line in _read("/proc/net/unix").splitlines()[1:]: + fields = line.split() + if len(fields) < 7 or not fields[6].isdigit(): + continue + path = fields[7] if len(fields) > 7 else None + labels[fields[6]] = f"unix {path}" if path else "unix socket, unnamed" + for line in _read("/proc/net/netlink").splitlines()[1:]: + fields = line.split() + if len(fields) >= 10 and fields[9].isdigit(): + labels[fields[9]] = "netlink to the kernel" + for path, kind in (("/proc/net/tcp", "tcp"), ("/proc/net/tcp6", "tcp"), + ("/proc/net/udp", "udp"), ("/proc/net/udp6", "udp")): + for line in _read(path).splitlines()[1:]: + fields = line.split() + if len(fields) < 10 or not fields[9].isdigit(): + continue + local = _hex_addr(fields[1]) + remote = _hex_addr(fields[2]) + listening = kind == "tcp" and fields[3] == "0A" + if listening: + labels[fields[9]] = f"{kind} listening on {local}" + elif remote and not remote.endswith(":0"): + labels[fields[9]] = f"{kind} {local} to {remote}" + else: + labels[fields[9]] = f"{kind} {local}" + return labels + + +# What a descriptor is, taken from the name the kernel gives it in /proc//fd. +FD_KINDS = ( + ("socket:[", "socket"), + ("pipe:[", "pipe"), + ("anon_inode:[timerfd]", "timer"), + ("anon_inode:[signalfd]", "signals"), + ("anon_inode:[eventfd]", "eventfd"), + ("anon_inode:inotify", "file changes"), + ("anon_inode:[eventpoll]", "epoll set"), + ("/dev/", "device"), +) + +EVENT_BITS = ((0x1, "data"), (0x4, "room to write"), (0x2, "urgent data"), + (0x2000, "the peer hanging up"), (0x10, "a hangup"), (0x8, "an error")) + + +def _fd_kind(target): + for prefix, kind in FD_KINDS: + if target.startswith(prefix): + return kind + return "file" if target.startswith("/") else "other" + + +def _events(mask): + if not isinstance(mask, int): + return None + named = [name for bit, name in EVENT_BITS if mask & bit] + return ", ".join(named[:2]) if named else None + + +def _epoll(entry): + """What an epoll set is watching, named where the name is knowable.""" + watch = entry.get("watching") + if not watch: + return None + labels = socket_labels() + kinds = {} + rows = [] + for item in watch.get("watched") or []: + target = item.get("target") or "" + kind = _fd_kind(target) if target else "gone" + kinds[kind] = kinds.get(kind, 0) + 1 + label = target + if kind == "socket": + label = labels.get(target[8:-1], target) + elif kind == "pipe": + label = f"pipe:[{target[6:-1]}]" + elif target.startswith("anon_inode:"): + label = kind + rows.append({"fd": item.get("fd"), "kind": kind, "label": label, + "waiting_for": _events(item.get("events"))}) + + # A set often holds the same thing many times over — four sockets to the + # journal, a dozen connections to one service. Listing them one by one says + # less than saying how many there are. + merged = {} + for row in rows: + key = (row["label"], row["waiting_for"]) + seen = merged.get(key) + if seen: + seen["count"] += 1 + else: + merged[key] = dict(row, count=1) + grouped = sorted(merged.values(), key=lambda r: (-r["count"], r["fd"] or 0)) + + return { + "kind": "epoll", + "epfd": entry.get("fd"), + "total": watch.get("total"), + "shown": len(rows), + "kinds": kinds, + "watched": grouped, + } + + +def _locks_for(pid, endpoints): + rows = (endpoints or {}).get("locks") or [] + return [r for r in rows if r.get("pid") == pid] + + +def _seen_waking(tids): + """Who the last window saw waking any of these threads. + + A thread that is still parked has not been woken for *this* wait, so a + waker named here is the one that released an earlier hold — or that woke a + sibling still grouped on the same word. Either way it is an observation, + not a claim that they hold the lock now. + """ + from kernel_ai.services import wakeups as wakeups_service + + merged = {} + source = {"available": False, "reason": "no-collector"} + window_s = None + available = False + for tid in tids: + found = wakeups_service.for_thread(tid) + source = found.get("source") or source + window_s = found.get("window_s") + available = bool(found.get("available")) + for row in found.get("wakers") or []: + waker = row.get("waker") or {} + key = waker.get("tid") + if key is None: + continue + seen = merged.get(key) + if seen is None: + seen = merged[key] = { + "tid": waker.get("tid"), + "comm": waker.get("comm"), + "pid": waker.get("pid"), + "idle": bool(waker.get("idle")), + "kernel": bool(waker.get("kernel")), + "count": 0, + "of": [], + "contexts": {}, + } + seen["count"] += int(row.get("count") or 0) + if tid not in seen["of"]: + seen["of"].append(tid) + for name, n in (row.get("contexts") or {}).items(): + seen["contexts"][name] = seen["contexts"].get(name, 0) + n + wakers = sorted(merged.values(), key=lambda row: -row["count"]) + return { + "available": available, + "source": source, + "window_s": window_s, + "wakers": wakers[:MAX_LISTED], + } + + +def describe(pid, tid=None): + """What this thread waits for, as far as /proc can honestly say.""" + pid = int(pid) + tid = int(tid) if tid is not None else pid + if not os.path.isdir(f"{PROC}/{pid}/task/{tid}"): + return {"pid": pid, "tid": tid, "error": "no such thread"} + + tasks, tasks_src = _snapshot(TASKS_SNAPSHOT) + endpoints, endpoints_src = _snapshot(ENDPOINTS_SNAPSHOT) + parked = (tasks or {}).get("tasks") or {} + entry = parked.get(str(tid)) or {} + + out = { + "pid": pid, + "tid": tid, + "comm": _read(f"{PROC}/{pid}/task/{tid}/comm") or None, + "process": _read(f"{PROC}/{pid}/comm") or None, + "state": _state(f"{PROC}/{pid}/task/{tid}"), + "call": entry.get("name"), + "wchan": entry.get("wchan"), + "fd_target": entry.get("fd_target"), + "locks": _locks_for(pid, endpoints), + "sources": {"parked_in": tasks_src, "endpoints": endpoints_src}, + } + out["state_label"] = STATE_LABEL.get(out["state"], "unknown") + + if entry.get("name", "").startswith("futex"): + out["waiting_on"] = _futex(pid, tid, entry, parked) + elif entry.get("watching"): + out["waiting_on"] = _epoll(entry) + else: + out["waiting_on"] = _pipe(pid, entry, endpoints) + + # Look up this thread, and on a futex the others waiting on the same word: + # whoever woke any of them released or signaled that word. + looked = [tid] + on = out.get("waiting_on") or {} + if on.get("kind") == "futex": + looked = [w["tid"] for w in (on.get("waiters") or [])] or [tid] + out["seen_waking"] = _seen_waking(looked) + out["sources"]["wakeups"] = out["seen_waking"].get("source") + return out diff --git a/kernel_ai/services/wakeups.py b/kernel_ai/services/wakeups.py new file mode 100644 index 0000000..8189cf7 --- /dev/null +++ b/kernel_ai/services/wakeups.py @@ -0,0 +1,187 @@ +"""Who woke whom, over a sampled window of the scheduler's work. + +Every other view in this project reads a state that is simply there for the +asking. This one cannot: a wakeup leaves nothing behind. One task makes another +runnable, the scheduler moves on, and the only trace is the tracepoint the +kernel fires at that instant. A root collector samples it — see +``deploy/ebpf/wakeup_collector.py`` — and this module shapes what it caught. + +Two honest limits are carried through into the payload rather than smoothed +over. The window is short and periodic, so the numbers are a sample of the +machine's waking, not a census of it. And the sampler is itself a task: reading +files wakes the things that serve them, so the collector's own thread is marked +wherever it appears. + +The vocabulary is worth keeping straight. A wakeup from *task* context is one +piece of software deciding another should run — a lock released, a message +written, a child spawned. A wakeup from *hardirq* context is the hardware +saying so: a packet landed, a disk finished, a timer expired. Which of the two +dominates says more about what a machine is doing than any load average. +""" + +from __future__ import annotations + +import json +import os +import time + +SNAPSHOT = os.environ.get("WAKEUPS_OUT", "/run/kernel-ai/wakeups.json") +SNAPSHOT_MAX_AGE_S = 30.0 +MAX_EDGES = 12 + +CONTEXT_MEANS = { + "task": "one task deciding another should run", + "softirq": "deferred kernel work — the network and timer path", + "hardirq": "the hardware itself, through an interrupt", +} + + +def _snapshot(path=None, max_age_s=None): + snap = path or SNAPSHOT + max_age_s = SNAPSHOT_MAX_AGE_S if max_age_s is None else max_age_s + try: + age = max(0.0, time.time() - os.path.getmtime(snap)) + except OSError: + return None, {"available": False, "reason": "no-collector"} + if age > max_age_s: + return None, {"available": False, "reason": "stale", "age_s": round(age, 1)} + try: + with open(snap, "r", encoding="utf-8", errors="ignore") as fh: + data = json.load(fh) + except (OSError, ValueError): + return None, {"available": False, "reason": "unreadable"} + if not isinstance(data, dict): + return None, {"available": False, "reason": "malformed"} + return data, {"available": True, "age_s": round(age, 1)} + + +def _unit(pid): + """The service a pid belongs to, if systemd owns it.""" + if not pid: + return None + try: + with open(f"/proc/{pid}/cgroup", "r", encoding="utf-8", errors="ignore") as fh: + text = fh.read() + except OSError: + return None + for part in text.strip().split("/"): + if part.endswith(".service") or part.endswith(".scope"): + return part + return None + + +def _named(row): + """A rolled-up end of the window, with tid 0 spelled as an idle cpu.""" + if not row: + return row + if row.get("tid") == 0 or row.get("comm") == "": + return dict(row, comm="idle cpu", idle=True) + return dict(row, idle=False) + + +def _side(edge, prefix): + """One end of an edge: the thread, its process, and what kind of thing it is.""" + tid = edge.get(f"{prefix}tid") + comm = edge.get(f"{prefix}comm") + pid = edge.get(f"{prefix}pid") + kernel = edge.get(f"{prefix}kernel") + # Tid 0 is not a task at all. It is the CPU with nothing to run, and the + # kernel books an interrupt's wakeup against it. + idle = tid == 0 or comm == "" + return { + "tid": tid, + "comm": "idle cpu" if idle else comm, + "pid": pid, + "kernel": bool(kernel), + "idle": idle, + "unit": _unit(pid) if pid and not kernel else None, + } + + +def _why(edge, contexts): + """The plainest true sentence about how this wakeup happened.""" + where = max(contexts, key=contexts.get) if contexts else "task" + if edge["waker"]["idle"]: + return "an interrupt arriving while the cpu had nothing to run" + if where == "hardirq": + return "an interrupt, taken while this waker happened to be running" + if where == "softirq": + return "deferred kernel work on the waker's back" + if edge.get("new"): + return "a task starting for the first time" + return "the waker itself, in ordinary code" + + +def describe(max_edges=MAX_EDGES): + """The wakeups seen in the last sampled window.""" + snap, source = _snapshot() + out = { + "available": source.get("available", False), + "source": source, + "window_s": None, + "events": 0, + "edges": [], + } + if not snap: + return out + + window = float(snap.get("window_s") or 0) or None + events = int(snap.get("events") or 0) + contexts = snap.get("contexts") or {} + observer = snap.get("observer_tid") + + edges = [] + for raw in (snap.get("edges") or [])[:max_edges]: + edge = { + "waker": _side(raw, "waker_"), + "woken": _side(raw, ""), + "count": int(raw.get("count") or 0), + "contexts": raw.get("contexts") or {}, + "new": bool(raw.get("new")), + } + edge["waker"]["observer"] = edge["waker"]["tid"] == observer + edge["woken"]["observer"] = edge["woken"]["tid"] == observer + edge["why"] = _why(edge, edge["contexts"]) + edges.append(edge) + + out.update({ + "window_s": window, + "events": events, + "rate_per_s": round(events / window) if window else None, + "lost": int(snap.get("lost") or 0), + "distinct_edges": int(snap.get("distinct_edges") or 0), + "contexts": {name: {"count": count, "means": CONTEXT_MEANS.get(name)} + for name, count in contexts.items() if count}, + "edges": edges, + "wakers": [_named(row) for row in (snap.get("wakers") or [])[:6]], + "wakees": [_named(row) for row in (snap.get("wakees") or [])[:6]], + "observer_tid": observer, + }) + return out + + +def for_thread(tid, max_edges=6): + """Who was seen waking this one thread — the empirical answer to a wait. + + A thread parked on a lock is woken by whoever released it. The kernel keeps + no record of the owner of an ordinary futex, but a wakeup observed in the + window names the thread that actually did it. + """ + snap, source = _snapshot() + if not snap: + return {"tid": int(tid), "available": False, "source": source, "wakers": []} + tid = int(tid) + rows = [] + for raw in snap.get("edges") or []: + if raw.get("tid") != tid: + continue + rows.append({"waker": _side(raw, "waker_"), "count": int(raw.get("count") or 0), + "contexts": raw.get("contexts") or {}}) + rows.sort(key=lambda row: -row["count"]) + return { + "tid": tid, + "available": True, + "source": source, + "window_s": snap.get("window_s"), + "wakers": rows[:max_edges], + } diff --git a/static/css/main.css b/static/css/main.css index d029458..ee7ccfd 100755 --- a/static/css/main.css +++ b/static/css/main.css @@ -174,91 +174,128 @@ svg { max-width: 300px; } -/* Namespace HUD tooltip (ink-on-paper FUI style) */ +/* Namespace hover chip — card language shared with the process dossier */ .ns-hud-tooltip { - background: rgba(20, 22, 26, 0.94); - color: #e8eaef; - border: 1px solid rgba(120, 126, 138, 0.5); - border-radius: 3px; - padding: 9px 11px; - min-width: 172px; - max-width: 240px; - box-shadow: 0 6px 22px rgba(0, 0, 0, 0.35); -} -.ns-hud-top { + background: none; + border: 0; + border-radius: 0; + padding: 0; + color: #f4f4ec; + /* Filters are applied before clipping, so the shadow lives on this wrapper + and the chamfer on the card inside it. */ + filter: drop-shadow(0 7px 13px rgba(7, 9, 12, 0.45)); + max-width: none; +} +.ns-hud-card { + min-width: 214px; + max-width: 254px; + background: #090c10; + border: 1px solid rgba(236, 236, 226, 0.17); + clip-path: polygon(0 0, calc(100% - 13px) 0, 100% 13px, 100% 100%, 0 100%); +} +/* The clip cuts the border along the chamfer, so redraw that hairline. */ +.ns-hud-card::after { + content: ''; + position: absolute; + top: 0; + left: calc(100% - 13px); + width: 19px; + height: 1px; + background: rgba(236, 236, 226, 0.17); + transform-origin: 0 0; + transform: rotate(45deg); +} +.ns-hud-head { 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); + align-items: center; + gap: 8px; + height: 25px; + padding: 0 13px 0 10px; + background: rgba(244, 244, 236, 0.055); + border-bottom: 1px solid rgba(236, 236, 226, 0.17); +} +.ns-hud-glyph { + position: relative; + flex: none; + width: 9px; + height: 9px; + border: 1px solid rgba(244, 244, 236, 0.5); + border-radius: 50%; } -.ns-hud-over { - font-size: 7.5px; - letter-spacing: 1.8px; - color: #7f8794; +.ns-hud-glyph::after { + content: ''; + position: absolute; + inset: 2px; + border-radius: 50%; + background: #e2a33e; } -.ns-hud-name { - font-size: 13px; - letter-spacing: 1.6px; - color: #ffffff; - line-height: 1.15; +.ns-hud-title { + flex: 1; + font-size: 9px; + letter-spacing: 1.7px; + color: #f4f4ec; } -.ns-hud-pill { +.ns-hud-flag { flex: none; - margin-top: 2px; - font-size: 7.5px; + font-size: 9px; 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; + color: rgba(244, 244, 236, 0.5); 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-flag.is-iso { color: #e2a33e; } +.ns-hud-body { padding: 10px 13px 11px; } +.ns-hud-name { + font-size: 17px; + line-height: 1; + letter-spacing: 2.4px; + color: #f4f4ec; } .ns-hud-desc { - font-size: 9.5px; + margin: 6px 0 9px; + font-size: 10px; line-height: 1.35; - color: #aab0bc; - margin-bottom: 7px; + color: rgba(244, 244, 236, 0.5); } -.ns-hud-rows { margin: 6px 0; } .ns-hud-row { display: flex; justify-content: space-between; - font-size: 9.5px; + align-items: baseline; + gap: 10px; + padding: 3px 0; + border-top: 1px solid rgba(236, 236, 226, 0.1); +} +.ns-hud-row span { + font-size: 9px; + letter-spacing: 1.2px; + color: rgba(244, 244, 236, 0.5); +} +.ns-hud-row b { + font-size: 11px; + font-weight: 400; letter-spacing: 0.4px; - padding: 1px 0; + color: #f4f4ec; } -.ns-hud-row span { color: #8b909c; } -.ns-hud-row b { color: #e8eaef; font-weight: 600; } -.ns-hud-bar { - margin-top: 7px; - height: 4px; - border-radius: 2px; - background: rgba(120, 126, 138, 0.25); - overflow: hidden; +.ns-hud-row b.is-live { color: #e2a33e; } +.ns-hud-meter { + margin-top: 10px; + height: 3px; + background: rgba(236, 236, 226, 0.1); } -.ns-hud-bar i { +.ns-hud-meter i { display: block; height: 100%; - background: rgba(88, 182, 216, 0.85); + background: #e2a33e; } .ns-hud-foot { display: flex; justify-content: space-between; - margin-top: 5px; - font-size: 8px; + gap: 10px; + margin-top: 7px; + font-size: 9px; letter-spacing: 1.2px; - color: #7f8794; + color: rgba(244, 244, 236, 0.26); } -.ns-hud-hint { color: #7fd0ea; } +.ns-hud-hint { color: rgba(226, 163, 62, 0.8); } /* Marker for namespace cells that contain real isolation (unique_count > 1) */ .ns-isolated-marker { @@ -271,93 +308,229 @@ svg { 50% { opacity: 1; } } -/* Namespace system-info tree (unfolds from a namespace cell) */ -.ns-tree-conn { stroke: rgba(90, 94, 102, 0.75); stroke-width: 1.3; fill: none; } -.ns-tree-anchor { fill: rgba(60, 63, 70, 0.9); } +/* Namespace detail panel (unfolds from a namespace cell) */ +.ns-tree-conn { stroke: rgba(58, 61, 68, 0.55); stroke-width: 1.1; fill: none; } +.ns-tree-anchor { fill: rgba(58, 61, 68, 0.85); } .ns-tree-frame { - fill: rgba(20, 22, 26, 0.95); - stroke: rgba(120, 126, 138, 0.5); + fill: rgba(9, 12, 16, 0.975); + stroke: rgba(236, 236, 226, 0.17); stroke-width: 1; } +.ns-tree-strip { fill: rgba(244, 244, 236, 0.055); } +.ns-tree-glyph-ring { fill: none; stroke: rgba(244, 244, 236, 0.5); stroke-width: 1.1; } +.ns-tree-glyph-dot { fill: #e2a33e; } .ns-tree-title { - fill: #ffffff; + fill: #f4f4ec; font-family: 'Share Tech Mono', monospace; - 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; + font-size: 9px; + letter-spacing: 1.7px; } -.ns-tree-pill text { - fill: #aeb4c0; +.ns-tree-meta { + fill: rgba(244, 244, 236, 0.5); font-family: 'Share Tech Mono', monospace; - font-size: 7.5px; + font-size: 9px; 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-meta.is-iso { fill: #e2a33e; } +.ns-tree-divider { stroke: rgba(236, 236, 226, 0.17); stroke-width: 0.9; } +.ns-tree-track { fill: rgba(236, 236, 226, 0.1); } +.ns-tree-track-fill { fill: #e2a33e; } .ns-tree-foot { - fill: rgba(160, 166, 178, 0.7); + fill: rgba(244, 244, 236, 0.26); font-family: 'Share Tech Mono', monospace; - font-size: 7.5px; - letter-spacing: 0.8px; + font-size: 9px; + letter-spacing: 1.2px; } -.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); - stroke: rgba(88, 182, 216, 1); +.ns-tree-link { stroke: rgba(236, 236, 226, 0.14); stroke-width: 1; fill: none; } +.ns-tree-root path { + fill: rgba(244, 244, 236, 0.06); + stroke: rgba(236, 236, 226, 0.17); stroke-width: 1; } .ns-tree-root text { - fill: #06131b; + fill: #f4f4ec; font-family: 'Share Tech Mono', monospace; - font-size: 9.5px; - font-weight: 600; - letter-spacing: 0.6px; + font-size: 10px; + letter-spacing: 1.6px; } +.ns-tree-root.is-iso path { stroke: rgba(226, 163, 62, 0.5); } +.ns-tree-root.is-iso text { fill: #e2a33e; } .ns-tree-branch rect { - fill: rgba(255, 255, 255, 0.1); - stroke: rgba(255, 255, 255, 0.28); + fill: rgba(244, 244, 236, 0.045); + stroke: rgba(236, 236, 226, 0.1); stroke-width: 1; } .ns-tree-branch text { - fill: #eef1f5; + fill: #f4f4ec; font-family: 'Share Tech Mono', monospace; 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); + letter-spacing: 1.6px; } -.ns-tree-branch.is-iso text { fill: #dff0f7; } +.ns-tree-branch.is-iso rect { stroke: rgba(226, 163, 62, 0.38); } +.ns-tree-branch.is-iso text { fill: #e2a33e; } .ns-tree-leaf rect { - fill: rgba(255, 255, 255, 0.045); - stroke: rgba(120, 126, 138, 0.42); + fill: rgba(244, 244, 236, 0.025); + stroke: rgba(236, 236, 226, 0.08); stroke-width: 1; } .ns-tree-leaf text { - fill: #c4c9d3; + fill: rgba(244, 244, 236, 0.72); + font-family: 'Share Tech Mono', monospace; + font-size: 9.5px; + letter-spacing: 0.3px; +} +.ns-tree-leaf.is-iso text { fill: #f4f4ec; } + +/* The card language shared by the kernel-element cards: a chamfered frame that + unfolds from the row it was opened on, a header strip, and a rail of stages + below it. Used by the syscall card and the interrupt card. */ +.kcard-conn { stroke: rgba(58, 61, 68, 0.55); stroke-width: 1.1; fill: none; } +.kcard-anchor { fill: rgba(58, 61, 68, 0.85); } +.kcard-frame { + fill: rgba(9, 12, 16, 0.975); + stroke: rgba(236, 236, 226, 0.17); + stroke-width: 1; +} +.kcard-strip { fill: rgba(244, 244, 236, 0.055); } +.kcard-glyph-ring { fill: none; stroke: rgba(244, 244, 236, 0.5); stroke-width: 1.1; } +.kcard-glyph-dot { fill: #e2a33e; } +.kcard-divider { stroke: rgba(236, 236, 226, 0.17); stroke-width: 0.9; } +.kcard-title { + fill: #f4f4ec; + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + letter-spacing: 1.7px; +} +.kcard-meta { + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + letter-spacing: 1px; +} +.kcard-line { + fill: rgba(244, 244, 236, 0.72); + font-family: 'Share Tech Mono', monospace; + font-size: 9.5px; + letter-spacing: 0.9px; +} +.kcard-faint { + fill: rgba(244, 244, 236, 0.34); font-family: 'Share Tech Mono', monospace; font-size: 8.5px; + letter-spacing: 0.8px; +} +.kcard-signature { + fill: #e2a33e; + font-family: 'Share Tech Mono', monospace; + font-size: 9.5px; + letter-spacing: 0.2px; +} +.kcard-summary { + fill: rgba(244, 244, 236, 0.56); + font-family: 'Share Tech Mono', monospace; + font-size: 9px; letter-spacing: 0.3px; } -.ns-tree-leaf.is-iso rect { stroke: rgba(88, 182, 216, 0.4); } +.kcard-section { + fill: rgba(244, 244, 236, 0.5); + font-family: 'Share Tech Mono', monospace; + font-size: 8.5px; + letter-spacing: 1.6px; +} +.kcard-rail { stroke: rgba(236, 236, 226, 0.16); stroke-width: 1; } +.kcard-node { fill: rgba(244, 244, 236, 0.55); } +.kcard-node.is-unconfirmed { fill: none; stroke: rgba(244, 244, 236, 0.34); stroke-width: 1; } +.kcard-node.is-sleep { fill: #e2a33e; } +.kcard-stage { + fill: rgba(244, 244, 236, 0.3); + font-family: 'Share Tech Mono', monospace; + font-size: 7.5px; + letter-spacing: 1.1px; +} +.kcard-symbol { + fill: #f4f4ec; + font-family: 'Share Tech Mono', monospace; + font-size: 10px; + letter-spacing: 0.2px; +} +.kcard-symbol.is-sleep { fill: #e2a33e; } +.kcard-note { + fill: rgba(244, 244, 236, 0.3); + font-family: 'Share Tech Mono', monospace; + font-size: 7.5px; + letter-spacing: 0.9px; +} +.kcard-waiter { + fill: rgba(244, 244, 236, 0.82); + font-family: 'Share Tech Mono', monospace; + font-size: 9.5px; + letter-spacing: 0.2px; +} +.kcard-waiter-dim { + fill: rgba(244, 244, 236, 0.42); + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + letter-spacing: 0.2px; +} +.kcard-foot { + fill: rgba(244, 244, 236, 0.26); + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + letter-spacing: 1.2px; +} +/* Share of a counter: how the interrupts of one line fell across the CPUs, and + what came through an aggregate counter. The amber fill marks the largest. */ +.kcard-bar-bg { fill: rgba(244, 244, 236, 0.08); } +.kcard-bar-fill { fill: rgba(244, 244, 236, 0.4); } +.kcard-bar-fill.is-top { fill: #e2a33e; } +/* The interrupt route map: a pale track that changes lane at 45°, stations + with their own small block of text, and ticks marking the CPUs that took a + share of the line. A dashed leg means the step it leads to is reasoned from + the device class rather than measured. */ +.irq-track { + fill: none; + stroke: rgba(226, 226, 214, 0.5); + stroke-width: 1.1; + stroke-linejoin: round; + stroke-linecap: round; +} +.irq-track.is-reasoned { stroke: rgba(226, 163, 62, 0.55); stroke-dasharray: 3 3; } +.irq-station { + fill: rgba(9, 12, 16, 0.98); + stroke: rgba(236, 236, 226, 0.7); + stroke-width: 1.1; +} +.irq-station.is-reasoned { stroke: #e2a33e; } +.irq-tick { fill: rgba(244, 244, 236, 0.14); } +.irq-tick.is-lit { fill: #e2a33e; } +.irq-station-title { + fill: rgba(240, 240, 232, 0.92); + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + letter-spacing: 0.6px; +} +.irq-station-line { + fill: rgba(244, 244, 236, 0.42); + font-family: 'Share Tech Mono', monospace; + font-size: 7.5px; + letter-spacing: 0.7px; +} +/* 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. */ +.kcard-state { + fill: rgba(244, 244, 236, 0.4); + font-family: 'Share Tech Mono', monospace; + font-size: 9.5px; + letter-spacing: 0.2px; +} +.kcard-state.is-run { fill: #e2a33e; } +.kcard-state.is-block { fill: #d98a6a; } +.kcard-inferred { + fill: rgba(226, 163, 62, 0.75); + font-family: 'Share Tech Mono', monospace; + font-size: 7.5px; + letter-spacing: 0.9px; +} /* Bezier curve styles - matching connection lines */ .bezier-curve { diff --git a/static/js/irq-card.js b/static/js/irq-card.js new file mode 100644 index 0000000..587b74c --- /dev/null +++ b/static/js/irq-card.js @@ -0,0 +1,360 @@ +// The card a row of the INTERRUPTS panel opens. +// +// An interrupt line is a kernel element like any other, so it gets the same +// card as a syscall does: what device registered it, which chip delivers it, +// which CPU is allowed to take it and which one actually did, and what runs +// afterwards in softirq context. +// +// One stage of the chain is reasoned rather than measured — the kernel keeps no +// record of which softirq vector a given line raises, so it is concluded from +// the class of the device — and that stage is labelled as an inference instead +// of being dressed up as a reading. Everything else is off /sys and /proc. +// +// Rates come from the panel rather than from the request: the panel measures +// them across its polling interval, a far steadier window than one request +// could sample. +const IrqCard = (() => { + const W = 430; + const PAD = 14; + const CUT = 15; + const HEADER = 25; + const LINE = 14; + const CHAIN_STEP = 21; + const BAR_STEP = 16; + const FOOTER = 34; + const MAX_CPU_BARS = 8; + + let openIrq = null; + let topKeeper = null; + let requestSeq = 0; + + const SUBSYSTEM_TINT = { + net: "rgba(103, 190, 224, 0.92)", + drivers: "rgba(224, 175, 98, 0.95)", + sched: "rgba(167, 200, 120, 0.9)", + kernel: "rgba(176, 186, 198, 0.9)" + }; + + function grouped(value) { + const number = Number(value); + if (!Number.isFinite(number)) return "—"; + return number.toLocaleString("en-US").replace(/,/g, " "); + } + + function clip(text, max) { + const value = String(text || ""); + return value.length > max ? `${value.slice(0, max - 1)}…` : value; + } + + function close() { + openIrq = null; + requestSeq += 1; + svg.selectAll(".irq-card-scrim, .irq-card-layer").remove(); + if (topKeeper) topKeeper.stop(); + d3.select("body").on("keydown.irqcard", null); + window.dispatchEvent(new CustomEvent("irq-card-closed")); + } + + // ``context`` carries what the panel already measured: the rate of this + // line and the rates of the softirq vectors, both over the polling + // interval. The card shows those rather than sampling its own. + function open(row, anchor, context) { + const irq = String((row && row.irq) || ""); + if (!irq) return; + if (openIrq === irq) { + close(); + return; + } + close(); + openIrq = irq; + const seq = ++requestSeq; + + fetch(`/api/irq/${encodeURIComponent(irq)}`, { cache: "no-store" }) + .then((r) => r.json()) + .then((data) => { + if (seq !== requestSeq) return; + if (!data || data.found === false) { + openIrq = null; + return; + } + draw(data, row, anchor, context || {}); + }) + .catch(() => { + if (seq !== requestSeq) return; + openIrq = null; + }); + } + + function softirqRate(context, vector) { + const rows = (context && context.soft) || []; + const hit = rows.find((r) => String(r.name || "").toUpperCase() === String(vector || "").toUpperCase()); + return hit ? Number(hit.per_sec) : null; + } + + function draw(data, row, anchor, context) { + const svgNode = svg.node(); + const viewW = (svgNode && svgNode.clientWidth) || window.innerWidth; + const viewH = (svgNode && svgNode.clientHeight) || window.innerHeight; + const cw = Math.min(W, viewW - 24); + + const chain = Array.isArray(data.chain) ? data.chain : []; + const children = Array.isArray(data.children) ? data.children : []; + const perCpu = Array.isArray(data.per_cpu) ? data.per_cpu : []; + const manyCpus = perCpu.length > 1; + const cpuBars = manyCpus ? perCpu.slice(0, MAX_CPU_BARS) : []; + + const subsystem = String((row && row.subsystem) || "kernel").toLowerCase(); + const tint = SUBSYSTEM_TINT[subsystem] || SUBSYSTEM_TINT.kernel; + + // Height follows the content: an aggregate counter has no chain, a + // single-CPU box has no distribution to draw, and only the hypervisor + // callback has anything coming through it. + let h = HEADER + 12 + LINE; + if (data.summary || data.kind === "aggregate") h += LINE; + h += 8 + LINE; + if (chain.length) h += 16 + LINE + chain.length * CHAIN_STEP; + h += 16 + LINE; + h += manyCpus ? cpuBars.length * BAR_STEP : 0; + if (children.length) h += 16 + LINE + children.length * BAR_STEP + LINE + 8; + h += FOOTER; + + let x = 290; + 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)); + + ensureDossierDefs(); + svg.append("rect") + .attr("class", "irq-card-scrim") + .attr("x", 0).attr("y", 0).attr("width", viewW).attr("height", viewH) + .attr("fill", ensureFocusVeilGradient()) + .style("opacity", 0) + .style("cursor", "pointer") + .on("click", () => close()) + .transition().duration(200).style("opacity", 1); + + const layer = svg.append("g").attr("class", "irq-card-layer"); + if (!topKeeper) { + topKeeper = createOverlayTopKeeper("irq-card-scrim", ["irq-card-layer"], () => !!openIrq); + } + topKeeper.start(); + + if (anchor && Number.isFinite(anchor.x) && Number.isFinite(anchor.y)) { + const connY = Math.max(y + 12, Math.min(y + h - 12, anchor.y)); + layer.append("circle") + .attr("class", "kcard-anchor") + .attr("cx", anchor.x).attr("cy", anchor.y).attr("r", 3); + layer.append("line") + .attr("class", "kcard-conn") + .attr("x1", anchor.x).attr("y1", anchor.y) + .attr("x2", anchor.x).attr("y2", anchor.y) + .transition().duration(220).ease(d3.easeCubicOut) + .attr("x2", x).attr("y2", connY); + } + + const panel = layer.append("g") + .attr("transform", `translate(${x}, ${y})`) + .on("click", (event) => event.stopPropagation()); + + panel.append("path") + .attr("class", "kcard-frame") + .attr("d", dossierCardPath(0, 0, cw, h, CUT)) + .attr("filter", "url(#dossier-drop)") + .attr("transform", `translate(0, ${h / 2}) scale(1, 0.02)`) + .transition().delay(120).duration(200).ease(d3.easeCubicOut) + .attr("transform", "translate(0,0) scale(1,1)"); + + const body = panel.append("g").attr("class", "irq-card-body").style("opacity", 0); + body.transition().delay(250).duration(180).style("opacity", 1); + + const text = (cls, tx, ty, value, anchorEnd) => body.append("text") + .attr("class", cls) + .attr("x", tx).attr("y", ty) + .attr("text-anchor", anchorEnd ? "end" : "start") + .text(value); + + body.append("path") + .attr("class", "kcard-strip") + .attr("d", `M0,0 H${cw - CUT} L${cw},${CUT} V${HEADER} H0 Z`); + body.append("circle") + .attr("class", "kcard-glyph-ring") + .attr("cx", PAD).attr("cy", HEADER / 2).attr("r", 4.2); + body.append("circle") + .attr("class", "kcard-glyph-dot") + .attr("cx", PAD).attr("cy", HEADER / 2).attr("r", 1.6); + + // A lettered row is one of the kernel's own counters, not a wire, so it + // is not announced as an IRQ number. + 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-meta", cw - 13, HEADER / 2 + 3.5, subsystem.toUpperCase(), true).style("fill", tint); + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", HEADER).attr("x2", cw).attr("y2", HEADER); + + let cy = HEADER + 12 + 10; + + const aggregate = data.kind === "aggregate"; + const identity = aggregate + ? String(data.summary || "kernel counter") + : [data.chip, data.name, data.type].filter(Boolean).join(" · "); + text("kcard-line", PAD, cy, clip(identity, 52)); + cy += LINE; + + const second = aggregate ? "the kernel counts this itself; no device is behind it" : data.summary; + if (second) { + text(aggregate ? "kcard-faint" : "kcard-summary", PAD, cy, clip(second, 56)); + cy += LINE; + } + + cy += 8; + const rate = row && Number.isFinite(Number(row.per_sec)) ? `${Number(row.per_sec).toFixed(1)}/s now` : null; + text("kcard-signature", PAD, cy, + [`${grouped(data.total)} since boot`, rate].filter(Boolean).join(" · ")); + cy += LINE; + + if (chain.length) { + cy += 16; + text("kcard-section", PAD, cy, "PATH INTO THE KERNEL"); + cy += LINE; + + const railX = PAD + 62; + body.append("line") + .attr("class", "kcard-rail") + .attr("x1", railX).attr("y1", cy - 2) + .attr("x2", railX).attr("y2", cy - 2 + (chain.length - 1) * CHAIN_STEP); + + chain.forEach((step, i) => { + const sy = cy - 2 + i * CHAIN_STEP; + const raises = step.stage === "raises"; + body.append("circle") + .attr("class", raises + ? "kcard-node is-sleep" + : (step.confirmed ? "kcard-node" : "kcard-node is-unconfirmed")) + .attr("cx", railX).attr("cy", sy).attr("r", raises ? 3.4 : 2.4); + text("kcard-stage", PAD, sy + 3, String(step.stage || "").toUpperCase()); + + // The vector carries the rate the panel measured for it, so the + // reasoned stage is at least anchored to a real number. + let symbol = String(step.symbol || ""); + if (raises) { + const vectorRate = softirqRate(context, step.symbol); + if (vectorRate !== null && Number.isFinite(vectorRate)) { + symbol = `${symbol} ${vectorRate.toFixed(1)}/s`; + } + } + body.append("text") + .attr("class", raises ? "kcard-symbol is-sleep" : "kcard-symbol") + .attr("x", railX + 12).attr("y", sy + 3.5) + .style("opacity", step.confirmed ? 1 : 0.45) + .text(clip(symbol, 32)); + + if (step.note) { + text(step.inferred ? "kcard-inferred" : "kcard-note", + cw - PAD, sy + 3.5, String(step.note).toUpperCase(), true); + } + }); + cy += chain.length * CHAIN_STEP; + } + + cy += 16; + text("kcard-section", PAD, cy, "WHICH CPU TAKES IT"); + cy += LINE; + + const affinity = data.affinity || {}; + const affinityNote = [ + affinity.allowed ? `ALLOWED ${affinity.allowed}` : null, + data.cpus_online === 1 ? "ONLY CPU ONLINE" : null + ].filter(Boolean).join(" · "); + + if (!manyCpus) { + // One core, so the distribution is a foregone conclusion. Saying it + // in a sentence beats drawing a bar that can only ever be full. + text("kcard-symbol", PAD, cy, `CPU${(data.per_cpu && data.per_cpu.length ? 0 : 0)}`); + text("kcard-line", PAD + 48, cy, "every one of them"); + if (affinityNote) text("kcard-note", cw - PAD, cy, affinityNote, true); + } else { + const total = perCpu.reduce((a, b) => a + Number(b || 0), 0) || 1; + const top = Math.max(...cpuBars.map((v) => Number(v || 0))); + const bx = PAD + 42; + const bw = cw - bx - 74; + cpuBars.forEach((value, index) => { + const by = cy - 7 + index * BAR_STEP; + const share = Number(value || 0) / total; + text("kcard-stage", PAD, by + 7, `CPU${index}`); + body.append("rect") + .attr("class", "kcard-bar-bg") + .attr("x", bx).attr("y", by).attr("width", bw).attr("height", 8).attr("rx", 1); + body.append("rect") + .attr("class", Number(value || 0) === top ? "kcard-bar-fill is-top" : "kcard-bar-fill") + .attr("x", bx).attr("y", by) + .attr("width", 0).attr("height", 8).attr("rx", 1) + .transition().delay(280 + index * 40).duration(260).ease(d3.easeCubicOut) + .attr("width", Math.max(1.5, bw * share)); + text("kcard-faint", cw - PAD, by + 7, `${(share * 100).toFixed(0)}%`, true); + }); + cy += cpuBars.length * BAR_STEP; + if (affinityNote) text("kcard-note", PAD, cy - 1, affinityNote); + } + + if (children.length) { + cy += manyCpus ? 16 : 30; + text("kcard-section", PAD, cy, "WHAT CAME THROUGH IT"); + cy += LINE; + const childTotal = Number(data.children_total) + || children.reduce((a, c) => a + Number(c.total || 0), 0) || 1; + const top = Math.max(...children.map((c) => Number(c.total || 0))); + const bx = PAD + 122; + // The share and the count share the right margin, and the count of + // a busy channel runs to nine digits, so it gets the room for them. + const bw = cw - bx - 120; + children.forEach((child, index) => { + const by = cy - 7 + index * BAR_STEP; + const share = Number(child.total || 0) / childTotal; + text("kcard-symbol", PAD, by + 7, clip(`IRQ ${child.irq} ${child.device || ""}`, 18)); + body.append("rect") + .attr("class", "kcard-bar-bg") + .attr("x", bx).attr("y", by).attr("width", bw).attr("height", 8).attr("rx", 1); + body.append("rect") + .attr("class", Number(child.total || 0) === top ? "kcard-bar-fill is-top" : "kcard-bar-fill") + .attr("x", bx).attr("y", by) + .attr("width", 0).attr("height", 8).attr("rx", 1) + .transition().delay(280 + index * 40).duration(260).ease(d3.easeCubicOut) + .attr("width", Math.max(1.5, bw * share)); + text("kcard-faint", cw - PAD, by + 7, + `${(share * 100).toFixed(1)}% ${grouped(child.total)}`, true); + }); + cy += children.length * BAR_STEP; + + // The counter and the channels are read a moment apart, so the two + // totals never match to the digit. Stating the coverage says what + // the arithmetic is for without inviting a diff of two numbers. + const covered = Math.round((childTotal / (Number(data.total) || childTotal)) * 100); + const hidden = Number(data.children_hidden || 0); + text("kcard-note", PAD, cy + 2, + `THESE ACCOUNT FOR ${covered}% OF THE COUNTER` + + (hidden ? ` · ${hidden} MORE CHANNEL${hidden === 1 ? "" : "S"}` : "")); + } + + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", h - FOOTER + 8).attr("x2", cw).attr("y2", h - FOOTER + 8); + text("kcard-foot", PAD, h - 10, "ESC OR CLICK OUTSIDE TO CLOSE"); + 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(); + }); + } + + return { + open, + close, + isOpen: () => !!openIrq, + openedIrq: () => openIrq + }; +})(); + +window.IrqCard = IrqCard; diff --git a/static/js/irq-ui.js b/static/js/irq-ui.js index 124093f..0e4f6bb 100644 --- a/static/js/irq-ui.js +++ b/static/js/irq-ui.js @@ -1,671 +1,432 @@ -// IRQ UI module extracted from main.js +// The INTERRUPTS panel in the bottom-left corner, and the route map that +// unfolds beside it on hover. +// +// The panel keeps the plain style of the live panels; the detail belongs to the +// card a click opens. The map is deliberately thin: it shows the line, the +// softirq vector its bottom half lands in, and the kernel function that vector +// runs — and nothing else. A line whose device class says nothing about the +// vector gets a map with one station and an admission, which is the honest +// shape of that answer. (function initIrqUI(){ +const PANEL_X = 30; +const PANEL_W = 232; +const TITLE_H = 22; +const HARD_ROW = 15; +const SOFT_ROW = 14; +const MAX_HARD = 5; +const MAX_SOFT = 4; +const MONO = 'Share Tech Mono, monospace'; + +let hoveredIrq = null; + +function panelGeometry(hardCount, softCount) { + const softLines = Math.ceil(softCount / 2); + const height = TITLE_H + 8 + hardCount * HARD_ROW + 12 + softLines * SOFT_ROW + 10; + return { + x: PANEL_X, + y: Math.max(20, window.innerHeight - height - 44), + w: PANEL_W, + h: height, + softLines + }; +} + +function clip(text, max) { + const value = String(text || ''); + return value.length > max ? `${value.slice(0, max - 1)}~` : value; +} + function renderIrqStackPanel(executionData) { d3.selectAll('.irq-stack-group').remove(); d3.selectAll('.irq-route-overlay').remove(); if (isMobileLayout()) return; const irqStack = executionData && executionData.irq_stack ? executionData.irq_stack : {}; - const hardRows = Array.isArray(irqStack.hard) ? irqStack.hard : []; - const softRows = Array.isArray(irqStack.soft) ? irqStack.soft : []; + const hardRows = (Array.isArray(irqStack.hard) ? irqStack.hard : []).slice(0, MAX_HARD); + const softRows = (Array.isArray(irqStack.soft) ? irqStack.soft : []).slice(0, MAX_SOFT); const summary = irqStack.summary || {}; + if (!hardRows.length && !softRows.length) return; - const svg = d3.select('svg'); - // Reuse former "CGROUP PROFILE" slot (left-bottom corner). - const panelX = 30; - const panelY = Math.max(20, window.innerHeight - 230); - const panelW = 230; - const rowH = 18; - const maxHard = 4; - const maxSoft = 2; - const shownHard = hardRows.slice(0, maxHard); - const shownSoft = softRows.slice(0, maxSoft); - const panelH = 24 + (shownHard.length + shownSoft.length + 2) * rowH + 16; - - const group = svg.append('g') - .attr('class', 'irq-stack-group'); + const svgRoot = d3.select('svg'); + const box = panelGeometry(hardRows.length, softRows.length); + + const group = svgRoot.append('g').attr('class', 'irq-stack-group'); group.append('rect') - .attr('x', panelX) - .attr('y', panelY - 6) - .attr('width', panelW) - .attr('height', panelH) + .attr('x', box.x) + .attr('y', box.y) + .attr('width', box.w) + .attr('height', box.h) .attr('rx', 8) .style('fill', '#333') .style('stroke', '#555') .style('stroke-width', '1px'); group.append('text') - .attr('x', panelX + 10) - .attr('y', panelY + 8) - .style('font-family', 'Share Tech Mono, monospace') + .attr('x', box.x + 12) + .attr('y', box.y + 15) + .style('font-family', MONO) .style('font-size', '10px') .style('letter-spacing', '0.5px') .style('fill', '#c8ccd4') - .text('IRQ STACK (HARD + SOFT)'); - - let y = panelY + 24; - shownHard.forEach((row) => { - const irqName = String(row.irq || '?'); - const labelRaw = String(row.label || irqName); - const label = labelRaw.length > 14 ? `${labelRaw.slice(0, 13)}~` : labelRaw; - const perSec = Number(row.per_sec || 0).toFixed(1); - const cpu = row.top_cpu === null || row.top_cpu === undefined ? '-' : row.top_cpu; + .text('INTERRUPTS'); + + const hardTotal = Number(summary.hard_total_per_sec || 0); + group.append('text') + .attr('x', box.x + box.w - 12) + .attr('y', box.y + 15) + .attr('text-anchor', 'end') + .style('font-family', MONO) + .style('font-size', '9px') + .style('fill', '#8b929c') + .text(`${hardTotal.toFixed(0)}/s`); + + let y = box.y + TITLE_H + 16; + hardRows.forEach((row) => { + const rowY = y; const rowGroup = group.append('g') .style('cursor', 'pointer') .on('mouseenter', () => { - window.__irqRouteMapHover = false; - drawIrqRouteOverlay(row, panelX + 10, y - 4); + hoveredIrq = String(row.irq); + highlight.style('opacity', 1); + drawIrqRouteOverlay(row, softRows, box, rowY); }) .on('mouseleave', () => { - // Give enough time to move cursor from IRQ row to the route map. + hoveredIrq = null; + highlight.style('opacity', 0); + // Long enough to reach the map, which sits right beside the row. setTimeout(() => { - if (!window.__irqRouteMapHover) { + if (!window.__irqRouteMapHover && hoveredIrq === null) { d3.selectAll('.irq-route-overlay').remove(); } - }, 1200); + }, 400); + }) + .on('click', (event) => { + event.stopPropagation(); + d3.selectAll('.irq-route-overlay').remove(); + if (window.IrqCard) { + window.IrqCard.open( + row, + { x: box.x + box.w, y: rowY - 4 }, + { soft: softRows } + ); + } }); - rowGroup.append('rect') - .attr('x', panelX + 6) - .attr('y', y - 11) - .attr('width', panelW - 14) - .attr('height', 13) + const highlight = rowGroup.append('rect') + .attr('x', box.x + 6) + .attr('y', rowY - 11) + .attr('width', box.w - 12) + .attr('height', 14) .attr('rx', 3) - .attr('fill', 'rgba(70,70,70,0.22)'); + .attr('fill', 'rgba(200, 204, 212, 0.10)') + .style('opacity', 0); + + rowGroup.append('rect') + .attr('x', box.x + 6) + .attr('y', rowY - 11) + .attr('width', box.w - 12) + .attr('height', 14) + .attr('fill', 'transparent'); + + rowGroup.append('text') + .attr('x', box.x + 12) + .attr('y', rowY) + .style('font-family', MONO) + .style('font-size', '9px') + .style('fill', '#d3d7de') + .text(clip(row.irq, 4)); rowGroup.append('text') - .attr('x', panelX + 10) - .attr('y', y) - .style('font-family', 'Share Tech Mono, monospace') + .attr('x', box.x + 52) + .attr('y', rowY) + .style('font-family', MONO) + .style('font-size', '9px') + .style('fill', '#a9b0ba') + .text(clip(row.device || row.label, 20)); + + rowGroup.append('text') + .attr('x', box.x + box.w - 12) + .attr('y', rowY) + .attr('text-anchor', 'end') + .style('font-family', MONO) .style('font-size', '9px') .style('fill', '#c8ccd4') - .text(`IRQ${irqName} ${label} C${cpu} ${perSec}/s`); - y += rowH; + .text(`${Number(row.per_sec || 0).toFixed(1)}/s`); + + y += HARD_ROW; }); group.append('line') - .attr('x1', panelX + 8) - .attr('x2', panelX + panelW - 8) - .attr('y1', y - 8) - .attr('y2', y - 8) - .attr('stroke', 'rgba(120,120,120,0.45)') + .attr('x1', box.x + 12) + .attr('x2', box.x + box.w - 12) + .attr('y1', y - 7) + .attr('y2', y - 7) + .attr('stroke', 'rgba(120, 120, 120, 0.45)') .attr('stroke-width', 0.8); - shownSoft.forEach((row) => { - const name = String(row.name || 'SOFT').toUpperCase(); - const perSec = Number(row.per_sec || 0).toFixed(1); + y += 6; + softRows.forEach((row, index) => { + const col = index % 2; + const line = Math.floor(index / 2); + const sx = box.x + 12 + col * 108; + const sy = y + line * SOFT_ROW; group.append('text') - .attr('x', panelX + 10) - .attr('y', y) - .style('font-family', 'Share Tech Mono, monospace') - .style('font-size', '9px') + .attr('x', sx) + .attr('y', sy) + .style('font-family', MONO) + .style('font-size', '8.5px') + .style('letter-spacing', '0.4px') + .style('fill', '#8b929c') + .text(clip(row.name, 8)); + group.append('text') + .attr('x', sx + 96) + .attr('y', sy) + .attr('text-anchor', 'end') + .style('font-family', MONO) + .style('font-size', '8.5px') .style('fill', '#b6c7d8') - .text(`S:${name} ${perSec}/s`); - y += rowH; + .text(`${Number(row.per_sec || 0).toFixed(1)}/s`); }); - - const hardRate = Number(summary.hard_total_per_sec || 0).toFixed(1); - const softRate = Number(summary.soft_total_per_sec || 0).toFixed(1); - const netRate = Number(summary.net_softirq_per_sec || 0).toFixed(1); - group.append('text') - .attr('x', panelX + 10) - .attr('y', panelY + panelH - 10) - .style('font-family', 'Share Tech Mono, monospace') - .style('font-size', '9px') - .style('fill', '#9ea9b6') - .text(`H:${hardRate}/s S:${softRate}/s NET:${netRate}/s`); -} - -function getIrqRouteHint(row) { - const label = String(row && row.label ? row.label : '').toLowerCase(); - const subsystem = String(row && row.subsystem ? row.subsystem : '').toLowerCase(); - if (label.includes('eth') || label.includes('net') || label.includes('wifi') || subsystem.includes('net')) { - return { - profile: 'NET', - soft: 'NET_RX', - kernel: 'network stack', - process: 'socket activity' - }; - } - if (label.includes('nvme') || label.includes('ahci') || label.includes('scsi') || label.includes('blk')) { - return { - profile: 'BLOCK', - soft: 'BLOCK', - kernel: 'block layer', - process: 'read/write wakeup' - }; - } - if (label.includes('timer') || label.includes('sched') || subsystem.includes('timer') || subsystem.includes('sched')) { - return { - profile: 'TIMER', - soft: 'TIMER', - kernel: 'scheduler/timer', - process: 'task wakeup' - }; - } - return { - profile: 'GENERIC', - soft: 'IRQ_THREAD', - kernel: 'driver/core', - process: 'syscall/io path' - }; } -function normalizeProcessLabel(name) { - if (!name) return ''; - const raw = String(name).trim(); - if (!raw) return ''; - const base = raw.split(':')[0].trim(); - return base || raw; -} +// The identity of a line does not change while the machine is up, and the +// counters move slowly, so a hover does not have to ask twice. The short life +// keeps the CPU shares from freezing at whatever they were an hour ago. +const DETAIL_TTL_MS = 10000; +const detailCache = new Map(); -function inferProcessFromConnections(profile) { - const manager = window.connectionsManager; - if (!manager || typeof manager.getCurrentConnections !== 'function') return ''; - const connections = manager.getCurrentConnections() || []; - const preferredPortsByProfile = { - NET: { '80': 'nginx', '443': 'nginx', '8080': 'nginx', '3000': 'node', '8000': 'python', '9000': 'php-fpm' }, - BLOCK: { '5432': 'postgres', '3306': 'mysql', '27017': 'mongod', '6379': 'redis' }, - TIMER: { '123': 'chronyd' }, - GENERIC: {} - }; - const map = preferredPortsByProfile[profile] || preferredPortsByProfile.GENERIC; - - for (let i = 0; i < connections.length; i += 1) { - const local = String(connections[i].local || ''); - const idx = local.lastIndexOf(':'); - if (idx === -1) continue; - const port = local.slice(idx + 1); - if (map[port]) return map[port]; +function irqDetail(irq) { + const cached = detailCache.get(irq); + if (cached && (Date.now() - cached.at) < DETAIL_TTL_MS) { + return Promise.resolve(cached.data); } - return ''; + return fetch(`/api/irq/${encodeURIComponent(irq)}`, { cache: 'no-store' }) + .then((r) => r.json()) + .then((data) => { + detailCache.set(irq, { at: Date.now(), data }); + return data; + }); } -function getIrqWakeTarget(hint) { - const fromHighlight = normalizeProcessLabel( - window.__highlightedProcessName || - (window.__highlightedProcess && window.__highlightedProcess.name) - ); - const fromConnections = inferProcessFromConnections(hint.profile); - - const defaults = { - NET: 'nginx', - BLOCK: 'postgres', - TIMER: 'systemd', - GENERIC: 'userspace' - }; - - if (fromHighlight) return fromHighlight; - if (fromConnections) return fromConnections; - return defaults[hint.profile] || defaults.GENERIC; -} +// The stations of the route, in the order the interrupt visits them. Each one +// is something the machine reports: the wire, the CPU that answered, the +// deferred vector and the function that runs it. The vector is the single +// reasoned step, and it arrives on a dashed segment so the map says as much +// without a caption. +function routeStations(row, detail, softRows) { + const vector = row.vector ? String(row.vector).toUpperCase() : null; + const soft = vector + ? (softRows || []).find((r) => String(r.name || '').toUpperCase() === vector) + : null; + const perCpu = (detail && Array.isArray(detail.per_cpu)) ? detail.per_cpu : []; + const total = perCpu.reduce((a, b) => a + Number(b || 0), 0); + const topCpu = perCpu.length + ? perCpu.indexOf(Math.max(...perCpu)) + : (Number.isFinite(Number(row.top_cpu)) ? Number(row.top_cpu) : null); + + const stations = []; + + stations.push({ + title: detail && detail.kind === 'aggregate' ? String(row.irq) : `IRQ ${row.irq}`, + lines: [ + clip(row.device || row.label, 22), + detail && detail.chip + ? clip([detail.chip, detail.type].filter(Boolean).join(' · '), 22) + : null, + `${Number(row.per_sec || 0).toFixed(1)}/s` + ].filter(Boolean) + }); -function getIrqWakeTargetMeta(wakeTarget) { - const highlighted = window.__highlightedProcess || null; - if (!highlighted) return null; - const highlightedName = normalizeProcessLabel(highlighted.name || '').toLowerCase(); - const targetName = normalizeProcessLabel(wakeTarget || '').toLowerCase(); - if (targetName && highlightedName && targetName !== highlightedName) { - return null; + if (topCpu !== null) { + const share = total ? (Number(perCpu[topCpu] || 0) / total) : 1; + const allowed = detail && detail.affinity ? detail.affinity.allowed : null; + stations.push({ + title: `CPU${topCpu}`, + lines: [ + total ? `${(share * 100).toFixed(0)}% of this line` : 'answers the line', + allowed ? `allowed ${allowed}` : null + ].filter(Boolean), + // One tick per online CPU, lit for the ones that took a share of + // this line. On a single-CPU box that is one lit tick, honestly. + ticks: perCpu.length ? perCpu.map((v) => Number(v || 0) / (total || 1)) : null + }); } - return highlighted; -} - -function getIrqStepContext(profile, stepLabel, hint, row, wakeTarget, rate) { - const label = String(stepLabel || '').toLowerCase(); - const cpu = row && row.top_cpu !== undefined && row.top_cpu !== null ? `CPU${row.top_cpu}` : 'CPU-'; - const latencyHint = rate > 180 ? 'latency sensitive' : rate > 90 ? 'active path' : 'normal load'; - if (profile === 'NET') { - if (label.includes('tcp')) { - return { title: 'NET/TCP', line1: `softirq: ${hint.soft}`, line2: 'skb parse + protocol dispatch', line3: `${cpu} | ${latencyHint}` }; - } - if (label.includes('wake_up')) { - return { title: 'WAKE QUEUE', line1: 'socket waitqueue signal', line2: 'wake_up_interruptible()', line3: `${cpu} | ${latencyHint}` }; - } - if (label.includes('epoll')) { - return { title: 'EPOLL', line1: 'ep_poll_callback', line2: 'fd ready event fanout', line3: `${cpu} | ${latencyHint}` }; + if (vector) { + stations.push({ + title: vector, + lines: [ + soft ? `${Number(soft.per_sec || 0).toFixed(1)}/s measured` : 'softirq', + 'by driver class' + ], + dashedBefore: true, + amber: true + }); + if (soft && soft.symbol) { + stations.push({ + title: String(soft.symbol), + lines: ['runs the deferred half', 'in kallsyms'] + }); } - return { title: 'NET EFFECT', line1: `${wakeTarget} runnable`, line2: 'userspace event loop resume', line3: `${cpu} | ${latencyHint}` }; } - if (profile === 'BLOCK') { - if (label.includes('bio')) { - return { title: 'BLOCK COMPLETE', line1: 'bio completion callback', line2: 'blk-mq request done', line3: `${cpu} | io completion` }; - } - if (label.includes('io_uring')) { - return { title: 'IO_URING CQ', line1: 'completion queue publish', line2: 'submitter wake condition', line3: `${cpu} | ${latencyHint}` }; - } - return { title: 'PROCESS WAKE', line1: `${wakeTarget} unblocked`, line2: 'read/write request finished', line3: `${cpu} | queue drained` }; + // A driver that asked for a threaded handler is served by that kthread, so + // the route ends there rather than in a softirq. Most lines have no thread + // and the route ends at the function above. + const thread = detail && detail.thread; + if (thread) { + stations.push({ + title: clip(thread.comm, 20), + lines: [`pid ${thread.pid}`, 'threaded handler'] + }); } - if (profile === 'TIMER') { - if (label.includes('hrtimer')) { - return { title: 'HRTIMER', line1: 'high-res timer interrupt', line2: 'tick handler dispatch', line3: `${cpu} | periodic pulse` }; - } - if (label.includes('scheduler')) { - return { title: 'SCHED', line1: 'scheduler tick accounting', line2: 'timeslice / vruntime update', line3: `${cpu} | ${latencyHint}` }; - } - return { title: 'RUNQUEUE', line1: `${wakeTarget} enqueue`, line2: 'task marked runnable', line3: `${cpu} | wake candidate` }; - } + return { stations, vector }; +} - return { title: 'IRQ STEP', line1: stepLabel, line2: `softirq: ${hint.soft}`, line3: `${cpu} | ${latencyHint}` }; +// The map itself. The line runs left to right and changes track with 45° +// jogs, each station carrying its own small block of text — the shape the +// panel has always used, now with only stations the machine can vouch for. +function drawIrqRouteOverlay(row, softRows, box, rowY) { + const token = `${row.irq}:${Date.now()}`; + window.__irqRouteToken = token; + irqDetail(row.irq) + .then((detail) => { + if (window.__irqRouteToken !== token) return; + paintRouteOverlay(row, softRows, box, rowY, detail || {}); + }) + .catch(() => { + if (window.__irqRouteToken !== token) return; + paintRouteOverlay(row, softRows, box, rowY, {}); + }); } -function drawIrqRouteOverlay(row, startX, startY) { +function paintRouteOverlay(row, softRows, box, rowY, detail) { d3.selectAll('.irq-route-overlay').remove(); - const svg = d3.select('svg'); - const overlay = svg.append('g').attr('class', 'irq-route-overlay'); - const width = window.innerWidth; - const height = window.innerHeight; - const hint = getIrqRouteHint(row); - const irqLabel = String(row && row.irq ? row.irq : '?'); - const routeLabel = String(row && row.label ? row.label : '').trim(); - const wakeTarget = getIrqWakeTarget(hint); - const wakeMeta = getIrqWakeTargetMeta(wakeTarget); - const processStageLabel = hint.profile === 'GENERIC' ? hint.process : `${wakeTarget} wake`; - const rate = Number(row && row.per_sec ? row.per_sec : 0); - const intensity = Math.max(0.35, Math.min(1, rate / 220)); - - const profileColors = { - NET: 'rgba(103, 190, 224, 0.95)', - BLOCK: 'rgba(224, 175, 98, 0.95)', - TIMER: 'rgba(167, 200, 120, 0.95)', - GENERIC: 'rgba(186, 194, 204, 0.92)' - }; - const profileColor = profileColors[hint.profile] || profileColors.GENERIC; - - const mapX = Math.max(22, Math.min(width * 0.16, startX + 36)); - const mapH = 128; - const mapY = Math.max(12, height - (mapH + 46)); - const mapW = Math.min(760, width - mapX - 20); - const contextCardW = 182; - const contextCardH = 56; - const routeRightPadding = 18; - const routeEndX = mapX + mapW - routeRightPadding; - - overlay.append('rect') - .attr('x', mapX) - .attr('y', mapY) - .attr('width', mapW) - .attr('height', mapH) - .attr('rx', 8) - .attr('fill', 'rgba(11, 14, 18, 0.9)') - .attr('stroke', 'rgba(82, 92, 108, 0.42)') - .attr('stroke-width', 0.9); - - const detailLayer = overlay.append('g') - .attr('class', 'irq-route-detail') - .style('opacity', 0.72); - - // Hover target for fading detailed layer to full opacity. - overlay.append('rect') - .attr('x', mapX) - .attr('y', mapY) - .attr('width', mapW) - .attr('height', mapH) - .attr('rx', 8) - .attr('fill', 'transparent') + const svgRoot = d3.select('svg'); + const overlay = svgRoot.append('g').attr('class', 'irq-route-overlay'); + + const { stations, vector } = routeStations(row, detail, softRows); + + const ROW_A = 64; + const ROW_B = 96; + const STEP = 150; + const LEAD = 40; + const TAIL = 118; + + // The frame ends under the deepest label rather than at a fixed height: + // a two-station route would otherwise sit in a half-empty box. + const deepest = stations.reduce((most, station, index) => ( + index % 2 === 0 ? most : Math.max(most, (station.lines || []).length || 1) + ), 0); + const MAP_H = ROW_B + 24 + Math.max(1, deepest) * 8 + 10; + + const mapX = box.x + box.w + 26; + const wanted = LEAD + (stations.length - 1) * STEP + TAIL; + const mapW = Math.min(wanted, window.innerWidth - mapX - 24); + if (mapW < 260) return; + const mapY = box.y + box.h - MAP_H; + const step = stations.length > 1 + ? (mapW - LEAD - TAIL) / (stations.length - 1) + : 0; + + overlay.append('path') + .attr('class', 'kcard-frame') + .attr('d', dossierCardPath(mapX, mapY, mapW, MAP_H, 13)) .style('pointer-events', 'all') - .on('mouseenter', () => { - window.__irqRouteMapHover = true; - detailLayer.transition().duration(120).style('opacity', 1); - }) + .on('mouseenter', () => { window.__irqRouteMapHover = true; }) .on('mouseleave', () => { window.__irqRouteMapHover = false; d3.selectAll('.irq-route-overlay').remove(); }); - const flowYOffset = 24; - const p0 = { x: mapX + 26, y: mapY + 38 + flowYOffset }; - const p1 = { x: mapX + Math.min(130, mapW * 0.22), y: mapY + 38 + flowYOffset }; - const p2 = { x: p1.x, y: mapY + 74 + flowYOffset }; - const p3 = { x: mapX + Math.min(300, mapW * 0.5), y: mapY + 74 + flowYOffset }; - const p4 = { x: p3.x, y: mapY + 48 + flowYOffset }; - const p5 = { x: Math.min(routeEndX - contextCardW - 36, mapX + Math.min(500, mapW * 0.72)), y: mapY + 48 + flowYOffset }; - const p6 = { x: p5.x, y: mapY + 74 + flowYOffset }; - const p7 = { x: routeEndX, y: mapY + 74 + flowYOffset }; - overlay.append('path') - .attr('d', `M ${startX} ${startY} Q ${mapX - 24} ${mapY - 8} ${p0.x} ${p0.y}`) + .attr('d', `M ${box.x + box.w} ${rowY - 4} L ${mapX} ${mapY + MAP_H / 2}`) .attr('fill', 'none') - .attr('stroke', 'rgba(118, 136, 155, 0.5)') + .attr('stroke', 'rgba(118, 136, 155, 0.42)') .attr('stroke-width', 0.9) .attr('stroke-dasharray', '2 3'); - // Compact route line (always visible) + detailed metro line below. - overlay.append('rect') - .attr('x', mapX + 10) - .attr('y', mapY + 6) - .attr('width', mapW - 20) - .attr('height', 16) - .attr('rx', 4) - .attr('fill', 'rgba(16, 20, 25, 0.9)') - .attr('stroke', 'rgba(74, 88, 106, 0.45)') - .attr('stroke-width', 0.6); + const label = (cls, x, y, value) => overlay.append('text') + .attr('class', cls) + .attr('x', x).attr('y', y) + .text(value); + label('kcard-section', mapX + 14, mapY + 18, 'ROUTE INTO THE KERNEL'); overlay.append('text') - .attr('x', mapX + 14) - .attr('y', mapY + 17) - .style('font-family', 'Share Tech Mono, monospace') - .style('font-size', '8px') - .style('letter-spacing', '0.35px') - .style('fill', 'rgba(198, 215, 228, 0.92)') - .text(`IRQ ${irqLabel} -> ${hint.soft} -> ${hint.kernel} -> ${processStageLabel}`); - - const metroPath = `M ${p0.x} ${p0.y} - L ${p1.x} ${p1.y} - L ${p2.x} ${p2.y} - L ${p3.x} ${p3.y} - L ${p4.x} ${p4.y} - L ${p5.x} ${p5.y} - L ${p6.x} ${p6.y} - L ${p7.x} ${p7.y}`; - - detailLayer.append('path') - .attr('d', metroPath) - .attr('fill', 'none') - .attr('stroke', 'rgba(35, 40, 48, 0.95)') - .attr('stroke-width', 4.2 + intensity * 0.7) - .attr('stroke-linecap', 'round') - .attr('stroke-linejoin', 'round'); - - detailLayer.append('path') - .attr('d', metroPath) - .attr('fill', 'none') - .attr('stroke', profileColor) - .attr('stroke-width', 1.9 + intensity * 1.1) - .attr('stroke-linecap', 'round') - .attr('stroke-linejoin', 'round'); - - const drawBranch = (path, color, label, lx, ly) => { - detailLayer.append('path') - .attr('d', path) - .attr('fill', 'none') - .attr('stroke', 'rgba(35, 40, 48, 0.94)') - .attr('stroke-width', 3.2) - .attr('stroke-linecap', 'round') - .attr('stroke-linejoin', 'round'); - - detailLayer.append('path') - .attr('d', path) - .attr('fill', 'none') - .attr('stroke', color) - .attr('stroke-width', 1.5 + intensity * 0.9) - .attr('stroke-linecap', 'round') - .attr('stroke-linejoin', 'round'); - - detailLayer.append('text') - .attr('x', lx) - .attr('y', ly) - .style('font-family', 'Share Tech Mono, monospace') - .style('font-size', '7px') - .style('fill', 'rgba(182, 198, 212, 0.85)') - .text(label); - }; - - const connectorUpLen = 14; - const connectorHorizontalLen = contextCardW; - const connectorVerticalLen = contextCardH; - const connectorY = p7.y - connectorUpLen; - const connectorLeftX = p7.x - connectorHorizontalLen; - const contextCardX = connectorLeftX; - const contextCardY = connectorY - contextCardH; - const contextCard = overlay.append('g') - .attr('class', 'irq-inline-context'); - - contextCard.append('rect') - .attr('x', contextCardX) - .attr('y', contextCardY) - .attr('width', contextCardW) - .attr('height', contextCardH) - .attr('rx', 5) - .attr('fill', 'rgba(12, 16, 20, 0.94)') - .attr('stroke', 'rgba(116, 165, 192, 0.5)') - .attr('stroke-width', 0.8); - - // Reference-like connector: up -> left (tooltip width) -> up (tooltip height). - const tooltipConnectorPath = `M ${p7.x} ${p7.y} - L ${p7.x} ${connectorY} - L ${connectorLeftX} ${connectorY} - L ${connectorLeftX} ${connectorY - connectorVerticalLen}`; - - detailLayer.append('path') - .attr('d', tooltipConnectorPath) - .attr('fill', 'none') - .attr('stroke', 'rgba(35, 40, 48, 0.94)') - .attr('stroke-width', 4.2 + intensity * 0.7) - .attr('stroke-linecap', 'round') - .attr('stroke-linejoin', 'round'); - - detailLayer.append('path') - .attr('d', tooltipConnectorPath) - .attr('fill', 'none') - .attr('stroke', profileColor) - .attr('stroke-width', 1.9 + intensity * 1.1) - .attr('stroke-linecap', 'round') - .attr('stroke-linejoin', 'round'); - - const renderInlineContext = (ctx) => { - contextCard.selectAll('.irq-inline-context-text').remove(); - const title = ctx && ctx.title ? ctx.title : 'IRQ CONTEXT'; - const line1 = ctx && ctx.line1 ? ctx.line1 : ''; - const line2 = ctx && ctx.line2 ? ctx.line2 : ''; - const line3 = ctx && ctx.line3 ? ctx.line3 : ''; - - contextCard.append('text') - .attr('class', 'irq-inline-context-text') - .attr('x', contextCardX + 8) - .attr('y', contextCardY + 11) - .style('font-family', 'Share Tech Mono, monospace') - .style('font-size', '7px') - .style('fill', 'rgba(194, 214, 228, 0.93)') - .text(title); - - contextCard.append('text') - .attr('class', 'irq-inline-context-text') - .attr('x', contextCardX + 8) - .attr('y', contextCardY + 24) - .style('font-family', 'Share Tech Mono, monospace') - .style('font-size', '6.6px') - .style('fill', 'rgba(178, 195, 209, 0.86)') - .text(line1); - - contextCard.append('text') - .attr('class', 'irq-inline-context-text') - .attr('x', contextCardX + 8) - .attr('y', contextCardY + 35) - .style('font-family', 'Share Tech Mono, monospace') - .style('font-size', '6.6px') - .style('fill', 'rgba(178, 195, 209, 0.84)') - .text(line2); - - contextCard.append('text') - .attr('class', 'irq-inline-context-text') - .attr('x', contextCardX + 8) - .attr('y', contextCardY + 46) - .style('font-family', 'Share Tech Mono, monospace') - .style('font-size', '6.4px') - .style('fill', 'rgba(162, 184, 202, 0.8)') - .text(line3); - }; - - const showStepContextInCard = (label) => { - renderInlineContext(getIrqStepContext(hint.profile, label, hint, row, wakeTarget, rate)); - }; - - const showWakeContextInCard = () => { - const pid = wakeMeta && wakeMeta.pid !== undefined ? wakeMeta.pid : '-'; - const rss = wakeMeta && wakeMeta.memory_mb !== undefined && wakeMeta.memory_mb !== null - ? `${Number(wakeMeta.memory_mb).toFixed(1)} MB` - : '-'; - const fds = wakeMeta && wakeMeta.num_fds !== undefined && wakeMeta.num_fds !== null - ? String(wakeMeta.num_fds) - : '-'; - renderInlineContext({ - title: `TARGET: ${wakeTarget}`, - line1: `PID: ${pid} RSS: ${rss}`, - line2: `FDs: ${fds}`, - line3: `${hint.soft} -> ${hint.kernel}` - }); - }; + .attr('class', 'kcard-note') + .attr('x', mapX + mapW - 14).attr('y', mapY + 18) + .attr('text-anchor', 'end') + .text('CLICK THE ROW FOR THE CARD'); + + if (!vector && stations.length < 2) { + label('kcard-symbol', mapX + 14, mapY + 52, clip(row.device || row.label, 40)); + label('kcard-faint', mapX + 14, mapY + 70, 'NO SOFTIRQ VECTOR FOLLOWS FROM THIS DEVICE CLASS'); + return; + } - const drawFlowSequence = (x, y, labels, color, dx, dy) => { - const points = labels.map((_, i) => ({ - x: x + i * dx, - y: y + i * dy - })); - const pathData = points.map((pt, i) => `${i === 0 ? 'M' : 'L'} ${pt.x} ${pt.y}`).join(' '); - - detailLayer.append('path') - .attr('d', pathData) - .attr('fill', 'none') - .attr('stroke', 'rgba(35, 40, 48, 0.94)') - .attr('stroke-width', 3.4) - .attr('stroke-linecap', 'round') - .attr('stroke-linejoin', 'round'); - - detailLayer.append('path') - .attr('d', pathData) - .attr('fill', 'none') - .attr('stroke', color) - .attr('stroke-width', 1.55 + intensity * 0.95) - .attr('stroke-linecap', 'round') - .attr('stroke-linejoin', 'round'); - - points.forEach((pt, i) => { - detailLayer.append('circle') - .attr('cx', pt.x) - .attr('cy', pt.y) - .attr('r', 2.8) - .attr('fill', 'rgba(11, 14, 18, 0.96)') - .attr('stroke', color) - .attr('stroke-width', 1.1); - - detailLayer.append('text') - .attr('x', pt.x + 5) - .attr('y', pt.y - 4) - .style('font-family', 'Share Tech Mono, monospace') - .style('font-size', '6.6px') - .style('letter-spacing', '0.2px') - .style('fill', 'rgba(188, 204, 218, 0.86)') - .text(labels[i]); - - detailLayer.append('circle') - .attr('cx', pt.x) - .attr('cy', pt.y) - .attr('r', 8) - .attr('fill', 'transparent') - .style('pointer-events', 'all') - .on('mouseenter', () => { - window.__irqRouteMapHover = true; - detailLayer.transition().duration(90).style('opacity', 1); - showStepContextInCard(labels[i]); - }); - }); - }; + const at = (index) => ({ + x: mapX + LEAD + step * index, + y: mapY + (index % 2 === 0 ? ROW_A : ROW_B), + up: index % 2 === 0 + }); - if (hint.profile === 'NET') { - // Requested: IRQ -> NET_RX -> TCP -> wake_up_interruptible -> epoll -> nginx wake. - drawFlowSequence( - p2.x + 14, - p2.y + 14, - ['TCP', 'wake_up_interruptible', 'epoll_wait', `${wakeTarget} wake`], - 'rgba(103, 190, 224, 0.92)', - 66, - 0 - ); - const n1 = `M ${p4.x} ${p4.y} L ${p4.x + 52} ${p4.y - 16} L ${p4.x + 120} ${p4.y - 16}`; - drawBranch(n1, 'rgba(103, 190, 224, 0.9)', 'socket/epoll wake', p4.x + 58, p4.y - 22); - } else if (hint.profile === 'BLOCK') { - drawFlowSequence( - p3.x + 14, - p3.y + 16, - ['bio_complete', 'io_uring/cq', 'wake_up_process'], - 'rgba(224, 175, 98, 0.92)', - 66, - 0 - ); - const b1 = `M ${p3.x} ${p3.y} L ${p3.x + 50} ${p3.y + 16} L ${p3.x + 110} ${p3.y + 16}`; - const b2 = `M ${p6.x} ${p6.y} L ${p6.x + 44} ${p6.y - 18} L ${p6.x + 102} ${p6.y - 18}`; - drawBranch(b1, 'rgba(224, 175, 98, 0.9)', 'disk completion', p3.x + 56, p3.y + 28); - drawBranch(b2, 'rgba(224, 175, 98, 0.9)', 'page cache wakeup', p6.x + 50, p6.y - 24); - } else if (hint.profile === 'TIMER') { - drawFlowSequence( - p2.x + 18, - p2.y + 12, - ['hrtimer', 'scheduler tick', 'runqueue', 'task wake'], - 'rgba(167, 200, 120, 0.92)', - 58, - 0 - ); - const t1 = `M ${p2.x} ${p2.y} L ${p2.x + 54} ${p2.y - 20} L ${p2.x + 116} ${p2.y - 20}`; - const t2 = `M ${p5.x} ${p5.y} L ${p5.x + 36} ${p5.y + 20} L ${p5.x + 98} ${p5.y + 20}`; - drawBranch(t1, 'rgba(167, 200, 120, 0.9)', 'scheduler tick', p2.x + 60, p2.y - 26); - drawBranch(t2, 'rgba(167, 200, 120, 0.9)', 'runqueue wakeup', p5.x + 42, p5.y + 30); + // Each leg is its own path so the reasoned one can be dashed on its own. + for (let i = 0; i < stations.length - 1; i += 1) { + const from = at(i); + const to = at(i + 1); + const drop = Math.abs(to.y - from.y); + const turn = to.x - drop; + const d = drop + ? `M ${from.x} ${from.y} L ${turn} ${from.y} L ${to.x} ${to.y}` + : `M ${from.x} ${from.y} L ${to.x} ${to.y}`; + overlay.append('path') + .attr('class', stations[i + 1].dashedBefore ? 'irq-track is-reasoned' : 'irq-track') + .attr('d', d); } - const stations = [ - { x: p0.x, y: p0.y, title: `IRQ ${irqLabel}`, detail: routeLabel || 'interrupt line', up: true }, - { x: p2.x, y: p2.y, title: hint.soft, detail: 'softirq', up: false }, - { x: p4.x, y: p4.y, title: hint.kernel, detail: 'kernel path', up: true }, - { x: p7.x, y: p7.y, title: processStageLabel, detail: 'userspace effect', up: false } - ]; - - stations.forEach((station) => { - detailLayer.append('circle') - .attr('cx', station.x) - .attr('cy', station.y) - .attr('r', 4.3) - .attr('fill', 'rgba(10, 13, 17, 0.95)') - .attr('stroke', 'rgba(148, 214, 238, 0.96)') - .attr('stroke-width', 1.3); - - const textY = station.up ? station.y - 9 : station.y + 14; - detailLayer.append('text') - .attr('x', station.x) - .attr('y', textY) - .attr('text-anchor', 'middle') - .style('font-family', 'Share Tech Mono, monospace') - .style('font-size', '8px') - .style('fill', 'rgba(196, 215, 228, 0.95)') - .text(String(station.title).toUpperCase()); - }); + stations.forEach((station, index) => drawStation(overlay, at(index), station)); +} +function drawStation(overlay, at, station) { overlay.append('circle') - .attr('cx', p7.x) - .attr('cy', p7.y) - .attr('r', 9) - .attr('fill', 'transparent') - .style('pointer-events', 'all') - .on('mouseenter', () => { - window.__irqRouteMapHover = true; - detailLayer.transition().duration(90).style('opacity', 1); - showWakeContextInCard(); + .attr('class', station.amber ? 'irq-station is-reasoned' : 'irq-station') + .attr('cx', at.x).attr('cy', at.y).attr('r', 2.8); + + // Ruler ticks along the track, on the side the label does not occupy. + if (Array.isArray(station.ticks)) { + station.ticks.slice(0, 8).forEach((share, i) => { + overlay.append('rect') + .attr('class', share > 0.01 ? 'irq-tick is-lit' : 'irq-tick') + .attr('x', at.x + 10 + i * 5) + .attr('y', at.y + (at.up ? 5 : -9)) + .attr('width', 3) + .attr('height', 4); }); + } - // Show context immediately with the map (no focus transfer required). - showWakeContextInCard(); - + const lines = (station.lines || []).slice(0, 3); + const lx = at.x + 9; + // A block above the track has to be lifted by its own height, or its last + // line lands on the rail it belongs to. + const ty = at.up + ? at.y - 17 - Math.max(0, lines.length - 1) * 8 + : at.y + 15; overlay.append('text') - .attr('x', mapX + mapW - 186) - .attr('y', mapY + 17) - .style('font-family', 'Share Tech Mono, monospace') - .style('font-size', '7px') - .style('letter-spacing', '0.5px') - .style('fill', 'rgba(140, 155, 171, 0.85)') - .text(`IRQ ROUTE MAP [${hint.profile}] ${rate.toFixed(1)}/s`); + .attr('class', 'irq-station-title') + .attr('x', lx).attr('y', ty) + .text(clip(station.title, 20)); + lines.forEach((line, i) => { + overlay.append('text') + .attr('class', line === 'by driver class' ? 'kcard-inferred' : 'irq-station-line') + .attr('x', lx).attr('y', ty + 9 + i * 8) + .text(clip(String(line).toUpperCase(), 24)); + }); } window.IrqUI = { renderIrqStackPanel, - drawIrqRouteOverlay, - getIrqRouteHint, - normalizeProcessLabel, - inferProcessFromConnections, - getIrqWakeTarget, - getIrqWakeTargetMeta, - getIrqStepContext + drawIrqRouteOverlay }; })(); diff --git a/static/js/isolation-ui.js b/static/js/isolation-ui.js index 15c0ee4..95da9bb 100644 --- a/static/js/isolation-ui.js +++ b/static/js/isolation-ui.js @@ -81,6 +81,22 @@ const NS_KIND = { let expandedNsId = null; +// Cells on the outer edge of the ring would push the chip off screen, so it +// flips to the other side of the cursor instead of being clipped. +function placeNamespaceTooltip(tip, event) { + const node = tip.node(); + if (!node) return; + const gap = 14; + const margin = 10; + let left = event.pageX + gap; + if (left + node.offsetWidth > window.innerWidth - margin) { + left = event.pageX - gap - node.offsetWidth; + } + const top = Math.min(event.pageY - 10, window.innerHeight - node.offsetHeight - margin); + tip.style('left', `${Math.max(margin, left)}px`) + .style('top', `${Math.max(margin, top)}px`); +} + function drawNamespaceShell(centerX, centerY, namespaces) { const shellGroup = svg.selectAll('.tag-icon').empty() ? svg.append('g').attr('class', 'namespace-shell-layer') @@ -196,34 +212,32 @@ function drawNamespaceShell(centerX, centerY, namespaces) { .on('mouseenter', (event) => { setFocus(idx); d3.selectAll('.ns-tooltip').remove(); - d3.select('body') + const tip = d3.select('body') .append('div') .attr('class', 'tooltip ns-tooltip ns-hud-tooltip') - .style('left', `${event.pageX + 14}px`) - .style('top', `${event.pageY - 10}px`) .html(` -
-
-
NAMESPACE
+
+
+ + NAMESPACE + ${isolated ? 'ISOLATED' : 'SINGLE'} +
+
${nsName.toUpperCase()}
+
${meta.isolates || 'Resource isolation'}
+
WORLDS${ns.unique_count || 0}
+
DOMINANT${ns.dominant_count || 0} procs
+
INODE${ns.dominant_inode || 'n/a'}
+
VIA${kind}
+
+
ACTIVITY ${Math.round(activity * 100)}%CLICK TO UNFOLD ▸
-
${isolated ? 'ISOLATED' : 'SINGLE'}
-
${meta.isolates || 'Resource isolation'}
-
-
WORLDS${ns.unique_count || 0}
-
DOMINANT${ns.dominant_count || 0} procs
-
INODE${ns.dominant_inode || 'n/a'}
-
VIA${kind}
-
-
-
ACTIVITY ${Math.round(activity * 100)}%click to unfold ▸
`); + placeNamespaceTooltip(tip, event); }) .on('mousemove', (event) => { - d3.selectAll('.ns-tooltip') - .style('left', `${event.pageX + 14}px`) - .style('top', `${event.pageY - 10}px`); + placeNamespaceTooltip(d3.selectAll('.ns-tooltip'), event); }) .on('mouseleave', () => { restoreFocus(); @@ -242,8 +256,13 @@ function drawNamespaceShell(centerX, centerY, namespaces) { } } +// Живые слои сцены дорисовываются в svg постоянно, поэтому пока панель открыта, +// её держит тот же сторож z-порядка, что и досье процесса. +let nsTreeTopKeeper = null; + function collapseNamespaceTree(animated = true) { expandedNsId = null; + if (nsTreeTopKeeper) nsTreeTopKeeper.stop(); d3.select('body').on('keydown.nstree', null); const layer = svg.selectAll('.ns-tree-layer'); const scrim = svg.selectAll('.ns-tree-scrim'); @@ -262,11 +281,11 @@ function expandNamespaceTree(ns, meta, nsName, cx, cy, mid) { const W = (svgNode && svgNode.clientWidth) || window.innerWidth; const H = (svgNode && svgNode.clientHeight) || window.innerHeight; - // Light scrim calms the busy ring behind the readout. + // Same focus veil the process dossier uses, so overlays read as one system. svg.append('rect') .attr('class', 'ns-tree-scrim') .attr('x', 0).attr('y', 0).attr('width', W).attr('height', H) - .attr('fill', 'rgba(230, 231, 233, 0.62)') + .attr('fill', ensureFocusVeilGradient()) .style('opacity', 0) .style('cursor', 'pointer') .on('click', () => collapseNamespaceTree()) @@ -274,6 +293,11 @@ function expandNamespaceTree(ns, meta, nsName, cx, cy, mid) { const layer = svg.append('g').attr('class', 'ns-tree-layer'); + if (!nsTreeTopKeeper) { + nsTreeTopKeeper = createOverlayTopKeeper('ns-tree-scrim', ['ns-tree-layer'], () => !!expandedNsId); + } + nsTreeTopKeeper.start(); + // Anchor = outer-mid point of the clicked cell (the "square" it grows from). const a = mid - Math.PI / 2; const rx = Math.cos(a), ry = Math.sin(a); @@ -287,10 +311,16 @@ function expandNamespaceTree(ns, meta, nsName, cx, cy, mid) { const worlds = Array.isArray(ns.worlds) ? ns.worlds : []; const worldLeaves = worlds.length ? worlds.map((w) => ({ - label: clip(`${w.count}p · ${(w.sample && w.sample[0]) || '—'}`, 20), + label: clip(`${w.count}p · ${(w.sample && w.sample[0]) || '—'}`, 22), title: `inode ${w.inode} · ${w.count} procs\n${(w.sample || []).join(', ') || 'n/a'}`, })) : [{ label: `${ns.dominant_count || 0} procs` }]; + // Only the richest inodes come back from the API, so name the remainder + // instead of letting the branch count disagree with the rows. + const hiddenWorlds = Math.max(0, Number(ns.unique_count || 0) - worlds.length); + if (worlds.length && hiddenWorlds) { + worldLeaves.push({ label: `+${hiddenWorlds} smaller ${hiddenWorlds === 1 ? 'world' : 'worlds'}` }); + } const branches = [ { label: 'IDENTITY', leaves: [ @@ -303,9 +333,10 @@ function expandNamespaceTree(ns, meta, nsName, cx, cy, mid) { ]; // Panel geometry (local content coords). - const rowH = 24; - const headerH = 40; - const footerH = 16; + const rowH = 25; + const headerH = 25; // header strip, same height as the dossier cards + const meterH = 2; + const footerH = 26; const padX = 14; const col0 = padX; // root chip const rootW = 56; @@ -315,7 +346,7 @@ function expandNamespaceTree(ns, meta, nsName, cx, cy, mid) { const leafW = 152; const panelW = col2 + leafW + padX; const totalLeaves = branches.reduce((s, b) => s + b.leaves.length, 0); - const contentTop = headerH + 8; + const contentTop = headerH + meterH + 12; const panelH = contentTop + totalLeaves * rowH + footerH; // Assign rows: leaves stack, branch = mean of its leaves, root = mean of branches. @@ -353,36 +384,63 @@ function expandNamespaceTree(ns, meta, nsName, cx, cy, mid) { .on('click', (event) => event.stopPropagation()); // Frame unfolds vertically from its centre (mechanical open). - panel.append('rect') + ensureDossierDefs(); + const cut = 15; + panel.append('path') .attr('class', 'ns-tree-frame') - .attr('width', panelW).attr('height', panelH).attr('rx', 4) + .attr('d', dossierCardPath(0, 0, panelW, panelH, cut)) + .attr('filter', 'url(#dossier-drop)') .attr('transform', `translate(0, ${panelH / 2}) scale(1, 0.02)`) .transition().delay(140).duration(220).ease(d3.easeCubicOut) .attr('transform', 'translate(0,0) scale(1,1)'); - // 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') + // Chrome: header strip with the ring glyph, activity meter, close hint. + const chrome = panel.append('g').attr('class', 'ns-tree-chrome').style('opacity', 0); + chrome.transition().delay(280).duration(200).style('opacity', 1); + + chrome.append('path') + .attr('class', 'ns-tree-strip') + .attr('d', `M0,0 H${panelW - cut} L${panelW},${cut} V${headerH} H0 Z`); + chrome.append('circle') + .attr('class', 'ns-tree-glyph-ring') + .attr('cx', padX).attr('cy', headerH / 2).attr('r', 4.2); + chrome.append('circle') + .attr('class', 'ns-tree-glyph-dot') + .attr('cx', padX).attr('cy', headerH / 2).attr('r', 1.6); + + chrome.append('text') .attr('class', 'ns-tree-title') - .attr('x', padX).attr('y', 16) + .attr('x', padX + 12).attr('y', headerH / 2 + 3.5) .text(`NAMESPACE · ${nsName.toUpperCase()}`); + chrome.append('text') + .attr('class', isolated ? 'ns-tree-meta is-iso' : 'ns-tree-meta') + .attr('x', panelW - 13).attr('y', headerH / 2 + 3.5) + .attr('text-anchor', 'end') + .text(isolated ? 'ISOLATED' : 'SINGLE'); + chrome.append('line') + .attr('class', 'ns-tree-divider') + .attr('x1', 0).attr('y1', headerH).attr('x2', panelW).attr('y2', headerH); + // Activity reads as a hairline meter across the full width of the strip. const pct = Math.round((ns.activity || 0) * 100); - const trackX = padX, trackY = 26, trackW = panelW - padX * 2; - header.append('rect') + chrome.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) + .attr('x', 0).attr('y', headerH).attr('width', panelW).attr('height', meterH); + chrome.append('rect') + .attr('class', 'ns-tree-track-fill') + .attr('x', 0).attr('y', headerH).attr('width', 0).attr('height', meterH) .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); + .attr('width', Math.max(2, panelW * (pct / 100))); + + chrome.append('text') + .attr('class', 'ns-tree-foot') + .attr('x', padX).attr('y', panelH - 10) + .text('ESC OR CLICK OUTSIDE TO CLOSE'); + chrome.append('text') + .attr('class', 'ns-tree-foot') + .attr('x', panelW - padX).attr('y', panelH - 10) + .attr('text-anchor', 'end') + .text(`ACTIVITY ${pct}%`); const linksG = panel.append('g').attr('class', 'ns-tree-links'); const nodesG = panel.append('g').attr('class', 'ns-tree-nodes'); @@ -401,7 +459,7 @@ function expandNamespaceTree(ns, meta, nsName, cx, cy, mid) { .transition().delay(delay).duration(260).ease(d3.easeCubicOut) .attr('stroke-dashoffset', 0); }; - const addChip = (fromX, fromY, x, y, w, label, cls, delay, title) => { + const addChip = (fromX, fromY, x, y, w, label, cls, delay, title, chamfer = 0) => { const g = nodesG.append('g') .attr('class', cls) .attr('transform', `translate(${fromX}, ${fromY}) scale(0.1)`) @@ -409,8 +467,12 @@ function expandNamespaceTree(ns, meta, nsName, cx, cy, mid) { if (title) { g.style('cursor', 'help').append('title').text(title); } - g.append('rect') - .attr('x', 0).attr('y', -9).attr('width', w).attr('height', 18).attr('rx', 3); + if (chamfer) { + g.append('path').attr('d', dossierCardPath(0, -9, w, 18, chamfer)); + } else { + g.append('rect') + .attr('x', 0).attr('y', -9).attr('width', w).attr('height', 18); + } g.append('text') .attr('x', 9).attr('y', 4) .text(label); @@ -420,8 +482,8 @@ function expandNamespaceTree(ns, meta, nsName, cx, cy, mid) { return g; }; - // 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); + // Root chip echoes the panel's clipped corner (accent when truly isolated). + addChip(col0, rootY, col0, rootY, rootW, nsName.toUpperCase(), isolated ? 'ns-tree-root is-iso' : 'ns-tree-root', 120, null, 5); // Branches + leaves. let leafSeq = 0; diff --git a/static/js/kernel-tape.js b/static/js/kernel-tape.js index 5c682a6..18ad28b 100644 --- a/static/js/kernel-tape.js +++ b/static/js/kernel-tape.js @@ -5,17 +5,38 @@ if (window.KernelTape) return; const POLL_MS = 1400; + const MOBILE_POLL_MS = 3200; + // Below this the band cannot hold enough rows to be worth showing. + const MOBILE_MIN_H = 116; const MAX_ROWS = 80; const MAX_NEW_PER_TICK = 6; + // Mirrors the DOSSIER palette in main.js so the tape and the process cards + // read as one instrument. Amber is reserved for live values. + const D = { + // The dossier cards float over empty map and can afford a hint of + // translucency; this panel spans the full height over the bright HUD, + // where the same 2.5% lets the status module ghost through. + ink: '#090c10', + edge: 'rgba(236, 236, 226, 0.17)', + headerFill: 'rgba(244, 244, 236, 0.055)', + text: '#f4f4ec', + dim: 'rgba(244, 244, 236, 0.5)', + faint: 'rgba(244, 244, 236, 0.26)', + accent: '#e2a33e', + mono: "'Share Tech Mono', monospace" + }; + + const CARD = { cut: 15, header: 25, width: 360 }; + 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)' } + network_stack: { text: 'NET' }, + file_system: { text: 'FS' }, + process_scheduler: { text: 'SCHED' }, + memory_management: { text: 'MEM' } }; - const ERR_COLOR = 'rgba(232, 96, 104, 0.98)'; - const WARN_COLOR = 'rgba(230, 193, 90, 0.95)'; + const ERR_COLOR = 'rgba(226, 96, 88, 0.95)'; + const WARN_COLOR = D.accent; function tagForSyscall(name) { const n = String(name || '').toLowerCase(); @@ -69,20 +90,46 @@ 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-blink { 0%,100% { opacity: 1; } 50% { opacity: 0.3; } } @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; } +@keyframes ktape-flash { 0% { background: rgba(226,96,88,0.22); } 100% { background: transparent; } } +#kernel-tape { font-family: ${D.mono}; } +#kernel-tape ::-webkit-scrollbar { width: 6px; } +#kernel-tape ::-webkit-scrollbar-thumb { background: rgba(244,244,236,0.16); } +#kernel-tape ::-webkit-scrollbar-track { background: transparent; } .ktape-row { animation: ktape-rowin 180ms ease-out; } .ktape-row.err { animation: ktape-rowin 180ms ease-out, ktape-flash 900ms ease-out; } +.ktape-glyph-dot { animation: ktape-blink 1.6s infinite; } +.ktape-btn:hover { color: ${D.text}; } `; document.head.appendChild(style); } + // Same chamfered outline the dossier cards use, so both read as one system. + // Docked to the right edge only the left and top sides are on screen, so the + // floating variant closes the outline on all four. + function cardPath(w, h, cut, floating) { + return floating + ? `M0.5,0.5 H${w - cut} L${w - 0.5},${cut} V${h - 0.5} H0.5 Z` + : `M0.5,0 V${h} H${w} V${cut} L${w - cut},0.5 Z`; + } + + function buildSkin(w, h, floating) { + const cut = CARD.cut; + const hd = CARD.header; + const right = floating ? w - 0.5 : w; + return ` + + + + + + +`; + } + function buildDom() { - // Toggle pill (visible when drawer is closed). + // Toggle pill (visible when the card is closed). const toggle = document.createElement('button'); toggle.id = 'kernel-tape-toggle'; Object.assign(toggle.style, { @@ -97,104 +144,182 @@ toggle.innerHTML = ' ACTIVITY'; toggle.addEventListener('click', () => api.setOpen(true)); - // Drawer. + // Card. 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', + position: 'fixed', top: '0', right: '0', zIndex: '9000', + display: 'flex', flexDirection: 'column', + color: D.text, transition: 'transform 260ms ease', + filter: 'drop-shadow(-8px 0 12px rgba(7,9,12,0.42))', overflow: 'hidden' }); - // Header. + const skin = document.createElement('div'); + Object.assign(skin.style, { position: 'absolute', inset: '0', pointerEvents: 'none' }); + + // Header sits on top of the skin's header strip. 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)' + position: 'relative', display: 'flex', alignItems: 'center', gap: '8px', + height: `${CARD.header}px`, padding: '0 12px 0 26px', flex: '0 0 auto' }); - 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' }); + Object.assign(title.style, { font: `9px/1 ${D.mono}`, letterSpacing: '1.7px', color: D.text }); 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 eps = document.createElement('span'); + eps.textContent = '0 ev/s'; + Object.assign(eps.style, { font: `9px/1 ${D.mono}`, letterSpacing: '1px', color: D.faint }); const pause = document.createElement('button'); + pause.className = 'ktape-btn'; 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' }); + Object.assign(pause.style, { + cursor: 'pointer', background: 'transparent', border: `1px solid ${D.edge}`, + color: D.dim, font: `9px/1 ${D.mono}`, letterSpacing: '1.2px', padding: '3px 6px' + }); 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); + const close = document.createElement('button'); + close.className = 'ktape-btn'; + close.textContent = '×'; + Object.assign(close.style, { + cursor: 'pointer', background: 'transparent', border: 'none', + color: D.dim, font: `12px/1 ${D.mono}`, padding: '0 0 0 2px' + }); + close.addEventListener('click', () => api.setOpen(false)); + header.append(title, spacer, eps, pause, close); + el.closeBtn = close; // Body (newest on top). const body = document.createElement('div'); - Object.assign(body.style, { flex: '1', overflowY: 'auto', overflowX: 'hidden', padding: '4px 0 10px' }); + Object.assign(body.style, { + position: 'relative', flex: '1', overflowY: 'auto', overflowX: 'hidden', + padding: '5px 0 7px', minHeight: '0', + maskImage: 'linear-gradient(to bottom, #000 calc(100% - 16px), transparent)', + webkitMaskImage: 'linear-gradient(to bottom, #000 calc(100% - 16px), transparent)' + }); - root.append(header, controls, body); + root.append(skin, header, body); document.body.append(toggle, root); el.toggle = toggle; el.root = root; + el.skin = skin; el.body = body; el.pauseBtn = pause; el.eps = eps; + + placeCard(); + // The hero re-renders a beat after a resize, so the band it leaves is + // only measurable once that settles. + let replace = null; + window.addEventListener('resize', () => { + placeCard(); + window.clearTimeout(replace); + replace = window.setTimeout(placeCard, 320); + }); + [260, 1100, 2600].forEach((d) => window.setTimeout(placeCard, d)); + } + + function onMobile() { + return typeof isMobileLayout === 'function' && isMobileLayout(); + } + + // On a phone the hero is width-limited, so it leaves a band of dead space + // under the caption. The tape fills that band instead of docking to an edge. + function placeMobileCard() { + const margin = 12; + const hud = document.getElementById('mobile-hud'); + const hudTop = hud && hud.offsetHeight + ? window.innerHeight - hud.offsetHeight + : window.innerHeight; + + // The hero publishes where it ends; the tape starts a gap below that. + const heroBottom = typeof window.__mobileHeroBottom === 'number' + ? window.__mobileHeroBottom + : window.innerHeight * 0.62; + + const top = Math.round(heroBottom + margin); + const h = Math.round(hudTop - margin - top); + const w = Math.round(window.innerWidth - margin * 2); + + // Landscape leaves no usable band; a stub of a feed is worse than none. + if (h < MOBILE_MIN_H) { + el.root.style.display = 'none'; + state.mobileGeom = 'hidden'; + return; + } + // The hero is drawn asynchronously, so placement is re-checked on every + // poll. Skip the repaint unless the band actually moved. + const key = `${top}:${w}:${h}`; + if (state.mobileGeom === key) return; + state.mobileGeom = key; + + el.root.style.display = 'flex'; + Object.assign(el.root.style, { + top: `${top}px`, left: `${margin}px`, right: 'auto', + width: `${w}px`, height: `${h}px`, + filter: 'drop-shadow(0 6px 16px rgba(7,9,12,0.34))' + }); + el.skin.innerHTML = buildSkin(w, h, true); + } + + function placeCard() { + if (!el.root) return; + if (onMobile()) { + placeMobileCard(); + return; + } + const w = Math.round(Math.min(CARD.width, window.innerWidth * 0.34)); + const h = window.innerHeight; + Object.assign(el.root.style, { + display: 'flex', top: '0', left: 'auto', right: '0', + width: `${w}px`, height: `${h}px`, + filter: 'drop-shadow(-8px 0 12px rgba(7,9,12,0.42))' + }); + el.skin.innerHTML = buildSkin(w, h); } function pushRow(ev) { if (!el.body) return; const row = document.createElement('div'); row.className = 'ktape-row' + (ev.level === 'err' ? ' err' : ''); + const idle = ev.level === 'dim'; Object.assign(row.style, { - display: 'flex', alignItems: 'baseline', gap: '8px', - padding: '2px 12px', whiteSpace: 'nowrap', fontSize: '11px', lineHeight: '15px', + display: 'flex', alignItems: 'baseline', gap: '7px', + padding: '1px 12px', whiteSpace: 'nowrap', 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' }); + Object.assign(t.style, { color: D.faint, flex: '0 0 auto', fontSize: '9px' }); 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' }); + Object.assign(sym.style, { color: ev.symColor || D.faint, flex: '0 0 auto', width: '9px', textAlign: 'center', fontSize: '11px' }); 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' }); + Object.assign(name.style, { + color: ev.level === 'err' ? ERR_COLOR : (idle ? D.dim : D.text), + flex: '0 1 auto', fontSize: '11px', overflow: 'hidden', textOverflow: 'ellipsis' + }); 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' }); + Object.assign(tag.style, { color: D.faint, flex: '0 0 auto', fontSize: '9px', letterSpacing: '1px' }); + // Every row in a feed is an event, so amber cannot mean "an event + // happened" — it is kept for the ones that are climbing or hurting. 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' }); + Object.assign(detail.style, { + color: ev.level === 'err' ? ERR_COLOR : (idle ? D.faint : (ev.live ? D.accent : D.dim)), + flex: '1 0 auto', overflow: 'hidden', textOverflow: 'ellipsis', + textAlign: 'right', fontSize: '9px' + }); row.append(t, sym, name, tag, detail); el.body.prepend(row); @@ -264,7 +389,7 @@ 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 symColor = c.isNew ? D.accent : (up ? D.accent : (c.delta < 0 ? D.dim : D.faint)); const detail = c.delta !== 0 ? `${c.delta > 0 ? '+' : ''}${c.delta} → ${c.c} proc` : `${c.c} proc`; @@ -272,8 +397,9 @@ ts: timeStamp(), sym, symColor, name: c.nm.toUpperCase(), - tagText: tag.text, tagColor: tag.color, + tagText: tag.text, detail, + live: c.delta > 0, level: c.heartbeat ? 'dim' : 'normal' }); }); @@ -297,17 +423,17 @@ 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' }); + pushRow({ ts: timeStamp(), sym: '!', symColor: ERR_COLOR, name: 'TCP RETRANSMIT', tagText: 'NET', 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' }); + pushRow({ ts: timeStamp(), sym: '!', symColor: ERR_COLOR, name: 'IP DROP', tagText: 'NET', 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' }); + pushRow({ ts: timeStamp(), sym: '!', symColor: ERR_COLOR, name: 'NIC DROP', tagText: 'NET', 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' }); + pushRow({ ts: timeStamp(), sym: '⇅', symColor: D.dim, name: 'ip flow', tagText: 'NET', detail: fmt, level: 'normal' }); } } @@ -349,15 +475,15 @@ 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' + ts: timeStamp(), sym: '→', symColor: D.accent, + name: `${c.local} → ${c.remote}`, tagText: 'NET', + detail: 'ESTAB', live: true, level: 'normal' }); }); closed.slice(0, 2).forEach((key) => { pushRow({ - ts: timeStamp(), sym: '×', symColor: '#7f97a4', - name: key.replace('>', ' × '), tagText: 'NET', tagColor: TAGS.network_stack.color, + ts: timeStamp(), sym: '×', symColor: D.dim, + name: key.replace('>', ' × '), tagText: 'NET', detail: 'CLOSE', level: 'normal' }); }); @@ -400,19 +526,19 @@ 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' + ts: timeStamp(), sym: '✦', symColor: D.accent, + name: `exec ${p.nm}`, tagText: 'SCHED', + detail: `pid ${p.pid}`, live: true, level: 'normal' }); - pulseNode(p.pid, 'rgba(167, 200, 120, 0.95)'); + pulseNode(p.pid, D.accent); }); exited.slice(0, 3).forEach((p) => { pushRow({ - ts: timeStamp(), sym: '⊝', symColor: '#7f97a4', - name: `exit ${p.nm}`, tagText: 'SCHED', tagColor: TAGS.process_scheduler.color, + ts: timeStamp(), sym: '⊝', symColor: D.dim, + name: `exit ${p.nm}`, tagText: 'SCHED', detail: `pid ${p.pid}`, level: 'normal' }); - pulseNode(p.pid, 'rgba(127, 151, 164, 0.9)'); + pulseNode(p.pid, D.dim); }); } @@ -439,22 +565,22 @@ 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' }); + pushRow({ ts: timeStamp(), sym: '·', symColor: D.dim, name: 'page faults', tagText: 'MEM', 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' }); + pushRow({ ts: timeStamp(), sym: '▲', symColor: WARN_COLOR, name: 'major fault', tagText: 'MEM', detail: `${maj}/s`, live: true, 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' }); + pushRow({ ts: timeStamp(), sym: '↧', symColor: WARN_COLOR, name: 'swap in', tagText: 'MEM', detail: `${fmtRate(swin)}/s`, live: true, 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' }); + pushRow({ ts: timeStamp(), sym: '↥', symColor: WARN_COLOR, name: 'swap out', tagText: 'MEM', detail: `${fmtRate(swout)}/s`, live: true, 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' }); + pushRow({ ts: timeStamp(), sym: '◀', symColor: D.dim, name: 'block read', tagText: 'FS', 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' }); + pushRow({ ts: timeStamp(), sym: '▶', symColor: D.dim, name: 'block write', tagText: 'FS', detail: `${wmb.toFixed(2)} MB/s · ${wiops} iops`, level: 'normal' }); } } @@ -486,7 +612,7 @@ 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', `rgba(226, 163, 62, ${(0.14 + amp * 0.2).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) @@ -495,6 +621,7 @@ function tick() { if (state.paused || !state.open) return; + if (onMobile()) placeCard(); const i = state.tickIndex++; // Core "breath" reflects activity accumulated since the previous tick. pulseCore(state.eventsSinceCore); @@ -506,32 +633,56 @@ updateEps(); } + // Delta-based feeds need a baseline sample before they can say anything, so + // opening primes them silently and the rate-based feeds fill the card at once. + function primeAndFill() { + state.firstSyscallSample = true; + state.firstConnSample = true; + state.firstProcSample = true; + tickSyscalls(); + tickConnections(); + tickProcesses(); + tickNetwork(); + tickIoPulse(); + window.setTimeout(() => { + if (state.open && !state.paused) tickSyscalls(); + }, 700); + } + 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'; + // The phone layout has no toggle: the tape lives in the dead band + // under the hero, so it is always on. + const mobile = onMobile(); + const wasOpen = state.open; + state.open = mobile ? true : !!open; + if (el.root) { + placeCard(); + el.root.style.transform = state.open ? 'translateX(0)' : 'translateX(100%)'; + el.root.style.pointerEvents = state.open ? 'auto' : 'none'; + } + if (el.toggle) el.toggle.style.display = (!mobile && !state.open) ? 'inline-flex' : 'none'; + if (el.closeBtn) el.closeBtn.style.display = mobile ? 'none' : 'inline-block'; + if (state.open && !wasOpen) primeAndFill(); }, setPaused(paused) { state.paused = !!paused; if (el.pauseBtn) { el.pauseBtn.textContent = paused ? 'RESUME' : 'PAUSE'; - el.pauseBtn.style.color = paused ? '#e8c15a' : '#9fb3bf'; + el.pauseBtn.style.color = paused ? D.accent : D.dim; } } }; 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); + // Desktop hides it behind the ACTIVITY pill; mobile shows it outright. + api.setOpen(onMobile()); + // Slower cadence on a phone: four endpoints every 1.4s is not a fair + // trade against a battery. + state.timer = setInterval(tick, onMobile() ? MOBILE_POLL_MS : POLL_MS); } if (document.readyState === 'loading') { diff --git a/static/js/main.js b/static/js/main.js index 49ccb3a..4d44d27 100755 --- a/static/js/main.js +++ b/static/js/main.js @@ -10,6 +10,20 @@ let rightSemicircleMenuManager; let connectionsManager; // make available for cleanup handlers let pinnedProcessDossier = null; const MOBILE_LAYOUT_BREAKPOINT = 900; +// Pixels-per-user-unit of the mobile hero frame; text divides by it to stay a +// constant size across screens. Set when the mobile viewBox is computed. +let mobileFrameScale = 1; +const MOBILE_HUD_FALLBACK_H = 72; +// Margin the mobile overlays keep from the screen edge and from each other. +const MOBILE_EDGE_GAP = 12; +const MOBILE_CHAMFER = 'polygon(0 0, calc(100% - 9px) 0, 100% 9px, 100% 100%, 0 100%)'; +// Strip kept for the activity tape, and the height below which the hero stops +// giving it up because it would no longer read as the hero. +const MOBILE_TAPE_RESERVE = 150; +const MOBILE_HERO_MIN = 300; +const MOBILE_LABEL_MIN_SCALE = 0.64; +// Advance of Share Tech Mono at the 9px the ring labels render at. +const MOBILE_LABEL_CHAR_PX = 5.7; const MOBILE_TOUCH_SHORT_SIDE = 820; function isMobileLayout() { // Primary signal: narrow viewport. @@ -220,9 +234,47 @@ function draw() { // the hero with a viewBox so the whole composition scales to fit and // stays centered (pointer/tooltip math uses pageX/Y, so hit-testing is // unaffected by the coordinate scaling). + // + // The notice above and the HUD below both cover the screen, so the hero + // is fitted to the band between them rather than to the viewport: sizing + // against the full height pushes it underneath one of them in landscape. + renderMobileHud(); + const noticeEl = renderMobileNotice(); + const hudEl = document.getElementById('mobile-hud'); + const hudH = hudEl && hudEl.offsetHeight ? hudEl.offsetHeight : MOBILE_HUD_FALLBACK_H; + const bandTop = noticeEl && noticeEl.offsetHeight + ? MOBILE_EDGE_GAP + noticeEl.offsetHeight + MOBILE_EDGE_GAP + : MOBILE_EDGE_GAP; + const bandH = Math.max(180, height - hudH - bandTop); const frameHalf = 255; - svg.attr('viewBox', `${centerX - frameHalf} ${centerY - frameHalf} ${frameHalf * 2} ${frameHalf * 2}`) + + // A width-limited hero would otherwise take the whole band on a short + // phone and squeeze the tape out of existence, so it gives up a strip + // for the feed — but only while enough height is left to stay a hero. + // What counts as "still a hero" scales with the screen: 300px is the + // floor on a normal phone, less on a narrow one where the width caps it + // anyway. + const heroMin = Math.min(MOBILE_HERO_MIN, width * 0.8); + const heroBand = bandH - MOBILE_TAPE_RESERVE >= heroMin + ? bandH - MOBILE_TAPE_RESERVE + : bandH; + + // Match the viewBox aspect to the viewport so `meet` yields exactly this + // scale, which is the largest the hero can be in the space it is given. + mobileFrameScale = Math.min(width, heroBand) / (frameHalf * 2); + const vbW = width / mobileFrameScale; + const vbH = height / mobileFrameScale; + + // On a narrow screen the hero is limited by width, so it can never fill + // the height — centring it would strand that slack above and below. + // Pinning it to the top of the band collects the slack into one band + // underneath, which the activity tape then fills. + const compH = frameHalf * 2 * mobileFrameScale; + const vbY = centerY - frameHalf - bandTop / mobileFrameScale; + svg.attr('viewBox', `${centerX - vbW / 2} ${vbY} ${vbW} ${vbH}`) .attr('preserveAspectRatio', 'xMidYMid meet'); + // The tape docks to whatever the hero leaves behind. + window.__mobileHeroBottom = Math.round(bandTop + compH); } else { // Desktop renders 1:1 with the viewport — make sure no mobile viewBox lingers. svg.attr('viewBox', null).attr('preserveAspectRatio', null); @@ -289,16 +341,12 @@ function draw() { if (mobileLayout) { // Keep Icon1 content in mobile center composition as requested. drawTagIcons(centerX, centerY); - drawMobileFormulaAndCaption(centerX, centerY, width, height); + drawMobileFormula(centerX, centerY); // Draw process ring only (no side/bottom UI layers, no tag/menu shells). + // Ring labels are drawn once the process data lands, from real names. drawProcessKernelMap2(centerX, centerY); - drawMobileDefaultProcessLabels(centerX, centerY); // Restore namespace shell segments in mobile mode. drawIsolationConceptLayer(centerX, centerY, width, height); - // Live, readable kernel metrics at the bottom (HTML overlay). - renderMobileHud(); - // Advise that the full experience is built for desktop. - renderMobileNotice(); return; } @@ -340,63 +388,90 @@ function draw() { } } -function drawMobileFormulaAndCaption(centerX, centerY, width, height) { +function drawMobileFormula(centerX, centerY) { const group = svg.append('g') .attr('class', 'mobile-formula-layer') .attr('pointer-events', 'none'); - const formulaY = Math.max(34, centerY - 245); - const captionY = Math.min(height - 22, centerY + 235); - - // Subtle text-only treatment to keep the mobile center clean. - group.append('text') - .attr('x', centerX) - .attr('y', formulaY) - .attr('text-anchor', 'middle') - .style('font-family', 'JetBrains Mono, Share Tech Mono, monospace') - .style('font-size', '13px') - .style('letter-spacing', '-0.1px') - .style('fill', 'rgba(52, 52, 52, 0.76)') - .text('L_new=L_old*e^(-dt/tau)+N*(1-e^(-dt/tau))'); + // This lives in user units, which the frame scales. The size is divided by + // that scale so the type lands at the same pixel size on a phone and on a + // tablet instead of ballooning with the composition. + const s = mobileFrameScale || 1; group.append('text') .attr('x', centerX) - .attr('y', captionY) + .attr('y', centerY - 246) .attr('text-anchor', 'middle') .style('font-family', 'Share Tech Mono, monospace') - .style('font-size', '11px') - .style('letter-spacing', '0.4px') - .style('fill', 'rgba(62, 62, 62, 0.58)') - .text('linux kernel · live process map'); + .style('font-size', `${(11 / s).toFixed(2)}px`) + .style('letter-spacing', `${(0.4 / s).toFixed(2)}px`) + .style('fill', 'rgba(52, 52, 52, 0.7)') + .text('L_new = L_old·e^(-dt/tau) + N·(1-e^(-dt/tau))'); } -function drawMobileDefaultProcessLabels(centerX, centerY) { +// Names the busiest processes around the hero. Drawn outside the node ring: the +// band inside it is already taken by the Ring-1 spokes and the KERNEL MODE +// label. Angles are offset by half a step so no name lands at the top or bottom, +// where the formula and the caption sit. +function drawMobileProcessLabels(centerX, centerY, names) { if (!isMobileLayout()) return; - const names = ['sshd', 'python3', 'nginx', 'cron', 'bash', 'systemd']; - const radius = 95; - const startAngle = -Math.PI / 2; + svg.selectAll('.mobile-process-labels').remove(); + if (!Array.isArray(names) || !names.length) return; + // Names are held at a constant pixel size, so the smaller the composition + // the more of it they take up. Past this point they crowd the ring instead + // of annotating it, and the ring reads better bare. + if ((mobileFrameScale || 1) < MOBILE_LABEL_MIN_SCALE) return; + + // Diagonals only. On the horizontal and vertical axes a name has nowhere to + // grow — outward runs past the frame, inward crowds the ring — while on a + // diagonal both the frame corner and the ring leave room. + const radius = 226; + const startAngle = -Math.PI / 4; const step = (2 * Math.PI) / names.length; const group = svg.append('g') - .attr('class', 'mobile-default-process-labels') + .attr('class', 'mobile-process-labels') .attr('pointer-events', 'none'); + // How many characters fit between the anchor and the screen edge. All four + // diagonals share the same horizontal offset, so one budget covers them. + const s = mobileFrameScale || 1; + const room = window.innerWidth / 2 - Math.abs(Math.cos(startAngle)) * radius * s - 8; + const maxChars = Math.max(6, Math.floor(room / MOBILE_LABEL_CHAR_PX)); + names.forEach((name, i) => { const angle = startAngle + i * step; const x = centerX + Math.cos(angle) * radius; const y = centerY + Math.sin(angle) * radius; + // Grow outward, away from the ring, into the free frame corner. + const anchor = Math.cos(angle) > 0 ? 'start' : 'end'; group.append('text') .attr('x', x) .attr('y', y) - .attr('text-anchor', 'middle') - .style('font-family', 'JetBrains Mono, Share Tech Mono, monospace') - .style('font-size', '9px') - .style('letter-spacing', '0.15px') - .style('fill', 'rgba(96, 96, 96, 0.52)') - .text(name); + .attr('text-anchor', anchor) + .style('font-family', 'Share Tech Mono, monospace') + .style('font-size', `${(9 / (mobileFrameScale || 1)).toFixed(2)}px`) + .style('letter-spacing', `${(0.6 / (mobileFrameScale || 1)).toFixed(2)}px`) + .style('fill', 'rgba(96, 96, 96, 0.62)') + .text(name.length > maxChars ? `${name.slice(0, maxChars - 1)}…` : name); }); } +// The busiest distinct process names, which is what the hero ring labels. +function topProcessNames(processes, limit) { + const seen = new Set(); + const out = []; + [...processes] + .sort((a, b) => (b.memory_mb || 0) - (a.memory_mb || 0)) + .forEach((p) => { + const name = (p.name || '').trim(); + if (!name || seen.has(name) || out.length >= limit) return; + seen.add(name); + out.push(name); + }); + return out; +} + // ---- Mobile HUD: a compact, readable strip of live kernel metrics ---------- // The hero is decorative; on a phone we still want real, glanceable numbers. // Pure HTML overlay (fixed), so the SVG viewBox scaling never touches it. @@ -415,26 +490,33 @@ function renderMobileHud() { hud.id = 'mobile-hud'; Object.assign(hud.style, { position: 'fixed', left: '0', right: '0', bottom: '0', zIndex: '8000', - display: 'flex', gap: '1px', justifyContent: 'center', + display: 'flex', gap: '6px', justifyContent: 'center', padding: '8px 8px calc(8px + env(safe-area-inset-bottom))', background: 'linear-gradient(0deg, rgba(8,10,13,0.94) 0%, rgba(8,10,13,0.0) 100%)', - fontFamily: "'JetBrains Mono','Share Tech Mono',monospace", pointerEvents: 'none' + fontFamily: DOSSIER.mono, pointerEvents: 'none' }); + // Chamfered like the dossier cards. clip-path drops the border, so the + // edge is a second clipped layer showing through by one pixel. MOBILE_HUD_TILES.forEach((tile) => { const cell = document.createElement('div'); Object.assign(cell.style, { - flex: '1 1 0', maxWidth: '120px', textAlign: 'center', - background: 'rgba(16,20,27,0.9)', border: '1px solid rgba(103,190,224,0.22)', - borderRadius: '8px', padding: '7px 4px 8px', margin: '0 3px' + flex: '1 1 0', maxWidth: '120px', background: DOSSIER.edge, + clipPath: MOBILE_CHAMFER, padding: '1px' + }); + const inner = document.createElement('div'); + Object.assign(inner.style, { + textAlign: 'center', background: '#090c10', clipPath: MOBILE_CHAMFER, + padding: '7px 4px 8px' }); const val = document.createElement('div'); val.id = `mobile-hud-${tile.id}`; val.textContent = '--'; - Object.assign(val.style, { color: '#dbe6ef', fontSize: '15px', fontWeight: '600', lineHeight: '1.1' }); + Object.assign(val.style, { color: DOSSIER.text, fontSize: '20px', lineHeight: '1.1' }); const lab = document.createElement('div'); lab.textContent = tile.label; - Object.assign(lab.style, { color: '#6f8895', fontSize: '8.5px', letterSpacing: '1px', marginTop: '3px' }); - cell.append(val, lab); + Object.assign(lab.style, { color: DOSSIER.faint, fontSize: '9px', letterSpacing: '1.7px', marginTop: '4px' }); + inner.append(val, lab); + cell.appendChild(inner); hud.appendChild(cell); }); document.body.appendChild(hud); @@ -455,42 +537,63 @@ function hideMobileHud() { } } -// Top banner advising that the experience is built for desktop. Dismissible, -// and once dismissed it stays hidden for the session. +// Says plainly that the full console is a desktop composition. Same card +// language as the HUD and the activity tape, and dismissible for the session. function renderMobileNotice() { - if (window.__mobileNoticeDismissed) return; + if (window.__mobileNoticeDismissed) return null; let notice = document.getElementById('mobile-notice'); if (!notice) { notice = document.createElement('div'); notice.id = 'mobile-notice'; Object.assign(notice.style, { - position: 'fixed', left: '0', right: '0', top: '0', zIndex: '8200', - display: 'flex', alignItems: 'center', gap: '8px', - padding: '9px 12px', paddingTop: 'calc(9px + env(safe-area-inset-top))', - background: 'rgba(12,16,22,0.92)', borderBottom: '1px solid rgba(103,190,224,0.25)', - backdropFilter: 'blur(4px)', - fontFamily: "'JetBrains Mono','Share Tech Mono',monospace", color: '#bcd3de' + position: 'fixed', left: `${MOBILE_EDGE_GAP}px`, right: `${MOBILE_EDGE_GAP}px`, + top: `calc(${MOBILE_EDGE_GAP}px + env(safe-area-inset-top))`, zIndex: '8200', + background: DOSSIER.edge, clipPath: MOBILE_CHAMFER, padding: '1px', + fontFamily: DOSSIER.mono + }); + + const inner = document.createElement('div'); + Object.assign(inner.style, { + background: '#090c10', clipPath: MOBILE_CHAMFER, padding: '8px 10px 9px' + }); + + const head = document.createElement('div'); + Object.assign(head.style, { display: 'flex', alignItems: 'center', gap: '7px' }); + const glyph = document.createElement('span'); + glyph.textContent = '◉'; + Object.assign(glyph.style, { color: DOSSIER.dim, fontSize: '9px', flex: '0 0 auto' }); + const title = document.createElement('span'); + title.textContent = 'DESIGNED FOR DESKTOP'; + Object.assign(title.style, { + color: DOSSIER.text, fontSize: '10px', letterSpacing: '1.7px', flex: '1 1 auto' }); - const icon = document.createElement('span'); - icon.textContent = '🖥'; - Object.assign(icon.style, { fontSize: '13px', flex: '0 0 auto', opacity: '0.9' }); - const text = document.createElement('span'); - text.textContent = 'Best viewed on desktop — this dashboard is designed for a large screen.'; - Object.assign(text.style, { flex: '1 1 auto', fontSize: '11px', lineHeight: '1.35', letterSpacing: '0.2px' }); const close = document.createElement('button'); close.textContent = '×'; Object.assign(close.style, { flex: '0 0 auto', cursor: 'pointer', background: 'transparent', border: 'none', - color: '#7f97a4', fontSize: '18px', lineHeight: '1', padding: '0 2px' + color: DOSSIER.faint, font: `13px/1 ${DOSSIER.mono}`, padding: '0 0 0 4px' }); close.addEventListener('click', () => { window.__mobileNoticeDismissed = true; notice.style.display = 'none'; + // Reclaim the strip the notice was holding. + if (typeof draw === 'function') draw(); + }); + head.append(glyph, title, close); + + const detail = document.createElement('div'); + detail.textContent = 'panels · dossier · kernel map need a large screen'; + Object.assign(detail.style, { + color: DOSSIER.faint, fontSize: '9px', letterSpacing: '0.5px', + lineHeight: '1.4', marginTop: '5px' }); - notice.append(icon, text, close); + + inner.append(head, detail); + notice.appendChild(inner); document.body.appendChild(notice); } - notice.style.display = 'flex'; + notice.style.display = 'block'; + return notice; } function hideMobileNotice() { @@ -528,25 +631,6 @@ function formatProcessValue(value, fallback = 'n/a') { return value === null || value === undefined || value === '' ? fallback : value; } -function processDossierRows(processData, details = {}) { - const threadsData = details.threadsData || {}; - const cpuData = details.cpuData || {}; - const fdsData = details.fdsData || {}; - const cpuTimes = cpuData.cpu_times || {}; - return [ - ['identity', `${formatProcessValue(processData.name, 'process')} · pid ${formatProcessValue(processData.pid)}`], - ['state', `${formatProcessValue(processData.status)} · mem ${formatProcessValue(processData.memory_mb, 0)} MB`], - ['threads', formatProcessValue(threadsData.thread_count, 'loading')], - ['vol ctx', threadsData.voluntary_ctxt_switches ? threadsData.voluntary_ctxt_switches.toLocaleString() : 'loading'], - ['nonvol ctx', threadsData.nonvoluntary_ctxt_switches ? threadsData.nonvoluntary_ctxt_switches.toLocaleString() : 'loading'], - ['cpu time', cpuTimes.user !== undefined ? `usr ${cpuTimes.user}s · sys ${cpuTimes.system}s` : 'loading'], - ['nice', cpuData.nice !== undefined && cpuData.nice !== null ? cpuData.nice : 'loading'], - ['fds', fdsData.num_fds !== undefined ? fdsData.num_fds : formatProcessValue(processData.num_fds, 'loading')], - ['open files', Array.isArray(fdsData.open_files) ? fdsData.open_files.length : 'loading'], - ['sockets', Array.isArray(fdsData.connections) ? fdsData.connections.length : 'loading'] - ]; -} - function processIoSummary(processData, details = {}) { const fdsData = details.fdsData || {}; return { @@ -556,16 +640,6 @@ function processIoSummary(processData, details = {}) { }; } -function descriptorTone(type) { - const normalized = String(type || '').toLowerCase(); - if (normalized.includes('stdin') || normalized.includes('stdout') || normalized.includes('stderr')) return '#2f2f2f'; - if (normalized.includes('unix')) return '#1f1f1f'; - if (normalized.includes('tcp') || normalized.includes('inet') || normalized.includes('socket')) return '#17455a'; - if (normalized.includes('pipe')) return '#5a5a5a'; - if (normalized.includes('file')) return '#634a1f'; - return '#4a4a4a'; -} - function descriptorTargetLabel(descriptor) { if (!descriptor) return ''; if (descriptor.remote_address) return descriptor.remote_address; @@ -573,837 +647,820 @@ function descriptorTargetLabel(descriptor) { return String(descriptor.target || ''); } -function processControlStrip(processData, details = {}) { - const threadsData = details.threadsData || {}; - const cpuData = details.cpuData || {}; - const io = processIoSummary(processData, details); - const cpuPercent = Number(cpuData.cpu_percent || processData.cpu_percent || 0); - const memoryMb = Number(processData.memory_mb || 0); - const threadCount = Number(threadsData.thread_count || processData.num_threads || 0); - return [ - { id: 'SYSCALL', active: true, value: formatProcessValue(processData.status, 'state') }, - { id: 'VFS', active: io.files > 0, value: `${io.files} files` }, - { id: 'SOCKET', active: io.sockets > 0, value: `${io.sockets} conns` }, - { id: 'IRQ', active: threadCount > 8, value: `${threadCount} th` }, - { id: 'SCHED', active: cpuPercent > 0 || threadCount > 1, value: `${cpuPercent}% cpu` }, - { id: 'MEM', active: memoryMb > 1, value: `${memoryMb.toFixed ? memoryMb.toFixed(1) : memoryMb} MB` }, - { id: 'FD', active: Number(io.fds || 0) > 0, value: `${io.fds} fd` } - ]; +// ── Process dossier ───────────────────────────────────────────────────────── +// Layered dark cards over the dimmed map: identity → vitals → descriptors. +// One cascading stack instead of scattered panels, so the eye reads top-down. +const DOSSIER = { + ink: 'rgba(9, 12, 16, 0.975)', + edge: 'rgba(236, 236, 226, 0.17)', + rule: 'rgba(236, 236, 226, 0.1)', + tint: 'rgba(244, 244, 236, 0.055)', + text: '#f4f4ec', + dim: 'rgba(244, 244, 236, 0.5)', + faint: 'rgba(244, 244, 236, 0.26)', + accent: '#e2a33e', + mono: 'Share Tech Mono, monospace' +}; + +const KERNEL_THREAD_RE = /^(kworker|ksoftirqd|migration|rcu_|rcub|rcuc|kthreadd|kswapd|kcompactd|khugepaged|kdevtmpfs|kauditd|jbd2|ext4-|xfs-|watchdog|irq\/|idle_inject|cpuhp|netns|kblockd|blkcg|scsi_|nvme|kstrp|oom_reaper|writeback|kintegrityd|kthrotld|dmcrypt|edac-|devfreq|acpi_|ipv6_addrconf)/i; + +// kthreadd (pid 2) fathers every kernel thread, so the ancestry settles this +// definitively. The name pattern is only a stand-in until lineage arrives. +function isKernelThreadProcess(processData, lineage) { + const chain = lineage && Array.isArray(lineage.chain) ? lineage.chain : null; + if (chain && chain.length) { + return Number(processData.pid) === 2 || chain.some((row) => Number(row.pid) === 2); + } + return KERNEL_THREAD_RE.test(String(processData.name || '')) + && Number(processData.memory_mb || 0) === 0; } -function processTraceMapSteps(processData, details = {}) { - const threadsData = details.threadsData || {}; - const cpuData = details.cpuData || {}; - const io = processIoSummary(processData, details); - const threadCount = Number(threadsData.thread_count || processData.num_threads || 0); - const cpuPercent = Number(cpuData.cpu_percent || processData.cpu_percent || 0); - return [ - { - id: 'PROCESS', - title: String(processData.name || 'process').slice(0, 14), - value: `pid ${formatProcessValue(processData.pid)}`, - active: true - }, - { - id: 'FD TABLE', - title: 'FD TABLE', - value: `${io.fds} fd`, - active: Number(io.fds || 0) > 0 - }, - { - id: 'VFS', - title: 'VFS', - value: `${io.files} files`, - active: Number(io.files || 0) > 0 - }, - { - id: 'SOCKET/PIPE', - title: 'SOCKET/PIPE', - value: `${io.sockets} sockets`, - active: Number(io.sockets || 0) > 0 - }, - { - id: 'SCHED', - title: 'SCHED', - value: `${threadCount} th · ${cpuPercent}%`, - active: threadCount > 1 || cpuPercent > 0 - }, - { - id: 'KERNEL', - title: 'KERNEL', - value: formatProcessValue(processData.status, 'state'), - active: true - } - ]; +function ensureDossierDefs() { + let defs = svg.select('defs'); + if (defs.empty()) defs = svg.append('defs'); + if (svg.select('#dossier-drop').empty()) { + defs.append('filter') + .attr('id', 'dossier-drop') + .attr('x', '-35%').attr('y', '-35%') + .attr('width', '190%').attr('height', '200%') + .append('feDropShadow') + .attr('dx', 0).attr('dy', 7) + .attr('stdDeviation', 10) + .attr('flood-color', '#07090c') + .attr('flood-opacity', 0.45); + } } -function processInteractionNodes(processData, details = {}) { - const threadsData = details.threadsData || {}; - const cpuData = details.cpuData || {}; - const io = processIoSummary(processData, details); - const cpuPercent = Number(cpuData.cpu_percent || processData.cpu_percent || 0); - const memoryMb = Number(processData.memory_mb || 0); - const threadCount = Number(threadsData.thread_count || processData.num_threads || 0); - return [ - { id: 'FD', label: 'FD', value: `${io.fds}`, active: Number(io.fds || 0) > 0 }, - { id: 'VFS', label: 'VFS', value: `${io.files}`, active: Number(io.files || 0) > 0 }, - { id: 'SOCK', label: 'SOCK', value: `${io.sockets}`, active: Number(io.sockets || 0) > 0 }, - { id: 'SCHED', label: 'SCHED', value: `${threadCount}`, active: threadCount > 1 || cpuPercent > 0 }, - { id: 'MEM', label: 'MEM', value: `${Math.round(memoryMb)}M`, active: memoryMb > 1 }, - { id: 'CPU', label: 'CPU', value: `${Math.round(cpuPercent)}%`, active: cpuPercent > 0 }, - { id: 'IPC', label: 'IPC', value: 'pipe', active: false }, - { id: 'IRQ', label: 'IRQ', value: 'ctx', active: threadCount > 8 } - ]; +// Square card with the clipped top-right corner of the reference dossier. +function dossierCardPath(x, y, w, h, cut = 15) { + return `M${x},${y} H${x + w - cut} L${x + w},${y + cut} V${y + h} H${x} Z`; } -function renderProcessInteractionModule(centerX, centerY, processData, anchor = null, details = {}) { - d3.selectAll('.process-interaction-module').remove(); - if (!processData || isMobileLayout()) return; +function dossierCard(layer, box, title, meta) { + const g = layer.append('g'); - const moduleCx = Math.max(310, centerX - 265); - const moduleCy = Math.max(190, centerY - 58); - const moduleR = anchor ? 80 : 74; - const nodes = processInteractionNodes(processData, details); - const activeCount = nodes.filter(n => n.active).length; + g.append('path') + .attr('d', dossierCardPath(box.x, box.y, box.w, box.h)) + .attr('fill', DOSSIER.ink) + .attr('stroke', DOSSIER.edge) + .attr('stroke-width', 1) + .attr('filter', 'url(#dossier-drop)'); - const group = svg.append('g') - .attr('class', 'process-interaction-module') - .attr('pointer-events', 'none'); + g.append('path') + .attr('d', `M${box.x},${box.y} H${box.x + box.w - 15} L${box.x + box.w},${box.y + 15} V${box.y + 25} H${box.x} Z`) + .attr('fill', DOSSIER.tint); - if (anchor) { - group.append('path') - .attr('d', `M${moduleCx + moduleR * 0.72},${moduleCy + moduleR * 0.58} C${moduleCx + 132},${moduleCy + 118} ${anchor.x - 74},${anchor.y + 22} ${anchor.x},${anchor.y}`) - .attr('fill', 'none') - .attr('stroke', 'rgba(12, 12, 12, 0.48)') - .attr('stroke-width', 1.1) - .attr('stroke-dasharray', '5 6'); - } else { - group.append('path') - .attr('d', `M${moduleCx + moduleR + 28},${moduleCy} C${moduleCx + moduleR + 90},${moduleCy - 36} ${centerX - 160},${centerY - 110} ${centerX - 80},${centerY - 76}`) - .attr('fill', 'none') - .attr('stroke', 'rgba(28, 28, 28, 0.22)') - .attr('stroke-width', 0.8); + g.append('line') + .attr('x1', box.x).attr('x2', box.x + box.w) + .attr('y1', box.y + 25).attr('y2', box.y + 25) + .attr('stroke', DOSSIER.edge) + .attr('stroke-width', 0.9); + + g.append('circle') + .attr('cx', box.x + 14).attr('cy', box.y + 12.5).attr('r', 4.2) + .attr('fill', 'none') + .attr('stroke', DOSSIER.dim) + .attr('stroke-width', 1.1); + g.append('circle') + .attr('cx', box.x + 14).attr('cy', box.y + 12.5).attr('r', 1.6) + .attr('fill', DOSSIER.accent); + + g.append('text') + .attr('x', box.x + 26).attr('y', box.y + 16) + .attr('font-family', DOSSIER.mono) + .attr('font-size', '9px') + .attr('letter-spacing', '1.7') + .attr('fill', DOSSIER.text) + .text(title); + + if (meta) { + g.append('text') + .attr('x', box.x + box.w - 13).attr('y', box.y + 16) + .attr('text-anchor', 'end') + .attr('font-family', DOSSIER.mono) + .attr('font-size', '9px') + .attr('letter-spacing', '1') + .attr('fill', DOSSIER.dim) + .text(meta); } + return g; +} - if (anchor) { - const pimDefs = group.append('defs'); - const pimFilter = pimDefs.append('filter') - .attr('id', 'pim-elevation') - .attr('x', '-60%') - .attr('y', '-60%') - .attr('width', '220%') - .attr('height', '220%'); - pimFilter.append('feDropShadow') - .attr('dx', 0) - .attr('dy', 3) - .attr('stdDeviation', 8) - .attr('flood-color', '#000') - .attr('flood-opacity', 0.42); - - // Radial gradient gives the disc instrument-like volume: dark core, lighter rim. - const pimGrad = pimDefs.append('radialGradient') - .attr('id', 'pim-disc-grad') - .attr('cx', '50%') - .attr('cy', '42%') - .attr('r', '62%'); - pimGrad.append('stop').attr('offset', '0%').attr('stop-color', '#0a0a0a'); - pimGrad.append('stop').attr('offset', '58%').attr('stop-color', '#141414'); - pimGrad.append('stop').attr('offset', '100%').attr('stop-color', '#2a2a28'); - - // Soft light halo separates the module from the busy scene behind it. - group.append('circle') - .attr('cx', moduleCx) - .attr('cy', moduleCy) - .attr('r', moduleR + 34) - .attr('fill', 'rgba(247, 247, 240, 0.62)') - .attr('stroke', 'none') - .style('filter', 'blur(3px)'); - - group.append('circle') - .attr('cx', moduleCx) - .attr('cy', moduleCy) - .attr('r', moduleR + 16) - .attr('fill', 'none') - .attr('stroke', 'rgba(20, 20, 20, 0.16)') - .attr('stroke-width', 1) - .attr('stroke-dasharray', '2 6'); +// Paths carry their meaning at the tail, so clip the front, not the end. +function elideDescriptorTarget(target, max) { + const text = String(target || ''); + if (text.length <= max) return text; + return text.includes('/') ? `…${text.slice(-(max - 1))}` : `${text.slice(0, max - 1)}…`; +} + +function schedulingRows(processData, threadsData, cpuData, kernelThread, fdsData) { + if (!fdsData) return [{ key: 'reading', value: `/proc/${processData.pid} …` }]; + + const times = cpuData.cpu_times || {}; + const vol = threadsData.voluntary_ctxt_switches; + const nonvol = threadsData.nonvoluntary_ctxt_switches; + const affinity = Array.isArray(cpuData.cpu_affinity) ? cpuData.cpu_affinity : null; + const rows = []; + + if (vol !== undefined || nonvol !== undefined) { + rows.push({ key: 'ctx switches', value: `${Number(vol || 0).toLocaleString()} vol · ${Number(nonvol || 0).toLocaleString()} forced` }); } + if (times.user !== undefined) { + rows.push({ key: 'cpu time', value: `usr ${times.user}s · sys ${times.system}s` }); + } + if (affinity) { + rows.push({ key: 'affinity', value: `cpu ${affinity.length > 4 ? `${affinity.length} cores` : affinity.join(',')}` }); + } + rows.push({ + key: 'descriptors', + value: kernelThread ? 'none — kernel task' : 'not readable at this privilege' + }); + return rows; +} - group.append('circle') - .attr('cx', moduleCx) - .attr('cy', moduleCy) - .attr('r', moduleR + (anchor ? 6 : 20)) - .attr('fill', anchor ? 'rgba(12, 12, 12, 0.10)' : 'rgba(240, 240, 232, 0.08)') - .attr('stroke', anchor ? 'rgba(8, 8, 8, 0.36)' : 'none') - .attr('stroke-width', anchor ? 1.2 : 1.1); - - group.append('circle') - .attr('cx', moduleCx) - .attr('cy', moduleCy) - .attr('r', moduleR) - .attr('fill', anchor ? 'url(#pim-disc-grad)' : 'rgba(240, 240, 232, 0.24)') - .attr('stroke', anchor ? 'rgba(0, 0, 0, 0.92)' : 'rgba(28, 28, 28, 0.26)') - .attr('stroke-width', anchor ? 1.8 : 1) - .style('filter', anchor ? 'url(#pim-elevation)' : null); - - if (anchor) { - group.append('circle') - .attr('cx', moduleCx) - .attr('cy', moduleCy) - .attr('r', moduleR - 4) - .attr('fill', 'none') - .attr('stroke', 'rgba(247, 247, 240, 0.55)') - .attr('stroke-width', 1.1); - - // Slowly rotating tick ring conveys live telemetry on the instrument rim. - const tickRing = group.append('g'); - const tickR = moduleR - 9; - const tickCount = 48; - for (let t = 0; t < tickCount; t++) { - const ta = (t / tickCount) * Math.PI * 2; - const major = t % 6 === 0; - const inner = tickR - (major ? 6 : 3); - tickRing.append('line') - .attr('x1', moduleCx + Math.cos(ta) * tickR) - .attr('y1', moduleCy + Math.sin(ta) * tickR) - .attr('x2', moduleCx + Math.cos(ta) * inner) - .attr('y2', moduleCy + Math.sin(ta) * inner) - .attr('stroke', `rgba(238, 238, 228, ${major ? 0.5 : 0.24})`) - .attr('stroke-width', major ? 1.1 : 0.7); - } - tickRing.append('animateTransform') - .attr('attributeName', 'transform') - .attr('type', 'rotate') - .attr('from', `0 ${moduleCx} ${moduleCy}`) - .attr('to', `360 ${moduleCx} ${moduleCy}`) - .attr('dur', '48s') - .attr('repeatCount', 'indefinite'); - - // Counter-rotating faint scan marker for extra liveliness. - const scan = group.append('g'); - scan.append('line') - .attr('x1', moduleCx) - .attr('y1', moduleCy) - .attr('x2', moduleCx) - .attr('y2', moduleCy - (moduleR - 12)) - .attr('stroke', 'rgba(238, 238, 228, 0.16)') - .attr('stroke-width', 1); - scan.append('animateTransform') - .attr('attributeName', 'transform') - .attr('type', 'rotate') - .attr('from', `360 ${moduleCx} ${moduleCy}`) - .attr('to', `0 ${moduleCx} ${moduleCy}`) - .attr('dur', '11s') - .attr('repeatCount', 'indefinite'); - - // Segmented activity arc: one segment per channel, aligned to its node. - const arcR = moduleR + 11; - const segGap = (Math.PI * 2 / nodes.length) * 0.22; - const segSpan = (Math.PI * 2 / nodes.length) - segGap; - const arcPoint = (a) => `${(moduleCx + Math.cos(a) * arcR).toFixed(2)},${(moduleCy + Math.sin(a) * arcR).toFixed(2)}`; - nodes.forEach((node, idx) => { - const center = -Math.PI / 2 + (idx / nodes.length) * Math.PI * 2; - const a0 = center - segSpan / 2; - const a1 = center + segSpan / 2; - const seg = group.append('path') - .attr('d', `M${arcPoint(a0)} A${arcR},${arcR} 0 0 1 ${arcPoint(a1)}`) - .attr('fill', 'none') - .attr('stroke', node.active ? 'rgba(238, 238, 228, 0.92)' : 'rgba(238, 238, 228, 0.16)') - .attr('stroke-width', node.active ? 2.6 : 1.4) - .attr('stroke-linecap', 'round'); - if (node.active) { - seg.append('animate') - .attr('attributeName', 'stroke-opacity') - .attr('values', '0.55;0.95;0.55') - .attr('dur', '2.6s') - .attr('begin', `${(idx % 5) * 0.32}s`) - .attr('repeatCount', 'indefinite'); - } - }); +function dossierVerdict(processData, fp, kernelThread) { + if (kernelThread) { + return 'Kernel thread — no userspace address space or file table.'; + } + const isolated = fp ? Number(fp.isolated_count || 0) : 0; + if (isolated > 0) { + return `Containerized — ${isolated} of ${fp.total} namespaces isolated from host.`; + } + if (fp && Number(fp.readable || 0) === 0) { + return 'Host process — namespace table not readable at this privilege.'; + } + if (fp) { + return `Host process — shares all ${fp.total} namespaces with the host.`; + } + return 'Reading namespace table…'; +} - // Activity gauge readout at the top gap of the arc. - group.append('text') - .attr('x', moduleCx) - .attr('y', moduleCy - arcR - 5) - .attr('text-anchor', 'middle') - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', '6px') - .attr('fill', 'rgba(20, 20, 20, 0.6)') - .text(`◆ ${activeCount}/${nodes.length}`); +// ── live activity sampling ────────────────────────────────────────────────── +// The API only exposes cumulative counters, so rates come from differencing two +// samples client-side. Nothing is drawn until a second sample lands. +const DOSSIER_SAMPLE_INTERVAL_MS = 2000; +const DOSSIER_SAMPLE_LIMIT = 31; +const ACTIVITY_BAR_SLOTS = 22; +const ACTIVITY_METRICS = [ + { + id: 'ctx', + label: 'ctx rate', + rate: (a, b, dt) => { + const prev = numOrNull(a.ctx_voluntary, a.ctx_nonvoluntary); + const next = numOrNull(b.ctx_voluntary, b.ctx_nonvoluntary); + return prev === null || next === null ? null : (next - prev) / dt; + }, + format: (v) => `${v < 10 ? v.toFixed(1) : Math.round(v)}/s`, + unavailable: 'counter not readable' + }, + { + id: 'cpu', + label: 'cpu burn', + rate: (a, b, dt) => { + const prev = numOrNull(a.cpu_user, a.cpu_system); + const next = numOrNull(b.cpu_user, b.cpu_system); + return prev === null || next === null ? null : ((next - prev) / dt) * 100; + }, + format: (v) => `${v < 10 ? v.toFixed(1) : Math.round(v)}%`, + unavailable: 'cpu times denied' + }, + { + id: 'io', + label: 'disk i/o', + rate: (a, b, dt) => { + const prev = numOrNull(a.read_bytes, a.write_bytes); + const next = numOrNull(b.read_bytes, b.write_bytes); + return prev === null || next === null ? null : (next - prev) / dt; + }, + format: (v) => formatByteRate(v), + unavailable: 'io counters denied' } +]; - group.append('circle') - .attr('cx', moduleCx) - .attr('cy', moduleCy) - .attr('r', moduleR - 18) - .attr('fill', 'none') - .attr('stroke', anchor ? 'rgba(238, 238, 228, 0.22)' : 'rgba(28, 28, 28, 0.18)') - .attr('stroke-width', anchor ? 1.05 : 0.8); +// Sums the arguments, or reports null when any part is missing. +function numOrNull(...values) { + let total = 0; + for (const value of values) { + if (value === null || value === undefined) return null; + total += Number(value); + } + return total; +} - group.append('circle') - .attr('cx', moduleCx) - .attr('cy', moduleCy) - .attr('r', anchor ? 26 : 22) - .attr('fill', anchor ? 'rgba(238, 238, 228, 0.92)' : 'rgba(32, 32, 32, 0.86)') - .attr('stroke', anchor ? 'rgba(0, 0, 0, 0.86)' : 'rgba(20, 20, 20, 0.72)') - .attr('stroke-width', 1.2); +function formatByteRate(bytesPerSec) { + const v = Math.max(0, Number(bytesPerSec) || 0); + if (v < 1024) return `${Math.round(v)} B/s`; + if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} KB/s`; + return `${(v / (1024 * 1024)).toFixed(1)} MB/s`; +} - group.append('text') - .attr('x', moduleCx) - .attr('y', moduleCy - 2) - .attr('text-anchor', 'middle') - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', '7.5px') - .attr('font-weight', '700') - .attr('fill', anchor ? '#161616' : '#f1f1ec') - .text(String(processData.name || 'PROC').slice(0, 8).toUpperCase()); +function activitySeries(metric, samples) { + const series = []; + for (let i = 1; i < samples.length; i += 1) { + const dt = Math.max(0.2, Number(samples[i].ts) - Number(samples[i - 1].ts)); + const rate = metric.rate(samples[i - 1], samples[i], dt); + // Counters only climb; a negative step means the pid was recycled. + series.push(rate === null || rate < 0 ? null : rate); + } + return series.slice(-ACTIVITY_BAR_SLOTS); +} - group.append('text') - .attr('x', moduleCx) - .attr('y', moduleCy + 11) - .attr('text-anchor', 'middle') - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', '6.5px') - .attr('fill', anchor ? '#333' : '#c9c9c0') - .text(`PID ${formatProcessValue(processData.pid)}`); +let dossierActivityTimer = null; - group.append('text') - .attr('x', moduleCx - moduleR) - .attr('y', moduleCy - moduleR - 12) - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', '8px') - .attr('font-weight', '700') - .attr('fill', anchor ? 'rgba(20, 20, 20, 0.82)' : 'rgba(28, 28, 28, 0.72)') - .text('PROCESS INTERACTION MODULE'); +function startDossierActivityPolling(pid) { + stopDossierActivityPolling(); + const tick = () => { + if (!pinnedProcessDossier || pinnedProcessDossier.process?.pid !== pid) { + stopDossierActivityPolling(); + return; + } + if (document.hidden) return; + fetch(`/api/process/${pid}/activity`) + .then((r) => r.json()) + .then((sample) => { + if (!pinnedProcessDossier || pinnedProcessDossier.process?.pid !== pid) return; + if (!sample || sample.error) return; + const samples = pinnedProcessDossier.samples || (pinnedProcessDossier.samples = []); + samples.push(sample); + if (samples.length > DOSSIER_SAMPLE_LIMIT) samples.shift(); + drawActivityRows(); + }) + .catch(() => {}); + }; + tick(); + dossierActivityTimer = setInterval(tick, DOSSIER_SAMPLE_INTERVAL_MS); +} - group.append('text') - .attr('x', moduleCx + moduleR - 2) - .attr('y', moduleCy - moduleR - 12) +function stopDossierActivityPolling() { + if (dossierActivityTimer) { + clearInterval(dossierActivityTimer); + dossierActivityTimer = null; + } +} + +// Redraws only the activity rows so polling never restarts the rest of the +// dossier (the containment halo animates, and a full redraw would reset it). +function drawActivityRows() { + const host = d3.select('.process-dossier-layer').select('.dossier-activity-rows'); + if (host.empty() || !pinnedProcessDossier || !pinnedProcessDossier.activityBox) return; + host.selectAll('*').remove(); + + const box = pinnedProcessDossier.activityBox; + const samples = pinnedProcessDossier.samples || []; + + // Drawn here rather than in the card header so the span stays current. + const spanS = samples.length > 1 + ? Math.round(Number(samples[samples.length - 1].ts) - Number(samples[0].ts)) + : 0; + host.append('text') + .attr('x', box.x + box.w - 13).attr('y', box.y + 16) .attr('text-anchor', 'end') - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', '7px') - .attr('fill', anchor ? 'rgba(20, 20, 20, 0.58)' : 'rgba(28, 28, 28, 0.52)') - .text(`ACTIVE ${activeCount}/${nodes.length}`); - - nodes.forEach((node, idx) => { - const angle = -Math.PI / 2 + (idx / nodes.length) * Math.PI * 2; - const nx = moduleCx + Math.cos(angle) * (moduleR + 8); - const ny = moduleCy + Math.sin(angle) * (moduleR + 8); - const labelOffset = Math.cos(angle) >= 0 ? 14 : -14; - const labelX = nx + labelOffset; - const labelAnchor = Math.cos(angle) >= 0 ? 'start' : 'end'; - const nodeFill = anchor - ? (node.active ? 'rgba(238, 238, 228, 0.96)' : 'rgba(28, 28, 28, 0.92)') - : (node.active ? 'rgba(28, 28, 28, 0.86)' : 'rgba(248, 248, 240, 0.72)'); - const nodeStroke = anchor - ? (node.active ? 'rgba(238, 238, 228, 0.84)' : 'rgba(238, 238, 228, 0.18)') - : 'rgba(28, 28, 28, 0.44)'; - const labelFill = anchor - ? (node.active ? 'rgba(238, 238, 228, 0.95)' : '#222') - : (node.active ? '#f1f1ec' : '#282828'); - const valueFill = anchor - ? (node.active ? 'rgba(238, 238, 228, 0.72)' : 'rgba(42, 42, 42, 0.62)') - : (node.active ? '#c9c9c0' : 'rgba(28, 28, 28, 0.55)'); - - group.append('line') - .attr('x1', moduleCx + Math.cos(angle) * 28) - .attr('y1', moduleCy + Math.sin(angle) * 28) - .attr('x2', nx) - .attr('y2', ny) - .attr('stroke', anchor - ? (node.active ? 'rgba(238, 238, 228, 0.36)' : 'rgba(238, 238, 228, 0.11)') - : (node.active ? 'rgba(28, 28, 28, 0.42)' : 'rgba(28, 28, 28, 0.16)')) - .attr('stroke-width', node.active ? 1.05 : 0.65); - - if (anchor && node.active) { - // Breathing pulse halo so active channels read as live telemetry. - const pulse = group.append('circle') - .attr('cx', nx) - .attr('cy', ny) - .attr('r', 7.8) - .attr('fill', 'none') - .attr('stroke', 'rgba(238, 238, 228, 0.5)') - .attr('stroke-width', 1.1); - const phase = `${(idx % 5) * 0.32}s`; - pulse.append('animate') - .attr('attributeName', 'r') - .attr('values', '7.8;14;7.8') - .attr('dur', '2.6s') - .attr('begin', phase) - .attr('repeatCount', 'indefinite'); - pulse.append('animate') - .attr('attributeName', 'stroke-opacity') - .attr('values', '0.55;0;0.55') - .attr('dur', '2.6s') - .attr('begin', phase) - .attr('repeatCount', 'indefinite'); + .attr('font-family', DOSSIER.mono) + .attr('font-size', '9px') + .attr('letter-spacing', '1') + .attr('fill', DOSSIER.dim) + .text(spanS > 0 ? `mean over ${spanS}s` : 'starting…'); + const barW = 4; + const barGap = 1; + const stripW = ACTIVITY_BAR_SLOTS * (barW + barGap); + const stripX = box.x + box.w - 82 - stripW; + const maxBarH = 11; + + ACTIVITY_METRICS.forEach((metric, idx) => { + const y = box.y + 42 + idx * 18; + + host.append('text') + .attr('x', box.x + 16).attr('y', y) + .attr('font-family', DOSSIER.mono) + .attr('font-size', '9px') + .attr('fill', DOSSIER.dim) + .text(metric.label); + + if (samples.length < 2) { + host.append('text') + .attr('x', box.x + box.w - 14).attr('y', y) + .attr('text-anchor', 'end') + .attr('font-family', DOSSIER.mono) + .attr('font-size', '9px') + .attr('fill', DOSSIER.faint) + .text(samples.length ? 'sampling…' : 'waiting…'); + return; } - const nodeCircle = group.append('circle') - .attr('cx', nx) - .attr('cy', ny) - .attr('r', node.active ? 7.8 : 6.2) - .attr('fill', nodeFill) - .attr('stroke', nodeStroke) - .attr('stroke-width', anchor && node.active ? 1.2 : 0.8); - - if (anchor && node.active) { - // Subtle core breathing keeps the node itself alive, not just its halo. - const corePhase = `${(idx % 5) * 0.32}s`; - nodeCircle.append('animate') - .attr('attributeName', 'r') - .attr('values', '7.2;8.4;7.2') - .attr('dur', '2.6s') - .attr('begin', corePhase) - .attr('repeatCount', 'indefinite'); + const series = activitySeries(metric, samples); + const known = series.filter((v) => v !== null); + if (!known.length) { + host.append('text') + .attr('x', box.x + box.w - 14).attr('y', y) + .attr('text-anchor', 'end') + .attr('font-family', DOSSIER.mono) + .attr('font-size', '9px') + .attr('fill', DOSSIER.faint) + .text(metric.unavailable); + return; } - group.append('rect') - .attr('x', Math.cos(angle) >= 0 ? nx + 10 : nx - 80) - .attr('y', ny - 9) - .attr('width', 70) - .attr('height', 18) - .attr('rx', 9) - .attr('fill', anchor - ? (node.active ? 'rgba(18, 18, 18, 0.92)' : 'rgba(238, 238, 228, 0.82)') - : (node.active ? 'rgba(28, 28, 28, 0.86)' : 'rgba(248, 248, 240, 0.58)')) - .attr('stroke', anchor ? 'rgba(238, 238, 228, 0.34)' : 'rgba(28, 28, 28, 0.22)') - .attr('stroke-width', anchor ? 0.9 : 0.7); + // Each row scales to its own peak; absolute units live in the readout. + const peak = Math.max(...known); + series.forEach((value, i) => { + const x = stripX + (ACTIVITY_BAR_SLOTS - series.length + i) * (barW + barGap); + if (value === null) return; + const h = peak > 0 ? Math.max(1, (value / peak) * maxBarH) : 1; + host.append('rect') + .attr('x', x).attr('y', y - 2 - h) + .attr('width', barW).attr('height', h) + .attr('fill', value > 0 ? DOSSIER.accent : 'rgba(244, 244, 236, 0.16)'); + }); - group.append('text') - .attr('x', labelX) - .attr('y', ny - 1) - .attr('text-anchor', labelAnchor) - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', anchor ? '8px' : '7.5px') - .attr('font-weight', '700') - .attr('fill', labelFill) - .text(node.label); + // Bursty processes read 0 on any single sample, so the readout is the + // window mean and the bars carry the per-sample shape. + const mean = known.reduce((sum, v) => sum + v, 0) / known.length; + host.append('text') + .attr('x', box.x + box.w - 14).attr('y', y) + .attr('text-anchor', 'end') + .attr('font-family', DOSSIER.mono) + .attr('font-size', '11px') + .attr('fill', mean > 0 ? DOSSIER.accent : DOSSIER.faint) + .text(metric.format(mean)); + }); +} - group.append('text') - .attr('x', labelX) - .attr('y', ny + 8) - .attr('text-anchor', labelAnchor) - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', anchor ? '6.4px' : '6px') - .attr('fill', valueFill) - .text(String(node.value).slice(0, 8)); +// Ancestry rows for the lineage card: init first, the process itself last. +// Each row carries the gap since its parent started, so the card reads as a +// spawn chronology rather than repeating the same age on every line. +// Long chains collapse in the middle so the card never outgrows the viewport. +function lineageRows(lineage, maxRows) { + const chain = lineage && Array.isArray(lineage.chain) ? lineage.chain : []; + if (!chain.length) return []; + + const withDelta = chain.map((row, i) => { + const prev = i > 0 ? chain[i - 1] : null; + const delta = prev && row.create_time && prev.create_time + ? row.create_time - prev.create_time + : null; + return { row, delta }; }); + + if (withDelta.length <= maxRows) return withDelta; + return [ + withDelta[0], + { gap: withDelta.length - (maxRows - 1) }, + ...withDelta.slice(-(maxRows - 2)) + ]; +} + +function formatSpawnDelta(seconds) { + const s = Number(seconds); + if (!Number.isFinite(s)) return ''; + if (s < 1) return '+<1s'; + if (s < 90) return `+${Math.round(s)}s`; + if (s < 5400) return `+${(s / 60).toFixed(1)}m`; + if (s < 172800) return `+${(s / 3600).toFixed(1)}h`; + return `+${(s / 86400).toFixed(1)}d`; +} + +function formatProcessAge(seconds) { + const s = Number(seconds || 0); + if (!Number.isFinite(s) || s <= 0) return '—'; + if (s < 60) return `${Math.round(s)}s`; + if (s < 3600) return `${Math.round(s / 60)}m`; + if (s < 86400) return `${(s / 3600).toFixed(1)}h`; + return `${(s / 86400).toFixed(1)}d`; +} + +function formatStartClock(createTime) { + if (!createTime) return '—'; + const d = new Date(Number(createTime) * 1000); + if (Number.isNaN(d.getTime())) return '—'; + const pad = (n) => String(n).padStart(2, '0'); + return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } function renderProcessDossier() { d3.selectAll('.process-dossier-layer').remove(); if (!pinnedProcessDossier || !pinnedProcessDossier.process || !pinnedProcessDossier.anchor) return; + ensureDossierDefs(); + const width = window.innerWidth; const height = window.innerHeight; const processData = pinnedProcessDossier.process; const anchor = pinnedProcessDossier.anchor; - const ioSummary = processIoSummary(processData, pinnedProcessDossier.details); - const controlRows = processControlStrip(processData, pinnedProcessDossier.details); - const threadsData = pinnedProcessDossier.details?.threadsData || {}; - const cpuData = pinnedProcessDossier.details?.cpuData || {}; - const menuW = Math.min(760, width - 220); - const menuH = 118; - const menuX = Math.max(120, width / 2 - menuW / 2); - const menuY = Math.min(height - menuH - 34, Math.max(height * 0.68, anchor.y + 210)); + const details = pinnedProcessDossier.details || {}; + const fdsData = details.fdsData || null; + const threadsData = details.threadsData || {}; + const cpuData = details.cpuData || {}; + const lineage = details.lineage || null; + const fp = fdsData ? (fdsData.namespace_fingerprint || null) : null; + const io = processIoSummary(processData, details); + const kernelThread = isKernelThreadProcess(processData, lineage); + const cpuPercent = Number(cpuData.cpu_percent || processData.cpu_percent || 0); + const memoryMb = Number(processData.memory_mb || 0); const threadCount = Number(threadsData.thread_count || processData.num_threads || 0); - const actionRows = [ - { id: 'STATE', value: formatProcessValue(processData.status, 'state'), active: true }, - { id: 'CPU', value: `${cpuPercent}%`, active: cpuPercent > 0 }, - { id: 'FD TABLE', value: `${ioSummary.fds} fd`, active: Number(ioSummary.fds || 0) > 0 }, - { id: 'VFS', value: `${ioSummary.files} files`, active: Number(ioSummary.files || 0) > 0 }, - { id: 'SOCKET', value: `${ioSummary.sockets} conns`, active: Number(ioSummary.sockets || 0) > 0 }, - { id: 'SCHED', value: `${threadCount} th`, active: threadCount > 1 || cpuPercent > 0 }, - { id: 'MEM', value: `${formatProcessValue(processData.memory_mb, 0)} MB`, active: Number(processData.memory_mb || 0) > 1 }, - { id: 'KERNEL', value: 'CONTACT', active: true } - ]; + + const cardW = 358; + const stackX = Math.max(72, Math.min(width * 0.5 - 300, width - cardW - 380)); + const overlap = 6; + + // ── measure every card before placing the stack ──────────────────────── + const nsList = fp && Array.isArray(fp.namespaces) ? fp.namespaces : []; + const showChips = nsList.some((ns) => ns.isolated); + + const heroName = String(processData.name || 'process').toUpperCase().slice(0, 20); + const heroSize = heroName.length > 17 ? 17 : (heroName.length > 13 ? 21 : 26); + const verdictLines = wrapDossierLines(dossierVerdict(processData, fp, kernelThread), 12, cardW - 32, 2); + + const heroRel = 39 + heroSize * 0.76; + const classRel = heroRel + 19; + const ruleRel = classRel + 14; + const labelRel = ruleRel + 17; + const verdictRel = labelRel + 16; + const chipsRel = verdictRel + verdictLines.length * 13 + 4; + const idH = (showChips ? chipsRel + 16 : chipsRel) + 14; + + const vitH = 84; + + const descriptors = fdsData && Array.isArray(fdsData.descriptors) ? fdsData.descriptors : []; + const fdRows = descriptors.slice(0, 6); + const bodyRows = fdRows.length + ? fdRows.map((d) => ({ + key: `fd ${d.fd}`, + mid: String(d.type || 'fd').toLowerCase().slice(0, 10), + // Drop the prefixes the type column already states. + value: elideDescriptorTarget( + descriptorTargetLabel(d) + .replace(/^socket:\[/, 'socket[') + .replace(/^anon_inode:/, ''), + 20 + ) + })) + : schedulingRows(processData, threadsData, cpuData, kernelThread, fdsData); + const fdTotal = Math.max(Number(io.fds || 0), descriptors.length); + const hasFooter = fdRows.length > 0 && fdTotal > fdRows.length; + const fdH = 36 + bodyRows.length * 16 + 10 + (hasFooter ? 14 : 0); + + const actH = 34 + ACTIVITY_METRICS.length * 18 + 8; + + // Activity is live, so it keeps its slot; lineage takes what is left over. + const fixedH = idH + (vitH - overlap) + (fdH - overlap) + (actH - overlap); + const roomForLineage = height - 72 - fixedH - 36; + const maxLineRows = Math.max(0, Math.min(6, Math.floor((roomForLineage - 52) / 20))); + const lineRows = maxLineRows >= 3 ? lineageRows(lineage, maxLineRows) : []; + const lineH = lineRows.length ? 34 + lineRows.length * 20 + 8 : 0; + + const totalH = fixedH + (lineH ? lineH - overlap : 0); + const stackY = Math.max(64, Math.min(height * 0.17, height - totalH - 32)); const layer = svg.append('g') .attr('class', 'process-dossier-layer') .attr('pointer-events', 'none'); + // ── link back to the selected node on the map ────────────────────────── + const linkX = stackX + cardW; + const linkY = stackY + 46; layer.append('path') - .attr('d', `M${anchor.x},${anchor.y} C${anchor.x + 34},${anchor.y + 96} ${menuX + 34},${menuY - 44} ${menuX + 54},${menuY + 52}`) + .attr('d', `M${anchor.x},${anchor.y} C${anchor.x - 60},${anchor.y} ${linkX + 70},${linkY} ${linkX},${linkY}`) .attr('fill', 'none') - .attr('stroke', 'rgba(16, 16, 16, 0.36)') - .attr('stroke-width', 1.05); - - layer.append('text') - .attr('x', (anchor.x + menuX) / 2) - .attr('y', (anchor.y + menuY) / 2) - .attr('text-anchor', 'middle') - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', '8px') - .attr('fill', 'rgba(24, 24, 24, 0.72)') - .text('PROCESS LINK'); - - layer.append('rect') - .attr('x', menuX) - .attr('y', menuY) - .attr('width', menuW) - .attr('height', menuH) - .attr('rx', 6) - .attr('fill', 'rgba(238, 238, 228, 0.68)') - .attr('stroke', 'rgba(24, 24, 24, 0.2)') - .attr('stroke-width', 0.9); - - layer.append('rect') - .attr('x', menuX + 10) - .attr('y', menuY + 10) - .attr('width', menuW - 20) - .attr('height', 26) - .attr('rx', 3) - .attr('fill', 'rgba(24, 24, 24, 0.86)'); - - layer.append('text') - .attr('x', menuX + 20) - .attr('y', menuY + 28) - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', '10px') - .attr('font-weight', '700') - .attr('fill', '#f2f2ea') - .text('PROCESS ACTION MENU'); - - layer.append('text') - .attr('x', menuX + menuW - 20) - .attr('y', menuY + 28) - .attr('text-anchor', 'end') - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', '8px') - .attr('fill', '#cfcfc8') - .text(`${String(processData.name || 'process').slice(0, 18)} · PID ${formatProcessValue(processData.pid)}`); - + .attr('stroke', 'rgba(20, 24, 30, 0.42)') + .attr('stroke-width', 1) + .attr('stroke-dasharray', '4 4'); layer.append('circle') - .attr('cx', anchor.x) - .attr('cy', anchor.y) - .attr('r', 9) + .attr('cx', anchor.x).attr('cy', anchor.y).attr('r', 8.5) .attr('fill', 'none') - .attr('stroke', 'rgba(16, 16, 16, 0.72)') - .attr('stroke-width', 1.1); - - const itemW = (menuW - 44) / 4; - actionRows.forEach((row, idx) => { - const col = idx % 4; - const r = Math.floor(idx / 4); - const x = menuX + 14 + col * itemW; - const y = menuY + 48 + r * 30; - const w = itemW - 8; - layer.append('rect') - .attr('x', x) - .attr('y', y) - .attr('width', w) - .attr('height', 22) - .attr('rx', 11) - .attr('fill', row.active ? 'rgba(24, 24, 24, 0.86)' : 'rgba(255, 255, 255, 0.44)') - .attr('stroke', 'rgba(24, 24, 24, 0.18)') - .attr('stroke-width', row.active ? 1 : 0.7); - - layer.append('circle') - .attr('cx', x + 11) - .attr('cy', y + 11) - .attr('r', 2.2) - .attr('fill', row.active ? '#f2f2ea' : 'rgba(24, 24, 24, 0.46)'); - - layer.append('text') - .attr('x', x + 20) - .attr('y', y + 10) - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', '8px') - .attr('font-weight', '700') - .attr('fill', row.active ? '#f2f2ea' : '#2f2f2f') - .text(row.id); - - layer.append('text') - .attr('x', x + w - 8) - .attr('y', y + 17) - .attr('text-anchor', 'end') - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', '6.5px') - .attr('fill', row.active ? '#cfcfc8' : '#666') - .text(String(row.value).slice(0, 12)); + .attr('stroke', DOSSIER.accent) + .attr('stroke-width', 1.6); + layer.append('circle') + .attr('cx', anchor.x).attr('cy', anchor.y).attr('r', 2.4) + .attr('fill', DOSSIER.accent); + + // ── card 1 · identity ────────────────────────────────────────────────── + const idBox = { x: stackX, y: stackY, w: cardW, h: idH }; + const idCard = dossierCard(layer, idBox, 'PROCESS DOSSIER', `PID ${formatProcessValue(processData.pid)}`); + + idCard.append('text') + .attr('x', idBox.x + 16).attr('y', idBox.y + heroRel) + .attr('font-family', DOSSIER.mono) + .attr('font-size', `${heroSize}px`) + .attr('letter-spacing', '1.2') + .attr('fill', DOSSIER.text) + .text(heroName); + + idCard.append('text') + .attr('x', idBox.x + 16).attr('y', idBox.y + classRel) + .attr('font-family', DOSSIER.mono) + .attr('font-size', '11px') + .attr('letter-spacing', '1.4') + .attr('fill', DOSSIER.dim) + .text(`${kernelThread ? 'KERNEL THREAD' : 'USER PROCESS'} [${String(processData.status || '?').slice(0, 1).toUpperCase()}]`); + + idCard.append('line') + .attr('x1', idBox.x + 16).attr('x2', idBox.x + idBox.w - 16) + .attr('y1', idBox.y + ruleRel).attr('y2', idBox.y + ruleRel) + .attr('stroke', DOSSIER.rule) + .attr('stroke-width', 1); + + idCard.append('text') + .attr('x', idBox.x + 16).attr('y', idBox.y + labelRel) + .attr('font-family', DOSSIER.mono) + .attr('font-size', '9px') + .attr('letter-spacing', '1.2') + .attr('fill', DOSSIER.faint) + .text('ASSESSMENT'); + + verdictLines.forEach((line, i) => { + idCard.append('text') + .attr('x', idBox.x + 16).attr('y', idBox.y + verdictRel + i * 13) + .attr('font-family', DOSSIER.mono) + .attr('font-size', '12px') + .attr('fill', DOSSIER.text) + .text(line); }); - renderFdDescriptorMap(layer, processData, pinnedProcessDossier.details, { - x: menuX + menuW - 258, - y: Math.max(88, menuY - 166), - width: 258, - height: 142, - anchor: { x: menuX + menuW - 42, y: menuY + 12 } - }); + if (showChips) { + const chipW = (idBox.w - 32 - 5 * 5) / 6; + nsList.slice(0, 6).forEach((ns, i) => { + const cx = idBox.x + 16 + i * (chipW + 5); + const cy = idBox.y + chipsRel; + const own = !!ns.isolated; + idCard.append('rect') + .attr('x', cx).attr('y', cy) + .attr('width', chipW).attr('height', 16) + .attr('rx', 3) + .attr('fill', own ? 'rgba(226, 163, 62, 0.18)' : 'rgba(255, 255, 255, 0.04)') + .attr('stroke', own ? DOSSIER.accent : DOSSIER.edge) + .attr('stroke-width', own ? 1 : 0.8) + .style('pointer-events', 'all') + .style('cursor', 'help') + .on('mouseenter', (event) => showNamespaceCellTooltip(event, ns)) + .on('mousemove', (event) => { + d3.selectAll('.ns-fp-tooltip') + .style('left', `${event.pageX + 12}px`) + .style('top', `${event.pageY - 10}px`); + }) + .on('mouseleave', () => d3.selectAll('.ns-fp-tooltip').remove()); + + idCard.append('text') + .attr('x', cx + chipW / 2).attr('y', cy + 11) + .attr('text-anchor', 'middle') + .attr('font-family', DOSSIER.mono) + .attr('font-size', '9px') + .attr('fill', own ? DOSSIER.accent : DOSSIER.dim) + .text(ns.label || String(ns.id || '').toUpperCase()); + }); + } + + // ── card 2 · vitals ──────────────────────────────────────────────────── + const vitBox = { x: stackX + 30, y: stackY + idH - overlap, w: cardW - 26, h: vitH }; + const vitCard = dossierCard(layer, vitBox, 'RESOURCES', kernelThread ? 'kernel space' : 'userspace'); - renderNamespaceFingerprint(layer, processData, pinnedProcessDossier.details, { - x: menuX, - y: Math.max(88, menuY - 150), - width: 226, - height: 126, - anchor: { x: menuX + 28, y: menuY + 12 } + // Amber marks a live value, grey a zero — the row reads at a glance. + const vitals = [ + { label: 'CPU', value: `${Math.round(cpuPercent)}`, unit: '%', live: cpuPercent > 0 }, + { + label: 'MEMORY', + value: memoryMb >= 100 ? `${Math.round(memoryMb)}` : memoryMb.toFixed(1), + unit: 'MB', + live: memoryMb > 0, + open: window.MemoryCard ? MemoryCard.open : null + }, + // The count is a door: the threads are what the kernel schedules, and + // the card behind it says what each of them is doing. + { + label: 'THREADS', + value: `${threadCount}`, + unit: '', + live: threadCount > 0, + open: threadCount > 0 && window.ThreadsCard ? ThreadsCard.open : null + }, + // Named for what it is: inet connections, not every socket fd. + { label: 'NET CONNS', value: `${io.sockets}`, unit: '', live: Number(io.sockets || 0) > 0 } + ]; + const colW = (vitBox.w - 28) / vitals.length; + vitals.forEach((v, i) => { + const cx = vitBox.x + 14 + i * colW; + vitCard.append('text') + .attr('x', cx).attr('y', vitBox.y + 56) + .attr('font-family', DOSSIER.mono) + .attr('font-size', '20px') + .attr('fill', v.live ? DOSSIER.accent : DOSSIER.faint) + .text(v.value); + if (v.unit) { + vitCard.append('text') + .attr('x', cx + String(v.value).length * 12 + 2).attr('y', vitBox.y + 56) + .attr('font-family', DOSSIER.mono) + .attr('font-size', '9px') + .attr('fill', v.live ? 'rgba(226, 163, 62, 0.7)' : DOSSIER.faint) + .text(v.unit); + } + const label = vitCard.append('text') + .attr('x', cx).attr('y', vitBox.y + 72) + .attr('font-family', DOSSIER.mono) + .attr('font-size', '9px') + .attr('letter-spacing', '1.2') + .attr('fill', DOSSIER.dim) + .text(v.label); + + if (!v.open) return; + const labelW = v.label.length * 6.6; + const rule = vitCard.append('line') + .attr('x1', cx).attr('x2', cx + labelW) + .attr('y1', vitBox.y + 76).attr('y2', vitBox.y + 76) + .attr('stroke', DOSSIER.accent) + .attr('stroke-width', 1) + .attr('opacity', 0.35); + vitCard.append('rect') + .attr('x', cx - 8).attr('y', vitBox.y + 36) + .attr('width', Math.max(46, colW - 6)).attr('height', 44) + .attr('fill', 'transparent') + .style('pointer-events', 'all') + .style('cursor', 'pointer') + .on('mouseenter', () => { + rule.attr('opacity', 1); + label.attr('fill', DOSSIER.accent); + }) + .on('mouseleave', () => { + rule.attr('opacity', 0.35); + label.attr('fill', DOSSIER.dim); + }) + .on('click', (event) => { + event.stopPropagation(); + v.open(processData.pid, { + x: cx + 20, + y: vitBox.y + 56, + // Each card of the stack is inset further right than the + // one above it; clear the widest of them. + clearOf: stackX + cardW + 34 + }); + }); }); - const nsFingerprint = pinnedProcessDossier.details?.fdsData?.namespace_fingerprint; - const containmentPeers = nsFingerprint && Array.isArray(nsFingerprint.peer_pids) - ? nsFingerprint.peer_pids - : []; - if (containmentPeers.length) { - drawContainmentHalo(layer, processData, containmentPeers); - } -} + // ── card 3 · descriptors when readable, otherwise scheduling ─────────── + // /proc//fd is unreadable for foreign processes at our privilege, so + // rather than a permanently empty table we fall back to data we do have. + const fdBox = { x: stackX + 60, y: vitBox.y + vitBox.h - overlap, w: cardW - 52, h: fdH }; + const fdCard = dossierCard( + layer, + fdBox, + fdRows.length ? 'DESCRIPTOR TABLE' : 'SCHEDULING', + fdRows.length + ? `${formatProcessValue(io.fds, 0)} fd` + : (kernelThread ? 'kernel task' : `nice ${formatProcessValue(cpuData.nice, '—')}`) + ); -function renderFdDescriptorMap(layer, processData, details = {}, box) { - const fdsData = details.fdsData || {}; - const descriptors = Array.isArray(fdsData.descriptors) ? fdsData.descriptors : []; - const rows = descriptors.length - ? descriptors.slice(0, 8) - : [ - { fd: 0, type: 'stdin', target: 'loading' }, - { fd: 1, type: 'stdout', target: 'loading' }, - { fd: 2, type: 'stderr', target: 'loading' } - ]; + bodyRows.forEach((row, idx) => { + const y = fdBox.y + 42 + idx * 16; + fdCard.append('text') + .attr('x', fdBox.x + 16).attr('y', y) + .attr('font-family', DOSSIER.mono) + .attr('font-size', '11px') + .attr('fill', DOSSIER.dim) + .text(row.key); + + if (row.mid) { + fdCard.append('text') + .attr('x', fdBox.x + 74).attr('y', y) + .attr('font-family', DOSSIER.mono) + .attr('font-size', '11px') + .attr('fill', DOSSIER.text) + .text(row.mid); + } - layer.append('path') - .attr('d', `M${box.anchor.x},${box.anchor.y} C${box.anchor.x - 18},${box.anchor.y - 42} ${box.x + box.width - 20},${box.y + box.height + 18} ${box.x + box.width - 32},${box.y + box.height - 4}`) - .attr('fill', 'none') - .attr('stroke', 'rgba(20, 20, 20, 0.22)') - .attr('stroke-width', 0.8); - - layer.append('rect') - .attr('x', box.x) - .attr('y', box.y) - .attr('width', box.width) - .attr('height', box.height) - .attr('rx', 6) - .attr('fill', 'rgba(238, 238, 228, 0.76)') - .attr('stroke', 'rgba(24, 24, 24, 0.18)') - .attr('stroke-width', 0.85); - - layer.append('rect') - .attr('x', box.x + 8) - .attr('y', box.y + 8) - .attr('width', box.width - 16) - .attr('height', 22) - .attr('rx', 3) - .attr('fill', 'rgba(24, 24, 24, 0.84)'); - - layer.append('text') - .attr('x', box.x + 16) - .attr('y', box.y + 23) - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', '8.5px') - .attr('font-weight', '700') - .attr('fill', '#f2f2ea') - .text('FD DESCRIPTOR MAP'); - - layer.append('text') - .attr('x', box.x + box.width - 16) - .attr('y', box.y + 23) - .attr('text-anchor', 'end') - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', '6.5px') - .attr('fill', '#cfcfc8') - .text(`PID ${formatProcessValue(processData.pid)}`); - - rows.forEach((descriptor, idx) => { - const y = box.y + 42 + idx * 12; - const tone = descriptorTone(descriptor.type); - const fdLabel = `fd ${formatProcessValue(descriptor.fd)}`; - const typeLabel = String(descriptor.type || 'descriptor').toUpperCase().slice(0, 11); - const targetLabel = descriptorTargetLabel(descriptor).replace(/^socket:\[/, 'socket[').slice(0, 22); - - layer.append('line') - .attr('x1', box.x + 16) - .attr('y1', y + 4) - .attr('x2', box.x + box.width - 16) - .attr('y2', y + 4) - .attr('stroke', 'rgba(24, 24, 24, 0.08)') - .attr('stroke-width', 0.6); - - layer.append('circle') - .attr('cx', box.x + 18) - .attr('cy', y) - .attr('r', 2.2) - .attr('fill', tone); - - layer.append('text') - .attr('x', box.x + 28) - .attr('y', y + 2.5) - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', '7px') - .attr('font-weight', '700') - .attr('fill', '#272727') - .text(fdLabel); - - layer.append('text') - .attr('x', box.x + 74) - .attr('y', y + 2.5) - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', '7px') - .attr('fill', tone) - .text(typeLabel); - - layer.append('text') - .attr('x', box.x + box.width - 16) - .attr('y', y + 2.5) + fdCard.append('text') + .attr('x', fdBox.x + fdBox.w - 14).attr('y', y) .attr('text-anchor', 'end') - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', '6.3px') - .attr('fill', 'rgba(36, 36, 36, 0.62)') - .text(targetLabel); + .attr('font-family', DOSSIER.mono) + .attr('font-size', '11px') + .attr('fill', row.mid ? DOSSIER.faint : DOSSIER.text) + .text(row.value); }); - if (descriptors.length > rows.length) { - layer.append('text') - .attr('x', box.x + box.width - 16) - .attr('y', box.y + box.height - 10) + if (hasFooter) { + fdCard.append('text') + .attr('x', fdBox.x + fdBox.w - 14).attr('y', fdBox.y + fdBox.h - 9) .attr('text-anchor', 'end') - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', '6.5px') - .attr('fill', 'rgba(36, 36, 36, 0.56)') - .text(`+${descriptors.length - rows.length} more descriptors`); + .attr('font-family', DOSSIER.mono) + .attr('font-size', '9px') + .attr('fill', DOSSIER.faint) + .text(`showing ${fdRows.length} of ${fdTotal}`); } -} -const NS_FINGERPRINT_PLACEHOLDER = [ - { id: 'mnt', label: 'MNT', isolated: false }, - { id: 'pid', label: 'PID', isolated: false }, - { id: 'net', label: 'NET', isolated: false }, - { id: 'ipc', label: 'IPC', isolated: false }, - { id: 'uts', label: 'UTS', isolated: false }, - { id: 'user', label: 'USER', isolated: false } -]; + // ── card 4 · live activity ───────────────────────────────────────────── + const actBox = { x: stackX + 90, y: fdBox.y + fdBox.h - overlap, w: cardW - 78, h: actH }; + pinnedProcessDossier.activityBox = actBox; + dossierCard(layer, actBox, 'ACTIVITY', null); + layer.append('g').attr('class', 'dossier-activity-rows'); + drawActivityRows(); + + // ── card 5 · lineage ─────────────────────────────────────────────────── + // Real kernel start times walked up the parent chain, oldest at the top. + if (lineRows.length) { + const lineBox = { x: stackX + 90, y: actBox.y + actBox.h - overlap, w: cardW - 78, h: lineH }; + const childMeta = lineage.child_count + ? `${lineage.child_count} child${lineage.child_count === 1 ? '' : 'ren'}` + : `age ${formatProcessAge(lineage.age_s)}`; + const lineCard = dossierCard(layer, lineBox, 'LINEAGE', childMeta); + + const spineX = lineBox.x + 22; + const firstY = lineBox.y + 44; + const lastY = firstY + (lineRows.length - 1) * 20; + lineCard.append('line') + .attr('x1', spineX).attr('x2', spineX) + .attr('y1', firstY - 4).attr('y2', lastY - 4) + .attr('stroke', DOSSIER.edge) + .attr('stroke-width', 1); -function renderNamespaceFingerprint(layer, processData, details = {}, box) { - const fdsData = details.fdsData || {}; - const fp = fdsData.namespace_fingerprint || null; - const nsList = fp && Array.isArray(fp.namespaces) ? fp.namespaces : NS_FINGERPRINT_PLACEHOLDER; - const isolatedCount = fp ? Number(fp.isolated_count || 0) : 0; - const total = fp ? Number(fp.total || nsList.length) : nsList.length; - const verdict = fp ? String(fp.verdict || '') : 'reading…'; + lineRows.forEach((entry, idx) => { + const y = firstY + idx * 20; + + if (entry.gap) { + lineCard.append('text') + .attr('x', spineX - 4).attr('y', y - 1) + .attr('text-anchor', 'middle') + .attr('font-family', DOSSIER.mono) + .attr('font-size', '11px') + .attr('fill', DOSSIER.faint) + .text('⋮'); + lineCard.append('text') + .attr('x', spineX + 18).attr('y', y - 1) + .attr('font-family', DOSSIER.mono) + .attr('font-size', '9px') + .attr('fill', DOSSIER.faint) + .text(`${entry.gap} more ancestors`); + return; + } - layer.append('path') - .attr('d', `M${box.anchor.x},${box.anchor.y} C${box.anchor.x + 18},${box.anchor.y - 42} ${box.x + 24},${box.y + box.height + 18} ${box.x + 32},${box.y + box.height - 4}`) - .attr('fill', 'none') - .attr('stroke', 'rgba(20, 20, 20, 0.22)') - .attr('stroke-width', 0.8); - - layer.append('rect') - .attr('x', box.x) - .attr('y', box.y) - .attr('width', box.width) - .attr('height', box.height) - .attr('rx', 6) - .attr('fill', 'rgba(238, 238, 228, 0.76)') - .attr('stroke', 'rgba(24, 24, 24, 0.18)') - .attr('stroke-width', 0.85); - - layer.append('rect') - .attr('x', box.x + 8) - .attr('y', box.y + 8) - .attr('width', box.width - 16) - .attr('height', 22) - .attr('rx', 3) - .attr('fill', 'rgba(24, 24, 24, 0.84)'); - - layer.append('text') - .attr('x', box.x + 16) - .attr('y', box.y + 23) - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', '8.5px') - .attr('font-weight', '700') - .attr('fill', '#f2f2ea') - .text('NAMESPACE ISOLATION'); - - layer.append('text') - .attr('x', box.x + box.width - 16) - .attr('y', box.y + 23) - .attr('text-anchor', 'end') - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', '7px') - .attr('font-weight', '700') - .attr('fill', isolatedCount > 0 ? '#9fe0c2' : '#cfcfc8') - .text(`${isolatedCount}/${total}`); - - layer.append('text') - .attr('x', box.x + 16) - .attr('y', box.y + 42) - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', '6.8px') - .attr('fill', isolatedCount > 0 ? 'rgba(20, 90, 64, 0.9)' : 'rgba(36, 36, 36, 0.6)') - .text(verdict.toUpperCase()); - - const cols = 3; - const gap = 7; - const cellW = (box.width - 32 - gap * (cols - 1)) / cols; - const cellH = 24; - const gridY = box.y + 52; - - nsList.slice(0, 6).forEach((ns, i) => { - const c = i % cols; - const r = Math.floor(i / cols); - const x = box.x + 16 + c * (cellW + gap); - const y = gridY + r * (cellH + 7); - const isolated = !!ns.isolated; - - layer.append('rect') - .attr('x', x) - .attr('y', y) - .attr('width', cellW) - .attr('height', cellH) - .attr('rx', 4) - .attr('fill', isolated ? 'rgba(24, 24, 24, 0.86)' : 'rgba(255, 255, 255, 0.5)') - .attr('stroke', isolated ? 'rgba(20, 90, 64, 0.55)' : 'rgba(24, 24, 24, 0.16)') - .attr('stroke-width', isolated ? 1 : 0.7) - .style('pointer-events', fp ? 'all' : 'none') - .style('cursor', fp ? 'help' : 'default') - .on('mouseenter', (event) => showNamespaceCellTooltip(event, ns)) - .on('mousemove', (event) => { - d3.selectAll('.ns-fp-tooltip') - .style('left', `${event.pageX + 12}px`) - .style('top', `${event.pageY - 10}px`); - }) - .on('mouseleave', () => d3.selectAll('.ns-fp-tooltip').remove()); - - // Filled node = own (isolated) namespace; hollow ring = shares host's. - layer.append('circle') - .attr('cx', x + 9) - .attr('cy', y + cellH / 2) - .attr('r', 3) - .attr('fill', isolated ? '#7fd6b0' : 'none') - .attr('stroke', isolated ? 'none' : 'rgba(24, 24, 24, 0.42)') - .attr('stroke-width', 0.9); - - layer.append('text') - .attr('x', x + 18) - .attr('y', y + cellH / 2 + 2.5) - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', '7.5px') - .attr('font-weight', '700') - .attr('fill', isolated ? '#f2f2ea' : '#2f2f2f') - .text(ns.label || String(ns.id || '').toUpperCase()); - - layer.append('text') - .attr('x', x + cellW - 5) - .attr('y', y + cellH / 2 + 2.5) - .attr('text-anchor', 'end') - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', '5.5px') - .attr('fill', isolated ? '#9fe0c2' : 'rgba(36, 36, 36, 0.45)') - .text(isolated ? 'OWN' : 'host'); + const row = entry.row; + const isSelf = Number(row.pid) === Number(processData.pid); + + lineCard.append('line') + .attr('x1', spineX).attr('x2', spineX + 10) + .attr('y1', y - 4).attr('y2', y - 4) + .attr('stroke', DOSSIER.edge) + .attr('stroke-width', 1); + + lineCard.append('circle') + .attr('cx', spineX).attr('cy', y - 4).attr('r', isSelf ? 3.4 : 2.4) + .attr('fill', isSelf ? DOSSIER.accent : DOSSIER.ink) + .attr('stroke', isSelf ? DOSSIER.accent : DOSSIER.dim) + .attr('stroke-width', 1.1); + + lineCard.append('text') + .attr('x', spineX + 16).attr('y', y) + .attr('font-family', DOSSIER.mono) + .attr('font-size', '11px') + .attr('fill', isSelf ? DOSSIER.accent : DOSSIER.text) + .text(elideDescriptorTarget(String(row.name || '?'), 15)); + + lineCard.append('text') + .attr('x', lineBox.x + lineBox.w - 76).attr('y', y) + .attr('text-anchor', 'end') + .attr('font-family', DOSSIER.mono) + .attr('font-size', '9px') + .attr('fill', DOSSIER.faint) + .text(`pid ${row.pid}`); + + // Root shows its wall clock start; the rest show the spawn gap. + lineCard.append('text') + .attr('x', lineBox.x + lineBox.w - 14).attr('y', y) + .attr('text-anchor', 'end') + .attr('font-family', DOSSIER.mono) + .attr('font-size', '9px') + .attr('fill', isSelf ? 'rgba(226, 163, 62, 0.75)' : DOSSIER.dim) + .text(entry.delta === null || entry.delta === undefined + ? formatStartClock(row.create_time) + : formatSpawnDelta(entry.delta)); + }); + } + + const containmentPeers = fp && Array.isArray(fp.peer_pids) ? fp.peer_pids : []; + if (containmentPeers.length) { + drawContainmentHalo(layer, processData, containmentPeers); + } +} + +// Naive greedy wrap for the assessment line; SVG has no flow text. +function wrapDossierLines(text, fontSize, maxWidth, maxLines) { + const maxChars = Math.max(12, Math.floor(maxWidth / (fontSize * 0.6))); + const lines = []; + let current = ''; + String(text).split(/\s+/).forEach((word) => { + const candidate = current ? `${current} ${word}` : word; + if (candidate.length > maxChars && current) { + lines.push(current); + current = word; + } else { + current = candidate; + } }); + if (current) lines.push(current); - const peerCount = fp ? Number(fp.peer_count || 0) : 0; - const footer = fp - ? (isolatedCount > 0 - ? `CO-RESIDENT: ${peerCount} proc${peerCount === 1 ? '' : 's'}` - : 'CO-RESIDENT: host namespace (shared)') - : 'hover a cell for inode'; - layer.append('text') - .attr('x', box.x + 16) - .attr('y', box.y + box.height - 9) - .attr('font-family', 'Share Tech Mono, monospace') - .attr('font-size', '6.3px') - .attr('fill', peerCount > 0 ? 'rgba(20, 90, 64, 0.85)' : 'rgba(36, 36, 36, 0.5)') - .text(footer); + if (lines.length > maxLines) { + const kept = lines.slice(0, maxLines); + kept[maxLines - 1] = `${kept[maxLines - 1].slice(0, maxChars - 1)}…`; + return kept; + } + return lines; } function showNamespaceCellTooltip(event, ns) { @@ -1498,7 +1555,8 @@ function clearPinnedProcessDossier() { const pinnedPid = pinnedProcessDossier.process?.pid; const isHighlighted = window.__highlightedProcess && window.__highlightedProcess.pid === pinnedPid; pinnedProcessDossier = null; - stopProcessModalTopKeeper(); + processModalTopKeeper.stop(); + stopDossierActivityPolling(); d3.selectAll('.process-modal-scrim').remove(); d3.selectAll('.process-dossier-layer').remove(); d3.selectAll('.process-interaction-module').remove(); @@ -1524,10 +1582,8 @@ function clearPinnedProcessDossier() { // Единый "модальный" скрим под панелями открытого меню процесса. // Мягкая бумажная вуаль с лёгкой виньеткой к краям гасит плотную основную // сцену, чтобы панели читались как сфокусированный слой, а не случайные окна. -function ensureProcessModalScrim() { - if (isMobileLayout()) return; - if (!svg.select('.process-modal-scrim').empty()) return; - +// Общая вуаль для всех накладок (досье, namespace), чтобы фокус читался одинаково. +function ensureFocusVeilGradient() { let defs = svg.select('defs'); if (defs.empty()) defs = svg.append('defs'); if (svg.select('#process-scrim-grad').empty()) { @@ -1538,6 +1594,14 @@ function ensureProcessModalScrim() { grad.append('stop').attr('offset', '68%').attr('stop-color', '#deded8').attr('stop-opacity', 0.58); grad.append('stop').attr('offset', '100%').attr('stop-color', '#d3d3cc').attr('stop-opacity', 0.76); } + return 'url(#process-scrim-grad)'; +} + +function ensureProcessModalScrim() { + if (isMobileLayout()) return; + if (!svg.select('.process-modal-scrim').empty()) return; + + ensureFocusVeilGradient(); const w = window.innerWidth; const h = window.innerHeight; @@ -1554,57 +1618,88 @@ function ensureProcessModalScrim() { // Держим фокус-слой корректным по z-order: любые "живые" элементы (анимация // syscalls и т.п.), дорисованные в svg ПОСЛЕ вуали, задвигаем ПОД неё. Панели // меню (scrim/dossier/module) не трогаем — иначе рестартовали бы их анимации. -let processModalTopObserver = null; - -function buryLiveLayersUnderScrim() { +function buryLiveLayersUnderScrim(scrimClass, overlayClasses) { const svgNode = svg.node(); if (!svgNode) return; - const scrimNode = svgNode.querySelector('.process-modal-scrim'); + const scrimNode = svgNode.querySelector(`.${scrimClass}`); if (!scrimNode) return; - const modalClasses = ['process-modal-scrim', 'process-dossier-layer', 'process-interaction-module']; + const keepAbove = [scrimClass, ...overlayClasses]; const toMove = []; for (let sib = scrimNode.nextSibling; sib; sib = sib.nextSibling) { if (sib.nodeType !== 1) continue; const cl = sib.classList; - if (cl && modalClasses.some(c => cl.contains(c))) continue; + if (cl && keepAbove.some(c => cl.contains(c))) continue; toMove.push(sib); } toMove.forEach(n => svgNode.insertBefore(n, scrimNode)); } -function startProcessModalTopKeeper() { - const svgNode = svg.node(); - if (!svgNode || typeof MutationObserver === 'undefined') return; - buryLiveLayersUnderScrim(); - if (processModalTopObserver) return; - processModalTopObserver = new MutationObserver(() => { - if (!pinnedProcessDossier) return; - processModalTopObserver.disconnect(); - buryLiveLayersUnderScrim(); - processModalTopObserver.observe(svgNode, { childList: true }); - }); - processModalTopObserver.observe(svgNode, { childList: true }); +// Накладки, открытые сейчас: самая старая первой. +// +// A keeper must not drag another overlay's nodes across its scrim, or two of +// them will exchange the same nodes forever and wedge the tab — which is what +// a card opened over a pinned dossier used to do. So an overlay buries the live +// layers of the page and the overlays opened before it, and leaves anything +// opened after it alone: the newest overlay stays on top, and the exchange has +// nowhere to start. +const openOverlays = []; + +// Один сторож на накладку: пока она открыта, живые слои остаются под вуалью. +function createOverlayTopKeeper(scrimClass, overlayClasses, isOpen) { + let observer = null; + const entry = { classes: [scrimClass, ...overlayClasses] }; + const bury = () => { + const mine = openOverlays.indexOf(entry); + const newer = mine === -1 + ? [] + : openOverlays.slice(mine + 1).reduce((all, o) => all.concat(o.classes), []); + buryLiveLayersUnderScrim(scrimClass, overlayClasses.concat(newer)); + }; + return { + start() { + const svgNode = svg.node(); + if (!svgNode || typeof MutationObserver === 'undefined') return; + if (!openOverlays.includes(entry)) openOverlays.push(entry); + bury(); + if (observer) return; + observer = new MutationObserver(() => { + if (!isOpen()) return; + observer.disconnect(); + bury(); + observer.observe(svgNode, { childList: true }); + }); + observer.observe(svgNode, { childList: true }); + }, + stop() { + const mine = openOverlays.indexOf(entry); + if (mine !== -1) openOverlays.splice(mine, 1); + if (observer) { + observer.disconnect(); + observer = null; + } + } + }; } -function stopProcessModalTopKeeper() { - if (processModalTopObserver) { - processModalTopObserver.disconnect(); - processModalTopObserver = null; - } -} +const processModalTopKeeper = createOverlayTopKeeper( + 'process-modal-scrim', + ['process-dossier-layer', 'process-interaction-module'], + () => !!pinnedProcessDossier +); function pinProcessDossier(processData, anchor) { + const samePid = pinnedProcessDossier && pinnedProcessDossier.process?.pid === processData.pid; pinnedProcessDossier = { process: processData, anchor, - details: pinnedProcessDossier && pinnedProcessDossier.process?.pid === processData.pid - ? pinnedProcessDossier.details - : {} + details: samePid ? pinnedProcessDossier.details : {}, + // Counter history is only comparable within one pid. + samples: samePid ? pinnedProcessDossier.samples : [] }; ensureProcessModalScrim(); renderProcessDossier(); - renderProcessInteractionModule(window.innerWidth / 2, window.innerHeight / 2, processData, anchor, pinnedProcessDossier.details); - startProcessModalTopKeeper(); + processModalTopKeeper.start(); + startDossierActivityPolling(processData.pid); if (window.nginxFilesManager && typeof window.nginxFilesManager.highlightProcessFiles === 'function') { window.nginxFilesManager.highlightProcessFiles(processData.pid); } @@ -1612,12 +1707,12 @@ function pinProcessDossier(processData, anchor) { Promise.all([ fetch(`/api/process/${processData.pid}/threads`).then(r => r.json()).catch(() => null), fetch(`/api/process/${processData.pid}/cpu`).then(r => r.json()).catch(() => null), - fetch(`/api/process/${processData.pid}/fds`).then(r => r.json()).catch(() => null) - ]).then(([threadsData, cpuData, fdsData]) => { + fetch(`/api/process/${processData.pid}/fds`).then(r => r.json()).catch(() => null), + fetch(`/api/process/${processData.pid}/lineage`).then(r => r.json()).catch(() => null) + ]).then(([threadsData, cpuData, fdsData, lineage]) => { if (!pinnedProcessDossier || pinnedProcessDossier.process?.pid !== processData.pid) return; - pinnedProcessDossier.details = { threadsData, cpuData, fdsData }; + pinnedProcessDossier.details = { threadsData, cpuData, fdsData, lineage }; renderProcessDossier(); - renderProcessInteractionModule(window.innerWidth / 2, window.innerHeight / 2, processData, anchor, pinnedProcessDossier.details); if (window.nginxFilesManager && typeof window.nginxFilesManager.showProcessFiles === 'function' && fdsData && !fdsData.error) { window.nginxFilesManager.showProcessFiles(processData.pid, fdsData); } @@ -2079,13 +2174,8 @@ function drawTagIcons(centerX, centerY) { // Draw panels function drawPanels(width, height) { - // Left panel - svg.append("rect") - .attr("x", 20) - .attr("y", 20) - .attr("width", 250) - .attr("height", 330) - .attr("class", "feature-panel"); + // The left frame is drawn by the syscall readout instead: its height has to + // follow the number of waiting processes, which changes between polls. // Right top status module, styled as a compact HUD window. const panelWidth = 214; @@ -2807,6 +2897,7 @@ function drawProcessKernelMap2(centerX, centerY) { drawIpcRelationshipRing(centerX, centerY, processAnchorsByName); } else { d3.selectAll('.ipc-ring-layer').remove(); + drawMobileProcessLabels(centerX, centerY, topProcessNames(processes, 4)); } renderProcessDossier(); diff --git a/static/js/memory-card.js b/static/js/memory-card.js new file mode 100644 index 0000000..032942e --- /dev/null +++ b/static/js/memory-card.js @@ -0,0 +1,336 @@ +// The card the MEMORY tile of the process dossier opens. +// +// The tile is one number: how many megabytes of this process are sitting in +// RAM. The card is what that number is made of — the virtual map, the resident +// slice of it, the file the executable came from, the libraries, the heap and +// the stacks of the threads. +// +// Two files under /proc disagree about how complete they can be. The map names +// every area and is refused for a few processes; status always names the +// totals. The card draws the map when it has one and the totals either way, +// and says which of the two it is looking at. +const MemoryCard = (() => { + const W = 600; + const PAD = 14; + const CUT = 15; + const HEADER = 25; + const LINE = 14; + const ROW_STEP = 16; + const FOOTER = 34; + const BAR_H = 8; + const MAX_LIBS = 8; + const MAX_STACKS = 8; + + const KIND_TINT = { + code: "#e2a33e", + library: "rgba(226, 163, 62, 0.55)", + heap: "rgba(244, 244, 236, 0.55)", + stack: "rgba(244, 244, 236, 0.35)", + anonymous: "rgba(244, 244, 236, 0.22)", + file: "rgba(244, 244, 236, 0.18)", + vdso: "rgba(244, 244, 236, 0.12)", + device: "rgba(217, 138, 106, 0.55)", + other: "rgba(244, 244, 236, 0.12)" + }; + + let openPid = null; + let topKeeper = null; + let requestSeq = 0; + + function clip(text, max) { + const value = String(text || ""); + return value.length > max ? `${value.slice(0, max - 1)}…` : value; + } + + function kb(value) { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return "—"; + if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} GB`; + if (n >= 1024) return `${n >= 10240 ? Math.round(n / 1024) : (n / 1024).toFixed(1)} MB`; + return `${Math.round(n)} KB`; + } + + function close() { + openPid = null; + requestSeq += 1; + svg.selectAll(".memory-card-scrim, .memory-card-layer").remove(); + if (topKeeper) topKeeper.stop(); + d3.select("body").on("keydown.memorycard", null); + window.dispatchEvent(new CustomEvent("kcard-closed")); + } + + function open(pid, anchor) { + const key = Number(pid); + if (!Number.isFinite(key)) return; + if (openPid === key) { + close(); + return; + } + close(); + openPid = key; + const seq = ++requestSeq; + fetch(`/api/process/${key}/memory`, { cache: "no-store" }) + .then((r) => r.json()) + .then((data) => { + if (seq !== requestSeq) return; + if (!data || data.error) { + openPid = null; + return; + } + draw(data, anchor); + }) + .catch((err) => { + if (seq !== requestSeq) return; + openPid = null; + if (window.frontendLogger) { + window.frontendLogger.error("memory card failed to draw", { + source: "memory-card", stack: String((err && err.stack) || err) + }); + } + }); + } + + function draw(data, anchor) { + const svgNode = svg.node(); + const viewW = (svgNode && svgNode.clientWidth) || window.innerWidth; + const viewH = (svgNode && svgNode.clientHeight) || window.innerHeight; + const cw = Math.min(W, viewW - 24); + const compact = cw < 460; + + const totals = data.totals || {}; + const kinds = data.kinds || []; + const libraries = (data.libraries || []).slice(0, MAX_LIBS); + const stacks = (data.stacks || []).slice(0, MAX_STACKS); + const sources = data.sources || {}; + const hasMap = !!(sources.maps && sources.maps.available); + const viaCollector = !!(sources.maps && sources.maps.via === "collector"); + const hiddenLibs = Math.max(0, Number(data.library_count || 0) - libraries.length); + const hiddenStacks = Math.max(0, Number(data.stack_count || 0) - stacks.length); + + const notes = []; + if (!hasMap) notes.push("THE MAP ITSELF IS CLOSED — THESE TOTALS ARE FROM /PROC/PID/STATUS"); + + let h = HEADER + 12 + 10; + h += LINE + LINE; // resident / virtual + if (hasMap && kinds.length) h += 16 + LINE + BAR_H + 8 + LINE; + if (data.executable || data.heap_kb) h += 16 + LINE + (data.executable ? LINE : 0) + (data.heap_kb ? LINE : 0); + if (stacks.length) h += 16 + LINE + stacks.length * ROW_STEP + (hiddenStacks ? LINE : 0); + if (libraries.length) h += 16 + LINE + libraries.length * ROW_STEP + (hiddenLibs ? LINE : 0); + if (!hasMap && !kinds.length) h += 16 + LINE + LINE; + if (notes.length) h += 10 + notes.length * LINE; + h += FOOTER; + + const from = anchor && Number.isFinite(anchor.x) ? anchor.x : 300; + let x = Number.isFinite(anchor && anchor.clearOf) ? anchor.clearOf : from + 40; + if (x + cw + 16 > viewW) x = Math.max(12, from - cw - 40); + if (x < 12) x = Math.max(12, viewW - cw - 16); + let y = (anchor && Number.isFinite(anchor.y) ? anchor.y : 120) - 30; + y = Math.max(12, Math.min(viewH - h - 12, y)); + + ensureDossierDefs(); + svg.append("rect") + .attr("class", "memory-card-scrim") + .attr("x", 0).attr("y", 0).attr("width", viewW).attr("height", viewH) + .attr("fill", ensureFocusVeilGradient()) + .style("opacity", 0) + .style("cursor", "pointer") + .on("click", () => close()) + .transition().duration(200).style("opacity", 1); + + const layer = svg.append("g").attr("class", "memory-card-layer"); + if (!topKeeper) { + topKeeper = createOverlayTopKeeper("memory-card-scrim", ["memory-card-layer"], () => openPid !== null); + } + topKeeper.start(); + + if (anchor && Number.isFinite(anchor.x) && Number.isFinite(anchor.y)) { + const connY = Math.max(y + 12, Math.min(y + h - 12, anchor.y)); + layer.append("circle") + .attr("class", "kcard-anchor") + .attr("cx", anchor.x).attr("cy", anchor.y).attr("r", 3); + layer.append("line") + .attr("class", "kcard-conn") + .attr("x1", anchor.x).attr("y1", anchor.y) + .attr("x2", anchor.x).attr("y2", anchor.y) + .transition().duration(220).ease(d3.easeCubicOut) + .attr("x2", x).attr("y2", connY); + } + + const panel = layer.append("g") + .attr("transform", `translate(${x}, ${y})`) + .on("click", (event) => event.stopPropagation()); + + panel.append("path") + .attr("class", "kcard-frame") + .attr("d", dossierCardPath(0, 0, cw, h, CUT)) + .attr("filter", "url(#dossier-drop)") + .attr("transform", `translate(0, ${h / 2}) scale(1, 0.02)`) + .transition().delay(120).duration(200).ease(d3.easeCubicOut) + .attr("transform", "translate(0,0) scale(1,1)"); + + const body = panel.append("g").attr("class", "memory-card-body").style("opacity", 0); + body.transition().delay(250).duration(180).style("opacity", 1); + + const text = (cls, tx, ty, value, anchorEnd) => body.append("text") + .attr("class", cls) + .attr("x", tx).attr("y", ty) + .attr("text-anchor", anchorEnd ? "end" : "start") + .text(value); + + body.append("path") + .attr("class", "kcard-strip") + .attr("d", `M0,0 H${cw - CUT} L${cw},${CUT} V${HEADER} H0 Z`); + body.append("circle") + .attr("class", "kcard-glyph-ring") + .attr("cx", PAD).attr("cy", HEADER / 2).attr("r", 4.2); + body.append("circle") + .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, + `MEMORY · ${String(data.comm || "process").toUpperCase()}`); + text("kcard-meta", cw - 13, HEADER / 2 + 3.5, `PID ${data.pid}`, true) + .style("fill", "rgba(244, 244, 236, 0.5)"); + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", HEADER).attr("x2", cw).attr("y2", HEADER); + + let cy = HEADER + 12 + 10; + + const rss = kb(totals.rss_kb); + const virt = kb(totals.virtual_kb); + const pss = totals.pss_kb != null ? kb(totals.pss_kb) : null; + const reservedKb = Number((kinds.find((k) => k.kind === "reserved") || {}).virtual_kb || 0); + const committed = Number(totals.virtual_kb || 0) - reservedKb; + text("kcard-line", PAD, cy, reservedKb + ? `${rss} resident · ${kb(committed)} committed · ${kb(reservedKb)} reserved` + : `${rss} resident of ${virt} mapped`); + cy += LINE; + const bits = []; + if (pss) bits.push(`${pss} proportional`); + if (totals.private_kb) bits.push(`${kb(totals.private_kb)} private`); + if (totals.shared_kb) bits.push(`${kb(totals.shared_kb)} shared`); + if (totals.swap_kb) bits.push(`${kb(totals.swap_kb)} swapped`); + text("kcard-summary", PAD, cy, bits.length + ? bits.join(" · ") + : (totals.exe_kb != null + ? `exe ${kb(totals.exe_kb)} · libraries ${kb(totals.lib_kb)} · data ${kb(totals.data_kb)}` + : "resident size is all /proc will say")); + cy += LINE; + + if (hasMap && kinds.length) { + cy += 16; + text("kcard-section", PAD, cy, "THE MAP · VIRTUAL SIZE"); + cy += LINE; + const drawn = kinds.filter((k) => k.kind !== "reserved"); + const span = drawn.reduce((sum, k) => sum + Number(k.virtual_kb || 0), 0) || 1; + const barX = PAD; + const barW = cw - PAD * 2; + let at = barX; + drawn.forEach((k) => { + const w = Math.max(1, barW * (Number(k.virtual_kb || 0) / span)); + body.append("rect") + .attr("x", at).attr("y", cy) + .attr("width", w).attr("height", BAR_H) + .attr("fill", KIND_TINT[k.kind] || KIND_TINT.other); + at += w; + }); + cy += BAR_H + 8; + text("kcard-faint", PAD, cy, clip( + drawn.slice(0, compact ? 3 : 5) + .map((k) => `${kb(k.virtual_kb)} ${k.kind}`) + .join(" · "), + compact ? 48 : 72)); + cy += LINE; + } + + if (data.executable || data.heap_kb) { + cy += 16; + text("kcard-section", PAD, cy, "WHAT IT IS MADE OF"); + cy += LINE; + if (data.executable) { + text("kcard-waiter", PAD, cy, "code"); + text("kcard-waiter-dim", 74, cy, clip(data.executable.name || data.executable.path, compact ? 22 : 36)); + text("kcard-faint", cw - PAD, cy, kb(data.executable.virtual_kb), true); + cy += LINE; + } + if (data.heap_kb) { + text("kcard-waiter-dim", PAD, cy, "heap"); + text("kcard-faint", cw - PAD, cy, kb(data.heap_kb), true); + cy += LINE; + } + } + + if (stacks.length) { + cy += 16; + text("kcard-section", PAD, cy, + Number(data.stack_count) === 1 ? "THE STACK" : `STACKS · ${data.stack_count} THREADS`); + cy += LINE; + stacks.forEach((s, i) => { + const ty = cy + 4 + i * ROW_STEP; + if (s.main) { + body.append("circle") + .attr("class", "kcard-glyph-dot") + .attr("cx", PAD - 6).attr("cy", ty - 3).attr("r", 1.5); + } + text("kcard-waiter-dim", PAD, ty, s.tid); + text("kcard-faint", 74, ty, s.main ? "main" : "thread"); + text("kcard-faint", cw - PAD, ty, kb(s.virtual_kb), true); + }); + cy += stacks.length * ROW_STEP; + if (hiddenStacks) { + text("kcard-faint", PAD, cy + 4, `AND ${hiddenStacks} MORE`); + cy += LINE; + } + } + + if (libraries.length) { + cy += 16; + text("kcard-section", PAD, cy, + `LIBRARIES · ${data.library_count} MAPPED`); + cy += LINE; + libraries.forEach((lib, i) => { + const ty = cy + 4 + i * ROW_STEP; + text("kcard-waiter-dim", PAD, ty, clip(lib.name, compact ? 22 : 40)); + text("kcard-faint", cw - PAD, ty, kb(lib.virtual_kb), true); + }); + cy += libraries.length * ROW_STEP; + if (hiddenLibs) { + text("kcard-faint", PAD, cy + 4, `AND ${hiddenLibs} MORE`); + cy += LINE; + } + } + + if (!hasMap && !kinds.length) { + cy += 16; + text("kcard-faint", PAD, cy, "THE KERNEL WILL NOT SHOW THE AREAS OF THIS PROCESS"); + cy += LINE; + text("kcard-faint", PAD, cy, "EXE · LIB · DATA ARE THE TOTALS IT STILL PUBLISHES"); + cy += LINE; + } + + if (notes.length) { + cy += 10; + notes.forEach((note) => { + text("kcard-faint", PAD, cy, note); + cy += LINE; + }); + } + + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", h - FOOTER + 8).attr("x2", cw).attr("y2", h - FOOTER + 8); + text("kcard-foot", PAD, h - 10, "ESC OR CLICK OUTSIDE TO CLOSE"); + text("kcard-foot", cw - PAD, h - 10, + hasMap + ? (viaCollector ? "COLLECTOR · KINDS AND SIZES" : `/PROC/${data.pid}/MAPS`) + : `/PROC/${data.pid}/STATUS`, true); + + d3.select("body").on("keydown.memorycard", (event) => { + if (event.key === "Escape") close(); + }); + } + + return { open, close, isOpen: () => openPid !== null }; +})(); + +window.MemoryCard = MemoryCard; diff --git a/static/js/network-stack.js b/static/js/network-stack.js index 5871468..ad4be97 100644 --- a/static/js/network-stack.js +++ b/static/js/network-stack.js @@ -2030,6 +2030,17 @@ class NetworkStackVisualization { this.container.appendChild(lifecyclePanel); this.overlayNodes.push(lifecyclePanel); this.lifecyclePanelNode = lifecyclePanel; + lifecyclePanel.addEventListener('click', (event) => { + const nav = event.target && event.target.closest + ? event.target.closest('[data-ns-nav]') + : null; + if (!nav) return; + const href = nav.getAttribute('data-ns-nav'); + if (!href) return; + event.preventDefault(); + event.stopPropagation(); + window.location.assign(href); + }); const layerTip = document.createElement('div'); layerTip.style.cssText = ` @@ -2516,8 +2527,20 @@ class NetworkStackVisualization { ? 'rgba(159, 233, 255, 0.88)' : (softActive ? 'rgba(230, 193, 90, 0.5)' : 'rgba(115, 128, 145, 0.34)'); const text = active ? '#ecfbff' : (softActive ? '#f2e2b5' : '#bac4cf'); + const navHref = node.id === 'crypto' + ? '/linux-crypto-subsystem' + : (node.id === 'security' ? '/linux-security-subsystem' : ''); + const navAttrs = navHref + ? ` data-ns-nav="${navHref}" title="Open ${node.label} subsystem" role="link"` + : ''; + const navStyle = navHref + ? 'cursor:pointer; text-decoration:none;' + : ''; + const tagLine = navHref + ? `${node.tags} · open →` + : node.tags; return ` - ${node.label} - ${node.tags} + ${tagLine} `; }; diff --git a/static/js/runqueue-card.js b/static/js/runqueue-card.js new file mode 100644 index 0000000..814e40e --- /dev/null +++ b/static/js/runqueue-card.js @@ -0,0 +1,375 @@ +// The card the LOAD line of the subsystem panel opens. +// +// A load average is this queue's length, averaged: every five seconds the +// kernel counts the tasks that are runnable or stuck in an uninterruptible +// wait, and folds that count into three exponential curves. The three numbers +// say how crowded the machine has been. They never say who was in the crowd, +// and that is the whole point of this card. +// +// What it shows is one frame. The queue is rebuilt thousands of times a second, +// and every row here was printed by the kernel in a single pass over a single +// runqueue, so the vruntimes and deadlines can honestly be compared with each +// other. The price of that consistency is that the frame is a second or two +// old, and the footer says how old. +const RunqueueCard = (() => { + const W = 660; + const PAD = 14; + const CUT = 15; + const HEADER = 25; + const LINE = 14; + const ROW_STEP = 17; + const FOOTER = 34; + + const COL_TASK = 62; + const TASK_CHARS = 14; + const COL_UNIT = 162; + const UNIT_CHARS = 22; + const COL_STANDING = 306; + const STANDING_CHARS = 38; + const VERDICT_INSET = 52; + + let isOpen = false; + let topKeeper = null; + let requestSeq = 0; + + function clip(text, max) { + const value = String(text || ""); + return value.length > max ? `${value.slice(0, max - 1)}…` : value; + } + + function num(value, digits) { + const v = Number(value); + if (!Number.isFinite(v)) return null; + return Math.abs(v) >= 100 ? String(Math.round(v)) : v.toFixed(digits === undefined ? 1 : digits); + } + + function close() { + if (!isOpen) return; + isOpen = false; + requestSeq += 1; + svg.selectAll(".runqueue-card-scrim, .runqueue-card-layer").remove(); + if (topKeeper) topKeeper.stop(); + d3.select("body").on("keydown.runqueuecard", null); + window.dispatchEvent(new CustomEvent("kcard-closed")); + } + + function open(anchor) { + if (isOpen) { + close(); + return; + } + isOpen = true; + const seq = ++requestSeq; + + fetch("/api/runqueue", { cache: "no-store" }) + .then((r) => r.json()) + .then((data) => { + if (seq !== requestSeq) return; + if (!data || data.error) { + isOpen = false; + return; + } + draw(data, anchor); + }) + .catch(() => { + if (seq !== requestSeq) return; + isOpen = false; + }); + } + + // Why this task is in the queue at all: it holds the CPU, or it is waiting + // for it. The observer is called out because reading the kernel's own + // bookkeeping is what put it there. + function standing(row) { + if (row.observer) return row.current ? "on the cpu · reading this file" : "waiting · reads this file"; + if (row.current) return "on the cpu"; + if (row.rt) return "waiting · real-time class"; + return "waiting for the cpu"; + } + + function verdictLabel(row) { + if (row.eligible === undefined || row.eligible === null) return null; + const lag = num(row.vlag_ms); + const mark = row.eligible ? "E" : "N"; + if (lag === null) return mark; + return `${mark} ${Number(row.vlag_ms) > 0 ? "+" : ""}${lag}`; + } + + function dueLabel(row) { + const due = num(row.due_ms); + return due === null ? null : `due ${due}`; + } + + function nextLine(cpu) { + const nxt = cpu.next; + if (!nxt) return null; + const named = nxt.tid + ? (cpu.queue.find((r) => r.tid === nxt.tid) || {}) + : null; + const who = named ? `${nxt.tid} ${String(named.comm || "").toUpperCase()}` : null; + if (nxt.exact) return `NEXT: ${who} · ELIGIBLE, EARLIEST DEADLINE`; + if (!who) return `NEXT: NOT DECIDABLE HERE · ${nxt.reason.toUpperCase()}`; + return `NEXT IN ITS OWN QUEUE: ${who} · ${nxt.reason.toUpperCase()}`; + } + + function draw(data, anchor) { + const svgNode = svg.node(); + const viewW = (svgNode && svgNode.clientWidth) || window.innerWidth; + const viewH = (svgNode && svgNode.clientHeight) || window.innerHeight; + const cw = Math.min(W, viewW - 24); + const compact = cw < 560; + + const colUnit = compact ? 150 : COL_UNIT; + const colStanding = compact ? cw - PAD : COL_STANDING; + const standingChars = compact ? 18 : STANDING_CHARS; + + const load = data.load || {}; + const scheduler = data.scheduler || {}; + const source = data.source || {}; + const cpus = Array.isArray(data.cpus) ? data.cpus : []; + const hasVerdict = !!source.available && scheduler.name === "EEVDF"; + + const notes = []; + if (!source.available) { + notes.push(source.reason === "no-collector" + ? "NAMING THE QUEUE NEEDS THE ROOT COLLECTOR — THE AVERAGES NEED NOTHING" + : `THE SNAPSHOT IS ${String(source.reason || "").toUpperCase()}`); + } else if (!hasVerdict) { + notes.push("THIS KERNEL SCHEDULES WITH CFS — IT KEEPS NO PER-TASK DEADLINE"); + } else { + notes.push("E = ELIGIBLE NOW · LAG IS VIRTUAL MS OWED · EEVDF TAKES THE NEAREST DEADLINE"); + notes.push("ONE FRAME OF A QUEUE THAT IS REBUILT THOUSANDS OF TIMES A SECOND"); + } + + const named = cpus.reduce((all, c) => all + c.queue.length, 0); + const drifted = source.available + && Number.isFinite(Number(load.running)) + && load.running !== named; + + // ── height ───────────────────────────────────────────────────────── + let h = HEADER + 12 + 10; + h += LINE; // counts · scheduler + h += LINE; // what a load average is + if (data.uninterruptible) h += LINE; + if (drifted) h += LINE; + cpus.forEach((cpu) => { + h += 16 + LINE + LINE; // section + legend + h += Math.max(1, cpu.queue.length) * ROW_STEP; + if (cpu.hidden) h += LINE; + if (nextLine(cpu)) h += 6 + LINE; + }); + if (!cpus.length) h += 16 + LINE; + h += 10 + notes.length * LINE; + h += FOOTER; + + const from = anchor && Number.isFinite(anchor.x) ? anchor.x : 240; + let x = from + 44; + if (x + cw + 16 > viewW) x = Math.max(12, viewW - cw - 16); + let y = (anchor && Number.isFinite(anchor.y) ? anchor.y : 120) - h + 40; + y = Math.max(12, Math.min(viewH - h - 12, y)); + + ensureDossierDefs(); + svg.append("rect") + .attr("class", "runqueue-card-scrim") + .attr("x", 0).attr("y", 0).attr("width", viewW).attr("height", viewH) + .attr("fill", ensureFocusVeilGradient()) + .style("opacity", 0) + .style("cursor", "pointer") + .on("click", () => close()) + .transition().duration(200).style("opacity", 1); + + const layer = svg.append("g").attr("class", "runqueue-card-layer"); + if (!topKeeper) { + topKeeper = createOverlayTopKeeper("runqueue-card-scrim", ["runqueue-card-layer"], () => isOpen); + } + topKeeper.start(); + + if (anchor && Number.isFinite(anchor.x) && Number.isFinite(anchor.y)) { + const connY = Math.max(y + 12, Math.min(y + h - 12, anchor.y)); + layer.append("circle") + .attr("class", "kcard-anchor") + .attr("cx", anchor.x).attr("cy", anchor.y).attr("r", 3); + layer.append("line") + .attr("class", "kcard-conn") + .attr("x1", anchor.x).attr("y1", anchor.y) + .attr("x2", anchor.x).attr("y2", anchor.y) + .transition().duration(220).ease(d3.easeCubicOut) + .attr("x2", x).attr("y2", connY); + } + + const panel = layer.append("g") + .attr("transform", `translate(${x}, ${y})`) + .on("click", (event) => event.stopPropagation()); + + panel.append("path") + .attr("class", "kcard-frame") + .attr("d", dossierCardPath(0, 0, cw, h, CUT)) + .attr("filter", "url(#dossier-drop)") + .attr("transform", `translate(0, ${h / 2}) scale(1, 0.02)`) + .transition().delay(120).duration(200).ease(d3.easeCubicOut) + .attr("transform", "translate(0,0) scale(1,1)"); + + const body = panel.append("g").attr("class", "runqueue-card-body").style("opacity", 0); + body.transition().delay(250).duration(180).style("opacity", 1); + + const text = (cls, tx, ty, value, anchorEnd) => body.append("text") + .attr("class", cls) + .attr("x", tx).attr("y", ty) + .attr("text-anchor", anchorEnd ? "end" : "start") + .text(value); + + body.append("path") + .attr("class", "kcard-strip") + .attr("d", `M0,0 H${cw - CUT} L${cw},${CUT} V${HEADER} H0 Z`); + body.append("circle") + .attr("class", "kcard-glyph-ring") + .attr("cx", PAD).attr("cy", HEADER / 2).attr("r", 4.2); + body.append("circle") + .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, "RUN QUEUE"); + const avgs = [load.avg1, load.avg5, load.avg15] + .filter((v) => Number.isFinite(Number(v))) + .map((v) => Number(v).toFixed(2)).join(" "); + text("kcard-meta", cw - 13, HEADER / 2 + 3.5, avgs ? `LOAD ${avgs}` : "", true) + .style("fill", "rgba(244, 244, 236, 0.5)"); + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", HEADER).attr("x2", cw).attr("y2", HEADER); + + let cy = HEADER + 12 + 10; + + // ── what the averages are counting ───────────────────────────────── + const running = Number.isFinite(Number(load.running)) ? Number(load.running) : data.queued; + const identity = [ + `${running} RUNNABLE NOW`, + Number.isFinite(Number(load.total)) ? `${load.total} THREADS ALIVE` : null, + scheduler.name ? (scheduler.slice_ms + ? `${scheduler.name} · SLICE ${scheduler.slice_ms} MS` + : scheduler.name) : null + ].filter(Boolean).join(" · "); + text("kcard-line", PAD, cy, identity); + cy += LINE; + + text("kcard-summary", PAD, cy, compact + ? "the load average is this queue's length, averaged" + : "a load average is this queue's length, averaged over one, five and fifteen minutes"); + cy += LINE; + + if (data.uninterruptible) { + const n = data.uninterruptible; + text("kcard-summary", PAD, cy, + `${n} more ${n === 1 ? "task waits" : "tasks wait"} uninterruptibly — counted by the load, not by the cpu`); + cy += LINE; + } + + // The count above is live and the rows below are a frame old, so when + // they disagree the card says which is which instead of letting the + // reader decide the arithmetic is broken. + if (drifted) { + text("kcard-summary", PAD, cy, + `the kernel counts ${load.running} runnable this instant; these ${named} are the last frame`); + cy += LINE; + } + + // ── one section per runqueue ─────────────────────────────────────── + cpus.forEach((cpu) => { + cy += 16; + const many = (data.cpu_count || 1) > 1; + const depth = Number.isFinite(Number(cpu.nr_running)) ? cpu.nr_running : cpu.queued; + text("kcard-section", PAD, cy, + many ? `CPU ${cpu.cpu} · ${depth} IN THE QUEUE` : `WHO IS COMPETING · ${depth} IN THE QUEUE`); + cy += LINE; + + text("kcard-stage", PAD, cy, "TID"); + text("kcard-stage", COL_TASK, cy, "TASK"); + if (!compact) text("kcard-stage", colUnit, cy, "UNIT"); + text("kcard-stage", colStanding, cy, "WHY IT IS HERE", compact); + if (hasVerdict && !compact) { + text("kcard-stage", cw - PAD - VERDICT_INSET, cy, "EEVDF", true); + text("kcard-stage", cw - PAD, cy, "DEADLINE", true); + } + cy += LINE; + + if (!cpu.queue.length) { + text("kcard-faint", PAD, cy + 4, "NOTHING QUEUED AT THIS INSTANT"); + cy += ROW_STEP; + } + + cpu.queue.forEach((row, i) => { + const ty = cy + 4 + i * ROW_STEP; + if (row.current) { + body.append("circle") + .attr("class", "kcard-glyph-dot") + .attr("cx", PAD - 6).attr("cy", ty - 3).attr("r", 1.5); + } + text(row.current ? "kcard-waiter" : "kcard-waiter-dim", PAD, ty, row.tid); + text(row.current ? "kcard-waiter" : "kcard-waiter-dim", + COL_TASK, ty, clip(row.comm, TASK_CHARS)); + if (!compact) { + text("kcard-faint", colUnit, ty, clip(row.unit || "—", UNIT_CHARS)); + } + text(row.observer ? "kcard-inferred" : (row.current ? "kcard-state is-run" : "kcard-waiter-dim"), + colStanding, ty, clip(standing(row), standingChars), compact); + + if (hasVerdict && !compact) { + const verdict = verdictLabel(row); + const due = verdict ? dueLabel(row) : null; + text(verdict ? (row.eligible ? "kcard-symbol is-sleep" : "kcard-waiter-dim") : "kcard-faint", + cw - PAD - VERDICT_INSET, ty, verdict || "—", true) + .style("font-size", "9px"); + if (due) { + text("kcard-waiter-dim", cw - PAD, ty, due, true) + .style("font-size", "9px"); + } + } + }); + cy += Math.max(1, cpu.queue.length) * ROW_STEP; + + if (cpu.hidden) { + text("kcard-faint", PAD, cy + 4, `+${cpu.hidden} MORE WAITING`); + cy += LINE; + } + + const next = nextLine(cpu); + if (next) { + cy += 6; + text(cpu.next && cpu.next.exact ? "kcard-section" : "kcard-faint", PAD, cy, next); + cy += LINE; + } + }); + + if (!cpus.length) { + cy += 16; + text("kcard-faint", PAD, cy, "NO RUNQUEUE IN THE SNAPSHOT"); + cy += LINE; + } + + cy += 10; + notes.forEach((note) => { + text("kcard-faint", PAD, cy, note); + cy += LINE; + }); + + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", h - FOOTER + 8).attr("x2", cw).attr("y2", h - FOOTER + 8); + text("kcard-foot", PAD, h - 10, "ESC OR CLICK OUTSIDE TO CLOSE"); + text("kcard-foot", cw - PAD, h - 10, + source.available && Number.isFinite(Number(source.age_s)) + ? `FRAME FROM ${Number(source.age_s).toFixed(1)} S AGO` + : "/PROC/LOADAVG", true); + + d3.select("body").on("keydown.runqueuecard", (event) => { + if (event.key === "Escape") close(); + }); + } + + return { + open, + close, + isOpen: () => isOpen + }; +})(); + +window.RunqueueCard = RunqueueCard; diff --git a/static/js/subsystem-focus.js b/static/js/subsystem-focus.js index f87d26f..d8e6cd1 100644 --- a/static/js/subsystem-focus.js +++ b/static/js/subsystem-focus.js @@ -4,6 +4,41 @@ const state = { focusedKey: null }; + // The bars follow the syscall panel above, whose height moves with the number + // of waiting processes. + const COLUMN_GAP = 30, FALLBACK_TOP = 350; + + let lastPayload = null; + + function formatBytes(bytes, suffix) { + const value = Number(bytes) || 0; + const units = [[1073741824, 'G'], [1048576, 'M'], [1024, 'K']]; + for (const [scale, tag] of units) { + if (value >= scale) { + const scaled = value / scale; + return `${scaled >= 10 ? Math.round(scaled) : scaled.toFixed(1)}${tag}${suffix}`; + } + } + return `${Math.round(value)}${suffix}`; + } + + // Each bar states its own unit, because a level (memory in use) and a rate + // (bytes on the wire) cannot honestly share one scale. + function readings(data) { + if (!data || data.warming || data.value === null || data.value === undefined) { + return { value: '—', detail: 'warming' }; + } + const detail = data.detail === null || data.detail === undefined ? '' : data.detail; + if (data.unit === 'bytes_per_sec') { + return { value: formatBytes(data.value, 'B/s'), detail: `${detail} sockets` }; + } + const value = `${Number(data.value).toFixed(data.value < 10 ? 1 : 0)}%`; + if (data.detail_unit === 'bytes') return { value, detail: `${formatBytes(detail, '')} used` }; + if (data.detail_unit === 'bytes_per_sec') return { value, detail: `${formatBytes(detail, 'B/s')} disk` }; + if (data.detail_unit === 'runnable') return { value, detail: `${detail} runnable` }; + return { value, detail: String(detail) }; + } + function applyFocusStyles() { const focusedKey = state.focusedKey; const hasFocus = !!focusedKey; @@ -27,6 +62,9 @@ .attr('fill', isActive ? '#101316' : '#222') .attr('opacity', isDim ? 0.52 : 1); + row.selectAll('.subsystem-indicator-detail') + .attr('opacity', isDim ? 0.4 : 0.72); + row.select('.subsystem-indicator-focus') .attr('opacity', isActive ? 0.95 : 0); }); @@ -42,24 +80,53 @@ return; } + lastPayload = subsystems || lastPayload; + const svg = d3.select('svg'); svg.selectAll('.subsystem-indicator').remove(); + // A clickable strip that survives the redraw above. + // + // The panel is torn down and rebuilt every refresh, and a press that + // begins on one instance of a rectangle and ends on its replacement is + // not a click at all — the browser fires nothing, and the drill-down + // silently does not happen. So these rectangles are created once, + // outside the sweep, and afterwards only moved into place. They are + // also invisible, and the layer they sit under is click-through, so + // nothing about the map's own behaviour changes. + const hitArea = (key, box) => { + let hit = svg.select(`rect.kai-hit-${key}`); + if (hit.empty()) { + hit = svg.append('rect') + .attr('class', `kai-hit kai-hit-${key}`) + .attr('fill', 'transparent') + .style('pointer-events', 'all') + .style('cursor', 'pointer'); + } + return hit.attr('x', box.x).attr('y', box.y) + .attr('width', box.width).attr('height', box.height) + .on('mouseenter', null).on('mouseleave', null).on('click', null) + .raise(); + }; + const subsystemNames = ['memory_management', 'process_scheduler', 'file_system', 'network_stack']; const subsystemLabels = { memory_management: 'Memory', process_scheduler: 'Scheduler', - file_system: 'File System', + file_system: 'IO wait', network_stack: 'Network' }; + const top = Math.round(window.__leftColumnCursor || FALLBACK_TOP) + COLUMN_GAP; + subsystemNames.forEach((name, i) => { const subsystem = subsystems[name]; if (!subsystem) return; const usage = subsystem.usage || 0; + const shown = readings(subsystem); const x = 30; - const y = 380 + i * 25; + const y = top + i * 25; const barWidth = 200; const barHeight = 15; @@ -105,16 +172,94 @@ .attr('font-size', '10px') .attr('fill', '#222'); + // What the bar is measuring, kept quieter than the reading itself. + // 72px clears the longest label ("Scheduler") at 10px mono. + rowGroup.append('text') + .attr('x', x + 72) + .attr('y', y + 11) + .text(shown.detail) + .attr('class', 'feature-text subsystem-indicator subsystem-indicator-detail') + .attr('font-size', '8px') + .attr('opacity', 0.72) + .attr('fill', '#222'); + rowGroup.append('text') .attr('x', x + barWidth - 5) .attr('y', y + 11) - .text(`${usage}%`) + .text(shown.value) .attr('class', 'feature-text subsystem-indicator subsystem-indicator-label') .attr('font-size', '10px') .attr('text-anchor', 'end') .attr('fill', '#222'); + + // The scheduler bar measures how busy the scheduler is; a click on + // it asks the other half of that question — what keeps making work + // for it. The bars are click-through by default (the map lives + // underneath), so this one rect has to ask for its events back. + if (name === 'process_scheduler' && window.WakeupsCard) { + const outline = rowGroup.append('rect') + .attr('x', x - 1.5).attr('y', y - 1.5) + .attr('width', barWidth + 3).attr('height', barHeight + 3) + .attr('fill', 'none') + .attr('stroke', '#e2a33e') + .attr('stroke-width', 0.9) + .attr('opacity', 0) + .attr('class', 'subsystem-indicator'); + hitArea('wakeups', { x: x, y: y, width: barWidth, height: barHeight }) + .on('mouseenter', () => outline.attr('opacity', 0.9)) + .on('mouseleave', () => outline.attr('opacity', 0)) + .on('click', (event) => { + event.stopPropagation(); + window.WakeupsCard.open({ + x: x + barWidth + 6, + y: y + barHeight / 2, + clearOf: x + barWidth + 40 + }); + }); + } }); + const load = (subsystems.process_scheduler || {}).load; + if (Array.isArray(load) && load.length === 3) { + const loadY = top + subsystemNames.length * 25 + 9; + const quiet = 'rgba(40, 44, 50, 0.6)'; + const lit = 'rgba(20, 24, 30, 0.95)'; + const loadText = svg.append('text') + .attr('class', 'feature-text subsystem-indicator') + .attr('x', 30) + .attr('y', loadY) + .attr('font-size', '8px') + .attr('letter-spacing', '0.5px') + .attr('fill', quiet) + .text(`LOAD ${load.map((v) => v.toFixed(2)).join(' ')}`); + + // The three numbers are the runqueue's length averaged over time, + // so the queue itself is what they drill into. + if (window.RunqueueCard) { + const box = loadText.node().getBBox(); + const rule = svg.append('line') + .attr('class', 'subsystem-indicator') + .attr('x1', 30).attr('x2', 30 + box.width) + .attr('y1', loadY + 2.5).attr('y2', loadY + 2.5) + .attr('stroke', quiet) + .attr('stroke-width', 0.5) + .attr('opacity', 0.35); + const hit = hitArea('load', { + x: 28, y: loadY - 9, width: box.width + 12, height: 14 + }); + hit.on('mouseenter', () => { + loadText.attr('fill', lit); + rule.attr('opacity', 0.85); + }).on('mouseleave', () => { + loadText.attr('fill', quiet); + rule.attr('opacity', 0.35); + }).on('click', (event) => { + event.stopPropagation(); + window.RunqueueCard.open({ x: 30 + box.width + 8, y: loadY - 3 }); + }); + } + } + applyFocusStyles(); } @@ -123,6 +268,10 @@ state.focusedKey = key || null; applyFocusStyles(); }, + // Called when the panel above changes height, so the two stay together. + reflow() { + if (lastPayload) render(lastPayload); + }, render }; diff --git a/static/js/syscall-card.js b/static/js/syscall-card.js new file mode 100644 index 0000000..f0945e5 --- /dev/null +++ b/static/js/syscall-card.js @@ -0,0 +1,299 @@ +// The card a syscall row opens. +// +// A system call is a kernel element in its own right, so it gets its own card +// rather than borrowing the process dossier: the number this architecture +// assigns it, the prototype userspace calls it with, the chain of kernel +// symbols it travels through, and the tasks parked in it at this moment. +// +// Everything the card prints comes from the running machine. The chain is +// filtered against /proc/kallsyms, so a symbol this kernel inlined away is +// simply absent, and the last stage is the function the kernel itself reports +// in /proc//wchan. +const SyscallCard = (() => { + const W = 448; + const PAD = 14; + const CUT = 15; + const HEADER = 25; + const LINE = 14; + const CHAIN_STEP = 21; + const WAITER_STEP = 17; + const FOOTER = 34; + const MAX_WAITERS = 7; + const MAX_CHARS = 58; + + let openName = null; + let topKeeper = null; + let requestSeq = 0; + + const SUBSYSTEM_TINT = { + net: "rgba(103, 190, 224, 0.92)", + fs: "rgba(188, 188, 188, 0.92)", + sched: "rgba(167, 200, 120, 0.9)", + mm: "rgba(180, 160, 214, 0.9)" + }; + + // The prototype is the one line that will not fit; break it on argument + // boundaries so each line still reads as C. + function wrapSignature(text, maxChars) { + const words = String(text || "").split(" ").filter(Boolean); + const lines = []; + let line = ""; + words.forEach((word) => { + if (line && (line.length + word.length + 1) > maxChars) { + lines.push(line); + line = " " + word; + } else { + line = line ? `${line} ${word}` : word; + } + }); + if (line) lines.push(line); + return lines; + } + + function clip(text, max) { + const value = String(text || ""); + return value.length > max ? `${value.slice(0, max - 1)}…` : value; + } + + function close() { + openName = null; + requestSeq += 1; + svg.selectAll(".syscall-card-scrim, .syscall-card-layer").remove(); + if (topKeeper) topKeeper.stop(); + d3.select("body").on("keydown.syscallcard", null); + window.dispatchEvent(new CustomEvent("kcard-closed")); + } + + // ``tag`` is the subsystem the panel put on the row. The card reuses it + // instead of the server's coarser mapping, so the header and the row the + // card came from never disagree. + function open(row, anchor, tag) { + const name = String((row && row.name) || ""); + if (!name) return; + if (openName === name) { + close(); + return; + } + close(); + openName = name; + const seq = ++requestSeq; + + fetch(`/api/syscall/${encodeURIComponent(name)}`, { cache: "no-store" }) + .then((r) => r.json()) + .then((data) => { + // The user may have clicked elsewhere while this was in flight. + if (seq !== requestSeq) return; + draw(data, anchor, tag); + }) + .catch(() => { + if (seq !== requestSeq) return; + openName = null; + }); + } + + function draw(data, anchor, tag) { + const svgNode = svg.node(); + const viewW = (svgNode && svgNode.clientWidth) || window.innerWidth; + const viewH = (svgNode && svgNode.clientHeight) || window.innerHeight; + + // On a phone the card takes the width it can get, and the prototype + // wraps to match rather than running off the frame. + const cw = Math.min(W, viewW - 24); + const maxChars = Math.max(24, Math.floor((cw - 2 * PAD) / 5.75)); + + const chain = Array.isArray(data.chain) ? data.chain : []; + const waiters = Array.isArray(data.waiters) ? data.waiters : []; + const shownWaiters = waiters.slice(0, MAX_WAITERS); + const signature = wrapSignature(data.signature, maxChars); + const label = (tag && tag.text) || String(data.subsystem || "").toUpperCase(); + const tint = (tag && tag.color) + || SUBSYSTEM_TINT[String(data.subsystem || "").toLowerCase()] + || "rgba(176, 186, 198, 0.9)"; + + // Height follows the content: an undocumented call has no prototype + // line, and a call nobody is parked in has no task list. + let h = HEADER + 12; + h += LINE; // nr / arch / source + h += LINE; // register convention + if (signature.length) h += 8 + signature.length * LINE; + if (data.summary) h += LINE; + h += 16 + LINE; // "path into the kernel" + h += chain.length * CHAIN_STEP; + h += 16 + LINE; // "parked now" + h += Math.max(1, shownWaiters.length) * WAITER_STEP; + if (waiters.length > shownWaiters.length || (data.count || 0) > waiters.length) h += LINE; + h += FOOTER; + + let x = 290; + if (x + cw + 16 > viewW) x = Math.max(12, viewW - cw - 16); + let y = (anchor && anchor.y ? anchor.y : 90) - 40; + y = Math.max(12, Math.min(viewH - h - 12, y)); + + ensureDossierDefs(); + svg.append("rect") + .attr("class", "syscall-card-scrim") + .attr("x", 0).attr("y", 0).attr("width", viewW).attr("height", viewH) + .attr("fill", ensureFocusVeilGradient()) + .style("opacity", 0) + .style("cursor", "pointer") + .on("click", () => close()) + .transition().duration(200).style("opacity", 1); + + const layer = svg.append("g").attr("class", "syscall-card-layer"); + if (!topKeeper) { + topKeeper = createOverlayTopKeeper("syscall-card-scrim", ["syscall-card-layer"], () => !!openName); + } + topKeeper.start(); + + // A line back to the row that was clicked, so the card reads as that + // row unfolding rather than as a window from nowhere. + if (anchor && Number.isFinite(anchor.x) && Number.isFinite(anchor.y)) { + const connY = Math.max(y + 12, Math.min(y + h - 12, anchor.y)); + layer.append("circle") + .attr("class", "kcard-anchor") + .attr("cx", anchor.x).attr("cy", anchor.y).attr("r", 3); + layer.append("line") + .attr("class", "kcard-conn") + .attr("x1", anchor.x).attr("y1", anchor.y) + .attr("x2", anchor.x).attr("y2", anchor.y) + .transition().duration(220).ease(d3.easeCubicOut) + .attr("x2", x).attr("y2", connY); + } + + const panel = layer.append("g") + .attr("transform", `translate(${x}, ${y})`) + .on("click", (event) => event.stopPropagation()); + + panel.append("path") + .attr("class", "kcard-frame") + .attr("d", dossierCardPath(0, 0, cw, h, CUT)) + .attr("filter", "url(#dossier-drop)") + .attr("transform", `translate(0, ${h / 2}) scale(1, 0.02)`) + .transition().delay(120).duration(200).ease(d3.easeCubicOut) + .attr("transform", "translate(0,0) scale(1,1)"); + + const body = panel.append("g").attr("class", "syscall-card-body").style("opacity", 0); + body.transition().delay(250).duration(180).style("opacity", 1); + + const text = (cls, tx, ty, value, anchorEnd) => body.append("text") + .attr("class", cls) + .attr("x", tx).attr("y", ty) + .attr("text-anchor", anchorEnd ? "end" : "start") + .text(value); + + body.append("path") + .attr("class", "kcard-strip") + .attr("d", `M0,0 H${cw - CUT} L${cw},${CUT} V${HEADER} H0 Z`); + body.append("circle") + .attr("class", "kcard-glyph-ring") + .attr("cx", PAD).attr("cy", HEADER / 2).attr("r", 4.2); + body.append("circle") + .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, `SYSCALL · ${String(data.name || "").toUpperCase()}`); + text("kcard-meta", cw - 13, HEADER / 2 + 3.5, label, true).style("fill", tint); + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", HEADER).attr("x2", cw).attr("y2", HEADER); + + let cy = HEADER + 12 + 10; + + const identity = [ + data.nr === null || data.nr === undefined ? "NR UNKNOWN" : `NR ${data.nr}`, + String(data.arch || "").toUpperCase(), + data.source ? data.source : "" + ].filter(Boolean).join(" · "); + text("kcard-line", PAD, cy, identity); + cy += LINE; + + text("kcard-faint", PAD, cy, String(data.abi || "")); + cy += LINE; + + if (signature.length) { + cy += 8; + signature.forEach((line) => { + text("kcard-signature", PAD, cy, line); + cy += LINE; + }); + } + + if (data.summary) { + text("kcard-summary", PAD, cy, data.summary); + cy += LINE; + } + + // The path into the kernel: one rail, one node per confirmed stage. + cy += 16; + text("kcard-section", PAD, cy, "PATH INTO THE KERNEL"); + cy += LINE; + + const railX = PAD + 62; + if (chain.length) { + body.append("line") + .attr("class", "kcard-rail") + .attr("x1", railX).attr("y1", cy - 2) + .attr("x2", railX).attr("y2", cy - 2 + (chain.length - 1) * CHAIN_STEP); + } + chain.forEach((step, i) => { + const sy = cy - 2 + i * CHAIN_STEP; + const sleeping = step.stage === "sleep"; + body.append("circle") + .attr("class", sleeping ? "kcard-node is-sleep" : (step.confirmed ? "kcard-node" : "kcard-node is-unconfirmed")) + .attr("cx", railX).attr("cy", sy).attr("r", sleeping ? 3.4 : 2.4); + text("kcard-stage", PAD, sy + 3, String(step.stage || "").toUpperCase()); + text(sleeping ? "kcard-symbol is-sleep" : "kcard-symbol", railX + 12, sy + 3.5, clip(step.symbol, 34)); + if (step.note) text("kcard-note", cw - PAD, sy + 3.5, String(step.note).toUpperCase(), true); + }); + cy += chain.length * CHAIN_STEP; + + // Who is standing in it right now. + cy += 16; + const count = Number(data.count || 0); + text("kcard-section", PAD, cy, `PARKED NOW · ${count} ${count === 1 ? "TASK" : "TASKS"}`); + cy += LINE; + + if (!shownWaiters.length) { + text("kcard-faint", PAD, cy + 4, "NOBODY IS PARKED IN THIS CALL RIGHT NOW"); + cy += WAITER_STEP; + } + shownWaiters.forEach((waiter) => { + const who = waiter.tid && waiter.tid !== waiter.pid + ? `${waiter.pid}/${waiter.tid} ${waiter.comm || "unnamed"}` + : `${waiter.pid} ${waiter.comm || "unnamed"}`; + text("kcard-waiter", PAD, cy + 4, clip(who, 24)); + text("kcard-waiter-dim", PAD + 156, cy + 4, + [waiter.state, waiter.wchan].filter(Boolean).join(" · ")); + if (waiter.fd_target) { + text("kcard-waiter-dim", cw - PAD, cy + 4, + `fd ${waiter.fd} → ${clip(waiter.fd_target, 22)}`, true); + } + cy += WAITER_STEP; + }); + + const notListed = Math.max(0, count - shownWaiters.length); + if (notListed) { + text("kcard-faint", PAD, cy + 4, `+${notListed} MORE PARKED HERE`); + cy += LINE; + } + + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", h - FOOTER + 8).attr("x2", cw).attr("y2", h - FOOTER + 8); + text("kcard-foot", PAD, h - 10, "ESC OR CLICK OUTSIDE TO CLOSE"); + const scope = (data.sample && data.sample.scope) === "machine" ? "WHOLE MACHINE" : "BACKEND ONLY"; + text("kcard-foot", cw - PAD, h - 10, scope, true); + + d3.select("body").on("keydown.syscallcard", (event) => { + if (event.key === "Escape") close(); + }); + } + + return { + open, + close, + isOpen: () => !!openName, + openedName: () => openName + }; +})(); + +window.SyscallCard = SyscallCard; diff --git a/static/js/syscalls.js b/static/js/syscalls.js index 1554a22..7bed7d1 100755 --- a/static/js/syscalls.js +++ b/static/js/syscalls.js @@ -4,22 +4,26 @@ class SyscallsManager { this.currentSyscalls = []; this.updateInterval = null; this.updateCallback = null; - this.minVisibleRows = 10; - this.warmupUntil = 0; - this.isWarmup = false; + this.hiddenCount = 0; + this.sampledAt = ""; this.pinnedSubsystemKey = null; - this.defaultSyscalls = [ - { name: "read", count: "166 643218" }, - { name: "write", count: "964 016161" }, - { name: "open", count: "972 983879" }, - { name: "close", count: "989 612075" }, - { name: "mmap", count: "819 540732" }, - { name: "fork", count: "512 826219" }, - { name: "execve", count: "025 461491" }, - { name: "socket", count: "838 475394" }, - { name: "connect", count: "632 094939" }, - { name: "accept", count: "417 205788" } - ]; + this.hoveredSubsystemKey = null; + // "loading" until the first answer arrives, then "ok" or "unavailable". + this.feedState = "loading"; + // "machine" when the root collector answered, "self" when the backend + // could only read its own processes. The footer says which. + this.sampleScope = null; + } + + // How many rows fit above the subsystem bars and the IRQ panel, which is + // docked to the bottom of the window. The list used to stop at eight on + // every screen; a tall window can hold the whole sample. + maxVisibleRows() { + const irqTop = Math.max(20, window.innerHeight - 230); + // Below the frame: a 30px gap, four 25px bars, the load line, some air. + const barsBlock = 30 + 4 * 25 + 14 + 8; + const chrome = 20 + 30 + 14; + return Math.max(4, Math.min(16, Math.floor((irqTop - barsBlock - chrome) / 30))); } getSubsystemKeyForSyscall(syscallName) { @@ -39,7 +43,12 @@ class SyscallsManager { name.includes("fsync") || name.includes("fdatasync") || name.includes("rename") || name.includes("unlink") || name.includes("mkdir") || name.includes("rmdir") || name.includes("getdents") || name.includes("chmod") || name.includes("chown") || - name.includes("mount") + name.includes("mount") || + // Descriptor and path plumbing shows up constantly in real samples. + name.includes("flock") || name.includes("fcntl") || name.includes("ioctl") || + name.includes("umask") || name.includes("dup") || name.includes("pipe") || + name.includes("access") || name.includes("truncate") || name.includes("sync") || + name.includes("link") || name.includes("xattr") || name.includes("chdir") ) { return "file_system"; } @@ -54,11 +63,15 @@ class SyscallsManager { name.includes("futex") || name.includes("clone") || name.includes("fork") || name.includes("exec") || name.includes("wait") || name.includes("sched") || name.includes("nanosleep") || name.includes("timer") || name.includes("kill") || - name.includes("signal") + name.includes("signal") || name.includes("sigreturn") || name.includes("rusage") || + name.includes("prctl") || name.includes("getpid") || name.includes("getppid") || + name.includes("exit") ) { return "process_scheduler"; } - return "process_scheduler"; + // Anything unrecognised used to be filed under the scheduler, which put a + // confident but wrong tag on real samples. Say "core" instead. + return "kernel_core"; } getSubsystemTag(subsystemKey) { @@ -84,85 +97,85 @@ class SyscallsManager { ); } + // /proc//syscall reports which processes are parked in a syscall at this + // instant and nothing else, so the panel shows exactly that: no padding rows, + // no counts carried over from the previous poll. normalizeSyscalls(syscalls) { const input = Array.isArray(syscalls) ? syscalls : []; - const normalized = []; - const seen = new Set(); - let usedHistory = false; - let usedDefaults = false; - - const addUnique = (entry) => { - const rawName = entry && entry.name !== undefined ? String(entry.name).trim() : ""; - if (!rawName) return; - const nameKey = rawName.toLowerCase(); - if (seen.has(nameKey)) return; - const rawCount = entry && entry.count !== undefined ? String(entry.count).trim() : ""; - normalized.push({ - name: rawName, - count: rawCount || "000 000000" - }); - seen.add(nameKey); - }; + const byName = new Map(); - input.forEach(addUnique); - const fromApiCount = normalized.length; - this.currentSyscalls.forEach((entry) => { - const before = normalized.length; - addUnique(entry); - if (normalized.length > before) usedHistory = true; - }); - this.defaultSyscalls.forEach((entry) => { - const before = normalized.length; - addUnique(entry); - if (normalized.length > before) usedDefaults = true; + input.forEach((entry) => { + const name = entry && entry.name !== undefined ? String(entry.name).trim() : ""; + if (!name) return; + const key = name.toLowerCase(); + const count = Number(entry.count) || 0; + const known = byName.get(key); + // Who is parked here. Counter-derived rows (vmstat, block, sockstat) + // carry no processes, and those rows simply do not open. + const waiters = (Array.isArray(entry.waiters) ? entry.waiters : []) + .map((w) => ({ pid: Number(w && w.pid), comm: String((w && w.comm) || "") })) + .filter((w) => Number.isFinite(w.pid) && w.pid > 0); + byName.set(key, { + name, + count: (known ? known.count : 0) + count, + waiters: (known ? known.waiters : []).concat(waiters) + }); }); + const limit = this.maxVisibleRows(); + const ordered = [...byName.values()].sort((a, b) => b.count - a.count); return { - rows: normalized.slice(0, this.minVisibleRows), - warmup: fromApiCount < this.minVisibleRows || usedHistory || usedDefaults + rows: ordered.slice(0, limit), + hidden: Math.max(0, ordered.length - limit) }; } + // Nothing parked is a normal, frequent state — say so instead of padding. + placeholderNote() { + if (this.feedState === "loading") return "reading /proc …"; + if (this.feedState === "unavailable") return "collector unavailable"; + return "nothing parked in a syscall"; + } + // Update system calls data async updateSyscallsTable() { try { const response = await fetch("/api/syscalls-realtime"); const data = await response.json(); - - if (data.syscalls) { - debugLog(`📊 System calls update: ${data.syscalls.length} syscalls received`); - const normalized = this.normalizeSyscalls(data.syscalls); - this.currentSyscalls = normalized.rows; - this.isWarmup = normalized.warmup; - if (this.isWarmup) { - this.warmupUntil = Date.now() + 2000; - } - this.renderSyscallsTable(); - debugLog(`✅ System calls rendered: ${this.currentSyscalls.length} items`); - - // Call callback if set - if (this.updateCallback) { - this.updateCallback(data); - } - } else { - console.warn('⚠️ No syscalls in API response, using fallback'); - this.useFallbackData(); + const rowsGiven = Array.isArray(data.syscalls); + const normalized = this.normalizeSyscalls(rowsGiven ? data.syscalls : []); + + this.currentSyscalls = normalized.rows; + this.hiddenCount = normalized.hidden; + this.feedState = rowsGiven ? "ok" : "unavailable"; + this.sampleScope = (data.sample && data.sample.scope) || null; + this.sampledAt = this.formatSampleTime(data.timestamp); + this.renderSyscallsTable(); + debugLog(`✅ System calls rendered: ${this.currentSyscalls.length} parked`); + + if (this.updateCallback) { + this.updateCallback(data); } } catch (error) { console.error('❌ Error getting system calls:', error); - this.useFallbackData(); + this.markFeedUnavailable(); } } - // Fallback data - useFallbackData() { - const normalized = this.normalizeSyscalls([]); - this.currentSyscalls = normalized.rows; - this.isWarmup = true; - this.warmupUntil = Date.now() + 2200; + formatSampleTime(timestamp) { + const when = timestamp ? new Date(timestamp) : new Date(); + if (Number.isNaN(when.getTime())) return ""; + return when.toTimeString().slice(0, 8); + } + + markFeedUnavailable() { + this.currentSyscalls = []; + this.hiddenCount = 0; + this.feedState = "unavailable"; this.renderSyscallsTable(); } + // Render system calls table renderSyscallsTable() { // Don't render if Matrix View is active @@ -172,30 +185,83 @@ class SyscallsManager { } const svg = d3.select("svg"); - + // Clear old elements (including panel groups) svg.selectAll(".syscall-box, .syscall-text, .syscall-panel-group").remove(); - svg.selectAll(".syscall-warmup-indicator").remove(); - + svg.selectAll(".syscall-frame, .syscall-foot").remove(); + debugLog(`🎨 Rendering ${this.currentSyscalls.length} system calls`); const manager = this; - + const rows = this.currentSyscalls; + + const panelX = 30; + const panelWidth = 230; + const panelHeight = 22; + const rowStep = 30; + const firstRowY = 35; + const bodyCount = Math.max(1, rows.length); + + // The frame used to be a fixed 250x330 box sized for ten padded rows. It + // keeps its old look but follows the number of real rows, so no empty + // box is left hanging under a short list. + const frameH = 30 + bodyCount * rowStep + 14; + svg.append("rect") + .attr("x", 20) + .attr("y", 20) + .attr("width", 250) + .attr("height", frameH) + .attr("class", "feature-panel syscall-frame syscall-box"); + + const rowText = (parent, x, y, value, size, fill, anchor) => parent.append("text") + .attr("x", x) + .attr("y", y) + .attr("class", "syscall-text") + .style("font-family", "Share Tech Mono, monospace") + .style("font-size", `${size}px`) + .style("text-anchor", anchor || "start") + .style("letter-spacing", "0.3px") + .style("fill", fill) + .text(value); + + // Nothing waiting is a normal state, so it gets a panel of its own rather + // than padding rows out with numbers nobody measured. + if (!rows.length) { + const panelGroup = svg.append("g").attr("class", "syscall-panel-group"); + panelGroup.append("rect") + .attr("x", panelX) + .attr("y", firstRowY) + .attr("width", panelWidth) + .attr("height", panelHeight) + .attr("rx", 8) + .attr("class", "syscall-box") + .style("fill", "#333") + .style("stroke", "#555") + .style("stroke-width", "1px"); + rowText(panelGroup, panelX + 8, firstRowY + 15, + this.placeholderNote().toUpperCase(), 10, "rgba(200, 204, 212, 0.62)"); + } + // Create new elements for system calls with diegetic UI panel style - this.currentSyscalls.forEach((syscall, i) => { + let cursorY = firstRowY; + rows.forEach((syscall, i) => { const subsystemKey = this.getSubsystemKeyForSyscall(syscall.name); const subsystemTag = this.getSubsystemTag(subsystemKey); - const isPinned = this.pinnedSubsystemKey && this.pinnedSubsystemKey === subsystemKey; + // Unmapped calls have no subsystem to light up, so they stay inert + // instead of offering a link that leads nowhere. + const linked = subsystemKey !== "kernel_core"; + const isPinned = linked && this.pinnedSubsystemKey === subsystemKey; + // Counter-derived rows (vmstat, block, sockstat) are not calls and + // have no anatomy to open. + const openable = !/^(vm|disk|net):/.test(syscall.name); const displayText = `${syscall.name.toUpperCase()} ${syscall.count}`; - const panelX = 30; - const panelY = 35 + i * 30; - const panelWidth = 230; - const panelHeight = 22; - + const panelY = cursorY; + cursorY += rowStep; + // Create panel group for each syscall const panelGroup = svg.append("g") .attr("class", "syscall-panel-group") .attr("data-syscall-index", i); - + // Panel background - diegetic UI style (like "SUBJECT U454.1") const panel = panelGroup.append("rect") .attr("x", panelX) @@ -206,18 +272,11 @@ class SyscallsManager { .attr("class", "syscall-box") .style("fill", "#333") // Same base color as right menu panels .style("stroke", isPinned ? subsystemTag.color : "#555") // Same border color as right menu panels - .style("stroke-width", "1px"); - + .style("stroke-width", "1px") + .style("cursor", linked ? "pointer" : "default"); + // Text inside panel - const text = panelGroup.append("text") - .attr("x", panelX + 8) - .attr("y", panelY + 15) - .text(displayText) - .attr("class", "syscall-text") - .style("font-family", "Share Tech Mono, monospace") - .style("font-size", "11px") - .style("fill", "#c8ccd4") // Milk-gray text color - .style("letter-spacing", "0.3px"); // Slight letter spacing + const text = rowText(panelGroup, panelX + 8, panelY + 15, displayText, 11, "#c8ccd4"); panelGroup.append("text") .attr("x", panelX + panelWidth - 8) @@ -229,7 +288,26 @@ class SyscallsManager { .style("text-anchor", "end") .style("letter-spacing", "0.3px") .style("fill", subsystemTag.color); - + + // A row that opens something says so; the caret is the only hint + // the old style has room for. + if (openable) { + panelGroup.append("text") + // Clear of the widest tag ("SCHED" at 8px) sitting at -8. + .attr("x", panelX + panelWidth - 40) + .attr("y", panelY + 15) + .text("\u25b8") + .attr("class", "syscall-text syscall-caret") + .style("font-family", "Share Tech Mono, monospace") + .style("font-size", "9px") + .style("text-anchor", "end") + .style("fill", "rgba(200, 204, 212, 0.75)"); + } + + // A counter row that maps to no subsystem has nothing to offer. + if (!linked && !openable) return; + panel.style("cursor", "pointer"); + // Hover effects panel .on("mouseenter", function() { @@ -241,7 +319,9 @@ class SyscallsManager { .style("stroke", "#ffffff") .style("stroke-width", "2px"); text.style("fill", "#000000"); // Dark text on white panel - manager.emitSubsystemFocus(subsystemKey, "hover"); + // "core" is not a subsystem row, so focusing it would only dim + // every bar without lighting one. + if (linked) manager.emitSubsystemFocus(subsystemKey, "hover"); }) .on("mouseleave", function() { d3.select(this) @@ -249,29 +329,55 @@ class SyscallsManager { .style("stroke", this.__originalStroke || (isPinned ? subsystemTag.color : "#555")) .style("stroke-width", this.__originalStrokeWidth || "1px"); text.style("fill", "#c8ccd4"); - manager.emitSubsystemFocus(null, "hover-clear"); + if (linked) manager.emitSubsystemFocus(null, "hover-clear"); }) .on("click", () => { - this.pinnedSubsystemKey = this.pinnedSubsystemKey === subsystemKey ? null : subsystemKey; + // The row opens the call itself: what it is in this kernel, + // the path it takes through it, and who is standing in it. + // The subsystem stays lit for as long as the card is open, + // so the bar below and the card tell the same story. + if (openable && window.SyscallCard) { + window.SyscallCard.open( + syscall, { x: panelX + panelWidth, y: panelY + 11 }, subsystemTag + ); + } + if (linked) this.pinnedSubsystemKey = isPinned ? null : subsystemKey; this.renderSyscallsTable(); this.emitSubsystemFocus(this.pinnedSubsystemKey, "pin-toggle"); }); }); - const shouldShowWarmup = this.isWarmup && Date.now() < this.warmupUntil; - if (shouldShowWarmup) { + // When the sample was taken, and whether the list had to be cut short. + const foot = [ + // Claiming a sample time would be wrong when nothing was sampled. + this.feedState === "ok" && this.sampledAt ? `SAMPLED ${this.sampledAt}` : "", + this.hiddenCount ? `+${this.hiddenCount} MORE` : "", + // Without the root collector the backend can only read its own + // processes. Saying so beats passing one row off as the machine. + this.sampleScope === "self" ? "BACKEND ONLY" : "" + ].filter(Boolean).join(" "); + if (foot) { svg.append("text") - .attr("class", "syscall-warmup-indicator") - .attr("x", 252) - .attr("y", 27) - .text("WARMUP") + .attr("x", panelX) + .attr("y", 20 + frameH - 8) + .attr("class", "syscall-text syscall-foot syscall-box") .style("font-family", "Share Tech Mono, monospace") .style("font-size", "8px") .style("letter-spacing", "0.5px") - .style("fill", "rgba(182, 196, 210, 0.85)"); + // Dark ink: this line sits on the light frame, not on a dark panel. + .style("fill", "rgba(40, 44, 50, 0.6)") + .text(foot); } - - debugLog(`✅ Rendered ${this.currentSyscalls.length} system call elements`); + + // The subsystem bars sit under this panel, so tell them where it now ends: + // its height moves with the number of waiters. + const bottom = 20 + frameH; + if (window.__leftColumnCursor !== bottom) { + window.__leftColumnCursor = bottom; + if (window.SubsystemFocus) window.SubsystemFocus.reflow(); + } + + debugLog(`✅ Rendered ${rows.length} system call rows`); this.emitSubsystemFocus(this.pinnedSubsystemKey, "render-sync"); // Display active connections below system calls // this.displayActiveConnections(); diff --git a/static/js/threads-card.js b/static/js/threads-card.js new file mode 100644 index 0000000..11ccec9 --- /dev/null +++ b/static/js/threads-card.js @@ -0,0 +1,433 @@ +// The card the THREADS tile of the process dossier opens. +// +// A process owns the memory and the descriptors, but the thing Linux puts on a +// CPU is the thread. So the threads get a card of their own, in the same +// language as the syscall and interrupt cards: one row per task of the process, +// what it is doing, and what the scheduler has made of it. +// +// Each row carries two measurements that are easy to confuse and worth keeping +// apart. Time on the CPU is what the thread got. Time queued is what it spent +// runnable while the CPU was busy with something else — on a machine with one +// CPU that is usually the larger of the two, and it is the number that decides +// how the machine feels. +// +// The last column is the scheduler's own verdict, not our arithmetic: the +// kernel prints E or N for each task in debugfs, the lag beside it is how far +// that thread's virtual clock sits from the fair clock of its cgroup, and the +// deadline is what EEVDF actually compares when it picks. It is filled in only +// for the threads holding a place in the queue, which on an idle machine is one +// row out of twenty — a queue is the only thing those numbers describe. +const ThreadsCard = (() => { + const W = 640; + const PAD = 14; + const CUT = 15; + const HEADER = 25; + const LINE = 14; + const ROW_STEP = 17; + const FOOTER = 34; + const MAX_ROWS = 16; + + // Column geometry of a row, at full width. + const COL_NAME = 58; + const NAME_CHARS = 15; + const COL_STATE = 150; + const COL_WHERE = 166; + const WHERE_CHARS = 29; + const BAR_X = 338; + const BAR_W = 102; + const COL_TIMES = 520; + // Both scheduler columns are right-aligned, the verdict clear of the + // deadline that follows it. + const VERDICT_INSET = 52; + + let openPid = null; + let topKeeper = null; + let requestSeq = 0; + + function clip(text, max) { + const value = String(text || ""); + return value.length > max ? `${value.slice(0, max - 1)}…` : value; + } + + // Durations here span microseconds to hours, so the unit moves with the + // value rather than drowning the row in digits. + function dur(ms) { + const v = Number(ms); + if (!Number.isFinite(v)) return "—"; + if (v >= 600000) return `${Math.round(v / 60000)}m`; + if (v >= 60000) return `${(v / 60000).toFixed(1)}m`; + if (v >= 1000) return `${(v / 1000).toFixed(1)}s`; + if (v >= 1) return `${Math.round(v)}ms`; + return v > 0 ? "<1ms" : "0"; + } + + function close() { + openPid = null; + requestSeq += 1; + svg.selectAll(".threads-card-scrim, .threads-card-layer").remove(); + if (topKeeper) topKeeper.stop(); + d3.select("body").on("keydown.threadscard", null); + window.dispatchEvent(new CustomEvent("kcard-closed")); + } + + function open(pid, anchor) { + const key = Number(pid); + if (!Number.isFinite(key)) return; + if (openPid === key) { + close(); + return; + } + close(); + openPid = key; + const seq = ++requestSeq; + + fetch(`/api/process/${key}/threads`, { cache: "no-store" }) + .then((r) => r.json()) + .then((data) => { + if (seq !== requestSeq) return; + if (!data || data.error) { + openPid = null; + return; + } + draw(data, anchor); + }) + .catch(() => { + if (seq !== requestSeq) return; + openPid = null; + }); + } + + // What the row says the thread is doing, preferring the call it is parked + // in over the bare state letter, and never inventing one for the other. + function whereLabel(thread, hasCollector) { + if (thread.on_cpu) return "on the cpu now"; + // The state is read live and the parked call comes from a snapshot a + // second or two old, so a thread that is running now outranks whatever + // call it was last seen waiting in. + if (thread.state === "R") return "runnable, queued"; + if (thread.parked_in) { + return thread.wchan && thread.wchan !== "0" + ? `${thread.parked_in} · ${thread.wchan}` + : thread.parked_in; + } + if (!hasCollector) return thread.state_label || ""; + return thread.state === "S" ? "no call in flight" : (thread.state_label || ""); + } + + function stateClass(state) { + if (state === "R") return "kcard-state is-run"; + if (state === "D") return "kcard-state is-block"; + return "kcard-state"; + } + + function verdictLabel(thread) { + if (thread.eligible === undefined || thread.eligible === null) return null; + const lag = Number(thread.vlag_ms); + const mark = thread.eligible ? "E" : "N"; + if (!Number.isFinite(lag)) return mark; + const sign = lag > 0 ? "+" : ""; + return `${mark} ${sign}${Math.abs(lag) >= 100 ? Math.round(lag) : lag.toFixed(1)}`; + } + + // Virtual ms until this thread's turn must have happened; negative once the + // deadline has passed, which is how a task gets picked next. + function dueLabel(thread) { + const due = Number(thread.due_ms); + if (!Number.isFinite(due)) return null; + return `due ${Math.abs(due) >= 100 ? Math.round(due) : due.toFixed(1)}`; + } + + function draw(data, anchor) { + const svgNode = svg.node(); + const viewW = (svgNode && svgNode.clientWidth) || window.innerWidth; + const viewH = (svgNode && svgNode.clientHeight) || window.innerHeight; + const cw = Math.min(W, viewW - 24); + // Below this the table cannot hold every column, so the name and the + // bar go and the row keeps what it cannot be read without. + const compact = cw < 520; + + // Narrow, the name and the bar go and the remaining columns close up + // rather than keeping the gaps they left behind. + const colState = compact ? 52 : COL_STATE; + const colWhere = compact ? 68 : COL_WHERE; + const colTimes = compact ? cw - PAD : COL_TIMES; + const whereChars = compact + ? Math.max(10, Math.floor((colTimes - 82 - colWhere) / 5.4)) + : WHERE_CHARS; + + const all = Array.isArray(data.threads) ? data.threads : []; + const rows = all.slice(0, MAX_ROWS); + const totals = data.totals || {}; + const sources = data.sources || {}; + const hasCalls = !!(sources.parked_in && sources.parked_in.available); + const schedulerName = (data.scheduler && data.scheduler.name) || null; + const hasCollectorVerdict = !!(sources.scheduler && sources.scheduler.available); + const hasVerdict = hasCollectorVerdict && schedulerName === "EEVDF"; + const hidden = Math.max(0, Number(data.thread_count || all.length) - rows.length); + + const notes = []; + if (!hasCalls) notes.push("THE CALL EACH THREAD IS PARKED IN NEEDS THE ROOT COLLECTOR"); + else if (window.WaitsCard && !compact && rows.some((t) => t.parked_in)) { + notes.push("CLICK WHAT A THREAD IS PARKED IN TO SEE WHAT IT IS WAITING FOR"); + } + if (!hasCollectorVerdict) { + notes.push("THE SCHEDULER VERDICT NEEDS THE ROOT COLLECTOR"); + } else if (!hasVerdict) { + notes.push("THIS KERNEL SCHEDULES WITH CFS — IT KEEPS NO PER-THREAD DEADLINE"); + } else if (!compact) { + notes.push("E = ELIGIBLE NOW · LAG IS VIRTUAL MS OWED · EEVDF TAKES THE NEAREST DEADLINE"); + notes.push("ONLY A QUEUED THREAD HAS EITHER — THE REST ARE ASLEEP AND HOLD NO PLACE"); + } + + let h = HEADER + 12; + h += LINE; // count · cpus · scheduler + h += LINE; // together on the cpu / queued + h += 16 + LINE + LINE; // section, then the column legend + h += rows.length * ROW_STEP; + if (hidden) h += LINE; + h += 10 + notes.length * LINE; + h += FOOTER; + + // The card opens beside the dossier that spawned it, clear of the whole + // stack rather than of the tile, so the two are read side by side. + const from = anchor && Number.isFinite(anchor.x) ? anchor.x : 290; + let x = Number.isFinite(anchor && anchor.clearOf) ? anchor.clearOf : from + 44; + if (x + cw + 16 > viewW) x = from - cw - 44; + if (x < 12) x = Math.max(12, viewW - cw - 16); + let y = (anchor && anchor.y ? anchor.y : 90) - 40; + y = Math.max(12, Math.min(viewH - h - 12, y)); + + ensureDossierDefs(); + svg.append("rect") + .attr("class", "threads-card-scrim") + .attr("x", 0).attr("y", 0).attr("width", viewW).attr("height", viewH) + .attr("fill", ensureFocusVeilGradient()) + .style("opacity", 0) + .style("cursor", "pointer") + .on("click", () => close()) + .transition().duration(200).style("opacity", 1); + + const layer = svg.append("g").attr("class", "threads-card-layer"); + if (!topKeeper) { + topKeeper = createOverlayTopKeeper("threads-card-scrim", ["threads-card-layer"], () => openPid !== null); + } + topKeeper.start(); + + if (anchor && Number.isFinite(anchor.x) && Number.isFinite(anchor.y)) { + const connY = Math.max(y + 12, Math.min(y + h - 12, anchor.y)); + layer.append("circle") + .attr("class", "kcard-anchor") + .attr("cx", anchor.x).attr("cy", anchor.y).attr("r", 3); + layer.append("line") + .attr("class", "kcard-conn") + .attr("x1", anchor.x).attr("y1", anchor.y) + .attr("x2", anchor.x).attr("y2", anchor.y) + .transition().duration(220).ease(d3.easeCubicOut) + .attr("x2", x).attr("y2", connY); + } + + const panel = layer.append("g") + .attr("transform", `translate(${x}, ${y})`) + .on("click", (event) => event.stopPropagation()); + + panel.append("path") + .attr("class", "kcard-frame") + .attr("d", dossierCardPath(0, 0, cw, h, CUT)) + .attr("filter", "url(#dossier-drop)") + .attr("transform", `translate(0, ${h / 2}) scale(1, 0.02)`) + .transition().delay(120).duration(200).ease(d3.easeCubicOut) + .attr("transform", "translate(0,0) scale(1,1)"); + + const body = panel.append("g").attr("class", "threads-card-body").style("opacity", 0); + body.transition().delay(250).duration(180).style("opacity", 1); + + const text = (cls, tx, ty, value, anchorEnd) => body.append("text") + .attr("class", cls) + .attr("x", tx).attr("y", ty) + .attr("text-anchor", anchorEnd ? "end" : "start") + .text(value); + + body.append("path") + .attr("class", "kcard-strip") + .attr("d", `M0,0 H${cw - CUT} L${cw},${CUT} V${HEADER} H0 Z`); + body.append("circle") + .attr("class", "kcard-glyph-ring") + .attr("cx", PAD).attr("cy", HEADER / 2).attr("r", 4.2); + body.append("circle") + .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, + `THREADS · ${String(data.comm || "process").toUpperCase()}`); + text("kcard-meta", cw - 13, HEADER / 2 + 3.5, `PID ${data.pid}`, true) + .style("fill", "rgba(244, 244, 236, 0.5)"); + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", HEADER).attr("x2", cw).attr("y2", HEADER); + + let cy = HEADER + 12 + 10; + + const count = Number(data.thread_count || all.length); + const cpus = Number(data.cpus || 1); + const scheduler = data.scheduler || {}; + const identity = [ + `${count} ${count === 1 ? "THREAD" : "THREADS"}`, + `${cpus} ${cpus === 1 ? "CPU" : "CPUS"}`, + scheduler.name ? `${scheduler.name} · SLICE ${scheduler.slice_ms} MS` : null + ].filter(Boolean).join(" · "); + text("kcard-line", PAD, cy, identity); + cy += LINE; + + const ran = Number(totals.ran_ms || 0); + const queued = Number(totals.waited_ms || 0); + text("kcard-summary", PAD, cy, compact + ? `${dur(ran)} on the cpu · ${dur(queued)} queued` + : `together ${dur(ran)} on the cpu and ${dur(queued)} queued for it since they started`); + cy += LINE; + + // ── the table ────────────────────────────────────────────────────── + cy += 16; + const parked = Number(totals.parked || 0); + text("kcard-section", PAD, cy, + parked ? `EACH THREAD · ${parked} PARKED IN A CALL` : "EACH THREAD"); + cy += LINE; + + text("kcard-stage", PAD, cy, "TID"); + if (!compact) text("kcard-stage", COL_NAME, cy, "NAME"); + text("kcard-stage", colWhere, cy, "WHERE IT IS"); + if (!compact) text("kcard-stage", BAR_X, cy, "ON CPU · QUEUED"); + text("kcard-stage", colTimes, cy, "TIME", true); + if (hasVerdict && !compact) { + text("kcard-stage", cw - PAD - VERDICT_INSET, cy, "EEVDF", true); + text("kcard-stage", cw - PAD, cy, "DEADLINE", true); + } + cy += LINE; + + const spans = rows.map((t) => Number(t.ran_ms || 0) + Number(t.waited_ms || 0)); + const widest = Math.max(1, ...spans); + + rows.forEach((thread, i) => { + const ty = cy + 4 + i * ROW_STEP; + + if (thread.leader) { + body.append("circle") + .attr("class", "kcard-glyph-dot") + .attr("cx", PAD - 6).attr("cy", ty - 3).attr("r", 1.5); + } + text("kcard-waiter", PAD, ty, thread.tid); + if (!compact) { + text("kcard-waiter-dim", COL_NAME, ty, clip(thread.name, NAME_CHARS)); + } + // Nearly every thread on a machine is asleep, and the next column + // says what in. The letter is worth a glance only when it is not: + // R is competing for the CPU, D is a wait nothing can interrupt. + if (thread.state !== "S") { + text(stateClass(thread.state), colState, ty, thread.state); + } + const where = text(thread.parked_in ? "kcard-waiter-dim" : "kcard-faint", + colWhere, ty, clip(whereLabel(thread, hasCalls), whereChars)); + // A parked thread waits for something in particular, and that + // something can often be named: the far end of a pipe, or the + // other threads stuck on the same lock. The cell opens it. + if (thread.parked_in && window.WaitsCard) { + const box = where.node().getBBox(); + const rule = body.append("line") + .attr("class", "kcard-divider") + .attr("x1", colWhere).attr("y1", ty + 2.5) + .attr("x2", colWhere + box.width).attr("y2", ty + 2.5) + .attr("opacity", 0.3); + body.append("rect") + .attr("x", colWhere - 3).attr("y", ty - 9) + .attr("width", box.width + 8).attr("height", 13) + .attr("fill", "transparent") + .style("cursor", "pointer") + .on("mouseenter", () => { + where.attr("fill", "#e2a33e"); + rule.attr("stroke", "#e2a33e").attr("opacity", 0.7); + }) + .on("mouseleave", () => { + where.attr("fill", null); + rule.attr("stroke", null).attr("opacity", 0.3); + }) + .on("click", (event) => { + event.stopPropagation(); + window.WaitsCard.open(data.pid, thread.tid, { + x: x + colWhere + box.width + 6, + y: y + ty - 3, + clearOf: x + cw + 26, + parent: { x: x, y: y, w: cw, h: h } + }); + }); + } + + const span = spans[i]; + if (!compact && span > 0) { + const width = Math.max(2, BAR_W * (span / widest)); + const onCpu = width * (Number(thread.ran_ms || 0) / span); + body.append("rect") + .attr("class", "kcard-bar-bg") + .attr("x", BAR_X).attr("y", ty - 7) + .attr("width", BAR_W).attr("height", 6); + body.append("rect") + .attr("class", "kcard-bar-fill") + .attr("x", BAR_X).attr("y", ty - 7) + .attr("width", width).attr("height", 6); + if (onCpu > 0.5) { + body.append("rect") + .attr("class", "kcard-bar-fill is-top") + .attr("x", BAR_X).attr("y", ty - 7) + .attr("width", onCpu).attr("height", 6); + } + } + + text("kcard-waiter-dim", colTimes, ty, + `${dur(thread.ran_ms)} · ${dur(thread.waited_ms)}`, true); + + if (hasVerdict && !compact) { + const verdict = verdictLabel(thread); + const due = verdict ? dueLabel(thread) : null; + // A thread that holds no place in the queue has no lag and no + // deadline, and the dash says so rather than leaving the row + // looking like the collector missed it. + text(verdict ? (thread.eligible ? "kcard-symbol is-sleep" : "kcard-waiter-dim") + : "kcard-faint", + cw - PAD - VERDICT_INSET, ty, verdict || "—", true) + .style("font-size", "9px"); + if (due) { + text("kcard-waiter-dim", cw - PAD, ty, due, true) + .style("font-size", "9px"); + } + } + }); + cy += rows.length * ROW_STEP; + + if (hidden) { + text("kcard-faint", PAD, cy + 4, `+${hidden} MORE ${hidden === 1 ? "THREAD" : "THREADS"}`); + cy += LINE; + } + + cy += 10; + notes.forEach((note) => { + text("kcard-faint", PAD, cy, note); + cy += LINE; + }); + + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", h - FOOTER + 8).attr("x2", cw).attr("y2", h - FOOTER + 8); + text("kcard-foot", PAD, h - 10, "ESC OR CLICK OUTSIDE TO CLOSE"); + text("kcard-foot", cw - PAD, h - 10, `/PROC/${data.pid}/TASK`, true); + + d3.select("body").on("keydown.threadscard", (event) => { + if (event.key === "Escape") close(); + }); + } + + return { + open, + close, + isOpen: () => openPid !== null, + openedPid: () => openPid + }; +})(); + +window.ThreadsCard = ThreadsCard; diff --git a/static/js/waits-card.js b/static/js/waits-card.js new file mode 100644 index 0000000..9fede6b --- /dev/null +++ b/static/js/waits-card.js @@ -0,0 +1,486 @@ +// The card a parked thread opens when you ask what it is waiting for. +// +// Two kinds of answer live here, and they differ in how complete they can be. +// +// A pipe can be answered fully: it has no name, only an inode, so the other end +// is whoever else holds a descriptor on that inode — a fact, once someone has +// walked every descriptor on the machine. +// +// A futex cannot. The word a thread waits on is exact, and so is the set of +// threads waiting on the same word. The holder is not: userspace takes the lock +// without telling the kernel, and nothing in /proc records who won it. Rather +// than name a likely thread and call it the owner, the card says why the +// question has no answer here and lists the threads that are not waiting — on a +// contended lock that set is usually one thread long, and the reader can draw +// the conclusion the card refuses to assert. +const WaitsCard = (() => { + const W = 580; + const PAD = 14; + const CUT = 15; + const HEADER = 25; + const LINE = 14; + const ROW_STEP = 16; + const FOOTER = 34; + const MAX_ROWS = 8; + + const COL_NAME = 74; + const COL_NOTE = 186; + + let openKey = null; + let topKeeper = null; + let requestSeq = 0; + + function clip(text, max) { + const value = String(text || ""); + return value.length > max ? `${value.slice(0, max - 1)}…` : value; + } + + // A sentence too long for the card is broken at a word, not cut off: these + // lines carry the reason an answer is missing, and half of that is worse + // than none. + function wrap(text, max, lines) { + const words = String(text || "").split(/\s+/).filter(Boolean); + const out = []; + let line = ""; + words.forEach((word) => { + if (!line.length) line = word; + else if (line.length + 1 + word.length <= max) line += ` ${word}`; + else { out.push(line); line = word; } + }); + if (line) out.push(line); + if (out.length > lines) { + return out.slice(0, lines - 1).concat(clip(out.slice(lines - 1).join(" "), max)); + } + return out; + } + + function close() { + openKey = null; + requestSeq += 1; + svg.selectAll(".waits-card-scrim, .waits-card-layer").remove(); + if (topKeeper) topKeeper.stop(); + d3.select("body").on("keydown.waitscard", null); + window.dispatchEvent(new CustomEvent("kcard-closed")); + } + + function open(pid, tid, anchor) { + const key = `${pid}:${tid}`; + if (openKey === key) { + close(); + return; + } + close(); + openKey = key; + const seq = ++requestSeq; + + fetch(`/api/process/${pid}/thread/${tid}/wait`, { cache: "no-store" }) + .then((r) => r.json()) + .then((data) => { + if (seq !== requestSeq) return; + if (!data || data.error) { + openKey = null; + return; + } + draw(data, anchor); + }) + .catch((err) => { + if (seq !== requestSeq) return; + openKey = null; + if (window.frontendLogger) { + window.frontendLogger.error("waits card failed to draw", { + source: "waits-card", stack: String((err && err.stack) || err) + }); + } + }); + } + + // The first line of the card: the call, and what kind of wait it is. + function headline(data) { + const on = data.waiting_on; + if (on && on.kind === "futex" && on.op) { + const flags = [on.op.name]; + if (on.op.private) flags.push("private"); + if (on.op.realtime) flags.push("realtime clock"); + return flags.join(" · "); + } + if (on && on.kind === "pipe") { + return `${on.direction || "holding"} pipe:[${on.inode}]${on.fd === null || on.fd === undefined ? "" : ` on fd ${on.fd}`}`; + } + if (data.call) return data.wchan ? `${data.call} · ${data.wchan}` : data.call; + return data.state_label || "not waiting in a call"; + } + + function draw(data, anchor) { + const svgNode = svg.node(); + const viewW = (svgNode && svgNode.clientWidth) || window.innerWidth; + const viewH = (svgNode && svgNode.clientHeight) || window.innerHeight; + const cw = Math.min(W, viewW - 24); + const compact = cw < 460; + + const on = data.waiting_on || null; + const isFutex = !!on && on.kind === "futex"; + const isPipe = !!on && on.kind === "pipe"; + const isEpoll = !!on && on.kind === "epoll"; + const sources = data.sources || {}; + const parkedSrc = sources.parked_in || {}; + const endpointsSrc = sources.endpoints || {}; + + const waiters = isFutex ? (on.waiters || []).slice(0, MAX_ROWS) : []; + // A short list of siblings is an answer in itself; a long one is not, + // and then only the threads that can be running are worth naming. + const pool = isFutex ? (on.candidates || {}) : {}; + const poolTotal = Number(pool.total || 0); + const listAll = poolTotal > 0 && poolTotal <= 6; + const candidates = (listAll ? (pool.sample || []) : (pool.running || [])).slice(0, MAX_ROWS); + const watched = isEpoll ? (on.watched || []).slice(0, MAX_ROWS) : []; + const farEnd = isPipe ? (on.other_end || []).slice(0, MAX_ROWS) : []; + const nearEnd = isPipe ? (on.same_end || []).slice(0, MAX_ROWS) : []; + const seen = data.seen_waking || {}; + const seenWakers = (seen.wakers || []).slice(0, MAX_ROWS); + const seenAvailable = !!seen.available; + const locks = (data.locks || []).slice(0, 4); + const ownerLines = isFutex + ? wrap(`the kernel does not record it — ${(on.owner && on.owner.why) || ""}`, + compact ? 52 : 76, 2) + : []; + + const notes = []; + if (!parkedSrc.available) notes.push("THE CALL A THREAD IS PARKED IN NEEDS THE ROOT COLLECTOR"); + else if (isPipe && !endpointsSrc.available) notes.push("PAIRING THE ENDS OF A PIPE NEEDS THE ROOT COLLECTOR"); + if (!seenAvailable) notes.push("WHO WAS SEEN WAKING IT NEEDS THE WAKEUP COLLECTOR"); + + // ── height ───────────────────────────────────────────────────────── + let h = HEADER + 12 + 10; + h += LINE; // headline + if (isFutex && on.op) h += LINE; // what that kind of wait means + if (isPipe) h += LINE + LINE; // what would end the wait, self-pipe note + if (isFutex) h += 16 + LINE + LINE; // the word + if (isFutex) h += 16 + LINE + waiters.length * ROW_STEP; + if (isFutex) h += 16 + LINE + ownerLines.length * LINE + LINE + candidates.length * ROW_STEP; + if (isEpoll) h += 16 + LINE + LINE + watched.length * ROW_STEP + LINE; + if (isPipe) h += 16 + LINE + Math.max(1, farEnd.length) * ROW_STEP; + if (isPipe && nearEnd.length) h += 16 + LINE + nearEnd.length * ROW_STEP; + if (!on) h += 16 + LINE; + h += 16 + LINE + LINE + Math.max(1, seenWakers.length) * ROW_STEP; + if (locks.length) h += 16 + LINE + locks.length * ROW_STEP; + if (notes.length) h += 10 + notes.length * LINE; + h += FOOTER; + + // This card is opened from inside another one, so where it lands says + // whether the two are related. Beside its parent if the screen allows, + // on either side; and when neither side fits, stacked over the parent + // with a corner of it left showing, rather than dropped onto some + // unrelated card across the map. + const from = anchor && Number.isFinite(anchor.x) ? anchor.x : 300; + const parent = (anchor && anchor.parent) || null; + let x = Number.isFinite(anchor && anchor.clearOf) ? anchor.clearOf : from + 40; + let y = (anchor && Number.isFinite(anchor.y) ? anchor.y : 120) - 30; + let stacked = false; + if (x + cw + 16 > viewW) { + const beside = parent ? parent.x - cw - 26 : from - cw - 40; + if (beside >= 12) { + x = beside; + } else if (parent) { + x = Math.min(parent.x + 28, viewW - cw - 12); + y = parent.y + 28; + stacked = true; + } else { + x = Math.max(12, viewW - cw - 16); + } + } + x = Math.max(12, x); + y = Math.max(12, Math.min(viewH - h - 12, y)); + + ensureDossierDefs(); + svg.append("rect") + .attr("class", "waits-card-scrim") + .attr("x", 0).attr("y", 0).attr("width", viewW).attr("height", viewH) + .attr("fill", ensureFocusVeilGradient()) + .style("opacity", 0) + .style("cursor", "pointer") + .on("click", () => close()) + .transition().duration(200).style("opacity", 1); + + const layer = svg.append("g").attr("class", "waits-card-layer"); + if (!topKeeper) { + topKeeper = createOverlayTopKeeper("waits-card-scrim", ["waits-card-layer"], () => openKey !== null); + } + topKeeper.start(); + + if (!stacked && anchor && Number.isFinite(anchor.x) && Number.isFinite(anchor.y)) { + const connY = Math.max(y + 12, Math.min(y + h - 12, anchor.y)); + layer.append("circle") + .attr("class", "kcard-anchor") + .attr("cx", anchor.x).attr("cy", anchor.y).attr("r", 3); + layer.append("line") + .attr("class", "kcard-conn") + .attr("x1", anchor.x).attr("y1", anchor.y) + .attr("x2", anchor.x).attr("y2", anchor.y) + .transition().duration(220).ease(d3.easeCubicOut) + .attr("x2", x).attr("y2", connY); + } + + const panel = layer.append("g") + .attr("transform", `translate(${x}, ${y})`) + .on("click", (event) => event.stopPropagation()); + + panel.append("path") + .attr("class", "kcard-frame") + .attr("d", dossierCardPath(0, 0, cw, h, CUT)) + .attr("filter", "url(#dossier-drop)") + .attr("transform", `translate(0, ${h / 2}) scale(1, 0.02)`) + .transition().delay(120).duration(200).ease(d3.easeCubicOut) + .attr("transform", "translate(0,0) scale(1,1)"); + + const body = panel.append("g").attr("class", "waits-card-body").style("opacity", 0); + body.transition().delay(250).duration(180).style("opacity", 1); + + const text = (cls, tx, ty, value, anchorEnd) => body.append("text") + .attr("class", cls) + .attr("x", tx).attr("y", ty) + .attr("text-anchor", anchorEnd ? "end" : "start") + .text(value); + + body.append("path") + .attr("class", "kcard-strip") + .attr("d", `M0,0 H${cw - CUT} L${cw},${CUT} V${HEADER} H0 Z`); + body.append("circle") + .attr("class", "kcard-glyph-ring") + .attr("cx", PAD).attr("cy", HEADER / 2).attr("r", 4.2); + body.append("circle") + .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, + `WAITING · ${String(data.comm || data.process || "thread").toUpperCase()}`); + text("kcard-meta", cw - 13, HEADER / 2 + 3.5, `TID ${data.tid}`, true) + .style("fill", "rgba(244, 244, 236, 0.5)"); + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", HEADER).attr("x2", cw).attr("y2", HEADER); + + let cy = HEADER + 12 + 10; + + text("kcard-line", PAD, cy, clip(headline(data), compact ? 44 : 66)); + cy += LINE; + if (isFutex && on.op) { + text("kcard-summary", PAD, cy, clip(on.op.means, compact ? 52 : 74)); + cy += LINE; + } + if (isPipe) { + text("kcard-summary", PAD, cy, clip(on.direction === "writing" + ? "the write returns once a reader has taken what is in the pipe" + : "the read returns when someone writes, or when the last writer closes it", + compact ? 52 : 76)); + cy += LINE; + } + + // ── the futex ────────────────────────────────────────────────────── + if (isFutex) { + cy += 16; + text("kcard-section", PAD, cy, "THE WORD IT WAITS ON"); + cy += LINE; + const bits = [on.word || "address unknown"]; + if (on.expected !== null && on.expected !== undefined) bits.push(`expected ${on.expected}`); + bits.push(on.scope); + text("kcard-waiter", PAD, cy, clip(bits.join(" · "), compact ? 44 : 66)); + cy += LINE; + + cy += 16; + const n = Number(on.waiter_count || (on.waiters || []).length); + text("kcard-section", PAD, cy, + n === 1 ? "NO OTHER THREAD WAITS ON IT" : `WAITING ON IT TOGETHER · ${n} THREADS`); + cy += LINE; + waiters.forEach((w, i) => { + const ty = cy + 4 + i * ROW_STEP; + if (w.self) { + body.append("circle") + .attr("class", "kcard-glyph-dot") + .attr("cx", PAD - 6).attr("cy", ty - 3).attr("r", 1.5); + } + text(w.self ? "kcard-waiter" : "kcard-waiter-dim", PAD, ty, w.tid); + text("kcard-waiter-dim", COL_NAME, ty, clip(w.comm, 14)); + if (w.self) text("kcard-inferred", COL_NOTE, ty, "this thread"); + }); + cy += waiters.length * ROW_STEP; + + cy += 16; + text("kcard-section", PAD, cy, "WHO HOLDS IT"); + cy += LINE; + ownerLines.forEach((line) => { + text("kcard-summary", PAD, cy, line); + cy += LINE; + }); + let lead; + if (!poolTotal) { + lead = "EVERY THREAD OF THIS PROCESS WAITS ON IT — THE HOLDER IS ELSEWHERE"; + } else if (listAll) { + lead = poolTotal === 1 + ? "ONE THREAD OF THIS PROCESS IS NOT WAITING FOR IT" + : `THE ${poolTotal} THREADS OF THIS PROCESS THAT ARE NOT WAITING FOR IT`; + } else if (candidates.length) { + lead = `OF ${poolTotal} THREADS NOT WAITING FOR IT, THESE CAN BE RUNNING NOW`; + } else { + lead = `${poolTotal} THREADS ARE NOT WAITING FOR IT, AND NONE OF THEM IS RUNNING NOW`; + } + text("kcard-faint", PAD, cy, lead); + cy += LINE; + candidates.forEach((c, i) => { + const ty = cy + 4 + i * ROW_STEP; + text("kcard-waiter-dim", PAD, ty, c.tid); + text("kcard-waiter-dim", COL_NAME, ty, clip(c.comm, 14)); + text(c.state === "R" ? "kcard-state is-run" : "kcard-faint", COL_NOTE, ty, + clip(c.state === "R" ? "on the cpu or queued" : (c.parked_in || c.state_label), 30)); + }); + cy += candidates.length * ROW_STEP; + } + + // ── the epoll set ────────────────────────────────────────────────── + if (isEpoll) { + cy += 16; + const total = Number(on.total || 0); + text("kcard-section", PAD, cy, + `WATCHING ${total} ${total === 1 ? "DESCRIPTOR" : "DESCRIPTORS"} · WAKES ON THE FIRST TO STIR`); + cy += LINE; + const kinds = Object.entries(on.kinds || {}) + .sort((a, b) => b[1] - a[1]) + .slice(0, compact ? 3 : 5) + .map(([kind, n]) => `${n} ${kind}${n > 1 && !kind.endsWith("s") ? "s" : ""}`); + text("kcard-summary", PAD, cy, clip(kinds.join(" · "), compact ? 52 : 74)); + cy += LINE; + watched.forEach((w, i) => { + const ty = cy + 4 + i * ROW_STEP; + text("kcard-waiter-dim", PAD, ty, w.count > 1 ? `${w.count} ×` : `fd ${w.fd}`); + text("kcard-waiter-dim", COL_NAME - 20, ty, clip(w.label || w.kind, compact ? 24 : 42)); + if (w.waiting_for) { + text("kcard-faint", cw - PAD, ty, `for ${w.waiting_for}`, true); + } + }); + cy += watched.length * ROW_STEP; + const covered = watched.reduce((sum, w) => sum + (w.count || 1), 0); + if (total > covered) { + text("kcard-faint", PAD, cy + 4, `AND ${total - covered} MORE IN THE SET`); + cy += LINE; + } + } + + // ── the pipe ─────────────────────────────────────────────────────── + if (isPipe) { + cy += 16; + text("kcard-section", PAD, cy, "THE OTHER END"); + cy += LINE; + if (!farEnd.length) { + text("kcard-faint", PAD, cy + 4, + "NO ONE HOLDS IT — A READ HERE RETURNS END OF FILE"); + cy += ROW_STEP; + } + farEnd.forEach((r, i) => { + const ty = cy + 4 + i * ROW_STEP; + text("kcard-waiter", PAD, ty, r.pid); + text("kcard-waiter-dim", COL_NAME, ty, clip(r.comm, 16)); + text("kcard-faint", COL_NOTE, ty, + `fd ${r.fd} · ${on.direction === "reading" ? "writes into it" : "reads from it"}`); + }); + if (farEnd.length) cy += farEnd.length * ROW_STEP; + if (farEnd.length && farEnd.every((r) => r.pid === data.pid)) { + text("kcard-faint", PAD, cy + 4, + "BOTH ENDS ARE HELD HERE — A PROCESS WAKING ITSELF"); + cy += LINE; + } + + if (nearEnd.length) { + cy += 16; + text("kcard-section", PAD, cy, "SHARING THIS END"); + cy += LINE; + nearEnd.forEach((r, i) => { + const ty = cy + 4 + i * ROW_STEP; + text("kcard-waiter-dim", PAD, ty, r.pid); + text("kcard-waiter-dim", COL_NAME, ty, clip(r.comm, 16)); + text("kcard-faint", COL_NOTE, ty, `fd ${r.fd} · inherited copy`); + }); + cy += nearEnd.length * ROW_STEP; + } + } + + if (!on) { + cy += 16; + text("kcard-faint", PAD, cy, + data.call ? "THIS CALL WAITS ON NOTHING THAT CAN BE PAIRED FROM /PROC" + : "THIS THREAD IS NOT PARKED IN A CALL"); + cy += LINE; + } + + // ── who was seen waking it ───────────────────────────────────────── + cy += 16; + text("kcard-section", PAD, cy, "WHO WAS SEEN WAKING IT"); + cy += LINE; + const windowMs = Math.round(Number(seen.window_s || 0) * 1000); + if (!seenAvailable) { + text("kcard-faint", PAD, cy + 4, "NO WINDOW OF WAKEUPS IS AVAILABLE"); + cy += ROW_STEP; + } else if (!seenWakers.length) { + text("kcard-faint", PAD, cy + 4, + windowMs + ? `NOBODY IN THE LAST ${windowMs} MS WINDOW — THAT IS A SAMPLE, NOT A CENSUS` + : "NOBODY IN THE LAST WINDOW — THAT IS A SAMPLE, NOT A CENSUS"); + cy += ROW_STEP; + } else { + text("kcard-summary", PAD, cy, clip( + isFutex + ? `in the last ${windowMs} ms — not the holder now, the last one seen letting go` + : `in the last ${windowMs} ms window of sched_wakeup`, + compact ? 52 : 76)); + cy += LINE; + seenWakers.forEach((w, i) => { + const ty = cy + 4 + i * ROW_STEP; + const where = Object.entries(w.contexts || {}).sort((a, b) => b[1] - a[1])[0]; + const tag = w.idle ? "irq" : (where ? where[0] : "task"); + text(w.idle ? "kcard-faint" : "kcard-waiter", PAD, ty, w.tid === 0 ? "idle" : w.tid); + text("kcard-waiter-dim", COL_NAME, ty, clip(w.comm, 14)); + text("kcard-faint", COL_NOTE, ty, + `${w.count}× · ${tag}${w.of && w.of.length > 1 ? ` · ${w.of.length} waiters` : ""}`); + }); + cy += seenWakers.length * ROW_STEP; + } + + if (locks.length) { + cy += 16; + text("kcard-section", PAD, cy, "FILE LOCKS HELD BY THIS PROCESS"); + cy += LINE; + locks.forEach((row, i) => { + const ty = cy + 4 + i * ROW_STEP; + text("kcard-waiter-dim", PAD, ty, row.kind); + text("kcard-waiter-dim", COL_NAME, ty, row.mode); + text("kcard-faint", COL_NOTE, ty, clip( + `${row.path || row.inode}${row.waiting ? " · blocked on it" : ""}`, + compact ? 26 : 52)); + }); + cy += locks.length * ROW_STEP; + } + + if (notes.length) { + cy += 10; + notes.forEach((note) => { + text("kcard-faint", PAD, cy, note); + cy += LINE; + }); + } + + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", h - FOOTER + 8).attr("x2", cw).attr("y2", h - FOOTER + 8); + text("kcard-foot", PAD, h - 10, "ESC OR CLICK OUTSIDE TO CLOSE"); + text("kcard-foot", cw - PAD, h - 10, `/PROC/${data.pid}/TASK/${data.tid}`, true); + + d3.select("body").on("keydown.waitscard", (event) => { + if (event.key === "Escape") close(); + }); + } + + return { + open, + close, + isOpen: () => openKey !== null + }; +})(); + +window.WaitsCard = WaitsCard; diff --git a/static/js/wakeups-card.js b/static/js/wakeups-card.js new file mode 100644 index 0000000..9fe0271 --- /dev/null +++ b/static/js/wakeups-card.js @@ -0,0 +1,311 @@ +// What the scheduler bar opens: who woke whom, over one sampled window. +// +// Every other card in this project shows a state that was simply there to be +// read. This one shows events that no longer exist by the time they are drawn, +// caught by a tracepoint for a quarter of a second at a time. That difference +// is the card's main honesty problem, so the window, its length and the gap +// between windows are said plainly rather than dressed up as a live feed. +const WakeupsCard = (() => { + const W = 620; + const PAD = 14; + const CUT = 15; + const HEADER = 25; + const LINE = 14; + const ROW_STEP = 16; + const FOOTER = 34; + const MAX_ROWS = 10; + + const COL_WOKEN = 220; + const TIMES_INSET = 62; + + let isOpen = false; + let topKeeper = null; + let requestSeq = 0; + + const CONTEXT_TAG = { task: "task", softirq: "softirq", hardirq: "irq" }; + + function clip(text, max) { + const value = String(text || ""); + return value.length > max ? `${value.slice(0, max - 1)}…` : value; + } + + function close() { + isOpen = false; + requestSeq += 1; + svg.selectAll(".wakeups-card-scrim, .wakeups-card-layer").remove(); + if (topKeeper) topKeeper.stop(); + d3.select("body").on("keydown.wakeupscard", null); + window.dispatchEvent(new CustomEvent("kcard-closed")); + } + + function open(anchor) { + if (isOpen) { + close(); + return; + } + isOpen = true; + const seq = ++requestSeq; + fetch("/api/wakeups", { cache: "no-store" }) + .then((r) => r.json()) + .then((data) => { + if (seq !== requestSeq) return; + draw(data || {}, anchor); + }) + .catch((err) => { + if (seq !== requestSeq) return; + isOpen = false; + if (window.frontendLogger) { + window.frontendLogger.error("wakeups card failed to draw", { + source: "wakeups-card", stack: String((err && err.stack) || err) + }); + } + }); + } + + // The dominant context is the one sentence worth drawing from a window: + // it says whether this machine is woken by its own software or by the world + // outside it. + function verdict(contexts, events) { + const rows = Object.entries(contexts || {}) + .map(([name, item]) => [name, Number((item && item.count) || 0)]) + .sort((a, b) => b[1] - a[1]); + if (!rows.length || !events) return null; + const [name, count] = rows[0]; + const share = Math.round((count / events) * 100); + if (name === "hardirq") { + return `${share}% of it came from hardware interrupts — this machine is woken from outside`; + } + if (name === "softirq") { + return `${share}% of it came from deferred kernel work — the network and timer path`; + } + return `${share}% of it was one task deciding another should run`; + } + + function side(end) { + if (!end) return ""; + if (end.idle) return "idle cpu"; + const tid = end.tid === null || end.tid === undefined ? "" : ` ${end.tid}`; + return `${clip(end.comm, 16)}${tid}`; + } + + function draw(data, anchor) { + const svgNode = svg.node(); + const viewW = (svgNode && svgNode.clientWidth) || window.innerWidth; + const viewH = (svgNode && svgNode.clientHeight) || window.innerHeight; + const cw = Math.min(W, viewW - 24); + const compact = cw < 480; + + const available = !!data.available; + const edges = (data.edges || []).slice(0, MAX_ROWS); + const events = Number(data.events || 0); + const window_s = Number(data.window_s || 0); + const lost = Number(data.lost || 0); + const line = available ? verdict(data.contexts, events) : null; + const hasObserver = edges.some((e) => (e.waker && e.waker.observer) + || (e.woken && e.woken.observer)); + + // A pair with a big count and a task with many partners are different + // shapes of busy: one is a conversation, the other is a hub. + const hub = (row, verb) => (row && row.count + ? `${clip(row.comm, 16)} ${row.tid} ${verb} ${row.count} times across ${row.partners} ${row.partners === 1 ? "task" : "tasks"}` + : null); + const hubs = available + ? [hub((data.wakers || [])[0], "woke"), hub((data.wakees || [])[0], "was woken")] + .filter(Boolean) + : []; + + const reason = ((data.source || {}).reason) || ""; + const missing = available ? null + : (reason === "stale" ? "THE LAST WINDOW IS TOO OLD TO SHOW" + : "SAMPLING WAKEUPS NEEDS THE ROOT COLLECTOR"); + + const notes = []; + if (available) { + notes.push("A WINDOW IS A SAMPLE — BETWEEN WINDOWS THE MACHINE WAKES UNOBSERVED"); + if (hasObserver) notes.push("THE SAMPLER WAKES WHAT IT READS — ITS OWN ROWS ARE MARKED"); + if (lost) notes.push(`${lost} EVENTS OVERRAN THE BUFFER AND WERE LOST`); + } + + // ── height ───────────────────────────────────────────────────────── + let h = HEADER + 12 + 10; + if (!available) { + h += LINE; + } else { + h += LINE; // how many, and whether any were lost + h += LINE; // the split by context + if (line) h += LINE; // what that split means + h += 16 + LINE + LINE + edges.length * ROW_STEP; + if (hubs.length) h += 16 + LINE + hubs.length * LINE; + } + if (notes.length) h += 10 + notes.length * LINE; + h += FOOTER; + + const from = anchor && Number.isFinite(anchor.x) ? anchor.x : 240; + let x = Number.isFinite(anchor && anchor.clearOf) ? anchor.clearOf : from + 40; + if (x + cw + 16 > viewW) x = Math.max(12, viewW - cw - 16); + let y = (anchor && Number.isFinite(anchor.y) ? anchor.y : 120) - 24; + y = Math.max(12, Math.min(viewH - h - 12, y)); + + ensureDossierDefs(); + svg.append("rect") + .attr("class", "wakeups-card-scrim") + .attr("x", 0).attr("y", 0).attr("width", viewW).attr("height", viewH) + .attr("fill", ensureFocusVeilGradient()) + .style("opacity", 0) + .style("cursor", "pointer") + .on("click", () => close()) + .transition().duration(200).style("opacity", 1); + + const layer = svg.append("g").attr("class", "wakeups-card-layer"); + if (!topKeeper) { + topKeeper = createOverlayTopKeeper("wakeups-card-scrim", ["wakeups-card-layer"], () => isOpen); + } + topKeeper.start(); + + if (anchor && Number.isFinite(anchor.x) && Number.isFinite(anchor.y)) { + const connY = Math.max(y + 12, Math.min(y + h - 12, anchor.y)); + layer.append("circle") + .attr("class", "kcard-anchor") + .attr("cx", anchor.x).attr("cy", anchor.y).attr("r", 3); + layer.append("line") + .attr("class", "kcard-conn") + .attr("x1", anchor.x).attr("y1", anchor.y) + .attr("x2", anchor.x).attr("y2", anchor.y) + .transition().duration(220).ease(d3.easeCubicOut) + .attr("x2", x).attr("y2", connY); + } + + const panel = layer.append("g") + .attr("transform", `translate(${x}, ${y})`) + .on("click", (event) => event.stopPropagation()); + + panel.append("path") + .attr("class", "kcard-frame") + .attr("d", dossierCardPath(0, 0, cw, h, CUT)) + .attr("filter", "url(#dossier-drop)") + .attr("transform", `translate(0, ${h / 2}) scale(1, 0.02)`) + .transition().delay(120).duration(200).ease(d3.easeCubicOut) + .attr("transform", "translate(0,0) scale(1,1)"); + + const body = panel.append("g").attr("class", "wakeups-card-body").style("opacity", 0); + body.transition().delay(250).duration(180).style("opacity", 1); + + const text = (cls, tx, ty, value, anchorEnd) => body.append("text") + .attr("class", cls) + .attr("x", tx).attr("y", ty) + .attr("text-anchor", anchorEnd ? "end" : "start") + .text(value); + + body.append("path") + .attr("class", "kcard-strip") + .attr("d", `M0,0 H${cw - CUT} L${cw},${CUT} V${HEADER} H0 Z`); + body.append("circle") + .attr("class", "kcard-glyph-ring") + .attr("cx", PAD).attr("cy", HEADER / 2).attr("r", 4.2); + body.append("circle") + .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, "WAKEUPS · WHO STARTS WHOM"); + if (available && data.rate_per_s) { + text("kcard-meta", cw - 13, HEADER / 2 + 3.5, `${data.rate_per_s} / S`, true) + .style("fill", "rgba(244, 244, 236, 0.5)"); + } + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", HEADER).attr("x2", cw).attr("y2", HEADER); + + let cy = HEADER + 12 + 10; + + if (!available) { + text("kcard-faint", PAD, cy, missing); + cy += LINE; + } else { + const ms = Math.round(window_s * 1000); + text("kcard-line", PAD, cy, + `${events} wakeups in a window of ${ms} ms${lost ? `, ${lost} lost` : ""}`); + cy += LINE; + + const split = Object.entries(data.contexts || {}) + .sort((a, b) => (b[1].count || 0) - (a[1].count || 0)) + .map(([name, item]) => `${item.count} ${CONTEXT_TAG[name] || name}`); + text("kcard-summary", PAD, cy, clip(split.join(" · "), compact ? 46 : 70)); + cy += LINE; + if (line) { + text("kcard-summary", PAD, cy, clip(line, compact ? 52 : 80)); + cy += LINE; + } + + cy += 16; + const distinct = Number(data.distinct_edges || edges.length); + text("kcard-section", PAD, cy, distinct > edges.length + ? `WHO WAKES WHOM · ${edges.length} BUSIEST OF ${distinct} PAIRS` + : "WHO WAKES WHOM"); + cy += LINE; + + text("kcard-stage", PAD, cy, "WAKER"); + text("kcard-stage", COL_WOKEN, cy, "WOKEN"); + text("kcard-stage", cw - PAD - TIMES_INSET, cy, "TIMES", true); + text("kcard-stage", cw - PAD, cy, "FROM", true); + cy += LINE; + + edges.forEach((edge, i) => { + const ty = cy + 4 + i * ROW_STEP; + const waker = edge.waker || {}; + const woken = edge.woken || {}; + if (waker.observer || woken.observer) { + body.append("circle") + .attr("class", "kcard-glyph-dot") + .attr("cx", PAD - 6).attr("cy", ty - 3).attr("r", 1.5); + } + text(waker.idle ? "kcard-faint" : "kcard-waiter", PAD, ty, side(waker)); + text("kcard-waiter-dim", COL_WOKEN, ty, side(woken)); + text("kcard-waiter-dim", cw - PAD - TIMES_INSET, ty, edge.count, true); + const where = Object.entries(edge.contexts || {}) + .sort((a, b) => b[1] - a[1])[0]; + const tag = edge.new ? "first run" : (where ? (CONTEXT_TAG[where[0]] || where[0]) : ""); + text(where && where[0] === "hardirq" ? "kcard-symbol is-sleep" : "kcard-faint", + cw - PAD, ty, tag, true); + }); + cy += edges.length * ROW_STEP; + + if (hubs.length) { + cy += 16; + text("kcard-section", PAD, cy, "THE BUSIEST ENDS OF THE WINDOW"); + cy += LINE; + hubs.forEach((hubLine) => { + text("kcard-summary", PAD, cy, clip(hubLine, compact ? 52 : 78)); + cy += LINE; + }); + } + } + + if (notes.length) { + cy += 10; + notes.forEach((note) => { + text("kcard-faint", PAD, cy, note); + cy += LINE; + }); + } + + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", h - FOOTER + 8).attr("x2", cw).attr("y2", h - FOOTER + 8); + text("kcard-foot", PAD, h - 10, "ESC OR CLICK OUTSIDE TO CLOSE"); + const age = Number((data.source || {}).age_s); + if (Number.isFinite(age)) { + text("kcard-foot", cw - PAD, h - 10, `WINDOW FROM ${age.toFixed(1)} S AGO`, true); + } + + d3.select("body").on("keydown.wakeupscard", (event) => { + if (event.key === "Escape") close(); + }); + } + + return { + open, + close, + isOpen: () => isOpen + }; +})(); + +window.WakeupsCard = WakeupsCard; diff --git a/templates/linux-crypto-subsystem.html b/templates/linux-crypto-subsystem.html index 96a413b..88f962c 100644 --- a/templates/linux-crypto-subsystem.html +++ b/templates/linux-crypto-subsystem.html @@ -140,7 +140,7 @@

Linux Crypto Kernel Visualization

- + - + - +