Files
timi-server/src/pages/dashboard/ServerDashboard/ServerDashboard.vue
2026-04-13 14:55:51 +08:00

784 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="server-dashboard">
<div v-if="isSnapshotLoading && !snapshotView" class="loading-wrap">
<t-loading text="加载系统状态..." />
</div>
<template v-else-if="snapshotView">
<t-cell-group title="系统" theme="card">
<t-cell title="操作系统" :note="Text.display(snapshotView?.snapshot?.os?.name)" />
<t-cell title="启动时间" :note="Time.toPassedDateTime(snapshotView?.snapshot?.os?.bootAt)" />
<t-cell
v-if="snapshotView?.snapshot?.os?.bootAt"
title="运行时长"
:note="Time.duration(Time.now() - snapshotView?.snapshot?.os?.bootAt)"
/>
<t-cell title="更多" arrow @click="router.push('/server/system-detail')" />
</t-cell-group>
<t-cell-group class="section performance" title="资源" theme="card">
<t-cell-info label="CPU" :value="Text.display(snapshotView?.snapshot?.cpu?.model)">
<progress-group
mode="heap"
:progress="cpuProgressList"
:note="Text.unit((snapshotView?.snapshot?.cpu?.usageTotal || 0) * 100, '%')"
/>
</t-cell-info>
<t-cell-info class="memory" label="内存" :value="memoryUsageText">
<progress-group
mode="splice"
:progress="memoryProgressList"
:note="Text.unit(memoryUsed * 100, '%')"
/>
</t-cell-info>
<t-cell-info class="memory-jvm" label="JVM 内存" :value="IOSize.format(jvmMemoryHeapMax)">
<progress-group
mode="heap"
:progress="jvmMemoryProgressList"
:note="Text.unit(jvmMemoryHeapCommited / jvmMemoryHeapMax * 100, '%')"
/>
</t-cell-info>
<t-cell title="网络发送速率" :note="IOSize.speed(snapshotView?.snapshot?.network?.txBytesPerSecond)">
<template #left-icon>
<t-icon name="arrow-up" />
</template>
</t-cell>
<t-cell title="网络接收速率" :note="IOSize.speed(snapshotView?.snapshot?.network?.rxBytesPerSecond)">
<template #left-icon>
<t-icon name="arrow-down" />
</template>
</t-cell>
<t-cell title="更多" arrow @click="router.push('/server/performance-detail')" />
</t-cell-group>
<t-cell-group class="section disk" title="磁盘" theme="card">
<t-cell-info
class="item"
v-for="item in snapshotView?.snapshot?.storagePartitions"
:key="item.uuid"
:label="item.mountPoint || item.partitionId"
:value="`${IOSize.format(item.used)} / ${IOSize.format(item.total)}`"
clip-text="label"
>
<progress-group
mode="heap"
:progress="[{
color: 'var(--td-brand-color)',
value: item.used / item.total
}]"
:note="Text.unit(item.used / item.total * 100, '%')"
/>
</t-cell-info>
<t-cell title="更多" arrow @click="router.push('/server/partitions-detail')" />
</t-cell-group>
</template>
<t-empty v-else description="暂无系统状态数据" />
<t-cell-group class="section history" title="历史采样" theme="card">
<t-tabs class="metric-tabs" :value="historyMetric" @change="handleHistoryMetricChange">
<t-tab-panel
v-for="item in historyMetricTabs"
:key="item.value"
:value="item.value"
:label="item.label"
/>
</t-tabs>
<div v-if="isHistoryLoading && !historyItems.length" class="loading-wrap">
<t-loading text="加载历史数据..." />
</div>
<template v-else-if="historyItems.length">
<v-chart
ref="chartRef"
class="chart"
:option="historyChartOption"
:update-options="historyChartUpdateOptions"
:autoresize="true"
/>
<div class="slider bg-white">
<span class="label">较早</span>
<t-slider
class="control"
:min="0"
:max="Math.max(historyItems.length - historyVisibleCount, 0)"
:step="1"
:value="historySliderValue"
@change="handleHistorySliderChange"
/>
<span class="label">最新</span>
</div>
<t-cell-group>
<t-cell v-for="item in currentHistorySummary" :key="item.label" :title="item.label" :note="item.value" />
</t-cell-group>
</template>
<t-empty v-else description="暂无历史采样数据" />
</t-cell-group>
</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 { useRouter } from "vue-router";
import type { EChartsOption, SeriesOption } from "echarts";
import SystemAPI from "@/api/SystemAPI";
import type { SystemStatusHistoryPoint, SystemStatusSnapshotView } from "@/types/System";
import type { DashboardHistoryMetric } from "@/store/settingStore";
import { useSettingStore } from "@/store/settingStore";
import { IOSize, type LabelValue, Text, Time, Toolkit } from "timi-web";
import TCellInfo from "@/components/TCellInfo.vue";
import type { ProgressItem } from "@/components/ProgressGroup.vue";
import ProgressGroup from "@/components/ProgressGroup.vue";
type HistoryMetric = DashboardHistoryMetric;
use([
SVGRenderer,
LineChart,
GridComponent,
TooltipComponent,
LegendComponent
]);
defineOptions({
name: "ServerDashboard"
});
const router = useRouter();
const settingStore = useSettingStore();
const isSnapshotLoading = ref(false);
const snapshotView = ref<SystemStatusSnapshotView | null>(null);
// ---------- CPU ----------
const cpuProgressList = computed<ProgressItem[]>(() => {
const system = snapshotView.value?.snapshot?.cpu?.usageSystem || 0;
const total = snapshotView.value?.snapshot?.cpu?.usageTotal || 0;
return [
{
color: "var(--td-brand-color)",
value: system,
legend: `系统占用(${Text.unit(system * 100, '%')}`
},
{
color: "var(--td-brand-color-5)",
value: total,
legend: `总占用(${Text.unit(total * 100, '%')}`
}
];
});
// ---------- 内存 ----------
const memoryUsed = computed(() => {
const usedBytes = snapshotView.value?.snapshot?.memory?.usedBytes || 1;
const totalBytes = snapshotView.value?.snapshot?.memory?.totalBytes || 1;
return usedBytes / totalBytes;
});
const memoryUsageText = computed(() => {
const usedBytes = snapshotView.value?.snapshot?.memory?.usedBytes;
const totalBytes = snapshotView.value?.snapshot?.memory?.totalBytes;
return `${IOSize.format(usedBytes)} / ${IOSize.format(totalBytes)}`;
});
const memoryTotal = computed(() => {
const total = snapshotView.value?.snapshot?.memory?.totalBytes || 0;
const totalSwap = snapshotView.value?.snapshot?.memory?.swapTotalBytes || 0;
return total + totalSwap;
});
const memoryProgressList = computed<ProgressItem[]>(() => {
const used = snapshotView.value?.snapshot?.memory?.usedBytes || 0;
const usedSwap = snapshotView.value?.snapshot?.memory?.swapUsedBytes || 0;
return [
{
color: "var(--td-brand-color)",
value: used / memoryTotal.value,
legend: `物理(${IOSize.format(used)}`
},
{
color: "var(--td-brand-color-5)",
value: usedSwap / memoryTotal.value,
legend: `交换区(${IOSize.format(usedSwap)}`
}
]
});
// ---------- JVM 内存 ----------
const jvmMemoryHeapMax = computed(() => snapshotView.value?.snapshot?.jvm?.heapMaxBytes || 0);
const jvmMemoryHeapCommited = computed(() => snapshotView.value?.snapshot?.jvm?.heapCommittedBytes || 0);
const jvmMemoryHeapUsed = computed(() => snapshotView.value?.snapshot?.jvm?.heapUsedBytes || 0);
const jvmMemoryProgressList = computed<ProgressItem[]>(() => {
return [
{
color: "var(--td-brand-color)",
value: jvmMemoryHeapCommited.value / jvmMemoryHeapMax.value,
legend: `已提交(${IOSize.format(jvmMemoryHeapCommited.value)}`
},
{
color: "var(--td-brand-color-5)",
value: jvmMemoryHeapUsed.value / jvmMemoryHeapMax.value,
legend: `已使用(${IOSize.format(jvmMemoryHeapUsed.value)}`
},
]
})
// ---------- 历史采样图表 ----------
const isHistoryLoading = ref(false);
const historyPoints = ref<SystemStatusHistoryPoint[]>([]);
const chartRef = ref<InstanceType<typeof VChart> | null>(null);
const historyChartUpdateOptions = Object.freeze({
notMerge: true
});
const isDarkTheme = ref(false);
// ---------- Tab ----------
const historyMetric = ref<HistoryMetric>("cpu");
const historyMetricTabs = computed(() => {
const labelMap: Record<HistoryMetric, string> = {
cpu: "CPU",
memory: "内存",
jvm: "JVM",
network: "网络"
};
return settingStore.dashboard.server.historyMetrics.map((metric) => ({
value: metric,
label: labelMap[metric]
}));
});
watch(
() => historyMetricTabs.value,
(tabs) => {
if (!tabs.length) {
historyMetric.value = "cpu";
return;
}
if (!tabs.some((item) => item.value === historyMetric.value)) {
historyMetric.value = tabs[0].value;
}
},
{ immediate: true }
);
function handleHistoryMetricChange(value: string): void {
const nextValue = value as HistoryMetric;
if (nextValue === "cpu" || nextValue === "memory" || nextValue === "jvm" || nextValue === "network") {
historyMetric.value = nextValue;
}
}
// ---------- 图表数据 ----------
const historyItems = computed<SystemStatusHistoryPoint[]>(() => {
return historyPoints.value.slice().sort((left, right) => left.at - right.at);
});
const currentHistoryRange = computed(() => {
const length = historyItems.value.length;
if (length < 1) {
return { startValue: 0, endValue: 0 };
}
const startValue = Math.min(Math.max(Math.floor(historySliderValue.value), 0), historySliderMaxStart.value);
const endValue = Math.min(startValue + historyVisibleCount.value - 1, length - 1);
return { startValue, endValue };
});
const visibleHistoryItems = computed<SystemStatusHistoryPoint[]>(() => {
if (!historyItems.value.length) {
return [];
}
return historyItems.value.slice(currentHistoryRange.value.startValue, currentHistoryRange.value.endValue + 1);
});
// ---------- 调整图表数据范围 ----------
const isHistorySliderTouched = ref(false);
const historySliderValue = ref(0);
const HISTORY_DEFAULT_VISIBLE_COUNT = 30;
const historyVisibleCount = computed(() => {
if (historyItems.value.length < 1) {
return 1;
}
return Math.min(HISTORY_DEFAULT_VISIBLE_COUNT, historyItems.value.length);
});
const historySliderMaxStart = computed(() => {
return Math.max(historyItems.value.length - historyVisibleCount.value, 0);
});
watch(() => historyItems.value.length, (length) => {
if (length < 1) {
historySliderValue.value = 0;
isHistorySliderTouched.value = false;
return;
}
if (!isHistorySliderTouched.value) {
historySliderValue.value = historySliderMaxStart.value;
return;
}
historySliderValue.value = Math.min(historySliderValue.value, historySliderMaxStart.value);
});
function handleHistorySliderChange(value: number): void {
historySliderValue.value = Math.min(Math.max(Math.floor(value), 0), historySliderMaxStart.value);
isHistorySliderTouched.value = true;
}
// ---------- 当前图表摘要 ----------
const currentHistorySummary = computed<LabelValue<string, any>[]>(() => {
if (!historyItems.value.length) {
return [];
}
const lastPoint = historyItems.value[historyItems.value.length - 1];
const summary: LabelValue<string, any>[] = [];
if (historyMetric.value === "cpu") {
summary.push(
{ label: "总使用率", value: Text.unit(lastPoint.cpuUsagePercent * 100, "%") },
{ label: "系统使用率", value: Text.unit(lastPoint.cpuSystemPercent * 100, "%") }
);
return summary;
}
if (historyMetric.value === "memory") {
summary.push(
{ label: "物理", value: IOSize.format(lastPoint.memoryUsedBytes) },
{ label: "交换区", value: IOSize.format(lastPoint.swapUsedBytes) }
);
return summary;
}
if (historyMetric.value === "jvm") {
summary.push(
{ label: "已使用", value: IOSize.format(lastPoint.heapUsedBytes) },
{ label: "已提交", value: IOSize.format(lastPoint.heapCommittedBytes) },
{ label: "GC 周期耗时", value: Text.unit(lastPoint.gcCycleTimeMs, "毫秒") },
{ label: "GC 暂停耗时", value: Text.unit(lastPoint.gcPauseTimeMs, "毫秒") }
);
return summary;
}
summary.push(
{ label: "接收", value: IOSize.speed(lastPoint.rxBytesPerSecond) },
{ label: "发送", value: IOSize.speed(lastPoint.txBytesPerSecond) }
);
return summary;
});
// ---------- 图表配置 ----------
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";
const memoryAxisMax = Math.max(snapshotView.value?.snapshot?.memory?.totalBytes || 0, 1);
// 通用配置
const commonOption: EChartsOption = {
tooltip: {
trigger: "axis",
confine: true,
position: (point, _params, _dom, _rect, size) => {
const gap = 6;
const fingerGap = 24;
const contentSize = size?.contentSize || [0, 0];
const viewSize = size?.viewSize || [0, 0];
const maxLeft = Math.max(viewSize[0] - contentSize[0] - gap, gap);
const maxTop = Math.max(viewSize[1] - contentSize[1] - gap, gap);
const clamp = (value: number, min: number, max: number): number => {
return Math.min(Math.max(value, min), max);
};
const preferLeft = point[0] - contentSize[0] - gap;
const preferRight = point[0] + gap;
const left = point[0] < viewSize[0] / 2
? clamp(preferRight, gap, maxLeft)
: clamp(preferLeft, gap, maxLeft);
const preferTop = point[1] - contentSize[1] - fingerGap;
const preferBottom = point[1] + fingerGap;
const top = preferTop >= gap
? clamp(preferTop, gap, maxTop)
: clamp(preferBottom, gap, maxTop);
return [left, top];
}
},
legend: {
top: 10,
textStyle: {
color: legendColor
}
},
grid: {
top: 40,
left: 8,
right: 8,
bottom: 8,
containLabel: true
},
xAxis: {
type: "category",
boundaryGap: false,
data: visibleHistoryItems.value.map((item) => item.at),
axisLabel: {
color: axisColor,
formatter: val => Time.toShortTime(Number(val))
},
axisLine: {
lineStyle: {
color: lineColor
}
}
}
};
if (!historyItems.value.length) {
return commonOption;
}
if (historyMetric.value === "cpu") {
return {
...commonOption,
tooltip: {
...commonOption.tooltip,
formatter: params => {
const list = Array.isArray(params) ? params : [params];
const title = `时间:${Time.toDateTime(Number(list[0]?.name))}`
const lines = list.map(item => `${item.marker}${item.seriesName}: ${Text.unit(Number(item.value) * 100, " %")}`);
return [title, ...lines].join("<br />");
}
},
yAxis: {
type: "value",
min: 0,
max: 1,
axisLabel: {
color: axisColor,
formatter: val => `${val * 100} %`
},
splitLine: {
lineStyle: {
color: lineColor
}
}
},
series: [
resolveLineSeries("总使用率", visibleHistoryItems.value.map((item) => item.cpuUsagePercent!)),
resolveLineSeries("系统使用率", visibleHistoryItems.value.map((item) => item.cpuSystemPercent!))
]
};
}
if (historyMetric.value === "memory") {
return {
...commonOption,
tooltip: {
...commonOption.tooltip,
formatter: (params) => {
const list = Array.isArray(params) ? params : [params];
const title = `时间:${Time.toDateTime(Number(list[0]?.name))}`
const lines = list.map(item => `${item.marker}${item.seriesName}: ${IOSize.format(Number(item.value))}`);
return [title, ...lines].join("<br />");
}
},
yAxis: {
type: "value",
max: memoryAxisMax,
axisLabel: {
color: axisColor,
formatter: (value: number) => IOSize.format(value)
},
splitLine: {
lineStyle: {
color: lineColor
}
}
},
series: [
resolveLineSeries("物理", visibleHistoryItems.value.map((item) => item.memoryUsedBytes!)),
resolveLineSeries("交换区", visibleHistoryItems.value.map((item) => item.swapUsedBytes!))
]
};
}
if (historyMetric.value === "jvm") {
return {
...commonOption,
tooltip: {
...commonOption.tooltip,
formatter: (params) => {
const list = Array.isArray(params) ? params : [params];
const title = `时间:${Time.toDateTime(Number(list[0]?.name))}`
const lines = list.map(item => {
const seriesName = item?.seriesName || "";
if (seriesName === "GC 周期耗时" || seriesName === "GC 暂停耗时") {
return `${item.marker}${item.seriesName}: ${Text.unit(Number(item.value), " 毫秒")}`
} else {
return `${item.marker}${item.seriesName}: ${IOSize.format(Number(item.value))}`
}
});
return [title, ...lines].join("<br />");
}
},
yAxis: [
{
type: "value",
axisLabel: {
color: axisColor,
formatter: (val: number) => IOSize.format(val)
},
splitLine: {
lineStyle: {
color: lineColor
}
}
},
{
type: "value",
axisLabel: {
color: axisColor,
formatter: "{value} ms"
},
splitLine: {
show: false
}
}
],
series: [
resolveLineSeries("已使用", visibleHistoryItems.value.map((item) => item.heapUsedBytes!)),
resolveLineSeries("已提交", visibleHistoryItems.value.map((item) => item.heapCommittedBytes!)),
resolveLineSeries("GC 周期", visibleHistoryItems.value.map((item) => item.gcCycleTimeMs!), 1),
resolveLineSeries("GC 暂停", visibleHistoryItems.value.map((item) => item.gcPauseTimeMs!), 1)
]
};
}
if (historyMetric.value === "network") {
return {
// 网络
...commonOption,
tooltip: {
...commonOption.tooltip,
formatter: (params) => {
const list = Array.isArray(params) ? params : [params];
const title = `时间:${Time.toDateTime(Number(list[0]?.name))}`
const lines = list.map(item => `${item.marker}${item.seriesName}: ${IOSize.speed(Number(item.value))}`);
return [title, ...lines].join("<br />");
}
},
yAxis: {
type: "value",
axisLabel: {
color: axisColor,
formatter: (val: number) => IOSize.format(val)
},
splitLine: {
lineStyle: {
color: lineColor
}
}
},
series: [
resolveLineSeries("接收", visibleHistoryItems.value.map((item) => item.rxBytesPerSecond!)),
resolveLineSeries("发送", visibleHistoryItems.value.map((item) => item.txBytesPerSecond!))
]
};
}
return commonOption;
});
watch(() => settingStore.dashboard.server, restartAutoRefresh, { deep: true });
function resolveLineSeries(name: string, data: Array<number | null>, yAxisIndex?: number): SeriesOption {
const series: SeriesOption = {
type: "line",
name,
showSymbol: false,
smooth: true,
lineStyle: {
width: 2
},
areaStyle: {
opacity: .18
},
data
};
if (typeof yAxisIndex === "number") {
series.yAxisIndex = yAxisIndex;
}
return series;
}
// ---------- 加载数据:当前状态 ----------
let snapshotTimer: number | undefined;
onUnmounted(() => {
window.clearInterval(snapshotTimer);
snapshotTimer = undefined;
});
async function refreshSnapshot(): Promise<void> {
if (isSnapshotLoading.value) {
return;
}
isSnapshotLoading.value = true;
try {
const metrics = settingStore.dashboard.server.snapshotMetrics.join(",");
snapshotView.value = await SystemAPI.getStatus(metrics);
} catch (error) {
Toast({
theme: "error",
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
});
} finally {
isSnapshotLoading.value = false;
}
}
// ---------- 加载数据:历史采集 ----------
let historyTimer: number | undefined;
async function refreshHistory(): Promise<void> {
if (isHistoryLoading.value) {
return;
}
isHistoryLoading.value = true;
try {
const metrics = settingStore.dashboard.server.historyMetrics.join(",");
const historyView = await SystemAPI.getStatusHistory({
window: "1h",
metrics
});
historyPoints.value = historyView.points || [];
} catch (error) {
Toast({
theme: "error",
message: error instanceof Error ? error.message : "请求失败,请稍后重试"
});
} finally {
isHistoryLoading.value = false;
}
}
onUnmounted(() => {
window.clearInterval(historyTimer);
historyTimer = undefined;
});
// ---------- 自动刷新 ----------
watch(() => settingStore.dashboard.server, restartAutoRefresh, { deep: true });
async function refreshDashboard(): Promise<void> {
await Promise.all([
refreshSnapshot(),
refreshHistory()
]);
}
function restartAutoRefresh(): void {
void refreshDashboard();
clearAutoRefreshTimer();
const snapshotIntervalMs = Math.max(settingStore.dashboard.server.snapshotRefreshSeconds, 1) * 1000;
const historyIntervalMs = Math.max(settingStore.dashboard.server.historyRefreshSeconds, 1) * 1000;
snapshotTimer = window.setInterval(() => {
void refreshSnapshot();
}, snapshotIntervalMs);
historyTimer = window.setInterval(() => {
void refreshHistory();
}, historyIntervalMs);
}
function clearAutoRefreshTimer(): void {
if (snapshotTimer !== null) {
window.clearInterval(snapshotTimer);
snapshotTimer = undefined;
}
if (historyTimer !== null) {
window.clearInterval(historyTimer);
historyTimer = undefined;
}
}
onMounted(restartAutoRefresh);
</script>
<style scoped lang="less">
.server-dashboard {
gap: 1rem;
display: flex;
flex-direction: column;
.section {
&.disk {
.collapses {
:deep(.t-cell__title) {
margin-right: 0;
}
.content {
width: 100%;
gap: .4rem;
display: flex;
flex-direction: column;
.header {
gap: .5rem;
display: flex;
align-items: center;
justify-content: space-between;
.title {
color: var(--app-text);
font-size: .875rem;
font-weight: bold;
}
.health {
font-size: .75rem;
color: var(--app-sub);
}
}
}
}
}
&.history {
.metric-tabs {
width: 100%;
--td-tab-track-width: 4rem;
}
.chart {
width: 100%;
height: 16rem;
background: #FFF;
}
.slider {
gap: .5rem;
display: flex;
padding: .5rem 2rem;
align-items: center;
.label {
flex: none;
font-size: .75rem;
color: var(--app-sub);
}
.control {
flex: 1;
}
}
}
}
.loading-wrap {
display: flex;
min-height: 4rem;
align-items: center;
justify-content: center;
}
}
</style>