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
+955 -285

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) { func configureFileLogging(logCfg *log.Config, cfg *config.Config) {
if cfg.Logging.File != nil { 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.Name = cfg.Logging.File.Name
logCfg.MaxSizeKB = cfg.Logging.File.MaxSizeMB * 1000 logCfg.MaxSizeKB = cfg.Logging.File.MaxSizeMB * 1000
logCfg.MaxTotalSizeKB = cfg.Logging.File.MaxTotalSizeMB * 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/plugin"
"github.com/lixenwraith/logwisp/internal/testutil" "github.com/lixenwraith/logwisp/internal/testutil"
"github.com/lixenwraith/logwisp/internal/tlsx" "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, // 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 // 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 // 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 // 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 replace its format
- `--source SPEC`, `--sink SPEC` and `--filter SPEC` add a stage, repeatable - `--source SPEC`, `--sink SPEC` and `--filter SPEC` add a stage, repeatable
- `--format SPEC`, `--rate-limit SPEC` and `--heartbeat SPEC`, once per pipeline - `--format SPEC`, `--rate-limit SPEC` and `--heartbeat SPEC`, once per pipeline
- a pipeline without `--source` reads stdin (console source `stdin`), one - a pipeline without `--source` reads stdin with a console source, one
without `--sink` writes stdout (console sink `stdout`) without `--sink` writes stdout with a console sink
- stdin has one reader, so the whole configuration holds at most one console - 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` and pipeline "a" has it`
A SPEC is `TYPE:key=value,...`, or `key=value,...` without a TYPE: 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. ones.
- `path`: a file, a directory (its files) or a glob, and this host decides - `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 which: an existing directory, or a path ending in `/`, is followed whole,
name or glob in its directory; for a directory not here, as in a `--dump` any other path is a file name or glob in its directory; for a directory
for another host, write `DIR/*`. Without one, `serve` and `edge` read stdin 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 - `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 - `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`, 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 ## Built-in Defaults
Without a configuration file and without pipeline flags or variables, lw runs Without a configuration file and without pipeline flags or variables, lw runs
one built-in pipeline, `pipe`: console source `stdin`, `raw` format, console one built-in pipeline, `pipe`: a console source, `raw` format, a console
sink `stdout`, no filter, no rate limit. lw is then a Unix filter, like `cat`: sink, no filter, no rate limit. lw is then a Unix filter, like `cat`:
```bash ```bash
lw < app.log > copy.log 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 - 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 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 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 no part) what they got. `v` switches between all (what the flow dropped
was dropped there, with no restart. lw's own log shows from info up at dimmed), what passed and what was dropped there, with no restart; the
every point; `s` also offers it alone, debug too. 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 - 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, 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 docker, journald, ECS, GCP, logfmt, or text after an RFC3339 stamp and
screen drops columns; a dropped entry is marked; a rune a terminal would a level name. A narrow screen drops columns; a rune a terminal would act
act on or draw wider than a cell shows as a placeholder. on or draw wider than a cell shows as a placeholder.
- Keys: arrows or `jk` move (up pauses, down onto the newest follows), - 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 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 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` 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 trace to error and up, `0` all; `s` chooses the point; `c` clears; `q`,
stage that dropped the row; `c` clears; `q`, Ctrl-C, `?`, `r`, `o`, `[` Ctrl-C, `?`, `r`, `o`, `[` and `]` act as on the diagram.
and `]` act as on the diagram.
- The detail, beside the rows from 160 columns and under them otherwise: - The detail, beside the rows from 160 columns and under them otherwise:
time and the stamp as written, level, origin, the verdict with a time and the stamp as written, level, origin (the source's type, and its
filter's patterns, the layout read, message, fields, and the payload as id when another), the flow (passed every stage, or the stage that
the sinks receive it, with escapes for what a cell cannot show. 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 - 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 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 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. end of input.
This built-in pipeline, This built-in pipeline,
.Dq pipe , .Dq pipe ,
joins the console source joins a console source to a console sink in the raw format, without a filter
.Dq stdin or rate limit; a configuration file that
to the console sink
.Dq stdout
in the raw format, without a filter or rate limit; a configuration file that
defines no pipelines runs it too. defines no pipelines runs it too.
Each line is written with one newline, whether it ended in a newline, a 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 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 runs them in place, for real: listeners bind, dialers connect, file sinks
write. write.
A live view then shows each entry with its fate, passed or the flow stage A live view then shows each entry with its fate, passed or the flow stage
that dropped it, and the log of that dropped it (dropped ones dimmed when all show), and the log of
.Nm ; .Nm
a console sink writing to the terminal runs as a null sink there, and a 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. console source needs piped input.
The screen draws on the controlling terminal, so standard input and output The screen draws on the controlling terminal, so standard input and output
stay data: stay data:
@@ -330,10 +332,8 @@ the configuration file.
They then replace the file's pipelines and the built-in default; every other They then replace the file's pipelines and the built-in default; every other
key keeps its usual precedence. key keeps its usual precedence.
A reload rereads the file but keeps these pipelines. A reload rereads the file but keeps these pipelines.
A pipeline without a source reads standard input with the console source A pipeline without a source reads standard input with a console source;
.Dq stdin ; one without a sink writes standard output with a console sink.
one without a sink writes standard output with the console sink
.Dq stdout .
Standard input has one reader: a second console source anywhere in the Standard input has one reader: a second console source anywhere in the
configuration fails validation. configuration fails validation.
.Bl -tag -width Ds .Bl -tag -width Ds
@@ -417,11 +417,12 @@ is
A A
.Cm path .Cm path
is a file, a directory, whose files are followed, or a glob, and this host 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 decides which: an existing directory, or a path ending in a slash, is
file name or glob in its directory; for a directory not here, as in a followed whole, any other path is a file name or glob in its directory; for a
directory not here, as in a
.Fl \-dump .Fl \-dump
for another host, write for another host, write
.Ql dir/* . .Ql dir/ .
Without one, Without one,
.Cm serve .Cm serve
and 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 ```toml
[[pipelines.plugin_sinks]] [[pipelines.plugin_sinks]]
id = "stdout" id = "console"
type = "console" type = "console"
[pipelines.plugin_sinks.config] [pipelines.plugin_sinks.config]
target = "stdout" target = "stdout"
+4 -2
View File
@@ -29,7 +29,9 @@ from = "end"
Options, each as type and default: 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. - `pattern` (string, `*`): glob over filenames; `*` and `?` only.
- `check_interval_ms` (int, `100`): directory rescan interval; minimum `10`. - `check_interval_ms` (int, `100`): directory rescan interval; minimum `10`.
An open file is polled every 100 ms whatever this is. 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 ```toml
[[pipelines.plugin_sources]] [[pipelines.plugin_sources]]
id = "stdin" id = "console"
type = "console" type = "console"
[pipelines.plugin_sources.config] [pipelines.plugin_sources.config]
buffer_size = 1000 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 { func pipeDefault(name string) PipelineConfig {
return PipelineConfig{ return PipelineConfig{
Name: name, Name: name,
Flow: &FlowConfig{Format: &FormatConfig{Type: "raw"}}, Flow: &FlowConfig{Format: &FormatConfig{Type: "raw"}},
PluginSources: []PluginSourceConfig{{ID: "stdin", Type: "console", Config: map[string]any{}}}, PluginSources: []PluginSourceConfig{{ID: FreeID("console", nil), Type: "console", Config: map[string]any{}}},
PluginSinks: []PluginSinkConfig{{ID: "stdout", 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, // 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 // or a file pattern; a path ending in '/' is a directory without asking. Off
// path ending in '/' is then a directory, one with a glob a pattern, and any // the target host it is the user's Answers, or nil: a path with a glob is then
// other fails with ErrPathKind. // a pattern, and any other fails with ErrPathKind.
type IsDir func(path string) (bool, error) type IsDir func(path string) (bool, error)
var ErrPathKind error = &KeyError{"path", errors.New("a directory or a file pattern?")} 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) { func HostIsDir(path string) (bool, error) {
fi, err := os.Stat(path) fi, err := os.Stat(ExpandHome(path))
return err == nil && fi.IsDir(), nil return err == nil && fi.IsDir(), nil
} }
@@ -81,8 +82,8 @@ func Answers(dirs map[string]bool) IsDir {
if dir, ok := dirs[path]; ok { if dir, ok := dirs[path]; ok {
return dir, nil return dir, nil
} }
if dir := strings.HasSuffix(path, "/"); dir || strings.ContainsAny(path, `*?[\`) { if strings.ContainsAny(path, `*?[\`) {
return dir, nil return false, nil
} }
return false, ErrPathKind return false, ErrPathKind
} }
@@ -353,7 +354,10 @@ func pathOrStdin(v map[string]string, isDir IsDir) ([]PluginSourceConfig, error)
if isDir == nil { if isDir == nil {
isDir = Answers(nil) isDir = Answers(nil)
} }
dir, err := isDir(path) dir, err := strings.HasSuffix(path, "/"), error(nil)
if !dir {
dir, err = isDir(path)
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
+16 -8
View File
@@ -2,6 +2,8 @@ package config
import ( import (
"errors" "errors"
"os"
"path/filepath"
"reflect" "reflect"
"slices" "slices"
"strings" "strings"
@@ -19,14 +21,14 @@ func TestPresetStartsAndNamesItsPipeline(t *testing.T) {
Flow: &FlowConfig{Format: &FormatConfig{Type: "json"}}, Flow: &FlowConfig{Format: &FormatConfig{Type: "json"}},
PluginSources: []PluginSourceConfig{{ID: "file", Type: "file", Config: map[string]any{"directory": "/var/log", "pattern": "app.log", "from": "end"}}}, PluginSources: []PluginSourceConfig{{ID: "file", Type: "file", Config: map[string]any{"directory": "/var/log", "pattern": "app.log", "from": "end"}}},
PluginSinks: []PluginSinkConfig{ 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{}}, {ID: "null", Type: "null", Config: map[string]any{}},
}, },
}, { }, {
Name: "mine", Name: "mine",
Flow: &FlowConfig{Format: &FormatConfig{Type: "raw"}}, Flow: &FlowConfig{Format: &FormatConfig{Type: "raw"}},
PluginSources: []PluginSourceConfig{{ID: "stdin", Type: "console", Config: map[string]any{}}}, PluginSources: []PluginSourceConfig{{ID: "console", Type: "console", Config: map[string]any{}}},
PluginSinks: []PluginSinkConfig{{ID: "stdout", Type: "console", Config: map[string]any{"color": "auto"}}}, PluginSinks: []PluginSinkConfig{{ID: "console", Type: "console", Config: map[string]any{"color": "auto"}}},
}} }}
if !reflect.DeepEqual(got, want) { if !reflect.DeepEqual(got, want) {
t.Fatalf("pipelines:\n got %+v\nwant %+v", 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, // A preset path ending in '/' is a directory, there yet or not; any other is
// and off the target host the user's answer, a single file too; without one // what isDir answers, a directory named with brackets too: this host's,
// it is a directory when it ends in '/', a pattern when it holds a glob, and // reading ~ as home, or off it the user's answer, a single file too; without
// otherwise ErrPathKind asks // one it is a pattern when it holds a glob, and otherwise ErrPathKind asks
func TestPresetPathKind(t *testing.T) { 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 } yes := func(string) (bool, error) { return true, nil }
file := Answers(map[string]bool{"/srv/app.log": false}) file := Answers(map[string]bool{"/srv/app.log": false})
for _, c := range []struct { for _, c := range []struct {
@@ -134,7 +141,8 @@ func TestPresetPathKind(t *testing.T) {
isDir IsDir isDir IsDir
want string 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/", 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) p, err := ExpandPreset("tail", map[string]string{"path": c.path}, c.isDir)
if c.want == "" { if c.want == "" {
if !errors.Is(err, ErrPathKind) { if !errors.Is(err, ErrPathKind) {
+30 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"maps" "maps"
"math" "math"
"os"
"reflect" "reflect"
"slices" "slices"
"strconv" "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, // 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 // 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) { func Decode[T any](role, typ string, m map[string]any) (*T, error) {
v, err := decode(role, typ, m) v, err := decode(role, typ, m)
if err != nil { 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 { if err := Settle(v.Interface()); err != nil {
return nil, err return nil, err
} }
expandPaths(v.Elem())
if n, ok := v.Interface().(networked); ok { if n, ok := v.Interface().(networked); ok {
if err := p.checkNetwork(n); err != nil { if err := p.checkNetwork(n); err != nil {
return nil, err 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 // Fill gives a table its defaults and checks nothing: Settle's first step
func Fill(v any) { settle(reflect.ValueOf(v).Elem()) } 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 // 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 // stays on unless set off; it does so in the tables m sets
func preset(v reflect.Value, m map[string]any) { func preset(v reflect.Value, m map[string]any) {
+32
View File
@@ -2,6 +2,7 @@ package config
import ( import (
"math" "math"
"os"
"reflect" "reflect"
"slices" "slices"
"strings" "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 // 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) { func TestSpecPipelinesDefaultToStdio(t *testing.T) {
isolateConfig(t) isolateConfig(t)
got := loadPipelines(t, "filter", "include:patterns=ERROR") 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" || 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 != "stdout" { len(p.PluginSinks) != 1 || p.PluginSinks[0].Type != "console" || p.PluginSinks[0].ID != "console" {
t.Fatalf("pipeline: %+v", p) t.Fatalf("pipeline: %+v", p)
} }
_, err := Load(Args{Specs: specs("pipeline", "a", "sink", "null", "pipeline", "b", "sink", "null")}) _, 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, // 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 // lw's own log, pino, zap, slog, logfmt, docker, journald, ECS, GCP, plain
// one set of roles, and names the runes a terminal would act on. It links no // text) into one set of roles, and names the runes a terminal would act on. It
// terminal code, so a viewer, a sink and the configuration page can share it. // links no terminal code, so a viewer, a sink and the configuration page can
// share it.
package logline package logline
import ( import (
"bytes" "bytes"
"encoding/hex" "encoding/hex"
"math" "math"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -70,8 +72,8 @@ type member struct {
} }
// Read finds e's roles in its message, a JSON record (also one inside such a // 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; // record's message) or text, and in its fields; a role the line does not name
// a role the line does not name is the entry's. // is the entry's.
func Read(e core.LogEntry) Line { func Read(e core.LogEntry) Line {
l, at, ok := record(strings.TrimSpace(e.Message), "") l, at, ok := record(strings.TrimSpace(e.Message), "")
if ok { if ok {
@@ -79,12 +81,7 @@ func Read(e core.LogEntry) Line {
l = merge(l, in) l = merge(l, in)
} }
} else { } else {
l = Line{Message: e.Message, Layout: "text"} l = text(e.Message)
if stamp, rest, ok := strings.Cut(e.Message, " "); ok {
if at, ok := readStamp(stamp); ok {
l.Time, l.Stamp, l.Message, l.Layout = at, stamp, strings.TrimLeft(rest, " "), "text, stamped"
}
}
} }
if f := strings.TrimSpace(string(e.Fields)); f != "" { if f := strings.TrimSpace(string(e.Fields)); f != "" {
_, kind, _ := scanValue(f, 0) _, 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 { if at = l.take(ms); at == "" && msg >= 0 {
l.Stamp = unquote(m.val, m.kind)
l.Time, _ = readTime(l.Stamp)
}
if m := find(ms, roleLevel); m != nil {
l.Level = level(m.key, unquote(m.val, m.kind))
}
if m := find(ms, roleTag); m != nil {
l.Tag = unquote(m.val, m.kind)
}
if m := find(ms, roleMsg); m != nil {
l.Message, at = unquote(m.val, m.kind), m.key
} else if msg >= 0 {
ms[msg].used = true ms[msg].used = true
l.Message, at = unquote(ms[msg].val, kStr), envAt 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 { if !whole {
l.Layout += ", cut short" l.Layout += ", cut short"
} }
if at != "" { l.Layout += messageIn(at)
l.Layout += ", message in " + at
} else {
l.Layout += ", no message"
}
return l, at, true 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 // 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 // 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) { 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 _, key := range roles[r] {
for i := range ms { for i := range ms {
m := &ms[i] 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 m.used = true
return m 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 // 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 | // 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 // 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) { func TestReadFindsTheRolesWhereTheirWriterPutsThem(t *testing.T) {
for _, c := range []struct { for _, c := range []struct {
in core.LogEntry 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"`}, `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}`), {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"`}, `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 // 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"}`), {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"`}, `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}}}}}}}}}}`), {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}"`}, `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`), {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" | `}, `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 INFO a stamp without a zone is UTC`), {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 | INFO | app.log | "INFO 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 // what only looks like a stamp is text: no day is invented
{arrived(`2026-13-45T99:99:99Z ERROR x`), {arrived(`2026-13-45T99:99:99Z ERROR x`),
`text | =- | ERROR | app.log | "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 package logline
import "encoding/json" import (
"encoding/json"
"strconv"
)
// JSON token kinds // JSON token kinds, and kText for a logfmt value, unquoted already
const ( const (
kNone byte = 0 kNone byte = 0
kStr byte = 's' kStr byte = 's'
@@ -11,6 +14,7 @@ const (
kNull byte = 'z' kNull byte = 'z'
kObj byte = 'o' kObj byte = 'o'
kArr byte = 'a' kArr byte = 'a'
kText byte = 't'
) )
func skipSpace(s string, i int) int { 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 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 // 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 { func unquote(tok string, kind byte) string {
if kind != kStr || len(tok) < 2 { if kind != kStr || len(tok) < 2 {
return tok return tok
+29 -13
View File
@@ -44,6 +44,7 @@ type FileSource struct {
// already reported, touched only by monitorLoop. // already reported, touched only by monitorLoop.
own []os.FileInfo own []os.FileInfo
ownSkipped map[string]bool ownSkipped map[string]bool
scanErr string // the last scan's error, "" once it read; monitorLoop's
// Application // Application
subscribers []chan core.LogEntry subscribers []chan core.LogEntry
@@ -138,7 +139,7 @@ func (fs *FileSource) Start() error {
go fs.monitorLoop() go fs.monitorLoop()
fs.logger.Info("msg", "File source started", fs.logger.Info("msg", "File source started",
"component", "File_source", "component", "file_source",
"path", fs.config.Directory, "path", fs.config.Directory,
"pattern", fs.config.Pattern, "pattern", fs.config.Pattern,
"check_interval_ms", fs.config.CheckIntervalMS) "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. // checkTargets finds matching files and ensures watchers are running for them.
func (fs *FileSource) checkTargets() { func (fs *FileSource) checkTargets() {
files, err := fs.scanFile() files, err := fs.scanFile()
fs.noteScan(err)
if err != nil { 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 return
} }
@@ -243,6 +240,32 @@ func (fs *FileSource) checkTargets() {
fs.cleanupWatchers() 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. // ensureWatcher creates and starts a new file watcher if one doesn't exist for the given path.
func (fs *FileSource) ensureWatcher(path string) { func (fs *FileSource) ensureWatcher(path string) {
fs.mu.Lock() fs.mu.Lock()
@@ -269,16 +292,9 @@ func (fs *FileSource) ensureWatcher(path string) {
go func() { go func() {
defer fs.wg.Done() defer fs.wg.Done()
if err := w.watch(fs.ctx); err != nil { if err := w.watch(fs.ctx); err != nil {
if errors.Is(err, context.Canceled) {
fs.logger.Debug("msg", "Watcher cancelled", fs.logger.Debug("msg", "Watcher cancelled",
"component", "file_source", "component", "file_source",
"path", path) "path", path)
} else {
fs.logger.Error("msg", "Watcher failed",
"component", "file_source",
"path", path,
"error", err)
}
} }
fs.removeWatcher(path, w) fs.removeWatcher(path, w)
+85
View File
@@ -1,7 +1,9 @@
package file package file
import ( import (
"bytes"
"context" "context"
"errors"
"os" "os"
"path/filepath" "path/filepath"
"slices" "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) { func TestStoppedWatcherReturnsNormally(t *testing.T) {
path := filepath.Join(t.TempDir(), "session.jsonl") path := filepath.Join(t.TempDir(), "session.jsonl")
testutil.WriteFile(t, path, "") testutil.WriteFile(t, path, "")
+16 -21
View File
@@ -47,6 +47,7 @@ type fileWatcher struct {
entriesRead atomic.Uint64 entriesRead atomic.Uint64
lastReadTime atomic.Value // time.Time lastReadTime atomic.Value // time.Time
logger *log.Logger logger *log.Logger
failed string // the last check's error, "" for none; watch's
} }
// newFileWatcher creates a new watcher for a specific file path. // 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 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 { 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) ticker := time.NewTicker(core.FileWatcherPollInterval)
defer ticker.Stop() defer ticker.Stop()
check := w.initPosition
for { for {
err := check()
if err == nil {
check = w.checkFile
}
w.note(err)
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
@@ -84,11 +88,15 @@ func (w *fileWatcher) watch(ctx context.Context) error {
if w.isStopped() { if w.isStopped() {
return nil 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)
} }
} }
@@ -127,20 +135,12 @@ func (w *fileWatcher) checkFile() error {
// File doesn't exist yet, keep watching // File doesn't exist yet, keep watching
return nil return nil
} }
w.logger.Error("msg", "Failed to open file for checking",
"component", "file_watcher",
"directory", w.directory,
"error", err)
return err return err
} }
defer file.Close() defer file.Close()
info, err := file.Stat() info, err := file.Stat()
if err != nil { if err != nil {
w.logger.Error("msg", "Failed to stat file",
"component", "file_watcher",
"directory", w.directory,
"error", err)
return err return err
} }
@@ -269,11 +269,6 @@ func (w *fileWatcher) checkFile() error {
} }
if err := scanner.Err(); err != nil { 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 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)) w = max(w, ui.RuneLen(l))
} }
in := a.frame(r, min(w, inside(r.W)), len(lines), n.title) in := a.frame(r, min(w, inside(r.W)), len(lines), n.title)
rows := in.H rows := a.window(in, &n.view, len(lines))
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)
for y := range min(rows, len(lines)-n.view.Offset) { for y := range min(rows, len(lines)-n.view.Offset) {
in.TextStyled(0, y, lines[n.view.Offset+y], a.th.Text) 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) { func (n *notice) handle(a *app, ev terminal.Event) {
switch { switch {
case ev.Type != terminal.EventKey: case ev.Type != terminal.EventKey, scroll(&n.view, ev):
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()
default: default:
if a.dialog = nil; n.closed != nil { if a.dialog = nil; n.closed != nil {
n.closed(a) 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 // confirm asks a yes or no question: y or n answers, as does Enter on the
// answer the arrows chose; Esc is no // answer the arrows chose; Esc is no
type confirm struct { 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"}, {"arrows, jk", "move; up pauses following, down to the newest follows"},
{"g G", "the oldest row; the newest, following; f follows too"}, {"g G", "the oldest row; the newest, following; f follows too"},
{f.enter, "the row's detail; J K scroll it"}, {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"}, {"/", "only the rows matching a pattern; lower case ignores case"},
{"1-5 0", "trace, debug, info, warn or error and up; all"}, {"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"}, {"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"}, {"c", "clear the rows"},
{"t esc", "back to the diagram; the pipelines keep running"}, {"t esc", "back to the diagram; the pipelines keep running"},
{"q", "stop the pipelines and quit"}, {"q", "stop the pipelines and quit"},
+87 -50
View File
@@ -3,6 +3,7 @@ package tui
import ( import (
"cmp" "cmp"
"fmt" "fmt"
"maps"
"strconv" "strconv"
"strings" "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 key config.Key // its Name is the dotted path in the node's options, "" on a filter's rows
depth int depth int
group string // a group's title, "" for a value 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 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
// 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)))
} }
// 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 { 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) 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 { for i, f := range filters {
fn := compose.Node{Role: "filters", Index: i} fn := compose.Node{Role: "filters", Index: i}
out = append(out, line{node: fn, group: fmt.Sprintf("%d %s", i+1, f.Type), item: -1}) 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) 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 { 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)] { if a.insp.open[a.foldKey(n, k.Name)] {
items := a.items(n, k.Name) items := a.items(n, k.Name)
for i := range len(items) + 1 { 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: default:
@@ -145,23 +144,28 @@ func (a *app) drawInspector(r ui.Region) {
help = clip(ui.WrapText(text, r.W-4), 2) help = clip(ui.WrapText(text, r.W-4), 2)
} }
rows := r.H - 1 - len(help) rows := r.H - 1 - len(help)
cols, perCol := 1, rows cols, perCol, second := 1, rows, 0
if half := (len(lines) + 1) / 2; r.W >= 80 && half <= rows { if r.W >= 80 {
cols, perCol = 2, half second = split(lines, rows)
} }
a.insp.scroll = ui.AdjustScroll(a.insp.cursor, ui.ClampScroll(a.insp.scroll, rows, len(lines)), rows, len(lines)) a.insp.scroll = ui.AdjustScroll(a.insp.cursor, ui.ClampScroll(a.insp.scroll, rows, len(lines)), rows, len(lines))
if cols == 2 { if second > 0 {
a.insp.scroll = 0 cols, perCol, a.insp.scroll = 2, max(second, len(lines)-second), 0
} }
colW, labelW := r.W/cols, 0 colW, labelW := r.W/cols, 0
for _, l := range lines { for _, l := range lines {
labelW = max(labelW, 2*l.depth+ui.RuneLen(label(l))+2) labelW = max(labelW, 2*l.depth+ui.RuneLen(label(l))+2)
} }
labelW = min(labelW, colW/2) labelW = min(labelW, colW/2)
for i := a.insp.scroll; i < len(lines) && i-a.insp.scroll < perCol*cols; i++ { for i := a.insp.scroll; i < len(lines); i++ {
at := i - a.insp.scroll x, y := 0, i-a.insp.scroll
row := r.Sub(at/perCol*colW, 1+at%perCol, colW-1, 1) if second > 0 && i >= second {
a.drawLine(row, lines[i], labelW, a.inspect && i == a.insp.cursor) 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 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 { 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 { func (a *app) nodeTitle(n compose.Node) string {
switch n.Role { switch n.Role {
case "source", "sink": case "source", "sink":
@@ -180,29 +197,34 @@ func (a *app) nodeTitle(n compose.Node) string {
return n.Role + " · flow" 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) { func (a *app) help(l line) (string, ui.Style) {
for _, p := range compose.Problems(a.problem) { 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 { 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 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 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 help += "; unset: " + d
} }
return help, a.th.Muted 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 { func label(l line) string {
if l.item >= 0 { if l.item >= 0 && l.key.Name != "" {
return strconv.Itoa(l.item + 1) return strconv.Itoa(l.item + 1)
} }
return l.key.Name[strings.LastIndex(l.key.Name, ".")+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 th := a.th
r = r.Sub(2*l.depth, 0, r.W-2*l.depth, 1) r = r.Sub(2*l.depth, 0, r.W-2*l.depth, 1)
k := l.key k := l.key
if l.group != "" { typing := focused && a.insp.field != nil
open := a.insp.open[a.foldKey(l.node, k.Name)] 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 == "" { 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 return
} }
v, _ := r.Field(0, labelW-2*l.depth, ui.Field{Label: label(l), Required: k.Required && l.item < 0}, focused, th) 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) items := a.items(l.node, k.Name)
cur := a.value(l.node, k.Name) cur := a.value(l.node, k.Name)
switch { switch {
case focused && a.insp.field != nil: case typing:
v.TextInput(a.insp.field, placeholder(k), true, th) v.TextInput(a.insp.field, placeholder(k), true, th)
case a.adds(l):
v.TextStyled(0, 0, "+ add", th.Muted)
case l.item >= 0: case l.item >= 0:
v.TextStyled(0, 0, ui.Truncate(items[l.item], v.W), th.Text) v.TextStyled(0, 0, ui.Truncate(items[l.item], v.W), th.Text)
case len(k.Enum) > 0: 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) a.insp.cursor = max(0, a.insp.cursor-1)
case is(ev, terminal.KeyDown, "j"): case is(ev, terminal.KeyDown, "j"):
a.insp.cursor = min(len(lines)-1, a.insp.cursor+1) 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() a.dialog = a.addMenu()
case l.group != "" && (enter || space || left || right): case l.group != "" && (enter || space || left || right):
fold := a.foldKey(l.node, k.Name) 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 a.insp.open[fold] = (enter || space) && !a.insp.open[fold] || right
case l.group != "" && k.Name == "" && is(ev, terminal.KeyNone, "JK"): case l.group != "" && k.Name == "" && is(ev, terminal.KeyNone, "JK"):
a.moveFilter(l.node.Index, map[rune]int{'J': 1, 'K': -1}[ev.Rune]) 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))) a.edit(a.comp.Set(a.pi, l.node, k.Name, strconv.FormatBool(right || !left && !on)))
case enter: case enter:
text := show(cmp.Or(cur, any(""))) text := show(cmp.Or(cur, any("")))
if items := a.items(l.node, k.Name); l.item >= 0 { if l.item >= 0 {
text = "" text = ""
if l.item < len(items) { if !l.add {
text = items[l.item] text = a.items(l.node, k.Name)[l.item]
} }
} }
a.insp.field = ui.NewTextFieldState(text) a.insp.field = ui.NewTextFieldState(text)
@@ -362,7 +388,7 @@ func (a *app) typeKey(ev terminal.Event, l line) {
value := a.insp.field.Value() value := a.insp.field.Value()
if a.commit(l, value) { if a.commit(l, value) {
a.insp.field = nil 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 a.insp.cursor++ // stays on the row that adds one
} }
focus := ui.Focus{Index: a.insp.cursor, Len: len(a.lines())} 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) items := a.items(l.node, k.Name)
switch { switch {
case l.item == len(items) && value == "": case l.add && value == "":
return true return true
case l.item == len(items): case l.add:
items = append(items, value) items = append(items, value)
case value == "": case value == "":
items = append(items[:l.item], items[l.item+1:]...) 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 // value, which returns to its default
func (a *app) clear(l line) { func (a *app) clear(l line) {
switch { switch {
case a.adds(l): case l.add:
case l.group != "" && l.key.Name == "": case l.group != "" && l.key.Name == "":
a.edit(a.comp.Remove(a.pi, l.node)) a.edit(a.comp.Remove(a.pi, l.node))
case l.item >= 0: 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) { 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)) { 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 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)) from, to := a.foldKey(compose.Node{Role: "filters", Index: i}, ""), a.foldKey(compose.Node{Role: "filters", Index: i + by}, "")
a.insp.open[from], a.insp.open[to] = a.insp.open[to], a.insp.open[from] 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() { for j, l := range a.lines() {
if l.group != "" && l.key.Name == "" && l.node.Index == i+by { if l.group != "" && l.key.Name == "" && l.node.Index == i+by {
a.insp.cursor = j 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 // choose puts the canvas cursor on a node, a filter on the filters stage
func (a *app) choose(n compose.Node) { func (a *app) choose(n compose.Node) {
if n.Role == "filters" { 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"} n = compose.Node{Role: "filters"}
} }
for col := range columns { 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 // Space turns a flow stage on with its defaults and off again; a stage with
// an enabled key switches that // an enabled key switches that
func TestSpaceSwitchesAStage(t *testing.T) { 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 // J and K move a filter, its folds and the cursor with it, the filter it
// either end as j and k do // passes taking its place with its own folds, and stop at either end as j and
func TestFiltersMoveWithTheirFold(t *testing.T) { // k do
func TestFiltersMoveWithTheirFolds(t *testing.T) {
c := pipeline(t, []string{"null"}, []string{"null"}) c := pipeline(t, []string{"null"}, []string{"null"})
for _, typ := range []string{"include", "exclude"} { for _, typ := range []string{"include", "exclude"} {
if _, err := c.Add(0, "filters", typ); err != nil { if _, err := c.Add(0, "filters", typ); err != nil {
@@ -403,11 +510,15 @@ func TestFiltersMoveWithTheirFold(t *testing.T) {
} }
} }
a := mono(c) 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") press(a, terminal.KeyEnter, "J", "J")
types := []config.FilterType{a.pipeline().Flow.Filters[0].Type, a.pipeline().Flow.Filters[1].Type} types := []config.FilterType{a.pipeline().Flow.Filters[0].Type, a.pipeline().Flow.Filters[1].Type}
l := a.lines()[a.insp.cursor] 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) t.Fatalf("types %v, cursor on %+v, open %v, status %q", types, l, a.insp.open, a.status)
} }
} }
+121 -61
View File
@@ -106,9 +106,9 @@ const (
// point is where on a pipeline the view looks, as the canvas cursor picks // 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 // 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 // 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 { type point struct {
pipe, name string pipe, name, all string
one, own bool one, own bool
source string source string
reach int reach int
@@ -116,11 +116,11 @@ type point struct {
} }
// lwShown is the lowest level of lw's own log a pipeline's point shows, so // lwShown is the lowest level of lw's own log a pipeline's point shows, so
// its debug records do not bury the entries // its startup records do not bury the entries
var lwShown = slices.IndexFunc(core.Levels, func(l core.Level) bool { return l.Level == "INFO" }) 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 // 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 // heartbeat only at the sinks' and its own
func (p point) shows(r *row, mode int) bool { func (p point) shows(r *row, mode int) bool {
switch { 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 // point is where the canvas cursor looks: a source, a flow stage or the
// filter the inspector is on, the heartbeat, or the sinks // filter the inspector is on, the heartbeat, or the sinks
func (a *app) point() point { 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 { switch {
case a.view.own: case a.view.own:
return point{name: "lw's own log", own: true, reach: passed, lo: passed, hi: passed - 1} 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 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) { if m := l[min(a.insp.cursor, len(l)-1)].node; m.Index < len(a.pipeline().Flow.Filters) {
r := rank("filters", m.Index) 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 { switch {
case !ok || n.Role == "sink": case !ok || n.Role == "sink":
case n.Role == "source": 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": 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: 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 return p
} }
@@ -224,6 +224,9 @@ func (r *row) own() int {
return n 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 // 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}) } 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] 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() { func (v *view) clear() {
v.rows, v.shown, v.bytes, v.cursor, v.scroll, v.paused = nil, nil, 0, 0, 0, false 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() a.dialog = a.pointMenu()
case is(ev, terminal.KeyNone, "c"): case is(ev, terminal.KeyNone, "c"):
v.clear() v.clear()
case is(ev, terminal.KeyNone, "i"): case is(ev, terminal.KeyNone, "i") && v.focusedEntry() != nil:
a.inspectDrop() a.dialog = &entry{row: *v.focusedEntry()}
case is(ev, terminal.KeyNone, "e"):
a.editDrop(v.focusedEntry())
case is(ev, terminal.KeyEscape, "") && v.detail: case is(ev, terminal.KeyEscape, "") && v.detail:
v.detail = false v.detail = false
case is(ev, terminal.KeyEscape, "t"): 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 // editDrop opens the inspector on the stage that dropped w
func (a *app) inspectDrop() { func (a *app) editDrop(w *row) {
w := a.view.focused()
pi := -1 pi := -1
if w != nil { 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 { switch {
case w == nil || pi < 0: 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 return
case w.stage == "": case !w.dropped():
a.note = "it passed every stage" a.note = "it passed every stage"
return return
} }
@@ -436,6 +448,11 @@ func (a *app) inspectDrop() {
a.inspectAt(func(l line) bool { return l.node == n }) 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 // pointMenu picks where the view looks, moving the canvas cursor there, or
// lw's own log while it reaches the screen: s // lw's own log while it reaches the screen: s
func (a *app) pointMenu() dialog { func (a *app) pointMenu() dialog {
@@ -488,24 +505,41 @@ func (a *app) pointMenu() dialog {
func (a *app) drawView(r ui.Region) { func (a *app) drawView(r ui.Region) {
v, th := &a.view, a.th v, th := &a.view, a.th
v.settle() v.settle()
title := v.built.at.name at := v.built.at
if !v.built.at.own { 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] title += " · " + verdicts[v.mode]
} }
if len(a.comp.Pipelines) > 1 && v.built.at.pipe != "" { if len(a.comp.Pipelines) > 1 && at.pipe != "" {
title = v.built.at.pipe + " · " + title title = at.pipe + " · " + title
} }
if v.floor > 0 { if v.floor > 0 {
title += " · " + core.Levels[v.floor-1].Level + " up" rest += " · " + core.Levels[v.floor-1].Level + " up"
} }
if v.pat.src != "" { if v.pat.src != "" {
title += " · /" + v.pat.src rest += " · /" + v.pat.src
} }
hint := fmt.Sprintf("following · %d rows", len(v.shown)) hint := fmt.Sprintf("following · %d rows", len(v.shown))
if v.paused { if v.paused {
hint = fmt.Sprintf("paused · %d newer", len(v.shown)-1-v.cursor) 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 { if a.viewing {
r.TextStyled(0, 0, string(th.Glyphs.Focus), th.Accent) 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 return tw, lw, gw
} }
// drawRow draws a row: the cursor's pointer, a mark when the flow dropped // drawRow draws a row: the cursor's pointer, its time, level and tag, then
// it, its time, level and tag, then its message and, muted, its fields. On // its message and, muted, its fields; all muted when the flow dropped it and
// the cursor row each role keeps its color on the bar, but for a reverse // the view shows all. On the cursor row each role keeps its color on the
// bar, which ui.Style.On draws in one style. // 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) { func (a *app) drawRow(r ui.Region, w *row, tw, lw, gw int, cursor bool) {
th, f := a.th, a.font 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 { if cursor {
r.FillStyle(th.Text.On(th.Selected)) 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), th.Accent.On(th.Selected))
r.TextStyled(0, 0, string(th.Glyphs.Pointer), on(th.Accent))
} }
if w.mark != "" { if w.mark != "" {
dash := "─" 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)) r.TextStyled(2, 0, text+strings.Repeat(dash, max(0, r.W-2-ui.RuneLen(text))), on(th.Border))
return return
} }
if w.rank != passed { x := 1
r.TextStyled(1, 0, string([]rune(f.invalid)[0]), on(th.Error))
}
x := 3 // a space after the mark, so it is not read as the time's first letter
if tw > 0 { if tw > 0 {
r.TextStyled(x, 0, w.at.Local().Format("15:04:05.000")[:tw], on(th.Muted)) r.TextStyled(x, 0, w.at.Local().Format("15:04:05.000")[:tw], on(th.Muted))
x += tw + 1 x += tw + 1
@@ -617,30 +656,37 @@ type dline struct {
style ui.Style 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) { func (a *app) drawDetail(r ui.Region) {
v, th := &a.view, a.th v := &a.view
w := v.focused() w := v.focusedEntry()
hint := "J K scroll" hint := "J K scroll"
if w != nil && w.pipe != "" && w.stage != "" { switch {
hint = "i inspects it " + hint 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) r.Rule(0, "detail", hint, a.th)
if w == nil { if w != nil {
return v.dscroll = a.drawLines(r.Sub(0, 1, r.W, r.H-1), a.details(w, r.W-2), v.dscroll)
}
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)
} }
} }
// 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 // 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 // flow, the layout logline found, its message and fields, then its payload
// payload as the sinks receive it; what a cell cannot show is an escape // as the sinks receive it; what a cell cannot show is an escape
func (a *app) details(w *row, width int) []dline { func (a *app) details(w *row, width int) []dline {
th := a.th th := a.th
var out []dline var out []dline
@@ -659,7 +705,7 @@ func (a *app) details(w *row, width int) []dline {
put("level", 8, w.Level, th.Text) put("level", 8, w.Level, th.Text)
put("from", 8, a.origin(w), th.Text) put("from", 8, a.origin(w), th.Text)
if w.pipe != "" { 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("layout", 8, w.Layout, th.Muted)
put("message", 8, w.Message, th.Text) put("message", 8, w.Message, th.Text)
@@ -677,18 +723,32 @@ func (a *app) details(w *row, width int) []dline {
return out return out
} }
// origin names where a row came from: lw's own log, or a pipeline's source // origin names where a row came from: lw's own log, or a pipeline's
// or heartbeat, and the tag the line names when it is another // 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 { func (a *app) origin(w *row) string {
switch { switch {
case w.pipe == "": case w.pipe == "":
return "lw's own log" return "lw's own log"
case w.source == "": case w.source == "":
return "pipeline " + w.pipe + " · heartbeat" 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 // 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" return "passed every stage"
case "filters": case "filters":
text := fmt.Sprintf("dropped by filter %d", w.index+1) 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) { 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 return text
+119 -14
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 // 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 // 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 // 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) { func TestThePointAndModePickTheRows(t *testing.T) {
c := pipeline(t, []string{"null", "null"}, []string{"null"}) c := pipeline(t, []string{"null", "null"}, []string{"null"})
for _, typ := range []string{"include", "exclude"} { for _, typ := range []string{"include", "exclude"} {
@@ -209,7 +209,7 @@ func TestThePointAndModePickTheRows(t *testing.T) {
for _, r := range []core.Tapped{ 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, "", 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", 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) 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, // The detail shows the entry whole: where it came from by its source's type,
// its fields, and the payload as the sinks receive it // 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) { func TestTheDetailShowsTheEntryWhole(t *testing.T) {
c := pipeline(t, []string{"null"}, []string{"null"}) c := pipeline(t, []string{"null"}, []string{"null"})
c.Pipelines[0].PluginSources[0].ID = "in"
if _, err := c.Add(0, "filters", "exclude"); err != nil { if _, err := c.Add(0, "filters", "exclude"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -372,17 +374,20 @@ func TestTheDetailShowsTheEntryWhole(t *testing.T) {
} }
a := watching(c, looks[terminal.ColorModeNone]) a := watching(c, looks[terminal.ColorModeNone])
line := `{"time":"2026-10-10T12:00:00Z","level":"WARN","sub":"fsm","fields":{"msg":"transition","state":"idle"}}` 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) press(a, terminal.KeyEnter)
screen := strings.Join(text(render(a, 170, 30), 170), "\n") 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) { if !strings.Contains(screen, want) {
t.Errorf("no %q\n%s", want, screen) 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")) 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") { if screen := strings.Join(text(render(a, 170, 30), 170), "\n"); a.origin(a.view.focused()) != "pipeline p · null source" ||
t.Errorf("a dropped row\n%s", screen) !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 w int
want string want string
}{{100, stamp + ".000 WARN app.log hello world"}, {60, stamp + ".000 W app.log hello world"}, }{{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) { 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) 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 // e edits the filter that dropped the focused row, from the rows or the
// looks at // row full screen, and the view then looks at it
func TestIInspectsTheFilterThatDropped(t *testing.T) { func TestEEditsTheStageThatDropped(t *testing.T) {
c := pipeline(t, []string{"null"}, []string{"null"}) c := pipeline(t, []string{"null"}, []string{"null"})
for _, typ := range []string{"include", "exclude"} { for _, typ := range []string{"include", "exclude"} {
if _, err := c.Add(0, "filters", typ); err != nil { if _, err := c.Add(0, "filters", typ); err != nil {
t.Fatal(err) t.Fatal(err)
} }
} }
for _, keys := range []string{"e", "ie"} {
a := watching(c, looks[terminal.ColorModeNone]) a := watching(c, looks[terminal.ColorModeNone])
a.view.take(tapped("p", "null", "filters", 1, "dropped")) 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") 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" { if s := screen(); !strings.Contains(s, "─ entry ─") || !strings.Contains(s, "message hello") || strings.Contains(s, "following") || strings.Contains(s, "t back") ||
t.Fatalf("viewing %v, inspecting %v on %+v, at %s", a.viewing, a.inspect, l.node, a.point().name) 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)
} }
} }