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:
yinjianm
2026-04-15 22:21:26 +08:00
parent 154bb7ee60
commit 9b45ad77e5
21 changed files with 1701 additions and 16 deletions
@@ -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');
+11 -1
View File
@@ -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} 信号失败`,
},
}));
}
}
};
+16 -1
View File
@@ -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)
}
}