[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
415
application/state/useServerStats.ts
Normal file
415
application/state/useServerStats.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user