From 2460c55952977d2dee16497ddf6ca71e670e2ed2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 07:23:35 +0000 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20=E8=BF=90=E8=A1=8C=E6=8C=87?= =?UTF-8?q?=E6=A0=87=E5=A2=9E=E5=8A=A0=E5=86=85=E5=AD=98=E4=B8=8E=E7=BB=B4?= =?UTF-8?q?=E5=BA=A6=E5=AE=9E=E4=BD=93=E6=9B=B2=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内存按主机总占用、BDS 和数据服务分色,维度实体按维度分色。数据服务会保留最近一小时的内存采样。 Co-authored-by: Shiroha --- .changeset/desktop-metric-series.md | 7 + docs/zh/api/monitoring.md | 4 +- docs/zh/guide/desktop.md | 2 +- packages/db-server/src/domain/metrics.ts | 49 +++++++ packages/db-server/src/routes/status.ts | 32 +++- .../src/renderer/components/MetricChart.tsx | 138 ++++++++++++------ .../src/renderer/components/MetricsPanel.tsx | 57 +++++++- .../desktop/src/renderer/dev/mock-bridge.ts | 49 ++++++- .../src/renderer/lib/metric-segments.ts | 25 ---- .../desktop/src/renderer/lib/metric-series.ts | 74 ++++++++++ .../desktop/src/renderer/styles/pages.css | 21 ++- packages/management/src/protocol/metrics.ts | 8 + 12 files changed, 383 insertions(+), 83 deletions(-) create mode 100644 .changeset/desktop-metric-series.md delete mode 100644 packages/desktop/src/renderer/lib/metric-segments.ts create mode 100644 packages/desktop/src/renderer/lib/metric-series.ts diff --git a/.changeset/desktop-metric-series.md b/.changeset/desktop-metric-series.md new file mode 100644 index 00000000..4d4a445f --- /dev/null +++ b/.changeset/desktop-metric-series.md @@ -0,0 +1,7 @@ +--- +"@sfmc-bds/desktop": minor +"@sfmc-bds/db-server": minor +"@sfmc-bds/management": minor +--- + +运行指标增加内存与维度实体曲线。内存按主机总占用、BDS 和数据服务分色;维度实体按维度分色。 diff --git a/docs/zh/api/monitoring.md b/docs/zh/api/monitoring.md index 12f7debd..de7e439a 100644 --- a/docs/zh/api/monitoring.md +++ b/docs/zh/api/monitoring.md @@ -8,7 +8,7 @@ - 在线玩家:通过 SAPI `world.getAllPlayers()` 采集,同时向现有状态接口提供玩家与世界快照。 - 实体数量:约每 30 秒查询三个维度中的可访问实体,查询失败返回 `null`,并保留采集时间。 - 视距区块估算:累加玩家客户端视距对应的面积,不扣除玩家间重叠,不代表真实已加载区块数量。 -- 主机内存、进程内存和累计 CPU 秒数:由数据服务读取宿主系统。进程资源只认当前实例的 BDS PID;权限不足或无法确认归属时返回未知。累计 CPU 时间不能按百分比展示。 +- 主机内存、进程内存和累计 CPU 秒数:由数据服务读取宿主系统。进程资源只认当前实例的 BDS PID;权限不足或无法确认归属时返回未知。累计 CPU 时间不能按百分比展示。主机已用内存、BDS 和数据服务内存会另外记入最近一小时,供曲线使用。 正常速度下,每 100 tick 上报一次指标。低 TPS 时墙钟上报间隔会变长;桌面不会补造中间点。 @@ -16,7 +16,7 @@ SDK 的 `@sfmc-bds/sdk/sapi/runtime` 导出 `getRuntimeMetrics()` 和 `getRuntimeTpsStatus()`,允许模块直接读取平台采样。初始化前快照为 `undefined`,TPS 为 `null`。不要修改返回值来控制采集。 -管理协议握手新增 `metrics` 能力。支持该能力时,`metrics.read` 返回 `current`、`history`、`fresh`、`updatedAt`、`host`、`processes` 和 `resourcesUpdatedAt`。旧平台缺少能力时,客户端展示升级提示。 +管理协议握手新增 `metrics` 能力。支持该能力时,`metrics.read` 返回 `current`、`history`、`fresh`、`updatedAt`、`host`、`processes`、`resourcesUpdatedAt` 和 `resourceHistory`。旧平台缺少能力时,客户端展示升级提示;旧数据服务没有 `resourceHistory` 时,桌面只保留本次打开后的内存采样。 数据服务提供以下入口: diff --git a/docs/zh/guide/desktop.md b/docs/zh/guide/desktop.md index 875da407..2f53b2ce 100644 --- a/docs/zh/guide/desktop.md +++ b/docs/zh/guide/desktop.md @@ -59,7 +59,7 @@ gh attestation verify '.\SFMC-Desktop-0.2.0-x64.exe' --repo DogeLakeDev/ScriptsF ![实例概览:服务状态、TPS 和资源指标](../../public/desktop/overview.jpg) -**概览** 查看服务状态、TPS、在线人数与主机/服务资源。停止状态、零在线人数和缺少采样都可能是正常情况;指标是否新鲜需要结合采样状态判断。 +**概览** 查看服务状态、TPS、在线人数、内存和维度实体。内存曲线包含主机总占用、BDS 和数据服务;维度实体按主世界、下界、末地分色。停止状态、零在线人数和缺少采样都可能是正常情况;指标是否新鲜需要结合采样状态判断。 ![模块管理:模块列表和详情](../../public/desktop/modules.jpg) diff --git a/packages/db-server/src/domain/metrics.ts b/packages/db-server/src/domain/metrics.ts index 07ee0b90..abbf4296 100644 --- a/packages/db-server/src/domain/metrics.ts +++ b/packages/db-server/src/domain/metrics.ts @@ -52,6 +52,55 @@ export function parseMetrics(data: Record, now: number): Metric }; } +export interface ResourceSample { + recordedAt: number; + hostUsedMb: number | null; + dbMb: number | null; + bdsMb: number | null; +} +const memoryMb = (value: unknown) => + value === null || (typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 8_388_608); + +export class ResourceHistory { + private lastWrite = 0; + private lastPrune = 0; + constructor(private query: QueryFn) { + query("CREATE TABLE IF NOT EXISTS sfmc_resource_metrics (recorded_at INTEGER PRIMARY KEY, sample TEXT NOT NULL)"); + } + append(sample: ResourceSample): void { + if (sample.recordedAt - this.lastWrite < 8_000) return; + if (sample.hostUsedMb === null && sample.dbMb === null && sample.bdsMb === null) return; + if (!memoryMb(sample.hostUsedMb) || !memoryMb(sample.dbMb) || !memoryMb(sample.bdsMb)) return; + this.query("INSERT OR REPLACE INTO sfmc_resource_metrics (recorded_at, sample) VALUES (?, ?)", [ + sample.recordedAt, + JSON.stringify(sample), + ]); + if (sample.recordedAt - this.lastPrune > 60_000) { + this.query("DELETE FROM sfmc_resource_metrics WHERE recorded_at < ?", [sample.recordedAt - 72 * 3600_000]); + this.query( + "DELETE FROM sfmc_resource_metrics WHERE recorded_at NOT IN (SELECT recorded_at FROM sfmc_resource_metrics ORDER BY recorded_at DESC LIMIT 60000)" + ); + this.lastPrune = sample.recordedAt; + } + this.lastWrite = sample.recordedAt; + } + read(now = Date.now()): ResourceSample[] { + const rows = this.query( + "SELECT sample FROM sfmc_resource_metrics WHERE recorded_at >= ? ORDER BY recorded_at DESC LIMIT 720", + [now - 3600_000] + ) as { sample: string }[]; + return rows.reverse().flatMap((row) => { + try { + const sample = JSON.parse(row.sample) as ResourceSample; + if (!memoryMb(sample.hostUsedMb) || !memoryMb(sample.dbMb) || !memoryMb(sample.bdsMb)) return []; + return [sample]; + } catch { + return []; + } + }); + } +} + export class MetricsHistory { private lastWrite = 0; private lastPrune = 0; diff --git a/packages/db-server/src/routes/status.ts b/packages/db-server/src/routes/status.ts index 051129c2..73be6b07 100644 --- a/packages/db-server/src/routes/status.ts +++ b/packages/db-server/src/routes/status.ts @@ -2,7 +2,7 @@ import { collectSystemStatus, type SystemStatusSnapshot } from "../domain/system-status.js"; import { PROJECT_ROOT } from "../project-root.js"; -import { MetricsHistory, parseMetrics, type MetricsSample } from "../domain/metrics.js"; +import { MetricsHistory, ResourceHistory, parseMetrics, type MetricsSample } from "../domain/metrics.js"; import { collectProcessResources } from "../domain/process-resources.js"; import type { QueryFn } from "../lib/sqlite.js"; import fs from "node:fs"; @@ -30,6 +30,26 @@ function createStatusRoutes({ collectSystem, projectRoot, query }: Deps): Return let live: LiveSnapshot | null = null; let metrics: MetricsSample | null = null; const history = query ? new MetricsHistory(query) : undefined; + const resources = query ? new ResourceHistory(query) : undefined; + let lastCapture = 0; + const captureResources = async (system: SystemStatusSnapshot | null) => { + if (!system) return null; + let ownedPid = 0; + try { ownedPid = Number(fs.readFileSync(pathModule.join(stateDir(root), "bds.pid"), "utf8").trim()); } catch { /* 未托管实例不猜测进程归属。 */ } + const owned = Number.isSafeInteger(ownedPid) && ownedPid > 0 && system.bds.pid === ownedPid; + const processes = { + db: await collectProcessResources(system.db.pid, system.db.running), + bds: owned ? await collectProcessResources(system.bds.pid, system.bds.running) : null, + }; + resources?.append({ + recordedAt: Date.now(), + hostUsedMb: system.host.memory.usedMb, + dbMb: processes.db.memoryMb, + bdsMb: processes.bds?.memoryMb ?? null, + }); + lastCapture = Date.now(); + return processes; + }; let systemCache: { data: SystemStatusSnapshot; at: number } | undefined; let collecting: Promise | undefined; const getSystem = async () => { @@ -37,6 +57,13 @@ function createStatusRoutes({ collectSystem, projectRoot, query }: Deps): Return collecting ??= collect(root).then(data => { systemCache = { data, at: Date.now() }; return data; }).finally(() => { collecting = undefined; }); return collecting; }; + if (resources) { + const timer = setInterval(() => { + if (Date.now() - lastCapture < 12_000) return; + void getSystem().then(system => captureResources(system)).catch(() => undefined); + }, 15_000); + timer.unref(); + } return async function handle({ path, method, req, res }): Promise { if (path === "/api/sfmc/status/live" || path === "/api/sfmc/metrics/live") { @@ -102,9 +129,10 @@ function createStatusRoutes({ collectSystem, projectRoot, query }: Deps): Return const owned = Number.isSafeInteger(ownedPid) && ownedPid > 0 && bds?.pid === ownedPid; const metricFresh = owned && metrics !== null && bds?.state === "running" && now - metrics.recordedAt <= 60_000 && (bdsAgeMs === null || now - metrics.recordedAt <= bdsAgeMs + 2000); - const processes = system ? { db: await collectProcessResources(system.db.pid, system.db.running), bds: owned ? await collectProcessResources(system.bds.pid, system.bds.running) : null } : null; + const processes = await captureResources(system); json(res, { fresh: metricFresh, current: metricFresh ? metrics : null, updatedAt: metrics?.recordedAt ?? null, history: history?.read(now) ?? [], host: system?.host ?? null, processes, resourcesUpdatedAt: system ? Date.now() : null, + resourceHistory: resources?.read(now) ?? [], note: metricFresh ? undefined : bds?.state === "stopped" ? "服务器未运行" : "等待平台指标同步" }); return true; } diff --git a/packages/desktop/src/renderer/components/MetricChart.tsx b/packages/desktop/src/renderer/components/MetricChart.tsx index 85cd3d93..2aec8e22 100644 --- a/packages/desktop/src/renderer/components/MetricChart.tsx +++ b/packages/desktop/src/renderer/components/MetricChart.tsx @@ -1,6 +1,6 @@ -/** ECharts 时间序列:按真实采样分段,断采、未知值和重启之间不连线。 */ -import type { MetricsResult, MetricsSample } from "@sfmc-bds/management"; -import { LineChart, type LineSeriesOption } from "echarts/charts"; +/** ECharts 时间序列:断采和未知值不连线。内存图左侧是主机已用,右侧是进程。 */ +import type { LineSeriesOption } from "echarts/charts"; +import { LineChart } from "echarts/charts"; import { GridComponent, TooltipComponent, @@ -12,30 +12,42 @@ import { SVGRenderer } from "echarts/renderers"; import { useEffect, useRef } from "react"; import { PALETTES, useAppearance } from "../app/theme.js"; import { fullTime } from "../lib/format.js"; -import { metricSegments } from "../lib/metric-segments.js"; +import type { ChartLine } from "../lib/metric-series.js"; import { EmptyState } from "./ui.js"; use([LineChart, GridComponent, TooltipComponent, SVGRenderer]); type ChartOption = ComposeOption; -type MetricField = "tps" | "onlineCount"; + +function axisMb(value: number) { + if (value === 0) return "0"; + const gb = value / 1024; + return `${gb >= 10 ? Math.round(gb) : gb.toFixed(1)}G`; +} + +function formatValue(y: "tps" | "count" | "memory", value: number) { + if (y === "tps") return value.toFixed(2); + if (y === "memory") return value >= 1024 ? `${(value / 1024).toFixed(1)} GB` : `${Math.round(value)} MB`; + return String(Math.round(value)); +} function TrendPlot({ - groups, - field, + lines, from, end, minutes, + label, + y, }: { - groups: MetricsSample[][]; - field: MetricField; + lines: ChartLine[]; from: number; end: number; minutes: number; + label: string; + y: "tps" | "count" | "memory"; }) { const container = useRef(null); const chart = useRef(undefined); const { dark } = useAppearance(); - const label = field === "tps" ? "TPS" : "在线人数"; useEffect(() => { if (!container.current) return; const instance = init(container.current, undefined, { renderer: "svg" }); @@ -51,10 +63,11 @@ function TrendPlot({ useEffect(() => { const palette = dark ? PALETTES.dark : PALETTES.light; const font = getComputedStyle(document.documentElement).getPropertyValue("--font-sans").trim(); + const memory = y === "memory"; const option: ChartOption = { animation: false, textStyle: { fontFamily: font, fontSize: 11, color: palette.text3 }, - grid: { left: 36, right: 12, top: 20, bottom: 28 }, + grid: { left: 40, right: memory ? 40 : 12, top: 16, bottom: 28 }, xAxis: { type: "time", min: from, @@ -70,15 +83,25 @@ function TrendPlot({ new Date(time).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }), }, }, - yAxis: { - type: "value", - min: 0, - ...(field === "tps" ? { max: 20, interval: 5 } : { minInterval: 1, splitNumber: 4 }), - axisLine: { show: false }, - axisTick: { show: false }, - axisLabel: { color: palette.text3 }, - splitLine: { lineStyle: { color: palette.line, width: 0.5 } }, - }, + yAxis: memory + ? [0, 1].map((index) => ({ + type: "value" as const, + min: 0, + position: index === 0 ? ("left" as const) : ("right" as const), + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: palette.text3, formatter: axisMb }, + splitLine: { show: index === 0, lineStyle: { color: palette.line, width: 0.5 } }, + })) + : { + type: "value", + min: 0, + ...(y === "tps" ? { max: 20, interval: 5 } : { minInterval: 1, splitNumber: 4 }), + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: palette.text3 }, + splitLine: { lineStyle: { color: palette.line, width: 0.5 } }, + }, tooltip: { trigger: "axis", renderMode: "richText", @@ -90,47 +113,74 @@ function TrendPlot({ textStyle: { color: palette.text, fontFamily: font, fontSize: 12 }, axisPointer: { type: "line", snap: true, lineStyle: { color: palette.text4, type: "dashed" } }, formatter: (params) => { - const row = Array.isArray(params) ? params[0] : params; - if (!row || !Array.isArray(row.value)) return ""; - const [time, value] = row.value; - if (typeof time !== "number" || typeof value !== "number") return ""; - return `${fullTime(time)}\n${label} ${field === "tps" ? value.toFixed(2) : value}`; + const rows = (Array.isArray(params) ? params : [params]).flatMap((row) => { + if (!row || !Array.isArray(row.value)) return []; + const [time, value] = row.value; + if (typeof time !== "number" || typeof value !== "number") return []; + return [{ time, text: `${row.seriesName} ${formatValue(y, value)}` }]; + }); + const first = rows[0]; + if (!first) return ""; + return [fullTime(first.time), ...rows.map((row) => row.text)].join("\n"); }, }, - series: groups.map((group, index) => ({ - id: `segment-${index}`, - name: label, - type: "line", + series: lines.map((line) => ({ + id: line.name, + name: line.name, + type: "line" as const, + yAxisIndex: memory ? line.axis : 0, connectNulls: false, smooth: false, - showSymbol: group.length === 1, + showSymbol: line.points.filter((point) => typeof point[1] === "number").length < 2, symbol: "circle", symbolSize: 5, - lineStyle: { color: palette.progress, width: 1.8 }, - itemStyle: { color: palette.progress }, + lineStyle: { color: line.color, width: 1.8 }, + itemStyle: { color: line.color }, emphasis: { disabled: true }, - data: group.map((sample) => [sample.recordedAt, sample[field]!]), + data: line.points, })), }; chart.current?.setOption(option, { notMerge: true }); - }, [dark, end, field, from, groups, label, minutes]); + }, [dark, end, from, label, lines, minutes, y]); return (
); } -export function MetricChart({ data, field, minutes }: { data: MetricsResult; field: MetricField; minutes: number }) { - const end = data.resourcesUpdatedAt ?? data.history.at(-1)?.recordedAt ?? Date.now(); - const from = end - minutes * 60_000; - const groups = metricSegments(data.history, field, from, end); - if (!groups.length) - return ( - - ); - return ; +export function MetricChart({ + lines, + from, + end, + minutes, + label, + y, +}: { + lines: ChartLine[]; + from: number; + end: number; + minutes: number; + label: string; + y: "tps" | "count" | "memory"; +}) { + if (!lines.length) return ; + return ( + <> + {lines.length > 1 && ( +
+ {lines.map((line) => ( + + + {line.name} + + ))} +
+ )} + + + ); } diff --git a/packages/desktop/src/renderer/components/MetricsPanel.tsx b/packages/desktop/src/renderer/components/MetricsPanel.tsx index f3f74a63..4f8159bc 100644 --- a/packages/desktop/src/renderer/components/MetricsPanel.tsx +++ b/packages/desktop/src/renderer/components/MetricsPanel.tsx @@ -1,8 +1,10 @@ /** 首页指标:服务端有界历史绘图,不补造采样;断采和重启分段展示。 */ -import type { MetricsResult } from "@sfmc-bds/management"; +import type { MetricsResult, ResourceSample } from "@sfmc-bds/management"; import { useEffect, useState } from "react"; import { useDesktop } from "../app/desktop.js"; +import { PALETTES, useAppearance } from "../app/theme.js"; import { fullTime } from "../lib/format.js"; +import { entityLines, memoryLines, scalarLine } from "../lib/metric-series.js"; import { MetricChart } from "./MetricChart.js"; import { Segmented } from "./controls.js"; import { EmptyState, Surface } from "./ui.js"; @@ -12,11 +14,13 @@ export function useRuntimeMetrics() { const { model, request } = useDesktop(); const supported = Boolean(model.handshake?.capabilities.includes("metrics")); const [data, setData] = useState(); + const [localResources, setLocalResources] = useState([]); const [receivedAt, setReceivedAt] = useState(0); const [failed, setFailed] = useState(false); const [clock, setClock] = useState(Date.now()); useEffect(() => { setData(undefined); + setLocalResources([]); setReceivedAt(0); setFailed(false); if (!supported || model.disconnected) return; @@ -29,6 +33,20 @@ export function useRuntimeMetrics() { setData(value); setReceivedAt(Date.now()); setFailed(false); + if (!Array.isArray(value.resourceHistory) && value.host && typeof value.resourcesUpdatedAt === "number") { + const point: ResourceSample = { + recordedAt: value.resourcesUpdatedAt, + hostUsedMb: value.host.memory.usedMb, + dbMb: value.processes?.db.memoryMb ?? null, + bdsMb: value.processes?.bds?.memoryMb ?? null, + }; + setLocalResources((current) => { + const last = current.at(-1); + if (last && point.recordedAt - last.recordedAt < 8_000) return current; + const cutoff = point.recordedAt - 3_600_000; + return [...current.filter((row) => row.recordedAt >= cutoff), point]; + }); + } } } catch { if (!disposed) setFailed(true); @@ -53,6 +71,7 @@ export function useRuntimeMetrics() { reachable, fresh: reachable && Boolean(data?.fresh), loading: !receivedAt && !failed && !model.disconnected, + resources: Array.isArray(data?.resourceHistory) ? data.resourceHistory : localResources, }; } @@ -63,12 +82,25 @@ const memory = (value: number | null | undefined) => ? `${(value / 1024).toFixed(1)} GB` : `${Math.round(value)} MB`; +type MetricView = "tps" | "onlineCount" | "memory" | "entities"; + export function MetricsPanel({ metrics }: { metrics: ReturnType }) { - const [field, setField] = useState<"tps" | "onlineCount">("tps"); + const [field, setField] = useState("tps"); const [minutes, setMinutes] = useState<"15" | "60">("15"); - const { data, supported, reachable, fresh, loading } = metrics; + const { dark } = useAppearance(); + const { data, supported, reachable, fresh, loading, resources } = metrics; const sample = fresh ? data?.current : null; const host = reachable ? data?.host : null; + const windowMinutes = Number(minutes); + const end = Math.max(data?.resourcesUpdatedAt ?? 0, data?.history.at(-1)?.recordedAt ?? 0, resources.at(-1)?.recordedAt ?? 0) || Date.now(); + const from = end - windowMinutes * 60_000; + const lines = !data + ? [] + : field === "memory" + ? memoryLines(resources, from, end) + : field === "entities" + ? entityLines(data.history, from, end) + : scalarLine(data.history, field, field === "tps" ? "TPS" : "在线人数", (dark ? PALETTES.dark : PALETTES.light).progress, from, end); return ( - {sample?.tps !== null && sample?.tps !== undefined ? ( + {field === "onlineCount" && sample ? ( + `在线 ${sample.onlineCount}` + ) : field === "memory" && host ? ( + `总占用 ${memory(host.memory.usedMb)}` + ) : sample?.tps !== null && sample?.tps !== undefined ? ( TPS {sample.tps.toFixed(2)} / 20 ) : ( "" )}
- {data?.history.length ? ( - + {data?.history.length || resources.length ? ( + ) : ( row.id === id)?.root ?? "", - capabilities: ["services", "logs", "modules", "config", "packs", "players", "updates", "operations"], + capabilities: ["services", "logs", "modules", "config", "packs", "players", "updates", "operations", "metrics"], daemonPid: id === "local-main" ? 15532 : 2231, daemonStartedAt: minutesAgo(id === "local-main" ? 372 : 4380), initialized: true, }); + const mockMetrics = () => { + const now = Date.now(); + const history = []; + const resourceHistory = []; + for (let i = 240; i >= 0; i--) { + const recordedAt = now - i * 15_000; + const wave = Math.sin(i / 8); + history.push({ + recordedAt, + bootId: i > 40 ? "boot-a" : "boot-b", + tps: i > 40 && i < 44 ? null : Math.round((19.4 + wave * 0.4) * 100) / 100, + onlineCount: 8 + (i % 6), + entities: { + "minecraft:overworld": 760 + Math.round(wave * 50), + "minecraft:nether": 180 + (i % 5) * 4, + "minecraft:the_end": i !== 0 && i % 40 === 0 ? null : 36 + (i % 7), + }, + entitiesUpdatedAt: recordedAt, + chunkEstimate: 96, + }); + resourceHistory.push({ + recordedAt, + hostUsedMb: 3100 + Math.round(wave * 280), + dbMb: 170 + (i % 4) * 6, + bdsMb: i > 70 && i < 76 ? null : 1280 + Math.round(Math.cos(i / 6) * 160), + }); + } + const current = history[history.length - 1]!; + const latest = resourceHistory[resourceHistory.length - 1]!; + return { + fresh: true, + updatedAt: now, + current, + history, + resourceHistory, + host: { + memory: { totalMb: 16384, usedMb: latest.hostUsedMb ?? 0, usedPercent: Math.round(((latest.hostUsedMb ?? 0) / 16384) * 100) }, + cpu: { cores: 8, model: "Mock CPU" }, + }, + processes: { + db: { pid: 17920, running: true, memoryMb: latest.dbMb, cpuSeconds: 88.2 }, + bds: { pid: 18244, running: true, memoryMb: latest.bdsMb, cpuSeconds: 640.5 }, + }, + resourcesUpdatedAt: now, + }; + }; const request = async (id: string, method: ManagementMethod, params: Record = {}): Promise => { const instance = get(id); if (!instance.connected) throw new Error("实例尚未连接"); @@ -360,6 +406,7 @@ export function installMockBridge() { ? { filename: "install-sfmc-service.ps1", script: "#Requires -RunAsAdministrator\n$ErrorActionPreference = 'Stop'\n$wrapper = 'D:\\SFMC\\survival\\.sfmc\\runtime\\sfmc3f2a9c1b.exe'\nif (Get-Service -Name 'sfmc3f2a9c1b' -ErrorAction SilentlyContinue) { throw '系统服务已存在,不重复安装' }\nCopy-Item -LiteralPath 'D:\\SFMC\\survival\\.sfmc\\runtime\\WinSW-x64.exe' -Destination $wrapper\n$credential = Get-Credential -UserName 'shiro' -Message '输入原 Windows 部署账号'\nNew-Service -Name 'sfmc3f2a9c1b' -BinaryPathName ('\"' + $wrapper + '\"') -Credential $credential -StartupType Automatic\nStart-Service -Name 'sfmc3f2a9c1b'\n" } : { filename: "install-sfmc-service.sh", script: "#!/bin/sh\nset -eu\n[ \"$(id -u)\" = 0 ] || { echo '请以管理员运行此一次性安装脚本'; exit 1; }\nunit=/etc/systemd/system/sfmc-3f2a9c1b.service\nsystemctl daemon-reload\nsystemctl enable --now sfmc-3f2a9c1b.service\n" }; case "deployment.create": return runTask(id, method, ["preflight", "prepare", "download", "start"]); + case "metrics.read": return mockMetrics(); default: throw new Error(`模拟桥未实现: ${method}`); } }; diff --git a/packages/desktop/src/renderer/lib/metric-segments.ts b/packages/desktop/src/renderer/lib/metric-segments.ts deleted file mode 100644 index be7a5825..00000000 --- a/packages/desktop/src/renderer/lib/metric-segments.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { MetricsSample } from "@sfmc-bds/management"; -export function metricSegments( - samples: MetricsSample[], - field: "tps" | "onlineCount", - from: number, - end: number -): MetricsSample[][] { - const groups: MetricsSample[][] = []; - let group: MetricsSample[] = []; - for (const sample of samples) { - const value = sample[field]; - if (sample.recordedAt < from || sample.recordedAt > end) continue; - if (typeof value !== "number" || !Number.isFinite(value)) { - group = []; - continue; - } - const previous = group.at(-1); - if (!previous || sample.bootId !== previous.bootId || sample.recordedAt - previous.recordedAt > 20_000) { - group = []; - groups.push(group); - } - group.push(sample); - } - return groups; -} diff --git a/packages/desktop/src/renderer/lib/metric-series.ts b/packages/desktop/src/renderer/lib/metric-series.ts new file mode 100644 index 00000000..c53e750a --- /dev/null +++ b/packages/desktop/src/renderer/lib/metric-series.ts @@ -0,0 +1,74 @@ +import type { MetricsSample, ResourceSample } from "@sfmc-bds/management"; +import { CHART_COLORS } from "../app/theme.js"; + +export interface ChartLine { + name: string; + color: string; + axis: 0 | 1; + points: [number, number | null][]; +} + +const DIMENSIONS: [string, string, string][] = [ + ["minecraft:overworld", "主世界", CHART_COLORS[4]], + ["minecraft:nether", "下界", CHART_COLORS[7]], + ["minecraft:the_end", "末地", CHART_COLORS[3]], +]; + +export function trendPoints( + samples: T[], + pick: (sample: T) => number | null | undefined, + from: number, + end: number, + gapMs: number +): [number, number | null][] { + const points: [number, number | null][] = []; + let previous: T | undefined; + for (const sample of samples) { + if (sample.recordedAt < from || sample.recordedAt > end) continue; + const value = pick(sample); + const numeric = typeof value === "number" && Number.isFinite(value) ? value : null; + const restarted = Boolean(previous?.bootId && sample.bootId && previous.bootId !== sample.bootId); + if (previous && (restarted || sample.recordedAt - previous.recordedAt > gapMs)) points.push([sample.recordedAt - 1, null]); + points.push([sample.recordedAt, numeric]); + previous = sample; + } + return points; +} + +function hasValue(points: [number, number | null][]) { + return points.some((point) => typeof point[1] === "number"); +} + +export function scalarLine(samples: MetricsSample[], field: "tps" | "onlineCount", name: string, color: string, from: number, end: number): ChartLine[] { + const points = trendPoints(samples, (sample) => sample[field], from, end, 20_000); + return hasValue(points) ? [{ name, color, axis: 0, points }] : []; +} + +export function memoryLines(samples: ResourceSample[], from: number, end: number): ChartLine[] { + const series: [string, string, 0 | 1, (sample: ResourceSample) => number | null][] = [ + ["总占用", CHART_COLORS[2], 0, (sample) => sample.hostUsedMb], + ["BDS", CHART_COLORS[6], 1, (sample) => sample.bdsMb], + ["数据服务", CHART_COLORS[1], 1, (sample) => sample.dbMb], + ]; + return series.flatMap(([name, color, axis, pick]) => { + const points = trendPoints(samples, pick, from, end, 45_000); + return hasValue(points) ? [{ name, color, axis, points }] : []; + }); +} + +export function entityLines(samples: MetricsSample[], from: number, end: number): ChartLine[] { + const extra = new Set(); + for (const sample of samples) for (const key of Object.keys(sample.entities)) extra.add(key); + const rows: [string, string, string][] = [ + ...DIMENSIONS, + ...[...extra] + .filter((key) => !DIMENSIONS.some((row) => row[0] === key)) + .sort() + .map((key, index): [string, string, string] => [key, key.replace(/^minecraft:/, ""), CHART_COLORS[index % CHART_COLORS.length]]), + ]; + return rows.flatMap(([key, name, color]) => { + if (!samples.some((sample) => key in sample.entities)) return []; + const points = trendPoints(samples, (sample) => sample.entities[key], from, end, 20_000); + return hasValue(points) ? [{ name, color, axis: 0 as const, points }] : []; + }); +} diff --git a/packages/desktop/src/renderer/styles/pages.css b/packages/desktop/src/renderer/styles/pages.css index 5177f4e7..924f563a 100644 --- a/packages/desktop/src/renderer/styles/pages.css +++ b/packages/desktop/src/renderer/styles/pages.css @@ -980,14 +980,31 @@ display: flex; align-items: center; justify-content: space-between; - gap: 12px; + flex-wrap: wrap; + gap: 8px 12px; + font-size: 12px; +} +.metric-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + margin-top: 10px; font-size: 12px; + color: var(--c-text-2); +} +.metric-legend i { + display: inline-block; + width: 12px; + height: 2px; + margin-right: 6px; + vertical-align: middle; + border-radius: 1px; } .metric-chart { display: block; width: 100%; height: 230px; - margin: 12px 0 4px; + margin: 8px 0 4px; } .metrics-meta { font-size: 11px; diff --git a/packages/management/src/protocol/metrics.ts b/packages/management/src/protocol/metrics.ts index 38a95f10..a1221e99 100644 --- a/packages/management/src/protocol/metrics.ts +++ b/packages/management/src/protocol/metrics.ts @@ -15,6 +15,12 @@ export interface ProcessResources { /** 累计 CPU 秒数;不是 CPU 百分比。 */ cpuSeconds: number | null; } +export interface ResourceSample { + recordedAt: number; + hostUsedMb: number | null; + dbMb: number | null; + bdsMb: number | null; +} export interface MetricsResult { fresh: boolean; updatedAt: number | null; @@ -26,5 +32,7 @@ export interface MetricsResult { } | null; processes: { db: ProcessResources; bds: ProcessResources | null } | null; resourcesUpdatedAt: number | null; + /** 最近一小时的主机已用内存与托管进程内存。旧数据服务没有该字段。 */ + resourceHistory?: ResourceSample[]; note?: string; }