From ad74f5a821a8d5aae320bea90bdccfa39bdbe5d5 Mon Sep 17 00:00:00 2001 From: Aleksei Fedorov Date: Sat, 15 Aug 2026 10:00:27 +0000 Subject: [PATCH] process inspect, ipc, memory card --- index.html | 11 +- kernel_ai/services/process_inspect.py | 253 ++++++++++++++++++++++++-- static/js/ipc-ui.js | 245 +++++++++++++++++++------ static/js/main.js | 72 +++++++- static/js/memory-card.js | 213 +++++++++++++++------- static/js/threads-card.js | 178 ++++++++++++------ static/js/waits-card.js | 53 +++++- 7 files changed, 823 insertions(+), 202 deletions(-) diff --git a/index.html b/index.html index dc050c7..cfbe699 100755 --- a/index.html +++ b/index.html @@ -94,10 +94,11 @@

Linux Kernel Ring 0 Visualization

- - - - + + + + + @@ -122,7 +123,7 @@

Linux Kernel Ring 0 Visualization

console.error('❌ RightSemicircleMenuManager class NOT loaded!'); } - + diff --git a/kernel_ai/services/process_inspect.py b/kernel_ai/services/process_inspect.py index 993b125..270d020 100644 --- a/kernel_ai/services/process_inspect.py +++ b/kernel_ai/services/process_inspect.py @@ -79,8 +79,31 @@ def _collect_local_tcp_pairs(proc_names_by_pid): return sorted(pairs) +def _is_ipc_shared_mapping(path): + """A shared mapping that is actually a channel, not a file two processes both mapped. + + ``/proc/pid/maps`` marks GPU buffers, font caches and Chromium pack files + ``s`` as well. Those are not pipes. The orbit only keeps POSIX shm, memfd + and System V segments — the things a process creates to talk to another. + """ + if not path: + return False + if path.startswith("/dev/shm/") or path.startswith("/run/shm/"): + return True + if path.startswith("/memfd:") or path.startswith("memfd:"): + return True + if path.startswith("/SYSV") or path.startswith("/dev/shm"): + return True + return False + + def get_ipc_links_summary(max_pairs=120, max_nodes=24): - """Collect IPC relationships by shared sockets, pipes and shared memory mappings.""" + """Collect IPC relationships by shared sockets, pipes and real shared memory. + + A pair is two processes that hold the same pipe, unix or tcp socket inode, + or the same POSIX/memfd/SYSV segment. Namespaces and shared file mappings + (fonts, GPU nodes, pack files) are not channels and do not make a pair. + """ socket_inode_re = re.compile(r"^socket:\[(\d+)\]$") pipe_inode_re = re.compile(r"^pipe:\[(\d+)\]$") socket_kinds = _parse_proc_net_socket_inodes() @@ -161,6 +184,8 @@ def get_ipc_links_summary(max_pairs=120, max_nodes=24): continue if map_path.startswith("["): continue + if not _is_ipc_shared_mapping(map_path): + continue shm_key = f"{dev}:{inode}:{map_path}" shm_owners.setdefault(shm_key, set()).add((pid, proc_name)) @@ -241,7 +266,8 @@ def consume_inode_owners(owner_map, kind): 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") + # Sharing a mount or net namespace is not a channel. The orbit would + # otherwise pair every process on the box with every other. 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") @@ -588,6 +614,205 @@ def get_process_namespace_fingerprint(pid): } +_TCP_STATES = { + "01": "ESTABLISHED", + "02": "SYN_SENT", + "03": "SYN_RECV", + "04": "FIN_WAIT1", + "05": "FIN_WAIT2", + "06": "TIME_WAIT", + "07": "CLOSE", + "08": "CLOSE_WAIT", + "09": "LAST_ACK", + "0A": "LISTEN", + "0B": "CLOSING", +} + + +def _family_label(family, conn_type): + fam = str(family or "").upper() + typ = str(conn_type or "").upper() + if "UNIX" in fam: + return "unix" + proto = "udp" if "DGRAM" in typ else "tcp" + if "INET6" in fam: + return f"{proto}6" + if "INET" in fam: + return proto + return "socket" + + +def _format_addr(addr): + if not addr: + return None + if hasattr(addr, "ip") and hasattr(addr, "port"): + return f"{addr.ip}:{addr.port}" + if hasattr(addr, "path") and addr.path: + return str(addr.path) + if isinstance(addr, (tuple, list)) and addr: + if len(addr) >= 2 and addr[1] is not None: + return f"{addr[0]}:{addr[1]}" + return str(addr[0]) + return str(addr) + + +def _split_ip_port(text): + if not text or ":" not in str(text): + return None + host, port = str(text).rsplit(":", 1) + if not port.isdigit(): + return None + return host, int(port) + + +def _tcp_peer_index(): + """Map a local 4-tuple to the process on the other end, when both ends are visible.""" + try: + names = {} + for proc in psutil.process_iter(["pid", "name"]): + info = proc.info + names[int(info["pid"])] = info.get("name") + + endpoints = {} + for conn in psutil.net_connections(kind="inet"): + 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) + + peers = {} + for (lip, lport, rip, rport), pid in endpoints.items(): + other = endpoints.get((rip, rport, lip, lport)) + if other and other != pid: + peers[(lip, lport, rip, rport)] = (other, names.get(other)) + return peers + except (psutil.Error, OSError, AttributeError, TypeError, ValueError, IndexError): + return {} + + +def _annotate_connection_peers(connections): + peers = _tcp_peer_index() + if not peers: + return connections + for row in connections: + ends = _split_ip_port(row.get("local_address")) + remote = _split_ip_port(row.get("remote_address")) + if not ends or not remote: + continue + hit = peers.get((ends[0], ends[1], remote[0], remote[1])) + if not hit: + continue + row["peer_pid"] = hit[0] + row["peer_name"] = hit[1] + return connections + + +def _hex_ipv4(hex_ip): + chunks = [hex_ip[i : i + 2] for i in range(0, 8, 2)] + chunks.reverse() + return ".".join(str(int(part, 16)) for part in chunks) + + +def _connections_from_procnet(pid): + """When ptrace is refused, pair this process's socket inodes with /proc/net.""" + try: + fd_dir = f"/proc/{pid}/fd" + inodes = {} + for fd_num in os.listdir(fd_dir): + if not fd_num.isdigit(): + continue + try: + target = os.readlink(f"{fd_dir}/{fd_num}") + except (OSError, PermissionError): + continue + if target.startswith("socket:[") and target.endswith("]"): + try: + inodes[int(target[8:-1])] = int(fd_num) + except ValueError: + continue + if not inodes: + return [] + + rows = [] + tables = ( + ("/proc/net/tcp", "tcp", False), + ("/proc/net/tcp6", "tcp6", True), + ("/proc/net/udp", "udp", False), + ("/proc/net/udp6", "udp6", True), + ) + for path, family, ipv6 in tables: + try: + handle = open(path, "r", encoding="utf-8", errors="replace") + except (OSError, PermissionError): + continue + with handle: + for line in handle: + parts = line.split() + if len(parts) < 10 or parts[0] == "sl": + continue + try: + inode = int(parts[9]) + except ValueError: + continue + fd = inodes.get(inode) + if fd is None: + continue + try: + local_hex, remote_hex = parts[1], parts[2] + if ipv6: + local_address = local_hex + remote_address = None if remote_hex.endswith(":0000") else remote_hex + else: + lip, lport = local_hex.split(":") + rip, rport = remote_hex.split(":") + local_address = f"{_hex_ipv4(lip)}:{int(lport, 16)}" + remote_address = None if rip == "00000000" else f"{_hex_ipv4(rip)}:{int(rport, 16)}" + except (ValueError, IndexError): + continue + rows.append( + { + "fd": fd, + "family": family, + "type": family, + "local_address": local_address, + "remote_address": remote_address, + "status": _TCP_STATES.get(parts[3].upper(), parts[3]), + "peer_pid": None, + "peer_name": None, + } + ) + return rows + except (OSError, PermissionError, ValueError, IndexError, TypeError): + return [] + + +def _collect_process_connections(pid, proc): + connections = [] + try: + for conn in proc.connections(): + connections.append( + { + "fd": conn.fd if hasattr(conn, "fd") else None, + "family": _family_label(getattr(conn, "family", None), getattr(conn, "type", None)), + "type": str(getattr(conn, "type", "") or ""), + "local_address": _format_addr(getattr(conn, "laddr", None)), + "remote_address": _format_addr(getattr(conn, "raddr", None)), + "status": getattr(conn, "status", None), + "peer_pid": None, + "peer_name": None, + } + ) + except (psutil.AccessDenied, psutil.NoSuchProcess, AttributeError): + connections = [] + if not connections: + connections = _connections_from_procnet(pid) + return _annotate_connection_peers(connections)[:20] + + def get_process_fds_info(pid): try: proc = psutil.Process(pid) @@ -630,21 +855,7 @@ def get_process_fds_info(pid): except (OSError, PermissionError): pass - connections = [] - try: - for conn in proc.connections(): - connections.append( - { - "fd": conn.fd if hasattr(conn, "fd") else None, - "family": str(conn.family), - "type": str(conn.type), - "local_address": f"{conn.laddr.ip}:{conn.laddr.port}" if conn.laddr else None, - "remote_address": f"{conn.raddr.ip}:{conn.raddr.port}" if conn.raddr else None, - "status": conn.status, - } - ) - except (psutil.AccessDenied, psutil.NoSuchProcess, AttributeError): - pass + connections = _collect_process_connections(pid, proc) descriptors = [] fd_dir = f"/proc/{pid}/fd" @@ -683,11 +894,13 @@ def get_process_fds_info(pid): conn = connection_by_fd.get(descriptor["fd"]) if not conn: continue - family = str(conn.get("family") or "").upper() - if "AF_UNIX" in family: + family = str(conn.get("family") or "").lower() + if family == "unix": descriptor["type"] = "unix socket" - elif "AF_INET" in family: + elif family.startswith("tcp"): descriptor["type"] = "tcp socket" if conn.get("remote_address") else "inet socket" + elif family.startswith("udp"): + descriptor["type"] = "udp socket" descriptor["local_address"] = conn.get("local_address") descriptor["remote_address"] = conn.get("remote_address") descriptor["status"] = conn.get("status") diff --git a/static/js/ipc-ui.js b/static/js/ipc-ui.js index 29581e8..ca87802 100644 --- a/static/js/ipc-ui.js +++ b/static/js/ipc-ui.js @@ -61,8 +61,39 @@ function buildChannelArc(cx, cy, startAngle, endAngle, radius) { return `M ${sx} ${sy} A ${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${ex} ${ey}`; } +const POLL_MS = 3000; +const MAX_STATIONS = 14; +let pollTimer = null; +let pollSeq = 0; +let lastCenter = null; +let lastAnchors = null; +let stationAngle = new Map(); +let vacatedAngles = []; + +function stopIpcOrbit() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + pollSeq += 1; +} + function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { + stopIpcOrbit(); + stationAngle = new Map(); + vacatedAngles = []; + lastCenter = { x: centerX, y: centerY }; + lastAnchors = processAnchorsByName; d3.selectAll('.ipc-ring-layer').remove(); + const seq = ++pollSeq; + fetchIpcAndPaint(centerX, centerY, processAnchorsByName, false, seq); + pollTimer = setInterval(() => { + if (document.hidden || !lastCenter) return; + fetchIpcAndPaint(lastCenter.x, lastCenter.y, lastAnchors, true, pollSeq); + }, POLL_MS); +} + +function fetchIpcAndPaint(centerX, centerY, processAnchorsByName, live, seq) { fetch('/api/ipc-links?max_nodes=18&max_pairs=120') .then(res => { if (!res.ok) { @@ -71,41 +102,93 @@ function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { return res.json(); }) .then(data => { - const ringGroup = svg.append('g') - .attr('class', 'ipc-ring-layer'); + if (seq !== pollSeq) return; + paintIpcOrbit(data, centerX, centerY, processAnchorsByName, live); + }) + .catch((error) => { + if (seq !== pollSeq) return; + console.warn('IPC ring data unavailable:', error); + if (live) return; + paintIpcFallback(centerX, centerY, processAnchorsByName); + }); +} + +function angleForStation(name, index, total) { + if (stationAngle.has(name)) return stationAngle.get(name); + const reused = vacatedAngles.shift(); + const angle = reused != null + ? reused + : (-Math.PI / 2 + (index / Math.max(total, 1)) * Math.PI * 2); + stationAngle.set(name, angle); + return angle; +} + +function forgetMissingStations(names) { + const seen = new Set(names); + stationAngle.forEach((angle, name) => { + if (!seen.has(name)) { + stationAngle.delete(name); + vacatedAngles.push(angle); + } + }); +} + +function orbitTranslate(cx, cy, radius, angle) { + return `translate(${cx + Math.cos(angle) * radius},${cy + Math.sin(angle) * radius})`; +} + +// First paint: start as a tight cluster at 12 o'clock, then slide apart +// along the ring to even 2π/n homes. Live polls keep the home angle. +function clusterAngle(index, total) { + if (total <= 1) return -Math.PI / 2; + return -Math.PI / 2 + (index - (total - 1) / 2) * 0.048; +} +function paintIpcOrbit(data, centerX, centerY, processAnchorsByName, live) { const ringCx = centerX; const ringCy = centerY; // Place IPC ring around the process circle (outside process endpoints). const ringR = 355; - 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); + let ringGroup = svg.select('.ipc-ring-layer'); + const hasRails = !ringGroup.empty() && !ringGroup.select('.ipc-orbit-rail').empty(); + if (!live || !hasRails) { + d3.selectAll('.ipc-ring-layer').remove(); + ringGroup = svg.append('g').attr('class', 'ipc-ring-layer'); + IPC_CHANNELS.forEach((channel, idx) => { + const radius = ringR + channel.radiusOffset; + ringGroup.append('circle') + .attr('class', 'ipc-orbit-rail') + .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('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); - }); + ringGroup.append('text') + .attr('class', 'ipc-orbit-rail') + .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); + }); + } + + ringGroup.select('.ipc-orbit-live').remove(); + d3.selectAll('.ipc-link-tooltip').remove(); + const liveGroup = ringGroup.append('g').attr('class', 'ipc-orbit-live'); if (!data || data.error) { console.warn('IPC API payload error:', data && data.error); } - let nodes = ((data && data.process_nodes) || []).slice(0, 14); + let nodes = ((data && data.process_nodes) || []).slice(0, MAX_STATIONS); // Fallback: if IPC endpoint is empty/unavailable on host, still render ring nodes from process anchors. if (!nodes.length) { const fallbackNames = Array.from(processAnchorsByName.keys()).slice(0, 14); @@ -125,7 +208,7 @@ function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { } const stats = (data && data.stats) || {}; - ringGroup.append('text') + liveGroup.append('text') .attr('x', ringCx) .attr('y', ringCy - ringR - 12) .attr('text-anchor', 'middle') @@ -168,18 +251,24 @@ function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { peerMap.set(key, arr.slice(0, 3)); }); - const pairArcLayer = ringGroup.append('g') + const enterMotion = !live; + const nodeCount = nodes.length; + const pairArcLayer = liveGroup.append('g') .attr('class', 'ipc-pair-arc-layer'); const maxDegree = Math.max(...nodes.map(n => Number(n.degree || 0)), 1); + const placedNames = nodes.map((node) => normalizeProcName(node.name || '')).filter(Boolean); + forgetMissingStations(placedNames); const nodePos = []; nodes.forEach((node, i) => { - const t = i / nodes.length; - const angle = -Math.PI / 2 + t * (Math.PI * 2); - const nx = ringCx + Math.cos(angle) * ringR; - const ny = ringCy + Math.sin(angle) * ringR; + const normalizedName = normalizeProcName(node.name || ''); + const homeAngle = angleForStation(normalizedName, i, nodeCount); + const fromAngle = enterMotion + ? (nodeCount <= 1 ? homeAngle - 0.34 : clusterAngle(i, nodeCount)) + : homeAngle; + const nx = ringCx + Math.cos(homeAngle) * ringR; + const ny = ringCy + Math.sin(homeAngle) * ringR; const degree = Number(node.degree || 0); const radius = 2.8 + (degree / maxDegree) * 2.8; - const normalizedName = normalizeProcName(node.name || ''); nodePos.push({ x: nx, y: ny, @@ -203,9 +292,23 @@ function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { }; const nodeChannel = dominantChannel(nodeChannelRow); - ringGroup.append('circle') - .attr('cx', nx) - .attr('cy', ny) + const station = liveGroup.append('g') + .attr('class', 'ipc-orbit-station') + .attr('transform', orbitTranslate(ringCx, ringCy, ringR, fromAngle)); + if (enterMotion) { + station.transition() + .delay(i * 42) + .duration(1180) + .ease(d3.easeCubicOut) + .attrTween('transform', () => (t) => { + const a = fromAngle + (homeAngle - fromAngle) * t; + return orbitTranslate(ringCx, ringCy, ringR, a); + }); + } + + station.append('circle') + .attr('cx', 0) + .attr('cy', 0) .attr('r', radius) .attr('fill', nodeChannel ? nodeChannel.color : 'rgba(90, 90, 90, 0.55)') .attr('stroke', 'rgba(24, 24, 24, 0.5)') @@ -239,15 +342,27 @@ function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { }) .on('mouseleave', () => { d3.selectAll('.ipc-link-tooltip').remove(); + }) + .on('click', (event) => { + event.stopPropagation(); + d3.selectAll('.ipc-link-tooltip').remove(); + const matches = (processAnchorsByName && processAnchorsByName.get(normalizedName)) || []; + const hintPid = matches[0] && matches[0].pid; + if (typeof window.openProcessDossier === "function") { + window.openProcessDossier({ + pid: hintPid, + name: node.name || normalizedName + }); + } }); IPC_CHANNELS.forEach((channel, channelIdx) => { const active = Number(nodeChannelRow[channel.degreeKey] || 0) > 0; - const dotAngle = angle - 0.18 + channelIdx * 0.12; + const dotAngle = homeAngle - 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) + station.append('circle') + .attr('cx', Math.cos(dotAngle) * dotRadius) + .attr('cy', 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') @@ -257,13 +372,10 @@ function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { if (i < 10) { const label = String(node.name || normalizedName); - const labelAngle = angle; - const lx = nx + Math.cos(labelAngle) * 11; - const ly = ny + Math.sin(labelAngle) * 11; - ringGroup.append('text') - .attr('x', lx) - .attr('y', ly) - .attr('text-anchor', Math.cos(labelAngle) >= 0 ? 'start' : 'end') + station.append('text') + .attr('x', Math.cos(homeAngle) * 11) + .attr('y', Math.sin(homeAngle) * 11) + .attr('text-anchor', Math.cos(homeAngle) >= 0 ? 'start' : 'end') .attr('dominant-baseline', 'middle') .style('font-family', 'Share Tech Mono, monospace') .style('font-size', '7px') @@ -281,6 +393,7 @@ function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { 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 sameStation = leftNode === rightNode; const linkWeights = { unixSocketWeight: Number(link.unix_socket_weight || 0), pipeWeight: Number(link.pipe_weight || 0), @@ -290,15 +403,28 @@ function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { 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)) + const radius = ringR + channel.radiusOffset; + // Two processes of the same name share a channel: a short + // bump on the ring, not an arc from a station to itself. + const arc = sameStation + ? buildChannelArc(ringCx, ringCy, startAngle - 0.14, startAngle + 0.14, radius) + : buildChannelArc(ringCx, ringCy, startAngle, endAngle, radius); + const destOpacity = channel.key === 'shm' ? 0.38 : 0.5; + const arcPath = pairArcLayer.append('path') + .attr('d', arc) .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) + .attr('opacity', enterMotion ? 0 : destOpacity) .style('pointer-events', 'none'); + if (enterMotion) { + arcPath.transition() + .delay(880) + .duration(420) + .attr('opacity', destOpacity); + } pairArcCount += 1; }); }); @@ -322,21 +448,27 @@ function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { ringR, laneOffset ); - ringGroup.append('path') + const spoke = liveGroup.append('path') .attr('d', routedPath) .attr('fill', 'none') .attr('stroke', 'rgba(78, 78, 78, 0.22)') .attr('stroke-width', 0.7) .attr('stroke-linecap', 'round') + .attr('opacity', enterMotion ? 0 : 1) .style('pointer-events', 'none'); + if (enterMotion) { + spoke.transition() + .delay(880) + .duration(420) + .attr('opacity', 1); + } linkOrdinal += 1; } }); - }) - .catch((error) => { - console.warn('IPC ring data unavailable:', error); - // Last-resort fallback ring from process names only. - const fallbackNames = Array.from(processAnchorsByName.keys()).slice(0, 14); +} + +function paintIpcFallback(centerX, centerY, processAnchorsByName) { + const fallbackNames = Array.from(processAnchorsByName.keys()).slice(0, MAX_STATIONS); if (!fallbackNames.length) return; const ringGroup = svg.append('g') .attr('class', 'ipc-ring-layer'); @@ -372,7 +504,6 @@ function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { .style('fill', 'rgba(62, 62, 62, 0.58)') .text(name.length > 12 ? `${name.slice(0, 11)}~` : name); }); - }); } function buildIpcRoutedPath(cx, cy, startX, startY, targetX, targetY, outerRingRadius, laneOffset = 0) { @@ -401,10 +532,16 @@ function buildIpcRoutedPath(cx, cy, startX, startY, targetX, targetY, outerRingR L ${targetX} ${targetY}`; } +document.addEventListener('visibilitychange', () => { + if (document.hidden || !lastCenter || !pollTimer) return; + fetchIpcAndPaint(lastCenter.x, lastCenter.y, lastAnchors, true, pollSeq); +}); + window.IpcUI = { normalizeProcName, getSharedChannelType, drawIpcRelationshipRing, + stopIpcOrbit, buildIpcRoutedPath }; })(); diff --git a/static/js/main.js b/static/js/main.js index 4d44d27..7627ed3 100755 --- a/static/js/main.js +++ b/static/js/main.js @@ -1234,7 +1234,13 @@ function renderProcessDossier() { 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 } + { + label: 'NET CONNS', + value: `${io.sockets}`, + unit: '', + live: Number(io.sockets || 0) > 0, + open: Number(io.sockets || 0) > 0 && window.SocketsCard ? SocketsCard.open : null + } ]; const colW = (vitBox.w - 28) / vitals.length; vitals.forEach((v, i) => { @@ -1687,6 +1693,52 @@ const processModalTopKeeper = createOverlayTopKeeper( () => !!pinnedProcessDossier ); +function closeOpenKernelCards() { + ["MemoryCard", "ThreadsCard", "WaitsCard", "WakeupsCard", "SocketsCard", + "SyscallCard", "IrqCard", "RunqueueCard"].forEach((name) => { + const card = window[name]; + if (card && typeof card.close === "function") card.close(); + }); +} + +function openProcessDossier(hint) { + const index = window.__processIndex || { byPid: new Map(), byName: new Map(), atPid: new Map() }; + const pid = Number(hint && hint.pid); + let processData = Number.isFinite(pid) && pid > 0 ? index.byPid.get(pid) : null; + if (!processData && hint && hint.name) { + const list = index.byName.get(normalizeProcName(hint.name)) || []; + processData = list[0] || null; + } + if (!processData) { + if (!Number.isFinite(pid) || pid <= 0) return; + processData = { + pid, + name: (hint && hint.name) || "process", + memory_mb: 0, + status: "" + }; + } + closeOpenKernelCards(); + const pos = index.atPid.get(processData.pid); + const anchor = pos || { x: window.innerWidth * 0.42, y: window.innerHeight * 0.42 }; + if (pinnedProcessDossier && pinnedProcessDossier.process?.pid !== processData.pid) { + clearPinnedProcessDossier(); + } + pinProcessDossier(processData, anchor); + svg.selectAll(".process-node-group").classed("process-pinned", false); + const group = svg.select(`.process-node-group[data-pid="${processData.pid}"]`); + if (!group.empty()) { + group.classed("process-pinned", true); + group.select(".process-node") + .interrupt() + .attr("r", 8) + .attr("fill", "#111") + .attr("stroke", "#000") + .attr("stroke-width", 2); + } +} +window.openProcessDossier = openProcessDossier; + function pinProcessDossier(processData, anchor) { const samePid = pinnedProcessDossier && pinnedProcessDossier.process?.pid === processData.pid; pinnedProcessDossier = { @@ -2413,6 +2465,18 @@ function drawProcessKernelMap2(centerX, centerY) { const numProcesses = processes.length; const mobileLayout = isMobileLayout(); const processAnchorsByName = new Map(); + const processByPid = new Map(); + const processByName = new Map(); + const processAtPid = new Map(); + processes.forEach((process) => { + if (!process || process.pid == null) return; + processByPid.set(process.pid, process); + const nm = normalizeProcName(process.name || ''); + if (!nm) return; + if (!processByName.has(nm)) processByName.set(nm, []); + processByName.get(nm).push(process); + }); + window.__processIndex = { byPid: processByPid, byName: processByName, atPid: processAtPid }; // Find min and max memory usage for scaling const memoryValues = processes.map(p => p.memory_mb || 0); @@ -2594,6 +2658,7 @@ function drawProcessKernelMap2(centerX, centerY) { } processAnchorsByName.get(normalizedName).push({ x: px, y: py, pid: process.pid, name: process.name }); } + processAtPid.set(process.pid, { x: px, y: py }); // Curve to process (same style as original) const cx1 = centerX + (px - centerX) * 0.3 + (Math.random() - 0.5) * 40; @@ -2896,6 +2961,7 @@ function drawProcessKernelMap2(centerX, centerY) { if (!mobileLayout) { drawIpcRelationshipRing(centerX, centerY, processAnchorsByName); } else { + stopIpcOrbit(); d3.selectAll('.ipc-ring-layer').remove(); drawMobileProcessLabels(centerX, centerY, topProcessNames(processes, 4)); } @@ -2950,6 +3016,10 @@ function drawIpcRelationshipRing(centerX, centerY, processAnchorsByName) { return callModuleFunction('IpcUI', 'drawIpcRelationshipRing', [centerX, centerY, processAnchorsByName]); } +function stopIpcOrbit() { + return callModuleFunction('IpcUI', 'stopIpcOrbit', []); +} + function buildIpcRoutedPath(cx, cy, startX, startY, targetX, targetY, outerRingRadius, laneOffset = 0) { return callModuleFunction( 'IpcUI', diff --git a/static/js/memory-card.js b/static/js/memory-card.js index 032942e..dddb6d4 100644 --- a/static/js/memory-card.js +++ b/static/js/memory-card.js @@ -9,6 +9,10 @@ // 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. +// +// While the card is open it re-reads the same payload every couple of seconds +// and repaints the body. The frame stays put: unfolding it again would look +// like a new card every tick. const MemoryCard = (() => { const W = 600; const PAD = 14; @@ -20,6 +24,7 @@ const MemoryCard = (() => { const BAR_H = 8; const MAX_LIBS = 8; const MAX_STACKS = 8; + const POLL_MS = 2000; const KIND_TINT = { code: "#e2a33e", @@ -36,6 +41,9 @@ const MemoryCard = (() => { let openPid = null; let topKeeper = null; let requestSeq = 0; + let pollTimer = null; + let lastAnchor = null; + let layout = null; function clip(text, max) { const value = String(text || ""); @@ -50,8 +58,18 @@ const MemoryCard = (() => { return `${Math.round(n)} KB`; } + function stopPoll() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + function close() { + stopPoll(); openPid = null; + lastAnchor = null; + layout = null; requestSeq += 1; svg.selectAll(".memory-card-scrim, .memory-card-layer").remove(); if (topKeeper) topKeeper.stop(); @@ -59,6 +77,29 @@ const MemoryCard = (() => { window.dispatchEvent(new CustomEvent("kcard-closed")); } + function startPoll(pid) { + stopPoll(); + pollTimer = setInterval(() => { + if (openPid !== pid) { + stopPoll(); + return; + } + if (document.hidden) return; + const seq = requestSeq; + fetch(`/api/process/${pid}/memory`, { cache: "no-store" }) + .then((r) => r.json()) + .then((data) => { + if (seq !== requestSeq || openPid !== pid) return; + if (!data || data.error) { + close(); + return; + } + draw(data, lastAnchor, true); + }) + .catch(() => {}); + }, POLL_MS); + } + function open(pid, anchor) { const key = Number(pid); if (!Number.isFinite(key)) return; @@ -68,6 +109,7 @@ const MemoryCard = (() => { } close(); openPid = key; + lastAnchor = anchor; const seq = ++requestSeq; fetch(`/api/process/${key}/memory`, { cache: "no-store" }) .then((r) => r.json()) @@ -77,7 +119,8 @@ const MemoryCard = (() => { openPid = null; return; } - draw(data, anchor); + draw(data, anchor, false); + startPoll(key); }) .catch((err) => { if (seq !== requestSeq) return; @@ -90,86 +133,130 @@ const MemoryCard = (() => { }); } - 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 || {}; + function cardHeight(data, compact) { 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 + h += LINE + LINE; 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; + if (!hasMap) h += 10 + LINE; h += FOOTER; + return h; + } - 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); + function draw(data, anchor, live) { + const svgNode = svg.node(); + const viewW = (svgNode && svgNode.clientWidth) || window.innerWidth; + const viewH = (svgNode && svgNode.clientHeight) || window.innerHeight; + const cw = (live && layout) ? layout.cw : Math.min(W, viewW - 24); + const compact = cw < 460; + const h = cardHeight(data, compact); + + let x; + let y; + if (live && layout) { + x = layout.x; + y = Math.max(12, Math.min(viewH - h - 12, layout.y)); + } else { + const from = anchor && Number.isFinite(anchor.x) ? anchor.x : 300; + 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); + y = (anchor && Number.isFinite(anchor.y) ? anchor.y : 120) - 30; + y = Math.max(12, Math.min(viewH - h - 12, y)); } - 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); + layout = { x, y, cw, h }; + + let layer; + let panel; + if (live) { + layer = svg.select(".memory-card-layer"); + panel = layer.select(".memory-card-panel"); + if (layer.empty() || panel.empty()) return; + panel.attr("transform", `translate(${x}, ${y})`); + panel.select(".kcard-frame").attr("d", dossierCardPath(0, 0, cw, h, CUT)); + if (anchor && Number.isFinite(anchor.x) && Number.isFinite(anchor.y)) { + const connY = Math.max(y + 12, Math.min(y + h - 12, anchor.y)); + layer.select(".kcard-conn").attr("x2", x).attr("y2", connY); + } + panel.select(".memory-card-body").remove(); + } else { + 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); + + 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); + } + + panel = layer.append("g") + .attr("class", "memory-card-panel") + .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 panel = layer.append("g") - .attr("transform", `translate(${x}, ${y})`) - .on("click", (event) => event.stopPropagation()); + const body = panel.append("g").attr("class", "memory-card-body"); + if (!live) { + body.style("opacity", 0); + body.transition().delay(250).duration(180).style("opacity", 1); + } - 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)"); + paintBody(body, data, cw, compact, h); + d3.select("body").on("keydown.memorycard", (event) => { + if (event.key === "Escape") close(); + }); + } - const body = panel.append("g").attr("class", "memory-card-body").style("opacity", 0); - body.transition().delay(250).duration(180).style("opacity", 1); + function paintBody(body, data, cw, compact, h) { + 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"); const text = (cls, tx, ty, value, anchorEnd) => body.append("text") .attr("class", cls) @@ -324,10 +411,6 @@ const MemoryCard = (() => { 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 }; diff --git a/static/js/threads-card.js b/static/js/threads-card.js index 11ccec9..7516ba6 100644 --- a/static/js/threads-card.js +++ b/static/js/threads-card.js @@ -17,6 +17,9 @@ // 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. +// +// While the card is open it re-reads the same payload every couple of seconds +// and repaints the table. The frame stays put. const ThreadsCard = (() => { const W = 640; const PAD = 14; @@ -39,10 +42,14 @@ const ThreadsCard = (() => { // Both scheduler columns are right-aligned, the verdict clear of the // deadline that follows it. const VERDICT_INSET = 52; + const POLL_MS = 2000; let openPid = null; let topKeeper = null; let requestSeq = 0; + let pollTimer = null; + let lastAnchor = null; + let layout = null; function clip(text, max) { const value = String(text || ""); @@ -61,8 +68,18 @@ const ThreadsCard = (() => { return v > 0 ? "<1ms" : "0"; } + function stopPoll() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + function close() { + stopPoll(); openPid = null; + lastAnchor = null; + layout = null; requestSeq += 1; svg.selectAll(".threads-card-scrim, .threads-card-layer").remove(); if (topKeeper) topKeeper.stop(); @@ -70,6 +87,29 @@ const ThreadsCard = (() => { window.dispatchEvent(new CustomEvent("kcard-closed")); } + function startPoll(pid) { + stopPoll(); + pollTimer = setInterval(() => { + if (openPid !== pid) { + stopPoll(); + return; + } + if (document.hidden) return; + const seq = requestSeq; + fetch(`/api/process/${pid}/threads`, { cache: "no-store" }) + .then((r) => r.json()) + .then((data) => { + if (seq !== requestSeq || openPid !== pid) return; + if (!data || data.error) { + close(); + return; + } + draw(data, lastAnchor, true); + }) + .catch(() => {}); + }, POLL_MS); + } + function open(pid, anchor) { const key = Number(pid); if (!Number.isFinite(key)) return; @@ -79,6 +119,7 @@ const ThreadsCard = (() => { } close(); openPid = key; + lastAnchor = anchor; const seq = ++requestSeq; fetch(`/api/process/${key}/threads`, { cache: "no-store" }) @@ -89,7 +130,8 @@ const ThreadsCard = (() => { openPid = null; return; } - draw(data, anchor); + draw(data, anchor, false); + startPoll(key); }) .catch(() => { if (seq !== requestSeq) return; @@ -137,11 +179,11 @@ const ThreadsCard = (() => { return `due ${Math.abs(due) >= 100 ? Math.round(due) : due.toFixed(1)}`; } - function draw(data, anchor) { + function draw(data, anchor, live) { 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 cw = (live && layout) ? layout.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; @@ -190,56 +232,83 @@ const ThreadsCard = (() => { // 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); + let x; + let y; + if (live && layout) { + x = layout.x; + y = Math.max(12, Math.min(viewH - h - 12, layout.y)); + } else { + const from = anchor && Number.isFinite(anchor.x) ? anchor.x : 290; + 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); + y = (anchor && anchor.y ? anchor.y : 90) - 40; + y = Math.max(12, Math.min(viewH - h - 12, y)); } - 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()); + layout = { x, y, cw, h }; + + let layer; + let panel; + if (live) { + layer = svg.select(".threads-card-layer"); + panel = layer.select(".threads-card-panel"); + if (layer.empty() || panel.empty()) return; + panel.attr("transform", `translate(${x}, ${y})`); + panel.select(".kcard-frame").attr("d", dossierCardPath(0, 0, cw, h, CUT)); + if (anchor && Number.isFinite(anchor.x) && Number.isFinite(anchor.y)) { + const connY = Math.max(y + 12, Math.min(y + h - 12, anchor.y)); + layer.select(".kcard-conn").attr("x2", x).attr("y2", connY); + } + panel.select(".threads-card-body").remove(); + } else { + 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); + + 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); + } - 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)"); + panel = layer.append("g") + .attr("class", "threads-card-panel") + .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 body = panel.append("g").attr("class", "threads-card-body"); + if (!live) { + 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) @@ -331,10 +400,11 @@ const ThreadsCard = (() => { 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); + .attr("stroke", "#e2a33e") + .attr("stroke-width", 1) + .attr("opacity", 0.35); body.append("rect") .attr("x", colWhere - 3).attr("y", ty - 9) .attr("width", box.width + 8).attr("height", 13) @@ -342,11 +412,11 @@ const ThreadsCard = (() => { .style("cursor", "pointer") .on("mouseenter", () => { where.attr("fill", "#e2a33e"); - rule.attr("stroke", "#e2a33e").attr("opacity", 0.7); + rule.attr("opacity", 1); }) .on("mouseleave", () => { where.attr("fill", null); - rule.attr("stroke", null).attr("opacity", 0.3); + rule.attr("opacity", 0.35); }) .on("click", (event) => { event.stopPropagation(); diff --git a/static/js/waits-card.js b/static/js/waits-card.js index 9fede6b..c815b33 100644 --- a/static/js/waits-card.js +++ b/static/js/waits-card.js @@ -63,6 +63,41 @@ const WaitsCard = (() => { window.dispatchEvent(new CustomEvent("kcard-closed")); } + function followProcess(pid, name) { + if (!pid || Number(pid) === 0) return; + close(); + if (typeof window.openProcessDossier === "function") { + window.openProcessDossier({ pid: Number(pid), name }); + } + } + + function door(body, label, x, y, width, pid, name) { + if (!pid || Number(pid) === 0) return; + const rule = body.append("line") + .attr("x1", x).attr("y1", y + 2.5) + .attr("x2", x + width).attr("y2", y + 2.5) + .attr("stroke", "#e2a33e") + .attr("stroke-width", 1) + .attr("opacity", 0.35); + body.append("rect") + .attr("x", x - 3).attr("y", y - 9) + .attr("width", width + 8).attr("height", 13) + .attr("fill", "transparent") + .style("cursor", "pointer") + .on("mouseenter", () => { + label.attr("fill", "#e2a33e"); + rule.attr("opacity", 1); + }) + .on("mouseleave", () => { + label.attr("fill", null); + rule.attr("opacity", 0.35); + }) + .on("click", (event) => { + event.stopPropagation(); + followProcess(pid, name); + }); + } + function open(pid, tid, anchor) { const key = `${pid}:${tid}`; if (openKey === key) { @@ -376,9 +411,13 @@ const WaitsCard = (() => { 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)); + const name = 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 (r.pid && r.pid !== data.pid) { + const box = name.node().getBBox(); + door(body, name, COL_NAME, ty, box.width, r.pid, r.comm); + } }); if (farEnd.length) cy += farEnd.length * ROW_STEP; if (farEnd.length && farEnd.every((r) => r.pid === data.pid)) { @@ -394,8 +433,12 @@ const WaitsCard = (() => { 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)); + const name = text("kcard-waiter-dim", COL_NAME, ty, clip(r.comm, 16)); text("kcard-faint", COL_NOTE, ty, `fd ${r.fd} · inherited copy`); + if (r.pid && r.pid !== data.pid) { + const box = name.node().getBBox(); + door(body, name, COL_NAME, ty, box.width, r.pid, r.comm); + } }); cy += nearEnd.length * ROW_STEP; } @@ -435,9 +478,13 @@ const WaitsCard = (() => { 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)); + const name = 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` : ""}`); + if (!w.idle && w.pid && w.pid !== data.pid) { + const box = name.node().getBBox(); + door(body, name, COL_NAME, ty, box.width, w.pid, w.comm); + } }); cy += seenWakers.length * ROW_STEP; }