feat(admin): live admin dashboard with solo presence tracking
Solo games run entirely in the browser, so the server previously had no idea who was playing right now (it only saw logins and finished games). This adds lightweight presence reporting and an admin dashboard. Presence (server): - POST /api/presence (logged-in users): heartbeat while a solo game runs, stores username, map (validated), difficulty, wave, since/lastSeen per user; entries expire automatically after 90s without a heartbeat - POST /api/presence/stop: explicit removal when the player exits Admin API (ADMIN_USERS env, comma-separated usernames): - GET /api/admin/overview: live solo players, multiplayer room summaries (code, mode, map, players — the server already tracks rooms), and global stats (accounts, rounds, crystals in circulation) - GET /api/admin/users?limit=100: user list with stats, newest login first - publicUser now carries an admin flag; non-admins get 403 Admin UI (src/components/AdminDashboard.vue, served at /admin): - Login gate for guests/non-admins (guest profiles are detected via isLoggedIn, not just user presence) - KPI cards, live solo table (player, map, difficulty, wave, duration), room table, and account table; auto-refresh every 5 seconds - App.vue renders the dashboard for /admin instead of the game and runs a screen watcher that starts/stops the solo presence heartbeat Config: ADMIN_USERS documented in docker-compose.yml and README. Tests: 6 new integration checks (admin flag, presence report/stop, 403 guard, overview contents, user list) — 37/37 green, build clean. Verified end-to-end in the browser: guest gate, admin login, and a live second player (map/difficulty/wave) appearing in the dashboard.
This commit is contained in:
parent
31cb594cb0
commit
69fbb015ab
8 changed files with 679 additions and 3 deletions
|
|
@ -23,7 +23,7 @@ let child = null
|
|||
function startServer() {
|
||||
child = spawn(process.execPath, ['server/server.mjs'], {
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, PORT: String(PORT), DATA_DIR: tmp, OIDC_ENABLED: 'false' },
|
||||
env: { ...process.env, PORT: String(PORT), DATA_DIR: tmp, OIDC_ENABLED: 'false', ADMIN_USERS: 'siteadmin' },
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
})
|
||||
child.stderr.on('data', () => {}) // drain (experimental-warning etc.)
|
||||
|
|
@ -247,6 +247,61 @@ try {
|
|||
is(json.user.upgrades.wave_bonus === 1 && json.user.crystals === 42, JSON.stringify(json))
|
||||
})
|
||||
|
||||
// ------------------------------------------------------- admin & presence
|
||||
const admin = makeClient()
|
||||
await check('Admin: ADMIN_USERS-Konto bekommt admin-Flag', async () => {
|
||||
const { status, json } = await admin.req('POST', '/api/auth/register', {
|
||||
username: 'siteadmin',
|
||||
password: 'adminpass123',
|
||||
displayName: 'Site Admin',
|
||||
})
|
||||
is(status === 200 && json.user.admin === true, JSON.stringify(json))
|
||||
const me = await client.req('GET', '/api/auth/me')
|
||||
is(me.json.user.admin === false, 'Normaler Account darf kein Admin sein')
|
||||
})
|
||||
|
||||
await check('Presence: Solo-Spiel wird gemeldet', async () => {
|
||||
const { status, json } = await client.req('POST', '/api/presence', {
|
||||
mode: 'solo',
|
||||
map: 'volcano',
|
||||
difficulty: 'hard',
|
||||
wave: 7,
|
||||
})
|
||||
eq({ status, json }, { status: 200, json: { ok: true } })
|
||||
})
|
||||
|
||||
await check('Admin: Übersicht ohne Admin-Rechte -> 403', async () => {
|
||||
const { status } = await client.req('GET', '/api/admin/overview')
|
||||
is(status === 403, `status ${status}`)
|
||||
})
|
||||
|
||||
await check('Admin: Übersicht zeigt Live-Solo-Spieler & Räume', async () => {
|
||||
const { status, json } = await admin.req('GET', '/api/admin/overview')
|
||||
is(status === 200, `status ${status}`)
|
||||
const p = json.soloPlayers.find((x) => x.username === 'testuser')
|
||||
if (!p) throw new Error('testuser nicht in soloPlayers')
|
||||
is(p.map === 'volcano', `map ${p.map}`)
|
||||
is(p.difficulty === 'hard', `difficulty ${p.difficulty}`)
|
||||
is(p.wave === 7, `wave ${p.wave}`)
|
||||
is(Array.isArray(json.rooms), 'rooms fehlen')
|
||||
is(json.stats.users >= 2, `stats.users ${json.stats.users}`)
|
||||
})
|
||||
|
||||
await check('Admin: Nutzerliste mit Statistiken', async () => {
|
||||
const { status, json } = await admin.req('GET', '/api/admin/users')
|
||||
is(status === 200 && json.users.length >= 2, JSON.stringify({ status, n: json.users?.length }))
|
||||
const t = json.users.find((u) => u.username === 'testuser')
|
||||
if (!t) throw new Error('testuser nicht in Nutzerliste')
|
||||
is(t.stats.gamesPlayed === 3, `gamesPlayed ${t.stats.gamesPlayed}`)
|
||||
})
|
||||
|
||||
await check('Presence: Stop entfernt Spieler aus Live-Übersicht', async () => {
|
||||
const stop = await client.req('POST', '/api/presence/stop')
|
||||
is(stop.status === 200, `status ${stop.status}`)
|
||||
const { json } = await admin.req('GET', '/api/admin/overview')
|
||||
is(!json.soloPlayers.some((x) => x.username === 'testuser'), 'testuser noch aktiv')
|
||||
})
|
||||
|
||||
await check('OIDC: Login-Endpunkt ohne Aktivierung -> 400', async () => {
|
||||
const { status, json } = await anon.req('GET', '/api/auth/oidc/login')
|
||||
is(status === 400 && json.error.includes('nicht aktiviert'), JSON.stringify(json))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue