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()
|
||||
}
|
||||
113
internal/ui/graph.go
Normal file
113
internal/ui/graph.go
Normal 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))
|
||||
}
|
||||
Reference in New Issue
Block a user