mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
feat(admin-ui): sub-room caps and test-case issue linking
Two admin screens for work that previously had server support but no way to reach it. Rooms → a room → Sub-rooms: per-sub-room max players, with the room's own advertised capacity shown alongside so the two are not mistaken for each other. Each row also shows whether the sub-room is a matchmaking target and which blob it loads. Activity → Test cases: file a GitHub issue against a case, follow the link, or unlink it, plus a reconcile button. Filing is idempotent server-side, so the button is safe to press twice. When GitHub is unconfigured the actions disable themselves and the page explains why rather than failing on click — a server with no token is an ordinary deployment. Both go in as tabs on the existing hub pages rather than new sidebar entries, matching how the nav is organised. Server side adds GET api/admin/v1/testcases (the list the SPA renders, with each case's issue number) and mirrors the three issue routes under /api/admin/v1/testcases/... because the SPA's client speaks that prefix exclusively. I had told the user this UI was not possible on this branch because wwwroot/admin holds a built bundle. That was wrong — the source is in DorkNet.Server/admin-ui and builds to it.
This commit is contained in:
@@ -36,6 +36,47 @@ public class TestCaseIssuesController(
|
|||||||
private const int StatusFailed = 2;
|
private const int StatusFailed = 2;
|
||||||
private const int StatusPassed = 3;
|
private const int StatusPassed = 3;
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>GET <c>api/admin/v1/testcases</c> — every test case with its
|
||||||
|
/// issue link, newest pass first. Optional <c>?passId=</c> narrows to one
|
||||||
|
/// test pass and <c>?status=</c> to one status.
|
||||||
|
///
|
||||||
|
/// Exists because the admin SPA speaks <c>/api/admin/v1</c> exclusively and
|
||||||
|
/// the client-facing test-case routes are shaped for the in-game QA tab
|
||||||
|
/// (per-pass, no issue fields).</summary>
|
||||||
|
[HttpGet("api/admin/v1/testcases")]
|
||||||
|
public async Task<IActionResult> 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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>POST <c>api/testcasemanagement/v1/testcase/{id}/issue</c> —
|
/// <summary>POST <c>api/testcasemanagement/v1/testcase/{id}/issue</c> —
|
||||||
/// file a GitHub issue for a test case and link it.
|
/// 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
|
/// pass is therefore safe, which is the whole point — the alternative is a
|
||||||
/// tester filing the same bug on every run.</summary>
|
/// tester filing the same bug on every run.</summary>
|
||||||
[HttpPost("api/testcasemanagement/v1/testcase/{id}/issue")]
|
[HttpPost("api/testcasemanagement/v1/testcase/{id}/issue")]
|
||||||
|
[HttpPost("api/admin/v1/testcases/{id}/issue")]
|
||||||
public async Task<IActionResult> CreateIssue(string id, CancellationToken ct)
|
public async Task<IActionResult> CreateIssue(string id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!github.IsConfigured)
|
if (!github.IsConfigured)
|
||||||
@@ -81,6 +123,7 @@ public class TestCaseIssuesController(
|
|||||||
/// unlink, leaving the issue itself alone. Closing someone's issue as a
|
/// unlink, leaving the issue itself alone. Closing someone's issue as a
|
||||||
/// side effect of tidying a QA link would be the wrong call.</summary>
|
/// side effect of tidying a QA link would be the wrong call.</summary>
|
||||||
[HttpDelete("api/testcasemanagement/v1/testcase/{id}/issue")]
|
[HttpDelete("api/testcasemanagement/v1/testcase/{id}/issue")]
|
||||||
|
[HttpDelete("api/admin/v1/testcases/{id}/issue")]
|
||||||
public async Task<IActionResult> UnlinkIssue(string id, CancellationToken ct)
|
public async Task<IActionResult> UnlinkIssue(string id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var tc = await db.TestCases.FirstOrDefaultAsync(c => c.Id == id, 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
|
/// change, so a case a tester has Claimed, or one nobody has run yet, is
|
||||||
/// never rewritten underneath them.</summary>
|
/// never rewritten underneath them.</summary>
|
||||||
[HttpPost("api/testcasemanagement/v1/issues/sync")]
|
[HttpPost("api/testcasemanagement/v1/issues/sync")]
|
||||||
|
[HttpPost("api/admin/v1/testcases/issues/sync")]
|
||||||
public async Task<IActionResult> Sync(CancellationToken ct)
|
public async Task<IActionResult> Sync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!github.IsConfigured)
|
if (!github.IsConfigured)
|
||||||
|
|||||||
@@ -1,25 +1,30 @@
|
|||||||
import { Audit } from './Audit';
|
import { Audit } from './Audit';
|
||||||
import { PlayerLogs } from './PlayerLogs';
|
import { PlayerLogs } from './PlayerLogs';
|
||||||
|
import { TestCases } from './TestCases';
|
||||||
import { PageHeader } from '../components/PageHeader';
|
import { PageHeader } from '../components/PageHeader';
|
||||||
import { Tabs, useTabParam } from '../components/Tabs';
|
import { Tabs, useTabParam } from '../components/Tabs';
|
||||||
|
|
||||||
// Both log views in one place: the admin audit trail and the per-player
|
// Internal operations views in one place: the admin audit trail, the
|
||||||
// HTTP request ring buffer. Old /audit and /logs routes redirect here.
|
// 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() {
|
export function Activity() {
|
||||||
const [tab, setTab] = useTabParam<'audit' | 'logs'>('audit', ['audit', 'logs']);
|
const [tab, setTab] = useTabParam<'audit' | 'logs' | 'testcases'>(
|
||||||
|
'audit', ['audit', 'logs', 'testcases']);
|
||||||
return (
|
return (
|
||||||
<div>
|
<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
|
||||||
tabs={[
|
tabs={[
|
||||||
{ key: 'audit', label: 'Audit log' },
|
{ key: 'audit', label: 'Audit log' },
|
||||||
{ key: 'logs', label: 'Player requests' },
|
{ key: 'logs', label: 'Player requests' },
|
||||||
|
{ key: 'testcases', label: 'Test cases' },
|
||||||
]}
|
]}
|
||||||
active={tab}
|
active={tab}
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
/>
|
/>
|
||||||
{tab === 'audit' && <Audit embedded />}
|
{tab === 'audit' && <Audit embedded />}
|
||||||
{tab === 'logs' && <PlayerLogs embedded />}
|
{tab === 'logs' && <PlayerLogs embedded />}
|
||||||
|
{tab === 'testcases' && <TestCases />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { ArrowLeft, RefreshCw, Trash } from '../components/Icons';
|
|||||||
// modals: general info edit, owner / co-owner / mod / host
|
// modals: general info edit, owner / co-owner / mod / host
|
||||||
// management, live photon instances, recent visitors, danger zone.
|
// 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> = {
|
const ROLE_LABEL: Record<0 | 1 | 2, string> = {
|
||||||
0: 'Co-owner',
|
0: 'Co-owner',
|
||||||
@@ -67,7 +67,7 @@ export function Room() {
|
|||||||
<>
|
<>
|
||||||
<RoomHeaderCard room={room} />
|
<RoomHeaderCard room={room} />
|
||||||
<div className="flex gap-1 mt-4 mb-3 overflow-x-auto border-b border-ink-800">
|
<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
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setTab(t)}
|
onClick={() => setTab(t)}
|
||||||
@@ -79,6 +79,7 @@ export function Room() {
|
|||||||
>
|
>
|
||||||
{t === 'general' ? 'General' :
|
{t === 'general' ? 'General' :
|
||||||
t === 'roles' ? 'Roles & ownership' :
|
t === 'roles' ? 'Roles & ownership' :
|
||||||
|
t === 'subrooms' ? 'Sub-rooms' :
|
||||||
t === 'instances' ? 'Live instances' :
|
t === 'instances' ? 'Live instances' :
|
||||||
t === 'leaderboards' ? 'Leaderboards' :
|
t === 'leaderboards' ? 'Leaderboards' :
|
||||||
t === 'visitors' ? 'Recent visitors' :
|
t === 'visitors' ? 'Recent visitors' :
|
||||||
@@ -89,6 +90,7 @@ export function Room() {
|
|||||||
|
|
||||||
{tab === 'general' && <GeneralTab room={room} onSaved={refresh} />}
|
{tab === 'general' && <GeneralTab room={room} onSaved={refresh} />}
|
||||||
{tab === 'roles' && <RolesTab room={room} onChanged={refresh} />}
|
{tab === 'roles' && <RolesTab room={room} onChanged={refresh} />}
|
||||||
|
{tab === 'subrooms' && <SubRoomsTab roomId={room.id} roomCapacity={room.maxCapacity} />}
|
||||||
{tab === 'instances' && <InstancesTab roomId={room.id} />}
|
{tab === 'instances' && <InstancesTab roomId={room.id} />}
|
||||||
{tab === 'leaderboards' && <LeaderboardsTab roomId={room.id} />}
|
{tab === 'leaderboards' && <LeaderboardsTab roomId={room.id} />}
|
||||||
{tab === 'visitors' && <VisitorsTab 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 ────────────────────
|
// ── 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 }) {
|
function InstancesTab({ roomId }: { roomId: number }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { data, loading, error, refresh } = useApi<RoomInstance[]>(`/rooms/${roomId}/instances`);
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+12
-1
@@ -56,7 +56,7 @@ that outer check.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Overview | `/` | Live ops dashboard, online players, active sessions, quick kick/ban/broadcast actions |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
endpoints answer `503` with the reason, and the background reconciler logs once
|
||||||
and idles. A server with no GitHub credentials is a normal deployment.
|
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):
|
Endpoints (admin-gated — these are not part of the 2023 client's surface):
|
||||||
|
|
||||||
- `POST api/testcasemanagement/v1/testcase/{id}/issue` — file and link.
|
- `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
|
- `POST api/testcasemanagement/v1/issues/sync` — reconcile now. The background
|
||||||
service runs the same code path on its interval, so manual and automatic
|
service runs the same code path on its interval, so manual and automatic
|
||||||
cannot drift.
|
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
|
### Where the link is stored
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user