overhaul
This commit is contained in:
66 files changed
+9227
-3679
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,131 +1,234 @@
|
||||
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('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,
|
||||
});
|
||||
|
||||
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);
|
||||
describe('localizedLabel', () => {
|
||||
it('maps known Enfusion localization keys', () => {
|
||||
expect(localizedLabel('#AR-Scenario_GameMode_Campaign')).toBe('Campaign');
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
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();
|
||||
it('humanises unknown keys instead of leaking them', () => {
|
||||
expect(localizedLabel('#AR-Scenario_GameMode_KingOfTheHill')).toBe('King Of The Hill');
|
||||
});
|
||||
|
||||
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 });
|
||||
it('passes plain text through', () => {
|
||||
expect(localizedLabel('Sandbox')).toBe('Sandbox');
|
||||
expect(localizedLabel(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkshopClient (v2)', () => {
|
||||
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 client = new WorkshopClient({
|
||||
baseUrl: 'https://workshop.test',
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
|
||||
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('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,
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
const mod = await client.getMod('AAAAAAAAAAAAAAA1');
|
||||
expect(mod.scenarios[0]).toMatchObject({
|
||||
scenarioId: '{39AB5D9094E502AA}Missions/OG_Conflict.conf',
|
||||
gamemode: null,
|
||||
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 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' });
|
||||
});
|
||||
});
|
||||
@@ -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-panel';
|
||||
|
||||
// ---------- 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,50 +194,162 @@ 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 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> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-API-Client': CLIENT_NAME,
|
||||
'User-Agent': CLIENT_NAME,
|
||||
...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
|
||||
},
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -175,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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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'] }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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’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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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’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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+103
-107
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user