Merge remote-tracking branch 'origin/march-2023-03-21' into deps/frontend-security-bumps

This commit is contained in:
Alexa
2026-08-02 01:03:57 +03:00
85 changed files with 29804 additions and 1617 deletions
@@ -1,25 +1,30 @@
import { Audit } from './Audit';
import { PlayerLogs } from './PlayerLogs';
import { TestCases } from './TestCases';
import { PageHeader } from '../components/PageHeader';
import { Tabs, useTabParam } from '../components/Tabs';
// Both log views in one place: the admin audit trail and the per-player
// HTTP request ring buffer. Old /audit and /logs routes redirect here.
// Internal operations views in one place: the admin audit trail, the
// per-player HTTP request ring buffer, and QA test cases with the GitHub
// issues filed against them. Old /audit and /logs routes redirect here.
export function Activity() {
const [tab, setTab] = useTabParam<'audit' | 'logs'>('audit', ['audit', 'logs']);
const [tab, setTab] = useTabParam<'audit' | 'logs' | 'testcases'>(
'audit', ['audit', 'logs', 'testcases']);
return (
<div>
<PageHeader title="Activity" blurb="Admin audit trail and per-player HTTP request logs." />
<PageHeader title="Activity" blurb="Admin audit trail, per-player HTTP request logs, and QA test cases." />
<Tabs
tabs={[
{ key: 'audit', label: 'Audit log' },
{ key: 'logs', label: 'Player requests' },
{ key: 'testcases', label: 'Test cases' },
]}
active={tab}
onChange={setTab}
/>
{tab === 'audit' && <Audit embedded />}
{tab === 'logs' && <PlayerLogs embedded />}
{tab === 'testcases' && <TestCases />}
</div>
);
}
+111 -2
View File
@@ -17,7 +17,7 @@ import { ArrowLeft, RefreshCw, Trash } from '../components/Icons';
// modals: general info edit, owner / co-owner / mod / host
// management, live photon instances, recent visitors, danger zone.
type Tab = 'general' | 'roles' | 'instances' | 'leaderboards' | 'visitors' | 'danger';
type Tab = 'general' | 'roles' | 'subrooms' | 'instances' | 'leaderboards' | 'visitors' | 'danger';
const ROLE_LABEL: Record<0 | 1 | 2, string> = {
0: 'Co-owner',
@@ -67,7 +67,7 @@ export function Room() {
<>
<RoomHeaderCard room={room} />
<div className="flex gap-1 mt-4 mb-3 overflow-x-auto border-b border-ink-800">
{(['general', 'roles', 'instances', 'leaderboards', 'visitors', 'danger'] as Tab[]).map(t => (
{(['general', 'roles', 'subrooms', 'instances', 'leaderboards', 'visitors', 'danger'] as Tab[]).map(t => (
<button
key={t}
onClick={() => setTab(t)}
@@ -79,6 +79,7 @@ export function Room() {
>
{t === 'general' ? 'General' :
t === 'roles' ? 'Roles & ownership' :
t === 'subrooms' ? 'Sub-rooms' :
t === 'instances' ? 'Live instances' :
t === 'leaderboards' ? 'Leaderboards' :
t === 'visitors' ? 'Recent visitors' :
@@ -89,6 +90,7 @@ export function Room() {
{tab === 'general' && <GeneralTab room={room} onSaved={refresh} />}
{tab === 'roles' && <RolesTab room={room} onChanged={refresh} />}
{tab === 'subrooms' && <SubRoomsTab roomId={room.id} roomCapacity={room.maxCapacity} />}
{tab === 'instances' && <InstancesTab roomId={room.id} />}
{tab === 'leaderboards' && <LeaderboardsTab roomId={room.id} />}
{tab === 'visitors' && <VisitorsTab roomId={room.id} />}
@@ -553,6 +555,113 @@ function TransferOwnerModal({ room, onClose, onTransferred }: {
// ── Instances tab — live photon matches in this room ────────────────────
interface SubRoom {
id: string;
subRoomId: number;
name: string;
maxPlayers: number;
canMatchmakeInto: boolean;
isSandbox: boolean;
dataBlobName: string;
}
// Per-sub-room player caps. Distinct from the room's own MaxCapacity, which is
// what the ROOM advertises and what matchmaking hands to
// RoomInstance.MaxCapacity — each sub-room caps itself separately, and the two
// are easy to conflate. The same value is editable in game by the room owner
// ("Max Player Count in This Subroom"); this reaches rooms an admin does not
// own.
function SubRoomsTab({ roomId, roomCapacity }: { roomId: number; roomCapacity: number }) {
const toast = useToast();
const { data, loading, error, refresh } = useApi<SubRoom[]>(`/rooms/${roomId}/subrooms`);
const [edits, setEdits] = useState<Record<number, string>>({});
const [saving, setSaving] = useState<number | null>(null);
const save = async (sub: SubRoom) => {
const raw = edits[sub.subRoomId];
const value = Number(raw);
if (!Number.isInteger(value) || value < 1 || value > 80) {
toast.push('Max players must be a whole number between 1 and 80', 'error');
return;
}
setSaving(sub.subRoomId);
try {
await api(`/rooms/${roomId}/subrooms/${sub.subRoomId}/maxplayers`, {
method: 'PUT',
body: { MaxPlayers: value },
});
toast.push(`${sub.name || `Sub-room ${sub.subRoomId}`} capped at ${value}`, 'success');
setEdits(prev => {
const next = { ...prev };
delete next[sub.subRoomId];
return next;
});
refresh();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setSaving(null);
}
};
return (
<div className="card overflow-hidden">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-ink-800">
<div className="text-xs text-ink-400">
Room advertises <span className="text-ink-200 font-medium">{roomCapacity}</span> — each
sub-room caps itself separately
</div>
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
</div>
{error && <div className="px-4 py-3 text-sm text-danger">{error}</div>}
{data && data.length === 0 && <Empty title="No sub-rooms" />}
{data && data.length > 0 && (
<ul className="divide-y divide-ink-800">
{data.map(sub => {
const pending = edits[sub.subRoomId];
const dirty = pending !== undefined && Number(pending) !== sub.maxPlayers;
return (
<li key={sub.id} className="px-4 py-3 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-ink-50">{sub.name || '(unnamed)'}</span>
<span className="badge-neutral">SubRoom {sub.subRoomId}</span>
{sub.isSandbox && <span className="badge-neutral">Sandbox</span>}
{!sub.canMatchmakeInto && <span className="badge-banned">No matchmaking</span>}
</div>
{sub.dataBlobName && (
<div className="text-xs text-ink-500 font-mono truncate mt-0.5">{sub.dataBlobName}</div>
)}
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-ink-400">Max players</label>
<input
type="number"
min={1}
max={80}
value={pending ?? String(sub.maxPlayers)}
onChange={e => setEdits(prev => ({ ...prev, [sub.subRoomId]: e.target.value }))}
className="input w-20 text-sm"
/>
<button
onClick={() => save(sub)}
disabled={!dirty || saving === sub.subRoomId}
className="btn-primary text-xs"
>
{saving === sub.subRoomId ? 'Saving…' : 'Save'}
</button>
</div>
</li>
);
})}
</ul>
)}
</div>
);
}
function InstancesTab({ roomId }: { roomId: number }) {
const toast = useToast();
const { data, loading, error, refresh } = useApi<RoomInstance[]>(`/rooms/${roomId}/instances`);
@@ -0,0 +1,193 @@
import { useState } from 'react';
import { api } from '../lib/api';
import { useApi } from '../lib/useApi';
import { PageHeader } from '../components/PageHeader';
import { Empty } from '../components/Empty';
import { useToast } from '../components/Toast';
import { RefreshCw, Trash } from '../components/Icons';
// QA test cases and the GitHub issues filed against them.
//
// Status values match the client's TestCaseStatus enum. Only Failed<->Passed
// are the reconciler's to move (a closed issue means fixed), so a Claimed case
// is never rewritten by a sync — see docs/admin.md.
const STATUS: Record<number, { label: string; className: string }> = {
0: { label: 'Not yet tested', className: 'badge-neutral' },
1: { label: 'Claimed', className: 'badge-neutral' },
2: { label: 'Failed', className: 'badge-banned' },
3: { label: 'Passed', className: 'badge-online' },
};
interface TestCaseRow {
id: string;
key: string;
title: string;
roomName: string;
status: number;
testPassId: number | null;
issueUrl: string;
issueNumber: number | null;
updatedAt: string;
}
interface TestCaseList {
githubConfigured: boolean;
repository: string;
cases: TestCaseRow[];
}
export function TestCases() {
const toast = useToast();
const [status, setStatus] = useState<number | ''>('');
const query = status === '' ? '' : `?status=${status}`;
const { data, loading, error, refresh } = useApi<TestCaseList>(`/testcases${query}`);
const [busy, setBusy] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
const fileIssue = async (row: TestCaseRow) => {
setBusy(row.id);
try {
const res = await api<{ issue: { number: number; url: string }; created: boolean }>(
`/testcases/${row.id}/issue`, { method: 'POST' },
);
toast.push(
res.created
? `Filed #${res.issue.number} for ${row.key}`
: `${row.key} is already linked to #${res.issue.number}`,
'success',
);
refresh();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(null);
}
};
const unlink = async (row: TestCaseRow) => {
setBusy(row.id);
try {
// Unlink only — the issue itself is deliberately left open.
await api(`/testcases/${row.id}/issue`, { method: 'DELETE' });
toast.push(`Unlinked ${row.key}`, 'success');
refresh();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setBusy(null);
}
};
const sync = async () => {
setSyncing(true);
try {
const res = await api<{ reconciled: number }>('/testcases/issues/sync', { method: 'POST' });
toast.push(
res.reconciled === 0 ? 'Everything already in sync' : `Updated ${res.reconciled} case(s)`,
'success',
);
refresh();
} catch (e) {
toast.push((e as Error).message, 'error');
} finally {
setSyncing(false);
}
};
return (
<div>
<PageHeader
title="Test cases"
actions={<>
<select
value={status}
onChange={e => setStatus(e.target.value === '' ? '' : Number(e.target.value))}
className="input text-xs"
>
<option value="">All statuses</option>
{Object.entries(STATUS).map(([value, s]) => (
<option key={value} value={value}>{s.label}</option>
))}
</select>
<button
onClick={sync}
className="btn-secondary text-xs"
disabled={syncing || !data?.githubConfigured}
title={data?.githubConfigured ? 'Reconcile linked cases against their issues' : 'GitHub is not configured'}
>
{syncing ? 'Syncing…' : 'Sync issues'}
</button>
<button onClick={refresh} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
</button>
</>}
/>
{/* A server with no GitHub token is an ordinary deployment, so this is a
note rather than an error. */}
{data && !data.githubConfigured && (
<div className="card px-4 py-3 text-sm text-ink-300 mb-4">
GitHub issue linking is off. Set <code className="font-mono">GitHub:Token</code> and{' '}
<code className="font-mono">GitHub:Repository</code> to enable it.
</div>
)}
{error && <div className="card px-4 py-3 text-sm text-danger mb-4">{error}</div>}
<div className="card overflow-hidden">
{data && data.cases.length === 0 && <Empty title="No test cases" />}
{data && data.cases.length > 0 && (
<ul className="divide-y divide-ink-800">
{data.cases.map(row => {
const badge = STATUS[row.status] ?? STATUS[0];
return (
<li key={row.id} className="px-4 py-3 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium text-ink-50 truncate">{row.title}</span>
<span className="badge-neutral font-mono">{row.key}</span>
<span className={badge.className}>{badge.label}</span>
{row.testPassId !== null && (
<span className="text-xs text-ink-500">pass {row.testPassId}</span>
)}
</div>
{row.roomName && <div className="text-xs text-ink-500 mt-0.5">{row.roomName}</div>}
</div>
<div className="flex items-center gap-2">
{row.issueNumber !== null ? (
<>
<a
href={row.issueUrl}
target="_blank"
rel="noreferrer"
className="btn-secondary text-xs"
>
#{row.issueNumber}
</a>
<button
onClick={() => unlink(row)}
disabled={busy === row.id}
className="btn-ghost text-xs text-danger"
>
<Trash /> Unlink
</button>
</>
) : (
<button
onClick={() => fileIssue(row)}
disabled={busy === row.id || !data.githubConfigured}
className="btn-primary text-xs"
title={data.githubConfigured ? 'File a GitHub issue' : 'GitHub is not configured'}
>
{busy === row.id ? 'Filing…' : 'File issue'}
</button>
)}
</div>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}