mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
Merge remote-tracking branch 'origin/march-2023-03-21' into deps/frontend-security-bumps
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user