Files
pzmanager/internal/api/handlers_tasks.go
T
mrleo1nidandClaude Opus 5 e78a72d516 Память JVM задаётся раздельно, автоперезапуск можно отменить
Панель принудительно ставила -Xms равным -Xmx: куча не растёт на ходу, но
JVM обязана занять весь объём при старте. На машине с 5 ГБ ОЗУ сервер с
4g так вообще не поднимался — «Failed to allocate initial Java heap».

Теперь -Xmx задаётся как раньше, а -Xms стал отдельным полем профиля и по
умолчанию пуст: JVM берёт память по мере надобности. Пара значений
проверяется при сохранении профиля, а не при запуске сервера, — про
опечатку лучше узнать из формы, чем из лога упавшего сервера.

Там же в супервизоре: ожидание автоподъёма стало отдельным состоянием.
Раньше упавший сервер уходил в цикл «падение — пауза 10 секунд — падение»,
причём во время паузы он считался остановленным, и прервать это было
нечем. Теперь паузу видно в статусе с обратным отсчётом и её можно
отменить, а после трёх падений сразу после запуска подряд панель
перестаёт поднимать сервер сама: так проявляется нехватка памяти или
битый конфиг, и перезапуск тут не помогает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 02:25:16 +03:00

252 lines
9.2 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 == "" && profile.JavaMemoryMin == "" {
return nil
}
if err := pzconfig.SetJavaMemory(s.cfg.ServerDir, profile.JavaMemory, profile.JavaMemoryMin); err != nil {
return err
}
if profile.JavaMemoryMin != "" {
log(fmt.Sprintf("Память JVM: от %s до %s", profile.JavaMemoryMin, profile.JavaMemory))
} else {
log("Лимит памяти JVM выставлен: " + profile.JavaMemory)
}
return nil
}