initial commit
This commit is contained in:
106 files changed
+24584
No files matched your search
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Reforger Panel</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@reforger-panel/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reforger-panel/shared": "*",
|
||||
"@tanstack/react-query": "^5.80.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@types/react": "^19.1.0",
|
||||
"@types/react-dom": "^19.1.0",
|
||||
"@vitejs/plugin-react": "^4.5.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vite": "^6.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect } from 'react';
|
||||
import { QueryClient, QueryClientProvider, useQueryClient } from '@tanstack/react-query';
|
||||
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 { 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';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
/** Redeems a stored invite code once, right after login, then refreshes /me. */
|
||||
function InviteRedeemer() {
|
||||
const client = useQueryClient();
|
||||
useEffect(() => {
|
||||
const code = localStorage.getItem('rp_invite');
|
||||
if (!code) return;
|
||||
localStorage.removeItem('rp_invite');
|
||||
void api
|
||||
.post('/api/invites/redeem', { code })
|
||||
.then(() => client.invalidateQueries({ queryKey: ['auth', 'me'] }))
|
||||
.catch(() => undefined); // invalid/expired codes fail quietly
|
||||
}, [client]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function AuthGate() {
|
||||
const { data: user, isLoading, error } = useCurrentUser();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Spinner label="Checking session…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error instanceof ApiClientError && error.status === 401) {
|
||||
return <LoginPage />;
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<InviteRedeemer />
|
||||
<Routes>
|
||||
<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="/players" element={<PlayersPage />} />
|
||||
<Route path="/killfeed" element={<KillfeedPage />} />
|
||||
<Route path="/activity" element={<ActivityPage />} />
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage user={user} />} />
|
||||
{/* Old bookmarks from the tabbed server page and plural path. */}
|
||||
<Route path="/server/:slug" element={<Navigate to="/" replace />} />
|
||||
<Route path="/configurations" element={<Navigate to="/configuration" replace />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<AuthGate />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ApiErrorBody } from '@reforger-panel/shared';
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
readonly code: string;
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, code: string, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const method = init.method ?? 'GET';
|
||||
const headers: Record<string, string> = { ...(init.headers as Record<string, string>) };
|
||||
if (method !== 'GET' && method !== 'HEAD') {
|
||||
headers['X-CSRF-Protection'] = '1';
|
||||
if (init.body) headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
const response = await fetch(path, { ...init, method, headers, credentials: 'same-origin' });
|
||||
if (!response.ok) {
|
||||
let code = 'INTERNAL_ERROR';
|
||||
let message = `Request failed (${response.status})`;
|
||||
try {
|
||||
const body = (await response.json()) as ApiErrorBody;
|
||||
code = body.error.code;
|
||||
message = body.error.message;
|
||||
} catch {
|
||||
// non-JSON error body
|
||||
}
|
||||
throw new ApiClientError(response.status, code, message);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'POST',
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}),
|
||||
put: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'PUT',
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}),
|
||||
patch: <T>(path: string, body?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'PATCH',
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
};
|
||||
@@ -0,0 +1,371 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type {
|
||||
ActivityItem,
|
||||
ConfigurationResponse,
|
||||
CurrentUser,
|
||||
InviteSummary,
|
||||
KillfeedEvent,
|
||||
MissionsResponse,
|
||||
PerformanceSettingsPatch,
|
||||
PerformanceSettingsResponse,
|
||||
RawLogsResponse,
|
||||
RestartScheduleInput,
|
||||
ResourceHistoryResponse,
|
||||
StartupResponse,
|
||||
KnownPlayer,
|
||||
LogIngestionHealth,
|
||||
LogSyncResult,
|
||||
ModPackSummary,
|
||||
PanelUser,
|
||||
PlayersResponse,
|
||||
ReforgerConfigMod,
|
||||
ServerModsResponse,
|
||||
UpdateModsResult,
|
||||
ServerResources,
|
||||
ServerScheduleSummary,
|
||||
ServerSummary,
|
||||
WorkshopHealth,
|
||||
WorkshopModDetail,
|
||||
WorkshopSearchResponse,
|
||||
} from '@reforger-panel/shared';
|
||||
import { api, ApiClientError } from './client.js';
|
||||
|
||||
export function useCurrentUser() {
|
||||
return useQuery({
|
||||
queryKey: ['auth', 'me'],
|
||||
queryFn: () => api.get<CurrentUser>('/api/auth/me'),
|
||||
retry: (failureCount, error) =>
|
||||
!(error instanceof ApiClientError && error.status === 401) && failureCount < 2,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => api.post('/api/auth/logout'),
|
||||
onSuccess: () => queryClient.clear(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useServers() {
|
||||
return useQuery({
|
||||
queryKey: ['servers'],
|
||||
queryFn: () => api.get<{ servers: ServerSummary[] }>('/api/servers'),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useServer(slug: string) {
|
||||
return useQuery({
|
||||
queryKey: ['servers', slug],
|
||||
queryFn: () => api.get<ServerSummary>(`/api/servers/${slug}`),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useServerResources(slug: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['servers', slug, 'resources'],
|
||||
queryFn: () => api.get<ServerResources>(`/api/servers/${slug}/resources`),
|
||||
refetchInterval: 10_000,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlayers(slug: string) {
|
||||
return useQuery({
|
||||
queryKey: ['servers', slug, 'players'],
|
||||
queryFn: () => api.get<PlayersResponse>(`/api/servers/${slug}/players`),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useKnownPlayers(slug: string) {
|
||||
return useQuery({
|
||||
queryKey: ['servers', slug, 'players', 'known'],
|
||||
queryFn: () => api.get<{ players: KnownPlayer[] }>(`/api/servers/${slug}/players/known`),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useActivity(slug: string, limit = 50) {
|
||||
return useQuery({
|
||||
queryKey: ['servers', slug, 'activity', limit],
|
||||
queryFn: () =>
|
||||
api.get<{ activity: ActivityItem[] }>(`/api/servers/${slug}/activity?limit=${limit}`),
|
||||
refetchInterval: 20_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useKillfeed(slug: string, limit = 100) {
|
||||
return useQuery({
|
||||
queryKey: ['servers', slug, 'killfeed', limit],
|
||||
queryFn: () =>
|
||||
api.get<{ events: KillfeedEvent[] }>(`/api/servers/${slug}/killfeed?limit=${limit}`),
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useConfiguration(slug: string) {
|
||||
return useQuery({
|
||||
queryKey: ['servers', slug, 'configuration'],
|
||||
queryFn: () => api.get<ConfigurationResponse>(`/api/servers/${slug}/configuration`),
|
||||
// Live download from the game server on each fetch — keep it calm.
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRawLogs(slug: string, lines: number, autoRefresh: boolean, 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,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useStartupVariables(slug: string, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ['servers', slug, 'startup'],
|
||||
queryFn: () => api.get<StartupResponse>(`/api/servers/${slug}/startup`),
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateStartupVariable(slug: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: { key: string; value: string }) =>
|
||||
api.put<{ ok: boolean; requiresRestart: boolean }>(
|
||||
`/api/servers/${slug}/startup/variable`,
|
||||
input,
|
||||
),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['servers', slug, 'startup'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useModPacks(slug: string) {
|
||||
return useQuery({
|
||||
queryKey: ['servers', slug, 'mod-packs'],
|
||||
queryFn: () => api.get<{ modPacks: ModPackSummary[] }>(`/api/servers/${slug}/mod-packs`),
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
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: () => {
|
||||
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: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['servers', slug] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useServerSchedules(slug: string, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ['servers', slug, 'schedules'],
|
||||
queryFn: () =>
|
||||
api.get<{ schedules: ServerScheduleSummary[]; fetchedAt: string }>(
|
||||
`/api/servers/${slug}/schedules`,
|
||||
),
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateRestartSchedule(slug: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: RestartScheduleInput) =>
|
||||
api.post<{ schedule: ServerScheduleSummary }>(
|
||||
`/api/servers/${slug}/schedules/restarts`,
|
||||
input,
|
||||
),
|
||||
onSuccess: () =>
|
||||
void queryClient.invalidateQueries({ queryKey: ['servers', slug, 'schedules'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRestartSchedule(slug: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: string; input: RestartScheduleInput }) =>
|
||||
api.put<{ schedule: ServerScheduleSummary }>(
|
||||
`/api/servers/${slug}/schedules/${id}/restart`,
|
||||
input,
|
||||
),
|
||||
onSuccess: () =>
|
||||
void queryClient.invalidateQueries({ queryKey: ['servers', slug, 'schedules'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSchedule(slug: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/api/servers/${slug}/schedules/${id}`),
|
||||
onSuccess: () =>
|
||||
void queryClient.invalidateQueries({ queryKey: ['servers', slug, 'schedules'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWorkshopHealth() {
|
||||
return useQuery({
|
||||
queryKey: ['workshop', 'health'],
|
||||
queryFn: () => api.get<WorkshopHealth>('/api/workshop/health'),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
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)}` : ''
|
||||
}`,
|
||||
),
|
||||
// An empty query browses the Workshop front page (/v1/mods).
|
||||
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: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUsers(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => api.get<{ users: PanelUser[] }>('/api/users'),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetUserRole() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, role }: { userId: string; role: string }) =>
|
||||
api.patch(`/api/users/${userId}/role`, { role }),
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['users'] }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export type ChartSeries = {
|
||||
points: { t: number; v: number }[];
|
||||
/** Any CSS color; used for the line and (when filled) the area. */
|
||||
color: string;
|
||||
fill?: boolean;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export function TimeSeriesChart({
|
||||
series,
|
||||
max,
|
||||
height = 64,
|
||||
className = '',
|
||||
}: {
|
||||
series: ChartSeries[];
|
||||
max?: number | null;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
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}`}
|
||||
>
|
||||
collecting data…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tMin = Math.min(...allPoints.map((p) => p.t));
|
||||
const tMax = Math.max(...allPoints.map((p) => p.t));
|
||||
const dataMax = Math.max(...allPoints.map((p) => p.v), 0);
|
||||
const scale = max && max > 0 ? max : dataMax > 0 ? dataMax * 1.15 : 1;
|
||||
const tSpan = Math.max(1, tMax - tMin);
|
||||
|
||||
const W = 100;
|
||||
const H = 40;
|
||||
const x = (t: number) => ((t - tMin) / tSpan) * W;
|
||||
const y = (v: number) => H - Math.min(1, Math.max(0, v / scale)) * H;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState } from 'react';
|
||||
import type { Role } from '@reforger-panel/shared';
|
||||
import { ROLE_LABELS } from '@reforger-panel/shared';
|
||||
import { useCreateInvite, useDeleteInvite, useInvites } from '../api/hooks.js';
|
||||
import { formatDateTime, formatRelativeTime } from '../lib/format.js';
|
||||
import { Button, Card, EmptyState, RoleBadge, Spinner } from './ui.js';
|
||||
|
||||
const INVITABLE_ROLES: Role[] = ['server_admin', 'mission_lead', 'viewer'];
|
||||
const INVITE_DURATIONS = [
|
||||
{ label: 'Never expires', value: 'never', hours: null },
|
||||
{ label: '7 days', value: '168', hours: 168 },
|
||||
{ label: '30 days', value: '720', hours: 720 },
|
||||
] as const;
|
||||
|
||||
function inviteLink(code: string): string {
|
||||
return `${window.location.origin}/?invite=${code}`;
|
||||
}
|
||||
|
||||
function isEffectivelyPermanent(expiresAt: string): boolean {
|
||||
return new Date(expiresAt).getTime() - Date.now() > 20 * 365 * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
export function InvitesCard() {
|
||||
const { data, isLoading } = useInvites(true);
|
||||
const createInvite = useCreateInvite();
|
||||
const deleteInvite = useDeleteInvite();
|
||||
const [role, setRole] = useState<Role>('mission_lead');
|
||||
const [duration, setDuration] = useState<(typeof INVITE_DURATIONS)[number]['value']>('never');
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
const copy = async (code: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteLink(code));
|
||||
setCopied(code);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
} catch {
|
||||
setCopied(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Invites"
|
||||
action={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<select
|
||||
value={role}
|
||||
onChange={(event) => setRole(event.target.value as Role)}
|
||||
className="input min-w-0 py-1.5"
|
||||
>
|
||||
{INVITABLE_ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{ROLE_LABELS[r]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={duration}
|
||||
onChange={(event) =>
|
||||
setDuration(event.target.value as (typeof INVITE_DURATIONS)[number]['value'])
|
||||
}
|
||||
className="input min-w-0 py-1.5"
|
||||
>
|
||||
{INVITE_DURATIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={createInvite.isPending}
|
||||
onClick={() =>
|
||||
createInvite.mutate({
|
||||
role,
|
||||
expiresInHours: INVITE_DURATIONS.find((option) => option.value === duration)!.hours,
|
||||
})
|
||||
}
|
||||
>
|
||||
{createInvite.isPending ? 'Creating…' : 'Create invite'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isLoading || !data ? (
|
||||
<Spinner />
|
||||
) : data.invites.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No invites yet"
|
||||
hint="Create one and send the link — the recipient logs in with Discord and gets the role automatically."
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{data.invites.map((invite) => {
|
||||
const permanent = isEffectivelyPermanent(invite.expiresAt);
|
||||
const expired = !permanent && new Date(invite.expiresAt).getTime() < Date.now();
|
||||
const state = invite.usedAt ? 'used' : expired ? 'expired' : 'active';
|
||||
return (
|
||||
<li
|
||||
key={invite.id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded border border-graphite-800 px-3.5 py-2.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<code className="font-mono text-zinc-200">{invite.code}</code>
|
||||
<RoleBadge role={invite.role} />
|
||||
{state === 'active' && <span className="text-xs text-accent-400">active</span>}
|
||||
{state === 'used' && (
|
||||
<span className="text-xs text-slate-dim">
|
||||
used by {invite.usedBy} {formatRelativeTime(invite.usedAt)}
|
||||
</span>
|
||||
)}
|
||||
{state === 'expired' && <span className="text-xs text-warn-400">expired</span>}
|
||||
</p>
|
||||
<p className="text-xs text-slate-dim">
|
||||
{permanent ? 'never expires' : `expires ${formatDateTime(invite.expiresAt)}`} ·
|
||||
created by {invite.createdBy ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{state === 'active' && (
|
||||
<Button onClick={() => void copy(invite.code)}>
|
||||
{copied === invite.code ? 'Copied!' : 'Copy link'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={deleteInvite.isPending}
|
||||
onClick={() => deleteInvite.mutate(invite.id)}
|
||||
>
|
||||
{state === 'active' ? 'Revoke' : 'Remove'}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<p className="mt-3 text-xs text-slate-dim">
|
||||
Invite links are single-use and grant the selected role at login. Redeemed roles persist
|
||||
until you change them under Users & roles.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useState } from 'react';
|
||||
import { NavLink, Outlet } 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 { PowerControls } from './widgets.js';
|
||||
|
||||
const NAV_ITEMS: {
|
||||
to: string;
|
||||
label: string;
|
||||
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' },
|
||||
];
|
||||
|
||||
export function Layout({ user }: { user: CurrentUser }) {
|
||||
const logout = useLogout();
|
||||
const { data: serversData } = useServers();
|
||||
const server = serversData?.servers[0];
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{navOpen && (
|
||||
<div
|
||||
aria-hidden
|
||||
onClick={() => setNavOpen(false)}
|
||||
className="fixed inset-0 z-20 bg-black/60 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 ${
|
||||
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">
|
||||
DZR.TOOLS
|
||||
</p>
|
||||
<p className="text-[10px] uppercase tracking-[0.16em] text-slate-dim">
|
||||
ARMA REFORGER OPS
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="min-h-0 flex-1 space-y-1 overflow-y-auto p-3">
|
||||
{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>
|
||||
))}
|
||||
</nav>
|
||||
<div className="border-t border-graphite-700/60 px-5 py-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{user.avatarUrl ? (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt=""
|
||||
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">
|
||||
{(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>
|
||||
<RoleBadge role={user.role} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title="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>
|
||||
</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">
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
<StatusBadge status={server.status} />
|
||||
<span className="hidden text-sm text-slate-ink md:inline">
|
||||
{server.onlinePlayerCount} / {server.maxPlayers ?? '—'} players
|
||||
</span>
|
||||
</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">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import { useConfiguration, useMissions, useSetPerformanceSettings } from '../api/hooks.js';
|
||||
import { Button, Card, Spinner } from './ui.js';
|
||||
import { shortScenario } from './widgets.js';
|
||||
|
||||
function missionSourceLabel(source: string): string {
|
||||
if (source === 'official') return '';
|
||||
if (source.startsWith('mod: ')) return `Mod: ${source.slice(5)}`;
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mission switcher. Options come from the scenario listing the server prints
|
||||
* at boot (requires the -listScenarios launch flag, standard on Reforger eggs).
|
||||
*/
|
||||
export function MissionCard({ slug, canEdit }: { slug: string; canEdit: boolean }) {
|
||||
const { data: config, refetch } = useConfiguration(slug);
|
||||
const { data: missions } = useMissions(slug);
|
||||
const save = useSetPerformanceSettings(slug);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<Card title="Mission">
|
||||
<Spinner />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const current = config.config.scenarioId;
|
||||
const currentName =
|
||||
missions?.missions.find((m) => m.scenarioId === current)?.name ?? shortScenario(current);
|
||||
const value = selected ?? current;
|
||||
const dirty = value !== current;
|
||||
|
||||
const submit = () => {
|
||||
setMessage(null);
|
||||
save.mutate(
|
||||
{ scenarioId: 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
|
||||
</Button>
|
||||
<Button variant="accent" onClick={submit} disabled={save.isPending}>
|
||||
{save.isPending ? 'Saving…' : 'Save to server'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-lg font-medium text-zinc-100">{currentName}</p>
|
||||
<p className="truncate font-mono text-xs text-slate-dim" title={current}>
|
||||
{shortScenario(current)}
|
||||
</p>
|
||||
</div>
|
||||
{canEdit &&
|
||||
(missions && missions.missions.length > 0 ? (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
setMessage(null);
|
||||
setSelected(event.target.value);
|
||||
}}
|
||||
className="input max-w-xs"
|
||||
>
|
||||
{!missions.missions.some((m) => m.scenarioId === current) && (
|
||||
<option value={current}>{currentName} (current)</option>
|
||||
)}
|
||||
{missions.missions.map((mission) => (
|
||||
<option key={mission.scenarioId} value={mission.scenarioId}>
|
||||
{mission.name}
|
||||
{missionSourceLabel(mission.source)
|
||||
? ` [${missionSourceLabel(mission.source)}]`
|
||||
: ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<p className="text-xs text-slate-dim">
|
||||
No scenario listing found in the current log — make sure the server runs with
|
||||
-listScenarios and has booted recently.
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
{message && <p className="mt-3 text-xs text-accent-400">{message}</p>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { PerformanceSettings } from '@reforger-panel/shared';
|
||||
import { usePerformanceSettings, useSetPerformanceSettings } from '../api/hooks.js';
|
||||
import { Button, Card, Spinner } from './ui.js';
|
||||
|
||||
type NumberKey = {
|
||||
[K in keyof PerformanceSettings]: PerformanceSettings[K] extends number | null ? K : never;
|
||||
}[keyof PerformanceSettings];
|
||||
type BooleanKey = Exclude<keyof PerformanceSettings, NumberKey>;
|
||||
|
||||
// Ranges/defaults from the Bohemia server-config reference. Blank fields are
|
||||
// omitted from config.json so the game default applies.
|
||||
// maxPlayers is deliberately absent: it is controlled via the MAX_PLAYERS
|
||||
// startup variable to avoid two "max players" inputs on one page.
|
||||
const NUMBER_FIELDS: { key: NumberKey; label: string; min: number; max: number; hint: string }[] = [
|
||||
{
|
||||
key: 'serverMaxViewDistance',
|
||||
label: 'Server view distance (m)',
|
||||
min: 500,
|
||||
max: 10000,
|
||||
hint: 'default 1600',
|
||||
},
|
||||
{
|
||||
key: 'networkViewDistance',
|
||||
label: 'Network view distance (m)',
|
||||
min: 500,
|
||||
max: 5000,
|
||||
hint: 'default 1500',
|
||||
},
|
||||
{
|
||||
key: 'serverMinGrassDistance',
|
||||
label: 'Min grass distance (m)',
|
||||
min: 0,
|
||||
max: 150,
|
||||
hint: '0 = client choice',
|
||||
},
|
||||
{ key: 'aiLimit', label: 'AI limit', min: -1, max: 1000, hint: '-1 = unlimited' },
|
||||
{
|
||||
key: 'playerSaveTime',
|
||||
label: 'Player save interval (s)',
|
||||
min: 1,
|
||||
max: 3600,
|
||||
hint: 'default 120',
|
||||
},
|
||||
{
|
||||
key: 'slotReservationTimeout',
|
||||
label: 'Slot reservation timeout (s)',
|
||||
min: 5,
|
||||
max: 300,
|
||||
hint: 'default 60',
|
||||
},
|
||||
];
|
||||
|
||||
const BOOLEAN_FIELDS: { key: BooleanKey; label: string; hint: string }[] = [
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
type FormState = Record<string, string>;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function PerformanceForm({ slug, canEdit }: { slug: string; canEdit: boolean }) {
|
||||
const { data, isLoading, error: loadError } = 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]);
|
||||
|
||||
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 = toFormState(data.settings);
|
||||
const dirty = Object.keys(form).some((key) => form[key] !== baseline[key]);
|
||||
|
||||
const set = (key: string, value: string) => {
|
||||
setMessage(null);
|
||||
setForm({ ...form, [key]: value });
|
||||
};
|
||||
|
||||
const validateAndBuild = (): PerformanceSettings | null => {
|
||||
const errors: Record<string, string> = {};
|
||||
const result = {} as Record<string, number | boolean | null>;
|
||||
for (const field of NUMBER_FIELDS) {
|
||||
const raw = (form[field.key] ?? '').trim();
|
||||
if (raw === '') {
|
||||
result[field.key] = null;
|
||||
continue;
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value < field.min || value > field.max) {
|
||||
errors[field.key] = `Must be a whole number between ${field.min} and ${field.max}.`;
|
||||
continue;
|
||||
}
|
||||
result[field.key] = value;
|
||||
}
|
||||
for (const field of BOOLEAN_FIELDS) {
|
||||
const raw = form[field.key] ?? '';
|
||||
result[field.key] = raw === '' ? null : raw === 'true';
|
||||
}
|
||||
setFieldErrors(errors);
|
||||
return Object.keys(errors).length > 0 ? null : (result as unknown as PerformanceSettings);
|
||||
};
|
||||
|
||||
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.',
|
||||
);
|
||||
},
|
||||
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">
|
||||
{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>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
disabled={!canEdit}
|
||||
value={form[field.key] ?? ''}
|
||||
placeholder="default"
|
||||
onChange={(event) => set(field.key, event.target.value)}
|
||||
className={inputClass(field.key)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{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>
|
||||
<select
|
||||
disabled={!canEdit}
|
||||
value={form[field.key] ?? ''}
|
||||
onChange={(event) => set(field.key, event.target.value)}
|
||||
className="input w-32"
|
||||
>
|
||||
<option value="">Game default</option>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</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.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { RestartScheduleInput, ServerScheduleSummary } from '@reforger-panel/shared';
|
||||
import {
|
||||
useCreateRestartSchedule,
|
||||
useDeleteSchedule,
|
||||
useServerSchedules,
|
||||
useUpdateRestartSchedule,
|
||||
} from '../api/hooks.js';
|
||||
import { formatDateTime } from '../lib/format.js';
|
||||
import { Button, Card, EmptyState, Spinner } from './ui.js';
|
||||
|
||||
const DAYS = [
|
||||
{ value: '*', label: 'Every day' },
|
||||
{ value: '0', label: 'Sunday' },
|
||||
{ value: '1', label: 'Monday' },
|
||||
{ value: '2', label: 'Tuesday' },
|
||||
{ value: '3', label: 'Wednesday' },
|
||||
{ value: '4', label: 'Thursday' },
|
||||
{ value: '5', label: 'Friday' },
|
||||
{ value: '6', label: 'Saturday' },
|
||||
] as const;
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function timeValue(schedule: ServerScheduleSummary): string {
|
||||
const hour = Number(schedule.hour);
|
||||
const minute = Number(schedule.minute);
|
||||
if (!Number.isInteger(hour) || !Number.isInteger(minute)) return '09:00';
|
||||
return `${pad(hour)}:${pad(minute)}`;
|
||||
}
|
||||
|
||||
function isRestartSchedule(schedule: ServerScheduleSummary): boolean {
|
||||
return schedule.tasks.some((task) => task.action === 'power' && task.payload === 'restart');
|
||||
}
|
||||
|
||||
function describeSchedule(schedule: ServerScheduleSummary): string {
|
||||
const day = DAYS.find((d) => d.value === schedule.dayOfWeek)?.label ?? schedule.dayOfWeek;
|
||||
return `${day} at ${timeValue(schedule)}`;
|
||||
}
|
||||
|
||||
function inputFromSchedule(schedule: ServerScheduleSummary): RestartScheduleInput {
|
||||
const [hour, minute] = timeValue(schedule).split(':').map(Number);
|
||||
return {
|
||||
name: schedule.name,
|
||||
isActive: schedule.isActive,
|
||||
minute: minute ?? 0,
|
||||
hour: hour ?? 9,
|
||||
dayOfWeek: DAYS.some((d) => d.value === schedule.dayOfWeek)
|
||||
? (schedule.dayOfWeek as RestartScheduleInput['dayOfWeek'])
|
||||
: '*',
|
||||
onlyWhenOnline: schedule.onlyWhenOnline,
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_INPUT: RestartScheduleInput = {
|
||||
name: 'Daily restart',
|
||||
isActive: true,
|
||||
minute: 0,
|
||||
hour: 9,
|
||||
dayOfWeek: '*',
|
||||
onlyWhenOnline: true,
|
||||
};
|
||||
|
||||
export function SchedulesCard({ slug, canEdit }: { slug: string; canEdit: boolean }) {
|
||||
const { data, isLoading, error } = useServerSchedules(slug, canEdit);
|
||||
const createSchedule = useCreateRestartSchedule(slug);
|
||||
const updateSchedule = useUpdateRestartSchedule(slug);
|
||||
const deleteSchedule = useDeleteSchedule(slug);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<RestartScheduleInput>(DEFAULT_INPUT);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const schedules = data?.schedules ?? [];
|
||||
const restartSchedules = useMemo(() => schedules.filter(isRestartSchedule), [schedules]);
|
||||
const editing = restartSchedules.find((schedule) => schedule.id === editingId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) setForm(inputFromSchedule(editing));
|
||||
}, [editing]);
|
||||
|
||||
if (!canEdit) return null;
|
||||
|
||||
const submit = () => {
|
||||
setMessage(null);
|
||||
const options = {
|
||||
onSuccess: () => {
|
||||
setMessage(editingId ? 'Restart schedule updated.' : 'Restart schedule created.');
|
||||
setEditingId(null);
|
||||
setForm(DEFAULT_INPUT);
|
||||
},
|
||||
onError: (err: Error) => setMessage(err.message),
|
||||
};
|
||||
if (editingId) {
|
||||
updateSchedule.mutate({ id: editingId, input: form }, options);
|
||||
return;
|
||||
}
|
||||
createSchedule.mutate(form, options);
|
||||
};
|
||||
|
||||
const busy = createSchedule.isPending || updateSchedule.isPending || deleteSchedule.isPending;
|
||||
|
||||
return (
|
||||
<Card title="Restart schedules">
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : error ? (
|
||||
<p className="text-sm text-danger-400">{error.message}</p>
|
||||
) : (
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="min-w-0">
|
||||
{restartSchedules.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No restart schedules"
|
||||
hint="Create one here instead of switching back to Pterodactyl."
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{restartSchedules.map((schedule) => (
|
||||
<li
|
||||
key={schedule.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-graphite-800 bg-graphite-950/20 px-3.5 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-zinc-200">{schedule.name}</p>
|
||||
<p className="text-xs text-slate-dim">
|
||||
{describeSchedule(schedule)} ·{' '}
|
||||
{schedule.onlyWhenOnline ? 'only when online' : 'runs regardless'} ·{' '}
|
||||
{schedule.isActive ? 'active' : 'paused'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-dim">
|
||||
next run {schedule.nextRunAt ? formatDateTime(schedule.nextRunAt) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setEditingId(schedule.id);
|
||||
setMessage(null);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
deleteSchedule.mutate(schedule.id, {
|
||||
onSuccess: () => setMessage('Schedule deleted.'),
|
||||
onError: (err) => setMessage(err.message),
|
||||
})
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-graphite-800 bg-graphite-950/20 p-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-200">
|
||||
{editingId ? 'Edit restart' : 'New restart'}
|
||||
</h3>
|
||||
<div className="mt-3 space-y-3">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-slate-dim">Name</span>
|
||||
<input
|
||||
className="input w-full"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-slate-dim">Time</span>
|
||||
<input
|
||||
className="input w-full"
|
||||
type="time"
|
||||
value={`${pad(form.hour)}:${pad(form.minute)}`}
|
||||
onChange={(event) => {
|
||||
const [hour, minute] = event.target.value.split(':').map(Number);
|
||||
setForm({ ...form, hour: hour ?? 0, minute: minute ?? 0 });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs text-slate-dim">Day</span>
|
||||
<select
|
||||
className="input w-full"
|
||||
value={form.dayOfWeek}
|
||||
onChange={(event) =>
|
||||
setForm({
|
||||
...form,
|
||||
dayOfWeek: event.target.value as RestartScheduleInput['dayOfWeek'],
|
||||
})
|
||||
}
|
||||
>
|
||||
{DAYS.map((day) => (
|
||||
<option key={day.value} value={day.value}>
|
||||
{day.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isActive}
|
||||
onChange={(event) => setForm({ ...form, isActive: event.target.checked })}
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.onlyWhenOnline}
|
||||
onChange={(event) => setForm({ ...form, onlyWhenOnline: event.target.checked })}
|
||||
/>
|
||||
Only run when server is online
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={busy || form.name.trim() === ''}
|
||||
onClick={submit}
|
||||
>
|
||||
{busy ? 'Saving…' : editingId ? 'Save schedule' : 'Create schedule'}
|
||||
</Button>
|
||||
{editingId && (
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setEditingId(null);
|
||||
setForm(DEFAULT_INPUT);
|
||||
setMessage(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{message && <p className="text-xs text-slate-dim">{message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState } from 'react';
|
||||
import { useStartupVariables, useUpdateStartupVariable } from '../api/hooks.js';
|
||||
import { Button, Card, EmptyState, Spinner } from './ui.js';
|
||||
|
||||
/**
|
||||
* Pterodactyl egg startup variables (passwords, launch options, …). Values
|
||||
* are only visible to owner/server admin; changes apply on the next restart.
|
||||
*/
|
||||
// Controlled elsewhere in the panel (mission dropdown) or intentionally not
|
||||
// exposed; hidden here to avoid duplicate/confusing inputs.
|
||||
const HIDDEN_VARIABLES = new Set(['SCENARIO_ID', 'PUBLIC_ADDRESS']);
|
||||
|
||||
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 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 },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setEdits((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[envVariable];
|
||||
return next;
|
||||
});
|
||||
setMessage(`${envVariable} saved — applies on the next restart.`);
|
||||
},
|
||||
onError: (updateError) => setMessage(updateError.message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="Startup variables (Pterodactyl)">
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : error ? (
|
||||
<p className="text-sm text-danger-400">{error.message}</p>
|
||||
) : !data || data.variables.length === 0 ? (
|
||||
<EmptyState title="No startup variables" hint="The egg exposes none for this server." />
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{data.variables
|
||||
.filter((variable) => !HIDDEN_VARIABLES.has(variable.envVariable))
|
||||
.map((variable) => {
|
||||
const edited = edits[variable.envVariable];
|
||||
const secret = isSecret(variable.envVariable) || isSecret(variable.name);
|
||||
const shown = revealed[variable.envVariable] ?? false;
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
{variable.description && (
|
||||
<p className="mt-0.5 text-xs text-slate-dim">{variable.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<input
|
||||
type={secret && !shown ? 'password' : 'text'}
|
||||
className="input w-48"
|
||||
disabled={!variable.isEditable || update.isPending}
|
||||
value={edited ?? variable.value}
|
||||
placeholder={variable.defaultValue || 'empty'}
|
||||
onChange={(event) =>
|
||||
setEdits({ ...edits, [variable.envVariable]: event.target.value })
|
||||
}
|
||||
/>
|
||||
{secret && (
|
||||
<Button
|
||||
onClick={() => setRevealed({ ...revealed, [variable.envVariable]: !shown })}
|
||||
>
|
||||
{shown ? 'Hide' : 'Show'}
|
||||
</Button>
|
||||
)}
|
||||
{variable.isEditable ? (
|
||||
edited !== undefined &&
|
||||
edited !== variable.value && (
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={update.isPending}
|
||||
onClick={() => saveVariable(variable.envVariable)}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)
|
||||
) : (
|
||||
<span className="text-xs text-slate-dim">read-only</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import type { Role, ServerStatus } from '@reforger-panel/shared';
|
||||
import { ROLE_LABELS } from '@reforger-panel/shared';
|
||||
|
||||
export function Card({
|
||||
title,
|
||||
action,
|
||||
children,
|
||||
className = '',
|
||||
padded = true,
|
||||
}: {
|
||||
title?: string;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
padded?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section className={`panel-card ${className}`}>
|
||||
{title !== undefined && (
|
||||
<header className="panel-card-header">
|
||||
<h2 className="panel-card-title">{title}</h2>
|
||||
{action}
|
||||
</header>
|
||||
)}
|
||||
<div className={padded ? 'p-5' : ''}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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}`}
|
||||
>
|
||||
<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>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
className={`shrink-0 rounded-md border border-graphite-700 object-cover ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<ServerStatus, { dot: string; text: string; label: string }> = {
|
||||
online: { dot: 'bg-accent-400', text: 'text-accent-400', 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' },
|
||||
};
|
||||
|
||||
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({
|
||||
value,
|
||||
max,
|
||||
warnAt = 0.8,
|
||||
}: {
|
||||
value: number;
|
||||
max: number | null;
|
||||
warnAt?: number;
|
||||
}) {
|
||||
if (!max || max <= 0) return null;
|
||||
const ratio = Math.min(1, value / max);
|
||||
const color = 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>
|
||||
);
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
variant = 'default',
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
variant?: 'default' | 'accent' | 'danger';
|
||||
title?: string;
|
||||
}) {
|
||||
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="button"
|
||||
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]}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useState } from 'react';
|
||||
import type {
|
||||
ActivityItem,
|
||||
Capability,
|
||||
ConfigurationResponse,
|
||||
CurrentUser,
|
||||
PlayersResponse,
|
||||
ServerSummary,
|
||||
} from '@reforger-panel/shared';
|
||||
import {
|
||||
useActivity,
|
||||
useLogHealth,
|
||||
useManualLogSync,
|
||||
usePlayers,
|
||||
usePowerAction,
|
||||
useWorkshopHealth,
|
||||
} from '../api/hooks.js';
|
||||
import { formatDateTime, formatDuration, formatRelativeTime } from '../lib/format.js';
|
||||
import { Button, Card, EmptyState, Spinner } from './ui.js';
|
||||
|
||||
function can(user: CurrentUser, capability: Capability): boolean {
|
||||
return user.capabilities.includes(capability);
|
||||
}
|
||||
|
||||
export function PowerControls({ user, server }: { user: CurrentUser; server: ServerSummary }) {
|
||||
const power = usePowerAction(server.slug);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
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),
|
||||
});
|
||||
};
|
||||
|
||||
const canStart = can(user, 'server.power.start');
|
||||
const canStop = can(user, 'server.power.stop');
|
||||
const canRestart = can(user, 'server.power.restart');
|
||||
if (!canStart && !canStop && !canRestart) return null;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-wrap items-center justify-end gap-2 md:w-auto">
|
||||
{canStart && (
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={power.isPending || server.status === 'online'}
|
||||
onClick={() => run('start')}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{canRestart && (
|
||||
<Button disabled={power.isPending} onClick={() => run('restart')}>
|
||||
Restart
|
||||
</Button>
|
||||
)}
|
||||
{canStop && (
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={power.isPending || server.status === 'offline'}
|
||||
onClick={() => run('stop')}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
{message && <span className="text-xs text-slate-dim">{message}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CurrentPlayersCard({
|
||||
slug,
|
||||
maxPlayers,
|
||||
}: {
|
||||
slug: string;
|
||||
maxPlayers: number | null;
|
||||
}) {
|
||||
const { data, isLoading } = usePlayers(slug);
|
||||
return (
|
||||
<Card
|
||||
title="Current players"
|
||||
action={
|
||||
data && (
|
||||
<span className="text-xs text-slate-dim">
|
||||
{data.stale ? (
|
||||
<span className="text-warn-400">data may be stale</span>
|
||||
) : (
|
||||
<>last synchronized {formatRelativeTime(data.lastSyncedAt)}</>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
{isLoading || !data ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<PlayersTable players={data} maxPlayers={maxPlayers ?? data.maxPlayers} />
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayersTable({
|
||||
players,
|
||||
maxPlayers,
|
||||
}: {
|
||||
players: PlayersResponse;
|
||||
maxPlayers: number | null;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-4 text-3xl font-semibold text-zinc-100">
|
||||
{players.onlineCount}
|
||||
<span className="text-base font-normal text-slate-dim"> / {maxPlayers ?? '—'} online</span>
|
||||
</p>
|
||||
{players.players.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No players connected"
|
||||
hint="Player presence is reconstructed from server logs and updates on each sync."
|
||||
/>
|
||||
) : (
|
||||
<div className="data-table-scroll">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Connected since</th>
|
||||
<th className="text-right">Session</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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">
|
||||
{formatDuration(player.sessionDurationSeconds)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ACTIVITY_COLORS: Record<string, string> = {
|
||||
player_connected: 'text-accent-400',
|
||||
player_disconnected: 'text-slate-ink',
|
||||
server_started: 'text-accent-400',
|
||||
server_stopped: 'text-warn-400',
|
||||
server_restart_detected: 'text-warn-400',
|
||||
log_sync_failed: 'text-danger-400',
|
||||
};
|
||||
|
||||
function logTimestamp(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
/** Log-style feed: monospace timestamps, fixed height, scrolls. */
|
||||
export function ActivityList({
|
||||
items,
|
||||
maxHeight = 320,
|
||||
}: {
|
||||
items: ActivityItem[];
|
||||
maxHeight?: number;
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<EmptyState 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 }}
|
||||
>
|
||||
<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"
|
||||
title={new Date(item.occurredAt).toLocaleString()}
|
||||
>
|
||||
<span className="shrink-0 text-slate-dim">{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">
|
||||
{item.kind === 'panel_action' ? 'panel' : 'server'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecentActivityCard({ slug, limit = 50 }: { slug: string; limit?: number }) {
|
||||
const { data, isLoading } = useActivity(slug, limit);
|
||||
return (
|
||||
<Card title="Recent activity">
|
||||
{isLoading || !data ? <Spinner /> : <ActivityList items={data.activity} />}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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)],
|
||||
['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)`],
|
||||
['Third person', c.disableThirdPerson ? 'Disabled' : 'Allowed'],
|
||||
['Cross-platform', c.crossPlatform ? 'Enabled' : 'Disabled'],
|
||||
['Mods', `${c.mods.length}`],
|
||||
];
|
||||
return (
|
||||
<dl className="space-y-2">
|
||||
{rows.map(([label, value]) => (
|
||||
<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}
|
||||
>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Operational health"
|
||||
action={
|
||||
can(user, 'logs.sync') && (
|
||||
<Button
|
||||
disabled={syncNow.isPending || logs?.configured === false}
|
||||
onClick={() =>
|
||||
syncNow.mutate(undefined, {
|
||||
onSuccess: (result) =>
|
||||
setSyncMessage(
|
||||
`Synced: ${result.processedLines} lines, ${result.createdEvents} new events`,
|
||||
),
|
||||
onError: (error) => setSyncMessage(error.message),
|
||||
})
|
||||
}
|
||||
>
|
||||
{syncNow.isPending ? 'Syncing…' : 'Sync logs 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>
|
||||
<dd>
|
||||
{!logs ? (
|
||||
<span className="text-slate-dim">checking…</span>
|
||||
) : !logs.configured ? (
|
||||
<span className="text-slate-dim">not configured</span>
|
||||
) : logs.stale ? (
|
||||
<span className="text-warn-400">stale</span>
|
||||
) : (
|
||||
<span className="text-accent-400">healthy</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<dt className="text-slate-ink">Last successful sync</dt>
|
||||
<dd className="text-zinc-300">
|
||||
{formatRelativeTime(logs?.lastSuccessfulSyncAt ?? null)}
|
||||
</dd>
|
||||
</div>
|
||||
{logs?.lastSync && (
|
||||
<div className="flex items-center justify-between">
|
||||
<dt className="text-slate-ink">Last sync processed</dt>
|
||||
<dd className="font-mono text-xs text-zinc-300">
|
||||
{logs.lastSync.processedLines} lines · {logs.lastSync.createdEvents} events
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{logs?.lastErrorMessage && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<dt className="shrink-0 text-slate-ink">Last sync error</dt>
|
||||
<dd
|
||||
className="truncate text-xs text-danger-400"
|
||||
title={`${formatRelativeTime(logs.lastErrorAt)}: ${logs.lastErrorMessage}`}
|
||||
>
|
||||
{logs.lastErrorMessage}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{syncMessage && <p className="text-xs text-slate-dim">{syncMessage}</p>}
|
||||
</dl>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@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;
|
||||
|
||||
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-graphite-950 text-zinc-200 antialiased;
|
||||
background: var(--color-graphite-950);
|
||||
}
|
||||
|
||||
button,
|
||||
a,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
@apply outline-none;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
@apply ring-2 ring-accent-500/45 ring-offset-2 ring-offset-graphite-950;
|
||||
}
|
||||
|
||||
::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;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
||||
const exponent = Math.min(Math.floor(Math.log2(bytes) / 10), units.length - 1);
|
||||
const value = bytes / 2 ** (10 * exponent);
|
||||
return `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[exponent]}`;
|
||||
}
|
||||
|
||||
export function formatDuration(totalSeconds: number): string {
|
||||
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '—';
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
if (hours >= 24) {
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ${hours % 24}h`;
|
||||
}
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
if (minutes > 0) return `${minutes}m`;
|
||||
return `${Math.floor(totalSeconds)}s`;
|
||||
}
|
||||
|
||||
export function formatRelativeTime(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return '—';
|
||||
const seconds = Math.round((Date.now() - then) / 1000);
|
||||
if (seconds < 5) return 'just now';
|
||||
if (seconds < 60) return `${seconds} seconds ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} day${days === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
export function formatDateTime(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
return date.toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App.js';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../api/client.js';
|
||||
|
||||
function DiscordMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden>
|
||||
<path d="M20.317 4.37a19.79 19.79 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.865-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.1 18.058a.082.082 0 0 0 .031.056 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.291.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.3 12.3 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.84 19.84 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.06.06 0 0 0-.031-.03ZM8.02 15.331c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418Zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const [devError, setDevError] = useState<string | null>(null);
|
||||
const { data: options } = useQuery({
|
||||
queryKey: ['auth', 'options'],
|
||||
queryFn: () => api.get<{ discord: boolean; devLogin: boolean }>('/api/auth/options'),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
// Invite links land here before login; stash the code so it can be redeemed
|
||||
// automatically right after the Discord round-trip.
|
||||
const inviteCode = new URLSearchParams(window.location.search).get('invite');
|
||||
if (inviteCode) {
|
||||
localStorage.setItem('rp_invite', inviteCode);
|
||||
}
|
||||
const pendingInvite = inviteCode ?? localStorage.getItem('rp_invite');
|
||||
|
||||
const devLogin = async () => {
|
||||
try {
|
||||
await api.post('/api/auth/dev-login');
|
||||
window.location.reload();
|
||||
} catch {
|
||||
setDevError('Dev login is not enabled (set DEV_AUTH_BYPASS=true locally).');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-dvh items-center justify-center overflow-hidden px-4">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute left-1/2 top-1/2 h-[38rem] w-[38rem] -translate-x-1/2 -translate-y-1/2 rounded-full bg-accent-500/10 blur-3xl"
|
||||
/>
|
||||
<div className="relative w-full max-w-sm rounded-lg border border-graphite-700/70 bg-graphite-900 p-8 shadow-xl shadow-black/40">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-accent-500/50 to-transparent"
|
||||
/>
|
||||
<div className="mb-8 text-center">
|
||||
<span className="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-lg border border-graphite-600 bg-graphite-850 font-mono text-lg font-bold tracking-tight text-accent-400 shadow-inner shadow-black/30">
|
||||
DZR
|
||||
</span>
|
||||
<h1 className="text-xl font-semibold uppercase tracking-[0.14em] text-zinc-100">
|
||||
DZR.TOOLS
|
||||
</h1>
|
||||
<p className="mt-1.5 text-[11px] font-medium uppercase tracking-[0.24em] text-slate-dim">
|
||||
Arma Reforger Ops
|
||||
</p>
|
||||
</div>
|
||||
{pendingInvite && (
|
||||
<p className="mb-4 rounded-md border border-accent-600/40 bg-accent-600/10 px-3 py-2.5 text-center text-xs font-medium text-accent-400">
|
||||
Invite detected. Sign in with Discord and the role will be applied automatically.
|
||||
</p>
|
||||
)}
|
||||
<a
|
||||
href="/api/auth/discord"
|
||||
className="flex w-full items-center justify-center gap-2.5 rounded-md bg-[#5865F2] px-4 py-3 text-sm font-semibold text-white transition-opacity hover:opacity-90"
|
||||
>
|
||||
<DiscordMark className="h-5 w-5" />
|
||||
Continue with Discord
|
||||
</a>
|
||||
{options?.devLogin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void devLogin()}
|
||||
className="mt-3 w-full rounded-md border border-graphite-600 px-4 py-2.5 text-center text-xs font-medium text-slate-dim transition-colors hover:text-zinc-300"
|
||||
>
|
||||
Local development login
|
||||
</button>
|
||||
)}
|
||||
{devError && <p className="mt-2 text-center text-xs text-danger-400">{devError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRawLogs, useServers } from '../api/hooks.js';
|
||||
import { formatRelativeTime } from '../lib/format.js';
|
||||
import { Button, Card, Spinner } from '../components/ui.js';
|
||||
|
||||
export function LogsPage() {
|
||||
const { data: serversData } = useServers();
|
||||
const slug = serversData?.servers[0]?.slug;
|
||||
const [lines, setLines] = useState(300);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [follow, setFollow] = useState(true);
|
||||
const { data, isLoading, error, refetch, isFetching } = useRawLogs(
|
||||
slug ?? '',
|
||||
lines,
|
||||
autoRefresh,
|
||||
slug !== undefined,
|
||||
);
|
||||
const viewportRef = useRef<HTMLPreElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (follow && viewportRef.current) {
|
||||
viewportRef.current.scrollTop = viewportRef.current.scrollHeight;
|
||||
}
|
||||
}, [data, follow]);
|
||||
|
||||
if (!slug) return <Spinner />;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-5">
|
||||
<h1 className="page-title">Logs</h1>
|
||||
<Card
|
||||
title={data ? data.path : 'console.log'}
|
||||
action={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{data && (
|
||||
<span className="text-xs text-slate-dim">
|
||||
fetched {formatRelativeTime(data.fetchedAt)}
|
||||
</span>
|
||||
)}
|
||||
<select
|
||||
value={lines}
|
||||
onChange={(event) => setLines(Number(event.target.value))}
|
||||
className="input py-1.5"
|
||||
>
|
||||
{[100, 300, 600, 1000].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
last {n} lines
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
variant={autoRefresh ? 'accent' : 'default'}
|
||||
onClick={() => setAutoRefresh((v) => !v)}
|
||||
title="Refresh every 10 seconds"
|
||||
>
|
||||
{autoRefresh ? 'Auto: on' : 'Auto: off'}
|
||||
</Button>
|
||||
<Button
|
||||
variant={follow ? 'accent' : 'default'}
|
||||
onClick={() => setFollow((v) => !v)}
|
||||
title="Keep scrolled to the newest lines"
|
||||
>
|
||||
{follow ? 'Follow' : 'Free scroll'}
|
||||
</Button>
|
||||
<Button disabled={isFetching} onClick={() => void refetch()}>
|
||||
{isFetching ? '…' : 'Refresh'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner label="Downloading log…" />
|
||||
) : error ? (
|
||||
<p className="text-sm text-danger-400">{error.message}</p>
|
||||
) : (
|
||||
<pre
|
||||
ref={viewportRef}
|
||||
className="max-h-[65vh] overflow-auto whitespace-pre rounded-md border border-graphite-800 bg-graphite-950 p-4 font-mono text-xs leading-relaxed text-zinc-300"
|
||||
>
|
||||
{data?.lines.join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
<p className="mt-3 text-xs text-slate-dim">
|
||||
Read-only tail of the current Reforger console log, downloaded through the Pterodactyl
|
||||
API. Visible to owner and server admins only.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
import { useState } from 'react';
|
||||
import type { CurrentUser, ReforgerConfigMod, WorkshopModDetail } from '@reforger-panel/shared';
|
||||
import { api } from '../api/client.js';
|
||||
import {
|
||||
useServerMods,
|
||||
useServers,
|
||||
useSetServerMods,
|
||||
useWorkshopMod,
|
||||
useWorkshopSearch,
|
||||
} from '../api/hooks.js';
|
||||
import { formatRelativeTime } from '../lib/format.js';
|
||||
import { Button, Card, EmptyState, ModImage, Spinner } from '../components/ui.js';
|
||||
|
||||
const COMMON_WORKSHOP_TAGS = [
|
||||
'WEAPONS',
|
||||
'VEHICLES',
|
||||
'MISSIONS',
|
||||
'EQUIPMENT',
|
||||
'GAMEPLAY',
|
||||
'MISC',
|
||||
'QUALITY OF LIFE',
|
||||
] as const;
|
||||
|
||||
const WORKSHOP_SORTS = [
|
||||
{ value: 'popularity', label: 'Popular' },
|
||||
{ value: 'newest', label: 'Newest' },
|
||||
{ value: 'subscribers', label: 'Subscribers' },
|
||||
{ value: 'version_size', label: 'Size' },
|
||||
] as const;
|
||||
|
||||
export function ModsPage({ user }: { user: CurrentUser }) {
|
||||
const { data: serversData } = useServers();
|
||||
const slug = serversData?.servers[0]?.slug;
|
||||
if (!slug) return <Spinner />;
|
||||
return <ModsBody slug={slug} user={user} />;
|
||||
}
|
||||
|
||||
function sameMods(a: ReforgerConfigMod[], b: ReforgerConfigMod[]): boolean {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
function ModsBody({ slug, user }: { slug: string; user: CurrentUser }) {
|
||||
const canManage = user.capabilities.includes('mods.manage');
|
||||
const { data, isLoading, error, refetch } = useServerMods(slug);
|
||||
const save = useSetServerMods(slug);
|
||||
const [draft, setDraft] = useState<ReforgerConfigMod[] | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const serverMods = data?.mods ?? [];
|
||||
const mods = draft ?? serverMods;
|
||||
const dirty = draft !== null && !sameMods(draft, serverMods);
|
||||
const installedIds = new Set(mods.map((mod) => mod.modId.toUpperCase()));
|
||||
|
||||
const addMod = (mod: ReforgerConfigMod) => {
|
||||
if (installedIds.has(mod.modId.toUpperCase())) return;
|
||||
setMessage(null);
|
||||
setDraft([...mods, mod]);
|
||||
};
|
||||
|
||||
const removeMod = (modId: string) => {
|
||||
setMessage(null);
|
||||
setDraft(mods.filter((mod) => mod.modId !== modId));
|
||||
};
|
||||
|
||||
const saveMods = () => {
|
||||
setMessage(null);
|
||||
save.mutate(mods, {
|
||||
onSuccess: (result) => {
|
||||
setDraft(null);
|
||||
setMessage(
|
||||
`Saved to config.json — ${result.added} added, ${result.removed} removed. ` +
|
||||
'Restart the server to apply.',
|
||||
);
|
||||
void refetch();
|
||||
},
|
||||
onError: (saveError) => setMessage(saveError.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-5">
|
||||
<div>
|
||||
<h1 className="page-title">Mods</h1>
|
||||
<p className="page-kicker">
|
||||
Review the live server mod list, stage changes, and pull metadata from the Reforger
|
||||
Workshop before saving config.json.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
title="Server mods (config.json)"
|
||||
action={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{data && !dirty && (
|
||||
<span className="text-xs text-slate-dim">
|
||||
fetched {formatRelativeTime(data.fetchedAt)}
|
||||
</span>
|
||||
)}
|
||||
{dirty && (
|
||||
<>
|
||||
<span className="text-xs text-warn-400">unsaved changes</span>
|
||||
<Button onClick={() => setDraft(null)} disabled={save.isPending}>
|
||||
Discard
|
||||
</Button>
|
||||
<Button variant="accent" onClick={saveMods} disabled={save.isPending}>
|
||||
{save.isPending ? 'Saving…' : 'Save to server'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner label="Downloading config.json…" />
|
||||
) : error ? (
|
||||
<p className="text-sm text-danger-400">{error.message}</p>
|
||||
) : mods.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No mods installed"
|
||||
hint={canManage ? 'Add mods from the Workshop below.' : 'The server runs vanilla.'}
|
||||
/>
|
||||
) : (
|
||||
<ul className="grid max-h-72 gap-1.5 overflow-y-auto pr-1 md:grid-cols-2 xl:grid-cols-3">
|
||||
{mods.map((mod) => (
|
||||
<li
|
||||
key={mod.modId}
|
||||
className="flex items-center justify-between rounded-md border border-graphite-800 bg-graphite-950/20 px-3 py-2.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-zinc-200">
|
||||
{mod.name ?? mod.modId}
|
||||
</p>
|
||||
<p className="font-mono text-xs text-slate-dim">
|
||||
{mod.modId}
|
||||
{mod.version ? ` · v${mod.version}` : ' · latest version'}
|
||||
</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button variant="danger" onClick={() => removeMod(mod.modId)}>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{message && <p className="mt-3 text-xs text-accent-400">{message}</p>}
|
||||
<p className="mt-3 text-xs text-slate-dim">
|
||||
Changes are written directly to the server's config.json (a config.json.bak backup is
|
||||
kept) and take effect on the next server restart.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<WorkshopBrowser canManage={canManage} installedIds={installedIds} onAdd={addMod} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkshopBrowser({
|
||||
canManage,
|
||||
installedIds,
|
||||
onAdd,
|
||||
}: {
|
||||
canManage: boolean;
|
||||
installedIds: Set<string>;
|
||||
onAdd: (mod: ReforgerConfigMod) => void;
|
||||
}) {
|
||||
const [input, setInput] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [activeTag, setActiveTag] = useState<string | null>(null);
|
||||
const [sort, setSort] = useState<(typeof WORKSHOP_SORTS)[number]['value']>('popularity');
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedModId, setSelectedModId] = useState<string | null>(null);
|
||||
const [addingId, setAddingId] = useState<string | null>(null);
|
||||
const effectiveQuery = [query, activeTag].filter(Boolean).join(' ');
|
||||
const { data, isFetching, error } = useWorkshopSearch(effectiveQuery, page, sort);
|
||||
|
||||
// Adding needs the mod's version, which only the detail endpoint provides.
|
||||
const addFromWorkshop = async (modId: string, fallbackName: string) => {
|
||||
setAddingId(modId);
|
||||
try {
|
||||
const detail = await api.get<WorkshopModDetail>(`/api/workshop/mods/${modId}`);
|
||||
onAdd({
|
||||
modId: detail.id,
|
||||
name: detail.name || fallbackName,
|
||||
...(detail.version ? { version: detail.version } : {}),
|
||||
});
|
||||
} catch {
|
||||
onAdd({ modId, name: fallbackName });
|
||||
} finally {
|
||||
setAddingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card title="Workshop">
|
||||
<form
|
||||
className="mb-3 grid gap-2 lg:grid-cols-[minmax(0,1fr)_180px_auto]"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setPage(1);
|
||||
setSelectedModId(null);
|
||||
setQuery(input.trim());
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
placeholder="Search the Reforger Workshop… (empty shows the front page)"
|
||||
className="input min-w-0 flex-1"
|
||||
/>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(event) => {
|
||||
setPage(1);
|
||||
setSort(event.target.value as (typeof WORKSHOP_SORTS)[number]['value']);
|
||||
}}
|
||||
className="input"
|
||||
>
|
||||
{WORKSHOP_SORTS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button variant="accent" disabled={isFetching}>
|
||||
{isFetching ? 'Loading…' : 'Search'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs uppercase tracking-wider text-slate-dim">Tags</span>
|
||||
{COMMON_WORKSHOP_TAGS.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
setSelectedModId(null);
|
||||
setActiveTag(activeTag === tag ? null : tag);
|
||||
}}
|
||||
className={`rounded-full border px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
activeTag === tag
|
||||
? 'border-accent-500/50 bg-accent-500/15 text-accent-400'
|
||||
: 'border-graphite-700 bg-graphite-950/20 text-slate-ink hover:border-graphite-600 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
{activeTag && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveTag(null);
|
||||
setPage(1);
|
||||
}}
|
||||
className="text-xs text-slate-dim hover:text-zinc-200"
|
||||
>
|
||||
Clear tag
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-danger-400">{error.message}</p>}
|
||||
{!data && !error && <Spinner label="Loading Workshop mods…" />}
|
||||
{data && (
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_430px]">
|
||||
<div className={`min-w-0 ${isFetching ? 'opacity-60' : ''} transition-opacity`}>
|
||||
<p className="mb-2 text-xs text-slate-dim">
|
||||
{effectiveQuery
|
||||
? `${data.meta.totalMods.toLocaleString()} results for “${effectiveQuery}”`
|
||||
: `${data.meta.totalMods.toLocaleString()} Workshop mods`}{' '}
|
||||
· page {data.meta.currentPage} of {data.meta.totalPages}
|
||||
</p>
|
||||
<ul className="max-h-[62vh] space-y-1.5 overflow-y-auto pr-1">
|
||||
{data.mods.map((mod) => {
|
||||
const installed = installedIds.has(mod.id.toUpperCase());
|
||||
return (
|
||||
<li key={mod.id} className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedModId(mod.id)}
|
||||
className={`flex min-w-0 flex-1 items-center gap-3 rounded-md border px-3 py-2 text-left transition-colors ${
|
||||
selectedModId === mod.id
|
||||
? 'border-accent-600/60 bg-accent-600/10'
|
||||
: 'border-graphite-800 bg-graphite-950/20 hover:border-graphite-600'
|
||||
}`}
|
||||
>
|
||||
<ModImage src={mod.imageUrl} className="h-10 w-10" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm text-zinc-200">{mod.name}</span>
|
||||
<span className="block truncate text-xs text-slate-dim">
|
||||
{mod.author} · {mod.size ?? '—'} · {mod.rating ?? '—'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{canManage && (
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={installed || addingId === mod.id}
|
||||
title={installed ? 'Already in the mod list' : undefined}
|
||||
onClick={() => void addFromWorkshop(mod.id, mod.name)}
|
||||
>
|
||||
{installed ? 'Added' : addingId === mod.id ? '…' : 'Add'}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button disabled={page <= 1 || isFetching} onClick={() => setPage((p) => p - 1)}>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
disabled={page >= data.meta.totalPages || isFetching}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ModDetailPanel
|
||||
modId={selectedModId}
|
||||
canManage={canManage}
|
||||
installedIds={installedIds}
|
||||
onAdd={onAdd}
|
||||
onTagSelect={(tag) => {
|
||||
setActiveTag(tag);
|
||||
setPage(1);
|
||||
setSelectedModId(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ModDetailPanel({
|
||||
modId,
|
||||
canManage,
|
||||
installedIds,
|
||||
onAdd,
|
||||
onTagSelect,
|
||||
}: {
|
||||
modId: string | null;
|
||||
canManage: boolean;
|
||||
installedIds: Set<string>;
|
||||
onAdd: (mod: ReforgerConfigMod) => void;
|
||||
onTagSelect: (tag: string) => void;
|
||||
}) {
|
||||
const { data: mod, isLoading } = useWorkshopMod(modId);
|
||||
if (!modId) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-graphite-700 bg-graphite-950/20 p-6">
|
||||
<EmptyState title="Select a mod" hint="Mod details load from the Workshop API." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isLoading || !mod) return <Spinner />;
|
||||
const installed = installedIds.has(mod.id.toUpperCase());
|
||||
return (
|
||||
<div className="max-h-[62vh] min-w-0 overflow-y-auto rounded-md border border-graphite-800 bg-graphite-950/20 p-4 xl:sticky xl:top-24">
|
||||
<div className="flex items-start gap-4">
|
||||
<ModImage src={mod.imageUrl} className="h-20 w-20" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base font-semibold text-zinc-100">{mod.name}</h3>
|
||||
<p className="text-xs text-slate-ink">
|
||||
by {mod.author} · v{mod.version ?? '—'} · game {mod.gameVersion ?? '—'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-dim">
|
||||
{mod.downloads?.toLocaleString() ?? '—'} downloads · {mod.rating ?? '—'} rating ·{' '}
|
||||
{mod.size ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{mod.summary && <p className="mt-3 text-sm text-zinc-300">{mod.summary}</p>}
|
||||
{mod.tags.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{mod.tags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onClick={() => onTagSelect(tag)}
|
||||
className="rounded-full border border-graphite-700 bg-graphite-900 px-2 py-0.5 text-xs text-slate-ink hover:border-accent-500/50 hover:text-accent-400"
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{mod.dependencies.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs uppercase tracking-wider text-warn-400">
|
||||
Dependencies (add these too)
|
||||
</p>
|
||||
<ul className="mt-1 space-y-0.5 text-sm text-zinc-300">
|
||||
{mod.dependencies.map((dep) => (
|
||||
<li key={dep.id ?? dep.name}>{dep.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
{canManage && (
|
||||
<Button
|
||||
variant="accent"
|
||||
disabled={installed}
|
||||
onClick={() =>
|
||||
onAdd({
|
||||
modId: mod.id,
|
||||
name: mod.name,
|
||||
...(mod.version ? { version: mod.version } : {}),
|
||||
})
|
||||
}
|
||||
>
|
||||
{installed ? 'Already added' : 'Add to server'}
|
||||
</Button>
|
||||
)}
|
||||
{mod.workshopUrl && (
|
||||
<a
|
||||
href={mod.workshopUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-accent-400 hover:underline"
|
||||
>
|
||||
Open in Workshop ↗
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { CurrentUser, ResourceSample } from '@reforger-panel/shared';
|
||||
import {
|
||||
useConfiguration,
|
||||
useResourceHistory,
|
||||
useServerResources,
|
||||
useServers,
|
||||
} from '../api/hooks.js';
|
||||
import { formatBytes, formatDuration } from '../lib/format.js';
|
||||
import { Card, Spinner } from '../components/ui.js';
|
||||
import { TimeSeriesChart } from '../components/charts.js';
|
||||
import {
|
||||
ConfigSummaryRows,
|
||||
CurrentPlayersCard,
|
||||
OpsHealthCard,
|
||||
RecentActivityCard,
|
||||
} from '../components/widgets.js';
|
||||
|
||||
export function OverviewPage({ user }: { user: CurrentUser }) {
|
||||
const { data: serversData, isLoading } = useServers();
|
||||
const server = serversData?.servers[0];
|
||||
|
||||
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 <Dashboard user={user} slug={server.slug} />;
|
||||
}
|
||||
|
||||
function seriesOf(
|
||||
samples: ResourceSample[] | undefined,
|
||||
pick: (s: ResourceSample) => number,
|
||||
): { t: number; v: number }[] {
|
||||
return (samples ?? []).map((s) => ({ t: s.t, v: pick(s) }));
|
||||
}
|
||||
|
||||
function Dashboard({ user, slug }: { user: CurrentUser; slug: string }) {
|
||||
const { data: serversData } = useServers();
|
||||
const server = serversData?.servers.find((s) => s.slug === slug);
|
||||
const { data: resources } = useServerResources(slug);
|
||||
const { data: config } = useConfiguration(slug);
|
||||
const { data: history } = useResourceHistory(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;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<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>
|
||||
<TimeSeriesChart
|
||||
className="mt-2"
|
||||
max={cpuLimit}
|
||||
series={[
|
||||
{
|
||||
points: seriesOf(samples, (s) => s.cpuPercent),
|
||||
color: 'var(--color-accent-400)',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<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>
|
||||
<TimeSeriesChart
|
||||
className="mt-2"
|
||||
max={memoryLimit}
|
||||
series={[
|
||||
{
|
||||
points: seriesOf(samples, (s) => s.memoryBytes),
|
||||
color: '#7dd3fc',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<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{' '}
|
||||
{resources && resources.uptimeMs > 0
|
||||
? formatDuration(resources.uptimeMs / 1000)
|
||||
: '—'}
|
||||
</span>
|
||||
</p>
|
||||
<TimeSeriesChart
|
||||
className="mt-2"
|
||||
series={[
|
||||
{
|
||||
points: seriesOf(samples, (s) => s.networkRxRate),
|
||||
color: 'var(--color-accent-400)',
|
||||
label: 'rx',
|
||||
},
|
||||
{
|
||||
points: seriesOf(samples, (s) => s.networkTxRate),
|
||||
color: 'var(--color-warn-400)',
|
||||
fill: false,
|
||||
label: 'tx',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-3">
|
||||
<div className="min-w-0 space-y-5 lg:col-span-2">
|
||||
<CurrentPlayersCard slug={slug} maxPlayers={server.maxPlayers} />
|
||||
<RecentActivityCard slug={slug} />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-5">
|
||||
<Card
|
||||
title="Current configuration"
|
||||
action={
|
||||
<Link to="/configuration" className="text-xs text-accent-400 hover:underline">
|
||||
View configuration
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{config ? <ConfigSummaryRows config={config} /> : <Spinner />}
|
||||
</Card>
|
||||
<Card
|
||||
title="Installed mods"
|
||||
action={
|
||||
<Link to="/mods" className="text-xs text-accent-400 hover:underline">
|
||||
Manage
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
{installedMods.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
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{installedMods.slice(0, 5).map((mod) => (
|
||||
<li key={mod.modId} className="truncate text-xs text-slate-ink">
|
||||
{mod.name ?? mod.modId}
|
||||
</li>
|
||||
))}
|
||||
{installedMods.length > 5 && (
|
||||
<li className="text-xs text-slate-dim">+ {installedMods.length - 5} more</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<OpsHealthCard user={user} slug={slug} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { CurrentUser, Role } from '@reforger-panel/shared';
|
||||
import { ROLES, ROLE_LABELS } from '@reforger-panel/shared';
|
||||
import {
|
||||
useActivity,
|
||||
useConfiguration,
|
||||
useKnownPlayers,
|
||||
useKillfeed,
|
||||
useLogHealth,
|
||||
usePlayers,
|
||||
useServers,
|
||||
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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlayersPage() {
|
||||
const slug = usePrimarySlug();
|
||||
if (!slug) return <Spinner />;
|
||||
return <PlayersBody slug={slug} />;
|
||||
}
|
||||
|
||||
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 sortedPlayers = useMemo(() => {
|
||||
const players = [...(known?.players ?? [])];
|
||||
players.sort((a, b) => {
|
||||
if (sort === 'online') {
|
||||
if (a.online !== b.online) return a.online ? -1 : 1;
|
||||
return b.lastSeenAt.localeCompare(a.lastSeenAt);
|
||||
}
|
||||
if (sort === 'last_seen') return b.lastSeenAt.localeCompare(a.lastSeenAt);
|
||||
if (sort === 'playtime') return b.totalPlaytimeSeconds - a.totalPlaytimeSeconds;
|
||||
if (sort === 'sessions') return b.totalSessions - a.totalSessions;
|
||||
return a.displayName.localeCompare(b.displayName);
|
||||
});
|
||||
return players;
|
||||
}, [known?.players, sort]);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-5">
|
||||
<h1 className="page-title">Players</h1>
|
||||
<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>
|
||||
}
|
||||
>
|
||||
{!known ? (
|
||||
<Spinner />
|
||||
) : known.players.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No players recorded yet"
|
||||
hint="Players are discovered from server log connect events."
|
||||
/>
|
||||
) : (
|
||||
<div className="data-table-scroll">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Player</th>
|
||||
<th>Identity</th>
|
||||
<th>Last seen</th>
|
||||
<th className="text-right">Sessions</th>
|
||||
<th className="text-right">Playtime</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
<td className="py-2 font-mono text-xs text-slate-dim">
|
||||
{player.externalPlayerId ? (
|
||||
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">
|
||||
{formatDuration(player.totalPlaytimeSeconds)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivityPage() {
|
||||
const slug = usePrimarySlug();
|
||||
if (!slug) return <Spinner />;
|
||||
return <ActivityBody slug={slug} />;
|
||||
}
|
||||
|
||||
export function KillfeedPage() {
|
||||
const slug = usePrimarySlug();
|
||||
if (!slug) return <Spinner />;
|
||||
return <KillfeedBody slug={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';
|
||||
}
|
||||
|
||||
function positionLabel(position: { x: number; y: number; z?: number | null } | null): string {
|
||||
if (!position) return 'position unknown';
|
||||
const z = typeof position.z === 'number' ? `, ${position.z.toFixed(0)}` : '';
|
||||
return `${position.x.toFixed(0)}, ${position.y.toFixed(0)}${z}`;
|
||||
}
|
||||
|
||||
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>
|
||||
<Card title="Recent kills">
|
||||
{isLoading || !data ? (
|
||||
<Spinner />
|
||||
) : data.events.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No kills recorded yet"
|
||||
hint="Killfeed requires ServerAdminTools kill event lines in the server log."
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{data.events.map((event) => (
|
||||
<li
|
||||
key={event.id}
|
||||
className="rounded-md border border-graphite-800 bg-graphite-950/20 px-3.5 py-3"
|
||||
>
|
||||
<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="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="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>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-dim">
|
||||
<span>{formatDateTime(event.occurredAt)}</span>
|
||||
<span>attacker {positionLabel(event.killerPosition)}</span>
|
||||
<span>victim {positionLabel(event.victimPosition)}</span>
|
||||
<span>
|
||||
distance{' '}
|
||||
{event.distanceMeters !== null ? `${event.distanceMeters.toFixed(0)} m` : '—'}
|
||||
</span>
|
||||
<span>weapon {event.weapon ?? '—'}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage({ user }: { user: CurrentUser }) {
|
||||
const isOwner = user.role === 'owner';
|
||||
const slug = usePrimarySlug();
|
||||
const { data: users } = useUsers(isOwner);
|
||||
const { data: workshop } = useWorkshopHealth();
|
||||
const { data: logs } = useLogHealth(slug ?? '', isOwner && slug !== null);
|
||||
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>
|
||||
|
||||
<Card title="Your account">
|
||||
<div className="flex items-center gap-3">
|
||||
{user.avatarUrl ? (
|
||||
<img
|
||||
src={user.avatarUrl}
|
||||
alt=""
|
||||
className="h-11 w-11 rounded-full border border-graphite-600"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-11 w-11 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>
|
||||
<p className="text-sm font-medium text-zinc-200">
|
||||
{user.displayName ?? user.username}{' '}
|
||||
<span className="text-slate-dim">({user.username})</span>
|
||||
</p>
|
||||
<RoleBadge role={user.role} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{isOwner && (
|
||||
<Card title="Users & roles">
|
||||
{!users ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{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"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{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">
|
||||
{panelUser.displayName ?? panelUser.username}
|
||||
</p>
|
||||
<p className="text-xs text-slate-dim">
|
||||
joined {formatDateTime(panelUser.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{panelUser.id === user.id ? (
|
||||
<RoleBadge role={panelUser.role} />
|
||||
) : (
|
||||
<select
|
||||
value={panelUser.role}
|
||||
onChange={(event) =>
|
||||
setRole.mutate({ userId: panelUser.id, role: event.target.value as Role })
|
||||
}
|
||||
className="input px-2 py-1 text-xs"
|
||||
>
|
||||
{ROLES.map((role) => (
|
||||
<option key={role} value={role}>
|
||||
{ROLE_LABELS[role]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isOwner && <InvitesCard />}
|
||||
|
||||
{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">
|
||||
<dt className="text-slate-ink">Pterodactyl</dt>
|
||||
<dd className="text-zinc-300">
|
||||
{logs?.configured ? 'configured' : 'mock / not configured'}
|
||||
</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>
|
||||
</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>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true,
|
||||
"useDefineForClassFields": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user