# 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 ```