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 }