v0.33.0 tui tap for live view and console enhancements

This commit is contained in:
lixen committed 2026-10-10 21:32:08 -04:00
1 parent a1e9d21d06
commit 87f5576ccf
41 files changed
+4606 -449

No files matched your search

+17
View File
@@ -298,9 +298,26 @@ func readDevice(path, user string, generate bool) ([]byte, bool, error) {
return data, false, err
}
// asker answers in the terminal's place while a host's screen owns it
var asker atomic.Pointer[func(user string, generate bool) ([]byte, error)]
// SetAsker has fn answer what the terminal would be asked; nil asks the
// terminal again. With generate, fn draws a password for an empty answer and
// shows it once, never in a log.
func SetAsker(fn func(user string, generate bool) ([]byte, error)) {
if fn == nil {
asker.Store(nil)
return
}
asker.Store(&fn)
}
// ask prompts on the terminal at path without echo. A generated password is
// shown there only: logs persist, and stderr is often a file.
func ask(path, user string, generate bool) ([]byte, error) {
if fn := asker.Load(); fn != nil {
return (*fn)(user, generate)
}
tty, err := os.OpenFile(path, os.O_RDWR, 0)
if err != nil {
return nil, err
+38
View File
@@ -29,6 +29,7 @@ import (
"github.com/lixenwraith/logwisp/internal/testutil"
"github.com/lixenwraith/auth"
"golang.org/x/term"
)
func TestMain(m *testing.M) {
@@ -689,6 +690,43 @@ func TestOnceReadSourcesAreReadAtStartupOnly(t *testing.T) {
}
}
// While a host's screen owns the terminal its asker answers in the prompt's
// place, once per user, like the terminal (a pty master is a terminal).
func TestAskerAnswersForTheTerminal(t *testing.T) {
probe, err := os.Open("/dev/ptmx")
if err != nil {
t.Skip("no pseudo-terminal:", err)
}
isTerminal := term.IsTerminal(int(probe.Fd()))
probe.Close()
if !isTerminal {
t.Skip("/dev/ptmx is no terminal here")
}
var asked []string
SetAsker(func(user string, generate bool) ([]byte, error) {
asked = append(asked, fmt.Sprintf("%s %v", user, generate))
return []byte("screen-secret"), nil
})
t.Cleanup(func() { SetAsker(nil); readOnce.Lock(); clear(readOnce.m); readOnce.Unlock() })
done := make(chan struct{})
go func() {
defer close(done)
for _, user := range []string{"edge", "edge", "aggregator"} {
if pw, err := ReadPassword("/dev/ptmx", user, true); err != nil || pw != "screen-secret" {
t.Errorf("%s: %q, %v", user, pw, err)
}
}
}()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("the terminal was asked, not the asker")
}
if fmt.Sprint(asked) != "[edge true aggregator true]" {
t.Errorf("asked %v", asked)
}
}
// A device is classed by the file it opens, not by stat: FreeBSD stats
// /dev/stdin as a device whatever it holds, so one that is no terminal is read.
func TestADeviceThatIsNoTerminalIsRead(t *testing.T) {
+6
View File
@@ -29,6 +29,12 @@ func Network(host string) (string, error) {
const (
MaxLogEntryBytes = 1024 * 1024
// A tapped entry's message, fields and payload are cut to this, and its
// level, source and node to TapNameBytes, so a viewer's scrollback holds
// no source's whole line
TapEntryBytes = 16 * 1024
TapNameBytes = 256
FileWatcherPollInterval = 100 * time.Millisecond
SessionDefaultMaxIdleTime = 30 * time.Minute
+74 -9
View File
@@ -3,6 +3,7 @@ package core
import (
"encoding/json"
"strings"
"sync/atomic"
"time"
)
@@ -26,17 +27,81 @@ type TransportEvent struct {
Entry LogEntry
}
// Levels are the entry levels by rising severity, each with the words naming
// it in a line: the sources, the console sink's painter and the viewer share them
var Levels = []struct {
// Tapped is what a pipeline's flow decided about one entry: Stage names the
// stage that dropped Event.Entry (rate_limit, filters at Index, format), or is
// "" when it passed and Event.Payload is what the sinks got.
type Tapped struct {
Pipeline string
Source string // the source's id; "" for a heartbeat
Stage string
Index int
Event TransportEvent
}
// Tap hands Tapped records to a viewer without ever waiting: a full tap drops
// and counts. Unless All is set it takes only what passed, so a strict filter
// cannot crowd those out.
type Tap struct {
ch chan Tapped
All atomic.Bool
drops atomic.Uint64
}
func NewTap(size int) *Tap { return &Tap{ch: make(chan Tapped, size)} }
// Offer clips only what it can keep, so a full tap costs the pipeline nothing;
// a reader racing it costs a drop, never a wait
func (t *Tap) Offer(r Tapped) {
switch {
case r.Stage != "" && !t.All.Load():
return
case len(t.ch) == cap(t.ch):
t.drops.Add(1)
return
}
e := &r.Event.Entry
r.Event.Payload = clip(r.Event.Payload, TapEntryBytes)
e.Message, e.Fields = clip(e.Message, TapEntryBytes), clip(e.Fields, TapEntryBytes)
e.Level, e.Source, e.Node = clip(e.Level, TapNameBytes), clip(e.Source, TapNameBytes), clip(e.Node, TapNameBytes)
select {
case t.ch <- r:
default:
t.drops.Add(1)
}
}
func (t *Tap) Records() <-chan Tapped { return t.ch }
// Dropped counts the records a full tap could not take
func (t *Tap) Dropped() uint64 { return t.drops.Load() }
// clip copies the first n bytes of a longer s, so a kept record does not pin
// its line
func clip[T ~string | ~[]byte](s T, n int) T {
if len(s) <= n {
return s
}
head := make([]byte, n)
copy(head, s)
return T(head)
}
// Level is a level: the words naming it in a line, and the further values a
// level key may hold, which prose would misread ("critical path")
type Level struct {
Level string `json:"level"`
Names []string `json:"names"`
}{
{"TRACE", []string{"TRACE"}},
{"DEBUG", []string{"DEBUG", "DBG"}},
{"INFO", []string{"INFO", "INF"}},
{"WARN", []string{"WARN", "WARNING"}},
{"ERROR", []string{"ERROR", "ERR", "FATAL"}},
Keyed []string `json:"-"`
}
// Levels are the entry levels by rising severity: the sources, the console
// sink's painter and the viewer share them
var Levels = []Level{
{"TRACE", []string{"TRACE"}, []string{"VERBOSE"}},
{"DEBUG", []string{"DEBUG", "DBG"}, nil},
{"INFO", []string{"INFO", "INF"}, []string{"I", "NOTICE", "INFORMATION"}},
{"WARN", []string{"WARN", "WARNING"}, []string{"W"}},
{"ERROR", []string{"ERROR", "ERR", "FATAL"}, []string{"E", "CRIT", "CRITICAL", "ALERT", "EMERG", "EMERGENCY", "PANIC", "DPANIC"}},
}
// LevelWord finds the first whole word of text, in any case, that names a
+44 -1
View File
@@ -1,6 +1,11 @@
package core
import "testing"
import (
"encoding/json"
"strings"
"testing"
"unsafe"
)
// A line's level is its first whole word, in any case, naming a level, not the
// most severe one named; a wanted level skips the names of the others.
@@ -22,3 +27,41 @@ func TestLevelIsTheFirstWordNamingOne(t *testing.T) {
}
}
}
// A tapped record keeps at most TapEntryBytes of an entry's message, fields
// and payload and TapNameBytes of its level, source and node, copied, so a
// viewer's scrollback holds none of the source's line
func TestTapClipsLongEntriesByCopying(t *testing.T) {
line := strings.Repeat("x", MaxLogEntryBytes)
payload, fields := []byte(line), json.RawMessage(line)
tap := NewTap(1)
tap.Offer(Tapped{Event: TransportEvent{Payload: payload, Entry: LogEntry{Message: line, Fields: fields, Level: line, Source: line, Node: line}}})
r := <-tap.Records()
e := r.Event.Entry
if len(e.Message) != TapEntryBytes || len(e.Fields) != TapEntryBytes || len(r.Event.Payload) != TapEntryBytes {
t.Fatalf("kept %d, %d and %d bytes, want %d", len(e.Message), len(e.Fields), len(r.Event.Payload), TapEntryBytes)
}
if len(e.Level) != TapNameBytes || len(e.Source) != TapNameBytes || len(e.Node) != TapNameBytes {
t.Fatalf("kept %d, %d and %d bytes of the names, want %d", len(e.Level), len(e.Source), len(e.Node), TapNameBytes)
}
for _, s := range []string{e.Message, e.Level, e.Source, e.Node} {
if unsafe.StringData(s) == unsafe.StringData(line) {
t.Fatal("a clipped part shares the source's line")
}
}
if &e.Fields[0] == &fields[0] || &r.Event.Payload[0] == &payload[0] {
t.Fatal("a clipped part shares the source's line")
}
}
// A full tap drops and counts without copying anything, so a tap nobody
// reads costs a pipeline nothing
func TestAFullTapCostsNothing(t *testing.T) {
line := strings.Repeat("x", MaxLogEntryBytes)
tap := NewTap(1)
rec := Tapped{Event: TransportEvent{Payload: []byte(line), Entry: LogEntry{Message: line, Fields: json.RawMessage(line), Level: line}}}
tap.Offer(rec)
if allocs := testing.AllocsPerRun(100, func() { tap.Offer(rec) }); allocs != 0 || tap.Dropped() != 101 {
t.Fatalf("%v allocations an offer, %d dropped", allocs, tap.Dropped())
}
}
+6 -17
View File
@@ -10,17 +10,15 @@ import (
"github.com/lixenwraith/log"
)
// Chain manages a sequence of filters, applying them in order
// Chain is a pipeline's filters, applied in order
type Chain struct {
filters []*Filter
logger *log.Logger
// Statistics
totalProcessed atomic.Uint64
totalPassed atomic.Uint64
}
// NewChain creates a new filter chain from a slice of filter configurations
func NewChain(configs []config.FilterConfig, logger *log.Logger) (*Chain, error) {
chain := &Chain{
filters: make([]*Filter, 0, len(configs)),
@@ -41,32 +39,23 @@ func NewChain(configs []config.FilterConfig, logger *log.Logger) (*Chain, error)
return chain, nil
}
// Apply runs a log entry through all filters in the chain
func (c *Chain) Apply(entry core.LogEntry) bool {
// Apply runs an entry through the filters in order and returns the index of
// the one that dropped it, or -1
func (c *Chain) Apply(entry core.LogEntry) int {
c.totalProcessed.Add(1)
// No filters means pass everything
if len(c.filters) == 0 {
c.totalPassed.Add(1)
return true
}
// All filters must pass
for i, filter := range c.filters {
if !filter.Apply(entry) {
c.logger.Debug("msg", "Entry filtered out",
"component", "filter_chain",
"filter_index", i,
"filter_type", filter.config.Type)
return false
return i
}
}
c.totalPassed.Add(1)
return true
return -1
}
// GetStats returns aggregated statistics for the entire chain
func (c *Chain) GetStats() map[string]any {
filterStats := make([]map[string]any, len(c.filters))
for i, filter := range c.filters {
+16 -38
View File
@@ -13,8 +13,8 @@ import (
"github.com/lixenwraith/log"
)
// Flow manages the complete processing pipeline for log entries:
// LogEntry -> Rate Limiter -> Filters -> Formatter (with Sanitizer) -> TransportEvent
// Flow takes a pipeline's entries through rate limit, filters and format to
// the TransportEvent its sinks get
type Flow struct {
rateLimiter *RateLimiter
filterChain *filter.Chain
@@ -22,13 +22,11 @@ type Flow struct {
heartbeat *HeartbeatGenerator
logger *log.Logger
// Statistics
totalProcessed atomic.Uint64
totalDropped atomic.Uint64
totalFormatted atomic.Uint64
}
// NewFlow creates a flow processor from configuration
func NewFlow(cfg *config.FlowConfig, logger *log.Logger) (*Flow, error) {
if cfg == nil {
cfg = &config.FlowConfig{}
@@ -38,7 +36,6 @@ func NewFlow(cfg *config.FlowConfig, logger *log.Logger) (*Flow, error) {
logger: logger,
}
// Create rate limiter if configured
if cfg.RateLimit != nil {
limiter, err := NewRateLimiter(*cfg.RateLimit, logger)
if err != nil {
@@ -47,7 +44,6 @@ func NewFlow(cfg *config.FlowConfig, logger *log.Logger) (*Flow, error) {
f.rateLimiter = limiter
}
// Create filter chain if configured
if len(cfg.Filters) > 0 {
chain, err := filter.NewChain(cfg.Filters, logger)
if err != nil {
@@ -56,14 +52,13 @@ func NewFlow(cfg *config.FlowConfig, logger *log.Logger) (*Flow, error) {
f.filterChain = chain
}
// Create formatter with sanitizer integration
formatter, err := format.NewFormatter(cfg.Format)
if err != nil {
return nil, fmt.Errorf("failed to create formatter: %w", err)
}
f.formatter = formatter
// Create heartbeat generator with the same formatter if configured
// Heartbeats are written like the entries
if cfg.Heartbeat != nil {
hb, err := NewHeartbeatGenerator(cfg.Heartbeat, formatter, logger)
if err != nil {
@@ -82,51 +77,35 @@ func NewFlow(cfg *config.FlowConfig, logger *log.Logger) (*Flow, error) {
return f, nil
}
// Process applies all flow stages to a log entry
// Returns TransportEvent and whether entry passed all stages
func (f *Flow) Process(entry core.LogEntry) (core.TransportEvent, bool) {
// Process applies the flow's stages to an entry and returns the event for the
// sinks, or the stage that dropped it and, for filters, which one
func (f *Flow) Process(entry core.LogEntry) (event core.TransportEvent, stage string, index int) {
f.totalProcessed.Add(1)
// Stage 1: Rate limiting
if f.rateLimiter != nil {
if !f.rateLimiter.Allow(entry) {
f.totalDropped.Add(1)
return core.TransportEvent{}, false
}
if f.rateLimiter != nil && !f.rateLimiter.Allow(entry) {
f.totalDropped.Add(1)
return core.TransportEvent{Entry: entry}, "rate_limit", 0
}
// Stage 2: Filtering
if f.filterChain != nil {
if !f.filterChain.Apply(entry) {
if i := f.filterChain.Apply(entry); i >= 0 {
f.totalDropped.Add(1)
return core.TransportEvent{}, false
return core.TransportEvent{Entry: entry}, "filters", i
}
}
// Stage 3: Formatting
formatted, err := f.formatter.Format(entry)
if err != nil {
f.logger.Error("msg", "Failed to format log entry",
"component", "flow",
"error", err)
f.totalDropped.Add(1)
return core.TransportEvent{}, false
return core.TransportEvent{Entry: entry}, "format", 0
}
f.totalFormatted.Add(1)
// Create transport event
event := core.TransportEvent{
Time: entry.Time,
Payload: formatted,
Entry: entry, // Carry structured entry so chain sinks are format-independent
}
return event, true
// The structured entry rides along so chain sinks are format-independent
return core.TransportEvent{Time: entry.Time, Payload: formatted, Entry: entry}, "", 0
}
// StartHeartbeat starts the heartbeat generator if configured
// Returns channel that emits heartbeat events
// StartHeartbeat returns the heartbeat events until ctx ends, or nil without
// a heartbeat
func (f *Flow) StartHeartbeat(ctx context.Context) <-chan core.TransportEvent {
if f.heartbeat == nil {
return nil
@@ -134,7 +113,6 @@ func (f *Flow) StartHeartbeat(ctx context.Context) <-chan core.TransportEvent {
return f.heartbeat.Start(ctx)
}
// GetStats returns flow statistics
func (f *Flow) GetStats() map[string]any {
stats := map[string]any{
"total_processed": f.totalProcessed.Load(),
+408
View File
@@ -0,0 +1,408 @@
// Package logline reads a log line of any shape (vif records, LogWisp's json,
// lw's own log, pino, zap, slog, docker, journald, ECS, GCP, plain text) into
// one set of roles, and names the runes a terminal would act on. It links no
// terminal code, so a viewer, a sink and the configuration page can share it.
package logline
import (
"bytes"
"encoding/hex"
"math"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/lixenwraith/logwisp/internal/core"
)
// Line is what a log line says, wherever its writer put it
type Line struct {
Time time.Time // zero when the line names none readable: the entry's time stands in
Stamp string // the time as the line wrote it
Level string // a core.Levels level, or a value outside their order as written (PROC)
Tag string // who wrote it: sub, source, logger and the like, else the entry's source
Message string
Fields []Field // what no role took, in the writer's order
Layout string // where the roles were found, so a reader can check the guess
}
// Field is a value no role took. Key is its dotted path, "" for a positional
// value; Value is a string's text or any other value as its JSON.
type Field struct {
Key, Value string
}
const (
roleTime = iota
roleLevel
roleTag
roleMsg
)
// roles are the keys each role is read from, the first present wins: a dotted
// key is a path into nested objects or a flat key spelled with dots. trace is
// the last tag, so it names LogWisp json's source but not vif's call chain.
var roles = [...][]string{
roleTime: {"time", "ts", "timestamp", "@timestamp", "t", "datetime", "__REALTIME_TIMESTAMP", "@t"},
roleLevel: {"level", "lvl", "severity", "log.level", "levelname", "loglevel", "PRIORITY", "@l"},
roleTag: {"sub", "source", "logger", "component", "name", "_SYSTEMD_UNIT", "trace"},
roleMsg: {"msg", "message", "MESSAGE", "@m", "@mt", "log", "text", "event"},
}
// envelopes hold a record's payload one level down; the first of them present
// is opened, and without a message of its own the record takes the envelope's
// first discriminator, or an array's first string (vif: fields.msg|type|ev)
var (
envelopes = []string{"fields", "data", "attrs", "payload"}
discriminators = []string{"msg", "type", "ev", "message"}
)
// nestMax bounds the depth flattened into dotted paths, and so their length
const nestMax = 8
// member is one leaf of a record; no role takes the envelope's
type member struct {
key, val string
kind byte
env bool
used bool
}
// Read finds e's roles in its message, a JSON record (also one inside such a
// record's message) or text with a leading RFC3339 stamp, and in its fields;
// a role the line does not name is the entry's.
func Read(e core.LogEntry) Line {
l, at, ok := record(strings.TrimSpace(e.Message), "")
if ok {
if in, _, ok := record(strings.TrimSpace(l.Message), at); ok {
l = merge(l, in)
}
} else {
l = Line{Message: e.Message, Layout: "text"}
if stamp, rest, ok := strings.Cut(e.Message, " "); ok {
if at, ok := readStamp(stamp); ok {
l.Time, l.Stamp, l.Message, l.Layout = at, stamp, strings.TrimLeft(rest, " "), "text, stamped"
}
}
}
if f := strings.TrimSpace(string(e.Fields)); f != "" {
_, kind, _ := scanValue(f, 0)
ms, msg, at := envelope(nil, "fields", f, kind)
if l.Message == "" && msg >= 0 {
ms[msg].used = true
l.Message, l.Layout = unquote(ms[msg].val, kStr), "entry, message in "+at
}
l.Fields = appendFields(l.Fields, ms)
}
if l.Level == "" {
l.Level = level("", e.Level)
}
if l.Tag == "" {
l.Tag = e.Source
}
l.Message = strings.TrimRight(l.Message, "\r\n")
return l
}
// record reads s as a JSON object; in names where s sat as another record's
// message, at where s's own message is. The members before a malformed part
// count, so a clipped line still shows its roles; ok is false without any.
func record(s, in string) (l Line, at string, ok bool) {
var top []member
whole := eachField(s, 0, func(k, v string, kind byte) bool {
top = append(top, member{key: k, val: v, kind: kind})
return true
})
if len(top) == 0 {
return Line{}, "", false
}
env := -1
for _, name := range envelopes {
for i := range top {
if env < 0 && top[i].key == name && (top[i].kind == kObj || top[i].kind == kArr) {
env = i
}
}
}
var ms []member
msg, envAt := -1, ""
for i, m := range top {
if i == env {
ms, msg, envAt = envelope(ms, m.key, m.val, m.kind)
} else {
ms = leaves(ms, m.key, m.val, m.kind, false, 0)
}
}
if m := find(ms, roleTime); m != nil {
l.Stamp = unquote(m.val, m.kind)
l.Time, _ = readTime(l.Stamp)
}
if m := find(ms, roleLevel); m != nil {
l.Level = level(m.key, unquote(m.val, m.kind))
}
if m := find(ms, roleTag); m != nil {
l.Tag = unquote(m.val, m.kind)
}
if m := find(ms, roleMsg); m != nil {
l.Message, at = unquote(m.val, m.kind), m.key
} else if msg >= 0 {
ms[msg].used = true
l.Message, at = unquote(ms[msg].val, kStr), envAt
}
l.Fields = appendFields(nil, ms)
l.Layout = "json"
if in != "" {
l.Layout += " in " + in
}
if !whole {
l.Layout += ", cut short"
}
if at != "" {
l.Layout += ", message in " + at
} else {
l.Layout += ", no message"
}
return l, at, true
}
// envelope appends the leaves of envelope name's value v; msg indexes its
// message in ms and at says where it was, -1 and "" when it holds none
func envelope(ms []member, name, v string, kind byte) (_ []member, msg int, at string) {
msg, rank, slot := -1, len(discriminators), -1
note := func(key string, kind byte, i int) {
for r := 0; r < rank && kind == kStr; r++ {
if discriminators[r] == key {
msg, rank, slot = len(ms), r, i
}
}
}
switch kind {
case kObj:
eachField(v, 0, func(k, fv string, fk byte) bool {
note(k, fk, -1)
ms = leaves(ms, k, fv, fk, true, 1)
return true
})
case kArr:
// lw's own log writes key/value pairs; LogWisp json, the message alone
n, pairs := 0, true
eachItem(v, 0, func(_ string, k byte) {
pairs = pairs && (n%2 == 1 || k == kStr)
n++
})
pairs = pairs && n > 0 && n%2 == 0
i, key := 0, ""
eachItem(v, 0, func(iv string, ik byte) {
switch {
case pairs && i%2 == 0:
key = unquote(iv, ik)
case pairs:
note(key, ik, i)
ms = leaves(ms, key, iv, ik, true, 1)
default:
if i == 0 && ik == kStr {
msg, slot = len(ms), 0
}
ms = leaves(ms, "", iv, ik, true, 1)
}
i++
})
default:
ms = leaves(ms, name, v, kind, true, 0)
}
switch {
case msg < 0:
case slot < 0:
at = name + "." + ms[msg].key
default:
at = name + "[" + strconv.Itoa(slot) + "]"
}
return ms, msg, at
}
// leaves appends v under key, an object as one leaf per member at its dotted
// path, so roles and readers see nested keys as if written flat
func leaves(ms []member, key, v string, kind byte, env bool, depth int) []member {
if kind == kObj && depth < nestMax {
n := len(ms)
if eachField(v, 0, func(k, fv string, fk byte) bool {
if key != "" {
k = key + "." + k
}
ms = leaves(ms, k, fv, fk, env, depth+1)
return true
}) && len(ms) > n {
return ms
}
ms = ms[:n]
}
return append(ms, member{key: key, val: v, kind: kind, env: env})
}
// find takes the leaf holding role r, outside the envelope and holding a
// string or a number: a null, a flag or a list names no role
func find(ms []member, r int) *member {
for _, key := range roles[r] {
for i := range ms {
m := &ms[i]
if m.key == key && !m.env && !m.used && (m.kind == kStr || m.kind == kNum) {
m.used = true
return m
}
}
}
return nil
}
func appendFields(fs []Field, ms []member) []Field {
for _, m := range ms {
if !m.used {
fs = append(fs, Field{m.key, unquote(m.val, m.kind)})
}
}
return fs
}
// merge lays a record found in out's message over out: its roles win, out's
// fill the ones it does not name, and out's fields come first
func merge(out, in Line) Line {
if in.Time.IsZero() {
in.Time, in.Stamp = out.Time, out.Stamp
}
if in.Level == "" {
in.Level = out.Level
}
if in.Tag == "" {
in.Tag = out.Tag
}
in.Fields = append(out.Fields, in.Fields...)
return in
}
// level reads a level value: a name in any case, or a number on its key's
// scale (journald's syslog PRIORITY, pino's tens, else slog's and
// lixenwraith/log's); any other value is kept as written, outside the order
func level(key, v string) string {
if n, ok := parseInt64(v); ok {
switch {
case key == "PRIORITY":
return [...]string{"ERROR", "ERROR", "ERROR", "ERROR", "WARN", "INFO", "INFO", "DEBUG"}[min(max(n, 0), 7)]
case n >= 10:
return [...]string{"TRACE", "DEBUG", "INFO", "WARN", "ERROR", "ERROR"}[min(n/10-1, 5)]
case n <= -8:
return "TRACE"
case n < 0:
return "DEBUG"
case n < 4:
return "INFO"
case n < 8:
return "WARN"
}
return "ERROR"
}
for _, l := range core.Levels {
for _, names := range [2][]string{l.Names, l.Keyed} {
for _, name := range names {
if strings.EqualFold(v, name) {
return l.Level
}
}
}
}
return v
}
// readStamp reads an RFC3339 stamp, one without a zone as UTC
func readStamp(s string) (time.Time, bool) {
at, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
at, err = time.Parse("2006-01-02T15:04:05.999999999", s)
}
return at, err == nil
}
// readTime reads an RFC3339 stamp, or a number since 1970 in seconds,
// milliseconds or microseconds, told apart by its magnitude
func readTime(s string) (time.Time, bool) {
if at, ok := readStamp(s); ok {
return at, true
}
whole, frac, _ := strings.Cut(s, ".")
n, ok := parseInt64(whole)
if !ok || n < 0 {
return time.Time{}, false
}
unit := int64(1e3)
switch {
case n < 1e11:
unit = 1e9
case n < 1e14:
unit = 1e6
}
if n > math.MaxInt64/unit {
return time.Time{}, false
}
ns := n * unit
for i := 0; i < len(frac); i++ {
if frac[i] < '0' || frac[i] > '9' {
return time.Time{}, false
}
unit /= 10
ns += int64(frac[i]-'0') * unit
}
return time.Unix(0, ns), true
}
// EscapeControls writes what a terminal would act on or reorder (Control) as
// <hex>, keeping the final newline: a log line cannot drive the terminal.
// Other invisible characters stay: emoji and Indic scripts need ZWJ.
func EscapeControls(p []byte) []byte {
body, newline := bytes.CutSuffix(p, []byte{'\n'})
if printableASCII(body) {
return p
}
out := make([]byte, 0, len(p)+16)
for len(body) > 0 {
r, n := utf8.DecodeRune(body)
if !Control(r, n) {
out = append(out, body[:n]...)
} else {
out = append(out, '<')
out = hex.AppendEncode(out, body[:n])
out = append(out, '>')
}
body = body[n:]
}
if newline {
out = append(out, '\n')
}
return out
}
// Control reports whether a terminal would act on the rune r, decoded from
// size bytes, or reorder text by it: C0 and C1 controls but tab, DEL, bidi
// controls, line separators and invalid UTF-8
func Control(r rune, size int) bool {
switch {
case r == utf8.RuneError && size == 1:
return true
case r < 0x20:
return r != '\t'
case r >= 0x7f && r <= 0x9f, r == 0x061c, r == 0x200e, r == 0x200f,
r >= 0x2028 && r <= 0x202e, r >= 0x2066 && r <= 0x2069:
return true
}
return false
}
func printableASCII(b []byte) bool {
for _, c := range b {
if (c < 0x20 || c > 0x7e) && c != '\t' {
return false
}
}
return true
}
+138
View File
@@ -0,0 +1,138 @@
package logline
import (
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"github.com/lixenwraith/logwisp/internal/core"
)
// arrived is a line as a source passes one it does not parse: whole, at its
// arrival, with the level its first level word names
func arrived(line string) core.LogEntry {
level, _, _ := core.LevelWord(line, "")
return core.LogEntry{Time: time.Unix(1, 0), Source: "app.log", Level: level, Message: line}
}
// Each writer's line yields its time, level, tag and message from where that
// writer puts them, the rest as fields in its order; the want is "layout |
// stamp=time | level | tag | message | fields". vif's, lw's and slog's lines
// are their writers' output; the others follow their formats' documentation.
func TestReadFindsTheRolesWhereTheirWriterPutsThem(t *testing.T) {
for _, c := range []struct {
in core.LogEntry
want string
}{
// vif: the tag is sub, the message the first of fields.msg, type and ev
{arrived(`{"time":"2026-10-10T19:09:54.251623265Z","level":"INFO","sub":"fsm","run":3,"tick":1200,"fields":{"msg":"transition","from":"idle","to":"run","elapsed":1500000}}`),
`json, message in fields.msg | 2026-10-10T19:09:54.251623265Z=2026-10-10T19:09:54.251623265Z | INFO | fsm | "transition" | run="3" tick="1200" from="idle" to="run" elapsed="1500000"`},
{arrived(`{"time":"2026-10-10T19:09:54.251635197Z","level":"WARN","sub":"fsm","run":3,"tick":1200,"trace":"main -> main","fields":{"msg":"slow tick","ms":73.25}}`),
`json, message in fields.msg | 2026-10-10T19:09:54.251635197Z=2026-10-10T19:09:54.251635197Z | WARN | fsm | "slow tick" | run="3" tick="1200" trace="main -> main" ms="73.25"`},
{arrived(`{"time":"2026-10-10T19:09:54.251635488Z","level":"INFO","sub":"journal","run":0,"tick":0,"fields":{"jseq":7,"domain":"player","ev":"EventX","payload":"x = 1\ny = 2\n"}}`),
`json, message in fields.ev | 2026-10-10T19:09:54.251635488Z=2026-10-10T19:09:54.251635488Z | INFO | journal | "EventX" | run="0" tick="0" jseq="7" domain="player" payload="x = 1\ny = 2\n"`},
{arrived(`{"time":"2026-08-29T21:15:23.33805528-04:00","level":"PROC","run":0,"tick":0,"frame":0,"fields":{"type":"proc","sequence":1,"processed_logs":0}}`),
`json, message in fields.type | 2026-08-29T21:15:23.33805528-04:00=2026-08-30T01:15:23.33805528Z | PROC | app.log | "proc" | run="0" tick="0" frame="0" sequence="1" processed_logs="0"`},
{arrived(`{"time":"2026-08-30T01:27:39.654615818Z","level":"INFO","sub":"anchor","run":0,"tick":0,"frame":0,"fields":{"schema":10,"jseq":0,"tick_ns":50000000}}`),
`json, no message | 2026-08-30T01:27:39.654615818Z=2026-08-30T01:27:39.654615818Z | INFO | anchor | "" | run="0" tick="0" frame="0" schema="10" jseq="0" tick_ns="50000000"`},
// LogWisp json: trace is the source, the message alone is fields[0], and a
// record the console source read whole sits there, over the outer roles
{arrived(`{"time":"2026-10-10T12:00:00Z","level":"WARN","trace":"app.log","message":"disk almost full","fields":{"disk":"/dev/sda1","nested":{"a":[1,2]},"pct":91}}`),
`json, message in message | 2026-10-10T12:00:00Z=2026-10-10T12:00:00Z | WARN | app.log | "disk almost full" | disk="/dev/sda1" nested.a="[1,2]" pct="91"`},
{arrived(`{"time":"2026-10-10T20:30:12.385839584Z","level":"INFO","trace":"app.log","fields":["TRACE deep detail"]}`),
`json, message in fields[0] | 2026-10-10T20:30:12.385839584Z=2026-10-10T20:30:12.385839584Z | INFO | app.log | "TRACE deep detail" | `},
{arrived(`{"time":"2026-10-10T20:43:03.350727184Z","level":"INFO","trace":"console","fields":["{\"time\":\"2026-10-10T12:00:00Z\",\"msg\":\"disk almost full\",\"fields\":{\"disk\":\"/dev/sda1\"}}"]}`),
`json in fields[0], message in msg | 2026-10-10T12:00:00Z=2026-10-10T12:00:00Z | INFO | console | "disk almost full" | disk="/dev/sda1"`},
{arrived(`{"time":"2026-10-10T20:43:38.342629146Z","level":"INFO","trace":"console","fields":["{\"msg\":\"no time here\",\"fields\":{\"k\":1}}"]}`),
`json in fields[0], message in msg | 2026-10-10T20:43:38.342629146Z=2026-10-10T20:43:38.342629146Z | INFO | console | "no time here" | k="1"`},
{arrived(`{"time":"2026-10-10T19:06:34.86389785Z","node":"edge-01","source":"app.log","level":"WARN","message":"disk almost full","fields":{"pct":91}}`),
`json, message in message | 2026-10-10T19:06:34.86389785Z=2026-10-10T19:06:34.86389785Z | WARN | app.log | "disk almost full" | node="edge-01" pct="91"`},
// lw's own log: key/value pairs, in the line or in what the file source kept
{arrived(`{"time":"2026-10-10T20:29:33.279610845Z","level":"INFO","fields":["msg","Initializing pipeline","pipeline","cli"]}`),
`json, message in fields[1] | 2026-10-10T20:29:33.279610845Z=2026-10-10T20:29:33.279610845Z | INFO | app.log | "Initializing pipeline" | pipeline="cli"`},
{core.LogEntry{Source: "lw.log", Level: "INFO", Fields: json.RawMessage(`["msg","LogWisp starting","status_reporter",false]`)},
`entry, message in fields[1] | =- | INFO | lw.log | "LogWisp starting" | status_reporter="false"`},
{core.LogEntry{Source: "app.log", Level: "warning", Message: "disk almost full", Fields: json.RawMessage(`{"pct":91}`)},
`text | =- | WARN | app.log | "disk almost full" | pct="91"`},
// pino's tens, zap's seconds, slog's scale and groups
{arrived(`{"level":30,"time":1728561600000,"pid":1,"hostname":"h","msg":"pino style"}`),
`json, message in msg | 1728561600000=2024-10-10T12:00:00Z | INFO | app.log | "pino style" | pid="1" hostname="h"`},
{arrived(`{"level":"info","ts":1728561600.123456,"caller":"main.go:12","msg":"zap style","n":1}`),
`json, message in msg | 1728561600.123456=2024-10-10T12:00:00.123456Z | INFO | app.log | "zap style" | caller="main.go:12" n="1"`},
{arrived(`{"time":"2026-10-10T20:30:23.451515887Z","level":"INFO","msg":"slog style","user":{"id":7,"name":"a"}}`),
`json, message in msg | 2026-10-10T20:30:23.451515887Z=2026-10-10T20:30:23.451515887Z | INFO | app.log | "slog style" | user.id="7" user.name="a"`},
{arrived(`{"time":"2026-10-10T20:39:59.809016667Z","level":-4,"msg":"slog level as a number","attempt":2}`),
`json, message in msg | 2026-10-10T20:39:59.809016667Z=2026-10-10T20:39:59.809016667Z | DEBUG | app.log | "slog level as a number" | attempt="2"`},
// docker's log ends in its newline; the level is the entry's
{arrived(`{"log":"docker line ERROR x\n","stream":"stderr","time":"2026-10-10T12:00:00.1Z"}`),
`json, message in log | 2026-10-10T12:00:00.1Z=2026-10-10T12:00:00.1Z | ERROR | app.log | "docker line ERROR x" | stream="stderr"`},
// journald writes numbers as strings: microseconds, syslog priority
{arrived(`{"__REALTIME_TIMESTAMP":"1728561600123456","PRIORITY":"3","_SYSTEMD_UNIT":"nginx.service","MESSAGE":"journald"}`),
`json, message in MESSAGE | 1728561600123456=2024-10-10T12:00:00.123456Z | ERROR | nginx.service | "journald" | `},
{arrived(`{"@timestamp":"2026-10-10T12:00:00Z","log.level":"warn","message":"ecs flat"}`),
`json, message in message | 2026-10-10T12:00:00Z=2026-10-10T12:00:00Z | WARN | app.log | "ecs flat" | `},
{arrived(`{"@timestamp":"2026-10-10T12:00:00Z","log":{"level":"error"},"message":"ecs nested"}`),
`json, message in message | 2026-10-10T12:00:00Z=2026-10-10T12:00:00Z | ERROR | app.log | "ecs nested" | `},
{arrived(`{"severity":"CRITICAL","timestamp":"2026-10-10T12:00:00Z","message":"gcp"}`),
`json, message in message | 2026-10-10T12:00:00Z=2026-10-10T12:00:00Z | ERROR | app.log | "gcp" | `},
{arrived(`{"a":1,"b":"no known keys","source":null}`),
`json, no message | =- | | app.log | "" | a="1" b="no known keys" source="null"`},
// a line the tap clipped keeps the roles before the cut
{arrived(`{"time":"2026-10-10T12:00:00Z","level":"INFO","msg":"clipped","big":"aaaa`),
`json, cut short, message in msg | 2026-10-10T12:00:00Z=2026-10-10T12:00:00Z | INFO | app.log | "clipped" | `},
// nesting past nestMax stays one value, bounding the paths
{arrived(`{"msg":"deep","a":{"b":{"c":{"d":{"e":{"f":{"g":{"h":{"i":{"j":1}}}}}}}}}}`),
`json, message in msg | =- | | app.log | "deep" | a.b.c.d.e.f.g.h.i="{\"j\":1}"`},
{arrived(`2026-10-10T12:00:00+02:00 ERROR db: connection refused`),
`text, stamped | 2026-10-10T12:00:00+02:00=2026-10-10T10:00:00Z | ERROR | app.log | "ERROR db: connection refused" | `},
{arrived(`2026-10-10T12:00:00.5 INFO a stamp without a zone is UTC`),
`text, stamped | 2026-10-10T12:00:00.5=2026-10-10T12:00:00.5Z | INFO | app.log | "INFO a stamp without a zone is UTC" | `},
// what only looks like a stamp is text: no day is invented
{arrived(`2026-13-45T99:99:99Z ERROR x`),
`text | =- | ERROR | app.log | "2026-13-45T99:99:99Z ERROR x" | `},
{arrived(`2026-10-10T12:00:00Zjunk ERROR x`),
`text | =- | ERROR | app.log | "2026-10-10T12:00:00Zjunk ERROR x" | `},
{arrived(`{not json} WARNING disk`),
`text | =- | WARN | app.log | "{not json} WARNING disk" | `},
} {
if got := show(Read(c.in)); got != c.want {
t.Errorf("%s\n got: %s\nwant: %s", c.in.Message, got, c.want)
}
}
}
func show(l Line) string {
at := "-"
if !l.Time.IsZero() {
at = l.Time.UTC().Format(time.RFC3339Nano)
}
var fs []string
for _, f := range l.Fields {
fs = append(fs, fmt.Sprintf("%s=%q", f.Key, f.Value))
}
return fmt.Sprintf("%s | %s=%s | %s | %s | %q | %s", l.Layout, l.Stamp, at, l.Level, l.Tag, l.Message, strings.Join(fs, " "))
}
// Everything a terminal would act on is escaped; tabs and the final newline,
// which it only lays out, are kept.
func TestEscapeControlsKeepsOnlyLayout(t *testing.T) {
for in, want := range map[string]string{
"plain\ttext\n": "plain\ttext\n",
"a\x1b[2Jb\n": "a<1b>[2Jb\n",
"bell\x07 del\x7f\n": "bell<07> del<7f>\n",
"inner\nnewline\n": "inner<0a>newline\n",
"c1 \u009b31m\n": "c1 <c29b>31m\n",
"bidi \u202eevil\n": "bidi <e280ae>evil\n",
"isolate \u2066x\u2069\n": "isolate <e281a6>x<e281a9>\n",
"line\u2028sep\n": "line<e280a8>sep\n",
"zwj \U0001f468\u200d\U0001f469\n": "zwj \U0001f468\u200d\U0001f469\n",
"bad \xff utf8": "bad <ff> utf8",
"ünïcødé and \ufffd kept\n": "ünïcødé and \ufffd kept\n",
} {
if got := string(EscapeControls([]byte(in))); got != want {
t.Errorf("%q: got %q, want %q", in, got, want)
}
}
}
+226
View File
@@ -0,0 +1,226 @@
package logline
import "encoding/json"
// JSON token kinds
const (
kNone byte = 0
kStr byte = 's'
kNum byte = 'n'
kBool byte = 'b'
kNull byte = 'z'
kObj byte = 'o'
kArr byte = 'a'
)
func skipSpace(s string, i int) int {
for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\r' || s[i] == '\n') {
i++
}
return i
}
func isNumByte(c byte) bool {
return c == '-' || c == '+' || c == '.' || c == 'e' || c == 'E' || (c >= '0' && c <= '9')
}
// scanString returns the index past the closing quote; i indexes the opener.
func scanString(s string, i int) (int, bool) {
for i++; i < len(s); i++ {
switch s[i] {
case '\\':
i++
case '"':
return i + 1, true
}
}
return i, false
}
// scanValue returns the end index and kind of the value starting at i.
func scanValue(s string, i int) (int, byte, bool) {
if i >= len(s) {
return i, kNone, false
}
switch c := s[i]; c {
case '"':
e, ok := scanString(s, i)
return e, kStr, ok
case '{', '[':
opener, closer, kind := byte('{'), byte('}'), kObj
if c == '[' {
opener, closer, kind = '[', ']', kArr
}
depth := 0
for i < len(s) {
ch := s[i]
if ch == '"' {
e, ok := scanString(s, i)
if !ok {
return e, kind, false
}
i = e
continue
}
if ch == opener {
depth++
} else if ch == closer {
depth--
if depth == 0 {
return i + 1, kind, true
}
}
i++
}
return i, kind, false
case 't', 'f', 'n':
kind := kBool
if c == 'n' {
kind = kNull
}
for i < len(s) && s[i] >= 'a' && s[i] <= 'z' {
i++
}
return i, kind, true
default:
e := i
for e < len(s) && isNumByte(s[e]) {
e++
}
return e, kNum, e > i
}
}
// eachField calls fn for each member of the object at i, which must index '{',
// until fn returns false; it reports whether the object is well formed.
func eachField(s string, i int, fn func(key, val string, kind byte) bool) bool {
if i >= len(s) || s[i] != '{' {
return false
}
i = skipSpace(s, i+1)
if i < len(s) && s[i] == '}' {
return true
}
for i < len(s) {
if s[i] != '"' {
return false
}
ke, ok := scanString(s, i)
if !ok {
return false
}
key := s[i+1 : ke-1]
i = skipSpace(s, ke)
if i >= len(s) || s[i] != ':' {
return false
}
i = skipSpace(s, i+1)
vs := i
ve, kind, ok := scanValue(s, i)
if !ok {
return false
}
if !fn(key, s[vs:ve], kind) {
return true
}
i = skipSpace(s, ve)
if i >= len(s) {
return false
}
switch s[i] {
case ',':
i = skipSpace(s, i+1)
case '}':
return true
default:
return false
}
}
return false
}
// eachItem calls fn for each element of the array at i, which must index '[';
// it reports whether the array is well formed.
func eachItem(s string, i int, fn func(val string, kind byte)) bool {
if i >= len(s) || s[i] != '[' {
return false
}
i = skipSpace(s, i+1)
if i < len(s) && s[i] == ']' {
return true
}
for i < len(s) {
ve, kind, ok := scanValue(s, i)
if !ok {
return false
}
fn(s[i:ve], kind)
i = skipSpace(s, ve)
if i >= len(s) {
return false
}
switch s[i] {
case ',':
i = skipSpace(s, i+1)
case ']':
return true
default:
return false
}
}
return false
}
// unquote returns the content of a string token, decoding only when it holds
// an escape; any other token is returned as its JSON.
func unquote(tok string, kind byte) string {
if kind != kStr || len(tok) < 2 {
return tok
}
in := tok[1 : len(tok)-1]
for i := 0; i < len(in); i++ {
if in[i] == '\\' {
var s string
if json.Unmarshal([]byte(tok), &s) == nil {
return s
}
break
}
}
return in
}
// parseInt64 parses a complete decimal integer token without allocating.
func parseInt64(s string) (int64, bool) {
if len(s) == 0 {
return 0, false
}
i, neg := 0, false
if s[0] == '-' || s[0] == '+' {
neg = s[0] == '-'
i++
}
if i >= len(s) {
return 0, false
}
var v int64
for ; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return 0, false
}
v = v*10 + int64(s[i]-'0')
if v < 0 {
return 0, false
}
}
if neg {
v = -v
}
return v, true
}
+51 -62
View File
@@ -21,13 +21,11 @@ import (
type Pipeline struct {
Config *config.PipelineConfig
// Components
Registry *Registry
Sources map[string]source.Source // Track instances by ID
Sources map[string]source.Source
Sinks map[string]sink.Sink
Sessions *session.Manager
// Pipeline flow
Flow *flow.Flow
Stats *PipelineStats
logger *log.Logger
@@ -35,7 +33,8 @@ type Pipeline struct {
// Sinks that stall dispatch rather than drop (core.CapBackpressure)
backpressure map[string]bool
// Runtime
tap atomic.Pointer[core.Tap] // a viewer's copy of what the flow decides
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
@@ -45,7 +44,6 @@ type Pipeline struct {
finishedOnce sync.Once
}
// PipelineStats contains runtime statistics for a pipeline
type PipelineStats struct {
StartTime time.Time
TotalEntriesDroppedBySink atomic.Uint64
@@ -54,18 +52,14 @@ type PipelineStats struct {
FlowStats map[string]any
}
// NewPipeline creates a new pipeline with registry support
func NewPipeline(
cfg *config.PipelineConfig,
logger *log.Logger,
) (*Pipeline, error) {
// Create pipeline context
pipelineCtx, pipelineCancel := context.WithCancel(context.Background())
// Create session manager with default timeout
sessionManager := session.NewManager(core.SessionDefaultMaxIdleTime)
// Create pipeline instance with registry
pipeline := &Pipeline{
Config: cfg,
Registry: NewRegistry(cfg.Name, logger),
@@ -81,17 +75,14 @@ func NewPipeline(
finished: make(chan struct{}),
}
// Create flow processor
flowProcessor, err := flow.NewFlow(cfg.Flow, logger)
if err != nil {
// If flow fails, stop session manager
pipelineCancel()
sessionManager.Stop()
return nil, fmt.Errorf("failed to create flow processor: %w", err)
}
pipeline.Flow = flowProcessor
// Initialize sources and sinks
if err := pipeline.initializeComponents(); err != nil {
pipelineCancel()
sessionManager.Stop()
@@ -102,10 +93,8 @@ func NewPipeline(
}
func (p *Pipeline) initializeComponents() error {
// Create sources based on plugin config if available
if len(p.Config.PluginSources) > 0 {
for _, srcCfg := range p.Config.PluginSources {
// Create session proxy for this source instance
sessionProxy := session.NewProxy(p.Sessions, srcCfg.ID)
src, err := p.Registry.CreateSource(
@@ -116,12 +105,11 @@ func (p *Pipeline) initializeComponents() error {
sessionProxy,
)
if err != nil {
return err // the registry names the plugin
return p.part("source", srcCfg.ID, err) // the registry names the plugin
}
// Check and inject capabilities using core interfaces
if err := p.initSourceCapabilities(src, srcCfg); err != nil {
return fmt.Errorf("failed to initiate capabilities for source %s: %w", srcCfg.ID, err)
return p.part("source", srcCfg.ID, fmt.Errorf("failed to initiate capabilities for source %s: %w", srcCfg.ID, err))
}
p.Sources[srcCfg.ID] = src
@@ -130,10 +118,8 @@ func (p *Pipeline) initializeComponents() error {
return fmt.Errorf("no plugin sources defined")
}
// Create sinks based on plugin config if available
if len(p.Config.PluginSinks) > 0 {
for _, sinkCfg := range p.Config.PluginSinks {
// Create session proxy for this sink instance
sessionProxy := session.NewProxy(p.Sessions, sinkCfg.ID)
snk, err := p.Registry.CreateSink(
@@ -144,12 +130,11 @@ func (p *Pipeline) initializeComponents() error {
sessionProxy,
)
if err != nil {
return err // the registry names the plugin
return p.part("sink", sinkCfg.ID, err) // the registry names the plugin
}
// Check and inject capabilities using core interfaces
if err := p.initSinkCapabilities(snk, sinkCfg); err != nil {
return fmt.Errorf("failed to initiate capabilities for sink %s: %w", sinkCfg.ID, err)
return p.part("sink", sinkCfg.ID, fmt.Errorf("failed to initiate capabilities for sink %s: %w", sinkCfg.ID, err))
}
p.Sinks[sinkCfg.ID] = snk
@@ -161,24 +146,34 @@ func (p *Pipeline) initializeComponents() error {
return nil
}
// initSourceCapabilities checks and injects optional capabilities
// PartError is a source's or sink's error, which names its part for a screen
// to place; its text is the error's own
type PartError struct {
Pipeline, Role, ID string
Err error
}
func (e *PartError) Error() string { return e.Err.Error() }
func (e *PartError) Unwrap() error { return e.Err }
func (p *Pipeline) part(role, id string, err error) error {
return &PartError{p.Config.Name, role, id, err}
}
func (p *Pipeline) initSourceCapabilities(s source.Source, cfg config.PluginSourceConfig) error {
// Initiate and activate source capabilities
var hasTLS, hasAuth bool
for _, c := range s.Capabilities() {
switch c {
// Network capabilities
case core.CapNetLimit:
continue // No-op for now, placeholder
continue
case core.CapTLS, core.CapProxyTLS:
hasTLS = true
case core.CapAuth:
hasAuth = true
// Session capabilities
case core.CapSessionAware:
case core.CapMultiSession:
continue // TODO
continue
default:
return fmt.Errorf("unknown capability type: %s", c)
@@ -202,27 +197,23 @@ func checkAuthCapability(hasTLS, hasAuth bool) error {
return nil
}
// initSinkCapabilities checks and injects optional capabilities
func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig) error {
// Initiate and activate sink capabilities
var hasTLS, hasAuth bool
for _, c := range s.Capabilities() {
switch c {
case core.CapBackpressure:
p.backpressure[cfg.ID] = true
// Network capabilities
case core.CapNetLimit:
continue // No-op for now, placeholder
continue
case core.CapTLS, core.CapProxyTLS:
hasTLS = true
case core.CapAuth:
hasAuth = true
// Session capabilities
case core.CapSessionAware:
case core.CapMultiSession:
continue // TODO
continue
default:
return fmt.Errorf("unknown capability type: %s", c)
@@ -238,19 +229,23 @@ func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig
// run connects the subscribed sources through the flow to the sinks. It
// returns once every source channel has closed, at Stop or at end of input.
func (p *Pipeline) run(subscriptions []<-chan core.LogEntry) {
func (p *Pipeline) run(ids []string, subscriptions []<-chan core.LogEntry) {
defer p.wg.Done()
defer p.finish()
defer p.logger.Info("msg", "Pipeline processing loop stopped", "pipeline", p.Config.Name)
var componentWg sync.WaitGroup
for _, ch := range subscriptions {
for i, ch := range subscriptions {
componentWg.Add(1)
go func() {
defer componentWg.Done()
// Range drains in-flight entries once the source closes the channel
for entry := range ch {
if event, passed := p.Flow.Process(entry); passed {
event, stage, index := p.Flow.Process(entry)
if t := p.tap.Load(); t != nil {
t.Offer(core.Tapped{Pipeline: p.Config.Name, Source: ids[i], Stage: stage, Index: index, Event: event})
}
if stage == "" {
p.dispatch(event)
}
}
@@ -270,6 +265,9 @@ func (p *Pipeline) run(subscriptions []<-chan core.LogEntry) {
if !ok {
return
}
if t := p.tap.Load(); t != nil {
t.Offer(core.Tapped{Pipeline: p.Config.Name, Event: event})
}
p.dispatch(event)
case <-hbCtx.Done():
return
@@ -303,13 +301,16 @@ func (p *Pipeline) dispatch(event core.TransportEvent) {
}
}
// SetTap offers what the flow decides to t from the next entry on; nil stops
func (p *Pipeline) SetTap(t *core.Tap) { p.tap.Store(t) }
func (p *Pipeline) finish() { p.finishedOnce.Do(func() { close(p.finished) }) }
// Finished is closed when every source has ended, or the pipeline shut down.
// Only a finite source (console at end of input) ends without Stop.
func (p *Pipeline) Finished() <-chan struct{} { return p.finished }
// Start starts the pipeline operation and all its components including flow, sources, and sinks
// Start starts the sinks before the sources, so the first entry has somewhere to go
func (p *Pipeline) Start() error {
if !p.running.CompareAndSwap(false, true) {
return fmt.Errorf("pipeline %s is already running", p.Config.Name)
@@ -319,33 +320,34 @@ func (p *Pipeline) Start() error {
p.ctx, p.cancel = context.WithCancel(context.Background())
p.halt = make(chan struct{})
// Start all sinks
for id, s := range p.Sinks {
if err := s.Start(p.ctx); err != nil {
return fmt.Errorf("failed to start sink %s: %w", id, err)
return p.part("sink", id, fmt.Errorf("failed to start sink %s: %w", id, err))
}
}
// Subscribed before Start: a source publishes from its first entry on
// Subscribed before Start: a source publishes from its first entry on. One
// loop keeps ids beside their channels; map order differs between loops.
ids := make([]string, 0, len(p.Sources))
subscriptions := make([]<-chan core.LogEntry, 0, len(p.Sources))
for _, src := range p.Sources {
subscriptions = append(subscriptions, src.Subscribe())
for id, src := range p.Sources {
ids, subscriptions = append(ids, id), append(subscriptions, src.Subscribe())
}
for id, src := range p.Sources {
if err := src.Start(); err != nil {
return fmt.Errorf("failed to start source %s: %w", id, err)
return p.part("source", id, fmt.Errorf("failed to start source %s: %w", id, err))
}
}
// Start the central processing loop
p.Stats.StartTime = time.Now()
p.wg.Add(1)
go p.run(subscriptions)
go p.run(ids, subscriptions)
return nil
}
// Stop stops the pipeline operation and all its components including flow, sources, and sinks
// Stop stops the sources, lets the run loop drain, then stops the sinks, so
// entries in flight reach them
func (p *Pipeline) Stop() error {
if !p.running.CompareAndSwap(true, false) {
return fmt.Errorf("pipeline %s is not running", p.Config.Name)
@@ -354,7 +356,6 @@ func (p *Pipeline) Stop() error {
p.logger.Info("msg", "Stopping pipeline", "pipeline", p.Config.Name)
close(p.halt)
// 1. Stop all sources concurrently to halt new data ingress and close their channels
var sourceWg sync.WaitGroup
for _, src := range p.Sources {
sourceWg.Add(1)
@@ -365,10 +366,8 @@ func (p *Pipeline) Stop() error {
}
sourceWg.Wait()
// 2. Wait for the run loop to finish processing and sending all in-flight data
p.wg.Wait()
// 3. Stop all sinks concurrently now that no new data will be sent
var sinkWg sync.WaitGroup
for _, s := range p.Sinks {
sinkWg.Add(1)
@@ -384,20 +383,18 @@ func (p *Pipeline) Stop() error {
return nil
}
// Shutdown gracefully stops the pipeline and all its components, deinitializing them for app shutdown or complete pipeline removal by service
// Shutdown stops the pipeline for good, with its session manager
func (p *Pipeline) Shutdown() {
p.logger.Info("msg", "Shutting down pipeline",
"component", "pipeline",
"pipeline", p.Config.Name)
// Ensure the pipeline is stopped before shutting down
if p.running.Load() {
if err := p.Stop(); err != nil {
p.logger.Error("msg", "Error stopping pipeline during shutdown", "error", err)
}
}
// Stop long-running components
if p.Sessions != nil {
p.Sessions.Stop()
}
@@ -408,10 +405,8 @@ func (p *Pipeline) Shutdown() {
"pipeline", p.Config.Name)
}
// GetStats returns a map of pipeline statistics
func (p *Pipeline) GetStats() map[string]any {
// Recovery to handle concurrent access during shutdown
// When service is shutting down, sources/sinks might be nil or partially stopped
// Sources and sinks may be partly stopped while the service shuts down
defer func() {
if r := recover(); r != nil {
p.logger.Error("msg", "Panic getting pipeline stats",
@@ -420,7 +415,6 @@ func (p *Pipeline) GetStats() map[string]any {
}
}()
// 1. Live collect source stats
sources := make([]map[string]any, 0, len(p.Sources))
for _, src := range p.Sources {
if src == nil {
@@ -438,7 +432,6 @@ func (p *Pipeline) GetStats() map[string]any {
})
}
// 2. Live collect sink stats
sinks := make([]map[string]any, 0, len(p.Sinks))
for _, snk := range p.Sinks {
if snk == nil {
@@ -456,7 +449,6 @@ func (p *Pipeline) GetStats() map[string]any {
})
}
// 3. Collect flow stats and calculate filtered total
var flowStats map[string]any
var totalFiltered uint64
var totalProcessed uint64
@@ -464,12 +456,10 @@ func (p *Pipeline) GetStats() map[string]any {
if p.Flow != nil {
flowStats = p.Flow.GetStats()
// Map the top-level processed counter directly from Flow's source of truth
if tp, ok := flowStats["total_processed"].(uint64); ok {
totalProcessed = tp
}
// Calculate total dropped specifically by the filter chain
if filters, ok := flowStats["filters"].(map[string]any); ok {
if totalPassed, ok := filters["total_passed"].(uint64); ok {
if tProc, ok := filters["total_processed"].(uint64); ok {
@@ -479,7 +469,6 @@ func (p *Pipeline) GetStats() map[string]any {
}
}
// 4. Calculate Uptime
var uptime int
if p.running.Load() && !p.Stats.StartTime.IsZero() {
uptime = int(time.Since(p.Stats.StartTime).Seconds())
+136 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strconv"
"strings"
"testing"
"time"
@@ -17,9 +18,12 @@ import (
"github.com/lixenwraith/log"
)
// finiteSource publishes n entries as soon as it starts, then ends
// finiteSource publishes n entries as soon as it starts, then ends, once hold
// closes when there is one. Entries carry its id as their Source.
type finiteSource struct {
id string
n int
hold chan struct{}
subs []chan core.LogEntry
}
@@ -33,9 +37,13 @@ func (s *finiteSource) Start() error {
go func() {
for i := range s.n {
for _, ch := range s.subs {
ch <- core.LogEntry{Time: time.Now(), Message: strconv.Itoa(i)}
msg := strconv.Itoa(i)
ch <- core.LogEntry{Time: time.Now(), Source: s.id, Message: msg, RawSize: int64(len(msg))}
}
}
if s.hold != nil {
<-s.hold
}
for _, ch := range s.subs {
close(ch)
}
@@ -74,7 +82,8 @@ var testSinks = map[string]*slowSink{}
func init() {
plugin.RegisterSource("random", func(id string, cfg map[string]any, _ *log.Logger, _ *session.Proxy) (source.Source, error) {
n, _ := strconv.Atoi(fmt.Sprint(cfg["n"]))
return &finiteSource{n: n}, nil
hold, _ := cfg["hold"].(chan struct{})
return &finiteSource{id: id, n: n, hold: hold}, nil
})
plugin.RegisterSink("null", func(id string, _ map[string]any, _ *log.Logger, _ *session.Proxy) (sink.Sink, error) {
return testSinks[id], nil
@@ -132,3 +141,127 @@ func TestStopReleasesStalledBackpressureSink(t *testing.T) {
t.Fatal("Stop blocked behind a stalled backpressure sink")
}
}
// The tap gets each entry's verdict under its source's id: the stage that
// dropped it, or what the sinks got. Drops come only when All is set, and
// heartbeats come as passed, from no source.
func TestTapSeesEachVerdict(t *testing.T) {
const n = 1000
verdict := func(msg string) (string, int) {
switch {
case len(msg) > 2:
return "rate_limit", 0
case strings.HasPrefix(msg, "1"):
return "filters", 0
case !strings.HasSuffix(msg, "0"):
return "filters", 1
}
return "", 0
}
for _, all := range []bool{false, true} {
hold := make(chan struct{})
sink := &slowSink{in: make(chan core.TransportEvent, 4*n)}
testSinks[t.Name()] = sink
p, err := NewPipeline(&config.PipelineConfig{
Name: t.Name(),
Flow: &config.FlowConfig{
RateLimit: &config.RateLimitConfig{EntriesPerSecond: 1e6, Policy: "drop", MaxEntrySizeBytes: 2},
Filters: []config.FilterConfig{{Type: "exclude", Patterns: []string{" 1"}}, {Type: "include", Patterns: []string{"0$"}}},
Heartbeat: &config.HeartbeatConfig{Enabled: true, IntervalMS: 100},
},
PluginSources: []config.PluginSourceConfig{
{ID: "a", Type: "random", Config: map[string]any{"n": n, "hold": hold}},
{ID: "b", Type: "random", Config: map[string]any{"n": n, "hold": hold}},
},
PluginSinks: []config.PluginSinkConfig{{ID: t.Name(), Type: "null"}},
}, log.NewLogger())
if err != nil {
t.Fatal(err)
}
tap := core.NewTap(4 * n)
tap.All.Store(all)
p.SetTap(tap)
if err := p.Start(); err != nil {
t.Fatal(err)
}
var got []core.Tapped
for beat := false; !beat; {
select {
case r := <-tap.Records():
if beat = r.Source == ""; beat && (r.Stage != "" || r.Event.Entry.Source != "heartbeat" || len(r.Event.Payload) == 0) {
t.Errorf("heartbeat %+v", r)
}
got = append(got, r)
case <-time.After(5 * time.Second):
t.Fatal("no heartbeat reached the tap")
}
}
close(hold)
<-p.Finished()
p.Shutdown()
for len(tap.Records()) > 0 {
got = append(got, <-tap.Records())
}
seen, passed := map[string]int{}, 0
for _, r := range got {
e := r.Event.Entry
if r.Stage == "" {
passed++
}
if r.Source == "" {
continue
}
stage, index := verdict(e.Message)
if r.Pipeline != t.Name() || r.Source != e.Source || r.Stage != stage || r.Index != index ||
stage == "" && string(r.Event.Payload) != e.Message+"\n" {
t.Errorf("all=%v: %s %q: got %s %s[%d] %q, want %s[%d]", all, r.Source, e.Message, r.Pipeline, r.Stage, r.Index, r.Event.Payload, stage, index)
}
seen[r.Source+e.Message]++
}
for i := range n {
msg := strconv.Itoa(i)
want := 1
if stage, _ := verdict(msg); stage != "" && !all {
want = 0
}
for _, id := range []string{"a", "b"} {
if seen[id+msg] != want {
t.Errorf("all=%v: %s %q tapped %d times, want %d", all, id, msg, seen[id+msg], want)
}
}
}
if tap.Dropped() != 0 || len(sink.in) != passed {
t.Errorf("all=%v: a tap with room dropped %d; the sink got %d, %d passed", all, tap.Dropped(), len(sink.in), passed)
}
}
}
// A tap nobody reads costs the pipeline nothing: every entry still reaches
// the sink, and the tap counts what it could not take.
func TestFullTapNeverStallsAPipeline(t *testing.T) {
const n = 2000
s := &slowSink{in: make(chan core.TransportEvent, 1), out: make(chan string, n)}
p := newTestPipeline(t, n, s)
tap := core.NewTap(1)
p.SetTap(tap)
if err := p.Start(); err != nil {
t.Fatal(err)
}
select {
case <-p.Finished():
case <-time.After(10 * time.Second):
t.Fatal("a full tap stalled the pipeline")
}
for i := range n {
select {
case <-s.out:
case <-time.After(5 * time.Second):
t.Fatalf("the sink got %d of %d", i, n)
}
}
p.Shutdown()
if tap.Dropped() != n-1 {
t.Fatalf("tap dropped %d, want %d", tap.Dropped(), n-1)
}
}
+13 -15
View File
@@ -7,6 +7,7 @@ import (
"sync"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/pipeline"
"github.com/lixenwraith/log"
@@ -25,7 +26,6 @@ type Service struct {
done chan struct{} // closed when every pipeline has finished
}
// NewService creates a new, empty service
func NewService(ctx context.Context, cfg *config.Config, logger *log.Logger) (*Service, error) {
serviceCtx, cancel := context.WithCancel(ctx)
svc := &Service{
@@ -37,12 +37,10 @@ func NewService(ctx context.Context, cfg *config.Config, logger *log.Logger) (*S
}
var errs error
// Initialize pipelines
for _, pipelineCfg := range cfg.Pipelines {
pipelineName := pipelineCfg.Name
logger.Info("msg", "Initializing pipeline", "pipeline", pipelineName)
// Create the pipeline
if pl, err := pipeline.NewPipeline(&pipelineCfg, logger); err != nil {
logger.Error("msg", "Failed to create pipeline",
"pipeline", pipelineCfg.Name,
@@ -63,13 +61,12 @@ func NewService(ctx context.Context, cfg *config.Config, logger *log.Logger) (*S
return svc, nil
}
// Start starts all or specific pipelines
// Start starts the named pipelines, or all of them without names
func (svc *Service) Start(names ...string) error {
svc.mu.RLock()
defer svc.mu.RUnlock()
var errs error
// If no names are provided, start all pipelines
if len(names) == 0 {
svc.logger.Info("msg", "Starting all pipelines")
for name, p := range svc.pipelines {
@@ -78,7 +75,6 @@ func (svc *Service) Start(names ...string) error {
}
}
} else {
// Start only the specified pipelines
svc.logger.Info("msg", "Starting specified pipelines", "pipelines", names)
for _, name := range names {
if p, exists := svc.pipelines[name]; exists {
@@ -108,18 +104,26 @@ func (svc *Service) Start(names ...string) error {
return errs
}
// SetTap gives every pipeline the viewer's tap; nil takes it away
func (svc *Service) SetTap(t *core.Tap) {
svc.mu.RLock()
defer svc.mu.RUnlock()
for _, p := range svc.pipelines {
p.SetTap(t)
}
}
// Done is closed once every pipeline has finished: all their sources reached
// the end of their input (stdin), or the service shut down.
func (svc *Service) Done() <-chan struct{} { return svc.done }
// Stop stops all or specific pipeline
// Stop stops the named pipelines, or all of them without names
func (svc *Service) Stop(names ...string) error {
svc.mu.RLock()
defer svc.mu.RUnlock()
var errs error
// If no names are provided, stop all pipelines
if len(names) == 0 {
svc.logger.Info("msg", "Stopping all pipelines")
for name, p := range svc.pipelines {
@@ -128,7 +132,6 @@ func (svc *Service) Stop(names ...string) error {
}
}
} else {
// Stop only the specified pipelines
svc.logger.Info("msg", "Stopping specified pipelines", "pipelines", names)
for _, name := range names {
if p, exists := svc.pipelines[name]; exists {
@@ -146,7 +149,6 @@ func (svc *Service) Stop(names ...string) error {
return errs
}
// GetPipeline returns a pipeline by its name
func (svc *Service) GetPipeline(name string) (*pipeline.Pipeline, error) {
svc.mu.RLock()
defer svc.mu.RUnlock()
@@ -158,7 +160,6 @@ func (svc *Service) GetPipeline(name string) (*pipeline.Pipeline, error) {
return pipeline, nil
}
// ListPipelines returns the names of all currently managed pipelines
func (svc *Service) ListPipelines() []string {
svc.mu.RLock()
defer svc.mu.RUnlock()
@@ -170,7 +171,6 @@ func (svc *Service) ListPipelines() []string {
return names
}
// RemovePipeline stops and removes a pipeline from the service
func (svc *Service) RemovePipeline(name string) error {
svc.mu.Lock()
defer svc.mu.Unlock()
@@ -191,7 +191,7 @@ func (svc *Service) RemovePipeline(name string) error {
return nil
}
// Shutdown gracefully stops all pipelines managed by the service
// Shutdown stops every pipeline for good, concurrently
func (svc *Service) Shutdown() {
svc.shutdownOnce.Do(svc.shutdown)
}
@@ -206,7 +206,6 @@ func (svc *Service) shutdown() {
}
svc.mu.Unlock()
// Stop all pipelines concurrently
var wg sync.WaitGroup
for _, pl := range pipelines {
wg.Add(1)
@@ -223,7 +222,6 @@ func (svc *Service) shutdown() {
svc.logger.Info("msg", "Service shutdown complete")
}
// GetGlobalStats returns statistics for all pipelines
func (svc *Service) GetGlobalStats() map[string]any {
svc.mu.RLock()
defer svc.mu.RUnlock()
+11 -57
View File
@@ -1,19 +1,17 @@
package console
import (
"bytes"
"context"
"encoding/hex"
"fmt"
"io"
"os"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/logwisp/internal/logline"
"github.com/lixenwraith/logwisp/internal/plugin"
"github.com/lixenwraith/logwisp/internal/session"
"github.com/lixenwraith/logwisp/internal/sink"
@@ -82,10 +80,7 @@ func NewConsoleSinkPlugin(
return nil, err
}
output := os.Stdout
if opts.Target == "stderr" {
output = os.Stderr
}
output := Stream(opts.Target)
// inline decides auto: a terminal, NO_COLOR unset, TERM not dumb. Paint
// returns its text unchanged exactly when color is off.
@@ -134,6 +129,14 @@ func NewConsoleSinkPlugin(
return cs, nil
}
// Stream is the stream a console sink with target writes
func Stream(target string) *os.File {
if target == "stderr" {
return os.Stderr
}
return os.Stdout
}
// Capabilities returns supported capabilities
func (cs *ConsoleSink) Capabilities() []core.Capability {
return []core.Capability{
@@ -228,7 +231,7 @@ func (cs *ConsoleSink) drain() {
func (cs *ConsoleSink) write(event core.TransportEvent) {
payload := event.Payload
if cs.escape {
payload = escapeControls(payload)
payload = logline.EscapeControls(payload)
}
if cs.painter != nil {
payload = paintLevel(cs.painter, payload, event.Entry.Level)
@@ -260,52 +263,3 @@ func paintLevel(p *inline.Printer, payload []byte, level string) []byte {
}
return payload
}
// escapeControls writes what a terminal would act on or reorder (C0 and C1
// controls but tab, DEL, bidi controls, line separators, invalid UTF-8) as
// <hex>, keeping the final newline: a log line cannot drive the terminal.
// Other invisible characters stay: emoji and Indic scripts need ZWJ.
func escapeControls(p []byte) []byte {
body, newline := bytes.CutSuffix(p, []byte{'\n'})
if printableASCII(body) {
return p
}
out := make([]byte, 0, len(p)+16)
for len(body) > 0 {
r, n := utf8.DecodeRune(body)
if !terminalControl(r, n) {
out = append(out, body[:n]...)
} else {
out = append(out, '<')
out = hex.AppendEncode(out, body[:n])
out = append(out, '>')
}
body = body[n:]
}
if newline {
out = append(out, '\n')
}
return out
}
func terminalControl(r rune, size int) bool {
switch {
case r == utf8.RuneError && size == 1:
return true
case r < 0x20:
return r != '\t'
case r >= 0x7f && r <= 0x9f, r == 0x061c, r == 0x200e, r == 0x200f,
r >= 0x2028 && r <= 0x202e, r >= 0x2066 && r <= 0x2069:
return true
}
return false
}
func printableASCII(b []byte) bool {
for _, c := range b {
if (c < 0x20 || c > 0x7e) && c != '\t' {
return false
}
}
return true
}
-22
View File
@@ -7,28 +7,6 @@ import (
"github.com/lixenwraith/terminal/inline"
)
// Everything a terminal would act on is escaped; tabs and the final newline,
// which it only lays out, are kept.
func TestEscapeControlsKeepsOnlyLayout(t *testing.T) {
for in, want := range map[string]string{
"plain\ttext\n": "plain\ttext\n",
"a\x1b[2Jb\n": "a<1b>[2Jb\n",
"bell\x07 del\x7f\n": "bell<07> del<7f>\n",
"inner\nnewline\n": "inner<0a>newline\n",
"c1 \u009b31m\n": "c1 <c29b>31m\n",
"bidi \u202eevil\n": "bidi <e280ae>evil\n",
"isolate \u2066x\u2069\n": "isolate <e281a6>x<e281a9>\n",
"line\u2028sep\n": "line<e280a8>sep\n",
"zwj \U0001f468\u200d\U0001f469\n": "zwj \U0001f468\u200d\U0001f469\n",
"bad \xff utf8": "bad <ff> utf8",
"ünïcødé and \ufffd kept\n": "ünïcødé and \ufffd kept\n",
} {
if got := string(escapeControls([]byte(in))); got != want {
t.Errorf("%q: got %q, want %q", in, got, want)
}
}
}
// The entry's level, under any of its names, is painted where its line first
// names it as a whole word, in any case; other words and levels stay plain.
func TestPaintLevelColorsTheFirstWholeName(t *testing.T) {
+1
View File
@@ -82,6 +82,7 @@ func NewNullSinkPlugin(
func (ns *NullSink) Capabilities() []core.Capability {
return []core.Capability{
core.CapSessionAware,
core.CapBackpressure, // it discards at once: a wait loses nothing, a drop would count
}
}
+190 -71
View File
@@ -1,6 +1,7 @@
package tui
import (
"cmp"
"fmt"
"slices"
"strconv"
@@ -13,64 +14,129 @@ import (
)
const (
pitch = 3 // rows a source or sink takes, its gap included
gutter = 4 // columns of wire on either side of the flow box
wideMin = 64 // the narrowest canvas with the parts side by side
gutter = 4 // columns of wire a side when the content does not fit
wideMin = 64 // the narrowest canvas with the parts side by side
besideMin = 110 // the narrowest screen with the inspector beside the canvas
listMax = 4 // option rows a source or sink lists beside the inspector
)
// spot is where a node draws: its first row and the columns it may use
type spot struct{ x, y, w int }
// Widths beside the inspector: columns sized to their content within these,
// and wires taking the slack up to wireMax a side
const (
sideMin, sideMax = 24, 40
boxMin, boxMax = 18, 44
wireMax = 16
inspMin, inspMax = 60, 100 // the inspector beside the canvas
)
// layout places a pipeline's parts: side by side with wires from 64 columns,
// stacked below that
// spot is where a node draws: its first row, the columns it may use, its
// rows, the column its head's text ends at (a source's wire starts past it),
// and its text under or beside its name
type spot struct {
x, y, w, h, end int
text []string
}
// layout places the screen's parts: the inspector beside the canvas from 110
// columns, below it under that; on the canvas, a pipeline's parts side by
// side with wires from 64 columns, stacked below that. Beside the inspector
// the parts take their content's widths, centred; below it, a fixed split.
type layout struct {
wide bool
h int
w, h int // the canvas's
inspector int // the inspector's width beside the canvas, 0 below it
title [3]spot
nodes [3][]spot // a column's nodes, or its one empty slot
box spot // the flow box's top left and width
boxH int
entry, exit int // the rows wires enter and leave the flow box
wire int // columns between a side column and the flow box
}
// layout lays out a screen w wide; each node's text is read once, and sizes
// the columns
func (a *app) layout(w int) layout {
var l layout
counts := [3]int{}
for col := range columns {
counts[col] = max(1, len(a.nodes(col)))
}
stages := counts[flowCol]
l.boxH = stages + 2
if w >= wideMin {
l.wide = true
boxW := max(18, min(30, (w-2*gutter)/3))
sideW := (w - boxW - 2*gutter) / 2
xs := [3]int{0, sideW + gutter, sideW + 2*gutter + boxW}
ws := [3]int{sideW, boxW, w - xs[sinksCol]}
for col := range columns {
l.title[col] = spot{xs[col], 0, ws[col]}
}
l.box = spot{xs[flowCol], 1, boxW}
for _, col := range []int{sourcesCol, sinksCol} {
for i := range counts[col] {
l.nodes[col] = append(l.nodes[col], spot{xs[col], 1 + i*pitch, ws[col]})
l := layout{w: w}
beside := w >= besideMin
side, box := sideMin, boxMin
for _, col := range []int{sourcesCol, sinksCol} {
for _, n := range a.nodes(col) {
p, _ := a.plugin(n)
s := spot{end: 2 + ui.RuneLen(p.Type)}
if beside {
s.text = a.optionRows(n)
} else {
s.text = []string{a.summary(n)}
}
if verb := ways[p.Side]; verb != "" {
s.end += 2 + ui.RuneLen(verb)
}
s.h = 1 + len(s.text)
side = max(side, s.end+3)
for _, t := range s.text {
side = max(side, 3+ui.RuneLen(t))
}
l.nodes[col] = append(l.nodes[col], s)
}
l.entry = l.box.y + 1 + (stages-1)/2
if len(l.nodes[col]) == 0 {
l.nodes[col] = []spot{{h: 2}}
}
}
var stages []spot
for _, n := range a.nodes(flowCol) {
text := a.summary(n)
if !a.on(n) {
text = "off"
}
box = max(box, 7+ui.RuneLen(n.Role)+ui.RuneLen(text))
stages = append(stages, spot{h: 1, text: []string{text}})
}
if beside {
want := 2*min(side, sideMax) + min(box, boxMax) + 2*wireMax + 4 // with a margin
l.inspector = max(min(inspMin, w*2/5), min(inspMax, w-want-1))
l.w = w - l.inspector - 1
}
l.boxH = max(1, len(stages)) + 2
if l.w >= wideMin {
l.wide = true
side, box = min(side, sideMax), min(box, boxMax)
l.wire = min(wireMax, (l.w-2*side-box)/2)
if !beside || l.wire < gutter {
box = max(boxMin, min(30, (l.w-2*gutter)/3))
side, l.wire = (l.w-box-2*gutter)/2, gutter
}
x0 := (l.w - 2*side - box - 2*l.wire) / 2
xs := [3]int{x0, x0 + side + l.wire, x0 + side + 2*l.wire + box}
ws := [3]int{side, box, side}
for col := range columns {
l.title[col] = spot{x: xs[col], w: ws[col]}
}
l.box = spot{x: xs[flowCol], y: 1, w: box}
l.h = 1 + l.boxH
for _, col := range []int{sourcesCol, sinksCol} {
y := 1
for i := range l.nodes[col] {
s := &l.nodes[col][i]
s.x, s.y, s.w = xs[col], y, ws[col]
y += s.h + 1
}
l.h = max(l.h, y-1)
}
l.entry = l.box.y + 1 + (l.boxH-3)/2
l.exit = l.entry
l.h = 1 + max(counts[sourcesCol]*pitch-1, l.boxH, counts[sinksCol]*pitch-1)
} else {
y := 0
for col := range columns {
l.title[col] = spot{0, y, w}
l.title[col] = spot{y: y, w: l.w}
y++
if col == flowCol {
l.box = spot{0, y, w}
l.box = spot{y: y, w: l.w}
y += l.boxH
} else {
for range counts[col] {
l.nodes[col] = append(l.nodes[col], spot{0, y, w})
y += pitch
for i := range l.nodes[col] {
s := &l.nodes[col][i]
s.y, s.w = y, l.w
y += s.h + 1
}
y--
}
@@ -80,18 +146,18 @@ func (a *app) layout(w int) layout {
}
l.h = y
}
for i := range stages {
l.nodes[flowCol] = append(l.nodes[flowCol], spot{l.box.x + 2, l.box.y + 1 + i, l.box.w - 4})
for i, s := range stages {
s.x, s.y, s.w = l.box.x+2, l.box.y+1+i, l.box.w-4
l.nodes[flowCol] = append(l.nodes[flowCol], s)
}
return l
}
// drawCanvas draws the pipeline, scrolled to keep the chosen node in view
func (a *app) drawCanvas(r ui.Region) {
l := a.layout(r.W)
func (a *app) drawCanvas(r ui.Region, l layout) {
chosen := l.nodes[a.col][min(a.row[a.col], len(l.nodes[a.col])-1)]
a.scroll.SetDimensions(l.h, r.H)
a.scroll.EnsureRange(chosen.y, 2-a.col%2)
a.scroll.EnsureRange(chosen.y, chosen.h)
r.Window(l.h, &a.scroll, a.th.Text, func(full ui.Region) { a.drawParts(full, l) })
}
@@ -112,7 +178,11 @@ func (a *app) drawParts(r ui.Region, l layout) {
a.drawEmpty(r, s, columns[col].role, chosen)
continue
}
a.drawNode(r, s, nodes[i], tints[col], chosen)
head := s.w // a source's leader starts past its text, where its bar ends
if l.wide && col == sourcesCol {
head = min(s.w, s.end+1)
}
a.drawNode(r, s, head, nodes[i], tints[col], chosen)
}
if l.wide && len(nodes) > 0 {
join(g, l, col)
@@ -129,12 +199,14 @@ func (a *app) drawParts(r ui.Region, l layout) {
r.TextStyled(l.box.x-1, l.entry, string(f.arrow), th.Border)
}
if len(a.nodes(sinksCol)) > 0 {
r.TextStyled(l.box.x+l.box.w+1, l.exit, string(f.arrow), th.Border)
for _, s := range l.nodes[sinksCol] {
r.TextStyled(s.x-1, s.y, string(f.arrow), th.Border)
}
}
}
// join wires a column's nodes to a trunk beside the flow box, and the trunk
// to the box's side
// join wires a column's nodes to a trunk midway along the wire, and the
// trunk to the flow box's side: a source's wire starts past its text
func join(g ui.Wires, l layout, col int) {
ys := []int{l.entry}
if col == sinksCol {
@@ -144,16 +216,16 @@ func join(g ui.Wires, l layout, col int) {
ys = append(ys, s.y)
}
if col == sourcesCol {
trunk := l.box.x - gutter + 1
trunk := l.box.x - 1 - l.wire/2
for _, s := range l.nodes[col] {
g.H(s.y, trunk-1, trunk)
g.H(s.y, s.x+min(s.end+1, s.w), trunk)
}
g.V(trunk, slices.Min(ys), slices.Max(ys))
g.H(l.entry, trunk, l.box.x-1)
return
}
right := l.box.x + l.box.w
trunk := right + 2
trunk := right + 1 + (l.wire-1)/2
g.H(l.exit, right, trunk)
g.V(trunk, slices.Min(ys), slices.Max(ys))
for _, s := range l.nodes[col] {
@@ -162,9 +234,10 @@ func join(g ui.Wires, l layout, col int) {
}
// drawNode draws a source or sink: a tinted bar, its type and whether it
// listens or dials, then a summary of its options. The chosen node's bar is
// solid; a node a problem names has its bar in the error style.
func (a *app) drawNode(r ui.Region, s spot, n compose.Node, tint ui.Style, chosen bool) {
// listens or dials, then its text. The chosen node is a bar across its rows,
// head wide on the first, whose tint block keeps its color; a node a problem
// names has its tint in the error style.
func (a *app) drawNode(r ui.Region, s spot, head int, n compose.Node, tint ui.Style, chosen bool) {
th := a.th
bg, bar := th.Text, a.font.bar
if chosen {
@@ -174,20 +247,31 @@ func (a *app) drawNode(r ui.Region, s spot, n compose.Node, tint ui.Style, chose
tint = th.Error
}
p, _ := a.plugin(n)
way := map[config.Side]string{config.Listener: "listens", config.ChainListener: "listens", config.Dialer: "dials"}[p.Side]
for dy, text := range []string{p.Type, a.summary(n)} {
row := r.Sub(s.x, s.y+dy, s.w, 1)
row.FillStyle(bg)
row.TextStyled(0, 0, string(bar), tint.On(bg))
style := th.Muted.On(bg)
verb, room := ways[p.Side], s.w-3
if verb != "" {
room -= 2 + ui.RuneLen(verb)
}
for dy := range s.h {
w := s.w
if dy == 0 {
style = th.Text.On(bg)
row.TextStyled(row.W-ui.RuneLen(way), 0, way, th.Muted.On(bg))
w = head
}
row.TextStyled(2, 0, ui.Truncate(text, row.W-ui.RuneLen(way)-3), style)
row := r.Sub(s.x, s.y+dy, w, 1)
row.FillStyle(bg)
row.TextStyled(0, 0, string(bar), tint.On(th.Text))
if dy > 0 {
row.TextStyled(2, 0, ui.Truncate(s.text[dy-1], row.W-3), th.Muted.On(bg))
continue
}
typ := ui.Truncate(p.Type, room)
row.TextStyled(2, 0, typ, th.Text.On(bg))
row.TextStyled(4+ui.RuneLen(typ), 0, verb, th.Muted.On(bg))
}
}
// ways say whether a source or sink listens or dials, by its side
var ways = map[config.Side]string{config.Listener: "listens", config.ChainListener: "listens", config.Dialer: "dials"}
func (a *app) drawEmpty(r ui.Region, s spot, role string, chosen bool) {
mark, style := " ", a.th.Muted
if chosen {
@@ -215,13 +299,12 @@ func (a *app) drawBox(r ui.Region, l layout) {
}
row := r.Sub(s.x, s.y, s.w, 1)
row.FillStyle(bg)
mark, tint, text := f.bar, a.look.Flow, th.Text
mark, tint, text, under := f.bar, a.look.Flow, th.Text, th.Text // a block keeps its tint on the bar
if chosen {
mark = f.barOn
}
summary := a.summary(n)
if !a.on(n) {
mark, tint, text, summary = f.Off, th.Muted, th.Muted, "off"
mark, tint, text, under = f.Off, th.Muted, th.Muted, bg
if chosen {
mark = th.Glyphs.Pointer
}
@@ -229,10 +312,10 @@ func (a *app) drawBox(r ui.Region, l layout) {
if a.faulty(n) {
tint = th.Error
}
row.TextStyled(0, 0, string(mark), tint.On(bg))
row.TextStyled(0, 0, string(mark), tint.On(under))
row.TextStyled(2, 0, n.Role, text.On(bg))
x := 3 + ui.RuneLen(n.Role)
row.TextStyled(x, 0, ui.Truncate(summary, row.W-x), th.Muted.On(bg))
row.TextStyled(x, 0, ui.Truncate(s.text[0], row.W-x), th.Muted.On(bg))
}
}
@@ -249,8 +332,8 @@ func (a *app) on(n compose.Node) bool {
return on
}
// summary is a node's options in a line: the values set, a table by its
// name, a switch by its key; the filters as +include and -exclude patterns
// summary is a node's options in a line: the values set, a table or a switch
// by its name; the filters as +include and -exclude patterns
func (a *app) summary(n compose.Node) string {
if n.Role == "filters" {
var out []string
@@ -260,23 +343,59 @@ func (a *app) summary(n compose.Node) string {
}
return strings.Join(out, " ")
}
opts, _, _ := a.comp.Options(a.pi, n)
var out []string
for _, s := range a.settings(n) {
out = append(out, cmp.Or(s.value, s.key))
}
return strings.Join(out, " ")
}
// optionRows are a source's or sink's options set, a row each with the
// values aligned, at most listMax; "defaults" when none is set
func (a *app) optionRows(n compose.Node) []string {
set := a.settings(n)
if len(set) == 0 {
return []string{"defaults"}
}
set = set[:min(len(set), listMax)]
keyW := 0
for _, s := range set {
keyW = max(keyW, ui.RuneLen(s.key))
}
var out []string
for _, s := range set {
row := s.key
if s.value != "" {
row = ui.PadRight(s.key, keyW) + " " + s.value
}
out = append(out, row)
}
return out
}
// setting is an option set: its key, and its value as shown, none for a
// table or a switch
type setting struct{ key, value string }
// settings are a node's options set, in its keys' order
func (a *app) settings(n compose.Node) []setting {
opts, _, _ := a.comp.Options(a.pi, n)
var out []setting
for _, k := range a.keys(n) {
v, ok := opts[k.Name]
switch {
case !ok || k.Name == "enabled" || v == false:
case k.Kind == "table":
if t, _ := v.(map[string]any); t["enabled"] != false {
out = append(out, k.Name)
out = append(out, setting{key: k.Name})
}
case v == true:
out = append(out, k.Name)
out = append(out, setting{key: k.Name})
default:
out = append(out, show(v))
out = append(out, setting{k.Name, show(v)})
}
}
return strings.Join(out, " ")
return out
}
// show writes a value as a form does: a list joined by commas
+89 -14
View File
@@ -1,6 +1,8 @@
package tui
import (
"crypto/rand"
"errors"
"fmt"
"strings"
@@ -239,6 +241,7 @@ type form struct {
focus ui.Focus
scroll ui.ViewportScroll // the fields' rows
err string
mask bool // draws each rune typed as '*'
apply func(a *app, values map[string]string) error
back func(a *app) // on Esc, after the form closes
}
@@ -278,7 +281,15 @@ func (f *form) draw(a *app, r ui.Region) {
view.Window(len(f.fields)*tall, &f.scroll, th.Text, func(all ui.Region) {
for i, p := range f.params {
v, _ := all.Field(i*tall, labelW, ui.Field{Label: p.Name, Required: p.Required}, i == f.focus.Index, th)
v.TextInput(f.fields[i], p.Default, i == f.focus.Index, th)
field := f.fields[i]
if !f.mask {
v.TextInput(field, p.Default, i == f.focus.Index, th)
continue
}
shown := *field
shown.Text = []rune(strings.Repeat("*", len(field.Text)))
v.TextInput(&shown, p.Default, i == f.focus.Index, th)
field.Scroll = shown.Scroll
}
})
note, style := f.params[f.focus.Index].Usage(), th.Muted
@@ -337,36 +348,81 @@ func (f *form) submit(a *app) {
}
}
// outputMenu is what o offers: run, or print one of the forms
// outputMenu is what o offers: run, tap, or print one of the forms
func (a *app) outputMenu() dialog {
forms := compose.Forms()
options := []ui.Option{{Name: "run", Key: 'r', Hint: "start the pipelines now, with lw's other settings"}}
options := []ui.Option{{Name: "run", Key: 'r', Hint: "start the pipelines now, with lw's other settings"},
{Name: "tap", Key: 't', Hint: "run here and watch what flows; it runs for real"}}
for _, f := range forms {
options = append(options, ui.Option{Name: f.Name, Key: rune(f.Name[0]), Hint: "print " + f.Hint})
}
list := ui.NewOptionListState(options)
list.Menu = true
return &chooser{title: "Output, once the screen closes", list: list,
return &chooser{title: "Run, or print once the screen closes", list: list,
pick: func(a *app, i int) {
if i == 0 {
switch i {
case 0:
a.run()
return
case 1:
a.tap()
return
}
text, err := forms[i-1].Write(a.comp)
text, err := forms[i-2].Write(a.comp)
if err != nil {
a.status = err.Error()
return
}
a.result, a.done = Result{Exit: Print, Output: text}, true
a.leave(Result{Exit: Print, Output: text})
}}
}
// askPassword asks in a masked form for a password a terminal would be asked
// for; with generate, Enter alone draws one and shows it once
func (a *app) askPassword(user string, generate bool, reply chan<- []byte) {
answer := func(pw []byte) {
select {
case reply <- pw:
default:
}
}
if a.live.leaving {
answer(nil)
return
}
help := "the password lw would ask on the terminal"
if generate {
help += "; Enter alone generates one, shown once"
}
f := &form{title: "Password for " + user, params: []config.PresetParam{{Name: "password", Required: !generate, Help: help}}, mask: true,
apply: func(a *app, v map[string]string) error {
pw := v["password"]
switch {
case pw == "" && !generate:
return errors.New("type the password, or esc to give none")
case pw == "":
// answered once read: the start goes on only then, so no
// later ask or error replaces the notice unread
pw = rand.Text()
n := textNotice("Password for "+user, "generated, shown this once: "+pw)
n.closed, a.dialog = func(*app) { answer([]byte(pw)) }, n
return nil
}
answer([]byte(pw))
return nil
},
back: func(*app) { answer(nil) }}
f.fill(nil)
a.dialog = f
}
// notice shows text, wrapped to the screen, until a key other than one
// that scrolls it
type notice struct {
title string
body func(w int) []string // the lines at a width
view ui.ViewportScroll
title string
body func(w int) []string // the lines at a width
view ui.ViewportScroll
closed func(a *app) // after a key closed it
}
// textNotice is a notice of plain text, its paragraphs split at '\n'
@@ -406,7 +462,9 @@ func (n *notice) handle(a *app, ev terminal.Event) {
case is(ev, terminal.KeyPageDown, ""):
n.view.PageDown()
default:
a.dialog = nil
if a.dialog = nil; n.closed != nil {
n.closed(a)
}
}
}
@@ -440,8 +498,9 @@ func (c *confirm) handle(a *app, ev terminal.Event) {
}
}
// help lists the keys, each beside its description or, narrow, above it
func help(f font) dialog {
// help lists the keys of the diagram or, viewing, the view's, each beside
// its description or, narrow, above it
func help(f font, viewing bool) dialog {
keys := [][2]string{
{"arrows, hjkl", "move; up from the top row to the pipeline bar"},
{f.enter, "inspect a part; edit a value; open a list; add"},
@@ -455,11 +514,27 @@ func help(f font) dialog {
{"[ ]", "previous or next pipeline"},
{"p", "start over from a preset"},
{"r", "run the pipelines"},
{"o", "run, or print a command line, environment or file"},
{"o", "run, tap, or print a command line, environment or file"},
{"t", "tap: run here and watch what flows; it runs for real: binds, dials, writes files"},
{"!", "every problem, moving to the first"},
{"paste", "an lw command line replaces the pipelines"},
{"q", "quit"},
}
if viewing {
keys = [][2]string{
{"arrows, jk", "move; up pauses following, down to the newest follows"},
{"g G", "the oldest row; the newest, following; f follows too"},
{f.enter, "the row's detail; J K scroll it"},
{"/", "only the rows matching a pattern; lower case ignores case"},
{"1-5 0", "trace, debug, info, warn or error and up; all"},
{"v", "all that reaches the point, what passed it, what it dropped"},
{"s", "look at a source, a flow stage, the sinks or lw's own log"},
{"i", "inspect the stage that dropped the row"},
{"c", "clear the rows"},
{"t esc", "back to the diagram; the pipelines keep running"},
{"q", "stop the pipelines and quit"},
}
}
return &notice{title: "Keys", body: func(w int) []string {
var lines []string
for _, k := range keys {
+2 -1
View File
@@ -163,8 +163,9 @@ func (a *app) drawInspector(r ui.Region) {
row := r.Sub(at/perCol*colW, 1+at%perCol, colW-1, 1)
a.drawLine(row, lines[i], labelW, a.inspect && i == a.insp.cursor)
}
under := min(r.H-len(help), 2+min(perCol, len(lines)-a.insp.scroll)) // two rows under the last key
for i, h := range help {
r.TextStyled(2, r.H-len(help)+i, h, style)
r.TextStyled(2, under+i, h, style)
}
}
+316
View File
@@ -0,0 +1,316 @@
package tui
import (
"errors"
"fmt"
"maps"
"math"
"os"
"os/signal"
"slices"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/lixenwraith/logwisp/internal/compose"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
ui "github.com/lixenwraith/terminal/tui"
)
// Runner runs pipelines in lw's process while the screen stays. Its calls
// block: the screen makes Start and Stop one at a time on a goroutine of its
// own, and reads State from another.
type Runner interface {
// Start runs specs in place of what runs, as a reload does, and returns
// the tap of what runs then: on an error the earlier run's when the new
// pipelines did not build, nil when nothing runs
Start(specs []config.Spec, ask Ask) (*core.Tap, error)
Stop()
State() RunState
}
// Ask answers a password prompt a terminal would show; with generate, an
// empty answer draws a password, shown once
type Ask func(user string, generate bool) ([]byte, error)
// RunState is what a run reports beside its tap
type RunState struct {
Counts map[string]Counts // by pipeline
Ended bool // every pipeline's input ended
Log *core.Tap // lw's own log, nil while it does not reach the terminal
}
// Counts are a pipeline's entries: in from its sources, dropped by its flow,
// and dropped by sinks that could not take them
type Counts struct{ In, Filtered, SinksDropped uint64 }
func (s RunState) same(o RunState) bool {
return s.Ended == o.Ended && s.Log == o.Log && maps.Equal(s.Counts, o.Counts)
}
// run is the screen's side of a Runner. A worker makes the calls; what they
// bring back waits in later for a tick, which wake posts once until handled.
type run struct {
runner Runner
post func() // posts a tick to the screen
jobs chan func()
worker sync.Once
busy sync.WaitGroup
mu sync.Mutex
later []func(a *app)
posted atomic.Bool
gone chan struct{} // closed when the screen leaves: asks fail
ended sync.Once
fed atomic.Pointer[core.Tap] // the run's tap, for the watch
// the screen's goroutine only
op string // "starting" or "stopping" while a job runs
specs []config.Spec // what runs, nil while nothing does
tap *core.Tap
state RunState
leaving bool
}
func newRun(runner Runner, post func()) *run {
return &run{runner: runner, post: post, jobs: make(chan func(), 4), gone: make(chan struct{})}
}
// do runs job on the worker, after the jobs before it
func (r *run) do(job func()) {
r.worker.Do(func() {
go func() {
for job := range r.jobs {
job()
r.busy.Done()
}
}()
})
r.busy.Add(1)
r.jobs <- job
}
// send has f run on the screen's goroutine at the next tick
func (r *run) send(f func(a *app)) {
r.mu.Lock()
r.later = append(r.later, f)
r.mu.Unlock()
r.wake()
}
// wake posts a tick unless one is waiting
func (r *run) wake() {
if r.posted.CompareAndSwap(false, true) {
r.post()
}
}
// watch wakes the screen while a tap holds records, every drainEvery at
// most, and reads the run's state every so often, waking the screen only
// when it changed; until the screen leaves
func (r *run) watch(every time.Duration) {
pace, state := time.NewTicker(drainEvery), time.NewTicker(every)
defer pace.Stop()
defer state.Stop()
var last RunState
for {
select {
case <-r.gone:
return
case <-pace.C:
if holds(r.fed.Load()) || holds(last.Log) {
r.wake()
}
case <-state.C:
if st := r.runner.State(); !st.same(last) {
last = st
r.send(func(a *app) { a.live.state = st })
}
}
}
}
func holds(t *core.Tap) bool { return t != nil && len(t.Records()) > 0 }
// onSignals leaves on SIGTERM and SIGHUP (an ssh session's end), stopping
// what runs, so sinks flush and the terminal is restored. A closed stdout or
// stderr fails the write with EPIPE, which the run logs, instead of killing lw
// on screen; stop gives SIGPIPE back to the runtime.
func (r *run) onSignals() (stop func()) {
sigs, pipe := make(chan os.Signal, 1), make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGTERM, syscall.SIGHUP)
signal.Notify(pipe, syscall.SIGPIPE)
go func() {
select {
case <-sigs:
r.send(func(a *app) { a.leave(Result{}) })
case <-r.gone:
}
}()
return func() { signal.Stop(sigs); signal.Stop(pipe) }
}
func (r *run) endAsks() { r.ended.Do(func() { close(r.gone) }) }
// close fails the asks, waits for the worker and stops what still runs
func (r *run) close() {
r.endAsks()
r.busy.Wait()
if r.runner != nil && !r.leaving {
r.runner.Stop()
}
close(r.jobs)
}
// active reports a run, or one starting
func (r *run) active() bool { return r.specs != nil || r.op != "" }
// ask is the Ask Start gets: the worker waits while the screen asks in a
// masked form, until it is answered or the screen leaves
func (r *run) ask(user string, generate bool) ([]byte, error) {
reply := make(chan []byte, 1)
r.send(func(a *app) { a.askPassword(user, generate, reply) })
select {
case pw := <-reply:
if pw == nil {
return nil, errors.New("no password given")
}
return pw, nil
case <-r.gone:
return nil, errors.New("the screen closed")
}
}
// tick runs what the worker and the state watch brought, then reads the
// taps into the view; before any key handling, so it neither clears the
// status nor reaches a text field
func (a *app) tick() {
r := a.live
r.posted.Store(false)
r.mu.Lock()
later := r.later
r.later = nil
r.mu.Unlock()
for _, f := range later {
f(a)
}
a.drain(drainBytes, r.state.Log, r.tap)
}
// tap runs the pipelines here, the screen staying, and gives the view the
// keys: t. After an edit it runs them again in place of the earlier ones, as
// a reload does.
func (a *app) tap() {
r := a.live
switch {
case r.runner == nil:
a.note = "t runs the pipelines here only from lw --tui"
return
case a.problem != nil:
a.status = "not valid yet: ! shows the problem"
return
case r.op != "":
a.note = r.op + "…"
return
}
specs := config.PipelineSpecs(a.comp.Pipelines)
if r.specs != nil && slices.Equal(specs, r.specs) {
a.viewing = true
return
}
r.op = "starting"
r.do(func() {
tap, err := r.runner.Start(specs, r.ask)
r.send(func(a *app) { a.started(specs, tap, err) })
})
}
// started applies Start's outcome: what runs, and the view, or why the new
// pipelines do not, on the part that failed. An earlier run that stopped is
// read whole first, so a rule in the view marks where the next began.
func (a *app) started(specs []config.Spec, tap *core.Tap, err error) {
r := a.live
if r.leaving {
return
}
if old := r.tap; old != nil && old != tap {
a.drain(math.MaxInt, old)
mark := "rerun "
if tap == nil {
mark = "stopped "
}
a.view.mark(mark + time.Now().Format("15:04:05"))
}
r.op, r.tap = "", tap
r.fed.Store(tap)
switch {
case err == nil:
r.specs, a.viewing = specs, true
case tap != nil:
a.fault(err, "not rerun: %s; the earlier pipelines still run")
default:
r.specs = nil
a.fault(err, "%s")
}
}
// fault chooses the part a start error names, placed as a problem is, and
// shows the error in format
func (a *app) fault(err error, format string) {
text := err.Error()
if pi, n, key, why, ok := compose.Locate(err); ok && pi >= 0 && pi < len(a.comp.Pipelines) {
if pi != a.pi {
a.pi, a.row = pi, [3]int{}
}
a.bar, a.inspect = false, false
a.choose(n)
text = why
if where := a.where(pi, n, key); where != "" {
text = where + ": " + why
}
}
a.dialog = textNotice("Not started", fmt.Sprintf(format, text))
}
// leave ends the screen with res once nothing runs: q, r and o's choices
func (a *app) leave(res Result) {
r := a.live
a.result, a.dialog = res, nil
if !r.active() {
a.done = true
return
}
r.leaving, r.op, a.note = true, "stopping", "stopping the pipelines…"
r.endAsks()
r.do(func() {
r.runner.Stop()
r.send(func(a *app) { a.done = true })
})
}
// runMark says on the bar what runs: a call underway, or the counts of the
// pipeline shown
func (a *app) runMark() (string, ui.Style) {
r := a.live
switch {
case r.op != "":
return r.op + "…", a.th.Muted
case r.specs == nil:
return "", a.th.Muted
}
c := r.state.Counts[a.pipeline().Name]
mark, style := string(a.font.arrow)+" running", a.th.Accent
if r.state.Ended {
mark, style = "input ended", a.th.Muted
}
mark += fmt.Sprintf(" · %d in · %d filtered", c.In, c.Filtered)
if c.SinksDropped > 0 {
mark += fmt.Sprintf(" · %d sinks dropped", c.SinksDropped)
}
if r.tap != nil && r.tap.Dropped() > 0 {
mark += fmt.Sprintf(" · screen missed %d", r.tap.Dropped())
}
return mark, style
}
+363
View File
@@ -0,0 +1,363 @@
package tui
import (
"errors"
"fmt"
"os"
"os/exec"
"slices"
"strings"
"sync"
"syscall"
"testing"
"time"
"github.com/lixenwraith/logwisp/internal/compose"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/terminal"
)
// fakeRunner keeps the screen's calls; start, when set, answers Start
type fakeRunner struct {
mu sync.Mutex
specs [][]config.Spec
stops int
state RunState
start func(ask Ask) (*core.Tap, error)
}
func (f *fakeRunner) Start(specs []config.Spec, ask Ask) (*core.Tap, error) {
f.mu.Lock()
f.specs = append(f.specs, specs)
start := f.start
f.mu.Unlock()
if start != nil {
return start(ask)
}
return core.NewTap(1), nil
}
func (f *fakeRunner) Stop() {
f.mu.Lock()
defer f.mu.Unlock()
f.stops++
}
func (f *fakeRunner) State() RunState {
f.mu.Lock()
defer f.mu.Unlock()
return f.state
}
func (f *fakeRunner) calls() (starts, stops int) {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.specs), f.stops
}
// running is a screen on c whose runner is f, already running c, back on
// the diagram from the view t opened
func running(t *testing.T, c *compose.Composition, f *fakeRunner) *app {
t.Helper()
a := mono(c)
a.live = newRun(f, func() {})
press(a, "t")
await(t, a)
if a.live.specs == nil || !a.viewing {
t.Fatalf("t did not run, viewing %v: %T %s", a.viewing, a.dialog, a.status)
}
press(a, "t")
return a
}
// await ticks once the runner's side brought something back, as the woken
// screen would
func await(t *testing.T, a *app) {
t.Helper()
for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(time.Millisecond) {
a.live.mu.Lock()
n := len(a.live.later)
a.live.mu.Unlock()
if n > 0 {
break
}
if time.Now().After(deadline) {
t.Fatal("nothing came back from the runner")
}
}
press(a, terminal.KeyNone)
}
// The tick is handled before any key: it clears no status and types nothing
// into the field being edited
func TestATickKeepsTheStatusAndTheField(t *testing.T) {
a := mono(pipeline(t, []string{"file"}, []string{"null"}))
press(a, terminal.KeyEnter, terminal.KeyEnter, "ab")
a.status = "refused"
press(a, terminal.KeyNone)
if a.status != "refused" || a.insp.field == nil || a.insp.field.Value() != "ab" {
t.Fatalf("status %q, field %v", a.status, a.insp.field)
}
}
// Wakes coalesce: one tick waits at a time, and handling it allows the next
func TestWakesCoalesce(t *testing.T) {
posts := 0
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
a.live = newRun(nil, func() { posts++ })
a.live.wake()
a.live.wake()
if press(a, terminal.KeyNone); posts != 1 {
t.Fatalf("%d ticks posted for two wakes", posts)
}
if a.live.wake(); posts != 2 {
t.Fatal("a handled tick kept the next wake from posting")
}
}
// The state is read on, but the screen is woken only by a change
func TestTheStateWakesTheScreenOnlyWhenItChanges(t *testing.T) {
var posts sync.WaitGroup
f := &fakeRunner{state: RunState{Counts: map[string]Counts{"p": {In: 1}}}}
r := newRun(f, func() { posts.Done() })
posts.Add(1)
go r.watch(time.Millisecond)
defer r.endAsks()
posts.Wait()
time.Sleep(20 * time.Millisecond)
r.posted.Store(false)
time.Sleep(20 * time.Millisecond)
if r.posted.Load() {
t.Fatal("an unchanged state woke the screen")
}
posts.Add(1)
f.mu.Lock()
f.state = RunState{Counts: map[string]Counts{"p": {In: 2}}}
f.mu.Unlock()
posts.Wait()
}
// t runs only a valid composition
func TestTapRefusesAnInvalidComposition(t *testing.T) {
f := &fakeRunner{}
a := mono(pipeline(t, []string{"null"}, []string{"tcp"}))
a.live = newRun(f, func() {})
if press(a, "t"); a.status != "not valid yet: ! shows the problem" || a.live.op != "" {
t.Fatalf("status %q, op %q", a.status, a.live.op)
}
if starts, _ := f.calls(); starts != 0 {
t.Fatal("started an invalid composition")
}
}
// t runs the pipelines and marks the bar; unchanged, t only opens the view,
// and after an edit it runs the edited pipelines
func TestTapRerunsAfterAnEdit(t *testing.T) {
f := &fakeRunner{state: RunState{Counts: map[string]Counts{"p": {In: 7, Filtered: 2}}}}
a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f)
a.live.state = f.State()
first := config.PipelineSpecs(a.comp.Pipelines)
if top := text(render(a, 100, 24), 100)[0]; !strings.Contains(top, "running · 7 in · 2 filtered") || !slices.Equal(f.specs[0], first) {
t.Fatalf("%q, specs %v", top, f.specs)
}
if press(a, "t"); a.live.op != "" || !a.viewing {
t.Fatalf("t on what runs: op %q, viewing %v", a.live.op, a.viewing)
}
press(a, "t")
_, err := a.comp.Add(0, "sink", "console")
a.edit(err)
press(a, "t")
await(t, a)
if starts, _ := f.calls(); starts != 2 || slices.Equal(f.specs[1], first) || !slices.Equal(a.live.specs, f.specs[1]) {
t.Fatalf("rerun: %d starts, running %v", starts, a.live.specs)
}
}
// A rerun that does not build leaves the earlier pipelines running, and says
// so; one that fails once they stopped leaves nothing running
func TestAFailedRerunSaysWhatRuns(t *testing.T) {
f := &fakeRunner{}
a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f)
earlier, tap := a.live.specs, a.live.tap
notice := func() string { return strings.Join(text(render(a, 120, 30), 120), "\n") }
f.start = func(Ask) (*core.Tap, error) { return tap, errors.New("bad sink") }
a.edit(a.comp.Set(0, compose.Node{Role: "format"}, "type", "txt"))
press(a, "t")
await(t, a)
if !slices.Equal(a.live.specs, earlier) || a.live.tap != tap || !strings.Contains(notice(), "not rerun: bad sink; the earlier pipelines still run") {
t.Fatalf("running %v\n%s", a.live.specs, notice())
}
f.start = func(Ask) (*core.Tap, error) { return nil, errors.New("port taken") }
press(a, terminal.KeyEscape, "t")
await(t, a)
if a.live.specs != nil || a.live.tap != nil || strings.Contains(notice(), "still run") {
t.Fatalf("running %v\n%s", a.live.specs, notice())
}
}
// A start error chooses the part its path names, as a problem's does, and
// shows its text
func TestAStartErrorChoosesItsPart(t *testing.T) {
why := config.At(compose.Node{Role: "sink", ID: "console"}.Path(0), errors.New("failed to start sink console: write: broken pipe"))
f := &fakeRunner{start: func(Ask) (*core.Tap, error) { return nil, why }}
a := mono(pipeline(t, []string{"null"}, []string{"null", "console"}))
a.live = newRun(f, func() {})
press(a, "t")
await(t, a)
n, _ := a.node()
if screen := strings.Join(text(render(a, 120, 30), 120), " "); n != (compose.Node{Role: "sink", ID: "console"}) || !strings.Contains(screen, "sink console: write") {
t.Fatalf("on %+v\n%s", n, screen)
}
}
// q asks before it stops what runs, and quits once it stopped
func TestQuitAsksWhileRunning(t *testing.T) {
f := &fakeRunner{}
a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f)
press(a, "q")
if c, asks := a.dialog.(*confirm); !asks || !strings.Contains(c.question, "Stop the pipelines") {
t.Fatalf("q while running: %T", a.dialog)
}
press(a, "y")
await(t, a)
if _, stops := f.calls(); !a.done || stops != 1 {
t.Fatalf("done %v after %d stops", a.done, stops)
}
}
// r and o's choices stop the run before the screen leaves, and take no key
// meanwhile
func TestLeavingStopsTheRunFirst(t *testing.T) {
for _, keys := range []string{"r", "oc"} {
f := &fakeRunner{}
a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f)
if press(a, keys, "q"); a.done || a.dialog != nil {
t.Fatalf("%s left before stopping: %T", keys, a.dialog)
}
await(t, a)
if _, stops := f.calls(); !a.done || stops != 1 || a.result.Exit == Quit {
t.Fatalf("%s: done %v after %d stops, %+v", keys, a.done, stops, a.result)
}
}
}
// A password a terminal would be asked for is asked in a masked form; with
// generate, Enter alone draws one, shown once, and that is the answer
func TestPasswordsAreAskedMasked(t *testing.T) {
for _, generate := range []bool{false, true} {
var got []byte
f := &fakeRunner{start: func(ask Ask) (*core.Tap, error) {
pw, err := ask("alice", generate)
got = pw
return core.NewTap(1), err
}}
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
a.live = newRun(f, func() {})
press(a, "t")
await(t, a)
typed := "s3cret"
if generate {
typed = ""
}
press(a, typed)
if screen := strings.Join(text(render(a, 100, 30), 100), "\n"); !strings.Contains(screen, "Password for alice") ||
strings.Contains(screen, "s3cret") || !strings.Contains(screen, strings.Repeat("*", len(typed))) {
t.Fatalf("the form:\n%s", screen)
}
press(a, terminal.KeyEnter)
shown := strings.Join(text(render(a, 100, 30), 100), " ")
if generate {
press(a, terminal.KeyEnter) // read
}
await(t, a)
if a.live.specs == nil || len(got) == 0 || !generate && string(got) != typed || generate && !strings.Contains(shown, "shown this once: "+string(got)) {
t.Fatalf("generate %v: answered %q\n%s", generate, got, shown)
}
}
}
// A generated password stays on screen until a key closes its notice: the
// start goes on only then, so neither a start error nor the next ask hides it
func TestAGeneratedPasswordStaysUntilRead(t *testing.T) {
for name, then := range map[string]func(Ask) error{
"a start error": func(Ask) error { return errors.New("port taken") },
"the next ask": func(ask Ask) error { _, err := ask("bob", true); return err },
} {
answered := make(chan struct{})
f := &fakeRunner{start: func(ask Ask) (*core.Tap, error) {
if _, err := ask("alice", true); err != nil {
return nil, err
}
close(answered)
return core.NewTap(1), then(ask)
}}
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
a.live = newRun(f, func() {})
press(a, "t")
await(t, a)
press(a, terminal.KeyEnter)
select {
case <-answered:
t.Fatalf("%s: answered before the password was read", name)
case <-time.After(50 * time.Millisecond):
}
if press(a, terminal.KeyNone); !strings.Contains(strings.Join(text(render(a, 100, 30), 100), " "), "shown this once") {
t.Fatalf("%s: the password left the screen unread: %T", name, a.dialog)
}
press(a, "x")
<-answered
await(t, a)
if screen := strings.Join(text(render(a, 100, 30), 100), " "); strings.Contains(screen, "shown this once") {
t.Fatalf("%s: the notice outlived its key\n%s", name, screen)
}
a.live.close()
}
}
// On screen a closed stdout fails the write with EPIPE instead of killing lw
// with the terminal raw; leaving gives SIGPIPE back to the runtime
func TestAClosedStdoutFailsWritesOnScreen(t *testing.T) {
if os.Getenv("LW_TEST_CLOSED_STDOUT") != "" {
stop := newRun(nil, func() {}).onSignals()
_, err := os.Stdout.Write([]byte("x\n"))
fmt.Fprintln(os.Stderr, "on screen:", err)
stop()
os.Stdout.Write([]byte("x\n"))
fmt.Fprintln(os.Stderr, "lived on")
os.Exit(0)
}
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
r.Close()
defer w.Close()
var stderr strings.Builder
cmd := exec.Command(os.Args[0], "-test.run=^TestAClosedStdoutFailsWritesOnScreen$")
cmd.Env, cmd.Stdout, cmd.Stderr = append(os.Environ(), "LW_TEST_CLOSED_STDOUT=1"), w, &stderr
cmd.Run()
ws, _ := cmd.ProcessState.Sys().(syscall.WaitStatus)
if !strings.Contains(stderr.String(), "on screen: write /dev/stdout: broken pipe") || !ws.Signaled() || ws.Signal() != syscall.SIGPIPE {
t.Fatalf("%v\n%s", ws, stderr.String())
}
}
// SIGTERM and SIGHUP stop what runs and leave
func TestSignalsStopTheRunAndLeave(t *testing.T) {
for _, sig := range []syscall.Signal{syscall.SIGTERM, syscall.SIGHUP} {
f := &fakeRunner{}
a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f)
stop := a.live.onSignals()
if err := syscall.Kill(os.Getpid(), sig); err != nil {
t.Fatal(err)
}
await(t, a)
await(t, a)
stop()
if _, stops := f.calls(); !a.done || stops != 1 {
t.Fatalf("%v: done %v after %d stops", sig, a.done, stops)
}
}
}
+52 -23
View File
@@ -10,49 +10,76 @@ import (
ui "github.com/lixenwraith/terminal/tui"
)
// look is a color tier's styles: the widgets' theme and each column's tint
// look is a color tier's styles: the widgets' theme, each column's tint, and
// the live view's levels by name, any other drawn as text
type look struct {
ui.Theme
Source, Flow, Sink ui.Style
Levels map[string]ui.Style
}
// The site's dark tokens; true color and 256 colors paint its background
// The website's dark tokens; true color and 256 colors paint its background
var (
siteBg = color.RGB{R: 0x14, G: 0x17, B: 0x1d}
sitePanel = color.RGB{R: 0x1c, G: 0x20, B: 0x28}
siteFg = color.RGB{R: 0xe3, G: 0xe7, B: 0xef}
siteMuted = color.RGB{R: 0x9a, G: 0xa3, B: 0xb5}
siteAccent = color.RGB{R: 0x6e, G: 0xa0, B: 0xff}
siteBg = color.RGB{R: 0x11, G: 0x11, B: 0x1b} // --dark-bg
siteFg = color.RGB{R: 0xff, G: 0xff, B: 0xff} // --text-color
)
var siteLook = look{
Theme: ui.Theme{
Text: ui.Style{Fg: siteFg, Bg: siteBg},
Muted: ui.Style{Fg: siteMuted},
Accent: ui.Style{Fg: siteAccent, Attr: terminal.AttrBold},
Selected: ui.Style{Bg: color.RGB{R: 0x26, G: 0x2c, B: 0x38}},
Input: ui.Style{Bg: sitePanel},
Muted: ui.Style{Fg: color.RGB{R: 0x94, G: 0xa3, B: 0xb8}}, // --muted-color
Accent: ui.Style{Fg: color.RGB{R: 0x8e, G: 0xa2, B: 0xff}, Attr: terminal.AttrBold}, // --accent-color
Selected: ui.Style{Bg: color.RGB{R: 0x1d, G: 0x1c, B: 0x3e}}, // --card-background-color
Input: ui.Style{Bg: color.RGB{R: 0x1a, G: 0x1b, B: 0x26}}, // --dark-surface
Cursor: ui.Style{Fg: siteBg, Bg: siteFg},
Error: ui.Style{Fg: color.RGB{R: 0xff, G: 0x6b, B: 0x6b}},
Border: ui.Style{Fg: color.RGB{R: 0x5a, G: 0x63, B: 0x75}},
Error: ui.Style{Fg: color.RGB{R: 0xf8, G: 0x71, B: 0x71}}, // --danger-color
Border: ui.Style{Fg: color.RGB{R: 0x7c, G: 0x7c, B: 0x82}}, // the diagram's --wire
},
Source: ui.Style{Fg: color.RGB{R: 0x56, G: 0xc8, B: 0xd8}},
Flow: ui.Style{Fg: color.RGB{R: 0xb1, G: 0x8c, B: 0xff}},
Sink: ui.Style{Fg: color.RGB{R: 0x6f, G: 0xcf, B: 0x8f}},
Source: ui.Style{Fg: color.RGB{R: 0x22, G: 0xd3, B: 0xee}}, // --hue-cyan
Flow: ui.Style{Fg: color.RGB{R: 0xa7, G: 0x8b, B: 0xfa}}, // --hue-violet
Sink: ui.Style{Fg: color.RGB{R: 0x34, G: 0xd3, B: 0x99}}, // --hue-green
Levels: map[string]ui.Style{
"TRACE": {Fg: color.RGB{R: 0x94, G: 0xa3, B: 0xb8}}, // --hue-slate
"DEBUG": {Fg: color.RGB{R: 0x60, G: 0xa5, B: 0xfa}}, // --hue-blue
"INFO": {Fg: color.RGB{R: 0x34, G: 0xd3, B: 0x99}}, // --ok-color
"WARN": {Fg: color.RGB{R: 0xfb, G: 0xbf, B: 0x24}}, // --warn-color
"ERROR": {Fg: color.RGB{R: 0xf8, G: 0x71, B: 0x71}, Attr: terminal.AttrBold}, // --danger-color
},
}
// console16 is the 16-color look, a text console's, where bold is bright:
// text is palette white rather than bold, which On would spread to every role
// drawn on it; muted the console's own grey; the cursor row a blue bar under
// which every role stays readable
var console16 = ui.Theme{
Text: ui.Style{Fg: color.RGB{R: color.ANSIBrightWhite}, Attr: terminal.AttrFg256 | terminal.AttrBgDefault},
Muted: ui.Style{Attr: terminal.AttrFgDefault},
Accent: ansi(color.ANSIBrightYellow),
Selected: ui.Style{Bg: color.RGB{R: color.ANSIBlue}, Attr: terminal.AttrBg256},
Input: ui.Theme16.Input,
Cursor: ui.Theme16.Cursor,
Error: ui.Theme16.Error,
Border: ui.Style{Attr: terminal.AttrFgDefault},
}
// ansi is a palette index the terminal shows in its own shade
func ansi(i uint8) ui.Style { return ui.Style{Fg: color.RGB{R: i}, Attr: terminal.AttrFg256} }
// looks holds one look per color tier: 16 colors and none keep the
// terminal's background
// terminal's background. Levels in 16 colors are picked by hand, as the
// quantizer turns the site's pastels grey; without color, bold and dim only
// add to the level's name.
var looks = map[terminal.ColorMode]look{
terminal.ColorModeTrueColor: siteLook,
terminal.ColorMode256: siteLook,
terminal.ColorMode16: {Theme: ui.Theme16,
Source: ansi(color.ANSICyan), Flow: ansi(color.ANSIMagenta), Sink: ansi(color.ANSIGreen)},
terminal.ColorMode16: {Theme: console16,
Source: ansi(color.ANSICyan), Flow: ansi(color.ANSIBrightMagenta), Sink: ansi(color.ANSIGreen),
Levels: map[string]ui.Style{"TRACE": console16.Muted, "DEBUG": ansi(color.ANSICyan), "INFO": ansi(color.ANSIGreen),
"WARN": ansi(color.ANSIBrightYellow), "ERROR": ansi(color.ANSIBrightRed)}},
terminal.ColorModeNone: {Theme: ui.MonoTheme,
Source: ui.MonoTheme.Text, Flow: ui.MonoTheme.Text, Sink: ui.MonoTheme.Text},
Source: ui.MonoTheme.Text, Flow: ui.MonoTheme.Text, Sink: ui.MonoTheme.Text,
Levels: map[string]ui.Style{"TRACE": ui.MonoTheme.Muted, "DEBUG": ui.MonoTheme.Muted, "INFO": ui.MonoTheme.Text,
"WARN": ui.MonoTheme.Accent, "ERROR": ui.MonoTheme.Accent}},
}
// font is the marks one character set draws: the widgets' and the canvas's;
@@ -63,6 +90,8 @@ type font struct {
arrow, down rune // into the flow box and a sink; between stacked parts
bar, barOn rune // a node, and the chosen node
dot rune // before a column's title
newline rune // a line break in a row of the live view
odd rune // in the live view, a rune a cell cannot show
valid string
invalid string
enter string
@@ -86,12 +115,12 @@ func (f font) fold(r rune) rune {
// fonts: Unicode, a text console's CP437, and ASCII outside UTF-8
var (
unicodeFont = font{Glyphs: ui.GlyphsUnicode, box: ui.LineDashed,
arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '●', valid: "✓", invalid: "✗", enter: "⏎", space: "␣"}
arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '●', newline: '↵', odd: '\uFFFD', valid: "✓", invalid: "✗", enter: "⏎", space: "␣"}
cp437Font = font{Glyphs: ui.GlyphsCP437, box: ui.LineSingle,
arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '•', valid: "√", invalid: "x", enter: "enter", space: "space",
arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '•', newline: '¶', odd: '?', valid: "√", invalid: "x", enter: "enter", space: "space",
folds: map[rune]rune{'…': '.'}}
asciiFont = font{Glyphs: ui.GlyphsASCII, box: ui.LineASCII,
arrow: '>', down: 'v', bar: '|', barOn: '#', dot: '*', valid: "ok", invalid: "x", enter: "enter", space: "space",
arrow: '>', down: 'v', bar: '|', barOn: '#', dot: '*', newline: '|', odd: '?', valid: "ok", invalid: "x", enter: "enter", space: "space",
folds: map[rune]rune{'…': '.', '·': '-'}, ascii: true}
)
+107 -34
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"slices"
"strings"
"time"
"github.com/lixenwraith/logwisp/internal/compose"
"github.com/lixenwraith/logwisp/internal/config"
@@ -41,8 +42,9 @@ var modes = map[string][]terminal.ColorMode{
}
// Run composes c on the terminal until the user leaves; an empty c opens the
// preset menu. color is lw's color setting.
func Run(c *compose.Composition, color string) (Result, error) {
// preset menu. color is lw's color setting; runner runs the pipelines on t,
// and nothing runs once Run returns.
func Run(c *compose.Composition, color string, runner Runner) (Result, error) {
t := terminal.New(modes[color]...)
if err := t.Init(); err != nil {
return Result{}, err
@@ -52,14 +54,21 @@ func Run(c *compose.Composition, color string) (Result, error) {
return Result{}, err
}
a := newApp(c, looks[t.ColorMode()], fontFor(terminal.DetectColorMode()))
a.live = newRun(runner, func() { t.PostEvent(terminal.Event{Type: terminal.EventKey, Key: terminal.KeyNone}) })
defer a.live.close()
defer a.live.onSignals()()
if runner != nil {
go a.live.watch(time.Second)
}
w, h := t.Size()
cells := make([]terminal.Cell, w*h)
for !a.done {
cells := make([]terminal.Cell, w*h)
a.paint(cells, w, h)
t.Flush(cells, w, h)
switch ev := t.PollEvent(); ev.Type {
case terminal.EventResize:
w, h = ev.Width, ev.Height
cells = make([]terminal.Cell, w*h)
case terminal.EventClosed:
return Result{}, errors.New("the terminal closed")
default:
@@ -93,6 +102,9 @@ type app struct {
inspect bool // keys go to the inspector
insp inspector
dialog dialog // takes every key while open
live *run // what t runs
view view // what runs shows
viewing bool // keys go to the view
status string // the last edit's refusal
note string // what the last action did
problem error // the composition's problems, joined
@@ -102,7 +114,7 @@ type app struct {
}
func newApp(c *compose.Composition, l look, f font) *app {
a := &app{comp: c, look: l, th: l.Theme, font: f}
a := &app{comp: c, look: l, th: l.Theme, font: f, live: newRun(nil, func() {})}
a.th.Glyphs = f.Glyphs
a.insp.open = map[string]bool{}
if len(c.Pipelines) == 0 {
@@ -192,15 +204,27 @@ func (a *app) edit(err error) bool {
func (a *app) check() { a.problem = a.comp.Validate() }
// handle routes an event: an open dialog takes all, then the inspector, then
// the canvas
// handle routes an event: the tick first, which the input never sends (the
// terminal library swallows unknown keys), then an open dialog takes all,
// then the view, the inspector, the canvas; leaving, nothing more is taken
func (a *app) handle(ev terminal.Event) {
if ev.Type == terminal.EventKey || ev.Type == terminal.EventPaste {
switch {
case ev.Type == terminal.EventKey && ev.Key == terminal.KeyNone:
a.tick()
return
case a.live.leaving:
return
case ev.Type == terminal.EventKey || ev.Type == terminal.EventPaste:
a.status, a.note = "", ""
}
if a.dialog == nil && !a.viewing {
a.view.own = false // a key on the diagram gives the point back to its cursor
}
switch {
case a.dialog != nil:
a.dialog.handle(a, ev)
case a.viewing:
a.viewKey(ev)
case ev.Type == terminal.EventPaste && a.insp.field != nil:
a.insp.field.Paste(ev.Text)
case ev.Type == terminal.EventPaste:
@@ -263,13 +287,15 @@ func (a *app) globalKey(ev terminal.Event) {
case is(ev, terminal.KeyNone, "o"):
a.dialog = a.outputMenu()
case is(ev, terminal.KeyNone, "?"):
a.dialog = help(a.font)
a.dialog = help(a.font, a.viewing)
case is(ev, terminal.KeyNone, "!"):
a.showProblem()
case is(ev, terminal.KeyNone, "c"):
a.changeType()
case is(ev, terminal.KeyNone, "r"):
a.run()
case is(ev, terminal.KeyNone, "t"):
a.tap()
case is(ev, terminal.KeyNone, "]") && n > 0:
a.pi, a.row, a.inspect = (a.pi+1)%n, [3]int{}, false
case is(ev, terminal.KeyNone, "[") && n > 0:
@@ -346,7 +372,7 @@ func (a *app) run() {
a.status = "not valid yet: ! shows the problem"
return
}
a.result, a.done = Result{Exit: Start, Pipelines: a.comp.Pipelines}, true
a.leave(Result{Exit: Start, Pipelines: a.comp.Pipelines})
}
// switchStage turns a flow stage on with its defaults, or off; a stage with
@@ -398,11 +424,15 @@ func (a *app) paste(text string) {
}
func (a *app) quit() {
if !a.changed {
a.done = true
return
leave := func(a *app) { a.leave(Result{}) }
switch {
case a.live.active():
a.dialog = &confirm{question: "Stop the pipelines and quit?", yes: leave}
case a.changed:
a.dialog = &confirm{question: "Quit without running or printing the pipelines?", yes: leave}
default:
leave(a)
}
a.dialog = &confirm{question: "Quit without running or printing the pipelines?", yes: func(a *app) { a.done = true }}
}
// showProblem chooses the part the first problem names and shows them all
@@ -447,8 +477,10 @@ func (a *app) choose(n compose.Node) {
}
}
// draw draws the screen: a title row, the canvas with the inspector beside
// it from 110 columns or below it, a status row, and any dialog over them
// draw draws the screen: a title row, the diagram, a status row, and any
// dialog over them. Once something ran, the live view takes the rows under
// the diagram from 40 rows, unless the inspector below it is in use, and the
// whole screen under that while it takes the keys.
func (a *app) draw(r ui.Region) {
r.FillStyle(a.th.Text)
status, style := a.bottom(r.W)
@@ -456,38 +488,67 @@ func (a *app) draw(r ui.Region) {
a.pi = min(a.pi, len(a.comp.Pipelines)-1)
a.node() // a delete can leave the row past its column's end
a.drawBar(r.Sub(0, 0, r.W, 1))
body := r.Sub(0, 1, r.W, r.H-1-len(status))
if r.W >= 110 {
iw := min(60, r.W*2/5)
a.drawCanvas(body.Sub(0, 0, r.W-iw-1, body.H))
a.drawInspector(body.Sub(r.W-iw, 0, iw, body.H))
} else {
ch := min(a.layout(r.W).h, body.H-min(10, body.H/2))
a.drawCanvas(body.Sub(0, 0, r.W, ch))
a.drawInspector(body.Sub(0, ch+1, r.W, body.H-ch-1))
body, l := r.Sub(0, 1, r.W, r.H-1-len(status)), a.layout(r.W)
a.view.sync(a.point(), a.live.tap)
switch watched := a.viewing || a.live.tap != nil || len(a.view.rows) > 0; {
case watched && r.H >= splitMin && (!a.inspect || l.inspector > 0):
top := l.h
if a.inspect {
top = max(top, body.H/2)
}
top = min(top, body.H*2/3)
a.drawDiagram(body.Sub(0, 0, r.W, top), l, false)
a.drawView(body.Sub(0, top, r.W, body.H-top))
case a.viewing:
a.drawView(body)
default:
a.drawDiagram(body, l, true)
}
}
for i, l := range status {
r.TextStyled(1, r.H-len(status)+i, l, style)
for i, row := range status {
r.TextStyled(1, r.H-len(status)+i, row, style)
}
if a.status == "" && a.note == "" && a.problem == nil {
keys := a.hints(r.W)
r.TextStyled(r.W-ui.RuneLen(keys)-1, r.H-1, keys, a.th.Muted)
x := 4 + ui.RuneLen(status[0])
r.TextStyled(x, r.H-1, a.hints(r.W-x-1), a.th.Muted)
}
if p := a.view.prompt; a.viewing && p != nil {
row := r.Sub(0, r.H-1, r.W, 1)
row.FillStyle(a.th.Text)
row.TextStyled(1, 0, "/", a.th.Accent)
row.Sub(3, 0, r.W-4, 1).TextInput(p, "a pattern: lower case ignores case; empty shows all", true, a.th)
}
if a.dialog != nil {
a.dialog.draw(a, r)
}
}
// drawDiagram draws the canvas with the inspector beside it from 110
// columns, else, with below, under it
func (a *app) drawDiagram(r ui.Region, l layout, below bool) {
switch {
case l.inspector > 0:
a.drawCanvas(r.Sub(0, 0, l.w, r.H), l)
a.drawInspector(r.Sub(r.W-l.inspector, 0, l.inspector, r.H))
case !below:
a.drawCanvas(r, l)
default:
ch := min(l.h, r.H-min(10, r.H/2))
a.drawCanvas(r.Sub(0, 0, r.W, ch), l)
a.drawInspector(r.Sub(0, ch+1, r.W, r.H-ch-1))
}
}
// drawBar draws the title row: the pipelines by name, the one shown marked,
// or it and its place when they do not fit; with the keys, the bar's verbs
// after them
func (a *app) drawBar(r ui.Region) {
th, f := a.th, a.font
full, hint := r, ""
if a.bar {
r.TextStyled(0, 0, string(f.Focus), th.Accent)
hint := "a add " + f.enter + " rename d delete"
if r.W >= 60 {
r.TextStyled(r.W-ui.RuneLen(hint)-1, 0, hint, th.Muted)
hint = "a add " + f.enter + " rename d delete"
r = r.Sub(0, 0, r.W-ui.RuneLen(hint)-2, 1)
}
}
@@ -513,8 +574,16 @@ func (a *app) drawBar(r ui.Region) {
x += ui.RuneLen(label) + 2
}
if len(names) < len(a.comp.Pipelines) {
r.TextStyled(x, 0, fmt.Sprintf("%d/%d", a.pi+1, len(a.comp.Pipelines)), th.Muted)
place := fmt.Sprintf("%d/%d", a.pi+1, len(a.comp.Pipelines))
r.TextStyled(x, 0, place, th.Muted)
x += ui.RuneLen(place) + 2
}
if mark, style := a.runMark(); mark != "" {
mark = ui.Truncate(mark, max(0, r.W-x))
r.TextStyled(x, 0, mark, style)
x += ui.RuneLen(mark) + 2
}
full.TextStyled(min(x+1, full.W-ui.RuneLen(hint)-1), 0, hint, th.Muted)
}
// paint draws the screen into cells, each rune one the font's terminal shows
@@ -552,10 +621,14 @@ func (a *app) bottom(w int) ([]string, ui.Style) {
return clip(ui.WrapText(text, w-2), 3), style
}
// hints are the status row's keys, shown while nothing else needs the row
// hints are the status row's keys in w columns, the view's while it takes
// them, shown after the status while nothing else needs the row
func (a *app) hints(w int) string {
keys := "a add d delete " + a.font.space + " on/off J/K move p preset o output ? help"
if ui.RuneLen(keys)+12 > w {
keys := "a add d delete " + a.font.space + " on/off t tap p preset o output ? help"
if a.viewing {
keys = "/ filter 0-5 level v mode s point " + a.font.enter + " detail t back ? help"
}
if ui.RuneLen(keys) > w {
keys = "? help"
}
return keys
+186 -15
View File
@@ -8,6 +8,7 @@ import (
"github.com/lixenwraith/logwisp/internal/compose"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/color"
"github.com/lixenwraith/terminal"
ui "github.com/lixenwraith/terminal/tui"
)
@@ -80,19 +81,20 @@ func focusKey(t *testing.T, a *app, name string) line {
}
// Each wire glyph's arms meet the cells beside it, whatever the count of
// sources and sinks and the width, and every source and sink is wired to
// the flow box's arrow
// sources and sinks and the width, and every source is wired from past its
// text to the flow box's arrow, and from the box to an arrow into each sink
func TestWiresJoinWhereTheyMeet(t *testing.T) {
arms := map[rune]uint8{'│': 5, '─': 10, '└': 3, '┌': 6, '├': 7, '┘': 9, '┴': 11, '┐': 12, '┤': 13, '┬': 14, '┼': 15, '►': 10}
steps := map[uint8][2]int{1: {0, -1}, 2: {1, 0}, 4: {0, 1}, 8: {-1, 0}}
opposite := map[uint8]uint8{1: 4, 2: 8, 4: 1, 8: 2}
for n := range 16 {
sources, sinks := slices.Repeat([]string{"null"}, n%4), slices.Repeat([]string{"null"}, n/4)
for _, w := range []int{64, 80, 109, 130} {
for _, screen := range []int{64, 80, 109, 130, 160, 240} {
a := mono(pipeline(t, sources, sinks))
l := a.layout(w)
l := a.layout(screen)
w := l.w
cells := make([]terminal.Cell, w*l.h)
a.drawCanvas(ui.NewRegion(cells, w, 0, 0, w, l.h))
a.drawCanvas(ui.NewRegion(cells, w, 0, 0, w, l.h), l)
glyph := func(x, y int) rune {
if x < 0 || x >= w || y < 0 || y >= l.h {
return ' '
@@ -124,13 +126,13 @@ func TestWiresJoinWhereTheyMeet(t *testing.T) {
walk(l.box.x-1, l.entry)
walk(l.box.x+l.box.w+1, l.exit)
for i := range sources {
if s := l.nodes[sourcesCol][i]; !reached[[2]int{s.x + s.w, s.y}] {
t.Errorf("%d sources, %d sinks, %d columns: source %d not wired", len(sources), len(sinks), w, i)
if s := l.nodes[sourcesCol][i]; !reached[[2]int{s.x + s.end + 1, s.y}] {
t.Errorf("%d sources, %d sinks, %d columns: source %d not wired from its text", len(sources), len(sinks), w, i)
}
}
for i := range sinks {
if s := l.nodes[sinksCol][i]; !reached[[2]int{s.x - 1, s.y}] {
t.Errorf("%d sources, %d sinks, %d columns: sink %d not wired", len(sources), len(sinks), w, i)
if s := l.nodes[sinksCol][i]; !reached[[2]int{s.x - 1, s.y}] || glyph(s.x-1, s.y) != '►' {
t.Errorf("%d sources, %d sinks, %d columns: sink %d not wired by an arrow", len(sources), len(sinks), w, i)
}
}
}
@@ -138,10 +140,17 @@ func TestWiresJoinWhereTheyMeet(t *testing.T) {
}
// The parts stack under 64 columns; from 64 they sit side by side with the
// inspector below, and from 110 the inspector sits beside them
// inspector below, split as ever, and from 110 the inspector sits beside
// them, taking what they leave up to 100 columns
func TestLayoutFollowsTheWidth(t *testing.T) {
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
for w, want := range map[int]string{63: "stacked", 64: "below", 109: "below", 110: "beside"} {
for w, box := range map[int]int{64: 18, 80: 24, 109: 30} {
if l := a.layout(w); l.box.w != box || l.nodes[sourcesCol][0].x != 0 || l.wire != gutter {
t.Errorf("%d columns: the box %d wide, the sources from %d, wires %d", w, l.box.w, l.nodes[sourcesCol][0].x, l.wire)
}
}
beside := map[int]int{110: 44, 240: 100} // the inspector's width
for w, want := range map[int]string{63: "stacked", 64: "below", 109: "below", 110: "beside", 240: "beside"} {
rows := text(render(a, w, 40), w)
at := func(s string) (int, int) {
for y, r := range rows {
@@ -163,12 +172,94 @@ func TestLayoutFollowsTheWidth(t *testing.T) {
case ruleX != 0 || ruleY <= flowY:
got = "lost"
}
if got != want {
t.Errorf("%d columns: %s, want %s\n%s", w, got, want, strings.Join(rows, "\n"))
if got != want || got == "beside" && w-ruleX != beside[w] {
t.Errorf("%d columns: %s, want %s, inspector %d wide\n%s", w, got, want, w-ruleX, strings.Join(rows, "\n"))
}
}
}
// From 110 columns the parts take the widths their content asks, nothing cut
// short and a source's wire after its last word, with at most 16 columns of
// wire a side, and sit centred
func TestWideCanvasSizesToItsContent(t *testing.T) {
c := pipeline(t, []string{"file", "http_chain"}, []string{"null"})
if err := c.Set(0, compose.Node{Role: "source", ID: "file"}, "directory", "/var/log/app/nginx"); err != nil {
t.Fatal(err)
}
for _, w := range []int{160, 240, 320} {
a := mono(c)
l := a.layout(w)
src, sink := l.nodes[sourcesCol][0], l.nodes[sinksCol][0]
wire, left, right := l.box.x-src.x-src.w, src.x, l.w-sink.x-sink.w
var canvas []string
for _, row := range text(render(a, w, 40), w)[1 : 1+l.h] {
canvas = append(canvas, string([]rune(row)[:l.w]))
}
if screen := strings.Join(canvas, "\n"); strings.Contains(screen, "…") || !strings.Contains(screen, "http_chain listens ─") || wire > 16 || left-right > 1 || right-left > 1 {
t.Errorf("%d columns: wire %d, margins %d and %d\n%s", w, wire, left, right, screen)
}
}
}
// Beside the inspector a source or sink lists its options set, a row each and
// four at most, or "defaults"; below it, their values in a line
func TestNodesListTheirOptionsBesideTheInspector(t *testing.T) {
c := pipeline(t, []string{"file"}, []string{"null"})
for k, v := range map[string]string{"directory": "/srv", "pattern": "*.log", "check_interval_ms": "50", "from": "end", "raw": "true"} {
if err := c.Set(0, compose.Node{Role: "source", ID: "file"}, k, v); err != nil {
t.Fatal(err)
}
}
a := mono(c)
for _, tc := range []struct {
w, rows int
show []string
}{
{240, 5, []string{"█ directory ", "▌ defaults"}},
{100, 2, []string{"/srv *.log"}},
} {
rows := text(render(a, tc.w, 30), tc.w)
x, blocks := a.layout(tc.w).nodes[sourcesCol][0].x, 0
for _, row := range rows {
if []rune(row)[x] == a.font.barOn {
blocks++
}
}
screen := strings.Join(rows, "\n")
for _, want := range tc.show {
if blocks != tc.rows || !strings.Contains(screen, want) {
t.Errorf("%d columns: %d rows, want %d and %q\n%s", tc.w, blocks, tc.rows, want, screen)
}
}
}
}
// The inspector's help sits two rows under its last key, not at the foot
func TestInspectorHelpSitsUnderItsKeys(t *testing.T) {
a, w := mono(pipeline(t, []string{"file"}, []string{"null"})), 240
press(a, terminal.KeyEnter)
var rows []string
for _, row := range text(render(a, w, 67), w) {
rows = append(rows, string([]rune(row)[a.layout(w).w+1:]))
}
help, _ := a.help(a.lines()[a.insp.cursor])
y := slices.IndexFunc(rows, func(r string) bool { return strings.Contains(r, help) })
if y < 3 || strings.TrimSpace(rows[y-1]) != "" || strings.TrimSpace(rows[y-2]) == "" {
t.Errorf("help on row %d\n%s", y, strings.Join(rows[:20], "\n"))
}
}
// Hints follow their text, three columns on, not the screen's far edge: the
// keys after the status, the bar's verbs after the pipelines
func TestHintsFollowTheirText(t *testing.T) {
a, w := mono(pipeline(t, []string{"null"}, []string{"null"})), 320
foot := text(render(a, w, 24), w)[23]
press(a, terminal.KeyUp)
if top := text(render(a, w, 24), w)[0]; !strings.HasPrefix(foot, " "+a.font.valid+" valid a add") || !strings.Contains(top, "● p a add") {
t.Errorf("%q\n%q", top, foot)
}
}
// Without color, the chosen part still changes a glyph: a source's or a
// sink's bar, a stage's on or off mark, an empty column's slot
func TestSelectionChangesAGlyph(t *testing.T) {
@@ -181,7 +272,7 @@ func TestSelectionChangesAGlyph(t *testing.T) {
l := a.layout(80)
at := func(col, row int) rune {
cells := make([]terminal.Cell, 80*l.h)
a.drawCanvas(ui.NewRegion(cells, 80, 0, 0, 80, l.h))
a.drawCanvas(ui.NewRegion(cells, 80, 0, 0, 80, l.h), l)
s := l.nodes[col][row]
return cells[s.y*80+s.x].Rune
}
@@ -448,6 +539,72 @@ func TestOnlyFullColorPaintsTheBackground(t *testing.T) {
}
}
// The cursor row is one background under every role, or one reverse bar
// without color: each cell of the chosen part's rows, but its tint block,
// takes the Selected style, and a source's bar ends where its leader starts
func TestTheCursorRowIsOneBackground(t *testing.T) {
c := pipeline(t, []string{"file"}, []string{"null"})
if err := c.Set(0, compose.Node{Role: "source", ID: "file"}, "directory", "/srv"); err != nil {
t.Fatal(err)
}
for _, mode := range []terminal.ColorMode{terminal.ColorModeNone, terminal.ColorMode16, terminal.ColorModeTrueColor} {
a := newApp(c, looks[mode], unicodeFont)
bar := func(cell terminal.Cell) bool {
if mode == terminal.ColorModeNone {
return cell.Attrs&terminal.AttrReverse != 0
}
return cell.Bg == a.th.Selected.Bg && cell.Attrs&(terminal.AttrBgColor|terminal.AttrReverse) == a.th.Selected.Attr&terminal.AttrBgColor
}
for _, w := range []int{80, 240} {
for _, at := range [][2]int{{sourcesCol, 0}, {flowCol, 1}} {
a.col, a.row[at[0]] = at[0], at[1]
cells, s := render(a, w, 30), a.layout(w).nodes[at[0]][at[1]]
for y := 1 + s.y; y < 1+s.y+s.h; y++ {
end := s.x + s.w
if at[0] == sourcesCol && y == 1+s.y {
end = s.x + s.end + 1
}
for x := s.x + 1; x < s.x+s.w; x++ {
if cell := cells[y*w+x]; bar(cell) != (x < end) {
t.Fatalf("mode %v, %d columns: %q at %d,%d is %+v", mode, w, cell.Rune, x, y, cell)
}
}
}
}
}
}
}
// The chosen part's tint block keeps its color on the cursor bar in every
// tier: drawn in its tint, never reversed
func TestTheChosenBlockKeepsItsTint(t *testing.T) {
c := pipeline(t, []string{"null"}, []string{"null"})
if err := c.Set(0, compose.Node{Role: "format"}, "type", "txt"); err != nil {
t.Fatal(err)
}
for mode, l := range looks {
a := newApp(c, l, unicodeFont)
for col, tint := range []ui.Style{l.Source, l.Flow, l.Sink} {
a.col, a.row[flowCol] = col, 2
cells, s := render(a, 80, 24), a.layout(80).nodes[col][a.row[col]]
if cell := cells[(1+s.y)*80+s.x]; cell.Rune != a.font.barOn || cell.Fg != tint.Fg || cell.Attrs&(terminal.AttrFgColor|terminal.AttrReverse) != tint.Attr&terminal.AttrFgColor {
t.Errorf("mode %v, column %d: %+v", mode, col, cell)
}
}
}
}
// 256 colors keep the site look's panel, edited field and cursor bar apart
func TestSiteShadesStayApartIn256Colors(t *testing.T) {
l, shades := looks[terminal.ColorMode256], map[uint8]bool{}
for _, c := range []color.RGB{l.Text.Bg, l.Input.Bg, l.Selected.Bg} {
shades[terminal.RGBTo256(c)] = true
}
if len(shades) != 3 {
t.Errorf("%v", shades)
}
}
// ASCII outside a UTF-8 locale, CP437 on a text console, else Unicode
func TestFontFollowsTheLocale(t *testing.T) {
for _, c := range []struct {
@@ -596,13 +753,27 @@ func TestConfirmAnswersOnEnter(t *testing.T) {
}
}
// The canvas scrolls to keep the chosen part in view, its bar solid
// The canvas scrolls to keep the chosen part in view, its bar solid on every
// row it takes
func TestCanvasKeepsTheChosenInView(t *testing.T) {
a := mono(pipeline(t, []string{"null"}, slices.Repeat([]string{"null"}, 8)))
a.col, a.row[sinksCol] = sinksCol, 7
if screen := strings.Join(text(render(a, 80, 24), 80), "\n"); !strings.ContainsRune(screen, a.font.barOn) {
t.Fatalf("the eighth sink is out of view:\n%s", screen)
}
c := pipeline(t, slices.Repeat([]string{"file"}, 4), []string{"null"})
a = mono(c)
for _, n := range a.nodes(sourcesCol) {
for k, v := range map[string]string{"directory": "/srv", "pattern": "*.log", "check_interval_ms": "50"} {
if err := c.Set(0, n, k, v); err != nil {
t.Fatal(err)
}
}
}
a.row[sourcesCol] = 3
if screen := strings.Join(text(render(a, 240, 14), 240), "\n"); strings.Count(screen, string(a.font.barOn)) != 4 {
t.Fatalf("the fourth source is not whole in view:\n%s", screen)
}
}
// A notice taller than the screen scrolls with j and k and says where it is
+859
View File
@@ -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},
},
}
+569
View File
@@ -0,0 +1,569 @@
package tui
import (
"encoding/json"
"fmt"
"math"
"slices"
"strings"
"testing"
"time"
"unsafe"
"github.com/lixenwraith/logwisp/internal/compose"
"github.com/lixenwraith/logwisp/internal/core"
"github.com/lixenwraith/terminal"
ui "github.com/lixenwraith/terminal/tui"
)
// tapped is an entry from a pipeline's source as its flow left it: dropped at
// stage and index, or passed with its payload when stage is ""
func tapped(pipe, source, stage string, index int, msg string) core.Tapped {
r := core.Tapped{Pipeline: pipe, Source: source, Stage: stage, Index: index,
Event: core.TransportEvent{Entry: core.LogEntry{Time: time.Unix(1, 0), Source: source, Message: msg}}}
if stage == "" {
r.Event.Payload = []byte(msg + "\n")
}
return r
}
// watching is a screen on c in look whose view takes the keys, drawn once so
// its rows are built for where the canvas cursor is
func watching(c *compose.Composition, l look) *app {
a := newApp(c, l, unicodeFont)
a.viewing = true
render(a, 80, 24)
return a
}
// shown is the messages of the rows the view shows
func shown(a *app) []string {
var out []string
for _, seq := range a.view.shown {
out = append(out, a.view.rows[seq-a.view.rows[0].seq].Message)
}
return out
}
// cursorRow is the screen row the view's pointer is on
func cursorRow(rows []string) (int, string) {
for y, r := range rows {
if strings.HasPrefix(r, string(unicodeFont.Glyphs.Pointer)) {
return y, r
}
}
return -1, ""
}
// The view follows the newest row; a move up pauses it on its row while rows
// arrive, and G, or moving down onto the newest, follows again
func TestTheViewFollowsUntilMovedUp(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
add := func(from, to int) {
for i := from; i < to; i++ {
a.view.take(tapped("p", "null", "", 0, fmt.Sprintf("row %03d", i)))
}
}
at := func() string {
_, r := cursorRow(text(render(a, 80, 24), 80))
return r
}
if add(0, 50); !strings.Contains(at(), "row 049") {
t.Fatalf("following: %q", at())
}
press(a, "k")
if add(50, 60); !strings.Contains(at(), "row 048") || !strings.Contains(text(render(a, 80, 24), 80)[1], "paused · 11 newer") {
t.Fatalf("paused: %q\n%s", at(), text(render(a, 80, 24), 80)[1])
}
if press(a, "G"); !strings.Contains(at(), "row 059") {
t.Fatalf("G: %q", at())
}
press(a, "k", "j")
if add(60, 61); !strings.Contains(at(), "row 060") {
t.Fatalf("down onto the newest: %q", at())
}
}
// The rows hold no more than ringBytes, the oldest going first; a paused
// cursor keeps its row, or takes the oldest kept when its own went
func TestTheRowsKeepWithinTheirBytes(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
big := strings.Repeat("x", 1<<20)
for i := range 20 {
a.view.take(tapped("p", "null", "", 0, fmt.Sprintf("%02d %s", i, big)))
}
render(a, 80, 24)
press(a, "k", "k")
focused := a.view.focused().Message[:2]
for i := 20; i < 25; i++ {
a.view.take(tapped("p", "null", "", 0, fmt.Sprintf("%02d %s", i, big)))
}
if got := a.view.focused().Message[:2]; a.view.bytes > ringBytes || got != focused {
t.Fatalf("%d bytes held; cursor on %s, was on %s", a.view.bytes, got, focused)
}
for i := 25; i < 55; i++ {
a.view.take(tapped("p", "null", "", 0, fmt.Sprintf("%02d %s", i, big)))
}
if got, oldest := a.view.focused().Message[:2], a.view.rows[0].Message[:2]; a.view.bytes > ringBytes || got != oldest || oldest == "00" {
t.Fatalf("%d bytes held; cursor on %s, the oldest kept %s", a.view.bytes, got, oldest)
}
}
// A pattern shows only the rows matching it; setting or clearing it keeps
// the focused row on its screen row, the rows around it back on clearing,
// whether the view was paused or following
func TestAFilterChangeKeepsTheFocusedRowInPlace(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
for i := range 300 {
msg := fmt.Sprintf("row %03d", i)
if i%3 == 2 {
msg += " needle"
}
a.view.take(tapped("p", "null", "", 0, msg))
}
screen := func() []string { return text(render(a, 80, 24), 80) }
press(a, "g")
screen()
press(a, strings.Repeat("j", 150))
screen()
press(a, strings.Repeat("k", 10))
y, r := cursorRow(screen())
if !strings.Contains(r, "row 140 needle") {
t.Fatalf("on %q", r)
}
press(a, "/", "needle", terminal.KeyEnter)
rows := screen()
if y2, r2 := cursorRow(rows); y2 != y || r2 != r || strings.Contains(strings.Join(rows, "\n"), "row 139") {
t.Fatalf("with the pattern, on row %d %q, was %d\n%s", y2, r2, y, strings.Join(rows, "\n"))
}
press(a, "/", terminal.KeyCtrlU, terminal.KeyEnter)
rows = screen()
if y2, r2 := cursorRow(rows); y2 != y || r2 != r || !strings.Contains(rows[y2-1], "row 139") || !strings.Contains(rows[y2+1], "row 141") {
t.Fatalf("cleared, on row %d %q, was %d\n%s", y2, r2, y, strings.Join(rows, "\n"))
}
following := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
for i := range 40 {
msg := fmt.Sprintf("row %03d", i)
if i == 10 {
msg += " needle"
}
following.view.take(tapped("p", "null", "", 0, msg))
}
press(following, "/", "needle", terminal.KeyEnter)
render(following, 80, 24)
press(following, "/", terminal.KeyCtrlU, terminal.KeyEnter)
if render(following, 80, 24); following.view.focused().Message != "row 010 needle" || !following.view.paused {
t.Fatalf("cleared while following, on %q", following.view.focused().Message)
}
}
// A row keeps copies of its text, none of the entry's, and its size counts
// every string it keeps, so the ring's bound holds for any line
func TestARowCountsAllItHolds(t *testing.T) {
long := strings.Repeat("y", 4096)
rec := tapped("p", "null", "", 0, `{"msg":"`+long+`","sub":"`+long+`","k":"`+long+`","t":"2026-10-10T12:00:00Z"}`)
rec.Event.Entry.Level = long
var v view
size := v.take(rec)
r := v.rows[0]
held := rowBytes + len(r.Stamp) + len(r.Level) + len(r.Tag) + len(r.Message) + len(r.Layout) + len(r.fields) + len(r.payload)
strs := []string{r.Stamp, r.Level, r.Tag, r.Message, r.Layout}
for _, f := range r.Fields {
held += int(unsafe.Sizeof(f)) + len(f.Key) + len(f.Value)
strs = append(strs, f.Key, f.Value)
}
if size < held || v.bytes != size {
t.Fatalf("size %d, ring %d, for %d held", size, v.bytes, held)
}
for _, s := range strs {
for _, from := range []string{rec.Event.Entry.Message, rec.Event.Entry.Level} {
if p, start := uintptr(unsafe.Pointer(unsafe.StringData(s))), uintptr(unsafe.Pointer(unsafe.StringData(from))); s != "" && p >= start && p < start+uintptr(len(from)) {
t.Fatalf("%.20q is the entry's", s)
}
}
}
}
// The canvas cursor picks the point and v the verdicts at it: a source's
// entries, what reaches a stage or a filter, passes it or it drops, the
// heartbeats, what the sinks got and what the flow dropped on the way; lw's
// own log shows from info up at every point, and whole at its own
func TestThePointAndModePickTheRows(t *testing.T) {
c := pipeline(t, []string{"null", "null"}, []string{"null"})
for _, typ := range []string{"include", "exclude"} {
if _, err := c.Add(0, "filters", typ); err != nil {
t.Fatal(err)
}
}
if err := c.AddPipeline("q"); err != nil {
t.Fatal(err)
}
s1, s2 := c.Pipelines[0].PluginSources[0].ID, c.Pipelines[0].PluginSources[1].ID
a := watching(c, looks[terminal.ColorModeNone])
lw := func(level, msg string) core.Tapped {
r := tapped("", "lw", "", 0, msg)
r.Event.Entry.Level = level
return r
}
for _, r := range []core.Tapped{
tapped("p", s1, "", 0, "a"), tapped("p", s2, "", 0, "b"), tapped("p", s1, "rate_limit", 0, "c"),
tapped("p", s1, "filters", 0, "d"), tapped("p", s2, "filters", 1, "e"), tapped("p", s1, "format", 0, "f"),
tapped("p", "", "", 0, "g"), tapped("q", s1, "", 0, "h"), lw("INFO", "i"), lw("DEBUG", "j"),
} {
a.view.take(r)
}
for _, c := range []struct {
pi int
at compose.Node
mode int
want string
own bool
}{
{0, compose.Node{Role: "sink", ID: "null"}, modeAll, "abgi", false},
{0, compose.Node{Role: "sink", ID: "null"}, modePassed, "abgi", false},
{0, compose.Node{Role: "sink", ID: "null"}, modeDropped, "cdefi", false},
{0, compose.Node{Role: "source", ID: s1}, modeAll, "acdfi", false},
{0, compose.Node{Role: "source", ID: s1}, modePassed, "ai", false},
{0, compose.Node{Role: "source", ID: s1}, modeDropped, "cdfi", false},
{0, compose.Node{Role: "rate_limit"}, modeAll, "abcdefi", false},
{0, compose.Node{Role: "rate_limit"}, modePassed, "abdefi", false},
{0, compose.Node{Role: "rate_limit"}, modeDropped, "ci", false},
{0, compose.Node{Role: "filters"}, modeAll, "abdefi", false},
{0, compose.Node{Role: "filters"}, modePassed, "abfi", false},
{0, compose.Node{Role: "filters"}, modeDropped, "dei", false},
{0, compose.Node{Role: "filters", Index: 1}, modeAll, "abefi", false},
{0, compose.Node{Role: "filters", Index: 1}, modeDropped, "ei", false},
{0, compose.Node{Role: "format"}, modeDropped, "fi", false},
{0, compose.Node{Role: "heartbeat"}, modeAll, "gi", false},
{0, compose.Node{Role: "heartbeat"}, modeDropped, "i", false},
{1, compose.Node{}, modeAll, "hi", false},
{0, compose.Node{Role: "filters"}, modeDropped, "ij", true},
} {
a.pi, a.inspect, a.view.mode, a.col, a.view.own = c.pi, false, c.mode, sinksCol, c.own
a.choose(c.at)
if c.at.Role == "filters" && c.at.Index > 0 {
a.inspectAt(func(l line) bool { return l.node == c.at })
}
render(a, 80, 24)
if got := strings.Join(shown(a), ""); got != c.want {
t.Errorf("at %+v in pipeline %d, %s: %q, want %q", c.at, c.pi, verdicts[c.mode], got, c.want)
}
}
}
// The tap takes the entries the flow dropped only while the view's point and
// mode show some
func TestTheTapTakesDropsOnlyWhileShown(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
a.live.tap = core.NewTap(1)
for _, c := range []struct {
at compose.Node
mode int
all bool
}{
{compose.Node{Role: "sink", ID: "null"}, modeAll, false},
{compose.Node{Role: "sink", ID: "null"}, modeDropped, true},
{compose.Node{Role: "source", ID: "null"}, modePassed, false},
{compose.Node{Role: "source", ID: "null"}, modeAll, true},
{compose.Node{Role: "filters"}, modePassed, true},
} {
a.view.mode = c.mode
a.choose(c.at)
if render(a, 80, 24); a.live.tap.All.Load() != c.all {
t.Errorf("at %+v, %s: the tap takes drops %v", c.at, verdicts[c.mode], !c.all)
}
}
}
// A digit is a level floor: rows under it hide, and those without a level;
// 0 shows them all
func TestTheLevelFloorHidesRowsUnderIt(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
for _, l := range []string{"DEBUG", "INFO", "WARN", "ERROR", ""} {
r := tapped("p", "null", "", 0, "at "+l)
r.Event.Entry.Level = l
a.view.take(r)
}
for _, c := range []struct{ key, want string }{{"4", "at WARN|at ERROR"}, {"2", "at DEBUG|at INFO|at WARN|at ERROR"}, {"0", "at DEBUG|at INFO|at WARN|at ERROR|at "}} {
press(a, c.key)
if render(a, 80, 24); strings.Join(shown(a), "|") != c.want {
t.Errorf("%s: %q", c.key, shown(a))
}
}
}
// A pattern in lower case ignores case, one with an upper-case letter keeps
// it, in substrings and regular expressions alike
func TestPatternsAreSmartCase(t *testing.T) {
for _, c := range []struct {
pattern, text string
match bool
}{
{"disk", "DISK full", true}, {"Disk", "disk full", false}, {"Disk", "Disk full", true},
{"d.sk", "DISK full", true}, {"D.sk", "disk full", false}, {"é", "CAFÉ", true}, {"x", "DISK", false},
} {
if p, err := newPattern(c.pattern); err != nil || p.match(c.text) != c.match {
t.Errorf("%q in %q: %v, %v", c.pattern, c.text, !c.match, err)
}
}
if _, err := newPattern("("); err == nil {
t.Error("( compiled")
}
}
// From 40 rows the view sits under the diagram, whichever takes the keys;
// shorter, it takes the screen while it takes the keys, t going back
func TestTheViewSitsUnderTheDiagramWhenTall(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
a.view.take(tapped("p", "null", "", 0, "hello"))
layout := func(h int) string {
screen := strings.Join(text(render(a, 80, h), 80), "\n")
diagram, view := strings.Index(screen, "● SOURCES"), strings.Index(screen, "hello")
switch {
case diagram >= 0 && view > diagram:
return "split"
case view >= 0 && diagram < 0:
return "view"
case diagram >= 0 && view < 0:
return "diagram"
}
return screen
}
if got := layout(40); got != "split" {
t.Errorf("40 rows: %s", got)
}
if got := layout(39); got != "view" {
t.Errorf("39 rows: %s", got)
}
press(a, "t")
if got := layout(39); got != "diagram" {
t.Errorf("39 rows, back: %s", got)
}
if got := layout(40); got != "split" {
t.Errorf("40 rows, back: %s", got)
}
}
// Enter shows the focused row's detail beside the rows from 160 columns,
// under them below that
func TestTheDetailSitsBesideFrom160(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
a.view.take(tapped("p", "null", "", 0, "hello"))
press(a, terminal.KeyEnter)
for w, beside := range map[int]bool{160: true, 159: false} {
rows := text(render(a, w, 30), w)
y := slices.IndexFunc(rows, func(r string) bool { return strings.Contains(r, "─ detail ─") })
if got := y == 2 && strings.Contains(rows[2], "hello"); got != beside {
t.Errorf("%d columns: beside %v, detail on row %d\n%s", w, got, y, strings.Join(rows, "\n"))
}
}
}
// The detail shows the entry whole: its verdict, the layout logline found,
// its fields, and the payload as the sinks receive it
func TestTheDetailShowsTheEntryWhole(t *testing.T) {
c := pipeline(t, []string{"null"}, []string{"null"})
if _, err := c.Add(0, "filters", "exclude"); err != nil {
t.Fatal(err)
}
if err := c.Set(0, compose.Node{Role: "filters"}, "patterns", "slow"); err != nil {
t.Fatal(err)
}
a := watching(c, looks[terminal.ColorModeNone])
line := `{"time":"2026-10-10T12:00:00Z","level":"WARN","sub":"fsm","fields":{"msg":"transition","state":"idle"}}`
a.view.take(tapped("p", "null", "", 0, line))
press(a, terminal.KeyEnter)
screen := strings.Join(text(render(a, 170, 30), 170), "\n")
for _, want := range []string{"verdict passed every stage", "layout json, message in fields.msg", "state idle", "as sinks receive it", line[:50]} {
if !strings.Contains(screen, want) {
t.Errorf("no %q\n%s", want, screen)
}
}
a.view.take(tapped("p", "null", "filters", 0, "slow tick"))
if screen := strings.Join(text(render(a, 170, 30), 170), "\n"); !strings.Contains(screen, "dropped by filter 1, exclude slow") || strings.Contains(screen, "as sinks receive it") {
t.Errorf("a dropped row\n%s", screen)
}
}
// A row draws one cell per rune: a wide, combining or control rune is the
// font's placeholder and a line break its mark, which the detail writes as
// escapes and a break
func TestRowsDrawOneCellPerRune(t *testing.T) {
msg := "a\u4f60\u0301\x1b[2Jb\tc\nd"
for _, f := range []font{unicodeFont, cp437Font, asciiFont} {
a := newApp(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone], f)
a.viewing = true
render(a, 80, 24)
a.view.take(tapped("p", "null", "", 0, msg))
odd, br := string(f.fold(f.odd)), string(f.fold(f.newline))
want := "a" + odd + odd + odd + "[2Jb c" + br + "d"
if row := text(render(a, 80, 24), 80)[2]; !strings.Contains(row, want) {
t.Errorf("%c: %q, want %q", f.bar, row, want)
}
}
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
a.view.take(tapped("p", "null", "", 0, msg))
press(a, terminal.KeyEnter)
if screen := strings.Join(text(render(a, 80, 30), 80), "\n"); !strings.Contains(screen, `message a\u4F60\u0301\u001B[2Jb\u0009c`+strings.Repeat(" ", 10)) {
t.Errorf("the detail\n%s", screen)
}
}
// A narrow view drops the time, then the level's name, the tag and the
// level, and never draws one over the message
func TestANarrowViewDropsColumns(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
r := tapped("p", "null", "", 0, "hello world")
r.Event.Entry.Level, r.Event.Entry.Source = "WARN", "app.log"
a.view.take(r)
stamp := time.Unix(1, 0).Format("15:04:05")
for _, c := range []struct {
w int
want string
}{{100, stamp + ".000 WARN app.log hello world"}, {60, stamp + ".000 W app.log hello world"},
{45, stamp + " W hello world"}, {30, "▸ W hello world"}, {20, "▸ hello world"}} {
if row := text(render(a, c.w, 24), c.w)[2]; !strings.Contains(row, c.want) {
t.Errorf("%d columns: %q, want %q", c.w, row, c.want)
}
}
}
// A row's level shows in its level's color; a level lw does not name, in the
// text's
func TestALevelShowsInItsColor(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeTrueColor])
for _, level := range []string{"WARN", "AUDIT"} {
r := tapped("p", "null", "", 0, "hello")
r.Event.Entry.Level = level
a.view.take(r)
}
cells := render(a, 100, 24)
for y, c := range []struct {
level string
want ui.Style
}{{"WARN", a.look.Levels["WARN"]}, {"AUDIT", a.th.Text}} {
row := []rune(text(cells, 100)[2+y])
x := slices.Index(row, rune(c.level[0]))
if got := cells[(2+y)*100+x]; x < 0 || got.Fg != c.want.Fg {
t.Errorf("%s at %d is %+v, want %+v", c.level, x, got, c.want)
}
}
}
// The cursor row is one bar: without color every cell takes one style, so a
// console draws no split bar; in color each keeps its role on the bar's
// background
func TestTheViewsCursorRowIsOneBar(t *testing.T) {
for _, mode := range []terminal.ColorMode{terminal.ColorModeNone, terminal.ColorMode16, terminal.ColorModeTrueColor} {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[mode])
r := tapped("p", "null", "filters", 0, "hello world")
r.Event.Entry.Level, r.Event.Entry.Fields = "ERROR", json.RawMessage(`{"k":1}`)
a.view.take(r)
a.view.mode = modeDropped
a.choose(compose.Node{Role: "sink", ID: "null"})
cells := render(a, 100, 24)
row, bar := cells[2*100:3*100], a.th.Text.On(a.th.Selected)
for x, c := range row {
one := c.Fg == bar.Fg && c.Bg == bar.Bg && c.Attrs == bar.Attr
if mode == terminal.ColorModeNone && !one || c.Bg != bar.Bg || c.Attrs&terminal.AttrBgColor != bar.Attr&terminal.AttrBgColor {
t.Fatalf("mode %v: %q at %d is %+v, the bar %+v", mode, c.Rune, x, c, bar)
}
}
}
}
// A tick reads the taps up to drainBytes, the rest waiting for the next
func TestADrainKeepsToItsBudget(t *testing.T) {
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
a.live.tap = core.NewTap(100)
for range 100 {
a.live.tap.Offer(tapped("p", "null", "", 0, strings.Repeat("x", 64<<10)))
}
press(a, terminal.KeyNone)
size := a.view.rows[0].size
if n, want := len(a.view.rows), int(math.Ceil(float64(drainBytes)/float64(size))); n != want || len(a.live.tap.Records()) != 100-n {
t.Fatalf("read %d rows of %d bytes, want %d; %d left", n, size, want, len(a.live.tap.Records()))
}
}
// While a tap holds records the watch wakes the screen; an empty one does
// not
func TestATapHoldingRecordsWakesTheScreen(t *testing.T) {
posts := make(chan struct{}, 8)
r := newRun(&fakeRunner{}, func() { posts <- struct{}{} })
tap := core.NewTap(1)
r.fed.Store(tap)
go r.watch(time.Hour)
defer r.endAsks()
select {
case <-posts:
t.Fatal("an empty tap woke the screen")
case <-time.After(3 * drainEvery):
}
tap.Offer(tapped("p", "null", "", 0, "x"))
select {
case <-posts:
case <-time.After(time.Second):
t.Fatal("a tap holding a record did not wake the screen")
}
}
// A rerun reads the earlier run's tap whole, then a rule marks where the new
// run's rows begin
func TestARerunMarksTheRows(t *testing.T) {
f := &fakeRunner{}
a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f)
a.live.tap = core.NewTap(4)
a.live.tap.Offer(tapped("p", "null", "", 0, "before"))
next := core.NewTap(4)
next.Offer(tapped("p", "null", "", 0, "after"))
f.start = func(Ask) (*core.Tap, error) { return next, nil }
a.edit(a.comp.Set(0, compose.Node{Role: "format"}, "type", "txt"))
press(a, "t")
await(t, a)
var got []string
for _, r := range a.view.rows {
got = append(got, r.Message+strings.SplitN(r.mark, " ", 2)[0])
}
if strings.Join(got, ",") != "before,rerun,after" {
t.Fatalf("%q", got)
}
}
// i inspects the filter that dropped the focused row, which the view then
// looks at
func TestIInspectsTheFilterThatDropped(t *testing.T) {
c := pipeline(t, []string{"null"}, []string{"null"})
for _, typ := range []string{"include", "exclude"} {
if _, err := c.Add(0, "filters", typ); err != nil {
t.Fatal(err)
}
}
a := watching(c, looks[terminal.ColorModeNone])
a.view.take(tapped("p", "null", "filters", 1, "dropped"))
press(a, "i")
if l := a.lines()[a.insp.cursor]; a.viewing || !a.inspect || l.node != (compose.Node{Role: "filters", Index: 1}) || a.point().name != "filter 2" {
t.Fatalf("viewing %v, inspecting %v on %+v, at %s", a.viewing, a.inspect, l.node, a.point().name)
}
}
// s picks the point from a list, moving the canvas cursor there, or lw's own
// log while it reaches the screen, until s picks another or a key on the
// diagram moves the cursor
func TestTheChooserPicksThePoint(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
press(a, "s", "format", terminal.KeyEnter)
if n, _ := a.node(); n.Role != "format" || a.point().name != "format" || !a.viewing {
t.Fatalf("on %+v, at %s, viewing %v", n, a.point().name, a.viewing)
}
a.live.state.Log = core.NewTap(1)
for _, leave := range [][]any{{"s", "format", terminal.KeyEnter}, {"t", "j"}} {
if press(a, "s", "own log", terminal.KeyEnter); !a.point().own {
t.Fatalf("at %s", a.point().name)
}
if press(a, leave...); a.point().own {
t.Fatalf("%v left the point on lw's own log", leave)
}
}
}