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>
)}