Skip to content

Commit c63ea0f

Browse files
route health on dashboard, detail panel on domains (#7)
Updated dashboard view Updated domains tab with details Fixed tab cycle issue Added route health on dashboard
1 parent 40a8673 commit c63ea0f

9 files changed

Lines changed: 609 additions & 53 deletions

File tree

internal/proxy/prober/prober.go

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// Package prober TCP-probes route upstreams on a fixed interval and exposes
2+
// per-host health snapshots for the dashboard and runtime status reporting.
3+
package prober
4+
5+
import (
6+
"context"
7+
"errors"
8+
"net"
9+
"net/url"
10+
"strings"
11+
"sync"
12+
"time"
13+
14+
"github.com/venkatkrishna07/mkdev/internal/store"
15+
)
16+
17+
// Status is the health verdict for a single route's upstream.
18+
type Status int
19+
20+
// Status values reported by Health / Snapshot.
21+
const (
22+
// StatusOff is the zero value and the recorded status for disabled routes.
23+
StatusOff Status = iota
24+
// StatusUp means the most recent dial succeeded.
25+
StatusUp
26+
// StatusDown means the most recent dial failed; LastErr carries the reason.
27+
StatusDown
28+
)
29+
30+
func (s Status) String() string {
31+
switch s {
32+
case StatusUp:
33+
return "up"
34+
case StatusDown:
35+
return "down"
36+
default:
37+
return "off"
38+
}
39+
}
40+
41+
// HealthState is the most recent probe outcome for one host.
42+
type HealthState struct {
43+
Status Status
44+
LastErr string
45+
LastProbe time.Time
46+
}
47+
48+
const (
49+
probePoolSize = 8
50+
errMaxLen = 80
51+
)
52+
53+
// dialer is overridable so future code can swap the network call without
54+
// changing the Prober's structure. Default does a bounded TCP DialContext.
55+
var dialer = func(ctx context.Context, target string, timeout time.Duration) error {
56+
d := net.Dialer{Timeout: timeout}
57+
conn, err := d.DialContext(ctx, "tcp", target)
58+
if err != nil {
59+
return err
60+
}
61+
_ = conn.Close()
62+
return nil
63+
}
64+
65+
// Prober periodically TCP-dials every enabled route's upstream.
66+
type Prober struct {
67+
interval time.Duration
68+
timeout time.Duration
69+
routes func() ([]store.Route, error)
70+
states sync.Map // host (lowercased) -> HealthState
71+
}
72+
73+
// New returns a Prober that pulls routes from the given function and probes
74+
// each enabled upstream every interval with per-dial timeout.
75+
func New(routes func() ([]store.Route, error), interval, timeout time.Duration) *Prober {
76+
return &Prober{
77+
interval: interval,
78+
timeout: timeout,
79+
routes: routes,
80+
}
81+
}
82+
83+
// Health returns the last known state for host, or the zero value (StatusOff)
84+
// if the host has never been probed.
85+
func (p *Prober) Health(host string) HealthState {
86+
v, ok := p.states.Load(strings.ToLower(host))
87+
if !ok {
88+
return HealthState{}
89+
}
90+
return v.(HealthState)
91+
}
92+
93+
// Snapshot returns a copy of every host's current health state.
94+
func (p *Prober) Snapshot() map[string]HealthState {
95+
out := map[string]HealthState{}
96+
p.states.Range(func(k, v any) bool {
97+
out[k.(string)] = v.(HealthState)
98+
return true
99+
})
100+
return out
101+
}
102+
103+
// Run probes immediately, then on every interval tick, until ctx is cancelled.
104+
func (p *Prober) Run(ctx context.Context) {
105+
p.tick(ctx)
106+
t := time.NewTicker(p.interval)
107+
defer t.Stop()
108+
for {
109+
select {
110+
case <-ctx.Done():
111+
return
112+
case <-t.C:
113+
p.tick(ctx)
114+
}
115+
}
116+
}
117+
118+
func (p *Prober) tick(ctx context.Context) {
119+
routes, err := p.routes()
120+
if err != nil {
121+
return
122+
}
123+
124+
live := make(map[string]struct{}, len(routes))
125+
jobs := make(chan store.Route)
126+
var wg sync.WaitGroup
127+
128+
for range probePoolSize {
129+
wg.Go(func() {
130+
for r := range jobs {
131+
p.probe(ctx, r)
132+
}
133+
})
134+
}
135+
136+
for _, r := range routes {
137+
host := strings.ToLower(r.Domain)
138+
live[host] = struct{}{}
139+
if !r.Enabled {
140+
p.states.Store(host, HealthState{Status: StatusOff, LastProbe: time.Now()})
141+
continue
142+
}
143+
select {
144+
case jobs <- r:
145+
case <-ctx.Done():
146+
close(jobs)
147+
wg.Wait()
148+
return
149+
}
150+
}
151+
close(jobs)
152+
wg.Wait()
153+
154+
p.states.Range(func(k, _ any) bool {
155+
if _, ok := live[k.(string)]; !ok {
156+
p.states.Delete(k)
157+
}
158+
return true
159+
})
160+
}
161+
162+
func (p *Prober) probe(ctx context.Context, r store.Route) {
163+
host := strings.ToLower(r.Domain)
164+
st := HealthState{Status: StatusUp, LastProbe: time.Now()}
165+
target := strings.TrimSpace(r.Target)
166+
switch target {
167+
case "":
168+
st.Status, st.LastErr = StatusDown, "bad upstream"
169+
default:
170+
addr, err := dialAddress(target)
171+
if err == nil {
172+
dctx, cancel := context.WithTimeout(ctx, p.timeout)
173+
err = dialer(dctx, addr, p.timeout)
174+
cancel()
175+
}
176+
if err != nil {
177+
st.Status, st.LastErr = StatusDown, truncErr(err.Error())
178+
}
179+
}
180+
p.states.Store(host, st)
181+
}
182+
183+
// dialAddress normalises a route Target (bare host[:port] or full URL) into a
184+
// host:port string suitable for net.Dialer.
185+
func dialAddress(target string) (string, error) {
186+
s := target
187+
if !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") {
188+
s = "http://" + s
189+
}
190+
u, err := url.Parse(s)
191+
if err != nil {
192+
return "", err
193+
}
194+
host := u.Hostname()
195+
if host == "" {
196+
return "", errors.New("no host in target")
197+
}
198+
port := u.Port()
199+
if port == "" {
200+
if u.Scheme == "https" {
201+
port = "443"
202+
} else {
203+
port = "80"
204+
}
205+
}
206+
return net.JoinHostPort(host, port), nil
207+
}
208+
209+
func truncErr(s string) string {
210+
if len(s) <= errMaxLen {
211+
return s
212+
}
213+
return s[:errMaxLen-3] + "..."
214+
}

internal/proxy/stats.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ type Stats struct {
2222
rpsMu sync.Mutex
2323
rpsBuf [rpsWindowSec]uint32
2424
rpsTs int64 // last bucket epoch second written
25+
26+
lastReq map[string]time.Time
2527
}
2628

2729
type ring struct {
@@ -33,7 +35,10 @@ type ring struct {
3335

3436
// NewStats returns an empty Stats collector.
3537
func NewStats() *Stats {
36-
return &Stats{buf: make(map[string]*ring)}
38+
return &Stats{
39+
buf: make(map[string]*ring),
40+
lastReq: make(map[string]time.Time),
41+
}
3742
}
3843

3944
// Record adds one RTT sample for domain and bumps the rolling RPS window.
@@ -56,6 +61,10 @@ func (s *Stats) Record(domain string, d time.Duration) {
5661
s.mu.Unlock()
5762

5863
s.bumpRPS()
64+
65+
s.rpsMu.Lock()
66+
s.lastReq[domain] = time.Now()
67+
s.rpsMu.Unlock()
5968
}
6069

6170
func (s *Stats) bumpRPS() {
@@ -140,6 +149,15 @@ func (s *Stats) RPS() []float64 {
140149
return out
141150
}
142151

152+
// LastSeen returns the time the most recent request for host was recorded.
153+
// Returns a zero-value time.Time when host has never received a request.
154+
func (s *Stats) LastSeen(host string) time.Time {
155+
host = strings.ToLower(host)
156+
s.rpsMu.Lock()
157+
defer s.rpsMu.Unlock()
158+
return s.lastReq[host]
159+
}
160+
143161
// Domains returns the set of domains that have recorded at least one request.
144162
func (s *Stats) Domains() []string {
145163
s.mu.RLock()
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package components
2+
3+
import (
4+
"github.com/charmbracelet/lipgloss"
5+
"github.com/venkatkrishna07/mkdev/internal/tui/styles"
6+
)
7+
8+
// ShareBadge renders a compact indicator of a route's share scope:
9+
// "LAN" in the accent colour when shared, "local" dimmed otherwise.
10+
func ShareBadge(th styles.Theme, shared bool) string {
11+
if shared {
12+
return lipgloss.NewStyle().Bold(true).Foreground(th.Accent).Render("LAN")
13+
}
14+
return th.Dim.Render("local")
15+
}

internal/tui/program.go

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
tea "github.com/charmbracelet/bubbletea"
1515
"github.com/charmbracelet/lipgloss"
1616
"github.com/venkatkrishna07/mkdev/internal/browser"
17+
"github.com/venkatkrishna07/mkdev/internal/store"
1718
"github.com/venkatkrishna07/mkdev/internal/tui/components"
1819
"github.com/venkatkrishna07/mkdev/internal/tui/modals"
1920
"github.com/venkatkrishna07/mkdev/internal/tui/styles"
@@ -111,20 +112,33 @@ func newRootModel(rt *Runtime) rootModel {
111112
RPS: rt.Stats.RPS,
112113
CA: rt.Issuer.CACert(),
113114
Start: time.Now(),
115+
Routes: func() []store.Route {
116+
rs, _ := rt.Store.ListRoutes()
117+
return rs
118+
},
119+
Health: rt.Prober.Health,
120+
LastSeen: rt.Stats.LastSeen,
121+
LAN: func() tabs.LANState {
122+
s := rt.LANState()
123+
return tabs.LANState{IP: s.IP, Advertising: s.Advertising, SharedCount: s.SharedCount}
124+
},
114125
}
115126
return rootModel{
116127
rt: rt,
117128
th: th,
118129
dashboard: tabs.NewDashboard(th, dashSrc),
119-
domains: tabs.NewDomainsWithRTT(th, 100, 24, rt.Stats.Snapshot),
120-
logs: tabs.NewLogs(th, logPath),
121-
doctor: tabs.NewDoctor(th, rt.Home, rt.Store),
122-
settings: tabs.NewSettings(th, rt.Home),
123-
binPath: bp,
124-
keys: DefaultKeyMap,
125-
help: h,
126-
spinner: sp,
127-
splash: true,
130+
domains: tabs.NewDomainsWithSources(th, 100, 24, rt.Stats.Snapshot, rt.Prober.Health, func() tabs.LANState {
131+
s := rt.LANState()
132+
return tabs.LANState{IP: s.IP, Advertising: s.Advertising, SharedCount: s.SharedCount}
133+
}),
134+
logs: tabs.NewLogs(th, logPath),
135+
doctor: tabs.NewDoctor(th, rt.Home, rt.Store),
136+
settings: tabs.NewSettings(th, rt.Home),
137+
binPath: bp,
138+
keys: DefaultKeyMap,
139+
help: h,
140+
spinner: sp,
141+
splash: true,
128142
}
129143
}
130144

@@ -267,11 +281,6 @@ func (m rootModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
267281
}
268282

269283
func (m rootModel) handleGlobalKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
270-
// Settings owns Tab/Shift+Tab for field navigation; tab-bar uses 1-5
271-
// (or ctrl+]/ctrl+[) to switch tabs while inside Settings.
272-
if m.active == tabSettings && (key.Matches(k, m.keys.NextTab) || key.Matches(k, m.keys.PrevTab)) {
273-
return m.forwardToActiveTab(k)
274-
}
275284
switch {
276285
case key.Matches(k, m.keys.Quit):
277286
m.modals = append(m.modals, modals.NewConfirm(m.th, "Quit mkdev?", "stops the proxy and closes the TUI"))

0 commit comments

Comments
 (0)