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 не дал хэш." }