add Dashboard
This commit is contained in:
653
src/pages/dashboard/ServerDashboard/ServerDashboard.vue
Normal file
653
src/pages/dashboard/ServerDashboard/ServerDashboard.vue
Normal 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>
|
||||
Reference in New Issue
Block a user