mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
Enhance admin dashboard: add new player count, active sessions, and online player details; improve stats display and layout
This commit is contained in:
@@ -1680,16 +1680,83 @@ public class AdminController(
|
|||||||
[HttpGet("stats")]
|
[HttpGet("stats")]
|
||||||
public async Task<ActionResult> Stats()
|
public async Task<ActionResult> Stats()
|
||||||
{
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
var totalPlayers = await db.Players.CountAsync();
|
var totalPlayers = await db.Players.CountAsync();
|
||||||
var totalRooms = await db.Rooms.CountAsync();
|
var totalRooms = await db.Rooms.CountAsync();
|
||||||
var totalInventions = await db.Inventions.CountAsync();
|
var totalInventions = await db.Inventions.CountAsync();
|
||||||
var openReports = await db.Reports.CountAsync(r => r.ResolvedAt == null);
|
var openReports = await db.Reports.CountAsync(r => r.ResolvedAt == null);
|
||||||
var bannedNow = await db.Players.CountAsync(p =>
|
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 =>
|
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 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
|
var topRooms = await db.Rooms
|
||||||
.OrderByDescending(r => r.VisitCount)
|
.OrderByDescending(r => r.VisitCount)
|
||||||
@@ -1697,6 +1764,31 @@ public class AdminController(
|
|||||||
.Select(r => new { r.Id, r.Name, r.VisitCount, r.VisitorCount, r.CheerCount })
|
.Select(r => new { r.Id, r.Name, r.VisitCount, r.VisitorCount, r.CheerCount })
|
||||||
.ToListAsync();
|
.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
|
var recentJoins = await db.Players
|
||||||
.OrderByDescending(p => p.CreatedAt)
|
.OrderByDescending(p => p.CreatedAt)
|
||||||
.Take(10)
|
.Take(10)
|
||||||
@@ -1710,16 +1802,28 @@ public class AdminController(
|
|||||||
Total = totalPlayers,
|
Total = totalPlayers,
|
||||||
OnlineNow = online.Length,
|
OnlineNow = online.Length,
|
||||||
BannedNow = bannedNow,
|
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,
|
Inventions = totalInventions,
|
||||||
|
Photos = new { Today = photosToday },
|
||||||
Moderation = new
|
Moderation = new
|
||||||
{
|
{
|
||||||
OpenReports = openReports,
|
OpenReports = openReports,
|
||||||
ActiveIpBans = activeIpBans,
|
ActiveIpBans = activeIpBans,
|
||||||
},
|
},
|
||||||
RecentJoins = recentJoins,
|
RecentJoins = recentJoins,
|
||||||
ServerTime = DateTime.UtcNow,
|
ServerTime = now,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,12 +170,56 @@ export interface Report {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Stats {
|
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: {
|
rooms: {
|
||||||
total: number;
|
total: number;
|
||||||
|
totalVisits: number;
|
||||||
|
totalCheers: number;
|
||||||
|
activeSessionCount: number;
|
||||||
|
inGamePlayerCount: number;
|
||||||
topByVisits: Array<{ id: number; name: string; visitCount: number; visitorCount: number; cheerCount: 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;
|
inventions: number;
|
||||||
|
photos: { today: number };
|
||||||
moderation: { openReports: number; activeIpBans: number };
|
moderation: { openReports: number; activeIpBans: number };
|
||||||
recentJoins: Array<{ id: number; username: string; createdAt: string }>;
|
recentJoins: Array<{ id: number; username: string; createdAt: string }>;
|
||||||
serverTime: string;
|
serverTime: string;
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 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() {
|
export function Dashboard() {
|
||||||
const [stats, setStats] = useState<Stats | null>(null);
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [action, setAction] = useState<ActionState>(null);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
try {
|
try {
|
||||||
const data = await get<Stats>('/stats');
|
setStats(await get<Stats>('/stats'));
|
||||||
setStats(data);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr((e as Error).message);
|
setErr((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -28,95 +35,230 @@ export function Dashboard() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-5">
|
||||||
<div className="flex items-end justify-between">
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight text-ink-50">Dashboard</h1>
|
<h1 className="text-2xl font-semibold tracking-tight text-ink-50">Live Ops</h1>
|
||||||
<p className="text-sm text-ink-400">Server snapshot, refreshed every 10s.</p>
|
<p className="text-sm text-ink-400">Online players, active rooms, and server health.</p>
|
||||||
</div>
|
</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}>
|
<button onClick={load} className="btn-secondary text-xs" disabled={loading}>
|
||||||
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{err && <div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger">{err}</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">
|
<section className="grid grid-cols-2 xl:grid-cols-6 gap-3">
|
||||||
<Stat label="Players online" value={stats?.players.onlineNow} accent="success" sub={stats && `${stats.players.total} total`} />
|
<Stat icon={<Users />} label="Online" value={stats?.players.onlineNow} sub={`${stats?.players.total ?? 0} accounts`} tone="success" />
|
||||||
<Stat label="Banned now" value={stats?.players.bannedNow} accent="danger" sub={stats && `${stats.moderation.activeIpBans} IP bans`} />
|
<Stat icon={<Activity />} label="In game" value={stats?.rooms.inGamePlayerCount} sub={`${stats?.rooms.activeSessionCount ?? 0} sessions`} tone="brand" />
|
||||||
<Stat label="Open reports" value={stats?.moderation.openReports} accent="warn" />
|
<Stat icon={<Building2 />} label="Rooms" value={stats?.rooms.total} sub={`${num(stats?.rooms.totalVisits ?? 0)} visits`} tone="neutral" />
|
||||||
<Stat label="Total rooms" value={stats?.rooms.total} accent="neutral" sub={stats && `${stats.inventions} inventions`} />
|
<Stat icon={<Gift />} label="Inventions" value={stats?.inventions} sub={`${num(stats?.rooms.totalCheers ?? 0)} room cheers`} tone="neutral" />
|
||||||
</div>
|
<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">
|
<section className="grid grid-cols-1 2xl:grid-cols-[minmax(0,1fr)_380px] gap-4">
|
||||||
<div className="card p-4">
|
<div className="card overflow-hidden">
|
||||||
<h2 className="text-sm font-semibold text-ink-100">Top rooms by visits</h2>
|
<div className="border-b border-ink-800 px-4 py-3 flex flex-wrap items-center gap-2">
|
||||||
<p className="text-xs text-ink-400 mb-3">All-time visit counts.</p>
|
<div>
|
||||||
<div className="table-scroll"><table className="w-full text-sm min-w-[480px]">
|
<h2 className="text-sm font-semibold text-ink-50">Online players</h2>
|
||||||
<thead className="text-[11px] uppercase tracking-wider text-ink-400 border-b border-ink-800">
|
<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>
|
<tr>
|
||||||
<th className="text-left font-medium pb-2">Room</th>
|
<th className="text-left font-medium px-4 py-2.5">Player</th>
|
||||||
<th className="text-right font-medium pb-2">Visits</th>
|
<th className="text-left font-medium px-4 py-2.5">Room</th>
|
||||||
<th className="text-right font-medium pb-2">Visitors</th>
|
<th className="text-left font-medium px-4 py-2.5">Instance</th>
|
||||||
<th className="text-right font-medium pb-2">Cheers</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-ink-800">
|
<tbody className="divide-y divide-ink-800">
|
||||||
{stats?.rooms.topByVisits.map(r => (
|
{onlinePlayers.map(p => (
|
||||||
<tr key={r.id} className="table-row-hover">
|
<tr key={p.id} className="table-row-hover">
|
||||||
<td className="py-2 text-ink-100">{r.name}</td>
|
<td className="px-4 py-2.5">
|
||||||
<td className="py-2 text-right text-ink-200 tabular-nums">{r.visitCount.toLocaleString()}</td>
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
<td className="py-2 text-right text-ink-200 tabular-nums">{r.visitorCount.toLocaleString()}</td>
|
<PlayerAvatar name={p.profileImageName} displayName={p.displayName || p.username} size={34} />
|
||||||
<td className="py-2 text-right text-ink-200 tabular-nums">{r.cheerCount.toLocaleString()}</td>
|
<div className="min-w-0">
|
||||||
</tr>
|
<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>
|
||||||
{!stats && <tr><td colSpan={4} className="py-6 text-center text-ink-400 text-xs">loading…</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-1 ml-1">
|
||||||
<div className="card p-4">
|
{p.isAdmin && <span className="badge-admin">Admin</span>}
|
||||||
<h2 className="text-sm font-semibold text-ink-100">Recent signups</h2>
|
{p.isJunior && <span className="badge-junior">Junior</span>}
|
||||||
<p className="text-xs text-ink-400 mb-3">Latest 10 accounts.</p>
|
{p.isVerified && <span className="badge-neutral">Verified</span>}
|
||||||
<div className="table-scroll"><table className="w-full text-sm min-w-[480px]">
|
</div>
|
||||||
<thead className="text-[11px] uppercase tracking-wider text-ink-400 border-b border-ink-800">
|
</div>
|
||||||
<tr>
|
</td>
|
||||||
<th className="text-left font-medium pb-2">Username</th>
|
<td className="px-4 py-2.5">
|
||||||
<th className="text-left font-medium pb-2">Joined</th>
|
{p.currentRoom ? (
|
||||||
</tr>
|
<Link to={`/rooms/${p.currentRoom.roomId}`} className="text-ink-100 hover:text-brand-200">
|
||||||
</thead>
|
{p.currentRoom.name}
|
||||||
<tbody className="divide-y divide-ink-800">
|
</Link>
|
||||||
{stats?.recentJoins.map(j => (
|
) : (
|
||||||
<tr key={j.id} className="table-row-hover">
|
<span className="text-ink-500">Unknown</span>
|
||||||
<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>
|
{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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{!stats && <tr><td colSpan={2} className="py-6 text-center text-ink-400 text-xs">loading…</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>
|
</tbody>
|
||||||
</table></div>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-[11px] text-ink-500">Server time: {stats ? new Date(stats.serverTime).toLocaleString() : '—'}</p>
|
<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 && stats.rooms.activeSessions.length === 0 && <div className="text-xs text-ink-500 py-4">No active sessions.</div>}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ label, value, sub, accent }: { label: string; value: number | undefined; sub?: string | null; accent: 'success' | 'danger' | 'warn' | 'neutral' }) {
|
function Stat({ label, value, sub, icon, tone }: { label: string; value: number | undefined; sub?: string; icon: React.ReactNode; tone: 'success' | 'warn' | 'brand' | 'neutral' }) {
|
||||||
const accentRing = {
|
const toneClass = {
|
||||||
success: 'before:bg-success',
|
success: 'text-success bg-success/10 border-success/25',
|
||||||
danger: 'before:bg-danger',
|
warn: 'text-warn bg-warn/10 border-warn/25',
|
||||||
warn: 'before:bg-warn',
|
brand: 'text-brand-200 bg-brand-500/10 border-brand-500/25',
|
||||||
neutral: 'before:bg-brand-500',
|
neutral: 'text-ink-200 bg-ink-800/60 border-ink-700',
|
||||||
}[accent];
|
}[tone];
|
||||||
return (
|
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="card !rounded-lg p-4">
|
||||||
<div className="text-[11px] uppercase tracking-widest text-ink-400">{label}</div>
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="mt-1 text-2xl font-semibold tabular-nums text-ink-50">{value?.toLocaleString() ?? '—'}</div>
|
<div className={`rounded-md border p-2 ${toneClass}`}>{icon}</div>
|
||||||
{sub && <div className="mt-0.5 text-xs text-ink-400">{sub}</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { api, get } from '../lib/api';
|
import { api, get } from '../lib/api';
|
||||||
import type { Player, PlayerDetail } from '../lib/types';
|
import type { Player, PlayerDetail } from '../lib/types';
|
||||||
import { absoluteTime, currencyName, num, relativeTime } from '../lib/format';
|
import { absoluteTime, currencyName, num, relativeTime } from '../lib/format';
|
||||||
@@ -11,7 +12,8 @@ import { PlayerAvatar } from '../components/PlayerAvatar';
|
|||||||
import { RefreshCw, Search } from '../components/Icons';
|
import { RefreshCw, Search } from '../components/Icons';
|
||||||
|
|
||||||
export function Players() {
|
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 [rows, setRows] = useState<Player[] | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
@@ -38,6 +40,11 @@ export function Players() {
|
|||||||
|
|
||||||
useEffect(() => { search(query); }, [query, search]);
|
useEffect(() => { search(query); }, [query, search]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const next = params.get('query') ?? '';
|
||||||
|
if (next !== query) setQuery(next);
|
||||||
|
}, [params, query]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
|||||||
Reference in New Issue
Block a user