This commit is contained in:
SowinskiBraeden committed 2026-09-05 13:10:36 -07:00
1 parent 3ecced51c8
commit 3adb45886a
66 files changed
+9227 -3679

No files matched your search

+33 -18
View File
@@ -4,20 +4,27 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { useCurrentUser } from './api/hooks.js';
import { api, ApiClientError } from './api/client.js';
import { Layout } from './components/layout.js';
import { Spinner } from './components/ui.js';
import { EmptyState, Spinner, ToastProvider } from './components/ui.js';
import { LoginPage } from './pages/login.js';
import { OverviewPage } from './pages/overview.js';
import { ModsPage } from './pages/mods.js';
import { LogsPage } from './pages/logs.js';
import {
ActivityPage,
ConfigurationsPage,
KillfeedPage,
PlayersPage,
SettingsPage,
} from './pages/simple-pages.js';
import { ConfigurationPage } from './pages/configuration.js';
import { MissionPage } from './pages/mission.js';
import { ConsolePage } from './pages/console.js';
import { ActivityPage, KillfeedPage, PlayersPage, SettingsPage } from './pages/simple-pages.js';
const queryClient = new QueryClient();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Config and mod reads hit the game server; do not re-fetch them just
// because a tab regained focus.
refetchOnWindowFocus: false,
retry: (failureCount, error) =>
!(error instanceof ApiClientError && error.status >= 400 && error.status < 500) &&
failureCount < 2,
},
},
});
/** Redeems a stored invite code once, right after login, then refreshes /me. */
function InviteRedeemer() {
@@ -49,8 +56,12 @@ function AuthGate() {
}
if (!user) {
return (
<div className="flex min-h-screen items-center justify-center text-sm text-danger-400">
Could not reach the panel API. Is the backend running?
<div className="flex min-h-screen items-center justify-center p-6">
<EmptyState
icon="alert"
title="Could not reach the panel API"
hint="Is the backend running?"
/>
</div>
);
}
@@ -62,13 +73,15 @@ function AuthGate() {
<Route element={<Layout user={user} />}>
<Route index element={<OverviewPage user={user} />} />
<Route path="/mods" element={<ModsPage user={user} />} />
<Route path="/configuration" element={<ConfigurationsPage user={user} />} />
<Route path="/configuration" element={<ConfigurationPage user={user} />} />
<Route path="/mission" element={<MissionPage user={user} />} />
<Route path="/players" element={<PlayersPage />} />
<Route path="/killfeed" element={<KillfeedPage />} />
<Route path="/activity" element={<ActivityPage />} />
<Route path="/logs" element={<LogsPage />} />
<Route path="/console" element={<ConsolePage />} />
<Route path="/settings" element={<SettingsPage user={user} />} />
{/* Old bookmarks from the tabbed server page and plural path. */}
{/* Old bookmarks. */}
<Route path="/logs" element={<Navigate to="/console" replace />} />
<Route path="/server/:slug" element={<Navigate to="/" replace />} />
<Route path="/configurations" element={<Navigate to="/configuration" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
@@ -81,9 +94,11 @@ function AuthGate() {
export function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AuthGate />
</BrowserRouter>
<ToastProvider>
<BrowserRouter>
<AuthGate />
</BrowserRouter>
</ToastProvider>
</QueryClientProvider>
);
}
+354 -176
View File
@@ -1,37 +1,49 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type {
ActivityItem,
ConfigPatchOp,
ConfigPatchResult,
ConfigRawResponse,
ConfigTreeResponse,
ConfigurationResponse,
ConsoleBacklog,
ConsoleLine,
CurrentUser,
InviteSummary,
KillfeedEvent,
MissionsResponse,
ModsCheckResponse,
PerformanceSettingsPatch,
PerformanceSettingsResponse,
RawLogsResponse,
RestartScheduleInput,
ResourceHistoryResponse,
StartupResponse,
KnownPlayer,
LogIngestionHealth,
LogSyncResult,
MissionsResponse,
ModPackSummary,
ModResolveResponse,
ModsOverviewResponse,
PanelUser,
PerformanceSettingsPatch,
PerformanceSettingsResponse,
PlayersResponse,
RawLogsResponse,
ReforgerConfigMod,
ResourceHistoryResponse,
RestartScheduleInput,
ServerModsResponse,
UpdateModsResult,
ServerResources,
ServerScheduleSummary,
ServerStatus,
ServerSummary,
WorkshopHealth,
StartupResponse,
UpdateModsResult,
WorkshopModDetail,
WorkshopModVersionsResponse,
WorkshopSearchResponse,
WorkshopServerModsResponse,
WorkshopServerSearchResponse,
} from '@reforger-panel/shared';
import { api, ApiClientError } from './client.js';
/* -------------------------------------------------------------------- auth */
export function useCurrentUser() {
return useQuery({
queryKey: ['auth', 'me'],
@@ -50,6 +62,8 @@ export function useLogout() {
});
}
/* ----------------------------------------------------------------- servers */
export function useServers() {
return useQuery({
queryKey: ['servers'],
@@ -58,23 +72,43 @@ export function useServers() {
});
}
export function useServer(slug: string) {
return useQuery({
queryKey: ['servers', slug],
queryFn: () => api.get<ServerSummary>(`/api/servers/${slug}`),
refetchInterval: 15_000,
});
/** The panel manages one server; every page derives its slug from here. */
export function usePrimaryServer(): ServerSummary | undefined {
return useServers().data?.servers[0];
}
export function useServerResources(slug: string, enabled = true) {
return useQuery({
queryKey: ['servers', slug, 'resources'],
queryFn: () => api.get<ServerResources>(`/api/servers/${slug}/resources`),
refetchInterval: 10_000,
// Backed by the websocket stats frame server-side, so this is a cheap
// in-memory read rather than an upstream request.
refetchInterval: 5_000,
enabled,
});
}
export function useResourceHistory(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'resources', 'history'],
queryFn: () => api.get<ResourceHistoryResponse>(`/api/servers/${slug}/resources/history`),
refetchInterval: 15_000,
});
}
export function usePowerAction(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (action: 'start' | 'stop' | 'restart') =>
api.post<{ ok: boolean; simulated: boolean }>(`/api/servers/${slug}/power/${action}`),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['servers'] });
},
});
}
/* ----------------------------------------------------------------- players */
export function usePlayers(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'players'],
@@ -109,6 +143,8 @@ export function useKillfeed(slug: string, limit = 100) {
});
}
/* ----------------------------------------------------------- configuration */
export function useConfiguration(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'configuration'],
@@ -119,45 +155,75 @@ export function useConfiguration(slug: string) {
});
}
export function useMissions(slug: string) {
export function usePerformanceSettings(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'missions'],
queryFn: () => api.get<MissionsResponse>(`/api/servers/${slug}/missions`),
staleTime: 60_000,
queryKey: ['servers', slug, 'config', 'performance'],
queryFn: () => api.get<PerformanceSettingsResponse>(`/api/servers/${slug}/config/performance`),
staleTime: 30_000,
refetchOnWindowFocus: false,
});
}
/**
* Opens a persistent SSE connection to stream live console output line by line.
* `onLine` is called for each received line. The connection closes and re-opens
* automatically when the component unmounts or `slug` changes.
*/
export function useConsoleStream(slug: string, onLine: (line: string) => void, enabled: boolean) {
const onLineRef = useRef(onLine);
onLineRef.current = onLine;
export type PerformanceSavePayload = {
settings: PerformanceSettingsPatch;
expectedRevision?: string;
writeStartupVars?: boolean;
};
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 useSetPerformanceSettings(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: PerformanceSavePayload) =>
api.put<PerformanceSettingsResponse & { changedFields: string[]; requiresRestart: boolean }>(
`/api/servers/${slug}/config/performance`,
payload,
),
onSuccess: () => {
// config.json moved: every view derived from it is now stale.
void queryClient.invalidateQueries({ queryKey: ['servers', slug] });
},
});
}
export function useRawLogs(slug: string, lines: number, autoRefresh: boolean, enabled: boolean) {
/** Every key present in config.json, for the searchable editor. */
export function useConfigTree(slug: string, enabled: boolean) {
return useQuery({
queryKey: ['servers', slug, 'logs', 'raw', lines],
queryFn: () => api.get<RawLogsResponse>(`/api/servers/${slug}/logs/raw?lines=${lines}`),
refetchInterval: autoRefresh ? 10_000 : false,
queryKey: ['servers', slug, 'config', 'tree'],
queryFn: () => api.get<ConfigTreeResponse>(`/api/servers/${slug}/config/tree`),
enabled,
staleTime: 30_000,
refetchOnWindowFocus: false,
});
}
export function usePatchConfig(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: {
ops: ConfigPatchOp[];
expectedRevision?: string;
writeStartupVars?: boolean;
}) => api.patch<ConfigPatchResult>(`/api/servers/${slug}/config`, payload),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['servers', slug] }),
});
}
export function useConfigRaw(slug: string, enabled: boolean) {
return useQuery({
queryKey: ['servers', slug, 'config', 'raw'],
queryFn: () => api.get<ConfigRawResponse>(`/api/servers/${slug}/config/raw`),
enabled,
staleTime: 30_000,
refetchOnWindowFocus: false,
});
}
export function usePutConfigRaw(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: { content: string; expectedRevision?: string }) =>
api.put<ConfigRawResponse>(`/api/servers/${slug}/config/raw`, payload),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['servers', slug] }),
});
}
@@ -183,6 +249,59 @@ export function useUpdateStartupVariable(slug: string) {
});
}
/* ---------------------------------------------------------------- missions */
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,
});
}
/* -------------------------------------------------------------------- mods */
export function useServerMods(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'mods'],
queryFn: () => api.get<ServerModsResponse>(`/api/servers/${slug}/mods`),
staleTime: 60_000,
refetchOnWindowFocus: false,
});
}
/**
* The whole Mods page in one request. While the server-side Workshop cache is
* still filling (`warming`), this refetches shortly so metadata appears
* progressively instead of blocking the first paint.
*/
export function useModsOverview(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'mods', 'overview'],
queryFn: () => api.get<ModsOverviewResponse>(`/api/servers/${slug}/mods/overview`),
staleTime: 60_000,
refetchOnWindowFocus: false,
refetchInterval: (query) => (query.state.data?.warming ? 3_000 : false),
});
}
export function useResolveMods(slug: string) {
return useMutation({
mutationFn: (mods: ReforgerConfigMod[]) =>
api.post<ModResolveResponse>(`/api/servers/${slug}/mods/resolve`, { mods }),
});
}
export function useSetServerMods(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: { mods: ReforgerConfigMod[]; expectedRevision?: string }) =>
api.put<UpdateModsResult>(`/api/servers/${slug}/mods`, payload),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['servers', slug] }),
});
}
export function useModPacks(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'mod-packs'],
@@ -190,128 +309,189 @@ export function useModPacks(slug: string) {
});
}
/* ---------------------------------------------------------------- workshop */
export type WorkshopSearchParams = {
query: string;
page: number;
sort?: string;
tag?: string;
category?: string;
};
export function useWorkshopSearch(params: WorkshopSearchParams, enabled = true) {
const search = new URLSearchParams({ q: params.query, page: String(params.page) });
if (params.sort) search.set('sort', params.sort);
if (params.tag) search.set('tag', params.tag);
if (params.category) search.set('category', params.category);
return useQuery({
queryKey: ['workshop', 'search', params],
queryFn: () => api.get<WorkshopSearchResponse>(`/api/workshop/search?${search}`),
enabled,
placeholderData: (previous) => previous,
staleTime: 5 * 60_000,
});
}
export function useWorkshopMod(modId: string | null) {
return useQuery({
queryKey: ['workshop', 'mod', modId],
queryFn: () => api.get<WorkshopModDetail>(`/api/workshop/mods/${modId}`),
enabled: modId !== null,
staleTime: 30 * 60_000,
});
}
export function useWorkshopModVersions(modId: string | null) {
return useQuery({
queryKey: ['workshop', 'mod', modId, 'versions'],
queryFn: () => api.get<WorkshopModVersionsResponse>(`/api/workshop/mods/${modId}/versions`),
enabled: modId !== null,
staleTime: 30 * 60_000,
});
}
/** Live server browser, used to copy another server's modlist. */
export function useWorkshopServers(query: string, enabled: boolean) {
return useQuery({
queryKey: ['workshop', 'servers', query],
queryFn: () =>
api.get<WorkshopServerSearchResponse>(`/api/workshop/servers?q=${encodeURIComponent(query)}`),
enabled: enabled && query.trim().length >= 2,
staleTime: 60_000,
});
}
export function useWorkshopServerMods(serverId: string | null) {
return useQuery({
queryKey: ['workshop', 'servers', serverId, 'mods'],
queryFn: () => api.get<WorkshopServerModsResponse>(`/api/workshop/servers/${serverId}/mods`),
enabled: serverId !== null,
staleTime: 60_000,
});
}
/* ------------------------------------------------------------ live console */
const MAX_CONSOLE_LINES = 2_000;
export type ConsoleFeed = {
lines: ConsoleLine[];
status: ServerStatus;
connected: boolean;
stats: ServerResources | null;
clear: () => void;
};
/**
* Subscribes to the panel's SSE relay of the Pterodactyl/Wings feed.
*
* Because the backend keeps its own line backlog, attaching mid-session
* immediately yields recent context — including install, update and mod
* download output, which never reaches the game's own log file.
*/
export function useConsoleFeed(slug: string, enabled: boolean): ConsoleFeed {
const [lines, setLines] = useState<ConsoleLine[]>([]);
const [status, setStatus] = useState<ServerStatus>('unknown');
const [connected, setConnected] = useState(false);
const [stats, setStats] = useState<ServerResources | null>(null);
const lastSeq = useRef(0);
useEffect(() => {
if (!enabled || !slug) return;
const source = new EventSource(`/api/servers/${slug}/console/stream`, {
withCredentials: true,
});
const append = (incoming: ConsoleLine[]) => {
const fresh = incoming.filter((line) => line.seq > lastSeq.current);
if (fresh.length === 0) return;
lastSeq.current = fresh[fresh.length - 1]!.seq;
setLines((current) => {
const next = [...current, ...fresh];
return next.length > MAX_CONSOLE_LINES ? next.slice(next.length - MAX_CONSOLE_LINES) : next;
});
};
const parse = <T>(event: MessageEvent<string>): T | null => {
try {
return JSON.parse(event.data) as T;
} catch {
return null;
}
};
source.addEventListener('backlog', (event) => {
const backlog = parse<ConsoleBacklog>(event as MessageEvent<string>);
if (!backlog) return;
// A reconnect replays the backlog; seq numbers keep it idempotent.
append(backlog.lines);
setStatus(backlog.status);
setConnected(backlog.connected);
});
source.addEventListener('line', (event) => {
const line = parse<ConsoleLine>(event as MessageEvent<string>);
if (line) append([line]);
});
source.addEventListener('status', (event) => {
const payload = parse<{ status: ServerStatus }>(event as MessageEvent<string>);
if (payload) setStatus(payload.status);
});
source.addEventListener('stats', (event) => {
const payload = parse<ServerResources>(event as MessageEvent<string>);
if (payload) setStats(payload);
});
source.onopen = () => setConnected(true);
source.onerror = () => setConnected(false);
return () => source.close();
}, [slug, enabled]);
return {
lines,
status,
connected,
stats,
clear: () => setLines([]),
};
}
/** The game's own log file, as a secondary diagnostic to the live feed. */
export function useRawLogs(slug: string, lines: number, enabled: boolean) {
return useQuery({
queryKey: ['servers', slug, 'logs', 'raw', lines],
queryFn: () => api.get<RawLogsResponse>(`/api/servers/${slug}/logs/raw?lines=${lines}`),
enabled,
staleTime: 10_000,
refetchOnWindowFocus: false,
});
}
/* ----------------------------------------------------------- log ingestion */
export function useLogHealth(slug: string, enabled: boolean) {
return useQuery({
queryKey: ['servers', slug, 'logs', 'health'],
queryFn: () => api.get<LogIngestionHealth>(`/api/servers/${slug}/logs/health`),
refetchInterval: 20_000,
refetchInterval: 30_000,
enabled,
});
}
export function usePowerAction(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (action: 'start' | 'stop' | 'restart') =>
api.post<{ ok: boolean; simulated: boolean }>(`/api/servers/${slug}/power/${action}`),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['servers'] });
},
});
}
export function useResourceHistory(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'resources', 'history'],
queryFn: () => api.get<ResourceHistoryResponse>(`/api/servers/${slug}/resources/history`),
refetchInterval: 15_000,
});
}
export function usePerformanceSettings(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'config', 'performance'],
queryFn: () => api.get<PerformanceSettingsResponse>(`/api/servers/${slug}/config/performance`),
staleTime: 60_000,
refetchOnWindowFocus: false,
});
}
export function useSetPerformanceSettings(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (settings: PerformanceSettingsPatch) =>
api.put<PerformanceSettingsResponse & { changedFields: string[]; requiresRestart: boolean }>(
`/api/servers/${slug}/config/performance`,
settings,
),
onSuccess: (result) => {
queryClient.setQueryData(['servers', slug, 'config', 'performance'], result);
void queryClient.invalidateQueries({ queryKey: ['servers', slug] });
},
});
}
export function useInvites(enabled: boolean) {
return useQuery({
queryKey: ['invites'],
queryFn: () => api.get<{ invites: InviteSummary[] }>('/api/invites'),
enabled,
});
}
export function useCreateInvite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: { role: string; expiresInHours?: number | null }) =>
api.post<{ id: string; code: string; role: string; expiresAt: string }>(
'/api/invites',
input,
),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['invites'] }),
});
}
export function useDeleteInvite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.delete(`/api/invites/${id}`),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['invites'] }),
});
}
export function useServerMods(slug: string) {
return useQuery({
queryKey: ['servers', slug, 'mods'],
queryFn: () => api.get<ServerModsResponse>(`/api/servers/${slug}/mods`),
// Each call downloads config.json from Pterodactyl — no background polling.
staleTime: 60_000,
refetchOnWindowFocus: false,
});
}
export function useSetServerMods(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (mods: ReforgerConfigMod[]) =>
api.put<UpdateModsResult>(`/api/servers/${slug}/mods`, { mods }),
onSuccess: (result) => {
queryClient.setQueryData(['servers', slug, 'mods'], result);
void queryClient.invalidateQueries({ queryKey: ['servers', slug] });
},
});
}
export function useServerModsCheck(slug: string, enabled: boolean) {
return useQuery({
queryKey: ['servers', slug, 'mods', 'check'],
queryFn: () => api.get<ModsCheckResponse>(`/api/servers/${slug}/mods/check`),
enabled,
staleTime: 2 * 60_000,
refetchOnWindowFocus: false,
});
}
export function useManualLogSync(slug: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => api.post<LogSyncResult>(`/api/servers/${slug}/logs/sync`),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['servers', slug] });
},
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['servers', slug] }),
});
}
/* --------------------------------------------------------------- schedules */
export function useServerSchedules(slug: string, enabled: boolean) {
return useQuery({
queryKey: ['servers', slug, 'schedules'],
@@ -359,35 +539,33 @@ export function useDeleteSchedule(slug: string) {
});
}
export function useWorkshopHealth() {
/* --------------------------------------------------------- users & invites */
export function useInvites(enabled: boolean) {
return useQuery({
queryKey: ['workshop', 'health'],
queryFn: () => api.get<WorkshopHealth>('/api/workshop/health'),
refetchInterval: 60_000,
queryKey: ['invites'],
queryFn: () => api.get<{ invites: InviteSummary[] }>('/api/invites'),
enabled,
});
}
export function useWorkshopSearch(query: string, page: number, sort?: string) {
return useQuery({
queryKey: ['workshop', 'search', query, page, sort],
queryFn: () =>
api.get<WorkshopSearchResponse>(
`/api/workshop/search?q=${encodeURIComponent(query)}&page=${page}${
sort ? `&sort=${encodeURIComponent(sort)}` : ''
}`,
export function useCreateInvite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: { role: string; expiresInHours?: number | null }) =>
api.post<{ id: string; code: string; role: string; expiresAt: string }>(
'/api/invites',
input,
),
// An empty query browses the Workshop front page (/v1/mods).
placeholderData: (previous) => previous,
staleTime: 5 * 60_000,
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['invites'] }),
});
}
export function useWorkshopMod(modId: string | null) {
return useQuery({
queryKey: ['workshop', 'mod', modId],
queryFn: () => api.get<WorkshopModDetail>(`/api/workshop/mods/${modId}`),
enabled: modId !== null,
staleTime: 5 * 60_000,
export function useDeleteInvite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.delete(`/api/invites/${id}`),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['invites'] }),
});
}
+136 -34
View File
@@ -1,3 +1,5 @@
import { useId, useRef, useState } from 'react';
export type ChartSeries = {
points: { t: number; v: number }[];
/** Any CSS color; used for the line and (when filled) the area. */
@@ -9,24 +11,34 @@ export type ChartSeries = {
/**
* Dependency-free SVG time-series chart. Series share the x (time) axis and a
* single y scale (`max` fixes it, e.g. 100 for CPU%; otherwise it fits data).
*
* Unlike a plain sparkline this is readable: it carries a gridline, an axis
* maximum, and a hover crosshair that reports the value under the pointer —
* previously there was no way to get a number off these graphs at all.
*/
export function TimeSeriesChart({
series,
max,
height = 64,
height = 56,
className = '',
format = (value: number) => value.toFixed(0),
}: {
series: ChartSeries[];
max?: number | null;
height?: number;
className?: string;
format?: (value: number) => string;
}) {
const clipId = useId();
const svgRef = useRef<SVGSVGElement | null>(null);
const [hover, setHover] = useState<{ ratio: number } | null>(null);
const allPoints = series.flatMap((s) => s.points);
if (allPoints.length < 2) {
return (
<div
style={{ height }}
className={`flex items-center justify-center rounded bg-graphite-850 text-xs text-slate-dim ${className}`}
className={`flex items-center justify-center rounded-xs border border-graphite-800 bg-graphite-950 text-2xs text-slate-faint ${className}`}
>
collecting data…
</div>
@@ -44,38 +56,128 @@ export function TimeSeriesChart({
const x = (t: number) => ((t - tMin) / tSpan) * W;
const y = (v: number) => H - Math.min(1, Math.max(0, v / scale)) * H;
/** Nearest sample to the hovered x position, per series. */
const hovered =
hover === null
? null
: series.map((s) => {
const target = tMin + hover.ratio * tSpan;
let best = s.points[0]!;
for (const point of s.points) {
if (Math.abs(point.t - target) < Math.abs(best.t - target)) best = point;
}
return { series: s, point: best };
});
const onPointerMove = (event: React.PointerEvent<SVGSVGElement>) => {
const rect = svgRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return;
setHover({ ratio: Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)) });
};
return (
<svg
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
style={{ height }}
className={`w-full ${className}`}
role="img"
>
{/* 50% guide line */}
<line x1="0" y1={H / 2} x2={W} y2={H / 2} stroke="currentColor" strokeOpacity="0.08" />
{series.map((s, index) => {
if (s.points.length < 2) return null;
const line = s.points
.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(p.t).toFixed(2)},${y(p.v).toFixed(2)}`)
.join(' ');
const first = s.points[0]!;
const last = s.points[s.points.length - 1]!;
const area = `${line} L${x(last.t).toFixed(2)},${H} L${x(first.t).toFixed(2)},${H} Z`;
return (
<g key={s.label ?? index}>
{s.fill !== false && <path d={area} fill={s.color} fillOpacity="0.12" />}
<path
d={line}
fill="none"
stroke={s.color}
strokeWidth="1.1"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</g>
);
})}
</svg>
<div className={`relative ${className}`}>
<svg
ref={svgRef}
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="none"
style={{ height }}
className="w-full touch-none"
role="img"
onPointerMove={onPointerMove}
onPointerLeave={() => setHover(null)}
>
<defs>
<clipPath id={clipId}>
<rect x="0" y="0" width={W} height={H} />
</clipPath>
</defs>
{/* Quarter gridlines give the eye a scale without adding clutter. */}
{[0.25, 0.5, 0.75].map((fraction) => (
<line
key={fraction}
x1="0"
y1={H * fraction}
x2={W}
y2={H * fraction}
stroke="currentColor"
strokeOpacity={fraction === 0.5 ? 0.12 : 0.06}
strokeWidth="0.5"
vectorEffect="non-scaling-stroke"
/>
))}
<g clipPath={`url(#${clipId})`}>
{series.map((s, index) => {
if (s.points.length < 2) return null;
const line = s.points
.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(p.t).toFixed(2)},${y(p.v).toFixed(2)}`)
.join(' ');
const first = s.points[0]!;
const last = s.points[s.points.length - 1]!;
const area = `${line} L${x(last.t).toFixed(2)},${H} L${x(first.t).toFixed(2)},${H} Z`;
return (
<g key={s.label ?? index}>
{s.fill !== false && <path d={area} fill={s.color} fillOpacity="0.1" />}
<path
d={line}
fill="none"
stroke={s.color}
strokeWidth="1.2"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</g>
);
})}
{hovered && (
<>
<line
x1={hover!.ratio * W}
y1="0"
x2={hover!.ratio * W}
y2={H}
stroke="currentColor"
strokeOpacity="0.35"
strokeWidth="0.5"
vectorEffect="non-scaling-stroke"
/>
{hovered.map(({ series: s, point }, index) => (
<circle
key={s.label ?? index}
cx={x(point.t)}
cy={y(point.v)}
r="1.5"
fill={s.color}
vectorEffect="non-scaling-stroke"
/>
))}
</>
)}
</g>
</svg>
{/* Axis maximum, so the shape has a magnitude attached to it. */}
<span className="numeric pointer-events-none absolute right-0 top-0 text-2xs leading-none text-slate-faint">
{format(scale)}
</span>
{hovered && (
<div
className="numeric pointer-events-none absolute -top-1 z-10 -translate-y-full whitespace-nowrap rounded-xs border border-graphite-600 bg-graphite-850 px-1.5 py-1 text-2xs text-zinc-100 shadow-lg shadow-black/40"
style={{
left: `${hover!.ratio * 100}%`,
transform: `translate(${hover!.ratio > 0.6 ? '-100%' : '0'}, -100%)`,
}}
>
{hovered.map(({ series: s, point }, index) => (
<div key={s.label ?? index} className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full" style={{ background: s.color }} />
{s.label && <span className="text-slate-dim">{s.label}</span>}
<span>{format(point.v)}</span>
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,293 @@
import { useMemo, useState } from 'react';
import type { ConfigEntry, ConfigPatchOp, StartupMirror } from '@reforger-panel/shared';
import { useConfigTree, usePatchConfig } from '../../api/hooks.js';
import { formatRelativeTime } from '../../lib/format.js';
import {
Badge,
Button,
EmptyState,
Notice,
SearchInput,
Spinner,
Toggle,
useToast,
} from '../ui.js';
import { Icon } from '../icons.js';
type EditValue = string | number | boolean | null;
/**
* Searchable editor over every key config.json actually contains.
*
* The panel used to reach only eleven hardcoded fields, and submitted all of
* them on every save. Here each row tracks its own dirty state and only the
* touched paths are sent, against the revision the page was loaded at — so a
* stale tab is rejected instead of quietly reverting someone else's edit.
*/
export function ConfigKeyEditor({ slug, canEdit }: { slug: string; canEdit: boolean }) {
const toast = useToast();
const { data, isLoading, error, refetch } = useConfigTree(slug, canEdit);
const patch = usePatchConfig(slug);
const [query, setQuery] = useState('');
const [edits, setEdits] = useState<Map<string, EditValue>>(new Map());
const [writeStartupVars, setWriteStartupVars] = useState(true);
const entries = data?.entries ?? [];
const mirrorByPath = useMemo(() => {
const map = new Map<string, StartupMirror>();
for (const mirror of data?.mirrors ?? []) map.set(mirror.configPath, mirror);
return map;
}, [data?.mirrors]);
const visible = useMemo(() => {
if (!query.trim()) return entries;
const needle = query.trim().toLowerCase();
return entries.filter(
(entry) =>
entry.path.toLowerCase().includes(needle) ||
String(entry.value ?? '')
.toLowerCase()
.includes(needle),
);
}, [entries, query]);
const setEdit = (path: string, value: EditValue) => {
setEdits((current) => {
const next = new Map(current);
next.set(path, value);
return next;
});
};
const clearEdit = (path: string) => {
setEdits((current) => {
const next = new Map(current);
next.delete(path);
return next;
});
};
const ops: ConfigPatchOp[] = useMemo(
() => [...edits.entries()].map(([path, value]) => ({ path, value })),
[edits],
);
const touchedMirrors = ops
.map((op) => mirrorByPath.get(op.path))
.filter((mirror): mirror is StartupMirror => mirror !== undefined);
const conflictingMirrors = (data?.mirrors ?? []).filter((mirror) => mirror.conflict);
const apply = () => {
patch.mutate(
{ ops, expectedRevision: data?.revision, writeStartupVars },
{
onSuccess: (result) => {
setEdits(new Map());
void refetch();
toast(
result.changedPaths.length === 0
? 'No changes to save.'
: `Saved ${result.changedPaths.length} value${result.changedPaths.length === 1 ? '' : 's'}${
result.startupVarsWritten.length > 0
? ` (also mirrored to ${result.startupVarsWritten.join(', ')})`
: ''
}. Restart to apply.`,
'ok',
);
},
onError: (mutationError) => toast(mutationError.message, 'danger'),
},
);
};
if (!canEdit) {
return <EmptyState icon="lock" title="Configuration editing is restricted to admins" />;
}
if (isLoading) return <Spinner label="Downloading config.json…" />;
if (error || !data) {
return (
<EmptyState
icon="alert"
title="Could not read config.json"
hint={error?.message}
action={
<Button icon="refresh" onClick={() => void refetch()}>
Retry
</Button>
}
/>
);
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<SearchInput
value={query}
onChange={setQuery}
placeholder="Find any key, e.g. view distance, rcon, battlEye…"
className="w-full sm:w-96"
/>
<span className="numeric ml-auto text-2xs text-slate-dim">
{visible.length} of {entries.length} keys · read {formatRelativeTime(data.fetchedAt)}
</span>
</div>
{conflictingMirrors.length > 0 && (
<Notice tone="warn" title="Some values are also templated from startup variables">
<p>
This egg regenerates parts of config.json from Pterodactyl startup variables at boot.
For these keys the file and the variable currently disagree, so the variable wins on the
next restart:
</p>
<ul className="mt-1.5 space-y-0.5">
{conflictingMirrors.map((mirror) => (
<li key={`${mirror.envVariable}-${mirror.configPath}`} className="font-mono text-2xs">
{mirror.configPath} = {String(mirror.configValue ?? '—')} · {mirror.envVariable} ={' '}
{mirror.startupValue || '—'}
</li>
))}
</ul>
</Notice>
)}
{visible.length === 0 ? (
<EmptyState title="No keys match that search" />
) : (
<ul className="divide-y divide-graphite-800 overflow-hidden rounded-md border border-graphite-700">
{visible.map((entry) => (
<KeyRow
key={entry.path}
entry={entry}
edited={edits.has(entry.path)}
editValue={edits.get(entry.path) ?? null}
mirror={mirrorByPath.get(entry.path)}
onChange={(value) => setEdit(entry.path, value)}
onReset={() => clearEdit(entry.path)}
/>
))}
</ul>
)}
{ops.length > 0 && (
<div className="sticky bottom-0 -mx-4 flex flex-wrap items-center gap-3 border-t border-graphite-600 bg-graphite-900/95 px-4 py-3 backdrop-blur">
<span className="text-xs text-zinc-200">
{ops.length} value{ops.length === 1 ? '' : 's'} changed
</span>
{touchedMirrors.length > 0 && (
<label className="flex items-center gap-2 text-2xs text-warn-400">
<Toggle
checked={writeStartupVars}
onChange={setWriteStartupVars}
label="Also write matching startup variables"
/>
Also write {touchedMirrors.map((mirror) => mirror.envVariable).join(', ')} so the
change survives a restart
</label>
)}
<div className="ml-auto flex items-center gap-2">
<Button onClick={() => setEdits(new Map())} disabled={patch.isPending}>
Discard
</Button>
<Button variant="accent" icon="upload" onClick={apply} loading={patch.isPending}>
Apply to server
</Button>
</div>
</div>
)}
</div>
);
}
function KeyRow({
entry,
edited,
editValue,
mirror,
onChange,
onReset,
}: {
entry: ConfigEntry;
edited: boolean;
editValue: EditValue;
mirror: StartupMirror | undefined;
onChange: (value: EditValue) => void;
onReset: () => void;
}) {
const value = edited ? editValue : entry.value;
const removed = edited && editValue === null;
const readOnly = entry.type === 'array';
return (
<li
className={`flex flex-wrap items-center gap-3 px-3 py-2 ${edited ? 'bg-accent-600/[0.06]' : 'hover:bg-graphite-850/50'}`}
>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 font-mono text-xs text-zinc-100">
<span className="truncate">{entry.path}</span>
{edited && <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent-400" />}
{mirror && (
<Badge tone="warn" icon="alert" title={`Also set by ${mirror.envVariable}`}>
{mirror.envVariable}
</Badge>
)}
</p>
{removed && (
<p className="mt-0.5 text-2xs text-danger-400">
Key will be removed — the game default applies.
</p>
)}
</div>
<div className="flex w-full shrink-0 items-center gap-2 sm:w-72">
{readOnly ? (
<span className="truncate font-mono text-2xs text-slate-dim" title={entry.raw}>
{entry.raw}
</span>
) : entry.type === 'boolean' ? (
<select
value={removed ? '' : String(value)}
onChange={(event) =>
onChange(event.target.value === '' ? null : event.target.value === 'true')
}
className="input"
>
<option value="true">true</option>
<option value="false">false</option>
<option value="">(remove key)</option>
</select>
) : entry.type === 'number' ? (
<input
type="number"
value={removed ? '' : String(value ?? '')}
placeholder="(removed)"
onChange={(event) =>
onChange(event.target.value === '' ? null : Number(event.target.value))
}
className="input numeric"
/>
) : (
<input
value={removed ? '' : String(value ?? '')}
placeholder="(removed)"
onChange={(event) => onChange(event.target.value)}
className="input font-mono text-xs"
/>
)}
{!readOnly && (
<button
type="button"
title={edited ? 'Revert to the value on the server' : 'Remove this key'}
onClick={() => (edited ? onReset() : onChange(null))}
className="shrink-0 rounded-sm border border-graphite-700 p-1.5 text-slate-dim transition-colors hover:text-zinc-100"
>
<Icon name={edited ? 'refresh' : 'trash'} className="h-3.5 w-3.5" />
</button>
)}
</div>
</li>
);
}
@@ -0,0 +1,33 @@
/**
* Startup variables that a Reforger egg typically templates into config.json
* at boot, and the config path each one lands on.
*
* Mirrors the authoritative server-side map in
* `apps/api/src/modules/config/startup-mirrors.ts`. The API detects these
* properly (it can see which variables the egg actually exposes and whether
* the two values currently disagree); this copy exists only so the startup
* variable list can label a row without a second round trip.
*/
export const STARTUP_MIRROR_HINTS: Record<string, string> = {
SCENARIO_ID: 'game.scenarioId',
MISSION_ID: 'game.scenarioId',
MAX_PLAYERS: 'game.maxPlayers',
SERVER_NAME: 'game.name',
HOSTNAME: 'game.name',
SERVER_PASSWORD: 'game.password',
ADMIN_PASSWORD: 'game.passwordAdmin',
GAME_PORT: 'bindPort',
SERVER_PORT: 'bindPort',
BIND_PORT: 'bindPort',
SERVER_IP: 'bindAddress',
BIND_ADDRESS: 'bindAddress',
A2S_PORT: 'a2s.port',
RCON_PORT: 'rcon.port',
RCON_PASSWORD: 'rcon.password',
CROSS_PLATFORM: 'game.crossPlatform',
CROSSPLAY: 'game.crossPlatform',
BATTLEYE: 'game.gameProperties.battlEye',
VISIBLE: 'game.visible',
DISABLE_THIRD_PERSON: 'game.gameProperties.disableThirdPerson',
VIEW_DISTANCE: 'game.gameProperties.serverMaxViewDistance',
};
@@ -0,0 +1,107 @@
import { useEffect, useMemo, useState } from 'react';
import { useConfigRaw, usePutConfigRaw } from '../../api/hooks.js';
import { formatRelativeTime } from '../../lib/format.js';
import { Badge, Button, EmptyState, Notice, Spinner, useToast } from '../ui.js';
/**
* Direct editor for config.json, for the cases a structured form cannot cover.
* The write is refused server-side if the file moved since it was loaded, and
* the previous content is always kept as config.json.bak.
*/
export function ConfigRawEditor({ slug, canEdit }: { slug: string; canEdit: boolean }) {
const toast = useToast();
const { data, isLoading, error, refetch } = useConfigRaw(slug, canEdit);
const save = usePutConfigRaw(slug);
const [content, setContent] = useState<string | null>(null);
useEffect(() => {
if (data) setContent((current) => current ?? data.content);
}, [data]);
const parseError = useMemo(() => {
if (content === null) return null;
try {
const parsed: unknown = JSON.parse(content);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return 'config.json must be a JSON object.';
}
if (!('game' in parsed)) return 'config.json must contain a "game" section.';
return null;
} catch (jsonError) {
return jsonError instanceof Error ? jsonError.message : 'Invalid JSON.';
}
}, [content]);
if (!canEdit) {
return <EmptyState icon="lock" title="Configuration editing is restricted to admins" />;
}
if (isLoading || content === null) return <Spinner label="Downloading config.json…" />;
if (error || !data) {
return (
<EmptyState
icon="alert"
title="Could not read config.json"
hint={error?.message}
action={
<Button icon="refresh" onClick={() => void refetch()}>
Retry
</Button>
}
/>
);
}
const dirty = content !== data.content;
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Badge tone={parseError ? 'danger' : 'ok'} icon={parseError ? 'alert' : 'check'}>
{parseError ? 'invalid JSON' : 'valid JSON'}
</Badge>
{dirty && <Badge tone="warn">unsaved changes</Badge>}
<span className="numeric ml-auto text-2xs text-slate-dim">
revision {data.revision} · read {formatRelativeTime(data.fetchedAt)}
</span>
</div>
{parseError && <Notice tone="danger">{parseError}</Notice>}
<textarea
spellCheck={false}
value={content}
onChange={(event) => setContent(event.target.value)}
className="input h-[28rem] w-full resize-y font-mono text-xs leading-5"
/>
<div className="flex flex-wrap items-center justify-end gap-2">
<span className="mr-auto text-2xs text-slate-dim">
The previous file is kept as config.json.bak. Changes apply on the next restart.
</span>
<Button onClick={() => setContent(data.content)} disabled={!dirty || save.isPending}>
Revert
</Button>
<Button
variant="accent"
icon="upload"
disabled={!dirty || parseError !== null}
loading={save.isPending}
onClick={() =>
save.mutate(
{ content, expectedRevision: data.revision },
{
onSuccess: (result) => {
setContent(result.content);
toast('config.json written. Restart to apply.', 'ok');
},
onError: (mutationError) => toast(mutationError.message, 'danger'),
},
)
}
>
Write config.json
</Button>
</div>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
import type { SVGProps } from 'react';
/**
* Inline SVG icon set — no icon dependency, no runtime font.
*
* Every glyph is drawn on a 24px grid with a 1.6px stroke so weights stay
* consistent next to 13px text, and inherits `currentColor` so a single class
* on the parent controls colour.
*/
export type IconName =
| 'gauge'
| 'package'
| 'sliders'
| 'map'
| 'users'
| 'crosshair'
| 'pulse'
| 'terminal'
| 'settings'
| 'play'
| 'stop'
| 'restart'
| 'plus'
| 'minus'
| 'trash'
| 'refresh'
| 'download'
| 'upload'
| 'search'
| 'close'
| 'check'
| 'chevron-down'
| 'chevron-right'
| 'chevron-left'
| 'alert'
| 'info'
| 'link'
| 'copy'
| 'menu'
| 'arrow-up'
| 'filter'
| 'server'
| 'image'
| 'lock'
| 'exit';
const PATHS: Record<IconName, string> = {
gauge: 'M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm1.4-3.4L17 7M3.6 18a9 9 0 1 1 16.8 0',
package: 'M21 8v8l-9 5-9-5V8l9-5 9 5Zm-18 0 9 5 9-5m-9 5v8',
sliders: 'M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0M14 4v4M8 10v4M16 16v4',
map: 'm9 4-6 3v13l6-3 6 3 6-3V4l-6 3-6-3Zm0 0v13m6-10v13',
users:
'M16 20v-1.5a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4V20M9 10.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM22 20v-1.5a4 4 0 0 0-3-3.87M16 3.6a4 4 0 0 1 0 6.8',
crosshair: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-15v3m0 6v3m6-6h-3m-6 0H3',
pulse: 'M3 12h3.5L9 5l4 14 2.5-7H21',
terminal:
'm5 8 4 4-4 4m6 1h8M3 20h18a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1Z',
settings:
'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7.5-3a7.5 7.5 0 0 1-.1 1.2l2 1.5-2 3.4-2.4-1a7.5 7.5 0 0 1-2 1.2l-.4 2.5h-4l-.4-2.5a7.5 7.5 0 0 1-2-1.2l-2.4 1-2-3.4 2-1.5a7.5 7.5 0 0 1 0-2.4l-2-1.5 2-3.4 2.4 1a7.5 7.5 0 0 1 2-1.2L8.6 3h4l.4 2.5a7.5 7.5 0 0 1 2 1.2l2.4-1 2 3.4-2 1.5c.06.4.1.8.1 1.2Z',
play: 'M7 4.5v15l13-7.5-13-7.5Z',
stop: 'M6 6h12v12H6z',
restart: 'M20 12a8 8 0 1 1-2.6-5.9M20 4v5h-5',
plus: 'M12 5v14M5 12h14',
minus: 'M5 12h14',
trash:
'M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m3 0v12a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7m4 4v6m4-6v6',
refresh: 'M21 12a9 9 0 0 1-15.1 6.6M3 12a9 9 0 0 1 15.1-6.6M3 20v-5h5M21 4v5h-5',
download: 'M12 3v12m0 0 4.5-4.5M12 15l-4.5-4.5M4 20h16',
upload: 'M12 21V9m0 0 4.5 4.5M12 9 7.5 13.5M4 4h16',
search: 'M20 20l-4.2-4.2M17 11a6 6 0 1 1-12 0 6 6 0 0 1 12 0Z',
close: 'M6 6l12 12M18 6 6 18',
check: 'm5 13 4.5 4.5L19 7',
'chevron-down': 'm6 9 6 6 6-6',
'chevron-right': 'm9 6 6 6-6 6',
'chevron-left': 'm15 6-6 6 6 6',
alert:
'M12 9v4.5m0 3.5v.01M10.3 4.2 2.6 17.6A2 2 0 0 0 4.3 20.6h15.4a2 2 0 0 0 1.7-3L13.7 4.2a2 2 0 0 0-3.4 0Z',
info: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-9.5V16m0-8v.01',
link: 'M10 13a5 5 0 0 0 7.5.5l2-2A5 5 0 0 0 12.5 4.5L11 6m3 5a5 5 0 0 0-7.5-.5l-2 2A5 5 0 0 0 11.5 19.5L13 18',
copy: 'M9 9h10v10a1 1 0 0 1-1 1h-8a1 1 0 0 1-1-1V9Zm-4 6H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1',
menu: 'M4 6h16M4 12h16M4 18h16',
'arrow-up': 'M12 20V5m0 0-6 6m6-6 6 6',
filter: 'M3 5h18l-7 8v6l-4 2v-8L3 5Z',
server:
'M4 4h16a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm0 10h16a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1Zm3-7h.01M7 17h.01',
image: 'M3 5h18v14H3zM9 11a1.75 1.75 0 1 0 0-3.5A1.75 1.75 0 0 0 9 11Zm-6 7 5-5 3 3 4-4 6 6',
lock: 'M7 11V8a5 5 0 0 1 10 0v3M5 11h14a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1Z',
exit: 'M15 17l5-5-5-5m5 5H9M12 3H5a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h7',
};
/** Icons that read better filled than stroked. */
const FILLED = new Set<IconName>(['play', 'stop']);
export function Icon({
name,
className = 'h-4 w-4',
...props
}: { name: IconName; className?: string } & Omit<SVGProps<SVGSVGElement>, 'name'>) {
const filled = FILLED.has(name);
return (
<svg
viewBox="0 0 24 24"
aria-hidden
focusable="false"
className={`shrink-0 ${className}`}
fill={filled ? 'currentColor' : 'none'}
stroke={filled ? 'none' : 'currentColor'}
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d={PATHS[name]} />
</svg>
);
}
/** Small animated ring used inside buttons while a mutation is in flight. */
export function Spinner16({ className = 'h-4 w-4' }: { className?: string }) {
return (
<span
aria-hidden
className={`inline-block animate-spin rounded-full border-2 border-current/25 border-t-current ${className}`}
/>
);
}
+120 -60
View File
@@ -1,31 +1,54 @@
import { useState } from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import type { Capability, CurrentUser } from '@reforger-panel/shared';
import { useLogout, useServers } from '../api/hooks.js';
import { RoleBadge, StatusBadge } from './ui.js';
import {
useLogout,
useModsOverview,
usePlayers,
useServerResources,
useServers,
} from '../api/hooks.js';
import { formatDuration } from '../lib/format.js';
import { IconButton, RoleBadge, StatusBadge } from './ui.js';
import { Icon, type IconName } from './icons.js';
import { PowerControls } from './widgets.js';
const NAV_ITEMS: {
to: string;
label: string;
icon: IconName;
exact?: boolean;
capability?: Capability;
}[] = [
{ to: '/', label: 'Overview', exact: true },
{ to: '/mods', label: 'Mods' },
{ to: '/configuration', label: 'Configuration' },
{ to: '/players', label: 'Players' },
{ to: '/killfeed', label: 'Killfeed' },
{ to: '/activity', label: 'Activity' },
{ to: '/logs', label: 'Logs', capability: 'ops.health.view' },
{ to: '/settings', label: 'Settings' },
{ to: '/', label: 'Overview', icon: 'gauge', exact: true },
{ to: '/mods', label: 'Mods', icon: 'package' },
{ to: '/configuration', label: 'Configuration', icon: 'sliders' },
{ to: '/mission', label: 'Mission', icon: 'map' },
{ to: '/players', label: 'Players', icon: 'users' },
{ to: '/killfeed', label: 'Killfeed', icon: 'crosshair' },
{ to: '/activity', label: 'Activity', icon: 'pulse' },
{ to: '/console', label: 'Console', icon: 'terminal', capability: 'ops.health.view' },
{ to: '/settings', label: 'Settings', icon: 'settings' },
];
export function Layout({ user }: { user: CurrentUser }) {
const logout = useLogout();
const { data: serversData } = useServers();
const server = serversData?.servers[0];
const slug = server?.slug ?? '';
const { data: resources } = useServerResources(slug, Boolean(slug));
const { data: players } = usePlayers(slug);
const { data: mods } = useModsOverview(slug);
const [navOpen, setNavOpen] = useState(false);
const location = useLocation();
// Close the drawer on navigation so a tap never leaves it hanging open.
useEffect(() => setNavOpen(false), [location.pathname]);
const counts: Partial<Record<string, number>> = {
'/mods': mods?.mods.length,
'/players': players?.onlineCount,
};
return (
<div className="flex min-h-screen">
@@ -33,46 +56,58 @@ export function Layout({ user }: { user: CurrentUser }) {
<div
aria-hidden
onClick={() => setNavOpen(false)}
className="fixed inset-0 z-20 bg-black/60 backdrop-blur-sm lg:hidden"
className="fixed inset-0 z-20 bg-black/70 backdrop-blur-sm lg:hidden"
/>
)}
<aside
className={`fixed inset-y-0 left-0 z-30 flex h-dvh w-56 shrink-0 flex-col border-r border-graphite-700/70 bg-graphite-900 transition-transform duration-200 lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 ${
className={`fixed inset-y-0 left-0 z-30 flex h-dvh w-56 shrink-0 flex-col border-r border-graphite-700 bg-graphite-900 transition-transform duration-150 lg:sticky lg:top-0 lg:h-screen lg:translate-x-0 ${
navOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex min-h-16 items-center border-b border-graphite-700/60 px-5">
<div>
<p className="text-[13px] font-semibold uppercase leading-tight tracking-[0.12em] text-zinc-100">
<div className="flex min-h-14 items-center gap-2.5 border-b border-graphite-700 px-4">
<span className="flex h-7 w-7 items-center justify-center rounded-sm border border-accent-600/50 bg-accent-600/15 text-accent-400">
<Icon name="server" className="h-4 w-4" />
</span>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase leading-tight tracking-[0.14em] text-zinc-100">
DZR.TOOLS
</p>
<p className="text-[10px] uppercase tracking-[0.16em] text-slate-dim">
ARMA REFORGER OPS
<p className="text-2xs uppercase leading-tight tracking-[0.16em] text-slate-faint">
Reforger Ops
</p>
</div>
</div>
<nav className="min-h-0 flex-1 space-y-1 overflow-y-auto p-3">
<nav className="min-h-0 flex-1 space-y-0.5 overflow-y-auto p-2">
{NAV_ITEMS.filter(
(item) => !item.capability || user.capabilities.includes(item.capability),
).map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.exact}
onClick={() => setNavOpen(false)}
className={({ isActive }) =>
`block rounded-md border border-transparent px-3.5 py-2.5 text-sm transition-colors ${
isActive
? 'border-graphite-700 bg-graphite-850 font-medium text-zinc-100'
: 'text-slate-ink hover:bg-graphite-800 hover:text-zinc-200'
}`
}
>
{item.label}
</NavLink>
))}
).map((item) => {
const count = counts[item.to];
return (
<NavLink
key={item.to}
to={item.to}
end={item.exact}
className={({ isActive }) =>
`flex items-center gap-2.5 rounded-sm border-l-2 px-3 py-2 text-sm transition-colors ${
isActive
? 'border-accent-500 bg-graphite-850 font-medium text-zinc-50'
: 'border-transparent text-slate-ink hover:bg-graphite-850/60 hover:text-zinc-100'
}`
}
>
<Icon name={item.icon} className="h-4 w-4" />
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{count !== undefined && count > 0 && (
<span className="numeric text-2xs text-slate-faint">{count}</span>
)}
</NavLink>
);
})}
</nav>
<div className="border-t border-graphite-700/60 px-5 py-4">
<div className="border-t border-graphite-700 px-3 py-3">
<div className="flex items-center gap-2.5">
{user.avatarUrl ? (
<img
@@ -81,60 +116,85 @@ export function Layout({ user }: { user: CurrentUser }) {
className="h-8 w-8 rounded-full border border-graphite-600"
/>
) : (
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-graphite-700 text-sm font-semibold text-zinc-300">
<span className="flex h-8 w-8 items-center justify-center rounded-full border border-graphite-600 bg-graphite-800 text-sm font-semibold text-zinc-300">
{(user.displayName ?? user.username).slice(0, 1).toUpperCase()}
</span>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-zinc-200">{user.displayName ?? user.username}</p>
<p className="truncate text-xs text-zinc-200">{user.displayName ?? user.username}</p>
<RoleBadge role={user.role} />
</div>
<button
type="button"
title="Log out"
<IconButton
icon="exit"
label="Log out"
onClick={() =>
logout.mutate(undefined, { onSuccess: () => window.location.reload() })
}
className="rounded-md border border-graphite-600 px-2 py-1 text-xs text-slate-ink transition-colors hover:border-danger-400/50 hover:text-danger-400"
>
Exit
</button>
size="sm"
/>
</div>
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-10 flex min-h-16 shrink-0 flex-wrap items-center gap-x-4 gap-y-2 border-b border-graphite-700/60 bg-graphite-900/85 px-4 py-3 backdrop-blur sm:px-6">
<header className="sticky top-0 z-10 flex min-h-14 shrink-0 flex-wrap items-center gap-x-4 gap-y-2 border-b border-graphite-700 bg-graphite-900/90 px-4 py-2.5 backdrop-blur sm:px-6">
<button
type="button"
aria-label="Open navigation"
onClick={() => setNavOpen(true)}
className="rounded-md border border-graphite-600 p-2 text-slate-ink transition-colors hover:text-zinc-200 lg:hidden"
className="rounded-sm border border-graphite-600 p-1.5 text-slate-ink transition-colors hover:text-zinc-100 lg:hidden"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" className="h-5 w-5">
<path d="M4 6h16M4 12h16M4 18h16" strokeWidth="1.8" strokeLinecap="round" />
</svg>
<Icon name="menu" className="h-4 w-4" />
</button>
{server ? (
<div className="flex min-w-0 flex-1 items-center gap-3 sm:gap-4">
<div className="min-w-28 truncate">
<p className="text-[10px] uppercase tracking-[0.16em] text-slate-dim">Server</p>
<h2 className="truncate text-base font-semibold text-zinc-100">{server.name}</h2>
<div className="flex min-w-0 flex-1 items-center gap-3 sm:gap-5">
<div className="min-w-0">
<p className="eyebrow leading-tight">Server</p>
<h2 className="truncate text-sm font-semibold leading-tight text-zinc-50">
{server.name}
</h2>
</div>
<StatusBadge status={server.status} />
<span className="hidden text-sm text-slate-ink md:inline">
{server.onlinePlayerCount} / {server.maxPlayers ?? '—'} players
</span>
<dl className="hidden items-center gap-5 md:flex">
<HeaderStat
label="Players"
value={`${server.onlinePlayerCount} / ${server.maxPlayers ?? '—'}`}
/>
<HeaderStat
label="Uptime"
value={
resources && resources.uptimeMs > 0
? formatDuration(resources.uptimeMs / 1000)
: '—'
}
/>
<HeaderStat
label="CPU"
value={resources ? `${resources.cpuPercent.toFixed(0)}%` : '—'}
/>
</dl>
</div>
) : (
<div className="flex-1" />
)}
{server && <PowerControls user={user} server={server} />}
</header>
<main className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
<main className="flex-1 overflow-y-auto p-4 sm:p-6">
<Outlet />
</main>
</div>
</div>
);
}
function HeaderStat({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="eyebrow leading-tight">{label}</dt>
<dd className="numeric text-sm leading-tight text-zinc-200">{value}</dd>
</div>
);
}
+224 -71
View File
@@ -1,20 +1,97 @@
import { useState } from 'react';
import { useConfiguration, useSetPerformanceSettings } from '../api/hooks.js';
import { Button, Card, Spinner } from './ui.js';
import { shortScenario } from './widgets.js';
import { useMemo, useState } from 'react';
import type { MissionInfo } from '@reforger-panel/shared';
import { useConfiguration, useMissions, useSetPerformanceSettings } from '../api/hooks.js';
import { Badge, Button, Card, EmptyState, Notice, SearchInput, Spinner, useToast } from './ui.js';
import { Icon } from './icons.js';
const DEFAULT_SCENARIO_ID = '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf';
const DEFAULT_SCENARIO_NAME = 'Campaign - Montignac (default)';
const SCENARIO_PATTERN = /^\{[0-9A-Fa-f]{16}\}[^\0\r\n]+\.conf$/;
/** Display form of a scenario id: just the file name, e.g. "23_Campaign.conf". */
export function shortScenario(scenarioId: string): string {
const slash = scenarioId.lastIndexOf('/');
return slash >= 0 ? scenarioId.slice(slash + 1) : scenarioId;
}
/**
* 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.
* Mission picker.
*
* Scenario discovery is now reliable: the vanilla list is bundled and merged
* with whatever the server prints at boot, and modded scenarios come from the
* Workshop v2 `scenarios[].gameId` field rather than being scraped out of prose.
* The raw id input is kept, but demoted to a fallback.
*/
export function MissionCard({ slug, canEdit }: { slug: string; canEdit: boolean }) {
const { data: config, refetch } = useConfiguration(slug);
const toast = useToast();
const { data: config, refetch: refetchConfig } = useConfiguration(slug);
const {
data: missions,
isLoading: missionsLoading,
refetch: refetchMissions,
} = useMissions(slug);
const save = useSetPerformanceSettings(slug);
const [selected, setSelected] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [manual, setManual] = useState('');
const [showManual, setShowManual] = useState(false);
const current = config?.config.scenarioId ?? '';
const groups = useMemo(() => {
if (!missions) return [];
const needle = query.trim().toLowerCase();
if (!needle) return missions.groups;
return missions.groups
.map((group) => ({
...group,
missions: group.missions.filter(
(mission) =>
mission.name.toLowerCase().includes(needle) ||
mission.scenarioId.toLowerCase().includes(needle) ||
(mission.gameMode ?? '').toLowerCase().includes(needle),
),
}))
.filter((group) => group.missions.length > 0);
}, [missions, query]);
const known = useMemo(
() =>
new Set(
(missions?.groups ?? []).flatMap((group) =>
group.missions.map((mission) => mission.scenarioId),
),
),
[missions],
);
const currentMission = useMemo(() => {
for (const group of missions?.groups ?? []) {
const match = group.missions.find((mission) => mission.scenarioId === current);
if (match) return { mission: match, groupLabel: group.label };
}
return null;
}, [missions, current]);
const apply = (scenarioId: string) => {
if (!SCENARIO_PATTERN.test(scenarioId)) {
toast('That does not look like a scenario id ({16 hex}Missions/….conf).', 'danger');
return;
}
save.mutate(
{
settings: { scenarioId },
expectedRevision: config?.revision,
writeStartupVars: true,
},
{
onSuccess: () => {
setManual('');
void refetchConfig();
toast('Mission saved to config.json. Restart the server to switch.', 'ok');
},
onError: (error) => toast(error.message, 'danger'),
},
);
};
if (!config) {
return (
@@ -24,82 +101,158 @@ export function MissionCard({ slug, canEdit }: { slug: string; canEdit: boolean
);
}
const current = config.config.scenarioId;
const value = selected ?? current;
const dirty = value !== current;
const submit = (scenarioIdOverride?: string) => {
setMessage(null);
save.mutate(
{ scenarioId: scenarioIdOverride ?? value },
{
onSuccess: () => {
setSelected(null);
setMessage('Mission saved to config.json — restart the server to switch.');
void refetch();
},
onError: (error) => setMessage(error.message),
},
);
};
return (
<Card
title="Mission"
action={
canEdit &&
dirty && (
<div className="flex items-center gap-2">
<Button onClick={() => setSelected(null)} disabled={save.isPending}>
Discard
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
icon="refresh"
onClick={() => void refetchMissions()}
title="Re-scan available missions"
/>
{canEdit && (
<Button size="sm" variant="ghost" onClick={() => setShowManual((open) => !open)}>
{showManual ? 'Hide manual entry' : 'Enter an ID manually'}
</Button>
<Button variant="accent" onClick={() => submit()} disabled={save.isPending}>
{save.isPending ? 'Saving…' : 'Save to server'}
</Button>
</div>
)
)}
</div>
}
>
<div className="space-y-4">
<div className="min-w-0 flex-1">
<p className="text-lg font-medium text-zinc-100">{shortScenario(current)}</p>
<p className="truncate font-mono text-xs text-slate-dim" title={current}>
{current}
<div className="rounded-sm border border-graphite-700 bg-graphite-950 px-3 py-2.5">
<p className="eyebrow">Currently configured</p>
<p className="mt-1 flex flex-wrap items-center gap-2 text-base text-zinc-50">
{currentMission?.mission.name ?? shortScenario(current)}
{currentMission && <Badge tone="accent">{currentMission.groupLabel}</Badge>}
{currentMission?.mission.gameMode && <Badge>{currentMission.mission.gameMode}</Badge>}
</p>
<p className="mt-0.5 truncate font-mono text-2xs text-slate-faint" title={current}>
{current || '(none set)'}
</p>
</div>
{canEdit && (
<div className="grid gap-2">
{!missionsLoading && current && !known.has(current) && (
<Notice tone="warn" title="Nothing installed provides this mission">
The server is configured for a scenario the base game does not ship and no installed mod
offers. It will fail to load it on the next restart — pick one below, or re-add the mod
that provided it.
</Notice>
)}
{(missions?.incompleteModIds.length ?? 0) > 0 && (
<Notice tone="info">
{missions!.incompleteModIds.length} installed mod
{missions!.incompleteModIds.length === 1 ? "'s" : "s'"} scenarios could not be read from
the Workshop, so this list may be incomplete.
</Notice>
)}
{canEdit && showManual && (
<div className="flex gap-2">
<input
value={value}
onChange={(event) => {
setMessage(null);
setSelected(event.target.value);
}}
value={manual}
placeholder="{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf"
className="input w-full font-mono text-xs"
onChange={(event) => setManual(event.target.value)}
className="input 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>
<Button
variant="accent"
disabled={!manual.trim() || save.isPending}
onClick={() => apply(manual.trim())}
>
Set
</Button>
</div>
)}
<SearchInput value={query} onChange={setQuery} placeholder="Search missions…" />
{missionsLoading ? (
<Spinner label="Reading available missions…" />
) : groups.length === 0 ? (
<EmptyState
icon="map"
title={query ? 'No missions match that search' : 'No missions found'}
hint={
query
? undefined
: 'Vanilla scenarios are always listed; modded scenarios come from the mods installed on this server.'
}
/>
) : (
<div className="space-y-4">
{groups.map((group) => (
<section key={group.id}>
<p className="eyebrow mb-1.5 flex items-center gap-2">
<Icon
name={group.kind === 'official' ? 'map' : 'package'}
className="h-3.5 w-3.5"
/>
{group.label}
<span className="numeric text-slate-faint">{group.missions.length}</span>
</p>
<ul className="divide-y divide-graphite-800 overflow-hidden rounded-sm border border-graphite-700">
{group.missions.map((mission) => (
<MissionRow
key={mission.scenarioId}
mission={mission}
active={mission.scenarioId === current}
canEdit={canEdit}
saving={save.isPending}
onSelect={() => apply(mission.scenarioId)}
/>
))}
</ul>
</section>
))}
</div>
)}
</div>
{message && <p className="mt-3 text-xs text-accent-400">{message}</p>}
</Card>
);
}
function MissionRow({
mission,
active,
canEdit,
saving,
onSelect,
}: {
mission: MissionInfo;
active: boolean;
canEdit: boolean;
saving: boolean;
onSelect: () => void;
}) {
return (
<li
className={`flex flex-wrap items-center gap-3 px-3 py-2 ${active ? 'bg-accent-600/[0.09]' : 'hover:bg-graphite-850/50'}`}
>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 truncate text-sm text-zinc-100">
{mission.name}
{active && <Badge tone="accent">running</Badge>}
</p>
<p className="truncate font-mono text-2xs text-slate-faint">{mission.scenarioId}</p>
</div>
{mission.gameMode && <Badge>{mission.gameMode}</Badge>}
{mission.playerCount ? (
<span className="numeric text-2xs text-slate-dim">{mission.playerCount}p</span>
) : null}
{canEdit && (
<Button
size="sm"
variant={active ? 'subtle' : 'accent'}
disabled={active || saving}
onClick={onSelect}
>
{active ? 'Current' : 'Use'}
</Button>
)}
</li>
);
}
@@ -0,0 +1,239 @@
import { useState } from 'react';
import { WORKSHOP_SORTS, type WorkshopModPreview, type WorkshopSort } from '@reforger-panel/shared';
import { useWorkshopSearch } from '../../api/hooks.js';
import { formatBytes } from '../../lib/format.js';
import { Badge, Button, EmptyState, ModImage, SearchInput, Skeleton } from '../ui.js';
import { Icon } from '../icons.js';
/**
* The upstream index rejects comma-separated tags, so filtering is one tag at
* a time. These are the tags that actually appear on Reforger Workshop mods.
*/
const TAGS = [
'SCENARIOS_MP',
'SCENARIOS_SP',
'WEAPONS',
'VEHICLES',
'CHARACTERS',
'TERRAINS',
'SYSTEMS',
'PROPS',
'EFFECTS',
'MISC',
] as const;
const SORT_LABELS: Record<WorkshopSort, string> = {
popularity: 'Popular',
'most-rated': 'Most rated',
'highest-rated': 'Highest rated',
subscribers: 'Subscribers',
newest: 'Newest',
created: 'Recently created',
'recently-updated': 'Recently updated',
largest: 'Largest',
name: 'Name',
};
export function BrowsePanel({
installedIds,
canManage,
onAdd,
onRemove,
onOpen,
}: {
installedIds: ReadonlySet<string>;
canManage: boolean;
onAdd: (mod: WorkshopModPreview) => void;
onRemove: (modId: string) => void;
onOpen: (modId: string) => void;
}) {
const [query, setQuery] = useState('');
const [sort, setSort] = useState<WorkshopSort>('popularity');
const [tag, setTag] = useState<string | null>(null);
const [page, setPage] = useState(1);
const search = useWorkshopSearch({ query, page, sort, tag: tag ?? undefined });
const mods = search.data?.mods ?? [];
const meta = search.data?.meta;
const reset =
<T,>(setter: (value: T) => void) =>
(value: T) => {
setter(value);
setPage(1);
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<SearchInput
value={query}
onChange={reset(setQuery)}
placeholder="Search the Workshop…"
className="w-full sm:w-80"
/>
<select
value={sort}
onChange={(event) => reset(setSort)(event.target.value as WorkshopSort)}
className="input w-auto"
>
{WORKSHOP_SORTS.map((value) => (
<option key={value} value={value}>
{SORT_LABELS[value]}
</option>
))}
</select>
{meta && (
<span className="numeric ml-auto text-2xs text-slate-dim">
{meta.totalMods.toLocaleString()} mods
</span>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{TAGS.map((value) => {
const active = tag === value;
return (
<button
key={value}
type="button"
onClick={() => reset(setTag)(active ? null : value)}
className={`rounded-xs border px-2 py-0.5 text-2xs font-semibold transition-colors ${
active
? 'border-accent-600 bg-accent-600/20 text-accent-300'
: 'border-graphite-700 bg-graphite-850 text-slate-dim hover:text-zinc-200'
}`}
>
{value}
</button>
);
})}
</div>
{search.isLoading ? (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className="panel-card space-y-2 p-3">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-3 w-2/3" />
<Skeleton className="h-3 w-1/3" />
</div>
))}
</div>
) : search.error ? (
<EmptyState
icon="alert"
title="The Workshop index is unavailable"
hint="reforgermods.net did not answer. Installed mods are unaffected."
action={
<Button icon="refresh" onClick={() => void search.refetch()}>
Retry
</Button>
}
/>
) : mods.length === 0 ? (
<EmptyState
title="No mods matched"
hint="Try a different search or clear the tag filter."
/>
) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{mods.map((mod) => (
<ModCard
key={mod.id}
mod={mod}
installed={installedIds.has(mod.id.toUpperCase())}
canManage={canManage}
onAdd={() => onAdd(mod)}
onRemove={() => onRemove(mod.id.toUpperCase())}
onOpen={() => onOpen(mod.id.toUpperCase())}
/>
))}
</div>
)}
{meta && meta.totalPages > 1 && (
<div className="flex items-center justify-center gap-3">
<Button
icon="chevron-left"
disabled={page <= 1 || search.isFetching}
onClick={() => setPage((current) => Math.max(1, current - 1))}
>
Previous
</Button>
<span className="numeric text-xs text-slate-dim">
Page {meta.currentPage} of {meta.totalPages}
</span>
<Button
disabled={page >= meta.totalPages || search.isFetching}
onClick={() => setPage((current) => current + 1)}
>
Next
</Button>
</div>
)}
</div>
);
}
function ModCard({
mod,
installed,
canManage,
onAdd,
onRemove,
onOpen,
}: {
mod: WorkshopModPreview;
installed: boolean;
canManage: boolean;
onAdd: () => void;
onRemove: () => void;
onOpen: () => void;
}) {
return (
<article className="panel-card flex flex-col overflow-hidden">
<button type="button" onClick={onOpen} className="group text-left">
<ModImage
src={mod.imageUrl}
className="h-28 w-full rounded-none border-0 border-b border-graphite-700"
/>
<div className="p-3">
<h3 className="truncate text-sm font-medium text-zinc-100 group-hover:text-accent-300">
{mod.name}
</h3>
<p className="truncate text-xs text-slate-dim">{mod.author}</p>
</div>
</button>
<div className="mt-auto space-y-2 px-3 pb-3">
<div className="numeric flex flex-wrap items-center gap-x-3 gap-y-1 text-2xs text-slate-dim">
{mod.version && <span>v{mod.version}</span>}
<span>
{mod.sizeBytes ? formatBytes(mod.sizeBytes) : (mod.sizeText ?? 'size unknown')}
</span>
{mod.rating !== null && mod.rating > 0 && (
<span className="flex items-center gap-1">
<Icon name="check" className="h-3 w-3 text-ok-400" />
{Math.round(mod.rating * 100)}%
</span>
)}
{mod.subscriberCount ? <span>{mod.subscriberCount.toLocaleString()} subs</span> : null}
</div>
{mod.obsolete && <Badge tone="danger">obsolete</Badge>}
{canManage &&
(installed ? (
<Button size="sm" variant="danger" icon="minus" onClick={onRemove} className="w-full">
Remove
</Button>
) : (
<Button size="sm" variant="accent" icon="plus" onClick={onAdd} className="w-full">
Add
</Button>
))}
</div>
</article>
);
}
@@ -0,0 +1,115 @@
import type { ModResolveResponse } from '@reforger-panel/shared';
import { formatBytes } from '../../lib/format.js';
import { Badge, Button } from '../ui.js';
import { Icon } from '../icons.js';
import type { Change } from './changeset.js';
const KIND_META: Record<
Change['kind'],
{ icon: 'plus' | 'minus' | 'arrow-up'; tone: string; label: string }
> = {
add: { icon: 'plus', tone: 'text-ok-400', label: 'add' },
remove: { icon: 'minus', tone: 'text-danger-400', label: 'remove' },
version: { icon: 'arrow-up', tone: 'text-warn-400', label: 'version' },
};
/**
* The staged plan. Everything the user has done since loading the page is
* shown as a reviewable diff, and Apply writes config.json exactly once.
*/
export function ChangesetBar({
changes,
resolution,
resolving,
applying,
onDiscard,
onApply,
onAddDependencies,
}: {
changes: readonly Change[];
resolution: ModResolveResponse | null;
resolving: boolean;
applying: boolean;
onDiscard: () => void;
onApply: () => void;
onAddDependencies: () => void;
}) {
if (changes.length === 0) return null;
const added = changes.filter((change) => change.kind === 'add').length;
const removed = changes.filter((change) => change.kind === 'remove').length;
const reversioned = changes.filter((change) => change.kind === 'version').length;
const missingDependencies = resolution?.addedDependencies ?? [];
return (
<div className="sticky bottom-0 z-20 -mx-4 mt-4 border-t border-graphite-600 bg-graphite-900/95 px-4 py-3 backdrop-blur sm:-mx-6 sm:px-6">
<div className="flex flex-wrap items-start gap-4">
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-2 text-xs">
<span className="eyebrow">Pending changes</span>
{added > 0 && <Badge tone="ok">{added} added</Badge>}
{reversioned > 0 && <Badge tone="warn">{reversioned} re-versioned</Badge>}
{removed > 0 && <Badge tone="danger">{removed} removed</Badge>}
{resolution?.totalSizeBytes ? (
<span className="numeric text-slate-dim">
{formatBytes(resolution.totalSizeBytes)} total after apply
</span>
) : null}
{resolving && <span className="text-slate-dim">checking dependencies…</span>}
</p>
<ul className="mt-2 max-h-32 space-y-0.5 overflow-y-auto pr-2">
{changes.map((change) => {
const meta = KIND_META[change.kind];
return (
<li
key={`${change.kind}-${change.modId}`}
className="flex items-center gap-2 text-xs"
>
<Icon name={meta.icon} className={`h-3 w-3 ${meta.tone}`} />
<span className="min-w-0 flex-1 truncate text-zinc-200">{change.name}</span>
<span className="numeric shrink-0 text-2xs text-slate-dim">
{change.kind === 'version'
? `${change.from ?? 'latest'} → ${change.to ?? 'latest'}`
: (change.to ?? change.from ?? 'latest')}
</span>
</li>
);
})}
</ul>
{missingDependencies.length > 0 && (
<p className="mt-2 flex flex-wrap items-center gap-2 text-2xs text-warn-400">
<Icon name="alert" className="h-3.5 w-3.5" />
{missingDependencies.length} required dependenc
{missingDependencies.length === 1 ? 'y is' : 'ies are'} not in the list
<Button size="sm" variant="subtle" icon="plus" onClick={onAddDependencies}>
Add all
</Button>
</p>
)}
{resolution && resolution.unresolvedIds.length > 0 && (
<p className="mt-1 text-2xs text-slate-dim">
{resolution.unresolvedIds.length} mod
{resolution.unresolvedIds.length === 1 ? '' : 's'} could not be checked against the
Workshop; they will be written as-is.
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="hidden text-2xs text-slate-dim sm:inline">
Applies on the next restart
</span>
<Button onClick={onDiscard} disabled={applying}>
Discard all
</Button>
<Button variant="accent" icon="upload" onClick={onApply} loading={applying}>
Apply to server
</Button>
</div>
</div>
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
import type { ModOverviewEntry, ReforgerConfigMod } from '@reforger-panel/shared';
/**
* The Mods page edits a local draft of `game.mods` and writes it once.
*
* The previous page autosaved 1.5s after every keystroke, which meant a bulk
* operation like "update all" or importing another server's list produced a
* burst of writes to config.json and no chance to review the result. Here every
* edit is staged, diffed against what the server actually has, and applied in
* a single request.
*/
export type DraftMod = ReforgerConfigMod & { modId: string };
export type ChangeKind = 'add' | 'remove' | 'version';
export type Change = {
modId: string;
kind: ChangeKind;
name: string;
/** Previous pinned version, for `version` and `remove`. */
from: string | null;
/** New pinned version, for `version` and `add`. */
to: string | null;
};
export function normalizeId(modId: string): string {
return modId.toUpperCase();
}
export function draftFromOverview(mods: readonly ModOverviewEntry[]): DraftMod[] {
return mods.map((mod) => ({
modId: mod.modId,
...(mod.configName ? { name: mod.configName } : {}),
...(mod.pinnedVersion ? { version: mod.pinnedVersion } : {}),
}));
}
export function displayName(
modId: string,
entry: ModOverviewEntry | undefined,
fallback?: string | null,
): string {
return entry?.workshop?.name ?? entry?.configName ?? fallback ?? modId;
}
export function computeChanges(
baseline: readonly DraftMod[],
draft: readonly DraftMod[],
nameOf: (modId: string, fallback?: string | null) => string,
): Change[] {
const before = new Map(baseline.map((mod) => [mod.modId, mod]));
const after = new Map(draft.map((mod) => [mod.modId, mod]));
const changes: Change[] = [];
for (const [modId, mod] of after) {
const existing = before.get(modId);
if (!existing) {
changes.push({
modId,
kind: 'add',
name: nameOf(modId, mod.name),
from: null,
to: mod.version ?? null,
});
} else if ((existing.version ?? null) !== (mod.version ?? null)) {
changes.push({
modId,
kind: 'version',
name: nameOf(modId, mod.name),
from: existing.version ?? null,
to: mod.version ?? null,
});
}
}
for (const [modId, mod] of before) {
if (after.has(modId)) continue;
changes.push({
modId,
kind: 'remove',
name: nameOf(modId, mod.name),
from: mod.version ?? null,
to: null,
});
}
// Adds first, then version bumps, then removals — reads as a plan.
const order: Record<ChangeKind, number> = { add: 0, version: 1, remove: 2 };
return changes.sort((a, b) => order[a.kind] - order[b.kind] || a.name.localeCompare(b.name));
}
export function upsertMod(draft: readonly DraftMod[], mod: DraftMod): DraftMod[] {
const modId = normalizeId(mod.modId);
const next = draft.filter((entry) => entry.modId !== modId);
next.push({ ...mod, modId });
return next;
}
export function removeMod(draft: readonly DraftMod[], modId: string): DraftMod[] {
const id = normalizeId(modId);
return draft.filter((entry) => entry.modId !== id);
}
export function setModVersion(
draft: readonly DraftMod[],
modId: string,
version: string | null,
): DraftMod[] {
const id = normalizeId(modId);
return draft.map((entry) => {
if (entry.modId !== id) return entry;
const { version: _dropped, ...rest } = entry;
return version ? { ...rest, version } : rest;
});
}
/**
* Merge keeps everything already installed and adds what is missing; replace
* mirrors the source list exactly, including removals and version pins.
*/
export function mergeModLists(
draft: readonly DraftMod[],
incoming: readonly DraftMod[],
mode: 'merge' | 'replace',
): DraftMod[] {
if (mode === 'replace') {
return incoming.map((mod) => ({ ...mod, modId: normalizeId(mod.modId) }));
}
const existing = new Set(draft.map((mod) => mod.modId));
return [
...draft,
...incoming
.filter((mod) => !existing.has(normalizeId(mod.modId)))
.map((mod) => ({ ...mod, modId: normalizeId(mod.modId) })),
];
}
@@ -0,0 +1,236 @@
import { useMemo, useState } from 'react';
import type { WorkshopServerSummary } from '@reforger-panel/shared';
import { useWorkshopServerMods, useWorkshopServers } from '../../api/hooks.js';
import { formatBytes } from '../../lib/format.js';
import {
Badge,
Button,
Dialog,
EmptyState,
SearchInput,
SegmentedControl,
Spinner,
StatusBadge,
} from '../ui.js';
import { Icon } from '../icons.js';
import type { DraftMod } from './changeset.js';
type Mode = 'merge' | 'replace';
/**
* Copies a modlist off a live Arma Reforger server, so a community setup can
* be reproduced without hunting down and adding ninety mods by hand.
*
* Nothing is written here — the result lands in the staged changeset, which is
* reviewed and applied like any other edit.
*/
export function ImportServerDialog({
open,
onClose,
currentIds,
onImport,
}: {
open: boolean;
onClose: () => void;
currentIds: ReadonlySet<string>;
onImport: (mods: DraftMod[], mode: Mode) => void;
}) {
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<WorkshopServerSummary | null>(null);
const [mode, setMode] = useState<Mode>('merge');
const servers = useWorkshopServers(query, open && selected === null);
const serverMods = useWorkshopServerMods(selected?.id ?? null);
const diff = useMemo(() => {
const mods = serverMods.data?.mods ?? [];
const incoming = mods.map((mod): DraftMod => ({
modId: mod.id,
name: mod.name,
...(mod.version ? { version: mod.version } : {}),
}));
const added = incoming.filter((mod) => !currentIds.has(mod.modId));
const shared = incoming.filter((mod) => currentIds.has(mod.modId));
const removed = [...currentIds].filter((id) => !incoming.some((mod) => mod.modId === id));
const addedBytes = mods
.filter((mod) => !currentIds.has(mod.id))
.reduce((sum, mod) => sum + (mod.sizeBytes ?? 0), 0);
return { incoming, added, shared, removed, addedBytes };
}, [serverMods.data?.mods, currentIds]);
const close = () => {
setSelected(null);
onClose();
};
return (
<Dialog
open={open}
onClose={close}
width="lg"
title="Import a modlist from a server"
description="Search the live Arma Reforger server browser, then stage its mods."
footer={
selected && (
<>
<Button icon="chevron-left" onClick={() => setSelected(null)}>
Back to search
</Button>
<Button
variant="accent"
icon="plus"
disabled={
serverMods.isLoading ||
(mode === 'merge' ? diff.added.length === 0 : diff.incoming.length === 0)
}
onClick={() => {
onImport(diff.incoming, mode);
close();
}}
>
{mode === 'merge'
? `Stage ${diff.added.length} new mod${diff.added.length === 1 ? '' : 's'}`
: `Replace list with ${diff.incoming.length}`}
</Button>
</>
)
}
>
{!selected ? (
<div className="space-y-3">
<SearchInput
autoFocus
value={query}
onChange={setQuery}
placeholder="Server name, e.g. HOGS OF WAR"
/>
{query.trim().length < 2 ? (
<EmptyState
icon="search"
title="Search for a server by name"
hint="Only servers that actually run mods are listed."
/>
) : servers.isLoading ? (
<Spinner label="Searching the server browser…" />
) : servers.error ? (
<EmptyState icon="alert" title="The server browser is unavailable right now" />
) : (servers.data?.servers.length ?? 0) === 0 ? (
<EmptyState title="No servers matched that name" />
) : (
<ul className="divide-y divide-graphite-800 rounded-sm border border-graphite-700">
{servers.data!.servers.map((server) => (
<li key={server.id}>
<button
type="button"
onClick={() => setSelected(server)}
className="flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-graphite-850"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-zinc-100">{server.name}</p>
<p className="numeric mt-0.5 flex flex-wrap gap-x-3 text-2xs text-slate-dim">
<span>
{server.players}/{server.maxPlayers} players
</span>
<span>{server.modCount} mods</span>
{server.region && <span>{server.region}</span>}
{server.scenarioName && (
<span className="truncate">{server.scenarioName}</span>
)}
</p>
</div>
<StatusBadge status={server.online ? 'online' : 'offline'} compact />
<Icon name="chevron-right" className="h-4 w-4 text-slate-faint" />
</button>
</li>
))}
</ul>
)}
</div>
) : (
<div className="space-y-4">
<div className="rounded-sm border border-graphite-700 bg-graphite-950 px-3 py-2.5">
<p className="truncate text-sm text-zinc-100">{selected.name}</p>
<p className="numeric mt-0.5 text-2xs text-slate-dim">
{selected.modCount} mods · {selected.players}/{selected.maxPlayers} players
{selected.scenarioName ? ` · ${selected.scenarioName}` : ''}
</p>
</div>
<SegmentedControl<Mode>
value={mode}
onChange={setMode}
options={[
{ value: 'merge', label: 'Merge — add what is missing' },
{ value: 'replace', label: 'Replace — mirror exactly' },
]}
/>
{serverMods.isLoading ? (
<Spinner label="Reading the server's mod list…" />
) : serverMods.error ? (
<EmptyState icon="alert" title="Could not read that server's mod list" />
) : (
<>
<div className="grid grid-cols-3 gap-2 text-center">
<Stat label="To add" value={diff.added.length} tone="ok" />
<Stat label="Already installed" value={diff.shared.length} tone="neutral" />
<Stat
label={mode === 'replace' ? 'To remove' : 'Kept (not on that server)'}
value={diff.removed.length}
tone={mode === 'replace' ? 'danger' : 'neutral'}
/>
</div>
{diff.addedBytes > 0 && (
<p className="numeric text-xs text-slate-dim">
Approximately {formatBytes(diff.addedBytes)} of new downloads.
{(serverMods.data?.unresolvedCount ?? 0) > 0 &&
` ${serverMods.data!.unresolvedCount} mod sizes are unknown.`}
</p>
)}
<div className="max-h-64 overflow-y-auto rounded-sm border border-graphite-700">
<ul className="divide-y divide-graphite-800">
{diff.incoming.map((mod) => {
const isNew = !currentIds.has(mod.modId);
return (
<li key={mod.modId} className="flex items-center gap-2 px-3 py-1.5 text-xs">
<Badge tone={isNew ? 'ok' : 'neutral'}>{isNew ? 'new' : 'have'}</Badge>
<span className="min-w-0 flex-1 truncate text-zinc-200">{mod.name}</span>
<span className="numeric shrink-0 text-slate-dim">
{mod.version ?? 'latest'}
</span>
</li>
);
})}
</ul>
</div>
</>
)}
</div>
)}
</Dialog>
);
}
function Stat({
label,
value,
tone,
}: {
label: string;
value: number;
tone: 'ok' | 'danger' | 'neutral';
}) {
const tones = {
ok: 'text-ok-400',
danger: 'text-danger-400',
neutral: 'text-zinc-200',
} as const;
return (
<div className="rounded-sm border border-graphite-700 bg-graphite-950 px-3 py-2">
<p className={`numeric text-lg font-semibold ${tones[tone]}`}>{value}</p>
<p className="text-2xs text-slate-dim">{label}</p>
</div>
);
}
@@ -0,0 +1,263 @@
import { useMemo, useState } from 'react';
import type { ModOverviewEntry, ModsOverviewResponse } from '@reforger-panel/shared';
import { formatBytes } from '../../lib/format.js';
import {
Badge,
Button,
EmptyState,
ModImage,
Notice,
SearchInput,
SegmentedControl,
} from '../ui.js';
import { Icon } from '../icons.js';
import type { DraftMod } from './changeset.js';
type Filter = 'all' | 'updates' | 'issues';
/**
* The installed modlist, joined with Workshop metadata server-side so the page
* paints in one request rather than one request per mod.
*/
export function InstalledPanel({
overview,
draft,
canManage,
onOpen,
onRemove,
onPinVersion,
onAddDependency,
}: {
overview: ModsOverviewResponse;
draft: readonly DraftMod[];
canManage: boolean;
onOpen: (modId: string) => void;
onRemove: (modId: string) => void;
onPinVersion: (entry: ModOverviewEntry) => void;
onAddDependency: (modId: string, name: string) => void;
}) {
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<Filter>('all');
const draftIds = useMemo(() => new Set(draft.map((mod) => mod.modId)), [draft]);
/**
* Rows come from the staged draft, not the server list, so a mod added in
* this session appears immediately and one queued for removal disappears.
*/
const rows = useMemo(() => {
const byId = new Map(overview.mods.map((entry) => [entry.modId, entry]));
return draft.map((mod) => ({
modId: mod.modId,
draft: mod,
entry: byId.get(mod.modId),
}));
}, [draft, overview.mods]);
const issueCount = overview.mods.filter(
(entry) =>
draftIds.has(entry.modId) &&
(entry.missingDependencies.some((dep) => !draftIds.has(dep.id)) ||
entry.workshop?.obsolete ||
entry.workshop === null),
).length;
const visible = rows.filter(({ modId, draft: mod, entry }) => {
const name = entry?.workshop?.name ?? mod.name ?? modId;
if (query && !`${name} ${modId}`.toLowerCase().includes(query.toLowerCase())) return false;
if (filter === 'updates') return Boolean(entry?.updateAvailable);
if (filter === 'issues') {
return Boolean(
entry === undefined ||
entry.workshop === null ||
entry.workshop.obsolete ||
entry.missingDependencies.some((dep) => !draftIds.has(dep.id)),
);
}
return true;
});
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<SearchInput
value={query}
onChange={setQuery}
placeholder="Filter installed mods…"
className="w-full sm:w-72"
/>
<SegmentedControl<Filter>
value={filter}
onChange={setFilter}
options={[
{ value: 'all', label: 'All', count: draft.length },
{ value: 'updates', label: 'Updates', count: overview.updatesAvailable },
{ value: 'issues', label: 'Issues', count: issueCount },
]}
/>
<div className="numeric ml-auto text-2xs text-slate-dim">
{overview.totalSizeBytes ? `${formatBytes(overview.totalSizeBytes)} installed` : null}
</div>
</div>
{overview.warming && (
<Notice tone="info">
Loading Workshop metadata for {overview.mods.length} mods. Names, versions and
dependencies fill in as they arrive.
</Notice>
)}
{overview.unresolvedIds.length > 0 && !overview.warming && (
<Notice tone="warn" title={`${overview.unresolvedIds.length} mods could not be identified`}>
They may be private, delisted, or the Workshop index may be missing them. They are still
installed and are left untouched.
</Notice>
)}
{visible.length === 0 ? (
<EmptyState
icon="package"
title={draft.length === 0 ? 'The server runs vanilla' : 'No mods match this filter'}
hint={
draft.length === 0
? 'Add mods from the Browse tab, or import a modlist from another server.'
: undefined
}
/>
) : (
<ul className="divide-y divide-graphite-800 overflow-hidden rounded-md border border-graphite-700">
{visible.map(({ modId, draft: mod, entry }) => (
<ModRow
key={modId}
modId={modId}
draft={mod}
entry={entry}
draftIds={draftIds}
canManage={canManage}
onOpen={() => onOpen(modId)}
onRemove={() => onRemove(modId)}
onPinVersion={() => entry && onPinVersion(entry)}
onAddDependency={onAddDependency}
/>
))}
</ul>
)}
</div>
);
}
function ModRow({
modId,
draft,
entry,
draftIds,
canManage,
onOpen,
onRemove,
onPinVersion,
onAddDependency,
}: {
modId: string;
draft: DraftMod;
entry: ModOverviewEntry | undefined;
draftIds: ReadonlySet<string>;
canManage: boolean;
onOpen: () => void;
onRemove: () => void;
onPinVersion: () => void;
onAddDependency: (modId: string, name: string) => void;
}) {
const workshop = entry?.workshop ?? null;
const name = workshop?.name ?? draft.name ?? entry?.configName ?? modId;
const pinned = draft.version ?? null;
const latest = workshop?.latestVersion ?? null;
const outdated = Boolean(pinned && latest && pinned !== latest);
const missing = (entry?.missingDependencies ?? []).filter((dep) => !draftIds.has(dep.id));
const blockers = (entry?.requiredBy ?? []).filter((id) => draftIds.has(id));
return (
<li className="flex flex-wrap items-center gap-3 px-3 py-2.5 hover:bg-graphite-850/50">
<button
type="button"
onClick={onOpen}
className="group flex min-w-0 flex-1 items-center gap-3 text-left"
>
<ModImage src={workshop?.imageUrl ?? null} className="h-9 w-14" />
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 truncate text-sm text-zinc-100 group-hover:text-accent-300">
{name}
{workshop?.obsolete && <Badge tone="danger">obsolete</Badge>}
{workshop === null && entry !== undefined && <Badge tone="warn">unknown</Badge>}
</p>
<p className="truncate font-mono text-2xs text-slate-faint">
{workshop?.author ? `${workshop.author} · ` : ''}
{modId}
</p>
</div>
</button>
<div className="numeric hidden w-24 shrink-0 text-right text-2xs text-slate-dim sm:block">
{workshop?.sizeBytes ? formatBytes(workshop.sizeBytes) : '—'}
</div>
<button
type="button"
disabled={!canManage || !entry}
onClick={onPinVersion}
title={pinned ? `Pinned to ${pinned}` : 'Tracking latest'}
className="numeric flex shrink-0 items-center gap-1.5 rounded-sm border border-graphite-700 bg-graphite-950 px-2 py-1 text-2xs text-zinc-200 transition-colors enabled:hover:border-graphite-500 disabled:opacity-50"
>
{pinned ?? 'latest'}
{outdated && (
<>
<Icon name="chevron-right" className="h-3 w-3 text-warn-400" />
<span className="text-warn-400">{latest}</span>
</>
)}
{canManage && entry && <Icon name="chevron-down" className="h-3 w-3 text-slate-faint" />}
</button>
{canManage && (
<Button
size="sm"
variant="ghost"
icon="trash"
onClick={onRemove}
title={
blockers.length > 0
? `Still required by ${blockers.length} installed mod(s)`
: 'Remove from the mod list'
}
/>
)}
{(missing.length > 0 || blockers.length > 0) && (
<div className="flex w-full flex-wrap items-center gap-2 pl-[4.25rem] text-2xs">
{missing.length > 0 && (
<>
<span className="text-warn-400">
Missing {missing.length} dependenc{missing.length === 1 ? 'y' : 'ies'}:
</span>
{missing.map((dependency) => (
<button
key={dependency.id}
type="button"
disabled={!canManage}
onClick={() => onAddDependency(dependency.id, dependency.name)}
className="rounded-xs border border-warn-400/40 bg-warn-400/10 px-1.5 py-0.5 text-warn-400 transition-colors enabled:hover:bg-warn-400/20 disabled:opacity-60"
>
+ {dependency.name}
</button>
))}
</>
)}
{blockers.length > 0 && (
<span className="text-slate-dim">
Required by {blockers.length} installed mod{blockers.length === 1 ? '' : 's'}
</span>
)}
</div>
)}
</li>
);
}
@@ -0,0 +1,168 @@
import { useWorkshopMod } from '../../api/hooks.js';
import { formatBytes, formatDateTime } from '../../lib/format.js';
import { Badge, Button, Dialog, EmptyState, ModImage, Spinner } from '../ui.js';
/** Read-only Workshop record for one mod, with the add/remove action inline. */
export function ModDetailDialog({
modId,
onClose,
installed,
canManage,
onAdd,
onRemove,
}: {
modId: string | null;
onClose: () => void;
installed: boolean;
canManage: boolean;
onAdd: () => void;
onRemove: () => void;
}) {
const { data: mod, isLoading, error } = useWorkshopMod(modId);
return (
<Dialog
open={modId !== null}
onClose={onClose}
width="lg"
title={mod?.name ?? 'Mod details'}
description={mod ? `by ${mod.author}` : undefined}
footer={
canManage &&
mod && (
<>
{mod.workshopUrl && (
<a
href={mod.workshopUrl}
target="_blank"
rel="noreferrer noopener"
className="mr-auto inline-flex items-center gap-1.5 text-xs text-accent-400 hover:underline"
>
Open on the Workshop
</a>
)}
{installed ? (
<Button variant="danger" icon="minus" onClick={onRemove}>
Remove from server
</Button>
) : (
<Button variant="accent" icon="plus" onClick={onAdd}>
Add to server
</Button>
)}
</>
)
}
>
{isLoading ? (
<Spinner label="Loading mod details…" />
) : error || !mod ? (
<EmptyState
icon="alert"
title="This mod could not be loaded"
hint="It may be private, delisted, or the metadata service may be down."
/>
) : (
<div className="space-y-4">
<div className="flex gap-4">
<ModImage src={mod.imageUrl} className="h-24 w-40" />
<dl className="grid flex-1 grid-cols-2 gap-x-4 gap-y-2 text-xs">
<Detail label="Latest version" value={mod.version ?? '—'} />
<Detail label="Game version" value={mod.gameVersion ?? '—'} />
<Detail
label="Size"
value={mod.sizeBytes ? formatBytes(mod.sizeBytes) : (mod.sizeText ?? '—')}
/>
<Detail
label="With dependencies"
value={mod.totalSizeBytes ? formatBytes(mod.totalSizeBytes) : '—'}
/>
<Detail
label="Rating"
value={
mod.rating === null
? '—'
: `${Math.round(mod.rating * 100)}%${mod.ratingCount ? ` (${mod.ratingCount})` : ''}`
}
/>
<Detail label="Subscribers" value={mod.subscriberCount?.toLocaleString() ?? '—'} />
<Detail label="Updated" value={formatDateTime(mod.updatedAt)} />
<Detail label="Mod ID" value={mod.id} mono />
</dl>
</div>
{(mod.obsolete || mod.tags.length > 0) && (
<div className="flex flex-wrap gap-1.5">
{mod.obsolete && <Badge tone="danger">obsolete</Badge>}
{mod.tags.map((tag) => (
<Badge key={tag}>{tag}</Badge>
))}
</div>
)}
{(mod.summary ?? mod.description) && (
<div>
<p className="eyebrow mb-1.5">Description</p>
<p className="max-h-48 overflow-y-auto whitespace-pre-wrap text-xs leading-5 text-slate-ink">
{mod.description ?? mod.summary}
</p>
</div>
)}
{mod.dependencies.length > 0 && (
<div>
<p className="eyebrow mb-1.5">Requires {mod.dependencies.length} other mods</p>
<ul className="divide-y divide-graphite-800 rounded-sm border border-graphite-700">
{mod.dependencies.map((dependency) => (
<li key={dependency.id} className="flex items-center gap-2 px-3 py-1.5 text-xs">
<span className="min-w-0 flex-1 truncate text-zinc-200">{dependency.name}</span>
<span className="numeric shrink-0 text-slate-dim">
{dependency.sizeBytes ? formatBytes(dependency.sizeBytes) : '—'}
</span>
</li>
))}
</ul>
</div>
)}
{mod.scenarios.length > 0 && (
<div>
<p className="eyebrow mb-1.5">
Ships {mod.scenarios.length} scenario{mod.scenarios.length === 1 ? '' : 's'}
</p>
<ul className="divide-y divide-graphite-800 rounded-sm border border-graphite-700">
{mod.scenarios.map((scenario) => (
<li key={scenario.scenarioId} className="px-3 py-2">
<p className="flex items-center gap-2 text-xs text-zinc-200">
{scenario.name}
{scenario.gameMode && <Badge>{scenario.gameMode}</Badge>}
{scenario.playerCount && (
<span className="numeric text-2xs text-slate-dim">
{scenario.playerCount} players
</span>
)}
</p>
<p className="mt-0.5 truncate font-mono text-2xs text-slate-faint">
{scenario.scenarioId}
</p>
</li>
))}
</ul>
</div>
)}
</div>
)}
</Dialog>
);
}
function Detail({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-baseline justify-between gap-3">
<dt className="eyebrow">{label}</dt>
<dd className={`truncate text-right text-xs text-zinc-200 ${mono ? 'font-mono' : 'numeric'}`}>
{value}
</dd>
</div>
);
}
@@ -0,0 +1,160 @@
import { useState } from 'react';
import { useWorkshopModVersions } from '../../api/hooks.js';
import { formatBytes, formatDateTime } from '../../lib/format.js';
import { Badge, Button, Dialog, EmptyState, Field, Spinner } from '../ui.js';
/**
* Pins a specific Workshop version, or clears the pin so the server tracks
* whatever is current. Reforger only accepts versions that actually exist, so
* the list is the primary control — but a manual field is kept for versions
* the metadata API has not indexed yet.
*/
export function VersionDialog({
open,
modId,
modName,
currentVersion,
latestVersion,
onClose,
onSelect,
}: {
open: boolean;
modId: string | null;
modName: string;
currentVersion: string | null;
latestVersion: string | null;
onClose: () => void;
onSelect: (version: string | null) => void;
}) {
const { data, isLoading, error } = useWorkshopModVersions(open ? modId : null);
const [manual, setManual] = useState('');
const [manualError, setManualError] = useState<string | null>(null);
const applyManual = () => {
const value = manual.trim();
if (!value) {
setManualError('Enter a version, or use "Track latest".');
return;
}
if (!/^[\w.+-]{1,32}$/.test(value)) {
setManualError('Versions may only contain letters, digits, dots, plus and dashes.');
return;
}
onSelect(value);
};
return (
<Dialog
open={open}
onClose={onClose}
width="lg"
title={`Version — ${modName}`}
description={
currentVersion
? `Currently pinned to ${currentVersion}.`
: 'Currently unpinned: the server takes the latest version at boot.'
}
footer={
<>
<Button onClick={onClose}>Cancel</Button>
<Button
variant="accent"
icon="check"
onClick={() => onSelect(null)}
disabled={currentVersion === null}
>
Track latest
</Button>
</>
}
>
<div className="space-y-4">
<Field
label="Enter a version manually"
hint="Use this when the version you need is newer than the metadata index."
error={manualError}
>
<div className="flex gap-2">
<input
value={manual}
placeholder={latestVersion ?? '1.0.0'}
onChange={(event) => {
setManual(event.target.value);
setManualError(null);
}}
onKeyDown={(event) => event.key === 'Enter' && applyManual()}
className={`input font-mono ${manualError ? 'input-error' : ''}`}
/>
<Button onClick={applyManual}>Pin</Button>
</div>
</Field>
<div>
<p className="eyebrow mb-2">Published versions</p>
{isLoading ? (
<Spinner label="Loading version history…" />
) : error ? (
<EmptyState
icon="alert"
title="Version history is unavailable"
hint="The Workshop metadata service did not answer. You can still pin a version manually above."
/>
) : !data || data.versions.length === 0 ? (
<EmptyState title="No published versions listed for this mod" />
) : (
<div className="max-h-80 overflow-y-auto rounded-sm border border-graphite-700">
<table className="data-table w-full">
<thead className="sticky top-0 bg-graphite-900">
<tr>
<th className="pl-3">Version</th>
<th>Game</th>
<th className="text-right">Size</th>
<th>Published</th>
<th />
</tr>
</thead>
<tbody>
{data.versions.map((version) => {
const active = version.version === currentVersion;
return (
<tr key={version.version}>
<td className="pl-3 font-mono text-xs text-zinc-100">
<span className="flex items-center gap-2">
{version.version}
{version.version === latestVersion && (
<Badge tone="accent">latest</Badge>
)}
{!version.approved && <Badge tone="warn">unapproved</Badge>}
</span>
</td>
<td className="numeric text-xs text-slate-dim">
{version.gameVersion ?? '—'}
</td>
<td className="numeric text-right text-xs text-slate-dim">
{version.sizeBytes ? formatBytes(version.sizeBytes) : '—'}
</td>
<td className="text-xs text-slate-dim">
{formatDateTime(version.createdAt)}
</td>
<td className="pr-3 text-right">
<Button
size="sm"
variant={active ? 'subtle' : 'accent'}
disabled={active}
onClick={() => onSelect(version.version)}
>
{active ? 'Pinned' : 'Pin'}
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
</Dialog>
);
}
+180 -115
View File
@@ -1,12 +1,15 @@
import { useEffect, useState } from 'react';
import type { PerformanceSettings } from '@reforger-panel/shared';
import { useMemo, useState } from 'react';
import type { PerformanceSettings, PerformanceSettingsPatch } from '@reforger-panel/shared';
import { usePerformanceSettings, useSetPerformanceSettings } from '../api/hooks.js';
import { Button, Card, Spinner } from './ui.js';
import { Button, EmptyState, Spinner, useToast } from './ui.js';
import { Icon } from './icons.js';
type NumberKey = {
[K in keyof PerformanceSettings]: PerformanceSettings[K] extends number | null ? K : never;
}[keyof PerformanceSettings];
type BooleanKey = Exclude<keyof PerformanceSettings, NumberKey>;
type BooleanKey = {
[K in keyof PerformanceSettings]: PerformanceSettings[K] extends boolean | null ? K : never;
}[keyof PerformanceSettings];
// Ranges/defaults from the Bohemia server-config reference. Blank fields are
// omitted from config.json so the game default applies.
@@ -15,95 +18,114 @@ type BooleanKey = Exclude<keyof PerformanceSettings, NumberKey>;
const NUMBER_FIELDS: { key: NumberKey; label: string; min: number; max: number; hint: string }[] = [
{
key: 'serverMaxViewDistance',
label: 'Server view distance (m)',
label: 'Server view distance',
min: 500,
max: 10000,
hint: 'default 1600',
hint: 'metres · default 1600',
},
{
key: 'networkViewDistance',
label: 'Network view distance (m)',
label: 'Network view distance',
min: 500,
max: 5000,
hint: 'default 1500',
hint: 'metres · default 1500',
},
{
key: 'serverMinGrassDistance',
label: 'Min grass distance (m)',
label: 'Min grass distance',
min: 0,
max: 150,
hint: '0 = client choice',
hint: 'metres · 0 = client choice',
},
{ key: 'aiLimit', label: 'AI limit', min: -1, max: 1000, hint: '-1 = unlimited' },
{
key: 'playerSaveTime',
label: 'Player save interval (s)',
label: 'Player save interval',
min: 1,
max: 3600,
hint: 'default 120',
hint: 'seconds · default 120',
},
{
key: 'slotReservationTimeout',
label: 'Slot reservation timeout (s)',
label: 'Slot reservation timeout',
min: 5,
max: 300,
hint: 'default 60',
hint: 'seconds · default 60',
},
];
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' },
{ key: 'lobbyPlayerSynchronise', label: 'Lobby player sync', hint: 'default enabled' },
{ key: 'disableAI', label: 'Disable AI', hint: 'default: AI enabled' },
{ key: 'disableThirdPerson', label: 'Disable third person', hint: 'default: allowed' },
{ key: 'fastValidation', label: 'Fast validation', hint: 'default: enabled' },
{ key: 'battlEye', label: 'BattlEye', hint: 'default: enabled' },
{ key: 'lobbyPlayerSynchronise', label: 'Lobby player sync', hint: 'default: enabled' },
];
type FormState = Record<string, string>;
type FieldKey = NumberKey | BooleanKey;
function toFormState(settings: PerformanceSettings): FormState {
const state: FormState = {};
for (const field of NUMBER_FIELDS) {
const value = settings[field.key];
state[field.key] = value === null ? '' : String(value);
}
for (const field of BOOLEAN_FIELDS) {
const value = settings[field.key];
state[field.key] = value === null ? '' : String(value);
}
return state;
function toText(value: number | boolean | null): string {
return value === null ? '' : String(value);
}
/**
* Curated, range-validated view of the performance settings.
*
* Only fields the user actually edits are submitted — the old form posted all
* thirteen values on every save, so a form loaded before somebody else's change
* silently reverted it on the next submit.
*/
export function PerformanceForm({ slug, canEdit }: { slug: string; canEdit: boolean }) {
const { data, isLoading, error: loadError } = usePerformanceSettings(slug);
const toast = useToast();
const { data, isLoading, error, refetch } = usePerformanceSettings(slug);
const save = useSetPerformanceSettings(slug);
const [form, setForm] = useState<FormState | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
useEffect(() => {
if (data && form === null) setForm(toFormState(data.settings));
}, [data, form]);
const [edits, setEdits] = useState<Map<FieldKey, string>>(new Map());
const [fieldErrors, setFieldErrors] = useState<Partial<Record<FieldKey, string>>>({});
if (isLoading || (!form && !loadError)) return <Spinner label="Downloading config.json…" />;
if (loadError) return <p className="text-sm text-danger-400">{loadError.message}</p>;
if (!form || !data) return null;
const baseline = data?.settings;
const baseline = toFormState(data.settings);
const dirty = Object.keys(form).some((key) => form[key] !== baseline[key]);
const dirtyKeys = useMemo(
() =>
[...edits.entries()]
.filter(([key, value]) => baseline && value !== toText(baseline[key]))
.map(([key]) => key),
[edits, baseline],
);
const set = (key: string, value: string) => {
setMessage(null);
setForm({ ...form, [key]: value });
if (isLoading) return <Spinner label="Downloading config.json…" />;
if (error || !data || !baseline) {
return (
<EmptyState
icon="alert"
title="Could not read the performance settings"
hint={error?.message}
action={
<Button icon="refresh" onClick={() => void refetch()}>
Retry
</Button>
}
/>
);
}
const valueOf = (key: FieldKey): string => edits.get(key) ?? toText(baseline[key]);
const isDirty = (key: FieldKey) => dirtyKeys.includes(key);
const set = (key: FieldKey, value: string) => {
setEdits((current) => new Map(current).set(key, value));
setFieldErrors((current) => ({ ...current, [key]: undefined }));
};
const validateAndBuild = (): PerformanceSettings | null => {
const errors: Record<string, string> = {};
const result = {} as Record<string, number | boolean | null>;
const submit = () => {
const errors: Partial<Record<FieldKey, string>> = {};
const patch: PerformanceSettingsPatch = {};
for (const field of NUMBER_FIELDS) {
const raw = (form[field.key] ?? '').trim();
if (!isDirty(field.key)) continue;
const raw = valueOf(field.key).trim();
if (raw === '') {
result[field.key] = null;
patch[field.key] = null;
continue;
}
const value = Number(raw);
@@ -111,86 +133,72 @@ export function PerformanceForm({ slug, canEdit }: { slug: string; canEdit: bool
errors[field.key] = `Must be a whole number between ${field.min} and ${field.max}.`;
continue;
}
result[field.key] = value;
patch[field.key] = value;
}
for (const field of BOOLEAN_FIELDS) {
const raw = form[field.key] ?? '';
result[field.key] = raw === '' ? null : raw === 'true';
if (!isDirty(field.key)) continue;
const raw = valueOf(field.key);
patch[field.key] = raw === '' ? null : raw === 'true';
}
setFieldErrors(errors);
return Object.keys(errors).length > 0 ? null : (result as unknown as PerformanceSettings);
};
if (Object.values(errors).some(Boolean)) return;
const submit = () => {
const settings = validateAndBuild();
if (!settings) return;
save.mutate(settings, {
onSuccess: (result) => {
setForm(null); // re-derive from the fresh server response on next load
setMessage(
result.changedFields.length > 0
? `Saved ${result.changedFields.length} change${result.changedFields.length === 1 ? '' : 's'} to config.json — restart the server to apply.`
: 'No changes to save.',
);
save.mutate(
{ settings: patch, expectedRevision: data.revision, writeStartupVars: true },
{
onSuccess: (result) => {
setEdits(new Map());
void refetch();
toast(
result.changedFields.length > 0
? `Saved ${result.changedFields.length} change${result.changedFields.length === 1 ? '' : 's'}. Restart to apply.`
: 'No changes to save.',
'ok',
);
},
onError: (saveError) => toast(saveError.message, 'danger'),
},
onError: (saveError) => setMessage(saveError.message),
});
);
};
const inputClass = (key: string) => `input w-32 ${fieldErrors[key] ? 'input-error' : ''}`;
return (
<Card
title="Performance settings (config.json)"
action={
canEdit &&
dirty && (
<div className="flex items-center gap-2">
<span className="text-xs text-warn-400">unsaved changes</span>
<Button onClick={() => setForm(toFormState(data.settings))} disabled={save.isPending}>
Discard
</Button>
<Button variant="accent" onClick={submit} disabled={save.isPending}>
{save.isPending ? 'Saving…' : 'Save to server'}
</Button>
</div>
)
}
>
<div className="grid gap-x-8 gap-y-4 md:grid-cols-2">
<div className="space-y-4">
<div className="grid gap-x-8 gap-y-3 md:grid-cols-2">
{NUMBER_FIELDS.map((field) => (
<div key={field.key} className="flex items-center justify-between gap-4">
<div>
<p className="text-sm text-zinc-200">{field.label}</p>
<p className="text-xs text-slate-dim">
{field.min}–{field.max} · {field.hint} · blank = game default
</p>
{fieldErrors[field.key] && (
<p className="text-xs text-danger-400">{fieldErrors[field.key]}</p>
)}
</div>
<FieldRow
key={field.key}
label={field.label}
hint={`${field.min}–${field.max} · ${field.hint} · blank = game default`}
dirty={isDirty(field.key)}
error={fieldErrors[field.key]}
onReset={() => set(field.key, toText(baseline[field.key]))}
>
<input
type="number"
inputMode="numeric"
min={field.min}
max={field.max}
disabled={!canEdit}
value={form[field.key] ?? ''}
value={valueOf(field.key)}
placeholder="default"
onChange={(event) => set(field.key, event.target.value)}
className={inputClass(field.key)}
className={`input numeric w-32 ${fieldErrors[field.key] ? 'input-error' : ''}`}
/>
</div>
</FieldRow>
))}
{BOOLEAN_FIELDS.map((field) => (
<div key={field.key} className="flex items-center justify-between gap-4">
<div>
<p className="text-sm text-zinc-200">{field.label}</p>
<p className="text-xs text-slate-dim">{field.hint}</p>
</div>
<FieldRow
key={field.key}
label={field.label}
hint={field.hint}
dirty={isDirty(field.key)}
onReset={() => set(field.key, toText(baseline[field.key]))}
>
<select
disabled={!canEdit}
value={form[field.key] ?? ''}
value={valueOf(field.key)}
onChange={(event) => set(field.key, event.target.value)}
className="input w-32"
>
@@ -198,15 +206,72 @@ export function PerformanceForm({ slug, canEdit }: { slug: string; canEdit: bool
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
</div>
</FieldRow>
))}
</div>
{message && <p className="mt-4 text-xs text-accent-400">{message}</p>}
<p className="mt-4 text-xs text-slate-dim">
Values are validated against the ranges in the Bohemia server-config reference and written
directly to config.json (backup kept as config.json.bak). Network/identity settings (bind
address, ports, passwords) are never touched here. Changes apply on the next restart.
{canEdit && dirtyKeys.length > 0 && (
<div className="flex flex-wrap items-center gap-2 border-t border-graphite-700 pt-3">
<span className="text-xs text-warn-400">
{dirtyKeys.length} field{dirtyKeys.length === 1 ? '' : 's'} changed
</span>
<div className="ml-auto flex gap-2">
<Button onClick={() => setEdits(new Map())} disabled={save.isPending}>
Discard
</Button>
<Button variant="accent" icon="upload" onClick={submit} loading={save.isPending}>
Apply to server
</Button>
</div>
</div>
)}
<p className="text-2xs leading-5 text-slate-dim">
Values are validated against the Bohemia server-config reference and written directly to
config.json (the previous file is kept as config.json.bak). Network and identity settings —
bind address, ports, passwords — are never touched here. Changes apply on the next restart.
</p>
</Card>
</div>
);
}
function FieldRow({
label,
hint,
dirty,
error,
onReset,
children,
}: {
label: string;
hint: string;
dirty: boolean;
error?: string;
onReset: () => void;
children: React.ReactNode;
}) {
return (
<div
className={`flex items-center justify-between gap-4 rounded-sm px-2 py-1.5 ${dirty ? 'bg-accent-600/[0.07]' : ''}`}
>
<div className="min-w-0">
<p className="flex items-center gap-2 text-sm text-zinc-100">
{label}
{dirty && (
<button
type="button"
title="Revert to the value on the server"
onClick={onReset}
className="text-accent-400 hover:text-accent-300"
>
<Icon name="refresh" className="h-3 w-3" />
</button>
)}
</p>
<p className="text-2xs text-slate-dim">{hint}</p>
{error && <p className="text-2xs text-danger-400">{error}</p>}
</div>
{children}
</div>
);
}
+30 -15
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useStartupVariables, useUpdateStartupVariable } from '../api/hooks.js';
import { Button, Card, EmptyState, Spinner } from './ui.js';
import { STARTUP_MIRROR_HINTS } from './config/mirror-hints.js';
import { Badge, Button, Card, EmptyState, Spinner, useToast } from './ui.js';
/**
* Pterodactyl egg startup variables (passwords, launch options, …). Values
@@ -14,15 +15,14 @@ export function StartupVarsCard({ slug }: { slug: string }) {
const { data, isLoading, error } = useStartupVariables(slug, true);
const update = useUpdateStartupVariable(slug);
const [edits, setEdits] = useState<Record<string, string>>({});
const [message, setMessage] = useState<string | null>(null);
const [revealed, setRevealed] = useState<Record<string, boolean>>({});
const toast = useToast();
const isSecret = (name: string) => /password|token|secret|key/i.test(name);
const saveVariable = (envVariable: string) => {
const value = edits[envVariable];
if (value === undefined) return;
setMessage(null);
update.mutate(
{ key: envVariable, value },
{
@@ -32,9 +32,9 @@ export function StartupVarsCard({ slug }: { slug: string }) {
delete next[envVariable];
return next;
});
setMessage(`${envVariable} saved — applies on the next restart.`);
toast(`${envVariable} saved — applies on the next restart.`, 'ok');
},
onError: (updateError) => setMessage(updateError.message),
onError: (updateError) => toast(updateError.message, 'danger'),
},
);
};
@@ -58,12 +58,23 @@ export function StartupVarsCard({ slug }: { slug: string }) {
return (
<li
key={variable.envVariable}
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-graphite-800 px-3.5 py-2.5"
className="flex flex-wrap items-center justify-between gap-3 rounded-sm border border-graphite-800 bg-graphite-950/40 px-3 py-2.5"
>
<div className="min-w-0 flex-1">
<p className="text-sm text-zinc-200">
{variable.name}{' '}
<code className="ml-1 text-xs text-slate-dim">{variable.envVariable}</code>
<p className="flex flex-wrap items-center gap-2 text-sm text-zinc-100">
{variable.name}
<code className="font-mono text-2xs text-slate-dim">
{variable.envVariable}
</code>
{STARTUP_MIRROR_HINTS[variable.envVariable] && (
<Badge
tone="warn"
icon="alert"
title={`Also written into config.json at ${STARTUP_MIRROR_HINTS[variable.envVariable]}`}
>
templates {STARTUP_MIRROR_HINTS[variable.envVariable]}
</Badge>
)}
</p>
{variable.description && (
<p className="mt-0.5 text-xs text-slate-dim">{variable.description}</p>
@@ -82,6 +93,7 @@ export function StartupVarsCard({ slug }: { slug: string }) {
/>
{secret && (
<Button
size="sm"
onClick={() => setRevealed({ ...revealed, [variable.envVariable]: !shown })}
>
{shown ? 'Hide' : 'Show'}
@@ -91,15 +103,17 @@ export function StartupVarsCard({ slug }: { slug: string }) {
edited !== undefined &&
edited !== variable.value && (
<Button
size="sm"
variant="accent"
disabled={update.isPending}
icon="upload"
loading={update.isPending}
onClick={() => saveVariable(variable.envVariable)}
>
Save
</Button>
)
) : (
<span className="text-xs text-slate-dim">read-only</span>
<Badge>read-only</Badge>
)}
</div>
</li>
@@ -107,10 +121,11 @@ export function StartupVarsCard({ slug }: { slug: string }) {
})}
</ul>
)}
{message && <p className="mt-3 text-xs text-accent-400">{message}</p>}
<p className="mt-3 text-xs text-slate-dim">
These are the same variables as Pterodactyl's Startup tab (server passwords live here, not
in config.json). Changes apply on the next server restart.
<p className="mt-3 text-2xs leading-5 text-slate-dim">
These are the same variables as Pterodactyl&rsquo;s Startup tab; server passwords live here
rather than in config.json. Variables marked as templating a config path are re-applied to
config.json when the container boots, so they win over a direct file edit. Changes apply on
the next server restart.
</p>
</Card>
);
+656 -97
View File
@@ -1,6 +1,20 @@
import { useState, type ReactNode } from 'react';
import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import type { Role, ServerStatus } from '@reforger-panel/shared';
import { ROLE_LABELS } from '@reforger-panel/shared';
import { Icon, Spinner16, type IconName } from './icons.js';
/* ------------------------------------------------------------------ layout */
export function Card({
title,
@@ -23,24 +37,385 @@ export function Card({
{action}
</header>
)}
<div className={padded ? 'p-5' : ''}>{children}</div>
<div className={padded ? 'p-4' : ''}>{children}</div>
</section>
);
}
export function PageHeader({
title,
kicker,
actions,
}: {
title: string;
kicker?: ReactNode;
actions?: ReactNode;
}) {
return (
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="page-title">{title}</h1>
{kicker && <p className="page-kicker">{kicker}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</div>
);
}
/* ----------------------------------------------------------------- buttons */
type ButtonVariant = 'default' | 'accent' | 'danger' | 'ghost' | 'subtle';
type ButtonSize = 'sm' | 'md';
const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
default: 'border-graphite-600 bg-graphite-800 text-zinc-200 hover:bg-graphite-700',
accent: 'border-accent-600 bg-accent-600/20 text-accent-300 hover:bg-accent-600/30',
danger: 'border-danger-400/45 bg-danger-400/10 text-danger-400 hover:bg-danger-400/20',
ghost:
'border-transparent bg-transparent text-slate-ink hover:bg-graphite-800 hover:text-zinc-100',
subtle:
'border-transparent bg-graphite-850 text-slate-ink hover:bg-graphite-800 hover:text-zinc-100',
};
const BUTTON_SIZES: Record<ButtonSize, string> = {
sm: 'min-h-7 gap-1.5 px-2 py-1 text-xs',
md: 'min-h-8 gap-2 px-3 py-1.5 text-sm',
};
export function Button({
children,
onClick,
disabled,
loading,
variant = 'default',
size = 'md',
icon,
title,
type = 'button',
className = '',
}: {
children?: ReactNode;
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
variant?: ButtonVariant;
size?: ButtonSize;
icon?: IconName;
title?: string;
type?: 'button' | 'submit';
className?: string;
}) {
return (
<button
type={type}
title={title}
onClick={onClick}
disabled={disabled || loading}
className={`inline-flex items-center justify-center rounded-sm border font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${BUTTON_VARIANTS[variant]} ${BUTTON_SIZES[size]} ${className}`}
>
{loading ? (
<Spinner16 className={size === 'sm' ? 'h-3 w-3' : 'h-3.5 w-3.5'} />
) : (
icon && <Icon name={icon} className={size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'} />
)}
{children}
</button>
);
}
export function IconButton({
icon,
label,
onClick,
disabled,
variant = 'ghost',
size = 'md',
}: {
icon: IconName;
/** Required: icon-only controls must still be announced. */
label: string;
onClick?: () => void;
disabled?: boolean;
variant?: ButtonVariant;
size?: ButtonSize;
}) {
return (
<button
type="button"
title={label}
aria-label={label}
onClick={onClick}
disabled={disabled}
className={`inline-flex items-center justify-center rounded-sm border transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${BUTTON_VARIANTS[variant]} ${size === 'sm' ? 'h-7 w-7' : 'h-8 w-8'}`}
>
<Icon name={icon} className={size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'} />
</button>
);
}
/* ------------------------------------------------------------------ inputs */
export function Field({
label,
hint,
error,
children,
}: {
label: string;
hint?: ReactNode;
error?: string | null;
children: ReactNode;
}) {
return (
<label className="block">
<span className="eyebrow">{label}</span>
<div className="mt-1.5">{children}</div>
{error ? (
<span className="mt-1 block text-xs text-danger-400">{error}</span>
) : (
hint && <span className="mt-1 block text-xs text-slate-dim">{hint}</span>
)}
</label>
);
}
export function SearchInput({
value,
onChange,
placeholder = 'Search…',
className = '',
autoFocus,
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
autoFocus?: boolean;
}) {
return (
<div className={`relative ${className}`}>
<Icon
name="search"
className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-dim"
/>
<input
type="search"
value={value}
autoFocus={autoFocus}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
className="input pl-8"
/>
{value && (
<button
type="button"
aria-label="Clear search"
onClick={() => onChange('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-dim hover:text-zinc-200"
>
<Icon name="close" className="h-3.5 w-3.5" />
</button>
)}
</div>
);
}
export function Toggle({
checked,
onChange,
label,
disabled,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
label: string;
disabled?: boolean;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`inline-flex h-5 w-9 shrink-0 items-center rounded-full border transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
checked ? 'border-accent-500 bg-accent-600/50' : 'border-graphite-600 bg-graphite-800'
}`}
>
<span
className={`h-3.5 w-3.5 rounded-full bg-zinc-200 transition-transform ${
checked ? 'translate-x-[18px]' : 'translate-x-[3px]'
}`}
/>
</button>
);
}
export function SegmentedControl<T extends string>({
value,
options,
onChange,
size = 'md',
}: {
value: T;
options: { value: T; label: string; icon?: IconName; count?: number }[];
onChange: (value: T) => void;
size?: ButtonSize;
}) {
return (
<div
role="tablist"
className="inline-flex items-center gap-0.5 rounded-sm border border-graphite-700 bg-graphite-950 p-0.5"
>
{options.map((option) => {
const active = option.value === value;
return (
<button
key={option.value}
role="tab"
type="button"
aria-selected={active}
onClick={() => onChange(option.value)}
className={`inline-flex items-center gap-1.5 rounded-xs font-semibold transition-colors ${
size === 'sm' ? 'px-2 py-1 text-2xs' : 'px-2.5 py-1.5 text-xs'
} ${
active
? 'bg-graphite-700 text-zinc-100'
: 'text-slate-dim hover:bg-graphite-850 hover:text-zinc-200'
}`}
>
{option.icon && <Icon name={option.icon} className="h-3.5 w-3.5" />}
{option.label}
{option.count !== undefined && (
<span className="numeric text-2xs text-slate-dim">{option.count}</span>
)}
</button>
);
})}
</div>
);
}
/* ------------------------------------------------------------------ status */
const STATUS_STYLES: Record<ServerStatus, { dot: string; text: string; label: string }> = {
online: {
dot: 'bg-ok-400 shadow-[0_0_8px_var(--color-ok-400)]',
text: 'text-ok-400',
label: 'Online',
},
offline: { dot: 'bg-slate-faint', text: 'text-slate-dim', 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' },
unknown: { dot: 'bg-graphite-500', text: 'text-slate-faint', label: 'Unknown' },
};
export function StatusBadge({ status, compact }: { status: ServerStatus; compact?: boolean }) {
const style = STATUS_STYLES[status] ?? STATUS_STYLES.unknown;
return (
<span
className={`inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border border-current/20 bg-current/5 px-2 py-0.5 text-2xs font-semibold uppercase tracking-wider ${style.text}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${style.dot}`} />
{!compact && style.label}
</span>
);
}
type BadgeTone = 'neutral' | 'accent' | 'ok' | 'warn' | 'danger' | 'info';
const BADGE_TONES: Record<BadgeTone, string> = {
neutral: 'border-graphite-600 bg-graphite-800 text-slate-ink',
accent: 'border-accent-600/50 bg-accent-600/12 text-accent-300',
ok: 'border-ok-400/40 bg-ok-400/10 text-ok-400',
warn: 'border-warn-400/40 bg-warn-400/10 text-warn-400',
danger: 'border-danger-400/40 bg-danger-400/10 text-danger-400',
info: 'border-info-400/40 bg-info-400/10 text-info-400',
};
export function Badge({
children,
tone = 'neutral',
icon,
title,
}: {
children: ReactNode;
tone?: BadgeTone;
icon?: IconName;
title?: string;
}) {
return (
<span
title={title}
className={`inline-flex items-center gap-1 whitespace-nowrap rounded-xs border px-1.5 py-0.5 text-2xs font-semibold ${BADGE_TONES[tone]}`}
>
{icon && <Icon name={icon} className="h-3 w-3" />}
{children}
</span>
);
}
const ROLE_TONES: Record<Role, BadgeTone> = {
owner: 'accent',
server_admin: 'info',
mission_lead: 'warn',
viewer: 'neutral',
};
export function RoleBadge({ role }: { role: Role }) {
return (
<Badge tone={ROLE_TONES[role]}>
<span className="uppercase tracking-wider">{ROLE_LABELS[role]}</span>
</Badge>
);
}
/* ------------------------------------------------------------- placeholders */
export function EmptyState({
title,
hint,
icon = 'info',
action,
}: {
title: string;
hint?: ReactNode;
icon?: IconName;
action?: ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-2 rounded-sm border border-dashed border-graphite-700 bg-graphite-950/50 px-4 py-10 text-center">
<Icon name={icon} className="h-5 w-5 text-slate-faint" />
<p className="text-sm font-medium text-zinc-300">{title}</p>
{hint && <p className="max-w-md text-xs leading-5 text-slate-dim">{hint}</p>}
{action}
</div>
);
}
export function Spinner({ label = 'Loading…' }: { label?: string }) {
return (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-slate-dim">
<Spinner16 className="h-4 w-4 text-accent-400" />
{label}
</div>
);
}
export function Skeleton({ className = 'h-4 w-full' }: { className?: string }) {
return <div className={`animate-pulse rounded-xs bg-graphite-800 ${className}`} />;
}
/** Image with a quiet placeholder when the URL is missing or fails to load. */
export function ModImage({ src, className = '' }: { src: string | null; className?: string }) {
const [failed, setFailed] = useState(false);
if (!src || failed) {
return (
<span
className={`flex shrink-0 items-center justify-center rounded-md border border-graphite-700 bg-graphite-800 text-slate-dim ${className}`}
className={`flex shrink-0 items-center justify-center rounded-xs border border-graphite-700 bg-graphite-800 text-slate-faint ${className}`}
>
<svg viewBox="0 0 24 24" fill="none" className="h-1/2 w-1/2" stroke="currentColor">
<rect x="3" y="4" width="18" height="16" rx="2" strokeWidth="1.5" />
<circle cx="9" cy="10" r="1.75" strokeWidth="1.5" />
<path d="M4 18l5-5 3 3 4-4 4 4" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
<Icon name="image" className="h-1/2 w-1/2" />
</span>
);
}
@@ -50,119 +425,303 @@ export function ModImage({ src, className = '' }: { src: string | null; classNam
alt=""
loading="lazy"
onError={() => setFailed(true)}
className={`shrink-0 rounded-md border border-graphite-700 object-cover ${className}`}
className={`shrink-0 rounded-xs border border-graphite-700 object-cover ${className}`}
/>
);
}
const STATUS_STYLES: Record<ServerStatus, { dot: string; text: string; label: string }> = {
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' },
unknown: { dot: 'bg-zinc-600', text: 'text-zinc-500', label: 'Unknown' },
};
/* ------------------------------------------------------------------ meters */
export function StatusBadge({ status }: { status: ServerStatus }) {
const style = STATUS_STYLES[status] ?? STATUS_STYLES.unknown;
return (
<span
className={`inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border border-current/20 bg-current/5 px-2.5 py-1 text-xs font-semibold ${style.text}`}
>
<span className={`h-2 w-2 rounded-full ${style.dot}`} />
{style.label}
</span>
);
}
const ROLE_STYLES: Record<Role, string> = {
owner: 'border-accent-500/40 bg-accent-500/10 text-accent-400',
server_admin: 'border-sky-500/40 bg-sky-500/10 text-sky-400',
mission_lead: 'border-warn-400/40 bg-warn-400/10 text-warn-400',
viewer: 'border-zinc-600 bg-zinc-800/60 text-zinc-400',
};
export function RoleBadge({ role }: { role: Role }) {
return (
<span
className={`inline-flex rounded border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${ROLE_STYLES[role]}`}
>
{ROLE_LABELS[role]}
</span>
);
}
export function EmptyState({ title, hint }: { title: string; hint?: string }) {
return (
<div className="flex flex-col items-center justify-center gap-1 rounded-md border border-dashed border-graphite-700 bg-graphite-950/35 px-4 py-8 text-center">
<p className="text-sm font-medium text-zinc-300">{title}</p>
{hint && <p className="text-xs text-slate-dim">{hint}</p>}
</div>
);
}
export function Spinner({ label = 'Loading…' }: { label?: string }) {
return (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-slate-dim">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-graphite-600 border-t-accent-500" />
{label}
</div>
);
}
export function StatBar({
export function ProgressBar({
value,
max,
warnAt = 0.8,
warnAt = 0.75,
dangerAt = 0.9,
className = '',
}: {
value: number;
max: number | null;
warnAt?: number;
dangerAt?: number;
className?: string;
}) {
if (!max || max <= 0) return null;
const ratio = Math.min(1, value / max);
const color = ratio >= warnAt ? 'bg-warn-400' : 'bg-accent-500';
const ratio = Math.min(1, Math.max(0, value / max));
const color =
ratio >= dangerAt ? 'bg-danger-400' : ratio >= warnAt ? 'bg-warn-400' : 'bg-accent-500';
return (
<div className="mt-2 h-1 w-full overflow-hidden rounded-full bg-graphite-700">
<div className={`h-full rounded-full ${color}`} style={{ width: `${ratio * 100}%` }} />
<div className={`h-1 w-full overflow-hidden rounded-full bg-graphite-800 ${className}`}>
<div
className={`h-full rounded-full transition-all ${color}`}
style={{ width: `${ratio * 100}%` }}
/>
</div>
);
}
export function Button({
export function MetricTile({
label,
value,
unit,
detail,
children,
onClick,
disabled,
variant = 'default',
title,
type = 'button',
}: {
children: ReactNode;
onClick?: () => void;
disabled?: boolean;
variant?: 'default' | 'accent' | 'danger';
title?: string;
type?: 'button' | 'submit';
label: string;
value: ReactNode;
unit?: ReactNode;
detail?: ReactNode;
children?: ReactNode;
}) {
const variants = {
default:
'border-graphite-600 bg-graphite-800 text-zinc-300 hover:border-graphite-600 hover:bg-graphite-700',
accent: 'border-accent-600/60 bg-accent-600/15 text-accent-400 hover:bg-accent-600/25',
danger: 'border-danger-400/40 bg-danger-400/10 text-danger-400 hover:bg-danger-400/20',
} as const;
return (
<button
type={type}
<section className="panel-card p-4">
<p className="eyebrow">{label}</p>
<p className="numeric mt-1.5 text-2xl font-semibold leading-none text-zinc-50">
{value}
{unit && <span className="ml-1 text-sm font-normal text-slate-dim">{unit}</span>}
</p>
{detail && <div className="numeric mt-1 text-xs text-slate-dim">{detail}</div>}
{children && <div className="mt-3">{children}</div>}
</section>
);
}
/* ------------------------------------------------------------------ dialog */
export function Dialog({
open,
onClose,
title,
description,
children,
footer,
width = 'md',
}: {
open: boolean;
onClose: () => void;
title: string;
description?: ReactNode;
children: ReactNode;
footer?: ReactNode;
width?: 'sm' | 'md' | 'lg' | 'xl';
}) {
const panelRef = useRef<HTMLDivElement | null>(null);
const headingId = useId();
useEffect(() => {
if (!open) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const { overflow } = document.body.style;
document.body.style.overflow = 'hidden';
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopPropagation();
onClose();
return;
}
if (event.key !== 'Tab' || !panelRef.current) return;
// Keep focus inside the dialog.
const focusable = panelRef.current.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
if (focusable.length === 0) return;
const first = focusable[0]!;
const last = focusable[focusable.length - 1]!;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', onKeyDown, true);
panelRef.current?.querySelector<HTMLElement>('input, button')?.focus();
return () => {
document.removeEventListener('keydown', onKeyDown, true);
document.body.style.overflow = overflow;
previouslyFocused?.focus?.();
};
}, [open, onClose]);
if (!open) return null;
const widths = { sm: 'max-w-sm', md: 'max-w-lg', lg: 'max-w-3xl', xl: 'max-w-5xl' } as const;
return createPortal(
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-8">
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} aria-hidden />
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={headingId}
className={`animate-fade-in relative z-10 my-auto w-full ${widths[width]} rounded-md border border-graphite-700 bg-graphite-900 shadow-2xl shadow-black/60`}
>
<header className="flex items-start justify-between gap-4 border-b border-graphite-700 px-4 py-3">
<div className="min-w-0">
<h2 id={headingId} className="text-base font-semibold text-zinc-50">
{title}
</h2>
{description && <p className="mt-0.5 text-xs text-slate-dim">{description}</p>}
</div>
<IconButton icon="close" label="Close" onClick={onClose} />
</header>
<div className="max-h-[70vh] overflow-y-auto p-4">{children}</div>
{footer && (
<footer className="flex flex-wrap items-center justify-end gap-2 border-t border-graphite-700 px-4 py-3">
{footer}
</footer>
)}
</div>
</div>,
document.body,
);
}
export function ConfirmDialog({
open,
onClose,
onConfirm,
title,
body,
confirmLabel = 'Confirm',
variant = 'danger',
loading,
}: {
open: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
body: ReactNode;
confirmLabel?: string;
variant?: ButtonVariant;
loading?: boolean;
}) {
return (
<Dialog
open={open}
onClose={onClose}
title={title}
onClick={onClick}
disabled={disabled}
className={`inline-flex min-h-9 items-center justify-center rounded-md border px-3.5 py-2 text-sm font-semibold transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${variants[variant]}`}
width="sm"
footer={
<>
<Button onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button variant={variant} onClick={onConfirm} loading={loading}>
{confirmLabel}
</Button>
</>
}
>
<div className="text-sm leading-6 text-slate-ink">{body}</div>
</Dialog>
);
}
/* ------------------------------------------------------------------ toasts */
type Toast = { id: number; tone: BadgeTone; message: string };
const ToastContext = createContext<(message: string, tone?: BadgeTone) => void>(() => {});
/** `toast('Saved')` from anywhere below <ToastProvider>. */
export function useToast() {
return useContext(ToastContext);
}
const TOAST_TONES: Record<BadgeTone, string> = {
neutral: 'border-graphite-600 bg-graphite-800 text-zinc-100',
accent: 'border-accent-600 bg-graphite-800 text-accent-300',
ok: 'border-ok-400/50 bg-graphite-800 text-ok-400',
warn: 'border-warn-400/50 bg-graphite-800 text-warn-400',
danger: 'border-danger-400/50 bg-graphite-800 text-danger-400',
info: 'border-info-400/50 bg-graphite-800 text-info-400',
};
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const nextId = useRef(1);
const push = useCallback((message: string, tone: BadgeTone = 'neutral') => {
const id = nextId.current++;
setToasts((current) => [...current.slice(-3), { id, tone, message }]);
const timer = setTimeout(
() => setToasts((current) => current.filter((toast) => toast.id !== id)),
tone === 'danger' ? 8_000 : 4_500,
);
return () => clearTimeout(timer);
}, []);
const value = useMemo(() => push, [push]);
return (
<ToastContext.Provider value={value}>
{children}
</button>
<div
aria-live="polite"
className="pointer-events-none fixed bottom-4 right-4 z-[60] flex w-80 flex-col gap-2"
>
{toasts.map((toast) => (
<div
key={toast.id}
className={`animate-fade-in pointer-events-auto flex items-start gap-2 rounded-sm border px-3 py-2 text-xs leading-5 shadow-lg shadow-black/40 ${TOAST_TONES[toast.tone]}`}
>
<Icon
name={toast.tone === 'danger' ? 'alert' : toast.tone === 'ok' ? 'check' : 'info'}
className="mt-0.5 h-3.5 w-3.5"
/>
<span className="min-w-0 flex-1">{toast.message}</span>
<button
type="button"
aria-label="Dismiss"
onClick={() => setToasts((current) => current.filter((t) => t.id !== toast.id))}
className="text-current/60 hover:text-current"
>
<Icon name="close" className="h-3 w-3" />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
/* ------------------------------------------------------------------ notices */
export function Notice({
tone = 'info',
title,
children,
action,
}: {
tone?: 'info' | 'warn' | 'danger' | 'ok';
title?: string;
children: ReactNode;
action?: ReactNode;
}) {
const tones = {
info: 'border-info-400/35 bg-info-400/[0.07] text-info-400',
warn: 'border-warn-400/35 bg-warn-400/[0.07] text-warn-400',
danger: 'border-danger-400/35 bg-danger-400/[0.07] text-danger-400',
ok: 'border-ok-400/35 bg-ok-400/[0.07] text-ok-400',
} as const;
return (
<div
className={`flex flex-wrap items-start gap-3 rounded-sm border px-3 py-2.5 ${tones[tone]}`}
>
<Icon
name={tone === 'ok' ? 'check' : tone === 'info' ? 'info' : 'alert'}
className="mt-0.5 h-4 w-4"
/>
<div className="min-w-0 flex-1 text-xs leading-5">
{title && <p className="font-semibold">{title}</p>}
<div className="text-slate-ink">{children}</div>
</div>
{action}
</div>
);
}
+85 -84
View File
@@ -1,4 +1,3 @@
import { useState } from 'react';
import type {
ActivityItem,
Capability,
@@ -13,10 +12,10 @@ import {
useManualLogSync,
usePlayers,
usePowerAction,
useWorkshopHealth,
} from '../api/hooks.js';
import { formatDateTime, formatDuration, formatRelativeTime } from '../lib/format.js';
import { Button, Card, EmptyState, Spinner } from './ui.js';
import { Badge, Button, Card, EmptyState, Spinner, useToast } from './ui.js';
import { shortScenario } from './mission-card.js';
function can(user: CurrentUser, capability: Capability): boolean {
return user.capabilities.includes(capability);
@@ -24,14 +23,18 @@ function can(user: CurrentUser, capability: Capability): boolean {
export function PowerControls({ user, server }: { user: CurrentUser; server: ServerSummary }) {
const power = usePowerAction(server.slug);
const [message, setMessage] = useState<string | null>(null);
const toast = useToast();
const run = (action: 'start' | 'stop' | 'restart') => {
setMessage(null);
power.mutate(action, {
onSuccess: (result) =>
setMessage(result.simulated ? `${action} simulated (mock mode)` : `${action} requested`),
onError: (error) => setMessage(error.message),
toast(
result.simulated
? `${action} simulated (mock mode) — watch the Console`
: `${action} requested — watch the Console for live output`,
'ok',
),
onError: (error) => toast(error.message, 'danger'),
});
};
@@ -40,32 +43,37 @@ export function PowerControls({ user, server }: { user: CurrentUser; server: Ser
const canRestart = can(user, 'server.power.restart');
if (!canStart && !canStop && !canRestart) return null;
const busy = power.isPending || server.status === 'starting' || server.status === 'stopping';
return (
<div className="flex w-full flex-wrap items-center justify-end gap-2 md:w-auto">
<div className="flex flex-wrap items-center justify-end gap-1.5">
{canStart && (
<Button
size="sm"
variant="accent"
disabled={power.isPending || server.status === 'online'}
icon="play"
disabled={busy || server.status === 'online'}
onClick={() => run('start')}
>
Start
</Button>
)}
{canRestart && (
<Button disabled={power.isPending} onClick={() => run('restart')}>
<Button size="sm" icon="restart" disabled={busy} onClick={() => run('restart')}>
Restart
</Button>
)}
{canStop && (
<Button
size="sm"
variant="danger"
disabled={power.isPending || server.status === 'offline'}
icon="stop"
disabled={busy || server.status === 'offline'}
onClick={() => run('stop')}
>
Stop
</Button>
)}
{message && <span className="text-xs text-slate-dim">{message}</span>}
</div>
);
}
@@ -83,11 +91,11 @@ export function CurrentPlayersCard({
title="Current players"
action={
data && (
<span className="text-xs text-slate-dim">
<span className="text-2xs text-slate-dim">
{data.stale ? (
<span className="text-warn-400">data may be stale</span>
) : (
<>last synchronized {formatRelativeTime(data.lastSyncedAt)}</>
<>synced {formatRelativeTime(data.lastSyncedAt)}</>
)}
</span>
)
@@ -111,14 +119,15 @@ function PlayersTable({
}) {
return (
<div>
<p className="mb-4 text-3xl font-semibold text-zinc-100">
<p className="numeric mb-4 text-3xl font-semibold leading-none text-zinc-50">
{players.onlineCount}
<span className="text-base font-normal text-slate-dim"> / {maxPlayers ?? '—'} online</span>
<span className="text-sm font-normal text-slate-dim"> / {maxPlayers ?? '—'} online</span>
</p>
{players.players.length === 0 ? (
<EmptyState
icon="users"
title="No players connected"
hint="Player presence is reconstructed from server logs and updates on each sync."
hint="Player presence is reconstructed from the server log and updates on each sync."
/>
) : (
<div className="data-table-scroll">
@@ -133,9 +142,9 @@ function PlayersTable({
<tbody>
{players.players.map((player) => (
<tr key={player.playerId}>
<td className="py-2 font-medium text-zinc-200">{player.displayName}</td>
<td className="py-2 text-slate-ink">{formatDateTime(player.connectedAt)}</td>
<td className="py-2 text-right font-mono text-xs text-accent-400">
<td className="font-medium text-zinc-100">{player.displayName}</td>
<td className="numeric text-slate-ink">{formatDateTime(player.connectedAt)}</td>
<td className="numeric text-right text-xs text-accent-400">
{formatDuration(player.sessionDurationSeconds)}
</td>
</tr>
@@ -149,9 +158,9 @@ function PlayersTable({
}
const ACTIVITY_COLORS: Record<string, string> = {
player_connected: 'text-accent-400',
player_connected: 'text-ok-400',
player_disconnected: 'text-slate-ink',
server_started: 'text-accent-400',
server_started: 'text-ok-400',
server_stopped: 'text-warn-400',
server_restart_detected: 'text-warn-400',
log_sync_failed: 'text-danger-400',
@@ -173,28 +182,31 @@ export function ActivityList({
}) {
if (items.length === 0) {
return (
<EmptyState title="No activity yet" hint="Panel actions and server events appear here." />
<EmptyState
icon="pulse"
title="No activity yet"
hint="Panel actions and server events appear here."
/>
);
}
return (
<div
className="overflow-y-auto rounded-md border border-graphite-800 bg-graphite-950/70 font-mono text-xs shadow-inner"
style={{ maxHeight }}
>
<div className="console-surface overflow-y-auto" style={{ maxHeight }}>
<ul>
{items.map((item) => (
<li
key={item.id}
className="flex items-baseline gap-3 border-b border-graphite-800/60 px-3 py-1.5 last:border-0 hover:bg-graphite-850/80"
className="flex items-baseline gap-3 border-b border-graphite-800/60 px-3 py-1.5 last:border-0 hover:bg-graphite-900/70"
title={new Date(item.occurredAt).toLocaleString()}
>
<span className="shrink-0 text-slate-dim">{logTimestamp(item.occurredAt)}</span>
<span className="numeric shrink-0 text-slate-faint">
{logTimestamp(item.occurredAt)}
</span>
<span
className={`min-w-0 flex-1 truncate ${ACTIVITY_COLORS[item.action] ?? 'text-zinc-300'}`}
>
{item.summary}
</span>
<span className="shrink-0 text-[10px] uppercase tracking-wider text-slate-dim">
<span className="shrink-0 text-2xs uppercase tracking-wider text-slate-faint">
{item.kind === 'panel_action' ? 'panel' : 'server'}
</span>
</li>
@@ -213,33 +225,25 @@ export function RecentActivityCard({ slug, limit = 50 }: { slug: string; limit?:
);
}
/** Display form of a scenario id: just the file name, e.g. "23_Campaign.conf". */
export function shortScenario(scenarioId: string): string {
const slash = scenarioId.lastIndexOf('/');
return slash >= 0 ? scenarioId.slice(slash + 1) : scenarioId;
}
export function ConfigSummaryRows({ config }: { config: ConfigurationResponse }) {
const c = config.config;
const rows: [string, string][] = [
['Mission', shortScenario(c.scenarioId)],
const rows: [string, string, string?][] = [
['Mission', shortScenario(c.scenarioId), c.scenarioId],
['Max players', String(c.maxPlayers)],
// Reforger uses -1 for "no AI limit".
['AI limit', c.aiLimit < 0 ? 'Unlimited' : String(c.aiLimit)],
['View distance', `${c.serverMaxViewDistance} m (network ${c.networkViewDistance} m)`],
['View distance', `${c.serverMaxViewDistance} m`],
['Network view distance', `${c.networkViewDistance} m`],
['Third person', c.disableThirdPerson ? 'Disabled' : 'Allowed'],
['Cross-platform', c.crossPlatform ? 'Enabled' : 'Disabled'],
['Mods', `${c.mods.length}`],
['Mods', String(c.mods.length)],
];
return (
<dl className="space-y-2">
{rows.map(([label, value]) => (
<dl className="space-y-1.5">
{rows.map(([label, value, title]) => (
<div key={label} className="flex items-baseline justify-between gap-4">
<dt className="shrink-0 text-xs uppercase tracking-wider text-slate-dim">{label}</dt>
<dd
className="truncate text-right font-mono text-xs text-zinc-300"
title={label === 'Mission' ? c.scenarioId : value}
>
<dt className="eyebrow shrink-0">{label}</dt>
<dd className="numeric truncate text-right text-xs text-zinc-200" title={title ?? value}>
{value}
</dd>
</div>
@@ -248,86 +252,84 @@ export function ConfigSummaryRows({ config }: { config: ConfigurationResponse })
);
}
/**
* Log-ingestion diagnostics. The reforgermods.net probe that used to sit here
* was removed: it polled every minute, told nobody anything actionable, and
* the Workshop cache degrades gracefully on its own.
*/
export function OpsHealthCard({ user, slug }: { user: CurrentUser; slug: string }) {
const visible = can(user, 'ops.health.view');
const { data: workshop } = useWorkshopHealth();
const { data: logs } = useLogHealth(slug, visible);
const syncNow = useManualLogSync(slug);
const [syncMessage, setSyncMessage] = useState<string | null>(null);
const toast = useToast();
if (!visible) return null;
return (
<Card
title="Operational health"
title="Log ingestion"
action={
can(user, 'logs.sync') && (
<Button
disabled={syncNow.isPending || logs?.configured === false}
size="sm"
icon="refresh"
loading={syncNow.isPending}
disabled={logs?.configured === false}
onClick={() =>
syncNow.mutate(undefined, {
onSuccess: (result) =>
setSyncMessage(
`Synced: ${result.processedLines} lines, ${result.createdEvents} new events`,
toast(
`Synced ${result.processedLines} lines, ${result.createdEvents} new events`,
'ok',
),
onError: (error) => setSyncMessage(error.message),
onError: (error) => toast(error.message, 'danger'),
})
}
>
{syncNow.isPending ? 'Syncing…' : 'Sync logs now'}
Sync now
</Button>
)
}
>
<dl className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<dt className="text-slate-ink">Workshop API</dt>
<dd>
{workshop ? (
workshop.ok ? (
<span className="text-accent-400">
healthy · {workshop.latencyMs} ms · {formatRelativeTime(workshop.checkedAt)}
</span>
) : (
<span className="text-danger-400" title={workshop.message ?? undefined}>
unreachable
</span>
)
) : (
<span className="text-slate-dim">checking…</span>
)}
</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-slate-ink">Log ingestion</dt>
<div className="flex items-center justify-between gap-4">
<dt className="text-slate-ink">Status</dt>
<dd>
{!logs ? (
<span className="text-slate-dim">checking…</span>
) : !logs.configured ? (
<span className="text-slate-dim">not configured</span>
<Badge>not configured</Badge>
) : logs.stale ? (
<span className="text-warn-400">stale</span>
<Badge tone="warn">stale</Badge>
) : (
<span className="text-accent-400">healthy</span>
<Badge tone="ok">healthy</Badge>
)}
</dd>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-4">
<dt className="text-slate-ink">Last successful sync</dt>
<dd className="text-zinc-300">
<dd className="numeric text-xs text-zinc-200">
{formatRelativeTime(logs?.lastSuccessfulSyncAt ?? null)}
</dd>
</div>
{logs?.lastSync && (
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-4">
<dt className="text-slate-ink">Last sync processed</dt>
<dd className="font-mono text-xs text-zinc-300">
<dd className="numeric text-xs text-zinc-200">
{logs.lastSync.processedLines} lines · {logs.lastSync.createdEvents} events
</dd>
</div>
)}
{logs?.logPath && (
<div className="flex items-center justify-between gap-4">
<dt className="text-slate-ink">Log file</dt>
<dd className="truncate font-mono text-2xs text-slate-dim" title={logs.logPath}>
{logs.logPath}
</dd>
</div>
)}
{logs?.lastErrorMessage && (
<div className="flex items-center justify-between gap-4">
<dt className="shrink-0 text-slate-ink">Last sync error</dt>
<dt className="shrink-0 text-slate-ink">Last error</dt>
<dd
className="truncate text-xs text-danger-400"
title={`${formatRelativeTime(logs.lastErrorAt)}: ${logs.lastErrorMessage}`}
@@ -336,7 +338,6 @@ export function OpsHealthCard({ user, slug }: { user: CurrentUser; slug: string
</dd>
</div>
)}
{syncMessage && <p className="text-xs text-slate-dim">{syncMessage}</p>}
</dl>
</Card>
);
+218 -112
View File
@@ -1,124 +1,230 @@
@import 'tailwindcss';
/*
* Industrial graphite system.
*
* Three ideas hold it together: a narrow neutral ramp so nothing shouts, a
* small set of semantic signal colours reserved for state (never decoration),
* and tight geometry — 4px rhythm, small radii, hairline rules — so dense
* operational data reads as instrumentation rather than as a marketing page.
*/
@theme {
--color-graphite-950: #12161b;
--color-graphite-900: #191e24;
--color-graphite-850: #20262e;
--color-graphite-800: #29313a;
--color-graphite-700: #3a4552;
--color-graphite-600: #505c69;
--color-slate-ink: #b1bac4;
--color-slate-dim: #838e9a;
--color-accent-500: #6f8fab;
--color-accent-400: #9bb4ca;
--color-accent-600: #58758e;
--color-warn-400: #d2a85b;
--color-danger-400: #d37a70;
/* Surfaces, darkest (page) to lightest (raised). */
--color-graphite-950: #0e1116;
--color-graphite-900: #141920;
--color-graphite-850: #1a2028;
--color-graphite-800: #212832;
--color-graphite-700: #2b3441;
--color-graphite-600: #3b4655;
--color-graphite-500: #4d5a6b;
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
/* Ink ramp. */
--color-slate-ink: #aab6c3;
--color-slate-dim: #78838f;
--color-slate-faint: #5a636f;
/* Accent — used for interactive affordances and the primary series. */
--color-accent-700: #3f5a75;
--color-accent-600: #4d6f8f;
--color-accent-500: #6e93b5;
--color-accent-400: #9dbcd8;
--color-accent-300: #c2d7ea;
/* Signals. Reserved for state; never used to decorate. */
--color-ok-400: #5fbf8f;
--color-warn-400: #d6a548;
--color-danger-400: #d9695f;
--color-info-400: #7fb2e5;
--font-sans: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
/* Small, hard radii. */
--radius-xs: 2px;
--radius-sm: 3px;
--radius-md: 5px;
--radius-lg: 8px;
/* Type scale, tuned for dense readouts. */
--text-2xs: 0.6875rem;
--text-2xs--line-height: 1rem;
--text-xs: 0.75rem;
--text-xs--line-height: 1.125rem;
--text-sm: 0.8125rem;
--text-sm--line-height: 1.25rem;
--text-base: 0.875rem;
--text-base--line-height: 1.375rem;
--text-lg: 1rem;
--text-lg--line-height: 1.5rem;
--text-xl: 1.25rem;
--text-xl--line-height: 1.75rem;
--text-2xl: 1.5rem;
--text-2xl--line-height: 1.875rem;
--text-3xl: 1.875rem;
--text-3xl--line-height: 2.125rem;
}
body {
@apply bg-graphite-950 text-zinc-200 antialiased;
background: var(--color-graphite-950);
@layer base {
html {
color-scheme: dark;
}
body {
@apply bg-graphite-950 text-zinc-200 antialiased;
}
button,
a,
input,
select,
textarea {
@apply outline-none;
}
:focus-visible {
@apply ring-2 ring-accent-500/50 ring-offset-2 ring-offset-graphite-950;
}
::selection {
background: color-mix(in srgb, var(--color-accent-500) 32%, transparent);
}
/* Quiet, thin scrollbars — the panel is full of scrolling regions. */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-graphite-600) transparent;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--color-graphite-600);
border: 3px solid transparent;
background-clip: content-box;
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-graphite-500);
background-clip: content-box;
}
}
button,
a,
input,
select,
textarea {
@apply outline-none;
@layer components {
/* Numbers that update in place must not reflow their neighbours. */
.numeric {
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum';
}
.panel-card {
/* min-w-0 lets cards shrink inside grid tracks instead of widening them. */
@apply min-w-0 rounded-md border border-graphite-700 bg-graphite-900;
}
.panel-card-header {
@apply flex flex-wrap items-center justify-between gap-3 border-b border-graphite-700 px-4 py-3;
}
.panel-card-title {
@apply text-2xs font-semibold uppercase tracking-[0.16em] text-slate-dim;
}
.page-title {
@apply text-2xl font-semibold tracking-tight text-zinc-50;
}
.page-kicker {
@apply mt-1 max-w-2xl text-sm leading-6 text-slate-ink;
}
/* Section label used above grouped controls and inside dense lists. */
.eyebrow {
@apply text-2xs font-semibold uppercase tracking-[0.16em] text-slate-dim;
}
.input {
@apply w-full rounded-sm border border-graphite-600 bg-graphite-950 px-2.5 py-1.5 text-sm text-zinc-100 transition-colors;
@apply placeholder:text-slate-faint hover:border-graphite-500 focus:border-accent-500;
@apply disabled:cursor-not-allowed disabled:opacity-45;
}
.input-error {
@apply border-danger-400/70 focus:border-danger-400;
}
/* No native number spinners — they clash with the theme. */
input[type='number'].input {
appearance: textfield;
-moz-appearance: textfield;
}
input[type='number'].input::-webkit-inner-spin-button,
input[type='number'].input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Selects: replace the native chrome with a themed chevron. */
select.input {
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2378838f' stroke-width='2.25' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.6rem center;
padding-right: 1.9rem;
}
select.input option {
@apply bg-graphite-850 text-zinc-100;
}
.data-table-scroll {
@apply overflow-x-auto;
}
.data-table {
@apply w-full min-w-fit text-sm;
}
.data-table th,
.data-table td {
@apply whitespace-nowrap pr-4 last:pr-0;
}
.data-table thead tr {
@apply border-b border-graphite-700 text-left text-2xs uppercase tracking-[0.12em] text-slate-dim;
}
.data-table th {
@apply pb-2 font-semibold;
}
.data-table tbody tr {
@apply border-b border-graphite-800 last:border-0 hover:bg-graphite-850/60;
}
.data-table td {
@apply py-2;
}
/* Terminal surface shared by the console and the activity feed. */
.console-surface {
@apply rounded-sm border border-graphite-800 bg-[#0a0d11] font-mono text-xs;
}
}
:focus-visible {
@apply ring-2 ring-accent-500/45 ring-offset-2 ring-offset-graphite-950;
}
@layer utilities {
@keyframes rp-fade-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: none;
}
}
::selection {
background: color-mix(in srgb, var(--color-accent-500) 35%, transparent);
}
.panel-card {
/* min-w-0 lets cards shrink inside grid tracks instead of widening them. */
@apply min-w-0 rounded-lg border border-graphite-700/70 bg-graphite-900 shadow-sm shadow-black/20;
}
.panel-card-header {
@apply flex flex-wrap items-center justify-between gap-3 border-b border-graphite-700/60 px-5 py-4;
}
.panel-card-title {
@apply text-xs font-semibold uppercase tracking-[0.14em] text-slate-ink;
}
.page-title {
@apply text-2xl font-semibold text-zinc-100;
letter-spacing: 0;
}
.page-kicker {
@apply mt-1 max-w-2xl text-sm leading-6 text-slate-ink;
}
.input {
@apply rounded-md border border-graphite-600 bg-graphite-950/55 px-3 py-2 text-sm text-zinc-200 shadow-sm transition-colors placeholder:text-slate-dim hover:border-slate-dim/70 focus:border-accent-500 disabled:cursor-not-allowed disabled:opacity-50;
}
.input-error {
@apply border-danger-400/70 focus:border-danger-400 focus:ring-danger-400/30;
}
/* No native number spinners — they clash with the theme. */
input[type='number'].input {
appearance: textfield;
-moz-appearance: textfield;
}
input[type='number'].input::-webkit-inner-spin-button,
input[type='number'].input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Selects: replace the native chrome with a themed chevron. */
select.input {
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b98a5' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.65rem center;
padding-right: 2rem;
}
select.input option {
@apply bg-graphite-850 text-zinc-200;
}
.data-table-scroll {
@apply overflow-x-auto;
}
.data-table {
@apply w-full min-w-fit text-sm;
}
.data-table th,
.data-table td {
@apply whitespace-nowrap pr-4 last:pr-0;
}
.data-table thead tr {
@apply border-b border-graphite-700/60 text-left text-[11px] uppercase tracking-wider text-slate-dim;
}
.data-table th {
@apply pb-2 font-medium;
}
.data-table tbody tr {
@apply border-b border-graphite-800/80 last:border-0 hover:bg-graphite-850/50;
}
.data-table td {
@apply py-2.5;
.animate-fade-in {
animation: rp-fade-in 120ms ease-out;
}
}
+84
View File
@@ -0,0 +1,84 @@
import { useState } from 'react';
import type { CurrentUser } from '@reforger-panel/shared';
import { useConfiguration, usePrimaryServer } from '../api/hooks.js';
import { formatRelativeTime } from '../lib/format.js';
import { Card, EmptyState, PageHeader, SegmentedControl, Spinner } from '../components/ui.js';
import { ConfigKeyEditor } from '../components/config/key-editor.js';
import { ConfigRawEditor } from '../components/config/raw-editor.js';
import { PerformanceForm } from '../components/performance-form.js';
import { StartupVarsCard } from '../components/startup-vars-card.js';
import { SchedulesCard } from '../components/schedules-card.js';
import { ConfigSummaryRows } from '../components/widgets.js';
type Tab = 'settings' | 'keys' | 'raw' | 'startup' | 'schedules';
export function ConfigurationPage({ user }: { user: CurrentUser }) {
const server = usePrimaryServer();
if (!server) return <Spinner />;
return <ConfigurationBody slug={server.slug} user={user} />;
}
function ConfigurationBody({ slug, user }: { slug: string; user: CurrentUser }) {
const canEdit = user.capabilities.includes('config.edit');
const [tab, setTab] = useState<Tab>('settings');
const { data: config } = useConfiguration(slug);
return (
<div className="w-full space-y-4">
<PageHeader
title="Configuration"
kicker={
<>
Edits are written straight to the server&rsquo;s config.json, verified by reading it
back, and rejected if the file changed since this page loaded.
{config && ` Read ${formatRelativeTime(config.fetchedAt)}.`}
</>
}
/>
<SegmentedControl<Tab>
value={tab}
onChange={setTab}
options={[
{ value: 'settings', label: 'Settings', icon: 'sliders' },
{ value: 'keys', label: 'All keys', icon: 'search' },
{ value: 'raw', label: 'Raw JSON', icon: 'terminal' },
{ value: 'startup', label: 'Startup variables', icon: 'server' },
{ value: 'schedules', label: 'Restarts', icon: 'restart' },
]}
/>
{tab === 'settings' && (
<div className="grid gap-4 lg:grid-cols-3">
<Card title="Performance settings" className="lg:col-span-2">
<PerformanceForm slug={slug} canEdit={canEdit} />
</Card>
<Card title="Live summary">
{config ? <ConfigSummaryRows config={config} /> : <Spinner />}
</Card>
</div>
)}
{tab === 'keys' && (
<Card title="Every key in config.json">
<ConfigKeyEditor slug={slug} canEdit={canEdit} />
</Card>
)}
{tab === 'raw' && (
<Card title="config.json">
<ConfigRawEditor slug={slug} canEdit={canEdit} />
</Card>
)}
{tab === 'startup' &&
(canEdit ? (
<StartupVarsCard slug={slug} />
) : (
<EmptyState icon="lock" title="Startup variables are restricted to admins" />
))}
{tab === 'schedules' && <SchedulesCard slug={slug} canEdit={canEdit} />}
</div>
);
}
+226
View File
@@ -0,0 +1,226 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ConsoleLine } from '@reforger-panel/shared';
import { useConsoleFeed, usePrimaryServer, useRawLogs } from '../api/hooks.js';
import { formatBytes, formatRelativeTime } from '../lib/format.js';
import {
Badge,
Button,
Card,
EmptyState,
IconButton,
PageHeader,
SearchInput,
SegmentedControl,
Spinner,
StatusBadge,
Toggle,
useToast,
} from '../components/ui.js';
type Source = 'live' | 'file';
/** Colour by severity, inferred from the line itself — Wings sends no level. */
function lineTone(line: ConsoleLine): string {
if (line.stream === 'install') return 'text-info-400';
if (line.stream === 'daemon') return 'text-accent-400';
const text = line.text;
if (/\b(ERROR|FATAL|Failed|failure|exception)\b/i.test(text)) return 'text-danger-400';
if (/\bWARN(ING)?\b/i.test(text)) return 'text-warn-400';
if (/\b(Success|ready to accept|successfully)\b/i.test(text)) return 'text-ok-400';
return 'text-zinc-300';
}
function timestamp(at: number): string {
const date = new Date(at);
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
export function ConsolePage() {
const server = usePrimaryServer();
if (!server) return <Spinner />;
return <ConsoleBody slug={server.slug} />;
}
function ConsoleBody({ slug }: { slug: string }) {
const toast = useToast();
const [source, setSource] = useState<Source>('live');
const [follow, setFollow] = useState(true);
const [filter, setFilter] = useState('');
const [fileLines, setFileLines] = useState(300);
const viewportRef = useRef<HTMLDivElement | null>(null);
const feed = useConsoleFeed(slug, source === 'live');
const file = useRawLogs(slug, fileLines, source === 'file');
const visible = useMemo(() => {
if (source === 'file') {
const lines = file.data?.lines ?? [];
return lines
.filter((text) => !filter || text.toLowerCase().includes(filter.toLowerCase()))
.map((text, index): ConsoleLine => ({ seq: index, stream: 'console', text, at: 0 }));
}
if (!filter) return feed.lines;
const needle = filter.toLowerCase();
return feed.lines.filter((line) => line.text.toLowerCase().includes(needle));
}, [source, feed.lines, file.data?.lines, filter]);
useEffect(() => {
if (!follow || !viewportRef.current) return;
viewportRef.current.scrollTop = viewportRef.current.scrollHeight;
}, [visible, follow]);
const copyAll = async () => {
try {
await navigator.clipboard.writeText(visible.map((line) => line.text).join('\n'));
toast(`Copied ${visible.length} lines`, 'ok');
} catch {
toast('Clipboard is not available in this browser', 'danger');
}
};
return (
<div className="w-full space-y-4">
<PageHeader
title="Console"
kicker={
source === 'live'
? 'Streamed live from Pterodactyl — installs, updates and mod downloads included, not just what the game writes to its own log.'
: "The game's own console.log, downloaded from the server."
}
actions={
<SegmentedControl<Source>
value={source}
onChange={setSource}
options={[
{ value: 'live', label: 'Live', icon: 'terminal' },
{ value: 'file', label: 'Game log', icon: 'download' },
]}
/>
}
/>
<Card
padded={false}
title={source === 'live' ? 'Pterodactyl live output' : (file.data?.path ?? 'console.log')}
action={
<div className="flex flex-wrap items-center justify-end gap-2">
{source === 'live' ? (
<>
<StatusBadge status={feed.status} />
<Badge tone={feed.connected ? 'ok' : 'warn'}>
{feed.connected ? 'connected' : 'reconnecting'}
</Badge>
<span className="numeric text-2xs text-slate-dim">{feed.lines.length} lines</span>
</>
) : (
<>
{file.data && (
<span className="text-2xs text-slate-dim">
fetched {formatRelativeTime(file.data.fetchedAt)}
</span>
)}
<select
value={fileLines}
onChange={(event) => setFileLines(Number(event.target.value))}
className="input w-auto py-1 text-xs"
>
{[100, 300, 600, 1000].map((n) => (
<option key={n} value={n}>
last {n} lines
</option>
))}
</select>
<IconButton icon="refresh" label="Reload" onClick={() => void file.refetch()} />
</>
)}
<IconButton icon="copy" label="Copy visible lines" onClick={() => void copyAll()} />
</div>
}
>
<div className="flex flex-wrap items-center gap-3 border-b border-graphite-700 px-4 py-2.5">
<SearchInput
value={filter}
onChange={setFilter}
placeholder="Filter lines…"
className="w-full sm:w-72"
/>
<label className="flex items-center gap-2 text-xs text-slate-ink">
<Toggle checked={follow} onChange={setFollow} label="Follow output" />
Follow
</label>
{source === 'live' && feed.lines.length > 0 && (
<Button size="sm" variant="ghost" icon="trash" onClick={feed.clear}>
Clear view
</Button>
)}
{filter && (
<span className="numeric text-2xs text-slate-dim">{visible.length} matching</span>
)}
</div>
<div
ref={viewportRef}
onWheel={() => setFollow(false)}
className="console-surface h-[calc(100vh-22rem)] min-h-80 overflow-auto rounded-none border-0"
>
{source === 'file' && file.isLoading ? (
<Spinner label="Downloading console.log…" />
) : visible.length === 0 ? (
<div className="p-6">
<EmptyState
icon="terminal"
title={filter ? 'No lines match that filter' : 'Waiting for output'}
hint={
filter
? undefined
: source === 'live'
? 'Output appears the moment the server does anything — press Start and watch the install and mod download run.'
: 'The game writes this file once it has started.'
}
/>
</div>
) : (
<ol>
{visible.map((line) => (
<li
key={`${line.seq}-${line.at}`}
className="flex items-baseline gap-3 px-3 py-px hover:bg-graphite-900/60"
>
{line.at > 0 && (
<span className="numeric shrink-0 select-none text-slate-faint">
{timestamp(line.at)}
</span>
)}
{line.stream !== 'console' && (
<span className="shrink-0 select-none text-2xs uppercase text-slate-faint">
{line.stream}
</span>
)}
<span
className={`min-w-0 flex-1 whitespace-pre-wrap break-all ${lineTone(line)}`}
>
{line.text}
</span>
</li>
))}
</ol>
)}
</div>
{source === 'live' && feed.stats && (
<div className="flex flex-wrap items-center gap-x-6 gap-y-1 border-t border-graphite-700 px-4 py-2 text-2xs text-slate-dim">
<span className="numeric">CPU {feed.stats.cpuPercent.toFixed(1)}%</span>
<span className="numeric">MEM {formatBytes(feed.stats.memoryBytes)}</span>
<span className="numeric">DISK {formatBytes(feed.stats.diskBytes)}</span>
<span className="numeric">
NET {formatBytes(feed.stats.networkRxBytes)} in /{' '}
{formatBytes(feed.stats.networkTxBytes)} out
</span>
<span>source: {feed.stats.source}</span>
</div>
)}
</Card>
</div>
);
}
-127
View File
@@ -1,127 +0,0 @@
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 [follow, setFollow] = useState(true);
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;
}
}, [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={title}
action={
<div className="flex flex-wrap items-center justify-end gap-2">
{mode === 'poll' && pollData && (
<span className="text-xs text-slate-dim">
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))}
className="input py-1.5"
>
{[100, 300, 600, 1000].map((n) => (
<option key={n} value={n}>
last {n} lines
</option>
))}
</select>
)}
<Button
variant={mode === 'stream' ? 'accent' : 'default'}
onClick={() => {
setStreamLines([]);
setMode((m) => (m === 'stream' ? 'poll' : 'stream'));
}}
title="Toggle between live SSE stream and 10s polling"
>
{mode === 'stream' ? 'Live' : 'Polling'}
</Button>
<Button
variant={follow ? 'accent' : 'default'}
onClick={() => setFollow((v) => !v)}
title="Keep scrolled to the newest lines"
>
{follow ? 'Follow' : 'Free scroll'}
</Button>
{mode === 'poll' && (
<Button disabled={isFetching} onClick={() => void refetch()}>
{isFetching ? '…' : 'Refresh'}
</Button>
)}
</div>
}
>
{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…" />
) : 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">
{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>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { CurrentUser } from '@reforger-panel/shared';
import { usePrimaryServer } from '../api/hooks.js';
import { PageHeader, Spinner } from '../components/ui.js';
import { MissionCard } from '../components/mission-card.js';
export function MissionPage({ user }: { user: CurrentUser }) {
const server = usePrimaryServer();
if (!server) return <Spinner />;
return (
<div className="w-full space-y-4">
<PageHeader
title="Mission"
kicker="Vanilla scenarios plus everything the installed mods ship. Switching writes game.scenarioId and takes effect on the next restart."
/>
<MissionCard slug={server.slug} canEdit={user.capabilities.includes('config.edit')} />
</div>
);
}
+310 -1510
View File
File diff suppressed because it is too large. Load diff
+103 -107
View File
@@ -2,12 +2,13 @@ import { Link } from 'react-router-dom';
import type { CurrentUser, ResourceSample } from '@reforger-panel/shared';
import {
useConfiguration,
useModsOverview,
usePrimaryServer,
useResourceHistory,
useServerResources,
useServers,
} from '../api/hooks.js';
import { formatBytes, formatDuration } from '../lib/format.js';
import { Card, Spinner } from '../components/ui.js';
import { Badge, Card, EmptyState, MetricTile, ProgressBar, Spinner } from '../components/ui.js';
import { TimeSeriesChart } from '../components/charts.js';
import {
ConfigSummaryRows,
@@ -17,18 +18,18 @@ import {
} from '../components/widgets.js';
export function OverviewPage({ user }: { user: CurrentUser }) {
const { data: serversData, isLoading } = useServers();
const server = serversData?.servers[0];
const server = usePrimaryServer();
const { isLoading } = useConfiguration(server?.slug ?? '');
if (isLoading) return <Spinner label="Loading dashboard…" />;
if (!server) {
return (
<Card title="No servers">
<p className="text-sm text-slate-ink">
No servers found. Run <code className="font-mono text-accent-400">npm run db:seed</code>{' '}
to create the training server.
</p>
</Card>
return isLoading ? (
<Spinner label="Loading dashboard…" />
) : (
<EmptyState
icon="server"
title="No servers configured"
hint="Run npm run db:seed to create the initial server record."
/>
);
}
return <Dashboard user={user} slug={server.slug} />;
@@ -42,168 +43,163 @@ function seriesOf(
}
function Dashboard({ user, slug }: { user: CurrentUser; slug: string }) {
const { data: serversData } = useServers();
const server = serversData?.servers.find((s) => s.slug === slug);
const server = usePrimaryServer();
const { data: resources } = useServerResources(slug);
const { data: config } = useConfiguration(slug);
const { data: history } = useResourceHistory(slug);
const { data: mods } = useModsOverview(slug);
if (!server) return null;
const installedMods = config?.config.mods ?? [];
const samples = history?.samples;
const memoryLimit = resources?.memoryLimitBytes ?? samples?.at(-1)?.memoryLimitBytes ?? null;
const cpuLimit = resources?.cpuLimitPercent ?? samples?.at(-1)?.cpuLimitPercent ?? 100;
const latest = samples?.at(-1);
const memoryLimit = resources?.memoryLimitBytes ?? latest?.memoryLimitBytes ?? null;
const cpuLimit = resources?.cpuLimitPercent ?? latest?.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 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)}%` : '—'}
<span className="text-sm font-normal text-slate-dim">
{cpuLimit && cpuLimit !== 100 ? ` / ${cpuLimit}%` : ''}
</span>
</p>
<div className="w-full space-y-4">
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<MetricTile
label="CPU"
value={resources ? `${resources.cpuPercent.toFixed(1)}%` : '—'}
unit={cpuLimit && cpuLimit !== 100 ? `of ${cpuLimit}%` : undefined}
detail={
resources && (
<Badge tone={resources.source === 'live' ? 'ok' : 'neutral'}>
{resources.source === 'live' ? 'live' : 'polled'}
</Badge>
)
}
>
<TimeSeriesChart
className="mt-2"
max={cpuLimit}
format={(value) => `${value.toFixed(0)}%`}
series={[
{
points: seriesOf(samples, (s) => s.cpuPercent),
color: 'var(--color-accent-400)',
},
{ points: seriesOf(samples, (s) => s.cpuPercent), color: 'var(--color-accent-400)' },
]}
/>
</Card>
</MetricTile>
<Card title="Memory">
<p className="text-2xl font-semibold text-zinc-100">
{resources ? formatBytes(resources.memoryBytes) : '—'}
<span className="text-sm font-normal text-slate-dim">
{memoryLimit ? ` / ${formatBytes(memoryLimit)}` : ''}
</span>
</p>
<MetricTile
label="Memory"
value={resources ? formatBytes(resources.memoryBytes) : '—'}
unit={memoryLimit ? `of ${formatBytes(memoryLimit)}` : undefined}
>
<TimeSeriesChart
className="mt-2"
max={memoryLimit}
format={formatBytes}
series={[
{
points: seriesOf(samples, (s) => s.memoryBytes),
color: '#7dd3fc',
},
{ points: seriesOf(samples, (s) => s.memoryBytes), color: 'var(--color-info-400)' },
]}
/>
</Card>
</MetricTile>
<Card title="Network">
<p className="text-sm text-zinc-300">
<span className="text-accent-400">
↓ {formatBytes(samples?.at(-1)?.networkRxRate ?? 0)}/s
</span>
<span className="ml-3 text-warn-400">
↑ {formatBytes(samples?.at(-1)?.networkTxRate ?? 0)}/s
</span>
<span className="ml-3 text-slate-dim">
up{' '}
<MetricTile
label="Network"
value={`${formatBytes(latest?.networkRxRate ?? 0)}/s`}
unit="in"
detail={
<>
{formatBytes(latest?.networkTxRate ?? 0)}/s out · up{' '}
{resources && resources.uptimeMs > 0
? formatDuration(resources.uptimeMs / 1000)
: '—'}
</span>
</p>
</>
}
>
<TimeSeriesChart
className="mt-2"
format={(value) => `${formatBytes(value)}/s`}
series={[
{
points: seriesOf(samples, (s) => s.networkRxRate),
color: 'var(--color-accent-400)',
label: 'rx',
label: 'in',
},
{
points: seriesOf(samples, (s) => s.networkTxRate),
color: 'var(--color-warn-400)',
fill: false,
label: 'tx',
label: 'out',
},
]}
/>
</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>
</MetricTile>
<MetricTile
label="Storage"
value={diskUsed !== null ? formatBytes(diskUsed) : '—'}
unit={diskLimit ? `of ${formatBytes(diskLimit)}` : undefined}
detail={
diskUsed !== null && diskLimit
? `${((diskUsed / diskLimit) * 100).toFixed(1)}% used`
: undefined
}
>
<ProgressBar value={diskUsed ?? 0} max={diskLimit} className="mt-1" />
</MetricTile>
</div>
<div className="grid gap-5 lg:grid-cols-3">
<div className="min-w-0 space-y-5 lg:col-span-2">
<div className="grid gap-4 lg:grid-cols-3">
<div className="min-w-0 space-y-4 lg:col-span-2">
<CurrentPlayersCard slug={slug} maxPlayers={server.maxPlayers} />
<RecentActivityCard slug={slug} />
</div>
<div className="min-w-0 space-y-5">
<div className="min-w-0 space-y-4">
<Card
title="Current configuration"
title="Configuration"
action={
<Link to="/configuration" className="text-xs text-accent-400 hover:underline">
View configuration
<Link to="/configuration" className="text-2xs text-accent-400 hover:underline">
Edit
</Link>
}
>
{config ? <ConfigSummaryRows config={config} /> : <Spinner />}
</Card>
<Card
title="Installed mods"
title="Mods"
action={
<Link to="/mods" className="text-xs text-accent-400 hover:underline">
<Link to="/mods" className="text-2xs text-accent-400 hover:underline">
Manage
</Link>
}
>
{installedMods.length === 0 ? (
{!mods ? (
<Spinner />
) : mods.mods.length === 0 ? (
<p className="text-sm text-slate-dim">The server runs vanilla (no mods).</p>
) : (
<div>
<p className="text-sm text-zinc-200">
{installedMods.length} mod{installedMods.length === 1 ? '' : 's'} in config.json
<div className="space-y-2">
<p className="numeric text-sm text-zinc-100">
{mods.mods.length} installed
{mods.totalSizeBytes ? ` · ${formatBytes(mods.totalSizeBytes)}` : ''}
</p>
<ul className="mt-2 space-y-1">
{installedMods.slice(0, 5).map((mod) => (
<div className="flex flex-wrap gap-1.5">
{mods.updatesAvailable > 0 && (
<Badge tone="warn">{mods.updatesAvailable} updates</Badge>
)}
{mods.unresolvedIds.length > 0 && (
<Badge tone="neutral">{mods.unresolvedIds.length} unidentified</Badge>
)}
{mods.orphanedMission && <Badge tone="danger">mission missing</Badge>}
{mods.warming && <Badge>loading metadata…</Badge>}
</div>
<ul className="space-y-0.5">
{mods.mods.slice(0, 5).map((mod) => (
<li key={mod.modId} className="truncate text-xs text-slate-ink">
{mod.name ?? mod.modId}
{mod.workshop?.name ?? mod.configName ?? mod.modId}
</li>
))}
{installedMods.length > 5 && (
<li className="text-xs text-slate-dim">+ {installedMods.length - 5} more</li>
{mods.mods.length > 5 && (
<li className="text-xs text-slate-faint">+ {mods.mods.length - 5} more</li>
)}
</ul>
</div>
)}
</Card>
<OpsHealthCard user={user} slug={slug} />
</div>
</div>
+126 -147
View File
@@ -3,69 +3,48 @@ import type { CurrentUser, Role } from '@reforger-panel/shared';
import { ROLES, ROLE_LABELS } from '@reforger-panel/shared';
import {
useActivity,
useConfiguration,
useKnownPlayers,
useKillfeed,
useKnownPlayers,
useLogHealth,
usePlayers,
useServers,
usePrimaryServer,
useSetUserRole,
useUsers,
useWorkshopHealth,
} from '../api/hooks.js';
import { formatDateTime, formatDuration, formatRelativeTime } from '../lib/format.js';
import { Card, EmptyState, RoleBadge, Spinner } from '../components/ui.js';
import { ActivityList, ConfigSummaryRows, CurrentPlayersCard } from '../components/widgets.js';
import {
Badge,
Card,
EmptyState,
PageHeader,
RoleBadge,
SearchInput,
Spinner,
} from '../components/ui.js';
import { ActivityList, CurrentPlayersCard } from '../components/widgets.js';
import { InvitesCard } from '../components/invites-card.js';
import { MissionCard } from '../components/mission-card.js';
import { PerformanceForm } from '../components/performance-form.js';
import { SchedulesCard } from '../components/schedules-card.js';
import { StartupVarsCard } from '../components/startup-vars-card.js';
function usePrimarySlug(): string | null {
const { data } = useServers();
return data?.servers[0]?.slug ?? null;
}
export function ConfigurationsPage({ user }: { user: CurrentUser }) {
const slug = usePrimarySlug();
if (!slug) return <Spinner />;
return <ConfigurationsBody slug={slug} user={user} />;
}
function ConfigurationsBody({ slug, user }: { slug: string; user: CurrentUser }) {
const { data: config } = useConfiguration(slug);
const canEdit = user.capabilities.includes('config.edit');
return (
<div className="w-full space-y-5">
<h1 className="page-title">Configuration</h1>
<MissionCard slug={slug} canEdit={canEdit} />
<PerformanceForm slug={slug} canEdit={canEdit} />
{/*<SchedulesCard slug={slug} canEdit={canEdit} />*/}
{canEdit && <StartupVarsCard slug={slug} />}
<Card title="Full config summary (live from the server)">
{config ? <ConfigSummaryRows config={config} /> : <Spinner />}
</Card>
</div>
);
}
/* ---------------------------------------------------------------- players */
export function PlayersPage() {
const slug = usePrimarySlug();
if (!slug) return <Spinner />;
return <PlayersBody slug={slug} />;
const server = usePrimaryServer();
if (!server) return <Spinner />;
return <PlayersBody slug={server.slug} />;
}
type PlayerSort = 'online' | 'last_seen' | 'playtime' | 'sessions' | 'name';
function PlayersBody({ slug }: { slug: string }) {
const { data: online } = usePlayers(slug);
const { data: known } = useKnownPlayers(slug);
const [sort, setSort] = useState<'online' | 'last_seen' | 'playtime' | 'sessions' | 'name'>(
'online',
);
const [sort, setSort] = useState<PlayerSort>('online');
const [query, setQuery] = useState('');
const sortedPlayers = useMemo(() => {
const players = [...(known?.players ?? [])];
players.sort((a, b) => {
const players = (known?.players ?? []).filter((player) =>
query ? player.displayName.toLowerCase().includes(query.toLowerCase()) : true,
);
return [...players].sort((a, b) => {
if (sort === 'online') {
if (a.online !== b.online) return a.online ? -1 : 1;
return b.lastSeenAt.localeCompare(a.lastSeenAt);
@@ -75,35 +54,43 @@ function PlayersBody({ slug }: { slug: string }) {
if (sort === 'sessions') return b.totalSessions - a.totalSessions;
return a.displayName.localeCompare(b.displayName);
});
return players;
}, [known?.players, sort]);
}, [known?.players, sort, query]);
return (
<div className="w-full space-y-5">
<h1 className="page-title">Players</h1>
<div className="w-full space-y-4">
<PageHeader title="Players" />
<CurrentPlayersCard slug={slug} maxPlayers={online?.maxPlayers ?? null} />
<Card
title="All known players"
action={
<select
value={sort}
onChange={(event) => setSort(event.target.value as typeof sort)}
className="input py-1.5 text-xs"
>
<option value="online">Online first</option>
<option value="last_seen">Last seen</option>
<option value="playtime">Playtime</option>
<option value="sessions">Sessions</option>
<option value="name">Name</option>
</select>
<div className="flex items-center gap-2">
<SearchInput
value={query}
onChange={setQuery}
placeholder="Find a player…"
className="w-44"
/>
<select
value={sort}
onChange={(event) => setSort(event.target.value as PlayerSort)}
className="input w-auto py-1 text-xs"
>
<option value="online">Online first</option>
<option value="last_seen">Last seen</option>
<option value="playtime">Playtime</option>
<option value="sessions">Sessions</option>
<option value="name">Name</option>
</select>
</div>
}
>
{!known ? (
<Spinner />
) : known.players.length === 0 ? (
) : sortedPlayers.length === 0 ? (
<EmptyState
title="No players recorded yet"
hint="Players are discovered from server log connect events."
icon="users"
title={query ? 'No players match that name' : 'No players recorded yet'}
hint={query ? undefined : 'Players are discovered from server log connect events.'}
/>
) : (
<div className="data-table-scroll">
@@ -120,24 +107,24 @@ function PlayersBody({ slug }: { slug: string }) {
<tbody>
{sortedPlayers.map((player) => (
<tr key={player.id}>
<td className="py-2 font-medium text-zinc-200">
{player.displayName}
{player.online && (
<span className="ml-2 rounded bg-accent-600/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-accent-400">
online
</span>
)}
<td className="font-medium text-zinc-100">
<span className="flex items-center gap-2">
{player.displayName}
{player.online && <Badge tone="ok">online</Badge>}
</span>
</td>
<td className="py-2 font-mono text-xs text-slate-dim">
<td className="font-mono text-2xs text-slate-faint">
{player.externalPlayerId ? (
player.externalPlayerId.slice(0, 12) + '…'
`${player.externalPlayerId.slice(0, 12)}…`
) : (
<span title="No stable ID in logs; matched by display name">name only</span>
)}
</td>
<td className="py-2 text-slate-ink">{formatRelativeTime(player.lastSeenAt)}</td>
<td className="py-2 text-right font-mono text-xs">{player.totalSessions}</td>
<td className="py-2 text-right font-mono text-xs">
<td className="numeric text-slate-ink">
{formatRelativeTime(player.lastSeenAt)}
</td>
<td className="numeric text-right text-xs">{player.totalSessions}</td>
<td className="numeric text-right text-xs">
{formatDuration(player.totalPlaytimeSeconds)}
</td>
</tr>
@@ -151,24 +138,20 @@ function PlayersBody({ slug }: { slug: string }) {
);
}
export function ActivityPage() {
const slug = usePrimarySlug();
if (!slug) return <Spinner />;
return <ActivityBody slug={slug} />;
}
/* --------------------------------------------------------------- killfeed */
export function KillfeedPage() {
const slug = usePrimarySlug();
if (!slug) return <Spinner />;
return <KillfeedBody slug={slug} />;
const server = usePrimaryServer();
if (!server) return <Spinner />;
return <KillfeedBody slug={server.slug} />;
}
function teamClass(team: string | null): string {
const normalized = team?.toLowerCase() ?? '';
if (normalized.includes('blue') || normalized.includes('blufor')) return 'bg-sky-500';
if (normalized.includes('opfor') || normalized.includes('red')) return 'bg-red-500';
if (normalized.includes('independent') || normalized.includes('green')) return 'bg-emerald-500';
return 'bg-slate-dim';
if (normalized.includes('blue') || normalized.includes('blufor')) return 'bg-info-400';
if (normalized.includes('opfor') || normalized.includes('red')) return 'bg-danger-400';
if (normalized.includes('independent') || normalized.includes('green')) return 'bg-ok-400';
return 'bg-slate-faint';
}
function positionLabel(position: { x: number; y: number; z?: number | null } | null): string {
@@ -180,42 +163,36 @@ function positionLabel(position: { x: number; y: number; z?: number | null } | n
function KillfeedBody({ slug }: { slug: string }) {
const { data, isLoading } = useKillfeed(slug, 150);
return (
<div className="w-full space-y-5">
<div>
<h1 className="page-title">Killfeed</h1>
<p className="page-kicker">
Parsed from ServerAdminTools kill events. Team, position, distance, and weapon show when
the log line provides them.
</p>
</div>
<div className="w-full space-y-4">
<PageHeader
title="Killfeed"
kicker="Parsed from ServerAdminTools kill events. Team, position, distance, and weapon show when the log line provides them."
/>
<Card title="Recent kills">
{isLoading || !data ? (
<Spinner />
) : data.events.length === 0 ? (
<EmptyState
icon="crosshair"
title="No kills recorded yet"
hint="Killfeed requires ServerAdminTools kill event lines in the server log."
/>
) : (
<ul className="space-y-2">
<ul className="space-y-1.5">
{data.events.map((event) => (
<li
key={event.id}
className="rounded-md border border-graphite-800 bg-graphite-950/20 px-3.5 py-3"
className="rounded-sm border border-graphite-800 bg-graphite-950/40 px-3 py-2"
>
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className={`h-2.5 w-2.5 rounded-full ${teamClass(event.killerTeam)}`} />
<span className={`h-2 w-2 rounded-full ${teamClass(event.killerTeam)}`} />
<span className="font-medium text-zinc-100">{event.killerName}</span>
<span className="text-slate-dim">killed</span>
<span className={`h-2.5 w-2.5 rounded-full ${teamClass(event.victimTeam)}`} />
<span className={`h-2 w-2 rounded-full ${teamClass(event.victimTeam)}`} />
<span className="font-medium text-zinc-100">{event.victimName}</span>
{event.friendly && (
<span className="rounded border border-warn-400/30 bg-warn-400/10 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-warn-400">
friendly
</span>
)}
{event.friendly && <Badge tone="warn">friendly</Badge>}
</div>
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-dim">
<div className="numeric mt-1 flex flex-wrap gap-x-4 gap-y-1 text-2xs text-slate-dim">
<span>{formatDateTime(event.occurredAt)}</span>
<span>attacker {positionLabel(event.killerPosition)}</span>
<span>victim {positionLabel(event.victimPosition)}</span>
@@ -234,33 +211,41 @@ function KillfeedBody({ slug }: { slug: string }) {
);
}
/* --------------------------------------------------------------- activity */
export function ActivityPage() {
const server = usePrimaryServer();
if (!server) return <Spinner />;
return <ActivityBody slug={server.slug} />;
}
function ActivityBody({ slug }: { slug: string }) {
const { data } = useActivity(slug, 100);
return (
<div className="w-full space-y-5">
<h1 className="page-title">Activity</h1>
<Card>{data ? <ActivityList items={data.activity} maxHeight={560} /> : <Spinner />}</Card>
<div className="w-full space-y-4">
<PageHeader title="Activity" kicker="Panel actions and parsed server events, newest last." />
<Card padded={false} className="p-4">
{data ? <ActivityList items={data.activity} maxHeight={640} /> : <Spinner />}
</Card>
</div>
);
}
/* --------------------------------------------------------------- settings */
export function SettingsPage({ user }: { user: CurrentUser }) {
const isOwner = user.role === 'owner';
const slug = usePrimarySlug();
const server = usePrimaryServer();
const { data: users } = useUsers(isOwner);
const { data: workshop } = useWorkshopHealth();
const { data: logs } = useLogHealth(slug ?? '', isOwner && slug !== null);
const { data: logs } = useLogHealth(server?.slug ?? '', isOwner && server !== undefined);
const setRole = useSetUserRole();
return (
<div className="w-full space-y-5">
<div>
<h1 className="page-title">Settings</h1>
<p className="page-kicker">
Manage private Discord access, server integrations, and the checks that matter before
exposing the panel to friends.
</p>
</div>
<div className="w-full space-y-4">
<PageHeader
title="Settings"
kicker="Manage private Discord access and review the panel's integrations."
/>
<Card title="Your account">
<div className="flex items-center gap-3">
@@ -276,7 +261,7 @@ export function SettingsPage({ user }: { user: CurrentUser }) {
</span>
)}
<div>
<p className="text-sm font-medium text-zinc-200">
<p className="text-sm font-medium text-zinc-100">
{user.displayName ?? user.username}{' '}
<span className="text-slate-dim">({user.username})</span>
</p>
@@ -290,23 +275,23 @@ export function SettingsPage({ user }: { user: CurrentUser }) {
{!users ? (
<Spinner />
) : (
<ul className="space-y-2">
<ul className="space-y-1.5">
{users.users.map((panelUser) => (
<li
key={panelUser.id}
className="flex items-center justify-between rounded-md border border-graphite-800 bg-graphite-950/20 px-3 py-2.5"
className="flex items-center justify-between gap-3 rounded-sm border border-graphite-800 bg-graphite-950/40 px-3 py-2"
>
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2.5">
{panelUser.avatarUrl ? (
<img src={panelUser.avatarUrl} alt="" className="h-7 w-7 rounded-full" />
) : (
<span className="h-7 w-7 rounded-full bg-graphite-700" />
)}
<div>
<p className="text-sm text-zinc-200">
<div className="min-w-0">
<p className="truncate text-sm text-zinc-100">
{panelUser.displayName ?? panelUser.username}
</p>
<p className="text-xs text-slate-dim">
<p className="text-2xs text-slate-dim">
joined {formatDateTime(panelUser.createdAt)}
</p>
</div>
@@ -319,7 +304,7 @@ export function SettingsPage({ user }: { user: CurrentUser }) {
onChange={(event) =>
setRole.mutate({ userId: panelUser.id, role: event.target.value as Role })
}
className="input px-2 py-1 text-xs"
className="input w-auto py-1 text-xs"
>
{ROLES.map((role) => (
<option key={role} value={role}>
@@ -340,30 +325,24 @@ export function SettingsPage({ user }: { user: CurrentUser }) {
{isOwner && (
<Card title="Integrations">
<dl className="space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-slate-ink">Workshop API</dt>
<dd className={workshop?.ok ? 'text-accent-400' : 'text-danger-400'}>
{workshop
? workshop.ok
? `healthy (${workshop.latencyMs} ms)`
: 'unreachable'
: '—'}
</dd>
</div>
<div className="flex justify-between">
<div className="flex items-center justify-between gap-4">
<dt className="text-slate-ink">Pterodactyl</dt>
<dd className="text-zinc-300">
{logs?.configured ? 'configured' : 'mock / not configured'}
<dd>
{logs?.configured ? (
<Badge tone="ok">configured</Badge>
) : (
<Badge>mock / not configured</Badge>
)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-slate-ink">Log path</dt>
<dd className="font-mono text-xs text-zinc-300">{logs?.logPath ?? '—'}</dd>
<div className="flex items-center justify-between gap-4">
<dt className="shrink-0 text-slate-ink">Game log path</dt>
<dd className="truncate font-mono text-2xs text-slate-dim">{logs?.logPath ?? '—'}</dd>
</div>
</dl>
<p className="mt-3 text-xs text-slate-dim">
Connection settings are managed through environment variables. Use real Pterodactyl
client API credentials for production and keep mock mode off.
<p className="mt-3 text-2xs leading-5 text-slate-dim">
Connection settings are managed through environment variables. Workshop metadata is
fetched on demand and cached in the API process — there is no background polling.
</p>
</Card>
)}