Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/desktop-metric-series.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@sfmc-bds/desktop": patch
"@sfmc-bds/db-server": patch
"@sfmc-bds/management": patch
---

运行指标增加内存与维度实体曲线。内存按主机总占用、BDS 和数据服务分色;维度实体按维度分色。
4 changes: 2 additions & 2 deletions docs/zh/api/monitoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@
- 在线玩家:通过 SAPI `world.getAllPlayers()` 采集,同时向现有状态接口提供玩家与世界快照。
- 实体数量:约每 30 秒查询三个维度中的可访问实体,查询失败返回 `null`,并保留采集时间。
- 视距区块估算:累加玩家客户端视距对应的面积,不扣除玩家间重叠,不代表真实已加载区块数量。
- 主机内存、进程内存和累计 CPU 秒数:由数据服务读取宿主系统。进程资源只认当前实例的 BDS PID;权限不足或无法确认归属时返回未知。累计 CPU 时间不能按百分比展示。
- 主机内存、进程内存和累计 CPU 秒数:由数据服务读取宿主系统。进程资源只认当前实例的 BDS PID;权限不足或无法确认归属时返回未知。累计 CPU 时间不能按百分比展示。主机已用内存、BDS 和数据服务内存会另外记入最近一小时,供曲线使用。

正常速度下,每 100 tick 上报一次指标。低 TPS 时墙钟上报间隔会变长;桌面不会补造中间点。

## 查询入口

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` 时,桌面只保留本次打开后的内存采样。

数据服务提供以下入口:

Expand Down
2 changes: 1 addition & 1 deletion docs/zh/guide/desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ gh attestation verify '.\SFMC-Desktop-0.3.1-x64.exe' --repo DogeLakeDev/ScriptsF

![实例概览:服务状态、TPS 和资源指标](../../public/desktop/overview.jpg)

**概览** 查看服务状态、TPS、在线人数与主机/服务资源。停止状态、零在线人数和缺少采样都可能是正常情况;指标是否新鲜需要结合采样状态判断。
**概览** 查看服务状态、TPS、在线人数、内存和维度实体。内存曲线包含主机总占用、BDS 和数据服务;维度实体按主世界、下界、末地分色。停止状态、零在线人数和缺少采样都可能是正常情况;指标是否新鲜需要结合采样状态判断。

![模块管理:模块列表和详情](../../public/desktop/modules.jpg)

Expand Down
49 changes: 49 additions & 0 deletions packages/db-server/src/domain/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,55 @@ export function parseMetrics(data: Record<string, unknown>, 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;
Expand Down
32 changes: 30 additions & 2 deletions packages/db-server/src/routes/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -30,13 +30,40 @@ 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<SystemStatusSnapshot> | undefined;
const getSystem = async () => {
if (systemCache && Date.now() - systemCache.at < 5000) return systemCache.data;
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<boolean> {
if (path === "/api/sfmc/status/live" || path === "/api/sfmc/metrics/live") {
Expand Down Expand Up @@ -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;
}
Expand Down
138 changes: 94 additions & 44 deletions packages/desktop/src/renderer/components/MetricChart.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<LineSeriesOption | GridComponentOption | TooltipComponentOption>;
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<HTMLDivElement>(null);
const chart = useRef<EChartsType>(undefined);
const { dark } = useAppearance();
const label = field === "tps" ? "TPS" : "在线人数";
useEffect(() => {
if (!container.current) return;
const instance = init(container.current, undefined, { renderer: "svg" });
Expand All @@ -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,
Expand All @@ -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",
Expand All @@ -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 (
<div
ref={container}
className="metric-chart"
role="img"
aria-label={`${label},最近 ${minutes} 分钟,${groups.flat().length} 个采样点`}
aria-label={`${label},最近 ${minutes} 分钟`}
/>
);
}

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 (
<EmptyState compact icon="activity" title="暂无采样" />
);
return <TrendPlot groups={groups} field={field} from={from} end={end} minutes={minutes} />;
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 <EmptyState compact icon="activity" title="暂无采样" />;
return (
<>
{lines.length > 1 && (
<div className="metric-legend">
{lines.map((line) => (
<span key={line.name}>
<i style={{ background: line.color }} />
{line.name}
</span>
))}
</div>
)}
<TrendPlot lines={lines} from={from} end={end} minutes={minutes} label={label} y={y} />
</>
);
}
Loading
Loading