enhanced mod menu + simplify scenario select

This commit is contained in:
SowinskiBraeden committed 2026-07-08 11:43:48 -07:00
1 parent 5f22548ba6
commit 0d61329e7c
14 files changed
+1799 -460

No files matched your search

+38 -2
View File
@@ -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({
+37 -40
View File
@@ -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"
>
{!missions.missions.some((m) => m.scenarioId === current) && (
<option value={current}>{currentName} (current)</option>
)}
{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>
))}
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}
>
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>
)}
</div>
{message && <p className="mt-3 text-xs text-accent-400">{message}</p>}
</Card>
+5 -1
View File
@@ -56,7 +56,11 @@ export function ModImage({ src, className = '' }: { src: string | null; classNam
}
const STATUS_STYLES: Record<ServerStatus, { dot: string; text: string; label: string }> = {
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' },
+74 -37
View File
@@ -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<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>
)}
<select
value={lines}
onChange={(event) => setLines(Number(event.target.value))}
className="input py-1.5"
>
{[100, 300, 600, 1000].map((n) => (
<option key={n} value={n}>
last {n} lines
</option>
))}
</select>
{mode === 'stream' && streamLines.length > 0 && (
<span className="text-xs text-slate-dim">
{streamLines.length} lines
</span>
)}
{mode === 'poll' && (
<select
value={lines}
onChange={(event) => setLines(Number(event.target.value))}
className="input py-1.5"
>
{[100, 300, 600, 1000].map((n) => (
<option key={n} value={n}>
last {n} lines
</option>
))}
</select>
)}
<Button
variant={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>
<Button disabled={isFetching} onClick={() => void refetch()}>
{isFetching ? '…' : 'Refresh'}
</Button>
{mode === 'poll' && (
<Button disabled={isFetching} onClick={() => void refetch()}>
{isFetching ? '…' : 'Refresh'}
</Button>
)}
</div>
}
>
{isLoading ? (
{mode === 'stream' ? (
streamLines.length === 0 ? (
<Spinner label="Connecting to console…" />
) : (
<pre
ref={viewportRef}
className="max-h-[65vh] overflow-auto whitespace-pre rounded-md border border-graphite-800 bg-graphite-950 p-4 font-mono text-xs leading-relaxed text-zinc-300"
>
{streamLines.join('\n')}
</pre>
)
) : pollLoading ? (
<Spinner label="Downloading log…" />
) : error ? (
<p className="text-sm text-danger-400">{error.message}</p>
) : 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"
>
{data?.lines.join('\n')}
{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>
+1269 -288
View File
File diff suppressed because it is too large. Load diff
+32 -1
View File
@@ -54,9 +54,13 @@ function Dashboard({ user, slug }: { user: CurrentUser; slug: string }) {
const memoryLimit = resources?.memoryLimitBytes ?? samples?.at(-1)?.memoryLimitBytes ?? null;
const 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">