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(); }); // История отправленных команд: та же логика, что и в обычном терминале.