feat(ui): фильтр уровней логов в панели «Логи»

This commit is contained in:
ki.sagidullin
2026-08-21 21:35:37 +05:00
parent ee76f9d89f
commit a762a1df80
7 changed files with 509 additions and 45 deletions

View File

@@ -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) {}
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()
}