TRXTD/src/components/AdminDashboard.vue
Tronax 69fbb015ab
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.
2026-08-17 15:07:02 +02:00

416 lines
11 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { DIFFICULTIES, MAPS } from '@/game/config'
import { isLoggedIn, login, logout } from '@/game/auth'
import { store } from '@/game/store'
interface SoloPlayer {
userId: string
username: string
displayName: string
map: string
difficulty: string
wave: number
since: number
lastSeen: number
}
interface RoomInfo {
code: string
mode: string
mapId: string
created: number
players: { id: number; name: string }[]
}
interface AdminUser {
id: string
username: string
displayName: string
crystals: number
createdAt: number
lastLogin: number
stats: { gamesPlayed: number; gamesWon: number; totalKills: number; totalScore: number; highestWave: number }
}
interface Overview {
soloPlayers: SoloPlayer[]
rooms: RoomInfo[]
stats: { users: number; gamesPlayed: number; gamesWon: number; crystalsInCirculation: number }
}
const username = ref('')
const password = ref('')
const loginError = ref('')
const busy = ref(false)
const overview = ref<Overview | null>(null)
const users = ref<AdminUser[]>([])
const lastRefresh = ref<Date | null>(null)
let refreshTimer: ReturnType<typeof setInterval> | null = null
const isAdmin = computed(() => Boolean(store.auth.user && (store.auth.user as unknown as { admin?: boolean }).admin))
const mapName = (id: string): string => (MAPS as Record<string, { name: string }>)[id]?.name ?? id
const diffName = (id: string): string =>
(DIFFICULTIES as Record<string, { name: string }>)[id]?.name ?? id
function sinceLabel(ts: number): string {
const min = Math.max(0, Math.round((Date.now() - ts) / 60000))
if (min < 1) return 'gerade eben'
if (min === 1) return 'seit 1 Min.'
if (min < 60) return `seit ${min} Min.`
const h = Math.floor(min / 60)
return `seit ${h} Std. ${min % 60} Min.`
}
function timeLabel(ts: number): string {
return new Date(ts).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
}
async function refresh(): Promise<void> {
try {
const [ov, us] = await Promise.all([
fetch('/api/admin/overview', { credentials: 'same-origin' }).then((r) => (r.ok ? r.json() : null)),
fetch('/api/admin/users?limit=100', { credentials: 'same-origin' }).then((r) => (r.ok ? r.json() : null)),
])
if (ov) overview.value = ov
if (us) users.value = us.users ?? []
lastRefresh.value = new Date()
} catch {
/* server unreachable keep last state */
}
}
async function doLogin(): Promise<void> {
busy.value = true
loginError.value = ''
const err = await login(username.value.trim(), password.value)
busy.value = false
if (err) loginError.value = err
}
onMounted(() => {
refreshTimer = setInterval(() => void refresh(), 5000)
})
onBeforeUnmount(() => {
if (refreshTimer) clearInterval(refreshTimer)
})
void refresh()
</script>
<template>
<div class="admin">
<header class="topbar">
<div class="brand">🛡 TRXTD Admin</div>
<div class="actions">
<span v-if="lastRefresh" class="refresh">aktualisiert {{ lastRefresh.toLocaleTimeString('de-DE') }}</span>
<a class="link" href="/">← Zum Spiel</a>
<button v-if="isLoggedIn()" class="btn small" @click="logout()">Abmelden</button>
</div>
</header>
<!-- not logged in (guests included) -->
<div v-if="!isLoggedIn()" class="gate">
<form class="gate-card" @submit.prevent="doLogin">
<h2>Admin-Anmeldung</h2>
<p class="hint">Melde dich mit einem Konto aus <code>ADMIN_USERS</code> an.</p>
<label class="field">
<span>Benutzername</span>
<input v-model="username" autocomplete="username" />
</label>
<label class="field">
<span>Passwort</span>
<input v-model="password" type="password" autocomplete="current-password" />
</label>
<p v-if="loginError" class="error">{{ loginError }}</p>
<button type="submit" class="btn primary" :disabled="busy">Anmelden</button>
</form>
</div>
<!-- logged in, but no admin rights -->
<div v-else-if="!isAdmin" class="gate">
<div class="gate-card">
<h2> Kein Zugriff</h2>
<p class="hint">
Dieses Konto ist kein Administrator. Hinterlege es in der Umgebungsvariable
<code>ADMIN_USERS</code> (Komma-getrennt) und starte den Server neu.
</p>
</div>
</div>
<!-- dashboard -->
<div v-else class="content">
<div class="kpis">
<div class="kpi"><span class="v">{{ overview?.soloPlayers.length ?? 0 }}</span><span class="k">Spielt jetzt Solo</span></div>
<div class="kpi"><span class="v">{{ overview?.rooms.length ?? 0 }}</span><span class="k">Multiplayer-Räume</span></div>
<div class="kpi"><span class="v">{{ overview?.stats.users ?? 0 }}</span><span class="k">Accounts</span></div>
<div class="kpi"><span class="v">{{ overview?.stats.gamesPlayed ?? 0 }}</span><span class="k">Runden gespielt</span></div>
<div class="kpi"><span class="v">💎 {{ overview?.stats.crystalsInCirculation ?? 0 }}</span><span class="k">Kristalle im Umlauf</span></div>
</div>
<section class="panel">
<h3>🎮 Spielt gerade Solo</h3>
<p v-if="!overview?.soloPlayers.length" class="empty">Gerade spielt niemand Solo.</p>
<table v-else>
<thead>
<tr><th>Spieler</th><th>Karte</th><th>Schwierigkeit</th><th>Welle</th><th>Online</th></tr>
</thead>
<tbody>
<tr v-for="p in overview.soloPlayers" :key="p.userId">
<td><b>{{ p.displayName }}</b> <span class="dim">@{{ p.username }}</span></td>
<td>{{ mapName(p.map) }}</td>
<td>{{ diffName(p.difficulty) }}</td>
<td>🌊 {{ p.wave }}</td>
<td class="live"><span class="dot" /> {{ sinceLabel(p.since) }}</td>
</tr>
</tbody>
</table>
</section>
<section class="panel">
<h3>👥 Multiplayer-Räume</h3>
<p v-if="!overview?.rooms.length" class="empty">Keine offenen Räume.</p>
<table v-else>
<thead>
<tr><th>Code</th><th>Modus</th><th>Karte</th><th>Spieler</th><th>Offen seit</th></tr>
</thead>
<tbody>
<tr v-for="r in overview.rooms" :key="r.code">
<td><b>{{ r.code }}</b></td>
<td>{{ r.mode === 'coop' ? '🤝 Coop' : '⚔ 1v1' }}</td>
<td>{{ mapName(r.mapId) }}</td>
<td>{{ r.players.map((p) => p.name).join(', ') }}</td>
<td>{{ sinceLabel(r.created) }}</td>
</tr>
</tbody>
</table>
</section>
<section class="panel">
<h3>📊 Accounts</h3>
<table>
<thead>
<tr><th>Spieler</th><th>💎 Kristalle</th><th>Runden</th><th>Siege</th><th>Beste Welle</th><th>Punkte</th><th>Letzter Login</th></tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td><b>{{ u.displayName }}</b> <span class="dim">@{{ u.username }}</span></td>
<td>💎 {{ u.crystals }}</td>
<td>{{ u.stats.gamesPlayed }}</td>
<td>{{ u.stats.gamesWon }}</td>
<td>🌊 {{ u.stats.highestWave }}</td>
<td>{{ u.stats.totalScore.toLocaleString('de-DE') }}</td>
<td>{{ timeLabel(u.lastLogin) }}</td>
</tr>
</tbody>
</table>
</section>
</div>
</div>
</template>
<style scoped>
.admin {
min-height: 100dvh;
background: var(--bg);
color: var(--text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: var(--panel);
border-bottom: 1px solid var(--panel-border);
position: sticky;
top: 0;
z-index: 5;
}
.brand {
font-weight: 800;
font-size: 17px;
}
.actions {
display: flex;
align-items: center;
gap: 12px;
}
.refresh {
color: var(--text-dim);
font-size: 12px;
}
.link {
color: var(--accent);
text-decoration: none;
font-weight: 700;
font-size: 13.5px;
}
.gate {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100dvh - 60px);
padding: 16px;
}
.gate-card {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 14px;
padding: 26px 30px;
width: 380px;
max-width: 94vw;
display: flex;
flex-direction: column;
gap: 12px;
}
.gate-card h2 {
margin: 0;
font-size: 19px;
}
.hint {
margin: 0;
color: var(--text-dim);
font-size: 12.5px;
line-height: 1.5;
}
.hint code {
background: var(--panel-inset);
border-radius: 4px;
padding: 1px 5px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field span {
font-size: 12px;
color: var(--text-dim);
}
.field input {
background: var(--panel-inset);
border: 1px solid var(--panel-border);
border-radius: 8px;
padding: 9px 12px;
color: var(--text);
font-family: inherit;
font-size: 14px;
outline: none;
}
.field input:focus {
border-color: var(--accent);
}
.error {
margin: 0;
color: #ff8a7a;
font-size: 13px;
}
.btn.primary {
background: linear-gradient(180deg, #59b34d, #3f8f37);
border: none;
color: #fff;
font-weight: 800;
padding: 10px;
border-radius: 9px;
cursor: pointer;
font-family: inherit;
}
.btn.small {
background: var(--panel-inset);
border: 1px solid var(--panel-border);
border-radius: 8px;
color: var(--text);
font-weight: 700;
font-size: 12.5px;
padding: 6px 12px;
cursor: pointer;
font-family: inherit;
}
.content {
max-width: 1080px;
margin: 0 auto;
padding: 18px 16px 40px;
display: flex;
flex-direction: column;
gap: 16px;
}
.kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 10px;
}
.kpi {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 12px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 3px;
}
.kpi .v {
font-size: 22px;
font-weight: 800;
color: var(--accent);
}
.kpi .k {
font-size: 12px;
color: var(--text-dim);
}
.panel {
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 12px;
padding: 14px 16px;
}
.panel h3 {
margin: 0 0 10px;
font-size: 15px;
}
.empty {
margin: 0;
color: var(--text-dim);
font-size: 13px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th {
text-align: left;
color: var(--text-dim);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.6px;
padding: 6px 8px;
border-bottom: 1px solid var(--panel-border);
}
td {
padding: 8px;
border-bottom: 1px solid rgba(38, 49, 64, 0.5);
white-space: nowrap;
}
.dim {
color: var(--text-dim);
font-size: 11.5px;
}
.live {
color: #8ce29b;
}
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #4caf50;
box-shadow: 0 0 6px #4caf50;
margin-right: 4px;
}
@media (max-width: 640px) {
.panel {
overflow-x: auto;
}
.refresh {
display: none;
}
}
</style>