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:
Tronax 2026-08-17 15:07:02 +02:00
parent 31cb594cb0
commit 69fbb015ab
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
8 changed files with 679 additions and 3 deletions

View file

@ -29,7 +29,9 @@ import {
getSession,
getUserById,
getUserByUsername,
globalStats,
hashPassword,
listUsers,
mergeGuest,
recordGameResult,
verifyPassword,
@ -63,6 +65,51 @@ const oidcPending = new Map()
let oidcDiscovery = null
let oidcDiscoveryAt = 0
// ------------------------------------------------------------------ admin & presence
/** usernames (comma-separated via ADMIN_USERS env) allowed to open /admin */
const ADMIN_USERS = String(process.env.ADMIN_USERS || '')
.toLowerCase()
.split(',')
.map((s) => s.trim())
.filter(Boolean)
function isAdminUser(username) {
return ADMIN_USERS.includes(String(username || '').toLowerCase())
}
/** live solo presence: userId -> { username, displayName, map, difficulty, wave, since, lastSeen } */
const soloPresence = new Map()
const PRESENCE_TTL_MS = 90 * 1000
function activeSoloPlayers() {
const now = Date.now()
const out = []
for (const [userId, p] of soloPresence.entries()) {
if (now - p.lastSeen > PRESENCE_TTL_MS) {
soloPresence.delete(userId) // expired: missed heartbeats
continue
}
out.push({ userId, ...p })
}
out.sort((a, b) => a.since - b.since)
return out
}
function roomSummaries() {
const out = []
for (const room of rooms.values()) {
out.push({
code: room.code,
mode: room.mode,
mapId: room.mapId,
created: room.created,
players: room.players.map((p) => ({ id: p.id, name: p.name })),
})
}
return out
}
// periodic session & oidc-state cleanup
setInterval(() => {
cleanExpiredSessions()
@ -206,6 +253,7 @@ function publicUser(user) {
upgrades: user.upgrades,
stats: user.stats,
oidc: Boolean(user.oidc_sub),
admin: isAdminUser(user.username),
}
}
@ -402,6 +450,45 @@ async function handleApi(req, res, url) {
const user = getUserById(sess.user_id)
if (!user) return json(res, 401, { error: 'Nicht angemeldet.' })
// --- live presence: solo players heartbeat here while playing ---
if (p === '/api/presence' && req.method === 'POST') {
const body = await readJsonBody(req).catch(() => null)
const now = Date.now()
const prev = soloPresence.get(user.id)
soloPresence.set(user.id, {
username: user.username,
displayName: user.display_name,
map: sanitizeMapId(String(body?.map || 'meadow')),
difficulty: String(body?.difficulty || 'normal').replace(/[^a-z_]/g, '').slice(0, 12) || 'normal',
wave: Math.max(0, Math.min(9999, Number(body?.wave) || 0)),
since: prev?.since ?? now,
lastSeen: now,
})
return json(res, 200, { ok: true })
}
if (p === '/api/presence/stop' && req.method === 'POST') {
soloPresence.delete(user.id)
return json(res, 200, { ok: true })
}
// --- admin dashboard data (ADMIN_USERS only) ---
if (p.startsWith('/api/admin/')) {
if (!isAdminUser(user.username)) return json(res, 403, { error: 'Kein Administrator.' })
if (p === '/api/admin/overview' && req.method === 'GET') {
return json(res, 200, {
soloPlayers: activeSoloPlayers(),
rooms: roomSummaries(),
stats: globalStats(),
})
}
if (p === '/api/admin/users' && req.method === 'GET') {
const limit = Number(url.searchParams.get('limit')) || 100
return json(res, 200, { users: listUsers(limit) })
}
return json(res, 404, { error: 'Nicht gefunden.' })
}
// --- buy meta upgrade ---
if (p === '/api/upgrades/buy' && req.method === 'POST') {
const body = await readJsonBody(req).catch(() => null)