feat: 添加网络监控功能,显示上传和下载速率
This commit is contained in:
1 parent
d2c49a8274
commit
c6f70ff804
6 files changed
+394
-28
No files matched your search
+10
-1
@@ -288,9 +288,18 @@
|
||||
</div>
|
||||
<progress id="resource-swap-progress" max="100" value="0" aria-label="Swap 使用率" data-i18n-aria-label-zh="Swap 使用率" data-i18n-aria-label-en="Swap usage"></progress>
|
||||
</div>
|
||||
<div class="resource-metric resource-network" id="resource-network" hidden>
|
||||
<div class="resource-metric-line">
|
||||
<strong id="resource-network-iface" class="resource-detail">-</strong>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="resource-network-sparkline" class="resource-sparkline" width="184" height="12" aria-label="网络速率" data-i18n-aria-label-zh="网络速率" data-i18n-aria-label-en="Network rate"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-metrics">
|
||||
<div><span data-i18n-zh="延迟" data-i18n-en="RTT">延迟</span><strong id="metric-rtt">--</strong></div>
|
||||
<div><span data-i18n-zh="时长" data-i18n-en="UPTIME">时长</span><strong id="metric-uptime">00:00</strong></div>
|
||||
<div><span data-i18n-zh="主机密钥" data-i18n-en="HOST KEY">主机密钥</span><strong id="metric-host-key">--</strong></div>
|
||||
</div>
|
||||
|
||||
+160
-14
@@ -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<HTMLElement>('session-subtitle'),
|
||||
liveOrb: element<HTMLElement>('live-orb'),
|
||||
liveOrbLabel: element<HTMLElement>('live-orb-label'),
|
||||
metricRtt: element<HTMLElement>('metric-rtt'),
|
||||
metricUptime: element<HTMLElement>('metric-uptime'),
|
||||
metricHostKey: element<HTMLElement>('metric-host-key'),
|
||||
resourceNetwork: element<HTMLElement>('resource-network'),
|
||||
resourceNetworkIface: element<HTMLElement>('resource-network-iface'),
|
||||
resourceNetworkRateUp: element<HTMLElement>('resource-network-rate-up'),
|
||||
resourceNetworkRateDown: element<HTMLElement>('resource-network-rate-down'),
|
||||
resourceNetworkSparkline: element<HTMLCanvasElement>('resource-network-sparkline'),
|
||||
terminalCard: element<HTMLElement>('terminal-card'),
|
||||
terminalStage: element<HTMLElement>('terminal-stage'),
|
||||
terminalElement: element<HTMLElement>('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<number>(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<string, string> {
|
||||
@@ -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<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.
|
||||
resetNetworkMetric();
|
||||
applyFormDefaults();
|
||||
const validationError = validateConnectForm();
|
||||
if (validationError) {
|
||||
@@ -1548,7 +1692,6 @@ async function connect(): Promise<void> {
|
||||
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<void> {
|
||||
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) => {
|
||||
|
||||
@@ -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<NetworkSample>;
|
||||
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<ProcessEntry>;
|
||||
@@ -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<HTMLButtonElement>('[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;
|
||||
|
||||
+121
-9
@@ -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
|
||||
`<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. */
|
||||
.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) {
|
||||
|
||||
+14
-1
@@ -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 `<iface>\t<rx>\t<tx>` 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/<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
|
||||
// (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 KEEPALIVE_NAME = new TextEncoder().encode('keepalive@openssh.com');
|
||||
|
||||
export class SSHSession {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user