Профиль — это отдельный сервер: свой мир, .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>
248 lines
9.0 KiB
Go
248 lines
9.0 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/mrleo1nid/pzmanager/internal/job"
|
|
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
|
"github.com/mrleo1nid/pzmanager/internal/pzserver"
|
|
)
|
|
|
|
func (s *Server) handleTaskStatus(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, s.jobs.Status())
|
|
}
|
|
|
|
func (s *Server) handleTaskCancel(w http.ResponseWriter, r *http.Request) {
|
|
if !s.jobs.Busy() {
|
|
writeError(w, http.StatusConflict, "нет активной задачи")
|
|
return
|
|
}
|
|
s.jobs.Cancel()
|
|
writeJSON(w, http.StatusOK, map[string]any{"cancelled": true})
|
|
}
|
|
|
|
// handleInstall ставит или обновляет серверные файлы через SteamCMD. Обновлять
|
|
// файлы под работающим сервером нельзя: SteamCMD перезапишет то, что открыто
|
|
// JVM, и сервер упадёт непредсказуемо.
|
|
func (s *Server) handleInstall(w http.ResponseWriter, r *http.Request) {
|
|
if s.sup.State() != pzserver.StateStopped {
|
|
writeError(w, http.StatusConflict, "сначала остановите игровой сервер")
|
|
return
|
|
}
|
|
|
|
name := "Установка/обновление сервера через SteamCMD"
|
|
err := s.jobs.Start(name, func(ctx context.Context, log func(string)) error {
|
|
if err := s.steam.InstallOrUpdate(ctx, log, s.cfg.ServerDir); err != nil {
|
|
return err
|
|
}
|
|
if err := s.applyJavaMemory(log); err != nil {
|
|
log("Не удалось выставить объём памяти JVM: " + err.Error())
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
writeOpError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
|
|
}
|
|
|
|
func (s *Server) handleListBackups(w http.ResponseWriter, r *http.Request) {
|
|
archives, err := s.backups.List()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
// Фильтр по профилю необязателен: панель показывает архивы всех профилей,
|
|
// помечая каждый, но умеет и сузить список.
|
|
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 := "Резервное копирование мира: " + body.Profile
|
|
err := s.jobs.Start(name, func(ctx context.Context, log func(string)) error {
|
|
// Опциональная остановка сервера даёт согласованный снимок: PZ
|
|
// сбрасывает мир на диск только при сохранении.
|
|
restart := false
|
|
if s.cfg.Backup.StopServer && profileIsRunning {
|
|
log("Останавливаю сервер перед бэкапом")
|
|
if err := s.sup.Stop(); err != nil && !errors.Is(err, pzserver.ErrNotRunning) {
|
|
return err
|
|
}
|
|
restart = true
|
|
}
|
|
|
|
_, err := s.backups.Create(ctx, log, body.Profile, body.Note)
|
|
|
|
if restart {
|
|
log("Поднимаю сервер обратно")
|
|
if startErr := s.sup.Start(); startErr != nil {
|
|
log("Не удалось запустить сервер: " + startErr.Error())
|
|
}
|
|
}
|
|
return err
|
|
})
|
|
if err != nil {
|
|
writeOpError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"task": name})
|
|
}
|
|
|
|
func (s *Server) handleDeleteBackup(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.backups.Delete(r.PathValue("name")); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"deleted": r.PathValue("name")})
|
|
}
|
|
|
|
func (s *Server) handleDownloadBackup(w http.ResponseWriter, r *http.Request) {
|
|
name := r.PathValue("name")
|
|
path, err := s.backups.Path(name)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "архив не найден")
|
|
return
|
|
}
|
|
defer f.Close()
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/gzip")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
|
|
http.ServeContent(w, r, name, info.ModTime(), f)
|
|
}
|
|
|
|
// handleRestoreBackup разворачивает архив поверх текущего мира. Сервер должен
|
|
// быть остановлен — иначе он держит файлы мира открытыми и перезапишет их
|
|
// своим состоянием при следующем сохранении.
|
|
func (s *Server) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
|
|
if s.sup.State() != pzserver.StateStopped {
|
|
writeError(w, http.StatusConflict, "сначала остановите игровой сервер")
|
|
return
|
|
}
|
|
name := r.PathValue("name")
|
|
if _, err := s.backups.Path(name); err != nil {
|
|
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, restoreProfile, "pre-restore"); err != nil {
|
|
return fmt.Errorf("страховочный бэкап не создан, восстановление отменено: %w", err)
|
|
}
|
|
return s.backups.Restore(ctx, log, name)
|
|
})
|
|
if err != nil {
|
|
writeOpError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"task": taskName})
|
|
}
|
|
|
|
// StartBackupScheduler запускает периодические бэкапы согласно конфигу.
|
|
// Интервал перечитывается на каждом тике, поэтому смена расписания в панели
|
|
// действует без перезапуска менеджера.
|
|
func StartBackupScheduler(ctx context.Context, s *Server) {
|
|
go func() {
|
|
ticker := time.NewTicker(time.Minute)
|
|
defer ticker.Stop()
|
|
last := time.Now()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case now := <-ticker.C:
|
|
interval := s.cfg.Backup.Schedule.Duration()
|
|
if interval <= 0 || now.Sub(last) < interval {
|
|
continue
|
|
}
|
|
last = now
|
|
if s.jobs.Busy() {
|
|
s.sup.AppendLog("manager", "Плановый бэкап пропущен: выполняется другая задача")
|
|
continue
|
|
}
|
|
profile := s.cfg.Active
|
|
err := s.jobs.Start("Плановое резервное копирование: "+profile,
|
|
func(ctx context.Context, log func(string)) error {
|
|
_, err := s.backups.Create(ctx, log, profile, "auto")
|
|
return err
|
|
})
|
|
if err != nil && !errors.Is(err, job.ErrBusy) {
|
|
s.sup.AppendLog("manager", "Плановый бэкап не запущен: "+err.Error())
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// applyJavaMemory переносит лимит памяти активного профиля в файл запуска
|
|
// сервера. Вызывается после установки: SteamCMD перезаписывает этот файл.
|
|
// При обычном старте то же самое делает супервизор.
|
|
func (s *Server) applyJavaMemory(log func(string)) error {
|
|
profile := s.cfg.ActiveProfile()
|
|
if profile.JavaMemory == "" {
|
|
return nil
|
|
}
|
|
if err := pzconfig.SetJavaMemory(s.cfg.ServerDir, profile.JavaMemory); err != nil {
|
|
return err
|
|
}
|
|
log("Лимит памяти JVM выставлен: " + profile.JavaMemory)
|
|
return nil
|
|
}
|