v0.35.0 tui fixes
Package / arch (push) Canceled after 0s
Package / user (push) Canceled after 0s
Package / deb (push) Canceled after 0s
Package / freebsd (push) Canceled after 0s
Package / image (push) Canceled after 0s
Test / test (push) Canceled after 0s
Test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-11 04:11:57 -04:00
1 parent 1abffcec6c
commit f4f8cc0986
24 files changed
+965 -295

No files matched your search

+1 -1
View File
@@ -207,7 +207,7 @@ func initializeLogger(cfg *config.Config) error {
func configureFileLogging(logCfg *log.Config, cfg *config.Config) {
if cfg.Logging.File != nil {
logCfg.Directory = cfg.Logging.File.Directory
logCfg.Directory = config.ExpandHome(cfg.Logging.File.Directory)
logCfg.Name = cfg.Logging.File.Name
logCfg.MaxSizeKB = cfg.Logging.File.MaxSizeMB * 1000
logCfg.MaxTotalSizeKB = cfg.Logging.File.MaxTotalSizeMB * 1000
+17
View File
@@ -18,6 +18,8 @@ import (
"github.com/lixenwraith/logwisp/internal/plugin"
"github.com/lixenwraith/logwisp/internal/testutil"
"github.com/lixenwraith/logwisp/internal/tlsx"
"github.com/lixenwraith/log"
)
// lw's own flags: a short is its long flag, an unknown single-dash one fails,
@@ -191,6 +193,21 @@ func TestDumpReadsBack(t *testing.T) {
}
}
// lw's own log file goes under the home directory for a leading ~, which the
// configuration keeps as typed
func TestTheLogDirectoryReadsTildeAsHome(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
empty := filepath.Join(home, "empty.toml")
if err := os.WriteFile(empty, nil, 0o600); err != nil {
t.Fatal(err)
}
logCfg := log.DefaultConfig()
if configureFileLogging(logCfg, loadTestConfig(t, empty, "--logging.file.directory=~/lw")); logCfg.Directory != home+"/lw" {
t.Fatalf("log directory %q", logCfg.Directory)
}
}
// The command line, file and environment a composition writes, read by a
// POSIX shell and loaded as lw loads them, give its pipelines back: every
// preset, a second sink, a file's stage without its defaults, an integer for
+25 -21
View File
@@ -129,10 +129,10 @@ keeps its [precedence](configuration.md#configuration-precedence).
replace its format
- `--source SPEC`, `--sink SPEC` and `--filter SPEC` add a stage, repeatable
- `--format SPEC`, `--rate-limit SPEC` and `--heartbeat SPEC`, once per pipeline
- a pipeline without `--source` reads stdin (console source `stdin`), one
without `--sink` writes stdout (console sink `stdout`)
- a pipeline without `--source` reads stdin with a console source, one
without `--sink` writes stdout with a console sink
- stdin has one reader, so the whole configuration holds at most one console
source: `pipelines[1].plugin_sources[stdin]: one console source may run,
source: `pipelines[1].plugin_sources[console]: one console source may run,
and pipeline "a" has it`
A SPEC is `TYPE:key=value,...`, or `key=value,...` without a TYPE:
@@ -202,9 +202,9 @@ defaults, `lw preset -h` the presets. An unknown key fails and lists the valid
ones.
- `path`: a file, a directory (its files) or a glob, and this host decides
which: an existing directory is followed whole, any other path is a file
name or glob in its directory; for a directory not here, as in a `--dump`
for another host, write `DIR/*`. Without one, `serve` and `edge` read stdin
which: an existing directory, or a path ending in `/`, is followed whole,
any other path is a file name or glob in its directory; for a directory
not here, as in a `--dump` for another host, write `DIR/`. Without one, `serve` and `edge` read stdin
- `from` (`end`, or `start`): where reading a file starts
- `user`: one SCRAM user, an edge's login or a listener's only user in place
of `users`; `password_file` holds its password and defaults to `/dev/tty`,
@@ -350,8 +350,8 @@ docker run --rm -p 8080:8080 -v /var/log/app:/logs:ro \
## Built-in Defaults
Without a configuration file and without pipeline flags or variables, lw runs
one built-in pipeline, `pipe`: console source `stdin`, `raw` format, console
sink `stdout`, no filter, no rate limit. lw is then a Unix filter, like `cat`:
one built-in pipeline, `pipe`: a console source, `raw` format, a console
sink, no filter, no rate limit. lw is then a Unix filter, like `cat`:
```bash
lw < app.log > copy.log
@@ -509,26 +509,30 @@ WebAssembly; `t` runs them under the screen and shows what flows.
- The canvas cursor picks its point, so the view opens on the first
source: a source shows its entries, a flow stage (or the filter the
inspector is on) what reaches it, the heartbeat its own, the sinks (or
no part) what they got. `v` switches between all, what passed and what
was dropped there, with no restart. lw's own log shows from info up at
every point; `s` also offers it alone, debug too.
no part) what they got. `v` switches between all (what the flow dropped
dimmed), what passed and what was dropped there, with no restart; the
title says which. lw's own log shows from warn up at every point; `s`
also offers it alone, debug too.
- A row is the entry's time, level, tag, message and fields, read from any
line shape: LogWisp's json, lw's own log, vif records, pino, zap, slog,
docker, journald, ECS, GCP, or text after an RFC3339 stamp. A narrow
screen drops columns; a dropped entry is marked; a rune a terminal would
act on or draw wider than a cell shows as a placeholder.
docker, journald, ECS, GCP, logfmt, or text after an RFC3339 stamp and
a level name. A narrow screen drops columns; a rune a terminal would act
on or draw wider than a cell shows as a placeholder.
- Keys: arrows or `jk` move (up pauses, down onto the newest follows),
PgUp/PgDn; `g` or Home the oldest, `G`, End or `f` the newest; Enter the
row's detail, `J`/`K` scroll it; `/` only rows matching a pattern,
row's detail, `J`/`K` scroll it; `i` the row full screen, scrolled by
`jk`, PgUp/PgDn, `g` and `G`, closed by Esc, `i` or `q`; `e`, there too,
edits the stage that dropped the row; `/` only rows matching a pattern,
ignoring case when it is all lower case (clearing it, or a level, keeps
the focused row in place, paused there unless it is the newest); `1`-`5`
trace to error and up, `0` all; `s` chooses the point; `i` inspects the
stage that dropped the row; `c` clears; `q`, Ctrl-C, `?`, `r`, `o`, `[`
and `]` act as on the diagram.
trace to error and up, `0` all; `s` chooses the point; `c` clears; `q`,
Ctrl-C, `?`, `r`, `o`, `[` and `]` act as on the diagram.
- The detail, beside the rows from 160 columns and under them otherwise:
time and the stamp as written, level, origin, the verdict with a
filter's patterns, the layout read, message, fields, and the payload as
the sinks receive it, with escapes for what a cell cannot show.
time and the stamp as written, level, origin (the source's type, and its
id when another), the flow (passed every stage, or the stage that
dropped it with a filter's patterns), the layout read, message, fields,
and the payload as the sinks receive it, with escapes for what a cell
cannot show.
- It keeps 32 MiB of rows, each entry's message, fields and payload cut to
16 KiB, and redraws at most every 100 ms while entries flow, at once on
a key, never when idle. A closed standard output or error fails the
+16 -15
View File
@@ -49,11 +49,8 @@ it copies standard input to standard output line by line and exits 0 at the
end of input.
This built-in pipeline,
.Dq pipe ,
joins the console source
.Dq stdin
to the console sink
.Dq stdout
in the raw format, without a filter or rate limit; a configuration file that
joins a console source to a console sink in the raw format, without a filter
or rate limit; a configuration file that
defines no pipelines runs it too.
Each line is written with one newline, whether it ended in a newline, a
carriage return and newline, or nothing; blank lines are skipped, and a line
@@ -170,9 +167,14 @@ On the screen,
runs them in place, for real: listeners bind, dialers connect, file sinks
write.
A live view then shows each entry with its fate, passed or the flow stage
that dropped it, and the log of
.Nm ;
a console sink writing to the terminal runs as a null sink there, and a
that dropped it (dropped ones dimmed when all show), and the log of
.Nm
from warnings up;
.Ic i
shows an entry full screen and
.Ic e
edits the stage that dropped it.
A console sink writing to the terminal runs as a null sink there, and a
console source needs piped input.
The screen draws on the controlling terminal, so standard input and output
stay data:
@@ -330,10 +332,8 @@ the configuration file.
They then replace the file's pipelines and the built-in default; every other
key keeps its usual precedence.
A reload rereads the file but keeps these pipelines.
A pipeline without a source reads standard input with the console source
.Dq stdin ;
one without a sink writes standard output with the console sink
.Dq stdout .
A pipeline without a source reads standard input with a console source;
one without a sink writes standard output with a console sink.
Standard input has one reader: a second console source anywhere in the
configuration fails validation.
.Bl -tag -width Ds
@@ -417,11 +417,12 @@ is
A
.Cm path
is a file, a directory, whose files are followed, or a glob, and this host
decides which: an existing directory is followed whole, any other path is a
file name or glob in its directory; for a directory not here, as in a
decides which: an existing directory, or a path ending in a slash, is
followed whole, any other path is a file name or glob in its directory; for a
directory not here, as in a
.Fl \-dump
for another host, write
.Ql dir/* .
.Ql dir/ .
Without one,
.Cm serve
and
+1 -1
View File
@@ -22,7 +22,7 @@ Writes formatted payloads to stdout or stderr. It is the sink of the built-in
```toml
[[pipelines.plugin_sinks]]
id = "stdout"
id = "console"
type = "console"
[pipelines.plugin_sinks.config]
target = "stdout"
+4 -2
View File
@@ -29,7 +29,9 @@ from = "end"
Options, each as type and default:
- `directory` (string, required): directory to scan; not recursive.
- `directory` (string, required): directory to scan; not recursive. A leading
`~/`, here as in every file or directory option, is the home directory; a
relative path starts at lw's working directory; a missing one is waited for.
- `pattern` (string, `*`): glob over filenames; `*` and `?` only.
- `check_interval_ms` (int, `100`): directory rescan interval; minimum `10`.
An open file is polled every 100 ms whatever this is.
@@ -91,7 +93,7 @@ built-in `pipe` pipeline, and of every spec pipeline given no `--source`.
```toml
[[pipelines.plugin_sources]]
id = "stdin"
id = "console"
type = "console"
[pipelines.plugin_sources.config]
buffer_size = 1000
+4 -3
View File
@@ -209,13 +209,14 @@ func defaults() *Config {
}
}
// pipeDefault is a filter in the Unix sense: stdin to stdout, line for line
// pipeDefault is a filter in the Unix sense: stdin to stdout, line for line.
// Its parts take the ids lw gives their type, so a retyped part follows it.
func pipeDefault(name string) PipelineConfig {
return PipelineConfig{
Name: name,
Flow: &FlowConfig{Format: &FormatConfig{Type: "raw"}},
PluginSources: []PluginSourceConfig{{ID: "stdin", Type: "console", Config: map[string]any{}}},
PluginSinks: []PluginSinkConfig{{ID: "stdout", Type: "console", Config: map[string]any{}}},
PluginSources: []PluginSourceConfig{{ID: FreeID("console", nil), Type: "console", Config: map[string]any{}}},
PluginSinks: []PluginSinkConfig{{ID: FreeID("console", nil), Type: "console", Config: map[string]any{}}},
}
}
+12 -8
View File
@@ -61,16 +61,17 @@ func mirror[T any](p PresetParam, key string) PresetParam {
}
// IsDir tells whether a preset's path is a directory, whose files it follows,
// or a file pattern. Off the target host it is the user's Answers, or nil: a
// path ending in '/' is then a directory, one with a glob a pattern, and any
// other fails with ErrPathKind.
// or a file pattern; a path ending in '/' is a directory without asking. Off
// the target host it is the user's Answers, or nil: a path with a glob is then
// a pattern, and any other fails with ErrPathKind.
type IsDir func(path string) (bool, error)
var ErrPathKind error = &KeyError{"path", errors.New("a directory or a file pattern?")}
// HostIsDir asks this host: a path that does not exist is a pattern
// HostIsDir asks this host, a leading ~ being the home directory: a path that
// does not exist is a pattern
func HostIsDir(path string) (bool, error) {
fi, err := os.Stat(path)
fi, err := os.Stat(ExpandHome(path))
return err == nil && fi.IsDir(), nil
}
@@ -81,8 +82,8 @@ func Answers(dirs map[string]bool) IsDir {
if dir, ok := dirs[path]; ok {
return dir, nil
}
if dir := strings.HasSuffix(path, "/"); dir || strings.ContainsAny(path, `*?[\`) {
return dir, nil
if strings.ContainsAny(path, `*?[\`) {
return false, nil
}
return false, ErrPathKind
}
@@ -353,7 +354,10 @@ func pathOrStdin(v map[string]string, isDir IsDir) ([]PluginSourceConfig, error)
if isDir == nil {
isDir = Answers(nil)
}
dir, err := isDir(path)
dir, err := strings.HasSuffix(path, "/"), error(nil)
if !dir {
dir, err = isDir(path)
}
if err != nil {
return nil, err
}
+16 -8
View File
@@ -2,6 +2,8 @@ package config
import (
"errors"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
@@ -19,14 +21,14 @@ func TestPresetStartsAndNamesItsPipeline(t *testing.T) {
Flow: &FlowConfig{Format: &FormatConfig{Type: "json"}},
PluginSources: []PluginSourceConfig{{ID: "file", Type: "file", Config: map[string]any{"directory": "/var/log", "pattern": "app.log", "from": "end"}}},
PluginSinks: []PluginSinkConfig{
{ID: "stdout", Type: "console", Config: map[string]any{"color": "auto"}},
{ID: "console", Type: "console", Config: map[string]any{"color": "auto"}},
{ID: "null", Type: "null", Config: map[string]any{}},
},
}, {
Name: "mine",
Flow: &FlowConfig{Format: &FormatConfig{Type: "raw"}},
PluginSources: []PluginSourceConfig{{ID: "stdin", Type: "console", Config: map[string]any{}}},
PluginSinks: []PluginSinkConfig{{ID: "stdout", Type: "console", Config: map[string]any{"color": "auto"}}},
PluginSources: []PluginSourceConfig{{ID: "console", Type: "console", Config: map[string]any{}}},
PluginSinks: []PluginSinkConfig{{ID: "console", Type: "console", Config: map[string]any{"color": "auto"}}},
}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("pipelines:\n got %+v\nwant %+v", got, want)
@@ -122,11 +124,16 @@ func TestPresetUserWithoutPasswordFileAsks(t *testing.T) {
}
}
// A preset path is what isDir answers, a directory named with brackets too,
// and off the target host the user's answer, a single file too; without one
// it is a directory when it ends in '/', a pattern when it holds a glob, and
// otherwise ErrPathKind asks
// A preset path ending in '/' is a directory, there yet or not; any other is
// what isDir answers, a directory named with brackets too: this host's,
// reading ~ as home, or off it the user's answer, a single file too; without
// one it is a pattern when it holds a glob, and otherwise ErrPathKind asks
func TestPresetPathKind(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
if err := os.Mkdir(filepath.Join(home, "logs"), 0o700); err != nil {
t.Fatal(err)
}
yes := func(string) (bool, error) { return true, nil }
file := Answers(map[string]bool{"/srv/app.log": false})
for _, c := range []struct {
@@ -134,7 +141,8 @@ func TestPresetPathKind(t *testing.T) {
isDir IsDir
want string
}{{"/srv/app/", nil, "/srv/app/ *"}, {"/srv/app/*.log", nil, "/srv/app *.log"}, {"/srv/app[1]", yes, "/srv/app[1] *"}, {"/srv/app", nil, ""},
{"/srv/app.log", file, "/srv app.log"}, {"/srv/app", file, ""}, {"/srv/app/", file, "/srv/app/ *"}} {
{"/srv/app.log", file, "/srv app.log"}, {"/srv/app", file, ""}, {"/srv/app/", file, "/srv/app/ *"},
{"~/logs", HostIsDir, "~/logs *"}, {home + "/later/", HostIsDir, home + "/later/ *"}} {
p, err := ExpandPreset("tail", map[string]string{"path": c.path}, c.isDir)
if c.want == "" {
if !errors.Is(err, ErrPathKind) {
+30 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"maps"
"math"
"os"
"reflect"
"slices"
"strconv"
@@ -234,7 +235,8 @@ func settingKeys(v reflect.Value, prefix string) []Key {
// Decode reads a plugin's config map into its options: unknown keys, kinds,
// the defaults, the tag rules, the tls, auth and acl rules of its side, then the
// options' own Check. It reads no file.
// options' own Check; a leading ~ in a path is the home directory. It reads no
// file.
func Decode[T any](role, typ string, m map[string]any) (*T, error) {
v, err := decode(role, typ, m)
if err != nil {
@@ -263,6 +265,7 @@ func decode(role, typ string, m map[string]any) (any, error) {
if err := Settle(v.Interface()); err != nil {
return nil, err
}
expandPaths(v.Elem())
if n, ok := v.Interface().(networked); ok {
if err := p.checkNetwork(n); err != nil {
return nil, err
@@ -287,6 +290,32 @@ func Settle(v any) error {
// Fill gives a table its defaults and checks nothing: Settle's first step
func Fill(v any) { settle(reflect.ValueOf(v).Elem()) }
// expandPaths reads ~ (ExpandHome) in the dir and file keys of a table, its
// tables' too, where lw reads them; the forms keep ~ as typed
func expandPaths(v reflect.Value) {
for _, o := range options(v.Type()) {
switch f := v.Field(o.index); {
case f.Kind() == reflect.Pointer && !f.IsNil() && f.Elem().Kind() == reflect.Struct:
expandPaths(f.Elem())
case o.Hint == "dir" || o.Hint == "file":
f.SetString(ExpandHome(f.String()))
}
}
}
// ExpandHome reads a leading ~ or ~/ as the home directory; ~user, and a
// host without a home directory (js/wasm), keep the path as it is
func ExpandHome(path string) string {
rest, ok := strings.CutPrefix(path, "~")
if !ok || rest != "" && !os.IsPathSeparator(rest[0]) {
return path
}
if home, err := os.UserHomeDir(); err == nil {
return home + rest
}
return path
}
// preset sets every default before decoding, so a switch that defaults on
// stays on unless set off; it does so in the tables m sets
func preset(v reflect.Value, m map[string]any) {
+32
View File
@@ -2,6 +2,7 @@ package config
import (
"math"
"os"
"reflect"
"slices"
"strings"
@@ -199,3 +200,34 @@ func TestTableKeysFollowThePlugin(t *testing.T) {
}
}
}
// A leading ~ or ~/ in a dir or file key is the home directory where lw reads
// it, in a plugin's options, its tables too, while the configuration keeps it
// as typed, the settings' too (lw's log reads it at startup); ~user, other
// keys and a host without a home directory keep it
func TestTildeIsHomeInPathKeys(t *testing.T) {
isolateConfig(t)
home := os.Getenv("HOME")
src, err := Decode[FileSourceOptions]("source", "file", map[string]any{"directory": "~/logs", "pattern": "~*"})
if err != nil || src.Directory != home+"/logs" || src.Pattern != "~*" {
t.Fatalf("file source: %+v %v", src, err)
}
tls := map[string]any{"enabled": true, "ca_file": "~", "cert_file": "~/c", "key_file": "~other/k"}
sink, err := Decode[TCPChainSinkOptions]("sink", "tcp_chain", map[string]any{"host": "h", "port": int64(1), "tls": tls})
if err != nil || sink.TLS.CAFile != home || sink.TLS.CertFile != home+"/c" || sink.TLS.KeyFile != "~other/k" {
t.Fatalf("tls table: %+v %v", sink.TLS, err)
}
m, err := Load(Args{Overrides: []string{"--logging.file.directory=~/lw"}, Specs: specs("source", "file:directory=~/logs")})
if err != nil {
t.Fatal(err)
}
defer m.Close()
cfg, err := m.Snapshot()
if err != nil || cfg.Logging.File.Directory != "~/lw" || cfg.Pipelines[0].PluginSources[0].Config["directory"] != "~/logs" {
t.Fatalf("as typed: %+v %v %v", cfg.Logging.File, cfg.Pipelines, err)
}
t.Setenv("HOME", "")
if src, err := Decode[FileSourceOptions]("source", "file", map[string]any{"directory": "~/logs"}); err != nil || src.Directory != "~/logs" {
t.Fatalf("without a home directory: %+v %v", src, err)
}
}
+4 -3
View File
@@ -172,12 +172,13 @@ func TestSpecPipelinesReplaceFilePipelines(t *testing.T) {
}
// A spec pipeline without a source reads stdin and one without a sink writes
// stdout, as a filter does; two pipelines cannot both read stdin.
// stdout, as a filter does, each part under its type's own id; two pipelines
// cannot both read stdin.
func TestSpecPipelinesDefaultToStdio(t *testing.T) {
isolateConfig(t)
got := loadPipelines(t, "filter", "include:patterns=ERROR")
if p := got[0]; len(p.PluginSources) != 1 || p.PluginSources[0].Type != "console" || p.PluginSources[0].ID != "stdin" ||
len(p.PluginSinks) != 1 || p.PluginSinks[0].Type != "console" || p.PluginSinks[0].ID != "stdout" {
if p := got[0]; len(p.PluginSources) != 1 || p.PluginSources[0].Type != "console" || p.PluginSources[0].ID != "console" ||
len(p.PluginSinks) != 1 || p.PluginSinks[0].Type != "console" || p.PluginSinks[0].ID != "console" {
t.Fatalf("pipeline: %+v", p)
}
_, err := Load(Args{Specs: specs("pipeline", "a", "sink", "null", "pipeline", "b", "sink", "null")})
+72 -30
View File
@@ -1,13 +1,15 @@
// 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.
// lw's own log, pino, zap, slog, logfmt, 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"
"slices"
"strconv"
"strings"
"time"
@@ -70,8 +72,8 @@ type member struct {
}
// 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.
// record's message) or text, 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 {
@@ -79,12 +81,7 @@ func Read(e core.LogEntry) Line {
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"
}
}
l = text(e.Message)
}
if f := strings.TrimSpace(string(e.Fields)); f != "" {
_, kind, _ := scanValue(f, 0)
@@ -135,19 +132,7 @@ func record(s, in string) (l Line, at string, ok bool) {
}
}
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 {
if at = l.take(ms); at == "" && msg >= 0 {
ms[msg].used = true
l.Message, at = unquote(ms[msg].val, kStr), envAt
}
@@ -160,14 +145,71 @@ func record(s, in string) (l Line, at string, ok bool) {
if !whole {
l.Layout += ", cut short"
}
if at != "" {
l.Layout += ", message in " + at
} else {
l.Layout += ", no message"
}
l.Layout += messageIn(at)
return l, at, true
}
// text reads a line that is no JSON record: a leading RFC3339 stamp, then a
// word naming a level; the rest is the message, or a record's roles when it
// is logfmt pairs, a level or a message among them (slog's text handler)
func text(s string) Line {
l, layout := Line{Message: s}, ""
if stamp, rest, ok := strings.Cut(s, " "); ok {
if at, ok := readStamp(stamp); ok {
l.Time, l.Stamp, l.Message, layout = at, stamp, strings.TrimLeft(rest, " "), ", stamped"
word, rest, _ := strings.Cut(l.Message, " ")
if lv, i, j := core.LevelWord(word, ""); lv != "" && i == 0 && j == len(word) {
l.Level, l.Message, layout = lv, strings.TrimLeft(rest, " "), layout+", level word"
}
}
}
var ms []member
named := false
pairs := eachPair(l.Message, func(k, v string) {
ms = append(ms, member{key: k, val: v, kind: kText})
named = named || slices.Contains(roles[roleLevel], k) || slices.Contains(roles[roleMsg], k)
})
if !pairs || !named {
l.Layout = "text" + layout
return l
}
l.Message = ""
at := l.take(ms)
l.Fields = appendFields(nil, ms)
l.Layout = "logfmt" + layout + messageIn(at)
return l
}
// take sets the roles ms names and returns the message's key, "" for none; a
// stamp or level read off the line's start stays, the member a field
func (l *Line) take(ms []member) (at string) {
if l.Stamp == "" {
if m := find(ms, roleTime); m != nil {
l.Stamp = unquote(m.val, m.kind)
l.Time, _ = readTime(l.Stamp)
}
}
if l.Level == "" {
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
}
return at
}
func messageIn(at string) string {
if at == "" {
return ", no message"
}
return ", message in " + at
}
// 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) {
@@ -248,7 +290,7 @@ 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) {
if m.key == key && !m.env && !m.used && (m.kind == kStr || m.kind == kNum || m.kind == kText) {
m.used = true
return m
}
+33 -4
View File
@@ -20,7 +20,8 @@ func arrived(line string) core.LogEntry {
// 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.
// are their writers' output (lw's key=value line as its log library writes one
// with FlagKV); the others follow their formats' documentation.
func TestReadFindsTheRolesWhereTheirWriterPutsThem(t *testing.T) {
for _, c := range []struct {
in core.LogEntry
@@ -65,6 +66,31 @@ func TestReadFindsTheRolesWhereTheirWriterPutsThem(t *testing.T) {
`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"`},
// logfmt, slog's text handler and go-kit's: key=value pairs, a value
// quoted as Go quotes it
{arrived(`time=2026-10-11T05:23:21.410Z level=WARN msg="disk almost full" pct=91 disk.dev=/dev/sda1 err="open \"x\": denied"`),
`logfmt, message in msg | 2026-10-11T05:23:21.410Z=2026-10-11T05:23:21.41Z | WARN | app.log | "disk almost full" | pct="91" disk.dev="/dev/sda1" err="open \"x\": denied"`},
{arrived(`level=info ts=2026-10-10T12:00:00.000Z caller=main.go:12 listen=:8080`),
`logfmt, no message | 2026-10-10T12:00:00.000Z=2026-10-10T12:00:00Z | INFO | app.log | "" | caller="main.go:12" listen=":8080"`},
// lw's own log as text: the level word after the stamp is the level, taken
// off the message (the entry names none here); its pairs, once key=value,
// are a record's
{core.LogEntry{Source: "lw", Message: `2026-10-11T05:23:20.28585127Z INFO msg "Waiting for directory" component file_source instance_id file directory /home/me/.local/state/vif/log`},
`text, stamped, level word | 2026-10-11T05:23:20.28585127Z=2026-10-11T05:23:20.28585127Z | INFO | lw | "msg \"Waiting for directory\" component file_source instance_id file directory /home/me/.local/state/vif/log" | `},
{arrived(`2026-10-11T05:19:17.289770496Z INFO msg="Waiting for directory" component=file_source instance_id=file directory=/home/me/.local/state/vif/log`),
`logfmt, stamped, level word, message in msg | 2026-10-11T05:19:17.289770496Z=2026-10-11T05:19:17.289770496Z | INFO | file_source | "Waiting for directory" | instance_id="file" directory="/home/me/.local/state/vif/log"`},
// the line's start keeps its stamp and level word; pairs naming them are fields
{arrived(`2026-10-11T05:00:00Z WARN msg="request done" t=3 level=debug`),
`logfmt, stamped, level word, message in msg | 2026-10-11T05:00:00Z=2026-10-11T05:00:00Z | WARN | app.log | "request done" | t="3" level="debug"`},
// an '=' in text is text: pairs alone, a level or message among them, count
{arrived(`x=1 y=2 note="no level or message key"`),
`text | =- | | app.log | "x=1 y=2 note=\"no level or message key\"" | `},
{arrived(`msg=not only pairs`),
`text | =- | | app.log | "msg=not only pairs" | `},
{arrived(`level=INFO msg="clipped at an escape\`),
`text | =- | INFO | app.log | "level=INFO msg=\"clipped at an escape\\" | `},
{arrived(`2026-10-10T12:00:00Z DEBUG cache hit ratio=0.93`),
`text, stamped, level word | 2026-10-10T12:00:00Z=2026-10-10T12:00:00Z | DEBUG | app.log | "cache hit ratio=0.93" | `},
// 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"`},
@@ -86,9 +112,12 @@ func TestReadFindsTheRolesWhereTheirWriterPutsThem(t *testing.T) {
{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" | `},
`text, stamped, level word | 2026-10-10T12:00:00+02:00=2026-10-10T10:00:00Z | ERROR | app.log | "db: connection refused" | `},
{arrived(`2026-10-10T12:00:00.5 a stamp without a zone is UTC`),
`text, stamped | 2026-10-10T12:00:00.5=2026-10-10T12:00:00.5Z | | app.log | "a stamp without a zone is UTC" | `},
// a level word is a whole word: one in brackets stays in the message
{arrived(`2026-10-10T12:00:00Z [WARN] disk almost full`),
`text, stamped | 2026-10-10T12:00:00Z=2026-10-10T12:00:00Z | WARN | app.log | "[WARN] disk almost full" | `},
// 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" | `},
+42 -3
View File
@@ -1,8 +1,11 @@
package logline
import "encoding/json"
import (
"encoding/json"
"strconv"
)
// JSON token kinds
// JSON token kinds, and kText for a logfmt value, unquoted already
const (
kNone byte = 0
kStr byte = 's'
@@ -11,6 +14,7 @@ const (
kNull byte = 'z'
kObj byte = 'o'
kArr byte = 'a'
kText byte = 't'
)
func skipSpace(s string, i int) int {
@@ -177,8 +181,43 @@ func eachItem(s string, i int, fn func(val string, kind byte)) bool {
return false
}
// eachPair calls fn for each pair of a logfmt line, key=value with the value
// bare or quoted as Go quotes it; it reports whether s is such pairs alone.
func eachPair(s string, fn func(key, val string)) bool {
bare := func(i int) int {
for i < len(s) && s[i] > ' ' && s[i] != '=' && s[i] != '"' {
i++
}
return i
}
for i := skipSpace(s, 0); i < len(s); i = skipSpace(s, i) {
k := i
if i = bare(k); i == k || i == len(s) || s[i] != '=' {
return false
}
key, v := s[k:i], i+1
i = bare(v)
val := s[v:i]
if i == v && i < len(s) && s[i] == '"' {
var ok bool
var err error
if i, ok = scanString(s, v); !ok {
return false
}
if val, err = strconv.Unquote(s[v:i]); err != nil {
return false
}
}
if i < len(s) && s[i] > ' ' {
return false
}
fn(key, val)
}
return true
}
// unquote returns the content of a string token, decoding only when it holds
// an escape; any other token is returned as its JSON.
// an escape; any other token, a logfmt value too, is returned as it is.
func unquote(tok string, kind byte) string {
if kind != kStr || len(tok) < 2 {
return tok
+32 -16
View File
@@ -44,6 +44,7 @@ type FileSource struct {
// already reported, touched only by monitorLoop.
own []os.FileInfo
ownSkipped map[string]bool
scanErr string // the last scan's error, "" once it read; monitorLoop's
// Application
subscribers []chan core.LogEntry
@@ -138,7 +139,7 @@ func (fs *FileSource) Start() error {
go fs.monitorLoop()
fs.logger.Info("msg", "File source started",
"component", "File_source",
"component", "file_source",
"path", fs.config.Directory,
"pattern", fs.config.Pattern,
"check_interval_ms", fs.config.CheckIntervalMS)
@@ -227,12 +228,8 @@ func (fs *FileSource) monitorLoop() {
// checkTargets finds matching files and ensures watchers are running for them.
func (fs *FileSource) checkTargets() {
files, err := fs.scanFile()
fs.noteScan(err)
if err != nil {
fs.logger.Warn("msg", "Failed to scan file",
"component", "file_source",
"path", fs.config.Directory,
"pattern", fs.config.Pattern,
"error", err)
return
}
@@ -243,6 +240,32 @@ func (fs *FileSource) checkTargets() {
fs.cleanupWatchers()
}
// noteScan logs when the directory cannot be read and when it can again, not
// every check: a missing one at info, as it may yet be made
func (fs *FileSource) noteScan(err error) {
switch {
case !changed(&fs.scanErr, err):
case err == nil:
fs.logger.Info("msg", "Directory readable", "component", "file_source", "instance_id", fs.id, "directory", fs.config.Directory)
case errors.Is(err, os.ErrNotExist):
fs.logger.Info("msg", "Waiting for directory", "component", "file_source", "instance_id", fs.id, "directory", fs.config.Directory)
default:
fs.logger.Warn("msg", "Cannot read directory", "component", "file_source", "instance_id", fs.id, "directory", fs.config.Directory, "error", err)
}
}
// changed reports whether err differs from the last check's, kept in last
// ("" for none), so a failure is logged as it starts or changes
func changed(last *string, err error) bool {
text := ""
if err != nil {
text = err.Error()
}
was := *last
*last = text
return text != was
}
// ensureWatcher creates and starts a new file watcher if one doesn't exist for the given path.
func (fs *FileSource) ensureWatcher(path string) {
fs.mu.Lock()
@@ -269,16 +292,9 @@ func (fs *FileSource) ensureWatcher(path string) {
go func() {
defer fs.wg.Done()
if err := w.watch(fs.ctx); err != nil {
if errors.Is(err, context.Canceled) {
fs.logger.Debug("msg", "Watcher cancelled",
"component", "file_source",
"path", path)
} else {
fs.logger.Error("msg", "Watcher failed",
"component", "file_source",
"path", path,
"error", err)
}
fs.logger.Debug("msg", "Watcher cancelled",
"component", "file_source",
"path", path)
}
fs.removeWatcher(path, w)
+85
View File
@@ -1,7 +1,9 @@
package file
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"slices"
@@ -39,6 +41,89 @@ func TestFileSourceSkipsItsOwnOutput(t *testing.T) {
}
}
// A directory the source cannot read is logged when that starts and when it
// ends, not on every check: a missing one at info, as it may yet be made,
// any other failure at warn
func TestUnreadableDirectoryIsLoggedOncePerChange(t *testing.T) {
logger, out := txtLogger(t)
dir := filepath.Join(t.TempDir(), "logs")
source := &FileSource{config: &config.FileSourceOptions{Directory: dir, Pattern: "*"}, watchers: map[string]*fileWatcher{}, logger: logger}
checksAfter := func(err error) {
if err != nil {
t.Fatal(err)
}
for range 3 {
source.checkTargets()
}
}
checksAfter(nil)
checksAfter(os.Mkdir(dir, 0o700))
checksAfter(errors.Join(os.Remove(dir), os.WriteFile(dir, nil, 0o600)))
if err := logger.Flush(time.Second); err != nil {
t.Fatal(err)
}
lines := slices.Collect(strings.Lines(out.String()))
want := [][2]string{{"INFO", "Waiting for directory"}, {"INFO", "Directory readable"}, {"WARN", "Cannot read directory"}}
for i, w := range want {
if len(lines) != len(want) || !strings.Contains(lines[i], " "+w[0]+" ") || !strings.Contains(lines[i], w[1]) {
t.Fatalf("logged:\n%swant %v", out.String(), want)
}
}
}
// A file the source cannot open, from its watcher's start or later, is logged
// as that starts, not on every check, and read once it opens: the watcher goes
// on
func TestUnreadableFileIsLoggedOnceAndWaitedFor(t *testing.T) {
logger, out := txtLogger(t)
dir := filepath.Join(t.TempDir(), "logs")
block := func() error { return errors.Join(os.RemoveAll(dir), os.WriteFile(dir, nil, 0o600)) } // logs/a.log cannot open
read := make(chan string, 10)
w := newFileWatcher(filepath.Join(dir, "a.log"), true, true, func(e core.LogEntry) { read <- e.Message }, logger)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error)
if err := block(); err != nil {
t.Fatal(err)
}
go func() { done <- w.watch(ctx) }()
time.Sleep(4 * core.FileWatcherPollInterval)
if err := errors.Join(os.Remove(dir), os.Mkdir(dir, 0o700), os.WriteFile(filepath.Join(dir, "a.log"), []byte("first\n"), 0o600)); err != nil {
t.Fatal(err)
}
select {
case <-read:
case <-time.After(time.Second):
t.Fatal("not read once it opened")
}
if err := block(); err != nil {
t.Fatal(err)
}
time.Sleep(4 * core.FileWatcherPollInterval)
cancel()
<-done
if err := logger.Flush(time.Second); err != nil {
t.Fatal(err)
}
if strings.Count(out.String(), "\n") != 2 || strings.Count(out.String(), " WARN ") != 2 || strings.Count(out.String(), "Cannot read file") != 2 {
t.Fatalf("logged:\n%swant Cannot read file twice", out.String())
}
}
// txtLogger logs txt lines into the buffer it returns, read after Flush
func txtLogger(t *testing.T) (*log.Logger, *bytes.Buffer) {
t.Helper()
out := &bytes.Buffer{}
logger := log.NewLogger()
logger.SetConsoleWriter(out)
cfg := log.DefaultConfig()
cfg.Format = "txt"
if err := logger.ApplyConfig(cfg); err != nil || logger.Start() != nil {
t.Fatal(err)
}
t.Cleanup(func() { logger.Shutdown() })
return logger, out
}
func TestStoppedWatcherReturnsNormally(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl")
testutil.WriteFile(t, path, "")
+17 -22
View File
@@ -47,6 +47,7 @@ type fileWatcher struct {
entriesRead atomic.Uint64
lastReadTime atomic.Value // time.Time
logger *log.Logger
failed string // the last check's error, "" for none; watch's
}
// newFileWatcher creates a new watcher for a specific file path.
@@ -67,16 +68,19 @@ func newFileWatcher(directory string, raw, fromStart bool, callback func(core.Lo
return w
}
// watch starts the main monitoring loop for the file
// watch follows the file until ctx ends or stop: it finds where to start,
// again each check until the file opens, then reads what the file gains
func (w *fileWatcher) watch(ctx context.Context) error {
if err := w.initPosition(); err != nil {
return fmt.Errorf("initPosition failed: %w", err)
}
ticker := time.NewTicker(core.FileWatcherPollInterval)
defer ticker.Stop()
check := w.initPosition
for {
err := check()
if err == nil {
check = w.checkFile
}
w.note(err)
select {
case <-ctx.Done():
return ctx.Err()
@@ -84,14 +88,18 @@ func (w *fileWatcher) watch(ctx context.Context) error {
if w.isStopped() {
return nil
}
if err := w.checkFile(); err != nil {
// Log error but continue watching
w.logger.Warn("msg", "checkFile error", "error", err)
}
}
}
}
// note logs a check's failure as it starts or changes, not every check; the
// watcher goes on
func (w *fileWatcher) note(err error) {
if changed(&w.failed, err) && err != nil {
w.logger.Warn("msg", "Cannot read file", "component", "file_watcher", "path", w.directory, "error", err)
}
}
// stop signals the watcher to terminate its loop
func (w *fileWatcher) stop() {
w.mu.Lock()
@@ -127,20 +135,12 @@ func (w *fileWatcher) checkFile() error {
// File doesn't exist yet, keep watching
return nil
}
w.logger.Error("msg", "Failed to open file for checking",
"component", "file_watcher",
"directory", w.directory,
"error", err)
return err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
w.logger.Error("msg", "Failed to stat file",
"component", "file_watcher",
"directory", w.directory,
"error", err)
return err
}
@@ -269,11 +269,6 @@ func (w *fileWatcher) checkFile() error {
}
if err := scanner.Err(); err != nil {
w.logger.Error("msg", "Scanner error while reading file",
"component", "file_watcher",
"directory", w.directory,
"position", startPos,
"error", err)
return err
}
+72 -20
View File
@@ -436,31 +436,15 @@ func (n *notice) draw(a *app, r ui.Region) {
w = max(w, ui.RuneLen(l))
}
in := a.frame(r, min(w, inside(r.W)), len(lines), n.title)
rows := in.H
if len(lines) > in.H { // the last row says where the view is
rows = max(1, in.H-1)
}
n.view.SetDimensions(len(lines), rows)
rows := a.window(in, &n.view, len(lines))
for y := range min(rows, len(lines)-n.view.Offset) {
in.TextStyled(0, y, lines[n.view.Offset+y], a.th.Text)
}
if rows < in.H {
where := fmt.Sprintf("j k: %d-%d of %d", n.view.Offset+1, n.view.Offset+rows, len(lines))
in.TextStyled(0, rows, ui.Truncate(where, in.W), a.th.Muted)
}
}
func (n *notice) handle(a *app, ev terminal.Event) {
switch {
case ev.Type != terminal.EventKey:
case is(ev, terminal.KeyUp, "k"):
n.view.ScrollBy(-1)
case is(ev, terminal.KeyDown, "j"):
n.view.ScrollBy(1)
case is(ev, terminal.KeyPageUp, ""):
n.view.PageUp()
case is(ev, terminal.KeyPageDown, ""):
n.view.PageDown()
case ev.Type != terminal.EventKey, scroll(&n.view, ev):
default:
if a.dialog = nil; n.closed != nil {
n.closed(a)
@@ -468,6 +452,73 @@ func (n *notice) handle(a *app, ev terminal.Event) {
}
}
// window sets v to n lines in r and returns the rows they take: all of r, or
// all but its last, which says where v is when they do not fit
func (a *app) window(r ui.Region, v *ui.ViewportScroll, n int) int {
rows := r.H
if n > r.H {
rows = max(1, r.H-1)
}
v.SetDimensions(n, rows)
if rows < r.H {
where := fmt.Sprintf("j k: %d-%d of %d", v.Offset+1, v.Offset+rows, n)
r.TextStyled(0, rows, ui.Truncate(where, r.W), a.th.Muted)
}
return rows
}
// scroll moves v on a key that scrolls, reporting whether ev was one
func scroll(v *ui.ViewportScroll, ev terminal.Event) bool {
switch {
case is(ev, terminal.KeyUp, "k"):
v.ScrollBy(-1)
case is(ev, terminal.KeyDown, "j"):
v.ScrollBy(1)
case is(ev, terminal.KeyPageUp, ""):
v.PageUp()
case is(ev, terminal.KeyPageDown, ""):
v.PageDown()
case is(ev, terminal.KeyHome, "g"):
v.Home()
case is(ev, terminal.KeyEnd, "G"):
v.End()
default:
return false
}
return true
}
// entry shows a row of the live view over the whole screen, every detail
// line, until Esc, i or q; e edits the stage that dropped it
type entry struct {
row row // a copy, which the ring's evictions leave whole
view ui.ViewportScroll
}
func (e *entry) draw(a *app, r ui.Region) {
r.FillStyle(a.th.Text)
hint := "esc back"
if e.row.dropped() {
hint = "e edit the stage that dropped it " + hint
}
r.Rule(0, "entry", hint, a.th)
body := r.Sub(0, 1, r.W, r.H-1)
lines := a.details(&e.row, r.W-2)
rows := a.window(body.Sub(1, 0, body.W-1, body.H), &e.view, len(lines))
a.drawLines(body.Sub(0, 0, body.W, rows), lines, e.view.Offset)
}
func (e *entry) handle(a *app, ev terminal.Event) {
switch {
case ev.Type != terminal.EventKey, scroll(&e.view, ev):
case is(ev, terminal.KeyNone, "e"):
a.dialog = nil
a.editDrop(&e.row)
case is(ev, terminal.KeyEscape, "iq"), is(ev, terminal.KeyCtrlC, ""):
a.dialog = nil
}
}
// confirm asks a yes or no question: y or n answers, as does Enter on the
// answer the arrows chose; Esc is no
type confirm struct {
@@ -525,11 +576,12 @@ func help(f font, viewing bool) dialog {
{"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"},
{"i", "the row full screen; esc, i or q back"},
{"/", "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"},
{"v", "all that reaches the point, the dropped dimmed; 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"},
{"e", "edit 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"},
+87 -50
View File
@@ -3,6 +3,7 @@ package tui
import (
"cmp"
"fmt"
"maps"
"strconv"
"strings"
@@ -28,14 +29,12 @@ type line struct {
key config.Key // its Name is the dotted path in the node's options, "" on a filter's rows
depth int
group string // a group's title, "" for a value
item int // a list element's index, the list's length on the row that adds one; -1 elsewhere
}
// adds reports whether a row adds: a list's last row, or the filters'
func (a *app) adds(l line) bool {
return l.item >= 0 && (l.key.Name == "" || l.item == len(a.items(l.node, l.key.Name)))
item int // a list element's index, the list's length on the row adding to it; -1 elsewhere
add bool // the row that adds an element or a filter
}
// foldKey names a group's fold; a filter's folds share its prefix, path ""
// its own
func (a *app) foldKey(n compose.Node, path string) string {
return fmt.Sprintf("%d/%s/%s/%d/%s", a.pi, n.Role, n.ID, n.Index, path)
}
@@ -54,11 +53,11 @@ func (a *app) lines() []line {
for i, f := range filters {
fn := compose.Node{Role: "filters", Index: i}
out = append(out, line{node: fn, group: fmt.Sprintf("%d %s", i+1, f.Type), item: -1})
if a.insp.open[a.foldKey(n, strconv.Itoa(i))] {
if a.insp.open[a.foldKey(fn, "")] {
out = a.keyLines(out, fn, a.keys(n), "", 1)
}
}
return append(out, line{node: compose.Node{Role: "filters", Index: len(filters)}, item: len(filters)})
return append(out, line{node: compose.Node{Role: "filters", Index: len(filters)}, item: -1, add: true})
}
func (a *app) keyLines(out []line, n compose.Node, keys []config.Key, prefix string, depth int) []line {
@@ -76,7 +75,7 @@ func (a *app) keyLines(out []line, n compose.Node, keys []config.Key, prefix str
if a.insp.open[a.foldKey(n, k.Name)] {
items := a.items(n, k.Name)
for i := range len(items) + 1 {
out = append(out, line{node: n, key: k, depth: depth + 1, item: i})
out = append(out, line{node: n, key: k, depth: depth + 1, item: i, add: i == len(items)})
}
}
default:
@@ -145,23 +144,28 @@ func (a *app) drawInspector(r ui.Region) {
help = clip(ui.WrapText(text, r.W-4), 2)
}
rows := r.H - 1 - len(help)
cols, perCol := 1, rows
if half := (len(lines) + 1) / 2; r.W >= 80 && half <= rows {
cols, perCol = 2, half
cols, perCol, second := 1, rows, 0
if r.W >= 80 {
second = split(lines, rows)
}
a.insp.scroll = ui.AdjustScroll(a.insp.cursor, ui.ClampScroll(a.insp.scroll, rows, len(lines)), rows, len(lines))
if cols == 2 {
a.insp.scroll = 0
if second > 0 {
cols, perCol, a.insp.scroll = 2, max(second, len(lines)-second), 0
}
colW, labelW := r.W/cols, 0
for _, l := range lines {
labelW = max(labelW, 2*l.depth+ui.RuneLen(label(l))+2)
}
labelW = min(labelW, colW/2)
for i := a.insp.scroll; i < len(lines) && i-a.insp.scroll < perCol*cols; i++ {
at := i - a.insp.scroll
row := r.Sub(at/perCol*colW, 1+at%perCol, colW-1, 1)
a.drawLine(row, lines[i], labelW, a.inspect && i == a.insp.cursor)
for i := a.insp.scroll; i < len(lines); i++ {
x, y := 0, i-a.insp.scroll
if second > 0 && i >= second {
x, y = colW, i-second
}
if y >= rows {
break
}
a.drawLine(r.Sub(x, 1+y, colW-1, 1), 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 {
@@ -169,6 +173,19 @@ func (a *app) drawInspector(r ui.Region) {
}
}
// split is the first row of a second column, the shorter the taller column
// the better, 0 for one: a column holds rows at most and starts on a top row,
// not inside an unfolded group nor on the row adding to what precedes it
func split(lines []line, rows int) int {
best, tall := 0, rows
for i := 1; i < len(lines); i++ {
if h := max(i, len(lines)-i); lines[i].depth == 0 && !lines[i].add && h <= tall {
best, tall = i, h
}
}
return best
}
func (a *app) nodeTitle(n compose.Node) string {
switch n.Role {
case "source", "sink":
@@ -180,29 +197,34 @@ func (a *app) nodeTitle(n compose.Node) string {
return n.Role + " · flow"
}
// help is the focused row's first problem, or its key's help and default
// help is the focused row's first problem, or its key's help and what
// unset means, or what d or Enter does on a list's row
func (a *app) help(l line) (string, ui.Style) {
for _, p := range compose.Problems(a.problem) {
if pi, n, key, text, ok := compose.Locate(p); ok && pi == a.pi && n == l.node && key == l.key.Name {
return text, a.th.Error
}
}
switch {
case l.group != "" && l.key.Name == "":
return "J and K move this filter; c changes its type; d deletes it", a.th.Muted
case a.adds(l) && l.key.Name == "":
return "adds a filter: include passes only the entries that match, exclude drops them", a.th.Muted
}
help := l.key.Help
if d := placeholder(l.key); d != "" && l.group == "" {
switch d := placeholder(l.key); {
case l.group != "" && l.key.Name == "":
help = "J and K move this filter; c changes its type; d deletes it"
case l.add && l.key.Name == "":
help = "adds a filter: include passes only the entries that match, exclude drops them"
case l.add:
help += "; " + a.font.enter + " adds one"
case l.item >= 0:
help += "; d deletes it"
case (l.key.Default != "" || l.key.Zero != "") && l.group == "": // a hint is a kind, not what unset means
help += "; unset: " + d
}
return help, a.th.Muted
}
// label is a row's name: its key's last part, or a list element's number
// label is a row's name: its key's last part, or a list element's number,
// which the row adding one shows while one is typed
func label(l line) string {
if l.item >= 0 {
if l.item >= 0 && l.key.Name != "" {
return strconv.Itoa(l.item + 1)
}
return l.key.Name[strings.LastIndex(l.key.Name, ".")+1:]
@@ -212,22 +234,29 @@ func (a *app) drawLine(r ui.Region, l line, labelW int, focused bool) {
th := a.th
r = r.Sub(2*l.depth, 0, r.W-2*l.depth, 1)
k := l.key
if l.group != "" {
open := a.insp.open[a.foldKey(l.node, k.Name)]
typing := focused && a.insp.field != nil
switch {
case l.group != "":
r.Group(0, l.group, a.groupSummary(l), a.insp.open[a.foldKey(l.node, k.Name)], focused, th)
return
case l.add && !typing: // where its list's labels or the filters' titles start, no number
text, style := "+ add", th.Muted
if k.Name == "" {
open = a.insp.open[a.foldKey(compose.Node{Role: "filters"}, strconv.Itoa(l.node.Index))]
text += " filter"
}
r.Group(0, l.group, a.groupSummary(l), open, focused, th)
if focused {
style = th.Text
r.TextStyled(0, 0, string(th.Glyphs.Focus), th.Accent)
}
r.TextStyled(2, 0, text, style)
return
}
v, _ := r.Field(0, labelW-2*l.depth, ui.Field{Label: label(l), Required: k.Required && l.item < 0}, focused, th)
items := a.items(l.node, k.Name)
cur := a.value(l.node, k.Name)
switch {
case focused && a.insp.field != nil:
case typing:
v.TextInput(a.insp.field, placeholder(k), true, th)
case a.adds(l):
v.TextStyled(0, 0, "+ add", th.Muted)
case l.item >= 0:
v.TextStyled(0, 0, ui.Truncate(items[l.item], v.W), th.Text)
case len(k.Enum) > 0:
@@ -307,13 +336,10 @@ func (a *app) inspectKey(ev terminal.Event) bool {
a.insp.cursor = max(0, a.insp.cursor-1)
case is(ev, terminal.KeyDown, "j"):
a.insp.cursor = min(len(lines)-1, a.insp.cursor+1)
case a.adds(l) && k.Name == "" && enter:
case l.add && k.Name == "" && enter:
a.dialog = a.addMenu()
case l.group != "" && (enter || space || left || right):
fold := a.foldKey(l.node, k.Name)
if k.Name == "" {
fold = a.foldKey(compose.Node{Role: "filters"}, strconv.Itoa(l.node.Index))
}
a.insp.open[fold] = (enter || space) && !a.insp.open[fold] || right
case l.group != "" && k.Name == "" && is(ev, terminal.KeyNone, "JK"):
a.moveFilter(l.node.Index, map[rune]int{'J': 1, 'K': -1}[ev.Rune])
@@ -337,10 +363,10 @@ func (a *app) inspectKey(ev terminal.Event) bool {
a.edit(a.comp.Set(a.pi, l.node, k.Name, strconv.FormatBool(right || !left && !on)))
case enter:
text := show(cmp.Or(cur, any("")))
if items := a.items(l.node, k.Name); l.item >= 0 {
if l.item >= 0 {
text = ""
if l.item < len(items) {
text = items[l.item]
if !l.add {
text = a.items(l.node, k.Name)[l.item]
}
}
a.insp.field = ui.NewTextFieldState(text)
@@ -362,7 +388,7 @@ func (a *app) typeKey(ev terminal.Event, l line) {
value := a.insp.field.Value()
if a.commit(l, value) {
a.insp.field = nil
if l.item >= 0 && l.item == len(a.items(l.node, l.key.Name))-1 && value != "" {
if l.add && value != "" {
a.insp.cursor++ // stays on the row that adds one
}
focus := ui.Focus{Index: a.insp.cursor, Len: len(a.lines())}
@@ -391,9 +417,9 @@ func (a *app) commit(l line, value string) bool {
}
items := a.items(l.node, k.Name)
switch {
case l.item == len(items) && value == "":
case l.add && value == "":
return true
case l.item == len(items):
case l.add:
items = append(items, value)
case value == "":
items = append(items[:l.item], items[l.item+1:]...)
@@ -410,7 +436,7 @@ func (a *app) commit(l line, value string) bool {
// value, which returns to its default
func (a *app) clear(l line) {
switch {
case a.adds(l):
case l.add:
case l.group != "" && l.key.Name == "":
a.edit(a.comp.Remove(a.pi, l.node))
case l.item >= 0:
@@ -420,14 +446,25 @@ func (a *app) clear(l line) {
}
}
// moveFilter moves a filter by one, its fold and the cursor with it
// moveFilter moves a filter by one, its folds and the cursor with it
func (a *app) moveFilter(i, by int) {
stage := compose.Node{Role: "filters"}
if i+by < 0 || i+by >= len(a.pipeline().Flow.Filters) || !a.edit(a.comp.MoveFilter(a.pi, i, i+by)) {
return // nothing moves past either end, as j and k stop there
}
from, to := a.foldKey(stage, strconv.Itoa(i)), a.foldKey(stage, strconv.Itoa(i+by))
a.insp.open[from], a.insp.open[to] = a.insp.open[to], a.insp.open[from]
from, to := a.foldKey(compose.Node{Role: "filters", Index: i}, ""), a.foldKey(compose.Node{Role: "filters", Index: i + by}, "")
moved := map[string]bool{}
for k, open := range a.insp.open {
switch {
case strings.HasPrefix(k, from):
moved[to+k[len(from):]] = open
case strings.HasPrefix(k, to):
moved[from+k[len(to):]] = open
default:
continue
}
delete(a.insp.open, k)
}
maps.Copy(a.insp.open, moved)
for j, l := range a.lines() {
if l.group != "" && l.key.Name == "" && l.node.Index == i+by {
a.insp.cursor = j
+1 -1
View File
@@ -465,7 +465,7 @@ func (a *app) inspectAt(at func(line) bool) {
// choose puts the canvas cursor on a node, a filter on the filters stage
func (a *app) choose(n compose.Node) {
if n.Role == "filters" {
a.insp.open[a.foldKey(compose.Node{Role: "filters"}, fmt.Sprint(n.Index))] = true
a.insp.open[a.foldKey(n, "")] = true
n = compose.Node{Role: "filters"}
}
for col := range columns {
+116 -5
View File
@@ -373,6 +373,112 @@ func TestListElementsAreRows(t *testing.T) {
}
}
// A key's help says what unset means when that is a default or what zero
// means, never the kind of value the key takes
func TestHelpSaysWhatUnsetMeans(t *testing.T) {
c := pipeline(t, nil, []string{"null"})
n, err := c.Add(0, "source", "file")
if err == nil {
err = c.Set(0, n, "directory", "/srv/app")
}
if err != nil {
t.Fatal(err)
}
a := mono(c)
dir, _ := a.help(focusKey(t, a, "directory"))
pattern, _ := a.help(focusKey(t, a, "pattern"))
if strings.Contains(dir, "unset") || !strings.HasSuffix(pattern, "; unset: *") {
t.Errorf("directory %q, pattern %q", dir, pattern)
}
}
// filtered is an app inspecting a pipeline whose filters match patterns,
// each a filter, the first with its patterns unfolded
func filtered(t *testing.T, patterns ...string) *app {
t.Helper()
c := pipeline(t, []string{"null"}, []string{"null"})
for _, p := range patterns {
n, err := c.Add(0, "filters", "include")
if err == nil {
err = c.Set(0, n, "patterns", p)
}
if err != nil {
t.Fatal(err)
}
}
a := mono(c)
first := compose.Node{Role: "filters"}
a.choose(first)
a.insp.open[a.foldKey(first, "patterns")] = true
a.inspectAt(func(l line) bool { return l.item == 0 && !l.add })
return a
}
// inspectorRows are the inspector's rows on a screen w by h, under its rule
func inspectorRows(a *app, w, h int) []string {
rows := text(render(a, w, h), w)
return rows[1+slices.IndexFunc(rows, func(r string) bool { return strings.HasPrefix(r, "── filters") }):]
}
// A list reads as one: its elements numbered at its labels' column, then
// "+ add" there with no number, and the filters' "+ add filter" where their
// titles start; an element's help says d deletes it, its add row's, focused
// with its mark, that Enter adds one
func TestAddRowsAreNotListElements(t *testing.T) {
a := filtered(t, "Event")
rows := inspectorRows(a, 78, 30)
at := func(s string) (int, string) { // where the first row holding s starts, past the focus mark, and its text
for _, r := range rows {
if strings.Contains(r, s) {
text := strings.TrimLeft(r, " "+string(a.th.Glyphs.Focus))
return ui.RuneLen(r) - ui.RuneLen(text), strings.TrimSpace(text)
}
}
return -1, ""
}
element, _ := at("Event")
add, addText := at("+ add")
title, _ := at("1 include")
addFilter, addFilterText := at("+ add filter")
screen := strings.Join(rows, "\n")
if add != element || addText != "+ add" || addFilter != title || addFilterText != "+ add filter" ||
!strings.Contains(screen, "RE2 patterns; d deletes it") || strings.Contains(screen, "unset:") {
t.Fatalf("element at %d, its add row at %d %q, filter at %d, its add row at %d %q\n%s", element, add, addText, title, addFilter, addFilterText, screen)
}
press(a, "j")
rows = inspectorRows(a, 78, 30)
focused := strings.TrimSpace(rows[slices.IndexFunc(rows, func(r string) bool { return strings.Contains(r, "+ add") })])
if focused != string(a.th.Glyphs.Focus)+" + add" || !strings.Contains(strings.Join(rows, "\n"), "RE2 patterns; "+a.font.enter+" adds one") {
t.Fatalf("the add row focused: %q\n%s", focused, strings.Join(rows, "\n"))
}
}
// The inspector's two columns never part an unfolded group, nor a list or
// the filters from the row adding to them, and hold no more rows than one
// column would
func TestTwoColumnsKeepGroupsWhole(t *testing.T) {
const w, h = 100, 30
right := func(rows []string) (out []string) {
for _, r := range rows {
if half := strings.TrimSpace(string([]rune(r)[w/2:])); half != "" {
out = append(out, half)
}
}
return out
}
a := filtered(t, "Event")
if got := right(inspectorRows(a, w, h)[:len(a.lines())]); len(got) > 0 {
t.Errorf("one filter, unfolded, split: %q", got)
}
a = filtered(t, "Event", "Start")
if got := right(inspectorRows(a, w, h)[:len(a.lines())]); len(got) != 2 || !strings.HasPrefix(got[0], "▸ 2 include") || got[1] != "+ add filter" {
t.Errorf("the second filter's column: %q", got)
}
if a.insp.cursor = len(a.lines()) - 1; !strings.Contains(strings.Join(inspectorRows(a, w, 16), "\n"), "+ add filter") {
t.Errorf("too short for two columns, the focused row went:\n%s", strings.Join(inspectorRows(a, w, 16), "\n"))
}
}
// Space turns a flow stage on with its defaults and off again; a stage with
// an enabled key switches that
func TestSpaceSwitchesAStage(t *testing.T) {
@@ -393,9 +499,10 @@ func TestSpaceSwitchesAStage(t *testing.T) {
}
}
// J and K move a filter, its fold and the cursor with it, and stop at
// either end as j and k do
func TestFiltersMoveWithTheirFold(t *testing.T) {
// J and K move a filter, its folds and the cursor with it, the filter it
// passes taking its place with its own folds, and stop at either end as j and
// k do
func TestFiltersMoveWithTheirFolds(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 {
@@ -403,11 +510,15 @@ func TestFiltersMoveWithTheirFold(t *testing.T) {
}
}
a := mono(c)
a.choose(compose.Node{Role: "filters", Index: 0})
first, second := compose.Node{Role: "filters", Index: 0}, compose.Node{Role: "filters", Index: 1}
a.choose(first)
a.insp.open[a.foldKey(first, "patterns")] = true
a.insp.open[a.foldKey(second, "")] = true
press(a, terminal.KeyEnter, "J", "J")
types := []config.FilterType{a.pipeline().Flow.Filters[0].Type, a.pipeline().Flow.Filters[1].Type}
l := a.lines()[a.insp.cursor]
if !slices.Equal(types, []config.FilterType{"exclude", "include"}) || l.node.Index != 1 || !a.insp.open[a.foldKey(compose.Node{Role: "filters"}, "1")] || a.status != "" {
moved := a.insp.open[a.foldKey(second, "")] && a.insp.open[a.foldKey(second, "patterns")] && a.insp.open[a.foldKey(first, "")] && !a.insp.open[a.foldKey(first, "patterns")]
if !slices.Equal(types, []config.FilterType{"exclude", "include"}) || l.node.Index != 1 || !moved || a.status != "" {
t.Fatalf("types %v, cursor on %+v, open %v, status %q", types, l, a.insp.open, a.status)
}
}
+125 -65
View File
@@ -106,21 +106,21 @@ const (
// 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.
// lw's own log alone, which s picks. all says what mode all shows.
type point struct {
pipe, name string
one, own bool
source string
reach int
lo, hi int
pipe, name, all 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" })
// its startup records do not bury the entries
var lwShown = slices.IndexFunc(core.Levels, func(l core.Level) bool { return l.Level == "WARN" })
// 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
// warn 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 {
@@ -151,7 +151,7 @@ func (p point) needsDrops(mode int) bool {
// 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}
p := point{name: "sinks", all: "all they got", 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}
@@ -163,17 +163,17 @@ func (a *app) point() point {
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}
return point{pipe: p.pipe, name: fmt.Sprintf("filter %d", m.Index+1), all: "all that reaches it", 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
p.name, p.all, p.one, p.source, p.reach = "source "+n.ID, "all it reads", true, n.ID, 0
case n.Role == "heartbeat":
p.name, p.one, p.lo, p.hi = "heartbeat", true, 1, 0
p.name, p.all, p.one, p.lo, p.hi = "heartbeat", "all it sends", 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)
p.name, p.all, p.reach, p.lo, p.hi = n.Role, "all that reaches it", rank(n.Role, 0), rank(n.Role, 0), rank(n.Role, 1<<16-1)
}
return p
}
@@ -224,6 +224,9 @@ func (r *row) own() int {
return n
}
// dropped reports whether the flow dropped the row
func (r *row) dropped() bool { return r.stage != "" }
// 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}) }
@@ -315,6 +318,14 @@ func (v *view) focused() *row {
return &v.rows[v.shown[v.cursor]-v.rows[0].seq]
}
// focusedEntry is the cursor's row when it is an entry, nil on a rule or none
func (v *view) focusedEntry() *row {
if w := v.focused(); w != nil && w.mark == "" {
return w
}
return nil
}
func (v *view) clear() {
v.rows, v.shown, v.bytes, v.cursor, v.scroll, v.paused = nil, nil, 0, 0, 0, false
}
@@ -379,8 +390,10 @@ func (a *app) viewKey(ev terminal.Event) {
a.dialog = a.pointMenu()
case is(ev, terminal.KeyNone, "c"):
v.clear()
case is(ev, terminal.KeyNone, "i"):
a.inspectDrop()
case is(ev, terminal.KeyNone, "i") && v.focusedEntry() != nil:
a.dialog = &entry{row: *v.focusedEntry()}
case is(ev, terminal.KeyNone, "e"):
a.editDrop(v.focusedEntry())
case is(ev, terminal.KeyEscape, "") && v.detail:
v.detail = false
case is(ev, terminal.KeyEscape, "t"):
@@ -412,18 +425,17 @@ func (a *app) promptKey(ev terminal.Event) {
}
}
// inspectDrop opens the inspector on the stage that dropped the focused row
func (a *app) inspectDrop() {
w := a.view.focused()
// editDrop opens the inspector on the stage that dropped w
func (a *app) editDrop(w *row) {
pi := -1
if w != nil {
pi = slices.IndexFunc(a.comp.Pipelines, func(p config.PipelineConfig) bool { return p.Name == w.pipe })
pi = a.pipeIndex(w.pipe)
}
switch {
case w == nil || pi < 0:
a.note = "i inspects the stage that dropped an entry"
a.note = "e edits the stage that dropped an entry"
return
case w.stage == "":
case !w.dropped():
a.note = "it passed every stage"
return
}
@@ -436,6 +448,11 @@ func (a *app) inspectDrop() {
a.inspectAt(func(l line) bool { return l.node == n })
}
// pipeIndex is the pipeline named name, -1 when none is
func (a *app) pipeIndex(name string) int {
return slices.IndexFunc(a.comp.Pipelines, func(p config.PipelineConfig) bool { return p.Name == name })
}
// 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 {
@@ -488,24 +505,41 @@ func (a *app) pointMenu() dialog {
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 {
at := v.built.at
title, more, dimmed, rest := at.name, "", "", ""
switch {
case at.own:
case v.mode == modeAll:
title, more = title+" · all", strings.TrimPrefix(at.all, "all")
if at.needsDrops(modeAll) {
dimmed = ", dropped dimmed"
}
default:
title += " · " + verdicts[v.mode]
}
if len(a.comp.Pipelines) > 1 && v.built.at.pipe != "" {
title = v.built.at.pipe + " · " + title
if len(a.comp.Pipelines) > 1 && at.pipe != "" {
title = at.pipe + " · " + title
}
if v.floor > 0 {
title += " · " + core.Levels[v.floor-1].Level + " up"
rest += " · " + core.Levels[v.floor-1].Level + " up"
}
if v.pat.src != "" {
title += " · /" + v.pat.src
rest += " · /" + 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)
// The hint keeps its room, the rule padding title and hint by 8 cells: the
// title gives up saying the dropped are dimmed, then what all holds, then
// its end
room, text := r.W-ui.RuneLen(hint)-8, title+more+dimmed+rest
for _, shorter := range []string{title + more + rest, title + rest} {
if ui.RuneLen(text) > room {
text = shorter
}
}
r.Rule(0, a.font.flat(text, room), hint, th)
if a.viewing {
r.TextStyled(0, 0, string(th.Glyphs.Focus), th.Accent)
}
@@ -563,17 +597,25 @@ func (v *view) widths(w int) (tw, lw, gw int) {
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.
// drawRow draws a row: the cursor's pointer, its time, level and tag, then
// its message and, muted, its fields; all muted when the flow dropped it and
// the view shows all. 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 }
dim := a.view.mode == modeAll && w.dropped()
on := func(s ui.Style) ui.Style {
if dim {
s = th.Muted
}
if cursor {
s = s.On(th.Selected)
}
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))
r.TextStyled(0, 0, string(th.Glyphs.Pointer), th.Accent.On(th.Selected))
}
if w.mark != "" {
dash := "─"
@@ -584,10 +626,7 @@ func (a *app) drawRow(r ui.Region, w *row, tw, lw, gw int, cursor bool) {
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
x := 1
if tw > 0 {
r.TextStyled(x, 0, w.at.Local().Format("15:04:05.000")[:tw], on(th.Muted))
x += tw + 1
@@ -617,30 +656,37 @@ type dline struct {
style ui.Style
}
// drawDetail draws the focused row whole, scrolled by J and K
// drawDetail draws the focused entry whole, scrolled by J and K
func (a *app) drawDetail(r ui.Region) {
v, th := &a.view, a.th
w := v.focused()
v := &a.view
w := v.focusedEntry()
hint := "J K scroll"
if w != nil && w.pipe != "" && w.stage != "" {
hint = "i inspects it " + hint
switch {
case w != nil && w.dropped():
hint = "i full screen e edit its stage " + hint
case w != nil:
hint = "i full screen " + 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)
r.Rule(0, "detail", hint, a.th)
if w != nil {
v.dscroll = a.drawLines(r.Sub(0, 1, r.W, r.H-1), a.details(w, r.W-2), v.dscroll)
}
}
// drawLines draws detail lines from top, kept in range, which it returns
func (a *app) drawLines(r ui.Region, lines []dline, top int) int {
top = max(0, min(top, len(lines)-r.H))
for y := 0; y < r.H && top+y < len(lines); y++ {
l := lines[top+y]
r.TextStyled(1, y, l.key, a.th.Muted)
r.TextStyled(2+ui.RuneLen(l.key), y, l.text, l.style)
}
return top
}
// 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
// flow, 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
@@ -659,7 +705,7 @@ func (a *app) details(w *row, width int) []dline {
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("flow", 8, a.verdict(w), th.Text)
}
put("layout", 8, w.Layout, th.Muted)
put("message", 8, w.Message, th.Text)
@@ -677,18 +723,32 @@ func (a *app) details(w *row, width int) []dline {
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
// origin names where a row came from: lw's own log, or a pipeline's
// heartbeat or source, by the type it has now and its id when that is
// another, then the tag the line names when it is not the source's id
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
from := "source " + w.source
if pi := a.pipeIndex(w.pipe); pi >= 0 {
for _, s := range a.comp.Pipelines[pi].PluginSources {
switch {
case s.ID != w.source:
case s.ID == s.Type:
from = s.Type + " source"
default:
from = s.Type + " source " + s.ID
}
}
}
if w.Tag != "" && w.Tag != w.source {
from += " · " + w.Tag
}
return "pipeline " + w.pipe + " · " + from
}
// verdict says what the flow did with a row: passed, or the stage, and for a
@@ -699,9 +759,9 @@ func (a *app) verdict(w *row) string {
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 pi := a.pipeIndex(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, " | ")
text += " (" + string(fs[w.index].Type) + " " + strings.Join(fs[w.index].Patterns, " | ") + ")"
}
}
return text
+121 -16
View File
@@ -188,7 +188,7 @@ func TestARowCountsAllItHolds(t *testing.T) {
// 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
// own log shows from warn 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"} {
@@ -209,7 +209,7 @@ func TestThePointAndModePickTheRows(t *testing.T) {
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"),
tapped("p", "", "", 0, "g"), tapped("q", s1, "", 0, "h"), lw("WARN", "i"), lw("INFO", "j"),
} {
a.view.take(r)
}
@@ -360,10 +360,12 @@ func TestTheDetailSitsBesideFrom160(t *testing.T) {
}
}
// The detail shows the entry whole: its verdict, the layout logline found,
// its fields, and the payload as the sinks receive it
// The detail shows the entry whole: where it came from by its source's type,
// its id when another, and its tag, what the flow did with it, 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"})
c.Pipelines[0].PluginSources[0].ID = "in"
if _, err := c.Add(0, "filters", "exclude"); err != nil {
t.Fatal(err)
}
@@ -372,17 +374,20 @@ func TestTheDetailShowsTheEntryWhole(t *testing.T) {
}
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))
a.view.take(tapped("p", "in", "", 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]} {
for _, want := range []string{"from pipeline p · null source in · fsm", "flow 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)
}
}
c.Pipelines[0].PluginSources[0].ID = "null"
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)
if screen := strings.Join(text(render(a, 170, 30), 170), "\n"); a.origin(a.view.focused()) != "pipeline p · null source" ||
!strings.Contains(screen, "flow dropped by filter 1 (exclude slow)") || strings.Contains(screen, "as sinks receive it") {
t.Errorf("a dropped row from a source whose id is its type\n%s", screen)
}
}
@@ -422,7 +427,7 @@ func TestANarrowViewDropsColumns(t *testing.T) {
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"}} {
{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)
}
@@ -531,20 +536,120 @@ func TestARerunMarksTheRows(t *testing.T) {
}
}
// i inspects the filter that dropped the focused row, which the view then
// looks at
func TestIInspectsTheFilterThatDropped(t *testing.T) {
// e edits the filter that dropped the focused row, from the rows or the
// row full screen, and the view then looks at it
func TestEEditsTheStageThatDropped(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"))
for _, keys := range []string{"e", "ie"} {
a := watching(c, looks[terminal.ColorModeNone])
a.view.take(tapped("p", "null", "filters", 1, "dropped"))
press(a, keys)
if l := a.lines()[a.insp.cursor]; a.dialog != nil || a.viewing || !a.inspect || l.node != (compose.Node{Role: "filters", Index: 1}) || a.point().name != "filter 2" {
t.Fatalf("%s: viewing %v, inspecting %v on %+v, at %s", keys, a.viewing, a.inspect, l.node, a.point().name)
}
}
}
// i shows the focused entry over the whole screen, every detail line, which
// j k g G scroll; Esc, i or q goes back to the rows, q quitting nothing. On no
// row or a rule across the rows it shows nothing.
func TestIShowsTheRowFullScreen(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
for _, rule := range []string{"", "rerun 10:00:00"} {
if rule != "" {
a.view.mark(rule)
}
if press(a, "i"); a.dialog != nil {
t.Fatalf("i on %q: %T", rule, a.dialog)
}
}
fields := ""
for i := range 40 {
fields += fmt.Sprintf(`,"k%02d":%d`, i, i)
}
a.view.take(tapped("p", "null", "", 0, `{"msg":"hello"`+fields+`}`))
screen := func() string { return strings.Join(text(render(a, 80, 24), 80), "\n") }
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)
if s := screen(); !strings.Contains(s, "─ entry ─") || !strings.Contains(s, "message hello") || strings.Contains(s, "following") || strings.Contains(s, "t back") ||
strings.Contains(s, "k39") || !strings.Contains(s, "j k: 1-22 of ") {
t.Fatalf("full screen:\n%s", s)
}
if press(a, "G"); !strings.Contains(screen(), "k39") {
t.Fatalf("G:\n%s", screen())
}
if press(a, "g", "j"); !strings.Contains(screen(), "j k: 2-23 of ") {
t.Fatalf("g j:\n%s", screen())
}
for _, back := range []any{terminal.KeyEscape, "i", "q"} {
press(a, back)
if a.dialog != nil || !a.viewing || a.done {
t.Fatalf("%v: dialog %T, viewing %v, done %v", back, a.dialog, a.viewing, a.done)
}
press(a, "i")
}
}
// Rows give the flow's drops no mark: where the view shows all, the rows the
// flow dropped are muted, which the rule says where some can show (not at the
// sinks); a mono screen shows them dim
func TestDroppedRowsAreMutedInAll(t *testing.T) {
a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone])
for _, r := range []core.Tapped{tapped("p", "null", "", 0, "kept"), tapped("p", "null", "filters", 0, "gone"), tapped("p", "null", "filters", 0, "newest")} {
a.view.take(r)
}
a.choose(compose.Node{Role: "source", ID: "null"})
for mode, want := range map[int]string{modeAll: "source null · all it reads, dropped dimmed", modeDropped: "source null · dropped"} {
a.view.mode = mode
cells := render(a, 80, 24)
rows := text(cells, 80)
attr := func(msg string) terminal.Attr {
y := slices.IndexFunc(rows, func(r string) bool { return strings.Contains(r, msg) })
if y < 0 || !strings.HasPrefix(string([]rune(rows[y])[1:]), time.Unix(1, 0).Format("15:04:05")) {
t.Fatalf("%s: no %q at column 1\n%s", verdicts[mode], msg, strings.Join(rows, "\n"))
}
return cells[y*80+ui.RuneLen(rows[y][:strings.Index(rows[y], msg)])].Attrs
}
if dim := attr("gone")&terminal.AttrDim != 0; !strings.Contains(rows[1], want) || dim != (mode == modeAll) || mode == modeAll && attr("kept")&terminal.AttrDim != 0 {
t.Errorf("%s: dropped row dim %v\n%s", verdicts[mode], dim, strings.Join(rows, "\n"))
}
}
a.view.mode = modeAll
if a.choose(compose.Node{Role: "sink", ID: "null"}); !strings.Contains(text(render(a, 80, 24), 80)[1], "sinks · all they got ─") {
t.Errorf("at the sinks: %q", text(render(a, 80, 24), 80)[1])
}
}
// The rule keeps its hint, following or paused, where the title would push it
// off: the title drops its note that the dropped are dimmed, then what all
// holds, then its end
func TestTheRuleKeepsItsHint(t *testing.T) {
c := pipeline(t, []string{"null"}, []string{"null"})
if err := c.AddPipeline("q"); err != nil {
t.Fatal(err)
}
a := watching(c, looks[terminal.ColorModeNone])
for _, msg := range []string{"tick 1", "tick 2"} {
a.view.take(tapped("p", "null", "", 0, `{"level":"WARN","msg":"`+msg+`"}`))
}
a.choose(compose.Node{Role: "source", ID: "null"})
press(a, "3", "/", "tick", terminal.KeyEnter, "k")
rule := func(w int) string { return text(render(a, w, 30), w)[1] }
if wide := rule(120); !strings.Contains(wide, "p · source null · all it reads, dropped dimmed · INFO up · /tick ─") {
t.Fatalf("120 columns: %q", wide)
}
if narrow := rule(78); !strings.Contains(narrow, "p · source null · all it reads · INFO up · /tick ─") || !strings.Contains(narrow, "paused · 1 newer") {
t.Errorf("78 columns: %q", narrow)
}
if narrower := rule(70); !strings.Contains(narrower, "p · source null · all · INFO up · /tick ─") || !strings.Contains(narrower, "paused · 1 newer") {
t.Errorf("70 columns: %q", narrower)
}
if narrowest := rule(40); !strings.Contains(narrowest, "p · source null…") || !strings.Contains(narrowest, "paused · 1 newer") {
t.Errorf("40 columns: %q", narrowest)
}
}