Fix blank TPS: read tick timing from vanilla 'tick query' instead of spark
spark sends command output asynchronously, so its RCON responses are empty and the sampler recorded null TPS/MSPT. Use the synchronous vanilla 'tick query' as the primary source (treating paused/frozen idle servers as healthy), keep spark as fallback, and route spark ping / healthreport through the server console (mc-send-to-console) with the log watcher capturing ping lines. Lag endpoint now falls back to the freshest recorded ping sample. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
85414391b0
commit
6b7839360d
4 files changed
+77
-9
No files matched your search
@@ -40,7 +40,13 @@ export async function GET() {
|
|||||||
|
|
||||||
const serverHealthy = (tps === null || tps >= 18) && (mspt === null || mspt <= 40);
|
const serverHealthy = (tps === null || tps >= 18) && (mspt === null || mspt <= 40);
|
||||||
const players = online.map((name) => {
|
const players = online.map((name) => {
|
||||||
const ping = livePings[name] ?? null;
|
// spark's RCON reply is often empty (async output) — fall back to the
|
||||||
|
// most recent logged sample if it's fresh enough.
|
||||||
|
const hist = historyByPlayer[name] ?? [];
|
||||||
|
const lastSample = hist.length > 0 ? hist[hist.length - 1] : null;
|
||||||
|
const ping =
|
||||||
|
livePings[name] ??
|
||||||
|
(lastSample && Date.now() - lastSample.ts < 5 * 60 * 1000 ? lastSample.ping : null);
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
ping,
|
ping,
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ function messageOf(line: string): string {
|
|||||||
|
|
||||||
function parseEvent(line: string, backfill: boolean) {
|
function parseEvent(line: string, backfill: boolean) {
|
||||||
const msg = messageOf(line).replace(/§[0-9a-fk-or]/gi, "");
|
const msg = messageOf(line).replace(/§[0-9a-fk-or]/gi, "");
|
||||||
|
|
||||||
|
// spark ping output routed through the console (see sampler fallback):
|
||||||
|
// "[⚡] Alice: 23ms"
|
||||||
|
const ping = msg.match(/\[⚡\]\s*([A-Za-z0-9_]{3,16}):\s*([\d.]+)\s*ms/);
|
||||||
|
if (ping && !backfill) {
|
||||||
|
db()
|
||||||
|
.prepare("INSERT INTO ping_samples(ts, player, ping) VALUES(?, ?, ?)")
|
||||||
|
.run(Date.now(), ping[1], Math.round(Number(ping[2])));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const emit = (type: string, player?: string, detail?: string) => {
|
const emit = (type: string, player?: string, detail?: string) => {
|
||||||
if (backfill) return; // don't re-record history after a panel restart
|
if (backfill) return; // don't re-record history after a panel restart
|
||||||
logActivity(type, player, detail);
|
logActivity(type, player, detail);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { rcon, stripColors, RconError } from "./rcon";
|
import { rcon, stripColors, RconError } from "./rcon";
|
||||||
|
import { execInContainer } from "./docker";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Game-level operations on top of RCON: player list, whitelist, EasyAuth
|
* Game-level operations on top of RCON: player list, whitelist, EasyAuth
|
||||||
@@ -93,6 +95,38 @@ export async function easyauthSetPassword(name: string, password: string): Promi
|
|||||||
|
|
||||||
export type TpsSample = { tps: number | null; mspt: number | null };
|
export type TpsSample = { tps: number | null; mspt: number | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primary TPS/MSPT source: vanilla `tick query`. Unlike spark, its output is
|
||||||
|
* returned synchronously over RCON. Typical output:
|
||||||
|
* "The game is running normally / Target tick rate: 20.0 per second. /
|
||||||
|
* Average time per tick: 2.1ms (Target: 50.0ms) ..."
|
||||||
|
*/
|
||||||
|
export async function tickQueryTps(): Promise<TpsSample | null> {
|
||||||
|
const out = stripColors(await rcon().exec("tick query"));
|
||||||
|
const target = Number(out.match(/tick rate:\s*([\d.]+)/i)?.[1]) || 20;
|
||||||
|
if (/paused|frozen/i.test(out)) {
|
||||||
|
// pause-when-empty idle state — the server is keeping up by definition
|
||||||
|
return { tps: target, mspt: null };
|
||||||
|
}
|
||||||
|
const mspt = out.match(/per tick:\s*([\d.]+)\s*ms/i);
|
||||||
|
if (!mspt) return null;
|
||||||
|
const ms = Number(mspt[1]);
|
||||||
|
const tps = ms > 0 ? Math.min(target, 1000 / ms) : target;
|
||||||
|
return { tps: Math.round(tps * 10) / 10, mspt: ms };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TPS with fallback chain: tick query → spark → nulls. */
|
||||||
|
export async function queryTps(): Promise<TpsSample> {
|
||||||
|
try {
|
||||||
|
const t = await tickQueryTps();
|
||||||
|
if (t) return t;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof RconError) return { tps: null, mspt: null };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return sparkTps();
|
||||||
|
}
|
||||||
|
|
||||||
export async function sparkTps(): Promise<TpsSample> {
|
export async function sparkTps(): Promise<TpsSample> {
|
||||||
try {
|
try {
|
||||||
const out = stripColors(await rcon().exec("spark tps"));
|
const out = stripColors(await rcon().exec("spark tps"));
|
||||||
@@ -127,5 +161,15 @@ export async function sparkPings(): Promise<Record<string, number>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function sparkHealthReport(): Promise<string> {
|
export async function sparkHealthReport(): Promise<string> {
|
||||||
return stripColors(await rcon().exec("spark healthreport"));
|
const out = stripColors(await rcon().exec("spark healthreport"));
|
||||||
|
if (out.trim()) return out;
|
||||||
|
// spark replies asynchronously; over RCON the response is often empty.
|
||||||
|
// Route the command through the server's real console instead — the report
|
||||||
|
// then shows up in the live log.
|
||||||
|
try {
|
||||||
|
await execInContainer(env.mcContainer, ["mc-send-to-console", "spark", "healthreport"], 15_000);
|
||||||
|
return "spark replies asynchronously, so the report can't be captured here.\nIt was sent to the server console instead — open the Console page to read it (takes a few seconds).";
|
||||||
|
} catch {
|
||||||
|
return "spark did not return output over RCON, and sending to the server console failed.";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { inspectContainer, containerStats } from "./docker";
|
import { inspectContainer, containerStats, execInContainer } from "./docker";
|
||||||
import { listOnlinePlayers, sparkTps, sparkPings } from "./mc";
|
import { listOnlinePlayers, queryTps, sparkPings } from "./mc";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { bus } from "./bus";
|
import { bus } from "./bus";
|
||||||
import { notify } from "./notify";
|
import { notify } from "./notify";
|
||||||
@@ -72,9 +72,9 @@ async function sampleTick(state: SamplerState) {
|
|||||||
players = (await listOnlinePlayers()).online;
|
players = (await listOnlinePlayers()).online;
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// spark TPS every 3rd tick (30s)
|
// TPS every 3rd tick (30s) — vanilla `tick query`, spark as fallback
|
||||||
if (state.tick % 3 === 0) {
|
if (state.tick % 3 === 0) {
|
||||||
const t = await sparkTps();
|
const t = await queryTps();
|
||||||
state.cachedTps = t.tps;
|
state.cachedTps = t.tps;
|
||||||
state.cachedMspt = t.mspt;
|
state.cachedMspt = t.mspt;
|
||||||
if (t.tps !== null && t.tps < 15) {
|
if (t.tps !== null && t.tps < 15) {
|
||||||
@@ -89,10 +89,17 @@ async function sampleTick(state: SamplerState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// per-player pings every 6th tick (60s)
|
// per-player pings every 6th tick (60s)
|
||||||
if (state.tick % 6 === 0) {
|
if (state.tick % 6 === 0 && (players ?? 0) > 0) {
|
||||||
const pings = await sparkPings();
|
const pings = await sparkPings();
|
||||||
const insert = db().prepare("INSERT INTO ping_samples(ts, player, ping) VALUES(?, ?, ?)");
|
if (Object.keys(pings).length > 0) {
|
||||||
for (const [player, ping] of Object.entries(pings)) insert.run(ts, player, ping);
|
const insert = db().prepare("INSERT INTO ping_samples(ts, player, ping) VALUES(?, ?, ?)");
|
||||||
|
for (const [player, ping] of Object.entries(pings)) insert.run(ts, player, ping);
|
||||||
|
} else {
|
||||||
|
// spark replies asynchronously, so RCON often gets an empty response.
|
||||||
|
// Fall back to sending the command to the server console; the output
|
||||||
|
// lands in the log and the log watcher records the pings from there.
|
||||||
|
void execInContainer(env.mcContainer, ["mc-send-to-console", "spark", "ping"], 15_000).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
state.cachedTps = null;
|
state.cachedTps = null;
|
||||||
|
|||||||
Reference in new issue
Block a user