feat(status-monitor): add websocket process manager and runtime metadata
extend server status payload with timezone, uptime, and process summary so the monitor sidebar can show richer at-a-glance host context. introduce process:list and process:signal websocket flows on active SSH sessions, enabling on-demand process querying and terminate/kill actions without adding new HTTP endpoints. add a dedicated process manager modal in the frontend with search, refresh, auto-refresh, and per-process actions, and wire localized labels for both english and chinese. enhance global connection fuzzy search scoring to include tag names as secondary-weight fields while preserving primary host/name relevance.
This commit is contained in:
@@ -31,6 +31,21 @@ interface ServerStatus {
|
||||
netInterface?: string;
|
||||
osName?: string;
|
||||
loadAvg?: number[];
|
||||
timezone?: string;
|
||||
uptimeSeconds?: number;
|
||||
processTotal?: number;
|
||||
processRunning?: number;
|
||||
processSleeping?: number;
|
||||
topProcesses?: Array<{
|
||||
pid: number;
|
||||
user: string;
|
||||
state: string;
|
||||
cpu: number;
|
||||
memPercent: number;
|
||||
memMb: number;
|
||||
startedAt: string;
|
||||
command: string;
|
||||
}>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@@ -50,6 +65,20 @@ interface DiskIoStats {
|
||||
|
||||
const previousNetStats = new Map<string, { rx: number; tx: number; timestamp: number }>();
|
||||
const previousDiskStats = new Map<string, { device: string; readBytes: number; writeBytes: number; timestamp: number }>();
|
||||
const monthMap: Record<string, string> = {
|
||||
Jan: '01',
|
||||
Feb: '02',
|
||||
Mar: '03',
|
||||
Apr: '04',
|
||||
May: '05',
|
||||
Jun: '06',
|
||||
Jul: '07',
|
||||
Aug: '08',
|
||||
Sep: '09',
|
||||
Oct: '10',
|
||||
Nov: '11',
|
||||
Dec: '12',
|
||||
};
|
||||
|
||||
export class StatusMonitorService {
|
||||
private clientStates: Map<string, ClientState>;
|
||||
@@ -121,6 +150,8 @@ export class StatusMonitorService {
|
||||
status.osName = nameMatch ? nameMatch[1] : (osReleaseOutput.match(/^NAME="?([^"]+)"?/m)?.[1] ?? 'Unknown');
|
||||
} catch (err) { /* noop */ }
|
||||
|
||||
await this.collectSystemTimeStatus(sshClient, status);
|
||||
|
||||
await this.collectCpuStatus(sshClient, status);
|
||||
|
||||
await this.collectMemoryStatus(sshClient, status);
|
||||
@@ -163,6 +194,7 @@ export class StatusMonitorService {
|
||||
} catch (err) { /* noop */ }
|
||||
|
||||
await this.collectNetworkStatus(sshClient, sessionId, timestamp, status);
|
||||
await this.collectProcessSummary(sshClient, status);
|
||||
} catch (error) {
|
||||
console.error(`[StatusMonitor ${sessionId}] General error fetching server status:`, error);
|
||||
}
|
||||
@@ -191,6 +223,27 @@ export class StatusMonitorService {
|
||||
status.cpuCores = await this.resolveCpuCoreCount(sshClient);
|
||||
}
|
||||
|
||||
private async collectSystemTimeStatus(sshClient: Client, status: Partial<ServerStatus>): Promise<void> {
|
||||
try {
|
||||
const [offsetOutput, timezoneOutput, uptimeOutput] = await Promise.all([
|
||||
this.executeSshCommand(sshClient, `date +"%z"`),
|
||||
this.executeSshCommand(sshClient, `date +"%Z"`),
|
||||
this.executeSshCommand(sshClient, `cat /proc/uptime | awk '{print int($1)}'`),
|
||||
]);
|
||||
|
||||
const offset = offsetOutput.trim();
|
||||
const timezone = timezoneOutput.trim();
|
||||
if (offset || timezone) {
|
||||
status.timezone = `${offset ? `GMT${offset}` : ''}${offset && timezone ? ' ' : ''}${timezone}`.trim();
|
||||
}
|
||||
|
||||
const uptimeSeconds = parseInt(uptimeOutput.trim(), 10);
|
||||
if (!isNaN(uptimeSeconds) && uptimeSeconds >= 0) {
|
||||
status.uptimeSeconds = uptimeSeconds;
|
||||
}
|
||||
} catch (err) { /* noop */ }
|
||||
}
|
||||
|
||||
private async resolveCpuCoreCount(sshClient: Client): Promise<number | undefined> {
|
||||
const parseCpuCount = (raw?: string): number | undefined => {
|
||||
if (!raw) {
|
||||
@@ -441,6 +494,58 @@ export class StatusMonitorService {
|
||||
} catch (err) { /* noop */ }
|
||||
}
|
||||
|
||||
private async collectProcessSummary(sshClient: Client, status: Partial<ServerStatus>): Promise<void> {
|
||||
const processListCommand = `ps -eo pid=,user=,state=,pcpu=,pmem=,rss=,lstart=,args= --sort=-pcpu | awk 'NR<=5{cmd=""; for(i=12;i<=NF;i++) cmd=cmd (i==12?"":" ") $i; print $1 "\\t" $2 "\\t" $3 "\\t" $4 "\\t" $5 "\\t" $6 "\\t" $7 " " $8 " " $9 " " $10 " " $11 "\\t" cmd}'`;
|
||||
const processSummaryCommand = `ps -eo state= | awk 'BEGIN{total=0; running=0; sleeping=0} {state=substr($1,1,1); total++; if(state=="R") running++; if(state=="S" || state=="D" || state=="I") sleeping++;} END {printf "%d\\t%d\\t%d", total, running, sleeping}'`;
|
||||
|
||||
try {
|
||||
const [processListOutput, processSummaryOutput] = await Promise.all([
|
||||
this.executeSshCommand(sshClient, processListCommand),
|
||||
this.executeSshCommand(sshClient, processSummaryCommand),
|
||||
]);
|
||||
|
||||
const summaryParts = processSummaryOutput.trim().split('\t');
|
||||
if (summaryParts.length >= 3) {
|
||||
status.processTotal = parseInt(summaryParts[0], 10) || 0;
|
||||
status.processRunning = parseInt(summaryParts[1], 10) || 0;
|
||||
status.processSleeping = parseInt(summaryParts[2], 10) || 0;
|
||||
}
|
||||
|
||||
status.topProcesses = processListOutput
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.map(line => {
|
||||
const [pidText, user, state, cpuText, memPercentText, rssKbText, startedAtRaw, command] = line.split('\t');
|
||||
const pid = parseInt(pidText, 10);
|
||||
const cpu = parseFloat(cpuText);
|
||||
const memPercent = parseFloat(memPercentText);
|
||||
const rssKb = parseInt(rssKbText, 10);
|
||||
|
||||
if (!Number.isInteger(pid) || !user || !state || Number.isNaN(cpu) || Number.isNaN(memPercent) || Number.isNaN(rssKb)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startedAtParts = startedAtRaw.trim().split(/\s+/);
|
||||
const month = monthMap[startedAtParts[1]] ?? startedAtParts[1];
|
||||
const day = (startedAtParts[2] ?? '').padStart(2, '0');
|
||||
const time = startedAtParts[3] ?? '';
|
||||
|
||||
return {
|
||||
pid,
|
||||
user,
|
||||
state: state.slice(0, 1).toUpperCase(),
|
||||
cpu: Number(cpu.toFixed(1)),
|
||||
memPercent: Number(memPercent.toFixed(1)),
|
||||
memMb: Number((rssKb / 1024).toFixed(1)),
|
||||
startedAt: month && day && time ? `${month}-${day} ${time}` : startedAtRaw.trim(),
|
||||
command: command?.trim() || '-',
|
||||
};
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null);
|
||||
} catch (err) { /* noop */ }
|
||||
}
|
||||
|
||||
private async parseProcNetDev(sshClient: Client): Promise<NetworkStats | null> {
|
||||
try {
|
||||
const output = await this.executeSshCommand(sshClient, 'cat /proc/net/dev');
|
||||
|
||||
@@ -41,6 +41,10 @@ import {
|
||||
handleDockerCommand,
|
||||
handleDockerGetStats
|
||||
} from './handlers/docker.handler';
|
||||
import {
|
||||
handleProcessList,
|
||||
handleProcessSignal,
|
||||
} from './handlers/process.handler';
|
||||
import {
|
||||
handleSftpOperation,
|
||||
handleSftpUploadStart,
|
||||
@@ -104,6 +108,12 @@ export function initializeConnectionHandler(wss: WebSocketServer, sshSuspendServ
|
||||
case 'docker:get_stats':
|
||||
await handleDockerGetStats(ws, sessionId, payload);
|
||||
break;
|
||||
case 'process:list':
|
||||
await handleProcessList(ws, sessionId, payload);
|
||||
break;
|
||||
case 'process:signal':
|
||||
await handleProcessSignal(ws, sessionId, payload);
|
||||
break;
|
||||
|
||||
// SFTP Cases (generic operations)
|
||||
case 'sftp:readdir':
|
||||
@@ -471,4 +481,4 @@ export function initializeConnectionHandler(wss: WebSocketServer, sshSuspendServ
|
||||
});
|
||||
|
||||
console.log('WebSocket connection handler initialized, including SshSuspendService event listener.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import WebSocket from 'ws';
|
||||
import type { ClientChannel } from 'ssh2';
|
||||
import type { AuthenticatedWebSocket } from '../types';
|
||||
import { clientStates } from '../state';
|
||||
|
||||
export interface RemoteProcessInfo {
|
||||
pid: number;
|
||||
user: string;
|
||||
state: string;
|
||||
cpu: number;
|
||||
memPercent: number;
|
||||
memMb: number;
|
||||
startedAt: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export interface RemoteProcessSummary {
|
||||
total: number;
|
||||
running: number;
|
||||
sleeping: number;
|
||||
}
|
||||
|
||||
interface ExecResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
}
|
||||
|
||||
const PROCESS_LIST_LIMIT_DEFAULT = 200;
|
||||
|
||||
const buildProcessListCommand = (limit = PROCESS_LIST_LIMIT_DEFAULT): string => {
|
||||
const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.min(500, Math.floor(limit)) : PROCESS_LIST_LIMIT_DEFAULT;
|
||||
return `ps -eo pid=,user=,state=,pcpu=,pmem=,rss=,lstart=,args= --sort=-pcpu | awk 'NR<=${safeLimit}{cmd=\"\"; for(i=12;i<=NF;i++) cmd=cmd (i==12?\"\":\" \") $i; print $1 "\\t" $2 "\\t" $3 "\\t" $4 "\\t" $5 "\\t" $6 "\\t" $7 " " $8 " " $9 " " $10 " " $11 "\\t" cmd}'`;
|
||||
};
|
||||
|
||||
const PROCESS_SUMMARY_COMMAND = `ps -eo state= | awk 'BEGIN{total=0; running=0; sleeping=0} {state=substr($1,1,1); total++; if(state=="R") running++; if(state=="S" || state=="D" || state=="I") sleeping++;} END {printf "%d\\t%d\\t%d", total, running, sleeping}'`;
|
||||
|
||||
const monthMap: Record<string, string> = {
|
||||
Jan: '01',
|
||||
Feb: '02',
|
||||
Mar: '03',
|
||||
Apr: '04',
|
||||
May: '05',
|
||||
Jun: '06',
|
||||
Jul: '07',
|
||||
Aug: '08',
|
||||
Sep: '09',
|
||||
Oct: '10',
|
||||
Nov: '11',
|
||||
Dec: '12',
|
||||
};
|
||||
|
||||
const formatStartedAt = (raw: string): string => {
|
||||
const parts = raw.trim().split(/\s+/);
|
||||
if (parts.length < 5) {
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
const [, month, day, time] = parts;
|
||||
const monthValue = monthMap[month] ?? month;
|
||||
const dayValue = day.padStart(2, '0');
|
||||
return `${monthValue}-${dayValue} ${time}`;
|
||||
};
|
||||
|
||||
export const parseRemoteProcessList = (output: string): RemoteProcessInfo[] => {
|
||||
return output
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.map(line => {
|
||||
const [pidText, user, state, cpuText, memPercentText, rssKbText, startedAtRaw, command] = line.split('\t');
|
||||
const pid = parseInt(pidText, 10);
|
||||
const cpu = parseFloat(cpuText);
|
||||
const memPercent = parseFloat(memPercentText);
|
||||
const rssKb = parseInt(rssKbText, 10);
|
||||
|
||||
if (!Number.isInteger(pid) || !user || !state || Number.isNaN(cpu) || Number.isNaN(memPercent) || Number.isNaN(rssKb)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
pid,
|
||||
user,
|
||||
state: state.slice(0, 1).toUpperCase(),
|
||||
cpu: Number(cpu.toFixed(1)),
|
||||
memPercent: Number(memPercent.toFixed(1)),
|
||||
memMb: Number((rssKb / 1024).toFixed(1)),
|
||||
startedAt: formatStartedAt(startedAtRaw),
|
||||
command: command?.trim() || '-',
|
||||
};
|
||||
})
|
||||
.filter((item): item is RemoteProcessInfo => item !== null);
|
||||
};
|
||||
|
||||
export const parseRemoteProcessSummary = (output: string): RemoteProcessSummary => {
|
||||
const [totalText, runningText, sleepingText] = output.trim().split('\t');
|
||||
return {
|
||||
total: Number.parseInt(totalText, 10) || 0,
|
||||
running: Number.parseInt(runningText, 10) || 0,
|
||||
sleeping: Number.parseInt(sleepingText, 10) || 0,
|
||||
};
|
||||
};
|
||||
|
||||
const executeSshCommand = (channelOwner: AuthenticatedWebSocket, command: string): Promise<ExecResult> => {
|
||||
const sessionId = channelOwner.sessionId;
|
||||
if (!sessionId) {
|
||||
throw new Error('缺少会话 ID');
|
||||
}
|
||||
|
||||
const state = clientStates.get(sessionId);
|
||||
if (!state?.sshClient) {
|
||||
throw new Error('SSH 连接未就绪');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let exitCode = 0;
|
||||
|
||||
state.sshClient.exec(command, (err, stream: ClientChannel) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
stream.on('close', (code?: number) => {
|
||||
resolve({
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
code: typeof code === 'number' ? code : exitCode,
|
||||
});
|
||||
});
|
||||
|
||||
stream.on('exit', (code?: number) => {
|
||||
if (typeof code === 'number') {
|
||||
exitCode = code;
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('data', (data: Buffer) => {
|
||||
stdout += data.toString('utf8');
|
||||
});
|
||||
|
||||
stream.stderr.on('data', (data: Buffer) => {
|
||||
stderr += data.toString('utf8');
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchRemoteProcessSnapshot = async (
|
||||
ws: AuthenticatedWebSocket,
|
||||
limit = PROCESS_LIST_LIMIT_DEFAULT,
|
||||
): Promise<{ processes: RemoteProcessInfo[]; summary: RemoteProcessSummary }> => {
|
||||
const [listResult, summaryResult] = await Promise.all([
|
||||
executeSshCommand(ws, buildProcessListCommand(limit)),
|
||||
executeSshCommand(ws, PROCESS_SUMMARY_COMMAND),
|
||||
]);
|
||||
|
||||
if (listResult.code !== 0 && listResult.stderr) {
|
||||
throw new Error(listResult.stderr);
|
||||
}
|
||||
|
||||
if (summaryResult.code !== 0 && summaryResult.stderr) {
|
||||
throw new Error(summaryResult.stderr);
|
||||
}
|
||||
|
||||
return {
|
||||
processes: parseRemoteProcessList(listResult.stdout),
|
||||
summary: parseRemoteProcessSummary(summaryResult.stdout),
|
||||
};
|
||||
};
|
||||
|
||||
export const handleProcessList = async (ws: AuthenticatedWebSocket, sessionId: string | undefined, payload?: { limit?: number }) => {
|
||||
try {
|
||||
if (!sessionId) {
|
||||
throw new Error('缺少活动会话');
|
||||
}
|
||||
|
||||
const { processes, summary } = await fetchRemoteProcessSnapshot(ws, payload?.limit);
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'process:list:response',
|
||||
sessionId,
|
||||
payload: {
|
||||
processes,
|
||||
total: summary.total,
|
||||
running: summary.running,
|
||||
sleeping: summary.sleeping,
|
||||
requestedAt: Date.now(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'process:list:error',
|
||||
sessionId,
|
||||
payload: {
|
||||
message: error.message || '获取进程列表失败',
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const handleProcessSignal = async (
|
||||
ws: AuthenticatedWebSocket,
|
||||
sessionId: string | undefined,
|
||||
payload?: { pid?: number; signal?: 'TERM' | 'KILL' },
|
||||
) => {
|
||||
const pid = Number(payload?.pid);
|
||||
const signal = payload?.signal === 'KILL' ? 'KILL' : 'TERM';
|
||||
|
||||
if (!sessionId || !Number.isInteger(pid) || pid <= 0) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'process:signal:response',
|
||||
sessionId,
|
||||
payload: {
|
||||
pid,
|
||||
signal,
|
||||
success: false,
|
||||
error: '无效的 PID 或会话',
|
||||
},
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeSshCommand(ws, `kill -${signal} ${pid}`);
|
||||
const success = result.code === 0 && !result.stderr;
|
||||
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'process:signal:response',
|
||||
sessionId,
|
||||
payload: {
|
||||
pid,
|
||||
signal,
|
||||
success,
|
||||
error: success ? undefined : (result.stderr || `发送 ${signal} 信号失败`),
|
||||
},
|
||||
}));
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'process:signal:response',
|
||||
sessionId,
|
||||
payload: {
|
||||
pid,
|
||||
signal,
|
||||
success: false,
|
||||
error: error.message || `发送 ${signal} 信号失败`,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -61,6 +61,21 @@ export interface DockerContainer {
|
||||
Labels: Record<string, string>;
|
||||
stats?: DockerStats | null; // 可选的 stats 字段
|
||||
}
|
||||
|
||||
export interface ProcessListRequest {
|
||||
type: 'process:list';
|
||||
payload?: {
|
||||
limit?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProcessSignalRequest {
|
||||
type: 'process:signal';
|
||||
payload: {
|
||||
pid: number;
|
||||
signal: 'TERM' | 'KILL';
|
||||
};
|
||||
}
|
||||
// --- SSH Suspend Mode WebSocket Message Types ---
|
||||
|
||||
// Client -> Server
|
||||
@@ -294,4 +309,4 @@ export interface SftpUploadProgressPayload {
|
||||
bytesWritten: number;
|
||||
totalSize: number;
|
||||
progress: number; // Calculated percentage (0-100)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user