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

Профиль — это отдельный сервер: свой мир, .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
+47 -12
View File
@@ -58,23 +58,49 @@ func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"backups": archives, "dir": s.backups.Dir()})
// Фильтр по профилю необязателен: панель показывает архивы всех профилей,
// помечая каждый, но умеет и сузить список.
if profile := r.URL.Query().Get("profile"); profile != "" {
filtered := archives[:0]
for _, a := range archives {
if a.Profile == profile {
filtered = append(filtered, a)
}
}
archives = filtered
}
writeJSON(w, http.StatusOK, map[string]any{
"backups": archives,
"dir": s.backups.Dir(),
"active": s.cfg.Active,
})
}
func (s *Server) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
var body struct {
Note string `json:"note"`
Note string `json:"note"`
Profile string `json:"profile"`
}
if r.ContentLength > 0 && !decodeJSON(w, r, &body) {
return
}
if body.Profile == "" {
body.Profile = s.cfg.Active
}
if _, ok := s.cfg.Profile(body.Profile); !ok {
writeError(w, http.StatusBadRequest, "профиль не найден")
return
}
// Живой сервер держит мир в памяти, и снимок чужого профиля тут ни при
// чём: остановка нужна только для профиля, который сейчас работает.
profileIsRunning := body.Profile == s.cfg.Active && s.sup.State() != pzserver.StateStopped
name := "Резервное копирование мира"
name := "Резервное копирование мира: " + body.Profile
err := s.jobs.Start(name, func(ctx context.Context, log func(string)) error {
// Опциональная остановка сервера даёт согласованный снимок: PZ
// сбрасывает мир на диск только при сохранении.
restart := false
if s.cfg.Backup.StopServer && s.sup.State() != pzserver.StateStopped {
if s.cfg.Backup.StopServer && profileIsRunning {
log("Останавливаю сервер перед бэкапом")
if err := s.sup.Stop(); err != nil && !errors.Is(err, pzserver.ErrNotRunning) {
return err
@@ -82,7 +108,7 @@ func (s *Server) handleCreateBackup(w http.ResponseWriter, r *http.Request) {
restart = true
}
_, err := s.backups.Create(ctx, log, body.Note)
_, err := s.backups.Create(ctx, log, body.Profile, body.Note)
if restart {
log("Поднимаю сервер обратно")
@@ -144,13 +170,19 @@ func (s *Server) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Страховочную копию снимаем с того профиля, поверх которого ляжет архив;
// у старых архивов профиль в имени не записан — берём активный.
restoreProfile := s.backups.ProfileOf(name)
if _, ok := s.cfg.Profile(restoreProfile); !ok {
restoreProfile = s.cfg.Active
}
taskName := "Восстановление из архива " + name
err := s.jobs.Start(taskName, func(ctx context.Context, log func(string)) error {
// Перед перезаписью снимаем страховочную копию текущего состояния:
// восстановление не туда — самая дорогая ошибка в этой панели.
log("Делаю страховочный бэкап текущего мира")
if _, err := s.backups.Create(ctx, log, "pre-restore"); err != nil {
if _, err := s.backups.Create(ctx, log, restoreProfile, "pre-restore"); err != nil {
return fmt.Errorf("страховочный бэкап не создан, восстановление отменено: %w", err)
}
return s.backups.Restore(ctx, log, name)
@@ -185,9 +217,10 @@ func StartBackupScheduler(ctx context.Context, s *Server) {
s.sup.AppendLog("manager", "Плановый бэкап пропущен: выполняется другая задача")
continue
}
err := s.jobs.Start("Плановое резервное копирование",
profile := s.cfg.Active
err := s.jobs.Start("Плановое резервное копирование: "+profile,
func(ctx context.Context, log func(string)) error {
_, err := s.backups.Create(ctx, log, "auto")
_, err := s.backups.Create(ctx, log, profile, "auto")
return err
})
if err != nil && !errors.Is(err, job.ErrBusy) {
@@ -198,15 +231,17 @@ func StartBackupScheduler(ctx context.Context, s *Server) {
}()
}
// applyJavaMemory переносит лимит памяти из конфига менеджера в файл запуска
// applyJavaMemory переносит лимит памяти активного профиля в файл запуска
// сервера. Вызывается после установки: SteamCMD перезаписывает этот файл.
// При обычном старте то же самое делает супервизор.
func (s *Server) applyJavaMemory(log func(string)) error {
if s.cfg.JavaMemory == "" {
profile := s.cfg.ActiveProfile()
if profile.JavaMemory == "" {
return nil
}
if err := pzconfig.SetJavaMemory(s.cfg.ServerDir, s.cfg.JavaMemory); err != nil {
if err := pzconfig.SetJavaMemory(s.cfg.ServerDir, profile.JavaMemory); err != nil {
return err
}
log("Лимит памяти JVM выставлен: " + s.cfg.JavaMemory)
log("Лимит памяти JVM выставлен: " + profile.JavaMemory)
return nil
}