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:
188
backend/internal/api/health.go
Normal file
188
backend/internal/api/health.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user