277 lines
6.9 KiB
Go
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()
|
|
}
|