diff --git a/frontend/index.html b/frontend/index.html index f5e0e7a..051134e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -288,9 +288,18 @@ +
-
延迟--
时长00:00
主机密钥--
diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 80b511f..d52bb4c 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -8,7 +8,7 @@ import { resolveConnectionControl, resolveConnectionPanel } from './ui-state'; import { classifyHostKey, SSH_FINGERPRINT_RE, type HostKeyPrompt } from './host-key'; import { FileManager, collectFileManagerElements } from './file-manager'; import { FileTree } from './file-tree'; -import { ProcessManager, collectProcessManagerElements } from './process-manager'; +import { ProcessManager, collectProcessManagerElements, type NetworkSample } from './process-manager'; import { resetTerminalForConnection } from './terminal-session'; import './style.css'; @@ -281,9 +281,13 @@ const ui = { sessionSubtitle: element('session-subtitle'), liveOrb: element('live-orb'), liveOrbLabel: element('live-orb-label'), - metricRtt: element('metric-rtt'), metricUptime: element('metric-uptime'), metricHostKey: element('metric-host-key'), + resourceNetwork: element('resource-network'), + resourceNetworkIface: element('resource-network-iface'), + resourceNetworkRateUp: element('resource-network-rate-up'), + resourceNetworkRateDown: element('resource-network-rate-down'), + resourceNetworkSparkline: element('resource-network-sparkline'), terminalCard: element('terminal-card'), terminalStage: element('terminal-stage'), terminalElement: element('terminal'), @@ -379,7 +383,6 @@ let connectionState: ConnectionState = 'idle'; let sessionStartedAt = 0; let uptimeTimer: number | null = null; let pingTimer: number | null = null; -let lastPingAt = 0; let pendingHostKey: HostKeyPrompt | null = null; let currentTargetKey = ''; let currentTargetLabel = ''; @@ -408,6 +411,151 @@ let fileManager: FileManager; let fileTree: FileTree; let processManager: ProcessManager; +// Network rate state. The backend only sends cumulative byte counters per +// sample; we differentiate them with a local clock (server timestamp) to +// derive the upload / download rate that drives the toolbar sparkline. +// `lastRx/lastTx/lastTs` are null until the first valid sample is received; +// on reset we clear them so the next sample is treated as a fresh baseline. +let netLastRx: number | null = null; +let netLastTx: number | null = null; +let netLastTs: number | null = null; +// 60-point ring buffer of the per-tick throughput magnitude (max of rx/tx). +// The newest sample lives at `netSparkIdx - 1`; the oldest at `netSparkIdx` +// once the buffer wraps, or at index 0 while it is still filling. +const NET_SPARK_POINTS = 60; +const netSpark = new Float32Array(NET_SPARK_POINTS); +let netSparkIdx = 0; +let netSparkFilled = 0; +let netBaselineEstablished = false; + +const NET_RATE_UNITS = ['B', 'KB', 'MB', 'GB'] as const; + +function formatNetworkRate(value: number): string { + if (!Number.isFinite(value) || value <= 0) return '0 B/s'; + let v = value; + let unitIndex = 0; + while (v >= 1024 && unitIndex < NET_RATE_UNITS.length - 1) { + v /= 1024; + unitIndex += 1; + } + const text = v < 10 ? v.toFixed(1) : v.toFixed(0); + return `${text}${NET_RATE_UNITS[unitIndex]}/s`; +} + +function drawNetworkSparkline(): void { + const canvas = ui.resourceNetworkSparkline; + const dpr = window.devicePixelRatio && window.devicePixelRatio > 0 ? window.devicePixelRatio : 1; + // Use clientWidth (CSS px) scaled by DPR for the backing store, so the + // bitmap stays crisp on Hi-DPI displays without forcing the parent layout. + const cssWidth = canvas.clientWidth || 184; + const cssHeight = canvas.clientHeight || 22; + const width = Math.max(1, Math.round(cssWidth * dpr)); + const height = Math.max(1, Math.round(cssHeight * dpr)); + if (canvas.width !== width) canvas.width = width; + if (canvas.height !== height) canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.clearRect(0, 0, width, height); + if (netSparkFilled === 0) return; + // Build a chronological copy so the rest of the math is index-stable. + const data = new Array(NET_SPARK_POINTS).fill(0); + const start = netSparkFilled < NET_SPARK_POINTS ? 0 : netSparkIdx; + for (let i = 0; i < NET_SPARK_POINTS; i += 1) { + data[i] = netSpark[(start + i) % NET_SPARK_POINTS]; + } + let minimum = data[0]; + let maximum = data[0]; + for (const v of data) { + if (v < minimum) minimum = v; + if (v > maximum) maximum = v; + } + // `span = max(mx - mn, 1)` keeps the bar heights defined even when every + // sample is identical (all bars collapse to the zero-line without div-by-zero). + const span = Math.max(maximum - minimum, 1); + const slot = width / NET_SPARK_POINTS; + // Leave a small gap between bars; floor so a 1px slot still draws a visible bar. + const barWidth = Math.max(1, Math.floor(slot * 0.7)); + ctx.fillStyle = '#69e6b4'; + for (let i = 0; i < NET_SPARK_POINTS; i += 1) { + const value = data[i]; + const normalized = (value - minimum) / span; + const barHeight = Math.max(1, Math.round(normalized * (height - 1))); + const x = Math.round(i * slot + ((slot - barWidth) / 2)); + const y = height - barHeight; + ctx.fillRect(x, y, barWidth, barHeight); + } +} + +function updateNetworkMetric(sample: NetworkSample | null, timestamp: number): void { + if (!sample) { + // The server may briefly stop sending a network section (e.g. busybox + // sh without /sys/class/net). Don't tear down the toolbar block on a + // single missing tick — only reset on a full teardown. The next valid + // sample will overwrite the UI state. + return; + } + if (ui.resourceNetwork.hidden) ui.resourceNetwork.hidden = false; + ui.resourceNetworkIface.textContent = sample.iface; + if (netLastTs === null) { + // First valid sample: stash the counters and timestamp; rate and sparkline + // both show zero until we have a second sample to diff against. + netLastRx = sample.rxBytes; + netLastTx = sample.txBytes; + netLastTs = timestamp; + if (!netBaselineEstablished) { + netSpark.fill(0); + netSparkIdx = 0; + netSparkFilled = 0; + netBaselineEstablished = true; + } + drawNetworkSparkline(); + ui.resourceNetworkRateUp.textContent = `↑ ${formatNetworkRate(0)}`; + ui.resourceNetworkRateDown.textContent = `↓ ${formatNetworkRate(0)}`; + return; + } + // `timestamp` is in milliseconds; clamp to a tiny positive value so a + // pathological zero/negative delta (rare clock skew) cannot divide-by-zero + // or produce an infinite rate. + const deltaSeconds = Math.max(0.001, (timestamp - (netLastTs ?? timestamp)) / 1000); + const deltaRx = Math.max(0, sample.rxBytes - (netLastRx ?? sample.rxBytes)); + const deltaTx = Math.max(0, sample.txBytes - (netLastTx ?? sample.txBytes)); + const rxRate = deltaRx / deltaSeconds; + const txRate = deltaTx / deltaSeconds; + netLastRx = sample.rxBytes; + netLastTx = sample.txBytes; + netLastTs = timestamp; + // Use the larger of the two rates as the sparkline magnitude so a + // quiescent direction doesn't make the chart look half-dead. + const magnitude = Math.max(rxRate, txRate); + netSpark[netSparkIdx] = magnitude; + netSparkIdx = (netSparkIdx + 1) % NET_SPARK_POINTS; + if (netSparkFilled < NET_SPARK_POINTS) netSparkFilled += 1; + drawNetworkSparkline(); + // `↑` = upload (tx), `↓` = download (rx) — match the toolbar arrow convention. + ui.resourceNetworkRateUp.textContent = `↑ ${formatNetworkRate(txRate)}`; + ui.resourceNetworkRateDown.textContent = `↓ ${formatNetworkRate(rxRate)}`; +} + +function resetNetworkMetric(): void { + // Idempotent: only touch the DOM / state if the block is currently visible, + // so repeated resets (one per processManager.reset() call site) are cheap. + if (ui.resourceNetwork.hidden && netBaselineEstablished === false && netLastTs === null) return; + ui.resourceNetwork.hidden = true; + ui.resourceNetworkIface.textContent = '-'; + ui.resourceNetworkRateUp.textContent = `↑ ${formatNetworkRate(0)}`; + ui.resourceNetworkRateDown.textContent = `↓ ${formatNetworkRate(0)}`; + const canvas = ui.resourceNetworkSparkline; + const ctx = canvas.getContext('2d'); + if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height); + netLastRx = null; + netLastTx = null; + netLastTs = null; + netSpark.fill(0); + netSparkIdx = 0; + netSparkFilled = 0; + netBaselineEstablished = false; +} + const terminal = new Terminal({ allowProposedApi: false, convertEol: false, @@ -446,6 +594,7 @@ processManager = new ProcessManager({ onError: (message) => event(message, 'process', true), onReconnect: (zh, en) => event(bilingual(zh, en), 'process'), onToast: (zh, en, kind) => toast(bilingual(zh, en), kind), + onNetworkSample: (sample, timestamp) => updateNetworkMetric(sample, timestamp), }); function terminalTheme(): Record { @@ -1188,8 +1337,7 @@ function startTimers(): void { uptimeTimer = window.setInterval(updateUptime, 1_000); pingTimer = window.setInterval(() => { if (socket?.readyState !== WebSocket.OPEN) return; - lastPingAt = performance.now(); - socket.send(JSON.stringify({ type: 'ping', ts: Date.now() })); + socket.send(JSON.stringify({ type: 'ping' })); }, PING_INTERVAL_MS); } @@ -1355,14 +1503,6 @@ function handleServerMessage(message: ServerMessage): void { event(bilingual('进程监控通道已可用。', 'Process monitor channel is available.'), 'process'); return; } - if (type === 'pong') { - if (lastPingAt > 0) ui.metricRtt.textContent = `${Math.max(1, Math.round(performance.now() - lastPingAt))} ms`; - return; - } - if (type === 'rtt') { - if (typeof message.latency === 'number') ui.metricRtt.textContent = `${Math.round(message.latency)} ms`; - return; - } if (type === 'host_key') { const fingerprint = message.fingerprint ?? ''; const keyType = message.keyType ?? ''; @@ -1477,6 +1617,7 @@ function failActiveConnection(activeSocket: WebSocket | null, closeReason: strin fileManager.reset(); fileTree?.setReady(false); processManager.reset(); + resetNetworkMetric(); clearHostKeyPrompt(); invalidateHistoryPasswordLoad(); updateConnectionStatus(displayReason); @@ -1511,6 +1652,9 @@ async function connect(): Promise { if (historyPasswordLoading) return; historyPasswordLoadGeneration++; historyPasswordLoading = false; + // Fresh baseline for a new session: the previous run may have left counters + // in `netLastRx` etc. if the user navigated away without closing cleanly. + resetNetworkMetric(); applyFormDefaults(); const validationError = validateConnectForm(); if (validationError) { @@ -1548,7 +1692,6 @@ async function connect(): Promise { decoder = createDecoder(ui.encoding.value); ui.sessionTitle.textContent = currentTargetLabel; updateConnectionStatus(localized('正在授权 Worker 会话...', 'Authorizing Worker session...')); - ui.metricRtt.textContent = '--'; ui.metricHostKey.textContent = '--'; event(bilingual(`正在连接 ${currentTargetLabel}`, `Starting ${currentTargetLabel}`), 'connect'); @@ -1620,6 +1763,7 @@ async function connect(): Promise { fileManager.reset(); fileTree?.setReady(false); processManager.reset(); + resetNetworkMetric(); clearHostKeyPrompt(); invalidateHistoryPasswordLoad(); const wasActive = connectionState === 'connected'; @@ -1665,6 +1809,7 @@ function disconnect(reason = bilingual('已由用户断开连接', 'Disconnected fileManager.reset(); fileTree?.setReady(false); processManager.reset(); + resetNetworkMetric(); clearHostKeyPrompt(); invalidateHistoryPasswordLoad(); currentExpectedFingerprint = ''; @@ -1953,6 +2098,7 @@ window.addEventListener('beforeunload', () => { fileTree?.setReady(false); fileTree?.destroy(); processManager.reset(); + resetNetworkMetric(); socket?.close(1000, 'Page closed'); }); document.addEventListener('keydown', (keyEvent) => { diff --git a/frontend/src/process-manager.ts b/frontend/src/process-manager.ts index 0370863..fcb77f9 100644 --- a/frontend/src/process-manager.ts +++ b/frontend/src/process-manager.ts @@ -50,11 +50,22 @@ interface ProcessSnapshot { loadAverage: [number, number, number] | null; memory: ResourceUsage | null; swap: ResourceUsage | null; + network: NetworkSample | null; }; processes: ProcessEntry[]; timestamp: number; } +// Single cumulative-byte network sample for the chosen interface. The +// server only forwards the raw counters; the frontend differentiates two +// successive samples with a local clock to compute the upload / download +// rate that drives the sparkline. +export interface NetworkSample { + iface: string; + rxBytes: number; + txBytes: number; +} + interface PendingKillContext { pid: number; command: string; @@ -76,6 +87,11 @@ interface ProcessManagerOptions { onError: (message: string) => void; onReconnect?: (zh: string, en: string) => void; onToast?: (zh: string, en: string, kind: 'info' | 'error') => void; + // Fired once per validated snapshot, even when the server didn't return a + // network sample. The frontend uses the local clock + successive samples to + // derive the upload / download rate; the first sample is the baseline + // (rate shows zero) and the next one starts drawing the sparkline. + onNetworkSample?: (sample: NetworkSample | null, timestamp: number) => void; } const MAX_PROCESSES = 512; @@ -100,6 +116,14 @@ function isUsage(value: unknown): value is ResourceUsage { && finitePercent(usage.percent) && usage.percent <= 100; } +function isNetworkSample(value: unknown): value is NetworkSample { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const sample = value as Partial; + return typeof sample.iface === 'string' && sample.iface.length > 0 && sample.iface.length <= 64 + && typeof sample.rxBytes === 'number' && Number.isSafeInteger(sample.rxBytes) && sample.rxBytes >= 0 + && typeof sample.txBytes === 'number' && Number.isSafeInteger(sample.txBytes) && sample.txBytes >= 0; +} + function isProcess(value: unknown): value is ProcessEntry { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const process = value as Partial; @@ -124,7 +148,8 @@ function isSnapshot(value: unknown): value is ProcessSnapshot { && (metrics!.cpuPercent === null || finitePercent(metrics!.cpuPercent)) && (metrics!.loadAverage === null || (Array.isArray(metrics!.loadAverage) && metrics!.loadAverage.length === 3 && metrics!.loadAverage.every(finitePercent))) && (metrics!.memory === null || isUsage(metrics!.memory)) - && (metrics!.swap === null || isUsage(metrics!.swap)); + && (metrics!.swap === null || isUsage(metrics!.swap)) + && (metrics!.network === null || isNetworkSample(metrics!.network)); } function formatPercent(value: number | null): string { @@ -169,6 +194,7 @@ export class ProcessManager { private readonly onError: (message: string) => void; private readonly onReconnect: ((zh: string, en: string) => void) | undefined; private readonly onToast: ((zh: string, en: string, kind: 'info' | 'error') => void) | undefined; + private readonly onNetworkSample: ((sample: NetworkSample | null, timestamp: number) => void) | undefined; private socket: WebSocket | null = null; private generation = 0; private snapshot: ProcessSnapshot | null = null; @@ -194,6 +220,7 @@ export class ProcessManager { this.onError = options.onError; this.onReconnect = options.onReconnect; this.onToast = options.onToast; + this.onNetworkSample = options.onNetworkSample; this.elements.panel.addEventListener('click', (event) => { const target = event.target as HTMLElement; const sortButton = target.closest('[data-process-sort]'); @@ -364,6 +391,11 @@ export class ProcessManager { if (isSnapshot(message)) { this.snapshot = message; this.render(); + // Fire after render so the parent can update dependent UI (sparkline + // canvas, network block visibility) using a fresh, validated sample. + // The callback is invoked even when network is null so the frontend + // can decide whether to hide the block on its own. + this.onNetworkSample?.(message.metrics.network, message.timestamp); return; } if (!message || typeof message !== 'object' || Array.isArray(message)) return; diff --git a/frontend/src/style.css b/frontend/src/style.css index 37203d5..6d617ec 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -966,12 +966,14 @@ button:disabled { flex: 0 0 auto; align-items: center; gap: 0px; + min-width: 144px; /* integer-pixel floor: 2 children × 72px to suppress sub-pixel jitter from upstream flex propagation */ } .session-metrics > div { - min-width: 53px; + min-width: 72px; /* widened from 53px to cover "01:00:00" / "999 ms" content without sub-pixel re-layout */ padding: 0 7px; border-left: 1px solid #1c2932; + font-variant-numeric: tabular-nums; /* tabular numerals — defensive even though --mono is monospace */ } .session-metrics span, @@ -984,13 +986,13 @@ button:disabled { .resource-metrics { display: flex; min-width: 0; - flex: 0 1 520px; + flex: 1 1 auto; align-items: center; - gap: 9px; + gap: 14px; } .resource-metric { - min-width: 0; + min-width: 60px; /* integer-pixel floor — absorbs sub-pixel flex-distribution jitter from sibling .session-metrics */ flex: 1 1 0; color: #a8b8c1; font-family: var(--mono); @@ -999,7 +1001,7 @@ button:disabled { } .resource-metric + .resource-metric { - padding-left: 9px; + padding-left: 12px; border-left: 1px solid #1c2932; } @@ -1069,6 +1071,70 @@ button:disabled { transition: background-color 320ms ease; } +.resource-sparkline { + display: block; + height: 12px; + margin-top: 1px; + background: #182530; + border-radius: 4px; + /* Width is now content-driven (canvas drawing buffer = 184px HTML attr), + with explicit floor/ceiling so the parent's flex algorithm has a stable + size to compute against — breaks the previous width: 100% cycle that + caused the sparkline (and surrounding metric box) to jitter on every + redraw. */ + width: auto; + min-width: 120px; /* 最小可见宽度(窄屏也不缩到看不见) */ + max-width: 100%; /* 不超过父容器(宽屏防止溢出) */ + flex: 0 0 auto; /* 钉死主轴宽度,不增不减 */ + align-self: stretch; /* cross-axis 拉满父容器高度方向 */ +} + +.resource-network .resource-metric-line > strong.resource-detail + strong.resource-detail { + color: #d4e0e5; + font-weight: 750; +} + +.resource-network #resource-network-iface { + flex: 0 0 auto; + color: #8fa2ad; + font-weight: 400; + letter-spacing: 0.02em; +} + +/* Desktop: keep the up/down rate values on a single inline row. The default + `` is inline, but the new node IDs aren't tied to .resource-detail + (which is a flex/text-align: right class), so restate the intent here. */ +.resource-network #resource-network-rate-up, +.resource-network #resource-network-rate-down { + display: inline-block; /* inline-block so min-width can lock the box */ + min-width: 10ch; /* reserve width for "↑ 999.9MB/s" so updates don't reflow */ + text-align: right; /* anchor changes to the right edge */ + font-variant-numeric: tabular-nums; /* equal-width digits ("1" == "8") */ + font-weight: 750; + white-space: nowrap; /* never wrap a single rate value */ + /* color removed — per-arrow colors follow below */ +} + +.resource-network #resource-network-rate-up { + color: #e0857a; /* upload (↑) — softer red */ +} + +.resource-network #resource-network-rate-down { + color: #82c898; /* download (↓) — softer green */ +} + +/* Desktop default: collapse the wrapper to inline so the up/down pair sits + on one row alongside iface. On narrow viewports the @media block below + promotes this wrapper to a flex column for a two-line layout. */ +.resource-network .resource-network-rates { + display: inline-flex; + align-items: baseline; + gap: 4px; + min-width: 21ch; /* up(10ch) + gap + down(10ch) keeps the box steady */ + font-variant-numeric: tabular-nums; /* propagate to children as a safety net */ + flex: 0 0 auto; /* pin width — no grow, no shrink */ +} + .session-metrics span { margin-bottom: 2px; color: #81939e; @@ -1083,6 +1149,8 @@ button:disabled { font-size: 10px; text-overflow: ellipsis; white-space: nowrap; + font-variant-numeric: tabular-nums; /* tabular numerals — pin glyph widths for digit-bound fields like RTT */ + min-width: 42px; /* integer-pixel floor for single-field value text */ } .terminal-card { @@ -2435,10 +2503,6 @@ button:disabled { padding: 0 5px; } - .session-metrics > div:first-child { - display: none; - } - } @media (max-width: 1500px) { @@ -2624,6 +2688,54 @@ button:disabled { width: 13px; height: 13px; } + + /* Narrow viewport: drop the swap metric to make room for a compact + two-line network view. The :not([hidden]) guard means the desktop + "default hidden" semantics still win when JS hides the node — no + `!important` needed, no risk of a stale placeholder leaking out. */ + .resource-metrics .resource-swap { + display: none; + } + + .resource-network:not([hidden]) { + display: flex; + } + + .resource-network #resource-network-iface, + .resource-network .resource-sparkline { + display: none; + } + + /* The wrapper becomes a vertical flex container; the strongs inside + (already `display: inline` + color/weight from the desktop rules) + stack naturally as two left-aligned rows (flex-start) so labels + line up with the rest of the compact toolbar. */ + .resource-network .resource-network-rates { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0; + line-height: 1.05; + min-width: 0; /* allow shrink on narrow screens so the toolbar doesn't overflow */ + flex: 0 0 auto; + } +} + +/* Narrow-screen fallback for the metrics row. + On wide screens (>=960px) the metrics container fills the toolbar + and each metric shares width equally (see base rules above). + On screens <960px we revert to content-driven sizing to avoid + squeezing the labels on small windows. */ +@media (max-width: 960px) { + .resource-metrics { + flex: 0 1 auto; + } + + .resource-metric { + flex: 0 1 auto; + min-width: 60px; + max-width: 320px; /* 兜底:避免极端情况下 metric 撑到 toolbar 边界 */ + } } @media (max-width: 200px) { diff --git a/src/backend/session.ts b/src/backend/session.ts index da6d978..471543d 100644 --- a/src/backend/session.ts +++ b/src/backend/session.ts @@ -92,6 +92,12 @@ const PROCESS_KILL_CHANNEL_OPEN_TIMEOUT_MS = 15_000; // channel; cap the bytes we keep per stream so one bad request cannot exhaust worker memory. const PROCESS_KILL_MAX_BUFFER_BYTES = 4 * 1024; const PROCESS_SNAPSHOT_MARKER = '__CF_WEBSSH_TOP_SNAPSHOT__'; +// Marks a single-line network block appended to the same stream as the top +// snapshot. The shell command emits `\t\t` once between two +// markers; if any step (missing /sys/class/net, unparsable netstat output, +// non-numeric counters) fails the marker is still printed but the body is +// empty so the frontend falls back to "hidden". +const PROCESS_NETWORK_MARKER = '__CF_WEBSSH_NETWORK__'; // Octal escapes keep the delimiter itself out of the command line shown by top. // `top` flags differ across platforms: Linux procps uses `-n 1` (1 iteration) + `-c` (full command line), // but on FreeBSD `-n 1` means "show 1 process" and `-w` is unsupported — so the previous @@ -105,7 +111,14 @@ const PROCESS_SNAPSHOT_MARKER = '__CF_WEBSSH_TOP_SNAPSHOT__'; // Linux: top -b -c -n 1 -w 0 || top -b -c -n 1 || top -b -n 1 // FreeBSD: top -b -a -d 1 (batch, all processes, 1 iteration) // macOS: top -l 1 -c -n 0 -s 0 (1 log sample, full command line, all processes, no delay) -const PROCESS_MONITOR_COMMAND = "LC_ALL=C LANG=C sh -c 'os=$(uname -s 2>/dev/null || echo Linux); export COLUMNS=4096; while :; do printf \"\\137\\137CF_WEBSSH_TOP_SNAPSHOT\\137\\137\\n\"; case \"$os\" in FreeBSD) top -b -a -d 1 2>/dev/null || exit 127;; Darwin) top -l 1 -c -n 0 -s 0 2>/dev/null || exit 127;; *) top -b -c -n 1 -w 0 2>/dev/null || top -b -c -n 1 2>/dev/null || top -b -n 1 2>/dev/null || exit 127;; esac; sleep 2; done'"; +// Network sampling: every loop iteration prints an empty `__CF_WEBSSH_NETWORK__` block after top +// and (best-effort) populates it with the first non-virtual interface's cumulative rx/tx bytes. +// The Linux branch reads /sys/class/net//statistics/{rx,tx}_bytes and skips lo/docker/veth/ +// bridge/tun/tailscale names so we never accidentally chart a container virtual NIC. The macOS +// fallback runs `netstat -ibn` and takes the first row that is not `lo*`. Any failure +// (no /sys/class/net, netstat missing, non-numeric counters) leaves the block empty and the +// frontend treats the tick as "no network sample". +const PROCESS_MONITOR_COMMAND = "LC_ALL=C LANG=C sh -c 'os=$(uname -s 2>/dev/null || echo Linux); export COLUMNS=4096; while :; do printf \"\\137\\137CF_WEBSSH_TOP_SNAPSHOT\\137\\137\\n\"; case \"$os\" in FreeBSD) top -b -a -d 1 2>/dev/null || exit 127;; Darwin) top -l 1 -c -n 0 -s 0 2>/dev/null || exit 127;; *) top -b -c -n 1 -w 0 2>/dev/null || top -b -c -n 1 2>/dev/null || top -b -n 1 2>/dev/null || exit 127;; esac; printf \"\\137\\137CF_WEBSSH_NETWORK\\137\\137\\n\"; if [ -d /sys/class/net ]; then for NDIR in /sys/class/net/*; do [ -d \"$NDIR\" ] || continue; NAME=${NDIR##*/}; case \"$NAME\" in lo|docker*|veth*|br-*|tun*|tailscale*) continue;; esac; RX=$(cat \"$NDIR/statistics/rx_bytes\" 2>/dev/null) || continue; TX=$(cat \"$NDIR/statistics/tx_bytes\" 2>/dev/null) || continue; case \"$RX$TX\" in *[!0-9]*) continue;; esac; printf \"%s\\t%s\\t%s\\n\" \"$NAME\" \"$RX\" \"$TX\"; break; done; elif command -v netstat >/dev/null 2>&1; then netstat -ibn 2>/dev/null | grep -v \"^lo\" | grep \"\" | head -n 1 | awk -v OFS=\"\\t\" \"{print \\$1, \\$7, \\$10}\"; fi; sleep 2; done'"; const KEEPALIVE_NAME = new TextEncoder().encode('keepalive@openssh.com'); export class SSHSession { diff --git a/src/backend/top-parser.ts b/src/backend/top-parser.ts index 91d6448..4240feb 100644 --- a/src/backend/top-parser.ts +++ b/src/backend/top-parser.ts @@ -3,6 +3,7 @@ export interface ProcessMetrics { loadAverage: [number, number, number] | null; memory: ResourceUsage | null; swap: ResourceUsage | null; + network: NetworkSample | null; } export interface ResourceUsage { @@ -11,6 +12,16 @@ export interface ResourceUsage { percent: number; } +// Single network interface sample, taken from a single top-monitor tick. The +// server only ever emits cumulative byte counters for one interface; the +// frontend differentiates successive samples with a local clock to compute +// the upload / download rate. +export interface NetworkSample { + iface: string; + rxBytes: number; + txBytes: number; +} + export interface ProcessEntry { pid: number; user: string; @@ -30,6 +41,10 @@ export interface ProcessSnapshot { const ANSI_ESCAPE_RE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g; const MAX_PROCESSES = 512; +// Matches the network section the shell command emits between successive +// `__CF_WEBSSH_NETWORK__` markers. The marker mirrors the top-snapshot marker +// (octal escapes for `_`) so the same framing primitives work for both blocks. +const PROCESS_NETWORK_MARKER = '__CF_WEBSSH_NETWORK__'; // Matches a single numeric token that may use ',' or '.' as decimal separator. const NUMBER_TOKEN_RE = /\d+(?:[.,]\d+)?/; @@ -223,6 +238,43 @@ function parseMemoryValue(value: string | undefined): number | null { return Math.round(amount * (1024 ** power)); } +// Parses a non-negative integer expressed in base 10. Returns null for empty +// strings, negative numbers, floats, hex/octal literals, or anything that does +// not fit in a safe integer. +function parseNonNegativeInteger(value: string | undefined | null): number | null { + if (!value) return null; + if (!/^\d+$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; +} + +// Extracts the single network interface sample embedded between the network +// markers. The payload is whitespace-separated (tabs inserted by `printf` on +// Linux, OFS in awk on macOS), so any run of whitespace is a valid separator. +// If the marker is missing, the section is empty, or any required field is +// invalid, returns null — the caller treats this as "no sample" and keeps the +// network block hidden. +export function parseNetwork(raw: string): NetworkSample | null { + const cleaned = raw.replace(ANSI_ESCAPE_RE, '').replace(/\r/g, ''); + const start = cleaned.indexOf(PROCESS_NETWORK_MARKER); + if (start < 0) return null; + const afterStart = start + PROCESS_NETWORK_MARKER.length; + const end = cleaned.indexOf(PROCESS_NETWORK_MARKER, afterStart); + const section = (end < 0 ? cleaned.slice(afterStart) : cleaned.slice(afterStart, end)).trim(); + if (!section) return null; + // Pick the first non-empty row; additional rows (e.g. partial dumps) are ignored. + const line = section.split('\n').map((entry) => entry.trim()).find((entry) => entry.length > 0); + if (!line) return null; + const parts = line.split(/\s+/); + if (parts.length < 3) return null; + const iface = parts[0]; + if (!iface) return null; + const rxBytes = parseNonNegativeInteger(parts[1]); + const txBytes = parseNonNegativeInteger(parts[2]); + if (rxBytes === null || txBytes === null) return null; + return { iface, rxBytes, txBytes }; +} + function parseProcesses(lines: string[]): ProcessEntry[] { // Header line starts with PID. Both Linux ("%CPU") and FreeBSD ("WCPU") headers match this. const headerLineIndex = lines.findIndex((line) => /^\s*PID\s+/i.test(line)); @@ -270,14 +322,16 @@ function parseProcesses(lines: string[]): ProcessEntry[] { } export function parseTopSnapshot(raw: string, timestamp = Date.now()): ProcessSnapshot | null { - const lines = raw.replace(ANSI_ESCAPE_RE, '').replace(/\r/g, '').split('\n'); + const cleaned = raw.replace(ANSI_ESCAPE_RE, '').replace(/\r/g, ''); + const lines = cleaned.split('\n'); const processes = parseProcesses(lines); const metrics: ProcessMetrics = { cpuPercent: parseCPU(lines), loadAverage: parseLoad(lines), memory: parseUsage(lines, 'Mem'), swap: parseUsage(lines, 'Swap'), + network: parseNetwork(cleaned), }; - if (processes.length === 0 && Object.values(metrics).every((value) => value === null)) return null; + if (processes.length === 0 && metrics.cpuPercent === null && metrics.loadAverage === null && metrics.memory === null && metrics.swap === null) return null; return { metrics, processes, timestamp }; }