diff --git a/package.json b/package.json index 5970253..7aa2b4c 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,8 @@ "music-metadata-browser": "^2.5.11", "pinia": "^3.0.2", "tdesign-mobile-vue": "^1.13.2", - "timi-tdesign-mobile": "0.0.2", - "timi-web": "0.0.3", + "timi-tdesign-mobile": "0.0.9", + "timi-web": "0.0.15", "ts-node": "^10.9.2", "vue": "^3.5.16", "vue-router": "4.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ca4ea7..116df08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,11 +27,11 @@ importers: specifier: ^1.13.2 version: 1.13.2(vue@3.5.31(typescript@5.8.3)) timi-tdesign-mobile: - specifier: 0.0.2 - version: 0.0.2(typescript@5.8.3) + specifier: 0.0.9 + version: 0.0.9(typescript@5.8.3) timi-web: - specifier: 0.0.3 - version: 0.0.3 + specifier: 0.0.15 + version: 0.0.15 ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@24.12.0)(typescript@5.8.3) @@ -1828,12 +1828,12 @@ packages: engines: {node: '>=10'} hasBin: true - timi-tdesign-mobile@0.0.2: - resolution: {integrity: sha512-R24yS4ovWTadCmpqJz3ajikMgk8YkKiUFbOIHuw3DBaJc2NwUcvVtY7aPu6kqnT/4j4AN3DgUFY+WkZbxqEhgQ==} + timi-tdesign-mobile@0.0.9: + resolution: {integrity: sha512-SV8m/FsLsEabSmJ/NvI650fLyb/JCLz7JUNQrkWeoo5xICElsN4x39L3n4lTzu6ARidjpT6uA+XEmA4wnyQmuA==} engines: {node: '>=16.0.0'} - timi-web@0.0.3: - resolution: {integrity: sha512-uCJ+XQf1DydvnZHyI5NmQxKmZWvkVNPRrhqEszhMEh4CKGMPG7wrNiTl6jAPDcY3x228AaRP5MPu6TzIs9GfFQ==} + timi-web@0.0.15: + resolution: {integrity: sha512-j5CU8Byd9qdK7AWUGM5vgQ5Tix20m/WrD5Sv4h77aFV0G/rqdKi8uoRg1uUXUZLqsIy1zjI7kbIV3DnRplBYog==} engines: {node: '>=16.0.0'} tinycolor2@1.6.0: @@ -3868,14 +3868,14 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 - timi-tdesign-mobile@0.0.2(typescript@5.8.3): + timi-tdesign-mobile@0.0.9(typescript@5.8.3): dependencies: vue: 3.5.31(typescript@5.8.3) vue-router: 4.5.0(vue@3.5.31(typescript@5.8.3)) transitivePeerDependencies: - typescript - timi-web@0.0.3: + timi-web@0.0.15: dependencies: axios: 1.13.5 less: 4.5.1 diff --git a/src/api/file.ts b/src/api/FileAPI.ts similarity index 97% rename from src/api/file.ts rename to src/api/FileAPI.ts index 1e980a2..f6310ae 100644 --- a/src/api/file.ts +++ b/src/api/FileAPI.ts @@ -1,6 +1,6 @@ import axios, { AxiosError } from "axios"; +import type { ServerFile } from "@/types/File"; import { useSettingStore } from "@/store/settingStore"; -import type { ServerFile } from "@/pages/file/fileExplorer.shared"; interface ApiResponse { code?: number | string; diff --git a/src/api/system.ts b/src/api/SystemAPI.ts similarity index 61% rename from src/api/system.ts rename to src/api/SystemAPI.ts index bc59a79..5425210 100644 --- a/src/api/system.ts +++ b/src/api/SystemAPI.ts @@ -1,5 +1,6 @@ import axios, { AxiosError } from "axios"; import { useSettingStore } from "@/store/settingStore"; +import type { SystemStatusHistoryView, SystemStatusSnapshotView } from "@/types/System"; interface ApiResponse { code?: number | string; @@ -8,87 +9,6 @@ interface ApiResponse { message?: string; } -export interface SystemStatusSnapshotView { - serverTime: number; - sampleRateMs: number; - snapshot: SystemStatusSnapshot; -} - -export interface SystemStatusHistoryView { - serverTime: number; - sampleRateMs: number; - from: number; - to: number; - points: SystemStatusHistoryPoint[]; -} - -export interface SystemStatusSnapshot { - os?: { - name?: string; - bootAt?: number; - uptimeMs?: number; - }; - cpu?: { - model?: string; - physicalCores?: number; - logicalCores?: number; - usagePercent?: number | null; - systemPercent?: number | null; - temperatureCelsius?: number; - }; - memory?: { - totalBytes?: number; - usedBytes?: number | null; - usagePercent?: number | null; - swapTotalBytes?: number; - swapUsedBytes?: number | null; - }; - jvm?: { - name?: string; - version?: string; - bootAt?: number; - heapInitBytes?: number; - heapMaxBytes?: number; - heapUsedBytes?: number | null; - heapCommittedBytes?: number | null; - gc?: { - collector?: string; - cycleCount?: number; - pauseCount?: number; - lastPauseAt?: number; - lastRecoveredBytes?: number; - }; - }; - network?: { - interfaceName?: string; - mac?: string; - rxBytesPerSecond?: number; - txBytesPerSecond?: number; - rxTotalBytes?: number; - txTotalBytes?: number; - rxPacketsTotal?: number; - txPacketsTotal?: number; - inErrors?: number; - outErrors?: number; - inDrops?: number; - collisions?: number; - }; -} - -export interface SystemStatusHistoryPoint { - at: number; - cpuUsagePercent?: number | null; - cpuSystemPercent?: number | null; - memoryUsedBytes?: number | null; - swapUsedBytes?: number | null; - heapUsedBytes?: number | null; - heapCommittedBytes?: number | null; - gcCycleTimeMs?: number | null; - gcPauseTimeMs?: number | null; - rxBytesPerSecond?: number | null; - txBytesPerSecond?: number | null; -} - export async function getSystemStatus(metrics?: string): Promise { const response = await axios.get | SystemStatusSnapshotView>(`${resolveBaseURL()}/system/server/status`, { params: { diff --git a/src/components/ProgressGroup.vue b/src/components/ProgressGroup.vue new file mode 100644 index 0000000..5a5547c --- /dev/null +++ b/src/components/ProgressGroup.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/src/components/RoutePlaceholder.vue b/src/components/RoutePlaceholder.vue deleted file mode 100644 index 57c2312..0000000 --- a/src/components/RoutePlaceholder.vue +++ /dev/null @@ -1,63 +0,0 @@ - - - - - diff --git a/src/components/TCellInfo.vue b/src/components/TCellInfo.vue new file mode 100644 index 0000000..2e272fd --- /dev/null +++ b/src/components/TCellInfo.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/src/layout/MainLayout.vue b/src/layout/MainLayout.vue index 6f94a92..77e2790 100644 --- a/src/layout/MainLayout.vue +++ b/src/layout/MainLayout.vue @@ -228,6 +228,12 @@ const contentHeight = computed(() => { transition: opacity 520ms, transform 520ms var(--tui-bezier); pointer-events: auto; + &.glass-white { + // 不明原因失效,重新配置 + backdrop-filter: blur(10px); + } + + &.is-hidden { opacity: 0; pointer-events: none; diff --git a/src/layout/RootLayout.vue b/src/layout/RootLayout.vue index 90abf63..696f8bc 100644 --- a/src/layout/RootLayout.vue +++ b/src/layout/RootLayout.vue @@ -1,5 +1,5 @@ diff --git a/src/pages/dashboard/DockerDashboard/DockerDashboard.vue b/src/pages/dashboard/DockerDashboard/DockerDashboard.vue index 937efeb..4f85f7d 100644 --- a/src/pages/dashboard/DockerDashboard/DockerDashboard.vue +++ b/src/pages/dashboard/DockerDashboard/DockerDashboard.vue @@ -2,7 +2,7 @@

Docker

-

容器状态

+

容器状态

这里用于承载容器列表、运行状态、镜像占用和资源使用情况。

@@ -26,7 +26,7 @@ } .tag, - .title, + .header, .desc { margin: 0; } @@ -40,7 +40,7 @@ font-size: .875rem; } - .title { + .header { font-size: 1.1rem; } @@ -54,4 +54,4 @@ box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .2); } } - \ No newline at end of file + diff --git a/src/pages/dashboard/ServerDashboard/ServerDashboard.vue b/src/pages/dashboard/ServerDashboard/ServerDashboard.vue index df9a5f9..89b4713 100644 --- a/src/pages/dashboard/ServerDashboard/ServerDashboard.vue +++ b/src/pages/dashboard/ServerDashboard/ServerDashboard.vue @@ -1,75 +1,112 @@ @@ -80,25 +117,22 @@ 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 { getSystemStatus, getSystemStatusHistory, - resolveSystemRequestErrorMessage, - type SystemStatusHistoryPoint, - type SystemStatusSnapshotView -} from "@/api/system"; -import type { EChartsOption } from "echarts"; + resolveSystemRequestErrorMessage +} 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 } from "timi-web"; +import TCellInfo from "@/components/TCellInfo.vue"; +import type { ProgressItem } from "@/components/ProgressGroup.vue"; +import ProgressGroup from "@/components/ProgressGroup.vue"; -interface RenderHistoryItem { - at: number; - atText: string; - cpuUsage: number | null; - memoryUsed: number | null; - rxSpeed: number | null; - txSpeed: number | null; -} - -type HistoryMetric = "cpu" | "memory" | "network"; +type HistoryMetric = DashboardHistoryMetric; use([ SVGRenderer, @@ -108,220 +142,270 @@ use([ LegendComponent ]); -const isLoading = ref(false); +defineOptions({ + name: "ServerDashboard" +}); + +const router = useRouter(); +const settingStore = useSettingStore(); + +const isSnapshotLoading = ref(false); const snapshotView = ref(null); -const historyPoints = ref([]); -const historyMetric = ref("cpu"); -const historyChartRef = ref | 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 +// ---------- CPU ---------- + +const cpuProgressList = computed(() => { + 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 osNameText = computed(() => snapshotView.value?.snapshot?.os?.name || "--"); +// ---------- 内存 ---------- -const cpuUsageText = computed(() => { - const usage = snapshotView.value?.snapshot?.cpu?.usagePercent; - return formatPercentText(usage); +const memoryUsed = computed(() => { + const usedBytes = snapshotView.value?.snapshot?.memory?.usedBytes || 1; + const totalBytes = snapshotView.value?.snapshot?.memory?.totalBytes || 1; + return usedBytes / totalBytes; }); - -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 "--"; + 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(() => { + 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(() => { + 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([]); +const chartRef = ref | null>(null); +const historyChartUpdateOptions = Object.freeze({ + notMerge: true +}); +const isDarkTheme = ref(false); +let historyTimer: number | null; + +// ---------- Tab ---------- + +const historyMetric = ref("cpu"); + +const historyMetricTabs = computed(() => { + const labelMap: Record = { + 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(() => { + 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 }; } - return `${formatBytes(usedBytes)} / ${formatBytes(totalBytes)} (${formatPercentText(memory.usagePercent)})`; + 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 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 visibleHistoryItems = computed(() => { + if (!historyItems.value.length) { + return []; } - - const usagePercent = usedBytes / maxBytes * 100; - return `${formatBytes(usedBytes)} / ${formatBytes(maxBytes)} (${formatPercentText(usagePercent)})`; + return historyItems.value.slice(currentHistoryRange.value.startValue, currentHistoryRange.value.endValue + 1); }); -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 "--"; +// ---------- 调整图表数据范围 ---------- + +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 `↓ ${formatSpeed(rx)} ↑ ${formatSpeed(tx)}`; + return Math.min(HISTORY_DEFAULT_VISIBLE_COUNT, historyItems.value.length); }); - -const uptimeText = computed(() => { - const uptime = snapshotView.value?.snapshot?.os?.uptimeMs; - if (typeof uptime !== "number" || uptime < 0) { - return "--"; +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; } - - return formatDuration(uptime); + if (!isHistorySliderTouched.value) { + historySliderValue.value = historySliderMaxStart.value; + return; + } + historySliderValue.value = Math.min(historySliderValue.value, historySliderMaxStart.value); }); -const historyItems = computed(() => { - 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) - })); +function handleHistorySliderChange(value: number): void { + historySliderValue.value = Math.min(Math.max(Math.floor(value), 0), historySliderMaxStart.value); + isHistorySliderTouched.value = true; +} + +// ---------- 当前图表摘要 ---------- + +const currentHistorySummary = computed[]>(() => { + if (!historyItems.value.length) { + return []; + } + const lastPoint = historyItems.value[historyItems.value.length - 1]; + const summary: LabelValue[] = []; + 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(() => { +// ---------- 图表配置 ---------- + +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); - 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 { + // 通用配置 + const commonOption: EChartsOption = { tooltip: { - trigger: "axis" + 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: 0, + top: 10, textStyle: { color: legendColor } @@ -336,89 +420,234 @@ const historyChartOption = computed(() => { xAxis: { type: "category", boundaryGap: false, - data: historyItems.value.map((item) => item.atText), + data: visibleHistoryItems.value.map((item) => item.at), axisLabel: { - color: axisColor + color: axisColor, + formatter: val => Time.toShortTime(Number(val)) }, 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) - } - ] + } }; + 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("
"); + } + }, + 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("
"); + } + }, + 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("
"); + } + }, + 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("
"); + } + }, + 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; }); -onMounted(() => { - syncTheme(); - window.addEventListener("resize", handleWindowResize); - themeObserver = new MutationObserver(() => { - syncTheme(); - }); - themeObserver.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class"] - }); - void refreshDashboard(); -}); +watch(() => settingStore.dashboard.server, restartAutoRefresh, { deep: true }); -onUnmounted(() => { - window.removeEventListener("resize", handleWindowResize); - themeObserver?.disconnect(); - themeObserver = null; -}); +function resolveLineSeries(name: string, data: Array, yAxisIndex?: number): SeriesOption { + const series: SeriesOption = { + type: "line", + name, + showSymbol: false, + smooth: true, + lineStyle: { + width: 2 + }, + areaStyle: { + opacity: .18 + }, + data + }; -async function refreshDashboard(): Promise { - if (isLoading.value) { - return; + if (typeof yAxisIndex === "number") { + series.yAxisIndex = yAxisIndex; } - isLoading.value = true; + return series; +} +// ---------- 加载数据:当前状态 ---------- + +let snapshotTimer: number | null; +onUnmounted(() => { + if (snapshotTimer !== null) { + window.clearInterval(snapshotTimer); + snapshotTimer = null; + } +}); + +async function refreshSnapshot(): Promise { + if (isSnapshotLoading.value) { + return; + } + isSnapshotLoading.value = true; try { - const [statusView, historyView] = await Promise.all([ - getSystemStatus("os,cpu,memory,network,jvm"), - getSystemStatusHistory({ - window: "30m", - metrics: "cpu,memory,network" - }) - ]); + const metrics = settingStore.dashboard.server.snapshotMetrics.join(","); + snapshotView.value = await getSystemStatus(metrics); + } catch (error) { + Toast({ + theme: "error", + message: resolveSystemRequestErrorMessage(error) + }); + } finally { + isSnapshotLoading.value = false; + } +} - snapshotView.value = statusView; +// ---------- 加载数据:历史采集 ---------- + +async function refreshHistory(): Promise { + if (isHistoryLoading.value) { + return; + } + isHistoryLoading.value = true; + try { + const metrics = settingStore.dashboard.server.historyMetrics.join(","); + const historyView = await getSystemStatusHistory({ + window: "1h", + metrics + }); historyPoints.value = historyView.points || []; } catch (error) { Toast({ @@ -426,183 +655,121 @@ async function refreshDashboard(): Promise { message: resolveSystemRequestErrorMessage(error) }); } finally { - isLoading.value = false; + isHistoryLoading.value = false; } } -function handleHistoryMetricChange(value: string): void { - const nextValue = value as HistoryMetric; - if (nextValue === "cpu" || nextValue === "memory" || nextValue === "network") { - historyMetric.value = nextValue; - } +// ---------- 自动刷新 ---------- + +watch(() => settingStore.dashboard.server, restartAutoRefresh, { deep: true }); + +async function refreshDashboard(): Promise { + 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 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; +function clearAutoRefreshTimer(): void { + if (snapshotTimer !== null) { + window.clearInterval(snapshotTimer); + snapshotTimer = null; + } + if (historyTimer !== null) { + window.clearInterval(historyTimer); + historyTimer = null; + } } +onMounted(restartAutoRefresh); diff --git a/src/pages/dashboard/ServerDashboard/ServerDetail.vue b/src/pages/dashboard/ServerDashboard/ServerDetail.vue new file mode 100644 index 0000000..903e523 --- /dev/null +++ b/src/pages/dashboard/ServerDashboard/ServerDetail.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/src/pages/dashboard/ServerDashboard/ServerPerformanceDetail.vue b/src/pages/dashboard/ServerDashboard/ServerPerformanceDetail.vue new file mode 100644 index 0000000..1837400 --- /dev/null +++ b/src/pages/dashboard/ServerDashboard/ServerPerformanceDetail.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/src/pages/dashboard/UPSDashboard/UPSDashboard.vue b/src/pages/dashboard/UPSDashboard/UPSDashboard.vue index 6c206c8..a8461fc 100644 --- a/src/pages/dashboard/UPSDashboard/UPSDashboard.vue +++ b/src/pages/dashboard/UPSDashboard/UPSDashboard.vue @@ -2,7 +2,7 @@

UPS

-

供电监控

+

供电监控

这里用于展示 UPS 电池状态、负载功率、剩余续航和告警信息。

@@ -26,7 +26,7 @@ } .tag, - .title, + .header, .desc { margin: 0; } @@ -40,7 +40,7 @@ font-size: .875rem; } - .title { + .header { font-size: 1.1rem; } @@ -54,4 +54,4 @@ box-shadow: 0 .35rem 1rem rgba(0, 0, 0, .2); } } - \ No newline at end of file + diff --git a/src/pages/file/FileExplorerList.vue b/src/pages/file/FileExplorerList.vue index c2ed0d4..46d7791 100644 --- a/src/pages/file/FileExplorerList.vue +++ b/src/pages/file/FileExplorerList.vue @@ -2,7 +2,7 @@ @@ -164,26 +59,23 @@ function resetConnect(): void { box-shadow: 0 .35rem 1rem rgba(17, 32, 56, .05); } - .head, - .group { + .head { gap: .5rem; display: flex; flex-direction: column; } .tag, - .label, .desc { margin: 0; color: var(--app-sub); } - .tag, - .label { + .tag { font-size: .875rem; } - .title { + .header { margin: 0; font-size: 1.25rem; } @@ -192,11 +84,10 @@ function resetConnect(): void { line-height: 1.6; } - .protocols, - .modes { + .entries { gap: .75rem; display: flex; - flex-wrap: wrap; + flex-direction: column; } } diff --git a/src/router/index.ts b/src/router/index.ts index 4bd7e8f..330e195 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -7,7 +7,11 @@ import { useSettingStore } from "@/store/settingStore"; import NotFoundPage from "@/pages/system/NotFoundPage.vue"; import ServerIndexPage from "@/pages/system/ServerIndexPage.vue"; import FileTab from "@/pages/tabs/FileTab.vue"; -import ServerLogPage from "@/pages/detail/ServerLogPage.vue"; +import ConnectSetting from "@/pages/setting/ConnectSetting.vue"; +import DashboardSetting from "@/pages/setting/DashboardSetting.vue"; +import ThemeSetting from "@/pages/setting/ThemeSetting.vue"; +import ServerDetail from "@/pages/dashboard/ServerDashboard/ServerDetail.vue"; +import ServerPerformanceDetail from "@/pages/dashboard/ServerDashboard/ServerPerformanceDetail.vue"; const router = createRouter({ history: createWebHistory("/"), @@ -66,16 +70,66 @@ const router = createRouter({ component: FileTab }, { - path: "/server/logs", - name: "ServerLogPage", + path: "/server/system-detail", + name: "ServerDetail", meta: { depth: 3, navBarVisible: true, navBarCanBack: true, - navBarTitle: "服务日志", + navBarTitle: "系统详情", + tabBarVisible: false, + bodyBackground: "#F4F4F4" + }, + component: ServerDetail + }, + { + path: "/server/performance-detail", + name: "ServerPerformanceDetail", + meta: { + depth: 3, + navBarVisible: true, + navBarCanBack: true, + navBarTitle: "资源详情", + tabBarVisible: false, + bodyBackground: "#F4F4F4" + }, + component: ServerPerformanceDetail + }, + { + path: "/settings/connect", + name: "ConnectSetting", + meta: { + depth: 3, + navBarVisible: true, + navBarCanBack: true, + navBarTitle: "连接配置", tabBarVisible: false }, - component: ServerLogPage + component: ConnectSetting + }, + { + path: "/settings/dashboard", + name: "DashboardSetting", + meta: { + depth: 3, + navBarVisible: true, + navBarCanBack: true, + navBarTitle: "仪表板设置", + tabBarVisible: false + }, + component: DashboardSetting + }, + { + path: "/settings/theme", + name: "ThemeSetting", + meta: { + depth: 3, + navBarVisible: true, + navBarCanBack: true, + navBarTitle: "主题设置", + tabBarVisible: false + }, + component: ThemeSetting } ] } @@ -120,4 +174,4 @@ router.afterEach((to: RouteLocationNormalized) => { globalUIStore.setBodyBackground(targetBackground); }); -export default router; \ No newline at end of file +export default router; diff --git a/src/store/musicPlayerStore.ts b/src/store/musicPlayerStore.ts index bf7cfa6..a4b251c 100644 --- a/src/store/musicPlayerStore.ts +++ b/src/store/musicPlayerStore.ts @@ -1,7 +1,7 @@ import { defineStore } from "pinia"; import { parseBlob } from "music-metadata-browser"; import { Toast } from "tdesign-mobile-vue"; -import { buildServerFileURL } from "@/api/file"; +import { buildServerFileURL } from "@/api/FileAPI"; import { isBrowserSupportedAudio } from "@/pages/file/fileAudio.shared"; import type { ExplorerItem } from "@/pages/file/fileExplorer.shared"; import Storage from "@/utils/Storage"; diff --git a/src/store/settingStore.ts b/src/store/settingStore.ts index 6b01b1c..27889cd 100644 --- a/src/store/settingStore.ts +++ b/src/store/settingStore.ts @@ -12,8 +12,23 @@ export interface ConnectSetting { token: string; } +export type DashboardSnapshotMetric = "os" | "cpu" | "memory" | "jvm" | "network" | "hardware" | "storage"; +export type DashboardHistoryMetric = "cpu" | "memory" | "jvm" | "network"; + +export interface ServerDashboardSetting { + snapshotRefreshSeconds: number; + historyRefreshSeconds: number; + snapshotMetrics: DashboardSnapshotMetric[]; + historyMetrics: DashboardHistoryMetric[]; +} + +export interface DashboardSetting { + server: ServerDashboardSetting; +} + interface SettingState { connect: ConnectSetting; + dashboard: DashboardSetting; } const defaultConnectSetting = (): ConnectSetting => ({ @@ -23,8 +38,20 @@ const defaultConnectSetting = (): ConnectSetting => ({ token: "" }); +const defaultServerDashboardSetting = (): ServerDashboardSetting => ({ + snapshotRefreshSeconds: 3, + historyRefreshSeconds: 10, + snapshotMetrics: ["os", "cpu", "memory", "jvm", "network", "hardware", "storage"], + historyMetrics: ["cpu", "memory", "jvm", "network"] +}); + +const defaultDashboardSetting = (): DashboardSetting => ({ + server: defaultServerDashboardSetting() +}); + const defaultSettingState = (): SettingState => ({ - connect: defaultConnectSetting() + connect: defaultConnectSetting(), + dashboard: defaultDashboardSetting() }); function normalizeConnectSetting(connect?: Partial): ConnectSetting { @@ -38,10 +65,52 @@ function normalizeConnectSetting(connect?: Partial): ConnectSett }; } +function normalizeSnapshotMetrics(metrics?: DashboardSnapshotMetric[]): DashboardSnapshotMetric[] { + const validMetrics: DashboardSnapshotMetric[] = ["os", "cpu", "memory", "jvm", "network", "hardware", "storage"]; + const metricList = Array.isArray(metrics) ? metrics : []; + const normalizedMetrics = validMetrics.filter((metric) => metricList.includes(metric)); + return normalizedMetrics.length ? normalizedMetrics : defaultServerDashboardSetting().snapshotMetrics; +} + +function normalizeHistoryMetrics(metrics?: DashboardHistoryMetric[]): DashboardHistoryMetric[] { + const validMetrics: DashboardHistoryMetric[] = ["cpu", "memory", "jvm", "network"]; + const metricList = Array.isArray(metrics) ? metrics : []; + const normalizedMetrics = validMetrics.filter((metric) => metricList.includes(metric)); + return normalizedMetrics.length ? normalizedMetrics : defaultServerDashboardSetting().historyMetrics; +} + +function normalizeRefreshSeconds(value?: number, fallback = 3): number { + if (typeof value !== "number" || Number.isNaN(value)) { + return fallback; + } + + return Math.min(Math.max(Math.floor(value), 1), 120); +} + +function normalizeServerDashboardSetting(setting?: Partial): ServerDashboardSetting { + const fallback = defaultServerDashboardSetting(); + return { + snapshotRefreshSeconds: normalizeRefreshSeconds(setting?.snapshotRefreshSeconds, fallback.snapshotRefreshSeconds), + historyRefreshSeconds: normalizeRefreshSeconds(setting?.historyRefreshSeconds, fallback.historyRefreshSeconds), + snapshotMetrics: normalizeSnapshotMetrics(setting?.snapshotMetrics), + historyMetrics: normalizeHistoryMetrics(setting?.historyMetrics) + }; +} + +function normalizeDashboardSetting(setting?: Partial): DashboardSetting { + return { + server: normalizeServerDashboardSetting(setting?.server) + }; +} + export const useSettingStore = defineStore("setting", () => { const state = ref(Storage.getDefault(SETTING_STORAGE_KEY, defaultSettingState())); + state.value.connect = normalizeConnectSetting(state.value.connect); + state.value.dashboard = normalizeDashboardSetting(state.value.dashboard); + persist(); const connect = computed(() => state.value.connect); + const dashboard = computed(() => state.value.dashboard); const hasConnectConfig = computed(() => { const currentConnect = state.value.connect; return !!currentConnect.host && !!currentConnect.port && !!currentConnect.token; @@ -64,10 +133,20 @@ export const useSettingStore = defineStore("setting", () => { persist(); } + function setServerDashboard(serverSetting: Partial): void { + state.value.dashboard.server = normalizeServerDashboardSetting({ + ...state.value.dashboard.server, + ...serverSetting + }); + persist(); + } + return { connect, + dashboard, hasConnectConfig, setConnect, - resetConnect + resetConnect, + setServerDashboard }; }); diff --git a/src/types/File.ts b/src/types/File.ts new file mode 100644 index 0000000..b371fc4 --- /dev/null +++ b/src/types/File.ts @@ -0,0 +1,12 @@ +export interface ServerFile { + name: string; + extension?: string; + absolutePath?: string; + size?: number; + modifiedAt?: number; + type?: string; + isFile?: boolean; + isDirectory?: boolean; + canPreview?: boolean; + previewURI?: string; +} diff --git a/src/types/System.ts b/src/types/System.ts new file mode 100644 index 0000000..16ed4a8 --- /dev/null +++ b/src/types/System.ts @@ -0,0 +1,108 @@ +export interface SystemStatusSnapshotView { + serverTime: number; + sampleRateMs: number; + snapshot: SystemStatusSnapshot; +} + +export interface SystemStatusHistoryView { + serverTime: number; + sampleRateMs: number; + from: number; + to: number; + points: SystemStatusHistoryPoint[]; +} + +export interface SystemStatusSnapshot { + os?: { + name: string; + bootAt: number; + }; + cpu?: { + model: string; + physicalCores: number; + logicalCores: number; + usageTotal: number; + usageSystem: number; + temperatureCelsius?: number; + }; + memory?: { + totalBytes: number; + usedBytes: number; + swapTotalBytes: number; + swapUsedBytes: number; + }; + jvm?: { + name: string; + version: string; + bootAt: number; + heapInitBytes: number; + heapMaxBytes: number; + heapUsedBytes: number; + heapCommittedBytes: number; + gc: { + collector: string; + cycleCount: number; + pauseCount: number; + lastPauseAt?: number; + lastRecoveredBytes?: number; + }; + }; + network?: { + interfaceName: string; + mac: string; + rxBytesPerSecond: number; + txBytesPerSecond: number; + rxTotalBytes: number; + txTotalBytes: number; + rxPacketsTotal: number; + txPacketsTotal: number; + inErrors?: number; + outErrors?: number; + inDrops?: number; + collisions?: number; + }; + hardware?: { + fanSpeeds?: Array; + baseboard?: { + manufacturer?: string; + model?: string; + version?: string; + serialNumber?: string; + }; + firmware?: { + manufacturer?: string; + name?: string; + description?: string; + version?: string; + releaseDate?: string; + }; + }; + storagePartitions?: Array<{ + diskName?: string; + diskModel?: string; + diskSerial?: string; + partitionId: string; + partitionName?: string; + partitionType?: string; + uuid: string; + mountPoint: string; + total: number; + used: number; + transferTimeMs?: number; + healthStatus?: string; + }>; +} + +export interface SystemStatusHistoryPoint { + at: number; + cpuUsagePercent: number; + cpuSystemPercent: number; + memoryUsedBytes: number; + swapUsedBytes: number; + heapUsedBytes: number; + heapCommittedBytes: number; + gcCycleTimeMs: number; + gcPauseTimeMs: number; + rxBytesPerSecond: number; + txBytesPerSecond: number; +}