Compare commits

...

11 Commits

Author SHA1 Message Date
MrFastwind
914e0fbe48 Make deployment-friendly defaults: no env vars needed to run
Some checks failed
CI / build (push) Failing after 3s
- Go binary defaults DATA_DIR=./data, SERVERFILE_DIR=./serverfiles
  (relative to cwd) — just copy the binary and run
- MODS_DIR, CFG_DIR, PROFILES_DIR are derived from SERVERFILE_DIR
- Removed DEV_DEPLOY_DIR indirection (was only used for development)
- Makefile expands defaults to ../dev-deploy/... directly for dev
2026-07-09 15:42:54 +02:00
MrFastwind
9aa6fca50e Fix embedded frontend serving: use fs.ReadFile instead of Gin FileFromFS
- FileFromFS with http.FS(embedFS) returned 301 redirects due to
  Gin setting c.Request.URL.Path to a relative path for http.FileServer
- Replace with direct fs.ReadFile + Gin c.Data, determining content
  type via mime.TypeByExtension
- Supports all asset types (JS, CSS, SVG, HTML) and SPA fallback
2026-07-09 15:34:50 +02:00
MrFastwind
bc0a252d7d Embed frontend dist into Go binary; remove frontend path from health
- New backend/embed package embeds frontend dist/ via //go:embed
- Go binary serves frontend from embedded FS (no external files needed)
- Dev mode uses placeholder dir (dev.txt) so Go compiles; Vite handles
  frontend in dev mode
- Makefile copies frontend/dist/ to backend/embed/dist/ during build
- Removed FRONTEND_DIR env var and filesystem serving fallback
- Removed frontend path from Health API response and Status page frontend
  section (only Served boolean remains)
- Removed frontendDir from Handler struct/constructor (unused)
2026-07-09 15:27:50 +02:00
MrFastwind
2d301b95b6 Add server health endpoint and status page
Backend:
- New /api/server/health endpoint returning server, paths, disk,
  mods, steamcmd, and frontend status
- Health endpoint checks directory existence/writability, disk usage,
  server binary, and frontend dist availability

Frontend:
- New Status page (route /status) with sections for Server, SteamCMD,
  Mods, Frontend, Directories (with exists/writable badges), and
  Disk Usage (with size formatting and used-percent coloring)
- Paths are clickable to copy and use start-truncation (ellipsis on
  the left) with native hover tooltip
- Health API client integration with 10s auto-refresh

Misc:
- Update .gitignore and Makefile, minor Layout nav addition
2026-07-09 15:22:43 +02:00
MrFastwind
6f782af913 refactor(modlist): replace html/template with text/template and clean up deps
Some checks failed
CI / build (push) Failing after 3s
2026-07-09 14:13:09 +02:00
MrFastwind
6774397885 feat: add modlist export to Arma 3 Launcher HTML format
- RenderModlistHTML: generates compatible preset HTML from a modlist
- GET /api/modlists/:id/export: returns downloadable .html file
- Export button on ModlistEditor page
- Skips local mods (no workshop ID), includes only Steam workshop mods
2026-07-06 18:33:24 +02:00
MrFastwind
d94593743f ci: add build and distribution pipeline with goreleaser and Gitea Actions
- .goreleaser.yaml: cross-compile linux/windows, Docker image, Gitea releases
- .gitea/workflows/ci.yml: verify build on every push/PR
- .gitea/workflows/release.yml: goreleaser + Docker on tag push
- Dockerfile: switch to debian:bookworm-slim, install steamcmd from Valve
- Dockerfile.goreleaser: minimal image used by goreleaser dockers
- docker-compose.yml: parameterize SERVERS_DIR via env var
2026-07-06 17:35:23 +02:00
MrFastwind
cc7cb0bc56 feat(frontend): add Mods page with table view, delete, and cleanup 2026-07-06 16:10:32 +02:00
MrFastwind
c06a9a7981 feat(backend): add mod management API endpoints 2026-07-06 16:10:30 +02:00
MrFastwind
26cc812e73 feat(backend): add mod manager service for scanning workshop/local mods 2026-07-06 16:10:24 +02:00
MrFastwind
149a60c6fb feat: add automation features (auto-update, auto-start, scheduled updates) 2026-07-06 16:10:17 +02:00
33 changed files with 1472 additions and 117 deletions

19
.dockerignore Normal file
View File

@@ -0,0 +1,19 @@
node_modules/
frontend/node_modules/
frontend/dist/
data/
serverfiles/
.git/
.gitignore
*.log
*.rpt
.DS_Store
.env
modlist-example.html
Start-Server-kp.bat
PLAN.md
CODEBASE.md
ARMA3.md
.gitea/
.goreleaser.yaml
.dockerignore

20
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,20 @@
name: CI
on:
push:
branches:
- "**"
pull_request: {}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.25"
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: cd backend && go build ./...
- run: cd frontend && npm ci && npm run build

View File

@@ -0,0 +1,30 @@
name: Release
on:
push:
tags:
- "v*"
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: "1.25"
- uses: actions/setup-node@v4
with:
node-version: "22"
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- run: cd frontend && npm ci
- uses: goreleaser/goreleaser-action@v6
with:
version: "~> 2"
args: release --clean
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
REGISTRY: ${{ gitea.server_host }}
REPOSITORY_OWNER: ${{ gitea.repository_owner }}

2
.gitignore vendored
View File

@@ -1,6 +1,7 @@
node_modules/ node_modules/
dist/ dist/
frontend/dist/ frontend/dist/
backend/embed/dist/
*.exe *.exe
*.test *.test
@@ -8,6 +9,7 @@ backend/arma3-web-server
data/ data/
serverfiles/ serverfiles/
dev-deploy/
.env .env
.DS_Store .DS_Store

52
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,52 @@
version: 2
project_name: arma3-web-server
before:
hooks:
- cd frontend && npm ci && npm run build
builds:
- id: server
dir: backend
main: ./cmd/server/
binary: arma3-web-server
goos:
- linux
- windows
goarch:
- amd64
env:
- CGO_ENABLED=0
archives:
- id: default
name_template: >-
{{ .ProjectName }}_{{ .Tag }}_{{ .Os }}_{{ .Arch }}
files:
- frontend/dist/**/*
dockers:
- ids:
- server
goos: linux
goarch: amd64
image_templates:
- "{{ .Env.REGISTRY }}/{{ .Env.REPOSITORY_OWNER }}/arma3-web-server:{{ .Tag }}"
- "{{ .Env.REGISTRY }}/{{ .Env.REPOSITORY_OWNER }}/arma3-web-server:latest"
dockerfile: Dockerfile.goreleaser
extra_files:
- frontend/dist
checksum:
name_template: "checksums.txt"
snapshot:
name_template: "{{ incpatch .Version }}-next"
release:
gitea:
owner: "{{ .Env.REPOSITORY_OWNER }}"
name: arma3-web-server
draft: false
prerelease: auto

View File

@@ -1,4 +1,4 @@
FROM golang:1.25-alpine AS backend FROM golang:1.25-bookworm AS backend
WORKDIR /src WORKDIR /src
COPY backend/go.mod backend/go.sum ./ COPY backend/go.mod backend/go.sum ./
RUN go mod download RUN go mod download
@@ -12,9 +12,19 @@ RUN npm ci
COPY frontend/ . COPY frontend/ .
RUN npm run build RUN npm run build
FROM alpine:3.21 FROM debian:bookworm-slim
RUN apk add --no-cache ca-certificates tzdata RUN apt-get update && \
RUN apk add --no-cache steamcmd libstdc++ libgcc || true apt-get install -y --no-install-recommends \
ca-certificates \
tzdata \
lib32gcc-s1 \
wget \
xz-utils && \
mkdir -p /steamcmd && \
wget -qO- https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz | \
tar xvz -C /steamcmd && \
ln -s /steamcmd/steamcmd.sh /usr/local/bin/steamcmd && \
rm -rf /var/lib/apt/lists/*
COPY --from=backend /server /usr/local/bin/arma3-web-server COPY --from=backend /server /usr/local/bin/arma3-web-server
COPY --from=frontend /src/dist /usr/share/arma3-web-server/frontend COPY --from=frontend /src/dist /usr/share/arma3-web-server/frontend
@@ -22,12 +32,6 @@ COPY --from=frontend /src/dist /usr/share/arma3-web-server/frontend
EXPOSE 8080 EXPOSE 8080
VOLUME ["/data", "/servers"] 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 DATA_DIR=/data
ENV SERVERFILE_DIR=/servers ENV SERVERFILE_DIR=/servers
ENV MODS_DIR=/servers/mods ENV MODS_DIR=/servers/mods

31
Dockerfile.goreleaser Normal file
View File

@@ -0,0 +1,31 @@
FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates \
tzdata \
lib32gcc-s1 \
wget \
xz-utils && \
mkdir -p /steamcmd && \
wget -qO- https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz | \
tar xvz -C /steamcmd && \
ln -s /steamcmd/steamcmd.sh /usr/local/bin/steamcmd && \
rm -rf /var/lib/apt/lists/*
COPY arma3-web-server /usr/local/bin/arma3-web-server
COPY frontend/dist /usr/share/arma3-web-server/frontend
EXPOSE 8080
VOLUME ["/data", "/servers"]
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"]

View File

@@ -1,11 +1,11 @@
.PHONY: backend frontend build run clean dev .PHONY: backend frontend build run clean dev
# Default paths — override via env or copy .env.example # Default paths for dev — override via env
SERVERFILE_DIR ?= ./serverfiles SERVERFILE_DIR ?= ../dev-deploy/serverfiles
MODS_DIR ?= ./serverfiles/mods MODS_DIR ?= ../dev-deploy/serverfiles/mods
CFG_DIR ?= ./serverfiles/cfg CFG_DIR ?= ../dev-deploy/serverfiles/cfg
PROFILES_DIR ?= ./serverfiles/profiles PROFILES_DIR ?= ../dev-deploy/serverfiles/profiles
DATA_DIR ?= ../../data DATA_DIR ?= ../dev-deploy/data
backend: backend:
cd backend && go build -o arma3-web-server ./cmd/server/ cd backend && go build -o arma3-web-server ./cmd/server/
@@ -13,15 +13,23 @@ backend:
frontend: frontend:
cd frontend && npm run build cd frontend && npm run build
build: backend frontend .PHONY: copyfrontend
copyfrontend:
rm -rf backend/embed/dist
mkdir -p backend/embed/dist
cp -r frontend/dist/. backend/embed/dist/
build: frontend copyfrontend backend
run: 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/ 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: dev:
mkdir -p backend/embed/dist
test -f backend/embed/dist/dev.txt || touch backend/embed/dist/dev.txt
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 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 cd frontend && npm run dev
clean: clean:
rm -f backend/arma3-web-server rm -f backend/arma3-web-server
rm -rf frontend/dist rm -rf frontend/dist backend/embed/dist

View File

@@ -50,6 +50,61 @@ All paths are configurable via environment variables:
| `FRONTEND_DIR` | `../frontend/dist` | Built frontend assets | | `FRONTEND_DIR` | `../frontend/dist` | Built frontend assets |
| `LISTEN` | `:8080` | HTTP listen address | | `LISTEN` | `:8080` | HTTP listen address |
## Automation
The panel can perform automatic operations at startup and on a schedule.
### Startup tasks
Configured in the **Automation** section of the Settings UI:
| Setting | Description |
|---------|-------------|
| **Auto-update server on startup** | Runs `steamcmd +app_update` for the Arma 3 server binary when the web service starts. |
| **Auto-update mods on startup** | Downloads workshop updates for every enabled mod in the active modlist when the web service starts. |
| **Auto-start server on startup** | Restarts the game server if it was running when the web service last stopped. Useful for recovery after host backup cycles or container restarts. |
All startup tasks run asynchronously — the web UI is available immediately.
### Scheduled updates
Set a **cron expression** in the `Scheduled Update` field to run game + mod updates on a recurring schedule (e.g. `"0 4 * * *"` for daily at 4 AM). Leave empty to disable.
The scheduled update runs even if the game server is currently running.
## SteamCMD Authentication
The panel integrates with SteamCMD for server binary updates and workshop mod downloads.
### Anonymous (default)
The `Steam User` field in the settings UI defaults to `anonymous`.
In this mode, **no Steam account is required** — SteamCMD uses `+login anonymous`.
- Workshop mod downloads always use this mode, regardless of the configured user.
- The Arma 3 server binary update is available via `+login anonymous` only if the server files are publicly accessible on that Steam account.
### Authenticated login
To update the Arma 3 server binary (`app_update 233780`) with a real Steam account, set the `Steam User` field to your account name.
The web panel does not pass a password — SteamCMD relies on a **cached session**.
You must authenticate manually once so the login token is persisted:
```bash
# Docker
docker exec -it arma3-web steamcmd +login your_steam_username
```
Enter your password when prompted, including any Steam Guard code if enabled. The session is cached inside the container's filesystem (`~/.steam/`).
> **Note:** SteamCMD sessions may expire. Re-run the login command if the "Update Server" button fails with an authentication error.
### Security
- Steam credentials saved in `data/settings.json` are stored as **plain text**.
- It is recommended to use a **dedicated Steam account** with only the necessary game licenses for automated server management.
## API ## API
REST API at `/api/*` and WebSocket endpoints at `/ws/*`. Key routes: REST API at `/api/*` and WebSocket endpoints at `/ws/*`. Key routes:

View File

@@ -2,14 +2,18 @@ package main
import ( import (
"context" "context"
"io/fs"
"log" "log"
"mime"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"strings"
"syscall" "syscall"
"time" "time"
"arma3-web-server/embed"
"arma3-web-server/internal/api" "arma3-web-server/internal/api"
"arma3-web-server/internal/services" "arma3-web-server/internal/services"
@@ -17,13 +21,12 @@ import (
) )
func main() { func main() {
dataDir := mustAbs(getEnv("DATA_DIR", filepath.Join("data"))) serverfileDir := mustAbs(getEnv("SERVERFILE_DIR", "./serverfiles"))
serverfileDir := mustAbs(getEnv("SERVERFILE_DIR", "serverfiles")) dataDir := mustAbs(getEnv("DATA_DIR", "./data"))
modsDir := mustAbs(getEnv("MODS_DIR", "serverfiles/mods")) modsDir := mustAbs(getEnv("MODS_DIR", filepath.Join(serverfileDir, "mods")))
cfgDir := mustAbs(getEnv("CFG_DIR", "serverfiles/cfg")) cfgDir := mustAbs(getEnv("CFG_DIR", filepath.Join(serverfileDir, "cfg")))
profilesDir := mustAbs(getEnv("PROFILES_DIR", "serverfiles/profiles")) profilesDir := mustAbs(getEnv("PROFILES_DIR", filepath.Join(serverfileDir, "profiles")))
listen := getEnv("LISTEN", ":8080") listen := getEnv("LISTEN", ":8080")
frontendDir := mustAbs(getEnv("FRONTEND_DIR", filepath.Join("..", "frontend", "dist")))
log.Printf("data dir: %s", dataDir) log.Printf("data dir: %s", dataDir)
log.Printf("serverfile dir: %s", serverfileDir) log.Printf("serverfile dir: %s", serverfileDir)
@@ -50,21 +53,86 @@ func main() {
modlistMgr := services.NewModlistManager(dataDir) modlistMgr := services.NewModlistManager(dataDir)
process := services.NewProcessManager(serverfileDir, modsDir, cfgDir, profilesDir, settings, modlistMgr, configMgr, streamer) process := services.NewProcessManager(serverfileDir, modsDir, cfgDir, profilesDir, settings, modlistMgr, configMgr, streamer)
steamcmd := services.NewSteamCmdManager(serverfileDir, streamer) steamcmd := services.NewSteamCmdManager(serverfileDir, streamer)
scheduler := services.NewScheduler(settings, modlistMgr, steamcmd)
r := gin.Default() r := gin.Default()
if fi, err := os.Stat(frontendDir); err == nil && fi.IsDir() { subFS, subErr := fs.Sub(embed.Frontend, "dist")
r.Static("/assets", filepath.Join(frontendDir, "assets")) if subErr == nil {
r.StaticFile("/favicon.ico", filepath.Join(frontendDir, "favicon.ico")) if _, err := subFS.Open("index.html"); err == nil {
r.NoRoute(func(c *gin.Context) { r.NoRoute(func(c *gin.Context) {
c.File(filepath.Join(frontendDir, "index.html")) path := strings.TrimPrefix(c.Request.URL.Path, "/")
if path == "" {
path = "index.html"
}
data, err := fs.ReadFile(subFS, path)
if err == nil {
ct := mime.TypeByExtension(filepath.Ext(path))
if ct == "" {
ct = "text/html; charset=utf-8"
}
c.Data(http.StatusOK, ct, data)
return
}
data, _ = fs.ReadFile(subFS, "index.html")
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
}) })
log.Printf("serving frontend from %s", frontendDir) log.Print("serving embedded frontend")
}
} }
handler := api.New(settings, configMgr, modlistMgr, process, steamcmd, streamer, serverfileDir, modsDir, cfgDir, profilesDir) handler := api.New(settings, configMgr, modlistMgr, process, steamcmd, scheduler, streamer, dataDir, serverfileDir, modsDir, cfgDir, profilesDir)
handler.SetupRoutes(r) handler.SetupRoutes(r)
// Startup auto-tasks
s, err := settings.Load()
if err != nil {
log.Printf("startup: load settings: %v", err)
} else {
if s.AutoUpdateOnStartup && s.SteamUser != "" {
go func() {
log.Print("startup: auto-updating gameserver")
if err := steamcmd.UpdateGame(s.SteamBranch, s.SteamUser); err != nil {
log.Printf("startup: auto-update game failed: %v", err)
}
}()
}
if s.AutoUpdateModsOnStartup && s.ActiveModlist != "" {
go func() {
log.Print("startup: auto-updating mods")
ml, err := modlistMgr.Get(s.ActiveModlist)
if err != nil {
log.Printf("startup: load modlist for auto-update: %v", err)
return
}
var modIDs []string
for _, m := range ml.Mods {
if m.Enabled {
modIDs = append(modIDs, m.ID)
}
}
if len(modIDs) > 0 {
if err := steamcmd.DownloadMods(modIDs); err != nil {
log.Printf("startup: auto-update mods failed: %v", err)
}
}
}()
}
if s.AutoStartOnStartup && s.WasRunning {
go func() {
log.Print("startup: auto-restarting server (was running before)")
if err := process.Start(); err != nil {
log.Printf("startup: auto-start failed: %v", err)
}
}()
}
}
// Start the scheduled updater
scheduler.Start()
log.Printf("starting server on %s", listen) log.Printf("starting server on %s", listen)
srv := &http.Server{Addr: listen, Handler: r} srv := &http.Server{Addr: listen, Handler: r}
@@ -80,6 +148,7 @@ func main() {
<-quit <-quit
log.Print("shutting down server...") log.Print("shutting down server...")
scheduler.Stop()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()

6
backend/embed/embed.go Normal file
View File

@@ -0,0 +1,6 @@
package embed
import "embed"
//go:embed dist
var Frontend embed.FS

View File

@@ -6,7 +6,8 @@ require (
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
modernc.org/sqlite v1.53.0 github.com/robfig/cron/v3 v3.0.1
golang.org/x/net v0.51.0
) )
require ( require (
@@ -14,7 +15,6 @@ require (
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // 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/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
@@ -28,21 +28,15 @@ require (
github.com/mattn/go-isatty v0.0.20 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // 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/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.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/sys v0.44.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // 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
) )

View File

@@ -9,8 +9,6 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= 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 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
@@ -32,14 +30,10 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 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/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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 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 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -53,8 +47,6 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -63,8 +55,8 @@ 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/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 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= 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/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 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.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.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -88,50 +80,16 @@ 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/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= 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 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= 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.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 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= 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 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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/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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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=

View File

@@ -0,0 +1,177 @@
package api
import (
"os"
"path/filepath"
"runtime"
"syscall"
"arma3-web-server/internal/services"
"github.com/gin-gonic/gin"
"golang.org/x/sys/unix"
)
type HealthResponse struct {
Server ServerHealth `json:"server"`
Paths []PathHealth `json:"paths"`
Mods ModsHealth `json:"mods"`
Disk []DiskHealth `json:"disk"`
SteamCMD SteamCMDHealth `json:"steamcmd"`
Frontend FrontendHealth `json:"frontend"`
}
type ServerHealth struct {
Running bool `json:"running"`
Installed bool `json:"installed"`
BinaryPath string `json:"binary_path,omitempty"`
}
type PathHealth struct {
Path string `json:"path"`
Exists bool `json:"exists"`
Writable bool `json:"writable"`
}
type ModsHealth struct {
WorkshopCount int `json:"workshop_count"`
LocalCount int `json:"local_count"`
}
type DiskHealth struct {
Path string `json:"path"`
TotalBytes uint64 `json:"total_bytes"`
FreeBytes uint64 `json:"free_bytes"`
UsedPercent float64 `json:"used_percent"`
}
type SteamCMDHealth struct {
Running bool `json:"running"`
}
type FrontendHealth struct {
Served bool `json:"served"`
}
func (h *Handler) Health(c *gin.Context) {
workshopMods := services.ListWorkshopMods(h.serverfileDir)
localMods := services.ListLocalMods(h.modsDir)
paths := h.checkPaths()
disk := h.checkDisk()
resp := HealthResponse{
Server: ServerHealth{
Running: h.process.IsRunning(),
Installed: checkServerBinary(h.serverfileDir),
BinaryPath: serverBinaryPath(h.serverfileDir),
},
Paths: paths,
Mods: ModsHealth{
WorkshopCount: len(workshopMods),
LocalCount: len(localMods),
},
Disk: disk,
SteamCMD: SteamCMDHealth{
Running: h.steamcmd.IsRunning(),
},
Frontend: FrontendHealth{
Served: true,
},
}
c.JSON(200, resp)
}
func (h *Handler) checkPaths() []PathHealth {
dirs := []string{
h.dataDir,
filepath.Join(h.dataDir, "modlists"),
h.serverfileDir,
h.modsDir,
h.cfgDir,
h.profilesDir,
}
result := make([]PathHealth, 0, len(dirs))
for _, dir := range dirs {
p := PathHealth{Path: dir}
fi, err := os.Stat(dir)
if err == nil && fi.IsDir() {
p.Exists = true
p.Writable = isWritable(dir)
}
result = append(result, p)
}
return result
}
func (h *Handler) checkDisk() []DiskHealth {
paths := []string{h.dataDir, h.serverfileDir}
seen := make(map[string]bool)
var result []DiskHealth
for _, p := range paths {
info, err := diskUsage(p)
if err != nil {
continue
}
key := info.Path
if seen[key] {
continue
}
seen[key] = true
result = append(result, info)
}
return result
}
func checkServerBinary(serverfileDir string) bool {
_, found := findServerBinary(serverfileDir)
return found
}
func serverBinaryPath(serverfileDir string) string {
path, _ := findServerBinary(serverfileDir)
return path
}
func findServerBinary(serverfileDir string) (string, bool) {
candidates := []string{"arma3server_x64"}
if runtime.GOOS == "windows" {
candidates = append(candidates, "arma3server_x64.exe")
} else {
candidates = append(candidates, "arma3server_x64.exe")
}
for _, name := range candidates {
p := filepath.Join(serverfileDir, name)
fi, err := os.Stat(p)
if err == nil && !fi.IsDir() {
return p, true
}
}
return "", false
}
func isWritable(path string) bool {
return unix.Access(path, unix.W_OK) == nil
}
func diskUsage(path string) (DiskHealth, error) {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return DiskHealth{}, err
}
total := stat.Blocks * uint64(stat.Bsize)
free := stat.Bfree * uint64(stat.Bsize)
var usedPercent float64
if total > 0 {
usedPercent = float64(total-free) / float64(total) * 100
}
return DiskHealth{
Path: path,
TotalBytes: total,
FreeBytes: free,
UsedPercent: usedPercent,
}, nil
}

View File

@@ -1,7 +1,9 @@
package api package api
import ( import (
"fmt"
"net/http" "net/http"
"strings"
"arma3-web-server/internal/models" "arma3-web-server/internal/models"
"arma3-web-server/internal/services" "arma3-web-server/internal/services"
@@ -121,6 +123,25 @@ func (h *Handler) ImportModlist(c *gin.Context) {
c.JSON(http.StatusCreated, ml) c.JSON(http.StatusCreated, ml)
} }
func (h *Handler) ExportModlist(c *gin.Context) {
ml, err := h.modlists.Get(c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
html, err := services.RenderModlistHTML(ml.Name, ml.Mods)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
filename := fmt.Sprintf("%s.html", strings.ReplaceAll(ml.Name, `"`, `_`))
c.Header("Content-Type", "text/html; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
c.String(http.StatusOK, html)
}
type modStatus struct { type modStatus struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`

View File

@@ -0,0 +1,89 @@
package api
import (
"net/http"
"arma3-web-server/internal/services"
"github.com/gin-gonic/gin"
)
type deleteModInput struct {
Path string `json:"path" binding:"required"`
}
func (h *Handler) ListMods(c *gin.Context) {
workshopMods := services.ListWorkshopMods(h.serverfileDir)
localMods := services.ListLocalMods(h.modsDir)
usedWorkshop, usedLocal := services.BuildUsageMap(h.modlists)
for i := range workshopMods {
if lists, ok := usedWorkshop[workshopMods[i].ID]; ok {
workshopMods[i].InUse = true
workshopMods[i].Modlists = lists
// pull name from first modlist that has it
if workshopMods[i].Name == "" {
workshopMods[i].Name = lists[0]
}
}
}
for i := range localMods {
// check both the display name and the @name
if lists, ok := usedLocal[localMods[i].ID]; ok {
localMods[i].InUse = true
localMods[i].Modlists = lists
} else if lists, ok := usedLocal[localMods[i].Name]; ok {
localMods[i].InUse = true
localMods[i].Modlists = lists
}
}
all := append(workshopMods, localMods...)
if all == nil {
all = []services.ModInfo{}
}
c.JSON(http.StatusOK, all)
}
func (h *Handler) DeleteMod(c *gin.Context) {
var input deleteModInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.RemoveMod(input.Path); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusNoContent, nil)
}
func (h *Handler) CleanupMods(c *gin.Context) {
workshopMods := services.ListWorkshopMods(h.serverfileDir)
localMods := services.ListLocalMods(h.modsDir)
usedWorkshop, usedLocal := services.BuildUsageMap(h.modlists)
var deleted int
for _, m := range workshopMods {
if _, ok := usedWorkshop[m.ID]; !ok {
if err := services.RemoveMod(m.Path); err == nil {
deleted++
}
}
}
for _, m := range localMods {
if _, ok := usedLocal[m.ID]; !ok {
if _, ok2 := usedLocal[m.Name]; !ok2 {
if err := services.RemoveMod(m.Path); err == nil {
deleted++
}
}
}
}
c.JSON(http.StatusOK, gin.H{"deleted": deleted})
}

View File

@@ -12,7 +12,9 @@ type Handler struct {
modlists *services.ModlistManager modlists *services.ModlistManager
process *services.ProcessManager process *services.ProcessManager
steamcmd *services.SteamCmdManager steamcmd *services.SteamCmdManager
scheduler *services.Scheduler
streamer *services.LogStreamer streamer *services.LogStreamer
dataDir string
serverfileDir string serverfileDir string
modsDir string modsDir string
cfgDir string cfgDir string
@@ -25,8 +27,9 @@ func New(
modlists *services.ModlistManager, modlists *services.ModlistManager,
process *services.ProcessManager, process *services.ProcessManager,
steamcmd *services.SteamCmdManager, steamcmd *services.SteamCmdManager,
scheduler *services.Scheduler,
streamer *services.LogStreamer, streamer *services.LogStreamer,
serverfileDir, modsDir, cfgDir, profilesDir string, dataDir, serverfileDir, modsDir, cfgDir, profilesDir string,
) *Handler { ) *Handler {
return &Handler{ return &Handler{
settings: settings, settings: settings,
@@ -34,7 +37,9 @@ func New(
modlists: modlists, modlists: modlists,
process: process, process: process,
steamcmd: steamcmd, steamcmd: steamcmd,
scheduler: scheduler,
streamer: streamer, streamer: streamer,
dataDir: dataDir,
serverfileDir: serverfileDir, serverfileDir: serverfileDir,
modsDir: modsDir, modsDir: modsDir,
cfgDir: cfgDir, cfgDir: cfgDir,
@@ -56,6 +61,7 @@ func (h *Handler) SetupRoutes(r *gin.Engine) {
api.POST("/server/steamcmd/download-mod", h.SteamCMDDownloadMod) api.POST("/server/steamcmd/download-mod", h.SteamCMDDownloadMod)
api.GET("/server/steamcmd/status", h.SteamCMDStatus) api.GET("/server/steamcmd/status", h.SteamCMDStatus)
api.GET("/server/paths", h.ServerPaths) api.GET("/server/paths", h.ServerPaths)
api.GET("/server/health", h.Health)
api.GET("/server/logs", h.ListLogs) api.GET("/server/logs", h.ListLogs)
api.GET("/server/logs/:file", h.GetLog) api.GET("/server/logs/:file", h.GetLog)
@@ -77,6 +83,11 @@ func (h *Handler) SetupRoutes(r *gin.Engine) {
api.GET("/modlists/:id/check", h.CheckModlistMods) api.GET("/modlists/:id/check", h.CheckModlistMods)
api.POST("/modlists/:id/download-missing", h.DownloadMissingMods) api.POST("/modlists/:id/download-missing", h.DownloadMissingMods)
api.POST("/modlists/:id/update-all", h.UpdateAllMods) api.POST("/modlists/:id/update-all", h.UpdateAllMods)
api.GET("/modlists/:id/export", h.ExportModlist)
api.GET("/mods", h.ListMods)
api.DELETE("/mods", h.DeleteMod)
api.POST("/mods/cleanup", h.CleanupMods)
} }
r.GET("/ws/server/logs", h.StreamLogs) r.GET("/ws/server/logs", h.StreamLogs)

View File

@@ -17,6 +17,10 @@ type updateSettingsInput struct {
DifficultyPresets *string `json:"difficulty_presets"` DifficultyPresets *string `json:"difficulty_presets"`
ActiveConfig *string `json:"active_config"` ActiveConfig *string `json:"active_config"`
ActiveModlist *string `json:"active_modlist"` ActiveModlist *string `json:"active_modlist"`
AutoUpdateOnStartup *bool `json:"auto_update_on_startup"`
AutoStartOnStartup *bool `json:"auto_start_on_startup"`
AutoUpdateModsOnStartup *bool `json:"auto_update_mods_on_startup"`
ScheduledUpdate *string `json:"scheduled_update"`
} }
func (h *Handler) GetSettings(c *gin.Context) { func (h *Handler) GetSettings(c *gin.Context) {
@@ -51,6 +55,10 @@ func (h *Handler) UpdateSettings(c *gin.Context) {
if input.DifficultyPresets != nil { s.DifficultyPresets = *input.DifficultyPresets } if input.DifficultyPresets != nil { s.DifficultyPresets = *input.DifficultyPresets }
if input.ActiveConfig != nil { s.ActiveConfig = *input.ActiveConfig } if input.ActiveConfig != nil { s.ActiveConfig = *input.ActiveConfig }
if input.ActiveModlist != nil { s.ActiveModlist = *input.ActiveModlist } if input.ActiveModlist != nil { s.ActiveModlist = *input.ActiveModlist }
if input.AutoUpdateOnStartup != nil { s.AutoUpdateOnStartup = *input.AutoUpdateOnStartup }
if input.AutoStartOnStartup != nil { s.AutoStartOnStartup = *input.AutoStartOnStartup }
if input.AutoUpdateModsOnStartup != nil { s.AutoUpdateModsOnStartup = *input.AutoUpdateModsOnStartup }
if input.ScheduledUpdate != nil { s.ScheduledUpdate = *input.ScheduledUpdate }
if err := h.process.WriteUserconfigFiles(s); err != nil { if err := h.process.WriteUserconfigFiles(s); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "write userconfig: " + err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "write userconfig: " + err.Error()})
@@ -61,6 +69,11 @@ func (h *Handler) UpdateSettings(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
if h.scheduler != nil {
h.scheduler.Refresh()
}
c.JSON(http.StatusOK, s) c.JSON(http.StatusOK, s)
} }

View File

@@ -13,5 +13,10 @@ type ServerSettings struct {
DifficultyPresets string `json:"difficulty_presets"` DifficultyPresets string `json:"difficulty_presets"`
ActiveConfig string `json:"active_config"` ActiveConfig string `json:"active_config"`
ActiveModlist string `json:"active_modlist"` ActiveModlist string `json:"active_modlist"`
AutoUpdateOnStartup bool `json:"auto_update_on_startup"`
AutoStartOnStartup bool `json:"auto_start_on_startup"`
AutoUpdateModsOnStartup bool `json:"auto_update_mods_on_startup"`
WasRunning bool `json:"was_running"`
ScheduledUpdate string `json:"scheduled_update"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }

View File

@@ -0,0 +1,120 @@
package services
import (
"os"
"path/filepath"
)
type ModInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Source string `json:"source"`
Path string `json:"path"`
Size int64 `json:"size"`
InUse bool `json:"in_use"`
Modlists []string `json:"modlists"`
}
func ListWorkshopMods(serverfileDir string) []ModInfo {
workshopDir := filepath.Join(serverfileDir, "steamapps", "workshop", "content", workshopAppID)
entries, err := os.ReadDir(workshopDir)
if err != nil {
return nil
}
var mods []ModInfo
for _, e := range entries {
if !e.IsDir() {
continue
}
id := e.Name()
p := filepath.Join(workshopDir, id)
size := dirSize(p)
mods = append(mods, ModInfo{
ID: id,
Name: "",
Source: "workshop",
Path: p,
Size: size,
})
}
return mods
}
func ListLocalMods(modsDir string) []ModInfo {
entries, err := os.ReadDir(modsDir)
if err != nil {
return nil
}
var mods []ModInfo
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
p := filepath.Join(modsDir, name)
size := dirSize(p)
displayName := name
if len(name) > 0 && name[0] == '@' {
displayName = name[1:]
}
mods = append(mods, ModInfo{
ID: displayName,
Name: name,
Source: "local",
Path: p,
Size: size,
})
}
return mods
}
func BuildUsageMap(modlists *ModlistManager) (usedWorkshop map[string][]string, usedLocal map[string][]string) {
usedWorkshop = make(map[string][]string)
usedLocal = make(map[string][]string)
lists, err := modlists.List()
if err != nil {
return
}
for _, li := range lists {
ml, err := modlists.Get(li.ID)
if err != nil {
continue
}
for _, m := range ml.Mods {
if m.ID != "" {
usedWorkshop[m.ID] = append(usedWorkshop[m.ID], ml.Name)
}
if m.Name != "" {
nameWithAt := "@" + m.Name
usedLocal[m.Name] = append(usedLocal[m.Name], ml.Name)
usedLocal[nameWithAt] = append(usedLocal[nameWithAt], ml.Name)
}
}
}
return
}
func RemoveMod(path string) error {
return os.RemoveAll(path)
}
func dirSize(path string) int64 {
var size int64
filepath.WalkDir(path, func(_ string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if !d.IsDir() {
fi, err := d.Info()
if err == nil {
size += fi.Size()
}
}
return nil
})
return size
}

View File

@@ -1,16 +1,122 @@
package services package services
import ( import (
"bytes"
"fmt" "fmt"
stdhtml "html"
"io" "io"
"net/url" "net/url"
"path" "path"
"strings" "strings"
"text/template"
"arma3-web-server/internal/models" "arma3-web-server/internal/models"
"golang.org/x/net/html" "golang.org/x/net/html"
) )
const modlistHTMLTmpl = `<?xml version="1.0" encoding="utf-8"?>
<html>
<!--Created by Arma 3 Web Server-->
<head>
<meta name="arma:Type" content="list" />
<meta name="generator" content="Arma 3 Web Server" />
<title>Arma 3</title>
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet" type="text/css" />
<style>
body {
margin: 0;
padding: 0;
color: #fff;
background: #000;
}
body, th, td {
font: 95%/1.3 Roboto, Segoe UI, Tahoma, Arial, Helvetica, sans-serif;
}
td {
padding: 3px 30px 3px 0;
}
h1 {
padding: 20px 20px 0 20px;
color: white;
font-weight: 200;
font-family: segoe ui;
font-size: 3em;
margin: 0;
}
em {
font-variant: italic;
color:silver;
}
.before-list {
padding: 5px 20px 10px 20px;
}
.mod-list {
background: #222222;
padding: 20px;
}
.dlc-list {
background: #222222;
padding: 20px;
}
.footer {
padding: 20px;
color:gray;
}
.whups {
color:gray;
}
a {
color: #D18F21;
text-decoration: underline;
}
a:hover {
color:#F1AF41;
text-decoration: none;
}
.from-steam {
color: #449EBD;
}
.from-local {
color: gray;
}
</style>
</head>
<body>
<h1>Arma 3 Mods</h1>
<p class="before-list">
<em>To import this preset, drag this file onto the Launcher window. Or click the MODS tab, then PRESET in the top right, then IMPORT at the bottom, and finally select this file.</em>
</p>
<div class="mod-list">
<table>{{range .Mods}}
<tr data-type="ModContainer">
<td data-type="DisplayName">{{.Name | safeHTML}}</td>
<td>
<span class="from-steam">Steam</span>
</td>
<td>
<a href="https://steamcommunity.com/sharedfiles/filedetails/?id={{.ID}}" data-type="Link">https://steamcommunity.com/sharedfiles/filedetails/?id={{.ID}}</a>
</td>
</tr>{{end}}
</table>
</div>
<div class="footer">
<span>Created by Arma 3 Web Server.</span>
</div>
</body>
</html>`
func ParseModlistHTML(r io.Reader) (string, []models.ModEntry, error) { func ParseModlistHTML(r io.Reader) (string, []models.ModEntry, error) {
doc, err := html.Parse(r) doc, err := html.Parse(r)
if err != nil { if err != nil {
@@ -114,3 +220,38 @@ func FileNameToModlistName(filename string) string {
name = strings.ReplaceAll(name, "-", " ") name = strings.ReplaceAll(name, "-", " ")
return name return name
} }
type renderMod struct {
Name string
ID string
}
func safeHTML(s string) string {
return stdhtml.EscapeString(s)
}
func RenderModlistHTML(name string, mods []models.ModEntry) (string, error) {
tmpl, err := template.New("modlist").Funcs(template.FuncMap{
"safeHTML": safeHTML,
}).Parse(modlistHTMLTmpl)
if err != nil {
return "", fmt.Errorf("parse template: %w", err)
}
var renderMods []renderMod
for _, m := range mods {
if m.ID == "" {
continue
}
renderMods = append(renderMods, renderMod{
Name: m.Name,
ID: m.ID,
})
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, struct{ Mods []renderMod }{renderMods}); err != nil {
return "", fmt.Errorf("execute template: %w", err)
}
return buf.String(), nil
}

View File

@@ -0,0 +1,92 @@
package services
import (
"log"
"sync"
"github.com/robfig/cron/v3"
)
type Scheduler struct {
cron *cron.Cron
entryID cron.EntryID
settings *SettingsManager
modlists *ModlistManager
steamcmd *SteamCmdManager
mu sync.Mutex
}
func NewScheduler(settings *SettingsManager, modlists *ModlistManager, steamcmd *SteamCmdManager) *Scheduler {
return &Scheduler{
cron: cron.New(),
settings: settings,
modlists: modlists,
steamcmd: steamcmd,
}
}
func (s *Scheduler) Start() {
s.cron.Start()
s.Refresh()
}
func (s *Scheduler) Stop() {
s.cron.Stop()
}
func (s *Scheduler) Refresh() {
s.mu.Lock()
defer s.mu.Unlock()
if s.entryID != 0 {
s.cron.Remove(s.entryID)
s.entryID = 0
}
sett, err := s.settings.Load()
if err != nil || sett.ScheduledUpdate == "" {
return
}
id, err := s.cron.AddFunc(sett.ScheduledUpdate, s.runScheduledUpdate)
if err != nil {
log.Printf("scheduler: invalid cron expression %q: %v", sett.ScheduledUpdate, err)
return
}
s.entryID = id
log.Printf("scheduler: scheduled update with cron %q", sett.ScheduledUpdate)
}
func (s *Scheduler) runScheduledUpdate() {
sett, err := s.settings.Load()
if err != nil {
return
}
if sett.SteamUser != "" && sett.SteamUser != "anonymous" {
log.Print("scheduler: running gameserver update")
if err := s.steamcmd.UpdateGame(sett.SteamBranch, sett.SteamUser); err != nil {
log.Printf("scheduler: game update failed: %v", err)
}
}
if sett.ActiveModlist != "" {
ml, err := s.modlists.Get(sett.ActiveModlist)
if err != nil {
log.Printf("scheduler: load modlist %s: %v", sett.ActiveModlist, err)
return
}
var modIDs []string
for _, m := range ml.Mods {
if m.Enabled {
modIDs = append(modIDs, m.ID)
}
}
if len(modIDs) > 0 {
log.Printf("scheduler: updating %d mod(s)", len(modIDs))
if err := s.steamcmd.DownloadMods(modIDs); err != nil {
log.Printf("scheduler: mod update failed: %v", err)
}
}
}
}

View File

@@ -72,6 +72,11 @@ func (pm *ProcessManager) Start() error {
return fmt.Errorf("load settings: %w", err) return fmt.Errorf("load settings: %w", err)
} }
s.WasRunning = true
if err := pm.settings.Save(s); err != nil {
return fmt.Errorf("save was_running: %w", err)
}
binName := "arma3server_x64" binName := "arma3server_x64"
if s.Platform == "windows" { if s.Platform == "windows" {
binName = "arma3server_x64.exe" binName = "arma3server_x64.exe"
@@ -141,6 +146,13 @@ func (pm *ProcessManager) Stop() error {
rp.cancel() rp.cancel()
<-rp.exited <-rp.exited
s, err := pm.settings.Load()
if err == nil {
s.WasRunning = false
pm.settings.Save(s)
}
return nil return nil
} }

View File

@@ -61,6 +61,11 @@ func (sm *SettingsManager) defaults() *models.ServerSettings {
Platform: "linux", Platform: "linux",
ActiveConfig: "", ActiveConfig: "",
ActiveModlist: "", ActiveModlist: "",
AutoUpdateOnStartup: false,
AutoStartOnStartup: false,
AutoUpdateModsOnStartup: false,
WasRunning: false,
ScheduledUpdate: "",
UpdatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
} }
} }

View File

@@ -6,8 +6,7 @@ services:
- "8080:8080" - "8080:8080"
volumes: volumes:
- ./data:/data - ./data:/data
# /servers should contain the Arma 3 server install (binary + steamapps/workshop for mods) - ${SERVERS_DIR:-./serverfiles}:/servers
- /path/to/your/servers:/servers
environment: environment:
- DATA_DIR=/data - DATA_DIR=/data
- SERVERFILE_DIR=/servers - SERVERFILE_DIR=/servers

View File

@@ -7,6 +7,8 @@ import ConfigEditor from './pages/ConfigEditor'
import Modlists from './pages/Modlists' import Modlists from './pages/Modlists'
import ModlistEditor from './pages/ModlistEditor' import ModlistEditor from './pages/ModlistEditor'
import Logs from './pages/Logs' import Logs from './pages/Logs'
import Mods from './pages/Mods'
import Status from './pages/Status'
function App() { function App() {
return ( return (
@@ -18,7 +20,9 @@ function App() {
<Route path="/configs/:name" element={<ConfigEditor />} /> <Route path="/configs/:name" element={<ConfigEditor />} />
<Route path="/modlists" element={<Modlists />} /> <Route path="/modlists" element={<Modlists />} />
<Route path="/modlists/:id" element={<ModlistEditor />} /> <Route path="/modlists/:id" element={<ModlistEditor />} />
<Route path="/mods" element={<Mods />} />
<Route path="/logs" element={<Logs />} /> <Route path="/logs" element={<Logs />} />
<Route path="/status" element={<Status />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</Layout> </Layout>

View File

@@ -1,4 +1,4 @@
import type { ServerSettings, ServerPaths, ConfigInfo, Modlist, ModlistListItem, ModEntry } from '../types' import type { ServerSettings, ServerPaths, ServerHealth, ConfigInfo, Modlist, ModlistListItem, ModEntry, ModInfo } from '../types'
const BASE = '/api' const BASE = '/api'
@@ -24,6 +24,7 @@ export const settingsApi = {
status: () => request<{ running: boolean }>('/server/status'), status: () => request<{ running: boolean }>('/server/status'),
steamcmd: () => request<{ branch: string; user: string; platform: string }>('/server/steamcmd'), steamcmd: () => request<{ branch: string; user: string; platform: string }>('/server/steamcmd'),
paths: () => request<ServerPaths>('/server/paths'), paths: () => request<ServerPaths>('/server/paths'),
health: () => request<ServerHealth>('/server/health'),
} }
export const configsApi = { export const configsApi = {
@@ -64,6 +65,7 @@ export const modlistsApi = {
return r.json() as Promise<Modlist> return r.json() as Promise<Modlist>
}) })
}, },
exportUrl: (id: string) => `${BASE}/modlists/${id}/export`,
checkMods: (id: string) => request<ModStatus[]>(`/modlists/${id}/check`), checkMods: (id: string) => request<ModStatus[]>(`/modlists/${id}/check`),
downloadMissing: (id: string) => request<{ status: string; count?: number }>(`/modlists/${id}/download-missing`, { method: 'POST' }), 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' }), updateAll: (id: string) => request<{ status: string; count?: number }>(`/modlists/${id}/update-all`, { method: 'POST' }),
@@ -82,6 +84,12 @@ export const steamcmdApi = {
status: () => request<{ running: boolean }>('/server/steamcmd/status'), status: () => request<{ running: boolean }>('/server/steamcmd/status'),
} }
export const modsApi = {
list: () => request<ModInfo[]>('/mods'),
remove: (path: string) => request<void>('/mods', { method: 'DELETE', body: JSON.stringify({ path }) }),
cleanup: () => request<{ deleted: number }>('/mods/cleanup', { method: 'POST' }),
}
export const logsApi = { export const logsApi = {
list: () => request<string[]>('/server/logs'), list: () => request<string[]>('/server/logs'),
get: (file: string) => fetch(`${BASE}/server/logs/${file}`).then(r => r.text()), get: (file: string) => fetch(`${BASE}/server/logs/${file}`).then(r => r.text()),

View File

@@ -6,7 +6,9 @@ const nav = [
{ to: '/settings', label: 'Settings', icon: '🖥' }, { to: '/settings', label: 'Settings', icon: '🖥' },
{ to: '/configs', label: 'Configs', icon: '⚙' }, { to: '/configs', label: 'Configs', icon: '⚙' },
{ to: '/modlists', label: 'Modlists', icon: '📦' }, { to: '/modlists', label: 'Modlists', icon: '📦' },
{ to: '/mods', label: 'Mods', icon: '🗂' },
{ to: '/logs', label: 'Logs', icon: '📋' }, { to: '/logs', label: 'Logs', icon: '📋' },
{ to: '/status', label: 'Status', icon: '📊' },
] ]
export default function Layout({ children }: { children: ReactNode }) { export default function Layout({ children }: { children: ReactNode }) {

View File

@@ -112,6 +112,13 @@ export default function ModlistEditor() {
</button> </button>
</> </>
)} )}
<a
href={modlistsApi.exportUrl(id!)}
download
className="px-3 py-1.5 bg-neutral-700 hover:bg-neutral-600 rounded text-sm"
>
Export
</a>
<button onClick={() => saveMut.mutate()} disabled={!dirty || saveMut.isPending} className="px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded text-sm"> <button onClick={() => saveMut.mutate()} disabled={!dirty || saveMut.isPending} className="px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded text-sm">
{saveMut.isPending ? 'Saving...' : 'Save'} {saveMut.isPending ? 'Saving...' : 'Save'}
</button> </button>

131
frontend/src/pages/Mods.tsx Normal file
View File

@@ -0,0 +1,131 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { modsApi } from '../api/client'
import { useState } from 'react'
function fmtSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
const v = bytes / Math.pow(1024, i)
return `${v.toFixed(i > 0 ? 1 : 0)} ${units[i]}`
}
export default function Mods() {
const qc = useQueryClient()
const [search, setSearch] = useState('')
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
const { data: mods, isLoading } = useQuery({ queryKey: ['mods'], queryFn: modsApi.list })
const deleteMut = useMutation({
mutationFn: (path: string) => modsApi.remove(path),
onSuccess: () => qc.invalidateQueries({ queryKey: ['mods'] }),
})
const cleanupMut = useMutation({
mutationFn: () => modsApi.cleanup(),
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: ['mods'] })
alert(`Deleted ${data.deleted} orphaned mod(s)`)
},
})
const orphaned = mods?.filter(m => !m.in_use) ?? []
const filtered = mods?.filter(m => {
if (!search) return true
const q = search.toLowerCase()
return m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q) || m.source.toLowerCase().includes(q)
})
const handleDelete = (path: string) => {
if (window.confirm('Delete this mod from disk? This cannot be undone.')) {
deleteMut.mutate(path)
}
setConfirmDelete(null)
}
const handleCleanup = () => {
const count = orphaned.length
if (count === 0) return
if (window.confirm(`Delete all ${count} orphaned mod(s) from disk? This cannot be undone.`)) {
cleanupMut.mutate()
}
}
if (isLoading) return <p className="text-neutral-500">Loading...</p>
return (
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-semibold">Installed Mods</h2>
<button
onClick={handleCleanup}
disabled={orphaned.length === 0 || cleanupMut.isPending}
className="px-4 py-2 bg-red-700 hover:bg-red-600 disabled:opacity-50 rounded text-sm"
>
{cleanupMut.isPending ? 'Deleting...' : `Delete All Orphaned (${orphaned.length})`}
</button>
</div>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Filter by name or ID..."
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm mb-4"
/>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-neutral-500 border-b border-neutral-800 text-left">
<th className="pb-2 pr-4">Name</th>
<th className="pb-2 pr-4">Source</th>
<th className="pb-2 pr-4 text-right">Size</th>
<th className="pb-2 pr-4">Status</th>
<th className="pb-2 pr-4">Modlists</th>
<th className="pb-2"></th>
</tr>
</thead>
<tbody>
{filtered?.map((m, i) => (
<tr key={i} className="border-b border-neutral-800/50 hover:bg-neutral-900/50">
<td className="py-2 pr-4 font-mono text-sm">
{m.name || m.id}
{!m.name && m.source === 'workshop' && (
<span className="text-neutral-600 ml-1">({m.id})</span>
)}
</td>
<td className="py-2 pr-4">
<span className={`text-xs px-2 py-0.5 rounded ${m.source === 'workshop' ? 'bg-blue-900 text-blue-300' : 'bg-green-900 text-green-300'}`}>
{m.source}
</span>
</td>
<td className="py-2 pr-4 text-right text-neutral-400">{fmtSize(m.size)}</td>
<td className="py-2 pr-4">
<span className={`text-xs px-2 py-0.5 rounded ${m.in_use ? 'bg-green-900 text-green-300' : 'bg-red-900 text-red-300'}`}>
{m.in_use ? 'Used' : 'Orphaned'}
</span>
</td>
<td className="py-2 pr-4 text-neutral-400 text-xs max-w-xs truncate">
{m.modlists.length > 0 ? m.modlists.join(', ') : '-'}
</td>
<td className="py-2">
<button
onClick={() => handleDelete(m.path)}
disabled={deleteMut.isPending && confirmDelete === m.path}
className="text-xs text-red-400 hover:text-red-300 disabled:opacity-50"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
{filtered?.length === 0 && (
<p className="text-neutral-600 text-sm mt-4">No mods found.</p>
)}
</div>
</div>
)
}

View File

@@ -26,6 +26,10 @@ export default function Settings() {
const [difficulty, setDifficulty] = useState('') const [difficulty, setDifficulty] = useState('')
const [activeConfig, setActiveConfig] = useState('') const [activeConfig, setActiveConfig] = useState('')
const [activeModlist, setActiveModlist] = useState('') const [activeModlist, setActiveModlist] = useState('')
const [autoUpdateOnStartup, setAutoUpdateOnStartup] = useState(false)
const [autoStartOnStartup, setAutoStartOnStartup] = useState(false)
const [autoUpdateModsOnStartup, setAutoUpdateModsOnStartup] = useState(false)
const [scheduledUpdate, setScheduledUpdate] = useState('')
useEffect(() => { useEffect(() => {
if (settings) { if (settings) {
@@ -39,6 +43,10 @@ export default function Settings() {
setDifficulty(settings.difficulty_presets) setDifficulty(settings.difficulty_presets)
setActiveConfig(settings.active_config) setActiveConfig(settings.active_config)
setActiveModlist(settings.active_modlist) setActiveModlist(settings.active_modlist)
setAutoUpdateOnStartup(settings.auto_update_on_startup)
setAutoStartOnStartup(settings.auto_start_on_startup)
setAutoUpdateModsOnStartup(settings.auto_update_mods_on_startup)
setScheduledUpdate(settings.scheduled_update)
} }
}, [settings]) }, [settings])
@@ -54,6 +62,10 @@ export default function Settings() {
difficulty_presets: difficulty, difficulty_presets: difficulty,
active_config: activeConfig, active_config: activeConfig,
active_modlist: activeModlist, active_modlist: activeModlist,
auto_update_on_startup: autoUpdateOnStartup,
auto_start_on_startup: autoStartOnStartup,
auto_update_mods_on_startup: autoUpdateModsOnStartup,
scheduled_update: scheduledUpdate,
}), }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['settings'] }); setDirty(false) }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['settings'] }); setDirty(false) },
}) })
@@ -220,6 +232,45 @@ export default function Settings() {
</span> </span>
</div> </div>
<hr className="border-neutral-800 my-6" />
<div>
<h3 className="text-lg font-medium mb-3">Automation</h3>
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={autoUpdateOnStartup} onChange={e => { setAutoUpdateOnStartup(e.target.checked); setDirty(true) }} className="accent-amber-600 w-4 h-4" />
<div>
<div className="text-sm">Auto-update server on startup</div>
<div className="text-xs text-neutral-500">Run gameserver update when the web service starts</div>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={autoUpdateModsOnStartup} onChange={e => { setAutoUpdateModsOnStartup(e.target.checked); setDirty(true) }} className="accent-amber-600 w-4 h-4" />
<div>
<div className="text-sm">Auto-update mods on startup</div>
<div className="text-xs text-neutral-500">Download workshop updates for the active modlist when the web service starts</div>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={autoStartOnStartup} onChange={e => { setAutoStartOnStartup(e.target.checked); setDirty(true) }} className="accent-amber-600 w-4 h-4" />
<div>
<div className="text-sm">Auto-start server on startup</div>
<div className="text-xs text-neutral-500">Restart the server if it was running before the web service stopped</div>
</div>
</label>
<div>
<label className="text-xs text-neutral-500 block mb-1">Scheduled update (cron)</label>
<input
value={scheduledUpdate}
onChange={e => { setScheduledUpdate(e.target.value); setDirty(true) }}
placeholder='e.g. "0 4 * * *" for daily at 4 AM'
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm font-mono"
/>
<div className="text-xs text-neutral-500 mt-1">Empty = disabled. Runs game + mod updates at the specified schedule.</div>
</div>
</div>
</div>
{showSteamCMD && ( {showSteamCMD && (
<div className="mt-4"> <div className="mt-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">

View File

@@ -0,0 +1,175 @@
import { useQuery } from '@tanstack/react-query'
import { useCallback, useState } from 'react'
import { settingsApi } from '../api/client'
function fmtSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + units[i]
}
function fmtPercent(p: number): string {
return p.toFixed(1) + '%'
}
function CopyPath({ path }: { path: string }) {
const [copied, setCopied] = useState(false)
const copy = useCallback(async () => {
try {
await navigator.clipboard.writeText(path)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {}
}, [path])
return (
<span
onClick={copy}
title={path}
className="cursor-pointer flex items-center max-w-full font-mono text-xs hover:text-blue-400 transition-colors"
>
<span
className="overflow-hidden text-ellipsis whitespace-nowrap"
style={{ direction: 'rtl', textAlign: 'left' }}
>
<span style={{ direction: 'ltr', unicodeBidi: 'embed' }}>
{path}
</span>
</span>
{copied && <span className="ml-0.5 text-green-400 shrink-0"></span>}
</span>
)
}
export default function Status() {
const { data, isLoading, error } = useQuery({
queryKey: ['server-health'],
queryFn: settingsApi.health,
refetchInterval: 10000,
})
if (isLoading) return <p className="text-neutral-400">Loading status...</p>
if (error) return <p className="text-red-400">Failed to load status: {(error as Error).message}</p>
if (!data) return null
return (
<div>
<h2 className="text-2xl font-semibold mb-6">Deploy Status</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Section title="Server">
<StatusRow label="Running" value={data.server.running ? 'Yes' : 'No'} ok={data.server.running} />
<StatusRow label="Binary installed" value={data.server.installed ? 'Yes' : 'No'} ok={data.server.installed} />
{data.server.binary_path && (
<StatusRow label="Binary path" value={<CopyPath path={data.server.binary_path} />} />
)}
</Section>
<Section title="SteamCMD">
<StatusRow label="Running" value={data.steamcmd.running ? 'Yes' : 'No'} ok={!data.steamcmd.running} warn={data.steamcmd.running} />
</Section>
<Section title="Mods">
<StatusRow label="Workshop mods" value={String(data.mods.workshop_count)} />
<StatusRow label="Local mods" value={String(data.mods.local_count)} />
<StatusRow label="Total" value={String(data.mods.workshop_count + data.mods.local_count)} />
</Section>
<Section title="Frontend">
<StatusRow label="Served" value={data.frontend.served ? 'Yes' : 'No'} ok={data.frontend.served} />
</Section>
</div>
<div className="mt-6">
<Section title="Directories">
<table className="w-full text-sm table-fixed">
<thead>
<tr className="text-neutral-500 border-b border-neutral-800">
<th className="text-left py-2 pr-4 w-[55%]">Path</th>
<th className="text-center py-2 px-4 w-[22%]">Exists</th>
<th className="text-center py-2 px-4 w-[23%]">Writable</th>
</tr>
</thead>
<tbody>
{data.paths.map((p, i) => (
<tr key={i} className="border-b border-neutral-800/50">
<td className="py-2 pr-4 overflow-hidden"><CopyPath path={p.path} /></td>
<td className="text-center py-2 px-4">
<Badge ok={p.exists} label={p.exists ? 'Yes' : 'No'} />
</td>
<td className="text-center py-2 px-4">
<Badge ok={p.writable} label={p.writable ? 'Yes' : 'No'} />
</td>
</tr>
))}
</tbody>
</table>
</Section>
</div>
<div className="mt-6">
<Section title="Disk Usage">
<table className="w-full text-sm table-fixed">
<thead>
<tr className="text-neutral-500 border-b border-neutral-800">
<th className="text-left py-2 pr-4 w-[40%]">Path</th>
<th className="text-right py-2 px-4 w-[20%]">Total</th>
<th className="text-right py-2 px-4 w-[20%]">Free</th>
<th className="text-right py-2 px-4 w-[20%]">Used</th>
</tr>
</thead>
<tbody>
{data.disk.map((d, i) => (
<tr key={i} className="border-b border-neutral-800/50">
<td className="py-2 pr-4 overflow-hidden"><CopyPath path={d.path} /></td>
<td className="text-right py-2 px-4">{fmtSize(d.total_bytes)}</td>
<td className="text-right py-2 px-4">{fmtSize(d.free_bytes)}</td>
<td className="text-right py-2 px-4">
<span className={d.used_percent > 90 ? 'text-red-400' : d.used_percent > 75 ? 'text-yellow-400' : ''}>
{fmtPercent(d.used_percent)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</Section>
</div>
</div>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
<h3 className="font-medium mb-3 text-neutral-300">{title}</h3>
<div>{children}</div>
</div>
)
}
function StatusRow({ label, value, ok, warn }: { label: string; value: React.ReactNode; ok?: boolean; warn?: boolean }) {
return (
<div className="flex justify-between items-center py-1.5 text-sm">
<span className="text-neutral-500 shrink-0">{label}</span>
<span className="flex items-center gap-2 min-w-0 max-w-[65%]">
{ok !== undefined && <Dot color={ok ? 'bg-green-400' : 'bg-red-400'} />}
{warn && <Dot color="bg-yellow-400" />}
<span className={`min-w-0 overflow-hidden ${ok === false ? 'text-red-400' : warn ? 'text-yellow-400' : ''}`}>{value}</span>
</span>
</div>
)
}
function Badge({ ok, label }: { ok: boolean; label: string }) {
return (
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${ok ? 'bg-green-900/50 text-green-400' : 'bg-red-900/50 text-red-400'}`}>
{label}
</span>
)
}
function Dot({ color }: { color: string }) {
return <span className={`inline-block w-2 h-2 rounded-full ${color}`} />
}

View File

@@ -9,6 +9,11 @@ export interface ServerSettings {
difficulty_presets: string difficulty_presets: string
active_config: string active_config: string
active_modlist: string active_modlist: string
auto_update_on_startup: boolean
auto_start_on_startup: boolean
auto_update_mods_on_startup: boolean
was_running: boolean
scheduled_update: string
updated_at: string updated_at: string
} }
@@ -45,3 +50,42 @@ export interface ServerPaths {
cfg_dir: string cfg_dir: string
profiles_dir: string profiles_dir: string
} }
export interface ServerHealth {
server: {
running: boolean
installed: boolean
binary_path: string
}
paths: {
path: string
exists: boolean
writable: boolean
}[]
mods: {
workshop_count: number
local_count: number
}
disk: {
path: string
total_bytes: number
free_bytes: number
used_percent: number
}[]
steamcmd: {
running: boolean
}
frontend: {
served: boolean
}
}
export interface ModInfo {
id: string
name: string
source: 'workshop' | 'local'
path: string
size: number
in_use: boolean
modlists: string[]
}