↑ 0 B/s
↓ 0 B/s
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index d52bb4c..759e37a 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -285,6 +285,7 @@ const ui = {
metricHostKey: element
('metric-host-key'),
resourceNetwork: element('resource-network'),
resourceNetworkIface: element('resource-network-iface'),
+ resourceNetworkSelect: element('resource-network-select'),
resourceNetworkRateUp: element('resource-network-rate-up'),
resourceNetworkRateDown: element('resource-network-rate-down'),
resourceNetworkSparkline: element('resource-network-sparkline'),
@@ -411,14 +412,12 @@ 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;
+// Network rate state. The backend sends cumulative byte counters per interface
+// per tick; we keep a per-interface baseline (counters + local clock timestamp)
+// so switching the selected interface — or the server reporting a different
+// set — never produces a bogus one-shot rate spike. `netIfaceList` is the
+// deduplicated, order-preserving list of interfaces present in the latest tick;
+// `netSelectedIface` stays null until the first tick resolves a default.
// 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.
@@ -426,7 +425,9 @@ const NET_SPARK_POINTS = 60;
const netSpark = new Float32Array(NET_SPARK_POINTS);
let netSparkIdx = 0;
let netSparkFilled = 0;
-let netBaselineEstablished = false;
+const netBaselines = new Map();
+let netIfaceList: string[] = [];
+let netSelectedIface: string | null = null;
const NET_RATE_UNITS = ['B', 'KB', 'MB', 'GB'] as const;
@@ -486,8 +487,55 @@ function drawNetworkSparkline(): void {
}
}
-function updateNetworkMetric(sample: NetworkSample | null, timestamp: number): void {
- if (!sample) {
+// Rebuilds the `` options only when the interface list changed, so a
+// rapid tick cannot recreate the element under an open dropdown (which would
+// drop focus). The iface label always follows the currently selected interface.
+function syncNetworkSelectOptions(): void {
+ const select = ui.resourceNetworkSelect;
+ const existing = Array.from(select.options).map((option) => option.value);
+ const unchanged = existing.length === netIfaceList.length
+ && existing.every((value, index) => value === netIfaceList[index]);
+ if (unchanged) return;
+ select.replaceChildren();
+ for (const iface of netIfaceList) {
+ const option = document.createElement('option');
+ option.value = iface;
+ option.textContent = iface;
+ select.append(option);
+ }
+}
+
+// Reflects the current interface list / selection in the toolbar: a single
+// interface keeps the plain-text label, two or more reveal the native select
+// (which itself displays the chosen interface name, so the redundant
+// plain-text label is hidden while the select is shown).
+function updateNetworkIfaceLabel(): void {
+ const select = ui.resourceNetworkSelect;
+ const multi = netIfaceList.length > 1;
+ ui.resourceNetworkIface.textContent = netSelectedIface ?? '-';
+ ui.resourceNetworkIface.hidden = multi;
+ select.hidden = !multi;
+ if (multi) {
+ syncNetworkSelectOptions();
+ select.value = netSelectedIface ?? '';
+ }
+}
+
+// Sole entry point for resetting the rate baseline. Called on interface
+// switch (manual or automatic fallback) and on full teardown; do NOT clear
+// baseline state by hand anywhere else.
+function resetNetworkBaseline(): void {
+ netBaselines.clear();
+ netSpark.fill(0);
+ netSparkIdx = 0;
+ netSparkFilled = 0;
+ drawNetworkSparkline();
+ ui.resourceNetworkRateUp.textContent = `↑ ${formatNetworkRate(0)}`;
+ ui.resourceNetworkRateDown.textContent = `↓ ${formatNetworkRate(0)}`;
+}
+
+function updateNetworkMetric(samples: NetworkSample[] | null, timestamp: number): void {
+ if (!samples || samples.length === 0) {
// 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
@@ -495,35 +543,66 @@ function updateNetworkMetric(sample: NetworkSample | null, timestamp: number): v
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;
- }
+
+ // Deduplicated, order-preserving interface list for this tick. A tick may
+ // carry the same interface twice (server fallback); only the first
+ // occurrence is kept so the selector stays stable.
+ const list: string[] = [];
+ const seen = new Set();
+ for (const sample of samples) {
+ if (seen.has(sample.iface)) continue;
+ seen.add(sample.iface);
+ list.push(sample.iface);
+ }
+ netIfaceList = list;
+
+ // Resolve the selected interface: keep the user's choice while it is still
+ // present, otherwise fall back to eth0 (or the first interface in order).
+ const previousSelection = netSelectedIface;
+ if (netSelectedIface === null || !netIfaceList.includes(netSelectedIface)) {
+ netSelectedIface = netIfaceList.includes('eth0') ? 'eth0' : netIfaceList[0];
+ }
+ if (netSelectedIface !== previousSelection) {
+ // Interface changed (user switch or automatic fallback): a fresh baseline
+ // prevents a bogus one-shot rate spike across different counters.
+ resetNetworkBaseline();
+ }
+
+ // Prune baselines for interfaces that disappeared (e.g. cable unplugged).
+ for (const iface of netBaselines.keys()) {
+ if (!netIfaceList.includes(iface)) netBaselines.delete(iface);
+ }
+
+ const selected = netSelectedIface;
+ const sample = samples.find((entry) => entry.iface === selected);
+ if (!sample) {
+ // The selected interface is absent from this tick (mid-switch); keep the
+ // current UI until the next tick provides it again.
+ return;
+ }
+
+ const baseline = netBaselines.get(selected);
+ if (!baseline) {
+ // First sample for the selected interface: stash the counters and
+ // timestamp; rate and sparkline both show zero until we have a second
+ // sample to diff against.
+ netBaselines.set(selected, { rx: sample.rxBytes, tx: sample.txBytes, ts: timestamp });
drawNetworkSparkline();
ui.resourceNetworkRateUp.textContent = `↑ ${formatNetworkRate(0)}`;
ui.resourceNetworkRateDown.textContent = `↓ ${formatNetworkRate(0)}`;
+ updateNetworkIfaceLabel();
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 deltaSeconds = Math.max(0.001, (timestamp - baseline.ts) / 1000);
+ const deltaRx = Math.max(0, sample.rxBytes - baseline.rx);
+ const deltaTx = Math.max(0, sample.txBytes - baseline.tx);
const rxRate = deltaRx / deltaSeconds;
const txRate = deltaTx / deltaSeconds;
- netLastRx = sample.rxBytes;
- netLastTx = sample.txBytes;
- netLastTs = timestamp;
+ netBaselines.set(selected, { rx: sample.rxBytes, tx: sample.txBytes, ts: 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);
@@ -534,26 +613,26 @@ function updateNetworkMetric(sample: NetworkSample | null, timestamp: number): v
// `↑` = upload (tx), `↓` = download (rx) — match the toolbar arrow convention.
ui.resourceNetworkRateUp.textContent = `↑ ${formatNetworkRate(txRate)}`;
ui.resourceNetworkRateDown.textContent = `↓ ${formatNetworkRate(rxRate)}`;
+ updateNetworkIfaceLabel();
}
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;
+ // Idempotent: only touch the DOM / state if the block is currently visible
+ // or still carries live state, so repeated resets (one per
+ // processManager.reset() call site) are cheap.
+ if (ui.resourceNetwork.hidden && netSelectedIface === null && netBaselines.size === 0) return;
ui.resourceNetwork.hidden = true;
+ netIfaceList = [];
+ netSelectedIface = null;
+ const select = ui.resourceNetworkSelect;
+ select.replaceChildren();
+ select.hidden = true;
+ // Restore the default single-card presentation: the plain-text label is
+ // visible again and the select is hidden, so a fresh session starts from a
+ // clean state regardless of the previous multi-card visibility toggle.
+ ui.resourceNetworkIface.hidden = false;
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;
+ resetNetworkBaseline();
}
const terminal = new Terminal({
@@ -1652,8 +1731,8 @@ 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.
+ // Fresh baseline for a new session: the previous run may have left network
+ // state (interface list, baselines) if the user navigated away uncleanly.
resetNetworkMetric();
applyFormDefaults();
const validationError = validateConnectForm();
@@ -2091,6 +2170,16 @@ ui.hostKeyDialog.addEventListener('cancel', (cancelEvent) => {
ui.hostKeyDialog.addEventListener('close', () => {
sendHostKeyDecision(ui.hostKeyDialog.returnValue === 'accept');
});
+// Bind the network interface selector once. Options are rebuilt by
+// syncNetworkSelectOptions() only when the interface list changes, so the
+// element keeps focus while the user interacts with it.
+ui.resourceNetworkSelect.addEventListener('change', () => {
+ const next = ui.resourceNetworkSelect.value;
+ if (!next || next === netSelectedIface) return;
+ netSelectedIface = next;
+ resetNetworkBaseline();
+ updateNetworkIfaceLabel();
+});
terminal.onData(sendTerminalData);
new ResizeObserver(() => fitTerminal(true)).observe(ui.terminalStage);
window.addEventListener('beforeunload', () => {
diff --git a/frontend/src/process-manager.ts b/frontend/src/process-manager.ts
index fcb77f9..7d0984a 100644
--- a/frontend/src/process-manager.ts
+++ b/frontend/src/process-manager.ts
@@ -50,16 +50,17 @@ interface ProcessSnapshot {
loadAverage: [number, number, number] | null;
memory: ResourceUsage | null;
swap: ResourceUsage | null;
- network: NetworkSample | 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.
+// Per-interface cumulative-byte network samples for the current tick. The
+// server forwards the raw counters for every non-virtual interface (capped at
+// MAX_NETWORK_INTERFACES); the frontend differentiates two successive samples
+// of the selected interface with a local clock to compute the upload /
+// download rate that drives the sparkline.
export interface NetworkSample {
iface: string;
rxBytes: number;
@@ -89,12 +90,17 @@ interface ProcessManagerOptions {
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;
+ // derive the per-interface upload / download rate; the first sample of the
+ // selected interface is the baseline (rate shows zero) and the next one
+ // starts drawing the sparkline.
+ onNetworkSample?: (samples: NetworkSample[] | null, timestamp: number) => void;
}
const MAX_PROCESSES = 512;
+// Upper bound on the number of network interface samples the backend may emit
+// per tick. Mirrors the shell/parser cap (src/backend) so a hostile or
+// misbehaving host cannot flood the frontend with rows.
+const MAX_NETWORK_INTERFACES = 32;
// Auto-reconnect backoff for the process monitor after an unexpected drop.
const RECONNECT_BASE_MS = 1_000;
const RECONNECT_MAX_MS = 15_000;
@@ -124,6 +130,15 @@ function isNetworkSample(value: unknown): value is NetworkSample {
&& typeof sample.txBytes === 'number' && Number.isSafeInteger(sample.txBytes) && sample.txBytes >= 0;
}
+// Guards a validated list of network samples: an array of at most
+// MAX_NETWORK_INTERFACES entries, each passing the single-sample guard.
+// The length cap mirrors the backend's shell/parser cap.
+export function isNetworkSampleList(value: unknown): value is NetworkSample[] {
+ return Array.isArray(value)
+ && value.length <= MAX_NETWORK_INTERFACES
+ && value.every(isNetworkSample);
+}
+
function isProcess(value: unknown): value is ProcessEntry {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const process = value as Partial;
@@ -149,7 +164,7 @@ function isSnapshot(value: unknown): value is ProcessSnapshot {
&& (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!.network === null || isNetworkSample(metrics!.network));
+ && (metrics!.network === null || isNetworkSampleList(metrics!.network));
}
function formatPercent(value: number | null): string {
@@ -194,7 +209,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 readonly onNetworkSample: ((samples: NetworkSample[] | null, timestamp: number) => void) | undefined;
private socket: WebSocket | null = null;
private generation = 0;
private snapshot: ProcessSnapshot | null = null;
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 11763c2..cfc8b52 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -1096,6 +1096,42 @@ button:disabled {
letter-spacing: 0.02em;
}
+/* Multi-interface selector: a compact native that sits next to the
+ iface label. Hidden by default (single-interface ticks keep the plain text
+ label); JS reveals it once a tick reports two or more interfaces. */
+.resource-network .resource-network-select {
+ flex: 0 1 auto;
+ min-width: 52px;
+ max-width: 110px;
+ padding: 0 4px;
+ color: #d4e0e5;
+ font-family: var(--mono);
+ font-size: 10px;
+ line-height: 1.4;
+ background: #182530;
+ border: 1px solid #263642;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+/* Explicit colors (bugfix): the narrow-viewport overlay turns the
+ itself into color: transparent; some mobile browsers (Android
+ Chrome / WebView) make the popped-up option list INHERIT that transparent
+ color, which rendered as white-on-white (invisible) options. Giving
+ an explicit dark theme — same palette as the desktop select — keeps the
+ native picker readable on every platform. Note: iOS Safari renders its own
+ system wheel picker that is not styleable via CSS (platform limitation). */
+.resource-network .resource-network-select option {
+ background-color: #182530;
+ color: #d4e0e5;
+}
+
+.resource-network .resource-network-select:hover,
+.resource-network .resource-network-select:focus-visible {
+ border-color: var(--accent);
+ outline: none;
+}
+
/* 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. */
@@ -2696,11 +2732,40 @@ button:disabled {
display: flex;
}
+ /* Positioning context for the invisible interface-picker overlay. */
+ .resource-network .resource-metric-line {
+ position: relative;
+ }
+
.resource-network #resource-network-iface,
.resource-network .resource-sparkline {
display: none;
}
+ /* Narrow viewport: instead of hiding the native select, turn it into an
+ invisible overlay covering the rates area. opacity:0 (NOT display:none /
+ visibility:hidden) keeps the control hit-testable and focusable, so
+ tapping the ↑/↓ rates opens the native picker; keyboard users can still
+ Tab to it and change the selection with arrow keys. */
+ .resource-network .resource-network-select {
+ position: absolute;
+ inset: 0;
+ z-index: 2; /* above the rates content so taps hit the select */
+ width: 100%;
+ height: 100%;
+ min-width: 0;
+ max-width: none;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: transparent;
+ opacity: 0;
+ cursor: pointer;
+ -webkit-appearance: none;
+ appearance: 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
diff --git a/src/backend/session.ts b/src/backend/session.ts
index bf96b5a..1604663 100644
--- a/src/backend/session.ts
+++ b/src/backend/session.ts
@@ -106,13 +106,14 @@ const PROCESS_SNAPSHOT_MARKER = '__CF_WEBSSH_TOP_SNAPSHOT__';
// 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)
// 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
+// and (best-effort) populates it with every non-virtual interface's cumulative rx/tx bytes, capped
+// at 32 rows so a host with many adapters cannot bloat the snapshot. 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 32 rows that are 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 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\"; NETN=0; 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; NETN=$((NETN+1)); [ \"$NETN\" -ge 32 ] && break; printf \"%s\\t%s\\t%s\\n\" \"$NAME\" \"$RX\" \"$TX\"; done; elif command -v netstat >/dev/null 2>&1; then netstat -ibn 2>/dev/null | grep -v \"^lo\" | grep \" \" | head -n 32 | 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 d222853..ca6e551 100644
--- a/src/backend/top-parser.ts
+++ b/src/backend/top-parser.ts
@@ -3,7 +3,7 @@ export interface ProcessMetrics {
loadAverage: [number, number, number] | null;
memory: ResourceUsage | null;
swap: ResourceUsage | null;
- network: NetworkSample | null;
+ network: NetworkSample[] | null;
}
export interface ResourceUsage {
@@ -12,10 +12,11 @@ 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.
+// Network interface samples from a single top-monitor tick. The server emits
+// one row per non-virtual interface (cumulative rx/tx byte counters, capped at
+// MAX_NETWORK_INTERFACES rows); the frontend differentiates successive samples
+// of the selected interface with a local clock to compute the upload /
+// download rate.
export interface NetworkSample {
iface: string;
rxBytes: number;
@@ -41,6 +42,10 @@ export interface ProcessSnapshot {
const ANSI_ESCAPE_RE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g;
const MAX_PROCESSES = 512;
+// Upper bound on the number of network interface rows the shell may emit per
+// tick (and the frontend guard accepts). Mirrors the shell's own 32-line cap
+// so a hostile or misbehaving host cannot flood the snapshot with rows.
+const MAX_NETWORK_INTERFACES = 32;
// Matches the network section the shell command emits after a single
// `__CF_WEBSSH_NETWORK__` marker per tick. The marker mirrors the top-snapshot
// marker (octal escapes for `_`) so the same framing primitives apply.
@@ -248,32 +253,38 @@ function parseNonNegativeInteger(value: string | undefined | null): number | nul
return Number.isSafeInteger(parsed) ? parsed : null;
}
-// Extracts the single network interface sample from the network section, which
+// Extracts the network interface samples from the network section, which
// starts at the `__CF_WEBSSH_NETWORK__` marker and runs to the end of the
// snapshot (or to a second marker if one exists). 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 {
+// run of whitespace is a valid separator. Every non-empty line is parsed in
+// order; malformed rows are skipped without aborting the section, and the
+// result is capped at MAX_NETWORK_INTERFACES. Returns [] when the marker is
+// missing, the section is empty, or every row is malformed — the caller maps
+// an empty list to null so the "no sample" semantics stay unchanged.
+export function parseNetwork(raw: string): NetworkSample[] {
const cleaned = raw.replace(ANSI_ESCAPE_RE, '').replace(/\r/g, '');
const start = cleaned.indexOf(PROCESS_NETWORK_MARKER);
- if (start < 0) return null;
+ if (start < 0) return [];
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 };
+ if (!section) return [];
+ const samples: NetworkSample[] = [];
+ for (const line of section.split('\n')) {
+ const entry = line.trim();
+ if (!entry) continue;
+ const parts = entry.split(/\s+/);
+ if (parts.length < 3) continue;
+ const iface = parts[0];
+ if (!iface) continue;
+ const rxBytes = parseNonNegativeInteger(parts[1]);
+ const txBytes = parseNonNegativeInteger(parts[2]);
+ if (rxBytes === null || txBytes === null) continue;
+ samples.push({ iface, rxBytes, txBytes });
+ if (samples.length >= MAX_NETWORK_INTERFACES) break;
+ }
+ return samples;
}
function parseProcesses(lines: string[]): ProcessEntry[] {
@@ -326,12 +337,16 @@ export function parseTopSnapshot(raw: string, timestamp = Date.now()): ProcessSn
const cleaned = raw.replace(ANSI_ESCAPE_RE, '').replace(/\r/g, '');
const lines = cleaned.split('\n');
const processes = parseProcesses(lines);
+ // An empty network section is indistinguishable from a missing one: map it to
+ // null so the "no sample" semantics (frontend keeps the block, snapshot-empty
+ // detection) stay unchanged for the single-tick case.
+ const parsedNetwork = parseNetwork(cleaned);
const metrics: ProcessMetrics = {
cpuPercent: parseCPU(lines),
loadAverage: parseLoad(lines),
memory: parseUsage(lines, 'Mem'),
swap: parseUsage(lines, 'Swap'),
- network: parseNetwork(cleaned),
+ network: parsedNetwork.length > 0 ? parsedNetwork : null,
};
if (processes.length === 0 && metrics.cpuPercent === null && metrics.loadAverage === null && metrics.memory === null && metrics.swap === null && metrics.network === null) return null;
return { metrics, processes, timestamp };