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
@@ -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>