wip-devtools

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:55:00 -04:00
1 parent 3c8d15b97d
commit e755f79371
5 files changed
+225

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
package mujica
import (
"fmt"
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// LogStreamState is a LogStream's pause, level filter and place.
type LogStreamState struct {
Paused bool
// MinLevel hides lines below it.
MinLevel LogLevel
// frozen is the line count at pause; lines after it wait.
frozen int
shown []int
scanned int
min LogLevel
rows ui.ListState
}
// LogStreamOptions configure a LogStream.
type LogStreamOptions struct {
// Height of the line well; zero means 320.
Height float32
Disabled bool
}
// LogStream shows live ANSI log lines that follow the end, pause and filter by level, virtualized.
func LogStream(c *ui.Context, lines []LogLine, s *LogStreamState, opts LogStreamOptions) ChoiceResult {
if s == nil {
panic("mujica: LogStream needs a state")
}
if opts.Height < 0 || s.MinLevel < LogDebug || s.MinLevel > LogError {
panic("mujica: LogStream needs Height >= 0 and a known MinLevel")
}
h := opts.Height
if h == 0 {
h = 320
}
var r ChoiceResult
r.Element = codePanel(c).Disabled(opts.Disabled).Height(h)
locked := r.Element.IsDisabled()
r.Element.Children(func() {
codeHeader(c, func() {
was := s.Paused
label := msg(c, "log_stream.pause")
if s.Paused {
label = msg(c, "log_stream.resume")
}
if Toggle(c, &s.Paused, label, ToggleOptions{Disabled: locked}).Changed() && !locked {
r.changed = true
} else {
s.Paused = was
}
levels := make([]Segment, 4)
for lv := LogDebug; lv <= LogError; lv++ {
levels[lv] = Segment{Label: logViewerName(c, lv)}
}
pick := int(s.MinLevel)
SegmentedControl(c, &pick, levels, SegmentedControlOptions{Label: msg(c, "log_stream.level"), Disabled: locked})
if LogLevel(pick) != s.MinLevel && !locked {
s.MinLevel, r.changed = LogLevel(pick), true
}
ui.Box(c).Grow(1)
if s.Paused && len(lines) > s.frozen {
codeCaption(c, fmt.Sprintf(msg(c, "log_stream.waiting"), len(lines)-s.frozen)).Role(ui.RoleStatus)
}
})
if !s.Paused {
s.frozen = len(lines)
}
logStreamFilter(lines[:min(s.frozen, len(lines))], s)
if len(s.shown) == 0 {
codeCaption(c, msg(c, "log_stream.empty")).Padding(Density(c).Unit() * 4)
return
}
s.rows.FollowEnd = !s.Paused
s.rows.Key = func(i int) any { return s.shown[i] }
d := Density(c)
ui.List(c, &s.rows, len(s.shown), func(i int) { logStreamRow(c, lines[s.shown[i]]) }).Grow(1).Padding(d.Unit(), 0)
})
if r.changed {
slog.Debug("mujica: log_stream change", "paused", s.Paused, "min_level", s.MinLevel)
}
return r
}
// logStreamFilter extends the shown lines, or redoes them after a filter change.
func logStreamFilter(lines []LogLine, s *LogStreamState) {
if s.min != s.MinLevel || s.scanned > len(lines) {
s.min, s.scanned, s.shown = s.MinLevel, 0, s.shown[:0]
}
for i := s.scanned; i < len(lines); i++ {
lv := lines[i].Level
if lv < LogDebug || lv > LogError {
panic("mujica: LogLine level out of range")
}
if lv >= s.MinLevel {
s.shown = append(s.shown, i)
}
}
s.scanned = len(lines)
}
// logStreamRow shows one line: time, level word and ANSI text.
func logStreamRow(c *ui.Context, l LogLine) {
k := Tokens(c)
d := Density(c)
col := [...]ui.Color{k.TextMuted, k.Info, k.Warning, k.Danger}[l.Level]
ui.Row(c).MinHeight(d.ControlHeight() - 8).Padding(0, d.Unit()*3).Gap(d.Unit() * 3).AlignItems(ui.Center).Children(func() {
if !l.Time.IsZero() {
codeMono(c, l.Time.Format("15:04:05.000")).TextColor(k.TextMuted).Shrink(0)
}
ui.Text(c, fmt.Sprintf("%-5s", logViewerName(c, l.Level))).Font(theme.MonoFont).FontSize(FontSize(c, codeSize)).
FontWeight(700).TextColor(col).SingleLine().Shrink(0)
ui.RichText(c, codeAnsiSpans(c, l.Text)...).Font(theme.MonoFont).FontSize(FontSize(c, codeSize)).Grow(1).MinWidth(0)
})
}
+97
View File
@@ -0,0 +1,97 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
func logStreamLines(n int) []LogLine {
out := make([]LogLine, n)
for i := range out {
out[i] = LogLine{Level: LogLevel(i % 4), Text: fmt.Sprintf("\x1b[1mline\x1b[0m %d", i)}
}
return out
}
func logStreamTester(lines *[]LogLine, s *LogStreamState, opts LogStreamOptions, changes *int) *ui.Tester {
return ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
if LogStream(c, *lines, s, opts).Changed() {
*changes++
}
}, 700, 400)
}
func TestLogStreamFollowPauseResume(t *testing.T) {
lines := logStreamLines(10)
var s LogStreamState
n := 0
tt := logStreamTester(&lines, &s, LogStreamOptions{}, &n)
if !tt.HasText("line 9") || tt.HasText("\x1b[1mline") {
t.Fatalf("texts %q", tt.Texts())
}
_ = tt.Click("Pause")
lines = append(lines, logStreamLines(13)[10:]...)
tt.Frame()
if !s.Paused || tt.HasText("line 12") || !tt.HasText("3 new lines") {
t.Fatalf("paused: %q", tt.Texts())
}
_ = tt.Click("Resume")
if s.Paused || !tt.HasText("line 12") || n != 2 {
t.Fatalf("resume: %q changes %d", tt.Texts(), n)
}
}
func TestLogStreamLevelFilter(t *testing.T) {
lines := logStreamLines(8)
var s LogStreamState
n := 0
tt := logStreamTester(&lines, &s, LogStreamOptions{}, &n)
_ = tt.Click("WARN")
if s.MinLevel != LogWarn || tt.HasText("line 5") || !tt.HasText("line 6") || !tt.HasText("line 7") {
t.Fatalf("filter: %v %q", s.MinLevel, tt.Texts())
}
tt.Key(0, ui.KeyRight)
if s.MinLevel != LogError || tt.HasText("line 6") {
t.Fatalf("keyboard filter: %v", s.MinLevel)
}
}
func TestLogStreamDisabledEmptyMany(t *testing.T) {
lines := logStreamLines(4)
var s LogStreamState
n := 0
tt := logStreamTester(&lines, &s, LogStreamOptions{Disabled: true}, &n)
_ = tt.Click("Pause")
_ = tt.Click("WARN")
if n != 0 || s.Paused || s.MinLevel != LogDebug {
t.Fatalf("disabled changed: %+v", s)
}
none := []LogLine{}
tt = logStreamTester(&none, &LogStreamState{}, LogStreamOptions{}, &n)
if !tt.HasText("No log lines") {
t.Fatal("empty text missing")
}
many := logStreamLines(100000)
var ms LogStreamState
tt = logStreamTester(&many, &ms, LogStreamOptions{}, &n)
if got := len(tt.Texts()); got > 300 || !tt.HasText("line 99999") {
t.Fatalf("%d texts; last line shown %v", got, tt.HasText("line 99999"))
}
}
func TestLogStreamSnapshot(t *testing.T) {
lines := []LogLine{
{Level: LogInfo, Text: "server listening on :8080"},
{Level: LogDebug, Text: "cache warm: 312 keys"},
{Level: LogWarn, Text: "slow query \x1b[33m412 ms\x1b[0m on guests"},
{Level: LogError, Text: "\x1b[31mpayment provider unreachable\x1b[0m"},
{Level: LogInfo, Text: "retry 1 of 3 succeeded"},
}
snapshot(t, "log_stream", 640, 260, func(c *ui.Context) {
var s LogStreamState
ui.Column(c).Padding(16).Children(func() { LogStream(c, lines, &s, LogStreamOptions{Height: 220}) })
})
}
+6
View File
@@ -66,6 +66,12 @@ func init() {
"dashboard_filter_bar.tags": {"Tags", "标签"},
"dashboard_filter_bar.clear": {"Clear filters", "清除过滤"},
"log_stream.pause": {"Pause", "暂停"},
"log_stream.resume": {"Resume", "继续"},
"log_stream.level": {"Minimum level", "最低级别"},
"log_stream.waiting": {"%d new lines", "%d 条新日志"},
"log_stream.empty": {"No log lines", "没有日志"},
"alert_list.empty": {"No alerts", "没有告警"},
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB