Compare commits
7 Commits
b853aead8c
...
6f782af913
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 }}
|
||||||
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
|
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
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"]
|
||||||
55
README.md
55
README.md
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
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,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)
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ type updateSettingsInput struct {
|
|||||||
DifficultyPresets *string `json:"difficulty_presets"`
|
DifficultyPresets *string `json:"difficulty_presets"`
|
||||||
ActiveConfig *string `json:"active_config"`
|
ActiveConfig *string `json:"active_config"`
|
||||||
ActiveModlist *string `json:"active_modlist"`
|
ActiveModlist *string `json:"active_modlist"`
|
||||||
|
AutoUpdateOnStartup *bool `json:"auto_update_on_startup"`
|
||||||
|
AutoStartOnStartup *bool `json:"auto_start_on_startup"`
|
||||||
|
AutoUpdateModsOnStartup *bool `json:"auto_update_mods_on_startup"`
|
||||||
|
ScheduledUpdate *string `json:"scheduled_update"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) GetSettings(c *gin.Context) {
|
func (h *Handler) GetSettings(c *gin.Context) {
|
||||||
@@ -51,6 +55,10 @@ func (h *Handler) UpdateSettings(c *gin.Context) {
|
|||||||
if input.DifficultyPresets != nil { s.DifficultyPresets = *input.DifficultyPresets }
|
if input.DifficultyPresets != nil { s.DifficultyPresets = *input.DifficultyPresets }
|
||||||
if input.ActiveConfig != nil { s.ActiveConfig = *input.ActiveConfig }
|
if input.ActiveConfig != nil { s.ActiveConfig = *input.ActiveConfig }
|
||||||
if input.ActiveModlist != nil { s.ActiveModlist = *input.ActiveModlist }
|
if input.ActiveModlist != nil { s.ActiveModlist = *input.ActiveModlist }
|
||||||
|
if input.AutoUpdateOnStartup != nil { s.AutoUpdateOnStartup = *input.AutoUpdateOnStartup }
|
||||||
|
if input.AutoStartOnStartup != nil { s.AutoStartOnStartup = *input.AutoStartOnStartup }
|
||||||
|
if input.AutoUpdateModsOnStartup != nil { s.AutoUpdateModsOnStartup = *input.AutoUpdateModsOnStartup }
|
||||||
|
if input.ScheduledUpdate != nil { s.ScheduledUpdate = *input.ScheduledUpdate }
|
||||||
|
|
||||||
if err := h.process.WriteUserconfigFiles(s); err != nil {
|
if err := h.process.WriteUserconfigFiles(s); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "write userconfig: " + err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "write userconfig: " + err.Error()})
|
||||||
@@ -61,6 +69,11 @@ func (h *Handler) UpdateSettings(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.scheduler != nil {
|
||||||
|
h.scheduler.Refresh()
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, s)
|
c.JSON(http.StatusOK, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,5 +13,10 @@ type ServerSettings struct {
|
|||||||
DifficultyPresets string `json:"difficulty_presets"`
|
DifficultyPresets string `json:"difficulty_presets"`
|
||||||
ActiveConfig string `json:"active_config"`
|
ActiveConfig string `json:"active_config"`
|
||||||
ActiveModlist string `json:"active_modlist"`
|
ActiveModlist string `json:"active_modlist"`
|
||||||
|
AutoUpdateOnStartup bool `json:"auto_update_on_startup"`
|
||||||
|
AutoStartOnStartup bool `json:"auto_start_on_startup"`
|
||||||
|
AutoUpdateModsOnStartup bool `json:"auto_update_mods_on_startup"`
|
||||||
|
WasRunning bool `json:"was_running"`
|
||||||
|
ScheduledUpdate string `json:"scheduled_update"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
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)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ func (sm *SettingsManager) defaults() *models.ServerSettings {
|
|||||||
Platform: "linux",
|
Platform: "linux",
|
||||||
ActiveConfig: "",
|
ActiveConfig: "",
|
||||||
ActiveModlist: "",
|
ActiveModlist: "",
|
||||||
|
AutoUpdateOnStartup: false,
|
||||||
|
AutoStartOnStartup: false,
|
||||||
|
AutoUpdateModsOnStartup: false,
|
||||||
|
WasRunning: false,
|
||||||
|
ScheduledUpdate: "",
|
||||||
UpdatedAt: time.Now().UTC(),
|
UpdatedAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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: '📋' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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
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 [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">
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user