From 6b7839360dff92398f59e0bbc60c0935a8af77f8 Mon Sep 17 00:00:00 2001 From: Aryan Singh Date: Fri, 10 Jul 2026 12:39:46 -0500 Subject: [PATCH] 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 --- dashboard/src/app/api/lag/route.ts | 8 +++++- dashboard/src/server/logwatch.ts | 11 +++++++ dashboard/src/server/mc.ts | 46 +++++++++++++++++++++++++++++- dashboard/src/server/sampler.ts | 21 +++++++++----- 4 files changed, 77 insertions(+), 9 deletions(-) diff --git a/dashboard/src/app/api/lag/route.ts b/dashboard/src/app/api/lag/route.ts index 3b9cf98..318bc85 100644 --- a/dashboard/src/app/api/lag/route.ts +++ b/dashboard/src/app/api/lag/route.ts @@ -40,7 +40,13 @@ export async function GET() { const serverHealthy = (tps === null || tps >= 18) && (mspt === null || mspt <= 40); 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 { name, ping, diff --git a/dashboard/src/server/logwatch.ts b/dashboard/src/server/logwatch.ts index 979387c..6878085 100644 --- a/dashboard/src/server/logwatch.ts +++ b/dashboard/src/server/logwatch.ts @@ -26,6 +26,17 @@ function messageOf(line: string): string { function parseEvent(line: string, backfill: boolean) { 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) => { if (backfill) return; // don't re-record history after a panel restart logActivity(type, player, detail); diff --git a/dashboard/src/server/mc.ts b/dashboard/src/server/mc.ts index 2abac72..fa2bafc 100644 --- a/dashboard/src/server/mc.ts +++ b/dashboard/src/server/mc.ts @@ -1,4 +1,6 @@ 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 @@ -93,6 +95,38 @@ export async function easyauthSetPassword(name: string, password: string): Promi 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 { + 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 { + 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 { try { const out = stripColors(await rcon().exec("spark tps")); @@ -127,5 +161,15 @@ export async function sparkPings(): Promise> { } export async function sparkHealthReport(): Promise { - 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."; + } } diff --git a/dashboard/src/server/sampler.ts b/dashboard/src/server/sampler.ts index fa8f49e..3728c0d 100644 --- a/dashboard/src/server/sampler.ts +++ b/dashboard/src/server/sampler.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import { execFile } from "node:child_process"; -import { inspectContainer, containerStats } from "./docker"; -import { listOnlinePlayers, sparkTps, sparkPings } from "./mc"; +import { inspectContainer, containerStats, execInContainer } from "./docker"; +import { listOnlinePlayers, queryTps, sparkPings } from "./mc"; import { db } from "./db"; import { bus } from "./bus"; import { notify } from "./notify"; @@ -72,9 +72,9 @@ async function sampleTick(state: SamplerState) { players = (await listOnlinePlayers()).online; } catch {} - // spark TPS every 3rd tick (30s) + // TPS every 3rd tick (30s) — vanilla `tick query`, spark as fallback if (state.tick % 3 === 0) { - const t = await sparkTps(); + const t = await queryTps(); state.cachedTps = t.tps; state.cachedMspt = t.mspt; if (t.tps !== null && t.tps < 15) { @@ -89,10 +89,17 @@ async function sampleTick(state: SamplerState) { } // 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 insert = db().prepare("INSERT INTO ping_samples(ts, player, ping) VALUES(?, ?, ?)"); - for (const [player, ping] of Object.entries(pings)) insert.run(ts, player, ping); + if (Object.keys(pings).length > 0) { + 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 { state.cachedTps = null;