From 963cf2e615d57ce97d04401f58825429f1539eaa Mon Sep 17 00:00:00 2001 From: Aleksei Fedorov Date: Mon, 24 Aug 2026 18:47:55 +0000 Subject: [PATCH] history cards --- index.html | 28 +- static/js/flow-history-card.js | 364 ++++++++++++++++++++++++ static/js/history-card.js | 492 +++++++++++++++++++++++++++++++++ static/js/ip-entry-card.js | 492 +++++++++++++++++++++++++++++++++ static/js/irq-history-card.js | 352 +++++++++++++++++++++++ 5 files changed, 1721 insertions(+), 7 deletions(-) create mode 100644 static/js/flow-history-card.js create mode 100644 static/js/history-card.js create mode 100644 static/js/ip-entry-card.js create mode 100644 static/js/irq-history-card.js diff --git a/index.html b/index.html index aa4cdff..8b3e73b 100755 --- a/index.html +++ b/index.html @@ -37,7 +37,7 @@ - + @@ -83,6 +83,16 @@

Linux Kernel Ring 0 Visualization

+ @@ -93,12 +103,15 @@

Linux Kernel Ring 0 Visualization

- + + - + + + @@ -107,10 +120,11 @@

Linux Kernel Ring 0 Visualization

- + - - + + + @@ -126,7 +140,7 @@

Linux Kernel Ring 0 Visualization

console.error('❌ RightSemicircleMenuManager class NOT loaded!'); } - + diff --git a/static/js/flow-history-card.js b/static/js/flow-history-card.js new file mode 100644 index 0000000..1366cae --- /dev/null +++ b/static/js/flow-history-card.js @@ -0,0 +1,364 @@ +// The card the HISTORY door of FLOW opens. +// +// FLOW is the now-picture of one 4-tuple: local, peer, the sock metro, +// traceroute. History is the biography of that session — bytes and segments +// it has moved, when it last spoke, the lowest RTT it has seen, whether it +// has had to retransmit. The kernel does not give a socket a birth clock +// the way it does a pid; these totals are the life it does keep. +// +// Not a second metro. Not another hop list. +const FlowHistoryCard = (() => { + const W = 520; + const PAD = 14; + const CUT = 15; + const HEADER = 25; + const LINE = 16; + const FOOTER = 34; + const LABEL_W = 78; + const POLL_MS = 2000; + + let openKey = null; + let topKeeper = null; + let requestSeq = 0; + let pollTimer = null; + let lastAnchor = null; + let lastQuery = null; + let layout = null; + + function clip(text, max) { + const value = String(text || ""); + return value.length > max ? `${value.slice(0, max - 1)}…` : value; + } + + function flowKey(query) { + return [ + String((query && query.local) || ""), + String((query && query.remote) || ""), + String((query && query.proto) || "TCP").toUpperCase() + ].join("|"); + } + + function formatBytes(value) { + if (value === null || value === undefined || !Number.isFinite(Number(value))) return "—"; + const v = Number(value); + if (v < 1024) return `${Math.round(v)} B`; + if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} KB`; + if (v < 1024 * 1024 * 1024) return `${(v / (1024 * 1024)).toFixed(1)} MB`; + return `${(v / (1024 * 1024 * 1024)).toFixed(2)} GB`; + } + + function formatCount(value) { + if (value === null || value === undefined || !Number.isFinite(Number(value))) return "—"; + const v = Number(value); + if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`; + if (v >= 10000) return `${Math.round(v / 1000)}k`; + return String(Math.round(v)); + } + + function formatAgo(ms) { + if (ms === null || ms === undefined || !Number.isFinite(Number(ms))) return "—"; + const s = Math.max(0, Number(ms) / 1000); + if (s < 1) return `${Math.round(Number(ms))}ms ago`; + if (s < 60) return `${s < 10 ? s.toFixed(1) : Math.round(s)}s ago`; + if (s < 3600) { + const m = Math.floor(s / 60); + const rem = Math.round(s % 60); + return rem ? `${m}m ${rem}s ago` : `${m}m ago`; + } + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + return m ? `${h}h ${m}m ago` : `${h}h ago`; + } + + function formatBusy(ms) { + if (ms === null || ms === undefined || !Number.isFinite(Number(ms))) return "—"; + const s = Number(ms) / 1000; + if (s < 1) return `${Math.round(Number(ms))}ms on the wire`; + if (s < 60) return `${s < 10 ? s.toFixed(1) : Math.round(s)}s on the wire`; + if (s < 3600) return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s on the wire`; + return `${(s / 3600).toFixed(1)}h on the wire`; + } + + function formatRtt(ms) { + if (ms === null || ms === undefined || !Number.isFinite(Number(ms))) return "—"; + const v = Number(ms); + if (v < 1) return `${v.toFixed(3)} ms`; + if (v < 10) return `${v.toFixed(2)} ms`; + return `${v.toFixed(1)} ms`; + } + + function stopPoll() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + function close() { + stopPoll(); + openKey = null; + lastAnchor = null; + lastQuery = null; + layout = null; + requestSeq += 1; + svg.selectAll(".flow-history-scrim, .flow-history-layer").remove(); + if (topKeeper) topKeeper.stop(); + d3.select("body").on("keydown.flowhistory", null); + window.dispatchEvent(new CustomEvent("kcard-closed")); + } + + function load(query) { + const params = new URLSearchParams(); + params.set("local", query.local); + params.set("remote", query.remote); + params.set("proto", query.proto || "TCP"); + return fetch(`/api/flow-history?${params.toString()}`, { cache: "no-store" }) + .then((r) => r.json()); + } + + function startPoll(query, key) { + stopPoll(); + pollTimer = setInterval(() => { + if (openKey !== key) { + stopPoll(); + return; + } + if (document.hidden) return; + const seq = requestSeq; + load(query).then((data) => { + if (seq !== requestSeq || openKey !== key) return; + if (!data || data.found === false) { + close(); + return; + } + draw(data, lastAnchor, true); + }).catch(() => {}); + }, POLL_MS); + } + + function open(connection, anchor) { + if (!connection || !connection.local || !connection.remote) return; + const query = { + local: String(connection.local), + remote: String(connection.remote), + proto: String(connection.type || connection.proto || "TCP").toUpperCase() + }; + const key = flowKey(query); + if (openKey === key) { + close(); + return; + } + close(); + openKey = key; + lastAnchor = anchor; + lastQuery = query; + const seq = ++requestSeq; + load(query).then((data) => { + if (seq !== requestSeq) return; + if (!data || data.found === false) { + openKey = null; + return; + } + draw(data, anchor, false); + startPoll(query, key); + }).catch((err) => { + if (seq !== requestSeq) return; + openKey = null; + if (window.frontendLogger) { + window.frontendLogger.error("flow history card failed to draw", { + source: "flow-history-card", stack: String((err && err.stack) || err) + }); + } + }); + } + + function cardHeight() { + let h = HEADER + 12 + 10; + h += LINE * 3; + h += 16 + LINE; + h += LINE * 4; + h += 16 + LINE; + h += LINE * 3; + h += FOOTER; + return h; + } + + 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 < 440; + const h = cardHeight(); + + let x; + let y; + if (live && layout) { + x = layout.x; + y = 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)); + } + layout = { x, y, cw, h }; + + let layer; + let panel; + if (live) { + layer = svg.select(".flow-history-layer"); + panel = layer.select(".flow-history-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(".flow-history-body").remove(); + } else { + ensureDossierDefs(); + svg.append("rect") + .attr("class", "flow-history-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", "flow-history-layer"); + if (!topKeeper) { + topKeeper = createOverlayTopKeeper( + "flow-history-scrim", + ["flow-history-layer"], + () => openKey !== 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", "flow-history-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", "flow-history-body"); + if (!live) { + body.style("opacity", 0); + body.transition().delay(250).duration(180).style("opacity", 1); + } + + paintBody(body, data, cw, compact, h); + d3.select("body").on("keydown.flowhistory", (event) => { + if (event.key === "Escape") close(); + }); + } + + function paintBody(body, data, cw, compact, h) { + const proto = String(data.proto || "TCP").toUpperCase(); + const state = String(data.state || "—").replace(/_/g, "-"); + const valueX = PAD + LABEL_W; + const maxVal = compact ? 36 : 48; + + const text = (cls, tx, ty, value, anchorEnd) => body.append("text") + .attr("class", cls) + .attr("x", tx).attr("y", ty) + .attr("text-anchor", anchorEnd ? "end" : "start") + .text(value); + + body.append("path") + .attr("class", "kcard-strip") + .attr("d", `M0,0 H${cw - CUT} L${cw},${CUT} V${HEADER} H0 Z`); + body.append("circle") + .attr("class", "kcard-glyph-ring") + .attr("cx", PAD).attr("cy", HEADER / 2).attr("r", 4.2); + body.append("circle") + .attr("class", "kcard-glyph-dot") + .attr("cx", PAD).attr("cy", HEADER / 2).attr("r", 1.6); + text("kcard-title", PAD + 12, HEADER / 2 + 3.5, "HISTORY · FLOW"); + text("kcard-meta", cw - 13, HEADER / 2 + 3.5, + `${proto} · ${clip(state, 12)}`, true) + .style("fill", "rgba(244, 244, 236, 0.5)"); + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", HEADER).attr("x2", cw).attr("y2", HEADER); + + let cy = HEADER + 12 + 10; + + function fact(label, value, accent) { + text("kcard-section", PAD, cy, label); + text(accent ? "kcard-signature" : "kcard-line", valueX, cy, clip(value || "—", maxVal)); + cy += LINE; + } + + fact("LOCAL", data.local || "—"); + fact("PEER", data.remote || "—", true); + fact("OWNER", data.owner || "—"); + + cy += 16; + text("kcard-section", PAD, cy, "LIFE"); + cy += LINE; + + fact("BYTES", `${formatBytes(data.bytes_sent)} sent · ${formatBytes(data.bytes_received)} recv`); + fact("SEGS", `${formatCount(data.segs_out)} out · ${formatCount(data.segs_in)} in`); + + const retransBits = []; + if (data.retrans_total != null) retransBits.push(`${formatCount(data.retrans_total)} segs`); + if (data.bytes_retrans) retransBits.push(formatBytes(data.bytes_retrans)); + if (data.retrans_now) retransBits.push(`${data.retrans_now} in flight`); + fact("RETRANS", retransBits.length ? retransBits.join(" · ") : "none"); + fact("BUSY", formatBusy(data.busy_ms)); + + cy += 16; + text("kcard-section", PAD, cy, "THE WIRE SINCE"); + cy += LINE; + + fact("LAST", `sent ${formatAgo(data.last_snd_ms)} · recv ${formatAgo(data.last_rcv_ms)}`); + const rttBits = [`now ${formatRtt(data.rtt_ms)}`]; + if (data.min_rtt_ms != null) rttBits.push(`min ${formatRtt(data.min_rtt_ms)}`); + fact("RTT", rttBits.join(" · ")); + + const pathBits = []; + if (data.cc) pathBits.push(data.cc); + if (data.cwnd != null) pathBits.push(`cwnd ${data.cwnd}`); + if (data.timer && data.timer.kind) { + pathBits.push(`${data.timer.kind} ${data.timer.left || ""}`.trim()); + } + fact("PATH", pathBits.length ? pathBits.join(" · ") : "—"); + + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", h - FOOTER + 8).attr("x2", cw).attr("y2", h - FOOTER + 8); + text("kcard-foot", PAD, h - 10, "ESC OR CLICK OUTSIDE TO CLOSE"); + text("kcard-foot", cw - PAD, h - 10, (data.source || "SS").toUpperCase(), true); + } + + return { open, close, isOpen: () => openKey !== null }; +})(); + +window.FlowHistoryCard = FlowHistoryCard; diff --git a/static/js/history-card.js b/static/js/history-card.js new file mode 100644 index 0000000..d99fade --- /dev/null +++ b/static/js/history-card.js @@ -0,0 +1,492 @@ +// The card the HISTORY door of the process dossier opens. +// +// The dossier is a now-picture: RSS, threads, sockets, a live activity tape, +// the spawn chain. History is the biography of this pid — when it started, +// who forked it, how long it has been alive, and the counters it has +// accumulated since then. It is not a second sparkline and not another +// lineage spine. +// +// Start time and parent come from /proc via lineage. Lifetime CPU, context +// switches, I/O, faults and the RSS high-water mark are the same class of +// counter ACTIVITY samples; here they stay totals, not rates. +const HistoryCard = (() => { + const W = 520; + const PAD = 14; + const CUT = 15; + const HEADER = 25; + const LINE = 16; + const FOOTER = 34; + const LABEL_W = 78; + 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 || ""); + return value.length > max ? `${value.slice(0, max - 1)}…` : value; + } + + function pad(n) { + return String(n).padStart(2, "0"); + } + + function formatStamp(createTime) { + const d = new Date(Number(createTime) * 1000); + if (Number.isNaN(d.getTime())) return "—"; + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; + } + + function formatAge(seconds) { + const s = Math.max(0, Math.floor(Number(seconds))); + if (!Number.isFinite(Number(seconds)) || Number(seconds) <= 0) return "—"; + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`; + if (s < 86400) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`; + return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`; + } + + function formatSpawnDelta(seconds) { + const s = Number(seconds); + if (!Number.isFinite(s)) return ""; + if (s < 1) return "+<1s after parent"; + if (s < 90) return `+${Math.round(s)}s after parent`; + if (s < 5400) return `+${(s / 60).toFixed(1)}m after parent`; + if (s < 172800) return `+${(s / 3600).toFixed(1)}h after parent`; + return `+${(s / 86400).toFixed(1)}d after parent`; + } + + function formatCpuSec(value) { + if (value === null || value === undefined || !Number.isFinite(Number(value))) return "—"; + const s = Number(value); + if (s < 1) return `${s.toFixed(2)}s`; + if (s < 60) return `${s.toFixed(1)}s`; + if (s < 3600) return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`; + return `${(s / 3600).toFixed(1)}h`; + } + + function formatBytes(value) { + if (value === null || value === undefined || !Number.isFinite(Number(value))) return null; + const v = Number(value); + if (v < 1024) return `${Math.round(v)} B`; + if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} KB`; + if (v < 1024 * 1024 * 1024) return `${(v / (1024 * 1024)).toFixed(1)} MB`; + return `${(v / (1024 * 1024 * 1024)).toFixed(2)} GB`; + } + + function formatKb(value) { + if (value === null || value === undefined || !Number.isFinite(Number(value))) return "—"; + return formatBytes(Number(value) * 1024) || "—"; + } + + function formatCount(value) { + if (value === null || value === undefined || !Number.isFinite(Number(value))) return "—"; + const v = Number(value); + if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`; + if (v >= 10000) return `${Math.round(v / 1000)}k`; + return String(Math.round(v)); + } + + function stateLabel(value) { + const raw = String(value || "").toLowerCase().replace(/_/g, " "); + if (!raw) return "—"; + if (raw === "disk sleep") return "disk-sleep"; + if (raw === "tracing stop") return "traced"; + if (raw === "wake kill") return "wake-kill"; + return raw; + } + + function processHint(pid) { + const index = window.__processIndex; + if (!index || !index.byPid) return null; + return index.byPid.get(Number(pid)) || null; + } + + function stopPoll() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + function close() { + stopPoll(); + openPid = null; + lastAnchor = null; + layout = null; + requestSeq += 1; + svg.selectAll(".history-card-scrim, .history-card-layer").remove(); + if (topKeeper) topKeeper.stop(); + d3.select("body").on("keydown.historycard", null); + window.dispatchEvent(new CustomEvent("kcard-closed")); + } + + function load(pid) { + return Promise.all([ + fetch(`/api/process/${pid}/lineage`, { cache: "no-store" }).then((r) => r.json()).catch(() => null), + fetch(`/api/process/${pid}/activity`, { cache: "no-store" }).then((r) => r.json()).catch(() => null) + ]).then(([lineage, activity]) => ({ lineage, activity })); + } + + function startPoll(pid) { + stopPoll(); + pollTimer = setInterval(() => { + if (openPid !== pid) { + stopPoll(); + return; + } + if (document.hidden) return; + const seq = requestSeq; + load(pid).then((data) => { + if (seq !== requestSeq || openPid !== pid) return; + if (!data.lineage || data.lineage.error) { + close(); + return; + } + draw(data, lastAnchor, true); + }).catch(() => {}); + }, POLL_MS); + } + + function open(pid, anchor) { + const key = Number(pid); + if (!Number.isFinite(key)) return; + if (openPid === key) { + close(); + return; + } + close(); + openPid = key; + lastAnchor = anchor; + const seq = ++requestSeq; + load(key).then((data) => { + if (seq !== requestSeq) return; + if (!data.lineage || data.lineage.error) { + openPid = null; + return; + } + draw(data, anchor, false); + startPoll(key); + }).catch((err) => { + if (seq !== requestSeq) return; + openPid = null; + if (window.frontendLogger) { + window.frontendLogger.error("history card failed to draw", { + source: "history-card", stack: String((err && err.stack) || err) + }); + } + }); + } + + function followProcess(pid, name) { + close(); + if (typeof window.openProcessDossier === "function") { + window.openProcessDossier({ pid, name }); + } + } + + function door(body, label, x, y, width, onOpen) { + 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(); + onOpen(); + }); + } + + function cardHeight() { + let h = HEADER + 12 + 10; + h += LINE * 4; + h += 16 + LINE; + h += LINE * 3; + h += 16 + LINE; + h += LINE * 6; + h += FOOTER; + return h; + } + + 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 < 440; + const h = cardHeight(); + + let x; + let y; + if (live && layout) { + x = layout.x; + y = 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)); + } + layout = { x, y, cw, h }; + + let layer; + let panel; + if (live) { + layer = svg.select(".history-card-layer"); + panel = layer.select(".history-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(".history-card-body").remove(); + } else { + ensureDossierDefs(); + svg.append("rect") + .attr("class", "history-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", "history-card-layer"); + if (!topKeeper) { + topKeeper = createOverlayTopKeeper("history-card-scrim", ["history-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", "history-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", "history-card-body"); + if (!live) { + body.style("opacity", 0); + body.transition().delay(250).duration(180).style("opacity", 1); + } + + paintBody(body, data, cw, compact, h); + d3.select("body").on("keydown.historycard", (event) => { + if (event.key === "Escape") close(); + }); + } + + function paintBody(body, data, cw, compact, h) { + const lineage = data.lineage || {}; + const activity = data.activity || {}; + const chain = Array.isArray(lineage.chain) ? lineage.chain : []; + const self = chain.length ? chain[chain.length - 1] : null; + const parent = chain.length > 1 ? chain[chain.length - 2] : null; + const hint = processHint(lineage.pid); + const comm = (self && self.name) || (hint && hint.name) || "process"; + const status = (self && self.status) || (hint && hint.status) || ""; + const username = (self && self.username) || ""; + const createTime = self && self.create_time; + const age = self && self.age_s != null ? self.age_s : lineage.age_s; + const forkDelta = parent && self && parent.create_time && self.create_time + ? self.create_time - parent.create_time + : null; + const children = Array.isArray(lineage.children) ? lineage.children : []; + const childCount = Number(lineage.child_count || children.length || 0); + const ancestors = chain.slice(0, -1); + const chainNames = ancestors.map((row) => clip(row.name || "?", 10)); + const shownChain = chainNames.length > (compact ? 3 : 5) + ? [...chainNames.slice(0, 1), "…", ...chainNames.slice(-(compact ? 1 : 3))] + : chainNames; + + const text = (cls, tx, ty, value, anchorEnd) => body.append("text") + .attr("class", cls) + .attr("x", tx).attr("y", ty) + .attr("text-anchor", anchorEnd ? "end" : "start") + .text(value); + + body.append("path") + .attr("class", "kcard-strip") + .attr("d", `M0,0 H${cw - CUT} L${cw},${CUT} V${HEADER} H0 Z`); + body.append("circle") + .attr("class", "kcard-glyph-ring") + .attr("cx", PAD).attr("cy", HEADER / 2).attr("r", 4.2); + body.append("circle") + .attr("class", "kcard-glyph-dot") + .attr("cx", PAD).attr("cy", HEADER / 2).attr("r", 1.6); + text("kcard-title", PAD + 12, HEADER / 2 + 3.5, + `HISTORY · ${String(comm).toUpperCase()}`); + text("kcard-meta", cw - 13, HEADER / 2 + 3.5, `PID ${lineage.pid}`, true) + .style("fill", "rgba(244, 244, 236, 0.5)"); + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", HEADER).attr("x2", cw).attr("y2", HEADER); + + let cy = HEADER + 12 + 10; + const valueX = PAD + LABEL_W; + + function fact(label, value, accent) { + text("kcard-section", PAD, cy, label); + text(accent ? "kcard-signature" : "kcard-line", valueX, cy, value || "—"); + cy += LINE; + } + + fact("STARTED", createTime ? formatStamp(createTime) : "—", true); + fact("AGE", formatAge(age)); + fact("STATE", stateLabel(status)); + fact("USER", username ? clip(username, compact ? 18 : 28) : "—"); + + cy += 16; + text("kcard-section", PAD, cy, "FORK"); + cy += LINE; + + text("kcard-section", PAD, cy, "PARENT"); + if (parent) { + const parentName = clip(parent.name || "?", compact ? 12 : 16); + const parentBits = [ + `pid ${parent.pid}`, + formatSpawnDelta(forkDelta) + ].filter(Boolean).join(" · "); + const nameLabel = text("kcard-signature", valueX, cy, parentName); + const nameW = parentName.length * 6.4; + door(body, nameLabel, valueX, cy, nameW, () => followProcess(parent.pid, parent.name)); + text("kcard-faint", valueX + nameW + 10, cy, parentBits); + } else { + text("kcard-line", valueX, cy, Number(lineage.pid) === 1 ? "no parent · this is init" : "—"); + } + cy += LINE; + + text("kcard-section", PAD, cy, "CHAIN"); + text("kcard-line", valueX, cy, shownChain.length + ? `${shownChain.join(" → ")} → ${clip(comm, 12)}` + : clip(comm, 20)); + cy += LINE; + + text("kcard-section", PAD, cy, "CHILDREN"); + if (childCount) { + const names = children.slice(0, compact ? 2 : 4).map((c) => clip(c.name || "?", 10)); + const extra = childCount - names.length; + text("kcard-line", valueX, cy, + `${childCount} · ${names.join(" · ")}${extra > 0 ? ` · +${extra}` : ""}`); + } else { + text("kcard-line", valueX, cy, "none"); + } + cy += LINE; + + cy += 16; + text("kcard-section", PAD, cy, "LIFETIME"); + cy += LINE; + + const userCpu = activity.cpu_user; + const sysCpu = activity.cpu_system; + text("kcard-section", PAD, cy, "CPU"); + text("kcard-line", valueX, cy, userCpu == null && sysCpu == null + ? "cpu times denied" + : `${formatCpuSec(userCpu)} user · ${formatCpuSec(sysCpu)} system`); + cy += LINE; + + text("kcard-section", PAD, cy, "CTXSW"); + text("kcard-line", valueX, cy, + activity.ctx_voluntary == null && activity.ctx_nonvoluntary == null + ? "counter not readable" + : `${formatCount(activity.ctx_voluntary)} yielded · ${formatCount(activity.ctx_nonvoluntary)} preempted`); + cy += LINE; + + const readB = formatBytes(activity.read_bytes); + const writeB = formatBytes(activity.write_bytes); + text("kcard-section", PAD, cy, "IO"); + text("kcard-line", valueX, cy, activity.io_readable === false || (readB == null && writeB == null) + ? "io counters denied" + : `${readB || "—"} read · ${writeB || "—"} write`); + cy += LINE; + + const rss = activity.rss_kb; + const peak = activity.rss_peak_kb; + text("kcard-section", PAD, cy, "RSS"); + if (rss == null && peak == null) { + text("kcard-line", valueX, cy, "resident size not readable"); + } else if (peak != null && rss != null && peak > rss) { + text("kcard-line", valueX, cy, `${formatKb(rss)} now · peak ${formatKb(peak)}`); + } else { + text("kcard-line", valueX, cy, `${formatKb(rss != null ? rss : peak)} · at peak`); + } + cy += LINE; + + text("kcard-section", PAD, cy, "FAULTS"); + if (activity.minflt == null && activity.majflt == null) { + text("kcard-line", valueX, cy, "fault counters not readable"); + } else { + const major = Number(activity.majflt || 0); + text("kcard-line", valueX, cy, major + ? `${formatCount(activity.minflt)} minor · ${formatCount(activity.majflt)} major` + : `${formatCount(activity.minflt)} minor · none major`); + } + cy += LINE; + + const kidsMin = Number(activity.cminflt || 0); + const kidsMaj = Number(activity.cmajflt || 0); + text("kcard-section", PAD, cy, "KIDS"); + if (activity.cminflt == null && activity.cmajflt == null) { + text("kcard-line", valueX, cy, "—"); + } else if (!kidsMin && !kidsMaj) { + text("kcard-line", valueX, cy, "no waited children"); + } else { + text("kcard-line", valueX, cy, kidsMaj + ? `${formatCount(kidsMin)} minor · ${formatCount(kidsMaj)} major` + : `${formatCount(kidsMin)} minor · none major`); + } + cy += LINE; + + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", h - FOOTER + 8).attr("x2", cw).attr("y2", h - FOOTER + 8); + text("kcard-foot", PAD, h - 10, "ESC OR CLICK OUTSIDE TO CLOSE"); + text("kcard-foot", cw - PAD, h - 10, `/PROC/${lineage.pid}/STAT`, true); + } + + return { open, close, isOpen: () => openPid !== null }; +})(); + +window.HistoryCard = HistoryCard; diff --git a/static/js/ip-entry-card.js b/static/js/ip-entry-card.js new file mode 100644 index 0000000..af6b455 --- /dev/null +++ b/static/js/ip-entry-card.js @@ -0,0 +1,492 @@ +// The card a FIB row or an ARP row in the Network IP map opens. +// +// The map is the index. The card is one object: this route, or this neighbour. +// HISTORY on the header is the NUD / next-hop life the kernel still keeps. +// Morph stays. The card draws on a host SVG inside the Network container +// because the main-page svg sits under z-index 9999. +const IpEntryCard = (() => { + const W = 500; + const PAD = 14; + const CUT = 15; + const HEADER = 25; + const LINE = 16; + const FOOTER = 34; + const LABEL_W = 86; + const POLL_MS = 2000; + + let openKey = null; + let requestSeq = 0; + let pollTimer = null; + let lastAnchor = null; + let lastQuery = null; + let layout = null; + let hostSel = null; + + function clip(text, max) { + const value = String(text || ""); + return value.length > max ? `${value.slice(0, max - 1)}…` : value; + } + + function entryKey(query) { + if (!query) return ""; + if (query.kind === "neigh") { + return `neigh|${query.ip || ""}|${query.iface || ""}`; + } + return `route|${query.destination || ""}|${query.gateway || ""}|${query.iface || ""}`; + } + + function queryFromRow(kind, row) { + if (kind === "neigh") { + return { kind: "neigh", ip: row.ip, iface: row.iface || "" }; + } + return { + kind: "route", + destination: row.destination, + gateway: row.gateway || "", + iface: row.iface || "" + }; + } + + function formatAgo(seconds) { + if (seconds === null || seconds === undefined || !Number.isFinite(Number(seconds))) return "—"; + const s = Math.max(0, Number(seconds)); + if (s < 1) return "<1s ago"; + if (s < 60) return `${Math.round(s)}s ago`; + if (s < 3600) return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s ago`; + return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m ago`; + } + + function routeFlags(bits) { + const n = Number(bits) || 0; + const names = []; + if (n & 0x1) names.push("UP"); + if (n & 0x2) names.push("GATEWAY"); + if (n & 0x4) names.push("HOST"); + if (n & 0x10) names.push("DYNAMIC"); + if (n & 0x20) names.push("MODIFIED"); + return names.length ? names.join(" · ") : "—"; + } + + function hostSvg() { + const net = document.getElementById("network-stack-container"); + const parent = net && getComputedStyle(net).display !== "none" ? net : document.body; + let node = document.getElementById("ip-entry-kcard-host"); + if (!node) { + node = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + node.id = "ip-entry-kcard-host"; + node.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + } + node.style.cssText = parent === document.body + ? "position:fixed;inset:0;width:100%;height:100%;z-index:10050;pointer-events:none;" + : "position:absolute;inset:0;width:100%;height:100%;z-index:1300;pointer-events:none;"; + if (node.parentNode !== parent) parent.appendChild(node); + const sel = d3.select(node); + if (sel.select("defs").empty()) { + const defs = sel.append("defs"); + defs.append("filter") + .attr("id", "ip-entry-drop") + .attr("x", "-35%").attr("y", "-35%") + .attr("width", "190%").attr("height", "200%") + .append("feDropShadow") + .attr("dx", 0).attr("dy", 7) + .attr("stdDeviation", 10) + .attr("flood-color", "#07090c") + .attr("flood-opacity", 0.45); + } + hostSel = sel; + return sel; + } + + function stopPoll() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + function close() { + stopPoll(); + openKey = null; + lastAnchor = null; + lastQuery = null; + layout = null; + requestSeq += 1; + if (hostSel) { + hostSel.selectAll(".ip-entry-scrim, .ip-entry-layer, .ip-hist-scrim, .ip-hist-layer").remove(); + } + const host = document.getElementById("ip-entry-kcard-host"); + if (host && !host.querySelector(".ip-entry-layer, .ip-hist-layer")) { + host.remove(); + hostSel = null; + } + d3.select("body").on("keydown.ipentry", null); + d3.select("body").on("keydown.iphist", null); + window.dispatchEvent(new CustomEvent("kcard-closed")); + } + + function load(query) { + const params = new URLSearchParams(); + params.set("kind", query.kind); + if (query.kind === "neigh") { + params.set("ip", query.ip || ""); + if (query.iface) params.set("iface", query.iface); + } else { + params.set("destination", query.destination || ""); + if (query.gateway) params.set("gateway", query.gateway); + if (query.iface) params.set("iface", query.iface); + } + return fetch(`/api/ip-entry?${params.toString()}`, { cache: "no-store" }) + .then((r) => r.json()); + } + + function startPoll(query, key) { + stopPoll(); + pollTimer = setInterval(() => { + if (openKey !== key) { + stopPoll(); + return; + } + if (document.hidden) return; + const seq = requestSeq; + load(query).then((data) => { + if (seq !== requestSeq || openKey !== key) return; + if (!data || data.found === false) { + close(); + return; + } + draw(data, lastAnchor, true); + }).catch(() => {}); + }, POLL_MS); + } + + function open(kind, row, anchor) { + if (!row || (kind !== "neigh" && kind !== "route")) return; + const query = queryFromRow(kind, row); + const key = entryKey(query); + if (openKey === key) { + close(); + return; + } + close(); + openKey = key; + lastAnchor = anchor; + lastQuery = query; + const seq = ++requestSeq; + load(query).then((data) => { + if (seq !== requestSeq) return; + if (!data || data.found === false) { + openKey = null; + return; + } + draw(data, anchor, false); + startPoll(query, key); + }).catch((err) => { + if (seq !== requestSeq) return; + openKey = null; + if (window.frontendLogger) { + window.frontendLogger.error("ip entry card failed to draw", { + source: "ip-entry-card", stack: String((err && err.stack) || err) + }); + } + }); + } + + function cardHeight(data) { + let h = HEADER + 12 + 10; + h += LINE * (data.kind === "neigh" ? 4 : 5); + h += 16 + LINE; + h += LINE * 3; + h += FOOTER; + return h; + } + + function draw(data, anchor, live) { + const svgHost = hostSvg(); + const viewW = window.innerWidth; + const viewH = window.innerHeight; + const cw = (live && layout) ? layout.cw : Math.min(W, viewW - 24); + const compact = cw < 420; + const h = cardHeight(data); + + let x; + let y; + if (live && layout) { + x = layout.x; + y = layout.y; + } else { + const from = anchor && Number.isFinite(anchor.x) ? anchor.x : viewW * 0.55; + x = Number.isFinite(anchor && anchor.clearOf) ? anchor.clearOf : from + 28; + if (x + cw + 16 > viewW) x = Math.max(12, from - cw - 28); + if (x < 12) x = Math.max(12, viewW - cw - 16); + y = (anchor && Number.isFinite(anchor.y) ? anchor.y : 160) - 24; + y = Math.max(12, Math.min(viewH - h - 12, y)); + } + layout = { x, y, cw, h }; + + let layer; + let panel; + if (live) { + layer = svgHost.select(".ip-entry-layer"); + panel = layer.select(".ip-entry-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)); + panel.select(".ip-entry-body").remove(); + } else { + svgHost.selectAll(".ip-entry-scrim, .ip-entry-layer").remove(); + svgHost.append("rect") + .attr("class", "ip-entry-scrim") + .attr("x", 0).attr("y", 0).attr("width", viewW).attr("height", viewH) + .attr("fill", "rgba(6, 9, 14, 0.42)") + .style("pointer-events", "all") + .style("cursor", "pointer") + .style("opacity", 0) + .on("click", () => close()) + .transition().duration(200).style("opacity", 1); + + layer = svgHost.append("g") + .attr("class", "ip-entry-layer") + .style("pointer-events", "all"); + + 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", x).attr("y2", connY); + } + + panel = layer.append("g") + .attr("class", "ip-entry-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(#ip-entry-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", "ip-entry-body"); + if (!live) { + body.style("opacity", 0); + body.transition().delay(250).duration(180).style("opacity", 1); + } + paintBody(body, data, cw, compact, h); + d3.select("body").on("keydown.ipentry", (event) => { + if (event.key !== "Escape") return; + if (svgHost.select(".ip-hist-layer").empty() === false) return; + close(); + }); + } + + function paintBody(body, data, cw, compact, h) { + const neigh = data.kind === "neigh"; + const title = neigh ? "NEIGH" : "ROUTE"; + const meta = neigh + ? (data.nud || data.state || "—") + : (data.default ? "DEFAULT" : "FIB"); + const valueX = PAD + LABEL_W; + const maxVal = compact ? 34 : 46; + + const text = (cls, tx, ty, value, anchorEnd) => body.append("text") + .attr("class", cls) + .attr("x", tx).attr("y", ty) + .attr("text-anchor", anchorEnd ? "end" : "start") + .text(value); + + body.append("path") + .attr("class", "kcard-strip") + .attr("d", `M0,0 H${cw - CUT} L${cw},${CUT} V${HEADER} H0 Z`); + body.append("circle") + .attr("class", "kcard-glyph-ring") + .attr("cx", PAD).attr("cy", HEADER / 2).attr("r", 4.2); + body.append("circle") + .attr("class", "kcard-glyph-dot") + .attr("cx", PAD).attr("cy", HEADER / 2).attr("r", 1.6); + text("kcard-title", PAD + 12, HEADER / 2 + 3.5, title); + const histX = PAD + (neigh ? 62 : 68); + const histLabel = text("kcard-meta", histX, HEADER / 2 + 3.5, "HISTORY") + .style("fill", "rgba(244, 244, 236, 0.5)") + .attr("letter-spacing", 1.2); + const histRule = body.append("line") + .attr("x1", histX).attr("x2", histX + 52) + .attr("y1", HEADER / 2 + 6.5).attr("y2", HEADER / 2 + 6.5) + .attr("stroke", "#e2a33e") + .attr("stroke-width", 1) + .attr("opacity", 0.35); + body.append("rect") + .attr("x", histX - 6).attr("y", 4) + .attr("width", 64).attr("height", 18) + .attr("fill", "transparent") + .style("cursor", "pointer") + .on("mouseenter", () => { + histRule.attr("opacity", 1); + histLabel.style("fill", "#e2a33e"); + }) + .on("mouseleave", () => { + histRule.attr("opacity", 0.35); + histLabel.style("fill", "rgba(244, 244, 236, 0.5)"); + }) + .on("click", (event) => { + event.stopPropagation(); + drawHistory(data); + }); + text("kcard-meta", cw - 13, HEADER / 2 + 3.5, meta, true) + .style("fill", "rgba(244, 244, 236, 0.5)"); + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", HEADER).attr("x2", cw).attr("y2", HEADER); + + let cy = HEADER + 12 + 10; + function fact(label, value, accent) { + text("kcard-section", PAD, cy, label); + text(accent ? "kcard-signature" : "kcard-line", valueX, cy, clip(value || "—", maxVal)); + cy += LINE; + } + + if (neigh) { + fact("IP", data.ip || "—", true); + fact("MAC", data.mac || "—"); + fact("IFACE", data.iface || "—"); + fact("NUD", data.nud || data.state || "—"); + } else { + fact("DEST", data.destination || "—", true); + fact("VIA", data.gateway && data.gateway !== "*" ? data.gateway : "on-link"); + fact("IFACE", data.iface || "—"); + fact("METRIC", data.metric == null ? "—" : String(data.metric)); + fact("FLAGS", routeFlags(data.flags)); + } + + cy += 16; + text("kcard-section", PAD, cy, neigh ? "THIS NEIGHBOUR" : "THIS ROUTE"); + cy += LINE; + if (neigh) { + fact("STATE", data.nud || data.state || "from /proc/net/arp"); + fact("CONFIRM", data.confirmed_s != null + ? formatAgo(data.confirmed_s) + : "arp flags only · no NUD clock"); + fact("PROBES", data.probes != null ? String(data.probes) : "—"); + } else { + const hop = data.nexthop; + fact("NEXTHOP", hop + ? `${hop.ip} · ${hop.nud || hop.state || "—"}` + : (data.gateway && data.gateway !== "*" ? "no ARP for gateway" : "on-link · no nh")); + fact("HOP MAC", hop && hop.mac ? hop.mac : "—"); + fact("LOOKUP", data.default ? "fib default" : "fib prefix"); + } + + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", h - FOOTER + 8).attr("x2", cw).attr("y2", h - FOOTER + 8); + text("kcard-foot", PAD, h - 10, "ESC OR CLICK OUTSIDE TO CLOSE"); + text("kcard-foot", cw - PAD, h - 10, + neigh ? "/PROC/NET/ARP" : "/PROC/NET/ROUTE", true); + } + + function historyHeight() { + return HEADER + 12 + 10 + LINE * 6 + FOOTER; + } + + function drawHistory(data) { + const svgHost = hostSvg(); + if (!svgHost.select(".ip-hist-layer").empty()) { + svgHost.selectAll(".ip-hist-scrim, .ip-hist-layer").remove(); + d3.select("body").on("keydown.iphist", null); + return; + } + const viewW = window.innerWidth; + const viewH = window.innerHeight; + const cw = Math.min(460, viewW - 24); + const h = historyHeight(); + let x = layout ? layout.x + layout.cw + 16 : 40; + if (x + cw + 12 > viewW) x = Math.max(12, (layout ? layout.x : viewW) - cw - 16); + let y = layout ? layout.y : 80; + y = Math.max(12, Math.min(viewH - h - 12, y)); + + svgHost.append("rect") + .attr("class", "ip-hist-scrim") + .attr("x", 0).attr("y", 0).attr("width", viewW).attr("height", viewH) + .attr("fill", "transparent") + .style("pointer-events", "all") + .on("click", () => { + svgHost.selectAll(".ip-hist-scrim, .ip-hist-layer").remove(); + d3.select("body").on("keydown.iphist", null); + }); + + const layer = svgHost.append("g") + .attr("class", "ip-hist-layer") + .style("pointer-events", "all"); + const panel = layer.append("g") + .attr("transform", `translate(${x}, ${y})`) + .on("click", (event) => event.stopPropagation()); + panel.append("path") + .attr("class", "kcard-frame") + .attr("d", dossierCardPath(0, 0, cw, h, CUT)) + .attr("filter", "url(#ip-entry-drop)"); + + const body = panel.append("g"); + const text = (cls, tx, ty, value, anchorEnd) => body.append("text") + .attr("class", cls) + .attr("x", tx).attr("y", ty) + .attr("text-anchor", anchorEnd ? "end" : "start") + .text(value); + const valueX = PAD + LABEL_W; + let cy = HEADER + 12 + 10; + function fact(label, value, accent) { + text("kcard-section", PAD, cy, label); + text(accent ? "kcard-signature" : "kcard-line", valueX, cy, clip(value || "—", 42)); + cy += LINE; + } + + body.append("path") + .attr("class", "kcard-strip") + .attr("d", `M0,0 H${cw - CUT} L${cw},${CUT} V${HEADER} H0 Z`); + body.append("circle").attr("class", "kcard-glyph-ring") + .attr("cx", PAD).attr("cy", HEADER / 2).attr("r", 4.2); + body.append("circle").attr("class", "kcard-glyph-dot") + .attr("cx", PAD).attr("cy", HEADER / 2).attr("r", 1.6); + text("kcard-title", PAD + 12, HEADER / 2 + 3.5, + data.kind === "neigh" ? "HISTORY · NEIGH" : "HISTORY · ROUTE"); + body.append("line").attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", HEADER).attr("x2", cw).attr("y2", HEADER); + + if (data.kind === "neigh") { + fact("NUD", data.nud || data.state || "—", true); + fact("USED", data.used_s != null ? formatAgo(data.used_s) : "not exported"); + fact("CONFIRM", data.confirmed_s != null ? formatAgo(data.confirmed_s) : "not exported"); + fact("UPDATED", data.updated_s != null ? formatAgo(data.updated_s) : "not exported"); + fact("PROBES", data.probes != null ? String(data.probes) : "—"); + fact("REF", data.ref != null ? String(data.ref) : "—"); + } else { + const hop = data.nexthop; + fact("DEST", data.destination || "—", true); + fact("VIA", data.gateway && data.gateway !== "*" ? data.gateway : "on-link"); + fact("NH NUD", hop ? (hop.nud || hop.state || "—") : "no neighbour yet"); + fact("CONFIRM", hop && hop.confirmed_s != null ? formatAgo(hop.confirmed_s) : "—"); + fact("PROBES", hop && hop.probes != null ? String(hop.probes) : "—"); + fact("NOTE", "kernel has no birth clock for a fib row"); + } + + body.append("line").attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", h - FOOTER + 8).attr("x2", cw).attr("y2", h - FOOTER + 8); + text("kcard-foot", PAD, h - 10, "ESC CLOSES HISTORY"); + text("kcard-foot", cw - PAD, h - 10, "IP -S NEIGH", true); + + d3.select("body").on("keydown.iphist", (event) => { + if (event.key !== "Escape") return; + svgHost.selectAll(".ip-hist-scrim, .ip-hist-layer").remove(); + d3.select("body").on("keydown.iphist", null); + }); + } + + return { open, close, isOpen: () => openKey !== null }; +})(); + +window.IpEntryCard = IpEntryCard; diff --git a/static/js/irq-history-card.js b/static/js/irq-history-card.js new file mode 100644 index 0000000..a77fe9e --- /dev/null +++ b/static/js/irq-history-card.js @@ -0,0 +1,352 @@ +// The card the HISTORY door of an IRQ card opens. +// +// The IRQ card is the path into the kernel: chip, handler, the inferred +// softirq, which CPU is allowed to take it. History is how that line has +// lived since boot — how many times it rang, what share of every interrupt +// that is, the mean rate over uptime, and whether it is hotter now than +// that mean. The kernel does not keep a clock of the first fire. +// +// Not a second device chain. Not another CPU bar. +const IrqHistoryCard = (() => { + const W = 480; + const PAD = 14; + const CUT = 15; + const HEADER = 25; + const LINE = 16; + const FOOTER = 34; + const LABEL_W = 78; + const POLL_MS = 2000; + + let openIrq = null; + let topKeeper = null; + let requestSeq = 0; + let pollTimer = null; + let lastAnchor = null; + let lastNowRate = null; + let lastSample = null; + let layout = null; + + function clip(text, max) { + const value = String(text || ""); + return value.length > max ? `${value.slice(0, max - 1)}…` : value; + } + + function grouped(value) { + const number = Number(value); + if (!Number.isFinite(number)) return "—"; + return number.toLocaleString("en-US").replace(/,/g, " "); + } + + function formatAge(seconds) { + const s = Math.max(0, Math.floor(Number(seconds))); + if (!Number.isFinite(Number(seconds)) || Number(seconds) <= 0) return "—"; + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`; + if (s < 86400) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`; + return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`; + } + + function formatRate(value) { + if (value === null || value === undefined || !Number.isFinite(Number(value))) return "—"; + const v = Number(value); + if (v < 0.1) return `${v.toFixed(2)}/s`; + if (v < 10) return `${v.toFixed(1)}/s`; + return `${Math.round(v)}/s`; + } + + function formatShare(value) { + if (value === null || value === undefined || !Number.isFinite(Number(value))) return "—"; + const pct = Number(value) * 100; + if (pct < 0.1) return "<0.1%"; + if (pct < 10) return `${pct.toFixed(1)}%`; + return `${Math.round(pct)}%`; + } + + function vsMean(nowRate, meanRate) { + if (!Number.isFinite(Number(nowRate)) || !Number.isFinite(Number(meanRate))) return "—"; + const now = Number(nowRate); + const mean = Number(meanRate); + if (mean <= 0) return now > 0 ? "hotter · was silent over uptime" : "quiet"; + const delta = (now - mean) / mean; + if (Math.abs(delta) < 0.08) return "in line with the mean"; + const pct = `${delta > 0 ? "+" : ""}${Math.round(delta * 100)}%`; + return delta > 0 ? `hotter · ${pct}` : `quieter · ${pct}`; + } + + function stopPoll() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + function close() { + stopPoll(); + openIrq = null; + lastAnchor = null; + lastNowRate = null; + lastSample = null; + layout = null; + requestSeq += 1; + svg.selectAll(".irq-history-scrim, .irq-history-layer").remove(); + if (topKeeper) topKeeper.stop(); + d3.select("body").on("keydown.irqhistory", null); + window.dispatchEvent(new CustomEvent("kcard-closed")); + } + + function load(irq) { + return fetch(`/api/irq/${encodeURIComponent(irq)}/history`, { cache: "no-store" }) + .then((r) => r.json()); + } + + function noteSample(data) { + const total = Number(data && data.total); + const ts = Date.now() / 1000; + if (lastSample && lastSample.irq === data.irq && Number.isFinite(total)) { + const dt = Math.max(0.2, ts - lastSample.ts); + const delta = total - lastSample.total; + if (delta >= 0) lastNowRate = delta / dt; + } + if (Number.isFinite(total)) lastSample = { irq: data.irq, total, ts }; + } + + function startPoll(irq) { + stopPoll(); + pollTimer = setInterval(() => { + if (openIrq !== irq) { + stopPoll(); + return; + } + if (document.hidden) return; + const seq = requestSeq; + load(irq).then((data) => { + if (seq !== requestSeq || openIrq !== irq) return; + if (!data || data.found === false) { + close(); + return; + } + noteSample(data); + draw(data, lastAnchor, true); + }).catch(() => {}); + }, POLL_MS); + } + + function open(irq, anchor, nowRate) { + const key = String(irq || ""); + if (!key) return; + if (openIrq === key) { + close(); + return; + } + close(); + openIrq = key; + lastAnchor = anchor; + lastNowRate = Number.isFinite(Number(nowRate)) ? Number(nowRate) : null; + lastSample = null; + const seq = ++requestSeq; + load(key).then((data) => { + if (seq !== requestSeq) return; + if (!data || data.found === false) { + openIrq = null; + return; + } + noteSample(data); + draw(data, anchor, false); + startPoll(key); + }).catch((err) => { + if (seq !== requestSeq) return; + openIrq = null; + if (window.frontendLogger) { + window.frontendLogger.error("irq history card failed to draw", { + source: "irq-history-card", stack: String((err && err.stack) || err) + }); + } + }); + } + + function cardHeight() { + let h = HEADER + 12 + 10; + h += LINE * 3; + h += 16 + LINE; + h += LINE * 4; + h += 16 + LINE; + h += LINE * 3; + h += FOOTER; + return h; + } + + 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 < 420; + const h = cardHeight(); + + let x; + let y; + if (live && layout) { + x = layout.x; + y = 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)); + } + layout = { x, y, cw, h }; + + let layer; + let panel; + if (live) { + layer = svg.select(".irq-history-layer"); + panel = layer.select(".irq-history-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(".irq-history-body").remove(); + } else { + ensureDossierDefs(); + svg.append("rect") + .attr("class", "irq-history-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", "irq-history-layer"); + if (!topKeeper) { + topKeeper = createOverlayTopKeeper( + "irq-history-scrim", + ["irq-history-layer"], + () => openIrq !== 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", "irq-history-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", "irq-history-body"); + if (!live) { + body.style("opacity", 0); + body.transition().delay(250).duration(180).style("opacity", 1); + } + + paintBody(body, data, cw, compact, h); + d3.select("body").on("keydown.irqhistory", (event) => { + if (event.key === "Escape") close(); + }); + } + + function paintBody(body, data, cw, compact, h) { + const aggregate = data.kind === "aggregate"; + const title = aggregate + ? `HISTORY · ${String(data.irq || "").toUpperCase()}` + : `HISTORY · IRQ ${data.irq}`; + const valueX = PAD + LABEL_W; + const maxVal = compact ? 34 : 46; + + const text = (cls, tx, ty, value, anchorEnd) => body.append("text") + .attr("class", cls) + .attr("x", tx).attr("y", ty) + .attr("text-anchor", anchorEnd ? "end" : "start") + .text(value); + + body.append("path") + .attr("class", "kcard-strip") + .attr("d", `M0,0 H${cw - CUT} L${cw},${CUT} V${HEADER} H0 Z`); + body.append("circle") + .attr("class", "kcard-glyph-ring") + .attr("cx", PAD).attr("cy", HEADER / 2).attr("r", 4.2); + body.append("circle") + .attr("class", "kcard-glyph-dot") + .attr("cx", PAD).attr("cy", HEADER / 2).attr("r", 1.6); + text("kcard-title", PAD + 12, HEADER / 2 + 3.5, clip(title, 36)); + text("kcard-meta", cw - 13, HEADER / 2 + 3.5, + aggregate ? "COUNTER" : "LINE", true) + .style("fill", "rgba(244, 244, 236, 0.5)"); + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", HEADER).attr("x2", cw).attr("y2", HEADER); + + let cy = HEADER + 12 + 10; + + function fact(label, value, accent) { + text("kcard-section", PAD, cy, label); + text(accent ? "kcard-signature" : "kcard-line", valueX, cy, clip(value || "—", maxVal)); + cy += LINE; + } + + fact("DEVICE", clip(data.device || data.label || "—", maxVal)); + fact("CHIP", aggregate ? "kernel counter" : (data.chip || "—")); + fact("UPTIME", `boot · ${formatAge(data.uptime_s)} ago`); + + cy += 16; + text("kcard-section", PAD, cy, "LIFE"); + cy += LINE; + + fact("COUNT", `${grouped(data.total)} since boot`, true); + fact("MEAN", `${formatRate(data.lifetime_per_sec)} over uptime`); + fact("SHARE", `${formatShare(data.share)} of all interrupts`); + fact("CPU", data.top_cpu == null + ? "—" + : `CPU${data.top_cpu} took ${formatShare(data.top_cpu_share)}`); + + cy += 16; + text("kcard-section", PAD, cy, "NOW"); + cy += LINE; + + fact("RATE", formatRate(lastNowRate)); + fact("VS MEAN", vsMean(lastNowRate, data.lifetime_per_sec)); + + const soft = data.softirq; + if (soft && soft.vector) { + fact("SOFTIRQ", `${soft.vector} · ${grouped(soft.total)} since boot`); + } else { + fact("SOFTIRQ", aggregate ? "none · this is the counter" : "not attributed"); + } + + body.append("line") + .attr("class", "kcard-divider") + .attr("x1", 0).attr("y1", h - FOOTER + 8).attr("x2", cw).attr("y2", h - FOOTER + 8); + text("kcard-foot", PAD, h - 10, "ESC OR CLICK OUTSIDE TO CLOSE"); + text("kcard-foot", cw - PAD, h - 10, "/PROC/INTERRUPTS", true); + } + + return { open, close, isOpen: () => openIrq !== null }; +})(); + +window.IrqHistoryCard = IrqHistoryCard;