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

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
/statuspanel
/bin/
/dist/
*.test
coverage.out
AGENTS.md

13
Makefile Normal file
View File

@@ -0,0 +1,13 @@
BINARY := statuspanel
CMD := ./cmd/statuspanel
.PHONY: build test clean
build:
CGO_ENABLED=0 go build -o $(BINARY) $(CMD)
test:
go test ./...
clean:
rm -f $(BINARY)

48
README.md Normal file
View File

@@ -0,0 +1,48 @@
# statuspanel
`statuspanel` is a pure-Go terminal status panel for a network interface. It uses
`github.com/rivo/tview` for the TUI and reads YAML configuration from
`/etc/statuspanel.yaml` by default.
## Build
```sh
go build ./cmd/statuspanel
```
ICMP ping uses raw sockets. On Linux, run as root or grant the binary the
capability:
```sh
sudo setcap cap_net_raw+ep ./statuspanel
```
## Run
```sh
./statuspanel -config ./statuspanel.yaml
```
Press `q` or `Ctrl-C` to quit.
## Configuration
```yaml
interface: eth0
gateway: "" # optional; when empty, statuspanel discovers the default gateway for interface
refresh: 2s
dns:
server: 1.1.1.1
domain: example.com
timeout: 1s
ping:
timeout: 1s
targets:
- name: Cloudflare DNS
address: 1.1.1.1
- name: Google DNS
address: 8.8.8.8
```

38
cmd/statuspanel/main.go Normal file
View File

@@ -0,0 +1,38 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"statuspanel/internal/config"
"statuspanel/internal/ui"
)
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "statuspanel: %v\n", err)
os.Exit(1)
}
}
func run() error {
configPath := flag.String("config", config.DefaultPath, "path to YAML configuration")
flag.Parse()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
cfg, err := config.Load(ctx, *configPath)
if err != nil {
return err
}
app := ui.New(cfg)
if err := app.Run(ctx); err != nil {
return err
}
return nil
}

19
go.mod Normal file
View File

@@ -0,0 +1,19 @@
module statuspanel
go 1.22
require (
github.com/gdamore/tcell/v2 v2.8.1
github.com/rivo/tview v0.42.1-0.20260316130009-63ee97f9e014
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/gdamore/encoding v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
)

84
go.sum Normal file
View File

@@ -0,0 +1,84 @@
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU=
github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/rivo/tview v0.42.1-0.20260316130009-63ee97f9e014 h1:t5NQ0p/bgrf96F27S7UwBD4/yTdxDEOWdTvs0CJZMpE=
github.com/rivo/tview v0.42.1-0.20260316130009-63ee97f9e014/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

131
internal/config/config.go Normal file
View File

@@ -0,0 +1,131 @@
// Package config loads and validates statuspanel YAML configuration.
package config
import (
"context"
"errors"
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
)
const (
// DefaultPath is the default configuration path used by statuspanel.
DefaultPath = "/etc/statuspanel.yaml"
)
// Duration wraps time.Duration with YAML string unmarshalling.
type Duration struct {
time.Duration
}
// UnmarshalYAML decodes duration strings such as "2s" or "500ms".
func (d *Duration) UnmarshalYAML(value *yaml.Node) error {
if value.Kind != yaml.ScalarNode {
return fmt.Errorf("duration must be a scalar")
}
parsed, err := time.ParseDuration(value.Value)
if err != nil {
return fmt.Errorf("parsing duration %q: %w", value.Value, err)
}
d.Duration = parsed
return nil
}
// Config describes statuspanel runtime configuration.
type Config struct {
Interface string `yaml:"interface"`
Gateway string `yaml:"gateway"`
Refresh Duration `yaml:"refresh"`
DNS DNSConfig `yaml:"dns"`
Ping PingConfig `yaml:"ping"`
Targets []PingTarget `yaml:"targets"`
}
// DNSConfig describes the DNS probe.
type DNSConfig struct {
Server string `yaml:"server"`
Domain string `yaml:"domain"`
Timeout Duration `yaml:"timeout"`
}
// PingConfig describes ICMP ping behavior.
type PingConfig struct {
Timeout Duration `yaml:"timeout"`
}
// PingTarget describes a host displayed in the reachability table.
type PingTarget struct {
Name string `yaml:"name"`
Address string `yaml:"address"`
}
// Default returns a configuration with safe runtime defaults.
func Default() Config {
return Config{
Refresh: Duration{Duration: 2 * time.Second},
DNS: DNSConfig{
Timeout: Duration{Duration: time.Second},
},
Ping: PingConfig{
Timeout: Duration{Duration: time.Second},
},
}
}
// Load reads and validates a YAML configuration file.
func Load(ctx context.Context, path string) (Config, error) {
if path == "" {
path = DefaultPath
}
if err := ctx.Err(); err != nil {
return Config{}, fmt.Errorf("checking context before loading config: %w", err)
}
data, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("reading config %s: %w", path, err)
}
if err := ctx.Err(); err != nil {
return Config{}, fmt.Errorf("checking context after loading config: %w", err)
}
cfg := Default()
if err := yaml.Unmarshal(data, &cfg); err != nil {
return Config{}, fmt.Errorf("parsing config %s: %w", path, err)
}
if err := cfg.Validate(); err != nil {
return Config{}, fmt.Errorf("validating config %s: %w", path, err)
}
return cfg, nil
}
// Validate checks whether the configuration can drive all probes.
func (c Config) Validate() error {
var errs []error
if c.Interface == "" {
errs = append(errs, errors.New("interface is required"))
}
if c.Refresh.Duration <= 0 {
errs = append(errs, errors.New("refresh must be greater than zero"))
}
if c.DNS.Server == "" {
errs = append(errs, errors.New("dns.server is required"))
}
if c.DNS.Domain == "" {
errs = append(errs, errors.New("dns.domain is required"))
}
if c.DNS.Timeout.Duration <= 0 {
errs = append(errs, errors.New("dns.timeout must be greater than zero"))
}
if c.Ping.Timeout.Duration <= 0 {
errs = append(errs, errors.New("ping.timeout must be greater than zero"))
}
for i, target := range c.Targets {
if target.Address == "" {
errs = append(errs, fmt.Errorf("targets[%d].address is required", i))
}
}
return errors.Join(errs...)
}

View File

@@ -0,0 +1,90 @@
package config_test
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"statuspanel/internal/config"
)
func TestLoad(t *testing.T) {
t.Run("loads valid yaml with defaults", func(t *testing.T) {
path := writeConfig(t, `
interface: eth0
dns:
server: 1.1.1.1
domain: example.com
targets:
- name: router
address: 192.0.2.1
`)
cfg, err := config.Load(context.Background(), path)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Interface != "eth0" {
t.Fatalf("Interface = %q, want eth0", cfg.Interface)
}
if cfg.Refresh.Duration != 2*time.Second {
t.Fatalf("Refresh = %v, want 2s", cfg.Refresh.Duration)
}
if cfg.Ping.Timeout.Duration != time.Second {
t.Fatalf("Ping timeout = %v, want 1s", cfg.Ping.Timeout.Duration)
}
})
t.Run("rejects missing required values", func(t *testing.T) {
path := writeConfig(t, `refresh: 1s`)
_, err := config.Load(context.Background(), path)
if err == nil {
t.Fatal("Load() error = nil, want validation error")
}
for _, want := range []string{"interface is required", "dns.server is required", "dns.domain is required"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("Load() error = %q, want containing %q", err, want)
}
}
})
}
func TestDurationUnmarshalYAML(t *testing.T) {
path := writeConfig(t, `
interface: eth0
refresh: 500ms
dns:
server: 1.1.1.1
domain: example.com
timeout: 250ms
ping:
timeout: 750ms
`)
cfg, err := config.Load(context.Background(), path)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Refresh.Duration != 500*time.Millisecond {
t.Fatalf("Refresh = %v, want 500ms", cfg.Refresh.Duration)
}
if cfg.DNS.Timeout.Duration != 250*time.Millisecond {
t.Fatalf("DNS timeout = %v, want 250ms", cfg.DNS.Timeout.Duration)
}
if cfg.Ping.Timeout.Duration != 750*time.Millisecond {
t.Fatalf("Ping timeout = %v, want 750ms", cfg.Ping.Timeout.Duration)
}
}
func writeConfig(t *testing.T, contents string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "statuspanel.yaml")
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatalf("writing config: %v", err)
}
return path
}

52
internal/probe/dns.go Normal file
View File

@@ -0,0 +1,52 @@
package probe
import (
"context"
"fmt"
"net"
"net/netip"
"time"
)
// DNSResult contains the outcome of one DNS lookup.
type DNSResult struct {
Reachable bool
Duration time.Duration
IPs []net.IP
Err error
}
// Resolve checks whether a domain can be resolved against a specific DNS server.
func Resolve(ctx context.Context, server string, domain string, timeout time.Duration) DNSResult {
start := time.Now()
lookupCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
dialer := net.Dialer{Timeout: timeout}
resolver := net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network string, address string) (net.Conn, error) {
conn, err := dialer.DialContext(ctx, network, dnsServerAddress(server))
if err != nil {
return nil, fmt.Errorf("dialing dns server %s: %w", server, err)
}
return conn, nil
},
}
ips, err := resolver.LookupIP(lookupCtx, "ip", domain)
duration := time.Since(start)
if err != nil {
return DNSResult{Duration: duration, Err: fmt.Errorf("resolving %s with %s: %w", domain, server, err)}
}
return DNSResult{Reachable: len(ips) > 0, Duration: duration, IPs: ips}
}
func dnsServerAddress(server string) string {
if _, err := netip.ParseAddrPort(server); err == nil {
return server
}
if addr, err := netip.ParseAddr(server); err == nil {
return net.JoinHostPort(addr.String(), "53")
}
return net.JoinHostPort(server, "53")
}

2
internal/probe/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package probe provides network, DNS, route, and interface probes for statuspanel.
package probe

View File

@@ -0,0 +1,23 @@
package probe
import "fmt"
// HumanizeBitsPerSecond formats a bit rate as kilobit, megabit, or gigabit.
func HumanizeBitsPerSecond(bitsPerSecond float64) string {
const (
kilobit = 1000
megabit = 1000 * kilobit
gigabit = 1000 * megabit
)
switch {
case bitsPerSecond >= gigabit:
return fmt.Sprintf("%.2f Gbit/s", bitsPerSecond/gigabit)
case bitsPerSecond >= megabit:
return fmt.Sprintf("%.2f Mbit/s", bitsPerSecond/megabit)
case bitsPerSecond >= kilobit:
return fmt.Sprintf("%.2f kbit/s", bitsPerSecond/kilobit)
default:
return fmt.Sprintf("%.0f bit/s", bitsPerSecond)
}
}

View File

@@ -0,0 +1,71 @@
package probe
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"os"
"strconv"
"strings"
)
const netDevPath = "/proc/net/dev"
// InterfaceStats contains received and transmitted byte counters.
type InterfaceStats struct {
Name string
RXBytes uint64
TXBytes uint64
}
// ReadInterfaceStats reads byte counters for a network interface.
func ReadInterfaceStats(ctx context.Context, name string) (InterfaceStats, error) {
if err := ctx.Err(); err != nil {
return InterfaceStats{}, fmt.Errorf("checking context before reading interface stats: %w", err)
}
data, err := os.ReadFile(netDevPath)
if err != nil {
return InterfaceStats{}, fmt.Errorf("reading %s: %w", netDevPath, err)
}
if err := ctx.Err(); err != nil {
return InterfaceStats{}, fmt.Errorf("checking context after reading interface stats: %w", err)
}
stats, err := parseNetDev(data, name)
if err != nil {
return InterfaceStats{}, err
}
return stats, nil
}
func parseNetDev(data []byte, name string) (InterfaceStats, error) {
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.Contains(line, ":") {
continue
}
parts := strings.SplitN(line, ":", 2)
if strings.TrimSpace(parts[0]) != name {
continue
}
fields := strings.Fields(parts[1])
if len(fields) < 16 {
return InterfaceStats{}, fmt.Errorf("parsing interface %s stats: expected at least 16 fields, got %d", name, len(fields))
}
rxBytes, err := strconv.ParseUint(fields[0], 10, 64)
if err != nil {
return InterfaceStats{}, fmt.Errorf("parsing interface %s rx bytes: %w", name, err)
}
txBytes, err := strconv.ParseUint(fields[8], 10, 64)
if err != nil {
return InterfaceStats{}, fmt.Errorf("parsing interface %s tx bytes: %w", name, err)
}
return InterfaceStats{Name: name, RXBytes: rxBytes, TXBytes: txBytes}, nil
}
if err := scanner.Err(); err != nil {
return InterfaceStats{}, fmt.Errorf("scanning %s contents: %w", netDevPath, err)
}
return InterfaceStats{}, errors.New("interface not found")
}

153
internal/probe/ping.go Normal file
View File

@@ -0,0 +1,153 @@
package probe
import (
"context"
"crypto/rand"
"encoding/binary"
"errors"
"fmt"
"net"
"time"
)
const (
icmpEchoReply = 0
icmpEcho = 8
)
// PingResult contains the outcome of one ICMP echo request.
type PingResult struct {
Reachable bool
RTT time.Duration
Err error
}
// Ping sends one ICMP echo request to an IPv4 host.
func Ping(ctx context.Context, host string, timeout time.Duration) (result PingResult) {
start := time.Now()
ip, err := resolveIPv4(ctx, host)
if err != nil {
return PingResult{Err: err}
}
conn, err := net.ListenPacket("ip4:icmp", "0.0.0.0")
if err != nil {
return PingResult{Err: fmt.Errorf("opening icmp socket: %w", err)}
}
defer func() {
if closeErr := conn.Close(); closeErr != nil && result.Err == nil {
result = PingResult{Err: fmt.Errorf("closing icmp socket: %w", closeErr)}
}
}()
deadline := time.Now().Add(timeout)
if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
deadline = ctxDeadline
}
if err := conn.SetDeadline(deadline); err != nil {
return PingResult{Err: fmt.Errorf("setting icmp deadline: %w", err)}
}
id, seq, err := echoIdentifiers()
if err != nil {
return PingResult{Err: err}
}
packet := echoPacket(id, seq)
if _, err := conn.WriteTo(packet, &net.IPAddr{IP: ip}); err != nil {
return PingResult{Err: fmt.Errorf("sending icmp echo to %s: %w", host, err)}
}
buffer := make([]byte, 1500)
for {
n, addr, err := conn.ReadFrom(buffer)
if err != nil {
if ctxErr := ctx.Err(); ctxErr != nil {
return PingResult{Err: fmt.Errorf("ping canceled: %w", ctxErr)}
}
return PingResult{Err: fmt.Errorf("reading icmp reply from %s: %w", host, err)}
}
if !sameIP(addr, ip) {
continue
}
if echoReplyMatches(buffer[:n], id, seq) {
return PingResult{Reachable: true, RTT: time.Since(start)}
}
}
}
func resolveIPv4(ctx context.Context, host string) (net.IP, error) {
if parsed := net.ParseIP(host); parsed != nil {
if ipv4 := parsed.To4(); ipv4 != nil {
return ipv4, nil
}
return nil, fmt.Errorf("%s is not an IPv4 address", host)
}
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("resolving ping host %s: %w", host, err)
}
for _, addr := range addrs {
if ipv4 := addr.IP.To4(); ipv4 != nil {
return ipv4, nil
}
}
return nil, fmt.Errorf("resolving ping host %s: no IPv4 address found", host)
}
func echoIdentifiers() (uint16, uint16, error) {
var data [4]byte
if _, err := rand.Read(data[:]); err != nil {
return 0, 0, fmt.Errorf("generating icmp identifiers: %w", err)
}
return binary.BigEndian.Uint16(data[0:2]), binary.BigEndian.Uint16(data[2:4]), nil
}
func echoPacket(id uint16, seq uint16) []byte {
payload := []byte("statuspanel")
packet := make([]byte, 8+len(payload))
packet[0] = icmpEcho
binary.BigEndian.PutUint16(packet[4:6], id)
binary.BigEndian.PutUint16(packet[6:8], seq)
copy(packet[8:], payload)
checksum := icmpChecksum(packet)
binary.BigEndian.PutUint16(packet[2:4], checksum)
return packet
}
func echoReplyMatches(packet []byte, id uint16, seq uint16) bool {
if len(packet) < 8 {
return false
}
if packet[0] == 69 && len(packet) >= 28 {
packet = packet[20:]
}
return len(packet) >= 8 &&
packet[0] == icmpEchoReply &&
binary.BigEndian.Uint16(packet[4:6]) == id &&
binary.BigEndian.Uint16(packet[6:8]) == seq
}
func sameIP(addr net.Addr, ip net.IP) bool {
ipAddr, ok := addr.(*net.IPAddr)
if !ok {
return false
}
return ipAddr.IP.Equal(ip)
}
func icmpChecksum(data []byte) uint16 {
var sum uint32
for len(data) > 1 {
sum += uint32(binary.BigEndian.Uint16(data[:2]))
data = data[2:]
}
if len(data) == 1 {
sum += uint32(data[0]) << 8
}
for sum>>16 != 0 {
sum = (sum & 0xffff) + (sum >> 16)
}
return ^uint16(sum)
}
var errUnsupportedGateway = errors.New("gateway discovery is unsupported on this platform")

View File

@@ -0,0 +1,99 @@
package probe
import (
"net"
"testing"
)
func TestHumanizeBitsPerSecond(t *testing.T) {
tests := []struct {
name string
rate float64
want string
}{
{name: "bits", rate: 999, want: "999 bit/s"},
{name: "kilobits", rate: 1500, want: "1.50 kbit/s"},
{name: "megabits", rate: 2500000, want: "2.50 Mbit/s"},
{name: "gigabits", rate: 4200000000, want: "4.20 Gbit/s"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := HumanizeBitsPerSecond(tt.rate); got != tt.want {
t.Fatalf("HumanizeBitsPerSecond(%v) = %q, want %q", tt.rate, got, tt.want)
}
})
}
}
func TestParseNetDev(t *testing.T) {
data := []byte(`Inter-| Receive | Transmit
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
lo: 1000 0 0 0 0 0 0 0 2000 0 0 0 0 0 0 0
eth0: 12345 1 0 0 0 0 0 0 67890 2 0 0 0 0 0 0
`)
stats, err := parseNetDev(data, "eth0")
if err != nil {
t.Fatalf("parseNetDev() error = %v", err)
}
if stats.RXBytes != 12345 || stats.TXBytes != 67890 {
t.Fatalf("stats = %+v, want rx 12345 tx 67890", stats)
}
}
func TestLittleEndianHexIPv4(t *testing.T) {
ip, err := littleEndianHexIPv4("0102A8C0")
if err != nil {
t.Fatalf("littleEndianHexIPv4() error = %v", err)
}
if !ip.Equal(net.IPv4(192, 168, 2, 1)) {
t.Fatalf("ip = %v, want 192.168.2.1", ip)
}
}
func TestParseLinuxDefaultGateway(t *testing.T) {
t.Run("explicit gateway default route", func(t *testing.T) {
data := []byte(`Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
eth0 00000000 0102A8C0 0003 0 0 100 00000000 0 0 0
`)
ip, err := parseLinuxDefaultGateway(data, "eth0")
if err != nil {
t.Fatalf("parseLinuxDefaultGateway() error = %v", err)
}
if !ip.Equal(net.IPv4(192, 168, 2, 1)) {
t.Fatalf("ip = %v, want 192.168.2.1", ip)
}
})
t.Run("point to point peer route", func(t *testing.T) {
data := []byte(`Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
ppp0 00000000 00000000 0001 0 0 0 00000000 0 0 0
ppp0 0100400A 00000000 0005 0 0 0 FFFFFFFF 0 0 0
`)
ip, err := parseLinuxDefaultGateway(data, "ppp0")
if err != nil {
t.Fatalf("parseLinuxDefaultGateway() error = %v", err)
}
if !ip.Equal(net.IPv4(10, 64, 0, 1)) {
t.Fatalf("ip = %v, want 10.64.0.1", ip)
}
})
t.Run("prefers explicit gateway over peer route", func(t *testing.T) {
data := []byte(`Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
ppp0 00000000 FE00400A 0003 0 0 0 00000000 0 0 0
ppp0 0100400A 00000000 0005 0 0 0 FFFFFFFF 0 0 0
`)
ip, err := parseLinuxDefaultGateway(data, "ppp0")
if err != nil {
t.Fatalf("parseLinuxDefaultGateway() error = %v", err)
}
if !ip.Equal(net.IPv4(10, 64, 0, 254)) {
t.Fatalf("ip = %v, want 10.64.0.254", ip)
}
})
}

View File

@@ -0,0 +1,151 @@
//go:build linux
package probe
import (
"bufio"
"bytes"
"context"
"encoding/hex"
"errors"
"fmt"
"net"
"os"
"strconv"
"strings"
)
const routePath = "/proc/net/route"
const (
routeFlagUp = 0x1
routeFlagGateway = 0x2
routeFlagHost = 0x4
)
type linuxRoute struct {
iface string
destination net.IP
gateway net.IP
flags int64
mask net.IP
}
// GatewayForInterface returns the default IPv4 gateway for an interface.
func GatewayForInterface(ctx context.Context, iface string) (net.IP, error) {
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("checking context before reading routes: %w", err)
}
data, err := os.ReadFile(routePath)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", routePath, err)
}
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("checking context after reading routes: %w", err)
}
ip, err := parseLinuxDefaultGateway(data, iface)
if err != nil {
return nil, err
}
return ip, nil
}
func parseLinuxDefaultGateway(data []byte, iface string) (net.IP, error) {
routes, err := parseLinuxRoutes(data, iface)
if err != nil {
return nil, err
}
hasDefaultRoute := false
for _, route := range routes {
if !route.isDefault() {
continue
}
hasDefaultRoute = true
if route.hasFlag(routeFlagUp) && route.hasFlag(routeFlagGateway) && !route.gateway.Equal(net.IPv4zero) {
return route.gateway, nil
}
}
if hasDefaultRoute {
for _, route := range routes {
if route.isPointToPointPeer() {
return route.destination, nil
}
}
}
return nil, errors.New("default gateway not found")
}
func parseLinuxRoutes(data []byte, iface string) ([]linuxRoute, error) {
var routes []linuxRoute
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 8 || fields[0] != iface {
continue
}
route, err := parseLinuxRoute(fields)
if err != nil {
return nil, err
}
routes = append(routes, route)
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scanning %s contents: %w", routePath, err)
}
return routes, nil
}
func parseLinuxRoute(fields []string) (linuxRoute, error) {
destination, err := littleEndianHexIPv4(fields[1])
if err != nil {
return linuxRoute{}, fmt.Errorf("parsing route destination for %s: %w", fields[0], err)
}
gateway, err := littleEndianHexIPv4(fields[2])
if err != nil {
return linuxRoute{}, fmt.Errorf("parsing route gateway for %s: %w", fields[0], err)
}
flags, err := strconv.ParseInt(fields[3], 16, 64)
if err != nil {
return linuxRoute{}, fmt.Errorf("parsing route flags for %s: %w", fields[0], err)
}
mask, err := littleEndianHexIPv4(fields[7])
if err != nil {
return linuxRoute{}, fmt.Errorf("parsing route mask for %s: %w", fields[0], err)
}
return linuxRoute{
iface: fields[0],
destination: destination,
gateway: gateway,
flags: flags,
mask: mask,
}, nil
}
func (r linuxRoute) isDefault() bool {
return r.destination.Equal(net.IPv4zero) && r.mask.Equal(net.IPv4zero)
}
func (r linuxRoute) isPointToPointPeer() bool {
return r.hasFlag(routeFlagUp) &&
r.hasFlag(routeFlagHost) &&
r.gateway.Equal(net.IPv4zero) &&
!r.destination.Equal(net.IPv4zero)
}
func (r linuxRoute) hasFlag(flag int64) bool {
return r.flags&flag != 0
}
func littleEndianHexIPv4(value string) (net.IP, error) {
decoded, err := hex.DecodeString(value)
if err != nil {
return nil, err
}
if len(decoded) != net.IPv4len {
return nil, fmt.Errorf("expected 4 bytes, got %d", len(decoded))
}
return net.IPv4(decoded[3], decoded[2], decoded[1], decoded[0]), nil
}

View File

@@ -0,0 +1,16 @@
//go:build !linux
package probe
import (
"context"
"net"
)
// GatewayForInterface returns the default IPv4 gateway for an interface.
func GatewayForInterface(ctx context.Context, iface string) (net.IP, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
return nil, errUnsupportedGateway
}

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

19
statuspanel.example.yaml Normal file
View File

@@ -0,0 +1,19 @@
interface: eth0
gateway: "" # optional; empty means auto-detect default gateway for the interface on Linux
refresh: 2s
dns:
server: 1.1.1.1
domain: example.com
timeout: 1s
ping:
timeout: 1s
targets:
- name: Cloudflare DNS
address: 1.1.1.1
- name: Google DNS
address: 8.8.8.8
- name: Quad9 DNS
address: 9.9.9.9