diff --git a/cmd/lw/bootstrap.go b/cmd/lw/bootstrap.go index 50be57d..c763d14 100644 --- a/cmd/lw/bootstrap.go +++ b/cmd/lw/bootstrap.go @@ -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 diff --git a/cmd/lw/cli_test.go b/cmd/lw/cli_test.go index 410d92a..dfd787b 100644 --- a/cmd/lw/cli_test.go +++ b/cmd/lw/cli_test.go @@ -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 diff --git a/doc/cli.md b/doc/cli.md index 5ad2885..f29faa8 100644 --- a/doc/cli.md +++ b/doc/cli.md @@ -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 diff --git a/doc/lw.1 b/doc/lw.1 index bd53796..40e4af3 100644 --- a/doc/lw.1 +++ b/doc/lw.1 @@ -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 diff --git a/doc/sinks.md b/doc/sinks.md index d41afdf..4484ed4 100644 --- a/doc/sinks.md +++ b/doc/sinks.md @@ -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" diff --git a/doc/sources.md b/doc/sources.md index 72d963f..a7e7528 100644 --- a/doc/sources.md +++ b/doc/sources.md @@ -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 diff --git a/internal/config/loader.go b/internal/config/loader.go index 163690e..f3bbdb0 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -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{}}}, } } diff --git a/internal/config/preset.go b/internal/config/preset.go index 71a3c59..b113064 100644 --- a/internal/config/preset.go +++ b/internal/config/preset.go @@ -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 } diff --git a/internal/config/preset_test.go b/internal/config/preset_test.go index 6c4b5b7..d7926ac 100644 --- a/internal/config/preset_test.go +++ b/internal/config/preset_test.go @@ -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) { diff --git a/internal/config/schema.go b/internal/config/schema.go index bf06d2d..f334126 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -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) { diff --git a/internal/config/schema_test.go b/internal/config/schema_test.go index b06b3a0..0536119 100644 --- a/internal/config/schema_test.go +++ b/internal/config/schema_test.go @@ -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) + } +} diff --git a/internal/config/spec_test.go b/internal/config/spec_test.go index 5dcaf12..5ae55cb 100644 --- a/internal/config/spec_test.go +++ b/internal/config/spec_test.go @@ -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")}) diff --git a/internal/logline/logline.go b/internal/logline/logline.go index fb96d02..7b97be2 100644 --- a/internal/logline/logline.go +++ b/internal/logline/logline.go @@ -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 } diff --git a/internal/logline/logline_test.go b/internal/logline/logline_test.go index ca5496f..4575bf3 100644 --- a/internal/logline/logline_test.go +++ b/internal/logline/logline_test.go @@ -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" | `}, diff --git a/internal/logline/scan.go b/internal/logline/scan.go index f9cca27..da20f77 100644 --- a/internal/logline/scan.go +++ b/internal/logline/scan.go @@ -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 diff --git a/internal/source/file/file.go b/internal/source/file/file.go index 671010c..d309436 100644 --- a/internal/source/file/file.go +++ b/internal/source/file/file.go @@ -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) diff --git a/internal/source/file/file_test.go b/internal/source/file/file_test.go index cadfc9a..f999c7c 100644 --- a/internal/source/file/file_test.go +++ b/internal/source/file/file_test.go @@ -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, "") diff --git a/internal/source/file/file_watcher.go b/internal/source/file/file_watcher.go index dddcc43..29ebe71 100644 --- a/internal/source/file/file_watcher.go +++ b/internal/source/file/file_watcher.go @@ -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 } diff --git a/internal/tui/dialog.go b/internal/tui/dialog.go index 41dd88c..55a03ef 100644 --- a/internal/tui/dialog.go +++ b/internal/tui/dialog.go @@ -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"}, diff --git a/internal/tui/inspector.go b/internal/tui/inspector.go index 8a7870f..b1fade4 100644 --- a/internal/tui/inspector.go +++ b/internal/tui/inspector.go @@ -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 diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 9ccc224..2f14686 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -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 { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 24b346e..e84c25d 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -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) } } diff --git a/internal/tui/view.go b/internal/tui/view.go index bf7e298..e5c6ad3 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -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 diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index 3e5ea17..5d827fb 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -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) } }