Files
languard-servers-manager/.claude/plan/arma3-ux-enhancement.md
2026-04-17 20:50:19 +07:00

967 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 | `[x] done` | TagListEditor, useServerConfigSchema, ConfigEditor widget routing, backend get_ui_schema + endpoint |
| 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 | `[x] done` | _parse_mod_cpp/_parse_meta_cpp in mod_manager, ModList split-pane redesign |
| 4 — Player Kick/Ban | `[x] done` | get_by_slot in PlayerRepository, get_rcon_client in ThreadRegistry, kick/ban endpoints, PlayerTable modals |
| 5 — Log File Browser | `[x] done` | list_log_files/get_log_file_path in RPTParser, logfiles_router (GET/download/DELETE), LogViewer file browser section |
**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 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 |
| 5 | Log File Browser + Level Filter | GOOD | ~3h |
| 6 | Server Card Quick Actions | ~~GOOD~~ | **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`:
```python
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:
```python
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:
```python
@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
```typescript
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
```typescript
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 `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:
```python
# 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:
```python
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:
```python
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:
```typescript
export interface Mission {
name: string;
filename: string;
size_bytes: number;
terrain: string; // new
}
```
Add rotation types and hooks:
```typescript
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[]`:
```typescript
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):
```typescript
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 (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):
```python
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()`:
```python
{
"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
```typescript
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 imports at top of file:
```python
from pydantic import BaseModel
from dependencies import require_admin
```
Add Pydantic schemas:
```python
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:
```python
@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", ""))`):
```python
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__`):
```python
@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
```python
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
```typescript
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 `.rpt` files to `C:\Users\<username>\AppData\Local\Arma 3` by default on Windows. The log directory should be configurable. Use a `log_dir` setting from server config (fall back to `server_dir / "logs"` if not set). The implementation below uses `server_dir / "logs"` as the convention for this app; the `ARMA3_LOG_DIR` env var or a config field can override it per-server.
```python
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 on `Arma3Adapter` (returns `RPTParser()`). No adapter changes needed — just call `adapter.get_log_parser()` directly.
```python
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
```python
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
```typescript
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:
```typescript
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)~~ **ALREADY IMPLEMENTED — SKIP**
**Verified:** `frontend/src/components/servers/ServerCard.tsx` already has full Start/Stop/Restart quick-action buttons (lines 71105), 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)
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** — 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-secondary` and `input-base` do NOT exist — use `btn-ghost` and `neu-input` respectively. Do NOT add new CSS files.
6. **Test file location**`frontend/src/__tests__/`. Mock hooks with `vi.mock("@/hooks/...")`. Follow `CreateServerPage.test.tsx` and existing test patterns.
7. **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).
8. **Immutability** — Never mutate state directly. Use spread (`[...arr]`, `{...obj}`) for all state updates.
9. **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).
10. **Boolean config fields** — Send as string `"true"` / `"false"`. The backend converts to actual Python bool.
11. **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).
12. **Multi-file upload** — Sequential, one file at a time. Show per-file `{ filename, done }` progress.
13. **Responsive layout** — Split-pane components (mods, potentially others) stack vertically on small screens using `flex-col` below `md:` breakpoint (`md:flex-row`).
14. **Mod folder names** — Show `display_name` when available; fall back to `name` (the raw folder path, e.g. `@CBA_A3`) as-is without stripping `@`.
15. **Kick/Ban buttons when offline** — Both buttons always visible for admins, disabled with `title="Server must be running"` tooltip when `server.status !== "running"`.
16. **Kick UX** — Use a modal dialog (same pattern as Ban) for consistency. Do not use inline row expansion.
17. **Ban duration** — Both presets (1h / 24h / 7d / Permanent) AND a free-text minutes input. Permanent = send `duration_minutes: null`.
18. **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).
19. **Delete confirmations** — Use a small modal dialog component, not `window.confirm()` (blocks browser events).
---
## Testing Checklist
Run after each phase:
```bash
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) — ALREADY IMPLEMENTED, no tests needed
### 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