perf(ui): кап буфера логов 10MB, O(1) вытеснение, дебаунс рендера
Some checks failed
CI / test (pull_request) Failing after 1m21s
CI / build-and-package (amd64, linux) (pull_request) Failing after 1m9s
CI / build-and-package (amd64, windows) (pull_request) Successful in 31s

- LogBuffer: ленивое вытеснение через start + инкрементальный total убирает
  O(n^2) копирование entries[1:] при большом числе записей
- LogPanel: лимит 10MB (maxLogBytes), перерисовка ленты коалесцируется
  (200мс) — строка не пересобирается на каждый append
- build-publish-ui.ps1: корень проекта задаётся через PROJECT_PATH в
  .gitea-creds (относительно scripts/), без него — папка рядом со скриптом
- тесты: живые записи Entries/Render, кап по байтам
This commit is contained in:
ki.sagidullin
2026-08-21 23:01:54 +05:00
parent a762a1df80
commit ee47785e1a
4 changed files with 147 additions and 30 deletions

View File

@@ -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
}