Профили серверов: несколько миров с раздельными настройками
Профиль — это отдельный сервер: свой мир, .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:
@@ -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
|
||||
profiles: # серверы: у каждого свой мир и настройки
|
||||
- id: vanilla # vanilla -> vanilla.ini, Saves/Multiplayer/vanilla
|
||||
title: Ванильный мир # как профиль подписан в панели
|
||||
admin_password: "" # пароль админа игрового сервера
|
||||
java_memory: 4g # -Xmx для JVM
|
||||
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`) считается по каждому профилю отдельно.
|
||||
Архивы, снятые до появления профилей, панель тоже читает и восстанавливает.
|
||||
|
||||
Бэкап на живом сервере технически корректен, но снимок может отставать от
|
||||
состояния мира в памяти. Если хочется гарантированной согласованности —
|
||||
|
||||
@@ -111,15 +111,13 @@ 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"`
|
||||
@@ -128,10 +126,7 @@ type managerConfigDTO struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+164
-36
@@ -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)
|
||||
|
||||
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)
|
||||
written += n
|
||||
if err != nil {
|
||||
// abort закрывает всё начатое и убирает недописанный файл.
|
||||
abort := func(err error) (Archive, error) {
|
||||
tw.Close()
|
||||
gz.Close()
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return Archive{}, err
|
||||
}
|
||||
|
||||
var written int64
|
||||
if world := m.cfg.WorldDirFor(profileID); dirExists(world) {
|
||||
n, err := addTree(ctx, tw, m.cfg.ZomboidDir, world, log)
|
||||
written += n
|
||||
if err != nil {
|
||||
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 находит серверные файлы профиля: <id>.ini,
|
||||
// <id>_SandboxVars.lua, <id>_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 {
|
||||
|
||||
+113
-30
@@ -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 проверяет имя, а не
|
||||
|
||||
+238
-27
@@ -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 — путь к <profile>.ini.
|
||||
func (c *Config) ServerConfigPathFor(profileID string) string {
|
||||
return filepath.Join(c.ZomboidDir, "Server", profileID+".ini")
|
||||
}
|
||||
|
||||
// SandboxConfigPathFor — путь к <profile>_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 — путь к <name>_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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mrleo1nid/pzmanager/internal/config"
|
||||
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
||||
"github.com/mrleo1nid/pzmanager/internal/sysinfo"
|
||||
)
|
||||
|
||||
@@ -44,6 +45,10 @@ const restartDelay = 10 * time.Second
|
||||
// Status — снимок состояния для веб-панели.
|
||||
type Status struct {
|
||||
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"`
|
||||
@@ -70,6 +75,9 @@ type Supervisor struct {
|
||||
lastExit string
|
||||
players []string
|
||||
playersAt time.Time
|
||||
// profile — профиль, с которым сервер был запущен: активный профиль
|
||||
// в конфиге может отличаться, если его успели переключить.
|
||||
profile config.Profile
|
||||
// intentionalStop отличает остановку по кнопке от падения: во втором
|
||||
// случае включается автоперезапуск.
|
||||
intentionalStop bool
|
||||
@@ -136,9 +144,16 @@ 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,
|
||||
Profile: profile.ID,
|
||||
ProfileTitle: profile.Name(),
|
||||
Players: append([]string{}, s.players...),
|
||||
LastExit: s.lastExit,
|
||||
AutoRestart: s.cfg.AutoRestart,
|
||||
@@ -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")
|
||||
|
||||
+174
-8
@@ -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) => `<option value="${escapeHTML(p.id)}">${escapeHTML(p.title || p.id)}</option>`)
|
||||
.join("");
|
||||
select.value = profiles.active;
|
||||
}
|
||||
|
||||
function renderProfileCards() {
|
||||
$("#profile-cards").innerHTML = profiles.list
|
||||
.map((p) => {
|
||||
const badge = p.active ? `<span class="badge">активный</span>` : "";
|
||||
const state = p.has_config
|
||||
? p.world_ready
|
||||
? "мир создан"
|
||||
: "настроен, мир ещё не создан"
|
||||
: "ни разу не запускался";
|
||||
|
||||
return `<div class="card profile-card" data-profile="${escapeHTML(p.id)}">
|
||||
<h3>${escapeHTML(p.title || p.id)} ${badge}</h3>
|
||||
<div class="key">${escapeHTML(p.id)}</div>
|
||||
<p class="muted small">${state}</p>
|
||||
|
||||
<label class="field">
|
||||
<span>Название</span>
|
||||
<input type="text" data-field="title" value="${escapeHTML(p.title || "")}">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Память JVM</span>
|
||||
<input type="text" data-field="java_memory" value="${escapeHTML(p.java_memory || "")}">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Пароль админа игры</span>
|
||||
<input type="password" data-field="admin_password" autocomplete="new-password"
|
||||
placeholder="${p.has_admin_password ? "пароль задан" : "не задан"}">
|
||||
</label>
|
||||
|
||||
<div class="profile-actions">
|
||||
<button class="btn btn-sm btn-primary" data-action="save">Сохранить</button>
|
||||
<button class="btn btn-sm" data-action="activate" ${p.active ? "disabled" : ""}>Сделать активным</button>
|
||||
<button class="btn btn-sm btn-danger" data-action="delete" ${p.active ? "disabled" : ""}>Удалить</button>
|
||||
</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
// renderCopySource предлагает копировать настройки только с тех профилей, у
|
||||
// которых конфиг уже создан игрой.
|
||||
function renderCopySource() {
|
||||
$("#new-profile-copy").innerHTML =
|
||||
`<option value="">не копировать</option>` +
|
||||
profiles.list
|
||||
.filter((p) => p.has_config)
|
||||
.map((p) => `<option value="${escapeHTML(p.id)}">${escapeHTML(p.title || p.id)}</option>`)
|
||||
.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) => `<tr>
|
||||
<td>${escapeHTML(archive.name)}</td>
|
||||
<td>${escapeHTML(profileTitle(archive.profile))}</td>
|
||||
<td>${formatBytes(archive.size_bytes)}</td>
|
||||
<td>${formatDate(archive.created_at)}</td>
|
||||
<td>
|
||||
@@ -734,7 +899,15 @@ async function loadBackups() {
|
||||
</tr>`
|
||||
);
|
||||
$("#backup-rows").innerHTML = rows.join("") ||
|
||||
`<tr><td colspan="4" class="muted">Пока ни одного архива</td></tr>`;
|
||||
`<tr><td colspan="5" class="muted">Пока ни одного архива</td></tr>`;
|
||||
}
|
||||
|
||||
// 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 || "Настройки сохранены");
|
||||
|
||||
+49
-1
@@ -46,6 +46,11 @@
|
||||
<span id="status-text">—</span>
|
||||
</div>
|
||||
|
||||
<label class="profile-picker">
|
||||
<span>Профиль</span>
|
||||
<select id="profile-select" title="Профиль сервера: свой мир, конфиг и моды"></select>
|
||||
</label>
|
||||
|
||||
<div class="controls">
|
||||
<button class="btn btn-start" id="btn-start">Запустить</button>
|
||||
<button class="btn btn-stop" id="btn-stop">Остановить</button>
|
||||
@@ -70,6 +75,7 @@
|
||||
<button class="tab" data-tab="sandbox">Песочница</button>
|
||||
<button class="tab" data-tab="mods">Моды</button>
|
||||
<button class="tab" data-tab="backups">Бэкапы</button>
|
||||
<button class="tab" data-tab="profiles">Профили</button>
|
||||
<button class="tab" data-tab="settings">Настройки</button>
|
||||
</nav>
|
||||
|
||||
@@ -81,6 +87,7 @@
|
||||
<div class="card">
|
||||
<h3>Состояние</h3>
|
||||
<div class="stat" id="ov-state">—</div>
|
||||
<div class="muted" id="ov-profile"></div>
|
||||
<div class="muted" id="ov-uptime"></div>
|
||||
<div class="muted" id="ov-pid"></div>
|
||||
<div class="muted error" id="ov-lastexit"></div>
|
||||
@@ -201,6 +208,7 @@
|
||||
<div>
|
||||
<h2>Резервные копии</h2>
|
||||
<p class="muted small" id="backup-dir"></p>
|
||||
<p class="muted small">Бэкап снимается с активного профиля: его мир и конфиги.</p>
|
||||
</div>
|
||||
<form id="backup-form">
|
||||
<input type="text" id="backup-note" placeholder="Пометка (необязательно)" class="input-sm">
|
||||
@@ -208,11 +216,51 @@
|
||||
</form>
|
||||
</div>
|
||||
<table class="table">
|
||||
<thead><tr><th>Архив</th><th>Размер</th><th>Создан</th><th></th></tr></thead>
|
||||
<thead><tr><th>Архив</th><th>Профиль</th><th>Размер</th><th>Создан</th><th></th></tr></thead>
|
||||
<tbody id="backup-rows"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Профили -->
|
||||
<section class="panel" data-panel="profiles">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Профили серверов</h2>
|
||||
<p class="muted small">У каждого профиля свой мир, конфиг, песочница и набор модов.
|
||||
Одновременно работает один — переключать можно на остановленном сервере.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="profile-cards" class="cards"></div>
|
||||
|
||||
<h3>Новый профиль</h3>
|
||||
<form id="profile-add-form" class="fields">
|
||||
<div class="cfg-field">
|
||||
<label for="new-profile-id">Идентификатор</label>
|
||||
<input type="text" id="new-profile-id" placeholder="modded" autocomplete="off">
|
||||
<div class="hint">Латиница, цифры, дефис. Станет именем конфига и папки мира.</div>
|
||||
</div>
|
||||
<div class="cfg-field">
|
||||
<label for="new-profile-title">Название</label>
|
||||
<input type="text" id="new-profile-title" placeholder="Сборка с модами" autocomplete="off">
|
||||
<div class="hint">Как профиль подписан в панели.</div>
|
||||
</div>
|
||||
<div class="cfg-field">
|
||||
<label for="new-profile-memory">Память JVM</label>
|
||||
<input type="text" id="new-profile-memory" placeholder="8g" autocomplete="off">
|
||||
<div class="hint">Применяется при запуске этого профиля.</div>
|
||||
</div>
|
||||
<div class="cfg-field">
|
||||
<label for="new-profile-copy">Скопировать настройки из</label>
|
||||
<select id="new-profile-copy"></select>
|
||||
<div class="hint">Копирует .ini и настройки песочницы. Мир останется новым.</div>
|
||||
</div>
|
||||
<div class="cfg-field">
|
||||
<button class="btn btn-primary" type="submit">Создать профиль</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Настройки -->
|
||||
<section class="panel" data-panel="settings">
|
||||
<h2>Настройки менеджера</h2>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user