From 8748807d1fbbdf8ac857db1155bfe22ab50769e1 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Tue, 7 Jul 2026 18:31:53 -0700 Subject: [PATCH 1/7] better dependecy management --- apps/web/src/pages/mods.tsx | 79 ++++++++++++++++++++++++++++++------- 1 file changed, 65 insertions(+), 14 deletions(-) diff --git a/apps/web/src/pages/mods.tsx b/apps/web/src/pages/mods.tsx index 692cfc0..7e6c58d 100644 --- a/apps/web/src/pages/mods.tsx +++ b/apps/web/src/pages/mods.tsx @@ -45,6 +45,7 @@ function ModsBody({ slug, user }: { slug: string; user: CurrentUser }) { const save = useSetServerMods(slug); const [draft, setDraft] = useState(null); const [message, setMessage] = useState(null); + const [selectedModId, setSelectedModId] = useState(null); const serverMods = data?.mods ?? []; const mods = draft ?? serverMods; @@ -135,11 +136,14 @@ function ModsBody({ slug, user }: { slug: string; user: CurrentUser }) { {mod.version ? ` · v${mod.version}` : ' · latest version'}

- {canManage && ( - - )} +
+ + {canManage && ( + + )} +
))} @@ -151,7 +155,13 @@ function ModsBody({ slug, user }: { slug: string; user: CurrentUser }) {

- + ); } @@ -160,17 +170,20 @@ function WorkshopBrowser({ canManage, installedIds, onAdd, + selectedModId, + setSelectedModId, }: { canManage: boolean; installedIds: Set; onAdd: (mod: ReforgerConfigMod) => void; + selectedModId: string | null; + setSelectedModId: (id: string | null) => void; }) { const [input, setInput] = useState(''); const [query, setQuery] = useState(''); const [activeTag, setActiveTag] = useState(null); const [sort, setSort] = useState<(typeof WORKSHOP_SORTS)[number]['value']>('popularity'); const [page, setPage] = useState(1); - const [selectedModId, setSelectedModId] = useState(null); const [addingId, setAddingId] = useState(null); const effectiveQuery = [query, activeTag].filter(Boolean).join(' '); const { data, isFetching, error } = useWorkshopSearch(effectiveQuery, page, sort); @@ -352,6 +365,24 @@ function ModDetailPanel({ onTagSelect: (tag: string) => void; }) { const { data: mod, isLoading } = useWorkshopMod(modId); + const [addingDepId, setAddingDepId] = useState(null); + + const addDep = async (depId: string, depName: string) => { + setAddingDepId(depId); + try { + const detail = await api.get(`/api/workshop/mods/${depId}`); + onAdd({ + modId: detail.id, + name: detail.name || depName, + ...(detail.version ? { version: detail.version } : {}), + }); + } catch { + onAdd({ modId: depId, name: depName }); + } finally { + setAddingDepId(null); + } + }; + if (!modId) { return (
@@ -393,13 +424,33 @@ function ModDetailPanel({ )} {mod.dependencies.length > 0 && (
-

- Dependencies (add these too) -

-
    - {mod.dependencies.map((dep) => ( -
  • {dep.name}
  • - ))} +

    Dependencies

    +
      + {mod.dependencies.map((dep) => { + const depInstalled = dep.id ? installedIds.has(dep.id.toUpperCase()) : false; + return ( +
    • + + {dep.name} + {depInstalled && ( + Installed + )} + + {canManage && dep.id && !depInstalled && ( + + )} +
    • + ); + })}
)} From 9311bc603aef1770ccf1578784d52fe7a3f560bf Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Tue, 7 Jul 2026 19:49:39 -0700 Subject: [PATCH 2/7] dependencies require version num --- apps/api/src/modules/servers/server-routes.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/api/src/modules/servers/server-routes.ts b/apps/api/src/modules/servers/server-routes.ts index 77090ae..3f53096 100644 --- a/apps/api/src/modules/servers/server-routes.ts +++ b/apps/api/src/modules/servers/server-routes.ts @@ -526,7 +526,21 @@ export function createServerRouter(deps: ServerRouterDeps): Router { 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!; await service.recordActivity({ serverId: server.id, From 5f22548ba63e04ab0f4c4b9d5e162a750ea44295 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Tue, 7 Jul 2026 19:58:11 -0700 Subject: [PATCH 3/7] resolve mobile search mod button --- apps/web/src/components/ui.tsx | 4 +++- apps/web/src/pages/mods.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ui.tsx b/apps/web/src/components/ui.tsx index 401260a..a262cc5 100644 --- a/apps/web/src/components/ui.tsx +++ b/apps/web/src/components/ui.tsx @@ -135,12 +135,14 @@ export function Button({ disabled, variant = 'default', title, + type = 'button', }: { children: ReactNode; onClick?: () => void; disabled?: boolean; variant?: 'default' | 'accent' | 'danger'; title?: string; + type?: 'button' | 'submit'; }) { const variants = { default: @@ -150,7 +152,7 @@ export function Button({ } as const; return ( From 0d61329e7c3b8bc2fc8063b26dae2b0d9c614d48 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Wed, 8 Jul 2026 11:43:48 -0700 Subject: [PATCH 4/7] enhanced mod menu + simplify scenario select --- .../src/modules/pterodactyl/mock-provider.ts | 2 +- .../reforger-logs/missions-catalog.test.ts | 13 +- .../modules/reforger-logs/missions-catalog.ts | 21 + .../src/modules/servers/resource-history.ts | 4 + apps/api/src/modules/servers/server-routes.ts | 196 ++- .../modules/workshop/workshop-client.test.ts | 49 +- .../src/modules/workshop/workshop-client.ts | 125 +- apps/web/src/api/hooks.ts | 40 +- apps/web/src/components/mission-card.tsx | 77 +- apps/web/src/components/ui.tsx | 6 +- apps/web/src/pages/logs.tsx | 111 +- apps/web/src/pages/mods.tsx | 1557 ++++++++++++++--- apps/web/src/pages/overview.tsx | 33 +- packages/shared/src/types.ts | 25 +- 14 files changed, 1799 insertions(+), 460 deletions(-) diff --git a/apps/api/src/modules/pterodactyl/mock-provider.ts b/apps/api/src/modules/pterodactyl/mock-provider.ts index 47a3b3a..22b04cd 100644 --- a/apps/api/src/modules/pterodactyl/mock-provider.ts +++ b/apps/api/src/modules/pterodactyl/mock-provider.ts @@ -116,7 +116,7 @@ export class MockGameServerProvider implements GameServerProvider { bindPort: 2001, game: { name: 'Mock Reforger Server', - scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf', + scenarioId: '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf', maxPlayers: 16, crossPlatform: true, gameProperties: { diff --git a/apps/api/src/modules/reforger-logs/missions-catalog.test.ts b/apps/api/src/modules/reforger-logs/missions-catalog.test.ts index 5f06103..11d138c 100644 --- a/apps/api/src/modules/reforger-logs/missions-catalog.test.ts +++ b/apps/api/src/modules/reforger-logs/missions-catalog.test.ts @@ -1,5 +1,10 @@ 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). const LOG = [ @@ -41,6 +46,12 @@ 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); + }); + it('converts mod scenarios into mission entries', () => { const missions = scenariosFromWorkshopMod({ id: 'ABC', diff --git a/apps/api/src/modules/reforger-logs/missions-catalog.ts b/apps/api/src/modules/reforger-logs/missions-catalog.ts index f51dcce..a2e9cb0 100644 --- a/apps/api/src/modules/reforger-logs/missions-catalog.ts +++ b/apps/api/src/modules/reforger-logs/missions-catalog.ts @@ -4,6 +4,14 @@ 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']); /** * 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[] { const merged: MissionInfo[] = []; const seen = new Set(); diff --git a/apps/api/src/modules/servers/resource-history.ts b/apps/api/src/modules/servers/resource-history.ts index 70138f2..10033eb 100644 --- a/apps/api/src/modules/servers/resource-history.ts +++ b/apps/api/src/modules/servers/resource-history.ts @@ -50,6 +50,8 @@ export class ResourceHistoryService { cpuLimitPercent: null, memoryBytes: 0, memoryLimitBytes: null, + diskBytes: 0, + diskLimitBytes: null, networkRxRate: 0, networkTxRate: 0, rxTotal: -1, @@ -80,6 +82,8 @@ export class ResourceHistoryService { cpuLimitPercent: resources.cpuLimitPercent, memoryBytes: resources.memoryBytes, memoryLimitBytes: resources.memoryLimitBytes, + diskBytes: resources.diskBytes, + diskLimitBytes: resources.diskLimitBytes, networkRxRate: Math.round(networkRxRate), networkTxRate: Math.round(networkTxRate), rxTotal: resources.networkRxBytes, diff --git a/apps/api/src/modules/servers/server-routes.ts b/apps/api/src/modules/servers/server-routes.ts index 3f53096..c1c68c4 100644 --- a/apps/api/src/modules/servers/server-routes.ts +++ b/apps/api/src/modules/servers/server-routes.ts @@ -2,6 +2,8 @@ import { Router } from 'express'; import { z } from 'zod'; import type { LogIngestionHealth, + MissionInfo, + ModDependencyIssue, ServerResources, ServerStatus, 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 { IngestionScheduler, ScheduledServer } from '../reforger-logs/ingestion/scheduler.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 { WorkshopClient } from '../workshop/workshop-client.js'; @@ -46,7 +54,8 @@ const performanceBodySchema = z .string() .trim() .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(), maxPlayers: z.number().int().min(1).max(128).nullable(), serverMaxViewDistance: z.number().int().min(500).max(10000).nullable(), @@ -290,31 +299,121 @@ export function createServerRouter(deps: ServerRouterDeps): Router { router.get('/:slug/missions', async (req, res, next) => { try { 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) { - 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( - 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) { - if (result.status === 'fulfilled') { - modMissions.push(...scenariosFromWorkshopMod(result.value)); + if (result.status !== 'fulfilled') continue; + 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({ - missions: mergeMissions(logMissions, modMissions), - fetchedAt: new Date().toISOString(), + missions: mergeMissions([DEFAULT_MISSION], modMissions), + fetchedAt: scenarioLookupComplete ? new Date().toISOString() : null, }); } catch (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((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( '/:slug/logs/raw', requireCapability('ops.health.view', 'Raw logs are restricted.'), @@ -506,6 +605,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( '/:slug/mods', syncRateLimit, diff --git a/apps/api/src/modules/workshop/workshop-client.test.ts b/apps/api/src/modules/workshop/workshop-client.test.ts index 7d6254f..3b73bb4 100644 --- a/apps/api/src/modules/workshop/workshop-client.test.ts +++ b/apps/api/src/modules/workshop/workshop-client.test.ts @@ -59,8 +59,8 @@ describe('normalizeImageUrl', () => { }); }); -describe('WorkshopClient image enrichment', () => { - it('warms list images from the detail endpoint in the background and caches them', async () => { +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/')) { @@ -76,26 +76,22 @@ describe('WorkshopClient image enrichment', () => { const first = await client.search('', 1); expect(first.mods[0]!.imageUrl).toBeNull(); - await vi.waitFor(() => { - const detailCalls = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/')); - expect(detailCalls).toHaveLength(1); - }); + expect(first.mods[0]!.version).toBeNull(); const detailCalls = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/')); - expect(detailCalls).toHaveLength(1); + expect(detailCalls).toHaveLength(0); - // 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); 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); }); - 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 path = String(url); - if (path.includes('/v1/mod/')) { - return new Response('nope', { status: 500 }); - } return new Response(JSON.stringify(listResponse()), { status: 200 }); }); const client = new WorkshopClient({ @@ -105,4 +101,31 @@ describe('WorkshopClient image enrichment', () => { const result = await client.search('', 1); 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, + }); + }); }); diff --git a/apps/api/src/modules/workshop/workshop-client.ts b/apps/api/src/modules/workshop/workshop-client.ts index 8dc83fe..a2969ec 100644 --- a/apps/api/src/modules/workshop/workshop-client.ts +++ b/apps/api/src/modules/workshop/workshop-client.ts @@ -25,6 +25,9 @@ const modPreviewSchema = z.object({ size: z.string().catch(''), rating: z.string().catch(''), ID: z.string(), + version: z.string().nullish(), + summary: z.string().nullish(), + tags: z.array(z.string()).catch([]), }); const searchResponseSchema = z.object({ @@ -61,7 +64,7 @@ const modDetailSchema = z.object({ z.object({ name: z.string(), description: z.string().catch(''), - scenarioID: z.string(), + scenarioID: z.string().catch(''), gamemode: z.string().catch(''), playerCount: z.number().catch(0), imageURL: z.string().catch(''), @@ -79,6 +82,23 @@ function extractModId(apiModUrl: string): string | 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 { + 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 * via.placeholder.com stubs, and detail endpoints sometimes concatenate two @@ -101,18 +121,29 @@ function toPreview(mod: z.infer): WorkshopModPreview { size: mod.size || null, rating: mod.rating || 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 PREVIEW_CACHE_TTL_MS = 60 * 60 * 1000; // matches upstream's 1 h detail cache export class WorkshopClient { private readonly baseUrl: string; private readonly fetchImpl: typeof fetch; private readonly timeoutMs: number; - /** modId → real image URL (or null when the mod has none). */ - private imageCache = new Map(); + /** 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; + } + >(); constructor(options: { baseUrl: string; fetchImpl?: typeof fetch; timeoutMs?: number }) { this.baseUrl = options.baseUrl.replace(/\/$/, ''); @@ -175,67 +206,22 @@ export class WorkshopClient { throw ApiError.upstream('Workshop API returned an unexpected response shape.'); } const mods = parsed.data.data.map(toPreview); - this.applyCachedImages(mods); - void this.enrichImages(mods).catch(() => undefined); + this.applyCachedPreviews(mods); return { mods, meta: parsed.data.meta, }; } - private applyCachedImages(mods: WorkshopModPreview[]): void { + private applyCachedPreviews(mods: WorkshopModPreview[]): void { const now = Date.now(); for (const mod of mods) { - if (mod.imageUrl) continue; - const cached = this.imageCache.get(mod.id); + const cached = this.previewCache.get(mod.id); if (cached && cached.expiresAt > now) { - mod.imageUrl = cached.url; - } - } - } - - /** - * 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 { - 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); + 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; } } } @@ -247,7 +233,7 @@ export class WorkshopClient { throw ApiError.upstream('Workshop API returned an unexpected response shape.'); } const mod = parsed.data.mod; - return { + const detail = { id: mod.id, name: mod.name, author: mod.author, @@ -272,11 +258,30 @@ export class WorkshopClient { scenarios: mod.scenarios.map((scenario) => ({ name: scenario.name, description: scenario.description || null, - scenarioId: scenario.scenarioID, - gamemode: scenario.gamemode || null, + scenarioId: extractScenarioId( + scenario.scenarioID, + scenario.gamemode, + scenario.description, + scenario.name, + ), + gamemode: cleanScenarioText(scenario.gamemode), playerCount: scenario.playerCount || null, 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; } } diff --git a/apps/web/src/api/hooks.ts b/apps/web/src/api/hooks.ts index 9bc32e4..e0654f9 100644 --- a/apps/web/src/api/hooks.ts +++ b/apps/web/src/api/hooks.ts @@ -1,3 +1,4 @@ +import { useEffect, useRef } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { ActivityItem, @@ -6,6 +7,7 @@ import type { InviteSummary, KillfeedEvent, MissionsResponse, + ModsCheckResponse, PerformanceSettingsPatch, PerformanceSettingsResponse, RawLogsResponse, @@ -121,11 +123,35 @@ export function useMissions(slug: string) { return useQuery({ queryKey: ['servers', slug, 'missions'], queryFn: () => api.get(`/api/servers/${slug}/missions`), - staleTime: 5 * 60_000, - refetchOnWindowFocus: false, + staleTime: 60_000, }); } +/** + * 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) => { + 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) { return useQuery({ queryKey: ['servers', slug, 'logs', 'raw', lines], @@ -264,6 +290,16 @@ export function useSetServerMods(slug: string) { }); } +export function useServerModsCheck(slug: string, enabled: boolean) { + return useQuery({ + queryKey: ['servers', slug, 'mods', 'check'], + queryFn: () => api.get(`/api/servers/${slug}/mods/check`), + enabled, + staleTime: 2 * 60_000, + refetchOnWindowFocus: false, + }); +} + export function useManualLogSync(slug: string) { const queryClient = useQueryClient(); return useMutation({ diff --git a/apps/web/src/components/mission-card.tsx b/apps/web/src/components/mission-card.tsx index 5a4c97c..2bc2726 100644 --- a/apps/web/src/components/mission-card.tsx +++ b/apps/web/src/components/mission-card.tsx @@ -1,21 +1,17 @@ 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 { shortScenario } from './widgets.js'; -function missionSourceLabel(source: string): string { - if (source === 'official') return ''; - if (source.startsWith('mod: ')) return `Mod: ${source.slice(5)}`; - return source; -} +const DEFAULT_SCENARIO_ID = '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf'; +const DEFAULT_SCENARIO_NAME = 'Campaign - Montignac (default)'; /** - * Mission switcher. Options come from the scenario listing the server prints - * at boot (requires the -listScenarios launch flag, standard on Reforger eggs). + * 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. */ export function MissionCard({ slug, canEdit }: { slug: string; canEdit: boolean }) { const { data: config, refetch } = useConfiguration(slug); - const { data: missions } = useMissions(slug); const save = useSetPerformanceSettings(slug); const [selected, setSelected] = useState(null); const [message, setMessage] = useState(null); @@ -29,15 +25,13 @@ export function MissionCard({ slug, canEdit }: { slug: string; canEdit: boolean } const current = config.config.scenarioId; - const currentName = - missions?.missions.find((m) => m.scenarioId === current)?.name ?? shortScenario(current); const value = selected ?? current; const dirty = value !== current; - const submit = () => { + const submit = (scenarioIdOverride?: string) => { setMessage(null); save.mutate( - { scenarioId: value }, + { scenarioId: scenarioIdOverride ?? value }, { onSuccess: () => { setSelected(null); @@ -59,48 +53,51 @@ export function MissionCard({ slug, canEdit }: { slug: string; canEdit: boolean -
) } > -
+
-

{currentName}

+

{shortScenario(current)}

- {shortScenario(current)} + {current}

- {canEdit && - (missions && missions.missions.length > 0 ? ( - { setMessage(null); setSelected(event.target.value); }} - className="input max-w-xs" - > - {!missions.missions.some((m) => m.scenarioId === current) && ( - - )} - {missions.missions.map((mission) => ( - - ))} - - ) : ( -

- No scenario listing found in the current log — make sure the server runs with - -listScenarios and has booted recently. -

- ))} + placeholder="{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf" + className="input w-full font-mono text-xs" + /> +
+ + +
+
+ )}
{message &&

{message}

} diff --git a/apps/web/src/components/ui.tsx b/apps/web/src/components/ui.tsx index a262cc5..4009fcf 100644 --- a/apps/web/src/components/ui.tsx +++ b/apps/web/src/components/ui.tsx @@ -56,7 +56,11 @@ export function ModImage({ src, className = '' }: { src: string | null; classNam } const STATUS_STYLES: Record = { - 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' }, 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' }, diff --git a/apps/web/src/pages/logs.tsx b/apps/web/src/pages/logs.tsx index 7e61685..a935f63 100644 --- a/apps/web/src/pages/logs.tsx +++ b/apps/web/src/pages/logs.tsx @@ -1,59 +1,81 @@ -import { useEffect, useRef, useState } from 'react'; -import { useRawLogs, useServers } from '../api/hooks.js'; +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 [autoRefresh, setAutoRefresh] = useState(true); const [follow, setFollow] = useState(true); - const { data, isLoading, error, refetch, isFetching } = useRawLogs( - slug ?? '', - lines, - autoRefresh, - slug !== undefined, - ); + const [streamLines, setStreamLines] = useState([]); const viewportRef = useRef(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; } - }, [data, follow]); + }, [streamLines, pollData, follow]); if (!slug) return ; + const title = mode === 'stream' ? (streamLines.length > 0 ? 'Live log' : 'console.log') : (pollData ? pollData.path : 'console.log'); + return (

Logs

- {data && ( + {mode === 'poll' && pollData && ( - fetched {formatRelativeTime(data.fetchedAt)} + fetched {formatRelativeTime(pollData.fetchedAt)} )} - + {mode === 'stream' && streamLines.length > 0 && ( + + {streamLines.length} lines + + )} + {mode === 'poll' && ( + + )} - + {mode === 'poll' && ( + + )}
} > - {isLoading ? ( + {mode === 'stream' ? ( + streamLines.length === 0 ? ( + + ) : ( +
+              {streamLines.join('\n')}
+            
+ ) + ) : pollLoading ? ( - ) : error ? ( -

{error.message}

+ ) : pollError ? ( +

{pollError.message}

) : (
-            {data?.lines.join('\n')}
+            {pollData?.lines.join('\n')}
           
)}

- Read-only tail of the current Reforger console log, downloaded through the Pterodactyl - API. Visible to owner and server admins only. + {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.

diff --git a/apps/web/src/pages/mods.tsx b/apps/web/src/pages/mods.tsx index 8c67c93..819a31c 100644 --- a/apps/web/src/pages/mods.tsx +++ b/apps/web/src/pages/mods.tsx @@ -1,9 +1,20 @@ -import { useState } from 'react'; -import type { CurrentUser, ReforgerConfigMod, WorkshopModDetail } from '@reforger-panel/shared'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import type { + CurrentUser, + ModDependencyIssue, + ModsCheckResponse, + ReforgerConfigMod, + WorkshopModDetail, + WorkshopModPreview, +} from '@reforger-panel/shared'; import { api } from '../api/client.js'; import { + useConfiguration, useServerMods, + useServerModsCheck, useServers, + useSetPerformanceSettings, useSetServerMods, useWorkshopMod, useWorkshopSearch, @@ -12,6 +23,9 @@ import { formatRelativeTime } from '../lib/format.js'; import { Button, Card, EmptyState, ModImage, Spinner } from '../components/ui.js'; const COMMON_WORKSHOP_TAGS = [ + 'SCENARIO', + 'SCENARIOS_MP', + 'SCENARIOS_SP', 'WEAPONS', 'VEHICLES', 'MISSIONS', @@ -28,6 +42,19 @@ const WORKSHOP_SORTS = [ { value: 'version_size', label: 'Size' }, ] as const; +const WORKSHOP_MOD_DETAIL_STALE_MS = 5 * 60_000; +const WORKSHOP_DETAIL_BATCH_INTERVAL_MS = 1_000; +const WORKSHOP_DETAIL_BATCH_SIZE = 4; +const WORKSHOP_DETAIL_BURST_CAPACITY = 20; +const WORKSHOP_DETAIL_FAILURE_COOLDOWN_MS = 60_000; +const WORKSHOP_DETAIL_PREFETCH_MARGIN = '500px'; + +const workshopDetailLimiter = { + inFlight: false, + burstTokens: WORKSHOP_DETAIL_BURST_CAPACITY, + lastTokenRefillAt: Date.now(), +}; + export function ModsPage({ user }: { user: CurrentUser }) { const { data: serversData } = useServers(); const slug = serversData?.servers[0]?.slug; @@ -39,145 +66,717 @@ function sameMods(a: ReforgerConfigMod[], b: ReforgerConfigMod[]): boolean { return JSON.stringify(a) === JSON.stringify(b); } +type ModsTab = 'installed' | 'browse'; + +const DEFAULT_SCENARIO_ID = '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf'; + function ModsBody({ slug, user }: { slug: string; user: CurrentUser }) { const canManage = user.capabilities.includes('mods.manage'); + const canEditConfig = user.capabilities.includes('config.edit'); const { data, isLoading, error, refetch } = useServerMods(slug); + const { data: configData } = useConfiguration(slug); + const currentScenarioId = configData?.config.scenarioId ?? null; const save = useSetServerMods(slug); + const savePerf = useSetPerformanceSettings(slug); const [draft, setDraft] = useState(null); const [message, setMessage] = useState(null); + const [resetMissionMessage, setResetMissionMessage] = useState(null); const [selectedModId, setSelectedModId] = useState(null); + const [activeTab, setActiveTab] = useState('installed'); + const [checkEnabled, setCheckEnabled] = useState(false); + const checkQuery = useServerModsCheck(slug, checkEnabled); const serverMods = data?.mods ?? []; const mods = draft ?? serverMods; const dirty = draft !== null && !sameMods(draft, serverMods); const installedIds = new Set(mods.map((mod) => mod.modId.toUpperCase())); + const missingVersionIds = new Set(mods.filter((m) => !m.version).map((m) => m.modId)); + const addMod = (mod: ReforgerConfigMod) => { if (installedIds.has(mod.modId.toUpperCase())) return; setMessage(null); setDraft([...mods, mod]); }; + const addMods = (newMods: ReforgerConfigMod[]) => { + const toAdd = newMods.filter((m) => !installedIds.has(m.modId.toUpperCase())); + if (toAdd.length === 0) return; + setMessage(null); + setDraft([...mods, ...toAdd]); + }; + const removeMod = (modId: string) => { setMessage(null); + if (selectedModId === modId) setSelectedModId(null); setDraft(mods.filter((mod) => mod.modId !== modId)); }; + const updateModVersion = (modId: string, version: string) => { + setMessage(null); + const normalized = version.trim(); + setDraft( + mods.map((mod) => + mod.modId === modId + ? { + ...mod, + ...(normalized ? { version: normalized } : { version: undefined }), + } + : mod, + ), + ); + }; + const saveMods = () => { setMessage(null); save.mutate(mods, { onSuccess: (result) => { setDraft(null); - setMessage( - `Saved to config.json — ${result.added} added, ${result.removed} removed. ` + - 'Restart the server to apply.', - ); + setMessage(`Saved — ${result.added} added, ${result.removed} removed. Restart to apply.`); + setCheckEnabled(false); void refetch(); }, onError: (saveError) => setMessage(saveError.message), }); }; + const patchVersions = () => { + setMessage(null); + save.mutate(mods, { + onSuccess: () => { + setDraft(null); + setMessage('Version info patched. Restart to apply.'); + void refetch(); + }, + onError: (saveError) => setMessage(saveError.message), + }); + }; + + const resetMission = () => { + setResetMissionMessage(null); + savePerf.mutate( + { scenarioId: DEFAULT_SCENARIO_ID }, + { + onSuccess: () => + setResetMissionMessage('Mission reset to Campaign - Montignac. Restart to apply.'), + onError: (err) => setResetMissionMessage(err.message), + }, + ); + }; + + const runCheck = () => { + if (dirty) { + setMessage('Save your changes before checking dependencies.'); + return; + } + setCheckEnabled(true); + if (checkQuery.isFetching) return; + void checkQuery.refetch(); + }; + return ( -
+

Mods

- Review the live server mod list, stage changes, and pull metadata from the Reforger - Workshop before saving config.json. + Manage the server mod list and browse the Reforger Workshop. Changes apply on the next + server restart.

- - {data && !dirty && ( - - fetched {formatRelativeTime(data.fetchedAt)} - - )} - {dirty && ( - <> - unsaved changes - - - - )} -
- } - > - {isLoading ? ( - - ) : error ? ( -

{error.message}

- ) : mods.length === 0 ? ( - - ) : ( -
    - {mods.map((mod) => ( -
  • -
    -

    - {mod.name ?? mod.modId} -

    -

    - {mod.modId} - {mod.version ? ` · v${mod.version}` : ' · latest version'} -

    -
    -
    - - {canManage && ( - - )} -
    -
  • - ))} -
- )} - {message &&

{message}

} -

- Changes are written directly to the server's config.json (a config.json.bak backup is - kept) and take effect on the next server restart. -

- +
+ {(['installed', 'browse'] as ModsTab[]).map((tab) => ( + + ))} +
- + {activeTab === 'installed' ? ( +
+ { + setDraft(null); + setMessage(null); + }} + onPatchVersions={patchVersions} + onCheckDeps={runCheck} + onAddMods={addMods} + onResetMission={resetMission} + /> +
+ ) : ( + void addAllDeps(deps, addMods)} + currentScenarioId={currentScenarioId} + /> + )}
); } +async function addAllDeps( + deps: Array<{ id: string | null; name: string }>, + addMods: (mods: ReforgerConfigMod[]) => void, +) { + const resolved = await Promise.allSettled( + deps + .filter((d) => d.id) + .map(async (d): Promise => { + try { + const detail = await api.get(`/api/workshop/mods/${d.id}`); + return { + modId: detail.id, + name: detail.name, + ...(detail.version ? { version: detail.version } : {}), + }; + } catch { + return { modId: d.id!, name: d.name }; + } + }), + ); + const mods = resolved + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + .map((r) => r.value); + addMods(mods); +} + +function workshopModQueryKey(modId: string) { + return ['workshop', 'mod', modId] as const; +} + +function useQueuedWorkshopModDetails(modIds: string[], visibleModIds: Set) { + const queryClient = useQueryClient(); + const failedUntilRef = useRef(new Map()); + const [loadingIds, setLoadingIds] = useState>(() => new Set()); + const [cacheVersion, setCacheVersion] = useState(0); + const modIdsKey = useMemo(() => modIds.join('|'), [modIds]); + const visibleKey = useMemo( + () => [...visibleModIds].sort((a, b) => a.localeCompare(b)).join('|'), + [visibleModIds], + ); + + useEffect(() => { + if (workshopDetailLimiter.inFlight) return; + const now = Date.now(); + const elapsedMs = now - workshopDetailLimiter.lastTokenRefillAt; + if (elapsedMs > 0) { + workshopDetailLimiter.burstTokens = Math.min( + WORKSHOP_DETAIL_BURST_CAPACITY, + workshopDetailLimiter.burstTokens + elapsedMs / 1000, + ); + workshopDetailLimiter.lastTokenRefillAt = now; + } + + const tokenCount = Math.floor(workshopDetailLimiter.burstTokens); + const batch = modIds + .filter((modId) => { + if (!visibleModIds.has(modId)) return false; + if ((failedUntilRef.current.get(modId) ?? 0) > now) return false; + return !queryClient.getQueryData(workshopModQueryKey(modId)); + }) + .slice(0, Math.min(WORKSHOP_DETAIL_BATCH_SIZE, tokenCount)); + + if (batch.length === 0) { + const timer = window.setTimeout( + () => setCacheVersion((version) => version + 1), + WORKSHOP_DETAIL_BATCH_INTERVAL_MS, + ); + return () => window.clearTimeout(timer); + } + + const timer = window.setTimeout(() => { + workshopDetailLimiter.burstTokens = Math.max( + 0, + workshopDetailLimiter.burstTokens - batch.length, + ); + workshopDetailLimiter.inFlight = true; + setLoadingIds(new Set(batch.map((modId) => modId.toUpperCase()))); + Promise.allSettled( + batch.map((modId) => + queryClient.fetchQuery({ + queryKey: workshopModQueryKey(modId), + queryFn: () => api.get(`/api/workshop/mods/${modId}`), + staleTime: WORKSHOP_MOD_DETAIL_STALE_MS, + retry: false, + }), + ), + ) + .then((results) => { + for (let i = 0; i < results.length; i++) { + if (results[i]?.status === 'rejected') { + failedUntilRef.current.set( + batch[i]!, + Date.now() + WORKSHOP_DETAIL_FAILURE_COOLDOWN_MS, + ); + } + } + }) + .finally(() => { + workshopDetailLimiter.inFlight = false; + setLoadingIds(new Set()); + setCacheVersion((version) => version + 1); + }); + }, WORKSHOP_DETAIL_BATCH_INTERVAL_MS); + + return () => window.clearTimeout(timer); + }, [cacheVersion, modIds, modIdsKey, queryClient, visibleKey, visibleModIds]); + + const detailByModId = useMemo(() => { + const details = new Map(); + for (const modId of modIds) { + const detail = queryClient.getQueryData(workshopModQueryKey(modId)); + if (detail) details.set(modId.toUpperCase(), detail); + } + return details; + }, [cacheVersion, modIds, queryClient]); + + return { detailByModId, loadingDetailIds: loadingIds }; +} + +// ── Installed Mods Panel ────────────────────────────────────────────────────── + +function InstalledModsPanel({ + mods, + canManage, + canEditConfig, + dirty, + isSaving, + isResettingMission, + missingVersionIds, + selectedModId, + fetchedAt, + isLoading, + loadError, + message, + resetMissionMessage, + checkResult, + isChecking, + installedIds, + currentScenarioId, + onSelectMod, + onRemoveMod, + onUpdateModVersion, + onSave, + onDiscard, + onPatchVersions, + onCheckDeps, + onAddMods, + onResetMission, +}: { + mods: ReforgerConfigMod[]; + canManage: boolean; + canEditConfig: boolean; + dirty: boolean; + isSaving: boolean; + isResettingMission: boolean; + missingVersionIds: Set; + selectedModId: string | null; + fetchedAt?: string; + isLoading: boolean; + loadError: string | null; + message: string | null; + resetMissionMessage: string | null; + checkResult: ModsCheckResponse | null; + isChecking: boolean; + installedIds: Set; + currentScenarioId: string | null; + onSelectMod: (id: string | null) => void; + onRemoveMod: (id: string) => void; + onUpdateModVersion: (id: string, version: string) => void; + onSave: () => void; + onDiscard: () => void; + onPatchVersions: () => void; + onCheckDeps: () => void; + onAddMods: (mods: ReforgerConfigMod[]) => void; + onResetMission: () => void; +}) { + const missingDepsByModId = new Map(); + if (checkResult) { + for (const issue of checkResult.modsWithMissingDeps) { + missingDepsByModId.set(issue.modId.toUpperCase(), issue); + } + } + + const allMissingDeps = checkResult + ? checkResult.modsWithMissingDeps.flatMap((issue) => issue.missing) + : []; + const uniqueMissingDeps = [ + ...new Map(allMissingDeps.filter((d) => d.id).map((d) => [d.id, d])).values(), + ]; + const [visibleModIds, setVisibleModIds] = useState>(() => new Set()); + const installedModIds = useMemo(() => mods.map((mod) => mod.modId), [mods]); + const { detailByModId, loadingDetailIds } = useQueuedWorkshopModDetails( + installedModIds, + visibleModIds, + ); + const handleInstalledModVisibility = useCallback((modId: string, visible: boolean) => { + setVisibleModIds((current) => { + const next = new Set(current); + if (visible) { + next.add(modId); + } else { + next.delete(modId); + } + return next; + }); + }, []); + const selectedInstalledModId = mods.some((mod) => mod.modId === selectedModId) + ? selectedModId + : null; + + return ( + 0 ? ` (${mods.length})` : ''}`} + action={ +
+ {!dirty && fetchedAt && ( + fetched {formatRelativeTime(fetchedAt)} + )} + {dirty && ( + <> + unsaved changes + + + + )} +
+ } + > + {isLoading ? ( + + ) : loadError ? ( +

{loadError}

+ ) : mods.length === 0 ? ( + + ) : ( +
+
+ {mods.map((mod) => { + const modIdKey = mod.modId.toUpperCase(); + return ( + onSelectMod(selectedModId === mod.modId ? null : mod.modId)} + onRemove={() => onRemoveMod(mod.modId)} + onUpdateVersion={(version) => onUpdateModVersion(mod.modId, version)} + onVisibilityChange={handleInstalledModVisibility} + /> + ); + })} +
+
+ )} + + {selectedInstalledModId && ( + onSelectMod(null)} + onAdd={(mod) => onAddMods([mod])} + onAddAllDeps={(deps) => void addAllDeps(deps, onAddMods)} + /> + )} + + {/* Bulk action toolbar */} + {mods.length > 0 && ( +
+ {canManage && missingVersionIds.size > 0 && ( + + )} + + {checkResult && !isChecking && ( + + checked {formatRelativeTime(checkResult.checkedAt)} + + )} +
+ )} + + {/* Orphaned mission alert from dependency check */} + {checkResult && !isChecking && checkResult.orphanedMission && ( +
+

Mission will be unavailable

+

+ The configured mission{' '} + + {checkResult.orphanedMission.name ?? + checkResult.orphanedMission.scenarioId.split('/').pop()} + {' '} + is not provided by any installed mod or official content. The server will fail to start. +

+ {canEditConfig ? ( + + ) : ( +

Switch the mission on the Configuration page.

+ )} + {resetMissionMessage && ( +

{resetMissionMessage}

+ )} +
+ )} + + {/* Dependency check results summary */} + {checkResult && !isChecking && uniqueMissingDeps.length > 0 && ( +
+

+ {checkResult.modsWithMissingDeps.length} mod + {checkResult.modsWithMissingDeps.length !== 1 ? 's' : ''} have missing dependencies ( + {uniqueMissingDeps.length} unique) +

+ {canManage && ( + + )} +
+ )} + {checkResult && + !isChecking && + checkResult.modsWithMissingDeps.length === 0 && + !checkResult.orphanedMission && ( +

All dependencies are installed.

+ )} + + {message &&

{message}

} +

+ Changes are written to config.json (backup kept) and take effect on restart. +

+
+ ); +} + +function InstalledModCard({ + mod, + detail, + isLoadingDetail, + depIssue, + noVersion, + selected, + canManage, + onView, + onRemove, + onUpdateVersion, + onVisibilityChange, +}: { + mod: ReforgerConfigMod; + detail: WorkshopModDetail | null; + isLoadingDetail: boolean; + depIssue: ModDependencyIssue | null; + noVersion: boolean; + selected: boolean; + canManage: boolean; + onView: () => void; + onRemove: () => void; + onUpdateVersion: (version: string) => void; + onVisibilityChange: (modId: string, visible: boolean) => void; +}) { + const cardRef = useRef(null); + const title = detail?.name ?? mod.name ?? mod.modId; + const configuredVersion = mod.version ?? ''; + const latestVersion = detail?.version ?? null; + const updateAvailable = + latestVersion !== null && configuredVersion !== '' && latestVersion !== configuredVersion; + const description = detail?.summary ?? detail?.description ?? null; + const tags = detail?.tags ?? []; + + useEffect(() => { + const node = cardRef.current; + if (!node) return; + if (!('IntersectionObserver' in window)) { + onVisibilityChange(mod.modId, true); + return () => onVisibilityChange(mod.modId, false); + } + + const observer = new IntersectionObserver( + ([entry]) => onVisibilityChange(mod.modId, entry?.isIntersecting ?? false), + { rootMargin: WORKSHOP_DETAIL_PREFETCH_MARGIN }, + ); + observer.observe(node); + return () => { + observer.disconnect(); + onVisibilityChange(mod.modId, false); + }; + }, [mod.modId, onVisibilityChange]); + + return ( +
+ +
+
+

{title}

+

{mod.modId}

+

+ configured v{configuredVersion || '-'} + {latestVersion && latestVersion !== configuredVersion + ? ` · latest v${latestVersion}` + : ''} +

+
+

+ {description ?? 'No description available.'} +

+
+ {noVersion && ( + + no version + + )} + {depIssue && ( + d.name).join(', ')}`} + className="rounded border border-danger-400/40 bg-danger-400/10 px-2 py-0.5 text-[11px] font-semibold text-danger-400" + > + {depIssue.missing.length} dep{depIssue.missing.length !== 1 ? 's' : ''} missing + + )} + {tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} +
+
+ {canManage && ( +
+ +
+ onUpdateVersion(event.target.value)} + placeholder={latestVersion ?? 'manual version'} + maxLength={32} + className="input min-h-9 px-2.5 py-1.5 font-mono text-xs" + /> + +
+
+ )} + + {canManage && ( + + )} +
+
+
+ ); +} + +// ── Workshop Browser ────────────────────────────────────────────────────────── + function WorkshopBrowser({ canManage, installedIds, onAdd, selectedModId, setSelectedModId, + onAddAllDeps, + currentScenarioId, }: { canManage: boolean; installedIds: Set; onAdd: (mod: ReforgerConfigMod) => void; selectedModId: string | null; setSelectedModId: (id: string | null) => void; + onAddAllDeps: (deps: Array<{ id: string | null; name: string }>) => void; + currentScenarioId: string | null; }) { const [input, setInput] = useState(''); const [query, setQuery] = useState(''); @@ -185,10 +784,26 @@ function WorkshopBrowser({ const [sort, setSort] = useState<(typeof WORKSHOP_SORTS)[number]['value']>('popularity'); const [page, setPage] = useState(1); const [addingId, setAddingId] = useState(null); + const [visibleModIds, setVisibleModIds] = useState>(() => new Set()); const effectiveQuery = [query, activeTag].filter(Boolean).join(' '); const { data, isFetching, error } = useWorkshopSearch(effectiveQuery, page, sort); + const browseModIds = useMemo(() => data?.mods.map((mod) => mod.id) ?? [], [data?.mods]); + const { detailByModId, loadingDetailIds } = useQueuedWorkshopModDetails( + browseModIds, + visibleModIds, + ); + const handleBrowseModVisibility = useCallback((modId: string, visible: boolean) => { + setVisibleModIds((current) => { + const next = new Set(current); + if (visible) { + next.add(modId); + } else { + next.delete(modId); + } + return next; + }); + }, []); - // Adding needs the mod's version, which only the detail endpoint provides. const addFromWorkshop = async (modId: string, fallbackName: string) => { setAddingId(modId); try { @@ -206,122 +821,127 @@ function WorkshopBrowser({ }; return ( - -
{ - event.preventDefault(); - setPage(1); - setSelectedModId(null); - setQuery(input.trim()); - }} - > - setInput(event.target.value)} - placeholder="Search the Reforger Workshop… (empty shows the front page)" - className="input min-w-0 flex-1" - /> - - -
+ setInput(event.target.value)} + placeholder="Search mods… (empty = front page)" + className="input min-w-0 flex-1" + /> + + + -
- Tags - {COMMON_WORKSHOP_TAGS.map((tag) => ( - - ))} - {activeTag && ( - - )} +
+ Tags + {COMMON_WORKSHOP_TAGS.map((tag) => ( + + ))} + {activeTag && ( + + )} +
{error &&

{error.message}

} {!data && !error && } + {data && ( -
+ <>
-

- {effectiveQuery - ? `${data.meta.totalMods.toLocaleString()} results for “${effectiveQuery}”` - : `${data.meta.totalMods.toLocaleString()} Workshop mods`}{' '} - · page {data.meta.currentPage} of {data.meta.totalPages} -

-
    +
    +

    + {effectiveQuery + ? `${data.meta.totalMods.toLocaleString()} results for "${effectiveQuery}"` + : `${data.meta.totalMods.toLocaleString()} Workshop mods`}{' '} + · page {data.meta.currentPage} of {data.meta.totalPages} +

    +
    + + +
    +
    +
    {data.mods.map((mod) => { const installed = installedIds.has(mod.id.toUpperCase()); return ( -
  • - - {canManage && ( - - )} -
  • + setSelectedModId(selectedModId === mod.id ? null : mod.id)} + onAdd={() => void addFromWorkshop(mod.id, mod.name)} + onTagSelect={(tag) => { + setActiveTag(tag); + setPage(1); + setSelectedModId(null); + }} + onVisibilityChange={handleBrowseModVisibility} + /> ); })} -
+
- { - setActiveTag(tag); - setPage(1); - setSelectedModId(null); - }} - /> - + {selectedModId && ( + setSelectedModId(null)} + onAdd={onAdd} + onAddAllDeps={onAddAllDeps} + onTagSelect={(tag) => { + setActiveTag(tag); + setPage(1); + setSelectedModId(null); + }} + /> + )} + )} -
+ ); } -function ModDetailPanel({ +function WorkshopModCard({ + mod, + detail, + installed, + selected, + canManage, + isLoadingDetail, + isAdding, + onSelect, + onAdd, + onTagSelect, + onVisibilityChange, +}: { + mod: WorkshopModPreview; + detail: WorkshopModDetail | null; + installed: boolean; + selected: boolean; + canManage: boolean; + isLoadingDetail: boolean; + isAdding: boolean; + onSelect: () => void; + onAdd: () => void; + onTagSelect: (tag: string) => void; + onVisibilityChange: (modId: string, visible: boolean) => void; +}) { + const cardRef = useRef(null); + const title = detail?.name ?? mod.name; + const author = detail?.author ?? mod.author; + const version = detail?.version ?? mod.version; + const size = detail?.size ?? mod.size; + const description = detail?.summary ?? detail?.description ?? mod.summary; + const tags = detail?.tags.length ? detail.tags : mod.tags; + const imageUrl = detail?.imageUrl ?? mod.imageUrl; + + useEffect(() => { + const node = cardRef.current; + if (!node) return; + if (!('IntersectionObserver' in window)) { + onVisibilityChange(mod.id, true); + return () => onVisibilityChange(mod.id, false); + } + + const observer = new IntersectionObserver( + ([entry]) => onVisibilityChange(mod.id, entry?.isIntersecting ?? false), + { rootMargin: WORKSHOP_DETAIL_PREFETCH_MARGIN }, + ); + observer.observe(node); + return () => { + observer.disconnect(); + onVisibilityChange(mod.id, false); + }; + }, [mod.id, onVisibilityChange]); + + return ( +
+ +
+
+

{title}

+

+ by {author} · v{version ?? '-'} · {size ?? '-'} +

+
+

+ {description ?? 'No description available.'} +

+ {tags.length > 0 && ( +
+ {tags.slice(0, 4).map((tag) => ( + + ))} +
+ )} +
+ + {canManage && !installed && ( + + )} +
+
+
+ ); +} + +// ── Mod Detail Modal ────────────────────────────────────────────────────────── + +function ModDetailModal({ modId, canManage, installedIds, + currentScenarioId, + onClose, onAdd, + onAddAllDeps, onTagSelect, }: { modId: string | null; canManage: boolean; installedIds: Set; + currentScenarioId: string | null; + onClose: () => void; onAdd: (mod: ReforgerConfigMod) => void; - onTagSelect: (tag: string) => void; + onAddAllDeps: (deps: Array<{ id: string | null; name: string }>) => void; + onTagSelect?: (tag: string) => void; }) { - const { data: mod, isLoading } = useWorkshopMod(modId); + const { data: mod, isLoading, error } = useWorkshopMod(modId); const [addingDepId, setAddingDepId] = useState(null); + const [addingAllDeps, setAddingAllDeps] = useState(false); + const [copiedScenarioId, setCopiedScenarioId] = useState(null); + const [copyFailedScenarioId, setCopyFailedScenarioId] = useState(null); const addDep = async (depId: string, depName: string) => { setAddingDepId(depId); @@ -383,104 +1129,339 @@ function ModDetailPanel({ } }; - if (!modId) { - return ( -
- -
- ); - } - if (isLoading || !mod) return ; - const installed = installedIds.has(mod.id.toUpperCase()); + const copyScenarioId = async (scenarioId: string, input: HTMLInputElement | null) => { + const value = scenarioId.trim(); + if (!value) return; + + const markCopied = () => { + setCopyFailedScenarioId(null); + setCopiedScenarioId(value); + window.setTimeout(() => setCopiedScenarioId(null), 1500); + }; + + if (navigator.clipboard) { + try { + await navigator.clipboard.writeText(value); + markCopied(); + return; + } catch { + // Fall back to selecting the readonly input for browsers that block Clipboard API in modals. + } + } + + let copied = false; + if (input) { + input.focus(); + input.select(); + input.setSelectionRange(0, value.length); + copied = document.execCommand('copy'); + } + + if (copied) { + markCopied(); + return; + } + + setCopiedScenarioId(null); + setCopyFailedScenarioId(value); + window.setTimeout(() => setCopyFailedScenarioId(null), 1800); + }; + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [onClose]); + + if (!modId) return null; + + const installed = mod ? installedIds.has(mod.id.toUpperCase()) : false; + const missingDeps = (mod?.dependencies ?? []).filter( + (dep) => dep.id && !installedIds.has(dep.id.toUpperCase()), + ); + + const providesCurrentMission = + mod !== undefined && + currentScenarioId !== null && + mod.scenarios.some((s) => s.scenarioId === currentScenarioId); + const missionWillBeOrphaned = providesCurrentMission && !installed; + const matchingScenario = mod?.scenarios.find((s) => s.scenarioId === currentScenarioId); + return ( -
-
- -
-

{mod.name}

-

- by {mod.author} · v{mod.version ?? '—'} · game {mod.gameVersion ?? '—'} -

-

- {mod.downloads?.toLocaleString() ?? '—'} downloads · {mod.rating ?? '—'} rating ·{' '} - {mod.size ?? '—'} -

+
+
-
- {mod.summary &&

{mod.summary}

} - {mod.tags.length > 0 && ( -
- {mod.tags.map((tag) => ( - - ))} -
- )} - {mod.dependencies.length > 0 && ( -
-

Dependencies

-
    - {mod.dependencies.map((dep) => { - const depInstalled = dep.id ? installedIds.has(dep.id.toUpperCase()) : false; - return ( -
  • - - {dep.name} - {depInstalled && ( - Installed + +
    + {error ? ( +
    +

    Could not load mod details

    +

    {error.message}

    +
    + ) : isLoading || !mod ? ( + + ) : ( +
    +
    + +
    + + + + + + +
    +
    + +
    + {missionWillBeOrphaned && ( +
    +

    + Active mission is from this mod +

    +

    + The server is configured to run{' '} + + {matchingScenario?.name ?? currentScenarioId?.split('/').pop()} + + . Without this mod installed the server will fail to start. +

    +
    + )} + +
    +
    +
    +

    + {mod.name} +

    +

    by {mod.author}

    +

    {mod.id}

    +
    + {installed && ( + + Installed + )} - - {canManage && dep.id && !depInstalled && ( +
    +

    + {mod.description ?? mod.summary ?? 'No description available.'} +

    +
    + + {mod.tags.length > 0 && ( +
    +

    + Tags +

    +
    + {mod.tags.map((tag) => + onTagSelect ? ( + + ) : ( + + {tag} + + ), + )} +
    +
    + )} + + {mod.scenarios.length > 0 && ( +
    +

    + Scenarios ({mod.scenarios.length}) +

    +
    + {mod.scenarios.map((scenario, index) => { + return ( + + ); + })} +
    +
    + )} + + {mod.dependencies.length > 0 && ( +
    +
    +

    + Dependencies ({mod.dependencies.length}) +

    + {canManage && missingDeps.length > 0 && ( + + )} +
    +
      + {mod.dependencies.map((dep) => { + const depInstalled = dep.id + ? installedIds.has(dep.id.toUpperCase()) + : false; + return ( +
    • + + {dep.name} + {depInstalled ? ( + Installed + ) : ( + Missing + )} + + {canManage && dep.id && !depInstalled && ( + + )} +
    • + ); + })} +
    +
    + )} + +
    + {canManage && !installed && ( )} -
  • - ); - })} -
+ {mod.workshopUrl && ( + + Open in Workshop + + )} +
+
+
+ )} + + + ); +} + +function ScenarioDetailRow({ + scenario, + copied, + copyFailed, + onCopy, +}: { + scenario: WorkshopModDetail['scenarios'][number]; + copied: boolean; + copyFailed: boolean; + onCopy: (scenarioId: string, input: HTMLInputElement | null) => void; +}) { + const inputRef = useRef(null); + + return ( +
+
+

{scenario.name}

+
+ +
+ event.currentTarget.select()} + /> + +
+
+
+

+ {scenario.gamemode ?? 'Scenario'} · players {scenario.playerCount ?? '-'} +

+ {scenario.description && ( +

{scenario.description}

)} -
- {canManage && ( - - )} - {mod.workshopUrl && ( - - Open in Workshop ↗ - - )} -
+
+ ); +} + +function DetailMetric({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

+ {value} +

); } diff --git a/apps/web/src/pages/overview.tsx b/apps/web/src/pages/overview.tsx index 816da15..1ce476d 100644 --- a/apps/web/src/pages/overview.tsx +++ b/apps/web/src/pages/overview.tsx @@ -54,9 +54,13 @@ function Dashboard({ user, slug }: { user: CurrentUser; slug: string }) { const memoryLimit = resources?.memoryLimitBytes ?? samples?.at(-1)?.memoryLimitBytes ?? null; 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 (
-
+

{resources ? `${resources.cpuPercent.toFixed(0)}%` : '—'} @@ -127,6 +131,33 @@ function Dashboard({ user, slug }: { user: CurrentUser; slug: string }) { ]} /> + +

+ {diskUsed !== null ? formatBytes(diskUsed) : '—'} + + {diskLimit ? ` / ${formatBytes(diskLimit)}` : ''} + +

+ {diskPercent !== null && ( +
+
+
90 + ? 'var(--color-danger-400)' + : diskPercent > 75 + ? 'var(--color-warn-400)' + : '#a3e635', + }} + /> +
+

{diskPercent.toFixed(1)}% used

+
+ )} +
diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index c5cbb77..11039c5 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -145,15 +145,15 @@ export type ConfigurationResponse = { export type MissionInfo = { scenarioId: string; - /** Display name from the startup scenario listing, e.g. "Conflict - Everon". */ + /** Display name for the scenario, e.g. "Campaign - Montignac". */ name: string; - /** 'official' or the source section header from the log. */ + /** 'official' or a mod source such as "mod: Scenario Pack". */ source: string; }; export type MissionsResponse = { missions: MissionInfo[]; - /** Null when the current log contains no scenario listing. */ + /** Null when the source could not be checked. */ fetchedAt: string | null; }; @@ -236,6 +236,8 @@ export type ResourceSample = { cpuLimitPercent: number | null; memoryBytes: number; memoryLimitBytes: number | null; + diskBytes: number; + diskLimitBytes: number | null; /** Bytes per second, derived from consecutive cumulative counters. */ networkRxRate: number; networkTxRate: number; @@ -297,6 +299,20 @@ export type ServerModsResponse = { 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 & { added: number; removed: number; @@ -321,6 +337,9 @@ export type WorkshopModPreview = { size: string | null; rating: string | null; workshopUrl: string | null; + version: string | null; + summary: string | null; + tags: string[]; }; export type WorkshopSearchResponse = { From ce3008451dfa7690d86b02be474350c5799ebe8e Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Fri, 10 Jul 2026 10:51:04 -0700 Subject: [PATCH 5/7] add disableAI to config --- apps/api/src/modules/config/performance-service.ts | 1 + .../src/modules/config/reforger-config-file.test.ts | 11 +++++++++-- apps/api/src/modules/config/reforger-config-file.ts | 1 + apps/api/src/modules/pterodactyl/mock-provider.ts | 2 +- apps/api/src/modules/servers/server-routes.ts | 1 + apps/api/test/auth-routes.test.ts | 1 + apps/api/test/mods-service.test.ts | 1 + apps/api/test/performance-service.test.ts | 4 +++- apps/web/src/api/hooks.ts | 3 ++- apps/web/src/components/performance-form.tsx | 1 + apps/web/src/pages/simple-pages.tsx | 2 +- packages/shared/src/reforger-config.ts | 1 + packages/shared/src/types.ts | 1 + 13 files changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/api/src/modules/config/performance-service.ts b/apps/api/src/modules/config/performance-service.ts index cee9f28..dc5089d 100644 --- a/apps/api/src/modules/config/performance-service.ts +++ b/apps/api/src/modules/config/performance-service.ts @@ -21,6 +21,7 @@ const FIELD_LOCATIONS: Record< disableThirdPerson: ['gameProperties', 'disableThirdPerson'], fastValidation: ['gameProperties', 'fastValidation'], battlEye: ['gameProperties', 'battlEye'], + disableAI: ['operating', 'disableAI'], aiLimit: ['operating', 'aiLimit'], playerSaveTime: ['operating', 'playerSaveTime'], slotReservationTimeout: ['operating', 'slotReservationTimeout'], diff --git a/apps/api/src/modules/config/reforger-config-file.test.ts b/apps/api/src/modules/config/reforger-config-file.test.ts index 0661f95..6c4b74c 100644 --- a/apps/api/src/modules/config/reforger-config-file.test.ts +++ b/apps/api/src/modules/config/reforger-config-file.test.ts @@ -11,7 +11,7 @@ const REAL_SHAPE = { a2s: { address: '0.0.0.0', port: 17777 }, rcon: { address: '127.0.0.1', port: 19999, password: 'hunter2', permission: 'admin' }, game: { - name: 'DazzledCorp Training Grounds', + name: 'DZR Training Grounds', password: '', passwordAdmin: 'secret', admins: ['76561198000000000'], @@ -34,7 +34,12 @@ const REAL_SHAPE = { { modId: '5AAF0CCE3F001FB5' }, ], }, - operating: { lobbyPlayerSynchronise: true, aiLimit: -1, playerSaveTime: 120 }, + operating: { + lobbyPlayerSynchronise: true, + disableAI: false, + aiLimit: -1, + playerSaveTime: 120 + }, }; describe('parseReforgerConfigJson', () => { @@ -44,6 +49,7 @@ describe('parseReforgerConfigJson', () => { serverName: 'DazzledCorp Training Grounds', maxPlayers: 16, scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf', + disableAI: false, aiLimit: -1, serverMaxViewDistance: 2500, networkViewDistance: 1000, @@ -66,6 +72,7 @@ describe('parseReforgerConfigJson', () => { const config = parseReforgerConfigJson('{"game":{"name":"Bare"}}'); expect(config.serverName).toBe('Bare'); expect(config.maxPlayers).toBe(0); + expect(config.disableAI).toBe(false); expect(config.aiLimit).toBe(-1); expect(config.mods).toEqual([]); }); diff --git a/apps/api/src/modules/config/reforger-config-file.ts b/apps/api/src/modules/config/reforger-config-file.ts index d0d7365..b74138d 100644 --- a/apps/api/src/modules/config/reforger-config-file.ts +++ b/apps/api/src/modules/config/reforger-config-file.ts @@ -52,6 +52,7 @@ export function mapReforgerConfig(raw: unknown): ReforgerServerConfig { serverName: str(game.name, 'Unnamed server'), maxPlayers: num(game.maxPlayers, 0), scenarioId: str(game.scenarioId), + disableAI: bool(operating.disableAI, false), // -1 means "no limit" in Reforger's operating.aiLimit. aiLimit: num(operating.aiLimit, -1), serverMaxViewDistance: num(gameProperties.serverMaxViewDistance, 0), diff --git a/apps/api/src/modules/pterodactyl/mock-provider.ts b/apps/api/src/modules/pterodactyl/mock-provider.ts index 22b04cd..80c0237 100644 --- a/apps/api/src/modules/pterodactyl/mock-provider.ts +++ b/apps/api/src/modules/pterodactyl/mock-provider.ts @@ -126,7 +126,7 @@ export class MockGameServerProvider implements GameServerProvider { }, mods: [{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' }], }, - operating: { aiLimit: 40 }, + operating: { disableAI: false, aiLimit: 40 }, }, null, 2, diff --git a/apps/api/src/modules/servers/server-routes.ts b/apps/api/src/modules/servers/server-routes.ts index c1c68c4..ebe1da1 100644 --- a/apps/api/src/modules/servers/server-routes.ts +++ b/apps/api/src/modules/servers/server-routes.ts @@ -64,6 +64,7 @@ const performanceBodySchema = z disableThirdPerson: z.boolean().nullable(), fastValidation: z.boolean().nullable(), battlEye: z.boolean().nullable(), + disableAI: z.boolean().nullable(), aiLimit: z.number().int().min(-1).max(1000).nullable(), playerSaveTime: z.number().int().min(1).max(3600).nullable(), slotReservationTimeout: z.number().int().min(5).max(300).nullable(), diff --git a/apps/api/test/auth-routes.test.ts b/apps/api/test/auth-routes.test.ts index 3051d79..36f090f 100644 --- a/apps/api/test/auth-routes.test.ts +++ b/apps/api/test/auth-routes.test.ts @@ -343,6 +343,7 @@ describe('performance config by role', () => { disableThirdPerson: null, fastValidation: null, battlEye: null, + disableAI: null, aiLimit: null, playerSaveTime: null, slotReservationTimeout: null, diff --git a/apps/api/test/mods-service.test.ts b/apps/api/test/mods-service.test.ts index 057404c..506e24b 100644 --- a/apps/api/test/mods-service.test.ts +++ b/apps/api/test/mods-service.test.ts @@ -69,6 +69,7 @@ describe('ServerModsService', () => { expect(parsed.bindPort).toBe(2001); expect(parsed.game.name).toBe('Mock Reforger Server'); expect(parsed.game.maxPlayers).toBe(16); + expect(parsed.operating.disableAI).toBe(false); expect(parsed.operating.aiLimit).toBe(40); }); diff --git a/apps/api/test/performance-service.test.ts b/apps/api/test/performance-service.test.ts index a0f17d6..bd0e32a 100644 --- a/apps/api/test/performance-service.test.ts +++ b/apps/api/test/performance-service.test.ts @@ -37,6 +37,7 @@ describe('PerformanceSettingsService', () => { // Present in the mock config.json: expect(settings.maxPlayers).toBe(16); expect(settings.serverMaxViewDistance).toBe(2500); + expect(settings.disableAI).toBe(false); expect(settings.aiLimit).toBe(40); expect(settings.disableThirdPerson).toBe(false); // Absent keys: @@ -50,10 +51,11 @@ describe('PerformanceSettingsService', () => { ...settings, maxPlayers: 32, playerSaveTime: 180, // new key + disableAI: null, 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); const written = JSON.parse(provider.writtenFiles.get('/config.json')!); diff --git a/apps/web/src/api/hooks.ts b/apps/web/src/api/hooks.ts index e0654f9..12ca5d7 100644 --- a/apps/web/src/api/hooks.ts +++ b/apps/web/src/api/hooks.ts @@ -235,7 +235,8 @@ export function useSetPerformanceSettings(slug: string) { `/api/servers/${slug}/config/performance`, settings, ), - onSuccess: () => { + onSuccess: (result) => { + queryClient.setQueryData(['servers', slug, 'config', 'performance'], result); void queryClient.invalidateQueries({ queryKey: ['servers', slug] }); }, }); diff --git a/apps/web/src/components/performance-form.tsx b/apps/web/src/components/performance-form.tsx index 15b7dd7..f750c5c 100644 --- a/apps/web/src/components/performance-form.tsx +++ b/apps/web/src/components/performance-form.tsx @@ -52,6 +52,7 @@ const NUMBER_FIELDS: { key: NumberKey; label: string; min: number; max: number; ]; 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' }, diff --git a/apps/web/src/pages/simple-pages.tsx b/apps/web/src/pages/simple-pages.tsx index 518e881..4bbd61f 100644 --- a/apps/web/src/pages/simple-pages.tsx +++ b/apps/web/src/pages/simple-pages.tsx @@ -42,7 +42,7 @@ function ConfigurationsBody({ slug, user }: { slug: string; user: CurrentUser })

Configuration

- + {/**/} {canEdit && } {config ? : } diff --git a/packages/shared/src/reforger-config.ts b/packages/shared/src/reforger-config.ts index 1f2fe34..b54dee2 100644 --- a/packages/shared/src/reforger-config.ts +++ b/packages/shared/src/reforger-config.ts @@ -13,6 +13,7 @@ export type ReforgerServerConfig = { serverName: string; maxPlayers: number; scenarioId: string; + disableAI: boolean; aiLimit: number; serverMaxViewDistance: number; networkViewDistance: number; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 11039c5..f161bbe 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -264,6 +264,7 @@ export type PerformanceSettings = { disableThirdPerson: boolean | null; // game.gameProperties (default false) fastValidation: 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) playerSaveTime: number | null; // operating, seconds (default 120) slotReservationTimeout: number | null; // operating, 5–300 s (default 60) From 17135c2efdc2648fae379e7a1539f52411058b1d Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Fri, 10 Jul 2026 22:43:07 -0700 Subject: [PATCH 6/7] upgrade all mods + autosave --- apps/web/src/api/hooks.ts | 3 +- apps/web/src/pages/mods.tsx | 180 +++++++++++++++++++++++++++++++----- 2 files changed, 159 insertions(+), 24 deletions(-) diff --git a/apps/web/src/api/hooks.ts b/apps/web/src/api/hooks.ts index 12ca5d7..54eb4fd 100644 --- a/apps/web/src/api/hooks.ts +++ b/apps/web/src/api/hooks.ts @@ -285,7 +285,8 @@ export function useSetServerMods(slug: string) { return useMutation({ mutationFn: (mods: ReforgerConfigMod[]) => api.put(`/api/servers/${slug}/mods`, { mods }), - onSuccess: () => { + onSuccess: (result) => { + queryClient.setQueryData(['servers', slug, 'mods'], result); void queryClient.invalidateQueries({ queryKey: ['servers', slug] }); }, }); diff --git a/apps/web/src/pages/mods.tsx b/apps/web/src/pages/mods.tsx index 819a31c..e96b34e 100644 --- a/apps/web/src/pages/mods.tsx +++ b/apps/web/src/pages/mods.tsx @@ -5,6 +5,7 @@ import type { ModDependencyIssue, ModsCheckResponse, ReforgerConfigMod, + UpdateModsResult, WorkshopModDetail, WorkshopModPreview, } from '@reforger-panel/shared'; @@ -48,6 +49,8 @@ const WORKSHOP_DETAIL_BATCH_SIZE = 4; const WORKSHOP_DETAIL_BURST_CAPACITY = 20; const WORKSHOP_DETAIL_FAILURE_COOLDOWN_MS = 60_000; const WORKSHOP_DETAIL_PREFETCH_MARGIN = '500px'; +const MODS_AUTOSAVE_DEBOUNCE_MS = 1_500; +const UPGRADE_ALL_DETAIL_BATCH_SIZE = 8; const workshopDetailLimiter = { inFlight: false, @@ -67,6 +70,7 @@ function sameMods(a: ReforgerConfigMod[], b: ReforgerConfigMod[]): boolean { } type ModsTab = 'installed' | 'browse'; +type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error'; const DEFAULT_SCENARIO_ID = '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf'; @@ -76,7 +80,7 @@ function ModsBody({ slug, user }: { slug: string; user: CurrentUser }) { const { data, isLoading, error, refetch } = useServerMods(slug); const { data: configData } = useConfiguration(slug); const currentScenarioId = configData?.config.scenarioId ?? null; - const save = useSetServerMods(slug); + const { mutate: saveMutate, isPending: isSavingMods } = useSetServerMods(slug); const savePerf = useSetPerformanceSettings(slug); const [draft, setDraft] = useState(null); const [message, setMessage] = useState(null); @@ -84,15 +88,64 @@ function ModsBody({ slug, user }: { slug: string; user: CurrentUser }) { const [selectedModId, setSelectedModId] = useState(null); const [activeTab, setActiveTab] = useState('installed'); const [checkEnabled, setCheckEnabled] = useState(false); + const [saveStatus, setSaveStatus] = useState('idle'); + const [isUpgradingAll, setIsUpgradingAll] = useState(false); const checkQuery = useServerModsCheck(slug, checkEnabled); const serverMods = data?.mods ?? []; const mods = draft ?? serverMods; + const modsKey = useMemo(() => JSON.stringify(mods), [mods]); + const modsRef = useRef(mods); + modsRef.current = mods; const dirty = draft !== null && !sameMods(draft, serverMods); const installedIds = new Set(mods.map((mod) => mod.modId.toUpperCase())); const missingVersionIds = new Set(mods.filter((m) => !m.version).map((m) => m.modId)); + const submitMods = useCallback( + ( + submittedMods: ReforgerConfigMod[], + getSuccessMessage: (result: UpdateModsResult, currentSaved: boolean) => string, + source: 'manual' | 'auto' = 'manual', + ) => { + setMessage(null); + setSaveStatus('saving'); + saveMutate(submittedMods, { + onSuccess: (result) => { + const currentSaved = sameMods(modsRef.current, submittedMods); + if (currentSaved) setDraft(null); + setSaveStatus(currentSaved ? 'saved' : 'pending'); + setMessage(getSuccessMessage(result, currentSaved)); + setCheckEnabled(false); + void refetch(); + }, + onError: (saveError) => { + setSaveStatus('error'); + setMessage( + source === 'auto' ? `Autosave failed: ${saveError.message}` : saveError.message, + ); + }, + }); + }, + [refetch, saveMutate], + ); + + useEffect(() => { + if (!canManage || !dirty || isSavingMods || isLoading) return; + setSaveStatus('pending'); + const timer = window.setTimeout(() => { + submitMods( + modsRef.current, + (result, currentSaved) => + currentSaved + ? `Autosaved — ${result.added} added, ${result.removed} removed. Restart to apply.` + : 'Autosaved. More changes pending.', + 'auto', + ); + }, MODS_AUTOSAVE_DEBOUNCE_MS); + return () => window.clearTimeout(timer); + }, [canManage, dirty, isLoading, isSavingMods, modsKey, submitMods]); + const addMod = (mod: ReforgerConfigMod) => { if (installedIds.has(mod.modId.toUpperCase())) return; setMessage(null); @@ -128,28 +181,60 @@ function ModsBody({ slug, user }: { slug: string; user: CurrentUser }) { }; const saveMods = () => { - setMessage(null); - save.mutate(mods, { - onSuccess: (result) => { - setDraft(null); - setMessage(`Saved — ${result.added} added, ${result.removed} removed. Restart to apply.`); - setCheckEnabled(false); - void refetch(); - }, - onError: (saveError) => setMessage(saveError.message), - }); + submitMods(mods, (result, currentSaved) => + currentSaved + ? `Saved — ${result.added} added, ${result.removed} removed. Restart to apply.` + : 'Saved. More changes pending.', + ); }; const patchVersions = () => { + submitMods(mods, (_result, currentSaved) => + currentSaved + ? 'Version info patched. Restart to apply.' + : 'Version info patched. More changes pending.', + ); + }; + + const upgradeAllVersions = async () => { + if (!canManage || isUpgradingAll || isSavingMods || mods.length === 0) return; + + setIsUpgradingAll(true); setMessage(null); - save.mutate(mods, { - onSuccess: () => { - setDraft(null); - setMessage('Version info patched. Restart to apply.'); - void refetch(); - }, - onError: (saveError) => setMessage(saveError.message), - }); + try { + const details = await fetchWorkshopDetailsForMods(mods); + let changed = 0; + let foundVersions = 0; + const upgraded = mods.map((mod) => { + const detail = details.get(mod.modId.toUpperCase()); + if (!detail?.version) return mod; + foundVersions += 1; + if (mod.version === detail.version) return mod; + changed += 1; + return { + ...mod, + name: mod.name ?? detail.name, + version: detail.version, + }; + }); + + if (changed === 0) { + setMessage( + foundVersions === 0 + ? 'No workshop version info was available for the installed mods.' + : 'All installed mods are already on the latest known versions.', + ); + return; + } + + setDraft(upgraded); + setSaveStatus('pending'); + setMessage( + `Updated ${changed} version number${changed === 1 ? '' : 's'}. Autosave will write the changes shortly.`, + ); + } finally { + setIsUpgradingAll(false); + } }; const resetMission = () => { @@ -210,7 +295,9 @@ function ModsBody({ slug, user }: { slug: string; user: CurrentUser }) { canManage={canManage} canEditConfig={canEditConfig} dirty={dirty} - isSaving={save.isPending} + isSaving={isSavingMods} + saveStatus={saveStatus} + isUpgradingAll={isUpgradingAll} isResettingMission={savePerf.isPending} missingVersionIds={missingVersionIds} selectedModId={selectedModId} @@ -230,8 +317,10 @@ function ModsBody({ slug, user }: { slug: string; user: CurrentUser }) { onDiscard={() => { setDraft(null); setMessage(null); + setSaveStatus('idle'); }} onPatchVersions={patchVersions} + onUpgradeAll={upgradeAllVersions} onCheckDeps={runCheck} onAddMods={addMods} onResetMission={resetMission} @@ -278,6 +367,26 @@ async function addAllDeps( addMods(mods); } +async function fetchWorkshopDetailsForMods( + mods: ReforgerConfigMod[], +): Promise> { + const details = new Map(); + + for (let i = 0; i < mods.length; i += UPGRADE_ALL_DETAIL_BATCH_SIZE) { + const batch = mods.slice(i, i + UPGRADE_ALL_DETAIL_BATCH_SIZE); + const results = await Promise.allSettled( + batch.map((mod) => api.get(`/api/workshop/mods/${mod.modId}`)), + ); + for (const result of results) { + if (result.status === 'fulfilled') { + details.set(result.value.id.toUpperCase(), result.value); + } + } + } + + return details; +} + function workshopModQueryKey(modId: string) { return ['workshop', 'mod', modId] as const; } @@ -379,6 +488,8 @@ function InstalledModsPanel({ canEditConfig, dirty, isSaving, + saveStatus, + isUpgradingAll, isResettingMission, missingVersionIds, selectedModId, @@ -397,6 +508,7 @@ function InstalledModsPanel({ onSave, onDiscard, onPatchVersions, + onUpgradeAll, onCheckDeps, onAddMods, onResetMission, @@ -406,6 +518,8 @@ function InstalledModsPanel({ canEditConfig: boolean; dirty: boolean; isSaving: boolean; + saveStatus: SaveStatus; + isUpgradingAll: boolean; isResettingMission: boolean; missingVersionIds: Set; selectedModId: string | null; @@ -424,6 +538,7 @@ function InstalledModsPanel({ onSave: () => void; onDiscard: () => void; onPatchVersions: () => void; + onUpgradeAll: () => void; onCheckDeps: () => void; onAddMods: (mods: ReforgerConfigMod[]) => void; onResetMission: () => void; @@ -467,17 +582,36 @@ function InstalledModsPanel({ title={`Installed Mods${mods.length > 0 ? ` (${mods.length})` : ''}`} action={
- {!dirty && fetchedAt && ( + {canManage && mods.length > 0 && ( + + )} + {saveStatus === 'pending' && ( + autosave pending + )} + {saveStatus === 'saving' && saving…} + {saveStatus === 'saved' && !dirty && ( + autosaved + )} + {saveStatus === 'error' && ( + autosave failed + )} + {!dirty && saveStatus !== 'saved' && fetchedAt && ( fetched {formatRelativeTime(fetchedAt)} )} {dirty && ( <> - unsaved changes )} From 3ecced51c8934804140188e4b76af255153145cc Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Fri, 10 Jul 2026 22:53:46 -0700 Subject: [PATCH 7/7] fix rate limit issue --- apps/web/src/pages/mods.tsx | 106 +++++++++++++----------------------- 1 file changed, 37 insertions(+), 69 deletions(-) diff --git a/apps/web/src/pages/mods.tsx b/apps/web/src/pages/mods.tsx index e96b34e..a15aea0 100644 --- a/apps/web/src/pages/mods.tsx +++ b/apps/web/src/pages/mods.tsx @@ -50,7 +50,6 @@ const WORKSHOP_DETAIL_BURST_CAPACITY = 20; const WORKSHOP_DETAIL_FAILURE_COOLDOWN_MS = 60_000; const WORKSHOP_DETAIL_PREFETCH_MARGIN = '500px'; const MODS_AUTOSAVE_DEBOUNCE_MS = 1_500; -const UPGRADE_ALL_DETAIL_BATCH_SIZE = 8; const workshopDetailLimiter = { inFlight: false, @@ -89,7 +88,6 @@ function ModsBody({ slug, user }: { slug: string; user: CurrentUser }) { const [activeTab, setActiveTab] = useState('installed'); const [checkEnabled, setCheckEnabled] = useState(false); const [saveStatus, setSaveStatus] = useState('idle'); - const [isUpgradingAll, setIsUpgradingAll] = useState(false); const checkQuery = useServerModsCheck(slug, checkEnabled); const serverMods = data?.mods ?? []; @@ -196,45 +194,13 @@ function ModsBody({ slug, user }: { slug: string; user: CurrentUser }) { ); }; - const upgradeAllVersions = async () => { - if (!canManage || isUpgradingAll || isSavingMods || mods.length === 0) return; - - setIsUpgradingAll(true); + const applyKnownVersionUpgrades = (upgraded: ReforgerConfigMod[], changed: number) => { setMessage(null); - try { - const details = await fetchWorkshopDetailsForMods(mods); - let changed = 0; - let foundVersions = 0; - const upgraded = mods.map((mod) => { - const detail = details.get(mod.modId.toUpperCase()); - if (!detail?.version) return mod; - foundVersions += 1; - if (mod.version === detail.version) return mod; - changed += 1; - return { - ...mod, - name: mod.name ?? detail.name, - version: detail.version, - }; - }); - - if (changed === 0) { - setMessage( - foundVersions === 0 - ? 'No workshop version info was available for the installed mods.' - : 'All installed mods are already on the latest known versions.', - ); - return; - } - - setDraft(upgraded); - setSaveStatus('pending'); - setMessage( - `Updated ${changed} version number${changed === 1 ? '' : 's'}. Autosave will write the changes shortly.`, - ); - } finally { - setIsUpgradingAll(false); - } + setDraft(upgraded); + setSaveStatus('pending'); + setMessage( + `Updated ${changed} known version number${changed === 1 ? '' : 's'}. Autosave will write the changes shortly.`, + ); }; const resetMission = () => { @@ -297,7 +263,6 @@ function ModsBody({ slug, user }: { slug: string; user: CurrentUser }) { dirty={dirty} isSaving={isSavingMods} saveStatus={saveStatus} - isUpgradingAll={isUpgradingAll} isResettingMission={savePerf.isPending} missingVersionIds={missingVersionIds} selectedModId={selectedModId} @@ -320,7 +285,7 @@ function ModsBody({ slug, user }: { slug: string; user: CurrentUser }) { setSaveStatus('idle'); }} onPatchVersions={patchVersions} - onUpgradeAll={upgradeAllVersions} + onUpgradeAll={applyKnownVersionUpgrades} onCheckDeps={runCheck} onAddMods={addMods} onResetMission={resetMission} @@ -367,26 +332,6 @@ async function addAllDeps( addMods(mods); } -async function fetchWorkshopDetailsForMods( - mods: ReforgerConfigMod[], -): Promise> { - const details = new Map(); - - for (let i = 0; i < mods.length; i += UPGRADE_ALL_DETAIL_BATCH_SIZE) { - const batch = mods.slice(i, i + UPGRADE_ALL_DETAIL_BATCH_SIZE); - const results = await Promise.allSettled( - batch.map((mod) => api.get(`/api/workshop/mods/${mod.modId}`)), - ); - for (const result of results) { - if (result.status === 'fulfilled') { - details.set(result.value.id.toUpperCase(), result.value); - } - } - } - - return details; -} - function workshopModQueryKey(modId: string) { return ['workshop', 'mod', modId] as const; } @@ -489,7 +434,6 @@ function InstalledModsPanel({ dirty, isSaving, saveStatus, - isUpgradingAll, isResettingMission, missingVersionIds, selectedModId, @@ -519,7 +463,6 @@ function InstalledModsPanel({ dirty: boolean; isSaving: boolean; saveStatus: SaveStatus; - isUpgradingAll: boolean; isResettingMission: boolean; missingVersionIds: Set; selectedModId: string | null; @@ -538,7 +481,7 @@ function InstalledModsPanel({ onSave: () => void; onDiscard: () => void; onPatchVersions: () => void; - onUpgradeAll: () => void; + onUpgradeAll: (upgraded: ReforgerConfigMod[], changed: number) => void; onCheckDeps: () => void; onAddMods: (mods: ReforgerConfigMod[]) => void; onResetMission: () => void; @@ -562,6 +505,23 @@ function InstalledModsPanel({ installedModIds, visibleModIds, ); + const knownVersionUpgrades = useMemo(() => { + let changed = 0; + let knownVersions = 0; + const upgraded = mods.map((mod) => { + const detail = detailByModId.get(mod.modId.toUpperCase()); + if (!detail?.version) return mod; + knownVersions += 1; + if (mod.version === detail.version) return mod; + changed += 1; + return { + ...mod, + name: mod.name ?? detail.name, + version: detail.version, + }; + }); + return { changed, knownVersions, upgraded }; + }, [detailByModId, mods]); const handleInstalledModVisibility = useCallback((modId: string, visible: boolean) => { setVisibleModIds((current) => { const next = new Set(current); @@ -585,11 +545,19 @@ function InstalledModsPanel({ {canManage && mods.length > 0 && ( )} {saveStatus === 'pending' && (