Add server health endpoint and status page

Backend:
- New /api/server/health endpoint returning server, paths, disk,
  mods, steamcmd, and frontend status
- Health endpoint checks directory existence/writability, disk usage,
  server binary, and frontend dist availability

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

Misc:
- Update .gitignore and Makefile, minor Layout nav addition
This commit is contained in:
MrFastwind
2026-07-09 15:22:43 +02:00
parent 6f782af913
commit 2d301b95b6
10 changed files with 422 additions and 15 deletions

View File

@@ -17,11 +17,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"))
devDeploy := getEnv("DEV_DEPLOY_DIR", filepath.Join("..", "dev-deploy"))
dataDir := mustAbs(getEnv("DATA_DIR", filepath.Join(devDeploy, "data")))
serverfileDir := mustAbs(getEnv("SERVERFILE_DIR", filepath.Join(devDeploy, "serverfiles")))
modsDir := mustAbs(getEnv("MODS_DIR", filepath.Join(devDeploy, "serverfiles", "mods")))
cfgDir := mustAbs(getEnv("CFG_DIR", filepath.Join(devDeploy, "serverfiles", "cfg")))
profilesDir := mustAbs(getEnv("PROFILES_DIR", filepath.Join(devDeploy, "serverfiles", "profiles")))
listen := getEnv("LISTEN", ":8080")
frontendDir := mustAbs(getEnv("FRONTEND_DIR", filepath.Join("..", "frontend", "dist")))
@@ -63,7 +64,7 @@ func main() {
log.Printf("serving frontend from %s", frontendDir)
}
handler := api.New(settings, configMgr, modlistMgr, process, steamcmd, scheduler, streamer, serverfileDir, modsDir, cfgDir, profilesDir)
handler := api.New(settings, configMgr, modlistMgr, process, steamcmd, scheduler, streamer, dataDir, serverfileDir, modsDir, cfgDir, profilesDir, frontendDir)
handler.SetupRoutes(r)
// Startup auto-tasks

View File

@@ -0,0 +1,188 @@
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"`
Path string `json:"path"`
}
func (h *Handler) Health(c *gin.Context) {
workshopMods := services.ListWorkshopMods(h.serverfileDir)
localMods := services.ListLocalMods(h.modsDir)
paths := h.checkPaths()
disk := h.checkDisk()
frontendServed, _ := h.checkFrontend()
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: frontendServed,
Path: h.frontendDir,
},
}
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 (h *Handler) checkFrontend() (bool, string) {
if h.frontendDir == "" {
return false, ""
}
fi, err := os.Stat(h.frontendDir)
return err == nil && fi.IsDir(), h.frontendDir
}
func checkServerBinary(serverfileDir string) bool {
_, found := findServerBinary(serverfileDir)
return found
}
func serverBinaryPath(serverfileDir string) string {
path, _ := findServerBinary(serverfileDir)
return path
}
func findServerBinary(serverfileDir string) (string, bool) {
candidates := []string{"arma3server_x64"}
if runtime.GOOS == "windows" {
candidates = append(candidates, "arma3server_x64.exe")
} else {
candidates = append(candidates, "arma3server_x64.exe")
}
for _, name := range candidates {
p := filepath.Join(serverfileDir, name)
fi, err := os.Stat(p)
if err == nil && !fi.IsDir() {
return p, true
}
}
return "", false
}
func isWritable(path string) bool {
return unix.Access(path, unix.W_OK) == nil
}
func diskUsage(path string) (DiskHealth, error) {
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return DiskHealth{}, err
}
total := stat.Blocks * uint64(stat.Bsize)
free := stat.Bfree * uint64(stat.Bsize)
var usedPercent float64
if total > 0 {
usedPercent = float64(total-free) / float64(total) * 100
}
return DiskHealth{
Path: path,
TotalBytes: total,
FreeBytes: free,
UsedPercent: usedPercent,
}, nil
}

View File

@@ -14,10 +14,12 @@ type Handler struct {
steamcmd *services.SteamCmdManager
scheduler *services.Scheduler
streamer *services.LogStreamer
dataDir string
serverfileDir string
modsDir string
cfgDir string
profilesDir string
frontendDir string
}
func New(
@@ -28,7 +30,7 @@ func New(
steamcmd *services.SteamCmdManager,
scheduler *services.Scheduler,
streamer *services.LogStreamer,
serverfileDir, modsDir, cfgDir, profilesDir string,
dataDir, serverfileDir, modsDir, cfgDir, profilesDir, frontendDir string,
) *Handler {
return &Handler{
settings: settings,
@@ -38,10 +40,12 @@ func New(
steamcmd: steamcmd,
scheduler: scheduler,
streamer: streamer,
dataDir: dataDir,
serverfileDir: serverfileDir,
modsDir: modsDir,
cfgDir: cfgDir,
profilesDir: profilesDir,
frontendDir: frontendDir,
}
}
@@ -59,6 +63,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)