170 lines
5.6 KiB
JavaScript
Executable File
170 lines
5.6 KiB
JavaScript
Executable File
#!/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}`
|
||
);
|