v0.33.0 tui tap for live view and console enhancements

This commit is contained in:
lixen committed 2026-10-10 21:32:08 -04:00
1 parent a1e9d21d06
commit 87f5576ccf
41 files changed
+4599 -442

No files matched your search

+5 -1
View File
@@ -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
+10 -16
View File
@@ -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
+10 -11
View File
@@ -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)
+2 -2
View File
@@ -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)
}
+216
View File
@@ -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
}
+309
View File
@@ -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
}
+2 -2
View File
@@ -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
+17 -5
View File
@@ -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
+61 -9
View File
@@ -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`
+20 -1
View File
@@ -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
+4 -1
View File
@@ -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.
+2 -1
View File
@@ -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.
---
+26 -12
View File
@@ -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`
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.
+1 -1
View File
@@ -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
+2 -4
View File
@@ -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=
+17
View File
@@ -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
+38
View File
@@ -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) {
+6
View File
@@ -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
+74 -9
View File
@@ -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
+44 -1
View File
@@ -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())
}
}
+6 -17
View File
@@ -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 {
+15 -37
View File
@@ -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) {
if f.rateLimiter != nil && !f.rateLimiter.Allow(entry) {
f.totalDropped.Add(1)
return core.TransportEvent{}, false
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
// The structured entry rides along so chain sinks are format-independent
return core.TransportEvent{Time: entry.Time, Payload: formatted, Entry: entry}, "", 0
}
return event, true
}
// 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(),
+408
View File
@@ -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
// <hex>, 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
}
+138
View File
@@ -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 <c29b>31m\n",
"bidi \u202eevil\n": "bidi <e280ae>evil\n",
"isolate \u2066x\u2069\n": "isolate <e281a6>x<e281a9>\n",
"line\u2028sep\n": "line<e280a8>sep\n",
"zwj \U0001f468\u200d\U0001f469\n": "zwj \U0001f468\u200d\U0001f469\n",
"bad \xff utf8": "bad <ff> 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)
}
}
}
+226
View File
@@ -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
}
+51 -62
View File
@@ -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())
+136 -3
View File
@@ -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)
}
}
+13 -15
View File
@@ -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()
+11 -57
View File
@@ -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
// <hex>, 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
}
-22
View File
@@ -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 <c29b>31m\n",
"bidi \u202eevil\n": "bidi <e280ae>evil\n",
"isolate \u2066x\u2069\n": "isolate <e281a6>x<e281a9>\n",
"line\u2028sep\n": "line<e280a8>sep\n",
"zwj \U0001f468\u200d\U0001f469\n": "zwj \U0001f468\u200d\U0001f469\n",
"bad \xff utf8": "bad <ff> 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) {
+1
View File
@@ -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
}
}
+188 -69
View File
@@ -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
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}
l := layout{w: w}
beside := w >= besideMin
side, box := sideMin, boxMin
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]})
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)
}
if len(l.nodes[col]) == 0 {
l.nodes[col] = []spot{{h: 2}}
}
}
l.entry = l.box.y + 1 + (stages-1)/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
+86 -11
View File
@@ -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
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 &notice{title: "Keys", body: func(w int) []string {
var lines []string
for _, k := range keys {
+2 -1
View File
@@ -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)
}
}
+316
View File
@@ -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
}
+363
View File
@@ -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)
}
}
}
+52 -23
View File
@@ -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}
)
+107 -34
View File
@@ -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()
for !a.done {
cells := make([]terminal.Cell, w*h)
for !a.done {
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
+186 -15
View File
@@ -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
+859
View File
@@ -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},
},
}
+569
View File
@@ -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)
}
}
}