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.
This commit is contained in:
155
docs/ANALYSIS.md
Normal file
155
docs/ANALYSIS.md
Normal 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
247
docs/CHERRY_PICK.md
Normal 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 49–66 (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
314
docs/HOW_IT_WORKS.md
Normal 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
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user