Files
languard-servers-manager/docs/HOW_IT_WORKS.md
Khoa (Revenovich) Tran Gia 4ba199dd62 docs: add arma-server-web-admin analysis reference docs
Brings in ANALYSIS.md, HOW_IT_WORKS.md, and CHERRY_PICK.md generated from
deep analysis of the arma-server-web-admin benchmark project. These docs
inform the Arma 3 UX enhancement plan (.claude/plan/arma3-ux-enhancement.md)
and provide context for implementing agents without needing to re-read the
source project.
2026-04-17 14:55:59 +07:00

9.1 KiB

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)

{
  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)

{
  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