Files
statuspanel/internal/ui/app.go
2026-07-05 14:26:03 -03:00

277 lines
6.9 KiB
Go

// 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()
}