enhanced mod menu + simplify scenario select
This commit is contained in:
14 files changed
+1628
-289
No files matched your search
@@ -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: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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(
|
||||
'/: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,
|
||||
|
||||
@@ -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(() => {
|
||||
expect(first.mods[0]!.version).toBeNull();
|
||||
const detailCalls = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/'));
|
||||
expect(detailCalls).toHaveLength(1);
|
||||
});
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 | 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
|
||||
* via.placeholder.com stubs, and detail endpoints sometimes concatenate two
|
||||
@@ -101,18 +121,29 @@ function toPreview(mod: z.infer<typeof modPreviewSchema>): 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<string, { url: string | null; expiresAt: number }>();
|
||||
/** modId -> detail fields used to make browse cards useful. */
|
||||
private previewCache = new Map<
|
||||
string,
|
||||
{
|
||||
imageUrl: string | null;
|
||||
version: string | null;
|
||||
summary: string | null;
|
||||
tags: string[];
|
||||
expiresAt: number;
|
||||
}
|
||||
>();
|
||||
|
||||
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<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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<MissionsResponse>(`/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<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) {
|
||||
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<ModsCheckResponse>(`/api/servers/${slug}/mods/check`),
|
||||
enabled,
|
||||
staleTime: 2 * 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useManualLogSync(slug: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -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<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 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
|
||||
<Button onClick={() => setSelected(null)} disabled={save.isPending}>
|
||||
Discard
|
||||
</Button>
|
||||
<Button variant="accent" onClick={submit} disabled={save.isPending}>
|
||||
<Button variant="accent" onClick={() => submit()} disabled={save.isPending}>
|
||||
{save.isPending ? 'Saving…' : 'Save to server'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="space-y-4">
|
||||
<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}>
|
||||
{shortScenario(current)}
|
||||
{current}
|
||||
</p>
|
||||
</div>
|
||||
{canEdit &&
|
||||
(missions && missions.missions.length > 0 ? (
|
||||
<select
|
||||
{canEdit && (
|
||||
<div className="grid gap-2">
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
setMessage(null);
|
||||
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) && (
|
||||
<option value={current}>{currentName} (current)</option>
|
||||
Use {DEFAULT_SCENARIO_NAME}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => submit(DEFAULT_SCENARIO_ID)}
|
||||
disabled={save.isPending || current === DEFAULT_SCENARIO_ID}
|
||||
>
|
||||
{save.isPending ? 'Saving…' : 'Reset to default'}
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
{message && <p className="mt-3 text-xs text-accent-400">{message}</p>}
|
||||
</Card>
|
||||
|
||||
@@ -56,7 +56,11 @@ export function ModImage({ src, className = '' }: { src: string | null; classNam
|
||||
}
|
||||
|
||||
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' },
|
||||
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' },
|
||||
|
||||
+61
-24
@@ -1,42 +1,60 @@
|
||||
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<string[]>([]);
|
||||
const viewportRef = useRef<HTMLPreElement | null>(null);
|
||||
|
||||
const onLine = useCallback((line: string) => {
|
||||
setStreamLines((prev) => {
|
||||
const next = [...prev, line];
|
||||
return next.length > MAX_STREAM_LINES ? next.slice(next.length - MAX_STREAM_LINES) : next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useConsoleStream(slug ?? '', onLine, mode === 'stream' && slug !== undefined);
|
||||
|
||||
// Polling fallback
|
||||
const { data: pollData, isLoading: pollLoading, error: pollError, refetch, isFetching } =
|
||||
useRawLogs(slug ?? '', lines, mode === 'poll', mode === 'poll' && slug !== undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (follow && viewportRef.current) {
|
||||
viewportRef.current.scrollTop = viewportRef.current.scrollHeight;
|
||||
}
|
||||
}, [data, follow]);
|
||||
}, [streamLines, pollData, follow]);
|
||||
|
||||
if (!slug) return <Spinner />;
|
||||
|
||||
const title = mode === 'stream' ? (streamLines.length > 0 ? 'Live log' : 'console.log') : (pollData ? pollData.path : 'console.log');
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-5">
|
||||
<h1 className="page-title">Logs</h1>
|
||||
<Card
|
||||
title={data ? data.path : 'console.log'}
|
||||
title={title}
|
||||
action={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{data && (
|
||||
{mode === 'poll' && pollData && (
|
||||
<span className="text-xs text-slate-dim">
|
||||
fetched {formatRelativeTime(data.fetchedAt)}
|
||||
fetched {formatRelativeTime(pollData.fetchedAt)}
|
||||
</span>
|
||||
)}
|
||||
{mode === 'stream' && streamLines.length > 0 && (
|
||||
<span className="text-xs text-slate-dim">
|
||||
{streamLines.length} lines
|
||||
</span>
|
||||
)}
|
||||
{mode === 'poll' && (
|
||||
<select
|
||||
value={lines}
|
||||
onChange={(event) => setLines(Number(event.target.value))}
|
||||
@@ -48,12 +66,16 @@ export function LogsPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button
|
||||
variant={autoRefresh ? 'accent' : 'default'}
|
||||
onClick={() => setAutoRefresh((v) => !v)}
|
||||
title="Refresh every 10 seconds"
|
||||
variant={mode === 'stream' ? 'accent' : 'default'}
|
||||
onClick={() => {
|
||||
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
|
||||
variant={follow ? 'accent' : 'default'}
|
||||
@@ -62,27 +84,42 @@ export function LogsPage() {
|
||||
>
|
||||
{follow ? 'Follow' : 'Free scroll'}
|
||||
</Button>
|
||||
{mode === 'poll' && (
|
||||
<Button disabled={isFetching} onClick={() => void refetch()}>
|
||||
{isFetching ? '…' : 'Refresh'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner label="Downloading log…" />
|
||||
) : error ? (
|
||||
<p className="text-sm text-danger-400">{error.message}</p>
|
||||
{mode === 'stream' ? (
|
||||
streamLines.length === 0 ? (
|
||||
<Spinner label="Connecting to console…" />
|
||||
) : (
|
||||
<pre
|
||||
ref={viewportRef}
|
||||
className="max-h-[65vh] overflow-auto whitespace-pre rounded-md border border-graphite-800 bg-graphite-950 p-4 font-mono text-xs leading-relaxed text-zinc-300"
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<p className="mt-3 text-xs text-slate-dim">
|
||||
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.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
+1113
-132
File diff suppressed because it is too large.
Load diff
@@ -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 (
|
||||
<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">
|
||||
<p className="text-2xl font-semibold text-zinc-100">
|
||||
{resources ? `${resources.cpuPercent.toFixed(0)}%` : '—'}
|
||||
@@ -127,6 +131,33 @@ function Dashboard({ user, slug }: { user: CurrentUser; slug: string }) {
|
||||
]}
|
||||
/>
|
||||
</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 className="grid gap-5 lg:grid-cols-3">
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user