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:
331
frontend/src/hooks/useServerDetail.ts
Normal file
331
frontend/src/hooks/useServerDetail.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user