Compare commits
4 Commits
6f782af913
...
914e0fbe48
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
914e0fbe48 | ||
|
|
9aa6fca50e | ||
|
|
bc0a252d7d | ||
|
|
2d301b95b6 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
backend/embed/dist/
|
||||||
|
|
||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
@@ -8,6 +9,7 @@ backend/arma3-web-server
|
|||||||
|
|
||||||
data/
|
data/
|
||||||
serverfiles/
|
serverfiles/
|
||||||
|
dev-deploy/
|
||||||
|
|
||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
24
Makefile
24
Makefile
@@ -1,11 +1,11 @@
|
|||||||
.PHONY: backend frontend build run clean dev
|
.PHONY: backend frontend build run clean dev
|
||||||
|
|
||||||
# Default paths — override via env or copy .env.example
|
# Default paths for dev — override via env
|
||||||
SERVERFILE_DIR ?= ./serverfiles
|
SERVERFILE_DIR ?= ../dev-deploy/serverfiles
|
||||||
MODS_DIR ?= ./serverfiles/mods
|
MODS_DIR ?= ../dev-deploy/serverfiles/mods
|
||||||
CFG_DIR ?= ./serverfiles/cfg
|
CFG_DIR ?= ../dev-deploy/serverfiles/cfg
|
||||||
PROFILES_DIR ?= ./serverfiles/profiles
|
PROFILES_DIR ?= ../dev-deploy/serverfiles/profiles
|
||||||
DATA_DIR ?= ../../data
|
DATA_DIR ?= ../dev-deploy/data
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
cd backend && go build -o arma3-web-server ./cmd/server/
|
cd backend && go build -o arma3-web-server ./cmd/server/
|
||||||
@@ -13,15 +13,23 @@ backend:
|
|||||||
frontend:
|
frontend:
|
||||||
cd frontend && npm run build
|
cd frontend && npm run build
|
||||||
|
|
||||||
build: backend frontend
|
.PHONY: copyfrontend
|
||||||
|
copyfrontend:
|
||||||
|
rm -rf backend/embed/dist
|
||||||
|
mkdir -p backend/embed/dist
|
||||||
|
cp -r frontend/dist/. backend/embed/dist/
|
||||||
|
|
||||||
|
build: frontend copyfrontend backend
|
||||||
|
|
||||||
run:
|
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 && 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:
|
dev:
|
||||||
|
mkdir -p backend/embed/dist
|
||||||
|
test -f backend/embed/dist/dev.txt || touch backend/embed/dist/dev.txt
|
||||||
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 && 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
|
cd frontend && npm run dev
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f backend/arma3-web-server
|
rm -f backend/arma3-web-server
|
||||||
rm -rf frontend/dist
|
rm -rf frontend/dist backend/embed/dist
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"arma3-web-server/embed"
|
||||||
"arma3-web-server/internal/api"
|
"arma3-web-server/internal/api"
|
||||||
"arma3-web-server/internal/services"
|
"arma3-web-server/internal/services"
|
||||||
|
|
||||||
@@ -17,13 +21,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
dataDir := mustAbs(getEnv("DATA_DIR", filepath.Join("data")))
|
serverfileDir := mustAbs(getEnv("SERVERFILE_DIR", "./serverfiles"))
|
||||||
serverfileDir := mustAbs(getEnv("SERVERFILE_DIR", "serverfiles"))
|
dataDir := mustAbs(getEnv("DATA_DIR", "./data"))
|
||||||
modsDir := mustAbs(getEnv("MODS_DIR", "serverfiles/mods"))
|
modsDir := mustAbs(getEnv("MODS_DIR", filepath.Join(serverfileDir, "mods")))
|
||||||
cfgDir := mustAbs(getEnv("CFG_DIR", "serverfiles/cfg"))
|
cfgDir := mustAbs(getEnv("CFG_DIR", filepath.Join(serverfileDir, "cfg")))
|
||||||
profilesDir := mustAbs(getEnv("PROFILES_DIR", "serverfiles/profiles"))
|
profilesDir := mustAbs(getEnv("PROFILES_DIR", filepath.Join(serverfileDir, "profiles")))
|
||||||
listen := getEnv("LISTEN", ":8080")
|
listen := getEnv("LISTEN", ":8080")
|
||||||
frontendDir := mustAbs(getEnv("FRONTEND_DIR", filepath.Join("..", "frontend", "dist")))
|
|
||||||
|
|
||||||
log.Printf("data dir: %s", dataDir)
|
log.Printf("data dir: %s", dataDir)
|
||||||
log.Printf("serverfile dir: %s", serverfileDir)
|
log.Printf("serverfile dir: %s", serverfileDir)
|
||||||
@@ -54,16 +57,31 @@ func main() {
|
|||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
if fi, err := os.Stat(frontendDir); err == nil && fi.IsDir() {
|
subFS, subErr := fs.Sub(embed.Frontend, "dist")
|
||||||
r.Static("/assets", filepath.Join(frontendDir, "assets"))
|
if subErr == nil {
|
||||||
r.StaticFile("/favicon.ico", filepath.Join(frontendDir, "favicon.ico"))
|
if _, err := subFS.Open("index.html"); err == nil {
|
||||||
r.NoRoute(func(c *gin.Context) {
|
r.NoRoute(func(c *gin.Context) {
|
||||||
c.File(filepath.Join(frontendDir, "index.html"))
|
path := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||||||
})
|
if path == "" {
|
||||||
log.Printf("serving frontend from %s", frontendDir)
|
path = "index.html"
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(subFS, path)
|
||||||
|
if err == nil {
|
||||||
|
ct := mime.TypeByExtension(filepath.Ext(path))
|
||||||
|
if ct == "" {
|
||||||
|
ct = "text/html; charset=utf-8"
|
||||||
|
}
|
||||||
|
c.Data(http.StatusOK, ct, data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, _ = fs.ReadFile(subFS, "index.html")
|
||||||
|
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
||||||
|
})
|
||||||
|
log.Print("serving embedded frontend")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
handler.SetupRoutes(r)
|
handler.SetupRoutes(r)
|
||||||
|
|
||||||
// Startup auto-tasks
|
// Startup auto-tasks
|
||||||
|
|||||||
6
backend/embed/embed.go
Normal file
6
backend/embed/embed.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package embed
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed dist
|
||||||
|
var Frontend embed.FS
|
||||||
177
backend/internal/api/health.go
Normal file
177
backend/internal/api/health.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Health(c *gin.Context) {
|
||||||
|
workshopMods := services.ListWorkshopMods(h.serverfileDir)
|
||||||
|
localMods := services.ListLocalMods(h.modsDir)
|
||||||
|
|
||||||
|
paths := h.checkPaths()
|
||||||
|
disk := h.checkDisk()
|
||||||
|
|
||||||
|
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: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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,6 +14,7 @@ type Handler struct {
|
|||||||
steamcmd *services.SteamCmdManager
|
steamcmd *services.SteamCmdManager
|
||||||
scheduler *services.Scheduler
|
scheduler *services.Scheduler
|
||||||
streamer *services.LogStreamer
|
streamer *services.LogStreamer
|
||||||
|
dataDir string
|
||||||
serverfileDir string
|
serverfileDir string
|
||||||
modsDir string
|
modsDir string
|
||||||
cfgDir string
|
cfgDir string
|
||||||
@@ -28,7 +29,7 @@ func New(
|
|||||||
steamcmd *services.SteamCmdManager,
|
steamcmd *services.SteamCmdManager,
|
||||||
scheduler *services.Scheduler,
|
scheduler *services.Scheduler,
|
||||||
streamer *services.LogStreamer,
|
streamer *services.LogStreamer,
|
||||||
serverfileDir, modsDir, cfgDir, profilesDir string,
|
dataDir, serverfileDir, modsDir, cfgDir, profilesDir string,
|
||||||
) *Handler {
|
) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
settings: settings,
|
settings: settings,
|
||||||
@@ -38,6 +39,7 @@ func New(
|
|||||||
steamcmd: steamcmd,
|
steamcmd: steamcmd,
|
||||||
scheduler: scheduler,
|
scheduler: scheduler,
|
||||||
streamer: streamer,
|
streamer: streamer,
|
||||||
|
dataDir: dataDir,
|
||||||
serverfileDir: serverfileDir,
|
serverfileDir: serverfileDir,
|
||||||
modsDir: modsDir,
|
modsDir: modsDir,
|
||||||
cfgDir: cfgDir,
|
cfgDir: cfgDir,
|
||||||
@@ -59,6 +61,7 @@ func (h *Handler) SetupRoutes(r *gin.Engine) {
|
|||||||
api.POST("/server/steamcmd/download-mod", h.SteamCMDDownloadMod)
|
api.POST("/server/steamcmd/download-mod", h.SteamCMDDownloadMod)
|
||||||
api.GET("/server/steamcmd/status", h.SteamCMDStatus)
|
api.GET("/server/steamcmd/status", h.SteamCMDStatus)
|
||||||
api.GET("/server/paths", h.ServerPaths)
|
api.GET("/server/paths", h.ServerPaths)
|
||||||
|
api.GET("/server/health", h.Health)
|
||||||
|
|
||||||
api.GET("/server/logs", h.ListLogs)
|
api.GET("/server/logs", h.ListLogs)
|
||||||
api.GET("/server/logs/:file", h.GetLog)
|
api.GET("/server/logs/:file", h.GetLog)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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'
|
import Mods from './pages/Mods'
|
||||||
|
import Status from './pages/Status'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -21,6 +22,7 @@ function App() {
|
|||||||
<Route path="/modlists/:id" element={<ModlistEditor />} />
|
<Route path="/modlists/:id" element={<ModlistEditor />} />
|
||||||
<Route path="/mods" element={<Mods />} />
|
<Route path="/mods" element={<Mods />} />
|
||||||
<Route path="/logs" element={<Logs />} />
|
<Route path="/logs" element={<Logs />} />
|
||||||
|
<Route path="/status" element={<Status />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -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'
|
const BASE = '/api'
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ export const settingsApi = {
|
|||||||
status: () => request<{ running: boolean }>('/server/status'),
|
status: () => request<{ running: boolean }>('/server/status'),
|
||||||
steamcmd: () => request<{ branch: string; user: string; platform: string }>('/server/steamcmd'),
|
steamcmd: () => request<{ branch: string; user: string; platform: string }>('/server/steamcmd'),
|
||||||
paths: () => request<ServerPaths>('/server/paths'),
|
paths: () => request<ServerPaths>('/server/paths'),
|
||||||
|
health: () => request<ServerHealth>('/server/health'),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const configsApi = {
|
export const configsApi = {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const nav = [
|
|||||||
{ to: '/modlists', label: 'Modlists', icon: '📦' },
|
{ to: '/modlists', label: 'Modlists', icon: '📦' },
|
||||||
{ to: '/mods', label: 'Mods', icon: '🗂' },
|
{ to: '/mods', label: 'Mods', icon: '🗂' },
|
||||||
{ to: '/logs', label: 'Logs', icon: '📋' },
|
{ to: '/logs', label: 'Logs', icon: '📋' },
|
||||||
|
{ to: '/status', label: 'Status', icon: '📊' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Layout({ children }: { children: ReactNode }) {
|
export default function Layout({ children }: { children: ReactNode }) {
|
||||||
|
|||||||
175
frontend/src/pages/Status.tsx
Normal file
175
frontend/src/pages/Status.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
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 (
|
||||||
|
<span
|
||||||
|
onClick={copy}
|
||||||
|
title={path}
|
||||||
|
className="cursor-pointer flex items-center max-w-full font-mono text-xs hover:text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="overflow-hidden text-ellipsis whitespace-nowrap"
|
||||||
|
style={{ direction: 'rtl', textAlign: 'left' }}
|
||||||
|
>
|
||||||
|
<span style={{ direction: 'ltr', unicodeBidi: 'embed' }}>
|
||||||
|
{path}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{copied && <span className="ml-0.5 text-green-400 shrink-0">✓</span>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Status() {
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['server-health'],
|
||||||
|
queryFn: settingsApi.health,
|
||||||
|
refetchInterval: 10000,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) return <p className="text-neutral-400">Loading status...</p>
|
||||||
|
if (error) return <p className="text-red-400">Failed to load status: {(error as Error).message}</p>
|
||||||
|
if (!data) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold mb-6">Deploy Status</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<Section title="Server">
|
||||||
|
<StatusRow label="Running" value={data.server.running ? 'Yes' : 'No'} ok={data.server.running} />
|
||||||
|
<StatusRow label="Binary installed" value={data.server.installed ? 'Yes' : 'No'} ok={data.server.installed} />
|
||||||
|
{data.server.binary_path && (
|
||||||
|
<StatusRow label="Binary path" value={<CopyPath path={data.server.binary_path} />} />
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="SteamCMD">
|
||||||
|
<StatusRow label="Running" value={data.steamcmd.running ? 'Yes' : 'No'} ok={!data.steamcmd.running} warn={data.steamcmd.running} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Mods">
|
||||||
|
<StatusRow label="Workshop mods" value={String(data.mods.workshop_count)} />
|
||||||
|
<StatusRow label="Local mods" value={String(data.mods.local_count)} />
|
||||||
|
<StatusRow label="Total" value={String(data.mods.workshop_count + data.mods.local_count)} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Frontend">
|
||||||
|
<StatusRow label="Served" value={data.frontend.served ? 'Yes' : 'No'} ok={data.frontend.served} />
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<Section title="Directories">
|
||||||
|
<table className="w-full text-sm table-fixed">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-neutral-500 border-b border-neutral-800">
|
||||||
|
<th className="text-left py-2 pr-4 w-[55%]">Path</th>
|
||||||
|
<th className="text-center py-2 px-4 w-[22%]">Exists</th>
|
||||||
|
<th className="text-center py-2 px-4 w-[23%]">Writable</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.paths.map((p, i) => (
|
||||||
|
<tr key={i} className="border-b border-neutral-800/50">
|
||||||
|
<td className="py-2 pr-4 overflow-hidden"><CopyPath path={p.path} /></td>
|
||||||
|
<td className="text-center py-2 px-4">
|
||||||
|
<Badge ok={p.exists} label={p.exists ? 'Yes' : 'No'} />
|
||||||
|
</td>
|
||||||
|
<td className="text-center py-2 px-4">
|
||||||
|
<Badge ok={p.writable} label={p.writable ? 'Yes' : 'No'} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<Section title="Disk Usage">
|
||||||
|
<table className="w-full text-sm table-fixed">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-neutral-500 border-b border-neutral-800">
|
||||||
|
<th className="text-left py-2 pr-4 w-[40%]">Path</th>
|
||||||
|
<th className="text-right py-2 px-4 w-[20%]">Total</th>
|
||||||
|
<th className="text-right py-2 px-4 w-[20%]">Free</th>
|
||||||
|
<th className="text-right py-2 px-4 w-[20%]">Used</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.disk.map((d, i) => (
|
||||||
|
<tr key={i} className="border-b border-neutral-800/50">
|
||||||
|
<td className="py-2 pr-4 overflow-hidden"><CopyPath path={d.path} /></td>
|
||||||
|
<td className="text-right py-2 px-4">{fmtSize(d.total_bytes)}</td>
|
||||||
|
<td className="text-right py-2 px-4">{fmtSize(d.free_bytes)}</td>
|
||||||
|
<td className="text-right py-2 px-4">
|
||||||
|
<span className={d.used_percent > 90 ? 'text-red-400' : d.used_percent > 75 ? 'text-yellow-400' : ''}>
|
||||||
|
{fmtPercent(d.used_percent)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
|
||||||
|
<h3 className="font-medium mb-3 text-neutral-300">{title}</h3>
|
||||||
|
<div>{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusRow({ label, value, ok, warn }: { label: string; value: React.ReactNode; ok?: boolean; warn?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between items-center py-1.5 text-sm">
|
||||||
|
<span className="text-neutral-500 shrink-0">{label}</span>
|
||||||
|
<span className="flex items-center gap-2 min-w-0 max-w-[65%]">
|
||||||
|
{ok !== undefined && <Dot color={ok ? 'bg-green-400' : 'bg-red-400'} />}
|
||||||
|
{warn && <Dot color="bg-yellow-400" />}
|
||||||
|
<span className={`min-w-0 overflow-hidden ${ok === false ? 'text-red-400' : warn ? 'text-yellow-400' : ''}`}>{value}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({ ok, label }: { ok: boolean; label: string }) {
|
||||||
|
return (
|
||||||
|
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${ok ? 'bg-green-900/50 text-green-400' : 'bg-red-900/50 text-red-400'}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dot({ color }: { color: string }) {
|
||||||
|
return <span className={`inline-block w-2 h-2 rounded-full ${color}`} />
|
||||||
|
}
|
||||||
@@ -51,6 +51,35 @@ export interface ServerPaths {
|
|||||||
profiles_dir: string
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface ModInfo {
|
export interface ModInfo {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
Reference in New Issue
Block a user