diff --git a/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseIssuesController.cs b/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseIssuesController.cs index f1c7ad7..78c81b2 100644 --- a/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseIssuesController.cs +++ b/DorkNet.Server/Controllers/API/TestCaseManagement/TestCaseIssuesController.cs @@ -36,6 +36,47 @@ public class TestCaseIssuesController( private const int StatusFailed = 2; private const int StatusPassed = 3; + + /// GET api/admin/v1/testcases — every test case with its + /// issue link, newest pass first. Optional ?passId= narrows to one + /// test pass and ?status= to one status. + /// + /// Exists because the admin SPA speaks /api/admin/v1 exclusively and + /// the client-facing test-case routes are shaped for the in-game QA tab + /// (per-pass, no issue fields). + [HttpGet("api/admin/v1/testcases")] + public async Task ListCases( + [FromQuery] uint? passId, [FromQuery] int? status, CancellationToken ct) + { + var query = db.TestCases.AsQueryable(); + if (passId is uint pass) query = query.Where(c => c.TestPassId == pass); + if (status is int s) query = query.Where(c => c.Status == s); + + var cases = await query + .OrderByDescending(c => c.TestPassId) + .ThenBy(c => c.Key) + .Take(500) + .ToListAsync(ct); + + return Ok(new + { + githubConfigured = github.IsConfigured, + repository = github.Repository, + cases = cases.Select(c => new + { + c.Id, + c.Key, + c.Title, + c.RoomName, + c.Status, + c.TestPassId, + issueUrl = c.JiraBugUrl, + issueNumber = GitHubIssueService.IssueNumberFromUrl(c.JiraBugUrl), + c.UpdatedAt, + }).ToList(), + }); + } + /// POST api/testcasemanagement/v1/testcase/{id}/issue — /// file a GitHub issue for a test case and link it. /// @@ -44,6 +85,7 @@ public class TestCaseIssuesController( /// pass is therefore safe, which is the whole point — the alternative is a /// tester filing the same bug on every run. [HttpPost("api/testcasemanagement/v1/testcase/{id}/issue")] + [HttpPost("api/admin/v1/testcases/{id}/issue")] public async Task CreateIssue(string id, CancellationToken ct) { if (!github.IsConfigured) @@ -81,6 +123,7 @@ public class TestCaseIssuesController( /// unlink, leaving the issue itself alone. Closing someone's issue as a /// side effect of tidying a QA link would be the wrong call. [HttpDelete("api/testcasemanagement/v1/testcase/{id}/issue")] + [HttpDelete("api/admin/v1/testcases/{id}/issue")] public async Task UnlinkIssue(string id, CancellationToken ct) { var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id, ct); @@ -112,6 +155,7 @@ public class TestCaseIssuesController( /// change, so a case a tester has Claimed, or one nobody has run yet, is /// never rewritten underneath them. [HttpPost("api/testcasemanagement/v1/issues/sync")] + [HttpPost("api/admin/v1/testcases/issues/sync")] public async Task Sync(CancellationToken ct) { if (!github.IsConfigured) diff --git a/DorkNet.Server/admin-ui/src/pages/Activity.tsx b/DorkNet.Server/admin-ui/src/pages/Activity.tsx index 9063dc9..d67aff8 100644 --- a/DorkNet.Server/admin-ui/src/pages/Activity.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Activity.tsx @@ -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 (
- + {tab === 'audit' && } {tab === 'logs' && } + {tab === 'testcases' && }
); } diff --git a/DorkNet.Server/admin-ui/src/pages/Room.tsx b/DorkNet.Server/admin-ui/src/pages/Room.tsx index 3a13e34..d913733 100644 --- a/DorkNet.Server/admin-ui/src/pages/Room.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Room.tsx @@ -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() { <>
- {(['general', 'roles', 'instances', 'leaderboards', 'visitors', 'danger'] as Tab[]).map(t => ( + {(['general', 'roles', 'subrooms', 'instances', 'leaderboards', 'visitors', 'danger'] as Tab[]).map(t => ( +
+ {error &&
{error}
} + {data && data.length === 0 && } + {data && data.length > 0 && ( +
    + {data.map(sub => { + const pending = edits[sub.subRoomId]; + const dirty = pending !== undefined && Number(pending) !== sub.maxPlayers; + return ( +
  • +
    +
    + {sub.name || '(unnamed)'} + SubRoom {sub.subRoomId} + {sub.isSandbox && Sandbox} + {!sub.canMatchmakeInto && No matchmaking} +
    + {sub.dataBlobName && ( +
    {sub.dataBlobName}
    + )} +
    +
    + + setEdits(prev => ({ ...prev, [sub.subRoomId]: e.target.value }))} + className="input w-20 text-sm" + /> + +
    +
  • + ); + })} +
+ )} + + ); +} + function InstancesTab({ roomId }: { roomId: number }) { const toast = useToast(); const { data, loading, error, refresh } = useApi(`/rooms/${roomId}/instances`); diff --git a/DorkNet.Server/admin-ui/src/pages/TestCases.tsx b/DorkNet.Server/admin-ui/src/pages/TestCases.tsx new file mode 100644 index 0000000..7eec33f --- /dev/null +++ b/DorkNet.Server/admin-ui/src/pages/TestCases.tsx @@ -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 = { + 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(''); + const query = status === '' ? '' : `?status=${status}`; + const { data, loading, error, refresh } = useApi(`/testcases${query}`); + const [busy, setBusy] = useState(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 ( +
+ + + + + } + /> + + {/* A server with no GitHub token is an ordinary deployment, so this is a + note rather than an error. */} + {data && !data.githubConfigured && ( +
+ GitHub issue linking is off. Set GitHub:Token and{' '} + GitHub:Repository to enable it. +
+ )} + {error &&
{error}
} + +
+ {data && data.cases.length === 0 && } + {data && data.cases.length > 0 && ( +
    + {data.cases.map(row => { + const badge = STATUS[row.status] ?? STATUS[0]; + return ( +
  • +
    +
    + {row.title} + {row.key} + {badge.label} + {row.testPassId !== null && ( + pass {row.testPassId} + )} +
    + {row.roomName &&
    {row.roomName}
    } +
    +
    + {row.issueNumber !== null ? ( + <> + + #{row.issueNumber} + + + + ) : ( + + )} +
    +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/docs/admin.md b/docs/admin.md index 6bc43fe..05546e3 100644 --- a/docs/admin.md +++ b/docs/admin.md @@ -56,7 +56,7 @@ that outer check. |---|---|---| | Overview | `/` | Live ops dashboard, online players, active sessions, quick kick/ban/broadcast actions | | Moderation | `/players` | Player directory, bans, reports, per-player ban/grant/gift/password/avatar/account actions | -| Activity | `/activity` | Admin audit log and per-player request logs | +| Activity | `/activity` | Admin audit log, per-player request logs, QA test cases + GitHub issue linking | | Content | `/rooms`, `/rooms/:id`, `/import-room`, `/content` | Room list/detail, room import, instances, leaderboards, community board, loading tips, 3D Charades word lists | | Operations | `/broadcast`, `/settings` | Server broadcast, server toggles (signups, everyone-is-friends, profanity filter, imported-room version clamp), signup codes, weekly challenges, Play menu tags, Rec Center doors, game config values | @@ -193,6 +193,12 @@ QA test cases can file and track GitHub issues. Configure with: endpoints answer `503` with the reason, and the background reconciler logs once and idles. A server with no GitHub credentials is a normal deployment. +UI: **Activity → Test cases** (`/activity?tab=testcases`). File or unlink an +issue per case, or reconcile them all. The buttons disable themselves with an +explanation when GitHub is unconfigured rather than failing on click. + +Sub-room caps live on the room page: **Rooms → a room → Sub-rooms**. + Endpoints (admin-gated — these are not part of the 2023 client's surface): - `POST api/testcasemanagement/v1/testcase/{id}/issue` — file and link. @@ -206,6 +212,11 @@ Endpoints (admin-gated — these are not part of the 2023 client's surface): - `POST api/testcasemanagement/v1/issues/sync` — reconcile now. The background service runs the same code path on its interval, so manual and automatic cannot drift. +- `GET api/admin/v1/testcases[?passId=&status=]` — the list the SPA renders, + with each case's issue number and link. + +Each of the three also answers under `/api/admin/v1/testcases/...`, because the +admin SPA speaks that prefix exclusively. ### Where the link is stored