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,331 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
// ── Types ──────────────────────────────────────────────────────────────
export interface EnrichedServer {
id: number;
name: string;
description: string | null;
game_type: string;
status: "stopped" | "running" | "starting" | "restarting" | "crashed";
pid: number | null;
exe_path: string;
game_port: number;
rcon_port: number | null;
auto_restart: boolean;
max_restarts: number;
restart_count: number;
last_restart_at: string | null;
started_at: string | null;
stopped_at: string | null;
created_at: string;
updated_at: string;
cpu_percent: number | null;
ram_mb: number | null;
player_count: number;
}
export interface ConfigSection {
[key: string]: unknown;
_meta: {
config_version: number;
schema_version: number;
};
}
export interface ConfigMap {
[sectionName: string]: ConfigSection;
}
export interface ConfigPreview {
[filename: string]: string;
}
export interface Player {
id: number;
server_id: number;
slot_id: number;
name: string;
guid: string;
ip: string;
ping: number;
game_data: string | null;
joined_at: string;
updated_at: string;
}
export interface PlayersResponse {
server_id: number;
player_count: number;
players: Player[];
}
export interface PlayerHistoryEntry {
id: number;
server_id: number;
name: string;
guid: string;
ip: string;
game_data: string | null;
joined_at: string;
left_at: string | null;
session_duration_seconds: number | null;
}
export interface PlayerHistoryResponse {
total: number;
items: PlayerHistoryEntry[];
}
export interface Ban {
id: number;
server_id: number;
guid: string;
name: string;
reason: string;
banned_by: string;
banned_at: string;
expires_at: string | null;
is_active: boolean;
game_data: string | null;
}
export interface CreateBanRequest {
player_uid: string;
ban_type?: "GUID" | "IP";
reason?: string;
duration_minutes?: number;
}
export interface Mission {
name: string;
filename: string;
size_bytes: number;
}
export interface MissionsResponse {
server_id: number;
missions: Mission[];
total: number;
}
export interface Mod {
name: string;
path: string;
size_bytes: number;
enabled: boolean;
}
export interface ModsResponse {
server_id: number;
mods: Mod[];
enabled_count: number;
}
// ── Query Hooks ────────────────────────────────────────────────────────
export function useServerConfig(serverId: number) {
return useQuery({
queryKey: ["servers", serverId, "config"],
queryFn: async () => {
const res = await apiClient.get<{ success: boolean; data: ConfigMap }>(
`/api/servers/${serverId}/config`,
);
return res.data.data;
},
enabled: serverId > 0,
});
}
export function useServerConfigSection(serverId: number, section: string) {
return useQuery({
queryKey: ["servers", serverId, "config", section],
queryFn: async () => {
const res = await apiClient.get<{
success: boolean;
data: ConfigSection;
}>(`/api/servers/${serverId}/config/${section}`);
return res.data.data;
},
enabled: serverId > 0 && !!section,
});
}
export function useServerConfigPreview(serverId: number) {
return useQuery({
queryKey: ["servers", serverId, "config", "preview"],
queryFn: async () => {
const res = await apiClient.get<{
success: boolean;
data: ConfigPreview;
}>(`/api/servers/${serverId}/config/preview`);
return res.data.data;
},
enabled: serverId > 0,
});
}
export function useServerPlayers(serverId: number) {
return useQuery({
queryKey: ["players", serverId],
queryFn: async () => {
const res = await apiClient.get<{
success: boolean;
data: PlayersResponse;
}>(`/api/servers/${serverId}/players`);
return res.data.data;
},
enabled: serverId > 0,
});
}
export function useServerPlayerHistory(
serverId: number,
opts?: { limit?: number; offset?: number; search?: string },
) {
return useQuery({
queryKey: ["players", serverId, "history", opts],
queryFn: async () => {
const params = new URLSearchParams();
if (opts?.limit) params.set("limit", String(opts.limit));
if (opts?.offset) params.set("offset", String(opts.offset));
if (opts?.search) params.set("search", opts.search);
const qs = params.toString();
const res = await apiClient.get<{
success: boolean;
data: PlayerHistoryResponse;
}>(`/api/servers/${serverId}/players/history${qs ? `?${qs}` : ""}`);
return res.data.data;
},
enabled: serverId > 0,
});
}
export function useServerBans(serverId: number) {
return useQuery({
queryKey: ["bans", serverId],
queryFn: async () => {
const res = await apiClient.get<{ success: boolean; data: Ban[] }>(
`/api/servers/${serverId}/bans`,
);
return res.data.data;
},
enabled: serverId > 0,
});
}
export function useServerMissions(serverId: number) {
return useQuery({
queryKey: ["missions", serverId],
queryFn: async () => {
const res = await apiClient.get<{
success: boolean;
data: MissionsResponse;
}>(`/api/servers/${serverId}/missions`);
return res.data.data;
},
enabled: serverId > 0,
});
}
export function useServerMods(serverId: number) {
return useQuery({
queryKey: ["mods", serverId],
queryFn: async () => {
const res = await apiClient.get<{
success: boolean;
data: ModsResponse;
}>(`/api/servers/${serverId}/mods`);
return res.data.data;
},
enabled: serverId > 0,
});
}
// ── Mutation Hooks ─────────────────────────────────────────────────────
export function useUpdateConfigSection(serverId: number, section: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Record<string, unknown>) =>
apiClient.put(`/api/servers/${serverId}/config/${section}`, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["servers", serverId, "config", section],
});
queryClient.invalidateQueries({
queryKey: ["servers", serverId, "config"],
});
},
});
}
export function useCreateBan(serverId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateBanRequest) =>
apiClient.post(`/api/servers/${serverId}/bans`, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["bans", serverId] });
},
});
}
export function useRevokeBan(serverId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (banId: number) =>
apiClient.delete(`/api/servers/${serverId}/bans/${banId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["bans", serverId] });
},
});
}
export function useUploadMission(serverId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (file: File) => {
const formData = new FormData();
formData.append("file", file);
return apiClient.post(`/api/servers/${serverId}/missions`, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["missions", serverId] });
},
});
}
export function useDeleteMission(serverId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (filename: string) =>
apiClient.delete(
`/api/servers/${serverId}/missions/${encodeURIComponent(filename)}`,
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["missions", serverId] });
},
});
}
export function useSetEnabledMods(serverId: number) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (mods: string[]) =>
apiClient.put(`/api/servers/${serverId}/mods/enabled`, { mods }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["mods", serverId] });
},
});
}
export function useSendCommand(serverId: number) {
return useMutation({
mutationFn: (command: string) =>
apiClient.post(`/api/servers/${serverId}/rcon/command`, { command }),
});
}