diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index c848e85..5ce94ee 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -1680,16 +1680,83 @@ public class AdminController( [HttpGet("stats")] public async Task Stats() { + var now = DateTime.UtcNow; var totalPlayers = await db.Players.CountAsync(); var totalRooms = await db.Rooms.CountAsync(); var totalInventions = await db.Inventions.CountAsync(); var openReports = await db.Reports.CountAsync(r => r.ResolvedAt == null); var bannedNow = await db.Players.CountAsync(p => - p.BannedUntil != null && p.BannedUntil > DateTime.UtcNow); + p.BannedUntil != null && p.BannedUntil > now); var activeIpBans = await db.IpBans.CountAsync(b => - b.Until == null || b.Until > DateTime.UtcNow); + b.Until == null || b.Until > now); var online = onlinePresence.OnlinePlayerIds().ToArray(); + var onlineSet = online.ToHashSet(); + + var currentRoomsByPlayer = online + .Select(id => new { PlayerId = id, Room = playerPresence.GetRoom(id) }) + .Where(x => x.Room is not null) + .ToDictionary(x => x.PlayerId, x => x.Room!); + + var roomIds = currentRoomsByPlayer.Values.Select(r => r.RoomId).Distinct().ToList(); + var roomNames = roomIds.Count == 0 + ? new Dictionary() + : await db.Rooms + .Where(r => roomIds.Contains(r.Id)) + .Select(r => new { r.Id, r.Name }) + .ToDictionaryAsync(r => r.Id, r => r.Name); + + var onlinePlayers = await db.Players + .Where(p => onlineSet.Contains(p.Id)) + .OrderBy(p => p.DisplayName) + .ThenBy(p => p.Username) + .Select(p => new + { + p.Id, + p.Username, + p.DisplayName, + p.Level, + p.ProfileImageName, + p.IsAdmin, + p.IsDeveloper, + p.IsCommunityTeam, + p.IsJunior, + p.IsVerified, + p.BannedUntil, + p.LastSeenAt, + }) + .ToListAsync(); + var onlinePlayerCards = onlinePlayers.Select(p => + { + currentRoomsByPlayer.TryGetValue(p.Id, out var room); + roomNames.TryGetValue(room?.RoomId ?? 0, out var roomName); + return new + { + p.Id, + p.Username, + p.DisplayName, + p.Level, + p.ProfileImageName, + p.IsAdmin, + p.IsDeveloper, + p.IsCommunityTeam, + p.IsJunior, + p.IsVerified, + p.BannedUntil, + p.LastSeenAt, + CurrentRoom = room is null ? null : new + { + room.RoomId, + Name = roomName ?? room.Name, + room.SubRoomId, + room.RoomInstanceId, + room.PhotonRoomId, + room.PhotonRegionId, + room.IsPrivate, + room.MaxCapacity, + }, + }; + }).ToList(); var topRooms = await db.Rooms .OrderByDescending(r => r.VisitCount) @@ -1697,6 +1764,31 @@ public class AdminController( .Select(r => new { r.Id, r.Name, r.VisitCount, r.VisitorCount, r.CheerCount }) .ToListAsync(); + var activeSessions = await db.GameSessions + .Where(s => s.PlayerCount > 0) + .OrderByDescending(s => s.PlayerCount) + .ThenByDescending(s => s.CreatedAt) + .Take(8) + .Select(s => new + { + s.Id, + s.RoomId, + s.ActivityLevelId, + s.Region, + s.PhotonRoomName, + s.PlayerCount, + s.MaxCapacity, + s.CreatedAt, + }) + .ToListAsync(); + var inGamePlayerCount = await db.GameSessions.SumAsync(s => (int?)s.PlayerCount) ?? 0; + var activeSessionCount = await db.GameSessions.CountAsync(s => s.PlayerCount > 0); + var photosToday = await db.Photos.CountAsync(p => + p.CreatedAt >= now.AddDays(-1) && p.DeletedAt == null); + var newPlayersToday = await db.Players.CountAsync(p => p.CreatedAt >= now.AddDays(-1)); + var totalVisitCount = await db.Rooms.SumAsync(r => (long)r.VisitCount); + var totalCheerCount = await db.Rooms.SumAsync(r => (long)r.CheerCount); + var recentJoins = await db.Players .OrderByDescending(p => p.CreatedAt) .Take(10) @@ -1710,16 +1802,28 @@ public class AdminController( Total = totalPlayers, OnlineNow = online.Length, BannedNow = bannedNow, + NewToday = newPlayersToday, + Online = onlinePlayerCards, + }, + Rooms = new + { + Total = totalRooms, + TotalVisits = totalVisitCount, + TotalCheers = totalCheerCount, + TopByVisits = topRooms, + ActiveSessions = activeSessions, + ActiveSessionCount = activeSessionCount, + InGamePlayerCount = inGamePlayerCount, }, - Rooms = new { Total = totalRooms, TopByVisits = topRooms }, Inventions = totalInventions, + Photos = new { Today = photosToday }, Moderation = new { OpenReports = openReports, ActiveIpBans = activeIpBans, }, RecentJoins = recentJoins, - ServerTime = DateTime.UtcNow, + ServerTime = now, }); } diff --git a/DorkNet.Server/admin-ui/src/lib/types.ts b/DorkNet.Server/admin-ui/src/lib/types.ts index 92d003d..6a04cf1 100644 --- a/DorkNet.Server/admin-ui/src/lib/types.ts +++ b/DorkNet.Server/admin-ui/src/lib/types.ts @@ -170,12 +170,56 @@ export interface Report { } export interface Stats { - players: { total: number; onlineNow: number; bannedNow: number }; + players: { + total: number; + onlineNow: number; + bannedNow: number; + newToday: number; + online: Array<{ + id: number; + username: string; + displayName: string; + level: number; + profileImageName: string | null; + isAdmin: boolean; + isDeveloper: boolean; + isCommunityTeam: boolean; + isJunior: boolean; + isVerified: boolean; + bannedUntil: string | null; + lastSeenAt: string; + currentRoom: null | { + roomId: number; + name: string; + subRoomId: number; + roomInstanceId: number; + photonRoomId: string; + photonRegionId: string; + isPrivate: boolean; + maxCapacity: number; + }; + }>; + }; rooms: { total: number; + totalVisits: number; + totalCheers: number; + activeSessionCount: number; + inGamePlayerCount: number; topByVisits: Array<{ id: number; name: string; visitCount: number; visitorCount: number; cheerCount: number }>; + activeSessions: Array<{ + id: number; + roomId: string; + activityLevelId: string; + region: string; + photonRoomName: string; + playerCount: number; + maxCapacity: number; + createdAt: string; + }>; }; inventions: number; + photos: { today: number }; moderation: { openReports: number; activeIpBans: number }; recentJoins: Array<{ id: number; username: string; createdAt: string }>; serverTime: string; diff --git a/DorkNet.Server/admin-ui/src/pages/Dashboard.tsx b/DorkNet.Server/admin-ui/src/pages/Dashboard.tsx index 473ebc7..a091c67 100644 --- a/DorkNet.Server/admin-ui/src/pages/Dashboard.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Dashboard.tsx @@ -1,19 +1,26 @@ import { useEffect, useState } from 'react'; -import { get } from '../lib/api'; +import { Link } from 'react-router-dom'; +import { api, get } from '../lib/api'; import type { Stats } from '../lib/types'; -import { RefreshCw } from '../components/Icons'; +import { absoluteTime, num, relativeTime } from '../lib/format'; +import { PlayerAvatar } from '../components/PlayerAvatar'; +import { useToast } from '../components/Toast'; +import { Activity, Ban, Building2, Gift, Megaphone, RefreshCw, Users } from '../components/Icons'; + +type ActionState = { playerId: number; username: string; kind: 'kick' | 'ban' } | null; export function Dashboard() { const [stats, setStats] = useState(null); const [err, setErr] = useState(null); const [loading, setLoading] = useState(false); + const [action, setAction] = useState(null); + const toast = useToast(); const load = async () => { setLoading(true); setErr(null); try { - const data = await get('/stats'); - setStats(data); + setStats(await get('/stats')); } catch (e) { setErr((e as Error).message); } finally { @@ -28,95 +35,230 @@ export function Dashboard() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const runAction = async (reason: string) => { + if (!action) return; + try { + if (action.kind === 'kick') { + await api(`/players/${action.playerId}/kick`, { method: 'POST', body: { Reason: reason } }); + toast.push(`Kicked @${action.username}`, 'success'); + } else { + await api(`/players/${action.playerId}/ban`, { method: 'POST', body: { DurationDays: 1, Reason: reason || 'Quick ban from live dashboard' } }); + toast.push(`Banned @${action.username} for 1 day`, 'success'); + } + setAction(null); + await load(); + } catch (e) { + toast.push((e as Error).message, 'error'); + } + }; + + const onlinePlayers = stats?.players.online ?? []; + return ( -
-
+
+
-

Dashboard

-

Server snapshot, refreshed every 10s.

+

Live Ops

+

Online players, active rooms, and server health.

+
+
+ Broadcast +
-
{err &&
{err}
} -
- - - - -
+
+ } label="Online" value={stats?.players.onlineNow} sub={`${stats?.players.total ?? 0} accounts`} tone="success" /> + } label="In game" value={stats?.rooms.inGamePlayerCount} sub={`${stats?.rooms.activeSessionCount ?? 0} sessions`} tone="brand" /> + } label="Rooms" value={stats?.rooms.total} sub={`${num(stats?.rooms.totalVisits ?? 0)} visits`} tone="neutral" /> + } label="Inventions" value={stats?.inventions} sub={`${num(stats?.rooms.totalCheers ?? 0)} room cheers`} tone="neutral" /> + } label="New today" value={stats?.players.newToday} sub={`${stats?.photos.today ?? 0} photos`} tone="brand" /> + } label="Moderation" value={stats?.moderation.openReports} sub={`${stats?.players.bannedNow ?? 0} banned`} tone="warn" /> +
-
-
-

Top rooms by visits

-

All-time visit counts.

-
- - - - - - - - - - {stats?.rooms.topByVisits.map(r => ( - - - - - +
+
+
+
+

Online players

+

{onlinePlayers.length} connected right now

+
+ All players +
+
+
RoomVisitsVisitorsCheers
{r.name}{r.visitCount.toLocaleString()}{r.visitorCount.toLocaleString()}{r.cheerCount.toLocaleString()}
+ + + + + + + + - ))} - {!stats && } - -
PlayerRoomInstanceLevelSeenActions
loading…
+ + + {onlinePlayers.map(p => ( + + +
+ +
+
{p.displayName || p.username}
+
@{p.username} · #{p.id}
+
+
+ {p.isAdmin && Admin} + {p.isJunior && Junior} + {p.isVerified && Verified} +
+
+ + + {p.currentRoom ? ( + + {p.currentRoom.name} + + ) : ( + Unknown + )} + {p.currentRoom?.isPrivate && Private} + + + {p.currentRoom ? ( + + #{p.currentRoom.roomInstanceId} · {p.currentRoom.photonRegionId} + + ) : '—'} + + {p.level} + + {relativeTime(p.lastSeenAt)} + + +
+ Open + Gift + + {!p.isAdmin && } +
+ + + ))} + {stats && onlinePlayers.length === 0 && ( + No online players. + )} + {!stats && !err && ( + Loading live players... + )} + + +
-
-

Recent signups

-

Latest 10 accounts.

-
- - - - - - - - {stats?.recentJoins.map(j => ( - - - - +
+ +
+ {stats?.rooms.activeSessions.map(s => ( +
+
+
{s.photonRoomName || `session-${s.id}`}
+ {s.playerCount}/{s.maxCapacity} +
+
+ room {s.roomId || 'unknown'} · {s.region} · {relativeTime(s.createdAt)} +
+
))} - {!stats &&
} - -
UsernameJoined
{j.username} #{j.id}{new Date(j.createdAt).toLocaleString()}
loading…
-
-
+ {stats && stats.rooms.activeSessions.length === 0 &&
No active sessions.
} +
+ -

Server time: {stats ? new Date(stats.serverTime).toLocaleString() : '—'}

+ +
+ {stats?.rooms.topByVisits.slice(0, 6).map((r, idx) => ( + +
+ {idx + 1} + {r.name} + {num(r.visitCount)} +
+ + ))} +
+
+
+ + +

Server time: {stats ? new Date(stats.serverTime).toLocaleString() : '-'}

+ + {action && ( + setAction(null)} + onConfirm={runAction} + /> + )} ); } -function Stat({ label, value, sub, accent }: { label: string; value: number | undefined; sub?: string | null; accent: 'success' | 'danger' | 'warn' | 'neutral' }) { - const accentRing = { - success: 'before:bg-success', - danger: 'before:bg-danger', - warn: 'before:bg-warn', - neutral: 'before:bg-brand-500', - }[accent]; +function Stat({ label, value, sub, icon, tone }: { label: string; value: number | undefined; sub?: string; icon: React.ReactNode; tone: 'success' | 'warn' | 'brand' | 'neutral' }) { + const toneClass = { + success: 'text-success bg-success/10 border-success/25', + warn: 'text-warn bg-warn/10 border-warn/25', + brand: 'text-brand-200 bg-brand-500/10 border-brand-500/25', + neutral: 'text-ink-200 bg-ink-800/60 border-ink-700', + }[tone]; return ( -
-
{label}
-
{value?.toLocaleString() ?? '—'}
- {sub &&
{sub}
} +
+
+
{icon}
+
+
{label}
+
{value?.toLocaleString() ?? '-'}
+
+
+ {sub &&
{sub}
} +
+ ); +} + +function Panel({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) { + return ( +
+
+

{title}

+

{subtitle}

+
+
{children}
+
+ ); +} + +function QuickAction({ action, onCancel, onConfirm }: { action: Exclude; onCancel: () => void; onConfirm: (reason: string) => void }) { + const [reason, setReason] = useState(action.kind === 'kick' ? 'Quick kick from live dashboard' : 'Quick ban from live dashboard'); + return ( +
+
+

+ {action.kind === 'kick' ? 'Kick player' : 'Ban player for 1 day'} +

+

@{action.username}

+ +