This commit is contained in:
SowinskiBraeden committed 2026-07-14 14:19:17 -07:00
commit 9f6a8b85f5
23 files changed
+1833 -306

No files matched your search

@@ -21,6 +21,7 @@ const FIELD_LOCATIONS: Record<
disableThirdPerson: ['gameProperties', 'disableThirdPerson'], disableThirdPerson: ['gameProperties', 'disableThirdPerson'],
fastValidation: ['gameProperties', 'fastValidation'], fastValidation: ['gameProperties', 'fastValidation'],
battlEye: ['gameProperties', 'battlEye'], battlEye: ['gameProperties', 'battlEye'],
disableAI: ['operating', 'disableAI'],
aiLimit: ['operating', 'aiLimit'], aiLimit: ['operating', 'aiLimit'],
playerSaveTime: ['operating', 'playerSaveTime'], playerSaveTime: ['operating', 'playerSaveTime'],
slotReservationTimeout: ['operating', 'slotReservationTimeout'], slotReservationTimeout: ['operating', 'slotReservationTimeout'],
@@ -11,7 +11,7 @@ const REAL_SHAPE = {
a2s: { address: '0.0.0.0', port: 17777 }, a2s: { address: '0.0.0.0', port: 17777 },
rcon: { address: '127.0.0.1', port: 19999, password: 'hunter2', permission: 'admin' }, rcon: { address: '127.0.0.1', port: 19999, password: 'hunter2', permission: 'admin' },
game: { game: {
name: 'DazzledCorp Training Grounds', name: 'DZR Training Grounds',
password: '', password: '',
passwordAdmin: 'secret', passwordAdmin: 'secret',
admins: ['76561198000000000'], admins: ['76561198000000000'],
@@ -34,7 +34,12 @@ const REAL_SHAPE = {
{ modId: '5AAF0CCE3F001FB5' }, { modId: '5AAF0CCE3F001FB5' },
], ],
}, },
operating: { lobbyPlayerSynchronise: true, aiLimit: -1, playerSaveTime: 120 }, operating: {
lobbyPlayerSynchronise: true,
disableAI: false,
aiLimit: -1,
playerSaveTime: 120
},
}; };
describe('parseReforgerConfigJson', () => { describe('parseReforgerConfigJson', () => {
@@ -44,6 +49,7 @@ describe('parseReforgerConfigJson', () => {
serverName: 'DazzledCorp Training Grounds', serverName: 'DazzledCorp Training Grounds',
maxPlayers: 16, maxPlayers: 16,
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf', scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
disableAI: false,
aiLimit: -1, aiLimit: -1,
serverMaxViewDistance: 2500, serverMaxViewDistance: 2500,
networkViewDistance: 1000, networkViewDistance: 1000,
@@ -66,6 +72,7 @@ describe('parseReforgerConfigJson', () => {
const config = parseReforgerConfigJson('{"game":{"name":"Bare"}}'); const config = parseReforgerConfigJson('{"game":{"name":"Bare"}}');
expect(config.serverName).toBe('Bare'); expect(config.serverName).toBe('Bare');
expect(config.maxPlayers).toBe(0); expect(config.maxPlayers).toBe(0);
expect(config.disableAI).toBe(false);
expect(config.aiLimit).toBe(-1); expect(config.aiLimit).toBe(-1);
expect(config.mods).toEqual([]); expect(config.mods).toEqual([]);
}); });
@@ -52,6 +52,7 @@ export function mapReforgerConfig(raw: unknown): ReforgerServerConfig {
serverName: str(game.name, 'Unnamed server'), serverName: str(game.name, 'Unnamed server'),
maxPlayers: num(game.maxPlayers, 0), maxPlayers: num(game.maxPlayers, 0),
scenarioId: str(game.scenarioId), scenarioId: str(game.scenarioId),
disableAI: bool(operating.disableAI, false),
// -1 means "no limit" in Reforger's operating.aiLimit. // -1 means "no limit" in Reforger's operating.aiLimit.
aiLimit: num(operating.aiLimit, -1), aiLimit: num(operating.aiLimit, -1),
serverMaxViewDistance: num(gameProperties.serverMaxViewDistance, 0), serverMaxViewDistance: num(gameProperties.serverMaxViewDistance, 0),
@@ -116,7 +116,7 @@ export class MockGameServerProvider implements GameServerProvider {
bindPort: 2001, bindPort: 2001,
game: { game: {
name: 'Mock Reforger Server', name: 'Mock Reforger Server',
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf', scenarioId: '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf',
maxPlayers: 16, maxPlayers: 16,
crossPlatform: true, crossPlatform: true,
gameProperties: { gameProperties: {
@@ -126,7 +126,7 @@ export class MockGameServerProvider implements GameServerProvider {
}, },
mods: [{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' }], mods: [{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' }],
}, },
operating: { aiLimit: 40 }, operating: { disableAI: false, aiLimit: 40 },
}, },
null, null,
2, 2,
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { mergeMissions, parseMissionList, scenariosFromWorkshopMod } from './missions-catalog.js'; import {
hasScenarioTag,
mergeMissions,
parseMissionList,
scenariosFromWorkshopMod,
} from './missions-catalog.js';
// Verbatim shape from a real console.log (server runs with -listScenarios). // Verbatim shape from a real console.log (server runs with -listScenarios).
const LOG = [ const LOG = [
@@ -41,6 +46,12 @@ describe('parseMissionList', () => {
}); });
describe('workshop scenario helpers', () => { 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);
});
it('converts mod scenarios into mission entries', () => { it('converts mod scenarios into mission entries', () => {
const missions = scenariosFromWorkshopMod({ const missions = scenariosFromWorkshopMod({
id: 'ABC', id: 'ABC',
@@ -4,6 +4,14 @@ import type { LogPathResolver } from './ingestion/log-path-resolver.js';
const CATALOG_TTL_MS = 10 * 60 * 1000; const CATALOG_TTL_MS = 10 * 60 * 1000;
const CATALOG_MAX_BYTES = 2 * 1024 * 1024; 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']);
/** /**
* Scenario listing printed at boot when the server runs with -listScenarios * Scenario listing printed at boot when the server runs with -listScenarios
@@ -45,6 +53,19 @@ export function scenariosFromWorkshopMod(mod: WorkshopModDetail): MissionInfo[]
})); }));
} }
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[] { export function mergeMissions(...groups: MissionInfo[][]): MissionInfo[] {
const merged: MissionInfo[] = []; const merged: MissionInfo[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
@@ -50,6 +50,8 @@ export class ResourceHistoryService {
cpuLimitPercent: null, cpuLimitPercent: null,
memoryBytes: 0, memoryBytes: 0,
memoryLimitBytes: null, memoryLimitBytes: null,
diskBytes: 0,
diskLimitBytes: null,
networkRxRate: 0, networkRxRate: 0,
networkTxRate: 0, networkTxRate: 0,
rxTotal: -1, rxTotal: -1,
@@ -80,6 +82,8 @@ export class ResourceHistoryService {
cpuLimitPercent: resources.cpuLimitPercent, cpuLimitPercent: resources.cpuLimitPercent,
memoryBytes: resources.memoryBytes, memoryBytes: resources.memoryBytes,
memoryLimitBytes: resources.memoryLimitBytes, memoryLimitBytes: resources.memoryLimitBytes,
diskBytes: resources.diskBytes,
diskLimitBytes: resources.diskLimitBytes,
networkRxRate: Math.round(networkRxRate), networkRxRate: Math.round(networkRxRate),
networkTxRate: Math.round(networkTxRate), networkTxRate: Math.round(networkTxRate),
rxTotal: resources.networkRxBytes, rxTotal: resources.networkRxBytes,
+199 -14
View File
@@ -2,6 +2,8 @@ import { Router } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import type { import type {
LogIngestionHealth, LogIngestionHealth,
MissionInfo,
ModDependencyIssue,
ServerResources, ServerResources,
ServerStatus, ServerStatus,
ServerSummary, ServerSummary,
@@ -17,7 +19,13 @@ import type { GameServerProvider } from '../pterodactyl/types.js';
import type { LogPathResolver } from '../reforger-logs/ingestion/log-path-resolver.js'; import type { LogPathResolver } from '../reforger-logs/ingestion/log-path-resolver.js';
import type { IngestionScheduler, ScheduledServer } from '../reforger-logs/ingestion/scheduler.js'; import type { IngestionScheduler, ScheduledServer } from '../reforger-logs/ingestion/scheduler.js';
import type { MissionCatalog } from '../reforger-logs/missions-catalog.js'; import type { MissionCatalog } from '../reforger-logs/missions-catalog.js';
import { mergeMissions, scenariosFromWorkshopMod } from '../reforger-logs/missions-catalog.js'; import {
DEFAULT_MISSION,
DEFAULT_SCENARIO_ID,
hasScenarioTag,
mergeMissions,
scenariosFromWorkshopMod,
} from '../reforger-logs/missions-catalog.js';
import type { ServerRecord, ServerService } from './server-service.js'; import type { ServerRecord, ServerService } from './server-service.js';
import type { WorkshopClient } from '../workshop/workshop-client.js'; import type { WorkshopClient } from '../workshop/workshop-client.js';
@@ -46,7 +54,8 @@ const performanceBodySchema = z
.string() .string()
.trim() .trim()
.max(200) .max(200)
.regex(/^\{[0-9A-Fa-f]{16}\}\S+\.conf$/, 'Invalid scenario id.') // Allow spaces in the path portion (some modded scenario IDs contain them).
.regex(/^\{[0-9A-Fa-f]{16}\}[^\0\r\n]+\.conf$/, 'Invalid scenario id.')
.nullable(), .nullable(),
maxPlayers: z.number().int().min(1).max(128).nullable(), maxPlayers: z.number().int().min(1).max(128).nullable(),
serverMaxViewDistance: z.number().int().min(500).max(10000).nullable(), serverMaxViewDistance: z.number().int().min(500).max(10000).nullable(),
@@ -55,6 +64,7 @@ const performanceBodySchema = z
disableThirdPerson: z.boolean().nullable(), disableThirdPerson: z.boolean().nullable(),
fastValidation: z.boolean().nullable(), fastValidation: z.boolean().nullable(),
battlEye: z.boolean().nullable(), battlEye: z.boolean().nullable(),
disableAI: z.boolean().nullable(),
aiLimit: z.number().int().min(-1).max(1000).nullable(), aiLimit: z.number().int().min(-1).max(1000).nullable(),
playerSaveTime: z.number().int().min(1).max(3600).nullable(), playerSaveTime: z.number().int().min(1).max(3600).nullable(),
slotReservationTimeout: z.number().int().min(5).max(300).nullable(), slotReservationTimeout: z.number().int().min(5).max(300).nullable(),
@@ -290,31 +300,121 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
router.get('/:slug/missions', async (req, res, next) => { router.get('/:slug/missions', async (req, res, next) => {
try { try {
const server = await loadServer(req.params.slug); const server = await loadServer(req.params.slug);
const logMissions = deps.missions ? (await deps.missions.list()).missions : [];
const modMissions = []; // Resolve the installed mod list from config.json.
// Prefer the mods service (already owns that parse); fall back to configSync.
let installedModIds: string[] = [];
if (deps.mods) { if (deps.mods) {
const installed = await deps.mods.getMods(server); const modsData = await deps.mods.getMods(server);
installedModIds = modsData.mods.map((m) => m.modId);
} else if (deps.configSync) {
const config = await deps.configSync.getLiveConfig(server).catch(() => null);
installedModIds = (config?.mods ?? []).map((m) => m.modId);
}
// Workshop API -> scenarios from installed scenario-tagged mods.
const modMissions: MissionInfo[] = [];
let scenarioLookupComplete = true;
if (installedModIds.length > 0) {
const details = await Promise.allSettled( const details = await Promise.allSettled(
installed.mods.map((mod) => deps.workshop.getMod(mod.modId)), installedModIds.map((modId) => deps.workshop.getMod(modId)),
); );
scenarioLookupComplete = details.every((result) => result.status === 'fulfilled');
for (const result of details) { for (const result of details) {
if (result.status === 'fulfilled') { if (result.status !== 'fulfilled') continue;
modMissions.push(...scenariosFromWorkshopMod(result.value)); const mod = result.value;
if (hasScenarioTag(mod.tags)) {
modMissions.push(...scenariosFromWorkshopMod(mod));
} }
} }
} }
if (!deps.missions && !deps.mods) {
throw ApiError.notConfigured('Missions require logs or config/mod access.');
}
res.json({ res.json({
missions: mergeMissions(logMissions, modMissions), missions: mergeMissions([DEFAULT_MISSION], modMissions),
fetchedAt: new Date().toISOString(), fetchedAt: scenarioLookupComplete ? new Date().toISOString() : null,
}); });
} catch (error) { } catch (error) {
next(error); next(error);
} }
}); });
router.get(
'/:slug/logs/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.');
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
const controller = new AbortController();
req.on('close', () => controller.abort());
// 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;
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.
}
await sleep(2000);
}
res.end();
} catch (error) {
next(error);
}
},
);
router.get( router.get(
'/:slug/logs/raw', '/:slug/logs/raw',
requireCapability('ops.health.view', 'Raw logs are restricted.'), requireCapability('ops.health.view', 'Raw logs are restricted.'),
@@ -506,6 +606,77 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
} }
}); });
router.get('/:slug/mods/check', 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 } = 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(),
});
} catch (error) {
next(error);
}
});
router.put( router.put(
'/:slug/mods', '/:slug/mods',
syncRateLimit, syncRateLimit,
@@ -526,7 +697,21 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
throw ApiError.validation('Duplicate mod ids in the list.'); throw ApiError.validation('Duplicate mod ids in the list.');
} }
const result = await deps.mods.setMods(server, 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(
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 result = await deps.mods.setMods(server, enrichedMods);
const user = req.user!; const user = req.user!;
await service.recordActivity({ await service.recordActivity({
serverId: server.id, serverId: server.id,
@@ -59,7 +59,7 @@ describe('normalizeImageUrl', () => {
}); });
}); });
describe('WorkshopClient image enrichment', () => { describe('WorkshopClient preview cache', () => {
it('identifies panel traffic to the upstream API', async () => { it('identifies panel traffic to the upstream API', async () => {
const fetchImpl = vi.fn(async () => { const fetchImpl = vi.fn(async () => {
return new Response(JSON.stringify({ status: 'ok' }), { status: 200 }); return new Response(JSON.stringify({ status: 'ok' }), { status: 200 });
@@ -82,7 +82,7 @@ describe('WorkshopClient image enrichment', () => {
); );
}); });
it('warms list images from the detail endpoint in the background and caches them', async () => { it('does not fan out detail requests during search', async () => {
const fetchImpl = vi.fn(async (url: string | URL) => { const fetchImpl = vi.fn(async (url: string | URL) => {
const path = String(url); const path = String(url);
if (path.includes('/v1/mod/')) { if (path.includes('/v1/mod/')) {
@@ -98,26 +98,22 @@ describe('WorkshopClient image enrichment', () => {
const first = await client.search('', 1); const first = await client.search('', 1);
expect(first.mods[0]!.imageUrl).toBeNull(); expect(first.mods[0]!.imageUrl).toBeNull();
await vi.waitFor(() => { expect(first.mods[0]!.version).toBeNull();
const detailCalls = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/')); const detailCalls = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/'));
expect(detailCalls).toHaveLength(1); expect(detailCalls).toHaveLength(0);
});
const detailCalls = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/'));
expect(detailCalls).toHaveLength(1);
// Second search hits the cache — no extra detail request. await client.getMod('AAAAAAAAAAAAAAA1');
// Second search can use the cached detail without another detail request.
const second = await client.search('', 1); const second = await client.search('', 1);
expect(second.mods[0]!.imageUrl).toBe(REAL_IMAGE); 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/')); const detailCallsAfter = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/'));
expect(detailCallsAfter).toHaveLength(1); expect(detailCallsAfter).toHaveLength(1);
}); });
it('leaves the image empty when the detail fetch fails', async () => { it('leaves the image empty when there is no cached detail', async () => {
const fetchImpl = vi.fn(async (url: string | URL) => { const fetchImpl = vi.fn(async (url: string | URL) => {
const path = String(url);
if (path.includes('/v1/mod/')) {
return new Response('nope', { status: 500 });
}
return new Response(JSON.stringify(listResponse()), { status: 200 }); return new Response(JSON.stringify(listResponse()), { status: 200 });
}); });
const client = new WorkshopClient({ const client = new WorkshopClient({
@@ -127,4 +123,31 @@ describe('WorkshopClient image enrichment', () => {
const result = await client.search('', 1); const result = await client.search('', 1);
expect(result.mods[0]!.imageUrl).toBeNull(); expect(result.mods[0]!.imageUrl).toBeNull();
}); });
it('extracts scenario IDs from malformed scenario metadata', async () => {
const fetchImpl = vi.fn(async () => {
const detail = detailResponse('AAAAAAAAAAAAAAA1');
detail.mod.scenarios = [
{
name: '[OG] Udachne',
description: '',
scenarioID: '',
gamemode: 'Scenario ID{39AB5D9094E502AA}Missions/OG_Conflict.conf',
playerCount: 0,
imageURL: '',
},
];
return new Response(JSON.stringify(detail), { status: 200 });
});
const client = new WorkshopClient({
baseUrl: 'https://workshop.test',
fetchImpl: fetchImpl as unknown as typeof fetch,
});
const mod = await client.getMod('AAAAAAAAAAAAAAA1');
expect(mod.scenarios[0]).toMatchObject({
scenarioId: '{39AB5D9094E502AA}Missions/OG_Conflict.conf',
gamemode: null,
});
});
}); });
@@ -25,6 +25,9 @@ const modPreviewSchema = z.object({
size: z.string().catch(''), size: z.string().catch(''),
rating: z.string().catch(''), rating: z.string().catch(''),
ID: z.string(), ID: z.string(),
version: z.string().nullish(),
summary: z.string().nullish(),
tags: z.array(z.string()).catch([]),
}); });
const searchResponseSchema = z.object({ const searchResponseSchema = z.object({
@@ -61,7 +64,7 @@ const modDetailSchema = z.object({
z.object({ z.object({
name: z.string(), name: z.string(),
description: z.string().catch(''), description: z.string().catch(''),
scenarioID: z.string(), scenarioID: z.string().catch(''),
gamemode: z.string().catch(''), gamemode: z.string().catch(''),
playerCount: z.number().catch(0), playerCount: z.number().catch(0),
imageURL: z.string().catch(''), imageURL: z.string().catch(''),
@@ -79,6 +82,23 @@ function extractModId(apiModUrl: string): string | null {
return match?.[1] ?? null; 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;
}
/** /**
* Upstream image URLs need repair: list endpoints return dead * Upstream image URLs need repair: list endpoints return dead
* via.placeholder.com stubs, and detail endpoints sometimes concatenate two * via.placeholder.com stubs, and detail endpoints sometimes concatenate two
@@ -101,19 +121,30 @@ function toPreview(mod: z.infer<typeof modPreviewSchema>): WorkshopModPreview {
size: mod.size || null, size: mod.size || null,
rating: mod.rating || null, rating: mod.rating || null,
workshopUrl: mod.originalModURL || null, workshopUrl: mod.originalModURL || null,
version: mod.version ?? null,
summary: mod.summary ?? null,
tags: mod.tags,
}; };
} }
const IMAGE_CACHE_TTL_MS = 60 * 60 * 1000; // matches upstream's 1 h detail cache
const IMAGE_FETCH_CONCURRENCY = 5;
const CLIENT_IDENTITY = 'reforger.dzr.tools'; const CLIENT_IDENTITY = 'reforger.dzr.tools';
const PREVIEW_CACHE_TTL_MS = 60 * 60 * 1000; // matches upstream's 1 h detail cache
export class WorkshopClient { export class WorkshopClient {
private readonly baseUrl: string; private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch; private readonly fetchImpl: typeof fetch;
private readonly timeoutMs: number; private readonly timeoutMs: number;
/** modId → real image URL (or null when the mod has none). */ /** modId -> detail fields used to make browse cards useful. */
private imageCache = new Map<string, { url: string | null; expiresAt: number }>(); private previewCache = new Map<
string,
{
imageUrl: string | null;
version: string | null;
summary: string | null;
tags: string[];
expiresAt: number;
}
>();
constructor(options: { baseUrl: string; fetchImpl?: typeof fetch; timeoutMs?: number }) { constructor(options: { baseUrl: string; fetchImpl?: typeof fetch; timeoutMs?: number }) {
this.baseUrl = options.baseUrl.replace(/\/$/, ''); this.baseUrl = options.baseUrl.replace(/\/$/, '');
@@ -180,67 +211,22 @@ export class WorkshopClient {
throw ApiError.upstream('Workshop API returned an unexpected response shape.'); throw ApiError.upstream('Workshop API returned an unexpected response shape.');
} }
const mods = parsed.data.data.map(toPreview); const mods = parsed.data.data.map(toPreview);
this.applyCachedImages(mods); this.applyCachedPreviews(mods);
void this.enrichImages(mods).catch(() => undefined);
return { return {
mods, mods,
meta: parsed.data.meta, meta: parsed.data.meta,
}; };
} }
private applyCachedImages(mods: WorkshopModPreview[]): void { private applyCachedPreviews(mods: WorkshopModPreview[]): void {
const now = Date.now(); const now = Date.now();
for (const mod of mods) { for (const mod of mods) {
if (mod.imageUrl) continue; const cached = this.previewCache.get(mod.id);
const cached = this.imageCache.get(mod.id);
if (cached && cached.expiresAt > now) { if (cached && cached.expiresAt > now) {
mod.imageUrl = cached.url; 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;
/**
* List responses carry no usable images, so fill them in from the detail
* endpoint (which does). This runs as a background cache warmer from search:
* first-load results are fast, later visits pick up cached images.
*/
private async enrichImages(mods: WorkshopModPreview[]): Promise<void> {
const now = Date.now();
const pending: WorkshopModPreview[] = [];
for (const mod of mods) {
if (mod.imageUrl) continue;
const cached = this.imageCache.get(mod.id);
if (cached && cached.expiresAt > now) {
mod.imageUrl = cached.url;
} else {
pending.push(mod);
}
}
if (pending.length === 0) return;
const queue = [...pending];
const worker = async () => {
for (;;) {
const mod = queue.shift();
if (!mod) return;
try {
const detail = await this.getMod(mod.id);
mod.imageUrl = detail.imageUrl;
} catch {
mod.imageUrl = null;
}
this.imageCache.set(mod.id, {
url: mod.imageUrl,
expiresAt: Date.now() + IMAGE_CACHE_TTL_MS,
});
}
};
await Promise.all(
Array.from({ length: Math.min(IMAGE_FETCH_CONCURRENCY, queue.length) }, () => worker()),
);
if (this.imageCache.size > 5_000) {
for (const [key, value] of this.imageCache) {
if (value.expiresAt <= now) this.imageCache.delete(key);
} }
} }
} }
@@ -252,7 +238,7 @@ export class WorkshopClient {
throw ApiError.upstream('Workshop API returned an unexpected response shape.'); throw ApiError.upstream('Workshop API returned an unexpected response shape.');
} }
const mod = parsed.data.mod; const mod = parsed.data.mod;
return { const detail = {
id: mod.id, id: mod.id,
name: mod.name, name: mod.name,
author: mod.author, author: mod.author,
@@ -277,11 +263,30 @@ export class WorkshopClient {
scenarios: mod.scenarios.map((scenario) => ({ scenarios: mod.scenarios.map((scenario) => ({
name: scenario.name, name: scenario.name,
description: scenario.description || null, description: scenario.description || null,
scenarioId: scenario.scenarioID, scenarioId: extractScenarioId(
gamemode: scenario.gamemode || null, scenario.scenarioID,
scenario.gamemode,
scenario.description,
scenario.name,
),
gamemode: cleanScenarioText(scenario.gamemode),
playerCount: scenario.playerCount || null, playerCount: scenario.playerCount || null,
imageUrl: normalizeImageUrl(scenario.imageURL), imageUrl: normalizeImageUrl(scenario.imageURL),
})), })),
}; };
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,
});
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;
} }
} }
+1
View File
@@ -343,6 +343,7 @@ describe('performance config by role', () => {
disableThirdPerson: null, disableThirdPerson: null,
fastValidation: null, fastValidation: null,
battlEye: null, battlEye: null,
disableAI: null,
aiLimit: null, aiLimit: null,
playerSaveTime: null, playerSaveTime: null,
slotReservationTimeout: null, slotReservationTimeout: null,
+1
View File
@@ -69,6 +69,7 @@ describe('ServerModsService', () => {
expect(parsed.bindPort).toBe(2001); expect(parsed.bindPort).toBe(2001);
expect(parsed.game.name).toBe('Mock Reforger Server'); expect(parsed.game.name).toBe('Mock Reforger Server');
expect(parsed.game.maxPlayers).toBe(16); expect(parsed.game.maxPlayers).toBe(16);
expect(parsed.operating.disableAI).toBe(false);
expect(parsed.operating.aiLimit).toBe(40); expect(parsed.operating.aiLimit).toBe(40);
}); });
+3 -1
View File
@@ -37,6 +37,7 @@ describe('PerformanceSettingsService', () => {
// Present in the mock config.json: // Present in the mock config.json:
expect(settings.maxPlayers).toBe(16); expect(settings.maxPlayers).toBe(16);
expect(settings.serverMaxViewDistance).toBe(2500); expect(settings.serverMaxViewDistance).toBe(2500);
expect(settings.disableAI).toBe(false);
expect(settings.aiLimit).toBe(40); expect(settings.aiLimit).toBe(40);
expect(settings.disableThirdPerson).toBe(false); expect(settings.disableThirdPerson).toBe(false);
// Absent keys: // Absent keys:
@@ -50,10 +51,11 @@ describe('PerformanceSettingsService', () => {
...settings, ...settings,
maxPlayers: 32, maxPlayers: 32,
playerSaveTime: 180, // new key playerSaveTime: 180, // new key
disableAI: null,
aiLimit: null, // remove key → game default aiLimit: null, // remove key → game default
}); });
expect(result.changedFields.sort()).toEqual(['aiLimit', 'maxPlayers', 'playerSaveTime']); expect(result.changedFields.sort()).toEqual(['aiLimit', 'disableAI', 'maxPlayers', 'playerSaveTime']);
expect(result.requiresRestart).toBe(true); expect(result.requiresRestart).toBe(true);
const written = JSON.parse(provider.writtenFiles.get('/config.json')!); const written = JSON.parse(provider.writtenFiles.get('/config.json')!);
+42 -4
View File
@@ -1,3 +1,4 @@
import { useEffect, useRef } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { import type {
ActivityItem, ActivityItem,
@@ -6,6 +7,7 @@ import type {
InviteSummary, InviteSummary,
KillfeedEvent, KillfeedEvent,
MissionsResponse, MissionsResponse,
ModsCheckResponse,
PerformanceSettingsPatch, PerformanceSettingsPatch,
PerformanceSettingsResponse, PerformanceSettingsResponse,
RawLogsResponse, RawLogsResponse,
@@ -121,11 +123,35 @@ export function useMissions(slug: string) {
return useQuery({ return useQuery({
queryKey: ['servers', slug, 'missions'], queryKey: ['servers', slug, 'missions'],
queryFn: () => api.get<MissionsResponse>(`/api/servers/${slug}/missions`), queryFn: () => api.get<MissionsResponse>(`/api/servers/${slug}/missions`),
staleTime: 5 * 60_000, staleTime: 60_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;
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 useRawLogs(slug: string, lines: number, autoRefresh: boolean, enabled: boolean) { export function useRawLogs(slug: string, lines: number, autoRefresh: boolean, enabled: boolean) {
return useQuery({ return useQuery({
queryKey: ['servers', slug, 'logs', 'raw', lines], queryKey: ['servers', slug, 'logs', 'raw', lines],
@@ -209,7 +235,8 @@ export function useSetPerformanceSettings(slug: string) {
`/api/servers/${slug}/config/performance`, `/api/servers/${slug}/config/performance`,
settings, settings,
), ),
onSuccess: () => { onSuccess: (result) => {
queryClient.setQueryData(['servers', slug, 'config', 'performance'], result);
void queryClient.invalidateQueries({ queryKey: ['servers', slug] }); void queryClient.invalidateQueries({ queryKey: ['servers', slug] });
}, },
}); });
@@ -258,12 +285,23 @@ export function useSetServerMods(slug: string) {
return useMutation({ return useMutation({
mutationFn: (mods: ReforgerConfigMod[]) => mutationFn: (mods: ReforgerConfigMod[]) =>
api.put<UpdateModsResult>(`/api/servers/${slug}/mods`, { mods }), api.put<UpdateModsResult>(`/api/servers/${slug}/mods`, { mods }),
onSuccess: () => { onSuccess: (result) => {
queryClient.setQueryData(['servers', slug, 'mods'], result);
void queryClient.invalidateQueries({ queryKey: ['servers', slug] }); 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) { export function useManualLogSync(slug: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
+35 -38
View File
@@ -1,21 +1,17 @@
import { useState } from 'react'; import { useState } from 'react';
import { useConfiguration, useMissions, useSetPerformanceSettings } from '../api/hooks.js'; import { useConfiguration, useSetPerformanceSettings } from '../api/hooks.js';
import { Button, Card, Spinner } from './ui.js'; import { Button, Card, Spinner } from './ui.js';
import { shortScenario } from './widgets.js'; import { shortScenario } from './widgets.js';
function missionSourceLabel(source: string): string { const DEFAULT_SCENARIO_ID = '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf';
if (source === 'official') return ''; const DEFAULT_SCENARIO_NAME = 'Campaign - Montignac (default)';
if (source.startsWith('mod: ')) return `Mod: ${source.slice(5)}`;
return source;
}
/** /**
* Mission switcher. Options come from the scenario listing the server prints * Mission editor. Scenario discovery through the Workshop API is not reliable
* at boot (requires the -listScenarios launch flag, standard on Reforger eggs). * enough for every mod, so the primary control is a manual scenario ID input.
*/ */
export function MissionCard({ slug, canEdit }: { slug: string; canEdit: boolean }) { export function MissionCard({ slug, canEdit }: { slug: string; canEdit: boolean }) {
const { data: config, refetch } = useConfiguration(slug); const { data: config, refetch } = useConfiguration(slug);
const { data: missions } = useMissions(slug);
const save = useSetPerformanceSettings(slug); const save = useSetPerformanceSettings(slug);
const [selected, setSelected] = useState<string | null>(null); const [selected, setSelected] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
@@ -29,15 +25,13 @@ export function MissionCard({ slug, canEdit }: { slug: string; canEdit: boolean
} }
const current = config.config.scenarioId; const current = config.config.scenarioId;
const currentName =
missions?.missions.find((m) => m.scenarioId === current)?.name ?? shortScenario(current);
const value = selected ?? current; const value = selected ?? current;
const dirty = value !== current; const dirty = value !== current;
const submit = () => { const submit = (scenarioIdOverride?: string) => {
setMessage(null); setMessage(null);
save.mutate( save.mutate(
{ scenarioId: value }, { scenarioId: scenarioIdOverride ?? value },
{ {
onSuccess: () => { onSuccess: () => {
setSelected(null); setSelected(null);
@@ -59,48 +53,51 @@ export function MissionCard({ slug, canEdit }: { slug: string; canEdit: boolean
<Button onClick={() => setSelected(null)} disabled={save.isPending}> <Button onClick={() => setSelected(null)} disabled={save.isPending}>
Discard Discard
</Button> </Button>
<Button variant="accent" onClick={submit} disabled={save.isPending}> <Button variant="accent" onClick={() => submit()} disabled={save.isPending}>
{save.isPending ? 'Saving…' : 'Save to server'} {save.isPending ? 'Saving…' : 'Save to server'}
</Button> </Button>
</div> </div>
) )
} }
> >
<div className="flex flex-wrap items-center gap-4"> <div className="space-y-4">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-lg font-medium text-zinc-100">{currentName}</p> <p className="text-lg font-medium text-zinc-100">{shortScenario(current)}</p>
<p className="truncate font-mono text-xs text-slate-dim" title={current}> <p className="truncate font-mono text-xs text-slate-dim" title={current}>
{shortScenario(current)} {current}
</p> </p>
</div> </div>
{canEdit && {canEdit && (
(missions && missions.missions.length > 0 ? ( <div className="grid gap-2">
<select <input
value={value} value={value}
onChange={(event) => { onChange={(event) => {
setMessage(null); setMessage(null);
setSelected(event.target.value); setSelected(event.target.value);
}} }}
className="input max-w-xs" placeholder="{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf"
className="input w-full font-mono text-xs"
/>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => {
setMessage(null);
setSelected(DEFAULT_SCENARIO_ID);
}}
disabled={save.isPending}
> >
{!missions.missions.some((m) => m.scenarioId === current) && ( Use {DEFAULT_SCENARIO_NAME}
<option value={current}>{currentName} (current)</option> </Button>
<Button
variant="danger"
onClick={() => submit(DEFAULT_SCENARIO_ID)}
disabled={save.isPending || current === DEFAULT_SCENARIO_ID}
>
{save.isPending ? 'Saving…' : 'Reset to default'}
</Button>
</div>
</div>
)} )}
{missions.missions.map((mission) => (
<option key={mission.scenarioId} value={mission.scenarioId}>
{mission.name}
{missionSourceLabel(mission.source)
? ` [${missionSourceLabel(mission.source)}]`
: ''}
</option>
))}
</select>
) : (
<p className="text-xs text-slate-dim">
No scenario listing found in the current log make sure the server runs with
-listScenarios and has booted recently.
</p>
))}
</div> </div>
{message && <p className="mt-3 text-xs text-accent-400">{message}</p>} {message && <p className="mt-3 text-xs text-accent-400">{message}</p>}
</Card> </Card>
@@ -52,6 +52,7 @@ const NUMBER_FIELDS: { key: NumberKey; label: string; min: number; max: number;
]; ];
const BOOLEAN_FIELDS: { key: BooleanKey; label: string; hint: string }[] = [ 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: 'disableThirdPerson', label: 'Disable third person', hint: 'default disabled' },
{ key: 'fastValidation', label: 'Fast validation', hint: 'default enabled' }, { key: 'fastValidation', label: 'Fast validation', hint: 'default enabled' },
{ key: 'battlEye', label: 'BattlEye', hint: 'default enabled' }, { key: 'battlEye', label: 'BattlEye', hint: 'default enabled' },
+8 -2
View File
@@ -56,7 +56,11 @@ export function ModImage({ src, className = '' }: { src: string | null; classNam
} }
const STATUS_STYLES: Record<ServerStatus, { dot: string; text: string; label: string }> = { const STATUS_STYLES: Record<ServerStatus, { dot: string; text: string; label: string }> = {
online: { dot: 'bg-accent-400', text: 'text-accent-400', label: 'Online' }, 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' }, offline: { dot: 'bg-zinc-500', text: 'text-zinc-400', label: 'Offline' },
starting: { dot: 'bg-warn-400 animate-pulse', text: 'text-warn-400', label: 'Starting' }, 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' }, stopping: { dot: 'bg-warn-400 animate-pulse', text: 'text-warn-400', label: 'Stopping' },
@@ -135,12 +139,14 @@ export function Button({
disabled, disabled,
variant = 'default', variant = 'default',
title, title,
type = 'button',
}: { }: {
children: ReactNode; children: ReactNode;
onClick?: () => void; onClick?: () => void;
disabled?: boolean; disabled?: boolean;
variant?: 'default' | 'accent' | 'danger'; variant?: 'default' | 'accent' | 'danger';
title?: string; title?: string;
type?: 'button' | 'submit';
}) { }) {
const variants = { const variants = {
default: default:
@@ -150,7 +156,7 @@ export function Button({
} as const; } as const;
return ( return (
<button <button
type="button" type={type}
title={title} title={title}
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
+61 -24
View File
@@ -1,42 +1,60 @@
import { useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useRawLogs, useServers } from '../api/hooks.js'; import { useConsoleStream, useRawLogs, useServers } from '../api/hooks.js';
import { formatRelativeTime } from '../lib/format.js'; import { formatRelativeTime } from '../lib/format.js';
import { Button, Card, Spinner } from '../components/ui.js'; import { Button, Card, Spinner } from '../components/ui.js';
const MAX_STREAM_LINES = 1000;
export function LogsPage() { export function LogsPage() {
const { data: serversData } = useServers(); const { data: serversData } = useServers();
const slug = serversData?.servers[0]?.slug; const slug = serversData?.servers[0]?.slug;
const [mode, setMode] = useState<'stream' | 'poll'>('stream');
const [lines, setLines] = useState(300); const [lines, setLines] = useState(300);
const [autoRefresh, setAutoRefresh] = useState(true);
const [follow, setFollow] = useState(true); const [follow, setFollow] = useState(true);
const { data, isLoading, error, refetch, isFetching } = useRawLogs( const [streamLines, setStreamLines] = useState<string[]>([]);
slug ?? '',
lines,
autoRefresh,
slug !== undefined,
);
const viewportRef = useRef<HTMLPreElement | null>(null); 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(() => { useEffect(() => {
if (follow && viewportRef.current) { if (follow && viewportRef.current) {
viewportRef.current.scrollTop = viewportRef.current.scrollHeight; viewportRef.current.scrollTop = viewportRef.current.scrollHeight;
} }
}, [data, follow]); }, [streamLines, pollData, follow]);
if (!slug) return <Spinner />; if (!slug) return <Spinner />;
const title = mode === 'stream' ? (streamLines.length > 0 ? 'Live log' : 'console.log') : (pollData ? pollData.path : 'console.log');
return ( return (
<div className="w-full space-y-5"> <div className="w-full space-y-5">
<h1 className="page-title">Logs</h1> <h1 className="page-title">Logs</h1>
<Card <Card
title={data ? data.path : 'console.log'} title={title}
action={ action={
<div className="flex flex-wrap items-center justify-end gap-2"> <div className="flex flex-wrap items-center justify-end gap-2">
{data && ( {mode === 'poll' && pollData && (
<span className="text-xs text-slate-dim"> <span className="text-xs text-slate-dim">
fetched {formatRelativeTime(data.fetchedAt)} fetched {formatRelativeTime(pollData.fetchedAt)}
</span> </span>
)} )}
{mode === 'stream' && streamLines.length > 0 && (
<span className="text-xs text-slate-dim">
{streamLines.length} lines
</span>
)}
{mode === 'poll' && (
<select <select
value={lines} value={lines}
onChange={(event) => setLines(Number(event.target.value))} onChange={(event) => setLines(Number(event.target.value))}
@@ -48,12 +66,16 @@ export function LogsPage() {
</option> </option>
))} ))}
</select> </select>
)}
<Button <Button
variant={autoRefresh ? 'accent' : 'default'} variant={mode === 'stream' ? 'accent' : 'default'}
onClick={() => setAutoRefresh((v) => !v)} onClick={() => {
title="Refresh every 10 seconds" setStreamLines([]);
setMode((m) => (m === 'stream' ? 'poll' : 'stream'));
}}
title="Toggle between live SSE stream and 10s polling"
> >
{autoRefresh ? 'Auto: on' : 'Auto: off'} {mode === 'stream' ? 'Live' : 'Polling'}
</Button> </Button>
<Button <Button
variant={follow ? 'accent' : 'default'} variant={follow ? 'accent' : 'default'}
@@ -62,27 +84,42 @@ export function LogsPage() {
> >
{follow ? 'Follow' : 'Free scroll'} {follow ? 'Follow' : 'Free scroll'}
</Button> </Button>
{mode === 'poll' && (
<Button disabled={isFetching} onClick={() => void refetch()}> <Button disabled={isFetching} onClick={() => void refetch()}>
{isFetching ? '…' : 'Refresh'} {isFetching ? '…' : 'Refresh'}
</Button> </Button>
)}
</div> </div>
} }
> >
{isLoading ? ( {mode === 'stream' ? (
<Spinner label="Downloading log…" /> streamLines.length === 0 ? (
) : error ? ( <Spinner label="Connecting to console…" />
<p className="text-sm text-danger-400">{error.message}</p>
) : ( ) : (
<pre <pre
ref={viewportRef} 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" 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"
> >
{data?.lines.join('\n')} {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> </pre>
)} )}
<p className="mt-3 text-xs text-slate-dim"> <p className="mt-3 text-xs text-slate-dim">
Read-only tail of the current Reforger console log, downloaded through the Pterodactyl {mode === 'stream'
API. Visible to owner and server admins only. ? '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> </p>
</Card> </Card>
</div> </div>
+1274 -140
View File
File diff suppressed because it is too large. Load diff
+32 -1
View File
@@ -54,9 +54,13 @@ function Dashboard({ user, slug }: { user: CurrentUser; slug: string }) {
const memoryLimit = resources?.memoryLimitBytes ?? samples?.at(-1)?.memoryLimitBytes ?? null; const memoryLimit = resources?.memoryLimitBytes ?? samples?.at(-1)?.memoryLimitBytes ?? null;
const cpuLimit = resources?.cpuLimitPercent ?? samples?.at(-1)?.cpuLimitPercent ?? 100; const cpuLimit = resources?.cpuLimitPercent ?? samples?.at(-1)?.cpuLimitPercent ?? 100;
const diskUsed = resources?.diskBytes ?? null;
const diskLimit = resources?.diskLimitBytes ?? null;
const diskPercent = diskUsed !== null && diskLimit ? (diskUsed / diskLimit) * 100 : null;
return ( return (
<div className="w-full space-y-5"> <div className="w-full space-y-5">
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card title="CPU"> <Card title="CPU">
<p className="text-2xl font-semibold text-zinc-100"> <p className="text-2xl font-semibold text-zinc-100">
{resources ? `${resources.cpuPercent.toFixed(0)}%` : '—'} {resources ? `${resources.cpuPercent.toFixed(0)}%` : '—'}
@@ -127,6 +131,33 @@ function Dashboard({ user, slug }: { user: CurrentUser; slug: string }) {
]} ]}
/> />
</Card> </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>
</div> </div>
<div className="grid gap-5 lg:grid-cols-3"> <div className="grid gap-5 lg:grid-cols-3">
+1 -1
View File
@@ -42,7 +42,7 @@ function ConfigurationsBody({ slug, user }: { slug: string; user: CurrentUser })
<h1 className="page-title">Configuration</h1> <h1 className="page-title">Configuration</h1>
<MissionCard slug={slug} canEdit={canEdit} /> <MissionCard slug={slug} canEdit={canEdit} />
<PerformanceForm slug={slug} canEdit={canEdit} /> <PerformanceForm slug={slug} canEdit={canEdit} />
<SchedulesCard slug={slug} canEdit={canEdit} /> {/*<SchedulesCard slug={slug} canEdit={canEdit} />*/}
{canEdit && <StartupVarsCard slug={slug} />} {canEdit && <StartupVarsCard slug={slug} />}
<Card title="Full config summary (live from the server)"> <Card title="Full config summary (live from the server)">
{config ? <ConfigSummaryRows config={config} /> : <Spinner />} {config ? <ConfigSummaryRows config={config} /> : <Spinner />}
+1
View File
@@ -13,6 +13,7 @@ export type ReforgerServerConfig = {
serverName: string; serverName: string;
maxPlayers: number; maxPlayers: number;
scenarioId: string; scenarioId: string;
disableAI: boolean;
aiLimit: number; aiLimit: number;
serverMaxViewDistance: number; serverMaxViewDistance: number;
networkViewDistance: number; networkViewDistance: number;
+23 -3
View File
@@ -145,15 +145,15 @@ export type ConfigurationResponse = {
export type MissionInfo = { export type MissionInfo = {
scenarioId: string; scenarioId: string;
/** Display name from the startup scenario listing, e.g. "Conflict - Everon". */ /** Display name for the scenario, e.g. "Campaign - Montignac". */
name: string; name: string;
/** 'official' or the source section header from the log. */ /** 'official' or a mod source such as "mod: Scenario Pack". */
source: string; source: string;
}; };
export type MissionsResponse = { export type MissionsResponse = {
missions: MissionInfo[]; missions: MissionInfo[];
/** Null when the current log contains no scenario listing. */ /** Null when the source could not be checked. */
fetchedAt: string | null; fetchedAt: string | null;
}; };
@@ -236,6 +236,8 @@ export type ResourceSample = {
cpuLimitPercent: number | null; cpuLimitPercent: number | null;
memoryBytes: number; memoryBytes: number;
memoryLimitBytes: number | null; memoryLimitBytes: number | null;
diskBytes: number;
diskLimitBytes: number | null;
/** Bytes per second, derived from consecutive cumulative counters. */ /** Bytes per second, derived from consecutive cumulative counters. */
networkRxRate: number; networkRxRate: number;
networkTxRate: number; networkTxRate: number;
@@ -262,6 +264,7 @@ export type PerformanceSettings = {
disableThirdPerson: boolean | null; // game.gameProperties (default false) disableThirdPerson: boolean | null; // game.gameProperties (default false)
fastValidation: boolean | null; // game.gameProperties (default true) fastValidation: boolean | null; // game.gameProperties (default true)
battlEye: boolean | null; // game.gameProperties (default true) battlEye: boolean | null; // game.gameProperties (default true)
disableAI: boolean | null; // operating (default false)
aiLimit: number | null; // operating, -1 = unlimited (default -1) aiLimit: number | null; // operating, -1 = unlimited (default -1)
playerSaveTime: number | null; // operating, seconds (default 120) playerSaveTime: number | null; // operating, seconds (default 120)
slotReservationTimeout: number | null; // operating, 5300 s (default 60) slotReservationTimeout: number | null; // operating, 5300 s (default 60)
@@ -297,6 +300,20 @@ export type ServerModsResponse = {
fetchedAt: string; fetchedAt: string;
}; };
export type ModDependencyIssue = {
modId: string;
modName: string | null;
missing: Array<{ id: string | null; name: string }>;
};
export type ModsCheckResponse = {
modsWithMissingVersions: string[];
modsWithMissingDeps: ModDependencyIssue[];
/** Non-null when the server's configured scenarioId is not in any known mission source. */
orphanedMission: { scenarioId: string; name: string | null } | null;
checkedAt: string;
};
export type UpdateModsResult = ServerModsResponse & { export type UpdateModsResult = ServerModsResponse & {
added: number; added: number;
removed: number; removed: number;
@@ -321,6 +338,9 @@ export type WorkshopModPreview = {
size: string | null; size: string | null;
rating: string | null; rating: string | null;
workshopUrl: string | null; workshopUrl: string | null;
version: string | null;
summary: string | null;
tags: string[];
}; };
export type WorkshopSearchResponse = { export type WorkshopSearchResponse = {