diff --git a/.gitignore b/.gitignore index eb270f8..b4219db 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ backend/arma3-web-server data/ serverfiles/ +dev-deploy/ .env .DS_Store diff --git a/Makefile b/Makefile index 6e3c769..731a159 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,13 @@ .PHONY: backend frontend build run clean dev +DEV_DEPLOY_DIR ?= ../dev-deploy + # 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 +SERVERFILE_DIR ?= $(DEV_DEPLOY_DIR)/serverfiles +MODS_DIR ?= $(DEV_DEPLOY_DIR)/serverfiles/mods +CFG_DIR ?= $(DEV_DEPLOY_DIR)/serverfiles/cfg +PROFILES_DIR ?= $(DEV_DEPLOY_DIR)/serverfiles/profiles +DATA_DIR ?= $(DEV_DEPLOY_DIR)/data backend: cd backend && go build -o arma3-web-server ./cmd/server/ @@ -16,10 +18,10 @@ frontend: build: backend frontend 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/ + cd backend && DEV_DEPLOY_DIR=$(DEV_DEPLOY_DIR) 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: - 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 backend && DEV_DEPLOY_DIR=$(DEV_DEPLOY_DIR) 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: diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 3931261..29539fd 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -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 diff --git a/backend/internal/api/health.go b/backend/internal/api/health.go new file mode 100644 index 0000000..f0070ed --- /dev/null +++ b/backend/internal/api/health.go @@ -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 +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index aeac260..ad21d4a 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -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) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ec25326..27fb09b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ 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 ( @@ -21,6 +22,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1b528e0..a154070 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,4 +1,4 @@ -import type { ServerSettings, ServerPaths, ConfigInfo, Modlist, ModlistListItem, ModEntry, ModInfo } 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('/server/paths'), + health: () => request('/server/health'), } export const configsApi = { diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 5f08575..574c3f4 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -8,6 +8,7 @@ const nav = [ { 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 }) { diff --git a/frontend/src/pages/Status.tsx b/frontend/src/pages/Status.tsx new file mode 100644 index 0000000..b6c9206 --- /dev/null +++ b/frontend/src/pages/Status.tsx @@ -0,0 +1,176 @@ +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 ( + + + + {path} + + + {copied && } + + ) +} + +export default function Status() { + const { data, isLoading, error } = useQuery({ + queryKey: ['server-health'], + queryFn: settingsApi.health, + refetchInterval: 10000, + }) + + if (isLoading) return

Loading status...

+ if (error) return

Failed to load status: {(error as Error).message}

+ if (!data) return null + + return ( +
+

Deploy Status

+ +
+
+ + + {data.server.binary_path && ( + } /> + )} +
+ +
+ +
+ +
+ + + +
+ +
+ + : '-'} /> +
+
+ +
+
+ + + + + + + + + + {data.paths.map((p, i) => ( + + + + + + ))} + +
PathExistsWritable
+ + + +
+
+
+ +
+
+ + + + + + + + + + + {data.disk.map((d, i) => ( + + + + + + + ))} + +
PathTotalFreeUsed
{fmtSize(d.total_bytes)}{fmtSize(d.free_bytes)} + 90 ? 'text-red-400' : d.used_percent > 75 ? 'text-yellow-400' : ''}> + {fmtPercent(d.used_percent)} + +
+
+
+
+ ) +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ) +} + +function StatusRow({ label, value, ok, warn }: { label: string; value: React.ReactNode; ok?: boolean; warn?: boolean }) { + return ( +
+ {label} + + {ok !== undefined && } + {warn && } + {value} + +
+ ) +} + +function Badge({ ok, label }: { ok: boolean; label: string }) { + return ( + + {label} + + ) +} + +function Dot({ color }: { color: string }) { + return +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d943463..31d5f60 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -51,6 +51,36 @@ export interface ServerPaths { 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 + path: string + } +} + export interface ModInfo { id: string name: string