diff --git a/log_stream.go b/log_stream.go new file mode 100644 index 0000000..0e188a5 --- /dev/null +++ b/log_stream.go @@ -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) + }) +} diff --git a/log_stream_test.go b/log_stream_test.go new file mode 100644 index 0000000..782c686 --- /dev/null +++ b/log_stream_test.go @@ -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}) }) + }) +} diff --git a/messages_devtools.go b/messages_devtools.go index 8829dd6..c5c325c 100644 --- a/messages_devtools.go +++ b/messages_devtools.go @@ -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", "没有告警"}, }) } diff --git a/testdata/snapshots/darwin/log_stream-dark.png b/testdata/snapshots/darwin/log_stream-dark.png new file mode 100644 index 0000000..64003df Binary files /dev/null and b/testdata/snapshots/darwin/log_stream-dark.png differ diff --git a/testdata/snapshots/darwin/log_stream-light.png b/testdata/snapshots/darwin/log_stream-light.png new file mode 100644 index 0000000..68839db Binary files /dev/null and b/testdata/snapshots/darwin/log_stream-light.png differ