v0.33.0 tui tap for live view and console enhancements
This commit is contained in:
1 parent
a1e9d21d06
commit
87f5576ccf
41 files changed
+4599
-442
No files matched your search
@@ -25,6 +25,9 @@ streams, or downstream LogWisp nodes.
|
|||||||
- **Presets**: `tail`, `serve`, `edge` and `aggregator` build the common
|
- **Presets**: `tail`, `serve`, `edge` and `aggregator` build the common
|
||||||
pipelines from a few keys (`lw --preset tail:path=/var/log/app`);
|
pipelines from a few keys (`lw --preset tail:path=/var/log/app`);
|
||||||
`lw --dump` turns any command line into a configuration file
|
`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
|
- **Independent pipelines**, each `sources → flow → sinks`, running concurrently
|
||||||
in one process
|
in one process
|
||||||
- **Fan-in and fan-out**: many sources and many sinks per pipeline
|
- **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
|
- Design notes: [mTLS](doc/mtls-auth-plan.md) and
|
||||||
[password](doc/scram-auth-plan.md) authentication
|
[password](doc/scram-auth-plan.md) authentication
|
||||||
- [CLI](doc/cli.md): filter use, flags, pipeline specifications, signals,
|
- [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,
|
- [Operations](doc/operations.md): running, monitoring, tuning,
|
||||||
troubleshooting
|
troubleshooting
|
||||||
- [To Do](doc/todo.md): planned work: packaging, `lw --tui` and its shared configuration engine
|
- [To Do](doc/todo.md): planned work: packaging, `lw --tui` and its shared configuration engine
|
||||||
|
|||||||
+10
-16
@@ -24,6 +24,7 @@ import (
|
|||||||
|
|
||||||
"github.com/lixenwraith/logwisp/internal/authz"
|
"github.com/lixenwraith/logwisp/internal/authz"
|
||||||
"github.com/lixenwraith/logwisp/internal/config"
|
"github.com/lixenwraith/logwisp/internal/config"
|
||||||
|
"github.com/lixenwraith/logwisp/internal/core"
|
||||||
"github.com/lixenwraith/logwisp/internal/service"
|
"github.com/lixenwraith/logwisp/internal/service"
|
||||||
"github.com/lixenwraith/logwisp/internal/version"
|
"github.com/lixenwraith/logwisp/internal/version"
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ import (
|
|||||||
|
|
||||||
// bootstrapInitial handles initial service startup with status reporter
|
// bootstrapInitial handles initial service startup with status reporter
|
||||||
func bootstrapInitial(ctx context.Context, cfg *config.Config) (*service.Service, context.CancelFunc, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("failed to bootstrap service: %w", err)
|
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.
|
// no pipeline runs until a later reload succeeds.
|
||||||
var errServiceStopped = errors.New("old service stopped, new one failed to start")
|
var errServiceStopped = errors.New("old service stopped, new one failed to start")
|
||||||
|
|
||||||
// handleReload orchestrates the entire hot-reload process including status reporter lifecycle
|
// handleReload orchestrates the entire hot-reload process including status
|
||||||
func handleReload(ctx context.Context, newCfg *config.Config, oldSvc *service.Service, statusCancel context.CancelFunc) (*service.Service, *config.Config, context.CancelFunc, error) {
|
// 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")
|
logger.Info("msg", "Starting configuration hot reload")
|
||||||
|
|
||||||
if err := config.ValidateConfig(newCfg); err != nil {
|
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
|
// Bootstrap a new service to ensure it's valid before touching the old one
|
||||||
logger.Debug("msg", "Bootstrapping new service with updated config")
|
logger.Debug("msg", "Bootstrapping new service with updated config")
|
||||||
newService, err := bootstrapService(ctx, newCfg)
|
newService, err := bootstrapService(ctx, newCfg, tap)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("msg", "Failed to bootstrap new service, keeping old service running", "error", err)
|
logger.Error("msg", "Failed to bootstrap new service, keeping old service running", "error", err)
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gracefully shut down the old service
|
|
||||||
if statusCancel != nil {
|
if statusCancel != nil {
|
||||||
statusCancel()
|
statusCancel()
|
||||||
}
|
}
|
||||||
@@ -83,14 +84,12 @@ func handleReload(ctx context.Context, newCfg *config.Config, oldSvc *service.Se
|
|||||||
oldSvc.Shutdown()
|
oldSvc.Shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the new service
|
|
||||||
if err := newService.Start(); err != nil {
|
if err := newService.Start(); err != nil {
|
||||||
newService.Shutdown()
|
newService.Shutdown()
|
||||||
logger.Error("msg", "Failed to start new service pipelines after reload. The application may be in a non-functional state.", "error", err)
|
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)
|
return nil, nil, nil, fmt.Errorf("%w: %w", errServiceStopped, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manage status reporter lifecycle
|
|
||||||
var newStatusCancel context.CancelFunc
|
var newStatusCancel context.CancelFunc
|
||||||
if newCfg.StatusReporter {
|
if newCfg.StatusReporter {
|
||||||
newStatusCancel = startStatusReporter(ctx, newService)
|
newStatusCancel = startStatusReporter(ctx, newService)
|
||||||
@@ -100,9 +99,9 @@ func handleReload(ctx context.Context, newCfg *config.Config, oldSvc *service.Se
|
|||||||
return newService, newCfg, newStatusCancel, nil
|
return newService, newCfg, newStatusCancel, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// bootstrapService creates and initializes the main log transport service and its pipelines
|
// bootstrapService builds the service and its pipelines, which offer what
|
||||||
func bootstrapService(ctx context.Context, cfg *config.Config) (*service.Service, error) {
|
// their flows decide to tap from their first entry; nil for none
|
||||||
// Create service with logger dependency injection
|
func bootstrapService(ctx context.Context, cfg *config.Config, tap *core.Tap) (*service.Service, error) {
|
||||||
svc, err := service.NewService(ctx, cfg, logger)
|
svc, err := service.NewService(ctx, cfg, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("msg", "Failed to initialize service",
|
logger.Error("msg", "Failed to initialize service",
|
||||||
@@ -110,6 +109,7 @@ func bootstrapService(ctx context.Context, cfg *config.Config) (*service.Service
|
|||||||
)
|
)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
svc.SetTap(tap)
|
||||||
|
|
||||||
logger.Info("msg", "LogWisp started",
|
logger.Info("msg", "LogWisp started",
|
||||||
"version", version.Short(),
|
"version", version.Short(),
|
||||||
@@ -149,27 +149,23 @@ func dumpConfig(cfg *config.Config, w io.Writer) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// initializeLogger sets up the global logger based on the application's configuration
|
|
||||||
func initializeLogger(cfg *config.Config) error {
|
func initializeLogger(cfg *config.Config) error {
|
||||||
logger = log.NewLogger()
|
logger = log.NewLogger()
|
||||||
logCfg := log.DefaultConfig()
|
logCfg := log.DefaultConfig()
|
||||||
|
|
||||||
if cfg.Quiet {
|
if cfg.Quiet {
|
||||||
// In quiet mode, disable ALL logging output
|
|
||||||
logCfg.Level = 255 // A level that disables all output
|
logCfg.Level = 255 // A level that disables all output
|
||||||
logCfg.EnableFile = false
|
logCfg.EnableFile = false
|
||||||
logCfg.EnableConsole = false
|
logCfg.EnableConsole = false
|
||||||
return logger.ApplyConfig(logCfg)
|
return logger.ApplyConfig(logCfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine log level
|
|
||||||
levelValue, err := log.Level(cfg.Logging.Level)
|
levelValue, err := log.Level(cfg.Logging.Level)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid log level: %w", err)
|
return fmt.Errorf("invalid log level: %w", err)
|
||||||
}
|
}
|
||||||
logCfg.Level = levelValue
|
logCfg.Level = levelValue
|
||||||
|
|
||||||
// Configure log format
|
|
||||||
if cfg.Logging.Format != "" {
|
if cfg.Logging.Format != "" {
|
||||||
logCfg.Format = 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)
|
logCfg.Sanitization = sanitizer.PolicyPreset(cfg.Logging.Sanitization)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure based on output mode
|
|
||||||
switch cfg.Logging.Output {
|
switch cfg.Logging.Output {
|
||||||
case "none":
|
case "none":
|
||||||
logCfg.EnableFile = false
|
logCfg.EnableFile = false
|
||||||
@@ -210,7 +205,6 @@ func initializeLogger(cfg *config.Config) error {
|
|||||||
return logger.ApplyConfig(logCfg)
|
return logger.ApplyConfig(logCfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// configureFileLogging sets up file-based logging parameters from the configuration
|
|
||||||
func configureFileLogging(logCfg *log.Config, cfg *config.Config) {
|
func configureFileLogging(logCfg *log.Config, cfg *config.Config) {
|
||||||
if cfg.Logging.File != nil {
|
if cfg.Logging.File != nil {
|
||||||
logCfg.Directory = cfg.Logging.File.Directory
|
logCfg.Directory = cfg.Logging.File.Directory
|
||||||
|
|||||||
+10
-11
@@ -53,7 +53,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 1. Initial setup ---
|
|
||||||
// Emulates nohup
|
// Emulates nohup
|
||||||
signal.Ignore(syscall.SIGHUP)
|
signal.Ignore(syscall.SIGHUP)
|
||||||
|
|
||||||
@@ -100,7 +99,6 @@ func main() {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// --- 2. Bootstrap initial service ---
|
|
||||||
svc, statusReporterCancel, err := bootstrapInitial(ctx, cfg)
|
svc, statusReporterCancel, err := bootstrapInitial(ctx, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("msg", "Failed to initialize service", "error", err)
|
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)")
|
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)
|
sigChan := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGUSR1)
|
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) {
|
if !fromDisk && cfg.StatusReporter == next.StatusReporter && reflect.DeepEqual(cfg.Pipelines, next.Pipelines) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
newSvc, newCfg, newStatusCancel, err := handleReload(ctx, next, svc, statusReporterCancel)
|
newSvc, newCfg, newStatusCancel, err := handleReload(ctx, next, svc, statusReporterCancel, nil)
|
||||||
switch {
|
switch {
|
||||||
case err == nil:
|
case err == nil:
|
||||||
svc, cfg, statusReporterCancel = newSvc, newCfg, newStatusCancel
|
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")
|
logger.Info("msg", "Application started, waiting for signals or config changes")
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -176,13 +172,13 @@ func main() {
|
|||||||
reload(true)
|
reload(true)
|
||||||
} else {
|
} else {
|
||||||
logger.Info("msg", "Shutdown signal received", "signal", sig)
|
logger.Info("msg", "Shutdown signal received", "signal", sig)
|
||||||
cancel() // Trigger service shutdown via context
|
cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
case event, ok := <-configChanges:
|
case event, ok := <-configChanges:
|
||||||
if !ok {
|
if !ok {
|
||||||
logger.Warn("msg", "Configuration watch channel closed, disabling auto-reload")
|
logger.Warn("msg", "Configuration watch channel closed, disabling auto-reload")
|
||||||
configChanges = nil // Stop selecting on this channel
|
configChanges = nil
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if collectConfigChanges(event, configChanges) {
|
if collectConfigChanges(event, configChanges) {
|
||||||
@@ -194,7 +190,7 @@ func main() {
|
|||||||
return
|
return
|
||||||
|
|
||||||
case <-ctx.Done():
|
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
|
// 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,
|
// the preset menu in place of the built-in pipe, may run them on screen, then
|
||||||
// or leaves inv to run the pipelines composed in place of the loaded ones
|
// 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) {
|
func composeOnScreen(inv *invocation) (code int, exit bool) {
|
||||||
fail := func(err error) (int, bool) {
|
fail := func(err error) (int, bool) {
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
@@ -257,7 +254,9 @@ func composeOnScreen(inv *invocation) (code int, exit bool) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fail(err)
|
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 {
|
switch {
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return fail(err)
|
return fail(err)
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func TestShippedConfigurationBuildsWithCheckedPluginDecoder(t *testing.T) {
|
|||||||
if len(cfg.Pipelines) != 1 || cfg.Pipelines[0].Name != "default" {
|
if len(cfg.Pipelines) != 1 || cfg.Pipelines[0].Name != "default" {
|
||||||
t.Fatalf("sample array-of-tables decoded incorrectly: %+v", cfg.Pipelines)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,7 @@ func TestInvalidReloadLeavesStatusReporterRunning(t *testing.T) {
|
|||||||
cfg.Pipelines[0].PluginSinks[0].Config["buffer_size"] = ^uint64(0)
|
cfg.Pipelines[0].PluginSinks[0].Config["buffer_size"] = ^uint64(0)
|
||||||
}
|
}
|
||||||
cancelled := false
|
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 {
|
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)
|
t.Fatalf("invalid reload affected running state: svc=%v cfg=%v cancelled=%v error=%v", svc, next, cancelled, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -27,8 +27,8 @@ streams, or downstream LogWisp nodes.
|
|||||||
certificate-based authorization
|
certificate-based authorization
|
||||||
- [Password Authentication](scram-auth-plan.md): design and rationale for
|
- [Password Authentication](scram-auth-plan.md): design and rationale for
|
||||||
Argon2id-SCRAM authentication; mTLS hardening
|
Argon2id-SCRAM authentication; mTLS hardening
|
||||||
- [CLI](cli.md): flags, signals, exit codes, `lw auth`; also the `lw(1)`
|
- [CLI](cli.md): flags, signals, exit codes, `lw --tui`, `lw auth`; also
|
||||||
manual, [`lw.1`](lw.1)
|
the `lw(1)` manual, [`lw.1`](lw.1)
|
||||||
- [Operations](operations.md): running, monitoring, tuning, troubleshooting
|
- [Operations](operations.md): running, monitoring, tuning, troubleshooting
|
||||||
- [To Do](todo.md): planned work: packaging, `lw --tui` and its shared configuration engine
|
- [To Do](todo.md): planned work: packaging, `lw --tui` and its shared configuration engine
|
||||||
|
|
||||||
|
|||||||
+17
-5
@@ -48,7 +48,11 @@ Package map:
|
|||||||
- `internal/core`: shared types (`LogEntry`, `TransportEvent`), capabilities,
|
- `internal/core`: shared types (`LogEntry`, `TransportEvent`), capabilities,
|
||||||
constants.
|
constants.
|
||||||
- `internal/tokenbucket`: rate limiter primitive.
|
- `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
|
## 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,
|
the source's `dropped_entries` counter. The console source alone waits,
|
||||||
since stdin can simply be read later.
|
since stdin can simply be read later.
|
||||||
2. **Flow** applies, in order: rate limit → filter chain → formatter. A drop at
|
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
|
3. The formatter output becomes a `core.TransportEvent`, which carries both the
|
||||||
formatted `Payload` and the original structured `Entry`.
|
formatted `Payload` and the original structured `Entry`.
|
||||||
4. **Dispatch** sends the event to every sink's input channel with a
|
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`.
|
`flow.total_dropped`.
|
||||||
- Pipeline → sink: a full sink input drops it for that sink only; pipeline
|
- Pipeline → sink: a full sink input drops it for that sink only; pipeline
|
||||||
`total_dropped_by_sink`. A sink declaring `core.CapBackpressure`, the console
|
`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
|
and null sinks, makes dispatch wait instead, so a filter loses no line to a
|
||||||
reader and its pipeline runs at the reader's pace; `Stop` ends the wait.
|
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;
|
- TCP/HTTP sink → client queue: a full queue drops it for that client only;
|
||||||
sink `dropped_writes`.
|
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
|
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
|
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
|
Because this is a full rebuild, listening sockets close and reopen and all
|
||||||
clients are disconnected. Application logging is configured once at startup and
|
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
|
Watch errors do not rebuild services. Queued changes are combined, and an
|
||||||
unchanged pipeline/status snapshot is skipped. Signals always rebuild for
|
unchanged pipeline/status snapshot is skipped. Signals always rebuild for
|
||||||
|
|||||||
+61
-9
@@ -335,6 +335,8 @@ A reload rereads the file and keeps the command-line or environment pipelines.
|
|||||||
|
|
||||||
- `SIGINT`, `SIGTERM`: graceful shutdown
|
- `SIGINT`, `SIGTERM`: graceful shutdown
|
||||||
- `SIGHUP`, `SIGUSR1`: reload the configuration
|
- `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
|
`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
|
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
|
`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
|
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`,
|
- Start: the pipelines `lw` would run with the same options (`-c FILE`,
|
||||||
pipeline flags, `LOGWISP_SOURCE`, a discovered file); with none but the
|
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
|
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
|
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
|
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
|
canvas, and beside it from 110 columns, where each part also lists up to
|
||||||
text and scroll dialogs; the status line wraps to three rows.
|
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
|
- 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
|
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
|
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
|
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
|
value to its default; Space turns a flow stage on or off; `J`/`K` move a
|
||||||
filter; `[` `]` switch pipelines; `p` presets; `!` shows every problem,
|
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
|
- 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
|
Left/Right switch, `a` adds one (empty or from a preset), Enter renames and
|
||||||
`d` deletes, asking first when it has parts.
|
`d` deletes, asking first when it has parts.
|
||||||
- Output, `o`, each picked by its letter: `r` runs the pipelines with the
|
- 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
|
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
|
keep them), `t` taps them, or `c`, `e` and `f` print the command line, the
|
||||||
variables (one pipeline) or the file's pipelines on standard output once
|
environment variables (one pipeline) or the file's pipelines on standard
|
||||||
the screen closes.
|
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
|
- 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
|
`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
|
file it starts from, but the shell empties a redirect's target before lw
|
||||||
reads it: redirect to a file lw does not load.
|
reads it: redirect to a file lw does not load.
|
||||||
- Color follows the `color` setting: `auto` the terminal and `NO_COLOR`,
|
- Color follows the `color` setting: `auto` the terminal and `NO_COLOR`,
|
||||||
`always` the terminal, `never` none. True color and 256 colors paint the
|
`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
|
website's dark theme; 16 colors a console look on the terminal's own
|
||||||
console draws with CP437 glyphs, and a terminal outside UTF-8 with ASCII.
|
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`
|
## `lw auth`
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.Dd October 5, 2026
|
.Dd October 10, 2026
|
||||||
.Dt LW 1
|
.Dt LW 1
|
||||||
.Os
|
.Os
|
||||||
.Sh NAME
|
.Sh NAME
|
||||||
@@ -163,6 +163,15 @@ and
|
|||||||
.Fl \-dump
|
.Fl \-dump
|
||||||
also do, or print them as a command line, environment variables or a
|
also do, or print them as a command line, environment variables or a
|
||||||
configuration file's pipelines.
|
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
|
The screen draws on the controlling terminal, so standard input and output
|
||||||
stay data:
|
stay data:
|
||||||
.Ql lw \-T > pipelines.toml .
|
.Ql lw \-T > pipelines.toml .
|
||||||
@@ -756,6 +765,16 @@ Before the service runs,
|
|||||||
is ignored, as under
|
is ignored, as under
|
||||||
.Xr nohup 1 .
|
.Xr nohup 1 .
|
||||||
.El
|
.El
|
||||||
|
.Pp
|
||||||
|
On the
|
||||||
|
.Fl \-tui
|
||||||
|
screen,
|
||||||
|
.Dv SIGTERM
|
||||||
|
and
|
||||||
|
.Dv SIGHUP
|
||||||
|
close it, stopping what it runs, and
|
||||||
|
.Nm
|
||||||
|
exits 0.
|
||||||
.Sh EXAMPLES
|
.Sh EXAMPLES
|
||||||
Copy a file line by line:
|
Copy a file line by line:
|
||||||
.Bd -literal -offset indent
|
.Bd -literal -offset indent
|
||||||
|
|||||||
+4
-1
@@ -745,6 +745,8 @@ edge. `password_file` is read by what it names, on either side:
|
|||||||
forwarded, and stderr is often a file.
|
forwarded, and stderr is often a file.
|
||||||
- With no terminal (systemd, cron) the plugin fails. `lw ... &` stops on
|
- With no terminal (systemd, cron) the plugin fails. `lw ... &` stops on
|
||||||
`SIGTTIN` at the prompt.
|
`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:
|
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
|
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
|
the cursor, retitle the window, write the clipboard (OSC 52) or reorder text
|
||||||
with bidi controls; pipes and files get the bytes unchanged. See
|
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
@@ -122,7 +122,8 @@ id = "discard"
|
|||||||
type = "null"
|
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
@@ -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
|
## 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
|
`lw --tui` composes pipelines in a full-screen terminal UI, runs them under
|
||||||
them at once or prints one form of them, chosen on exit: a command line,
|
the screen with a live view (`t`), and on exit runs them at once or prints one
|
||||||
environment variables, or a configuration file. Its logic is a pure engine that
|
form of them: a command line, environment variables, or a configuration file.
|
||||||
lixen.com's planned Configure tab (lixencom `doc/todo.md`) runs as WebAssembly,
|
Its logic is a pure engine that lixen.com's planned Configure tab (lixencom
|
||||||
so no part of LogWisp is rewritten in JavaScript.
|
`doc/todo.md`) runs as WebAssembly, so no part of LogWisp is rewritten in
|
||||||
|
JavaScript.
|
||||||
|
|
||||||
What exists:
|
What exists:
|
||||||
- Option structs with toml tags for every plugin and flow stage
|
- 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
|
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:
|
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,
|
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)).
|
5. `lw --tui`: done ([doc/cli.md](cli.md#lw---tui)).
|
||||||
6. WebAssembly: done. `make wasm` writes `bin/lwconf/`, which a site drops
|
6. WebAssembly: done. `make wasm` writes `bin/lwconf/`, which a site drops
|
||||||
in whole:
|
in whole:
|
||||||
@@ -154,11 +156,23 @@ Steps:
|
|||||||
- Pipelines are a JSON list in the file's shape (`name`, `flow`,
|
- Pipelines are a JSON list in the file's shape (`name`, `flow`,
|
||||||
`plugin_sources`, `plugin_sinks`); a misspelled key is refused. A node
|
`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.
|
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
|
Not yet:
|
||||||
output.
|
- 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
|
||||||
Still to derive: `AuthOptions.Check` alone limits `auth.node_binding` to chain
|
new file.
|
||||||
sources and `auth.trusted_proxies` to the http sink, so the schema lists both
|
- Per-sink taps: what each sink took, where the tap at the flow's exit shows
|
||||||
on every listener; a side tag naming those sides would filter them as `http`
|
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.
|
filters HTTP's keys.
|
||||||
@@ -6,7 +6,7 @@ require (
|
|||||||
github.com/lixenwraith/auth v0.5.1
|
github.com/lixenwraith/auth v0.5.1
|
||||||
github.com/lixenwraith/color v0.1.3
|
github.com/lixenwraith/color v0.1.3
|
||||||
github.com/lixenwraith/config v0.2.2
|
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/terminal v0.2.2-0.20261010220430-c657af606d34
|
||||||
github.com/lixenwraith/toml v0.1.3
|
github.com/lixenwraith/toml v0.1.3
|
||||||
golang.org/x/term v0.47.0
|
golang.org/x/term v0.47.0
|
||||||
|
|||||||
@@ -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/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 h1:X+l3Tf8Rbk49cipYBJU7/pfdw5wDwD4hRxtFH7rZgOc=
|
||||||
github.com/lixenwraith/config v0.2.2/go.mod h1:yfJQwKS+/6Dg70GIoHeYc0eYztOPETpqe6nUIbakcyg=
|
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.12-0.20261011001400-86d4fe10bd5e h1:5ocB2Wsd4nQbpX+lkA3LSuHf23GQ46BPejNy3oCZZbU=
|
||||||
github.com/lixenwraith/log v0.1.11/go.mod h1:EE8i5nSAW2yZBTytKuhdgehmgzrby6kW1k5DyH1SvBg=
|
github.com/lixenwraith/log v0.1.12-0.20261011001400-86d4fe10bd5e/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/terminal v0.2.2-0.20261010220430-c657af606d34 h1:GasarGRKYbAXzEz1utbcf9n4+N7WXPKyTbUtQZj8v74=
|
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/terminal v0.2.2-0.20261010220430-c657af606d34/go.mod h1:V0P/FDpY+UeSvx1VIVuTG7llo0RbK3XrSJLDA7GqHqY=
|
||||||
github.com/lixenwraith/toml v0.1.3 h1:mhnNVLNogdaqjGd3eC4rV9ZqY3j1Sv+DK1erZ+SPFeI=
|
github.com/lixenwraith/toml v0.1.3 h1:mhnNVLNogdaqjGd3eC4rV9ZqY3j1Sv+DK1erZ+SPFeI=
|
||||||
|
|||||||
@@ -298,9 +298,26 @@ func readDevice(path, user string, generate bool) ([]byte, bool, error) {
|
|||||||
return data, false, err
|
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
|
// ask prompts on the terminal at path without echo. A generated password is
|
||||||
// shown there only: logs persist, and stderr is often a file.
|
// shown there only: logs persist, and stderr is often a file.
|
||||||
func ask(path, user string, generate bool) ([]byte, error) {
|
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)
|
tty, err := os.OpenFile(path, os.O_RDWR, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
"github.com/lixenwraith/logwisp/internal/testutil"
|
"github.com/lixenwraith/logwisp/internal/testutil"
|
||||||
|
|
||||||
"github.com/lixenwraith/auth"
|
"github.com/lixenwraith/auth"
|
||||||
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
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
|
// 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.
|
// /dev/stdin as a device whatever it holds, so one that is no terminal is read.
|
||||||
func TestADeviceThatIsNoTerminalIsRead(t *testing.T) {
|
func TestADeviceThatIsNoTerminalIsRead(t *testing.T) {
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ func Network(host string) (string, error) {
|
|||||||
const (
|
const (
|
||||||
MaxLogEntryBytes = 1024 * 1024
|
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
|
FileWatcherPollInterval = 100 * time.Millisecond
|
||||||
|
|
||||||
SessionDefaultMaxIdleTime = 30 * time.Minute
|
SessionDefaultMaxIdleTime = 30 * time.Minute
|
||||||
|
|||||||
+74
-9
@@ -3,6 +3,7 @@ package core
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,17 +27,81 @@ type TransportEvent struct {
|
|||||||
Entry LogEntry
|
Entry LogEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
// Levels are the entry levels by rising severity, each with the words naming
|
// Tapped is what a pipeline's flow decided about one entry: Stage names the
|
||||||
// it in a line: the sources, the console sink's painter and the viewer share them
|
// stage that dropped Event.Entry (rate_limit, filters at Index, format), or is
|
||||||
var Levels = []struct {
|
// "" 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"`
|
Level string `json:"level"`
|
||||||
Names []string `json:"names"`
|
Names []string `json:"names"`
|
||||||
}{
|
Keyed []string `json:"-"`
|
||||||
{"TRACE", []string{"TRACE"}},
|
}
|
||||||
{"DEBUG", []string{"DEBUG", "DBG"}},
|
|
||||||
{"INFO", []string{"INFO", "INF"}},
|
// Levels are the entry levels by rising severity: the sources, the console
|
||||||
{"WARN", []string{"WARN", "WARNING"}},
|
// sink's painter and the viewer share them
|
||||||
{"ERROR", []string{"ERROR", "ERR", "FATAL"}},
|
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
|
// LevelWord finds the first whole word of text, in any case, that names a
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package core
|
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
|
// 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.
|
// 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,17 +10,15 @@ import (
|
|||||||
"github.com/lixenwraith/log"
|
"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 {
|
type Chain struct {
|
||||||
filters []*Filter
|
filters []*Filter
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
|
|
||||||
// Statistics
|
|
||||||
totalProcessed atomic.Uint64
|
totalProcessed atomic.Uint64
|
||||||
totalPassed 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) {
|
func NewChain(configs []config.FilterConfig, logger *log.Logger) (*Chain, error) {
|
||||||
chain := &Chain{
|
chain := &Chain{
|
||||||
filters: make([]*Filter, 0, len(configs)),
|
filters: make([]*Filter, 0, len(configs)),
|
||||||
@@ -41,32 +39,23 @@ func NewChain(configs []config.FilterConfig, logger *log.Logger) (*Chain, error)
|
|||||||
return chain, nil
|
return chain, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply runs a log entry through all filters in the chain
|
// Apply runs an entry through the filters in order and returns the index of
|
||||||
func (c *Chain) Apply(entry core.LogEntry) bool {
|
// the one that dropped it, or -1
|
||||||
|
func (c *Chain) Apply(entry core.LogEntry) int {
|
||||||
c.totalProcessed.Add(1)
|
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 {
|
for i, filter := range c.filters {
|
||||||
if !filter.Apply(entry) {
|
if !filter.Apply(entry) {
|
||||||
c.logger.Debug("msg", "Entry filtered out",
|
c.logger.Debug("msg", "Entry filtered out",
|
||||||
"component", "filter_chain",
|
"component", "filter_chain",
|
||||||
"filter_index", i,
|
"filter_index", i,
|
||||||
"filter_type", filter.config.Type)
|
"filter_type", filter.config.Type)
|
||||||
return false
|
return i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.totalPassed.Add(1)
|
c.totalPassed.Add(1)
|
||||||
return true
|
return -1
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStats returns aggregated statistics for the entire chain
|
|
||||||
func (c *Chain) GetStats() map[string]any {
|
func (c *Chain) GetStats() map[string]any {
|
||||||
filterStats := make([]map[string]any, len(c.filters))
|
filterStats := make([]map[string]any, len(c.filters))
|
||||||
for i, filter := range c.filters {
|
for i, filter := range c.filters {
|
||||||
|
|||||||
+15
-37
@@ -13,8 +13,8 @@ import (
|
|||||||
"github.com/lixenwraith/log"
|
"github.com/lixenwraith/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Flow manages the complete processing pipeline for log entries:
|
// Flow takes a pipeline's entries through rate limit, filters and format to
|
||||||
// LogEntry -> Rate Limiter -> Filters -> Formatter (with Sanitizer) -> TransportEvent
|
// the TransportEvent its sinks get
|
||||||
type Flow struct {
|
type Flow struct {
|
||||||
rateLimiter *RateLimiter
|
rateLimiter *RateLimiter
|
||||||
filterChain *filter.Chain
|
filterChain *filter.Chain
|
||||||
@@ -22,13 +22,11 @@ type Flow struct {
|
|||||||
heartbeat *HeartbeatGenerator
|
heartbeat *HeartbeatGenerator
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
|
|
||||||
// Statistics
|
|
||||||
totalProcessed atomic.Uint64
|
totalProcessed atomic.Uint64
|
||||||
totalDropped atomic.Uint64
|
totalDropped atomic.Uint64
|
||||||
totalFormatted atomic.Uint64
|
totalFormatted atomic.Uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFlow creates a flow processor from configuration
|
|
||||||
func NewFlow(cfg *config.FlowConfig, logger *log.Logger) (*Flow, error) {
|
func NewFlow(cfg *config.FlowConfig, logger *log.Logger) (*Flow, error) {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
cfg = &config.FlowConfig{}
|
cfg = &config.FlowConfig{}
|
||||||
@@ -38,7 +36,6 @@ func NewFlow(cfg *config.FlowConfig, logger *log.Logger) (*Flow, error) {
|
|||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create rate limiter if configured
|
|
||||||
if cfg.RateLimit != nil {
|
if cfg.RateLimit != nil {
|
||||||
limiter, err := NewRateLimiter(*cfg.RateLimit, logger)
|
limiter, err := NewRateLimiter(*cfg.RateLimit, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -47,7 +44,6 @@ func NewFlow(cfg *config.FlowConfig, logger *log.Logger) (*Flow, error) {
|
|||||||
f.rateLimiter = limiter
|
f.rateLimiter = limiter
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create filter chain if configured
|
|
||||||
if len(cfg.Filters) > 0 {
|
if len(cfg.Filters) > 0 {
|
||||||
chain, err := filter.NewChain(cfg.Filters, logger)
|
chain, err := filter.NewChain(cfg.Filters, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -56,14 +52,13 @@ func NewFlow(cfg *config.FlowConfig, logger *log.Logger) (*Flow, error) {
|
|||||||
f.filterChain = chain
|
f.filterChain = chain
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create formatter with sanitizer integration
|
|
||||||
formatter, err := format.NewFormatter(cfg.Format)
|
formatter, err := format.NewFormatter(cfg.Format)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create formatter: %w", err)
|
return nil, fmt.Errorf("failed to create formatter: %w", err)
|
||||||
}
|
}
|
||||||
f.formatter = formatter
|
f.formatter = formatter
|
||||||
|
|
||||||
// Create heartbeat generator with the same formatter if configured
|
// Heartbeats are written like the entries
|
||||||
if cfg.Heartbeat != nil {
|
if cfg.Heartbeat != nil {
|
||||||
hb, err := NewHeartbeatGenerator(cfg.Heartbeat, formatter, logger)
|
hb, err := NewHeartbeatGenerator(cfg.Heartbeat, formatter, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,51 +77,35 @@ func NewFlow(cfg *config.FlowConfig, logger *log.Logger) (*Flow, error) {
|
|||||||
return f, nil
|
return f, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process applies all flow stages to a log entry
|
// Process applies the flow's stages to an entry and returns the event for the
|
||||||
// Returns TransportEvent and whether entry passed all stages
|
// sinks, or the stage that dropped it and, for filters, which one
|
||||||
func (f *Flow) Process(entry core.LogEntry) (core.TransportEvent, bool) {
|
func (f *Flow) Process(entry core.LogEntry) (event core.TransportEvent, stage string, index int) {
|
||||||
f.totalProcessed.Add(1)
|
f.totalProcessed.Add(1)
|
||||||
|
if f.rateLimiter != nil && !f.rateLimiter.Allow(entry) {
|
||||||
// Stage 1: Rate limiting
|
|
||||||
if f.rateLimiter != nil {
|
|
||||||
if !f.rateLimiter.Allow(entry) {
|
|
||||||
f.totalDropped.Add(1)
|
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 != nil {
|
||||||
if !f.filterChain.Apply(entry) {
|
if i := f.filterChain.Apply(entry); i >= 0 {
|
||||||
f.totalDropped.Add(1)
|
f.totalDropped.Add(1)
|
||||||
return core.TransportEvent{}, false
|
return core.TransportEvent{Entry: entry}, "filters", i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage 3: Formatting
|
|
||||||
formatted, err := f.formatter.Format(entry)
|
formatted, err := f.formatter.Format(entry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
f.logger.Error("msg", "Failed to format log entry",
|
f.logger.Error("msg", "Failed to format log entry",
|
||||||
"component", "flow",
|
"component", "flow",
|
||||||
"error", err)
|
"error", err)
|
||||||
f.totalDropped.Add(1)
|
f.totalDropped.Add(1)
|
||||||
return core.TransportEvent{}, false
|
return core.TransportEvent{Entry: entry}, "format", 0
|
||||||
}
|
}
|
||||||
|
|
||||||
f.totalFormatted.Add(1)
|
f.totalFormatted.Add(1)
|
||||||
|
// The structured entry rides along so chain sinks are format-independent
|
||||||
// Create transport event
|
return core.TransportEvent{Time: entry.Time, Payload: formatted, Entry: entry}, "", 0
|
||||||
event := core.TransportEvent{
|
|
||||||
Time: entry.Time,
|
|
||||||
Payload: formatted,
|
|
||||||
Entry: entry, // Carry structured entry so chain sinks are format-independent
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return event, true
|
// StartHeartbeat returns the heartbeat events until ctx ends, or nil without
|
||||||
}
|
// a heartbeat
|
||||||
|
|
||||||
// StartHeartbeat starts the heartbeat generator if configured
|
|
||||||
// Returns channel that emits heartbeat events
|
|
||||||
func (f *Flow) StartHeartbeat(ctx context.Context) <-chan core.TransportEvent {
|
func (f *Flow) StartHeartbeat(ctx context.Context) <-chan core.TransportEvent {
|
||||||
if f.heartbeat == nil {
|
if f.heartbeat == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -134,7 +113,6 @@ func (f *Flow) StartHeartbeat(ctx context.Context) <-chan core.TransportEvent {
|
|||||||
return f.heartbeat.Start(ctx)
|
return f.heartbeat.Start(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStats returns flow statistics
|
|
||||||
func (f *Flow) GetStats() map[string]any {
|
func (f *Flow) GetStats() map[string]any {
|
||||||
stats := map[string]any{
|
stats := map[string]any{
|
||||||
"total_processed": f.totalProcessed.Load(),
|
"total_processed": f.totalProcessed.Load(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -21,13 +21,11 @@ import (
|
|||||||
type Pipeline struct {
|
type Pipeline struct {
|
||||||
Config *config.PipelineConfig
|
Config *config.PipelineConfig
|
||||||
|
|
||||||
// Components
|
|
||||||
Registry *Registry
|
Registry *Registry
|
||||||
Sources map[string]source.Source // Track instances by ID
|
Sources map[string]source.Source
|
||||||
Sinks map[string]sink.Sink
|
Sinks map[string]sink.Sink
|
||||||
Sessions *session.Manager
|
Sessions *session.Manager
|
||||||
|
|
||||||
// Pipeline flow
|
|
||||||
Flow *flow.Flow
|
Flow *flow.Flow
|
||||||
Stats *PipelineStats
|
Stats *PipelineStats
|
||||||
logger *log.Logger
|
logger *log.Logger
|
||||||
@@ -35,7 +33,8 @@ type Pipeline struct {
|
|||||||
// Sinks that stall dispatch rather than drop (core.CapBackpressure)
|
// Sinks that stall dispatch rather than drop (core.CapBackpressure)
|
||||||
backpressure map[string]bool
|
backpressure map[string]bool
|
||||||
|
|
||||||
// Runtime
|
tap atomic.Pointer[core.Tap] // a viewer's copy of what the flow decides
|
||||||
|
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
@@ -45,7 +44,6 @@ type Pipeline struct {
|
|||||||
finishedOnce sync.Once
|
finishedOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// PipelineStats contains runtime statistics for a pipeline
|
|
||||||
type PipelineStats struct {
|
type PipelineStats struct {
|
||||||
StartTime time.Time
|
StartTime time.Time
|
||||||
TotalEntriesDroppedBySink atomic.Uint64
|
TotalEntriesDroppedBySink atomic.Uint64
|
||||||
@@ -54,18 +52,14 @@ type PipelineStats struct {
|
|||||||
FlowStats map[string]any
|
FlowStats map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPipeline creates a new pipeline with registry support
|
|
||||||
func NewPipeline(
|
func NewPipeline(
|
||||||
cfg *config.PipelineConfig,
|
cfg *config.PipelineConfig,
|
||||||
logger *log.Logger,
|
logger *log.Logger,
|
||||||
) (*Pipeline, error) {
|
) (*Pipeline, error) {
|
||||||
// Create pipeline context
|
|
||||||
pipelineCtx, pipelineCancel := context.WithCancel(context.Background())
|
pipelineCtx, pipelineCancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
// Create session manager with default timeout
|
|
||||||
sessionManager := session.NewManager(core.SessionDefaultMaxIdleTime)
|
sessionManager := session.NewManager(core.SessionDefaultMaxIdleTime)
|
||||||
|
|
||||||
// Create pipeline instance with registry
|
|
||||||
pipeline := &Pipeline{
|
pipeline := &Pipeline{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Registry: NewRegistry(cfg.Name, logger),
|
Registry: NewRegistry(cfg.Name, logger),
|
||||||
@@ -81,17 +75,14 @@ func NewPipeline(
|
|||||||
finished: make(chan struct{}),
|
finished: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create flow processor
|
|
||||||
flowProcessor, err := flow.NewFlow(cfg.Flow, logger)
|
flowProcessor, err := flow.NewFlow(cfg.Flow, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If flow fails, stop session manager
|
|
||||||
pipelineCancel()
|
pipelineCancel()
|
||||||
sessionManager.Stop()
|
sessionManager.Stop()
|
||||||
return nil, fmt.Errorf("failed to create flow processor: %w", err)
|
return nil, fmt.Errorf("failed to create flow processor: %w", err)
|
||||||
}
|
}
|
||||||
pipeline.Flow = flowProcessor
|
pipeline.Flow = flowProcessor
|
||||||
|
|
||||||
// Initialize sources and sinks
|
|
||||||
if err := pipeline.initializeComponents(); err != nil {
|
if err := pipeline.initializeComponents(); err != nil {
|
||||||
pipelineCancel()
|
pipelineCancel()
|
||||||
sessionManager.Stop()
|
sessionManager.Stop()
|
||||||
@@ -102,10 +93,8 @@ func NewPipeline(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *Pipeline) initializeComponents() error {
|
func (p *Pipeline) initializeComponents() error {
|
||||||
// Create sources based on plugin config if available
|
|
||||||
if len(p.Config.PluginSources) > 0 {
|
if len(p.Config.PluginSources) > 0 {
|
||||||
for _, srcCfg := range p.Config.PluginSources {
|
for _, srcCfg := range p.Config.PluginSources {
|
||||||
// Create session proxy for this source instance
|
|
||||||
sessionProxy := session.NewProxy(p.Sessions, srcCfg.ID)
|
sessionProxy := session.NewProxy(p.Sessions, srcCfg.ID)
|
||||||
|
|
||||||
src, err := p.Registry.CreateSource(
|
src, err := p.Registry.CreateSource(
|
||||||
@@ -116,12 +105,11 @@ func (p *Pipeline) initializeComponents() error {
|
|||||||
sessionProxy,
|
sessionProxy,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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 {
|
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
|
p.Sources[srcCfg.ID] = src
|
||||||
@@ -130,10 +118,8 @@ func (p *Pipeline) initializeComponents() error {
|
|||||||
return fmt.Errorf("no plugin sources defined")
|
return fmt.Errorf("no plugin sources defined")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create sinks based on plugin config if available
|
|
||||||
if len(p.Config.PluginSinks) > 0 {
|
if len(p.Config.PluginSinks) > 0 {
|
||||||
for _, sinkCfg := range p.Config.PluginSinks {
|
for _, sinkCfg := range p.Config.PluginSinks {
|
||||||
// Create session proxy for this sink instance
|
|
||||||
sessionProxy := session.NewProxy(p.Sessions, sinkCfg.ID)
|
sessionProxy := session.NewProxy(p.Sessions, sinkCfg.ID)
|
||||||
|
|
||||||
snk, err := p.Registry.CreateSink(
|
snk, err := p.Registry.CreateSink(
|
||||||
@@ -144,12 +130,11 @@ func (p *Pipeline) initializeComponents() error {
|
|||||||
sessionProxy,
|
sessionProxy,
|
||||||
)
|
)
|
||||||
if err != nil {
|
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 {
|
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
|
p.Sinks[sinkCfg.ID] = snk
|
||||||
@@ -161,24 +146,34 @@ func (p *Pipeline) initializeComponents() error {
|
|||||||
return nil
|
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 {
|
func (p *Pipeline) initSourceCapabilities(s source.Source, cfg config.PluginSourceConfig) error {
|
||||||
// Initiate and activate source capabilities
|
|
||||||
var hasTLS, hasAuth bool
|
var hasTLS, hasAuth bool
|
||||||
for _, c := range s.Capabilities() {
|
for _, c := range s.Capabilities() {
|
||||||
switch c {
|
switch c {
|
||||||
// Network capabilities
|
|
||||||
case core.CapNetLimit:
|
case core.CapNetLimit:
|
||||||
continue // No-op for now, placeholder
|
continue
|
||||||
case core.CapTLS, core.CapProxyTLS:
|
case core.CapTLS, core.CapProxyTLS:
|
||||||
hasTLS = true
|
hasTLS = true
|
||||||
case core.CapAuth:
|
case core.CapAuth:
|
||||||
hasAuth = true
|
hasAuth = true
|
||||||
|
|
||||||
// Session capabilities
|
|
||||||
case core.CapSessionAware:
|
case core.CapSessionAware:
|
||||||
case core.CapMultiSession:
|
case core.CapMultiSession:
|
||||||
continue // TODO
|
continue
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown capability type: %s", c)
|
return fmt.Errorf("unknown capability type: %s", c)
|
||||||
@@ -202,27 +197,23 @@ func checkAuthCapability(hasTLS, hasAuth bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// initSinkCapabilities checks and injects optional capabilities
|
|
||||||
func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig) error {
|
func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig) error {
|
||||||
// Initiate and activate sink capabilities
|
|
||||||
var hasTLS, hasAuth bool
|
var hasTLS, hasAuth bool
|
||||||
for _, c := range s.Capabilities() {
|
for _, c := range s.Capabilities() {
|
||||||
switch c {
|
switch c {
|
||||||
case core.CapBackpressure:
|
case core.CapBackpressure:
|
||||||
p.backpressure[cfg.ID] = true
|
p.backpressure[cfg.ID] = true
|
||||||
|
|
||||||
// Network capabilities
|
|
||||||
case core.CapNetLimit:
|
case core.CapNetLimit:
|
||||||
continue // No-op for now, placeholder
|
continue
|
||||||
case core.CapTLS, core.CapProxyTLS:
|
case core.CapTLS, core.CapProxyTLS:
|
||||||
hasTLS = true
|
hasTLS = true
|
||||||
case core.CapAuth:
|
case core.CapAuth:
|
||||||
hasAuth = true
|
hasAuth = true
|
||||||
|
|
||||||
// Session capabilities
|
|
||||||
case core.CapSessionAware:
|
case core.CapSessionAware:
|
||||||
case core.CapMultiSession:
|
case core.CapMultiSession:
|
||||||
continue // TODO
|
continue
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown capability type: %s", c)
|
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
|
// 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.
|
// 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.wg.Done()
|
||||||
defer p.finish()
|
defer p.finish()
|
||||||
defer p.logger.Info("msg", "Pipeline processing loop stopped", "pipeline", p.Config.Name)
|
defer p.logger.Info("msg", "Pipeline processing loop stopped", "pipeline", p.Config.Name)
|
||||||
|
|
||||||
var componentWg sync.WaitGroup
|
var componentWg sync.WaitGroup
|
||||||
for _, ch := range subscriptions {
|
for i, ch := range subscriptions {
|
||||||
componentWg.Add(1)
|
componentWg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer componentWg.Done()
|
defer componentWg.Done()
|
||||||
// Range drains in-flight entries once the source closes the channel
|
// Range drains in-flight entries once the source closes the channel
|
||||||
for entry := range ch {
|
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)
|
p.dispatch(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,6 +265,9 @@ func (p *Pipeline) run(subscriptions []<-chan core.LogEntry) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if t := p.tap.Load(); t != nil {
|
||||||
|
t.Offer(core.Tapped{Pipeline: p.Config.Name, Event: event})
|
||||||
|
}
|
||||||
p.dispatch(event)
|
p.dispatch(event)
|
||||||
case <-hbCtx.Done():
|
case <-hbCtx.Done():
|
||||||
return
|
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) }) }
|
func (p *Pipeline) finish() { p.finishedOnce.Do(func() { close(p.finished) }) }
|
||||||
|
|
||||||
// Finished is closed when every source has ended, or the pipeline shut down.
|
// 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.
|
// Only a finite source (console at end of input) ends without Stop.
|
||||||
func (p *Pipeline) Finished() <-chan struct{} { return p.finished }
|
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 {
|
func (p *Pipeline) Start() error {
|
||||||
if !p.running.CompareAndSwap(false, true) {
|
if !p.running.CompareAndSwap(false, true) {
|
||||||
return fmt.Errorf("pipeline %s is already running", p.Config.Name)
|
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.ctx, p.cancel = context.WithCancel(context.Background())
|
||||||
p.halt = make(chan struct{})
|
p.halt = make(chan struct{})
|
||||||
|
|
||||||
// Start all sinks
|
|
||||||
for id, s := range p.Sinks {
|
for id, s := range p.Sinks {
|
||||||
if err := s.Start(p.ctx); err != nil {
|
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))
|
subscriptions := make([]<-chan core.LogEntry, 0, len(p.Sources))
|
||||||
for _, src := range p.Sources {
|
for id, src := range p.Sources {
|
||||||
subscriptions = append(subscriptions, src.Subscribe())
|
ids, subscriptions = append(ids, id), append(subscriptions, src.Subscribe())
|
||||||
}
|
}
|
||||||
for id, src := range p.Sources {
|
for id, src := range p.Sources {
|
||||||
if err := src.Start(); err != nil {
|
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.Stats.StartTime = time.Now()
|
||||||
p.wg.Add(1)
|
p.wg.Add(1)
|
||||||
go p.run(subscriptions)
|
go p.run(ids, subscriptions)
|
||||||
|
|
||||||
return nil
|
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 {
|
func (p *Pipeline) Stop() error {
|
||||||
if !p.running.CompareAndSwap(true, false) {
|
if !p.running.CompareAndSwap(true, false) {
|
||||||
return fmt.Errorf("pipeline %s is not running", p.Config.Name)
|
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)
|
p.logger.Info("msg", "Stopping pipeline", "pipeline", p.Config.Name)
|
||||||
close(p.halt)
|
close(p.halt)
|
||||||
|
|
||||||
// 1. Stop all sources concurrently to halt new data ingress and close their channels
|
|
||||||
var sourceWg sync.WaitGroup
|
var sourceWg sync.WaitGroup
|
||||||
for _, src := range p.Sources {
|
for _, src := range p.Sources {
|
||||||
sourceWg.Add(1)
|
sourceWg.Add(1)
|
||||||
@@ -365,10 +366,8 @@ func (p *Pipeline) Stop() error {
|
|||||||
}
|
}
|
||||||
sourceWg.Wait()
|
sourceWg.Wait()
|
||||||
|
|
||||||
// 2. Wait for the run loop to finish processing and sending all in-flight data
|
|
||||||
p.wg.Wait()
|
p.wg.Wait()
|
||||||
|
|
||||||
// 3. Stop all sinks concurrently now that no new data will be sent
|
|
||||||
var sinkWg sync.WaitGroup
|
var sinkWg sync.WaitGroup
|
||||||
for _, s := range p.Sinks {
|
for _, s := range p.Sinks {
|
||||||
sinkWg.Add(1)
|
sinkWg.Add(1)
|
||||||
@@ -384,20 +383,18 @@ func (p *Pipeline) Stop() error {
|
|||||||
return nil
|
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() {
|
func (p *Pipeline) Shutdown() {
|
||||||
p.logger.Info("msg", "Shutting down pipeline",
|
p.logger.Info("msg", "Shutting down pipeline",
|
||||||
"component", "pipeline",
|
"component", "pipeline",
|
||||||
"pipeline", p.Config.Name)
|
"pipeline", p.Config.Name)
|
||||||
|
|
||||||
// Ensure the pipeline is stopped before shutting down
|
|
||||||
if p.running.Load() {
|
if p.running.Load() {
|
||||||
if err := p.Stop(); err != nil {
|
if err := p.Stop(); err != nil {
|
||||||
p.logger.Error("msg", "Error stopping pipeline during shutdown", "error", err)
|
p.logger.Error("msg", "Error stopping pipeline during shutdown", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop long-running components
|
|
||||||
if p.Sessions != nil {
|
if p.Sessions != nil {
|
||||||
p.Sessions.Stop()
|
p.Sessions.Stop()
|
||||||
}
|
}
|
||||||
@@ -408,10 +405,8 @@ func (p *Pipeline) Shutdown() {
|
|||||||
"pipeline", p.Config.Name)
|
"pipeline", p.Config.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStats returns a map of pipeline statistics
|
|
||||||
func (p *Pipeline) GetStats() map[string]any {
|
func (p *Pipeline) GetStats() map[string]any {
|
||||||
// Recovery to handle concurrent access during shutdown
|
// Sources and sinks may be partly stopped while the service shuts down
|
||||||
// When service is shutting down, sources/sinks might be nil or partially stopped
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
p.logger.Error("msg", "Panic getting pipeline stats",
|
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))
|
sources := make([]map[string]any, 0, len(p.Sources))
|
||||||
for _, src := range p.Sources {
|
for _, src := range p.Sources {
|
||||||
if src == nil {
|
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))
|
sinks := make([]map[string]any, 0, len(p.Sinks))
|
||||||
for _, snk := range p.Sinks {
|
for _, snk := range p.Sinks {
|
||||||
if snk == nil {
|
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 flowStats map[string]any
|
||||||
var totalFiltered uint64
|
var totalFiltered uint64
|
||||||
var totalProcessed uint64
|
var totalProcessed uint64
|
||||||
@@ -464,12 +456,10 @@ func (p *Pipeline) GetStats() map[string]any {
|
|||||||
if p.Flow != nil {
|
if p.Flow != nil {
|
||||||
flowStats = p.Flow.GetStats()
|
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 {
|
if tp, ok := flowStats["total_processed"].(uint64); ok {
|
||||||
totalProcessed = tp
|
totalProcessed = tp
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate total dropped specifically by the filter chain
|
|
||||||
if filters, ok := flowStats["filters"].(map[string]any); ok {
|
if filters, ok := flowStats["filters"].(map[string]any); ok {
|
||||||
if totalPassed, ok := filters["total_passed"].(uint64); ok {
|
if totalPassed, ok := filters["total_passed"].(uint64); ok {
|
||||||
if tProc, ok := filters["total_processed"].(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
|
var uptime int
|
||||||
if p.running.Load() && !p.Stats.StartTime.IsZero() {
|
if p.running.Load() && !p.Stats.StartTime.IsZero() {
|
||||||
uptime = int(time.Since(p.Stats.StartTime).Seconds())
|
uptime = int(time.Since(p.Stats.StartTime).Seconds())
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,9 +18,12 @@ import (
|
|||||||
"github.com/lixenwraith/log"
|
"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 {
|
type finiteSource struct {
|
||||||
|
id string
|
||||||
n int
|
n int
|
||||||
|
hold chan struct{}
|
||||||
subs []chan core.LogEntry
|
subs []chan core.LogEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,9 +37,13 @@ func (s *finiteSource) Start() error {
|
|||||||
go func() {
|
go func() {
|
||||||
for i := range s.n {
|
for i := range s.n {
|
||||||
for _, ch := range s.subs {
|
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 {
|
for _, ch := range s.subs {
|
||||||
close(ch)
|
close(ch)
|
||||||
}
|
}
|
||||||
@@ -74,7 +82,8 @@ var testSinks = map[string]*slowSink{}
|
|||||||
func init() {
|
func init() {
|
||||||
plugin.RegisterSource("random", func(id string, cfg map[string]any, _ *log.Logger, _ *session.Proxy) (source.Source, error) {
|
plugin.RegisterSource("random", func(id string, cfg map[string]any, _ *log.Logger, _ *session.Proxy) (source.Source, error) {
|
||||||
n, _ := strconv.Atoi(fmt.Sprint(cfg["n"]))
|
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) {
|
plugin.RegisterSink("null", func(id string, _ map[string]any, _ *log.Logger, _ *session.Proxy) (sink.Sink, error) {
|
||||||
return testSinks[id], nil
|
return testSinks[id], nil
|
||||||
@@ -132,3 +141,127 @@ func TestStopReleasesStalledBackpressureSink(t *testing.T) {
|
|||||||
t.Fatal("Stop blocked behind a stalled backpressure sink")
|
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
@@ -7,6 +7,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/lixenwraith/logwisp/internal/config"
|
"github.com/lixenwraith/logwisp/internal/config"
|
||||||
|
"github.com/lixenwraith/logwisp/internal/core"
|
||||||
"github.com/lixenwraith/logwisp/internal/pipeline"
|
"github.com/lixenwraith/logwisp/internal/pipeline"
|
||||||
|
|
||||||
"github.com/lixenwraith/log"
|
"github.com/lixenwraith/log"
|
||||||
@@ -25,7 +26,6 @@ type Service struct {
|
|||||||
done chan struct{} // closed when every pipeline has finished
|
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) {
|
func NewService(ctx context.Context, cfg *config.Config, logger *log.Logger) (*Service, error) {
|
||||||
serviceCtx, cancel := context.WithCancel(ctx)
|
serviceCtx, cancel := context.WithCancel(ctx)
|
||||||
svc := &Service{
|
svc := &Service{
|
||||||
@@ -37,12 +37,10 @@ func NewService(ctx context.Context, cfg *config.Config, logger *log.Logger) (*S
|
|||||||
}
|
}
|
||||||
|
|
||||||
var errs error
|
var errs error
|
||||||
// Initialize pipelines
|
|
||||||
for _, pipelineCfg := range cfg.Pipelines {
|
for _, pipelineCfg := range cfg.Pipelines {
|
||||||
pipelineName := pipelineCfg.Name
|
pipelineName := pipelineCfg.Name
|
||||||
logger.Info("msg", "Initializing pipeline", "pipeline", pipelineName)
|
logger.Info("msg", "Initializing pipeline", "pipeline", pipelineName)
|
||||||
|
|
||||||
// Create the pipeline
|
|
||||||
if pl, err := pipeline.NewPipeline(&pipelineCfg, logger); err != nil {
|
if pl, err := pipeline.NewPipeline(&pipelineCfg, logger); err != nil {
|
||||||
logger.Error("msg", "Failed to create pipeline",
|
logger.Error("msg", "Failed to create pipeline",
|
||||||
"pipeline", pipelineCfg.Name,
|
"pipeline", pipelineCfg.Name,
|
||||||
@@ -63,13 +61,12 @@ func NewService(ctx context.Context, cfg *config.Config, logger *log.Logger) (*S
|
|||||||
return svc, nil
|
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 {
|
func (svc *Service) Start(names ...string) error {
|
||||||
svc.mu.RLock()
|
svc.mu.RLock()
|
||||||
defer svc.mu.RUnlock()
|
defer svc.mu.RUnlock()
|
||||||
|
|
||||||
var errs error
|
var errs error
|
||||||
// If no names are provided, start all pipelines
|
|
||||||
if len(names) == 0 {
|
if len(names) == 0 {
|
||||||
svc.logger.Info("msg", "Starting all pipelines")
|
svc.logger.Info("msg", "Starting all pipelines")
|
||||||
for name, p := range svc.pipelines {
|
for name, p := range svc.pipelines {
|
||||||
@@ -78,7 +75,6 @@ func (svc *Service) Start(names ...string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Start only the specified pipelines
|
|
||||||
svc.logger.Info("msg", "Starting specified pipelines", "pipelines", names)
|
svc.logger.Info("msg", "Starting specified pipelines", "pipelines", names)
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
if p, exists := svc.pipelines[name]; exists {
|
if p, exists := svc.pipelines[name]; exists {
|
||||||
@@ -108,18 +104,26 @@ func (svc *Service) Start(names ...string) error {
|
|||||||
return errs
|
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
|
// Done is closed once every pipeline has finished: all their sources reached
|
||||||
// the end of their input (stdin), or the service shut down.
|
// the end of their input (stdin), or the service shut down.
|
||||||
func (svc *Service) Done() <-chan struct{} { return svc.done }
|
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 {
|
func (svc *Service) Stop(names ...string) error {
|
||||||
svc.mu.RLock()
|
svc.mu.RLock()
|
||||||
defer svc.mu.RUnlock()
|
defer svc.mu.RUnlock()
|
||||||
|
|
||||||
var errs error
|
var errs error
|
||||||
|
|
||||||
// If no names are provided, stop all pipelines
|
|
||||||
if len(names) == 0 {
|
if len(names) == 0 {
|
||||||
svc.logger.Info("msg", "Stopping all pipelines")
|
svc.logger.Info("msg", "Stopping all pipelines")
|
||||||
for name, p := range svc.pipelines {
|
for name, p := range svc.pipelines {
|
||||||
@@ -128,7 +132,6 @@ func (svc *Service) Stop(names ...string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Stop only the specified pipelines
|
|
||||||
svc.logger.Info("msg", "Stopping specified pipelines", "pipelines", names)
|
svc.logger.Info("msg", "Stopping specified pipelines", "pipelines", names)
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
if p, exists := svc.pipelines[name]; exists {
|
if p, exists := svc.pipelines[name]; exists {
|
||||||
@@ -146,7 +149,6 @@ func (svc *Service) Stop(names ...string) error {
|
|||||||
return errs
|
return errs
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPipeline returns a pipeline by its name
|
|
||||||
func (svc *Service) GetPipeline(name string) (*pipeline.Pipeline, error) {
|
func (svc *Service) GetPipeline(name string) (*pipeline.Pipeline, error) {
|
||||||
svc.mu.RLock()
|
svc.mu.RLock()
|
||||||
defer svc.mu.RUnlock()
|
defer svc.mu.RUnlock()
|
||||||
@@ -158,7 +160,6 @@ func (svc *Service) GetPipeline(name string) (*pipeline.Pipeline, error) {
|
|||||||
return pipeline, nil
|
return pipeline, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListPipelines returns the names of all currently managed pipelines
|
|
||||||
func (svc *Service) ListPipelines() []string {
|
func (svc *Service) ListPipelines() []string {
|
||||||
svc.mu.RLock()
|
svc.mu.RLock()
|
||||||
defer svc.mu.RUnlock()
|
defer svc.mu.RUnlock()
|
||||||
@@ -170,7 +171,6 @@ func (svc *Service) ListPipelines() []string {
|
|||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemovePipeline stops and removes a pipeline from the service
|
|
||||||
func (svc *Service) RemovePipeline(name string) error {
|
func (svc *Service) RemovePipeline(name string) error {
|
||||||
svc.mu.Lock()
|
svc.mu.Lock()
|
||||||
defer svc.mu.Unlock()
|
defer svc.mu.Unlock()
|
||||||
@@ -191,7 +191,7 @@ func (svc *Service) RemovePipeline(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shutdown gracefully stops all pipelines managed by the service
|
// Shutdown stops every pipeline for good, concurrently
|
||||||
func (svc *Service) Shutdown() {
|
func (svc *Service) Shutdown() {
|
||||||
svc.shutdownOnce.Do(svc.shutdown)
|
svc.shutdownOnce.Do(svc.shutdown)
|
||||||
}
|
}
|
||||||
@@ -206,7 +206,6 @@ func (svc *Service) shutdown() {
|
|||||||
}
|
}
|
||||||
svc.mu.Unlock()
|
svc.mu.Unlock()
|
||||||
|
|
||||||
// Stop all pipelines concurrently
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for _, pl := range pipelines {
|
for _, pl := range pipelines {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
@@ -223,7 +222,6 @@ func (svc *Service) shutdown() {
|
|||||||
svc.logger.Info("msg", "Service shutdown complete")
|
svc.logger.Info("msg", "Service shutdown complete")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetGlobalStats returns statistics for all pipelines
|
|
||||||
func (svc *Service) GetGlobalStats() map[string]any {
|
func (svc *Service) GetGlobalStats() map[string]any {
|
||||||
svc.mu.RLock()
|
svc.mu.RLock()
|
||||||
defer svc.mu.RUnlock()
|
defer svc.mu.RUnlock()
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
package console
|
package console
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/lixenwraith/logwisp/internal/config"
|
"github.com/lixenwraith/logwisp/internal/config"
|
||||||
"github.com/lixenwraith/logwisp/internal/core"
|
"github.com/lixenwraith/logwisp/internal/core"
|
||||||
|
"github.com/lixenwraith/logwisp/internal/logline"
|
||||||
"github.com/lixenwraith/logwisp/internal/plugin"
|
"github.com/lixenwraith/logwisp/internal/plugin"
|
||||||
"github.com/lixenwraith/logwisp/internal/session"
|
"github.com/lixenwraith/logwisp/internal/session"
|
||||||
"github.com/lixenwraith/logwisp/internal/sink"
|
"github.com/lixenwraith/logwisp/internal/sink"
|
||||||
@@ -82,10 +80,7 @@ func NewConsoleSinkPlugin(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
output := os.Stdout
|
output := Stream(opts.Target)
|
||||||
if opts.Target == "stderr" {
|
|
||||||
output = os.Stderr
|
|
||||||
}
|
|
||||||
|
|
||||||
// inline decides auto: a terminal, NO_COLOR unset, TERM not dumb. Paint
|
// inline decides auto: a terminal, NO_COLOR unset, TERM not dumb. Paint
|
||||||
// returns its text unchanged exactly when color is off.
|
// returns its text unchanged exactly when color is off.
|
||||||
@@ -134,6 +129,14 @@ func NewConsoleSinkPlugin(
|
|||||||
return cs, nil
|
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
|
// Capabilities returns supported capabilities
|
||||||
func (cs *ConsoleSink) Capabilities() []core.Capability {
|
func (cs *ConsoleSink) Capabilities() []core.Capability {
|
||||||
return []core.Capability{
|
return []core.Capability{
|
||||||
@@ -228,7 +231,7 @@ func (cs *ConsoleSink) drain() {
|
|||||||
func (cs *ConsoleSink) write(event core.TransportEvent) {
|
func (cs *ConsoleSink) write(event core.TransportEvent) {
|
||||||
payload := event.Payload
|
payload := event.Payload
|
||||||
if cs.escape {
|
if cs.escape {
|
||||||
payload = escapeControls(payload)
|
payload = logline.EscapeControls(payload)
|
||||||
}
|
}
|
||||||
if cs.painter != nil {
|
if cs.painter != nil {
|
||||||
payload = paintLevel(cs.painter, payload, event.Entry.Level)
|
payload = paintLevel(cs.painter, payload, event.Entry.Level)
|
||||||
@@ -260,52 +263,3 @@ func paintLevel(p *inline.Printer, payload []byte, level string) []byte {
|
|||||||
}
|
}
|
||||||
return payload
|
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
|
|
||||||
}
|
|
||||||
@@ -7,28 +7,6 @@ import (
|
|||||||
"github.com/lixenwraith/terminal/inline"
|
"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
|
// 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.
|
// names it as a whole word, in any case; other words and levels stay plain.
|
||||||
func TestPaintLevelColorsTheFirstWholeName(t *testing.T) {
|
func TestPaintLevelColorsTheFirstWholeName(t *testing.T) {
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ func NewNullSinkPlugin(
|
|||||||
func (ns *NullSink) Capabilities() []core.Capability {
|
func (ns *NullSink) Capabilities() []core.Capability {
|
||||||
return []core.Capability{
|
return []core.Capability{
|
||||||
core.CapSessionAware,
|
core.CapSessionAware,
|
||||||
|
core.CapBackpressure, // it discards at once: a wait loses nothing, a drop would count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+188
-69
@@ -1,6 +1,7 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -13,64 +14,129 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
pitch = 3 // rows a source or sink takes, its gap included
|
gutter = 4 // columns of wire a side when the content does not fit
|
||||||
gutter = 4 // columns of wire on either side of the flow box
|
|
||||||
wideMin = 64 // the narrowest canvas with the parts side by side
|
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
|
// Widths beside the inspector: columns sized to their content within these,
|
||||||
type spot struct{ x, y, w int }
|
// 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,
|
// spot is where a node draws: its first row, the columns it may use, its
|
||||||
// stacked below that
|
// 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 {
|
type layout struct {
|
||||||
wide bool
|
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
|
title [3]spot
|
||||||
nodes [3][]spot // a column's nodes, or its one empty slot
|
nodes [3][]spot // a column's nodes, or its one empty slot
|
||||||
box spot // the flow box's top left and width
|
box spot // the flow box's top left and width
|
||||||
boxH int
|
boxH int
|
||||||
entry, exit int // the rows wires enter and leave the flow box
|
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 {
|
func (a *app) layout(w int) layout {
|
||||||
var l layout
|
l := layout{w: w}
|
||||||
counts := [3]int{}
|
beside := w >= besideMin
|
||||||
for col := range columns {
|
side, box := sideMin, boxMin
|
||||||
counts[col] = max(1, len(a.nodes(col)))
|
|
||||||
}
|
|
||||||
stages := counts[flowCol]
|
|
||||||
l.boxH = stages + 2
|
|
||||||
if w >= wideMin {
|
|
||||||
l.wide = true
|
|
||||||
boxW := max(18, min(30, (w-2*gutter)/3))
|
|
||||||
sideW := (w - boxW - 2*gutter) / 2
|
|
||||||
xs := [3]int{0, sideW + gutter, sideW + 2*gutter + boxW}
|
|
||||||
ws := [3]int{sideW, boxW, w - xs[sinksCol]}
|
|
||||||
for col := range columns {
|
|
||||||
l.title[col] = spot{xs[col], 0, ws[col]}
|
|
||||||
}
|
|
||||||
l.box = spot{xs[flowCol], 1, boxW}
|
|
||||||
for _, col := range []int{sourcesCol, sinksCol} {
|
for _, col := range []int{sourcesCol, sinksCol} {
|
||||||
for i := range counts[col] {
|
for _, n := range a.nodes(col) {
|
||||||
l.nodes[col] = append(l.nodes[col], spot{xs[col], 1 + i*pitch, ws[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.exit = l.entry
|
||||||
l.h = 1 + max(counts[sourcesCol]*pitch-1, l.boxH, counts[sinksCol]*pitch-1)
|
|
||||||
} else {
|
} else {
|
||||||
y := 0
|
y := 0
|
||||||
for col := range columns {
|
for col := range columns {
|
||||||
l.title[col] = spot{0, y, w}
|
l.title[col] = spot{y: y, w: l.w}
|
||||||
y++
|
y++
|
||||||
if col == flowCol {
|
if col == flowCol {
|
||||||
l.box = spot{0, y, w}
|
l.box = spot{y: y, w: l.w}
|
||||||
y += l.boxH
|
y += l.boxH
|
||||||
} else {
|
} else {
|
||||||
for range counts[col] {
|
for i := range l.nodes[col] {
|
||||||
l.nodes[col] = append(l.nodes[col], spot{0, y, w})
|
s := &l.nodes[col][i]
|
||||||
y += pitch
|
s.y, s.w = y, l.w
|
||||||
|
y += s.h + 1
|
||||||
}
|
}
|
||||||
y--
|
y--
|
||||||
}
|
}
|
||||||
@@ -80,18 +146,18 @@ func (a *app) layout(w int) layout {
|
|||||||
}
|
}
|
||||||
l.h = y
|
l.h = y
|
||||||
}
|
}
|
||||||
for i := range stages {
|
for i, s := range stages {
|
||||||
l.nodes[flowCol] = append(l.nodes[flowCol], spot{l.box.x + 2, l.box.y + 1 + i, l.box.w - 4})
|
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
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
// drawCanvas draws the pipeline, scrolled to keep the chosen node in view
|
// drawCanvas draws the pipeline, scrolled to keep the chosen node in view
|
||||||
func (a *app) drawCanvas(r ui.Region) {
|
func (a *app) drawCanvas(r ui.Region, l layout) {
|
||||||
l := a.layout(r.W)
|
|
||||||
chosen := l.nodes[a.col][min(a.row[a.col], len(l.nodes[a.col])-1)]
|
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.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) })
|
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)
|
a.drawEmpty(r, s, columns[col].role, chosen)
|
||||||
continue
|
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 {
|
if l.wide && len(nodes) > 0 {
|
||||||
join(g, l, col)
|
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)
|
r.TextStyled(l.box.x-1, l.entry, string(f.arrow), th.Border)
|
||||||
}
|
}
|
||||||
if len(a.nodes(sinksCol)) > 0 {
|
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
|
// join wires a column's nodes to a trunk midway along the wire, and the
|
||||||
// to the box's side
|
// trunk to the flow box's side: a source's wire starts past its text
|
||||||
func join(g ui.Wires, l layout, col int) {
|
func join(g ui.Wires, l layout, col int) {
|
||||||
ys := []int{l.entry}
|
ys := []int{l.entry}
|
||||||
if col == sinksCol {
|
if col == sinksCol {
|
||||||
@@ -144,16 +216,16 @@ func join(g ui.Wires, l layout, col int) {
|
|||||||
ys = append(ys, s.y)
|
ys = append(ys, s.y)
|
||||||
}
|
}
|
||||||
if col == sourcesCol {
|
if col == sourcesCol {
|
||||||
trunk := l.box.x - gutter + 1
|
trunk := l.box.x - 1 - l.wire/2
|
||||||
for _, s := range l.nodes[col] {
|
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.V(trunk, slices.Min(ys), slices.Max(ys))
|
||||||
g.H(l.entry, trunk, l.box.x-1)
|
g.H(l.entry, trunk, l.box.x-1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
right := l.box.x + l.box.w
|
right := l.box.x + l.box.w
|
||||||
trunk := right + 2
|
trunk := right + 1 + (l.wire-1)/2
|
||||||
g.H(l.exit, right, trunk)
|
g.H(l.exit, right, trunk)
|
||||||
g.V(trunk, slices.Min(ys), slices.Max(ys))
|
g.V(trunk, slices.Min(ys), slices.Max(ys))
|
||||||
for _, s := range l.nodes[col] {
|
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
|
// 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
|
// listens or dials, then its text. The chosen node is a bar across its rows,
|
||||||
// solid; a node a problem names has its bar in the error style.
|
// head wide on the first, whose tint block keeps its color; a node a problem
|
||||||
func (a *app) drawNode(r ui.Region, s spot, n compose.Node, tint ui.Style, chosen bool) {
|
// 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
|
th := a.th
|
||||||
bg, bar := th.Text, a.font.bar
|
bg, bar := th.Text, a.font.bar
|
||||||
if chosen {
|
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
|
tint = th.Error
|
||||||
}
|
}
|
||||||
p, _ := a.plugin(n)
|
p, _ := a.plugin(n)
|
||||||
way := map[config.Side]string{config.Listener: "listens", config.ChainListener: "listens", config.Dialer: "dials"}[p.Side]
|
verb, room := ways[p.Side], s.w-3
|
||||||
for dy, text := range []string{p.Type, a.summary(n)} {
|
if verb != "" {
|
||||||
row := r.Sub(s.x, s.y+dy, s.w, 1)
|
room -= 2 + ui.RuneLen(verb)
|
||||||
row.FillStyle(bg)
|
}
|
||||||
row.TextStyled(0, 0, string(bar), tint.On(bg))
|
for dy := range s.h {
|
||||||
style := th.Muted.On(bg)
|
w := s.w
|
||||||
if dy == 0 {
|
if dy == 0 {
|
||||||
style = th.Text.On(bg)
|
w = head
|
||||||
row.TextStyled(row.W-ui.RuneLen(way), 0, way, th.Muted.On(bg))
|
|
||||||
}
|
}
|
||||||
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) {
|
func (a *app) drawEmpty(r ui.Region, s spot, role string, chosen bool) {
|
||||||
mark, style := " ", a.th.Muted
|
mark, style := " ", a.th.Muted
|
||||||
if chosen {
|
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 := r.Sub(s.x, s.y, s.w, 1)
|
||||||
row.FillStyle(bg)
|
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 {
|
if chosen {
|
||||||
mark = f.barOn
|
mark = f.barOn
|
||||||
}
|
}
|
||||||
summary := a.summary(n)
|
|
||||||
if !a.on(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 {
|
if chosen {
|
||||||
mark = th.Glyphs.Pointer
|
mark = th.Glyphs.Pointer
|
||||||
}
|
}
|
||||||
@@ -229,10 +312,10 @@ func (a *app) drawBox(r ui.Region, l layout) {
|
|||||||
if a.faulty(n) {
|
if a.faulty(n) {
|
||||||
tint = th.Error
|
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))
|
row.TextStyled(2, 0, n.Role, text.On(bg))
|
||||||
x := 3 + ui.RuneLen(n.Role)
|
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
|
return on
|
||||||
}
|
}
|
||||||
|
|
||||||
// summary is a node's options in a line: the values set, a table by its
|
// summary is a node's options in a line: the values set, a table or a switch
|
||||||
// name, a switch by its key; the filters as +include and -exclude patterns
|
// by its name; the filters as +include and -exclude patterns
|
||||||
func (a *app) summary(n compose.Node) string {
|
func (a *app) summary(n compose.Node) string {
|
||||||
if n.Role == "filters" {
|
if n.Role == "filters" {
|
||||||
var out []string
|
var out []string
|
||||||
@@ -260,23 +343,59 @@ func (a *app) summary(n compose.Node) string {
|
|||||||
}
|
}
|
||||||
return strings.Join(out, " ")
|
return strings.Join(out, " ")
|
||||||
}
|
}
|
||||||
opts, _, _ := a.comp.Options(a.pi, n)
|
|
||||||
var out []string
|
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) {
|
for _, k := range a.keys(n) {
|
||||||
v, ok := opts[k.Name]
|
v, ok := opts[k.Name]
|
||||||
switch {
|
switch {
|
||||||
case !ok || k.Name == "enabled" || v == false:
|
case !ok || k.Name == "enabled" || v == false:
|
||||||
case k.Kind == "table":
|
case k.Kind == "table":
|
||||||
if t, _ := v.(map[string]any); t["enabled"] != false {
|
if t, _ := v.(map[string]any); t["enabled"] != false {
|
||||||
out = append(out, k.Name)
|
out = append(out, setting{key: k.Name})
|
||||||
}
|
}
|
||||||
case v == true:
|
case v == true:
|
||||||
out = append(out, k.Name)
|
out = append(out, setting{key: k.Name})
|
||||||
default:
|
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
|
// show writes a value as a form does: a list joined by commas
|
||||||
|
|||||||
+86
-11
@@ -1,6 +1,8 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -239,6 +241,7 @@ type form struct {
|
|||||||
focus ui.Focus
|
focus ui.Focus
|
||||||
scroll ui.ViewportScroll // the fields' rows
|
scroll ui.ViewportScroll // the fields' rows
|
||||||
err string
|
err string
|
||||||
|
mask bool // draws each rune typed as '*'
|
||||||
apply func(a *app, values map[string]string) error
|
apply func(a *app, values map[string]string) error
|
||||||
back func(a *app) // on Esc, after the form closes
|
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) {
|
view.Window(len(f.fields)*tall, &f.scroll, th.Text, func(all ui.Region) {
|
||||||
for i, p := range f.params {
|
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, _ := 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
|
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 {
|
func (a *app) outputMenu() dialog {
|
||||||
forms := compose.Forms()
|
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 {
|
for _, f := range forms {
|
||||||
options = append(options, ui.Option{Name: f.Name, Key: rune(f.Name[0]), Hint: "print " + f.Hint})
|
options = append(options, ui.Option{Name: f.Name, Key: rune(f.Name[0]), Hint: "print " + f.Hint})
|
||||||
}
|
}
|
||||||
list := ui.NewOptionListState(options)
|
list := ui.NewOptionListState(options)
|
||||||
list.Menu = true
|
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) {
|
pick: func(a *app, i int) {
|
||||||
if i == 0 {
|
switch i {
|
||||||
|
case 0:
|
||||||
a.run()
|
a.run()
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
a.status = err.Error()
|
a.status = err.Error()
|
||||||
return
|
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
|
// notice shows text, wrapped to the screen, until a key other than one
|
||||||
// that scrolls it
|
// that scrolls it
|
||||||
type notice struct {
|
type notice struct {
|
||||||
title string
|
title string
|
||||||
body func(w int) []string // the lines at a width
|
body func(w int) []string // the lines at a width
|
||||||
view ui.ViewportScroll
|
view ui.ViewportScroll
|
||||||
|
closed func(a *app) // after a key closed it
|
||||||
}
|
}
|
||||||
|
|
||||||
// textNotice is a notice of plain text, its paragraphs split at '\n'
|
// 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, ""):
|
case is(ev, terminal.KeyPageDown, ""):
|
||||||
n.view.PageDown()
|
n.view.PageDown()
|
||||||
default:
|
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
|
// help lists the keys of the diagram or, viewing, the view's, each beside
|
||||||
func help(f font) dialog {
|
// its description or, narrow, above it
|
||||||
|
func help(f font, viewing bool) dialog {
|
||||||
keys := [][2]string{
|
keys := [][2]string{
|
||||||
{"arrows, hjkl", "move; up from the top row to the pipeline bar"},
|
{"arrows, hjkl", "move; up from the top row to the pipeline bar"},
|
||||||
{f.enter, "inspect a part; edit a value; open a list; add"},
|
{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"},
|
{"[ ]", "previous or next pipeline"},
|
||||||
{"p", "start over from a preset"},
|
{"p", "start over from a preset"},
|
||||||
{"r", "run the pipelines"},
|
{"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"},
|
{"!", "every problem, moving to the first"},
|
||||||
{"paste", "an lw command line replaces the pipelines"},
|
{"paste", "an lw command line replaces the pipelines"},
|
||||||
{"q", "quit"},
|
{"q", "quit"},
|
||||||
}
|
}
|
||||||
|
if viewing {
|
||||||
|
keys = [][2]string{
|
||||||
|
{"arrows, jk", "move; up pauses following, down to the newest follows"},
|
||||||
|
{"g G", "the oldest row; the newest, following; f follows too"},
|
||||||
|
{f.enter, "the row's detail; J K scroll it"},
|
||||||
|
{"/", "only the rows matching a pattern; lower case ignores case"},
|
||||||
|
{"1-5 0", "trace, debug, info, warn or error and up; all"},
|
||||||
|
{"v", "all that reaches the point, what passed it, what it dropped"},
|
||||||
|
{"s", "look at a source, a flow stage, the sinks or lw's own log"},
|
||||||
|
{"i", "inspect the stage that dropped the row"},
|
||||||
|
{"c", "clear the rows"},
|
||||||
|
{"t esc", "back to the diagram; the pipelines keep running"},
|
||||||
|
{"q", "stop the pipelines and quit"},
|
||||||
|
}
|
||||||
|
}
|
||||||
return ¬ice{title: "Keys", body: func(w int) []string {
|
return ¬ice{title: "Keys", body: func(w int) []string {
|
||||||
var lines []string
|
var lines []string
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
|
|||||||
@@ -163,8 +163,9 @@ func (a *app) drawInspector(r ui.Region) {
|
|||||||
row := r.Sub(at/perCol*colW, 1+at%perCol, colW-1, 1)
|
row := r.Sub(at/perCol*colW, 1+at%perCol, colW-1, 1)
|
||||||
a.drawLine(row, lines[i], labelW, a.inspect && i == a.insp.cursor)
|
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 {
|
for i, h := range help {
|
||||||
r.TextStyled(2, r.H-len(help)+i, h, style)
|
r.TextStyled(2, under+i, h, style)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -10,49 +10,76 @@ import (
|
|||||||
ui "github.com/lixenwraith/terminal/tui"
|
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 {
|
type look struct {
|
||||||
ui.Theme
|
ui.Theme
|
||||||
Source, Flow, Sink ui.Style
|
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 (
|
var (
|
||||||
siteBg = color.RGB{R: 0x14, G: 0x17, B: 0x1d}
|
siteBg = color.RGB{R: 0x11, G: 0x11, B: 0x1b} // --dark-bg
|
||||||
sitePanel = color.RGB{R: 0x1c, G: 0x20, B: 0x28}
|
siteFg = color.RGB{R: 0xff, G: 0xff, B: 0xff} // --text-color
|
||||||
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}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var siteLook = look{
|
var siteLook = look{
|
||||||
Theme: ui.Theme{
|
Theme: ui.Theme{
|
||||||
Text: ui.Style{Fg: siteFg, Bg: siteBg},
|
Text: ui.Style{Fg: siteFg, Bg: siteBg},
|
||||||
Muted: ui.Style{Fg: siteMuted},
|
Muted: ui.Style{Fg: color.RGB{R: 0x94, G: 0xa3, B: 0xb8}}, // --muted-color
|
||||||
Accent: ui.Style{Fg: siteAccent, Attr: terminal.AttrBold},
|
Accent: ui.Style{Fg: color.RGB{R: 0x8e, G: 0xa2, B: 0xff}, Attr: terminal.AttrBold}, // --accent-color
|
||||||
Selected: ui.Style{Bg: color.RGB{R: 0x26, G: 0x2c, B: 0x38}},
|
Selected: ui.Style{Bg: color.RGB{R: 0x1d, G: 0x1c, B: 0x3e}}, // --card-background-color
|
||||||
Input: ui.Style{Bg: sitePanel},
|
Input: ui.Style{Bg: color.RGB{R: 0x1a, G: 0x1b, B: 0x26}}, // --dark-surface
|
||||||
Cursor: ui.Style{Fg: siteBg, Bg: siteFg},
|
Cursor: ui.Style{Fg: siteBg, Bg: siteFg},
|
||||||
Error: ui.Style{Fg: color.RGB{R: 0xff, G: 0x6b, B: 0x6b}},
|
Error: ui.Style{Fg: color.RGB{R: 0xf8, G: 0x71, B: 0x71}}, // --danger-color
|
||||||
Border: ui.Style{Fg: color.RGB{R: 0x5a, G: 0x63, B: 0x75}},
|
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}},
|
Source: ui.Style{Fg: color.RGB{R: 0x22, G: 0xd3, B: 0xee}}, // --hue-cyan
|
||||||
Flow: ui.Style{Fg: color.RGB{R: 0xb1, G: 0x8c, B: 0xff}},
|
Flow: ui.Style{Fg: color.RGB{R: 0xa7, G: 0x8b, B: 0xfa}}, // --hue-violet
|
||||||
Sink: ui.Style{Fg: color.RGB{R: 0x6f, G: 0xcf, B: 0x8f}},
|
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
|
// 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} }
|
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
|
// 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{
|
var looks = map[terminal.ColorMode]look{
|
||||||
terminal.ColorModeTrueColor: siteLook,
|
terminal.ColorModeTrueColor: siteLook,
|
||||||
terminal.ColorMode256: siteLook,
|
terminal.ColorMode256: siteLook,
|
||||||
terminal.ColorMode16: {Theme: ui.Theme16,
|
terminal.ColorMode16: {Theme: console16,
|
||||||
Source: ansi(color.ANSICyan), Flow: ansi(color.ANSIMagenta), Sink: ansi(color.ANSIGreen)},
|
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,
|
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;
|
// 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
|
arrow, down rune // into the flow box and a sink; between stacked parts
|
||||||
bar, barOn rune // a node, and the chosen node
|
bar, barOn rune // a node, and the chosen node
|
||||||
dot rune // before a column's title
|
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
|
valid string
|
||||||
invalid string
|
invalid string
|
||||||
enter 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
|
// fonts: Unicode, a text console's CP437, and ASCII outside UTF-8
|
||||||
var (
|
var (
|
||||||
unicodeFont = font{Glyphs: ui.GlyphsUnicode, box: ui.LineDashed,
|
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,
|
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{'…': '.'}}
|
folds: map[rune]rune{'…': '.'}}
|
||||||
asciiFont = font{Glyphs: ui.GlyphsASCII, box: ui.LineASCII,
|
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}
|
folds: map[rune]rune{'…': '.', '·': '-'}, ascii: true}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+107
-34
@@ -9,6 +9,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/lixenwraith/logwisp/internal/compose"
|
"github.com/lixenwraith/logwisp/internal/compose"
|
||||||
"github.com/lixenwraith/logwisp/internal/config"
|
"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
|
// Run composes c on the terminal until the user leaves; an empty c opens the
|
||||||
// preset menu. color is lw's color setting.
|
// preset menu. color is lw's color setting; runner runs the pipelines on t,
|
||||||
func Run(c *compose.Composition, color string) (Result, error) {
|
// and nothing runs once Run returns.
|
||||||
|
func Run(c *compose.Composition, color string, runner Runner) (Result, error) {
|
||||||
t := terminal.New(modes[color]...)
|
t := terminal.New(modes[color]...)
|
||||||
if err := t.Init(); err != nil {
|
if err := t.Init(); err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
@@ -52,14 +54,21 @@ func Run(c *compose.Composition, color string) (Result, error) {
|
|||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
a := newApp(c, looks[t.ColorMode()], fontFor(terminal.DetectColorMode()))
|
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()
|
w, h := t.Size()
|
||||||
for !a.done {
|
|
||||||
cells := make([]terminal.Cell, w*h)
|
cells := make([]terminal.Cell, w*h)
|
||||||
|
for !a.done {
|
||||||
a.paint(cells, w, h)
|
a.paint(cells, w, h)
|
||||||
t.Flush(cells, w, h)
|
t.Flush(cells, w, h)
|
||||||
switch ev := t.PollEvent(); ev.Type {
|
switch ev := t.PollEvent(); ev.Type {
|
||||||
case terminal.EventResize:
|
case terminal.EventResize:
|
||||||
w, h = ev.Width, ev.Height
|
w, h = ev.Width, ev.Height
|
||||||
|
cells = make([]terminal.Cell, w*h)
|
||||||
case terminal.EventClosed:
|
case terminal.EventClosed:
|
||||||
return Result{}, errors.New("the terminal closed")
|
return Result{}, errors.New("the terminal closed")
|
||||||
default:
|
default:
|
||||||
@@ -93,6 +102,9 @@ type app struct {
|
|||||||
inspect bool // keys go to the inspector
|
inspect bool // keys go to the inspector
|
||||||
insp inspector
|
insp inspector
|
||||||
dialog dialog // takes every key while open
|
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
|
status string // the last edit's refusal
|
||||||
note string // what the last action did
|
note string // what the last action did
|
||||||
problem error // the composition's problems, joined
|
problem error // the composition's problems, joined
|
||||||
@@ -102,7 +114,7 @@ type app struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newApp(c *compose.Composition, l look, f font) *app {
|
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.th.Glyphs = f.Glyphs
|
||||||
a.insp.open = map[string]bool{}
|
a.insp.open = map[string]bool{}
|
||||||
if len(c.Pipelines) == 0 {
|
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() }
|
func (a *app) check() { a.problem = a.comp.Validate() }
|
||||||
|
|
||||||
// handle routes an event: an open dialog takes all, then the inspector, then
|
// handle routes an event: the tick first, which the input never sends (the
|
||||||
// the canvas
|
// 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) {
|
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 = "", ""
|
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 {
|
switch {
|
||||||
case a.dialog != nil:
|
case a.dialog != nil:
|
||||||
a.dialog.handle(a, ev)
|
a.dialog.handle(a, ev)
|
||||||
|
case a.viewing:
|
||||||
|
a.viewKey(ev)
|
||||||
case ev.Type == terminal.EventPaste && a.insp.field != nil:
|
case ev.Type == terminal.EventPaste && a.insp.field != nil:
|
||||||
a.insp.field.Paste(ev.Text)
|
a.insp.field.Paste(ev.Text)
|
||||||
case ev.Type == terminal.EventPaste:
|
case ev.Type == terminal.EventPaste:
|
||||||
@@ -263,13 +287,15 @@ func (a *app) globalKey(ev terminal.Event) {
|
|||||||
case is(ev, terminal.KeyNone, "o"):
|
case is(ev, terminal.KeyNone, "o"):
|
||||||
a.dialog = a.outputMenu()
|
a.dialog = a.outputMenu()
|
||||||
case is(ev, terminal.KeyNone, "?"):
|
case is(ev, terminal.KeyNone, "?"):
|
||||||
a.dialog = help(a.font)
|
a.dialog = help(a.font, a.viewing)
|
||||||
case is(ev, terminal.KeyNone, "!"):
|
case is(ev, terminal.KeyNone, "!"):
|
||||||
a.showProblem()
|
a.showProblem()
|
||||||
case is(ev, terminal.KeyNone, "c"):
|
case is(ev, terminal.KeyNone, "c"):
|
||||||
a.changeType()
|
a.changeType()
|
||||||
case is(ev, terminal.KeyNone, "r"):
|
case is(ev, terminal.KeyNone, "r"):
|
||||||
a.run()
|
a.run()
|
||||||
|
case is(ev, terminal.KeyNone, "t"):
|
||||||
|
a.tap()
|
||||||
case is(ev, terminal.KeyNone, "]") && n > 0:
|
case is(ev, terminal.KeyNone, "]") && n > 0:
|
||||||
a.pi, a.row, a.inspect = (a.pi+1)%n, [3]int{}, false
|
a.pi, a.row, a.inspect = (a.pi+1)%n, [3]int{}, false
|
||||||
case is(ev, terminal.KeyNone, "[") && n > 0:
|
case is(ev, terminal.KeyNone, "[") && n > 0:
|
||||||
@@ -346,7 +372,7 @@ func (a *app) run() {
|
|||||||
a.status = "not valid yet: ! shows the problem"
|
a.status = "not valid yet: ! shows the problem"
|
||||||
return
|
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
|
// 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() {
|
func (a *app) quit() {
|
||||||
if !a.changed {
|
leave := func(a *app) { a.leave(Result{}) }
|
||||||
a.done = true
|
switch {
|
||||||
return
|
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
|
// 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
|
// draw draws the screen: a title row, the diagram, a status row, and any
|
||||||
// it from 110 columns or below it, a status row, and any dialog over them
|
// 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) {
|
func (a *app) draw(r ui.Region) {
|
||||||
r.FillStyle(a.th.Text)
|
r.FillStyle(a.th.Text)
|
||||||
status, style := a.bottom(r.W)
|
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.pi = min(a.pi, len(a.comp.Pipelines)-1)
|
||||||
a.node() // a delete can leave the row past its column's end
|
a.node() // a delete can leave the row past its column's end
|
||||||
a.drawBar(r.Sub(0, 0, r.W, 1))
|
a.drawBar(r.Sub(0, 0, r.W, 1))
|
||||||
body := r.Sub(0, 1, r.W, r.H-1-len(status))
|
body, l := r.Sub(0, 1, r.W, r.H-1-len(status)), a.layout(r.W)
|
||||||
if r.W >= 110 {
|
a.view.sync(a.point(), a.live.tap)
|
||||||
iw := min(60, r.W*2/5)
|
switch watched := a.viewing || a.live.tap != nil || len(a.view.rows) > 0; {
|
||||||
a.drawCanvas(body.Sub(0, 0, r.W-iw-1, body.H))
|
case watched && r.H >= splitMin && (!a.inspect || l.inspector > 0):
|
||||||
a.drawInspector(body.Sub(r.W-iw, 0, iw, body.H))
|
top := l.h
|
||||||
} else {
|
if a.inspect {
|
||||||
ch := min(a.layout(r.W).h, body.H-min(10, body.H/2))
|
top = max(top, 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))
|
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 {
|
for i, row := range status {
|
||||||
r.TextStyled(1, r.H-len(status)+i, l, style)
|
r.TextStyled(1, r.H-len(status)+i, row, style)
|
||||||
}
|
}
|
||||||
if a.status == "" && a.note == "" && a.problem == nil {
|
if a.status == "" && a.note == "" && a.problem == nil {
|
||||||
keys := a.hints(r.W)
|
x := 4 + ui.RuneLen(status[0])
|
||||||
r.TextStyled(r.W-ui.RuneLen(keys)-1, r.H-1, keys, a.th.Muted)
|
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 {
|
if a.dialog != nil {
|
||||||
a.dialog.draw(a, r)
|
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,
|
// 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
|
// 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) {
|
func (a *app) drawBar(r ui.Region) {
|
||||||
th, f := a.th, a.font
|
th, f := a.th, a.font
|
||||||
|
full, hint := r, ""
|
||||||
if a.bar {
|
if a.bar {
|
||||||
r.TextStyled(0, 0, string(f.Focus), th.Accent)
|
r.TextStyled(0, 0, string(f.Focus), th.Accent)
|
||||||
hint := "a add " + f.enter + " rename d delete"
|
|
||||||
if r.W >= 60 {
|
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)
|
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
|
x += ui.RuneLen(label) + 2
|
||||||
}
|
}
|
||||||
if len(names) < len(a.comp.Pipelines) {
|
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
|
// 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
|
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 {
|
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"
|
keys := "a add d delete " + a.font.space + " on/off t tap p preset o output ? help"
|
||||||
if ui.RuneLen(keys)+12 > w {
|
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"
|
keys = "? help"
|
||||||
}
|
}
|
||||||
return keys
|
return keys
|
||||||
|
|||||||
+186
-15
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/lixenwraith/logwisp/internal/compose"
|
"github.com/lixenwraith/logwisp/internal/compose"
|
||||||
"github.com/lixenwraith/logwisp/internal/config"
|
"github.com/lixenwraith/logwisp/internal/config"
|
||||||
|
|
||||||
|
"github.com/lixenwraith/color"
|
||||||
"github.com/lixenwraith/terminal"
|
"github.com/lixenwraith/terminal"
|
||||||
ui "github.com/lixenwraith/terminal/tui"
|
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
|
// 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
|
// sources and sinks and the width, and every source is wired from past its
|
||||||
// the flow box's arrow
|
// text to the flow box's arrow, and from the box to an arrow into each sink
|
||||||
func TestWiresJoinWhereTheyMeet(t *testing.T) {
|
func TestWiresJoinWhereTheyMeet(t *testing.T) {
|
||||||
arms := map[rune]uint8{'│': 5, '─': 10, '└': 3, '┌': 6, '├': 7, '┘': 9, '┴': 11, '┐': 12, '┤': 13, '┬': 14, '┼': 15, '►': 10}
|
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}}
|
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}
|
opposite := map[uint8]uint8{1: 4, 2: 8, 4: 1, 8: 2}
|
||||||
for n := range 16 {
|
for n := range 16 {
|
||||||
sources, sinks := slices.Repeat([]string{"null"}, n%4), slices.Repeat([]string{"null"}, n/4)
|
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))
|
a := mono(pipeline(t, sources, sinks))
|
||||||
l := a.layout(w)
|
l := a.layout(screen)
|
||||||
|
w := l.w
|
||||||
cells := make([]terminal.Cell, w*l.h)
|
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 {
|
glyph := func(x, y int) rune {
|
||||||
if x < 0 || x >= w || y < 0 || y >= l.h {
|
if x < 0 || x >= w || y < 0 || y >= l.h {
|
||||||
return ' '
|
return ' '
|
||||||
@@ -124,13 +126,13 @@ func TestWiresJoinWhereTheyMeet(t *testing.T) {
|
|||||||
walk(l.box.x-1, l.entry)
|
walk(l.box.x-1, l.entry)
|
||||||
walk(l.box.x+l.box.w+1, l.exit)
|
walk(l.box.x+l.box.w+1, l.exit)
|
||||||
for i := range sources {
|
for i := range sources {
|
||||||
if s := l.nodes[sourcesCol][i]; !reached[[2]int{s.x + s.w, s.y}] {
|
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", len(sources), len(sinks), w, i)
|
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 {
|
for i := range sinks {
|
||||||
if s := l.nodes[sinksCol][i]; !reached[[2]int{s.x - 1, s.y}] {
|
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", len(sources), len(sinks), w, i)
|
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
|
// 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) {
|
func TestLayoutFollowsTheWidth(t *testing.T) {
|
||||||
a := mono(pipeline(t, []string{"null"}, []string{"null"}))
|
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)
|
rows := text(render(a, w, 40), w)
|
||||||
at := func(s string) (int, int) {
|
at := func(s string) (int, int) {
|
||||||
for y, r := range rows {
|
for y, r := range rows {
|
||||||
@@ -163,12 +172,94 @@ func TestLayoutFollowsTheWidth(t *testing.T) {
|
|||||||
case ruleX != 0 || ruleY <= flowY:
|
case ruleX != 0 || ruleY <= flowY:
|
||||||
got = "lost"
|
got = "lost"
|
||||||
}
|
}
|
||||||
if got != want {
|
if got != want || got == "beside" && w-ruleX != beside[w] {
|
||||||
t.Errorf("%d columns: %s, want %s\n%s", w, got, want, strings.Join(rows, "\n"))
|
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
|
// 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
|
// sink's bar, a stage's on or off mark, an empty column's slot
|
||||||
func TestSelectionChangesAGlyph(t *testing.T) {
|
func TestSelectionChangesAGlyph(t *testing.T) {
|
||||||
@@ -181,7 +272,7 @@ func TestSelectionChangesAGlyph(t *testing.T) {
|
|||||||
l := a.layout(80)
|
l := a.layout(80)
|
||||||
at := func(col, row int) rune {
|
at := func(col, row int) rune {
|
||||||
cells := make([]terminal.Cell, 80*l.h)
|
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]
|
s := l.nodes[col][row]
|
||||||
return cells[s.y*80+s.x].Rune
|
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
|
// ASCII outside a UTF-8 locale, CP437 on a text console, else Unicode
|
||||||
func TestFontFollowsTheLocale(t *testing.T) {
|
func TestFontFollowsTheLocale(t *testing.T) {
|
||||||
for _, c := range []struct {
|
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) {
|
func TestCanvasKeepsTheChosenInView(t *testing.T) {
|
||||||
a := mono(pipeline(t, []string{"null"}, slices.Repeat([]string{"null"}, 8)))
|
a := mono(pipeline(t, []string{"null"}, slices.Repeat([]string{"null"}, 8)))
|
||||||
a.col, a.row[sinksCol] = sinksCol, 7
|
a.col, a.row[sinksCol] = sinksCol, 7
|
||||||
if screen := strings.Join(text(render(a, 80, 24), 80), "\n"); !strings.ContainsRune(screen, a.font.barOn) {
|
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)
|
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
|
// A notice taller than the screen scrolls with j and k and says where it is
|
||||||
|
|||||||
@@ -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},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user