wip-devtools
This commit is contained in:
5 files changed
+225
No files matched your search
+122
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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}) })
|
||||
})
|
||||
}
|
||||
@@ -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", "没有告警"},
|
||||
})
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Reference in new issue
Block a user