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

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