- .claude/plan/arma3-ux-enhancement.md: full plan review pass
- Add Progress Tracker table for session handoff
- Fix Phase 1 field names to match ServerConfig model (password_admin,
battleye, disable_von)
- Fix Phase 2 rotation endpoints to use ServerService(db) inline pattern
- Fix Phase 4 router/service: add get_by_slot() to PlayerRepository,
add get_rcon_client() to ThreadRegistry, fix BanRepository.create()
signature (expires_at not duration_minutes), correct router pattern
- Fix Phase 6: already implemented, mark as SKIP
- Fix CSS class names: btn-secondary→btn-ghost, input-base→neu-input
- Add 19 implementation decisions from Q&A session to Coding Conventions
- CLAUDE.md: update status table, type mapping table, add plan summary
and new endpoint list, add key implementation gotchas section
- frontend/README.md: replace Vite boilerplate with project README
- frontend/tests-e2e: E2E test improvements from previous session
(mock-based login error test, full dashboard mock coverage)
40 KiB
Plan: Arma 3 Adapter UX Enhancement
Status: APPROVED — Ready to implement Branch: main Estimated effort: ~20h total (5 active phases)
Progress Tracker
IMPLEMENTING AGENT: Update this section at the start and end of each session. Mark each phase
[x]when ALL its checklist items pass. This is the only reliable way for the next session to know where to pick up.
| Phase | Status | Last session note |
|---|---|---|
| 1 — Config UI Schema | [ ] not started |
|
| 2 — Mission Rotation | [ ] not started |
|
| 3 — Mod Display Names + Split Pane | [ ] not started |
|
| 4 — Player Kick/Ban | [ ] not started |
|
| 5 — Log File Browser | [ ] not started |
How to resume: Read this table first. Find the first phase that is not [x] done. Read only that phase section — do not re-read earlier phases. Run cd frontend && npx tsc --noEmit to confirm the build is clean before making any changes.
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 structuredocs/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 |
| 5 | Log File Browser + Level Filter | GOOD | ~3h |
| 6 | Server Card Quick Actions | DONE |
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": {
# Field names MUST match Arma3ConfigGenerator.ServerConfig exactly
"hostname": {"widget": "text", "label": "Server Hostname"},
"max_players": {"widget": "number", "label": "Max Players", "min": 1, "max": 1000},
"password": {"widget": "password", "label": "Player Password"},
"password_admin": {"widget": "password", "label": "Admin Password"}, # NOT 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"]},
"battleye": {"widget": "toggle", "label": "BattleEye Anti-Cheat"}, # NOT battle_eye
"disable_von": {"widget": "toggle", "label": "Disable Voice over Net (VoN)"}, # NOT von — and it's inverted
"verify_signatures": {"widget": "number", "label": "Verify Signatures (0=off, 1=on, 2=strict)", "min": 0, "max": 2},
"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()
Follow the existing pattern: ServerService.__init__ already stores self._server_repo and self._config_repo. No db param needed:
def get_config_schema(self, server_id: int) -> dict:
server = self.get_server(server_id) # raises 404 if not found, uses self._server_repo
adapter = GameAdapterRegistry.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, following the ServerService(db) inline pattern:
@router.get("/{server_id}/config/schema")
def get_config_schema(
server_id: int,
db: Annotated[Connection, Depends(get_db)],
_user: Annotated[dict, Depends(get_current_user)],
) -> dict:
schema = ServerService(db).get_config_schema(server_id)
return {"success": True, "data": schema, "error": None}
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 neu-input"
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-ghost text-sm">
+ Add
</button>
</div>
);
}
1.6 frontend/src/components/servers/ConfigEditor.tsx — consume schema
- Import
useServerConfigSchemaandTagListEditor - 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>withschema.options.map(o => <option>)"toggle"→<input type="checkbox" />(styled toggle)"tag-list"→<TagListEditor value={} onChange={} placeholder={} />- fallback →
<input type="text" />
- Use
schema.labelas 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 — follow the ServerService(db) inline pattern used by all existing routers:
from typing import Annotated
from sqlalchemy.engine import Connection
from core.servers.service import ServerService
@router.get("/rotation") # prefix already includes /{server_id}/missions
def get_mission_rotation(
server_id: int,
db: Annotated[Connection, Depends(get_db)],
_user: Annotated[dict, Depends(get_current_user)],
) -> dict:
# "server" section is always seeded on create — never None for existing server
config = ServerService(db).get_config_section(server_id, "server")
missions = config.get("missions", [])
return {"success": True, "data": {"missions": missions}, "error": None}
@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:
# ServerService.update_config_section() handles load-merge-upsert + 409 on conflict
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 {"success": True, "data": {"missions": updated.get("missions", [])}, "error": None}
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"] });
// Invalidate server config section too — missions are stored inside it
queryClient.invalidateQueries({ queryKey: ["servers", serverId, "config", "server"] });
},
});
}
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" />→ callsuseUploadMission(files) - Show per-file upload progress (filename + spinner/✓)
Section B: Mission Rotation
- Table: # | Mission Name | Terrain | Difficulty | Remove
- Row: index, name, terrain badge,
<select>withDIFFICULTY_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 loaduploadProgress: { filename: string; done: boolean }[]— per-file status (sequential uploads)
config_version source: Call useServerConfigSection(serverId, "server") inside MissionList. Read sectionData._meta.config_version and pass it as config_version when calling useUpdateMissionRotation. This hook already exists in useServerDetail.ts.
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 as module-level functions (not class methods — pure Path → str | None, no state needed, easier to test):
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 withenabled === falseselected: Mod[]— mods withenabled === 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 imports at top of file:
from pydantic import BaseModel
from dependencies import require_admin
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 — follow existing ServerService(db) inline pattern:
@router.post("/{slot_id}/kick") # prefix already is /servers/{server_id}/players
def kick_player(
server_id: int, slot_id: int,
body: KickRequest,
db: Annotated[Connection, Depends(get_db)],
_admin: Annotated[dict, Depends(require_admin)],
) -> dict:
ServerService(db).kick_player(server_id, slot_id, body.reason)
return {"success": True, "data": {"message": f"Player {slot_id} kicked"}, "error": None}
@router.post("/{slot_id}/ban")
def ban_player_from_list(
server_id: int, slot_id: int,
body: BanFromPlayerRequest,
db: Annotated[Connection, Depends(get_db)],
admin: Annotated[dict, Depends(require_admin)],
) -> dict:
ban = ServerService(db).ban_from_player(
server_id, slot_id, body.reason, body.duration_minutes,
banned_by=admin["username"],
)
return {"success": True, "data": ban, "error": None}
4.2 backend/core/dal/player_repository.py — add get_by_slot()
get_by_slot() does not exist yet. Add it. Note: slot_id is stored as a string in the DB (see upsert() which calls str(player.get("slot_id", ""))):
def get_by_slot(self, server_id: int, slot_id: int) -> dict | None:
return self._fetchone(
"SELECT * FROM players WHERE server_id = :sid AND slot_id = :slot",
{"sid": server_id, "slot": str(slot_id)}, # cast to str — stored as string in DB
)
4.2 backend/core/threads/thread_registry.py — add get_rcon_client()
ThreadRegistry.get_remote_admin() does not exist. Add this class method. The RCon client lives on bundle["rcon_poller"]._client (verified from RemoteAdminPollerThread.__init__):
@classmethod
def get_rcon_client(cls, server_id: int):
"""Return the live Arma3RemoteAdmin client for a running server, or None."""
registry = cls._get_instance()
if registry is None:
return None
bundle = registry._bundles.get(server_id)
if bundle is None:
return None
poller = bundle.get("rcon_poller")
if poller is None or not poller.is_alive():
return None
return getattr(poller, "_client", None)
4.2 backend/core/servers/service.py — new service methods
def kick_player(self, server_id: int, slot_id: int, reason: str) -> None:
from core.threads.thread_registry import ThreadRegistry
ra = ThreadRegistry.get_rcon_client(server_id) # use get_rcon_client, NOT get_remote_admin
if not ra or not ra.is_connected():
raise HTTPException(status.HTTP_400_BAD_REQUEST,
detail={"code": "RCON_NOT_CONNECTED", "message": "RCon not connected — server must be running"})
success = ra.kick_player(int(slot_id), reason) # kick_player takes int, slot stored as str
if not success:
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"code": "KICK_FAILED", "message": "Kick command failed"})
def ban_from_player(
self, server_id: int, slot_id: int,
reason: str, duration_minutes: int | None,
banned_by: str, # pass admin["username"] from router — service never accepts User objects
) -> dict:
from datetime import datetime, timezone, timedelta
from core.dal.player_repository import PlayerRepository
from core.dal.ban_repository import BanRepository
player = PlayerRepository(self._db).get_by_slot(server_id, int(slot_id))
if not player:
raise HTTPException(status.HTTP_404_NOT_FOUND,
detail={"code": "NOT_FOUND", "message": "Player not found"})
# Convert duration_minutes → expires_at ISO string (None = permanent)
expires_at = None
if duration_minutes is not None and duration_minutes > 0:
expires_at = (datetime.now(timezone.utc) + timedelta(minutes=duration_minutes)).isoformat()
from core.threads.thread_registry import ThreadRegistry
ra = ThreadRegistry.get_rcon_client(server_id)
if ra and ra.is_connected():
ra.ban_player(player["guid"], duration_minutes or 0, reason)
ban_repo = BanRepository(self._db)
ban_id = ban_repo.create(
server_id=server_id, guid=player["guid"], name=player["name"],
reason=reason, banned_by=banned_by,
expires_at=expires_at, # BanRepository.create() takes expires_at, NOT duration_minutes
)
return dict(ban_repo.get_by_id(ban_id)) # create() returns int id, get_by_id() returns 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
Log path: Arma 3 writes
.rptfiles toC:\Users\<username>\AppData\Local\Arma 3by default on Windows. The log directory should be configurable. Use alog_dirsetting from server config (fall back toserver_dir / "logs"if not set). The implementation below usesserver_dir / "logs"as the convention for this app; theARMA3_LOG_DIRenv var or a config field can override it per-server.
def list_log_files(self, server_dir: Path) -> list[dict]:
import os
log_dir = Path(os.environ.get("ARMA3_LOG_DIR", str(server_dir / "logs")))
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
Note:
adapter.get_log_parser()already exists onArma3Adapter(returnsRPTParser()). No adapter changes needed — just calladapter.get_log_parser()directly.
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)anduseDeleteLogFile(serverId) - Table: Filename | Size | Modified | Actions (Download, Delete)
- Download: open
/api/servers/{id}/logfiles/{name}/downloadin 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) ALREADY IMPLEMENTED — SKIP
Verified: frontend/src/components/servers/ServerCard.tsx already has full Start/Stop/Restart quick-action buttons (lines 71–105), including e.preventDefault() + e.stopPropagation(), pending states, lucide icons (Play, Square, RotateCcw), and error notifications via useUIStore. Nothing to do here.
Coding Conventions (for implementing agent)
-
apiClient — All API calls use
apiClientfrom@/lib/api(axios instance with JWT interceptor). Never usefetchdirectly. -
Mutations — Always follow
useMutationfrom TanStack Query. On success, callqueryClient.invalidateQueries({ queryKey: [...] }). -
Admin guard — Check
useAuthStore(s => s.user?.role === "admin")to show/hide admin-only controls. Never hide entire sections — hide only action buttons/columns. -
Optimistic locking — Config PUT endpoints require
config_versionin the body (from_meta.config_versionin the fetched config). A 409 response = conflict; display an error message to user. -
CSS classes — Existing utility classes:
neu-card,btn-primary,btn-ghost,btn-danger,neu-input,text-text-primary,text-text-muted,text-status-crashed,bg-accent,bg-surface-recessed,shadow-neu-recessed,shadow-neu-raised.btn-secondaryandinput-basedo NOT exist — usebtn-ghostandneu-inputrespectively. Do NOT add new CSS files. -
Test file location —
frontend/src/__tests__/. Mock hooks withvi.mock("@/hooks/..."). FollowCreateServerPage.test.tsxand existing test patterns. -
Do NOT modify —
backend/adapters/arma3/remote_admin.py(RCon client is stable),backend/core/websocket/(WS manager is stable),backend/core/auth/(auth is stable). -
Immutability — Never mutate state directly. Use spread (
[...arr],{...obj}) for all state updates. -
Missing config fields — If a field exists in
get_ui_schema()but is absent from the current section data, render it with an empty/default value (not hidden). -
Boolean config fields — Send as string
"true"/"false". The backend converts to actual Python bool. -
Password fields — Render as editable
<input type="password" />in edit mode with a show/hide toggle button. Toggle state is component-local (resets to hidden on navigation/reload). -
Multi-file upload — Sequential, one file at a time. Show per-file
{ filename, done }progress. -
Responsive layout — Split-pane components (mods, potentially others) stack vertically on small screens using
flex-colbelowmd:breakpoint (md:flex-row). -
Mod folder names — Show
display_namewhen available; fall back toname(the raw folder path, e.g.@CBA_A3) as-is without stripping@. -
Kick/Ban buttons when offline — Both buttons always visible for admins, disabled with
title="Server must be running"tooltip whenserver.status !== "running". -
Kick UX — Use a modal dialog (same pattern as Ban) for consistency. Do not use inline row expansion.
-
Ban duration — Both presets (1h / 24h / 7d / Permanent) AND a free-text minutes input. Permanent = send
duration_minutes: null. -
Log file download — Blob fetch via
apiClient→URL.createObjectURL()→ programmatic anchor click. Never open in new tab (auth header not sent by browser for new-tab navigations). -
Delete confirmations — Use a small modal dialog component, not
window.confirm()(blocks browser events).
Testing Checklist
Run after each phase:
cd frontend && npx vitest run
cd frontend && npx tsc --noEmit
Phase 1 (Config Schema)
- Config tab:
forced_difficultyrenders as<select>, not<input> motd_linesrenders as<textarea>battle_eyerenders as toggle checkboxadmin_uidsrenders 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
.pbofiles via multi-select; all appear in Available Missions - Add all to rotation, set different difficulties; Save; GET config shows
missionsarray - Remove a mission from rotation; Save; confirms removed
Phase 3 (Mod Display Names)
- Mod with
mod.cppshows display name instead of folder name - Click mod in Available pane → moves to Selected; click Apply →
enabled = truein response - Search filter narrows visible mods in each pane
Phase 6 (Quick Actions) — ALREADY IMPLEMENTED, no tests needed
Phase 5 (Log Files)
- After server runs ≥1 min: Log Files section shows
.rptfile - Download → file downloads; Delete → file removed and list refreshes
- Click "Error" filter → only error-level lines in live stream