commit b853aead8c51eb42ef294d45105ca1a34f6d086f Author: MrFastwind Date: Mon Jul 6 01:29:23 2026 +0200 Initial commit 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" /> +
+
+ +