Compare commits

...
2 Commits
Author SHA1 Message Date
SowinskiBraedenandClaude Opus 5 7493459b17 Merge origin/main into overhaul
Both sides added upstream client-identification headers independently.
Keep the overhaul's client (caching now lives in workshop-cache.ts, so
the in-client preview cache and its TTL are gone) and adopt origin's
identity string 'reforger.dzr.tools'. Origin's header test is kept,
retargeted from the removed health() onto search().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iyv7BRFwX5nGqY5uV91Uu
2026-09-05 13:12:58 -07:00
SowinskiBraeden 3adb45886a overhaul 2026-09-05 13:10:36 -07:00
66 changed files with 9226 additions and 3690 deletions

No files matched your search

+17 -4
View File
@@ -23,7 +23,12 @@ OWNER_DISCORD_ID=
DEV_AUTH_BYPASS=false
# --- Reforger Workshop API (backend-only, never called from the browser) ---
# The panel uses the v2 API. Responses are cached in-process with
# stale-while-revalidate, so the Mods page does not wait on the network.
REFORGER_WORKSHOP_API_BASE_URL=https://api.reforgermods.net
# Optional. The free public tier needs no credentials; set a key only if you
# have a paid reforgermods.net plan and want its higher rate limits.
REFORGER_WORKSHOP_API_KEY=
# --- Pterodactyl (Client API, not Application API) ---
# Leave USE_MOCK_PTERODACTYL=true to run everything locally with mock data.
@@ -33,14 +38,22 @@ PTERODACTYL_CLIENT_API_KEY=
# The short server identifier from the Pterodactyl server URL, e.g. "1a2b3c4d".
PTERODACTYL_SERVER_ID=
USE_MOCK_PTERODACTYL=true
# Proxy Pterodactyl's Wings websocket for the live console and real-time
# resource metrics. This is what makes install, update and mod-download output
# visible in the panel; without it you only see the game's own log file, which
# does not exist until the game has already started. Set to false to fall back
# to REST polling.
PTERODACTYL_WEBSOCKET_ENABLED=true
# --- Reforger config import ---
# Path of the server's config.json in the Pterodactyl file manager. Imported
# read-only to populate the Configuration pages, server name, and max players.
# --- Reforger config ---
# Path of the server's config.json in the Pterodactyl file manager. Read live
# on every request and written in place by the Configuration and Mods pages
# (the previous file is always kept as config.json.bak).
REFORGER_CONFIG_PATH=/config.json
REFORGER_CONFIG_SYNC_INTERVAL_SECONDS=300
# --- Reforger log ingestion ---
# Powers player sessions and the killfeed, which are parsed out of the game's
# own log file. The live console does not depend on this.
# Recommended: set REFORGER_LOG_DIRECTORY (e.g. /profile/logs) and the panel
# follows the newest logs_* dated subfolder automatically on every sync.
# REFORGER_ADMIN_LOG_PATH pins one exact file and overrides discovery.
+30 -1
View File
@@ -1,9 +1,27 @@
# Reforger Panel
Reforger Panel is a private web control panel for an Arma Reforger server hosted through Pterodactyl. It provides a focused interface for trusted server staff to monitor the server, manage access, review player activity, inspect configuration, search Workshop mods, and run limited power actions without exposing Pterodactyl credentials to the browser.
Reforger Panel is a private web control panel for an Arma Reforger server hosted through Pterodactyl. It gives trusted server staff a focused interface to watch the server live, manage the mod list, edit `config.json` safely, pick a mission, review player activity, and run power actions without exposing Pterodactyl credentials to the browser.
The panel is intended for one private community server. It is not a replacement for Pterodactyl and does not provide billing, public signup, raw console access, arbitrary file management, or multi-tenant hosting.
## What it does
- **Live console.** The panel proxies Pterodactyl's Wings websocket, so the Console page shows
output from the moment you press Start — container pull, SteamCMD update, mod downloads, then
the game itself. The same feed drives the CPU/memory/network/disk numbers, so they match what
Pterodactyl reports rather than lagging a poll behind.
- **Mod manager.** Backed by the reforgermods.net **v2** API. Add and remove mods, pin a version
from the published version list (or type one in), resolve dependencies, update one mod or all of
them, clear the list, or copy a modlist off any live server in the public server browser. Edits
are staged as a reviewable diff and written to `config.json` once.
- **Configuration.** Typed, range-validated cards for the settings the panel understands, a
searchable view of _every_ key the file actually contains, and a raw JSON editor. Only the fields
you touch are written, writes are serialised and verified by reading the file back, and a write
based on a stale copy is rejected instead of silently reverting someone else's change. Keys that
a Reforger egg re-templates from a startup variable are flagged, with an option to write both.
- **Mission select.** One list of the vanilla scenarios plus one group per installed mod that ships
missions, with a warning when the configured scenario is no longer provided by anything.
## Project Structure
```text
@@ -19,6 +37,7 @@ packages/shared Shared roles, DTOs, and Reforger configuration types
- Docker, for the local Postgres database
- Discord application credentials, only when using real Discord login
- Pterodactyl Client API credentials, only when connecting to a real server
- Outbound access to `api.reforgermods.net` for Workshop metadata (backend only)
## Local Setup
@@ -111,6 +130,11 @@ REFORGER_LOG_FILE_PATTERN=console.log
The panel follows the newest `logs_*` folder during sync. If you need to pin a single file instead, set `REFORGER_ADMIN_LOG_PATH`.
Log ingestion feeds player sessions and the killfeed only. The Console page does not depend on it —
it reads Pterodactyl's websocket directly, which is why it can show the install and mod-download
phases that never reach the game's own log file. That relay needs `PTERODACTYL_WEBSOCKET_ENABLED=true`
(the default) and a client API key with websocket access to the server.
## Common Commands
```bash
@@ -125,6 +149,11 @@ npm run db:seed # Seed initial server data
## Notes
- `config.json` is read live on every request and written in place; the previous contents are always
kept as `config.json.bak`.
- Workshop metadata is cached in the API process with stale-while-revalidate and request pacing, so
the Mods page is fast and the upstream rate limit is never approached. There is no background
polling of reforgermods.net — it is queried on demand only.
- Roles are stored in the panel database.
- New Discord users start as viewers.
- The Discord account matching `OWNER_DISCORD_ID` becomes the owner.
+2
View File
@@ -20,6 +20,7 @@
"express": "^5.1.0",
"pg": "^8.16.0",
"pino": "^9.7.0",
"ws": "^8.21.3",
"zod": "^3.25.0"
},
"devDependencies": {
@@ -27,6 +28,7 @@
"@types/node": "^24.0.0",
"@types/pg": "^8.15.0",
"@types/supertest": "^6.0.0",
"@types/ws": "^8.18.1",
"drizzle-kit": "^0.31.0",
"pino-pretty": "^13.0.0",
"supertest": "^7.1.0",
+13 -4
View File
@@ -11,11 +11,14 @@ import type { Logger } from './lib/logger.js';
import { createAuthRouter } from './modules/auth/auth-routes.js';
import { csrfProtection, sessionResolver } from './modules/auth/auth-middleware.js';
import type { SessionService } from './modules/auth/session-service.js';
import type { ConfigEditorService } from './modules/config/config-editor-service.js';
import type { ConfigSyncService } from './modules/config/config-sync.js';
import type { ServerModsService } from './modules/config/mods-service.js';
import type { PerformanceSettingsService } from './modules/config/performance-service.js';
import type { ServerMetricsService } from './modules/servers/metrics-service.js';
import type { ResourceHistoryService } from './modules/servers/resource-history.js';
import type { MissionCatalog } from './modules/reforger-logs/missions-catalog.js';
import type { MissionsService } from './modules/reforger-logs/missions-catalog.js';
import type { ConsoleHub } from './modules/pterodactyl/console-hub.js';
import type { GameServerProvider } from './modules/pterodactyl/types.js';
import type { LogPathResolver } from './modules/reforger-logs/ingestion/log-path-resolver.js';
import type { IngestionScheduler } from './modules/reforger-logs/ingestion/scheduler.js';
@@ -24,7 +27,7 @@ import type { ServerService } from './modules/servers/server-service.js';
import { createInviteRouter } from './modules/invites/invite-routes.js';
import { createUserRouter } from './modules/users/user-routes.js';
import { createWorkshopRouter } from './modules/workshop/workshop-routes.js';
import type { WorkshopClient } from './modules/workshop/workshop-client.js';
import type { WorkshopCache } from './modules/workshop/workshop-cache.js';
export type AppDeps = {
env: Env;
@@ -33,14 +36,17 @@ export type AppDeps = {
sessions: SessionService;
servers: ServerService;
provider: GameServerProvider;
workshop: WorkshopClient;
metrics: ServerMetricsService;
consoleHub: ConsoleHub | null;
workshop: WorkshopCache;
scheduler: IngestionScheduler | null;
resolveLogPath: LogPathResolver | null;
configSync: ConfigSyncService | null;
configEditor: ConfigEditorService | null;
mods: ServerModsService | null;
performance: PerformanceSettingsService | null;
resourceHistory: ResourceHistoryService | null;
missions: MissionCatalog | null;
missions: MissionsService;
};
export function createApp(deps: AppDeps) {
@@ -108,9 +114,12 @@ export function createApp(deps: AppDeps) {
createServerRouter({
service: deps.servers,
provider: deps.provider,
metrics: deps.metrics,
consoleHub: deps.consoleHub,
scheduler: deps.scheduler,
resolveLogPath: deps.resolveLogPath,
configSync: deps.configSync,
configEditor: deps.configEditor,
mods: deps.mods,
performance: deps.performance,
resourceHistory: deps.resourceHistory,
+10 -1
View File
@@ -23,14 +23,23 @@ const envSchema = z
DEV_AUTH_BYPASS: booleanString,
REFORGER_WORKSHOP_API_BASE_URL: z.string().url().default('https://api.reforgermods.net'),
/** Optional paid-tier key; the free public tier needs no credentials. */
REFORGER_WORKSHOP_API_KEY: z.string().default(''),
PTERODACTYL_BASE_URL: z.string().default(''),
PTERODACTYL_CLIENT_API_KEY: z.string().default(''),
PTERODACTYL_SERVER_ID: z.string().default(''),
USE_MOCK_PTERODACTYL: booleanString,
/**
* Proxy Pterodactyl's Wings websocket for the live console and real-time
* resource metrics. Turn off to fall back to REST polling only.
*/
PTERODACTYL_WEBSOCKET_ENABLED: z
.enum(['true', 'false'])
.default('true')
.transform((v) => v === 'true'),
REFORGER_CONFIG_PATH: z.string().default('/config.json'),
REFORGER_CONFIG_SYNC_INTERVAL_SECONDS: z.coerce.number().int().min(60).max(86400).default(300),
REFORGER_ADMIN_LOG_PATH: z.string().default(''),
REFORGER_LOG_DIRECTORY: z.string().default(''),
+83 -32
View File
@@ -3,14 +3,19 @@ import { createDb } from './db/client.js';
import { isPterodactylConfigured, loadEnv } from './env.js';
import { createLogger } from './lib/logger.js';
import { SessionService } from './modules/auth/session-service.js';
import { ConfigEditorService } from './modules/config/config-editor-service.js';
import { ConfigFileGateway } from './modules/config/config-file-gateway.js';
import { ConfigSyncService } from './modules/config/config-sync.js';
import { ServerModsService } from './modules/config/mods-service.js';
import { PerformanceSettingsService } from './modules/config/performance-service.js';
import { ServerMetricsService } from './modules/servers/metrics-service.js';
import { ResourceHistoryService } from './modules/servers/resource-history.js';
import { MissionCatalog } from './modules/reforger-logs/missions-catalog.js';
import { MissionCatalog, MissionsService } from './modules/reforger-logs/missions-catalog.js';
import type { ConsoleHub } from './modules/pterodactyl/console-hub.js';
import { MockConsoleHub } from './modules/pterodactyl/mock-console-hub.js';
import { MockGameServerProvider } from './modules/pterodactyl/mock-provider.js';
import { PterodactylProvider } from './modules/pterodactyl/pterodactyl-provider.js';
import { WingsConsoleHub } from './modules/pterodactyl/wings-socket.js';
import type { GameServerProvider } from './modules/pterodactyl/types.js';
import { DrizzleIngestionStore } from './modules/reforger-logs/ingestion/drizzle-store.js';
import { LogIngestionService } from './modules/reforger-logs/ingestion/ingestion-service.js';
@@ -18,6 +23,7 @@ import { createLogPathResolver } from './modules/reforger-logs/ingestion/log-pat
import { PterodactylLogSource } from './modules/reforger-logs/ingestion/pterodactyl-log-source.js';
import { IngestionScheduler } from './modules/reforger-logs/ingestion/scheduler.js';
import { ServerService } from './modules/servers/server-service.js';
import { WorkshopCache } from './modules/workshop/workshop-cache.js';
import { WorkshopClient } from './modules/workshop/workshop-client.js';
const logger = createLogger();
@@ -26,34 +32,72 @@ const env = loadEnv();
const { db, pool } = createDb(env.DATABASE_URL);
const mockLogPath = env.REFORGER_ADMIN_LOG_PATH || '/profile/logs/console.log';
const provider: GameServerProvider = env.USE_MOCK_PTERODACTYL
const mockProvider = env.USE_MOCK_PTERODACTYL
? new MockGameServerProvider({ logPath: mockLogPath })
: new PterodactylProvider({
baseUrl: env.PTERODACTYL_BASE_URL,
apiKey: env.PTERODACTYL_CLIENT_API_KEY,
});
: null;
const provider: GameServerProvider =
mockProvider ??
new PterodactylProvider({
baseUrl: env.PTERODACTYL_BASE_URL,
apiKey: env.PTERODACTYL_CLIENT_API_KEY,
});
const sessions = new SessionService(db, env.OWNER_DISCORD_ID);
const servers = new ServerService(db);
const workshop = new WorkshopClient({ baseUrl: env.REFORGER_WORKSHOP_API_BASE_URL });
const configSync = isPterodactylConfigured(env)
? new ConfigSyncService(provider, servers, logger, env.REFORGER_CONFIG_PATH)
: null;
const gateway = new ConfigFileGateway(provider, env.REFORGER_CONFIG_PATH);
const mods = configSync ? new ServerModsService(gateway, configSync, logger) : null;
const performance = configSync ? new PerformanceSettingsService(gateway, configSync, logger) : null;
const resourceHistory = new ResourceHistoryService(provider, logger);
// Log ingestion runs when a backend is configured and we know where logs live.
const logsConfigured =
isPterodactylConfigured(env) &&
Boolean(env.REFORGER_ADMIN_LOG_PATH || env.REFORGER_LOG_DIRECTORY || env.USE_MOCK_PTERODACTYL);
const workshop = new WorkshopCache(
new WorkshopClient({
baseUrl: env.REFORGER_WORKSHOP_API_BASE_URL,
apiKey: env.REFORGER_WORKSHOP_API_KEY,
}),
logger,
);
const primaryServer = (await servers.listServers())[0] ?? null;
const providerServerId = primaryServer
? (primaryServer.pterodactylServerId ?? primaryServer.slug)
: '';
/**
* The live console feed. Under mock mode it is simulated; against a real
* Pterodactyl it is the Wings websocket, which is the only source that shows
* install/update/mod-download output as it happens.
*/
let consoleHub: ConsoleHub | null = null;
if (mockProvider) {
consoleHub = new MockConsoleHub(mockProvider);
} else if (isPterodactylConfigured(env) && env.PTERODACTYL_WEBSOCKET_ENABLED && providerServerId) {
consoleHub = new WingsConsoleHub({
baseUrl: env.PTERODACTYL_BASE_URL,
apiKey: env.PTERODACTYL_CLIENT_API_KEY,
serverId: providerServerId,
logger,
});
} else {
logger.info('live console disabled (websocket off or backend not configured)');
}
const gateway = new ConfigFileGateway(provider, env.REFORGER_CONFIG_PATH);
const configured = isPterodactylConfigured(env);
const configSync = configured ? new ConfigSyncService(gateway, servers, logger) : null;
const configEditor = configSync
? new ConfigEditorService(gateway, provider, configSync, logger)
: null;
const mods = configured ? new ServerModsService(gateway, workshop, logger) : null;
const performance = configEditor
? new PerformanceSettingsService(gateway, configEditor, logger)
: null;
const metrics = new ServerMetricsService(provider, consoleHub);
const resourceHistory = new ResourceHistoryService(metrics, logger);
// Log ingestion runs when a backend is configured and we know where logs live.
// It no longer powers the console view — only player sessions and killfeed,
// which are parsed out of the game's own log file.
const logsConfigured =
configured &&
Boolean(env.REFORGER_ADMIN_LOG_PATH || env.REFORGER_LOG_DIRECTORY || env.USE_MOCK_PTERODACTYL);
const resolveLogPath =
logsConfigured && primaryServer
? createLogPathResolver({
@@ -65,10 +109,11 @@ const resolveLogPath =
})
: null;
const missions =
const missionCatalog =
resolveLogPath && primaryServer
? new MissionCatalog(provider, resolveLogPath, providerServerId)
: null;
const missions = new MissionsService(workshop, missionCatalog);
let scheduler: IngestionScheduler | null = null;
if (resolveLogPath) {
@@ -94,10 +139,13 @@ const app = createApp({
sessions,
servers,
provider,
metrics,
consoleHub,
workshop,
scheduler,
resolveLogPath,
configSync,
configEditor,
mods,
performance,
resourceHistory,
@@ -111,6 +159,8 @@ const httpServer = app.listen(env.PORT, () => {
);
});
consoleHub?.start();
if (scheduler && resolveLogPath && primaryServer) {
scheduler.start([
{
@@ -121,20 +171,21 @@ if (scheduler && resolveLogPath && primaryServer) {
]);
}
if (primaryServer && isPterodactylConfigured(env)) {
if (primaryServer && configured) {
resourceHistory.start([{ serverId: primaryServer.id, providerServerId }]);
}
// Import the real config.json at startup and on an interval so the panel
// always reflects what the server actually runs.
let configSyncTimer: ReturnType<typeof setInterval> | null = null;
if (configSync) {
void configSync.syncAllQuietly();
configSyncTimer = setInterval(
() => void configSync.syncAllQuietly(),
env.REFORGER_CONFIG_SYNC_INTERVAL_SECONDS * 1000,
);
configSyncTimer.unref();
// Import the real config.json once at startup so the panel reflects what the
// server actually runs. Writes trigger their own sync, and every read is live,
// so there is deliberately no background polling loop here.
if (configSync && primaryServer) {
void configSync
.syncAllQuietly()
.then(() => mods?.getMods(primaryServer))
// Prime the Workshop cache with the installed mod list so the first visit
// to the Mods page renders without waiting on the network.
.then((installed) => workshop.warm((installed?.mods ?? []).map((mod) => mod.modId)))
.catch(() => undefined);
}
// Hourly cleanup of expired sessions.
@@ -151,10 +202,10 @@ async function shutdown(signal: string) {
logger.info({ signal }, 'shutting down');
httpServer.close();
clearInterval(sessionCleanup);
if (configSyncTimer) clearInterval(configSyncTimer);
resourceHistory.stop();
await consoleHub?.stop();
if (scheduler) await scheduler.stop();
if (provider instanceof MockGameServerProvider) provider.dispose();
if (mockProvider) mockProvider.dispose();
await pool.end();
process.exit(0);
}
+3
View File
@@ -38,6 +38,9 @@ export class ApiError extends Error {
static rateLimited(message = 'Too many requests. Try again shortly.') {
return new ApiError('RATE_LIMITED', message);
}
static conflict(message = 'This resource changed since you loaded it.') {
return new ApiError('CONFLICT', message);
}
static upstream(message = 'An upstream service is unavailable.') {
return new ApiError('UPSTREAM_UNAVAILABLE', message);
}
@@ -0,0 +1,188 @@
import type {
ConfigPatchOp,
ConfigPatchResult,
ConfigRawResponse,
ConfigTreeResponse,
StartupMirror,
} from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import type { Logger } from '../../lib/logger.js';
import type { GameServerProvider } from '../pterodactyl/types.js';
import type { ServerRecord } from '../servers/server-service.js';
import type { ConfigFileGateway } from './config-file-gateway.js';
import { applyConfigOps, flattenConfig, verifyConfigOps } from './config-tree.js';
import type { ConfigSyncService } from './config-sync.js';
import { detectStartupMirrors, mirrorsForPath } from './startup-mirrors.js';
function providerId(server: ServerRecord): string {
return server.pterodactylServerId ?? server.slug;
}
/**
* The general-purpose config.json editor: every key the file actually
* contains, addressed by path, patched one field at a time.
*
* The old editor could only reach eleven hardcoded keys and submitted all of
* them on every save, so a stale form quietly reverted whatever anyone else
* had changed. Here only the paths the user touched are sent, and the write is
* refused outright if the file moved since it was loaded.
*/
export class ConfigEditorService {
constructor(
private readonly gateway: ConfigFileGateway,
private readonly provider: GameServerProvider,
private readonly configSync: ConfigSyncService,
private readonly logger: Logger,
) {}
private async loadMirrors(
server: ServerRecord,
root: Record<string, unknown>,
): Promise<StartupMirror[]> {
const variables = await this.provider
.listStartupVariables(providerId(server))
.then((list) =>
list.map((variable) => ({
name: variable.name,
description: variable.description,
envVariable: variable.envVariable,
value: variable.serverValue,
defaultValue: variable.defaultValue,
isEditable: variable.isEditable,
})),
)
.catch(() => []);
return detectStartupMirrors(root, variables);
}
async getTree(server: ServerRecord): Promise<ConfigTreeResponse> {
const document = await this.gateway.download(providerId(server));
return {
entries: flattenConfig(document.root),
mirrors: await this.loadMirrors(server, document.root),
revision: document.revision,
fetchedAt: new Date().toISOString(),
};
}
async getRaw(server: ServerRecord): Promise<ConfigRawResponse> {
const document = await this.gateway.download(providerId(server));
return {
content: document.raw,
revision: document.revision,
fetchedAt: new Date().toISOString(),
};
}
async putRaw(
server: ServerRecord,
content: string,
expectedRevision?: string,
): Promise<ConfigRawResponse> {
const document = await this.gateway.replace(providerId(server), content, expectedRevision);
await this.afterWrite(server);
return {
content: document.raw,
revision: document.revision,
fetchedAt: new Date().toISOString(),
};
}
/**
* Applies field-level edits. When `writeStartupVars` is set, any changed path
* that the egg also templates from a startup variable is written to both
* places, so the change survives the next restart.
*/
async patch(
server: ServerRecord,
ops: readonly ConfigPatchOp[],
options: { expectedRevision?: string; writeStartupVars?: boolean } = {},
): Promise<ConfigPatchResult> {
if (ops.length === 0) {
const current = await this.gateway.download(providerId(server));
return {
changedPaths: [],
startupVarsWritten: [],
revision: current.revision,
fetchedAt: new Date().toISOString(),
requiresRestart: true,
};
}
let changedPaths: string[] = [];
const document = await this.gateway.mutate(providerId(server), {
expectedRevision: options.expectedRevision,
apply: (root) => {
changedPaths = applyConfigOps(root, ops);
},
verify: (readBack) => {
const failed = verifyConfigOps(readBack, ops);
if (failed) {
throw ApiError.upstream(
`Config write verification failed for "${failed}" — the file on the server does not match. Check config.json.bak.`,
);
}
},
});
const startupVarsWritten = options.writeStartupVars
? await this.mirrorChangedPaths(server, document.root, ops, changedPaths)
: [];
await this.afterWrite(server);
this.logger.info(
{ serverId: server.id, changedPaths, startupVarsWritten },
'config.json updated',
);
return {
changedPaths,
startupVarsWritten,
revision: document.revision,
fetchedAt: new Date().toISOString(),
requiresRestart: true,
};
}
private async mirrorChangedPaths(
server: ServerRecord,
root: Record<string, unknown>,
ops: readonly ConfigPatchOp[],
changedPaths: readonly string[],
): Promise<string[]> {
const mirrors = await this.loadMirrors(server, root);
const written: string[] = [];
for (const path of changedPaths) {
const op = ops.find((candidate) => candidate.path === path);
if (!op || op.value === null) continue;
for (const mirror of mirrorsForPath(path, mirrors)) {
try {
await this.provider.updateStartupVariable(
providerId(server),
mirror.envVariable,
String(op.value),
);
written.push(mirror.envVariable);
} catch (error) {
// Some eggs mark variables read-only; that is not fatal for the
// config write that already succeeded.
this.logger.warn(
{ serverId: server.id, envVariable: mirror.envVariable, err: String(error) },
'startup variable mirror write failed',
);
}
}
}
return written;
}
private async afterWrite(server: ServerRecord): Promise<void> {
await this.configSync.sync(server).catch((error) => {
this.logger.warn(
{ serverId: server.id, err: String(error) },
'post-write config sync failed',
);
});
}
}
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { ApiError } from '../../lib/errors.js';
import type { GameServerProvider } from '../pterodactyl/types.js';
@@ -9,22 +10,64 @@ export function asRecord(value: unknown): Record<string, unknown> | null {
: null;
}
export type ConfigDocument = {
raw: string;
root: Record<string, unknown>;
/** Content hash of `raw`. Callers echo it back to prove what they edited. */
revision: string;
/** Indentation detected in the source file, reused when writing. */
indent: string;
};
function revisionOf(raw: string): string {
return createHash('sha256').update(raw).digest('hex').slice(0, 16);
}
/** Keeps the file looking the way its author left it instead of reformatting. */
function detectIndent(raw: string): string {
const match = /\n([ \t]+)"/.exec(raw);
return match?.[1] ?? ' ';
}
/**
* Shared read-modify-write access to the server's config.json: size-guarded
* download + parse, and a write path that backs the previous content up to
* `<config>.bak` and verifies the upload by downloading it again. Callers
* mutate only their own keys on the parsed document so everything else in the
* file passes through untouched.
* Shared read-modify-write access to the server's config.json.
*
* Three properties matter here, and all three were missing before:
*
* 1. **Serialisation.** Every mutation runs under a per-server lock, so a mod
* list write and a settings write can no longer interleave, lose each
* other's changes, and overwrite the same `.bak`.
* 2. **Optimistic concurrency.** Callers pass the revision their edits were
* based on; if the file moved underneath them the write is rejected as a
* conflict instead of silently reverting somebody else's change.
* 3. **Read-back verification.** The upload is downloaded again and checked
* before the call is reported as successful.
*
* Callers mutate only their own keys on the parsed document, so everything
* else in the file passes through untouched.
*/
export class ConfigFileGateway {
/** Tail of the pending operation chain, per provider server id. */
private readonly locks = new Map<string, Promise<unknown>>();
constructor(
private readonly provider: GameServerProvider,
readonly configPath: string,
) {}
async download(
providerServerId: string,
): Promise<{ raw: string; root: Record<string, unknown> }> {
private withLock<T>(key: string, task: () => Promise<T>): Promise<T> {
const previous = this.locks.get(key) ?? Promise.resolve();
const next = previous.then(task, task);
// Swallow rejections on the chain itself so one failure cannot poison
// every subsequent operation.
this.locks.set(
key,
next.catch(() => undefined),
);
return next;
}
private async read(providerServerId: string): Promise<ConfigDocument> {
const file = await this.provider.downloadTextFile(
providerServerId,
this.configPath,
@@ -40,27 +83,93 @@ export class ConfigFileGateway {
throw ApiError.upstream('Server config.json is not valid JSON.');
}
const root = asRecord(parsed);
if (!root || !asRecord(root.game)) {
if (!root) {
throw ApiError.upstream('Server config.json is not a JSON object.');
}
if (!asRecord(root.game)) {
throw ApiError.upstream('Server config.json has no "game" section; refusing to modify it.');
}
return { raw: file.content, root };
return {
raw: file.content,
root,
revision: revisionOf(file.content),
indent: detectIndent(file.content),
};
}
/** Reads the current document. Waits for any in-flight write to finish. */
async download(providerServerId: string): Promise<ConfigDocument> {
return this.withLock(providerServerId, () => this.read(providerServerId));
}
/**
* Backs up `previousRaw`, writes the mutated document, downloads it again
* and hands the verified parsed result to `verify` (throw there to fail).
* Applies `apply` to a freshly-read document and writes it back, keeping a
* `<config>.bak` of the previous content. `verify` receives the re-downloaded
* document and should throw if the change did not land.
*
* Pass `expectedRevision` to reject the write when the file changed since the
* caller loaded it.
*/
async write(
async mutate(
providerServerId: string,
root: Record<string, unknown>,
previousRaw: string,
verify: (readBack: Record<string, unknown>) => void,
): Promise<Record<string, unknown>> {
await this.provider.writeTextFile(providerServerId, `${this.configPath}.bak`, previousRaw);
const serialized = `${JSON.stringify(root, null, 4)}\n`;
await this.provider.writeTextFile(providerServerId, this.configPath, serialized);
const readBack = await this.download(providerServerId);
verify(readBack.root);
return readBack.root;
options: {
expectedRevision?: string | undefined;
apply: (root: Record<string, unknown>) => void;
verify: (readBack: Record<string, unknown>) => void;
},
): Promise<ConfigDocument> {
return this.withLock(providerServerId, async () => {
const current = await this.read(providerServerId);
if (options.expectedRevision && options.expectedRevision !== current.revision) {
throw ApiError.conflict(
'config.json changed on the server since you loaded it. Refresh and re-apply your changes.',
);
}
options.apply(current.root);
const serialized = `${JSON.stringify(current.root, null, current.indent)}\n`;
if (serialized === current.raw) {
return current; // nothing to do; skip the write and the backup churn
}
await this.provider.writeTextFile(providerServerId, `${this.configPath}.bak`, current.raw);
await this.provider.writeTextFile(providerServerId, this.configPath, serialized);
const readBack = await this.read(providerServerId);
options.verify(readBack.root);
return readBack;
});
}
/** Replaces the whole file. Used by the raw JSON editor. */
async replace(
providerServerId: string,
content: string,
expectedRevision?: string,
): Promise<ConfigDocument> {
let parsed: unknown;
try {
parsed = JSON.parse(content.replace(/^\uFEFF/, ''));
} catch {
throw ApiError.validation('That is not valid JSON.');
}
const root = asRecord(parsed);
if (!root || !asRecord(root.game)) {
throw ApiError.validation('config.json must be an object with a "game" section.');
}
return this.withLock(providerServerId, async () => {
const current = await this.read(providerServerId);
if (expectedRevision && expectedRevision !== current.revision) {
throw ApiError.conflict(
'config.json changed on the server since you loaded it. Refresh and re-apply your changes.',
);
}
const normalized = content.endsWith('\n') ? content : `${content}\n`;
if (normalized === current.raw) return current;
await this.provider.writeTextFile(providerServerId, `${this.configPath}.bak`, current.raw);
await this.provider.writeTextFile(providerServerId, this.configPath, normalized);
return this.read(providerServerId);
});
}
}
+21 -17
View File
@@ -1,10 +1,13 @@
import type { ReforgerServerConfig } from '@reforger-panel/shared';
import { sanitizeErrorMessage, type Logger } from '../../lib/logger.js';
import type { GameServerProvider } from '../pterodactyl/types.js';
import type { ServerRecord, ServerService } from '../servers/server-service.js';
import { parseReforgerConfigJson } from './reforger-config-file.js';
import type { ConfigFileGateway } from './config-file-gateway.js';
import { mapReforgerConfig } from './reforger-config-file.js';
const CONFIG_MAX_BYTES = 256 * 1024;
export type LiveConfig = {
config: ReforgerServerConfig;
revision: string;
};
export type ConfigSyncResult = {
serverName: string;
@@ -13,30 +16,31 @@ export type ConfigSyncResult = {
};
/**
* Reads the server's real config.json (via the provider, read-only) and keeps
* the server row's name/maxPlayers in line with what the server actually
* runs. Configuration is always served live; no revision history is kept.
* Reads the server's real config.json and keeps the server row's
* name/maxPlayers in line with what the server actually runs. Configuration is
* always served live; no revision history is kept.
*
* Reads go through the shared gateway so they queue behind in-flight writes
* and return the same content revision the editors use for conflict detection.
*/
export class ConfigSyncService {
constructor(
private readonly provider: GameServerProvider,
private readonly gateway: ConfigFileGateway,
private readonly servers: ServerService,
private readonly logger: Logger,
private readonly configPath: string,
) {}
async getLiveConfig(server: ServerRecord): Promise<ReforgerServerConfig> {
const providerServerId = server.pterodactylServerId ?? server.slug;
const file = await this.provider.downloadTextFile(
providerServerId,
this.configPath,
CONFIG_MAX_BYTES,
);
return parseReforgerConfigJson(file.content);
private providerId(server: ServerRecord): string {
return server.pterodactylServerId ?? server.slug;
}
async getLiveConfig(server: ServerRecord): Promise<LiveConfig> {
const document = await this.gateway.download(this.providerId(server));
return { config: mapReforgerConfig(document.root), revision: document.revision };
}
async sync(server: ServerRecord): Promise<ConfigSyncResult> {
const config = await this.getLiveConfig(server);
const { config } = await this.getLiveConfig(server);
const maxPlayers = config.maxPlayers > 0 ? config.maxPlayers : null;
if (server.name !== config.serverName || server.maxPlayers !== maxPlayers) {
await this.servers.updateServerInfo(server.id, {
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import { applyConfigOps, flattenConfig, readAtPath, verifyConfigOps } from './config-tree.js';
function sampleConfig(): Record<string, unknown> {
return {
bindAddress: '0.0.0.0',
bindPort: 2001,
game: {
name: 'Test Server',
maxPlayers: 16,
gameProperties: { serverMaxViewDistance: 2500, battlEye: true },
mods: [{ modId: 'AAAA000000000001' }],
},
operating: { aiLimit: 40 },
};
}
describe('flattenConfig', () => {
it('exposes every leaf by dotted path', () => {
const paths = flattenConfig(sampleConfig()).map((entry) => entry.path);
expect(paths).toContain('bindPort');
expect(paths).toContain('game.gameProperties.serverMaxViewDistance');
expect(paths).toContain('operating.aiLimit');
});
it('hides the mod list, which the Mods page owns', () => {
const paths = flattenConfig(sampleConfig()).map((entry) => entry.path);
expect(paths).not.toContain('game.mods');
});
it('records value types so the editor can pick an input', () => {
const entries = flattenConfig(sampleConfig());
expect(entries.find((entry) => entry.path === 'bindPort')?.type).toBe('number');
expect(entries.find((entry) => entry.path === 'bindAddress')?.type).toBe('string');
expect(entries.find((entry) => entry.path === 'game.gameProperties.battlEye')?.type).toBe(
'boolean',
);
});
});
describe('applyConfigOps', () => {
it('touches only the given paths and reports what changed', () => {
const root = sampleConfig();
const changed = applyConfigOps(root, [
{ path: 'game.maxPlayers', value: 64 },
{ path: 'bindPort', value: 2001 }, // unchanged
]);
expect(changed).toEqual(['game.maxPlayers']);
expect(readAtPath(root, 'game.maxPlayers')).toBe(64);
expect(readAtPath(root, 'game.name')).toBe('Test Server');
});
it('creates missing intermediate sections when setting a new key', () => {
const root = sampleConfig();
applyConfigOps(root, [{ path: 'operating.playerSaveTime', value: 180 }]);
applyConfigOps(root, [{ path: 'rcon.port', value: 19999 }]);
expect(readAtPath(root, 'operating.playerSaveTime')).toBe(180);
expect(readAtPath(root, 'rcon.port')).toBe(19999);
});
it('removes a key on null so the game default applies', () => {
const root = sampleConfig();
const changed = applyConfigOps(root, [{ path: 'operating.aiLimit', value: null }]);
expect(changed).toEqual(['operating.aiLimit']);
expect('aiLimit' in (root.operating as Record<string, unknown>)).toBe(false);
});
it('does not create sections just to delete from them', () => {
const root = sampleConfig();
const changed = applyConfigOps(root, [{ path: 'nothing.here', value: null }]);
expect(changed).toEqual([]);
expect('nothing' in root).toBe(false);
});
it('leaves the mod list untouched', () => {
const root = sampleConfig();
applyConfigOps(root, [{ path: 'game.maxPlayers', value: 32 }]);
expect((root.game as Record<string, unknown>).mods).toEqual([{ modId: 'AAAA000000000001' }]);
});
});
describe('verifyConfigOps', () => {
it('passes when every op landed', () => {
const root = sampleConfig();
const ops = [
{ path: 'game.maxPlayers', value: 64 },
{ path: 'operating.aiLimit', value: null },
];
applyConfigOps(root, ops);
expect(verifyConfigOps(root, ops)).toBeNull();
});
it('names the first path that did not land', () => {
const root = sampleConfig();
expect(verifyConfigOps(root, [{ path: 'game.maxPlayers', value: 64 }])).toBe('game.maxPlayers');
});
});
+146
View File
@@ -0,0 +1,146 @@
import type { ConfigEntry, ConfigPatchOp, ConfigValueType } from '@reforger-panel/shared';
import { asRecord } from './config-file-gateway.js';
/**
* `game.mods` is owned by the Mods page, which understands versions and
* dependencies. Hand-editing a 90-entry array in a generic key editor is a
* good way to break a server, so it is hidden from the flat view (the raw JSON
* tab still shows it).
*/
const HIDDEN_PATHS = new Set(['game.mods']);
function typeOf(value: unknown): ConfigValueType {
if (value === null) return 'null';
if (Array.isArray(value)) return 'array';
switch (typeof value) {
case 'string':
return 'string';
case 'number':
return 'number';
case 'boolean':
return 'boolean';
default:
return 'object';
}
}
/**
* Flattens config.json into dotted leaf paths (`game.gameProperties.battlEye`)
* so the editor can search and address every value the file actually contains,
* rather than only the handful of keys the panel happens to know about.
*
* Arrays are surfaced as single non-recursive entries carrying their JSON text;
* the flat editor renders them read-only and defers to the raw tab.
*/
export function flattenConfig(root: Record<string, unknown>): ConfigEntry[] {
const entries: ConfigEntry[] = [];
const walk = (node: Record<string, unknown>, prefix: string): void => {
for (const [key, value] of Object.entries(node)) {
const path = prefix ? `${prefix}.${key}` : key;
if (HIDDEN_PATHS.has(path)) continue;
const child = asRecord(value);
if (child) {
walk(child, path);
continue;
}
if (Array.isArray(value)) {
entries.push({ path, value: null, type: 'array', raw: JSON.stringify(value) });
continue;
}
entries.push({
path,
value: value as string | number | boolean | null,
type: typeOf(value),
});
}
};
walk(root, '');
entries.sort((a, b) => a.path.localeCompare(b.path));
return entries;
}
export function readAtPath(
root: Record<string, unknown>,
path: string,
): string | number | boolean | null {
const segments = path.split('.');
let node: unknown = root;
for (const segment of segments) {
const record = asRecord(node);
if (!record || !(segment in record)) return null;
node = record[segment];
}
if (node === undefined || asRecord(node) || Array.isArray(node)) return null;
return node as string | number | boolean | null;
}
/**
* Applies patch operations in place and returns the paths that actually
* changed. `value: null` removes the key entirely so the game's own default
* applies — the same semantics the performance form has always had.
*
* Only the supplied paths are touched, which is the core fix for the old
* whole-object form submit that silently reverted concurrent edits.
*/
export function applyConfigOps(
root: Record<string, unknown>,
ops: readonly ConfigPatchOp[],
): string[] {
const changed: string[] = [];
for (const op of ops) {
const segments = op.path.split('.').filter(Boolean);
const leaf = segments.pop();
if (!leaf) continue;
if (op.value === null) {
// Removal: never create the intermediate objects on the way down.
let node: Record<string, unknown> | null = root;
for (const segment of segments) {
node = asRecord(node[segment]);
if (!node) break;
}
if (node && leaf in node) {
delete node[leaf];
changed.push(op.path);
}
continue;
}
let node: Record<string, unknown> = root;
for (const segment of segments) {
const child = asRecord(node[segment]);
if (child) {
node = child;
} else {
const created: Record<string, unknown> = {};
node[segment] = created;
node = created;
}
}
if (node[leaf] !== op.value) {
node[leaf] = op.value;
changed.push(op.path);
}
}
return changed;
}
/** Verifies that a write landed, for the gateway's read-back check. */
export function verifyConfigOps(
readBack: Record<string, unknown>,
ops: readonly ConfigPatchOp[],
): string | null {
for (const op of ops) {
const actual = readAtPath(readBack, op.path);
if (op.value === null) {
if (actual !== null) return op.path;
} else if (actual !== op.value) {
return op.path;
}
}
return null;
}
+275 -34
View File
@@ -1,13 +1,41 @@
import type {
ModOverviewEntry,
ModResolveResponse,
ModsOverviewResponse,
ModWorkshopInfo,
ReforgerConfigMod,
ResolvedMod,
ServerModsResponse,
UpdateModsResult,
WorkshopDependency,
WorkshopModDetail,
} from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import type { Logger } from '../../lib/logger.js';
import { OFFICIAL_SCENARIO_IDS } from '../reforger-logs/missions-catalog.js';
import type { WorkshopCache } from '../workshop/workshop-cache.js';
import type { ServerRecord } from '../servers/server-service.js';
import { asRecord, type ConfigFileGateway } from './config-file-gateway.js';
import type { ConfigSyncService } from './config-sync.js';
import { readAtPath } from './config-tree.js';
/**
* How long the overview waits for cold Workshop lookups before answering with
* what it has. A large modlist on a cold cache cannot be resolved inside one
* request without either blocking for a minute or tripping the upstream rate
* limit, so the response is returned immediately with `warming: true` and the
* client refetches while the background fill completes.
*/
const WARM_WAIT_MS = 2_500;
/** Guard against a pathological dependency graph. */
const MAX_RESOLVED_MODS = 400;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(resolve, ms);
timer.unref?.();
});
}
function readMods(root: Record<string, unknown>): ReforgerConfigMod[] {
const game = asRecord(root.game);
@@ -28,15 +56,32 @@ function readMods(root: Record<string, unknown>): ReforgerConfigMod[] {
.filter((mod): mod is ReforgerConfigMod => mod !== null);
}
function toWorkshopInfo(detail: WorkshopModDetail): ModWorkshopInfo {
return {
name: detail.name,
author: detail.author,
summary: detail.summary,
imageUrl: detail.imageUrl,
workshopUrl: detail.workshopUrl,
latestVersion: detail.version,
gameVersion: detail.gameVersion,
sizeBytes: detail.sizeBytes,
scenarioCount: detail.scenarioCount,
dependencyCount: detail.dependencyCount,
obsolete: detail.obsolete,
tags: detail.tags,
};
}
/**
* Manages the `game.mods` array of the server's real config.json through the
* shared ConfigFileGateway (backup + read-back verification; all other config
* fields pass through untouched). Changes apply on the next server restart.
* Owns the `game.mods` array of the server's real config.json, and joins it
* with Workshop metadata so the panel can show versions, sizes, dependencies
* and available updates without the browser making one request per mod.
*/
export class ServerModsService {
constructor(
private readonly gateway: ConfigFileGateway,
private readonly configSync: ConfigSyncService,
private readonly workshop: WorkshopCache,
private readonly logger: Logger,
) {}
@@ -45,51 +90,247 @@ export class ServerModsService {
}
async getMods(server: ServerRecord): Promise<ServerModsResponse> {
const { root } = await this.gateway.download(this.providerId(server));
return { mods: readMods(root), fetchedAt: new Date().toISOString() };
const document = await this.gateway.download(this.providerId(server));
const mods = readMods(document.root);
// Keep the cache aligned with what is actually installed.
this.workshop.warm(mods.map((mod) => mod.modId));
return { mods, revision: document.revision, fetchedAt: new Date().toISOString() };
}
async setMods(server: ServerRecord, mods: ReforgerConfigMod[]): Promise<UpdateModsResult> {
/**
* Everything the Mods page needs in one request: installed mods joined with
* cached Workshop metadata, update availability, dependency gaps and the
* reverse "required by" edges that make removals safe.
*/
async getOverview(server: ServerRecord): Promise<ModsOverviewResponse> {
const document = await this.gateway.download(this.providerId(server));
const installed = readMods(document.root);
const ids = installed.map((mod) => mod.modId.toUpperCase());
const installedIds = new Set(ids);
// Start every lookup, then answer with whatever resolved in time.
await Promise.race([
Promise.allSettled(ids.map((id) => this.workshop.tryGetMod(id))),
delay(WARM_WAIT_MS),
]);
const details = new Map<string, WorkshopModDetail | null | undefined>();
for (const id of ids) details.set(id, this.workshop.peekMod(id));
// Reverse dependency edges: which installed mods need each mod.
const requiredBy = new Map<string, string[]>();
for (const [id, detail] of details) {
if (!detail) continue;
for (const dependency of detail.dependencies) {
const dependents = requiredBy.get(dependency.id) ?? [];
dependents.push(id);
requiredBy.set(dependency.id, dependents);
}
}
const unresolvedIds: string[] = [];
let warming = false;
let totalSizeBytes: number | null = null;
let updatesAvailable = 0;
const mods: ModOverviewEntry[] = installed.map((mod) => {
const id = mod.modId.toUpperCase();
const detail = details.get(id);
if (detail === undefined) warming = true;
if (detail === null) unresolvedIds.push(id);
const pinnedVersion = mod.version ?? null;
const workshop = detail ? toWorkshopInfo(detail) : null;
const updateAvailable = Boolean(
pinnedVersion && workshop?.latestVersion && pinnedVersion !== workshop.latestVersion,
);
if (updateAvailable) updatesAvailable += 1;
if (workshop?.sizeBytes) totalSizeBytes = (totalSizeBytes ?? 0) + workshop.sizeBytes;
const missingDependencies: WorkshopDependency[] = (detail?.dependencies ?? []).filter(
(dependency) => !installedIds.has(dependency.id),
);
return {
modId: id,
configName: mod.name ?? null,
pinnedVersion,
workshop,
updateAvailable,
missingDependencies,
requiredBy: (requiredBy.get(id) ?? []).filter((dependent) => dependent !== id),
};
});
return {
mods,
revision: document.revision,
fetchedAt: new Date().toISOString(),
totalSizeBytes,
updatesAvailable,
unresolvedIds,
warming,
orphanedMission: this.detectOrphanedMission(document.root, details, warming),
};
}
/**
* Flags a configured scenario that nothing installed can provide — the usual
* cause of a server that boots to the wrong mission after a mod removal.
* Only reported once every mod resolved, so a warming cache never produces a
* false alarm.
*/
private detectOrphanedMission(
root: Record<string, unknown>,
details: Map<string, WorkshopModDetail | null | undefined>,
warming: boolean,
): { scenarioId: string } | null {
if (warming) return null;
const scenarioId = readAtPath(root, 'game.scenarioId');
if (typeof scenarioId !== 'string' || !scenarioId) return null;
if (OFFICIAL_SCENARIO_IDS.has(scenarioId)) return null;
for (const detail of details.values()) {
if (!detail) return null; // an unresolved mod might well provide it
if (detail.scenarios.some((scenario) => scenario.scenarioId === scenarioId)) return null;
}
return { scenarioId };
}
/**
* Expands a desired mod list into everything needed to make it load: the
* requested mods plus the transitive dependency closure, with sizes so the
* UI can show the download cost before anything is written.
*/
async resolve(desired: readonly ReforgerConfigMod[]): Promise<ModResolveResponse> {
const requested = new Map<string, ReforgerConfigMod>();
for (const mod of desired) requested.set(mod.modId.toUpperCase(), mod);
const resolved = new Map<string, ResolvedMod>();
const unresolvedIds: string[] = [];
const queue = [...requested.keys()];
const seen = new Set(queue);
for (const id of queue) {
const mod = requested.get(id);
resolved.set(id, {
modId: id,
name: mod?.name ?? null,
version: mod?.version ?? null,
sizeBytes: null,
viaDependency: false,
requiredBy: [],
});
}
while (queue.length > 0 && resolved.size < MAX_RESOLVED_MODS) {
// Resolve a whole level at a time so the pacer can overlap requests.
const level = queue.splice(0, queue.length);
const details = await Promise.all(level.map((id) => this.workshop.tryGetMod(id)));
for (let index = 0; index < level.length; index += 1) {
const id = level[index]!;
const detail = details[index] ?? null;
const entry = resolved.get(id)!;
if (!detail) {
unresolvedIds.push(id);
continue;
}
entry.name = entry.name ?? detail.name;
entry.sizeBytes = detail.sizeBytes;
for (const dependency of detail.dependencies) {
const existing = resolved.get(dependency.id);
if (existing) {
if (!existing.requiredBy.includes(id)) existing.requiredBy.push(id);
continue;
}
resolved.set(dependency.id, {
modId: dependency.id,
name: dependency.name,
version: null,
sizeBytes: dependency.sizeBytes,
viaDependency: true,
requiredBy: [id],
});
if (!seen.has(dependency.id)) {
seen.add(dependency.id);
queue.push(dependency.id);
}
}
}
}
const mods = [...resolved.values()];
const sized = mods.filter((mod) => mod.sizeBytes !== null);
return {
mods,
addedDependencies: mods.filter((mod) => mod.viaDependency),
totalSizeBytes:
sized.length > 0 ? sized.reduce((sum, mod) => sum + (mod.sizeBytes ?? 0), 0) : null,
unresolvedIds,
};
}
/**
* Writes the mod list. `expectedRevision` is required in practice: it is how
* a stale browser tab is stopped from reverting somebody else's change.
*/
async setMods(
server: ServerRecord,
mods: readonly ReforgerConfigMod[],
expectedRevision?: string,
): Promise<UpdateModsResult> {
const providerId = this.providerId(server);
const { raw, root } = await this.gateway.download(providerId);
const previous = readMods(root);
const before = await this.gateway.download(providerId);
const previous = readMods(before.root);
const previousIds = new Set(previous.map((mod) => mod.modId.toUpperCase()));
const nextIds = new Set(mods.map((mod) => mod.modId.toUpperCase()));
const added = [...nextIds].filter((id) => !previousIds.has(id)).length;
const removed = [...previousIds].filter((id) => !nextIds.has(id)).length;
const previousById = new Map(previous.map((mod) => [mod.modId.toUpperCase(), mod]));
const nextById = new Map(mods.map((mod) => [mod.modId.toUpperCase(), mod]));
const game = asRecord(root.game)!;
game.mods = mods.map((mod) => ({
modId: mod.modId.toUpperCase(),
const added = [...nextById.keys()].filter((id) => !previousById.has(id)).length;
const removed = [...previousById.keys()].filter((id) => !nextById.has(id)).length;
const changed = [...nextById].filter(([id, mod]) => {
const existing = previousById.get(id);
return existing !== undefined && (existing.version ?? null) !== (mod.version ?? null);
}).length;
const normalized = [...nextById.entries()].map(([id, mod]) => ({
modId: id,
...(mod.name ? { name: mod.name } : {}),
...(mod.version ? { version: mod.version } : {}),
}));
const expectedIds = [...nextById.keys()].sort();
const verified = await this.gateway.write(providerId, root, raw, (readBack) => {
const verifyIds = readMods(readBack)
.map((mod) => mod.modId.toUpperCase())
.sort();
if (JSON.stringify(verifyIds) !== JSON.stringify([...nextIds].sort())) {
throw ApiError.upstream(
'Config write verification failed — the file on the server does not match. Check config.json.bak.',
);
}
const document = await this.gateway.mutate(providerId, {
expectedRevision: expectedRevision ?? before.revision,
apply: (root) => {
const game = asRecord(root.game)!;
game.mods = normalized;
},
verify: (readBack) => {
const actual = readMods(readBack)
.map((mod) => mod.modId.toUpperCase())
.sort();
if (JSON.stringify(actual) !== JSON.stringify(expectedIds)) {
throw ApiError.upstream(
'Config write verification failed — the file on the server does not match. Check config.json.bak.',
);
}
},
});
await this.configSync.sync(server).catch((error) => {
this.logger.warn(
{ serverId: server.id, err: String(error) },
'post-write config sync failed',
);
});
const result = readMods(document.root);
this.workshop.warm(result.map((mod) => mod.modId));
this.logger.info({ serverId: server.id, added, removed }, 'server mods updated');
this.logger.info({ serverId: server.id, added, removed, changed }, 'server mods updated');
return {
mods: readMods(verified),
mods: result,
revision: document.revision,
fetchedAt: new Date().toISOString(),
added,
removed,
changed,
requiresRestart: true,
};
}
@@ -1,83 +1,59 @@
import type {
ConfigPatchOp,
PerformanceSettings,
PerformanceSettingsPatch,
PerformanceSettingsResponse,
} from '@reforger-panel/shared';
import type { Logger } from '../../lib/logger.js';
import type { ServerRecord } from '../servers/server-service.js';
import { asRecord, type ConfigFileGateway } from './config-file-gateway.js';
import type { ConfigSyncService } from './config-sync.js';
import type { ConfigEditorService } from './config-editor-service.js';
import type { ConfigFileGateway } from './config-file-gateway.js';
import { readAtPath } from './config-tree.js';
/** Where each performance field lives inside config.json. */
const FIELD_LOCATIONS: Record<
keyof PerformanceSettings,
['game' | 'gameProperties' | 'operating', string]
> = {
scenarioId: ['game', 'scenarioId'],
maxPlayers: ['game', 'maxPlayers'],
serverMaxViewDistance: ['gameProperties', 'serverMaxViewDistance'],
networkViewDistance: ['gameProperties', 'networkViewDistance'],
serverMinGrassDistance: ['gameProperties', 'serverMinGrassDistance'],
disableThirdPerson: ['gameProperties', 'disableThirdPerson'],
fastValidation: ['gameProperties', 'fastValidation'],
battlEye: ['gameProperties', 'battlEye'],
disableAI: ['operating', 'disableAI'],
aiLimit: ['operating', 'aiLimit'],
playerSaveTime: ['operating', 'playerSaveTime'],
slotReservationTimeout: ['operating', 'slotReservationTimeout'],
lobbyPlayerSynchronise: ['operating', 'lobbyPlayerSynchronise'],
/** Where each curated performance field lives inside config.json. */
export const PERFORMANCE_FIELD_PATHS: Record<keyof PerformanceSettings, string> = {
scenarioId: 'game.scenarioId',
maxPlayers: 'game.maxPlayers',
serverMaxViewDistance: 'game.gameProperties.serverMaxViewDistance',
networkViewDistance: 'game.gameProperties.networkViewDistance',
serverMinGrassDistance: 'game.gameProperties.serverMinGrassDistance',
disableThirdPerson: 'game.gameProperties.disableThirdPerson',
fastValidation: 'game.gameProperties.fastValidation',
battlEye: 'game.gameProperties.battlEye',
disableAI: 'operating.disableAI',
aiLimit: 'operating.aiLimit',
playerSaveTime: 'operating.playerSaveTime',
slotReservationTimeout: 'operating.slotReservationTimeout',
lobbyPlayerSynchronise: 'operating.lobbyPlayerSynchronise',
};
function sectionFor(
root: Record<string, unknown>,
section: 'game' | 'gameProperties' | 'operating',
createMissing: boolean,
): Record<string, unknown> | null {
const game = asRecord(root.game)!;
if (section === 'game') return game;
if (section === 'gameProperties') {
let props = asRecord(game.gameProperties);
if (!props && createMissing) {
props = {};
game.gameProperties = props;
}
return props;
}
let operating = asRecord(root.operating);
if (!operating && createMissing) {
operating = {};
root.operating = operating;
}
return operating;
}
const PERFORMANCE_FIELDS = Object.keys(PERFORMANCE_FIELD_PATHS) as (keyof PerformanceSettings)[];
export function readPerformanceSettings(root: Record<string, unknown>): PerformanceSettings {
const result = {} as Record<keyof PerformanceSettings, number | boolean | string | null>;
for (const [field, [section, key]] of Object.entries(FIELD_LOCATIONS) as [
keyof PerformanceSettings,
['game' | 'gameProperties' | 'operating', string],
][]) {
const container = sectionFor(root, section, false);
const value = container?.[key];
const validType =
field === 'scenarioId'
? typeof value === 'string'
: typeof value === 'number' || typeof value === 'boolean';
result[field] = validType ? (value as number | boolean | string) : null;
const result = {} as Record<keyof PerformanceSettings, string | number | boolean | null>;
for (const field of PERFORMANCE_FIELDS) {
const value = readAtPath(root, PERFORMANCE_FIELD_PATHS[field]);
const expected = field === 'scenarioId' ? 'string' : ['number', 'boolean'];
const matches =
typeof expected === 'string' ? typeof value === expected : expected.includes(typeof value);
result[field] = matches ? value : null;
}
return result as PerformanceSettings;
}
/**
* Edits the performance-related keys of the live config.json. A `null` value
* removes the key from the file entirely so the game's own default applies —
* network/identity fields (bind address, ports, passwords, rcon…) are never
* touched by this service.
* The curated view of config.json: typed, range-validated fields for the
* settings the panel understands well.
*
* It now shares the patch engine with the general key editor, so a save only
* ever touches the fields the caller explicitly included. Previously the form
* posted all thirteen values on every submit, which meant a form loaded before
* somebody else's change silently reverted it.
*/
export class PerformanceSettingsService {
constructor(
private readonly gateway: ConfigFileGateway,
private readonly configSync: ConfigSyncService,
private readonly editor: ConfigEditorService,
private readonly logger: Logger,
) {}
@@ -86,59 +62,48 @@ export class PerformanceSettingsService {
}
async get(server: ServerRecord): Promise<PerformanceSettingsResponse> {
const { root } = await this.gateway.download(this.providerId(server));
return { settings: readPerformanceSettings(root), fetchedAt: new Date().toISOString() };
const document = await this.gateway.download(this.providerId(server));
return {
settings: readPerformanceSettings(document.root),
revision: document.revision,
fetchedAt: new Date().toISOString(),
};
}
async update(
server: ServerRecord,
patch: PerformanceSettingsPatch,
options: { expectedRevision?: string; writeStartupVars?: boolean } = {},
): Promise<PerformanceSettingsResponse & { changedFields: string[]; requiresRestart: true }> {
const providerId = this.providerId(server);
const { raw, root } = await this.gateway.download(providerId);
const before = readPerformanceSettings(root);
const before = await this.get(server);
// Only fields the caller actually sent, and only where the value differs.
const ops: ConfigPatchOp[] = [];
const changedFields: string[] = [];
for (const [field, [section, key]] of Object.entries(FIELD_LOCATIONS) as [
keyof PerformanceSettings,
['game' | 'gameProperties' | 'operating', string],
][]) {
if (!(field in patch)) continue; // untouched fields stay as-is
const next = patch[field] as number | boolean | string | null;
if (before[field] === next) continue;
for (const field of PERFORMANCE_FIELDS) {
if (!(field in patch)) continue;
const next = patch[field] ?? null;
if (before.settings[field] === next) continue;
ops.push({ path: PERFORMANCE_FIELD_PATHS[field], value: next });
changedFields.push(field);
if (next === null) {
const container = sectionFor(root, section, false);
if (container) delete container[key];
} else {
const container = sectionFor(root, section, true)!;
container[key] = next;
}
}
if (changedFields.length > 0) {
await this.gateway.write(providerId, root, raw, (readBack) => {
const after = readPerformanceSettings(readBack);
for (const field of changedFields) {
if (
after[field as keyof PerformanceSettings] !== patch[field as keyof PerformanceSettings]
) {
throw new Error('Config write verification failed. Check config.json.bak.');
}
}
});
await this.configSync.sync(server).catch((error) => {
this.logger.warn(
{ serverId: server.id, err: String(error) },
'post-write config sync failed',
);
});
this.logger.info({ serverId: server.id, changedFields }, 'performance settings updated');
if (ops.length === 0) {
return { ...before, changedFields: [], requiresRestart: true };
}
const result = await this.editor.patch(server, ops, {
expectedRevision: options.expectedRevision ?? before.revision,
writeStartupVars: options.writeStartupVars ?? true,
});
this.logger.info({ serverId: server.id, changedFields }, 'performance settings updated');
const after = await this.get(server);
return {
settings: { ...before, ...patch } as PerformanceSettings,
fetchedAt: new Date().toISOString(),
settings: after.settings,
revision: result.revision,
fetchedAt: result.fetchedAt,
changedFields,
requiresRestart: true,
};
@@ -38,7 +38,7 @@ const REAL_SHAPE = {
lobbyPlayerSynchronise: true,
disableAI: false,
aiLimit: -1,
playerSaveTime: 120
playerSaveTime: 120,
},
};
@@ -46,7 +46,7 @@ describe('parseReforgerConfigJson', () => {
it('maps a real-shaped config.json into the panel model', () => {
const config = parseReforgerConfigJson(JSON.stringify(REAL_SHAPE));
expect(config).toEqual({
serverName: 'DazzledCorp Training Grounds',
serverName: 'DZR Training Grounds',
maxPlayers: 16,
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
disableAI: false,
@@ -57,7 +57,7 @@ describe('parseReforgerConfigJson', () => {
disableThirdPerson: true,
mods: [
{ modId: '591AF5BDA9F7CE8B', name: 'Some Mod', version: '1.0.2' },
{ modId: '5AAF0CCE3F001FB5' },
{ modId: '5AAF0CCE3F001FB5', name: undefined, version: undefined },
],
});
});
@@ -0,0 +1,80 @@
import type { StartupMirror, StartupVariable } from '@reforger-panel/shared';
import { readAtPath } from './config-tree.js';
/**
* Reforger eggs commonly regenerate config.json from Pterodactyl startup
* variables when the container boots. When that happens, editing the file
* alone looks like it worked — the panel writes it, verifies it, and then the
* next restart silently throws the change away.
*
* This map is how the panel notices. Only pairs whose startup variable
* actually exists on the egg are reported, so eggs that do not template
* anything produce no warnings at all.
*/
export const STARTUP_MIRROR_MAP: readonly { envVariable: string; configPath: string }[] = [
{ envVariable: 'SCENARIO_ID', configPath: 'game.scenarioId' },
{ envVariable: 'MISSION_ID', configPath: 'game.scenarioId' },
{ envVariable: 'MAX_PLAYERS', configPath: 'game.maxPlayers' },
{ envVariable: 'SERVER_NAME', configPath: 'game.name' },
{ envVariable: 'HOSTNAME', configPath: 'game.name' },
{ envVariable: 'SERVER_PASSWORD', configPath: 'game.password' },
{ envVariable: 'ADMIN_PASSWORD', configPath: 'game.passwordAdmin' },
{ envVariable: 'GAME_PORT', configPath: 'bindPort' },
{ envVariable: 'SERVER_PORT', configPath: 'bindPort' },
{ envVariable: 'BIND_PORT', configPath: 'bindPort' },
{ envVariable: 'SERVER_IP', configPath: 'bindAddress' },
{ envVariable: 'BIND_ADDRESS', configPath: 'bindAddress' },
{ envVariable: 'A2S_PORT', configPath: 'a2s.port' },
{ envVariable: 'RCON_PORT', configPath: 'rcon.port' },
{ envVariable: 'RCON_PASSWORD', configPath: 'rcon.password' },
{ envVariable: 'CROSS_PLATFORM', configPath: 'game.crossPlatform' },
{ envVariable: 'CROSSPLAY', configPath: 'game.crossPlatform' },
{ envVariable: 'BATTLEYE', configPath: 'game.gameProperties.battlEye' },
{ envVariable: 'VISIBLE', configPath: 'game.visible' },
{ envVariable: 'DISABLE_THIRD_PERSON', configPath: 'game.gameProperties.disableThirdPerson' },
{ envVariable: 'VIEW_DISTANCE', configPath: 'game.gameProperties.serverMaxViewDistance' },
];
/** Loose comparison — startup variables are always strings. */
function sameValue(startupValue: string, configValue: string | number | boolean | null): boolean {
if (configValue === null) return startupValue === '';
if (typeof configValue === 'boolean') {
const normalized = startupValue.trim().toLowerCase();
return configValue
? ['1', 'true', 'yes'].includes(normalized)
: ['0', 'false', 'no', ''].includes(normalized);
}
return String(configValue).trim() === startupValue.trim();
}
export function detectStartupMirrors(
root: Record<string, unknown>,
variables: readonly StartupVariable[],
): StartupMirror[] {
const byEnv = new Map(variables.map((variable) => [variable.envVariable, variable]));
const mirrors: StartupMirror[] = [];
const seen = new Set<string>();
for (const { envVariable, configPath } of STARTUP_MIRROR_MAP) {
const variable = byEnv.get(envVariable);
if (!variable) continue;
const key = `${envVariable}:${configPath}`;
if (seen.has(key)) continue;
seen.add(key);
const configValue = readAtPath(root, configPath);
mirrors.push({
envVariable,
configPath,
startupValue: variable.value,
configValue,
conflict: !sameValue(variable.value, configValue),
});
}
return mirrors;
}
/** Startup variables that mirror a given config path, for "write both". */
export function mirrorsForPath(path: string, mirrors: readonly StartupMirror[]): StartupMirror[] {
return mirrors.filter((mirror) => mirror.configPath === path);
}
@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest';
import type { ConsoleEvent, LiveStats } from './console-hub.js';
import { BaseConsoleHub, mapPowerState, stripAnsi } from './console-hub.js';
const ESC = String.fromCharCode(27);
class TestHub extends BaseConsoleHub {
start(): void {
this.setConnected(true);
}
async stop(): Promise<void> {
this.setConnected(false);
}
emitOutput(stream: 'console' | 'install' | 'daemon', chunk: string): void {
this.pushOutput(stream, chunk);
}
emitStats(stats: LiveStats): void {
this.setStats(stats);
}
emitStatus(status: Parameters<BaseConsoleHub['setStatus']>[0]): void {
this.setStatus(status);
}
}
function stats(overrides: Partial<LiveStats> = {}): LiveStats {
return {
status: 'online',
cpuPercent: 38,
memoryBytes: 1024,
diskBytes: 2048,
networkRxBytes: 10,
networkTxBytes: 20,
uptimeMs: 1000,
at: Date.now(),
...overrides,
};
}
describe('mapPowerState', () => {
it('maps every Wings state, defaulting to unknown', () => {
expect(mapPowerState('running')).toBe('online');
expect(mapPowerState('starting')).toBe('starting');
expect(mapPowerState('stopping')).toBe('stopping');
expect(mapPowerState('offline')).toBe('offline');
expect(mapPowerState(undefined)).toBe('unknown');
expect(mapPowerState('something-new')).toBe('unknown');
});
});
describe('stripAnsi', () => {
it('removes the colour escapes Wings wraps console output in', () => {
expect(stripAnsi(`${ESC}[0;32mSuccess!${ESC}[0m`)).toBe('Success!');
});
it('leaves plain text alone', () => {
expect(stripAnsi('NETWORK : Server is ready')).toBe('NETWORK : Server is ready');
});
});
describe('BaseConsoleHub', () => {
it('splits chunks into lines and numbers them for de-duplication', () => {
const hub = new TestHub();
hub.emitOutput('console', 'first\r\nsecond\n\nthird\n');
const lines = hub.backlog().lines;
expect(lines.map((line) => line.text)).toEqual(['first', 'second', 'third']);
expect(lines.map((line) => line.seq)).toEqual([1, 2, 3]);
});
it('keeps install output distinguishable from game output', () => {
const hub = new TestHub();
hub.emitOutput('install', 'Downloading mod 595F2BF2F44836FB');
expect(hub.backlog().lines[0]!.stream).toBe('install');
});
it('replays the backlog and current state to a late subscriber', () => {
const hub = new TestHub();
hub.start();
hub.emitOutput('console', 'boot line');
hub.emitStatus('starting');
const backlog = hub.backlog();
expect(backlog.lines).toHaveLength(1);
expect(backlog.status).toBe('starting');
expect(backlog.connected).toBe(true);
});
it('fans events out to subscribers until they unsubscribe', () => {
const hub = new TestHub();
const events: ConsoleEvent[] = [];
const unsubscribe = hub.subscribe((event) => events.push(event));
hub.emitOutput('console', 'one');
hub.emitStatus('online');
unsubscribe();
hub.emitOutput('console', 'two');
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({ type: 'line' });
expect(events[1]).toMatchObject({ type: 'status', status: 'online' });
});
it('does not re-emit an unchanged status', () => {
const hub = new TestHub();
const events: ConsoleEvent[] = [];
hub.subscribe((event) => events.push(event));
hub.emitStatus('online');
hub.emitStatus('online');
expect(events.filter((event) => event.type === 'status')).toHaveLength(1);
});
it('takes the status carried by a stats frame', () => {
const hub = new TestHub();
hub.emitStats(stats({ status: 'starting' }));
expect(hub.latestStatus()).toBe('starting');
expect(hub.latestStats()?.cpuPercent).toBe(38);
});
it('a throwing subscriber cannot break the feed for others', () => {
const hub = new TestHub();
const seen: string[] = [];
hub.subscribe(() => {
throw new Error('bad subscriber');
});
hub.subscribe((event) => {
if (event.type === 'line') seen.push(event.line.text);
});
hub.emitOutput('console', 'still delivered');
expect(seen).toEqual(['still delivered']);
});
});
@@ -0,0 +1,148 @@
import type {
ConsoleBacklog,
ConsoleLine,
ConsoleLineStream,
ServerStatus,
} from '@reforger-panel/shared';
/** Maps a hosting backend's power state onto the panel's status vocabulary. */
export function mapPowerState(state: string | undefined): ServerStatus {
switch (state) {
case 'running':
return 'online';
case 'offline':
return 'offline';
case 'starting':
return 'starting';
case 'stopping':
return 'stopping';
default:
return 'unknown';
}
}
/** A resource frame pushed by Wings, or synthesised by the mock provider. */
export type LiveStats = {
status: ServerStatus;
cpuPercent: number;
memoryBytes: number;
diskBytes: number;
networkRxBytes: number;
networkTxBytes: number;
uptimeMs: number;
/** Unix ms the frame arrived. */
at: number;
};
export type ConsoleEvent =
| { type: 'line'; line: ConsoleLine }
| { type: 'status'; status: ServerStatus }
| { type: 'stats'; stats: LiveStats };
/**
* A live feed of a game server's console, status and resource usage.
*
* The panel previously reconstructed "live" output by repeatedly downloading
* the *game's* log file, which meant nothing was visible until the game itself
* had started and created that file the install, update and mod download
* phases were invisible. A hub instead carries whatever the hosting backend is
* emitting, in real time.
*/
export interface ConsoleHub {
start(): void;
stop(): Promise<void>;
/** Returns an unsubscribe function. */
subscribe(listener: (event: ConsoleEvent) => void): () => void;
/** Recent lines plus current state, so a new viewer sees context instantly. */
backlog(): ConsoleBacklog;
latestStats(): LiveStats | null;
latestStatus(): ServerStatus;
}
const MAX_BACKLOG_LINES = 2_000;
/**
* Wings colourises console output with ANSI escapes; the panel styles lines
* itself, so they are stripped before they reach the browser. Built from a
* char code rather than a literal escape to keep this file plain ASCII.
*/
const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;?]*[A-Za-z]`, 'g');
export function stripAnsi(value: string): string {
return value.replace(ANSI_PATTERN, '');
}
/**
* Shared line buffer, subscriber fan-out and state tracking. Both the real
* Wings hub and the mock hub build on this.
*/
export abstract class BaseConsoleHub implements ConsoleHub {
private readonly listeners = new Set<(event: ConsoleEvent) => void>();
private readonly lines: ConsoleLine[] = [];
private seq = 0;
private status: ServerStatus = 'unknown';
private stats: LiveStats | null = null;
protected connected = false;
abstract start(): void;
abstract stop(): Promise<void>;
subscribe(listener: (event: ConsoleEvent) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
backlog(): ConsoleBacklog {
return { lines: [...this.lines], status: this.status, connected: this.connected };
}
latestStats(): LiveStats | null {
return this.stats;
}
latestStatus(): ServerStatus {
return this.status;
}
private emit(event: ConsoleEvent): void {
for (const listener of this.listeners) {
try {
listener(event);
} catch {
// A broken subscriber must never take the upstream feed down.
}
}
}
/** Splits on newlines and pushes each non-empty line into the ring buffer. */
protected pushOutput(stream: ConsoleLineStream, chunk: string): void {
const at = Date.now();
for (const raw of stripAnsi(chunk).split(/\r?\n/)) {
const text = raw.replace(/\r/g, '').trimEnd();
if (!text) continue;
const line: ConsoleLine = { seq: ++this.seq, stream, text, at };
this.lines.push(line);
this.emit({ type: 'line', line });
}
if (this.lines.length > MAX_BACKLOG_LINES) {
this.lines.splice(0, this.lines.length - MAX_BACKLOG_LINES);
}
}
protected setStatus(status: ServerStatus): void {
if (this.status === status) return;
this.status = status;
this.emit({ type: 'status', status });
}
protected setStats(stats: LiveStats): void {
this.stats = stats;
// Wings reports the state alongside every stats frame; trust it.
this.setStatus(stats.status);
this.emit({ type: 'stats', stats });
}
protected setConnected(connected: boolean): void {
this.connected = connected;
}
}
@@ -0,0 +1,106 @@
import type { ServerStatus } from '@reforger-panel/shared';
import { BaseConsoleHub } from './console-hub.js';
import type { MockGameServerProvider } from './mock-provider.js';
const STATS_INTERVAL_MS = 2_000;
/**
* Simulates the Wings feed so the whole panel live console, status strip and
* resource graphs works under USE_MOCK_PTERODACTYL. The startup script
* deliberately includes the update and mod-download phase, since that is the
* part the real websocket exists to surface.
*/
const BOOT_SCRIPT: { delayMs: number; stream: 'console' | 'install' | 'daemon'; text: string }[] = [
{
delayMs: 100,
stream: 'daemon',
text: 'Pulling Docker container image, ensuring it is up to date.',
},
{ delayMs: 600, stream: 'daemon', text: 'Finished pulling Docker container image.' },
{ delayMs: 900, stream: 'console', text: 'Redirecting stderr to stdout.' },
{
delayMs: 1200,
stream: 'console',
text: 'Update state (0x5) verifying install, progress: 46.12 (1039 / 2253)',
},
{ delayMs: 1800, stream: 'console', text: 'Success! App "1874900" fully installed.' },
{
delayMs: 2200,
stream: 'console',
text: 'Downloading workshop mod 591AF5BDA9F7CE8B (Mock Sample Mod)',
},
{
delayMs: 2900,
stream: 'console',
text: 'Mod 591AF5BDA9F7CE8B downloaded (4.2 MiB), verifying.',
},
{
delayMs: 3300,
stream: 'console',
text: 'ENGINE : Enfusion engine build: 1.3.0.42 (mock)',
},
{ delayMs: 3700, stream: 'console', text: 'DEFAULT : Loading world.' },
{ delayMs: 4100, stream: 'console', text: 'DEFAULT : Game successfully created.' },
{ delayMs: 4300, stream: 'console', text: 'NETWORK : Server is ready to accept connections' },
];
export class MockConsoleHub extends BaseConsoleHub {
private statsTimer: ReturnType<typeof setInterval> | null = null;
private bootTimers: ReturnType<typeof setTimeout>[] = [];
private unsubscribe: (() => void) | null = null;
constructor(private readonly provider: MockGameServerProvider) {
super();
}
start(): void {
if (this.statsTimer) return;
this.setConnected(true);
this.setStatus(this.provider.currentStatus);
this.unsubscribe = this.provider.onStatusChange((status) => this.onStatusChange(status));
this.statsTimer = setInterval(() => void this.pushStats(), STATS_INTERVAL_MS);
this.statsTimer.unref?.();
void this.pushStats();
this.pushOutput('daemon', 'Attached to mock Pterodactyl console.');
}
async stop(): Promise<void> {
if (this.statsTimer) clearInterval(this.statsTimer);
this.statsTimer = null;
for (const timer of this.bootTimers) clearTimeout(timer);
this.bootTimers = [];
this.unsubscribe?.();
this.unsubscribe = null;
this.setConnected(false);
}
private onStatusChange(status: ServerStatus): void {
this.setStatus(status);
if (status === 'starting') this.playBootScript();
if (status === 'stopping') this.pushOutput('daemon', 'Stopping server container.');
if (status === 'offline') this.pushOutput('daemon', 'Server marked as offline.');
}
private playBootScript(): void {
for (const timer of this.bootTimers) clearTimeout(timer);
this.bootTimers = BOOT_SCRIPT.map((step) => {
const timer = setTimeout(() => this.pushOutput(step.stream, step.text), step.delayMs);
timer.unref?.();
return timer;
});
}
private async pushStats(): Promise<void> {
const resources = await this.provider.getServerResources();
this.setStats({
status: resources.status,
cpuPercent: resources.cpuPercent,
memoryBytes: resources.memoryBytes,
diskBytes: resources.diskBytes,
networkRxBytes: resources.networkRxBytes,
networkTxBytes: resources.networkTxBytes,
uptimeMs: resources.uptimeMs,
at: Date.now(),
});
}
}
@@ -7,6 +7,7 @@ import { ApiError } from '../../lib/errors.js';
import type {
DownloadableFile,
GameServerProvider,
ProviderServerLimits,
ProviderServerResources,
ServerFileEntry,
} from './types.js';
@@ -138,11 +139,11 @@ export class MockGameServerProvider implements GameServerProvider {
}
private transition(to: ServerStatus, after: number, thenTo: ServerStatus) {
this.status = to;
this.setStatus(to);
if (this.transitionTimer) clearTimeout(this.transitionTimer);
this.transitionTimer = setTimeout(() => {
this.status = thenTo;
if (thenTo === 'online') this.startedAt = Date.now();
this.setStatus(thenTo);
this.transitionTimer = null;
}, after);
this.transitionTimer.unref?.();
@@ -152,6 +153,32 @@ export class MockGameServerProvider implements GameServerProvider {
return this.status;
}
/** Exposed so the panel can subscribe to simulated power transitions. */
get currentStatus(): ServerStatus {
return this.status;
}
onStatusChange(listener: (status: ServerStatus) => void): () => void {
this.statusListeners.add(listener);
return () => this.statusListeners.delete(listener);
}
private readonly statusListeners = new Set<(status: ServerStatus) => void>();
private setStatus(status: ServerStatus): void {
if (this.status === status) return;
this.status = status;
for (const listener of this.statusListeners) listener(status);
}
async getServerLimits(): Promise<ProviderServerLimits> {
return {
cpuLimitPercent: 400,
memoryLimitBytes: 8 * 1024 ** 3,
diskLimitBytes: 40 * 1024 ** 3,
};
}
async getServerResources(): Promise<ProviderServerResources> {
const online = this.status === 'online';
const wobble = (base: number, spread: number) => base + (Math.random() - 0.5) * spread;
@@ -183,8 +210,8 @@ export class MockGameServerProvider implements GameServerProvider {
this.transition('stopping', STOP_DELAY_MS, 'starting');
setTimeout(() => {
if (this.status === 'starting') {
this.status = 'online';
this.startedAt = Date.now();
this.setStatus('online');
}
}, STOP_DELAY_MS + START_DELAY_MS).unref?.();
}
@@ -215,6 +242,30 @@ export class MockGameServerProvider implements GameServerProvider {
}
private startupVariables = [
{
name: 'Server Name',
description: 'Templated into config.json at boot by the egg.',
envVariable: 'SERVER_NAME',
serverValue: 'Mock Reforger Server',
defaultValue: 'Arma Reforger Server',
isEditable: true,
},
{
name: 'Max Players',
description: 'Templated into config.json at boot by the egg.',
envVariable: 'MAX_PLAYERS',
serverValue: '16',
defaultValue: '64',
isEditable: true,
},
{
name: 'Scenario ID',
description: 'Templated into config.json at boot by the egg.',
envVariable: 'SCENARIO_ID',
serverValue: '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf',
defaultValue: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
isEditable: true,
},
{
name: 'Server Password',
description: 'Password required to join the server.',
@@ -5,9 +5,11 @@ import type {
ServerStatus,
} from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import { mapPowerState } from './console-hub.js';
import type {
DownloadableFile,
GameServerProvider,
ProviderServerLimits,
ProviderServerResources,
ServerFileEntry,
} from './types.js';
@@ -15,6 +17,8 @@ import type {
const DEFAULT_TIMEOUT_MS = 10_000;
const DOWNLOAD_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024;
/** Plan limits change rarely, but not never; a minute keeps them honest. */
const LIMITS_CACHE_TTL_MS = 60_000;
type PterodactylOptions = {
baseUrl: string;
@@ -60,21 +64,6 @@ type PterodactylScheduleAttributes = {
};
};
function mapState(state: string): ServerStatus {
switch (state) {
case 'running':
return 'online';
case 'offline':
return 'offline';
case 'starting':
return 'starting';
case 'stopping':
return 'stopping';
default:
return 'unknown';
}
}
/**
* Pterodactyl Client API provider. Uses only client-scoped endpoints (status,
* resources, power, read-only file access). Errors are sanitized: they carry
@@ -143,9 +132,9 @@ export class PterodactylProvider implements GameServerProvider {
}
}
private async getLimits(serverId: string) {
async getServerLimits(serverId: string): Promise<ProviderServerLimits> {
const cached = this.limitsCache.get(serverId);
if (cached && Date.now() - cached.fetchedAt < 5 * 60_000) return cached;
if (cached && Date.now() - cached.fetchedAt < LIMITS_CACHE_TTL_MS) return cached;
const data = await this.request<{
attributes?: { limits?: { cpu?: number; memory?: number; disk?: number } };
}>('server details', `/servers/${encodeURIComponent(serverId)}`);
@@ -182,14 +171,14 @@ export class PterodactylProvider implements GameServerProvider {
const attrs = data.attributes ?? {};
const res = attrs.resources ?? {};
const limits = await this.getLimits(serverId).catch(() => ({
const limits = await this.getServerLimits(serverId).catch(() => ({
cpuLimitPercent: null,
memoryLimitBytes: null,
diskLimitBytes: null,
}));
return {
status: mapState(attrs.current_state ?? 'unknown'),
status: mapPowerState(attrs.current_state),
cpuPercent: res.cpu_absolute ?? 0,
cpuLimitPercent: limits.cpuLimitPercent,
memoryBytes: res.memory_bytes ?? 0,
+12
View File
@@ -4,6 +4,12 @@ import type {
ServerStatus,
} from '@reforger-panel/shared';
export type ProviderServerLimits = {
cpuLimitPercent: number | null;
memoryLimitBytes: number | null;
diskLimitBytes: number | null;
};
export type ProviderServerResources = {
status: ServerStatus;
cpuPercent: number;
@@ -42,6 +48,12 @@ export type DownloadableFile = {
export interface GameServerProvider {
getServerStatus(serverId: string): Promise<ServerStatus>;
getServerResources(serverId: string): Promise<ProviderServerResources>;
/**
* Plan limits, cached by the implementation. Split out from resources so a
* live websocket stats frame (which carries usage but not limits) can still
* be rendered with a denominator.
*/
getServerLimits(serverId: string): Promise<ProviderServerLimits>;
startServer(serverId: string): Promise<void>;
stopServer(serverId: string): Promise<void>;
@@ -0,0 +1,240 @@
import WebSocket from 'ws';
import type { Logger } from '../../lib/logger.js';
import { BaseConsoleHub, mapPowerState, type LiveStats } from './console-hub.js';
type WebsocketCredentials = { token: string; socket: string };
type WingsMessage = { event?: string; args?: unknown[] };
const CREDENTIAL_TIMEOUT_MS = 10_000;
const RECONNECT_BASE_MS = 1_000;
const RECONNECT_MAX_MS = 30_000;
/** Wings tokens last 10 minutes; refresh well before the warning arrives. */
const TOKEN_REFRESH_MS = 8 * 60_000;
/**
* Keeps one authenticated Wings websocket open for the server and republishes
* everything it emits: console output (including the SteamCMD/mod-download
* phase that never reaches the game's own log file), install output, power
* state transitions, and resource frames.
*
* One upstream connection is shared by every panel viewer, and the base class
* keeps a line backlog so a browser attaching mid-session immediately sees
* context instead of an empty pane.
*/
export class WingsConsoleHub extends BaseConsoleHub {
private socket: WebSocket | null = null;
private stopped = true;
private reconnectAttempt = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private refreshTimer: ReturnType<typeof setInterval> | null = null;
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
constructor(
private readonly options: {
baseUrl: string;
apiKey: string;
serverId: string;
logger: Logger;
fetchImpl?: typeof fetch;
},
) {
super();
this.baseUrl = options.baseUrl.replace(/\/$/, '');
this.fetchImpl = options.fetchImpl ?? fetch;
}
start(): void {
if (!this.stopped) return;
this.stopped = false;
this.refreshTimer = setInterval(() => void this.reauthenticate(), TOKEN_REFRESH_MS);
this.refreshTimer.unref?.();
void this.connect();
}
async stop(): Promise<void> {
this.stopped = true;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.refreshTimer) clearInterval(this.refreshTimer);
this.reconnectTimer = null;
this.refreshTimer = null;
const socket = this.socket;
this.socket = null;
this.setConnected(false);
socket?.close();
}
/** Fetches a fresh websocket token from the Pterodactyl client API. */
private async fetchCredentials(): Promise<WebsocketCredentials> {
const url = `${this.baseUrl}/api/client/servers/${encodeURIComponent(
this.options.serverId,
)}/websocket`;
const response = await this.fetchImpl(url, {
headers: {
Authorization: `Bearer ${this.options.apiKey}`,
Accept: 'application/json',
},
signal: AbortSignal.timeout(CREDENTIAL_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`websocket credentials returned HTTP ${response.status}`);
}
const body = (await response.json()) as { data?: { token?: string; socket?: string } };
const token = body.data?.token;
const socket = body.data?.socket;
if (!token || !socket) throw new Error('websocket credentials response was incomplete');
return { token, socket };
}
private scheduleReconnect(): void {
if (this.stopped || this.reconnectTimer) return;
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** this.reconnectAttempt);
this.reconnectAttempt = Math.min(this.reconnectAttempt + 1, 5);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
void this.connect();
}, delay);
this.reconnectTimer.unref?.();
}
private async connect(): Promise<void> {
if (this.stopped || this.socket) return;
let credentials: WebsocketCredentials;
try {
credentials = await this.fetchCredentials();
} catch (error) {
this.options.logger.warn(
{ err: error instanceof Error ? error.message : String(error) },
'wings websocket credentials failed',
);
this.scheduleReconnect();
return;
}
if (this.stopped) return;
// Wings only upgrades connections whose Origin matches the panel URL
// (see wings router/websocket GetHandler.CheckOrigin), which is why this
// uses `ws` rather than Node's built-in WebSocket.
const socket = new WebSocket(credentials.socket, { origin: this.baseUrl });
this.socket = socket;
socket.on('open', () => {
this.reconnectAttempt = 0;
this.setConnected(true);
this.send(socket, 'auth', credentials.token);
});
socket.on('message', (raw: WebSocket.RawData) => this.handleMessage(String(raw)));
socket.on('error', (error: Error) => {
this.options.logger.debug({ err: error.message }, 'wings websocket error');
});
socket.on('close', () => {
if (this.socket === socket) this.socket = null;
this.setConnected(false);
this.scheduleReconnect();
});
}
private send(socket: WebSocket, event: string, ...args: string[]): void {
if (socket.readyState !== WebSocket.OPEN) return;
socket.send(JSON.stringify({ event, args }));
}
/** Re-auths the live socket with a fresh token before the old one expires. */
private async reauthenticate(): Promise<void> {
const socket = this.socket;
if (!socket || socket.readyState !== WebSocket.OPEN) return;
try {
const { token } = await this.fetchCredentials();
this.send(socket, 'auth', token);
} catch (error) {
this.options.logger.debug(
{ err: error instanceof Error ? error.message : String(error) },
'wings token refresh failed',
);
}
}
private handleMessage(raw: string): void {
let message: WingsMessage;
try {
message = JSON.parse(raw) as WingsMessage;
} catch {
return;
}
const arg = typeof message.args?.[0] === 'string' ? (message.args[0] as string) : '';
switch (message.event) {
case 'auth success':
// Ask for the recent backlog and an immediate resource frame so the
// panel is populated the moment it attaches.
if (this.socket) {
this.send(this.socket, 'send logs');
this.send(this.socket, 'send stats');
}
break;
case 'console output':
this.pushOutput('console', arg);
break;
case 'install output':
this.pushOutput('install', arg);
break;
case 'install started':
this.pushOutput('daemon', 'Installation started.');
break;
case 'install completed':
this.pushOutput('daemon', 'Installation completed.');
break;
case 'daemon message':
this.pushOutput('daemon', arg);
break;
case 'daemon error':
case 'jwt error':
this.pushOutput('daemon', arg);
void this.reauthenticate();
break;
case 'token expiring':
case 'token expired':
void this.reauthenticate();
break;
case 'status':
this.setStatus(mapPowerState(arg));
break;
case 'stats':
this.handleStats(arg);
break;
default:
break;
}
}
private handleStats(payload: string): void {
let parsed: {
state?: string;
cpu_absolute?: number;
memory_bytes?: number;
disk_bytes?: number;
uptime?: number;
network?: { rx_bytes?: number; tx_bytes?: number };
};
try {
parsed = JSON.parse(payload) as typeof parsed;
} catch {
return;
}
const stats: LiveStats = {
status: mapPowerState(parsed.state),
cpuPercent: parsed.cpu_absolute ?? 0,
memoryBytes: parsed.memory_bytes ?? 0,
diskBytes: parsed.disk_bytes ?? 0,
networkRxBytes: parsed.network?.rx_bytes ?? 0,
networkTxBytes: parsed.network?.tx_bytes ?? 0,
uptimeMs: parsed.uptime ?? 0,
at: Date.now(),
};
this.setStats(stats);
}
}
@@ -1,10 +1,13 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import type { WorkshopModDetail, WorkshopScenario } from '@reforger-panel/shared';
import type { WorkshopCache } from '../workshop/workshop-cache.js';
import {
hasScenarioTag,
mergeMissions,
MissionsService,
OFFICIAL_MISSIONS,
OFFICIAL_SCENARIO_IDS,
parseMissionList,
scenariosFromWorkshopMod,
} from './missions-catalog.js';
import type { MissionCatalog, ParsedMission } from './missions-catalog.js';
// Verbatim shape from a real console.log (server runs with -listScenarios).
const LOG = [
@@ -23,12 +26,12 @@ describe('parseMissionList', () => {
it('parses scenario ids, display names, and section sources', () => {
const missions = parseMissionList(LOG);
expect(missions).toHaveLength(4);
expect(missions[0]).toEqual({
expect(missions[0]).toMatchObject({
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
name: 'Conflict - Everon',
source: 'official',
});
expect(missions[3]).toEqual({
expect(missions[3]).toMatchObject({
scenarioId: '{ABCDEF0123456789}Missions/CustomOps.conf',
name: 'Custom Ops',
source: 'workshop',
@@ -36,8 +39,7 @@ describe('parseMissionList', () => {
});
it('deduplicates repeated listings (multiple boots in one file)', () => {
const missions = parseMissionList(`${LOG}\n${LOG}`);
expect(missions).toHaveLength(4);
expect(parseMissionList(`${LOG}\n${LOG}`)).toHaveLength(4);
});
it('returns an empty list when no listing is present', () => {
@@ -45,62 +47,128 @@ describe('parseMissionList', () => {
});
});
describe('workshop scenario helpers', () => {
it('recognizes scenario tag variants from the workshop', () => {
expect(hasScenarioTag(['SCENARIOS_MP'])).toBe(true);
expect(hasScenarioTag(['scenario sp'])).toBe(true);
expect(hasScenarioTag(['WEAPONS'])).toBe(false);
describe('OFFICIAL_MISSIONS', () => {
it('covers the vanilla scenarios with well-formed ids', () => {
expect(OFFICIAL_MISSIONS.length).toBeGreaterThan(15);
for (const mission of OFFICIAL_MISSIONS) {
expect(mission.scenarioId).toMatch(/^\{[0-9A-F]{16}\}Missions\/.+\.conf$/);
expect(mission.name).not.toMatch(/^#AR-/);
}
expect(OFFICIAL_SCENARIO_IDS.has('{ECC61978EDCC2B5A}Missions/23_Campaign.conf')).toBe(true);
});
});
it('converts mod scenarios into mission entries', () => {
const missions = scenariosFromWorkshopMod({
id: 'ABC',
name: 'Scenario Pack',
author: 'Author',
imageUrl: null,
size: null,
rating: null,
workshopUrl: null,
version: null,
gameVersion: null,
subscribers: null,
downloads: null,
createdAtText: null,
lastModifiedText: null,
summary: null,
description: null,
license: null,
tags: [],
dependencies: [],
scenarios: [
function scenario(id: string, name: string): WorkshopScenario {
return {
scenarioId: id,
name,
gameMode: 'Campaign',
author: null,
description: null,
playerCount: 64,
};
}
function fakeWorkshop(mods: Record<string, { name: string; scenarios: WorkshopScenario[] }>) {
return {
tryGetMod: vi.fn(async (id: string) => {
const mod = mods[id];
if (!mod) return null;
return {
id,
name: mod.name,
scenarioCount: mod.scenarios.length,
scenarios: mod.scenarios,
} as unknown as WorkshopModDetail;
}),
getScenarios: vi.fn(async (id: string) => mods[id]?.scenarios ?? []),
} as unknown as WorkshopCache;
}
function fakeCatalog(missions: ParsedMission[]): MissionCatalog {
return {
list: async () => ({ missions, fetchedAt: new Date().toISOString() }),
} as MissionCatalog;
}
describe('MissionsService', () => {
it('always offers the vanilla scenarios as one group', async () => {
const service = new MissionsService(fakeWorkshop({}), null);
const result = await service.list([]);
const official = result.groups.find((group) => group.id === 'official')!;
expect(official.kind).toBe('official');
expect(official.missions.length).toBeGreaterThan(15);
});
it('adds one group per installed mod that ships scenarios', async () => {
const workshop = fakeWorkshop({
AAAA000000000001: {
name: 'Scenario Pack',
scenarios: [scenario('{1111111111111111}Missions/RaidNight.conf', 'Raid Night')],
},
BBBB000000000002: { name: 'Weapons Only', scenarios: [] },
});
const service = new MissionsService(workshop, null);
const result = await service.list([
{ modId: 'aaaa000000000001' },
{ modId: 'BBBB000000000002' },
]);
const modGroups = result.groups.filter((group) => group.kind === 'mod');
expect(modGroups).toHaveLength(1);
expect(modGroups[0]!.label).toBe('Scenario Pack');
expect(modGroups[0]!.missions[0]).toEqual({
scenarioId: '{1111111111111111}Missions/RaidNight.conf',
name: 'Raid Night',
gameMode: 'Campaign',
playerCount: 64,
});
});
it('reports mods it could not resolve rather than silently dropping them', async () => {
const service = new MissionsService(fakeWorkshop({}), null);
const result = await service.list([{ modId: 'CCCC000000000003' }]);
expect(result.incompleteModIds).toEqual(['CCCC000000000003']);
});
it("prefers the server's own naming for official scenarios", async () => {
const service = new MissionsService(
fakeWorkshop({}),
fakeCatalog([
{
name: 'Raid Night',
description: null,
scenarioId: '{1111111111111111}Missions/RaidNight.conf',
gamemode: 'Coop',
playerCount: 32,
imageUrl: null,
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
name: 'Conflict - Everon (from log)',
gameMode: null,
playerCount: null,
source: 'official',
},
],
});
expect(missions).toEqual([
{
scenarioId: '{1111111111111111}Missions/RaidNight.conf',
name: 'Raid Night',
source: 'mod: Scenario Pack',
},
]);
]),
);
const official = (await service.list([])).groups.find((group) => group.id === 'official')!;
const everon = official.missions.find(
(mission) => mission.scenarioId === '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
)!;
expect(everon.name).toBe('Conflict - Everon (from log)');
// Only listed once, despite also being in the bundled set.
expect(
official.missions.filter((mission) => mission.scenarioId === everon.scenarioId),
).toHaveLength(1);
});
it('deduplicates mission groups while preserving first source', () => {
const merged = mergeMissions(
[{ scenarioId: 'same', name: 'From Log', source: 'workshop' }],
[{ scenarioId: 'same', name: 'From Mod', source: 'mod: Pack' }],
[{ scenarioId: 'other', name: 'Other', source: 'mod: Pack' }],
it('surfaces log-reported scenarios that no mod group covers', async () => {
const service = new MissionsService(
fakeWorkshop({}),
fakeCatalog([
{
scenarioId: '{ABCDEF0123456789}Missions/CustomOps.conf',
name: 'Custom Ops',
gameMode: null,
playerCount: null,
source: 'workshop',
},
]),
);
expect(merged).toEqual([
{ scenarioId: 'same', name: 'From Log', source: 'workshop' },
{ scenarioId: 'other', name: 'Other', source: 'mod: Pack' },
]);
const fallback = (await service.list([])).groups.find((group) => group.id === 'server-log')!;
expect(fallback.missions[0]!.name).toBe('Custom Ops');
});
});
@@ -1,17 +1,156 @@
import type { MissionInfo, MissionsResponse, WorkshopModDetail } from '@reforger-panel/shared';
import type {
MissionGroup,
MissionInfo,
MissionsResponse,
ReforgerConfigMod,
} from '@reforger-panel/shared';
import type { WorkshopCache } from '../workshop/workshop-cache.js';
import type { GameServerProvider } from '../pterodactyl/types.js';
import type { LogPathResolver } from './ingestion/log-path-resolver.js';
const CATALOG_TTL_MS = 10 * 60 * 1000;
const CATALOG_MAX_BYTES = 2 * 1024 * 1024;
export const DEFAULT_SCENARIO_ID = '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf';
export const DEFAULT_MISSION: MissionInfo = {
scenarioId: DEFAULT_SCENARIO_ID,
name: 'Campaign - Montignac',
source: 'official',
};
const SCENARIO_TAGS = new Set(['scenario', 'scenario mp', 'scenario sp']);
/** Kept as the "known-good fallback" target for orphaned-mission recovery. */
export const DEFAULT_SCENARIO_ID = '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf';
/**
* Scenarios that ship with the base game.
*
* Compiled from the scenario ids live vanilla servers actually report (i.e.
* servers whose scenario is not provided by a Workshop mod), so the list
* reflects the shipped game rather than guesswork. It is a seed, not a closed
* set: anything the server itself prints at boot with `-listScenarios` is
* merged on top, so a game update that adds a mission still shows up.
*/
export const OFFICIAL_MISSIONS: readonly MissionInfo[] = [
{
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
name: 'Conflict - Everon',
gameMode: 'Conflict',
playerCount: 128,
},
{
scenarioId: '{C41618FD18E9D714}Missions/23_Campaign_Arland.conf',
name: 'Conflict - Arland',
gameMode: 'Conflict',
playerCount: 128,
},
{
scenarioId: '{9C6054B42A044DEC}Missions/23_Campaign_Cain.conf',
name: 'Conflict - Kolguyev',
gameMode: 'Conflict',
playerCount: 128,
},
{
scenarioId: '{28802845ADA64D52}Missions/23_Campaign_NorthCentral.conf',
name: 'Conflict - Northern Everon',
gameMode: 'Conflict',
playerCount: 64,
},
{
scenarioId: DEFAULT_SCENARIO_ID,
name: 'Conflict - Montignac',
gameMode: 'Conflict',
playerCount: 64,
},
{
scenarioId: '{0220741028718E7F}Missions/23_Campaign_HQC_Everon.conf',
name: 'Commander - Everon',
gameMode: 'Commander',
playerCount: 128,
},
{
scenarioId: '{68D1240A11492545}Missions/23_Campaign_HQC_Arland.conf',
name: 'Commander - Arland',
gameMode: 'Commander',
playerCount: 128,
},
{
scenarioId: '{BB5345C22DD2B655}Missions/23_Campaign_HQC_Cain.conf',
name: 'Commander - Kolguyev',
gameMode: 'Commander',
playerCount: 128,
},
{
scenarioId: '{DAA03C6E6099D50F}Missions/24_CombatOps.conf',
name: 'Combat Ops - Arland',
gameMode: 'Combat Ops',
playerCount: 16,
},
{
scenarioId: '{DFAC5FABD11F2390}Missions/26_CombatOpsEveron.conf',
name: 'Combat Ops - Everon',
gameMode: 'Combat Ops',
playerCount: 16,
},
{
scenarioId: '{CB347F2F10065C9C}Missions/CombatOpsCain.conf',
name: 'Combat Ops - Kolguyev',
gameMode: 'Combat Ops',
playerCount: 16,
},
{
scenarioId: '{59AD59368755F41A}Missions/21_GM_Eden.conf',
name: 'Game Master - Everon',
gameMode: 'Game Master',
playerCount: 64,
},
{
scenarioId: '{2BBBE828037C6F4B}Missions/22_GM_Arland.conf',
name: 'Game Master - Arland',
gameMode: 'Game Master',
playerCount: 64,
},
{
scenarioId: '{F45C6C15D31252E6}Missions/27_GM_Cain.conf',
name: 'Game Master - Kolguyev',
gameMode: 'Game Master',
playerCount: 64,
},
{
scenarioId: '{3F2E005F43DBD2F8}Missions/CAH_Briars_Coast.conf',
name: 'Capture & Hold - The Briars',
gameMode: 'Capture & Hold',
playerCount: 32,
},
{
scenarioId: '{589945FB9FA7B97D}Missions/CAH_Concrete_Plant.conf',
name: 'Capture & Hold - Concrete Plant',
gameMode: 'Capture & Hold',
playerCount: 32,
},
{
scenarioId: '{9405201CBD22A30C}Missions/CAH_Factory.conf',
name: 'Capture & Hold - Almara Factory',
gameMode: 'Capture & Hold',
playerCount: 32,
},
{
scenarioId: '{1CD06B409C6FAE56}Missions/CAH_Forest.conf',
name: "Capture & Hold - Simon's Wood",
gameMode: 'Capture & Hold',
playerCount: 32,
},
{
scenarioId: '{7C491B1FCC0FF0E1}Missions/CAH_LeMoule.conf',
name: 'Capture & Hold - Le Moule',
gameMode: 'Capture & Hold',
playerCount: 32,
},
{
scenarioId: '{2B4183DF23E88249}Missions/CAH_Morton.conf',
name: 'Capture & Hold - Morton',
gameMode: 'Capture & Hold',
playerCount: 32,
},
];
export const OFFICIAL_SCENARIO_IDS = new Set(
OFFICIAL_MISSIONS.map((mission) => mission.scenarioId),
);
export type ParsedMission = MissionInfo & { source: string };
/**
* Scenario listing printed at boot when the server runs with -listScenarios
@@ -22,8 +161,8 @@ const SCENARIO_TAGS = new Set(['scenario', 'scenario mp', 'scenario sp']);
const SECTION_PATTERN = /SCRIPT\s*:\s*(.+ scenarios) \(\d+ entr/i;
const MISSION_PATTERN = /SCRIPT\s*:\s*(\{[0-9A-Fa-f]{16}\}\S+\.conf)(?:\s+\((.+)\))?\s*$/;
export function parseMissionList(logContent: string): MissionInfo[] {
const missions: MissionInfo[] = [];
export function parseMissionList(logContent: string): ParsedMission[] {
const missions: ParsedMission[] = [];
const seen = new Set<string>();
let currentSource = 'official';
for (const line of logContent.split('\n')) {
@@ -38,6 +177,8 @@ export function parseMissionList(logContent: string): MissionInfo[] {
missions.push({
scenarioId: mission[1]!,
name: mission[2] ?? mission[1]!.slice(mission[1]!.lastIndexOf('/') + 1),
gameMode: null,
playerCount: null,
source: currentSource,
});
}
@@ -45,47 +186,13 @@ export function parseMissionList(logContent: string): MissionInfo[] {
return missions;
}
export function scenariosFromWorkshopMod(mod: WorkshopModDetail): MissionInfo[] {
return mod.scenarios.map((scenario) => ({
scenarioId: scenario.scenarioId,
name: scenario.name,
source: `mod: ${mod.name}`,
}));
}
function normalizeTag(tag: string): string {
return tag
.trim()
.toLowerCase()
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.replace(/^scenarios\b/, 'scenario');
}
export function hasScenarioTag(tags: string[]): boolean {
return tags.some((tag) => SCENARIO_TAGS.has(normalizeTag(tag)));
}
export function mergeMissions(...groups: MissionInfo[][]): MissionInfo[] {
const merged: MissionInfo[] = [];
const seen = new Set<string>();
for (const group of groups) {
for (const mission of group) {
if (seen.has(mission.scenarioId)) continue;
seen.add(mission.scenarioId);
merged.push(mission);
}
}
return merged;
}
/**
* Extracts the available-missions dropdown data from the server's current
* Extracts the scenarios the server itself reported at boot from its
* console.log. Cached briefly; a fresh boot log always carries the listing
* near the top, so the head of the file is enough.
*/
export class MissionCatalog {
private cache: { missions: MissionInfo[]; fetchedAt: string; expiresAt: number } | null = null;
private cache: { missions: ParsedMission[]; fetchedAt: string; expiresAt: number } | null = null;
constructor(
private readonly provider: GameServerProvider,
@@ -93,7 +200,7 @@ export class MissionCatalog {
private readonly providerServerId: string,
) {}
async list(force = false): Promise<MissionsResponse> {
async list(force = false): Promise<{ missions: ParsedMission[]; fetchedAt: string | null }> {
if (!force && this.cache && this.cache.expiresAt > Date.now()) {
return { missions: this.cache.missions, fetchedAt: this.cache.fetchedAt };
}
@@ -121,3 +228,106 @@ export class MissionCatalog {
return { missions: [], fetchedAt: null };
}
}
function dedupe(missions: MissionInfo[]): MissionInfo[] {
const seen = new Set<string>();
const result: MissionInfo[] = [];
for (const mission of missions) {
if (seen.has(mission.scenarioId)) continue;
seen.add(mission.scenarioId);
result.push(mission);
}
return result;
}
/**
* Builds the mission picker: one list of the vanilla scenarios, then one group
* per installed mod that ships scenarios.
*
* Scenario ids come from the Workshop v2 `scenarios[].gameId` field, which is
* an actual id rather than something scraped out of prose, and a mod is asked
* for scenarios based on its reported `scenarioCount` rather than a tag
* heuristic so mods that ship missions without tagging themselves as
* scenario mods are no longer missed.
*/
export class MissionsService {
constructor(
private readonly workshop: WorkshopCache,
private readonly catalog: MissionCatalog | null,
) {}
async list(installedMods: readonly ReforgerConfigMod[]): Promise<MissionsResponse> {
const fromLog = this.catalog
? await this.catalog.list().catch(() => ({ missions: [], fetchedAt: null }))
: { missions: [] as ParsedMission[], fetchedAt: null };
const logOfficial = fromLog.missions.filter((mission) => mission.source === 'official');
const logOther = fromLog.missions.filter((mission) => mission.source !== 'official');
const groups: MissionGroup[] = [
{
id: 'official',
label: 'Official (vanilla)',
kind: 'official',
// The server's own listing wins on naming; the bundled set fills in
// whatever a rotated log no longer mentions.
missions: dedupe([...logOfficial, ...OFFICIAL_MISSIONS]).sort((a, b) =>
a.name.localeCompare(b.name),
),
},
];
const incompleteModIds: string[] = [];
const modIds = installedMods.map((mod) => mod.modId.toUpperCase());
const details = await Promise.all(modIds.map((id) => this.workshop.tryGetMod(id)));
for (let index = 0; index < modIds.length; index += 1) {
const modId = modIds[index]!;
const detail = details[index];
if (!detail) {
incompleteModIds.push(modId);
continue;
}
if (detail.scenarioCount === 0) continue;
const scenarios = await this.workshop.getScenarios(modId);
if (scenarios.length === 0) {
// Reported scenarios we could not enumerate — say so rather than
// silently showing a shorter list than the server has.
if (detail.scenarioCount > 0) incompleteModIds.push(modId);
continue;
}
groups.push({
id: modId,
label: detail.name,
kind: 'mod',
missions: scenarios.map((scenario) => ({
scenarioId: scenario.scenarioId,
name: scenario.name,
gameMode: scenario.gameMode,
playerCount: scenario.playerCount,
})),
});
}
// Anything the server reported that no group covers (mods the Workshop
// does not know about, hand-installed missions).
const covered = new Set(
groups.flatMap((group) => group.missions.map((mission) => mission.scenarioId)),
);
const uncovered = logOther.filter((mission) => !covered.has(mission.scenarioId));
if (uncovered.length > 0) {
groups.push({
id: 'server-log',
label: 'Reported by the server',
kind: 'mod',
missions: dedupe(uncovered),
});
}
return {
groups: groups.filter((group) => group.missions.length > 0),
incompleteModIds,
fetchedAt: new Date().toISOString(),
};
}
}
@@ -0,0 +1,68 @@
import type { ServerResources, ServerStatus } from '@reforger-panel/shared';
import type { ConsoleHub } from '../pterodactyl/console-hub.js';
import type { GameServerProvider, ProviderServerLimits } from '../pterodactyl/types.js';
/**
* A Wings `stats` frame arrives roughly every two seconds. Anything older than
* this is treated as gone stale and we fall back to the REST endpoint.
*/
const LIVE_MAX_AGE_MS = 10_000;
const NO_LIMITS: ProviderServerLimits = {
cpuLimitPercent: null,
memoryLimitBytes: null,
diskLimitBytes: null,
};
/**
* Single source of truth for "what is the server doing right now".
*
* Resource numbers previously came only from a 10-second REST poll, so every
* tile and graph in the panel lagged reality and disagreed with what
* Pterodactyl itself displayed. When the console hub is connected its pushed
* frames are authoritative; the REST call remains as the fallback for when the
* websocket is down or disabled.
*/
export class ServerMetricsService {
constructor(
private readonly provider: GameServerProvider,
private readonly hub: ConsoleHub | null,
) {}
private liveStats() {
const stats = this.hub?.latestStats();
if (!stats) return null;
return Date.now() - stats.at <= LIVE_MAX_AGE_MS ? stats : null;
}
async getStatus(serverId: string): Promise<ServerStatus> {
const live = this.liveStats();
if (live) return live.status;
const hubStatus = this.hub?.latestStatus();
if (hubStatus && hubStatus !== 'unknown') return hubStatus;
return this.provider.getServerStatus(serverId);
}
async getResources(serverId: string): Promise<ServerResources> {
const live = this.liveStats();
if (live) {
const limits = await this.provider.getServerLimits(serverId).catch(() => NO_LIMITS);
return {
status: live.status,
cpuPercent: live.cpuPercent,
cpuLimitPercent: limits.cpuLimitPercent,
memoryBytes: live.memoryBytes,
memoryLimitBytes: limits.memoryLimitBytes,
diskBytes: live.diskBytes,
diskLimitBytes: limits.diskLimitBytes,
networkRxBytes: live.networkRxBytes,
networkTxBytes: live.networkTxBytes,
uptimeMs: live.uptimeMs,
fetchedAt: new Date(live.at).toISOString(),
source: 'live',
};
}
const resources = await this.provider.getServerResources(serverId);
return { ...resources, fetchedAt: new Date().toISOString(), source: 'poll' };
}
}
@@ -1,6 +1,6 @@
import type { ResourceHistoryResponse, ResourceSample } from '@reforger-panel/shared';
import type { Logger } from '../../lib/logger.js';
import type { GameServerProvider } from '../pterodactyl/types.js';
import type { ServerMetricsService } from './metrics-service.js';
export const SAMPLE_INTERVAL_SECONDS = 15;
const MAX_SAMPLES = 240; // ~1 hour window
@@ -9,8 +9,12 @@ type RawSample = ResourceSample & { rxTotal: number; txTotal: number };
/**
* In-memory rolling window of resource usage for the dashboard graphs.
* Network rates are derived from the provider's cumulative rx/tx counters;
* history is intentionally not persisted (it is telemetry, not records).
* Network rates are derived from the cumulative rx/tx counters; history is
* intentionally not persisted (it is telemetry, not records).
*
* Samples come from ServerMetricsService, so when the Wings websocket is up
* this reads an already-pushed frame rather than issuing its own HTTP request
* every 15 seconds.
*/
export class ResourceHistoryService {
private samples = new Map<string, RawSample[]>();
@@ -18,7 +22,7 @@ export class ResourceHistoryService {
private servers: { serverId: string; providerServerId: string }[] = [];
constructor(
private readonly provider: GameServerProvider,
private readonly metrics: ServerMetricsService,
private readonly logger: Logger,
private readonly intervalSeconds: number = SAMPLE_INTERVAL_SECONDS,
) {}
@@ -62,7 +66,7 @@ export class ResourceHistoryService {
}
private async sampleOne(serverId: string, providerServerId: string): Promise<void> {
const resources = await this.provider.getServerResources(providerServerId);
const resources = await this.metrics.getResources(providerServerId);
const previous = this.samples.get(serverId)?.at(-1);
const now = Date.now();
+354 -255
View File
@@ -1,54 +1,54 @@
import { Router } from 'express';
import { z } from 'zod';
import type {
ConfigPatchOp,
LogIngestionHealth,
MissionInfo,
ModDependencyIssue,
ServerResources,
ReforgerConfigMod,
ServerStatus,
ServerSummary,
} from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import { rateLimit } from '../../lib/rate-limit.js';
import { requireAuth, requireCapability } from '../auth/auth-middleware.js';
import type { ConfigEditorService } from '../config/config-editor-service.js';
import type { ConfigSyncService } from '../config/config-sync.js';
import type { ServerModsService } from '../config/mods-service.js';
import type { PerformanceSettingsService } from '../config/performance-service.js';
import type { ResourceHistoryService } from './resource-history.js';
import type { ConsoleHub } from '../pterodactyl/console-hub.js';
import type { GameServerProvider } from '../pterodactyl/types.js';
import type { LogPathResolver } from '../reforger-logs/ingestion/log-path-resolver.js';
import type { IngestionScheduler, ScheduledServer } from '../reforger-logs/ingestion/scheduler.js';
import type { MissionCatalog } from '../reforger-logs/missions-catalog.js';
import {
DEFAULT_MISSION,
DEFAULT_SCENARIO_ID,
hasScenarioTag,
mergeMissions,
scenariosFromWorkshopMod,
} from '../reforger-logs/missions-catalog.js';
import type { MissionsService } from '../reforger-logs/missions-catalog.js';
import type { WorkshopCache } from '../workshop/workshop-cache.js';
import type { ServerMetricsService } from './metrics-service.js';
import type { ResourceHistoryService } from './resource-history.js';
import type { ServerRecord, ServerService } from './server-service.js';
import type { WorkshopClient } from '../workshop/workshop-client.js';
const slugSchema = z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/, 'Invalid server slug.');
export type ServerRouterDeps = {
service: ServerService;
provider: GameServerProvider;
metrics: ServerMetricsService;
consoleHub: ConsoleHub | null;
scheduler: IngestionScheduler | null;
resolveLogPath: LogPathResolver | null;
configSync: ConfigSyncService | null;
configEditor: ConfigEditorService | null;
mods: ServerModsService | null;
performance: PerformanceSettingsService | null;
resourceHistory: ResourceHistoryService | null;
missions: MissionCatalog | null;
workshop: WorkshopClient;
missions: MissionsService;
workshop: WorkshopCache;
staleAfterSeconds: number;
mockMode: boolean;
};
const revisionSchema = z.string().regex(/^[a-f0-9]{8,64}$/, 'Invalid revision.');
// Validation ranges follow the Bohemia server-config reference. Only provided
// keys are touched; `null` removes the key (the game default applies).
const performanceBodySchema = z
const performanceSettingsSchema = z
.object({
scenarioId: z
.string()
@@ -73,6 +73,48 @@ const performanceBodySchema = z
.partial()
.strict();
const performanceBodySchema = z.object({
settings: performanceSettingsSchema,
expectedRevision: revisionSchema.optional(),
writeStartupVars: z.boolean().default(true),
});
/**
* Dotted config paths only no array indices, no prototype-polluting
* segments. `game.mods` is owned by the mods endpoints, which understand
* versions and dependencies, so it is refused here.
*/
const configPathSchema = z
.string()
.max(200)
.regex(/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$/, 'Invalid config path.')
.refine((path) => !path.split('.').some((segment) => segment === '__proto__'), 'Invalid path.')
.refine((path) => path !== 'game.mods' && !path.startsWith('game.mods.'), {
message: 'The mod list is managed on the Mods page.',
});
const configPatchBodySchema = z.object({
ops: z
.array(
z.object({
path: configPathSchema,
value: z.union([z.string().max(4000), z.number(), z.boolean(), z.null()]),
}),
)
.min(1)
.max(200),
expectedRevision: revisionSchema.optional(),
writeStartupVars: z.boolean().default(false),
});
const configRawBodySchema = z.object({
content: z
.string()
.min(2)
.max(256 * 1024),
expectedRevision: revisionSchema.optional(),
});
const startupVariableBodySchema = z.object({
key: z.string().regex(/^[A-Z0-9_]{1,64}$/, 'Invalid variable name.'),
value: z.string().max(500),
@@ -91,31 +133,43 @@ const scheduleIdSchema = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/, 'Invalid sche
// Reforger Workshop mod IDs are 16 hex characters (see the Bohemia server
// config reference); name/version are free-ish text with sane caps.
const modEntrySchema = z.object({
modId: z.string().regex(/^[A-Fa-f0-9]{16}$/, 'Invalid mod id.'),
name: z.string().trim().max(200).optional(),
version: z
.string()
.trim()
.max(32)
.regex(/^[\w.+-]*$/, 'Invalid version.')
.optional(),
});
const modsBodySchema = z.object({
mods: z
.array(
z.object({
modId: z.string().regex(/^[A-Fa-f0-9]{16}$/, 'Invalid mod id.'),
name: z.string().trim().max(200).optional(),
version: z
.string()
.trim()
.max(32)
.regex(/^[\w.+-]*$/, 'Invalid version.')
.optional(),
}),
)
.max(200),
mods: z.array(modEntrySchema).max(300),
expectedRevision: revisionSchema.optional(),
});
const modsResolveBodySchema = z.object({
mods: z.array(modEntrySchema).max(300),
});
function providerId(server: ServerRecord): string {
return server.pterodactylServerId ?? server.slug;
}
/** Rejects duplicate mod ids up front instead of silently collapsing them. */
function assertNoDuplicates(mods: readonly ReforgerConfigMod[]): void {
const ids = mods.map((mod) => mod.modId.toUpperCase());
if (new Set(ids).size !== ids.length) {
throw ApiError.validation('Duplicate mod ids in the list.');
}
}
export function createServerRouter(deps: ServerRouterDeps): Router {
const router = Router();
const { service, provider } = deps;
const { service, provider, metrics } = deps;
const powerRateLimit = rateLimit({ windowMs: 60_000, max: 10, keyPrefix: 'power' });
const writeRateLimit = rateLimit({ windowMs: 60_000, max: 20, keyPrefix: 'configwrite' });
const syncRateLimit = rateLimit({ windowMs: 60_000, max: 6, keyPrefix: 'logsync' });
router.use(requireAuth);
@@ -128,10 +182,24 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
return server;
}
function requireMods(): ServerModsService {
if (!deps.mods) {
throw ApiError.notConfigured('Mod management requires a configured game server backend.');
}
return deps.mods;
}
function requireConfigEditor(): ConfigEditorService {
if (!deps.configEditor) {
throw ApiError.notConfigured('Config editing requires a configured game server backend.');
}
return deps.configEditor;
}
async function toSummary(server: ServerRecord): Promise<ServerSummary> {
let status = server.status as ServerStatus;
try {
status = await provider.getServerStatus(providerId(server));
status = await metrics.getStatus(providerId(server));
if (status !== server.status) {
await service.updateStatus(server.id, status);
}
@@ -151,6 +219,8 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
};
}
// ---------- server identity & telemetry ----------
router.get('/', async (_req, res, next) => {
try {
const servers = await service.listServers();
@@ -162,8 +232,7 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
router.get('/:slug', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
res.json(await toSummary(server));
res.json(await toSummary(await loadServer(req.params.slug)));
} catch (error) {
next(error);
}
@@ -172,9 +241,7 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
router.get('/:slug/resources', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
const resources = await provider.getServerResources(providerId(server));
const body: ServerResources = { ...resources, fetchedAt: new Date().toISOString() };
res.json(body);
res.json(await metrics.getResources(providerId(server)));
} catch (error) {
next(error);
}
@@ -192,6 +259,21 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
}
});
// ---------- configuration ----------
router.get('/:slug/configuration', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.configSync) {
throw ApiError.notConfigured('Configuration requires a configured game server backend.');
}
const { config, revision } = await deps.configSync.getLiveConfig(server);
res.json({ config, revision, fetchedAt: new Date().toISOString() });
} catch (error) {
next(error);
}
});
router.get('/:slug/config/performance', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
@@ -206,7 +288,7 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
router.put(
'/:slug/config/performance',
syncRateLimit,
writeRateLimit,
requireCapability('config.edit', 'You do not have permission to edit the configuration.'),
async (req, res, next) => {
try {
@@ -221,14 +303,10 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
issue ? `${issue.path.join('.')}: ${issue.message}` : 'Invalid settings.',
);
}
const result = await deps.performance.update(server, body.data);
// Many Reforger eggs template config.json from startup variables at
// boot; mirror the mission there too so switching sticks either way.
if (result.changedFields.includes('scenarioId') && body.data.scenarioId) {
await provider
.updateStartupVariable(providerId(server), 'SCENARIO_ID', body.data.scenarioId)
.catch(() => undefined); // variable may not exist on this egg
}
const result = await deps.performance.update(server, body.data.settings, {
expectedRevision: body.data.expectedRevision,
writeStartupVars: body.data.writeStartupVars,
});
if (result.changedFields.length > 0) {
const user = req.user!;
await service.recordActivity({
@@ -246,6 +324,117 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
},
);
/** Every key config.json actually contains, for the searchable editor. */
router.get(
'/:slug/config/tree',
requireCapability('config.edit', 'Configuration editing is restricted.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
res.json(await requireConfigEditor().getTree(server));
} catch (error) {
next(error);
}
},
);
router.patch(
'/:slug/config',
writeRateLimit,
requireCapability('config.edit', 'You do not have permission to edit the configuration.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
const body = configPatchBodySchema.safeParse(req.body);
if (!body.success) {
const issue = body.error.issues[0];
throw ApiError.validation(issue?.message ?? 'Invalid configuration patch.');
}
const ops: ConfigPatchOp[] = body.data.ops;
const result = await requireConfigEditor().patch(server, ops, {
expectedRevision: body.data.expectedRevision,
writeStartupVars: body.data.writeStartupVars,
});
if (result.changedPaths.length > 0) {
const user = req.user!;
await service.recordActivity({
serverId: server.id,
actorUserId: user.id,
action: 'config.updated',
// Paths only — values can be passwords.
summary: `config.json updated by ${user.displayName ?? user.username}: ${result.changedPaths.join(', ')} (applies on restart)`,
metadata: { changedPaths: result.changedPaths },
});
}
res.json(result);
} catch (error) {
next(error);
}
},
);
router.get(
'/:slug/config/raw',
requireCapability('config.edit', 'Configuration editing is restricted.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
res.json(await requireConfigEditor().getRaw(server));
} catch (error) {
next(error);
}
},
);
router.put(
'/:slug/config/raw',
writeRateLimit,
requireCapability('config.edit', 'You do not have permission to edit the configuration.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
const body = configRawBodySchema.safeParse(req.body);
if (!body.success) throw ApiError.validation('Invalid config.json payload.');
const result = await requireConfigEditor().putRaw(
server,
body.data.content,
body.data.expectedRevision,
);
const user = req.user!;
await service.recordActivity({
serverId: server.id,
actorUserId: user.id,
action: 'config.raw.updated',
summary: `config.json replaced by ${user.displayName ?? user.username} (applies on restart)`,
metadata: { revision: result.revision },
});
res.json(result);
} catch (error) {
next(error);
}
},
);
router.post(
'/:slug/config/sync',
syncRateLimit,
requireCapability('ops.health.view', 'Config sync is restricted to owner and server admins.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.configSync) {
throw ApiError.notConfigured('Config import requires a configured game server backend.');
}
const result = await deps.configSync.sync(server);
res.json({ ok: true, serverName: result.serverName, maxPlayers: result.maxPlayers });
} catch (error) {
next(error);
}
},
);
// ---------- players, activity, killfeed ----------
router.get('/:slug/players', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
@@ -284,137 +473,108 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
}
});
router.get('/:slug/configuration', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.configSync) {
throw ApiError.notConfigured('Configuration requires a configured game server backend.');
}
const config = await deps.configSync.getLiveConfig(server);
res.json({ config, fetchedAt: new Date().toISOString() });
} catch (error) {
next(error);
}
});
// ---------- missions ----------
router.get('/:slug/missions', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
// Resolve the installed mod list from config.json.
// Prefer the mods service (already owns that parse); fall back to configSync.
let installedModIds: string[] = [];
let installed: ReforgerConfigMod[] = [];
if (deps.mods) {
const modsData = await deps.mods.getMods(server);
installedModIds = modsData.mods.map((m) => m.modId);
installed = (await deps.mods.getMods(server)).mods;
} else if (deps.configSync) {
const config = await deps.configSync.getLiveConfig(server).catch(() => null);
installedModIds = (config?.mods ?? []).map((m) => m.modId);
const live = await deps.configSync.getLiveConfig(server).catch(() => null);
installed = live?.config.mods ?? [];
}
// Workshop API -> scenarios from installed scenario-tagged mods.
const modMissions: MissionInfo[] = [];
let scenarioLookupComplete = true;
if (installedModIds.length > 0) {
const details = await Promise.allSettled(
installedModIds.map((modId) => deps.workshop.getMod(modId)),
);
scenarioLookupComplete = details.every((result) => result.status === 'fulfilled');
for (const result of details) {
if (result.status !== 'fulfilled') continue;
const mod = result.value;
if (hasScenarioTag(mod.tags)) {
modMissions.push(...scenariosFromWorkshopMod(mod));
}
}
}
res.json({
missions: mergeMissions([DEFAULT_MISSION], modMissions),
fetchedAt: scenarioLookupComplete ? new Date().toISOString() : null,
});
res.json(await deps.missions.list(installed));
} catch (error) {
next(error);
}
});
// ---------- live console ----------
/**
* Server-Sent Events relay of the Pterodactyl/Wings feed.
*
* This is what makes install, update and mod-download output visible: it
* carries whatever the hosting backend emits, rather than tailing the game's
* own log file, which does not exist until the game has already started.
*/
router.get(
'/:slug/logs/stream',
'/:slug/console/stream',
requireCapability('ops.health.view', 'Live console stream is restricted.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.resolveLogPath) {
throw ApiError.notConfigured('Log streaming requires a configured game server backend.');
await loadServer(req.params.slug);
const hub = deps.consoleHub;
if (!hub) {
throw ApiError.notConfigured(
'Live console requires a configured game server backend with the websocket enabled.',
);
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
const controller = new AbortController();
req.on('close', () => controller.abort());
const send = (event: string, data: unknown) => {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
};
// Poll the log file every 2 seconds and push only new lines via SSE.
// Tracks total file size to derive the new-content offset on each poll,
// so we never re-send lines and handle log rotation gracefully.
let lastTotalBytes = 0;
send('backlog', hub.backlog());
const stats = hub.latestStats();
if (stats) send('stats', stats);
const sleep = (ms: number) =>
new Promise<void>((resolve) => {
const t = setTimeout(resolve, ms);
controller.signal.addEventListener(
'abort',
() => {
clearTimeout(t);
resolve();
},
{ once: true },
);
});
while (!controller.signal.aborted) {
try {
const logPath = await deps.resolveLogPath();
if (logPath) {
const file = await provider.downloadTextFile(providerId(server), logPath, 512 * 1024);
const totalBytes =
file.totalSizeBytes ?? file.contentStartOffset + file.content.length;
let newContent: string;
if (lastTotalBytes === 0 || totalBytes < lastTotalBytes) {
// First poll or log rotated — send all available content.
newContent = file.content;
} else {
const skip = Math.max(0, lastTotalBytes - file.contentStartOffset);
newContent = file.content.slice(skip);
}
lastTotalBytes = totalBytes;
if (newContent) {
for (const line of newContent.split('\n')) {
if (controller.signal.aborted) break;
if (line) res.write(`data: ${JSON.stringify(line)}\n\n`);
}
}
}
} catch {
// Provider unreachable or no log yet — keep the connection alive.
const unsubscribe = hub.subscribe((event) => {
switch (event.type) {
case 'line':
send('line', event.line);
break;
case 'status':
send('status', { status: event.status });
break;
case 'stats':
send('stats', event.stats);
break;
}
await sleep(2000);
}
});
res.end();
// Proxies drop idle connections; a comment frame keeps them open.
const heartbeat = setInterval(() => res.write(': ping\n\n'), 25_000);
heartbeat.unref?.();
req.on('close', () => {
clearInterval(heartbeat);
unsubscribe();
res.end();
});
} catch (error) {
next(error);
}
},
);
router.get(
'/:slug/console/backlog',
requireCapability('ops.health.view', 'Live console is restricted.'),
async (req, res, next) => {
try {
await loadServer(req.params.slug);
if (!deps.consoleHub) {
throw ApiError.notConfigured('Live console requires a configured game server backend.');
}
res.json(deps.consoleHub.backlog());
} catch (error) {
next(error);
}
},
);
/** The game's own log file — kept as a diagnostic alongside the live feed. */
router.get(
'/:slug/logs/raw',
requireCapability('ops.health.view', 'Raw logs are restricted.'),
@@ -447,6 +607,8 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
},
);
// ---------- startup variables ----------
router.get(
'/:slug/startup',
requireCapability('config.edit', 'Startup variables are restricted.'),
@@ -473,7 +635,7 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
router.put(
'/:slug/startup/variable',
syncRateLimit,
writeRateLimit,
requireCapability('config.edit', 'Startup variables are restricted.'),
async (req, res, next) => {
try {
@@ -497,6 +659,8 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
},
);
// ---------- schedules ----------
router.get(
'/:slug/schedules',
requireCapability('config.edit', 'Schedule management is restricted.'),
@@ -515,7 +679,7 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
router.post(
'/:slug/schedules/restarts',
syncRateLimit,
writeRateLimit,
requireCapability('config.edit', 'Schedule management is restricted.'),
async (req, res, next) => {
try {
@@ -540,7 +704,7 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
router.put(
'/:slug/schedules/:scheduleId/restart',
syncRateLimit,
writeRateLimit,
requireCapability('config.edit', 'Schedule management is restricted.'),
async (req, res, next) => {
try {
@@ -571,7 +735,7 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
router.delete(
'/:slug/schedules/:scheduleId',
syncRateLimit,
writeRateLimit,
requireCapability('config.edit', 'Schedule management is restricted.'),
async (req, res, next) => {
try {
@@ -594,84 +758,39 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
},
);
// ---------- mods ----------
router.get('/:slug/mods', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.mods) {
throw ApiError.notConfigured('Mod management requires a configured game server backend.');
}
res.json(await deps.mods.getMods(server));
res.json(await requireMods().getMods(await loadServer(req.params.slug)));
} catch (error) {
next(error);
}
});
router.get('/:slug/mods/check', async (req, res, next) => {
/**
* Everything the Mods page renders, in one request: installed mods joined
* with cached Workshop metadata, latest versions, dependency gaps and
* removal blockers. Replaces the old fan-out of one browser request per mod.
*/
router.get('/:slug/mods/overview', async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.mods) {
throw ApiError.notConfigured('Mod management requires a configured game server backend.');
res.json(await requireMods().getOverview(await loadServer(req.params.slug)));
} catch (error) {
next(error);
}
});
/** Expands a staged mod list into its full dependency closure, with sizes. */
router.post('/:slug/mods/resolve', async (req, res, next) => {
try {
await loadServer(req.params.slug);
const body = modsResolveBodySchema.safeParse(req.body);
if (!body.success) {
throw ApiError.validation(body.error.issues[0]?.message ?? 'Invalid mod list.');
}
const { mods } = await deps.mods.getMods(server);
const installedIds = new Set(mods.map((m) => m.modId.toUpperCase()));
// Fetch workshop details for all installed mods in parallel.
const details = await Promise.allSettled(mods.map((mod) => deps.workshop.getMod(mod.modId)));
const modsWithMissingVersions: string[] = [];
const modsWithMissingDeps: ModDependencyIssue[] = [];
for (let i = 0; i < mods.length; i++) {
const mod = mods[i]!;
if (!mod.version) modsWithMissingVersions.push(mod.modId);
const result = details[i]!;
if (result.status === 'fulfilled') {
const missing = result.value.dependencies.filter(
(dep) => dep.id && !installedIds.has(dep.id.toUpperCase()),
);
if (missing.length > 0) {
modsWithMissingDeps.push({
modId: mod.modId,
modName: mod.name ?? result.value.name ?? null,
missing,
});
}
}
}
// Detect orphaned mission: configured scenarioId no longer available.
let orphanedMission: { scenarioId: string; name: string | null } | null = null;
const configData = deps.configSync
? await deps.configSync.getLiveConfig(server).catch(() => null)
: null;
const scenarioId = configData?.scenarioId ?? null;
if (scenarioId) {
const knownScenarioIds = new Set([DEFAULT_SCENARIO_ID]);
const scenarioLookupComplete = details.every((result) => result.status === 'fulfilled');
for (const result of details) {
if (result.status === 'fulfilled') {
const mod = result.value;
if (!hasScenarioTag(mod.tags)) continue;
for (const s of mod.scenarios) {
knownScenarioIds.add(s.scenarioId);
}
}
}
if (scenarioLookupComplete && !knownScenarioIds.has(scenarioId)) {
orphanedMission = {
scenarioId,
name: null,
};
}
}
res.json({
modsWithMissingVersions,
modsWithMissingDeps,
orphanedMission,
checkedAt: new Date().toISOString(),
});
assertNoDuplicates(body.data.mods);
res.json(await requireMods().resolve(body.data.mods));
} catch (error) {
next(error);
}
@@ -679,46 +798,41 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
router.put(
'/:slug/mods',
syncRateLimit,
writeRateLimit,
requireCapability('mods.manage', 'You do not have permission to manage mods.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.mods) {
throw ApiError.notConfigured('Mod management requires a configured game server backend.');
}
const mods = requireMods();
const body = modsBodySchema.safeParse(req.body);
if (!body.success) {
throw ApiError.validation(body.error.issues[0]?.message ?? 'Invalid mod list.');
}
// Reject duplicate mod ids up front instead of silently collapsing.
const ids = body.data.mods.map((mod) => mod.modId.toUpperCase());
if (new Set(ids).size !== ids.length) {
throw ApiError.validation('Duplicate mod ids in the list.');
}
assertNoDuplicates(body.data.mods);
// Reforger requires a version in config.json for each mod to load.
// Fetch it from the Workshop for any mod the caller didn't supply one for.
const enrichedMods = await Promise.all(
// Reforger needs a version in config.json for each mod to load. Fill in
// the latest known version for any entry the caller left unpinned.
const enriched = await Promise.all(
body.data.mods.map(async (mod) => {
if (mod.version) return mod;
try {
const detail = await deps.workshop.getMod(mod.modId);
return { ...mod, ...(detail.version ? { version: detail.version } : {}) };
} catch {
return mod;
}
const detail = await deps.workshop.tryGetMod(mod.modId);
return detail?.version ? { ...mod, version: detail.version } : mod;
}),
);
const result = await deps.mods.setMods(server, enrichedMods);
const result = await mods.setMods(server, enriched, body.data.expectedRevision);
const user = req.user!;
await service.recordActivity({
serverId: server.id,
actorUserId: user.id,
action: 'mods.updated',
summary: `Mods updated by ${user.displayName ?? user.username}: ${result.added} added, ${result.removed} removed (${result.mods.length} total, applies on restart)`,
metadata: { added: result.added, removed: result.removed, total: result.mods.length },
summary: `Mods updated by ${user.displayName ?? user.username}: ${result.added} added, ${result.removed} removed, ${result.changed} re-versioned (${result.mods.length} total, applies on restart)`,
metadata: {
added: result.added,
removed: result.removed,
changed: result.changed,
total: result.mods.length,
},
});
res.json(result);
} catch (error) {
@@ -736,6 +850,8 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
}
});
// ---------- power ----------
const powerActions = [
{
action: 'start' as const,
@@ -784,6 +900,8 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
);
}
// ---------- log ingestion ----------
router.post(
'/:slug/logs/sync',
syncRateLimit,
@@ -817,25 +935,6 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
},
);
router.post(
'/:slug/config/sync',
syncRateLimit,
requireCapability('ops.health.view', 'Config sync is restricted to owner and server admins.'),
async (req, res, next) => {
try {
const server = await loadServer(req.params.slug);
if (!deps.configSync) {
throw ApiError.notConfigured('Config import requires a configured game server backend.');
}
// Config is served live; this just refreshes the stored name/capacity.
const result = await deps.configSync.sync(server);
res.json({ ok: true, serverName: result.serverName, maxPlayers: result.maxPlayers });
} catch (error) {
next(error);
}
},
);
router.get(
'/:slug/logs/health',
requireCapability('ops.health.view', 'Operational diagnostics are restricted.'),
@@ -0,0 +1,149 @@
import { describe, expect, it, vi } from 'vitest';
import type { WorkshopModDetail } from '@reforger-panel/shared';
import { createLogger } from '../../lib/logger.js';
import { ApiError } from '../../lib/errors.js';
import { WorkshopCache } from './workshop-cache.js';
import type { WorkshopClient } from './workshop-client.js';
function detail(id: string, overrides: Partial<WorkshopModDetail> = {}): WorkshopModDetail {
return {
id,
name: `Mod ${id}`,
author: 'Author',
summary: null,
imageUrl: null,
workshopUrl: null,
version: '1.0.0',
gameVersion: null,
sizeBytes: 1024,
sizeText: '1 KiB',
rating: null,
ratingCount: null,
subscriberCount: null,
createdAt: null,
updatedAt: null,
tags: [],
obsolete: false,
description: null,
license: null,
downloadCount: null,
previewImages: [],
screenshots: [],
versionCount: 1,
dependencyCount: 0,
scenarioCount: 0,
dependencySizeBytes: null,
totalSizeBytes: null,
dependencies: [],
scenarios: [],
...overrides,
};
}
function cacheWith(getMod: (id: string) => Promise<WorkshopModDetail>) {
const client = { getMod: vi.fn(getMod) } as unknown as WorkshopClient;
return { cache: new WorkshopCache(client, createLogger('silent')), client };
}
describe('WorkshopCache', () => {
it('serves repeated reads of the same mod from cache', async () => {
const { cache, client } = cacheWith(async (id) => detail(id));
await cache.getMod('AAAA000000000001');
await cache.getMod('AAAA000000000001');
expect(client.getMod).toHaveBeenCalledTimes(1);
});
it('collapses concurrent reads into a single upstream request', async () => {
// This is what stops the mods overview, the dependency check and the
// mission list from each fanning out over the same ids.
let resolveFetch: (value: WorkshopModDetail) => void = () => {};
const { cache, client } = cacheWith(
() =>
new Promise<WorkshopModDetail>((resolve) => {
resolveFetch = resolve;
}),
);
const pending = Promise.all([
cache.getMod('AAAA000000000002'),
cache.getMod('AAAA000000000002'),
cache.getMod('AAAA000000000002'),
]);
resolveFetch(detail('AAAA000000000002'));
const results = await pending;
expect(client.getMod).toHaveBeenCalledTimes(1);
expect(results.map((mod) => mod.id)).toEqual([
'AAAA000000000002',
'AAAA000000000002',
'AAAA000000000002',
]);
});
it('normalises ids so casing differences share one entry', async () => {
const { cache, client } = cacheWith(async (id) => detail(id));
await cache.getMod('aaaa000000000003');
await cache.getMod('AAAA000000000003');
expect(client.getMod).toHaveBeenCalledTimes(1);
});
it('remembers a missing mod instead of asking again', async () => {
const { cache, client } = cacheWith(async () => {
throw ApiError.notFound('Workshop mod not found.');
});
await expect(cache.getMod('AAAA000000000004')).rejects.toMatchObject({ code: 'NOT_FOUND' });
await expect(cache.getMod('AAAA000000000004')).rejects.toMatchObject({ code: 'NOT_FOUND' });
expect(client.getMod).toHaveBeenCalledTimes(1);
expect(cache.peekMod('AAAA000000000004')).toBeNull();
});
it('backs off after an upstream failure rather than hammering it', async () => {
const { cache, client } = cacheWith(async () => {
throw ApiError.upstream('boom');
});
await expect(cache.tryGetMod('AAAA000000000005')).resolves.toBeNull();
await expect(cache.tryGetMod('AAAA000000000005')).resolves.toBeNull();
expect(client.getMod).toHaveBeenCalledTimes(1);
});
it('reports uncached ids as unknown, not missing', async () => {
const { cache } = cacheWith(async (id) => detail(id));
expect(cache.peekMod('AAAA000000000006')).toBeUndefined();
await cache.getMod('AAAA000000000006');
expect(cache.peekMod('AAAA000000000006')?.name).toBe('Mod AAAA000000000006');
});
it('reuses the detail payload for dependencies instead of a second request', async () => {
const { cache, client } = cacheWith(async (id) =>
detail(id, {
dependencyCount: 1,
dependencies: [
{
id: 'BBBB000000000001',
name: 'Dep',
version: null,
sizeBytes: 10,
published: true,
private: false,
},
],
}),
);
const dependencies = await cache.getDependencies('AAAA000000000007');
expect(dependencies).toHaveLength(1);
expect(client.getMod).toHaveBeenCalledTimes(1);
});
it('skips the scenario request for mods that ship none', async () => {
const { cache } = cacheWith(async (id) => detail(id, { scenarioCount: 0 }));
await expect(cache.getScenarios('AAAA000000000008')).resolves.toEqual([]);
});
it('drops cached entries on invalidate', async () => {
const { cache, client } = cacheWith(async (id) => detail(id));
await cache.getMod('AAAA000000000009');
cache.invalidate(['AAAA000000000009']);
await cache.getMod('AAAA000000000009');
expect(client.getMod).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,339 @@
import type {
WorkshopDependency,
WorkshopModDetail,
WorkshopModVersionsResponse,
WorkshopScenario,
WorkshopSearchResponse,
WorkshopServerModsResponse,
WorkshopServerSearchResponse,
} from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import { sanitizeErrorMessage, type Logger } from '../../lib/logger.js';
import type { WorkshopClient, WorkshopSearchParams } from './workshop-client.js';
/**
* TTLs mirror what reforgermods.net caches upstream, so we never ask more
* often than the answer can change. `stale` is how long a value stays usable
* while a refresh runs in the background.
*/
const TTL = {
detail: { fresh: 60 * 60_000, stale: 24 * 60 * 60_000 },
versions: { fresh: 6 * 60 * 60_000, stale: 7 * 24 * 60 * 60_000 },
search: { fresh: 10 * 60_000, stale: 60 * 60_000 },
servers: { fresh: 60_000, stale: 10 * 60_000 },
/** How long a "this mod does not exist" answer is trusted. */
negative: 10 * 60_000,
} as const;
/** After an upstream failure, fail fast for this long instead of retrying. */
const FAILURE_COOLDOWN_MS = 30_000;
const MAX_ENTRIES = 2_000;
type Entry = {
value: unknown;
/** True for a cached "not found" — value is null and must not be retried yet. */
missing: boolean;
freshUntil: number;
staleUntil: number;
};
/**
* Concurrency gate plus a token bucket. The free upstream tier allows 60
* requests/minute per IP with a burst of 20; we deliberately sit under that so
* the panel never trips a 429 and never has to throttle in the browser (which
* is what the old client-side limiter in mods.tsx was doing).
*/
class RequestPacer {
private active = 0;
private tokens: number;
private lastRefillAt = Date.now();
private waiters: (() => void)[] = [];
constructor(
private readonly maxConcurrent: number,
private readonly perMinute: number,
private readonly burst: number,
) {
this.tokens = burst;
}
private refill(): void {
const now = Date.now();
const elapsed = now - this.lastRefillAt;
if (elapsed <= 0) return;
this.lastRefillAt = now;
this.tokens = Math.min(this.burst, this.tokens + (elapsed / 60_000) * this.perMinute);
}
private tryTake(): boolean {
this.refill();
if (this.active >= this.maxConcurrent || this.tokens < 1) return false;
this.tokens -= 1;
this.active += 1;
return true;
}
private pump(): void {
while (this.waiters.length > 0) {
if (!this.tryTake()) {
// Nothing available now; re-check when a token could have accrued.
setTimeout(() => this.pump(), Math.ceil(60_000 / this.perMinute)).unref?.();
return;
}
this.waiters.shift()!();
}
}
async run<T>(task: () => Promise<T>): Promise<T> {
if (!this.tryTake()) {
await new Promise<void>((resolve) => {
this.waiters.push(resolve);
setTimeout(() => this.pump(), 0).unref?.();
});
}
try {
return await task();
} finally {
this.active -= 1;
this.pump();
}
}
}
/**
* Read-through cache over the Workshop API with stale-while-revalidate,
* single-flight de-duplication and request pacing.
*
* This is what makes the Mods page fast: the installed-mod overview, the
* mission list and the dependency check all hit the same warm entries instead
* of each fanning out over the network, and a stale entry is served instantly
* while it refreshes behind the request.
*/
export class WorkshopCache {
private readonly store = new Map<string, Entry>();
private readonly inflight = new Map<string, Promise<unknown>>();
private readonly failures = new Map<string, number>();
private readonly pacer = new RequestPacer(6, 50, 15);
constructor(
private readonly client: WorkshopClient,
private readonly logger: Logger,
) {}
// ---------- cache primitives ----------
private read(key: string): Entry | undefined {
const entry = this.store.get(key);
if (!entry) return undefined;
if (entry.staleUntil <= Date.now()) {
this.store.delete(key);
return undefined;
}
// Refresh LRU position.
this.store.delete(key);
this.store.set(key, entry);
return entry;
}
private write(key: string, value: unknown, ttl: { fresh: number; stale: number }): void {
const now = Date.now();
this.store.delete(key);
this.store.set(key, {
value,
missing: false,
freshUntil: now + ttl.fresh,
staleUntil: now + ttl.stale,
});
this.evict();
}
private writeMissing(key: string): void {
const now = Date.now();
this.store.delete(key);
this.store.set(key, {
value: null,
missing: true,
freshUntil: now + TTL.negative,
staleUntil: now + TTL.negative,
});
this.evict();
}
private evict(): void {
while (this.store.size > MAX_ENTRIES) {
const oldest = this.store.keys().next();
if (oldest.done) break;
this.store.delete(oldest.value);
}
}
/**
* Runs `loader` once per key even if called concurrently, paced against the
* upstream rate limit. A 404 is remembered as a negative entry; any other
* failure starts a short cooldown so a flapping upstream cannot be hammered.
*/
private async load<T>(
key: string,
ttl: { fresh: number; stale: number },
loader: () => Promise<T>,
): Promise<T> {
const existing = this.inflight.get(key);
if (existing) return (await existing) as T;
const cooldownUntil = this.failures.get(key);
if (cooldownUntil && cooldownUntil > Date.now()) {
throw ApiError.upstream('Workshop API is temporarily unavailable.');
}
const promise = this.pacer
.run(loader)
.then((value) => {
this.failures.delete(key);
this.write(key, value, ttl);
return value;
})
.catch((error: unknown) => {
if (error instanceof ApiError && error.code === 'NOT_FOUND') {
this.failures.delete(key);
this.writeMissing(key);
throw error;
}
this.failures.set(key, Date.now() + FAILURE_COOLDOWN_MS);
throw error;
})
.finally(() => {
this.inflight.delete(key);
});
this.inflight.set(key, promise);
return (await promise) as T;
}
private async cached<T>(
key: string,
ttl: { fresh: number; stale: number },
loader: () => Promise<T>,
): Promise<T> {
const entry = this.read(key);
if (entry?.missing) throw ApiError.notFound('Workshop mod not found.');
if (entry) {
if (entry.freshUntil > Date.now()) return entry.value as T;
// Stale but usable: serve it now, refresh behind the caller's back.
void this.load(key, ttl, loader).catch(() => undefined);
return entry.value as T;
}
return this.load(key, ttl, loader);
}
// ---------- public API ----------
/** Synchronous peek. `undefined` means "not cached", `null` means "known missing". */
peekMod(modId: string): WorkshopModDetail | null | undefined {
const entry = this.read(`mod:${modId.toUpperCase()}`);
if (!entry) return undefined;
return entry.missing ? null : (entry.value as WorkshopModDetail);
}
async getMod(modId: string): Promise<WorkshopModDetail> {
const id = modId.toUpperCase();
return this.cached(`mod:${id}`, TTL.detail, () => this.client.getMod(id));
}
/** Non-throwing variant for bulk paths that must tolerate missing mods. */
async tryGetMod(modId: string): Promise<WorkshopModDetail | null> {
return this.getMod(modId).catch(() => null);
}
async getVersions(modId: string): Promise<WorkshopModVersionsResponse> {
const id = modId.toUpperCase();
return this.cached(`versions:${id}`, TTL.versions, () => this.client.getVersions(id));
}
async getDependencies(modId: string): Promise<WorkshopDependency[]> {
// The detail payload already carries dependencies, so reuse that entry
// instead of spending a second upstream request on the same information.
const detail = await this.tryGetMod(modId);
if (detail) return detail.dependencies;
const id = modId.toUpperCase();
return this.cached(`deps:${id}`, TTL.detail, () => this.client.getDependencies(id));
}
async getScenarios(modId: string): Promise<WorkshopScenario[]> {
const detail = await this.tryGetMod(modId);
if (!detail) return [];
// scenarioCount is authoritative; skip the request entirely for mods that
// ship none (the old code used a tag heuristic that both over- and
// under-matched).
if (detail.scenarioCount === 0) return [];
if (detail.scenarios.length > 0) return detail.scenarios;
const id = modId.toUpperCase();
return this.cached(`scenarios:${id}`, TTL.detail, () => this.client.getScenarios(id)).catch(
() => [],
);
}
async search(params: WorkshopSearchParams): Promise<WorkshopSearchResponse> {
const key = `search:${params.query ?? ''}|${params.page ?? 1}|${params.sort ?? ''}|${
params.tag ?? ''
}|${params.category ?? ''}`;
return this.cached(key, TTL.search, () => this.client.search(params));
}
async searchServers(query: string, page: number): Promise<WorkshopServerSearchResponse> {
return this.cached(`servers:${query}|${page}`, TTL.servers, () =>
this.client.searchServers(query, page),
);
}
async getServerMods(serverId: string): Promise<WorkshopServerModsResponse> {
return this.cached(`server-mods:${serverId}`, TTL.servers, () =>
this.client.getServerMods(serverId),
);
}
/**
* Populates details for a set of mod ids in the background. Called at boot
* with the installed mod list so the first Mods page open is already warm,
* and after a mod list change so the new entries are ready.
*/
warm(modIds: string[]): void {
const cold = modIds.filter((id) => this.peekMod(id) === undefined);
if (cold.length === 0) return;
this.logger.debug({ count: cold.length }, 'warming workshop cache');
void Promise.allSettled(cold.map((id) => this.tryGetMod(id))).then((results) => {
const failed = results.filter((r) => r.status === 'fulfilled' && r.value === null).length;
if (failed > 0) {
this.logger.debug({ failed, total: cold.length }, 'workshop cache warm partially failed');
}
});
}
/** Drops cached entries so the next read goes upstream (explicit Refresh). */
invalidate(modIds?: string[]): void {
if (!modIds) {
this.store.clear();
this.failures.clear();
return;
}
for (const modId of modIds) {
const id = modId.toUpperCase();
for (const prefix of ['mod', 'versions', 'deps', 'scenarios']) {
this.store.delete(`${prefix}:${id}`);
this.failures.delete(`${prefix}:${id}`);
}
}
}
/** Diagnostics for the settings page. */
stats(): { entries: number; inflight: number; cooldowns: number } {
return {
entries: this.store.size,
inflight: this.inflight.size,
cooldowns: [...this.failures.values()].filter((until) => until > Date.now()).length,
};
}
}
export function describeWorkshopError(error: unknown): string {
return sanitizeErrorMessage(error);
}
@@ -1,78 +1,131 @@
import { describe, expect, it, vi } from 'vitest';
import { WorkshopClient, normalizeImageUrl } from './workshop-client.js';
import { WorkshopClient, localizedLabel, normalizeImageUrl } from './workshop-client.js';
const REAL_IMAGE = 'https://ar-gcp-cdn.bistudio.com/image/abcd/1234';
const MOD_ID = '595F2BF2F44836FB';
/** Shape taken from a real GET /v2/mods response. */
function listResponse() {
return {
status: 'success',
meta: { totalPages: 1, currentPage: 1, totalMods: 2, shownMods: 2 },
meta: { totalPages: 3, currentPage: 1, totalMods: 42, shownMods: 1 },
data: [
{
id: MOD_ID,
name: 'Mod A',
author: 'Author',
imageURL: 'https://via.placeholder.com/640x360',
originalModURL: 'https://reforger.armaplatform.com/workshop/AAAAAAAAAAAAAAA1',
apiModURL: 'https://api.reforgermods.net/v1/mod/AAAAAAAAAAAAAAA1',
size: '1 MB',
rating: '99%',
ID: 'AAAAAAAAAAAAAAA1',
summary: 'Summary',
version: '1.2.0',
gameVersion: '1.8.0.10',
size: 204219382,
sizeFormatted: '195 MiB',
rating: 0.88,
ratingCount: 9323,
subscriberCount: 31997,
updatedAt: '2026-08-14T06:07:52Z',
tags: ['WEAPONS'],
// Upstream still serves placeholder stubs on some rows.
imageUrl: 'https://via.placeholder.com/640x360',
workshopUrl: `https://reforger.armaplatform.com/workshop/${MOD_ID}`,
},
],
};
}
function detailResponse(id: string) {
function detailResponse() {
return {
status: 'success',
mod: {
id: MOD_ID,
name: 'Mod A',
author: 'Author',
originalModURL: `https://reforger.armaplatform.com/workshop/${id}`,
apiModURL: `https://api.reforgermods.net/v1/mod/${id}`,
// Upstream bug: two URLs concatenated.
imageURL: `https://reforger.armaplatform.com${REAL_IMAGE}`,
rating: '99%',
version: '1.2.0',
size: '1 MB',
id,
tags: [],
dependencies: [],
scenarios: [],
// Upstream bug: two URLs concatenated.
imageUrl: `https://reforger.armaplatform.com${REAL_IMAGE}`,
size: 0,
previewImages: [REAL_IMAGE],
screenshots: [],
scenarioCount: 2,
dependencyCount: 1,
totalSize: 8940841288,
tags: ['SCENARIOS_MP'],
dependencies: [
{
id: '1337c0de5dabbeef',
name: 'Content Pack',
version: '0.16.5150',
size: 6343204665,
published: true,
private: false,
},
],
scenarios: [
{
name: 'Conflict - Everon (RHS)',
gameId: '{AAD43C10045857C1}Missions/RHS_Conflict.conf',
gameMode: '#AR-Scenario_GameMode_Campaign',
author: '#AR-Author_BI',
description: '#AR-Campaign_GamemodeDesc',
playerCount: 64,
},
// No gameId: unusable as a mission, so it must be dropped.
{ name: 'Broken', gameId: '', gameMode: null, playerCount: 0 },
],
},
};
}
function client(handler: (path: string) => unknown) {
const fetchImpl = vi.fn(async (url: string | URL) => {
const path = String(url).replace('https://workshop.test', '');
return new Response(JSON.stringify(handler(path)), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}) as unknown as typeof fetch;
return {
client: new WorkshopClient({ baseUrl: 'https://workshop.test', fetchImpl }),
fetchImpl: fetchImpl as unknown as ReturnType<typeof vi.fn>,
};
}
describe('normalizeImageUrl', () => {
it('drops dead placeholder URLs', () => {
it('drops placeholder stubs', () => {
expect(normalizeImageUrl('https://via.placeholder.com/640x360')).toBeNull();
});
it('repairs concatenated double URLs', () => {
it('recovers the real URL from a concatenated pair', () => {
expect(normalizeImageUrl(`https://reforger.armaplatform.com${REAL_IMAGE}`)).toBe(REAL_IMAGE);
});
it('passes through well-formed URLs and rejects junk', () => {
expect(normalizeImageUrl(REAL_IMAGE)).toBe(REAL_IMAGE);
it('returns null for empty or non-http values', () => {
expect(normalizeImageUrl('')).toBeNull();
expect(normalizeImageUrl('not a url')).toBeNull();
expect(normalizeImageUrl('not-a-url')).toBeNull();
});
});
describe('WorkshopClient preview cache', () => {
it('identifies panel traffic to the upstream API', async () => {
const fetchImpl = vi.fn(async () => {
return new Response(JSON.stringify({ status: 'ok' }), { status: 200 });
});
const client = new WorkshopClient({
baseUrl: 'https://workshop.test',
fetchImpl: fetchImpl as unknown as typeof fetch,
});
describe('localizedLabel', () => {
it('maps known Enfusion localization keys', () => {
expect(localizedLabel('#AR-Scenario_GameMode_Campaign')).toBe('Campaign');
});
await client.health();
it('humanises unknown keys instead of leaking them', () => {
expect(localizedLabel('#AR-Scenario_GameMode_KingOfTheHill')).toBe('King Of The Hill');
});
it('passes plain text through', () => {
expect(localizedLabel('Sandbox')).toBe('Sandbox');
expect(localizedLabel(null)).toBeNull();
});
});
describe('WorkshopClient (v2)', () => {
it('identifies panel traffic to the upstream API', async () => {
const { client: workshop, fetchImpl } = client(() => listResponse());
await workshop.search({});
expect(fetchImpl).toHaveBeenCalledWith(
'https://workshop.test/v1/health',
expect.any(String),
expect.objectContaining({
headers: expect.objectContaining({
'User-Agent': 'reforger.dzr.tools',
@@ -82,72 +135,115 @@ describe('WorkshopClient preview cache', () => {
);
});
it('does not fan out detail requests during search', async () => {
const fetchImpl = vi.fn(async (url: string | URL) => {
const path = String(url);
if (path.includes('/v1/mod/')) {
const id = path.slice(path.lastIndexOf('/') + 1);
return new Response(JSON.stringify(detailResponse(id)), { status: 200 });
}
return new Response(JSON.stringify(listResponse()), { status: 200 });
});
const client = new WorkshopClient({
baseUrl: 'https://workshop.test',
fetchImpl: fetchImpl as unknown as typeof fetch,
it('maps search results with typed sizes and ratings', async () => {
const { client: workshop, fetchImpl } = client(() => listResponse());
const result = await workshop.search({
query: 'mod a',
page: 2,
sort: 'newest',
tag: 'WEAPONS',
});
const first = await client.search('', 1);
expect(first.mods[0]!.imageUrl).toBeNull();
expect(first.mods[0]!.version).toBeNull();
const detailCalls = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/'));
expect(detailCalls).toHaveLength(0);
await client.getMod('AAAAAAAAAAAAAAA1');
// Second search can use the cached detail without another detail request.
const second = await client.search('', 1);
expect(second.mods[0]!.imageUrl).toBe(REAL_IMAGE);
expect(second.mods[0]!.version).toBe('1.2.0');
const detailCallsAfter = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/'));
expect(detailCallsAfter).toHaveLength(1);
expect(String(fetchImpl.mock.calls[0]![0])).toContain('/v2/mods?');
expect(String(fetchImpl.mock.calls[0]![0])).toContain('tags=WEAPONS');
expect(result.meta.totalMods).toBe(42);
const mod = result.mods[0]!;
expect(mod.sizeBytes).toBe(204219382);
expect(mod.rating).toBe(0.88);
expect(mod.subscriberCount).toBe(31997);
expect(mod.imageUrl).toBeNull(); // placeholder stripped
});
it('leaves the image empty when there is no cached detail', async () => {
const fetchImpl = vi.fn(async (url: string | URL) => {
return new Response(JSON.stringify(listResponse()), { status: 200 });
it('maps mod details, uppercasing dependency ids and keeping real scenario ids', async () => {
const { client: workshop } = client(() => detailResponse());
const detail = await workshop.getMod(MOD_ID);
expect(detail.dependencies).toEqual([
{
id: '1337C0DE5DABBEEF',
name: 'Content Pack',
version: '0.16.5150',
sizeBytes: 6343204665,
published: true,
private: false,
},
]);
expect(detail.scenarios).toHaveLength(1);
expect(detail.scenarios[0]).toEqual({
scenarioId: '{AAD43C10045857C1}Missions/RHS_Conflict.conf',
name: 'Conflict - Everon (RHS)',
gameMode: 'Campaign',
author: 'BI',
description: 'Campaign',
playerCount: 64,
});
const client = new WorkshopClient({
baseUrl: 'https://workshop.test',
fetchImpl: fetchImpl as unknown as typeof fetch,
});
const result = await client.search('', 1);
expect(result.mods[0]!.imageUrl).toBeNull();
expect(detail.imageUrl).toBe(REAL_IMAGE);
// Upstream reports 0 for "unknown", which must not read as "0 bytes".
expect(detail.sizeBytes).toBeNull();
expect(detail.totalSizeBytes).toBe(8940841288);
});
it('extracts scenario IDs from malformed scenario metadata', async () => {
const fetchImpl = vi.fn(async () => {
const detail = detailResponse('AAAAAAAAAAAAAAA1');
detail.mod.scenarios = [
{
name: '[OG] Udachne',
description: '',
scenarioID: '',
gamemode: 'Scenario ID{39AB5D9094E502AA}Missions/OG_Conflict.conf',
playerCount: 0,
imageURL: '',
},
];
return new Response(JSON.stringify(detail), { status: 200 });
});
const client = new WorkshopClient({
baseUrl: 'https://workshop.test',
fetchImpl: fetchImpl as unknown as typeof fetch,
});
it('maps the version history used by the version picker', async () => {
const { client: workshop } = client(() => ({
status: 'success',
data: {
modId: MOD_ID,
count: 1,
versions: [
{
version: '0.16.5150',
gameVersion: '1.8.0.10',
size: 204219382,
sizeFormatted: '195 MiB',
approved: true,
published: true,
createdAt: '2026-08-14T06:05:43Z',
scenarioCount: 11,
dependencyCount: 2,
},
],
},
}));
const mod = await client.getMod('AAAAAAAAAAAAAAA1');
expect(mod.scenarios[0]).toMatchObject({
scenarioId: '{39AB5D9094E502AA}Missions/OG_Conflict.conf',
gamemode: null,
const result = await workshop.getVersions(MOD_ID);
expect(result.modId).toBe(MOD_ID);
expect(result.versions[0]).toMatchObject({
version: '0.16.5150',
gameVersion: '1.8.0.10',
sizeBytes: 204219382,
approved: true,
});
});
it('maps a server mod list for the import-from-server flow', async () => {
const { client: workshop } = client(() => ({
status: 'success',
serverId: 'room-1',
summary: { count: 2, knownSize: 4096, unresolvedCount: 1 },
data: [
{ id: '69f40d8f38530936', name: 'Hogs Scenario Core', version: '1.0.20', size: 2011590 },
{ id: '64610AFB74AA9842', name: 'WCS_Core', version: null, size: 0 },
],
}));
const result = await workshop.getServerMods('room-1');
expect(result.mods).toEqual([
{
id: '69F40D8F38530936',
name: 'Hogs Scenario Core',
version: '1.0.20',
sizeBytes: 2011590,
},
{ id: '64610AFB74AA9842', name: 'WCS_Core', version: null, sizeBytes: null },
]);
expect(result.unresolvedCount).toBe(1);
});
it('translates upstream failures into ApiError codes', async () => {
const fetchImpl = vi.fn(
async () => new Response('nope', { status: 429 }),
) as unknown as typeof fetch;
const workshop = new WorkshopClient({ baseUrl: 'https://workshop.test', fetchImpl });
await expect(workshop.getMod(MOD_ID)).rejects.toMatchObject({ code: 'RATE_LIMITED' });
});
});
+367 -195
View File
@@ -1,108 +1,190 @@
import { z } from 'zod';
import type {
WorkshopHealth,
WorkshopDependency,
WorkshopModDetail,
WorkshopModPreview,
WorkshopModVersion,
WorkshopModVersionsResponse,
WorkshopScenario,
WorkshopSearchResponse,
WorkshopServerModsResponse,
WorkshopServerSearchResponse,
WorkshopServerSummary,
WorkshopSort,
} from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import { sanitizeErrorMessage } from '../../lib/logger.js';
/**
* Client for the public reforgermods.net Workshop metadata API.
* Endpoint shapes follow https://reforgermods.net/?page=documentation/api:
* GET /v1/health
* GET /v1/mods/{page}?search={q}&sort={sort}
* GET /v1/mod/{mod_id}
* Backend-only the browser never talks to this host directly.
* Client for the reforgermods.net Workshop metadata API, v2.
* Endpoint shapes follow https://reforgermods.net/arma-reforger-mods-api/v2/:
* GET /v2/mods?page&search&sort&tags&category
* GET /v2/mods/{id}
* GET /v2/mods/{id}/versions
* GET /v2/mods/{id}/dependencies
* GET /v2/mods/{id}/scenarios
* GET /v2/servers?search&hasMods&perPage
* GET /v2/servers/{id}/mods
*
* v2 returns typed values (byte counts, numeric ratings) and real scenario ids,
* so none of v1's string parsing is needed here. Backend-only the browser
* never talks to this host directly. Caching and request pacing live in
* WorkshopCache; this class is a thin typed transport.
*/
/** Identifies the panel to the upstream, as its docs request. */
const CLIENT_NAME = 'reforger.dzr.tools';
// ---------- upstream schemas ----------
const modPreviewSchema = z.object({
name: z.string(),
author: z.string().catch('Unknown'),
imageURL: z.string().catch(''),
originalModURL: z.string().catch(''),
size: z.string().catch(''),
rating: z.string().catch(''),
ID: z.string(),
version: z.string().nullish(),
id: z.string(),
name: z.string().catch('Unknown mod'),
summary: z.string().nullish(),
author: z.string().catch('Unknown'),
version: z.string().nullish(),
gameVersion: z.string().nullish(),
size: z.number().nullish(),
sizeFormatted: z.string().nullish(),
rating: z.number().nullish(),
ratingCount: z.number().nullish(),
subscriberCount: z.number().nullish(),
createdAt: z.string().nullish(),
updatedAt: z.string().nullish(),
obsolete: z.boolean().nullish(),
tags: z.array(z.string()).catch([]),
imageUrl: z.string().nullish(),
workshopUrl: z.string().nullish(),
});
const searchResponseSchema = z.object({
status: z.string(),
meta: z.object({
totalPages: z.number().catch(1),
currentPage: z.number().catch(1),
totalMods: z.number().catch(0),
}),
meta: z
.object({
totalPages: z.number().catch(1),
currentPage: z.number().catch(1),
totalMods: z.number().catch(0),
})
.catch({ totalPages: 1, currentPage: 1, totalMods: 0 }),
data: z.array(modPreviewSchema).catch([]),
});
const modDetailSchema = z.object({
name: z.string(),
author: z.string().catch('Unknown'),
originalModURL: z.string().catch(''),
imageURL: z.string().catch(''),
rating: z.string().catch(''),
version: z.string().nullish(),
gameVersion: z.string().nullish(),
size: z.string().catch(''),
subscribers: z.number().nullish(),
downloads: z.number().nullish(),
created: z.string().nullish(),
lastModified: z.string().nullish(),
const dependencySchema = z.object({
id: z.string(),
summary: z.string().nullish(),
name: z.string().catch('Unknown mod'),
version: z.string().nullish(),
size: z.number().nullish(),
published: z.boolean().nullish(),
private: z.boolean().nullish(),
});
const scenarioSchema = z.object({
name: z.string().catch('Unnamed scenario'),
gameId: z.string().nullish(),
gameMode: z.string().nullish(),
author: z.string().nullish(),
description: z.string().nullish(),
playerCount: z.number().nullish(),
});
const modDetailSchema = modPreviewSchema.extend({
description: z.string().nullish(),
license: z.string().nullish(),
tags: z.array(z.string()).catch([]),
dependencies: z.array(z.object({ name: z.string(), apiModURL: z.string().catch('') })).catch([]),
scenarios: z
downloadCount: z.number().nullish(),
previewImages: z.array(z.string()).catch([]),
screenshots: z.array(z.string()).catch([]),
versionCount: z.number().nullish(),
dependencyCount: z.number().nullish(),
scenarioCount: z.number().nullish(),
dependencySize: z.number().nullish(),
totalSize: z.number().nullish(),
dependencies: z.array(dependencySchema).catch([]),
scenarios: z.array(scenarioSchema).catch([]),
});
const modDetailEnvelopeSchema = z.object({ mod: modDetailSchema });
const versionSchema = z.object({
version: z.string(),
gameVersion: z.string().nullish(),
size: z.number().nullish(),
sizeFormatted: z.string().nullish(),
approved: z.boolean().nullish(),
published: z.boolean().nullish(),
createdAt: z.string().nullish(),
scenarioCount: z.number().nullish(),
dependencyCount: z.number().nullish(),
});
const versionsEnvelopeSchema = z.object({
data: z.object({
modId: z.string().catch(''),
versions: z.array(versionSchema).catch([]),
}),
});
const dependenciesEnvelopeSchema = z.object({
data: z.object({
dependencies: z.array(dependencySchema).catch([]),
}),
});
const scenariosEnvelopeSchema = z.object({
data: z.object({
scenarios: z.array(scenarioSchema).catch([]),
}),
});
const serverSummarySchema = z.object({
id: z.string(),
name: z.string().catch('Unnamed server'),
scenarioId: z.string().nullish(),
scenarioName: z.string().nullish(),
gameVersion: z.string().nullish(),
players: z.number().catch(0),
maxPlayers: z.number().catch(0),
region: z.string().nullish(),
platform: z.string().nullish(),
modCount: z.number().catch(0),
official: z.boolean().nullish(),
online: z.boolean().nullish(),
});
const serverSearchEnvelopeSchema = z.object({
meta: z
.object({
totalPages: z.number().catch(1),
currentPage: z.number().catch(1),
totalServers: z.number().catch(0),
})
.catch({ totalPages: 1, currentPage: 1, totalServers: 0 }),
data: z.array(serverSummarySchema).catch([]),
});
const serverModsEnvelopeSchema = z.object({
serverId: z.string().catch(''),
summary: z
.object({
knownSize: z.number().nullish(),
unresolvedCount: z.number().nullish(),
})
.nullish(),
data: z
.array(
z.object({
name: z.string(),
description: z.string().catch(''),
scenarioID: z.string().catch(''),
gamemode: z.string().catch(''),
playerCount: z.number().catch(0),
imageURL: z.string().catch(''),
id: z.string(),
name: z.string().catch('Unknown mod'),
version: z.string().nullish(),
size: z.number().nullish(),
}),
)
.catch([]),
});
const modDetailEnvelopeSchema = z.object({ status: z.string(), mod: modDetailSchema });
export type WorkshopSort = 'popularity' | 'newest' | 'subscribers' | 'version_size';
function extractModId(apiModUrl: string): string | null {
const match = /\/v1\/mod\/([^/?#]+)/.exec(apiModUrl);
return match?.[1] ?? null;
}
const SCENARIO_ID_PATTERN = /(\{[0-9a-fA-F]{16}\}[^\s,;)]*?\.conf)/;
const SCENARIO_ID_WITH_LABEL_PATTERN =
/scenario\s*id\s*:?\s*\{[0-9a-fA-F]{16}\}[^\s,;)]*?\.conf/i;
function extractScenarioId(...values: Array<string | null | undefined>): string {
for (const value of values) {
const match = value?.match(SCENARIO_ID_PATTERN);
if (match?.[1]) return match[1];
}
return '';
}
function cleanScenarioText(value: string | null | undefined): string | null {
const cleaned = value?.replace(SCENARIO_ID_WITH_LABEL_PATTERN, '').trim();
return cleaned || null;
}
// ---------- normalisation helpers ----------
/**
* Upstream image URLs need repair: list endpoints return dead
* via.placeholder.com stubs, and detail endpoints sometimes concatenate two
* URLs ("https://reforger.armaplatform.comhttps://ar-gcp-cdn...").
* Upstream image URLs still need repair: list endpoints occasionally return
* dead via.placeholder.com stubs, and some rows concatenate two URLs
* ("https://reforger.armaplatform.comhttps://ar-gcp-cdn...").
*/
export function normalizeImageUrl(raw: string | null | undefined): string | null {
if (!raw) return null;
@@ -112,44 +194,150 @@ export function normalizeImageUrl(raw: string | null | undefined): string | null
return candidate.startsWith('http') ? candidate : null;
}
/** Upstream reports 0 for "size unknown"; keep that distinct from "0 bytes". */
function sizeOrNull(size: number | null | undefined): number | null {
return typeof size === 'number' && size > 0 ? size : null;
}
function text(value: string | null | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
function toPreview(mod: z.infer<typeof modPreviewSchema>): WorkshopModPreview {
return {
id: mod.ID,
id: mod.id,
name: mod.name,
author: mod.author,
imageUrl: normalizeImageUrl(mod.imageURL),
size: mod.size || null,
rating: mod.rating || null,
workshopUrl: mod.originalModURL || null,
version: mod.version ?? null,
summary: mod.summary ?? null,
summary: text(mod.summary),
imageUrl: normalizeImageUrl(mod.imageUrl),
workshopUrl: text(mod.workshopUrl),
version: text(mod.version),
gameVersion: text(mod.gameVersion),
sizeBytes: sizeOrNull(mod.size),
sizeText: text(mod.sizeFormatted),
rating: typeof mod.rating === 'number' ? mod.rating : null,
ratingCount: mod.ratingCount ?? null,
subscriberCount: mod.subscriberCount ?? null,
createdAt: text(mod.createdAt),
updatedAt: text(mod.updatedAt),
tags: mod.tags,
obsolete: mod.obsolete ?? false,
};
}
const CLIENT_IDENTITY = 'reforger.dzr.tools';
const PREVIEW_CACHE_TTL_MS = 60 * 60 * 1000; // matches upstream's 1 h detail cache
function toDependency(dep: z.infer<typeof dependencySchema>): WorkshopDependency {
return {
id: dep.id.toUpperCase(),
name: dep.name,
version: text(dep.version),
sizeBytes: sizeOrNull(dep.size),
published: dep.published ?? true,
private: dep.private ?? false,
};
}
/**
* Scenario `gameMode` / `description` are Enfusion localization keys such as
* `#AR-Scenario_GameMode_Campaign`. Map the ones that show up in practice and
* fall back to a readable form of the key rather than leaking `#AR-` at users.
*/
const GAME_MODE_LABELS: Record<string, string> = {
'#AR-Scenario_GameMode_Campaign': 'Campaign',
'#AR-Scenario_GameMode_Conflict': 'Conflict',
'#AR-Scenario_GameMode_CombatOps': 'Combat Ops',
'#AR-Scenario_GameMode_GameMaster': 'Game Master',
'#AR-Scenario_GameMode_Tutorial': 'Tutorial',
'#AR-ServerBrowser_ServerScenario': 'Scenario',
'#AR-Campaign_GamemodeDesc': 'Campaign',
'#AR-CombatScenario_Description': 'Combat Ops',
};
export function localizedLabel(value: string | null | undefined): string | null {
const raw = text(value);
if (!raw) return null;
if (!raw.startsWith('#')) return raw;
const mapped = GAME_MODE_LABELS[raw];
if (mapped) return mapped;
// "#AR-Scenario_GameMode_FooBar" -> "Foo Bar"
const tail =
raw
.replace(/^#[A-Za-z]+-/, '')
.split('_')
.pop() ?? raw;
const spaced = tail.replace(/([a-z0-9])([A-Z])/g, '$1 $2').trim();
return spaced || null;
}
function toScenario(scenario: z.infer<typeof scenarioSchema>): WorkshopScenario | null {
const scenarioId = text(scenario.gameId);
if (!scenarioId) return null;
return {
scenarioId,
name: scenario.name,
gameMode: localizedLabel(scenario.gameMode),
author: localizedLabel(scenario.author),
description: localizedLabel(scenario.description),
playerCount: scenario.playerCount && scenario.playerCount > 0 ? scenario.playerCount : null,
};
}
function toVersion(version: z.infer<typeof versionSchema>): WorkshopModVersion {
return {
version: version.version,
gameVersion: text(version.gameVersion),
sizeBytes: sizeOrNull(version.size),
sizeText: text(version.sizeFormatted),
approved: version.approved ?? true,
published: version.published ?? true,
createdAt: text(version.createdAt),
scenarioCount: version.scenarioCount ?? null,
dependencyCount: version.dependencyCount ?? null,
};
}
function toServerSummary(server: z.infer<typeof serverSummarySchema>): WorkshopServerSummary {
return {
id: server.id,
name: server.name,
scenarioId: text(server.scenarioId),
scenarioName: text(server.scenarioName),
gameVersion: text(server.gameVersion),
players: server.players,
maxPlayers: server.maxPlayers,
region: text(server.region),
platform: text(server.platform),
modCount: server.modCount,
official: server.official ?? false,
online: server.online ?? true,
};
}
export type WorkshopSearchParams = {
query?: string;
page?: number;
sort?: WorkshopSort;
/** Upstream accepts a single tag only; comma-separated values are rejected. */
tag?: string;
category?: string;
};
export class WorkshopClient {
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly timeoutMs: number;
/** modId -> detail fields used to make browse cards useful. */
private previewCache = new Map<
string,
{
imageUrl: string | null;
version: string | null;
summary: string | null;
tags: string[];
expiresAt: number;
}
>();
private readonly apiKey: string;
constructor(options: { baseUrl: string; fetchImpl?: typeof fetch; timeoutMs?: number }) {
constructor(options: {
baseUrl: string;
fetchImpl?: typeof fetch;
timeoutMs?: number;
apiKey?: string;
}) {
this.baseUrl = options.baseUrl.replace(/\/$/, '');
this.fetchImpl = options.fetchImpl ?? fetch;
this.timeoutMs = options.timeoutMs ?? 10_000;
this.timeoutMs = options.timeoutMs ?? 12_000;
this.apiKey = options.apiKey ?? '';
}
private async get(path: string): Promise<unknown> {
@@ -158,8 +346,9 @@ export class WorkshopClient {
response = await this.fetchImpl(`${this.baseUrl}${path}`, {
headers: {
Accept: 'application/json',
'User-Agent': CLIENT_IDENTITY,
'X-API-Client': CLIENT_IDENTITY,
'X-API-Client': CLIENT_NAME,
'User-Agent': CLIENT_NAME,
...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
},
signal: AbortSignal.timeout(this.timeoutMs),
});
@@ -180,113 +369,96 @@ export class WorkshopClient {
return response.json();
}
async health(): Promise<WorkshopHealth> {
const startedAt = Date.now();
try {
await this.get('/v1/health');
return {
ok: true,
latencyMs: Date.now() - startedAt,
checkedAt: new Date().toISOString(),
message: null,
};
} catch (error) {
return {
ok: false,
latencyMs: null,
checkedAt: new Date().toISOString(),
message: sanitizeErrorMessage(error),
};
}
}
async search(query: string, page = 1, sort?: WorkshopSort): Promise<WorkshopSearchResponse> {
const params = new URLSearchParams();
if (query) params.set('search', query);
if (sort) params.set('sort', sort);
const qs = params.size > 0 ? `?${params.toString()}` : '';
const raw = await this.get(`/v1/mods/${Math.max(1, page)}${qs}`);
const parsed = searchResponseSchema.safeParse(raw);
private parse<T extends z.ZodTypeAny>(schema: T, raw: unknown): z.infer<T> {
const parsed = schema.safeParse(raw);
if (!parsed.success) {
throw ApiError.upstream('Workshop API returned an unexpected response shape.');
}
const mods = parsed.data.data.map(toPreview);
this.applyCachedPreviews(mods);
return {
mods,
meta: parsed.data.meta,
};
return parsed.data;
}
private applyCachedPreviews(mods: WorkshopModPreview[]): void {
const now = Date.now();
for (const mod of mods) {
const cached = this.previewCache.get(mod.id);
if (cached && cached.expiresAt > now) {
mod.imageUrl = mod.imageUrl ?? cached.imageUrl;
mod.version = mod.version ?? cached.version;
mod.summary = mod.summary ?? cached.summary;
mod.tags = mod.tags.length > 0 ? mod.tags : cached.tags;
}
}
async search(params: WorkshopSearchParams = {}): Promise<WorkshopSearchResponse> {
const search = new URLSearchParams();
search.set('page', String(Math.max(1, params.page ?? 1)));
if (params.query) search.set('search', params.query);
if (params.sort) search.set('sort', params.sort);
if (params.tag) search.set('tags', params.tag);
if (params.category) search.set('category', params.category);
const parsed = this.parse(searchResponseSchema, await this.get(`/v2/mods?${search}`));
return { mods: parsed.data.map(toPreview), meta: parsed.meta };
}
async getMod(modId: string): Promise<WorkshopModDetail> {
const raw = await this.get(`/v1/mod/${encodeURIComponent(modId)}`);
const parsed = modDetailEnvelopeSchema.safeParse(raw);
if (!parsed.success) {
throw ApiError.upstream('Workshop API returned an unexpected response shape.');
}
const mod = parsed.data.mod;
const detail = {
id: mod.id,
name: mod.name,
author: mod.author,
imageUrl: normalizeImageUrl(mod.imageURL),
size: mod.size || null,
rating: mod.rating || null,
workshopUrl: mod.originalModURL || null,
version: mod.version ?? null,
gameVersion: mod.gameVersion ?? null,
subscribers: mod.subscribers ?? null,
downloads: mod.downloads ?? null,
createdAtText: mod.created ?? null,
lastModifiedText: mod.lastModified ?? null,
summary: mod.summary ?? null,
description: mod.description ?? null,
license: mod.license ?? null,
tags: mod.tags,
dependencies: mod.dependencies.map((dep) => ({
name: dep.name,
id: extractModId(dep.apiModURL),
})),
scenarios: mod.scenarios.map((scenario) => ({
name: scenario.name,
description: scenario.description || null,
scenarioId: extractScenarioId(
scenario.scenarioID,
scenario.gamemode,
scenario.description,
scenario.name,
),
gamemode: cleanScenarioText(scenario.gamemode),
playerCount: scenario.playerCount || null,
imageUrl: normalizeImageUrl(scenario.imageURL),
})),
const raw = await this.get(`/v2/mods/${encodeURIComponent(modId)}`);
const { mod } = this.parse(modDetailEnvelopeSchema, raw);
return {
...toPreview(mod),
description: text(mod.description),
license: text(mod.license),
downloadCount: mod.downloadCount ?? null,
previewImages: mod.previewImages
.map(normalizeImageUrl)
.filter((url): url is string => url !== null),
screenshots: mod.screenshots
.map(normalizeImageUrl)
.filter((url): url is string => url !== null),
versionCount: mod.versionCount ?? null,
dependencyCount: mod.dependencyCount ?? mod.dependencies.length,
scenarioCount: mod.scenarioCount ?? mod.scenarios.length,
dependencySizeBytes: sizeOrNull(mod.dependencySize),
totalSizeBytes: sizeOrNull(mod.totalSize),
dependencies: mod.dependencies.map(toDependency),
scenarios: mod.scenarios
.map(toScenario)
.filter((scenario): scenario is WorkshopScenario => scenario !== null),
};
this.previewCache.set(detail.id, {
imageUrl: detail.imageUrl,
version: detail.version,
summary: detail.summary ?? detail.description,
tags: detail.tags,
expiresAt: Date.now() + PREVIEW_CACHE_TTL_MS,
}
async getVersions(modId: string): Promise<WorkshopModVersionsResponse> {
const raw = await this.get(`/v2/mods/${encodeURIComponent(modId)}/versions`);
const { data } = this.parse(versionsEnvelopeSchema, raw);
return { modId: data.modId || modId, versions: data.versions.map(toVersion) };
}
async getDependencies(modId: string): Promise<WorkshopDependency[]> {
const raw = await this.get(`/v2/mods/${encodeURIComponent(modId)}/dependencies`);
const { data } = this.parse(dependenciesEnvelopeSchema, raw);
return data.dependencies.map(toDependency);
}
async getScenarios(modId: string): Promise<WorkshopScenario[]> {
const raw = await this.get(`/v2/mods/${encodeURIComponent(modId)}/scenarios`);
const { data } = this.parse(scenariosEnvelopeSchema, raw);
return data.scenarios
.map(toScenario)
.filter((scenario): scenario is WorkshopScenario => scenario !== null);
}
async searchServers(query: string, page = 1): Promise<WorkshopServerSearchResponse> {
const search = new URLSearchParams({
page: String(Math.max(1, page)),
perPage: '25',
hasMods: 'true',
sort: 'players',
});
if (this.previewCache.size > 5_000) {
const now = Date.now();
for (const [key, value] of this.previewCache) {
if (value.expiresAt <= now) this.previewCache.delete(key);
}
}
return detail;
if (query) search.set('search', query);
const parsed = this.parse(serverSearchEnvelopeSchema, await this.get(`/v2/servers?${search}`));
return { servers: parsed.data.map(toServerSummary), meta: parsed.meta };
}
async getServerMods(serverId: string): Promise<WorkshopServerModsResponse> {
const raw = await this.get(`/v2/servers/${encodeURIComponent(serverId)}/mods?sizes=true`);
const parsed = this.parse(serverModsEnvelopeSchema, raw);
return {
serverId: parsed.serverId || serverId,
mods: parsed.data.map((mod) => ({
id: mod.id.toUpperCase(),
name: mod.name,
version: text(mod.version),
sizeBytes: sizeOrNull(mod.size),
})),
knownSizeBytes: sizeOrNull(parsed.summary?.knownSize),
unresolvedCount: parsed.summary?.unresolvedCount ?? 0,
};
}
}
@@ -1,41 +1,53 @@
import { Router } from 'express';
import { z } from 'zod';
import { WORKSHOP_SORTS } from '@reforger-panel/shared';
import { ApiError } from '../../lib/errors.js';
import { rateLimit } from '../../lib/rate-limit.js';
import { requireAuth } from '../auth/auth-middleware.js';
import type { WorkshopClient } from './workshop-client.js';
import type { WorkshopCache } from './workshop-cache.js';
const searchQuerySchema = z.object({
q: z.string().trim().max(100).default(''),
page: z.coerce.number().int().min(1).max(10_000).default(1),
sort: z.enum(['popularity', 'newest', 'subscribers', 'version_size']).optional(),
sort: z.enum(WORKSHOP_SORTS).optional(),
// Upstream rejects comma-separated tags, so this is deliberately singular.
tag: z
.string()
.trim()
.max(40)
.regex(/^[A-Za-z0-9 _-]*$/, 'Invalid tag.')
.optional(),
category: z
.string()
.trim()
.max(40)
.regex(/^[a-z0-9-]*$/, 'Invalid category.')
.optional(),
});
const modIdSchema = z.string().regex(/^[A-Za-z0-9]{1,32}$/, 'Invalid mod id.');
const serverSearchQuerySchema = z.object({
q: z.string().trim().max(100).default(''),
page: z.coerce.number().int().min(1).max(1_000).default(1),
});
export function createWorkshopRouter(client: WorkshopClient): Router {
const modIdSchema = z.string().regex(/^[A-Fa-f0-9]{16}$/, 'Invalid mod id.');
// reforgermods.net identifies servers by room UUID.
const serverIdSchema = z.string().regex(/^[A-Za-z0-9-]{8,64}$/, 'Invalid server id.');
export function createWorkshopRouter(workshop: WorkshopCache): Router {
const router = Router();
// The upstream allows 60 req/min per IP; stay well under it.
const workshopRateLimit = rateLimit({ windowMs: 60_000, max: 30, keyPrefix: 'workshop' });
// The cache does the real upstream pacing; this only guards against a
// runaway browser loop hammering our own API.
const workshopRateLimit = rateLimit({ windowMs: 60_000, max: 240, keyPrefix: 'workshop' });
router.use(requireAuth, workshopRateLimit);
router.get('/health', async (_req, res, next) => {
try {
res.json(await client.health());
} catch (error) {
next(error);
}
});
router.get('/search', async (req, res, next) => {
try {
const parsed = searchQuerySchema.safeParse(req.query);
if (!parsed.success) {
throw ApiError.validation('Invalid search parameters.');
}
const { q, page, sort } = parsed.data;
res.json(await client.search(q, page, sort));
if (!parsed.success) throw ApiError.validation('Invalid search parameters.');
const { q, page, sort, tag, category } = parsed.data;
res.json(await workshop.search({ query: q, page, sort, tag, category }));
} catch (error) {
next(error);
}
@@ -44,10 +56,39 @@ export function createWorkshopRouter(client: WorkshopClient): Router {
router.get('/mods/:id', async (req, res, next) => {
try {
const parsed = modIdSchema.safeParse(req.params.id);
if (!parsed.success) {
throw ApiError.validation('Invalid mod id.');
}
res.json(await client.getMod(parsed.data));
if (!parsed.success) throw ApiError.validation('Invalid mod id.');
res.json(await workshop.getMod(parsed.data));
} catch (error) {
next(error);
}
});
router.get('/mods/:id/versions', async (req, res, next) => {
try {
const parsed = modIdSchema.safeParse(req.params.id);
if (!parsed.success) throw ApiError.validation('Invalid mod id.');
res.json(await workshop.getVersions(parsed.data));
} catch (error) {
next(error);
}
});
/** Live server browser — backs "add a modlist based off another server". */
router.get('/servers', async (req, res, next) => {
try {
const parsed = serverSearchQuerySchema.safeParse(req.query);
if (!parsed.success) throw ApiError.validation('Invalid server search parameters.');
res.json(await workshop.searchServers(parsed.data.q, parsed.data.page));
} catch (error) {
next(error);
}
});
router.get('/servers/:id/mods', async (req, res, next) => {
try {
const parsed = serverIdSchema.safeParse(req.params.id);
if (!parsed.success) throw ApiError.validation('Invalid server id.');
res.json(await workshop.getServerMods(parsed.data));
} catch (error) {
next(error);
}
+32 -7
View File
@@ -16,7 +16,9 @@ import type { ResourceHistoryService } from '../src/modules/servers/resource-his
import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js';
import type { IngestionScheduler } from '../src/modules/reforger-logs/ingestion/scheduler.js';
import type { ServerRecord, ServerService } from '../src/modules/servers/server-service.js';
import { WorkshopClient } from '../src/modules/workshop/workshop-client.js';
import type { WorkshopCache } from '../src/modules/workshop/workshop-cache.js';
import type { MissionsService } from '../src/modules/reforger-logs/missions-catalog.js';
import { ServerMetricsService } from '../src/modules/servers/metrics-service.js';
const OWNER_ID = '111111111111111111';
@@ -115,24 +117,42 @@ function buildApp() {
sessions,
servers,
provider,
workshop: new WorkshopClient({ baseUrl: 'https://workshop.invalid' }),
workshop: {
warm: () => undefined,
peekMod: () => undefined,
tryGetMod: async () => null,
} as unknown as WorkshopCache,
metrics: new ServerMetricsService(provider, null),
consoleHub: null,
scheduler,
resolveLogPath: async () => '/profile/logs/console.log',
configSync: null,
configEditor: null,
mods: {
getMods: async () => ({ mods: [], fetchedAt: new Date().toISOString() }),
getMods: async () => ({
mods: [],
revision: 'a1b2c3d4',
fetchedAt: new Date().toISOString(),
}),
setMods: async () => ({
mods: [],
revision: 'a1b2c3d4',
fetchedAt: new Date().toISOString(),
added: 0,
removed: 0,
changed: 0,
requiresRestart: true as const,
}),
} as unknown as ServerModsService,
performance: {
get: async () => ({ settings: {}, fetchedAt: new Date().toISOString() }),
get: async () => ({
settings: {},
revision: 'a1b2c3d4',
fetchedAt: new Date().toISOString(),
}),
update: async (_server: unknown, settings: unknown) => ({
settings,
revision: 'a1b2c3d4',
fetchedAt: new Date().toISOString(),
changedFields: [],
requiresRestart: true as const,
@@ -141,7 +161,9 @@ function buildApp() {
resourceHistory: {
history: () => ({ samples: [], intervalSeconds: 15 }),
} as unknown as ResourceHistoryService,
missions: null,
missions: {
list: async () => ({ groups: [], incompleteModIds: [], fetchedAt: null }),
} as unknown as MissionsService,
});
return { app, provider, activity };
}
@@ -335,7 +357,7 @@ describe('schedule management by role', () => {
});
describe('performance config by role', () => {
const validBody = {
const validSettings = {
maxPlayers: 32,
serverMaxViewDistance: null,
networkViewDistance: null,
@@ -349,6 +371,9 @@ describe('performance config by role', () => {
slotReservationTimeout: null,
lobbyPlayerSynchronise: null,
};
// The endpoint takes a settings envelope so callers can also pass the
// revision their edits were based on.
const validBody = { settings: validSettings };
it('allows owner and server_admin, forbids mission_lead and viewer', async () => {
const { app } = buildApp();
@@ -373,7 +398,7 @@ describe('performance config by role', () => {
const response = await request(app)
.put('/api/servers/training-server/config/performance')
.set(asUser('owner-token'))
.send({ ...validBody, serverMaxViewDistance: 99999 });
.send({ settings: { ...validSettings, serverMaxViewDistance: 99999 } });
expect(response.status).toBe(400);
expect(response.body.error.message).toContain('serverMaxViewDistance');
});
+225 -43
View File
@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { WorkshopModDetail } from '@reforger-panel/shared';
import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js';
import { ConfigFileGateway } from '../src/modules/config/config-file-gateway.js';
import { ServerModsService } from '../src/modules/config/mods-service.js';
import type { ConfigSyncService } from '../src/modules/config/config-sync.js';
import type { WorkshopCache } from '../src/modules/workshop/workshop-cache.js';
import type { ServerRecord } from '../src/modules/servers/server-service.js';
import { createLogger } from '../src/lib/logger.js';
import { ApiError } from '../src/lib/errors.js';
@@ -19,38 +20,81 @@ const server: ServerRecord = {
updatedAt: new Date(),
};
const MOCK_MOD = '591AF5BDA9F7CE8B';
const ADMIN_TOOLS = '5AAF0CCE3F001FB5';
const DEPENDENCY = 'BBBB000000000001';
function workshopDetail(overrides: Partial<WorkshopModDetail> & { id: string }) {
return {
name: `Mod ${overrides.id}`,
author: 'Author',
summary: null,
imageUrl: null,
workshopUrl: null,
version: '1.0.2',
gameVersion: null,
sizeBytes: 1024,
sizeText: '1 KiB',
rating: null,
ratingCount: null,
subscriberCount: null,
createdAt: null,
updatedAt: null,
tags: [],
obsolete: false,
description: null,
license: null,
downloadCount: null,
previewImages: [],
screenshots: [],
versionCount: 1,
dependencyCount: 0,
scenarioCount: 0,
dependencySizeBytes: null,
totalSizeBytes: null,
dependencies: [],
scenarios: [],
...overrides,
} as WorkshopModDetail;
}
function fakeWorkshop(catalog: Record<string, WorkshopModDetail> = {}) {
const resolved = new Map(Object.entries(catalog));
return {
warm: vi.fn(),
peekMod: (id: string) => resolved.get(id.toUpperCase()) ?? null,
tryGetMod: async (id: string) => resolved.get(id.toUpperCase()) ?? null,
getMod: async (id: string) => {
const mod = resolved.get(id.toUpperCase());
if (!mod) throw ApiError.notFound('Workshop mod not found.');
return mod;
},
} as unknown as WorkshopCache;
}
describe('ServerModsService', () => {
let provider: MockGameServerProvider;
let service: ServerModsService;
let configSyncCalled: number;
let gateway: ConfigFileGateway;
function build(catalog: Record<string, WorkshopModDetail> = {}) {
return new ServerModsService(gateway, fakeWorkshop(catalog), createLogger('silent'));
}
beforeEach(() => {
provider = new MockGameServerProvider();
configSyncCalled = 0;
const configSync = {
sync: async () => {
configSyncCalled += 1;
return { changed: true, revisionVersion: 2, serverName: 'x', maxPlayers: 16 };
},
} as unknown as ConfigSyncService;
service = new ServerModsService(
new ConfigFileGateway(provider, '/config.json'),
configSync,
createLogger('silent'),
);
gateway = new ConfigFileGateway(provider, '/config.json');
});
it('reads the current mods from config.json', async () => {
const result = await service.getMods(server);
expect(result.mods).toEqual([
{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' },
]);
it('reads the current mods from config.json with a revision to write back against', async () => {
const result = await build().getMods(server);
expect(result.mods).toEqual([{ modId: MOCK_MOD, name: 'Mock Sample Mod', version: '1.0.2' }]);
expect(result.revision).toMatch(/^[a-f0-9]{16}$/);
});
it('writes the new mod list while preserving every other config field', async () => {
const result = await service.setMods(server, [
{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' },
{ modId: '5AAF0CCE3F001FB5', name: 'Server Admin Tools' },
const result = await build().setMods(server, [
{ modId: MOCK_MOD, name: 'Mock Sample Mod', version: '1.0.2' },
{ modId: ADMIN_TOOLS, name: 'Server Admin Tools' },
]);
expect(result.added).toBe(1);
@@ -58,14 +102,11 @@ describe('ServerModsService', () => {
expect(result.requiresRestart).toBe(true);
expect(result.mods).toHaveLength(2);
const written = provider.writtenFiles.get('/config.json')!;
const parsed = JSON.parse(written);
// game.mods replaced…
const parsed = JSON.parse(provider.writtenFiles.get('/config.json')!);
expect(parsed.game.mods).toEqual([
{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' },
{ modId: '5AAF0CCE3F001FB5', name: 'Server Admin Tools' },
{ modId: MOCK_MOD, name: 'Mock Sample Mod', version: '1.0.2' },
{ modId: ADMIN_TOOLS, name: 'Server Admin Tools' },
]);
// …everything else untouched.
expect(parsed.bindPort).toBe(2001);
expect(parsed.game.name).toBe('Mock Reforger Server');
expect(parsed.game.maxPlayers).toBe(16);
@@ -73,37 +114,178 @@ describe('ServerModsService', () => {
expect(parsed.operating.aiLimit).toBe(40);
});
it('writes a rollback backup of the previous file before modifying it', async () => {
const before = (await provider.downloadTextFile('abc123', '/config.json')).content;
await service.setMods(server, []);
expect(provider.writtenFiles.get('/config.json.bak')).toBe(before);
// Removal reflected in the live file.
const after = JSON.parse(provider.writtenFiles.get('/config.json')!);
expect(after.game.mods).toEqual([]);
it('counts a version change as changed rather than add plus remove', async () => {
const result = await build().setMods(server, [
{ modId: MOCK_MOD, name: 'Mock Sample Mod', version: '2.0.0' },
]);
expect(result).toMatchObject({ added: 0, removed: 0, changed: 1 });
});
it('imports a config revision after a successful write', async () => {
await service.setMods(server, []);
expect(configSyncCalled).toBe(1);
it('writes a rollback backup of the previous file before modifying it', async () => {
const before = (await provider.downloadTextFile('abc123', '/config.json')).content;
await build().setMods(server, []);
expect(provider.writtenFiles.get('/config.json.bak')).toBe(before);
expect(JSON.parse(provider.writtenFiles.get('/config.json')!).game.mods).toEqual([]);
});
it('normalizes mod ids to uppercase and drops empty name/version', async () => {
const result = await service.setMods(server, [{ modId: '69c566706abd5a3c', name: '' }]);
const result = await build().setMods(server, [{ modId: '69c566706abd5a3c', name: '' }]);
expect(result.mods).toEqual([{ modId: '69C566706ABD5A3C' }]);
});
it('rejects a write based on a stale revision instead of clobbering it', async () => {
const service = build();
const stale = (await service.getMods(server)).revision;
// Somebody else edits the file in between.
await service.setMods(server, [{ modId: ADMIN_TOOLS }]);
await expect(service.setMods(server, [], stale)).rejects.toMatchObject({ code: 'CONFLICT' });
});
it('fails the write when read-back verification does not match', async () => {
// Simulate a server that ignores writes to config.json.
const originalWrite = provider.writeTextFile.bind(provider);
vi.spyOn(provider, 'writeTextFile').mockImplementation(async (sid, path, content) => {
if (path === '/config.json') return; // swallow the write
await originalWrite(sid, path, content);
});
await expect(service.setMods(server, [])).rejects.toThrow(/verification failed/);
await expect(build().setMods(server, [])).rejects.toThrow(/verification failed/);
});
it('refuses to modify a config without a game section', async () => {
await provider.writeTextFile('abc123', '/config.json', '{"something": true}');
await expect(service.setMods(server, [])).rejects.toThrow(ApiError);
await expect(build().setMods(server, [])).rejects.toThrow(ApiError);
});
describe('overview', () => {
it('joins installed mods with workshop metadata and flags updates', async () => {
const service = build({
[MOCK_MOD]: workshopDetail({ id: MOCK_MOD, name: 'Mock Sample Mod', version: '1.4.0' }),
});
const overview = await service.getOverview(server);
expect(overview.mods).toHaveLength(1);
const entry = overview.mods[0]!;
expect(entry.pinnedVersion).toBe('1.0.2');
expect(entry.workshop?.latestVersion).toBe('1.4.0');
expect(entry.updateAvailable).toBe(true);
expect(overview.updatesAvailable).toBe(1);
expect(overview.warming).toBe(false);
});
it('lists missing dependencies and removal blockers', async () => {
await build().setMods(server, [{ modId: MOCK_MOD }, { modId: ADMIN_TOOLS }]);
const service = build({
[MOCK_MOD]: workshopDetail({
id: MOCK_MOD,
dependencyCount: 1,
dependencies: [
{
id: DEPENDENCY,
name: 'Required Pack',
version: null,
sizeBytes: 10,
published: true,
private: false,
},
],
}),
[ADMIN_TOOLS]: workshopDetail({
id: ADMIN_TOOLS,
dependencyCount: 1,
dependencies: [
{
id: MOCK_MOD,
name: 'Mock Sample Mod',
version: null,
sizeBytes: 10,
published: true,
private: false,
},
],
}),
});
const overview = await service.getOverview(server);
const sample = overview.mods.find((mod) => mod.modId === MOCK_MOD)!;
expect(sample.missingDependencies.map((dep) => dep.id)).toEqual([DEPENDENCY]);
// Admin Tools depends on the sample mod, so removing it is unsafe.
expect(sample.requiredBy).toEqual([ADMIN_TOOLS]);
});
it('reports mods the workshop could not resolve', async () => {
const overview = await build().getOverview(server);
expect(overview.unresolvedIds).toEqual([MOCK_MOD]);
expect(overview.mods[0]!.workshop).toBeNull();
});
});
describe('resolve', () => {
it('pulls in transitive dependencies with their sizes', async () => {
const service = build({
[MOCK_MOD]: workshopDetail({
id: MOCK_MOD,
sizeBytes: 100,
dependencyCount: 1,
dependencies: [
{
id: DEPENDENCY,
name: 'Required Pack',
version: null,
sizeBytes: 900,
published: true,
private: false,
},
],
}),
[DEPENDENCY]: workshopDetail({ id: DEPENDENCY, name: 'Required Pack', sizeBytes: 900 }),
});
const result = await service.resolve([{ modId: MOCK_MOD, version: '1.0.2' }]);
expect(result.mods.map((mod) => mod.modId).sort()).toEqual([DEPENDENCY, MOCK_MOD].sort());
expect(result.addedDependencies).toHaveLength(1);
expect(result.addedDependencies[0]).toMatchObject({
modId: DEPENDENCY,
viaDependency: true,
requiredBy: [MOCK_MOD],
});
expect(result.totalSizeBytes).toBe(1000);
});
it('does not loop forever on a circular dependency graph', async () => {
const service = build({
[MOCK_MOD]: workshopDetail({
id: MOCK_MOD,
dependencies: [
{
id: DEPENDENCY,
name: 'B',
version: null,
sizeBytes: 1,
published: true,
private: false,
},
],
}),
[DEPENDENCY]: workshopDetail({
id: DEPENDENCY,
dependencies: [
{
id: MOCK_MOD,
name: 'A',
version: null,
sizeBytes: 1,
published: true,
private: false,
},
],
}),
});
const result = await service.resolve([{ modId: MOCK_MOD }]);
expect(result.mods).toHaveLength(2);
});
it('names ids the workshop does not know instead of dropping them', async () => {
const result = await build().resolve([{ modId: 'CCCC000000000009' }]);
expect(result.unresolvedIds).toEqual(['CCCC000000000009']);
});
});
});
+102 -11
View File
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { MockGameServerProvider } from '../src/modules/pterodactyl/mock-provider.js';
import { ConfigEditorService } from '../src/modules/config/config-editor-service.js';
import { ConfigFileGateway } from '../src/modules/config/config-file-gateway.js';
import { PerformanceSettingsService } from '../src/modules/config/performance-service.js';
import type { ConfigSyncService } from '../src/modules/config/config-sync.js';
@@ -20,16 +21,16 @@ const server: ServerRecord = {
describe('PerformanceSettingsService', () => {
let provider: MockGameServerProvider;
let gateway: ConfigFileGateway;
let editor: ConfigEditorService;
let service: PerformanceSettingsService;
beforeEach(() => {
provider = new MockGameServerProvider();
gateway = new ConfigFileGateway(provider, '/config.json');
const configSync = { sync: async () => ({}) } as unknown as ConfigSyncService;
service = new PerformanceSettingsService(
new ConfigFileGateway(provider, '/config.json'),
configSync,
createLogger('silent'),
);
editor = new ConfigEditorService(gateway, provider, configSync, createLogger('silent'));
service = new PerformanceSettingsService(gateway, editor, createLogger('silent'));
});
it('reads current values, reporting absent keys as null', async () => {
@@ -46,16 +47,19 @@ describe('PerformanceSettingsService', () => {
});
it('sets changed values and removes nulled keys, preserving everything else', async () => {
const { settings } = await service.get(server);
const result = await service.update(server, {
...settings,
maxPlayers: 32,
playerSaveTime: 180, // new key
disableAI: null,
aiLimit: null, // remove key game default
aiLimit: null, // remove key -> game default
});
expect(result.changedFields.sort()).toEqual(['aiLimit', 'disableAI', 'maxPlayers', 'playerSaveTime']);
expect(result.changedFields.sort()).toEqual([
'aiLimit',
'disableAI',
'maxPlayers',
'playerSaveTime',
]);
expect(result.requiresRestart).toBe(true);
const written = JSON.parse(provider.writtenFiles.get('/config.json')!);
@@ -66,14 +70,101 @@ describe('PerformanceSettingsService', () => {
expect(written.bindPort).toBe(2001);
expect(written.game.scenarioId).toContain('Missions');
expect(written.game.mods).toHaveLength(1);
// Backup written:
expect(provider.writtenFiles.get('/config.json.bak')).toBeTruthy();
});
/**
* The old form posted every field on every save, so a form loaded before
* somebody else's change silently reverted it. Only the submitted keys may
* ever be written.
*/
it('leaves fields the caller did not submit alone', async () => {
await service.update(server, { maxPlayers: 48 });
const written = JSON.parse(provider.writtenFiles.get('/config.json')!);
expect(written.game.maxPlayers).toBe(48);
expect(written.game.gameProperties.serverMaxViewDistance).toBe(2500);
expect(written.operating.aiLimit).toBe(40);
});
it('does not write the file at all when nothing changed', async () => {
const { settings } = await service.get(server);
const result = await service.update(server, settings);
const result = await service.update(server, {
maxPlayers: settings.maxPlayers,
aiLimit: settings.aiLimit,
});
expect(result.changedFields).toEqual([]);
expect(provider.writtenFiles.has('/config.json')).toBe(false);
});
it('rejects a save based on a revision that has since moved', async () => {
const stale = (await service.get(server)).revision;
await service.update(server, { maxPlayers: 24 });
await expect(
service.update(server, { maxPlayers: 48 }, { expectedRevision: stale }),
).rejects.toMatchObject({ code: 'CONFLICT' });
});
});
describe('ConfigEditorService', () => {
let provider: MockGameServerProvider;
let editor: ConfigEditorService;
beforeEach(() => {
provider = new MockGameServerProvider();
const gateway = new ConfigFileGateway(provider, '/config.json');
const configSync = { sync: async () => ({}) } as unknown as ConfigSyncService;
editor = new ConfigEditorService(gateway, provider, configSync, createLogger('silent'));
});
it('exposes every key in the file, not just the ones the panel knows', async () => {
const tree = await editor.getTree(server);
const paths = tree.entries.map((entry) => entry.path);
expect(paths).toContain('bindAddress');
expect(paths).toContain('game.crossPlatform');
expect(paths).toContain('game.gameProperties.networkViewDistance');
expect(tree.revision).toMatch(/^[a-f0-9]{16}$/);
});
/**
* Reforger eggs often re-template config.json from startup variables at
* boot, which is why edits could appear to save and then vanish.
*/
it('flags config keys that a startup variable also controls', async () => {
const tree = await editor.getTree(server);
const mirror = tree.mirrors.find((entry) => entry.envVariable === 'MAX_PLAYERS');
expect(mirror).toBeDefined();
expect(mirror!.configPath).toBe('game.maxPlayers');
});
it('patches an arbitrary path and reports what changed', async () => {
const result = await editor.patch(server, [
{ path: 'operating.slotReservationTimeout', value: 90 },
]);
expect(result.changedPaths).toEqual(['operating.slotReservationTimeout']);
const written = JSON.parse(provider.writtenFiles.get('/config.json')!);
expect(written.operating.slotReservationTimeout).toBe(90);
});
it('mirrors a changed value into its startup variable when asked', async () => {
const result = await editor.patch(server, [{ path: 'game.maxPlayers', value: 40 }], {
writeStartupVars: true,
});
expect(result.startupVarsWritten).toContain('MAX_PLAYERS');
const variables = await provider.listStartupVariables();
expect(variables.find((v) => v.envVariable === 'MAX_PLAYERS')?.serverValue).toBe('40');
});
it('round-trips the raw editor and rejects invalid JSON', async () => {
const raw = await editor.getRaw(server);
expect(JSON.parse(raw.content).game.name).toBe('Mock Reforger Server');
await expect(editor.putRaw(server, '{ nope')).rejects.toMatchObject({
code: 'VALIDATION_ERROR',
});
});
it('refuses a raw write that would drop the game section', async () => {
await expect(editor.putRaw(server, '{"bindPort":2001}')).rejects.toMatchObject({
code: 'VALIDATION_ERROR',
});
});
});
+33 -18
View File
@@ -4,20 +4,27 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { useCurrentUser } from './api/hooks.js';
import { api, ApiClientError } from './api/client.js';
import { Layout } from './components/layout.js';
import { Spinner } from './components/ui.js';
import { EmptyState, Spinner, ToastProvider } from './components/ui.js';
import { LoginPage } from './pages/login.js';
import { OverviewPage } from './pages/overview.js';
import { ModsPage } from './pages/mods.js';
import { LogsPage } from './pages/logs.js';
import {
ActivityPage,
ConfigurationsPage,
KillfeedPage,
PlayersPage,
SettingsPage,
} from './pages/simple-pages.js';
import { ConfigurationPage } from './pages/configuration.js';
import { MissionPage } from './pages/mission.js';
import { ConsolePage } from './pages/console.js';
import { ActivityPage, KillfeedPage, PlayersPage, SettingsPage } from './pages/simple-pages.js';
const queryClient = new QueryClient();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Config and mod reads hit the game server; do not re-fetch them just
// because a tab regained focus.
refetchOnWindowFocus: false,
retry: (failureCount, error) =>
!(error instanceof ApiClientError && error.status >= 400 && error.status < 500) &&
failureCount < 2,
},
},
});
/** Redeems a stored invite code once, right after login, then refreshes /me. */
function InviteRedeemer() {
@@ -49,8 +56,12 @@ function AuthGate() {
}
if (!user) {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-danger-400">
Could not reach the panel API. Is the backend running?
<div className="flex min-h-screen items-center justify-center p-6">
<EmptyState
icon="alert"
title="Could not reach the panel API"
hint="Is the backend running?"
/>
</div>
);
}
@@ -62,13 +73,15 @@ function AuthGate() {
<Route element={<Layout user={user} />}>
<Route index element={<OverviewPage user={user} />} />
<Route path="/mods" element={<ModsPage user={user} />} />
<Route path="/configuration" element={<ConfigurationsPage user={user} />} />
<Route path="/configuration" element={<ConfigurationPage user={user} />} />
<Route path="/mission" element={<MissionPage user={user} />} />
<Route path="/players" element={<PlayersPage />} />
<Route path="/killfeed" element={<KillfeedPage />} />
<Route path="/activity" element={<ActivityPage />} />
<Route path="/logs" element={<LogsPage />} />
<Route path="/console" element={<ConsolePage />} />
<Route path="/settings" element={<SettingsPage user={user} />} />
{/* Old bookmarks from the tabbed server page and plural path. */}
{/* Old bookmarks. */}
<Route path="/logs" element={<Navigate to="/console" replace />} />
<Route path="/server/:slug" element={<Navigate to="/" replace />} />
<Route path="/configurations" element={<Navigate to="/configuration" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
@@ -81,9 +94,11 @@ function AuthGate() {
export function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthGate />
</BrowserRouter>
<ToastProvider>
<BrowserRouter>
<AuthGate />
</BrowserRouter>
</ToastProvider>
</QueryClientProvider>
);
}
+354 -176
View File
@@ -1,37 +1,49 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type {
ActivityItem,
ConfigPatchOp,
ConfigPatchResult,
ConfigRawResponse,
ConfigTreeResponse,
ConfigurationResponse,
ConsoleBacklog,
ConsoleLine,
CurrentUser,
InviteSummary,
KillfeedEvent,
MissionsResponse,
ModsCheckResponse,
PerformanceSettingsPatch,
PerformanceSettingsResponse,
RawLogsResponse,
RestartScheduleInput,
ResourceHistoryResponse,
StartupResponse,
KnownPlayer,
LogIngestionHealth,
LogSyncResult,
MissionsResponse,
ModPackSummary,
ModResolveResponse,
ModsOverviewResponse,
PanelUser,
PerformanceSettingsPatch,
PerformanceSettingsResponse,
PlayersResponse,
RawLogsResponse,
ReforgerConfigMod,
ResourceHistoryResponse,
RestartScheduleInput,
ServerModsResponse,
UpdateModsResult,
ServerResources,
ServerScheduleSummary,
ServerStatus,
ServerSummary,
WorkshopHealth,
StartupResponse,
UpdateModsResult,
WorkshopModDetail,
WorkshopModVersionsResponse,
WorkshopSearchResponse,
WorkshopServerModsResponse,
WorkshopServerSearchResponse,
} from '@reforger-panel/shared';
import { api, ApiClientError } from './client.js';
/* -------------------------------------------------------------------- auth */
export function useCurrentUser() {
return useQuery({
queryKey: ['auth', 'me'],
@@ -50,6 +62,8 @@ export function useLogout() {
});
}
/* ----------------------------------------------------------------- servers */
export function useServers() {
return useQuery({
queryKey: ['servers'],
@@ -58,23 +72,43 @@ export function useServers() {
});
}
export function useServer(slug: string) {
return useQuery({
queryKey: ['servers', slug],
queryFn: () => api.get<ServerSummary>(`/api/servers/${slug}`),
refetchInterval: 15_000,
});
/** The panel manages one server; every page derives its slug from here. */
export function usePrimaryServer(): ServerSummary | undefined {
return useServers().data?.servers[0];
}
export function useServerResources(slug: string, enabled = true) {
return useQuery({
queryKey: ['servers', slug, 'resources'],
queryFn: () => api.get<ServerResources>(`/api/servers/${slug}/resources`),
refetchInterval: 10_000,
// Backed by the websocket stats frame server-side, so this is a cheap
// in-memory read rather than an upstream request.
refetchInterval: 5_000,
enabled,
});
}
export function useResourceHistory(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'resources', 'history'],
queryFn: () => api.get<ResourceHistoryResponse>(`/api/servers/${slug}/resources/history`),
refetchInterval: 15_000,
});
}
export function usePowerAction(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (action: 'start' | 'stop' | 'restart') =>
api.post<{ ok: boolean; simulated: boolean }>(`/api/servers/${slug}/power/${action}`),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['servers'] });
},
});
}
/* ----------------------------------------------------------------- players */
export function usePlayers(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'players'],
@@ -109,6 +143,8 @@ export function useKillfeed(slug: string, limit = 100) {
});
}
/* ----------------------------------------------------------- configuration */
export function useConfiguration(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'configuration'],
@@ -119,45 +155,75 @@ export function useConfiguration(slug: string) {
});
}
export function useMissions(slug: string) {
export function usePerformanceSettings(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'missions'],
queryFn: () => api.get<MissionsResponse>(`/api/servers/${slug}/missions`),
staleTime: 60_000,
queryKey: ['servers', slug, 'config', 'performance'],
queryFn: () => api.get<PerformanceSettingsResponse>(`/api/servers/${slug}/config/performance`),
staleTime: 30_000,
refetchOnWindowFocus: false,
});
}
/**
* Opens a persistent SSE connection to stream live console output line by line.
* `onLine` is called for each received line. The connection closes and re-opens
* automatically when the component unmounts or `slug` changes.
*/
export function useConsoleStream(slug: string, onLine: (line: string) => void, enabled: boolean) {
const onLineRef = useRef(onLine);
onLineRef.current = onLine;
export type PerformanceSavePayload = {
settings: PerformanceSettingsPatch;
expectedRevision?: string;
writeStartupVars?: boolean;
};
useEffect(() => {
if (!enabled || !slug) return;
const es = new EventSource(`/api/servers/${slug}/logs/stream`, { withCredentials: true });
es.onmessage = (e: MessageEvent<string>) => {
try {
const line = JSON.parse(e.data) as string;
onLineRef.current(line);
} catch {
// ignore
}
};
es.onerror = () => es.close();
return () => es.close();
}, [slug, enabled]);
export function useSetPerformanceSettings(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: PerformanceSavePayload) =>
api.put<PerformanceSettingsResponse & { changedFields: string[]; requiresRestart: boolean }>(
`/api/servers/${slug}/config/performance`,
payload,
),
onSuccess: () => {
// config.json moved: every view derived from it is now stale.
void queryClient.invalidateQueries({ queryKey: ['servers', slug] });
},
});
}
export function useRawLogs(slug: string, lines: number, autoRefresh: boolean, enabled: boolean) {
/** Every key present in config.json, for the searchable editor. */
export function useConfigTree(slug: string, enabled: boolean) {
return useQuery({
queryKey: ['servers', slug, 'logs', 'raw', lines],
queryFn: () => api.get<RawLogsResponse>(`/api/servers/${slug}/logs/raw?lines=${lines}`),
refetchInterval: autoRefresh ? 10_000 : false,
queryKey: ['servers', slug, 'config', 'tree'],
queryFn: () => api.get<ConfigTreeResponse>(`/api/servers/${slug}/config/tree`),
enabled,
staleTime: 30_000,
refetchOnWindowFocus: false,
});
}
export function usePatchConfig(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: {
ops: ConfigPatchOp[];
expectedRevision?: string;
writeStartupVars?: boolean;
}) => api.patch<ConfigPatchResult>(`/api/servers/${slug}/config`, payload),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['servers', slug] }),
});
}
export function useConfigRaw(slug: string, enabled: boolean) {
return useQuery({
queryKey: ['servers', slug, 'config', 'raw'],
queryFn: () => api.get<ConfigRawResponse>(`/api/servers/${slug}/config/raw`),
enabled,
staleTime: 30_000,
refetchOnWindowFocus: false,
});
}
export function usePutConfigRaw(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: { content: string; expectedRevision?: string }) =>
api.put<ConfigRawResponse>(`/api/servers/${slug}/config/raw`, payload),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['servers', slug] }),
});
}
@@ -183,6 +249,59 @@ export function useUpdateStartupVariable(slug: string) {
});
}
/* ---------------------------------------------------------------- missions */
export function useMissions(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'missions'],
queryFn: () => api.get<MissionsResponse>(`/api/servers/${slug}/missions`),
staleTime: 5 * 60_000,
refetchOnWindowFocus: false,
});
}
/* -------------------------------------------------------------------- mods */
export function useServerMods(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'mods'],
queryFn: () => api.get<ServerModsResponse>(`/api/servers/${slug}/mods`),
staleTime: 60_000,
refetchOnWindowFocus: false,
});
}
/**
* The whole Mods page in one request. While the server-side Workshop cache is
* still filling (`warming`), this refetches shortly so metadata appears
* progressively instead of blocking the first paint.
*/
export function useModsOverview(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'mods', 'overview'],
queryFn: () => api.get<ModsOverviewResponse>(`/api/servers/${slug}/mods/overview`),
staleTime: 60_000,
refetchOnWindowFocus: false,
refetchInterval: (query) => (query.state.data?.warming ? 3_000 : false),
});
}
export function useResolveMods(slug: string) {
return useMutation({
mutationFn: (mods: ReforgerConfigMod[]) =>
api.post<ModResolveResponse>(`/api/servers/${slug}/mods/resolve`, { mods }),
});
}
export function useSetServerMods(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: { mods: ReforgerConfigMod[]; expectedRevision?: string }) =>
api.put<UpdateModsResult>(`/api/servers/${slug}/mods`, payload),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['servers', slug] }),
});
}
export function useModPacks(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'mod-packs'],
@@ -190,128 +309,189 @@ export function useModPacks(slug: string) {
});
}
/* ---------------------------------------------------------------- workshop */
export type WorkshopSearchParams = {
query: string;
page: number;
sort?: string;
tag?: string;
category?: string;
};
export function useWorkshopSearch(params: WorkshopSearchParams, enabled = true) {
const search = new URLSearchParams({ q: params.query, page: String(params.page) });
if (params.sort) search.set('sort', params.sort);
if (params.tag) search.set('tag', params.tag);
if (params.category) search.set('category', params.category);
return useQuery({
queryKey: ['workshop', 'search', params],
queryFn: () => api.get<WorkshopSearchResponse>(`/api/workshop/search?${search}`),
enabled,
placeholderData: (previous) => previous,
staleTime: 5 * 60_000,
});
}
export function useWorkshopMod(modId: string | null) {
return useQuery({
queryKey: ['workshop', 'mod', modId],
queryFn: () => api.get<WorkshopModDetail>(`/api/workshop/mods/${modId}`),
enabled: modId !== null,
staleTime: 30 * 60_000,
});
}
export function useWorkshopModVersions(modId: string | null) {
return useQuery({
queryKey: ['workshop', 'mod', modId, 'versions'],
queryFn: () => api.get<WorkshopModVersionsResponse>(`/api/workshop/mods/${modId}/versions`),
enabled: modId !== null,
staleTime: 30 * 60_000,
});
}
/** Live server browser, used to copy another server's modlist. */
export function useWorkshopServers(query: string, enabled: boolean) {
return useQuery({
queryKey: ['workshop', 'servers', query],
queryFn: () =>
api.get<WorkshopServerSearchResponse>(`/api/workshop/servers?q=${encodeURIComponent(query)}`),
enabled: enabled && query.trim().length >= 2,
staleTime: 60_000,
});
}
export function useWorkshopServerMods(serverId: string | null) {
return useQuery({
queryKey: ['workshop', 'servers', serverId, 'mods'],
queryFn: () => api.get<WorkshopServerModsResponse>(`/api/workshop/servers/${serverId}/mods`),
enabled: serverId !== null,
staleTime: 60_000,
});
}
/* ------------------------------------------------------------ live console */
const MAX_CONSOLE_LINES = 2_000;
export type ConsoleFeed = {
lines: ConsoleLine[];
status: ServerStatus;
connected: boolean;
stats: ServerResources | null;
clear: () => void;
};
/**
* Subscribes to the panel's SSE relay of the Pterodactyl/Wings feed.
*
* Because the backend keeps its own line backlog, attaching mid-session
* immediately yields recent context including install, update and mod
* download output, which never reaches the game's own log file.
*/
export function useConsoleFeed(slug: string, enabled: boolean): ConsoleFeed {
const [lines, setLines] = useState<ConsoleLine[]>([]);
const [status, setStatus] = useState<ServerStatus>('unknown');
const [connected, setConnected] = useState(false);
const [stats, setStats] = useState<ServerResources | null>(null);
const lastSeq = useRef(0);
useEffect(() => {
if (!enabled || !slug) return;
const source = new EventSource(`/api/servers/${slug}/console/stream`, {
withCredentials: true,
});
const append = (incoming: ConsoleLine[]) => {
const fresh = incoming.filter((line) => line.seq > lastSeq.current);
if (fresh.length === 0) return;
lastSeq.current = fresh[fresh.length - 1]!.seq;
setLines((current) => {
const next = [...current, ...fresh];
return next.length > MAX_CONSOLE_LINES ? next.slice(next.length - MAX_CONSOLE_LINES) : next;
});
};
const parse = <T>(event: MessageEvent<string>): T | null => {
try {
return JSON.parse(event.data) as T;
} catch {
return null;
}
};
source.addEventListener('backlog', (event) => {
const backlog = parse<ConsoleBacklog>(event as MessageEvent<string>);
if (!backlog) return;
// A reconnect replays the backlog; seq numbers keep it idempotent.
append(backlog.lines);
setStatus(backlog.status);
setConnected(backlog.connected);
});
source.addEventListener('line', (event) => {
const line = parse<ConsoleLine>(event as MessageEvent<string>);
if (line) append([line]);
});
source.addEventListener('status', (event) => {
const payload = parse<{ status: ServerStatus }>(event as MessageEvent<string>);
if (payload) setStatus(payload.status);
});
source.addEventListener('stats', (event) => {
const payload = parse<ServerResources>(event as MessageEvent<string>);
if (payload) setStats(payload);
});
source.onopen = () => setConnected(true);
source.onerror = () => setConnected(false);
return () => source.close();
}, [slug, enabled]);
return {
lines,
status,
connected,
stats,
clear: () => setLines([]),
};
}
/** The game's own log file, as a secondary diagnostic to the live feed. */
export function useRawLogs(slug: string, lines: number, enabled: boolean) {
return useQuery({
queryKey: ['servers', slug, 'logs', 'raw', lines],
queryFn: () => api.get<RawLogsResponse>(`/api/servers/${slug}/logs/raw?lines=${lines}`),
enabled,
staleTime: 10_000,
refetchOnWindowFocus: false,
});
}
/* ----------------------------------------------------------- log ingestion */
export function useLogHealth(slug: string, enabled: boolean) {
return useQuery({
queryKey: ['servers', slug, 'logs', 'health'],
queryFn: () => api.get<LogIngestionHealth>(`/api/servers/${slug}/logs/health`),
refetchInterval: 20_000,
refetchInterval: 30_000,
enabled,
});
}
export function usePowerAction(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (action: 'start' | 'stop' | 'restart') =>
api.post<{ ok: boolean; simulated: boolean }>(`/api/servers/${slug}/power/${action}`),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['servers'] });
},
});
}
export function useResourceHistory(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'resources', 'history'],
queryFn: () => api.get<ResourceHistoryResponse>(`/api/servers/${slug}/resources/history`),
refetchInterval: 15_000,
});
}
export function usePerformanceSettings(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'config', 'performance'],
queryFn: () => api.get<PerformanceSettingsResponse>(`/api/servers/${slug}/config/performance`),
staleTime: 60_000,
refetchOnWindowFocus: false,
});
}
export function useSetPerformanceSettings(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (settings: PerformanceSettingsPatch) =>
api.put<PerformanceSettingsResponse & { changedFields: string[]; requiresRestart: boolean }>(
`/api/servers/${slug}/config/performance`,
settings,
),
onSuccess: (result) => {
queryClient.setQueryData(['servers', slug, 'config', 'performance'], result);
void queryClient.invalidateQueries({ queryKey: ['servers', slug] });
},
});
}
export function useInvites(enabled: boolean) {
return useQuery({
queryKey: ['invites'],
queryFn: () => api.get<{ invites: InviteSummary[] }>('/api/invites'),
enabled,
});
}
export function useCreateInvite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: { role: string; expiresInHours?: number | null }) =>
api.post<{ id: string; code: string; role: string; expiresAt: string }>(
'/api/invites',
input,
),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['invites'] }),
});
}
export function useDeleteInvite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.delete(`/api/invites/${id}`),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['invites'] }),
});
}
export function useServerMods(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'mods'],
queryFn: () => api.get<ServerModsResponse>(`/api/servers/${slug}/mods`),
// Each call downloads config.json from Pterodactyl — no background polling.
staleTime: 60_000,
refetchOnWindowFocus: false,
});
}
export function useSetServerMods(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (mods: ReforgerConfigMod[]) =>
api.put<UpdateModsResult>(`/api/servers/${slug}/mods`, { mods }),
onSuccess: (result) => {
queryClient.setQueryData(['servers', slug, 'mods'], result);
void queryClient.invalidateQueries({ queryKey: ['servers', slug] });
},
});
}
export function useServerModsCheck(slug: string, enabled: boolean) {
return useQuery({
queryKey: ['servers', slug, 'mods', 'check'],
queryFn: () => api.get<ModsCheckResponse>(`/api/servers/${slug}/mods/check`),
enabled,
staleTime: 2 * 60_000,
refetchOnWindowFocus: false,
});
}
export function useManualLogSync(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => api.post<LogSyncResult>(`/api/servers/${slug}/logs/sync`),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['servers', slug] });
},
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['servers', slug] }),
});
}
/* --------------------------------------------------------------- schedules */
export function useServerSchedules(slug: string, enabled: boolean) {
return useQuery({
queryKey: ['servers', slug, 'schedules'],
@@ -359,35 +539,33 @@ export function useDeleteSchedule(slug: string) {
});
}
export function useWorkshopHealth() {
/* --------------------------------------------------------- users & invites */
export function useInvites(enabled: boolean) {
return useQuery({
queryKey: ['workshop', 'health'],
queryFn: () => api.get<WorkshopHealth>('/api/workshop/health'),
refetchInterval: 60_000,
queryKey: ['invites'],
queryFn: () => api.get<{ invites: InviteSummary[] }>('/api/invites'),
enabled,
});
}
export function useWorkshopSearch(query: string, page: number, sort?: string) {
return useQuery({
queryKey: ['workshop', 'search', query, page, sort],
queryFn: () =>
api.get<WorkshopSearchResponse>(
`/api/workshop/search?q=${encodeURIComponent(query)}&page=${page}${
sort ? `&sort=${encodeURIComponent(sort)}` : ''
}`,
export function useCreateInvite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: { role: string; expiresInHours?: number | null }) =>
api.post<{ id: string; code: string; role: string; expiresAt: string }>(
'/api/invites',
input,
),
// An empty query browses the Workshop front page (/v1/mods).
placeholderData: (previous) => previous,
staleTime: 5 * 60_000,
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['invites'] }),
});
}
export function useWorkshopMod(modId: string | null) {
return useQuery({
queryKey: ['workshop', 'mod', modId],
queryFn: () => api.get<WorkshopModDetail>(`/api/workshop/mods/${modId}`),
enabled: modId !== null,
staleTime: 5 * 60_000,
export function useDeleteInvite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.delete(`/api/invites/${id}`),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['invites'] }),
});
}
+136 -34
View File
@@ -1,3 +1,5 @@
import { useId, useRef, useState } from 'react';
export type ChartSeries = {
points: { t: number; v: number }[];
/** Any CSS color; used for the line and (when filled) the area. */
@@ -9,24 +11,34 @@ export type ChartSeries = {
/**
* Dependency-free SVG time-series chart. Series share the x (time) axis and a
* single y scale (`max` fixes it, e.g. 100 for CPU%; otherwise it fits data).
*
* Unlike a plain sparkline this is readable: it carries a gridline, an axis
* maximum, and a hover crosshair that reports the value under the pointer
* previously there was no way to get a number off these graphs at all.
*/
export function TimeSeriesChart({
series,
max,
height = 64,
height = 56,
className = '',
format = (value: number) => value.toFixed(0),
}: {
series: ChartSeries[];
max?: number | null;
height?: number;
className?: string;
format?: (value: number) => string;
}) {
const clipId = useId();
const svgRef = useRef<SVGSVGElement | null>(null);
const [hover, setHover] = useState<{ ratio: number } | null>(null);
const allPoints = series.flatMap((s) => s.points);
if (allPoints.length < 2) {
return (
<div
style={{ height }}
className={`flex items-center justify-center rounded bg-graphite-850 text-xs text-slate-dim ${className}`}
className={`flex items-center justify-center rounded-xs border border-graphite-800 bg-graphite-950 text-2xs text-slate-faint ${className}`}
>
collecting data
</div>
@@ -44,38 +56,128 @@ export function TimeSeriesChart({
const x = (t: number) => ((t - tMin) / tSpan) * W;
const y = (v: number) => H - Math.min(1, Math.max(0, v / scale)) * H;
/** Nearest sample to the hovered x position, per series. */
const hovered =
hover === null
? null
: series.map((s) => {
const target = tMin + hover.ratio * tSpan;
let best = s.points[0]!;
for (const point of s.points) {
if (Math.abs(point.t - target) < Math.abs(best.t - target)) best = point;
}
return { series: s, point: best };
});
const onPointerMove = (event: React.PointerEvent<SVGSVGElement>) => {
const rect = svgRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return;
setHover({ ratio: Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)) });
};
return (
<svg
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
style={{ height }}
className={`w-full ${className}`}
role="img"
>
{/* 50% guide line */}
<line x1="0" y1={H / 2} x2={W} y2={H / 2} stroke="currentColor" strokeOpacity="0.08" />
{series.map((s, index) => {
if (s.points.length < 2) return null;
const line = s.points
.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(p.t).toFixed(2)},${y(p.v).toFixed(2)}`)
.join(' ');
const first = s.points[0]!;
const last = s.points[s.points.length - 1]!;
const area = `${line} L${x(last.t).toFixed(2)},${H} L${x(first.t).toFixed(2)},${H} Z`;
return (
<g key={s.label ?? index}>
{s.fill !== false && <path d={area} fill={s.color} fillOpacity="0.12" />}
<path
d={line}
fill="none"
stroke={s.color}
strokeWidth="1.1"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</g>
);
})}
</svg>
<div className={`relative ${className}`}>
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
style={{ height }}
className="w-full touch-none"
role="img"
onPointerMove={onPointerMove}
onPointerLeave={() => setHover(null)}
>
<defs>
<clipPath id={clipId}>
<rect x="0" y="0" width={W} height={H} />
</clipPath>
</defs>
{/* Quarter gridlines give the eye a scale without adding clutter. */}
{[0.25, 0.5, 0.75].map((fraction) => (
<line
key={fraction}
x1="0"
y1={H * fraction}
x2={W}
y2={H * fraction}
stroke="currentColor"
strokeOpacity={fraction === 0.5 ? 0.12 : 0.06}
strokeWidth="0.5"
vectorEffect="non-scaling-stroke"
/>
))}
<g clipPath={`url(#${clipId})`}>
{series.map((s, index) => {
if (s.points.length < 2) return null;
const line = s.points
.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(p.t).toFixed(2)},${y(p.v).toFixed(2)}`)
.join(' ');
const first = s.points[0]!;
const last = s.points[s.points.length - 1]!;
const area = `${line} L${x(last.t).toFixed(2)},${H} L${x(first.t).toFixed(2)},${H} Z`;
return (
<g key={s.label ?? index}>
{s.fill !== false && <path d={area} fill={s.color} fillOpacity="0.1" />}
<path
d={line}
fill="none"
stroke={s.color}
strokeWidth="1.2"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</g>
);
})}
{hovered && (
<>
<line
x1={hover!.ratio * W}
y1="0"
x2={hover!.ratio * W}
y2={H}
stroke="currentColor"
strokeOpacity="0.35"
strokeWidth="0.5"
vectorEffect="non-scaling-stroke"
/>
{hovered.map(({ series: s, point }, index) => (
<circle
key={s.label ?? index}
cx={x(point.t)}
cy={y(point.v)}
r="1.5"
fill={s.color}
vectorEffect="non-scaling-stroke"
/>
))}
</>
)}
</g>
</svg>
{/* Axis maximum, so the shape has a magnitude attached to it. */}
<span className="numeric pointer-events-none absolute right-0 top-0 text-2xs leading-none text-slate-faint">
{format(scale)}
</span>
{hovered && (
<div
className="numeric pointer-events-none absolute -top-1 z-10 -translate-y-full whitespace-nowrap rounded-xs border border-graphite-600 bg-graphite-850 px-1.5 py-1 text-2xs text-zinc-100 shadow-lg shadow-black/40"
style={{
left: `${hover!.ratio * 100}%`,
transform: `translate(${hover!.ratio > 0.6 ? '-100%' : '0'}, -100%)`,
}}
>
{hovered.map(({ series: s, point }, index) => (
<div key={s.label ?? index} className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full" style={{ background: s.color }} />
{s.label && <span className="text-slate-dim">{s.label}</span>}
<span>{format(point.v)}</span>
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,293 @@
import { useMemo, useState } from 'react';
import type { ConfigEntry, ConfigPatchOp, StartupMirror } from '@reforger-panel/shared';
import { useConfigTree, usePatchConfig } from '../../api/hooks.js';
import { formatRelativeTime } from '../../lib/format.js';
import {
Badge,
Button,
EmptyState,
Notice,
SearchInput,
Spinner,
Toggle,
useToast,
} from '../ui.js';
import { Icon } from '../icons.js';
type EditValue = string | number | boolean | null;
/**
* Searchable editor over every key config.json actually contains.
*
* The panel used to reach only eleven hardcoded fields, and submitted all of
* them on every save. Here each row tracks its own dirty state and only the
* touched paths are sent, against the revision the page was loaded at so a
* stale tab is rejected instead of quietly reverting someone else's edit.
*/
export function ConfigKeyEditor({ slug, canEdit }: { slug: string; canEdit: boolean }) {
const toast = useToast();
const { data, isLoading, error, refetch } = useConfigTree(slug, canEdit);
const patch = usePatchConfig(slug);
const [query, setQuery] = useState('');
const [edits, setEdits] = useState<Map<string, EditValue>>(new Map());
const [writeStartupVars, setWriteStartupVars] = useState(true);
const entries = data?.entries ?? [];
const mirrorByPath = useMemo(() => {
const map = new Map<string, StartupMirror>();
for (const mirror of data?.mirrors ?? []) map.set(mirror.configPath, mirror);
return map;
}, [data?.mirrors]);
const visible = useMemo(() => {
if (!query.trim()) return entries;
const needle = query.trim().toLowerCase();
return entries.filter(
(entry) =>
entry.path.toLowerCase().includes(needle) ||
String(entry.value ?? '')
.toLowerCase()
.includes(needle),
);
}, [entries, query]);
const setEdit = (path: string, value: EditValue) => {
setEdits((current) => {
const next = new Map(current);
next.set(path, value);
return next;
});
};
const clearEdit = (path: string) => {
setEdits((current) => {
const next = new Map(current);
next.delete(path);
return next;
});
};
const ops: ConfigPatchOp[] = useMemo(
() => [...edits.entries()].map(([path, value]) => ({ path, value })),
[edits],
);
const touchedMirrors = ops
.map((op) => mirrorByPath.get(op.path))
.filter((mirror): mirror is StartupMirror => mirror !== undefined);
const conflictingMirrors = (data?.mirrors ?? []).filter((mirror) => mirror.conflict);
const apply = () => {
patch.mutate(
{ ops, expectedRevision: data?.revision, writeStartupVars },
{
onSuccess: (result) => {
setEdits(new Map());
void refetch();
toast(
result.changedPaths.length === 0
? 'No changes to save.'
: `Saved ${result.changedPaths.length} value${result.changedPaths.length === 1 ? '' : 's'}${
result.startupVarsWritten.length > 0
? ` (also mirrored to ${result.startupVarsWritten.join(', ')})`
: ''
}. Restart to apply.`,
'ok',
);
},
onError: (mutationError) => toast(mutationError.message, 'danger'),
},
);
};
if (!canEdit) {
return <EmptyState icon="lock" title="Configuration editing is restricted to admins" />;
}
if (isLoading) return <Spinner label="Downloading config.json…" />;
if (error || !data) {
return (
<EmptyState
icon="alert"
title="Could not read config.json"
hint={error?.message}
action={
<Button icon="refresh" onClick={() => void refetch()}>
Retry
</Button>
}
/>
);
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<SearchInput
value={query}
onChange={setQuery}
placeholder="Find any key, e.g. view distance, rcon, battlEye…"
className="w-full sm:w-96"
/>
<span className="numeric ml-auto text-2xs text-slate-dim">
{visible.length} of {entries.length} keys · read {formatRelativeTime(data.fetchedAt)}
</span>
</div>
{conflictingMirrors.length > 0 && (
<Notice tone="warn" title="Some values are also templated from startup variables">
<p>
This egg regenerates parts of config.json from Pterodactyl startup variables at boot.
For these keys the file and the variable currently disagree, so the variable wins on the
next restart:
</p>
<ul className="mt-1.5 space-y-0.5">
{conflictingMirrors.map((mirror) => (
<li key={`${mirror.envVariable}-${mirror.configPath}`} className="font-mono text-2xs">
{mirror.configPath} = {String(mirror.configValue ?? '—')} · {mirror.envVariable} ={' '}
{mirror.startupValue || '—'}
</li>
))}
</ul>
</Notice>
)}
{visible.length === 0 ? (
<EmptyState title="No keys match that search" />
) : (
<ul className="divide-y divide-graphite-800 overflow-hidden rounded-md border border-graphite-700">
{visible.map((entry) => (
<KeyRow
key={entry.path}
entry={entry}
edited={edits.has(entry.path)}
editValue={edits.get(entry.path) ?? null}
mirror={mirrorByPath.get(entry.path)}
onChange={(value) => setEdit(entry.path, value)}
onReset={() => clearEdit(entry.path)}
/>
))}
</ul>
)}
{ops.length > 0 && (
<div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-3 border-t border-graphite-600 bg-graphite-900/95 px-4 py-3 backdrop-blur">
<span className="text-xs text-zinc-200">
{ops.length} value{ops.length === 1 ? '' : 's'} changed
</span>
{touchedMirrors.length > 0 && (
<label className="flex items-center gap-2 text-2xs text-warn-400">
<Toggle
checked={writeStartupVars}
onChange={setWriteStartupVars}
label="Also write matching startup variables"
/>
Also write {touchedMirrors.map((mirror) => mirror.envVariable).join(', ')} so the
change survives a restart
</label>
)}
<div className="ml-auto flex items-center gap-2">
<Button onClick={() => setEdits(new Map())} disabled={patch.isPending}>
Discard
</Button>
<Button variant="accent" icon="upload" onClick={apply} loading={patch.isPending}>
Apply to server
</Button>
</div>
</div>
)}
</div>
);
}
function KeyRow({
entry,
edited,
editValue,
mirror,
onChange,
onReset,
}: {
entry: ConfigEntry;
edited: boolean;
editValue: EditValue;
mirror: StartupMirror | undefined;
onChange: (value: EditValue) => void;
onReset: () => void;
}) {
const value = edited ? editValue : entry.value;
const removed = edited && editValue === null;
const readOnly = entry.type === 'array';
return (
<li
className={`flex flex-wrap items-center gap-3 px-3 py-2 ${edited ? 'bg-accent-600/[0.06]' : 'hover:bg-graphite-850/50'}`}
>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 font-mono text-xs text-zinc-100">
<span className="truncate">{entry.path}</span>
{edited && <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent-400" />}
{mirror && (
<Badge tone="warn" icon="alert" title={`Also set by ${mirror.envVariable}`}>
{mirror.envVariable}
</Badge>
)}
</p>
{removed && (
<p className="mt-0.5 text-2xs text-danger-400">
Key will be removed the game default applies.
</p>
)}
</div>
<div className="flex w-full shrink-0 items-center gap-2 sm:w-72">
{readOnly ? (
<span className="truncate font-mono text-2xs text-slate-dim" title={entry.raw}>
{entry.raw}
</span>
) : entry.type === 'boolean' ? (
<select
value={removed ? '' : String(value)}
onChange={(event) =>
onChange(event.target.value === '' ? null : event.target.value === 'true')
}
className="input"
>
<option value="true">true</option>
<option value="false">false</option>
<option value="">(remove key)</option>
</select>
) : entry.type === 'number' ? (
<input
type="number"
value={removed ? '' : String(value ?? '')}
placeholder="(removed)"
onChange={(event) =>
onChange(event.target.value === '' ? null : Number(event.target.value))
}
className="input numeric"
/>
) : (
<input
value={removed ? '' : String(value ?? '')}
placeholder="(removed)"
onChange={(event) => onChange(event.target.value)}
className="input font-mono text-xs"
/>
)}
{!readOnly && (
<button
type="button"
title={edited ? 'Revert to the value on the server' : 'Remove this key'}
onClick={() => (edited ? onReset() : onChange(null))}
className="shrink-0 rounded-sm border border-graphite-700 p-1.5 text-slate-dim transition-colors hover:text-zinc-100"
>
<Icon name={edited ? 'refresh' : 'trash'} className="h-3.5 w-3.5" />
</button>
)}
</div>
</li>
);
}
@@ -0,0 +1,33 @@
/**
* Startup variables that a Reforger egg typically templates into config.json
* at boot, and the config path each one lands on.
*
* Mirrors the authoritative server-side map in
* `apps/api/src/modules/config/startup-mirrors.ts`. The API detects these
* properly (it can see which variables the egg actually exposes and whether
* the two values currently disagree); this copy exists only so the startup
* variable list can label a row without a second round trip.
*/
export const STARTUP_MIRROR_HINTS: Record<string, string> = {
SCENARIO_ID: 'game.scenarioId',
MISSION_ID: 'game.scenarioId',
MAX_PLAYERS: 'game.maxPlayers',
SERVER_NAME: 'game.name',
HOSTNAME: 'game.name',
SERVER_PASSWORD: 'game.password',
ADMIN_PASSWORD: 'game.passwordAdmin',
GAME_PORT: 'bindPort',
SERVER_PORT: 'bindPort',
BIND_PORT: 'bindPort',
SERVER_IP: 'bindAddress',
BIND_ADDRESS: 'bindAddress',
A2S_PORT: 'a2s.port',
RCON_PORT: 'rcon.port',
RCON_PASSWORD: 'rcon.password',
CROSS_PLATFORM: 'game.crossPlatform',
CROSSPLAY: 'game.crossPlatform',
BATTLEYE: 'game.gameProperties.battlEye',
VISIBLE: 'game.visible',
DISABLE_THIRD_PERSON: 'game.gameProperties.disableThirdPerson',
VIEW_DISTANCE: 'game.gameProperties.serverMaxViewDistance',
};
@@ -0,0 +1,107 @@
import { useEffect, useMemo, useState } from 'react';
import { useConfigRaw, usePutConfigRaw } from '../../api/hooks.js';
import { formatRelativeTime } from '../../lib/format.js';
import { Badge, Button, EmptyState, Notice, Spinner, useToast } from '../ui.js';
/**
* Direct editor for config.json, for the cases a structured form cannot cover.
* The write is refused server-side if the file moved since it was loaded, and
* the previous content is always kept as config.json.bak.
*/
export function ConfigRawEditor({ slug, canEdit }: { slug: string; canEdit: boolean }) {
const toast = useToast();
const { data, isLoading, error, refetch } = useConfigRaw(slug, canEdit);
const save = usePutConfigRaw(slug);
const [content, setContent] = useState<string | null>(null);
useEffect(() => {
if (data) setContent((current) => current ?? data.content);
}, [data]);
const parseError = useMemo(() => {
if (content === null) return null;
try {
const parsed: unknown = JSON.parse(content);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return 'config.json must be a JSON object.';
}
if (!('game' in parsed)) return 'config.json must contain a "game" section.';
return null;
} catch (jsonError) {
return jsonError instanceof Error ? jsonError.message : 'Invalid JSON.';
}
}, [content]);
if (!canEdit) {
return <EmptyState icon="lock" title="Configuration editing is restricted to admins" />;
}
if (isLoading || content === null) return <Spinner label="Downloading config.json…" />;
if (error || !data) {
return (
<EmptyState
icon="alert"
title="Could not read config.json"
hint={error?.message}
action={
<Button icon="refresh" onClick={() => void refetch()}>
Retry
</Button>
}
/>
);
}
const dirty = content !== data.content;
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Badge tone={parseError ? 'danger' : 'ok'} icon={parseError ? 'alert' : 'check'}>
{parseError ? 'invalid JSON' : 'valid JSON'}
</Badge>
{dirty && <Badge tone="warn">unsaved changes</Badge>}
<span className="numeric ml-auto text-2xs text-slate-dim">
revision {data.revision} · read {formatRelativeTime(data.fetchedAt)}
</span>
</div>
{parseError && <Notice tone="danger">{parseError}</Notice>}
<textarea
spellCheck={false}
value={content}
onChange={(event) => setContent(event.target.value)}
className="input h-[28rem] w-full resize-y font-mono text-xs leading-5"
/>
<div className="flex flex-wrap items-center justify-end gap-2">
<span className="mr-auto text-2xs text-slate-dim">
The previous file is kept as config.json.bak. Changes apply on the next restart.
</span>
<Button onClick={() => setContent(data.content)} disabled={!dirty || save.isPending}>
Revert
</Button>
<Button
variant="accent"
icon="upload"
disabled={!dirty || parseError !== null}
loading={save.isPending}
onClick={() =>
save.mutate(
{ content, expectedRevision: data.revision },
{
onSuccess: (result) => {
setContent(result.content);
toast('config.json written. Restart to apply.', 'ok');
},
onError: (mutationError) => toast(mutationError.message, 'danger'),
},
)
}
>
Write config.json
</Button>
</div>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
import type { SVGProps } from 'react';
/**
* Inline SVG icon set no icon dependency, no runtime font.
*
* Every glyph is drawn on a 24px grid with a 1.6px stroke so weights stay
* consistent next to 13px text, and inherits `currentColor` so a single class
* on the parent controls colour.
*/
export type IconName =
| 'gauge'
| 'package'
| 'sliders'
| 'map'
| 'users'
| 'crosshair'
| 'pulse'
| 'terminal'
| 'settings'
| 'play'
| 'stop'
| 'restart'
| 'plus'
| 'minus'
| 'trash'
| 'refresh'
| 'download'
| 'upload'
| 'search'
| 'close'
| 'check'
| 'chevron-down'
| 'chevron-right'
| 'chevron-left'
| 'alert'
| 'info'
| 'link'
| 'copy'
| 'menu'
| 'arrow-up'
| 'filter'
| 'server'
| 'image'
| 'lock'
| 'exit';
const PATHS: Record<IconName, string> = {
gauge: 'M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm1.4-3.4L17 7M3.6 18a9 9 0 1 1 16.8 0',
package: 'M21 8v8l-9 5-9-5V8l9-5 9 5Zm-18 0 9 5 9-5m-9 5v8',
sliders: 'M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0M14 4v4M8 10v4M16 16v4',
map: 'm9 4-6 3v13l6-3 6 3 6-3V4l-6 3-6-3Zm0 0v13m6-10v13',
users:
'M16 20v-1.5a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4V20M9 10.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM22 20v-1.5a4 4 0 0 0-3-3.87M16 3.6a4 4 0 0 1 0 6.8',
crosshair: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-15v3m0 6v3m6-6h-3m-6 0H3',
pulse: 'M3 12h3.5L9 5l4 14 2.5-7H21',
terminal:
'm5 8 4 4-4 4m6 1h8M3 20h18a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1Z',
settings:
'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7.5-3a7.5 7.5 0 0 1-.1 1.2l2 1.5-2 3.4-2.4-1a7.5 7.5 0 0 1-2 1.2l-.4 2.5h-4l-.4-2.5a7.5 7.5 0 0 1-2-1.2l-2.4 1-2-3.4 2-1.5a7.5 7.5 0 0 1 0-2.4l-2-1.5 2-3.4 2.4 1a7.5 7.5 0 0 1 2-1.2L8.6 3h4l.4 2.5a7.5 7.5 0 0 1 2 1.2l2.4-1 2 3.4-2 1.5c.06.4.1.8.1 1.2Z',
play: 'M7 4.5v15l13-7.5-13-7.5Z',
stop: 'M6 6h12v12H6z',
restart: 'M20 12a8 8 0 1 1-2.6-5.9M20 4v5h-5',
plus: 'M12 5v14M5 12h14',
minus: 'M5 12h14',
trash:
'M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m3 0v12a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7m4 4v6m4-6v6',
refresh: 'M21 12a9 9 0 0 1-15.1 6.6M3 12a9 9 0 0 1 15.1-6.6M3 20v-5h5M21 4v5h-5',
download: 'M12 3v12m0 0 4.5-4.5M12 15l-4.5-4.5M4 20h16',
upload: 'M12 21V9m0 0 4.5 4.5M12 9 7.5 13.5M4 4h16',
search: 'M20 20l-4.2-4.2M17 11a6 6 0 1 1-12 0 6 6 0 0 1 12 0Z',
close: 'M6 6l12 12M18 6 6 18',
check: 'm5 13 4.5 4.5L19 7',
'chevron-down': 'm6 9 6 6 6-6',
'chevron-right': 'm9 6 6 6-6 6',
'chevron-left': 'm15 6-6 6 6 6',
alert:
'M12 9v4.5m0 3.5v.01M10.3 4.2 2.6 17.6A2 2 0 0 0 4.3 20.6h15.4a2 2 0 0 0 1.7-3L13.7 4.2a2 2 0 0 0-3.4 0Z',
info: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-9.5V16m0-8v.01',
link: 'M10 13a5 5 0 0 0 7.5.5l2-2A5 5 0 0 0 12.5 4.5L11 6m3 5a5 5 0 0 0-7.5-.5l-2 2A5 5 0 0 0 11.5 19.5L13 18',
copy: 'M9 9h10v10a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V9Zm-4 6H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1',
menu: 'M4 6h16M4 12h16M4 18h16',
'arrow-up': 'M12 20V5m0 0-6 6m6-6 6 6',
filter: 'M3 5h18l-7 8v6l-4 2v-8L3 5Z',
server:
'M4 4h16a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm0 10h16a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1Zm3-7h.01M7 17h.01',
image: 'M3 5h18v14H3zM9 11a1.75 1.75 0 1 0 0-3.5A1.75 1.75 0 0 0 9 11Zm-6 7 5-5 3 3 4-4 6 6',
lock: 'M7 11V8a5 5 0 0 1 10 0v3M5 11h14a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1Z',
exit: 'M15 17l5-5-5-5m5 5H9M12 3H5a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h7',
};
/** Icons that read better filled than stroked. */
const FILLED = new Set<IconName>(['play', 'stop']);
export function Icon({
name,
className = 'h-4 w-4',
...props
}: { name: IconName; className?: string } & Omit<SVGProps<SVGSVGElement>, 'name'>) {
const filled = FILLED.has(name);
return (
<svg
viewBox="0 0 24 24"
aria-hidden
focusable="false"
className={`shrink-0 ${className}`}
fill={filled ? 'currentColor' : 'none'}
stroke={filled ? 'none' : 'currentColor'}
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d={PATHS[name]} />
</svg>
);
}
/** Small animated ring used inside buttons while a mutation is in flight. */
export function Spinner16({ className = 'h-4 w-4' }: { className?: string }) {
return (
<span
aria-hidden
className={`inline-block animate-spin rounded-full border-2 border-current/25 border-t-current ${className}`}
/>
);
}
+120 -60
View File
@@ -1,31 +1,54 @@
import { useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import type { Capability, CurrentUser } from '@reforger-panel/shared';
import { useLogout, useServers } from '../api/hooks.js';
import { RoleBadge, StatusBadge } from './ui.js';
import {
useLogout,
useModsOverview,
usePlayers,
useServerResources,
useServers,
} from '../api/hooks.js';
import { formatDuration } from '../lib/format.js';
import { IconButton, RoleBadge, StatusBadge } from './ui.js';
import { Icon, type IconName } from './icons.js';
import { PowerControls } from './widgets.js';
const NAV_ITEMS: {
to: string;
label: string;
icon: IconName;
exact?: boolean;
capability?: Capability;
}[] = [
{ to: '/', label: 'Overview', exact: true },
{ to: '/mods', label: 'Mods' },
{ to: '/configuration', label: 'Configuration' },
{ to: '/players', label: 'Players' },
{ to: '/killfeed', label: 'Killfeed' },
{ to: '/activity', label: 'Activity' },
{ to: '/logs', label: 'Logs', capability: 'ops.health.view' },
{ to: '/settings', label: 'Settings' },
{ to: '/', label: 'Overview', icon: 'gauge', exact: true },
{ to: '/mods', label: 'Mods', icon: 'package' },
{ to: '/configuration', label: 'Configuration', icon: 'sliders' },
{ to: '/mission', label: 'Mission', icon: 'map' },
{ to: '/players', label: 'Players', icon: 'users' },
{ to: '/killfeed', label: 'Killfeed', icon: 'crosshair' },
{ to: '/activity', label: 'Activity', icon: 'pulse' },
{ to: '/console', label: 'Console', icon: 'terminal', capability: 'ops.health.view' },
{ to: '/settings', label: 'Settings', icon: 'settings' },
];
export function Layout({ user }: { user: CurrentUser }) {
const logout = useLogout();
const { data: serversData } = useServers();
const server = serversData?.servers[0];
const slug = server?.slug ?? '';
const { data: resources } = useServerResources(slug, Boolean(slug));
const { data: players } = usePlayers(slug);
const { data: mods } = useModsOverview(slug);
const [navOpen, setNavOpen] = useState(false);
const location = useLocation();
// Close the drawer on navigation so a tap never leaves it hanging open.
useEffect(() => setNavOpen(false), [location.pathname]);
const counts: Partial<Record<string, number>> = {
'/mods': mods?.mods.length,
'/players': players?.onlineCount,
};
return (
<div className="flex min-h-screen">
@@ -33,46 +56,58 @@ export function Layout({ user }: { user: CurrentUser }) {
<div
aria-hidden
onClick={() => setNavOpen(false)}
className="fixed inset-0 z-20 bg-black/60 backdrop-blur-sm lg:hidden"
className="fixed inset-0 z-20 bg-black/70 backdrop-blur-sm lg:hidden"
/>
)}
<aside
className={`fixed inset-y-0 left-0 z-30 flex h-dvh w-56 shrink-0 flex-col border-r border-graphite-700/70 bg-graphite-900 transition-transform duration-200 lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 ${
className={`fixed inset-y-0 left-0 z-30 flex h-dvh w-56 shrink-0 flex-col border-r border-graphite-700 bg-graphite-900 transition-transform duration-150 lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 ${
navOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex min-h-16 items-center border-b border-graphite-700/60 px-5">
<div>
<p className="text-[13px] font-semibold uppercase leading-tight tracking-[0.12em] text-zinc-100">
<div className="flex min-h-14 items-center gap-2.5 border-b border-graphite-700 px-4">
<span className="flex h-7 w-7 items-center justify-center rounded-sm border border-accent-600/50 bg-accent-600/15 text-accent-400">
<Icon name="server" className="h-4 w-4" />
</span>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase leading-tight tracking-[0.14em] text-zinc-100">
DZR.TOOLS
</p>
<p className="text-[10px] uppercase tracking-[0.16em] text-slate-dim">
ARMA REFORGER OPS
<p className="text-2xs uppercase leading-tight tracking-[0.16em] text-slate-faint">
Reforger Ops
</p>
</div>
</div>
<nav className="min-h-0 flex-1 space-y-1 overflow-y-auto p-3">
<nav className="min-h-0 flex-1 space-y-0.5 overflow-y-auto p-2">
{NAV_ITEMS.filter(
(item) => !item.capability || user.capabilities.includes(item.capability),
).map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.exact}
onClick={() => setNavOpen(false)}
className={({ isActive }) =>
`block rounded-md border border-transparent px-3.5 py-2.5 text-sm transition-colors ${
isActive
? 'border-graphite-700 bg-graphite-850 font-medium text-zinc-100'
: 'text-slate-ink hover:bg-graphite-800 hover:text-zinc-200'
}`
}
>
{item.label}
</NavLink>
))}
).map((item) => {
const count = counts[item.to];
return (
<NavLink
key={item.to}
to={item.to}
end={item.exact}
className={({ isActive }) =>
`flex items-center gap-2.5 rounded-sm border-l-2 px-3 py-2 text-sm transition-colors ${
isActive
? 'border-accent-500 bg-graphite-850 font-medium text-zinc-50'
: 'border-transparent text-slate-ink hover:bg-graphite-850/60 hover:text-zinc-100'
}`
}
>
<Icon name={item.icon} className="h-4 w-4" />
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{count !== undefined && count > 0 && (
<span className="numeric text-2xs text-slate-faint">{count}</span>
)}
</NavLink>
);
})}
</nav>
<div className="border-t border-graphite-700/60 px-5 py-4">
<div className="border-t border-graphite-700 px-3 py-3">
<div className="flex items-center gap-2.5">
{user.avatarUrl ? (
<img
@@ -81,60 +116,85 @@ export function Layout({ user }: { user: CurrentUser }) {
className="h-8 w-8 rounded-full border border-graphite-600"
/>
) : (
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-graphite-700 text-sm font-semibold text-zinc-300">
<span className="flex h-8 w-8 items-center justify-center rounded-full border border-graphite-600 bg-graphite-800 text-sm font-semibold text-zinc-300">
{(user.displayName ?? user.username).slice(0, 1).toUpperCase()}
</span>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-zinc-200">{user.displayName ?? user.username}</p>
<p className="truncate text-xs text-zinc-200">{user.displayName ?? user.username}</p>
<RoleBadge role={user.role} />
</div>
<button
type="button"
title="Log out"
<IconButton
icon="exit"
label="Log out"
onClick={() =>
logout.mutate(undefined, { onSuccess: () => window.location.reload() })
}
className="rounded-md border border-graphite-600 px-2 py-1 text-xs text-slate-ink transition-colors hover:border-danger-400/50 hover:text-danger-400"
>
Exit
</button>
size="sm"
/>
</div>
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-10 flex min-h-16 shrink-0 flex-wrap items-center gap-x-4 gap-y-2 border-b border-graphite-700/60 bg-graphite-900/85 px-4 py-3 backdrop-blur sm:px-6">
<header className="sticky top-0 z-10 flex min-h-14 shrink-0 flex-wrap items-center gap-x-4 gap-y-2 border-b border-graphite-700 bg-graphite-900/90 px-4 py-2.5 backdrop-blur sm:px-6">
<button
type="button"
aria-label="Open navigation"
onClick={() => setNavOpen(true)}
className="rounded-md border border-graphite-600 p-2 text-slate-ink transition-colors hover:text-zinc-200 lg:hidden"
className="rounded-sm border border-graphite-600 p-1.5 text-slate-ink transition-colors hover:text-zinc-100 lg:hidden"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" className="h-5 w-5">
<path d="M4 6h16M4 12h16M4 18h16" strokeWidth="1.8" strokeLinecap="round" />
</svg>
<Icon name="menu" className="h-4 w-4" />
</button>
{server ? (
<div className="flex min-w-0 flex-1 items-center gap-3 sm:gap-4">
<div className="min-w-28 truncate">
<p className="text-[10px] uppercase tracking-[0.16em] text-slate-dim">Server</p>
<h2 className="truncate text-base font-semibold text-zinc-100">{server.name}</h2>
<div className="flex min-w-0 flex-1 items-center gap-3 sm:gap-5">
<div className="min-w-0">
<p className="eyebrow leading-tight">Server</p>
<h2 className="truncate text-sm font-semibold leading-tight text-zinc-50">
{server.name}
</h2>
</div>
<StatusBadge status={server.status} />
<span className="hidden text-sm text-slate-ink md:inline">
{server.onlinePlayerCount} / {server.maxPlayers ?? '—'} players
</span>
<dl className="hidden items-center gap-5 md:flex">
<HeaderStat
label="Players"
value={`${server.onlinePlayerCount} / ${server.maxPlayers ?? '—'}`}
/>
<HeaderStat
label="Uptime"
value={
resources && resources.uptimeMs > 0
? formatDuration(resources.uptimeMs / 1000)
: '—'
}
/>
<HeaderStat
label="CPU"
value={resources ? `${resources.cpuPercent.toFixed(0)}%` : '—'}
/>
</dl>
</div>
) : (
<div className="flex-1" />
)}
{server && <PowerControls user={user} server={server} />}
</header>
<main className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
<main className="flex-1 overflow-y-auto p-4 sm:p-6">
<Outlet />
</main>
</div>
</div>
);
}
function HeaderStat({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="eyebrow leading-tight">{label}</dt>
<dd className="numeric text-sm leading-tight text-zinc-200">{value}</dd>
</div>
);
}
+224 -71
View File
@@ -1,20 +1,97 @@
import { useState } from 'react';
import { useConfiguration, useSetPerformanceSettings } from '../api/hooks.js';
import { Button, Card, Spinner } from './ui.js';
import { shortScenario } from './widgets.js';
import { useMemo, useState } from 'react';
import type { MissionInfo } from '@reforger-panel/shared';
import { useConfiguration, useMissions, useSetPerformanceSettings } from '../api/hooks.js';
import { Badge, Button, Card, EmptyState, Notice, SearchInput, Spinner, useToast } from './ui.js';
import { Icon } from './icons.js';
const DEFAULT_SCENARIO_ID = '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf';
const DEFAULT_SCENARIO_NAME = 'Campaign - Montignac (default)';
const SCENARIO_PATTERN = /^\{[0-9A-Fa-f]{16}\}[^\0\r\n]+\.conf$/;
/** Display form of a scenario id: just the file name, e.g. "23_Campaign.conf". */
export function shortScenario(scenarioId: string): string {
const slash = scenarioId.lastIndexOf('/');
return slash >= 0 ? scenarioId.slice(slash + 1) : scenarioId;
}
/**
* Mission editor. Scenario discovery through the Workshop API is not reliable
* enough for every mod, so the primary control is a manual scenario ID input.
* Mission picker.
*
* Scenario discovery is now reliable: the vanilla list is bundled and merged
* with whatever the server prints at boot, and modded scenarios come from the
* Workshop v2 `scenarios[].gameId` field rather than being scraped out of prose.
* The raw id input is kept, but demoted to a fallback.
*/
export function MissionCard({ slug, canEdit }: { slug: string; canEdit: boolean }) {
const { data: config, refetch } = useConfiguration(slug);
const toast = useToast();
const { data: config, refetch: refetchConfig } = useConfiguration(slug);
const {
data: missions,
isLoading: missionsLoading,
refetch: refetchMissions,
} = useMissions(slug);
const save = useSetPerformanceSettings(slug);
const [selected, setSelected] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [manual, setManual] = useState('');
const [showManual, setShowManual] = useState(false);
const current = config?.config.scenarioId ?? '';
const groups = useMemo(() => {
if (!missions) return [];
const needle = query.trim().toLowerCase();
if (!needle) return missions.groups;
return missions.groups
.map((group) => ({
...group,
missions: group.missions.filter(
(mission) =>
mission.name.toLowerCase().includes(needle) ||
mission.scenarioId.toLowerCase().includes(needle) ||
(mission.gameMode ?? '').toLowerCase().includes(needle),
),
}))
.filter((group) => group.missions.length > 0);
}, [missions, query]);
const known = useMemo(
() =>
new Set(
(missions?.groups ?? []).flatMap((group) =>
group.missions.map((mission) => mission.scenarioId),
),
),
[missions],
);
const currentMission = useMemo(() => {
for (const group of missions?.groups ?? []) {
const match = group.missions.find((mission) => mission.scenarioId === current);
if (match) return { mission: match, groupLabel: group.label };
}
return null;
}, [missions, current]);
const apply = (scenarioId: string) => {
if (!SCENARIO_PATTERN.test(scenarioId)) {
toast('That does not look like a scenario id ({16 hex}Missions/….conf).', 'danger');
return;
}
save.mutate(
{
settings: { scenarioId },
expectedRevision: config?.revision,
writeStartupVars: true,
},
{
onSuccess: () => {
setManual('');
void refetchConfig();
toast('Mission saved to config.json. Restart the server to switch.', 'ok');
},
onError: (error) => toast(error.message, 'danger'),
},
);
};
if (!config) {
return (
@@ -24,82 +101,158 @@ export function MissionCard({ slug, canEdit }: { slug: string; canEdit: boolean
);
}
const current = config.config.scenarioId;
const value = selected ?? current;
const dirty = value !== current;
const submit = (scenarioIdOverride?: string) => {
setMessage(null);
save.mutate(
{ scenarioId: scenarioIdOverride ?? value },
{
onSuccess: () => {
setSelected(null);
setMessage('Mission saved to config.json — restart the server to switch.');
void refetch();
},
onError: (error) => setMessage(error.message),
},
);
};
return (
<Card
title="Mission"
action={
canEdit &&
dirty && (
<div className="flex items-center gap-2">
<Button onClick={() => setSelected(null)} disabled={save.isPending}>
Discard
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
icon="refresh"
onClick={() => void refetchMissions()}
title="Re-scan available missions"
/>
{canEdit && (
<Button size="sm" variant="ghost" onClick={() => setShowManual((open) => !open)}>
{showManual ? 'Hide manual entry' : 'Enter an ID manually'}
</Button>
<Button variant="accent" onClick={() => submit()} disabled={save.isPending}>
{save.isPending ? 'Saving…' : 'Save to server'}
</Button>
</div>
)
)}
</div>
}
>
<div className="space-y-4">
<div className="min-w-0 flex-1">
<p className="text-lg font-medium text-zinc-100">{shortScenario(current)}</p>
<p className="truncate font-mono text-xs text-slate-dim" title={current}>
{current}
<div className="rounded-sm border border-graphite-700 bg-graphite-950 px-3 py-2.5">
<p className="eyebrow">Currently configured</p>
<p className="mt-1 flex flex-wrap items-center gap-2 text-base text-zinc-50">
{currentMission?.mission.name ?? shortScenario(current)}
{currentMission && <Badge tone="accent">{currentMission.groupLabel}</Badge>}
{currentMission?.mission.gameMode && <Badge>{currentMission.mission.gameMode}</Badge>}
</p>
<p className="mt-0.5 truncate font-mono text-2xs text-slate-faint" title={current}>
{current || '(none set)'}
</p>
</div>
{canEdit && (
<div className="grid gap-2">
{!missionsLoading && current && !known.has(current) && (
<Notice tone="warn" title="Nothing installed provides this mission">
The server is configured for a scenario the base game does not ship and no installed mod
offers. It will fail to load it on the next restart pick one below, or re-add the mod
that provided it.
</Notice>
)}
{(missions?.incompleteModIds.length ?? 0) > 0 && (
<Notice tone="info">
{missions!.incompleteModIds.length} installed mod
{missions!.incompleteModIds.length === 1 ? "'s" : "s'"} scenarios could not be read from
the Workshop, so this list may be incomplete.
</Notice>
)}
{canEdit && showManual && (
<div className="flex gap-2">
<input
value={value}
onChange={(event) => {
setMessage(null);
setSelected(event.target.value);
}}
value={manual}
placeholder="{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf"
className="input w-full font-mono text-xs"
onChange={(event) => setManual(event.target.value)}
className="input font-mono text-xs"
/>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => {
setMessage(null);
setSelected(DEFAULT_SCENARIO_ID);
}}
disabled={save.isPending}
>
Use {DEFAULT_SCENARIO_NAME}
</Button>
<Button
variant="danger"
onClick={() => submit(DEFAULT_SCENARIO_ID)}
disabled={save.isPending || current === DEFAULT_SCENARIO_ID}
>
{save.isPending ? 'Saving…' : 'Reset to default'}
</Button>
</div>
<Button
variant="accent"
disabled={!manual.trim() || save.isPending}
onClick={() => apply(manual.trim())}
>
Set
</Button>
</div>
)}
<SearchInput value={query} onChange={setQuery} placeholder="Search missions…" />
{missionsLoading ? (
<Spinner label="Reading available missions…" />
) : groups.length === 0 ? (
<EmptyState
icon="map"
title={query ? 'No missions match that search' : 'No missions found'}
hint={
query
? undefined
: 'Vanilla scenarios are always listed; modded scenarios come from the mods installed on this server.'
}
/>
) : (
<div className="space-y-4">
{groups.map((group) => (
<section key={group.id}>
<p className="eyebrow mb-1.5 flex items-center gap-2">
<Icon
name={group.kind === 'official' ? 'map' : 'package'}
className="h-3.5 w-3.5"
/>
{group.label}
<span className="numeric text-slate-faint">{group.missions.length}</span>
</p>
<ul className="divide-y divide-graphite-800 overflow-hidden rounded-sm border border-graphite-700">
{group.missions.map((mission) => (
<MissionRow
key={mission.scenarioId}
mission={mission}
active={mission.scenarioId === current}
canEdit={canEdit}
saving={save.isPending}
onSelect={() => apply(mission.scenarioId)}
/>
))}
</ul>
</section>
))}
</div>
)}
</div>
{message && <p className="mt-3 text-xs text-accent-400">{message}</p>}
</Card>
);
}
function MissionRow({
mission,
active,
canEdit,
saving,
onSelect,
}: {
mission: MissionInfo;
active: boolean;
canEdit: boolean;
saving: boolean;
onSelect: () => void;
}) {
return (
<li
className={`flex flex-wrap items-center gap-3 px-3 py-2 ${active ? 'bg-accent-600/[0.09]' : 'hover:bg-graphite-850/50'}`}
>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 truncate text-sm text-zinc-100">
{mission.name}
{active && <Badge tone="accent">running</Badge>}
</p>
<p className="truncate font-mono text-2xs text-slate-faint">{mission.scenarioId}</p>
</div>
{mission.gameMode && <Badge>{mission.gameMode}</Badge>}
{mission.playerCount ? (
<span className="numeric text-2xs text-slate-dim">{mission.playerCount}p</span>
) : null}
{canEdit && (
<Button
size="sm"
variant={active ? 'subtle' : 'accent'}
disabled={active || saving}
onClick={onSelect}
>
{active ? 'Current' : 'Use'}
</Button>
)}
</li>
);
}
@@ -0,0 +1,239 @@
import { useState } from 'react';
import { WORKSHOP_SORTS, type WorkshopModPreview, type WorkshopSort } from '@reforger-panel/shared';
import { useWorkshopSearch } from '../../api/hooks.js';
import { formatBytes } from '../../lib/format.js';
import { Badge, Button, EmptyState, ModImage, SearchInput, Skeleton } from '../ui.js';
import { Icon } from '../icons.js';
/**
* The upstream index rejects comma-separated tags, so filtering is one tag at
* a time. These are the tags that actually appear on Reforger Workshop mods.
*/
const TAGS = [
'SCENARIOS_MP',
'SCENARIOS_SP',
'WEAPONS',
'VEHICLES',
'CHARACTERS',
'TERRAINS',
'SYSTEMS',
'PROPS',
'EFFECTS',
'MISC',
] as const;
const SORT_LABELS: Record<WorkshopSort, string> = {
popularity: 'Popular',
'most-rated': 'Most rated',
'highest-rated': 'Highest rated',
subscribers: 'Subscribers',
newest: 'Newest',
created: 'Recently created',
'recently-updated': 'Recently updated',
largest: 'Largest',
name: 'Name',
};
export function BrowsePanel({
installedIds,
canManage,
onAdd,
onRemove,
onOpen,
}: {
installedIds: ReadonlySet<string>;
canManage: boolean;
onAdd: (mod: WorkshopModPreview) => void;
onRemove: (modId: string) => void;
onOpen: (modId: string) => void;
}) {
const [query, setQuery] = useState('');
const [sort, setSort] = useState<WorkshopSort>('popularity');
const [tag, setTag] = useState<string | null>(null);
const [page, setPage] = useState(1);
const search = useWorkshopSearch({ query, page, sort, tag: tag ?? undefined });
const mods = search.data?.mods ?? [];
const meta = search.data?.meta;
const reset =
<T,>(setter: (value: T) => void) =>
(value: T) => {
setter(value);
setPage(1);
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<SearchInput
value={query}
onChange={reset(setQuery)}
placeholder="Search the Workshop…"
className="w-full sm:w-80"
/>
<select
value={sort}
onChange={(event) => reset(setSort)(event.target.value as WorkshopSort)}
className="input w-auto"
>
{WORKSHOP_SORTS.map((value) => (
<option key={value} value={value}>
{SORT_LABELS[value]}
</option>
))}
</select>
{meta && (
<span className="numeric ml-auto text-2xs text-slate-dim">
{meta.totalMods.toLocaleString()} mods
</span>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{TAGS.map((value) => {
const active = tag === value;
return (
<button
key={value}
type="button"
onClick={() => reset(setTag)(active ? null : value)}
className={`rounded-xs border px-2 py-0.5 text-2xs font-semibold transition-colors ${
active
? 'border-accent-600 bg-accent-600/20 text-accent-300'
: 'border-graphite-700 bg-graphite-850 text-slate-dim hover:text-zinc-200'
}`}
>
{value}
</button>
);
})}
</div>
{search.isLoading ? (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className="panel-card space-y-2 p-3">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-3 w-2/3" />
<Skeleton className="h-3 w-1/3" />
</div>
))}
</div>
) : search.error ? (
<EmptyState
icon="alert"
title="The Workshop index is unavailable"
hint="reforgermods.net did not answer. Installed mods are unaffected."
action={
<Button icon="refresh" onClick={() => void search.refetch()}>
Retry
</Button>
}
/>
) : mods.length === 0 ? (
<EmptyState
title="No mods matched"
hint="Try a different search or clear the tag filter."
/>
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{mods.map((mod) => (
<ModCard
key={mod.id}
mod={mod}
installed={installedIds.has(mod.id.toUpperCase())}
canManage={canManage}
onAdd={() => onAdd(mod)}
onRemove={() => onRemove(mod.id.toUpperCase())}
onOpen={() => onOpen(mod.id.toUpperCase())}
/>
))}
</div>
)}
{meta && meta.totalPages > 1 && (
<div className="flex items-center justify-center gap-3">
<Button
icon="chevron-left"
disabled={page <= 1 || search.isFetching}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
Previous
</Button>
<span className="numeric text-xs text-slate-dim">
Page {meta.currentPage} of {meta.totalPages}
</span>
<Button
disabled={page >= meta.totalPages || search.isFetching}
onClick={() => setPage((current) => current + 1)}
>
Next
</Button>
</div>
)}
</div>
);
}
function ModCard({
mod,
installed,
canManage,
onAdd,
onRemove,
onOpen,
}: {
mod: WorkshopModPreview;
installed: boolean;
canManage: boolean;
onAdd: () => void;
onRemove: () => void;
onOpen: () => void;
}) {
return (
<article className="panel-card flex flex-col overflow-hidden">
<button type="button" onClick={onOpen} className="group text-left">
<ModImage
src={mod.imageUrl}
className="h-28 w-full rounded-none border-0 border-b border-graphite-700"
/>
<div className="p-3">
<h3 className="truncate text-sm font-medium text-zinc-100 group-hover:text-accent-300">
{mod.name}
</h3>
<p className="truncate text-xs text-slate-dim">{mod.author}</p>
</div>
</button>
<div className="mt-auto space-y-2 px-3 pb-3">
<div className="numeric flex flex-wrap items-center gap-x-3 gap-y-1 text-2xs text-slate-dim">
{mod.version && <span>v{mod.version}</span>}
<span>
{mod.sizeBytes ? formatBytes(mod.sizeBytes) : (mod.sizeText ?? 'size unknown')}
</span>
{mod.rating !== null && mod.rating > 0 && (
<span className="flex items-center gap-1">
<Icon name="check" className="h-3 w-3 text-ok-400" />
{Math.round(mod.rating * 100)}%
</span>
)}
{mod.subscriberCount ? <span>{mod.subscriberCount.toLocaleString()} subs</span> : null}
</div>
{mod.obsolete && <Badge tone="danger">obsolete</Badge>}
{canManage &&
(installed ? (
<Button size="sm" variant="danger" icon="minus" onClick={onRemove} className="w-full">
Remove
</Button>
) : (
<Button size="sm" variant="accent" icon="plus" onClick={onAdd} className="w-full">
Add
</Button>
))}
</div>
</article>
);
}
@@ -0,0 +1,115 @@
import type { ModResolveResponse } from '@reforger-panel/shared';
import { formatBytes } from '../../lib/format.js';
import { Badge, Button } from '../ui.js';
import { Icon } from '../icons.js';
import type { Change } from './changeset.js';
const KIND_META: Record<
Change['kind'],
{ icon: 'plus' | 'minus' | 'arrow-up'; tone: string; label: string }
> = {
add: { icon: 'plus', tone: 'text-ok-400', label: 'add' },
remove: { icon: 'minus', tone: 'text-danger-400', label: 'remove' },
version: { icon: 'arrow-up', tone: 'text-warn-400', label: 'version' },
};
/**
* The staged plan. Everything the user has done since loading the page is
* shown as a reviewable diff, and Apply writes config.json exactly once.
*/
export function ChangesetBar({
changes,
resolution,
resolving,
applying,
onDiscard,
onApply,
onAddDependencies,
}: {
changes: readonly Change[];
resolution: ModResolveResponse | null;
resolving: boolean;
applying: boolean;
onDiscard: () => void;
onApply: () => void;
onAddDependencies: () => void;
}) {
if (changes.length === 0) return null;
const added = changes.filter((change) => change.kind === 'add').length;
const removed = changes.filter((change) => change.kind === 'remove').length;
const reversioned = changes.filter((change) => change.kind === 'version').length;
const missingDependencies = resolution?.addedDependencies ?? [];
return (
<div className="sticky bottom-0 z-20 -mx-4 mt-4 border-t border-graphite-600 bg-graphite-900/95 px-4 py-3 backdrop-blur sm:-mx-6 sm:px-6">
<div className="flex flex-wrap items-start gap-4">
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-2 text-xs">
<span className="eyebrow">Pending changes</span>
{added > 0 && <Badge tone="ok">{added} added</Badge>}
{reversioned > 0 && <Badge tone="warn">{reversioned} re-versioned</Badge>}
{removed > 0 && <Badge tone="danger">{removed} removed</Badge>}
{resolution?.totalSizeBytes ? (
<span className="numeric text-slate-dim">
{formatBytes(resolution.totalSizeBytes)} total after apply
</span>
) : null}
{resolving && <span className="text-slate-dim">checking dependencies</span>}
</p>
<ul className="mt-2 max-h-32 space-y-0.5 overflow-y-auto pr-2">
{changes.map((change) => {
const meta = KIND_META[change.kind];
return (
<li
key={`${change.kind}-${change.modId}`}
className="flex items-center gap-2 text-xs"
>
<Icon name={meta.icon} className={`h-3 w-3 ${meta.tone}`} />
<span className="min-w-0 flex-1 truncate text-zinc-200">{change.name}</span>
<span className="numeric shrink-0 text-2xs text-slate-dim">
{change.kind === 'version'
? `${change.from ?? 'latest'}${change.to ?? 'latest'}`
: (change.to ?? change.from ?? 'latest')}
</span>
</li>
);
})}
</ul>
{missingDependencies.length > 0 && (
<p className="mt-2 flex flex-wrap items-center gap-2 text-2xs text-warn-400">
<Icon name="alert" className="h-3.5 w-3.5" />
{missingDependencies.length} required dependenc
{missingDependencies.length === 1 ? 'y is' : 'ies are'} not in the list
<Button size="sm" variant="subtle" icon="plus" onClick={onAddDependencies}>
Add all
</Button>
</p>
)}
{resolution && resolution.unresolvedIds.length > 0 && (
<p className="mt-1 text-2xs text-slate-dim">
{resolution.unresolvedIds.length} mod
{resolution.unresolvedIds.length === 1 ? '' : 's'} could not be checked against the
Workshop; they will be written as-is.
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="hidden text-2xs text-slate-dim sm:inline">
Applies on the next restart
</span>
<Button onClick={onDiscard} disabled={applying}>
Discard all
</Button>
<Button variant="accent" icon="upload" onClick={onApply} loading={applying}>
Apply to server
</Button>
</div>
</div>
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
import type { ModOverviewEntry, ReforgerConfigMod } from '@reforger-panel/shared';
/**
* The Mods page edits a local draft of `game.mods` and writes it once.
*
* The previous page autosaved 1.5s after every keystroke, which meant a bulk
* operation like "update all" or importing another server's list produced a
* burst of writes to config.json and no chance to review the result. Here every
* edit is staged, diffed against what the server actually has, and applied in
* a single request.
*/
export type DraftMod = ReforgerConfigMod & { modId: string };
export type ChangeKind = 'add' | 'remove' | 'version';
export type Change = {
modId: string;
kind: ChangeKind;
name: string;
/** Previous pinned version, for `version` and `remove`. */
from: string | null;
/** New pinned version, for `version` and `add`. */
to: string | null;
};
export function normalizeId(modId: string): string {
return modId.toUpperCase();
}
export function draftFromOverview(mods: readonly ModOverviewEntry[]): DraftMod[] {
return mods.map((mod) => ({
modId: mod.modId,
...(mod.configName ? { name: mod.configName } : {}),
...(mod.pinnedVersion ? { version: mod.pinnedVersion } : {}),
}));
}
export function displayName(
modId: string,
entry: ModOverviewEntry | undefined,
fallback?: string | null,
): string {
return entry?.workshop?.name ?? entry?.configName ?? fallback ?? modId;
}
export function computeChanges(
baseline: readonly DraftMod[],
draft: readonly DraftMod[],
nameOf: (modId: string, fallback?: string | null) => string,
): Change[] {
const before = new Map(baseline.map((mod) => [mod.modId, mod]));
const after = new Map(draft.map((mod) => [mod.modId, mod]));
const changes: Change[] = [];
for (const [modId, mod] of after) {
const existing = before.get(modId);
if (!existing) {
changes.push({
modId,
kind: 'add',
name: nameOf(modId, mod.name),
from: null,
to: mod.version ?? null,
});
} else if ((existing.version ?? null) !== (mod.version ?? null)) {
changes.push({
modId,
kind: 'version',
name: nameOf(modId, mod.name),
from: existing.version ?? null,
to: mod.version ?? null,
});
}
}
for (const [modId, mod] of before) {
if (after.has(modId)) continue;
changes.push({
modId,
kind: 'remove',
name: nameOf(modId, mod.name),
from: mod.version ?? null,
to: null,
});
}
// Adds first, then version bumps, then removals — reads as a plan.
const order: Record<ChangeKind, number> = { add: 0, version: 1, remove: 2 };
return changes.sort((a, b) => order[a.kind] - order[b.kind] || a.name.localeCompare(b.name));
}
export function upsertMod(draft: readonly DraftMod[], mod: DraftMod): DraftMod[] {
const modId = normalizeId(mod.modId);
const next = draft.filter((entry) => entry.modId !== modId);
next.push({ ...mod, modId });
return next;
}
export function removeMod(draft: readonly DraftMod[], modId: string): DraftMod[] {
const id = normalizeId(modId);
return draft.filter((entry) => entry.modId !== id);
}
export function setModVersion(
draft: readonly DraftMod[],
modId: string,
version: string | null,
): DraftMod[] {
const id = normalizeId(modId);
return draft.map((entry) => {
if (entry.modId !== id) return entry;
const { version: _dropped, ...rest } = entry;
return version ? { ...rest, version } : rest;
});
}
/**
* Merge keeps everything already installed and adds what is missing; replace
* mirrors the source list exactly, including removals and version pins.
*/
export function mergeModLists(
draft: readonly DraftMod[],
incoming: readonly DraftMod[],
mode: 'merge' | 'replace',
): DraftMod[] {
if (mode === 'replace') {
return incoming.map((mod) => ({ ...mod, modId: normalizeId(mod.modId) }));
}
const existing = new Set(draft.map((mod) => mod.modId));
return [
...draft,
...incoming
.filter((mod) => !existing.has(normalizeId(mod.modId)))
.map((mod) => ({ ...mod, modId: normalizeId(mod.modId) })),
];
}
@@ -0,0 +1,236 @@
import { useMemo, useState } from 'react';
import type { WorkshopServerSummary } from '@reforger-panel/shared';
import { useWorkshopServerMods, useWorkshopServers } from '../../api/hooks.js';
import { formatBytes } from '../../lib/format.js';
import {
Badge,
Button,
Dialog,
EmptyState,
SearchInput,
SegmentedControl,
Spinner,
StatusBadge,
} from '../ui.js';
import { Icon } from '../icons.js';
import type { DraftMod } from './changeset.js';
type Mode = 'merge' | 'replace';
/**
* Copies a modlist off a live Arma Reforger server, so a community setup can
* be reproduced without hunting down and adding ninety mods by hand.
*
* Nothing is written here the result lands in the staged changeset, which is
* reviewed and applied like any other edit.
*/
export function ImportServerDialog({
open,
onClose,
currentIds,
onImport,
}: {
open: boolean;
onClose: () => void;
currentIds: ReadonlySet<string>;
onImport: (mods: DraftMod[], mode: Mode) => void;
}) {
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<WorkshopServerSummary | null>(null);
const [mode, setMode] = useState<Mode>('merge');
const servers = useWorkshopServers(query, open && selected === null);
const serverMods = useWorkshopServerMods(selected?.id ?? null);
const diff = useMemo(() => {
const mods = serverMods.data?.mods ?? [];
const incoming = mods.map((mod): DraftMod => ({
modId: mod.id,
name: mod.name,
...(mod.version ? { version: mod.version } : {}),
}));
const added = incoming.filter((mod) => !currentIds.has(mod.modId));
const shared = incoming.filter((mod) => currentIds.has(mod.modId));
const removed = [...currentIds].filter((id) => !incoming.some((mod) => mod.modId === id));
const addedBytes = mods
.filter((mod) => !currentIds.has(mod.id))
.reduce((sum, mod) => sum + (mod.sizeBytes ?? 0), 0);
return { incoming, added, shared, removed, addedBytes };
}, [serverMods.data?.mods, currentIds]);
const close = () => {
setSelected(null);
onClose();
};
return (
<Dialog
open={open}
onClose={close}
width="lg"
title="Import a modlist from a server"
description="Search the live Arma Reforger server browser, then stage its mods."
footer={
selected && (
<>
<Button icon="chevron-left" onClick={() => setSelected(null)}>
Back to search
</Button>
<Button
variant="accent"
icon="plus"
disabled={
serverMods.isLoading ||
(mode === 'merge' ? diff.added.length === 0 : diff.incoming.length === 0)
}
onClick={() => {
onImport(diff.incoming, mode);
close();
}}
>
{mode === 'merge'
? `Stage ${diff.added.length} new mod${diff.added.length === 1 ? '' : 's'}`
: `Replace list with ${diff.incoming.length}`}
</Button>
</>
)
}
>
{!selected ? (
<div className="space-y-3">
<SearchInput
autoFocus
value={query}
onChange={setQuery}
placeholder="Server name, e.g. HOGS OF WAR"
/>
{query.trim().length < 2 ? (
<EmptyState
icon="search"
title="Search for a server by name"
hint="Only servers that actually run mods are listed."
/>
) : servers.isLoading ? (
<Spinner label="Searching the server browser…" />
) : servers.error ? (
<EmptyState icon="alert" title="The server browser is unavailable right now" />
) : (servers.data?.servers.length ?? 0) === 0 ? (
<EmptyState title="No servers matched that name" />
) : (
<ul className="divide-y divide-graphite-800 rounded-sm border border-graphite-700">
{servers.data!.servers.map((server) => (
<li key={server.id}>
<button
type="button"
onClick={() => setSelected(server)}
className="flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-graphite-850"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-zinc-100">{server.name}</p>
<p className="numeric mt-0.5 flex flex-wrap gap-x-3 text-2xs text-slate-dim">
<span>
{server.players}/{server.maxPlayers} players
</span>
<span>{server.modCount} mods</span>
{server.region && <span>{server.region}</span>}
{server.scenarioName && (
<span className="truncate">{server.scenarioName}</span>
)}
</p>
</div>
<StatusBadge status={server.online ? 'online' : 'offline'} compact />
<Icon name="chevron-right" className="h-4 w-4 text-slate-faint" />
</button>
</li>
))}
</ul>
)}
</div>
) : (
<div className="space-y-4">
<div className="rounded-sm border border-graphite-700 bg-graphite-950 px-3 py-2.5">
<p className="truncate text-sm text-zinc-100">{selected.name}</p>
<p className="numeric mt-0.5 text-2xs text-slate-dim">
{selected.modCount} mods · {selected.players}/{selected.maxPlayers} players
{selected.scenarioName ? ` · ${selected.scenarioName}` : ''}
</p>
</div>
<SegmentedControl<Mode>
value={mode}
onChange={setMode}
options={[
{ value: 'merge', label: 'Merge — add what is missing' },
{ value: 'replace', label: 'Replace — mirror exactly' },
]}
/>
{serverMods.isLoading ? (
<Spinner label="Reading the server's mod list…" />
) : serverMods.error ? (
<EmptyState icon="alert" title="Could not read that server's mod list" />
) : (
<>
<div className="grid grid-cols-3 gap-2 text-center">
<Stat label="To add" value={diff.added.length} tone="ok" />
<Stat label="Already installed" value={diff.shared.length} tone="neutral" />
<Stat
label={mode === 'replace' ? 'To remove' : 'Kept (not on that server)'}
value={diff.removed.length}
tone={mode === 'replace' ? 'danger' : 'neutral'}
/>
</div>
{diff.addedBytes > 0 && (
<p className="numeric text-xs text-slate-dim">
Approximately {formatBytes(diff.addedBytes)} of new downloads.
{(serverMods.data?.unresolvedCount ?? 0) > 0 &&
` ${serverMods.data!.unresolvedCount} mod sizes are unknown.`}
</p>
)}
<div className="max-h-64 overflow-y-auto rounded-sm border border-graphite-700">
<ul className="divide-y divide-graphite-800">
{diff.incoming.map((mod) => {
const isNew = !currentIds.has(mod.modId);
return (
<li key={mod.modId} className="flex items-center gap-2 px-3 py-1.5 text-xs">
<Badge tone={isNew ? 'ok' : 'neutral'}>{isNew ? 'new' : 'have'}</Badge>
<span className="min-w-0 flex-1 truncate text-zinc-200">{mod.name}</span>
<span className="numeric shrink-0 text-slate-dim">
{mod.version ?? 'latest'}
</span>
</li>
);
})}
</ul>
</div>
</>
)}
</div>
)}
</Dialog>
);
}
function Stat({
label,
value,
tone,
}: {
label: string;
value: number;
tone: 'ok' | 'danger' | 'neutral';
}) {
const tones = {
ok: 'text-ok-400',
danger: 'text-danger-400',
neutral: 'text-zinc-200',
} as const;
return (
<div className="rounded-sm border border-graphite-700 bg-graphite-950 px-3 py-2">
<p className={`numeric text-lg font-semibold ${tones[tone]}`}>{value}</p>
<p className="text-2xs text-slate-dim">{label}</p>
</div>
);
}
@@ -0,0 +1,263 @@
import { useMemo, useState } from 'react';
import type { ModOverviewEntry, ModsOverviewResponse } from '@reforger-panel/shared';
import { formatBytes } from '../../lib/format.js';
import {
Badge,
Button,
EmptyState,
ModImage,
Notice,
SearchInput,
SegmentedControl,
} from '../ui.js';
import { Icon } from '../icons.js';
import type { DraftMod } from './changeset.js';
type Filter = 'all' | 'updates' | 'issues';
/**
* The installed modlist, joined with Workshop metadata server-side so the page
* paints in one request rather than one request per mod.
*/
export function InstalledPanel({
overview,
draft,
canManage,
onOpen,
onRemove,
onPinVersion,
onAddDependency,
}: {
overview: ModsOverviewResponse;
draft: readonly DraftMod[];
canManage: boolean;
onOpen: (modId: string) => void;
onRemove: (modId: string) => void;
onPinVersion: (entry: ModOverviewEntry) => void;
onAddDependency: (modId: string, name: string) => void;
}) {
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<Filter>('all');
const draftIds = useMemo(() => new Set(draft.map((mod) => mod.modId)), [draft]);
/**
* Rows come from the staged draft, not the server list, so a mod added in
* this session appears immediately and one queued for removal disappears.
*/
const rows = useMemo(() => {
const byId = new Map(overview.mods.map((entry) => [entry.modId, entry]));
return draft.map((mod) => ({
modId: mod.modId,
draft: mod,
entry: byId.get(mod.modId),
}));
}, [draft, overview.mods]);
const issueCount = overview.mods.filter(
(entry) =>
draftIds.has(entry.modId) &&
(entry.missingDependencies.some((dep) => !draftIds.has(dep.id)) ||
entry.workshop?.obsolete ||
entry.workshop === null),
).length;
const visible = rows.filter(({ modId, draft: mod, entry }) => {
const name = entry?.workshop?.name ?? mod.name ?? modId;
if (query && !`${name} ${modId}`.toLowerCase().includes(query.toLowerCase())) return false;
if (filter === 'updates') return Boolean(entry?.updateAvailable);
if (filter === 'issues') {
return Boolean(
entry === undefined ||
entry.workshop === null ||
entry.workshop.obsolete ||
entry.missingDependencies.some((dep) => !draftIds.has(dep.id)),
);
}
return true;
});
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<SearchInput
value={query}
onChange={setQuery}
placeholder="Filter installed mods…"
className="w-full sm:w-72"
/>
<SegmentedControl<Filter>
value={filter}
onChange={setFilter}
options={[
{ value: 'all', label: 'All', count: draft.length },
{ value: 'updates', label: 'Updates', count: overview.updatesAvailable },
{ value: 'issues', label: 'Issues', count: issueCount },
]}
/>
<div className="numeric ml-auto text-2xs text-slate-dim">
{overview.totalSizeBytes ? `${formatBytes(overview.totalSizeBytes)} installed` : null}
</div>
</div>
{overview.warming && (
<Notice tone="info">
Loading Workshop metadata for {overview.mods.length} mods. Names, versions and
dependencies fill in as they arrive.
</Notice>
)}
{overview.unresolvedIds.length > 0 && !overview.warming && (
<Notice tone="warn" title={`${overview.unresolvedIds.length} mods could not be identified`}>
They may be private, delisted, or the Workshop index may be missing them. They are still
installed and are left untouched.
</Notice>
)}
{visible.length === 0 ? (
<EmptyState
icon="package"
title={draft.length === 0 ? 'The server runs vanilla' : 'No mods match this filter'}
hint={
draft.length === 0
? 'Add mods from the Browse tab, or import a modlist from another server.'
: undefined
}
/>
) : (
<ul className="divide-y divide-graphite-800 overflow-hidden rounded-md border border-graphite-700">
{visible.map(({ modId, draft: mod, entry }) => (
<ModRow
key={modId}
modId={modId}
draft={mod}
entry={entry}
draftIds={draftIds}
canManage={canManage}
onOpen={() => onOpen(modId)}
onRemove={() => onRemove(modId)}
onPinVersion={() => entry && onPinVersion(entry)}
onAddDependency={onAddDependency}
/>
))}
</ul>
)}
</div>
);
}
function ModRow({
modId,
draft,
entry,
draftIds,
canManage,
onOpen,
onRemove,
onPinVersion,
onAddDependency,
}: {
modId: string;
draft: DraftMod;
entry: ModOverviewEntry | undefined;
draftIds: ReadonlySet<string>;
canManage: boolean;
onOpen: () => void;
onRemove: () => void;
onPinVersion: () => void;
onAddDependency: (modId: string, name: string) => void;
}) {
const workshop = entry?.workshop ?? null;
const name = workshop?.name ?? draft.name ?? entry?.configName ?? modId;
const pinned = draft.version ?? null;
const latest = workshop?.latestVersion ?? null;
const outdated = Boolean(pinned && latest && pinned !== latest);
const missing = (entry?.missingDependencies ?? []).filter((dep) => !draftIds.has(dep.id));
const blockers = (entry?.requiredBy ?? []).filter((id) => draftIds.has(id));
return (
<li className="flex flex-wrap items-center gap-3 px-3 py-2.5 hover:bg-graphite-850/50">
<button
type="button"
onClick={onOpen}
className="group flex min-w-0 flex-1 items-center gap-3 text-left"
>
<ModImage src={workshop?.imageUrl ?? null} className="h-9 w-14" />
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 truncate text-sm text-zinc-100 group-hover:text-accent-300">
{name}
{workshop?.obsolete && <Badge tone="danger">obsolete</Badge>}
{workshop === null && entry !== undefined && <Badge tone="warn">unknown</Badge>}
</p>
<p className="truncate font-mono text-2xs text-slate-faint">
{workshop?.author ? `${workshop.author} · ` : ''}
{modId}
</p>
</div>
</button>
<div className="numeric hidden w-24 shrink-0 text-right text-2xs text-slate-dim sm:block">
{workshop?.sizeBytes ? formatBytes(workshop.sizeBytes) : '—'}
</div>
<button
type="button"
disabled={!canManage || !entry}
onClick={onPinVersion}
title={pinned ? `Pinned to ${pinned}` : 'Tracking latest'}
className="numeric flex shrink-0 items-center gap-1.5 rounded-sm border border-graphite-700 bg-graphite-950 px-2 py-1 text-2xs text-zinc-200 transition-colors enabled:hover:border-graphite-500 disabled:opacity-50"
>
{pinned ?? 'latest'}
{outdated && (
<>
<Icon name="chevron-right" className="h-3 w-3 text-warn-400" />
<span className="text-warn-400">{latest}</span>
</>
)}
{canManage && entry && <Icon name="chevron-down" className="h-3 w-3 text-slate-faint" />}
</button>
{canManage && (
<Button
size="sm"
variant="ghost"
icon="trash"
onClick={onRemove}
title={
blockers.length > 0
? `Still required by ${blockers.length} installed mod(s)`
: 'Remove from the mod list'
}
/>
)}
{(missing.length > 0 || blockers.length > 0) && (
<div className="flex w-full flex-wrap items-center gap-2 pl-[4.25rem] text-2xs">
{missing.length > 0 && (
<>
<span className="text-warn-400">
Missing {missing.length} dependenc{missing.length === 1 ? 'y' : 'ies'}:
</span>
{missing.map((dependency) => (
<button
key={dependency.id}
type="button"
disabled={!canManage}
onClick={() => onAddDependency(dependency.id, dependency.name)}
className="rounded-xs border border-warn-400/40 bg-warn-400/10 px-1.5 py-0.5 text-warn-400 transition-colors enabled:hover:bg-warn-400/20 disabled:opacity-60"
>
+ {dependency.name}
</button>
))}
</>
)}
{blockers.length > 0 && (
<span className="text-slate-dim">
Required by {blockers.length} installed mod{blockers.length === 1 ? '' : 's'}
</span>
)}
</div>
)}
</li>
);
}
@@ -0,0 +1,168 @@
import { useWorkshopMod } from '../../api/hooks.js';
import { formatBytes, formatDateTime } from '../../lib/format.js';
import { Badge, Button, Dialog, EmptyState, ModImage, Spinner } from '../ui.js';
/** Read-only Workshop record for one mod, with the add/remove action inline. */
export function ModDetailDialog({
modId,
onClose,
installed,
canManage,
onAdd,
onRemove,
}: {
modId: string | null;
onClose: () => void;
installed: boolean;
canManage: boolean;
onAdd: () => void;
onRemove: () => void;
}) {
const { data: mod, isLoading, error } = useWorkshopMod(modId);
return (
<Dialog
open={modId !== null}
onClose={onClose}
width="lg"
title={mod?.name ?? 'Mod details'}
description={mod ? `by ${mod.author}` : undefined}
footer={
canManage &&
mod && (
<>
{mod.workshopUrl && (
<a
href={mod.workshopUrl}
target="_blank"
rel="noreferrer noopener"
className="mr-auto inline-flex items-center gap-1.5 text-xs text-accent-400 hover:underline"
>
Open on the Workshop
</a>
)}
{installed ? (
<Button variant="danger" icon="minus" onClick={onRemove}>
Remove from server
</Button>
) : (
<Button variant="accent" icon="plus" onClick={onAdd}>
Add to server
</Button>
)}
</>
)
}
>
{isLoading ? (
<Spinner label="Loading mod details…" />
) : error || !mod ? (
<EmptyState
icon="alert"
title="This mod could not be loaded"
hint="It may be private, delisted, or the metadata service may be down."
/>
) : (
<div className="space-y-4">
<div className="flex gap-4">
<ModImage src={mod.imageUrl} className="h-24 w-40" />
<dl className="grid flex-1 grid-cols-2 gap-x-4 gap-y-2 text-xs">
<Detail label="Latest version" value={mod.version ?? '—'} />
<Detail label="Game version" value={mod.gameVersion ?? '—'} />
<Detail
label="Size"
value={mod.sizeBytes ? formatBytes(mod.sizeBytes) : (mod.sizeText ?? '—')}
/>
<Detail
label="With dependencies"
value={mod.totalSizeBytes ? formatBytes(mod.totalSizeBytes) : '—'}
/>
<Detail
label="Rating"
value={
mod.rating === null
? '—'
: `${Math.round(mod.rating * 100)}%${mod.ratingCount ? ` (${mod.ratingCount})` : ''}`
}
/>
<Detail label="Subscribers" value={mod.subscriberCount?.toLocaleString() ?? '—'} />
<Detail label="Updated" value={formatDateTime(mod.updatedAt)} />
<Detail label="Mod ID" value={mod.id} mono />
</dl>
</div>
{(mod.obsolete || mod.tags.length > 0) && (
<div className="flex flex-wrap gap-1.5">
{mod.obsolete && <Badge tone="danger">obsolete</Badge>}
{mod.tags.map((tag) => (
<Badge key={tag}>{tag}</Badge>
))}
</div>
)}
{(mod.summary ?? mod.description) && (
<div>
<p className="eyebrow mb-1.5">Description</p>
<p className="max-h-48 overflow-y-auto whitespace-pre-wrap text-xs leading-5 text-slate-ink">
{mod.description ?? mod.summary}
</p>
</div>
)}
{mod.dependencies.length > 0 && (
<div>
<p className="eyebrow mb-1.5">Requires {mod.dependencies.length} other mods</p>
<ul className="divide-y divide-graphite-800 rounded-sm border border-graphite-700">
{mod.dependencies.map((dependency) => (
<li key={dependency.id} className="flex items-center gap-2 px-3 py-1.5 text-xs">
<span className="min-w-0 flex-1 truncate text-zinc-200">{dependency.name}</span>
<span className="numeric shrink-0 text-slate-dim">
{dependency.sizeBytes ? formatBytes(dependency.sizeBytes) : '—'}
</span>
</li>
))}
</ul>
</div>
)}
{mod.scenarios.length > 0 && (
<div>
<p className="eyebrow mb-1.5">
Ships {mod.scenarios.length} scenario{mod.scenarios.length === 1 ? '' : 's'}
</p>
<ul className="divide-y divide-graphite-800 rounded-sm border border-graphite-700">
{mod.scenarios.map((scenario) => (
<li key={scenario.scenarioId} className="px-3 py-2">
<p className="flex items-center gap-2 text-xs text-zinc-200">
{scenario.name}
{scenario.gameMode && <Badge>{scenario.gameMode}</Badge>}
{scenario.playerCount && (
<span className="numeric text-2xs text-slate-dim">
{scenario.playerCount} players
</span>
)}
</p>
<p className="mt-0.5 truncate font-mono text-2xs text-slate-faint">
{scenario.scenarioId}
</p>
</li>
))}
</ul>
</div>
)}
</div>
)}
</Dialog>
);
}
function Detail({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-baseline justify-between gap-3">
<dt className="eyebrow">{label}</dt>
<dd className={`truncate text-right text-xs text-zinc-200 ${mono ? 'font-mono' : 'numeric'}`}>
{value}
</dd>
</div>
);
}
@@ -0,0 +1,160 @@
import { useState } from 'react';
import { useWorkshopModVersions } from '../../api/hooks.js';
import { formatBytes, formatDateTime } from '../../lib/format.js';
import { Badge, Button, Dialog, EmptyState, Field, Spinner } from '../ui.js';
/**
* Pins a specific Workshop version, or clears the pin so the server tracks
* whatever is current. Reforger only accepts versions that actually exist, so
* the list is the primary control but a manual field is kept for versions
* the metadata API has not indexed yet.
*/
export function VersionDialog({
open,
modId,
modName,
currentVersion,
latestVersion,
onClose,
onSelect,
}: {
open: boolean;
modId: string | null;
modName: string;
currentVersion: string | null;
latestVersion: string | null;
onClose: () => void;
onSelect: (version: string | null) => void;
}) {
const { data, isLoading, error } = useWorkshopModVersions(open ? modId : null);
const [manual, setManual] = useState('');
const [manualError, setManualError] = useState<string | null>(null);
const applyManual = () => {
const value = manual.trim();
if (!value) {
setManualError('Enter a version, or use "Track latest".');
return;
}
if (!/^[\w.+-]{1,32}$/.test(value)) {
setManualError('Versions may only contain letters, digits, dots, plus and dashes.');
return;
}
onSelect(value);
};
return (
<Dialog
open={open}
onClose={onClose}
width="lg"
title={`Version — ${modName}`}
description={
currentVersion
? `Currently pinned to ${currentVersion}.`
: 'Currently unpinned: the server takes the latest version at boot.'
}
footer={
<>
<Button onClick={onClose}>Cancel</Button>
<Button
variant="accent"
icon="check"
onClick={() => onSelect(null)}
disabled={currentVersion === null}
>
Track latest
</Button>
</>
}
>
<div className="space-y-4">
<Field
label="Enter a version manually"
hint="Use this when the version you need is newer than the metadata index."
error={manualError}
>
<div className="flex gap-2">
<input
value={manual}
placeholder={latestVersion ?? '1.0.0'}
onChange={(event) => {
setManual(event.target.value);
setManualError(null);
}}
onKeyDown={(event) => event.key === 'Enter' && applyManual()}
className={`input font-mono ${manualError ? 'input-error' : ''}`}
/>
<Button onClick={applyManual}>Pin</Button>
</div>
</Field>
<div>
<p className="eyebrow mb-2">Published versions</p>
{isLoading ? (
<Spinner label="Loading version history…" />
) : error ? (
<EmptyState
icon="alert"
title="Version history is unavailable"
hint="The Workshop metadata service did not answer. You can still pin a version manually above."
/>
) : !data || data.versions.length === 0 ? (
<EmptyState title="No published versions listed for this mod" />
) : (
<div className="max-h-80 overflow-y-auto rounded-sm border border-graphite-700">
<table className="data-table w-full">
<thead className="sticky top-0 bg-graphite-900">
<tr>
<th className="pl-3">Version</th>
<th>Game</th>
<th className="text-right">Size</th>
<th>Published</th>
<th />
</tr>
</thead>
<tbody>
{data.versions.map((version) => {
const active = version.version === currentVersion;
return (
<tr key={version.version}>
<td className="pl-3 font-mono text-xs text-zinc-100">
<span className="flex items-center gap-2">
{version.version}
{version.version === latestVersion && (
<Badge tone="accent">latest</Badge>
)}
{!version.approved && <Badge tone="warn">unapproved</Badge>}
</span>
</td>
<td className="numeric text-xs text-slate-dim">
{version.gameVersion ?? '—'}
</td>
<td className="numeric text-right text-xs text-slate-dim">
{version.sizeBytes ? formatBytes(version.sizeBytes) : '—'}
</td>
<td className="text-xs text-slate-dim">
{formatDateTime(version.createdAt)}
</td>
<td className="pr-3 text-right">
<Button
size="sm"
variant={active ? 'subtle' : 'accent'}
disabled={active}
onClick={() => onSelect(version.version)}
>
{active ? 'Pinned' : 'Pin'}
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
</Dialog>
);
}
+180 -115
View File
@@ -1,12 +1,15 @@
import { useEffect, useState } from 'react';
import type { PerformanceSettings } from '@reforger-panel/shared';
import { useMemo, useState } from 'react';
import type { PerformanceSettings, PerformanceSettingsPatch } from '@reforger-panel/shared';
import { usePerformanceSettings, useSetPerformanceSettings } from '../api/hooks.js';
import { Button, Card, Spinner } from './ui.js';
import { Button, EmptyState, Spinner, useToast } from './ui.js';
import { Icon } from './icons.js';
type NumberKey = {
[K in keyof PerformanceSettings]: PerformanceSettings[K] extends number | null ? K : never;
}[keyof PerformanceSettings];
type BooleanKey = Exclude<keyof PerformanceSettings, NumberKey>;
type BooleanKey = {
[K in keyof PerformanceSettings]: PerformanceSettings[K] extends boolean | null ? K : never;
}[keyof PerformanceSettings];
// Ranges/defaults from the Bohemia server-config reference. Blank fields are
// omitted from config.json so the game default applies.
@@ -15,95 +18,114 @@ type BooleanKey = Exclude<keyof PerformanceSettings, NumberKey>;
const NUMBER_FIELDS: { key: NumberKey; label: string; min: number; max: number; hint: string }[] = [
{
key: 'serverMaxViewDistance',
label: 'Server view distance (m)',
label: 'Server view distance',
min: 500,
max: 10000,
hint: 'default 1600',
hint: 'metres · default 1600',
},
{
key: 'networkViewDistance',
label: 'Network view distance (m)',
label: 'Network view distance',
min: 500,
max: 5000,
hint: 'default 1500',
hint: 'metres · default 1500',
},
{
key: 'serverMinGrassDistance',
label: 'Min grass distance (m)',
label: 'Min grass distance',
min: 0,
max: 150,
hint: '0 = client choice',
hint: 'metres · 0 = client choice',
},
{ key: 'aiLimit', label: 'AI limit', min: -1, max: 1000, hint: '-1 = unlimited' },
{
key: 'playerSaveTime',
label: 'Player save interval (s)',
label: 'Player save interval',
min: 1,
max: 3600,
hint: 'default 120',
hint: 'seconds · default 120',
},
{
key: 'slotReservationTimeout',
label: 'Slot reservation timeout (s)',
label: 'Slot reservation timeout',
min: 5,
max: 300,
hint: 'default 60',
hint: 'seconds · default 60',
},
];
const BOOLEAN_FIELDS: { key: BooleanKey; label: string; hint: string }[] = [
{ key: 'disableAI', label: 'Disable AI', hint: 'default enabled' },
{ key: 'disableThirdPerson', label: 'Disable third person', hint: 'default disabled' },
{ key: 'fastValidation', label: 'Fast validation', hint: 'default enabled' },
{ key: 'battlEye', label: 'BattlEye', hint: 'default enabled' },
{ key: 'lobbyPlayerSynchronise', label: 'Lobby player sync', hint: 'default enabled' },
{ key: 'disableAI', label: 'Disable AI', hint: 'default: AI enabled' },
{ key: 'disableThirdPerson', label: 'Disable third person', hint: 'default: allowed' },
{ key: 'fastValidation', label: 'Fast validation', hint: 'default: enabled' },
{ key: 'battlEye', label: 'BattlEye', hint: 'default: enabled' },
{ key: 'lobbyPlayerSynchronise', label: 'Lobby player sync', hint: 'default: enabled' },
];
type FormState = Record<string, string>;
type FieldKey = NumberKey | BooleanKey;
function toFormState(settings: PerformanceSettings): FormState {
const state: FormState = {};
for (const field of NUMBER_FIELDS) {
const value = settings[field.key];
state[field.key] = value === null ? '' : String(value);
}
for (const field of BOOLEAN_FIELDS) {
const value = settings[field.key];
state[field.key] = value === null ? '' : String(value);
}
return state;
function toText(value: number | boolean | null): string {
return value === null ? '' : String(value);
}
/**
* Curated, range-validated view of the performance settings.
*
* Only fields the user actually edits are submitted the old form posted all
* thirteen values on every save, so a form loaded before somebody else's change
* silently reverted it on the next submit.
*/
export function PerformanceForm({ slug, canEdit }: { slug: string; canEdit: boolean }) {
const { data, isLoading, error: loadError } = usePerformanceSettings(slug);
const toast = useToast();
const { data, isLoading, error, refetch } = usePerformanceSettings(slug);
const save = useSetPerformanceSettings(slug);
const [form, setForm] = useState<FormState | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
useEffect(() => {
if (data && form === null) setForm(toFormState(data.settings));
}, [data, form]);
const [edits, setEdits] = useState<Map<FieldKey, string>>(new Map());
const [fieldErrors, setFieldErrors] = useState<Partial<Record<FieldKey, string>>>({});
if (isLoading || (!form && !loadError)) return <Spinner label="Downloading config.json…" />;
if (loadError) return <p className="text-sm text-danger-400">{loadError.message}</p>;
if (!form || !data) return null;
const baseline = data?.settings;
const baseline = toFormState(data.settings);
const dirty = Object.keys(form).some((key) => form[key] !== baseline[key]);
const dirtyKeys = useMemo(
() =>
[...edits.entries()]
.filter(([key, value]) => baseline && value !== toText(baseline[key]))
.map(([key]) => key),
[edits, baseline],
);
const set = (key: string, value: string) => {
setMessage(null);
setForm({ ...form, [key]: value });
if (isLoading) return <Spinner label="Downloading config.json…" />;
if (error || !data || !baseline) {
return (
<EmptyState
icon="alert"
title="Could not read the performance settings"
hint={error?.message}
action={
<Button icon="refresh" onClick={() => void refetch()}>
Retry
</Button>
}
/>
);
}
const valueOf = (key: FieldKey): string => edits.get(key) ?? toText(baseline[key]);
const isDirty = (key: FieldKey) => dirtyKeys.includes(key);
const set = (key: FieldKey, value: string) => {
setEdits((current) => new Map(current).set(key, value));
setFieldErrors((current) => ({ ...current, [key]: undefined }));
};
const validateAndBuild = (): PerformanceSettings | null => {
const errors: Record<string, string> = {};
const result = {} as Record<string, number | boolean | null>;
const submit = () => {
const errors: Partial<Record<FieldKey, string>> = {};
const patch: PerformanceSettingsPatch = {};
for (const field of NUMBER_FIELDS) {
const raw = (form[field.key] ?? '').trim();
if (!isDirty(field.key)) continue;
const raw = valueOf(field.key).trim();
if (raw === '') {
result[field.key] = null;
patch[field.key] = null;
continue;
}
const value = Number(raw);
@@ -111,86 +133,72 @@ export function PerformanceForm({ slug, canEdit }: { slug: string; canEdit: bool
errors[field.key] = `Must be a whole number between ${field.min} and ${field.max}.`;
continue;
}
result[field.key] = value;
patch[field.key] = value;
}
for (const field of BOOLEAN_FIELDS) {
const raw = form[field.key] ?? '';
result[field.key] = raw === '' ? null : raw === 'true';
if (!isDirty(field.key)) continue;
const raw = valueOf(field.key);
patch[field.key] = raw === '' ? null : raw === 'true';
}
setFieldErrors(errors);
return Object.keys(errors).length > 0 ? null : (result as unknown as PerformanceSettings);
};
if (Object.values(errors).some(Boolean)) return;
const submit = () => {
const settings = validateAndBuild();
if (!settings) return;
save.mutate(settings, {
onSuccess: (result) => {
setForm(null); // re-derive from the fresh server response on next load
setMessage(
result.changedFields.length > 0
? `Saved ${result.changedFields.length} change${result.changedFields.length === 1 ? '' : 's'} to config.json — restart the server to apply.`
: 'No changes to save.',
);
save.mutate(
{ settings: patch, expectedRevision: data.revision, writeStartupVars: true },
{
onSuccess: (result) => {
setEdits(new Map());
void refetch();
toast(
result.changedFields.length > 0
? `Saved ${result.changedFields.length} change${result.changedFields.length === 1 ? '' : 's'}. Restart to apply.`
: 'No changes to save.',
'ok',
);
},
onError: (saveError) => toast(saveError.message, 'danger'),
},
onError: (saveError) => setMessage(saveError.message),
});
);
};
const inputClass = (key: string) => `input w-32 ${fieldErrors[key] ? 'input-error' : ''}`;
return (
<Card
title="Performance settings (config.json)"
action={
canEdit &&
dirty && (
<div className="flex items-center gap-2">
<span className="text-xs text-warn-400">unsaved changes</span>
<Button onClick={() => setForm(toFormState(data.settings))} disabled={save.isPending}>
Discard
</Button>
<Button variant="accent" onClick={submit} disabled={save.isPending}>
{save.isPending ? 'Saving…' : 'Save to server'}
</Button>
</div>
)
}
>
<div className="grid gap-x-8 gap-y-4 md:grid-cols-2">
<div className="space-y-4">
<div className="grid gap-x-8 gap-y-3 md:grid-cols-2">
{NUMBER_FIELDS.map((field) => (
<div key={field.key} className="flex items-center justify-between gap-4">
<div>
<p className="text-sm text-zinc-200">{field.label}</p>
<p className="text-xs text-slate-dim">
{field.min}{field.max} · {field.hint} · blank = game default
</p>
{fieldErrors[field.key] && (
<p className="text-xs text-danger-400">{fieldErrors[field.key]}</p>
)}
</div>
<FieldRow
key={field.key}
label={field.label}
hint={`${field.min}${field.max} · ${field.hint} · blank = game default`}
dirty={isDirty(field.key)}
error={fieldErrors[field.key]}
onReset={() => set(field.key, toText(baseline[field.key]))}
>
<input
type="number"
inputMode="numeric"
min={field.min}
max={field.max}
disabled={!canEdit}
value={form[field.key] ?? ''}
value={valueOf(field.key)}
placeholder="default"
onChange={(event) => set(field.key, event.target.value)}
className={inputClass(field.key)}
className={`input numeric w-32 ${fieldErrors[field.key] ? 'input-error' : ''}`}
/>
</div>
</FieldRow>
))}
{BOOLEAN_FIELDS.map((field) => (
<div key={field.key} className="flex items-center justify-between gap-4">
<div>
<p className="text-sm text-zinc-200">{field.label}</p>
<p className="text-xs text-slate-dim">{field.hint}</p>
</div>
<FieldRow
key={field.key}
label={field.label}
hint={field.hint}
dirty={isDirty(field.key)}
onReset={() => set(field.key, toText(baseline[field.key]))}
>
<select
disabled={!canEdit}
value={form[field.key] ?? ''}
value={valueOf(field.key)}
onChange={(event) => set(field.key, event.target.value)}
className="input w-32"
>
@@ -198,15 +206,72 @@ export function PerformanceForm({ slug, canEdit }: { slug: string; canEdit: bool
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
</div>
</FieldRow>
))}
</div>
{message && <p className="mt-4 text-xs text-accent-400">{message}</p>}
<p className="mt-4 text-xs text-slate-dim">
Values are validated against the ranges in the Bohemia server-config reference and written
directly to config.json (backup kept as config.json.bak). Network/identity settings (bind
address, ports, passwords) are never touched here. Changes apply on the next restart.
{canEdit && dirtyKeys.length > 0 && (
<div className="flex flex-wrap items-center gap-2 border-t border-graphite-700 pt-3">
<span className="text-xs text-warn-400">
{dirtyKeys.length} field{dirtyKeys.length === 1 ? '' : 's'} changed
</span>
<div className="ml-auto flex gap-2">
<Button onClick={() => setEdits(new Map())} disabled={save.isPending}>
Discard
</Button>
<Button variant="accent" icon="upload" onClick={submit} loading={save.isPending}>
Apply to server
</Button>
</div>
</div>
)}
<p className="text-2xs leading-5 text-slate-dim">
Values are validated against the Bohemia server-config reference and written directly to
config.json (the previous file is kept as config.json.bak). Network and identity settings
bind address, ports, passwords are never touched here. Changes apply on the next restart.
</p>
</Card>
</div>
);
}
function FieldRow({
label,
hint,
dirty,
error,
onReset,
children,
}: {
label: string;
hint: string;
dirty: boolean;
error?: string;
onReset: () => void;
children: React.ReactNode;
}) {
return (
<div
className={`flex items-center justify-between gap-4 rounded-sm px-2 py-1.5 ${dirty ? 'bg-accent-600/[0.07]' : ''}`}
>
<div className="min-w-0">
<p className="flex items-center gap-2 text-sm text-zinc-100">
{label}
{dirty && (
<button
type="button"
title="Revert to the value on the server"
onClick={onReset}
className="text-accent-400 hover:text-accent-300"
>
<Icon name="refresh" className="h-3 w-3" />
</button>
)}
</p>
<p className="text-2xs text-slate-dim">{hint}</p>
{error && <p className="text-2xs text-danger-400">{error}</p>}
</div>
{children}
</div>
);
}
+30 -15
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useStartupVariables, useUpdateStartupVariable } from '../api/hooks.js';
import { Button, Card, EmptyState, Spinner } from './ui.js';
import { STARTUP_MIRROR_HINTS } from './config/mirror-hints.js';
import { Badge, Button, Card, EmptyState, Spinner, useToast } from './ui.js';
/**
* Pterodactyl egg startup variables (passwords, launch options, ). Values
@@ -14,15 +15,14 @@ export function StartupVarsCard({ slug }: { slug: string }) {
const { data, isLoading, error } = useStartupVariables(slug, true);
const update = useUpdateStartupVariable(slug);
const [edits, setEdits] = useState<Record<string, string>>({});
const [message, setMessage] = useState<string | null>(null);
const [revealed, setRevealed] = useState<Record<string, boolean>>({});
const toast = useToast();
const isSecret = (name: string) => /password|token|secret|key/i.test(name);
const saveVariable = (envVariable: string) => {
const value = edits[envVariable];
if (value === undefined) return;
setMessage(null);
update.mutate(
{ key: envVariable, value },
{
@@ -32,9 +32,9 @@ export function StartupVarsCard({ slug }: { slug: string }) {
delete next[envVariable];
return next;
});
setMessage(`${envVariable} saved — applies on the next restart.`);
toast(`${envVariable} saved — applies on the next restart.`, 'ok');
},
onError: (updateError) => setMessage(updateError.message),
onError: (updateError) => toast(updateError.message, 'danger'),
},
);
};
@@ -58,12 +58,23 @@ export function StartupVarsCard({ slug }: { slug: string }) {
return (
<li
key={variable.envVariable}
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-graphite-800 px-3.5 py-2.5"
className="flex flex-wrap items-center justify-between gap-3 rounded-sm border border-graphite-800 bg-graphite-950/40 px-3 py-2.5"
>
<div className="min-w-0 flex-1">
<p className="text-sm text-zinc-200">
{variable.name}{' '}
<code className="ml-1 text-xs text-slate-dim">{variable.envVariable}</code>
<p className="flex flex-wrap items-center gap-2 text-sm text-zinc-100">
{variable.name}
<code className="font-mono text-2xs text-slate-dim">
{variable.envVariable}
</code>
{STARTUP_MIRROR_HINTS[variable.envVariable] && (
<Badge
tone="warn"
icon="alert"
title={`Also written into config.json at ${STARTUP_MIRROR_HINTS[variable.envVariable]}`}
>
templates {STARTUP_MIRROR_HINTS[variable.envVariable]}
</Badge>
)}
</p>
{variable.description && (
<p className="mt-0.5 text-xs text-slate-dim">{variable.description}</p>
@@ -82,6 +93,7 @@ export function StartupVarsCard({ slug }: { slug: string }) {
/>
{secret && (
<Button
size="sm"
onClick={() => setRevealed({ ...revealed, [variable.envVariable]: !shown })}
>
{shown ? 'Hide' : 'Show'}
@@ -91,15 +103,17 @@ export function StartupVarsCard({ slug }: { slug: string }) {
edited !== undefined &&
edited !== variable.value && (
<Button
size="sm"
variant="accent"
disabled={update.isPending}
icon="upload"
loading={update.isPending}
onClick={() => saveVariable(variable.envVariable)}
>
Save
</Button>
)
) : (
<span className="text-xs text-slate-dim">read-only</span>
<Badge>read-only</Badge>
)}
</div>
</li>
@@ -107,10 +121,11 @@ export function StartupVarsCard({ slug }: { slug: string }) {
})}
</ul>
)}
{message && <p className="mt-3 text-xs text-accent-400">{message}</p>}
<p className="mt-3 text-xs text-slate-dim">
These are the same variables as Pterodactyl's Startup tab (server passwords live here, not
in config.json). Changes apply on the next server restart.
<p className="mt-3 text-2xs leading-5 text-slate-dim">
These are the same variables as Pterodactyl&rsquo;s Startup tab; server passwords live here
rather than in config.json. Variables marked as templating a config path are re-applied to
config.json when the container boots, so they win over a direct file edit. Changes apply on
the next server restart.
</p>
</Card>
);
+656 -97
View File
@@ -1,6 +1,20 @@
import { useState, type ReactNode } from 'react';
import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import type { Role, ServerStatus } from '@reforger-panel/shared';
import { ROLE_LABELS } from '@reforger-panel/shared';
import { Icon, Spinner16, type IconName } from './icons.js';
/* ------------------------------------------------------------------ layout */
export function Card({
title,
@@ -23,24 +37,385 @@ export function Card({
{action}
</header>
)}
<div className={padded ? 'p-5' : ''}>{children}</div>
<div className={padded ? 'p-4' : ''}>{children}</div>
</section>
);
}
export function PageHeader({
title,
kicker,
actions,
}: {
title: string;
kicker?: ReactNode;
actions?: ReactNode;
}) {
return (
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="page-title">{title}</h1>
{kicker && <p className="page-kicker">{kicker}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</div>
);
}
/* ----------------------------------------------------------------- buttons */
type ButtonVariant = 'default' | 'accent' | 'danger' | 'ghost' | 'subtle';
type ButtonSize = 'sm' | 'md';
const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
default: 'border-graphite-600 bg-graphite-800 text-zinc-200 hover:bg-graphite-700',
accent: 'border-accent-600 bg-accent-600/20 text-accent-300 hover:bg-accent-600/30',
danger: 'border-danger-400/45 bg-danger-400/10 text-danger-400 hover:bg-danger-400/20',
ghost:
'border-transparent bg-transparent text-slate-ink hover:bg-graphite-800 hover:text-zinc-100',
subtle:
'border-transparent bg-graphite-850 text-slate-ink hover:bg-graphite-800 hover:text-zinc-100',
};
const BUTTON_SIZES: Record<ButtonSize, string> = {
sm: 'min-h-7 gap-1.5 px-2 py-1 text-xs',
md: 'min-h-8 gap-2 px-3 py-1.5 text-sm',
};
export function Button({
children,
onClick,
disabled,
loading,
variant = 'default',
size = 'md',
icon,
title,
type = 'button',
className = '',
}: {
children?: ReactNode;
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
variant?: ButtonVariant;
size?: ButtonSize;
icon?: IconName;
title?: string;
type?: 'button' | 'submit';
className?: string;
}) {
return (
<button
type={type}
title={title}
onClick={onClick}
disabled={disabled || loading}
className={`inline-flex items-center justify-center rounded-sm border font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${BUTTON_VARIANTS[variant]} ${BUTTON_SIZES[size]} ${className}`}
>
{loading ? (
<Spinner16 className={size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5'} />
) : (
icon && <Icon name={icon} className={size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'} />
)}
{children}
</button>
);
}
export function IconButton({
icon,
label,
onClick,
disabled,
variant = 'ghost',
size = 'md',
}: {
icon: IconName;
/** Required: icon-only controls must still be announced. */
label: string;
onClick?: () => void;
disabled?: boolean;
variant?: ButtonVariant;
size?: ButtonSize;
}) {
return (
<button
type="button"
title={label}
aria-label={label}
onClick={onClick}
disabled={disabled}
className={`inline-flex items-center justify-center rounded-sm border transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${BUTTON_VARIANTS[variant]} ${size === 'sm' ? 'h-7 w-7' : 'h-8 w-8'}`}
>
<Icon name={icon} className={size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'} />
</button>
);
}
/* ------------------------------------------------------------------ inputs */
export function Field({
label,
hint,
error,
children,
}: {
label: string;
hint?: ReactNode;
error?: string | null;
children: ReactNode;
}) {
return (
<label className="block">
<span className="eyebrow">{label}</span>
<div className="mt-1.5">{children}</div>
{error ? (
<span className="mt-1 block text-xs text-danger-400">{error}</span>
) : (
hint && <span className="mt-1 block text-xs text-slate-dim">{hint}</span>
)}
</label>
);
}
export function SearchInput({
value,
onChange,
placeholder = 'Search…',
className = '',
autoFocus,
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
autoFocus?: boolean;
}) {
return (
<div className={`relative ${className}`}>
<Icon
name="search"
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-dim"
/>
<input
type="search"
value={value}
autoFocus={autoFocus}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="input pl-8"
/>
{value && (
<button
type="button"
aria-label="Clear search"
onClick={() => onChange('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-dim hover:text-zinc-200"
>
<Icon name="close" className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
export function Toggle({
checked,
onChange,
label,
disabled,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
label: string;
disabled?: boolean;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`inline-flex h-5 w-9 shrink-0 items-center rounded-full border transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
checked ? 'border-accent-500 bg-accent-600/50' : 'border-graphite-600 bg-graphite-800'
}`}
>
<span
className={`h-3.5 w-3.5 rounded-full bg-zinc-200 transition-transform ${
checked ? 'translate-x-[18px]' : 'translate-x-[3px]'
}`}
/>
</button>
);
}
export function SegmentedControl<T extends string>({
value,
options,
onChange,
size = 'md',
}: {
value: T;
options: { value: T; label: string; icon?: IconName; count?: number }[];
onChange: (value: T) => void;
size?: ButtonSize;
}) {
return (
<div
role="tablist"
className="inline-flex items-center gap-0.5 rounded-sm border border-graphite-700 bg-graphite-950 p-0.5"
>
{options.map((option) => {
const active = option.value === value;
return (
<button
key={option.value}
role="tab"
type="button"
aria-selected={active}
onClick={() => onChange(option.value)}
className={`inline-flex items-center gap-1.5 rounded-xs font-semibold transition-colors ${
size === 'sm' ? 'px-2 py-1 text-2xs' : 'px-2.5 py-1.5 text-xs'
} ${
active
? 'bg-graphite-700 text-zinc-100'
: 'text-slate-dim hover:bg-graphite-850 hover:text-zinc-200'
}`}
>
{option.icon && <Icon name={option.icon} className="h-3.5 w-3.5" />}
{option.label}
{option.count !== undefined && (
<span className="numeric text-2xs text-slate-dim">{option.count}</span>
)}
</button>
);
})}
</div>
);
}
/* ------------------------------------------------------------------ status */
const STATUS_STYLES: Record<ServerStatus, { dot: string; text: string; label: string }> = {
online: {
dot: 'bg-ok-400 shadow-[0_0_8px_var(--color-ok-400)]',
text: 'text-ok-400',
label: 'Online',
},
offline: { dot: 'bg-slate-faint', text: 'text-slate-dim', label: 'Offline' },
starting: { dot: 'bg-warn-400 animate-pulse', text: 'text-warn-400', label: 'Starting' },
stopping: { dot: 'bg-warn-400 animate-pulse', text: 'text-warn-400', label: 'Stopping' },
unknown: { dot: 'bg-graphite-500', text: 'text-slate-faint', label: 'Unknown' },
};
export function StatusBadge({ status, compact }: { status: ServerStatus; compact?: boolean }) {
const style = STATUS_STYLES[status] ?? STATUS_STYLES.unknown;
return (
<span
className={`inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border border-current/20 bg-current/5 px-2 py-0.5 text-2xs font-semibold uppercase tracking-wider ${style.text}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${style.dot}`} />
{!compact && style.label}
</span>
);
}
type BadgeTone = 'neutral' | 'accent' | 'ok' | 'warn' | 'danger' | 'info';
const BADGE_TONES: Record<BadgeTone, string> = {
neutral: 'border-graphite-600 bg-graphite-800 text-slate-ink',
accent: 'border-accent-600/50 bg-accent-600/12 text-accent-300',
ok: 'border-ok-400/40 bg-ok-400/10 text-ok-400',
warn: 'border-warn-400/40 bg-warn-400/10 text-warn-400',
danger: 'border-danger-400/40 bg-danger-400/10 text-danger-400',
info: 'border-info-400/40 bg-info-400/10 text-info-400',
};
export function Badge({
children,
tone = 'neutral',
icon,
title,
}: {
children: ReactNode;
tone?: BadgeTone;
icon?: IconName;
title?: string;
}) {
return (
<span
title={title}
className={`inline-flex items-center gap-1 whitespace-nowrap rounded-xs border px-1.5 py-0.5 text-2xs font-semibold ${BADGE_TONES[tone]}`}
>
{icon && <Icon name={icon} className="h-3 w-3" />}
{children}
</span>
);
}
const ROLE_TONES: Record<Role, BadgeTone> = {
owner: 'accent',
server_admin: 'info',
mission_lead: 'warn',
viewer: 'neutral',
};
export function RoleBadge({ role }: { role: Role }) {
return (
<Badge tone={ROLE_TONES[role]}>
<span className="uppercase tracking-wider">{ROLE_LABELS[role]}</span>
</Badge>
);
}
/* ------------------------------------------------------------- placeholders */
export function EmptyState({
title,
hint,
icon = 'info',
action,
}: {
title: string;
hint?: ReactNode;
icon?: IconName;
action?: ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-2 rounded-sm border border-dashed border-graphite-700 bg-graphite-950/50 px-4 py-10 text-center">
<Icon name={icon} className="h-5 w-5 text-slate-faint" />
<p className="text-sm font-medium text-zinc-300">{title}</p>
{hint && <p className="max-w-md text-xs leading-5 text-slate-dim">{hint}</p>}
{action}
</div>
);
}
export function Spinner({ label = 'Loading…' }: { label?: string }) {
return (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-slate-dim">
<Spinner16 className="h-4 w-4 text-accent-400" />
{label}
</div>
);
}
export function Skeleton({ className = 'h-4 w-full' }: { className?: string }) {
return <div className={`animate-pulse rounded-xs bg-graphite-800 ${className}`} />;
}
/** Image with a quiet placeholder when the URL is missing or fails to load. */
export function ModImage({ src, className = '' }: { src: string | null; className?: string }) {
const [failed, setFailed] = useState(false);
if (!src || failed) {
return (
<span
className={`flex shrink-0 items-center justify-center rounded-md border border-graphite-700 bg-graphite-800 text-slate-dim ${className}`}
className={`flex shrink-0 items-center justify-center rounded-xs border border-graphite-700 bg-graphite-800 text-slate-faint ${className}`}
>
<svg viewBox="0 0 24 24" fill="none" className="h-1/2 w-1/2" stroke="currentColor">
<rect x="3" y="4" width="18" height="16" rx="2" strokeWidth="1.5" />
<circle cx="9" cy="10" r="1.75" strokeWidth="1.5" />
<path d="M4 18l5-5 3 3 4-4 4 4" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
<Icon name="image" className="h-1/2 w-1/2" />
</span>
);
}
@@ -50,119 +425,303 @@ export function ModImage({ src, className = '' }: { src: string | null; classNam
alt=""
loading="lazy"
onError={() => setFailed(true)}
className={`shrink-0 rounded-md border border-graphite-700 object-cover ${className}`}
className={`shrink-0 rounded-xs border border-graphite-700 object-cover ${className}`}
/>
);
}
const STATUS_STYLES: Record<ServerStatus, { dot: string; text: string; label: string }> = {
online: {
dot: 'bg-emerald-400 shadow-[0_0_10px_rgba(52,211,153,0.75)]',
text: 'text-emerald-300',
label: 'Online',
},
offline: { dot: 'bg-zinc-500', text: 'text-zinc-400', label: 'Offline' },
starting: { dot: 'bg-warn-400 animate-pulse', text: 'text-warn-400', label: 'Starting' },
stopping: { dot: 'bg-warn-400 animate-pulse', text: 'text-warn-400', label: 'Stopping' },
unknown: { dot: 'bg-zinc-600', text: 'text-zinc-500', label: 'Unknown' },
};
/* ------------------------------------------------------------------ meters */
export function StatusBadge({ status }: { status: ServerStatus }) {
const style = STATUS_STYLES[status] ?? STATUS_STYLES.unknown;
return (
<span
className={`inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border border-current/20 bg-current/5 px-2.5 py-1 text-xs font-semibold ${style.text}`}
>
<span className={`h-2 w-2 rounded-full ${style.dot}`} />
{style.label}
</span>
);
}
const ROLE_STYLES: Record<Role, string> = {
owner: 'border-accent-500/40 bg-accent-500/10 text-accent-400',
server_admin: 'border-sky-500/40 bg-sky-500/10 text-sky-400',
mission_lead: 'border-warn-400/40 bg-warn-400/10 text-warn-400',
viewer: 'border-zinc-600 bg-zinc-800/60 text-zinc-400',
};
export function RoleBadge({ role }: { role: Role }) {
return (
<span
className={`inline-flex rounded border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${ROLE_STYLES[role]}`}
>
{ROLE_LABELS[role]}
</span>
);
}
export function EmptyState({ title, hint }: { title: string; hint?: string }) {
return (
<div className="flex flex-col items-center justify-center gap-1 rounded-md border border-dashed border-graphite-700 bg-graphite-950/35 px-4 py-8 text-center">
<p className="text-sm font-medium text-zinc-300">{title}</p>
{hint && <p className="text-xs text-slate-dim">{hint}</p>}
</div>
);
}
export function Spinner({ label = 'Loading…' }: { label?: string }) {
return (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-slate-dim">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-graphite-600 border-t-accent-500" />
{label}
</div>
);
}
export function StatBar({
export function ProgressBar({
value,
max,
warnAt = 0.8,
warnAt = 0.75,
dangerAt = 0.9,
className = '',
}: {
value: number;
max: number | null;
warnAt?: number;
dangerAt?: number;
className?: string;
}) {
if (!max || max <= 0) return null;
const ratio = Math.min(1, value / max);
const color = ratio >= warnAt ? 'bg-warn-400' : 'bg-accent-500';
const ratio = Math.min(1, Math.max(0, value / max));
const color =
ratio >= dangerAt ? 'bg-danger-400' : ratio >= warnAt ? 'bg-warn-400' : 'bg-accent-500';
return (
<div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-graphite-700">
<div className={`h-full rounded-full ${color}`} style={{ width: `${ratio * 100}%` }} />
<div className={`h-1 w-full overflow-hidden rounded-full bg-graphite-800 ${className}`}>
<div
className={`h-full rounded-full transition-all ${color}`}
style={{ width: `${ratio * 100}%` }}
/>
</div>
);
}
export function Button({
export function MetricTile({
label,
value,
unit,
detail,
children,
onClick,
disabled,
variant = 'default',
title,
type = 'button',
}: {
children: ReactNode;
onClick?: () => void;
disabled?: boolean;
variant?: 'default' | 'accent' | 'danger';
title?: string;
type?: 'button' | 'submit';
label: string;
value: ReactNode;
unit?: ReactNode;
detail?: ReactNode;
children?: ReactNode;
}) {
const variants = {
default:
'border-graphite-600 bg-graphite-800 text-zinc-300 hover:border-graphite-600 hover:bg-graphite-700',
accent: 'border-accent-600/60 bg-accent-600/15 text-accent-400 hover:bg-accent-600/25',
danger: 'border-danger-400/40 bg-danger-400/10 text-danger-400 hover:bg-danger-400/20',
} as const;
return (
<button
type={type}
<section className="panel-card p-4">
<p className="eyebrow">{label}</p>
<p className="numeric mt-1.5 text-2xl font-semibold leading-none text-zinc-50">
{value}
{unit && <span className="ml-1 text-sm font-normal text-slate-dim">{unit}</span>}
</p>
{detail && <div className="numeric mt-1 text-xs text-slate-dim">{detail}</div>}
{children && <div className="mt-3">{children}</div>}
</section>
);
}
/* ------------------------------------------------------------------ dialog */
export function Dialog({
open,
onClose,
title,
description,
children,
footer,
width = 'md',
}: {
open: boolean;
onClose: () => void;
title: string;
description?: ReactNode;
children: ReactNode;
footer?: ReactNode;
width?: 'sm' | 'md' | 'lg' | 'xl';
}) {
const panelRef = useRef<HTMLDivElement | null>(null);
const headingId = useId();
useEffect(() => {
if (!open) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const { overflow } = document.body.style;
document.body.style.overflow = 'hidden';
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopPropagation();
onClose();
return;
}
if (event.key !== 'Tab' || !panelRef.current) return;
// Keep focus inside the dialog.
const focusable = panelRef.current.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
if (focusable.length === 0) return;
const first = focusable[0]!;
const last = focusable[focusable.length - 1]!;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', onKeyDown, true);
panelRef.current?.querySelector<HTMLElement>('input, button')?.focus();
return () => {
document.removeEventListener('keydown', onKeyDown, true);
document.body.style.overflow = overflow;
previouslyFocused?.focus?.();
};
}, [open, onClose]);
if (!open) return null;
const widths = { sm: 'max-w-sm', md: 'max-w-lg', lg: 'max-w-3xl', xl: 'max-w-5xl' } as const;
return createPortal(
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-8">
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} aria-hidden />
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={headingId}
className={`animate-fade-in relative z-10 my-auto w-full ${widths[width]} rounded-md border border-graphite-700 bg-graphite-900 shadow-2xl shadow-black/60`}
>
<header className="flex items-start justify-between gap-4 border-b border-graphite-700 px-4 py-3">
<div className="min-w-0">
<h2 id={headingId} className="text-base font-semibold text-zinc-50">
{title}
</h2>
{description && <p className="mt-0.5 text-xs text-slate-dim">{description}</p>}
</div>
<IconButton icon="close" label="Close" onClick={onClose} />
</header>
<div className="max-h-[70vh] overflow-y-auto p-4">{children}</div>
{footer && (
<footer className="flex flex-wrap items-center justify-end gap-2 border-t border-graphite-700 px-4 py-3">
{footer}
</footer>
)}
</div>
</div>,
document.body,
);
}
export function ConfirmDialog({
open,
onClose,
onConfirm,
title,
body,
confirmLabel = 'Confirm',
variant = 'danger',
loading,
}: {
open: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
body: ReactNode;
confirmLabel?: string;
variant?: ButtonVariant;
loading?: boolean;
}) {
return (
<Dialog
open={open}
onClose={onClose}
title={title}
onClick={onClick}
disabled={disabled}
className={`inline-flex min-h-9 items-center justify-center rounded-md border px-3.5 py-2 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${variants[variant]}`}
width="sm"
footer={
<>
<Button onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button variant={variant} onClick={onConfirm} loading={loading}>
{confirmLabel}
</Button>
</>
}
>
<div className="text-sm leading-6 text-slate-ink">{body}</div>
</Dialog>
);
}
/* ------------------------------------------------------------------ toasts */
type Toast = { id: number; tone: BadgeTone; message: string };
const ToastContext = createContext<(message: string, tone?: BadgeTone) => void>(() => {});
/** `toast('Saved')` from anywhere below <ToastProvider>. */
export function useToast() {
return useContext(ToastContext);
}
const TOAST_TONES: Record<BadgeTone, string> = {
neutral: 'border-graphite-600 bg-graphite-800 text-zinc-100',
accent: 'border-accent-600 bg-graphite-800 text-accent-300',
ok: 'border-ok-400/50 bg-graphite-800 text-ok-400',
warn: 'border-warn-400/50 bg-graphite-800 text-warn-400',
danger: 'border-danger-400/50 bg-graphite-800 text-danger-400',
info: 'border-info-400/50 bg-graphite-800 text-info-400',
};
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const nextId = useRef(1);
const push = useCallback((message: string, tone: BadgeTone = 'neutral') => {
const id = nextId.current++;
setToasts((current) => [...current.slice(-3), { id, tone, message }]);
const timer = setTimeout(
() => setToasts((current) => current.filter((toast) => toast.id !== id)),
tone === 'danger' ? 8_000 : 4_500,
);
return () => clearTimeout(timer);
}, []);
const value = useMemo(() => push, [push]);
return (
<ToastContext.Provider value={value}>
{children}
</button>
<div
aria-live="polite"
className="pointer-events-none fixed bottom-4 right-4 z-[60] flex w-80 flex-col gap-2"
>
{toasts.map((toast) => (
<div
key={toast.id}
className={`animate-fade-in pointer-events-auto flex items-start gap-2 rounded-sm border px-3 py-2 text-xs leading-5 shadow-lg shadow-black/40 ${TOAST_TONES[toast.tone]}`}
>
<Icon
name={toast.tone === 'danger' ? 'alert' : toast.tone === 'ok' ? 'check' : 'info'}
className="mt-0.5 h-3.5 w-3.5"
/>
<span className="min-w-0 flex-1">{toast.message}</span>
<button
type="button"
aria-label="Dismiss"
onClick={() => setToasts((current) => current.filter((t) => t.id !== toast.id))}
className="text-current/60 hover:text-current"
>
<Icon name="close" className="h-3 w-3" />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
/* ------------------------------------------------------------------ notices */
export function Notice({
tone = 'info',
title,
children,
action,
}: {
tone?: 'info' | 'warn' | 'danger' | 'ok';
title?: string;
children: ReactNode;
action?: ReactNode;
}) {
const tones = {
info: 'border-info-400/35 bg-info-400/[0.07] text-info-400',
warn: 'border-warn-400/35 bg-warn-400/[0.07] text-warn-400',
danger: 'border-danger-400/35 bg-danger-400/[0.07] text-danger-400',
ok: 'border-ok-400/35 bg-ok-400/[0.07] text-ok-400',
} as const;
return (
<div
className={`flex flex-wrap items-start gap-3 rounded-sm border px-3 py-2.5 ${tones[tone]}`}
>
<Icon
name={tone === 'ok' ? 'check' : tone === 'info' ? 'info' : 'alert'}
className="mt-0.5 h-4 w-4"
/>
<div className="min-w-0 flex-1 text-xs leading-5">
{title && <p className="font-semibold">{title}</p>}
<div className="text-slate-ink">{children}</div>
</div>
{action}
</div>
);
}
+85 -84
View File
@@ -1,4 +1,3 @@
import { useState } from 'react';
import type {
ActivityItem,
Capability,
@@ -13,10 +12,10 @@ import {
useManualLogSync,
usePlayers,
usePowerAction,
useWorkshopHealth,
} from '../api/hooks.js';
import { formatDateTime, formatDuration, formatRelativeTime } from '../lib/format.js';
import { Button, Card, EmptyState, Spinner } from './ui.js';
import { Badge, Button, Card, EmptyState, Spinner, useToast } from './ui.js';
import { shortScenario } from './mission-card.js';
function can(user: CurrentUser, capability: Capability): boolean {
return user.capabilities.includes(capability);
@@ -24,14 +23,18 @@ function can(user: CurrentUser, capability: Capability): boolean {
export function PowerControls({ user, server }: { user: CurrentUser; server: ServerSummary }) {
const power = usePowerAction(server.slug);
const [message, setMessage] = useState<string | null>(null);
const toast = useToast();
const run = (action: 'start' | 'stop' | 'restart') => {
setMessage(null);
power.mutate(action, {
onSuccess: (result) =>
setMessage(result.simulated ? `${action} simulated (mock mode)` : `${action} requested`),
onError: (error) => setMessage(error.message),
toast(
result.simulated
? `${action} simulated (mock mode) — watch the Console`
: `${action} requested — watch the Console for live output`,
'ok',
),
onError: (error) => toast(error.message, 'danger'),
});
};
@@ -40,32 +43,37 @@ export function PowerControls({ user, server }: { user: CurrentUser; server: Ser
const canRestart = can(user, 'server.power.restart');
if (!canStart && !canStop && !canRestart) return null;
const busy = power.isPending || server.status === 'starting' || server.status === 'stopping';
return (
<div className="flex w-full flex-wrap items-center justify-end gap-2 md:w-auto">
<div className="flex flex-wrap items-center justify-end gap-1.5">
{canStart && (
<Button
size="sm"
variant="accent"
disabled={power.isPending || server.status === 'online'}
icon="play"
disabled={busy || server.status === 'online'}
onClick={() => run('start')}
>
Start
</Button>
)}
{canRestart && (
<Button disabled={power.isPending} onClick={() => run('restart')}>
<Button size="sm" icon="restart" disabled={busy} onClick={() => run('restart')}>
Restart
</Button>
)}
{canStop && (
<Button
size="sm"
variant="danger"
disabled={power.isPending || server.status === 'offline'}
icon="stop"
disabled={busy || server.status === 'offline'}
onClick={() => run('stop')}
>
Stop
</Button>
)}
{message && <span className="text-xs text-slate-dim">{message}</span>}
</div>
);
}
@@ -83,11 +91,11 @@ export function CurrentPlayersCard({
title="Current players"
action={
data && (
<span className="text-xs text-slate-dim">
<span className="text-2xs text-slate-dim">
{data.stale ? (
<span className="text-warn-400">data may be stale</span>
) : (
<>last synchronized {formatRelativeTime(data.lastSyncedAt)}</>
<>synced {formatRelativeTime(data.lastSyncedAt)}</>
)}
</span>
)
@@ -111,14 +119,15 @@ function PlayersTable({
}) {
return (
<div>
<p className="mb-4 text-3xl font-semibold text-zinc-100">
<p className="numeric mb-4 text-3xl font-semibold leading-none text-zinc-50">
{players.onlineCount}
<span className="text-base font-normal text-slate-dim"> / {maxPlayers ?? '—'} online</span>
<span className="text-sm font-normal text-slate-dim"> / {maxPlayers ?? '—'} online</span>
</p>
{players.players.length === 0 ? (
<EmptyState
icon="users"
title="No players connected"
hint="Player presence is reconstructed from server logs and updates on each sync."
hint="Player presence is reconstructed from the server log and updates on each sync."
/>
) : (
<div className="data-table-scroll">
@@ -133,9 +142,9 @@ function PlayersTable({
<tbody>
{players.players.map((player) => (
<tr key={player.playerId}>
<td className="py-2 font-medium text-zinc-200">{player.displayName}</td>
<td className="py-2 text-slate-ink">{formatDateTime(player.connectedAt)}</td>
<td className="py-2 text-right font-mono text-xs text-accent-400">
<td className="font-medium text-zinc-100">{player.displayName}</td>
<td className="numeric text-slate-ink">{formatDateTime(player.connectedAt)}</td>
<td className="numeric text-right text-xs text-accent-400">
{formatDuration(player.sessionDurationSeconds)}
</td>
</tr>
@@ -149,9 +158,9 @@ function PlayersTable({
}
const ACTIVITY_COLORS: Record<string, string> = {
player_connected: 'text-accent-400',
player_connected: 'text-ok-400',
player_disconnected: 'text-slate-ink',
server_started: 'text-accent-400',
server_started: 'text-ok-400',
server_stopped: 'text-warn-400',
server_restart_detected: 'text-warn-400',
log_sync_failed: 'text-danger-400',
@@ -173,28 +182,31 @@ export function ActivityList({
}) {
if (items.length === 0) {
return (
<EmptyState title="No activity yet" hint="Panel actions and server events appear here." />
<EmptyState
icon="pulse"
title="No activity yet"
hint="Panel actions and server events appear here."
/>
);
}
return (
<div
className="overflow-y-auto rounded-md border border-graphite-800 bg-graphite-950/70 font-mono text-xs shadow-inner"
style={{ maxHeight }}
>
<div className="console-surface overflow-y-auto" style={{ maxHeight }}>
<ul>
{items.map((item) => (
<li
key={item.id}
className="flex items-baseline gap-3 border-b border-graphite-800/60 px-3 py-1.5 last:border-0 hover:bg-graphite-850/80"
className="flex items-baseline gap-3 border-b border-graphite-800/60 px-3 py-1.5 last:border-0 hover:bg-graphite-900/70"
title={new Date(item.occurredAt).toLocaleString()}
>
<span className="shrink-0 text-slate-dim">{logTimestamp(item.occurredAt)}</span>
<span className="numeric shrink-0 text-slate-faint">
{logTimestamp(item.occurredAt)}
</span>
<span
className={`min-w-0 flex-1 truncate ${ACTIVITY_COLORS[item.action] ?? 'text-zinc-300'}`}
>
{item.summary}
</span>
<span className="shrink-0 text-[10px] uppercase tracking-wider text-slate-dim">
<span className="shrink-0 text-2xs uppercase tracking-wider text-slate-faint">
{item.kind === 'panel_action' ? 'panel' : 'server'}
</span>
</li>
@@ -213,33 +225,25 @@ export function RecentActivityCard({ slug, limit = 50 }: { slug: string; limit?:
);
}
/** Display form of a scenario id: just the file name, e.g. "23_Campaign.conf". */
export function shortScenario(scenarioId: string): string {
const slash = scenarioId.lastIndexOf('/');
return slash >= 0 ? scenarioId.slice(slash + 1) : scenarioId;
}
export function ConfigSummaryRows({ config }: { config: ConfigurationResponse }) {
const c = config.config;
const rows: [string, string][] = [
['Mission', shortScenario(c.scenarioId)],
const rows: [string, string, string?][] = [
['Mission', shortScenario(c.scenarioId), c.scenarioId],
['Max players', String(c.maxPlayers)],
// Reforger uses -1 for "no AI limit".
['AI limit', c.aiLimit < 0 ? 'Unlimited' : String(c.aiLimit)],
['View distance', `${c.serverMaxViewDistance} m (network ${c.networkViewDistance} m)`],
['View distance', `${c.serverMaxViewDistance} m`],
['Network view distance', `${c.networkViewDistance} m`],
['Third person', c.disableThirdPerson ? 'Disabled' : 'Allowed'],
['Cross-platform', c.crossPlatform ? 'Enabled' : 'Disabled'],
['Mods', `${c.mods.length}`],
['Mods', String(c.mods.length)],
];
return (
<dl className="space-y-2">
{rows.map(([label, value]) => (
<dl className="space-y-1.5">
{rows.map(([label, value, title]) => (
<div key={label} className="flex items-baseline justify-between gap-4">
<dt className="shrink-0 text-xs uppercase tracking-wider text-slate-dim">{label}</dt>
<dd
className="truncate text-right font-mono text-xs text-zinc-300"
title={label === 'Mission' ? c.scenarioId : value}
>
<dt className="eyebrow shrink-0">{label}</dt>
<dd className="numeric truncate text-right text-xs text-zinc-200" title={title ?? value}>
{value}
</dd>
</div>
@@ -248,86 +252,84 @@ export function ConfigSummaryRows({ config }: { config: ConfigurationResponse })
);
}
/**
* Log-ingestion diagnostics. The reforgermods.net probe that used to sit here
* was removed: it polled every minute, told nobody anything actionable, and
* the Workshop cache degrades gracefully on its own.
*/
export function OpsHealthCard({ user, slug }: { user: CurrentUser; slug: string }) {
const visible = can(user, 'ops.health.view');
const { data: workshop } = useWorkshopHealth();
const { data: logs } = useLogHealth(slug, visible);
const syncNow = useManualLogSync(slug);
const [syncMessage, setSyncMessage] = useState<string | null>(null);
const toast = useToast();
if (!visible) return null;
return (
<Card
title="Operational health"
title="Log ingestion"
action={
can(user, 'logs.sync') && (
<Button
disabled={syncNow.isPending || logs?.configured === false}
size="sm"
icon="refresh"
loading={syncNow.isPending}
disabled={logs?.configured === false}
onClick={() =>
syncNow.mutate(undefined, {
onSuccess: (result) =>
setSyncMessage(
`Synced: ${result.processedLines} lines, ${result.createdEvents} new events`,
toast(
`Synced ${result.processedLines} lines, ${result.createdEvents} new events`,
'ok',
),
onError: (error) => setSyncMessage(error.message),
onError: (error) => toast(error.message, 'danger'),
})
}
>
{syncNow.isPending ? 'Syncing…' : 'Sync logs now'}
Sync now
</Button>
)
}
>
<dl className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<dt className="text-slate-ink">Workshop API</dt>
<dd>
{workshop ? (
workshop.ok ? (
<span className="text-accent-400">
healthy · {workshop.latencyMs} ms · {formatRelativeTime(workshop.checkedAt)}
</span>
) : (
<span className="text-danger-400" title={workshop.message ?? undefined}>
unreachable
</span>
)
) : (
<span className="text-slate-dim">checking</span>
)}
</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-slate-ink">Log ingestion</dt>
<div className="flex items-center justify-between gap-4">
<dt className="text-slate-ink">Status</dt>
<dd>
{!logs ? (
<span className="text-slate-dim">checking</span>
) : !logs.configured ? (
<span className="text-slate-dim">not configured</span>
<Badge>not configured</Badge>
) : logs.stale ? (
<span className="text-warn-400">stale</span>
<Badge tone="warn">stale</Badge>
) : (
<span className="text-accent-400">healthy</span>
<Badge tone="ok">healthy</Badge>
)}
</dd>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-4">
<dt className="text-slate-ink">Last successful sync</dt>
<dd className="text-zinc-300">
<dd className="numeric text-xs text-zinc-200">
{formatRelativeTime(logs?.lastSuccessfulSyncAt ?? null)}
</dd>
</div>
{logs?.lastSync && (
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-4">
<dt className="text-slate-ink">Last sync processed</dt>
<dd className="font-mono text-xs text-zinc-300">
<dd className="numeric text-xs text-zinc-200">
{logs.lastSync.processedLines} lines · {logs.lastSync.createdEvents} events
</dd>
</div>
)}
{logs?.logPath && (
<div className="flex items-center justify-between gap-4">
<dt className="text-slate-ink">Log file</dt>
<dd className="truncate font-mono text-2xs text-slate-dim" title={logs.logPath}>
{logs.logPath}
</dd>
</div>
)}
{logs?.lastErrorMessage && (
<div className="flex items-center justify-between gap-4">
<dt className="shrink-0 text-slate-ink">Last sync error</dt>
<dt className="shrink-0 text-slate-ink">Last error</dt>
<dd
className="truncate text-xs text-danger-400"
title={`${formatRelativeTime(logs.lastErrorAt)}: ${logs.lastErrorMessage}`}
@@ -336,7 +338,6 @@ export function OpsHealthCard({ user, slug }: { user: CurrentUser; slug: string
</dd>
</div>
)}
{syncMessage && <p className="text-xs text-slate-dim">{syncMessage}</p>}
</dl>
</Card>
);
+218 -112
View File
@@ -1,124 +1,230 @@
@import 'tailwindcss';
/*
* Industrial graphite system.
*
* Three ideas hold it together: a narrow neutral ramp so nothing shouts, a
* small set of semantic signal colours reserved for state (never decoration),
* and tight geometry 4px rhythm, small radii, hairline rules so dense
* operational data reads as instrumentation rather than as a marketing page.
*/
@theme {
--color-graphite-950: #12161b;
--color-graphite-900: #191e24;
--color-graphite-850: #20262e;
--color-graphite-800: #29313a;
--color-graphite-700: #3a4552;
--color-graphite-600: #505c69;
--color-slate-ink: #b1bac4;
--color-slate-dim: #838e9a;
--color-accent-500: #6f8fab;
--color-accent-400: #9bb4ca;
--color-accent-600: #58758e;
--color-warn-400: #d2a85b;
--color-danger-400: #d37a70;
/* Surfaces, darkest (page) to lightest (raised). */
--color-graphite-950: #0e1116;
--color-graphite-900: #141920;
--color-graphite-850: #1a2028;
--color-graphite-800: #212832;
--color-graphite-700: #2b3441;
--color-graphite-600: #3b4655;
--color-graphite-500: #4d5a6b;
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
/* Ink ramp. */
--color-slate-ink: #aab6c3;
--color-slate-dim: #78838f;
--color-slate-faint: #5a636f;
/* Accent — used for interactive affordances and the primary series. */
--color-accent-700: #3f5a75;
--color-accent-600: #4d6f8f;
--color-accent-500: #6e93b5;
--color-accent-400: #9dbcd8;
--color-accent-300: #c2d7ea;
/* Signals. Reserved for state; never used to decorate. */
--color-ok-400: #5fbf8f;
--color-warn-400: #d6a548;
--color-danger-400: #d9695f;
--color-info-400: #7fb2e5;
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
/* Small, hard radii. */
--radius-xs: 2px;
--radius-sm: 3px;
--radius-md: 5px;
--radius-lg: 8px;
/* Type scale, tuned for dense readouts. */
--text-2xs: 0.6875rem;
--text-2xs--line-height: 1rem;
--text-xs: 0.75rem;
--text-xs--line-height: 1.125rem;
--text-sm: 0.8125rem;
--text-sm--line-height: 1.25rem;
--text-base: 0.875rem;
--text-base--line-height: 1.375rem;
--text-lg: 1rem;
--text-lg--line-height: 1.5rem;
--text-xl: 1.25rem;
--text-xl--line-height: 1.75rem;
--text-2xl: 1.5rem;
--text-2xl--line-height: 1.875rem;
--text-3xl: 1.875rem;
--text-3xl--line-height: 2.125rem;
}
body {
@apply bg-graphite-950 text-zinc-200 antialiased;
background: var(--color-graphite-950);
@layer base {
html {
color-scheme: dark;
}
body {
@apply bg-graphite-950 text-zinc-200 antialiased;
}
button,
a,
input,
select,
textarea {
@apply outline-none;
}
:focus-visible {
@apply ring-2 ring-accent-500/50 ring-offset-2 ring-offset-graphite-950;
}
::selection {
background: color-mix(in srgb, var(--color-accent-500) 32%, transparent);
}
/* Quiet, thin scrollbars — the panel is full of scrolling regions. */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-graphite-600) transparent;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--color-graphite-600);
border: 3px solid transparent;
background-clip: content-box;
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-graphite-500);
background-clip: content-box;
}
}
button,
a,
input,
select,
textarea {
@apply outline-none;
@layer components {
/* Numbers that update in place must not reflow their neighbours. */
.numeric {
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum';
}
.panel-card {
/* min-w-0 lets cards shrink inside grid tracks instead of widening them. */
@apply min-w-0 rounded-md border border-graphite-700 bg-graphite-900;
}
.panel-card-header {
@apply flex flex-wrap items-center justify-between gap-3 border-b border-graphite-700 px-4 py-3;
}
.panel-card-title {
@apply text-2xs font-semibold uppercase tracking-[0.16em] text-slate-dim;
}
.page-title {
@apply text-2xl font-semibold tracking-tight text-zinc-50;
}
.page-kicker {
@apply mt-1 max-w-2xl text-sm leading-6 text-slate-ink;
}
/* Section label used above grouped controls and inside dense lists. */
.eyebrow {
@apply text-2xs font-semibold uppercase tracking-[0.16em] text-slate-dim;
}
.input {
@apply w-full rounded-sm border border-graphite-600 bg-graphite-950 px-2.5 py-1.5 text-sm text-zinc-100 transition-colors;
@apply placeholder:text-slate-faint hover:border-graphite-500 focus:border-accent-500;
@apply disabled:cursor-not-allowed disabled:opacity-45;
}
.input-error {
@apply border-danger-400/70 focus:border-danger-400;
}
/* No native number spinners — they clash with the theme. */
input[type='number'].input {
appearance: textfield;
-moz-appearance: textfield;
}
input[type='number'].input::-webkit-inner-spin-button,
input[type='number'].input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Selects: replace the native chrome with a themed chevron. */
select.input {
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2378838f' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.6rem center;
padding-right: 1.9rem;
}
select.input option {
@apply bg-graphite-850 text-zinc-100;
}
.data-table-scroll {
@apply overflow-x-auto;
}
.data-table {
@apply w-full min-w-fit text-sm;
}
.data-table th,
.data-table td {
@apply whitespace-nowrap pr-4 last:pr-0;
}
.data-table thead tr {
@apply border-b border-graphite-700 text-left text-2xs uppercase tracking-[0.12em] text-slate-dim;
}
.data-table th {
@apply pb-2 font-semibold;
}
.data-table tbody tr {
@apply border-b border-graphite-800 last:border-0 hover:bg-graphite-850/60;
}
.data-table td {
@apply py-2;
}
/* Terminal surface shared by the console and the activity feed. */
.console-surface {
@apply rounded-sm border border-graphite-800 bg-[#0a0d11] font-mono text-xs;
}
}
:focus-visible {
@apply ring-2 ring-accent-500/45 ring-offset-2 ring-offset-graphite-950;
}
@layer utilities {
@keyframes rp-fade-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: none;
}
}
::selection {
background: color-mix(in srgb, var(--color-accent-500) 35%, transparent);
}
.panel-card {
/* min-w-0 lets cards shrink inside grid tracks instead of widening them. */
@apply min-w-0 rounded-lg border border-graphite-700/70 bg-graphite-900 shadow-sm shadow-black/20;
}
.panel-card-header {
@apply flex flex-wrap items-center justify-between gap-3 border-b border-graphite-700/60 px-5 py-4;
}
.panel-card-title {
@apply text-xs font-semibold uppercase tracking-[0.14em] text-slate-ink;
}
.page-title {
@apply text-2xl font-semibold text-zinc-100;
letter-spacing: 0;
}
.page-kicker {
@apply mt-1 max-w-2xl text-sm leading-6 text-slate-ink;
}
.input {
@apply rounded-md border border-graphite-600 bg-graphite-950/55 px-3 py-2 text-sm text-zinc-200 shadow-sm transition-colors placeholder:text-slate-dim hover:border-slate-dim/70 focus:border-accent-500 disabled:cursor-not-allowed disabled:opacity-50;
}
.input-error {
@apply border-danger-400/70 focus:border-danger-400 focus:ring-danger-400/30;
}
/* No native number spinners — they clash with the theme. */
input[type='number'].input {
appearance: textfield;
-moz-appearance: textfield;
}
input[type='number'].input::-webkit-inner-spin-button,
input[type='number'].input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Selects: replace the native chrome with a themed chevron. */
select.input {
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b98a5' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.65rem center;
padding-right: 2rem;
}
select.input option {
@apply bg-graphite-850 text-zinc-200;
}
.data-table-scroll {
@apply overflow-x-auto;
}
.data-table {
@apply w-full min-w-fit text-sm;
}
.data-table th,
.data-table td {
@apply whitespace-nowrap pr-4 last:pr-0;
}
.data-table thead tr {
@apply border-b border-graphite-700/60 text-left text-[11px] uppercase tracking-wider text-slate-dim;
}
.data-table th {
@apply pb-2 font-medium;
}
.data-table tbody tr {
@apply border-b border-graphite-800/80 last:border-0 hover:bg-graphite-850/50;
}
.data-table td {
@apply py-2.5;
.animate-fade-in {
animation: rp-fade-in 120ms ease-out;
}
}
+84
View File
@@ -0,0 +1,84 @@
import { useState } from 'react';
import type { CurrentUser } from '@reforger-panel/shared';
import { useConfiguration, usePrimaryServer } from '../api/hooks.js';
import { formatRelativeTime } from '../lib/format.js';
import { Card, EmptyState, PageHeader, SegmentedControl, Spinner } from '../components/ui.js';
import { ConfigKeyEditor } from '../components/config/key-editor.js';
import { ConfigRawEditor } from '../components/config/raw-editor.js';
import { PerformanceForm } from '../components/performance-form.js';
import { StartupVarsCard } from '../components/startup-vars-card.js';
import { SchedulesCard } from '../components/schedules-card.js';
import { ConfigSummaryRows } from '../components/widgets.js';
type Tab = 'settings' | 'keys' | 'raw' | 'startup' | 'schedules';
export function ConfigurationPage({ user }: { user: CurrentUser }) {
const server = usePrimaryServer();
if (!server) return <Spinner />;
return <ConfigurationBody slug={server.slug} user={user} />;
}
function ConfigurationBody({ slug, user }: { slug: string; user: CurrentUser }) {
const canEdit = user.capabilities.includes('config.edit');
const [tab, setTab] = useState<Tab>('settings');
const { data: config } = useConfiguration(slug);
return (
<div className="w-full space-y-4">
<PageHeader
title="Configuration"
kicker={
<>
Edits are written straight to the server&rsquo;s config.json, verified by reading it
back, and rejected if the file changed since this page loaded.
{config && ` Read ${formatRelativeTime(config.fetchedAt)}.`}
</>
}
/>
<SegmentedControl<Tab>
value={tab}
onChange={setTab}
options={[
{ value: 'settings', label: 'Settings', icon: 'sliders' },
{ value: 'keys', label: 'All keys', icon: 'search' },
{ value: 'raw', label: 'Raw JSON', icon: 'terminal' },
{ value: 'startup', label: 'Startup variables', icon: 'server' },
{ value: 'schedules', label: 'Restarts', icon: 'restart' },
]}
/>
{tab === 'settings' && (
<div className="grid gap-4 lg:grid-cols-3">
<Card title="Performance settings" className="lg:col-span-2">
<PerformanceForm slug={slug} canEdit={canEdit} />
</Card>
<Card title="Live summary">
{config ? <ConfigSummaryRows config={config} /> : <Spinner />}
</Card>
</div>
)}
{tab === 'keys' && (
<Card title="Every key in config.json">
<ConfigKeyEditor slug={slug} canEdit={canEdit} />
</Card>
)}
{tab === 'raw' && (
<Card title="config.json">
<ConfigRawEditor slug={slug} canEdit={canEdit} />
</Card>
)}
{tab === 'startup' &&
(canEdit ? (
<StartupVarsCard slug={slug} />
) : (
<EmptyState icon="lock" title="Startup variables are restricted to admins" />
))}
{tab === 'schedules' && <SchedulesCard slug={slug} canEdit={canEdit} />}
</div>
);
}
+226
View File
@@ -0,0 +1,226 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ConsoleLine } from '@reforger-panel/shared';
import { useConsoleFeed, usePrimaryServer, useRawLogs } from '../api/hooks.js';
import { formatBytes, formatRelativeTime } from '../lib/format.js';
import {
Badge,
Button,
Card,
EmptyState,
IconButton,
PageHeader,
SearchInput,
SegmentedControl,
Spinner,
StatusBadge,
Toggle,
useToast,
} from '../components/ui.js';
type Source = 'live' | 'file';
/** Colour by severity, inferred from the line itself — Wings sends no level. */
function lineTone(line: ConsoleLine): string {
if (line.stream === 'install') return 'text-info-400';
if (line.stream === 'daemon') return 'text-accent-400';
const text = line.text;
if (/\b(ERROR|FATAL|Failed|failure|exception)\b/i.test(text)) return 'text-danger-400';
if (/\bWARN(ING)?\b/i.test(text)) return 'text-warn-400';
if (/\b(Success|ready to accept|successfully)\b/i.test(text)) return 'text-ok-400';
return 'text-zinc-300';
}
function timestamp(at: number): string {
const date = new Date(at);
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
export function ConsolePage() {
const server = usePrimaryServer();
if (!server) return <Spinner />;
return <ConsoleBody slug={server.slug} />;
}
function ConsoleBody({ slug }: { slug: string }) {
const toast = useToast();
const [source, setSource] = useState<Source>('live');
const [follow, setFollow] = useState(true);
const [filter, setFilter] = useState('');
const [fileLines, setFileLines] = useState(300);
const viewportRef = useRef<HTMLDivElement | null>(null);
const feed = useConsoleFeed(slug, source === 'live');
const file = useRawLogs(slug, fileLines, source === 'file');
const visible = useMemo(() => {
if (source === 'file') {
const lines = file.data?.lines ?? [];
return lines
.filter((text) => !filter || text.toLowerCase().includes(filter.toLowerCase()))
.map((text, index): ConsoleLine => ({ seq: index, stream: 'console', text, at: 0 }));
}
if (!filter) return feed.lines;
const needle = filter.toLowerCase();
return feed.lines.filter((line) => line.text.toLowerCase().includes(needle));
}, [source, feed.lines, file.data?.lines, filter]);
useEffect(() => {
if (!follow || !viewportRef.current) return;
viewportRef.current.scrollTop = viewportRef.current.scrollHeight;
}, [visible, follow]);
const copyAll = async () => {
try {
await navigator.clipboard.writeText(visible.map((line) => line.text).join('\n'));
toast(`Copied ${visible.length} lines`, 'ok');
} catch {
toast('Clipboard is not available in this browser', 'danger');
}
};
return (
<div className="w-full space-y-4">
<PageHeader
title="Console"
kicker={
source === 'live'
? 'Streamed live from Pterodactyl — installs, updates and mod downloads included, not just what the game writes to its own log.'
: "The game's own console.log, downloaded from the server."
}
actions={
<SegmentedControl<Source>
value={source}
onChange={setSource}
options={[
{ value: 'live', label: 'Live', icon: 'terminal' },
{ value: 'file', label: 'Game log', icon: 'download' },
]}
/>
}
/>
<Card
padded={false}
title={source === 'live' ? 'Pterodactyl live output' : (file.data?.path ?? 'console.log')}
action={
<div className="flex flex-wrap items-center justify-end gap-2">
{source === 'live' ? (
<>
<StatusBadge status={feed.status} />
<Badge tone={feed.connected ? 'ok' : 'warn'}>
{feed.connected ? 'connected' : 'reconnecting'}
</Badge>
<span className="numeric text-2xs text-slate-dim">{feed.lines.length} lines</span>
</>
) : (
<>
{file.data && (
<span className="text-2xs text-slate-dim">
fetched {formatRelativeTime(file.data.fetchedAt)}
</span>
)}
<select
value={fileLines}
onChange={(event) => setFileLines(Number(event.target.value))}
className="input w-auto py-1 text-xs"
>
{[100, 300, 600, 1000].map((n) => (
<option key={n} value={n}>
last {n} lines
</option>
))}
</select>
<IconButton icon="refresh" label="Reload" onClick={() => void file.refetch()} />
</>
)}
<IconButton icon="copy" label="Copy visible lines" onClick={() => void copyAll()} />
</div>
}
>
<div className="flex flex-wrap items-center gap-3 border-b border-graphite-700 px-4 py-2.5">
<SearchInput
value={filter}
onChange={setFilter}
placeholder="Filter lines…"
className="w-full sm:w-72"
/>
<label className="flex items-center gap-2 text-xs text-slate-ink">
<Toggle checked={follow} onChange={setFollow} label="Follow output" />
Follow
</label>
{source === 'live' && feed.lines.length > 0 && (
<Button size="sm" variant="ghost" icon="trash" onClick={feed.clear}>
Clear view
</Button>
)}
{filter && (
<span className="numeric text-2xs text-slate-dim">{visible.length} matching</span>
)}
</div>
<div
ref={viewportRef}
onWheel={() => setFollow(false)}
className="console-surface h-[calc(100vh-22rem)] min-h-80 overflow-auto rounded-none border-0"
>
{source === 'file' && file.isLoading ? (
<Spinner label="Downloading console.log…" />
) : visible.length === 0 ? (
<div className="p-6">
<EmptyState
icon="terminal"
title={filter ? 'No lines match that filter' : 'Waiting for output'}
hint={
filter
? undefined
: source === 'live'
? 'Output appears the moment the server does anything — press Start and watch the install and mod download run.'
: 'The game writes this file once it has started.'
}
/>
</div>
) : (
<ol>
{visible.map((line) => (
<li
key={`${line.seq}-${line.at}`}
className="flex items-baseline gap-3 px-3 py-px hover:bg-graphite-900/60"
>
{line.at > 0 && (
<span className="numeric shrink-0 select-none text-slate-faint">
{timestamp(line.at)}
</span>
)}
{line.stream !== 'console' && (
<span className="shrink-0 select-none text-2xs uppercase text-slate-faint">
{line.stream}
</span>
)}
<span
className={`min-w-0 flex-1 whitespace-pre-wrap break-all ${lineTone(line)}`}
>
{line.text}
</span>
</li>
))}
</ol>
)}
</div>
{source === 'live' && feed.stats && (
<div className="flex flex-wrap items-center gap-x-6 gap-y-1 border-t border-graphite-700 px-4 py-2 text-2xs text-slate-dim">
<span className="numeric">CPU {feed.stats.cpuPercent.toFixed(1)}%</span>
<span className="numeric">MEM {formatBytes(feed.stats.memoryBytes)}</span>
<span className="numeric">DISK {formatBytes(feed.stats.diskBytes)}</span>
<span className="numeric">
NET {formatBytes(feed.stats.networkRxBytes)} in /{' '}
{formatBytes(feed.stats.networkTxBytes)} out
</span>
<span>source: {feed.stats.source}</span>
</div>
)}
</Card>
</div>
);
}
-127
View File
@@ -1,127 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useConsoleStream, useRawLogs, useServers } from '../api/hooks.js';
import { formatRelativeTime } from '../lib/format.js';
import { Button, Card, Spinner } from '../components/ui.js';
const MAX_STREAM_LINES = 1000;
export function LogsPage() {
const { data: serversData } = useServers();
const slug = serversData?.servers[0]?.slug;
const [mode, setMode] = useState<'stream' | 'poll'>('stream');
const [lines, setLines] = useState(300);
const [follow, setFollow] = useState(true);
const [streamLines, setStreamLines] = useState<string[]>([]);
const viewportRef = useRef<HTMLPreElement | null>(null);
const onLine = useCallback((line: string) => {
setStreamLines((prev) => {
const next = [...prev, line];
return next.length > MAX_STREAM_LINES ? next.slice(next.length - MAX_STREAM_LINES) : next;
});
}, []);
useConsoleStream(slug ?? '', onLine, mode === 'stream' && slug !== undefined);
// Polling fallback
const { data: pollData, isLoading: pollLoading, error: pollError, refetch, isFetching } =
useRawLogs(slug ?? '', lines, mode === 'poll', mode === 'poll' && slug !== undefined);
useEffect(() => {
if (follow && viewportRef.current) {
viewportRef.current.scrollTop = viewportRef.current.scrollHeight;
}
}, [streamLines, pollData, follow]);
if (!slug) return <Spinner />;
const title = mode === 'stream' ? (streamLines.length > 0 ? 'Live log' : 'console.log') : (pollData ? pollData.path : 'console.log');
return (
<div className="w-full space-y-5">
<h1 className="page-title">Logs</h1>
<Card
title={title}
action={
<div className="flex flex-wrap items-center justify-end gap-2">
{mode === 'poll' && pollData && (
<span className="text-xs text-slate-dim">
fetched {formatRelativeTime(pollData.fetchedAt)}
</span>
)}
{mode === 'stream' && streamLines.length > 0 && (
<span className="text-xs text-slate-dim">
{streamLines.length} lines
</span>
)}
{mode === 'poll' && (
<select
value={lines}
onChange={(event) => setLines(Number(event.target.value))}
className="input py-1.5"
>
{[100, 300, 600, 1000].map((n) => (
<option key={n} value={n}>
last {n} lines
</option>
))}
</select>
)}
<Button
variant={mode === 'stream' ? 'accent' : 'default'}
onClick={() => {
setStreamLines([]);
setMode((m) => (m === 'stream' ? 'poll' : 'stream'));
}}
title="Toggle between live SSE stream and 10s polling"
>
{mode === 'stream' ? 'Live' : 'Polling'}
</Button>
<Button
variant={follow ? 'accent' : 'default'}
onClick={() => setFollow((v) => !v)}
title="Keep scrolled to the newest lines"
>
{follow ? 'Follow' : 'Free scroll'}
</Button>
{mode === 'poll' && (
<Button disabled={isFetching} onClick={() => void refetch()}>
{isFetching ? '…' : 'Refresh'}
</Button>
)}
</div>
}
>
{mode === 'stream' ? (
streamLines.length === 0 ? (
<Spinner label="Connecting to console…" />
) : (
<pre
ref={viewportRef}
className="max-h-[65vh] overflow-auto whitespace-pre rounded-md border border-graphite-800 bg-graphite-950 p-4 font-mono text-xs leading-relaxed text-zinc-300"
>
{streamLines.join('\n')}
</pre>
)
) : pollLoading ? (
<Spinner label="Downloading log…" />
) : pollError ? (
<p className="text-sm text-danger-400">{pollError.message}</p>
) : (
<pre
ref={viewportRef}
className="max-h-[65vh] overflow-auto whitespace-pre rounded-md border border-graphite-800 bg-graphite-950 p-4 font-mono text-xs leading-relaxed text-zinc-300"
>
{pollData?.lines.join('\n')}
</pre>
)}
<p className="mt-3 text-xs text-slate-dim">
{mode === 'stream'
? 'Live log tail streamed via SSE (polls every 2 s). Switch to polling for manual refresh.'
: 'Read-only tail of the current Reforger console log, downloaded through the Pterodactyl API.'}
{' '}Visible to owner and server admins only.
</p>
</Card>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { CurrentUser } from '@reforger-panel/shared';
import { usePrimaryServer } from '../api/hooks.js';
import { PageHeader, Spinner } from '../components/ui.js';
import { MissionCard } from '../components/mission-card.js';
export function MissionPage({ user }: { user: CurrentUser }) {
const server = usePrimaryServer();
if (!server) return <Spinner />;
return (
<div className="w-full space-y-4">
<PageHeader
title="Mission"
kicker="Vanilla scenarios plus everything the installed mods ship. Switching writes game.scenarioId and takes effect on the next restart."
/>
<MissionCard slug={server.slug} canEdit={user.capabilities.includes('config.edit')} />
</div>
);
}
+310 -1510
View File
File diff suppressed because it is too large. Load diff
+103 -107
View File
@@ -2,12 +2,13 @@ import { Link } from 'react-router-dom';
import type { CurrentUser, ResourceSample } from '@reforger-panel/shared';
import {
useConfiguration,
useModsOverview,
usePrimaryServer,
useResourceHistory,
useServerResources,
useServers,
} from '../api/hooks.js';
import { formatBytes, formatDuration } from '../lib/format.js';
import { Card, Spinner } from '../components/ui.js';
import { Badge, Card, EmptyState, MetricTile, ProgressBar, Spinner } from '../components/ui.js';
import { TimeSeriesChart } from '../components/charts.js';
import {
ConfigSummaryRows,
@@ -17,18 +18,18 @@ import {
} from '../components/widgets.js';
export function OverviewPage({ user }: { user: CurrentUser }) {
const { data: serversData, isLoading } = useServers();
const server = serversData?.servers[0];
const server = usePrimaryServer();
const { isLoading } = useConfiguration(server?.slug ?? '');
if (isLoading) return <Spinner label="Loading dashboard…" />;
if (!server) {
return (
<Card title="No servers">
<p className="text-sm text-slate-ink">
No servers found. Run <code className="font-mono text-accent-400">npm run db:seed</code>{' '}
to create the training server.
</p>
</Card>
return isLoading ? (
<Spinner label="Loading dashboard…" />
) : (
<EmptyState
icon="server"
title="No servers configured"
hint="Run npm run db:seed to create the initial server record."
/>
);
}
return <Dashboard user={user} slug={server.slug} />;
@@ -42,168 +43,163 @@ function seriesOf(
}
function Dashboard({ user, slug }: { user: CurrentUser; slug: string }) {
const { data: serversData } = useServers();
const server = serversData?.servers.find((s) => s.slug === slug);
const server = usePrimaryServer();
const { data: resources } = useServerResources(slug);
const { data: config } = useConfiguration(slug);
const { data: history } = useResourceHistory(slug);
const { data: mods } = useModsOverview(slug);
if (!server) return null;
const installedMods = config?.config.mods ?? [];
const samples = history?.samples;
const memoryLimit = resources?.memoryLimitBytes ?? samples?.at(-1)?.memoryLimitBytes ?? null;
const cpuLimit = resources?.cpuLimitPercent ?? samples?.at(-1)?.cpuLimitPercent ?? 100;
const latest = samples?.at(-1);
const memoryLimit = resources?.memoryLimitBytes ?? latest?.memoryLimitBytes ?? null;
const cpuLimit = resources?.cpuLimitPercent ?? latest?.cpuLimitPercent ?? 100;
const diskUsed = resources?.diskBytes ?? null;
const diskLimit = resources?.diskLimitBytes ?? null;
const diskPercent = diskUsed !== null && diskLimit ? (diskUsed / diskLimit) * 100 : null;
return (
<div className="w-full space-y-5">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card title="CPU">
<p className="text-2xl font-semibold text-zinc-100">
{resources ? `${resources.cpuPercent.toFixed(0)}%` : '—'}
<span className="text-sm font-normal text-slate-dim">
{cpuLimit && cpuLimit !== 100 ? ` / ${cpuLimit}%` : ''}
</span>
</p>
<div className="w-full space-y-4">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<MetricTile
label="CPU"
value={resources ? `${resources.cpuPercent.toFixed(1)}%` : '—'}
unit={cpuLimit && cpuLimit !== 100 ? `of ${cpuLimit}%` : undefined}
detail={
resources && (
<Badge tone={resources.source === 'live' ? 'ok' : 'neutral'}>
{resources.source === 'live' ? 'live' : 'polled'}
</Badge>
)
}
>
<TimeSeriesChart
className="mt-2"
max={cpuLimit}
format={(value) => `${value.toFixed(0)}%`}
series={[
{
points: seriesOf(samples, (s) => s.cpuPercent),
color: 'var(--color-accent-400)',
},
{ points: seriesOf(samples, (s) => s.cpuPercent), color: 'var(--color-accent-400)' },
]}
/>
</Card>
</MetricTile>
<Card title="Memory">
<p className="text-2xl font-semibold text-zinc-100">
{resources ? formatBytes(resources.memoryBytes) : '—'}
<span className="text-sm font-normal text-slate-dim">
{memoryLimit ? ` / ${formatBytes(memoryLimit)}` : ''}
</span>
</p>
<MetricTile
label="Memory"
value={resources ? formatBytes(resources.memoryBytes) : '—'}
unit={memoryLimit ? `of ${formatBytes(memoryLimit)}` : undefined}
>
<TimeSeriesChart
className="mt-2"
max={memoryLimit}
format={formatBytes}
series={[
{
points: seriesOf(samples, (s) => s.memoryBytes),
color: '#7dd3fc',
},
{ points: seriesOf(samples, (s) => s.memoryBytes), color: 'var(--color-info-400)' },
]}
/>
</Card>
</MetricTile>
<Card title="Network">
<p className="text-sm text-zinc-300">
<span className="text-accent-400">
{formatBytes(samples?.at(-1)?.networkRxRate ?? 0)}/s
</span>
<span className="ml-3 text-warn-400">
{formatBytes(samples?.at(-1)?.networkTxRate ?? 0)}/s
</span>
<span className="ml-3 text-slate-dim">
up{' '}
<MetricTile
label="Network"
value={`${formatBytes(latest?.networkRxRate ?? 0)}/s`}
unit="in"
detail={
<>
{formatBytes(latest?.networkTxRate ?? 0)}/s out · up{' '}
{resources && resources.uptimeMs > 0
? formatDuration(resources.uptimeMs / 1000)
: '—'}
</span>
</p>
</>
}
>
<TimeSeriesChart
className="mt-2"
format={(value) => `${formatBytes(value)}/s`}
series={[
{
points: seriesOf(samples, (s) => s.networkRxRate),
color: 'var(--color-accent-400)',
label: 'rx',
label: 'in',
},
{
points: seriesOf(samples, (s) => s.networkTxRate),
color: 'var(--color-warn-400)',
fill: false,
label: 'tx',
label: 'out',
},
]}
/>
</Card>
<Card title="Storage">
<p className="text-2xl font-semibold text-zinc-100">
{diskUsed !== null ? formatBytes(diskUsed) : '—'}
<span className="text-sm font-normal text-slate-dim">
{diskLimit ? ` / ${formatBytes(diskLimit)}` : ''}
</span>
</p>
{diskPercent !== null && (
<div className="mt-3">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-graphite-800">
<div
className="h-full rounded-full transition-all"
style={{
width: `${Math.min(100, diskPercent).toFixed(1)}%`,
backgroundColor:
diskPercent > 90
? 'var(--color-danger-400)'
: diskPercent > 75
? 'var(--color-warn-400)'
: '#a3e635',
}}
/>
</div>
<p className="mt-1 text-xs text-slate-dim">{diskPercent.toFixed(1)}% used</p>
</div>
)}
</Card>
</MetricTile>
<MetricTile
label="Storage"
value={diskUsed !== null ? formatBytes(diskUsed) : '—'}
unit={diskLimit ? `of ${formatBytes(diskLimit)}` : undefined}
detail={
diskUsed !== null && diskLimit
? `${((diskUsed / diskLimit) * 100).toFixed(1)}% used`
: undefined
}
>
<ProgressBar value={diskUsed ?? 0} max={diskLimit} className="mt-1" />
</MetricTile>
</div>
<div className="grid gap-5 lg:grid-cols-3">
<div className="min-w-0 space-y-5 lg:col-span-2">
<div className="grid gap-4 lg:grid-cols-3">
<div className="min-w-0 space-y-4 lg:col-span-2">
<CurrentPlayersCard slug={slug} maxPlayers={server.maxPlayers} />
<RecentActivityCard slug={slug} />
</div>
<div className="min-w-0 space-y-5">
<div className="min-w-0 space-y-4">
<Card
title="Current configuration"
title="Configuration"
action={
<Link to="/configuration" className="text-xs text-accent-400 hover:underline">
View configuration
<Link to="/configuration" className="text-2xs text-accent-400 hover:underline">
Edit
</Link>
}
>
{config ? <ConfigSummaryRows config={config} /> : <Spinner />}
</Card>
<Card
title="Installed mods"
title="Mods"
action={
<Link to="/mods" className="text-xs text-accent-400 hover:underline">
<Link to="/mods" className="text-2xs text-accent-400 hover:underline">
Manage
</Link>
}
>
{installedMods.length === 0 ? (
{!mods ? (
<Spinner />
) : mods.mods.length === 0 ? (
<p className="text-sm text-slate-dim">The server runs vanilla (no mods).</p>
) : (
<div>
<p className="text-sm text-zinc-200">
{installedMods.length} mod{installedMods.length === 1 ? '' : 's'} in config.json
<div className="space-y-2">
<p className="numeric text-sm text-zinc-100">
{mods.mods.length} installed
{mods.totalSizeBytes ? ` · ${formatBytes(mods.totalSizeBytes)}` : ''}
</p>
<ul className="mt-2 space-y-1">
{installedMods.slice(0, 5).map((mod) => (
<div className="flex flex-wrap gap-1.5">
{mods.updatesAvailable > 0 && (
<Badge tone="warn">{mods.updatesAvailable} updates</Badge>
)}
{mods.unresolvedIds.length > 0 && (
<Badge tone="neutral">{mods.unresolvedIds.length} unidentified</Badge>
)}
{mods.orphanedMission && <Badge tone="danger">mission missing</Badge>}
{mods.warming && <Badge>loading metadata</Badge>}
</div>
<ul className="space-y-0.5">
{mods.mods.slice(0, 5).map((mod) => (
<li key={mod.modId} className="truncate text-xs text-slate-ink">
{mod.name ?? mod.modId}
{mod.workshop?.name ?? mod.configName ?? mod.modId}
</li>
))}
{installedMods.length > 5 && (
<li className="text-xs text-slate-dim">+ {installedMods.length - 5} more</li>
{mods.mods.length > 5 && (
<li className="text-xs text-slate-faint">+ {mods.mods.length - 5} more</li>
)}
</ul>
</div>
)}
</Card>
<OpsHealthCard user={user} slug={slug} />
</div>
</div>
+126 -147
View File
@@ -3,69 +3,48 @@ import type { CurrentUser, Role } from '@reforger-panel/shared';
import { ROLES, ROLE_LABELS } from '@reforger-panel/shared';
import {
useActivity,
useConfiguration,
useKnownPlayers,
useKillfeed,
useKnownPlayers,
useLogHealth,
usePlayers,
useServers,
usePrimaryServer,
useSetUserRole,
useUsers,
useWorkshopHealth,
} from '../api/hooks.js';
import { formatDateTime, formatDuration, formatRelativeTime } from '../lib/format.js';
import { Card, EmptyState, RoleBadge, Spinner } from '../components/ui.js';
import { ActivityList, ConfigSummaryRows, CurrentPlayersCard } from '../components/widgets.js';
import {
Badge,
Card,
EmptyState,
PageHeader,
RoleBadge,
SearchInput,
Spinner,
} from '../components/ui.js';
import { ActivityList, CurrentPlayersCard } from '../components/widgets.js';
import { InvitesCard } from '../components/invites-card.js';
import { MissionCard } from '../components/mission-card.js';
import { PerformanceForm } from '../components/performance-form.js';
import { SchedulesCard } from '../components/schedules-card.js';
import { StartupVarsCard } from '../components/startup-vars-card.js';
function usePrimarySlug(): string | null {
const { data } = useServers();
return data?.servers[0]?.slug ?? null;
}
export function ConfigurationsPage({ user }: { user: CurrentUser }) {
const slug = usePrimarySlug();
if (!slug) return <Spinner />;
return <ConfigurationsBody slug={slug} user={user} />;
}
function ConfigurationsBody({ slug, user }: { slug: string; user: CurrentUser }) {
const { data: config } = useConfiguration(slug);
const canEdit = user.capabilities.includes('config.edit');
return (
<div className="w-full space-y-5">
<h1 className="page-title">Configuration</h1>
<MissionCard slug={slug} canEdit={canEdit} />
<PerformanceForm slug={slug} canEdit={canEdit} />
{/*<SchedulesCard slug={slug} canEdit={canEdit} />*/}
{canEdit && <StartupVarsCard slug={slug} />}
<Card title="Full config summary (live from the server)">
{config ? <ConfigSummaryRows config={config} /> : <Spinner />}
</Card>
</div>
);
}
/* ---------------------------------------------------------------- players */
export function PlayersPage() {
const slug = usePrimarySlug();
if (!slug) return <Spinner />;
return <PlayersBody slug={slug} />;
const server = usePrimaryServer();
if (!server) return <Spinner />;
return <PlayersBody slug={server.slug} />;
}
type PlayerSort = 'online' | 'last_seen' | 'playtime' | 'sessions' | 'name';
function PlayersBody({ slug }: { slug: string }) {
const { data: online } = usePlayers(slug);
const { data: known } = useKnownPlayers(slug);
const [sort, setSort] = useState<'online' | 'last_seen' | 'playtime' | 'sessions' | 'name'>(
'online',
);
const [sort, setSort] = useState<PlayerSort>('online');
const [query, setQuery] = useState('');
const sortedPlayers = useMemo(() => {
const players = [...(known?.players ?? [])];
players.sort((a, b) => {
const players = (known?.players ?? []).filter((player) =>
query ? player.displayName.toLowerCase().includes(query.toLowerCase()) : true,
);
return [...players].sort((a, b) => {
if (sort === 'online') {
if (a.online !== b.online) return a.online ? -1 : 1;
return b.lastSeenAt.localeCompare(a.lastSeenAt);
@@ -75,35 +54,43 @@ function PlayersBody({ slug }: { slug: string }) {
if (sort === 'sessions') return b.totalSessions - a.totalSessions;
return a.displayName.localeCompare(b.displayName);
});
return players;
}, [known?.players, sort]);
}, [known?.players, sort, query]);
return (
<div className="w-full space-y-5">
<h1 className="page-title">Players</h1>
<div className="w-full space-y-4">
<PageHeader title="Players" />
<CurrentPlayersCard slug={slug} maxPlayers={online?.maxPlayers ?? null} />
<Card
title="All known players"
action={
<select
value={sort}
onChange={(event) => setSort(event.target.value as typeof sort)}
className="input py-1.5 text-xs"
>
<option value="online">Online first</option>
<option value="last_seen">Last seen</option>
<option value="playtime">Playtime</option>
<option value="sessions">Sessions</option>
<option value="name">Name</option>
</select>
<div className="flex items-center gap-2">
<SearchInput
value={query}
onChange={setQuery}
placeholder="Find a player…"
className="w-44"
/>
<select
value={sort}
onChange={(event) => setSort(event.target.value as PlayerSort)}
className="input w-auto py-1 text-xs"
>
<option value="online">Online first</option>
<option value="last_seen">Last seen</option>
<option value="playtime">Playtime</option>
<option value="sessions">Sessions</option>
<option value="name">Name</option>
</select>
</div>
}
>
{!known ? (
<Spinner />
) : known.players.length === 0 ? (
) : sortedPlayers.length === 0 ? (
<EmptyState
title="No players recorded yet"
hint="Players are discovered from server log connect events."
icon="users"
title={query ? 'No players match that name' : 'No players recorded yet'}
hint={query ? undefined : 'Players are discovered from server log connect events.'}
/>
) : (
<div className="data-table-scroll">
@@ -120,24 +107,24 @@ function PlayersBody({ slug }: { slug: string }) {
<tbody>
{sortedPlayers.map((player) => (
<tr key={player.id}>
<td className="py-2 font-medium text-zinc-200">
{player.displayName}
{player.online && (
<span className="ml-2 rounded bg-accent-600/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-accent-400">
online
</span>
)}
<td className="font-medium text-zinc-100">
<span className="flex items-center gap-2">
{player.displayName}
{player.online && <Badge tone="ok">online</Badge>}
</span>
</td>
<td className="py-2 font-mono text-xs text-slate-dim">
<td className="font-mono text-2xs text-slate-faint">
{player.externalPlayerId ? (
player.externalPlayerId.slice(0, 12) + '…'
`${player.externalPlayerId.slice(0, 12)}`
) : (
<span title="No stable ID in logs; matched by display name">name only</span>
)}
</td>
<td className="py-2 text-slate-ink">{formatRelativeTime(player.lastSeenAt)}</td>
<td className="py-2 text-right font-mono text-xs">{player.totalSessions}</td>
<td className="py-2 text-right font-mono text-xs">
<td className="numeric text-slate-ink">
{formatRelativeTime(player.lastSeenAt)}
</td>
<td className="numeric text-right text-xs">{player.totalSessions}</td>
<td className="numeric text-right text-xs">
{formatDuration(player.totalPlaytimeSeconds)}
</td>
</tr>
@@ -151,24 +138,20 @@ function PlayersBody({ slug }: { slug: string }) {
);
}
export function ActivityPage() {
const slug = usePrimarySlug();
if (!slug) return <Spinner />;
return <ActivityBody slug={slug} />;
}
/* --------------------------------------------------------------- killfeed */
export function KillfeedPage() {
const slug = usePrimarySlug();
if (!slug) return <Spinner />;
return <KillfeedBody slug={slug} />;
const server = usePrimaryServer();
if (!server) return <Spinner />;
return <KillfeedBody slug={server.slug} />;
}
function teamClass(team: string | null): string {
const normalized = team?.toLowerCase() ?? '';
if (normalized.includes('blue') || normalized.includes('blufor')) return 'bg-sky-500';
if (normalized.includes('opfor') || normalized.includes('red')) return 'bg-red-500';
if (normalized.includes('independent') || normalized.includes('green')) return 'bg-emerald-500';
return 'bg-slate-dim';
if (normalized.includes('blue') || normalized.includes('blufor')) return 'bg-info-400';
if (normalized.includes('opfor') || normalized.includes('red')) return 'bg-danger-400';
if (normalized.includes('independent') || normalized.includes('green')) return 'bg-ok-400';
return 'bg-slate-faint';
}
function positionLabel(position: { x: number; y: number; z?: number | null } | null): string {
@@ -180,42 +163,36 @@ function positionLabel(position: { x: number; y: number; z?: number | null } | n
function KillfeedBody({ slug }: { slug: string }) {
const { data, isLoading } = useKillfeed(slug, 150);
return (
<div className="w-full space-y-5">
<div>
<h1 className="page-title">Killfeed</h1>
<p className="page-kicker">
Parsed from ServerAdminTools kill events. Team, position, distance, and weapon show when
the log line provides them.
</p>
</div>
<div className="w-full space-y-4">
<PageHeader
title="Killfeed"
kicker="Parsed from ServerAdminTools kill events. Team, position, distance, and weapon show when the log line provides them."
/>
<Card title="Recent kills">
{isLoading || !data ? (
<Spinner />
) : data.events.length === 0 ? (
<EmptyState
icon="crosshair"
title="No kills recorded yet"
hint="Killfeed requires ServerAdminTools kill event lines in the server log."
/>
) : (
<ul className="space-y-2">
<ul className="space-y-1.5">
{data.events.map((event) => (
<li
key={event.id}
className="rounded-md border border-graphite-800 bg-graphite-950/20 px-3.5 py-3"
className="rounded-sm border border-graphite-800 bg-graphite-950/40 px-3 py-2"
>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className={`h-2.5 w-2.5 rounded-full ${teamClass(event.killerTeam)}`} />
<span className={`h-2 w-2 rounded-full ${teamClass(event.killerTeam)}`} />
<span className="font-medium text-zinc-100">{event.killerName}</span>
<span className="text-slate-dim">killed</span>
<span className={`h-2.5 w-2.5 rounded-full ${teamClass(event.victimTeam)}`} />
<span className={`h-2 w-2 rounded-full ${teamClass(event.victimTeam)}`} />
<span className="font-medium text-zinc-100">{event.victimName}</span>
{event.friendly && (
<span className="rounded border border-warn-400/30 bg-warn-400/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-warn-400">
friendly
</span>
)}
{event.friendly && <Badge tone="warn">friendly</Badge>}
</div>
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-dim">
<div className="numeric mt-1 flex flex-wrap gap-x-4 gap-y-1 text-2xs text-slate-dim">
<span>{formatDateTime(event.occurredAt)}</span>
<span>attacker {positionLabel(event.killerPosition)}</span>
<span>victim {positionLabel(event.victimPosition)}</span>
@@ -234,33 +211,41 @@ function KillfeedBody({ slug }: { slug: string }) {
);
}
/* --------------------------------------------------------------- activity */
export function ActivityPage() {
const server = usePrimaryServer();
if (!server) return <Spinner />;
return <ActivityBody slug={server.slug} />;
}
function ActivityBody({ slug }: { slug: string }) {
const { data } = useActivity(slug, 100);
return (
<div className="w-full space-y-5">
<h1 className="page-title">Activity</h1>
<Card>{data ? <ActivityList items={data.activity} maxHeight={560} /> : <Spinner />}</Card>
<div className="w-full space-y-4">
<PageHeader title="Activity" kicker="Panel actions and parsed server events, newest last." />
<Card padded={false} className="p-4">
{data ? <ActivityList items={data.activity} maxHeight={640} /> : <Spinner />}
</Card>
</div>
);
}
/* --------------------------------------------------------------- settings */
export function SettingsPage({ user }: { user: CurrentUser }) {
const isOwner = user.role === 'owner';
const slug = usePrimarySlug();
const server = usePrimaryServer();
const { data: users } = useUsers(isOwner);
const { data: workshop } = useWorkshopHealth();
const { data: logs } = useLogHealth(slug ?? '', isOwner && slug !== null);
const { data: logs } = useLogHealth(server?.slug ?? '', isOwner && server !== undefined);
const setRole = useSetUserRole();
return (
<div className="w-full space-y-5">
<div>
<h1 className="page-title">Settings</h1>
<p className="page-kicker">
Manage private Discord access, server integrations, and the checks that matter before
exposing the panel to friends.
</p>
</div>
<div className="w-full space-y-4">
<PageHeader
title="Settings"
kicker="Manage private Discord access and review the panel's integrations."
/>
<Card title="Your account">
<div className="flex items-center gap-3">
@@ -276,7 +261,7 @@ export function SettingsPage({ user }: { user: CurrentUser }) {
</span>
)}
<div>
<p className="text-sm font-medium text-zinc-200">
<p className="text-sm font-medium text-zinc-100">
{user.displayName ?? user.username}{' '}
<span className="text-slate-dim">({user.username})</span>
</p>
@@ -290,23 +275,23 @@ export function SettingsPage({ user }: { user: CurrentUser }) {
{!users ? (
<Spinner />
) : (
<ul className="space-y-2">
<ul className="space-y-1.5">
{users.users.map((panelUser) => (
<li
key={panelUser.id}
className="flex items-center justify-between rounded-md border border-graphite-800 bg-graphite-950/20 px-3 py-2.5"
className="flex items-center justify-between gap-3 rounded-sm border border-graphite-800 bg-graphite-950/40 px-3 py-2"
>
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2.5">
{panelUser.avatarUrl ? (
<img src={panelUser.avatarUrl} alt="" className="h-7 w-7 rounded-full" />
) : (
<span className="h-7 w-7 rounded-full bg-graphite-700" />
)}
<div>
<p className="text-sm text-zinc-200">
<div className="min-w-0">
<p className="truncate text-sm text-zinc-100">
{panelUser.displayName ?? panelUser.username}
</p>
<p className="text-xs text-slate-dim">
<p className="text-2xs text-slate-dim">
joined {formatDateTime(panelUser.createdAt)}
</p>
</div>
@@ -319,7 +304,7 @@ export function SettingsPage({ user }: { user: CurrentUser }) {
onChange={(event) =>
setRole.mutate({ userId: panelUser.id, role: event.target.value as Role })
}
className="input px-2 py-1 text-xs"
className="input w-auto py-1 text-xs"
>
{ROLES.map((role) => (
<option key={role} value={role}>
@@ -340,30 +325,24 @@ export function SettingsPage({ user }: { user: CurrentUser }) {
{isOwner && (
<Card title="Integrations">
<dl className="space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-slate-ink">Workshop API</dt>
<dd className={workshop?.ok ? 'text-accent-400' : 'text-danger-400'}>
{workshop
? workshop.ok
? `healthy (${workshop.latencyMs} ms)`
: 'unreachable'
: '—'}
</dd>
</div>
<div className="flex justify-between">
<div className="flex items-center justify-between gap-4">
<dt className="text-slate-ink">Pterodactyl</dt>
<dd className="text-zinc-300">
{logs?.configured ? 'configured' : 'mock / not configured'}
<dd>
{logs?.configured ? (
<Badge tone="ok">configured</Badge>
) : (
<Badge>mock / not configured</Badge>
)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-ink">Log path</dt>
<dd className="font-mono text-xs text-zinc-300">{logs?.logPath ?? '—'}</dd>
<div className="flex items-center justify-between gap-4">
<dt className="shrink-0 text-slate-ink">Game log path</dt>
<dd className="truncate font-mono text-2xs text-slate-dim">{logs?.logPath ?? '—'}</dd>
</div>
</dl>
<p className="mt-3 text-xs text-slate-dim">
Connection settings are managed through environment variables. Use real Pterodactyl
client API credentials for production and keep mock mode off.
<p className="mt-3 text-2xs leading-5 text-slate-dim">
Connection settings are managed through environment variables. Workshop metadata is
fetched on demand and cached in the API process there is no background polling.
</p>
</Card>
)}
+33
View File
@@ -34,6 +34,7 @@
"express": "^5.1.0",
"pg": "^8.16.0",
"pino": "^9.7.0",
"ws": "^8.21.3",
"zod": "^3.25.0"
},
"devDependencies": {
@@ -41,6 +42,7 @@
"@types/node": "^24.0.0",
"@types/pg": "^8.15.0",
"@types/supertest": "^6.0.0",
"@types/ws": "^8.18.1",
"drizzle-kit": "^0.31.0",
"pino-pretty": "^13.0.0",
"supertest": "^7.1.0",
@@ -2508,6 +2510,16 @@
"@types/superagent": "^8.1.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz",
@@ -9809,6 +9821,27 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+271 -41
View File
@@ -1,5 +1,5 @@
import type { Capability, Role } from './roles.js';
import type { ReforgerServerConfig } from './reforger-config.js';
import type { ReforgerConfigMod, ReforgerServerConfig } from './reforger-config.js';
// ---------- API envelope ----------
@@ -73,6 +73,11 @@ export type ServerResources = {
networkTxBytes: number;
uptimeMs: number;
fetchedAt: string;
/**
* Where the numbers came from. 'live' means a Wings `stats` frame pushed
* within the last few seconds; 'poll' means the slower REST fallback.
*/
source: 'live' | 'poll';
};
// ---------- Players ----------
@@ -140,23 +145,96 @@ export type KillfeedEvent = {
/** The live config.json, downloaded from the server on request. */
export type ConfigurationResponse = {
config: ReforgerServerConfig;
revision: string;
fetchedAt: string;
};
export type ConfigValueType = 'string' | 'number' | 'boolean' | 'null' | 'array' | 'object';
/** One leaf of config.json, addressed by dotted path (e.g. `game.maxPlayers`). */
export type ConfigEntry = {
path: string;
value: string | number | boolean | null;
type: ConfigValueType;
/** JSON text for values the flat editor cannot represent inline. */
raw?: string;
};
/**
* A config.json key that some Reforger eggs re-template from a Pterodactyl
* startup variable at boot. Editing the file alone would be silently undone.
*/
export type StartupMirror = {
envVariable: string;
configPath: string;
startupValue: string;
configValue: string | number | boolean | null;
/** True when the two currently disagree. */
conflict: boolean;
};
export type ConfigTreeResponse = {
entries: ConfigEntry[];
mirrors: StartupMirror[];
revision: string;
fetchedAt: string;
};
/** `value: null` removes the key so the game default applies. */
export type ConfigPatchOp = {
path: string;
value: string | number | boolean | null;
};
export type ConfigPatchRequest = {
ops: ConfigPatchOp[];
/** Revision the edits were based on; a mismatch is rejected as a conflict. */
expectedRevision?: string;
/** Also mirror changed values into their matching startup variables. */
writeStartupVars?: boolean;
};
export type ConfigPatchResult = {
changedPaths: string[];
startupVarsWritten: string[];
revision: string;
fetchedAt: string;
requiresRestart: true;
};
export type ConfigRawResponse = {
content: string;
revision: string;
fetchedAt: string;
};
// ---------- Missions ----------
export type MissionInfo = {
scenarioId: string;
/** Display name for the scenario, e.g. "Campaign - Montignac". */
/** Display name, e.g. "Conflict - Everon". */
name: string;
/** 'official' or a mod source such as "mod: Scenario Pack". */
source: string;
gameMode: string | null;
playerCount: number | null;
};
export type MissionGroup = {
/** 'official', or the workshop mod id that ships these scenarios. */
id: string;
label: string;
kind: 'official' | 'mod';
missions: MissionInfo[];
};
export type MissionsResponse = {
missions: MissionInfo[];
/** Null when the source could not be checked. */
groups: MissionGroup[];
/** Installed mods whose scenario list could not be resolved this time. */
incompleteModIds: string[];
fetchedAt: string | null;
};
// ---------- Logs ----------
export type RawLogsResponse = {
path: string;
lines: string[];
@@ -178,6 +256,24 @@ export type StartupResponse = {
fetchedAt: string;
};
// ---------- Live console (Pterodactyl / Wings) ----------
export type ConsoleLineStream = 'console' | 'install' | 'daemon';
export type ConsoleLine = {
/** Monotonic per-connection sequence number, for de-duplication. */
seq: number;
stream: ConsoleLineStream;
text: string;
at: number;
};
export type ConsoleBacklog = {
lines: ConsoleLine[];
status: ServerStatus;
connected: boolean;
};
// ---------- Schedules ----------
export type ServerScheduleTask = {
@@ -273,6 +369,7 @@ export type PerformanceSettings = {
export type PerformanceSettingsResponse = {
settings: PerformanceSettings;
revision: string;
fetchedAt: string;
};
@@ -295,52 +392,119 @@ export type InviteSummary = {
// ---------- Server mods (game.mods in config.json) ----------
export type ServerModsResponse = {
mods: { modId: string; name?: string; version?: string }[];
/** When the config.json this list came from was downloaded. */
mods: ReforgerConfigMod[];
/** sha256 of the config.json this list came from; required to write it back. */
revision: string;
fetchedAt: string;
};
export type ModDependencyIssue = {
modId: string;
modName: string | null;
missing: Array<{ id: string | null; name: string }>;
/** Workshop metadata attached to an installed mod. Null when unresolvable. */
export type ModWorkshopInfo = {
name: string;
author: string;
summary: string | null;
imageUrl: string | null;
workshopUrl: string | null;
latestVersion: string | null;
gameVersion: string | null;
sizeBytes: number | null;
scenarioCount: number;
dependencyCount: number;
obsolete: boolean;
tags: string[];
};
export type ModsCheckResponse = {
modsWithMissingVersions: string[];
modsWithMissingDeps: ModDependencyIssue[];
/** Non-null when the server's configured scenarioId is not in any known mission source. */
orphanedMission: { scenarioId: string; name: string | null } | null;
checkedAt: string;
export type ModOverviewEntry = {
modId: string;
/** Name recorded in config.json, if any. */
configName: string | null;
/** Version pinned in config.json; null means "track latest". */
pinnedVersion: string | null;
workshop: ModWorkshopInfo | null;
updateAvailable: boolean;
/** Dependencies of this mod that are missing from the server's list. */
missingDependencies: WorkshopDependency[];
/** Ids of installed mods that depend on this one — blockers for removal. */
requiredBy: string[];
};
export type ModsOverviewResponse = {
mods: ModOverviewEntry[];
revision: string;
fetchedAt: string;
totalSizeBytes: number | null;
updatesAvailable: number;
/** Mod ids the Workshop could not resolve (private, delisted, or upstream down). */
unresolvedIds: string[];
/** True while lookups are still warming; refetch shortly for complete data. */
warming: boolean;
/** Set when config.json's scenarioId is not offered by anything installed. */
orphanedMission: { scenarioId: string } | null;
};
export type ResolvedMod = {
modId: string;
name: string | null;
version: string | null;
sizeBytes: number | null;
/** True when pulled in as a dependency rather than explicitly requested. */
viaDependency: boolean;
/** Requested mods that require this one. */
requiredBy: string[];
};
export type ModResolveResponse = {
/** The requested list plus every dependency needed to make it load. */
mods: ResolvedMod[];
/** The subset that had to be added to satisfy dependencies. */
addedDependencies: ResolvedMod[];
totalSizeBytes: number | null;
unresolvedIds: string[];
};
export type UpdateModsResult = ServerModsResponse & {
added: number;
removed: number;
changed: number;
/** Reforger only picks up config changes on the next server restart. */
requiresRestart: true;
};
// ---------- Workshop ----------
// ---------- Workshop (reforgermods.net v2) ----------
export type WorkshopHealth = {
ok: boolean;
latencyMs: number | null;
checkedAt: string;
message: string | null;
};
export const WORKSHOP_SORTS = [
'popularity',
'most-rated',
'highest-rated',
'subscribers',
'newest',
'created',
'recently-updated',
'largest',
'name',
] as const;
export type WorkshopSort = (typeof WORKSHOP_SORTS)[number];
export type WorkshopModPreview = {
id: string;
name: string;
author: string;
summary: string | null;
imageUrl: string | null;
size: string | null;
rating: string | null;
workshopUrl: string | null;
version: string | null;
summary: string | null;
gameVersion: string | null;
sizeBytes: number | null;
sizeText: string | null;
/** 01. */
rating: number | null;
ratingCount: number | null;
subscriberCount: number | null;
createdAt: string | null;
updatedAt: string | null;
tags: string[];
obsolete: boolean;
};
export type WorkshopSearchResponse = {
@@ -352,30 +516,96 @@ export type WorkshopSearchResponse = {
};
};
export type WorkshopScenario = {
export type WorkshopDependency = {
id: string;
name: string;
description: string | null;
version: string | null;
sizeBytes: number | null;
published: boolean;
private: boolean;
};
export type WorkshopScenario = {
/** The `{HEX16}Missions/....conf` id used by game.scenarioId. */
scenarioId: string;
gamemode: string | null;
name: string;
gameMode: string | null;
author: string | null;
description: string | null;
playerCount: number | null;
imageUrl: string | null;
};
export type WorkshopModDetail = WorkshopModPreview & {
version: string | null;
gameVersion: string | null;
subscribers: number | null;
downloads: number | null;
createdAtText: string | null;
lastModifiedText: string | null;
summary: string | null;
description: string | null;
license: string | null;
tags: string[];
dependencies: { name: string; id: string | null }[];
downloadCount: number | null;
previewImages: string[];
screenshots: string[];
versionCount: number | null;
dependencyCount: number;
scenarioCount: number;
dependencySizeBytes: number | null;
totalSizeBytes: number | null;
dependencies: WorkshopDependency[];
scenarios: WorkshopScenario[];
};
export type WorkshopModVersion = {
version: string;
gameVersion: string | null;
sizeBytes: number | null;
sizeText: string | null;
approved: boolean;
published: boolean;
createdAt: string | null;
scenarioCount: number | null;
dependencyCount: number | null;
};
export type WorkshopModVersionsResponse = {
modId: string;
versions: WorkshopModVersion[];
};
/** A live Arma Reforger server from the reforgermods.net server browser. */
export type WorkshopServerSummary = {
id: string;
name: string;
scenarioId: string | null;
scenarioName: string | null;
gameVersion: string | null;
players: number;
maxPlayers: number;
region: string | null;
platform: string | null;
modCount: number;
official: boolean;
online: boolean;
};
export type WorkshopServerSearchResponse = {
servers: WorkshopServerSummary[];
meta: {
totalPages: number;
currentPage: number;
totalServers: number;
};
};
export type WorkshopServerMod = {
id: string;
name: string;
version: string | null;
sizeBytes: number | null;
};
export type WorkshopServerModsResponse = {
serverId: string;
mods: WorkshopServerMod[];
knownSizeBytes: number | null;
unresolvedCount: number;
};
// ---------- Log ingestion ----------
export type ServerEventType =