add Dashboard

This commit is contained in:
Timi
2026-04-10 00:01:38 +08:00
parent d9e32c4dbe
commit 489cbb5d0f
14 changed files with 1134 additions and 54 deletions

View File

@@ -0,0 +1,653 @@
<template>
<div class="server-dashboard">
<section class="card">
<div class="head">
<h3 class="sub-title">当前状态</h3>
<t-button size="small" variant="outline" :loading="isLoading" @click="refreshDashboard">
刷新
</t-button>
</div>
<div v-if="isLoading && !snapshotView" class="loading-wrap">
<t-loading text="加载系统状态..." />
</div>
<div v-else-if="snapshotView" class="metrics">
<div class="metric-item">
<span class="metric-label">操作系统</span>
<span class="metric-value" v-text="osNameText" />
</div>
<div class="metric-item">
<span class="metric-label">CPU 使用率</span>
<span class="metric-value" v-text="cpuUsageText" />
</div>
<div class="metric-item">
<span class="metric-label">CPU 温度</span>
<span class="metric-value" v-text="cpuTemperatureText" />
</div>
<div class="metric-item">
<span class="metric-label">内存使用</span>
<span class="metric-value" v-text="memoryUsageText" />
</div>
<div class="metric-item">
<span class="metric-label">JVM 堆内存</span>
<span class="metric-value" v-text="heapUsageText" />
</div>
<div class="metric-item">
<span class="metric-label">网络吞吐</span>
<span class="metric-value" v-text="networkThroughputText" />
</div>
<div class="metric-item">
<span class="metric-label">系统运行时长</span>
<span class="metric-value" v-text="uptimeText" />
</div>
</div>
<t-empty v-else description="暂无系统状态数据" />
</section>
<section class="card">
<div class="head">
<h3 class="sub-title">历史采样30 分钟</h3>
<t-tabs class="metric-tabs" :value="historyMetric" @change="handleHistoryMetricChange">
<t-tab-panel value="cpu" label="CPU" />
<t-tab-panel value="memory" label="内存" />
<t-tab-panel value="network" label="网络" />
</t-tabs>
</div>
<div v-if="isLoading && !historyItems.length" class="loading-wrap">
<t-loading text="加载历史数据..." />
</div>
<v-chart
v-else-if="historyItems.length"
ref="historyChartRef"
class="history-chart"
:option="historyChartOption"
:autoresize="true"
/>
<t-empty v-else description="暂无历史采样数据" />
</section>
</div>
</template>
<script setup lang="ts">
import { Toast } from "tdesign-mobile-vue";
import { use } from "echarts/core";
import { SVGRenderer } from "echarts/renderers";
import { LineChart } from "echarts/charts";
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
import VChart from "vue-echarts";
import {
getSystemStatus,
getSystemStatusHistory,
resolveSystemRequestErrorMessage,
type SystemStatusHistoryPoint,
type SystemStatusSnapshotView
} from "@/api/system";
import type { EChartsOption } from "echarts";
interface RenderHistoryItem {
at: number;
atText: string;
cpuUsage: number | null;
memoryUsed: number | null;
rxSpeed: number | null;
txSpeed: number | null;
}
type HistoryMetric = "cpu" | "memory" | "network";
use([
SVGRenderer,
LineChart,
GridComponent,
TooltipComponent,
LegendComponent
]);
const isLoading = ref(false);
const snapshotView = ref<SystemStatusSnapshotView | null>(null);
const historyPoints = ref<SystemStatusHistoryPoint[]>([]);
const historyMetric = ref<HistoryMetric>("cpu");
const historyChartRef = ref<InstanceType<typeof VChart> | null>(null);
const isDarkTheme = ref(false);
let themeObserver: MutationObserver | null = null;
const dateTimeFormatter = new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false
});
const osNameText = computed(() => snapshotView.value?.snapshot?.os?.name || "--");
const cpuUsageText = computed(() => {
const usage = snapshotView.value?.snapshot?.cpu?.usagePercent;
return formatPercentText(usage);
});
const cpuTemperatureText = computed(() => {
const temperature = snapshotView.value?.snapshot?.cpu?.temperatureCelsius;
if (typeof temperature !== "number" || Number.isNaN(temperature)) {
return "--";
}
return `${temperature.toFixed(1)} °C`;
});
const memoryUsageText = computed(() => {
const memory = snapshotView.value?.snapshot?.memory;
const usedBytes = memory?.usedBytes;
const totalBytes = memory?.totalBytes;
if (typeof usedBytes !== "number" || typeof totalBytes !== "number" || totalBytes < 1) {
return "--";
}
return `${formatBytes(usedBytes)} / ${formatBytes(totalBytes)} (${formatPercentText(memory.usagePercent)})`;
});
const heapUsageText = computed(() => {
const jvm = snapshotView.value?.snapshot?.jvm;
const usedBytes = jvm?.heapUsedBytes;
const maxBytes = jvm?.heapMaxBytes;
if (typeof usedBytes !== "number" || typeof maxBytes !== "number" || maxBytes < 1) {
return "--";
}
const usagePercent = usedBytes / maxBytes * 100;
return `${formatBytes(usedBytes)} / ${formatBytes(maxBytes)} (${formatPercentText(usagePercent)})`;
});
const networkThroughputText = computed(() => {
const network = snapshotView.value?.snapshot?.network;
const rx = network?.rxBytesPerSecond;
const tx = network?.txBytesPerSecond;
if (typeof rx !== "number" || typeof tx !== "number") {
return "--";
}
return `${formatSpeed(rx)}${formatSpeed(tx)}`;
});
const uptimeText = computed(() => {
const uptime = snapshotView.value?.snapshot?.os?.uptimeMs;
if (typeof uptime !== "number" || uptime < 0) {
return "--";
}
return formatDuration(uptime);
});
const historyItems = computed<RenderHistoryItem[]>(() => {
return historyPoints.value
.slice()
.sort((left, right) => right.at - left.at)
.slice(0, 12)
.map((point) => ({
at: point.at,
atText: formatDateTime(point.at),
cpuUsage: normalizeNumber(point.cpuUsagePercent),
memoryUsed: normalizeNumber(point.memoryUsedBytes),
rxSpeed: normalizeNumber(point.rxBytesPerSecond),
txSpeed: normalizeNumber(point.txBytesPerSecond)
}));
});
const historyChartOption = computed<EChartsOption>(() => {
const axisColor = isDarkTheme.value ? "#9aa4b2" : "#7e8a9a";
const lineColor = isDarkTheme.value ? "rgba(255, 255, 255, .12)" : "rgba(18, 42, 66, .08)";
const legendColor = isDarkTheme.value ? "#d7dee8" : "#334155";
if (historyMetric.value === "cpu") {
return {
tooltip: {
trigger: "axis"
},
grid: {
top: 40,
left: 8,
right: 8,
bottom: 8,
containLabel: true
},
xAxis: {
type: "category",
boundaryGap: false,
data: historyItems.value.map((item) => item.atText),
axisLabel: {
color: axisColor
},
axisLine: {
lineStyle: {
color: lineColor
}
}
},
yAxis: {
type: "value",
min: 0,
max: 100,
axisLabel: {
color: axisColor,
formatter: "{value}%"
},
splitLine: {
lineStyle: {
color: lineColor
}
}
},
series: [
{
type: "line",
name: "CPU 使用率",
showSymbol: false,
smooth: true,
lineStyle: {
width: 2
},
areaStyle: {
opacity: .18
},
data: historyItems.value.map((item) => item.cpuUsage)
}
]
};
}
if (historyMetric.value === "memory") {
return {
tooltip: {
trigger: "axis"
},
grid: {
top: 40,
left: 8,
right: 8,
bottom: 8,
containLabel: true
},
xAxis: {
type: "category",
boundaryGap: false,
data: historyItems.value.map((item) => item.atText),
axisLabel: {
color: axisColor
},
axisLine: {
lineStyle: {
color: lineColor
}
}
},
yAxis: {
type: "value",
axisLabel: {
color: axisColor,
formatter: (value: number) => formatBytes(value)
},
splitLine: {
lineStyle: {
color: lineColor
}
}
},
series: [
{
type: "line",
name: "内存使用",
showSymbol: false,
smooth: true,
lineStyle: {
width: 2
},
areaStyle: {
opacity: .18
},
data: historyItems.value.map((item) => item.memoryUsed)
}
]
};
}
return {
tooltip: {
trigger: "axis"
},
legend: {
top: 0,
textStyle: {
color: legendColor
}
},
grid: {
top: 40,
left: 8,
right: 8,
bottom: 8,
containLabel: true
},
xAxis: {
type: "category",
boundaryGap: false,
data: historyItems.value.map((item) => item.atText),
axisLabel: {
color: axisColor
},
axisLine: {
lineStyle: {
color: lineColor
}
}
},
yAxis: {
type: "value",
axisLabel: {
color: axisColor,
formatter: (value: number) => `${formatBytes(value)}/s`
},
splitLine: {
lineStyle: {
color: lineColor
}
}
},
series: [
{
type: "line",
name: "接收",
showSymbol: false,
smooth: true,
lineStyle: {
width: 2
},
data: historyItems.value.map((item) => item.rxSpeed)
},
{
type: "line",
name: "发送",
showSymbol: false,
smooth: true,
lineStyle: {
width: 2
},
data: historyItems.value.map((item) => item.txSpeed)
}
]
};
});
onMounted(() => {
syncTheme();
window.addEventListener("resize", handleWindowResize);
themeObserver = new MutationObserver(() => {
syncTheme();
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"]
});
void refreshDashboard();
});
onUnmounted(() => {
window.removeEventListener("resize", handleWindowResize);
themeObserver?.disconnect();
themeObserver = null;
});
async function refreshDashboard(): Promise<void> {
if (isLoading.value) {
return;
}
isLoading.value = true;
try {
const [statusView, historyView] = await Promise.all([
getSystemStatus("os,cpu,memory,network,jvm"),
getSystemStatusHistory({
window: "30m",
metrics: "cpu,memory,network"
})
]);
snapshotView.value = statusView;
historyPoints.value = historyView.points || [];
} catch (error) {
Toast({
theme: "error",
message: resolveSystemRequestErrorMessage(error)
});
} finally {
isLoading.value = false;
}
}
function handleHistoryMetricChange(value: string): void {
const nextValue = value as HistoryMetric;
if (nextValue === "cpu" || nextValue === "memory" || nextValue === "network") {
historyMetric.value = nextValue;
}
}
function syncTheme(): void {
const htmlDark = document.documentElement.classList.contains("theme-dark");
const bodyDark = document.body.classList.contains("theme-dark");
isDarkTheme.value = htmlDark || bodyDark;
}
function handleWindowResize(): void {
historyChartRef.value?.resize();
}
function formatDateTime(value?: number): string {
if (!value) {
return "--";
}
return dateTimeFormatter.format(value);
}
function formatPercentText(value?: number | null): string {
if (typeof value !== "number" || Number.isNaN(value)) {
return "--";
}
return `${value.toFixed(1)}%`;
}
function formatSpeed(value?: number | null): string {
if (typeof value !== "number" || Number.isNaN(value) || value < 0) {
return "--";
}
return `${formatBytes(value)}/s`;
}
function formatBytes(value?: number | null): string {
if (typeof value !== "number" || Number.isNaN(value) || value < 0) {
return "--";
}
if (value < 1024) {
return `${value} B`;
}
const units = ["KB", "MB", "GB", "TB", "PB"];
let nextValue = value / 1024;
let index = 0;
while (1024 <= nextValue && index < units.length - 1) {
nextValue /= 1024;
index += 1;
}
if (10 <= nextValue) {
return `${nextValue.toFixed(0)} ${units[index]}`;
}
if (1 <= nextValue) {
return `${nextValue.toFixed(1)} ${units[index]}`;
}
return `${nextValue.toFixed(2)} ${units[index]}`;
}
function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const secondsInDay = 24 * 3600;
const secondsInHour = 3600;
const secondsInMinute = 60;
const days = Math.floor(totalSeconds / secondsInDay);
const hours = Math.floor(totalSeconds % secondsInDay / secondsInHour);
const minutes = Math.floor(totalSeconds % secondsInHour / secondsInMinute);
const seconds = totalSeconds % secondsInMinute;
const parts: string[] = [];
if (0 < days) {
parts.push(`${days}`);
}
if (0 < hours || parts.length) {
parts.push(`${hours} 小时`);
}
if (0 < minutes || parts.length) {
parts.push(`${minutes} 分钟`);
}
parts.push(`${seconds}`);
return parts.join(" ");
}
function normalizeNumber(value?: number | null): number | null {
if (typeof value !== "number" || Number.isNaN(value)) {
return null;
}
return value;
}
</script>
<style scoped lang="less">
.server-dashboard {
gap: 1rem;
display: flex;
padding: 1rem;
flex-direction: column;
.card {
gap: .75rem;
border: 1px solid var(--app-line);
display: flex;
padding: 1rem;
background: #FFF;
border-radius: 1rem;
flex-direction: column;
box-shadow: 0 .35rem 1rem rgba(17, 32, 56, .05);
}
.intro {
gap: .5rem;
}
.tag,
.title,
.desc,
.time,
.sub-title {
margin: 0;
}
.tag,
.desc,
.time {
color: var(--app-sub);
}
.tag {
font-size: .875rem;
}
.title {
font-size: 1.1rem;
}
.desc {
line-height: 1.6;
}
.time {
font-size: .8125rem;
}
.head {
gap: .75rem;
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: space-between;
}
.sub-title {
font-size: 1rem;
color: var(--app-text);
}
.metric-tabs {
width: 100%;
}
.loading-wrap {
display: flex;
min-height: 4rem;
align-items: center;
justify-content: center;
}
.history-chart {
width: 100%;
height: 15rem;
}
.metrics {
gap: .75rem;
display: flex;
flex-direction: column;
}
.metric-item {
display: flex;
align-items: center;
justify-content: space-between;
}
.metric-label {
font-size: .875rem;
color: var(--app-sub);
}
.metric-value {
font-weight: 600;
font-size: .875rem;
color: var(--app-text);
}
:deep(.metric-tabs .t-tabs__nav) {
padding: 0;
}
}
:global(.theme-dark) .server-dashboard {
.card {
box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .2);
}
}
</style>