initial commit

This commit is contained in:
SowinskiBraeden committed 2026-07-05 16:54:59 -07:00
commit ce8f719a05
106 files changed
+24584

No files matched your search

+85
View File
@@ -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>
);
}
+90
View File
@@ -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>
);
}
+435
View File
@@ -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>
);
}
+181
View File
@@ -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>
);
}
+372
View File
@@ -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>
);
}