Backend: - Complete FastAPI backend with 42+ REST endpoints (auth, servers, config, players, bans, missions, mods, games, system) - Game adapter architecture with Arma 3 as first-class adapter - WebSocket real-time events for status, metrics, logs, players - Background thread system (process monitor, metrics, log tail, RCon poller) - Fernet encryption for sensitive config fields at rest - JWT auth with admin/viewer roles, bcrypt password hashing - SQLite with WAL mode, parameterized queries, migration system - APScheduler cleanup jobs for logs, metrics, events Frontend: - Server Detail page with 7 tabs (overview, config, players, bans, missions, mods, logs) - Settings page with password change and admin user management - Create Server wizard (4-step; known bug: silent validation failure) - New hooks: useServerDetail, useAuth, useGames - New components: ServerHeader, ConfigEditor, PlayerTable, BanTable, MissionList, ModList, LogViewer, PasswordChange, UserManager - WebSocket onEvent callback for real-time log accumulation - 120 unit tests passing (Vitest + React Testing Library) Docs: - Added .gitignore, CLAUDE.md, README.md - Updated FRONTEND.md, ARCHITECTURE.md with current implementation state - Added .env.example for backend configuration Known issues: - Create Server form: "Next" buttons don't validate before advancing, causing silent submit failure when fields are invalid - Config sub-tabs need UX redesign for non-technical users
148 lines
5.5 KiB
TypeScript
148 lines
5.5 KiB
TypeScript
import { useState } from "react";
|
|
|
|
import { useServerPlayers, useServerPlayerHistory } from "@/hooks/useServerDetail";
|
|
|
|
interface PlayerTableProps {
|
|
serverId: number;
|
|
}
|
|
|
|
export function PlayerTable({ serverId }: PlayerTableProps) {
|
|
const { data: playersData, isLoading } = useServerPlayers(serverId);
|
|
const [showHistory, setShowHistory] = useState(false);
|
|
|
|
if (isLoading) {
|
|
return <div className="text-text-muted text-sm p-4">Loading players...</div>;
|
|
}
|
|
|
|
const players = playersData?.players ?? [];
|
|
const playerCount = playersData?.player_count ?? 0;
|
|
|
|
return (
|
|
<div data-testid="player-table">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-text-primary font-semibold">
|
|
Online Players ({playerCount})
|
|
</h3>
|
|
<button
|
|
onClick={() => setShowHistory(!showHistory)}
|
|
className="btn-ghost text-sm"
|
|
>
|
|
{showHistory ? "Current Players" : "Player History"}
|
|
</button>
|
|
</div>
|
|
|
|
{showHistory ? (
|
|
<PlayerHistorySection serverId={serverId} />
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-surface-overlay">
|
|
<th className="text-left text-text-muted font-medium px-3 py-2">Slot</th>
|
|
<th className="text-left text-text-muted font-medium px-3 py-2">Name</th>
|
|
<th className="text-left text-text-muted font-medium px-3 py-2">GUID</th>
|
|
<th className="text-left text-text-muted font-medium px-3 py-2">IP</th>
|
|
<th className="text-right text-text-muted font-medium px-3 py-2">Ping</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{players.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="text-text-muted text-center py-6">
|
|
No players online
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
players.map((player) => (
|
|
<tr key={player.id} className="border-b border-surface-overlay/50 hover:bg-surface-overlay/30">
|
|
<td className="font-mono text-text-secondary px-3 py-2">{player.slot_id}</td>
|
|
<td className="text-text-primary px-3 py-2">{player.name}</td>
|
|
<td className="font-mono text-text-muted text-xs px-3 py-2">{player.guid}</td>
|
|
<td className="font-mono text-text-muted text-xs px-3 py-2">{player.ip}</td>
|
|
<td className="text-right font-mono text-text-secondary px-3 py-2">{player.ping}ms</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PlayerHistorySection({ serverId }: { serverId: number }) {
|
|
const [search, setSearch] = useState("");
|
|
const { data: historyData, isLoading } = useServerPlayerHistory(serverId, {
|
|
limit: 50,
|
|
search: search || undefined,
|
|
});
|
|
|
|
if (isLoading) {
|
|
return <div className="text-text-muted text-sm p-4">Loading history...</div>;
|
|
}
|
|
|
|
const entries = historyData?.items ?? [];
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-3">
|
|
<input
|
|
type="text"
|
|
placeholder="Search players..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="neu-input w-full text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-surface-overlay">
|
|
<th className="text-left text-text-muted font-medium px-3 py-2">Name</th>
|
|
<th className="text-left text-text-muted font-medium px-3 py-2">GUID</th>
|
|
<th className="text-left text-text-muted font-medium px-3 py-2">Joined</th>
|
|
<th className="text-left text-text-muted font-medium px-3 py-2">Left</th>
|
|
<th className="text-right text-text-muted font-medium px-3 py-2">Duration</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{entries.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="text-text-muted text-center py-6">
|
|
No player history
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
entries.map((entry) => (
|
|
<tr key={entry.id} className="border-b border-surface-overlay/50 hover:bg-surface-overlay/30">
|
|
<td className="text-text-primary px-3 py-2">{entry.name}</td>
|
|
<td className="font-mono text-text-muted text-xs px-3 py-2">{entry.guid}</td>
|
|
<td className="text-text-secondary text-xs px-3 py-2">{formatTime(entry.joined_at)}</td>
|
|
<td className="text-text-secondary text-xs px-3 py-2">
|
|
{entry.left_at ? formatTime(entry.left_at) : "--"}
|
|
</td>
|
|
<td className="text-right font-mono text-text-secondary px-3 py-2">
|
|
{entry.session_duration_seconds ? formatDuration(entry.session_duration_seconds) : "--"}
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function formatTime(iso: string): string {
|
|
return new Date(iso).toLocaleString();
|
|
}
|
|
|
|
function formatDuration(seconds: number): string {
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
if (h > 0) return `${h}h ${m}m`;
|
|
return `${m}m`;
|
|
} |