Веб-панель управления сервером Project Zomboid
Менеджер запускает сервер PZ как дочерний процесс и держит его stdin/stdout: отсюда живая консоль без RCON, стриминг логов через SSE и корректная остановка по цепочке quit -> SIGTERM -> SIGKILL для всей группы процессов. Сам менеджер работает под systemd. Что входит: - управление жизненным циклом сервера, автозапуск и автоподъём после падения; - метрики процесса из /proc и состояние хоста, список игроков онлайн; - редакторы servertest.ini и SandboxVars.lua, сохраняющие исходное форматирование файлов; - моды: списки Workshop ID и Mod ID с порядком загрузки, названия из Steam; - бэкапы мира по расписанию и вручную, с ротацией и восстановлением; - установка и обновление сервера через SteamCMD; - вход по логину и паролю, первичная настройка по одноразовому коду. Веб-интерфейс без сборщика, вшит в бинарник через embed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
// Package sysinfo снимает метрики хоста и группы процессов игрового сервера.
|
||||
package sysinfo
|
||||
|
||||
import "sync"
|
||||
|
||||
// ProcStats — потребление ресурсов одной группой процессов.
|
||||
type ProcStats struct {
|
||||
// CPUPercent — доля CPU за интервал между двумя снимками; 100% = одно ядро.
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
// RSSBytes — резидентная память всех процессов группы.
|
||||
RSSBytes int64 `json:"rss_bytes"`
|
||||
// Threads — суммарное число потоков.
|
||||
Threads int `json:"threads"`
|
||||
}
|
||||
|
||||
// HostStats — общее состояние машины.
|
||||
type HostStats struct {
|
||||
MemTotalBytes int64 `json:"mem_total_bytes"`
|
||||
MemAvailableBytes int64 `json:"mem_available_bytes"`
|
||||
Load1 float64 `json:"load1"`
|
||||
CPUCount int `json:"cpu_count"`
|
||||
DiskTotalBytes int64 `json:"disk_total_bytes"`
|
||||
DiskFreeBytes int64 `json:"disk_free_bytes"`
|
||||
}
|
||||
|
||||
// Sampler считает CPU по разнице между вызовами, поэтому хранит прошлый снимок.
|
||||
type Sampler struct {
|
||||
mu sync.Mutex
|
||||
lastCPU float64 // накопленное CPU-время группы, секунды
|
||||
lastWall float64 // момент снятия, секунды монотонных часов
|
||||
}
|
||||
|
||||
// NewSampler создаёт счётчик метрик для одной группы процессов.
|
||||
func NewSampler() *Sampler { return &Sampler{} }
|
||||
|
||||
// Reset забывает предыдущий снимок — вызывается при перезапуске сервера,
|
||||
// иначе первый замер нового процесса посчитается от чужой базы.
|
||||
func (s *Sampler) Reset() {
|
||||
s.mu.Lock()
|
||||
s.lastCPU, s.lastWall = 0, 0
|
||||
s.mu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//go:build linux
|
||||
|
||||
package sysinfo
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
var pageSize = int64(os.Getpagesize())
|
||||
|
||||
// clockTicks — USER_HZ. На всех практически встречающихся Linux-сборках 100.
|
||||
const clockTicks = 100.0
|
||||
|
||||
// Sample обходит /proc и суммирует статистику всех процессов, входящих в
|
||||
// группу pgid: сервер PZ — это скрипт-обёртка плюс порождённая им JVM.
|
||||
func (s *Sampler) Sample(pgid int) ProcStats {
|
||||
var (
|
||||
stats ProcStats
|
||||
cpuSecs float64
|
||||
)
|
||||
if pgid <= 0 {
|
||||
s.Reset()
|
||||
return stats
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return stats
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
utime, stime, threads, group, ok := readProcStat(pid)
|
||||
if !ok || group != pgid {
|
||||
continue
|
||||
}
|
||||
cpuSecs += (utime + stime) / clockTicks
|
||||
stats.Threads += threads
|
||||
stats.RSSBytes += readRSS(pid)
|
||||
}
|
||||
|
||||
now := uptimeSeconds()
|
||||
s.mu.Lock()
|
||||
if s.lastWall > 0 && now > s.lastWall {
|
||||
stats.CPUPercent = (cpuSecs - s.lastCPU) / (now - s.lastWall) * 100
|
||||
if stats.CPUPercent < 0 {
|
||||
stats.CPUPercent = 0
|
||||
}
|
||||
}
|
||||
s.lastCPU, s.lastWall = cpuSecs, now
|
||||
s.mu.Unlock()
|
||||
return stats
|
||||
}
|
||||
|
||||
// readProcStat разбирает /proc/<pid>/stat. Имя процесса заключено в скобки и
|
||||
// может содержать пробелы, поэтому режем строку после последней ')'.
|
||||
func readProcStat(pid int) (utime, stime float64, threads, pgrp int, ok bool) {
|
||||
raw, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat"))
|
||||
if err != nil {
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
line := string(raw)
|
||||
idx := strings.LastIndex(line, ")")
|
||||
if idx < 0 || idx+2 >= len(line) {
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
// Поля после comm: state(3) ppid(4) pgrp(5) ... utime(14) stime(15) ... num_threads(20).
|
||||
fields := strings.Fields(line[idx+2:])
|
||||
if len(fields) < 18 {
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
pgrp, _ = strconv.Atoi(fields[2])
|
||||
utime, _ = strconv.ParseFloat(fields[11], 64)
|
||||
stime, _ = strconv.ParseFloat(fields[12], 64)
|
||||
threads, _ = strconv.Atoi(fields[17])
|
||||
return utime, stime, threads, pgrp, true
|
||||
}
|
||||
|
||||
// readRSS берёт резидентные страницы из /proc/<pid>/statm (второе поле).
|
||||
func readRSS(pid int) int64 {
|
||||
raw, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "statm"))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
fields := strings.Fields(string(raw))
|
||||
if len(fields) < 2 {
|
||||
return 0
|
||||
}
|
||||
pages, _ := strconv.ParseInt(fields[1], 10, 64)
|
||||
return pages * pageSize
|
||||
}
|
||||
|
||||
func uptimeSeconds() float64 {
|
||||
raw, err := os.ReadFile("/proc/uptime")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
fields := strings.Fields(string(raw))
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
v, _ := strconv.ParseFloat(fields[0], 64)
|
||||
return v
|
||||
}
|
||||
|
||||
// Host собирает состояние машины; diskPath задаёт раздел для расчёта места.
|
||||
func Host(diskPath string) HostStats {
|
||||
h := HostStats{CPUCount: cpuCount()}
|
||||
|
||||
if raw, err := os.ReadFile("/proc/meminfo"); err == nil {
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
key, value, found := strings.Cut(line, ":")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
kb, _ := strconv.ParseInt(strings.Fields(strings.TrimSpace(value))[0], 10, 64)
|
||||
switch key {
|
||||
case "MemTotal":
|
||||
h.MemTotalBytes = kb * 1024
|
||||
case "MemAvailable":
|
||||
h.MemAvailableBytes = kb * 1024
|
||||
}
|
||||
}
|
||||
}
|
||||
if raw, err := os.ReadFile("/proc/loadavg"); err == nil {
|
||||
if fields := strings.Fields(string(raw)); len(fields) > 0 {
|
||||
h.Load1, _ = strconv.ParseFloat(fields[0], 64)
|
||||
}
|
||||
}
|
||||
if diskPath != "" {
|
||||
var st unix.Statfs_t
|
||||
if err := unix.Statfs(diskPath, &st); err == nil {
|
||||
h.DiskTotalBytes = int64(st.Blocks) * int64(st.Bsize)
|
||||
h.DiskFreeBytes = int64(st.Bavail) * int64(st.Bsize)
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func cpuCount() int {
|
||||
raw, err := os.ReadFile("/proc/cpuinfo")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return strings.Count(string(raw), "processor\t:")
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build !linux
|
||||
|
||||
package sysinfo
|
||||
|
||||
import "runtime"
|
||||
|
||||
// Sample на не-Linux платформах не читает /proc: менеджер рассчитан на
|
||||
// Ubuntu, а сборка под другие ОС нужна только для разработки.
|
||||
func (s *Sampler) Sample(pgid int) ProcStats { return ProcStats{} }
|
||||
|
||||
// Host отдаёт лишь число ядер — остальное платформозависимо.
|
||||
func Host(diskPath string) HostStats {
|
||||
return HostStats{CPUCount: runtime.NumCPU()}
|
||||
}
|
||||
Reference in New Issue
Block a user