From 20795b053da75c483edf00277456650a163050ae Mon Sep 17 00:00:00 2001 From: mrleo1nid Date: Mon, 7 Sep 2026 17:57:11 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D1=8C=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=B2=D0=B5=D1=88=D0=B0=D0=B5=D1=82=20=D0=B2?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D1=83=20=D0=BF=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BC=20=D0=BB=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=D0=B2=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Загрузка мира со сборкой модов печатает десятки тысяч строк: каждое исключение мода — стек на два десятка строк. Консоль рисовала их по одной и после каждой прокручивалась вниз, заставляя браузер пересчитывать раскладку всего списка. Десять тысяч строк блокировали вкладку на 29 секунд, и запуск сервера выглядел как зависшая панель. Строки копятся и выводятся пачкой раз в кадр: те же десять тысяч теперь занимают 14 мс. Узлы сверх предела списка не создаются вовсе, а на скрытой вкладке очередь ограничена, чтобы не расти в памяти. Заодно вывод игрового сервера больше не гоняется через словарь переводов: он английский, в словаре менеджера его нет, а перебор шаблонов регулярками на таком потоке стоит дорого. Co-Authored-By: Claude Opus 5 --- internal/api/handlers_server.go | 18 ++++++-- internal/api/handlers_server_test.go | 18 ++++++++ web/static/app.js | 64 ++++++++++++++++++++++------ 3 files changed, 83 insertions(+), 17 deletions(-) create mode 100644 internal/api/handlers_server_test.go diff --git a/internal/api/handlers_server.go b/internal/api/handlers_server.go index 9d81667..f5c62e1 100644 --- a/internal/api/handlers_server.go +++ b/internal/api/handlers_server.go @@ -85,9 +85,15 @@ func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) { }) } +// 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 { @@ -95,7 +101,9 @@ func translateLines(r *http.Request, lines []pzserver.LogLine) []pzserver.LogLin } out := make([]pzserver.LogLine, len(lines)) for i, line := range lines { - line.Text = i18n.T(lang, line.Text) + if translatable(line.Stream) { + line.Text = i18n.T(lang, line.Text) + } out[i] = line } return out @@ -149,7 +157,9 @@ func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) { if !ok { return } - line.Text = i18n.T(lang, line.Text) + if translatable(line.Stream) { + line.Text = i18n.T(lang, line.Text) + } if !writeSSE(w, line) { return } diff --git a/internal/api/handlers_server_test.go b/internal/api/handlers_server_test.go new file mode 100644 index 0000000..9522ca8 --- /dev/null +++ b/internal/api/handlers_server_test.go @@ -0,0 +1,18 @@ +package api + +import "testing" + +// Вывод игрового сервера идёт мимо словаря: он английский, а перебор шаблонов +// на десятках тысяч строк загрузки мира стоит дорого. +func TestTranslatableOnlyManagerStreams(t *testing.T) { + for _, stream := range []string{"manager", "task"} { + if !translatable(stream) { + t.Errorf("строки потока %q должны переводиться", stream) + } + } + for _, stream := range []string{"stdout", "stderr", ""} { + if translatable(stream) { + t.Errorf("строки потока %q переводить незачем", stream) + } + } +} diff --git a/web/static/app.js b/web/static/app.js index acde7bb..0c89026 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -436,8 +436,8 @@ function handleConsoleMeta(meta) { lastSeq = 0; if (!restarted) return; - $("#console-output").innerHTML = ""; - pushConsoleLine("manager", t("console.managerRestarted")); + clearConsoleScreen(); + queueConsoleLine("manager", t("console.managerRestarted")); // Переоткрываем поток: этот запрашивал историю с прежнего номера, а её у // нового запуска нет. openConsoleStream(); @@ -451,30 +451,68 @@ function closeConsoleStream() { function appendConsoleLine(line) { if (line.seq <= lastSeq) return; lastSeq = line.seq; - pushConsoleLine(line.stream, line.text); + queueConsoleLine(line.stream, line.text); } -// pushConsoleLine рисует строку на экране. Отдельно от нумерации: свои -// сообщения панель показывает без номера из буфера менеджера. -function pushConsoleLine(stream, text) { +// Загрузка мира со сборкой модов печатает десятки тысяч строк за минуты: +// каждое исключение — стек на два десятка строк. Рисовать их по одной нельзя: +// прокрутка вниз после каждой строки заставляет браузер пересчитывать раскладку +// всего списка, и вкладка встаёт колом на десятки секунд. Поэтому строки +// копятся и выводятся пачкой раз в кадр. +let consoleQueue = []; +let consoleFrame = 0; + +// queueConsoleLine ставит строку в очередь на отрисовку. Отдельно от нумерации: +// свои сообщения панель показывает без номера из буфера менеджера. +function queueConsoleLine(stream, text) { + consoleQueue.push({ stream, text }); + // На скрытой вкладке кадры не идут, и очередь росла бы без предела: держим + // столько, сколько всё равно поместится на экране. + if (consoleQueue.length > MAX_CONSOLE_LINES * 2) { + consoleQueue = consoleQueue.slice(-MAX_CONSOLE_LINES); + } + if (!consoleFrame) consoleFrame = requestAnimationFrame(flushConsoleQueue); +} + +function flushConsoleQueue() { + consoleFrame = 0; + const pending = consoleQueue; + consoleQueue = []; + if (!pending.length) return; + const output = $("#console-output"); const filter = $("#console-filter").value.trim().toLowerCase(); + // Из потопа сохранится всё равно только хвост — остальные узлы незачем даже + // создавать. + const from = Math.max(0, pending.length - MAX_CONSOLE_LINES); - const el = document.createElement("div"); - 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); + const batch = document.createDocumentFragment(); + for (let i = from; i < pending.length; i++) { + const { stream, text } = pending[i]; + const el = document.createElement("div"); + el.className = `line-${stream}`; + el.textContent = text; + el.dataset.text = text.toLowerCase(); + if (filter && !el.dataset.text.includes(filter)) el.hidden = true; + batch.appendChild(el); + } + output.appendChild(batch); while (output.childElementCount > MAX_CONSOLE_LINES) { output.removeChild(output.firstElementChild); } + // Прокрутка — один раз на пачку: именно она и стоит дорого. if ($("#console-autoscroll").checked) { output.scrollTop = output.scrollHeight; } } +// clearConsoleScreen убирает и то, что уже нарисовано, и то, что ждёт кадра. +function clearConsoleScreen() { + consoleQueue = []; + $("#console-output").innerHTML = ""; +} + $("#console-filter").addEventListener("input", (event) => { const filter = event.target.value.trim().toLowerCase(); Array.from($("#console-output").children).forEach((line) => { @@ -484,7 +522,7 @@ $("#console-filter").addEventListener("input", (event) => { $("#btn-console-clear").addEventListener("click", () => { // Чистим только экран: история остаётся в буфере менеджера. - $("#console-output").innerHTML = ""; + clearConsoleScreen(); }); // История отправленных команд: та же логика, что и в обычном терминале.