- FieldSchema gains optional `advanced` boolean flag - ConfigEditor reads schema at top level and passes sectionSchema as prop - ConfigSectionForm filters out advanced fields by default; "Show advanced" toggle reveals them without entering edit mode - Profile (Difficulty) section shows an inline banner when forced_difficulty is not "Custom", guiding users to the right setting - All 173 frontend tests pass; tsc clean
457 lines
12 KiB
TypeScript
457 lines
12 KiB
TypeScript
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;
|
|
terrain: string;
|
|
}
|
|
|
|
export type MissionParamValue = number | string | boolean;
|
|
|
|
export interface MissionRotationEntry {
|
|
name: string;
|
|
difficulty: string;
|
|
params: Record<string, MissionParamValue>;
|
|
}
|
|
|
|
export interface MissionsResponse {
|
|
server_id: number;
|
|
missions: Mission[];
|
|
total: number;
|
|
}
|
|
|
|
export interface Mod {
|
|
name: string;
|
|
path: string;
|
|
size_bytes: number;
|
|
enabled: boolean;
|
|
is_server_mod: boolean;
|
|
display_name: string | null;
|
|
workshop_id: string | null;
|
|
}
|
|
|
|
export interface EnabledModEntry {
|
|
name: string;
|
|
is_server_mod: boolean;
|
|
}
|
|
|
|
export interface FieldSchema {
|
|
widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list" | "hidden" | "key-value";
|
|
label?: string;
|
|
placeholder?: string;
|
|
min?: number;
|
|
max?: number;
|
|
options?: string[];
|
|
advanced?: boolean;
|
|
}
|
|
|
|
export interface ConfigSchema {
|
|
[section: string]: { [field: string]: FieldSchema };
|
|
}
|
|
|
|
export interface ModsResponse {
|
|
server_id: number;
|
|
mods: Mod[];
|
|
enabled_count: number;
|
|
}
|
|
|
|
// ── Query Hooks ────────────────────────────────────────────────────────
|
|
|
|
export function useServerConfigSchema(serverId: number) {
|
|
return useQuery({
|
|
queryKey: ["servers", serverId, "config", "schema"],
|
|
queryFn: async () => {
|
|
const res = await apiClient.get<{ success: boolean; data: ConfigSchema }>(
|
|
`/api/servers/${serverId}/config/schema`,
|
|
);
|
|
return res.data.data;
|
|
},
|
|
enabled: serverId > 0,
|
|
});
|
|
}
|
|
|
|
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 useServerMissionRotation(serverId: number) {
|
|
return useQuery({
|
|
queryKey: ["missions", serverId, "rotation"],
|
|
queryFn: async () => {
|
|
const res = await apiClient.get<{
|
|
success: boolean;
|
|
data: { missions: MissionRotationEntry[] };
|
|
}>(`/api/servers/${serverId}/missions/rotation`);
|
|
return res.data.data.missions;
|
|
},
|
|
enabled: serverId > 0,
|
|
});
|
|
}
|
|
|
|
export function useUpdateMissionRotation(serverId: number) {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (data: { missions: MissionRotationEntry[]; config_version: number }) =>
|
|
apiClient.put(`/api/servers/${serverId}/missions/rotation`, data),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["missions", serverId, "rotation"] });
|
|
queryClient.invalidateQueries({ queryKey: ["servers", serverId, "config", "server"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useUploadMission(serverId: number) {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (files: File[]) => {
|
|
for (const file of files) {
|
|
const formData = new FormData();
|
|
formData.append("file", file);
|
|
await 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: EnabledModEntry[]) =>
|
|
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 }),
|
|
});
|
|
}
|
|
|
|
export function useKickPlayer(serverId: number) {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ slotId, reason }: { slotId: number; reason: string }) =>
|
|
apiClient.post(`/api/servers/${serverId}/players/${slotId}/kick`, { reason }),
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["players", serverId] }),
|
|
});
|
|
}
|
|
|
|
export function useBanPlayer(serverId: number) {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ slotId, reason, durationMinutes }: { slotId: number; reason: string; durationMinutes?: number }) =>
|
|
apiClient.post(`/api/servers/${serverId}/players/${slotId}/ban`, {
|
|
reason,
|
|
duration_minutes: durationMinutes ?? null,
|
|
}),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["players", serverId] });
|
|
queryClient.invalidateQueries({ queryKey: ["bans", serverId] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export interface LogFile {
|
|
filename: string;
|
|
size_bytes: number;
|
|
modified_at: number;
|
|
}
|
|
|
|
export function useServerLogFiles(serverId: number) {
|
|
return useQuery({
|
|
queryKey: ["servers", serverId, "logfiles"],
|
|
queryFn: async () => {
|
|
const res = await apiClient.get<{ success: boolean; data: LogFile[] }>(
|
|
`/api/servers/${serverId}/logfiles`,
|
|
);
|
|
return res.data.data;
|
|
},
|
|
enabled: serverId > 0,
|
|
refetchInterval: 30_000,
|
|
});
|
|
}
|
|
|
|
export function useDeleteLogFile(serverId: number) {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (filename: string) =>
|
|
apiClient.delete(`/api/servers/${serverId}/logfiles/${encodeURIComponent(filename)}`),
|
|
onSuccess: () =>
|
|
queryClient.invalidateQueries({ queryKey: ["servers", serverId, "logfiles"] }),
|
|
});
|
|
} |