primeiro commit

This commit is contained in:
2026-07-05 14:26:03 -03:00
commit 87a356c742
19 changed files with 1404 additions and 0 deletions

276
internal/ui/app.go Normal file
View 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()
}

113
internal/ui/graph.go Normal file
View File

@@ -0,0 +1,113 @@
package ui
import (
"math"
"sync"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
type rateSample struct {
inBitsPerSecond float64
outBitsPerSecond float64
}
// RateGraph draws inbound and outbound bit-rate history.
type RateGraph struct {
*tview.Box
mu sync.Mutex
samples []rateSample
maxSamples int
}
// NewRateGraph creates a graph primitive with bounded history.
func NewRateGraph(maxSamples int) *RateGraph {
graph := &RateGraph{
Box: tview.NewBox().SetBorder(true).SetTitle(" Interface traffic "),
maxSamples: maxSamples,
}
return graph
}
// AddSample appends one inbound/outbound sample to the graph.
func (g *RateGraph) AddSample(inBitsPerSecond float64, outBitsPerSecond float64) {
g.mu.Lock()
defer g.mu.Unlock()
g.samples = append(g.samples, rateSample{
inBitsPerSecond: inBitsPerSecond,
outBitsPerSecond: outBitsPerSecond,
})
if len(g.samples) > g.maxSamples {
g.samples = g.samples[len(g.samples)-g.maxSamples:]
}
}
// Draw renders the graph primitive.
func (g *RateGraph) Draw(screen tcell.Screen) {
g.Box.DrawForSubclass(screen, g)
x, y, width, height := g.GetInnerRect()
if width <= 0 || height <= 0 {
return
}
samples := g.copySamples()
if len(samples) == 0 {
return
}
maxRate := maxSampleRate(samples)
if maxRate <= 0 {
maxRate = 1
}
label := "in: green out: blue"
for i, r := range label {
if i >= width {
break
}
screen.SetContent(x+i, y, r, nil, tcell.StyleDefault.Foreground(tcell.ColorGray))
}
start := 0
if len(samples) > width {
start = len(samples) - width
}
plotHeight := height - 1
for col, sample := range samples[start:] {
drawPoint(screen, x+col, y+1, plotHeight, sample.inBitsPerSecond, maxRate, tcell.ColorGreen)
drawPoint(screen, x+col, y+1, plotHeight, sample.outBitsPerSecond, maxRate, tcell.ColorDodgerBlue)
}
}
func (g *RateGraph) copySamples() []rateSample {
g.mu.Lock()
defer g.mu.Unlock()
samples := make([]rateSample, len(g.samples))
copy(samples, g.samples)
return samples
}
func maxSampleRate(samples []rateSample) float64 {
var maxRate float64
for _, sample := range samples {
maxRate = math.Max(maxRate, sample.inBitsPerSecond)
maxRate = math.Max(maxRate, sample.outBitsPerSecond)
}
return maxRate
}
func drawPoint(screen tcell.Screen, x int, y int, height int, value float64, maxRate float64, color tcell.Color) {
if height <= 0 {
return
}
ratio := value / maxRate
if ratio < 0 {
ratio = 0
}
if ratio > 1 {
ratio = 1
}
row := height - 1 - int(math.Round(ratio*float64(height-1)))
screen.SetContent(x, y+row, '●', nil, tcell.StyleDefault.Foreground(color))
}