Add owner-only operational counts dashboard
This commit is contained in:
1 parent
53a1ba77a1
commit
c6784468a1
10 files changed
+234
-1
No files matched your search
@@ -0,0 +1,16 @@
|
||||
.admin-operations-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.admin-operations-grid > div {
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
background: #171c16;
|
||||
}
|
||||
|
||||
.admin-operations-grid dt { color: #b7bbaf; font-size: 0.9rem; }
|
||||
.admin-operations-grid dd { margin: 0.35rem 0 0; font-size: 1.2rem; overflow-wrap: anywhere; }
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ClientRequestError, requestJson } from '@/lib/client-request';
|
||||
import type { AdminOperations } from '@/db/admin-operations';
|
||||
import '../admin.css';
|
||||
import './operations.css';
|
||||
|
||||
type Account = { id: string; role: 'owner' | 'operator' | 'viewer' };
|
||||
type Snapshot = AdminOperations & { revision: string };
|
||||
const message = (error: unknown) => error instanceof Error ? error.message : 'Operational status is unavailable.';
|
||||
|
||||
export default function OperationsPage() {
|
||||
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
requestJson<{ admin: Account }>('/api/admin/session', { cache: 'no-store' })
|
||||
.then(async ({ admin }) => {
|
||||
if (admin.role !== 'owner') throw new Error('Operations are available only to an owner.');
|
||||
const result = await requestJson<Snapshot>('/api/admin/operations', {
|
||||
cache: 'no-store', headers: { 'X-Dune-Admin-Id': admin.id },
|
||||
});
|
||||
if (!canceled) { setSnapshot(result); setNotice(''); }
|
||||
})
|
||||
.catch(error => {
|
||||
if (canceled) return;
|
||||
setSnapshot(null);
|
||||
setNotice(error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)
|
||||
? 'Owner access changed or expired. Sign in to administration again.' : message(error));
|
||||
})
|
||||
.finally(() => { if (!canceled) setLoading(false); });
|
||||
return () => { canceled = true; };
|
||||
}, [refresh]);
|
||||
|
||||
return <main className="admin-shell">
|
||||
<header className="admin-header"><div><a className="admin-return" href="/admin">Administration</a><h1>Operations</h1></div>
|
||||
<Button variant="outline" disabled={loading} onClick={() => { setSnapshot(null); setLoading(true); setRefresh(value => value + 1); }}>Refresh status</Button></header>
|
||||
<p>Owner-only, read-only counts from the current database and the revision baked into this server build. Refresh to take a new sample; no monitoring or automatic repair runs here.</p>
|
||||
{notice && <p role="alert" className="admin-notice">{notice}</p>}
|
||||
{loading ? <output>Checking owner access and database…</output> : snapshot ? <section aria-labelledby="operations-sample">
|
||||
<h2 id="operations-sample">Sample at {new Date(snapshot.observedAt).toLocaleString()}</h2>
|
||||
<dl className="admin-operations-grid">
|
||||
<div><dt>Server build revision</dt><dd>{snapshot.revision}</dd></div>
|
||||
<div><dt>Saved rooms (including removed/archived)</dt><dd>{snapshot.rooms}</dd></div>
|
||||
<div><dt>Removed rooms</dt><dd>{snapshot.removedRooms}</dd></div>
|
||||
<div><dt>Archived rooms</dt><dd>{snapshot.archivedRooms}</dd></div>
|
||||
<div><dt>Paused rooms</dt><dd>{snapshot.pausedRooms}</dd></div>
|
||||
<div><dt>Closed rooms</dt><dd>{snapshot.closedRooms}</dd></div>
|
||||
<div><dt>Active seat credentials</dt><dd>{snapshot.activeSeats}</dd></div>
|
||||
<div><dt>Invalid JSON room states</dt><dd>{snapshot.unreadableRooms}</dd></div>
|
||||
<div><dt>Latest room write</dt><dd>{snapshot.lastRoomChange === null ? 'No rooms' : new Date(snapshot.lastRoomChange).toLocaleString()}</dd></div>
|
||||
<div><dt>Room backup snapshots</dt><dd>{snapshot.backupSnapshots}</dd></div>
|
||||
<div><dt>Backup snapshot payload bytes</dt><dd>{snapshot.backupBytes.toLocaleString()}</dd></div>
|
||||
<div><dt>Recorded private exports</dt><dd>{snapshot.backupDownloads}</dd></div>
|
||||
</dl>
|
||||
<p>These counters do not check SQLite integrity, disk free space, backup recoverability, stalled decisions or server health outside this request. The invalid JSON count detects syntax only, not rule-level save validity; this page cannot repair a room.</p>
|
||||
</section> : <p><a className="admin-room-link" href="/admin">Sign in to administration</a></p>}
|
||||
</main>;
|
||||
}
|
||||
@@ -125,6 +125,7 @@ export default function Administration() {
|
||||
<div className="admin-section-heading"><div><h2 id="admin-rooms-title">Rooms</h2><p className="admin-secondary">Public room details. Cards, sealed choices and private discussion stay hidden.</p></div>
|
||||
<div className="admin-room-actions">{account.role !== 'viewer' && <Button disabled={controlsBusy} onClick={() => { setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(true); }}>Create a room</Button>}
|
||||
{account.role === 'owner' && <a className="admin-room-link" href="/admin/backups">Room backups</a>}
|
||||
{account.role === 'owner' && <a className="admin-room-link" href="/admin/operations">Operations</a>}
|
||||
<Button variant="outline" disabled={controlsBusy} onClick={() => void load(applied, directory?.page ?? 1)}><RefreshCw aria-hidden="true" /> Refresh</Button></div></div>
|
||||
{creatingRoom && account.role !== 'viewer' && <AdminRoomCreation key={account.id} accountId={account.id}
|
||||
onClose={() => setCreatingRoom(false)} onDenied={accessChanged} onBusyChange={setOperationBusy}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { AdminError, requireAdmin } from '@/db/admin-access';
|
||||
import { readAdminOperations } from '@/db/admin-operations';
|
||||
import { adminFailure, adminResponse, adminToken } from '@/lib/admin-http';
|
||||
import { buildRevision } from '@/lib/build-revision';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const identity = await requireAdmin(env.DB, adminToken(request), ['owner']);
|
||||
const expected = request.headers.get('X-Dune-Admin-Id');
|
||||
if (expected && expected !== identity.id)
|
||||
throw new AdminError('The signed-in administrator changed. Reload operations.', 401);
|
||||
const counts = await readAdminOperations(env.DB, identity);
|
||||
return adminResponse({ revision: buildRevision, ...counts });
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { AdminError, type AdminIdentity } from './admin-access';
|
||||
|
||||
export type AdminOperations = {
|
||||
observedAt: number;
|
||||
rooms: number;
|
||||
removedRooms: number;
|
||||
archivedRooms: number;
|
||||
pausedRooms: number;
|
||||
closedRooms: number;
|
||||
activeSeats: number;
|
||||
unreadableRooms: number;
|
||||
lastRoomChange: number | null;
|
||||
backupSnapshots: number;
|
||||
backupBytes: number;
|
||||
backupDownloads: number;
|
||||
};
|
||||
|
||||
type Counts = Omit<AdminOperations, 'observedAt'>;
|
||||
|
||||
/** Aggregate only operational counters, never serialized game or private backup rows. */
|
||||
export async function readAdminOperations(database: D1Database, identity: AdminIdentity, now = Date.now()): Promise<AdminOperations> {
|
||||
const row = await database.prepare(`SELECT
|
||||
(SELECT COUNT(*) FROM rooms) rooms,
|
||||
(SELECT COUNT(*) FROM rooms r JOIN room_removals m ON m.room_code=r.code WHERE m.removed=1) removedRooms,
|
||||
(SELECT COUNT(*) FROM rooms r JOIN room_archives a ON a.room_code=r.code WHERE a.archived=1) archivedRooms,
|
||||
(SELECT COUNT(*) FROM rooms r JOIN room_controls c ON c.room_code=r.code WHERE c.paused=1) pausedRooms,
|
||||
(SELECT COUNT(*) FROM rooms r JOIN room_closures c ON c.room_code=r.code WHERE c.closed=1) closedRooms,
|
||||
(SELECT COUNT(*) FROM seats WHERE revoked=0) activeSeats,
|
||||
(SELECT COUNT(*) FROM rooms WHERE json_valid(state)=0) unreadableRooms,
|
||||
(SELECT MAX(updated_at) FROM rooms) lastRoomChange,
|
||||
(SELECT COUNT(*) FROM admin_room_backups) backupSnapshots,
|
||||
(SELECT COALESCE(SUM(size_bytes),0) FROM admin_room_backups) backupBytes,
|
||||
(SELECT COUNT(*) FROM admin_room_backup_downloads) backupDownloads
|
||||
WHERE EXISTS (SELECT 1 FROM admin_sessions s JOIN admin_accounts a ON a.id=s.admin_id
|
||||
WHERE s.token_hash=? AND a.id=? AND a.enabled=1 AND a.role='owner'
|
||||
AND s.revoked_at IS NULL AND s.expires_at>? AND s.generation=a.session_generation)`)
|
||||
.bind(identity.sessionHash, identity.id, now).first<Counts>();
|
||||
if (!row) throw new AdminError('Owner sign-in required.', 403);
|
||||
return { observedAt: now, ...row };
|
||||
}
|
||||
+7
-1
@@ -15,7 +15,7 @@ recovery and persistence systems where suitable.
|
||||
| Lifecycle and removal | [Pause/resume and joining locks](ADMIN_ROOM_CONTROLS.md), [administrative close/reopen](ADMIN_ROOM_CLOSURE.md) and [recoverable removal/restoration](ADMIN_ROOM_REMOVAL.md) have controls, durable audit, exact retries and player/AI enforcement. [Archive/unarchive](ADMIN_ROOM_ARCHIVE.md) has separate directory filters and exact retries. Permanent deletion and bulk actions remain missing. | Prototyped, partial |
|
||||
| Participant support | [Participant AI](ADMIN_PARTICIPANT_AI.md) enables an existing difficulty on an eligible human seat in a paused game, retaining access/takeback and private custody. [Discussion controls](ADMIN_DISCUSSION.md) mute/unmute new sends while retaining history and gameplay. Participant removal, access revocation, assisted recovery and further replacement remain. Existing lobby controls can reassign the host. | Prototyped, partial |
|
||||
| Saved-game operations | [Owner-only room backups](ADMIN_BACKUPS.md) capture/list/download a size-limited, version-fenced room snapshot without changing play or access; import validation, safe checkpoint restore and interrupted-work diagnosis/resume remain missing. | Prototyped, partial |
|
||||
| Operations and audit | Show server/build/storage health, room/player counts and actionable errors; provide maintenance controls and a searchable record of administrator actions. | Missing |
|
||||
| Operations and audit | The owner-only [operations page](../app/admin/operations/page.tsx) samples build revision, room/seat flags, JSON syntax failures and backup/export counts without exposing game contents. Searchable action history, failed-attempt records, integrity/disk checks, stalled-decision diagnosis and maintenance controls remain. | Prototyped, partial |
|
||||
|
||||
Track each area through Missing, Prototyped, Integrated, Verified and Polished.
|
||||
As implementation begins, link its controls, server actions, persistence,
|
||||
@@ -64,6 +64,12 @@ state and related room-owned records without pausing or editing the room. The
|
||||
metadata directory omits private contents; downloaded JSON contains secret
|
||||
game state and hashed seat/recovery credentials. Import/restore are not available.
|
||||
|
||||
The owner-only [operations page](../app/admin/operations/page.tsx) provides
|
||||
manual read-only samples of room lifecycle counts, active seat credentials,
|
||||
invalid JSON saves, latest room write, backup payload usage and export receipts.
|
||||
The database query repeats current owner authority. This is not an integrity
|
||||
check, disk-capacity monitor, restore validation or automated repair.
|
||||
|
||||
The directory uses an explicit field allowlist, independent of any player view.
|
||||
It never returns hands, Traitors, predictions, plans, private messages, recovery
|
||||
records or raw saved state. Searches inspect only codes and public player names.
|
||||
|
||||
@@ -39,6 +39,13 @@ limits, missing import/restore and absent unified action-history entries keep
|
||||
the saved-game operations gate open. Local isolated capture/export and a
|
||||
signed-out browser denial passed; this is not deployed evidence.
|
||||
|
||||
The owner-only [read-only operations sample](ADMIN_PANEL.md) now exposes the
|
||||
build revision, saved-room and active-seat counts, lifecycle flags, invalid
|
||||
JSON room counts, latest room write and backup/export usage. Live SQL owner
|
||||
authorization and desktop/mobile local UI checks passed. It does not inspect
|
||||
SQLite integrity, disk space, stalled turns or restore safety; no live NAS
|
||||
deployment or production administrator acceptance is claimed.
|
||||
|
||||
The bounded [Fremen Nexus Cunning](NEXUS_FREMEN_RULES.md#connected-native-cunning-path)
|
||||
prototype connects an initially empty worm's public offer, one spent private
|
||||
card, a Karama response, a typed remote ride after the Nexus, human controls
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 28 September 2026 — owner-only operational counts
|
||||
|
||||
`/admin/operations` now samples the build revision and live database aggregates:
|
||||
saved/removed/archived/paused/closed rooms, active seat credentials, syntactically
|
||||
invalid JSON saves, last room write, backup snapshot bytes/count and committed
|
||||
private exports. Its read query repeats owner authority in SQL and returns no
|
||||
saved state, seat token, message, backup payload or audit reason. The page is
|
||||
read-only and refreshes only on request; it makes no integrity, disk-capacity,
|
||||
repair or stalled-decision claim. Two isolated D1 tests pass for counts, live
|
||||
demotion/revocation and unchanged saved games. The production HTTP suite
|
||||
passed 54/54 with anonymous/forged-session denial, and the build includes the
|
||||
new route/page. A real local signed-out browser denied access; desktop and
|
||||
390-pixel samples rendered correctly using **mocked owner API responses** only,
|
||||
not an authenticated production owner. Full administrator operations and
|
||||
deployed acceptance remain gated; no production data was changed.
|
||||
|
||||
## 28 September 2026 — owner-only room backup capture
|
||||
|
||||
The new `/admin/backups` page lets an authenticated owner capture, list and
|
||||
|
||||
@@ -39,3 +39,16 @@ void test('room-host and forged administrator credentials cannot list, create or
|
||||
assert.deepEqual(Object.keys(await response.json()), ['error']);
|
||||
}
|
||||
});
|
||||
|
||||
void test('anonymous and forged administrator session cannot read operational counters', async () => {
|
||||
const credentials: Record<string, string>[] = [
|
||||
{},
|
||||
{ cookie: 'dune_admin_session=' + 'a'.repeat(64), 'X-Dune-Admin-Id': backupId },
|
||||
];
|
||||
for (const headers of credentials) {
|
||||
const response = await fetch(base + '/api/admin/operations', { headers, signal: AbortSignal.timeout(15_000) });
|
||||
assert.equal(response.status, 401);
|
||||
assert.equal(response.headers.get('cache-control'), 'no-store');
|
||||
assert.deepEqual(Object.keys(await response.json()), ['error']);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import test from 'node:test';
|
||||
import { adminLogin, requireAdmin, AdminError } from '../db/admin-access';
|
||||
import { readAdminOperations } from '../db/admin-operations';
|
||||
import { adminStore } from './admin-access-fixture';
|
||||
|
||||
const now = 10_000;
|
||||
|
||||
void test('owner operations sample counts saved states and backup provenance without leaking contents or changing rooms', async () => {
|
||||
const store = adminStore();
|
||||
try {
|
||||
const owner = store.provision('owner');
|
||||
const login = await adminLogin(store.database, owner.key, now);
|
||||
const identity = await requireAdmin(store.database, login.token, ['owner'], now);
|
||||
store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)').run('OPERAABC', '{"private":"sealed card"}', 12, 9000);
|
||||
store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)').run('OPERAABD', '{broken private state', 3, 9200);
|
||||
store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,0)').run('private-session', 'OPERAABC', 'one');
|
||||
store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,1)').run('revoked-session', 'OPERAABC', 'two');
|
||||
store.sqlite.prepare('INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES(?,1,1,?,?)').run('OPERAABD', now, now);
|
||||
store.sqlite.prepare('INSERT INTO room_archives(room_code,archived,revision,archived_at,updated_at) VALUES(?,1,1,?,?)').run('OPERAABD', now, now);
|
||||
store.sqlite.prepare('INSERT INTO room_controls(room_code,paused,join_locked,revision,updated_at) VALUES(?,1,0,1,?)').run('OPERAABC', now);
|
||||
store.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES(?,1,1,?,?)').run('OPERAABD', now, now);
|
||||
const backupId = randomUUID();
|
||||
store.sqlite.prepare(`INSERT INTO admin_room_backups
|
||||
(operation_id,actor_admin_id,room_code,request_hash,room_version,created_at,size_bytes,digest,payload,reason)
|
||||
VALUES(?,?,?,'request',12,?,14,'digest','secret payload','private reason')`).run(backupId, owner.id, 'OPERAABC', now);
|
||||
store.sqlite.prepare('INSERT INTO admin_room_backup_downloads(download_id,operation_id,actor_admin_id,room_code,created_at) VALUES(?,?,?,?,?)').run(randomUUID(), backupId, owner.id, 'OPERAABC', now);
|
||||
const before = store.sqlite.prepare('SELECT code,state,version FROM rooms ORDER BY code').all();
|
||||
const result = await readAdminOperations(store.database, identity, now);
|
||||
assert.deepEqual(result, {
|
||||
observedAt: now, rooms: 2, removedRooms: 1, archivedRooms: 1,
|
||||
pausedRooms: 1, closedRooms: 1, activeSeats: 1, unreadableRooms: 1,
|
||||
lastRoomChange: 9200, backupSnapshots: 1, backupBytes: 14, backupDownloads: 1,
|
||||
});
|
||||
assert.equal(JSON.stringify(result).includes('secret'), false);
|
||||
assert.deepEqual(store.sqlite.prepare('SELECT code,state,version FROM rooms ORDER BY code').all(), before);
|
||||
} finally { store.sqlite.close(); }
|
||||
});
|
||||
|
||||
void test('stale owner identity, demotion and revoked session cannot sample operational counts', async () => {
|
||||
const store = adminStore();
|
||||
try {
|
||||
const owner = store.provision('owner');
|
||||
store.provision('owner', 'Other owner');
|
||||
const login = await adminLogin(store.database, owner.key, now);
|
||||
const identity = await requireAdmin(store.database, login.token, ['owner'], now);
|
||||
const denied = (error: unknown) => error instanceof AdminError && error.status === 403;
|
||||
store.sqlite.prepare("UPDATE admin_accounts SET role='operator' WHERE id=?").run(owner.id);
|
||||
await assert.rejects(readAdminOperations(store.database, identity, now), denied);
|
||||
store.sqlite.prepare("UPDATE admin_accounts SET role='owner' WHERE id=?").run(owner.id);
|
||||
store.sqlite.prepare('UPDATE admin_sessions SET revoked_at=? WHERE token_hash=?').run(now, identity.sessionHash);
|
||||
await assert.rejects(readAdminOperations(store.database, identity, now), denied);
|
||||
} finally { store.sqlite.close(); }
|
||||
});
|
||||
Reference in new issue
Block a user