feat(ui): filtr urovney logov + cap bufera 10MB #12
@@ -4,6 +4,7 @@ package desktop
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/container"
|
"fyne.io/fyne/v2/container"
|
||||||
@@ -12,8 +13,13 @@ import (
|
|||||||
"github.com/kamelion/ratatoskr-go/internal/ui"
|
"github.com/kamelion/ratatoskr-go/internal/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxLogLen — обрезка буфера панели «Логи», чтобы не расти бесконечно.
|
// maxLogBytes — кап суммарного объёма текста буфера «Логи» (байты), чтобы
|
||||||
const maxLogLen = 200_000
|
// не расти бесконечно и не копить мегабайты в памяти.
|
||||||
|
const maxLogBytes = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
// renderFlushMs — дебаунс-интервал перерисовки ленты (мс): при всплеске
|
||||||
|
// логов рендер выполняется не на каждую запись, а раз в этот интервал.
|
||||||
|
const renderFlushMs = 200
|
||||||
|
|
||||||
// LogPanel — Fyne-реализация ui.LogPanel (спец 12.8, 12.9): поток системных
|
// LogPanel — Fyne-реализация ui.LogPanel (спец 12.8, 12.9): поток системных
|
||||||
// логов с фильтром по уровням (error/warning/info/debug).
|
// логов с фильтром по уровням (error/warning/info/debug).
|
||||||
@@ -24,25 +30,31 @@ const maxLogLen = 200_000
|
|||||||
// widget.PopUp с widget.Check по каждому уровню; изменение набора сразу
|
// widget.PopUp с widget.Check по каждому уровню; изменение набора сразу
|
||||||
// перерисовывает ленту — и по уже загруженным записям, и для новых.
|
// перерисовывает ленту — и по уже загруженным записям, и для новых.
|
||||||
//
|
//
|
||||||
// Состояние (buf/selected) доступается только с потока Fyne: Append приходит
|
// Состояние (buf/selected/pending/flushScheduled/lastFlush) доступается только
|
||||||
// из горутин Hub и заворачивается в fyne.Do, колбэки чекбоксов Fyne вызывает
|
// с потока Fyne: Append приходит из горутин Hub и заворачивается в fyne.Do,
|
||||||
// сам на своём потоке.
|
// колбэки чекбоксов Fyne вызывает сам на своём потоке. Перерисовка ленты
|
||||||
|
// коалесцируется (см. scheduleFlush), т.к. строка ленты пересобирается
|
||||||
|
// целиком и при капе в 10MB на каждый append была бы дорогой.
|
||||||
type LogPanel struct {
|
type LogPanel struct {
|
||||||
label *widget.Label
|
label *widget.Label
|
||||||
btn *widget.Button // кнопка-дропдаун фильтра уровней
|
btn *widget.Button // кнопка-дропдаун фильтра уровней
|
||||||
|
|
||||||
buf *ui.LogBuffer // буфер записей, старые вытесняются
|
buf *ui.LogBuffer // буфер записей, старые вытесняются
|
||||||
selected map[string]bool // уровень → показывать (по умолчанию все)
|
selected map[string]bool // уровень → показывать (по умолчанию все)
|
||||||
|
|
||||||
|
pending bool // буфер изменился, рендер отложен (поток Fyne)
|
||||||
|
flushScheduled bool // фоновый флаш уже запущен (поток Fyne)
|
||||||
|
lastFlush int64 // мс последнего рендера (поток Fyne)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLogPanel создаёт LogPanel со всеми включёнными уровнями.
|
// NewLogPanel создаёт LogPanel с уровнями error/warning/info (debug скрыт).
|
||||||
func NewLogPanel() *LogPanel {
|
func NewLogPanel() *LogPanel {
|
||||||
p := &LogPanel{
|
p := &LogPanel{
|
||||||
buf: ui.NewLogBuffer(maxLogLen),
|
buf: ui.NewLogBuffer(maxLogBytes),
|
||||||
selected: make(map[string]bool, len(ui.LogLevels)),
|
selected: make(map[string]bool, len(ui.LogLevels)),
|
||||||
}
|
}
|
||||||
for _, lvl := range ui.LogLevels {
|
for _, lvl := range ui.LogLevels {
|
||||||
p.selected[lvl] = true
|
p.selected[lvl] = lvl != ui.LogLevelDebug
|
||||||
}
|
}
|
||||||
|
|
||||||
p.label = widget.NewLabel("")
|
p.label = widget.NewLabel("")
|
||||||
@@ -68,11 +80,42 @@ func (p *LogPanel) Append(level, text string) {
|
|||||||
dropped := p.buf.Append(lvl, text)
|
dropped := p.buf.Append(lvl, text)
|
||||||
// Перерисовка нужна, если запись видима или вытеснение задело ленту.
|
// Перерисовка нужна, если запись видима или вытеснение задело ленту.
|
||||||
if p.selected[lvl] || dropped {
|
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 перестраивает текст ленты из буфера с учётом выбранного набора
|
// render перестраивает текст ленты из буфера с учётом выбранного набора
|
||||||
// уровней. Пустой набор → пустая лента (без ошибок).
|
// уровней. Пустой набор → пустая лента (без ошибок).
|
||||||
func (p *LogPanel) render() {
|
func (p *LogPanel) render() {
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ type LogEntry struct {
|
|||||||
Text string
|
Text string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// compactThreshold — порог, при котором «мёртвые» (вытесненные сдвигом)
|
||||||
|
// записи физически убираются из буфера пачкой. Вытеснение лениво: оно лишь
|
||||||
|
// инкрементирует start, поэтому не копирует хвост массива на каждую
|
||||||
|
// вытесненную запись (иначе trim был бы O(n²)). Уборка выполняется редко.
|
||||||
|
const compactThreshold = 256
|
||||||
|
|
||||||
// LogBuffer — ограниченный буфер записей лога с фильтрацией по уровням
|
// LogBuffer — ограниченный буфер записей лога с фильтрацией по уровням
|
||||||
// (спец 12.9). Чистая логика без Fyne: используется Fyne-реализацией
|
// (спец 12.9). Чистая логика без Fyne: используется Fyne-реализацией
|
||||||
// LogPanel и тестируется в internal/ui.
|
// LogPanel и тестируется в internal/ui.
|
||||||
@@ -78,10 +84,12 @@ type LogEntry struct {
|
|||||||
// на потоке Fyne).
|
// на потоке Fyne).
|
||||||
type LogBuffer struct {
|
type LogBuffer struct {
|
||||||
entries []LogEntry
|
entries []LogEntry
|
||||||
maxLen int // лимит суммарного объёма текста записей, символы
|
start int // индекс первого «живого» элемента (ленивое вытеснение)
|
||||||
|
total int // суммарный объём текста живых записей [start..], байты
|
||||||
|
maxLen int // лимит суммарного объёма текста записей, байты
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLogBuffer создаёт буфер ёмкостью maxLen символов текста записей.
|
// NewLogBuffer создаёт буфер ёмкостью maxLen байт текста записей.
|
||||||
func NewLogBuffer(maxLen int) *LogBuffer {
|
func NewLogBuffer(maxLen int) *LogBuffer {
|
||||||
return &LogBuffer{maxLen: maxLen}
|
return &LogBuffer{maxLen: maxLen}
|
||||||
}
|
}
|
||||||
@@ -95,42 +103,51 @@ func (b *LogBuffer) Append(level, text string) bool {
|
|||||||
Level: NormalizeLogLevel(level),
|
Level: NormalizeLogLevel(level),
|
||||||
Text: strings.TrimRight(text, "\r\n"),
|
Text: strings.TrimRight(text, "\r\n"),
|
||||||
})
|
})
|
||||||
|
b.total += len(b.entries[len(b.entries)-1].Text) + 1
|
||||||
return b.trim()
|
return b.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
// trim удерживает суммарный объём текста записей в пределах maxLen,
|
// trim удерживает суммарный объём текста живых записей в пределах maxLen,
|
||||||
// отбрасывая старые записи; одиночная запись длиннее лимита обрезается
|
// лениво вытесняя старые (инкремент start); одиночная запись длиннее лимита
|
||||||
// до последних maxLen символов. Сообщает, было ли вытеснение/обрезка.
|
// обрезается до последних maxLen байт. Сообщает, было ли вытеснение/обрезка.
|
||||||
func (b *LogBuffer) trim() bool {
|
func (b *LogBuffer) trim() bool {
|
||||||
total := 0
|
|
||||||
for _, e := range b.entries {
|
|
||||||
total += len(e.Text) + 1
|
|
||||||
}
|
|
||||||
dropped := false
|
dropped := false
|
||||||
for total > b.maxLen && len(b.entries) > 1 {
|
live := len(b.entries) - b.start
|
||||||
total -= len(b.entries[0].Text) + 1
|
for b.total > b.maxLen && live > 1 {
|
||||||
b.entries = b.entries[1:]
|
b.total -= len(b.entries[b.start].Text) + 1
|
||||||
|
b.start++
|
||||||
|
live--
|
||||||
dropped = true
|
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.entries[n-1].Text = b.entries[n-1].Text[len(b.entries[n-1].Text)-b.maxLen:]
|
||||||
|
b.total = b.maxLen + 1
|
||||||
dropped = true
|
dropped = true
|
||||||
}
|
}
|
||||||
return dropped
|
return dropped
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entries возвращает копию списка записей буфера.
|
// Entries возвращает копию списка живых записей буфера.
|
||||||
func (b *LogBuffer) Entries() []LogEntry {
|
func (b *LogBuffer) Entries() []LogEntry {
|
||||||
out := make([]LogEntry, len(b.entries))
|
out := make([]LogEntry, len(b.entries)-b.start)
|
||||||
copy(out, b.entries)
|
copy(out, b.entries[b.start:])
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render строит текст ленты только из записей уровней, отмеченных в
|
// Render строит текст ленты только из живых записей уровней, отмеченных в
|
||||||
// selected (уровень → показывать). Пустой или nil набор → пустая лента.
|
// selected (уровень → показывать). Пустой или nil набор → пустая лента.
|
||||||
func (b *LogBuffer) Render(selected map[string]bool) string {
|
func (b *LogBuffer) Render(selected map[string]bool) string {
|
||||||
|
if selected == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
var s strings.Builder
|
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] {
|
if !selected[e.Level] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,3 +185,47 @@ func TestLogBufferBounded(t *testing.T) {
|
|||||||
t.Fatalf("oversized entry text = %q, want %q", got, "efgh")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ CI собирает ТОЛЬКО headless-linux (см. .gitea/workflows/ci.yaml)
|
|||||||
GITEA_TOKEN=write:packages-токен
|
GITEA_TOKEN=write:packages-токен
|
||||||
UPDATE_TOKEN=read:package-токен
|
UPDATE_TOKEN=read:package-токен
|
||||||
GIT_MAIN_URL=http://gitea.hal9000.home
|
GIT_MAIN_URL=http://gitea.hal9000.home
|
||||||
|
PROJECT_PATH=..\..\foo (опционально) корень собираемого проекта
|
||||||
|
относительно папки scripts/; без него —
|
||||||
|
папка рядом со скриптом
|
||||||
- Ветка уже запушена в `main` (VERSION = commit-<sha7> от текущего HEAD).
|
- Ветка уже запушена в `main` (VERSION = commit-<sha7> от текущего HEAD).
|
||||||
|
|
||||||
Пример:
|
Пример:
|
||||||
@@ -24,14 +27,11 @@ CI собирает ТОЛЬКО headless-linux (см. .gitea/workflows/ci.yaml)
|
|||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
$RepoRoot = Split-Path -Parent $ScriptDir
|
|
||||||
$CredsFile = Join-Path $ScriptDir ".gitea-creds"
|
$CredsFile = Join-Path $ScriptDir ".gitea-creds"
|
||||||
$Package = "ratatoskr"
|
$Package = "ratatoskr"
|
||||||
$Owner = "kamelion"
|
$Owner = "kamelion"
|
||||||
$Filename = "ratatoskr-windows-amd64.exe"
|
$Filename = "ratatoskr-windows-amd64.exe"
|
||||||
|
|
||||||
Set-Location $RepoRoot
|
|
||||||
|
|
||||||
# --- 0. Проверка C-тулчейна ---
|
# --- 0. Проверка C-тулчейна ---
|
||||||
if ($env:CGO_ENABLED -eq "0") { Write-Error "CGO_ENABLED=0 — нужен C-компилятор (MinGW). Уберите его из env." }
|
if ($env:CGO_ENABLED -eq "0") { Write-Error "CGO_ENABLED=0 — нужен C-компилятор (MinGW). Уберите его из env." }
|
||||||
if (-not (Get-Command gcc -ErrorAction SilentlyContinue)) {
|
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 $giteaToken) { Write-Error "В $CredsFile нет GITEA_TOKEN (нужен write:packages)." }
|
||||||
if (-not $giteaUrl) { Write-Error "В $CredsFile нет GIT_MAIN_URL." }
|
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-<sha7> текущего HEAD (совпадает с именованием CI) ---
|
# --- 2. Версия = commit-<sha7> текущего HEAD (совпадает с именованием CI) ---
|
||||||
$sha = (git rev-parse --short HEAD).Trim()
|
$sha = (git rev-parse --short HEAD).Trim()
|
||||||
if (-not $sha) { Write-Error "git rev-parse --short HEAD не дал хэш." }
|
if (-not $sha) { Write-Error "git rev-parse --short HEAD не дал хэш." }
|
||||||
|
|||||||
Reference in New Issue
Block a user