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
11
README.md
11
README.md
|
|
@ -85,6 +85,17 @@ npm run build
|
||||||
npm run server # serves dist/ + ws auf :3001
|
npm run server # serves dist/ + ws auf :3001
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 🛡️ Admin-Dashboard (`/admin`)
|
||||||
|
|
||||||
|
Live-Übersicht über den Server: wer gerade **Solo spielt** (Karte, Schwierigkeit, Welle — angemeldete Spieler senden dazu alle 20 s einen Heartbeat), offene **Multiplayer-Räume**, Account-**Statistiken** und die **Nutzerliste**. Aktualisiert sich alle 5 s automatisch.
|
||||||
|
|
||||||
|
Freischalten über die Umgebungsvariable `ADMIN_USERS` (Komma-getrennte Benutzernamen):
|
||||||
|
```yaml
|
||||||
|
environment:
|
||||||
|
- ADMIN_USERS=deinaccount
|
||||||
|
```
|
||||||
|
Dann als eines dieser Konten unter `https://deine-domain/admin` anmelden. Solo-Spiele laufen zwar lokal im Browser, aber angemeldete Spieler melden ihren Spielstatus an den Server; Gäste bleiben unsichtbar.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Spielmodi
|
## Spielmodi
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ services:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
environment:
|
environment:
|
||||||
- PORT=3001
|
- PORT=3001
|
||||||
|
# --- Optional: Admin-Dashboard (/admin) ---
|
||||||
|
# Komma-getrennte Benutzernamen mit Admin-Zugriff auf das Live-Dashboard
|
||||||
|
# - ADMIN_USERS=deinaccount
|
||||||
# --- Optional: OpenID Connect (Single Sign-On) ---
|
# --- Optional: OpenID Connect (Single Sign-On) ---
|
||||||
# - OIDC_ENABLED=true
|
# - OIDC_ENABLED=true
|
||||||
# - OIDC_ISSUER=https://auth.example.com
|
# - OIDC_ISSUER=https://auth.example.com
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ let child = null
|
||||||
function startServer() {
|
function startServer() {
|
||||||
child = spawn(process.execPath, ['server/server.mjs'], {
|
child = spawn(process.execPath, ['server/server.mjs'], {
|
||||||
cwd: ROOT,
|
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'],
|
stdio: ['ignore', 'ignore', 'pipe'],
|
||||||
})
|
})
|
||||||
child.stderr.on('data', () => {}) // drain (experimental-warning etc.)
|
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))
|
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 () => {
|
await check('OIDC: Login-Endpunkt ohne Aktivierung -> 400', async () => {
|
||||||
const { status, json } = await anon.req('GET', '/api/auth/oidc/login')
|
const { status, json } = await anon.req('GET', '/api/auth/oidc/login')
|
||||||
is(status === 400 && json.error.includes('nicht aktiviert'), JSON.stringify(json))
|
is(status === 400 && json.error.includes('nicht aktiviert'), JSON.stringify(json))
|
||||||
|
|
|
||||||
|
|
@ -316,3 +316,52 @@ export function recordGameResult(userId, { win, score, wave, kills, crystalsEarn
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- admin dashboard
|
||||||
|
|
||||||
|
export function listUsers(limit = 100) {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT u.id, u.username, u.display_name, u.crystals, u.created_at, u.last_login,
|
||||||
|
s.games_played, s.games_won, s.total_kills, s.total_score, s.highest_wave
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_stats s ON s.user_id = u.id
|
||||||
|
ORDER BY u.last_login DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
)
|
||||||
|
.all(Math.max(1, Math.min(500, Math.floor(Number(limit) || 100))))
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
username: r.username,
|
||||||
|
displayName: r.display_name,
|
||||||
|
crystals: r.crystals,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
lastLogin: r.last_login,
|
||||||
|
stats: {
|
||||||
|
gamesPlayed: r.games_played ?? 0,
|
||||||
|
gamesWon: r.games_won ?? 0,
|
||||||
|
totalKills: r.total_kills ?? 0,
|
||||||
|
totalScore: r.total_score ?? 0,
|
||||||
|
highestWave: r.highest_wave ?? 0,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function globalStats() {
|
||||||
|
const r = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT COUNT(*) AS users,
|
||||||
|
COALESCE(SUM(s.games_played), 0) AS gamesPlayed,
|
||||||
|
COALESCE(SUM(s.games_won), 0) AS gamesWon,
|
||||||
|
COALESCE(SUM(u.crystals), 0) AS crystalsInCirculation
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_stats s ON s.user_id = u.id`,
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
return {
|
||||||
|
users: r.users,
|
||||||
|
gamesPlayed: r.gamesPlayed,
|
||||||
|
gamesWon: r.gamesWon,
|
||||||
|
crystalsInCirculation: r.crystalsInCirculation,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,9 @@ import {
|
||||||
getSession,
|
getSession,
|
||||||
getUserById,
|
getUserById,
|
||||||
getUserByUsername,
|
getUserByUsername,
|
||||||
|
globalStats,
|
||||||
hashPassword,
|
hashPassword,
|
||||||
|
listUsers,
|
||||||
mergeGuest,
|
mergeGuest,
|
||||||
recordGameResult,
|
recordGameResult,
|
||||||
verifyPassword,
|
verifyPassword,
|
||||||
|
|
@ -63,6 +65,51 @@ const oidcPending = new Map()
|
||||||
let oidcDiscovery = null
|
let oidcDiscovery = null
|
||||||
let oidcDiscoveryAt = 0
|
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
|
// periodic session & oidc-state cleanup
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
cleanExpiredSessions()
|
cleanExpiredSessions()
|
||||||
|
|
@ -206,6 +253,7 @@ function publicUser(user) {
|
||||||
upgrades: user.upgrades,
|
upgrades: user.upgrades,
|
||||||
stats: user.stats,
|
stats: user.stats,
|
||||||
oidc: Boolean(user.oidc_sub),
|
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)
|
const user = getUserById(sess.user_id)
|
||||||
if (!user) return json(res, 401, { error: 'Nicht angemeldet.' })
|
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 ---
|
// --- buy meta upgrade ---
|
||||||
if (p === '/api/upgrades/buy' && req.method === 'POST') {
|
if (p === '/api/upgrades/buy' && req.method === 'POST') {
|
||||||
const body = await readJsonBody(req).catch(() => null)
|
const body = await readJsonBody(req).catch(() => null)
|
||||||
|
|
|
||||||
23
src/App.vue
23
src/App.vue
|
|
@ -1,4 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import AdminDashboard from '@/components/AdminDashboard.vue'
|
||||||
import AuthModal from '@/components/AuthModal.vue'
|
import AuthModal from '@/components/AuthModal.vue'
|
||||||
import EndOverlay from '@/components/EndOverlay.vue'
|
import EndOverlay from '@/components/EndOverlay.vue'
|
||||||
import GameCanvas from '@/components/GameCanvas.vue'
|
import GameCanvas from '@/components/GameCanvas.vue'
|
||||||
|
|
@ -13,8 +14,25 @@ import StartScreen from '@/components/StartScreen.vue'
|
||||||
import TowerPanel from '@/components/TowerPanel.vue'
|
import TowerPanel from '@/components/TowerPanel.vue'
|
||||||
import TowerShop from '@/components/TowerShop.vue'
|
import TowerShop from '@/components/TowerShop.vue'
|
||||||
import { playerColor } from '@/game/config'
|
import { playerColor } from '@/game/config'
|
||||||
|
import { startSoloPresence, stopSoloPresence } from '@/game/auth'
|
||||||
import { store } from '@/game/store'
|
import { store } from '@/game/store'
|
||||||
import { computed } from 'vue'
|
import { computed, watch } from 'vue'
|
||||||
|
|
||||||
|
/** /admin serves the admin dashboard instead of the game (SPA fallback) */
|
||||||
|
const isAdminRoute =
|
||||||
|
typeof window !== 'undefined' && window.location.pathname.replace(/\/+$/, '').endsWith('/admin')
|
||||||
|
|
||||||
|
/** solo presence heartbeat: active while a solo game is on screen */
|
||||||
|
if (!isAdminRoute) {
|
||||||
|
watch(
|
||||||
|
() => store.screen,
|
||||||
|
(screen) => {
|
||||||
|
if (screen === 'game' && !store.mp.active) startSoloPresence()
|
||||||
|
else stopSoloPresence()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** duel: colored frame + label around the main canvas showing whose board it is */
|
/** duel: colored frame + label around the main canvas showing whose board it is */
|
||||||
const frameStyle = computed(() => {
|
const frameStyle = computed(() => {
|
||||||
|
|
@ -37,7 +55,8 @@ const badgeColor = computed(() => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="app" :class="{ scrollable: store.screen === 'menu' || store.screen === 'lobby' }">
|
<AdminDashboard v-if="isAdminRoute" />
|
||||||
|
<div v-else class="app" :class="{ scrollable: store.screen === 'menu' || store.screen === 'lobby' }">
|
||||||
<StartScreen v-if="store.screen === 'menu'" />
|
<StartScreen v-if="store.screen === 'menu'" />
|
||||||
<LobbyScreen v-else-if="store.screen === 'lobby'" />
|
<LobbyScreen v-else-if="store.screen === 'lobby'" />
|
||||||
<div v-else class="game-layout">
|
<div v-else class="game-layout">
|
||||||
|
|
|
||||||
416
src/components/AdminDashboard.vue
Normal file
416
src/components/AdminDashboard.vue
Normal file
|
|
@ -0,0 +1,416 @@
|
||||||
|
<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>
|
||||||
|
|
@ -110,6 +110,42 @@ export function oidcLogin(): void {
|
||||||
window.location.href = '/api/auth/oidc/login'
|
window.location.href = '/api/auth/oidc/login'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- live presence
|
||||||
|
// While a solo game is running, logged-in players send a lightweight heartbeat
|
||||||
|
// so the admin dashboard can show who is currently playing (map, wave, …).
|
||||||
|
|
||||||
|
let presenceTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let presenceActive = false
|
||||||
|
|
||||||
|
export function startSoloPresence(): void {
|
||||||
|
if (!isLoggedIn()) return
|
||||||
|
stopSoloPresence()
|
||||||
|
presenceActive = true
|
||||||
|
const beat = () =>
|
||||||
|
void api('/api/presence', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
mode: 'solo',
|
||||||
|
map: store.mapId,
|
||||||
|
difficulty: store.difficulty,
|
||||||
|
wave: store.waveNo,
|
||||||
|
}),
|
||||||
|
}).catch(() => undefined)
|
||||||
|
beat()
|
||||||
|
presenceTimer = setInterval(beat, 20_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopSoloPresence(): void {
|
||||||
|
if (presenceTimer !== null) {
|
||||||
|
clearInterval(presenceTimer)
|
||||||
|
presenceTimer = null
|
||||||
|
}
|
||||||
|
if (presenceActive) {
|
||||||
|
presenceActive = false
|
||||||
|
void api('/api/presence/stop', { method: 'POST' }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
export async function logout(): Promise<void> {
|
||||||
await api('/api/auth/logout', { method: 'POST' }).catch(() => undefined)
|
await api('/api/auth/logout', { method: 'POST' }).catch(() => undefined)
|
||||||
store.auth.user = loadGuestProfile()
|
store.auth.user = loadGuestProfile()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue