Консоль не вешает вкладку под потоком логов сборки

Загрузка мира со сборкой модов печатает десятки тысяч строк: каждое
исключение мода — стек на два десятка строк. Консоль рисовала их по одной
и после каждой прокручивалась вниз, заставляя браузер пересчитывать
раскладку всего списка. Десять тысяч строк блокировали вкладку на 29
секунд, и запуск сервера выглядел как зависшая панель.

Строки копятся и выводятся пачкой раз в кадр: те же десять тысяч теперь
занимают 14 мс. Узлы сверх предела списка не создаются вовсе, а на скрытой
вкладке очередь ограничена, чтобы не расти в памяти.

Заодно вывод игрового сервера больше не гоняется через словарь переводов:
он английский, в словаре менеджера его нет, а перебор шаблонов регулярками
на таком потоке стоит дорого.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 17:57:11 +03:00
co-authored by Claude Opus 5
parent 6d27fd049d
commit 04e83ff01e
3 changed files with 83 additions and 17 deletions
+14 -4
View File
@@ -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 переводит строки консоли на язык читающего. Буфер один на // translateLines переводит строки консоли на язык читающего. Буфер один на
// всех, поэтому перевод делается на выходе, у каждого подключения свой. Вывод // всех, поэтому перевод делается на выходе, у каждого подключения свой.
// самого игрового сервера через словарь проходит без изменений: он английский.
func translateLines(r *http.Request, lines []pzserver.LogLine) []pzserver.LogLine { func translateLines(r *http.Request, lines []pzserver.LogLine) []pzserver.LogLine {
lang := i18n.FromRequest(r) lang := i18n.FromRequest(r)
if lang == i18n.Default { if lang == i18n.Default {
@@ -95,7 +101,9 @@ func translateLines(r *http.Request, lines []pzserver.LogLine) []pzserver.LogLin
} }
out := make([]pzserver.LogLine, len(lines)) out := make([]pzserver.LogLine, len(lines))
for i, line := range 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 out[i] = line
} }
return out return out
@@ -149,7 +157,9 @@ func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
line.Text = i18n.T(lang, line.Text) if translatable(line.Stream) {
line.Text = i18n.T(lang, line.Text)
}
if !writeSSE(w, line) { if !writeSSE(w, line) {
return return
} }
+18
View File
@@ -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)
}
}
}
+51 -13
View File
@@ -436,8 +436,8 @@ function handleConsoleMeta(meta) {
lastSeq = 0; lastSeq = 0;
if (!restarted) return; if (!restarted) return;
$("#console-output").innerHTML = ""; clearConsoleScreen();
pushConsoleLine("manager", t("console.managerRestarted")); queueConsoleLine("manager", t("console.managerRestarted"));
// Переоткрываем поток: этот запрашивал историю с прежнего номера, а её у // Переоткрываем поток: этот запрашивал историю с прежнего номера, а её у
// нового запуска нет. // нового запуска нет.
openConsoleStream(); openConsoleStream();
@@ -451,30 +451,68 @@ function closeConsoleStream() {
function appendConsoleLine(line) { function appendConsoleLine(line) {
if (line.seq <= lastSeq) return; if (line.seq <= lastSeq) return;
lastSeq = line.seq; 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 output = $("#console-output");
const filter = $("#console-filter").value.trim().toLowerCase(); const filter = $("#console-filter").value.trim().toLowerCase();
// Из потопа сохранится всё равно только хвост — остальные узлы незачем даже
// создавать.
const from = Math.max(0, pending.length - MAX_CONSOLE_LINES);
const el = document.createElement("div"); const batch = document.createDocumentFragment();
el.className = `line-${stream}`; for (let i = from; i < pending.length; i++) {
el.textContent = text; const { stream, text } = pending[i];
el.dataset.text = text.toLowerCase(); const el = document.createElement("div");
if (filter && !el.dataset.text.includes(filter)) el.hidden = true; el.className = `line-${stream}`;
output.appendChild(el); 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) { while (output.childElementCount > MAX_CONSOLE_LINES) {
output.removeChild(output.firstElementChild); output.removeChild(output.firstElementChild);
} }
// Прокрутка — один раз на пачку: именно она и стоит дорого.
if ($("#console-autoscroll").checked) { if ($("#console-autoscroll").checked) {
output.scrollTop = output.scrollHeight; output.scrollTop = output.scrollHeight;
} }
} }
// clearConsoleScreen убирает и то, что уже нарисовано, и то, что ждёт кадра.
function clearConsoleScreen() {
consoleQueue = [];
$("#console-output").innerHTML = "";
}
$("#console-filter").addEventListener("input", (event) => { $("#console-filter").addEventListener("input", (event) => {
const filter = event.target.value.trim().toLowerCase(); const filter = event.target.value.trim().toLowerCase();
Array.from($("#console-output").children).forEach((line) => { Array.from($("#console-output").children).forEach((line) => {
@@ -484,7 +522,7 @@ $("#console-filter").addEventListener("input", (event) => {
$("#btn-console-clear").addEventListener("click", () => { $("#btn-console-clear").addEventListener("click", () => {
// Чистим только экран: история остаётся в буфере менеджера. // Чистим только экран: история остаётся в буфере менеджера.
$("#console-output").innerHTML = ""; clearConsoleScreen();
}); });
// История отправленных команд: та же логика, что и в обычном терминале. // История отправленных команд: та же логика, что и в обычном терминале.