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:
yinjianm
2026-04-19 04:18:33 +08:00
parent 0e01153157
commit 41f237c15d
25 changed files with 1449 additions and 159 deletions
+107 -114
View File
@@ -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) {