feat: Phase 2 — Mission rotation management + multi-file upload

- Backend: add terrain field to Arma3MissionManager.list_missions()
- Backend: add missions field to ServerConfig Pydantic model
- Backend: add GET /missions/rotation and PUT /missions/rotation endpoints
- Frontend: Mission type gains terrain field; new MissionRotationEntry type
- Frontend: useServerMissionRotation and useUpdateMissionRotation hooks
- Frontend: useUploadMission updated to accept File[] with sequential upload
- Frontend: MissionList redesigned with Available Missions + Mission Rotation sections
- Frontend: per-file upload progress tracking, terrain badges, difficulty select
- Tests: 5 new tests; fixed existing useUploadMission test for File[] API; 141 pass
This commit is contained in:
Tran G. (Revernomad) Khoa
2026-04-17 20:33:04 +07:00
parent dedf082491
commit 4aae08420b
9 changed files with 459 additions and 82 deletions

View File

@@ -13,7 +13,7 @@
| Phase | Status | Last session note | | Phase | Status | Last session note |
|-------|--------|------------------| |-------|--------|------------------|
| 1 — Config UI Schema | `[x] done` | TagListEditor, useServerConfigSchema, ConfigEditor widget routing, backend get_ui_schema + endpoint | | 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` | | | 3 — Mod Display Names + Split Pane | `[ ] not started` | |
| 4 — Player Kick/Ban | `[ ] not started` | | | 4 — Player Kick/Ban | `[ ] not started` | |
| 5 — Log File Browser | `[ ] not started` | | | 5 — Log File Browser | `[ ] not started` | |

View File

@@ -57,6 +57,7 @@ class ServerConfig(BaseModel):
headless_clients: list[str] = Field(default_factory=list) headless_clients: list[str] = Field(default_factory=list)
local_clients: list[str] = Field(default_factory=list) local_clients: list[str] = Field(default_factory=list)
admin_uids: list[str] = Field(default_factory=list) admin_uids: list[str] = Field(default_factory=list)
missions: list[dict] = Field(default_factory=list)
class BasicConfig(BaseModel): class BasicConfig(BaseModel):

View File

@@ -52,10 +52,12 @@ class Arma3MissionManager:
try: try:
for entry in missions_dir.iterdir(): for entry in missions_dir.iterdir():
if entry.is_file() and entry.suffix.lower() == _ALLOWED_EXTENSION: if entry.is_file() and entry.suffix.lower() == _ALLOWED_EXTENSION:
parsed = self.parse_mission_filename(entry.name)
missions.append({ missions.append({
"name": entry.stem, "name": entry.stem,
"filename": entry.name, "filename": entry.name,
"size_bytes": entry.stat().st_size, "size_bytes": entry.stat().st_size,
"terrain": parsed["terrain"],
}) })
except OSError as exc: except OSError as exc:
raise AdapterError(f"Cannot list missions: {exc}") from exc raise AdapterError(f"Cannot list missions: {exc}") from exc

View File

@@ -5,6 +5,7 @@ import logging
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
from pydantic import BaseModel
from sqlalchemy.engine import Connection from sqlalchemy.engine import Connection
from adapters.exceptions import AdapterError 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 _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): def _ok(data):
return {"success": True, "data": data, "error": None} 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) 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("") @router.get("")
def list_missions( def list_missions(
server_id: int, server_id: int,

View File

@@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
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);
});
});

View File

@@ -308,7 +308,7 @@ describe("useUploadMission", () => {
}); });
const file = new File(["mission data"], "mission.pbo", { type: "application/octet-stream" }); 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)); await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(apiClient.post).toHaveBeenCalledWith( expect(apiClient.post).toHaveBeenCalledWith(
`/api/servers/${SERVER_ID}/missions`, `/api/servers/${SERVER_ID}/missions`,

View File

@@ -1,7 +1,15 @@
import { useState, useRef } from "react"; import { useState, useRef, useEffect } from "react";
import { Upload, Trash2 } from "lucide-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 { useAuthStore } from "@/store/auth.store";
import { useUIStore } from "@/store/ui.store"; import { useUIStore } from "@/store/ui.store";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
@@ -10,24 +18,54 @@ interface MissionListProps {
serverId: number; serverId: number;
} }
const DIFFICULTY_OPTIONS = ["", "Recruit", "Regular", "Veteran", "Custom"];
interface UploadProgress {
filename: string;
done: boolean;
}
export function MissionList({ serverId }: MissionListProps) { export function MissionList({ serverId }: MissionListProps) {
const isAdmin = useAuthStore((s) => s.user?.role === "admin"); const isAdmin = useAuthStore((s) => s.user?.role === "admin");
const addNotification = useUIStore((s) => s.addNotification); 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 uploadMission = useUploadMission(serverId);
const deleteMission = useDeleteMission(serverId); const deleteMission = useDeleteMission(serverId);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [rotation, setRotation] = useState<MissionRotationEntry[]>([]);
const [uploadProgress, setUploadProgress] = useState<UploadProgress[]>([]);
// 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<HTMLInputElement>) => { const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const files = Array.from(e.target.files ?? []);
if (!file) return; if (files.length === 0) return;
const progress: UploadProgress[] = files.map((f) => ({ filename: f.name, done: false }));
setUploadProgress(progress);
try { try {
await uploadMission.mutateAsync(file); for (let i = 0; i < files.length; i++) {
addNotification({ type: "success", message: `Mission ${file.name} uploaded` }); 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) { } catch (err) {
logger.error("MissionList", "Failed to upload mission: %s", err); logger.error("MissionList", "Failed to upload missions: %s", err);
addNotification({ type: "error", message: "Failed to upload mission" }); addNotification({ type: "error", message: "Failed to upload one or more missions" });
} }
setUploadProgress([]);
if (fileInputRef.current) fileInputRef.current.value = ""; 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 <div className="text-text-muted text-sm p-4">Loading missions...</div>; return <div className="text-text-muted text-sm p-4">Loading missions...</div>;
} }
const missions = missionsData?.missions ?? []; const missions = missionsData?.missions ?? [];
return ( return (
<div data-testid="mission-list"> <div data-testid="mission-list" className="space-y-8">
<div className="flex items-center justify-between mb-4"> {/* Section A: Available Missions */}
<h3 className="text-text-primary font-semibold"> <div>
Missions ({missionsData?.total ?? 0}) <div className="flex items-center justify-between mb-3">
</h3> <h3 className="text-text-primary font-semibold">
{isAdmin && ( Available Missions ({missions.length})
<label className="btn-primary flex items-center gap-1.5 text-sm cursor-pointer"> </h3>
<Upload size={14} /> {isAdmin && (
Upload .pbo <label className="btn-primary flex items-center gap-1.5 text-sm cursor-pointer">
<input <Upload size={14} />
ref={fileInputRef} Upload .pbo
type="file" <input
accept=".pbo" ref={fileInputRef}
onChange={handleUpload} type="file"
className="hidden" accept=".pbo"
disabled={uploadMission.isPending} multiple
/> onChange={handleUpload}
</label> className="hidden"
disabled={uploadMission.isPending}
/>
</label>
)}
</div>
{uploadProgress.length > 0 && (
<div className="mb-3 space-y-1">
{uploadProgress.map((p) => (
<div key={p.filename} className="flex items-center gap-2 text-sm text-text-secondary">
{p.done ? (
<span className="text-green-400"></span>
) : (
<span className="animate-spin inline-block w-3 h-3 border border-accent border-t-transparent rounded-full" />
)}
{p.filename}
</div>
))}
</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">Mission Name</th>
<th className="text-left text-text-muted font-medium px-3 py-2">Terrain</th>
<th className="text-right text-text-muted font-medium px-3 py-2">Size</th>
{isAdmin && (
<th className="text-right text-text-muted font-medium px-3 py-2">Actions</th>
)}
</tr>
</thead>
<tbody>
{missions.length === 0 ? (
<tr>
<td colSpan={isAdmin ? 4 : 3} className="text-text-muted text-center py-6">
No missions uploaded
</td>
</tr>
) : (
missions.map((mission) => (
<tr
key={mission.filename}
className="border-b border-surface-overlay/50 hover:bg-surface-overlay/30"
>
<td className="text-text-primary px-3 py-2">{mission.name}</td>
<td className="px-3 py-2">
{mission.terrain ? (
<span className="bg-accent/20 text-accent text-xs px-2 py-0.5 rounded">
{mission.terrain}
</span>
) : (
<span className="text-text-muted text-xs"></span>
)}
</td>
<td className="text-right font-mono text-text-muted text-xs px-3 py-2">
{formatSize(mission.size_bytes)}
</td>
{isAdmin && (
<td className="text-right px-3 py-2">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => addToRotation(mission.name)}
disabled={rotation.some((r) => r.name === mission.name)}
className="btn-ghost text-xs flex items-center gap-1"
title="Add to rotation"
>
<Plus size={12} />
Rotation
</button>
<button
onClick={() => handleDelete(mission.filename)}
disabled={deleteMission.isPending}
className="btn-ghost text-status-crashed"
aria-label={`Delete ${mission.filename}`}
>
<Trash2 size={14} />
</button>
</div>
</td>
)}
</tr>
))
)}
</tbody>
</table>
</div>
</div> </div>
{uploadMission.isPending && ( {/* Section B: Mission Rotation */}
<div className="text-text-secondary text-sm mb-3 animate-pulse">Uploading mission...</div> <div>
)} <div className="flex items-center justify-between mb-3">
<h3 className="text-text-primary font-semibold">
Mission Rotation ({rotation.length})
</h3>
{isAdmin && (
<div className="flex gap-2">
<button
onClick={handleClearRotation}
className="btn-ghost text-sm text-status-crashed"
disabled={rotation.length === 0}
>
Clear
</button>
<button
onClick={handleSaveRotation}
disabled={updateRotation.isPending}
className="btn-primary flex items-center gap-1.5 text-sm"
>
<Save size={14} />
{updateRotation.isPending ? "Saving..." : "Save Rotation"}
</button>
</div>
)}
</div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-surface-overlay"> <tr className="border-b border-surface-overlay">
<th className="text-left text-text-muted font-medium px-3 py-2">Filename</th> <th className="text-left text-text-muted font-medium px-3 py-2">#</th>
<th className="text-left text-text-muted font-medium px-3 py-2">Mission</th> <th className="text-left text-text-muted font-medium px-3 py-2">Mission Name</th>
<th className="text-right text-text-muted font-medium px-3 py-2">Size</th> <th className="text-left text-text-muted font-medium px-3 py-2">Terrain</th>
{isAdmin && ( <th className="text-left text-text-muted font-medium px-3 py-2">Difficulty</th>
<th className="text-right text-text-muted font-medium px-3 py-2">Actions</th> {isAdmin && (
)} <th className="text-right text-text-muted font-medium px-3 py-2">Remove</th>
</tr> )}
</thead>
<tbody>
{missions.length === 0 ? (
<tr>
<td colSpan={isAdmin ? 4 : 3} className="text-text-muted text-center py-6">
No missions uploaded
</td>
</tr> </tr>
) : ( </thead>
missions.map((mission) => ( <tbody>
<tr key={mission.filename} className="border-b border-surface-overlay/50 hover:bg-surface-overlay/30"> {rotation.length === 0 ? (
<td className="font-mono text-text-primary text-xs px-3 py-2">{mission.filename}</td> <tr>
<td className="text-text-secondary px-3 py-2">{mission.name}</td> <td colSpan={isAdmin ? 5 : 4} className="text-text-muted text-center py-6">
<td className="text-right font-mono text-text-muted text-xs px-3 py-2"> No missions in rotation. Add from Available above.
{formatSize(mission.size_bytes)}
</td> </td>
{isAdmin && (
<td className="text-right px-3 py-2">
<button
onClick={() => handleDelete(mission.filename)}
disabled={deleteMission.isPending}
className="btn-ghost text-status-crashed"
aria-label={`Delete ${mission.filename}`}
>
<Trash2 size={14} />
</button>
</td>
)}
</tr> </tr>
)) ) : (
)} rotation.map((entry, idx) => {
</tbody> const missionFile = missions.find((m) => m.name === entry.name);
</table> return (
<tr
key={`${entry.name}-${idx}`}
className="border-b border-surface-overlay/50 hover:bg-surface-overlay/30"
>
<td className="text-text-muted font-mono text-xs px-3 py-2">{idx + 1}</td>
<td className="text-text-primary px-3 py-2">{entry.name}</td>
<td className="px-3 py-2">
{missionFile?.terrain ? (
<span className="bg-accent/20 text-accent text-xs px-2 py-0.5 rounded">
{missionFile.terrain}
</span>
) : (
<span className="text-text-muted text-xs"></span>
)}
</td>
<td className="px-3 py-2">
{isAdmin ? (
<select
className="neu-input text-sm py-1"
value={entry.difficulty}
onChange={(e) => updateDifficulty(idx, e.target.value)}
>
{DIFFICULTY_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt || "Default"}
</option>
))}
</select>
) : (
<span className="text-text-secondary">{entry.difficulty || "Default"}</span>
)}
</td>
{isAdmin && (
<td className="text-right px-3 py-2">
<button
onClick={() => removeFromRotation(idx)}
className="btn-ghost text-status-crashed"
aria-label={`Remove ${entry.name} from rotation`}
>
<X size={14} />
</button>
</td>
)}
</tr>
);
})
)}
</tbody>
</table>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -102,6 +102,12 @@ export interface Mission {
name: string; name: string;
filename: string; filename: string;
size_bytes: number; size_bytes: number;
terrain: string;
}
export interface MissionRotationEntry {
name: string;
difficulty: string;
} }
export interface MissionsResponse { 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) { export function useUploadMission(serverId: number) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (file: File) => { mutationFn: async (files: File[]) => {
const formData = new FormData(); for (const file of files) {
formData.append("file", file); const formData = new FormData();
return apiClient.post(`/api/servers/${serverId}/missions`, formData, { formData.append("file", file);
headers: { "Content-Type": "multipart/form-data" }, await apiClient.post(`/api/servers/${serverId}/missions`, formData, {
}); headers: { "Content-Type": "multipart/form-data" },
});
}
}, },
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["missions", serverId] }); queryClient.invalidateQueries({ queryKey: ["missions", serverId] });

View File

@@ -0,0 +1 @@
{"version":"4.1.4","results":[[":frontend/src/__tests__/MissionRotationPhase2.test.tsx",{"duration":0,"failed":true}]]}