From 277ab7af4a837376efcaf0bbe199617a6f70d5c4 Mon Sep 17 00:00:00 2001 From: mrleo1nid Date: Fri, 4 Sep 2026 00:27:12 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D0=B8=20?= =?UTF-8?q?=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=D0=BE=D0=B2:=20=D0=BD?= =?UTF-8?q?=D0=B5=D1=81=D0=BA=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BC=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=20=D1=81=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=BC=D0=B8=20=D0=BD=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Профиль — это отдельный сервер: свой мир, .ini, песочница, набор модов и лимит памяти JVM. Project Zomboid различает их по -servername, поэтому файлы профилей не пересекаются. Работает всегда один профиль: серверные файлы, игровые порты и файл запуска JVM общие, так что переключение разрешено только на остановленном сервере. - config: список профилей вместо одиночных server_name/admin_password/ java_memory; старые поля переносятся в первый профиль при загрузке; - supervisor: запускает активный профиль и выставляет его -Xmx на каждом старте, потому что файл запуска общий; - бэкапы: архив снимается с одного профиля (мир + его конфиги), имя профиля попадает в имя файла, ротация считается по профилям; - API профилей: список, создание с копированием настроек из другого профиля, правка, удаление и переключение активного; - панель: селектор профиля в шапке и вкладка со списком профилей. Удаление профиля убирает его только из панели: мир и конфиги остаются на диске, чтобы случайное нажатие не стоило сохранений. Co-Authored-By: Claude Opus 5 --- README.md | 58 +++++-- internal/api/handlers_config.go | 53 ++---- internal/api/handlers_profiles.go | 223 +++++++++++++++++++++++++ internal/api/handlers_tasks.go | 59 +++++-- internal/api/server.go | 5 + internal/backup/backup.go | 204 ++++++++++++++++++----- internal/backup/backup_test.go | 143 ++++++++++++---- internal/config/config.go | 265 +++++++++++++++++++++++++++--- internal/config/config_test.go | 161 ++++++++++++++++++ internal/pzserver/supervisor.go | 63 ++++--- web/static/app.js | 182 +++++++++++++++++++- web/static/index.html | 50 +++++- web/static/style.css | 34 ++++ 13 files changed, 1317 insertions(+), 183 deletions(-) create mode 100644 internal/api/handlers_profiles.go create mode 100644 internal/config/config_test.go diff --git a/README.md b/README.md index 551f7f1..3df3742 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ ## Что умеет +- **Профили серверов** — несколько независимых сборок (свой мир, конфиг, + песочница, моды и лимит памяти) с переключением одной кнопкой. - **Управление сервером** — запуск, остановка (через команду `quit`, мир сохраняется), перезапуск, автоподъём после падения, автозапуск вместе с менеджером. @@ -22,9 +24,9 @@ браузере, с поиском по параметрам и сохранением комментариев в файле. - **Моды** — списки Workshop ID и Mod ID с порядком загрузки, названия модов подтягиваются из мастерской Steam. -- **Бэкапы** — ручные и по расписанию, с ротацией, скачиванием и - восстановлением (перед восстановлением автоматически снимается страховочная - копия). +- **Бэкапы** — ручные и по расписанию, отдельно по каждому профилю, с + ротацией, скачиванием и восстановлением (перед восстановлением + автоматически снимается страховочная копия). - **Установка и обновление** сервера через SteamCMD прямо из панели. ## Как это устроено @@ -91,6 +93,34 @@ sudo ufw allow 16261/udp sudo ufw allow 16262/udp ``` +## Профили серверов + +Профиль — это отдельный сервер: свой мир, свой `*.ini`, свои настройки +песочницы, свой список модов и свой лимит памяти. Project Zomboid различает их +по параметру `-servername`, поэтому профили не мешают друг другу: + +``` +~/Zomboid/Server/vanilla.ini ~/Zomboid/Saves/Multiplayer/vanilla/ +~/Zomboid/Server/vanilla_SandboxVars.lua +~/Zomboid/Server/modded.ini ~/Zomboid/Saves/Multiplayer/modded/ +~/Zomboid/Server/modded_SandboxVars.lua +``` + +Работает всегда один профиль — активный: серверные файлы, игровые порты и файл +запуска JVM общие. Переключить профиль можно на остановленном сервере, выбрав +его в шапке панели или на вкладке «Профили»; после этого все вкладки — +конфиг, песочница, моды, бэкапы — относятся уже к нему. + +При создании профиля можно скопировать `.ini` и настройки песочницы из +существующего: удобно, когда нужен тот же набор правил, но чистый мир. + +Удаление профиля убирает его только из панели — мир и конфиги остаются на +диске. Чтобы вернуть профиль, создайте его с тем же идентификатором. + +Обновление с версии, где сервер был один, проходит само: старые поля +`server_name`, `admin_password` и `java_memory` превращаются в первый профиль +при первом запуске. + ## Доступ снаружи Панель управляет сервером, поэтому наружу её лучше не выставлять голой. Два @@ -148,10 +178,16 @@ data_dir: /home/pzserver/.local/share/pzmanager server_dir: /home/pzserver/pzserver # куда SteamCMD ставит сервер zomboid_dir: /home/pzserver/Zomboid # миры, конфиги и логи игры steamcmd_path: "" # пусто — найти в PATH или скачать -server_name: servertest # servertest -> servertest.ini -admin_password: "" # пароль админа игрового сервера -java_memory: 4g # -Xmx для JVM -extra_args: [] # доп. аргументы start-server.sh +profiles: # серверы: у каждого свой мир и настройки + - id: vanilla # vanilla -> vanilla.ini, Saves/Multiplayer/vanilla + title: Ванильный мир # как профиль подписан в панели + admin_password: "" # пароль админа игрового сервера + java_memory: 4g # -Xmx для JVM этого профиля + extra_args: [] # доп. аргументы start-server.sh + - id: modded + title: Сборка с модами + java_memory: 8g +active_profile: vanilla # какой профиль запускается и настраивается autostart: false # поднимать сервер вместе с менеджером autorestart: true # поднимать сервер после падения stop_timeout: 1m30s # сколько ждать после quit до SIGTERM @@ -159,7 +195,7 @@ log_buffer_lines: 5000 # строк консоли в памяти backup: dir: /home/pzserver/pzbackups schedule: 6h # 0 — отключить автобэкапы - keep: 20 # сколько архивов хранить + keep: 20 # архивов на профиль stop_server: false # останавливать сервер на время бэкапа ``` @@ -168,8 +204,10 @@ backup: ## Бэкапы -В архив попадают `~/Zomboid/Saves` и `~/Zomboid/Server` (миры и конфиги). Имя -файла — `pz-ГГГГММДД-ЧЧММСС[-пометка].tar.gz`. +В архив попадают мир активного профиля и его серверные конфиги. Имя файла — +`pz-профиль-ГГГГММДД-ЧЧММСС[-пометка].tar.gz`, так что архивы разных сборок не +путаются, а ротация (`backup.keep`) считается по каждому профилю отдельно. +Архивы, снятые до появления профилей, панель тоже читает и восстанавливает. Бэкап на живом сервере технически корректен, но снимок может отставать от состояния мира в памяти. Если хочется гарантированной согласованности — diff --git a/internal/api/handlers_config.go b/internal/api/handlers_config.go index 9d20a14..78a3f7a 100644 --- a/internal/api/handlers_config.go +++ b/internal/api/handlers_config.go @@ -111,27 +111,22 @@ func (s *Server) handlePutSandbox(w http.ResponseWriter, r *http.Request) { } // managerConfigDTO — вид конфига менеджера, удобный для формы в браузере: -// длительности в секундах, пароль наружу не отдаётся. +// длительности в секундах. Всё, что относится к конкретному серверу (имя, +// пароль админа, память JVM), живёт в профилях и правится отдельно. type managerConfigDTO struct { - Listen string `json:"listen"` - ServerDir string `json:"server_dir"` - ZomboidDir string `json:"zomboid_dir"` - SteamCMDPath string `json:"steamcmd_path"` - ServerName string `json:"server_name"` - JavaMemory string `json:"java_memory"` - ExtraArgs []string `json:"extra_args"` - AutoStart bool `json:"autostart"` - AutoRestart bool `json:"autorestart"` - StopTimeoutSec int64 `json:"stop_timeout_sec"` - LogBufferLines int `json:"log_buffer_lines"` - BackupDir string `json:"backup_dir"` - BackupScheduleMin int64 `json:"backup_schedule_min"` - BackupKeep int `json:"backup_keep"` - BackupStopServer bool `json:"backup_stop_server"` - HasAdminPassword bool `json:"has_admin_password"` - AdminPassword string `json:"admin_password,omitempty"` - ConfigPath string `json:"config_path"` - RequiresRestartMsg string `json:"requires_restart_msg,omitempty"` + Listen string `json:"listen"` + ServerDir string `json:"server_dir"` + ZomboidDir string `json:"zomboid_dir"` + SteamCMDPath string `json:"steamcmd_path"` + AutoStart bool `json:"autostart"` + AutoRestart bool `json:"autorestart"` + StopTimeoutSec int64 `json:"stop_timeout_sec"` + LogBufferLines int `json:"log_buffer_lines"` + BackupDir string `json:"backup_dir"` + BackupScheduleMin int64 `json:"backup_schedule_min"` + BackupKeep int `json:"backup_keep"` + BackupStopServer bool `json:"backup_stop_server"` + ConfigPath string `json:"config_path"` } func (s *Server) handleGetManagerConfig(w http.ResponseWriter, r *http.Request) { @@ -141,9 +136,6 @@ func (s *Server) handleGetManagerConfig(w http.ResponseWriter, r *http.Request) ServerDir: c.ServerDir, ZomboidDir: c.ZomboidDir, SteamCMDPath: c.SteamCMDPath, - ServerName: c.ServerName, - JavaMemory: c.JavaMemory, - ExtraArgs: c.ExtraArgs, AutoStart: c.AutoStart, AutoRestart: c.AutoRestart, StopTimeoutSec: c.StopTimeout.Seconds(), @@ -152,14 +144,11 @@ func (s *Server) handleGetManagerConfig(w http.ResponseWriter, r *http.Request) BackupScheduleMin: int64(c.Backup.Schedule.Duration().Minutes()), BackupKeep: c.Backup.Keep, BackupStopServer: c.Backup.StopServer, - HasAdminPassword: c.AdminPassword != "", ConfigPath: c.Path(), }) } -// handlePutManagerConfig сохраняет настройки менеджера. Пустой admin_password -// означает "оставить прежний", иначе его нельзя было бы не потерять при -// каждом сохранении формы. +// handlePutManagerConfig сохраняет общие настройки менеджера. func (s *Server) handlePutManagerConfig(w http.ResponseWriter, r *http.Request) { var dto managerConfigDTO if !decodeJSON(w, r, &dto) { @@ -171,13 +160,6 @@ func (s *Server) handlePutManagerConfig(w http.ResponseWriter, r *http.Request) c.ServerDir = dto.ServerDir c.ZomboidDir = dto.ZomboidDir c.SteamCMDPath = dto.SteamCMDPath - c.ServerName = dto.ServerName - c.JavaMemory = dto.JavaMemory - // Пустой список аргументов от формы означает "не трогать": ExtraArgs - // правятся только через файл конфигурации. - if dto.ExtraArgs != nil { - c.ExtraArgs = dto.ExtraArgs - } c.AutoStart = dto.AutoStart c.AutoRestart = dto.AutoRestart c.LogBufferLines = dto.LogBufferLines @@ -188,9 +170,6 @@ func (s *Server) handlePutManagerConfig(w http.ResponseWriter, r *http.Request) c.StopTimeout = config.Duration(time.Duration(dto.StopTimeoutSec) * time.Second) } c.Backup.Schedule = config.Duration(time.Duration(dto.BackupScheduleMin) * time.Minute) - if dto.AdminPassword != "" { - c.AdminPassword = dto.AdminPassword - } if err := c.Save(); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) diff --git a/internal/api/handlers_profiles.go b/internal/api/handlers_profiles.go new file mode 100644 index 0000000..50576ef --- /dev/null +++ b/internal/api/handlers_profiles.go @@ -0,0 +1,223 @@ +package api + +import ( + "fmt" + "io" + "net/http" + "os" + + "github.com/mrleo1nid/pzmanager/internal/config" + "github.com/mrleo1nid/pzmanager/internal/pzserver" +) + +// profileDTO — профиль в том виде, в каком его показывает панель. Пароль +// наружу не отдаётся, только признак того, что он задан. +type profileDTO struct { + ID string `json:"id"` + Title string `json:"title"` + JavaMemory string `json:"java_memory"` + ExtraArgs []string `json:"extra_args"` + HasAdminPassword bool `json:"has_admin_password"` + AdminPassword string `json:"admin_password,omitempty"` + // Признаки состояния на диске: по ним панель подсказывает, что профиль + // ещё ни разу не запускался. + HasConfig bool `json:"has_config"` + WorldReady bool `json:"world_ready"` + Active bool `json:"active"` + // CopyFrom заполняется только при создании профиля. + CopyFrom string `json:"copy_from,omitempty"` +} + +func (s *Server) profileToDTO(p config.Profile) profileDTO { + return profileDTO{ + ID: p.ID, + Title: p.Title, + JavaMemory: p.JavaMemory, + ExtraArgs: p.ExtraArgs, + HasAdminPassword: p.AdminPassword != "", + HasConfig: fileExists(s.cfg.ServerConfigPathFor(p.ID)), + WorldReady: fileExists(s.cfg.WorldDirFor(p.ID)), + Active: p.ID == s.cfg.Active, + } +} + +func (s *Server) handleListProfiles(w http.ResponseWriter, r *http.Request) { + profiles := make([]profileDTO, 0, len(s.cfg.Profiles)) + for _, p := range s.cfg.Profiles { + profiles = append(profiles, s.profileToDTO(p)) + } + writeJSON(w, http.StatusOK, map[string]any{ + "profiles": profiles, + "active": s.cfg.Active, + // Переключать профиль можно только на остановленном сервере. + "can_switch": s.sup.State() == pzserver.StateStopped, + }) +} + +// handleCreateProfile заводит новый профиль и, если попросили, копирует в него +// конфиг и настройки песочницы существующего. +func (s *Server) handleCreateProfile(w http.ResponseWriter, r *http.Request) { + var dto profileDTO + if !decodeJSON(w, r, &dto) { + return + } + + profile := config.Profile{ + ID: dto.ID, + Title: dto.Title, + JavaMemory: dto.JavaMemory, + ExtraArgs: dto.ExtraArgs, + AdminPassword: dto.AdminPassword, + } + if err := s.cfg.AddProfile(profile); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if dto.CopyFrom != "" { + if err := s.copyProfileConfigs(dto.CopyFrom, profile.ID); err != nil { + // Профиль уже добавлен в память — откатываем, чтобы не остался + // наполовину настроенный. + _ = s.cfg.DeleteProfile(profile.ID) + writeError(w, http.StatusBadRequest, err.Error()) + return + } + } + + if err := s.cfg.Save(); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + s.sup.AppendLog("manager", fmt.Sprintf("Создан профиль %q", profile.Name())) + writeJSON(w, http.StatusCreated, s.profileToDTO(profile)) +} + +// copyProfileConfigs дублирует .ini и SandboxVars.lua исходного профиля под +// именем нового: так новый сервер стартует с уже знакомыми настройками и +// списком модов, но со своим миром. +func (s *Server) copyProfileConfigs(from, to string) error { + if _, ok := s.cfg.Profile(from); !ok { + return fmt.Errorf("профиль-источник %q не найден", from) + } + pairs := [][2]string{ + {s.cfg.ServerConfigPathFor(from), s.cfg.ServerConfigPathFor(to)}, + {s.cfg.SandboxConfigPathFor(from), s.cfg.SandboxConfigPathFor(to)}, + } + copied := 0 + for _, pair := range pairs { + err := copyFile(pair[0], pair[1]) + if os.IsNotExist(err) { + continue + } + if err != nil { + return fmt.Errorf("скопировать настройки: %w", err) + } + copied++ + } + if copied == 0 { + return fmt.Errorf("у профиля %q ещё нет файлов настроек — сначала запустите его хотя бы раз", from) + } + s.sup.AppendLog("manager", fmt.Sprintf("Настройки профиля %q скопированы в %q", from, to)) + return nil +} + +func (s *Server) handleUpdateProfile(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + current, ok := s.cfg.Profile(id) + if !ok { + writeError(w, http.StatusNotFound, "профиль не найден") + return + } + + var dto profileDTO + if !decodeJSON(w, r, &dto) { + return + } + + updated := config.Profile{ + ID: id, + Title: dto.Title, + JavaMemory: dto.JavaMemory, + ExtraArgs: dto.ExtraArgs, + // Пустой пароль означает "оставить прежний": наружу мы его не отдаём, + // и форма присылает пустую строку, пока его не меняли. + AdminPassword: current.AdminPassword, + } + if dto.AdminPassword != "" { + updated.AdminPassword = dto.AdminPassword + } + + if err := s.cfg.UpdateProfile(id, updated); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := s.cfg.Save(); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + + note := "Профиль сохранён" + if s.sup.State() != pzserver.StateStopped && id == s.cfg.Active { + note = "Профиль сохранён. Память и аргументы запуска применятся после перезапуска сервера." + } + writeJSON(w, http.StatusOK, map[string]any{"saved": true, "note": note}) +} + +func (s *Server) handleDeleteProfile(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if err := s.cfg.DeleteProfile(id); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := s.cfg.Save(); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + s.sup.AppendLog("manager", fmt.Sprintf( + "Профиль %q удалён из панели. Мир и конфиги остались на диске: %s", + id, s.cfg.WorldDirFor(id))) + writeJSON(w, http.StatusOK, map[string]any{"deleted": id}) +} + +// handleActivateProfile переключает активный профиль. Делать это на живом +// сервере нельзя: профили делят игровые порты и файлы запуска. +func (s *Server) handleActivateProfile(w http.ResponseWriter, r *http.Request) { + if s.sup.State() != pzserver.StateStopped { + writeError(w, http.StatusConflict, "сначала остановите игровой сервер") + return + } + id := r.PathValue("id") + if err := s.cfg.SetActive(id); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := s.cfg.Save(); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + s.sup.AppendLog("manager", fmt.Sprintf("Активный профиль: %s", s.cfg.ActiveProfile().Name())) + writeJSON(w, http.StatusOK, map[string]any{"active": id}) +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + // O_EXCL: перезаписывать чужой конфиг при создании профиля нельзя. + out, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + return err +} + +func fileExists(p string) bool { + _, err := os.Stat(p) + return err == nil +} diff --git a/internal/api/handlers_tasks.go b/internal/api/handlers_tasks.go index 4d2c8be..42e39b8 100644 --- a/internal/api/handlers_tasks.go +++ b/internal/api/handlers_tasks.go @@ -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 } diff --git a/internal/api/server.go b/internal/api/server.go index 566ff89..e911734 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -95,6 +95,11 @@ func (s *Server) Handler() http.Handler { "PUT /api/config/sandbox": s.handlePutSandbox, "GET /api/config/manager": s.handleGetManagerConfig, "PUT /api/config/manager": s.handlePutManagerConfig, + "GET /api/profiles": s.handleListProfiles, + "POST /api/profiles": s.handleCreateProfile, + "PUT /api/profiles/{id}": s.handleUpdateProfile, + "DELETE /api/profiles/{id}": s.handleDeleteProfile, + "POST /api/profiles/{id}/activate": s.handleActivateProfile, "GET /api/mods": s.handleGetMods, "PUT /api/mods": s.handlePutMods, "POST /api/mods/lookup": s.handleLookupMods, diff --git a/internal/backup/backup.go b/internal/backup/backup.go index f605d9f..99f6792 100644 --- a/internal/backup/backup.go +++ b/internal/backup/backup.go @@ -1,5 +1,5 @@ // Package backup архивирует миры и конфиги Project Zomboid и восстанавливает -// их обратно. +// их обратно. Каждый архив относится к одному профилю сервера. package backup import ( @@ -22,13 +22,15 @@ import ( // Archive — один файл бэкапа на диске. type Archive struct { Name string `json:"name"` + Profile string `json:"profile"` SizeBytes int64 `json:"size_bytes"` CreatedAt int64 `json:"created_at"` } -// nameRe ограничивает имена архивов, которые принимает API: имя приходит от -// клиента и используется как путь. -var nameRe = regexp.MustCompile(`^pz-[0-9]{8}-[0-9]{6}(-[a-zA-Z0-9_-]+)?\.tar\.gz$`) +// nameRe разбирает имена архивов: имя приходит от клиента и используется как +// путь, поэтому набор символов жёстко ограничен. Часть с профилем +// необязательна — так читаются архивы, снятые до появления профилей. +var nameRe = regexp.MustCompile(`^pz-(?:([A-Za-z0-9_-]+)-)?[0-9]{8}-[0-9]{6}(?:-[a-zA-Z0-9_-]+)?\.tar\.gz$`) // Manager владеет директорией с бэкапами. type Manager struct { @@ -53,7 +55,11 @@ func (m *Manager) List() ([]Archive, error) { out := make([]Archive, 0, len(entries)) for _, e := range entries { - if e.IsDir() || !nameRe.MatchString(e.Name()) { + if e.IsDir() { + continue + } + match := nameRe.FindStringSubmatch(e.Name()) + if match == nil { continue } info, err := e.Info() @@ -62,6 +68,7 @@ func (m *Manager) List() ([]Archive, error) { } out = append(out, Archive{ Name: e.Name(), + Profile: match[1], SizeBytes: info.Size(), CreatedAt: info.ModTime().Unix(), }) @@ -70,6 +77,21 @@ func (m *Manager) List() ([]Archive, error) { return out, nil } +// ListForProfile отбирает архивы одного профиля. +func (m *Manager) ListForProfile(profileID string) ([]Archive, error) { + all, err := m.List() + if err != nil { + return nil, err + } + out := make([]Archive, 0, len(all)) + for _, a := range all { + if a.Profile == profileID { + out = append(out, a) + } + } + return out, nil +} + // Path возвращает полный путь к архиву, проверив имя. func (m *Manager) Path(name string) (string, error) { if !nameRe.MatchString(name) { @@ -78,20 +100,34 @@ func (m *Manager) Path(name string) (string, error) { return filepath.Join(m.cfg.Backup.Dir, name), nil } -// Create собирает архив из Saves и серверных конфигов. Суффикс note попадает в -// имя файла, чтобы ручные бэкапы было легко отличить. -func (m *Manager) Create(ctx context.Context, log func(string), note string) (Archive, error) { +// ProfileOf возвращает профиль, которому принадлежит архив. Для архивов, +// снятых до появления профилей, возвращает пустую строку. +func (m *Manager) ProfileOf(name string) string { + match := nameRe.FindStringSubmatch(name) + if match == nil { + return "" + } + return match[1] +} + +// Create собирает архив мира профиля и его серверных конфигов. Суффикс note +// попадает в имя файла, чтобы ручные бэкапы было легко отличить. +func (m *Manager) Create(ctx context.Context, log func(string), profileID, note string) (Archive, error) { + if err := config.ValidateProfileID(profileID); err != nil { + return Archive{}, err + } if err := os.MkdirAll(m.cfg.Backup.Dir, 0o755); err != nil { return Archive{}, err } - name := fmt.Sprintf("pz-%s", time.Now().Format("20060102-150405")) + + name := fmt.Sprintf("pz-%s-%s", profileID, time.Now().Format("20060102-150405")) if note = sanitizeNote(note); note != "" { name += "-" + note } name += ".tar.gz" target := filepath.Join(m.cfg.Backup.Dir, name) - log("Создаю архив " + name) + log(fmt.Sprintf("Создаю архив %s (профиль %s)", name, profileID)) // Пишем во временный файл: недописанный архив не должен попасть в список. tmp := target + ".part" f, err := os.Create(tmp) @@ -102,22 +138,35 @@ func (m *Manager) Create(ctx context.Context, log func(string), note string) (Ar gz := gzip.NewWriter(f) tw := tar.NewWriter(gz) + // abort закрывает всё начатое и убирает недописанный файл. + abort := func(err error) (Archive, error) { + tw.Close() + gz.Close() + f.Close() + os.Remove(tmp) + return Archive{}, err + } + var written int64 - sources := []string{m.cfg.SavesDir(), filepath.Join(m.cfg.ZomboidDir, "Server")} - for _, src := range sources { - if _, err := os.Stat(src); err != nil { - log("Пропускаю " + src + " — нет такой директории") - continue - } - n, err := addTree(ctx, tw, m.cfg.ZomboidDir, src, log) + if world := m.cfg.WorldDirFor(profileID); dirExists(world) { + n, err := addTree(ctx, tw, m.cfg.ZomboidDir, world, log) written += n if err != nil { - tw.Close() - gz.Close() - f.Close() - os.Remove(tmp) - return Archive{}, err + return abort(err) } + } else { + log("Мир ещё не создан — архивирую только конфиги") + } + + configs, err := profileConfigFiles(m.cfg.ServerDirPath(), profileID) + if err != nil { + return abort(err) + } + for _, file := range configs { + if err := addFile(tw, m.cfg.ZomboidDir, file); err != nil { + return abort(err) + } + written++ } if err := tw.Close(); err != nil { @@ -145,19 +194,86 @@ func (m *Manager) Create(ctx context.Context, log func(string), note string) (Ar } log(fmt.Sprintf("Архив готов: %s (%s, файлов: %d)", name, humanSize(info.Size()), written)) - if err := m.Prune(m.cfg.Backup.Keep, log); err != nil { + if err := m.Prune(profileID, m.cfg.Backup.Keep, log); err != nil { log("Ротация не удалась: " + err.Error()) } - return Archive{Name: name, SizeBytes: info.Size(), CreatedAt: info.ModTime().Unix()}, nil + return Archive{ + Name: name, + Profile: profileID, + SizeBytes: info.Size(), + CreatedAt: info.ModTime().Unix(), + }, nil +} + +// profileConfigFiles находит серверные файлы профиля: .ini, +// _SandboxVars.lua, _spawnpoints.lua и прочие, которые создаёт игра. +// Разделитель после префикса проверяется явно: иначе профиль "test" утащил бы +// файлы профиля "test2". +func profileConfigFiles(serverDir, profileID string) ([]string, error) { + entries, err := os.ReadDir(serverDir) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + + var out []string + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if strings.HasSuffix(name, ".bak") || strings.HasSuffix(name, ".tmp") { + continue + } + rest, ok := strings.CutPrefix(name, profileID) + if !ok || rest == "" { + continue + } + if rest[0] != '.' && rest[0] != '_' { + continue + } + out = append(out, filepath.Join(serverDir, name)) + } + return out, nil +} + +// addFile кладёт один файл в архив, сохраняя путь относительно root. +func addFile(tw *tar.Writer, root, filePath string) error { + info, err := os.Stat(filePath) + if err != nil { + return err + } + rel, err := filepath.Rel(root, filePath) + if err != nil { + return err + } + hdr, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + hdr.Name = filepath.ToSlash(rel) + if err := tw.WriteHeader(hdr); err != nil { + return err + } + + f, err := os.Open(filePath) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(tw, f) + return err } // addTree кладёт директорию в архив, сохраняя пути относительно root. func addTree(ctx context.Context, tw *tar.Writer, root, dir string, log func(string)) (int64, error) { var count int64 - err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + err := filepath.Walk(dir, func(filePath string, info os.FileInfo, err error) error { if err != nil { // Файл мог исчезнуть, пока идёт обход, — это не повод падать. - log("Пропускаю " + path + ": " + err.Error()) + log("Пропускаю " + filePath + ": " + err.Error()) return nil } select { @@ -169,7 +285,7 @@ func addTree(ctx context.Context, tw *tar.Writer, root, dir string, log func(str return nil } - rel, err := filepath.Rel(root, path) + rel, err := filepath.Rel(root, filePath) if err != nil { return err } @@ -188,7 +304,7 @@ func addTree(ctx context.Context, tw *tar.Writer, root, dir string, log func(str return nil } - f, err := os.Open(path) + f, err := os.Open(filePath) if err != nil { // Блокировка файла сервером не должна ронять весь бэкап. log("Не прочитан " + rel + ": " + err.Error()) @@ -204,14 +320,16 @@ func addTree(ctx context.Context, tw *tar.Writer, root, dir string, log func(str return count, err } -// Restore распаковывает архив поверх ~/Zomboid. Сервер должен быть остановлен — -// это проверяет вызывающий код. +// Restore распаковывает архив поверх ~/Zomboid. Пути внутри архива уже +// содержат имя профиля, поэтому восстановление не зависит от того, какой +// профиль сейчас активен. Сервер должен быть остановлен — это проверяет +// вызывающий код. func (m *Manager) Restore(ctx context.Context, log func(string), name string) error { - path, err := m.Path(name) + archivePath, err := m.Path(name) if err != nil { return err } - f, err := os.Open(path) + f, err := os.Open(archivePath) if err != nil { return err } @@ -272,23 +390,28 @@ func (m *Manager) Restore(ctx context.Context, log func(string), name string) er // Delete удаляет архив. func (m *Manager) Delete(name string) error { - path, err := m.Path(name) + archivePath, err := m.Path(name) if err != nil { return err } - return os.Remove(path) + return os.Remove(archivePath) } -// Prune оставляет только keep самых свежих архивов (keep <= 0 — не трогать). -func (m *Manager) Prune(keep int, log func(string)) error { +// Prune оставляет только keep самых свежих архивов профиля (keep <= 0 — не +// трогать). Ротация идёт по профилям отдельно: иначе частые бэкапы активного +// сервера вытеснили бы архивы остальных. +func (m *Manager) Prune(profileID string, keep int, log func(string)) error { if keep <= 0 { return nil } - archives, err := m.List() + archives, err := m.ListForProfile(profileID) if err != nil { return err } - for _, a := range archives[min(keep, len(archives)):] { + if len(archives) <= keep { + return nil + } + for _, a := range archives[keep:] { if err := m.Delete(a.Name); err != nil { return err } @@ -332,6 +455,11 @@ func sanitizeNote(note string) string { return strings.Trim(b.String(), "_-") } +func dirExists(p string) bool { + info, err := os.Stat(p) + return err == nil && info.IsDir() +} + func humanSize(n int64) string { const unit = 1024 if n < unit { diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go index c775bdd..3d50054 100644 --- a/internal/backup/backup_test.go +++ b/internal/backup/backup_test.go @@ -39,59 +39,114 @@ func TestSanitizeNote(t *testing.T) { } } -func TestNameValidation(t *testing.T) { +func TestNameValidationAndProfileParsing(t *testing.T) { m := New(&config.Config{}) + if _, err := m.Path("../../etc/passwd"); err == nil { t.Error("имя с переходом по каталогам принято") } - if _, err := m.Path("pz-20250903-181500-auto.tar.gz"); err != nil { + if _, err := m.Path("pz-vanilla-20260904-181500-auto.tar.gz"); err != nil { t.Errorf("корректное имя отклонено: %v", err) } + // Архивы, снятые до появления профилей, тоже должны читаться. + if _, err := m.Path("pz-20250903-214141.tar.gz"); err != nil { + t.Errorf("архив старого формата отклонён: %v", err) + } + + cases := map[string]string{ + "pz-vanilla-20260904-181500-auto.tar.gz": "vanilla", + "pz-modded_b42-20260904-181500.tar.gz": "modded_b42", + "pz-20250903-214141.tar.gz": "", + "не-архив.tar.gz": "", + } + for name, want := range cases { + if got := m.ProfileOf(name); got != want { + t.Errorf("ProfileOf(%q) = %q, ожидалось %q", name, got, want) + } + } } -// TestCreateAndRestore проверяет полный цикл: архивируем дерево, портим файл, -// восстанавливаем и убеждаемся, что содержимое вернулось. +// TestProfileConfigFiles проверяет, что профиль забирает только свои файлы: +// префиксы имён профилей часто пересекаются. +func TestProfileConfigFiles(t *testing.T) { + dir := t.TempDir() + files := []string{ + "test.ini", "test_SandboxVars.lua", "test_spawnpoints.lua", + "test2.ini", "test2_SandboxVars.lua", + "testing.ini", "test.ini.bak", "other.ini", + } + for _, name := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + + got, err := profileConfigFiles(dir, "test") + if err != nil { + t.Fatal(err) + } + want := map[string]bool{ + "test.ini": true, "test_SandboxVars.lua": true, "test_spawnpoints.lua": true, + } + if len(got) != len(want) { + t.Fatalf("выбрано %d файлов, ожидалось %d: %v", len(got), len(want), got) + } + for _, path := range got { + if !want[filepath.Base(path)] { + t.Errorf("лишний файл в архиве профиля: %s", filepath.Base(path)) + } + } +} + +// TestCreateAndRestore проверяет полный цикл для одного профиля: архивируем +// мир и конфиг, портим их, восстанавливаем и убеждаемся, что вернулось +// содержимое именно этого профиля. func TestCreateAndRestore(t *testing.T) { root := t.TempDir() zomboid := filepath.Join(root, "Zomboid") - savePath := filepath.Join(zomboid, "Saves", "Multiplayer", "world.bin") + world := filepath.Join(zomboid, "Saves", "Multiplayer", "vanilla", "map_t.bin") + iniPath := filepath.Join(zomboid, "Server", "vanilla.ini") + otherWorld := filepath.Join(zomboid, "Saves", "Multiplayer", "modded", "map_t.bin") - if err := os.MkdirAll(filepath.Dir(savePath), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(savePath, []byte("исходный мир"), 0o644); err != nil { - t.Fatal(err) - } + writeFile(t, world, "исходный мир") + writeFile(t, iniPath, "MaxPlayers=32") + writeFile(t, otherWorld, "чужой мир") cfg := &config.Config{ZomboidDir: zomboid} cfg.Backup.Dir = filepath.Join(root, "backups") m := New(cfg) - archive, err := m.Create(context.Background(), func(string) {}, "test") + archive, err := m.Create(context.Background(), func(string) {}, "vanilla", "test") if err != nil { t.Fatalf("Create: %v", err) } - if archive.SizeBytes == 0 { - t.Error("архив пустой") + if archive.Profile != "vanilla" { + t.Errorf("профиль архива = %q", archive.Profile) } - if err := os.WriteFile(savePath, []byte("испорчено"), 0o644); err != nil { - t.Fatal(err) - } + writeFile(t, world, "испорчено") + writeFile(t, iniPath, "MaxPlayers=1") + writeFile(t, otherWorld, "чужой мир изменён") + if err := m.Restore(context.Background(), func(string) {}, archive.Name); err != nil { t.Fatalf("Restore: %v", err) } - restored, err := os.ReadFile(savePath) - if err != nil { - t.Fatal(err) + if got := readFile(t, world); got != "исходный мир" { + t.Errorf("мир после восстановления = %q", got) } - if string(restored) != "исходный мир" { - t.Errorf("после восстановления содержимое = %q", restored) + if got := readFile(t, iniPath); got != "MaxPlayers=32" { + t.Errorf("конфиг после восстановления = %q", got) + } + // Мир другого профиля восстановление трогать не должно. + if got := readFile(t, otherWorld); got != "чужой мир изменён" { + t.Errorf("затронут мир чужого профиля: %q", got) } } -func TestPruneKeepsNewest(t *testing.T) { +// TestPruneIsPerProfile проверяет, что ротация не выкидывает архивы соседних +// профилей: у каждого профиля своя история. +func TestPruneIsPerProfile(t *testing.T) { root := t.TempDir() cfg := &config.Config{ZomboidDir: filepath.Join(root, "Zomboid")} cfg.Backup.Dir = filepath.Join(root, "backups") @@ -101,24 +156,52 @@ func TestPruneKeepsNewest(t *testing.T) { t.Fatal(err) } names := []string{ - "pz-20250101-000000.tar.gz", - "pz-20250102-000000.tar.gz", - "pz-20250103-000000.tar.gz", + "pz-vanilla-20260101-000000.tar.gz", + "pz-vanilla-20260102-000000.tar.gz", + "pz-vanilla-20260103-000000.tar.gz", + "pz-modded-20260101-000000.tar.gz", } for _, name := range names { writeEmptyArchive(t, filepath.Join(cfg.Backup.Dir, name)) } - if err := m.Prune(2, func(string) {}); err != nil { + if err := m.Prune("vanilla", 2, func(string) {}); err != nil { t.Fatal(err) } - left, err := m.List() + + vanilla, err := m.ListForProfile("vanilla") if err != nil { t.Fatal(err) } - if len(left) != 2 { - t.Fatalf("осталось %d архивов, ожидалось 2: %v", len(left), left) + if len(vanilla) != 2 { + t.Errorf("у vanilla осталось %d архивов, ожидалось 2", len(vanilla)) } + modded, err := m.ListForProfile("modded") + if err != nil { + t.Fatal(err) + } + if len(modded) != 1 { + t.Errorf("ротация зацепила архивы профиля modded: осталось %d", len(modded)) + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) } // writeEmptyArchive создаёт валидный пустой tar.gz — List проверяет имя, а не diff --git a/internal/config/config.go b/internal/config/config.go index 03a6c8d..157112a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,12 +5,56 @@ import ( "fmt" "os" "path/filepath" + "regexp" + "strings" "time" "gopkg.in/yaml.v3" ) -// Config — всё, что менеджеру нужно знать о хосте и о сервере PZ. +// profileIDRe ограничивает идентификатор профиля: он становится именем файлов +// PZ (servertest.ini, servertest_SandboxVars.lua) и частью пути к миру. +var profileIDRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,32}$`) + +// Profile — один игровой сервер: собственный мир, конфиг, набор модов и +// параметры запуска. ID профиля — это -servername для Project Zomboid. +type Profile struct { + // ID — имя набора конфигов PZ: servertest -> servertest.ini, мир лежит в + // Saves/Multiplayer/servertest. + ID string `yaml:"id"` + + // Title — понятное человеку название для веб-панели. + Title string `yaml:"title"` + + // AdminPassword — пароль админа игрового сервера, нужен при первом + // запуске PZ. Хранится здесь, чтобы не вводить его в консоли. + AdminPassword string `yaml:"admin_password"` + + // JavaMemory — -Xmx для JVM, например "8g". Применяется при каждом + // запуске: файл запуска сервера общий для всех профилей. + JavaMemory string `yaml:"java_memory"` + + // ExtraArgs — дополнительные аргументы start-server.sh. + ExtraArgs []string `yaml:"extra_args"` +} + +// Name возвращает название для интерфейса: заголовок, а если его нет — ID. +func (p Profile) Name() string { + if p.Title != "" { + return p.Title + } + return p.ID +} + +// ValidateProfileID проверяет идентификатор, приходящий из веб-панели. +func ValidateProfileID(id string) error { + if !profileIDRe.MatchString(id) { + return fmt.Errorf("идентификатор %q недопустим: разрешены латинские буквы, цифры, дефис и подчёркивание (до 32 символов)", id) + } + return nil +} + +// Config — всё, что менеджеру нужно знать о хосте и о серверах PZ. type Config struct { // Listen — адрес веб-панели. Для доступа только через SSH-туннель // используйте 127.0.0.1:8080. @@ -19,7 +63,8 @@ type Config struct { // DataDir — рабочая директория менеджера (пользователи, сессии, бэкапы). DataDir string `yaml:"data_dir"` - // ServerDir — куда SteamCMD ставит серверные файлы PZ. + // ServerDir — куда SteamCMD ставит серверные файлы PZ. Общий для всех + // профилей: сама игра одна, различаются только конфиги и миры. ServerDir string `yaml:"server_dir"` // ZomboidDir — ~/Zomboid: сохранения, конфиги, логи. @@ -28,18 +73,12 @@ type Config struct { // SteamCMDPath — путь к бинарю steamcmd.sh (пустой = ищем в PATH). SteamCMDPath string `yaml:"steamcmd_path"` - // ServerName — имя набора конфигов PZ (servertest -> servertest.ini). - ServerName string `yaml:"server_name"` + // Profiles — сконфигурированные серверы. Одновременно работает только + // один: они делят серверные файлы и игровые порты. + Profiles []Profile `yaml:"profiles"` - // AdminPassword — пароль админа игрового сервера, нужен при первом - // запуске PZ. Хранится здесь, чтобы не вводить его в консоли. - AdminPassword string `yaml:"admin_password"` - - // JavaMemory — -Xmx для JVM сервера, например "8g". - JavaMemory string `yaml:"java_memory"` - - // ExtraArgs — дополнительные аргументы запуска сервера. - ExtraArgs []string `yaml:"extra_args"` + // Active — ID профиля, который запускается и настраивается сейчас. + Active string `yaml:"active_profile"` // AutoStart — поднимать игровой сервер сразу при старте менеджера. AutoStart bool `yaml:"autostart"` @@ -56,6 +95,13 @@ type Config struct { // Backup — настройки резервного копирования. Backup BackupConfig `yaml:"backup"` + // Поля ниже остались от версии с единственным сервером. При загрузке они + // превращаются в первый профиль и больше не записываются в файл. + LegacyServerName string `yaml:"server_name,omitempty"` + LegacyAdminPassword string `yaml:"admin_password,omitempty"` + LegacyJavaMemory string `yaml:"java_memory,omitempty"` + LegacyExtraArgs []string `yaml:"extra_args,omitempty"` + path string } @@ -65,13 +111,23 @@ type BackupConfig struct { Dir string `yaml:"dir"` // Schedule — интервал автобэкапа; 0 отключает автоматику. Schedule Duration `yaml:"schedule"` - // Keep — сколько последних архивов хранить (0 = не удалять). + // Keep — сколько последних архивов хранить для каждого профиля + // (0 = не удалять). Keep int `yaml:"keep"` // StopServer — останавливать сервер на время бэкапа. Безопаснее, но // игроков выкидывает; по умолчанию выключено. StopServer bool `yaml:"stop_server"` } +// DefaultProfile — профиль, который создаётся на чистой установке. +func DefaultProfile() Profile { + return Profile{ + ID: "servertest", + Title: "Основной сервер", + JavaMemory: "4g", + } +} + // Default возвращает конфиг с разумными значениями для Ubuntu. func Default() *Config { home, _ := os.UserHomeDir() @@ -80,8 +136,8 @@ func Default() *Config { DataDir: filepath.Join(home, ".local", "share", "pzmanager"), ServerDir: filepath.Join(home, "pzserver"), ZomboidDir: filepath.Join(home, "Zomboid"), - ServerName: "servertest", - JavaMemory: "4g", + Profiles: []Profile{DefaultProfile()}, + Active: DefaultProfile().ID, AutoRestart: true, StopTimeout: Duration(90 * time.Second), LogBufferLines: 5000, @@ -109,13 +165,58 @@ func Load(path string) (*Config, error) { if err != nil { return nil, fmt.Errorf("прочитать конфиг %s: %w", path, err) } + + // Профили из файла должны заменить умолчания, а не дополнить их. + cfg.Profiles = nil if err := yaml.Unmarshal(raw, cfg); err != nil { return nil, fmt.Errorf("разобрать конфиг %s: %w", path, err) } + + migrated := cfg.migrateLegacy() cfg.applyFallbacks() + if migrated { + // Сразу переписываем файл, чтобы старые поля не остались висеть + // рядом с новым списком профилей и не путали при правке руками. + if err := cfg.Save(); err != nil { + return nil, fmt.Errorf("обновить конфиг %s: %w", path, err) + } + } return cfg, nil } +// migrateLegacy превращает настройки единственного сервера в первый профиль. +// Возвращает true, если конфиг пришлось поменять. +func (c *Config) migrateLegacy() bool { + hasLegacy := c.LegacyServerName != "" || c.LegacyAdminPassword != "" || + c.LegacyJavaMemory != "" || len(c.LegacyExtraArgs) > 0 + if !hasLegacy { + return false + } + + profile := Profile{ + ID: c.LegacyServerName, + AdminPassword: c.LegacyAdminPassword, + JavaMemory: c.LegacyJavaMemory, + ExtraArgs: c.LegacyExtraArgs, + } + if profile.ID == "" { + profile.ID = DefaultProfile().ID + } + profile.Title = "Основной сервер" + + // Если профили уже описаны, старые поля просто отбрасываем: список + // профилей — источник истины. + if len(c.Profiles) == 0 { + c.Profiles = []Profile{profile} + c.Active = profile.ID + } + c.LegacyServerName = "" + c.LegacyAdminPassword = "" + c.LegacyJavaMemory = "" + c.LegacyExtraArgs = nil + return true +} + // applyFallbacks подставляет умолчания вместо пустых значений: конфиг, // написанный руками, редко заполнен целиком. func (c *Config) applyFallbacks() { @@ -132,12 +233,6 @@ func (c *Config) applyFallbacks() { if c.ZomboidDir == "" { c.ZomboidDir = d.ZomboidDir } - if c.ServerName == "" { - c.ServerName = d.ServerName - } - if c.JavaMemory == "" { - c.JavaMemory = d.JavaMemory - } if c.StopTimeout <= 0 { c.StopTimeout = d.StopTimeout } @@ -147,6 +242,102 @@ func (c *Config) applyFallbacks() { if c.Backup.Dir == "" { c.Backup.Dir = d.Backup.Dir } + if len(c.Profiles) == 0 { + c.Profiles = []Profile{DefaultProfile()} + } + for i := range c.Profiles { + if c.Profiles[i].JavaMemory == "" { + c.Profiles[i].JavaMemory = DefaultProfile().JavaMemory + } + } + // Активный профиль мог быть удалён из файла руками. + if _, ok := c.Profile(c.Active); !ok { + c.Active = c.Profiles[0].ID + } +} + +// Profile возвращает профиль по идентификатору. +func (c *Config) Profile(id string) (Profile, bool) { + for _, p := range c.Profiles { + if p.ID == id { + return p, true + } + } + return Profile{}, false +} + +// ActiveProfile возвращает профиль, который сейчас запускается и правится. +func (c *Config) ActiveProfile() Profile { + if p, ok := c.Profile(c.Active); ok { + return p + } + // До сюда доходить не должно: applyFallbacks чинит ссылку на активный + // профиль, а список никогда не бывает пустым. + if len(c.Profiles) > 0 { + return c.Profiles[0] + } + return DefaultProfile() +} + +// AddProfile добавляет новый профиль. +func (c *Config) AddProfile(p Profile) error { + if err := ValidateProfileID(p.ID); err != nil { + return err + } + if _, exists := c.Profile(p.ID); exists { + return fmt.Errorf("профиль %q уже существует", p.ID) + } + if p.JavaMemory == "" { + p.JavaMemory = DefaultProfile().JavaMemory + } + p.Title = strings.TrimSpace(p.Title) + c.Profiles = append(c.Profiles, p) + return nil +} + +// UpdateProfile заменяет настройки существующего профиля. Идентификатор +// профиля неизменен: от него зависят имена файлов конфигов и путь к миру. +func (c *Config) UpdateProfile(id string, p Profile) error { + for i := range c.Profiles { + if c.Profiles[i].ID != id { + continue + } + p.ID = id + if p.JavaMemory == "" { + p.JavaMemory = c.Profiles[i].JavaMemory + } + p.Title = strings.TrimSpace(p.Title) + c.Profiles[i] = p + return nil + } + return fmt.Errorf("профиль %q не найден", id) +} + +// DeleteProfile убирает профиль из конфига. Файлы мира при этом остаются на +// диске: удалять чужие сохранения по нажатию кнопки в панели слишком опасно. +func (c *Config) DeleteProfile(id string) error { + if len(c.Profiles) == 1 { + return fmt.Errorf("нельзя удалить единственный профиль") + } + if id == c.Active { + return fmt.Errorf("нельзя удалить активный профиль — сначала переключитесь на другой") + } + for i, p := range c.Profiles { + if p.ID == id { + c.Profiles = append(c.Profiles[:i], c.Profiles[i+1:]...) + return nil + } + } + return fmt.Errorf("профиль %q не найден", id) +} + +// SetActive переключает активный профиль. +func (c *Config) SetActive(id string) error { + if _, ok := c.Profile(id); !ok { + return fmt.Errorf("профиль %q не найден", id) + } + c.Active = id + return nil } // Save записывает конфиг атомарно: сначала во временный файл рядом, затем @@ -185,17 +376,37 @@ func (c *Config) EnsureDirs() error { return nil } -// ServerConfigPath — путь к servertest.ini (или другому имени сервера). +// ServerConfigPathFor — путь к .ini. +func (c *Config) ServerConfigPathFor(profileID string) string { + return filepath.Join(c.ZomboidDir, "Server", profileID+".ini") +} + +// SandboxConfigPathFor — путь к _SandboxVars.lua. +func (c *Config) SandboxConfigPathFor(profileID string) string { + return filepath.Join(c.ZomboidDir, "Server", profileID+"_SandboxVars.lua") +} + +// WorldDirFor — директория мира профиля. +func (c *Config) WorldDirFor(profileID string) string { + return filepath.Join(c.ZomboidDir, "Saves", "Multiplayer", profileID) +} + +// ServerConfigPath — конфиг активного профиля. func (c *Config) ServerConfigPath() string { - return filepath.Join(c.ZomboidDir, "Server", c.ServerName+".ini") + return c.ServerConfigPathFor(c.ActiveProfile().ID) } -// SandboxConfigPath — путь к _SandboxVars.lua. +// SandboxConfigPath — настройки песочницы активного профиля. func (c *Config) SandboxConfigPath() string { - return filepath.Join(c.ZomboidDir, "Server", c.ServerName+"_SandboxVars.lua") + return c.SandboxConfigPathFor(c.ActiveProfile().ID) } -// SavesDir — директория с мирами (то, что бэкапим). +// ServerDirPath — директория с серверными конфигами всех профилей. +func (c *Config) ServerDirPath() string { + return filepath.Join(c.ZomboidDir, "Server") +} + +// SavesDir — директория со всеми мирами. func (c *Config) SavesDir() string { return filepath.Join(c.ZomboidDir, "Saves") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..fb107e8 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,161 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestLoadMigratesSingleServerConfig проверяет обновление с версии, где сервер +// был один: его настройки должны стать первым профилем, а старые поля — +// исчезнуть из файла. +func TestLoadMigratesSingleServerConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + legacy := strings.Join([]string{ + "listen: 127.0.0.1:8080", + "server_name: myworld", + "admin_password: hunter2", + "java_memory: 8g", + "extra_args:", + " - -debug", + }, "\n") + if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + + if len(cfg.Profiles) != 1 { + t.Fatalf("получено %d профилей, ожидался 1: %+v", len(cfg.Profiles), cfg.Profiles) + } + p := cfg.Profiles[0] + if p.ID != "myworld" || p.AdminPassword != "hunter2" || p.JavaMemory != "8g" { + t.Errorf("настройки не перенесены в профиль: %+v", p) + } + if len(p.ExtraArgs) != 1 || p.ExtraArgs[0] != "-debug" { + t.Errorf("extra_args не перенесены: %v", p.ExtraArgs) + } + if cfg.Active != "myworld" { + t.Errorf("активный профиль = %q", cfg.Active) + } + + // Файл должен быть переписан без устаревших полей. + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + saved := string(raw) + for _, key := range []string{"server_name:", "admin_password:", "java_memory:", "extra_args:"} { + if strings.Contains(saved, "\n"+key) { + t.Errorf("в файле осталось устаревшее поле %s:\n%s", key, saved) + } + } + if !strings.Contains(saved, "active_profile: myworld") { + t.Errorf("активный профиль не записан:\n%s", saved) + } +} + +func TestLoadCreatesDefaultProfile(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if len(cfg.Profiles) != 1 || cfg.Profiles[0].ID != "servertest" { + t.Fatalf("профиль по умолчанию не создан: %+v", cfg.Profiles) + } + if cfg.ActiveProfile().ID != "servertest" { + t.Errorf("активный профиль = %q", cfg.ActiveProfile().ID) + } +} + +// TestLoadRepairsDanglingActive: активный профиль могли удалить руками из +// файла, панель не должна из-за этого падать. +func TestLoadRepairsDanglingActive(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.yaml") + raw := strings.Join([]string{ + "active_profile: удалённый", + "profiles:", + " - id: vanilla", + " title: Ванилла", + }, "\n") + if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Active != "vanilla" { + t.Errorf("активный профиль = %q, ожидался vanilla", cfg.Active) + } + if cfg.Profiles[0].JavaMemory == "" { + t.Error("профилю без java_memory не подставлено умолчание") + } +} + +func TestProfileCRUD(t *testing.T) { + cfg := Default() + + if err := cfg.AddProfile(Profile{ID: "modded", Title: "С модами"}); err != nil { + t.Fatal(err) + } + if err := cfg.AddProfile(Profile{ID: "modded"}); err == nil { + t.Error("дубликат профиля принят") + } + if err := cfg.AddProfile(Profile{ID: "плохой id"}); err == nil { + t.Error("недопустимый идентификатор принят") + } + + if err := cfg.DeleteProfile("servertest"); err == nil { + t.Error("активный профиль удалён без переключения") + } + if err := cfg.SetActive("modded"); err != nil { + t.Fatal(err) + } + if err := cfg.DeleteProfile("servertest"); err != nil { + t.Fatal(err) + } + if err := cfg.DeleteProfile("modded"); err == nil { + t.Error("удалён единственный оставшийся профиль") + } + if err := cfg.SetActive("несуществующий"); err == nil { + t.Error("активирован несуществующий профиль") + } +} + +func TestUpdateProfileKeepsID(t *testing.T) { + cfg := Default() + err := cfg.UpdateProfile("servertest", Profile{ID: "другой", Title: "Переименован", JavaMemory: "16g"}) + if err != nil { + t.Fatal(err) + } + p := cfg.ActiveProfile() + if p.ID != "servertest" { + t.Errorf("идентификатор профиля изменился: %q", p.ID) + } + if p.Title != "Переименован" || p.JavaMemory != "16g" { + t.Errorf("настройки не сохранены: %+v", p) + } +} + +func TestProfilePaths(t *testing.T) { + cfg := Default() + cfg.ZomboidDir = filepath.Join("/home", "pz", "Zomboid") + + if got := cfg.ServerConfigPathFor("modded"); filepath.Base(got) != "modded.ini" { + t.Errorf("конфиг профиля: %s", got) + } + if got := cfg.SandboxConfigPathFor("modded"); filepath.Base(got) != "modded_SandboxVars.lua" { + t.Errorf("песочница профиля: %s", got) + } + if got := cfg.WorldDirFor("modded"); filepath.Base(got) != "modded" || + !strings.Contains(filepath.ToSlash(got), "Saves/Multiplayer") { + t.Errorf("мир профиля: %s", got) + } +} diff --git a/internal/pzserver/supervisor.go b/internal/pzserver/supervisor.go index f0faa40..a47624d 100644 --- a/internal/pzserver/supervisor.go +++ b/internal/pzserver/supervisor.go @@ -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") diff --git a/web/static/app.js b/web/static/app.js index f9cb06f..3b8636c 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -124,6 +124,7 @@ function showApp(login) { $("#auth-screen").classList.add("hidden"); $("#app").classList.remove("hidden"); $("#current-user").textContent = login; + loadProfiles().catch(fail); startPolling(); openConsoleStream(); loadTab(currentTab); @@ -188,6 +189,7 @@ $("#tabs").addEventListener("click", (event) => { // модов меняются редко, держать их в опросе статуса незачем. function loadTab(name) { const loaders = { + profiles: loadProfiles, config: loadServerConfig, sandbox: loadSandbox, mods: loadMods, @@ -234,6 +236,7 @@ function renderStatus(status) { $("#status-dot").className = `dot ${status.state}`; $("#ov-state").textContent = label; + $("#ov-profile").textContent = status.profile_title ? `Профиль: ${status.profile_title}` : ""; $("#ov-uptime").textContent = formatUptime(status.uptime_sec); $("#ov-pid").textContent = status.pid ? `PID ${status.pid}` : ""; $("#ov-lastexit").textContent = @@ -267,6 +270,18 @@ function renderStatus(status) { $("#btn-stop").disabled = !running; $("#btn-restart").disabled = !status.installed; $("#btn-install").disabled = running; + + // Профили делят игровые порты и файл запуска, поэтому переключать их можно + // только на остановленном сервере. + const picker = $("#profile-select"); + picker.disabled = running; + picker.title = running + ? "Остановите сервер, чтобы переключить профиль" + : "Профиль сервера: свой мир, конфиг и моды"; + // Активный профиль мог смениться в другой вкладке браузера. + if (!running && status.profile && picker.value !== status.profile) { + picker.value = status.profile; + } } function renderTask(task) { @@ -713,6 +728,155 @@ $("#btn-mods-save").addEventListener("click", async () => { } }); +// --------------------------------------------------------------------------- +// Профили серверов +// --------------------------------------------------------------------------- + +let profiles = { list: [], active: "" }; + +async function loadProfiles() { + const data = await get("/api/profiles"); + profiles = { list: data.profiles || [], active: data.active }; + + renderProfileSelect(); + renderProfileCards(); + renderCopySource(); +} + +function renderProfileSelect() { + const select = $("#profile-select"); + select.innerHTML = profiles.list + .map((p) => ``) + .join(""); + select.value = profiles.active; +} + +function renderProfileCards() { + $("#profile-cards").innerHTML = profiles.list + .map((p) => { + const badge = p.active ? `активный` : ""; + const state = p.has_config + ? p.world_ready + ? "мир создан" + : "настроен, мир ещё не создан" + : "ни разу не запускался"; + + return `
+

${escapeHTML(p.title || p.id)} ${badge}

+
${escapeHTML(p.id)}
+

${state}

+ + + + + +
+ + + +
+
`; + }) + .join(""); +} + +// renderCopySource предлагает копировать настройки только с тех профилей, у +// которых конфиг уже создан игрой. +function renderCopySource() { + $("#new-profile-copy").innerHTML = + `` + + profiles.list + .filter((p) => p.has_config) + .map((p) => ``) + .join(""); +} + +// switchProfile меняет активный профиль. Все вкладки показывают именно его +// конфиг, моды и мир, поэтому открытую вкладку перезагружаем. +async function switchProfile(id) { + try { + await post(`/api/profiles/${encodeURIComponent(id)}/activate`); + await loadProfiles(); + ok("Активный профиль переключён"); + refreshStatus(); + loadTab(currentTab); + } catch (error) { + fail(error); + // Селектор показывает то, чего не произошло, — возвращаем прежнее значение. + $("#profile-select").value = profiles.active; + } +} + +$("#profile-select").addEventListener("change", (event) => switchProfile(event.target.value)); + +$("#profile-cards").addEventListener("click", async (event) => { + const button = event.target.closest("button[data-action]"); + if (!button) return; + + const card = button.closest(".profile-card"); + const id = card.dataset.profile; + const field = (name) => card.querySelector(`[data-field="${name}"]`).value; + + try { + switch (button.dataset.action) { + case "save": + await put(`/api/profiles/${encodeURIComponent(id)}`, { + title: field("title"), + java_memory: field("java_memory"), + admin_password: field("admin_password"), + }); + ok("Профиль сохранён"); + await loadProfiles(); + break; + + case "activate": + await switchProfile(id); + break; + + case "delete": + if (!confirm(`Убрать профиль ${id} из панели?\n\n` + + `Мир и конфиги останутся на диске — профиль можно вернуть, создав его с тем же идентификатором.`)) { + return; + } + await del(`/api/profiles/${encodeURIComponent(id)}`); + ok("Профиль удалён"); + await loadProfiles(); + break; + } + } catch (error) { + fail(error); + } +}); + +$("#profile-add-form").addEventListener("submit", async (event) => { + event.preventDefault(); + try { + await post("/api/profiles", { + id: $("#new-profile-id").value.trim(), + title: $("#new-profile-title").value.trim(), + java_memory: $("#new-profile-memory").value.trim(), + copy_from: $("#new-profile-copy").value, + }); + $("#new-profile-id").value = ""; + $("#new-profile-title").value = ""; + $("#new-profile-memory").value = ""; + ok("Профиль создан"); + await loadProfiles(); + } catch (error) { + fail(error); + } +}); + // --------------------------------------------------------------------------- // Бэкапы // --------------------------------------------------------------------------- @@ -724,6 +888,7 @@ async function loadBackups() { const rows = (data.backups || []).map( (archive) => ` ${escapeHTML(archive.name)} + ${escapeHTML(profileTitle(archive.profile))} ${formatBytes(archive.size_bytes)} ${formatDate(archive.created_at)} @@ -734,7 +899,15 @@ async function loadBackups() { ` ); $("#backup-rows").innerHTML = rows.join("") || - `Пока ни одного архива`; + `Пока ни одного архива`; +} + +// profileTitle подписывает архив понятным именем профиля. У архивов, снятых до +// появления профилей, идентификатора в имени нет. +function profileTitle(id) { + if (!id) return "—"; + const profile = profiles.list.find((p) => p.id === id); + return profile ? profile.title || profile.id : id; } $("#backup-form").addEventListener("submit", async (event) => { @@ -781,9 +954,6 @@ const SETTINGS_FIELDS = [ ["server_dir", "Директория сервера", "text", "Куда SteamCMD ставит файлы Project Zomboid"], ["zomboid_dir", "Директория данных (~/Zomboid)", "text", "Здесь лежат миры, конфиги и логи игры"], ["steamcmd_path", "Путь к steamcmd.sh", "text", "Пусто — найти в PATH или скачать автоматически"], - ["server_name", "Имя конфигурации сервера", "text", "servertest → servertest.ini"], - ["java_memory", "Память JVM", "text", "Например 8g. Применяется при установке/обновлении"], - ["admin_password", "Пароль админа игры", "password", "Оставьте пустым, чтобы не менять"], ["autostart", "Запускать сервер вместе с менеджером", "checkbox", ""], ["autorestart", "Поднимать сервер после падения", "checkbox", ""], ["stop_timeout_sec", "Таймаут остановки, сек", "number", "Сколько ждать после команды quit до SIGTERM"], @@ -813,8 +983,6 @@ async function loadSettings() { input.name = key; if (type === "checkbox") { input.checked = Boolean(config[key]); - } else if (key === "admin_password") { - input.placeholder = config.has_admin_password ? "пароль задан" : "не задан"; } else { input.value = config[key] ?? ""; } @@ -850,8 +1018,6 @@ $("#btn-settings-save").addEventListener("click", async () => { payload[key] = input.value.trim(); } }); - // Пустой пароль означает «не менять» — не отправляем его вовсе. - if (!payload.admin_password) delete payload.admin_password; try { const result = await put("/api/config/manager", payload); ok(result.note || "Настройки сохранены"); diff --git a/web/static/index.html b/web/static/index.html index 6bde546..852f9fe 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -46,6 +46,11 @@ + +
@@ -70,6 +75,7 @@ + @@ -81,6 +87,7 @@

Состояние

+
@@ -201,6 +208,7 @@

Резервные копии

+

Бэкап снимается с активного профиля: его мир и конфиги.

@@ -208,11 +216,51 @@
- +
АрхивРазмерСоздан
АрхивПрофильРазмерСоздан
+ +
+
+
+

Профили серверов

+

У каждого профиля свой мир, конфиг, песочница и набор модов. + Одновременно работает один — переключать можно на остановленном сервере.

+
+
+ +
+ +

Новый профиль

+
+
+ + +
Латиница, цифры, дефис. Станет именем конфига и папки мира.
+
+
+ + +
Как профиль подписан в панели.
+
+
+ + +
Применяется при запуске этого профиля.
+
+
+ + +
Копирует .ini и настройки песочницы. Мир останется новым.
+
+
+ +
+
+
+

Настройки менеджера

diff --git a/web/static/style.css b/web/static/style.css index 6f741bc..c5b7d0e 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -382,3 +382,37 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; } .controls .btn { flex: 1; } .content { padding: 14px; } } + +/* ---------- Профили ---------- */ + +.profile-picker { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--muted); +} +.profile-picker select { + width: auto; + padding: 5px 10px; + font-size: 13px; +} +.profile-picker select:disabled { opacity: .55; cursor: not-allowed; } + +.profile-card .field { margin-bottom: 10px; } +.profile-card .key { margin-bottom: 8px; } + +.profile-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; } + +.badge { + display: inline-block; + vertical-align: middle; + margin-left: 6px; + padding: 2px 8px; + border-radius: 999px; + background: var(--accent-dark); + color: #fff; + font-size: 11px; + text-transform: none; + letter-spacing: 0; +}