diff --git a/internal/api/handlers_server.go b/internal/api/handlers_server.go index 54510b6..9d81667 100644 --- a/internal/api/handlers_server.go +++ b/internal/api/handlers_server.go @@ -79,7 +79,10 @@ func (s *Server) handleConsole(w http.ResponseWriter, r *http.Request) { // 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{"lines": translateLines(r, s.sup.Lines(since))}) + writeJSON(w, http.StatusOK, map[string]any{ + "epoch": s.sup.LogEpoch(), + "lines": translateLines(r, s.sup.Lines(since)), + }) } // translateLines переводит строки консоли на язык читающего. Буфер один на @@ -119,7 +122,13 @@ func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) { 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) { @@ -162,3 +171,14 @@ func writeSSE(w http.ResponseWriter, line pzserver.LogLine) bool { _, 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 +} diff --git a/internal/pzserver/logbuf.go b/internal/pzserver/logbuf.go index b59484e..585ac47 100644 --- a/internal/pzserver/logbuf.go +++ b/internal/pzserver/logbuf.go @@ -1,8 +1,12 @@ package pzserver import ( + "crypto/rand" + "encoding/hex" "regexp" + "strconv" "sync" + "time" ) // ansiRe ловит управляющие последовательности терминала: SteamCMD и сам @@ -32,6 +36,7 @@ type logBuffer struct { lines []LogLine capacity int nextSeq int64 + epoch string subs map[int]chan LogLine nextSub int } @@ -41,10 +46,25 @@ func newLogBuffer(capacity int) *logBuffer { lines: make([]LogLine, 0, capacity), capacity: capacity, nextSeq: 1, + epoch: newEpoch(), subs: make(map[int]chan LogLine), } } +// newEpoch помечает этот запуск менеджера. Номера строк живут только в памяти +// и после перезапуска начинаются заново — открытая вкладка приняла бы новые +// строки за уже показанные и молчала бы до перезагрузки страницы. По смене +// метки браузер понимает, что нумерацию надо начать с нуля. +func newEpoch() string { + var raw [8]byte + if _, err := rand.Read(raw[:]); err != nil { + // Случайность здесь не нужна для безопасности: метку достаточно + // отличать от предыдущей, и время запуска с этим справляется. + return strconv.FormatInt(time.Now().UnixNano(), 36) + } + return hex.EncodeToString(raw[:]) +} + // append кладёт строку в буфер и рассылает подписчикам. func (b *logBuffer) append(line LogLine) { // Чистим здесь, а не у каждого источника: через буфер проходят и вывод diff --git a/internal/pzserver/logbuf_test.go b/internal/pzserver/logbuf_test.go index 1e0953c..14d7441 100644 --- a/internal/pzserver/logbuf_test.go +++ b/internal/pzserver/logbuf_test.go @@ -39,3 +39,21 @@ func TestAppendStripsANSI(t *testing.T) { t.Errorf("в буфер попала строка с кодами: %q", lines[0].Text) } } + +// Номера строк живут в памяти менеджера: после перезапуска они начинаются +// заново. Метка запуска должна это показывать, иначе открытая вкладка примет +// новые строки за уже показанные. +func TestLogBufferEpochDiffers(t *testing.T) { + first, second := newLogBuffer(8), newLogBuffer(8) + if first.epoch == "" { + t.Fatal("метка запуска пустая") + } + if first.epoch == second.epoch { + t.Fatalf("метки совпали: %q", first.epoch) + } + first.append(LogLine{Stream: "manager", Text: "первый запуск"}) + second.append(LogLine{Stream: "manager", Text: "второй запуск"}) + if got := second.since(0); len(got) != 1 || got[0].Seq != 1 { + t.Fatalf("нумерация нового буфера: %+v", got) + } +} diff --git a/internal/pzserver/supervisor.go b/internal/pzserver/supervisor.go index a55982d..3943afb 100644 --- a/internal/pzserver/supervisor.go +++ b/internal/pzserver/supervisor.go @@ -157,6 +157,9 @@ func (s *Supervisor) AppendLog(stream, text string) { // Lines возвращает историю консоли начиная с номера seq. func (s *Supervisor) Lines(seq int64) []LogLine { return s.log.since(seq) } +// LogEpoch — метка этого запуска менеджера. Меняется вместе с нумерацией строк. +func (s *Supervisor) LogEpoch() string { return s.log.epoch } + // Subscribe отдаёт канал новых строк консоли и функцию отписки. func (s *Supervisor) Subscribe() (<-chan LogLine, func()) { return s.log.subscribe(256) } diff --git a/web/static/app.js b/web/static/app.js index 420938b..771ced4 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -398,17 +398,38 @@ $("#btn-task-cancel").addEventListener("click", async () => { let eventSource = null; let lastSeq = 0; +// Метка запуска менеджера: номера строк живут в его памяти и после +// перезапуска начинаются заново. +let logEpoch = null; const MAX_CONSOLE_LINES = 3000; function openConsoleStream() { closeConsoleStream(); eventSource = new EventSource(`/api/logs/stream?since=${lastSeq}`); + eventSource.addEventListener("meta", (event) => handleConsoleMeta(JSON.parse(event.data))); eventSource.onmessage = (event) => appendConsoleLine(JSON.parse(event.data)); eventSource.onerror = () => { // Браузер переподключается сам; сообщать об этом каждый раз не нужно. }; } +// handleConsoleMeta ловит перезапуск менеджера: без сброса счётчика вкладка +// приняла бы новые строки за уже показанные и осталась бы пустой до +// перезагрузки страницы. +function handleConsoleMeta(meta) { + if (logEpoch === meta.epoch) return; + const restarted = logEpoch !== null; + logEpoch = meta.epoch; + lastSeq = 0; + if (!restarted) return; + + $("#console-output").innerHTML = ""; + pushConsoleLine("manager", t("console.managerRestarted")); + // Переоткрываем поток: этот запрашивал историю с прежнего номера, а её у + // нового запуска нет. + openConsoleStream(); +} + function closeConsoleStream() { if (eventSource) eventSource.close(); eventSource = null; @@ -417,14 +438,19 @@ function closeConsoleStream() { function appendConsoleLine(line) { if (line.seq <= lastSeq) return; lastSeq = line.seq; + pushConsoleLine(line.stream, line.text); +} +// pushConsoleLine рисует строку на экране. Отдельно от нумерации: свои +// сообщения панель показывает без номера из буфера менеджера. +function pushConsoleLine(stream, text) { const output = $("#console-output"); const filter = $("#console-filter").value.trim().toLowerCase(); const el = document.createElement("div"); - el.className = `line-${line.stream}`; - el.textContent = line.text; - el.dataset.text = line.text.toLowerCase(); + el.className = `line-${stream}`; + el.textContent = text; + el.dataset.text = text.toLowerCase(); if (filter && !el.dataset.text.includes(filter)) el.hidden = true; output.appendChild(el); diff --git a/web/static/i18n.js b/web/static/i18n.js index 710a7df..c7047ba 100644 --- a/web/static/i18n.js +++ b/web/static/i18n.js @@ -159,6 +159,7 @@ const STRINGS = { "console.autoscroll": "Прокручивать за выводом", "console.filter": "Фильтр по строке…", "console.clear": "Очистить экран", + "console.managerRestarted": "— менеджер перезапущен, консоль начата заново —", "console.placeholder": "Команда сервера, например: players, save, quit", "console.placeholderOffline": "Сервер остановлен — консоль принимает команды только на работающем", "console.send": "Отправить", @@ -501,6 +502,7 @@ const STRINGS = { "console.autoscroll": "Scroll with output", "console.filter": "Filter by text…", "console.clear": "Clear screen", + "console.managerRestarted": "— the manager restarted, the console starts over —", "console.placeholder": "Server command, for example: players, save, quit", "console.placeholderOffline": "Server stopped — the console only accepts commands while it runs", "console.send": "Send",