From 1a2f069caaca81fc68f6e92a398342cb1e49ed77 Mon Sep 17 00:00:00 2001 From: Aleksei Fedorov Date: Mon, 15 Jun 2026 14:44:09 +0000 Subject: [PATCH] service communication channels + filesystem --- index.html | 10 +- kernel_ai/api/rest.py | 1 + kernel_ai/http/api.py | 2 + kernel_ai/http/api_handlers/kernel.py | 12 + kernel_ai/services/core_observability.py | 107 ++ kernel_ai/services/process_inspect.py | 164 ++- kernel_ai/services/telemetry_orchestration.py | 4 + static/css/main.css | 18 + static/js/flow-ui.js | 17 + static/js/ipc-ui.js | 155 ++- static/js/main.js | 1037 ++++++++++++++++- 11 files changed, 1467 insertions(+), 60 deletions(-) diff --git a/index.html b/index.html index 97d086c..1459a69 100755 --- a/index.html +++ b/index.html @@ -31,7 +31,7 @@ - + @@ -84,13 +84,13 @@

Linux Kernel Ring 0 Visualization

- - + + - + @@ -108,6 +108,6 @@

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 7bba879..f76f9aa 100644 --- a/kernel_ai/api/rest.py +++ b/kernel_ai/api/rest.py @@ -10,6 +10,7 @@ ("/process-kernel-map", "process_kernel_map", h.process_kernel_map, None), ("/processes", "get_processes", h.get_processes, None), ("/nginx-files", "nginx_files", h.nginx_files, None), + ("/io-open-files", "io_open_files", h.io_open_files, None), ("/active-connections", "active_connections", h.active_connections, None), ("/traceroute", "traceroute_info", h.traceroute_info, None), ("/network-stack-realtime", "network_stack_realtime", h.network_stack_realtime, None), diff --git a/kernel_ai/http/api.py b/kernel_ai/http/api.py index 42c26f4..7057714 100644 --- a/kernel_ai/http/api.py +++ b/kernel_ai/http/api.py @@ -2,6 +2,7 @@ from kernel_ai.http.api_handlers.kernel import ( get_execution_context, + io_open_files, kernel_data, kernel_dna, nginx_files, @@ -56,6 +57,7 @@ "get_processes", "get_processes_detailed", "ingest_frontend_logs", + "io_open_files", "isolation_context", "kernel_data", "kernel_dna", diff --git a/kernel_ai/http/api_handlers/kernel.py b/kernel_ai/http/api_handlers/kernel.py index 54aec50..7dcc4fa 100644 --- a/kernel_ai/http/api_handlers/kernel.py +++ b/kernel_ai/http/api_handlers/kernel.py @@ -49,6 +49,18 @@ def nginx_files(): return api_json(lambda: {"files": _telemetry.get_nginx_open_files()}) +def io_open_files(): + def _payload(): + try: + limit = int(request.args.get("limit", 40)) + except (TypeError, ValueError): + limit = 40 + limit = max(1, min(limit, 80)) + return {"files": _telemetry.get_io_open_files(limit=limit)} + + return api_json(_payload) + + def get_execution_context(): return api_json( lambda: _telemetry.get_execution_context_data( diff --git a/kernel_ai/services/core_observability.py b/kernel_ai/services/core_observability.py index 622b7b9..2adcb31 100644 --- a/kernel_ai/services/core_observability.py +++ b/kernel_ai/services/core_observability.py @@ -196,6 +196,113 @@ def get_mock_nginx_files(): ] +def _classify_io_file_path(path): + """Classify an open-file path into a coarse filesystem category.""" + low = path.lower() + if "/var/log/" in low or low.endswith(".log"): + return "log" + if "/etc/" in low or low.endswith( + (".conf", ".cfg", ".ini", ".yaml", ".yml", ".json", ".toml") + ): + return "config" + if low.endswith(".so") or ".so." in low or "/lib/" in low or "/lib64/" in low: + return "lib" + if "/dev/" in low: + return "device" + if low.endswith((".db", ".sqlite", ".sqlite3")) or "/var/lib/" in low: + return "data" + return "other" + + +def _io_open_files_mock(): + """Mock data for the system-wide open-files I/O layer.""" + return [ + {"path": "/lib/x86_64-linux-gnu/libc.so.6", "type": "lib", "activity": 14, + "process": "gunicorn", "process_count": 9, "pids": []}, + {"path": "/etc/nginx/nginx.conf", "type": "config", "activity": 6, + "process": "nginx", "process_count": 3, "pids": []}, + {"path": "/var/log/nginx/access.log", "type": "log", "activity": 5, + "process": "nginx", "process_count": 2, "pids": []}, + {"path": "/var/lib/postgresql/data/base", "type": "data", "activity": 4, + "process": "postgres", "process_count": 2, "pids": []}, + {"path": "/var/log/syslog", "type": "log", "activity": 3, + "process": "rsyslogd", "process_count": 1, "pids": []}, + {"path": "/etc/ssl/certs/ca-certificates.crt", "type": "config", "activity": 2, + "process": "python3", "process_count": 1, "pids": []}, + ] + + +def get_io_open_files(limit=40, max_procs=600): + """Aggregate open files across processes ranked by how widely they're held. + + "Activity" is approximated by the number of open handles to a path across the + system, which surfaces hot/shared files (libs, configs, logs) for the + KERNEL I/O LAYER visualization. Returns a list sorted by activity desc. + """ + try: + counts = {} + scanned = 0 + for proc in psutil.process_iter(["pid", "name"]): + if scanned >= max_procs: + break + scanned += 1 + try: + open_files = proc.open_files() + except (psutil.NoSuchProcess, psutil.AccessDenied, OSError): + continue + name = proc.info.get("name") or "" + pid = proc.info.get("pid") + for file in open_files: + path = getattr(file, "path", None) + if not path: + continue + rec = counts.get(path) + if rec is None: + rec = { + "path": path, + "type": _classify_io_file_path(path), + "count": 0, + "procs": set(), + "pids": set(), + } + counts[path] = rec + rec["count"] += 1 + if name: + rec["procs"].add(name) + if pid is not None: + rec["pids"].add(pid) + + if not counts: + return _io_open_files_mock() + + ranked = sorted(counts.values(), key=lambda r: r["count"], reverse=True)[:limit] + result = [] + for rec in ranked: + procs = sorted(rec["procs"]) + result.append( + { + "path": rec["path"], + "type": rec["type"], + "activity": rec["count"], + "process": procs[0] if procs else "", + "process_count": len(procs), + "pids": sorted(rec["pids"])[:32], + } + ) + return result + except (psutil.Error, OSError, ValueError) as exc: + log_event( + logger, + "DEBUG", + "Failed to aggregate system open files, using mock", + event_dataset="kernel_ai.app", + component="services.core_observability", + operation="get_io_open_files", + event_data={"error": str(exc)}, + ) + return _io_open_files_mock() + + def get_nginx_open_files(): """Get open files for Nginx process.""" try: diff --git a/kernel_ai/services/process_inspect.py b/kernel_ai/services/process_inspect.py index f6b4b42..f0a8e15 100644 --- a/kernel_ai/services/process_inspect.py +++ b/kernel_ai/services/process_inspect.py @@ -11,15 +11,87 @@ from kernel_ai.sentry_helpers import capture_exception +def _parse_proc_net_socket_inodes(): + """Map socket inodes to coarse transport families available from /proc/net.""" + socket_kinds = {} + + try: + with open("/proc/net/unix", "r", encoding="utf-8", errors="replace") as f: + for line in f: + parts = line.split() + if len(parts) < 7 or parts[0] == "Num": + continue + if parts[6].isdigit(): + socket_kinds[int(parts[6])] = "unix" + except (OSError, PermissionError): + pass + + for path, kind in ( + ("/proc/net/tcp", "tcp"), + ("/proc/net/tcp6", "tcp"), + ("/proc/net/udp", "udp"), + ("/proc/net/udp6", "udp"), + ): + try: + with open(path, "r", encoding="utf-8", errors="replace") as f: + for line in f: + parts = line.split() + if len(parts) < 10 or parts[0] == "sl": + continue + if parts[9].isdigit(): + socket_kinds[int(parts[9])] = kind + except (OSError, PermissionError): + continue + + return socket_kinds + + +def _collect_local_tcp_pairs(proc_names_by_pid): + """Return local process-name pairs connected through TCP loopback/local sockets.""" + endpoints = {} + try: + connections = psutil.net_connections(kind="tcp") + except (psutil.AccessDenied, OSError): + return [] + + for conn in connections: + pid = getattr(conn, "pid", None) + laddr = getattr(conn, "laddr", None) + raddr = getattr(conn, "raddr", None) + if not pid or not laddr or not raddr: + continue + if not hasattr(laddr, "ip") or not hasattr(raddr, "ip"): + continue + endpoints[(laddr.ip, int(laddr.port), raddr.ip, int(raddr.port))] = int(pid) + + pairs = set() + for (local_ip, local_port, remote_ip, remote_port), left_pid in endpoints.items(): + right_pid = endpoints.get((remote_ip, remote_port, local_ip, local_port)) + if not right_pid or right_pid == left_pid: + continue + left_name = proc_names_by_pid.get(left_pid) + right_name = proc_names_by_pid.get(right_pid) + if not left_name or not right_name: + continue + pairs.add(tuple(sorted((left_name, right_name)))) + + return sorted(pairs) + + def get_ipc_links_summary(max_pairs=120, max_nodes=24): """Collect IPC relationships by shared sockets, pipes and shared memory mappings.""" socket_inode_re = re.compile(r"^socket:\[(\d+)\]$") pipe_inode_re = re.compile(r"^pipe:\[(\d+)\]$") + socket_kinds = _parse_proc_net_socket_inodes() socket_owners = {} + other_socket_owners = {} + unix_socket_owners = {} + tcp_socket_owners = {} pipe_owners = {} shm_owners = {} namespace_keys = ["mnt", "pid", "net", "ipc", "uts", "user"] namespace_owners = {} + proc_names_by_pid = {} for proc_dir in os.listdir("/proc"): if not proc_dir.isdigit(): @@ -32,6 +104,7 @@ def get_ipc_links_summary(max_pairs=120, max_nodes=24): continue if not proc_name: continue + proc_names_by_pid[pid] = proc_name fd_dir = f"/proc/{pid}/fd" try: @@ -50,6 +123,13 @@ def get_ipc_links_summary(max_pairs=120, max_nodes=24): if sm: inode = int(sm.group(1)) socket_owners.setdefault(inode, set()).add((pid, proc_name)) + socket_kind = socket_kinds.get(inode) + if socket_kind == "unix": + unix_socket_owners.setdefault(inode, set()).add((pid, proc_name)) + elif socket_kind == "tcp": + tcp_socket_owners.setdefault(inode, set()).add((pid, proc_name)) + else: + other_socket_owners.setdefault(inode, set()).add((pid, proc_name)) continue pm = pipe_inode_re.match(target) @@ -95,11 +175,15 @@ def get_ipc_links_summary(max_pairs=120, max_nodes=24): pair_totals = {} pair_socket = {} + pair_unix_socket = {} + pair_tcp = {} pair_pipe = {} pair_shm = {} pair_namespace = {} degree_total = {} degree_socket = {} + degree_unix_socket = {} + degree_tcp = {} degree_pipe = {} degree_shm = {} degree_namespace = {} @@ -112,6 +196,12 @@ def add_pair_counts(name_a, name_b, kind): pair_totals[key] = pair_totals.get(key, 0) + 1 if kind == "socket": pair_socket[key] = pair_socket.get(key, 0) + 1 + elif kind == "unix_socket": + pair_socket[key] = pair_socket.get(key, 0) + 1 + pair_unix_socket[key] = pair_unix_socket.get(key, 0) + 1 + elif kind == "tcp": + pair_socket[key] = pair_socket.get(key, 0) + 1 + pair_tcp[key] = pair_tcp.get(key, 0) + 1 elif kind == "pipe": pair_pipe[key] = pair_pipe.get(key, 0) + 1 elif kind == "shm": @@ -123,6 +213,12 @@ def add_pair_counts(name_a, name_b, kind): degree_total[nm] = degree_total.get(nm, 0) + 1 if kind == "socket": degree_socket[nm] = degree_socket.get(nm, 0) + 1 + elif kind == "unix_socket": + degree_socket[nm] = degree_socket.get(nm, 0) + 1 + degree_unix_socket[nm] = degree_unix_socket.get(nm, 0) + 1 + elif kind == "tcp": + degree_socket[nm] = degree_socket.get(nm, 0) + 1 + degree_tcp[nm] = degree_tcp.get(nm, 0) + 1 elif kind == "pipe": degree_pipe[nm] = degree_pipe.get(nm, 0) + 1 elif kind == "shm": @@ -139,10 +235,15 @@ def consume_inode_owners(owner_map, kind): for j in range(i + 1, len(unique)): add_pair_counts(unique[i][1], unique[j][1], kind) - consume_inode_owners(socket_owners, "socket") + consume_inode_owners(other_socket_owners, "socket") + consume_inode_owners(unix_socket_owners, "unix_socket") + consume_inode_owners(tcp_socket_owners, "tcp") consume_inode_owners(pipe_owners, "pipe") consume_inode_owners(shm_owners, "shm") consume_inode_owners(namespace_owners, "namespace") + local_tcp_pairs = _collect_local_tcp_pairs(proc_names_by_pid) + for left_name, right_name in local_tcp_pairs: + add_pair_counts(left_name, right_name, "tcp") sorted_pairs = sorted(pair_totals.items(), key=lambda kv: kv[1], reverse=True)[:max_pairs] pair_links = [] @@ -153,6 +254,8 @@ def consume_inode_owners(owner_map, kind): "right": right, "weight": int(weight), "socket_weight": int(pair_socket.get((left, right), pair_socket.get((right, left), 0))), + "unix_socket_weight": int(pair_unix_socket.get((left, right), pair_unix_socket.get((right, left), 0))), + "tcp_weight": int(pair_tcp.get((left, right), pair_tcp.get((right, left), 0))), "pipe_weight": int(pair_pipe.get((left, right), pair_pipe.get((right, left), 0))), "shm_weight": int(pair_shm.get((left, right), pair_shm.get((right, left), 0))), "ns_weight": int(pair_namespace.get((left, right), pair_namespace.get((right, left), 0))), @@ -167,6 +270,8 @@ def consume_inode_owners(owner_map, kind): "name": name, "degree": int(degree), "socket_degree": int(degree_socket.get(name, 0)), + "unix_socket_degree": int(degree_unix_socket.get(name, 0)), + "tcp_degree": int(degree_tcp.get(name, 0)), "pipe_degree": int(degree_pipe.get(name, 0)), "shm_degree": int(degree_shm.get(name, 0)), "ns_degree": int(degree_namespace.get(name, 0)), @@ -178,6 +283,9 @@ def consume_inode_owners(owner_map, kind): "pair_links": pair_links, "stats": { "shared_socket_inodes": int(sum(1 for owners in socket_owners.values() if len({pid for pid, _ in owners}) > 1)), + "shared_unix_socket_inodes": int(sum(1 for owners in unix_socket_owners.values() if len({pid for pid, _ in owners}) > 1)), + "shared_tcp_socket_inodes": int(sum(1 for owners in tcp_socket_owners.values() if len({pid for pid, _ in owners}) > 1)), + "local_tcp_pairs": len(local_tcp_pairs), "shared_pipe_inodes": int(sum(1 for owners in pipe_owners.values() if len({pid for pid, _ in owners}) > 1)), "shared_memory_regions": int(sum(1 for owners in shm_owners.values() if len({pid for pid, _ in owners}) > 1)), "shared_namespace_groups": int(sum(1 for owners in namespace_owners.values() if len({pid for pid, _ in owners}) > 1)), @@ -313,7 +421,59 @@ def get_process_fds_info(pid): except (psutil.AccessDenied, psutil.NoSuchProcess, AttributeError): pass - return {"pid": pid, "num_fds": num_fds, "open_files": open_files[:20], "connections": connections[:20]} + descriptors = [] + fd_dir = f"/proc/{pid}/fd" + try: + if os.path.exists(fd_dir): + for fd_num in sorted((fd for fd in os.listdir(fd_dir) if fd.isdigit()), key=lambda value: int(value)): + try: + target = os.readlink(f"{fd_dir}/{fd_num}") + except (OSError, PermissionError): + continue + + fd_index = int(fd_num) + if fd_index == 0: + fd_type = "stdin" + elif fd_index == 1: + fd_type = "stdout" + elif fd_index == 2: + fd_type = "stderr" + elif target.startswith("socket:"): + fd_type = "socket" + elif target.startswith("pipe:"): + fd_type = "pipe" + elif target.startswith("anon_inode:"): + fd_type = "anon_inode" + elif target.startswith("/"): + fd_type = "file" + else: + fd_type = "descriptor" + + descriptors.append({"fd": fd_index, "type": fd_type, "target": target}) + except (OSError, PermissionError): + pass + + connection_by_fd = {item.get("fd"): item for item in connections if item.get("fd") is not None} + for descriptor in descriptors: + conn = connection_by_fd.get(descriptor["fd"]) + if not conn: + continue + family = str(conn.get("family") or "").upper() + if "AF_UNIX" in family: + descriptor["type"] = "unix socket" + elif "AF_INET" in family: + descriptor["type"] = "tcp socket" if conn.get("remote_address") else "inet socket" + descriptor["local_address"] = conn.get("local_address") + descriptor["remote_address"] = conn.get("remote_address") + descriptor["status"] = conn.get("status") + + return { + "pid": pid, + "num_fds": num_fds, + "open_files": open_files[:20], + "connections": connections[:20], + "descriptors": descriptors[:40], + } except (psutil.NoSuchProcess, psutil.AccessDenied) as e: return {"error": f"Access denied or process not found: {str(e)}"} except Exception as e: diff --git a/kernel_ai/services/telemetry_orchestration.py b/kernel_ai/services/telemetry_orchestration.py index 03f6add..ca1df46 100644 --- a/kernel_ai/services/telemetry_orchestration.py +++ b/kernel_ai/services/telemetry_orchestration.py @@ -57,6 +57,10 @@ def get_nginx_open_files(): return _core_observability_service.get_nginx_open_files() +def get_io_open_files(limit=40): + return _core_observability_service.get_io_open_files(limit=limit) + + def _kernel_dna_softirq_nucleotides(limit=8): return _syscalls_service.get_softirq_nucleotides( map_interrupt_to_subsystem_fn=map_interrupt_to_subsystem, diff --git a/static/css/main.css b/static/css/main.css index b725d51..0e16b62 100755 --- a/static/css/main.css +++ b/static/css/main.css @@ -21,6 +21,24 @@ svg { filter: drop-shadow(0 0 5px rgba(40, 48, 58, 0.22)); } +.central-pulse-grid { + transform-box: fill-box; + transform-origin: center; + animation: centralGridPulse 3.6s ease-in-out infinite; + mix-blend-mode: multiply; +} + +@keyframes centralGridPulse { + 0%, 100% { + opacity: 0.48; + transform: scale(0.992); + } + 50% { + opacity: 0.82; + transform: scale(1.012); + } +} + /* Tag icons */ .tag-icon { cursor: pointer; diff --git a/static/js/flow-ui.js b/static/js/flow-ui.js index 19a3f81..904baba 100644 --- a/static/js/flow-ui.js +++ b/static/js/flow-ui.js @@ -190,6 +190,23 @@ function drawLowerBezierGrid(num = 90) { const yBase = height - 200 + lowerFlowYOffset; drawBezierDecor(width, height, yBase); + // Surface line the whiskers should skim along (matches the primary rail in drawBezierDecor). + const surfaceY = Math.min(height - 78, yBase + 8); + const centerX = width / 2; + // Rail span matches drawBezierDecor's primary rail so whiskers ride its length. + const railHalf = Math.min(540, Math.max(320, width * 0.32)); + + // Publish the I/O surface geometry so the open-files layer can dock file + // markers onto the rail (crest = busiest file, left → right = decreasing). + window.__ioLayerGeometry = { + surfaceY, + centerX, + railHalf, + crestX: centerX - railHalf, + railLeft: centerX - railHalf, + railRight: centerX + railHalf + }; + for (let i = 0; i < num; i++) { const fromLeft = i < num / 2; diff --git a/static/js/ipc-ui.js b/static/js/ipc-ui.js index 50d3db0..29581e8 100644 --- a/static/js/ipc-ui.js +++ b/static/js/ipc-ui.js @@ -10,9 +10,19 @@ function normalizeProcName(name) { return lower; } -function getSharedChannelType(socketWeight, pipeWeight, shmWeight, nsWeight) { +const IPC_CHANNELS = [ + { key: 'unix', label: 'UNIX', short: 'U', weightKey: 'unixSocketWeight', degreeKey: 'unixSocketDegree', statKey: 'shared_unix_socket_inodes', radiusOffset: -30, color: 'rgba(28, 28, 28, 0.72)', dash: '2 5' }, + { key: 'pipe', label: 'PIPE', short: 'P', weightKey: 'pipeWeight', degreeKey: 'pipeDegree', statKey: 'shared_pipe_inodes', radiusOffset: -10, color: 'rgba(72, 72, 72, 0.62)', dash: '7 5' }, + { key: 'tcp', label: 'TCP', short: 'T', weightKey: 'tcpWeight', degreeKey: 'tcpDegree', statKey: 'shared_tcp_socket_inodes', radiusOffset: 12, color: 'rgba(16, 58, 78, 0.66)', dash: 'none' }, + { key: 'shm', label: 'SHM', short: 'M', weightKey: 'shmWeight', degreeKey: 'shmDegree', statKey: 'shared_memory_regions', radiusOffset: 34, color: 'rgba(86, 62, 28, 0.55)', dash: '1 8' } +]; + +function getSharedChannelType(socketWeight, pipeWeight, shmWeight, nsWeight, unixSocketWeight = 0, tcpWeight = 0) { const channels = []; - if (Number(socketWeight || 0) > 0) channels.push('SOCKET'); + if (Number(unixSocketWeight || 0) > 0) channels.push('UNIX'); + if (Number(tcpWeight || 0) > 0) channels.push('TCP'); + const genericSocketWeight = Number(socketWeight || 0) - Number(unixSocketWeight || 0) - Number(tcpWeight || 0); + if (genericSocketWeight > 0) channels.push('SOCKET'); if (Number(pipeWeight || 0) > 0) channels.push('PIPE'); if (Number(shmWeight || 0) > 0) channels.push('SHM'); if (Number(nsWeight || 0) > 0) channels.push('NS'); @@ -21,6 +31,36 @@ function getSharedChannelType(socketWeight, pipeWeight, shmWeight, nsWeight) { return `MIXED (${channels.join('+')})`; } +function channelWeight(link, channel) { + return Number(link[channel.weightKey] || 0); +} + +function dominantChannel(row) { + let best = IPC_CHANNELS[0]; + let bestValue = -1; + IPC_CHANNELS.forEach((channel) => { + const value = Number(row[channel.degreeKey] || row[channel.weightKey] || 0); + if (value > bestValue) { + best = channel; + bestValue = value; + } + }); + return bestValue > 0 ? best : null; +} + +function buildChannelArc(cx, cy, startAngle, endAngle, radius) { + let delta = endAngle - startAngle; + while (delta > Math.PI) delta -= Math.PI * 2; + while (delta < -Math.PI) delta += Math.PI * 2; + const sweepFlag = delta >= 0 ? 1 : 0; + const largeArcFlag = Math.abs(delta) > Math.PI ? 1 : 0; + const sx = cx + Math.cos(startAngle) * radius; + const sy = cy + Math.sin(startAngle) * radius; + const ex = cx + Math.cos(endAngle) * radius; + const ey = cy + Math.sin(endAngle) * radius; + return `M ${sx} ${sy} A ${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${ex} ${ey}`; +} + function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { d3.selectAll('.ipc-ring-layer').remove(); fetch('/api/ipc-links?max_nodes=18&max_pairs=120') @@ -39,21 +79,27 @@ function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { // Place IPC ring around the process circle (outside process endpoints). const ringR = 355; - ringGroup.append('circle') - .attr('cx', ringCx) - .attr('cy', ringCy) - .attr('r', ringR) - .attr('fill', 'none') - .attr('stroke', 'rgba(70, 70, 70, 0.26)') - .attr('stroke-width', 0.9); + IPC_CHANNELS.forEach((channel, idx) => { + const radius = ringR + channel.radiusOffset; + ringGroup.append('circle') + .attr('cx', ringCx) + .attr('cy', ringCy) + .attr('r', radius) + .attr('fill', 'none') + .attr('stroke', channel.color) + .attr('stroke-width', idx === 2 ? 1.05 : 0.75) + .attr('stroke-dasharray', channel.dash) + .attr('opacity', 0.46); - ringGroup.append('circle') - .attr('cx', ringCx) - .attr('cy', ringCy) - .attr('r', ringR - 12) - .attr('fill', 'none') - .attr('stroke', 'rgba(70, 70, 70, 0.14)') - .attr('stroke-width', 0.7); + ringGroup.append('text') + .attr('x', ringCx + radius + 8) + .attr('y', ringCy - 3 + idx * 9) + .style('font-family', 'Share Tech Mono, monospace') + .style('font-size', '7px') + .style('letter-spacing', '0.8px') + .style('fill', channel.color) + .text(channel.label); + }); if (!data || data.error) { console.warn('IPC API payload error:', data && data.error); @@ -67,6 +113,8 @@ function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { name: nm, degree: 1, socket_degree: 0, + unix_socket_degree: 0, + tcp_degree: 0, pipe_degree: 0, shm_degree: 0, ns_degree: 0 @@ -85,7 +133,7 @@ function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { .style('font-size', '8px') .style('letter-spacing', '0.7px') .style('fill', 'rgba(58, 58, 58, 0.58)') - .text(`IPC LINKS SOCKET:${stats.shared_socket_inodes || 0} PIPE:${stats.shared_pipe_inodes || 0} SHM:${stats.shared_memory_regions || 0} NS:${stats.shared_namespace_groups || 0} PAIRS:${stats.pair_count || 0}`); + .text(`IPC ORBIT UNIX:${stats.shared_unix_socket_inodes || 0} PIPE:${stats.shared_pipe_inodes || 0} TCP:${(stats.shared_tcp_socket_inodes || 0) + (stats.local_tcp_pairs || 0)} SHM:${stats.shared_memory_regions || 0} PAIRS:${stats.pair_count || 0}`); const peerMap = new Map(); (((data && data.pair_links) || [])).forEach((link) => { @@ -98,6 +146,8 @@ function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { peer: link.right || right, weight: Number(link.weight || 0), socketWeight: Number(link.socket_weight || 0), + unixSocketWeight: Number(link.unix_socket_weight || 0), + tcpWeight: Number(link.tcp_weight || 0), pipeWeight: Number(link.pipe_weight || 0), shmWeight: Number(link.shm_weight || 0), nsWeight: Number(link.ns_weight || 0) @@ -106,6 +156,8 @@ function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { peer: link.left || left, weight: Number(link.weight || 0), socketWeight: Number(link.socket_weight || 0), + unixSocketWeight: Number(link.unix_socket_weight || 0), + tcpWeight: Number(link.tcp_weight || 0), pipeWeight: Number(link.pipe_weight || 0), shmWeight: Number(link.shm_weight || 0), nsWeight: Number(link.ns_weight || 0) @@ -116,6 +168,8 @@ function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { peerMap.set(key, arr.slice(0, 3)); }); + const pairArcLayer = ringGroup.append('g') + .attr('class', 'ipc-pair-arc-layer'); const maxDegree = Math.max(...nodes.map(n => Number(n.degree || 0)), 1); const nodePos = []; nodes.forEach((node, i) => { @@ -134,18 +188,28 @@ function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { radius, degree, socketDegree: Number(node.socket_degree || 0), + unixSocketDegree: Number(node.unix_socket_degree || 0), + tcpDegree: Number(node.tcp_degree || 0), pipeDegree: Number(node.pipe_degree || 0), shmDegree: Number(node.shm_degree || 0), nsDegree: Number(node.ns_degree || 0) }); + const nodeChannelRow = { + unixSocketDegree: Number(node.unix_socket_degree || 0), + tcpDegree: Number(node.tcp_degree || 0), + pipeDegree: Number(node.pipe_degree || 0), + shmDegree: Number(node.shm_degree || 0) + }; + const nodeChannel = dominantChannel(nodeChannelRow); + ringGroup.append('circle') .attr('cx', nx) .attr('cy', ny) .attr('r', radius) - .attr('fill', 'rgba(90, 90, 90, 0.55)') - .attr('stroke', 'rgba(34, 34, 34, 0.35)') - .attr('stroke-width', 0.7) + .attr('fill', nodeChannel ? nodeChannel.color : 'rgba(90, 90, 90, 0.55)') + .attr('stroke', 'rgba(24, 24, 24, 0.5)') + .attr('stroke-width', 0.85) .style('pointer-events', 'all') .style('cursor', 'pointer') .on('mouseenter', () => { @@ -153,8 +217,8 @@ function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { const peers = peerMap.get(normalizedName) || []; const peerText = peers.length ? peers.map((p) => { - const channelType = getSharedChannelType(p.socketWeight, p.pipeWeight, p.shmWeight, p.nsWeight); - return `${p.peer}: ${p.weight} [${channelType}] (s:${p.socketWeight} p:${p.pipeWeight} shm:${p.shmWeight} ns:${p.nsWeight})`; + const channelType = getSharedChannelType(p.socketWeight, p.pipeWeight, p.shmWeight, p.nsWeight, p.unixSocketWeight, p.tcpWeight); + return `${p.peer}: ${p.weight} [${channelType}] (unix:${p.unixSocketWeight} pipe:${p.pipeWeight} tcp:${p.tcpWeight} shm:${p.shmWeight})`; }).join('
') : 'No peer details'; d3.select('body') @@ -171,12 +235,26 @@ function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { .style('z-index', '1200') .style('left', `${nx + 10}px`) .style('top', `${ny - 14}px`) - .html(`${node.name || normalizedName}
Links: ${degree}
Socket: ${Number(node.socket_degree || 0)} | Pipe: ${Number(node.pipe_degree || 0)} | SHM: ${Number(node.shm_degree || 0)} | NS: ${Number(node.ns_degree || 0)}

${peerText}`); + .html(`${node.name || normalizedName}
Links: ${degree}
UNIX: ${Number(node.unix_socket_degree || 0)} | PIPE: ${Number(node.pipe_degree || 0)} | TCP: ${Number(node.tcp_degree || 0)} | SHM: ${Number(node.shm_degree || 0)}

${peerText}`); }) .on('mouseleave', () => { d3.selectAll('.ipc-link-tooltip').remove(); }); + IPC_CHANNELS.forEach((channel, channelIdx) => { + const active = Number(nodeChannelRow[channel.degreeKey] || 0) > 0; + const dotAngle = angle - 0.18 + channelIdx * 0.12; + const dotRadius = radius + 8.5; + ringGroup.append('circle') + .attr('cx', nx + Math.cos(dotAngle) * dotRadius) + .attr('cy', ny + Math.sin(dotAngle) * dotRadius) + .attr('r', active ? 1.9 : 1.05) + .attr('fill', active ? channel.color : 'rgba(92, 92, 92, 0.20)') + .attr('stroke', active ? 'rgba(18,18,18,0.35)' : 'none') + .attr('stroke-width', 0.45) + .style('pointer-events', 'none'); + }); + if (i < 10) { const label = String(node.name || normalizedName); const labelAngle = angle; @@ -194,6 +272,37 @@ function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { } }); + const nodeByName = new Map(nodePos.map((node) => [node.name, node])); + let pairArcCount = 0; + (((data && data.pair_links) || [])).forEach((link) => { + if (pairArcCount > 90) return; + const leftNode = nodeByName.get(normalizeProcName(link.left || '')); + const rightNode = nodeByName.get(normalizeProcName(link.right || '')); + if (!leftNode || !rightNode) return; + const startAngle = Math.atan2(leftNode.y - ringCy, leftNode.x - ringCx); + const endAngle = Math.atan2(rightNode.y - ringCy, rightNode.x - ringCx); + const linkWeights = { + unixSocketWeight: Number(link.unix_socket_weight || 0), + pipeWeight: Number(link.pipe_weight || 0), + tcpWeight: Number(link.tcp_weight || 0), + shmWeight: Number(link.shm_weight || 0) + }; + IPC_CHANNELS.forEach((channel) => { + const weight = channelWeight(linkWeights, channel); + if (weight <= 0 || pairArcCount > 90) return; + pairArcLayer.append('path') + .attr('d', buildChannelArc(ringCx, ringCy, startAngle, endAngle, ringR + channel.radiusOffset)) + .attr('fill', 'none') + .attr('stroke', channel.color) + .attr('stroke-width', Math.min(2.1, 0.65 + weight * 0.18)) + .attr('stroke-dasharray', channel.dash) + .attr('stroke-linecap', 'round') + .attr('opacity', channel.key === 'shm' ? 0.38 : 0.5) + .style('pointer-events', 'none'); + pairArcCount += 1; + }); + }); + let linkOrdinal = 0; const laneOffsets = [-26, -14, 14, 26, -20, 20]; nodePos.forEach((ipcNode) => { diff --git a/static/js/main.js b/static/js/main.js index 6dce744..6040caa 100755 --- a/static/js/main.js +++ b/static/js/main.js @@ -8,6 +8,7 @@ let resizeTimeout; let nginxFilesManager; let rightSemicircleMenuManager; let connectionsManager; // make available for cleanup handlers +let pinnedProcessDossier = null; const MOBILE_LAYOUT_BREAKPOINT = 900; function isMobileLayout() { return window.innerWidth <= MOBILE_LAYOUT_BREAKPOINT; @@ -209,6 +210,12 @@ function draw() { svg.selectAll(".syscall-box, .syscall-text").remove(); } + svg.on('click.processDossierClear', function(event) { + const target = event.target; + if (target && target.closest && target.closest('.process-node-group')) return; + clearPinnedProcessDossier(); + }); + // Define gradients for depth const defs = svg.append("defs"); @@ -250,6 +257,7 @@ function draw() { // Draw Ring-1 Execution Context drawRing1(centerX, centerY); + drawCentralPulseGridForeground(centerX, centerY); // Mobile mode: keep only the central process composition. if (mobileLayout) { @@ -359,8 +367,788 @@ function drawMobileDefaultProcessLabels(centerX, centerY) { }); } +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 { + fds: fdsData.num_fds !== undefined ? fdsData.num_fds : formatProcessValue(processData.num_fds, 0), + files: Array.isArray(fdsData.open_files) ? fdsData.open_files.length : 0, + sockets: Array.isArray(fdsData.connections) ? fdsData.connections.length : 0 + }; +} + +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; + if (descriptor.local_address) return descriptor.local_address; + 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` } + ]; +} + +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 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 } + ]; +} + +function renderProcessInteractionModule(centerX, centerY, processData, anchor = null, details = {}) { + d3.selectAll('.process-interaction-module').remove(); + if (!processData || isMobileLayout()) return; + + 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; + + const group = svg.append('g') + .attr('class', 'process-interaction-module') + .attr('pointer-events', 'none'); + + 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); + } + + 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'); + } + + 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'); + } + }); + + // 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}`); + } + + 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); + + 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); + + 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()); + + 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)}`); + + 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'); + + group.append('text') + .attr('x', moduleCx + moduleR - 2) + .attr('y', moduleCy - moduleR - 12) + .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'); + } + + 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'); + } + + 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); + + 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); + + 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)); + }); +} + +function renderProcessDossier() { + d3.selectAll('.process-dossier-layer').remove(); + if (!pinnedProcessDossier || !pinnedProcessDossier.process || !pinnedProcessDossier.anchor) return; + + 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 cpuPercent = Number(cpuData.cpu_percent || processData.cpu_percent || 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 layer = svg.append('g') + .attr('class', 'process-dossier-layer') + .attr('pointer-events', 'none'); + + 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('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)}`); + + layer.append('circle') + .attr('cx', anchor.x) + .attr('cy', anchor.y) + .attr('r', 9) + .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)); + }); + + 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 } + }); +} + +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' } + ]; + + 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) + .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); + }); + + if (descriptors.length > rows.length) { + layer.append('text') + .attr('x', box.x + box.width - 16) + .attr('y', box.y + box.height - 10) + .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`); + } +} + +function clearPinnedProcessDossier() { + if (!pinnedProcessDossier) return; + const pinnedPid = pinnedProcessDossier.process?.pid; + const isHighlighted = window.__highlightedProcess && window.__highlightedProcess.pid === pinnedPid; + pinnedProcessDossier = null; + d3.selectAll('.process-dossier-layer').remove(); + d3.selectAll('.process-interaction-module').remove(); + d3.selectAll('.process-node-group').classed('process-pinned', false); + if (window.nginxFilesManager && typeof window.nginxFilesManager.clearProcessHighlight === 'function') { + window.nginxFilesManager.clearProcessHighlight(); + } + if (pinnedPid !== undefined && pinnedPid !== null) { + const pinnedGroup = svg.select(`.process-node-group[data-pid="${pinnedPid}"]`); + pinnedGroup.select('.process-node') + .interrupt() + .attr('r', isHighlighted ? 3 : 1) + .attr('fill', '#888') + .attr('stroke', '#555') + .attr('stroke-width', isHighlighted ? 1 : 0.5); + svg.select(`.process-line[data-pid="${pinnedPid}"]`) + .attr('stroke', 'url(#lineGradient)') + .attr('stroke-width', 0.9) + .attr('opacity', isHighlighted ? 0.16 : 0.07); + } +} + +function pinProcessDossier(processData, anchor) { + pinnedProcessDossier = { + process: processData, + anchor, + details: pinnedProcessDossier && pinnedProcessDossier.process?.pid === processData.pid + ? pinnedProcessDossier.details + : {} + }; + renderProcessDossier(); + renderProcessInteractionModule(window.innerWidth / 2, window.innerHeight / 2, processData, anchor, pinnedProcessDossier.details); + if (window.nginxFilesManager && typeof window.nginxFilesManager.highlightProcessFiles === 'function') { + window.nginxFilesManager.highlightProcessFiles(processData.pid); + } + + 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]) => { + if (!pinnedProcessDossier || pinnedProcessDossier.process?.pid !== processData.pid) return; + pinnedProcessDossier.details = { threadsData, cpuData, fdsData }; + 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); + } + }); +} + // Draw central circle function drawCentralCircle(centerX, centerY) { + drawCentralPulseGrid(centerX, centerY); + svg.append("circle") .attr("cx", centerX) .attr("cy", centerY) @@ -376,6 +1164,111 @@ function drawCentralCircle(centerX, centerY) { .attr("height", 60); } +function drawCentralPulseGrid(centerX, centerY) { + const grid = svg.append("g") + .attr("class", "central-pulse-grid") + .attr("pointer-events", "none"); + const radii = [74, 112, 156, 204]; + + radii.forEach((radius, idx) => { + grid.append("circle") + .attr("cx", centerX) + .attr("cy", centerY) + .attr("r", radius) + .attr("fill", "none") + .attr("stroke", "rgba(38, 38, 38, 0.18)") + .attr("stroke-width", idx === 0 ? 1 : 0.7) + .attr("stroke-dasharray", idx % 2 === 0 ? "2 7" : "1 9"); + }); + + for (let i = 0; i < 36; i += 1) { + const angle = (i / 36) * Math.PI * 2; + const inner = 78 + (i % 3) * 10; + const outer = 218 - (i % 4) * 13; + const x1 = centerX + Math.cos(angle) * inner; + const y1 = centerY + Math.sin(angle) * inner; + const x2 = centerX + Math.cos(angle) * outer; + const y2 = centerY + Math.sin(angle) * outer; + grid.append("line") + .attr("x1", x1) + .attr("y1", y1) + .attr("x2", x2) + .attr("y2", y2) + .attr("stroke", "rgba(38, 38, 38, 0.08)") + .attr("stroke-width", 0.55); + } + + for (let i = 0; i < 72; i += 1) { + const angle = (i / 72) * Math.PI * 2; + const radius = i % 2 === 0 ? 186 : 196; + grid.append("circle") + .attr("cx", centerX + Math.cos(angle) * radius) + .attr("cy", centerY + Math.sin(angle) * radius) + .attr("r", i % 9 === 0 ? 1.7 : 1.05) + .attr("fill", "rgba(38, 38, 38, 0.28)"); + } +} + +function drawCentralPulseGridForeground(centerX, centerY) { + const grid = svg.append("g") + .attr("class", "central-pulse-grid central-pulse-grid-foreground") + .attr("pointer-events", "none"); + const innerR = 66; + const outerR = 148; + + [68, 92, 118, 144].forEach((radius, idx) => { + const ring = grid.append("circle") + .attr("cx", centerX) + .attr("cy", centerY) + .attr("r", radius) + .attr("fill", "none") + .attr("stroke", "rgba(24, 24, 24, 0.46)") + .attr("stroke-width", idx === 1 ? 1.2 : 0.85) + .attr("stroke-dasharray", idx % 2 === 0 ? "2 6" : "1 8") + .attr("opacity", 0.82); + + ring.append("animate") + .attr("attributeName", "opacity") + .attr("values", "0.5;0.95;0.5") + .attr("dur", `${3.2 + idx * 0.35}s`) + .attr("repeatCount", "indefinite"); + }); + + for (let i = 0; i < 64; i += 1) { + const angle = (i / 64) * Math.PI * 2; + const tickInner = innerR + (i % 4) * 5; + const tickOuter = outerR - (i % 5) * 4; + grid.append("line") + .attr("x1", centerX + Math.cos(angle) * tickInner) + .attr("y1", centerY + Math.sin(angle) * tickInner) + .attr("x2", centerX + Math.cos(angle) * tickOuter) + .attr("y2", centerY + Math.sin(angle) * tickOuter) + .attr("stroke", "rgba(28, 28, 28, 0.18)") + .attr("stroke-width", i % 8 === 0 ? 0.95 : 0.55) + .attr("opacity", i % 3 === 0 ? 0.58 : 0.34); + } + + for (let i = 0; i < 96; i += 1) { + const angle = (i / 96) * Math.PI * 2; + const radius = 128 + (i % 3) * 6; + const dot = grid.append("circle") + .attr("cx", centerX + Math.cos(angle) * radius) + .attr("cy", centerY + Math.sin(angle) * radius) + .attr("r", i % 12 === 0 ? 1.8 : 1) + .attr("fill", "rgba(24, 24, 24, 0.46)") + .attr("opacity", 0.68); + + if (i % 8 === 0) { + dot.append("animate") + .attr("attributeName", "opacity") + .attr("values", "0.28;0.9;0.28") + .attr("dur", "2.8s") + .attr("begin", `${(i % 16) * 0.08}s`) + .attr("repeatCount", "indefinite"); + } + } +} + // Ring-1 update interval (global to prevent multiple intervals) let ring1UpdateInterval = null; @@ -715,16 +1608,45 @@ function drawPanels(width, height) { .attr("height", 330) .attr("class", "feature-panel"); - // Right panel - increased width to accommodate longer values - // Positioned so right margin equals left margin (20px) - const panelWidth = 170; - const rightMargin = 20; // Same as left margin + // Right top status module, styled as a compact HUD window. + const panelWidth = 214; + const panelHeight = 118; + const rightMargin = 20; + const panelX = width - panelWidth - rightMargin; + const panelY = 20; svg.append("rect") - .attr("x", width - panelWidth - rightMargin) - .attr("y", 20) + .attr("x", panelX) + .attr("y", panelY) .attr("width", panelWidth) - .attr("height", 100) - .attr("class", "feature-panel"); + .attr("height", panelHeight) + .attr("rx", 4) + .attr("fill", "rgba(232, 232, 222, 0.72)") + .attr("stroke", "rgba(28, 28, 28, 0.34)") + .attr("stroke-width", 1); + + svg.append("rect") + .attr("x", panelX + 8) + .attr("y", panelY + 8) + .attr("width", panelWidth - 16) + .attr("height", 24) + .attr("rx", 2) + .attr("fill", "rgba(24, 24, 24, 0.86)") + .attr("stroke", "rgba(24, 24, 24, 0.9)"); + + svg.append("text") + .attr("x", panelX + 16) + .attr("y", panelY + 24) + .attr("font-family", "Share Tech Mono, monospace") + .attr("font-size", "9px") + .attr("font-weight", "700") + .attr("fill", "#f1f1e8") + .text("SYSTEM STATUS MODULE"); + + svg.append("circle") + .attr("cx", panelX + panelWidth - 20) + .attr("cy", panelY + 20) + .attr("r", 4) + .attr("fill", "rgba(241, 241, 232, 0.9)"); // Text in right panel - will be updated with real data const panelData = [ @@ -734,29 +1656,50 @@ function drawPanels(width, height) { {label: "Memory", value: "Loading..."} ]; - const leftPadding = 10; // Padding from left edge of panel - const rightPadding = 10; // Padding from right edge of panel - panelData.forEach((item, i) => { + const col = i % 2; + const row = Math.floor(i / 2); + const boxW = 92; + const boxH = 30; + const boxX = panelX + 12 + col * 98; + const boxY = panelY + 42 + row * 36; const textGroup = svg.append("g") .attr("class", `panel-item-${i}`); - - // Labels aligned to left with padding + + textGroup.append("rect") + .attr("x", boxX) + .attr("y", boxY) + .attr("width", boxW) + .attr("height", boxH) + .attr("rx", 3) + .attr("fill", i === 1 ? "rgba(24, 24, 24, 0.82)" : "rgba(255, 255, 255, 0.48)") + .attr("stroke", "rgba(24, 24, 24, 0.18)") + .attr("stroke-width", 0.7); + + textGroup.append("circle") + .attr("cx", boxX + 9) + .attr("cy", boxY + 10) + .attr("r", 2.2) + .attr("fill", i === 1 ? "#f1f1e8" : "rgba(24, 24, 24, 0.56)"); + textGroup.append("text") - .attr("x", width - panelWidth - rightMargin + leftPadding) - .attr("y", 45 + i * 22) - .text(item.label + ":") - .attr("class", "feature-text"); - - // Values aligned to right edge of panel with padding + .attr("x", boxX + 16) + .attr("y", boxY + 11) + .text(item.label.toUpperCase()) + .attr("font-family", "Share Tech Mono, monospace") + .attr("font-size", "6.8px") + .attr("fill", i === 1 ? "#d8d8cf" : "rgba(40, 40, 40, 0.62)"); + textGroup.append("text") - .attr("x", width - rightMargin - rightPadding) - .attr("y", 45 + i * 22) + .attr("x", boxX + boxW - 8) + .attr("y", boxY + 24) .text(item.value) - .attr("class", "feature-text") + .attr("font-family", "Share Tech Mono, monospace") + .attr("font-size", "10px") + .attr("fill", i === 1 ? "#f1f1e8" : "#222") .attr("id", `panel-value-${i}`) .attr("font-weight", "bold") - .attr("text-anchor", "end"); // Right-align text to prevent overflow + .attr("text-anchor", "end"); }); // Update panel with real data @@ -1059,6 +2002,7 @@ function drawProcessKernelMap2(centerX, centerY) { } else { console.warn('⚠️ No process selected for highlighting'); } + d3.selectAll('.process-interaction-module').remove(); processes.forEach((process, i) => { const angle = i * 2 * Math.PI / numProcesses; @@ -1118,7 +2062,8 @@ function drawProcessKernelMap2(centerX, centerY) { // Determine if this is the highlighted process const isHighlighted = highlightedProcess && process.pid === highlightedProcess.pid; - const baseRadius = isHighlighted ? 3 : 1; // Larger for highlighted process + const isPinnedProcess = pinnedProcessDossier && pinnedProcessDossier.process?.pid === process.pid; + const baseRadius = isPinnedProcess ? 4.8 : (isHighlighted ? 3 : 1); // Larger for highlighted/pinned process const hoverRadius = baseRadius * 2.5; // Radius when hovering const hitAreaRadius = 12; // Invisible hit area for easier clicking @@ -1142,13 +2087,21 @@ function drawProcessKernelMap2(centerX, centerY) { .attr("cx", px) .attr("cy", py) .attr("r", 0) // Start with radius 0 - .attr("fill", "#888") - .attr("stroke", "#555") - .attr("stroke-width", isHighlighted ? 1 : 0.5) + .attr("fill", isPinnedProcess ? "#111" : "#888") + .attr("stroke", isPinnedProcess ? "#000" : "#555") + .attr("stroke-width", isPinnedProcess ? 2 : (isHighlighted ? 1 : 0.5)) .attr("opacity", 0) .attr("class", "process-node") .style("pointer-events", "none"); // Don't interfere with hit area + if (isPinnedProcess) { + pinnedProcessDossier.anchor = { x: px, y: py }; + line.attr("stroke", "#222") + .attr("stroke-width", 1.8) + .attr("opacity", 0.42) + .attr("data-original-opacity", 0.42); + } + // Animate circle appearance circle.transition() .duration(150) @@ -1299,6 +2252,24 @@ function drawProcessKernelMap2(centerX, centerY) { .style("top", (event.pageY - 10) + "px"); }); }) + .on("click", function(event, d) { + event.stopPropagation(); + const processData = d || process; + if (pinnedProcessDossier && pinnedProcessDossier.process?.pid !== processData.pid) { + clearPinnedProcessDossier(); + } + pinProcessDossier(processData, { x: px, y: py }); + svg.selectAll('.process-node-group').classed('process-pinned', false); + processGroup.classed('process-pinned', true); + svg.selectAll('.process-node') + .attr('stroke', '#555') + .attr('stroke-width', 0.5); + circle.interrupt() + .attr('r', 8) + .attr('fill', '#111') + .attr('stroke', '#000') + .attr('stroke-width', 2); + }) .on("mouseout", function(event, d) { // Get the actual process data from the datum const processData = d || process; @@ -1308,7 +2279,8 @@ function drawProcessKernelMap2(centerX, centerY) { // Reset circle size on mouseout const isHighlighted = highlightedProcess && processData.pid === highlightedProcess.pid; - if (!isHighlighted) { + const isPinnedProcess = pinnedProcessDossier && pinnedProcessDossier.process?.pid === processData.pid; + if (!isHighlighted && !isPinnedProcess) { // Return to normal size circle.transition() .duration(200) @@ -1320,8 +2292,8 @@ function drawProcessKernelMap2(centerX, centerY) { // Return highlighted process to its default size circle.transition() .duration(200) - .attr("r", baseRadius) - .attr("stroke-width", 1); + .attr("r", isPinnedProcess ? 4.8 : baseRadius) + .attr("stroke-width", isPinnedProcess ? 2 : 1); } // Restore highlighted process to its default size if it was shrunk @@ -1351,6 +2323,11 @@ function drawProcessKernelMap2(centerX, centerY) { } else { d3.selectAll('.ipc-ring-layer').remove(); } + renderProcessDossier(); + + // Process lines radiate from the center and wash out the pulse grid; + // lift it back above them so the central lattice stays visible. + d3.selectAll('.central-pulse-grid-foreground').raise(); }) .catch(error => { console.error('Error fetching processes:', error);