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:
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