feat(frontend): revamp monitor cards and process table
Add a dedicated CPU history chart with per-core live indicators, switch the network block to a vertical stack with a tighter height cap, and lift process counts into header pills. Also make the process list columns sortable, add spacing around the close button, and extend backend CPU sampling to include per-core usage data.
This commit is contained in:
@@ -7,6 +7,9 @@ import { useUiNotificationsStore } from '../stores/uiNotifications.store';
|
||||
import type { ProcessListItem } from '../types/server.types';
|
||||
import type { ProcessListResponsePayload, ProcessSignalResponsePayload, WebSocketMessage } from '../types/websocket.types';
|
||||
|
||||
type ProcessSortKey = 'pid' | 'user' | 'state' | 'cpu' | 'mem' | 'startedAt' | 'command';
|
||||
type ProcessSortDirection = 'asc' | 'desc';
|
||||
|
||||
const props = defineProps<{
|
||||
isVisible: boolean;
|
||||
sessionId: string | null;
|
||||
@@ -30,6 +33,8 @@ const runningProcesses = ref(0);
|
||||
const sleepingProcesses = ref(0);
|
||||
const lastUpdatedAt = ref<number | null>(null);
|
||||
const processError = ref<string | null>(null);
|
||||
const sortKey = ref<ProcessSortKey | null>(null);
|
||||
const sortDirection = ref<ProcessSortDirection>('asc');
|
||||
|
||||
let unregisterListResponse: (() => void) | null = null;
|
||||
let unregisterListError: (() => void) | null = null;
|
||||
@@ -54,6 +59,67 @@ const filteredProcesses = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const defaultSortDirections: Record<ProcessSortKey, ProcessSortDirection> = {
|
||||
pid: 'asc',
|
||||
user: 'asc',
|
||||
state: 'asc',
|
||||
cpu: 'desc',
|
||||
mem: 'desc',
|
||||
startedAt: 'desc',
|
||||
command: 'asc',
|
||||
};
|
||||
|
||||
const compareText = (left: string, right: string) =>
|
||||
left.localeCompare(right, undefined, { sensitivity: 'base', numeric: true });
|
||||
|
||||
const sortedProcesses = computed(() => {
|
||||
const currentSortKey = sortKey.value;
|
||||
if (!currentSortKey) {
|
||||
return filteredProcesses.value;
|
||||
}
|
||||
|
||||
const directionFactor = sortDirection.value === 'asc' ? 1 : -1;
|
||||
|
||||
return filteredProcesses.value
|
||||
.map((item, index) => ({ item, index }))
|
||||
.sort((leftEntry, rightEntry) => {
|
||||
const left = leftEntry.item;
|
||||
const right = rightEntry.item;
|
||||
|
||||
let result = 0;
|
||||
switch (currentSortKey) {
|
||||
case 'pid':
|
||||
result = left.pid - right.pid;
|
||||
break;
|
||||
case 'cpu':
|
||||
result = left.cpu - right.cpu;
|
||||
break;
|
||||
case 'mem':
|
||||
result = left.memMb - right.memMb;
|
||||
break;
|
||||
case 'user':
|
||||
result = compareText(left.user, right.user);
|
||||
break;
|
||||
case 'state':
|
||||
result = compareText(left.state, right.state);
|
||||
break;
|
||||
case 'startedAt':
|
||||
result = compareText(left.startedAt, right.startedAt);
|
||||
break;
|
||||
case 'command':
|
||||
result = compareText(left.command, right.command);
|
||||
break;
|
||||
}
|
||||
|
||||
if (result !== 0) {
|
||||
return result * directionFactor;
|
||||
}
|
||||
|
||||
return leftEntry.index - rightEntry.index;
|
||||
})
|
||||
.map(({ item }) => item);
|
||||
});
|
||||
|
||||
const formatMemoryMb = (value: number): string => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return t('statusMonitor.notAvailable');
|
||||
@@ -71,6 +137,38 @@ const lastUpdatedText = computed(() => {
|
||||
return new Date(lastUpdatedAt.value).toLocaleTimeString();
|
||||
});
|
||||
|
||||
const toggleSort = (key: ProcessSortKey) => {
|
||||
if (sortKey.value === key) {
|
||||
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc';
|
||||
return;
|
||||
}
|
||||
|
||||
sortKey.value = key;
|
||||
sortDirection.value = defaultSortDirections[key];
|
||||
};
|
||||
|
||||
const isSortedBy = (key: ProcessSortKey) => sortKey.value === key;
|
||||
|
||||
const getSortIcon = (key: ProcessSortKey) => {
|
||||
if (!isSortedBy(key)) {
|
||||
return 'fas fa-sort';
|
||||
}
|
||||
|
||||
return sortDirection.value === 'asc' ? 'fas fa-chevron-up' : 'fas fa-chevron-down';
|
||||
};
|
||||
|
||||
const getSortLabel = (key: ProcessSortKey, label: string) => {
|
||||
if (!isSortedBy(key)) {
|
||||
return label;
|
||||
}
|
||||
|
||||
return `${label} - ${
|
||||
sortDirection.value === 'asc'
|
||||
? t('common.sortAscending', '升序')
|
||||
: t('common.sortDescending', '降序')
|
||||
}`;
|
||||
};
|
||||
|
||||
const stateTone = (state: string) => {
|
||||
switch (state) {
|
||||
case 'R':
|
||||
@@ -285,25 +383,102 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div v-else class="process-table-wrap">
|
||||
<div v-if="!isLoading && filteredProcesses.length === 0" class="process-state">
|
||||
<div v-if="!isLoading && sortedProcesses.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>
|
||||
<button
|
||||
class="process-sort-button"
|
||||
type="button"
|
||||
:aria-label="getSortLabel('pid', t('statusMonitor.processManager.columns.pid'))"
|
||||
:title="getSortLabel('pid', t('statusMonitor.processManager.columns.pid'))"
|
||||
@click="toggleSort('pid')"
|
||||
>
|
||||
<span>{{ t('statusMonitor.processManager.columns.pid') }}</span>
|
||||
<i :class="[getSortIcon('pid'), 'process-sort-button__icon', { 'process-sort-button__icon--active': isSortedBy('pid') }]"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button
|
||||
class="process-sort-button"
|
||||
type="button"
|
||||
:aria-label="getSortLabel('user', t('statusMonitor.processManager.columns.user'))"
|
||||
:title="getSortLabel('user', t('statusMonitor.processManager.columns.user'))"
|
||||
@click="toggleSort('user')"
|
||||
>
|
||||
<span>{{ t('statusMonitor.processManager.columns.user') }}</span>
|
||||
<i :class="[getSortIcon('user'), 'process-sort-button__icon', { 'process-sort-button__icon--active': isSortedBy('user') }]"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button
|
||||
class="process-sort-button"
|
||||
type="button"
|
||||
:aria-label="getSortLabel('state', t('statusMonitor.processManager.columns.state'))"
|
||||
:title="getSortLabel('state', t('statusMonitor.processManager.columns.state'))"
|
||||
@click="toggleSort('state')"
|
||||
>
|
||||
<span>{{ t('statusMonitor.processManager.columns.state') }}</span>
|
||||
<i :class="[getSortIcon('state'), 'process-sort-button__icon', { 'process-sort-button__icon--active': isSortedBy('state') }]"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button
|
||||
class="process-sort-button"
|
||||
type="button"
|
||||
:aria-label="getSortLabel('cpu', t('statusMonitor.processManager.columns.cpu'))"
|
||||
:title="getSortLabel('cpu', t('statusMonitor.processManager.columns.cpu'))"
|
||||
@click="toggleSort('cpu')"
|
||||
>
|
||||
<span>{{ t('statusMonitor.processManager.columns.cpu') }}</span>
|
||||
<i :class="[getSortIcon('cpu'), 'process-sort-button__icon', { 'process-sort-button__icon--active': isSortedBy('cpu') }]"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button
|
||||
class="process-sort-button"
|
||||
type="button"
|
||||
:aria-label="getSortLabel('mem', t('statusMonitor.processManager.columns.mem'))"
|
||||
:title="getSortLabel('mem', t('statusMonitor.processManager.columns.mem'))"
|
||||
@click="toggleSort('mem')"
|
||||
>
|
||||
<span>{{ t('statusMonitor.processManager.columns.mem') }}</span>
|
||||
<i :class="[getSortIcon('mem'), 'process-sort-button__icon', { 'process-sort-button__icon--active': isSortedBy('mem') }]"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button
|
||||
class="process-sort-button"
|
||||
type="button"
|
||||
:aria-label="getSortLabel('startedAt', t('statusMonitor.processManager.columns.start'))"
|
||||
:title="getSortLabel('startedAt', t('statusMonitor.processManager.columns.start'))"
|
||||
@click="toggleSort('startedAt')"
|
||||
>
|
||||
<span>{{ t('statusMonitor.processManager.columns.start') }}</span>
|
||||
<i :class="[getSortIcon('startedAt'), 'process-sort-button__icon', { 'process-sort-button__icon--active': isSortedBy('startedAt') }]"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th>
|
||||
<button
|
||||
class="process-sort-button"
|
||||
type="button"
|
||||
:aria-label="getSortLabel('command', t('statusMonitor.processManager.columns.command'))"
|
||||
:title="getSortLabel('command', t('statusMonitor.processManager.columns.command'))"
|
||||
@click="toggleSort('command')"
|
||||
>
|
||||
<span>{{ t('statusMonitor.processManager.columns.command') }}</span>
|
||||
<i :class="[getSortIcon('command'), 'process-sort-button__icon', { 'process-sort-button__icon--active': isSortedBy('command') }]"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th>{{ t('statusMonitor.processManager.columns.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in filteredProcesses" :key="item.pid">
|
||||
<tr v-for="item in sortedProcesses" :key="item.pid">
|
||||
<td class="process-table__mono">{{ item.pid }}</td>
|
||||
<td>{{ item.user }}</td>
|
||||
<td>
|
||||
@@ -374,9 +549,21 @@ onUnmounted(() => {
|
||||
right: 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
color: #cbd5e1;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.process-modal-close:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #f8fbff;
|
||||
}
|
||||
|
||||
.process-modal-toolbar {
|
||||
@@ -384,6 +571,7 @@ onUnmounted(() => {
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-right: 52px;
|
||||
}
|
||||
|
||||
.process-modal-search {
|
||||
@@ -505,6 +693,30 @@ onUnmounted(() => {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.process-sort-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-transform: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.process-sort-button__icon {
|
||||
color: rgba(159, 176, 191, 0.56);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.process-sort-button__icon--active {
|
||||
color: #f8fbff;
|
||||
}
|
||||
|
||||
.process-table__mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
@@ -569,6 +781,7 @@ onUnmounted(() => {
|
||||
.process-modal-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding-right: 52px;
|
||||
}
|
||||
|
||||
.process-modal-controls {
|
||||
|
||||
@@ -58,30 +58,34 @@
|
||||
</header>
|
||||
|
||||
<section class="monitor-module monitor-module--usage">
|
||||
<div class="cpu-module__hero">
|
||||
<div class="monitor-module__heading">
|
||||
<div>
|
||||
<span class="monitor-module__eyebrow">{{ t('statusMonitor.cpuLabel') }}</span>
|
||||
<h5 class="monitor-module__title">CPU</h5>
|
||||
</div>
|
||||
<div class="cpu-module__sparkline" aria-hidden="true">
|
||||
<svg viewBox="0 0 160 28" preserveAspectRatio="none">
|
||||
<path class="cpu-module__sparkline-path" :d="cpuSparklinePath"></path>
|
||||
</svg>
|
||||
<h5 class="monitor-module__title">{{ t('statusMonitor.cpuUsageTitle') }}</h5>
|
||||
</div>
|
||||
<span class="monitor-module__pill">{{ displayCpuCores }}</span>
|
||||
</div>
|
||||
|
||||
<div class="usage-lane-list">
|
||||
<article class="usage-lane usage-lane--cpu usage-lane--solo">
|
||||
<div class="usage-lane__content">
|
||||
<div class="usage-lane__meta">
|
||||
<span class="usage-lane__label">{{ displayCpuCores }}</span>
|
||||
<span class="usage-lane__value-inline">{{ cpuUsageLane.value }}</span>
|
||||
<div class="module-split module-split--cpu">
|
||||
<StatusMonitorCpuHistoryChart :cpu-history="currentCpuHistory" />
|
||||
|
||||
<div class="cpu-core-grid">
|
||||
<article
|
||||
v-for="item in cpuCoreItems"
|
||||
:key="item.key"
|
||||
class="usage-lane usage-lane--cpu"
|
||||
>
|
||||
<div class="usage-lane__content">
|
||||
<div class="usage-lane__meta">
|
||||
<span class="usage-lane__label">{{ item.label }}</span>
|
||||
<span class="usage-lane__value-inline">{{ item.value }}</span>
|
||||
</div>
|
||||
<div class="usage-lane__track">
|
||||
<span class="usage-lane__fill" :style="{ width: `${item.percent}%` }"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="usage-lane__track">
|
||||
<span class="usage-lane__fill" :style="{ width: `${cpuUsageLane.percent}%` }"></span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -219,15 +223,15 @@
|
||||
<span class="monitor-module__eyebrow">{{ t('statusMonitor.processManager.title') }}</span>
|
||||
<h5 class="monitor-module__title">{{ t('statusMonitor.processManager.subtitle') }}</h5>
|
||||
</div>
|
||||
<button class="monitor-action-button" type="button" @click="isProcessManagerVisible = true">
|
||||
{{ t('statusMonitor.processManager.viewAll') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="process-summary-strip">
|
||||
<div v-for="item in processSummaryItems" :key="item.key" class="process-summary-item">
|
||||
<span class="process-summary-item__label">{{ item.label }}</span>
|
||||
<span class="process-summary-item__value">{{ item.value }}</span>
|
||||
<div class="monitor-module__heading-actions">
|
||||
<div class="process-summary-pills">
|
||||
<span v-for="item in processSummaryItems" :key="item.key" class="monitor-module__pill">
|
||||
{{ item.label }} {{ item.value }}
|
||||
</span>
|
||||
</div>
|
||||
<button class="monitor-action-button" type="button" @click="isProcessManagerVisible = true">
|
||||
{{ t('statusMonitor.processManager.viewAll') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -274,6 +278,7 @@ 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 StatusMonitorCpuHistoryChart from './StatusMonitorCpuHistoryChart.vue';
|
||||
import StatusCharts from './StatusCharts.vue';
|
||||
import StatusMonitorNetworkHistoryChart from './StatusMonitorNetworkHistoryChart.vue';
|
||||
import { useSessionStore } from '../stores/session.store';
|
||||
@@ -509,28 +514,29 @@ const systemCardMetaItems = computed<MonitorOverviewItem[]>(() => [
|
||||
{ key: 'uptime', label: t('statusMonitor.uptimeLabel'), value: uptimeDisplay.value },
|
||||
]);
|
||||
|
||||
const cpuUsageLane = computed(() => ({
|
||||
value: `${Math.round(displayCpuPercent.value)}%`,
|
||||
percent: displayCpuPercent.value,
|
||||
}));
|
||||
const cpuCoreItems = computed(() => {
|
||||
const rawPercents = currentServerStatus.value?.cpuCorePercents;
|
||||
const fallbackCoreCount = (() => {
|
||||
const currentCores = currentServerStatus.value?.cpuCores;
|
||||
if (typeof currentCores !== 'number' || !Number.isFinite(currentCores) || currentCores <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round(currentCores);
|
||||
})();
|
||||
|
||||
const buildSparklinePath = (samples: readonly number[], width: number, height: number, usableHeight: number): string => {
|
||||
if (samples.length === 0) {
|
||||
return `M0 ${height - 4} L${width} ${height - 4}`;
|
||||
}
|
||||
const normalizedPercents = Array.isArray(rawPercents) && rawPercents.length > 0
|
||||
? rawPercents
|
||||
: Array.from({ length: fallbackCoreCount }, () => 0);
|
||||
|
||||
const step = samples.length > 1 ? width / (samples.length - 1) : width;
|
||||
|
||||
return samples.map((value, index) => {
|
||||
const x = Number((index * step).toFixed(2));
|
||||
const y = Number((height - 4 - (value / 100) * usableHeight).toFixed(2));
|
||||
return `${index === 0 ? 'M' : 'L'}${x} ${y}`;
|
||||
}).join(' ');
|
||||
};
|
||||
|
||||
const cpuSparklinePath = computed(() => {
|
||||
const samples = currentCpuHistory.value.slice(-24).map(value => clampPercent(value ?? 0));
|
||||
return buildSparklinePath(samples, 160, 28, 18);
|
||||
return normalizedPercents.map((percent, index) => {
|
||||
const clampedPercent = clampPercent(percent);
|
||||
return {
|
||||
key: `cpu-core-${index + 1}`,
|
||||
label: t('statusMonitor.cpuCoreLabel', { index: index + 1 }),
|
||||
value: `${Math.round(clampedPercent)}%`,
|
||||
percent: clampedPercent,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const networkFlowItems = computed(() => [
|
||||
@@ -864,6 +870,21 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.monitor-module__heading-actions {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.process-summary-pills {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.monitor-module__title {
|
||||
margin: 6px 0 0;
|
||||
color: #f8fbff;
|
||||
@@ -872,36 +893,6 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.cpu-module__hero {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(96px, 1fr);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cpu-module__sparkline {
|
||||
height: 28px;
|
||||
min-width: 0;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.14);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.cpu-module__sparkline svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cpu-module__sparkline-path {
|
||||
fill: none;
|
||||
stroke: #7dd3fc;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
filter: drop-shadow(0 0 6px rgba(125, 211, 252, 0.28));
|
||||
}
|
||||
|
||||
.usage-lane-list,
|
||||
.memory-stat-stack,
|
||||
.network-stat-stack,
|
||||
.disk-stat-stack,
|
||||
@@ -994,6 +985,16 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
|
||||
background: linear-gradient(90deg, #7dd3fc, #2563eb);
|
||||
}
|
||||
|
||||
.cpu-core-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
max-height: 208px;
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.module-split {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -1004,11 +1005,22 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.module-split--network {
|
||||
grid-template-columns: minmax(0, 0.92fr) minmax(0, 1.08fr);
|
||||
.module-split--cpu {
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.module-split--network {
|
||||
grid-template-columns: 1fr;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.monitor-module--network {
|
||||
max-height: 350px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.memory-ring-panel,
|
||||
.disk-device-card,
|
||||
.disk-io-card,
|
||||
@@ -1126,9 +1138,9 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
|
||||
|
||||
.network-table {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
padding: 10px 12px;
|
||||
gap: 6px;
|
||||
height: auto;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.network-table__header,
|
||||
@@ -1143,17 +1155,21 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
|
||||
}
|
||||
|
||||
.network-table__header {
|
||||
padding-bottom: 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
.network-table__columns {
|
||||
padding-top: 2px;
|
||||
padding-top: 0;
|
||||
color: #9cb0c2;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.network-stat-stack {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.network-table__columns span,
|
||||
.network-stat span,
|
||||
.disk-summary-table__head span,
|
||||
@@ -1171,7 +1187,7 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.06);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 10px 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.network-stat__label {
|
||||
@@ -1189,7 +1205,7 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
|
||||
.network-stat__value,
|
||||
.network-stat__total {
|
||||
color: #f8fbff;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
@@ -1337,31 +1353,6 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.process-summary-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.process-summary-item {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.process-summary-item__label {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.process-summary-item__value {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #f8fbff;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.process-preview-item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -1448,6 +1439,7 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
|
||||
|
||||
@container (max-width: 250px) {
|
||||
.module-split--memory,
|
||||
.module-split--cpu,
|
||||
.module-split--network,
|
||||
.disk-compact-top {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -1457,6 +1449,10 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cpu-core-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.network-table__header,
|
||||
.network-table__columns,
|
||||
.network-stat,
|
||||
@@ -1487,9 +1483,6 @@ const copyIpToClipboard = async (ipAddress: string | null) => {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.cpu-module__hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 360px) {
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div ref="chartHostRef" class="cpu-history-chart">
|
||||
<div class="cpu-history-chart__header">
|
||||
<div>
|
||||
<p class="cpu-history-chart__subtitle">{{ t('statusMonitor.cpuHistoryRecentPoints', { count: displayPointCount }) }}</p>
|
||||
<h6 class="cpu-history-chart__title">{{ t('statusMonitor.cpuUsageTitle') }}</h6>
|
||||
</div>
|
||||
<span class="cpu-history-chart__latest">{{ t('statusMonitor.latestCpuValue', { value: latestCpuValue }) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="cpu-history-chart__canvas">
|
||||
<Line :data="cpuChartData" :options="cpuChartOptions" :key="chartKey" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Line } from 'vue-chartjs';
|
||||
import {
|
||||
CategoryScale,
|
||||
Chart as ChartJS,
|
||||
type ChartOptions,
|
||||
Legend,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
type TooltipItem,
|
||||
} from 'chart.js';
|
||||
|
||||
ChartJS.register(
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
CategoryScale,
|
||||
);
|
||||
|
||||
const DISPLAY_POINTS = 24;
|
||||
|
||||
const props = defineProps({
|
||||
cpuHistory: {
|
||||
type: Array as PropType<readonly (number | null)[]>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const chartHostRef = ref<HTMLElement | null>(null);
|
||||
const chartKey = ref(0);
|
||||
let chartResizeObserver: ResizeObserver | null = null;
|
||||
let lastChartHostWidth = 0;
|
||||
|
||||
const recentCpuHistory = computed(() => props.cpuHistory.slice(-DISPLAY_POINTS));
|
||||
const displayPointCount = computed(() => Math.max(recentCpuHistory.value.length, DISPLAY_POINTS));
|
||||
const latestCpuValue = computed(() => {
|
||||
const latest = recentCpuHistory.value[recentCpuHistory.value.length - 1];
|
||||
return typeof latest === 'number' && Number.isFinite(latest) ? latest.toFixed(1) : '0.0';
|
||||
});
|
||||
|
||||
const chartLabels = computed(() =>
|
||||
Array.from({ length: displayPointCount.value }, (_, index) => `${index + 1}`),
|
||||
);
|
||||
|
||||
const cpuChartData = computed(() => ({
|
||||
labels: chartLabels.value,
|
||||
datasets: [
|
||||
{
|
||||
label: t('statusMonitor.cpuUsageLabel'),
|
||||
data: recentCpuHistory.value.map(value => (value === null || value === undefined ? null : Number(value.toFixed(1)))),
|
||||
borderColor: 'rgba(125, 211, 252, 1)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.18)',
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
tension: 0.24,
|
||||
spanGaps: true,
|
||||
fill: false,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const cpuChartOptions = computed<ChartOptions<'line'>>(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
title: () => '',
|
||||
label: (context: TooltipItem<'line'>) => {
|
||||
const value = context.parsed.y;
|
||||
if (value === null) {
|
||||
return t('statusMonitor.cpuUsageLabel');
|
||||
}
|
||||
return `${t('statusMonitor.cpuUsageLabel')}: ${Number(value).toFixed(1)}%`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: false,
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
min: 0,
|
||||
max: 100,
|
||||
ticks: {
|
||||
color: '#8fa0b3',
|
||||
callback: value => `${value}%`,
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.12)',
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const rerenderChart = (): void => {
|
||||
chartKey.value += 1;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => recentCpuHistory.value,
|
||||
() => {
|
||||
rerenderChart();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
const host = chartHostRef.value;
|
||||
if (!host || typeof ResizeObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
lastChartHostWidth = Math.round(host.getBoundingClientRect().width);
|
||||
chartResizeObserver = new ResizeObserver(entries => {
|
||||
const entry = entries[0];
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextWidth = Math.round(entry.contentRect.width);
|
||||
if (!nextWidth || Math.abs(nextWidth - lastChartHostWidth) < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastChartHostWidth = nextWidth;
|
||||
nextTick(() => {
|
||||
rerenderChart();
|
||||
});
|
||||
});
|
||||
|
||||
chartResizeObserver.observe(host);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
chartResizeObserver?.disconnect();
|
||||
chartResizeObserver = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cpu-history-chart {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.08);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.02)),
|
||||
radial-gradient(circle at top left, rgba(59, 130, 246, 0.08), transparent 62%);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.cpu-history-chart__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cpu-history-chart__subtitle {
|
||||
margin: 0;
|
||||
color: #8fa0b3;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.cpu-history-chart__title {
|
||||
margin: 6px 0 0;
|
||||
color: #f8fbff;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.cpu-history-chart__latest {
|
||||
display: inline-flex;
|
||||
min-height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(96, 165, 250, 0.22);
|
||||
background: rgba(37, 99, 235, 0.18);
|
||||
padding: 0 10px;
|
||||
color: #dbeafe;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.cpu-history-chart__canvas {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 164px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cpu-history-chart__canvas :deep(canvas) {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
@container (max-width: 300px) {
|
||||
.cpu-history-chart__header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -259,22 +259,22 @@ onBeforeUnmount(() => {
|
||||
<style scoped>
|
||||
.network-history-chart {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
height: auto;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.08);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.02)),
|
||||
radial-gradient(circle at top left, rgba(59, 130, 246, 0.06), transparent 62%);
|
||||
padding: 12px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.network-history-chart__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.network-history-chart__subtitle {
|
||||
@@ -327,7 +327,7 @@ onBeforeUnmount(() => {
|
||||
.network-history-chart__canvas {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 164px;
|
||||
height: 108px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,19 @@ export function createStatusMonitorManager(sessionId: string, wsDeps: StatusMoni
|
||||
|
||||
|
||||
// --- WebSocket 消息处理 ---
|
||||
const normalizeCpuCorePercents = (values: unknown): number[] | undefined => {
|
||||
if (!Array.isArray(values)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return values.map(value => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.min(100, Number(value.toFixed(1))));
|
||||
});
|
||||
};
|
||||
|
||||
const handleStatusUpdate = (payload: MessagePayload, message?: WebSocketMessage) => {
|
||||
// 检查消息是否属于此会话
|
||||
if (message?.sessionId && message.sessionId !== sessionId) {
|
||||
@@ -49,7 +62,10 @@ export function createStatusMonitorManager(sessionId: string, wsDeps: StatusMoni
|
||||
|
||||
// console.debug(`[会话 ${sessionId}][状态监控模块] 收到 status_update:`, JSON.stringify(payload));
|
||||
if (payload?.status) {
|
||||
const newStatus: ServerStatus = payload.status;
|
||||
const newStatus: ServerStatus = {
|
||||
...payload.status,
|
||||
cpuCorePercents: normalizeCpuCorePercents(payload.status.cpuCorePercents),
|
||||
};
|
||||
serverStatus.value = newStatus;
|
||||
statusError.value = null; // 收到有效状态时清除错误
|
||||
|
||||
|
||||
@@ -689,7 +689,9 @@
|
||||
"memoryUsageTitleUnit": "Memory Usage ({unit})",
|
||||
"networkSpeedTitleUnit": "Network Speed ({unit})",
|
||||
"networkHistoryRecentPoints": "Latest {count} samples",
|
||||
"cpuHistoryRecentPoints": "Latest {count} samples",
|
||||
"cpuUsageLabel": "CPU Usage (%)",
|
||||
"cpuCoreLabel": "Core {index}",
|
||||
"memoryUsageLabelUnit": "Memory Usage ({unit})",
|
||||
"networkDownloadLabelUnit": "Download ({unit})",
|
||||
"networkUploadLabelUnit": "Upload ({unit})",
|
||||
|
||||
@@ -1424,7 +1424,9 @@
|
||||
"memoryUsageTitleUnit": "メモリ使用状況 ({unit})",
|
||||
"networkSpeedTitleUnit": "ネットワーク速度 ({unit})",
|
||||
"networkHistoryRecentPoints": "直近 {count} 件のサンプル",
|
||||
"cpuHistoryRecentPoints": "直近 {count} 件のサンプル",
|
||||
"cpuUsageLabel": "CPU使用率 (%)",
|
||||
"cpuCoreLabel": "コア {index}",
|
||||
"memoryUsageLabelUnit": "メモリ使用量 ({unit})",
|
||||
"networkDownloadLabelUnit": "ダウンロード ({unit})",
|
||||
"networkUploadLabelUnit": "アップロード ({unit})",
|
||||
|
||||
@@ -689,7 +689,9 @@
|
||||
"memoryUsageTitleUnit": "内存使用情况 ({unit})",
|
||||
"networkSpeedTitleUnit": "网络速度 ({unit})",
|
||||
"networkHistoryRecentPoints": "最近 {count} 个采样点",
|
||||
"cpuHistoryRecentPoints": "最近 {count} 个采样点",
|
||||
"cpuUsageLabel": "CPU 使用率 (%)",
|
||||
"cpuCoreLabel": "核心 {index}",
|
||||
"memoryUsageLabelUnit": "内存使用 ({unit})",
|
||||
"networkDownloadLabelUnit": "下载 ({unit})",
|
||||
"networkUploadLabelUnit": "上传 ({unit})",
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface ProcessListItem {
|
||||
export interface ServerStatus {
|
||||
cpuPercent?: number;
|
||||
cpuCores?: number;
|
||||
cpuCorePercents?: readonly number[];
|
||||
memPercent?: number;
|
||||
memUsed?: number; // MB
|
||||
memTotal?: number; // MB
|
||||
|
||||
Reference in New Issue
Block a user