primeiro commit
This commit is contained in:
276
internal/ui/app.go
Normal file
276
internal/ui/app.go
Normal file
@@ -0,0 +1,276 @@
|
||||
// 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()
|
||||
}
|
||||
Reference in New Issue
Block a user