check / check (push) Failing after 4s
Detection is a TCP connect to the dish endpoint bound to the physical interface; once a dish answers, get_status is read every 5s and two status lines render under the physical pane, red on disconnect or alert. No dish: nothing drawn, no RPC. Minimal vendored proto bindings for get_status only. Independent review passed (PR 7 comment 100012). Model: opus-4-8 (implementation and review); model: claude-fable-5 (merge)
167 lines
3.8 KiB
Go
167 lines
3.8 KiB
Go
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
|
|
}
|