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:
Tran G. (Revernomad) Khoa
2026-04-17 11:58:34 +07:00
parent 620429c9b8
commit 6511353b55
119 changed files with 13752 additions and 5000 deletions

View 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`;
}