From b853aead8c51eb42ef294d45105ca1a34f6d086f Mon Sep 17 00:00:00 2001 From: MrFastwind Date: Mon, 6 Jul 2026 01:29:23 +0200 Subject: [PATCH] Initial commit --- .gitignore | 15 + ARMA3.md | 29 + CODEBASE.md | 156 ++ Dockerfile | 40 + Makefile | 27 + PLAN.md | 213 ++ README.md | 104 + backend/cmd/server/main.go | 105 + backend/go.mod | 48 + backend/go.sum | 137 ++ backend/internal/api/configs.go | 114 + backend/internal/api/logs.go | 196 ++ backend/internal/api/modlists.go | 202 ++ backend/internal/api/router.go | 85 + backend/internal/api/settings.go | 173 ++ backend/internal/models/modlist.go | 25 + backend/internal/models/settings.go | 17 + backend/internal/services/config_manager.go | 89 + backend/internal/services/log_streamer.go | 81 + backend/internal/services/modlist_manager.go | 182 ++ backend/internal/services/modlist_parser.go | 116 ++ backend/internal/services/server_process.go | 267 +++ backend/internal/services/settings.go | 66 + backend/internal/services/steamcmd.go | 142 ++ docker-compose.yml | 20 + frontend/index.html | 13 + frontend/package-lock.json | 1949 ++++++++++++++++++ frontend/package.json | 33 + frontend/public/favicon.svg | 1 + frontend/src/App.tsx | 28 + frontend/src/api/client.ts | 91 + frontend/src/components/ConfigEditor.tsx | 30 + frontend/src/components/Layout.tsx | 37 + frontend/src/components/LiveTerminal.tsx | 87 + frontend/src/index.css | 1 + frontend/src/main.tsx | 18 + frontend/src/pages/ConfigEditor.tsx | 56 + frontend/src/pages/Configs.tsx | 59 + frontend/src/pages/Dashboard.tsx | 66 + frontend/src/pages/Logs.tsx | 83 + frontend/src/pages/ModlistEditor.tsx | 175 ++ frontend/src/pages/Modlists.tsx | 80 + frontend/src/pages/Settings.tsx | 247 +++ frontend/src/types/index.ts | 47 + frontend/tsconfig.app.json | 26 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 23 + frontend/vite.config.ts | 16 + 48 files changed, 5822 insertions(+) create mode 100644 .gitignore create mode 100644 ARMA3.md create mode 100644 CODEBASE.md create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 backend/cmd/server/main.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/api/configs.go create mode 100644 backend/internal/api/logs.go create mode 100644 backend/internal/api/modlists.go create mode 100644 backend/internal/api/router.go create mode 100644 backend/internal/api/settings.go create mode 100644 backend/internal/models/modlist.go create mode 100644 backend/internal/models/settings.go create mode 100644 backend/internal/services/config_manager.go create mode 100644 backend/internal/services/log_streamer.go create mode 100644 backend/internal/services/modlist_manager.go create mode 100644 backend/internal/services/modlist_parser.go create mode 100644 backend/internal/services/server_process.go create mode 100644 backend/internal/services/settings.go create mode 100644 backend/internal/services/steamcmd.go create mode 100644 docker-compose.yml create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/components/ConfigEditor.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/components/LiveTerminal.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/ConfigEditor.tsx create mode 100644 frontend/src/pages/Configs.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/Logs.tsx create mode 100644 frontend/src/pages/ModlistEditor.tsx create mode 100644 frontend/src/pages/Modlists.tsx create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb270f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +node_modules/ +dist/ +frontend/dist/ + +*.exe +*.test +backend/arma3-web-server + +data/ +serverfiles/ + +.env +.DS_Store +*.log +*.rpt diff --git a/ARMA3.md b/ARMA3.md new file mode 100644 index 0000000..6a427a8 --- /dev/null +++ b/ARMA3.md @@ -0,0 +1,29 @@ +Description of ArmA3 structure + +# Directory Structure + +## GameDir +```dir +/ +|- mpmissions/ +|- userconfig/ #cba, ai difficulty, ecc +``` +## Cfg & Mods +Parameter based, can be everywhere +# Parameters +``` +aram3server_x64[.exe] +-server +-world=empty +-loadMissionToMemory +-maxMem=13107 +-cpuCount=16 +-hugepages +-enableHT +-setThreadCharacteristics +-noPause +-profiles="" # path to the profiles directory (misison save dir) and in general the logs directory +-config="/file.cfg" # server related config, name, password, autostart missions ) +-mods=";" # list of paths to mods separated by ';' +-filePatching +``` diff --git a/CODEBASE.md b/CODEBASE.md new file mode 100644 index 0000000..a0b0a92 --- /dev/null +++ b/CODEBASE.md @@ -0,0 +1,156 @@ +# Codebase Analysis: arma3-web-server + +## Identity +- **Language / runtime**: Go 1.25 (backend), TypeScript 6 + React 19 (frontend) +- **Framework(s)**: Gin v1.12 (HTTP router), gorilla/websocket (WS), TanStack Query v5 (data fetching), Tailwind CSS 4 (styling), Vite 8 (bundler) +- **Build system**: Go toolchain (`go build`), Vite/Rollup for frontend, multi-stage Docker build +- **Test framework**: None detected + +## Directory map + +``` +arma3-web-server/ +├── backend/ # Go backend +│ ├── cmd/server/main.go # Entry point, env parsing, dir creation, wiring +│ ├── internal/ +│ │ ├── api/ # Gin HTTP handlers + WebSocket endpoints +│ │ │ ├── router.go # Route registration, Handler struct + New() +│ │ │ ├── settings.go # Settings CRUD + server start/stop/restart + steamcmd +│ │ │ ├── configs.go # .cfg file CRUD handlers +│ │ │ ├── modlists.go # Modlist CRUD + import + check/download-missing/update-all +│ │ │ └── logs.go # WS streaming (server, steamcmd, rpt) + log file listing +│ │ ├── models/ # Data structs +│ │ │ ├── settings.go # ServerSettings (singleton) +│ │ │ └── modlist.go # Modlist + ModEntry + ModlistListItem +│ │ └── services/ # Business logic layer +│ │ ├── settings.go # JSON load/save from data/settings.json +│ │ ├── config_manager.go # .cfg file I/O in $CFG_DIR +│ │ ├── modlist_manager.go # Modlist CRUD against data/modlists/*.json +│ │ ├── modlist_parser.go # HTML Arma Launcher preset parser +│ │ ├── server_process.go # Process lifecycle, arg builder, mod path resolver +│ │ ├── steamcmd.go # SteamCMD manager (UpdateGame, DownloadMod, DownloadMods) +│ │ └── log_streamer.go # Pub/sub fan-out for stdout/stderr via channels +│ ├── go.mod / go.sum +│ └── serverfiles/ # Default SERVERFILE_DIR (created at startup) +├── frontend/ # React SPA +│ ├── src/ +│ │ ├── App.tsx # Router setup (React Router v7) +│ │ ├── main.tsx # Entry point (QueryClient + BrowserRouter) +│ │ ├── index.css # Tailwind v4 imports +│ │ ├── api/client.ts # Typed fetch wrappers + WS URL builders +│ │ ├── types/index.ts # ServerSettings, Modlist, ModEntry, ConfigInfo, ServerPaths +│ │ ├── pages/ # 7 route-level page components +│ │ │ ├── Dashboard.tsx # Overview cards (status, configs, modlists) +│ │ │ ├── Settings.tsx # Main settings tab + userconfig tabs + SteamCMD section +│ │ │ ├── Configs.tsx # List/create/duplicate/delete configs +│ │ │ ├── ConfigEditor.tsx # Full-page Monaco editor for a single config +│ │ │ ├── Modlists.tsx # List/create/duplicate/delete + HTML import +│ │ │ ├── ModlistEditor.tsx # Mod list reorder + enable/disable + check/download-missing/update-all +│ │ │ └── Logs.tsx # Tabbed LiveTerminal (Server Console / RPT / SteamCMD) + file browser +│ │ └── components/ +│ │ ├── ConfigEditor.tsx # Monaco editor wrapper +│ │ ├── LiveTerminal.tsx # xterm.js + auto-reconnect WebSocket +│ │ ├── Layout.tsx # Sidebar navigation shell +│ │ └── ui/ # Empty +│ ├── package.json +│ ├── vite.config.ts # Proxy /api + /ws to :8080 +│ └── tsconfig*.json +├── data/ # Runtime data (mounted volume in Docker) +│ ├── presets/ # (unused) +│ └── servers/ # (unused) +├── Dockerfile # Multi-stage: Go build -> npm build -> alpine runtime +├── docker-compose.yml # Single service with volume mounts + env vars +└── Makefile # Convenience targets: backend, frontend, build, run, dev, clean +``` + +## Dependencies + +### Backend (Go) + +| Module | Type | Version | Purpose | +|---|---|---|---| +| `github.com/gin-gonic/gin` | framework | v1.12.0 | HTTP routing, middleware, request binding | +| `github.com/gorilla/websocket` | library | v1.5.3 | WebSocket upgrade + message I/O | +| `github.com/google/uuid` | utility | v1.6.0 | UUID generation for modlists | +| `modernc.org/sqlite` | database | v1.53.0 | Declared but **not used** (file-based chosen) | +| `golang.org/x/net` | stdlib | v0.51.0 | HTML parser for modlist import | + +### Frontend (npm) + +| Package | Type | Version | Purpose | +|---|---|---|---| +| `react` / `react-dom` | framework | ^19.2.7 | UI rendering | +| `react-router-dom` | routing | ^7.18.1 | Client-side routing (7 pages) | +| `@tanstack/react-query` | data fetching | ^5.101.2 | Server state, caching, polling, mutations | +| `tailwindcss` | styling | ^4.3.2 | Utility-first CSS | +| `@monaco-editor/react` | editor | ^4.7.0 | Monaco code editor for .cfg files | +| `@xterm/xterm` / `@xterm/addon-fit` | terminal | ^6.0.0 / ^0.11.0 | In-browser xterm.js terminal | +| `zustand` | state | ^5.0.14 | Declared but **unused** (no stores exist) | +| `vite` | bundler | ^8.1.1 | Dev server + production build | +| `@vitejs/plugin-react` | tooling | ^6.0.3 | React fast-refresh / JSX transform | +| `typescript` | language | ~6.0.2 | Type checking | +| `oxlint` | linter | ^1.71.0 | Linting | + +## Architecture + +### Pattern: Layered (API -> Service -> Model), file-based persistence + +``` +HTTP/WS Gin Router Services Filesystem +--------- ---------- -------- ---------- +Browser -- REST/WS --> api.Handler --> SettingsManager --> data/settings.json + | ConfigManager --> $CFG_DIR/*.cfg + | ModlistManager --> data/modlists/*.json + | ProcessManager --> spawn arma3server_x64 + | SteamCmdManager --> spawn steamcmd + | LogStreamer --> pub/sub (channels) + +---> WebSocket clients (xterm.js) +``` + +### Key abstractions +- **Handler** (api package) -- stateless; receives service pointers via constructor injection. All handlers are methods on Handler. +- ***Manager** (services package) -- stateful structs for each concern. Hold their own `sync.Mutex` for thread safety. +- **LogStreamer** -- pub/sub per named stream (`"server"`, `"steamcmd"`). Subscribe returns a `chan string`; Stream reads from an `io.Reader` (process stdout/stderr) and broadcasts to all subscribers of that key. +- **ProcessManager** -- manages the Arma 3 server child process lifecycle. Builds the command line from settings, resolves mod paths, monitors process exit. + +### Notable design decisions +1. **No database** -- everything is files: `settings.json`, `*.cfg` in `$CFG_DIR`, modlist JSON files. Backup means copying directories. +2. **Single server instance** -- ProcessManager allows only one running process at a time. +3. **`%command%` substitution** -- if ServerParameters contains `%command%`, the entire textarea is treated as a shell template, with `%command%` replaced by the binary path and auto-args (`-config=`, `-mod=`, `-profiles=`, `-port=`) appended. Enables Wine wrappers. +4. **Two-tier mod resolution** -- resolveModPath checks `$MODS_DIR/@name` first (manual/symlinked), then falls back to `$SERVERFILE_DIR/steamapps/workshop/content/107410/` for workshop downloads. +5. **SteamCMD async with live streaming** -- all steamcmd operations run in background goroutines, output broadcast to `"steamcmd"` key. +6. **Env-defined paths** -- all dirs set via env vars with hardcoded defaults, resolved to absolute paths at startup. + +## Data flow (example: Start server) + +``` +User clicks "Start" (Settings.tsx) + --> handleSaveThen() -- auto-saves if dirty + --> POST /api/server/start + --> Handler.StartServer() (settings.go) + --> process.Start() (server_process.go) + --> Load settings.json + --> If %command% in ServerParameters: + --> Replace %command% with binPath + --> Split into command + args + --> Append buildAutoArgs(s): -config=, -mod=, -profiles=, -port= + --> exec.CommandContext(ctx, parts[0], append(parts[1:], autoArgs...)...) + Else: + --> buildArgs(s): split parameters + append auto-args + --> exec.CommandContext(ctx, binPath, args...) + --> cmd.Dir = serverfileDir + --> Pipe stdout/stderr + --> cmd.Start() + --> Stream stdout/stderr to LogStreamer("server") + --> Monitor exit, broadcast [SERVER_PROCESS_EXITED] + --> Returns {"status": "starting"} + --> Frontend polls GET /api/server/status every 3s + --> Shows "Running" badge +``` + +## CI/CD +- **No CI pipeline** -- no `.github/` or `.gitlab-ci.yml` found +- **No tests** -- zero test files anywhere in the codebase +- **Docker build**: multi-stage Dockerfile at root -- builds Go binary, builds frontend dist, assembles Alpine image with steamcmd, exposes `:8080`, mounts `/data` and `/servers` volumes +- **Deployment**: single docker-compose.yml with one service, volume mounts for persistence +- **Local dev**: Makefile targets -- `make run` starts backend, `make dev` starts both backend + Vite dev server with hot reload diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..30a525e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +FROM golang:1.25-alpine AS backend +WORKDIR /src +COPY backend/go.mod backend/go.sum ./ +RUN go mod download +COPY backend/ . +RUN CGO_ENABLED=0 go build -o /server ./cmd/server/ + +FROM node:22-alpine AS frontend +WORKDIR /src +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ . +RUN npm run build + +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates tzdata +RUN apk add --no-cache steamcmd libstdc++ libgcc || true + +COPY --from=backend /server /usr/local/bin/arma3-web-server +COPY --from=frontend /src/dist /usr/share/arma3-web-server/frontend + +EXPOSE 8080 +VOLUME ["/data", "/servers"] + +# Expected mount structure under /servers: +# /servers/ ← SERVERFILE_DIR (arma3server_x64 binary, userconfig/) +# /servers/steamapps/workshop/content/107410/ ← workshop mods (downloaded via SteamCMD) +# /servers/mods/ ← MODS_DIR (@modname mod folders) +# /servers/cfg/ ← CFG_DIR (.cfg files) +# /servers/profiles/ ← PROFILES_DIR (logs, .rpt, server profile) +ENV DATA_DIR=/data +ENV SERVERFILE_DIR=/servers +ENV MODS_DIR=/servers/mods +ENV CFG_DIR=/servers/cfg +ENV PROFILES_DIR=/servers/profiles +ENV LISTEN=:8080 +ENV FRONTEND_DIR=/usr/share/arma3-web-server/frontend +ENV GIN_MODE=release + +ENTRYPOINT ["arma3-web-server"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6e3c769 --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +.PHONY: backend frontend build run clean dev + +# Default paths — override via env or copy .env.example +SERVERFILE_DIR ?= ./serverfiles +MODS_DIR ?= ./serverfiles/mods +CFG_DIR ?= ./serverfiles/cfg +PROFILES_DIR ?= ./serverfiles/profiles +DATA_DIR ?= ../../data + +backend: + cd backend && go build -o arma3-web-server ./cmd/server/ + +frontend: + cd frontend && npm run build + +build: backend frontend + +run: + cd backend && SERVERFILE_DIR=$(SERVERFILE_DIR) MODS_DIR=$(MODS_DIR) CFG_DIR=$(CFG_DIR) PROFILES_DIR=$(PROFILES_DIR) DATA_DIR=$(DATA_DIR) GIN_MODE=debug go run ./cmd/server/ + +dev: + cd backend && SERVERFILE_DIR=$(SERVERFILE_DIR) MODS_DIR=$(MODS_DIR) CFG_DIR=$(CFG_DIR) PROFILES_DIR=$(PROFILES_DIR) DATA_DIR=$(DATA_DIR) GIN_MODE=debug go run ./cmd/server/ & + cd frontend && npm run dev + +clean: + rm -f backend/arma3-web-server + rm -rf frontend/dist diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..569ebad --- /dev/null +++ b/PLAN.md @@ -0,0 +1,213 @@ +# Arma 3 Web Server — Architecture Plan + +## Overview + +A web-based control panel to configure, update, install, and execute a **single** Arma 3 dedicated server instance. Supports both Windows and Linux server binaries via SteamCMD. + +--- + +## Tech Stack + +| Layer | Choice | Rationale | +|-------|--------|-----------| +| **Backend** | Go 1.24+ (`gin`, `gorilla/websocket`) | Single binary, cross-compiles, goroutines for process streaming | +| **Storage** | File-based (JSON on disk) | No database dependency; settings, configs, and modlists are files | +| **Frontend** | React 19 + TypeScript + Vite | Fast iteration, rich ecosystem | +| **UI Kit** | Tailwind CSS 4 | Dark-theme UI, utility-first styling | +| **Code Editor** | Monaco (VS Code) | Syntax-highlighted config editing | +| **Terminal** | xterm.js | Live log display from server stdout | + +--- + +## Project Structure + +``` +arma3-web-server/ +├── backend/ +│ ├── cmd/server/main.go # Entry point +│ ├── internal/ +│ │ ├── api/ # HTTP handlers (Gin routes) +│ │ │ ├── router.go # Route registration +│ │ │ ├── settings.go # Server settings + start/stop/restart +│ │ │ ├── configs.go # .cfg file CRUD +│ │ │ ├── modlists.go # Modlist CRUD +│ │ │ └── logs.go # WebSocket handler for live logs +│ │ ├── models/ # Data structs +│ │ │ ├── settings.go # ServerSettings (singleton) +│ │ │ └── modlist.go # Modlist + ModEntry +│ │ └── services/ # Business logic +│ │ ├── settings.go # Singleton settings load/save +│ │ ├── config_manager.go # .cfg file I/O in $CFG_DIR +│ │ ├── modlist_manager.go # Modlist CRUD (JSON files) +│ │ ├── server_process.go # Process lifecycle (start/stop/restart) +│ │ └── log_streamer.go # stdout/stderr → WebSocket fan-out +│ ├── go.mod +│ └── go.sum +├── frontend/ +│ ├── src/ +│ │ ├── pages/ +│ │ │ ├── Dashboard.tsx +│ │ │ ├── Settings.tsx +│ │ │ ├── Configs.tsx +│ │ │ ├── ConfigEditor.tsx +│ │ │ ├── Modlists.tsx +│ │ │ ├── ModlistEditor.tsx +│ │ │ └── Logs.tsx +│ │ ├── components/ +│ │ │ ├── ConfigEditor.tsx # Monaco wrapper +│ │ │ ├── LiveTerminal.tsx # xterm.js wrapper +│ │ │ └── Layout.tsx # Sidebar nav shell +│ │ ├── api/client.ts # Typed fetch wrapper +│ │ └── types/index.ts # TypeScript types +│ ├── package.json +│ └── vite.config.ts +├── data/ # Runtime data +│ ├── settings.json # Singleton server settings +│ └── modlists/ # Modlist JSON files (*.json) +├── docker-compose.yml +├── Dockerfile +└── Makefile +``` + +--- + +## Environment Variables + +| Env | Purpose | Default | +|-----|---------|---------| +| `SERVERFILE_DIR` | Base dir where `arma3server_x64` binary + `userconfig/` live | `""` (must be set) | +| `MODS_DIR` | Base dir where `@modname` folders are stored | `""` (must be set) | +| `CFG_DIR` | Base dir where `.cfg` config files are stored | `""` (must be set) | +| `PROFILES_DIR` | Server profile/save path (ignored in UI) | `$SERVERFILE_DIR/Profiles` | +| `DATA_DIR` | Internal data (settings.json, modlists/) | `./data` | +| `LISTEN` | HTTP listen address | `:8080` | +| `FRONTEND_DIR` | Path to built frontend files | `../frontend/dist` | + +--- + +## Data Model + +### Server Settings (singleton) — `data/settings.json` + +```json +{ + "ip_port": "0.0.0.0:2302", + "server_parameters": "-server -world=empty -loadMissionToMemory -noPause", + "steam_branch": "stable", + "steam_user": "anonymous", + "platform": "linux", + "cba_settings": "", + "ai_level_presets": "", + "difficulty_presets": "", + "active_config": "server", + "active_modlist": "uuid-of-modlist", + "updated_at": "2026-01-01T00:00:00Z" +} +``` + +### Configs — `$CFG_DIR/*.cfg` + +Each config is a plain `.cfg` file stored in the env-defined `CFG_DIR`. +CRUD operations create/rename/delete these files directly on disk. + +### Modlists — `data/modlists/{uuid}.json` + +```json +{ + "id": "uuid", + "name": "My Modlist", + "mods": [ + { "id": "450814997", "name": "CBA_A3", "enabled": true }, + { "id": "463939057", "name": "ACE", "enabled": false } + ], + "created_at": "...", + "updated_at": "..." +} +``` + +--- + +## Server Configuration + +The three userconfig files are **singletons** — only one version can exist on disk at a time: + +| File | Disk location | Managed in | +|------|--------------|------------| +| `cba_settings.sqf` | `$SERVERFILE_DIR/userconfig/cba_settings.sqf` | Settings page (textarea) | +| `CfgAILevelPresets.sqf` | `$SERVERFILE_DIR/userconfig/CfgAILevelPresets.sqf` | Settings page (textarea) | +| `CfgDifficultyPresets.sqf` | `$SERVERFILE_DIR/userconfig/CfgDifficultyPresets.sqf` | Settings page (textarea) | + +These are written to disk immediately when settings are saved. + +--- + +## API Routes + +``` +# Server Settings (singleton) +GET /api/server/settings +PUT /api/server/settings # also writes userconfig files to disk +POST /api/server/start +POST /api/server/stop +POST /api/server/restart +GET /api/server/status +GET /api/server/steamcmd # returns current branch/user/platform +POST /api/server/steamcmd/update-game +POST /api/server/steamcmd/download-mod +GET /api/server/steamcmd/status + +# Configs (.cfg files in $CFG_DIR) +GET /api/configs +POST /api/configs +GET /api/configs/:name +PUT /api/configs/:name +DELETE /api/configs/:name +POST /api/configs/:name/duplicate + +# Modlists +GET /api/modlists +POST /api/modlists +GET /api/modlists/:id +PUT /api/modlists/:id +DELETE /api/modlists/:id +POST /api/modlists/:id/duplicate +POST /api/modlists/import (multipart form: file=*.html — parses Arma Launcher HTML preset) +GET /api/modlists/:id/check (returns mod entries with downloaded: bool) +POST /api/modlists/:id/download-missing +POST /api/modlists/:id/update-all + +# Logs +GET /ws/server/logs (WebSocket — live server stream) +GET /ws/steamcmd/logs (WebSocket — live steamcmd stream) +GET /api/server/logs (list log files) +GET /api/server/logs/:file (read log file) +``` + +--- + +## Process Start Flow + +``` +User clicks Start + → Settings loaded from data/settings.json + → Args built from server_parameters + -config=.cfg + → -profiles=$PROFILES_DIR auto-appended (unless already in server_parameters) + → -port= derived from ip_port field (unless -port= already in server_parameters) + → Mod path built from active modlist (enabled mods → @modname paths) + → arma3server_x64 spawned via exec.CommandContext + → stdout/stderr piped to LogStreamer fan-out + → WebSocket clients receive live output + → On exit, process reference cleaned up +``` + +--- + +## Key Design Decisions + +1. **File-based storage** — No database. Settings, configs, and modlists are files on disk. Portable, backup-able with simple file copy. +2. **Env-defined paths** — `SERVERFILE_DIR`, `MODS_DIR`, `CFG_DIR`, `PROFILES_DIR` are environment variables, not stored in the UI. +3. **Configs are real .cfg files** — Stored in `$CFG_DIR`, directly usable by the Arma 3 server's `-config=` parameter. +4. **Modlist = mod references + enabled state** — The `-mod=` parameter is built at start time by resolving enabled mods. For each mod, `$MODS_DIR/@name` is tried first (manually placed or symlinked); if not found and a Steam Workshop `id` exists, `$SERVERFILE_DIR/steamapps/workshop/content/107410/` is used as fallback. +5. **Single server instance** — Only one server at a time. No multi-instance support. +6. **No auth for v1** — JWT auth can be added later without breaking the API design. +7. **Docker-first deployment** — Single docker-compose.yml bundles SteamCMD, backend, and frontend serving. diff --git a/README.md b/README.md new file mode 100644 index 0000000..33830d6 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# Arma 3 Web Server + +Web-based management panel for Arma 3 dedicated servers. Start, stop, configure, and monitor your server from a browser. + +## Features + +- **Server control** — Start, stop, restart, and view status +- **Live console** — WebSocket terminal showing server stdout/stderr and RPT log tailing +- **Config management** — Create, edit, duplicate, and delete `.cfg` configuration files with a Monaco editor +- **Modlist management** — Create modlists with reorderable entries, enable/disable per mod, import from HTML (e.g. ArmA 3 Steam Workshop collections), check download status +- **SteamCMD integration** — Update the server binary, download individual workshop mods, bulk download missing or outdated mods, all streamed live to the UI +- **Settings UI** — Edit server parameters, IP:port, platform, Steam branch/user, CBA/AI/difficulty presets, and select active config/modlist +- **Log viewer** — Browse and read server log files + +## Quick Start + +### Docker + +```bash +docker compose up -d +``` + +The web UI is served on `http://localhost:8080`. + +### Manual + +```bash +# Backend +cd backend +go build -o arma3-web-server ./cmd/server +./arma3-web-server + +# Frontend (development) +cd frontend +npm install +npm run dev +``` + +## Configuration + +All paths are configurable via environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `DATA_DIR` | `data` | Server metadata (settings, modlist JSON files) | +| `SERVERFILE_DIR` | `serverfiles` | Arma 3 server installation | +| `MODS_DIR` | `serverfiles/mods` | Local mod symlinks/copies | +| `CFG_DIR` | `serverfiles/cfg` | Server configuration `.cfg` files | +| `PROFILES_DIR` | `serverfiles/profiles` | Arma 3 profile and log directory | +| `FRONTEND_DIR` | `../frontend/dist` | Built frontend assets | +| `LISTEN` | `:8080` | HTTP listen address | + +## API + +REST API at `/api/*` and WebSocket endpoints at `/ws/*`. Key routes: + +| Method | Path | Description | +|--------|------|-------------| +| `GET/PUT` | `/api/server/settings` | Read/update server settings | +| `POST` | `/api/server/start\|stop\|restart` | Server lifecycle | +| `GET` | `/api/server/status` | Running state | +| `GET/POST/PUT/DELETE` | `/api/configs` | CRUD for `.cfg` files | +| `GET/POST/PUT/DELETE` | `/api/modlists` | CRUD for modlists | +| `POST` | `/api/modlists/import` | Import HTML workshop list | +| `GET` | `/api/modlists/:id/check` | Check mod download status | +| `POST` | `/api/modlists/:id/download-missing\|update-all` | Bulk workshop operations | +| `POST` | `/api/server/steamcmd/update-game\|download-mod` | SteamCMD operations | +| `GET` | `/api/server/logs` | List log files | +| `GET` | `/api/server/paths` | Show server file paths | +| `WS` | `/ws/server/logs\|rpt\|steamcmd/logs` | Live log streaming | + +## Project Structure + +``` +backend/ + cmd/server/ Entry point + internal/ + api/ HTTP handlers and routes + models/ Data types + services/ Business logic +frontend/ + src/ + api/ HTTP and WebSocket client + components/ Shared UI components + pages/ Route-level views + types/ TypeScript interfaces +``` + +## Development + +```bash +# Backend +cd backend && go run ./cmd/server + +# Frontend (hot reload) +cd frontend && npm run dev + +# Lint +cd frontend && npm run lint +``` + +## License + +MIT diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..d9bd6be --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,105 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "arma3-web-server/internal/api" + "arma3-web-server/internal/services" + + "github.com/gin-gonic/gin" +) + +func main() { + dataDir := mustAbs(getEnv("DATA_DIR", filepath.Join("data"))) + serverfileDir := mustAbs(getEnv("SERVERFILE_DIR", "serverfiles")) + modsDir := mustAbs(getEnv("MODS_DIR", "serverfiles/mods")) + cfgDir := mustAbs(getEnv("CFG_DIR", "serverfiles/cfg")) + profilesDir := mustAbs(getEnv("PROFILES_DIR", "serverfiles/profiles")) + listen := getEnv("LISTEN", ":8080") + frontendDir := mustAbs(getEnv("FRONTEND_DIR", filepath.Join("..", "frontend", "dist"))) + + log.Printf("data dir: %s", dataDir) + log.Printf("serverfile dir: %s", serverfileDir) + log.Printf("mods dir: %s", modsDir) + log.Printf("cfg dir: %s", cfgDir) + log.Printf("profiles dir: %s", profilesDir) + log.Printf("listening on %s", listen) + + dirs := []string{ + dataDir, filepath.Join(dataDir, "modlists"), + serverfileDir, modsDir, cfgDir, profilesDir, + filepath.Join(serverfileDir, "steamapps", "workshop", "content", "107410"), + } + for _, dir := range dirs { + if err := os.MkdirAll(dir, 0755); err != nil { + log.Fatalf("mkdir %s: %v", dir, err) + } + } + + settings := services.NewSettingsManager(dataDir) + + streamer := services.NewLogStreamer() + configMgr := services.NewConfigManager(cfgDir) + modlistMgr := services.NewModlistManager(dataDir) + process := services.NewProcessManager(serverfileDir, modsDir, cfgDir, profilesDir, settings, modlistMgr, configMgr, streamer) + steamcmd := services.NewSteamCmdManager(serverfileDir, streamer) + + r := gin.Default() + + if fi, err := os.Stat(frontendDir); err == nil && fi.IsDir() { + r.Static("/assets", filepath.Join(frontendDir, "assets")) + r.StaticFile("/favicon.ico", filepath.Join(frontendDir, "favicon.ico")) + r.NoRoute(func(c *gin.Context) { + c.File(filepath.Join(frontendDir, "index.html")) + }) + log.Printf("serving frontend from %s", frontendDir) + } + + handler := api.New(settings, configMgr, modlistMgr, process, steamcmd, streamer, serverfileDir, modsDir, cfgDir, profilesDir) + handler.SetupRoutes(r) + + log.Printf("starting server on %s", listen) + + srv := &http.Server{Addr: listen, Handler: r} + + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("server: %v", err) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Print("shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("server forced shutdown: %v", err) + } + log.Print("server exited") +} + +func mustAbs(p string) string { + a, err := filepath.Abs(p) + if err != nil { + log.Fatalf("abs %s: %v", p, err) + } + return a +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..b23cc5b --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,48 @@ +module arma3-web-server + +go 1.25.0 + +require ( + github.com/gin-gonic/gin v1.12.0 + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 + modernc.org/sqlite v1.53.0 +) + +require ( + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/arch v0.22.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + modernc.org/libc v1.73.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..33a5bed --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,137 @@ +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= +golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= +modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= +modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/internal/api/configs.go b/backend/internal/api/configs.go new file mode 100644 index 0000000..963934c --- /dev/null +++ b/backend/internal/api/configs.go @@ -0,0 +1,114 @@ +package api + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +type createConfigInput struct { + Name string `json:"name" binding:"required"` + Content string `json:"content"` +} + +type updateConfigInput struct { + Content string `json:"content" binding:"required"` +} + +type duplicateConfigInput struct { + NewName string `json:"new_name" binding:"required"` +} + +func (h *Handler) ListConfigs(c *gin.Context) { + configs, err := h.configs.List() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, configs) +} + +func validateConfigName(name string) bool { + return name != "" && !strings.ContainsAny(name, "/\\") +} + +func (h *Handler) CreateConfig(c *gin.Context) { + var input createConfigInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if !validateConfigName(input.Name) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config name"}) + return + } + if err := h.configs.Create(input.Name, input.Content); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"name": input.Name}) +} + +func (h *Handler) GetConfig(c *gin.Context) { + name := c.Param("name") + if !validateConfigName(name) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config name"}) + return + } + content, err := h.configs.Get(name) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.String(http.StatusOK, content) +} + +func (h *Handler) UpdateConfig(c *gin.Context) { + var input updateConfigInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + name := c.Param("name") + if !validateConfigName(name) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config name"}) + return + } + if err := h.configs.Update(name, input.Content); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok"}) +} + +func (h *Handler) DeleteConfig(c *gin.Context) { + name := c.Param("name") + if !validateConfigName(name) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config name"}) + return + } + if err := h.configs.Delete(name); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusNoContent, nil) +} + +func (h *Handler) DuplicateConfig(c *gin.Context) { + var input duplicateConfigInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + name := c.Param("name") + if !validateConfigName(name) || !validateConfigName(input.NewName) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config name"}) + return + } + if err := h.configs.Duplicate(name, input.NewName); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"name": input.NewName}) +} diff --git a/backend/internal/api/logs.go b/backend/internal/api/logs.go new file mode 100644 index 0000000..675a182 --- /dev/null +++ b/backend/internal/api/logs.go @@ -0,0 +1,196 @@ +package api + +import ( + "io" + "log" + "net/http" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, +} + +func (h *Handler) StreamLogs(c *gin.Context) { + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + log.Printf("websocket upgrade error: %v", err) + return + } + defer conn.Close() + + clientID := conn.RemoteAddr().String() + ch := h.streamer.Subscribe("server", clientID) + defer h.streamer.Unsubscribe("server", clientID) + + for line := range ch { + if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil { + break + } + } +} + +func (h *Handler) ListLogs(c *gin.Context) { + profilesDir := h.process.ProfilesDir() + files := h.collectLogFiles(profilesDir, "") + + logsDir := filepath.Join(profilesDir, "logs") + if fi, err := os.Stat(logsDir); err == nil && fi.IsDir() { + files = append(files, h.collectLogFiles(logsDir, "logs")...) + } + + slices.Sort(files) + if files == nil { + files = []string{} + } + c.JSON(http.StatusOK, files) +} + +func (h *Handler) collectLogFiles(dir, prefix string) []string { + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var files []string + for _, e := range entries { + if !e.IsDir() { + ext := filepath.Ext(e.Name()) + if ext == ".log" || ext == ".rpt" || e.Name() == "script.log" { + if prefix != "" { + files = append(files, prefix+"/"+e.Name()) + } else { + files = append(files, e.Name()) + } + } + } + } + return files +} + +func (h *Handler) StreamSteamCMDLogs(c *gin.Context) { + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + log.Printf("websocket upgrade error: %v", err) + return + } + defer conn.Close() + + clientID := conn.RemoteAddr().String() + ch := h.streamer.Subscribe("steamcmd", clientID) + defer h.streamer.Unsubscribe("steamcmd", clientID) + + for line := range ch { + if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil { + break + } + } +} + +func (h *Handler) StreamRPTLogs(c *gin.Context) { + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + log.Printf("websocket upgrade error: %v", err) + return + } + defer conn.Close() + + profilesDir := h.process.ProfilesDir() + var currentPath string + var currentOffset int64 + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + + for range ticker.C { + path := findLatestRPT(profilesDir) + + if path != currentPath { + currentPath = path + currentOffset = 0 + if currentPath == "" { + continue + } + conn.WriteMessage(websocket.TextMessage, []byte("--- tailing: "+filepath.Base(currentPath)+" ---")) + } + if currentPath == "" { + continue + } + + fi, err := os.Stat(currentPath) + if err != nil { + currentPath = "" + continue + } + if fi.Size() <= currentOffset { + continue + } + + f, err := os.Open(currentPath) + if err != nil { + currentPath = "" + continue + } + f.Seek(currentOffset, io.SeekStart) + buf := make([]byte, fi.Size()-currentOffset) + n, _ := io.ReadFull(f, buf) + f.Close() + + currentOffset += int64(n) + for _, line := range strings.Split(string(buf[:n]), "\n") { + if line == "" { + continue + } + if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil { + return + } + } + } +} + +func findLatestRPT(dir string) string { + pattern := filepath.Join(dir, "arma3server_x64_*.rpt") + matches, err := filepath.Glob(pattern) + if err != nil || len(matches) == 0 { + return "" + } + sort.Slice(matches, func(i, j int) bool { + fi, _ := os.Stat(matches[i]) + fj, _ := os.Stat(matches[j]) + return fi.ModTime().After(fj.ModTime()) + }) + return matches[0] +} + +func (h *Handler) GetLog(c *gin.Context) { + filename := filepath.Base(c.Param("file")) + if filename == "" || strings.ContainsRune(c.Param("file"), os.PathSeparator) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"}) + return + } + profilesDir := h.process.ProfilesDir() + path := filepath.Join(profilesDir, filename) + + if _, err := os.Stat(path); os.IsNotExist(err) { + logsPath := filepath.Join(profilesDir, "logs", filename) + if _, err2 := os.Stat(logsPath); err2 == nil { + path = logsPath + } else { + c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) + return + } + } + + data, err := os.ReadFile(path) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.String(http.StatusOK, string(data)) +} diff --git a/backend/internal/api/modlists.go b/backend/internal/api/modlists.go new file mode 100644 index 0000000..9444ae1 --- /dev/null +++ b/backend/internal/api/modlists.go @@ -0,0 +1,202 @@ +package api + +import ( + "net/http" + + "arma3-web-server/internal/models" + "arma3-web-server/internal/services" + + "github.com/gin-gonic/gin" +) + +type createModlistInput struct { + Name string `json:"name" binding:"required"` +} + +type updateModlistInput struct { + Name string `json:"name" binding:"required"` + Mods []models.ModEntry `json:"mods"` +} + +type duplicateModlistInput struct { + Name string `json:"name" binding:"required"` +} + +func (h *Handler) ListModlists(c *gin.Context) { + items, err := h.modlists.List() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, items) +} + +func (h *Handler) CreateModlist(c *gin.Context) { + var input createModlistInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ml, err := h.modlists.Create(input.Name) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, ml) +} + +func (h *Handler) GetModlist(c *gin.Context) { + ml, err := h.modlists.Get(c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, ml) +} + +func (h *Handler) UpdateModlist(c *gin.Context) { + var input updateModlistInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ml, err := h.modlists.Update(c.Param("id"), input.Name, input.Mods) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, ml) +} + +func (h *Handler) DeleteModlist(c *gin.Context) { + if err := h.modlists.Delete(c.Param("id")); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusNoContent, nil) +} + +func (h *Handler) DuplicateModlist(c *gin.Context) { + var input duplicateModlistInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + ml, err := h.modlists.Duplicate(c.Param("id"), input.Name) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, ml) +} + +func (h *Handler) ImportModlist(c *gin.Context) { + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "file field required"}) + return + } + defer file.Close() + + _, mods, err := services.ParseModlistHTML(file) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "parse html: " + err.Error()}) + return + } + + listName := services.FileNameToModlistName(header.Filename) + + ml, err := h.modlists.Create(listName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ml, err = h.modlists.Update(ml.ID, ml.Name, mods) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, ml) +} + +type modStatus struct { + ID string `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + Downloaded bool `json:"downloaded"` +} + +func (h *Handler) CheckModlistMods(c *gin.Context) { + ml, err := h.modlists.Get(c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + dir := h.process.ServerfileDir() + var result []modStatus + for _, m := range ml.Mods { + result = append(result, modStatus{ + ID: m.ID, + Name: m.Name, + Enabled: m.Enabled, + Downloaded: services.CheckWorkshopMod(dir, m.ID), + }) + } + c.JSON(http.StatusOK, result) +} + +func (h *Handler) DownloadMissingMods(c *gin.Context) { + ml, err := h.modlists.Get(c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + dir := h.process.ServerfileDir() + var missing []string + for _, m := range ml.Mods { + if m.ID != "" && !services.CheckWorkshopMod(dir, m.ID) { + missing = append(missing, m.ID) + } + } + + if len(missing) == 0 { + c.JSON(http.StatusOK, gin.H{"status": "all mods present"}) + return + } + + if err := h.steamcmd.DownloadMods(missing); err != nil { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "downloading", "count": len(missing)}) +} + +func (h *Handler) UpdateAllMods(c *gin.Context) { + ml, err := h.modlists.Get(c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + var ids []string + for _, m := range ml.Mods { + if m.ID != "" { + ids = append(ids, m.ID) + } + } + + if len(ids) == 0 { + c.JSON(http.StatusOK, gin.H{"status": "no mods with workshop ids"}) + return + } + + if err := h.steamcmd.DownloadMods(ids); err != nil { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "updating", "count": len(ids)}) +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go new file mode 100644 index 0000000..18940a1 --- /dev/null +++ b/backend/internal/api/router.go @@ -0,0 +1,85 @@ +package api + +import ( + "arma3-web-server/internal/services" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + settings *services.SettingsManager + configs *services.ConfigManager + modlists *services.ModlistManager + process *services.ProcessManager + steamcmd *services.SteamCmdManager + streamer *services.LogStreamer + serverfileDir string + modsDir string + cfgDir string + profilesDir string +} + +func New( + settings *services.SettingsManager, + configs *services.ConfigManager, + modlists *services.ModlistManager, + process *services.ProcessManager, + steamcmd *services.SteamCmdManager, + streamer *services.LogStreamer, + serverfileDir, modsDir, cfgDir, profilesDir string, +) *Handler { + return &Handler{ + settings: settings, + configs: configs, + modlists: modlists, + process: process, + steamcmd: steamcmd, + streamer: streamer, + serverfileDir: serverfileDir, + modsDir: modsDir, + cfgDir: cfgDir, + profilesDir: profilesDir, + } +} + +func (h *Handler) SetupRoutes(r *gin.Engine) { + api := r.Group("/api") + { + api.GET("/server/settings", h.GetSettings) + api.PUT("/server/settings", h.UpdateSettings) + api.POST("/server/start", h.StartServer) + api.POST("/server/stop", h.StopServer) + api.POST("/server/restart", h.RestartServer) + api.GET("/server/status", h.ServerStatus) + api.GET("/server/steamcmd", h.SteamCMDSettings) + api.POST("/server/steamcmd/update-game", h.SteamCMDUpdateGame) + api.POST("/server/steamcmd/download-mod", h.SteamCMDDownloadMod) + api.GET("/server/steamcmd/status", h.SteamCMDStatus) + api.GET("/server/paths", h.ServerPaths) + + api.GET("/server/logs", h.ListLogs) + api.GET("/server/logs/:file", h.GetLog) + + api.GET("/configs", h.ListConfigs) + api.POST("/configs", h.CreateConfig) + api.GET("/configs/:name", h.GetConfig) + api.PUT("/configs/:name", h.UpdateConfig) + api.DELETE("/configs/:name", h.DeleteConfig) + api.POST("/configs/:name/duplicate", h.DuplicateConfig) + + api.GET("/modlists", h.ListModlists) + api.POST("/modlists", h.CreateModlist) + api.GET("/modlists/:id", h.GetModlist) + api.PUT("/modlists/:id", h.UpdateModlist) + api.DELETE("/modlists/:id", h.DeleteModlist) + api.POST("/modlists/:id/duplicate", h.DuplicateModlist) + api.POST("/modlists/import", h.ImportModlist) + api.GET("/modlists/:id/check", h.CheckModlistMods) + api.POST("/modlists/:id/download-missing", h.DownloadMissingMods) + api.POST("/modlists/:id/update-all", h.UpdateAllMods) + } + + r.GET("/ws/server/logs", h.StreamLogs) + r.GET("/ws/steamcmd/logs", h.StreamSteamCMDLogs) + r.GET("/ws/server/rpt", h.StreamRPTLogs) +} diff --git a/backend/internal/api/settings.go b/backend/internal/api/settings.go new file mode 100644 index 0000000..242c44d --- /dev/null +++ b/backend/internal/api/settings.go @@ -0,0 +1,173 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +type updateSettingsInput struct { + IPPort *string `json:"ip_port"` + ServerParameters *string `json:"server_parameters"` + SteamBranch *string `json:"steam_branch"` + SteamUser *string `json:"steam_user"` + Platform *string `json:"platform"` + CBASettings *string `json:"cba_settings"` + AILevelPresets *string `json:"ai_level_presets"` + DifficultyPresets *string `json:"difficulty_presets"` + ActiveConfig *string `json:"active_config"` + ActiveModlist *string `json:"active_modlist"` +} + +func (h *Handler) GetSettings(c *gin.Context) { + s, err := h.settings.Load() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, s) +} + +func (h *Handler) UpdateSettings(c *gin.Context) { + var input updateSettingsInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + s, err := h.settings.Load() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if input.IPPort != nil { s.IPPort = *input.IPPort } + if input.ServerParameters != nil { s.ServerParameters = *input.ServerParameters } + if input.SteamBranch != nil { s.SteamBranch = *input.SteamBranch } + if input.SteamUser != nil { s.SteamUser = *input.SteamUser } + if input.Platform != nil { s.Platform = *input.Platform } + if input.CBASettings != nil { s.CBASettings = *input.CBASettings } + if input.AILevelPresets != nil { s.AILevelPresets = *input.AILevelPresets } + if input.DifficultyPresets != nil { s.DifficultyPresets = *input.DifficultyPresets } + if input.ActiveConfig != nil { s.ActiveConfig = *input.ActiveConfig } + if input.ActiveModlist != nil { s.ActiveModlist = *input.ActiveModlist } + + if err := h.process.WriteUserconfigFiles(s); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "write userconfig: " + err.Error()}) + return + } + + if err := h.settings.Save(s); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, s) +} + +func (h *Handler) StartServer(c *gin.Context) { + if err := h.process.Start(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "starting"}) +} + +func (h *Handler) StopServer(c *gin.Context) { + if err := h.process.Stop(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "stopping"}) +} + +func (h *Handler) RestartServer(c *gin.Context) { + if err := h.process.Restart(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "restarting"}) +} + +func (h *Handler) ServerStatus(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "running": h.process.IsRunning(), + }) +} + +func (h *Handler) SteamCMDSettings(c *gin.Context) { + s, err := h.settings.Load() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "branch": s.SteamBranch, + "user": s.SteamUser, + "platform": s.Platform, + }) +} + +type steamcmdUpdateGameInput struct { + Branch string `json:"branch"` + User string `json:"user"` +} + +func (h *Handler) SteamCMDUpdateGame(c *gin.Context) { + var input steamcmdUpdateGameInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user := input.User + if user == "" { + user = "anonymous" + } + branch := input.Branch + if branch == "" { + branch = "stable" + } + + if err := h.steamcmd.UpdateGame(branch, user); err != nil { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "started"}) +} + +type steamcmdDownloadModInput struct { + ModID string `json:"mod_id"` +} + +func (h *Handler) SteamCMDDownloadMod(c *gin.Context) { + var input steamcmdDownloadModInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if input.ModID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "mod_id is required"}) + return + } + + if err := h.steamcmd.DownloadMod(input.ModID); err != nil { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "started"}) +} + +func (h *Handler) SteamCMDStatus(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "running": h.steamcmd.IsRunning(), + }) +} + +func (h *Handler) ServerPaths(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "serverfile_dir": h.serverfileDir, + "mods_dir": h.modsDir, + "cfg_dir": h.cfgDir, + "profiles_dir": h.profilesDir, + }) +} diff --git a/backend/internal/models/modlist.go b/backend/internal/models/modlist.go new file mode 100644 index 0000000..c2fa406 --- /dev/null +++ b/backend/internal/models/modlist.go @@ -0,0 +1,25 @@ +package models + +import "time" + +type ModEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` +} + +type Modlist struct { + ID string `json:"id"` + Name string `json:"name"` + Mods []ModEntry `json:"mods"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ModlistListItem struct { + ID string `json:"id"` + Name string `json:"name"` + ModCount int `json:"mod_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/backend/internal/models/settings.go b/backend/internal/models/settings.go new file mode 100644 index 0000000..43b0ec4 --- /dev/null +++ b/backend/internal/models/settings.go @@ -0,0 +1,17 @@ +package models + +import "time" + +type ServerSettings struct { + IPPort string `json:"ip_port"` + ServerParameters string `json:"server_parameters"` + SteamBranch string `json:"steam_branch"` + SteamUser string `json:"steam_user"` + Platform string `json:"platform"` + CBASettings string `json:"cba_settings"` + AILevelPresets string `json:"ai_level_presets"` + DifficultyPresets string `json:"difficulty_presets"` + ActiveConfig string `json:"active_config"` + ActiveModlist string `json:"active_modlist"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/backend/internal/services/config_manager.go b/backend/internal/services/config_manager.go new file mode 100644 index 0000000..0a31067 --- /dev/null +++ b/backend/internal/services/config_manager.go @@ -0,0 +1,89 @@ +package services + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +type ConfigManager struct { + cfgDir string +} + +func NewConfigManager(cfgDir string) *ConfigManager { + return &ConfigManager{cfgDir: cfgDir} +} + +type ConfigInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` +} + +func (cm *ConfigManager) List() ([]ConfigInfo, error) { + entries, err := os.ReadDir(cm.cfgDir) + if err != nil { + if os.IsNotExist(err) { + return []ConfigInfo{}, nil + } + return nil, err + } + + var configs []ConfigInfo + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".cfg") { + info, _ := e.Info() + configs = append(configs, ConfigInfo{ + Name: strings.TrimSuffix(e.Name(), ".cfg"), + Size: info.Size(), + }) + } + } + return configs, nil +} + +func (cm *ConfigManager) Get(name string) (string, error) { + path := cm.path(name) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("config not found") + } + return "", err + } + return string(data), nil +} + +func (cm *ConfigManager) Create(name, content string) error { + path := cm.path(name) + if _, err := os.Stat(path); err == nil { + return fmt.Errorf("config already exists") + } + return os.WriteFile(path, []byte(content), 0644) +} + +func (cm *ConfigManager) Update(name, content string) error { + return os.WriteFile(cm.path(name), []byte(content), 0644) +} + +func (cm *ConfigManager) Delete(name string) error { + return os.Remove(cm.path(name)) +} + +func (cm *ConfigManager) Duplicate(name, newName string) error { + content, err := cm.Get(name) + if err != nil { + return err + } + return cm.Create(newName, content) +} + +func (cm *ConfigManager) path(name string) string { + return filepath.Join(cm.cfgDir, sanitizeConfigName(name)+".cfg") +} + +func sanitizeConfigName(name string) string { + name = filepath.Base(name) + name = strings.TrimSuffix(name, ".cfg") + return name +} diff --git a/backend/internal/services/log_streamer.go b/backend/internal/services/log_streamer.go new file mode 100644 index 0000000..c2c8770 --- /dev/null +++ b/backend/internal/services/log_streamer.go @@ -0,0 +1,81 @@ +package services + +import ( + "bufio" + "io" + "log" + "sync" +) + +type LogStreamer struct { + mu sync.RWMutex + subs map[string]map[string]chan string +} + +func NewLogStreamer() *LogStreamer { + return &LogStreamer{ + subs: make(map[string]map[string]chan string), + } +} + +func (ls *LogStreamer) Subscribe(serverID, clientID string) chan string { + ls.mu.Lock() + defer ls.mu.Unlock() + + if ls.subs[serverID] == nil { + ls.subs[serverID] = make(map[string]chan string) + } + + ch := make(chan string, 256) + ls.subs[serverID][clientID] = ch + return ch +} + +func (ls *LogStreamer) Unsubscribe(serverID, clientID string) { + ls.mu.Lock() + defer ls.mu.Unlock() + + if ls.subs[serverID] != nil { + if ch, ok := ls.subs[serverID][clientID]; ok { + close(ch) + delete(ls.subs[serverID], clientID) + } + if len(ls.subs[serverID]) == 0 { + delete(ls.subs, serverID) + } + } +} + +func (ls *LogStreamer) Stream(serverID string, reader io.Reader, closeMsg string) { + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + line := scanner.Text() + ls.broadcast(serverID, line) + } + if err := scanner.Err(); err != nil { + log.Printf("log stream error for server %s: %v", serverID, err) + } + if closeMsg != "" { + ls.broadcast(serverID, closeMsg) + } +} + +func (ls *LogStreamer) Broadcast(serverID, line string) { + ls.broadcast(serverID, line) +} + +func (ls *LogStreamer) broadcast(serverID, line string) { + ls.mu.RLock() + defer ls.mu.RUnlock() + + if ls.subs[serverID] == nil { + return + } + + for _, ch := range ls.subs[serverID] { + select { + case ch <- line: + default: + } + } +} diff --git a/backend/internal/services/modlist_manager.go b/backend/internal/services/modlist_manager.go new file mode 100644 index 0000000..93ed9aa --- /dev/null +++ b/backend/internal/services/modlist_manager.go @@ -0,0 +1,182 @@ +package services + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "arma3-web-server/internal/models" + + "github.com/google/uuid" +) + +type ModlistManager struct { + dir string + mu sync.RWMutex +} + +func NewModlistManager(dataDir string) *ModlistManager { + return &ModlistManager{ + dir: filepath.Join(dataDir, "modlists"), + } +} + +func (mm *ModlistManager) path(id string) string { + return filepath.Join(mm.dir, id+".json") +} + +func (mm *ModlistManager) List() ([]models.ModlistListItem, error) { + mm.mu.RLock() + defer mm.mu.RUnlock() + + entries, err := os.ReadDir(mm.dir) + if err != nil { + if os.IsNotExist(err) { + return []models.ModlistListItem{}, nil + } + return nil, err + } + + var items []models.ModlistListItem + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + data, err := os.ReadFile(filepath.Join(mm.dir, e.Name())) + if err != nil { + continue + } + var m models.Modlist + if err := json.Unmarshal(data, &m); err != nil { + continue + } + items = append(items, models.ModlistListItem{ + ID: m.ID, + Name: m.Name, + ModCount: len(m.Mods), + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + }) + } + return items, nil +} + +func (mm *ModlistManager) Get(id string) (*models.Modlist, error) { + mm.mu.RLock() + defer mm.mu.RUnlock() + + data, err := os.ReadFile(mm.path(id)) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("modlist not found") + } + return nil, err + } + var m models.Modlist + if err := json.Unmarshal(data, &m); err != nil { + return nil, err + } + return &m, nil +} + +func (mm *ModlistManager) Create(name string) (*models.Modlist, error) { + mm.mu.Lock() + defer mm.mu.Unlock() + + now := time.Now().UTC() + m := &models.Modlist{ + ID: uuid.New().String(), + Name: name, + Mods: []models.ModEntry{}, + CreatedAt: now, + UpdatedAt: now, + } + + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return nil, err + } + if err := os.MkdirAll(mm.dir, 0755); err != nil { + return nil, err + } + if err := os.WriteFile(mm.path(m.ID), data, 0644); err != nil { + return nil, err + } + return m, nil +} + +func (mm *ModlistManager) Update(id, name string, mods []models.ModEntry) (*models.Modlist, error) { + mm.mu.Lock() + defer mm.mu.Unlock() + + m, err := mm.getLocked(id) + if err != nil { + return nil, err + } + + m.Name = name + m.Mods = mods + m.UpdatedAt = time.Now().UTC() + + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return nil, err + } + if err := os.WriteFile(mm.path(id), data, 0644); err != nil { + return nil, err + } + return m, nil +} + +func (mm *ModlistManager) Delete(id string) error { + mm.mu.Lock() + defer mm.mu.Unlock() + return os.Remove(mm.path(id)) +} + +func (mm *ModlistManager) Duplicate(id, newName string) (*models.Modlist, error) { + mm.mu.Lock() + defer mm.mu.Unlock() + + orig, err := mm.getLocked(id) + if err != nil { + return nil, err + } + + now := time.Now().UTC() + dup := &models.Modlist{ + ID: uuid.New().String(), + Name: newName, + Mods: orig.Mods, + CreatedAt: now, + UpdatedAt: now, + } + + data, err := json.MarshalIndent(dup, "", " ") + if err != nil { + return nil, err + } + if err := os.WriteFile(mm.path(dup.ID), data, 0644); err != nil { + return nil, err + } + return dup, nil +} + +func (mm *ModlistManager) getLocked(id string) (*models.Modlist, error) { + data, err := os.ReadFile(mm.path(id)) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("modlist not found") + } + return nil, err + } + var m models.Modlist + if err := json.Unmarshal(data, &m); err != nil { + return nil, err + } + return &m, nil +} diff --git a/backend/internal/services/modlist_parser.go b/backend/internal/services/modlist_parser.go new file mode 100644 index 0000000..139f0ec --- /dev/null +++ b/backend/internal/services/modlist_parser.go @@ -0,0 +1,116 @@ +package services + +import ( + "fmt" + "io" + "net/url" + "path" + "strings" + + "arma3-web-server/internal/models" + "golang.org/x/net/html" +) + +func ParseModlistHTML(r io.Reader) (string, []models.ModEntry, error) { + doc, err := html.Parse(r) + if err != nil { + return "", nil, fmt.Errorf("parse html: %w", err) + } + + var mods []models.ModEntry + seen := map[string]bool{} + + var walk func(*html.Node) + walk = func(n *html.Node) { + if n.Type == html.ElementNode && n.Data == "tr" { + if name, id, ok := extractModRow(n); ok && id != "" { + if !seen[id] { + seen[id] = true + mods = append(mods, models.ModEntry{ + ID: id, + Name: cleanModName(name), + Enabled: true, + }) + } + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(doc) + + if mods == nil { + mods = []models.ModEntry{} + } + return "", mods, nil +} + +func extractModRow(tr *html.Node) (name, id string, ok bool) { + var cells []*html.Node + for c := tr.FirstChild; c != nil; c = c.NextSibling { + if c.Type == html.ElementNode && c.Data == "td" { + cells = append(cells, c) + } + } + if len(cells) < 3 { + return "", "", false + } + + name = extractText(cells[0]) + + for c := cells[2].FirstChild; c != nil; c = c.NextSibling { + if c.Type == html.ElementNode && c.Data == "a" { + id = extractWorkshopID(c) + break + } + } + + if name == "" { + return "", "", false + } + return name, id, true +} + +func extractText(n *html.Node) string { + var b strings.Builder + var walk func(*html.Node) + walk = func(n *html.Node) { + if n.Type == html.TextNode { + b.WriteString(n.Data) + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(n) + return strings.TrimSpace(b.String()) +} + +func extractWorkshopID(a *html.Node) string { + for _, attr := range a.Attr { + if attr.Key == "href" { + u, err := url.Parse(attr.Val) + if err != nil { + continue + } + return u.Query().Get("id") + } + } + return "" +} + +func cleanModName(name string) string { + name = strings.TrimSpace(name) + name = strings.ReplaceAll(name, "/", "_") + name = strings.ReplaceAll(name, "\\", "_") + return name +} + +func FileNameToModlistName(filename string) string { + ext := path.Ext(filename) + name := strings.TrimSuffix(filename, ext) + name = strings.ReplaceAll(name, "_", " ") + name = strings.ReplaceAll(name, "-", " ") + return name +} diff --git a/backend/internal/services/server_process.go b/backend/internal/services/server_process.go new file mode 100644 index 0000000..dd6c7ee --- /dev/null +++ b/backend/internal/services/server_process.go @@ -0,0 +1,267 @@ +package services + +import ( + "context" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + + "arma3-web-server/internal/models" +) + +type ProcessManager struct { + serverfileDir string + modsDir string + cfgDir string + profilesDir string + settings *SettingsManager + modlists *ModlistManager + configs *ConfigManager + streamer *LogStreamer + proc *runningProcess + mu sync.RWMutex +} + +type runningProcess struct { + cmd *exec.Cmd + cancel context.CancelFunc + exited chan struct{} +} + +func NewProcessManager(serverfileDir, modsDir, cfgDir, profilesDir string, settings *SettingsManager, modlists *ModlistManager, configs *ConfigManager, streamer *LogStreamer) *ProcessManager { + return &ProcessManager{ + serverfileDir: serverfileDir, + modsDir: modsDir, + cfgDir: cfgDir, + profilesDir: profilesDir, + settings: settings, + modlists: modlists, + configs: configs, + streamer: streamer, + } +} + +func (pm *ProcessManager) ServerfileDir() string { + return pm.serverfileDir +} + +func (pm *ProcessManager) ProfilesDir() string { + return pm.profilesDir +} + +func (pm *ProcessManager) IsRunning() bool { + pm.mu.RLock() + defer pm.mu.RUnlock() + return pm.proc != nil +} + +func (pm *ProcessManager) Start() error { + pm.mu.Lock() + if pm.proc != nil { + pm.mu.Unlock() + return fmt.Errorf("server already running") + } + pm.mu.Unlock() + + s, err := pm.settings.Load() + if err != nil { + return fmt.Errorf("load settings: %w", err) + } + + binName := "arma3server_x64" + if s.Platform == "windows" { + binName = "arma3server_x64.exe" + } + binPath := filepath.Join(pm.serverfileDir, binName) + + ctx, cancel := context.WithCancel(context.Background()) + var cmd *exec.Cmd + if strings.Contains(s.ServerParameters, "%command%") { + full := strings.ReplaceAll(s.ServerParameters, "%command%", binPath) + parts := splitArgs(full) + if len(parts) == 0 { + cancel() + return fmt.Errorf("empty command after %%command%% substitution") + } + autoArgs := pm.buildAutoArgs(s) + cmd = exec.CommandContext(ctx, parts[0], append(parts[1:], autoArgs...)...) + } else { + cmd = exec.CommandContext(ctx, binPath, pm.buildArgs(s)...) + } + cmd.Dir = pm.serverfileDir + + stdout, err := cmd.StdoutPipe() + if err != nil { + cancel() + return fmt.Errorf("stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + cancel() + return fmt.Errorf("stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + cancel() + return fmt.Errorf("start: %w", err) + } + + exited := make(chan struct{}) + pm.mu.Lock() + pm.proc = &runningProcess{cmd: cmd, cancel: cancel, exited: exited} + pm.mu.Unlock() + + go pm.streamer.Stream("server", stdout, "") + go pm.streamer.Stream("server", stderr, "") + + go func() { + cmd.Wait() + pm.mu.Lock() + pm.proc = nil + pm.mu.Unlock() + close(exited) + pm.streamer.Broadcast("server", "[SERVER_PROCESS_EXITED]") + }() + + return nil +} + +func (pm *ProcessManager) Stop() error { + pm.mu.Lock() + rp := pm.proc + pm.mu.Unlock() + + if rp == nil { + return fmt.Errorf("server not running") + } + + rp.cancel() + <-rp.exited + return nil +} + +func (pm *ProcessManager) Restart() error { + _ = pm.Stop() + return pm.Start() +} + +func splitArgs(raw string) []string { + var args []string + for _, part := range strings.Fields(raw) { + part = strings.TrimSpace(part) + if part != "" { + args = append(args, part) + } + } + return args +} + +func (pm *ProcessManager) buildAutoArgs(s *models.ServerSettings) []string { + var args []string + + if s.ActiveConfig != "" { + cfgPath := filepath.Join(pm.cfgDir, s.ActiveConfig+".cfg") + args = append(args, "-config="+cfgPath) + } + + if s.ActiveModlist != "" { + modPath := pm.buildModPath(s.ActiveModlist) + if modPath != "" { + args = append(args, modPath) + } + } + + if pm.profilesDir != "" && !hasArgPrefix(args, "-profiles=") { + args = append(args, "-profiles="+pm.profilesDir) + } + + if s.IPPort != "" && !hasArgPrefix(args, "-port=") { + if _, portStr, err := net.SplitHostPort(s.IPPort); err == nil && portStr != "" { + args = append(args, "-port="+portStr) + } + } + + return args +} + +func (pm *ProcessManager) buildArgs(s *models.ServerSettings) []string { + args := splitArgs(s.ServerParameters) + args = append(args, pm.buildAutoArgs(s)...) + return args +} + +func hasArgPrefix(args []string, prefix string) bool { + for _, a := range args { + if strings.HasPrefix(a, prefix) { + return true + } + } + return false +} + +const workshopAppID = "107410" + +func (pm *ProcessManager) buildModPath(modlistID string) string { + ml, err := pm.modlists.Get(modlistID) + if err != nil { + return "" + } + + var parts []string + for _, mod := range ml.Mods { + if !mod.Enabled { + continue + } + parts = append(parts, pm.resolveModPath(mod)) + } + + if len(parts) == 0 { + return "" + } + return "-mod=" + strings.Join(parts, ";") +} + +func (pm *ProcessManager) resolveModPath(mod models.ModEntry) string { + if mod.Name != "" { + p := filepath.Join(pm.modsDir, "@"+mod.Name) + if _, err := os.Stat(p); err == nil { + return p + } + } + + if mod.ID != "" { + p := filepath.Join(pm.serverfileDir, "steamapps", "workshop", "content", workshopAppID, mod.ID) + if _, err := os.Stat(p); err == nil { + return p + } + } + + if mod.Name != "" { + return filepath.Join(pm.modsDir, "@"+mod.Name) + } + return "" +} + +func (pm *ProcessManager) WriteUserconfigFiles(s *models.ServerSettings) error { + userconfigDir := filepath.Join(pm.serverfileDir, "userconfig") + if err := os.MkdirAll(userconfigDir, 0755); err != nil { + return err + } + + files := map[string]string{ + "cba_settings.sqf": s.CBASettings, + "CfgAILevelPresets.sqf": s.AILevelPresets, + "CfgDifficultyPresets.sqf": s.DifficultyPresets, + } + for name, content := range files { + path := filepath.Join(userconfigDir, name) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + return fmt.Errorf("write %s: %w", name, err) + } + } + return nil +} diff --git a/backend/internal/services/settings.go b/backend/internal/services/settings.go new file mode 100644 index 0000000..2e22a6a --- /dev/null +++ b/backend/internal/services/settings.go @@ -0,0 +1,66 @@ +package services + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + "time" + + "arma3-web-server/internal/models" +) + +type SettingsManager struct { + path string + mu sync.Mutex +} + +func NewSettingsManager(dataDir string) *SettingsManager { + return &SettingsManager{ + path: filepath.Join(dataDir, "settings.json"), + } +} + +func (sm *SettingsManager) Load() (*models.ServerSettings, error) { + sm.mu.Lock() + defer sm.mu.Unlock() + + data, err := os.ReadFile(sm.path) + if err != nil { + if os.IsNotExist(err) { + return sm.defaults(), nil + } + return nil, err + } + + var s models.ServerSettings + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + return &s, nil +} + +func (sm *SettingsManager) Save(s *models.ServerSettings) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + s.UpdatedAt = time.Now().UTC() + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return os.WriteFile(sm.path, data, 0644) +} + +func (sm *SettingsManager) defaults() *models.ServerSettings { + return &models.ServerSettings{ + IPPort: "0.0.0.0:2302", + ServerParameters: "-server -world=empty -loadMissionToMemory -noPause", + SteamBranch: "stable", + SteamUser: "anonymous", + Platform: "linux", + ActiveConfig: "", + ActiveModlist: "", + UpdatedAt: time.Now().UTC(), + } +} diff --git a/backend/internal/services/steamcmd.go b/backend/internal/services/steamcmd.go new file mode 100644 index 0000000..f29770b --- /dev/null +++ b/backend/internal/services/steamcmd.go @@ -0,0 +1,142 @@ +package services + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "sync" + "time" +) + +func CheckWorkshopMod(serverfileDir, modID string) bool { + if modID == "" { + return false + } + p := filepath.Join(serverfileDir, "steamapps", "workshop", "content", "107410", modID) + fi, err := os.Stat(p) + return err == nil && fi.IsDir() +} + +const arma3AppID = "233780" + +type SteamCmdManager struct { + serverfileDir string + streamer *LogStreamer + mu sync.Mutex + cancel context.CancelFunc + running bool +} + +func NewSteamCmdManager(serverfileDir string, streamer *LogStreamer) *SteamCmdManager { + return &SteamCmdManager{ + serverfileDir: serverfileDir, + streamer: streamer, + } +} + +func (s *SteamCmdManager) IsRunning() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.running +} + +func (s *SteamCmdManager) UpdateGame(branch, user string) error { + s.mu.Lock() + if s.running { + s.mu.Unlock() + return fmt.Errorf("steamcmd already running") + } + s.mu.Unlock() + + args := []string{ + "+force_install_dir", s.serverfileDir, + "+login", user, + "+app_update", arma3AppID, + } + if branch != "" && branch != "stable" { + args = append(args, "-beta", branch) + } + args = append(args, "validate", "+quit") + + return s.run("Game update", args) +} + +func (s *SteamCmdManager) DownloadMod(modID string) error { + return s.DownloadMods([]string{modID}) +} + +func (s *SteamCmdManager) DownloadMods(modIDs []string) error { + s.mu.Lock() + if s.running { + s.mu.Unlock() + return fmt.Errorf("steamcmd already running") + } + s.mu.Unlock() + + if len(modIDs) == 0 { + return fmt.Errorf("no mod ids provided") + } + + args := []string{ + "+force_install_dir", s.serverfileDir, + "+login", "anonymous", + } + for _, id := range modIDs { + args = append(args, "+workshop_download_item", "107410", id) + } + args = append(args, "+quit") + + label := fmt.Sprintf("Download %d mod(s)", len(modIDs)) + return s.run(label, args) +} + +const steamcmdTimeout = 10 * time.Minute + +func (s *SteamCmdManager) run(label string, args []string) error { + ctx, cancel := context.WithTimeout(context.Background(), steamcmdTimeout) + + s.streamer.Broadcast("steamcmd", "[STEAMCMD] "+label+" starting...") + + cmd := exec.CommandContext(ctx, "steamcmd", args...) + + stdout, err := cmd.StdoutPipe() + if err != nil { + cancel() + return fmt.Errorf("stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + cancel() + return fmt.Errorf("stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + cancel() + return fmt.Errorf("start steamcmd: %w", err) + } + + s.mu.Lock() + s.running = true + s.cancel = cancel + s.mu.Unlock() + + go s.streamer.Stream("steamcmd", stdout, "") + go s.streamer.Stream("steamcmd", stderr, "") + + go func() { + err := cmd.Wait() + s.mu.Lock() + s.running = false + s.cancel = nil + s.mu.Unlock() + if err == nil { + s.streamer.Broadcast("steamcmd", "[STEAMCMD] SUCCESS: "+label+" finished") + } else { + s.streamer.Broadcast("steamcmd", "[STEAMCMD] ERROR: "+label+" failed: "+err.Error()) + } + }() + + return nil +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9c74a7a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + arma3-web-server: + build: . + container_name: arma3-web + ports: + - "8080:8080" + volumes: + - ./data:/data + # /servers should contain the Arma 3 server install (binary + steamapps/workshop for mods) + - /path/to/your/servers:/servers + environment: + - DATA_DIR=/data + - SERVERFILE_DIR=/servers + - MODS_DIR=/servers/mods + - CFG_DIR=/servers/cfg + - PROFILES_DIR=/servers/profiles + - LISTEN=:8080 + - FRONTEND_DIR=/usr/share/arma3-web-server/frontend + - GIN_MODE=release + restart: unless-stopped diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..85b777e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + arma3-frontend + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..d1db082 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1949 @@ +{ + "name": "arma3-frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "arma3-frontend", + "version": "0.0.0", + "dependencies": { + "@monaco-editor/react": "^4.7.0", + "@tailwindcss/vite": "^4.3.2", + "@tanstack/react-query": "^5.101.2", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1", + "tailwindcss": "^4.3.2", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.72.0.tgz", + "integrity": "sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.72.0.tgz", + "integrity": "sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.72.0.tgz", + "integrity": "sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.72.0.tgz", + "integrity": "sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.72.0.tgz", + "integrity": "sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.72.0.tgz", + "integrity": "sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.72.0.tgz", + "integrity": "sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.72.0.tgz", + "integrity": "sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.72.0.tgz", + "integrity": "sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.72.0.tgz", + "integrity": "sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.72.0.tgz", + "integrity": "sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.72.0.tgz", + "integrity": "sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.72.0.tgz", + "integrity": "sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.72.0.tgz", + "integrity": "sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.72.0.tgz", + "integrity": "sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.72.0.tgz", + "integrity": "sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.72.0.tgz", + "integrity": "sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.72.0.tgz", + "integrity": "sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.72.0.tgz", + "integrity": "sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", + "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", + "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.72.0.tgz", + "integrity": "sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.72.0", + "@oxlint/binding-android-arm64": "1.72.0", + "@oxlint/binding-darwin-arm64": "1.72.0", + "@oxlint/binding-darwin-x64": "1.72.0", + "@oxlint/binding-freebsd-x64": "1.72.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.72.0", + "@oxlint/binding-linux-arm-musleabihf": "1.72.0", + "@oxlint/binding-linux-arm64-gnu": "1.72.0", + "@oxlint/binding-linux-arm64-musl": "1.72.0", + "@oxlint/binding-linux-ppc64-gnu": "1.72.0", + "@oxlint/binding-linux-riscv64-gnu": "1.72.0", + "@oxlint/binding-linux-riscv64-musl": "1.72.0", + "@oxlint/binding-linux-s390x-gnu": "1.72.0", + "@oxlint/binding-linux-x64-gnu": "1.72.0", + "@oxlint/binding-linux-x64-musl": "1.72.0", + "@oxlint/binding-openharmony-arm64": "1.72.0", + "@oxlint/binding-win32-arm64-msvc": "1.72.0", + "@oxlint/binding-win32-ia32-msvc": "1.72.0", + "@oxlint/binding-win32-x64-msvc": "1.72.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.22.1", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-router": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f6acf5f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "arma3-frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@monaco-editor/react": "^4.7.0", + "@tailwindcss/vite": "^4.3.2", + "@tanstack/react-query": "^5.101.2", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.1", + "tailwindcss": "^4.3.2", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..d0638dd --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,28 @@ +import { Routes, Route, Navigate } from 'react-router-dom' +import Layout from './components/Layout' +import Dashboard from './pages/Dashboard' +import Settings from './pages/Settings' +import Configs from './pages/Configs' +import ConfigEditor from './pages/ConfigEditor' +import Modlists from './pages/Modlists' +import ModlistEditor from './pages/ModlistEditor' +import Logs from './pages/Logs' + +function App() { + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ) +} + +export default App diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..4967e76 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,91 @@ +import type { ServerSettings, ServerPaths, ConfigInfo, Modlist, ModlistListItem, ModEntry } from '../types' + +const BASE = '/api' + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + headers: { 'Content-Type': 'application/json', ...options?.headers }, + ...options, + }) + if (res.status === 204) return undefined as T + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })) + throw new Error(err.error || res.statusText) + } + return res.json() +} + +export const settingsApi = { + get: () => request('/server/settings'), + update: (data: Partial) => request('/server/settings', { method: 'PUT', body: JSON.stringify(data) }), + start: () => request<{ status: string }>('/server/start', { method: 'POST' }), + stop: () => request<{ status: string }>('/server/stop', { method: 'POST' }), + restart: () => request<{ status: string }>('/server/restart', { method: 'POST' }), + status: () => request<{ running: boolean }>('/server/status'), + steamcmd: () => request<{ branch: string; user: string; platform: string }>('/server/steamcmd'), + paths: () => request('/server/paths'), +} + +export const configsApi = { + list: () => request('/configs'), + get: async (name: string) => { + const r = await fetch(`${BASE}/configs/${name}`) + if (!r.ok) { + const err = await r.json().catch(() => ({ error: r.statusText })) + throw new Error(err.error || r.statusText) + } + return r.text() + }, + create: (name: string, content?: string) => request<{ name: string }>('/configs', { method: 'POST', body: JSON.stringify({ name, content: content || '' }) }), + update: (name: string, content: string) => request<{ status: string }>(`/configs/${name}`, { method: 'PUT', body: JSON.stringify({ content }) }), + delete: (name: string) => request(`/configs/${name}`, { method: 'DELETE' }), + duplicate: (name: string, newName: string) => request<{ name: string }>(`/configs/${name}/duplicate`, { method: 'POST', body: JSON.stringify({ new_name: newName }) }), +} + +export interface ModStatus { + id: string + name: string + enabled: boolean + downloaded: boolean +} + +export const modlistsApi = { + list: () => request('/modlists'), + get: (id: string) => request(`/modlists/${id}`), + create: (name: string) => request('/modlists', { method: 'POST', body: JSON.stringify({ name }) }), + update: (id: string, data: { name: string; mods: ModEntry[] }) => request(`/modlists/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + delete: (id: string) => request(`/modlists/${id}`, { method: 'DELETE' }), + duplicate: (id: string, name: string) => request(`/modlists/${id}/duplicate`, { method: 'POST', body: JSON.stringify({ name }) }), + importHtml: (file: File) => { + const form = new FormData() + form.append('file', file) + return fetch(`${BASE}/modlists/import`, { method: 'POST', body: form }).then(r => { + if (!r.ok) return r.json().then(e => { throw new Error(e.error) }) + return r.json() as Promise + }) + }, + checkMods: (id: string) => request(`/modlists/${id}/check`), + downloadMissing: (id: string) => request<{ status: string; count?: number }>(`/modlists/${id}/download-missing`, { method: 'POST' }), + updateAll: (id: string) => request<{ status: string; count?: number }>(`/modlists/${id}/update-all`, { method: 'POST' }), +} + +function wsUrl(path: string): string { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + return `${protocol}//${window.location.host}${path}` +} + +export const steamcmdApi = { + updateGame: (branch: string, user: string) => + request<{ status: string }>('/server/steamcmd/update-game', { method: 'POST', body: JSON.stringify({ branch, user }) }), + downloadMod: (modId: string) => + request<{ status: string }>('/server/steamcmd/download-mod', { method: 'POST', body: JSON.stringify({ mod_id: modId }) }), + status: () => request<{ running: boolean }>('/server/steamcmd/status'), +} + +export const logsApi = { + list: () => request('/server/logs'), + get: (file: string) => fetch(`${BASE}/server/logs/${file}`).then(r => r.text()), + wsUrl: () => wsUrl('/ws/server/logs'), + steamcmdWsUrl: () => wsUrl('/ws/steamcmd/logs'), + rptWsUrl: () => wsUrl('/ws/server/rpt'), +} diff --git a/frontend/src/components/ConfigEditor.tsx b/frontend/src/components/ConfigEditor.tsx new file mode 100644 index 0000000..279ba96 --- /dev/null +++ b/frontend/src/components/ConfigEditor.tsx @@ -0,0 +1,30 @@ +import Editor from '@monaco-editor/react' + +interface Props { + value: string + onChange: (value: string) => void + language?: string +} + +export default function ConfigEditor({ value, onChange, language = 'plaintext' }: Props) { + return ( +
+ onChange(v ?? '')} + theme="vs-dark" + options={{ + minimap: { enabled: false }, + fontSize: 13, + fontFamily: '"JetBrains Mono", "Fira Code", monospace', + lineNumbers: 'on', + scrollBeyondLastLine: false, + automaticLayout: true, + tabSize: 2, + }} + /> +
+ ) +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..3a6db62 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,37 @@ +import { Link, useLocation } from 'react-router-dom' +import type { ReactNode } from 'react' + +const nav = [ + { to: '/', label: 'Dashboard', icon: '⊞' }, + { to: '/settings', label: 'Settings', icon: '🖥' }, + { to: '/configs', label: 'Configs', icon: '⚙' }, + { to: '/modlists', label: 'Modlists', icon: '📦' }, + { to: '/logs', label: 'Logs', icon: '📋' }, +] + +export default function Layout({ children }: { children: ReactNode }) { + const loc = useLocation() + + return ( +
+ +
{children}
+
+ ) +} diff --git a/frontend/src/components/LiveTerminal.tsx b/frontend/src/components/LiveTerminal.tsx new file mode 100644 index 0000000..075de54 --- /dev/null +++ b/frontend/src/components/LiveTerminal.tsx @@ -0,0 +1,87 @@ +import { useEffect, useRef } from 'react' +import { Terminal } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import '@xterm/xterm/css/xterm.css' + +interface Props { + wsUrl: string +} + +export default function LiveTerminal({ wsUrl }: Props) { + const containerRef = useRef(null) + const terminalRef = useRef(null) + const fitRef = useRef(null) + + useEffect(() => { + if (!containerRef.current) return + + const term = new Terminal({ + theme: { background: '#0a0a0a', foreground: '#e0e0e0', cursor: '#e0e0e0' }, + fontSize: 13, + fontFamily: '"JetBrains Mono", "Fira Code", monospace', + cursorBlink: true, + convertEol: true, + rows: 30, + }) + + const fit = new FitAddon() + term.loadAddon(fit) + term.open(containerRef.current) + fit.fit() + term.write('Connecting...\r\n') + + terminalRef.current = term + fitRef.current = fit + + let ws: WebSocket | null = null + let reconnectTimer: ReturnType + let disposed = false + + function connect() { + if (disposed) return + ws = new WebSocket(wsUrl) + ws.onopen = () => { + if (disposed) { ws?.close(); return } + term.clear() + term.write('--- Connected ---\r\n') + } + ws.onmessage = (event) => { + const line = event.data as string + if (line === '[SERVER_PROCESS_EXITED]') { + term.write('\r\n\x1b[31m--- Process exited ---\x1b[0m\r\n') + } else if (line.startsWith('[STEAMCMD] SUCCESS:')) { + term.write('\r\n\x1b[32m' + line + '\x1b[0m\r\n') + } else if (line.startsWith('[STEAMCMD] ERROR:')) { + term.write('\r\n\x1b[31m' + line + '\x1b[0m\r\n') + } else if (line.startsWith('[STEAMCMD]')) { + term.write('\r\n\x1b[33m' + line + '\x1b[0m\r\n') + } else { + term.writeln(line) + } + } + ws.onclose = () => { + if (disposed) return + term.write('\r\n\x1b[33m--- Disconnected, reconnecting in 3s ---\x1b[0m\r\n') + reconnectTimer = setTimeout(connect, 3000) + } + ws.onerror = () => ws?.close() + } + + connect() + + const resizeObserver = new ResizeObserver(() => { + try { fit.fit() } catch {} + }) + resizeObserver.observe(containerRef.current) + + return () => { + disposed = true + clearTimeout(reconnectTimer) + resizeObserver.disconnect() + ws?.close() + term.dispose() + } + }, [wsUrl]) + + return
+} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..9ac4ae8 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,18 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import './index.css' +import App from './App' + +const queryClient = new QueryClient() + +createRoot(document.getElementById('root')!).render( + + + + + + + , +) diff --git a/frontend/src/pages/ConfigEditor.tsx b/frontend/src/pages/ConfigEditor.tsx new file mode 100644 index 0000000..21f3af7 --- /dev/null +++ b/frontend/src/pages/ConfigEditor.tsx @@ -0,0 +1,56 @@ +import { useParams, Link } from 'react-router-dom' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { configsApi } from '../api/client' +import { useState, useEffect } from 'react' +import Editor from '@monaco-editor/react' + +export default function ConfigEditor() { + const { name } = useParams<{ name: string }>() + const qc = useQueryClient() + const { data: content, isLoading } = useQuery({ queryKey: ['config', name], queryFn: () => configsApi.get(name!) }) + const [value, setValue] = useState('') + const [dirty, setDirty] = useState(false) + + useEffect(() => { if (content !== undefined) { setValue(content); setDirty(false) } }, [content]) + + const saveMut = useMutation({ + mutationFn: () => configsApi.update(name!, value), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['config', name] }); setDirty(false) }, + }) + + if (isLoading) return

Loading...

+ if (!name) return

No config specified

+ + return ( +
+
+
+ ← Configs +

{name}.cfg

+
+ +
+ +
+ { setValue(v ?? ''); setDirty(true) }} + theme="vs-dark" + options={{ + minimap: { enabled: false }, + fontSize: 13, + fontFamily: '"JetBrains Mono", "Fira Code", monospace', + lineNumbers: 'on', + scrollBeyondLastLine: false, + automaticLayout: true, + tabSize: 2, + }} + /> +
+
+ ) +} diff --git a/frontend/src/pages/Configs.tsx b/frontend/src/pages/Configs.tsx new file mode 100644 index 0000000..238c847 --- /dev/null +++ b/frontend/src/pages/Configs.tsx @@ -0,0 +1,59 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { configsApi } from '../api/client' +import { Link, useNavigate } from 'react-router-dom' +import { useState } from 'react' + +export default function Configs() { + const qc = useQueryClient() + const navigate = useNavigate() + const { data: configs, isLoading } = useQuery({ queryKey: ['configs'], queryFn: configsApi.list }) + const [showCreate, setShowCreate] = useState(false) + const [name, setName] = useState('') + + const createMut = useMutation({ + mutationFn: () => configsApi.create(name), + onSuccess: (res) => { qc.invalidateQueries({ queryKey: ['configs'] }); setShowCreate(false); setName(''); navigate(`/configs/${res.name}`) }, + }) + + const deleteMut = useMutation({ + mutationFn: (name: string) => configsApi.delete(name), + onSuccess: () => qc.invalidateQueries({ queryKey: ['configs'] }), + }) + + const duplicateMut = useMutation({ + mutationFn: (name: string) => configsApi.duplicate(name, `${name}-copy`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['configs'] }), + }) + + return ( +
+
+

Configs

+ +
+ + {showCreate && ( +
+ setName(e.target.value)} placeholder="Config name" className="bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm flex-1" /> + +
+ )} + + {isLoading &&

Loading...

} + +
+ {configs?.map(c => ( +
+ {c.name}.cfg +
+ {(c.size / 1024).toFixed(1)} KB + + +
+
+ ))} + {configs?.length === 0 &&

No configs yet.

} +
+
+ ) +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..253d5c3 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,66 @@ +import { useQuery } from '@tanstack/react-query' +import { Link } from 'react-router-dom' +import { settingsApi, configsApi, modlistsApi } from '../api/client' + +export default function Dashboard() { + const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: settingsApi.get }) + const { data: status } = useQuery({ queryKey: ['server-status'], queryFn: settingsApi.status, refetchInterval: 5000 }) + const { data: configs } = useQuery({ queryKey: ['configs'], queryFn: configsApi.list }) + const { data: modlists } = useQuery({ queryKey: ['modlists'], queryFn: modlistsApi.list }) + + return ( +
+

Dashboard

+ +
+
+
Server
+
+ {status?.running ? 'Running' : 'Stopped'} +
+
+
+
Configs
+
{configs?.length ?? 0}
+
+
+
Modlists
+
{modlists?.length ?? 0}
+
+
+
Platform
+
{settings?.platform ?? '-'}
+
+
+ +
+
+

Server Info

+ {settings ? ( +
+ + + + + +
+ ) :

Loading...

} +
+ +
+

Quick Actions

+
+ Settings + Configs + Modlists + Logs +
+
+
+
+ ) +} + +function Row({ label, value }: { label: string; value: string }) { + return
{label}{value}
+} diff --git a/frontend/src/pages/Logs.tsx b/frontend/src/pages/Logs.tsx new file mode 100644 index 0000000..0469e34 --- /dev/null +++ b/frontend/src/pages/Logs.tsx @@ -0,0 +1,83 @@ +import { useQuery } from '@tanstack/react-query' +import { logsApi } from '../api/client' +import LiveTerminal from '../components/LiveTerminal' +import { useState } from 'react' + +type TermTab = 'server' | 'rpt' | 'steamcmd' + +export default function Logs() { + const { data: logFiles } = useQuery({ queryKey: ['logs'], queryFn: logsApi.list }) + const [selectedFile, setSelectedFile] = useState(null) + const { data: fileContent } = useQuery({ + queryKey: ['log', selectedFile], + queryFn: () => logsApi.get(selectedFile!), + enabled: !!selectedFile, + }) + const [termTab, setTermTab] = useState('server') + + const wsUrl = termTab === 'server' ? logsApi.wsUrl() : termTab === 'rpt' ? logsApi.rptWsUrl() : logsApi.steamcmdWsUrl() + + return ( +
+
+

Live Console

+
+ +
+ + + +
+ +
+ +
+ +

Log Files

+
+
+ {logFiles?.map(f => ( + + ))} + {(!logFiles || logFiles.length === 0) &&

No log files

} +
+
+ {fileContent ? ( +
{fileContent}
+ ) : ( +

Select a log file

+ )} +
+
+
+ ) +} diff --git a/frontend/src/pages/ModlistEditor.tsx b/frontend/src/pages/ModlistEditor.tsx new file mode 100644 index 0000000..8858b4e --- /dev/null +++ b/frontend/src/pages/ModlistEditor.tsx @@ -0,0 +1,175 @@ +import { useParams, Link } from 'react-router-dom' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { modlistsApi, steamcmdApi, logsApi } from '../api/client' +import { useState, useEffect } from 'react' +import LiveTerminal from '../components/LiveTerminal' + +interface ModEntry { + id: string + name: string + enabled: boolean +} + +export default function ModlistEditor() { + const { id } = useParams<{ id: string }>() + const qc = useQueryClient() + const { data: modlist, isLoading } = useQuery({ queryKey: ['modlist', id], queryFn: () => modlistsApi.get(id!) }) + const [name, setName] = useState('') + const [mods, setMods] = useState([]) + const [dirty, setDirty] = useState(false) + const [newId, setNewId] = useState('') + const [newName, setNewName] = useState('') + + const { data: steamcmdStatus } = useQuery({ queryKey: ['steamcmd-status'], queryFn: steamcmdApi.status, refetchInterval: 3000 }) + const steamcmdBusy = steamcmdStatus?.running ?? false + + const { data: modStatuses } = useQuery({ + queryKey: ['modlist-check', id], + queryFn: () => modlistsApi.checkMods(id!), + enabled: !!id, + refetchInterval: steamcmdBusy ? 3000 : false, + }) + const downloadedMap = new Map(modStatuses?.map(m => [m.id, m.downloaded]) ?? []) + + useEffect(() => { + if (modlist) { setName(modlist.name); setMods(modlist.mods); setDirty(false) } + }, [modlist]) + + const saveMut = useMutation({ + mutationFn: () => modlistsApi.update(id!, { name, mods }), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['modlist', id] }); qc.invalidateQueries({ queryKey: ['modlists'] }); setDirty(false) }, + }) + + const toggleMod = (index: number) => { + setMods(prev => prev.map((m, i) => i === index ? { ...m, enabled: !m.enabled } : m)) + setDirty(true) + } + + const removeMod = (index: number) => { + setMods(prev => prev.filter((_, i) => i !== index)) + setDirty(true) + } + + const addMod = () => { + if (!newId.trim() || !newName.trim()) return + setMods(prev => [...prev, { id: newId.trim(), name: newName.trim(), enabled: true }]) + setNewId(''); setNewName('') + setDirty(true) + } + + const moveMod = (index: number, direction: -1 | 1) => { + const target = index + direction + if (target < 0 || target >= mods.length) return + setMods(prev => { + const next = [...prev] + ;[next[index], next[target]] = [next[target], next[index]] + return next + }) + setDirty(true) + } + + const [showSteamCMD, setShowSteamCMD] = useState(false) + + const downloadMissingMut = useMutation({ + mutationFn: () => modlistsApi.downloadMissing(id!), + onSuccess: () => { setShowSteamCMD(true); qc.invalidateQueries({ queryKey: ['steamcmd-status'] }) }, + }) + const updateAllMut = useMutation({ + mutationFn: () => modlistsApi.updateAll(id!), + onSuccess: () => { setShowSteamCMD(true); qc.invalidateQueries({ queryKey: ['steamcmd-status'] }) }, + }) + + if (isLoading) return

Loading...

+ if (!modlist) return

Modlist not found

+ + const modCount = mods.length + const missingCount = mods.filter(m => m.id && !downloadedMap.get(m.id)).length + const hasWorkshopMods = mods.some(m => m.id) + + return ( +
+
+
+ ← Modlists +

{modlist.name}

+
+
+ {hasWorkshopMods && ( + <> + + + + )} + +
+
+ +
+ {steamcmdBusy ? ( + SteamCMD running — status auto-refreshing + ) : modStatuses && ( + {downloadedMap.size} mods checked — {missingCount} missing + )} +
+ +
+ + { setName(e.target.value); setDirty(true) }} className="bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm w-full max-w-md" /> +
+ +
+ setNewId(e.target.value)} placeholder="Workshop ID" className="bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm w-40" /> + setNewName(e.target.value)} placeholder="Mod name" className="bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm flex-1 max-w-xs" /> + +
+ +
+ {mods.map((mod, i) => { + const status = downloadedMap.get(mod.id) + return ( +
+ + + toggleMod(i)} className="accent-blue-500" /> +
+ {mod.name} + ({mod.id}) +
+ {mod.id && status !== undefined && ( + status + ? + : + )} + +
+ ) + })} + {mods.length === 0 &&

No mods. Add one using the form above.

} +
+ + {showSteamCMD && ( +
+
+

SteamCMD Output

+ +
+ +
+ )} +
+ ) +} diff --git a/frontend/src/pages/Modlists.tsx b/frontend/src/pages/Modlists.tsx new file mode 100644 index 0000000..a67c7c7 --- /dev/null +++ b/frontend/src/pages/Modlists.tsx @@ -0,0 +1,80 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { modlistsApi } from '../api/client' +import { Link, useNavigate } from 'react-router-dom' +import { useRef, useState } from 'react' + +export default function Modlists() { + const qc = useQueryClient() + const navigate = useNavigate() + const fileRef = useRef(null) + const { data: modlists, isLoading } = useQuery({ queryKey: ['modlists'], queryFn: modlistsApi.list }) + const [showCreate, setShowCreate] = useState(false) + const [name, setName] = useState('') + + const createMut = useMutation({ + mutationFn: () => modlistsApi.create(name), + onSuccess: (res) => { qc.invalidateQueries({ queryKey: ['modlists'] }); setShowCreate(false); setName(''); navigate(`/modlists/${res.id}`) }, + }) + + const deleteMut = useMutation({ + mutationFn: (id: string) => modlistsApi.delete(id), + onSuccess: () => qc.invalidateQueries({ queryKey: ['modlists'] }), + }) + + const duplicateMut = useMutation({ + mutationFn: (id: string) => { + const orig = modlists?.find(m => m.id === id) + return modlistsApi.duplicate(id, `${orig?.name ?? 'copy'} (copy)`) + }, + onSuccess: () => qc.invalidateQueries({ queryKey: ['modlists'] }), + }) + + const importMut = useMutation({ + mutationFn: (file: File) => modlistsApi.importHtml(file), + onSuccess: (res) => { qc.invalidateQueries({ queryKey: ['modlists'] }); navigate(`/modlists/${res.id}`) }, + }) + + const handleImport = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) importMut.mutate(file) + e.target.value = '' + } + + return ( +
+ +
+

Modlists

+
+ + +
+
+ + {showCreate && ( +
+ setName(e.target.value)} placeholder="Modlist name" className="bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm flex-1" /> + +
+ )} + + {isLoading &&

Loading...

} + +
+ {modlists?.map(m => ( +
+
+ {m.name} + {m.mod_count} mods +
+
+ + +
+
+ ))} + {modlists?.length === 0 &&

No modlists yet.

} +
+
+ ) +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..b1aee4e --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,247 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { settingsApi, configsApi, modlistsApi, steamcmdApi, logsApi } from '../api/client' +import { useState, useEffect } from 'react' +import ConfigEditor from '../components/ConfigEditor' +import LiveTerminal from '../components/LiveTerminal' + +type Tab = 'main' | 'cba' | 'ai' | 'difficulty' + +export default function Settings() { + const qc = useQueryClient() + const { data: settings, isLoading } = useQuery({ queryKey: ['settings'], queryFn: settingsApi.get }) + const { data: configs } = useQuery({ queryKey: ['configs'], queryFn: configsApi.list }) + const { data: modlists } = useQuery({ queryKey: ['modlists'], queryFn: modlistsApi.list }) + const { data: paths } = useQuery({ queryKey: ['paths'], queryFn: settingsApi.paths }) + const { data: status } = useQuery({ queryKey: ['server-status'], queryFn: settingsApi.status, refetchInterval: 3000 }) + const [tab, setTab] = useState('main') + const [dirty, setDirty] = useState(false) + + const [ipPort, setIpPort] = useState('') + const [params, setParams] = useState('') + const [branch, setBranch] = useState('stable') + const [user, setUser] = useState('anonymous') + const [platform, setPlatform] = useState<'linux' | 'windows'>('linux') + const [cba, setCba] = useState('') + const [aiLevel, setAiLevel] = useState('') + const [difficulty, setDifficulty] = useState('') + const [activeConfig, setActiveConfig] = useState('') + const [activeModlist, setActiveModlist] = useState('') + + useEffect(() => { + if (settings) { + setIpPort(settings.ip_port) + setParams(settings.server_parameters) + setBranch(settings.steam_branch) + setUser(settings.steam_user) + setPlatform(settings.platform) + setCba(settings.cba_settings) + setAiLevel(settings.ai_level_presets) + setDifficulty(settings.difficulty_presets) + setActiveConfig(settings.active_config) + setActiveModlist(settings.active_modlist) + } + }, [settings]) + + const saveMut = useMutation({ + mutationFn: () => settingsApi.update({ + ip_port: ipPort, + server_parameters: params, + steam_branch: branch, + steam_user: user, + platform, + cba_settings: cba, + ai_level_presets: aiLevel, + difficulty_presets: difficulty, + active_config: activeConfig, + active_modlist: activeModlist, + }), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['settings'] }); setDirty(false) }, + }) + + const startMut = useMutation({ mutationFn: settingsApi.start, onSuccess: () => qc.invalidateQueries({ queryKey: ['server-status'] }) }) + const stopMut = useMutation({ mutationFn: settingsApi.stop, onSuccess: () => qc.invalidateQueries({ queryKey: ['server-status'] }) }) + const restartMut = useMutation({ mutationFn: settingsApi.restart, onSuccess: () => qc.invalidateQueries({ queryKey: ['server-status'] }) }) + + const [modId, setModId] = useState('') + const [showSteamCMD, setShowSteamCMD] = useState(false) + const { data: steamcmdStatus } = useQuery({ queryKey: ['steamcmd-status'], queryFn: steamcmdApi.status, refetchInterval: 3000 }) + + const updateGameMut = useMutation({ + mutationFn: () => steamcmdApi.updateGame(branch, user), + onSuccess: () => { setShowSteamCMD(true); qc.invalidateQueries({ queryKey: ['steamcmd-status'] }) }, + }) + const downloadModMut = useMutation({ + mutationFn: () => steamcmdApi.downloadMod(modId), + onSuccess: () => { setShowSteamCMD(true); qc.invalidateQueries({ queryKey: ['steamcmd-status'] }) }, + }) + + const handleSaveThen = async (action: () => Promise) => { + if (dirty) { + await saveMut.mutateAsync() + } + await action() + } + + if (isLoading) return

Loading...

+ + const tabs: { key: Tab; label: string; note?: string }[] = [ + { key: 'main', label: 'Main' }, + { key: 'cba', label: 'CBA Settings', note: 'userconfig/cba_settings.sqf' }, + { key: 'ai', label: 'AI Level Presets', note: 'userconfig/CfgAILevelPresets.sqf' }, + { key: 'difficulty', label: 'Difficulty Presets', note: 'userconfig/CfgDifficultyPresets.sqf' }, + ] + + return ( +
+
+

Server Settings

+
+ + {status?.running ? 'Running' : 'Stopped'} + + + + + +
+
+ +
+ {tabs.map(t => ( + + ))} +
+ + {tab === 'main' ? ( +
+
+ + { setIpPort(e.target.value); setDirty(true) }} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" /> +
+
+ +