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
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:
1 parent
1abffcec6c
commit
f4f8cc0986
24 files changed
+955
-285
No files matched your search
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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{}}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" | `},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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, "")
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user