Files
languard-servers-manager/.claude/plan/arma3-ux-enhancement.md
2026-04-17 14:57:30 +07:00

34 KiB

Plan: Arma 3 Adapter UX Enhancement

Status: APPROVED — Ready to implement Branch: main Estimated effort: ~20h total (6 phases)


Background & Context

What was analyzed

arma-server-web-admin (Node.js/Express + Backbone.js) was deep-analyzed as a UX benchmark. Reference documentation is in docs/ (this repo):

  • docs/ANALYSIS.md — feature inventory, tech stack, directory structure
  • docs/HOW_IT_WORKS.md — internal flows (server start/stop, mission upload, mod discovery, Socket.IO bridge)
  • docs/CHERRY_PICK.md — adapter candidates with file paths and adapter strategies

You do NOT need to read the original arma-server-web-admin source. All relevant patterns are documented in docs/ and self-contained below.

Problem statement

languard-servers-manager has a complete backend (42+ endpoints, Arma3ConfigGenerator, Arma3MissionManager, Arma3ModManager, Arma3BanManager, Arma3RemoteAdmin) but the frontend has gaps that make daily Arma 3 server administration painful:

Problem Root cause
All config fields render as text boxes ConfigEditor is generic; no per-field widget hints
Can't build a mission rotation Backend config supports missions[] array; no UI
Mods show @CBA_A3 not "Community Base Addons" mod.cpp not parsed; no display_name field
Can't kick a player from the UI Arma3RemoteAdmin.kick_player() exists; endpoint missing
Can't browse/download historical log files Only real-time WebSocket stream; no file browser
Must navigate to detail page to start/stop No quick-action buttons on ServerCard

Cross-Reference: Cherry-Pick Classification

MUST HAVE

Feature Gap in languard
Config field UI widgets (textarea/dropdown/toggle/tag-list) Generic <input> for everything
Kick player from Players tab Arma3RemoteAdmin.kick_player(slot_id, reason) exists but no HTTP endpoint
Mission rotation table + per-mission difficulty Backend: Arma3MissionManager.get_rotation_config() exists. Frontend: nothing
Multi-file mission upload useUploadMission accepts single File only

GOOD TO HAVE

Feature Gap
Mod display names (mod.cpp parsing) list_available_mods() returns path only, no display_name
Split-pane mod assignment UI Flat checkbox list
Server card start/stop quick actions Must navigate to ServerDetailPage
Log file browser + download WebSocket stream only
Admin UIDs tag list in config Hidden in raw JSON config editor
Ban from player list quick action User must go to Bans tab manually

OPTIONAL

Feature Note
Steam Workshop mission download Requires external steamcmd binary
Mod Steam Workshop ID (meta.cpp) Informational only
Log viewer level filter Pure client-side
Headless client count in UI Niche advanced feature

Current Codebase — Critical File Inventory

Backend (FastAPI + Python)

backend/
├── adapters/arma3/
│   ├── adapter.py            # Arma3Adapter — registers capabilities, returns sub-managers
│   ├── config_generator.py   # Arma3ConfigGenerator
│   │                           SECTIONS: ServerSection, BasicSection, ProfileSection,
│   │                           LaunchSection, RconSection
│   │                           KEY FIELDS IN ServerSection:
│   │                             hostname (str), max_players (int), password (str),
│   │                             admin_password (str), motd_lines (list[str]),
│   │                             forced_difficulty (str), battle_eye (bool), von (bool),
│   │                             admin_uids (list[str])
│   │                           KEY FIELDS IN LaunchSection:
│   │                             additional_args (list[str])
│   │                           METHODS: get_sections(), write_configs(), build_launch_args(),
│   │                           get_sensitive_fields(), get_defaults()
│   │                           ADD: get_ui_schema() -> dict
│   ├── mission_manager.py    # Arma3MissionManager
│   │                           list_missions() -> [{name, filename, size_bytes}]
│   │                           parse_mission_filename(fn) -> {mission_name, terrain, filename}
│   │                           get_rotation_config(entries) -> Arma3 missions config block string
│   │                           UPDATE: list_missions() add terrain field
│   ├── mod_manager.py        # Arma3ModManager
│   │                           list_available_mods() -> [{name, path, size_bytes, enabled}]
│   │                           get_enabled_mods(), set_enabled_mods(), build_mod_args()
│   │                           UPDATE: add display_name, workshop_id to list_available_mods()
│   ├── log_parser.py         # RPTParser
│   │                           parse_line(), get_log_file_resolver()
│   │                           ADD: list_log_files(server_dir), get_log_file_path(server_dir, filename)
│   ├── remote_admin.py       # Arma3RemoteAdmin (DO NOT MODIFY — stable)
│   │                           kick_player(slot_id, reason) -> bool  ← USE THIS
│   │                           ban_player(uid, duration_minutes, reason) -> bool
│   │                           get_players() -> list[dict]
│   └── ban_manager.py        # Arma3BanManager — bans.txt sync (stable)
│
├── core/servers/
│   ├── router.py             # Main server endpoints
│   │                           ADD: GET /api/servers/{id}/config/schema
│   ├── service.py            # ServerService
│   │                           ADD: get_config_schema(), kick_player(), ban_from_player()
│   ├── missions_router.py    # GET/POST/DELETE /api/servers/{id}/missions/*
│   │                           ADD: GET /api/servers/{id}/missions/rotation
│   │                           ADD: PUT /api/servers/{id}/missions/rotation
│   ├── players_router.py     # GET /api/servers/{id}/players
│   │                           ADD: POST /api/servers/{id}/players/{slot_id}/kick
│   │                           ADD: POST /api/servers/{id}/players/{slot_id}/ban
│   └── [NEW] logfiles_router.py
│         GET  /api/servers/{id}/logfiles
│         GET  /api/servers/{id}/logfiles/{filename}/download
│         DELETE /api/servers/{id}/logfiles/{filename}
│
└── main.py                   # ADD: include_router(logfiles_router)

Frontend (React 19 + TypeScript + TanStack Query)

frontend/src/
├── hooks/
│   └── useServerDetail.ts    # All query/mutation hooks for server detail
│                               ADD: useServerConfigSchema()
│                               ADD: useServerMissionRotation(), useUpdateMissionRotation()
│                               ADD: useKickPlayer(), useBanPlayer()
│                               ADD: useServerLogFiles(), useDeleteLogFile()
│                               UPDATE: useUploadMission(File[]) — was single File
│                               UPDATE: Mod type — add display_name, workshop_id
│                               UPDATE: Mission type — add terrain field
│
├── components/servers/
│   ├── ConfigEditor.tsx      # UPDATE: consume schema, render per-widget type
│   ├── MissionList.tsx       # REDESIGN: Available section + Rotation section
│   ├── ModList.tsx           # REDESIGN: split pane (Available vs Selected)
│   ├── PlayerTable.tsx       # UPDATE: add Actions column (Kick/Ban buttons, admin only)
│   ├── LogViewer.tsx         # UPDATE: level filter + Log Files browser section
│   └── ServerCard.tsx        # UPDATE: Start/Stop quick-action buttons
│
└── components/ui/
    └── [NEW] TagListEditor.tsx   # Dynamic string-list editor (reused in ConfigEditor)

Execution Order

Phase Description Priority Est.
1 Config UI Schema (widget hints per field) MUST ~4h
4 Player Kick/Ban MUST ~3h
2 Mission Rotation + Multi-file upload MUST ~5h
3 Mod Display Names + Split Pane GOOD ~4h
6 Server Card Quick Actions GOOD ~1h
5 Log File Browser + Level Filter GOOD ~3h

Phase 1 — Config UI Schema System (MUST HAVE, ~4h)

Goal: Each Arma 3 config field renders with the right UI widget instead of a generic text box.

1.1 backend/adapters/arma3/config_generator.py — add get_ui_schema()

Add this method to Arma3ConfigGenerator:

def get_ui_schema(self) -> dict:
    return {
        "server": {
            "hostname":             {"widget": "text",     "label": "Server Hostname"},
            "max_players":          {"widget": "number",   "label": "Max Players", "min": 1, "max": 256},
            "password":             {"widget": "password", "label": "Player Password"},
            "admin_password":       {"widget": "password", "label": "Admin Password"},
            "motd_lines":           {"widget": "textarea", "label": "Message of the Day (one line per row)"},
            "forced_difficulty":    {"widget": "select",   "label": "Difficulty Preset",
                                     "options": ["", "Recruit", "Regular", "Veteran", "Custom"]},
            "battle_eye":           {"widget": "toggle",   "label": "BattleEye Anti-Cheat"},
            "von":                  {"widget": "toggle",   "label": "Voice over Net (VoN)"},
            "verify_signatures":    {"widget": "toggle",   "label": "Verify Addon Signatures"},
            "persistent":           {"widget": "toggle",   "label": "Persistent (keep running when empty)"},
            "admin_uids":           {"widget": "tag-list", "label": "Admin Steam UIDs",
                                     "placeholder": "76561198000000000"},
        },
        "basic": {
            "max_packet_size":      {"widget": "number", "label": "Max Packet Size"},
            "max_custom_file_size": {"widget": "number", "label": "Max Custom File Size (bytes)"},
        },
        "launch": {
            "additional_args":      {"widget": "tag-list", "label": "Additional Startup Parameters",
                                     "placeholder": "-limitFPS=100"},
        },
        "rcon": {
            "password":             {"widget": "password", "label": "RCon Password"},
            "port":                 {"widget": "number",   "label": "RCon Port"},
        },
    }

1.2 backend/core/servers/service.py — add get_config_schema()

async def get_config_schema(self, server_id: int, db: Session) -> dict:
    server = self.server_repo.get_by_id(server_id, db)
    adapter = self.adapter_registry.get(server.game_type)
    config_gen = adapter.get_config_generator()
    if hasattr(config_gen, "get_ui_schema"):
        return config_gen.get_ui_schema()
    return {}

1.3 backend/core/servers/router.py — new endpoint

Add after existing config routes:

@router.get("/{server_id}/config/schema")
async def get_config_schema(
    server_id: int,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user),
):
    schema = await server_service.get_config_schema(server_id, db)
    return {"success": True, "data": schema}

1.4 frontend/src/hooks/useServerDetail.ts — add schema types + hook

export interface FieldSchema {
  widget: "text" | "number" | "password" | "textarea" | "select" | "toggle" | "tag-list";
  label?: string;
  placeholder?: string;
  min?: number;
  max?: number;
  options?: string[];
}

export interface ConfigSchema {
  [section: string]: { [field: string]: FieldSchema };
}

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,
  });
}

1.5 frontend/src/components/ui/TagListEditor.tsx — NEW component

interface TagListEditorProps {
  value: string[];
  onChange: (v: string[]) => void;
  placeholder?: string;
  disabled?: boolean;
}

export function TagListEditor({ value, onChange, placeholder, disabled }: TagListEditorProps) {
  const update = (idx: number, val: string) =>
    onChange(value.map((v, i) => (i === idx ? val : v)));
  const remove = (idx: number) => onChange(value.filter((_, i) => i !== idx));
  const add = () => onChange([...value, ""]);

  return (
    <div className="space-y-1">
      {value.map((item, idx) => (
        <div key={idx} className="flex gap-2">
          <input
            className="flex-1 input-base"
            value={item}
            placeholder={placeholder}
            disabled={disabled}
            onChange={(e) => update(idx, e.target.value)}
          />
          <button
            type="button"
            onClick={() => remove(idx)}
            disabled={disabled}
            className="btn-ghost text-status-crashed px-2"
          >
            
          </button>
        </div>
      ))}
      <button type="button" onClick={add} disabled={disabled} className="btn-secondary text-sm">
        + Add
      </button>
    </div>
  );
}

1.6 frontend/src/components/servers/ConfigEditor.tsx — consume schema

  • Import useServerConfigSchema and TagListEditor
  • Call useServerConfigSchema(serverId) alongside existing config queries
  • For each field in a config section, look up schema?.[sectionName]?.[fieldName]
  • Render based on widget:
    • "text"<input type="text" />
    • "number"<input type="number" min={} max={} />
    • "password"<input type="password" />
    • "textarea"<textarea rows={4} />
    • "select"<select> with schema.options.map(o => <option>)
    • "toggle"<input type="checkbox" /> (styled toggle)
    • "tag-list"<TagListEditor value={} onChange={} placeholder={} />
    • fallback → <input type="text" />
  • Use schema.label as the field label; fall back to field name (capitalized) if absent

Phase 2 — Mission Rotation Management (MUST HAVE, ~5h)

Goal: Users build/reorder a mission rotation with per-mission difficulty. Multi-file upload.

2.1 backend/adapters/arma3/mission_manager.py — add terrain to list_missions()

parse_mission_filename() already extracts terrain. Ensure list_missions() includes it:

# Current return per mission:
{"name": mission_name, "filename": filename, "size_bytes": size}
# Updated return:
{"name": mission_name, "filename": filename, "size_bytes": size, "terrain": terrain}

2.2 backend/core/servers/missions_router.py — add rotation endpoints

Add Pydantic schemas at top of file:

class MissionRotationEntry(BaseModel):
    name: str
    difficulty: str = ""

class MissionRotationUpdate(BaseModel):
    missions: list[MissionRotationEntry]
    config_version: int

Add endpoints:

@router.get("/{server_id}/missions/rotation")
async def get_mission_rotation(
    server_id: int, db=Depends(get_db), user=Depends(get_current_user)
):
    # Read server config section "server", field "missions"
    config = config_repo.get_section(server_id, "server", db)
    missions = config.get("missions", [])
    return {"success": True, "data": {"missions": missions}}

@router.put("/{server_id}/missions/rotation")
async def update_mission_rotation(
    server_id: int,
    body: MissionRotationUpdate,
    db=Depends(get_db),
    user=Depends(require_admin),
):
    # Update "missions" field in server config section with optimistic locking
    config_repo.update_field(
        server_id, "server", "missions",
        [e.model_dump() for e in body.missions],
        body.config_version, db
    )
    return {"success": True, "data": {"missions": [e.model_dump() for e in body.missions]}}

2.3 frontend/src/hooks/useServerDetail.ts — rotation hooks + updated types

Update Mission type:

export interface Mission {
  name: string;
  filename: string;
  size_bytes: number;
  terrain: string;   // new
}

Add rotation types and hooks:

export interface MissionRotationEntry {
  name: string;
  difficulty: string;
}

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"] }),
  });
}

Update useUploadMission to accept File[]:

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] }),
  });
}

2.4 frontend/src/components/servers/MissionList.tsx — redesign

Difficulty constant (define once, reuse here and in Phase 1 schema):

const DIFFICULTY_OPTIONS = ["", "Recruit", "Regular", "Veteran", "Custom"];

Component layout — two sections:

Section A: Available Missions

  • Table: Mission Name | Terrain badge | Size | Actions
  • Actions per row: "Add to Rotation" button + Delete button
  • Upload zone: <input type="file" multiple accept=".pbo" /> → calls useUploadMission(files)
  • Show per-file upload progress (filename + spinner/✓)

Section B: Mission Rotation

  • Table: # | Mission Name | Terrain | Difficulty | Remove
  • Row: index, name, terrain badge, <select> with DIFFICULTY_OPTIONS, remove button
  • "Save Rotation" → useUpdateMissionRotation({ missions: rotation, config_version })
  • "Clear Rotation" button → sets rotation to []

State:

  • rotation: MissionRotationEntry[] — local state, synced from query on load
  • uploadProgress: { filename: string; done: boolean }[] — per-file status

Phase 3 — Mod Display Names + Split Pane (GOOD TO HAVE, ~4h)

Goal: Parse mod.cpp for human-readable names. Redesign mod UI as split pane.

3.1 backend/adapters/arma3/mod_manager.py — add display_name + workshop_id

Add private helpers:

import re

def _parse_mod_cpp(mod_dir: Path) -> str | None:
    mod_cpp = mod_dir / "mod.cpp"
    if not mod_cpp.exists():
        return None
    text = mod_cpp.read_text(errors="ignore")
    m = re.search(r'name\s*=\s*"([^"]+)"', text, re.IGNORECASE)
    return m.group(1) if m else None

def _parse_meta_cpp(mod_dir: Path) -> str | None:
    meta_cpp = mod_dir / "meta.cpp"
    if not meta_cpp.exists():
        return None
    text = meta_cpp.read_text(errors="ignore")
    m = re.search(r'publishedid\s*=\s*(\d+)', text, re.IGNORECASE)
    return m.group(1) if m else None

Update each mod dict in list_available_mods():

{
    "name": str(rel_path),
    "path": str(mod_dir),
    "size_bytes": size,
    "enabled": name in enabled_set,
    "display_name": _parse_mod_cpp(mod_dir),   # new — None if no mod.cpp
    "workshop_id":  _parse_meta_cpp(mod_dir),  # new — None if no meta.cpp
}

3.2 frontend/src/hooks/useServerDetail.ts — update Mod type

export interface Mod {
  name: string;
  path: string;
  size_bytes: number;
  enabled: boolean;
  display_name: string | null;   // new
  workshop_id: string | null;    // new
}

3.3 frontend/src/components/servers/ModList.tsx — split pane redesign

CSS grid 2-column (50% each). Each pane:

  • Header: "Available (N)" / "Selected (N)"
  • Search <input> for client-side filter
  • Scrollable list of mod rows

Each mod row:

  • Primary text: display_name ?? name
  • Secondary text (small, muted): name (folder path)
  • Optional badge: "Workshop" if workshop_id !== null
  • File size (e.g., "1.2 GB")
  • Click → moves to other pane (immutable state update)

State:

  • available: Mod[] — mods with enabled === false
  • selected: Mod[] — mods with enabled === true
  • Initialized from query; mutations update local state only until "Apply"

Bottom of component:

  • "Apply Selection" button → useSetEnabledMods(selected.map(m => m.name))
  • Shows confirmation: "N mods selected. Server restart required for changes to take effect."

Phase 4 — Player Kick + Ban (MUST HAVE, ~3h)

Goal: Admin can kick or ban from the Players tab via RCon without using raw RCon console.

4.1 backend/core/servers/players_router.py — new action endpoints

Add Pydantic schemas:

class KickRequest(BaseModel):
    reason: str = "Kicked by admin"

class BanFromPlayerRequest(BaseModel):
    reason: str = "Banned by admin"
    duration_minutes: int | None = None  # None = permanent

Add endpoints:

@router.post("/{server_id}/players/{slot_id}/kick")
async def kick_player(
    server_id: int, slot_id: int,
    body: KickRequest,
    db=Depends(get_db), user=Depends(require_admin)
):
    await server_service.kick_player(server_id, slot_id, body.reason, db)
    return {"success": True, "data": {"message": f"Player {slot_id} kicked"}}

@router.post("/{server_id}/players/{slot_id}/ban")
async def ban_player_from_list(
    server_id: int, slot_id: int,
    body: BanFromPlayerRequest,
    db=Depends(get_db), user=Depends(require_admin)
):
    ban = await server_service.ban_from_player(
        server_id, slot_id, body.reason, body.duration_minutes, db
    )
    return {"success": True, "data": ban}

4.2 backend/core/servers/service.py — new service methods

async def kick_player(self, server_id: int, slot_id: int, reason: str, db: Session):
    ra = self.thread_registry.get_remote_admin(server_id)
    if not ra or not ra.is_connected():
        raise HTTPException(400, "RCon not connected — server must be running")
    success = ra.kick_player(slot_id, reason)
    if not success:
        raise HTTPException(500, "Kick command failed")

async def ban_from_player(
    self, server_id: int, slot_id: int,
    reason: str, duration_minutes: int | None, db: Session
) -> dict:
    player = self.player_repo.get_by_slot(server_id, slot_id, db)
    if not player:
        raise HTTPException(404, "Player not found")
    ra = self.thread_registry.get_remote_admin(server_id)
    if ra and ra.is_connected():
        ra.ban_player(player.guid, duration_minutes or 0, reason)
    ban = self.ban_repo.create(
        server_id=server_id, guid=player.guid, name=player.name,
        reason=reason, banned_by=current_user.username,
        duration_minutes=duration_minutes, db=db
    )
    return ban.to_dict()

4.3 frontend/src/hooks/useServerDetail.ts — kick/ban mutations

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] });
    },
  });
}

4.4 frontend/src/components/servers/PlayerTable.tsx — add Actions column

  • Import useKickPlayer, useBanPlayer, useAuthStore
  • Check isAdmin = useAuthStore(s => s.user?.role === "admin")
  • Add "Actions" column at far right, visible only when isAdmin
  • Kick flow: "Kick" button → inline reason input on that row → "Confirm" → kickPlayer({ slotId: player.slot_id, reason })
  • Ban flow: "Ban" button → small modal/dialog with reason textarea + optional duration (minutes) → "Confirm Ban" → banPlayer({ slotId, reason, durationMinutes })
  • Disable both buttons when server.status !== "running" with tooltip "Server must be running"

Phase 5 — Log File Browser (GOOD TO HAVE, ~3h)

Goal: Browse, download, and delete historical .rpt log files. Filter live log by level.

5.1 backend/adapters/arma3/log_parser.py — add file listing

def list_log_files(self, server_dir: Path) -> list[dict]:
    log_dir = server_dir / "server"
    if not log_dir.exists():
        return []
    files = sorted(log_dir.glob("*.rpt"), key=lambda f: f.stat().st_mtime, reverse=True)
    return [
        {
            "filename": f.name,
            "size_bytes": f.stat().st_size,
            "modified_at": f.stat().st_mtime,  # Unix timestamp float
        }
        for f in files
    ]

def get_log_file_path(self, server_dir: Path, filename: str) -> Path:
    """Returns absolute path with path-traversal protection."""
    log_dir = (server_dir / "server").resolve()
    path = (log_dir / filename).resolve()
    if not str(path).startswith(str(log_dir)):
        raise ValueError("Path traversal detected")
    if not path.exists():
        raise FileNotFoundError(filename)
    return path

5.2 backend/core/servers/logfiles_router.py — NEW file

from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from pathlib import Path

router = APIRouter(prefix="/api/servers", tags=["logfiles"])

@router.get("/{server_id}/logfiles")
async def list_log_files(
    server_id: int, db=Depends(get_db), user=Depends(get_current_user)
):
    server = server_repo.get_by_id(server_id, db)
    adapter = adapter_registry.get(server.game_type)
    log_parser = adapter.get_log_parser()
    server_dir = Path(settings.SERVERS_DIR) / str(server_id)
    return {"success": True, "data": log_parser.list_log_files(server_dir)}

@router.get("/{server_id}/logfiles/{filename}/download")
async def download_log_file(
    server_id: int, filename: str, db=Depends(get_db), user=Depends(get_current_user)
):
    server = server_repo.get_by_id(server_id, db)
    adapter = adapter_registry.get(server.game_type)
    log_parser = adapter.get_log_parser()
    server_dir = Path(settings.SERVERS_DIR) / str(server_id)
    try:
        path = log_parser.get_log_file_path(server_dir, filename)
    except (ValueError, FileNotFoundError) as e:
        raise HTTPException(404, str(e))
    return FileResponse(path, filename=filename, media_type="text/plain")

@router.delete("/{server_id}/logfiles/{filename}")
async def delete_log_file(
    server_id: int, filename: str, db=Depends(get_db), user=Depends(require_admin)
):
    server = server_repo.get_by_id(server_id, db)
    adapter = adapter_registry.get(server.game_type)
    log_parser = adapter.get_log_parser()
    server_dir = Path(settings.SERVERS_DIR) / str(server_id)
    try:
        path = log_parser.get_log_file_path(server_dir, filename)
        path.unlink()
    except (ValueError, FileNotFoundError) as e:
        raise HTTPException(404, str(e))
    return {"success": True, "data": {"message": f"{filename} deleted"}}

5.3 backend/main.py — register router

from backend.core.servers.logfiles_router import router as logfiles_router
app.include_router(logfiles_router)

5.4 frontend/src/hooks/useServerDetail.ts — log file hooks

export interface LogFile {
  filename: string;
  size_bytes: number;
  modified_at: number;  // Unix timestamp float
}

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"] }),
  });
}

5.5 frontend/src/components/servers/LogViewer.tsx — extend

Add level filter at top of component:

const [levelFilter, setLevelFilter] = useState<"all" | "info" | "warning" | "error">("all");
const filtered = levelFilter === "all" ? logs : logs.filter(l => l.level === levelFilter);
// Render filtered instead of logs

Filter buttons: [All] [Info] [Warning] [Error] — active button uses bg-accent class.

Add Log Files section below live stream (collapsible):

  • Uses useServerLogFiles(serverId) and useDeleteLogFile(serverId)
  • Table: Filename | Size | Modified | Actions (Download, Delete)
  • Download: open /api/servers/{id}/logfiles/{name}/download in new tab (with auth token in header via apiClient blob request → object URL → anchor click)
  • Delete: confirm prompt before calling mutation

Phase 6 — Server Card Quick Actions (GOOD TO HAVE, ~1h)

Goal: Start/Stop from dashboard without navigating to detail page.

6.1 frontend/src/components/servers/ServerCard.tsx

Check useServers.ts for existing start/stop mutation hooks (look for useStartServer, useStopServer, or useServerAction). Import and use them.

Add to card footer:

// Show Start when stopped or crashed
{(server.status === "stopped" || server.status === "crashed") && (
  <button
    onClick={(e) => { e.preventDefault(); startServer(); }}
    disabled={isStarting}
    className="btn-primary text-xs px-2 py-1"
    aria-label="Start server"
  >
    {isStarting ? <Spinner size="xs" /> : "▶ Start"}
  </button>
)}

// Show Stop when running or starting
{(server.status === "running" || server.status === "starting") && (
  <button
    onClick={(e) => { e.preventDefault(); stopServer(); }}
    disabled={isStopping}
    className="btn-secondary text-xs px-2 py-1"
    aria-label="Stop server"
  >
    {isStopping ? <Spinner size="xs" /> : "■ Stop"}
  </button>
)}

Note: e.preventDefault() is needed if the card itself is a link/clickable element — prevents navigation when clicking the action button.


Coding Conventions (for implementing agent)

  1. apiClient — All API calls use apiClient from @/lib/api (axios instance with JWT interceptor). Never use fetch directly.

  2. Mutations — Always follow useMutation from TanStack Query. On success, call queryClient.invalidateQueries({ queryKey: [...] }).

  3. Admin guard — Check useAuthStore(s => s.user?.role === "admin") to show/hide admin-only controls. Never hide entire sections — hide only action buttons/columns.

  4. Optimistic locking — Config PUT endpoints require config_version in the body (from _meta.config_version in the fetched config). A 409 response = conflict; display an error message to user.

  5. CSS classes — Use existing utility classes: neu-card, btn-primary, btn-secondary, btn-ghost, input-base, text-text-primary, text-text-muted, text-status-crashed, bg-accent, bg-surface-recessed, shadow-neu-recessed, shadow-neu-raised. Do NOT add new CSS files.

  6. Test file locationfrontend/src/__tests__/. Mock hooks with vi.mock("@/hooks/..."). Follow CreateServerPage.test.tsx and existing test patterns.

  7. Do NOT modifybackend/adapters/arma3/remote_admin.py (RCon client is stable), backend/core/websocket/ (WS manager is stable), backend/core/auth/ (auth is stable).

  8. Immutability — Never mutate state directly. Use spread ([...arr], {...obj}) for all state updates.


Testing Checklist

Run after each phase:

cd frontend && npx vitest run
cd frontend && npx tsc --noEmit

Phase 1 (Config Schema)

  • Config tab: forced_difficulty renders as <select>, not <input>
  • motd_lines renders as <textarea>
  • battle_eye renders as toggle checkbox
  • admin_uids renders as TagListEditor; add/remove a UID; save; confirm persisted

Phase 4 (Kick/Ban)

  • With running server + connected player: click Kick, enter reason, confirm; player disconnects
  • Click Ban, enter reason + duration; entry appears in Bans tab

Phase 2 (Mission Rotation)

  • Upload 3 .pbo files via multi-select; all appear in Available Missions
  • Add all to rotation, set different difficulties; Save; GET config shows missions array
  • Remove a mission from rotation; Save; confirms removed

Phase 3 (Mod Display Names)

  • Mod with mod.cpp shows display name instead of folder name
  • Click mod in Available pane → moves to Selected; click Apply → enabled = true in response
  • Search filter narrows visible mods in each pane

Phase 6 (Quick Actions)

  • Stopped server card shows ▶ Start; click → status transitions to running without navigation
  • Running server card shows ■ Stop; click → status transitions to stopped

Phase 5 (Log Files)

  • After server runs ≥1 min: Log Files section shows .rpt file
  • Download → file downloads; Delete → file removed and list refreshes
  • Click "Error" filter → only error-level lines in live stream