From 87f5576ccf192132d60f029df43e13b218409d14ed30579d9f0aed4649fedbac Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Sat, 10 Oct 2026 21:32:08 -0400 Subject: [PATCH] v0.33.0 tui tap for live view and console enhancements --- README.md | 6 +- cmd/lw/bootstrap.go | 26 +- cmd/lw/main.go | 21 +- cmd/lw/reload_test.go | 4 +- cmd/lw/screen.go | 216 +++++++ cmd/lw/screen_test.go | 309 +++++++++ doc/README.md | 4 +- doc/architecture.md | 22 +- doc/cli.md | 70 ++- doc/lw.1 | 21 +- doc/security.md | 5 +- doc/sinks.md | 3 +- doc/todo.md | 40 +- go.mod | 2 +- go.sum | 6 +- internal/authz/scram.go | 17 + internal/authz/scram_test.go | 38 ++ internal/core/const.go | 6 + internal/core/flow.go | 83 ++- internal/core/flow_test.go | 45 +- internal/filter/chain.go | 23 +- internal/flow/flow.go | 54 +- internal/logline/logline.go | 408 ++++++++++++ internal/logline/logline_test.go | 138 +++++ internal/logline/scan.go | 226 +++++++ internal/pipeline/pipeline.go | 113 ++-- internal/pipeline/pipeline_test.go | 139 ++++- internal/service/service.go | 28 +- internal/sink/console/console.go | 68 +- internal/sink/console/console_test.go | 22 - internal/sink/null/null.go | 1 + internal/tui/canvas.go | 261 +++++--- internal/tui/dialog.go | 103 ++- internal/tui/inspector.go | 3 +- internal/tui/run.go | 316 ++++++++++ internal/tui/run_test.go | 363 +++++++++++ internal/tui/theme.go | 75 ++- internal/tui/tui.go | 141 ++++- internal/tui/tui_test.go | 201 +++++- internal/tui/view.go | 859 ++++++++++++++++++++++++++ internal/tui/view_test.go | 569 +++++++++++++++++ 41 files changed, 4606 insertions(+), 449 deletions(-) create mode 100644 cmd/lw/screen.go create mode 100644 cmd/lw/screen_test.go create mode 100644 internal/logline/logline.go create mode 100644 internal/logline/logline_test.go create mode 100644 internal/logline/scan.go create mode 100644 internal/tui/run.go create mode 100644 internal/tui/run_test.go create mode 100644 internal/tui/view.go create mode 100644 internal/tui/view_test.go diff --git a/README.md b/README.md index 45b6d8d..dc7a0c1 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,9 @@ streams, or downstream LogWisp nodes. - **Presets**: `tail`, `serve`, `edge` and `aggregator` build the common pipelines from a few keys (`lw --preset tail:path=/var/log/app`); `lw --dump` turns any command line into a configuration file +- **On screen**: `lw --tui` composes pipelines on a terminal and runs or + prints them; `t` runs them under the screen, where a live view shows each + entry with its fate: passed, or the stage or filter that dropped it - **Independent pipelines**, each `sources → flow → sinks`, running concurrently in one process - **Fan-in and fan-out**: many sources and many sinks per pipeline @@ -112,7 +115,8 @@ authentication designs for the rationale and what is deliberately left out. - Design notes: [mTLS](doc/mtls-auth-plan.md) and [password](doc/scram-auth-plan.md) authentication - [CLI](doc/cli.md): filter use, flags, pipeline specifications, signals, - exit codes, `lw auth`; also the `lw(1)` manual, [`doc/lw.1`](doc/lw.1) + exit codes, `lw --tui`, `lw auth`; also the `lw(1)` manual, + [`doc/lw.1`](doc/lw.1) - [Operations](doc/operations.md): running, monitoring, tuning, troubleshooting - [To Do](doc/todo.md): planned work: packaging, `lw --tui` and its shared configuration engine diff --git a/cmd/lw/bootstrap.go b/cmd/lw/bootstrap.go index 9edeb0b..50be57d 100644 --- a/cmd/lw/bootstrap.go +++ b/cmd/lw/bootstrap.go @@ -24,6 +24,7 @@ import ( "github.com/lixenwraith/logwisp/internal/authz" "github.com/lixenwraith/logwisp/internal/config" + "github.com/lixenwraith/logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/service" "github.com/lixenwraith/logwisp/internal/version" @@ -34,7 +35,7 @@ import ( // bootstrapInitial handles initial service startup with status reporter func bootstrapInitial(ctx context.Context, cfg *config.Config) (*service.Service, context.CancelFunc, error) { - svc, err := bootstrapService(ctx, cfg) + svc, err := bootstrapService(ctx, cfg, nil) if err != nil { return nil, nil, fmt.Errorf("failed to bootstrap service: %w", err) } @@ -57,8 +58,9 @@ func bootstrapInitial(ctx context.Context, cfg *config.Config) (*service.Service // no pipeline runs until a later reload succeeds. var errServiceStopped = errors.New("old service stopped, new one failed to start") -// handleReload orchestrates the entire hot-reload process including status reporter lifecycle -func handleReload(ctx context.Context, newCfg *config.Config, oldSvc *service.Service, statusCancel context.CancelFunc) (*service.Service, *config.Config, context.CancelFunc, error) { +// handleReload orchestrates the entire hot-reload process including status +// reporter lifecycle; tap is the screen's, nil for none +func handleReload(ctx context.Context, newCfg *config.Config, oldSvc *service.Service, statusCancel context.CancelFunc, tap *core.Tap) (*service.Service, *config.Config, context.CancelFunc, error) { logger.Info("msg", "Starting configuration hot reload") if err := config.ValidateConfig(newCfg); err != nil { @@ -68,13 +70,12 @@ func handleReload(ctx context.Context, newCfg *config.Config, oldSvc *service.Se // Bootstrap a new service to ensure it's valid before touching the old one logger.Debug("msg", "Bootstrapping new service with updated config") - newService, err := bootstrapService(ctx, newCfg) + newService, err := bootstrapService(ctx, newCfg, tap) if err != nil { logger.Error("msg", "Failed to bootstrap new service, keeping old service running", "error", err) return nil, nil, nil, err } - // Gracefully shut down the old service if statusCancel != nil { statusCancel() } @@ -83,14 +84,12 @@ func handleReload(ctx context.Context, newCfg *config.Config, oldSvc *service.Se oldSvc.Shutdown() } - // Start the new service if err := newService.Start(); err != nil { newService.Shutdown() logger.Error("msg", "Failed to start new service pipelines after reload. The application may be in a non-functional state.", "error", err) return nil, nil, nil, fmt.Errorf("%w: %w", errServiceStopped, err) } - // Manage status reporter lifecycle var newStatusCancel context.CancelFunc if newCfg.StatusReporter { newStatusCancel = startStatusReporter(ctx, newService) @@ -100,9 +99,9 @@ func handleReload(ctx context.Context, newCfg *config.Config, oldSvc *service.Se return newService, newCfg, newStatusCancel, nil } -// bootstrapService creates and initializes the main log transport service and its pipelines -func bootstrapService(ctx context.Context, cfg *config.Config) (*service.Service, error) { - // Create service with logger dependency injection +// bootstrapService builds the service and its pipelines, which offer what +// their flows decide to tap from their first entry; nil for none +func bootstrapService(ctx context.Context, cfg *config.Config, tap *core.Tap) (*service.Service, error) { svc, err := service.NewService(ctx, cfg, logger) if err != nil { logger.Error("msg", "Failed to initialize service", @@ -110,6 +109,7 @@ func bootstrapService(ctx context.Context, cfg *config.Config) (*service.Service ) return nil, err } + svc.SetTap(tap) logger.Info("msg", "LogWisp started", "version", version.Short(), @@ -149,27 +149,23 @@ func dumpConfig(cfg *config.Config, w io.Writer) int { return 0 } -// initializeLogger sets up the global logger based on the application's configuration func initializeLogger(cfg *config.Config) error { logger = log.NewLogger() logCfg := log.DefaultConfig() if cfg.Quiet { - // In quiet mode, disable ALL logging output logCfg.Level = 255 // A level that disables all output logCfg.EnableFile = false logCfg.EnableConsole = false return logger.ApplyConfig(logCfg) } - // Determine log level levelValue, err := log.Level(cfg.Logging.Level) if err != nil { return fmt.Errorf("invalid log level: %w", err) } logCfg.Level = levelValue - // Configure log format if cfg.Logging.Format != "" { logCfg.Format = cfg.Logging.Format } @@ -177,7 +173,6 @@ func initializeLogger(cfg *config.Config) error { logCfg.Sanitization = sanitizer.PolicyPreset(cfg.Logging.Sanitization) } - // Configure based on output mode switch cfg.Logging.Output { case "none": logCfg.EnableFile = false @@ -210,7 +205,6 @@ func initializeLogger(cfg *config.Config) error { return logger.ApplyConfig(logCfg) } -// configureFileLogging sets up file-based logging parameters from the configuration func configureFileLogging(logCfg *log.Config, cfg *config.Config) { if cfg.Logging.File != nil { logCfg.Directory = cfg.Logging.File.Directory diff --git a/cmd/lw/main.go b/cmd/lw/main.go index 0f38a5a..2d77626 100644 --- a/cmd/lw/main.go +++ b/cmd/lw/main.go @@ -53,7 +53,6 @@ func main() { } } - // --- 1. Initial setup --- // Emulates nohup signal.Ignore(syscall.SIGHUP) @@ -100,7 +99,6 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // --- 2. Bootstrap initial service --- svc, statusReporterCancel, err := bootstrapInitial(ctx, cfg) if err != nil { logger.Error("msg", "Failed to initialize service", "error", err) @@ -112,7 +110,6 @@ func main() { fmt.Fprintln(os.Stderr, "lw: reading standard input; Ctrl-D ends it (lw --help for usage)") } - // --- 3. Setup signals and shutdown --- sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGUSR1) @@ -156,7 +153,7 @@ func main() { if !fromDisk && cfg.StatusReporter == next.StatusReporter && reflect.DeepEqual(cfg.Pipelines, next.Pipelines) { return } - newSvc, newCfg, newStatusCancel, err := handleReload(ctx, next, svc, statusReporterCancel) + newSvc, newCfg, newStatusCancel, err := handleReload(ctx, next, svc, statusReporterCancel, nil) switch { case err == nil: svc, cfg, statusReporterCancel = newSvc, newCfg, newStatusCancel @@ -166,7 +163,6 @@ func main() { } } - // --- 4. Main Application Event Loop --- logger.Info("msg", "Application started, waiting for signals or config changes") for { select { @@ -176,13 +172,13 @@ func main() { reload(true) } else { logger.Info("msg", "Shutdown signal received", "signal", sig) - cancel() // Trigger service shutdown via context + cancel() } case event, ok := <-configChanges: if !ok { logger.Warn("msg", "Configuration watch channel closed, disabling auto-reload") - configChanges = nil // Stop selecting on this channel + configChanges = nil continue } if collectConfigChanges(event, configChanges) { @@ -194,7 +190,7 @@ func main() { return case <-ctx.Done(): - return // Exit the loop and trigger deferred shutdown + return } } } @@ -236,8 +232,9 @@ func loadFailed(err error) int { } // composeOnScreen is lw --tui: it starts from the pipelines lw would load, or -// the preset menu in place of the built-in pipe, then prints a form and exits, -// or leaves inv to run the pipelines composed in place of the loaded ones +// the preset menu in place of the built-in pipe, may run them on screen, then +// prints a form and exits, or leaves inv to run the pipelines composed in +// place of the loaded ones func composeOnScreen(inv *invocation) (code int, exit bool) { fail := func(err error) (int, bool) { fmt.Fprintf(os.Stderr, "Error: %v\n", err) @@ -257,7 +254,9 @@ func composeOnScreen(inv *invocation) (code int, exit bool) { if err != nil { return fail(err) } - res, err := tui.Run(start, cfg.Color) + runner := &screenRunner{load: inv.load} + res, err := tui.Run(start, cfg.Color, runner) + runner.close() switch { case err != nil: return fail(err) diff --git a/cmd/lw/reload_test.go b/cmd/lw/reload_test.go index c9daa3f..28ac59a 100644 --- a/cmd/lw/reload_test.go +++ b/cmd/lw/reload_test.go @@ -49,7 +49,7 @@ func TestShippedConfigurationBuildsWithCheckedPluginDecoder(t *testing.T) { if len(cfg.Pipelines) != 1 || cfg.Pipelines[0].Name != "default" { t.Fatalf("sample array-of-tables decoded incorrectly: %+v", cfg.Pipelines) } - svc, err := bootstrapService(context.Background(), cfg) + svc, err := bootstrapService(context.Background(), cfg, nil) if err != nil { t.Fatal(err) } @@ -98,7 +98,7 @@ func TestInvalidReloadLeavesStatusReporterRunning(t *testing.T) { cfg.Pipelines[0].PluginSinks[0].Config["buffer_size"] = ^uint64(0) } cancelled := false - svc, next, _, err := handleReload(context.Background(), cfg, nil, func() { cancelled = true }) + svc, next, _, err := handleReload(context.Background(), cfg, nil, func() { cancelled = true }, nil) if err == nil || svc != nil || next != nil || cancelled { t.Fatalf("invalid reload affected running state: svc=%v cfg=%v cancelled=%v error=%v", svc, next, cancelled, err) } diff --git a/cmd/lw/screen.go b/cmd/lw/screen.go new file mode 100644 index 0000000..02a4169 --- /dev/null +++ b/cmd/lw/screen.go @@ -0,0 +1,216 @@ +package main + +import ( + "context" + "errors" + "os" + "slices" + "strings" + "sync" + "time" + + "github.com/lixenwraith/logwisp/internal/authz" + "github.com/lixenwraith/logwisp/internal/compose" + "github.com/lixenwraith/logwisp/internal/config" + "github.com/lixenwraith/logwisp/internal/core" + "github.com/lixenwraith/logwisp/internal/pipeline" + "github.com/lixenwraith/logwisp/internal/service" + "github.com/lixenwraith/logwisp/internal/sink/console" + "github.com/lixenwraith/logwisp/internal/tui" + + "golang.org/x/term" +) + +// The records a tap holds for the screen between ticks: a run's, lw's log's +const ( + runTapRecords = 1024 + logTapRecords = 256 +) + +// screenRunner runs lw --tui's pipelines while the screen owns the terminal. +// Start loads them as r would, through the file, environment and overrides, +// and replaces what runs as a reload does. +type screenRunner struct { + load config.Args + mu sync.Mutex // svc, tap and log, which State reads from another goroutine + svc *service.Service + tap *core.Tap + log *core.Tap +} + +func (r *screenRunner) Start(specs []config.Spec, ask tui.Ask) (*core.Tap, error) { + cfg, err := r.config(specs) + if err == nil { + err = r.startLogger(cfg) + } + if err != nil { + return r.tap, err + } + authz.SetAsker(ask) + tap := core.NewTap(runTapRecords) + tap.All.Store(true) // until the view's first draw says what its point shows + if r.svc == nil { + svc, err := bootstrapService(context.Background(), cfg, tap) + if err != nil { + return nil, placed(cfg, err) + } + if err := svc.Start(); err != nil { + svc.Shutdown() + return nil, placed(cfg, err) + } + r.set(svc, tap) + return tap, nil + } + svc, _, _, err := handleReload(context.Background(), cfg, r.svc, nil, tap) + switch { + case errors.Is(err, errServiceStopped): + r.set(nil, nil) + return nil, placed(cfg, err) + case err != nil: + return r.tap, placed(cfg, err) + } + r.set(svc, tap) + return tap, nil +} + +// placed addresses a start error to the part it names, by the path the +// screen places a configuration problem by +func placed(cfg *config.Config, err error) error { + part, ok := errors.AsType[*pipeline.PartError](err) + if !ok { + return err + } + pi := slices.IndexFunc(cfg.Pipelines, func(p config.PipelineConfig) bool { return p.Name == part.Pipeline }) + if pi < 0 { + return err + } + return config.At(compose.Node{Role: part.Role, ID: part.ID}.Path(pi), err) +} + +func (r *screenRunner) set(svc *service.Service, tap *core.Tap) { + r.mu.Lock() + defer r.mu.Unlock() + r.svc, r.tap = svc, tap +} + +func (r *screenRunner) Stop() { + r.mu.Lock() + svc := r.svc + r.svc, r.tap = nil, nil + r.mu.Unlock() + if svc != nil { + svc.Shutdown() + } +} + +func (r *screenRunner) State() tui.RunState { + r.mu.Lock() + svc, st := r.svc, tui.RunState{Log: r.log} + r.mu.Unlock() + if svc == nil { + return st + } + select { + case <-svc.Done(): + st.Ended = true + default: + } + st.Counts = map[string]tui.Counts{} + pipelines, _ := svc.GetGlobalStats()["pipelines"].(map[string]any) + for name, v := range pipelines { + p, _ := v.(map[string]any) + flow, _ := p["flow"].(map[string]any) + var c tui.Counts + c.In, _ = flow["total_processed"].(uint64) + c.Filtered, _ = flow["total_dropped"].(uint64) + c.SinksDropped, _ = p["total_dropped_by_sink"].(uint64) + st.Counts[name] = c + } + return st +} + +// close ends view mode before lw runs on or exits: nothing runs, lw's log is +// shut down, and terminals answer passwords again +func (r *screenRunner) close() { + r.Stop() + authz.SetAsker(nil) + shutdownLogger() + logger = nil +} + +// config is specs as they would run on screen: no status reporter, as the +// screen shows the counts, and the terminal's streams the screen's +func (r *screenRunner) config(specs []config.Spec) (*config.Config, error) { + load := r.load + load.Specs = specs + m, err := config.Load(load) + if err != nil { + return nil, err + } + defer m.Close() + cfg, err := m.Snapshot() + if err != nil { + return nil, err + } + cfg.StatusReporter = false + return cfg, onScreen(cfg) +} + +// onScreen gives the screen the streams that are the terminal: a console sink +// writing to it runs as a null sink with its id (the tap shows what it would +// print; both make the pipeline wait, not drop), and a console source would +// read the screen's keys +func onScreen(cfg *config.Config) error { + for i := range cfg.Pipelines { + p := &cfg.Pipelines[i] + for _, s := range p.PluginSources { + if s.Type == "console" && term.IsTerminal(int(os.Stdin.Fd())) { + return config.At(compose.Node{Role: "source", ID: s.ID}.Path(i), + errors.New("console reads the keyboard this screen uses: pipe input in (cmd | lw -T), or run with r")) + } + } + for j, s := range p.PluginSinks { + target, _ := s.Config["target"].(string) + if s.Type == "console" && term.IsTerminal(int(console.Stream(target).Fd())) { + p.PluginSinks[j] = config.PluginSinkConfig{ID: s.ID, Type: "null"} + } + } + } + return nil +} + +// startLogger starts lw's log on the first run. While its console is the +// terminal, its records and the logger's own diagnostics go to the screen's +// lw stream instead: the one place that routes lw's log on screen. +func (r *screenRunner) startLogger(cfg *config.Config) error { + if logger != nil { + return nil + } + if err := initializeLogger(cfg); err != nil { + logger = nil + return err + } + c := logger.GetConfig() + streams := map[string][]*os.File{"stdout": {os.Stdout}, "stderr": {os.Stderr}, "split": {os.Stdout, os.Stderr}}[c.ConsoleTarget] + if c.EnableConsole && slices.ContainsFunc(streams, func(f *os.File) bool { return term.IsTerminal(int(f.Fd())) }) { + w := lwStream{core.NewTap(logTapRecords)} + logger.SetConsoleWriter(w) + logger.SetErrorHandler(func(msg string) { w.Write([]byte(msg)) }) + r.mu.Lock() + r.log = w.tap + r.mu.Unlock() + } + return logger.Start() +} + +// lwStream offers each record lw's log writes as an entry of source "lw" +type lwStream struct{ tap *core.Tap } + +func (w lwStream) Write(p []byte) (int, error) { + now := time.Now() + line := strings.TrimRight(string(p), "\r\n") // a copy: the logger reuses p + level, _, _ := core.LevelWord(line, "") + w.tap.Offer(core.Tapped{Source: "lw", Event: core.TransportEvent{Time: now, + Entry: core.LogEntry{Time: now, Source: "lw", Level: level, Message: line, RawSize: int64(len(p))}}}) + return len(p), nil +} diff --git a/cmd/lw/screen_test.go b/cmd/lw/screen_test.go new file mode 100644 index 0000000..cef2d8c --- /dev/null +++ b/cmd/lw/screen_test.go @@ -0,0 +1,309 @@ +package main + +import ( + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/lixenwraith/logwisp/internal/authz" + "github.com/lixenwraith/logwisp/internal/compose" + "github.com/lixenwraith/logwisp/internal/config" + "github.com/lixenwraith/logwisp/internal/core" + "github.com/lixenwraith/logwisp/internal/plugin" + "github.com/lixenwraith/logwisp/internal/session" + "github.com/lixenwraith/logwisp/internal/testutil" + + "github.com/lixenwraith/log" + "golang.org/x/term" +) + +// onTerminal makes *stream a pseudo-terminal's master, which is a terminal, +// until the test ends +func onTerminal(t *testing.T, stream **os.File) { + t.Helper() + pty, err := os.OpenFile("/dev/ptmx", os.O_RDWR, 0) + if err != nil || !term.IsTerminal(int(pty.Fd())) { + t.Skip("no pseudo-terminal here") + } + was := *stream + *stream = pty + t.Cleanup(func() { *stream = was; pty.Close() }) +} + +// screenRunnerFor is a runner on lw's settings in args, with specs to start +func screenRunnerFor(t *testing.T, args ...string) (*screenRunner, func(pipeline ...string) []config.Spec) { + t.Helper() + testutil.ClearEnvPrefix(t, "LOGWISP_") + path := filepath.Join(t.TempDir(), "empty.toml") + testutil.WriteFile(t, path, "") + inv, err := parseCommandLine(append([]string{"-c", path}, args...)) + if err != nil { + t.Fatal(err) + } + r := &screenRunner{load: inv.load} + t.Cleanup(func() { r.Stop(); authz.SetAsker(nil) }) + return r, func(pipeline ...string) []config.Spec { + inv, err := parseCommandLine(append([]string{"--pipeline", "p"}, pipeline...)) + if err != nil { + t.Fatal(err) + } + return inv.load.Specs + } +} + +// A console sink writing to the terminal runs as a null sink with its id, which +// keeps the console's back-pressure, so the screen counts no sink drops a run +// would not have; one writing elsewhere keeps writing there +func TestConsoleSinksOnTheTerminalRunAsNullSinks(t *testing.T) { + onTerminal(t, &os.Stdout) + cfg := &config.Config{Pipelines: []config.PipelineConfig{{Name: "p", PluginSinks: []config.PluginSinkConfig{ + {ID: "out", Type: "console"}, {ID: "err", Type: "console", Config: map[string]any{"target": "stderr"}}}}}} + null, err := os.Open(os.DevNull) + if err != nil { + t.Fatal(err) + } + defer null.Close() + stderr := os.Stderr + os.Stderr = null + defer func() { os.Stderr = stderr }() + if err := onScreen(cfg); err != nil { + t.Fatal(err) + } + s := cfg.Pipelines[0].PluginSinks + if s[0].ID != "out" || s[0].Type != "null" || s[0].Config != nil || s[1].Type != "console" { + t.Fatalf("%+v", s) + } + build, _ := plugin.GetSink(s[0].Type) + stand, err := build(s[0].ID, s[0].Config, log.NewLogger(), session.NewProxy(session.NewManager(time.Minute), s[0].ID)) + if err != nil || !slices.Contains(stand.Capabilities(), core.CapBackpressure) { + t.Fatalf("the stand-in drops where the console waits: %v", err) + } +} + +// A console source would read the screen's keys: it needs piped input, and +// the refusal names its part +func TestAConsoleSourceNeedsPipedInput(t *testing.T) { + cfg := &config.Config{Pipelines: []config.PipelineConfig{{Name: "p", PluginSources: []config.PluginSourceConfig{{ID: "in", Type: "console"}}}}} + in, out, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer out.Close() + stdin := os.Stdin + os.Stdin = in + err = onScreen(cfg) + os.Stdin = stdin + in.Close() + if err != nil { + t.Fatalf("piped: %v", err) + } + onTerminal(t, &os.Stdin) + err = onScreen(cfg) + if pi, n, _, why, ok := compose.Locate(err); !ok || pi != 0 || n != (compose.Node{Role: "source", ID: "in"}) || !strings.HasPrefix(why, "console reads the keyboard") { + t.Fatalf("on a terminal: %v", err) + } +} + +// A start error names the part that failed, as a problem's path does, both +// when the part does not build and when it does not start +func TestAStartErrorNamesItsPart(t *testing.T) { + testLogger(t) + missing := filepath.Join(t.TempDir(), "missing") + held, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer held.Close() + for name, sink := range map[string]string{ + "does not build": fmt.Sprintf("tcp:host=127.0.0.1,port=9,tls.enabled=true,tls.cert_file=%s.pem,tls.key_file=%s.key", missing, missing), + "does not start": fmt.Sprintf("tcp:host=127.0.0.1,port=%d", held.Addr().(*net.TCPAddr).Port), + } { + r, specs := screenRunnerFor(t, "--quiet") + _, err := r.Start(specs("--source", "null", "--sink", "null", "--sink", sink), nil) + if pi, n, _, _, ok := compose.Locate(err); !ok || pi != 0 || n != (compose.Node{Role: "sink", ID: "tcp"}) { + t.Errorf("a sink that %s: %+v in %v", name, n, err) + } + } +} + +// While the screen runs, a password the terminal would be asked for is asked +// through Start's ask; once the screen closes, the terminal is asked again. +// It runs alone: an earlier test's startup end stops every terminal read. +func TestTheScreenAsksInTheTerminalsPlace(t *testing.T) { + if os.Getenv("LW_TEST_SCREEN_ASKS") == "" { + cmd := exec.Command(os.Args[0], "-test.run=^TestTheScreenAsksInTheTerminalsPlace$", "-test.v") + cmd.Env = append(os.Environ(), "LW_TEST_SCREEN_ASKS=1") + out, err := cmd.CombinedOutput() + switch { + case strings.Contains(string(out), "--- SKIP"): + t.Skip("no pseudo-terminal here") + case err != nil || !strings.Contains(string(out), "--- PASS"): + t.Fatalf("%v\n%s", err, out) + } + return + } + if probe, err := os.Open("/dev/ptmx"); err != nil || !term.IsTerminal(int(probe.Fd())) { + t.Skip("no pseudo-terminal here") + } else { + probe.Close() + } + testLogger(t) + r, specs := screenRunnerFor(t, "--quiet") + asked := make(chan string, 2) + ask := func(user string, generate bool) ([]byte, error) { + asked <- user + return []byte("from-the-screen"), nil + } + started := make(chan struct{}) + go func() { + defer close(started) + r.Start(specs("--preset", "edge:path="+t.TempDir()+",to=127.0.0.1:9,pin=sha256//"+strings.Repeat("A", 43)+"=,user=on-screen,password_file=/dev/ptmx"), ask) + }() + select { + case user := <-asked: + if user != "on-screen" { + t.Fatalf("asked for %s", user) + } + case <-time.After(5 * time.Second): + t.Fatal("the terminal was asked, not the screen") + } + <-started + r.close() + go authz.ReadPassword("/dev/ptmx", "after-the-screen", false) + select { + case user := <-asked: + t.Fatalf("the closed screen was asked for %s", user) + case <-time.After(200 * time.Millisecond): + } +} + +// A run on screen has no status reporter, whatever the file says: the bar +// shows the counts, and a rerun keeps no cancel for one +func TestARunOnScreenHasNoStatusReporter(t *testing.T) { + r, specs := screenRunnerFor(t, "--status_reporter=true") + cfg, err := r.config(specs("--source", "null", "--sink", "null")) + if err != nil || cfg.StatusReporter { + t.Fatalf("status reporter %v: %v", cfg != nil && cfg.StatusReporter, err) + } +} + +// The state counts each pipeline's entries in and filtered by its flow apart +// from those its sinks dropped +func TestTheStateCountsInFilteredAndSinkDrops(t *testing.T) { + testLogger(t) + r, specs := screenRunnerFor(t, "--quiet") + if _, err := r.Start(specs("--source", "random:interval_ms=1", "--filter", "exclude:patterns=.", "--sink", "null"), nil); err != nil { + t.Fatal(err) + } + for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(10 * time.Millisecond) { + c := r.State().Counts["p"] + if c.In > 0 && c.Filtered > 0 && c.SinksDropped == 0 { + return + } + if time.Now().After(deadline) { + t.Fatalf("counts %+v", c) + } + } +} + +// A run's entries reach its tap. A rerun replaces what runs as a reload does: +// pipelines that do not build leave the earlier ones running, with their tap; +// a start that fails once those stopped leaves nothing. +func TestARerunKeepsWhatRunsUntilTheNewPipelinesBuild(t *testing.T) { + testLogger(t) + r, specs := screenRunnerFor(t, "--quiet") + tap, err := r.Start(specs("--source", "random:interval_ms=1", "--sink", "null"), nil) + if err != nil || tap == nil { + t.Fatalf("start: %v", err) + } + select { + case rec := <-tap.Records(): + if rec.Pipeline != "p" || rec.Stage != "" { + t.Fatalf("%+v", rec) + } + case <-time.After(5 * time.Second): + t.Fatal("no entry reached the run's tap") + } + missing := filepath.Join(t.TempDir(), "missing") + bad := fmt.Sprintf("tcp:host=127.0.0.1,port=9,tls.enabled=true,tls.cert_file=%s.pem,tls.key_file=%s.key", missing, missing) + if kept, err := r.Start(specs("--source", "null", "--sink", bad), nil); kept != tap || err == nil || len(r.State().Counts) != 1 { + t.Fatalf("a rerun that does not build: %v, kept %v", err, kept == tap) + } + held, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer held.Close() + taken := fmt.Sprintf("tcp:host=127.0.0.1,port=%d", held.Addr().(*net.TCPAddr).Port) + if kept, err := r.Start(specs("--source", "null", "--sink", taken), nil); kept != nil || err == nil || len(r.State().Counts) != 0 { + t.Fatalf("a rerun that does not start: %v, %v", err, r.State()) + } +} + +// A run's tap takes what the flow drops from its first entry, before a view +// says whether its point shows drops +func TestARunsTapTakesDropsFromItsStart(t *testing.T) { + testLogger(t) + r, specs := screenRunnerFor(t, "--quiet") + tap, err := r.Start(specs("--source", "random:interval_ms=1", "--filter", "exclude:patterns=.", "--sink", "null"), nil) + if err != nil { + t.Fatalf("start: %v", err) + } + select { + case rec := <-tap.Records(): + if rec.Stage != "filters" { + t.Fatalf("%+v", rec) + } + case <-time.After(5 * time.Second): + t.Fatal("no dropped entry reached the run's tap") + } +} + +// lw's log, written to the terminal, shows in the screen's lw stream instead; +// written elsewhere, it stays there +func TestLwsLogShowsOnTheScreen(t *testing.T) { + null, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + defer null.Close() + stderr := os.Stderr + os.Stderr = null + elsewhere := screenLog(t) + os.Stderr = stderr + if elsewhere != nil { + t.Fatal("an lw stream for a log written to a file") + } + onTerminal(t, &os.Stderr) + log := screenLog(t) + if log == nil { + t.Fatal("no lw stream for a log on the terminal") + } + select { + case rec := <-log.Records(): + if rec.Event.Entry.Source != "lw" || rec.Event.Entry.Message == "" { + t.Fatalf("%+v", rec) + } + case <-time.After(5 * time.Second): + t.Fatal("lw's log did not reach its stream") + } +} + +// screenLog is the lw stream of a run whose log writes info to stderr +func screenLog(t *testing.T) *core.Tap { + t.Helper() + was := logger + logger = nil + r, specs := screenRunnerFor(t, "--logging.output=stderr", "--logging.level=info") + t.Cleanup(func() { r.close(); logger = was }) + if _, err := r.Start(specs("--source", "null", "--sink", "null"), nil); err != nil { + t.Fatal(err) + } + return r.State().Log +} diff --git a/doc/README.md b/doc/README.md index 59dafe8..0fd0e2e 100644 --- a/doc/README.md +++ b/doc/README.md @@ -27,8 +27,8 @@ streams, or downstream LogWisp nodes. certificate-based authorization - [Password Authentication](scram-auth-plan.md): design and rationale for Argon2id-SCRAM authentication; mTLS hardening -- [CLI](cli.md): flags, signals, exit codes, `lw auth`; also the `lw(1)` - manual, [`lw.1`](lw.1) +- [CLI](cli.md): flags, signals, exit codes, `lw --tui`, `lw auth`; also + the `lw(1)` manual, [`lw.1`](lw.1) - [Operations](operations.md): running, monitoring, tuning, troubleshooting - [To Do](todo.md): planned work: packaging, `lw --tui` and its shared configuration engine diff --git a/doc/architecture.md b/doc/architecture.md index 8af3fbe..8f5df1b 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -48,7 +48,11 @@ Package map: - `internal/core`: shared types (`LogEntry`, `TransportEvent`), capabilities, constants. - `internal/tokenbucket`: rate limiter primitive. -- `internal/sanitize`: standalone hex-escaping helpers. +- `internal/tui`: `lw --tui`, the diagram and the live view of what `t` + runs; `cmd/lw/screen.go` runs the pipelines for it. +- `internal/logline`: reads a line of any common shape into time, level, + tag, message and fields for the live view; holds the one rule for runes a + terminal acts on, which the console sink shares. ## Plugin Registration @@ -74,7 +78,9 @@ instance of a single type fails, naming both pipelines. the source's `dropped_entries` counter. The console source alone waits, since stdin can simply be read later. 2. **Flow** applies, in order: rate limit → filter chain → formatter. A drop at - any stage ends the entry's life and increments `flow.total_dropped`. + any stage ends the entry's life and increments `flow.total_dropped`. The + verdict, passed or the stage that dropped it (a filter by its index), goes + to the pipeline's tap when a viewer set one, as `lw --tui`'s live view does. 3. The formatter output becomes a `core.TransportEvent`, which carries both the formatted `Payload` and the original structured `Entry`. 4. **Dispatch** sends the event to every sink's input channel with a @@ -107,10 +113,15 @@ loses nothing. Each stage drops and counts instead of waiting: `flow.total_dropped`. - Pipeline → sink: a full sink input drops it for that sink only; pipeline `total_dropped_by_sink`. A sink declaring `core.CapBackpressure`, the console - sink, makes dispatch wait instead, so a filter loses no line to a slow - reader and its pipeline runs at the reader's pace; `Stop` ends the wait. + and null sinks, makes dispatch wait instead, so a filter loses no line to a + slow reader and its pipeline runs at the reader's pace; `Stop` ends the wait. - TCP/HTTP sink → client queue: a full queue drops it for that client only; sink `dropped_writes`. +- Flow → tap: a full tap drops the record, copying nothing, which the live + view counts as "screen missed". It copies the first 16 KiB of a longer + message, fields or payload and 256 bytes of a level, source or node, so a + viewer's rows pin no source's line, and takes dropped entries only while + the view shows them. The `tcp_chain` sink is the one deliberate exception. It holds a line across reconnects until it is written or the process shuts down, so a downstream @@ -202,7 +213,8 @@ Reload (signal or file watch) rebuilds the entire service: Because this is a full rebuild, listening sockets close and reopen and all clients are disconnected. Application logging is configured once at startup and -is **not** re-applied on reload. +is **not** re-applied on reload. `t` on the `lw --tui` screen reruns through +the same path, giving each new service the screen's tap before it starts. Watch errors do not rebuild services. Queued changes are combined, and an unchanged pipeline/status snapshot is skipped. Signals always rebuild for diff --git a/doc/cli.md b/doc/cli.md index feecd6f..067520a 100644 --- a/doc/cli.md +++ b/doc/cli.md @@ -335,6 +335,8 @@ A reload rereads the file and keeps the command-line or environment pipelines. - `SIGINT`, `SIGTERM`: graceful shutdown - `SIGHUP`, `SIGUSR1`: reload the configuration +- On the `lw --tui` screen, `SIGTERM` and `SIGHUP` close it, stopping what + `t` runs, and lw exits 0 `SIGHUP` is ignored during startup, before the signal handler is installed, so LogWisp survives a terminal hang-up like `nohup`. Once running, it triggers a @@ -469,7 +471,7 @@ kill -USR1 $(pidof lw) `lw --tui` (`-T`) composes pipelines on a full-screen terminal, drawn as the website's configurator draws them, with the engine the website runs as -WebAssembly. +WebAssembly; `t` runs them under the screen and shows what flows. - Start: the pipelines `lw` would run with the same options (`-c FILE`, pipeline flags, `LOGWISP_SOURCE`, a discovered file); with none but the @@ -480,8 +482,10 @@ WebAssembly. type, whether it listens or dials, and a summary; the four flow stages in their order in a dashed box, the ones off dimmed. The inspector, the chosen part's options with radio buttons for few choices, sits below the - canvas, and beside it from 110 columns. Narrow panes stack labels, wrap - text and scroll dialogs; the status line wraps to three rows. + canvas, and beside it from 110 columns, where each part also lists up to + four options it sets; wide screens size the columns to their content and + centre them. Narrow panes stack labels, wrap text and scroll dialogs; the + status line wraps to three rows. - Keys: arrows or `hjkl` move; Enter inspects a part, opening on its first required key, then edits a value (empty returns it to its default), opens a list's choices or adds on an add row; in a value, Enter sets it, and Tab @@ -490,23 +494,71 @@ WebAssembly. changes a source's or sink's type in its place; `d` deletes, or returns a value to its default; Space turns a flow stage on or off; `J`/`K` move a filter; `[` `]` switch pipelines; `p` presets; `!` shows every problem, - moving to the first; `r` runs; `o` output; `?` keys; `q` quits. + moving to the first; `r` runs; `t` taps; `o` output; `?` keys; `q` quits. - Pipelines: Up from a column's top row reaches the bar, where `[` `]` or Left/Right switch, `a` adds one (empty or from a preset), Enter renames and `d` deletes, asking first when it has parts. - Output, `o`, each picked by its letter: `r` runs the pipelines with the other options given (so `--check` and `--dump` apply to them, and reloads - keep them), or `c`, `e` and `f` print the command line, the environment - variables (one pipeline) or the file's pipelines on standard output once - the screen closes. + keep them), `t` taps them, or `c`, `e` and `f` print the command line, the + environment variables (one pipeline) or the file's pipelines on standard + output once the screen closes. +- Tap, `t`: runs the pipelines in this process as `r` would, without the + status reporter, and opens the live view. It runs for real: listeners + bind, dialers connect and log in, file sinks write. + - The terminal stays the screen's: a console sink writing to it runs as a + null sink with its id, and the view shows what it would print; a console + source needs piped input (`cmd | lw -T`); lw's own log, when it writes to + the terminal, shows in the view as source `lw`; a password the terminal + would be asked for is asked in a masked dialog. + - Leaving the view keeps the pipelines running. `t` after an edit reruns + them as a reload does: when the new ones do not build, the earlier ones + keep running and the error chooses the part it names. `q` asks before it + stops them, `r` and `o`'s choices stop them first, and `SIGTERM` or + `SIGHUP` (an ssh session's end) stops them and closes the screen. + - The bar counts the shown pipeline's entries in, filtered by the flow, + dropped by sinks, and missed by the screen: the view never slows a + pipeline. +- Live view: from 40 rows it sits under the diagram; shorter, it takes the + screen while it has the keys. `t` or Esc gives them back to the diagram. + - The canvas cursor picks its point, so the view opens on the first + source: a source shows its entries, a flow stage (or the filter the + inspector is on) what reaches it, the heartbeat its own, the sinks (or + no part) what they got. `v` switches between all, what passed and what + was dropped there, with no restart. lw's own log shows from info up at + every point; `s` also offers it alone, debug too. + - A row is the entry's time, level, tag, message and fields, read from any + line shape: LogWisp's json, lw's own log, vif records, pino, zap, slog, + docker, journald, ECS, GCP, or text after an RFC3339 stamp. A narrow + screen drops columns; a dropped entry is marked; a rune a terminal would + act on or draw wider than a cell shows as a placeholder. + - Keys: arrows or `jk` move (up pauses, down onto the newest follows), + PgUp/PgDn; `g` the oldest, `G` or `f` the newest; Enter the row's + detail, `J`/`K` scroll it; `/` only rows matching a pattern, ignoring + case when it is all lower case (clearing it, or a level, keeps the + focused row in place, paused there unless it is the newest); `1`-`5` + trace to error and up, `0` all; `s` chooses the point; + `i` inspects the stage that dropped the row; `c` clears; `q`, `?`, `r`, + `o`, `[` and `]` act as on the diagram. + - The detail, beside the rows from 160 columns and under them otherwise: + time and the stamp as written, level, origin, the verdict with a + filter's patterns, the layout read, message, fields, and the payload as + the sinks receive it, with escapes for what a cell cannot show. + - It keeps 32 MiB of rows, each entry's message, fields and payload cut to + 16 KiB, and redraws at most every 100 ms while entries flow, at once on + a key, never when idle. A closed standard output or error fails the + write (logged) rather than end lw with the terminal raw. - The screen draws on the controlling terminal: `lw -T > pipelines.toml` and `cmd | lw -T` keep standard input and output as data. lw never writes the file it starts from, but the shell empties a redirect's target before lw reads it: redirect to a file lw does not load. - Color follows the `color` setting: `auto` the terminal and `NO_COLOR`, `always` the terminal, `never` none. True color and 256 colors paint the - website's dark theme; 16 colors and none keep the terminal's own; a text - console draws with CP437 glyphs, and a terminal outside UTF-8 with ASCII. + website's dark theme; 16 colors a console look on the terminal's own + background (bright white text, its grey for hints, a blue cursor bar that + keeps each tint); none the terminal's own colors, the cursor row one + reverse bar. A text console draws with CP437 glyphs, and a terminal outside + UTF-8 with ASCII. ## `lw auth` diff --git a/doc/lw.1 b/doc/lw.1 index 8915695..c17231a 100644 --- a/doc/lw.1 +++ b/doc/lw.1 @@ -1,4 +1,4 @@ -.Dd October 5, 2026 +.Dd October 10, 2026 .Dt LW 1 .Os .Sh NAME @@ -163,6 +163,15 @@ and .Fl \-dump also do, or print them as a command line, environment variables or a configuration file's pipelines. +On the screen, +.Ic t +runs them in place, for real: listeners bind, dialers connect, file sinks +write. +A live view then shows each entry with its fate, passed or the flow stage +that dropped it, and the log of +.Nm ; +a console sink writing to the terminal runs as a null sink there, and a +console source needs piped input. The screen draws on the controlling terminal, so standard input and output stay data: .Ql lw \-T > pipelines.toml . @@ -756,6 +765,16 @@ Before the service runs, is ignored, as under .Xr nohup 1 . .El +.Pp +On the +.Fl \-tui +screen, +.Dv SIGTERM +and +.Dv SIGHUP +close it, stopping what it runs, and +.Nm +exits 0. .Sh EXAMPLES Copy a file line by line: .Bd -literal -offset indent diff --git a/doc/security.md b/doc/security.md index 0e965b0..df41e29 100644 --- a/doc/security.md +++ b/doc/security.md @@ -745,6 +745,8 @@ edge. `password_file` is read by what it names, on either side: forwarded, and stderr is often a file. - With no terminal (systemd, cron) the plugin fails. `lw ... &` stops on `SIGTTIN` at the prompt. + - Under `lw --tui`'s `t`, the screen asks in a masked dialog instead; a + generated password stays shown until a key closes it. Trade-offs: @@ -1094,4 +1096,5 @@ The `raw` format passes control characters through. A console sink writing to a terminal escapes them itself (`escape = "auto"`), so a log line cannot move the cursor, retitle the window, write the clipboard (OSC 52) or reorder text with bidi controls; pipes and files get the bytes unchanged. See -[Sinks](sinks.md#console). +[Sinks](sinks.md#console). `lw --tui`'s live view applies the same rule to +everything it draws. diff --git a/doc/sinks.md b/doc/sinks.md index 9279b61..03d3227 100644 --- a/doc/sinks.md +++ b/doc/sinks.md @@ -122,7 +122,8 @@ id = "discard" type = "null" ``` -No options. The input queue is fixed at 1000. +No options. The input queue is fixed at 1000; a full one makes the pipeline +wait, which costs nothing as it empties at once, rather than count a drop. --- diff --git a/doc/todo.md b/doc/todo.md index d40aff0..458c386 100644 --- a/doc/todo.md +++ b/doc/todo.md @@ -74,11 +74,12 @@ release tag, and both skeletons download the tagged source. ## 2. `lw --tui` and the configuration engine it shares with lixen.com -`lw --tui` composes pipelines in a full-screen terminal UI, then either runs -them at once or prints one form of them, chosen on exit: a command line, -environment variables, or a configuration file. Its logic is a pure engine that -lixen.com's planned Configure tab (lixencom `doc/todo.md`) runs as WebAssembly, -so no part of LogWisp is rewritten in JavaScript. +`lw --tui` composes pipelines in a full-screen terminal UI, runs them under +the screen with a live view (`t`), and on exit runs them at once or prints one +form of them: a command line, environment variables, or a configuration file. +Its logic is a pure engine that lixen.com's planned Configure tab (lixencom +`doc/todo.md`) runs as WebAssembly, so no part of LogWisp is rewritten in +JavaScript. What exists: - Option structs with toml tags for every plugin and flow stage @@ -117,7 +118,8 @@ Steps: optional, in `color`: export the 16 xterm reference shades (RGBTo16 maps the site's hues badly, so the 16-color tier is chosen by hand). v0.2.1: radio buttons, narrow form fields and option lists, menus, frames, rules, - wires and windows, moved from `lw --tui`. + wires and windows, moved from `lw --tui`. Pinned at the untagged v0.2.2 + commit: one glyph per cell at output, a reverse cursor bar in one style. 5. `lw --tui`: done ([doc/cli.md](cli.md#lw---tui)). 6. WebAssembly: done. `make wasm` writes `bin/lwconf/`, which a site drops in whole: @@ -154,11 +156,23 @@ Steps: - Pipelines are a JSON list in the file's shape (`name`, `flow`, `plugin_sources`, `plugin_sinks`); a misspelled key is refused. A node is `{role, id, index}`: a source's or sink's id, a filter's index. +7. `t`, the tap: done ([doc/cli.md](cli.md#lw---tui)): `core.Tap` takes each + flow verdict, `internal/logline` reads any line shape, and the live view + shows them under the diagram. -Still to verify: a scripted session on a pseudo-terminal for Run and each -output. - -Still to derive: `AuthOptions.Check` alone limits `auth.node_binding` to chain -sources and `auth.trusted_proxies` to the http sink, so the schema lists both -on every listener; a side tag naming those sides would filter them as `http` -filters HTTP's keys. +Not yet: +- Pins and export in the live view, as vif-log has them: pinned rows survive + filter changes and show alone on a key; the pins or the shown rows go to a + new file. +- Per-sink taps: what each sink took, where the tap at the flow's exit shows + what dispatch offered (a full sink input drops for that sink only). +- Live filter patching: a filter edited while `t` runs takes effect through + `Filter.UpdatePatterns` (unused), not a rerun that rebinds every listener. +- The wide-rune table `internal/tui/view.go` copies from + `lixenwraith/terminal`'s one-cell rule goes once terminal exports that rule. +- A scripted session on a pseudo-terminal for Run, each output and `t` (keys, + the password dialog, `SIGHUP`), on a text console and over ssh. +- `AuthOptions.Check` alone limits `auth.node_binding` to chain sources and + `auth.trusted_proxies` to the http sink, so the schema lists both on every + listener; a side tag naming those sides would filter them as `http` + filters HTTP's keys. diff --git a/go.mod b/go.mod index b7cd20d..9b2afdd 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/lixenwraith/auth v0.5.1 github.com/lixenwraith/color v0.1.3 github.com/lixenwraith/config v0.2.2 - github.com/lixenwraith/log v0.1.11 + github.com/lixenwraith/log v0.1.12-0.20261011001400-86d4fe10bd5e github.com/lixenwraith/terminal v0.2.2-0.20261010220430-c657af606d34 github.com/lixenwraith/toml v0.1.3 golang.org/x/term v0.47.0 diff --git a/go.sum b/go.sum index 796192d..e1e9dd1 100644 --- a/go.sum +++ b/go.sum @@ -6,10 +6,8 @@ github.com/lixenwraith/color v0.1.3 h1:vSuvtU4zh1mIZS/7UGHmMdsTr+a7eCjUYGbou7kzF github.com/lixenwraith/color v0.1.3/go.mod h1:L/I3Vxp6pqslAIzWyZGgKCm2lyStHCA4vKaUoMUm7bU= github.com/lixenwraith/config v0.2.2 h1:X+l3Tf8Rbk49cipYBJU7/pfdw5wDwD4hRxtFH7rZgOc= github.com/lixenwraith/config v0.2.2/go.mod h1:yfJQwKS+/6Dg70GIoHeYc0eYztOPETpqe6nUIbakcyg= -github.com/lixenwraith/log v0.1.11 h1:dKWLnaR8dhXxj4l/aU5XZiFIsr8geaJQbOf8m7SiKhU= -github.com/lixenwraith/log v0.1.11/go.mod h1:EE8i5nSAW2yZBTytKuhdgehmgzrby6kW1k5DyH1SvBg= -github.com/lixenwraith/terminal v0.2.1 h1:KsMfmxrRicBFWMdXJIEJ3cpLWVw6IoNF8GwgsxbD3kQ= -github.com/lixenwraith/terminal v0.2.1/go.mod h1:V0P/FDpY+UeSvx1VIVuTG7llo0RbK3XrSJLDA7GqHqY= +github.com/lixenwraith/log v0.1.12-0.20261011001400-86d4fe10bd5e h1:5ocB2Wsd4nQbpX+lkA3LSuHf23GQ46BPejNy3oCZZbU= +github.com/lixenwraith/log v0.1.12-0.20261011001400-86d4fe10bd5e/go.mod h1:EE8i5nSAW2yZBTytKuhdgehmgzrby6kW1k5DyH1SvBg= github.com/lixenwraith/terminal v0.2.2-0.20261010220430-c657af606d34 h1:GasarGRKYbAXzEz1utbcf9n4+N7WXPKyTbUtQZj8v74= github.com/lixenwraith/terminal v0.2.2-0.20261010220430-c657af606d34/go.mod h1:V0P/FDpY+UeSvx1VIVuTG7llo0RbK3XrSJLDA7GqHqY= github.com/lixenwraith/toml v0.1.3 h1:mhnNVLNogdaqjGd3eC4rV9ZqY3j1Sv+DK1erZ+SPFeI= diff --git a/internal/authz/scram.go b/internal/authz/scram.go index b132036..4b39eb4 100644 --- a/internal/authz/scram.go +++ b/internal/authz/scram.go @@ -298,9 +298,26 @@ func readDevice(path, user string, generate bool) ([]byte, bool, error) { return data, false, err } +// asker answers in the terminal's place while a host's screen owns it +var asker atomic.Pointer[func(user string, generate bool) ([]byte, error)] + +// SetAsker has fn answer what the terminal would be asked; nil asks the +// terminal again. With generate, fn draws a password for an empty answer and +// shows it once, never in a log. +func SetAsker(fn func(user string, generate bool) ([]byte, error)) { + if fn == nil { + asker.Store(nil) + return + } + asker.Store(&fn) +} + // ask prompts on the terminal at path without echo. A generated password is // shown there only: logs persist, and stderr is often a file. func ask(path, user string, generate bool) ([]byte, error) { + if fn := asker.Load(); fn != nil { + return (*fn)(user, generate) + } tty, err := os.OpenFile(path, os.O_RDWR, 0) if err != nil { return nil, err diff --git a/internal/authz/scram_test.go b/internal/authz/scram_test.go index a41b9d7..341dc72 100644 --- a/internal/authz/scram_test.go +++ b/internal/authz/scram_test.go @@ -29,6 +29,7 @@ import ( "github.com/lixenwraith/logwisp/internal/testutil" "github.com/lixenwraith/auth" + "golang.org/x/term" ) func TestMain(m *testing.M) { @@ -689,6 +690,43 @@ func TestOnceReadSourcesAreReadAtStartupOnly(t *testing.T) { } } +// While a host's screen owns the terminal its asker answers in the prompt's +// place, once per user, like the terminal (a pty master is a terminal). +func TestAskerAnswersForTheTerminal(t *testing.T) { + probe, err := os.Open("/dev/ptmx") + if err != nil { + t.Skip("no pseudo-terminal:", err) + } + isTerminal := term.IsTerminal(int(probe.Fd())) + probe.Close() + if !isTerminal { + t.Skip("/dev/ptmx is no terminal here") + } + var asked []string + SetAsker(func(user string, generate bool) ([]byte, error) { + asked = append(asked, fmt.Sprintf("%s %v", user, generate)) + return []byte("screen-secret"), nil + }) + t.Cleanup(func() { SetAsker(nil); readOnce.Lock(); clear(readOnce.m); readOnce.Unlock() }) + done := make(chan struct{}) + go func() { + defer close(done) + for _, user := range []string{"edge", "edge", "aggregator"} { + if pw, err := ReadPassword("/dev/ptmx", user, true); err != nil || pw != "screen-secret" { + t.Errorf("%s: %q, %v", user, pw, err) + } + } + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("the terminal was asked, not the asker") + } + if fmt.Sprint(asked) != "[edge true aggregator true]" { + t.Errorf("asked %v", asked) + } +} + // A device is classed by the file it opens, not by stat: FreeBSD stats // /dev/stdin as a device whatever it holds, so one that is no terminal is read. func TestADeviceThatIsNoTerminalIsRead(t *testing.T) { diff --git a/internal/core/const.go b/internal/core/const.go index 5abe5cd..4582ef1 100644 --- a/internal/core/const.go +++ b/internal/core/const.go @@ -29,6 +29,12 @@ func Network(host string) (string, error) { const ( MaxLogEntryBytes = 1024 * 1024 + // A tapped entry's message, fields and payload are cut to this, and its + // level, source and node to TapNameBytes, so a viewer's scrollback holds + // no source's whole line + TapEntryBytes = 16 * 1024 + TapNameBytes = 256 + FileWatcherPollInterval = 100 * time.Millisecond SessionDefaultMaxIdleTime = 30 * time.Minute diff --git a/internal/core/flow.go b/internal/core/flow.go index cd9781a..233c776 100644 --- a/internal/core/flow.go +++ b/internal/core/flow.go @@ -3,6 +3,7 @@ package core import ( "encoding/json" "strings" + "sync/atomic" "time" ) @@ -26,17 +27,81 @@ type TransportEvent struct { Entry LogEntry } -// Levels are the entry levels by rising severity, each with the words naming -// it in a line: the sources, the console sink's painter and the viewer share them -var Levels = []struct { +// Tapped is what a pipeline's flow decided about one entry: Stage names the +// stage that dropped Event.Entry (rate_limit, filters at Index, format), or is +// "" when it passed and Event.Payload is what the sinks got. +type Tapped struct { + Pipeline string + Source string // the source's id; "" for a heartbeat + Stage string + Index int + Event TransportEvent +} + +// Tap hands Tapped records to a viewer without ever waiting: a full tap drops +// and counts. Unless All is set it takes only what passed, so a strict filter +// cannot crowd those out. +type Tap struct { + ch chan Tapped + All atomic.Bool + drops atomic.Uint64 +} + +func NewTap(size int) *Tap { return &Tap{ch: make(chan Tapped, size)} } + +// Offer clips only what it can keep, so a full tap costs the pipeline nothing; +// a reader racing it costs a drop, never a wait +func (t *Tap) Offer(r Tapped) { + switch { + case r.Stage != "" && !t.All.Load(): + return + case len(t.ch) == cap(t.ch): + t.drops.Add(1) + return + } + e := &r.Event.Entry + r.Event.Payload = clip(r.Event.Payload, TapEntryBytes) + e.Message, e.Fields = clip(e.Message, TapEntryBytes), clip(e.Fields, TapEntryBytes) + e.Level, e.Source, e.Node = clip(e.Level, TapNameBytes), clip(e.Source, TapNameBytes), clip(e.Node, TapNameBytes) + select { + case t.ch <- r: + default: + t.drops.Add(1) + } +} + +func (t *Tap) Records() <-chan Tapped { return t.ch } + +// Dropped counts the records a full tap could not take +func (t *Tap) Dropped() uint64 { return t.drops.Load() } + +// clip copies the first n bytes of a longer s, so a kept record does not pin +// its line +func clip[T ~string | ~[]byte](s T, n int) T { + if len(s) <= n { + return s + } + head := make([]byte, n) + copy(head, s) + return T(head) +} + +// Level is a level: the words naming it in a line, and the further values a +// level key may hold, which prose would misread ("critical path") +type Level struct { Level string `json:"level"` Names []string `json:"names"` -}{ - {"TRACE", []string{"TRACE"}}, - {"DEBUG", []string{"DEBUG", "DBG"}}, - {"INFO", []string{"INFO", "INF"}}, - {"WARN", []string{"WARN", "WARNING"}}, - {"ERROR", []string{"ERROR", "ERR", "FATAL"}}, + Keyed []string `json:"-"` +} + +// Levels are the entry levels by rising severity: the sources, the console +// sink's painter and the viewer share them +var Levels = []Level{ + {"TRACE", []string{"TRACE"}, []string{"VERBOSE"}}, + {"DEBUG", []string{"DEBUG", "DBG"}, nil}, + {"INFO", []string{"INFO", "INF"}, []string{"I", "NOTICE", "INFORMATION"}}, + {"WARN", []string{"WARN", "WARNING"}, []string{"W"}}, + {"ERROR", []string{"ERROR", "ERR", "FATAL"}, []string{"E", "CRIT", "CRITICAL", "ALERT", "EMERG", "EMERGENCY", "PANIC", "DPANIC"}}, } // LevelWord finds the first whole word of text, in any case, that names a diff --git a/internal/core/flow_test.go b/internal/core/flow_test.go index abfa345..02f24ae 100644 --- a/internal/core/flow_test.go +++ b/internal/core/flow_test.go @@ -1,6 +1,11 @@ package core -import "testing" +import ( + "encoding/json" + "strings" + "testing" + "unsafe" +) // A line's level is its first whole word, in any case, naming a level, not the // most severe one named; a wanted level skips the names of the others. @@ -22,3 +27,41 @@ func TestLevelIsTheFirstWordNamingOne(t *testing.T) { } } } + +// A tapped record keeps at most TapEntryBytes of an entry's message, fields +// and payload and TapNameBytes of its level, source and node, copied, so a +// viewer's scrollback holds none of the source's line +func TestTapClipsLongEntriesByCopying(t *testing.T) { + line := strings.Repeat("x", MaxLogEntryBytes) + payload, fields := []byte(line), json.RawMessage(line) + tap := NewTap(1) + tap.Offer(Tapped{Event: TransportEvent{Payload: payload, Entry: LogEntry{Message: line, Fields: fields, Level: line, Source: line, Node: line}}}) + r := <-tap.Records() + e := r.Event.Entry + if len(e.Message) != TapEntryBytes || len(e.Fields) != TapEntryBytes || len(r.Event.Payload) != TapEntryBytes { + t.Fatalf("kept %d, %d and %d bytes, want %d", len(e.Message), len(e.Fields), len(r.Event.Payload), TapEntryBytes) + } + if len(e.Level) != TapNameBytes || len(e.Source) != TapNameBytes || len(e.Node) != TapNameBytes { + t.Fatalf("kept %d, %d and %d bytes of the names, want %d", len(e.Level), len(e.Source), len(e.Node), TapNameBytes) + } + for _, s := range []string{e.Message, e.Level, e.Source, e.Node} { + if unsafe.StringData(s) == unsafe.StringData(line) { + t.Fatal("a clipped part shares the source's line") + } + } + if &e.Fields[0] == &fields[0] || &r.Event.Payload[0] == &payload[0] { + t.Fatal("a clipped part shares the source's line") + } +} + +// A full tap drops and counts without copying anything, so a tap nobody +// reads costs a pipeline nothing +func TestAFullTapCostsNothing(t *testing.T) { + line := strings.Repeat("x", MaxLogEntryBytes) + tap := NewTap(1) + rec := Tapped{Event: TransportEvent{Payload: []byte(line), Entry: LogEntry{Message: line, Fields: json.RawMessage(line), Level: line}}} + tap.Offer(rec) + if allocs := testing.AllocsPerRun(100, func() { tap.Offer(rec) }); allocs != 0 || tap.Dropped() != 101 { + t.Fatalf("%v allocations an offer, %d dropped", allocs, tap.Dropped()) + } +} diff --git a/internal/filter/chain.go b/internal/filter/chain.go index a714a05..9c663d6 100644 --- a/internal/filter/chain.go +++ b/internal/filter/chain.go @@ -10,17 +10,15 @@ import ( "github.com/lixenwraith/log" ) -// Chain manages a sequence of filters, applying them in order +// Chain is a pipeline's filters, applied in order type Chain struct { filters []*Filter logger *log.Logger - // Statistics totalProcessed atomic.Uint64 totalPassed atomic.Uint64 } -// NewChain creates a new filter chain from a slice of filter configurations func NewChain(configs []config.FilterConfig, logger *log.Logger) (*Chain, error) { chain := &Chain{ filters: make([]*Filter, 0, len(configs)), @@ -41,32 +39,23 @@ func NewChain(configs []config.FilterConfig, logger *log.Logger) (*Chain, error) return chain, nil } -// Apply runs a log entry through all filters in the chain -func (c *Chain) Apply(entry core.LogEntry) bool { +// Apply runs an entry through the filters in order and returns the index of +// the one that dropped it, or -1 +func (c *Chain) Apply(entry core.LogEntry) int { c.totalProcessed.Add(1) - - // No filters means pass everything - if len(c.filters) == 0 { - c.totalPassed.Add(1) - return true - } - - // All filters must pass for i, filter := range c.filters { if !filter.Apply(entry) { c.logger.Debug("msg", "Entry filtered out", "component", "filter_chain", "filter_index", i, "filter_type", filter.config.Type) - return false + return i } } - c.totalPassed.Add(1) - return true + return -1 } -// GetStats returns aggregated statistics for the entire chain func (c *Chain) GetStats() map[string]any { filterStats := make([]map[string]any, len(c.filters)) for i, filter := range c.filters { diff --git a/internal/flow/flow.go b/internal/flow/flow.go index 2ca9088..6d085f0 100644 --- a/internal/flow/flow.go +++ b/internal/flow/flow.go @@ -13,8 +13,8 @@ import ( "github.com/lixenwraith/log" ) -// Flow manages the complete processing pipeline for log entries: -// LogEntry -> Rate Limiter -> Filters -> Formatter (with Sanitizer) -> TransportEvent +// Flow takes a pipeline's entries through rate limit, filters and format to +// the TransportEvent its sinks get type Flow struct { rateLimiter *RateLimiter filterChain *filter.Chain @@ -22,13 +22,11 @@ type Flow struct { heartbeat *HeartbeatGenerator logger *log.Logger - // Statistics totalProcessed atomic.Uint64 totalDropped atomic.Uint64 totalFormatted atomic.Uint64 } -// NewFlow creates a flow processor from configuration func NewFlow(cfg *config.FlowConfig, logger *log.Logger) (*Flow, error) { if cfg == nil { cfg = &config.FlowConfig{} @@ -38,7 +36,6 @@ func NewFlow(cfg *config.FlowConfig, logger *log.Logger) (*Flow, error) { logger: logger, } - // Create rate limiter if configured if cfg.RateLimit != nil { limiter, err := NewRateLimiter(*cfg.RateLimit, logger) if err != nil { @@ -47,7 +44,6 @@ func NewFlow(cfg *config.FlowConfig, logger *log.Logger) (*Flow, error) { f.rateLimiter = limiter } - // Create filter chain if configured if len(cfg.Filters) > 0 { chain, err := filter.NewChain(cfg.Filters, logger) if err != nil { @@ -56,14 +52,13 @@ func NewFlow(cfg *config.FlowConfig, logger *log.Logger) (*Flow, error) { f.filterChain = chain } - // Create formatter with sanitizer integration formatter, err := format.NewFormatter(cfg.Format) if err != nil { return nil, fmt.Errorf("failed to create formatter: %w", err) } f.formatter = formatter - // Create heartbeat generator with the same formatter if configured + // Heartbeats are written like the entries if cfg.Heartbeat != nil { hb, err := NewHeartbeatGenerator(cfg.Heartbeat, formatter, logger) if err != nil { @@ -82,51 +77,35 @@ func NewFlow(cfg *config.FlowConfig, logger *log.Logger) (*Flow, error) { return f, nil } -// Process applies all flow stages to a log entry -// Returns TransportEvent and whether entry passed all stages -func (f *Flow) Process(entry core.LogEntry) (core.TransportEvent, bool) { +// Process applies the flow's stages to an entry and returns the event for the +// sinks, or the stage that dropped it and, for filters, which one +func (f *Flow) Process(entry core.LogEntry) (event core.TransportEvent, stage string, index int) { f.totalProcessed.Add(1) - - // Stage 1: Rate limiting - if f.rateLimiter != nil { - if !f.rateLimiter.Allow(entry) { - f.totalDropped.Add(1) - return core.TransportEvent{}, false - } + if f.rateLimiter != nil && !f.rateLimiter.Allow(entry) { + f.totalDropped.Add(1) + return core.TransportEvent{Entry: entry}, "rate_limit", 0 } - - // Stage 2: Filtering if f.filterChain != nil { - if !f.filterChain.Apply(entry) { + if i := f.filterChain.Apply(entry); i >= 0 { f.totalDropped.Add(1) - return core.TransportEvent{}, false + return core.TransportEvent{Entry: entry}, "filters", i } } - - // Stage 3: Formatting formatted, err := f.formatter.Format(entry) if err != nil { f.logger.Error("msg", "Failed to format log entry", "component", "flow", "error", err) f.totalDropped.Add(1) - return core.TransportEvent{}, false + return core.TransportEvent{Entry: entry}, "format", 0 } - f.totalFormatted.Add(1) - - // Create transport event - event := core.TransportEvent{ - Time: entry.Time, - Payload: formatted, - Entry: entry, // Carry structured entry so chain sinks are format-independent - } - - return event, true + // The structured entry rides along so chain sinks are format-independent + return core.TransportEvent{Time: entry.Time, Payload: formatted, Entry: entry}, "", 0 } -// StartHeartbeat starts the heartbeat generator if configured -// Returns channel that emits heartbeat events +// StartHeartbeat returns the heartbeat events until ctx ends, or nil without +// a heartbeat func (f *Flow) StartHeartbeat(ctx context.Context) <-chan core.TransportEvent { if f.heartbeat == nil { return nil @@ -134,7 +113,6 @@ func (f *Flow) StartHeartbeat(ctx context.Context) <-chan core.TransportEvent { return f.heartbeat.Start(ctx) } -// GetStats returns flow statistics func (f *Flow) GetStats() map[string]any { stats := map[string]any{ "total_processed": f.totalProcessed.Load(), diff --git a/internal/logline/logline.go b/internal/logline/logline.go new file mode 100644 index 0000000..fb96d02 --- /dev/null +++ b/internal/logline/logline.go @@ -0,0 +1,408 @@ +// Package logline reads a log line of any shape (vif records, LogWisp's json, +// lw's own log, pino, zap, slog, docker, journald, ECS, GCP, plain text) into +// one set of roles, and names the runes a terminal would act on. It links no +// terminal code, so a viewer, a sink and the configuration page can share it. +package logline + +import ( + "bytes" + "encoding/hex" + "math" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/lixenwraith/logwisp/internal/core" +) + +// Line is what a log line says, wherever its writer put it +type Line struct { + Time time.Time // zero when the line names none readable: the entry's time stands in + Stamp string // the time as the line wrote it + Level string // a core.Levels level, or a value outside their order as written (PROC) + Tag string // who wrote it: sub, source, logger and the like, else the entry's source + Message string + Fields []Field // what no role took, in the writer's order + Layout string // where the roles were found, so a reader can check the guess +} + +// Field is a value no role took. Key is its dotted path, "" for a positional +// value; Value is a string's text or any other value as its JSON. +type Field struct { + Key, Value string +} + +const ( + roleTime = iota + roleLevel + roleTag + roleMsg +) + +// roles are the keys each role is read from, the first present wins: a dotted +// key is a path into nested objects or a flat key spelled with dots. trace is +// the last tag, so it names LogWisp json's source but not vif's call chain. +var roles = [...][]string{ + roleTime: {"time", "ts", "timestamp", "@timestamp", "t", "datetime", "__REALTIME_TIMESTAMP", "@t"}, + roleLevel: {"level", "lvl", "severity", "log.level", "levelname", "loglevel", "PRIORITY", "@l"}, + roleTag: {"sub", "source", "logger", "component", "name", "_SYSTEMD_UNIT", "trace"}, + roleMsg: {"msg", "message", "MESSAGE", "@m", "@mt", "log", "text", "event"}, +} + +// envelopes hold a record's payload one level down; the first of them present +// is opened, and without a message of its own the record takes the envelope's +// first discriminator, or an array's first string (vif: fields.msg|type|ev) +var ( + envelopes = []string{"fields", "data", "attrs", "payload"} + discriminators = []string{"msg", "type", "ev", "message"} +) + +// nestMax bounds the depth flattened into dotted paths, and so their length +const nestMax = 8 + +// member is one leaf of a record; no role takes the envelope's +type member struct { + key, val string + kind byte + env bool + used bool +} + +// Read finds e's roles in its message, a JSON record (also one inside such a +// record's message) or text with a leading RFC3339 stamp, and in its fields; +// a role the line does not name is the entry's. +func Read(e core.LogEntry) Line { + l, at, ok := record(strings.TrimSpace(e.Message), "") + if ok { + if in, _, ok := record(strings.TrimSpace(l.Message), at); ok { + l = merge(l, in) + } + } else { + l = Line{Message: e.Message, Layout: "text"} + if stamp, rest, ok := strings.Cut(e.Message, " "); ok { + if at, ok := readStamp(stamp); ok { + l.Time, l.Stamp, l.Message, l.Layout = at, stamp, strings.TrimLeft(rest, " "), "text, stamped" + } + } + } + if f := strings.TrimSpace(string(e.Fields)); f != "" { + _, kind, _ := scanValue(f, 0) + ms, msg, at := envelope(nil, "fields", f, kind) + if l.Message == "" && msg >= 0 { + ms[msg].used = true + l.Message, l.Layout = unquote(ms[msg].val, kStr), "entry, message in "+at + } + l.Fields = appendFields(l.Fields, ms) + } + if l.Level == "" { + l.Level = level("", e.Level) + } + if l.Tag == "" { + l.Tag = e.Source + } + l.Message = strings.TrimRight(l.Message, "\r\n") + return l +} + +// record reads s as a JSON object; in names where s sat as another record's +// message, at where s's own message is. The members before a malformed part +// count, so a clipped line still shows its roles; ok is false without any. +func record(s, in string) (l Line, at string, ok bool) { + var top []member + whole := eachField(s, 0, func(k, v string, kind byte) bool { + top = append(top, member{key: k, val: v, kind: kind}) + return true + }) + if len(top) == 0 { + return Line{}, "", false + } + env := -1 + for _, name := range envelopes { + for i := range top { + if env < 0 && top[i].key == name && (top[i].kind == kObj || top[i].kind == kArr) { + env = i + } + } + } + var ms []member + msg, envAt := -1, "" + for i, m := range top { + if i == env { + ms, msg, envAt = envelope(ms, m.key, m.val, m.kind) + } else { + ms = leaves(ms, m.key, m.val, m.kind, false, 0) + } + } + + if m := find(ms, roleTime); m != nil { + l.Stamp = unquote(m.val, m.kind) + l.Time, _ = readTime(l.Stamp) + } + if m := find(ms, roleLevel); m != nil { + l.Level = level(m.key, unquote(m.val, m.kind)) + } + if m := find(ms, roleTag); m != nil { + l.Tag = unquote(m.val, m.kind) + } + if m := find(ms, roleMsg); m != nil { + l.Message, at = unquote(m.val, m.kind), m.key + } else if msg >= 0 { + ms[msg].used = true + l.Message, at = unquote(ms[msg].val, kStr), envAt + } + l.Fields = appendFields(nil, ms) + + l.Layout = "json" + if in != "" { + l.Layout += " in " + in + } + if !whole { + l.Layout += ", cut short" + } + if at != "" { + l.Layout += ", message in " + at + } else { + l.Layout += ", no message" + } + return l, at, true +} + +// envelope appends the leaves of envelope name's value v; msg indexes its +// message in ms and at says where it was, -1 and "" when it holds none +func envelope(ms []member, name, v string, kind byte) (_ []member, msg int, at string) { + msg, rank, slot := -1, len(discriminators), -1 + note := func(key string, kind byte, i int) { + for r := 0; r < rank && kind == kStr; r++ { + if discriminators[r] == key { + msg, rank, slot = len(ms), r, i + } + } + } + switch kind { + case kObj: + eachField(v, 0, func(k, fv string, fk byte) bool { + note(k, fk, -1) + ms = leaves(ms, k, fv, fk, true, 1) + return true + }) + case kArr: + // lw's own log writes key/value pairs; LogWisp json, the message alone + n, pairs := 0, true + eachItem(v, 0, func(_ string, k byte) { + pairs = pairs && (n%2 == 1 || k == kStr) + n++ + }) + pairs = pairs && n > 0 && n%2 == 0 + i, key := 0, "" + eachItem(v, 0, func(iv string, ik byte) { + switch { + case pairs && i%2 == 0: + key = unquote(iv, ik) + case pairs: + note(key, ik, i) + ms = leaves(ms, key, iv, ik, true, 1) + default: + if i == 0 && ik == kStr { + msg, slot = len(ms), 0 + } + ms = leaves(ms, "", iv, ik, true, 1) + } + i++ + }) + default: + ms = leaves(ms, name, v, kind, true, 0) + } + switch { + case msg < 0: + case slot < 0: + at = name + "." + ms[msg].key + default: + at = name + "[" + strconv.Itoa(slot) + "]" + } + return ms, msg, at +} + +// leaves appends v under key, an object as one leaf per member at its dotted +// path, so roles and readers see nested keys as if written flat +func leaves(ms []member, key, v string, kind byte, env bool, depth int) []member { + if kind == kObj && depth < nestMax { + n := len(ms) + if eachField(v, 0, func(k, fv string, fk byte) bool { + if key != "" { + k = key + "." + k + } + ms = leaves(ms, k, fv, fk, env, depth+1) + return true + }) && len(ms) > n { + return ms + } + ms = ms[:n] + } + return append(ms, member{key: key, val: v, kind: kind, env: env}) +} + +// find takes the leaf holding role r, outside the envelope and holding a +// string or a number: a null, a flag or a list names no role +func find(ms []member, r int) *member { + for _, key := range roles[r] { + for i := range ms { + m := &ms[i] + if m.key == key && !m.env && !m.used && (m.kind == kStr || m.kind == kNum) { + m.used = true + return m + } + } + } + return nil +} + +func appendFields(fs []Field, ms []member) []Field { + for _, m := range ms { + if !m.used { + fs = append(fs, Field{m.key, unquote(m.val, m.kind)}) + } + } + return fs +} + +// merge lays a record found in out's message over out: its roles win, out's +// fill the ones it does not name, and out's fields come first +func merge(out, in Line) Line { + if in.Time.IsZero() { + in.Time, in.Stamp = out.Time, out.Stamp + } + if in.Level == "" { + in.Level = out.Level + } + if in.Tag == "" { + in.Tag = out.Tag + } + in.Fields = append(out.Fields, in.Fields...) + return in +} + +// level reads a level value: a name in any case, or a number on its key's +// scale (journald's syslog PRIORITY, pino's tens, else slog's and +// lixenwraith/log's); any other value is kept as written, outside the order +func level(key, v string) string { + if n, ok := parseInt64(v); ok { + switch { + case key == "PRIORITY": + return [...]string{"ERROR", "ERROR", "ERROR", "ERROR", "WARN", "INFO", "INFO", "DEBUG"}[min(max(n, 0), 7)] + case n >= 10: + return [...]string{"TRACE", "DEBUG", "INFO", "WARN", "ERROR", "ERROR"}[min(n/10-1, 5)] + case n <= -8: + return "TRACE" + case n < 0: + return "DEBUG" + case n < 4: + return "INFO" + case n < 8: + return "WARN" + } + return "ERROR" + } + for _, l := range core.Levels { + for _, names := range [2][]string{l.Names, l.Keyed} { + for _, name := range names { + if strings.EqualFold(v, name) { + return l.Level + } + } + } + } + return v +} + +// readStamp reads an RFC3339 stamp, one without a zone as UTC +func readStamp(s string) (time.Time, bool) { + at, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + at, err = time.Parse("2006-01-02T15:04:05.999999999", s) + } + return at, err == nil +} + +// readTime reads an RFC3339 stamp, or a number since 1970 in seconds, +// milliseconds or microseconds, told apart by its magnitude +func readTime(s string) (time.Time, bool) { + if at, ok := readStamp(s); ok { + return at, true + } + whole, frac, _ := strings.Cut(s, ".") + n, ok := parseInt64(whole) + if !ok || n < 0 { + return time.Time{}, false + } + unit := int64(1e3) + switch { + case n < 1e11: + unit = 1e9 + case n < 1e14: + unit = 1e6 + } + if n > math.MaxInt64/unit { + return time.Time{}, false + } + ns := n * unit + for i := 0; i < len(frac); i++ { + if frac[i] < '0' || frac[i] > '9' { + return time.Time{}, false + } + unit /= 10 + ns += int64(frac[i]-'0') * unit + } + return time.Unix(0, ns), true +} + +// EscapeControls writes what a terminal would act on or reorder (Control) as +// , keeping the final newline: a log line cannot drive the terminal. +// Other invisible characters stay: emoji and Indic scripts need ZWJ. +func EscapeControls(p []byte) []byte { + body, newline := bytes.CutSuffix(p, []byte{'\n'}) + if printableASCII(body) { + return p + } + out := make([]byte, 0, len(p)+16) + for len(body) > 0 { + r, n := utf8.DecodeRune(body) + if !Control(r, n) { + out = append(out, body[:n]...) + } else { + out = append(out, '<') + out = hex.AppendEncode(out, body[:n]) + out = append(out, '>') + } + body = body[n:] + } + if newline { + out = append(out, '\n') + } + return out +} + +// Control reports whether a terminal would act on the rune r, decoded from +// size bytes, or reorder text by it: C0 and C1 controls but tab, DEL, bidi +// controls, line separators and invalid UTF-8 +func Control(r rune, size int) bool { + switch { + case r == utf8.RuneError && size == 1: + return true + case r < 0x20: + return r != '\t' + case r >= 0x7f && r <= 0x9f, r == 0x061c, r == 0x200e, r == 0x200f, + r >= 0x2028 && r <= 0x202e, r >= 0x2066 && r <= 0x2069: + return true + } + return false +} + +func printableASCII(b []byte) bool { + for _, c := range b { + if (c < 0x20 || c > 0x7e) && c != '\t' { + return false + } + } + return true +} diff --git a/internal/logline/logline_test.go b/internal/logline/logline_test.go new file mode 100644 index 0000000..ca5496f --- /dev/null +++ b/internal/logline/logline_test.go @@ -0,0 +1,138 @@ +package logline + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/lixenwraith/logwisp/internal/core" +) + +// arrived is a line as a source passes one it does not parse: whole, at its +// arrival, with the level its first level word names +func arrived(line string) core.LogEntry { + level, _, _ := core.LevelWord(line, "") + return core.LogEntry{Time: time.Unix(1, 0), Source: "app.log", Level: level, Message: line} +} + +// Each writer's line yields its time, level, tag and message from where that +// writer puts them, the rest as fields in its order; the want is "layout | +// stamp=time | level | tag | message | fields". vif's, lw's and slog's lines +// are their writers' output; the others follow their formats' documentation. +func TestReadFindsTheRolesWhereTheirWriterPutsThem(t *testing.T) { + for _, c := range []struct { + in core.LogEntry + want string + }{ + // vif: the tag is sub, the message the first of fields.msg, type and ev + {arrived(`{"time":"2026-10-10T19:09:54.251623265Z","level":"INFO","sub":"fsm","run":3,"tick":1200,"fields":{"msg":"transition","from":"idle","to":"run","elapsed":1500000}}`), + `json, message in fields.msg | 2026-10-10T19:09:54.251623265Z=2026-10-10T19:09:54.251623265Z | INFO | fsm | "transition" | run="3" tick="1200" from="idle" to="run" elapsed="1500000"`}, + {arrived(`{"time":"2026-10-10T19:09:54.251635197Z","level":"WARN","sub":"fsm","run":3,"tick":1200,"trace":"main -> main","fields":{"msg":"slow tick","ms":73.25}}`), + `json, message in fields.msg | 2026-10-10T19:09:54.251635197Z=2026-10-10T19:09:54.251635197Z | WARN | fsm | "slow tick" | run="3" tick="1200" trace="main -> main" ms="73.25"`}, + {arrived(`{"time":"2026-10-10T19:09:54.251635488Z","level":"INFO","sub":"journal","run":0,"tick":0,"fields":{"jseq":7,"domain":"player","ev":"EventX","payload":"x = 1\ny = 2\n"}}`), + `json, message in fields.ev | 2026-10-10T19:09:54.251635488Z=2026-10-10T19:09:54.251635488Z | INFO | journal | "EventX" | run="0" tick="0" jseq="7" domain="player" payload="x = 1\ny = 2\n"`}, + {arrived(`{"time":"2026-08-29T21:15:23.33805528-04:00","level":"PROC","run":0,"tick":0,"frame":0,"fields":{"type":"proc","sequence":1,"processed_logs":0}}`), + `json, message in fields.type | 2026-08-29T21:15:23.33805528-04:00=2026-08-30T01:15:23.33805528Z | PROC | app.log | "proc" | run="0" tick="0" frame="0" sequence="1" processed_logs="0"`}, + {arrived(`{"time":"2026-08-30T01:27:39.654615818Z","level":"INFO","sub":"anchor","run":0,"tick":0,"frame":0,"fields":{"schema":10,"jseq":0,"tick_ns":50000000}}`), + `json, no message | 2026-08-30T01:27:39.654615818Z=2026-08-30T01:27:39.654615818Z | INFO | anchor | "" | run="0" tick="0" frame="0" schema="10" jseq="0" tick_ns="50000000"`}, + // LogWisp json: trace is the source, the message alone is fields[0], and a + // record the console source read whole sits there, over the outer roles + {arrived(`{"time":"2026-10-10T12:00:00Z","level":"WARN","trace":"app.log","message":"disk almost full","fields":{"disk":"/dev/sda1","nested":{"a":[1,2]},"pct":91}}`), + `json, message in message | 2026-10-10T12:00:00Z=2026-10-10T12:00:00Z | WARN | app.log | "disk almost full" | disk="/dev/sda1" nested.a="[1,2]" pct="91"`}, + {arrived(`{"time":"2026-10-10T20:30:12.385839584Z","level":"INFO","trace":"app.log","fields":["TRACE deep detail"]}`), + `json, message in fields[0] | 2026-10-10T20:30:12.385839584Z=2026-10-10T20:30:12.385839584Z | INFO | app.log | "TRACE deep detail" | `}, + {arrived(`{"time":"2026-10-10T20:43:03.350727184Z","level":"INFO","trace":"console","fields":["{\"time\":\"2026-10-10T12:00:00Z\",\"msg\":\"disk almost full\",\"fields\":{\"disk\":\"/dev/sda1\"}}"]}`), + `json in fields[0], message in msg | 2026-10-10T12:00:00Z=2026-10-10T12:00:00Z | INFO | console | "disk almost full" | disk="/dev/sda1"`}, + {arrived(`{"time":"2026-10-10T20:43:38.342629146Z","level":"INFO","trace":"console","fields":["{\"msg\":\"no time here\",\"fields\":{\"k\":1}}"]}`), + `json in fields[0], message in msg | 2026-10-10T20:43:38.342629146Z=2026-10-10T20:43:38.342629146Z | INFO | console | "no time here" | k="1"`}, + {arrived(`{"time":"2026-10-10T19:06:34.86389785Z","node":"edge-01","source":"app.log","level":"WARN","message":"disk almost full","fields":{"pct":91}}`), + `json, message in message | 2026-10-10T19:06:34.86389785Z=2026-10-10T19:06:34.86389785Z | WARN | app.log | "disk almost full" | node="edge-01" pct="91"`}, + // lw's own log: key/value pairs, in the line or in what the file source kept + {arrived(`{"time":"2026-10-10T20:29:33.279610845Z","level":"INFO","fields":["msg","Initializing pipeline","pipeline","cli"]}`), + `json, message in fields[1] | 2026-10-10T20:29:33.279610845Z=2026-10-10T20:29:33.279610845Z | INFO | app.log | "Initializing pipeline" | pipeline="cli"`}, + {core.LogEntry{Source: "lw.log", Level: "INFO", Fields: json.RawMessage(`["msg","LogWisp starting","status_reporter",false]`)}, + `entry, message in fields[1] | =- | INFO | lw.log | "LogWisp starting" | status_reporter="false"`}, + {core.LogEntry{Source: "app.log", Level: "warning", Message: "disk almost full", Fields: json.RawMessage(`{"pct":91}`)}, + `text | =- | WARN | app.log | "disk almost full" | pct="91"`}, + // pino's tens, zap's seconds, slog's scale and groups + {arrived(`{"level":30,"time":1728561600000,"pid":1,"hostname":"h","msg":"pino style"}`), + `json, message in msg | 1728561600000=2024-10-10T12:00:00Z | INFO | app.log | "pino style" | pid="1" hostname="h"`}, + {arrived(`{"level":"info","ts":1728561600.123456,"caller":"main.go:12","msg":"zap style","n":1}`), + `json, message in msg | 1728561600.123456=2024-10-10T12:00:00.123456Z | INFO | app.log | "zap style" | caller="main.go:12" n="1"`}, + {arrived(`{"time":"2026-10-10T20:30:23.451515887Z","level":"INFO","msg":"slog style","user":{"id":7,"name":"a"}}`), + `json, message in msg | 2026-10-10T20:30:23.451515887Z=2026-10-10T20:30:23.451515887Z | INFO | app.log | "slog style" | user.id="7" user.name="a"`}, + {arrived(`{"time":"2026-10-10T20:39:59.809016667Z","level":-4,"msg":"slog level as a number","attempt":2}`), + `json, message in msg | 2026-10-10T20:39:59.809016667Z=2026-10-10T20:39:59.809016667Z | DEBUG | app.log | "slog level as a number" | attempt="2"`}, + // docker's log ends in its newline; the level is the entry's + {arrived(`{"log":"docker line ERROR x\n","stream":"stderr","time":"2026-10-10T12:00:00.1Z"}`), + `json, message in log | 2026-10-10T12:00:00.1Z=2026-10-10T12:00:00.1Z | ERROR | app.log | "docker line ERROR x" | stream="stderr"`}, + // journald writes numbers as strings: microseconds, syslog priority + {arrived(`{"__REALTIME_TIMESTAMP":"1728561600123456","PRIORITY":"3","_SYSTEMD_UNIT":"nginx.service","MESSAGE":"journald"}`), + `json, message in MESSAGE | 1728561600123456=2024-10-10T12:00:00.123456Z | ERROR | nginx.service | "journald" | `}, + {arrived(`{"@timestamp":"2026-10-10T12:00:00Z","log.level":"warn","message":"ecs flat"}`), + `json, message in message | 2026-10-10T12:00:00Z=2026-10-10T12:00:00Z | WARN | app.log | "ecs flat" | `}, + {arrived(`{"@timestamp":"2026-10-10T12:00:00Z","log":{"level":"error"},"message":"ecs nested"}`), + `json, message in message | 2026-10-10T12:00:00Z=2026-10-10T12:00:00Z | ERROR | app.log | "ecs nested" | `}, + {arrived(`{"severity":"CRITICAL","timestamp":"2026-10-10T12:00:00Z","message":"gcp"}`), + `json, message in message | 2026-10-10T12:00:00Z=2026-10-10T12:00:00Z | ERROR | app.log | "gcp" | `}, + {arrived(`{"a":1,"b":"no known keys","source":null}`), + `json, no message | =- | | app.log | "" | a="1" b="no known keys" source="null"`}, + // a line the tap clipped keeps the roles before the cut + {arrived(`{"time":"2026-10-10T12:00:00Z","level":"INFO","msg":"clipped","big":"aaaa`), + `json, cut short, message in msg | 2026-10-10T12:00:00Z=2026-10-10T12:00:00Z | INFO | app.log | "clipped" | `}, + // nesting past nestMax stays one value, bounding the paths + {arrived(`{"msg":"deep","a":{"b":{"c":{"d":{"e":{"f":{"g":{"h":{"i":{"j":1}}}}}}}}}}`), + `json, message in msg | =- | | app.log | "deep" | a.b.c.d.e.f.g.h.i="{\"j\":1}"`}, + {arrived(`2026-10-10T12:00:00+02:00 ERROR db: connection refused`), + `text, stamped | 2026-10-10T12:00:00+02:00=2026-10-10T10:00:00Z | ERROR | app.log | "ERROR db: connection refused" | `}, + {arrived(`2026-10-10T12:00:00.5 INFO a stamp without a zone is UTC`), + `text, stamped | 2026-10-10T12:00:00.5=2026-10-10T12:00:00.5Z | INFO | app.log | "INFO a stamp without a zone is UTC" | `}, + // what only looks like a stamp is text: no day is invented + {arrived(`2026-13-45T99:99:99Z ERROR x`), + `text | =- | ERROR | app.log | "2026-13-45T99:99:99Z ERROR x" | `}, + {arrived(`2026-10-10T12:00:00Zjunk ERROR x`), + `text | =- | ERROR | app.log | "2026-10-10T12:00:00Zjunk ERROR x" | `}, + {arrived(`{not json} WARNING disk`), + `text | =- | WARN | app.log | "{not json} WARNING disk" | `}, + } { + if got := show(Read(c.in)); got != c.want { + t.Errorf("%s\n got: %s\nwant: %s", c.in.Message, got, c.want) + } + } +} + +func show(l Line) string { + at := "-" + if !l.Time.IsZero() { + at = l.Time.UTC().Format(time.RFC3339Nano) + } + var fs []string + for _, f := range l.Fields { + fs = append(fs, fmt.Sprintf("%s=%q", f.Key, f.Value)) + } + return fmt.Sprintf("%s | %s=%s | %s | %s | %q | %s", l.Layout, l.Stamp, at, l.Level, l.Tag, l.Message, strings.Join(fs, " ")) +} + +// Everything a terminal would act on is escaped; tabs and the final newline, +// which it only lays out, are kept. +func TestEscapeControlsKeepsOnlyLayout(t *testing.T) { + for in, want := range map[string]string{ + "plain\ttext\n": "plain\ttext\n", + "a\x1b[2Jb\n": "a<1b>[2Jb\n", + "bell\x07 del\x7f\n": "bell<07> del<7f>\n", + "inner\nnewline\n": "inner<0a>newline\n", + "c1 \u009b31m\n": "c1 31m\n", + "bidi \u202eevil\n": "bidi evil\n", + "isolate \u2066x\u2069\n": "isolate x\n", + "line\u2028sep\n": "linesep\n", + "zwj \U0001f468\u200d\U0001f469\n": "zwj \U0001f468\u200d\U0001f469\n", + "bad \xff utf8": "bad utf8", + "ünïcødé and \ufffd kept\n": "ünïcødé and \ufffd kept\n", + } { + if got := string(EscapeControls([]byte(in))); got != want { + t.Errorf("%q: got %q, want %q", in, got, want) + } + } +} diff --git a/internal/logline/scan.go b/internal/logline/scan.go new file mode 100644 index 0000000..f9cca27 --- /dev/null +++ b/internal/logline/scan.go @@ -0,0 +1,226 @@ +package logline + +import "encoding/json" + +// JSON token kinds +const ( + kNone byte = 0 + kStr byte = 's' + kNum byte = 'n' + kBool byte = 'b' + kNull byte = 'z' + kObj byte = 'o' + kArr byte = 'a' +) + +func skipSpace(s string, i int) int { + for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\r' || s[i] == '\n') { + i++ + } + return i +} + +func isNumByte(c byte) bool { + return c == '-' || c == '+' || c == '.' || c == 'e' || c == 'E' || (c >= '0' && c <= '9') +} + +// scanString returns the index past the closing quote; i indexes the opener. +func scanString(s string, i int) (int, bool) { + for i++; i < len(s); i++ { + switch s[i] { + case '\\': + i++ + case '"': + return i + 1, true + } + } + return i, false +} + +// scanValue returns the end index and kind of the value starting at i. +func scanValue(s string, i int) (int, byte, bool) { + if i >= len(s) { + return i, kNone, false + } + switch c := s[i]; c { + case '"': + e, ok := scanString(s, i) + return e, kStr, ok + + case '{', '[': + opener, closer, kind := byte('{'), byte('}'), kObj + if c == '[' { + opener, closer, kind = '[', ']', kArr + } + depth := 0 + for i < len(s) { + ch := s[i] + if ch == '"' { + e, ok := scanString(s, i) + if !ok { + return e, kind, false + } + i = e + continue + } + if ch == opener { + depth++ + } else if ch == closer { + depth-- + if depth == 0 { + return i + 1, kind, true + } + } + i++ + } + return i, kind, false + + case 't', 'f', 'n': + kind := kBool + if c == 'n' { + kind = kNull + } + for i < len(s) && s[i] >= 'a' && s[i] <= 'z' { + i++ + } + return i, kind, true + + default: + e := i + for e < len(s) && isNumByte(s[e]) { + e++ + } + return e, kNum, e > i + } +} + +// eachField calls fn for each member of the object at i, which must index '{', +// until fn returns false; it reports whether the object is well formed. +func eachField(s string, i int, fn func(key, val string, kind byte) bool) bool { + if i >= len(s) || s[i] != '{' { + return false + } + i = skipSpace(s, i+1) + if i < len(s) && s[i] == '}' { + return true + } + for i < len(s) { + if s[i] != '"' { + return false + } + ke, ok := scanString(s, i) + if !ok { + return false + } + key := s[i+1 : ke-1] + + i = skipSpace(s, ke) + if i >= len(s) || s[i] != ':' { + return false + } + i = skipSpace(s, i+1) + + vs := i + ve, kind, ok := scanValue(s, i) + if !ok { + return false + } + if !fn(key, s[vs:ve], kind) { + return true + } + + i = skipSpace(s, ve) + if i >= len(s) { + return false + } + switch s[i] { + case ',': + i = skipSpace(s, i+1) + case '}': + return true + default: + return false + } + } + return false +} + +// eachItem calls fn for each element of the array at i, which must index '['; +// it reports whether the array is well formed. +func eachItem(s string, i int, fn func(val string, kind byte)) bool { + if i >= len(s) || s[i] != '[' { + return false + } + i = skipSpace(s, i+1) + if i < len(s) && s[i] == ']' { + return true + } + for i < len(s) { + ve, kind, ok := scanValue(s, i) + if !ok { + return false + } + fn(s[i:ve], kind) + i = skipSpace(s, ve) + if i >= len(s) { + return false + } + switch s[i] { + case ',': + i = skipSpace(s, i+1) + case ']': + return true + default: + return false + } + } + return false +} + +// unquote returns the content of a string token, decoding only when it holds +// an escape; any other token is returned as its JSON. +func unquote(tok string, kind byte) string { + if kind != kStr || len(tok) < 2 { + return tok + } + in := tok[1 : len(tok)-1] + for i := 0; i < len(in); i++ { + if in[i] == '\\' { + var s string + if json.Unmarshal([]byte(tok), &s) == nil { + return s + } + break + } + } + return in +} + +// parseInt64 parses a complete decimal integer token without allocating. +func parseInt64(s string) (int64, bool) { + if len(s) == 0 { + return 0, false + } + i, neg := 0, false + if s[0] == '-' || s[0] == '+' { + neg = s[0] == '-' + i++ + } + if i >= len(s) { + return 0, false + } + var v int64 + for ; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return 0, false + } + v = v*10 + int64(s[i]-'0') + if v < 0 { + return 0, false + } + } + if neg { + v = -v + } + return v, true +} diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index e0d65dc..0ede650 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -21,13 +21,11 @@ import ( type Pipeline struct { Config *config.PipelineConfig - // Components Registry *Registry - Sources map[string]source.Source // Track instances by ID + Sources map[string]source.Source Sinks map[string]sink.Sink Sessions *session.Manager - // Pipeline flow Flow *flow.Flow Stats *PipelineStats logger *log.Logger @@ -35,7 +33,8 @@ type Pipeline struct { // Sinks that stall dispatch rather than drop (core.CapBackpressure) backpressure map[string]bool - // Runtime + tap atomic.Pointer[core.Tap] // a viewer's copy of what the flow decides + ctx context.Context cancel context.CancelFunc wg sync.WaitGroup @@ -45,7 +44,6 @@ type Pipeline struct { finishedOnce sync.Once } -// PipelineStats contains runtime statistics for a pipeline type PipelineStats struct { StartTime time.Time TotalEntriesDroppedBySink atomic.Uint64 @@ -54,18 +52,14 @@ type PipelineStats struct { FlowStats map[string]any } -// NewPipeline creates a new pipeline with registry support func NewPipeline( cfg *config.PipelineConfig, logger *log.Logger, ) (*Pipeline, error) { - // Create pipeline context pipelineCtx, pipelineCancel := context.WithCancel(context.Background()) - // Create session manager with default timeout sessionManager := session.NewManager(core.SessionDefaultMaxIdleTime) - // Create pipeline instance with registry pipeline := &Pipeline{ Config: cfg, Registry: NewRegistry(cfg.Name, logger), @@ -81,17 +75,14 @@ func NewPipeline( finished: make(chan struct{}), } - // Create flow processor flowProcessor, err := flow.NewFlow(cfg.Flow, logger) if err != nil { - // If flow fails, stop session manager pipelineCancel() sessionManager.Stop() return nil, fmt.Errorf("failed to create flow processor: %w", err) } pipeline.Flow = flowProcessor - // Initialize sources and sinks if err := pipeline.initializeComponents(); err != nil { pipelineCancel() sessionManager.Stop() @@ -102,10 +93,8 @@ func NewPipeline( } func (p *Pipeline) initializeComponents() error { - // Create sources based on plugin config if available if len(p.Config.PluginSources) > 0 { for _, srcCfg := range p.Config.PluginSources { - // Create session proxy for this source instance sessionProxy := session.NewProxy(p.Sessions, srcCfg.ID) src, err := p.Registry.CreateSource( @@ -116,12 +105,11 @@ func (p *Pipeline) initializeComponents() error { sessionProxy, ) if err != nil { - return err // the registry names the plugin + return p.part("source", srcCfg.ID, err) // the registry names the plugin } - // Check and inject capabilities using core interfaces if err := p.initSourceCapabilities(src, srcCfg); err != nil { - return fmt.Errorf("failed to initiate capabilities for source %s: %w", srcCfg.ID, err) + return p.part("source", srcCfg.ID, fmt.Errorf("failed to initiate capabilities for source %s: %w", srcCfg.ID, err)) } p.Sources[srcCfg.ID] = src @@ -130,10 +118,8 @@ func (p *Pipeline) initializeComponents() error { return fmt.Errorf("no plugin sources defined") } - // Create sinks based on plugin config if available if len(p.Config.PluginSinks) > 0 { for _, sinkCfg := range p.Config.PluginSinks { - // Create session proxy for this sink instance sessionProxy := session.NewProxy(p.Sessions, sinkCfg.ID) snk, err := p.Registry.CreateSink( @@ -144,12 +130,11 @@ func (p *Pipeline) initializeComponents() error { sessionProxy, ) if err != nil { - return err // the registry names the plugin + return p.part("sink", sinkCfg.ID, err) // the registry names the plugin } - // Check and inject capabilities using core interfaces if err := p.initSinkCapabilities(snk, sinkCfg); err != nil { - return fmt.Errorf("failed to initiate capabilities for sink %s: %w", sinkCfg.ID, err) + return p.part("sink", sinkCfg.ID, fmt.Errorf("failed to initiate capabilities for sink %s: %w", sinkCfg.ID, err)) } p.Sinks[sinkCfg.ID] = snk @@ -161,24 +146,34 @@ func (p *Pipeline) initializeComponents() error { return nil } -// initSourceCapabilities checks and injects optional capabilities +// PartError is a source's or sink's error, which names its part for a screen +// to place; its text is the error's own +type PartError struct { + Pipeline, Role, ID string + Err error +} + +func (e *PartError) Error() string { return e.Err.Error() } +func (e *PartError) Unwrap() error { return e.Err } + +func (p *Pipeline) part(role, id string, err error) error { + return &PartError{p.Config.Name, role, id, err} +} + func (p *Pipeline) initSourceCapabilities(s source.Source, cfg config.PluginSourceConfig) error { - // Initiate and activate source capabilities var hasTLS, hasAuth bool for _, c := range s.Capabilities() { switch c { - // Network capabilities case core.CapNetLimit: - continue // No-op for now, placeholder + continue case core.CapTLS, core.CapProxyTLS: hasTLS = true case core.CapAuth: hasAuth = true - // Session capabilities case core.CapSessionAware: case core.CapMultiSession: - continue // TODO + continue default: return fmt.Errorf("unknown capability type: %s", c) @@ -202,27 +197,23 @@ func checkAuthCapability(hasTLS, hasAuth bool) error { return nil } -// initSinkCapabilities checks and injects optional capabilities func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig) error { - // Initiate and activate sink capabilities var hasTLS, hasAuth bool for _, c := range s.Capabilities() { switch c { case core.CapBackpressure: p.backpressure[cfg.ID] = true - // Network capabilities case core.CapNetLimit: - continue // No-op for now, placeholder + continue case core.CapTLS, core.CapProxyTLS: hasTLS = true case core.CapAuth: hasAuth = true - // Session capabilities case core.CapSessionAware: case core.CapMultiSession: - continue // TODO + continue default: return fmt.Errorf("unknown capability type: %s", c) @@ -238,19 +229,23 @@ func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig // run connects the subscribed sources through the flow to the sinks. It // returns once every source channel has closed, at Stop or at end of input. -func (p *Pipeline) run(subscriptions []<-chan core.LogEntry) { +func (p *Pipeline) run(ids []string, subscriptions []<-chan core.LogEntry) { defer p.wg.Done() defer p.finish() defer p.logger.Info("msg", "Pipeline processing loop stopped", "pipeline", p.Config.Name) var componentWg sync.WaitGroup - for _, ch := range subscriptions { + for i, ch := range subscriptions { componentWg.Add(1) go func() { defer componentWg.Done() // Range drains in-flight entries once the source closes the channel for entry := range ch { - if event, passed := p.Flow.Process(entry); passed { + event, stage, index := p.Flow.Process(entry) + if t := p.tap.Load(); t != nil { + t.Offer(core.Tapped{Pipeline: p.Config.Name, Source: ids[i], Stage: stage, Index: index, Event: event}) + } + if stage == "" { p.dispatch(event) } } @@ -270,6 +265,9 @@ func (p *Pipeline) run(subscriptions []<-chan core.LogEntry) { if !ok { return } + if t := p.tap.Load(); t != nil { + t.Offer(core.Tapped{Pipeline: p.Config.Name, Event: event}) + } p.dispatch(event) case <-hbCtx.Done(): return @@ -303,13 +301,16 @@ func (p *Pipeline) dispatch(event core.TransportEvent) { } } +// SetTap offers what the flow decides to t from the next entry on; nil stops +func (p *Pipeline) SetTap(t *core.Tap) { p.tap.Store(t) } + func (p *Pipeline) finish() { p.finishedOnce.Do(func() { close(p.finished) }) } // Finished is closed when every source has ended, or the pipeline shut down. // Only a finite source (console at end of input) ends without Stop. func (p *Pipeline) Finished() <-chan struct{} { return p.finished } -// Start starts the pipeline operation and all its components including flow, sources, and sinks +// Start starts the sinks before the sources, so the first entry has somewhere to go func (p *Pipeline) Start() error { if !p.running.CompareAndSwap(false, true) { return fmt.Errorf("pipeline %s is already running", p.Config.Name) @@ -319,33 +320,34 @@ func (p *Pipeline) Start() error { p.ctx, p.cancel = context.WithCancel(context.Background()) p.halt = make(chan struct{}) - // Start all sinks for id, s := range p.Sinks { if err := s.Start(p.ctx); err != nil { - return fmt.Errorf("failed to start sink %s: %w", id, err) + return p.part("sink", id, fmt.Errorf("failed to start sink %s: %w", id, err)) } } - // Subscribed before Start: a source publishes from its first entry on + // Subscribed before Start: a source publishes from its first entry on. One + // loop keeps ids beside their channels; map order differs between loops. + ids := make([]string, 0, len(p.Sources)) subscriptions := make([]<-chan core.LogEntry, 0, len(p.Sources)) - for _, src := range p.Sources { - subscriptions = append(subscriptions, src.Subscribe()) + for id, src := range p.Sources { + ids, subscriptions = append(ids, id), append(subscriptions, src.Subscribe()) } for id, src := range p.Sources { if err := src.Start(); err != nil { - return fmt.Errorf("failed to start source %s: %w", id, err) + return p.part("source", id, fmt.Errorf("failed to start source %s: %w", id, err)) } } - // Start the central processing loop p.Stats.StartTime = time.Now() p.wg.Add(1) - go p.run(subscriptions) + go p.run(ids, subscriptions) return nil } -// Stop stops the pipeline operation and all its components including flow, sources, and sinks +// Stop stops the sources, lets the run loop drain, then stops the sinks, so +// entries in flight reach them func (p *Pipeline) Stop() error { if !p.running.CompareAndSwap(true, false) { return fmt.Errorf("pipeline %s is not running", p.Config.Name) @@ -354,7 +356,6 @@ func (p *Pipeline) Stop() error { p.logger.Info("msg", "Stopping pipeline", "pipeline", p.Config.Name) close(p.halt) - // 1. Stop all sources concurrently to halt new data ingress and close their channels var sourceWg sync.WaitGroup for _, src := range p.Sources { sourceWg.Add(1) @@ -365,10 +366,8 @@ func (p *Pipeline) Stop() error { } sourceWg.Wait() - // 2. Wait for the run loop to finish processing and sending all in-flight data p.wg.Wait() - // 3. Stop all sinks concurrently now that no new data will be sent var sinkWg sync.WaitGroup for _, s := range p.Sinks { sinkWg.Add(1) @@ -384,20 +383,18 @@ func (p *Pipeline) Stop() error { return nil } -// Shutdown gracefully stops the pipeline and all its components, deinitializing them for app shutdown or complete pipeline removal by service +// Shutdown stops the pipeline for good, with its session manager func (p *Pipeline) Shutdown() { p.logger.Info("msg", "Shutting down pipeline", "component", "pipeline", "pipeline", p.Config.Name) - // Ensure the pipeline is stopped before shutting down if p.running.Load() { if err := p.Stop(); err != nil { p.logger.Error("msg", "Error stopping pipeline during shutdown", "error", err) } } - // Stop long-running components if p.Sessions != nil { p.Sessions.Stop() } @@ -408,10 +405,8 @@ func (p *Pipeline) Shutdown() { "pipeline", p.Config.Name) } -// GetStats returns a map of pipeline statistics func (p *Pipeline) GetStats() map[string]any { - // Recovery to handle concurrent access during shutdown - // When service is shutting down, sources/sinks might be nil or partially stopped + // Sources and sinks may be partly stopped while the service shuts down defer func() { if r := recover(); r != nil { p.logger.Error("msg", "Panic getting pipeline stats", @@ -420,7 +415,6 @@ func (p *Pipeline) GetStats() map[string]any { } }() - // 1. Live collect source stats sources := make([]map[string]any, 0, len(p.Sources)) for _, src := range p.Sources { if src == nil { @@ -438,7 +432,6 @@ func (p *Pipeline) GetStats() map[string]any { }) } - // 2. Live collect sink stats sinks := make([]map[string]any, 0, len(p.Sinks)) for _, snk := range p.Sinks { if snk == nil { @@ -456,7 +449,6 @@ func (p *Pipeline) GetStats() map[string]any { }) } - // 3. Collect flow stats and calculate filtered total var flowStats map[string]any var totalFiltered uint64 var totalProcessed uint64 @@ -464,12 +456,10 @@ func (p *Pipeline) GetStats() map[string]any { if p.Flow != nil { flowStats = p.Flow.GetStats() - // Map the top-level processed counter directly from Flow's source of truth if tp, ok := flowStats["total_processed"].(uint64); ok { totalProcessed = tp } - // Calculate total dropped specifically by the filter chain if filters, ok := flowStats["filters"].(map[string]any); ok { if totalPassed, ok := filters["total_passed"].(uint64); ok { if tProc, ok := filters["total_processed"].(uint64); ok { @@ -479,7 +469,6 @@ func (p *Pipeline) GetStats() map[string]any { } } - // 4. Calculate Uptime var uptime int if p.running.Load() && !p.Stats.StartTime.IsZero() { uptime = int(time.Since(p.Stats.StartTime).Seconds()) diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 0c0c27e..df060a4 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strconv" + "strings" "testing" "time" @@ -17,9 +18,12 @@ import ( "github.com/lixenwraith/log" ) -// finiteSource publishes n entries as soon as it starts, then ends +// finiteSource publishes n entries as soon as it starts, then ends, once hold +// closes when there is one. Entries carry its id as their Source. type finiteSource struct { + id string n int + hold chan struct{} subs []chan core.LogEntry } @@ -33,9 +37,13 @@ func (s *finiteSource) Start() error { go func() { for i := range s.n { for _, ch := range s.subs { - ch <- core.LogEntry{Time: time.Now(), Message: strconv.Itoa(i)} + msg := strconv.Itoa(i) + ch <- core.LogEntry{Time: time.Now(), Source: s.id, Message: msg, RawSize: int64(len(msg))} } } + if s.hold != nil { + <-s.hold + } for _, ch := range s.subs { close(ch) } @@ -74,7 +82,8 @@ var testSinks = map[string]*slowSink{} func init() { plugin.RegisterSource("random", func(id string, cfg map[string]any, _ *log.Logger, _ *session.Proxy) (source.Source, error) { n, _ := strconv.Atoi(fmt.Sprint(cfg["n"])) - return &finiteSource{n: n}, nil + hold, _ := cfg["hold"].(chan struct{}) + return &finiteSource{id: id, n: n, hold: hold}, nil }) plugin.RegisterSink("null", func(id string, _ map[string]any, _ *log.Logger, _ *session.Proxy) (sink.Sink, error) { return testSinks[id], nil @@ -132,3 +141,127 @@ func TestStopReleasesStalledBackpressureSink(t *testing.T) { t.Fatal("Stop blocked behind a stalled backpressure sink") } } + +// The tap gets each entry's verdict under its source's id: the stage that +// dropped it, or what the sinks got. Drops come only when All is set, and +// heartbeats come as passed, from no source. +func TestTapSeesEachVerdict(t *testing.T) { + const n = 1000 + verdict := func(msg string) (string, int) { + switch { + case len(msg) > 2: + return "rate_limit", 0 + case strings.HasPrefix(msg, "1"): + return "filters", 0 + case !strings.HasSuffix(msg, "0"): + return "filters", 1 + } + return "", 0 + } + for _, all := range []bool{false, true} { + hold := make(chan struct{}) + sink := &slowSink{in: make(chan core.TransportEvent, 4*n)} + testSinks[t.Name()] = sink + p, err := NewPipeline(&config.PipelineConfig{ + Name: t.Name(), + Flow: &config.FlowConfig{ + RateLimit: &config.RateLimitConfig{EntriesPerSecond: 1e6, Policy: "drop", MaxEntrySizeBytes: 2}, + Filters: []config.FilterConfig{{Type: "exclude", Patterns: []string{" 1"}}, {Type: "include", Patterns: []string{"0$"}}}, + Heartbeat: &config.HeartbeatConfig{Enabled: true, IntervalMS: 100}, + }, + PluginSources: []config.PluginSourceConfig{ + {ID: "a", Type: "random", Config: map[string]any{"n": n, "hold": hold}}, + {ID: "b", Type: "random", Config: map[string]any{"n": n, "hold": hold}}, + }, + PluginSinks: []config.PluginSinkConfig{{ID: t.Name(), Type: "null"}}, + }, log.NewLogger()) + if err != nil { + t.Fatal(err) + } + tap := core.NewTap(4 * n) + tap.All.Store(all) + p.SetTap(tap) + if err := p.Start(); err != nil { + t.Fatal(err) + } + var got []core.Tapped + for beat := false; !beat; { + select { + case r := <-tap.Records(): + if beat = r.Source == ""; beat && (r.Stage != "" || r.Event.Entry.Source != "heartbeat" || len(r.Event.Payload) == 0) { + t.Errorf("heartbeat %+v", r) + } + got = append(got, r) + case <-time.After(5 * time.Second): + t.Fatal("no heartbeat reached the tap") + } + } + close(hold) + <-p.Finished() + p.Shutdown() + for len(tap.Records()) > 0 { + got = append(got, <-tap.Records()) + } + + seen, passed := map[string]int{}, 0 + for _, r := range got { + e := r.Event.Entry + if r.Stage == "" { + passed++ + } + if r.Source == "" { + continue + } + stage, index := verdict(e.Message) + if r.Pipeline != t.Name() || r.Source != e.Source || r.Stage != stage || r.Index != index || + stage == "" && string(r.Event.Payload) != e.Message+"\n" { + t.Errorf("all=%v: %s %q: got %s %s[%d] %q, want %s[%d]", all, r.Source, e.Message, r.Pipeline, r.Stage, r.Index, r.Event.Payload, stage, index) + } + seen[r.Source+e.Message]++ + } + for i := range n { + msg := strconv.Itoa(i) + want := 1 + if stage, _ := verdict(msg); stage != "" && !all { + want = 0 + } + for _, id := range []string{"a", "b"} { + if seen[id+msg] != want { + t.Errorf("all=%v: %s %q tapped %d times, want %d", all, id, msg, seen[id+msg], want) + } + } + } + if tap.Dropped() != 0 || len(sink.in) != passed { + t.Errorf("all=%v: a tap with room dropped %d; the sink got %d, %d passed", all, tap.Dropped(), len(sink.in), passed) + } + } +} + +// A tap nobody reads costs the pipeline nothing: every entry still reaches +// the sink, and the tap counts what it could not take. +func TestFullTapNeverStallsAPipeline(t *testing.T) { + const n = 2000 + s := &slowSink{in: make(chan core.TransportEvent, 1), out: make(chan string, n)} + p := newTestPipeline(t, n, s) + tap := core.NewTap(1) + p.SetTap(tap) + if err := p.Start(); err != nil { + t.Fatal(err) + } + select { + case <-p.Finished(): + case <-time.After(10 * time.Second): + t.Fatal("a full tap stalled the pipeline") + } + for i := range n { + select { + case <-s.out: + case <-time.After(5 * time.Second): + t.Fatalf("the sink got %d of %d", i, n) + } + } + p.Shutdown() + if tap.Dropped() != n-1 { + t.Fatalf("tap dropped %d, want %d", tap.Dropped(), n-1) + } +} diff --git a/internal/service/service.go b/internal/service/service.go index 180b73b..da88ecf 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -7,6 +7,7 @@ import ( "sync" "github.com/lixenwraith/logwisp/internal/config" + "github.com/lixenwraith/logwisp/internal/core" "github.com/lixenwraith/logwisp/internal/pipeline" "github.com/lixenwraith/log" @@ -25,7 +26,6 @@ type Service struct { done chan struct{} // closed when every pipeline has finished } -// NewService creates a new, empty service func NewService(ctx context.Context, cfg *config.Config, logger *log.Logger) (*Service, error) { serviceCtx, cancel := context.WithCancel(ctx) svc := &Service{ @@ -37,12 +37,10 @@ func NewService(ctx context.Context, cfg *config.Config, logger *log.Logger) (*S } var errs error - // Initialize pipelines for _, pipelineCfg := range cfg.Pipelines { pipelineName := pipelineCfg.Name logger.Info("msg", "Initializing pipeline", "pipeline", pipelineName) - // Create the pipeline if pl, err := pipeline.NewPipeline(&pipelineCfg, logger); err != nil { logger.Error("msg", "Failed to create pipeline", "pipeline", pipelineCfg.Name, @@ -63,13 +61,12 @@ func NewService(ctx context.Context, cfg *config.Config, logger *log.Logger) (*S return svc, nil } -// Start starts all or specific pipelines +// Start starts the named pipelines, or all of them without names func (svc *Service) Start(names ...string) error { svc.mu.RLock() defer svc.mu.RUnlock() var errs error - // If no names are provided, start all pipelines if len(names) == 0 { svc.logger.Info("msg", "Starting all pipelines") for name, p := range svc.pipelines { @@ -78,7 +75,6 @@ func (svc *Service) Start(names ...string) error { } } } else { - // Start only the specified pipelines svc.logger.Info("msg", "Starting specified pipelines", "pipelines", names) for _, name := range names { if p, exists := svc.pipelines[name]; exists { @@ -108,18 +104,26 @@ func (svc *Service) Start(names ...string) error { return errs } +// SetTap gives every pipeline the viewer's tap; nil takes it away +func (svc *Service) SetTap(t *core.Tap) { + svc.mu.RLock() + defer svc.mu.RUnlock() + for _, p := range svc.pipelines { + p.SetTap(t) + } +} + // Done is closed once every pipeline has finished: all their sources reached // the end of their input (stdin), or the service shut down. func (svc *Service) Done() <-chan struct{} { return svc.done } -// Stop stops all or specific pipeline +// Stop stops the named pipelines, or all of them without names func (svc *Service) Stop(names ...string) error { svc.mu.RLock() defer svc.mu.RUnlock() var errs error - // If no names are provided, stop all pipelines if len(names) == 0 { svc.logger.Info("msg", "Stopping all pipelines") for name, p := range svc.pipelines { @@ -128,7 +132,6 @@ func (svc *Service) Stop(names ...string) error { } } } else { - // Stop only the specified pipelines svc.logger.Info("msg", "Stopping specified pipelines", "pipelines", names) for _, name := range names { if p, exists := svc.pipelines[name]; exists { @@ -146,7 +149,6 @@ func (svc *Service) Stop(names ...string) error { return errs } -// GetPipeline returns a pipeline by its name func (svc *Service) GetPipeline(name string) (*pipeline.Pipeline, error) { svc.mu.RLock() defer svc.mu.RUnlock() @@ -158,7 +160,6 @@ func (svc *Service) GetPipeline(name string) (*pipeline.Pipeline, error) { return pipeline, nil } -// ListPipelines returns the names of all currently managed pipelines func (svc *Service) ListPipelines() []string { svc.mu.RLock() defer svc.mu.RUnlock() @@ -170,7 +171,6 @@ func (svc *Service) ListPipelines() []string { return names } -// RemovePipeline stops and removes a pipeline from the service func (svc *Service) RemovePipeline(name string) error { svc.mu.Lock() defer svc.mu.Unlock() @@ -191,7 +191,7 @@ func (svc *Service) RemovePipeline(name string) error { return nil } -// Shutdown gracefully stops all pipelines managed by the service +// Shutdown stops every pipeline for good, concurrently func (svc *Service) Shutdown() { svc.shutdownOnce.Do(svc.shutdown) } @@ -206,7 +206,6 @@ func (svc *Service) shutdown() { } svc.mu.Unlock() - // Stop all pipelines concurrently var wg sync.WaitGroup for _, pl := range pipelines { wg.Add(1) @@ -223,7 +222,6 @@ func (svc *Service) shutdown() { svc.logger.Info("msg", "Service shutdown complete") } -// GetGlobalStats returns statistics for all pipelines func (svc *Service) GetGlobalStats() map[string]any { svc.mu.RLock() defer svc.mu.RUnlock() diff --git a/internal/sink/console/console.go b/internal/sink/console/console.go index ed3f2aa..f2e2faa 100644 --- a/internal/sink/console/console.go +++ b/internal/sink/console/console.go @@ -1,19 +1,17 @@ package console import ( - "bytes" "context" - "encoding/hex" "fmt" "io" "os" "sync" "sync/atomic" "time" - "unicode/utf8" "github.com/lixenwraith/logwisp/internal/config" "github.com/lixenwraith/logwisp/internal/core" + "github.com/lixenwraith/logwisp/internal/logline" "github.com/lixenwraith/logwisp/internal/plugin" "github.com/lixenwraith/logwisp/internal/session" "github.com/lixenwraith/logwisp/internal/sink" @@ -82,10 +80,7 @@ func NewConsoleSinkPlugin( return nil, err } - output := os.Stdout - if opts.Target == "stderr" { - output = os.Stderr - } + output := Stream(opts.Target) // inline decides auto: a terminal, NO_COLOR unset, TERM not dumb. Paint // returns its text unchanged exactly when color is off. @@ -134,6 +129,14 @@ func NewConsoleSinkPlugin( return cs, nil } +// Stream is the stream a console sink with target writes +func Stream(target string) *os.File { + if target == "stderr" { + return os.Stderr + } + return os.Stdout +} + // Capabilities returns supported capabilities func (cs *ConsoleSink) Capabilities() []core.Capability { return []core.Capability{ @@ -228,7 +231,7 @@ func (cs *ConsoleSink) drain() { func (cs *ConsoleSink) write(event core.TransportEvent) { payload := event.Payload if cs.escape { - payload = escapeControls(payload) + payload = logline.EscapeControls(payload) } if cs.painter != nil { payload = paintLevel(cs.painter, payload, event.Entry.Level) @@ -260,52 +263,3 @@ func paintLevel(p *inline.Printer, payload []byte, level string) []byte { } return payload } - -// escapeControls writes what a terminal would act on or reorder (C0 and C1 -// controls but tab, DEL, bidi controls, line separators, invalid UTF-8) as -// , keeping the final newline: a log line cannot drive the terminal. -// Other invisible characters stay: emoji and Indic scripts need ZWJ. -func escapeControls(p []byte) []byte { - body, newline := bytes.CutSuffix(p, []byte{'\n'}) - if printableASCII(body) { - return p - } - out := make([]byte, 0, len(p)+16) - for len(body) > 0 { - r, n := utf8.DecodeRune(body) - if !terminalControl(r, n) { - out = append(out, body[:n]...) - } else { - out = append(out, '<') - out = hex.AppendEncode(out, body[:n]) - out = append(out, '>') - } - body = body[n:] - } - if newline { - out = append(out, '\n') - } - return out -} - -func terminalControl(r rune, size int) bool { - switch { - case r == utf8.RuneError && size == 1: - return true - case r < 0x20: - return r != '\t' - case r >= 0x7f && r <= 0x9f, r == 0x061c, r == 0x200e, r == 0x200f, - r >= 0x2028 && r <= 0x202e, r >= 0x2066 && r <= 0x2069: - return true - } - return false -} - -func printableASCII(b []byte) bool { - for _, c := range b { - if (c < 0x20 || c > 0x7e) && c != '\t' { - return false - } - } - return true -} diff --git a/internal/sink/console/console_test.go b/internal/sink/console/console_test.go index 257fd0c..6806aae 100644 --- a/internal/sink/console/console_test.go +++ b/internal/sink/console/console_test.go @@ -7,28 +7,6 @@ import ( "github.com/lixenwraith/terminal/inline" ) -// Everything a terminal would act on is escaped; tabs and the final newline, -// which it only lays out, are kept. -func TestEscapeControlsKeepsOnlyLayout(t *testing.T) { - for in, want := range map[string]string{ - "plain\ttext\n": "plain\ttext\n", - "a\x1b[2Jb\n": "a<1b>[2Jb\n", - "bell\x07 del\x7f\n": "bell<07> del<7f>\n", - "inner\nnewline\n": "inner<0a>newline\n", - "c1 \u009b31m\n": "c1 31m\n", - "bidi \u202eevil\n": "bidi evil\n", - "isolate \u2066x\u2069\n": "isolate x\n", - "line\u2028sep\n": "linesep\n", - "zwj \U0001f468\u200d\U0001f469\n": "zwj \U0001f468\u200d\U0001f469\n", - "bad \xff utf8": "bad utf8", - "ünïcødé and \ufffd kept\n": "ünïcødé and \ufffd kept\n", - } { - if got := string(escapeControls([]byte(in))); got != want { - t.Errorf("%q: got %q, want %q", in, got, want) - } - } -} - // The entry's level, under any of its names, is painted where its line first // names it as a whole word, in any case; other words and levels stay plain. func TestPaintLevelColorsTheFirstWholeName(t *testing.T) { diff --git a/internal/sink/null/null.go b/internal/sink/null/null.go index 1fc5ed9..85bb801 100644 --- a/internal/sink/null/null.go +++ b/internal/sink/null/null.go @@ -82,6 +82,7 @@ func NewNullSinkPlugin( func (ns *NullSink) Capabilities() []core.Capability { return []core.Capability{ core.CapSessionAware, + core.CapBackpressure, // it discards at once: a wait loses nothing, a drop would count } } diff --git a/internal/tui/canvas.go b/internal/tui/canvas.go index 35258f3..a4233d0 100644 --- a/internal/tui/canvas.go +++ b/internal/tui/canvas.go @@ -1,6 +1,7 @@ package tui import ( + "cmp" "fmt" "slices" "strconv" @@ -13,64 +14,129 @@ import ( ) const ( - pitch = 3 // rows a source or sink takes, its gap included - gutter = 4 // columns of wire on either side of the flow box - wideMin = 64 // the narrowest canvas with the parts side by side + gutter = 4 // columns of wire a side when the content does not fit + wideMin = 64 // the narrowest canvas with the parts side by side + besideMin = 110 // the narrowest screen with the inspector beside the canvas + listMax = 4 // option rows a source or sink lists beside the inspector ) -// spot is where a node draws: its first row and the columns it may use -type spot struct{ x, y, w int } +// Widths beside the inspector: columns sized to their content within these, +// and wires taking the slack up to wireMax a side +const ( + sideMin, sideMax = 24, 40 + boxMin, boxMax = 18, 44 + wireMax = 16 + inspMin, inspMax = 60, 100 // the inspector beside the canvas +) -// layout places a pipeline's parts: side by side with wires from 64 columns, -// stacked below that +// spot is where a node draws: its first row, the columns it may use, its +// rows, the column its head's text ends at (a source's wire starts past it), +// and its text under or beside its name +type spot struct { + x, y, w, h, end int + text []string +} + +// layout places the screen's parts: the inspector beside the canvas from 110 +// columns, below it under that; on the canvas, a pipeline's parts side by +// side with wires from 64 columns, stacked below that. Beside the inspector +// the parts take their content's widths, centred; below it, a fixed split. type layout struct { wide bool - h int + w, h int // the canvas's + inspector int // the inspector's width beside the canvas, 0 below it title [3]spot nodes [3][]spot // a column's nodes, or its one empty slot box spot // the flow box's top left and width boxH int entry, exit int // the rows wires enter and leave the flow box + wire int // columns between a side column and the flow box } +// layout lays out a screen w wide; each node's text is read once, and sizes +// the columns func (a *app) layout(w int) layout { - var l layout - counts := [3]int{} - for col := range columns { - counts[col] = max(1, len(a.nodes(col))) - } - stages := counts[flowCol] - l.boxH = stages + 2 - if w >= wideMin { - l.wide = true - boxW := max(18, min(30, (w-2*gutter)/3)) - sideW := (w - boxW - 2*gutter) / 2 - xs := [3]int{0, sideW + gutter, sideW + 2*gutter + boxW} - ws := [3]int{sideW, boxW, w - xs[sinksCol]} - for col := range columns { - l.title[col] = spot{xs[col], 0, ws[col]} - } - l.box = spot{xs[flowCol], 1, boxW} - for _, col := range []int{sourcesCol, sinksCol} { - for i := range counts[col] { - l.nodes[col] = append(l.nodes[col], spot{xs[col], 1 + i*pitch, ws[col]}) + l := layout{w: w} + beside := w >= besideMin + side, box := sideMin, boxMin + for _, col := range []int{sourcesCol, sinksCol} { + for _, n := range a.nodes(col) { + p, _ := a.plugin(n) + s := spot{end: 2 + ui.RuneLen(p.Type)} + if beside { + s.text = a.optionRows(n) + } else { + s.text = []string{a.summary(n)} } + if verb := ways[p.Side]; verb != "" { + s.end += 2 + ui.RuneLen(verb) + } + s.h = 1 + len(s.text) + side = max(side, s.end+3) + for _, t := range s.text { + side = max(side, 3+ui.RuneLen(t)) + } + l.nodes[col] = append(l.nodes[col], s) } - l.entry = l.box.y + 1 + (stages-1)/2 + if len(l.nodes[col]) == 0 { + l.nodes[col] = []spot{{h: 2}} + } + } + var stages []spot + for _, n := range a.nodes(flowCol) { + text := a.summary(n) + if !a.on(n) { + text = "off" + } + box = max(box, 7+ui.RuneLen(n.Role)+ui.RuneLen(text)) + stages = append(stages, spot{h: 1, text: []string{text}}) + } + if beside { + want := 2*min(side, sideMax) + min(box, boxMax) + 2*wireMax + 4 // with a margin + l.inspector = max(min(inspMin, w*2/5), min(inspMax, w-want-1)) + l.w = w - l.inspector - 1 + } + l.boxH = max(1, len(stages)) + 2 + if l.w >= wideMin { + l.wide = true + side, box = min(side, sideMax), min(box, boxMax) + l.wire = min(wireMax, (l.w-2*side-box)/2) + if !beside || l.wire < gutter { + box = max(boxMin, min(30, (l.w-2*gutter)/3)) + side, l.wire = (l.w-box-2*gutter)/2, gutter + } + x0 := (l.w - 2*side - box - 2*l.wire) / 2 + xs := [3]int{x0, x0 + side + l.wire, x0 + side + 2*l.wire + box} + ws := [3]int{side, box, side} + for col := range columns { + l.title[col] = spot{x: xs[col], w: ws[col]} + } + l.box = spot{x: xs[flowCol], y: 1, w: box} + l.h = 1 + l.boxH + for _, col := range []int{sourcesCol, sinksCol} { + y := 1 + for i := range l.nodes[col] { + s := &l.nodes[col][i] + s.x, s.y, s.w = xs[col], y, ws[col] + y += s.h + 1 + } + l.h = max(l.h, y-1) + } + l.entry = l.box.y + 1 + (l.boxH-3)/2 l.exit = l.entry - l.h = 1 + max(counts[sourcesCol]*pitch-1, l.boxH, counts[sinksCol]*pitch-1) } else { y := 0 for col := range columns { - l.title[col] = spot{0, y, w} + l.title[col] = spot{y: y, w: l.w} y++ if col == flowCol { - l.box = spot{0, y, w} + l.box = spot{y: y, w: l.w} y += l.boxH } else { - for range counts[col] { - l.nodes[col] = append(l.nodes[col], spot{0, y, w}) - y += pitch + for i := range l.nodes[col] { + s := &l.nodes[col][i] + s.y, s.w = y, l.w + y += s.h + 1 } y-- } @@ -80,18 +146,18 @@ func (a *app) layout(w int) layout { } l.h = y } - for i := range stages { - l.nodes[flowCol] = append(l.nodes[flowCol], spot{l.box.x + 2, l.box.y + 1 + i, l.box.w - 4}) + for i, s := range stages { + s.x, s.y, s.w = l.box.x+2, l.box.y+1+i, l.box.w-4 + l.nodes[flowCol] = append(l.nodes[flowCol], s) } return l } // drawCanvas draws the pipeline, scrolled to keep the chosen node in view -func (a *app) drawCanvas(r ui.Region) { - l := a.layout(r.W) +func (a *app) drawCanvas(r ui.Region, l layout) { chosen := l.nodes[a.col][min(a.row[a.col], len(l.nodes[a.col])-1)] a.scroll.SetDimensions(l.h, r.H) - a.scroll.EnsureRange(chosen.y, 2-a.col%2) + a.scroll.EnsureRange(chosen.y, chosen.h) r.Window(l.h, &a.scroll, a.th.Text, func(full ui.Region) { a.drawParts(full, l) }) } @@ -112,7 +178,11 @@ func (a *app) drawParts(r ui.Region, l layout) { a.drawEmpty(r, s, columns[col].role, chosen) continue } - a.drawNode(r, s, nodes[i], tints[col], chosen) + head := s.w // a source's leader starts past its text, where its bar ends + if l.wide && col == sourcesCol { + head = min(s.w, s.end+1) + } + a.drawNode(r, s, head, nodes[i], tints[col], chosen) } if l.wide && len(nodes) > 0 { join(g, l, col) @@ -129,12 +199,14 @@ func (a *app) drawParts(r ui.Region, l layout) { r.TextStyled(l.box.x-1, l.entry, string(f.arrow), th.Border) } if len(a.nodes(sinksCol)) > 0 { - r.TextStyled(l.box.x+l.box.w+1, l.exit, string(f.arrow), th.Border) + for _, s := range l.nodes[sinksCol] { + r.TextStyled(s.x-1, s.y, string(f.arrow), th.Border) + } } } -// join wires a column's nodes to a trunk beside the flow box, and the trunk -// to the box's side +// join wires a column's nodes to a trunk midway along the wire, and the +// trunk to the flow box's side: a source's wire starts past its text func join(g ui.Wires, l layout, col int) { ys := []int{l.entry} if col == sinksCol { @@ -144,16 +216,16 @@ func join(g ui.Wires, l layout, col int) { ys = append(ys, s.y) } if col == sourcesCol { - trunk := l.box.x - gutter + 1 + trunk := l.box.x - 1 - l.wire/2 for _, s := range l.nodes[col] { - g.H(s.y, trunk-1, trunk) + g.H(s.y, s.x+min(s.end+1, s.w), trunk) } g.V(trunk, slices.Min(ys), slices.Max(ys)) g.H(l.entry, trunk, l.box.x-1) return } right := l.box.x + l.box.w - trunk := right + 2 + trunk := right + 1 + (l.wire-1)/2 g.H(l.exit, right, trunk) g.V(trunk, slices.Min(ys), slices.Max(ys)) for _, s := range l.nodes[col] { @@ -162,9 +234,10 @@ func join(g ui.Wires, l layout, col int) { } // drawNode draws a source or sink: a tinted bar, its type and whether it -// listens or dials, then a summary of its options. The chosen node's bar is -// solid; a node a problem names has its bar in the error style. -func (a *app) drawNode(r ui.Region, s spot, n compose.Node, tint ui.Style, chosen bool) { +// listens or dials, then its text. The chosen node is a bar across its rows, +// head wide on the first, whose tint block keeps its color; a node a problem +// names has its tint in the error style. +func (a *app) drawNode(r ui.Region, s spot, head int, n compose.Node, tint ui.Style, chosen bool) { th := a.th bg, bar := th.Text, a.font.bar if chosen { @@ -174,20 +247,31 @@ func (a *app) drawNode(r ui.Region, s spot, n compose.Node, tint ui.Style, chose tint = th.Error } p, _ := a.plugin(n) - way := map[config.Side]string{config.Listener: "listens", config.ChainListener: "listens", config.Dialer: "dials"}[p.Side] - for dy, text := range []string{p.Type, a.summary(n)} { - row := r.Sub(s.x, s.y+dy, s.w, 1) - row.FillStyle(bg) - row.TextStyled(0, 0, string(bar), tint.On(bg)) - style := th.Muted.On(bg) + verb, room := ways[p.Side], s.w-3 + if verb != "" { + room -= 2 + ui.RuneLen(verb) + } + for dy := range s.h { + w := s.w if dy == 0 { - style = th.Text.On(bg) - row.TextStyled(row.W-ui.RuneLen(way), 0, way, th.Muted.On(bg)) + w = head } - row.TextStyled(2, 0, ui.Truncate(text, row.W-ui.RuneLen(way)-3), style) + row := r.Sub(s.x, s.y+dy, w, 1) + row.FillStyle(bg) + row.TextStyled(0, 0, string(bar), tint.On(th.Text)) + if dy > 0 { + row.TextStyled(2, 0, ui.Truncate(s.text[dy-1], row.W-3), th.Muted.On(bg)) + continue + } + typ := ui.Truncate(p.Type, room) + row.TextStyled(2, 0, typ, th.Text.On(bg)) + row.TextStyled(4+ui.RuneLen(typ), 0, verb, th.Muted.On(bg)) } } +// ways say whether a source or sink listens or dials, by its side +var ways = map[config.Side]string{config.Listener: "listens", config.ChainListener: "listens", config.Dialer: "dials"} + func (a *app) drawEmpty(r ui.Region, s spot, role string, chosen bool) { mark, style := " ", a.th.Muted if chosen { @@ -215,13 +299,12 @@ func (a *app) drawBox(r ui.Region, l layout) { } row := r.Sub(s.x, s.y, s.w, 1) row.FillStyle(bg) - mark, tint, text := f.bar, a.look.Flow, th.Text + mark, tint, text, under := f.bar, a.look.Flow, th.Text, th.Text // a block keeps its tint on the bar if chosen { mark = f.barOn } - summary := a.summary(n) if !a.on(n) { - mark, tint, text, summary = f.Off, th.Muted, th.Muted, "off" + mark, tint, text, under = f.Off, th.Muted, th.Muted, bg if chosen { mark = th.Glyphs.Pointer } @@ -229,10 +312,10 @@ func (a *app) drawBox(r ui.Region, l layout) { if a.faulty(n) { tint = th.Error } - row.TextStyled(0, 0, string(mark), tint.On(bg)) + row.TextStyled(0, 0, string(mark), tint.On(under)) row.TextStyled(2, 0, n.Role, text.On(bg)) x := 3 + ui.RuneLen(n.Role) - row.TextStyled(x, 0, ui.Truncate(summary, row.W-x), th.Muted.On(bg)) + row.TextStyled(x, 0, ui.Truncate(s.text[0], row.W-x), th.Muted.On(bg)) } } @@ -249,8 +332,8 @@ func (a *app) on(n compose.Node) bool { return on } -// summary is a node's options in a line: the values set, a table by its -// name, a switch by its key; the filters as +include and -exclude patterns +// summary is a node's options in a line: the values set, a table or a switch +// by its name; the filters as +include and -exclude patterns func (a *app) summary(n compose.Node) string { if n.Role == "filters" { var out []string @@ -260,23 +343,59 @@ func (a *app) summary(n compose.Node) string { } return strings.Join(out, " ") } - opts, _, _ := a.comp.Options(a.pi, n) var out []string + for _, s := range a.settings(n) { + out = append(out, cmp.Or(s.value, s.key)) + } + return strings.Join(out, " ") +} + +// optionRows are a source's or sink's options set, a row each with the +// values aligned, at most listMax; "defaults" when none is set +func (a *app) optionRows(n compose.Node) []string { + set := a.settings(n) + if len(set) == 0 { + return []string{"defaults"} + } + set = set[:min(len(set), listMax)] + keyW := 0 + for _, s := range set { + keyW = max(keyW, ui.RuneLen(s.key)) + } + var out []string + for _, s := range set { + row := s.key + if s.value != "" { + row = ui.PadRight(s.key, keyW) + " " + s.value + } + out = append(out, row) + } + return out +} + +// setting is an option set: its key, and its value as shown, none for a +// table or a switch +type setting struct{ key, value string } + +// settings are a node's options set, in its keys' order +func (a *app) settings(n compose.Node) []setting { + opts, _, _ := a.comp.Options(a.pi, n) + var out []setting for _, k := range a.keys(n) { v, ok := opts[k.Name] switch { case !ok || k.Name == "enabled" || v == false: case k.Kind == "table": if t, _ := v.(map[string]any); t["enabled"] != false { - out = append(out, k.Name) + out = append(out, setting{key: k.Name}) } case v == true: - out = append(out, k.Name) + out = append(out, setting{key: k.Name}) default: - out = append(out, show(v)) + out = append(out, setting{k.Name, show(v)}) } } - return strings.Join(out, " ") + return out } // show writes a value as a form does: a list joined by commas diff --git a/internal/tui/dialog.go b/internal/tui/dialog.go index b8746b8..41dd88c 100644 --- a/internal/tui/dialog.go +++ b/internal/tui/dialog.go @@ -1,6 +1,8 @@ package tui import ( + "crypto/rand" + "errors" "fmt" "strings" @@ -239,6 +241,7 @@ type form struct { focus ui.Focus scroll ui.ViewportScroll // the fields' rows err string + mask bool // draws each rune typed as '*' apply func(a *app, values map[string]string) error back func(a *app) // on Esc, after the form closes } @@ -278,7 +281,15 @@ func (f *form) draw(a *app, r ui.Region) { view.Window(len(f.fields)*tall, &f.scroll, th.Text, func(all ui.Region) { for i, p := range f.params { v, _ := all.Field(i*tall, labelW, ui.Field{Label: p.Name, Required: p.Required}, i == f.focus.Index, th) - v.TextInput(f.fields[i], p.Default, i == f.focus.Index, th) + field := f.fields[i] + if !f.mask { + v.TextInput(field, p.Default, i == f.focus.Index, th) + continue + } + shown := *field + shown.Text = []rune(strings.Repeat("*", len(field.Text))) + v.TextInput(&shown, p.Default, i == f.focus.Index, th) + field.Scroll = shown.Scroll } }) note, style := f.params[f.focus.Index].Usage(), th.Muted @@ -337,36 +348,81 @@ func (f *form) submit(a *app) { } } -// outputMenu is what o offers: run, or print one of the forms +// outputMenu is what o offers: run, tap, or print one of the forms func (a *app) outputMenu() dialog { forms := compose.Forms() - options := []ui.Option{{Name: "run", Key: 'r', Hint: "start the pipelines now, with lw's other settings"}} + options := []ui.Option{{Name: "run", Key: 'r', Hint: "start the pipelines now, with lw's other settings"}, + {Name: "tap", Key: 't', Hint: "run here and watch what flows; it runs for real"}} for _, f := range forms { options = append(options, ui.Option{Name: f.Name, Key: rune(f.Name[0]), Hint: "print " + f.Hint}) } list := ui.NewOptionListState(options) list.Menu = true - return &chooser{title: "Output, once the screen closes", list: list, + return &chooser{title: "Run, or print once the screen closes", list: list, pick: func(a *app, i int) { - if i == 0 { + switch i { + case 0: a.run() return + case 1: + a.tap() + return } - text, err := forms[i-1].Write(a.comp) + text, err := forms[i-2].Write(a.comp) if err != nil { a.status = err.Error() return } - a.result, a.done = Result{Exit: Print, Output: text}, true + a.leave(Result{Exit: Print, Output: text}) }} } +// askPassword asks in a masked form for a password a terminal would be asked +// for; with generate, Enter alone draws one and shows it once +func (a *app) askPassword(user string, generate bool, reply chan<- []byte) { + answer := func(pw []byte) { + select { + case reply <- pw: + default: + } + } + if a.live.leaving { + answer(nil) + return + } + help := "the password lw would ask on the terminal" + if generate { + help += "; Enter alone generates one, shown once" + } + f := &form{title: "Password for " + user, params: []config.PresetParam{{Name: "password", Required: !generate, Help: help}}, mask: true, + apply: func(a *app, v map[string]string) error { + pw := v["password"] + switch { + case pw == "" && !generate: + return errors.New("type the password, or esc to give none") + case pw == "": + // answered once read: the start goes on only then, so no + // later ask or error replaces the notice unread + pw = rand.Text() + n := textNotice("Password for "+user, "generated, shown this once: "+pw) + n.closed, a.dialog = func(*app) { answer([]byte(pw)) }, n + return nil + } + answer([]byte(pw)) + return nil + }, + back: func(*app) { answer(nil) }} + f.fill(nil) + a.dialog = f +} + // notice shows text, wrapped to the screen, until a key other than one // that scrolls it type notice struct { - title string - body func(w int) []string // the lines at a width - view ui.ViewportScroll + title string + body func(w int) []string // the lines at a width + view ui.ViewportScroll + closed func(a *app) // after a key closed it } // textNotice is a notice of plain text, its paragraphs split at '\n' @@ -406,7 +462,9 @@ func (n *notice) handle(a *app, ev terminal.Event) { case is(ev, terminal.KeyPageDown, ""): n.view.PageDown() default: - a.dialog = nil + if a.dialog = nil; n.closed != nil { + n.closed(a) + } } } @@ -440,8 +498,9 @@ func (c *confirm) handle(a *app, ev terminal.Event) { } } -// help lists the keys, each beside its description or, narrow, above it -func help(f font) dialog { +// help lists the keys of the diagram or, viewing, the view's, each beside +// its description or, narrow, above it +func help(f font, viewing bool) dialog { keys := [][2]string{ {"arrows, hjkl", "move; up from the top row to the pipeline bar"}, {f.enter, "inspect a part; edit a value; open a list; add"}, @@ -455,11 +514,27 @@ func help(f font) dialog { {"[ ]", "previous or next pipeline"}, {"p", "start over from a preset"}, {"r", "run the pipelines"}, - {"o", "run, or print a command line, environment or file"}, + {"o", "run, tap, or print a command line, environment or file"}, + {"t", "tap: run here and watch what flows; it runs for real: binds, dials, writes files"}, {"!", "every problem, moving to the first"}, {"paste", "an lw command line replaces the pipelines"}, {"q", "quit"}, } + if viewing { + keys = [][2]string{ + {"arrows, jk", "move; up pauses following, down to the newest follows"}, + {"g G", "the oldest row; the newest, following; f follows too"}, + {f.enter, "the row's detail; J K scroll it"}, + {"/", "only the rows matching a pattern; lower case ignores case"}, + {"1-5 0", "trace, debug, info, warn or error and up; all"}, + {"v", "all that reaches the point, what passed it, what it dropped"}, + {"s", "look at a source, a flow stage, the sinks or lw's own log"}, + {"i", "inspect the stage that dropped the row"}, + {"c", "clear the rows"}, + {"t esc", "back to the diagram; the pipelines keep running"}, + {"q", "stop the pipelines and quit"}, + } + } return ¬ice{title: "Keys", body: func(w int) []string { var lines []string for _, k := range keys { diff --git a/internal/tui/inspector.go b/internal/tui/inspector.go index ae6af79..c8fbbcf 100644 --- a/internal/tui/inspector.go +++ b/internal/tui/inspector.go @@ -163,8 +163,9 @@ func (a *app) drawInspector(r ui.Region) { row := r.Sub(at/perCol*colW, 1+at%perCol, colW-1, 1) a.drawLine(row, lines[i], labelW, a.inspect && i == a.insp.cursor) } + under := min(r.H-len(help), 2+min(perCol, len(lines)-a.insp.scroll)) // two rows under the last key for i, h := range help { - r.TextStyled(2, r.H-len(help)+i, h, style) + r.TextStyled(2, under+i, h, style) } } diff --git a/internal/tui/run.go b/internal/tui/run.go new file mode 100644 index 0000000..bb08923 --- /dev/null +++ b/internal/tui/run.go @@ -0,0 +1,316 @@ +package tui + +import ( + "errors" + "fmt" + "maps" + "math" + "os" + "os/signal" + "slices" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/lixenwraith/logwisp/internal/compose" + "github.com/lixenwraith/logwisp/internal/config" + "github.com/lixenwraith/logwisp/internal/core" + + ui "github.com/lixenwraith/terminal/tui" +) + +// Runner runs pipelines in lw's process while the screen stays. Its calls +// block: the screen makes Start and Stop one at a time on a goroutine of its +// own, and reads State from another. +type Runner interface { + // Start runs specs in place of what runs, as a reload does, and returns + // the tap of what runs then: on an error the earlier run's when the new + // pipelines did not build, nil when nothing runs + Start(specs []config.Spec, ask Ask) (*core.Tap, error) + Stop() + State() RunState +} + +// Ask answers a password prompt a terminal would show; with generate, an +// empty answer draws a password, shown once +type Ask func(user string, generate bool) ([]byte, error) + +// RunState is what a run reports beside its tap +type RunState struct { + Counts map[string]Counts // by pipeline + Ended bool // every pipeline's input ended + Log *core.Tap // lw's own log, nil while it does not reach the terminal +} + +// Counts are a pipeline's entries: in from its sources, dropped by its flow, +// and dropped by sinks that could not take them +type Counts struct{ In, Filtered, SinksDropped uint64 } + +func (s RunState) same(o RunState) bool { + return s.Ended == o.Ended && s.Log == o.Log && maps.Equal(s.Counts, o.Counts) +} + +// run is the screen's side of a Runner. A worker makes the calls; what they +// bring back waits in later for a tick, which wake posts once until handled. +type run struct { + runner Runner + post func() // posts a tick to the screen + jobs chan func() + worker sync.Once + busy sync.WaitGroup + mu sync.Mutex + later []func(a *app) + posted atomic.Bool + gone chan struct{} // closed when the screen leaves: asks fail + ended sync.Once + fed atomic.Pointer[core.Tap] // the run's tap, for the watch + + // the screen's goroutine only + op string // "starting" or "stopping" while a job runs + specs []config.Spec // what runs, nil while nothing does + tap *core.Tap + state RunState + leaving bool +} + +func newRun(runner Runner, post func()) *run { + return &run{runner: runner, post: post, jobs: make(chan func(), 4), gone: make(chan struct{})} +} + +// do runs job on the worker, after the jobs before it +func (r *run) do(job func()) { + r.worker.Do(func() { + go func() { + for job := range r.jobs { + job() + r.busy.Done() + } + }() + }) + r.busy.Add(1) + r.jobs <- job +} + +// send has f run on the screen's goroutine at the next tick +func (r *run) send(f func(a *app)) { + r.mu.Lock() + r.later = append(r.later, f) + r.mu.Unlock() + r.wake() +} + +// wake posts a tick unless one is waiting +func (r *run) wake() { + if r.posted.CompareAndSwap(false, true) { + r.post() + } +} + +// watch wakes the screen while a tap holds records, every drainEvery at +// most, and reads the run's state every so often, waking the screen only +// when it changed; until the screen leaves +func (r *run) watch(every time.Duration) { + pace, state := time.NewTicker(drainEvery), time.NewTicker(every) + defer pace.Stop() + defer state.Stop() + var last RunState + for { + select { + case <-r.gone: + return + case <-pace.C: + if holds(r.fed.Load()) || holds(last.Log) { + r.wake() + } + case <-state.C: + if st := r.runner.State(); !st.same(last) { + last = st + r.send(func(a *app) { a.live.state = st }) + } + } + } +} + +func holds(t *core.Tap) bool { return t != nil && len(t.Records()) > 0 } + +// onSignals leaves on SIGTERM and SIGHUP (an ssh session's end), stopping +// what runs, so sinks flush and the terminal is restored. A closed stdout or +// stderr fails the write with EPIPE, which the run logs, instead of killing lw +// on screen; stop gives SIGPIPE back to the runtime. +func (r *run) onSignals() (stop func()) { + sigs, pipe := make(chan os.Signal, 1), make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGTERM, syscall.SIGHUP) + signal.Notify(pipe, syscall.SIGPIPE) + go func() { + select { + case <-sigs: + r.send(func(a *app) { a.leave(Result{}) }) + case <-r.gone: + } + }() + return func() { signal.Stop(sigs); signal.Stop(pipe) } +} + +func (r *run) endAsks() { r.ended.Do(func() { close(r.gone) }) } + +// close fails the asks, waits for the worker and stops what still runs +func (r *run) close() { + r.endAsks() + r.busy.Wait() + if r.runner != nil && !r.leaving { + r.runner.Stop() + } + close(r.jobs) +} + +// active reports a run, or one starting +func (r *run) active() bool { return r.specs != nil || r.op != "" } + +// ask is the Ask Start gets: the worker waits while the screen asks in a +// masked form, until it is answered or the screen leaves +func (r *run) ask(user string, generate bool) ([]byte, error) { + reply := make(chan []byte, 1) + r.send(func(a *app) { a.askPassword(user, generate, reply) }) + select { + case pw := <-reply: + if pw == nil { + return nil, errors.New("no password given") + } + return pw, nil + case <-r.gone: + return nil, errors.New("the screen closed") + } +} + +// tick runs what the worker and the state watch brought, then reads the +// taps into the view; before any key handling, so it neither clears the +// status nor reaches a text field +func (a *app) tick() { + r := a.live + r.posted.Store(false) + r.mu.Lock() + later := r.later + r.later = nil + r.mu.Unlock() + for _, f := range later { + f(a) + } + a.drain(drainBytes, r.state.Log, r.tap) +} + +// tap runs the pipelines here, the screen staying, and gives the view the +// keys: t. After an edit it runs them again in place of the earlier ones, as +// a reload does. +func (a *app) tap() { + r := a.live + switch { + case r.runner == nil: + a.note = "t runs the pipelines here only from lw --tui" + return + case a.problem != nil: + a.status = "not valid yet: ! shows the problem" + return + case r.op != "": + a.note = r.op + "…" + return + } + specs := config.PipelineSpecs(a.comp.Pipelines) + if r.specs != nil && slices.Equal(specs, r.specs) { + a.viewing = true + return + } + r.op = "starting" + r.do(func() { + tap, err := r.runner.Start(specs, r.ask) + r.send(func(a *app) { a.started(specs, tap, err) }) + }) +} + +// started applies Start's outcome: what runs, and the view, or why the new +// pipelines do not, on the part that failed. An earlier run that stopped is +// read whole first, so a rule in the view marks where the next began. +func (a *app) started(specs []config.Spec, tap *core.Tap, err error) { + r := a.live + if r.leaving { + return + } + if old := r.tap; old != nil && old != tap { + a.drain(math.MaxInt, old) + mark := "rerun " + if tap == nil { + mark = "stopped " + } + a.view.mark(mark + time.Now().Format("15:04:05")) + } + r.op, r.tap = "", tap + r.fed.Store(tap) + switch { + case err == nil: + r.specs, a.viewing = specs, true + case tap != nil: + a.fault(err, "not rerun: %s; the earlier pipelines still run") + default: + r.specs = nil + a.fault(err, "%s") + } +} + +// fault chooses the part a start error names, placed as a problem is, and +// shows the error in format +func (a *app) fault(err error, format string) { + text := err.Error() + if pi, n, key, why, ok := compose.Locate(err); ok && pi >= 0 && pi < len(a.comp.Pipelines) { + if pi != a.pi { + a.pi, a.row = pi, [3]int{} + } + a.bar, a.inspect = false, false + a.choose(n) + text = why + if where := a.where(pi, n, key); where != "" { + text = where + ": " + why + } + } + a.dialog = textNotice("Not started", fmt.Sprintf(format, text)) +} + +// leave ends the screen with res once nothing runs: q, r and o's choices +func (a *app) leave(res Result) { + r := a.live + a.result, a.dialog = res, nil + if !r.active() { + a.done = true + return + } + r.leaving, r.op, a.note = true, "stopping", "stopping the pipelines…" + r.endAsks() + r.do(func() { + r.runner.Stop() + r.send(func(a *app) { a.done = true }) + }) +} + +// runMark says on the bar what runs: a call underway, or the counts of the +// pipeline shown +func (a *app) runMark() (string, ui.Style) { + r := a.live + switch { + case r.op != "": + return r.op + "…", a.th.Muted + case r.specs == nil: + return "", a.th.Muted + } + c := r.state.Counts[a.pipeline().Name] + mark, style := string(a.font.arrow)+" running", a.th.Accent + if r.state.Ended { + mark, style = "input ended", a.th.Muted + } + mark += fmt.Sprintf(" · %d in · %d filtered", c.In, c.Filtered) + if c.SinksDropped > 0 { + mark += fmt.Sprintf(" · %d sinks dropped", c.SinksDropped) + } + if r.tap != nil && r.tap.Dropped() > 0 { + mark += fmt.Sprintf(" · screen missed %d", r.tap.Dropped()) + } + return mark, style +} diff --git a/internal/tui/run_test.go b/internal/tui/run_test.go new file mode 100644 index 0000000..3651b91 --- /dev/null +++ b/internal/tui/run_test.go @@ -0,0 +1,363 @@ +package tui + +import ( + "errors" + "fmt" + "os" + "os/exec" + "slices" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/lixenwraith/logwisp/internal/compose" + "github.com/lixenwraith/logwisp/internal/config" + "github.com/lixenwraith/logwisp/internal/core" + + "github.com/lixenwraith/terminal" +) + +// fakeRunner keeps the screen's calls; start, when set, answers Start +type fakeRunner struct { + mu sync.Mutex + specs [][]config.Spec + stops int + state RunState + start func(ask Ask) (*core.Tap, error) +} + +func (f *fakeRunner) Start(specs []config.Spec, ask Ask) (*core.Tap, error) { + f.mu.Lock() + f.specs = append(f.specs, specs) + start := f.start + f.mu.Unlock() + if start != nil { + return start(ask) + } + return core.NewTap(1), nil +} + +func (f *fakeRunner) Stop() { + f.mu.Lock() + defer f.mu.Unlock() + f.stops++ +} + +func (f *fakeRunner) State() RunState { + f.mu.Lock() + defer f.mu.Unlock() + return f.state +} + +func (f *fakeRunner) calls() (starts, stops int) { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.specs), f.stops +} + +// running is a screen on c whose runner is f, already running c, back on +// the diagram from the view t opened +func running(t *testing.T, c *compose.Composition, f *fakeRunner) *app { + t.Helper() + a := mono(c) + a.live = newRun(f, func() {}) + press(a, "t") + await(t, a) + if a.live.specs == nil || !a.viewing { + t.Fatalf("t did not run, viewing %v: %T %s", a.viewing, a.dialog, a.status) + } + press(a, "t") + return a +} + +// await ticks once the runner's side brought something back, as the woken +// screen would +func await(t *testing.T, a *app) { + t.Helper() + for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(time.Millisecond) { + a.live.mu.Lock() + n := len(a.live.later) + a.live.mu.Unlock() + if n > 0 { + break + } + if time.Now().After(deadline) { + t.Fatal("nothing came back from the runner") + } + } + press(a, terminal.KeyNone) +} + +// The tick is handled before any key: it clears no status and types nothing +// into the field being edited +func TestATickKeepsTheStatusAndTheField(t *testing.T) { + a := mono(pipeline(t, []string{"file"}, []string{"null"})) + press(a, terminal.KeyEnter, terminal.KeyEnter, "ab") + a.status = "refused" + press(a, terminal.KeyNone) + if a.status != "refused" || a.insp.field == nil || a.insp.field.Value() != "ab" { + t.Fatalf("status %q, field %v", a.status, a.insp.field) + } +} + +// Wakes coalesce: one tick waits at a time, and handling it allows the next +func TestWakesCoalesce(t *testing.T) { + posts := 0 + a := mono(pipeline(t, []string{"null"}, []string{"null"})) + a.live = newRun(nil, func() { posts++ }) + a.live.wake() + a.live.wake() + if press(a, terminal.KeyNone); posts != 1 { + t.Fatalf("%d ticks posted for two wakes", posts) + } + if a.live.wake(); posts != 2 { + t.Fatal("a handled tick kept the next wake from posting") + } +} + +// The state is read on, but the screen is woken only by a change +func TestTheStateWakesTheScreenOnlyWhenItChanges(t *testing.T) { + var posts sync.WaitGroup + f := &fakeRunner{state: RunState{Counts: map[string]Counts{"p": {In: 1}}}} + r := newRun(f, func() { posts.Done() }) + posts.Add(1) + go r.watch(time.Millisecond) + defer r.endAsks() + posts.Wait() + time.Sleep(20 * time.Millisecond) + r.posted.Store(false) + time.Sleep(20 * time.Millisecond) + if r.posted.Load() { + t.Fatal("an unchanged state woke the screen") + } + posts.Add(1) + f.mu.Lock() + f.state = RunState{Counts: map[string]Counts{"p": {In: 2}}} + f.mu.Unlock() + posts.Wait() +} + +// t runs only a valid composition +func TestTapRefusesAnInvalidComposition(t *testing.T) { + f := &fakeRunner{} + a := mono(pipeline(t, []string{"null"}, []string{"tcp"})) + a.live = newRun(f, func() {}) + if press(a, "t"); a.status != "not valid yet: ! shows the problem" || a.live.op != "" { + t.Fatalf("status %q, op %q", a.status, a.live.op) + } + if starts, _ := f.calls(); starts != 0 { + t.Fatal("started an invalid composition") + } +} + +// t runs the pipelines and marks the bar; unchanged, t only opens the view, +// and after an edit it runs the edited pipelines +func TestTapRerunsAfterAnEdit(t *testing.T) { + f := &fakeRunner{state: RunState{Counts: map[string]Counts{"p": {In: 7, Filtered: 2}}}} + a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f) + a.live.state = f.State() + first := config.PipelineSpecs(a.comp.Pipelines) + if top := text(render(a, 100, 24), 100)[0]; !strings.Contains(top, "running · 7 in · 2 filtered") || !slices.Equal(f.specs[0], first) { + t.Fatalf("%q, specs %v", top, f.specs) + } + if press(a, "t"); a.live.op != "" || !a.viewing { + t.Fatalf("t on what runs: op %q, viewing %v", a.live.op, a.viewing) + } + press(a, "t") + _, err := a.comp.Add(0, "sink", "console") + a.edit(err) + press(a, "t") + await(t, a) + if starts, _ := f.calls(); starts != 2 || slices.Equal(f.specs[1], first) || !slices.Equal(a.live.specs, f.specs[1]) { + t.Fatalf("rerun: %d starts, running %v", starts, a.live.specs) + } +} + +// A rerun that does not build leaves the earlier pipelines running, and says +// so; one that fails once they stopped leaves nothing running +func TestAFailedRerunSaysWhatRuns(t *testing.T) { + f := &fakeRunner{} + a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f) + earlier, tap := a.live.specs, a.live.tap + notice := func() string { return strings.Join(text(render(a, 120, 30), 120), "\n") } + f.start = func(Ask) (*core.Tap, error) { return tap, errors.New("bad sink") } + a.edit(a.comp.Set(0, compose.Node{Role: "format"}, "type", "txt")) + press(a, "t") + await(t, a) + if !slices.Equal(a.live.specs, earlier) || a.live.tap != tap || !strings.Contains(notice(), "not rerun: bad sink; the earlier pipelines still run") { + t.Fatalf("running %v\n%s", a.live.specs, notice()) + } + f.start = func(Ask) (*core.Tap, error) { return nil, errors.New("port taken") } + press(a, terminal.KeyEscape, "t") + await(t, a) + if a.live.specs != nil || a.live.tap != nil || strings.Contains(notice(), "still run") { + t.Fatalf("running %v\n%s", a.live.specs, notice()) + } +} + +// A start error chooses the part its path names, as a problem's does, and +// shows its text +func TestAStartErrorChoosesItsPart(t *testing.T) { + why := config.At(compose.Node{Role: "sink", ID: "console"}.Path(0), errors.New("failed to start sink console: write: broken pipe")) + f := &fakeRunner{start: func(Ask) (*core.Tap, error) { return nil, why }} + a := mono(pipeline(t, []string{"null"}, []string{"null", "console"})) + a.live = newRun(f, func() {}) + press(a, "t") + await(t, a) + n, _ := a.node() + if screen := strings.Join(text(render(a, 120, 30), 120), " "); n != (compose.Node{Role: "sink", ID: "console"}) || !strings.Contains(screen, "sink console: write") { + t.Fatalf("on %+v\n%s", n, screen) + } +} + +// q asks before it stops what runs, and quits once it stopped +func TestQuitAsksWhileRunning(t *testing.T) { + f := &fakeRunner{} + a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f) + press(a, "q") + if c, asks := a.dialog.(*confirm); !asks || !strings.Contains(c.question, "Stop the pipelines") { + t.Fatalf("q while running: %T", a.dialog) + } + press(a, "y") + await(t, a) + if _, stops := f.calls(); !a.done || stops != 1 { + t.Fatalf("done %v after %d stops", a.done, stops) + } +} + +// r and o's choices stop the run before the screen leaves, and take no key +// meanwhile +func TestLeavingStopsTheRunFirst(t *testing.T) { + for _, keys := range []string{"r", "oc"} { + f := &fakeRunner{} + a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f) + if press(a, keys, "q"); a.done || a.dialog != nil { + t.Fatalf("%s left before stopping: %T", keys, a.dialog) + } + await(t, a) + if _, stops := f.calls(); !a.done || stops != 1 || a.result.Exit == Quit { + t.Fatalf("%s: done %v after %d stops, %+v", keys, a.done, stops, a.result) + } + } +} + +// A password a terminal would be asked for is asked in a masked form; with +// generate, Enter alone draws one, shown once, and that is the answer +func TestPasswordsAreAskedMasked(t *testing.T) { + for _, generate := range []bool{false, true} { + var got []byte + f := &fakeRunner{start: func(ask Ask) (*core.Tap, error) { + pw, err := ask("alice", generate) + got = pw + return core.NewTap(1), err + }} + a := mono(pipeline(t, []string{"null"}, []string{"null"})) + a.live = newRun(f, func() {}) + press(a, "t") + await(t, a) + typed := "s3cret" + if generate { + typed = "" + } + press(a, typed) + if screen := strings.Join(text(render(a, 100, 30), 100), "\n"); !strings.Contains(screen, "Password for alice") || + strings.Contains(screen, "s3cret") || !strings.Contains(screen, strings.Repeat("*", len(typed))) { + t.Fatalf("the form:\n%s", screen) + } + press(a, terminal.KeyEnter) + shown := strings.Join(text(render(a, 100, 30), 100), " ") + if generate { + press(a, terminal.KeyEnter) // read + } + await(t, a) + if a.live.specs == nil || len(got) == 0 || !generate && string(got) != typed || generate && !strings.Contains(shown, "shown this once: "+string(got)) { + t.Fatalf("generate %v: answered %q\n%s", generate, got, shown) + } + } +} + +// A generated password stays on screen until a key closes its notice: the +// start goes on only then, so neither a start error nor the next ask hides it +func TestAGeneratedPasswordStaysUntilRead(t *testing.T) { + for name, then := range map[string]func(Ask) error{ + "a start error": func(Ask) error { return errors.New("port taken") }, + "the next ask": func(ask Ask) error { _, err := ask("bob", true); return err }, + } { + answered := make(chan struct{}) + f := &fakeRunner{start: func(ask Ask) (*core.Tap, error) { + if _, err := ask("alice", true); err != nil { + return nil, err + } + close(answered) + return core.NewTap(1), then(ask) + }} + a := mono(pipeline(t, []string{"null"}, []string{"null"})) + a.live = newRun(f, func() {}) + press(a, "t") + await(t, a) + press(a, terminal.KeyEnter) + select { + case <-answered: + t.Fatalf("%s: answered before the password was read", name) + case <-time.After(50 * time.Millisecond): + } + if press(a, terminal.KeyNone); !strings.Contains(strings.Join(text(render(a, 100, 30), 100), " "), "shown this once") { + t.Fatalf("%s: the password left the screen unread: %T", name, a.dialog) + } + press(a, "x") + <-answered + await(t, a) + if screen := strings.Join(text(render(a, 100, 30), 100), " "); strings.Contains(screen, "shown this once") { + t.Fatalf("%s: the notice outlived its key\n%s", name, screen) + } + a.live.close() + } +} + +// On screen a closed stdout fails the write with EPIPE instead of killing lw +// with the terminal raw; leaving gives SIGPIPE back to the runtime +func TestAClosedStdoutFailsWritesOnScreen(t *testing.T) { + if os.Getenv("LW_TEST_CLOSED_STDOUT") != "" { + stop := newRun(nil, func() {}).onSignals() + _, err := os.Stdout.Write([]byte("x\n")) + fmt.Fprintln(os.Stderr, "on screen:", err) + stop() + os.Stdout.Write([]byte("x\n")) + fmt.Fprintln(os.Stderr, "lived on") + os.Exit(0) + } + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + r.Close() + defer w.Close() + var stderr strings.Builder + cmd := exec.Command(os.Args[0], "-test.run=^TestAClosedStdoutFailsWritesOnScreen$") + cmd.Env, cmd.Stdout, cmd.Stderr = append(os.Environ(), "LW_TEST_CLOSED_STDOUT=1"), w, &stderr + cmd.Run() + ws, _ := cmd.ProcessState.Sys().(syscall.WaitStatus) + if !strings.Contains(stderr.String(), "on screen: write /dev/stdout: broken pipe") || !ws.Signaled() || ws.Signal() != syscall.SIGPIPE { + t.Fatalf("%v\n%s", ws, stderr.String()) + } +} + +// SIGTERM and SIGHUP stop what runs and leave +func TestSignalsStopTheRunAndLeave(t *testing.T) { + for _, sig := range []syscall.Signal{syscall.SIGTERM, syscall.SIGHUP} { + f := &fakeRunner{} + a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f) + stop := a.live.onSignals() + if err := syscall.Kill(os.Getpid(), sig); err != nil { + t.Fatal(err) + } + await(t, a) + await(t, a) + stop() + if _, stops := f.calls(); !a.done || stops != 1 { + t.Fatalf("%v: done %v after %d stops", sig, a.done, stops) + } + } +} diff --git a/internal/tui/theme.go b/internal/tui/theme.go index e1cda1b..0b1a3e8 100644 --- a/internal/tui/theme.go +++ b/internal/tui/theme.go @@ -10,49 +10,76 @@ import ( ui "github.com/lixenwraith/terminal/tui" ) -// look is a color tier's styles: the widgets' theme and each column's tint +// look is a color tier's styles: the widgets' theme, each column's tint, and +// the live view's levels by name, any other drawn as text type look struct { ui.Theme Source, Flow, Sink ui.Style + Levels map[string]ui.Style } -// The site's dark tokens; true color and 256 colors paint its background +// The website's dark tokens; true color and 256 colors paint its background var ( - siteBg = color.RGB{R: 0x14, G: 0x17, B: 0x1d} - sitePanel = color.RGB{R: 0x1c, G: 0x20, B: 0x28} - siteFg = color.RGB{R: 0xe3, G: 0xe7, B: 0xef} - siteMuted = color.RGB{R: 0x9a, G: 0xa3, B: 0xb5} - siteAccent = color.RGB{R: 0x6e, G: 0xa0, B: 0xff} + siteBg = color.RGB{R: 0x11, G: 0x11, B: 0x1b} // --dark-bg + siteFg = color.RGB{R: 0xff, G: 0xff, B: 0xff} // --text-color ) var siteLook = look{ Theme: ui.Theme{ Text: ui.Style{Fg: siteFg, Bg: siteBg}, - Muted: ui.Style{Fg: siteMuted}, - Accent: ui.Style{Fg: siteAccent, Attr: terminal.AttrBold}, - Selected: ui.Style{Bg: color.RGB{R: 0x26, G: 0x2c, B: 0x38}}, - Input: ui.Style{Bg: sitePanel}, + Muted: ui.Style{Fg: color.RGB{R: 0x94, G: 0xa3, B: 0xb8}}, // --muted-color + Accent: ui.Style{Fg: color.RGB{R: 0x8e, G: 0xa2, B: 0xff}, Attr: terminal.AttrBold}, // --accent-color + Selected: ui.Style{Bg: color.RGB{R: 0x1d, G: 0x1c, B: 0x3e}}, // --card-background-color + Input: ui.Style{Bg: color.RGB{R: 0x1a, G: 0x1b, B: 0x26}}, // --dark-surface Cursor: ui.Style{Fg: siteBg, Bg: siteFg}, - Error: ui.Style{Fg: color.RGB{R: 0xff, G: 0x6b, B: 0x6b}}, - Border: ui.Style{Fg: color.RGB{R: 0x5a, G: 0x63, B: 0x75}}, + Error: ui.Style{Fg: color.RGB{R: 0xf8, G: 0x71, B: 0x71}}, // --danger-color + Border: ui.Style{Fg: color.RGB{R: 0x7c, G: 0x7c, B: 0x82}}, // the diagram's --wire }, - Source: ui.Style{Fg: color.RGB{R: 0x56, G: 0xc8, B: 0xd8}}, - Flow: ui.Style{Fg: color.RGB{R: 0xb1, G: 0x8c, B: 0xff}}, - Sink: ui.Style{Fg: color.RGB{R: 0x6f, G: 0xcf, B: 0x8f}}, + Source: ui.Style{Fg: color.RGB{R: 0x22, G: 0xd3, B: 0xee}}, // --hue-cyan + Flow: ui.Style{Fg: color.RGB{R: 0xa7, G: 0x8b, B: 0xfa}}, // --hue-violet + Sink: ui.Style{Fg: color.RGB{R: 0x34, G: 0xd3, B: 0x99}}, // --hue-green + Levels: map[string]ui.Style{ + "TRACE": {Fg: color.RGB{R: 0x94, G: 0xa3, B: 0xb8}}, // --hue-slate + "DEBUG": {Fg: color.RGB{R: 0x60, G: 0xa5, B: 0xfa}}, // --hue-blue + "INFO": {Fg: color.RGB{R: 0x34, G: 0xd3, B: 0x99}}, // --ok-color + "WARN": {Fg: color.RGB{R: 0xfb, G: 0xbf, B: 0x24}}, // --warn-color + "ERROR": {Fg: color.RGB{R: 0xf8, G: 0x71, B: 0x71}, Attr: terminal.AttrBold}, // --danger-color + }, +} + +// console16 is the 16-color look, a text console's, where bold is bright: +// text is palette white rather than bold, which On would spread to every role +// drawn on it; muted the console's own grey; the cursor row a blue bar under +// which every role stays readable +var console16 = ui.Theme{ + Text: ui.Style{Fg: color.RGB{R: color.ANSIBrightWhite}, Attr: terminal.AttrFg256 | terminal.AttrBgDefault}, + Muted: ui.Style{Attr: terminal.AttrFgDefault}, + Accent: ansi(color.ANSIBrightYellow), + Selected: ui.Style{Bg: color.RGB{R: color.ANSIBlue}, Attr: terminal.AttrBg256}, + Input: ui.Theme16.Input, + Cursor: ui.Theme16.Cursor, + Error: ui.Theme16.Error, + Border: ui.Style{Attr: terminal.AttrFgDefault}, } // ansi is a palette index the terminal shows in its own shade func ansi(i uint8) ui.Style { return ui.Style{Fg: color.RGB{R: i}, Attr: terminal.AttrFg256} } // looks holds one look per color tier: 16 colors and none keep the -// terminal's background +// terminal's background. Levels in 16 colors are picked by hand, as the +// quantizer turns the site's pastels grey; without color, bold and dim only +// add to the level's name. var looks = map[terminal.ColorMode]look{ terminal.ColorModeTrueColor: siteLook, terminal.ColorMode256: siteLook, - terminal.ColorMode16: {Theme: ui.Theme16, - Source: ansi(color.ANSICyan), Flow: ansi(color.ANSIMagenta), Sink: ansi(color.ANSIGreen)}, + terminal.ColorMode16: {Theme: console16, + Source: ansi(color.ANSICyan), Flow: ansi(color.ANSIBrightMagenta), Sink: ansi(color.ANSIGreen), + Levels: map[string]ui.Style{"TRACE": console16.Muted, "DEBUG": ansi(color.ANSICyan), "INFO": ansi(color.ANSIGreen), + "WARN": ansi(color.ANSIBrightYellow), "ERROR": ansi(color.ANSIBrightRed)}}, terminal.ColorModeNone: {Theme: ui.MonoTheme, - Source: ui.MonoTheme.Text, Flow: ui.MonoTheme.Text, Sink: ui.MonoTheme.Text}, + Source: ui.MonoTheme.Text, Flow: ui.MonoTheme.Text, Sink: ui.MonoTheme.Text, + Levels: map[string]ui.Style{"TRACE": ui.MonoTheme.Muted, "DEBUG": ui.MonoTheme.Muted, "INFO": ui.MonoTheme.Text, + "WARN": ui.MonoTheme.Accent, "ERROR": ui.MonoTheme.Accent}}, } // font is the marks one character set draws: the widgets' and the canvas's; @@ -63,6 +90,8 @@ type font struct { arrow, down rune // into the flow box and a sink; between stacked parts bar, barOn rune // a node, and the chosen node dot rune // before a column's title + newline rune // a line break in a row of the live view + odd rune // in the live view, a rune a cell cannot show valid string invalid string enter string @@ -86,12 +115,12 @@ func (f font) fold(r rune) rune { // fonts: Unicode, a text console's CP437, and ASCII outside UTF-8 var ( unicodeFont = font{Glyphs: ui.GlyphsUnicode, box: ui.LineDashed, - arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '●', valid: "✓", invalid: "✗", enter: "⏎", space: "␣"} + arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '●', newline: '↵', odd: '\uFFFD', valid: "✓", invalid: "✗", enter: "⏎", space: "␣"} cp437Font = font{Glyphs: ui.GlyphsCP437, box: ui.LineSingle, - arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '•', valid: "√", invalid: "x", enter: "enter", space: "space", + arrow: '►', down: '▼', bar: '▌', barOn: '█', dot: '•', newline: '¶', odd: '?', valid: "√", invalid: "x", enter: "enter", space: "space", folds: map[rune]rune{'…': '.'}} asciiFont = font{Glyphs: ui.GlyphsASCII, box: ui.LineASCII, - arrow: '>', down: 'v', bar: '|', barOn: '#', dot: '*', valid: "ok", invalid: "x", enter: "enter", space: "space", + arrow: '>', down: 'v', bar: '|', barOn: '#', dot: '*', newline: '|', odd: '?', valid: "ok", invalid: "x", enter: "enter", space: "space", folds: map[rune]rune{'…': '.', '·': '-'}, ascii: true} ) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 17f4504..9ccc224 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -9,6 +9,7 @@ import ( "fmt" "slices" "strings" + "time" "github.com/lixenwraith/logwisp/internal/compose" "github.com/lixenwraith/logwisp/internal/config" @@ -41,8 +42,9 @@ var modes = map[string][]terminal.ColorMode{ } // Run composes c on the terminal until the user leaves; an empty c opens the -// preset menu. color is lw's color setting. -func Run(c *compose.Composition, color string) (Result, error) { +// preset menu. color is lw's color setting; runner runs the pipelines on t, +// and nothing runs once Run returns. +func Run(c *compose.Composition, color string, runner Runner) (Result, error) { t := terminal.New(modes[color]...) if err := t.Init(); err != nil { return Result{}, err @@ -52,14 +54,21 @@ func Run(c *compose.Composition, color string) (Result, error) { return Result{}, err } a := newApp(c, looks[t.ColorMode()], fontFor(terminal.DetectColorMode())) + a.live = newRun(runner, func() { t.PostEvent(terminal.Event{Type: terminal.EventKey, Key: terminal.KeyNone}) }) + defer a.live.close() + defer a.live.onSignals()() + if runner != nil { + go a.live.watch(time.Second) + } w, h := t.Size() + cells := make([]terminal.Cell, w*h) for !a.done { - cells := make([]terminal.Cell, w*h) a.paint(cells, w, h) t.Flush(cells, w, h) switch ev := t.PollEvent(); ev.Type { case terminal.EventResize: w, h = ev.Width, ev.Height + cells = make([]terminal.Cell, w*h) case terminal.EventClosed: return Result{}, errors.New("the terminal closed") default: @@ -93,6 +102,9 @@ type app struct { inspect bool // keys go to the inspector insp inspector dialog dialog // takes every key while open + live *run // what t runs + view view // what runs shows + viewing bool // keys go to the view status string // the last edit's refusal note string // what the last action did problem error // the composition's problems, joined @@ -102,7 +114,7 @@ type app struct { } func newApp(c *compose.Composition, l look, f font) *app { - a := &app{comp: c, look: l, th: l.Theme, font: f} + a := &app{comp: c, look: l, th: l.Theme, font: f, live: newRun(nil, func() {})} a.th.Glyphs = f.Glyphs a.insp.open = map[string]bool{} if len(c.Pipelines) == 0 { @@ -192,15 +204,27 @@ func (a *app) edit(err error) bool { func (a *app) check() { a.problem = a.comp.Validate() } -// handle routes an event: an open dialog takes all, then the inspector, then -// the canvas +// handle routes an event: the tick first, which the input never sends (the +// terminal library swallows unknown keys), then an open dialog takes all, +// then the view, the inspector, the canvas; leaving, nothing more is taken func (a *app) handle(ev terminal.Event) { - if ev.Type == terminal.EventKey || ev.Type == terminal.EventPaste { + switch { + case ev.Type == terminal.EventKey && ev.Key == terminal.KeyNone: + a.tick() + return + case a.live.leaving: + return + case ev.Type == terminal.EventKey || ev.Type == terminal.EventPaste: a.status, a.note = "", "" } + if a.dialog == nil && !a.viewing { + a.view.own = false // a key on the diagram gives the point back to its cursor + } switch { case a.dialog != nil: a.dialog.handle(a, ev) + case a.viewing: + a.viewKey(ev) case ev.Type == terminal.EventPaste && a.insp.field != nil: a.insp.field.Paste(ev.Text) case ev.Type == terminal.EventPaste: @@ -263,13 +287,15 @@ func (a *app) globalKey(ev terminal.Event) { case is(ev, terminal.KeyNone, "o"): a.dialog = a.outputMenu() case is(ev, terminal.KeyNone, "?"): - a.dialog = help(a.font) + a.dialog = help(a.font, a.viewing) case is(ev, terminal.KeyNone, "!"): a.showProblem() case is(ev, terminal.KeyNone, "c"): a.changeType() case is(ev, terminal.KeyNone, "r"): a.run() + case is(ev, terminal.KeyNone, "t"): + a.tap() case is(ev, terminal.KeyNone, "]") && n > 0: a.pi, a.row, a.inspect = (a.pi+1)%n, [3]int{}, false case is(ev, terminal.KeyNone, "[") && n > 0: @@ -346,7 +372,7 @@ func (a *app) run() { a.status = "not valid yet: ! shows the problem" return } - a.result, a.done = Result{Exit: Start, Pipelines: a.comp.Pipelines}, true + a.leave(Result{Exit: Start, Pipelines: a.comp.Pipelines}) } // switchStage turns a flow stage on with its defaults, or off; a stage with @@ -398,11 +424,15 @@ func (a *app) paste(text string) { } func (a *app) quit() { - if !a.changed { - a.done = true - return + leave := func(a *app) { a.leave(Result{}) } + switch { + case a.live.active(): + a.dialog = &confirm{question: "Stop the pipelines and quit?", yes: leave} + case a.changed: + a.dialog = &confirm{question: "Quit without running or printing the pipelines?", yes: leave} + default: + leave(a) } - a.dialog = &confirm{question: "Quit without running or printing the pipelines?", yes: func(a *app) { a.done = true }} } // showProblem chooses the part the first problem names and shows them all @@ -447,8 +477,10 @@ func (a *app) choose(n compose.Node) { } } -// draw draws the screen: a title row, the canvas with the inspector beside -// it from 110 columns or below it, a status row, and any dialog over them +// draw draws the screen: a title row, the diagram, a status row, and any +// dialog over them. Once something ran, the live view takes the rows under +// the diagram from 40 rows, unless the inspector below it is in use, and the +// whole screen under that while it takes the keys. func (a *app) draw(r ui.Region) { r.FillStyle(a.th.Text) status, style := a.bottom(r.W) @@ -456,38 +488,67 @@ func (a *app) draw(r ui.Region) { a.pi = min(a.pi, len(a.comp.Pipelines)-1) a.node() // a delete can leave the row past its column's end a.drawBar(r.Sub(0, 0, r.W, 1)) - body := r.Sub(0, 1, r.W, r.H-1-len(status)) - if r.W >= 110 { - iw := min(60, r.W*2/5) - a.drawCanvas(body.Sub(0, 0, r.W-iw-1, body.H)) - a.drawInspector(body.Sub(r.W-iw, 0, iw, body.H)) - } else { - ch := min(a.layout(r.W).h, body.H-min(10, body.H/2)) - a.drawCanvas(body.Sub(0, 0, r.W, ch)) - a.drawInspector(body.Sub(0, ch+1, r.W, body.H-ch-1)) + body, l := r.Sub(0, 1, r.W, r.H-1-len(status)), a.layout(r.W) + a.view.sync(a.point(), a.live.tap) + switch watched := a.viewing || a.live.tap != nil || len(a.view.rows) > 0; { + case watched && r.H >= splitMin && (!a.inspect || l.inspector > 0): + top := l.h + if a.inspect { + top = max(top, body.H/2) + } + top = min(top, body.H*2/3) + a.drawDiagram(body.Sub(0, 0, r.W, top), l, false) + a.drawView(body.Sub(0, top, r.W, body.H-top)) + case a.viewing: + a.drawView(body) + default: + a.drawDiagram(body, l, true) } } - for i, l := range status { - r.TextStyled(1, r.H-len(status)+i, l, style) + for i, row := range status { + r.TextStyled(1, r.H-len(status)+i, row, style) } if a.status == "" && a.note == "" && a.problem == nil { - keys := a.hints(r.W) - r.TextStyled(r.W-ui.RuneLen(keys)-1, r.H-1, keys, a.th.Muted) + x := 4 + ui.RuneLen(status[0]) + r.TextStyled(x, r.H-1, a.hints(r.W-x-1), a.th.Muted) + } + if p := a.view.prompt; a.viewing && p != nil { + row := r.Sub(0, r.H-1, r.W, 1) + row.FillStyle(a.th.Text) + row.TextStyled(1, 0, "/", a.th.Accent) + row.Sub(3, 0, r.W-4, 1).TextInput(p, "a pattern: lower case ignores case; empty shows all", true, a.th) } if a.dialog != nil { a.dialog.draw(a, r) } } +// drawDiagram draws the canvas with the inspector beside it from 110 +// columns, else, with below, under it +func (a *app) drawDiagram(r ui.Region, l layout, below bool) { + switch { + case l.inspector > 0: + a.drawCanvas(r.Sub(0, 0, l.w, r.H), l) + a.drawInspector(r.Sub(r.W-l.inspector, 0, l.inspector, r.H)) + case !below: + a.drawCanvas(r, l) + default: + ch := min(l.h, r.H-min(10, r.H/2)) + a.drawCanvas(r.Sub(0, 0, r.W, ch), l) + a.drawInspector(r.Sub(0, ch+1, r.W, r.H-ch-1)) + } +} + // drawBar draws the title row: the pipelines by name, the one shown marked, // or it and its place when they do not fit; with the keys, the bar's verbs +// after them func (a *app) drawBar(r ui.Region) { th, f := a.th, a.font + full, hint := r, "" if a.bar { r.TextStyled(0, 0, string(f.Focus), th.Accent) - hint := "a add " + f.enter + " rename d delete" if r.W >= 60 { - r.TextStyled(r.W-ui.RuneLen(hint)-1, 0, hint, th.Muted) + hint = "a add " + f.enter + " rename d delete" r = r.Sub(0, 0, r.W-ui.RuneLen(hint)-2, 1) } } @@ -513,8 +574,16 @@ func (a *app) drawBar(r ui.Region) { x += ui.RuneLen(label) + 2 } if len(names) < len(a.comp.Pipelines) { - r.TextStyled(x, 0, fmt.Sprintf("%d/%d", a.pi+1, len(a.comp.Pipelines)), th.Muted) + place := fmt.Sprintf("%d/%d", a.pi+1, len(a.comp.Pipelines)) + r.TextStyled(x, 0, place, th.Muted) + x += ui.RuneLen(place) + 2 } + if mark, style := a.runMark(); mark != "" { + mark = ui.Truncate(mark, max(0, r.W-x)) + r.TextStyled(x, 0, mark, style) + x += ui.RuneLen(mark) + 2 + } + full.TextStyled(min(x+1, full.W-ui.RuneLen(hint)-1), 0, hint, th.Muted) } // paint draws the screen into cells, each rune one the font's terminal shows @@ -552,10 +621,14 @@ func (a *app) bottom(w int) ([]string, ui.Style) { return clip(ui.WrapText(text, w-2), 3), style } -// hints are the status row's keys, shown while nothing else needs the row +// hints are the status row's keys in w columns, the view's while it takes +// them, shown after the status while nothing else needs the row func (a *app) hints(w int) string { - keys := "a add d delete " + a.font.space + " on/off J/K move p preset o output ? help" - if ui.RuneLen(keys)+12 > w { + keys := "a add d delete " + a.font.space + " on/off t tap p preset o output ? help" + if a.viewing { + keys = "/ filter 0-5 level v mode s point " + a.font.enter + " detail t back ? help" + } + if ui.RuneLen(keys) > w { keys = "? help" } return keys diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 04ac1bd..f7cf527 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -8,6 +8,7 @@ import ( "github.com/lixenwraith/logwisp/internal/compose" "github.com/lixenwraith/logwisp/internal/config" + "github.com/lixenwraith/color" "github.com/lixenwraith/terminal" ui "github.com/lixenwraith/terminal/tui" ) @@ -80,19 +81,20 @@ func focusKey(t *testing.T, a *app, name string) line { } // Each wire glyph's arms meet the cells beside it, whatever the count of -// sources and sinks and the width, and every source and sink is wired to -// the flow box's arrow +// sources and sinks and the width, and every source is wired from past its +// text to the flow box's arrow, and from the box to an arrow into each sink func TestWiresJoinWhereTheyMeet(t *testing.T) { arms := map[rune]uint8{'│': 5, '─': 10, '└': 3, '┌': 6, '├': 7, '┘': 9, '┴': 11, '┐': 12, '┤': 13, '┬': 14, '┼': 15, '►': 10} steps := map[uint8][2]int{1: {0, -1}, 2: {1, 0}, 4: {0, 1}, 8: {-1, 0}} opposite := map[uint8]uint8{1: 4, 2: 8, 4: 1, 8: 2} for n := range 16 { sources, sinks := slices.Repeat([]string{"null"}, n%4), slices.Repeat([]string{"null"}, n/4) - for _, w := range []int{64, 80, 109, 130} { + for _, screen := range []int{64, 80, 109, 130, 160, 240} { a := mono(pipeline(t, sources, sinks)) - l := a.layout(w) + l := a.layout(screen) + w := l.w cells := make([]terminal.Cell, w*l.h) - a.drawCanvas(ui.NewRegion(cells, w, 0, 0, w, l.h)) + a.drawCanvas(ui.NewRegion(cells, w, 0, 0, w, l.h), l) glyph := func(x, y int) rune { if x < 0 || x >= w || y < 0 || y >= l.h { return ' ' @@ -124,13 +126,13 @@ func TestWiresJoinWhereTheyMeet(t *testing.T) { walk(l.box.x-1, l.entry) walk(l.box.x+l.box.w+1, l.exit) for i := range sources { - if s := l.nodes[sourcesCol][i]; !reached[[2]int{s.x + s.w, s.y}] { - t.Errorf("%d sources, %d sinks, %d columns: source %d not wired", len(sources), len(sinks), w, i) + if s := l.nodes[sourcesCol][i]; !reached[[2]int{s.x + s.end + 1, s.y}] { + t.Errorf("%d sources, %d sinks, %d columns: source %d not wired from its text", len(sources), len(sinks), w, i) } } for i := range sinks { - if s := l.nodes[sinksCol][i]; !reached[[2]int{s.x - 1, s.y}] { - t.Errorf("%d sources, %d sinks, %d columns: sink %d not wired", len(sources), len(sinks), w, i) + if s := l.nodes[sinksCol][i]; !reached[[2]int{s.x - 1, s.y}] || glyph(s.x-1, s.y) != '►' { + t.Errorf("%d sources, %d sinks, %d columns: sink %d not wired by an arrow", len(sources), len(sinks), w, i) } } } @@ -138,10 +140,17 @@ func TestWiresJoinWhereTheyMeet(t *testing.T) { } // The parts stack under 64 columns; from 64 they sit side by side with the -// inspector below, and from 110 the inspector sits beside them +// inspector below, split as ever, and from 110 the inspector sits beside +// them, taking what they leave up to 100 columns func TestLayoutFollowsTheWidth(t *testing.T) { a := mono(pipeline(t, []string{"null"}, []string{"null"})) - for w, want := range map[int]string{63: "stacked", 64: "below", 109: "below", 110: "beside"} { + for w, box := range map[int]int{64: 18, 80: 24, 109: 30} { + if l := a.layout(w); l.box.w != box || l.nodes[sourcesCol][0].x != 0 || l.wire != gutter { + t.Errorf("%d columns: the box %d wide, the sources from %d, wires %d", w, l.box.w, l.nodes[sourcesCol][0].x, l.wire) + } + } + beside := map[int]int{110: 44, 240: 100} // the inspector's width + for w, want := range map[int]string{63: "stacked", 64: "below", 109: "below", 110: "beside", 240: "beside"} { rows := text(render(a, w, 40), w) at := func(s string) (int, int) { for y, r := range rows { @@ -163,12 +172,94 @@ func TestLayoutFollowsTheWidth(t *testing.T) { case ruleX != 0 || ruleY <= flowY: got = "lost" } - if got != want { - t.Errorf("%d columns: %s, want %s\n%s", w, got, want, strings.Join(rows, "\n")) + if got != want || got == "beside" && w-ruleX != beside[w] { + t.Errorf("%d columns: %s, want %s, inspector %d wide\n%s", w, got, want, w-ruleX, strings.Join(rows, "\n")) } } } +// From 110 columns the parts take the widths their content asks, nothing cut +// short and a source's wire after its last word, with at most 16 columns of +// wire a side, and sit centred +func TestWideCanvasSizesToItsContent(t *testing.T) { + c := pipeline(t, []string{"file", "http_chain"}, []string{"null"}) + if err := c.Set(0, compose.Node{Role: "source", ID: "file"}, "directory", "/var/log/app/nginx"); err != nil { + t.Fatal(err) + } + for _, w := range []int{160, 240, 320} { + a := mono(c) + l := a.layout(w) + src, sink := l.nodes[sourcesCol][0], l.nodes[sinksCol][0] + wire, left, right := l.box.x-src.x-src.w, src.x, l.w-sink.x-sink.w + var canvas []string + for _, row := range text(render(a, w, 40), w)[1 : 1+l.h] { + canvas = append(canvas, string([]rune(row)[:l.w])) + } + if screen := strings.Join(canvas, "\n"); strings.Contains(screen, "…") || !strings.Contains(screen, "http_chain listens ─") || wire > 16 || left-right > 1 || right-left > 1 { + t.Errorf("%d columns: wire %d, margins %d and %d\n%s", w, wire, left, right, screen) + } + } +} + +// Beside the inspector a source or sink lists its options set, a row each and +// four at most, or "defaults"; below it, their values in a line +func TestNodesListTheirOptionsBesideTheInspector(t *testing.T) { + c := pipeline(t, []string{"file"}, []string{"null"}) + for k, v := range map[string]string{"directory": "/srv", "pattern": "*.log", "check_interval_ms": "50", "from": "end", "raw": "true"} { + if err := c.Set(0, compose.Node{Role: "source", ID: "file"}, k, v); err != nil { + t.Fatal(err) + } + } + a := mono(c) + for _, tc := range []struct { + w, rows int + show []string + }{ + {240, 5, []string{"█ directory ", "▌ defaults"}}, + {100, 2, []string{"/srv *.log"}}, + } { + rows := text(render(a, tc.w, 30), tc.w) + x, blocks := a.layout(tc.w).nodes[sourcesCol][0].x, 0 + for _, row := range rows { + if []rune(row)[x] == a.font.barOn { + blocks++ + } + } + screen := strings.Join(rows, "\n") + for _, want := range tc.show { + if blocks != tc.rows || !strings.Contains(screen, want) { + t.Errorf("%d columns: %d rows, want %d and %q\n%s", tc.w, blocks, tc.rows, want, screen) + } + } + } +} + +// The inspector's help sits two rows under its last key, not at the foot +func TestInspectorHelpSitsUnderItsKeys(t *testing.T) { + a, w := mono(pipeline(t, []string{"file"}, []string{"null"})), 240 + press(a, terminal.KeyEnter) + var rows []string + for _, row := range text(render(a, w, 67), w) { + rows = append(rows, string([]rune(row)[a.layout(w).w+1:])) + } + help, _ := a.help(a.lines()[a.insp.cursor]) + y := slices.IndexFunc(rows, func(r string) bool { return strings.Contains(r, help) }) + if y < 3 || strings.TrimSpace(rows[y-1]) != "" || strings.TrimSpace(rows[y-2]) == "" { + t.Errorf("help on row %d\n%s", y, strings.Join(rows[:20], "\n")) + } +} + +// Hints follow their text, three columns on, not the screen's far edge: the +// keys after the status, the bar's verbs after the pipelines +func TestHintsFollowTheirText(t *testing.T) { + a, w := mono(pipeline(t, []string{"null"}, []string{"null"})), 320 + foot := text(render(a, w, 24), w)[23] + press(a, terminal.KeyUp) + if top := text(render(a, w, 24), w)[0]; !strings.HasPrefix(foot, " "+a.font.valid+" valid a add") || !strings.Contains(top, "● p a add") { + t.Errorf("%q\n%q", top, foot) + } +} + // Without color, the chosen part still changes a glyph: a source's or a // sink's bar, a stage's on or off mark, an empty column's slot func TestSelectionChangesAGlyph(t *testing.T) { @@ -181,7 +272,7 @@ func TestSelectionChangesAGlyph(t *testing.T) { l := a.layout(80) at := func(col, row int) rune { cells := make([]terminal.Cell, 80*l.h) - a.drawCanvas(ui.NewRegion(cells, 80, 0, 0, 80, l.h)) + a.drawCanvas(ui.NewRegion(cells, 80, 0, 0, 80, l.h), l) s := l.nodes[col][row] return cells[s.y*80+s.x].Rune } @@ -448,6 +539,72 @@ func TestOnlyFullColorPaintsTheBackground(t *testing.T) { } } +// The cursor row is one background under every role, or one reverse bar +// without color: each cell of the chosen part's rows, but its tint block, +// takes the Selected style, and a source's bar ends where its leader starts +func TestTheCursorRowIsOneBackground(t *testing.T) { + c := pipeline(t, []string{"file"}, []string{"null"}) + if err := c.Set(0, compose.Node{Role: "source", ID: "file"}, "directory", "/srv"); err != nil { + t.Fatal(err) + } + for _, mode := range []terminal.ColorMode{terminal.ColorModeNone, terminal.ColorMode16, terminal.ColorModeTrueColor} { + a := newApp(c, looks[mode], unicodeFont) + bar := func(cell terminal.Cell) bool { + if mode == terminal.ColorModeNone { + return cell.Attrs&terminal.AttrReverse != 0 + } + return cell.Bg == a.th.Selected.Bg && cell.Attrs&(terminal.AttrBgColor|terminal.AttrReverse) == a.th.Selected.Attr&terminal.AttrBgColor + } + for _, w := range []int{80, 240} { + for _, at := range [][2]int{{sourcesCol, 0}, {flowCol, 1}} { + a.col, a.row[at[0]] = at[0], at[1] + cells, s := render(a, w, 30), a.layout(w).nodes[at[0]][at[1]] + for y := 1 + s.y; y < 1+s.y+s.h; y++ { + end := s.x + s.w + if at[0] == sourcesCol && y == 1+s.y { + end = s.x + s.end + 1 + } + for x := s.x + 1; x < s.x+s.w; x++ { + if cell := cells[y*w+x]; bar(cell) != (x < end) { + t.Fatalf("mode %v, %d columns: %q at %d,%d is %+v", mode, w, cell.Rune, x, y, cell) + } + } + } + } + } + } +} + +// The chosen part's tint block keeps its color on the cursor bar in every +// tier: drawn in its tint, never reversed +func TestTheChosenBlockKeepsItsTint(t *testing.T) { + c := pipeline(t, []string{"null"}, []string{"null"}) + if err := c.Set(0, compose.Node{Role: "format"}, "type", "txt"); err != nil { + t.Fatal(err) + } + for mode, l := range looks { + a := newApp(c, l, unicodeFont) + for col, tint := range []ui.Style{l.Source, l.Flow, l.Sink} { + a.col, a.row[flowCol] = col, 2 + cells, s := render(a, 80, 24), a.layout(80).nodes[col][a.row[col]] + if cell := cells[(1+s.y)*80+s.x]; cell.Rune != a.font.barOn || cell.Fg != tint.Fg || cell.Attrs&(terminal.AttrFgColor|terminal.AttrReverse) != tint.Attr&terminal.AttrFgColor { + t.Errorf("mode %v, column %d: %+v", mode, col, cell) + } + } + } +} + +// 256 colors keep the site look's panel, edited field and cursor bar apart +func TestSiteShadesStayApartIn256Colors(t *testing.T) { + l, shades := looks[terminal.ColorMode256], map[uint8]bool{} + for _, c := range []color.RGB{l.Text.Bg, l.Input.Bg, l.Selected.Bg} { + shades[terminal.RGBTo256(c)] = true + } + if len(shades) != 3 { + t.Errorf("%v", shades) + } +} + // ASCII outside a UTF-8 locale, CP437 on a text console, else Unicode func TestFontFollowsTheLocale(t *testing.T) { for _, c := range []struct { @@ -596,13 +753,27 @@ func TestConfirmAnswersOnEnter(t *testing.T) { } } -// The canvas scrolls to keep the chosen part in view, its bar solid +// The canvas scrolls to keep the chosen part in view, its bar solid on every +// row it takes func TestCanvasKeepsTheChosenInView(t *testing.T) { a := mono(pipeline(t, []string{"null"}, slices.Repeat([]string{"null"}, 8))) a.col, a.row[sinksCol] = sinksCol, 7 if screen := strings.Join(text(render(a, 80, 24), 80), "\n"); !strings.ContainsRune(screen, a.font.barOn) { t.Fatalf("the eighth sink is out of view:\n%s", screen) } + c := pipeline(t, slices.Repeat([]string{"file"}, 4), []string{"null"}) + a = mono(c) + for _, n := range a.nodes(sourcesCol) { + for k, v := range map[string]string{"directory": "/srv", "pattern": "*.log", "check_interval_ms": "50"} { + if err := c.Set(0, n, k, v); err != nil { + t.Fatal(err) + } + } + } + a.row[sourcesCol] = 3 + if screen := strings.Join(text(render(a, 240, 14), 240), "\n"); strings.Count(screen, string(a.font.barOn)) != 4 { + t.Fatalf("the fourth source is not whole in view:\n%s", screen) + } } // A notice taller than the screen scrolls with j and k and says where it is diff --git a/internal/tui/view.go b/internal/tui/view.go new file mode 100644 index 0000000..bf7e298 --- /dev/null +++ b/internal/tui/view.go @@ -0,0 +1,859 @@ +package tui + +import ( + "fmt" + "regexp" + "slices" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/lixenwraith/logwisp/internal/compose" + "github.com/lixenwraith/logwisp/internal/config" + "github.com/lixenwraith/logwisp/internal/core" + "github.com/lixenwraith/logwisp/internal/logline" + + "github.com/lixenwraith/terminal" + ui "github.com/lixenwraith/terminal/tui" +) + +const ( + splitMin = 40 // the shortest screen with the diagram over the live view + ringBytes = 32 << 20 // what the view's rows may hold + drainBytes = 1 << 20 // what a tick reads from the taps; the rest waits for the next + drainEvery = 100 * time.Millisecond + detailBeside = 160 // the narrowest view with the detail beside its rows + rowBytes = 400 // a row's own fields, counted beside its text + fieldBytes = 32 // a field's two string headers +) + +// passed ranks the verdict of an entry no stage dropped: past every stage +const passed = 1 << 30 + +// stages are the flow's stages in the order entries pass them +var stages = func() (out []string) { + for _, s := range config.FlowStages() { + out = append(out, s.Key) + } + return out +}() + +// rank orders a verdict along the flow: the stage that dropped the entry, a +// filter by its index, or passed +func rank(stage string, index int) int { + if stage == "" { + return passed + } + return slices.Index(stages, stage)<<16 | min(max(index, 0), 1<<16-1) +} + +// row is an entry the view holds, read once through logline on arrival into +// text of its own, or a rule across the rows where a run ended +type row struct { + logline.Line // Level as written when outside core.Levels (PROC) + seq uint64 + at time.Time + level int // in core.Levels, -1 for none or one outside them + fields string // Fields as k=v, as the list shows them + pipe, source string // pipe is "" for lw's own log and rules + stage string + index, rank int + payload []byte + size int // what it holds: its text, payload and rowBytes + mark string // a rule's text +} + +// view is the live view: rows bounded by what they hold, and the seqs of +// those the filters pass, oldest first. The screen's goroutine alone uses it. +type view struct { + rows []row + bytes int + next uint64 + shown []uint64 + cursor int // in shown + scroll int + paused bool // the cursor keeps its row; else it is on the newest + detail bool + dscroll int + tagW int + listH int + mode int + floor int // 0 shows every row, n those of core.Levels[n-1] and above + own bool // the point is lw's own log until the canvas cursor moves + pat pattern + prompt *ui.TextFieldState // the pattern being typed + built query // what shown holds +} + +// query is what picks the rows shown +type query struct { + at point + mode, floor int + pattern string +} + +// verdicts are what the view shows at its point: all that reaches it, what +// passed it, what it dropped +var verdicts = [...]string{"all", "passed", "dropped"} + +const ( + modeAll = iota + modePassed + modeDropped +) + +// point is where on a pipeline the view looks, as the canvas cursor picks +// it: the verdicts reaching it rank from reach, those it drops from lo to +// hi; a source's and the heartbeat's take only their own entries. own is +// lw's own log alone, which s picks. +type point struct { + pipe, name string + one, own bool + source string + reach int + lo, hi int +} + +// lwShown is the lowest level of lw's own log a pipeline's point shows, so +// its debug records do not bury the entries +var lwShown = slices.IndexFunc(core.Levels, func(l core.Level) bool { return l.Level == "INFO" }) + +// shows reports whether the point in mode shows r: lw's own log shows from +// info up at every point and whole at its own, which names no pipeline; a +// heartbeat only at the sinks' and its own +func (p point) shows(r *row, mode int) bool { + switch { + case r.pipe == "": + return p.own || r.level >= lwShown + case r.pipe != p.pipe || p.one && r.source != p.source, r.source == "" && p.reach != passed: + return false + case mode == modeDropped: + return p.lo <= r.rank && r.rank <= p.hi + case mode == modePassed: + return r.rank > p.hi + } + return r.rank >= p.reach +} + +// needsDrops reports whether the point in mode shows entries the flow +// dropped, which the tap then takes too +func (p point) needsDrops(mode int) bool { + switch mode { + case modeDropped: + return p.lo <= p.hi + case modePassed: + return p.hi+1 < passed + } + return p.reach < passed +} + +// point is where the canvas cursor looks: a source, a flow stage or the +// filter the inspector is on, the heartbeat, or the sinks +func (a *app) point() point { + p := point{name: "sinks", reach: passed, hi: passed - 1} + switch { + case a.view.own: + return point{name: "lw's own log", own: true, reach: passed, lo: passed, hi: passed - 1} + case len(a.comp.Pipelines) == 0: + return p + } + p.pipe = a.pipeline().Name + n, ok := a.node() + if l := a.lines(); a.inspect && ok && n.Role == "filters" && len(l) > 0 { + if m := l[min(a.insp.cursor, len(l)-1)].node; m.Index < len(a.pipeline().Flow.Filters) { + r := rank("filters", m.Index) + return point{pipe: p.pipe, name: fmt.Sprintf("filter %d", m.Index+1), reach: r, lo: r, hi: r} + } + } + switch { + case !ok || n.Role == "sink": + case n.Role == "source": + p.name, p.one, p.source, p.reach = "source "+n.ID, true, n.ID, 0 + case n.Role == "heartbeat": + p.name, p.one, p.lo, p.hi = "heartbeat", true, 1, 0 + default: + p.name, p.reach, p.lo, p.hi = n.Role, rank(n.Role, 0), rank(n.Role, 0), rank(n.Role, 1<<16-1) + } + return p +} + +// take adds a tapped record as a row and returns what the row holds +func (v *view) take(t core.Tapped) int { + e := t.Event.Entry + r := row{Line: logline.Read(e), pipe: t.Pipeline, source: t.Source, + stage: t.Stage, index: t.Index, rank: rank(t.Stage, t.Index), payload: t.Event.Payload} + r.at = r.Time + for _, at := range []time.Time{e.Time, t.Event.Time, time.Now()} { + if r.at.IsZero() { + r.at = at + } + } + r.level = slices.IndexFunc(core.Levels, func(l core.Level) bool { return l.Level == r.Level }) + var b strings.Builder + for _, f := range r.Fields { + if b.Len() > 0 { + b.WriteByte(' ') + } + if f.Key != "" { + b.WriteString(f.Key + "=") + } + b.WriteString(f.Value) + } + r.fields = b.String() + r.size = rowBytes + r.own() + len(r.fields) + len(r.payload) + v.tagW = max(v.tagW, ui.RuneLen(r.Tag)) + v.add(r) + return r.size +} + +// own copies the line's text, which logline cuts from the entry's, so a row +// pins no entry and its size is what it holds; it returns those bytes +func (r *row) own() int { + l := &r.Line + for _, s := range []*string{&l.Stamp, &l.Level, &l.Tag, &l.Message, &l.Layout} { + *s = strings.Clone(*s) + } + n := len(l.Stamp) + len(l.Level) + len(l.Tag) + len(l.Message) + len(l.Layout) + fields := make([]logline.Field, len(l.Fields)) + for i, f := range l.Fields { + fields[i] = logline.Field{Key: strings.Clone(f.Key), Value: strings.Clone(f.Value)} + n += fieldBytes + len(f.Key) + len(f.Value) + } + l.Fields = fields + return n +} + +// mark draws a rule across the rows, which every filter shows +func (v *view) mark(text string) { v.add(row{mark: text, level: -1, size: rowBytes}) } + +// add appends r, then evicts the oldest rows past ringBytes; the cursor +// keeps its row, or takes the oldest kept when its own went +func (v *view) add(r row) { + r.seq = v.next + v.next++ + v.rows = append(v.rows, r) + v.bytes += r.size + if v.passes(&v.rows[len(v.rows)-1]) { + v.shown = append(v.shown, r.seq) + } + for v.bytes > ringBytes && len(v.rows) > 1 { + old := v.rows[0].seq + v.bytes -= v.rows[0].size + v.rows[0] = row{} // the array keeps no evicted text + v.rows = v.rows[1:] + if len(v.shown) > 0 && v.shown[0] == old { + v.shown = v.shown[1:] + v.cursor, v.scroll = max(0, v.cursor-1), max(0, v.scroll-1) + } + } +} + +// passes reports whether the query shown was built for shows r; a rule shows +// always +func (v *view) passes(r *row) bool { + q := v.built + switch { + case r.mark != "": + return true + case !q.at.shows(r, q.mode), q.floor > 0 && r.level < q.floor-1: + return false + } + return v.pat.match(r.Message) || v.pat.match(r.fields) || v.pat.match(r.Tag) +} + +// sync rebuilds the rows shown when the point or a filter changed, and has +// the tap take drops while they show. A paused cursor stays on its row, and +// that on its screen row; so does a following one when a filter changed, which +// pauses it unless its row is still the newest. +func (v *view) sync(at point, tap *core.Tap) { + if tap != nil { + tap.All.Store(at.needsDrops(v.mode)) + } + q, was := query{at, v.mode, v.floor, v.pat.src}, v.built + if q == was { + return + } + v.built = q + f := v.focused() + line := v.cursor - v.scroll + v.shown = v.shown[:0] + for i := range v.rows { + if v.passes(&v.rows[i]) { + v.shown = append(v.shown, v.rows[i].seq) + } + } + if f != nil && (v.paused || q.pattern != was.pattern || q.floor != was.floor) { + v.cursor, _ = slices.BinarySearch(v.shown, f.seq) + v.scroll = v.cursor - line + v.paused = v.paused || v.cursor < len(v.shown)-1 + } + v.settle() +} + +// settle puts the cursor on the newest row while following, and in range +func (v *view) settle() { + if !v.paused { + v.cursor = len(v.shown) - 1 + } + v.cursor = max(0, min(v.cursor, len(v.shown)-1)) +} + +// move moves the cursor d rows: off the newest pauses, onto it follows +func (v *view) move(d int) { + v.settle() + v.cursor = max(0, min(v.cursor+d, len(v.shown)-1)) + v.paused, v.dscroll = v.cursor < len(v.shown)-1, 0 +} + +// focused is the cursor's row, nil while none shows +func (v *view) focused() *row { + if len(v.shown) == 0 { + return nil + } + v.settle() + return &v.rows[v.shown[v.cursor]-v.rows[0].seq] +} + +func (v *view) clear() { + v.rows, v.shown, v.bytes, v.cursor, v.scroll, v.paused = nil, nil, 0, 0, 0, false +} + +// drain reads taps into the view until they are empty or it read budget +// bytes; what is left waits for the next tick +func (a *app) drain(budget int, taps ...*core.Tap) { + for budget > 0 { + took := false + for _, t := range taps { + if t == nil { + continue + } + select { + case rec := <-t.Records(): + budget -= a.view.take(rec) + took = true + default: + } + } + if !took { + return + } + } +} + +// viewKey takes the keys of the live view; the composer's stay inert but for +// quitting, help, running, printing and the pipelines +func (a *app) viewKey(ev terminal.Event) { + v := &a.view + if v.prompt != nil { + a.promptKey(ev) + return + } + switch { + case ev.Type != terminal.EventKey: + case is(ev, terminal.KeyUp, "k"): + v.move(-1) + case is(ev, terminal.KeyDown, "j"): + v.move(1) + case is(ev, terminal.KeyPageUp, ""): + v.move(-ui.PageDelta(v.listH)) + case is(ev, terminal.KeyPageDown, ""): + v.move(ui.PageDelta(v.listH)) + case is(ev, terminal.KeyHome, "g"): + v.move(-len(v.shown)) + case is(ev, terminal.KeyEnd, "Gf"): + v.paused = false + case is(ev, terminal.KeyEnter, ""): + v.detail, v.dscroll = !v.detail, 0 + case is(ev, terminal.KeyNone, "J"): + v.dscroll++ + case is(ev, terminal.KeyNone, "K"): + v.dscroll = max(0, v.dscroll-1) + case is(ev, terminal.KeyNone, "/"): + v.prompt = ui.NewTextFieldState(v.pat.src) + case is(ev, terminal.KeyNone, "012345"): + v.floor = int(ev.Rune - '0') + case is(ev, terminal.KeyNone, "v"): + v.mode = (v.mode + 1) % len(verdicts) + case is(ev, terminal.KeyNone, "s"): + a.dialog = a.pointMenu() + case is(ev, terminal.KeyNone, "c"): + v.clear() + case is(ev, terminal.KeyNone, "i"): + a.inspectDrop() + case is(ev, terminal.KeyEscape, "") && v.detail: + v.detail = false + case is(ev, terminal.KeyEscape, "t"): + a.viewing = false + case is(ev, terminal.KeyCtrlC, "q?ro[]"): + a.globalKey(ev) + } +} + +// promptKey types the pattern; Enter shows the rows matching it, all when it +// is empty +func (a *app) promptKey(ev terminal.Event) { + v := &a.view + switch { + case ev.Type == terminal.EventPaste: + v.prompt.Paste(ev.Text) + case ev.Type != terminal.EventKey: + case is(ev, terminal.KeyEscape, ""): + v.prompt = nil + case is(ev, terminal.KeyEnter, ""): + p, err := newPattern(v.prompt.Value()) + if v.prompt = nil; err != nil { + a.status = "pattern: " + err.Error() + return + } + v.pat = p + default: + v.prompt.HandleKey(ev.Key, ev.Rune, ev.Modifiers) + } +} + +// inspectDrop opens the inspector on the stage that dropped the focused row +func (a *app) inspectDrop() { + w := a.view.focused() + pi := -1 + if w != nil { + pi = slices.IndexFunc(a.comp.Pipelines, func(p config.PipelineConfig) bool { return p.Name == w.pipe }) + } + switch { + case w == nil || pi < 0: + a.note = "i inspects the stage that dropped an entry" + return + case w.stage == "": + a.note = "it passed every stage" + return + } + n := compose.Node{Role: w.stage} + if w.stage == "filters" { + n.Index = w.index + } + a.pi, a.viewing = pi, false + a.choose(n) + a.inspectAt(func(l line) bool { return l.node == n }) +} + +// pointMenu picks where the view looks, moving the canvas cursor there, or +// lw's own log while it reaches the screen: s +func (a *app) pointMenu() dialog { + type at struct { + pi, col int + n compose.Node + own bool + } + var options []ui.Option + var ats []at + for pi, p := range a.comp.Pipelines { + add := func(name, hint string, col int, n compose.Node) { + if len(a.comp.Pipelines) > 1 { + name = p.Name + " · " + name + } + options = append(options, ui.Option{Name: name, Hint: hint}) + ats = append(ats, at{pi: pi, col: col, n: n}) + } + for _, s := range p.PluginSources { + add("source "+s.ID, "its entries", sourcesCol, compose.Node{Role: "source", ID: s.ID}) + } + for _, s := range stages { + hint := "what reaches it, passes it, or it drops" + if s == "heartbeat" { + hint = "its heartbeats" + } + add(s, hint, flowCol, compose.Node{Role: s}) + } + add("sinks", "what the sinks got", sinksCol, compose.Node{}) + } + if a.live.state.Log != nil { + options = append(options, ui.Option{Name: "lw's own log", Hint: "its records alone, debug too"}) + ats = append(ats, at{own: true}) + } + return &chooser{title: "Look at", list: ui.NewOptionListState(options), pick: func(a *app, i int) { + t := ats[i] + if a.view.own = t.own; t.own { + return + } + a.pi, a.col, a.row, a.inspect, a.bar = t.pi, t.col, [3]int{}, false, false + if t.n.Role != "" { + a.choose(t.n) + } + }} +} + +// drawView draws the live view: a rule naming its point and filters, the +// rows, and the focused row's detail beside them from 160 columns, else +// under them +func (a *app) drawView(r ui.Region) { + v, th := &a.view, a.th + v.settle() + title := v.built.at.name + if !v.built.at.own { + title += " · " + verdicts[v.mode] + } + if len(a.comp.Pipelines) > 1 && v.built.at.pipe != "" { + title = v.built.at.pipe + " · " + title + } + if v.floor > 0 { + title += " · " + core.Levels[v.floor-1].Level + " up" + } + if v.pat.src != "" { + title += " · /" + v.pat.src + } + hint := fmt.Sprintf("following · %d rows", len(v.shown)) + if v.paused { + hint = fmt.Sprintf("paused · %d newer", len(v.shown)-1-v.cursor) + } + r.Rule(0, a.font.flat(title, r.W), hint, th) + if a.viewing { + r.TextStyled(0, 0, string(th.Glyphs.Focus), th.Accent) + } + list := r.Sub(0, 1, r.W, r.H-1) + if !v.detail { + a.drawRows(list) + return + } + if r.W >= detailBeside { + w := r.W * 62 / 100 + a.drawRows(list.Sub(0, 0, w, list.H)) + a.drawDetail(list.Sub(w+1, 0, list.W-w-1, list.H)) + return + } + h := list.H - list.H*2/5 + a.drawRows(list.Sub(0, 0, list.W, h)) + a.drawDetail(list.Sub(0, h, list.W, list.H-h)) +} + +func (a *app) drawRows(r ui.Region) { + v := &a.view + v.listH = r.H + if len(v.shown) == 0 { + r.TextStyled(2, 0, "nothing here yet", a.th.Muted) + return + } + v.settle() + v.scroll = ui.AdjustScroll(v.cursor, ui.ClampScroll(v.scroll, r.H, len(v.shown)), r.H, len(v.shown)) + tw, lw, gw := v.widths(r.W) + first := v.rows[0].seq + for y := 0; y < r.H && v.scroll+y < len(v.shown); y++ { + i := v.scroll + y + a.drawRow(r.Sub(0, y, r.W, 1), &v.rows[v.shown[i]-first], tw, lw, gw, i == v.cursor) + } +} + +// widths are a row's time, level and tag columns w wide; each drops as w +// narrows, so none overlaps the message +func (v *view) widths(w int) (tw, lw, gw int) { + switch { + case w >= 60: + tw = 12 + case w >= 40: + tw = 8 + } + switch { + case w >= 100: + lw = 5 + case w >= 24: + lw = 1 + } + if w >= 50 { + gw = max(6, min(16, v.tagW)) + } + return tw, lw, gw +} + +// drawRow draws a row: the cursor's pointer, a mark when the flow dropped +// it, its time, level and tag, then its message and, muted, its fields. On +// the cursor row each role keeps its color on the bar, but for a reverse +// bar, which ui.Style.On draws in one style. +func (a *app) drawRow(r ui.Region, w *row, tw, lw, gw int, cursor bool) { + th, f := a.th, a.font + on := func(s ui.Style) ui.Style { return s } + if cursor { + r.FillStyle(th.Text.On(th.Selected)) + on = func(s ui.Style) ui.Style { return s.On(th.Selected) } + r.TextStyled(0, 0, string(th.Glyphs.Pointer), on(th.Accent)) + } + if w.mark != "" { + dash := "─" + if f.ascii { + dash = "-" + } + text := dash + dash + " " + w.mark + " " + r.TextStyled(2, 0, text+strings.Repeat(dash, max(0, r.W-2-ui.RuneLen(text))), on(th.Border)) + return + } + if w.rank != passed { + r.TextStyled(1, 0, string([]rune(f.invalid)[0]), on(th.Error)) + } + x := 3 // a space after the mark, so it is not read as the time's first letter + if tw > 0 { + r.TextStyled(x, 0, w.at.Local().Format("15:04:05.000")[:tw], on(th.Muted)) + x += tw + 1 + } + if lw > 0 { + style, ok := a.look.Levels[w.Level] + if !ok { + style = th.Text + } + r.TextStyled(x, 0, f.flat(w.Level, lw), on(style)) + x += lw + 1 + } + if gw > 0 { + r.TextStyled(x, 0, f.flat(w.Tag, gw), on(a.look.Source)) + x += gw + 1 + } + msg := f.flat(w.Message, r.W-x) + r.TextStyled(x, 0, msg, on(th.Text)) + if x += ui.RuneLen(msg) + 2; x < r.W { + r.TextStyled(x, 0, f.flat(w.fields, r.W-x), on(th.Muted)) + } +} + +// dline is a line of the detail: a key, muted, beside its text +type dline struct { + key, text string + style ui.Style +} + +// drawDetail draws the focused row whole, scrolled by J and K +func (a *app) drawDetail(r ui.Region) { + v, th := &a.view, a.th + w := v.focused() + hint := "J K scroll" + if w != nil && w.pipe != "" && w.stage != "" { + hint = "i inspects it " + hint + } + r.Rule(0, "detail", hint, th) + if w == nil { + return + } + lines := a.details(w, r.W-2) + v.dscroll = max(0, min(v.dscroll, len(lines)-(r.H-1))) + for y := 1; y < r.H && v.dscroll+y-1 < len(lines); y++ { + l := lines[v.dscroll+y-1] + r.TextStyled(1, y, l.key, th.Muted) + r.TextStyled(2+ui.RuneLen(l.key), y, l.text, l.style) + } +} + +// details are a row's detail lines w wide: its time, level, origin and +// verdict, the layout logline found, its message and fields, then its +// payload as the sinks receive it; what a cell cannot show is an escape +func (a *app) details(w *row, width int) []dline { + th := a.th + var out []dline + put := func(key string, keyW int, value string, style ui.Style) { + for i, s := range ui.WrapText(escape(value), max(1, width-keyW-1)) { + if i > 0 { + key = "" + } + out = append(out, dline{ui.PadRight(ui.Truncate(escape(key), keyW), keyW), s, style}) + } + } + put("time", 8, w.at.Local().Format("2006-01-02 15:04:05.000 MST"), th.Text) + if w.Stamp != "" { + put("written", 8, w.Stamp, th.Muted) + } + put("level", 8, w.Level, th.Text) + put("from", 8, a.origin(w), th.Text) + if w.pipe != "" { + put("verdict", 8, a.verdict(w), th.Text) + } + put("layout", 8, w.Layout, th.Muted) + put("message", 8, w.Message, th.Text) + keyW := 8 + for _, f := range w.Fields { + keyW = max(keyW, ui.RuneLen(f.Key)) + } + for _, f := range w.Fields { + put(f.Key, min(keyW, width/3), f.Value, th.Text) + } + if w.payload != nil { + out = append(out, dline{text: "as sinks receive it", style: th.Accent}) + put("", 0, strings.TrimSuffix(string(w.payload), "\n"), th.Text) + } + return out +} + +// origin names where a row came from: lw's own log, or a pipeline's source +// or heartbeat, and the tag the line names when it is another +func (a *app) origin(w *row) string { + switch { + case w.pipe == "": + return "lw's own log" + case w.source == "": + return "pipeline " + w.pipe + " · heartbeat" + case w.Tag != w.source: + return "pipeline " + w.pipe + " · source " + w.source + " · " + w.Tag + } + return "pipeline " + w.pipe + " · source " + w.source +} + +// verdict says what the flow did with a row: passed, or the stage, and for a +// filter the patterns it has now, that dropped it +func (a *app) verdict(w *row) string { + switch w.stage { + case "": + return "passed every stage" + case "filters": + text := fmt.Sprintf("dropped by filter %d", w.index+1) + if pi := slices.IndexFunc(a.comp.Pipelines, func(p config.PipelineConfig) bool { return p.Name == w.pipe }); pi >= 0 { + if fs := a.comp.Pipelines[pi].Flow.Filters; w.index < len(fs) { + text += ", " + string(fs[w.index].Type) + " " + strings.Join(fs[w.index].Patterns, " | ") + } + } + return text + } + return "dropped by " + w.stage +} + +// oneCell reports whether a cell shows the rune r, decoded from size bytes, +// as itself: not one a terminal acts on (logline.Control), and one taking a +// column of its own +func oneCell(r rune, size int) bool { + if r >= ' ' && r < 0x7f { + return true + } + return !logline.Control(r, size) && !unicode.In(r, unicode.Cc, unicode.Cf, unicode.Mn, unicode.Me, unicode.Mc, wideOrJoining) +} + +// flat is s on one row of at most w cells: a line break as the font's mark, +// a tab as a space, what a cell cannot show as the font's placeholder, and +// '…' where it is cut +func (f font) flat(s string, w int) string { + if w <= 0 { + return "" + } + out := make([]rune, 0, min(w, len(s))) + i := 0 + for i < len(s) && len(out) < w { + r, n := utf8.DecodeRuneInString(s[i:]) + i += n + switch { + case r == '\n': + r = f.newline + case r == '\t': + r = ' ' + case !oneCell(r, n): + r = f.odd + } + out = append(out, r) + } + if i < len(s) && w > 1 { + out[w-1] = '…' + } + return string(out) +} + +// escape writes each rune a cell cannot show as a \u escape and each invalid +// byte as a \x one; line breaks stay, for the detail to keep them +func escape(s string) string { + var b strings.Builder + for i := 0; i < len(s); { + r, n := utf8.DecodeRuneInString(s[i:]) + switch { + case r == '\n' || oneCell(r, n): + b.WriteString(s[i : i+n]) + case r == utf8.RuneError && n == 1: + fmt.Fprintf(&b, `\x%02x`, s[i]) + case r > 0xffff: + fmt.Fprintf(&b, `\U%08X`, r) + default: + fmt.Fprintf(&b, `\u%04X`, r) + } + i += n + } + return b.String() +} + +// pattern is a smart-case match: all lower case ignores case. Without +// regexp metacharacters an ASCII pattern is a substring, found without +// allocating; any other is RE2. +type pattern struct { + src, lit string + fold bool + re *regexp.Regexp +} + +func newPattern(s string) (pattern, error) { + p := pattern{src: s, lit: s, fold: s == strings.ToLower(s)} + if regexp.QuoteMeta(s) == s && !strings.ContainsFunc(s, func(r rune) bool { return r > unicode.MaxASCII }) { + return p, nil + } + expr := s + if p.fold { + expr = "(?i)" + s + } + re, err := regexp.Compile(expr) + p.re = re + return p, err +} + +func (p pattern) match(s string) bool { + switch { + case p.re != nil: + return p.re.MatchString(s) + case p.fold: + return foldContains(s, p.lit) + } + return strings.Contains(s, p.lit) +} + +// foldContains reports whether s holds the lower-case ASCII needle, in any +// case +func foldContains(s, needle string) bool { + for i := 0; i+len(needle) <= len(s); i++ { + j := 0 + for j < len(needle) && lower(s[i+j]) == needle[j] { + j++ + } + if j == len(needle) { + return true + } + } + return false +} + +func lower(c byte) byte { + if c >= 'A' && c <= 'Z' { + return c + 'a' - 'A' + } + return c +} + +// wideOrJoining is what a terminal draws in two columns, East Asian wide and +// fullwidth and emoji presentation as of Unicode 17, and the letters a +// grapheme cluster joins to a neighbour (Hangul jamo, Prepend, SpacingMark and +// Extend ones); a range spans the unassigned code points between its runes +var wideOrJoining = &unicode.RangeTable{ + R16: []unicode.Range16{ + {0x0D4E, 0x0D4E, 1}, {0x0E33, 0x0E33, 1}, {0x0EB3, 0x0EB3, 1}, + {0x1100, 0x11FF, 1}, {0x231A, 0x231B, 1}, {0x2329, 0x232A, 1}, {0x23E9, 0x23EC, 1}, + {0x23F0, 0x23F0, 1}, {0x23F3, 0x23F3, 1}, {0x25FD, 0x25FE, 1}, {0x2614, 0x2615, 1}, + {0x2630, 0x2637, 1}, {0x2648, 0x2653, 1}, {0x267F, 0x267F, 1}, {0x268A, 0x268F, 1}, + {0x2693, 0x2693, 1}, {0x26A1, 0x26A1, 1}, {0x26AA, 0x26AB, 1}, {0x26BD, 0x26BE, 1}, + {0x26C4, 0x26C5, 1}, {0x26CE, 0x26CE, 1}, {0x26D4, 0x26D4, 1}, {0x26EA, 0x26EA, 1}, + {0x26F2, 0x26F3, 1}, {0x26F5, 0x26F5, 1}, {0x26FA, 0x26FA, 1}, {0x26FD, 0x26FD, 1}, + {0x2705, 0x2705, 1}, {0x270A, 0x270B, 1}, {0x2728, 0x2728, 1}, {0x274C, 0x274C, 1}, + {0x274E, 0x274E, 1}, {0x2753, 0x2755, 1}, {0x2757, 0x2757, 1}, {0x2795, 0x2797, 1}, + {0x27B0, 0x27B0, 1}, {0x27BF, 0x27BF, 1}, {0x2B1B, 0x2B1C, 1}, {0x2B50, 0x2B50, 1}, + {0x2B55, 0x2B55, 1}, {0x2E80, 0x303E, 1}, {0x3041, 0x3247, 1}, {0x3250, 0xA4C6, 1}, + {0xA960, 0xA97C, 1}, {0xAC00, 0xD7FF, 1}, {0xF900, 0xFAFF, 1}, {0xFE10, 0xFE19, 1}, + {0xFE30, 0xFE6B, 1}, {0xFF01, 0xFF60, 1}, {0xFF9E, 0xFF9F, 1}, {0xFFE0, 0xFFE6, 1}, + }, + R32: []unicode.Range32{ + {0x111C2, 0x111C3, 1}, {0x113D1, 0x113D1, 1}, {0x1193F, 0x1193F, 1}, {0x11941, 0x11941, 1}, + {0x11A84, 0x11A89, 1}, {0x11D46, 0x11D46, 1}, {0x11F02, 0x11F02, 1}, + {0x16FE0, 0x1B2FB, 1}, {0x1D300, 0x1D376, 1}, {0x1F004, 0x1F004, 1}, {0x1F0CF, 0x1F0CF, 1}, + {0x1F18E, 0x1F18E, 1}, {0x1F191, 0x1F19A, 1}, {0x1F1E6, 0x1F320, 1}, {0x1F32D, 0x1F335, 1}, + {0x1F337, 0x1F37C, 1}, {0x1F37E, 0x1F393, 1}, {0x1F3A0, 0x1F3CA, 1}, {0x1F3CF, 0x1F3D3, 1}, + {0x1F3E0, 0x1F3F0, 1}, {0x1F3F4, 0x1F3F4, 1}, {0x1F3F8, 0x1F43E, 1}, {0x1F440, 0x1F440, 1}, + {0x1F442, 0x1F4FC, 1}, {0x1F4FF, 0x1F53D, 1}, {0x1F54B, 0x1F54E, 1}, {0x1F550, 0x1F567, 1}, + {0x1F57A, 0x1F57A, 1}, {0x1F595, 0x1F596, 1}, {0x1F5A4, 0x1F5A4, 1}, {0x1F5FB, 0x1F64F, 1}, + {0x1F680, 0x1F6C5, 1}, {0x1F6CC, 0x1F6CC, 1}, {0x1F6D0, 0x1F6D2, 1}, {0x1F6D5, 0x1F6DF, 1}, + {0x1F6EB, 0x1F6EC, 1}, {0x1F6F4, 0x1F6FC, 1}, {0x1F7E0, 0x1F7F0, 1}, {0x1F90C, 0x1F93A, 1}, + {0x1F93C, 0x1F945, 1}, {0x1F947, 0x1F9FF, 1}, {0x1FA70, 0x1FAF8, 1}, {0x20000, 0x3FFFF, 1}, + }, +} diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go new file mode 100644 index 0000000..3e5ea17 --- /dev/null +++ b/internal/tui/view_test.go @@ -0,0 +1,569 @@ +package tui + +import ( + "encoding/json" + "fmt" + "math" + "slices" + "strings" + "testing" + "time" + "unsafe" + + "github.com/lixenwraith/logwisp/internal/compose" + "github.com/lixenwraith/logwisp/internal/core" + + "github.com/lixenwraith/terminal" + ui "github.com/lixenwraith/terminal/tui" +) + +// tapped is an entry from a pipeline's source as its flow left it: dropped at +// stage and index, or passed with its payload when stage is "" +func tapped(pipe, source, stage string, index int, msg string) core.Tapped { + r := core.Tapped{Pipeline: pipe, Source: source, Stage: stage, Index: index, + Event: core.TransportEvent{Entry: core.LogEntry{Time: time.Unix(1, 0), Source: source, Message: msg}}} + if stage == "" { + r.Event.Payload = []byte(msg + "\n") + } + return r +} + +// watching is a screen on c in look whose view takes the keys, drawn once so +// its rows are built for where the canvas cursor is +func watching(c *compose.Composition, l look) *app { + a := newApp(c, l, unicodeFont) + a.viewing = true + render(a, 80, 24) + return a +} + +// shown is the messages of the rows the view shows +func shown(a *app) []string { + var out []string + for _, seq := range a.view.shown { + out = append(out, a.view.rows[seq-a.view.rows[0].seq].Message) + } + return out +} + +// cursorRow is the screen row the view's pointer is on +func cursorRow(rows []string) (int, string) { + for y, r := range rows { + if strings.HasPrefix(r, string(unicodeFont.Glyphs.Pointer)) { + return y, r + } + } + return -1, "" +} + +// The view follows the newest row; a move up pauses it on its row while rows +// arrive, and G, or moving down onto the newest, follows again +func TestTheViewFollowsUntilMovedUp(t *testing.T) { + a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone]) + add := func(from, to int) { + for i := from; i < to; i++ { + a.view.take(tapped("p", "null", "", 0, fmt.Sprintf("row %03d", i))) + } + } + at := func() string { + _, r := cursorRow(text(render(a, 80, 24), 80)) + return r + } + if add(0, 50); !strings.Contains(at(), "row 049") { + t.Fatalf("following: %q", at()) + } + press(a, "k") + if add(50, 60); !strings.Contains(at(), "row 048") || !strings.Contains(text(render(a, 80, 24), 80)[1], "paused · 11 newer") { + t.Fatalf("paused: %q\n%s", at(), text(render(a, 80, 24), 80)[1]) + } + if press(a, "G"); !strings.Contains(at(), "row 059") { + t.Fatalf("G: %q", at()) + } + press(a, "k", "j") + if add(60, 61); !strings.Contains(at(), "row 060") { + t.Fatalf("down onto the newest: %q", at()) + } +} + +// The rows hold no more than ringBytes, the oldest going first; a paused +// cursor keeps its row, or takes the oldest kept when its own went +func TestTheRowsKeepWithinTheirBytes(t *testing.T) { + a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone]) + big := strings.Repeat("x", 1<<20) + for i := range 20 { + a.view.take(tapped("p", "null", "", 0, fmt.Sprintf("%02d %s", i, big))) + } + render(a, 80, 24) + press(a, "k", "k") + focused := a.view.focused().Message[:2] + for i := 20; i < 25; i++ { + a.view.take(tapped("p", "null", "", 0, fmt.Sprintf("%02d %s", i, big))) + } + if got := a.view.focused().Message[:2]; a.view.bytes > ringBytes || got != focused { + t.Fatalf("%d bytes held; cursor on %s, was on %s", a.view.bytes, got, focused) + } + for i := 25; i < 55; i++ { + a.view.take(tapped("p", "null", "", 0, fmt.Sprintf("%02d %s", i, big))) + } + if got, oldest := a.view.focused().Message[:2], a.view.rows[0].Message[:2]; a.view.bytes > ringBytes || got != oldest || oldest == "00" { + t.Fatalf("%d bytes held; cursor on %s, the oldest kept %s", a.view.bytes, got, oldest) + } +} + +// A pattern shows only the rows matching it; setting or clearing it keeps +// the focused row on its screen row, the rows around it back on clearing, +// whether the view was paused or following +func TestAFilterChangeKeepsTheFocusedRowInPlace(t *testing.T) { + a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone]) + for i := range 300 { + msg := fmt.Sprintf("row %03d", i) + if i%3 == 2 { + msg += " needle" + } + a.view.take(tapped("p", "null", "", 0, msg)) + } + screen := func() []string { return text(render(a, 80, 24), 80) } + press(a, "g") + screen() + press(a, strings.Repeat("j", 150)) + screen() + press(a, strings.Repeat("k", 10)) + y, r := cursorRow(screen()) + if !strings.Contains(r, "row 140 needle") { + t.Fatalf("on %q", r) + } + press(a, "/", "needle", terminal.KeyEnter) + rows := screen() + if y2, r2 := cursorRow(rows); y2 != y || r2 != r || strings.Contains(strings.Join(rows, "\n"), "row 139") { + t.Fatalf("with the pattern, on row %d %q, was %d\n%s", y2, r2, y, strings.Join(rows, "\n")) + } + press(a, "/", terminal.KeyCtrlU, terminal.KeyEnter) + rows = screen() + if y2, r2 := cursorRow(rows); y2 != y || r2 != r || !strings.Contains(rows[y2-1], "row 139") || !strings.Contains(rows[y2+1], "row 141") { + t.Fatalf("cleared, on row %d %q, was %d\n%s", y2, r2, y, strings.Join(rows, "\n")) + } + following := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone]) + for i := range 40 { + msg := fmt.Sprintf("row %03d", i) + if i == 10 { + msg += " needle" + } + following.view.take(tapped("p", "null", "", 0, msg)) + } + press(following, "/", "needle", terminal.KeyEnter) + render(following, 80, 24) + press(following, "/", terminal.KeyCtrlU, terminal.KeyEnter) + if render(following, 80, 24); following.view.focused().Message != "row 010 needle" || !following.view.paused { + t.Fatalf("cleared while following, on %q", following.view.focused().Message) + } +} + +// A row keeps copies of its text, none of the entry's, and its size counts +// every string it keeps, so the ring's bound holds for any line +func TestARowCountsAllItHolds(t *testing.T) { + long := strings.Repeat("y", 4096) + rec := tapped("p", "null", "", 0, `{"msg":"`+long+`","sub":"`+long+`","k":"`+long+`","t":"2026-10-10T12:00:00Z"}`) + rec.Event.Entry.Level = long + var v view + size := v.take(rec) + r := v.rows[0] + held := rowBytes + len(r.Stamp) + len(r.Level) + len(r.Tag) + len(r.Message) + len(r.Layout) + len(r.fields) + len(r.payload) + strs := []string{r.Stamp, r.Level, r.Tag, r.Message, r.Layout} + for _, f := range r.Fields { + held += int(unsafe.Sizeof(f)) + len(f.Key) + len(f.Value) + strs = append(strs, f.Key, f.Value) + } + if size < held || v.bytes != size { + t.Fatalf("size %d, ring %d, for %d held", size, v.bytes, held) + } + for _, s := range strs { + for _, from := range []string{rec.Event.Entry.Message, rec.Event.Entry.Level} { + if p, start := uintptr(unsafe.Pointer(unsafe.StringData(s))), uintptr(unsafe.Pointer(unsafe.StringData(from))); s != "" && p >= start && p < start+uintptr(len(from)) { + t.Fatalf("%.20q is the entry's", s) + } + } + } +} + +// The canvas cursor picks the point and v the verdicts at it: a source's +// entries, what reaches a stage or a filter, passes it or it drops, the +// heartbeats, what the sinks got and what the flow dropped on the way; lw's +// own log shows from info up at every point, and whole at its own +func TestThePointAndModePickTheRows(t *testing.T) { + c := pipeline(t, []string{"null", "null"}, []string{"null"}) + for _, typ := range []string{"include", "exclude"} { + if _, err := c.Add(0, "filters", typ); err != nil { + t.Fatal(err) + } + } + if err := c.AddPipeline("q"); err != nil { + t.Fatal(err) + } + s1, s2 := c.Pipelines[0].PluginSources[0].ID, c.Pipelines[0].PluginSources[1].ID + a := watching(c, looks[terminal.ColorModeNone]) + lw := func(level, msg string) core.Tapped { + r := tapped("", "lw", "", 0, msg) + r.Event.Entry.Level = level + return r + } + for _, r := range []core.Tapped{ + tapped("p", s1, "", 0, "a"), tapped("p", s2, "", 0, "b"), tapped("p", s1, "rate_limit", 0, "c"), + tapped("p", s1, "filters", 0, "d"), tapped("p", s2, "filters", 1, "e"), tapped("p", s1, "format", 0, "f"), + tapped("p", "", "", 0, "g"), tapped("q", s1, "", 0, "h"), lw("INFO", "i"), lw("DEBUG", "j"), + } { + a.view.take(r) + } + for _, c := range []struct { + pi int + at compose.Node + mode int + want string + own bool + }{ + {0, compose.Node{Role: "sink", ID: "null"}, modeAll, "abgi", false}, + {0, compose.Node{Role: "sink", ID: "null"}, modePassed, "abgi", false}, + {0, compose.Node{Role: "sink", ID: "null"}, modeDropped, "cdefi", false}, + {0, compose.Node{Role: "source", ID: s1}, modeAll, "acdfi", false}, + {0, compose.Node{Role: "source", ID: s1}, modePassed, "ai", false}, + {0, compose.Node{Role: "source", ID: s1}, modeDropped, "cdfi", false}, + {0, compose.Node{Role: "rate_limit"}, modeAll, "abcdefi", false}, + {0, compose.Node{Role: "rate_limit"}, modePassed, "abdefi", false}, + {0, compose.Node{Role: "rate_limit"}, modeDropped, "ci", false}, + {0, compose.Node{Role: "filters"}, modeAll, "abdefi", false}, + {0, compose.Node{Role: "filters"}, modePassed, "abfi", false}, + {0, compose.Node{Role: "filters"}, modeDropped, "dei", false}, + {0, compose.Node{Role: "filters", Index: 1}, modeAll, "abefi", false}, + {0, compose.Node{Role: "filters", Index: 1}, modeDropped, "ei", false}, + {0, compose.Node{Role: "format"}, modeDropped, "fi", false}, + {0, compose.Node{Role: "heartbeat"}, modeAll, "gi", false}, + {0, compose.Node{Role: "heartbeat"}, modeDropped, "i", false}, + {1, compose.Node{}, modeAll, "hi", false}, + {0, compose.Node{Role: "filters"}, modeDropped, "ij", true}, + } { + a.pi, a.inspect, a.view.mode, a.col, a.view.own = c.pi, false, c.mode, sinksCol, c.own + a.choose(c.at) + if c.at.Role == "filters" && c.at.Index > 0 { + a.inspectAt(func(l line) bool { return l.node == c.at }) + } + render(a, 80, 24) + if got := strings.Join(shown(a), ""); got != c.want { + t.Errorf("at %+v in pipeline %d, %s: %q, want %q", c.at, c.pi, verdicts[c.mode], got, c.want) + } + } +} + +// The tap takes the entries the flow dropped only while the view's point and +// mode show some +func TestTheTapTakesDropsOnlyWhileShown(t *testing.T) { + a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone]) + a.live.tap = core.NewTap(1) + for _, c := range []struct { + at compose.Node + mode int + all bool + }{ + {compose.Node{Role: "sink", ID: "null"}, modeAll, false}, + {compose.Node{Role: "sink", ID: "null"}, modeDropped, true}, + {compose.Node{Role: "source", ID: "null"}, modePassed, false}, + {compose.Node{Role: "source", ID: "null"}, modeAll, true}, + {compose.Node{Role: "filters"}, modePassed, true}, + } { + a.view.mode = c.mode + a.choose(c.at) + if render(a, 80, 24); a.live.tap.All.Load() != c.all { + t.Errorf("at %+v, %s: the tap takes drops %v", c.at, verdicts[c.mode], !c.all) + } + } +} + +// A digit is a level floor: rows under it hide, and those without a level; +// 0 shows them all +func TestTheLevelFloorHidesRowsUnderIt(t *testing.T) { + a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone]) + for _, l := range []string{"DEBUG", "INFO", "WARN", "ERROR", ""} { + r := tapped("p", "null", "", 0, "at "+l) + r.Event.Entry.Level = l + a.view.take(r) + } + for _, c := range []struct{ key, want string }{{"4", "at WARN|at ERROR"}, {"2", "at DEBUG|at INFO|at WARN|at ERROR"}, {"0", "at DEBUG|at INFO|at WARN|at ERROR|at "}} { + press(a, c.key) + if render(a, 80, 24); strings.Join(shown(a), "|") != c.want { + t.Errorf("%s: %q", c.key, shown(a)) + } + } +} + +// A pattern in lower case ignores case, one with an upper-case letter keeps +// it, in substrings and regular expressions alike +func TestPatternsAreSmartCase(t *testing.T) { + for _, c := range []struct { + pattern, text string + match bool + }{ + {"disk", "DISK full", true}, {"Disk", "disk full", false}, {"Disk", "Disk full", true}, + {"d.sk", "DISK full", true}, {"D.sk", "disk full", false}, {"é", "CAFÉ", true}, {"x", "DISK", false}, + } { + if p, err := newPattern(c.pattern); err != nil || p.match(c.text) != c.match { + t.Errorf("%q in %q: %v, %v", c.pattern, c.text, !c.match, err) + } + } + if _, err := newPattern("("); err == nil { + t.Error("( compiled") + } +} + +// From 40 rows the view sits under the diagram, whichever takes the keys; +// shorter, it takes the screen while it takes the keys, t going back +func TestTheViewSitsUnderTheDiagramWhenTall(t *testing.T) { + a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone]) + a.view.take(tapped("p", "null", "", 0, "hello")) + layout := func(h int) string { + screen := strings.Join(text(render(a, 80, h), 80), "\n") + diagram, view := strings.Index(screen, "● SOURCES"), strings.Index(screen, "hello") + switch { + case diagram >= 0 && view > diagram: + return "split" + case view >= 0 && diagram < 0: + return "view" + case diagram >= 0 && view < 0: + return "diagram" + } + return screen + } + if got := layout(40); got != "split" { + t.Errorf("40 rows: %s", got) + } + if got := layout(39); got != "view" { + t.Errorf("39 rows: %s", got) + } + press(a, "t") + if got := layout(39); got != "diagram" { + t.Errorf("39 rows, back: %s", got) + } + if got := layout(40); got != "split" { + t.Errorf("40 rows, back: %s", got) + } +} + +// Enter shows the focused row's detail beside the rows from 160 columns, +// under them below that +func TestTheDetailSitsBesideFrom160(t *testing.T) { + a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone]) + a.view.take(tapped("p", "null", "", 0, "hello")) + press(a, terminal.KeyEnter) + for w, beside := range map[int]bool{160: true, 159: false} { + rows := text(render(a, w, 30), w) + y := slices.IndexFunc(rows, func(r string) bool { return strings.Contains(r, "─ detail ─") }) + if got := y == 2 && strings.Contains(rows[2], "hello"); got != beside { + t.Errorf("%d columns: beside %v, detail on row %d\n%s", w, got, y, strings.Join(rows, "\n")) + } + } +} + +// The detail shows the entry whole: its verdict, the layout logline found, +// its fields, and the payload as the sinks receive it +func TestTheDetailShowsTheEntryWhole(t *testing.T) { + c := pipeline(t, []string{"null"}, []string{"null"}) + if _, err := c.Add(0, "filters", "exclude"); err != nil { + t.Fatal(err) + } + if err := c.Set(0, compose.Node{Role: "filters"}, "patterns", "slow"); err != nil { + t.Fatal(err) + } + a := watching(c, looks[terminal.ColorModeNone]) + line := `{"time":"2026-10-10T12:00:00Z","level":"WARN","sub":"fsm","fields":{"msg":"transition","state":"idle"}}` + a.view.take(tapped("p", "null", "", 0, line)) + press(a, terminal.KeyEnter) + screen := strings.Join(text(render(a, 170, 30), 170), "\n") + for _, want := range []string{"verdict passed every stage", "layout json, message in fields.msg", "state idle", "as sinks receive it", line[:50]} { + if !strings.Contains(screen, want) { + t.Errorf("no %q\n%s", want, screen) + } + } + a.view.take(tapped("p", "null", "filters", 0, "slow tick")) + if screen := strings.Join(text(render(a, 170, 30), 170), "\n"); !strings.Contains(screen, "dropped by filter 1, exclude slow") || strings.Contains(screen, "as sinks receive it") { + t.Errorf("a dropped row\n%s", screen) + } +} + +// A row draws one cell per rune: a wide, combining or control rune is the +// font's placeholder and a line break its mark, which the detail writes as +// escapes and a break +func TestRowsDrawOneCellPerRune(t *testing.T) { + msg := "a\u4f60\u0301\x1b[2Jb\tc\nd" + for _, f := range []font{unicodeFont, cp437Font, asciiFont} { + a := newApp(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone], f) + a.viewing = true + render(a, 80, 24) + a.view.take(tapped("p", "null", "", 0, msg)) + odd, br := string(f.fold(f.odd)), string(f.fold(f.newline)) + want := "a" + odd + odd + odd + "[2Jb c" + br + "d" + if row := text(render(a, 80, 24), 80)[2]; !strings.Contains(row, want) { + t.Errorf("%c: %q, want %q", f.bar, row, want) + } + } + a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone]) + a.view.take(tapped("p", "null", "", 0, msg)) + press(a, terminal.KeyEnter) + if screen := strings.Join(text(render(a, 80, 30), 80), "\n"); !strings.Contains(screen, `message a\u4F60\u0301\u001B[2Jb\u0009c`+strings.Repeat(" ", 10)) { + t.Errorf("the detail\n%s", screen) + } +} + +// A narrow view drops the time, then the level's name, the tag and the +// level, and never draws one over the message +func TestANarrowViewDropsColumns(t *testing.T) { + a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone]) + r := tapped("p", "null", "", 0, "hello world") + r.Event.Entry.Level, r.Event.Entry.Source = "WARN", "app.log" + a.view.take(r) + stamp := time.Unix(1, 0).Format("15:04:05") + for _, c := range []struct { + w int + want string + }{{100, stamp + ".000 WARN app.log hello world"}, {60, stamp + ".000 W app.log hello world"}, + {45, stamp + " W hello world"}, {30, "▸ W hello world"}, {20, "▸ hello world"}} { + if row := text(render(a, c.w, 24), c.w)[2]; !strings.Contains(row, c.want) { + t.Errorf("%d columns: %q, want %q", c.w, row, c.want) + } + } +} + +// A row's level shows in its level's color; a level lw does not name, in the +// text's +func TestALevelShowsInItsColor(t *testing.T) { + a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeTrueColor]) + for _, level := range []string{"WARN", "AUDIT"} { + r := tapped("p", "null", "", 0, "hello") + r.Event.Entry.Level = level + a.view.take(r) + } + cells := render(a, 100, 24) + for y, c := range []struct { + level string + want ui.Style + }{{"WARN", a.look.Levels["WARN"]}, {"AUDIT", a.th.Text}} { + row := []rune(text(cells, 100)[2+y]) + x := slices.Index(row, rune(c.level[0])) + if got := cells[(2+y)*100+x]; x < 0 || got.Fg != c.want.Fg { + t.Errorf("%s at %d is %+v, want %+v", c.level, x, got, c.want) + } + } +} + +// The cursor row is one bar: without color every cell takes one style, so a +// console draws no split bar; in color each keeps its role on the bar's +// background +func TestTheViewsCursorRowIsOneBar(t *testing.T) { + for _, mode := range []terminal.ColorMode{terminal.ColorModeNone, terminal.ColorMode16, terminal.ColorModeTrueColor} { + a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[mode]) + r := tapped("p", "null", "filters", 0, "hello world") + r.Event.Entry.Level, r.Event.Entry.Fields = "ERROR", json.RawMessage(`{"k":1}`) + a.view.take(r) + a.view.mode = modeDropped + a.choose(compose.Node{Role: "sink", ID: "null"}) + cells := render(a, 100, 24) + row, bar := cells[2*100:3*100], a.th.Text.On(a.th.Selected) + for x, c := range row { + one := c.Fg == bar.Fg && c.Bg == bar.Bg && c.Attrs == bar.Attr + if mode == terminal.ColorModeNone && !one || c.Bg != bar.Bg || c.Attrs&terminal.AttrBgColor != bar.Attr&terminal.AttrBgColor { + t.Fatalf("mode %v: %q at %d is %+v, the bar %+v", mode, c.Rune, x, c, bar) + } + } + } +} + +// A tick reads the taps up to drainBytes, the rest waiting for the next +func TestADrainKeepsToItsBudget(t *testing.T) { + a := mono(pipeline(t, []string{"null"}, []string{"null"})) + a.live.tap = core.NewTap(100) + for range 100 { + a.live.tap.Offer(tapped("p", "null", "", 0, strings.Repeat("x", 64<<10))) + } + press(a, terminal.KeyNone) + size := a.view.rows[0].size + if n, want := len(a.view.rows), int(math.Ceil(float64(drainBytes)/float64(size))); n != want || len(a.live.tap.Records()) != 100-n { + t.Fatalf("read %d rows of %d bytes, want %d; %d left", n, size, want, len(a.live.tap.Records())) + } +} + +// While a tap holds records the watch wakes the screen; an empty one does +// not +func TestATapHoldingRecordsWakesTheScreen(t *testing.T) { + posts := make(chan struct{}, 8) + r := newRun(&fakeRunner{}, func() { posts <- struct{}{} }) + tap := core.NewTap(1) + r.fed.Store(tap) + go r.watch(time.Hour) + defer r.endAsks() + select { + case <-posts: + t.Fatal("an empty tap woke the screen") + case <-time.After(3 * drainEvery): + } + tap.Offer(tapped("p", "null", "", 0, "x")) + select { + case <-posts: + case <-time.After(time.Second): + t.Fatal("a tap holding a record did not wake the screen") + } +} + +// A rerun reads the earlier run's tap whole, then a rule marks where the new +// run's rows begin +func TestARerunMarksTheRows(t *testing.T) { + f := &fakeRunner{} + a := running(t, pipeline(t, []string{"null"}, []string{"null"}), f) + a.live.tap = core.NewTap(4) + a.live.tap.Offer(tapped("p", "null", "", 0, "before")) + next := core.NewTap(4) + next.Offer(tapped("p", "null", "", 0, "after")) + f.start = func(Ask) (*core.Tap, error) { return next, nil } + a.edit(a.comp.Set(0, compose.Node{Role: "format"}, "type", "txt")) + press(a, "t") + await(t, a) + var got []string + for _, r := range a.view.rows { + got = append(got, r.Message+strings.SplitN(r.mark, " ", 2)[0]) + } + if strings.Join(got, ",") != "before,rerun,after" { + t.Fatalf("%q", got) + } +} + +// i inspects the filter that dropped the focused row, which the view then +// looks at +func TestIInspectsTheFilterThatDropped(t *testing.T) { + c := pipeline(t, []string{"null"}, []string{"null"}) + for _, typ := range []string{"include", "exclude"} { + if _, err := c.Add(0, "filters", typ); err != nil { + t.Fatal(err) + } + } + a := watching(c, looks[terminal.ColorModeNone]) + a.view.take(tapped("p", "null", "filters", 1, "dropped")) + press(a, "i") + if l := a.lines()[a.insp.cursor]; a.viewing || !a.inspect || l.node != (compose.Node{Role: "filters", Index: 1}) || a.point().name != "filter 2" { + t.Fatalf("viewing %v, inspecting %v on %+v, at %s", a.viewing, a.inspect, l.node, a.point().name) + } +} + +// s picks the point from a list, moving the canvas cursor there, or lw's own +// log while it reaches the screen, until s picks another or a key on the +// diagram moves the cursor +func TestTheChooserPicksThePoint(t *testing.T) { + a := watching(pipeline(t, []string{"null"}, []string{"null"}), looks[terminal.ColorModeNone]) + press(a, "s", "format", terminal.KeyEnter) + if n, _ := a.node(); n.Role != "format" || a.point().name != "format" || !a.viewing { + t.Fatalf("on %+v, at %s, viewing %v", n, a.point().name, a.viewing) + } + a.live.state.Log = core.NewTap(1) + for _, leave := range [][]any{{"s", "format", terminal.KeyEnter}, {"t", "j"}} { + if press(a, "s", "own log", terminal.KeyEnter); !a.point().own { + t.Fatalf("at %s", a.point().name) + } + if press(a, leave...); a.point().own { + t.Fatalf("%v left the point on lw's own log", leave) + } + } +}