// Package ui renders the statuspanel terminal user interface. package ui import ( "context" "fmt" "strings" "time" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "statuspanel/internal/config" "statuspanel/internal/probe" ) const graphHistory = 120 // App coordinates probes and terminal rendering. type App struct { cfg config.Config application *tview.Application summary *tview.TextView table *tview.Table graph *RateGraph } type snapshot struct { gatewayAddress string gateway probe.PingResult dns probe.DNSResult inRate float64 outRate float64 statsErr error gatewayErr error targets []targetSnapshot } type targetSnapshot struct { target config.PingTarget result probe.PingResult } // New constructs a statuspanel TUI application. func New(cfg config.Config) *App { app := &App{ cfg: cfg, application: tview.NewApplication(), summary: tview.NewTextView().SetDynamicColors(true).SetWrap(false), table: tview.NewTable().SetBorders(false).SetFixed(1, 0), graph: NewRateGraph(graphHistory), } app.summary.SetBorder(true).SetTitle(" Status ") app.table.SetBorder(true).SetTitle(" Ping targets ") root := tview.NewFlex().SetDirection(tview.FlexRow). AddItem(app.summary, 7, 0, false). AddItem(app.graph, 0, 1, false). AddItem(app.table, 0, 1, false) app.application.SetRoot(root, true).EnableMouse(false) app.application.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Key() == tcell.KeyCtrlC || event.Rune() == 'q' { app.application.Stop() return nil } return event }) return app } // Run starts the TUI and refresh loop until the context is canceled. func (a *App) Run(ctx context.Context) error { ctx, cancel := context.WithCancel(ctx) defer cancel() go a.refreshLoop(ctx) go func() { <-ctx.Done() a.application.Stop() }() if err := a.application.Run(); err != nil { return fmt.Errorf("running terminal application: %w", err) } return nil } func (a *App) refreshLoop(ctx context.Context) { var previousStats probe.InterfaceStats var previousTime time.Time a.refreshOnce(ctx, &previousStats, &previousTime) ticker := time.NewTicker(a.cfg.Refresh.Duration) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: a.refreshOnce(ctx, &previousStats, &previousTime) } } } func (a *App) refreshOnce(ctx context.Context, previousStats *probe.InterfaceStats, previousTime *time.Time) { snap := a.collect(ctx, previousStats, previousTime) a.application.QueueUpdateDraw(func() { a.render(snap) }) } func (a *App) collect(ctx context.Context, previousStats *probe.InterfaceStats, previousTime *time.Time) snapshot { snap := snapshot{ targets: make([]targetSnapshot, 0, len(a.cfg.Targets)), } now := time.Now() stats, err := probe.ReadInterfaceStats(ctx, a.cfg.Interface) if err != nil { snap.statsErr = err } else { if !previousTime.IsZero() && previousStats.Name == stats.Name { elapsed := now.Sub(*previousTime).Seconds() if elapsed > 0 { snap.inRate = byteDelta(previousStats.RXBytes, stats.RXBytes) * 8 / elapsed snap.outRate = byteDelta(previousStats.TXBytes, stats.TXBytes) * 8 / elapsed } } *previousStats = stats *previousTime = now } gateway := a.cfg.Gateway if gateway == "" { ip, gatewayErr := probe.GatewayForInterface(ctx, a.cfg.Interface) if gatewayErr != nil { snap.gatewayErr = gatewayErr } else { gateway = ip.String() } } snap.gatewayAddress = gateway if gateway != "" { snap.gateway = probe.Ping(ctx, gateway, a.cfg.Ping.Timeout.Duration) } snap.dns = probe.Resolve(ctx, a.cfg.DNS.Server, a.cfg.DNS.Domain, a.cfg.DNS.Timeout.Duration) for _, target := range a.cfg.Targets { snap.targets = append(snap.targets, targetSnapshot{ target: target, result: probe.Ping(ctx, target.Address, a.cfg.Ping.Timeout.Duration), }) } return snap } func (a *App) render(snap snapshot) { a.graph.AddSample(snap.inRate, snap.outRate) a.summary.SetText(summaryText(a.cfg, snap)) renderTargets(a.table, snap.targets) } func summaryText(cfg config.Config, snap snapshot) string { traffic := fmt.Sprintf("Traffic: in [green]%s[-] out [dodgerblue]%s[-]", probe.HumanizeBitsPerSecond(snap.inRate), probe.HumanizeBitsPerSecond(snap.outRate)) if snap.statsErr != nil { traffic = fmt.Sprintf("Traffic: [red]%s[-]", snap.statsErr) } gatewayAddress := snap.gatewayAddress if gatewayAddress == "" { gatewayAddress = "-" } lines := []string{ fmt.Sprintf("Interface: [white]%s[-] Refresh: [white]%s[-]", cfg.Interface, cfg.Refresh.Duration), traffic, fmt.Sprintf("Gateway: %s %s %s", yesNo(snap.gateway.Reachable), gatewayAddress, rttOrError(snap.gateway.RTT, firstErr(snap.gateway.Err, snap.gatewayErr))), fmt.Sprintf("DNS: %s %s", yesNo(snap.dns.Reachable), dnsDetail(cfg, snap.dns)), "Quit: q or Ctrl-C", } return strings.Join(lines, "\n") } func renderTargets(table *tview.Table, targets []targetSnapshot) { table.Clear() headers := []string{"Name", "Address", "Reachable", "Round Trip", "Error"} for col, header := range headers { table.SetCell(0, col, tview.NewTableCell(header). SetTextColor(tcell.ColorYellow). SetSelectable(false). SetExpansion(1)) } for row, target := range targets { result := target.result name := target.target.Name if name == "" { name = target.target.Address } values := []string{ name, target.target.Address, plainYesNo(result.Reachable), durationOrDash(result.RTT), errorText(result.Err), } for col, value := range values { color := tcell.ColorWhite if col == 2 { color = reachabilityColor(result.Reachable) } table.SetCell(row+1, col, tview.NewTableCell(value).SetTextColor(color).SetExpansion(1)) } } } func byteDelta(previous uint64, current uint64) float64 { if current < previous { return 0 } return float64(current - previous) } func yesNo(ok bool) string { if ok { return "[green]yes[-]" } return "[red]no[-]" } func plainYesNo(ok bool) string { if ok { return "yes" } return "no" } func reachabilityColor(ok bool) tcell.Color { if ok { return tcell.ColorGreen } return tcell.ColorRed } func rttOrError(rtt time.Duration, err error) string { if err != nil { return fmt.Sprintf("[red]%s[-]", err) } return durationOrDash(rtt) } func durationOrDash(duration time.Duration) string { if duration <= 0 { return "-" } return duration.Round(time.Millisecond).String() } func dnsDetail(cfg config.Config, result probe.DNSResult) string { if result.Err != nil { return fmt.Sprintf("[red]%s[-]", result.Err) } return fmt.Sprintf("%s via %s in %s", cfg.DNS.Domain, cfg.DNS.Server, durationOrDash(result.Duration)) } func firstErr(errs ...error) error { for _, err := range errs { if err != nil { return err } } return nil } func errorText(err error) string { if err == nil { return "" } return err.Error() }