Merge pull request #16 from cmliu/beta

Beta
This commit is contained in:
CMLiussss authored and GitHub committed 2026-08-03 00:32:25 +08:00
commit f878f14281
6 files changed
+272 -86

No files matched your search

+1
View File
@@ -291,6 +291,7 @@
<div class="resource-metric resource-network" id="resource-network" hidden>
<div class="resource-metric-line">
<strong id="resource-network-iface" class="resource-detail">-</strong>
<select id="resource-network-select" class="resource-network-select" hidden aria-label="网卡选择" data-i18n-aria-label-zh="网卡选择" data-i18n-aria-label-en="Network interface"></select>
<div class="resource-network-rates">
<strong id="resource-network-rate-up">↑ 0 B/s</strong>
<strong id="resource-network-rate-down">↓ 0 B/s</strong>
+136 -47
View File
@@ -285,6 +285,7 @@ const ui = {
metricHostKey: element<HTMLElement>('metric-host-key'),
resourceNetwork: element<HTMLElement>('resource-network'),
resourceNetworkIface: element<HTMLElement>('resource-network-iface'),
resourceNetworkSelect: element<HTMLSelectElement>('resource-network-select'),
resourceNetworkRateUp: element<HTMLElement>('resource-network-rate-up'),
resourceNetworkRateDown: element<HTMLElement>('resource-network-rate-down'),
resourceNetworkSparkline: element<HTMLCanvasElement>('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<string, { rx: number; tx: number; ts: number }>();
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 `<select>` 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<string>();
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<void> {
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', () => {
+25 -10
View File
@@ -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<ProcessEntry>;
@@ -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;
+65
View File
@@ -1096,6 +1096,42 @@ button:disabled {
letter-spacing: 0.02em;
}
/* Multi-interface selector: a compact native <select> 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 <option> colors (bugfix): the narrow-viewport overlay turns the
<select> 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 <option>
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
`<strong>` 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
+6 -5
View File
@@ -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/<iface>/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 <Link#N> 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/<iface>/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 <Link#N> 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 \"<Link#[0-9]>\" | 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 \"<Link#[0-9]>\" | 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 {
+39 -24
View File
@@ -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 };