v0.33.0 tui tap for live view and console enhancements

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

No files matched your search

+316
View File
@@ -0,0 +1,316 @@
package tui
import (
"errors"
"fmt"
"maps"
"math"
"os"
"os/signal"
"slices"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/lixenwraith/logwisp/internal/compose"
"github.com/lixenwraith/logwisp/internal/config"
"github.com/lixenwraith/logwisp/internal/core"
ui "github.com/lixenwraith/terminal/tui"
)
// Runner runs pipelines in lw's process while the screen stays. Its calls
// block: the screen makes Start and Stop one at a time on a goroutine of its
// own, and reads State from another.
type Runner interface {
// Start runs specs in place of what runs, as a reload does, and returns
// the tap of what runs then: on an error the earlier run's when the new
// pipelines did not build, nil when nothing runs
Start(specs []config.Spec, ask Ask) (*core.Tap, error)
Stop()
State() RunState
}
// Ask answers a password prompt a terminal would show; with generate, an
// empty answer draws a password, shown once
type Ask func(user string, generate bool) ([]byte, error)
// RunState is what a run reports beside its tap
type RunState struct {
Counts map[string]Counts // by pipeline
Ended bool // every pipeline's input ended
Log *core.Tap // lw's own log, nil while it does not reach the terminal
}
// Counts are a pipeline's entries: in from its sources, dropped by its flow,
// and dropped by sinks that could not take them
type Counts struct{ In, Filtered, SinksDropped uint64 }
func (s RunState) same(o RunState) bool {
return s.Ended == o.Ended && s.Log == o.Log && maps.Equal(s.Counts, o.Counts)
}
// run is the screen's side of a Runner. A worker makes the calls; what they
// bring back waits in later for a tick, which wake posts once until handled.
type run struct {
runner Runner
post func() // posts a tick to the screen
jobs chan func()
worker sync.Once
busy sync.WaitGroup
mu sync.Mutex
later []func(a *app)
posted atomic.Bool
gone chan struct{} // closed when the screen leaves: asks fail
ended sync.Once
fed atomic.Pointer[core.Tap] // the run's tap, for the watch
// the screen's goroutine only
op string // "starting" or "stopping" while a job runs
specs []config.Spec // what runs, nil while nothing does
tap *core.Tap
state RunState
leaving bool
}
func newRun(runner Runner, post func()) *run {
return &run{runner: runner, post: post, jobs: make(chan func(), 4), gone: make(chan struct{})}
}
// do runs job on the worker, after the jobs before it
func (r *run) do(job func()) {
r.worker.Do(func() {
go func() {
for job := range r.jobs {
job()
r.busy.Done()
}
}()
})
r.busy.Add(1)
r.jobs <- job
}
// send has f run on the screen's goroutine at the next tick
func (r *run) send(f func(a *app)) {
r.mu.Lock()
r.later = append(r.later, f)
r.mu.Unlock()
r.wake()
}
// wake posts a tick unless one is waiting
func (r *run) wake() {
if r.posted.CompareAndSwap(false, true) {
r.post()
}
}
// watch wakes the screen while a tap holds records, every drainEvery at
// most, and reads the run's state every so often, waking the screen only
// when it changed; until the screen leaves
func (r *run) watch(every time.Duration) {
pace, state := time.NewTicker(drainEvery), time.NewTicker(every)
defer pace.Stop()
defer state.Stop()
var last RunState
for {
select {
case <-r.gone:
return
case <-pace.C:
if holds(r.fed.Load()) || holds(last.Log) {
r.wake()
}
case <-state.C:
if st := r.runner.State(); !st.same(last) {
last = st
r.send(func(a *app) { a.live.state = st })
}
}
}
}
func holds(t *core.Tap) bool { return t != nil && len(t.Records()) > 0 }
// onSignals leaves on SIGTERM and SIGHUP (an ssh session's end), stopping
// what runs, so sinks flush and the terminal is restored. A closed stdout or
// stderr fails the write with EPIPE, which the run logs, instead of killing lw
// on screen; stop gives SIGPIPE back to the runtime.
func (r *run) onSignals() (stop func()) {
sigs, pipe := make(chan os.Signal, 1), make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGTERM, syscall.SIGHUP)
signal.Notify(pipe, syscall.SIGPIPE)
go func() {
select {
case <-sigs:
r.send(func(a *app) { a.leave(Result{}) })
case <-r.gone:
}
}()
return func() { signal.Stop(sigs); signal.Stop(pipe) }
}
func (r *run) endAsks() { r.ended.Do(func() { close(r.gone) }) }
// close fails the asks, waits for the worker and stops what still runs
func (r *run) close() {
r.endAsks()
r.busy.Wait()
if r.runner != nil && !r.leaving {
r.runner.Stop()
}
close(r.jobs)
}
// active reports a run, or one starting
func (r *run) active() bool { return r.specs != nil || r.op != "" }
// ask is the Ask Start gets: the worker waits while the screen asks in a
// masked form, until it is answered or the screen leaves
func (r *run) ask(user string, generate bool) ([]byte, error) {
reply := make(chan []byte, 1)
r.send(func(a *app) { a.askPassword(user, generate, reply) })
select {
case pw := <-reply:
if pw == nil {
return nil, errors.New("no password given")
}
return pw, nil
case <-r.gone:
return nil, errors.New("the screen closed")
}
}
// tick runs what the worker and the state watch brought, then reads the
// taps into the view; before any key handling, so it neither clears the
// status nor reaches a text field
func (a *app) tick() {
r := a.live
r.posted.Store(false)
r.mu.Lock()
later := r.later
r.later = nil
r.mu.Unlock()
for _, f := range later {
f(a)
}
a.drain(drainBytes, r.state.Log, r.tap)
}
// tap runs the pipelines here, the screen staying, and gives the view the
// keys: t. After an edit it runs them again in place of the earlier ones, as
// a reload does.
func (a *app) tap() {
r := a.live
switch {
case r.runner == nil:
a.note = "t runs the pipelines here only from lw --tui"
return
case a.problem != nil:
a.status = "not valid yet: ! shows the problem"
return
case r.op != "":
a.note = r.op + "…"
return
}
specs := config.PipelineSpecs(a.comp.Pipelines)
if r.specs != nil && slices.Equal(specs, r.specs) {
a.viewing = true
return
}
r.op = "starting"
r.do(func() {
tap, err := r.runner.Start(specs, r.ask)
r.send(func(a *app) { a.started(specs, tap, err) })
})
}
// started applies Start's outcome: what runs, and the view, or why the new
// pipelines do not, on the part that failed. An earlier run that stopped is
// read whole first, so a rule in the view marks where the next began.
func (a *app) started(specs []config.Spec, tap *core.Tap, err error) {
r := a.live
if r.leaving {
return
}
if old := r.tap; old != nil && old != tap {
a.drain(math.MaxInt, old)
mark := "rerun "
if tap == nil {
mark = "stopped "
}
a.view.mark(mark + time.Now().Format("15:04:05"))
}
r.op, r.tap = "", tap
r.fed.Store(tap)
switch {
case err == nil:
r.specs, a.viewing = specs, true
case tap != nil:
a.fault(err, "not rerun: %s; the earlier pipelines still run")
default:
r.specs = nil
a.fault(err, "%s")
}
}
// fault chooses the part a start error names, placed as a problem is, and
// shows the error in format
func (a *app) fault(err error, format string) {
text := err.Error()
if pi, n, key, why, ok := compose.Locate(err); ok && pi >= 0 && pi < len(a.comp.Pipelines) {
if pi != a.pi {
a.pi, a.row = pi, [3]int{}
}
a.bar, a.inspect = false, false
a.choose(n)
text = why
if where := a.where(pi, n, key); where != "" {
text = where + ": " + why
}
}
a.dialog = textNotice("Not started", fmt.Sprintf(format, text))
}
// leave ends the screen with res once nothing runs: q, r and o's choices
func (a *app) leave(res Result) {
r := a.live
a.result, a.dialog = res, nil
if !r.active() {
a.done = true
return
}
r.leaving, r.op, a.note = true, "stopping", "stopping the pipelines…"
r.endAsks()
r.do(func() {
r.runner.Stop()
r.send(func(a *app) { a.done = true })
})
}
// runMark says on the bar what runs: a call underway, or the counts of the
// pipeline shown
func (a *app) runMark() (string, ui.Style) {
r := a.live
switch {
case r.op != "":
return r.op + "…", a.th.Muted
case r.specs == nil:
return "", a.th.Muted
}
c := r.state.Counts[a.pipeline().Name]
mark, style := string(a.font.arrow)+" running", a.th.Accent
if r.state.Ended {
mark, style = "input ended", a.th.Muted
}
mark += fmt.Sprintf(" · %d in · %d filtered", c.In, c.Filtered)
if c.SinksDropped > 0 {
mark += fmt.Sprintf(" · %d sinks dropped", c.SinksDropped)
}
if r.tap != nil && r.tap.Dropped() > 0 {
mark += fmt.Sprintf(" · screen missed %d", r.tap.Dropped())
}
return mark, style
}