Менеджер запускает сервер 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>
157 lines
4.1 KiB
Go
157 lines
4.1 KiB
Go
//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:")
|
|
}
|