Files
minecraft/dashboard/scripts/export-feed.mjs

170 lines
5.6 KiB
JavaScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// @ts-check
/**
* Feed exporter for the CraftDeck public read-only mirror.
*
* Reads the live panel database (READ-ONLY) and writes a small, sanitised
* SQLite "feed" file that the PUBLIC_READONLY dashboard serves from. The feed
* contains ONLY:
*
* metrics – the sampler time-series (last 7 days)
* activity – join/leave events only, as (id, ts, type, player); the
* free-text `detail` column (which can hold IPs on connect/
* blocked rows) is deliberately dropped
* feed_meta – one small status JSON blob (running, online names, counts)
*
* It NEVER copies panel_users, approved_players, settings, notifications,
* schedules or ping_samples, so none of those can leak to the public app.
*
* The file is written to a temp path and atomically renamed into place, so
* readers never observe a half-written feed. No external dependencies — only
* the built-in node:sqlite module.
*
* Env:
* SOURCE_DB path to the live panel DB (default: dev-data/app/craftdeck.db)
* FEED_PATH path to write the feed to (default: dev-data/feed/feed.db)
* MC_VERSION optional version string for the status blob
* SERVER_TYPE optional server type string for the status blob
*/
import { DatabaseSync } from "node:sqlite";
import fs from "node:fs";
import path from "node:path";
const SOURCE_DB = process.env.SOURCE_DB || "dev-data/app/craftdeck.db";
const FEED_PATH = process.env.FEED_PATH || "dev-data/feed/feed.db";
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
function log(msg) {
console.log(`[export-feed] ${msg}`);
}
if (!fs.existsSync(SOURCE_DB)) {
console.error(`[export-feed] source DB not found: ${SOURCE_DB}`);
process.exit(1);
}
const src = new DatabaseSync(SOURCE_DB, { readOnly: true });
// ── derive current status from the panel DB (no Docker/RCON) ──────────────
const now = Date.now();
const latest =
/** @type {any} */ (src.prepare("SELECT * FROM metrics ORDER BY ts DESC LIMIT 1").get()) || null;
// The sampler writes a row every 10s; a fresh row with cpu present means the
// mc container was up at sample time.
const fresh = latest && now - Number(latest.ts) < 60_000;
const running = Boolean(fresh && latest.cpu != null);
// Reconstruct who is currently online by replaying join/leave since the most
// recent server start (a restart clears everyone).
let onlineNames = [];
if (running) {
const lastStart = /** @type {any} */ (
src
.prepare("SELECT MAX(ts) AS ts FROM activity WHERE type IN ('server_online')")
.get()
);
const sinceTs = lastStart && lastStart.ts ? Number(lastStart.ts) : now - 24 * 60 * 60 * 1000;
const events = /** @type {any[]} */ (
src
.prepare(
"SELECT ts, type, player FROM activity WHERE ts >= ? AND type IN ('join','leave') ORDER BY ts ASC"
)
.all(sinceTs)
);
const set = new Set();
for (const e of events) {
if (!e.player) continue;
if (e.type === "join") set.add(e.player);
else set.delete(e.player);
}
onlineNames = [...set];
}
const playersOnline = running ? Number(latest?.players ?? onlineNames.length) || onlineNames.length : 0;
const status = {
generatedAt: now,
running,
playersOnline,
playersMax: 0,
playerNames: onlineNames,
version: process.env.MC_VERSION || null,
serverType: process.env.SERVER_TYPE || null,
};
// ── build the feed in a temp file, then atomically swap it in ─────────────
fs.mkdirSync(path.dirname(FEED_PATH), { recursive: true });
const tmp = `${FEED_PATH}.tmp-${process.pid}`;
for (const p of [tmp, `${tmp}-wal`, `${tmp}-shm`, `${tmp}-journal`]) {
try {
fs.rmSync(p);
} catch {}
}
const out = new DatabaseSync(tmp);
out.exec("PRAGMA journal_mode = DELETE");
out.exec(`
CREATE TABLE metrics (
ts INTEGER PRIMARY KEY,
cpu REAL, mem_used INTEGER, mem_limit INTEGER,
net_rx INTEGER, net_tx INTEGER,
tps REAL, mspt REAL, players INTEGER,
disk_used INTEGER, disk_free INTEGER
);
CREATE TABLE activity (
id INTEGER PRIMARY KEY,
ts INTEGER NOT NULL,
type TEXT NOT NULL,
player TEXT
);
CREATE INDEX idx_activity_ts ON activity(ts DESC);
CREATE TABLE feed_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
`);
const since = now - WEEK_MS;
const metricRows = /** @type {any[]} */ (
src.prepare("SELECT * FROM metrics WHERE ts > ? ORDER BY ts ASC").all(since)
);
const insMetric = out.prepare(
`INSERT INTO metrics(ts, cpu, mem_used, mem_limit, net_rx, net_tx, tps, mspt, players, disk_used, disk_free)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const m of metricRows) {
insMetric.run(
m.ts, m.cpu, m.mem_used, m.mem_limit, m.net_rx, m.net_tx,
m.tps, m.mspt, m.players, m.disk_used, m.disk_free
);
}
// Only join/leave — the `detail` column is intentionally not carried over.
const activityRows = /** @type {any[]} */ (
src
.prepare(
"SELECT id, ts, type, player FROM activity WHERE ts > ? AND type IN ('join','leave') ORDER BY ts ASC"
)
.all(since)
);
const insActivity = out.prepare("INSERT INTO activity(id, ts, type, player) VALUES(?, ?, ?, ?)");
for (const a of activityRows) insActivity.run(a.id, a.ts, a.type, a.player);
out.prepare("INSERT INTO feed_meta(key, value) VALUES('status', ?)").run(JSON.stringify(status));
out.close();
src.close();
// Atomic replace (same filesystem). Clean up any leftover side files.
for (const p of [`${FEED_PATH}-wal`, `${FEED_PATH}-shm`, `${FEED_PATH}-journal`]) {
try {
fs.rmSync(p);
} catch {}
}
fs.renameSync(tmp, FEED_PATH);
log(
`wrote ${FEED_PATH}: ${metricRows.length} metrics, ${activityRows.length} join/leave, ` +
`running=${running}, online=${playersOnline}`
);