Профили серверов: несколько миров с раздельными настройками

Профиль — это отдельный сервер: свой мир, .ini, песочница, набор модов и
лимит памяти JVM. Project Zomboid различает их по -servername, поэтому
файлы профилей не пересекаются. Работает всегда один профиль: серверные
файлы, игровые порты и файл запуска JVM общие, так что переключение
разрешено только на остановленном сервере.

- config: список профилей вместо одиночных server_name/admin_password/
  java_memory; старые поля переносятся в первый профиль при загрузке;
- supervisor: запускает активный профиль и выставляет его -Xmx на каждом
  старте, потому что файл запуска общий;
- бэкапы: архив снимается с одного профиля (мир + его конфиги), имя
  профиля попадает в имя файла, ротация считается по профилям;
- API профилей: список, создание с копированием настроек из другого
  профиля, правка, удаление и переключение активного;
- панель: селектор профиля в шапке и вкладка со списком профилей.

Удаление профиля убирает его только из панели: мир и конфиги остаются на
диске, чтобы случайное нажатие не стоило сохранений.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 00:27:12 +03:00
co-authored by Claude Opus 5
parent c384d60416
commit 277ab7af4a
13 changed files with 1317 additions and 183 deletions
+43 -20
View File
@@ -15,6 +15,7 @@ import (
"time"
"github.com/mrleo1nid/pzmanager/internal/config"
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
"github.com/mrleo1nid/pzmanager/internal/sysinfo"
)
@@ -43,16 +44,20 @@ const restartDelay = 10 * time.Second
// Status — снимок состояния для веб-панели.
type Status struct {
State State `json:"state"`
Installed bool `json:"installed"`
PID int `json:"pid"`
UptimeSec int64 `json:"uptime_sec"`
Players []string `json:"players"`
PlayersAt int64 `json:"players_at"`
LastExit string `json:"last_exit"`
AutoRestart bool `json:"auto_restart"`
Proc sysinfo.ProcStats `json:"proc"`
Host sysinfo.HostStats `json:"host"`
State State `json:"state"`
// Profile и ProfileTitle описывают профиль, который сейчас работает
// (а если сервер остановлен — который будет запущен).
Profile string `json:"profile"`
ProfileTitle string `json:"profile_title"`
Installed bool `json:"installed"`
PID int `json:"pid"`
UptimeSec int64 `json:"uptime_sec"`
Players []string `json:"players"`
PlayersAt int64 `json:"players_at"`
LastExit string `json:"last_exit"`
AutoRestart bool `json:"auto_restart"`
Proc sysinfo.ProcStats `json:"proc"`
Host sysinfo.HostStats `json:"host"`
}
// Supervisor владеет процессом сервера: запускает, кормит командами консоли,
@@ -70,6 +75,9 @@ type Supervisor struct {
lastExit string
players []string
playersAt time.Time
// profile — профиль, с которым сервер был запущен: активный профиль
// в конфиге может отличаться, если его успели переключить.
profile config.Profile
// intentionalStop отличает остановку по кнопке от падения: во втором
// случае включается автоперезапуск.
intentionalStop bool
@@ -136,12 +144,19 @@ func (s *Supervisor) State() State {
// Status собирает полный снимок состояния вместе с метриками процесса и хоста.
func (s *Supervisor) Status() Status {
profile := s.cfg.ActiveProfile()
s.mu.Lock()
if s.state != StateStopped {
profile = s.profile
}
st := Status{
State: s.state,
Players: append([]string{}, s.players...),
LastExit: s.lastExit,
AutoRestart: s.cfg.AutoRestart,
State: s.state,
Profile: profile.ID,
ProfileTitle: profile.Name(),
Players: append([]string{}, s.players...),
LastExit: s.lastExit,
AutoRestart: s.cfg.AutoRestart,
}
if !s.playersAt.IsZero() {
st.PlayersAt = s.playersAt.Unix()
@@ -186,11 +201,18 @@ func (s *Supervisor) startLocked() error {
return fmt.Errorf("chmod %s: %w", script, err)
}
args := []string{"-servername", s.cfg.ServerName}
if s.cfg.AdminPassword != "" {
args = append(args, "-adminpassword", s.cfg.AdminPassword)
profile := s.cfg.ActiveProfile()
// Лимит памяти живёт в файле запуска, общем для всех профилей, поэтому
// выставляем его на каждом старте — иначе профиль унаследует чужой -Xmx.
if err := pzconfig.SetJavaMemory(s.cfg.ServerDir, profile.JavaMemory); err != nil {
s.AppendLog("manager", "Не удалось выставить лимит памяти JVM: "+err.Error())
}
args = append(args, s.cfg.ExtraArgs...)
args := []string{"-servername", profile.ID}
if profile.AdminPassword != "" {
args = append(args, "-adminpassword", profile.AdminPassword)
}
args = append(args, profile.ExtraArgs...)
cmd := exec.Command(script, args...)
cmd.Dir = s.cfg.ServerDir
@@ -220,6 +242,7 @@ func (s *Supervisor) startLocked() error {
}
s.cmd = cmd
s.profile = profile
s.stdin = stdin
s.pgid = processGroupID(cmd.Process.Pid)
s.state = StateStarting
@@ -230,8 +253,8 @@ func (s *Supervisor) startLocked() error {
s.exited = make(chan struct{})
s.sampler.Reset()
s.AppendLog("manager", fmt.Sprintf("Запуск сервера: %s %s (pid %d)",
script, strings.Join(args, " "), cmd.Process.Pid))
s.AppendLog("manager", fmt.Sprintf("Запуск профиля %q: %s %s (pid %d)",
profile.Name(), script, strings.Join(args, " "), cmd.Process.Pid))
go s.readPipe(stdout, "stdout")
go s.readPipe(stderr, "stderr")