v0.33.0 tui tap for live view and console enhancements
This commit is contained in:
1 parent
a1e9d21d06
commit
87f5576ccf
41 files changed
+4606
-449
No files matched your search
@@ -0,0 +1,859 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/lixenwraith/logwisp/internal/compose"
|
||||
"github.com/lixenwraith/logwisp/internal/config"
|
||||
"github.com/lixenwraith/logwisp/internal/core"
|
||||
"github.com/lixenwraith/logwisp/internal/logline"
|
||||
|
||||
"github.com/lixenwraith/terminal"
|
||||
ui "github.com/lixenwraith/terminal/tui"
|
||||
)
|
||||
|
||||
const (
|
||||
splitMin = 40 // the shortest screen with the diagram over the live view
|
||||
ringBytes = 32 << 20 // what the view's rows may hold
|
||||
drainBytes = 1 << 20 // what a tick reads from the taps; the rest waits for the next
|
||||
drainEvery = 100 * time.Millisecond
|
||||
detailBeside = 160 // the narrowest view with the detail beside its rows
|
||||
rowBytes = 400 // a row's own fields, counted beside its text
|
||||
fieldBytes = 32 // a field's two string headers
|
||||
)
|
||||
|
||||
// passed ranks the verdict of an entry no stage dropped: past every stage
|
||||
const passed = 1 << 30
|
||||
|
||||
// stages are the flow's stages in the order entries pass them
|
||||
var stages = func() (out []string) {
|
||||
for _, s := range config.FlowStages() {
|
||||
out = append(out, s.Key)
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
// rank orders a verdict along the flow: the stage that dropped the entry, a
|
||||
// filter by its index, or passed
|
||||
func rank(stage string, index int) int {
|
||||
if stage == "" {
|
||||
return passed
|
||||
}
|
||||
return slices.Index(stages, stage)<<16 | min(max(index, 0), 1<<16-1)
|
||||
}
|
||||
|
||||
// row is an entry the view holds, read once through logline on arrival into
|
||||
// text of its own, or a rule across the rows where a run ended
|
||||
type row struct {
|
||||
logline.Line // Level as written when outside core.Levels (PROC)
|
||||
seq uint64
|
||||
at time.Time
|
||||
level int // in core.Levels, -1 for none or one outside them
|
||||
fields string // Fields as k=v, as the list shows them
|
||||
pipe, source string // pipe is "" for lw's own log and rules
|
||||
stage string
|
||||
index, rank int
|
||||
payload []byte
|
||||
size int // what it holds: its text, payload and rowBytes
|
||||
mark string // a rule's text
|
||||
}
|
||||
|
||||
// view is the live view: rows bounded by what they hold, and the seqs of
|
||||
// those the filters pass, oldest first. The screen's goroutine alone uses it.
|
||||
type view struct {
|
||||
rows []row
|
||||
bytes int
|
||||
next uint64
|
||||
shown []uint64
|
||||
cursor int // in shown
|
||||
scroll int
|
||||
paused bool // the cursor keeps its row; else it is on the newest
|
||||
detail bool
|
||||
dscroll int
|
||||
tagW int
|
||||
listH int
|
||||
mode int
|
||||
floor int // 0 shows every row, n those of core.Levels[n-1] and above
|
||||
own bool // the point is lw's own log until the canvas cursor moves
|
||||
pat pattern
|
||||
prompt *ui.TextFieldState // the pattern being typed
|
||||
built query // what shown holds
|
||||
}
|
||||
|
||||
// query is what picks the rows shown
|
||||
type query struct {
|
||||
at point
|
||||
mode, floor int
|
||||
pattern string
|
||||
}
|
||||
|
||||
// verdicts are what the view shows at its point: all that reaches it, what
|
||||
// passed it, what it dropped
|
||||
var verdicts = [...]string{"all", "passed", "dropped"}
|
||||
|
||||
const (
|
||||
modeAll = iota
|
||||
modePassed
|
||||
modeDropped
|
||||
)
|
||||
|
||||
// point is where on a pipeline the view looks, as the canvas cursor picks
|
||||
// it: the verdicts reaching it rank from reach, those it drops from lo to
|
||||
// hi; a source's and the heartbeat's take only their own entries. own is
|
||||
// lw's own log alone, which s picks.
|
||||
type point struct {
|
||||
pipe, name string
|
||||
one, own bool
|
||||
source string
|
||||
reach int
|
||||
lo, hi int
|
||||
}
|
||||
|
||||
// lwShown is the lowest level of lw's own log a pipeline's point shows, so
|
||||
// its debug records do not bury the entries
|
||||
var lwShown = slices.IndexFunc(core.Levels, func(l core.Level) bool { return l.Level == "INFO" })
|
||||
|
||||
// shows reports whether the point in mode shows r: lw's own log shows from
|
||||
// info up at every point and whole at its own, which names no pipeline; a
|
||||
// heartbeat only at the sinks' and its own
|
||||
func (p point) shows(r *row, mode int) bool {
|
||||
switch {
|
||||
case r.pipe == "":
|
||||
return p.own || r.level >= lwShown
|
||||
case r.pipe != p.pipe || p.one && r.source != p.source, r.source == "" && p.reach != passed:
|
||||
return false
|
||||
case mode == modeDropped:
|
||||
return p.lo <= r.rank && r.rank <= p.hi
|
||||
case mode == modePassed:
|
||||
return r.rank > p.hi
|
||||
}
|
||||
return r.rank >= p.reach
|
||||
}
|
||||
|
||||
// needsDrops reports whether the point in mode shows entries the flow
|
||||
// dropped, which the tap then takes too
|
||||
func (p point) needsDrops(mode int) bool {
|
||||
switch mode {
|
||||
case modeDropped:
|
||||
return p.lo <= p.hi
|
||||
case modePassed:
|
||||
return p.hi+1 < passed
|
||||
}
|
||||
return p.reach < passed
|
||||
}
|
||||
|
||||
// point is where the canvas cursor looks: a source, a flow stage or the
|
||||
// filter the inspector is on, the heartbeat, or the sinks
|
||||
func (a *app) point() point {
|
||||
p := point{name: "sinks", reach: passed, hi: passed - 1}
|
||||
switch {
|
||||
case a.view.own:
|
||||
return point{name: "lw's own log", own: true, reach: passed, lo: passed, hi: passed - 1}
|
||||
case len(a.comp.Pipelines) == 0:
|
||||
return p
|
||||
}
|
||||
p.pipe = a.pipeline().Name
|
||||
n, ok := a.node()
|
||||
if l := a.lines(); a.inspect && ok && n.Role == "filters" && len(l) > 0 {
|
||||
if m := l[min(a.insp.cursor, len(l)-1)].node; m.Index < len(a.pipeline().Flow.Filters) {
|
||||
r := rank("filters", m.Index)
|
||||
return point{pipe: p.pipe, name: fmt.Sprintf("filter %d", m.Index+1), reach: r, lo: r, hi: r}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case !ok || n.Role == "sink":
|
||||
case n.Role == "source":
|
||||
p.name, p.one, p.source, p.reach = "source "+n.ID, true, n.ID, 0
|
||||
case n.Role == "heartbeat":
|
||||
p.name, p.one, p.lo, p.hi = "heartbeat", true, 1, 0
|
||||
default:
|
||||
p.name, p.reach, p.lo, p.hi = n.Role, rank(n.Role, 0), rank(n.Role, 0), rank(n.Role, 1<<16-1)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// take adds a tapped record as a row and returns what the row holds
|
||||
func (v *view) take(t core.Tapped) int {
|
||||
e := t.Event.Entry
|
||||
r := row{Line: logline.Read(e), pipe: t.Pipeline, source: t.Source,
|
||||
stage: t.Stage, index: t.Index, rank: rank(t.Stage, t.Index), payload: t.Event.Payload}
|
||||
r.at = r.Time
|
||||
for _, at := range []time.Time{e.Time, t.Event.Time, time.Now()} {
|
||||
if r.at.IsZero() {
|
||||
r.at = at
|
||||
}
|
||||
}
|
||||
r.level = slices.IndexFunc(core.Levels, func(l core.Level) bool { return l.Level == r.Level })
|
||||
var b strings.Builder
|
||||
for _, f := range r.Fields {
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
if f.Key != "" {
|
||||
b.WriteString(f.Key + "=")
|
||||
}
|
||||
b.WriteString(f.Value)
|
||||
}
|
||||
r.fields = b.String()
|
||||
r.size = rowBytes + r.own() + len(r.fields) + len(r.payload)
|
||||
v.tagW = max(v.tagW, ui.RuneLen(r.Tag))
|
||||
v.add(r)
|
||||
return r.size
|
||||
}
|
||||
|
||||
// own copies the line's text, which logline cuts from the entry's, so a row
|
||||
// pins no entry and its size is what it holds; it returns those bytes
|
||||
func (r *row) own() int {
|
||||
l := &r.Line
|
||||
for _, s := range []*string{&l.Stamp, &l.Level, &l.Tag, &l.Message, &l.Layout} {
|
||||
*s = strings.Clone(*s)
|
||||
}
|
||||
n := len(l.Stamp) + len(l.Level) + len(l.Tag) + len(l.Message) + len(l.Layout)
|
||||
fields := make([]logline.Field, len(l.Fields))
|
||||
for i, f := range l.Fields {
|
||||
fields[i] = logline.Field{Key: strings.Clone(f.Key), Value: strings.Clone(f.Value)}
|
||||
n += fieldBytes + len(f.Key) + len(f.Value)
|
||||
}
|
||||
l.Fields = fields
|
||||
return n
|
||||
}
|
||||
|
||||
// mark draws a rule across the rows, which every filter shows
|
||||
func (v *view) mark(text string) { v.add(row{mark: text, level: -1, size: rowBytes}) }
|
||||
|
||||
// add appends r, then evicts the oldest rows past ringBytes; the cursor
|
||||
// keeps its row, or takes the oldest kept when its own went
|
||||
func (v *view) add(r row) {
|
||||
r.seq = v.next
|
||||
v.next++
|
||||
v.rows = append(v.rows, r)
|
||||
v.bytes += r.size
|
||||
if v.passes(&v.rows[len(v.rows)-1]) {
|
||||
v.shown = append(v.shown, r.seq)
|
||||
}
|
||||
for v.bytes > ringBytes && len(v.rows) > 1 {
|
||||
old := v.rows[0].seq
|
||||
v.bytes -= v.rows[0].size
|
||||
v.rows[0] = row{} // the array keeps no evicted text
|
||||
v.rows = v.rows[1:]
|
||||
if len(v.shown) > 0 && v.shown[0] == old {
|
||||
v.shown = v.shown[1:]
|
||||
v.cursor, v.scroll = max(0, v.cursor-1), max(0, v.scroll-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// passes reports whether the query shown was built for shows r; a rule shows
|
||||
// always
|
||||
func (v *view) passes(r *row) bool {
|
||||
q := v.built
|
||||
switch {
|
||||
case r.mark != "":
|
||||
return true
|
||||
case !q.at.shows(r, q.mode), q.floor > 0 && r.level < q.floor-1:
|
||||
return false
|
||||
}
|
||||
return v.pat.match(r.Message) || v.pat.match(r.fields) || v.pat.match(r.Tag)
|
||||
}
|
||||
|
||||
// sync rebuilds the rows shown when the point or a filter changed, and has
|
||||
// the tap take drops while they show. A paused cursor stays on its row, and
|
||||
// that on its screen row; so does a following one when a filter changed, which
|
||||
// pauses it unless its row is still the newest.
|
||||
func (v *view) sync(at point, tap *core.Tap) {
|
||||
if tap != nil {
|
||||
tap.All.Store(at.needsDrops(v.mode))
|
||||
}
|
||||
q, was := query{at, v.mode, v.floor, v.pat.src}, v.built
|
||||
if q == was {
|
||||
return
|
||||
}
|
||||
v.built = q
|
||||
f := v.focused()
|
||||
line := v.cursor - v.scroll
|
||||
v.shown = v.shown[:0]
|
||||
for i := range v.rows {
|
||||
if v.passes(&v.rows[i]) {
|
||||
v.shown = append(v.shown, v.rows[i].seq)
|
||||
}
|
||||
}
|
||||
if f != nil && (v.paused || q.pattern != was.pattern || q.floor != was.floor) {
|
||||
v.cursor, _ = slices.BinarySearch(v.shown, f.seq)
|
||||
v.scroll = v.cursor - line
|
||||
v.paused = v.paused || v.cursor < len(v.shown)-1
|
||||
}
|
||||
v.settle()
|
||||
}
|
||||
|
||||
// settle puts the cursor on the newest row while following, and in range
|
||||
func (v *view) settle() {
|
||||
if !v.paused {
|
||||
v.cursor = len(v.shown) - 1
|
||||
}
|
||||
v.cursor = max(0, min(v.cursor, len(v.shown)-1))
|
||||
}
|
||||
|
||||
// move moves the cursor d rows: off the newest pauses, onto it follows
|
||||
func (v *view) move(d int) {
|
||||
v.settle()
|
||||
v.cursor = max(0, min(v.cursor+d, len(v.shown)-1))
|
||||
v.paused, v.dscroll = v.cursor < len(v.shown)-1, 0
|
||||
}
|
||||
|
||||
// focused is the cursor's row, nil while none shows
|
||||
func (v *view) focused() *row {
|
||||
if len(v.shown) == 0 {
|
||||
return nil
|
||||
}
|
||||
v.settle()
|
||||
return &v.rows[v.shown[v.cursor]-v.rows[0].seq]
|
||||
}
|
||||
|
||||
func (v *view) clear() {
|
||||
v.rows, v.shown, v.bytes, v.cursor, v.scroll, v.paused = nil, nil, 0, 0, 0, false
|
||||
}
|
||||
|
||||
// drain reads taps into the view until they are empty or it read budget
|
||||
// bytes; what is left waits for the next tick
|
||||
func (a *app) drain(budget int, taps ...*core.Tap) {
|
||||
for budget > 0 {
|
||||
took := false
|
||||
for _, t := range taps {
|
||||
if t == nil {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case rec := <-t.Records():
|
||||
budget -= a.view.take(rec)
|
||||
took = true
|
||||
default:
|
||||
}
|
||||
}
|
||||
if !took {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// viewKey takes the keys of the live view; the composer's stay inert but for
|
||||
// quitting, help, running, printing and the pipelines
|
||||
func (a *app) viewKey(ev terminal.Event) {
|
||||
v := &a.view
|
||||
if v.prompt != nil {
|
||||
a.promptKey(ev)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case ev.Type != terminal.EventKey:
|
||||
case is(ev, terminal.KeyUp, "k"):
|
||||
v.move(-1)
|
||||
case is(ev, terminal.KeyDown, "j"):
|
||||
v.move(1)
|
||||
case is(ev, terminal.KeyPageUp, ""):
|
||||
v.move(-ui.PageDelta(v.listH))
|
||||
case is(ev, terminal.KeyPageDown, ""):
|
||||
v.move(ui.PageDelta(v.listH))
|
||||
case is(ev, terminal.KeyHome, "g"):
|
||||
v.move(-len(v.shown))
|
||||
case is(ev, terminal.KeyEnd, "Gf"):
|
||||
v.paused = false
|
||||
case is(ev, terminal.KeyEnter, ""):
|
||||
v.detail, v.dscroll = !v.detail, 0
|
||||
case is(ev, terminal.KeyNone, "J"):
|
||||
v.dscroll++
|
||||
case is(ev, terminal.KeyNone, "K"):
|
||||
v.dscroll = max(0, v.dscroll-1)
|
||||
case is(ev, terminal.KeyNone, "/"):
|
||||
v.prompt = ui.NewTextFieldState(v.pat.src)
|
||||
case is(ev, terminal.KeyNone, "012345"):
|
||||
v.floor = int(ev.Rune - '0')
|
||||
case is(ev, terminal.KeyNone, "v"):
|
||||
v.mode = (v.mode + 1) % len(verdicts)
|
||||
case is(ev, terminal.KeyNone, "s"):
|
||||
a.dialog = a.pointMenu()
|
||||
case is(ev, terminal.KeyNone, "c"):
|
||||
v.clear()
|
||||
case is(ev, terminal.KeyNone, "i"):
|
||||
a.inspectDrop()
|
||||
case is(ev, terminal.KeyEscape, "") && v.detail:
|
||||
v.detail = false
|
||||
case is(ev, terminal.KeyEscape, "t"):
|
||||
a.viewing = false
|
||||
case is(ev, terminal.KeyCtrlC, "q?ro[]"):
|
||||
a.globalKey(ev)
|
||||
}
|
||||
}
|
||||
|
||||
// promptKey types the pattern; Enter shows the rows matching it, all when it
|
||||
// is empty
|
||||
func (a *app) promptKey(ev terminal.Event) {
|
||||
v := &a.view
|
||||
switch {
|
||||
case ev.Type == terminal.EventPaste:
|
||||
v.prompt.Paste(ev.Text)
|
||||
case ev.Type != terminal.EventKey:
|
||||
case is(ev, terminal.KeyEscape, ""):
|
||||
v.prompt = nil
|
||||
case is(ev, terminal.KeyEnter, ""):
|
||||
p, err := newPattern(v.prompt.Value())
|
||||
if v.prompt = nil; err != nil {
|
||||
a.status = "pattern: " + err.Error()
|
||||
return
|
||||
}
|
||||
v.pat = p
|
||||
default:
|
||||
v.prompt.HandleKey(ev.Key, ev.Rune, ev.Modifiers)
|
||||
}
|
||||
}
|
||||
|
||||
// inspectDrop opens the inspector on the stage that dropped the focused row
|
||||
func (a *app) inspectDrop() {
|
||||
w := a.view.focused()
|
||||
pi := -1
|
||||
if w != nil {
|
||||
pi = slices.IndexFunc(a.comp.Pipelines, func(p config.PipelineConfig) bool { return p.Name == w.pipe })
|
||||
}
|
||||
switch {
|
||||
case w == nil || pi < 0:
|
||||
a.note = "i inspects the stage that dropped an entry"
|
||||
return
|
||||
case w.stage == "":
|
||||
a.note = "it passed every stage"
|
||||
return
|
||||
}
|
||||
n := compose.Node{Role: w.stage}
|
||||
if w.stage == "filters" {
|
||||
n.Index = w.index
|
||||
}
|
||||
a.pi, a.viewing = pi, false
|
||||
a.choose(n)
|
||||
a.inspectAt(func(l line) bool { return l.node == n })
|
||||
}
|
||||
|
||||
// pointMenu picks where the view looks, moving the canvas cursor there, or
|
||||
// lw's own log while it reaches the screen: s
|
||||
func (a *app) pointMenu() dialog {
|
||||
type at struct {
|
||||
pi, col int
|
||||
n compose.Node
|
||||
own bool
|
||||
}
|
||||
var options []ui.Option
|
||||
var ats []at
|
||||
for pi, p := range a.comp.Pipelines {
|
||||
add := func(name, hint string, col int, n compose.Node) {
|
||||
if len(a.comp.Pipelines) > 1 {
|
||||
name = p.Name + " · " + name
|
||||
}
|
||||
options = append(options, ui.Option{Name: name, Hint: hint})
|
||||
ats = append(ats, at{pi: pi, col: col, n: n})
|
||||
}
|
||||
for _, s := range p.PluginSources {
|
||||
add("source "+s.ID, "its entries", sourcesCol, compose.Node{Role: "source", ID: s.ID})
|
||||
}
|
||||
for _, s := range stages {
|
||||
hint := "what reaches it, passes it, or it drops"
|
||||
if s == "heartbeat" {
|
||||
hint = "its heartbeats"
|
||||
}
|
||||
add(s, hint, flowCol, compose.Node{Role: s})
|
||||
}
|
||||
add("sinks", "what the sinks got", sinksCol, compose.Node{})
|
||||
}
|
||||
if a.live.state.Log != nil {
|
||||
options = append(options, ui.Option{Name: "lw's own log", Hint: "its records alone, debug too"})
|
||||
ats = append(ats, at{own: true})
|
||||
}
|
||||
return &chooser{title: "Look at", list: ui.NewOptionListState(options), pick: func(a *app, i int) {
|
||||
t := ats[i]
|
||||
if a.view.own = t.own; t.own {
|
||||
return
|
||||
}
|
||||
a.pi, a.col, a.row, a.inspect, a.bar = t.pi, t.col, [3]int{}, false, false
|
||||
if t.n.Role != "" {
|
||||
a.choose(t.n)
|
||||
}
|
||||
}}
|
||||
}
|
||||
|
||||
// drawView draws the live view: a rule naming its point and filters, the
|
||||
// rows, and the focused row's detail beside them from 160 columns, else
|
||||
// under them
|
||||
func (a *app) drawView(r ui.Region) {
|
||||
v, th := &a.view, a.th
|
||||
v.settle()
|
||||
title := v.built.at.name
|
||||
if !v.built.at.own {
|
||||
title += " · " + verdicts[v.mode]
|
||||
}
|
||||
if len(a.comp.Pipelines) > 1 && v.built.at.pipe != "" {
|
||||
title = v.built.at.pipe + " · " + title
|
||||
}
|
||||
if v.floor > 0 {
|
||||
title += " · " + core.Levels[v.floor-1].Level + " up"
|
||||
}
|
||||
if v.pat.src != "" {
|
||||
title += " · /" + v.pat.src
|
||||
}
|
||||
hint := fmt.Sprintf("following · %d rows", len(v.shown))
|
||||
if v.paused {
|
||||
hint = fmt.Sprintf("paused · %d newer", len(v.shown)-1-v.cursor)
|
||||
}
|
||||
r.Rule(0, a.font.flat(title, r.W), hint, th)
|
||||
if a.viewing {
|
||||
r.TextStyled(0, 0, string(th.Glyphs.Focus), th.Accent)
|
||||
}
|
||||
list := r.Sub(0, 1, r.W, r.H-1)
|
||||
if !v.detail {
|
||||
a.drawRows(list)
|
||||
return
|
||||
}
|
||||
if r.W >= detailBeside {
|
||||
w := r.W * 62 / 100
|
||||
a.drawRows(list.Sub(0, 0, w, list.H))
|
||||
a.drawDetail(list.Sub(w+1, 0, list.W-w-1, list.H))
|
||||
return
|
||||
}
|
||||
h := list.H - list.H*2/5
|
||||
a.drawRows(list.Sub(0, 0, list.W, h))
|
||||
a.drawDetail(list.Sub(0, h, list.W, list.H-h))
|
||||
}
|
||||
|
||||
func (a *app) drawRows(r ui.Region) {
|
||||
v := &a.view
|
||||
v.listH = r.H
|
||||
if len(v.shown) == 0 {
|
||||
r.TextStyled(2, 0, "nothing here yet", a.th.Muted)
|
||||
return
|
||||
}
|
||||
v.settle()
|
||||
v.scroll = ui.AdjustScroll(v.cursor, ui.ClampScroll(v.scroll, r.H, len(v.shown)), r.H, len(v.shown))
|
||||
tw, lw, gw := v.widths(r.W)
|
||||
first := v.rows[0].seq
|
||||
for y := 0; y < r.H && v.scroll+y < len(v.shown); y++ {
|
||||
i := v.scroll + y
|
||||
a.drawRow(r.Sub(0, y, r.W, 1), &v.rows[v.shown[i]-first], tw, lw, gw, i == v.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
// widths are a row's time, level and tag columns w wide; each drops as w
|
||||
// narrows, so none overlaps the message
|
||||
func (v *view) widths(w int) (tw, lw, gw int) {
|
||||
switch {
|
||||
case w >= 60:
|
||||
tw = 12
|
||||
case w >= 40:
|
||||
tw = 8
|
||||
}
|
||||
switch {
|
||||
case w >= 100:
|
||||
lw = 5
|
||||
case w >= 24:
|
||||
lw = 1
|
||||
}
|
||||
if w >= 50 {
|
||||
gw = max(6, min(16, v.tagW))
|
||||
}
|
||||
return tw, lw, gw
|
||||
}
|
||||
|
||||
// drawRow draws a row: the cursor's pointer, a mark when the flow dropped
|
||||
// it, its time, level and tag, then its message and, muted, its fields. On
|
||||
// the cursor row each role keeps its color on the bar, but for a reverse
|
||||
// bar, which ui.Style.On draws in one style.
|
||||
func (a *app) drawRow(r ui.Region, w *row, tw, lw, gw int, cursor bool) {
|
||||
th, f := a.th, a.font
|
||||
on := func(s ui.Style) ui.Style { return s }
|
||||
if cursor {
|
||||
r.FillStyle(th.Text.On(th.Selected))
|
||||
on = func(s ui.Style) ui.Style { return s.On(th.Selected) }
|
||||
r.TextStyled(0, 0, string(th.Glyphs.Pointer), on(th.Accent))
|
||||
}
|
||||
if w.mark != "" {
|
||||
dash := "─"
|
||||
if f.ascii {
|
||||
dash = "-"
|
||||
}
|
||||
text := dash + dash + " " + w.mark + " "
|
||||
r.TextStyled(2, 0, text+strings.Repeat(dash, max(0, r.W-2-ui.RuneLen(text))), on(th.Border))
|
||||
return
|
||||
}
|
||||
if w.rank != passed {
|
||||
r.TextStyled(1, 0, string([]rune(f.invalid)[0]), on(th.Error))
|
||||
}
|
||||
x := 3 // a space after the mark, so it is not read as the time's first letter
|
||||
if tw > 0 {
|
||||
r.TextStyled(x, 0, w.at.Local().Format("15:04:05.000")[:tw], on(th.Muted))
|
||||
x += tw + 1
|
||||
}
|
||||
if lw > 0 {
|
||||
style, ok := a.look.Levels[w.Level]
|
||||
if !ok {
|
||||
style = th.Text
|
||||
}
|
||||
r.TextStyled(x, 0, f.flat(w.Level, lw), on(style))
|
||||
x += lw + 1
|
||||
}
|
||||
if gw > 0 {
|
||||
r.TextStyled(x, 0, f.flat(w.Tag, gw), on(a.look.Source))
|
||||
x += gw + 1
|
||||
}
|
||||
msg := f.flat(w.Message, r.W-x)
|
||||
r.TextStyled(x, 0, msg, on(th.Text))
|
||||
if x += ui.RuneLen(msg) + 2; x < r.W {
|
||||
r.TextStyled(x, 0, f.flat(w.fields, r.W-x), on(th.Muted))
|
||||
}
|
||||
}
|
||||
|
||||
// dline is a line of the detail: a key, muted, beside its text
|
||||
type dline struct {
|
||||
key, text string
|
||||
style ui.Style
|
||||
}
|
||||
|
||||
// drawDetail draws the focused row whole, scrolled by J and K
|
||||
func (a *app) drawDetail(r ui.Region) {
|
||||
v, th := &a.view, a.th
|
||||
w := v.focused()
|
||||
hint := "J K scroll"
|
||||
if w != nil && w.pipe != "" && w.stage != "" {
|
||||
hint = "i inspects it " + hint
|
||||
}
|
||||
r.Rule(0, "detail", hint, th)
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
lines := a.details(w, r.W-2)
|
||||
v.dscroll = max(0, min(v.dscroll, len(lines)-(r.H-1)))
|
||||
for y := 1; y < r.H && v.dscroll+y-1 < len(lines); y++ {
|
||||
l := lines[v.dscroll+y-1]
|
||||
r.TextStyled(1, y, l.key, th.Muted)
|
||||
r.TextStyled(2+ui.RuneLen(l.key), y, l.text, l.style)
|
||||
}
|
||||
}
|
||||
|
||||
// details are a row's detail lines w wide: its time, level, origin and
|
||||
// verdict, the layout logline found, its message and fields, then its
|
||||
// payload as the sinks receive it; what a cell cannot show is an escape
|
||||
func (a *app) details(w *row, width int) []dline {
|
||||
th := a.th
|
||||
var out []dline
|
||||
put := func(key string, keyW int, value string, style ui.Style) {
|
||||
for i, s := range ui.WrapText(escape(value), max(1, width-keyW-1)) {
|
||||
if i > 0 {
|
||||
key = ""
|
||||
}
|
||||
out = append(out, dline{ui.PadRight(ui.Truncate(escape(key), keyW), keyW), s, style})
|
||||
}
|
||||
}
|
||||
put("time", 8, w.at.Local().Format("2006-01-02 15:04:05.000 MST"), th.Text)
|
||||
if w.Stamp != "" {
|
||||
put("written", 8, w.Stamp, th.Muted)
|
||||
}
|
||||
put("level", 8, w.Level, th.Text)
|
||||
put("from", 8, a.origin(w), th.Text)
|
||||
if w.pipe != "" {
|
||||
put("verdict", 8, a.verdict(w), th.Text)
|
||||
}
|
||||
put("layout", 8, w.Layout, th.Muted)
|
||||
put("message", 8, w.Message, th.Text)
|
||||
keyW := 8
|
||||
for _, f := range w.Fields {
|
||||
keyW = max(keyW, ui.RuneLen(f.Key))
|
||||
}
|
||||
for _, f := range w.Fields {
|
||||
put(f.Key, min(keyW, width/3), f.Value, th.Text)
|
||||
}
|
||||
if w.payload != nil {
|
||||
out = append(out, dline{text: "as sinks receive it", style: th.Accent})
|
||||
put("", 0, strings.TrimSuffix(string(w.payload), "\n"), th.Text)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// origin names where a row came from: lw's own log, or a pipeline's source
|
||||
// or heartbeat, and the tag the line names when it is another
|
||||
func (a *app) origin(w *row) string {
|
||||
switch {
|
||||
case w.pipe == "":
|
||||
return "lw's own log"
|
||||
case w.source == "":
|
||||
return "pipeline " + w.pipe + " · heartbeat"
|
||||
case w.Tag != w.source:
|
||||
return "pipeline " + w.pipe + " · source " + w.source + " · " + w.Tag
|
||||
}
|
||||
return "pipeline " + w.pipe + " · source " + w.source
|
||||
}
|
||||
|
||||
// verdict says what the flow did with a row: passed, or the stage, and for a
|
||||
// filter the patterns it has now, that dropped it
|
||||
func (a *app) verdict(w *row) string {
|
||||
switch w.stage {
|
||||
case "":
|
||||
return "passed every stage"
|
||||
case "filters":
|
||||
text := fmt.Sprintf("dropped by filter %d", w.index+1)
|
||||
if pi := slices.IndexFunc(a.comp.Pipelines, func(p config.PipelineConfig) bool { return p.Name == w.pipe }); pi >= 0 {
|
||||
if fs := a.comp.Pipelines[pi].Flow.Filters; w.index < len(fs) {
|
||||
text += ", " + string(fs[w.index].Type) + " " + strings.Join(fs[w.index].Patterns, " | ")
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
return "dropped by " + w.stage
|
||||
}
|
||||
|
||||
// oneCell reports whether a cell shows the rune r, decoded from size bytes,
|
||||
// as itself: not one a terminal acts on (logline.Control), and one taking a
|
||||
// column of its own
|
||||
func oneCell(r rune, size int) bool {
|
||||
if r >= ' ' && r < 0x7f {
|
||||
return true
|
||||
}
|
||||
return !logline.Control(r, size) && !unicode.In(r, unicode.Cc, unicode.Cf, unicode.Mn, unicode.Me, unicode.Mc, wideOrJoining)
|
||||
}
|
||||
|
||||
// flat is s on one row of at most w cells: a line break as the font's mark,
|
||||
// a tab as a space, what a cell cannot show as the font's placeholder, and
|
||||
// '…' where it is cut
|
||||
func (f font) flat(s string, w int) string {
|
||||
if w <= 0 {
|
||||
return ""
|
||||
}
|
||||
out := make([]rune, 0, min(w, len(s)))
|
||||
i := 0
|
||||
for i < len(s) && len(out) < w {
|
||||
r, n := utf8.DecodeRuneInString(s[i:])
|
||||
i += n
|
||||
switch {
|
||||
case r == '\n':
|
||||
r = f.newline
|
||||
case r == '\t':
|
||||
r = ' '
|
||||
case !oneCell(r, n):
|
||||
r = f.odd
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if i < len(s) && w > 1 {
|
||||
out[w-1] = '…'
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// escape writes each rune a cell cannot show as a \u escape and each invalid
|
||||
// byte as a \x one; line breaks stay, for the detail to keep them
|
||||
func escape(s string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); {
|
||||
r, n := utf8.DecodeRuneInString(s[i:])
|
||||
switch {
|
||||
case r == '\n' || oneCell(r, n):
|
||||
b.WriteString(s[i : i+n])
|
||||
case r == utf8.RuneError && n == 1:
|
||||
fmt.Fprintf(&b, `\x%02x`, s[i])
|
||||
case r > 0xffff:
|
||||
fmt.Fprintf(&b, `\U%08X`, r)
|
||||
default:
|
||||
fmt.Fprintf(&b, `\u%04X`, r)
|
||||
}
|
||||
i += n
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// pattern is a smart-case match: all lower case ignores case. Without
|
||||
// regexp metacharacters an ASCII pattern is a substring, found without
|
||||
// allocating; any other is RE2.
|
||||
type pattern struct {
|
||||
src, lit string
|
||||
fold bool
|
||||
re *regexp.Regexp
|
||||
}
|
||||
|
||||
func newPattern(s string) (pattern, error) {
|
||||
p := pattern{src: s, lit: s, fold: s == strings.ToLower(s)}
|
||||
if regexp.QuoteMeta(s) == s && !strings.ContainsFunc(s, func(r rune) bool { return r > unicode.MaxASCII }) {
|
||||
return p, nil
|
||||
}
|
||||
expr := s
|
||||
if p.fold {
|
||||
expr = "(?i)" + s
|
||||
}
|
||||
re, err := regexp.Compile(expr)
|
||||
p.re = re
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (p pattern) match(s string) bool {
|
||||
switch {
|
||||
case p.re != nil:
|
||||
return p.re.MatchString(s)
|
||||
case p.fold:
|
||||
return foldContains(s, p.lit)
|
||||
}
|
||||
return strings.Contains(s, p.lit)
|
||||
}
|
||||
|
||||
// foldContains reports whether s holds the lower-case ASCII needle, in any
|
||||
// case
|
||||
func foldContains(s, needle string) bool {
|
||||
for i := 0; i+len(needle) <= len(s); i++ {
|
||||
j := 0
|
||||
for j < len(needle) && lower(s[i+j]) == needle[j] {
|
||||
j++
|
||||
}
|
||||
if j == len(needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func lower(c byte) byte {
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
return c + 'a' - 'A'
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// wideOrJoining is what a terminal draws in two columns, East Asian wide and
|
||||
// fullwidth and emoji presentation as of Unicode 17, and the letters a
|
||||
// grapheme cluster joins to a neighbour (Hangul jamo, Prepend, SpacingMark and
|
||||
// Extend ones); a range spans the unassigned code points between its runes
|
||||
var wideOrJoining = &unicode.RangeTable{
|
||||
R16: []unicode.Range16{
|
||||
{0x0D4E, 0x0D4E, 1}, {0x0E33, 0x0E33, 1}, {0x0EB3, 0x0EB3, 1},
|
||||
{0x1100, 0x11FF, 1}, {0x231A, 0x231B, 1}, {0x2329, 0x232A, 1}, {0x23E9, 0x23EC, 1},
|
||||
{0x23F0, 0x23F0, 1}, {0x23F3, 0x23F3, 1}, {0x25FD, 0x25FE, 1}, {0x2614, 0x2615, 1},
|
||||
{0x2630, 0x2637, 1}, {0x2648, 0x2653, 1}, {0x267F, 0x267F, 1}, {0x268A, 0x268F, 1},
|
||||
{0x2693, 0x2693, 1}, {0x26A1, 0x26A1, 1}, {0x26AA, 0x26AB, 1}, {0x26BD, 0x26BE, 1},
|
||||
{0x26C4, 0x26C5, 1}, {0x26CE, 0x26CE, 1}, {0x26D4, 0x26D4, 1}, {0x26EA, 0x26EA, 1},
|
||||
{0x26F2, 0x26F3, 1}, {0x26F5, 0x26F5, 1}, {0x26FA, 0x26FA, 1}, {0x26FD, 0x26FD, 1},
|
||||
{0x2705, 0x2705, 1}, {0x270A, 0x270B, 1}, {0x2728, 0x2728, 1}, {0x274C, 0x274C, 1},
|
||||
{0x274E, 0x274E, 1}, {0x2753, 0x2755, 1}, {0x2757, 0x2757, 1}, {0x2795, 0x2797, 1},
|
||||
{0x27B0, 0x27B0, 1}, {0x27BF, 0x27BF, 1}, {0x2B1B, 0x2B1C, 1}, {0x2B50, 0x2B50, 1},
|
||||
{0x2B55, 0x2B55, 1}, {0x2E80, 0x303E, 1}, {0x3041, 0x3247, 1}, {0x3250, 0xA4C6, 1},
|
||||
{0xA960, 0xA97C, 1}, {0xAC00, 0xD7FF, 1}, {0xF900, 0xFAFF, 1}, {0xFE10, 0xFE19, 1},
|
||||
{0xFE30, 0xFE6B, 1}, {0xFF01, 0xFF60, 1}, {0xFF9E, 0xFF9F, 1}, {0xFFE0, 0xFFE6, 1},
|
||||
},
|
||||
R32: []unicode.Range32{
|
||||
{0x111C2, 0x111C3, 1}, {0x113D1, 0x113D1, 1}, {0x1193F, 0x1193F, 1}, {0x11941, 0x11941, 1},
|
||||
{0x11A84, 0x11A89, 1}, {0x11D46, 0x11D46, 1}, {0x11F02, 0x11F02, 1},
|
||||
{0x16FE0, 0x1B2FB, 1}, {0x1D300, 0x1D376, 1}, {0x1F004, 0x1F004, 1}, {0x1F0CF, 0x1F0CF, 1},
|
||||
{0x1F18E, 0x1F18E, 1}, {0x1F191, 0x1F19A, 1}, {0x1F1E6, 0x1F320, 1}, {0x1F32D, 0x1F335, 1},
|
||||
{0x1F337, 0x1F37C, 1}, {0x1F37E, 0x1F393, 1}, {0x1F3A0, 0x1F3CA, 1}, {0x1F3CF, 0x1F3D3, 1},
|
||||
{0x1F3E0, 0x1F3F0, 1}, {0x1F3F4, 0x1F3F4, 1}, {0x1F3F8, 0x1F43E, 1}, {0x1F440, 0x1F440, 1},
|
||||
{0x1F442, 0x1F4FC, 1}, {0x1F4FF, 0x1F53D, 1}, {0x1F54B, 0x1F54E, 1}, {0x1F550, 0x1F567, 1},
|
||||
{0x1F57A, 0x1F57A, 1}, {0x1F595, 0x1F596, 1}, {0x1F5A4, 0x1F5A4, 1}, {0x1F5FB, 0x1F64F, 1},
|
||||
{0x1F680, 0x1F6C5, 1}, {0x1F6CC, 0x1F6CC, 1}, {0x1F6D0, 0x1F6D2, 1}, {0x1F6D5, 0x1F6DF, 1},
|
||||
{0x1F6EB, 0x1F6EC, 1}, {0x1F6F4, 0x1F6FC, 1}, {0x1F7E0, 0x1F7F0, 1}, {0x1F90C, 0x1F93A, 1},
|
||||
{0x1F93C, 0x1F945, 1}, {0x1F947, 0x1F9FF, 1}, {0x1FA70, 0x1FAF8, 1}, {0x20000, 0x3FFFF, 1},
|
||||
},
|
||||
}
|
||||
Reference in new issue
Block a user