enhanced mod menu + simplify scenario select
This commit is contained in:
14 files changed
+1799
-460
No files matched your search
@@ -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"
|
||||
>
|
||||
{!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>
|
||||
|
||||
@@ -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
@@ -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
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">
|
||||
|
||||
Reference in new issue
Block a user