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)
}
}
@@ -23,7 +23,9 @@ const inputRef = ref<HTMLInputElement | null>(null);
const query = ref('');
const selectedIndex = ref(0);
const results = computed(() => searchConnections(props.connections, query.value, 8));
const results = computed(() => searchConnections(props.connections, query.value, 8, {
getAdditionalFields: getConnectionTagNames,
}));
watch(results, async (nextResults) => {
if (nextResults.length === 0) {
@@ -0,0 +1,578 @@
<script setup lang="ts">
import { computed, ref, watch, onUnmounted } from 'vue';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { useSessionStore } from '../stores/session.store';
import { useUiNotificationsStore } from '../stores/uiNotifications.store';
import type { ProcessListItem } from '../types/server.types';
import type { ProcessListResponsePayload, ProcessSignalResponsePayload, WebSocketMessage } from '../types/websocket.types';
const props = defineProps<{
isVisible: boolean;
sessionId: string | null;
}>();
const emit = defineEmits<{
(e: 'close'): void;
}>();
const { t } = useI18n();
const sessionStore = useSessionStore();
const uiNotificationsStore = useUiNotificationsStore();
const { sessions } = storeToRefs(sessionStore);
const searchQuery = ref('');
const autoRefresh = ref(true);
const isLoading = ref(false);
const processItems = ref<ProcessListItem[]>([]);
const totalProcesses = ref(0);
const runningProcesses = ref(0);
const sleepingProcesses = ref(0);
const lastUpdatedAt = ref<number | null>(null);
const processError = ref<string | null>(null);
let unregisterListResponse: (() => void) | null = null;
let unregisterListError: (() => void) | null = null;
let unregisterSignalResponse: (() => void) | null = null;
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null;
const currentSession = computed(() => (props.sessionId ? sessions.value.get(props.sessionId) ?? null : null));
const currentWsManager = computed(() => currentSession.value?.wsManager ?? null);
const filteredProcesses = computed(() => {
const keyword = searchQuery.value.trim().toLowerCase();
if (!keyword) {
return processItems.value;
}
return processItems.value.filter(item => {
return (
String(item.pid).includes(keyword) ||
item.user.toLowerCase().includes(keyword) ||
item.command.toLowerCase().includes(keyword)
);
});
});
const formatMemoryMb = (value: number): string => {
if (!Number.isFinite(value)) {
return t('statusMonitor.notAvailable');
}
if (value < 1024) {
return `${value.toFixed(1)} M`;
}
return `${(value / 1024).toFixed(1)} G`;
};
const lastUpdatedText = computed(() => {
if (!lastUpdatedAt.value) {
return t('statusMonitor.notAvailable');
}
return new Date(lastUpdatedAt.value).toLocaleTimeString();
});
const stateTone = (state: string) => {
switch (state) {
case 'R':
return 'process-state--running';
case 'S':
case 'D':
case 'I':
return 'process-state--sleeping';
default:
return 'process-state--other';
}
};
const cleanupHandlers = () => {
unregisterListResponse?.();
unregisterListError?.();
unregisterSignalResponse?.();
unregisterListResponse = null;
unregisterListError = null;
unregisterSignalResponse = null;
};
const stopAutoRefresh = () => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
};
const closeModal = () => {
emit('close');
};
const requestProcessList = () => {
if (!props.isVisible || !props.sessionId || !currentWsManager.value?.isConnected.value) {
return;
}
isLoading.value = true;
currentWsManager.value.sendMessage({
type: 'process:list',
sessionId: props.sessionId,
payload: { limit: 200 },
});
};
const handleSignal = (pid: number, signal: 'TERM' | 'KILL') => {
if (!props.sessionId || !currentWsManager.value?.isConnected.value) {
return;
}
currentWsManager.value.sendMessage({
type: 'process:signal',
sessionId: props.sessionId,
payload: { pid, signal },
});
};
const attachHandlers = () => {
cleanupHandlers();
if (!currentWsManager.value) {
return;
}
unregisterListResponse = currentWsManager.value.onMessage('process:list:response', (payload: ProcessListResponsePayload, message?: WebSocketMessage) => {
if (message?.sessionId && message.sessionId !== props.sessionId) {
return;
}
processItems.value = payload.processes ?? [];
totalProcesses.value = payload.total ?? processItems.value.length;
runningProcesses.value = payload.running ?? 0;
sleepingProcesses.value = payload.sleeping ?? 0;
lastUpdatedAt.value = payload.requestedAt ?? Date.now();
processError.value = null;
isLoading.value = false;
});
unregisterListError = currentWsManager.value.onMessage('process:list:error', (payload: { message?: string }, message?: WebSocketMessage) => {
if (message?.sessionId && message.sessionId !== props.sessionId) {
return;
}
isLoading.value = false;
processError.value = payload?.message || t('statusMonitor.processManager.loadFailed');
});
unregisterSignalResponse = currentWsManager.value.onMessage('process:signal:response', (payload: ProcessSignalResponsePayload, message?: WebSocketMessage) => {
if (message?.sessionId && message.sessionId !== props.sessionId) {
return;
}
if (payload.success) {
uiNotificationsStore.showSuccess(
payload.signal === 'KILL'
? t('statusMonitor.processManager.forceKillSuccess', { pid: payload.pid })
: t('statusMonitor.processManager.terminateSuccess', { pid: payload.pid }),
);
requestProcessList();
return;
}
uiNotificationsStore.showError(payload.error || t('statusMonitor.processManager.signalFailed', { pid: payload.pid }));
});
};
const syncAutoRefresh = () => {
stopAutoRefresh();
if (!props.isVisible || !autoRefresh.value) {
return;
}
autoRefreshTimer = setInterval(() => {
requestProcessList();
}, 5000);
};
watch(
() => [props.isVisible, props.sessionId, currentWsManager.value] as const,
([visible, sessionId, wsManager]) => {
stopAutoRefresh();
cleanupHandlers();
if (!visible || !sessionId || !wsManager) {
return;
}
attachHandlers();
requestProcessList();
syncAutoRefresh();
},
{ immediate: true },
);
watch(autoRefresh, () => {
syncAutoRefresh();
});
watch(
() => props.isVisible,
visible => {
if (!visible) {
stopAutoRefresh();
}
},
);
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeModal();
}
};
watch(
() => props.isVisible,
visible => {
if (visible) {
document.addEventListener('keydown', handleKeydown);
} else {
document.removeEventListener('keydown', handleKeydown);
}
},
{ immediate: true },
);
onUnmounted(() => {
stopAutoRefresh();
cleanupHandlers();
document.removeEventListener('keydown', handleKeydown);
});
</script>
<template>
<div v-if="isVisible" class="process-modal-overlay" @click.self="closeModal">
<div class="process-modal-shell">
<button class="process-modal-close" type="button" @click="closeModal" :title="t('common.close', '关闭')">
<i class="fas fa-times"></i>
</button>
<div class="process-modal-handle"></div>
<header class="process-modal-toolbar">
<input
v-model="searchQuery"
class="process-modal-search"
type="text"
:placeholder="t('statusMonitor.processManager.searchPlaceholder')"
/>
<div class="process-modal-controls">
<label class="process-auto-refresh">
<span>{{ t('statusMonitor.processManager.autoRefresh') }}</span>
<input v-model="autoRefresh" type="checkbox" />
</label>
<button class="process-refresh-button" type="button" @click="requestProcessList">
<i class="fas fa-rotate-right"></i>
<span>{{ t('statusMonitor.processManager.refresh') }}</span>
</button>
</div>
</header>
<div class="process-modal-summary">
<span class="process-summary-pill">{{ t('statusMonitor.processManager.total') }} {{ totalProcesses }}</span>
<span class="process-summary-pill process-summary-pill--running">{{ t('statusMonitor.processManager.running') }} {{ runningProcesses }}</span>
<span class="process-summary-pill">{{ t('statusMonitor.processManager.sleeping') }} {{ sleepingProcesses }}</span>
<span class="process-summary-pill">{{ t('statusMonitor.processManager.updatedAt') }} {{ lastUpdatedText }}</span>
</div>
<div v-if="processError" class="process-state process-state--error">
{{ processError }}
</div>
<div v-else class="process-table-wrap">
<div v-if="!isLoading && filteredProcesses.length === 0" class="process-state">
{{ t('statusMonitor.processManager.empty') }}
</div>
<table v-else class="process-table">
<thead>
<tr>
<th>{{ t('statusMonitor.processManager.columns.pid') }}</th>
<th>{{ t('statusMonitor.processManager.columns.user') }}</th>
<th>{{ t('statusMonitor.processManager.columns.state') }}</th>
<th>{{ t('statusMonitor.processManager.columns.cpu') }}</th>
<th>{{ t('statusMonitor.processManager.columns.mem') }}</th>
<th>{{ t('statusMonitor.processManager.columns.start') }}</th>
<th>{{ t('statusMonitor.processManager.columns.command') }}</th>
<th>{{ t('statusMonitor.processManager.columns.actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in filteredProcesses" :key="item.pid">
<td class="process-table__mono">{{ item.pid }}</td>
<td>{{ item.user }}</td>
<td>
<span :class="['process-state-pill', stateTone(item.state)]">{{ item.state }}</span>
</td>
<td class="process-table__mono">{{ item.cpu.toFixed(1) }}%</td>
<td class="process-table__mono">{{ formatMemoryMb(item.memMb) }}</td>
<td class="process-table__mono">{{ item.startedAt }}</td>
<td class="process-table__command" :title="item.command">{{ item.command }}</td>
<td>
<div class="process-actions">
<button class="process-action-button" type="button" @click="handleSignal(item.pid, 'TERM')">
{{ t('statusMonitor.processManager.terminate') }}
</button>
<button class="process-action-button process-action-button--danger" type="button" @click="handleSignal(item.pid, 'KILL')">
{{ t('statusMonitor.processManager.forceKill') }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<style scoped>
.process-modal-overlay {
position: fixed;
inset: 0;
z-index: 80;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(0, 0, 0, 0.68);
backdrop-filter: blur(3px);
}
.process-modal-shell {
position: relative;
display: flex;
max-height: min(88vh, 760px);
width: min(96vw, 1160px);
flex-direction: column;
gap: 12px;
overflow: hidden;
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: linear-gradient(180deg, rgba(18, 18, 18, 0.98), rgba(12, 12, 12, 0.98));
padding: 16px;
color: #f8fbff;
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.45);
}
.process-modal-handle {
align-self: center;
width: 52px;
height: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.22);
}
.process-modal-close {
position: absolute;
top: 14px;
right: 14px;
border: none;
background: transparent;
color: #cbd5e1;
font-size: 18px;
cursor: pointer;
}
.process-modal-toolbar {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.process-modal-search {
min-width: 0;
flex: 1;
height: 40px;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.22);
background: rgba(255, 255, 255, 0.03);
padding: 0 14px;
color: #f8fbff;
font-size: 14px;
}
.process-modal-controls {
display: flex;
align-items: center;
gap: 12px;
}
.process-auto-refresh {
display: inline-flex;
align-items: center;
gap: 8px;
color: #d8e2ea;
font-size: 13px;
}
.process-refresh-button,
.process-action-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(255, 255, 255, 0.04);
color: #f8fbff;
cursor: pointer;
font-size: 13px;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.process-refresh-button {
height: 40px;
padding: 0 14px;
}
.process-action-button {
min-width: 58px;
height: 32px;
padding: 0 10px;
}
.process-refresh-button:hover,
.process-action-button:hover {
border-color: rgba(148, 163, 184, 0.38);
background: rgba(255, 255, 255, 0.08);
}
.process-action-button--danger {
color: #fca5a5;
}
.process-modal-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.process-summary-pill {
display: inline-flex;
min-height: 28px;
align-items: center;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.16);
background: rgba(255, 255, 255, 0.03);
padding: 0 10px;
color: #cbd5e1;
font-size: 12px;
font-weight: 700;
}
.process-summary-pill--running {
color: #bbf7d0;
}
.process-table-wrap {
min-height: 0;
flex: 1;
overflow: auto;
border-radius: 14px;
border: 1px solid rgba(148, 163, 184, 0.14);
}
.process-table {
width: 100%;
border-collapse: collapse;
min-width: 980px;
background: rgba(12, 12, 12, 0.96);
}
.process-table th,
.process-table td {
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
padding: 12px 10px;
text-align: left;
font-size: 13px;
}
.process-table th {
position: sticky;
top: 0;
z-index: 1;
background: rgba(22, 22, 22, 0.98);
color: #9fb0bf;
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.process-table__mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.process-table__command {
max-width: 420px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.process-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.process-state,
.process-state-pill {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
}
.process-state {
min-height: 180px;
color: #9fb0bf;
}
.process-state--error {
color: #fca5a5;
}
.process-state-pill {
min-width: 36px;
padding: 4px 10px;
border: 1px solid rgba(148, 163, 184, 0.18);
font-size: 12px;
font-weight: 700;
}
.process-state--running {
border-color: rgba(34, 197, 94, 0.24);
background: rgba(22, 101, 52, 0.26);
color: #bbf7d0;
}
.process-state--sleeping {
border-color: rgba(37, 99, 235, 0.24);
background: rgba(30, 64, 175, 0.22);
color: #bfdbfe;
}
.process-state--other {
border-color: rgba(148, 163, 184, 0.24);
background: rgba(51, 65, 85, 0.28);
color: #cbd5e1;
}
@media (max-width: 860px) {
.process-modal-toolbar {
flex-direction: column;
align-items: stretch;
}
.process-modal-controls {
justify-content: space-between;
}
}
</style>
@@ -205,6 +205,52 @@
</div>
</div>
</section>
<section class="monitor-card monitor-card--process">
<div class="monitor-card__header">
<div class="monitor-card__title-group">
<span class="monitor-card__icon monitor-card__icon--process">
<i class="fas fa-list-check"></i>
</span>
<div>
<h5 class="monitor-card__title">{{ t('statusMonitor.processManager.title') }}</h5>
<p class="monitor-card__subtitle">{{ t('statusMonitor.processManager.subtitle') }}</p>
</div>
</div>
<button class="monitor-action-button" type="button" @click="isProcessManagerVisible = true">
{{ t('statusMonitor.processManager.viewAll') }}
</button>
</div>
<div class="process-summary-grid">
<div class="process-summary-item">
<span class="process-summary-item__label">{{ t('statusMonitor.processManager.total') }}</span>
<span class="process-summary-item__value">{{ processTotalDisplay }}</span>
</div>
<div class="process-summary-item">
<span class="process-summary-item__label">{{ t('statusMonitor.processManager.running') }}</span>
<span class="process-summary-item__value">{{ processRunningDisplay }}</span>
</div>
<div class="process-summary-item">
<span class="process-summary-item__label">{{ t('statusMonitor.processManager.sleeping') }}</span>
<span class="process-summary-item__value">{{ processSleepingDisplay }}</span>
</div>
</div>
<div v-if="topProcessPreview.length > 0" class="process-preview-list">
<article v-for="item in topProcessPreview" :key="item.pid" class="process-preview-item">
<div class="process-preview-item__meta">
<span class="process-preview-item__pid">PID {{ item.pid }}</span>
<span class="process-preview-item__cpu">{{ item.cpu.toFixed(1) }}%</span>
<span class="process-preview-item__mem">{{ formatProcessMemory(item.memMb) }}</span>
</div>
<div class="process-preview-item__command" :title="item.command">{{ item.command }}</div>
</article>
</div>
<div v-else class="process-preview-empty">
{{ t('statusMonitor.processManager.empty') }}
</div>
</section>
</div>
<StatusCharts
@@ -213,6 +259,12 @@
:active-session-id="activeSessionId"
/>
</section>
<ProcessManagerModal
:is-visible="isProcessManagerVisible"
:session-id="activeSessionId"
@close="isProcessManagerVisible = false"
/>
</div>
</template>
@@ -220,12 +272,13 @@
import { computed, ref, watch, type CSSProperties, type PropType } from 'vue';
import { useI18n } from 'vue-i18n';
import { storeToRefs } from 'pinia';
import ProcessManagerModal from './ProcessManagerModal.vue';
import StatusCharts from './StatusCharts.vue';
import { useSessionStore } from '../stores/session.store';
import { useSettingsStore } from '../stores/settings.store';
import { useConnectionsStore } from '../stores/connections.store';
import { useUiNotificationsStore } from '../stores/uiNotifications.store';
import type { ServerStatus } from '../types/server.types';
import type { ProcessListItem, ServerStatus } from '../types/server.types';
interface MonitorMetaItem {
key: string;
@@ -241,6 +294,7 @@ const connectionsStore = useConnectionsStore();
const uiNotificationsStore = useUiNotificationsStore();
const { sessions } = storeToRefs(sessionStore);
const { statusMonitorShowIpBoolean } = storeToRefs(settingsStore);
const isProcessManagerVisible = ref(false);
const props = defineProps({
activeSessionId: {
@@ -265,6 +319,10 @@ const displaySwapPercent = computed(() => clampPercent(currentServerStatus.value
const displayMemoryPercent = computed(() => clampPercent(currentServerStatus.value?.memPercent));
const displayDiskPercent = computed(() => clampPercent(currentServerStatus.value?.diskPercent));
const currentStatusError = computed<string | null>(() => currentSessionState.value?.statusMonitorManager?.statusError?.value ?? null);
const timezoneDisplay = computed(() => currentServerStatus.value?.timezone || t('statusMonitor.notAvailable'));
const processTotalDisplay = computed(() => currentServerStatus.value?.processTotal ?? 0);
const processRunningDisplay = computed(() => currentServerStatus.value?.processRunning ?? 0);
const processSleepingDisplay = computed(() => currentServerStatus.value?.processSleeping ?? 0);
const cachedCpuModel = ref<string | null>(null);
const cachedCpuCores = ref<number | null>(null);
@@ -340,6 +398,34 @@ const formatMemorySize = (mb?: number): string => {
return `${(mb / 1024).toFixed(1)} ${t('statusMonitor.gigaBytes')}`;
};
const formatUptime = (seconds?: number): string => {
if (seconds === undefined || seconds === null || !Number.isFinite(seconds) || seconds < 0) {
return t('statusMonitor.notAvailable');
}
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return `${days}${t('statusMonitor.uptimeDaySuffix')} ${hours}${t('statusMonitor.uptimeHourSuffix')}`;
}
if (hours > 0) {
return `${hours}${t('statusMonitor.uptimeHourSuffix')} ${minutes}${t('statusMonitor.uptimeMinuteSuffix')}`;
}
return `${minutes}${t('statusMonitor.uptimeMinuteSuffix')}`;
};
const formatProcessMemory = (mb?: number): string => {
if (mb === undefined || mb === null || !Number.isFinite(mb)) {
return t('statusMonitor.notAvailable');
}
if (mb < 1024) {
return `${mb.toFixed(1)} M`;
}
return `${(mb / 1024).toFixed(1)} G`;
};
const swapDisplay = computed(() => {
const total = currentServerStatus.value?.swapTotal ?? 0;
const used = currentServerStatus.value?.swapUsed ?? 0;
@@ -433,6 +519,9 @@ const totalTrafficDisplay = computed(() => {
return `${totalDown} / ${totalUp}`;
});
const uptimeDisplay = computed(() => formatUptime(currentServerStatus.value?.uptimeSeconds));
const topProcessPreview = computed<readonly ProcessListItem[]>(() => currentServerStatus.value?.topProcesses ?? []);
const maxCurrentNetworkRate = computed(() => {
const rxRate = currentServerStatus.value?.netRxRate ?? 0;
const txRate = currentServerStatus.value?.netTxRate ?? 0;
@@ -450,6 +539,8 @@ const monitorMetaItems = computed<MonitorMetaItem[]>(() => {
const items: MonitorMetaItem[] = [
{ key: 'cpu-model', label: t('statusMonitor.cpuModelLabel'), value: displayCpuModel.value },
{ key: 'cpu-cores', label: t('statusMonitor.cpuLabel'), value: displayCpuCores.value },
{ key: 'timezone', label: t('statusMonitor.timezoneLabel'), value: timezoneDisplay.value },
{ key: 'uptime', label: t('statusMonitor.uptimeLabel'), value: uptimeDisplay.value },
{ key: 'memory-total', label: t('statusMonitor.memoryCardTitle'), value: memoryTotalDisplay.value },
{ key: 'disk-mount', label: t('statusMonitor.diskMountLabel'), value: diskMountPointDisplay.value },
];
@@ -883,6 +974,10 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
color: #fbbf24;
}
.monitor-card__icon--process {
color: #fda4af;
}
.monitor-card__title {
margin: 0;
font-size: 15px;
@@ -1086,6 +1181,99 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
margin-top: 4px;
}
.monitor-action-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 32px;
border-radius: 10px;
border: 1px solid rgba(96, 165, 250, 0.22);
background: rgba(37, 99, 235, 0.18);
padding: 0 12px;
color: #dbeafe;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.process-summary-grid {
display: grid;
gap: 8px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.process-summary-item {
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.08);
background: rgba(255, 255, 255, 0.03);
padding: 10px;
}
.process-summary-item__label {
display: block;
color: #8fa0b3;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.process-summary-item__value {
display: block;
margin-top: 6px;
color: #f8fbff;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 20px;
font-weight: 800;
}
.process-preview-list {
display: grid;
gap: 8px;
}
.process-preview-item {
border-radius: 12px;
border: 1px solid rgba(148, 163, 184, 0.08);
background: rgba(255, 255, 255, 0.03);
padding: 10px;
}
.process-preview-item__meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
color: #9fb0bf;
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.process-preview-item__cpu {
color: #bfdbfe;
}
.process-preview-item__mem {
color: #fde68a;
}
.process-preview-item__command {
margin-top: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #f8fbff;
font-size: 13px;
}
.process-preview-empty {
border-radius: 12px;
border: 1px dashed rgba(148, 163, 184, 0.16);
padding: 14px;
color: #8fa0b3;
font-size: 13px;
text-align: center;
}
@container (min-width: 560px) {
.monitor-rail {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1127,6 +1315,10 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
grid-column: auto;
}
.monitor-card--process {
grid-column: 2 / span 2;
}
.disk-info-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
@@ -1163,7 +1355,8 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
.memory-stats-grid,
.disk-rate-grid,
.disk-info-grid {
.disk-info-grid,
.process-summary-grid {
grid-template-columns: 1fr;
}
}
+35 -1
View File
@@ -658,6 +658,11 @@
"totalTrafficLabel": "Traffic Since Boot",
"downloadLabel": "Download",
"uploadLabel": "Upload",
"timezoneLabel": "Timezone",
"uptimeLabel": "Uptime",
"uptimeDaySuffix": "d",
"uptimeHourSuffix": "h",
"uptimeMinuteSuffix": "m",
"notAvailable": "N/A",
"bytesPerSecond": "B/s",
"kiloBytesPerSecond": "KB/s",
@@ -687,7 +692,36 @@
"networkUploadLabelUnit": "Upload ({unit})",
"latestCpuValue": "{value}%",
"latestMemoryValue": "{value} {unit}",
"latestNetworkValue": "↓ {download} ↑ {upload} {unit}"
"latestNetworkValue": "↓ {download} ↑ {upload} {unit}",
"processManager": {
"title": "Process Manager",
"subtitle": "Summary view / open full manager",
"viewAll": "View All",
"total": "Total",
"running": "Running",
"sleeping": "Sleeping",
"updatedAt": "Updated",
"searchPlaceholder": "Search PID / user / command",
"autoRefresh": "Auto Refresh",
"refresh": "Refresh",
"empty": "No process data",
"loadFailed": "Failed to load process list",
"terminate": "Terminate",
"forceKill": "Force Kill",
"terminateSuccess": "Terminate signal sent to process {pid}",
"forceKillSuccess": "Force kill signal sent to process {pid}",
"signalFailed": "Process {pid} operation failed",
"columns": {
"pid": "PID",
"user": "User",
"state": "State",
"cpu": "CPU",
"mem": "MEM",
"start": "Start",
"command": "Command",
"actions": "Actions"
}
}
},
"tags": {
"title": "Tag Management",
+35 -1
View File
@@ -658,6 +658,11 @@
"totalTrafficLabel": "开机累计流量",
"downloadLabel": "下行",
"uploadLabel": "上行",
"timezoneLabel": "时区",
"uptimeLabel": "运行时间",
"uptimeDaySuffix": "天",
"uptimeHourSuffix": "时",
"uptimeMinuteSuffix": "分",
"notAvailable": "N/A",
"bytesPerSecond": "B/s",
"kiloBytesPerSecond": "KB/s",
@@ -687,7 +692,36 @@
"networkUploadLabelUnit": "上传 ({unit})",
"latestCpuValue": "{value}%",
"latestMemoryValue": "{value} {unit}",
"latestNetworkValue": "↓ {download} ↑ {upload} {unit}"
"latestNetworkValue": "↓ {download} ↑ {upload} {unit}",
"processManager": {
"title": "进程管理",
"subtitle": "默认概览 / 点击查看全部",
"viewAll": "查看全部",
"total": "总数",
"running": "运行中",
"sleeping": "休眠中",
"updatedAt": "更新于",
"searchPlaceholder": "搜索 PID / 用户 / 命令",
"autoRefresh": "自动刷新",
"refresh": "刷新",
"empty": "暂无进程数据",
"loadFailed": "加载进程列表失败",
"terminate": "结束",
"forceKill": "强制结束",
"terminateSuccess": "已向进程 {pid} 发送结束信号",
"forceKillSuccess": "已向进程 {pid} 发送强制结束信号",
"signalFailed": "进程 {pid} 操作失败",
"columns": {
"pid": "PID",
"user": "用户",
"state": "状态",
"cpu": "CPU",
"mem": "MEM",
"start": "启动时间",
"command": "命令",
"actions": "操作"
}
}
},
"tags": {
"title": "标签管理",
@@ -1,4 +1,15 @@
// 类型定义:用于服务器状态监控数据 (从 useStatusMonitor 迁移)
export interface ProcessListItem {
pid: number;
user: string;
state: string;
cpu: number;
memPercent: number;
memMb: number;
startedAt: string;
command: string;
}
export interface ServerStatus {
cpuPercent?: number;
cpuCores?: number;
@@ -26,6 +37,12 @@ export interface ServerStatus {
netTxTotalBytes?: number; // Bytes since boot
netInterface?: string;
osName?: string;
timezone?: string;
uptimeSeconds?: number;
processTotal?: number;
processRunning?: number;
processSleeping?: number;
topProcesses?: readonly ProcessListItem[];
}
// 可以根据需要添加其他与服务器或连接状态相关的类型
@@ -27,6 +27,21 @@ export interface SftpUploadProgressMessage extends WebSocketMessage {
payload: SftpUploadProgressPayload;
}
export interface ProcessListResponsePayload {
processes: import('./server.types').ProcessListItem[];
total: number;
running: number;
sleeping: number;
requestedAt: number;
}
export interface ProcessSignalResponsePayload {
pid: number;
signal: 'TERM' | 'KILL';
success: boolean;
error?: string;
}
// --- SSH Suspend Mode WebSocket Message Types ---
// 导入挂起会话类型,用于相关消息的 payload
@@ -5,6 +5,10 @@ export interface ConnectionSearchResult {
score: number;
}
export interface ConnectionSearchOptions {
getAdditionalFields?: (connection: ConnectionInfo) => Array<string | null | undefined>;
}
const normalize = (value: string | null | undefined): string => (value ?? '').trim().toLowerCase();
const getDisplayName = (connection: ConnectionInfo): string => connection.name?.trim() || connection.host;
@@ -58,7 +62,11 @@ const getFieldScore = (text: string, query: string): number => {
return Math.max(70, 180 - firstMatchIndex * 4 - gapPenalty * 3);
};
const scoreConnection = (connection: ConnectionInfo, query: string): number => {
const scoreConnection = (
connection: ConnectionInfo,
query: string,
options?: ConnectionSearchOptions,
): number => {
const fields: Array<[string, number]> = [
[normalize(connection.name), 40],
[normalize(connection.host), 28],
@@ -66,6 +74,11 @@ const scoreConnection = (connection: ConnectionInfo, query: string): number => {
[normalize(connection.type), 10],
];
const additionalFields = options?.getAdditionalFields?.(connection) ?? [];
additionalFields.forEach((field) => {
fields.push([normalize(field), 14]);
});
let bestScore = 0;
for (const [field, weight] of fields) {
@@ -84,6 +97,7 @@ export const searchConnections = (
connections: ConnectionInfo[],
rawQuery: string,
limit = 8,
options?: ConnectionSearchOptions,
): ConnectionSearchResult[] => {
const query = normalize(rawQuery);
@@ -104,7 +118,7 @@ export const searchConnections = (
return connections
.map((connection) => ({
connection,
score: scoreConnection(connection, query),
score: scoreConnection(connection, query, options),
}))
.filter((item) => item.score > 0)
.sort((left, right) => {