Compare commits
11 Commits
b853aead8c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
914e0fbe48 | ||
|
|
9aa6fca50e | ||
|
|
bc0a252d7d | ||
|
|
2d301b95b6 | ||
|
|
6f782af913 | ||
|
|
6774397885 | ||
|
|
d94593743f | ||
|
|
cc7cb0bc56 | ||
|
|
c06a9a7981 | ||
|
|
26cc812e73 | ||
|
|
149a60c6fb |
19
.dockerignore
Normal file
19
.dockerignore
Normal 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
20
.gitea/workflows/ci.yml
Normal 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
|
||||
30
.gitea/workflows/release.yml
Normal file
30
.gitea/workflows/release.yml
Normal 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
2
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
frontend/dist/
|
||||
backend/embed/dist/
|
||||
|
||||
*.exe
|
||||
*.test
|
||||
@@ -8,6 +9,7 @@ backend/arma3-web-server
|
||||
|
||||
data/
|
||||
serverfiles/
|
||||
dev-deploy/
|
||||
|
||||
.env
|
||||
.DS_Store
|
||||
|
||||
52
.goreleaser.yaml
Normal file
52
.goreleaser.yaml
Normal 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
|
||||
24
Dockerfile
24
Dockerfile
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.25-alpine AS backend
|
||||
FROM golang:1.25-bookworm AS backend
|
||||
WORKDIR /src
|
||||
COPY backend/go.mod backend/go.sum ./
|
||||
RUN go mod download
|
||||
@@ -12,9 +12,19 @@ RUN npm ci
|
||||
COPY frontend/ .
|
||||
RUN npm run build
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
RUN apk add --no-cache steamcmd libstdc++ libgcc || true
|
||||
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 --from=backend /server /usr/local/bin/arma3-web-server
|
||||
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
|
||||
VOLUME ["/data", "/servers"]
|
||||
|
||||
# Expected mount structure under /servers:
|
||||
# /servers/ ← SERVERFILE_DIR (arma3server_x64 binary, userconfig/)
|
||||
# /servers/steamapps/workshop/content/107410/ ← workshop mods (downloaded via SteamCMD)
|
||||
# /servers/mods/ ← MODS_DIR (@modname mod folders)
|
||||
# /servers/cfg/ ← CFG_DIR (.cfg files)
|
||||
# /servers/profiles/ ← PROFILES_DIR (logs, .rpt, server profile)
|
||||
ENV DATA_DIR=/data
|
||||
ENV SERVERFILE_DIR=/servers
|
||||
ENV MODS_DIR=/servers/mods
|
||||
|
||||
31
Dockerfile.goreleaser
Normal file
31
Dockerfile.goreleaser
Normal 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"]
|
||||
24
Makefile
24
Makefile
@@ -1,11 +1,11 @@
|
||||
.PHONY: backend frontend build run clean dev
|
||||
|
||||
# Default paths — override via env or copy .env.example
|
||||
SERVERFILE_DIR ?= ./serverfiles
|
||||
MODS_DIR ?= ./serverfiles/mods
|
||||
CFG_DIR ?= ./serverfiles/cfg
|
||||
PROFILES_DIR ?= ./serverfiles/profiles
|
||||
DATA_DIR ?= ../../data
|
||||
# Default paths for dev — override via env
|
||||
SERVERFILE_DIR ?= ../dev-deploy/serverfiles
|
||||
MODS_DIR ?= ../dev-deploy/serverfiles/mods
|
||||
CFG_DIR ?= ../dev-deploy/serverfiles/cfg
|
||||
PROFILES_DIR ?= ../dev-deploy/serverfiles/profiles
|
||||
DATA_DIR ?= ../dev-deploy/data
|
||||
|
||||
backend:
|
||||
cd backend && go build -o arma3-web-server ./cmd/server/
|
||||
@@ -13,15 +13,23 @@ backend:
|
||||
frontend:
|
||||
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:
|
||||
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:
|
||||
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 frontend && npm run dev
|
||||
|
||||
clean:
|
||||
rm -f backend/arma3-web-server
|
||||
rm -rf frontend/dist
|
||||
rm -rf frontend/dist backend/embed/dist
|
||||
|
||||
55
README.md
55
README.md
@@ -50,6 +50,61 @@ All paths are configurable via environment variables:
|
||||
| `FRONTEND_DIR` | `../frontend/dist` | Built frontend assets |
|
||||
| `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
|
||||
|
||||
REST API at `/api/*` and WebSocket endpoints at `/ws/*`. Key routes:
|
||||
|
||||
@@ -2,14 +2,18 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"log"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"arma3-web-server/embed"
|
||||
"arma3-web-server/internal/api"
|
||||
"arma3-web-server/internal/services"
|
||||
|
||||
@@ -17,13 +21,12 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
dataDir := mustAbs(getEnv("DATA_DIR", filepath.Join("data")))
|
||||
serverfileDir := mustAbs(getEnv("SERVERFILE_DIR", "serverfiles"))
|
||||
modsDir := mustAbs(getEnv("MODS_DIR", "serverfiles/mods"))
|
||||
cfgDir := mustAbs(getEnv("CFG_DIR", "serverfiles/cfg"))
|
||||
profilesDir := mustAbs(getEnv("PROFILES_DIR", "serverfiles/profiles"))
|
||||
serverfileDir := mustAbs(getEnv("SERVERFILE_DIR", "./serverfiles"))
|
||||
dataDir := mustAbs(getEnv("DATA_DIR", "./data"))
|
||||
modsDir := mustAbs(getEnv("MODS_DIR", filepath.Join(serverfileDir, "mods")))
|
||||
cfgDir := mustAbs(getEnv("CFG_DIR", filepath.Join(serverfileDir, "cfg")))
|
||||
profilesDir := mustAbs(getEnv("PROFILES_DIR", filepath.Join(serverfileDir, "profiles")))
|
||||
listen := getEnv("LISTEN", ":8080")
|
||||
frontendDir := mustAbs(getEnv("FRONTEND_DIR", filepath.Join("..", "frontend", "dist")))
|
||||
|
||||
log.Printf("data dir: %s", dataDir)
|
||||
log.Printf("serverfile dir: %s", serverfileDir)
|
||||
@@ -50,21 +53,86 @@ func main() {
|
||||
modlistMgr := services.NewModlistManager(dataDir)
|
||||
process := services.NewProcessManager(serverfileDir, modsDir, cfgDir, profilesDir, settings, modlistMgr, configMgr, streamer)
|
||||
steamcmd := services.NewSteamCmdManager(serverfileDir, streamer)
|
||||
scheduler := services.NewScheduler(settings, modlistMgr, steamcmd)
|
||||
|
||||
r := gin.Default()
|
||||
|
||||
if fi, err := os.Stat(frontendDir); err == nil && fi.IsDir() {
|
||||
r.Static("/assets", filepath.Join(frontendDir, "assets"))
|
||||
r.StaticFile("/favicon.ico", filepath.Join(frontendDir, "favicon.ico"))
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
c.File(filepath.Join(frontendDir, "index.html"))
|
||||
})
|
||||
log.Printf("serving frontend from %s", frontendDir)
|
||||
subFS, subErr := fs.Sub(embed.Frontend, "dist")
|
||||
if subErr == nil {
|
||||
if _, err := subFS.Open("index.html"); err == nil {
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
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.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)
|
||||
|
||||
// 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)
|
||||
|
||||
srv := &http.Server{Addr: listen, Handler: r}
|
||||
@@ -80,6 +148,7 @@ func main() {
|
||||
<-quit
|
||||
|
||||
log.Print("shutting down server...")
|
||||
scheduler.Stop()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
6
backend/embed/embed.go
Normal file
6
backend/embed/embed.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package embed
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed dist
|
||||
var Frontend embed.FS
|
||||
@@ -6,7 +6,8 @@ require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
modernc.org/sqlite v1.53.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
golang.org/x/net v0.51.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -14,7 +15,6 @@ require (
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
@@ -28,21 +28,15 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
modernc.org/libc v1.73.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
@@ -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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
@@ -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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
@@ -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/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -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/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
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/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
177
backend/internal/api/health.go
Normal file
177
backend/internal/api/health.go
Normal 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
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"arma3-web-server/internal/models"
|
||||
"arma3-web-server/internal/services"
|
||||
@@ -121,6 +123,25 @@ func (h *Handler) ImportModlist(c *gin.Context) {
|
||||
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 {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
||||
89
backend/internal/api/mods.go
Normal file
89
backend/internal/api/mods.go
Normal 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})
|
||||
}
|
||||
@@ -12,7 +12,9 @@ type Handler struct {
|
||||
modlists *services.ModlistManager
|
||||
process *services.ProcessManager
|
||||
steamcmd *services.SteamCmdManager
|
||||
scheduler *services.Scheduler
|
||||
streamer *services.LogStreamer
|
||||
dataDir string
|
||||
serverfileDir string
|
||||
modsDir string
|
||||
cfgDir string
|
||||
@@ -25,8 +27,9 @@ func New(
|
||||
modlists *services.ModlistManager,
|
||||
process *services.ProcessManager,
|
||||
steamcmd *services.SteamCmdManager,
|
||||
scheduler *services.Scheduler,
|
||||
streamer *services.LogStreamer,
|
||||
serverfileDir, modsDir, cfgDir, profilesDir string,
|
||||
dataDir, serverfileDir, modsDir, cfgDir, profilesDir string,
|
||||
) *Handler {
|
||||
return &Handler{
|
||||
settings: settings,
|
||||
@@ -34,7 +37,9 @@ func New(
|
||||
modlists: modlists,
|
||||
process: process,
|
||||
steamcmd: steamcmd,
|
||||
scheduler: scheduler,
|
||||
streamer: streamer,
|
||||
dataDir: dataDir,
|
||||
serverfileDir: serverfileDir,
|
||||
modsDir: modsDir,
|
||||
cfgDir: cfgDir,
|
||||
@@ -56,6 +61,7 @@ func (h *Handler) SetupRoutes(r *gin.Engine) {
|
||||
api.POST("/server/steamcmd/download-mod", h.SteamCMDDownloadMod)
|
||||
api.GET("/server/steamcmd/status", h.SteamCMDStatus)
|
||||
api.GET("/server/paths", h.ServerPaths)
|
||||
api.GET("/server/health", h.Health)
|
||||
|
||||
api.GET("/server/logs", h.ListLogs)
|
||||
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.POST("/modlists/:id/download-missing", h.DownloadMissingMods)
|
||||
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)
|
||||
|
||||
@@ -7,16 +7,20 @@ import (
|
||||
)
|
||||
|
||||
type updateSettingsInput struct {
|
||||
IPPort *string `json:"ip_port"`
|
||||
ServerParameters *string `json:"server_parameters"`
|
||||
SteamBranch *string `json:"steam_branch"`
|
||||
SteamUser *string `json:"steam_user"`
|
||||
Platform *string `json:"platform"`
|
||||
CBASettings *string `json:"cba_settings"`
|
||||
AILevelPresets *string `json:"ai_level_presets"`
|
||||
DifficultyPresets *string `json:"difficulty_presets"`
|
||||
ActiveConfig *string `json:"active_config"`
|
||||
ActiveModlist *string `json:"active_modlist"`
|
||||
IPPort *string `json:"ip_port"`
|
||||
ServerParameters *string `json:"server_parameters"`
|
||||
SteamBranch *string `json:"steam_branch"`
|
||||
SteamUser *string `json:"steam_user"`
|
||||
Platform *string `json:"platform"`
|
||||
CBASettings *string `json:"cba_settings"`
|
||||
AILevelPresets *string `json:"ai_level_presets"`
|
||||
DifficultyPresets *string `json:"difficulty_presets"`
|
||||
ActiveConfig *string `json:"active_config"`
|
||||
ActiveModlist *string `json:"active_modlist"`
|
||||
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) {
|
||||
@@ -51,6 +55,10 @@ func (h *Handler) UpdateSettings(c *gin.Context) {
|
||||
if input.DifficultyPresets != nil { s.DifficultyPresets = *input.DifficultyPresets }
|
||||
if input.ActiveConfig != nil { s.ActiveConfig = *input.ActiveConfig }
|
||||
if input.ActiveModlist != nil { s.ActiveModlist = *input.ActiveModlist }
|
||||
if 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 {
|
||||
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()})
|
||||
return
|
||||
}
|
||||
|
||||
if h.scheduler != nil {
|
||||
h.scheduler.Refresh()
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, s)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,15 +3,20 @@ package models
|
||||
import "time"
|
||||
|
||||
type ServerSettings struct {
|
||||
IPPort string `json:"ip_port"`
|
||||
ServerParameters string `json:"server_parameters"`
|
||||
SteamBranch string `json:"steam_branch"`
|
||||
SteamUser string `json:"steam_user"`
|
||||
Platform string `json:"platform"`
|
||||
CBASettings string `json:"cba_settings"`
|
||||
AILevelPresets string `json:"ai_level_presets"`
|
||||
DifficultyPresets string `json:"difficulty_presets"`
|
||||
ActiveConfig string `json:"active_config"`
|
||||
ActiveModlist string `json:"active_modlist"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
IPPort string `json:"ip_port"`
|
||||
ServerParameters string `json:"server_parameters"`
|
||||
SteamBranch string `json:"steam_branch"`
|
||||
SteamUser string `json:"steam_user"`
|
||||
Platform string `json:"platform"`
|
||||
CBASettings string `json:"cba_settings"`
|
||||
AILevelPresets string `json:"ai_level_presets"`
|
||||
DifficultyPresets string `json:"difficulty_presets"`
|
||||
ActiveConfig string `json:"active_config"`
|
||||
ActiveModlist string `json:"active_modlist"`
|
||||
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"`
|
||||
}
|
||||
|
||||
120
backend/internal/services/mod_manager.go
Normal file
120
backend/internal/services/mod_manager.go
Normal 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
|
||||
}
|
||||
@@ -1,16 +1,122 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
stdhtml "html"
|
||||
"io"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"arma3-web-server/internal/models"
|
||||
"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) {
|
||||
doc, err := html.Parse(r)
|
||||
if err != nil {
|
||||
@@ -114,3 +220,38 @@ func FileNameToModlistName(filename string) string {
|
||||
name = strings.ReplaceAll(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
|
||||
}
|
||||
|
||||
92
backend/internal/services/scheduler.go
Normal file
92
backend/internal/services/scheduler.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,11 @@ func (pm *ProcessManager) Start() error {
|
||||
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"
|
||||
if s.Platform == "windows" {
|
||||
binName = "arma3server_x64.exe"
|
||||
@@ -141,6 +146,13 @@ func (pm *ProcessManager) Stop() error {
|
||||
|
||||
rp.cancel()
|
||||
<-rp.exited
|
||||
|
||||
s, err := pm.settings.Load()
|
||||
if err == nil {
|
||||
s.WasRunning = false
|
||||
pm.settings.Save(s)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -54,13 +54,18 @@ func (sm *SettingsManager) Save(s *models.ServerSettings) error {
|
||||
|
||||
func (sm *SettingsManager) defaults() *models.ServerSettings {
|
||||
return &models.ServerSettings{
|
||||
IPPort: "0.0.0.0:2302",
|
||||
ServerParameters: "-server -world=empty -loadMissionToMemory -noPause",
|
||||
SteamBranch: "stable",
|
||||
SteamUser: "anonymous",
|
||||
Platform: "linux",
|
||||
ActiveConfig: "",
|
||||
ActiveModlist: "",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
IPPort: "0.0.0.0:2302",
|
||||
ServerParameters: "-server -world=empty -loadMissionToMemory -noPause",
|
||||
SteamBranch: "stable",
|
||||
SteamUser: "anonymous",
|
||||
Platform: "linux",
|
||||
ActiveConfig: "",
|
||||
ActiveModlist: "",
|
||||
AutoUpdateOnStartup: false,
|
||||
AutoStartOnStartup: false,
|
||||
AutoUpdateModsOnStartup: false,
|
||||
WasRunning: false,
|
||||
ScheduledUpdate: "",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@ services:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
# /servers should contain the Arma 3 server install (binary + steamapps/workshop for mods)
|
||||
- /path/to/your/servers:/servers
|
||||
- ${SERVERS_DIR:-./serverfiles}:/servers
|
||||
environment:
|
||||
- DATA_DIR=/data
|
||||
- SERVERFILE_DIR=/servers
|
||||
|
||||
@@ -7,6 +7,8 @@ import ConfigEditor from './pages/ConfigEditor'
|
||||
import Modlists from './pages/Modlists'
|
||||
import ModlistEditor from './pages/ModlistEditor'
|
||||
import Logs from './pages/Logs'
|
||||
import Mods from './pages/Mods'
|
||||
import Status from './pages/Status'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -18,7 +20,9 @@ function App() {
|
||||
<Route path="/configs/:name" element={<ConfigEditor />} />
|
||||
<Route path="/modlists" element={<Modlists />} />
|
||||
<Route path="/modlists/:id" element={<ModlistEditor />} />
|
||||
<Route path="/mods" element={<Mods />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="/status" element={<Status />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -24,6 +24,7 @@ export const settingsApi = {
|
||||
status: () => request<{ running: boolean }>('/server/status'),
|
||||
steamcmd: () => request<{ branch: string; user: string; platform: string }>('/server/steamcmd'),
|
||||
paths: () => request<ServerPaths>('/server/paths'),
|
||||
health: () => request<ServerHealth>('/server/health'),
|
||||
}
|
||||
|
||||
export const configsApi = {
|
||||
@@ -64,6 +65,7 @@ export const modlistsApi = {
|
||||
return r.json() as Promise<Modlist>
|
||||
})
|
||||
},
|
||||
exportUrl: (id: string) => `${BASE}/modlists/${id}/export`,
|
||||
checkMods: (id: string) => request<ModStatus[]>(`/modlists/${id}/check`),
|
||||
downloadMissing: (id: string) => request<{ status: string; count?: number }>(`/modlists/${id}/download-missing`, { method: 'POST' }),
|
||||
updateAll: (id: string) => request<{ status: string; count?: number }>(`/modlists/${id}/update-all`, { method: 'POST' }),
|
||||
@@ -82,6 +84,12 @@ export const steamcmdApi = {
|
||||
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 = {
|
||||
list: () => request<string[]>('/server/logs'),
|
||||
get: (file: string) => fetch(`${BASE}/server/logs/${file}`).then(r => r.text()),
|
||||
|
||||
@@ -6,7 +6,9 @@ const nav = [
|
||||
{ to: '/settings', label: 'Settings', icon: '🖥' },
|
||||
{ to: '/configs', label: 'Configs', icon: '⚙' },
|
||||
{ to: '/modlists', label: 'Modlists', icon: '📦' },
|
||||
{ to: '/mods', label: 'Mods', icon: '🗂' },
|
||||
{ to: '/logs', label: 'Logs', icon: '📋' },
|
||||
{ to: '/status', label: 'Status', icon: '📊' },
|
||||
]
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -112,6 +112,13 @@ export default function ModlistEditor() {
|
||||
</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">
|
||||
{saveMut.isPending ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
|
||||
131
frontend/src/pages/Mods.tsx
Normal file
131
frontend/src/pages/Mods.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -26,6 +26,10 @@ export default function Settings() {
|
||||
const [difficulty, setDifficulty] = useState('')
|
||||
const [activeConfig, setActiveConfig] = 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(() => {
|
||||
if (settings) {
|
||||
@@ -39,6 +43,10 @@ export default function Settings() {
|
||||
setDifficulty(settings.difficulty_presets)
|
||||
setActiveConfig(settings.active_config)
|
||||
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])
|
||||
|
||||
@@ -54,6 +62,10 @@ export default function Settings() {
|
||||
difficulty_presets: difficulty,
|
||||
active_config: activeConfig,
|
||||
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) },
|
||||
})
|
||||
@@ -220,6 +232,45 @@ export default function Settings() {
|
||||
</span>
|
||||
</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 && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
|
||||
175
frontend/src/pages/Status.tsx
Normal file
175
frontend/src/pages/Status.tsx
Normal 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}`} />
|
||||
}
|
||||
@@ -9,6 +9,11 @@ export interface ServerSettings {
|
||||
difficulty_presets: string
|
||||
active_config: 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
|
||||
}
|
||||
|
||||
@@ -45,3 +50,42 @@ export interface ServerPaths {
|
||||
cfg_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[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user