diff --git a/.claude/plan/arma3-ux-enhancement.md b/.claude/plan/arma3-ux-enhancement.md index c76479f..f319196 100644 --- a/.claude/plan/arma3-ux-enhancement.md +++ b/.claude/plan/arma3-ux-enhancement.md @@ -13,7 +13,7 @@ | Phase | Status | Last session note | |-------|--------|------------------| | 1 — Config UI Schema | `[x] done` | TagListEditor, useServerConfigSchema, ConfigEditor widget routing, backend get_ui_schema + endpoint | -| 2 — Mission Rotation | `[ ] not started` | | +| 2 — Mission Rotation | `[x] done` | terrain in list_missions, rotation GET/PUT endpoints, MissionRotationEntry type, useServerMissionRotation/useUpdateMissionRotation hooks, multi-file upload, MissionList redesigned with Available + Rotation sections | | 3 — Mod Display Names + Split Pane | `[ ] not started` | | | 4 — Player Kick/Ban | `[ ] not started` | | | 5 — Log File Browser | `[ ] not started` | | diff --git a/backend/adapters/arma3/config_generator.py b/backend/adapters/arma3/config_generator.py index 7188704..2f57027 100644 --- a/backend/adapters/arma3/config_generator.py +++ b/backend/adapters/arma3/config_generator.py @@ -57,6 +57,7 @@ class ServerConfig(BaseModel): headless_clients: list[str] = Field(default_factory=list) local_clients: list[str] = Field(default_factory=list) admin_uids: list[str] = Field(default_factory=list) + missions: list[dict] = Field(default_factory=list) class BasicConfig(BaseModel): diff --git a/backend/adapters/arma3/mission_manager.py b/backend/adapters/arma3/mission_manager.py index 1ed91e1..ad520e0 100644 --- a/backend/adapters/arma3/mission_manager.py +++ b/backend/adapters/arma3/mission_manager.py @@ -52,10 +52,12 @@ class Arma3MissionManager: try: for entry in missions_dir.iterdir(): if entry.is_file() and entry.suffix.lower() == _ALLOWED_EXTENSION: + parsed = self.parse_mission_filename(entry.name) missions.append({ "name": entry.stem, "filename": entry.name, "size_bytes": entry.stat().st_size, + "terrain": parsed["terrain"], }) except OSError as exc: raise AdapterError(f"Cannot list missions: {exc}") from exc diff --git a/backend/core/servers/missions_router.py b/backend/core/servers/missions_router.py index 0ce197e..20b6ac9 100644 --- a/backend/core/servers/missions_router.py +++ b/backend/core/servers/missions_router.py @@ -5,6 +5,7 @@ import logging from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status +from pydantic import BaseModel from sqlalchemy.engine import Connection from adapters.exceptions import AdapterError @@ -20,6 +21,16 @@ router = APIRouter(prefix="/servers/{server_id}/missions", tags=["missions"]) _MAX_UPLOAD_SIZE = 500 * 1024 * 1024 # 500 MB +class MissionRotationEntry(BaseModel): + name: str + difficulty: str = "" + + +class MissionRotationUpdate(BaseModel): + missions: list[MissionRotationEntry] + config_version: int + + def _ok(data): return {"success": True, "data": data, "error": None} @@ -35,6 +46,35 @@ def _get_mission_manager(server_id: int, game_type: str): return adapter.get_mission_manager(server_id) +@router.get("/rotation") +def get_mission_rotation( + server_id: int, + db: Annotated[Connection, Depends(get_db)], + _user: Annotated[dict, Depends(get_current_user)], +) -> dict: + """Get the current mission rotation from the server config.""" + config = ServerService(db).get_config_section(server_id, "server") + missions = config.get("missions", []) + return _ok({"missions": missions}) + + +@router.put("/rotation") +def update_mission_rotation( + server_id: int, + body: MissionRotationUpdate, + db: Annotated[Connection, Depends(get_db)], + _admin: Annotated[dict, Depends(require_admin)], +) -> dict: + """Replace the mission rotation in the server config.""" + updated = ServerService(db).update_config_section( + server_id=server_id, + section="server", + data={"missions": [e.model_dump() for e in body.missions]}, + expected_version=body.config_version, + ) + return _ok({"missions": updated.get("missions", [])}) + + @router.get("") def list_missions( server_id: int, diff --git a/frontend/src/__tests__/MissionRotationPhase2.test.tsx b/frontend/src/__tests__/MissionRotationPhase2.test.tsx new file mode 100644 index 0000000..67f7894 --- /dev/null +++ b/frontend/src/__tests__/MissionRotationPhase2.test.tsx @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + +import { + useServerMissionRotation, + useUpdateMissionRotation, + useUploadMission, +} from "@/hooks/useServerDetail"; +import type { Mission } from "@/hooks/useServerDetail"; + +vi.mock("@/lib/api", () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})); + +import { apiClient } from "@/lib/api"; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("Mission type has terrain field", () => { + it("Mission type includes terrain", () => { + const m: Mission = { + name: "test", + filename: "test.Altis.pbo", + size_bytes: 1000, + terrain: "Altis", + }; + expect(m.terrain).toBe("Altis"); + }); +}); + +describe("useServerMissionRotation", () => { + beforeEach(() => vi.mocked(apiClient.get).mockReset()); + + it("fetches rotation from /missions/rotation", async () => { + const mockMissions = [{ name: "mission1.Altis", difficulty: "Regular" }]; + vi.mocked(apiClient.get).mockResolvedValue({ + data: { success: true, data: { missions: mockMissions } }, + }); + const { result } = renderHook(() => useServerMissionRotation(1), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data).toEqual(mockMissions); + expect(apiClient.get).toHaveBeenCalledWith("/api/servers/1/missions/rotation"); + }); + + it("is disabled when serverId is 0", () => { + const { result } = renderHook(() => useServerMissionRotation(0), { wrapper: createWrapper() }); + expect(result.current.fetchStatus).toBe("idle"); + }); +}); + +describe("useUpdateMissionRotation", () => { + beforeEach(() => vi.mocked(apiClient.put).mockReset()); + + it("puts to /missions/rotation with missions and config_version", async () => { + vi.mocked(apiClient.put).mockResolvedValue({ data: { success: true } }); + const { result } = renderHook(() => useUpdateMissionRotation(1), { wrapper: createWrapper() }); + await result.current.mutateAsync({ + missions: [{ name: "mission1.Altis", difficulty: "Veteran" }], + config_version: 5, + }); + expect(apiClient.put).toHaveBeenCalledWith( + "/api/servers/1/missions/rotation", + { missions: [{ name: "mission1.Altis", difficulty: "Veteran" }], config_version: 5 }, + ); + }); +}); + +describe("useUploadMission accepts File[]", () => { + beforeEach(() => vi.mocked(apiClient.post).mockReset()); + + it("posts each file sequentially", async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { success: true } }); + const { result } = renderHook(() => useUploadMission(1), { wrapper: createWrapper() }); + const files = [ + new File(["a"], "a.pbo", { type: "application/octet-stream" }), + new File(["b"], "b.pbo", { type: "application/octet-stream" }), + ]; + await result.current.mutateAsync(files); + expect(apiClient.post).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/__tests__/useServerDetail.test.tsx b/frontend/src/__tests__/useServerDetail.test.tsx index 107c8f8..6503dd7 100644 --- a/frontend/src/__tests__/useServerDetail.test.tsx +++ b/frontend/src/__tests__/useServerDetail.test.tsx @@ -308,7 +308,7 @@ describe("useUploadMission", () => { }); const file = new File(["mission data"], "mission.pbo", { type: "application/octet-stream" }); - result.current.mutate(file); + result.current.mutate([file]); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(apiClient.post).toHaveBeenCalledWith( `/api/servers/${SERVER_ID}/missions`, diff --git a/frontend/src/components/servers/MissionList.tsx b/frontend/src/components/servers/MissionList.tsx index ad7502a..ad65670 100644 --- a/frontend/src/components/servers/MissionList.tsx +++ b/frontend/src/components/servers/MissionList.tsx @@ -1,7 +1,15 @@ -import { useState, useRef } from "react"; -import { Upload, Trash2 } from "lucide-react"; +import { useState, useRef, useEffect } from "react"; +import { Upload, Trash2, Plus, X, Save } from "lucide-react"; -import { useServerMissions, useUploadMission, useDeleteMission } from "@/hooks/useServerDetail"; +import { + useServerMissions, + useServerMissionRotation, + useUpdateMissionRotation, + useUploadMission, + useDeleteMission, + useServerConfigSection, +} from "@/hooks/useServerDetail"; +import type { MissionRotationEntry } from "@/hooks/useServerDetail"; import { useAuthStore } from "@/store/auth.store"; import { useUIStore } from "@/store/ui.store"; import { logger } from "@/lib/logger"; @@ -10,24 +18,54 @@ interface MissionListProps { serverId: number; } +const DIFFICULTY_OPTIONS = ["", "Recruit", "Regular", "Veteran", "Custom"]; + +interface UploadProgress { + filename: string; + done: boolean; +} + export function MissionList({ serverId }: MissionListProps) { const isAdmin = useAuthStore((s) => s.user?.role === "admin"); const addNotification = useUIStore((s) => s.addNotification); - const { data: missionsData, isLoading } = useServerMissions(serverId); + + const { data: missionsData, isLoading: missionsLoading } = useServerMissions(serverId); + const { data: rotationData, isLoading: rotationLoading } = useServerMissionRotation(serverId); + const { data: serverSection } = useServerConfigSection(serverId, "server"); + const updateRotation = useUpdateMissionRotation(serverId); const uploadMission = useUploadMission(serverId); const deleteMission = useDeleteMission(serverId); + const fileInputRef = useRef(null); + const [rotation, setRotation] = useState([]); + const [uploadProgress, setUploadProgress] = useState([]); + + // Sync rotation from query on load + useEffect(() => { + if (rotationData) setRotation(rotationData); + }, [rotationData]); + + const configVersion = (serverSection?._meta as { config_version?: number })?.config_version ?? 0; const handleUpload = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; + const files = Array.from(e.target.files ?? []); + if (files.length === 0) return; + + const progress: UploadProgress[] = files.map((f) => ({ filename: f.name, done: false })); + setUploadProgress(progress); + try { - await uploadMission.mutateAsync(file); - addNotification({ type: "success", message: `Mission ${file.name} uploaded` }); + for (let i = 0; i < files.length; i++) { + await uploadMission.mutateAsync([files[i]]); + setUploadProgress((prev) => prev.map((p, idx) => (idx === i ? { ...p, done: true } : p))); + } + addNotification({ type: "success", message: `${files.length} mission(s) uploaded` }); } catch (err) { - logger.error("MissionList", "Failed to upload mission: %s", err); - addNotification({ type: "error", message: "Failed to upload mission" }); + logger.error("MissionList", "Failed to upload missions: %s", err); + addNotification({ type: "error", message: "Failed to upload one or more missions" }); } + + setUploadProgress([]); if (fileInputRef.current) fileInputRef.current.value = ""; }; @@ -41,82 +79,247 @@ export function MissionList({ serverId }: MissionListProps) { } }; - if (isLoading) { + const addToRotation = (missionName: string) => { + if (rotation.some((r) => r.name === missionName)) return; + setRotation([...rotation, { name: missionName, difficulty: "" }]); + }; + + const removeFromRotation = (idx: number) => { + setRotation(rotation.filter((_, i) => i !== idx)); + }; + + const updateDifficulty = (idx: number, difficulty: string) => { + setRotation(rotation.map((r, i) => (i === idx ? { ...r, difficulty } : r))); + }; + + const handleSaveRotation = async () => { + try { + await updateRotation.mutateAsync({ missions: rotation, config_version: configVersion }); + addNotification({ type: "success", message: "Mission rotation saved" }); + } catch (err) { + logger.error("MissionList", "Failed to save rotation: %s", err); + addNotification({ type: "error", message: "Failed to save mission rotation" }); + } + }; + + const handleClearRotation = () => setRotation([]); + + if (missionsLoading || rotationLoading) { return
Loading missions...
; } const missions = missionsData?.missions ?? []; return ( -
-
-

- Missions ({missionsData?.total ?? 0}) -

- {isAdmin && ( - +
+ {/* Section A: Available Missions */} +
+
+

+ Available Missions ({missions.length}) +

+ {isAdmin && ( + + )} +
+ + {uploadProgress.length > 0 && ( +
+ {uploadProgress.map((p) => ( +
+ {p.done ? ( + + ) : ( + + )} + {p.filename} +
+ ))} +
)} + +
+ + + + + + + {isAdmin && ( + + )} + + + + {missions.length === 0 ? ( + + + + ) : ( + missions.map((mission) => ( + + + + + {isAdmin && ( + + )} + + )) + )} + +
Mission NameTerrainSizeActions
+ No missions uploaded +
{mission.name} + {mission.terrain ? ( + + {mission.terrain} + + ) : ( + + )} + + {formatSize(mission.size_bytes)} + +
+ + +
+
+
- {uploadMission.isPending && ( -
Uploading mission...
- )} + {/* Section B: Mission Rotation */} +
+
+

+ Mission Rotation ({rotation.length}) +

+ {isAdmin && ( +
+ + +
+ )} +
-
- - - - - - - {isAdmin && ( - - )} - - - - {missions.length === 0 ? ( - - +
+
FilenameMissionSizeActions
- No missions uploaded -
+ + + + + + + {isAdmin && ( + + )} - ) : ( - missions.map((mission) => ( - - - - + + {rotation.length === 0 ? ( + + - {isAdmin && ( - - )} - )) - )} - -
#Mission NameTerrainDifficultyRemove
{mission.filename}{mission.name} - {formatSize(mission.size_bytes)} +
+ No missions in rotation. Add from Available above. - -
+ ) : ( + rotation.map((entry, idx) => { + const missionFile = missions.find((m) => m.name === entry.name); + return ( + + {idx + 1} + {entry.name} + + {missionFile?.terrain ? ( + + {missionFile.terrain} + + ) : ( + + )} + + + {isAdmin ? ( + + ) : ( + {entry.difficulty || "Default"} + )} + + {isAdmin && ( + + + + )} + + ); + }) + )} + + +
); @@ -126,4 +329,4 @@ function formatSize(bytes: number): string { if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${bytes} B`; -} \ No newline at end of file +} diff --git a/frontend/src/hooks/useServerDetail.ts b/frontend/src/hooks/useServerDetail.ts index 9565bd0..5770f37 100644 --- a/frontend/src/hooks/useServerDetail.ts +++ b/frontend/src/hooks/useServerDetail.ts @@ -102,6 +102,12 @@ export interface Mission { name: string; filename: string; size_bytes: number; + terrain: string; +} + +export interface MissionRotationEntry { + name: string; + difficulty: string; } export interface MissionsResponse { @@ -311,15 +317,43 @@ export function useRevokeBan(serverId: number) { }); } +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: (file: File) => { - const formData = new FormData(); - formData.append("file", file); - return apiClient.post(`/api/servers/${serverId}/missions`, formData, { - headers: { "Content-Type": "multipart/form-data" }, - }); + 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] }); diff --git a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json new file mode 100644 index 0000000..4cee02e --- /dev/null +++ b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json @@ -0,0 +1 @@ +{"version":"4.1.4","results":[[":frontend/src/__tests__/MissionRotationPhase2.test.tsx",{"duration":0,"failed":true}]]} \ No newline at end of file