Improve application responsiveness and background processing

This commit is contained in:
2026-10-04 19:56:06 -04:00
parent 1392a082da
commit ed004ddbcf
70 changed files with 1324 additions and 345 deletions
+3
View File
@@ -31,3 +31,6 @@ PLUTAINER_IMAGE=ghcr.io/ayymoss/plutainer:latest
# Existing legacy registrations can still use their previous KOMODO_*, # Existing legacy registrations can still use their previous KOMODO_*,
# SERVERS_ROOT, T6_SERVERS_ROOT, and RCON_HOST settings. Native hosts need none. # SERVERS_ROOT, T6_SERVERS_ROOT, and RCON_HOST settings. Native hosts need none.
# Fleet concurrency and campaign retention are saved in Settings, not env vars. # Fleet concurrency and campaign retention are saved in Settings, not env vars.
# Optional request/report timings and event-loop delay diagnostics.
EC_PERF_LOG=false
+14 -1
View File
@@ -148,7 +148,20 @@ npm run test:agent
npm run build npm run build
``` ```
Builds regenerate the downloadable agent, standalone updater and checksum manifest. Local controller Builds regenerate the downloadable agent, standalone updater and checksum manifest,
and bundle the metrics/leaderboard workers under `build/reports`. `npm run dev`
and `npm test` compile those workers automatically. After editing a report
implementation during development, restart `npm run dev` to rebuild its worker.
Reports use separate read-only SQLite connections and bounded revision-aware caches;
metrics snapshots can be reused for up to 30 seconds when their inputs are unchanged.
Set `EC_PERF_LOG=true` to log slow request timings, report computation/cache timings,
and event-loop delay. Responses include `Server-Timing`, and browser navigations
record an `ec:navigation` performance measure. `npm run benchmark:leaderboard`
uses an isolated synthetic database; pass `-- --scenario all --baseline-ref <commit>`
to compare full response parity and warm query timings with an earlier revision.
Local controller
metadata defaults to `./data/cherry.db`; use a separate test database when metadata defaults to `./data/cherry.db`; use a separate test database when
exercising enrollment. Production agent transport requires HTTPS. exercising enrollment. Production agent transport requires HTTPS.
+132 -11
View File
@@ -30,7 +30,7 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.3.0" VERSION = "1.4.0"
LABEL = "io.electric-cherry" LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024 MAX_FILE = 32 * 1024 * 1024
LIBRARY_DOWNLOAD_WORKERS = 4 LIBRARY_DOWNLOAD_WORKERS = 4
@@ -329,10 +329,47 @@ def managed_write(root, value, content, uid=1000, gid=1000):
os.unlink(temp_name, dir_fd=parent) os.unlink(temp_name, dir_fd=parent)
class ReadWriteGate:
"""Let observations run together; global mutations wait for existing readers."""
def __init__(self):
self.condition = threading.Condition()
self.readers = 0
self.writers = 0
@contextlib.contextmanager
def read(self):
with self.condition:
while self.writers:
self.condition.wait()
self.readers += 1
try:
yield
finally:
with self.condition:
self.readers -= 1
self.condition.notify_all()
@contextlib.contextmanager
def write(self):
# The mutation lane is serial, so there is only one writer.
with self.condition:
self.writers += 1
while self.readers:
self.condition.wait()
try:
yield
finally:
with self.condition:
self.writers -= 1
self.condition.notify_all()
class Journal: class Journal:
def __init__(self, path): def __init__(self, path, recover=True):
self.db = sqlite3.connect(path) self.db = sqlite3.connect(path)
self.db.row_factory = sqlite3.Row self.db.row_factory = sqlite3.Row
if not recover:
return
self.db.execute("PRAGMA journal_mode=WAL") self.db.execute("PRAGMA journal_mode=WAL")
self.db.execute("PRAGMA synchronous=FULL") self.db.execute("PRAGMA synchronous=FULL")
self.db.execute("CREATE TABLE IF NOT EXISTS operations (id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, request TEXT NOT NULL, state TEXT NOT NULL, result TEXT, error TEXT, updated REAL NOT NULL)") self.db.execute("CREATE TABLE IF NOT EXISTS operations (id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, request TEXT NOT NULL, state TEXT NOT NULL, result TEXT, error TEXT, updated REAL NOT NULL)")
@@ -672,6 +709,10 @@ class Agent:
self.docker = docker or Docker() self.docker = docker or Docker()
self.stopping = threading.Event() self.stopping = threading.Event()
self.current_operation = None self.current_operation = None
self.instance_locks = {}
self.read_gate = ReadWriteGate()
self.read_claim_lock = threading.Lock()
self.read_claims = set()
self.cancelled_creations = frozenset() self.cancelled_creations = frozenset()
self.library_progress = {} self.library_progress = {}
self.library_transfers = {} self.library_transfers = {}
@@ -732,17 +773,25 @@ class Agent:
return Path(self.config_path) == AGENT_CONFIG and (UPDATE_JOURNAL.exists() or UPDATE_JOURNAL.is_symlink()) return Path(self.config_path) == AGENT_CONFIG and (UPDATE_JOURNAL.exists() or UPDATE_JOURNAL.is_symlink())
@contextlib.contextmanager @contextlib.contextmanager
def activity(self): def activity(self, read_only=False):
path = self.root / "agent" / "activity.lock" path = self.root / "agent" / "activity.lock"
descriptor = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) descriptor = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
mutation_descriptor = None
try: try:
try: try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) # All work excludes the updater. Mutations additionally exclude
# other mutation batches while read lanes may continue.
fcntl.flock(descriptor, fcntl.LOCK_SH | fcntl.LOCK_NB)
if not read_only:
mutation_descriptor = os.open(self.root / "agent" / "mutation.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
fcntl.flock(mutation_descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError: except BlockingIOError:
yield False yield False
return return
yield True yield True
finally: finally:
if mutation_descriptor is not None:
os.close(mutation_descriptor)
os.close(descriptor) os.close(descriptor)
def record_health(self): def record_health(self):
@@ -953,7 +1002,7 @@ class Agent:
if line.startswith("PRETTY_NAME="): if line.startswith("PRETTY_NAME="):
os_name = line.split("=", 1)[1].strip('"') os_name = line.split("=", 1)[1].strip('"')
update = self.update_inventory() update = self.update_inventory()
capabilities = ["instance.cancel-create"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else []) capabilities = ["instance.cancel-create", "operations.read-lane"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})} return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
def safe_to_disrupt(self, instance_id, generation): def safe_to_disrupt(self, instance_id, generation):
@@ -1860,23 +1909,89 @@ class Agent:
except (AgentError, OSError, ValueError): except (AgentError, OSError, ValueError):
pass pass
@staticmethod
def read_operation(operation):
return operation.get("kind") in ("host.inspect", "instance.inspect", "files.read", "files.list", "files.stat", "logs.read") or (operation.get("kind") == "rcon.execute" and operation.get("payload", {}).get("command") in ("status", "getstatus", "version"))
def instance_lock(self, operation):
key = operation.get("instanceId")
return self.instance_locks.setdefault(key, threading.RLock()) if key else contextlib.nullcontext()
def read_loop(self):
# Each lane owns its SQLite connection and never writes executing_id,
# checkpoints, or current_operation used by the mutation state machine.
journal = Journal(self.root / "agent" / "operations.sqlite3", recover=False)
try:
while not self.stopping.is_set():
try:
if self.update_pending():
self.stopping.wait(3)
continue
response = self.request("GET", "/api/agent/operations?lane=read&wait=20")
for operation in response.get("operations", []):
if self.stopping.is_set() or not self.read_operation(operation):
continue
with self.read_claim_lock:
if operation["id"] in self.read_claims:
continue
self.read_claims.add(operation["id"])
try:
with self.activity(read_only=True) as acquired:
if not acquired or self.update_pending():
continue
with self.read_gate.read(), self.instance_lock(operation):
row, _ = journal.accept(operation)
if row["state"] in ("accepted", "unknown", "running"):
self.report(journal.update(operation["id"], "running"))
try:
result = self.dispatch(operation)
row = journal.update(operation["id"], "completed", result)
except Exception as err:
row = journal.update(operation["id"], "failed", error=str(err) if isinstance(err, AgentError) else "Observation unavailable")
self.report(row)
finally:
with self.read_claim_lock:
self.read_claims.discard(operation["id"])
except (AgentError, OSError, ValueError, KeyError, sqlite3.Error):
self.stopping.wait(3)
finally:
journal.close()
def poll_operations(self): def poll_operations(self):
if self.stopping.is_set() or self.update_pending():
return
# Check both update and mutation locks before delivery, release them
# during the long poll, and recheck immediately before accepting work.
with self.activity() as acquired:
if not acquired:
return
# Never hold the updater lock while waiting for delivery.
response = self.request("GET", "/api/agent/operations?lane=write&wait=20")
with self.activity() as acquired: with self.activity() as acquired:
if not acquired or self.stopping.is_set() or self.update_pending(): if not acquired or self.stopping.is_set() or self.update_pending():
return return
if self.config.get("pendingCredential"): if self.config.get("pendingCredential"):
self.recover_credential() self.recover_credential()
response = self.request("GET", "/api/agent/operations") completed = True
for operation in response.get("operations", []): for operation in response.get("operations", []):
if self.stopping.is_set(): if self.stopping.is_set():
break break
if self.read_operation(operation):
continue
self.current_operation = operation["id"] self.current_operation = operation["id"]
# Global changes wait for observations; instance changes only
# block observations for their own immutable instance identity.
guard = self.read_gate.write() if operation["kind"] in ("host.decommission", "credentials.rotate") else contextlib.nullcontext()
try: try:
accepted, _ = self.journal.accept(operation) with guard, self.instance_lock(operation):
self.report(accepted) accepted, _ = self.journal.accept(operation)
self.report(self.execute(operation, report_running=True)) self.report(accepted)
result = self.execute(operation, report_running=True)
self.report(result)
completed = completed and result["state"] in ("completed", "failed")
finally: finally:
self.current_operation = None self.current_operation = None
return completed
def run(self): def run(self):
# Obtain the configured host port range before accepting any deployment. # Obtain the configured host port range before accepting any deployment.
@@ -1895,13 +2010,19 @@ class Agent:
self.stopping.wait(3) self.stopping.wait(3)
heartbeats = threading.Thread(target=self.heartbeat_loop, daemon=True) heartbeats = threading.Thread(target=self.heartbeat_loop, daemon=True)
heartbeats.start() heartbeats.start()
readers = [threading.Thread(target=self.read_loop, daemon=True) for _ in range(2)]
for reader in readers:
reader.start()
while not self.stopping.is_set(): while not self.stopping.is_set():
completed = False
try: try:
self.poll_operations() completed = self.poll_operations()
except (AgentError, OSError, ValueError, KeyError): except (AgentError, OSError, ValueError, KeyError):
# Logs deliberately omit URLs, tokens, payloads and raw errors. # Logs deliberately omit URLs, tokens, payloads and raw errors.
print("Host controller contact unavailable; existing containers continue running", file=sys.stderr) print("Host controller contact unavailable; existing containers continue running", file=sys.stderr)
self.stopping.wait(3) self.stopping.wait(0.25 if completed else 3)
for reader in readers:
reader.join(timeout=2)
heartbeats.join(timeout=2) heartbeats.join(timeout=2)
+63 -4
View File
@@ -4,6 +4,7 @@ import json
import os import os
from pathlib import Path from pathlib import Path
import tempfile import tempfile
import threading
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
@@ -43,11 +44,15 @@ class UpdateIntegrationTests(unittest.TestCase):
with self.assertRaises(BlockingIOError): with self.assertRaises(BlockingIOError):
fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB) fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
def test_operation_delivery_execution_and_reports_exclude_updates(self): def test_execution_and_reports_exclude_updates_but_long_poll_does_not(self):
operation = {"id": "one", "kind": "host.inspect", "payload": {}} operation = {"id": "one", "kind": "runtime.pull", "payload": {}}
calls = [] calls = []
def request(method, path, body=None): def request(method, path, body=None):
self.assert_activity_held() if method != "GET":
self.assert_activity_held()
else:
with (self.agent.root / "agent" / "activity.lock").open("a") as stream:
fcntl.flock(stream, fcntl.LOCK_EX | fcntl.LOCK_NB)
calls.append((method, path)) calls.append((method, path))
return {"operations": [operation]} if method == "GET" else {} return {"operations": [operation]} if method == "GET" else {}
def dispatch(value): def dispatch(value):
@@ -82,7 +87,61 @@ class UpdateIntegrationTests(unittest.TestCase):
module.UPDATE_JOURNAL.unlink() module.UPDATE_JOURNAL.unlink()
with patch.object(self.agent, "request", return_value={"operations": []}) as request: with patch.object(self.agent, "request", return_value={"operations": []}) as request:
self.agent.poll_operations() self.agent.poll_operations()
request.assert_called_once_with("GET", "/api/agent/operations") request.assert_called_once_with("GET", "/api/agent/operations?lane=write&wait=20")
def test_read_lane_completes_while_mutation_is_busy_and_excludes_updater(self):
read_done = threading.Event()
operation = {"id": "read-one", "kind": "host.inspect", "payload": {}}
self.agent.request = lambda method, path, body=None: {"operations": [operation]} if method == "GET" else {}
def dispatch(value):
self.assert_activity_held()
read_done.set()
self.agent.stopping.set()
return {"ok": True}
self.agent.dispatch = dispatch
with self.agent.activity() as acquired:
self.assertTrue(acquired)
reader = threading.Thread(target=self.agent.read_loop)
reader.start()
self.assertTrue(read_done.wait(2), "Read waited behind unrelated mutation activity")
reader.join(2)
self.assertFalse(reader.is_alive())
self.assertEqual(self.agent.journal.get("read-one")["state"], "completed")
self.assertIsNone(self.agent.executing_id)
def test_read_lane_never_dispatches_mutations(self):
def request(method, path, body=None):
self.agent.stopping.set()
return {"operations": [{"id": "bad-read", "kind": "instance.remove", "payload": {}}]}
self.agent.request = request
with patch.object(self.agent, "dispatch") as dispatch:
self.agent.read_loop()
dispatch.assert_not_called()
def test_instance_mutations_and_global_mutations_exclude_observations(self):
operation = {"instanceId": "one"}
entered = threading.Event()
release = threading.Event()
def read():
with self.agent.read_gate.read(), self.agent.instance_lock(operation):
entered.set()
release.wait(2)
with self.agent.instance_lock(operation):
reader = threading.Thread(target=read)
reader.start()
self.assertFalse(entered.wait(0.05))
self.assertTrue(entered.wait(2))
writer_entered = threading.Event()
def write():
with self.agent.read_gate.write():
writer_entered.set()
writer = threading.Thread(target=write)
writer.start()
self.assertFalse(writer_entered.wait(0.05))
release.set()
reader.join(2)
writer.join(2)
self.assertTrue(writer_entered.is_set())
def test_failed_controller_contact_never_writes_healthy_receipt(self): def test_failed_controller_contact_never_writes_healthy_receipt(self):
with patch.object(self.agent, "request", side_effect=module.AgentError("offline")): with patch.object(self.agent, "request", side_effect=module.AgentError("offline")):
+20
View File
@@ -443,3 +443,23 @@ check can install the release once the batch has drained. When a heartbeat
advertises cancellation support, delivery resumes with cancellation cleanup advertises cancellation support, delivery resumes with cancellation cleanup
first. This controller-side hold also recovers cancellations already waiting first. This controller-side hold also recovers cancellations already waiting
on older agents. Hosts without automatic updates need the current installer. on older agents. Hosts without automatic updates need the current installer.
## Responsive observations (agent 1.4.0)
Agent 1.4.0 separates read-only inspections, file/log reads, and the exact RCON
commands `status`, `getstatus`, and `version` from the serial mutation queue.
Two bounded read lanes can run during runtime or library downloads. Instance
mutations exclude reads of that instance; credential rotation and host cleanup
wait for active reads. Both lanes retain durable operation IDs, generation checks,
and the updater activity lock. Older agents continue using the combined queue.
Agents long-poll `GET /api/agent/operations?lane=read|write&wait=20`; requests
without those parameters retain immediate combined delivery. The controller wakes
pending polls when work is queued and rechecks authentication after waiting.
Long polls release the updater lock; execution reacquires it and rechecks update
state before accepting work.
Browser game-folder uploads transfer up to three files concurrently. Chunks of
one file remain serial and checksum-verified. Publication waits for admitted file
transfers and prevents subsequent writes; pause/resume still uses acknowledged
per-file offsets. Host downloads retain their existing bounded concurrency.
+6 -2
View File
@@ -6,7 +6,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build && npm run build:reports -- --outDir build/reports",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run", "test": "vitest run",
@@ -15,7 +15,11 @@
"seed:fixture": "tsx scripts/seed-fixture.ts", "seed:fixture": "tsx scripts/seed-fixture.ts",
"agent:package": "python3 agent/package_release.py", "agent:package": "python3 agent/package_release.py",
"prebuild": "npm run agent:package", "prebuild": "npm run agent:package",
"test:agent": "python3 -m unittest discover -s agent/tests -v" "test:agent": "python3 -m unittest discover -s agent/tests -v",
"build:reports": "vite build --config vite.reports.config.ts",
"predev": "npm run build:reports",
"pretest": "npm run build:reports",
"pretest:watch": "npm run build:reports"
}, },
"dependencies": { "dependencies": {
"@fontsource/alfa-slab-one": "^5.3.0", "@fontsource/alfa-slab-one": "^5.3.0",
+1
View File
@@ -45,6 +45,7 @@ const percentile = (values, fraction) => [...values].sort((a, b) => a - b)[Math.
function seed(handle, definition) { function seed(handle, definition) {
handle.exec(source('src/lib/server/db/schema.sql')); handle.exec(source('src/lib/server/db/schema.sql'));
handle.exec("CREATE TABLE report_revisions(topic TEXT PRIMARY KEY, revision INTEGER NOT NULL); INSERT INTO report_revisions VALUES ('leaderboard',0)");
const match = handle.prepare(`INSERT INTO leaderboard_matches const match = handle.prepare(`INSERT INTO leaderboard_matches
(id, server, game, map, map_key, map_name, started_at, ended_at, last_received_at, (id, server, game, map, map_key, map_name, started_at, ended_at, last_received_at,
status, start_observed, start_players, highest_round, last_event, last_fingerprint) status, start_observed, start_players, highest_round, last_event, last_fingerprint)
+12 -1
View File
@@ -3,6 +3,8 @@ import { migrate } from '$lib/server/db/migrate.js';
import { startWorker } from '$lib/server/pipeline/worker.js'; import { startWorker } from '$lib/server/pipeline/worker.js';
import { interruptDeployments } from '$lib/server/provisioning/history.js'; import { interruptDeployments } from '$lib/server/provisioning/history.js';
import { authenticationHandle } from '$lib/server/auth/guard.js'; import { authenticationHandle } from '$lib/server/auth/guard.js';
import { emit } from '$lib/server/pipeline/bus.js';
import { startPerformanceMonitor } from '$lib/server/performance.js';
let booted = false; let booted = false;
function boot(): void { function boot(): void {
@@ -11,6 +13,7 @@ function boot(): void {
migrate(); migrate();
interruptDeployments(); interruptDeployments();
startWorker(); startWorker();
startPerformanceMonitor();
} }
// SvelteKit calls handle() for every request; using the first request as the // SvelteKit calls handle() for every request; using the first request as the
@@ -18,5 +21,13 @@ function boot(): void {
// out-of-band init hook. // out-of-band init hook.
export const handle: Handle = async (input) => { export const handle: Handle = async (input) => {
boot(); boot();
return authenticationHandle(input); const started = performance.now();
const response = await authenticationHandle(input);
const elapsed = performance.now() - started;
response.headers.append('Server-Timing', `app;dur=${elapsed.toFixed(1)}`);
if (process.env.EC_PERF_LOG === 'true' && elapsed > 250 && !input.event.route.id?.startsWith('/api/agent/')) {
console.info('[request]', { route: input.event.route.id, method: input.event.request.method, status: response.status, elapsedMs: Math.round(elapsed) });
}
if (input.event.locals.user && !['GET','HEAD','OPTIONS'].includes(input.event.request.method) && response.status < 400) emit({ type: 'fleet-invalidated' });
return response;
}; };
+2
View File
@@ -1,4 +1,5 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import { installReportRevisions } from '../reports/revisions.js';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import { config } from '../config.js'; import { config } from '../config.js';
@@ -368,6 +369,7 @@ export function db(): Database.Database {
backfillLeaderboard(handle); backfillLeaderboard(handle);
initializeFleetSettings(handle); initializeFleetSettings(handle);
migrateNativeInstances(handle); migrateNativeInstances(handle);
installReportRevisions(handle);
} catch (err) { } catch (err) {
handle.close(); handle.close();
throw err; throw err;
+33 -24
View File
@@ -10,6 +10,11 @@ const metrics = new Set<string>(leaderboardMetrics.map(metric => metric.key));
export const MAX_LEADERBOARD_OFFSET = 1_000_000; export const MAX_LEADERBOARD_OFFSET = 1_000_000;
type RankedEntry = Omit<LeaderboardEntry, 'records'>; type RankedEntry = Omit<LeaderboardEntry, 'records'>;
interface RankedScope { revision: number; entries: RankedEntry[]; maps: LeaderboardMap[]; summary: LeaderboardResponse['summary'] }
const scopeCaches = new WeakMap<object, Map<string, RankedScope>>();
// Match SQLite's built-in lower(), which folds ASCII rather than Unicode.
const searchText = (value: string) => value.replace(/[A-Z]/g, ch => ch.toLowerCase());
interface LeaderboardRow extends Omit<RankedEntry, 'guid'> { interface LeaderboardRow extends Omit<RankedEntry, 'guid'> {
// A left join keeps metadata available when the requested page has no players. // A left join keeps metadata available when the requested page has no players.
guid: string | null; guid: string | null;
@@ -91,21 +96,15 @@ function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): Leade
SELECT measures.*, p.name, RANK() OVER (ORDER BY measures.${metric} DESC) AS rank SELECT measures.*, p.name, RANK() OVER (ORDER BY measures.${metric} DESC) AS rank
FROM measures JOIN identities p ON p.guid = measures.guid AND p.recency = 1 ${qualification} FROM measures JOIN identities p ON p.guid = measures.guid AND p.recency = 1 ${qualification}
)`; )`;
if (exactGuid !== undefined) { let revision = -1;
const entries = handle.prepare(`${aggregated} SELECT * FROM ranked WHERE guid = ?`) try { revision = (handle.prepare("SELECT revision FROM report_revisions WHERE topic='leaderboard'").get() as { revision: number }).revision; }
.all(...parameters, exactGuid) as RankedEntry[]; catch { /* Standalone history tools can operate without cache metadata. */ }
return addRecordSources(handle, entries, options)[0] ?? null; let cache = scopeCaches.get(handle);
} if (!cache) { cache = new Map(); scopeCaches.set(handle, cache); }
const cacheKey = JSON.stringify([options.game ?? '', options.map ?? '', metric]);
const query = options.q?.trim() ?? ''; let saved = cache.get(cacheKey);
const search = query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : ''; if (!saved || revision < 0 || saved.revision !== revision) {
// Reuse one ranked set for the page and matching count. Summaries deliberately const rows = handle.prepare(`${aggregated}, playerSummary AS (
// cover the full game/map scope, including players below an efficiency threshold.
const rows = handle.prepare(`${aggregated}, searched AS MATERIALIZED (
SELECT * FROM ranked ${search}
), page AS (
SELECT * FROM searched ORDER BY ${metric} DESC, name COLLATE NOCASE, name, guid LIMIT ? OFFSET ?
), playerSummary AS (
SELECT COUNT(*) AS players, COALESCE(SUM(kills), 0) AS kills, SELECT COUNT(*) AS players, COALESCE(SUM(kills), 0) AS kills,
COALESCE(SUM(roundsSurvived), 0) AS roundsSurvived, COALESCE(SUM(roundsSurvived), 0) AS roundsSurvived,
COALESCE(MAX(highestRound), 0) AS highestRound FROM totals COALESCE(MAX(highestRound), 0) AS highestRound FROM totals
@@ -113,21 +112,18 @@ function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): Leade
SELECT COUNT(DISTINCT match_id) AS matches, MIN(matchStartedAt) AS firstTrackedAt, SELECT COUNT(DISTINCT match_id) AS matches, MIN(matchStartedAt) AS firstTrackedAt,
MAX(matchUpdatedAt) AS updatedAt FROM observations MAX(matchUpdatedAt) AS updatedAt FROM observations
), metadata AS ( ), metadata AS (
SELECT (SELECT COUNT(*) FROM searched) AS total, playerSummary.*, matchSummary.* SELECT playerSummary.*, matchSummary.* FROM playerSummary CROSS JOIN matchSummary
FROM playerSummary CROSS JOIN matchSummary ) SELECT ranked.*, metadata.players AS summaryPlayers,
) SELECT page.*, metadata.total AS matchingTotal, metadata.players AS summaryPlayers,
metadata.matches AS summaryMatches, metadata.kills AS summaryKills, metadata.matches AS summaryMatches, metadata.kills AS summaryKills,
metadata.roundsSurvived AS summaryRounds, metadata.highestRound AS summaryHighestRound, metadata.roundsSurvived AS summaryRounds, metadata.highestRound AS summaryHighestRound,
metadata.firstTrackedAt AS summaryFirstTrackedAt, metadata.updatedAt AS summaryUpdatedAt metadata.firstTrackedAt AS summaryFirstTrackedAt, metadata.updatedAt AS summaryUpdatedAt
FROM metadata LEFT JOIN page ON 1 FROM metadata LEFT JOIN ranked ON 1
ORDER BY page.${metric} DESC, page.name COLLATE NOCASE, page.name, page.guid`) ORDER BY ranked.${metric} DESC, ranked.name COLLATE NOCASE, ranked.name, ranked.guid`)
.all(...parameters, ...(query ? [query, query] : []), limit, offset) as LeaderboardRow[]; .all(...parameters) as LeaderboardRow[];
const rankedEntries = rows.filter((row): row is LeaderboardRow & { guid: string } => row.guid !== null) const rankedEntries = rows.filter((row): row is LeaderboardRow & { guid: string } => row.guid !== null)
.map(({ matchingTotal, summaryPlayers, summaryMatches, summaryKills, summaryRounds, .map(({ matchingTotal, summaryPlayers, summaryMatches, summaryKills, summaryRounds,
summaryHighestRound, summaryFirstTrackedAt, summaryUpdatedAt, ...entry }) => entry); summaryHighestRound, summaryFirstTrackedAt, summaryUpdatedAt, ...entry }) => entry);
const entries = addRecordSources(handle, rankedEntries, options);
const metadata = rows[0]; const metadata = rows[0];
const total = metadata.matchingTotal;
const summary: LeaderboardResponse['summary'] = { const summary: LeaderboardResponse['summary'] = {
players: metadata.summaryPlayers, matches: metadata.summaryMatches, kills: metadata.summaryKills, players: metadata.summaryPlayers, matches: metadata.summaryMatches, kills: metadata.summaryKills,
roundsSurvived: metadata.summaryRounds, highestRound: metadata.summaryHighestRound, roundsSurvived: metadata.summaryRounds, highestRound: metadata.summaryHighestRound,
@@ -140,5 +136,18 @@ function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): Leade
GROUP BY game, map_key HAVING id = MAX(id) GROUP BY game, map_key HAVING id = MAX(id)
ORDER BY game, name COLLATE NOCASE, map_key`) ORDER BY game, name COLLATE NOCASE, map_key`)
.all(...(options.game ? [options.game] : [])) as LeaderboardMap[]; .all(...(options.game ? [options.game] : [])) as LeaderboardMap[];
return { entries, maps, total, limit, offset, metric, summary }; saved = { revision, entries: rankedEntries, maps, summary };
cache.delete(cacheKey);
if (rankedEntries.length <= 100_000) cache.set(cacheKey, saved);
// Bound total retained players as well as the number of scopes.
while (cache.size > 8 || [...cache.values()].reduce((n, scope) => n + scope.entries.length, 0) > 100_000) cache.delete(cache.keys().next().value!);
}
if (exactGuid !== undefined) {
const entry = saved.entries.find(entry => entry.guid === exactGuid);
return entry ? addRecordSources(handle, [entry], options)[0] : null;
}
const query = searchText(options.q?.trim() ?? '');
const matching = query ? saved.entries.filter(entry => searchText(entry.name).includes(query) || searchText(entry.guid).includes(query)) : saved.entries;
const entries = addRecordSources(handle, matching.slice(offset, offset + limit), options);
return { entries, maps: saved.maps, total: matching.length, limit, offset, metric, summary: saved.summary };
} }
+10 -2
View File
@@ -179,6 +179,10 @@ export function setServerField(
field: keyof ServerRow, field: keyof ServerRow,
value: string | number | null value: string | number | null
): void { ): void {
setServerFields(name, { [field]: value });
}
export function setServerFields(name: string, fields: Partial<Record<keyof ServerRow, string | number | null>>): void {
const allowed: Array<keyof ServerRow> = [ const allowed: Array<keyof ServerRow> = [
'komodo_type', 'komodo_type',
'komodo_name', 'komodo_name',
@@ -211,8 +215,12 @@ export function setServerField(
'game_ended_at', 'game_ended_at',
'game_cycle_key' 'game_cycle_key'
]; ];
if (!allowed.includes(field)) throw new Error(`refusing to set ${field}`); const entries = Object.entries(fields);
db().prepare(`UPDATE servers SET ${field} = ? WHERE name = ?`).run(value, name); if (!entries.length) return;
for (const [field] of entries) if (!allowed.includes(field as keyof ServerRow)) throw new Error(`refusing to set ${field}`);
db().prepare(`UPDATE servers SET ${entries.map(([field]) => `${field} = ?`).join(', ')} WHERE name = ?
AND (${entries.map(([field]) => `${field} IS NOT ?`).join(' OR ')})`)
.run(...entries.map(([,value]) => value), name, ...entries.map(([,value]) => value));
} }
/** /**
+51
View File
@@ -0,0 +1,51 @@
import { db } from '../db/index.js';
import { listServers } from './servers.js';
import { observationReader } from './status.js';
import { config, komodoConfigured, rconConfigured } from '../config.js';
import { runningVersions } from '../integrations/plutonium.js';
import { listHosts } from '../hosts/index.js';
import type { NativeInstance } from '../backends/native.js';
export function fleetSnapshot() {
const rconHost = config.rcon.host;
const hosts = listHosts();
const hostsById = new Map(hosts.map(({ inventory: _inventory, ...host }) => [host.id, host]));
const versions = new Map(runningVersions().map(version => [version.server, version]));
const instances = new Map((db().prepare('SELECT * FROM native_instances WHERE retired_at IS NULL').all() as NativeInstance[]).map(instance => [instance.server, instance]));
const holds = new Map<string, { target: string; betaId: number }[]>();
for (const row of db().prepare(`SELECT bsv.server, s.target_rel_path AS target, b.id AS betaId
FROM beta_servers bsv JOIN betas b ON b.id=bsv.beta_id JOIN beta_scripts bs ON bs.beta_id=b.id
JOIN scripts s ON s.id=bs.script_id WHERE bsv.server_deleted_at IS NULL AND b.state IN ('running','ready-promote')`).all() as
{ server: string; target: string; betaId: number }[]) {
const entries = holds.get(row.server) ?? []; entries.push({ target: row.target, betaId: row.betaId }); holds.set(row.server, entries);
}
const observe = observationReader();
const servers = listServers().map((s) => {
const instance = s.native_instance_id ? instances.get(s.name) : null;
return {
...observe(s),
host: s.host_id ? hostsById.get(s.host_id) ?? null : null,
network: instance ? { bindAddress: instance.bind_address, localPort: instance.port, publicAddress: instance.public_address, publicPort: instance.public_port } : null,
plutonium: versions.get(s.name),
// Derived endpoint — same logic as endpointFor() but without asking
// Komodo. If we don't have a port yet, endpoint is null.
endpoint: instance ? { host: `agent:${instance.host_id}`, port: instance.port } : s.game_port ? { host: s.rcon_host ?? rconHost, port: s.game_port } : null,
held: holds.get(s.name) ?? []
};
});
return {
servers,
serverNow: Date.now(),
reservedNames: (db().prepare("SELECT j.server FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id WHERE d.state IN ('queued', 'running', 'awaiting-health')").all() as { server: string }[]).map(row => row.server),
integrations: {
native: hosts.length > 0 || !komodoConfigured(),
hostsReady: hosts.filter(host => host.readiness === 'ready').length,
hostsTotal: hosts.length,
rcon: rconConfigured(),
komodo: komodoConfigured(),
rconTimeoutMs: config.rcon.timeoutMs,
defaultRconHost: rconHost
}
};
}
+41 -5
View File
@@ -4,21 +4,57 @@ import { isProvisioning } from './provisioning.js';
import { restartSettling } from '../provisioning/restarts.js'; import { restartSettling } from '../provisioning/restarts.js';
import { type ServerRow } from './servers.js'; import { type ServerRow } from './servers.js';
import { telemetryReport, latestGameSubmission } from './status-evidence.js'; import { telemetryReport, latestGameSubmission } from './status-evidence.js';
import { db } from '../db/index.js';
import { parseGameEvent, type GameTelemetry } from '../../telemetry.js';
import { restartRequestInFlight } from '../provisioning/restarts.js';
interface Evidence { provisioning: boolean; settling: boolean; telemetry: GameTelemetry | null; submission: string | null }
/** One set of evidence reads per snapshot, rather than four queries per server. */
export function observationReader() {
const handle = db();
const operations = new Map((handle.prepare(`SELECT server, action, state FROM server_deployments
WHERE rowid IN (SELECT MAX(rowid) FROM server_deployments WHERE server_deleted_at IS NULL GROUP BY server)`).all() as
{ server: string; action: string; state: string }[]).map(row => [row.server, row]));
const restarts = new Map((handle.prepare('SELECT server, requested_at, healthy_at FROM restart_rollout').all() as
{ server: string; requested_at: string; healthy_at: string | null }[]).map(row => [row.server, row]));
const submissions = new Map((handle.prepare(`SELECT server, MAX(at) AS at FROM (
SELECT server, submitted_at AS at FROM server_deployments WHERE server_deleted_at IS NULL AND action IN ('create','start','restart','rebuild')
UNION ALL SELECT server, requested_at AS at FROM restart_rollout) GROUP BY server`).all() as
{ server: string; at: string | null }[]).map(row => [row.server, row.at]));
const reports = new Map<string, GameTelemetry>();
for (const row of handle.prepare('SELECT server, received_at, payload FROM server_telemetry WHERE payload IS NOT NULL').all() as
{ server: string; received_at: string; payload: string }[]) {
try {
const saved = JSON.parse(row.payload) as GameTelemetry;
const event = parseGameEvent(saved);
if (event.server === row.server) reports.set(row.server, { ...event, receivedAt: row.received_at, timing: saved.timing });
} catch { /* Invalid retained telemetry is unavailable. */ }
}
return (server: ServerRow) => {
const operation = operations.get(server.name), restart = restarts.get(server.name);
return serverObservation(server, {
provisioning: !!operation && ['create','rebuild','start'].includes(operation.action) && ['queued','running','awaiting-health'].includes(operation.state),
settling: restartRequestInFlight(server.name) || !!(restart && !restart.healthy_at && Date.now() - Date.parse(restart.requested_at) < 15_000),
telemetry: reports.get(server.name) ?? null, submission: submissions.get(server.name) ?? null
});
};
}
/** Shared snapshot for REST, status events, metrics, and other server-side readers. */ /** Shared snapshot for REST, status events, metrics, and other server-side readers. */
export function serverObservation(server: ServerRow) { export function serverObservation(server: ServerRow, evidence?: Evidence) {
let roster = null; let roster = null;
try { roster = server.last_roster ? JSON.parse(server.last_roster) : null; } catch { /* unavailable */ } try { roster = server.last_roster ? JSON.parse(server.last_roster) : null; } catch { /* unavailable */ }
const rcon: GameObservation = { last_players: server.last_players, last_map: server.last_map, const rcon: GameObservation = { last_players: server.last_players, last_map: server.last_map,
last_gametype: server.last_gametype, last_seen_at: server.last_seen_at, last_error: server.last_error, roster }; last_gametype: server.last_gametype, last_seen_at: server.last_seen_at, last_error: server.last_error, roster };
const provisioning = isProvisioning(server.name); const provisioning = evidence ? evidence.provisioning : isProvisioning(server.name);
const state = restartSettling(server.name) ? 'restarting' : server.container_state; const state = (evidence ? evidence.settling : restartSettling(server.name)) ? 'restarting' : server.container_state;
const ready = !provisioning && (!state || state === 'running'); const ready = !provisioning && (!state || state === 'running');
const health = provisioning ? 'provisioning' : classifyHealth(server.last_seen_at, server.last_error, state); const health = provisioning ? 'provisioning' : classifyHealth(server.last_seen_at, server.last_error, state);
return selectServerObservation({ ...server, ...rcon, rcon_status: rcon, container_state: state, health, return selectServerObservation({ ...server, ...rcon, rcon_status: rcon, container_state: state, health,
status_source: null as 'telemetry' | 'rcon' | null, status_source: null as 'telemetry' | 'rcon' | null,
telemetry: telemetryReport(server.name) ?? undefined, telemetry: (evidence ? evidence.telemetry : telemetryReport(server.name)) ?? undefined,
telemetry_after: latestGameSubmission(server.name), telemetry_after: evidence ? evidence.submission : latestGameSubmission(server.name),
last_error: ready ? server.last_error : null, last_error: ready ? server.last_error : null,
replySnippet: ready ? server.last_reply_snippet : null, replySnippet: ready ? server.last_reply_snippet : null,
active_since: ready ? server.active_since : null, inactive_since: ready ? server.inactive_since : null, active_since: ready ? server.active_since : null, inactive_since: ready ? server.inactive_since : null,
+16 -9
View File
@@ -18,14 +18,14 @@ type CacheRow = { host_id: string; version_id: string; status: LibraryCacheState
const cacheFromRow = (row: CacheRow): HostLibraryCache => ({ hostId: row.host_id, libraryVersionId: row.version_id, status: row.status, bytesDownloaded: row.bytes_downloaded, bytesTotal: row.bytes_total, manifestSha256: row.manifest_sha256, error: row.error, updatedAt: row.updated_at, operationId: row.operation_id, const cacheFromRow = (row: CacheRow): HostLibraryCache => ({ hostId: row.host_id, libraryVersionId: row.version_id, status: row.status, bytesDownloaded: row.bytes_downloaded, bytesTotal: row.bytes_total, manifestSha256: row.manifest_sha256, error: row.error, updatedAt: row.updated_at, operationId: row.operation_id,
...(row.bytes_verified == null ? {} : { bytesVerified: row.bytes_verified }), ...(row.bytes_verified == null ? {} : { bytesVerified: row.bytes_verified }),
...(row.transfer_progress ? { transfer: JSON.parse(row.transfer_progress) as LibraryTransferProgress } : {}) }); ...(row.transfer_progress ? { transfer: JSON.parse(row.transfer_progress) as LibraryTransferProgress } : {}) });
function versionFromRow(row: VersionRow): GameLibraryVersion { function versionFromRow(row: VersionRow & { file_count?: number }, summaryCaches?: Map<string, HostLibraryCache[]>): GameLibraryVersion {
const files = JSON.parse(row.manifest) as GameFileEntry[]; const files = summaryCaches ? [] : JSON.parse(row.manifest) as GameFileEntry[];
return { id: row.id, libraryId: row.library_id, game: row.game, version: row.version, return { id: row.id, libraryId: row.library_id, game: row.game, version: row.version,
description: row.description, state: row.state, sizeBytes: row.size_bytes, description: row.description, state: row.state, sizeBytes: row.size_bytes,
files, fileCount: files.length || (database().prepare('SELECT COUNT(*) AS count FROM game_file_upload_entries WHERE version_id=?').get(row.id) as { count: number }).count, manifestSha256: row.manifest_sha256, createdAt: row.created_at, files, fileCount: row.file_count ?? (files.length || (database().prepare('SELECT COUNT(*) AS count FROM game_file_upload_entries WHERE version_id=?').get(row.id) as { count: number }).count), manifestSha256: row.manifest_sha256, createdAt: row.created_at,
publishedAt: row.published_at, storageLocation: row.storage_location, sourceHostId: row.source_host_id || null, publishedAt: row.published_at, storageLocation: row.storage_location, sourceHostId: row.source_host_id || null,
sourceVolume: row.source_volume || null, error: row.error, operationId: row.operation_id, sourceVolume: row.source_volume || null, error: row.error, operationId: row.operation_id,
caches: (database().prepare('SELECT * FROM host_library_caches WHERE version_id = ?').all(row.id) as CacheRow[]).map(cacheFromRow) }; caches: summaryCaches ? summaryCaches.get(row.id) ?? [] : (database().prepare('SELECT * FROM host_library_caches WHERE version_id = ?').all(row.id) as CacheRow[]).map(cacheFromRow) };
} }
export function getLibraryVersion(id: string): GameLibraryVersion | null { export function getLibraryVersion(id: string): GameLibraryVersion | null {
const row = database().prepare('SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id WHERE v.id=?').get(id) as VersionRow | undefined; const row = database().prepare('SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id WHERE v.id=?').get(id) as VersionRow | undefined;
@@ -52,12 +52,19 @@ export function latestPublishedLibraries() {
return version ? [{ ...version, name: game.name }] : []; return version ? [{ ...version, name: game.name }] : [];
}); });
} }
export function listLibraries(): GameLibrary[] { export function listLibraries(summary = false): GameLibrary[] {
reconcileLibraryOperations(); reconcileLibraryOperations();
let summaryCaches: Map<string, HostLibraryCache[]> | undefined;
if (summary) {
summaryCaches = new Map();
for (const row of database().prepare('SELECT * FROM host_library_caches').all() as CacheRow[]) {
const items = summaryCaches.get(row.version_id) ?? []; items.push(cacheFromRow(row)); summaryCaches.set(row.version_id, items);
}
}
const latest = new Map(latestPublishedLibraries().map(version => [version.game, version.id])); const latest = new Map(latestPublishedLibraries().map(version => [version.game, version.id]));
return GAME_FILE_GAMES.map(game => { return GAME_FILE_GAMES.map(game => {
const versions = (database().prepare(`SELECT v.*, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id const versions = (database().prepare(`SELECT ${summary ? `v.id, v.library_id, v.version, v.description, v.dlc, v.state, v.size_bytes, v.manifest_sha256, v.created_at, v.published_at, v.storage_location, v.source_host_id, v.source_volume, v.error, v.operation_id, CASE WHEN json_array_length(v.manifest)>0 THEN json_array_length(v.manifest) ELSE (SELECT COUNT(*) FROM game_file_upload_entries e WHERE e.version_id=v.id) END AS file_count` : 'v.*'}, l.game FROM game_file_versions v JOIN game_file_libraries l ON l.id=v.library_id
WHERE l.game=? ORDER BY v.created_at DESC,v.rowid DESC`).all(game.id) as VersionRow[]).map(versionFromRow); WHERE l.game=? ORDER BY v.created_at DESC,v.rowid DESC`).all(game.id) as VersionRow[]).map(row => versionFromRow(row, summaryCaches));
const firstLibrary = database().prepare('SELECT created_at FROM game_file_libraries WHERE game=? ORDER BY created_at,rowid LIMIT 1').get(game.id) as { created_at: string } | undefined; const firstLibrary = database().prepare('SELECT created_at FROM game_file_libraries WHERE game=? ORDER BY created_at,rowid LIMIT 1').get(game.id) as { created_at: string } | undefined;
return { id: game.id, name: game.name, game: game.id, description: '', createdAt: firstLibrary?.created_at ?? '', latestVersionId: latest.get(game.id) ?? null, versions }; return { id: game.id, name: game.name, game: game.id, description: '', createdAt: firstLibrary?.created_at ?? '', latestVersionId: latest.get(game.id) ?? null, versions };
}); });
@@ -392,9 +399,9 @@ export function reconcileLibraryOperations(): void {
/** Lightweight list used in launch forms; large manifests stay on the detail endpoint. */ /** Lightweight list used in launch forms; large manifests stay on the detail endpoint. */
export function libraryOverview() { export function libraryOverview() {
const hosts = listHosts(); const hosts = listHosts();
return { libraries: listLibraries().map(library => ({ ...library, versions: library.versions.map(({ files: _files, ...version }) => ({ return { libraries: listLibraries(true).map(library => ({ ...library, versions: library.versions.map(({ files: _files, ...version }) => ({
...version, caches: hosts.map(host => { ...version, caches: hosts.map(host => {
const cached = getHostLibraryCache(host.id, version.id); const cached = version.caches.find(cache => cache.hostId === host.id);
if (cached) return { ...cached, status: cached.status === 'ready' && library.latestVersionId !== version.id ? 'outdated' as const : cached.status }; if (cached) return { ...cached, status: cached.status === 'ready' && library.latestVersionId !== version.id ? 'outdated' as const : cached.status };
return { return {
hostId: host.id, libraryVersionId: version.id, status: 'missing' as const, bytesDownloaded: 0, hostId: host.id, libraryVersionId: version.id, status: 'missing' as const, bytesDownloaded: 0,
+35 -5
View File
@@ -44,20 +44,50 @@ export function validateUploadPlan(input: unknown): UploadFilePlan[] {
return manifest.map(file => ({ path: file.path, size: file.size, lastModified: modified.get(file.path)! })); return manifest.map(file => ({ path: file.path, size: file.size, lastModified: modified.get(file.path)! }));
} }
const uploadLocks = new Map<string, Promise<void>>(); interface UploadLocks { barrier: Promise<void>; writers: number; files: Map<string, Promise<void>>; active: Set<Promise<void>> }
const uploadLocks = new Map<string, UploadLocks>();
function locksFor(key: string): UploadLocks {
let locks = uploadLocks.get(key);
if (!locks) { locks = { barrier: Promise.resolve(), writers: 0, files: new Map(), active: new Set() }; uploadLocks.set(key, locks); }
return locks;
}
function releaseLocks(key: string, locks: UploadLocks) {
if (!locks.writers && !locks.active.size && uploadLocks.get(key) === locks) uploadLocks.delete(key);
}
/** Serialize transfers and final publication for one import; a rejected operation never poisons the lock. */ /** Serialize transfers and final publication for one import; a rejected operation never poisons the lock. */
export async function withUploadLock<T>(root: string, callback: () => Promise<T> | T): Promise<T> { export async function withUploadLock<T>(root: string, callback: () => Promise<T> | T): Promise<T> {
const key = resolve(root); const key = resolve(root);
const previous = uploadLocks.get(key) ?? Promise.resolve(); const locks = locksFor(key);
// Capture readers admitted before publication, then prevent later readers
// from starting until publication finishes (including failed publication).
const previous = Promise.all([locks.barrier, ...locks.active]);
let release!: () => void; let release!: () => void;
const current = new Promise<void>(done => { release = done; }); const current = new Promise<void>(done => { release = done; });
uploadLocks.set(key, current); locks.barrier = current;
locks.writers++;
await previous; await previous;
try { return await callback(); } try { return await callback(); }
finally { finally {
release(); release();
if (uploadLocks.get(key) === current) uploadLocks.delete(key); locks.writers--;
releaseLocks(key, locks);
}
}
/** Different files may transfer together; offsets for the same file remain serial. */
export async function withUploadFileLock<T>(root: string, file: string, callback: () => Promise<T>): Promise<T> {
const key = resolve(root), locks = locksFor(key);
const previous = Promise.all([locks.barrier, locks.files.get(file)]);
let release!: () => void;
const current = new Promise<void>(done => { release = done; });
locks.files.set(file, current); locks.active.add(current);
await previous;
try { return await callback(); }
finally {
release(); locks.active.delete(current);
if (locks.files.get(file) === current) locks.files.delete(file);
releaseLocks(key, locks);
} }
} }
@@ -95,7 +125,7 @@ export async function receiveUploadChunk(
} }
if (!body && chunk.size !== 0) throw new Error('A file chunk body is required.'); if (!body && chunk.size !== 0) throw new Error('A file chunk body is required.');
return withUploadLock(root, async () => { return withUploadFileLock(root, file.path, async () => {
await beforeWrite?.(); await beforeWrite?.();
const progress = await getUploadFileProgress(root, file); const progress = await getUploadFileProgress(root, file);
if (chunk.offset !== progress.receivedBytes) throw new UploadOffsetError(progress.receivedBytes); if (chunk.offset !== progress.receivedBytes) throw new UploadOffsetError(progress.receivedBytes);
+25 -5
View File
@@ -1,4 +1,5 @@
import { recordAgentReceipt } from '../provisioning/agent-events.js'; import { recordAgentReceipt } from '../provisioning/agent-events.js';
import { EventEmitter } from 'node:events';
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { isIP } from 'node:net'; import { isIP } from 'node:net';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
@@ -8,6 +9,16 @@ import type { Host, HostDecommission, HostDeletion, HostInventory, HostMode, Hos
export const AGENT_PROTOCOL_VERSION = 1; export const AGENT_PROTOCOL_VERSION = 1;
export const HEARTBEAT_MAX_AGE_MS = 90_000; export const HEARTBEAT_MAX_AGE_MS = 90_000;
const now = () => new Date().toISOString(); const now = () => new Date().toISOString();
const workEvents = new EventEmitter();
workEvents.setMaxListeners(0);
export function waitForHostWork(hostId: string, signal: AbortSignal, timeoutMs = 20_000): Promise<void> {
return new Promise(resolve => {
const done = () => { clearTimeout(timer); workEvents.off(hostId, done); signal.removeEventListener('abort', done); resolve(); };
const timer = setTimeout(done, timeoutMs);
workEvents.on(hostId, done); signal.addEventListener('abort', done, { once: true });
if (signal.aborted) done();
});
}
const digest = (token: string) => createHash('sha256').update(token).digest('hex'); const digest = (token: string) => createHash('sha256').update(token).digest('hex');
function database() { const handle = db(); ensureHostSchema(handle); return handle; } function database() { const handle = db(); ensureHostSchema(handle); return handle; }
type HostRow = { id: string; name: string; hostname: string; mode: HostMode; port_min: number; port_max: number; bind_address: string; public_address: string; minimum_free_bytes: number; created_at: string; enrolled_at: string | null; last_heartbeat: string | null; inventory: string; credential_active: number }; type HostRow = { id: string; name: string; hostname: string; mode: HostMode; port_min: number; port_max: number; bind_address: string; public_address: string; minimum_free_bytes: number; created_at: string; enrolled_at: string | null; last_heartbeat: string | null; inventory: string; credential_active: number };
@@ -113,6 +124,7 @@ export function deleteHost(id: string): { deleted: boolean; operationId?: string
ON CONFLICT(host_id) DO UPDATE SET operation_id=excluded.operation_id,token_hash=excluded.token_hash,stage='queued',requested_at=excluded.requested_at,updated_at=excluded.updated_at,cleaned_at=NULL,error=NULL`) ON CONFLICT(host_id) DO UPDATE SET operation_id=excluded.operation_id,token_hash=excluded.token_hash,stage='queued',requested_at=excluded.requested_at,updated_at=excluded.updated_at,cleaned_at=NULL,error=NULL`)
.run(id, operationId, digest(token), timestamp, timestamp); .run(id, operationId, digest(token), timestamp, timestamp);
handle.prepare("UPDATE ec_hosts SET mode='draining' WHERE id=?").run(id); handle.prepare("UPDATE ec_hosts SET mode='draining' WHERE id=?").run(id);
queueMicrotask(() => workEvents.emit(id));
return { deleted: false, operationId }; return { deleted: false, operationId };
}).immediate(); }).immediate();
} }
@@ -287,7 +299,7 @@ export function requireReadyHost(hostId: string, options: { libraryId?: string;
type OperationRow = { id: string; host_id: string; kind: string; instance_id: string | null; generation: number | null; payload: string; state: HostOperationState; result: string | null; error: string | null; created_at: string; updated_at: string }; type OperationRow = { id: string; host_id: string; kind: string; instance_id: string | null; generation: number | null; payload: string; state: HostOperationState; result: string | null; error: string | null; created_at: string; updated_at: string };
function operationFromRow(row: OperationRow): HostOperation { return { id: row.id, hostId: row.host_id, kind: row.kind, instanceId: row.instance_id, generation: row.generation, payload: JSON.parse(row.payload), state: row.state, result: row.result === null ? null : JSON.parse(row.result), error: row.error, createdAt: row.created_at, updatedAt: row.updated_at }; } function operationFromRow(row: OperationRow): HostOperation { return { id: row.id, hostId: row.host_id, kind: row.kind, instanceId: row.instance_id, generation: row.generation, payload: JSON.parse(row.payload), state: row.state, result: row.result === null ? null : JSON.parse(row.result), error: row.error, createdAt: row.created_at, updatedAt: row.updated_at }; }
export function getHostOperation(id: string): HostOperation | null { const row = database().prepare('SELECT * FROM ec_host_operations WHERE id=?').get(id) as OperationRow | undefined; return row ? operationFromRow(row) : null; } export function getHostOperation(id: string): HostOperation | null { const row = database().prepare('SELECT * FROM ec_host_operations WHERE id=?').get(id) as OperationRow | undefined; return row ? operationFromRow(row) : null; }
export function listHostOperations(hostId: string, limit = 100): HostOperation[] { return (database().prepare('SELECT * FROM ec_host_operations WHERE host_id=? ORDER BY created_at DESC,rowid DESC LIMIT ?').all(hostId, Math.min(500, Math.max(1, limit))) as OperationRow[]).map(operationFromRow); } export function listHostOperations(hostId: string, limit = 100, summary = false): HostOperation[] { return (database().prepare(`SELECT ${summary ? `id,host_id,kind,instance_id,generation,state,error,created_at,updated_at,'{}' AS payload,NULL AS result` : '*'} FROM ec_host_operations WHERE host_id=? ORDER BY created_at DESC,rowid DESC LIMIT ?`).all(hostId, Math.min(500, Math.max(1, limit))) as OperationRow[]).map(operationFromRow); }
/** Heartbeats can tell an executing create to stop at its next safe command boundary. */ /** Heartbeats can tell an executing create to stop at its next safe command boundary. */
export function pendingCreationCancellations(hostId: string): string[] { export function pendingCreationCancellations(hostId: string): string[] {
return (database().prepare(`SELECT json_extract(payload,'$.createOperationId') AS id FROM ec_host_operations return (database().prepare(`SELECT json_extract(payload,'$.createOperationId') AS id FROM ec_host_operations
@@ -300,7 +312,7 @@ export function queueHostOperation(hostId: string, kind: string, payload: Record
if (!/^[a-z][a-z0-9_.-]{0,79}$/.test(kind)) throw new Error('Invalid operation kind.'); if (!/^[a-z][a-z0-9_.-]{0,79}$/.test(kind)) throw new Error('Invalid operation kind.');
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('Operation payload must be an object.'); if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw new Error('Operation payload must be an object.');
if (options.generation !== undefined && (!Number.isSafeInteger(options.generation) || options.generation < 1)) throw new Error('Instance generation must be a positive integer.'); if (options.generation !== undefined && (!Number.isSafeInteger(options.generation) || options.generation < 1)) throw new Error('Instance generation must be a positive integer.');
return database().transaction(() => { const operation = database().transaction(() => {
if (options.idempotencyKey) { if (options.idempotencyKey) {
const existing = database().prepare('SELECT * FROM ec_host_operations WHERE host_id=? AND idempotency_key=?').get(hostId, options.idempotencyKey) as OperationRow | undefined; const existing = database().prepare('SELECT * FROM ec_host_operations WHERE host_id=? AND idempotency_key=?').get(hostId, options.idempotencyKey) as OperationRow | undefined;
if (existing) { if (existing) {
@@ -315,8 +327,10 @@ export function queueHostOperation(hostId: string, kind: string, payload: Record
database().prepare('INSERT INTO ec_host_operation_events(operation_id,state,created_at) VALUES(?,?,?)').run(id, 'pending', timestamp); database().prepare('INSERT INTO ec_host_operation_events(operation_id,state,created_at) VALUES(?,?,?)').run(id, 'pending', timestamp);
return getHostOperation(id)!; return getHostOperation(id)!;
})(); })();
queueMicrotask(() => workEvents.emit(hostId));
return operation;
} }
export function pendingHostOperations(hostId: string): HostOperation[] { export function pendingHostOperations(hostId: string, lane: 'all' | 'read' | 'write' = 'all'): HostOperation[] {
const host = requireHost(hostId); const host = requireHost(hostId);
if (host.mode === 'disabled' || !host.connected) return []; if (host.mode === 'disabled' || !host.connected) return [];
// Older agents hold activity.lock across the entire delivered batch, including // Older agents hold activity.lock across the entire delivered batch, including
@@ -328,8 +342,12 @@ export function pendingHostOperations(hostId: string): HostOperation[] {
WHERE host_id=? AND kind='instance.cancel-create' AND state IN ('pending','accepted','running','unknown') LIMIT 1`).get(hostId)) return []; WHERE host_id=? AND kind='instance.cancel-create' AND state IN ('pending','accepted','running','unknown') LIMIT 1`).get(hostId)) return [];
// Accepted/running/unknown commands keep the same immutable ID. The agent // Accepted/running/unknown commands keep the same immutable ID. The agent
// consults its durable journal before acknowledging or executing a replay. // consults its durable journal before acknowledging or executing a replay.
const readOnly = `(kind IN ('host.inspect','instance.inspect','files.read','files.list','files.stat','logs.read')
OR (kind='rcon.execute' AND json_extract(payload,'$.command') IN ('status','getstatus','version')))`;
const laneFilter = lane === 'all' ? '' : ` AND ${lane === 'write' ? 'NOT ' : ''}${readOnly}`;
return database().transaction(() => { return database().transaction(() => {
const rows = database().prepare(`SELECT * FROM ec_host_operations o WHERE host_id=? AND state IN ('pending','accepted','running','unknown') const rows = database().prepare(`SELECT * FROM ec_host_operations o WHERE host_id=? AND state IN ('pending','accepted','running','unknown')
${laneFilter}
AND (? OR state != 'pending' OR kind NOT IN ('instance.create','create')) AND (? OR state != 'pending' OR kind NOT IN ('instance.create','create'))
AND (kind!='instance.cancel-create' OR ?) AND (kind!='instance.cancel-create' OR ?)
AND NOT EXISTS (SELECT 1 FROM ec_host_operations cancellation WHERE cancellation.host_id=o.host_id AND NOT EXISTS (SELECT 1 FROM ec_host_operations cancellation WHERE cancellation.host_id=o.host_id
@@ -342,7 +360,7 @@ export function pendingHostOperations(hostId: string): HostOperation[] {
})(); })();
} }
export function reportHostOperation(hostId: string, id: string, input: { state: unknown; result?: unknown; error?: unknown }): HostOperation { export function reportHostOperation(hostId: string, id: string, input: { state: unknown; result?: unknown; error?: unknown }): HostOperation {
return database().transaction(() => { const operation = database().transaction(() => {
requireHost(hostId); requireHost(hostId);
const operation = getHostOperation(id); const operation = getHostOperation(id);
if (!operation || operation.hostId !== hostId) throw new Error('Operation was not found for this host.'); if (!operation || operation.hostId !== hostId) throw new Error('Operation was not found for this host.');
@@ -367,6 +385,8 @@ export function reportHostOperation(hostId: string, id: string, input: { state:
} }
return getHostOperation(id)!; return getHostOperation(id)!;
})(); })();
queueMicrotask(() => workEvents.emit(`operation:${id}`));
return operation;
} }
export async function waitHostOperation(id: string, timeoutMs = 30_000): Promise<HostOperation> { export async function waitHostOperation(id: string, timeoutMs = 30_000): Promise<HostOperation> {
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
@@ -375,7 +395,7 @@ export async function waitHostOperation(id: string, timeoutMs = 30_000): Promise
if (!operation) throw new Error('Host operation was not found.'); if (!operation) throw new Error('Host operation was not found.');
if (['completed','failed','unknown'].includes(operation.state)) return operation; if (['completed','failed','unknown'].includes(operation.state)) return operation;
if (Date.now() >= deadline) throw new Error(`Host operation ${id} is awaiting acknowledgement; reconcile this operation ID before retrying.`); if (Date.now() >= deadline) throw new Error(`Host operation ${id} is awaiting acknowledgement; reconcile this operation ID before retrying.`);
await new Promise(resolve => setTimeout(resolve, 200)); await waitForHostWork(`operation:${id}`, new AbortController().signal, Math.min(1000, Math.max(1, deadline - Date.now())));
} }
} }
+8 -3
View File
@@ -22,13 +22,18 @@ export function freshRelease(): number | null {
export function runningVersions(): PlutoniumVersion[] { export function runningVersions(): PlutoniumVersion[] {
const versions = db().prepare('SELECT * FROM plutonium_versions').all() as PlutoniumVersion[]; const versions = db().prepare('SELECT * FROM plutonium_versions').all() as PlutoniumVersion[];
const target = freshRelease(); const target = freshRelease();
const byServer = new Map(versions.map(version => [version.server, version]));
const epochs = new Map((db().prepare(`SELECT server, MAX(at) AS at FROM (
SELECT server, submitted_at AS at FROM server_deployments WHERE server_deleted_at IS NULL AND action IN ('create','start','restart','rebuild')
UNION ALL SELECT server, requested_at AS at FROM restart_rollout) GROUP BY server`).all() as { server: string; at: string | null }[]).map(row => [row.server, row.at]));
const recovering = new Set((db().prepare('SELECT server FROM restart_rollout WHERE healthy_at IS NULL').all() as { server: string }[]).map(row => row.server));
return listServers().map(server => { return listServers().map(server => {
const value = versions.find(v => v.server === server.name) const value = byServer.get(server.name)
?? { server: server.name, revision: null, checked_at: null, error: null }; ?? { server: server.name, revision: null, checked_at: null, error: null };
const age = value.checked_at ? Date.now() - Date.parse(value.checked_at) : NaN; const age = value.checked_at ? Date.now() - Date.parse(value.checked_at) : NaN;
const epoch = latestGameSubmission(server.name); const epoch = epochs.get(server.name);
const stopped = server.desired_state === 'stopped'; const stopped = server.desired_state === 'stopped';
const valid = !value.error && age >= 0 && age < HOUR && (!epoch || value.checked_at! > epoch) && !restartRecoveryPending(server.name); const valid = !value.error && age >= 0 && age < HOUR && (!epoch || value.checked_at! > epoch) && !recovering.has(server.name);
const revision = valid && !stopped ? value.revision : null; const revision = valid && !stopped ? value.revision : null;
return { ...value, revision, stopped, return { ...value, revision, stopped,
update_status: stopped ? 'stopped' : revision === null || target === null ? 'unknown' update_status: stopped ? 'stopped' : revision === null || target === null ? 'unknown'
+13
View File
@@ -0,0 +1,13 @@
import { monitorEventLoopDelay } from 'node:perf_hooks';
let started = false;
export function startPerformanceMonitor() {
if (started || process.env.EC_PERF_LOG !== 'true') return;
started = true;
const delay = monitorEventLoopDelay({ resolution: 20 }); delay.enable();
setInterval(() => {
console.info('[performance]', { eventLoopP95Ms: Math.round(delay.percentile(95) / 1e6),
eventLoopMaxMs: Math.round(delay.max / 1e6), rssMiB: Math.round(process.memoryUsage().rss / 1048576) });
delay.reset();
}, 60_000).unref();
}
+10 -1
View File
@@ -46,6 +46,7 @@ export type BetaStage =
export type BetaState = 'running' | 'ready-promote' | 'promoted' | 'rolled-back'; export type BetaState = 'running' | 'ready-promote' | 'promoted' | 'rolled-back';
export type PipelineEvent = export type PipelineEvent =
| { type: 'fleet-invalidated' }
| { type: 'server-telemetry'; entries: GameTelemetry[] } | { type: 'server-telemetry'; entries: GameTelemetry[] }
| { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] } | { type: 'deployment-opened'; deploymentId: string; action: string; servers: string[] }
| { type: 'server-changed'; deploymentId: string; server: string; files: number } | { type: 'server-changed'; deploymentId: string; server: string; files: number }
@@ -58,7 +59,7 @@ export type PipelineEvent =
error: string | null; error: string | null;
} }
| { type: 'sweep-tick'; frozen: boolean; reason?: string } | { type: 'sweep-tick'; frozen: boolean; reason?: string }
| { type: 'server-status'; entries: ServerStatusEntry[] } | { type: 'server-status'; entries: ServerStatusEntry[]; delta?: boolean }
| { type: 'beta-opened'; betaId: number; scriptIds: number[]; servers: string[] } | { type: 'beta-opened'; betaId: number; scriptIds: number[]; servers: string[] }
| { | {
type: 'beta-server-stage'; type: 'beta-server-stage';
@@ -78,9 +79,17 @@ class Bus extends EventEmitter {
} }
const bus = new Bus(); const bus = new Bus();
let invalidationPending = false;
export function invalidateFleet(): void {
if (invalidationPending) return;
invalidationPending = true;
// Flush after nested database transactions have committed.
queueMicrotask(() => { invalidationPending = false; bus.emit('event', { type: 'fleet-invalidated' }); });
}
export function emit(event: PipelineEvent): void { export function emit(event: PipelineEvent): void {
bus.emit('event', event); bus.emit('event', event);
if (event.type === 'beta-opened' || event.type === 'beta-state' || event.type === 'beta-server-stage') invalidateFleet();
} }
export function subscribe(listener: (event: PipelineEvent) => void): () => void { export function subscribe(listener: (event: PipelineEvent) => void): () => void {
+44 -29
View File
@@ -6,10 +6,10 @@ import { emit } from './bus.js';
import type { ServerStatusEntry } from './bus.js'; import type { ServerStatusEntry } from './bus.js';
import { rconStatusResult } from '../integrations/rcon.js'; import { rconStatusResult } from '../integrations/rcon.js';
import type { RconFailure } from '../integrations/rcon.js'; import type { RconFailure } from '../integrations/rcon.js';
import { getServer, listServers, setServerField, type ServerRow } from '../fleet/servers.js'; import { getServer, listServers, setServerFields, type ServerRow } from '../fleet/servers.js';
import { komodoInspectHealthy } from '../integrations/komodo.js'; import { komodoInspectHealthy } from '../integrations/komodo.js';
import { restartSettling } from '../provisioning/index.js'; import { restartSettling } from '../provisioning/index.js';
import { serverObservation } from '../fleet/status.js'; import { serverObservation, observationReader } from '../fleet/status.js';
import { telemetryEvidence, latestGameSubmission } from '../fleet/status-evidence.js'; import { telemetryEvidence, latestGameSubmission } from '../fleet/status-evidence.js';
import { isProvisioning } from '../fleet/provisioning.js'; import { isProvisioning } from '../fleet/provisioning.js';
import { STATUS_REFRESH_MS } from '../../server-freshness.js'; import { STATUS_REFRESH_MS } from '../../server-freshness.js';
@@ -76,6 +76,15 @@ function encodeError(reason: RconFailure, detail: string): string {
const pendingStatus = new Map<string, Promise<number | null>>(); const pendingStatus = new Map<string, Promise<number | null>>();
const statusQueue: Array<() => Promise<void>> = []; const statusQueue: Array<() => Promise<void>> = [];
let activeStatus = 0; let activeStatus = 0;
const changedStatus = new Map<string, ServerStatusEntry>();
function publishStatus(entry: ServerStatusEntry) {
const scheduled = changedStatus.size > 0;
changedStatus.set(entry.server, entry);
if (!scheduled) queueMicrotask(() => {
const entries = [...changedStatus.values()]; changedStatus.clear();
emit({ type: 'server-status', delta: true, entries });
});
}
function drainStatusQueue(): void { function drainStatusQueue(): void {
while (activeStatus < STATUS_CONCURRENCY && statusQueue.length) { while (activeStatus < STATUS_CONCURRENCY && statusQueue.length) {
@@ -95,9 +104,9 @@ function queueStatus(server: string): Promise<number | null> {
let capacity: number | null = null; let capacity: number | null = null;
try { try {
capacity = await refreshServerStatus(server); capacity = await refreshServerStatus(server);
// Consumers expect a full snapshot. Re-read it now so neither game // Publish this server's latest evidence without waiting for the fleet.
// data nor container evidence waits for the slowest fleet member. const current = getServer(server);
emit({ type: 'server-status', entries: listServers().map(statusEntry) }); if (current) publishStatus(statusEntry(current));
} catch (err) { console.error(`[worker] status error for ${server}:`, err); } } catch (err) { console.error(`[worker] status error for ${server}:`, err); }
finally { pendingStatus.delete(server); finish(capacity); } finally { pendingStatus.delete(server); finish(capacity); }
}); });
@@ -113,8 +122,9 @@ export async function fanoutStatus(): Promise<void> {
try { try {
// Metrics remain complete fleet collections, using freshness at capture // Metrics remain complete fleet collections, using freshness at capture
// time rather than the status saved at the beginning of a long scan. // time rather than the status saved at the beginning of a long scan.
const observe = observationReader();
recordMetrics(listServers().map(srv => { recordMetrics(listServers().map(srv => {
const current = serverObservation(srv); const current = observe(srv);
return { server: srv.name, game: srv.game ?? 'unknown', return { server: srv.name, game: srv.game ?? 'unknown',
map: selectedZombieMap(srv.game, current.last_map ?? srv.map, srv.map_slug)?.friendly ?? current.last_map ?? srv.map ?? 'Unknown map', map: selectedZombieMap(srv.game, current.last_map ?? srv.map, srv.map_slug)?.friendly ?? current.last_map ?? srv.map ?? 'Unknown map',
players: current.health === 'online' ? current.last_players : null, players: current.health === 'online' ? current.last_players : null,
@@ -127,6 +137,8 @@ async function refreshServerStatus(server: string): Promise<number | null> {
const srv = getServer(server); const srv = getServer(server);
if (!srv) return null; if (!srv) return null;
let capacity: number | null = null; let capacity: number | null = null;
let updates: Partial<Record<keyof ServerRow, string | number | null>> = {};
const flush = () => { setServerFields(srv.name, updates); updates = {}; };
if (!isProvisioning(srv.name)) { if (!isProvisioning(srv.name)) {
const epoch = latestGameSubmission(srv.name); const epoch = latestGameSubmission(srv.name);
const unchanged = () => epoch === latestGameSubmission(srv.name) && !isProvisioning(srv.name) const unchanged = () => epoch === latestGameSubmission(srv.name) && !isProvisioning(srv.name)
@@ -136,53 +148,56 @@ async function refreshServerStatus(server: string): Promise<number | null> {
: await komodoInspectHealthy(srv.name).catch(err => ({ ok: false as const, state: 'unknown', reason: (err as Error).message })); : await komodoInspectHealthy(srv.name).catch(err => ({ ok: false as const, state: 'unknown', reason: (err as Error).message }));
// A lifecycle request may be submitted while an inspection is in flight. // A lifecycle request may be submitted while an inspection is in flight.
if (unchanged()) { if (unchanged()) {
setServerField(srv.name, 'container_state', container.ok ? 'running' : container.state?.toLowerCase() ?? 'unknown'); updates.container_state = container.ok ? 'running' : container.state?.toLowerCase() ?? 'unknown';
setServerField(srv.name, 'container_checked_at', new Date().toISOString()); updates.container_checked_at = new Date().toISOString();
if (!container.ok) { if (!container.ok) {
setServerField(srv.name, 'last_error', null); updates.last_error = null;
setServerField(srv.name, 'last_reply_snippet', null); updates.last_reply_snippet = null;
} else if (!telemetryEvidence(srv.name)) { } else if (!telemetryEvidence(srv.name)) {
flush();
const result = await rconStatusResult(srv.name).catch(err => ({ ok: false as const, const result = await rconStatusResult(srv.name).catch(err => ({ ok: false as const,
reason: 'send-error' as RconFailure, detail: (err as Error).message, rawReply: undefined })); reason: 'send-error' as RconFailure, detail: (err as Error).message, rawReply: undefined }));
if (unchanged()) { if (unchanged()) {
const now = new Date().toISOString(); const now = new Date().toISOString();
setServerField(srv.name, 'last_checked_at', now); updates.last_checked_at = now;
setServerField(srv.name, 'last_reply_snippet', result.rawReply ?? null); updates.last_reply_snippet = result.rawReply ?? null;
if (result.ok) { if (result.ok) {
capacity = result.status.capacity ?? null; capacity = result.status.capacity ?? null;
setServerField(srv.name, 'last_players', result.status.players); updates.last_players = result.status.players;
setServerField(srv.name, 'last_map', result.status.map); updates.last_map = result.status.map;
setServerField(srv.name, 'last_gametype', result.status.gametype); updates.last_gametype = result.status.gametype;
setServerField(srv.name, 'last_roster', JSON.stringify(result.status.roster)); updates.last_roster = JSON.stringify(result.status.roster);
setServerField(srv.name, 'last_seen_at', now); updates.last_seen_at = now;
setServerField(srv.name, 'last_error', null); updates.last_error = null;
} else setServerField(srv.name, 'last_error', encodeError(result.reason, result.detail)); } else updates.last_error = encodeError(result.reason, result.detail);
} }
} }
} }
} else { } else {
setServerField(srv.name, 'last_error', null); updates.last_error = null;
setServerField(srv.name, 'last_reply_snippet', null); updates.last_reply_snippet = null;
} }
flush();
const current = getServer(srv.name) ?? srv; const current = getServer(srv.name) ?? srv;
const status = serverObservation(current); const status = serverObservation(current);
// Occupancy timers and match clearing follow the same source as the UI. // Occupancy timers and match clearing follow the same source as the UI.
if (status.health === 'online' && status.last_players !== null) { if (status.health === 'online' && status.last_players !== null) {
if (status.last_players > 0) { if (status.last_players > 0) {
setServerField(srv.name, 'active_since', current.active_since ?? status.last_seen_at); updates.active_since = current.active_since ?? status.last_seen_at;
setServerField(srv.name, 'inactive_since', null); updates.inactive_since = null;
} else { } else {
setServerField(srv.name, 'active_since', null); updates.active_since = null;
setServerField(srv.name, 'inactive_since', current.inactive_since ?? status.last_seen_at); updates.inactive_since = current.inactive_since ?? status.last_seen_at;
setServerField(srv.name, 'game_started_at', null); updates.game_started_at = null;
setServerField(srv.name, 'game_ended_at', null); updates.game_ended_at = null;
} }
} }
flush();
return capacity; return capacity;
} }
function statusEntry(srv: ServerRow): ServerStatusEntry { export function statusEntry(srv: ServerRow, observe = serverObservation): ServerStatusEntry {
const final = serverObservation(srv); const final = observe(srv);
return { server: srv.name, players: final.last_players, map: final.last_map ?? srv.map, return { server: srv.name, players: final.last_players, map: final.last_map ?? srv.map,
gametype: final.last_gametype, roster: final.roster, health: final.health, lastSeenAt: final.last_seen_at, gametype: final.last_gametype, roster: final.roster, health: final.health, lastSeenAt: final.last_seen_at,
lastError: final.last_error, replySnippet: final.replySnippet, lastError: final.last_error, replySnippet: final.replySnippet,
+2
View File
@@ -1,4 +1,5 @@
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { invalidateFleet } from '../pipeline/bus.js';
import { provisioningPhases, type ProvisioningPhase, type ProvisioningProgress, type ProvisioningCheck, import { provisioningPhases, type ProvisioningPhase, type ProvisioningProgress, type ProvisioningCheck,
type ProvisioningCheckpoint, type ProvisioningBlocker, type RecoveryObservation } from '../../provisioning-detail.js'; type ProvisioningCheckpoint, type ProvisioningBlocker, type RecoveryObservation } from '../../provisioning-detail.js';
@@ -116,5 +117,6 @@ export function recordProgress(id: string, update: ProgressUpdate): void {
const insert = handle.prepare(`INSERT INTO server_deployment_events (deployment_id, created_at, message, kind, phase_key, details_json) const insert = handle.prepare(`INSERT INTO server_deployment_events (deployment_id, created_at, message, kind, phase_key, details_json)
VALUES (?, ?, ?, ?, ?, ?)`); VALUES (?, ?, ?, ?, ?, ?)`);
for (const event of events) insert.run(id, now, event.message, event.kind, next.phase, event.details ? JSON.stringify(event.details) : null); for (const event of events) insert.run(id, now, event.message, event.kind, next.phase, event.details ? JSON.stringify(event.details) : null);
if (events.length) invalidateFleet();
})(); })();
} }
+5
View File
@@ -0,0 +1,5 @@
import Database from 'better-sqlite3';
import { workerData } from 'node:worker_threads';
const handle = new Database(workerData.dbPath, { readonly: true, fileMustExist: true });
export function db() { return handle; }
+64
View File
@@ -0,0 +1,64 @@
import { Worker } from 'node:worker_threads';
import { resolve } from 'node:path';
import { config } from '../config.js';
import { db } from '../db/index.js';
import type { MetricsQuery, MetricsData } from '../../metrics.js';
import type { LeaderboardOptions, LeaderboardResponse, PlayerProfile, LeaderboardEntry } from '../../leaderboard.js';
class ReportWorker {
private worker: Worker | null = null;
private sequence = 0;
private pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>();
private flights = new Map<string, Promise<any>>();
async close() {
const worker = this.worker; this.worker = null;
for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error('Report service stopped')); }
this.pending.clear(); this.flights.clear();
await worker?.terminate();
}
run<T>(kind: string, input: unknown): Promise<T> {
const requestInput = input as { query?: MetricsQuery; now?: number };
const key = JSON.stringify(kind === 'metrics' ? [kind, requestInput.query, Math.floor(requestInput.now! / 30_000)] : [kind, input]);
const flight = this.flights.get(key);
if (flight) return flight;
if (this.pending.size >= 64) return Promise.reject(new Error('Reports are busy. Please retry shortly.'));
db(); // Finish migrations before opening the independent reader.
if (!this.worker) {
const worker = this.worker = new Worker(resolve(process.env.NODE_ENV === 'production' ? 'build/reports/worker.mjs' : '.svelte-kit/reports/worker.mjs'), { workerData: { dbPath: resolve(config.dbPath) } });
worker.on('message', ({ id, value, error, elapsedMs, cached }) => {
const pending = this.pending.get(id); if (!pending) return;
clearTimeout(pending.timer); this.pending.delete(id);
if (process.env.EC_PERF_LOG === 'true') console.info('[report]', { kind, elapsedMs, cached });
if (error) pending.reject(new Error(error)); else pending.resolve(value);
if (!this.pending.size) worker.unref();
});
const fail = (error: Error) => {
if (this.worker !== worker) return;
this.worker = null;
for (const pending of this.pending.values()) { clearTimeout(pending.timer); pending.reject(error); }
this.pending.clear(); void worker.terminate();
};
worker.on('error', fail);
worker.on('exit', () => fail(new Error('Report worker stopped. Please retry.')));
}
this.worker.ref();
const id = ++this.sequence;
const promise = new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
// Reset the worker rather than leaving an expensive abandoned query queued.
const worker = this.worker; this.worker = null;
for (const item of this.pending.values()) { clearTimeout(item.timer); item.reject(new Error('Report timed out. Please retry.')); }
this.pending.clear(); void worker?.terminate();
}, 60_000);
this.pending.set(id, { resolve, reject, timer }); this.worker!.postMessage({ id, kind, input });
}).finally(() => { if (this.flights.get(key) === promise) this.flights.delete(key); });
this.flights.set(key, promise);
return promise;
}
}
const metrics = new ReportWorker();
const leaderboard = new ReportWorker();
export const metricsReport = (query: MetricsQuery) => metrics.run<MetricsData>('metrics', { query, now: Date.now() });
export const leaderboardReport = (query: LeaderboardOptions) => leaderboard.run<LeaderboardResponse>('leaderboard', query);
export const playerReport = (guid: string, offset: number, options: LeaderboardOptions) => leaderboard.run<{ profile: PlayerProfile | null; entry: LeaderboardEntry | null }>('player', { guid, offset, options });
export async function closeReports() { await Promise.all([metrics.close(), leaderboard.close()]); }
+14
View File
@@ -0,0 +1,14 @@
import type Database from 'better-sqlite3';
export function installReportRevisions(handle: Database.Database) {
handle.exec(`CREATE TABLE IF NOT EXISTS report_revisions (topic TEXT PRIMARY KEY, revision INTEGER NOT NULL DEFAULT 0);
INSERT OR IGNORE INTO report_revisions(topic) VALUES ('metrics'), ('leaderboard');`);
for (const [topic, tables] of Object.entries({ metrics: ['metric_samples', 'servers'],
leaderboard: ['leaderboard_matches', 'leaderboard_match_players', 'leaderboard_players'] })) {
for (const table of tables) for (const action of ['INSERT', 'UPDATE', 'DELETE']) {
const condition = table === 'servers' && action === 'UPDATE' ? 'WHEN OLD.destroyed_at IS NOT NEW.destroyed_at OR OLD.name IS NOT NEW.name' : '';
handle.exec(`CREATE TRIGGER IF NOT EXISTS report_${table}_${action} AFTER ${action} ON ${table} ${condition}
BEGIN UPDATE report_revisions SET revision=revision+1 WHERE topic='${topic}'; END;`);
}
}
}
+33
View File
@@ -0,0 +1,33 @@
import { parentPort } from 'node:worker_threads';
import { db } from '../db/index.js';
import { readMetrics } from '../fleet/metrics.js';
import { getLeaderboard, getPlayerLeaderboardEntry } from '../fleet/leaderboard.js';
import { getPlayerProfile } from '../fleet/player-profile.js';
const cache = new Map<string, { value: unknown; bytes: number }>();
let bytes = 0;
parentPort!.on('message', ({ id, kind, input }) => {
const started = performance.now();
try {
const result = db().transaction(() => {
const topic = kind === 'metrics' ? 'metrics' : 'leaderboard';
const { revision } = db().prepare('SELECT revision FROM report_revisions WHERE topic=?').get(topic) as { revision: number };
const now = kind === 'metrics' ? input.now : Date.now();
const key = JSON.stringify([kind, revision, kind === 'metrics' ? Math.floor(now / 30_000) : 0, kind === 'metrics' ? input.query : input]);
const saved = cache.get(key);
if (saved) { cache.delete(key); cache.set(key, saved); return { value: saved.value, cached: true }; }
const value = kind === 'metrics' ? readMetrics(input.query, now)
: kind === 'leaderboard' ? getLeaderboard(input)
: { profile: getPlayerProfile(input.guid, input.offset, input.options), entry: getPlayerLeaderboardEntry(input.guid, input.options) };
const size = Buffer.byteLength(JSON.stringify(value));
if (size < 16 * 1024 * 1024) {
cache.set(key, { value, bytes: size }); bytes += size;
while (cache.size > 64 || bytes > 32 * 1024 * 1024) {
const oldest = cache.keys().next().value!; bytes -= cache.get(oldest)!.bytes; cache.delete(oldest);
}
}
return { value, cached: false };
})();
parentPort!.postMessage({ id, ...result, elapsedMs: performance.now() - started });
} catch (cause) { parentPort!.postMessage({ id, error: cause instanceof Error ? cause.message : 'Report failed' }); }
});
+15
View File
@@ -0,0 +1,15 @@
/** Load only the requested history, respecting the API's per-request page bound. */
export async function fetchCampaigns<T>(filters: Record<string, string>, count: number, signal?: AbortSignal): Promise<{ deployments: T[]; total: number }> {
const deployments: T[] = [];
let total = Infinity;
while (deployments.length < Math.min(count, total)) {
const params = new URLSearchParams({ ...filters, limit: String(Math.min(500, count - deployments.length)), offset: String(deployments.length) });
const response = await fetch(`/api/campaigns?${params}`, { signal });
if (!response.ok) throw new Error('Could not refresh campaigns.');
const page = await response.json() as { deployments: T[]; total: number };
total = page.total;
deployments.push(...page.deployments);
if (!page.deployments.length) break;
}
return { deployments, total: Number.isFinite(total) ? total : 0 };
}
+12 -5
View File
@@ -1,25 +1,32 @@
/** Poll visible page content without overlapping requests. Fleet watches use their own monitor. */ /** Poll visible page content without overlapping requests. Fleet watches use their own monitor. */
export function pollVisible(task: () => void | Promise<unknown>, interval: number, immediate = true): () => void { export function pollVisible(task: (signal: AbortSignal) => unknown | Promise<unknown>, interval: number, immediate = true): () => void {
let stopped = false; let stopped = false;
let running = false; let running = false;
let timer: ReturnType<typeof setTimeout> | undefined; let timer: ReturnType<typeof setTimeout> | undefined;
let controller: AbortController | undefined;
let failures = 0;
const visible = () => document.visibilityState !== 'hidden'; const visible = () => document.visibilityState !== 'hidden';
const run = async () => { const run = async () => {
if (stopped || running || !visible()) return; if (stopped || running || !visible()) return;
clearTimeout(timer); clearTimeout(timer);
running = true; running = true;
try { await task(); } controller = new AbortController();
catch (error) { console.error('Page refresh failed', error); } const timeout = setTimeout(() => controller?.abort(), 30_000);
try { failures = await task(controller.signal) === false ? failures + 1 : 0; }
catch (error) { if (!controller.signal.aborted) { failures++; console.error('Page refresh failed', error); } }
finally { finally {
clearTimeout(timeout);
controller = undefined;
running = false; running = false;
if (!stopped && visible()) timer = setTimeout(run, interval); if (!stopped && visible()) timer = setTimeout(run, interval * Math.min(8, 2 ** failures));
} }
}; };
const visibility = () => { const visibility = () => {
clearTimeout(timer); clearTimeout(timer);
if (!visible()) controller?.abort();
if (document.visibilityState !== 'hidden') void run(); if (document.visibilityState !== 'hidden') void run();
}; };
document.addEventListener('visibilitychange', visibility); document.addEventListener('visibilitychange', visibility);
if (immediate) void run(); else timer = setTimeout(run, interval); if (immediate) void run(); else timer = setTimeout(run, interval);
return () => { stopped = true; clearTimeout(timer); document.removeEventListener('visibilitychange', visibility); }; return () => { stopped = true; clearTimeout(timer); controller?.abort(); document.removeEventListener('visibilitychange', visibility); };
} }
+82 -30
View File
@@ -1,7 +1,9 @@
import { freshStatusTimestamp } from '$lib/server-freshness.js';
import { pollVisible } from '../poll.js';
import { selectServerObservation } from '$lib/server-observation.js'; import { selectServerObservation } from '$lib/server-observation.js';
import { derived, writable } from 'svelte/store'; import { derived, writable, get } from 'svelte/store';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { statusByServer, statusReceivedAt, syncServerTime, matchEventsByServer, telemetryByServer, uiNow } from './servers.js'; import { statusByServer, statusReceivedAt, syncServerTime, serverTimeNow, matchEventsByServer, telemetryByServer, uiNow } from './servers.js';
import { type FleetServer, type FleetOperation } from '$lib/fleet-overview.js'; import { type FleetServer, type FleetOperation } from '$lib/fleet-overview.js';
export const fleet = writable<{ servers: FleetServer[]; operations: FleetOperation[]; receivedAt: number }>({ export const fleet = writable<{ servers: FleetServer[]; operations: FleetOperation[]; receivedAt: number }>({
@@ -11,9 +13,30 @@ export const fleetError = writable('');
export const fleetRefreshing = writable(false); export const fleetRefreshing = writable(false);
export const fleetIntegrations = writable<{ rcon: boolean; komodo: boolean; native?: boolean; hostsReady?: number; hostsTotal?: number; rconTimeoutMs: number; defaultRconHost: string } | null>(null); export const fleetIntegrations = writable<{ rcon: boolean; komodo: boolean; native?: boolean; hostsReady?: number; hostsTotal?: number; rconTimeoutMs: number; defaultRconHost: string } | null>(null);
export const fleetServers = derived([fleet, statusByServer, statusReceivedAt, matchEventsByServer, telemetryByServer, uiNow], ([snapshot, live, received, matches, reports, now]) => const observed = new Map<string, { server: FleetServer; status: unknown; match: unknown; report: unknown; phase: string; value: FleetServer }>();
snapshot.servers.map(server => { const freshnessPhase = derived([fleet, statusByServer, telemetryByServer, uiNow], ([snapshot, live, reports, now]) => {
const status = received > snapshot.receivedAt ? live.get(server.name) : undefined; const times = snapshot.servers.flatMap(server => {
const status = live.get(server.name);
return [status?.lastSeenAt, status?.containerCheckedAt, status?.rconStatus?.last_seen_at, status?.telemetry?.receivedAt,
server.last_seen_at, server.container_checked_at, server.last_checked_at, server.rcon_status?.last_seen_at,
server.telemetry?.receivedAt, reports.get(server.name)?.receivedAt];
});
return times.map(time => `${freshStatusTimestamp(time ?? null, now)}:${now - Date.parse(time ?? '') >= 0 && now - Date.parse(time ?? '') <= 120_000}`).join(',');
});
export const fleetServers = derived([fleet, statusByServer, statusReceivedAt, matchEventsByServer, telemetryByServer, freshnessPhase], ([snapshot, live, received, matches, reports]) => {
const now = get(uiNow);
const next = snapshot.servers.map(server => {
const candidate = live.get(server.name);
const status = (candidate?.receivedAt ?? received) > snapshot.receivedAt ? candidate : undefined;
const match = matches.get(server.name);
const report = reports.get(server.name);
const times = [status?.lastSeenAt, status?.containerCheckedAt, status?.rconStatus?.last_seen_at,
status?.telemetry?.receivedAt, server.last_seen_at, server.container_checked_at, server.last_checked_at,
server.rcon_status?.last_seen_at, server.telemetry?.receivedAt, report?.receivedAt];
const phase = times.map(time => `${freshStatusTimestamp(time ?? null, now)}:${now - Date.parse(time ?? '') >= 0 && now - Date.parse(time ?? '') <= 120_000}`).join(',');
const saved = observed.get(server.name);
if (saved && saved.server === server && saved.status === status && saved.match === match && saved.report === report && saved.phase === phase) return saved.value;
const remember = (value: FleetServer) => { observed.set(server.name, { server, status, match, report, phase, value }); return value; };
const base = status ? { ...server, last_players: status.players, last_map: status.map, const base = status ? { ...server, last_players: status.players, last_map: status.map,
last_gametype: status.gametype, last_seen_at: status.lastSeenAt, last_error: status.lastError, last_gametype: status.gametype, last_seen_at: status.lastSeenAt, last_error: status.lastError,
roster: status.roster, health: status.health, endpoint: status.endpoint, replySnippet: status.replySnippet, active_since: status.activeSince, roster: status.roster, health: status.health, endpoint: status.endpoint, replySnippet: status.replySnippet, active_since: status.activeSince,
@@ -23,38 +46,65 @@ export const fleetServers = derived([fleet, statusByServer, statusReceivedAt, ma
telemetry_after: status.telemetryAfter ?? server.telemetry_after, rcon_status: status.rconStatus, telemetry_after: status.telemetryAfter ?? server.telemetry_after, rcon_status: status.rconStatus,
telemetry: status.telemetry ?? server.telemetry } : server; telemetry: status.telemetry ?? server.telemetry } : server;
const current = selectServerObservation(base, reports.get(server.name), now); const current = selectServerObservation(base, reports.get(server.name), now);
const match = matches.get(server.name);
// Apply lifecycle events immediately, until a newer status observes them // Apply lifecycle events immediately, until a newer status observes them
// or confirms that the lobby has emptied. // or confirms that the lobby has emptied.
if (!match || current.health !== 'online' if (!match || current.health !== 'online'
|| (current.telemetry_after && Date.parse(match.receivedAt) <= Date.parse(current.telemetry_after)) || (current.telemetry_after && Date.parse(match.receivedAt) <= Date.parse(current.telemetry_after))
|| Date.parse(match.receivedAt) <= Date.parse(current.last_seen_at ?? '') || Date.parse(match.receivedAt) <= Date.parse(current.last_seen_at ?? '')
|| match.receivedAt < (current.game_started_at ?? '') || match.receivedAt < (current.game_started_at ?? '')
|| match.receivedAt < (current.game_ended_at ?? '')) return current; || match.receivedAt < (current.game_ended_at ?? '')) return remember(current);
return { ...current, game_started_at: match.event === 'game_start' ? match.receivedAt : current.game_started_at, return remember({ ...current, game_started_at: match.event === 'game_start' ? match.receivedAt : current.game_started_at,
game_ended_at: match.event === 'game_end' ? match.receivedAt : null }; game_ended_at: match.event === 'game_end' ? match.receivedAt : null });
})); });
const names = new Set(snapshot.servers.map(server => server.name));
for (const name of observed.keys()) if (!names.has(name)) observed.delete(name);
return next;
});
let snapshotServerNow = 0;
export function hydrateFleet(snapshot: { servers: FleetServer[]; operations: FleetOperation[]; integrations: NonNullable<ReturnType<typeof getIntegrations>>; serverNow: number }) {
if (!browser || snapshot.serverNow < snapshotServerNow) return;
snapshotServerNow = snapshot.serverNow;
syncServerTime(snapshot.serverNow);
fleetIntegrations.set(snapshot.integrations);
fleet.set({ servers: snapshot.servers, operations: snapshot.operations, receivedAt: Date.now() - Math.max(0, serverTimeNow() - snapshot.serverNow) });
}
function getIntegrations() { return get(fleetIntegrations); }
let inFlight: Promise<void> | null = null; let inFlight: Promise<void> | null = null;
export function refreshFleet(): Promise<void> { let refreshRequested = false;
export function refreshFleet(afterCurrent = false): Promise<void> {
if (!browser) return Promise.resolve(); if (!browser) return Promise.resolve();
if (inFlight) return inFlight; if (inFlight) { refreshRequested ||= afterCurrent; return inFlight; }
inFlight = (async () => { inFlight = (async () => {
fleetRefreshing.set(true); fleetRefreshing.set(true);
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 12_000); const timeout = setTimeout(() => controller.abort(), 12_000);
const requestedAt = Date.now(); const requestedAt = Date.now();
try { try {
const responses = await Promise.all([ const results = await Promise.allSettled([
fetch('/api/servers', { signal: controller.signal }), (async () => {
fetch('/api/fleet', { signal: controller.signal }) const response = await fetch('/api/servers', { signal: controller.signal });
if (!response.ok) throw new Error('Could not refresh servers. Retrying automatically.');
const servers = await response.json();
if (servers.serverNow < snapshotServerNow) return;
snapshotServerNow = servers.serverNow;
syncServerTime(servers.serverNow); fleetIntegrations.set(servers.integrations);
fleet.update(value => ({ ...value, servers: servers.servers, receivedAt: requestedAt }));
})(),
(async () => {
const response = await fetch('/api/fleet', { signal: controller.signal });
if (!response.ok) throw new Error('Could not refresh operations. Retrying automatically.');
const operations = await response.json();
fleet.update(value => ({ ...value, operations: operations.operations }));
})()
]); ]);
if (responses.some(response => !response.ok)) throw new Error('Could not refresh Fleet. Retrying automatically.'); const failure = results.find(result => result.status === 'rejected');
const [servers, operations] = await Promise.all(responses.map(response => response.json())); if (failure?.status === 'rejected') throw failure.reason;
syncServerTime(servers.serverNow);
fleetError.set(''); fleetError.set('');
fleetIntegrations.set(servers.integrations);
fleet.set({ servers: servers.servers, operations: operations.operations, receivedAt: requestedAt });
} catch (error) { } catch (error) {
fleetError.set(error instanceof Error && error.name !== 'AbortError' fleetError.set(error instanceof Error && error.name !== 'AbortError'
? error.message : 'Fleet refresh timed out. Retrying automatically.'); ? error.message : 'Fleet refresh timed out. Retrying automatically.');
@@ -62,23 +112,25 @@ export function refreshFleet(): Promise<void> {
clearTimeout(timeout); clearTimeout(timeout);
fleetRefreshing.set(false); fleetRefreshing.set(false);
} }
})().finally(() => { inFlight = null; }); })().finally(() => {
inFlight = null;
if (refreshRequested) { refreshRequested = false; void refreshFleet(); }
});
return inFlight; return inFlight;
} }
let stop: (() => void) | null = null; let stop: (() => void) | null = null;
export function startFleetMonitor(): () => void { export function startFleetMonitor(): () => void {
if (!browser || stop) return () => {}; if (!browser || stop) return () => {};
void refreshFleet(); const stopPolling = pollVisible(() => refreshFleet(), 60_000, !get(fleet).receivedAt || Date.now() - get(fleet).receivedAt > 30_000);
const interval = setInterval(() => { let refreshTimer: ReturnType<typeof setTimeout> | undefined;
if (document.visibilityState !== 'hidden') void refreshFleet(); const changed = () => {
}, 10_000); if (refreshTimer || document.visibilityState === 'hidden') return;
const onFocus = () => { if (document.visibilityState === 'visible') void refreshFleet(); }; refreshTimer = setTimeout(() => { refreshTimer = undefined; void refreshFleet(true); }, 250);
document.addEventListener('visibilitychange', onFocus); };
window.addEventListener('ec:fleet-changed', changed);
stop = () => { stop = () => {
clearInterval(interval); stopPolling(); clearTimeout(refreshTimer); window.removeEventListener('ec:fleet-changed', changed); stop = null;
document.removeEventListener('visibilitychange', onFocus);
stop = null;
}; };
return stop; return stop;
} }
+3
View File
@@ -26,6 +26,9 @@ function updateDeployment(id: string, mutate: (d: PipelineDeployment) => void):
export function startPipelineStream(): (() => void) | undefined { export function startPipelineStream(): (() => void) | undefined {
if (!browser || source) return; if (!browser || source) return;
source = new EventSource('/api/events/pipeline'); source = new EventSource('/api/events/pipeline');
for (const event of ['server-changed', 'server-state', 'deployment-opened', 'fleet-invalidated']) {
source.addEventListener(event, () => window.dispatchEvent(new Event('ec:fleet-changed')));
}
source.addEventListener('deployment-opened', (evt) => { source.addEventListener('deployment-opened', (evt) => {
const data = JSON.parse((evt as MessageEvent).data) as { deploymentId: string; action: string; servers: string[] }; const data = JSON.parse((evt as MessageEvent).data) as { deploymentId: string; action: string; servers: string[] };
+6 -5
View File
@@ -17,6 +17,7 @@ export interface RconEndpoint {
} }
export interface ServerStatus { export interface ServerStatus {
receivedAt?: number;
server: string; server: string;
containerState?: string | null; containerState?: string | null;
containerCheckedAt?: string | null; containerCheckedAt?: string | null;
@@ -91,17 +92,17 @@ export function startServerStream(): (() => void) | undefined {
}); });
}); });
source.addEventListener('server-status', (evt) => { source.addEventListener('server-status', (evt) => {
const data = JSON.parse((evt as MessageEvent).data) as { entries: ServerStatus[]; serverNow?: number }; const data = JSON.parse((evt as MessageEvent).data) as { entries: ServerStatus[]; serverNow?: number; delta?: boolean };
syncServerTime(data.serverNow); syncServerTime(data.serverNow);
statusByServer.update(() => { statusByServer.update(previous => {
const m = new Map<string, ServerStatus>(); const m = data.delta ? new Map(previous) : new Map<string, ServerStatus>();
for (const e of data.entries) m.set(e.server, e); for (const e of data.entries) m.set(e.server, { ...e, receivedAt: Date.now() });
return m; return m;
}); });
statusReceivedAt.set(Date.now()); statusReceivedAt.set(Date.now());
}); });
if (tickInterval === null) { if (tickInterval === null) {
tickInterval = setInterval(() => _uiNow.set(serverTimeNow()), 1000); tickInterval = setInterval(() => { if (typeof document === 'undefined' || document.visibilityState !== 'hidden') _uiNow.set(serverTimeNow()); }, 1000);
} }
return () => { source?.close(); source = null; if (tickInterval) clearInterval(tickInterval); tickInterval = null; }; return () => { source?.close(); source = null; if (tickInterval) clearInterval(tickInterval); tickInterval = null; };
} }
+13 -4
View File
@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { startTimeZonePreference } from '$ui/stores/time-zone.js'; import { startTimeZonePreference } from '$ui/stores/time-zone.js';
import { afterNavigate, onNavigate, goto } from '$app/navigation'; import { afterNavigate, beforeNavigate, onNavigate, goto } from '$app/navigation';
import { startPipelineStream } from '$ui/stores/pipeline.js'; import { startPipelineStream } from '$ui/stores/pipeline.js';
import { startServerStream } from '$ui/stores/servers.js'; import { startServerStream } from '$ui/stores/servers.js';
import { startFleetMonitor } from '$ui/stores/fleet.js'; import { startFleetMonitor, hydrateFleet } from '$ui/stores/fleet.js';
import { page } from '$app/stores'; import { page } from '$app/stores';
import BrandMark from '$ui/components/BrandMark.svelte'; import BrandMark from '$ui/components/BrandMark.svelte';
import Toaster from '$ui/components/Toaster.svelte'; import Toaster from '$ui/components/Toaster.svelte';
@@ -40,6 +40,8 @@
else helpOpen = true; else helpOpen = true;
} }
beforeNavigate(() => performance.mark('ec:navigation-start'));
afterNavigate(() => { if (performance.getEntriesByName('ec:navigation-start').length) { performance.measure('ec:navigation', 'ec:navigation-start'); performance.clearMarks('ec:navigation-start'); } });
afterNavigate(() => { menuOpen = false; accountOpen = false; resolveConfirmation(false); }); afterNavigate(() => { menuOpen = false; accountOpen = false; resolveConfirmation(false); });
function closeMenu(event: KeyboardEvent) { function closeMenu(event: KeyboardEvent) {
@@ -67,8 +69,15 @@
const stopNotifications = startNotificationHistory(); const stopNotifications = startNotificationHistory();
const stopPipeline = startPipelineStream(); const stopPipeline = startPipelineStream();
const stopServers = startServerStream(); const stopServers = startServerStream();
const stopFleet = startFleetMonitor(); return () => { stopNotifications(); stopPipeline?.(); stopServers?.(); };
return () => { stopNotifications(); stopFleet(); stopPipeline?.(); stopServers?.(); }; });
$effect(() => {
if (!sessionReady) return;
const path = $page.url.pathname;
if (!(path === '/' || /^\/(servers|scripts|configs|plugins)\//.test(path) || path === '/betas/new')) return;
if ('fleetSnapshot' in $page.data && $page.data.fleetSnapshot) hydrateFleet($page.data.fleetSnapshot);
return startFleetMonitor();
}); });
onMount(() => { onMount(() => {
+4
View File
@@ -0,0 +1,4 @@
import { fleetSnapshot } from '$lib/server/fleet/snapshot.js';
import { fleetOperations } from '$lib/server/fleet/overview.js';
export function load() { return { fleetSnapshot: { ...fleetSnapshot(), operations: fleetOperations() } }; }
+21 -17
View File
@@ -1,4 +1,7 @@
<script lang="ts"> <script lang="ts">
let { data }: { data: import('./$types').PageData } = $props();
const snapshot = $derived($fleet.receivedAt ? $fleet : data.fleetSnapshot);
const visibleFleet = $derived($fleet.receivedAt ? $fleetServers : data.fleetSnapshot.servers);
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { afterNavigate, replaceState } from '$app/navigation'; import { afterNavigate, replaceState } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
@@ -60,7 +63,7 @@
} catch { /* Preferences also work when storage is disabled. */ } } catch { /* Preferences also work when storage is disabled. */ }
readViewUrl(); readViewUrl();
preferencesReady = true; preferencesReady = true;
void refreshFleet();
}); });
$effect(() => { $effect(() => {
@@ -75,25 +78,26 @@
if (url.href !== window.location.href) replaceState(url, $page.state); if (url.href !== window.location.href) replaceState(url, $page.state);
}); });
}); });
const snapshotAge = $derived.by(() => { $uiNow; return Math.max(0, Math.floor((Date.now() - $fleet.receivedAt) / 1000)); }); const snapshotAge = $derived.by(() => { $uiNow; return Math.max(0, Math.floor((Date.now() - ($fleet.receivedAt || data.fleetSnapshot.serverNow)) / 1000)); });
const integrations = $derived($fleetIntegrations ?? data.fleetSnapshot.integrations);
let servers = $derived($fleetServers.map(server => server.health === 'online' && !freshServer(server, $uiNow) let servers = $derived(visibleFleet);
? { ...server, health: 'stale' as const } : server)); const observationNow = () => untrack(() => $fleet.receivedAt ? $uiNow : data.fleetSnapshot.serverNow);
let activeOperations = $derived($fleet.operations.filter(op => op.state !== 'done')); let activeOperations = $derived(snapshot.operations.filter(op => op.state !== 'done'));
let attentionOperations = $derived(activeOperations.filter(op => op.state === 'attention')); let attentionOperations = $derived(activeOperations.filter(op => op.state === 'attention'));
let campaigns = $derived(new Set(activeOperations.filter(op => op.kind === 'campaign').map(op => op.group)).size); let campaigns = $derived(new Set(activeOperations.filter(op => op.kind === 'campaign').map(op => op.group)).size);
let healthy = $derived(servers.filter(server => freshServer(server, $uiNow))); let healthy = $derived(servers.filter(server => freshServer(server, observationNow())));
let players = $derived(healthy.reduce((sum, server) => sum + (server.last_players ?? 0), 0)); let players = $derived(healthy.reduce((sum, server) => sum + (server.last_players ?? 0), 0));
let empty = $derived(healthy.filter(server => server.last_players === 0).length); let empty = $derived(healthy.filter(server => server.last_players === 0).length);
let problems = $derived(servers.filter(server => server.health === 'offline' || server.health === 'stale')); let problems = $derived(servers.filter(server => server.health === 'offline' || server.health === 'stale'));
let starting = $derived(servers.filter(server => server.health === 'starting').length); let starting = $derived(servers.filter(server => server.health === 'starting').length);
let provisioning = $derived(servers.filter(server => server.health === 'provisioning').length); let provisioning = $derived(servers.filter(server => server.health === 'provisioning').length);
let filtered = $derived(servers.filter(server => matchesFleet(server, query, filter, game, pinned, activeOperations, $uiNow)) let filtered = $derived(servers.filter(server => matchesFleet(server, query, filter, game, pinned, activeOperations, observationNow()))
.sort((a, b) => { .sort((a, b) => {
const pinOrder = Number(pinned.includes(b.name)) - Number(pinned.includes(a.name)); const pinOrder = Number(pinned.includes(b.name)) - Number(pinned.includes(a.name));
if (pinOrder) return pinOrder; if (pinOrder) return pinOrder;
const rank = (server: FleetServer) => sort === 'players' ? (freshServer(server, $uiNow) ? server.last_players ?? -1 : -1) const rank = (server: FleetServer) => sort === 'players' ? (freshServer(server, observationNow()) ? server.last_players ?? -1 : -1)
: sort === 'round' ? currentRound(server, $telemetryByServer.get(server.name), $uiNow) ?? -1 : sort === 'round' ? currentRound(server, $telemetryByServer.get(server.name), observationNow()) ?? -1
: sort === 'duration' ? matchDuration(server) : 0; : sort === 'duration' ? matchDuration(server) : 0;
return rank(b) - rank(a) || serverTitle(a.name).localeCompare(serverTitle(b.name), undefined, { numeric: true }); return rank(b) - rank(a) || serverTitle(a.name).localeCompare(serverTitle(b.name), undefined, { numeric: true });
})); }));
@@ -109,12 +113,12 @@
} }
return [...result.entries()].sort(([, a], [, b]) => a.title.localeCompare(b.title)); return [...result.entries()].sort(([, a], [, b]) => a.title.localeCompare(b.title));
}); });
let spotlight = $derived(servers.filter(server => currentRound(server, $telemetryByServer.get(server.name), $uiNow) !== null) let spotlight = $derived(servers.filter(server => currentRound(server, $telemetryByServer.get(server.name), observationNow()) !== null)
.sort((a, b) => matchDuration(b) - matchDuration(a))[0]); .sort((a, b) => matchDuration(b) - matchDuration(a))[0]);
function matchDuration(server: FleetServer): number { function matchDuration(server: FleetServer): number {
return freshServer(server, $uiNow) && server.active_since && server.game_started_at && !server.game_ended_at return freshServer(server, observationNow()) && server.active_since && server.game_started_at && !server.game_ended_at
&& (server.last_players ?? 0) > 0 ? Math.max(0, $uiNow - Date.parse(server.game_started_at)) : -1; && (server.last_players ?? 0) > 0 ? Math.max(0, observationNow() - Date.parse(server.game_started_at)) : -1;
} }
function togglePin(name: string) { pinned = pinned.includes(name) ? pinned.filter(item => item !== name) : [...pinned, name]; } function togglePin(name: string) { pinned = pinned.includes(name) ? pinned.filter(item => item !== name) : [...pinned, name]; }
function launch(selection: { game: ZombieGame; slug: string } | null = null) { function launch(selection: { game: ZombieGame; slug: string } | null = null) {
@@ -139,7 +143,7 @@
<button onclick={() => void refreshFleet()}>Retry now</button></div> <button onclick={() => void refreshFleet()}>Retry now</button></div>
{/if} {/if}
{#if !$fleet.receivedAt} {#if !$fleet.receivedAt && !data.fleetSnapshot}
{#if !$fleetError}<div class="loading" role="status">Loading your fleet…</div>{/if} {#if !$fleetError}<div class="loading" role="status">Loading your fleet…</div>{/if}
{:else} {:else}
{#if servers.length}<div class="overview" aria-label="Fleet summary"> {#if servers.length}<div class="overview" aria-label="Fleet summary">
@@ -149,10 +153,10 @@
<a class="metric-link" class:no-campaigns={!campaigns} href="/campaigns"><strong>{campaigns}</strong> active campaigns</a> <a class="metric-link" class:no-campaigns={!campaigns} href="/campaigns"><strong>{campaigns}</strong> active campaigns</a>
<span class="freshness">{$fleetError ? 'Reconnecting' : 'Updated'} · {snapshotAge < 60 ? `${snapshotAge}s` : `${Math.floor(snapshotAge / 60)}m`} ago</span> <span class="freshness">{$fleetError ? 'Reconnecting' : 'Updated'} · {snapshotAge < 60 ? `${snapshotAge}s` : `${Math.floor(snapshotAge / 60)}m`} ago</span>
</div>{/if} </div>{/if}
{#if $fleetIntegrations?.native && !$fleetIntegrations.hostsReady} {#if integrations?.native && !integrations.hostsReady}
<div class="setup-note"><span><strong>Prepare a game host</strong> · {$fleetIntegrations.hostsTotal ? 'Hosts are connected or enrolled but need setup before new deployments.' : 'Add a host, prepare its runtime, and download game files.'}</span><a href="/hosts">Review hosts →</a></div> <div class="setup-note"><span><strong>Prepare a game host</strong> · {integrations.hostsTotal ? 'Hosts are connected or enrolled but need setup before new deployments.' : 'Add a host, prepare its runtime, and download game files.'}</span><a href="/hosts">Review hosts →</a></div>
{:else if $page.data.user?.role === 'admin' && $fleetIntegrations && !$fleetIntegrations.native && (!$fleetIntegrations.komodo || !$fleetIntegrations.rcon)} {:else if $page.data.user?.role === 'admin' && $fleetIntegrations && !integrations.native && (!integrations.komodo || !integrations.rcon)}
<div class="setup-note"><span><strong>Finish system setup</strong> · {!$fleetIntegrations.komodo ? 'Komodo' : ''}{!$fleetIntegrations.komodo && !$fleetIntegrations.rcon ? ' and ' : ''}{!$fleetIntegrations.rcon ? 'RCON' : ''} not configured.</span><a href="/settings">Review setup →</a></div> <div class="setup-note"><span><strong>Finish system setup</strong> · {!integrations.komodo ? 'Komodo' : ''}{!integrations.komodo && !integrations.rcon ? ' and ' : ''}{!integrations.rcon ? 'RCON' : ''} not configured.</span><a href="/settings">Review setup →</a></div>
{/if} {/if}
{#if problems.length || attentionOperations.length || starting || provisioning} {#if problems.length || attentionOperations.length || starting || provisioning}
+11 -3
View File
@@ -1,9 +1,17 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { pendingHostOperations } from '$lib/server/hosts/index.js'; import { pendingHostOperations, waitForHostWork } from '$lib/server/hosts/index.js';
import { authenticateAgent } from '$lib/server/hosts/http.js'; import { authenticateAgent } from '$lib/server/hosts/http.js';
import { reconcileHostPreparation } from '$lib/server/hosts/preparation.js'; import { reconcileHostPreparation } from '$lib/server/hosts/preparation.js';
export function GET({ request }: { request: Request }) { export async function GET({ request }: { request: Request }) {
const host = authenticateAgent(request); const host = authenticateAgent(request);
reconcileHostPreparation(host.id); reconcileHostPreparation(host.id);
return json({ operations: pendingHostOperations(host.id), serverTime: new Date().toISOString() }, { headers: { 'Cache-Control': 'no-store' } }); const params = new URL(request.url).searchParams;
const lane = params.get('lane') === 'read' ? 'read' : params.get('lane') === 'write' ? 'write' : 'all';
let operations = pendingHostOperations(host.id, lane);
if (!operations.length && params.get('wait') === '20') {
await waitForHostWork(host.id, request.signal);
authenticateAgent(request); // Revocation still applies after waiting.
operations = pendingHostOperations(host.id, lane);
}
return json({ operations, serverTime: new Date().toISOString() }, { headers: { 'Cache-Control': 'no-store' } });
} }
+19 -6
View File
@@ -1,9 +1,22 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { serverTitle } from '$lib/ui/server-labels.js';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
export async function GET({ url }: { url: URL }) { export async function GET({ url }: { url: URL }) {
const activeOnly = url.searchParams.get('active') === 'true'; const activeOnly = url.searchParams.get('active') === 'true';
const historyOnly = url.searchParams.get('view') === 'history';
const server = url.searchParams.get('server'); const server = url.searchParams.get('server');
const query = (url.searchParams.get('q') ?? '').slice(0, 100).toLowerCase();
const limit = Math.min(500, Math.max(1, Math.floor(Number(url.searchParams.get('limit')) || 25)));
const offset = Math.min(1_000_000, Math.max(0, Math.floor(Number(url.searchParams.get('offset')) || 0)));
const active = `EXISTS (SELECT 1 FROM deployment_servers a WHERE a.deployment_id=d.id AND a.server_deleted_at IS NULL AND a.restart_state IN ('pending','failed'))`;
const names = query ? (db().prepare('SELECT DISTINCT server FROM deployment_servers').all() as { server: string }[])
.map(row => row.server).filter(name => `${name} ${serverTitle(name)}`.toLowerCase().includes(query)) : [];
const where = `(? IS NULL OR EXISTS (SELECT 1 FROM deployment_servers target WHERE target.deployment_id=d.id AND target.server=?))
${activeOnly ? `AND ${active}` : historyOnly ? `AND NOT ${active}` : ''}
${query ? `AND EXISTS (SELECT 1 FROM deployment_servers target WHERE target.deployment_id=d.id AND target.server IN (${names.map(() => '?').join(',') || 'NULL'}))` : ''}`;
const parameters = [server, server, ...names];
const { total } = db().prepare(`SELECT COUNT(*) AS total FROM deployments d WHERE ${where}`).get(...parameters) as { total: number };
const deployments = db() const deployments = db()
.prepare( .prepare(
@@ -15,11 +28,11 @@ export async function GET({ url }: { url: URL }) {
LEFT JOIN scripts s ON s.id = d.script_id LEFT JOIN scripts s ON s.id = d.script_id
LEFT JOIN script_versions sv ON sv.id = d.script_version_id LEFT JOIN script_versions sv ON sv.id = d.script_version_id
LEFT JOIN script_versions pv ON pv.id = d.prior_version_id LEFT JOIN script_versions pv ON pv.id = d.prior_version_id
WHERE (? IS NULL OR EXISTS (SELECT 1 FROM deployment_servers target WHERE target.deployment_id = d.id AND target.server = ?)) WHERE ${where}
ORDER BY d.started_at DESC ORDER BY d.started_at DESC, d.rowid DESC
LIMIT 500` LIMIT ? OFFSET ?`
) )
.all(server, server) as Array<{ .all(...parameters, limit, offset) as Array<{
id: string; id: string;
action: string; action: string;
started_at: string; started_at: string;
@@ -36,7 +49,7 @@ export async function GET({ url }: { url: URL }) {
}>; }>;
const ids = deployments.map(deployment => deployment.id); const ids = deployments.map(deployment => deployment.id);
if (!ids.length) return json({ deployments: [] }); if (!ids.length) return json({ deployments: [], total, limit, offset });
const placeholders = ids.map(() => '?').join(','); const placeholders = ids.map(() => '?').join(',');
const serversByDep = db() const serversByDep = db()
.prepare( .prepare(
@@ -95,5 +108,5 @@ export async function GET({ url }: { url: URL }) {
})) }))
.filter((d) => (activeOnly ? d.servers.some((s) => !s.server_deleted_at && (s.restart_state === 'pending' || s.restart_state === 'failed')) : true)); .filter((d) => (activeOnly ? d.servers.some((s) => !s.server_deleted_at && (s.restart_state === 'pending' || s.restart_state === 'failed')) : true));
return json({ deployments: rows }); return json({ deployments: rows, total, limit, offset });
} }
+1 -1
View File
@@ -21,7 +21,7 @@ export async function GET() {
event.type === 'server-state' || event.type === 'server-state' ||
event.type === 'deployment-opened' || event.type === 'deployment-opened' ||
event.type === 'server-changed' || event.type === 'server-changed' ||
event.type === 'sweep-tick' event.type === 'sweep-tick' || event.type === 'fleet-invalidated'
) { ) {
send(controller, event); send(controller, event);
} }
+6
View File
@@ -1,3 +1,6 @@
import { observationReader } from '$lib/server/fleet/status.js';
import { listServers } from '$lib/server/fleet/servers.js';
import { statusEntry } from '$lib/server/pipeline/worker.js';
import { subscribe } from '$lib/server/pipeline/bus.js'; import { subscribe } from '$lib/server/pipeline/bus.js';
import { listTelemetry } from '$lib/server/fleet/telemetry.js'; import { listTelemetry } from '$lib/server/fleet/telemetry.js';
@@ -15,7 +18,10 @@ export async function GET() {
controller.enqueue(encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify({ ...event, serverNow: Date.now() })}\n\n`)); controller.enqueue(encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify({ ...event, serverNow: Date.now() })}\n\n`));
} }
}); });
const observation = observationReader();
const observe = (server: ReturnType<typeof listServers>[number]) => statusEntry(server, observation);
controller.enqueue(encoder.encode(': hello\n\n')); controller.enqueue(encoder.encode(': hello\n\n'));
controller.enqueue(encoder.encode(`event: server-status\ndata: ${JSON.stringify({ entries: listServers().map(server => observe(server)), snapshot: true, serverNow: Date.now() })}\n\n`));
controller.enqueue(encoder.encode(`event: server-telemetry\ndata: ${JSON.stringify({ entries: listTelemetry(), snapshot: true, serverNow: Date.now() })}\n\n`)); controller.enqueue(encoder.encode(`event: server-telemetry\ndata: ${JSON.stringify({ entries: listTelemetry(), snapshot: true, serverNow: Date.now() })}\n\n`));
heartbeat = setInterval(() => controller.enqueue(encoder.encode(': ping\n\n')), HEARTBEAT_MS); heartbeat = setInterval(() => controller.enqueue(encoder.encode(': ping\n\n')), HEARTBEAT_MS);
}, },
+1 -1
View File
@@ -8,7 +8,7 @@ export function GET({ request, params }: Event) {
requireManagementOrigin(request); const host = getHost(params.id); if (!host) error(404, 'Host not found.'); requireManagementOrigin(request); const host = getHost(params.id); if (!host) error(404, 'Host not found.');
// Payloads and results can contain RCON keys or staged credentials. History // Payloads and results can contain RCON keys or staged credentials. History
// exposes metadata only; agent APIs remain the only command delivery path. // exposes metadata only; agent APIs remain the only command delivery path.
return json({ host: presentHostPreparation(host), defaultRuntimeImage: config.komodo.defaultImage, deletion: hostDeletionStatus(host.id), operations: listHostOperations(host.id).map(({ payload: _payload, result: _result, ...operation }) => operation) }); return json({ host: presentHostPreparation(host), defaultRuntimeImage: config.komodo.defaultImage, deletion: hostDeletionStatus(host.id), operations: listHostOperations(host.id, 100, true).map(({ payload: _payload, result: _result, ...operation }) => operation) });
} }
export async function PATCH({ request, params }: Event) { export async function PATCH({ request, params }: Event) {
requireManagementOrigin(request); requireManagementOrigin(request);
+4 -3
View File
@@ -1,7 +1,8 @@
import { leaderboardReport } from '$lib/server/reports/index.js';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { getLeaderboard, isLeaderboardMetric, MAX_LEADERBOARD_OFFSET } from '$lib/server/fleet/leaderboard.js'; import { isLeaderboardMetric, MAX_LEADERBOARD_OFFSET } from '$lib/server/fleet/leaderboard.js';
export function GET({ url }: { url: URL }) { export async function GET({ url }: { url: URL }) {
const params = url.searchParams; const params = url.searchParams;
const game = params.get('game'); const game = params.get('game');
const map = params.get('map'); const map = params.get('map');
@@ -20,7 +21,7 @@ export function GET({ url }: { url: URL }) {
|| !Number.isFinite(offset) || offset < 0 || offset > MAX_LEADERBOARD_OFFSET) { || !Number.isFinite(offset) || offset < 0 || offset > MAX_LEADERBOARD_OFFSET) {
return json({ ok: false, kind: 'bad-request', error: 'Use game t5 or t6, a supported metric, map key up to 1024 characters, q up to 100 characters, limit 1–100, and offset 0–1000000.' }, { status: 400 }); return json({ ok: false, kind: 'bad-request', error: 'Use game t5 or t6, a supported metric, map key up to 1024 characters, q up to 100 characters, limit 1–100, and offset 0–1000000.' }, { status: 400 });
} }
return json(getLeaderboard({ game: game ?? undefined, map: map || undefined, return json(await leaderboardReport({ game: game ?? undefined, map: map || undefined,
metric: metric !== null && isLeaderboardMetric(metric) ? metric : undefined, metric: metric !== null && isLeaderboardMetric(metric) ? metric : undefined,
q: q ?? undefined, limit, offset })); q: q ?? undefined, limit, offset }));
} }
+3 -3
View File
@@ -1,9 +1,9 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { parseMetricsQuery } from '$lib/metrics.js'; import { parseMetricsQuery } from '$lib/metrics.js';
import { readMetrics } from '$lib/server/fleet/metrics.js'; import { metricsReport } from '$lib/server/reports/index.js';
export function GET({ url }: { url: URL }) { export async function GET({ url }: { url: URL }) {
return json(readMetrics(parseMetricsQuery(url.searchParams)), { return json(await metricsReport(parseMetricsQuery(url.searchParams)), {
headers: { 'cache-control': 'no-store' } headers: { 'cache-control': 'no-store' }
}); });
} }
+2 -40
View File
@@ -1,48 +1,10 @@
import { db } from '$lib/server/db/index.js'; import { fleetSnapshot } from '$lib/server/fleet/snapshot.js';
import { kick } from '$lib/server/pipeline/worker.js'; import { kick } from '$lib/server/pipeline/worker.js';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { listServers, heldByBeta } from '$lib/server/fleet/servers.js';
import { serverObservation } from '$lib/server/fleet/status.js';
import { queueCreateServer } from '$lib/server/provisioning/index.js'; import { queueCreateServer } from '$lib/server/provisioning/index.js';
import { config, komodoConfigured, rconConfigured } from '$lib/server/config.js';
import type { GameMode, ZombieGame } from '$lib/server/fleet/map-aliases.js'; import type { GameMode, ZombieGame } from '$lib/server/fleet/map-aliases.js';
import { runningVersions } from '$lib/server/integrations/plutonium.js';
import { listHosts } from '$lib/server/hosts/index.js';
import { nativeInstance } from '$lib/server/backends/native.js';
export async function GET() { export function GET() { return json(fleetSnapshot()); }
const rconHost = config.rcon.host;
const hosts = listHosts();
const hostsById = new Map(hosts.map(host => [host.id, host]));
const versions = new Map(runningVersions().map(version => [version.server, version]));
const servers = listServers().map((s) => {
const instance = s.native_instance_id ? nativeInstance(s.name) : null;
return {
...serverObservation(s),
host: s.host_id ? hostsById.get(s.host_id) ?? null : null,
network: instance ? { bindAddress: instance.bind_address, localPort: instance.port, publicAddress: instance.public_address, publicPort: instance.public_port } : null,
plutonium: versions.get(s.name),
// Derived endpoint — same logic as endpointFor() but without asking
// Komodo. If we don't have a port yet, endpoint is null.
endpoint: instance ? { host: `agent:${instance.host_id}`, port: instance.port } : s.game_port ? { host: s.rcon_host ?? rconHost, port: s.game_port } : null,
held: [...heldByBeta(s.name).entries()].map(([target, betaId]) => ({ target, betaId }))
};
});
return json({
servers,
serverNow: Date.now(),
reservedNames: (db().prepare("SELECT j.server FROM server_creation_jobs j JOIN server_deployments d ON d.id = j.deployment_id WHERE d.state IN ('queued', 'running', 'awaiting-health')").all() as { server: string }[]).map(row => row.server),
integrations: {
native: hosts.length > 0 || !komodoConfigured(),
hostsReady: hosts.filter(host => host.readiness === 'ready').length,
hostsTotal: hosts.length,
rcon: rconConfigured(),
komodo: komodoConfigured(),
rconTimeoutMs: config.rcon.timeoutMs,
defaultRconHost: rconHost
}
});
}
export async function POST({ request }: { request: Request }) { export async function POST({ request }: { request: Request }) {
const body = (await request.json().catch(() => ({}))) as { const body = (await request.json().catch(() => ({}))) as {
+1 -1
View File
@@ -176,7 +176,7 @@
<div class="page"> <div class="page">
<SectionHeader title="New beta" subtitle="Choose exact file versions and test servers, then review before starting.">{#snippet actions()}<Button href="/betas" variant="ghost">Back to betas</Button>{/snippet}</SectionHeader> <SectionHeader title="New beta" subtitle="Choose exact file versions and test servers, then review before starting.">{#snippet actions()}<Button href="/betas" variant="ghost">Back to betas</Button>{/snippet}</SectionHeader>
{#if flash}<div class="ui-error" role="alert">{flash}{#if !scripts.length}<Button size="sm" onclick={()=>initialize()}>Retry</Button>{/if}</div>{/if} {#if flash}<div class="ui-error" role="alert">{flash}{#if !scripts.length}<Button size="sm" onclick={()=>initialize()}>Retry</Button>{/if}</div>{/if}
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={refreshFleet}>Retry servers</Button></div>{/if} {#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={() => refreshFleet(true)}>Retry servers</Button></div>{/if}
{#if notice}<p role="status" class="notice">{notice}</p>{/if} {#if notice}<p role="status" class="notice">{notice}</p>{/if}
{#if loading}<EmptyState title="Loading beta setup…"/> {#if loading}<EmptyState title="Loading beta setup…"/>
{:else if availableDraft}<section class="ui-panel draft"><h2>Resume unfinished beta?</h2><p>Saved {$timeFormat.dateTime(availableDraft.savedAt)} · {availableDraft.picks.filter(p=>p.scriptId!==null).length} files · {availableDraft.servers.length} selected servers. Versions and eligibility will be rechecked.</p><div class="actions"><Button variant="primary" onclick={resumeDraft}>Resume draft</Button><Button onclick={discardDraft}>{hasLinkedSetup ? 'Discard draft and use linked files' : 'Discard draft'}</Button></div></section> {:else if availableDraft}<section class="ui-panel draft"><h2>Resume unfinished beta?</h2><p>Saved {$timeFormat.dateTime(availableDraft.savedAt)} · {availableDraft.picks.filter(p=>p.scriptId!==null).length} files · {availableDraft.servers.length} selected servers. Versions and eligibility will be rechecked.</p><div class="actions"><Button variant="primary" onclick={resumeDraft}>Resume draft</Button><Button onclick={discardDraft}>{hasLinkedSetup ? 'Discard draft and use linked files' : 'Discard draft'}</Button></div></section>
+22 -9
View File
@@ -4,6 +4,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { fetchCampaigns } from '$ui/campaigns.js';
import { confirmAction } from '$ui/confirm.js'; import { confirmAction } from '$ui/confirm.js';
import { serverTitle } from '$ui/server-labels.js'; import { serverTitle } from '$ui/server-labels.js';
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte'; import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
@@ -48,6 +49,8 @@
} }
let loadError = $state(''); let loadError = $state('');
let limit = $state(25); let limit = $state(25);
let historyTotal = $state(0);
let loadSequence = 0;
let deployments = $state<Deployment[]>([]); let deployments = $state<Deployment[]>([]);
let loading = $state(true); let loading = $state(true);
let rollingBack = $state<string | null>(null); let rollingBack = $state<string | null>(null);
@@ -67,15 +70,25 @@
void goto(url, { replaceState: true, noScroll: true, keepFocus: true }); void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
} }
async function loadCampaigns(): Promise<void> { async function loadCampaigns(signal?: AbortSignal): Promise<boolean> {
const sequence = ++loadSequence;
try { try {
const res = await fetch('/api/campaigns'); const [history, active] = await Promise.all([
if (!res.ok) throw new Error('Could not refresh campaigns.'); fetchCampaigns<Deployment>({ q: serverFilter, view: 'history' }, limit, signal),
deployments = (await res.json()).deployments; loadError = ''; fetchCampaigns<Deployment>({ q: serverFilter, active: 'true' }, Infinity, signal)
} catch (error) { loadError = (error as Error).message; } ]);
finally { loading = false; } if (sequence !== loadSequence || signal?.aborted) return true;
deployments = [...active.deployments, ...history.deployments]; historyTotal = history.total; loadError = '';
return true;
} catch (error) {
if (sequence === loadSequence && !signal?.aborted) loadError = (error as Error).message;
return false;
} finally { if (sequence === loadSequence && !signal?.aborted) loading = false; }
} }
onMount(() => pollVisible(loadCampaigns, 5000)); $effect(() => {
void limit; void serverFilter;
return pollVisible(loadCampaigns, 15000);
});
function isActive(d: Deployment): boolean { function isActive(d: Deployment): boolean {
return d.servers.some((s) => !s.server_deleted_at && (s.restart_state === 'pending' || s.restart_state === 'failed')); return d.servers.some((s) => !s.server_deleted_at && (s.restart_state === 'pending' || s.restart_state === 'failed'));
@@ -145,7 +158,7 @@
subtitle="Track file campaigns, server activation, and rollbacks." subtitle="Track file campaigns, server activation, and rollbacks."
/> />
{#if loadError}<div class="ui-error" role="alert">{loadError}<Button size="sm" onclick={loadCampaigns}>Retry</Button></div>{/if} {#if loadError}<div class="ui-error" role="alert">{loadError}<Button size="sm" onclick={() => loadCampaigns()}>Retry</Button></div>{/if}
{#if loading} {#if loading}
<div class="loading">Loading…</div> <div class="loading">Loading…</div>
{:else} {:else}
@@ -195,7 +208,7 @@
{/each} {/each}
{/if} {/if}
</section> </section>
<ListFooter total={history.length} bind:limit onchange={() => saveContext()} label="campaigns" /> <ListFooter total={historyTotal} bind:limit onchange={() => saveContext()} label="campaigns" />
{/if} {/if}
</div> </div>
+35 -24
View File
@@ -26,7 +26,9 @@
let phase = $state<Phase>('idle'); let currentPath = $state(''); let pauseRequested = $state(false); let phase = $state<Phase>('idle'); let currentPath = $state(''); let pauseRequested = $state(false);
let acceptedBytes = $state(0); let sendingBytes = $state(0); let completedFiles = $state(0); let acceptedBytes = $state(0); let sendingBytes = $state(0); let completedFiles = $state(0);
let folderInput = $state<HTMLInputElement>(); let folderInput = $state<HTMLInputElement>();
let activeRequest: XMLHttpRequest | null = null; let disposed = false; const activeRequests = new Map<XMLHttpRequest, number>(); let disposed = false; let failedTransfer = false;
const updateSending = () => { sendingBytes = [...activeRequests.values()].reduce((a, b) => a + b, 0); };
const abortUploads = () => { for (const request of activeRequests.keys()) request.abort(); };
const canManage = $derived($page.data.user?.role === 'admin'); const canManage = $derived($page.data.user?.role === 'admin');
const onlineHosts = $derived(hosts.filter(host => host.connected && host.credentialActive && host.mode !== 'disabled')); const onlineHosts = $derived(hosts.filter(host => host.connected && host.credentialActive && host.mode !== 'disabled'));
const totalBytes = $derived(folder?.size ?? uploadStatus?.files.reduce((total, file) => total + file.size, 0) ?? 0); const totalBytes = $derived(folder?.size ?? uploadStatus?.files.reduce((total, file) => total + file.size, 0) ?? 0);
@@ -122,21 +124,21 @@
finally { uploadBusy = false; } finally { uploadBusy = false; }
await tick(); document.getElementById('game-upload')?.scrollIntoView({ behavior: 'smooth' }); await tick(); document.getElementById('game-upload')?.scrollIntoView({ behavior: 'smooth' });
} }
function pauseUpload() { pauseRequested = true; activeRequest?.abort(); } function pauseUpload() { pauseRequested = true; abortUploads(); }
async function sendChunk(path: string, file: File, offset: number, length: number): Promise<number> { async function sendChunk(path: string, file: File, offset: number, length: number): Promise<number> {
const chunk = file.slice(offset, offset + length); const chunk = file.slice(offset, offset + length);
const hash = await crypto.subtle.digest('SHA-256', await chunk.arrayBuffer()); const hash = await crypto.subtle.digest('SHA-256', await chunk.arrayBuffer());
if (pauseRequested || disposed) throw new Error('Upload paused.'); if (pauseRequested || disposed || failedTransfer) throw new Error('Upload paused.');
const sha256 = [...new Uint8Array(hash)].map(value => value.toString(16).padStart(2, '0')).join(''); const sha256 = [...new Uint8Array(hash)].map(value => value.toString(16).padStart(2, '0')).join('');
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = new XMLHttpRequest(); activeRequest = request; const request = new XMLHttpRequest(); activeRequests.set(request, 0);
request.open('PUT', `/api/game-files/${uploadId}/upload/files/${path.split('/').map(encodeURIComponent).join('/')}`); request.open('PUT', `/api/game-files/${uploadId}/upload/files/${path.split('/').map(encodeURIComponent).join('/')}`);
request.setRequestHeader('Content-Type', 'application/octet-stream'); request.setRequestHeader('Content-Type', 'application/octet-stream');
request.setRequestHeader('X-Upload-Offset', String(offset)); request.setRequestHeader('X-Upload-Length', String(length)); request.setRequestHeader('X-Upload-Offset', String(offset)); request.setRequestHeader('X-Upload-Length', String(length));
request.setRequestHeader('X-Content-SHA256', sha256); request.timeout = 300_000; request.setRequestHeader('X-Content-SHA256', sha256); request.timeout = 300_000;
request.upload.onprogress = event => { sendingBytes = Math.min(length, event.loaded); }; request.upload.onprogress = event => { activeRequests.set(request, Math.min(length, event.loaded)); updateSending(); };
request.onload = () => { request.onload = () => {
activeRequest = null; activeRequests.delete(request); updateSending();
try { try {
const body = JSON.parse(request.responseText); const body = JSON.parse(request.responseText);
if (request.status < 200 || request.status >= 300) throw new Error(body.error ?? `Upload failed (${request.status}).`); if (request.status < 200 || request.status >= 300) throw new Error(body.error ?? `Upload failed (${request.status}).`);
@@ -144,16 +146,16 @@
resolve(body.receivedBytes); resolve(body.receivedBytes);
} catch (cause) { reject(cause); } } catch (cause) { reject(cause); }
}; };
request.onerror = () => { activeRequest = null; reject(new Error('The connection was interrupted. Resume to continue from the last saved chunk.')); }; request.onerror = () => { activeRequests.delete(request); updateSending(); reject(new Error('The connection was interrupted. Resume to continue from the last saved chunk.')); };
request.ontimeout = () => { activeRequest = null; reject(new Error('This chunk timed out. Resume to check saved progress and retry.')); }; request.ontimeout = () => { activeRequests.delete(request); updateSending(); reject(new Error('This chunk timed out. Resume to check saved progress and retry.')); };
request.onabort = () => { activeRequest = null; reject(new Error('Upload paused.')); }; request.onabort = () => { activeRequests.delete(request); updateSending(); reject(new Error('Upload paused.')); };
request.send(chunk); request.send(chunk);
}); });
} }
async function startUpload(event: SubmitEvent) { async function startUpload(event: SubmitEvent) {
event.preventDefault(); if (uploadBusy || !folder) return; event.preventDefault(); if (uploadBusy || !folder) return;
if (!globalThis.crypto?.subtle) { uploadError = 'Folder uploads require a secure connection. Open this controller over HTTPS or localhost.'; return; } if (!globalThis.crypto?.subtle) { uploadError = 'Folder uploads require a secure connection. Open this controller over HTTPS or localhost.'; return; }
uploadBusy = true; pauseRequested = false; uploadError = ''; notice = ''; phase = 'preparing'; uploadBusy = true; pauseRequested = false; failedTransfer = false; uploadError = ''; notice = ''; phase = 'preparing';
try { try {
if (!uploadId) { if (!uploadId) {
const result = await responseBody(await fetch('/api/game-files', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ game, files: folder.files.map(({ path, file }) => ({ path, size: file.size, lastModified: file.lastModified })) }) })); const result = await responseBody(await fetch('/api/game-files', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ game, files: folder.files.map(({ path, file }) => ({ path, size: file.size, lastModified: file.lastModified })) }) }));
@@ -165,19 +167,28 @@
const localFiles = new Map(folder.files.map(item => [item.path, item.file])); const localFiles = new Map(folder.files.map(item => [item.path, item.file]));
const chunkSize = Math.min(8_388_608, Math.max(1, status.chunkSize)); const chunkSize = Math.min(8_388_608, Math.max(1, status.chunkSize));
phase = 'uploading'; phase = 'uploading';
for (const remote of status.files) { let nextFile = 0;
if (pauseRequested || disposed) break; let failure: unknown;
if (remote.complete) continue; const transfer = async () => {
currentPath = remote.path; while (!pauseRequested && !disposed && !failure) {
const file = localFiles.get(remote.path)!; const remote = status.files[nextFile++];
do { if (!remote) return;
if (pauseRequested || disposed) break; if (remote.complete) continue;
const length = Math.min(chunkSize, remote.size - remote.receivedBytes); currentPath = remote.path;
remote.receivedBytes = await sendChunk(remote.path, file, remote.receivedBytes, length); const file = localFiles.get(remote.path)!;
acceptedBytes += length; sendingBytes = 0; try {
if (remote.receivedBytes === remote.size) { remote.complete = true; completedFiles++; } do {
} while (!remote.complete); if (pauseRequested || disposed || failure) return;
} const length = Math.min(chunkSize, remote.size - remote.receivedBytes);
remote.receivedBytes = await sendChunk(remote.path, file, remote.receivedBytes, length);
acceptedBytes += length;
if (remote.receivedBytes === remote.size) { remote.complete = true; completedFiles++; }
} while (!remote.complete);
} catch (cause) { failure ??= cause; failedTransfer = true; abortUploads(); }
}
};
await Promise.all(Array.from({ length: Math.min(3, status.files.length) }, transfer));
if (failure) throw failure;
if (pauseRequested || disposed) { phase = 'paused'; return; } if (pauseRequested || disposed) { phase = 'paused'; return; }
phase = 'verifying'; currentPath = ''; phase = 'verifying'; currentPath = '';
const verification = await responseBody(await fetch(`/api/game-files/${uploadId}/upload/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })) as UploadStatus; const verification = await responseBody(await fetch(`/api/game-files/${uploadId}/upload/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })) as UploadStatus;
@@ -192,7 +203,7 @@
}); });
onMount(() => { onMount(() => {
const stop = pollVisible(load, 5000); const stop = pollVisible(load, 5000);
return () => { disposed = true; pauseRequested = true; activeRequest?.abort(); stop(); }; return () => { disposed = true; pauseRequested = true; abortUploads(); stop(); };
}); });
</script> </script>
+4
View File
@@ -0,0 +1,4 @@
import { listHosts } from '$lib/server/hosts/index.js';
import { presentHostPreparation } from '$lib/server/hosts/preparation.js';
export function load() { return { hosts: listHosts().map(presentHostPreparation) }; }
+9 -7
View File
@@ -1,22 +1,24 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount, untrack } from 'svelte';
import { pollVisible } from '$ui/poll.js';
let { data }: { data: import('./$types').PageData } = $props();
import { page } from '$app/stores'; import { page } from '$app/stores';
import type { Host } from '$lib/hosts.js'; import type { Host } from '$lib/hosts.js';
import { hostAgentUpdateLabel, hostDeletionMessage, hostReadinessLabel } from '$lib/hosts.js'; import { hostAgentUpdateLabel, hostDeletionMessage, hostReadinessLabel } from '$lib/hosts.js';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte'; import DeleteHostDialog from '$ui/components/DeleteHostDialog.svelte';
let hosts = $state<Host[]>([]); let hosts = $state<Host[]>(untrack(() => data.hosts));
let loading = $state(true); let busy = $state(false); let error = $state(''); let loading = $state(false); let busy = $state(false); let error = $state('');
let notice = $state(''); let deleteOpen = $state(false); let deletingHost = $state<Host | null>(null); let notice = $state(''); let deleteOpen = $state(false); let deletingHost = $state<Host | null>(null);
let loadSequence = 0; let loadSequence = 0;
let name = $state(''); let portMin = $state(28960); let portMax = $state(28980); let name = $state(''); let portMin = $state(28960); let portMax = $state(28980);
let enrollment = $state<{ command: string; expiresAt: string; hostId: string } | null>(null); let enrollment = $state<{ command: string; expiresAt: string; hostId: string } | null>(null);
const canManage = $derived($page.data.user?.role === 'admin'); const canManage = $derived($page.data.user?.role === 'admin');
async function load() { async function load(signal?: AbortSignal) {
const sequence = ++loadSequence; const sequence = ++loadSequence;
try { const response = await fetch('/api/hosts'); const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not load hosts.'); if (sequence === loadSequence) { hosts = body.hosts; error = ''; } } try { const response = await fetch('/api/hosts', { signal: signal ?? AbortSignal.timeout(15000) }); const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not load hosts.'); if (sequence === loadSequence) { hosts = body.hosts; error = ''; } }
catch (cause) { if (sequence === loadSequence) error = (cause as Error).message; } finally { if (sequence === loadSequence) loading = false; } catch (cause) { if (!signal?.aborted && sequence === loadSequence) error = (cause as Error).message; return false; } finally { if (sequence === loadSequence) loading = false; }
} }
async function deleted(result: { deleted: boolean }) { async function deleted(result: { deleted: boolean }) {
notice = result.deleted ? `${deletingHost?.name ?? 'Host'} deleted.` : `Cleanup requested for ${deletingHost?.name ?? 'host'}. It remains listed until its files and agent are removed.`; notice = result.deleted ? `${deletingHost?.name ?? 'Host'} deleted.` : `Cleanup requested for ${deletingHost?.name ?? 'host'}. It remains listed until its files and agent are removed.`;
@@ -32,7 +34,7 @@
enrollment = body.enrollment; name = ''; await load(); enrollment = body.enrollment; name = ''; await load();
} catch (cause) { error = (cause as Error).message; } finally { busy = false; } } catch (cause) { error = (cause as Error).message; } finally { busy = false; }
} }
onMount(() => { void load(); const timer = setInterval(() => { void load(); }, 15000); return () => clearInterval(timer); }); onMount(() => pollVisible(load, 30000, false));
</script> </script>
<svelte:head><title>Hosts · Electric Cherry</title></svelte:head> <svelte:head><title>Hosts · Electric Cherry</title></svelte:head>
+14 -5
View File
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { untrack } from 'svelte';
import { pollVisible } from '$ui/poll.js';
let requestSequence = 0;
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import type { Host, HostOperation, HostMode } from '$lib/hosts.js'; import type { Host, HostOperation, HostMode } from '$lib/hosts.js';
@@ -17,11 +19,12 @@
const canManage = $derived($page.data.user?.role === 'admin'); const canManage = $derived($page.data.user?.role === 'admin');
const cleaning = $derived(!!host?.decommission); const cleaning = $derived(!!host?.decommission);
function bytes(value: unknown) { return typeof value === 'number' ? `${(value / 1073741824).toFixed(1)} GiB` : 'Unknown'; } function bytes(value: unknown) { return typeof value === 'number' ? `${(value / 1073741824).toFixed(1)} GiB` : 'Unknown'; }
async function load(initialize = false) { async function load(initialize = false, signal?: AbortSignal) {
const target = endpoint; const sequence = ++requestSequence;
try { try {
const response = await fetch(endpoint); const body = await response.json(); if (response.status === 404 && host?.decommission) { await goto('/hosts'); return; } if (!response.ok) throw new Error(body.error ?? body.message ?? 'Could not load host.'); host = body.host; operations = body.operations; const response = await fetch(target, { signal: signal ?? AbortSignal.timeout(15000) }); const body = await response.json(); if (signal?.aborted || target !== endpoint || sequence !== requestSequence) return; if (response.status === 404 && host?.decommission) { await goto('/hosts'); return; } if (!response.ok) throw new Error(body.error ?? body.message ?? 'Could not load host.'); host = body.host; operations = body.operations; error = '';
if (initialize && host) { name = host.name; portMin = host.portMin; portMax = host.portMax; bindAddress = host.bindAddress; publicAddress = host.publicAddress; minimumFreeGB = host.minimumFreeBytes / 1073741824; runtimeImage = body.defaultRuntimeImage ?? runtimeImage; } if (initialize && host) { name = host.name; portMin = host.portMin; portMax = host.portMax; bindAddress = host.bindAddress; publicAddress = host.publicAddress; minimumFreeGB = host.minimumFreeBytes / 1073741824; runtimeImage = body.defaultRuntimeImage ?? runtimeImage; }
} catch (cause) { error = (cause as Error).message; } finally { loading = false; } } catch (cause) { if (!signal?.aborted && sequence === requestSequence) error = (cause as Error).message; return false; } finally { if (sequence === requestSequence) loading = false; }
} }
async function action(path: string, method: string, body?: unknown, message?: string) { async function action(path: string, method: string, body?: unknown, message?: string) {
busy = true; error = ''; notice = ''; busy = true; error = ''; notice = '';
@@ -30,7 +33,13 @@
} }
async function save(event: SubmitEvent) { event.preventDefault(); await action('', 'PATCH', { name, portMin, portMax, bindAddress, publicAddress, minimumFreeBytes: Math.round(minimumFreeGB * 1073741824) }, 'Host settings saved.'); } async function save(event: SubmitEvent) { event.preventDefault(); await action('', 'PATCH', { name, portMin, portMax, bindAddress, publicAddress, minimumFreeBytes: Math.round(minimumFreeGB * 1073741824) }, 'Host settings saved.'); }
async function mode(value: HostMode) { await action('', 'PATCH', { mode: value }, value === 'draining' ? 'Drain enabled. Existing games continue until safe retirement.' : `Host ${value === 'active' ? 'enabled' : 'disabled'}.`); } async function mode(value: HostMode) { await action('', 'PATCH', { mode: value }, value === 'draining' ? 'Drain enabled. Existing games continue until safe retirement.' : `Host ${value === 'active' ? 'enabled' : 'disabled'}.`); }
onMount(() => { void load(true); const timer = setInterval(() => { void load(); }, 10000); return () => clearInterval(timer); }); $effect(() => {
void endpoint;
return untrack(() => {
let initialize = true;
return pollVisible(async signal => { const result = await load(initialize, signal); if (result !== false) initialize = false; return result; }, 15000);
});
});
</script> </script>
<svelte:head><title>{host?.name ?? 'Host'} · Electric Cherry</title></svelte:head> <svelte:head><title>{host?.name ?? 'Host'} · Electric Cherry</title></svelte:head>
+10
View File
@@ -0,0 +1,10 @@
import { GET } from '../api/leaderboard/+server.js';
import { error } from '@sveltejs/kit';
import type { LeaderboardResponse } from '$lib/leaderboard.js';
export async function load({ url }: { url: URL }) {
const response = await GET({ url });
const body = await response.json();
if (!response.ok) error(response.status, body.error);
return { leaderboard: body as LeaderboardResponse };
}
+12 -10
View File
@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
let { data: initial }: { data: import('./$types').PageData } = $props();
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page, navigating } from '$app/stores';
const changingFilters = $derived($navigating?.to?.route.id === '/leaderboard');
import { leaderboardMetrics, type LeaderboardMetric, type PlayerStatMetric, type LeaderboardEntry, type LeaderboardMap, type LeaderboardResponse } from '$lib/leaderboard.js'; import { leaderboardMetrics, type LeaderboardMetric, type PlayerStatMetric, type LeaderboardEntry, type LeaderboardMap, type LeaderboardResponse } from '$lib/leaderboard.js';
import { playerContextHref } from '$lib/leaderboard-navigation.js'; import { playerContextHref } from '$lib/leaderboard-navigation.js';
import { efficiencyMetrics, isEfficiencyMetric, efficiencyRequirement, efficiencySample, formatEfficiencyValue } from '$lib/leaderboard-efficiency.js'; import { efficiencyMetrics, isEfficiencyMetric, efficiencyRequirement, efficiencySample, formatEfficiencyValue } from '$lib/leaderboard-efficiency.js';
@@ -33,9 +35,9 @@
const formatValue = (entry: LeaderboardEntry, key: LeaderboardMetric) => isEfficiencyMetric(key) const formatValue = (entry: LeaderboardEntry, key: LeaderboardMetric) => isEfficiencyMetric(key)
? formatEfficiencyValue(entry[key], key) : number.format(entry[key]); ? formatEfficiencyValue(entry[key], key) : number.format(entry[key]);
let data = $state<LeaderboardResponse | null>(null); let data = $state<LeaderboardResponse | null>(untrack(() => initial.leaderboard));
let maps = $state<LeaderboardMap[]>([]); let maps = $state<LeaderboardMap[]>(untrack(() => initial.leaderboard.maps));
let loading = $state(true); let loading = $state(false);
let error = $state(''); let error = $state('');
let search = $state(''); let search = $state('');
let mounted = $state(false); let mounted = $state(false);
@@ -68,8 +70,8 @@
$effect(() => { search = query; }); $effect(() => { search = query; });
$effect(() => { $effect(() => {
const url = apiUrl; const report = initial.leaderboard;
if (mounted) untrack(() => { void refresh(url); }); untrack(() => { ++requestId; controller?.abort(); data = report; maps = report.maps; loadedUrl = apiUrl; loading = false; error = ''; });
}); });
onMount(() => { onMount(() => {
@@ -91,7 +93,7 @@
controller?.abort(); controller?.abort();
const activeController = new AbortController(); const activeController = new AbortController();
controller = activeController; controller = activeController;
if (url !== loadedUrl) { data = null; checkedAt = null; } if (url !== loadedUrl) checkedAt = null;
loading = true; loading = true;
error = ''; error = '';
const timeout = setTimeout(() => activeController.abort(), 20_000); const timeout = setTimeout(() => activeController.abort(), 20_000);
@@ -191,10 +193,10 @@
</div> </div>
</details> </details>
<section class="rankings" class:compact={!fullTable} aria-labelledby="rankings-title" aria-busy={loading}> <section class="rankings" class:compact={!fullTable} aria-labelledby="rankings-title" aria-busy={loading || changingFilters}>
<div class="rankings-header"> <div class="rankings-header">
<div><h2 id="rankings-title">{metricLabel}</h2><p>{description(metric)}</p>{#if isEfficiencyMetric(metric)}<p>Qualification is measured within the selected game and map. Fleet totals include all recorded players.</p>{/if}</div> <div><h2 id="rankings-title">{metricLabel}</h2><p>{description(metric)}</p>{#if isEfficiencyMetric(metric)}<p>Qualification is measured within the selected game and map. Fleet totals include all recorded players.</p>{/if}</div>
<div class="refresh-status" role="status">{loading ? 'Refreshing…' : checkedAt ? `Checked ${$timeFormat.time(checkedAt)}` : ''}<span>Refreshes every 15 seconds</span></div> <div class="refresh-status" role="status">{changingFilters ? 'Updating filters…' : loading ? 'Refreshing…' : checkedAt ? `Checked ${$timeFormat.time(checkedAt)}` : ''}<span>Refreshes every 15 seconds</span></div>
</div> </div>
{#if data.entries.length} {#if data.entries.length}
<div class="mobile-view"><button class="text-button" aria-pressed={fullTable} onclick={() => fullTable = !fullTable}>{fullTable ? 'Show selected stat only' : 'Show all stats and player IDs'}</button></div> <div class="mobile-view"><button class="text-button" aria-pressed={fullTable} onclick={() => fullTable = !fullTable}>{fullTable ? 'Show selected stat only' : 'Show all stats and player IDs'}</button></div>
@@ -227,7 +229,7 @@
</div> </div>
<div class="pagination"> <div class="pagination">
<span>{number.format(data.offset + 1)}–{number.format(data.offset + data.entries.length)} of {number.format(data.total)} {isEfficiencyMetric(metric) ? 'qualifying ' : ''}{data.total === 1 ? 'player' : 'players'}{query ? ` matching “${query}”` : ''}</span> <span>{number.format(data.offset + 1)}–{number.format(data.offset + data.entries.length)} of {number.format(data.total)} {isEfficiencyMetric(metric) ? 'qualifying ' : ''}{data.total === 1 ? 'player' : 'players'}{query ? ` matching “${query}”` : ''}</span>
<div><Button size="sm" disabled={data.offset === 0 || loading} onclick={() => updateFilters({ offset: String(Math.max(0, offset - pageSize)) })}>Previous</Button><Button size="sm" disabled={data.offset + data.entries.length >= data.total || loading} onclick={() => updateFilters({ offset: String(offset + pageSize) })}>Next</Button></div> <div><Button size="sm" disabled={data.offset === 0 || loading || changingFilters} onclick={() => updateFilters({ offset: String(Math.max(0, offset - pageSize)) })}>Previous</Button><Button size="sm" disabled={data.offset + data.entries.length >= data.total || loading || changingFilters} onclick={() => updateFilters({ offset: String(offset + pageSize) })}>Next</Button></div>
</div> </div>
{:else if isEfficiencyMetric(metric)} {:else if isEfficiencyMetric(metric)}
<EmptyState spacious title="No qualifying players found" description={`${efficiencyRequirement(metric)} in this game/map selection. Try another scope or player search.`}> <EmptyState spacious title="No qualifying players found" description={`${efficiencyRequirement(metric)} in this game/map selection. Try another scope or player search.`}>
@@ -1,9 +1,9 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { getPlayerProfile } from '$lib/server/fleet/player-profile.js'; import { playerReport } from '$lib/server/reports/index.js';
import { getPlayerLeaderboardEntry, isLeaderboardMetric } from '$lib/server/fleet/leaderboard.js'; import { isLeaderboardMetric } from '$lib/server/fleet/leaderboard.js';
import { leaderboardReturnHref } from '$lib/leaderboard-navigation.js'; import { leaderboardReturnHref } from '$lib/leaderboard-navigation.js';
export function load({ params, url }: { params: { guid: string }; url: URL }) { export async function load({ params, url }: { params: { guid: string }; url: URL }) {
const game = url.searchParams.get('game'); const game = url.searchParams.get('game');
const map = url.searchParams.get('map'); const map = url.searchParams.get('map');
const requestedMetric = url.searchParams.get('metric'); const requestedMetric = url.searchParams.get('metric');
@@ -13,8 +13,8 @@ export function load({ params, url }: { params: { guid: string }; url: URL }) {
} }
const metric = requestedMetric !== null && isLeaderboardMetric(requestedMetric) ? requestedMetric : 'highestRound'; const metric = requestedMetric !== null && isLeaderboardMetric(requestedMetric) ? requestedMetric : 'highestRound';
const options = { game: game || undefined, map: map || undefined, metric }; const options = { game: game || undefined, map: map || undefined, metric };
const profile = getPlayerProfile(params.guid, Number(url.searchParams.get('offset') ?? 0), options); const { profile, entry } = await playerReport(params.guid, Number(url.searchParams.get('offset') ?? 0), options);
if (!profile) error(404, 'No recorded stats found for this player'); if (!profile) error(404, 'No recorded stats found for this player');
return { profile, returnTo: leaderboardReturnHref(url.searchParams.get('returnTo')), return { profile, returnTo: leaderboardReturnHref(url.searchParams.get('returnTo')),
scope: { game: game ?? '', map: map ?? '', metric, entry: getPlayerLeaderboardEntry(params.guid, options) } }; scope: { game: game ?? '', map: map ?? '', metric, entry } };
} }
+2 -2
View File
@@ -1,7 +1,7 @@
import { readMetrics } from '$lib/server/fleet/metrics.js'; import { metricsReport } from '$lib/server/reports/index.js';
import { parseMetricsQuery } from '$lib/metrics.js'; import { parseMetricsQuery } from '$lib/metrics.js';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ url }) => { export const load: PageServerLoad = ({ url }) => {
return readMetrics(parseMetricsQuery(url.searchParams)); return metricsReport(parseMetricsQuery(url.searchParams));
}; };
@@ -0,0 +1,4 @@
import { fleetSnapshot } from '$lib/server/fleet/snapshot.js';
import { fleetOperations } from '$lib/server/fleet/overview.js';
export function load() { return { fleetSnapshot: { ...fleetSnapshot(), operations: fleetOperations() } }; }
+32 -16
View File
@@ -1,9 +1,13 @@
<script lang="ts"> <script lang="ts">
let { data }: { data: import('./$types').PageData } = $props();
const snapshot = $derived($fleet.receivedAt ? $fleet : data.fleetSnapshot);
const visibleFleet = $derived($fleet.receivedAt ? $fleetServers : data.fleetSnapshot.servers);
import RuntimeVersion from '$ui/components/RuntimeVersion.svelte'; import RuntimeVersion from '$ui/components/RuntimeVersion.svelte';
import MatchHistory from '$ui/components/MatchHistory.svelte'; import MatchHistory from '$ui/components/MatchHistory.svelte';
import RoundTelemetry from '$ui/components/RoundTelemetry.svelte'; import RoundTelemetry from '$ui/components/RoundTelemetry.svelte';
import TelemetryDiagnostics from '$ui/components/TelemetryDiagnostics.svelte'; import TelemetryDiagnostics from '$ui/components/TelemetryDiagnostics.svelte';
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { fetchCampaigns } from '$ui/campaigns.js';
import { fleet, fleetServers, fleetError, fleetIntegrations, refreshFleet } from '$ui/stores/fleet.js'; import { fleet, fleetServers, fleetError, fleetIntegrations, refreshFleet } from '$ui/stores/fleet.js';
import { freshServer, fleetIdentity } from '$lib/fleet-overview.js'; import { freshServer, fleetIdentity } from '$lib/fleet-overview.js';
import { serverTitle } from '$ui/server-labels.js'; import { serverTitle } from '$ui/server-labels.js';
@@ -105,9 +109,9 @@
} }
let name = $derived($page.params.name ?? ''); let name = $derived($page.params.name ?? '');
let server = $derived(($fleetServers.find(s => s.name === name) as Server | undefined) ?? null); let server = $derived((visibleFleet.find(s => s.name === name) as Server | undefined) ?? null);
const native = $derived(!!server?.native_instance_id); const native = $derived(!!server?.native_instance_id);
let integrations = $derived($fleetIntegrations); let integrations = $derived($fleetIntegrations ?? data.fleetSnapshot.integrations);
let history = $state<DeploymentRow[]>([]); let history = $state<DeploymentRow[]>([]);
let inventory = $state<InventoryEntry[]>([]); let inventory = $state<InventoryEntry[]>([]);
let komodoProbe = $state<KomodoProbeResult | null>(null); let komodoProbe = $state<KomodoProbeResult | null>(null);
@@ -127,27 +131,39 @@
let tabLoading = $state(false); let tabLoading = $state(false);
let tabError = $state(''); let tabError = $state('');
let historyLimit = $state(25); let historyLimit = $state(25);
let historyTotal = $state(0);
let inventoryLimit = $state(25); let inventoryLimit = $state(25);
const operations = $derived($fleet.operations.filter(op => op.server === name)); const operations = $derived(snapshot.operations.filter(op => op.server === name));
async function loadTab(currentName: string, tab: string) { let tabSequence = 0;
async function loadTab(currentName: string, tab: string, signal?: AbortSignal) {
if (tab !== 'history' && tab !== 'inventory') return; if (tab !== 'history' && tab !== 'inventory') return;
const sequence = ++tabSequence;
try { try {
const response = await fetch(tab === 'history' ? '/api/campaigns?server=' + encodeURIComponent(currentName) : '/api/servers/' + encodeURIComponent(currentName) + '/scripts'); if (tab === 'history') {
if (!response.ok) throw new Error('Could not refresh ' + tab + '.'); const body = await fetchCampaigns<DeploymentRow>({ server: currentName }, historyLimit, signal);
const body = await response.json(); if (sequence !== tabSequence || name !== currentName || activeTab !== tab || signal?.aborted) return;
if (name !== currentName || activeTab !== tab) return; history = body.deployments; historyTotal = body.total;
if (tab === 'history') history = body.deployments; else inventory = body.scripts; } else {
const response = await fetch('/api/servers/' + encodeURIComponent(currentName) + '/scripts', { signal });
if (!response.ok) throw new Error('Could not refresh inventory.');
const body = await response.json();
if (sequence !== tabSequence || name !== currentName || activeTab !== tab || signal?.aborted) return;
inventory = body.scripts;
}
tabError = ''; tabError = '';
} catch (error) { if (name === currentName && activeTab === tab) tabError = (error as Error).message; } return true;
finally { if (name === currentName && activeTab === tab) tabLoading = false; } } catch (error) {
if (sequence === tabSequence && name === currentName && activeTab === tab && !signal?.aborted) tabError = (error as Error).message;
return false;
} finally { if (sequence === tabSequence && name === currentName && activeTab === tab && !signal?.aborted) tabLoading = false; }
} }
$effect(() => { $effect(() => {
const currentName = name; const currentName = name;
const tab = activeTab; const tab = activeTab;
tabError = ''; history = []; inventory = []; historyLimit = 25; inventoryLimit = 25; tabError = ''; history = []; inventory = []; historyLimit = 25; inventoryLimit = 25;
tabLoading = tab === 'history' || tab === 'inventory'; tabLoading = tab === 'history' || tab === 'inventory';
return pollVisible(() => loadTab(currentName, tab), 10000); return pollVisible(signal => loadTab(currentName, tab, signal), 10000);
}); });
$effect(() => { name; komodoProbe = null; rconProbe = null; netDiagnosis = null; }); $effect(() => { name; komodoProbe = null; rconProbe = null; netDiagnosis = null; });
const fresh = $derived(server ? freshServer(server, $uiNow) : false); const fresh = $derived(server ? freshServer(server, $uiNow) : false);
@@ -312,7 +328,7 @@
</Card> </Card>
{#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={refreshFleet}>Retry</Button></div>{/if} {#if $fleetError}<div class="ui-error" role="alert">{$fleetError}<Button size="sm" onclick={() => refreshFleet(true)}>Retry</Button></div>{/if}
{#if hasDiagnosticIssue} {#if hasDiagnosticIssue}
<section class="connection-issue" aria-label="Server connection issue"> <section class="connection-issue" aria-label="Server connection issue">
<div><strong>{effectiveHealth === 'stale' ? 'Server report is outdated' : 'Server is not responding'}</strong><p>Player count is unknown. {effectiveSeen ? `Last successful report ${ago(effectiveSeen, $uiNow)}.` : 'No successful report has been received.'} {native && !server.host?.connected ? 'The host agent is offline; the game may still be running.' : !native && integrations && !integrations.rcon ? 'RCON is not configured.' : ''}</p></div> <div><strong>{effectiveHealth === 'stale' ? 'Server report is outdated' : 'Server is not responding'}</strong><p>Player count is unknown. {effectiveSeen ? `Last successful report ${ago(effectiveSeen, $uiNow)}.` : 'No successful report has been received.'} {native && !server.host?.connected ? 'The host agent is offline; the game may still be running.' : !native && integrations && !integrations.rcon ? 'RCON is not configured.' : ''}</p></div>
@@ -588,13 +604,13 @@
</div> </div>
{/if} {/if}
</Card> </Card>
<ListFooter total={history.length} bind:limit={historyLimit} label="campaigns" /> <ListFooter total={historyTotal} bind:limit={historyLimit} onchange={() => loadTab(name, 'history')} label="campaigns" />
{/if} {/if}
</div> </div>
</div> </div>
{:else if $fleetError} {:else if $fleetError}
<div class="ui-error" role="alert">{$fleetError}<Button onclick={refreshFleet}>Retry</Button></div> <div class="ui-error" role="alert">{$fleetError}<Button onclick={() => refreshFleet(true)}>Retry</Button></div>
{:else if $fleet.receivedAt} {:else if $fleet.receivedAt || data.fleetSnapshot}
<EmptyState title="Server not found" description="This server may have been removed from the fleet." /> <EmptyState title="Server not found" description="This server may have been removed from the fleet." />
{:else} {:else}
<div class="loading">Loading…</div> <div class="loading">Loading…</div>
+132 -11
View File
@@ -30,7 +30,7 @@ import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
VERSION = "1.3.0" VERSION = "1.4.0"
LABEL = "io.electric-cherry" LABEL = "io.electric-cherry"
MAX_FILE = 32 * 1024 * 1024 MAX_FILE = 32 * 1024 * 1024
LIBRARY_DOWNLOAD_WORKERS = 4 LIBRARY_DOWNLOAD_WORKERS = 4
@@ -329,10 +329,47 @@ def managed_write(root, value, content, uid=1000, gid=1000):
os.unlink(temp_name, dir_fd=parent) os.unlink(temp_name, dir_fd=parent)
class ReadWriteGate:
"""Let observations run together; global mutations wait for existing readers."""
def __init__(self):
self.condition = threading.Condition()
self.readers = 0
self.writers = 0
@contextlib.contextmanager
def read(self):
with self.condition:
while self.writers:
self.condition.wait()
self.readers += 1
try:
yield
finally:
with self.condition:
self.readers -= 1
self.condition.notify_all()
@contextlib.contextmanager
def write(self):
# The mutation lane is serial, so there is only one writer.
with self.condition:
self.writers += 1
while self.readers:
self.condition.wait()
try:
yield
finally:
with self.condition:
self.writers -= 1
self.condition.notify_all()
class Journal: class Journal:
def __init__(self, path): def __init__(self, path, recover=True):
self.db = sqlite3.connect(path) self.db = sqlite3.connect(path)
self.db.row_factory = sqlite3.Row self.db.row_factory = sqlite3.Row
if not recover:
return
self.db.execute("PRAGMA journal_mode=WAL") self.db.execute("PRAGMA journal_mode=WAL")
self.db.execute("PRAGMA synchronous=FULL") self.db.execute("PRAGMA synchronous=FULL")
self.db.execute("CREATE TABLE IF NOT EXISTS operations (id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, request TEXT NOT NULL, state TEXT NOT NULL, result TEXT, error TEXT, updated REAL NOT NULL)") self.db.execute("CREATE TABLE IF NOT EXISTS operations (id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, request TEXT NOT NULL, state TEXT NOT NULL, result TEXT, error TEXT, updated REAL NOT NULL)")
@@ -672,6 +709,10 @@ class Agent:
self.docker = docker or Docker() self.docker = docker or Docker()
self.stopping = threading.Event() self.stopping = threading.Event()
self.current_operation = None self.current_operation = None
self.instance_locks = {}
self.read_gate = ReadWriteGate()
self.read_claim_lock = threading.Lock()
self.read_claims = set()
self.cancelled_creations = frozenset() self.cancelled_creations = frozenset()
self.library_progress = {} self.library_progress = {}
self.library_transfers = {} self.library_transfers = {}
@@ -732,17 +773,25 @@ class Agent:
return Path(self.config_path) == AGENT_CONFIG and (UPDATE_JOURNAL.exists() or UPDATE_JOURNAL.is_symlink()) return Path(self.config_path) == AGENT_CONFIG and (UPDATE_JOURNAL.exists() or UPDATE_JOURNAL.is_symlink())
@contextlib.contextmanager @contextlib.contextmanager
def activity(self): def activity(self, read_only=False):
path = self.root / "agent" / "activity.lock" path = self.root / "agent" / "activity.lock"
descriptor = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) descriptor = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
mutation_descriptor = None
try: try:
try: try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) # All work excludes the updater. Mutations additionally exclude
# other mutation batches while read lanes may continue.
fcntl.flock(descriptor, fcntl.LOCK_SH | fcntl.LOCK_NB)
if not read_only:
mutation_descriptor = os.open(self.root / "agent" / "mutation.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
fcntl.flock(mutation_descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError: except BlockingIOError:
yield False yield False
return return
yield True yield True
finally: finally:
if mutation_descriptor is not None:
os.close(mutation_descriptor)
os.close(descriptor) os.close(descriptor)
def record_health(self): def record_health(self):
@@ -953,7 +1002,7 @@ class Agent:
if line.startswith("PRETTY_NAME="): if line.startswith("PRETTY_NAME="):
os_name = line.split("=", 1)[1].strip('"') os_name = line.split("=", 1)[1].strip('"')
update = self.update_inventory() update = self.update_inventory()
capabilities = ["instance.cancel-create"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else []) capabilities = ["instance.cancel-create", "operations.read-lane"] + (["host.decommission"] if self.can_uninstall() else []) + (["agent.update"] if update["enabled"] else [])
return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})} return {"hostname": socket.gethostname(), "agentVersion": VERSION, "protocolVersion": 1, "capabilities": capabilities, "agentUpdate": update, "os": os_name, "osVersion": platform.release(), "architecture": platform.machine(), "cpu": platform.processor() or platform.machine(), "cpuCount": os.cpu_count() or 0, "memoryTotal": memory.get("MemTotal", 0), "memoryAvailable": memory.get("MemAvailable", 0), "storageTotal": usage.total, "storageAvailable": usage.free, "dockerVersion": docker_version, "dockerRunning": docker_running, "directoriesWritable": os.access(self.root, os.W_OK), "runtimeImages": sorted(set(images)), "libraries": libraries, "libraryCaches": caches, "currentOperation": self.current_operation, "portRange": self.config.get("portRange", {"min": 28960, "max": 29060})}
def safe_to_disrupt(self, instance_id, generation): def safe_to_disrupt(self, instance_id, generation):
@@ -1860,23 +1909,89 @@ class Agent:
except (AgentError, OSError, ValueError): except (AgentError, OSError, ValueError):
pass pass
@staticmethod
def read_operation(operation):
return operation.get("kind") in ("host.inspect", "instance.inspect", "files.read", "files.list", "files.stat", "logs.read") or (operation.get("kind") == "rcon.execute" and operation.get("payload", {}).get("command") in ("status", "getstatus", "version"))
def instance_lock(self, operation):
key = operation.get("instanceId")
return self.instance_locks.setdefault(key, threading.RLock()) if key else contextlib.nullcontext()
def read_loop(self):
# Each lane owns its SQLite connection and never writes executing_id,
# checkpoints, or current_operation used by the mutation state machine.
journal = Journal(self.root / "agent" / "operations.sqlite3", recover=False)
try:
while not self.stopping.is_set():
try:
if self.update_pending():
self.stopping.wait(3)
continue
response = self.request("GET", "/api/agent/operations?lane=read&wait=20")
for operation in response.get("operations", []):
if self.stopping.is_set() or not self.read_operation(operation):
continue
with self.read_claim_lock:
if operation["id"] in self.read_claims:
continue
self.read_claims.add(operation["id"])
try:
with self.activity(read_only=True) as acquired:
if not acquired or self.update_pending():
continue
with self.read_gate.read(), self.instance_lock(operation):
row, _ = journal.accept(operation)
if row["state"] in ("accepted", "unknown", "running"):
self.report(journal.update(operation["id"], "running"))
try:
result = self.dispatch(operation)
row = journal.update(operation["id"], "completed", result)
except Exception as err:
row = journal.update(operation["id"], "failed", error=str(err) if isinstance(err, AgentError) else "Observation unavailable")
self.report(row)
finally:
with self.read_claim_lock:
self.read_claims.discard(operation["id"])
except (AgentError, OSError, ValueError, KeyError, sqlite3.Error):
self.stopping.wait(3)
finally:
journal.close()
def poll_operations(self): def poll_operations(self):
if self.stopping.is_set() or self.update_pending():
return
# Check both update and mutation locks before delivery, release them
# during the long poll, and recheck immediately before accepting work.
with self.activity() as acquired:
if not acquired:
return
# Never hold the updater lock while waiting for delivery.
response = self.request("GET", "/api/agent/operations?lane=write&wait=20")
with self.activity() as acquired: with self.activity() as acquired:
if not acquired or self.stopping.is_set() or self.update_pending(): if not acquired or self.stopping.is_set() or self.update_pending():
return return
if self.config.get("pendingCredential"): if self.config.get("pendingCredential"):
self.recover_credential() self.recover_credential()
response = self.request("GET", "/api/agent/operations") completed = True
for operation in response.get("operations", []): for operation in response.get("operations", []):
if self.stopping.is_set(): if self.stopping.is_set():
break break
if self.read_operation(operation):
continue
self.current_operation = operation["id"] self.current_operation = operation["id"]
# Global changes wait for observations; instance changes only
# block observations for their own immutable instance identity.
guard = self.read_gate.write() if operation["kind"] in ("host.decommission", "credentials.rotate") else contextlib.nullcontext()
try: try:
accepted, _ = self.journal.accept(operation) with guard, self.instance_lock(operation):
self.report(accepted) accepted, _ = self.journal.accept(operation)
self.report(self.execute(operation, report_running=True)) self.report(accepted)
result = self.execute(operation, report_running=True)
self.report(result)
completed = completed and result["state"] in ("completed", "failed")
finally: finally:
self.current_operation = None self.current_operation = None
return completed
def run(self): def run(self):
# Obtain the configured host port range before accepting any deployment. # Obtain the configured host port range before accepting any deployment.
@@ -1895,13 +2010,19 @@ class Agent:
self.stopping.wait(3) self.stopping.wait(3)
heartbeats = threading.Thread(target=self.heartbeat_loop, daemon=True) heartbeats = threading.Thread(target=self.heartbeat_loop, daemon=True)
heartbeats.start() heartbeats.start()
readers = [threading.Thread(target=self.read_loop, daemon=True) for _ in range(2)]
for reader in readers:
reader.start()
while not self.stopping.is_set(): while not self.stopping.is_set():
completed = False
try: try:
self.poll_operations() completed = self.poll_operations()
except (AgentError, OSError, ValueError, KeyError): except (AgentError, OSError, ValueError, KeyError):
# Logs deliberately omit URLs, tokens, payloads and raw errors. # Logs deliberately omit URLs, tokens, payloads and raw errors.
print("Host controller contact unavailable; existing containers continue running", file=sys.stderr) print("Host controller contact unavailable; existing containers continue running", file=sys.stderr)
self.stopping.wait(3) self.stopping.wait(0.25 if completed else 3)
for reader in readers:
reader.join(timeout=2)
heartbeats.join(timeout=2) heartbeats.join(timeout=2)
+2 -2
View File
@@ -1,8 +1,8 @@
{ {
"version": "1.3.0", "version": "1.4.0",
"protocolVersion": 1, "protocolVersion": 1,
"platform": "debian-amd64", "platform": "debian-amd64",
"sha256": "a698bdbba3716726a5a63fbd18392b53bd4c1e0c4cbaca94eabd66d8ca328dc2", "sha256": "8bb6bf3c433def0540cf0de11304f23820c9afcaf1d90d8cdaa7a2a09a455f2d",
"updaterProtocolVersion": 1, "updaterProtocolVersion": 1,
"updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8" "updaterSha256": "066bb896726600ece9ce72697ce523c9a707cc9a621505931ba0892dec59d3c8"
} }
+2
View File
@@ -47,6 +47,8 @@ export async function newFixture(servers: string[] = ['t5_zombie_theater_1']): P
dbPath, dbPath,
cleanup: async () => { cleanup: async () => {
try { try {
const { closeReports } = await import('../../src/lib/server/reports/index.js');
await closeReports();
const mod = (await import('../../src/lib/server/db/index.js')) as { const mod = (await import('../../src/lib/server/db/index.js')) as {
closeDb: () => void; closeDb: () => void;
}; };
+17 -1
View File
@@ -5,7 +5,7 @@ import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { import {
getUploadFileProgress, hashUploadedFiles, MAX_UPLOAD_CHUNK_BYTES, getUploadFileProgress, hashUploadedFiles, MAX_UPLOAD_CHUNK_BYTES,
receiveUploadChunk, UploadOffsetError, validateUploadPlan, withUploadLock receiveUploadChunk, UploadOffsetError, validateUploadPlan, withUploadLock, withUploadFileLock
} from '../../src/lib/server/game-files/uploads.js'; } from '../../src/lib/server/game-files/uploads.js';
import { manifestChecksum, verifyGameFile } from '../../src/lib/server/game-files/storage.js'; import { manifestChecksum, verifyGameFile } from '../../src/lib/server/game-files/storage.js';
@@ -22,6 +22,22 @@ beforeEach(async () => {
}); });
afterEach(async () => { await rm(directory, { recursive: true, force: true }); }); afterEach(async () => { await rm(directory, { recursive: true, force: true }); });
it('allows independent files together, serializes same-file offsets, and makes publication a barrier', async () => {
let release!: () => void;
const blocked = new Promise<void>(resolve => { release = resolve; });
const order: string[] = [];
const first = withUploadFileLock(root, 'a', async () => { order.push('a1'); await blocked; });
const same = withUploadFileLock(root, 'a', async () => { order.push('a2'); });
const other = withUploadFileLock(root, 'b', async () => { order.push('b'); });
const publish = withUploadLock(root, async () => { order.push('publish'); });
const later = withUploadFileLock(root, 'c', async () => { order.push('c'); });
await other;
expect(order).toEqual(['a1', 'b']);
release();
await Promise.all([first, same, publish, later]);
expect(order).toEqual(['a1', 'b', 'a2', 'publish', 'c']);
});
describe('browser folder upload plans', () => { describe('browser folder upload plans', () => {
it('preserves folder structure and source identity while sorting canonical paths', () => { it('preserves folder structure and source identity while sorting canonical paths', () => {
expect(validateUploadPlan([{ path: 'zone/map.ff', size: 10, lastModified: 100 }, { path: 'main/data.iwd', size: 0 }])).toEqual([ expect(validateUploadPlan([{ path: 'zone/map.ff', size: 10, lastModified: 100 }, { path: 'main/data.iwd', size: 0 }])).toEqual([
+6 -6
View File
@@ -89,20 +89,20 @@ describe('automatic host preparation', () => {
}); });
expect(operations(host.id)).toEqual([]); expect(operations(host.id)).toEqual([]);
const firstPoll = await pollOperations({ request: request('operations') }).json(); const firstPoll = await (await pollOperations({ request: request('operations') })).json();
expect(firstPoll.operations).toHaveLength(1); expect(firstPoll.operations).toHaveLength(1);
const runtimeOperation = firstPoll.operations[0]; const runtimeOperation = firstPoll.operations[0];
expect(runtimeOperation).toMatchObject({ kind: 'runtime.pull', payload: { image: runtime } }); expect(runtimeOperation).toMatchObject({ kind: 'runtime.pull', payload: { image: runtime } });
expect((await pollOperations({ request: request('operations') }).json()).operations).toEqual([runtimeOperation]); expect((await (await pollOperations({ request: request('operations') })).json()).operations).toEqual([runtimeOperation]);
const version = await publish(); const version = await publish();
expect((await pollOperations({ request: request('operations') }).json()).operations).toEqual([runtimeOperation]); expect((await (await pollOperations({ request: request('operations') })).json()).operations).toEqual([runtimeOperation]);
vi.setSystemTime(Date.now() + 1); vi.setSystemTime(Date.now() + 1);
const completedRuntime = await reportOperation({ const completedRuntime = await reportOperation({
request: request(`operations/${runtimeOperation.id}`, { state: 'completed' }), params: { id: runtimeOperation.id } request: request(`operations/${runtimeOperation.id}`, { state: 'completed' }), params: { id: runtimeOperation.id }
}); });
expect(completedRuntime.status).toBe(200); expect(completedRuntime.status).toBe(200);
expect((await pollOperations({ request: request('operations') }).json()).operations).toEqual([]); expect((await (await pollOperations({ request: request('operations') })).json()).operations).toEqual([]);
expect(operations(host.id)).toHaveLength(1); expect(operations(host.id)).toHaveLength(1);
vi.setSystemTime(Date.now() + 1); vi.setSystemTime(Date.now() + 1);
@@ -113,7 +113,7 @@ describe('automatic host preparation', () => {
const download = operations(host.id)[0]; const download = operations(host.id)[0];
expect(download).toMatchObject({ kind: 'library.download', payload: { libraryVersionId: version.id } }); expect(download).toMatchObject({ kind: 'library.download', payload: { libraryVersionId: version.id } });
expect((await reportHeartbeat({ request: request('heartbeat', { inventory: preparedInventory }) })).status).toBe(200); expect((await reportHeartbeat({ request: request('heartbeat', { inventory: preparedInventory }) })).status).toBe(200);
expect((await pollOperations({ request: request('operations') }).json()).operations).toEqual([download]); expect((await (await pollOperations({ request: request('operations') })).json()).operations).toEqual([download]);
expect(operations(host.id)).toHaveLength(2); expect(operations(host.id)).toHaveLength(2);
vi.setSystemTime(Date.now() + 1); vi.setSystemTime(Date.now() + 1);
@@ -129,7 +129,7 @@ describe('automatic host preparation', () => {
expect(finalHeartbeat.status).toBe(200); expect(finalHeartbeat.status).toBe(200);
expect(libraries.requireReadyLibrary(host.id, version.id).id).toBe(version.id); expect(libraries.requireReadyLibrary(host.id, version.id).id).toBe(version.id);
expect(preparation.hostPreparationStatus(hosts.getHost(host.id)!).state).toBe('ready'); expect(preparation.hostPreparationStatus(hosts.getHost(host.id)!).state).toBe('ready');
expect((await pollOperations({ request: request('operations') }).json()).operations).toEqual([]); expect((await (await pollOperations({ request: request('operations') })).json()).operations).toEqual([]);
expect(operations(host.id)).toHaveLength(2); expect(operations(host.id)).toHaveLength(2);
}); });
@@ -123,7 +123,7 @@ it('qualifies before competition ranking, then searches and paginates without ch
expect(page).toMatchObject({ total: 3, entries: [{ guid: 'Blake', rank: 1 }], summary: full.summary }); expect(page).toMatchObject({ total: 3, entries: [{ guid: 'Blake', rank: 1 }], summary: full.summary });
expect(getLeaderboard({ metric: 'averageKills', offset: 3 })).toMatchObject({ total: 3, entries: [], summary: full.summary }); expect(getLeaderboard({ metric: 'averageKills', offset: 3 })).toMatchObject({ total: 3, entries: [], summary: full.summary });
const { GET } = await import('../../src/routes/api/leaderboard/+server.js'); const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
const response = GET({ url: new URL('http://localhost/api/leaderboard?metric=averageKills&game=t5&q=current&limit=1') }); const response = await GET({ url: new URL('http://localhost/api/leaderboard?metric=averageKills&game=t5&q=current&limit=1') });
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ metric: 'averageKills', total: 1, expect(await response.json()).toMatchObject({ metric: 'averageKills', total: 1,
entries: [{ guid: 'Casey', rank: 3, averageKills: 10, gamesPlayed: 5 }], summary: full.summary }); entries: [{ guid: 'Casey', rank: 3, averageKills: 10, gamesPlayed: 5 }], summary: full.summary });
@@ -135,7 +135,7 @@ it('accepts each efficiency metric through the API when the qualified group is e
const { efficiencyMetrics } = await import('../../src/lib/leaderboard-efficiency.js'); const { efficiencyMetrics } = await import('../../src/lib/leaderboard-efficiency.js');
const { GET } = await import('../../src/routes/api/leaderboard/+server.js'); const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
for (const { key } of efficiencyMetrics) { for (const { key } of efficiencyMetrics) {
const response = GET({ url: new URL(`http://localhost/api/leaderboard?metric=${key}`) }); const response = await GET({ url: new URL(`http://localhost/api/leaderboard?metric=${key}`) });
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ metric: key, entries: [], total: 0, expect(await response.json()).toMatchObject({ metric: key, entries: [], total: 0,
summary: { players: 1, matches: 4, kills: 80 } }); summary: { players: 1, matches: 4, kills: 80 } });
+8 -8
View File
@@ -386,15 +386,15 @@ describe('player profiles', () => {
expect(getPlayerProfile('101', 9999)?.offset).toBe(25); expect(getPlayerProfile('101', 9999)?.offset).toBe(25);
for (const offset of [-1, NaN, 0.5]) expect(getPlayerProfile('101', offset)?.offset).toBe(0); for (const offset of [-1, NaN, 0.5]) expect(getPlayerProfile('101', offset)?.offset).toBe(0);
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js'); const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
expect(load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?offset=25') }).profile).toEqual(second); expect((await load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?offset=25') })).profile).toEqual(second);
}); });
it('loads canonical profile URLs, encodes GUIDs and returns 404 for missing players', async () => { it('loads canonical profile URLs, encodes GUIDs and returns 404 for missing players', async () => {
await seedRecords(); await seedRecords();
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js'); const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
const url = new URL('http://localhost/leaderboard/players/101'); const url = new URL('http://localhost/leaderboard/players/101');
expect(load({ params: { guid: '101' }, url }).profile).toMatchObject({ name: 'Ace', stats: { kills: 770, gamesPlayed: 4 } }); expect((await load({ params: { guid: '101' }, url })).profile).toMatchObject({ name: 'Ace', stats: { kills: 770, gamesPlayed: 4 } });
expect(() => load({ params: { guid: 'missing' }, url })).toThrow(expect.objectContaining({ status: 404 })); await expect(load({ params: { guid: 'missing' }, url })).rejects.toThrow(expect.objectContaining({ status: 404 }));
const { playerProfileHref } = await import('../../src/lib/leaderboard.js'); const { playerProfileHref } = await import('../../src/lib/leaderboard.js');
expect(playerProfileHref('001/2?#')).toBe('/leaderboard/players/001%2F2%3F%23'); expect(playerProfileHref('001/2?#')).toBe('/leaderboard/players/001%2F2%3F%23');
}); });
@@ -417,7 +417,7 @@ describe('leaderboard API', () => {
const handle = await seedRecords(); const handle = await seedRecords();
handle.prepare("UPDATE leaderboard_players SET name = 'JoshWright' WHERE guid = '101'").run(); handle.prepare("UPDATE leaderboard_players SET name = 'JoshWright' WHERE guid = '101'").run();
const { GET } = await import('../../src/routes/api/leaderboard/+server.js'); const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
const response = GET({ url: new URL('http://localhost/api/leaderboard?q=JoshWright&metric=kills') }); const response = await GET({ url: new URL('http://localhost/api/leaderboard?q=JoshWright&metric=kills') });
expect(response.status).toBe(200); expect(response.status).toBe(200);
const result = await response.json() as LeaderboardResponse; const result = await response.json() as LeaderboardResponse;
expect(result).toMatchObject({ total: 1, summary: { players: 4, matches: 5, kills: 1140 } }); expect(result).toMatchObject({ total: 1, summary: { players: 4, matches: 5, kills: 1140 } });
@@ -429,7 +429,7 @@ describe('leaderboard API', () => {
it('returns the requested ranking and pagination', async () => { it('returns the requested ranking and pagination', async () => {
await seedRecords(); await seedRecords();
const { GET } = await import('../../src/routes/api/leaderboard/+server.js'); const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
const response = GET({ url: new URL('http://localhost/api/leaderboard?game=t5&map=t5%3Atheater&metric=kills&limit=1&offset=1') }); const response = await GET({ url: new URL('http://localhost/api/leaderboard?game=t5&map=t5%3Atheater&metric=kills&limit=1&offset=1') });
expect(response.status).toBe(200); expect(response.status).toBe(200);
const result = await response.json() as LeaderboardResponse; const result = await response.json() as LeaderboardResponse;
expect(result).toMatchObject({ metric: 'kills', limit: 1, offset: 1, total: 3 }); expect(result).toMatchObject({ metric: 'kills', limit: 1, offset: 1, total: 3 });
@@ -442,7 +442,7 @@ describe('leaderboard API', () => {
for (const query of ['game=t7', 'game=', 'metric=unknown', 'limit=0', 'limit=101', 'limit=', for (const query of ['game=t7', 'game=', 'metric=unknown', 'limit=0', 'limit=101', 'limit=',
'limit=1.5', 'limit=1e1', 'offset=-1', 'offset=1000001', 'offset=Infinity', 'offset=0.5', 'limit=1.5', 'limit=1e1', 'offset=-1', 'offset=1000001', 'offset=Infinity', 'offset=0.5',
`q=${'a'.repeat(101)}`, `map=${'a'.repeat(1025)}`]) { `q=${'a'.repeat(101)}`, `map=${'a'.repeat(1025)}`]) {
const response = GET({ url: new URL(`http://localhost/api/leaderboard?${query}`) }); const response = await GET({ url: new URL(`http://localhost/api/leaderboard?${query}`) });
expect(response.status, query).toBe(400); expect(response.status, query).toBe(400);
expect(await response.json()).toMatchObject({ ok: false, kind: 'bad-request' }); expect(await response.json()).toMatchObject({ ok: false, kind: 'bad-request' });
} }
@@ -455,9 +455,9 @@ describe('leaderboard API', () => {
handle.prepare('UPDATE leaderboard_matches SET map = ?, map_key = ?, map_name = ? WHERE id = 1') handle.prepare('UPDATE leaderboard_matches SET map = ?, map_key = ?, map_name = ? WHERE id = 1')
.run(map, key, map); .run(map, key, map);
const { GET } = await import('../../src/routes/api/leaderboard/+server.js'); const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
const all = await GET({ url: new URL('http://localhost/api/leaderboard') }).json() as LeaderboardResponse; const all = await (await GET({ url: new URL('http://localhost/api/leaderboard') })).json() as LeaderboardResponse;
const returnedKey = all.maps.find(option => option.map === map)!.key; const returnedKey = all.maps.find(option => option.map === map)!.key;
const response = GET({ url: new URL(`http://localhost/api/leaderboard?map=${encodeURIComponent(returnedKey)}`) }); const response = await GET({ url: new URL(`http://localhost/api/leaderboard?map=${encodeURIComponent(returnedKey)}`) });
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ total: 2, summary: { matches: 1, kills: 200 } }); expect(await response.json()).toMatchObject({ total: 2, summary: { matches: 1, kills: 200 } });
}); });
+3 -3
View File
@@ -150,16 +150,16 @@ describe('historical metrics', () => {
vi.spyOn(Date, 'now').mockReturnValue(at + 30_000); vi.spyOn(Date, 'now').mockReturnValue(at + 30_000);
for (const range of ['1', '7', '30', 'invalid', '365', '-1', '']) { for (const range of ['1', '7', '30', 'invalid', '365', '-1', '']) {
const url = new URL(`http://localhost/metrics?days=${range}`); const url = new URL(`http://localhost/metrics?days=${range}`);
const response = GET({ url }); const response = await GET({ url });
const payload = await response.json(); const payload = await response.json();
expect(response.headers.get('cache-control')).toBe('no-store'); expect(response.headers.get('cache-control')).toBe('no-store');
expect(payload).toEqual(await load({ url } as Parameters<typeof load>[0])); expect(payload).toEqual(await load({ url } as Parameters<typeof load>[0]));
expect(payload.days).toBe(['1', '7', '30'].includes(range) ? Number(range) : 7); expect(payload.days).toBe(['1', '7', '30'].includes(range) ? Number(range) : 7);
} }
expect((await GET({ url: new URL('http://localhost/api/metrics') }).json()).days).toBe(7); expect((await (await GET({ url: new URL('http://localhost/api/metrics') })).json()).days).toBe(7);
for (const search of ['days=1&game=t5&map=Moon&timeZone=Asia%2FKathmandu', 'days=7&map=Moon', 'game=t6&timeZone=Invalid']) { for (const search of ['days=1&game=t5&map=Moon&timeZone=Asia%2FKathmandu', 'days=7&map=Moon', 'game=t6&timeZone=Invalid']) {
const url = new URL(`http://localhost/metrics?${search}`); const url = new URL(`http://localhost/metrics?${search}`);
const payload = await GET({ url }).json(); const payload = await (await GET({ url })).json();
const { days, ...scope } = parseMetricsQuery(url.searchParams); const { days, ...scope } = parseMetricsQuery(url.searchParams);
expect(payload).toEqual(await load({ url } as Parameters<typeof load>[0])); expect(payload).toEqual(await load({ url } as Parameters<typeof load>[0]));
expect(payload).toMatchObject({ days, scope }); expect(payload).toMatchObject({ days, scope });
+18
View File
@@ -15,6 +15,24 @@ async function enrolled() {
} }
describe('native host enrollment and scoped authentication', () => { describe('native host enrollment and scoped authentication', () => {
it('separates observation and mutation delivery and wakes long polls on newly queued work', async () => {
const api = await enrolled();
const inspect = api.queueHostOperation(api.host.id, 'host.inspect', {});
const restart = api.queueHostOperation(api.host.id, 'instance.restart', {});
const status = api.queueHostOperation(api.host.id, 'rcon.execute', { command: 'status' });
const command = api.queueHostOperation(api.host.id, 'rcon.execute', { command: 'map_restart' });
expect(api.pendingHostOperations(api.host.id, 'read').map(op => op.id).sort()).toEqual([inspect.id, status.id].sort());
expect(api.pendingHostOperations(api.host.id, 'write').map(op => op.id).sort()).toEqual([restart.id, command.id].sort());
// Let earlier enqueue notifications finish before subscribing.
await Promise.resolve();
const controller = new AbortController();
const waiting = api.waitForHostWork(api.host.id, controller.signal, 20_000);
api.queueHostOperation(api.host.id, 'host.inspect', {});
await waiting;
const aborted = api.waitForHostWork(api.host.id, controller.signal, 20_000);
controller.abort();
await aborted;
});
it('consumes an enrollment exactly once and stores only credential hashes', async () => { it('consumes an enrollment exactly once and stores only credential hashes', async () => {
const api = await enrolled(); const api = await enrolled();
expect(api.authenticateCredential(api.credentials.credential)?.id).toBe(api.host.id); expect(api.authenticateCredential(api.credentials.credential)?.id).toBe(api.host.id);
+10 -12
View File
@@ -74,15 +74,15 @@ it('keeps lifetime profiles available when selected history is empty or an effic
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
addMatches(db(), '101', 4); addMatches(db(), '101', 4);
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js'); const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
const filtered = load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?game=t6&map=t6%3Atown&offset=25') }); const filtered = await load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?game=t6&map=t6%3Atown&offset=25') });
expect(filtered).toMatchObject({ returnTo: '/leaderboard', expect(filtered).toMatchObject({ returnTo: '/leaderboard',
profile: { stats: { gamesPlayed: 4, kills: 40 }, matches: [], matchesTotal: 0, offset: 0 }, profile: { stats: { gamesPlayed: 4, kills: 40 }, matches: [], matchesTotal: 0, offset: 0 },
scope: { game: 't6', map: 't6:town', metric: 'highestRound', entry: null } }); scope: { game: 't6', map: 't6:town', metric: 'highestRound', entry: null } });
const unqualified = load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?metric=averageKills') }); const unqualified = await load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?metric=averageKills') });
expect(unqualified).toMatchObject({ profile: { matchesTotal: 4, stats: { gamesPlayed: 4 } }, expect(unqualified).toMatchObject({ profile: { matchesTotal: 4, stats: { gamesPlayed: 4 } },
scope: { game: '', map: '', metric: 'averageKills', entry: null } }); scope: { game: '', map: '', metric: 'averageKills', entry: null } });
addMatches(db(), '101', 1); addMatches(db(), '101', 1);
expect(load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?metric=averageKills') })) expect(await load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?metric=averageKills') }))
.toMatchObject({ scope: { entry: { guid: '101', rank: 1, averageKills: 10, gamesPlayed: 5 } } }); .toMatchObject({ scope: { entry: { guid: '101', rank: 1, averageKills: 10, gamesPlayed: 5 } } });
}); });
@@ -93,13 +93,13 @@ it('loads canonical navigation context while keeping source search and paginatio
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js'); const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
const url = new URL('http://localhost/leaderboard/players/101?game=t5&map=t5%3Atheater&metric=kills'); const url = new URL('http://localhost/leaderboard/players/101?game=t5&map=t5%3Atheater&metric=kills');
url.searchParams.set('returnTo', '/leaderboard?game=t5&map=t5%3Atheater&metric=kills&q=101&offset=50&unknown=drop'); url.searchParams.set('returnTo', '/leaderboard?game=t5&map=t5%3Atheater&metric=kills&q=101&offset=50&unknown=drop');
const loaded = load({ params: { guid: '101' }, url }); const loaded = await load({ params: { guid: '101' }, url });
expect(loaded.returnTo).toBe('/leaderboard?game=t5&map=t5%3Atheater&metric=kills&q=101&offset=50'); expect(loaded.returnTo).toBe('/leaderboard?game=t5&map=t5%3Atheater&metric=kills&q=101&offset=50');
expect(loaded.scope).toMatchObject({ game: 't5', map: 't5:theater', metric: 'kills', entry: { guid: '101', rank: 2 } }); expect(loaded.scope).toMatchObject({ game: 't5', map: 't5:theater', metric: 'kills', entry: { guid: '101', rank: 2 } });
expect(loaded.profile.offset).toBe(0); expect(loaded.profile.offset).toBe(0);
url.searchParams.set('returnTo', 'https://external.invalid/leaderboard'); url.searchParams.set('returnTo', 'https://external.invalid/leaderboard');
expect(load({ params: { guid: '101' }, url }).returnTo).toBe('/leaderboard'); expect((await load({ params: { guid: '101' }, url })).returnTo).toBe('/leaderboard');
const defaults = load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101') }); const defaults = await load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101') });
expect(defaults).toMatchObject({ returnTo: '/leaderboard', scope: { game: '', map: '', metric: 'highestRound' } }); expect(defaults).toMatchObject({ returnTo: '/leaderboard', scope: { game: '', map: '', metric: 'highestRound' } });
}); });
@@ -108,13 +108,11 @@ it('rejects invalid filter parameters and retains existing offset normalization
addMatches(db(), '101', 30); addMatches(db(), '101', 30);
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js'); const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
for (const query of ['game=t7', 'game=', 'metric=', 'metric=unknown', `map=${'x'.repeat(1025)}`]) { for (const query of ['game=t7', 'game=', 'metric=', 'metric=unknown', `map=${'x'.repeat(1025)}`]) {
expect(() => load({ params: { guid: '101' }, url: new URL(`http://localhost/leaderboard/players/101?${query}`) })) await expect(load({ params: { guid: '101' }, url: new URL(`http://localhost/leaderboard/players/101?${query}`) })).rejects.toThrow(expect.objectContaining({ status: 400 }));
.toThrow(expect.objectContaining({ status: 400 }));
} }
for (const query of ['offset=garbage', 'offset=-1', 'offset=1.5', 'offset=Infinity']) { for (const query of ['offset=garbage', 'offset=-1', 'offset=1.5', 'offset=Infinity']) {
expect(load({ params: { guid: '101' }, url: new URL(`http://localhost/leaderboard/players/101?${query}`) }).profile.offset).toBe(0); expect((await load({ params: { guid: '101' }, url: new URL(`http://localhost/leaderboard/players/101?${query}`) })).profile.offset).toBe(0);
} }
expect(load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?offset=999') }).profile.offset).toBe(25); expect((await load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?offset=999') })).profile.offset).toBe(25);
expect(() => load({ params: { guid: 'missing' }, url: new URL('http://localhost/leaderboard/players/missing?game=t5') })) await expect(load({ params: { guid: 'missing' }, url: new URL('http://localhost/leaderboard/players/missing?game=t5') })).rejects.toThrow(expect.objectContaining({ status: 404 }));
.toThrow(expect.objectContaining({ status: 404 }));
}); });
+27
View File
@@ -0,0 +1,27 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { parseMetricsQuery } from '../../src/lib/metrics.js';
let fixture: Fixture;
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); });
it('runs reports off the request thread and invalidates cached history after writes', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
const { metricsReport } = await import('../../src/lib/server/reports/index.js');
const now = Date.UTC(2026, 9, 4, 12);
vi.spyOn(Date, 'now').mockReturnValue(now);
const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: 4 };
recordMetrics([sample], now - 30_000);
const query = parseMetricsQuery(new URLSearchParams('days=1'));
const first = metricsReport(query);
const winner = await Promise.race([first.then(() => 'report'), new Promise<string>(resolve => setTimeout(() => resolve('timer'), 0))]);
expect(winner).toBe('timer');
expect(await first).toEqual(readMetrics(query, now));
expect(await metricsReport(query)).toEqual(await first);
db().prepare('UPDATE metric_samples SET players=3').run();
const changed = await metricsReport(query);
expect(changed).toEqual(readMetrics(query, now));
expect(changed.summary.playerHours).toBeCloseTo((await first).summary.playerHours * 3);
});
+6 -1
View File
@@ -18,8 +18,13 @@ describe('server detail data', () => {
for (let n = 0; n < 501; n++) { deployment.run('other-' + n, '2026-09-18'); member.run('other-' + n, 'other'); } for (let n = 0; n < 501; n++) { deployment.run('other-' + n, '2026-09-18'); member.run('other-' + n, 'other'); }
})(); })();
const { GET } = await import('../../src/routes/api/campaigns/+server.js'); const { GET } = await import('../../src/routes/api/campaigns/+server.js');
const all = await (await GET({ url: new URL('http://localhost/api/campaigns') })).json(); const all = await (await GET({ url: new URL('http://localhost/api/campaigns?limit=500') })).json();
expect(all.deployments).toHaveLength(500); expect(all.deployments).toHaveLength(500);
const first = await (await GET({ url: new URL('http://localhost/api/campaigns?limit=25') })).json();
const second = await (await GET({ url: new URL('http://localhost/api/campaigns?limit=25&offset=25') })).json();
expect(first.total).toBe(502);
expect(first.deployments).toHaveLength(25);
expect(new Set([...first.deployments, ...second.deployments].map(row => row.id)).size).toBe(50);
const filtered = await (await GET({ url: new URL('http://localhost/api/campaigns?server=target') })).json(); const filtered = await (await GET({ url: new URL('http://localhost/api/campaigns?server=target') })).json();
expect(filtered.deployments.map((row: { id: string }) => row.id)).toEqual(['older-target']); expect(filtered.deployments.map((row: { id: string }) => row.id)).toEqual(['older-target']);
expect(filtered.deployments[0].servers).toHaveLength(2); expect(filtered.deployments[0].servers).toHaveLength(2);
+1
View File
@@ -76,6 +76,7 @@ it('timestamps initial telemetry and subsequent status and telemetry events with
}; };
try { try {
await reader.read(); // Initial SSE comment. await reader.read(); // Initial SSE comment.
expect(await nextEvent()).toMatchObject({ snapshot: true, serverNow: startedAt, entries: expect.any(Array) });
expect(await nextEvent()).toMatchObject({ snapshot: true, entries: [], serverNow: startedAt }); expect(await nextEvent()).toMatchObject({ snapshot: true, entries: [], serverNow: startedAt });
clock.mockReturnValue(startedAt + 30_000); clock.mockReturnValue(startedAt + 30_000);
emit({ type: 'server-status', entries: [] }); emit({ type: 'server-status', entries: [] });
+33
View File
@@ -0,0 +1,33 @@
import { afterEach, expect, it, vi } from 'vitest';
import { fetchCampaigns } from '../../src/lib/ui/campaigns.js';
afterEach(() => vi.unstubAllGlobals());
it('loads history beyond the API page limit without truncating or fetching unwanted older rows', async () => {
const requests: URL[] = [];
vi.stubGlobal('fetch', vi.fn(async (input: string) => {
const url = new URL(input, 'http://localhost');
requests.push(url);
const start = Number(url.searchParams.get('offset'));
const count = Number(url.searchParams.get('limit'));
return Response.json({ total: 900, deployments: Array.from({ length: count }, (_, i) => ({ id: start + i })) });
}));
const result = await fetchCampaigns<{ id: number }>({ server: 'test-server', view: 'history' }, 525);
expect(result.total).toBe(900);
expect(result.deployments.map(row => row.id)).toEqual(Array.from({ length: 525 }, (_, i) => i));
expect(requests.map(url => Object.fromEntries(url.searchParams))).toEqual([
{ server: 'test-server', view: 'history', offset: '0', limit: '500' },
{ server: 'test-server', view: 'history', offset: '500', limit: '25' }
]);
});
it('loads all active campaigns independently of the history limit', async () => {
vi.stubGlobal('fetch', vi.fn(async (input: string) => {
const url = new URL(input, 'http://localhost');
const start = Number(url.searchParams.get('offset'));
return Response.json({ total: 550, deployments: Array.from({ length: start ? 50 : 500 }, (_, i) => ({ id: start + i })) });
}));
const result = await fetchCampaigns<{ id: number }>({ active: 'true' }, Infinity);
expect(result.deployments).toHaveLength(550);
expect(result.deployments[549].id).toBe(549);
});
+2
View File
@@ -43,6 +43,8 @@ describe('visible page polling', () => {
stop = pollVisible(task, 1000, false); stop = pollVisible(task, 1000, false);
expect(task).not.toHaveBeenCalled(); expect(task).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(2000); await vi.advanceTimersByTimeAsync(2000);
expect(task).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1000);
expect(task).toHaveBeenCalledTimes(2); expect(task).toHaveBeenCalledTimes(2);
}); });
}); });
+28
View File
@@ -67,6 +67,34 @@ async function setup() {
} }
describe('Fleet report clock', () => { describe('Fleet report clock', () => {
it('accepts newer navigation snapshots while keeping stream evidence newer than the snapshot', async () => {
const { fleetServers, hydrateFleet } = await setup();
await vi.advanceTimersByTimeAsync(2000);
emit(epoch + 2000, [status(epoch + 2000, { players: 3 })]);
const integrations = { rcon: true, komodo: false, rconTimeoutMs: 3000, defaultRconHost: '' };
hydrateFleet({ serverNow: epoch + 1000, servers: [server(epoch + 1000), server(epoch, { name: 'two' })], operations: [], integrations });
expect(get(fleetServers)).toHaveLength(2);
expect(get(fleetServers)[0].last_players).toBe(3);
hydrateFleet({ serverNow: epoch, servers: [], operations: [], integrations });
expect(get(fleetServers)).toHaveLength(2);
});
it('merges partial events without dropping other servers or repainting unchanged elapsed ticks', async () => {
snapshot.servers = [server(), server(epoch, { name: 'two' })];
const { fleetServers, statusByServer } = await setup();
emit(epoch, [status(epoch), status(epoch, { server: 'two' })]);
await vi.advanceTimersByTimeAsync(1000);
source.dispatchEvent(new MessageEvent('server-status', { data: JSON.stringify({
delta: true, serverNow: epoch + 1000, entries: [status(epoch + 1000, { players: 2 })]
}) }));
expect(get(statusByServer).size).toBe(2);
expect(get(fleetServers).find(server => server.name === 't5_zombie_theater_1')?.last_players).toBe(2);
const changes = vi.fn();
const unsubscribe = fleetServers.subscribe(changes);
changes.mockClear();
await vi.advanceTimersByTimeAsync(5000);
expect(changes).not.toHaveBeenCalled();
unsubscribe();
});
it('keeps persisted occupancy and match anchors through live reports before the next status poll', async () => { it('keeps persisted occupancy and match anchors through live reports before the next status poll', async () => {
const at = (seconds: number) => new Date(epoch + seconds * 1000).toISOString(); const at = (seconds: number) => new Date(epoch + seconds * 1000).toISOString();
snapshot.servers = [server(epoch, { container_state: 'running', container_checked_at: at(0) })]; snapshot.servers = [server(epoch, { container_state: 'running', container_checked_at: at(0) })];
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
import { resolve } from 'node:path';
// Build the same report implementations for Node workers. Their database is
// read-only; migrations and all application writes stay in the controller.
export default defineConfig({
plugins: [{ name: 'report-database', enforce: 'pre', resolveId(source) {
if (source.endsWith('/db/index.js')) return resolve('src/lib/server/reports/database.ts');
} }],
build: {
ssr: true, outDir: '.svelte-kit/reports', emptyOutDir: true,
rollupOptions: {
input: 'src/lib/server/reports/worker.ts', external: ['better-sqlite3'],
output: { entryFileNames: 'worker.mjs' }
}
}
});