feat: implement full backend + frontend server detail, settings, and create server pages
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
This commit is contained in:
148
frontend/src/components/servers/PlayerTable.tsx
Normal file
148
frontend/src/components/servers/PlayerTable.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
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`;
|
||||
}
|
||||
Reference in New Issue
Block a user