From a762a1df80776da75faa0a8dbad3879d0d25f48a Mon Sep 17 00:00:00 2001 From: "ki.sagidullin" Date: Fri, 21 Aug 2026 21:35:37 +0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(ui):=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D1=80=20=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D0=B5=D0=B9=20=D0=BB?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=D0=B2=20=D0=B2=20=D0=BF=D0=B0=D0=BD=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8=20=C2=AB=D0=9B=D0=BE=D0=B3=D0=B8=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ui-spec.md | 16 ++- internal/events/log.go | 44 +++++++- internal/events/log_test.go | 48 +++++++- internal/ui/desktop/log_panel.go | 127 +++++++++++++++++---- internal/ui/desktop/window.go | 8 +- internal/ui/log_panel.go | 128 +++++++++++++++++++-- internal/ui/log_panel_test.go | 183 ++++++++++++++++++++++++++++++- 7 files changed, 509 insertions(+), 45 deletions(-) diff --git a/docs/ui-spec.md b/docs/ui-spec.md index dc7bd23..ca28875 100644 --- a/docs/ui-spec.md +++ b/docs/ui-spec.md @@ -170,11 +170,19 @@ ### 12.9. Контракт виджета «Логи» (LogPanel) -Панель «Логи» — поток системных логов (сырые строки из `events.LogLine`). +Панель «Логи» — поток системных логов из `events.LogLine` (уровень + строка). -- `Append(text)` — добавить строку лога; при превышении лимита старые строки - отбрасываются (буфер ограничен, не расти бесконечно). -- Контейнер виджета — отдаётся окну для встраивания в таб «Логи» (Scroll). +- `Append(level, text)` — добавить запись лога; уровень нормализуется к одному + из четырёх (`ui.NormalizeLogLevel`: error/warning/info/debug, неизвестные → + info); при превышении лимита старые записи отбрасываются (буфер ограничен, + не расти бесконечно). +- **Фильтр уровней**: над лентой — выпадающий список с чекбоксами + (error/warning/info/debug). По умолчанию отмечены все уровни (виден полный + лог); показываются только записи выбранных уровней; изменение набора сразу + перерисовывает ленту — и по уже загруженным записям, и для вновь + поступающих. Если ни один уровень не выбран — лента пуста (без ошибок). +- Контейнер виджета — отдаётся окну для встраивания в таб «Логи» (строка + фильтра + Scroll с лентой). - Обновление содержимого — на потоке Fyne (внутри панели `fyne.Do`), не из горутины Hub. diff --git a/internal/events/log.go b/internal/events/log.go index affca1b..03c7a08 100644 --- a/internal/events/log.go +++ b/internal/events/log.go @@ -5,6 +5,14 @@ import ( "sync" ) +// Канонические уровни записей лога (фильтр панели «Логи», см. ui.LogLevels). +const ( + LevelError = "error" + LevelWarning = "warning" + LevelInfo = "info" + LevelDebug = "debug" +) + // LogLine — строка лога для панели «Логи». type LogLine struct { Level string @@ -13,6 +21,37 @@ type LogLine struct { func (LogLine) _event() {} +// markers проверяет наличие любого из маркеров в уже приведённой к нижнему +// регистру строке. +func markers(s string, anyOf ...string) bool { + for _, m := range anyOf { + if strings.Contains(s, m) { + return true + } + } + return false +} + +// ClassifyLevel определяет уровень строки лога по её тексту. +// +// Стандартный log.Printf не несёт уровня, поэтому применяются эвристики по +// маркерам в тексте (в приоритете debug → warning → error): "debug"/"trace" — +// отладка; "warn"/"предупреж" — предупреждение; "error"/"ошибк"/"failed"/ +// "fatal"/"panic" — ошибка. Остальные строки — информационные. +func ClassifyLevel(line string) string { + s := strings.ToLower(line) + switch { + case markers(s, "debug", "trace"): + return LevelDebug + case markers(s, "warn", "предупреж"): + return LevelWarning + case markers(s, "error", "ошибк", "failed", "failure", "fatal", "panic"): + return LevelError + default: + return LevelInfo + } +} + // LogBus — тип-обёртка над *Bus для логов. // // Логи идут отдельной шиной, чтобы большие объёмы текста не блокировали @@ -35,7 +74,8 @@ type LogWriter struct { buf strings.Builder } -// NewLogWriter создаёт LogWriter, публикующий в шину логов события LogLine{Level:"log"}. +// NewLogWriter создаёт LogWriter, публикующий строки лога как события +// LogLine; уровень каждой строки определяется эвристикой ClassifyLevel. func NewLogWriter(bus *LogBus) *LogWriter { return &LogWriter{bus: bus.Bus} } @@ -56,7 +96,7 @@ func (w *LogWriter) Write(p []byte) (int, error) { line := strings.TrimSuffix(data[:idx], "\r") data = data[idx+1:] if line != "" { - w.bus.Publish(LogLine{Level: "log", Text: line}) + w.bus.Publish(LogLine{Level: ClassifyLevel(line), Text: line}) } } w.buf.Reset() diff --git a/internal/events/log_test.go b/internal/events/log_test.go index 8300e06..a2a85d4 100644 --- a/internal/events/log_test.go +++ b/internal/events/log_test.go @@ -21,7 +21,8 @@ func TestLogWriterLines(t *testing.T) { if !ok { t.Fatalf("got %T, want LogLine", first) } - if ll.Text != "first line" || ll.Level != "log" { + // строка без маркеров ошибок/отладки — информационная + if ll.Text != "first line" || ll.Level != LevelInfo { t.Fatalf("unexpected first line: %+v", ll) } @@ -73,4 +74,49 @@ func TestLogWriterMultiSplit(t *testing.T) { if strings.Join(texts, ",") != "line1,line2,line3" { t.Fatalf("got %v", texts) } +} + +// ClassifyLevel определяет уровень строки по маркерам в тексте. +func TestClassifyLevel(t *testing.T) { + cases := []struct { + line string + want string + }{ + {"app: db opened /tmp/r.db", LevelInfo}, + {"app: worker started", LevelInfo}, + {"opencode: debug: poll request", LevelDebug}, + {"trace: session resumed", LevelDebug}, + {"tg: warn: long poll timeout", LevelWarning}, + {"ПРЕДУПРЕЖДЕНИЕ: конфиг не задан", LevelWarning}, + {"app: process /start #5: run failed: exit status 1", LevelError}, + {"ERROR: connection refused", LevelError}, + {"worker: job failed after 3 retries", LevelError}, + {"panic: runtime error", LevelError}, + } + for _, c := range cases { + if got := ClassifyLevel(c.line); got != c.want { + t.Errorf("ClassifyLevel(%q) = %q, want %q", c.line, got, c.want) + } + } +} + +// LogWriter публикует уровень, определённый по тексту строки. +func TestLogWriterClassifiesLevels(t *testing.T) { + lbus := NewLogBus(16) + ch, unsub := lbus.Subscribe() + defer unsub() + + w := NewLogWriter(lbus) + if _, err := w.Write([]byte("all good\nERROR: boom\n")); err != nil { + t.Fatalf("Write: %v", err) + } + + first := receiveOne(t, ch).(LogLine) + if first.Level != LevelInfo || first.Text != "all good" { + t.Fatalf("unexpected first: %+v", first) + } + second := receiveOne(t, ch).(LogLine) + if second.Level != LevelError || second.Text != "ERROR: boom" { + t.Fatalf("unexpected second: %+v", second) + } } \ No newline at end of file diff --git a/internal/ui/desktop/log_panel.go b/internal/ui/desktop/log_panel.go index 001571d..24a66d4 100644 --- a/internal/ui/desktop/log_panel.go +++ b/internal/ui/desktop/log_panel.go @@ -3,6 +3,8 @@ package desktop import ( + "strings" + "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/widget" @@ -10,40 +12,121 @@ import ( "github.com/kamelion/ratatoskr-go/internal/ui" ) -// LogPanel — Fyne-реализация ui.LogPanel (спец 12.8, 12.9): поток системных логов. -// -// Внутри — widget.Label с ограниченным буфером (maxLogLen); Append обрезает -// строку до лимита. Вызовы Append приходят из горутин Hub, поэтому обновление -// виджета выполняется через fyne.Do (на потоке Fyne). -type LogPanel struct { - label *widget.Label -} - // maxLogLen — обрезка буфера панели «Логи», чтобы не расти бесконечно. const maxLogLen = 200_000 -// NewLogPanel создаёт LogPanel. +// LogPanel — Fyne-реализация ui.LogPanel (спец 12.8, 12.9): поток системных +// логов с фильтром по уровням (error/warning/info/debug). +// +// Записи хранятся в ui.LogBuffer (ограниченный буфер); лента показывает +// только записи уровней, отмеченных в фильтре (по умолчанию отмечены все). +// Фильтр — выпадающий список с чекбоксами: кнопка над лентой открывает +// widget.PopUp с widget.Check по каждому уровню; изменение набора сразу +// перерисовывает ленту — и по уже загруженным записям, и для новых. +// +// Состояние (buf/selected) доступается только с потока Fyne: Append приходит +// из горутин Hub и заворачивается в fyne.Do, колбэки чекбоксов Fyne вызывает +// сам на своём потоке. +type LogPanel struct { + label *widget.Label + btn *widget.Button // кнопка-дропдаун фильтра уровней + + buf *ui.LogBuffer // буфер записей, старые вытесняются + selected map[string]bool // уровень → показывать (по умолчанию все) +} + +// NewLogPanel создаёт LogPanel со всеми включёнными уровнями. func NewLogPanel() *LogPanel { - l := widget.NewLabel("") - l.Wrapping = fyne.TextWrapWord - return &LogPanel{label: l} + p := &LogPanel{ + buf: ui.NewLogBuffer(maxLogLen), + selected: make(map[string]bool, len(ui.LogLevels)), + } + for _, lvl := range ui.LogLevels { + p.selected[lvl] = true + } + + p.label = widget.NewLabel("") + p.label.Wrapping = fyne.TextWrapWord + p.btn = widget.NewButton(p.filterCaption(), p.showFilterPopUp) + return p } -// Widget возвращает контейнер панели для встраивания в окно (таб «Логи»). +// Widget возвращает контейнер панели для встраивания в окно (таб «Логи»): +// строка фильтра сверху, лента лога в скролле снизу. func (p *LogPanel) Widget() fyne.CanvasObject { - return container.NewScroll(p.label) + return container.NewBorder( + container.NewHBox(p.btn), nil, nil, nil, + container.NewScroll(p.label), + ) } -// Append добавляет строку лога (спец 12.9), с ограничением буфера. -func (p *LogPanel) Append(text string) { +// Append добавляет запись лога (спец 12.9), с ограничением буфера. +// Вызывается из горутин Hub → вся работа на потоке Fyne через fyne.Do. +func (p *LogPanel) Append(level, text string) { fyne.Do(func() { - s := p.label.Text + text + "\n" - if len(s) > maxLogLen { - s = s[len(s)-maxLogLen:] + lvl := ui.NormalizeLogLevel(level) + dropped := p.buf.Append(lvl, text) + // Перерисовка нужна, если запись видима или вытеснение задело ленту. + if p.selected[lvl] || dropped { + p.render() } - p.label.SetText(s) }) } +// render перестраивает текст ленты из буфера с учётом выбранного набора +// уровней. Пустой набор → пустая лента (без ошибок). +func (p *LogPanel) render() { + p.label.SetText(p.buf.Render(p.selected)) +} + +// setLevel включает/выключает показ уровня и сразу обновляет ленту. +// Вызывается только с потока Fyne (колбэки чекбоксов). +func (p *LogPanel) setLevel(level string, on bool) { + if p.selected[level] == on { + return + } + p.selected[level] = on + p.btn.SetText(p.filterCaption()) + p.render() +} + +// filterCaption — подпись кнопки-дропдауна: текущий выбранный набор уровней. +func (p *LogPanel) filterCaption() string { + var chosen []string + for _, lvl := range ui.LogLevels { + if p.selected[lvl] { + chosen = append(chosen, lvl) + } + } + switch { + case len(chosen) == len(ui.LogLevels): + return "Уровни: все" + case len(chosen) == 0: + return "Уровни: нет" + default: + return "Уровни: " + strings.Join(chosen, "+") + } +} + +// showFilterPopUp открывает выпадающий список с чекбоксами уровней под +// кнопкой фильтра. Список пересобирается при каждом открытии — чекбоксы +// всегда отражают актуальное состояние фильтра. +func (p *LogPanel) showFilterPopUp() { + c := fyne.CurrentApp().Driver().CanvasForObject(p.btn) + if c == nil { + return // виджет ещё не встроен в окно + } + rows := make([]fyne.CanvasObject, 0, len(ui.LogLevels)) + for _, lvl := range ui.LogLevels { + lvl := lvl + cb := widget.NewCheck(lvl, func(on bool) { p.setLevel(lvl, on) }) + cb.SetChecked(p.selected[lvl]) + rows = append(rows, cb) + } + pop := widget.NewPopUp(container.NewPadded(container.NewVBox(rows...)), c) + pos := fyne.CurrentApp().Driver().AbsolutePositionForObject(p.btn) + pop.ShowAtPosition(fyne.NewPos(pos.X, pos.Y+p.btn.Size().Height)) +} + // compile-time проверка реализации контракта. -var _ ui.LogPanel = (*LogPanel)(nil) \ No newline at end of file +var _ ui.LogPanel = (*LogPanel)(nil) diff --git a/internal/ui/desktop/window.go b/internal/ui/desktop/window.go index 84c2158..6e74601 100644 --- a/internal/ui/desktop/window.go +++ b/internal/ui/desktop/window.go @@ -283,9 +283,9 @@ func (w *Window) refreshList() { w.taskList.Select(w.selected) } -// appendLog добавляет строку в панель «Логи» (делегирует в LogPanel). -func (w *Window) appendLog(text string) { - w.logs.Append(text) +// appendLog добавляет запись в панель «Логи» (делегирует в LogPanel). +func (w *Window) appendLog(level, text string) { + w.logs.Append(level, text) } // submitText отправляет ввод пользователя из вкладки в Router. @@ -442,7 +442,7 @@ func (w *Window) OnAgentActivity(e events.AgentActivity) { } func (w *Window) OnLog(e events.LogLine) { - w.appendLog(e.Text) + w.appendLog(e.Level, e.Text) } // compile-time проверка: окно реализует chat.Channel и ui.View. diff --git a/internal/ui/log_panel.go b/internal/ui/log_panel.go index 65fafb0..b40f6f4 100644 --- a/internal/ui/log_panel.go +++ b/internal/ui/log_panel.go @@ -9,15 +9,51 @@ // Контракты компонентов-виджетов (спец 12.8): интерфейс + Nil-реализация. package ui -// LogPanel — панель «Логи» (спец 12.9): поток системных логов (сырые строки). +import "strings" + +// Канонические уровни записей лога для фильтра панели «Логи» (спец 12.9). +const ( + LogLevelError = "error" + LogLevelWarning = "warning" + LogLevelInfo = "info" + LogLevelDebug = "debug" +) + +// LogLevels — уровни в порядке их вывода в фильтре виджета «Логи». +var LogLevels = []string{LogLevelError, LogLevelWarning, LogLevelInfo, LogLevelDebug} + +// NormalizeLogLevel приводит уровень записи лога к одному из канонических +// уровней (LogLevels). Синонимы маппятся на ближайший уровень (err/fatal → +// error, warn → warning, trace → debug); пустые и неизвестные значения +// ("log" от events.LogWriter и всё прочее) считаются информационными. +func NormalizeLogLevel(level string) string { + switch strings.ToLower(strings.TrimSpace(level)) { + case LogLevelError, "err", "fatal": + return LogLevelError + case LogLevelWarning, "warn": + return LogLevelWarning + case LogLevelDebug, "trace": + return LogLevelDebug + default: + return LogLevelInfo + } +} + +// LogPanel — панель «Логи» (спец 12.9): поток системных логов с уровнями. // -// Содержит строки лога в буфере ограниченного размера; при превышении лимита -// старые строки отбрасываются. Контейнер виджета отдаётся окну для встраивания -// в таб «Логи» — реализация (Fyne) сама владеет своим виджетом. +// Хранит записи (уровень + строка) в буфере ограниченного размера; при +// превышении лимита старые записи отбрасываются. Реализация обязана +// поддерживать фильтр по уровням: выпадающий список с чекбоксами +// (error/warning/info/debug), по умолчанию отмечены все уровни; показываются +// только записи выбранных уровней, изменение набора сразу обновляет ленту. +// Контейнер виджета отдаётся окну для встраивания в таб «Логи» — реализация +// (Fyne) сама владеет своим виджетом. type LogPanel interface { - // Append добавляет строку лога. Реализация обязана перекладывать работу - // на поток Fyne (fyne.Do / fyne.DoAndWait), т.к. вызывается из горутин Hub. - Append(text string) + // Append добавляет запись лога: level — уровень из events.LogLine + // (нормализуется через NormalizeLogLevel), text — строка лога. + // Реализация обязана перекладывать работу на поток Fyne (fyne.Do / + // fyne.DoAndWait), т.к. вызывается из горутин Hub. + Append(level, text string) } // NilLogPanel — no-op реализация LogPanel для headless-режима и тестов. @@ -26,4 +62,80 @@ type NilLogPanel struct{} // NewNilLogPanel создаёт NilLogPanel. func NewNilLogPanel() *NilLogPanel { return &NilLogPanel{} } -func (NilLogPanel) Append(string) {} \ No newline at end of file +func (NilLogPanel) Append(string, string) {} + +// LogEntry — запись буфера панели «Логи»: канонический уровень + строка. +type LogEntry struct { + Level string + Text string +} + +// LogBuffer — ограниченный буфер записей лога с фильтрацией по уровням +// (спец 12.9). Чистая логика без Fyne: используется Fyne-реализацией +// LogPanel и тестируется в internal/ui. +// +// Не синхронизирована: потокобезопасность обеспечивает владелец (все вызовы +// на потоке Fyne). +type LogBuffer struct { + entries []LogEntry + maxLen int // лимит суммарного объёма текста записей, символы +} + +// NewLogBuffer создаёт буфер ёмкостью maxLen символов текста записей. +func NewLogBuffer(maxLen int) *LogBuffer { + return &LogBuffer{maxLen: maxLen} +} + +// Append добавляет запись (уровень нормализуется через NormalizeLogLevel, +// хвостовые переводы строк срезаются). При превышении лимита старые записи +// вытесняются. Сообщает, было ли вытеснение/обрезка — владелец должен +// перерисовать ленту, даже если новая запись не видима. +func (b *LogBuffer) Append(level, text string) bool { + b.entries = append(b.entries, LogEntry{ + Level: NormalizeLogLevel(level), + Text: strings.TrimRight(text, "\r\n"), + }) + return b.trim() +} + +// trim удерживает суммарный объём текста записей в пределах maxLen, +// отбрасывая старые записи; одиночная запись длиннее лимита обрезается +// до последних maxLen символов. Сообщает, было ли вытеснение/обрезка. +func (b *LogBuffer) trim() bool { + total := 0 + for _, e := range b.entries { + total += len(e.Text) + 1 + } + dropped := false + for total > b.maxLen && len(b.entries) > 1 { + total -= len(b.entries[0].Text) + 1 + b.entries = b.entries[1:] + dropped = true + } + if n := len(b.entries); n > 0 && len(b.entries[n-1].Text) > b.maxLen { + b.entries[n-1].Text = b.entries[n-1].Text[len(b.entries[n-1].Text)-b.maxLen:] + dropped = true + } + return dropped +} + +// Entries возвращает копию списка записей буфера. +func (b *LogBuffer) Entries() []LogEntry { + out := make([]LogEntry, len(b.entries)) + copy(out, b.entries) + return out +} + +// Render строит текст ленты только из записей уровней, отмеченных в +// selected (уровень → показывать). Пустой или nil набор → пустая лента. +func (b *LogBuffer) Render(selected map[string]bool) string { + var s strings.Builder + for _, e := range b.entries { + if !selected[e.Level] { + continue + } + s.WriteString(e.Text) + s.WriteByte('\n') + } + return s.String() +} diff --git a/internal/ui/log_panel_test.go b/internal/ui/log_panel_test.go index 5a84ec5..c50e535 100644 --- a/internal/ui/log_panel_test.go +++ b/internal/ui/log_panel_test.go @@ -6,7 +6,182 @@ import "testing" // вызывается из горутин Hub и из тестов без Fyne. func TestNilLogPanelAppendNoop(t *testing.T) { p := NewNilLogPanel() - p.Append("line 1") - p.Append("") - p.Append("line 3") -} \ No newline at end of file + p.Append(LogLevelError, "line 1") + p.Append("", "") + p.Append(LogLevelDebug, "line 3") +} + +// NormalizeLogLevel приводит любые уровни к каноническим четырём: +// синонимы маппятся, неизвестные/пустые значения считаются info. +func TestNormalizeLogLevel(t *testing.T) { + cases := []struct { + in string + want string + }{ + {LogLevelError, LogLevelError}, + {"ERROR", LogLevelError}, + {" err ", LogLevelError}, + {"fatal", LogLevelError}, + {LogLevelWarning, LogLevelWarning}, + {"WARN", LogLevelWarning}, + {"Warning", LogLevelWarning}, + {LogLevelInfo, LogLevelInfo}, + {"INFO", LogLevelInfo}, + {LogLevelDebug, LogLevelDebug}, + {"DEBUG", LogLevelDebug}, + {"trace", LogLevelDebug}, + // события LogWriter без явного уровня и прочий мусор → info + {"log", LogLevelInfo}, + {"", LogLevelInfo}, + {" ", LogLevelInfo}, + {"unknown", LogLevelInfo}, + } + for _, c := range cases { + if got := NormalizeLogLevel(c.in); got != c.want { + t.Errorf("NormalizeLogLevel(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// LogLevels — ровно четыре уровня фильтра в фиксированном порядке. +func TestLogLevelsContract(t *testing.T) { + want := []string{LogLevelError, LogLevelWarning, LogLevelInfo, LogLevelDebug} + if len(LogLevels) != len(want) { + t.Fatalf("len(LogLevels) = %d, want %d", len(LogLevels), len(want)) + } + for i := range want { + if LogLevels[i] != want[i] { + t.Fatalf("LogLevels = %v, want %v", LogLevels, want) + } + } +} + +// allLevels — набор «отмечены все уровни» (состояние фильтра по умолчанию). +func allLevels() map[string]bool { + m := make(map[string]bool, len(LogLevels)) + for _, lvl := range LogLevels { + m[lvl] = true + } + return m +} + +// По умолчанию (все уровни отмечены) рендерится полный лог; уровни +// нормализуются при добавлении. +func TestLogBufferRenderAllByDefault(t *testing.T) { + b := NewLogBuffer(1000) + b.Append("error", "e1") + b.Append("warn", "w1") // синоним → warning + b.Append("log", "i1") // неизвестный уровень → info + b.Append("trace", "d1") // синоним → debug + + got := b.Render(allLevels()) + want := "e1\nw1\ni1\nd1\n" + if got != want { + t.Fatalf("Render(all) = %q, want %q", got, want) + } + + entries := b.Entries() + wantLvls := []string{LogLevelError, LogLevelWarning, LogLevelInfo, LogLevelDebug} + for i, e := range entries { + if e.Level != wantLvls[i] { + t.Fatalf("entry[%d].Level = %q, want %q", i, e.Level, wantLvls[i]) + } + } +} + +// Показываются только записи выбранных уровней. +func TestLogBufferRenderSelectedOnly(t *testing.T) { + b := NewLogBuffer(1000) + b.Append(LogLevelError, "err line") + b.Append(LogLevelWarning, "warn line") + b.Append(LogLevelInfo, "info line") + b.Append(LogLevelDebug, "debug line") + + onlyErrors := map[string]bool{LogLevelError: true} + if got, want := b.Render(onlyErrors), "err line\n"; got != want { + t.Fatalf("Render(error only) = %q, want %q", got, want) + } + + errorsAndDebug := map[string]bool{LogLevelError: true, LogLevelDebug: true} + if got, want := b.Render(errorsAndDebug), "err line\ndebug line\n"; got != want { + t.Fatalf("Render(error+debug) = %q, want %q", got, want) + } +} + +// Ни один уровень не выбран — лента пуста (и это не ошибка). +func TestLogBufferRenderEmptySelection(t *testing.T) { + b := NewLogBuffer(1000) + b.Append(LogLevelError, "err line") + b.Append(LogLevelInfo, "info line") + + if got := b.Render(map[string]bool{}); got != "" { + t.Fatalf("Render(none) = %q, want empty", got) + } + if got := b.Render(nil); got != "" { + t.Fatalf("Render(nil) = %q, want empty", got) + } +} + +// Фильтрация применяется и к уже загруженным записям: смена набора уровней +// меняет результат рендера того же буфера без новых Append. +func TestLogBufferRefilterLoadedEntries(t *testing.T) { + b := NewLogBuffer(1000) + b.Append(LogLevelError, "err line") + b.Append(LogLevelInfo, "info line") + + all := allLevels() + if got, want := b.Render(all), "err line\ninfo line\n"; got != want { + t.Fatalf("Render(all) = %q, want %q", got, want) + } + delete(all, LogLevelInfo) + if got, want := b.Render(all), "err line\n"; got != want { + t.Fatalf("Render(error) after refilter = %q, want %q", got, want) + } + all[LogLevelInfo] = true // вернули галочку — запись снова видима + if got, want := b.Render(all), "err line\ninfo line\n"; got != want { + t.Fatalf("Render(all) after re-check = %q, want %q", got, want) + } +} + +// Новые записи сразу попадают в ленту своего уровня. +func TestLogBufferNewEntriesVisibleImmediately(t *testing.T) { + b := NewLogBuffer(1000) + sel := map[string]bool{LogLevelError: true} + + b.Append(LogLevelError, "first") + if got, want := b.Render(sel), "first\n"; got != want { + t.Fatalf("Render = %q, want %q", got, want) + } + + b.Append(LogLevelInfo, "noise") // невидимый уровень — ленту не меняет + if got, want := b.Render(sel), "first\n"; got != want { + t.Fatalf("Render after invisible append = %q, want %q", got, want) + } + + b.Append(LogLevelError, "second") + if got, want := b.Render(sel), "first\nsecond\n"; got != want { + t.Fatalf("Render after visible append = %q, want %q", got, want) + } +} + +// Буфер ограничен: старые записи вытесняются, суммарный объём ≤ лимита. +func TestLogBufferBounded(t *testing.T) { + b := NewLogBuffer(10) + for i := 0; i < 5; i++ { + b.Append(LogLevelInfo, "0123456789") // каждая запись — ровно лимит + } + entries := b.Entries() + if len(entries) != 1 { + t.Fatalf("len(entries) = %d, want 1 (старые вытеснены)", len(entries)) + } + if entries[0].Text != "0123456789" { + t.Fatalf("text = %q", entries[0].Text) + } + + // одиночная запись длиннее лимита обрезается до последних maxLen символов + b2 := NewLogBuffer(4) + b2.Append(LogLevelInfo, "abcdefgh") + if got := b2.Entries()[0].Text; got != "efgh" { + t.Fatalf("oversized entry text = %q, want %q", got, "efgh") + } +} -- 2.49.1 From ee47785e1af46a195aa84c88adb79b97bbc442f6 Mon Sep 17 00:00:00 2001 From: "ki.sagidullin" Date: Fri, 21 Aug 2026 23:01:54 +0500 Subject: [PATCH 2/2] =?UTF-8?q?perf(ui):=20=D0=BA=D0=B0=D0=BF=20=D0=B1?= =?UTF-8?q?=D1=83=D1=84=D0=B5=D1=80=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2?= =?UTF-8?q?=2010MB,=20O(1)=20=D0=B2=D1=8B=D1=82=D0=B5=D1=81=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5,=20=D0=B4=D0=B5=D0=B1=D0=B0=D1=83=D0=BD?= =?UTF-8?q?=D1=81=20=D1=80=D0=B5=D0=BD=D0=B4=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LogBuffer: ленивое вытеснение через start + инкрементальный total убирает O(n^2) копирование entries[1:] при большом числе записей - LogPanel: лимит 10MB (maxLogBytes), перерисовка ленты коалесцируется (200мс) — строка не пересобирается на каждый append - build-publish-ui.ps1: корень проекта задаётся через PROJECT_PATH в .gitea-creds (относительно scripts/), без него — папка рядом со скриптом - тесты: живые записи Entries/Render, кап по байтам --- internal/ui/desktop/log_panel.go | 61 +++++++++++++++++++++++++++----- internal/ui/log_panel.go | 53 +++++++++++++++++---------- internal/ui/log_panel_test.go | 44 +++++++++++++++++++++++ scripts/build-publish-ui.ps1 | 19 ++++++++-- 4 files changed, 147 insertions(+), 30 deletions(-) diff --git a/internal/ui/desktop/log_panel.go b/internal/ui/desktop/log_panel.go index 24a66d4..89fffc5 100644 --- a/internal/ui/desktop/log_panel.go +++ b/internal/ui/desktop/log_panel.go @@ -4,6 +4,7 @@ package desktop import ( "strings" + "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" @@ -12,8 +13,13 @@ import ( "github.com/kamelion/ratatoskr-go/internal/ui" ) -// maxLogLen — обрезка буфера панели «Логи», чтобы не расти бесконечно. -const maxLogLen = 200_000 +// maxLogBytes — кап суммарного объёма текста буфера «Логи» (байты), чтобы +// не расти бесконечно и не копить мегабайты в памяти. +const maxLogBytes = 10 * 1024 * 1024 + +// renderFlushMs — дебаунс-интервал перерисовки ленты (мс): при всплеске +// логов рендер выполняется не на каждую запись, а раз в этот интервал. +const renderFlushMs = 200 // LogPanel — Fyne-реализация ui.LogPanel (спец 12.8, 12.9): поток системных // логов с фильтром по уровням (error/warning/info/debug). @@ -24,25 +30,31 @@ const maxLogLen = 200_000 // widget.PopUp с widget.Check по каждому уровню; изменение набора сразу // перерисовывает ленту — и по уже загруженным записям, и для новых. // -// Состояние (buf/selected) доступается только с потока Fyne: Append приходит -// из горутин Hub и заворачивается в fyne.Do, колбэки чекбоксов Fyne вызывает -// сам на своём потоке. +// Состояние (buf/selected/pending/flushScheduled/lastFlush) доступается только +// с потока Fyne: Append приходит из горутин Hub и заворачивается в fyne.Do, +// колбэки чекбоксов Fyne вызывает сам на своём потоке. Перерисовка ленты +// коалесцируется (см. scheduleFlush), т.к. строка ленты пересобирается +// целиком и при капе в 10MB на каждый append была бы дорогой. type LogPanel struct { label *widget.Label btn *widget.Button // кнопка-дропдаун фильтра уровней buf *ui.LogBuffer // буфер записей, старые вытесняются selected map[string]bool // уровень → показывать (по умолчанию все) + + pending bool // буфер изменился, рендер отложен (поток Fyne) + flushScheduled bool // фоновый флаш уже запущен (поток Fyne) + lastFlush int64 // мс последнего рендера (поток Fyne) } -// NewLogPanel создаёт LogPanel со всеми включёнными уровнями. +// NewLogPanel создаёт LogPanel с уровнями error/warning/info (debug скрыт). func NewLogPanel() *LogPanel { p := &LogPanel{ - buf: ui.NewLogBuffer(maxLogLen), + buf: ui.NewLogBuffer(maxLogBytes), selected: make(map[string]bool, len(ui.LogLevels)), } for _, lvl := range ui.LogLevels { - p.selected[lvl] = true + p.selected[lvl] = lvl != ui.LogLevelDebug } p.label = widget.NewLabel("") @@ -68,11 +80,42 @@ func (p *LogPanel) Append(level, text string) { dropped := p.buf.Append(lvl, text) // Перерисовка нужна, если запись видима или вытеснение задело ленту. if p.selected[lvl] || dropped { - p.render() + p.pending = true + p.scheduleFlush() } }) } +// scheduleFlush планирует отложенный рендер через renderFlushMs (или сразу, +// если с прошлого рендера прошло больше интервала). Все вызовы — с потока +// Fyne; фоновый поток только спит и по таймеру отправляет flush через fyne.Do, +// поэтому защита от повторного планирования (flushScheduled) работает без +// блокировок. +func (p *LogPanel) scheduleFlush() { + if p.flushScheduled { + return + } + p.flushScheduled = true + delay := int64(renderFlushMs) - (time.Now().UnixMilli() - p.lastFlush) + if delay < 1 { + delay = 1 + } + go func() { + time.Sleep(time.Duration(delay) * time.Millisecond) + fyne.Do(p.flush) + }() +} + +// flush выполняет отложенную перерисовку ленты (на потоке Fyne). +func (p *LogPanel) flush() { + p.flushScheduled = false + if p.pending { + p.pending = false + p.lastFlush = time.Now().UnixMilli() + p.render() + } +} + // render перестраивает текст ленты из буфера с учётом выбранного набора // уровней. Пустой набор → пустая лента (без ошибок). func (p *LogPanel) render() { diff --git a/internal/ui/log_panel.go b/internal/ui/log_panel.go index b40f6f4..d2b21ad 100644 --- a/internal/ui/log_panel.go +++ b/internal/ui/log_panel.go @@ -70,6 +70,12 @@ type LogEntry struct { Text string } +// compactThreshold — порог, при котором «мёртвые» (вытесненные сдвигом) +// записи физически убираются из буфера пачкой. Вытеснение лениво: оно лишь +// инкрементирует start, поэтому не копирует хвост массива на каждую +// вытесненную запись (иначе trim был бы O(n²)). Уборка выполняется редко. +const compactThreshold = 256 + // LogBuffer — ограниченный буфер записей лога с фильтрацией по уровням // (спец 12.9). Чистая логика без Fyne: используется Fyne-реализацией // LogPanel и тестируется в internal/ui. @@ -78,10 +84,12 @@ type LogEntry struct { // на потоке Fyne). type LogBuffer struct { entries []LogEntry - maxLen int // лимит суммарного объёма текста записей, символы + start int // индекс первого «живого» элемента (ленивое вытеснение) + total int // суммарный объём текста живых записей [start..], байты + maxLen int // лимит суммарного объёма текста записей, байты } -// NewLogBuffer создаёт буфер ёмкостью maxLen символов текста записей. +// NewLogBuffer создаёт буфер ёмкостью maxLen байт текста записей. func NewLogBuffer(maxLen int) *LogBuffer { return &LogBuffer{maxLen: maxLen} } @@ -95,42 +103,51 @@ func (b *LogBuffer) Append(level, text string) bool { Level: NormalizeLogLevel(level), Text: strings.TrimRight(text, "\r\n"), }) + b.total += len(b.entries[len(b.entries)-1].Text) + 1 return b.trim() } -// trim удерживает суммарный объём текста записей в пределах maxLen, -// отбрасывая старые записи; одиночная запись длиннее лимита обрезается -// до последних maxLen символов. Сообщает, было ли вытеснение/обрезка. +// trim удерживает суммарный объём текста живых записей в пределах maxLen, +// лениво вытесняя старые (инкремент start); одиночная запись длиннее лимита +// обрезается до последних maxLen байт. Сообщает, было ли вытеснение/обрезка. func (b *LogBuffer) trim() bool { - total := 0 - for _, e := range b.entries { - total += len(e.Text) + 1 - } dropped := false - for total > b.maxLen && len(b.entries) > 1 { - total -= len(b.entries[0].Text) + 1 - b.entries = b.entries[1:] + live := len(b.entries) - b.start + for b.total > b.maxLen && live > 1 { + b.total -= len(b.entries[b.start].Text) + 1 + b.start++ + live-- dropped = true } - if n := len(b.entries); n > 0 && len(b.entries[n-1].Text) > b.maxLen { + // Компакция «мёртвых» записей пачкой, а не на каждом append. + if b.start >= compactThreshold { + b.entries = b.entries[b.start:] + b.start = 0 + } + if n := len(b.entries); n > b.start && len(b.entries[n-1].Text) > b.maxLen { b.entries[n-1].Text = b.entries[n-1].Text[len(b.entries[n-1].Text)-b.maxLen:] + b.total = b.maxLen + 1 dropped = true } return dropped } -// Entries возвращает копию списка записей буфера. +// Entries возвращает копию списка живых записей буфера. func (b *LogBuffer) Entries() []LogEntry { - out := make([]LogEntry, len(b.entries)) - copy(out, b.entries) + out := make([]LogEntry, len(b.entries)-b.start) + copy(out, b.entries[b.start:]) return out } -// Render строит текст ленты только из записей уровней, отмеченных в +// Render строит текст ленты только из живых записей уровней, отмеченных в // selected (уровень → показывать). Пустой или nil набор → пустая лента. func (b *LogBuffer) Render(selected map[string]bool) string { + if selected == nil { + return "" + } var s strings.Builder - for _, e := range b.entries { + for i := b.start; i < len(b.entries); i++ { + e := b.entries[i] if !selected[e.Level] { continue } diff --git a/internal/ui/log_panel_test.go b/internal/ui/log_panel_test.go index c50e535..cc078bf 100644 --- a/internal/ui/log_panel_test.go +++ b/internal/ui/log_panel_test.go @@ -185,3 +185,47 @@ func TestLogBufferBounded(t *testing.T) { t.Fatalf("oversized entry text = %q, want %q", got, "efgh") } } + +// Буфер держит только живые записи: ленивое вытеснение (start) не даёт +// «мёртвым» записям попасть в Entries/Render. +func TestLogBufferLiveEntriesOnly(t *testing.T) { + b := NewLogBuffer(30) + for i := 0; i < 3; i++ { + b.Append(LogLevelInfo, "0123456789") // 10 байт + \n каждая + } + // сумма 33 > 30 → вытесняется первая запись, остаются две живые + entries := b.Entries() + if len(entries) != 2 { + t.Fatalf("len(Entries) = %d, want 2 (мёртвая запись скрыта)", len(entries)) + } + if entries[0].Text != "0123456789" || entries[1].Text != "0123456789" { + t.Fatalf("unexpected live entries: %+v", entries) + } + if got, want := b.Render(allLevels()), "0123456789\n0123456789\n"; got != want { + t.Fatalf("Render = %q, want %q", got, want) + } +} + +// Кап считается по байтам текста, а не по числу записей: короткие записи +// не вытесняются, пока их суммарный объём не превысит лимит. +func TestLogBufferBytesCap(t *testing.T) { + b := NewLogBuffer(100) + for i := 0; i < 3; i++ { + b.Append(LogLevelInfo, "hello") // 5 байт + \n + } + if got := len(b.Entries()); got != 3 { + t.Fatalf("len(Entries) = %d, want 3 (все помещаются)", got) + } + + // одна большая запись вытесняет всё старое (live=1) и обрезается + b2 := NewLogBuffer(8) + b2.Append(LogLevelInfo, "short") + b2.Append(LogLevelInfo, "verylongtext") + entries := b2.Entries() + if len(entries) != 1 { + t.Fatalf("len(Entries) = %d, want 1", len(entries)) + } + if entries[0].Text != "longtext" { + t.Fatalf("oversized entry text = %q, want %q", entries[0].Text, "longtext") + } +} diff --git a/scripts/build-publish-ui.ps1 b/scripts/build-publish-ui.ps1 index e752d36..d0aa4bc 100644 --- a/scripts/build-publish-ui.ps1 +++ b/scripts/build-publish-ui.ps1 @@ -15,6 +15,9 @@ CI собирает ТОЛЬКО headless-linux (см. .gitea/workflows/ci.yaml) GITEA_TOKEN=write:packages-токен UPDATE_TOKEN=read:package-токен GIT_MAIN_URL=http://gitea.hal9000.home + PROJECT_PATH=..\..\foo (опционально) корень собираемого проекта + относительно папки scripts/; без него — + папка рядом со скриптом - Ветка уже запушена в `main` (VERSION = commit- от текущего HEAD). Пример: @@ -24,14 +27,11 @@ CI собирает ТОЛЬКО headless-linux (см. .gitea/workflows/ci.yaml) $ErrorActionPreference = "Stop" $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$RepoRoot = Split-Path -Parent $ScriptDir $CredsFile = Join-Path $ScriptDir ".gitea-creds" $Package = "ratatoskr" $Owner = "kamelion" $Filename = "ratatoskr-windows-amd64.exe" -Set-Location $RepoRoot - # --- 0. Проверка C-тулчейна --- if ($env:CGO_ENABLED -eq "0") { Write-Error "CGO_ENABLED=0 — нужен C-компилятор (MinGW). Уберите его из env." } if (-not (Get-Command gcc -ErrorAction SilentlyContinue)) { @@ -59,6 +59,19 @@ $giteaUrl = $creds["GIT_MAIN_URL"] if (-not $giteaToken) { Write-Error "В $CredsFile нет GITEA_TOKEN (нужен write:packages)." } if (-not $giteaUrl) { Write-Error "В $CredsFile нет GIT_MAIN_URL." } +# --- 1a. Корень целевого проекта --- +# PROJECT_PATH из .gitea-creds (относительно папки scripts/) либо, если не задан, +# папка рядом со скриптом (прежнее поведение). +$projPath = $creds["PROJECT_PATH"] +if ($projPath -ne "" -and $projPath) { + $RepoRoot = (Resolve-Path -LiteralPath (Join-Path $ScriptDir $projPath)).Path + if (-not $RepoRoot) { Write-Error "PROJECT_PATH не найден: $projPath (относительно $ScriptDir)" } +} else { + $RepoRoot = Split-Path -Parent $ScriptDir +} +Set-Location $RepoRoot +Write-Host "[root] $RepoRoot" + # --- 2. Версия = commit- текущего HEAD (совпадает с именованием CI) --- $sha = (git rev-parse --short HEAD).Trim() if (-not $sha) { Write-Error "git rev-parse --short HEAD не дал хэш." } -- 2.49.1