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 @@
+
+
+
-
+
+ ↑ 0 B/s
+ ↓ 0 B/s
+
+
+
+
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 };
}