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:
129
frontend/src/components/servers/MissionList.tsx
Normal file
129
frontend/src/components/servers/MissionList.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { Upload, Trash2 } from "lucide-react";
|
||||
|
||||
import { useServerMissions, useUploadMission, useDeleteMission } from "@/hooks/useServerDetail";
|
||||
import { useAuthStore } from "@/store/auth.store";
|
||||
import { useUIStore } from "@/store/ui.store";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
interface MissionListProps {
|
||||
serverId: number;
|
||||
}
|
||||
|
||||
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 uploadMission = useUploadMission(serverId);
|
||||
const deleteMission = useDeleteMission(serverId);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
await uploadMission.mutateAsync(file);
|
||||
addNotification({ type: "success", message: `Mission ${file.name} uploaded` });
|
||||
} catch (err) {
|
||||
logger.error("MissionList", "Failed to upload mission: %s", err);
|
||||
addNotification({ type: "error", message: "Failed to upload mission" });
|
||||
}
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const handleDelete = async (filename: string) => {
|
||||
try {
|
||||
await deleteMission.mutateAsync(filename);
|
||||
addNotification({ type: "info", message: `Mission ${filename} deleted` });
|
||||
} catch (err) {
|
||||
logger.error("MissionList", "Failed to delete mission %s: %s", filename, err);
|
||||
addNotification({ type: "error", message: `Failed to delete ${filename}` });
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-text-muted text-sm p-4">Loading missions...</div>;
|
||||
}
|
||||
|
||||
const missions = missionsData?.missions ?? [];
|
||||
|
||||
return (
|
||||
<div data-testid="mission-list">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-text-primary font-semibold">
|
||||
Missions ({missionsData?.total ?? 0})
|
||||
</h3>
|
||||
{isAdmin && (
|
||||
<label className="btn-primary flex items-center gap-1.5 text-sm cursor-pointer">
|
||||
<Upload size={14} />
|
||||
Upload .pbo
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pbo"
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
disabled={uploadMission.isPending}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uploadMission.isPending && (
|
||||
<div className="text-text-secondary text-sm mb-3 animate-pulse">Uploading mission...</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">Filename</th>
|
||||
<th className="text-left text-text-muted font-medium px-3 py-2">Mission</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="font-mono text-text-primary text-xs px-3 py-2">{mission.filename}</td>
|
||||
<td className="text-text-secondary px-3 py-2">{mission.name}</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">
|
||||
<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>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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`;
|
||||
}
|
||||
Reference in New Issue
Block a user