diff --git a/index.html b/index.html index 8b3e73b..b8ba102 100755 --- a/index.html +++ b/index.html @@ -37,7 +37,7 @@ - + @@ -83,16 +83,6 @@

Linux Kernel Ring 0 Visualization

- @@ -109,7 +99,7 @@

Linux Kernel Ring 0 Visualization

- + @@ -122,9 +112,7 @@

Linux Kernel Ring 0 Visualization

- - - + @@ -140,7 +128,7 @@

Linux Kernel Ring 0 Visualization

console.error('❌ RightSemicircleMenuManager class NOT loaded!'); } - - + + diff --git a/kernel_ai/api/rest.py b/kernel_ai/api/rest.py index 4512dbf..b62ac01 100644 --- a/kernel_ai/api/rest.py +++ b/kernel_ai/api/rest.py @@ -18,6 +18,7 @@ ("/active-connections", "active_connections", h.active_connections, None), ("/traceroute", "traceroute_info", h.traceroute_info, None), ("/flow-history", "flow_history", h.flow_history, None), + ("/socket-activity", "socket_activity", h.socket_activity, None), ("/ip-entry", "ip_entry", h.ip_entry, None), ("/network-stack-realtime", "network_stack_realtime", h.network_stack_realtime, None), ("/devices-realtime", "devices_realtime", h.devices_realtime, None), diff --git a/kernel_ai/http/api.py b/kernel_ai/http/api.py index b4d2c78..f25ae36 100644 --- a/kernel_ai/http/api.py +++ b/kernel_ai/http/api.py @@ -15,6 +15,7 @@ runqueue, sentry_test, siem_alerts, + socket_activity, syscall_detail, syscalls_realtime, wakeups, @@ -107,6 +108,7 @@ "security_realtime", "sentry_test", "siem_alerts", + "socket_activity", "syscall_detail", "syscalls_realtime", "traceroute_info", diff --git a/kernel_ai/http/api_handlers/kernel.py b/kernel_ai/http/api_handlers/kernel.py index 19ef837..ee1f46c 100644 --- a/kernel_ai/http/api_handlers/kernel.py +++ b/kernel_ai/http/api_handlers/kernel.py @@ -37,6 +37,18 @@ def _payload(): return api_json(_payload) +def socket_activity(): + def _payload(): + local = request.args.get("local", "").strip() + remote = request.args.get("remote", "").strip() + proto = request.args.get("proto", "TCP").strip() + if not local or not remote: + raise ValueError("Missing 'local' or 'remote' query parameter") + return _telemetry.get_socket_activity(local=local, remote=remote, proto=proto) + + return api_json(_payload, exception_statuses=[(ValueError, 400)]) + + def syscall_detail(name): """What one syscall is inside this kernel, plus who is parked in it. diff --git a/kernel_ai/http/pages.py b/kernel_ai/http/pages.py index 43961c8..f5398de 100644 --- a/kernel_ai/http/pages.py +++ b/kernel_ai/http/pages.py @@ -108,6 +108,16 @@ def linux_devices_subsystem_html(): return redirect("/linux-devices-subsystem", code=301) +def io_elevator_prototype_page(): + """Local design prototype: vintage floor-selector as block I/O elevator.""" + return render_template("io-elevator-prototype.html") + + +def syscall_selector_prototype_page(): + """Local design prototype: selector arms as sys_call_table dispatch.""" + return render_template("syscall-selector-prototype.html") + + def health_check(): return jsonify( { diff --git a/kernel_ai/services/network.py b/kernel_ai/services/network.py index d6014ad..6bac6dc 100644 --- a/kernel_ai/services/network.py +++ b/kernel_ai/services/network.py @@ -389,7 +389,12 @@ def _float_field(text, name): def _parse_ss_flow_row(header, details, proto): - """Biography fields from one ss -inoe row. No sock cookie, no cgroup path.""" + """Biography fields from one ss -inoe row. No sock cookie, no cgroup path. + + Also the shape of a full segment on this path — mss, pmtu, the negotiated + window scale. That is what lets a reader draw the frame to true scale + instead of a nominal one. + """ parts = header.split() if proto == "TCP": if len(parts) < 5: @@ -435,6 +440,16 @@ def _parse_ss_flow_row(header, details, proto): if name in _CC_NAMES: cc = name break + # ss prints the negotiated options as bare words before the cc name. They + # decide how many bytes of every segment are option header rather than data. + opt_ts = bool(re.search(r"(?/syscall, + which needs ptrace-level access and is therefore closed for most processes + on a box with ptrace_scope set. The *counts* of read and write calls in + /proc//io are not, so a process that will not tell us what it is + parked in will still tell us how many calls it has made. The two are + reported separately: a missing name is not a quiet process. + """ + try: + pid = int(pid) + except (TypeError, ValueError): + return {"readable": False, "reason": "bad pid"} + + io = _read_proc_io(pid) + ctxt = _read_proc_ctxt(pid) + base = {"readable": bool(io or ctxt), "io": io, "ctxt": ctxt} + if not base["readable"]: + return {"readable": False, "reason": "process is gone or closed to us"} + + task_dir = f"/proc/{pid}/task" + try: + tids = sorted(os.listdir(task_dir), key=int) + except (OSError, ValueError): + return dict(base, calls_readable=False, reason="task list not readable", calls=[]) + + calls = [] + parked = 0 + denied = 0 + for tid in tids: + try: + with open(f"{task_dir}/{tid}/syscall", "r", encoding="utf-8", errors="replace") as fh: + line = fh.read().strip() + except PermissionError: + denied += 1 + continue + except (OSError, ValueError): + continue + if not line or line in ("-1", "running"): + continue + head = line.split() + try: + nr = int(head[0]) + except (IndexError, ValueError): + continue + parked += 1 + if len(calls) >= max_calls: + continue + calls.append({ + "tid": int(tid), + "comm": _read_comm(tid), + "nr": nr, + "name": syscall_names.get(nr, f"syscall_{nr}"), + }) + if denied and not calls: + return dict( + base, + calls_readable=False, + reason="ptrace scope hides what it is parked in", + calls=[], + threads=len(tids), + ) + return dict( + base, + calls_readable=True, + reason=None, + calls=calls, + parked=parked, + threads=len(tids), + ) + + def get_softirq_nucleotides(map_interrupt_to_subsystem_fn, limit=8): """Per-vector softirq totals from /proc/softirqs.""" out = [] diff --git a/kernel_ai/services/telemetry_orchestration.py b/kernel_ai/services/telemetry_orchestration.py index b9a28f8..51e41ed 100644 --- a/kernel_ai/services/telemetry_orchestration.py +++ b/kernel_ai/services/telemetry_orchestration.py @@ -7,6 +7,7 @@ from kernel_ai.services import core_observability as _core_observability_service from kernel_ai.services import execution as _execution_service from kernel_ai.services import kernel_maps as _kernel_maps_service +from kernel_ai.services import network as _network_service from kernel_ai.services import syscalls as _syscalls_service try: @@ -54,6 +55,58 @@ def get_syscall_sample(): ) +def get_socket_activity(local, remote, proto="TCP"): + """One socket, its owner, and what that owner is calling right now. + + The activity tape is machine-wide, so watching it beside a hovered socket + invites a connection that is not there. This is that connection made real: + the owning task is resolved through the socket's own fd, and the calls + reported are its threads' — not the box's. When the owner is closed to us + the answer says so instead of falling back to the machine feed. + """ + owner = _network_service.get_socket_owner(local=local, remote=remote, proto=proto) + flow = _network_service.get_flow_history(local=local, remote=remote, proto=proto) + payload = { + "local": local, + "remote": remote, + "proto": str(proto or "TCP").upper(), + "found": bool(flow.get("found")), + "owner": owner or None, + "readable": False, + "calls_readable": False, + "reason": None, + "calls": [], + "socket": { + key: flow.get(key) + for key in ( + "state", "bytes_sent", "bytes_received", "segs_out", "segs_in", + "retrans_total", "rtt_ms", "cwnd", "recv_q", "send_q", + "last_snd_ms", "last_rcv_ms", + ) + }, + } + if not owner: + payload["reason"] = "socket not found" if not payload["found"] else "owner unknown" + return payload + if not owner.get("visible"): + payload["reason"] = owner.get("reason") or "owner not visible from here" + return payload + + tasks = _syscalls_service.read_process_calls(owner.get("pid"), get_syscall_names()) + payload["readable"] = bool(tasks.get("readable")) + payload["calls_readable"] = bool(tasks.get("calls_readable")) + payload["reason"] = tasks.get("reason") + payload["calls"] = [ + dict(call, subsystem=map_syscall_to_subsystem(call.get("name", ""))) + for call in tasks.get("calls", []) + ] + payload["parked"] = tasks.get("parked") + payload["threads"] = tasks.get("threads") + payload["io"] = tasks.get("io") or {} + payload["ctxt"] = tasks.get("ctxt") or {} + return payload + + def get_kernel_subsystem_status(): return _core_observability_service.get_kernel_subsystem_status() diff --git a/kernel_ai/views/pages.py b/kernel_ai/views/pages.py index aa53fe3..cb76dbe 100644 --- a/kernel_ai/views/pages.py +++ b/kernel_ai/views/pages.py @@ -29,6 +29,8 @@ ("/linux-devices-subsystem", "linux_devices_subsystem_page", h.linux_devices_subsystem_page), ("/devices", "devices_page_legacy", h.devices_page_legacy), ("/linux-devices-subsystem.html", "linux_devices_subsystem_html", h.linux_devices_subsystem_html), + ("/io-elevator", "io_elevator_prototype_page", h.io_elevator_prototype_page), + ("/syscall-selector", "syscall_selector_prototype_page", h.syscall_selector_prototype_page), ("/health", "health_check", h.health_check), ] diff --git a/static/css/main.css b/static/css/main.css index 01d493d..d088cd5 100755 --- a/static/css/main.css +++ b/static/css/main.css @@ -514,6 +514,105 @@ svg { font-size: 7.5px; letter-spacing: 0.7px; } +/* One frame of this flow, drawn to scale. The header bands are deliberately + thin — that sliver against the hatched cargo is the whole argument. Below, + the same bytes opened into the field grid of RFC 791 and 793: a cell drawn + solid was measured, a dashed cell is reasoned from the protocol, and a cell + left empty is a field we know is there and cannot see. */ +.frame-dim { + stroke: rgba(244, 244, 236, 0.28); + stroke-width: 0.8; +} +.frame-dim-label { + fill: rgba(244, 244, 236, 0.5); + font-family: 'Share Tech Mono', monospace; + font-size: 8px; + letter-spacing: 1.3px; +} +.frame-band { + fill: #e2a33e; + stroke: none; +} +.frame-band.is-1 { fill: rgba(226, 163, 62, 0.78); } +.frame-band.is-2 { fill: rgba(226, 163, 62, 0.56); } +.frame-cargo { stroke: none; } +.frame-cargo-edge { + fill: none; + stroke: rgba(244, 244, 236, 0.22); + stroke-width: 0.9; +} +.frame-cargo-label { + fill: rgba(244, 244, 236, 0.72); + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + letter-spacing: 1.6px; +} +.frame-dim-label.is-header { fill: rgba(226, 163, 62, 0.85); } +.frame-wedge { + stroke: rgba(226, 163, 62, 0.7); + stroke-width: 0.9; + stroke-dasharray: 3 2; +} +.frame-wedge-fill { fill: rgba(226, 163, 62, 0.09); } +.frame-zoom { + fill: rgba(226, 163, 62, 0.7); + font-family: 'Share Tech Mono', monospace; + font-size: 7.5px; + letter-spacing: 1.4px; +} +.frame-block-title { + fill: rgba(244, 244, 236, 0.5); + font-family: 'Share Tech Mono', monospace; + font-size: 8.5px; + letter-spacing: 1.6px; +} +.frame-ruler { + stroke: rgba(244, 244, 236, 0.2); + stroke-width: 0.7; +} +.frame-bit { + fill: rgba(244, 244, 236, 0.28); + font-family: 'Share Tech Mono', monospace; + font-size: 6.5px; + letter-spacing: 0.3px; +} +.frame-cell { + fill: none; + stroke: rgba(244, 244, 236, 0.16); + stroke-width: 0.8; +} +.frame-cell.is-live { + fill: rgba(226, 163, 62, 0.09); + stroke: rgba(226, 163, 62, 0.72); + stroke-width: 1; +} +.frame-cell.is-derived { + fill: none; + stroke: rgba(226, 163, 62, 0.5); + stroke-width: 0.9; + stroke-dasharray: 3 2; +} +.frame-cell-name { + fill: rgba(244, 244, 236, 0.34); + font-family: 'Share Tech Mono', monospace; + font-size: 6.5px; + letter-spacing: 0.5px; +} +.frame-cell-value { + fill: #e2a33e; + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + letter-spacing: 0.2px; +} +.frame-cell-value.is-derived { fill: rgba(226, 163, 62, 0.72); } +.frame-verdict { + fill: rgba(244, 244, 236, 0.62); + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + letter-spacing: 1.1px; +} + +/* Official sock graph on the FLOW card: amber metro of socket → sock → nic. */ .flow-metro-rail { fill: none; stroke: rgba(226, 163, 62, 0.16); @@ -546,6 +645,17 @@ svg { font-size: 7px; letter-spacing: 0.45px; } +/* The verdict word of a flow, stamped like the classification of a dossier. */ +.flow-stamp { + fill: #f4f4ec; + font-family: 'Share Tech Mono', monospace; + font-size: 19px; + letter-spacing: 2.2px; +} +.flow-stamp-rule { + stroke: rgba(226, 163, 62, 0.55); + stroke-width: 1; +} /* The state letter of a thread. Amber is a thread the kernel has on a CPU or ready for one; the warm tint is an uninterruptible wait, which is the state worth noticing; everything else is asleep and stays quiet. */ diff --git a/static/js/flow-card.js b/static/js/flow-card.js index fa13d05..27cdac8 100644 --- a/static/js/flow-card.js +++ b/static/js/flow-card.js @@ -1,13 +1,16 @@ // The card a row in the main-page connection list opens. // -// The list is the index: who is talking to whom. The card is one 4-tuple -// and the official sock graph — socket → sock → rcv/snd queues → nic — -// as an amber metro on the right. Traceroute hops stay lines. PATH is -// the door into the network stack for this flow. +// Same cascading stack as PROCESS DOSSIER: identity → the wire → the segment. +// The upper card keeps the amber sock metro on the right (socket → nic), with +// the 4-tuple on the left. HISTORY and PATH stay doors on that card. // -// The map chrome stays. This card uses the kcard frame, same as SOCKETS. +// A cell is only filled in when ss measured it. What we cannot see stays an +// empty named box; what we reason out rather than read is drawn dashed. const FlowCard = (() => { - const W = 680; + const ID_W = 620; + const RAIL_W = 280; + const WIRE_W = 280; + const SEG_W = 640; const PAD = 14; const CUT = 15; const HEADER = 25; @@ -15,8 +18,13 @@ const FlowCard = (() => { const ROW_STEP = 16; const FOOTER = 34; const MAX_HOPS = 6; + const FIG_W = 604; + const FIG_H = 262; const METRO_W = 300; const METRO_H = 232; + const STAMP_H = 50; + const OVERLAP = 14; + const INSET = 26; let openKey = null; let topKeeper = null; @@ -24,6 +32,7 @@ const FlowCard = (() => { let lastAnchor = null; let lastFlow = null; let lastTrace = null; + let lastInfo = null; let layout = null; function clip(text, max) { @@ -63,6 +72,7 @@ const FlowCard = (() => { lastAnchor = null; lastFlow = null; lastTrace = null; + lastInfo = null; layout = null; requestSeq += 1; svg.selectAll(".flow-card-scrim, .flow-card-layer").remove(); @@ -149,8 +159,9 @@ const FlowCard = (() => { lastAnchor = anchor; lastFlow = connection; lastTrace = null; + lastInfo = null; const seq = ++requestSeq; - draw(connection, null, anchor, false); + draw(connection, anchor, false); const remoteIp = parseEndpoint(connection.remote).host; const fetchTrace = window.connectionsManager && typeof window.connectionsManager.fetchTraceroute === "function" @@ -161,62 +172,79 @@ const FlowCard = (() => { Promise.resolve(fetchTrace).then((trace) => { if (seq !== requestSeq || openKey !== key) return; lastTrace = trace; - draw(connection, trace, lastAnchor, true); + draw(connection, lastAnchor, true); }); + + const params = new URLSearchParams(); + params.set("local", String(connection.local || "")); + params.set("remote", String(connection.remote || "")); + params.set("proto", String(connection.type || "TCP").toUpperCase()); + fetch(`/api/flow-history?${params.toString()}`, { cache: "no-store" }) + .then((r) => r.json()) + .catch(() => null) + .then((info) => { + if (seq !== requestSeq || openKey !== key) return; + lastInfo = (info && info.found !== false) ? info : null; + draw(connection, lastAnchor, true); + }); } - function cardHeight(trace, showFigure) { - let h = HEADER + 12 + 10; - h += LINE; - h += 16 + LINE + LINE; - h += 16 + LINE + LINE; - h += 16 + LINE; - const hops = Array.isArray(trace && trace.hops) ? trace.hops.slice(0, MAX_HOPS) : []; - if (trace && trace.note && !hops.length) h += LINE; - else if (!trace) h += LINE; - else h += Math.max(1, hops.length) * ROW_STEP; - h += FOOTER; - if (showFigure) h = Math.max(h, HEADER + 18 + METRO_H + FOOTER); - return h; + + function hopCount() { + const hops = Array.isArray(lastTrace && lastTrace.hops) ? lastTrace.hops.slice(0, MAX_HOPS) : []; + if (lastTrace && lastTrace.note && !hops.length) return 1; + if (!lastTrace) return 1; + return Math.max(1, hops.length); } - function draw(connection, trace, anchor, live) { + function stackMetrics(viewW, viewH, anchor) { + const reserved = window.KernelTape + && typeof window.KernelTape.reservedWidth === "function" + ? window.KernelTape.reservedWidth() + : 0; + const idW = ID_W; + const wireW = WIRE_W; + const segAvail = viewW - reserved - 40; + const segW = Math.max(320, Math.min(SEG_W, segAvail - INSET * 2)); + const showFigure = segW >= 520; + // Upper card is rail + sock metro side by side, height set by the metro. + const idH = HEADER + 10 + METRO_H + 18; + const wireH = HEADER + 16 + hopCount() * ROW_STEP + 20; + const segH = showFigure ? HEADER + 12 + FIG_H + 18 : HEADER + 36; + + const totalH = idH + (wireH - OVERLAP) + (segH - OVERLAP); + // Beside the connection list, like the process dossier sits beside + // its node — not berthed against the Activity pill on the right edge. + const clearOf = Number.isFinite(anchor && anchor.clearOf) ? anchor.clearOf : 596; + let stackX = clearOf + 12; + const widest = Math.max(idW, wireW + INSET, segW + INSET * 2); + if (stackX + widest + reserved + 16 > viewW) { + stackX = Math.max(12, viewW - reserved - widest - 16); + } + let stackY = (anchor && Number.isFinite(anchor.y) ? anchor.y : 120) - 24; + stackY = Math.max(12, Math.min(viewH - totalH - 12, stackY)); + return { idW, wireW, segW, showFigure, idH, wireH, segH, totalH, stackX, stackY, reserved }; + } + + function draw(connection, 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 showFigure = cw >= 520; - const h = cardHeight(trace, showFigure); - - 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) - 20; - y = Math.max(12, Math.min(viewH - h - 12, y)); - } - layout = { x, y, cw, h }; + const m = stackMetrics(viewW, viewH, anchor); + layout = { + x: m.stackX, y: m.stackY, cw: m.segW + INSET * 2, h: m.totalH, + ...m + }; let layer; - let panel; if (live) { layer = svg.select(".flow-card-layer"); - panel = layer.select(".flow-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 (layer.empty()) return; + layer.select(".flow-card-stack").remove(); 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); + const connY = Math.max(m.stackY + 12, Math.min(m.stackY + m.idH - 12, anchor.y)); + layer.select(".kcard-conn").attr("x2", m.stackX).attr("y2", connY); } - panel.select(".flow-card-body").remove(); } else { ensureDossierDefs(); svg.append("rect") @@ -235,7 +263,7 @@ const FlowCard = (() => { 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)); + const connY = Math.max(m.stackY + 12, Math.min(m.stackY + m.idH - 12, anchor.y)); layer.append("circle") .attr("class", "kcard-anchor") .attr("cx", anchor.x).attr("cy", anchor.y).attr("r", 3); @@ -244,30 +272,18 @@ const FlowCard = (() => { .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); + .attr("x2", m.stackX).attr("y2", connY); } - - panel = layer.append("g") - .attr("class", "flow-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", "flow-card-body"); - if (!live) { - body.style("opacity", 0); - body.transition().delay(250).duration(180).style("opacity", 1); - } + const stack = layer.append("g") + .attr("class", "flow-card-stack") + .on("click", (event) => event.stopPropagation()); + + paintIdentity(stack, connection, m); + paintWire(stack, m); + paintSegment(stack, connection, m); - paintBody(body, connection, trace, cw, compact, showFigure, h); d3.select("body").on("keydown.flowcard", (event) => { if (event.key !== "Escape") return; if (window.FlowHistoryCard && FlowHistoryCard.isOpen()) return; @@ -357,127 +373,440 @@ const FlowCard = (() => { }); } - function paintBody(body, connection, trace, cw, compact, showFigure, h) { + function paintIdentity(stack, connection, m) { const proto = String(connection.type || "TCP").toUpperCase(); const state = stateName(connection.state, proto); const local = parseEndpoint(connection.local); const remote = parseEndpoint(connection.remote); - const hops = Array.isArray(trace && trace.hops) ? trace.hops.slice(0, MAX_HOPS) : []; - const colX = PAD; - const metroX = showFigure ? cw - PAD - METRO_W : 0; - - const text = (cls, tx, ty, value, anchorEnd) => body.append("text") - .attr("class", cls) - .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, "FLOW"); + const info = lastInfo; + const box = { x: m.stackX, y: m.stackY, w: m.idW, h: m.idH }; + const meta = info && info.owner + ? `${clip(String(info.owner).toUpperCase(), 14)} · ${proto}` + : `${proto} · ${clip(state, 12)}`; + const card = dossierCard(stack, box, "FLOW", meta); + card.style("pointer-events", "all"); + if (window.FlowHistoryCard) { - const histX = PAD + 58; + const histX = box.x + 58; const histW = 52; - const histLabel = text("kcard-signature", histX, HEADER / 2 + 3.5, "HISTORY") - .attr("letter-spacing", 1.2); - const histRule = body.append("line") + const histLabel = card.append("text") + .attr("x", histX).attr("y", box.y + 16) + .attr("font-family", "Share Tech Mono, monospace") + .attr("font-size", "9px") + .attr("letter-spacing", "1.2") + .attr("fill", "#e2a33e") + .text("HISTORY"); + const histRule = card.append("line") .attr("x1", histX).attr("x2", histX + histW) - .attr("y1", HEADER / 2 + 6.5).attr("y2", HEADER / 2 + 6.5) - .attr("stroke", "#e2a33e") - .attr("stroke-width", 1) - .attr("opacity", 0.35); - body.append("rect") - .attr("x", histX - 6).attr("y", 4) + .attr("y1", box.y + 19).attr("y2", box.y + 19) + .attr("stroke", "#e2a33e").attr("stroke-width", 1).attr("opacity", 0.35); + card.append("rect") + .attr("x", histX - 6).attr("y", box.y + 4) .attr("width", histW + 12).attr("height", 18) .attr("fill", "transparent") .style("cursor", "pointer") - .on("mouseenter", () => { - histRule.attr("opacity", 1); - }) - .on("mouseleave", () => { - histRule.attr("opacity", 0.35); - }) + .on("mouseenter", () => histRule.attr("opacity", 1)) + .on("mouseleave", () => histRule.attr("opacity", 0.35)) .on("click", (event) => { event.stopPropagation(); FlowHistoryCard.open(connection, { - x: (layout && layout.x || 0) + histX + 20, - y: (layout && layout.y || 0) + HEADER / 2, - clearOf: layout ? layout.x + layout.cw + 16 : undefined + x: histX + 20, + y: box.y + 16, + clearOf: box.x + box.w + 16 }); }); } - text("kcard-meta", cw - 13, HEADER / 2 + 3.5, - `${proto} · ${clip(state, 10)}`, 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); - - if (showFigure) { - body.append("line") - .attr("class", "kcard-divider") - .attr("x1", metroX - 10).attr("y1", HEADER + 12) - .attr("x2", metroX - 10).attr("y2", h - FOOTER + 4); - const fig = body.append("g").attr("class", "flow-sock-metro"); - drawSocketMetro(fig, metroX, HEADER + 10, METRO_W); - } - - let cy = HEADER + 12 + 10; - text("kcard-line", colX, cy, compact - ? "this 4-tuple · official sock" - : "this 4-tuple · official sock body"); - cy += LINE; - cy += 16; - text("kcard-section", colX, cy, "LOCAL"); - cy += LINE; - text("kcard-waiter", colX, cy, clip(`${local.host}:${local.port}`, compact ? 36 : 42)); - cy += LINE; - - cy += 16; - text("kcard-section", colX, cy, "PEER"); + // Left rail: the 4-tuple. Right pane: the sock metro from the screenshot. + const metroX = box.x + box.w - PAD - METRO_W; + card.append("line") + .attr("class", "kcard-divider") + .attr("x1", metroX - 12).attr("y1", box.y + HEADER + 8) + .attr("x2", metroX - 12).attr("y2", box.y + box.h - 16); + const metro = card.append("g").attr("class", "flow-sock-metro"); + drawSocketMetro(metro, metroX, box.y + HEADER + 4, METRO_W); + + let cy = box.y + HEADER + 18; + card.append("text").attr("class", "kcard-line") + .attr("x", box.x + 16).attr("y", cy) + .text("this 4-tuple, official sock body"); + cy += 18; + card.append("text").attr("class", "kcard-section") + .attr("x", box.x + 16).attr("y", cy).text("LOCAL"); cy += LINE; - text("kcard-waiter", colX, cy, clip(`${remote.host}:${remote.port}`, compact ? 36 : 42)); + card.append("text").attr("class", "kcard-waiter") + .attr("x", box.x + 16).attr("y", cy) + .text(clip(`${local.host}:${local.port}`, 32)); + cy += 18; + card.append("text").attr("class", "kcard-section") + .attr("x", box.x + 16).attr("y", cy).text("PEER"); cy += LINE; + card.append("text").attr("class", "kcard-waiter") + .attr("x", box.x + 16).attr("y", cy) + .text(clip(`${remote.host}:${remote.port}`, 32)); + + cy += 22; + card.append("text").attr("class", "kcard-section") + .attr("x", box.x + 16).attr("y", cy).text("STATE"); + cy += 20; + card.append("text").attr("class", "flow-stamp") + .attr("x", box.x + 16).attr("y", cy) + .text(clip(state, 14)); + cy += 12; + const shape = [proto]; + if (info && info.cc) shape.push(String(info.cc).toUpperCase()); + if (info && info.cwnd) shape.push(`CWND ${info.cwnd}`); + if (info && info.rtt_ms != null) shape.push(`RTT ${Number(info.rtt_ms).toFixed(1)} MS`); + card.append("text").attr("class", "kcard-note") + .attr("x", box.x + 16).attr("y", cy) + .text(clip(shape.join(" · "), 36)); + cy += 8; + card.append("line").attr("class", "flow-stamp-rule") + .attr("x1", box.x + 16).attr("y1", cy) + .attr("x2", box.x + Math.min(176, RAIL_W - 32)).attr("y2", cy); + + const pathLabel = card.append("text").attr("class", "kcard-signature") + .attr("x", box.x + box.w - 14).attr("y", box.y + box.h - 12) + .attr("text-anchor", "end") + .attr("letter-spacing", 1.2) + .text("PATH"); + card.append("text").attr("class", "kcard-foot") + .attr("x", box.x + 16).attr("y", box.y + box.h - 12) + .text("ESC TO CLOSE"); + try { + const pb = pathLabel.node().getBBox(); + door(card, pathLabel, pb.x, box.y + box.h - 12, pb.width, followPath); + } catch (e) { + door(card, pathLabel, box.x + box.w - 48, box.y + box.h - 12, 34, followPath); + } + } - cy += 16; - text("kcard-section", colX, cy, "THE WIRE"); - cy += LINE; - if (!trace) { - text("kcard-faint", colX, cy, "TRACEROUTE · LOADING"); - cy += LINE; - } else if (hops.length) { + function paintWire(stack, m) { + const box = { + x: m.stackX + INSET, + y: m.stackY + m.idH - OVERLAP, + w: m.wireW, + h: m.wireH + }; + const card = dossierCard(stack, box, "THE WIRE", "path"); + card.style("pointer-events", "all"); + const hops = Array.isArray(lastTrace && lastTrace.hops) ? lastTrace.hops.slice(0, MAX_HOPS) : []; + let cy = box.y + HEADER + 16; + if (!lastTrace) { + card.append("text").attr("class", "kcard-faint") + .attr("x", box.x + 14).attr("y", cy).text("TRACEROUTE · LOADING"); + return; + } + if (hops.length) { hops.forEach((hop) => { const rtt = hop.rtt_ms === null || hop.rtt_ms === undefined ? "*" : `${Number(hop.rtt_ms).toFixed(1)} ms`; - text("kcard-waiter-dim", colX, cy + 4, - clip(`${hop.hop}. ${hop.target} ${rtt}`, compact ? 34 : 40)); + card.append("text").attr("class", "kcard-waiter-dim") + .attr("x", box.x + 14).attr("y", cy) + .text(clip(`${hop.hop}. ${hop.target} ${rtt}`, 36)); cy += ROW_STEP; }); - } else { - text("kcard-faint", colX, cy, clip( - (trace && trace.note) || "NO HOPS FROM HERE", - compact ? 34 : 40 - )); - cy += LINE; + return; } + card.append("text").attr("class", "kcard-faint") + .attr("x", box.x + 14).attr("y", cy) + .text(clip((lastTrace && lastTrace.note) || "NO HOPS FROM HERE", 34)); + } - body.append("line") - .attr("class", "kcard-divider") - .attr("x1", 0).attr("y1", h - FOOTER + 8).attr("x2", cw).attr("y2", h - FOOTER + 8); - text("kcard-foot", PAD, h - 10, "ESC OR CLICK OUTSIDE TO CLOSE"); - const pathLabel = text("kcard-signature", cw - PAD, h - 10, "PATH", true) - .attr("letter-spacing", 1.2); - const pathBox = pathLabel.node().getBBox(); - door(body, pathLabel, pathBox.x, h - 10, pathBox.width, followPath); + function paintSegment(stack, connection, m) { + const box = { + x: m.stackX + INSET * 2, + y: m.stackY + m.idH + m.wireH - OVERLAP * 2, + w: m.segW, + h: m.segH + }; + const card = dossierCard(stack, box, "SEGMENT", m.showFigure ? "to scale" : "narrow"); + card.style("pointer-events", "all"); + if (!m.showFigure) { + card.append("text").attr("class", "kcard-faint") + .attr("x", box.x + 14).attr("y", box.y + HEADER + 18) + .text("widen the window to read the frame"); + return; + } + const fig = card.append("g").attr("class", "flow-frame"); + drawFrameSection(fig, box.x + 12, box.y + HEADER + 4, box.w - 24, box.h - HEADER - 12, connection, lastInfo); + } + + + function ensureFrameDefs() { + let defs = svg.select("defs"); + if (defs.empty()) defs = svg.append("defs"); + if (!svg.select("#flow-cargo-hatch").empty()) return; + const pattern = defs.append("pattern") + .attr("id", "flow-cargo-hatch") + .attr("width", 6).attr("height", 6) + .attr("patternUnits", "userSpaceOnUse") + .attr("patternTransform", "rotate(45)"); + pattern.append("line") + .attr("x1", 0).attr("y1", 0).attr("x2", 0).attr("y2", 6) + .attr("stroke", "rgba(244, 244, 236, 0.14)") + .attr("stroke-width", 1); + } + + function num(value) { + const v = Number(value); + return Number.isFinite(v) && v > 0 ? v : 0; + } + + // What one full segment of this socket looks like on the wire. MSS is the + // payload the peer agreed to accept, so a full segment carries exactly + // that. The option bytes are reasoned, not read: Linux pads the timestamp + // option to 12, and SACK blocks only cost bytes when there is a hole. + function frameModel(info) { + const mss = num(info && info.mss); + const advmss = num(info && info.advmss); + const ts = !!(info && info.opt_ts); + const optionBytes = ts ? 12 : 0; + const payload = mss || advmss || 1460; + const tcp = 20 + optionBytes; + return { + eth: 14, + ip: 20, + tcp, + optionBytes, + payload, + header: 14 + 20 + tcp, + total: 14 + 20 + tcp + payload, + measured: mss > 0, + ts, + sack: !!(info && info.opt_sack), + pmtu: num(info && info.pmtu), + wscale: info && Number.isFinite(Number(info.wscale_snd)) + ? Number(info.wscale_snd) + : null, + window: num(info && info.snd_wnd) + }; + } + + // A 32-bit row of a header, laid out the way the RFC draws it. A cell with + // a value was measured; a cell without one is a field we know is there and + // cannot see; a derived cell is dashed. + function headerRow(g, ox, y, w, rowH, cells) { + let bit = 0; + cells.forEach((cell) => { + const cx = ox + (bit / 32) * w; + const cwid = (cell.bits / 32) * w; + bit += cell.bits; + const state = cell.value ? (cell.derived ? "is-derived" : "is-live") : ""; + g.append("rect") + .attr("class", `frame-cell ${state}`.trim()) + .attr("x", cx).attr("y", y) + .attr("width", cwid).attr("height", rowH); + const tight = cwid < 52; + const name = tight && cell.short ? cell.short : cell.name; + g.append("text") + .attr("class", "frame-cell-name") + .attr("x", cx + 4).attr("y", y + 7) + .text(name); + if (!cell.value) return; + g.append("text") + .attr("class", `frame-cell-value ${state}`.trim()) + .attr("x", cx + 4).attr("y", y + rowH - 4) + .text(clip(cell.value, Math.max(2, Math.floor((cwid - 7) / 5.1)))); + }); + } + + function headerBlock(g, ox, oy, w, title, rows, rowH) { + g.append("text") + .attr("class", "frame-block-title") + .attr("x", ox).attr("y", oy) + .text(title); + const rulerY = oy + 16; + g.append("line") + .attr("class", "frame-ruler") + .attr("x1", ox).attr("y1", rulerY).attr("x2", ox + w).attr("y2", rulerY); + [0, 8, 16, 24, 32].forEach((b) => { + const bx = ox + (b / 32) * w; + g.append("line") + .attr("class", "frame-ruler") + .attr("x1", bx).attr("y1", rulerY - 3).attr("x2", bx).attr("y2", rulerY); + if (b === 32) return; + g.append("text") + .attr("class", "frame-bit") + .attr("x", bx + 2).attr("y", rulerY - 4.5) + .text(String(b)); + }); + rows.forEach((cells, i) => { + headerRow(g, ox, rulerY + 3 + i * rowH, w, rowH, cells); + }); + return rulerY + 3 + rows.length * rowH; + } + + function drawFrameSection(g, ox, oy, w, availH, connection, info) { + ensureFrameDefs(); + const model = frameModel(info); + const local = parseEndpoint(connection.local); + const remote = parseEndpoint(connection.remote); + const scale = (bytes) => (bytes / model.total) * w; + + g.append("text") + .attr("class", "kcard-section") + .attr("x", ox).attr("y", oy + 8) + .text("ONE FULL SEGMENT, TO SCALE"); + + // The true-scale ribbon. The header slivers are meant to look small. + const barY = oy + 22; + const barH = 26; + const dimY = barY - 5; + g.append("line") + .attr("class", "frame-dim") + .attr("x1", ox).attr("y1", dimY).attr("x2", ox + w).attr("y2", dimY); + [ox, ox + w].forEach((tx) => { + g.append("line") + .attr("class", "frame-dim") + .attr("x1", tx).attr("y1", dimY - 3).attr("x2", tx).attr("y2", dimY + 3); + }); + g.append("text") + .attr("class", "frame-dim-label") + .attr("x", ox + w).attr("y", dimY - 4) + .attr("text-anchor", "end") + .text(`${model.total} B ON THE WIRE`); + + const cargoX = ox + scale(model.header); + g.append("rect") + .attr("class", "frame-cargo") + .attr("x", cargoX).attr("y", barY) + .attr("width", ox + w - cargoX).attr("height", barH) + .attr("fill", "url(#flow-cargo-hatch)"); + g.append("rect") + .attr("class", "frame-cargo-edge") + .attr("x", cargoX).attr("y", barY) + .attr("width", ox + w - cargoX).attr("height", barH); + g.append("text") + .attr("class", "frame-cargo-label") + .attr("x", cargoX + (ox + w - cargoX) / 2).attr("y", barY + barH / 2 + 3.5) + .attr("text-anchor", "middle") + .text(`PAYLOAD · ${model.payload} B`); + + let hx = ox; + [ + { bytes: model.eth, name: "ETH" }, + { bytes: model.ip, name: "IP" }, + { bytes: model.tcp, name: "TCP" } + ].forEach((part, i) => { + const pw = scale(part.bytes); + g.append("rect") + .attr("class", `frame-band is-${i}`) + .attr("x", hx).attr("y", barY) + .attr("width", Math.max(1.2, pw)).attr("height", barH); + hx += pw; + }); + + // The magnifier: the header sliver opens into the field grid below. + const sliverEnd = ox + scale(model.header); + const wedgeTop = barY + barH; + const wedgeBottom = wedgeTop + 24; + g.append("path") + .attr("class", "frame-wedge-fill") + .attr("d", `M${ox},${wedgeTop} L${sliverEnd},${wedgeTop} ` + + `L${ox + w},${wedgeBottom} L${ox},${wedgeBottom} Z`); + g.append("line") + .attr("class", "frame-wedge") + .attr("x1", ox).attr("y1", wedgeTop).attr("x2", ox).attr("y2", wedgeBottom); + g.append("line") + .attr("class", "frame-wedge") + .attr("x1", sliverEnd).attr("y1", wedgeTop) + .attr("x2", ox + w).attr("y2", wedgeBottom); + // Left-aligned so the caption stays clear of the wedge's diagonal. + g.append("text") + .attr("class", "frame-zoom") + .attr("x", ox + 4).attr("y", wedgeBottom - 6) + .text(`ETH ${model.eth} + IP ${model.ip} + TCP ${model.tcp} = ${model.header} B, ` + + `OPENED ${Math.round(w / scale(model.header))}×`); + + const totalLen = model.ip + model.tcp + model.payload; + const ipRows = [ + [ + { bits: 4, name: "VER", value: "4" }, + { bits: 4, name: "IHL", value: "5" }, + { bits: 8, name: "DSCP/ECN", short: "TOS" }, + { bits: 16, name: "TOTAL LENGTH", short: "LEN", value: String(totalLen), derived: true } + ], + [ + { bits: 16, name: "IDENTIFICATION", short: "ID" }, + { bits: 3, name: "FLG" }, + { bits: 13, name: "FRAGMENT OFFSET", short: "FRAG" } + ], + [ + { bits: 8, name: "TTL" }, + { bits: 8, name: "PROTOCOL", short: "PROTO", value: "6 TCP" }, + { bits: 16, name: "HEADER CHECKSUM", short: "CKSUM" } + ], + [{ bits: 32, name: "SOURCE ADDRESS", value: local.host }], + [{ bits: 32, name: "DESTINATION ADDRESS", value: remote.host }] + ]; + const rawWindow = model.window && model.wscale !== null + ? Math.round(model.window / Math.pow(2, model.wscale)) + : 0; + const tcpRows = [ + [ + { bits: 16, name: "SOURCE PORT", short: "SPORT", value: local.port }, + { bits: 16, name: "DESTINATION PORT", short: "DPORT", value: remote.port } + ], + [{ bits: 32, name: "SEQUENCE NUMBER" }], + [{ bits: 32, name: "ACKNOWLEDGMENT NUMBER" }], + [ + { bits: 4, name: "OFF", value: String(model.tcp / 4), derived: true }, + { bits: 6, name: "RSVD" }, + { bits: 6, name: "FLAGS", value: "ACK", derived: true }, + { + bits: 16, + name: "WINDOW", + value: rawWindow ? String(rawWindow) : "", + derived: true + } + ], + [ + { bits: 16, name: "CHECKSUM", short: "CKSUM" }, + { bits: 16, name: "URGENT POINTER", short: "URG" } + ] + ]; + if (model.optionBytes) { + tcpRows.push([{ + bits: 32, + name: `OPTIONS · ${model.optionBytes} B`, + value: model.sack ? "NOP NOP TIMESTAMP · SACK OK" : "NOP NOP TIMESTAMP", + derived: true + }]); + } + + const blockW = (w - 20) / 2; + const rowH = 20; + const blockY = wedgeBottom + 14; + headerBlock(g, ox, blockY, blockW, "IPv4 · 20 B", ipRows, rowH); + const tcpBottom = headerBlock( + g, ox + blockW + 20, blockY, blockW, `TCP · ${model.tcp} B`, tcpRows, rowH + ); + + const notes = []; + if (rawWindow && model.wscale) { + notes.push(`WINDOW ${rawWindow} × 2^${model.wscale} = ${model.window} B`); + } else if (model.wscale) { + notes.push(`WINDOW SCALED × 2^${model.wscale}`); + } + if (model.pmtu) notes.push(`PMTU ${model.pmtu}`); + if (!model.measured) notes.push("MSS NOT REPORTED · NOMINAL SEGMENT"); + // The punchline sits on the floor of the card, the way the reference + // dossier keeps its log strip at the bottom. + const verdictY = Math.max(tcpBottom + 26, oy + availH - 6); + if (notes.length) { + g.append("text") + .attr("class", "kcard-note") + .attr("x", ox).attr("y", verdictY - 14) + .text(notes.join(" · ")); + } + const overhead = (model.header / model.total) * 100; + g.append("text") + .attr("class", "frame-verdict") + .attr("x", ox).attr("y", verdictY) + .text(`${model.header} B OF CEREMONY CARRY ${model.payload} B OF CARGO` + + ` · ${overhead.toFixed(1)}% OVERHEAD`); } return { open, close, isOpen: () => openKey !== null }; diff --git a/static/js/io-elevator.js b/static/js/io-elevator.js new file mode 100644 index 0000000..ed4a669 --- /dev/null +++ b/static/js/io-elevator.js @@ -0,0 +1,597 @@ +// Prototype: vintage elevator floor-selector as Linux block I/O elevator. +// Floors ≈ LBA bands. Car ≈ disk head. Latched calls ≈ pending bio requests. +// Direction stickiness ≈ classic elevator / SCAN / mq-deadline spirit. +const IoElevator = (() => { + const FLOORS = 12; + const W = 920; + const H = 640; + const SHAFT_X = 210; + const SHAFT_W = 168; + const FLOOR_H = 42; + const SHAFT_TOP = 88; + const BTN_X = 470; + const PAD = 28; + + const state = { + floor: 3, + target: null, + dir: 0, // -1 down (toward higher LBA / lower floor index visually bottom), +1 up + moving: false, + queue: [], // { id, floor, kind: 'R'|'W', latched: true } + seq: 1, + pulse: null, + scheduler: "elevator · SCAN", + }; + + let svgRoot = null; + let carG = null; + let raf = null; + let lastTs = 0; + + function floorY(floor) { + // Floor 0 at bottom of shaft (low LBA), high floor at top — like a building. + const idx = FLOORS - 1 - floor; + return SHAFT_TOP + idx * FLOOR_H + FLOOR_H / 2; + } + + function lbaLabel(floor) { + const band = Math.floor((floor / FLOORS) * 100); + const next = Math.floor(((floor + 1) / FLOORS) * 100); + return `${String(band).padStart(2, "0")}–${String(next).padStart(2, "0")}% LBA`; + } + + function enqueue(floor, kind) { + floor = Math.max(0, Math.min(FLOORS - 1, floor | 0)); + if (state.queue.some((q) => q.floor === floor && q.kind === kind)) return; + state.queue.push({ + id: state.seq++, + floor, + kind: kind === "W" ? "W" : "R", + latched: true, + born: performance.now(), + }); + pickNext(); + renderButtons(); + renderLegend(); + } + + function pickNext() { + if (state.moving || !state.queue.length) { + if (!state.queue.length) { + state.target = null; + state.dir = 0; + } + return; + } + const here = state.floor; + let dir = state.dir || 1; + + const ahead = state.queue.filter((q) => + dir > 0 ? q.floor > here : q.floor < here + ); + const same = state.queue.filter((q) => q.floor === here); + if (same.length) { + serveFloor(here); + return; + } + if (!ahead.length) { + dir = -dir; + state.dir = dir; + const other = state.queue.filter((q) => + dir > 0 ? q.floor > here : q.floor < here + ); + if (!other.length) { + // Only opposite same-side leftovers — go to nearest + state.queue.sort((a, b) => Math.abs(a.floor - here) - Math.abs(b.floor - here)); + state.target = state.queue[0].floor; + state.dir = state.target > here ? 1 : state.target < here ? -1 : 0; + startMove(); + return; + } + state.target = dir > 0 + ? Math.min(...other.map((q) => q.floor)) + : Math.max(...other.map((q) => q.floor)); + startMove(); + return; + } + state.dir = dir; + state.target = dir > 0 + ? Math.min(...ahead.map((q) => q.floor)) + : Math.max(...ahead.map((q) => q.floor)); + startMove(); + } + + function startMove() { + if (state.target == null || state.target === state.floor) { + serveFloor(state.floor); + return; + } + state.moving = true; + state.dir = state.target > state.floor ? 1 : -1; + renderChrome(); + } + + function serveFloor(floor) { + const served = state.queue.filter((q) => q.floor === floor); + state.queue = state.queue.filter((q) => q.floor !== floor); + state.moving = false; + state.target = null; + renderButtons(); + renderChrome(); + flashServed(floor, served); + // Brief dwell, then continue + setTimeout(() => pickNext(), 280); + } + + function flashServed(floor, served) { + if (!svgRoot || !served.length) return; + const y = floorY(floor); + const kinds = served.map((s) => s.kind).join("+"); + const g = svgRoot.append("g").attr("class", "io-el-flash"); + g.append("rect") + .attr("x", SHAFT_X + 8) + .attr("y", y - 14) + .attr("width", SHAFT_W - 16) + .attr("height", 28) + .attr("rx", 2) + .attr("fill", "#e2a33e") + .attr("opacity", 0.55); + g.append("text") + .attr("x", SHAFT_X + SHAFT_W / 2) + .attr("y", y + 4) + .attr("text-anchor", "middle") + .attr("fill", "#1a1a1a") + .attr("font-size", 11) + .attr("font-family", "Share Tech Mono, monospace") + .text(`SERVE ${kinds}`); + g.transition().duration(420).attr("opacity", 0).remove(); + } + + function tick(ts) { + raf = requestAnimationFrame(tick); + if (!lastTs) lastTs = ts; + const dt = Math.min(0.05, (ts - lastTs) / 1000); + lastTs = ts; + + if (state.moving && state.target != null) { + const speed = 3.2; // floors per second + const step = state.dir * speed * dt; + const next = state.floor + step; + if ((state.dir > 0 && next >= state.target) || (state.dir < 0 && next <= state.target)) { + state.floor = state.target; + placeCar(true); + serveFloor(state.floor); + } else { + state.floor = next; + placeCar(false); + } + } + + // Soft ambient calls when disk is busy + maybeAmbient(ts); + } + + let lastAmbient = 0; + function maybeAmbient(ts) { + if (ts - lastAmbient < 900) return; + const p = state.pulse || {}; + const iops = (p.disk_read_iops || 0) + (p.disk_write_iops || 0); + const mb = (p.disk_read_mb_s || 0) + (p.disk_write_mb_s || 0); + const busy = iops > 2 || mb > 0.05; + if (!busy && state.queue.length >= 2) return; + if (state.queue.length >= 7) return; + lastAmbient = ts; + if (Math.random() > (busy ? 0.35 : 0.08)) return; + const floor = Math.floor(Math.random() * FLOORS); + const kind = (p.disk_write_iops || 0) > (p.disk_read_iops || 0) ? "W" : "R"; + if (Math.random() < 0.45) enqueue(floor, kind === "W" ? "W" : "R"); + else enqueue(floor, Math.random() < 0.5 ? "R" : "W"); + } + + function placeCar(snap) { + if (!carG) return; + const y = floorY(state.floor); + if (snap) { + carG.attr("transform", `translate(0,${y})`); + } else { + carG.attr("transform", `translate(0,${y})`); + } + renderChrome(); + } + + function renderChrome() { + if (!svgRoot) return; + const dirEl = svgRoot.select(".io-el-dir"); + const posEl = svgRoot.select(".io-el-pos"); + const tgtEl = svgRoot.select(".io-el-tgt"); + const qEl = svgRoot.select(".io-el-q"); + const arrow = state.dir > 0 ? "▲ UP" : state.dir < 0 ? "▼ DOWN" : "◆ IDLE"; + dirEl.text(arrow); + posEl.text(`CAR floor ${Math.round(state.floor)} · head @ ${lbaLabel(Math.round(state.floor))}`); + tgtEl.text(state.target == null ? "NEXT —" : `NEXT floor ${state.target}`); + qEl.text(`QUEUE ${state.queue.length} latched`); + + svgRoot.selectAll(".io-el-floor-mark").attr("opacity", (d) => { + const f = Math.round(state.floor); + return d === f ? 1 : 0.35; + }); + svgRoot.selectAll(".io-el-floor-fill").attr("fill", (d) => { + const f = Math.round(state.floor); + if (d === f) return "rgba(226,163,62,0.22)"; + if (state.queue.some((q) => q.floor === d)) return "rgba(90,110,130,0.12)"; + return "transparent"; + }); + } + + function renderButtons() { + if (!svgRoot) return; + const rows = []; + for (let f = FLOORS - 1; f >= 0; f -= 1) { + rows.push({ floor: f, R: state.queue.find((q) => q.floor === f && q.kind === "R"), W: state.queue.find((q) => q.floor === f && q.kind === "W") }); + } + const g = svgRoot.select(".io-el-buttons"); + const sel = g.selectAll(".io-el-btn-row").data(rows, (d) => d.floor); + const enter = sel.enter().append("g").attr("class", "io-el-btn-row"); + enter.append("text").attr("class", "io-el-btn-lab"); + enter.append("g").attr("class", "io-el-btn-r"); + enter.append("g").attr("class", "io-el-btn-w"); + const all = enter.merge(sel); + all.attr("transform", (d, i) => `translate(${BTN_X},${SHAFT_TOP + i * FLOOR_H + 8})`); + all.select(".io-el-btn-lab") + .attr("x", 0) + .attr("y", 16) + .attr("fill", "#3a3a3a") + .attr("font-size", 12) + .attr("font-family", "Share Tech Mono, monospace") + .text((d) => String(d.floor).padStart(2, "0")); + + drawLatch(all.select(".io-el-btn-r"), 36, "R", (d) => !!d.R, (d) => enqueue(d.floor, "R")); + drawLatch(all.select(".io-el-btn-w"), 92, "W", (d) => !!d.W, (d) => enqueue(d.floor, "W")); + sel.exit().remove(); + } + + function drawLatch(sel, x, label, isOn, onClick) { + sel.each(function (d) { + const node = d3.select(this); + node.selectAll("*").remove(); + const on = isOn(d); + node.append("circle") + .attr("cx", x + 14) + .attr("cy", 12) + .attr("r", 11) + .attr("fill", on ? (label === "W" ? "#c45c3a" : "#e2a33e") : "#cfcac0") + .attr("stroke", on ? "#1a1a1a" : "#8a8578") + .attr("stroke-width", on ? 1.6 : 1) + .style("cursor", "pointer") + .on("click", (event) => { + event.stopPropagation(); + onClick(d); + }); + node.append("text") + .attr("x", x + 14) + .attr("y", 16) + .attr("text-anchor", "middle") + .attr("fill", on ? "#1a1a1a" : "#5a564c") + .attr("font-size", 10) + .attr("font-family", "Share Tech Mono, monospace") + .attr("pointer-events", "none") + .text(label); + // mechanical latch tab + if (on) { + node.append("rect") + .attr("x", x + 26) + .attr("y", 8) + .attr("width", 6) + .attr("height", 8) + .attr("fill", "#1a1a1a") + .attr("opacity", 0.55); + } + }); + } + + function renderLegend() { + if (!svgRoot) return; + const p = state.pulse || {}; + const line = [ + `live disk R ${Number(p.disk_read_mb_s || 0).toFixed(2)} MB/s · W ${Number(p.disk_write_mb_s || 0).toFixed(2)} MB/s`, + `iops ${p.disk_read_iops || 0}r / ${p.disk_write_iops || 0}w`, + state.scheduler, + ].join(" · "); + svgRoot.select(".io-el-live").text(line); + } + + function build() { + const host = d3.select("#io-elevator"); + host.selectAll("*").remove(); + svgRoot = host + .append("svg") + .attr("viewBox", `0 0 ${W} ${H}`) + .attr("class", "io-elevator-svg"); + + // Panel plate + svgRoot.append("rect") + .attr("x", PAD) + .attr("y", PAD) + .attr("width", W - PAD * 2) + .attr("height", H - PAD * 2) + .attr("rx", 6) + .attr("fill", "#ebe6dc") + .attr("stroke", "#2a2a2a") + .attr("stroke-width", 1.4); + + // Inner bevel + svgRoot.append("rect") + .attr("x", PAD + 8) + .attr("y", PAD + 8) + .attr("width", W - PAD * 2 - 16) + .attr("height", H - PAD * 2 - 16) + .attr("rx", 3) + .attr("fill", "none") + .attr("stroke", "rgba(0,0,0,0.12)"); + + // Title plate + svgRoot.append("text") + .attr("x", PAD + 22) + .attr("y", 58) + .attr("fill", "#1a1a1a") + .attr("font-size", 18) + .attr("font-family", "Share Tech Mono, monospace") + .attr("letter-spacing", "0.08em") + .text("BLOCK I/O · FLOOR SELECTOR"); + + svgRoot.append("text") + .attr("x", PAD + 22) + .attr("y", 76) + .attr("fill", "#6a655c") + .attr("font-size", 11) + .attr("font-family", "Share Tech Mono, monospace") + .text("prototype — elevator algorithm as pre-computer lift selector"); + + // Direction / status + svgRoot.append("text").attr("class", "io-el-dir") + .attr("x", W - PAD - 24) + .attr("y", 58) + .attr("text-anchor", "end") + .attr("fill", "#c49a3c") + .attr("font-size", 16) + .attr("font-family", "Share Tech Mono, monospace") + .text("◆ IDLE"); + + svgRoot.append("text").attr("class", "io-el-pos") + .attr("x", PAD + 22) + .attr("y", H - 52) + .attr("fill", "#3a3a3a") + .attr("font-size", 12) + .attr("font-family", "Share Tech Mono, monospace"); + + svgRoot.append("text").attr("class", "io-el-tgt") + .attr("x", PAD + 22) + .attr("y", H - 34) + .attr("fill", "#6a655c") + .attr("font-size", 12) + .attr("font-family", "Share Tech Mono, monospace"); + + svgRoot.append("text").attr("class", "io-el-q") + .attr("x", BTN_X) + .attr("y", H - 34) + .attr("fill", "#6a655c") + .attr("font-size", 12) + .attr("font-family", "Share Tech Mono, monospace"); + + svgRoot.append("text").attr("class", "io-el-live") + .attr("x", PAD + 22) + .attr("y", H - 14) + .attr("fill", "#8a8578") + .attr("font-size", 10) + .attr("font-family", "Share Tech Mono, monospace"); + + // Metaphor column (left) + const myth = svgRoot.append("g").attr("transform", `translate(${PAD + 22},140)`); + const lines = [ + ["SHAFT", "address space / LBA"], + ["FLOOR", "sector band"], + ["CAR", "disk head"], + ["LATCH", "pending bio"], + ["R / W", "read / write"], + ["▲▼", "SCAN direction"], + ]; + lines.forEach((row, i) => { + myth.append("text") + .attr("y", i * 28) + .attr("fill", "#c49a3c") + .attr("font-size", 11) + .attr("font-family", "Share Tech Mono, monospace") + .text(row[0]); + myth.append("text") + .attr("y", i * 28 + 12) + .attr("fill", "#6a655c") + .attr("font-size", 10) + .attr("font-family", "Share Tech Mono, monospace") + .text(row[1]); + }); + + // Shaft + const shaftH = FLOORS * FLOOR_H; + svgRoot.append("rect") + .attr("x", SHAFT_X) + .attr("y", SHAFT_TOP) + .attr("width", SHAFT_W) + .attr("height", shaftH) + .attr("fill", "#2c3038") + .attr("stroke", "#1a1a1a") + .attr("stroke-width", 1.5); + + // Floor marks + const floors = d3.range(FLOORS); + const marks = svgRoot.append("g").attr("class", "io-el-floors"); + const fg = marks.selectAll("g").data(floors).enter().append("g"); + fg.append("rect") + .attr("class", "io-el-floor-fill") + .attr("x", SHAFT_X + 1) + .attr("y", (d) => floorY(d) - FLOOR_H / 2 + 1) + .attr("width", SHAFT_W - 2) + .attr("height", FLOOR_H - 2) + .attr("fill", "transparent"); + fg.append("line") + .attr("x1", SHAFT_X) + .attr("x2", SHAFT_X + SHAFT_W) + .attr("y1", (d) => floorY(d) + FLOOR_H / 2) + .attr("y2", (d) => floorY(d) + FLOOR_H / 2) + .attr("stroke", "rgba(255,255,255,0.08)"); + fg.append("text") + .attr("class", "io-el-floor-mark") + .attr("x", SHAFT_X + 14) + .attr("y", (d) => floorY(d) + 4) + .attr("fill", "#e2a33e") + .attr("font-size", 13) + .attr("font-family", "Share Tech Mono, monospace") + .attr("opacity", 0.35) + .text((d) => String(d).padStart(2, "0")); + fg.append("text") + .attr("x", SHAFT_X + SHAFT_W - 10) + .attr("y", (d) => floorY(d) + 4) + .attr("text-anchor", "end") + .attr("fill", "rgba(255,255,255,0.28)") + .attr("font-size", 9) + .attr("font-family", "Share Tech Mono, monospace") + .text((d) => lbaLabel(d).replace(" LBA", "")); + + // Guide rail + svgRoot.append("line") + .attr("x1", SHAFT_X + SHAFT_W / 2) + .attr("x2", SHAFT_X + SHAFT_W / 2) + .attr("y1", SHAFT_TOP + 4) + .attr("y2", SHAFT_TOP + shaftH - 4) + .attr("stroke", "rgba(226,163,62,0.25)") + .attr("stroke-width", 2) + .attr("stroke-dasharray", "3 7"); + + // Car / traveling contact + carG = svgRoot.append("g").attr("class", "io-el-car"); + carG.append("rect") + .attr("x", SHAFT_X + 28) + .attr("y", -16) + .attr("width", SHAFT_W - 56) + .attr("height", 32) + .attr("rx", 2) + .attr("fill", "#d8b15a") + .attr("stroke", "#1a1a1a") + .attr("stroke-width", 1.4); + carG.append("rect") + .attr("x", SHAFT_X + 36) + .attr("y", -8) + .attr("width", SHAFT_W - 72) + .attr("height", 16) + .attr("fill", "#2c3038"); + // Brush contacts left/right + carG.append("rect") + .attr("x", SHAFT_X + 18) + .attr("y", -5) + .attr("width", 8) + .attr("height", 10) + .attr("fill", "#8a8578"); + carG.append("rect") + .attr("x", SHAFT_X + SHAFT_W - 26) + .attr("y", -5) + .attr("width", 8) + .attr("height", 10) + .attr("fill", "#8a8578"); + carG.append("text") + .attr("x", SHAFT_X + SHAFT_W / 2) + .attr("y", 4) + .attr("text-anchor", "middle") + .attr("fill", "#f2e6c4") + .attr("font-size", 10) + .attr("font-family", "Share Tech Mono, monospace") + .text("HEAD"); + + // Call bank header + svgRoot.append("text") + .attr("x", BTN_X) + .attr("y", SHAFT_TOP - 18) + .attr("fill", "#1a1a1a") + .attr("font-size", 12) + .attr("font-family", "Share Tech Mono, monospace") + .text("CALL BANK · click to latch"); + svgRoot.append("text") + .attr("x", BTN_X + 36) + .attr("y", SHAFT_TOP - 4) + .attr("fill", "#8a8578") + .attr("font-size", 10) + .attr("font-family", "Share Tech Mono, monospace") + .text("R"); + svgRoot.append("text") + .attr("x", BTN_X + 92) + .attr("y", SHAFT_TOP - 4) + .attr("fill", "#8a8578") + .attr("font-size", 10) + .attr("font-family", "Share Tech Mono, monospace") + .text("W"); + + svgRoot.append("g").attr("class", "io-el-buttons"); + + // Side note + svgRoot.append("text") + .attr("x", 680) + .attr("y", 140) + .attr("fill", "#1a1a1a") + .attr("font-size", 12) + .attr("font-family", "Share Tech Mono, monospace") + .text("HOW TO READ"); + const how = [ + "Buttons latch like old lift calls.", + "Car keeps direction until the", + "shaft is empty ahead, then turns.", + "", + "That is the elevator idea inside", + "Linux block scheduling: serve the", + "disk in sweeps, not as a random", + "scatter of seeks.", + "", + "Later: real request queue, device,", + "mq-deadline / BFQ labels.", + ]; + how.forEach((t, i) => { + svgRoot.append("text") + .attr("x", 680) + .attr("y", 162 + i * 16) + .attr("fill", "#6a655c") + .attr("font-size", 11) + .attr("font-family", "Share Tech Mono, monospace") + .text(t); + }); + + placeCar(true); + renderButtons(); + renderChrome(); + renderLegend(); + + // Seed a few calls so the machine is alive on first look + enqueue(8, "R"); + enqueue(2, "W"); + enqueue(5, "R"); + } + + function pollPulse() { + fetch("/api/io-pulse", { cache: "no-store" }) + .then((r) => r.json()) + .then((d) => { + state.pulse = d; + renderLegend(); + }) + .catch(() => {}); + } + + function start() { + build(); + lastTs = 0; + if (raf) cancelAnimationFrame(raf); + raf = requestAnimationFrame(tick); + pollPulse(); + setInterval(pollPulse, 2000); + } + + return { start, enqueue }; +})(); + +document.addEventListener("DOMContentLoaded", () => IoElevator.start()); diff --git a/static/js/kernel-tape.js b/static/js/kernel-tape.js index 18ad28b..330baff 100644 --- a/static/js/kernel-tape.js +++ b/static/js/kernel-tape.js @@ -80,9 +80,26 @@ eventsSinceCore: 0, eventsThisSecond: 0, epsWindowStart: Date.now(), - eps: 0 + eps: 0, + // While a socket is hovered the tape narrows to that socket: its owner's + // calls and its own counters, instead of the machine-wide feeds. + focus: null, + focusPrev: null, + focusSeq: 0, + // The pill is fixed HTML, so an SVG scrim cannot cover it: while a card + // is berthed against the right edge the pill would float on top of it. + pillHidden: false }; + // The pill is only offered when there is nothing in its way: the tape itself + // is shut, no card holds the edge, and this is not the phone layout, where + // the tape is always on and has no toggle at all. + function applyPill() { + if (!el.toggle) return; + const show = !onMobile() && !state.open && !state.pillHidden; + el.toggle.style.display = show ? 'inline-flex' : 'none'; + } + const el = {}; function injectStyles() { @@ -167,6 +184,7 @@ const title = document.createElement('span'); title.textContent = 'KERNEL ACTIVITY'; Object.assign(title.style, { font: `9px/1 ${D.mono}`, letterSpacing: '1.7px', color: D.text }); + el.title = title; const spacer = document.createElement('span'); spacer.style.flex = '1'; const eps = document.createElement('span'); @@ -619,6 +637,113 @@ .on('end', () => ring.remove()); } + function setTitle() { + if (!el.title) return; + const f = state.focus; + if (!f) { + el.title.textContent = 'KERNEL ACTIVITY'; + el.title.style.color = D.text; + return; + } + el.title.textContent = f.owner + ? `SOCKET · ${String(f.owner).toUpperCase()}${f.pid ? ` ${f.pid}` : ''}` + : 'SOCKET · RESOLVING'; + el.title.style.color = D.accent; + } + + // One socket's own feed. The parked-call names need ptrace-level access and + // are often closed to us; the read/write counters in /proc//io and the + // socket's byte and segment counters are not. When the names are missing the + // tape says so once, rather than going quiet and looking idle. + async function tickSocket() { + const focus = state.focus; + if (!focus) return; + const seq = state.focusSeq; + const params = new URLSearchParams({ + local: focus.local, remote: focus.remote, proto: focus.proto || 'TCP' + }); + let data; + try { + data = await getJson(`/api/socket-activity?${params.toString()}`); + } catch (e) { + return; + } + if (seq !== state.focusSeq || !state.focus) return; + + const owner = data && data.owner; + if (owner && owner.comm) { + focus.owner = owner.comm; + focus.pid = owner.pid; + setTitle(); + } + if (!data || !data.found) { + pushRow({ ts: timeStamp(), sym: '·', name: 'socket gone', tagText: 'SOCK', detail: '', level: 'dim' }); + return; + } + + const prev = state.focusPrev; + const io = data.io || {}; + const sock = data.socket || {}; + state.focusPrev = { io, sock, reason: data.reason }; + + if (!prev) { + const who = owner && owner.comm ? `${owner.comm} pid ${owner.pid} fd ${owner.fd}` : (data.reason || 'owner not visible'); + pushRow({ ts: timeStamp(), sym: '⌖', symColor: D.accent, name: 'FOCUS', tagText: 'SOCK', detail: who, live: true }); + if (data.readable && !data.calls_readable && data.reason) { + pushRow({ ts: timeStamp(), sym: '·', name: data.reason, tagText: 'SOCK', detail: 'counts only', level: 'dim' }); + } + return; + } + + const step = (now, before) => { + const a = Number(now); + const b = Number(before); + if (!Number.isFinite(a) || !Number.isFinite(b)) return 0; + return a - b; + }; + + const reads = step(io.syscr, prev.io && prev.io.syscr); + const writes = step(io.syscw, prev.io && prev.io.syscw); + if (reads > 0) { + pushRow({ ts: timeStamp(), sym: '▲', symColor: D.accent, name: 'read()', tagText: 'FS', detail: `+${reads} calls`, live: true }); + } + if (writes > 0) { + pushRow({ ts: timeStamp(), sym: '▲', symColor: D.accent, name: 'write()', tagText: 'FS', detail: `+${writes} calls`, live: true }); + } + + const segsOut = step(sock.segs_out, prev.sock && prev.sock.segs_out); + const segsIn = step(sock.segs_in, prev.sock && prev.sock.segs_in); + const sent = step(sock.bytes_sent, prev.sock && prev.sock.bytes_sent); + const recv = step(sock.bytes_received, prev.sock && prev.sock.bytes_received); + if (segsOut > 0 || sent > 0) { + pushRow({ ts: timeStamp(), sym: '↑', symColor: D.accent, name: 'segments out', tagText: 'NET', detail: `+${segsOut} · ${sent} B`, live: true }); + } + if (segsIn > 0 || recv > 0) { + pushRow({ ts: timeStamp(), sym: '↓', symColor: D.accent, name: 'segments in', tagText: 'NET', detail: `+${segsIn} · ${recv} B`, live: true }); + } + const retrans = step(sock.retrans_total, prev.sock && prev.sock.retrans_total); + if (retrans > 0) { + pushRow({ ts: timeStamp(), sym: '!', symColor: ERR_COLOR, name: 'RETRANSMIT', tagText: 'NET', detail: `+${retrans} segs`, level: 'err' }); + } + + (data.calls || []).slice(0, MAX_NEW_PER_TICK).forEach((call) => { + pushRow({ + ts: timeStamp(), + sym: '·', + name: String(call.name || '').toUpperCase(), + tagText: tagForSyscall(call.name).text, + detail: `tid ${call.tid} parked`, + level: 'normal' + }); + }); + + const idle = !reads && !writes && !segsOut && !segsIn && !(data.calls || []).length; + if (idle) { + const rtt = Number.isFinite(Number(sock.rtt_ms)) ? `rtt ${Number(sock.rtt_ms).toFixed(1)} ms` : 'quiet'; + pushRow({ ts: timeStamp(), sym: '·', name: 'no calls this tick', tagText: 'SOCK', detail: rtt, level: 'dim' }); + } + } + function tick() { if (state.paused || !state.open) return; if (onMobile()) placeCard(); @@ -626,6 +751,13 @@ // Core "breath" reflects activity accumulated since the previous tick. pulseCore(state.eventsSinceCore); state.eventsSinceCore = 0; + // A hovered socket owns the tape: mixing the machine-wide feeds back in + // is exactly the false connection this feature exists to remove. + if (state.focus) { + tickSocket(); + updateEps(); + return; + } tickSyscalls(); if (i % 2 === 0) { tickNetwork(); tickIoPulse(); } else { tickConnections(); } @@ -661,8 +793,13 @@ el.root.style.transform = state.open ? 'translateX(0)' : 'translateX(100%)'; el.root.style.pointerEvents = state.open ? 'auto' : 'none'; } - if (el.toggle) el.toggle.style.display = (!mobile && !state.open) ? 'inline-flex' : 'none'; + applyPill(); if (el.closeBtn) el.closeBtn.style.display = mobile ? 'none' : 'inline-block'; + if (!state.open) { + state.focus = null; + state.focusPrev = null; + setTitle(); + } if (state.open && !wasOpen) primeAndFill(); }, setPaused(paused) { @@ -671,6 +808,48 @@ el.pauseBtn.textContent = paused ? 'RESUME' : 'PAUSE'; el.pauseBtn.style.color = paused ? D.accent : D.dim; } + }, + // Stand the pill down while something else owns the right edge. + setPillHidden(hidden) { + state.pillHidden = !!hidden; + applyPill(); + }, + // How much of the right edge the open tape owns, so cards can berth + // beside it instead of underneath it. + reservedWidth() { + if (!state.open || onMobile() || !el.root) return 0; + return el.root.offsetWidth || 0; + }, + setFocus(socket) { + if (!state.open || !socket || !socket.local || !socket.remote) return; + const key = `${socket.local}|${socket.remote}`; + if (state.focus && state.focus.key === key) return; + state.focusSeq += 1; + state.focus = { + key, + local: String(socket.local), + remote: String(socket.remote), + proto: String(socket.proto || 'TCP').toUpperCase(), + owner: null, + pid: null + }; + state.focusPrev = null; + setTitle(); + // Sweeping the pointer down the list would otherwise fire a pair of + // ss runs per row; only a hover that settles is worth a request. + const seq = state.focusSeq; + window.setTimeout(() => { + if (seq !== state.focusSeq || state.paused) return; + tickSocket(); + }, 250); + }, + clearFocus() { + if (!state.focus) return; + state.focusSeq += 1; + state.focus = null; + state.focusPrev = null; + setTitle(); + state.firstSyscallSample = true; } }; window.KernelTape = api; diff --git a/static/js/main.js b/static/js/main.js index 32c4b50..6caf2c0 100755 --- a/static/js/main.js +++ b/static/js/main.js @@ -386,6 +386,9 @@ function draw() { if (window.rightSemicircleMenuManager) { window.rightSemicircleMenuManager.renderRightSemicircleMenu(); } + + // Rooms index in the same dossier language as a process card: a cascading + // stack of doors, sitting next to the arc rather than as a second HTML nav. } function drawMobileFormula(centerX, centerY) { @@ -1690,6 +1693,17 @@ function buryLiveLayersUnderScrim(scrimClass, overlayClasses) { // nowhere to start. const openOverlays = []; +// The veil is drawn inside the svg, so it dims the map but has no reach over +// fixed HTML chrome: the ACTIVITY pill kept floating on top of any card berthed +// against the right edge. Every overlay passes through the keeper below, so one +// call here stands the pill down for all of them and brings it back when the +// last one closes. +function syncOverlayChrome() { + if (window.KernelTape && typeof window.KernelTape.setPillHidden === 'function') { + window.KernelTape.setPillHidden(openOverlays.length > 0); + } +} + // Один сторож на накладку: пока она открыта, живые слои остаются под вуалью. function createOverlayTopKeeper(scrimClass, overlayClasses, isOpen) { let observer = null; @@ -1706,6 +1720,7 @@ function createOverlayTopKeeper(scrimClass, overlayClasses, isOpen) { const svgNode = svg.node(); if (!svgNode || typeof MutationObserver === 'undefined') return; if (!openOverlays.includes(entry)) openOverlays.push(entry); + syncOverlayChrome(); bury(); if (observer) return; observer = new MutationObserver(() => { @@ -1719,6 +1734,7 @@ function createOverlayTopKeeper(scrimClass, overlayClasses, isOpen) { stop() { const mine = openOverlays.indexOf(entry); if (mine !== -1) openOverlays.splice(mine, 1); + syncOverlayChrome(); if (observer) { observer.disconnect(); observer = null; diff --git a/static/js/memory-belt.js b/static/js/memory-belt.js index c7bbacc..c23324e 100644 --- a/static/js/memory-belt.js +++ b/static/js/memory-belt.js @@ -1,7 +1,7 @@ // Linux memory subsystem — strip map (same API payload as processes-realtime memory_visual) -// Version: 10 — Tron/FLYNN style pass: cyan cold, white hot runs, bank grid +// Version: 11 — noise sits on a locked grid; hot marks are horizontal runs -debugLog('💾 memory-belt.js v10: Script loading...'); +debugLog('💾 memory-belt.js v11: Script loading...'); class MemorySubsystemVisualization { constructor() { @@ -848,35 +848,33 @@ class MemorySubsystemVisualization { this.ctx.fillStyle = '#02080c'; this.ctx.fillRect(viewX, viewY, viewW, viewH); - // Far grid layer (slow parallax) - const gOffX = this.parallaxX * 0.25; - const gOffY = this.parallaxY * 0.25; + // Locked lattice — noise is allowed only on this grid, not as free grain. this.ctx.save(); this.ctx.beginPath(); this.ctx.rect(viewX, viewY, viewW, viewH); this.ctx.clip(); - this.ctx.strokeStyle = 'rgba(0, 120, 135, 0.28)'; - this.ctx.lineWidth = 1; const gStep = 10; - for (let gx = viewX - gStep * 2; gx <= viewX + viewW + gStep * 2; gx += gStep) { - const xx = gx + gOffX; + this.ctx.strokeStyle = 'rgba(0, 120, 135, 0.12)'; + this.ctx.lineWidth = 1; + for (let gx = viewX; gx <= viewX + viewW; gx += gStep) { this.ctx.beginPath(); - this.ctx.moveTo(xx + 0.5, viewY); - this.ctx.lineTo(xx + 0.5, viewY + viewH); + this.ctx.moveTo(gx + 0.5, viewY); + this.ctx.lineTo(gx + 0.5, viewY + viewH); this.ctx.stroke(); } - for (let gy = viewY - gStep * 2; gy <= viewY + viewH + gStep * 2; gy += gStep) { - const yy = gy + gOffY; + this.ctx.strokeStyle = 'rgba(180, 220, 230, 0.07)'; + for (let gy = viewY; gy <= viewY + viewH; gy += 2) { this.ctx.beginPath(); - this.ctx.moveTo(viewX, yy + 0.5); - this.ctx.lineTo(viewX + viewW, yy + 0.5); + this.ctx.moveTo(viewX, gy + 0.5); + this.ctx.lineTo(viewX + viewW, gy + 0.5); this.ctx.stroke(); } - this.ctx.fillStyle = 'rgba(0, 140, 150, 0.16)'; - for (let gy = viewY; gy <= viewY + viewH; gy += gStep * 2) { - for (let gx = viewX; gx <= viewX + viewW; gx += gStep * 2) { - this.ctx.fillRect(gx + gOffX, gy + gOffY, 1.2, 1.2); - } + this.ctx.strokeStyle = 'rgba(0, 140, 155, 0.22)'; + for (let gy = viewY; gy <= viewY + viewH; gy += gStep) { + this.ctx.beginPath(); + this.ctx.moveTo(viewX, gy + 0.5); + this.ctx.lineTo(viewX + viewW, gy + 0.5); + this.ctx.stroke(); } this.ctx.restore(); @@ -951,7 +949,7 @@ class MemorySubsystemVisualization { const hotspotPoints = []; const hotMask = new Uint8Array(rows * cols); const cellState = new Array(rows * cols); - const bandSigma = rows * 0.22; + const bandSigma = rows * 0.16; const bandCenter = rows * 0.5; let hotCount = 0; const focusKind = this.fabricFocus?.kind || null; @@ -967,7 +965,7 @@ class MemorySubsystemVisualization { const n2 = Math.cos(cx * 0.11 - ry * 0.29 + this.tick * 0.002); const n3 = Math.sin((cx + ry * 3) * 0.19 + this.tick * 0.0012); const noise = (n1 + n2 + n3 + 3) / 6; - const heat = Math.max(0, Math.min(1, src.heat * 0.5 + noise * 0.5)); + const heat = Math.max(0, Math.min(1, src.heat * 0.78 + noise * 0.22)); // Slow discrete drift of lit pattern (~every ~40 frames) const seed = ((cx + 13) * 73856093) ^ ((ry + 29) * 19349663) ^ (Math.floor(this.tick * 0.05) * 83492791); const rnd = ((seed >>> 0) % 10000) / 10000; @@ -975,7 +973,7 @@ class MemorySubsystemVisualization { const aboveHot = ry > 0 && hotMask[(ry - 1) * cols + cx]; const clump = (leftHot ? 0.16 : 0) + (aboveHot ? 0.08 : 0); // Cream = pressure now (PSI / reclaim / local heat), not byte occupancy. - const lit = rnd < (bandGate * (0.28 + heat * 0.4) + clump * 0.85 + usedLevel * 0.03 + pressureBoost); + const lit = rnd < (bandGate * (0.18 + heat * 0.36) + clump * 1.05 + usedLevel * 0.02 + pressureBoost); hotMask[ry * cols + cx] = lit ? 1 : 0; // depth layer: 0 far mass, 1 mid mass, 2 pressure hotspots let layer = 0; @@ -988,107 +986,125 @@ class MemorySubsystemVisualization { } } - const layers = [ - { id: 0, parallax: 0.35, scale: 0.92 }, - { id: 1, parallax: 0.65, scale: 0.97 }, - { id: 2, parallax: 1.0, scale: 1.0 } - ]; - this.ctx.save(); this.ctx.beginPath(); this.ctx.rect(viewX, viewY, viewW, viewH); this.ctx.clip(); - layers.forEach((L) => { - const ox = this.parallaxX * L.parallax; - const oy = this.parallaxY * L.parallax; - for (let ry = 0; ry < rows; ry++) { - for (let cx = 0; cx < cols; cx++) { - const st = cellState[ry * cols + cx]; - if (!st || st.layer !== L.id) continue; - const px0 = viewX + cx * cellW; - const py0 = viewY + ry * cellH; - const gapX = 1.4 + (2 - L.id) * 0.4; - const gapY = 1.8 + (2 - L.id) * 0.5; - let rw = Math.max(2, (cellW - gapX) * L.scale); - let rh = Math.max(2, Math.min(cellH - gapY, cellW * 0.55) * L.scale); - const px = px0 + ox + (cellW - rw) * 0.5; - const py = py0 + oy + (cellH - rh) * 0.5; - const related = focusKind - && (st.src.kind === focusKind || st.src.rowId === focusRow); - const dimUnrelated = focusKind && !related; - - if (L.id === 2) { - // Pressure hotspot — pale yellow-white (FLYNN active) - const thr = thrashAtm * 0.25; - const r = Math.floor(235 + 20 * st.heat); - const g = Math.floor(240 + 12 * st.heat - thr * 30); - const b = Math.floor(200 + 30 * st.heat + thr * 25); - const bright = (0.7 + st.heat * 0.3) * (dimUnrelated ? 0.25 : 1); - // variable-length run bricks (1–4 cells feel) - const runBoost = (st.rnd > 0.55) ? 1.0 : (st.rnd > 0.3 ? 0.65 : 0.35); - const drawW = Math.max(2, rw * (0.85 + runBoost * 0.9)); - this.ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${bright.toFixed(3)})`; - this.ctx.fillRect(px, py, Math.min(drawW, cellW * 3.2), rh); - // soft glow edge - this.ctx.fillStyle = `rgba(255, 255, 230, ${(0.12 * bright).toFixed(3)})`; - this.ctx.fillRect(px - 0.5, py - 0.5, Math.min(drawW, cellW * 3.2) + 1, rh + 1); - const usedW = Math.min(drawW, cellW * 3.2); - if (related) { - this.ctx.strokeStyle = 'rgba(255, 252, 220, 0.95)'; - this.ctx.lineWidth = 1; - this.ctx.strokeRect(px + 0.5, py + 0.5, usedW - 1, rh - 1); - } - if (st.heat > 0.5 || st.rnd > 0.65) { - hotspotPoints.push({ x: px + usedW * 0.5, y: py + rh * 0.5, heat: st.heat }); - } - const procOwner = weightedProcPool.length - ? weightedProcPool[(Math.abs(cx * 97 + ry * 53 + (st.seed >>> 4)) % weightedProcPool.length)] - : null; - if (this.memoryFabricHits.length < 1200) { - this.memoryFabricHits.push({ - x: px, y: py, w: usedW, h: rh, - heat: st.heat, - kind: st.src.kind, - rowId: st.src.rowId, - label: st.src.label, - pid: procOwner ? Number(procOwner.pid || 0) : null, - name: procOwner ? String(procOwner.name || 'proc') : null, - role: procOwner ? String(procOwner.role || 'userspace') : null, - pressure_score: procOwner ? Number(procOwner.pressure_score || 0) : 0, - rss_mb: procOwner ? Number(procOwner.rss_mb || 0) : 0, - swap_mb: procOwner ? Number(procOwner.swap_mb || 0) : 0, - anon_mb: procOwner ? Number(procOwner.anon_mb || 0) : 0, - file_mb: procOwner ? Number(procOwner.file_mb || 0) : 0, - majflt: procOwner ? Number(procOwner.majflt || 0) : 0 - }); - } - } else if (L.id === 1) { - // Cold cyan mass — more visible like FLYNN field - const a = (0.22 + st.heat * 0.28) * (dimUnrelated ? 0.3 : 1) * (related ? 1.3 : 1); - this.ctx.fillStyle = this.fabricKindTint(st.src.kind, st.heat, a); - this.ctx.fillRect(px, py, rw, rh); - } else { - const a = (0.1 + st.heat * 0.14) * (dimUnrelated ? 0.35 : 1); - this.ctx.fillStyle = this.fabricKindTint(st.src.kind, st.heat * 0.7, a); - this.ctx.fillRect(px, py, rw, rh); + const rh = Math.max(2, Math.min(cellH - 2.2, cellW * 0.42)); + const ox = this.parallaxX * 0.12; + const oy = this.parallaxY * 0.08; + + // Cold mass: short dashes snapped to the lattice. Not a third layer of grain. + for (let ry = 0; ry < rows; ry++) { + let cx = 0; + while (cx < cols) { + const st = cellState[ry * cols + cx]; + if (!st || st.layer !== 1 || st.lit) { + cx += 1; + continue; + } + const start = cx; + let heat = st.heat; + let run = 1; + const want = 1 + ((st.seed >>> 8) % 3); + while (run < want && cx + 1 < cols) { + const nxt = cellState[ry * cols + cx + 1]; + if (!nxt || nxt.layer !== 1 || nxt.lit) break; + cx += 1; + run += 1; + heat = Math.max(heat, nxt.heat); + } + const related = focusKind + && (st.src.kind === focusKind || st.src.rowId === focusRow); + const dimUnrelated = focusKind && !related; + const px = viewX + start * cellW + 0.7; + const py = viewY + ry * cellH + (cellH - rh) * 0.5; + const a = (0.14 + heat * 0.2) * (dimUnrelated ? 0.28 : 1) * (related ? 1.25 : 1); + this.ctx.fillStyle = this.fabricKindTint(st.src.kind, heat, a); + this.ctx.fillRect(px, py, Math.max(2, run * cellW - 1.6), rh); + cx += 1 + ((st.seed >>> 12) % 2); + } + } + + // Hot noise: merge neighbours into horizontal runs. Flynn clusters / cclabs streaks. + for (let ry = 0; ry < rows; ry++) { + let cx = 0; + while (cx < cols) { + const st = cellState[ry * cols + cx]; + if (!st || !st.lit) { + cx += 1; + continue; + } + const start = cx; + let heat = st.heat; + while (cx + 1 < cols && cellState[ry * cols + cx + 1] && cellState[ry * cols + cx + 1].lit) { + cx += 1; + heat = Math.max(heat, cellState[ry * cols + cx].heat); + } + const run = cx - start + 1; + const related = focusKind + && (st.src.kind === focusKind || st.src.rowId === focusRow); + const dimUnrelated = focusKind && !related; + const px = viewX + start * cellW + ox + 0.6; + const py = viewY + ry * cellH + oy + (cellH - rh) * 0.5; + const usedW = Math.max(2, run * cellW - 1.2); + const thr = thrashAtm * 0.25; + const r = Math.floor(235 + 20 * heat); + const g = Math.floor(240 + 12 * heat - thr * 30); + const b = Math.floor(200 + 30 * heat + thr * 25); + const bright = (0.72 + heat * 0.28) * (dimUnrelated ? 0.25 : 1); + this.ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${bright.toFixed(3)})`; + this.ctx.fillRect(px, py, usedW, rh); + this.ctx.fillStyle = `rgba(255, 255, 230, ${(0.1 * bright).toFixed(3)})`; + this.ctx.fillRect(px - 0.4, py - 0.4, usedW + 0.8, rh + 0.8); + if (related) { + this.ctx.strokeStyle = 'rgba(255, 252, 220, 0.95)'; + this.ctx.lineWidth = 1; + this.ctx.strokeRect(px + 0.5, py + 0.5, usedW - 1, rh - 1); + } + if (heat > 0.55 || run >= 3) { + hotspotPoints.push({ x: px + usedW * 0.5, y: py + rh * 0.5, heat }); + } + for (let k = start; k <= cx; k += 1) { + const cell = cellState[ry * cols + k]; + const procOwner = weightedProcPool.length + ? weightedProcPool[(Math.abs(k * 97 + ry * 53 + (cell.seed >>> 4)) % weightedProcPool.length)] + : null; + if (this.memoryFabricHits.length < 1200) { + this.memoryFabricHits.push({ + x: viewX + k * cellW + ox, y: py, w: cellW, h: rh, + heat: cell.heat, + kind: cell.src.kind, + rowId: cell.src.rowId, + label: cell.src.label, + pid: procOwner ? Number(procOwner.pid || 0) : null, + name: procOwner ? String(procOwner.name || 'proc') : null, + role: procOwner ? String(procOwner.role || 'userspace') : null, + pressure_score: procOwner ? Number(procOwner.pressure_score || 0) : 0, + rss_mb: procOwner ? Number(procOwner.rss_mb || 0) : 0, + swap_mb: procOwner ? Number(procOwner.swap_mb || 0) : 0, + anon_mb: procOwner ? Number(procOwner.anon_mb || 0) : 0, + file_mb: procOwner ? Number(procOwner.file_mb || 0) : 0, + majflt: procOwner ? Number(procOwner.majflt || 0) : 0 + }); } } + cx += 1; } - }); + } this.ctx.restore(); - // Stall smear — horizontal pressure band when MM is busy (atmosphere only) - if (stallAtm > 0.08) { + if (stallAtm > 0.18) { this.ctx.save(); this.ctx.beginPath(); this.ctx.rect(viewX, viewY, viewW, viewH); this.ctx.clip(); - const smearY = viewY + viewH * (0.42 + 0.04 * Math.sin(this.tick * 0.01)); - const smearH = viewH * (0.1 + stallAtm * 0.12); + const smearY = viewY + viewH * (0.44 + 0.02 * Math.sin(this.tick * 0.01)); + const smearH = viewH * (0.06 + stallAtm * 0.06); const smear = this.ctx.createLinearGradient(viewX, smearY, viewX, smearY + smearH); smear.addColorStop(0, 'rgba(230, 240, 180, 0)'); - smear.addColorStop(0.5, `rgba(230, 240, 180, ${(0.04 + stallAtm * 0.1).toFixed(3)})`); + smear.addColorStop(0.5, `rgba(230, 240, 180, ${(0.02 + stallAtm * 0.05).toFixed(3)})`); smear.addColorStop(1, 'rgba(230, 240, 180, 0)'); this.ctx.fillStyle = smear; this.ctx.fillRect(viewX, smearY, viewW, smearH); @@ -1101,13 +1117,11 @@ class MemorySubsystemVisualization { this.ctx.rect(viewX, viewY, viewW, viewH); this.ctx.clip(); this.ctx.globalCompositeOperation = 'lighter'; - const bloomOx = this.parallaxX; - const bloomOy = this.parallaxY; hotspotPoints.forEach((p, i) => { - if (i % 4 !== 0) return; - const rr = 5 + p.heat * 12; + if (i % 7 !== 0) return; + const rr = 3 + p.heat * 6; const glow = this.ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, rr); - glow.addColorStop(0, `rgba(255, 255, 230, ${(0.16 + p.heat * 0.26).toFixed(3)})`); + glow.addColorStop(0, `rgba(255, 255, 230, ${(0.06 + p.heat * 0.1).toFixed(3)})`); glow.addColorStop(1, 'rgba(40, 120, 100, 0)'); this.ctx.fillStyle = glow; this.ctx.beginPath(); @@ -1117,6 +1131,39 @@ class MemorySubsystemVisualization { this.ctx.restore(); } + // Numeric dust — real meminfo / PSI / reclaim, locked to the mid-band. + this.ctx.save(); + this.ctx.beginPath(); + this.ctx.rect(viewX, viewY, viewW, viewH); + this.ctx.clip(); + const dustVals = [ + Number(summary.cached_mb || 0).toFixed(0), + Number(summary.anon_mb || 0).toFixed(0), + Number(summary.available_mb || 0).toFixed(0), + Number(summary.slab_mb || 0).toFixed(0), + psiSome.toFixed(1), + psiFull.toFixed(1), + Number(summary.used_percent || 0).toFixed(1), + Number(summary.swap_percent || 0).toFixed(1), + reclaimDelta > 0 ? String(Math.round(reclaimDelta)) : null, + Number(summary.active_mb || 0).toFixed(0), + Number(summary.inactive_mb || 0).toFixed(0) + ].filter((v) => v !== null && v !== '0' && v !== '0.0'); + this.ctx.font = '7px "Share Tech Mono", monospace'; + this.ctx.textAlign = 'left'; + const dustN = Math.min(16, dustVals.length * 3); + for (let i = 0; i < dustN; i++) { + const seed = ((i + 7) * 1103515245 + Math.floor(this.tick * 0.02) * 12345) >>> 0; + const nx = ((seed & 0xffff) / 0xffff) - 0.5; + const ny = (((seed >>> 16) & 0xffff) / 0xffff) - 0.5; + const dx = viewX + viewW * (0.5 + nx * 0.62); + const dy = viewY + viewH * (0.5 + ny * 0.28); + const a = 0.18 + ((seed >>> 8) % 20) / 100; + this.ctx.fillStyle = `rgba(190, 220, 230, ${a.toFixed(3)})`; + this.ctx.fillText(dustVals[i % dustVals.length], dx, dy); + } + this.ctx.restore(); + const vignette = this.ctx.createRadialGradient(centerX, centerY, Math.min(viewW, viewH) * 0.2, centerX, centerY, Math.max(viewW, viewH) * 0.75); vignette.addColorStop(0, 'rgba(0, 0, 0, 0)'); vignette.addColorStop(0.7, 'rgba(0, 0, 0, 0.08)'); @@ -1180,6 +1227,21 @@ class MemorySubsystemVisualization { viewY + 14 ); + if (reclaimDelta > 0) { + this.ctx.save(); + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + this.ctx.font = '11px "Share Tech Mono", monospace'; + const capY = viewY + viewH - (hero ? 118 : 36); + this.ctx.fillStyle = 'rgba(160, 230, 235, 0.88)'; + this.ctx.fillText('reclaiming pages', centerX - 62, capY); + this.ctx.fillStyle = 'rgba(236, 244, 200, 0.82)'; + this.ctx.fillText('in the background.', centerX + 78, capY); + this.ctx.restore(); + this.ctx.textAlign = 'start'; + this.ctx.textBaseline = 'alphabetic'; + } + // Field legend — what this plane means const legY = viewY + viewH - (hero ? 96 : 18); this.ctx.fillStyle = 'rgba(0, 0, 0, 0.35)'; diff --git a/static/js/network-stack.js b/static/js/network-stack.js index 87ae735..1aaf10a 100644 --- a/static/js/network-stack.js +++ b/static/js/network-stack.js @@ -1,7 +1,7 @@ // Network Stack Visualization - vertical packet flow through Linux networking layers // Version: 11 — Netfilter morph (syntax-safe) + hooks/conntrack/verdict -debugLog('🌐 network-stack.js v18 + socket-body hook: Script loading...'); +debugLog('🌐 network-stack.js v18: Script loading...'); class NetworkStackVisualization { constructor() { @@ -123,7 +123,6 @@ class NetworkStackVisualization { this.hideOsiTiles = true; // Keep the particle swarm and hero packet off — morph stays as panel ribbons. this.hideVerticalOrbs = true; - this.socketBodyMode = true; this.packetMorphEnabled = false; this.packetMorphPhase = -1; this.packetMorphCtx = null; @@ -259,10 +258,6 @@ class NetworkStackVisualization { this.camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000); this.camera.position.set(0, 1.2, 13.5); this.camera.lookAt(0, 0, 0); - if (this.socketBodyMode) { - this.camera.position.set(0.35, 0.2, 10.6); - this.camera.lookAt(0.2, 0, 0); - } this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); this.renderer.setSize(window.innerWidth, window.innerHeight); @@ -276,16 +271,10 @@ class NetworkStackVisualization { this.scene.add(ambient); this.scene.add(key); - if (this.socketBodyMode && typeof this.createSocketBody === 'function') { - this.createSocketBody(); - this.createSocketBodyUI(); - } else { - this.socketBodyMode = false; - this.createLayerStack(); - this.createPacket(); - this.createFlowParticles(); - this.createOverlayUI(); - } + this.createLayerStack(); + this.createPacket(); + this.createFlowParticles(); + this.createOverlayUI(); this.addExitButton(); this.mouseMoveHandler = (event) => this.onMouseMove(event); @@ -2740,10 +2729,6 @@ class NetworkStackVisualization { if (this.exitButton && this.exitButton.parentNode) { this.exitButton.parentNode.removeChild(this.exitButton); } - if (this.socketBodyMode && document.querySelector('nav.page-nav')) { - this.exitButton = null; - return; - } const exitBtn = document.createElement('button'); exitBtn.textContent = 'EXIT VIEW'; exitBtn.className = 'network-stack-exit-button'; @@ -2952,9 +2937,7 @@ class NetworkStackVisualization { const driverLevel = classify(driverTxQ, 120, 300); const nicLevel = classify(nicErrTotal, 5, 20); - if (this.socketBodyMode && typeof this.updateSocketTitle === 'function') { - this.updateSocketTitle(); - } else if (this.flowNode) { + if (this.flowNode) { if (flow) { const remote = flow.remote || 'remote'; this.flowNode.innerHTML = ''; @@ -3182,15 +3165,7 @@ class NetworkStackVisualization { } fetchTelemetry() { - const follow = this.flowFollow || (this.parseFlowFollowFromUrl && this.parseFlowFollowFromUrl()); - let telemetryUrl = '/api/network-stack-realtime'; - if (follow && follow.remote) { - const params = new URLSearchParams(); - params.set('remote', follow.remote); - if (follow.local) params.set('local', follow.local); - telemetryUrl += '?' + params.toString(); - } - return window.fetchJson(telemetryUrl, { cache: 'no-store' }, { + return window.fetchJson('/api/network-stack-realtime', { cache: 'no-store' }, { timeoutMs: 6000, suppressToast: true, context: 'network-stack-realtime' @@ -3990,7 +3965,6 @@ class NetworkStackVisualization { e.stopPropagation(); const index = Number(el.getAttribute('data-route-index')); this.openIpKernelMorph({ kind: 'route', index }); - this.openIpEntryCard('route', index, el); }); }); root.querySelectorAll('.ns-ip-neigh-row').forEach((el) => { @@ -3998,7 +3972,6 @@ class NetworkStackVisualization { e.stopPropagation(); const index = Number(el.getAttribute('data-neigh-index')); this.openIpKernelMorph({ kind: 'neigh', index }); - this.openIpEntryCard('neigh', index, el); }); }); const back = root.querySelector('.ns-ip-back-puzzle'); @@ -4045,7 +4018,7 @@ class NetworkStackVisualization { `).join(''); const morph = this.ipMorphTarget ? this.buildIpKernelMorphHtml(this.ipMorphTarget) : `
- tip: click a route or neigh row — the card is the object, morph is the translation + tip: click a route or neigh row to watch IP → kernel translation
`; const html = `
@@ -4077,21 +4050,6 @@ class NetworkStackVisualization { } } - openIpEntryCard(kind, index, el) { - if (!window.IpEntryCard || typeof window.IpEntryCard.open !== 'function') return; - const map = this.getIpMapData(); - const rows = kind === 'route' ? (map.routes || []) : (map.neigh || []); - const row = rows[Number(index)]; - if (!row) return; - const host = (this.container || document.body).getBoundingClientRect(); - const box = el && el.getBoundingClientRect ? el.getBoundingClientRect() : host; - window.IpEntryCard.open(kind, row, { - x: box.left - host.left + box.width, - y: box.top - host.top + box.height / 2, - clearOf: box.left - host.left + box.width + 12 - }); - } - openIpKernelMorph(target) { this.freezeIpMapSnapshot(); const next = target || { kind: 'flow' }; @@ -4173,7 +4131,7 @@ class NetworkStackVisualization { const icmpHot = n(icmp.in_errors) + n(icmp.out_errors) + n(icmp.in_dest_unreach) > 0; // Morph ribbon is center-overlay only — never inject into the right panel. const tip = compact - ? '
click route/neigh → card + morph
' + ? '
click route/neigh → IP→kernel morph (center)
' : ''; const flowDiagram = `
@@ -5484,17 +5442,15 @@ class NetworkStackVisualization { if (!this.hideVerticalOrbs) { this.updateFlowParticles(dt); } - if (!this.socketBodyMode) { - this.updateLayerStrips(dt); - this.updatePathHopRig(dt); - this.updatePacketLifecycleUI(); - const t = now * 0.00025; - this.camera.position.x = Math.sin(t) * 0.9; - this.camera.position.z = 13.2 + Math.cos(t) * 0.35; - this.camera.lookAt(0, 0, 0); - } else if (typeof this.updateSocketBody === 'function') { - this.updateSocketBody(dt); - } + this.updateLayerStrips(dt); + this.updatePathHopRig(dt); + this.updatePacketLifecycleUI(); + + // Gentle camera drift for cinematic depth. + const t = now * 0.00025; + this.camera.position.x = Math.sin(t) * 0.9; + this.camera.position.z = 13.2 + Math.cos(t) * 0.35; + this.camera.lookAt(0, 0, 0); // Overlay updates must never block the 3D render: a bug in connector // projection should at worst drop the leader lines, not blank the scene. @@ -5745,9 +5701,6 @@ class NetworkStackVisualization { } deactivate() { - if (window.IpEntryCard && typeof window.IpEntryCard.close === 'function') { - window.IpEntryCard.close(); - } this.isActive = false; this.pathHopPending = null; this.disposePathHopRig(); diff --git a/static/js/right-semicircle-menu.js b/static/js/right-semicircle-menu.js index 8a2b463..63afcec 100644 --- a/static/js/right-semicircle-menu.js +++ b/static/js/right-semicircle-menu.js @@ -4,7 +4,7 @@ class RightSemicircleMenuManager { constructor() { debugLog('RightSemicircleMenuManager constructor called'); this.menuItems = [ - { id: 'scheduler', icon: '/static/images/scheduler.svg', label: 'Crypto' }, + { id: 'scheduler', icon: '/static/images/crypto-api.svg?v=2', label: 'Crypto' }, { id: 'processes', icon: '/static/images/process-manager.svg', label: 'Processes' }, { id: 'memory', icon: '/static/images/memory-manager.svg', label: 'Memory' }, { id: 'files', icon: '/static/images/file-system.svg', label: 'Files' }, diff --git a/static/js/syscall-selector.js b/static/js/syscall-selector.js new file mode 100644 index 0000000..9623f01 --- /dev/null +++ b/static/js/syscall-selector.js @@ -0,0 +1,399 @@ +// Small amber-on-black schematic: elevator selector as sys_call_table. +// Wireframe / robot-blueprint style — sits beside a menu as a visual decode. +const SyscallSelector = (() => { + const SLOTS = [ + { nr: 0, name: "read" }, + { nr: 1, name: "write" }, + { nr: 3, name: "close" }, + { nr: 7, name: "poll" }, + { nr: 9, name: "mmap" }, + { nr: 56, name: "clone" }, + { nr: 73, name: "ppoll" }, + { nr: 202, name: "futex" }, + { nr: 257, name: "openat" }, + { nr: 270, name: "epoll_wait" }, + { nr: 281, name: "epoll_pwait" }, + { nr: 318, name: "getrandom" }, + ]; + + // Compact schematic — drone-icon scale, not a full machine plate. + const SCH = { x: 28, y: 56, w: 120, h: 320 }; + const MENU_X = 180; + const W = 560; + const H = 420; + const AMBER = "#e2a33e"; + const AMBER_DIM = "rgba(226,163,62,0.28)"; + const AMBER_MID = "rgba(226,163,62,0.55)"; + const AMBER_HOT = "#f0c56a"; + + const state = { + live: new Map(), + focus: null, + ts: "", + }; + + let svg = null; + + function clip(text, n) { + const s = String(text || ""); + return s.length > n ? `${s.slice(0, n - 1)}…` : s; + } + + function liveFor(slot) { + const byName = state.live.get(slot.name) || state.live.get(String(slot.name).toLowerCase()); + if (byName) return byName; + for (const v of state.live.values()) { + if (Number(v.nr) === Number(slot.nr)) return v; + } + return null; + } + + function hotness(slot) { + const live = liveFor(slot); + if (!live) return 0; + return Math.max(0.4, Math.min(1, 0.4 + live.count / 10)); + } + + function pegY(i) { + const top = SCH.y + 36; + const bot = SCH.y + SCH.h - 28; + return top + (i / Math.max(1, SLOTS.length - 1)) * (bot - top); + } + + function build() { + const host = d3.select("#syscall-selector"); + host.selectAll("*").remove(); + svg = host + .append("svg") + .attr("viewBox", `0 0 ${W} ${H}`) + .attr("class", "syscall-selector-svg"); + + // Title strip + svg.append("text") + .attr("x", 28) + .attr("y", 28) + .attr("fill", AMBER) + .attr("font-size", 11) + .attr("font-family", "Share Tech Mono, monospace") + .attr("letter-spacing", "0.18em") + .text("SYS_CALL_TABLE"); + + svg.append("text") + .attr("x", 28) + .attr("y", 44) + .attr("fill", AMBER_MID) + .attr("font-size", 9) + .attr("font-family", "Share Tech Mono, monospace") + .text("SELECTOR · VISUAL DECODE"); + + // --- Schematic (left): vertical spine + side pegs, robot wireframe --- + const sch = svg.append("g").attr("class", "schematic"); + + // Soft frame, thin — like HUD glass + sch.append("rect") + .attr("x", SCH.x) + .attr("y", SCH.y) + .attr("width", SCH.w) + .attr("height", SCH.h) + .attr("fill", "none") + .attr("stroke", AMBER_DIM) + .attr("stroke-width", 1); + + // Corner ticks + const tick = 8; + [ + [SCH.x, SCH.y], + [SCH.x + SCH.w, SCH.y], + [SCH.x, SCH.y + SCH.h], + [SCH.x + SCH.w, SCH.y + SCH.h], + ].forEach(([cx, cy], idx) => { + const dx = idx % 2 === 0 ? tick : -tick; + const dy = idx < 2 ? tick : -tick; + sch.append("path") + .attr("d", `M${cx} ${cy + dy} V${cy} H${cx + dx}`) + .attr("fill", "none") + .attr("stroke", AMBER_MID) + .attr("stroke-width", 1); + }); + + // Spine (table axis) — vertical rod like the cylinder selector + const spineX = SCH.x + SCH.w / 2; + sch.append("line") + .attr("class", "spine") + .attr("x1", spineX) + .attr("x2", spineX) + .attr("y1", SCH.y + 22) + .attr("y2", SCH.y + SCH.h - 16) + .attr("stroke", AMBER) + .attr("stroke-width", 1.4); + + // Caps on spine (robot joint style) + [SCH.y + 22, SCH.y + SCH.h - 16].forEach((yy) => { + sch.append("circle") + .attr("cx", spineX) + .attr("cy", yy) + .attr("r", 3) + .attr("fill", "none") + .attr("stroke", AMBER) + .attr("stroke-width", 1); + }); + + // Contact bus on the right edge of schematic (handlers) + sch.append("line") + .attr("x1", SCH.x + SCH.w - 14) + .attr("x2", SCH.x + SCH.w - 14) + .attr("y1", SCH.y + 28) + .attr("y2", SCH.y + SCH.h - 22) + .attr("stroke", AMBER_DIM) + .attr("stroke-width", 1) + .attr("stroke-dasharray", "2 3"); + + sch.append("text") + .attr("x", spineX) + .attr("y", SCH.y + 14) + .attr("text-anchor", "middle") + .attr("fill", AMBER_MID) + .attr("font-size", 7) + .attr("font-family", "Share Tech Mono, monospace") + .attr("letter-spacing", "0.12em") + .text("TABLE"); + + const pegs = sch.append("g").attr("class", "pegs"); + const pegEnter = pegs.selectAll("g.peg").data(SLOTS).enter().append("g") + .attr("class", (d, i) => `peg peg-${i}`) + .style("cursor", "pointer") + .on("click", (event, d) => { + const i = SLOTS.indexOf(d); + state.focus = state.focus === i ? null : i; + paint(); + renderMenu(); + }); + + pegEnter.append("line").attr("class", "peg-arm"); + pegEnter.append("circle").attr("class", "peg-joint").attr("r", 2.2).attr("fill", "none"); + pegEnter.append("circle").attr("class", "peg-tip").attr("r", 2.4).attr("fill", "none"); + pegEnter.append("line").attr("class", "peg-contact"); // tip → bus when hot + + // --- Menu (right): Westworld-style stacked bars --- + svg.append("g").attr("class", "menu"); + + svg.append("text") + .attr("x", MENU_X) + .attr("y", SCH.y + 12) + .attr("fill", AMBER_MID) + .attr("font-size", 8) + .attr("font-family", "Share Tech Mono, monospace") + .attr("letter-spacing", "0.14em") + .text("DISPATCH ENTRIES"); + + // Footer decode key + svg.append("text") + .attr("x", 28) + .attr("y", H - 18) + .attr("fill", AMBER_DIM) + .attr("font-size", 9) + .attr("font-family", "Share Tech Mono, monospace") + .attr("class", "footer-live") + .text(""); + + svg.append("text") + .attr("x", 28) + .attr("y", H - 4) + .attr("fill", AMBER_DIM) + .attr("font-size", 8) + .attr("font-family", "Share Tech Mono, monospace") + .text("SPINE = table[] · PEG = nr · TILT + CONTACT = active dispatch"); + + paint(); + renderMenu(); + } + + function paint() { + if (!svg) return; + const spineX = SCH.x + SCH.w / 2; + const busX = SCH.x + SCH.w - 14; + + svg.selectAll("g.peg").each(function (slot, i) { + const g = d3.select(this); + const y = pegY(i); + const hot = hotness(slot); + const focused = state.focus === i; + const active = hot > 0 || focused; + + // Idle: short peg to the right. Active: longer arm tilted up toward bus. + const idleLen = 22; + const hotLen = 42; + const len = idleLen + (hotLen - idleLen) * (focused ? Math.max(hot, 0.85) : hot); + // Tilt sideways-up when engaged (degrees from horizontal) + const tilt = active ? -28 * (focused ? Math.max(hot, 0.7) : hot) : 0; + const rad = (tilt * Math.PI) / 180; + const tipX = spineX + Math.cos(rad) * len; + const tipY = y + Math.sin(rad) * len; + + const stroke = active ? AMBER_HOT : AMBER_MID; + const width = active ? 1.35 : 1; + + g.select(".peg-arm") + .attr("x1", spineX) + .attr("y1", y) + .attr("x2", tipX) + .attr("y2", tipY) + .attr("stroke", stroke) + .attr("stroke-width", width); + + g.select(".peg-joint") + .attr("cx", spineX) + .attr("cy", y) + .attr("stroke", stroke) + .attr("stroke-width", 1); + + g.select(".peg-tip") + .attr("cx", tipX) + .attr("cy", tipY) + .attr("stroke", active ? AMBER_HOT : AMBER_DIM) + .attr("stroke-width", 1) + .attr("fill", active ? AMBER : "none") + .attr("fill-opacity", active ? 0.85 : 0); + + // Contact line to bus only when engaged + g.select(".peg-contact") + .attr("x1", tipX) + .attr("y1", tipY) + .attr("x2", busX) + .attr("y2", tipY) + .attr("stroke", active ? AMBER : "transparent") + .attr("stroke-width", 0.8) + .attr("stroke-dasharray", active ? "1.5 2" : null) + .attr("opacity", active ? 0.7 : 0); + }); + } + + function renderMenu() { + if (!svg) return; + const menu = svg.select("g.menu"); + const rowH = 24; + const startY = SCH.y + 28; + + const rows = menu.selectAll("g.menu-row").data(SLOTS, (d) => d.nr); + const enter = rows.enter().append("g") + .attr("class", "menu-row") + .style("cursor", "pointer") + .on("click", (event, d) => { + const i = SLOTS.indexOf(d); + state.focus = state.focus === i ? null : i; + paint(); + renderMenu(); + }); + + enter.append("rect").attr("class", "menu-bar"); + enter.append("circle").attr("class", "menu-dot"); + enter.append("text").attr("class", "menu-nr"); + enter.append("text").attr("class", "menu-name"); + enter.append("text").attr("class", "menu-meta"); + + const all = enter.merge(rows); + all.attr("transform", (d, i) => `translate(${MENU_X},${startY + i * rowH})`); + + all.each(function (slot, i) { + const g = d3.select(this); + const live = liveFor(slot); + const hot = hotness(slot) > 0; + const focused = state.focus === i; + const on = hot || focused; + + g.select(".menu-bar") + .attr("x", 0) + .attr("y", 0) + .attr("width", 340) + .attr("height", rowH - 4) + .attr("fill", on ? "rgba(226,163,62,0.08)" : "rgba(226,163,62,0.03)") + .attr("stroke", on ? AMBER_MID : AMBER_DIM) + .attr("stroke-width", focused ? 1.2 : 0.6); + + g.select(".menu-dot") + .attr("cx", 14) + .attr("cy", 10) + .attr("r", 3.5) + .attr("fill", hot ? AMBER : "none") + .attr("stroke", AMBER) + .attr("stroke-width", 1); + + g.select(".menu-nr") + .attr("x", 28) + .attr("y", 13) + .attr("fill", AMBER_MID) + .attr("font-size", 9) + .attr("font-family", "Share Tech Mono, monospace") + .text(String(slot.nr).padStart(3, "0")); + + g.select(".menu-name") + .attr("x", 58) + .attr("y", 13) + .attr("fill", on ? AMBER_HOT : AMBER) + .attr("font-size", 10) + .attr("font-family", "Share Tech Mono, monospace") + .attr("letter-spacing", "0.06em") + .text(slot.name.toUpperCase()); + + g.select(".menu-meta") + .attr("x", 328) + .attr("y", 13) + .attr("text-anchor", "end") + .attr("fill", AMBER_MID) + .attr("font-size", 8) + .attr("font-family", "Share Tech Mono, monospace") + .text(live ? `×${live.count}` : "—"); + }); + + rows.exit().remove(); + + const liveNames = [...new Set([...state.live.keys()].filter((k) => k === k.toLowerCase() || !state.live.has(k.toLowerCase())))] + .slice(0, 5) + .join(" · "); + svg.select(".footer-live").text( + state.ts + ? `SAMPLE ${state.ts}${liveNames ? ` ${clip(liveNames.toUpperCase(), 48)}` : " NO PARKED"}` + : "WAITING FOR /PROC SAMPLE…" + ); + } + + function ingest(data) { + const map = new Map(); + const rows = data && Array.isArray(data.syscalls) ? data.syscalls : []; + rows.forEach((row) => { + const name = String((row && row.name) || "").trim(); + if (!name) return; + const entry = { + name, + nr: row.nr, + count: Number(row.count) || 0, + waiters: Array.isArray(row.waiters) ? row.waiters : [], + }; + map.set(name, entry); + map.set(name.toLowerCase(), entry); + }); + state.live = map; + state.ts = data && data.timestamp + ? new Date(data.timestamp).toTimeString().slice(0, 8) + : ""; + paint(); + renderMenu(); + } + + function poll() { + fetch("/api/syscalls-realtime", { cache: "no-store" }) + .then((r) => r.json()) + .then(ingest) + .catch(() => {}); + } + + function start() { + build(); + poll(); + setInterval(poll, 1500); + } + + return { start }; +})(); + +document.addEventListener("DOMContentLoaded", () => SyscallSelector.start());