Enhance admin dashboard: add new player count, active sessions, and online player details; improve stats display and layout

This commit is contained in:
Alexa
2026-06-02 02:33:31 +03:00
parent 5dbcf50d37
commit 27c560906f
4 changed files with 378 additions and 81 deletions
@@ -1680,16 +1680,83 @@ public class AdminController(
[HttpGet("stats")]
public async Task<ActionResult> 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<long, string>()
: 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,
});
}
+45 -1
View File
@@ -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;
+217 -75
View File
@@ -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<Stats | null>(null);
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [action, setAction] = useState<ActionState>(null);
const toast = useToast();
const load = async () => {
setLoading(true);
setErr(null);
try {
const data = await get<Stats>('/stats');
setStats(data);
setStats(await get<Stats>('/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 (
<div className="space-y-6">
<div className="flex items-end justify-between">
<div className="space-y-5">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-ink-50">Dashboard</h1>
<p className="text-sm text-ink-400">Server snapshot, refreshed every 10s.</p>
<h1 className="text-2xl font-semibold tracking-tight text-ink-50">Live Ops</h1>
<p className="text-sm text-ink-400">Online players, active rooms, and server health.</p>
</div>
<div className="flex gap-2">
<Link to="/broadcast" className="btn-secondary text-xs"><Megaphone /> Broadcast</Link>
<button onClick={load} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} />
Refresh
</button>
</div>
<button onClick={load} className="btn-secondary text-xs" disabled={loading}>
<RefreshCw className={loading ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{err && <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{err}</div>}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Stat label="Players online" value={stats?.players.onlineNow} accent="success" sub={stats && `${stats.players.total} total`} />
<Stat label="Banned now" value={stats?.players.bannedNow} accent="danger" sub={stats && `${stats.moderation.activeIpBans} IP bans`} />
<Stat label="Open reports" value={stats?.moderation.openReports} accent="warn" />
<Stat label="Total rooms" value={stats?.rooms.total} accent="neutral" sub={stats && `${stats.inventions} inventions`} />
</div>
<section className="grid grid-cols-2 xl:grid-cols-6 gap-3">
<Stat icon={<Users />} label="Online" value={stats?.players.onlineNow} sub={`${stats?.players.total ?? 0} accounts`} tone="success" />
<Stat icon={<Activity />} label="In game" value={stats?.rooms.inGamePlayerCount} sub={`${stats?.rooms.activeSessionCount ?? 0} sessions`} tone="brand" />
<Stat icon={<Building2 />} label="Rooms" value={stats?.rooms.total} sub={`${num(stats?.rooms.totalVisits ?? 0)} visits`} tone="neutral" />
<Stat icon={<Gift />} label="Inventions" value={stats?.inventions} sub={`${num(stats?.rooms.totalCheers ?? 0)} room cheers`} tone="neutral" />
<Stat icon={<Users />} label="New today" value={stats?.players.newToday} sub={`${stats?.photos.today ?? 0} photos`} tone="brand" />
<Stat icon={<Ban />} label="Moderation" value={stats?.moderation.openReports} sub={`${stats?.players.bannedNow ?? 0} banned`} tone="warn" />
</section>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="card p-4">
<h2 className="text-sm font-semibold text-ink-100">Top rooms by visits</h2>
<p className="text-xs text-ink-400 mb-3">All-time visit counts.</p>
<div className="table-scroll"><table className="w-full text-sm min-w-[480px]">
<thead className="text-[11px] uppercase tracking-wider text-ink-400 border-b border-ink-800">
<tr>
<th className="text-left font-medium pb-2">Room</th>
<th className="text-right font-medium pb-2">Visits</th>
<th className="text-right font-medium pb-2">Visitors</th>
<th className="text-right font-medium pb-2">Cheers</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-800">
{stats?.rooms.topByVisits.map(r => (
<tr key={r.id} className="table-row-hover">
<td className="py-2 text-ink-100">{r.name}</td>
<td className="py-2 text-right text-ink-200 tabular-nums">{r.visitCount.toLocaleString()}</td>
<td className="py-2 text-right text-ink-200 tabular-nums">{r.visitorCount.toLocaleString()}</td>
<td className="py-2 text-right text-ink-200 tabular-nums">{r.cheerCount.toLocaleString()}</td>
<section className="grid grid-cols-1 2xl:grid-cols-[minmax(0,1fr)_380px] gap-4">
<div className="card overflow-hidden">
<div className="border-b border-ink-800 px-4 py-3 flex flex-wrap items-center gap-2">
<div>
<h2 className="text-sm font-semibold text-ink-50">Online players</h2>
<p className="text-xs text-ink-400">{onlinePlayers.length} connected right now</p>
</div>
<Link to="/players" className="btn-ghost text-xs ml-auto">All players</Link>
</div>
<div className="table-scroll">
<table className="w-full text-sm min-w-[900px]">
<thead className="text-[11px] uppercase tracking-wider text-ink-400 bg-ink-900/60 border-b border-ink-800">
<tr>
<th className="text-left font-medium px-4 py-2.5">Player</th>
<th className="text-left font-medium px-4 py-2.5">Room</th>
<th className="text-left font-medium px-4 py-2.5">Instance</th>
<th className="text-right font-medium px-4 py-2.5">Level</th>
<th className="text-left font-medium px-4 py-2.5">Seen</th>
<th className="text-right font-medium px-4 py-2.5">Actions</th>
</tr>
))}
{!stats && <tr><td colSpan={4} className="py-6 text-center text-ink-400 text-xs">loading…</td></tr>}
</tbody>
</table></div>
</thead>
<tbody className="divide-y divide-ink-800">
{onlinePlayers.map(p => (
<tr key={p.id} className="table-row-hover">
<td className="px-4 py-2.5">
<div className="flex items-center gap-2.5 min-w-0">
<PlayerAvatar name={p.profileImageName} displayName={p.displayName || p.username} size={34} />
<div className="min-w-0">
<div className="font-medium text-ink-50 truncate">{p.displayName || p.username}</div>
<div className="text-xs text-ink-400 truncate">@{p.username} <span className="text-ink-600">·</span> #{p.id}</div>
</div>
<div className="flex gap-1 ml-1">
{p.isAdmin && <span className="badge-admin">Admin</span>}
{p.isJunior && <span className="badge-junior">Junior</span>}
{p.isVerified && <span className="badge-neutral">Verified</span>}
</div>
</div>
</td>
<td className="px-4 py-2.5">
{p.currentRoom ? (
<Link to={`/rooms/${p.currentRoom.roomId}`} className="text-ink-100 hover:text-brand-200">
{p.currentRoom.name}
</Link>
) : (
<span className="text-ink-500">Unknown</span>
)}
{p.currentRoom?.isPrivate && <span className="badge-neutral ml-2">Private</span>}
</td>
<td className="px-4 py-2.5 font-mono text-xs text-ink-300">
{p.currentRoom ? (
<span title={p.currentRoom.photonRoomId}>
#{p.currentRoom.roomInstanceId} · {p.currentRoom.photonRegionId}
</span>
) : '—'}
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-ink-200">{p.level}</td>
<td className="px-4 py-2.5 text-xs text-ink-300" title={absoluteTime(p.lastSeenAt)}>
{relativeTime(p.lastSeenAt)}
</td>
<td className="px-4 py-2.5">
<div className="flex justify-end gap-1.5">
<Link to={`/players?query=${encodeURIComponent(p.username)}`} className="btn-ghost text-xs">Open</Link>
<Link to={`/gift?player=${p.id}`} className="btn-ghost text-xs">Gift</Link>
<button onClick={() => setAction({ playerId: p.id, username: p.username, kind: 'kick' })} className="btn-secondary text-xs">Kick</button>
{!p.isAdmin && <button onClick={() => setAction({ playerId: p.id, username: p.username, kind: 'ban' })} className="btn-danger text-xs">Ban 1d</button>}
</div>
</td>
</tr>
))}
{stats && onlinePlayers.length === 0 && (
<tr><td colSpan={6} className="py-10 text-center text-xs text-ink-400">No online players.</td></tr>
)}
{!stats && !err && (
<tr><td colSpan={6} className="py-10 text-center text-xs text-ink-400">Loading live players...</td></tr>
)}
</tbody>
</table>
</div>
</div>
<div className="card p-4">
<h2 className="text-sm font-semibold text-ink-100">Recent signups</h2>
<p className="text-xs text-ink-400 mb-3">Latest 10 accounts.</p>
<div className="table-scroll"><table className="w-full text-sm min-w-[480px]">
<thead className="text-[11px] uppercase tracking-wider text-ink-400 border-b border-ink-800">
<tr>
<th className="text-left font-medium pb-2">Username</th>
<th className="text-left font-medium pb-2">Joined</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-800">
{stats?.recentJoins.map(j => (
<tr key={j.id} className="table-row-hover">
<td className="py-2 text-ink-100">{j.username} <span className="text-ink-500 text-xs">#{j.id}</span></td>
<td className="py-2 text-ink-300 text-xs">{new Date(j.createdAt).toLocaleString()}</td>
</tr>
<div className="space-y-4">
<Panel title="Active sessions" subtitle={`${stats?.rooms.activeSessionCount ?? 0} open`}>
<div className="space-y-2">
{stats?.rooms.activeSessions.map(s => (
<div key={s.id} className="rounded-lg border border-ink-800 bg-ink-950/40 p-3">
<div className="flex items-center justify-between gap-2">
<div className="font-mono text-xs text-ink-200 truncate">{s.photonRoomName || `session-${s.id}`}</div>
<span className="badge-online">{s.playerCount}/{s.maxCapacity}</span>
</div>
<div className="mt-1 text-[11px] text-ink-500">
room {s.roomId || 'unknown'} · {s.region} · {relativeTime(s.createdAt)}
</div>
</div>
))}
{!stats && <tr><td colSpan={2} className="py-6 text-center text-ink-400 text-xs">loading…</td></tr>}
</tbody>
</table></div>
</div>
</div>
{stats && stats.rooms.activeSessions.length === 0 && <div className="text-xs text-ink-500 py-4">No active sessions.</div>}
</div>
</Panel>
<p className="text-[11px] text-ink-500">Server time: {stats ? new Date(stats.serverTime).toLocaleString() : '—'}</p>
<Panel title="Top rooms" subtitle="By visits">
<div className="space-y-2">
{stats?.rooms.topByVisits.slice(0, 6).map((r, idx) => (
<Link key={r.id} to={`/rooms/${r.id}`} className="block rounded-lg border border-ink-800 bg-ink-950/40 px-3 py-2 hover:bg-ink-800/60">
<div className="flex items-center gap-2">
<span className="text-xs text-ink-500 w-5 tabular-nums">{idx + 1}</span>
<span className="text-sm text-ink-100 truncate flex-1">{r.name}</span>
<span className="text-xs text-ink-300 tabular-nums">{num(r.visitCount)}</span>
</div>
</Link>
))}
</div>
</Panel>
</div>
</section>
<p className="text-[11px] text-ink-500">Server time: {stats ? new Date(stats.serverTime).toLocaleString() : '-'}</p>
{action && (
<QuickAction
action={action}
onCancel={() => setAction(null)}
onConfirm={runAction}
/>
)}
</div>
);
}
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 (
<div className={`card p-4 relative overflow-hidden before:absolute before:left-0 before:top-0 before:h-full before:w-0.5 ${accentRing}`}>
<div className="text-[11px] uppercase tracking-widest text-ink-400">{label}</div>
<div className="mt-1 text-2xl font-semibold tabular-nums text-ink-50">{value?.toLocaleString() ?? '—'}</div>
{sub && <div className="mt-0.5 text-xs text-ink-400">{sub}</div>}
<div className="card !rounded-lg p-4">
<div className="flex items-start justify-between gap-2">
<div className={`rounded-md border p-2 ${toneClass}`}>{icon}</div>
<div className="text-right">
<div className="text-[10px] uppercase tracking-widest text-ink-400">{label}</div>
<div className="mt-1 text-2xl font-semibold tabular-nums text-ink-50">{value?.toLocaleString() ?? '-'}</div>
</div>
</div>
{sub && <div className="mt-2 text-xs text-ink-400 truncate">{sub}</div>}
</div>
);
}
function Panel({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) {
return (
<div className="card !rounded-lg overflow-hidden">
<div className="border-b border-ink-800 px-4 py-3">
<h2 className="text-sm font-semibold text-ink-50">{title}</h2>
<p className="text-xs text-ink-400">{subtitle}</p>
</div>
<div className="p-3">{children}</div>
</div>
);
}
function QuickAction({ action, onCancel, onConfirm }: { action: Exclude<ActionState, null>; onCancel: () => void; onConfirm: (reason: string) => void }) {
const [reason, setReason] = useState(action.kind === 'kick' ? 'Quick kick from live dashboard' : 'Quick ban from live dashboard');
return (
<div className="fixed inset-0 z-50 grid place-items-center bg-ink-950/70 px-4">
<div className="card !rounded-lg w-full max-w-md p-4">
<h2 className="text-sm font-semibold text-ink-50">
{action.kind === 'kick' ? 'Kick player' : 'Ban player for 1 day'}
</h2>
<p className="mt-1 text-xs text-ink-400">@{action.username}</p>
<label className="label block mt-4">Reason</label>
<textarea value={reason} onChange={e => setReason(e.target.value)} rows={3} className="input mt-1.5" />
<div className="mt-4 flex justify-end gap-2">
<button onClick={onCancel} className="btn-ghost text-xs">Cancel</button>
<button onClick={() => onConfirm(reason)} className={action.kind === 'kick' ? 'btn-secondary text-xs' : 'btn-danger text-xs'}>
{action.kind === 'kick' ? 'Kick' : 'Ban 1d'}
</button>
</div>
</div>
</div>
);
}
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api, get } from '../lib/api';
import type { Player, PlayerDetail } from '../lib/types';
import { absoluteTime, currencyName, num, relativeTime } from '../lib/format';
@@ -11,7 +12,8 @@ import { PlayerAvatar } from '../components/PlayerAvatar';
import { RefreshCw, Search } from '../components/Icons';
export function Players() {
const [query, setQuery] = useState('');
const [params] = useSearchParams();
const [query, setQuery] = useState(params.get('query') ?? '');
const [rows, setRows] = useState<Player[] | null>(null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
@@ -38,6 +40,11 @@ export function Players() {
useEffect(() => { search(query); }, [query, search]);
useEffect(() => {
const next = params.get('query') ?? '';
if (next !== query) setQuery(next);
}, [params, query]);
return (
<div>
<PageHeader