Files
NetMesh/application/state/useServerStats.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

416 lines
13 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export interface DiskInfo {
capacityKey?: string; // Filesystem or shared-pool identity for deduplication
filesystemType?: string; // Filesystem type reported by df (for network/FUSE filtering)
mountPoint: string;
used: number; // Used in GB
total: number; // Total in GB
percent: number; // Usage percentage
}
export interface NetInterfaceInfo {
name: string; // Interface name (e.g., eth0, ens33)
rxBytes: number; // Total received bytes
txBytes: number; // Total transmitted bytes
rxSpeed: number; // Receive speed (bytes/sec)
txSpeed: number; // Transmit speed (bytes/sec)
}
export interface ProcessInfo {
pid: string;
memPercent: number;
command: string;
}
export interface ServerStats {
hostname?: string; // Remote hostname
osName?: string; // Remote operating system name
kernelRelease?: string; // Remote kernel release
uptimeSeconds?: number | null;// Remote uptime in seconds
loadAverage?: number[]; // 1/5/15 minute load average
cpu: number | null; // CPU usage percentage (0-100)
cpuCores: number | null; // Number of CPU cores
cpuPerCore: number[]; // Per-core CPU usage array
memTotal: number | null; // Total memory in MB
memUsed: number | null; // Used memory in MB (excluding buffers/cache)
memFree: number | null; // Free memory in MB
memBuffers: number | null; // Buffers in MB
memCached: number | null; // Cached in MB
swapTotal: number | null; // Total swap in MB
swapUsed: number | null; // Used swap in MB
topProcesses: ProcessInfo[]; // Top 10 processes by memory
diskPercent: number | null; // Disk usage percentage for root partition
diskUsed: number | null; // Disk used in GB
diskTotal: number | null; // Total disk in GB
disks: DiskInfo[]; // All mounted disks
netRxSpeed: number; // Total network receive speed (bytes/sec)
netTxSpeed: number; // Total network transmit speed (bytes/sec)
latencyMs: number | null; // TCP connection establishment latency to the SSH endpoint
netInterfaces: NetInterfaceInfo[]; // Per-interface network stats
lastUpdated: number | null; // Timestamp of last successful update
}
interface UseServerStatsOptions {
sessionId: string;
enabled: boolean; // Whether stats collection is enabled (from settings)
refreshInterval: number; // Refresh interval in seconds
isSupportedOs: boolean; // Only collect stats for Linux/macOS servers
isConnected: boolean; // Only collect when connected
}
interface ServerStatsState {
stats: ServerStats;
isLoading: boolean;
error: string | null;
}
interface ServerStatsClient {
enabled: boolean;
refreshInterval: number;
isSupportedOs: boolean;
isConnected: boolean;
}
type ServerStatsListener = (state: ServerStatsState) => void;
interface SharedServerStatsSession {
sessionId: string;
state: ServerStatsState;
listeners: Set<ServerStatsListener>;
clients: Map<number, ServerStatsClient>;
intervalRef: ReturnType<typeof setInterval> | null;
initialTimerRef: ReturnType<typeof setTimeout> | null;
connectedAt: number;
hasFetched: boolean;
fetchGeneration: number;
consecutiveFailures: number;
givenUp: boolean;
pollIntervalMs: number | null;
pollingActive: boolean;
inflight: Promise<void> | null;
inflightGeneration: number | null;
}
const CONSECUTIVE_FAILURE_LIMIT = 3;
const serverStatsSessions = new Map<string, SharedServerStatsSession>();
let nextClientId = 1;
function createEmptyServerStats(): ServerStats {
return {
hostname: undefined,
osName: undefined,
kernelRelease: undefined,
uptimeSeconds: null,
loadAverage: [],
cpu: null,
cpuCores: null,
cpuPerCore: [],
memTotal: null,
memUsed: null,
memFree: null,
memBuffers: null,
memCached: null,
swapTotal: null,
swapUsed: null,
topProcesses: [],
diskPercent: null,
diskUsed: null,
diskTotal: null,
disks: [],
netRxSpeed: 0,
netTxSpeed: 0,
latencyMs: null,
netInterfaces: [],
lastUpdated: null,
};
}
function createInitialState(): ServerStatsState {
return {
stats: createEmptyServerStats(),
isLoading: false,
error: null,
};
}
function getSharedServerStatsSession(sessionId: string): SharedServerStatsSession {
const existing = serverStatsSessions.get(sessionId);
if (existing) return existing;
const session: SharedServerStatsSession = {
sessionId,
state: createInitialState(),
listeners: new Set(),
clients: new Map(),
intervalRef: null,
initialTimerRef: null,
connectedAt: 0,
hasFetched: false,
fetchGeneration: 0,
consecutiveFailures: 0,
givenUp: false,
pollIntervalMs: null,
pollingActive: false,
inflight: null,
inflightGeneration: null,
};
serverStatsSessions.set(sessionId, session);
return session;
}
function emitServerStatsState(session: SharedServerStatsSession): void {
for (const listener of session.listeners) {
listener(session.state);
}
}
function updateServerStatsState(session: SharedServerStatsSession, patch: Partial<ServerStatsState>): void {
session.state = { ...session.state, ...patch };
emitServerStatsState(session);
}
function clearServerStatsTimers(session: SharedServerStatsSession): void {
if (session.initialTimerRef) {
clearTimeout(session.initialTimerRef);
session.initialTimerRef = null;
}
if (session.intervalRef) {
clearInterval(session.intervalRef);
session.intervalRef = null;
}
session.pollingActive = false;
session.pollIntervalMs = null;
}
function getActiveServerStatsClients(session: SharedServerStatsSession): ServerStatsClient[] {
return Array.from(session.clients.values()).filter((client) => (
client.enabled && client.isSupportedOs && client.isConnected
));
}
function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
return {
hostname: stats.hostname,
osName: stats.osName,
kernelRelease: stats.kernelRelease,
uptimeSeconds: Number.isFinite(stats.uptimeSeconds) ? Number(stats.uptimeSeconds) : null,
loadAverage: Array.isArray(stats.loadAverage) ? stats.loadAverage : [],
cpu: stats.cpu ?? null,
cpuCores: stats.cpuCores ?? null,
cpuPerCore: stats.cpuPerCore || [],
memTotal: stats.memTotal ?? null,
memUsed: stats.memUsed ?? null,
memFree: stats.memFree ?? null,
memBuffers: stats.memBuffers ?? null,
memCached: stats.memCached ?? null,
swapTotal: stats.swapTotal ?? null,
swapUsed: stats.swapUsed ?? null,
topProcesses: stats.topProcesses || [],
diskPercent: stats.diskPercent ?? null,
diskUsed: stats.diskUsed ?? null,
diskTotal: stats.diskTotal ?? null,
disks: stats.disks || [],
netRxSpeed: stats.netRxSpeed || 0,
netTxSpeed: stats.netTxSpeed || 0,
latencyMs: Number.isFinite(stats.latencyMs) ? Number(stats.latencyMs) : null,
netInterfaces: stats.netInterfaces || [],
lastUpdated: Date.now(),
};
}
function markServerStatsFailure(session: SharedServerStatsSession, message: string): void {
session.consecutiveFailures += 1;
updateServerStatsState(session, { error: message });
if (session.consecutiveFailures >= CONSECUTIVE_FAILURE_LIMIT) {
// Stop polling this session. This covers network devices that advertise
// OpenSSH but do not support the POSIX stats shell command.
session.givenUp = true;
clearServerStatsTimers(session);
}
}
async function fetchSharedServerStats(session: SharedServerStatsSession, force = false): Promise<void> {
if (!session.sessionId) return;
if (session.inflight && session.inflightGeneration === session.fetchGeneration) {
await session.inflight;
return;
}
if (session.givenUp && !force) return;
if (getActiveServerStatsClients(session).length === 0) return;
const bridge = netcattyBridge.get();
if (!bridge?.getServerStats) return;
if (force) {
session.givenUp = false;
session.consecutiveFailures = 0;
}
const generation = session.fetchGeneration;
session.inflightGeneration = generation;
updateServerStatsState(session, { isLoading: true, error: null });
const request = (async () => {
try {
const result = await bridge.getServerStats(session.sessionId);
// Discard stale responses from before a polling restart or reconnect.
if (generation !== session.fetchGeneration) return;
if (result.pending) {
// Transient "not ready yet", e.g. a Mosh session whose SSH handshake
// has not finished. Do not count this toward the hard-failure cutoff.
return;
}
if (result.success && result.stats) {
session.hasFetched = true;
session.consecutiveFailures = 0;
updateServerStatsState(session, {
stats: normalizeServerStats(result.stats),
error: null,
});
} else if (result.error) {
markServerStatsFailure(session, result.error);
} else {
markServerStatsFailure(session, 'No stats returned');
}
} catch (err) {
if (generation === session.fetchGeneration) {
markServerStatsFailure(session, err instanceof Error ? err.message : 'Unknown error');
}
} finally {
if (generation === session.fetchGeneration) {
updateServerStatsState(session, { isLoading: false });
}
}
})();
session.inflight = request;
try {
await request;
} finally {
if (session.inflight === request) {
session.inflight = null;
session.inflightGeneration = null;
}
}
}
function reconcileSharedServerStatsSession(session: SharedServerStatsSession): void {
const activeClients = getActiveServerStatsClients(session);
if (activeClients.length === 0) {
// Pause polling only. Keep the last successful stats so remounting Overview
// (or re-enabling a terminal stats strip) can paint immediately instead of
// flashing "No system overview data yet." Still clear give-up / failure so a
// later resume can auto-retry (old reset wiped stats + flags together).
clearServerStatsTimers(session);
session.givenUp = false;
session.consecutiveFailures = 0;
if (session.state.isLoading) {
updateServerStatsState(session, { isLoading: false });
}
return;
}
if (session.connectedAt === 0) {
session.connectedAt = Date.now();
}
const intervalMs = Math.max(5, Math.min(...activeClients.map((client) => client.refreshInterval))) * 1000;
const shouldRestartPolling = !session.pollingActive || session.pollIntervalMs !== intervalMs;
if (!shouldRestartPolling) return;
if (session.givenUp) return;
clearServerStatsTimers(session);
session.fetchGeneration += 1;
session.pollingActive = true;
session.pollIntervalMs = intervalMs;
const connectionAge = Date.now() - session.connectedAt;
const needsWarmup = !session.hasFetched && connectionAge < 2000;
session.initialTimerRef = setTimeout(() => {
session.initialTimerRef = null;
void fetchSharedServerStats(session);
}, needsWarmup ? 2000 : 0);
session.intervalRef = setInterval(() => {
void fetchSharedServerStats(session);
}, intervalMs);
}
function maybeDisposeSharedServerStatsSession(session: SharedServerStatsSession): void {
if (session.clients.size > 0 || session.listeners.size > 0) return;
clearServerStatsTimers(session);
session.fetchGeneration += 1;
serverStatsSessions.delete(session.sessionId);
}
export function useServerStats({
sessionId,
enabled,
refreshInterval,
isSupportedOs,
isConnected,
}: UseServerStatsOptions) {
const clientIdRef = useRef<number | null>(null);
if (clientIdRef.current === null) {
clientIdRef.current = nextClientId;
nextClientId += 1;
}
const [state, setState] = useState<ServerStatsState>(() => getSharedServerStatsSession(sessionId).state);
useEffect(() => {
const session = getSharedServerStatsSession(sessionId);
const clientId = clientIdRef.current;
if (clientId === null) return;
const listener: ServerStatsListener = (nextState) => {
setState(nextState);
};
session.listeners.add(listener);
setState(session.state);
return () => {
session.listeners.delete(listener);
session.clients.delete(clientId);
reconcileSharedServerStatsSession(session);
maybeDisposeSharedServerStatsSession(session);
};
}, [sessionId]);
useEffect(() => {
const session = getSharedServerStatsSession(sessionId);
const clientId = clientIdRef.current;
if (clientId === null) return;
session.clients.set(clientId, {
enabled,
refreshInterval,
isSupportedOs,
isConnected,
});
setState(session.state);
reconcileSharedServerStatsSession(session);
}, [sessionId, enabled, refreshInterval, isSupportedOs, isConnected]);
const refresh = useCallback(() => {
const session = getSharedServerStatsSession(sessionId);
void fetchSharedServerStats(session, true).finally(() => {
reconcileSharedServerStatsSession(session);
});
}, [sessionId]);
return {
stats: state.stats,
isLoading: state.isLoading,
error: state.error,
refresh,
};
}