6 files changed
+272
-86
No files matched your search
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user