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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user