Compare commits

...

3 Commits

Author SHA1 Message Date
Khoa (Revenovich) Tran Gia
34cc1fd008 docs: update plan file to reference local docs/ instead of external path 2026-04-17 14:57:30 +07:00
Khoa (Revenovich) Tran Gia
4ba199dd62 docs: add arma-server-web-admin analysis reference docs
Brings in ANALYSIS.md, HOW_IT_WORKS.md, and CHERRY_PICK.md generated from
deep analysis of the arma-server-web-admin benchmark project. These docs
inform the Arma 3 UX enhancement plan (.claude/plan/arma3-ux-enhancement.md)
and provide context for implementing agents without needing to re-read the
source project.
2026-04-17 14:55:59 +07:00
Khoa (Revenovich) Tran Gia
5d009d50d1 docs: add Arma 3 UX enhancement implementation plan
Cross-references arma-server-web-admin benchmark, classifies cherry-pick
candidates (must-have/good-to-have/optional), and provides a 6-phase
implementation plan covering config UI schema, mission rotation, mod display
names, player kick/ban, log file browser, and server card quick actions.
Plan is self-contained — no need to re-read the benchmark project to execute.
2026-04-17 14:53:37 +07:00
4 changed files with 1607 additions and 0 deletions

View File

@@ -0,0 +1,891 @@
# 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`:
```python
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()`
```python
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:
```python
@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
```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 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:
```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:
```python
@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:
```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"] }),
});
}
```
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
---
## 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:
```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 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:
```python
@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
```python
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
```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
```python
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
```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)
**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:
```typescript
// 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 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.
---
## 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)
- [ ] 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

155
docs/ANALYSIS.md Normal file
View File

@@ -0,0 +1,155 @@
# Arma Server Web Admin — Full Analysis
## Project Overview
**Arma Server Web Admin** is a Node.js/Express web application providing a browser-based administration panel for managing one or more Arma game server instances (Arma 1/2/2OA/3, CWA, OFP).
It consolidates all day-to-day server management tasks into a single UI:
- Launch and stop game server processes
- Monitor live player counts, current mission, and server state
- Upload and rotate missions (.pbo files, including Steam Workshop)
- Discover and assign mods per server
- Browse and download server log files
- Configure every server setting (ports, passwords, difficulty, mods, MOTD, etc.)
- Deploy headless clients automatically alongside the server process
---
## Technology Stack
| Layer | Technology |
|-------|-----------|
| HTTP server | Node.js + Express.js |
| Real-time | Socket.IO 2.x |
| Game process | arma-server (Node wrapper) |
| Game query | Gamedig |
| Steam Workshop | steam-workshop |
| File helpers | fs.extra, glob, multer |
| Async control | async 2.x |
| Frontend SPA | Backbone.js + Marionette.js |
| Templating | Underscore.js |
| UI framework | Bootstrap 3 |
| Build | Webpack v1 |
| Auth | express-basic-auth (optional) |
---
## Directory Structure
```
arma-server-web-admin/
├── app.js # Entry point: Express + Socket.IO + route wiring
├── config.js.example # All configuration options with defaults
├── config.docker.js # Docker-specific config overrides
├── webpack.config.js # Frontend bundling
├── package.json
├── lib/ # Backend business logic
│ ├── manager.js # Multi-server lifecycle manager (EventEmitter)
│ ├── server.js # Single server wrapper (start/stop/query/persist)
│ ├── missions.js # Mission file discovery, upload, Workshop download
│ ├── logs.js # Log file discovery, cleanup, platform paths
│ ├── settings.js # Config accessor (public subset for client)
│ ├── setup-basic-auth.js # Optional HTTP Basic Auth middleware
│ └── mods/
│ ├── index.js # Mod discovery + parallel metadata resolution
│ ├── folderSize.js # Recursive folder size calculator
│ ├── modFile.js # Parses mod.cpp for display name
│ └── steamMeta.js # Parses meta.cpp for Steam Workshop ID
├── routes/ # Express routers (each returns a router factory)
│ ├── servers.js # /api/servers/* (CRUD + start/stop)
│ ├── missions.js # /api/missions/* (list/upload/download/delete)
│ ├── mods.js # /api/mods/* (list/delete)
│ ├── logs.js # /api/logs/* (list/view/download/delete)
│ └── settings.js # /api/settings (GET public settings)
└── public/ # Single-page frontend (built by Webpack)
├── index.html
├── css/styles.css
└── js/
├── app.js # RequireJS bootstrap + Socket.IO init
└── app/
├── router.js # Backbone Router (5 routes)
├── models/ # Backbone Models
├── collections/ # Backbone Collections
└── views/ # Marionette views
```
---
## Feature Inventory
### Server Lifecycle
- Create / edit / delete server definitions persisted to `servers.json`
- Start / stop server processes (Windows `.exe`, Linux binary, Wine)
- Auto-start servers on application launch (`auto_start` flag)
- Process ID tracking
- 5-second polling via Gamedig for live state (players, mission, status)
### Configuration
- Title, port, max players
- Player password, admin password
- Message of the Day (MOTD, multi-line)
- BattleEye, VoN, signature verification, file patching, persistent mode
- Difficulty override (Recruit / Regular / Veteran / Custom)
- Per-server additional `server.cfg` text (freeform)
- Per-server startup parameters (e.g. `-limitFPS=100`)
- Per-server mod selection
- Per-server mission rotation with per-mission difficulty
### Mission Management
- List `.pbo` files from `mpmissions/`
- Upload up to 64 `.pbo` files at once (multipart)
- Download from Steam Workshop by ID
- Download mission files to browser
- Delete mission files
- View name, world name, file size, timestamps
### Mod Management
- Auto-discover mods via glob patterns (`@*`, `csla`, `ef`, etc.)
- Extract display name from `mod.cpp`
- Extract Steam Workshop ID from `meta.cpp`
- Calculate folder sizes recursively (symlink-aware)
- Delete mod folders
- Assign mods per server (split-pane UI)
### Log Management
- Discover `.rpt` log files from platform-appropriate paths:
- **Windows:** `AppData\Local\[Game]\`
- **Linux:** `[game_path]/logs/`
- **Wine:** `.wine/drive_c/users/.../Application Data/[Game]/`
- View log contents inline
- Download logs
- Delete logs
- Auto-cleanup: retain only the 20 newest log files
### Headless Clients
- Configure number of headless clients per server
- Auto-launch when server starts, auto-kill when server stops
- Connect to `127.0.0.1:[server_port]`
### Real-Time Updates
- Socket.IO push for: server state, mission list, mod list, settings
- All connected clients receive updates simultaneously
### Authentication
- Optional HTTP Basic Auth (single or multiple users)
- Credentials in `config.js`; no database
### Platform Support
- Windows, Linux, Wine, Docker
---
## Data Persistence
| Data | Storage |
|------|---------|
| Server definitions | `servers.json` (plain JSON, in-memory on load) |
| Mission files | `[game_path]/mpmissions/` (filesystem) |
| Mod files | `[game_path]/[mod_dirs]/` (filesystem) |
| Log files | Platform-specific log directory |
| App config | `config.js` (static, not written at runtime) |
No database. All runtime state lives in the `Manager` class and is flushed to `servers.json` on every mutation.

247
docs/CHERRY_PICK.md Normal file
View File

@@ -0,0 +1,247 @@
# Cherry-Pick Candidates for languard-servers-manager
This document lists modules and functions from `arma-server-web-admin` that are worth adapting into `languard-servers-manager`. Each entry explains what the code does, why it is valuable, and a concrete adapter strategy.
Target project: `E:\TestScript\languard-servers-manager` (FastAPI + React stack).
---
## 1. Manager Pattern — Multi-Server Lifecycle Registry
**Source:** `lib/manager.js`
**Key functions:** `load()`, `save()`, `addServer()`, `removeServer()`, `getServer()`, `getServers()`
**What it does:**
Maintains an in-memory registry of Server instances (both as an ordered array and a hash for O(1) lookup), flushes state to `servers.json` on every mutation, and emits change events so the Socket.IO layer can broadcast diffs.
**Why it is valuable:**
languard already has a similar concept (servers stored in SQLite), but the EventEmitter pattern that automatically triggers broadcasts on every mutation is clean and decoupled. The dual array+hash storage pattern is also worth copying for cache performance.
**Adapter strategy:**
```
Create: backend/core/servers/manager.py
- ServerManager class with in-memory cache (dict + list)
- on_change callback / asyncio.Event instead of EventEmitter
- load() reads from SQLite via existing ServerRepository
- save() writes back through repository
- Emit WebSocket broadcasts via FastAPI WebSocket manager on every mutation
```
---
## 2. Process Lifecycle — Start / Stop / Kill with Graceful Fallback
**Source:** `lib/server.js``start()`, `stop()` methods
**What it does:**
Spawns a child process for the game server, captures its PID, starts a status-poll interval, pipes stdout/stderr to a log file, and on stop sends SIGTERM with a 5-second SIGKILL fallback. Headless clients are launched/killed alongside the main process.
**Why it is valuable:**
languard's backend already launches processes, but the graceful stop with timed SIGKILL fallback and the headless client co-lifecycle are patterns not yet present.
**Adapter strategy:**
```
Adapt: backend/core/servers/process_manager.py
- async def start_server(config) -> asyncio.subprocess.Process
- async def stop_server(proc, timeout=5) -> SIGTERM then SIGKILL
- Capture PID, store in DB server record
- Pipe stdout/stderr to dated log file (matches existing log path logic)
```
---
## 3. Status Polling — Gamedig-style Periodic Query
**Source:** `lib/server.js``queryStatus()` + `setInterval` pattern
**What it does:**
Every 5 seconds, queries the running game server via the game's UDP query protocol (Gamedig). Stores the response (`players`, `mission`, `state`) in the server's in-memory state and emits it to connected clients.
**Why it is valuable:**
languard's frontend relies on WebSocket events pushed from process stdout. A periodic external query would give accurate player counts and current mission data independent of log output.
**Adapter strategy:**
```
Create: backend/core/servers/status_poller.py
- asyncio.Task per running server (cancel on stop)
- Use python-a2s or opengsq-python to query UDP game port
- QueryAdapter interface: arma3, dayz, etc. as subclasses
- On result: update server record in DB, push via WebSocket manager
- Poll interval configurable (default 5 s)
```
---
## 4. Mod Discovery — Parallel Metadata Resolution
**Source:** `lib/mods/index.js`, `lib/mods/modFile.js`, `lib/mods/steamMeta.js`, `lib/mods/folderSize.js`
**What it does:**
Finds all mod directories via glob, then for each mod concurrently: parses `mod.cpp` for display name, parses `meta.cpp` for Steam Workshop ID, and recursively calculates folder size with symlink-aware deduplication.
**Why it is valuable:**
languard's mod tab lists paths but does not extract Steam IDs, human-readable names, or folder sizes. This pattern handles all three in parallel efficiently.
**Adapter strategy:**
```
Create: backend/core/mods/scanner.py
- async def scan_mods(game_path: str) -> list[ModMeta]
- Use pathlib.glob for discovery
- asyncio.gather for parallel metadata:
parse_mod_file(path) -> ModFileAdapter (reads mod.cpp)
parse_steam_meta(path) -> SteamMetaAdapter (reads meta.cpp)
folder_size(path) -> recursive os.scandir sum
- ModMeta dataclass: { path, name, steam_id, size_bytes }
```
---
## 5. Log File Management — Platform Paths + Auto-Cleanup
**Source:** `lib/logs.js``logsPath()`, `logFiles()`, `cleanupOldLogFiles()`
**What it does:**
Resolves the game log directory based on platform (windows / linux / wine). Lists `.rpt` files with metadata, auto-deletes the oldest files beyond a configurable retention limit (default 20).
**Why it is valuable:**
languard streams real-time logs via WebSocket but has no endpoint to browse historical `.rpt` files on disk. Discovery and cleanup logic is directly reusable.
**Adapter strategy:**
```
Adapt: backend/core/logs/log_manager.py
- def logs_path(platform: str, game_path: str) -> Path
- def list_logs(logs_dir: Path) -> list[LogMeta] (stat each .rpt)
- def cleanup_old_logs(logs_dir: Path, keep: int = 20)
- Expose via:
GET /api/servers/{id}/logfiles
GET /api/servers/{id}/logfiles/{name}/download
DELETE /api/servers/{id}/logfiles/{name}
```
---
## 6. Mission Multi-File Upload + Filename Parsing
**Source:** `lib/missions.js``updateMissions()` + `routes/missions.js` upload handler
**What it does:**
Accepts multipart upload of up to 64 `.pbo` files simultaneously (parallel move with limit 8), scans the `mpmissions/` directory, and extracts `{ name, world }` from the Arma filename convention `missionname.worldname.pbo`.
**Why it is valuable:**
languard's missions tab lists files but does not support drag-and-drop multi-file upload or Steam Workshop download. The `.pbo` filename parsing convention is Arma-specific and worth encoding explicitly.
**Adapter strategy:**
```
Adapt: backend/routers/missions.py
- POST /api/servers/{id}/missions -> UploadFile[] via FastAPI
- Validate .pbo extension server-side (reject others)
- asyncio.gather with semaphore (limit 8): move to mpmissions/
- Parse filename: "name.world.pbo" -> { name, world }
- Trigger rescan -> return updated list
- Optional: POST /api/missions/workshop { id } -> delegate to steamcmd
```
---
## 7. EventEmitter → WebSocket Broadcast Bridge
**Source:** `app.js` lines 4966 (event bridge block)
**What it does:**
Listens on EventEmitter events from Manager, Missions, and Mods, then calls `io.emit()` to broadcast to all connected Socket.IO clients. New connections receive a full state snapshot immediately on connect.
**Why it is valuable:**
languard's WebSocket layer pushes real-time log lines but does not broadcast server state changes to all connected tabs/clients simultaneously. The "snapshot on connect + push diffs on change" pattern is directly applicable.
**Adapter strategy:**
```
Adapt: backend/core/websocket/broadcast_manager.py
- WebSocketManager class (already partially exists in languard)
- Add: publish(event: str, payload) -> broadcast_all()
- On new connection: send snapshot of all servers, missions, mods
- On server state change: publish("servers", get_all_servers())
- Same for missions and mods events
```
---
## 8. Mission Rotation Table — Per-Mission Difficulty
**Source:** `public/js/app/views/servers/missions/rotation/` (list + item views)
**What it does:**
Renders a table of missions in the server's active rotation. Each row has a difficulty dropdown. Rows can be added from the discovered mission list or removed individually. The full rotation array saves with the server config.
**Why it is valuable:**
languard's missions tab shows available missions but has no drag-to-add rotation table with per-mission difficulty. This is a key Arma workflow.
**Adapter strategy:**
```
Adapt: frontend/src/pages/ServerDetailPage.tsx (Missions tab)
- MissionRotationTable component
- Row type: { name: string, difficulty: '' | 'Recruit' | 'Regular' | 'Veteran' }
- Add row: select from available missions dropdown
- Remove row: delete button per row
- Save: PUT /api/servers/{id} { missions: [...] }
- Backend: MissionRotationItem Pydantic schema as array field on Server
```
---
## 9. Mod Assignment UI — Split-Pane Available / Selected
**Source:** `public/js/app/views/servers/mods/` (available list + selected list views)
**What it does:**
Two side-by-side lists: all discovered mods on the left, server-assigned mods on the right. Clicking a mod moves it between lists. Each list has a search filter. Selection saves when the settings form submits.
**Why it is valuable:**
languard's mods tab uses a flat checkbox list. The split-pane pattern with instant visual feedback is significantly more usable when mod counts are large (50+).
**Adapter strategy:**
```
Adapt: frontend/src/pages/ServerDetailPage.tsx (Mods tab)
- <AvailableModsList> and <SelectedModsList> side by side
- Shared state: selectedMods: string[] (mod paths)
- On click: immutable transfer between lists
- Search input on each list (client-side filter)
- On save: PUT /api/servers/{id} { mods: selectedMods }
```
---
## 10. Startup Parameter Editor — Dynamic String List
**Source:** `public/js/app/views/servers/parameters/` (list + item views)
**What it does:**
Renders a dynamic list of startup parameter inputs (e.g., `-limitFPS=100`). Rows can be added or removed. The list persists with the server config.
**Why it is valuable:**
languard's Create Server wizard captures some fixed parameters but has no UI for arbitrary additional startup flags, which power users need.
**Adapter strategy:**
```
Create: frontend/src/components/ParameterEditor.tsx
- Props: value: string[], onChange: (v: string[]) => void
- Renders list of text inputs with remove buttons
- Add button appends empty string
- Used in: CreateServerPage wizard (step 3) and ServerDetailPage settings tab
- Save: include in PUT /api/servers/{id} { parameters: [...] }
```
---
## Priority Ranking
| Priority | Candidate | Effort | Value |
|----------|-----------|--------|-------|
| High | Status Polling (asyncio task per server) | Medium | Accurate live player counts |
| High | Mission Rotation Table UI | Medium | Key missing workflow |
| High | Mission Multi-File Upload | Low | Missing feature |
| High | Mod Discovery with Parallel Metadata | Medium | Rich mod metadata |
| Medium | Startup Parameter Editor UI | Low | Power-user feature |
| Medium | Mod Split-Pane Selection UI | Medium | UX improvement for large mod lists |
| Medium | Log File Discovery + Cleanup | Low | Historical log access |
| Low | EventEmitter Broadcast Bridge | Low | Already partially implemented |
| Low | Config-Driven Optional Auth | Low | Dev convenience only |

314
docs/HOW_IT_WORKS.md Normal file
View File

@@ -0,0 +1,314 @@
# How Arma Server Web Admin Works
## Application Boot Sequence
```
node app.js
├── Load config.js
├── Create Express app + HTTP server
├── Attach Socket.IO to HTTP server
├── Instantiate: Settings, Missions, Mods, Manager
├── Manager.load() → read servers.json, restore Server instances
├── Register event bridges (manager/missions/mods → io.emit)
├── Mount routes (/api/*)
├── Serve public/ (SPA)
├── Optional: setup-basic-auth middleware
└── http.listen(config.port)
```
---
## Core Flow: Server Lifecycle
### Start a Server
```
POST /api/servers/:id/start
→ routes/servers.js → manager.startServer(id)
→ lib/server.js Server.start()
├── Instantiate ArmaServer.Server with merged config
├── Write server.cfg to filesystem (via arma-server lib)
├── Spawn child process:
│ Windows → arma3server.exe [params]
│ Linux → ./arma3server [params]
│ Wine → wine arma3server.exe [params]
├── On Linux: pipe stdout/stderr to dated .rpt log file
├── Start queryStatusInterval every 5 s (Gamedig)
├── If number_of_headless_clients > 0 → startHeadlessClients()
└── Emit 'state' event → manager bubbles → io.emit('servers', ...)
```
### Stop a Server
```
POST /api/servers/:id/stop
→ Server.stop()
├── instance.kill() (SIGTERM)
├── setTimeout(5000) → instance.kill() if still alive (SIGKILL)
├── stopHeadlessClients()
├── clearInterval(queryStatusInterval)
└── On 'close' → emit 'state'
```
### Status Polling (every 5 seconds)
```
setInterval → Server.queryStatus()
→ Gamedig.query({ type: 'arma3', host: '127.0.0.1', port })
→ On success: store { players, mission, status } in server.state
→ On failure: set state = 'stopped' if instance.exitCode set
→ Emit 'state' → manager → io.emit('servers', getServers())
```
### Persistence
```
Any mutation (add/edit/delete/start/stop)
→ Manager.save()
→ JSON.stringify(serversArr.map(s => s.toJSON()))
→ Write to servers.json
→ Emit 'servers' event → io.emit('servers', ...)
```
---
## Core Flow: Mission Management
### List Missions
```
GET /api/missions/
→ missions.missions (pre-loaded array)
→ [ { name, world, filename, size, created, modified }, ... ]
```
### Upload Missions
```
POST /api/missions (multipart/form-data, field: "missions")
→ multer stores files to temp dir
→ Filter: only .pbo extension allowed
→ async.parallelLimit(8): fs.move(temp → mpmissions/filename)
→ missions.updateMissions()
→ fs.readdir(mpmissions/)
→ stat each file → build metadata object
→ update this.missions array
→ io.emit('missions', missions.missions)
```
### Steam Workshop Download
```
POST /api/missions/workshop { id: "workshop_id" }
→ steamWorkshop.downloadFile(id, mpmissionsDir)
→ missions.updateMissions()
→ io.emit('missions', ...)
```
---
## Core Flow: Mod Management
### Discovery Pipeline
```
Mods.updateMods()
→ glob('**/{@*,csla,ef,...}/addons', gamePath)
→ For each modDir, async.map → resolveModData(modDir)
├── async.parallel:
│ folderSize(modDir) → recursive sum of file sizes (symlink-aware)
│ modFile(modDir) → parse mod.cpp → { name }
│ steamMeta(modDir) → parse meta.cpp → { id, name }
└── Merge results into:
{ name: relative_path, size, formattedSize, modFile, steamMeta }
→ this.mods = result array
→ Emit 'mods'
```
### Assign Mods to Server
```
Client UI: drag/click mod from "Available" → "Selected"
→ Backbone model update: server.mods = [ 'path/to/@mod', ... ]
→ PUT /api/servers/:id { mods: [...] }
→ manager.saveServer(id, body)
→ Manager.save() → servers.json
```
---
## Core Flow: Log Management
### Locate Log Files
```
Logs.logsPath()
→ if config.type === 'windows' → AppData/Local/[GameName]/
→ if config.type === 'linux' → config.path/logs/
→ if config.type === 'wine' → .wine/drive_c/users/.../AppData/[GameName]/
Logs.logFiles()
→ fs.readdir(logsPath)
→ filter: /\.rpt$/
→ stat each → { name, size, created, modified }
→ sort by modified desc
```
### Auto-Cleanup
```
After any delete or Linux log write:
Logs.cleanupOldLogFiles()
→ logFiles() → sort by modified
→ if count > 20: delete oldest (count - 20) files
```
### Linux Real-Time Logging
```
Server.start() (Linux)
→ logStream = fs.createWriteStream(logPath, { flags: 'a' })
→ process.stdout.pipe(logStream)
→ process.stderr.pipe(logStream)
```
---
## Real-Time Architecture (Socket.IO)
```
Backend (EventEmitter chain)
Manager/Missions/Mods emit events via Node EventEmitter
app.js bridges each to Socket.IO:
manager.on('servers', () => io.emit('servers', manager.getServers()))
missions.on('missions', (m) => io.emit('missions', m))
mods.on('mods', (m) => io.emit('mods', m))
On new client connection:
socket.emit('missions', missions.missions) // push initial snapshot
socket.emit('mods', mods.mods)
socket.emit('servers', manager.getServers())
socket.emit('settings', settings.getPublicSettings())
Frontend (Backbone + Socket.IO)
socket.on('servers', (servers) → serversCollection.set(servers))
socket.on('missions', (m) → missionsCollection.set(m))
socket.on('mods', (m) → modsCollection.set(m))
→ Backbone triggers 'change'/'add'/'remove' → Marionette re-renders views
```
---
## Frontend SPA Architecture
### Routing
Five Backbone routes drive the entire SPA:
| Route | Handler | View |
|-------|---------|------|
| `` (home) | `home()` | `ServersListView` — server grid |
| `logs` | `logs()` | `LogsListView` — log file browser |
| `missions` | `missions()` | `MissionsView` — upload + list |
| `mods` | `mods()` | `ModsView` — mod browser |
| `servers/:id` | `server(id)` | `ServerView` — tabbed detail page |
### View Hierarchy
```
LayoutView (root, persists across route changes)
├── Region: navigation → NavigationView (server list sidebar)
└── Region: content → (swapped per route)
├── ServersListView
│ └── ServerItemView (per server card)
├── LogsListView
│ └── LogItemView (per log file)
├── MissionsView
│ ├── UploadView (file input + drag-and-drop)
│ ├── WorkshopView (Steam ID input)
│ └── MissionsListView → MissionItemView
├── ModsView
│ ├── AvailableModsListView → ModItemView
│ └── SelectedModsListView → SelectedModItemView
└── ServerView (tabbed LayoutView)
├── Tab: Info → InfoView (status, start/stop, PID, players)
├── Tab: Mods → ServerModsView (split pane)
├── Tab: Missions → MissionRotationView (add/remove rotation rows)
├── Tab: Parameters → ParametersView (startup param editor)
├── Tab: Players → PlayersView (live player table)
└── Tab: Settings → FormView (full server config form)
```
### Settings Form Save Flow
```
FormView.save()
→ Collect: jQuery serializeArray() + checkbox state
→ Validate: title required
→ AJAX PUT /api/servers/:id { ...formData }
→ On 200: server model updated, navigate to /servers/:newId
→ On error: SweetAlert error dialog
```
---
## Configuration System
### Global Config (`config.js`)
```javascript
{
game: 'arma3', // Game variant
path: '/opt/arma3', // Game install path
port: 3000, // Web UI port
host: '0.0.0.0',
type: 'linux', // 'windows' | 'linux' | 'wine'
parameters: [], // Global startup params (all servers)
serverMods: [], // Server-side mods (all servers)
admins: [], // Steam IDs auto-granted admin
auth: { username, password }, // Optional Basic Auth
prefix: '', // Prepended to all server hostnames
suffix: '',
additionalConfigurationOptions: '' // Appended to all server.cfg
}
```
### Per-Server Config (stored in `servers.json`)
```javascript
{
id: 'my-server', // URL-safe slug of title
title: 'My Server',
port: 2302,
max_players: 32,
password: '',
admin_password: '',
motd: '',
auto_start: false,
battle_eye: true,
persistent: false,
von: true,
verify_signatures: false,
file_patching: false,
allowed_file_patching: 0,
forcedDifficulty: '',
number_of_headless_clients: 0,
parameters: [],
additionalConfigurationOptions: '',
missions: [{ name: 'mission.world', difficulty: '' }],
mods: ['@CBA_A3', '@ACE']
}
```
---
## Authentication Flow
```
If config.auth defined:
setupBasicAuth(app, config.auth)
→ app.use(expressBasicAuth({ users: { username: password } }))
→ All routes require valid Authorization: Basic ... header
→ req.auth.user available in all route handlers
→ Morgan logs include authenticated username
```