diff --git a/internal/ui/app.go b/internal/ui/app.go index bbe57fe..e336934 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -2,6 +2,7 @@ package ui import ( + "context" "fmt" "strings" "time" @@ -40,8 +41,19 @@ type App struct { services *tview.TextView state map[string]serviceState // keyed by service name + probes []probe } +// probe checks that one service answers; it returns its version. +type probe struct { + name string + check func(ctx context.Context) (string, error) +} + +// heartbeat is how often the connection indicators are refreshed in the +// background, so a service shows up as connected without visiting its view. +const heartbeat = 30 * time.Second + // serviceState is the last known connection state of a backend service. type serviceState struct { known bool @@ -77,8 +89,20 @@ func New(cfg *config.Config) *App { radarrClient := radarr.NewClient(cfg.Radarr.URL, cfg.Radarr.APIKey) sonarrClient := sonarr.NewClient(cfg.Sonarr.URL, cfg.Sonarr.APIKey) a.logs = newLogView(a) + sabClient := sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey) + a.probes = []probe{ + {"Radarr", func(ctx context.Context) (string, error) { + st, err := radarrClient.SystemStatus(ctx) + return st.Version, err + }}, + {"Sonarr", func(ctx context.Context) (string, error) { + st, err := sonarrClient.SystemStatus(ctx) + return st.Version, err + }}, + {"SABnzbd", sabClient.Version}, + } library := newLibraryView(a, radarrClient, sonarrClient) - downloads := newDownloadsView(a, sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey), + downloads := newDownloadsView(a, sabClient, []arrSource{ {name: "Radarr", queue: radarrClient.Queue, remove: radarrClient.RemoveQueueItem}, {name: "Sonarr", queue: sonarrClient.Queue, remove: sonarrClient.RemoveQueueItem}, @@ -170,9 +194,33 @@ func (a *App) post(f func()) { // Run starts the event loop and blocks until the application quits. func (a *App) Run() error { + stop := make(chan struct{}) + defer close(stop) + go a.watchServices(stop) return a.app.Run() } +// watchServices checks all services now and then every heartbeat until stop is closed. +func (a *App) watchServices(stop <-chan struct{}) { + tick := time.NewTicker(heartbeat) + defer tick.Stop() + for { + for _, p := range a.probes { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + version, err := p.check(ctx) + a.post(func() { a.SetService(p.name, version, err) }) + }() + } + select { + case <-tick.C: + case <-stop: + return + } + } +} + func (a *App) renderStatus() { var hints string if n := len(a.overlays); n > 0 { @@ -304,12 +352,6 @@ func (a *App) handleKey(ev *tcell.EventKey) *tcell.EventKey { a.app.Stop() return nil } - for i, v := range a.views { - if ev.Rune() == v.key { - a.switchTo(i) - return nil - } - } } return ev } diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go new file mode 100644 index 0000000..612df07 --- /dev/null +++ b/internal/ui/app_test.go @@ -0,0 +1,40 @@ +package ui + +import ( + "testing" + + "github.com/gdamore/tcell/v2" + + "git.stinnesbeck.com/nils/arr-tui/internal/config" +) + +func newTestApp(t *testing.T) *App { + t.Helper() + // Unreachable services: the views only start background requests that fail. + cfg := &config.Config{ + Radarr: config.Arr{Service: config.Service{URL: "http://127.0.0.1:1", APIKey: "k"}}, + Sonarr: config.Arr{Service: config.Service{URL: "http://127.0.0.1:1", APIKey: "k"}}, + SABnzbd: config.Service{URL: "http://127.0.0.1:1", APIKey: "k"}, + UI: config.UI{RefreshSeconds: 3}, + } + return New(cfg) +} + +func TestOnlyFunctionKeysSwitchViews(t *testing.T) { + a := newTestApp(t) + for _, r := range "12345\t" { + key := tcell.NewEventKey(tcell.KeyRune, r, 0) + if r == '\t' { + key = tcell.NewEventKey(tcell.KeyTab, 0, 0) + } + a.handleKey(key) + if a.active != 0 { + t.Fatalf("key %q switched to view %d", r, a.active) + } + } + for i, k := range []tcell.Key{tcell.KeyF1, tcell.KeyF2, tcell.KeyF3, tcell.KeyF4, tcell.KeyF5} { + if a.handleKey(tcell.NewEventKey(k, 0, 0)) != nil || a.active != i { + t.Errorf("F%d: active view %d, want %d", i+1, a.active, i) + } + } +}