Add Starlink status lines for the physical gateway pane (closes #3)
check / check (push) Failing after 1s
check / check (push) Failing after 1s
When the non-VPN physical gateway is a Starlink dish, two lines are shown under that pane: state, uptime, obstruction and alert count; then pop-ping latency and drop rate with downlink/uplink throughput. They turn red when the dish is not connected or an alert is active. Detection is a TCP connect to the dish's fixed endpoint 192.168.100.1:9200, bound to the physical interface the same way the latency probes bind. Until a dish answers nothing is drawn and no status is fetched, so an absent dish adds no noise. Status comes from the dish's local get_status gRPC call. Detection and the fetch sit behind a small Client interface; the loop and the pure Render function are tested with a fake, no dish and no network. Model: opus-4-8
This commit is contained in:
@@ -1,7 +1,52 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.eeqj.de/sneak/rtnetmon/internal/starlink"
|
||||
)
|
||||
|
||||
// Test-only accessors exposing unexported state for white-box assertions.
|
||||
|
||||
// SetStarlinkClient injects a Starlink client for iface, bypassing the real
|
||||
// gRPC dialer so the loop can be driven with a fake.
|
||||
func (m *Monitor) SetStarlinkClient(iface string, c starlink.Client) {
|
||||
for _, st := range m.interfaces {
|
||||
if st.Name == iface {
|
||||
m.slPane = st
|
||||
m.slState = &starlinkState{}
|
||||
m.slClient = c
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StarlinkEnabled reports whether a Starlink client is configured.
|
||||
func (m *Monitor) StarlinkEnabled() bool { return m.slClient != nil }
|
||||
|
||||
// StarlinkPaneName returns the name of the pane the Starlink lines attach
|
||||
// to, or "" if none.
|
||||
func (m *Monitor) StarlinkPaneName() string {
|
||||
if m.slPane == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return m.slPane.Name
|
||||
}
|
||||
|
||||
// StarlinkProbeStep runs one detection step.
|
||||
func (m *Monitor) StarlinkProbeStep(ctx context.Context) { m.starlinkProbe(ctx) }
|
||||
|
||||
// StarlinkRefreshStep runs one status-refresh step.
|
||||
func (m *Monitor) StarlinkRefreshStep(ctx context.Context) { m.starlinkRefresh(ctx) }
|
||||
|
||||
// StarlinkSnapshot returns the current Starlink display state: detected,
|
||||
// have-status, and the latest status.
|
||||
func (m *Monitor) StarlinkSnapshot() (bool, bool, starlink.Status) {
|
||||
return m.slState.snapshot()
|
||||
}
|
||||
|
||||
// ReachabilityHosts returns the configured reachability hosts.
|
||||
func (m *Monitor) ReachabilityHosts() []string { return m.reachabilityHosts }
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
"time"
|
||||
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
|
||||
"git.eeqj.de/sneak/rtnetmon/internal/starlink"
|
||||
)
|
||||
|
||||
// Meter glyphs used to render the ASCII loss meter.
|
||||
@@ -90,6 +92,12 @@ type Monitor struct {
|
||||
// Interfaces to monitor (one or two)
|
||||
interfaces []*InterfaceStatus
|
||||
|
||||
// Starlink status for the physical (non-VPN gateway) pane. All three
|
||||
// are nil unless EnableStarlink was called for a monitored interface.
|
||||
slClient starlink.Client
|
||||
slState *starlinkState
|
||||
slPane *InterfaceStatus
|
||||
|
||||
// Logging
|
||||
logFile string
|
||||
|
||||
@@ -208,6 +216,8 @@ func (m *Monitor) Run(ctx context.Context) error {
|
||||
go m.tcpLoop(ctx, st, tcpHosts)
|
||||
}
|
||||
|
||||
go m.starlinkLoop(ctx)
|
||||
|
||||
m.logf("Starting UI loop")
|
||||
m.uiLoop(ctx)
|
||||
m.logf("UI loop exited, monitor ending")
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
|
||||
"git.eeqj.de/sneak/rtnetmon/internal/starlink"
|
||||
)
|
||||
|
||||
// Starlink probe and status cadence: detect once a minute until a dish
|
||||
// answers, then refresh the status every few seconds.
|
||||
const (
|
||||
starlinkProbePeriod = time.Minute
|
||||
starlinkStatusPeriod = 5 * time.Second
|
||||
)
|
||||
|
||||
// starlinkState holds the latest dish status for the physical pane. Nothing
|
||||
// is drawn until a dish first answers, so an absent dish adds no lines.
|
||||
type starlinkState struct {
|
||||
mu sync.RWMutex
|
||||
detected bool
|
||||
haveStatus bool
|
||||
status starlink.Status
|
||||
}
|
||||
|
||||
// snapshot returns whether a dish was detected, whether a status has been
|
||||
// read, and the latest status, all under the read lock.
|
||||
func (s *starlinkState) snapshot() (bool, bool, starlink.Status) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.detected, s.haveStatus, s.status
|
||||
}
|
||||
|
||||
// EnableStarlink turns on Starlink detection for the named interface, which
|
||||
// must be the non-VPN physical gateway pane. It is a no-op if no monitored
|
||||
// interface has that name.
|
||||
func (m *Monitor) EnableStarlink(iface string) {
|
||||
for _, st := range m.interfaces {
|
||||
if st.Name == iface {
|
||||
m.slPane = st
|
||||
m.slState = &starlinkState{}
|
||||
m.slClient = starlink.NewClient(m.starlinkDialer(iface))
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// starlinkDialer returns a dialer that binds to iface exactly as the TCP
|
||||
// latency probes do, so the dish is reached over the physical interface.
|
||||
func (m *Monitor) starlinkDialer(iface string) starlink.DialFunc {
|
||||
return func(ctx context.Context, addr string) (net.Conn, error) {
|
||||
la, err := localAddr(iface)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d := net.Dialer{LocalAddr: la, Control: bindControl(iface)}
|
||||
|
||||
return d.DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
}
|
||||
|
||||
// starlinkLoop probes for a dish and refreshes its status until the context
|
||||
// is cancelled. It does nothing when Starlink was not enabled.
|
||||
func (m *Monitor) starlinkLoop(ctx context.Context) {
|
||||
if m.slClient == nil {
|
||||
return
|
||||
}
|
||||
|
||||
m.logf("Starting Starlink monitoring for %s", m.slPane.Name)
|
||||
m.starlinkProbe(ctx)
|
||||
|
||||
probe := time.NewTicker(starlinkProbePeriod)
|
||||
status := time.NewTicker(starlinkStatusPeriod)
|
||||
|
||||
defer probe.Stop()
|
||||
defer status.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
m.logf("Stopping Starlink monitoring for %s", m.slPane.Name)
|
||||
|
||||
return
|
||||
case <-probe.C:
|
||||
m.starlinkProbe(ctx)
|
||||
case <-status.C:
|
||||
m.starlinkRefresh(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// starlinkProbe detects the dish; once detected it stays detected, and a
|
||||
// first status is fetched immediately.
|
||||
func (m *Monitor) starlinkProbe(ctx context.Context) {
|
||||
if detected, _, _ := m.slState.snapshot(); detected {
|
||||
return
|
||||
}
|
||||
|
||||
if !m.slClient.Probe(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
m.slState.mu.Lock()
|
||||
m.slState.detected = true
|
||||
m.slState.mu.Unlock()
|
||||
|
||||
m.notifyUI()
|
||||
m.starlinkRefresh(ctx)
|
||||
}
|
||||
|
||||
// starlinkRefresh fetches status only once a dish has been detected, so an
|
||||
// absent dish produces no status traffic.
|
||||
func (m *Monitor) starlinkRefresh(ctx context.Context) {
|
||||
if detected, _, _ := m.slState.snapshot(); !detected {
|
||||
return
|
||||
}
|
||||
|
||||
st, err := m.slClient.Status(ctx)
|
||||
if err != nil {
|
||||
m.logf("Starlink status error: %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
m.slState.mu.Lock()
|
||||
m.slState.status = st
|
||||
m.slState.haveStatus = true
|
||||
m.slState.mu.Unlock()
|
||||
|
||||
m.notifyUI()
|
||||
}
|
||||
|
||||
// drawStarlink draws the two dish status lines under the physical pane. It
|
||||
// draws nothing until a dish has answered.
|
||||
func (m *Monitor) drawStarlink(scr tcell.Screen, y int) int {
|
||||
detected, haveStatus, st := m.slState.snapshot()
|
||||
if !detected {
|
||||
return y
|
||||
}
|
||||
|
||||
if !haveStatus {
|
||||
Put(scr, colLeft, y, "Starlink: detected, status unavailable", styleBrightRed())
|
||||
|
||||
return y + lineStep
|
||||
}
|
||||
|
||||
line1, line2, alert := starlink.Render(st)
|
||||
|
||||
style := tcell.StyleDefault
|
||||
if alert {
|
||||
style = styleBrightRed()
|
||||
}
|
||||
|
||||
Put(scr, colLeft, y, line1, style)
|
||||
y += lineStep
|
||||
Put(scr, colLeft, y, line2, style)
|
||||
|
||||
return y + lineStep
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package monitor_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/rtnetmon/internal/monitor"
|
||||
"git.eeqj.de/sneak/rtnetmon/internal/starlink"
|
||||
)
|
||||
|
||||
// fakeDish is a Starlink client for tests: no dish, no network. It records
|
||||
// how often each method is called and returns programmed results.
|
||||
type fakeDish struct {
|
||||
mu sync.Mutex
|
||||
probeOK bool
|
||||
status starlink.Status
|
||||
statusErr error
|
||||
probeCalls int
|
||||
statusCalls int
|
||||
}
|
||||
|
||||
func (f *fakeDish) Probe(_ context.Context) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
f.probeCalls++
|
||||
|
||||
return f.probeOK
|
||||
}
|
||||
|
||||
func (f *fakeDish) Status(_ context.Context) (starlink.Status, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
f.statusCalls++
|
||||
|
||||
return f.status, f.statusErr
|
||||
}
|
||||
|
||||
func (f *fakeDish) counts() (int, int) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
return f.probeCalls, f.statusCalls
|
||||
}
|
||||
|
||||
func newTwoPaneMonitor() *monitor.Monitor {
|
||||
return monitor.NewMonitor([]monitor.IfaceSpec{
|
||||
{Name: ifaceTest0, Label: "A"},
|
||||
{Name: ifaceTest1, Label: "B"},
|
||||
}, "")
|
||||
}
|
||||
|
||||
func TestEnableStarlinkSelectsPane(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mon := newTwoPaneMonitor()
|
||||
mon.EnableStarlink(ifaceTest1)
|
||||
|
||||
if !mon.StarlinkEnabled() {
|
||||
t.Fatal("StarlinkEnabled = false, want true")
|
||||
}
|
||||
|
||||
if got := mon.StarlinkPaneName(); got != ifaceTest1 {
|
||||
t.Errorf("StarlinkPaneName = %q, want %q", got, ifaceTest1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnableStarlinkUnknownInterface(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mon := newTwoPaneMonitor()
|
||||
mon.EnableStarlink("does-not-exist")
|
||||
|
||||
if mon.StarlinkEnabled() {
|
||||
t.Error("StarlinkEnabled = true for an unknown interface, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStarlinkNoDishNoStatus verifies that when no dish answers, no status
|
||||
// is ever fetched and nothing is marked detected.
|
||||
func TestStarlinkNoDishNoStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mon := newTwoPaneMonitor()
|
||||
fake := &fakeDish{probeOK: false}
|
||||
mon.SetStarlinkClient(ifaceTest1, fake)
|
||||
|
||||
ctx := context.Background()
|
||||
mon.StarlinkProbeStep(ctx)
|
||||
mon.StarlinkRefreshStep(ctx)
|
||||
|
||||
detected, haveStatus, _ := mon.StarlinkSnapshot()
|
||||
if detected || haveStatus {
|
||||
t.Errorf("detected=%v haveStatus=%v, want both false", detected, haveStatus)
|
||||
}
|
||||
|
||||
if _, status := fake.counts(); status != 0 {
|
||||
t.Errorf("status calls = %d, want 0 (no probing noise)", status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStarlinkDetectedFetchesStatus verifies that once a dish answers,
|
||||
// detection triggers a status fetch and later refreshes fetch again.
|
||||
func TestStarlinkDetectedFetchesStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mon := newTwoPaneMonitor()
|
||||
want := starlink.Status{
|
||||
State: "CONNECTED",
|
||||
Uptime: 2 * time.Hour,
|
||||
DownlinkMbps: 100,
|
||||
}
|
||||
fake := &fakeDish{probeOK: true, status: want}
|
||||
mon.SetStarlinkClient(ifaceTest1, fake)
|
||||
|
||||
ctx := context.Background()
|
||||
mon.StarlinkProbeStep(ctx)
|
||||
|
||||
detected, haveStatus, got := mon.StarlinkSnapshot()
|
||||
if !detected || !haveStatus {
|
||||
t.Fatalf("detected=%v haveStatus=%v, want both true", detected, haveStatus)
|
||||
}
|
||||
|
||||
if got != want {
|
||||
t.Errorf("status = %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
mon.StarlinkRefreshStep(ctx)
|
||||
|
||||
probe, status := fake.counts()
|
||||
if probe != 1 {
|
||||
t.Errorf("probe calls = %d, want 1", probe)
|
||||
}
|
||||
|
||||
if status != 2 {
|
||||
t.Errorf("status calls = %d, want 2", status)
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,11 @@ func (m *Monitor) DrawInterface(scr tcell.Screen, y, w int, st *InterfaceStatus)
|
||||
y = m.drawTCPTable(scr, y, st, tcpHosts)
|
||||
y = drawICMPStats(scr, y, st)
|
||||
|
||||
// The Starlink lines belong to the physical (non-VPN gateway) pane only.
|
||||
if st == m.slPane {
|
||||
y = m.drawStarlink(scr, y)
|
||||
}
|
||||
|
||||
return y
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user