- LogBuffer: ленивое вытеснение через start + инкрементальный total убирает O(n^2) копирование entries[1:] при большом числе записей - LogPanel: лимит 10MB (maxLogBytes), перерисовка ленты коалесцируется (200мс) — строка не пересобирается на каждый append - build-publish-ui.ps1: корень проекта задаётся через PROJECT_PATH в .gitea-creds (относительно scripts/), без него — папка рядом со скриптом - тесты: живые записи Entries/Render, кап по байтам
159 lines
7.8 KiB
Go
159 lines
7.8 KiB
Go
// Package ui — контроллер и представления десктопного интерфейса (Fyne).
|
||
//
|
||
// Слой обмена с ядром: однонаправленный поток (спец 12.2–12.5).
|
||
// - Core мутирует состояние; UI только читает снимки и реагирует на события.
|
||
// - Действия UI = команды (CreateTask/ApproveTask/...), которые зовут Core.
|
||
// - Подписчик шины (Controller) получает события и перекладывает их в View
|
||
// (реализация Fyne) через fyne.Do — никаких прямых вызовов Fyne из core.
|
||
//
|
||
// Контракты компонентов-виджетов (спец 12.8): интерфейс + Nil-реализация.
|
||
package ui
|
||
|
||
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): поток системных логов с уровнями.
|
||
//
|
||
// Хранит записи (уровень + строка) в буфере ограниченного размера; при
|
||
// превышении лимита старые записи отбрасываются. Реализация обязана
|
||
// поддерживать фильтр по уровням: выпадающий список с чекбоксами
|
||
// (error/warning/info/debug), по умолчанию отмечены все уровни; показываются
|
||
// только записи выбранных уровней, изменение набора сразу обновляет ленту.
|
||
// Контейнер виджета отдаётся окну для встраивания в таб «Логи» — реализация
|
||
// (Fyne) сама владеет своим виджетом.
|
||
type LogPanel interface {
|
||
// Append добавляет запись лога: level — уровень из events.LogLine
|
||
// (нормализуется через NormalizeLogLevel), text — строка лога.
|
||
// Реализация обязана перекладывать работу на поток Fyne (fyne.Do /
|
||
// fyne.DoAndWait), т.к. вызывается из горутин Hub.
|
||
Append(level, text string)
|
||
}
|
||
|
||
// NilLogPanel — no-op реализация LogPanel для headless-режима и тестов.
|
||
type NilLogPanel struct{}
|
||
|
||
// NewNilLogPanel создаёт NilLogPanel.
|
||
func NewNilLogPanel() *NilLogPanel { return &NilLogPanel{} }
|
||
|
||
func (NilLogPanel) Append(string, string) {}
|
||
|
||
// LogEntry — запись буфера панели «Логи»: канонический уровень + строка.
|
||
type LogEntry struct {
|
||
Level string
|
||
Text string
|
||
}
|
||
|
||
// compactThreshold — порог, при котором «мёртвые» (вытесненные сдвигом)
|
||
// записи физически убираются из буфера пачкой. Вытеснение лениво: оно лишь
|
||
// инкрементирует start, поэтому не копирует хвост массива на каждую
|
||
// вытесненную запись (иначе trim был бы O(n²)). Уборка выполняется редко.
|
||
const compactThreshold = 256
|
||
|
||
// LogBuffer — ограниченный буфер записей лога с фильтрацией по уровням
|
||
// (спец 12.9). Чистая логика без Fyne: используется Fyne-реализацией
|
||
// LogPanel и тестируется в internal/ui.
|
||
//
|
||
// Не синхронизирована: потокобезопасность обеспечивает владелец (все вызовы
|
||
// на потоке Fyne).
|
||
type LogBuffer struct {
|
||
entries []LogEntry
|
||
start int // индекс первого «живого» элемента (ленивое вытеснение)
|
||
total int // суммарный объём текста живых записей [start..], байты
|
||
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"),
|
||
})
|
||
b.total += len(b.entries[len(b.entries)-1].Text) + 1
|
||
return b.trim()
|
||
}
|
||
|
||
// trim удерживает суммарный объём текста живых записей в пределах maxLen,
|
||
// лениво вытесняя старые (инкремент start); одиночная запись длиннее лимита
|
||
// обрезается до последних maxLen байт. Сообщает, было ли вытеснение/обрезка.
|
||
func (b *LogBuffer) trim() bool {
|
||
dropped := false
|
||
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
|
||
}
|
||
// Компакция «мёртвых» записей пачкой, а не на каждом 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 возвращает копию списка живых записей буфера.
|
||
func (b *LogBuffer) Entries() []LogEntry {
|
||
out := make([]LogEntry, len(b.entries)-b.start)
|
||
copy(out, b.entries[b.start:])
|
||
return out
|
||
}
|
||
|
||
// Render строит текст ленты только из живых записей уровней, отмеченных в
|
||
// selected (уровень → показывать). Пустой или nil набор → пустая лента.
|
||
func (b *LogBuffer) Render(selected map[string]bool) string {
|
||
if selected == nil {
|
||
return ""
|
||
}
|
||
var s strings.Builder
|
||
for i := b.start; i < len(b.entries); i++ {
|
||
e := b.entries[i]
|
||
if !selected[e.Level] {
|
||
continue
|
||
}
|
||
s.WriteString(e.Text)
|
||
s.WriteByte('\n')
|
||
}
|
||
return s.String()
|
||
}
|