Compare commits

..

7 Commits

Author SHA1 Message Date
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
28 changed files with 1028 additions and 95 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 }}

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

@@ -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

@@ -50,6 +50,7 @@ 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()
@@ -62,9 +63,58 @@ func main() {
log.Printf("serving frontend from %s", frontendDir) log.Printf("serving frontend from %s", frontendDir)
} }
handler := api.New(settings, configMgr, modlistMgr, process, steamcmd, streamer, serverfileDir, modsDir, cfgDir, profilesDir) handler := api.New(settings, configMgr, modlistMgr, process, steamcmd, scheduler, streamer, 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 +130,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()

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

@@ -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,6 +12,7 @@ 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
serverfileDir string serverfileDir string
modsDir string modsDir string
@@ -25,6 +26,7 @@ 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, serverfileDir, modsDir, cfgDir, profilesDir string,
) *Handler { ) *Handler {
@@ -34,6 +36,7 @@ func New(
modlists: modlists, modlists: modlists,
process: process, process: process,
steamcmd: steamcmd, steamcmd: steamcmd,
scheduler: scheduler,
streamer: streamer, streamer: streamer,
serverfileDir: serverfileDir, serverfileDir: serverfileDir,
modsDir: modsDir, modsDir: modsDir,
@@ -77,6 +80,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

@@ -7,16 +7,20 @@ import (
) )
type updateSettingsInput struct { type updateSettingsInput struct {
IPPort *string `json:"ip_port"` IPPort *string `json:"ip_port"`
ServerParameters *string `json:"server_parameters"` ServerParameters *string `json:"server_parameters"`
SteamBranch *string `json:"steam_branch"` SteamBranch *string `json:"steam_branch"`
SteamUser *string `json:"steam_user"` SteamUser *string `json:"steam_user"`
Platform *string `json:"platform"` Platform *string `json:"platform"`
CBASettings *string `json:"cba_settings"` CBASettings *string `json:"cba_settings"`
AILevelPresets *string `json:"ai_level_presets"` AILevelPresets *string `json:"ai_level_presets"`
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

@@ -3,15 +3,20 @@ package models
import "time" import "time"
type ServerSettings struct { type ServerSettings struct {
IPPort string `json:"ip_port"` IPPort string `json:"ip_port"`
ServerParameters string `json:"server_parameters"` ServerParameters string `json:"server_parameters"`
SteamBranch string `json:"steam_branch"` SteamBranch string `json:"steam_branch"`
SteamUser string `json:"steam_user"` SteamUser string `json:"steam_user"`
Platform string `json:"platform"` Platform string `json:"platform"`
CBASettings string `json:"cba_settings"` CBASettings string `json:"cba_settings"`
AILevelPresets string `json:"ai_level_presets"` AILevelPresets string `json:"ai_level_presets"`
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"`
UpdatedAt time.Time `json:"updated_at"` 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"`
} }

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

@@ -54,13 +54,18 @@ func (sm *SettingsManager) Save(s *models.ServerSettings) error {
func (sm *SettingsManager) defaults() *models.ServerSettings { func (sm *SettingsManager) defaults() *models.ServerSettings {
return &models.ServerSettings{ return &models.ServerSettings{
IPPort: "0.0.0.0:2302", IPPort: "0.0.0.0:2302",
ServerParameters: "-server -world=empty -loadMissionToMemory -noPause", ServerParameters: "-server -world=empty -loadMissionToMemory -noPause",
SteamBranch: "stable", SteamBranch: "stable",
SteamUser: "anonymous", SteamUser: "anonymous",
Platform: "linux", Platform: "linux",
ActiveConfig: "", ActiveConfig: "",
ActiveModlist: "", ActiveModlist: "",
UpdatedAt: time.Now().UTC(), AutoUpdateOnStartup: false,
AutoStartOnStartup: false,
AutoUpdateModsOnStartup: false,
WasRunning: false,
ScheduledUpdate: "",
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,7 @@ 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'
function App() { function App() {
return ( return (
@@ -18,6 +19,7 @@ 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="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>

View File

@@ -1,4 +1,4 @@
import type { ServerSettings, ServerPaths, ConfigInfo, Modlist, ModlistListItem, ModEntry } from '../types' import type { ServerSettings, ServerPaths, ConfigInfo, Modlist, ModlistListItem, ModEntry, ModInfo } from '../types'
const BASE = '/api' const BASE = '/api'
@@ -64,6 +64,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 +83,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,6 +6,7 @@ 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: '📋' },
] ]

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

@@ -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,13 @@ export interface ServerPaths {
cfg_dir: string cfg_dir: string
profiles_dir: string profiles_dir: string
} }
export interface ModInfo {
id: string
name: string
source: 'workshop' | 'local'
path: string
size: number
in_use: boolean
modlists: string[]
}