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.
This commit is contained in:
891
.claude/plan/arma3-ux-enhancement.md
Normal file
891
.claude/plan/arma3-ux-enhancement.md
Normal 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. Full documentation was written to `E:\TestScript\arma-server-web-admin\docs\`:
|
||||||
|
- `ANALYSIS.md` — feature inventory, tech stack, directory structure
|
||||||
|
- `HOW_IT_WORKS.md` — internal flows (server start/stop, mission upload, mod discovery, Socket.IO bridge)
|
||||||
|
- `CHERRY_PICK.md` — adapter candidates with file paths and adapter strategies
|
||||||
|
|
||||||
|
**You do NOT need to re-read arma-server-web-admin.** All relevant patterns are 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
|
||||||
Reference in New Issue
Block a user