package api import ( "encoding/json" "errors" "fmt" "net/http" "strconv" "time" "github.com/mrleo1nid/pzmanager/internal/i18n" "github.com/mrleo1nid/pzmanager/internal/pzserver" ) func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { lang := i18n.FromRequest(r) status := s.sup.Status() status.LastExit = i18n.T(lang, status.LastExit) // Имя задачи придумано менеджером и висит в панели, пока задача идёт. task := s.jobs.Status() task.Name = i18n.T(lang, task.Name) task.Error = i18n.T(lang, task.Error) writeJSON(w, http.StatusOK, map[string]any{"server": status, "task": task}) } func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) { if err := s.sup.Start(); err != nil { writeOpError(w, r, err) return } writeJSON(w, http.StatusOK, map[string]any{"state": s.sup.State()}) } // handleStop останавливает сервер в фоне: корректное завершение занимает до // нескольких минут, и держать HTTP-запрос всё это время незачем — панель // видит прогресс по состоянию и логам. func (s *Server) handleStop(w http.ResponseWriter, r *http.Request) { if s.sup.State() == pzserver.StateStopped { writeOpError(w, r, pzserver.ErrNotRunning) return } go func() { if err := s.sup.Stop(); err != nil && !errors.Is(err, pzserver.ErrNotRunning) { s.sup.AppendLog("manager", "Остановка не удалась: "+err.Error()) } }() writeJSON(w, http.StatusAccepted, map[string]any{"state": pzserver.StateStopping}) } func (s *Server) handleRestart(w http.ResponseWriter, r *http.Request) { if !s.sup.Installed() { writeOpError(w, r, pzserver.ErrNotInstalled) return } go func() { if err := s.sup.Restart(); err != nil { s.sup.AppendLog("manager", "Перезапуск не удался: "+err.Error()) } }() writeJSON(w, http.StatusAccepted, map[string]any{"state": s.sup.State()}) } func (s *Server) handleConsole(w http.ResponseWriter, r *http.Request) { var body struct { Command string `json:"command"` } if !decodeJSON(w, r, &body) { return } if err := s.sup.Send(body.Command); err != nil { writeOpError(w, r, err) return } writeJSON(w, http.StatusOK, map[string]any{"sent": body.Command}) } // handleLogs отдаёт историю консоли начиная с указанного номера строки. func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) { since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64) writeJSON(w, http.StatusOK, map[string]any{ "epoch": s.sup.LogEpoch(), "lines": translateLines(r, s.sup.Lines(since)), }) } // translatable отсеивает вывод самого игрового сервера: он на английском и в // словаре менеджера его нет. Перебирать ради него все шаблоны накладно — // загрузка мира со сборкой модов печатает десятки тысяч строк. func translatable(stream string) bool { return stream == "manager" || stream == "task" } // translateLines переводит строки консоли на язык читающего. Буфер один на // всех, поэтому перевод делается на выходе, у каждого подключения свой. func translateLines(r *http.Request, lines []pzserver.LogLine) []pzserver.LogLine { lang := i18n.FromRequest(r) if lang == i18n.Default { return lines } out := make([]pzserver.LogLine, len(lines)) for i, line := range lines { if translatable(line.Stream) { line.Text = i18n.T(lang, line.Text) } out[i] = line } return out } // handleLogStream — живая консоль через Server-Sent Events. SSE выбран вместо // вебсокетов: поток односторонний, а переподключение браузер берёт на себя. func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) { flusher, ok := w.(http.Flusher) if !ok { writeError(w, r, http.StatusInternalServerError, "потоковая отдача не поддерживается") return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") // Отключаем буферизацию в nginx, иначе строки придут пачкой. w.Header().Set("X-Accel-Buffering", "no") w.WriteHeader(http.StatusOK) lines, unsubscribe := s.sup.Subscribe() defer unsubscribe() lang := i18n.FromRequest(r) // Метка запуска идёт первой: по ней вкладка понимает, что менеджер // перезапустился и нумерация строк началась заново. if !writeSSEEvent(w, "meta", map[string]string{"epoch": s.sup.LogEpoch()}) { return } // Затем хвост истории, чтобы вкладка не открывалась пустой. since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64) for _, line := range translateLines(r, s.sup.Lines(since)) { if !writeSSE(w, line) { return } } flusher.Flush() // Пинг не даёт прокси закрыть простаивающее соединение. ping := time.NewTicker(25 * time.Second) defer ping.Stop() for { select { case <-r.Context().Done(): return case line, ok := <-lines: if !ok { return } if translatable(line.Stream) { line.Text = i18n.T(lang, line.Text) } if !writeSSE(w, line) { return } flusher.Flush() case <-ping.C: if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil { return } flusher.Flush() } } } func writeSSE(w http.ResponseWriter, line pzserver.LogLine) bool { payload, err := json.Marshal(line) if err != nil { return true } _, err = fmt.Fprintf(w, "data: %s\n\n", payload) return err == nil } // writeSSEEvent отправляет именованное событие: браузер ловит его отдельным // слушателем и не путает со строками консоли. func writeSSEEvent(w http.ResponseWriter, name string, value any) bool { payload, err := json.Marshal(value) if err != nil { return true } _, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", name, payload) return err == nil }