feat: search multiple NetBox instances with isolated host identities
Release / release (push) Successful in 5m16s
Release / release (push) Successful in 5m16s
This commit is contained in:
1 parent
a2a6dd7591
commit
7f53b90266
26 files changed
+1299
-115
No files matched your search
@@ -0,0 +1,81 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
)
|
||||
|
||||
func tuiSourceServer(t *testing.T, fail bool) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if fail {
|
||||
http.Error(w, "offline", 503)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/api/dcim/devices/" {
|
||||
fmt.Fprint(w, `{"count":1,"results":[{"id":1,"name":"web","primary_ip4":{"address":"10.0.0.1/24"}}]}`)
|
||||
} else {
|
||||
fmt.Fprint(w, `{"count":0,"results":[]}`)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestCombinedSearchDoesNotStopAtFirstCachedInstance(t *testing.T) {
|
||||
office, lab := tuiSourceServer(t, false), tuiSourceServer(t, false)
|
||||
client := netbox.NewMultiClient([]netbox.Source{{Name: "office", URL: office.URL}, {Name: "lab", URL: lab.URL}})
|
||||
c := cache.New("", 3600)
|
||||
c.Upsert(cache.Entry{Name: "office/web", Kind: "device", IP: "10.0.0.9"})
|
||||
m := New(client, c, "")
|
||||
msg := m.doSearch("web", 1)().(searchResultMsg)
|
||||
if msg.err != nil || len(msg.entries) != 2 {
|
||||
t.Fatalf("cache hid another instance: %+v, %v", msg.entries, msg.err)
|
||||
}
|
||||
m.input.SetValue("web")
|
||||
m.seq = 1
|
||||
m.Update(msg)
|
||||
if len(m.list.Items()) != 2 {
|
||||
t.Fatal("combined results not displayed")
|
||||
}
|
||||
for _, item := range m.list.Items() {
|
||||
if !strings.Contains(item.(hostItem).name, "/") {
|
||||
t.Fatal("instance not visible in results")
|
||||
}
|
||||
}
|
||||
m.list.Select(0)
|
||||
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||
if m.selected == nil || m.selected.Name != "lab/web" {
|
||||
t.Fatalf("selection lost source: %+v", m.selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialOutageKeepsLiveAndCachedResultsVisible(t *testing.T) {
|
||||
office, lab := tuiSourceServer(t, false), tuiSourceServer(t, true)
|
||||
client := netbox.NewMultiClient([]netbox.Source{{Name: "office", URL: office.URL}, {Name: "lab", URL: lab.URL}})
|
||||
c := cache.New("", 3600)
|
||||
c.Upsert(cache.Entry{Name: "lab/web", Kind: "device", IP: "10.0.0.2"})
|
||||
m := New(client, c, "")
|
||||
m.input.SetValue("web")
|
||||
m.seq = 1
|
||||
m.Update(tea.WindowSizeMsg{Width: 100, Height: 24})
|
||||
msg := m.doSearch("web", 1)().(searchResultMsg)
|
||||
if len(msg.entries) != 2 || msg.err == nil {
|
||||
t.Fatalf("partial outage lost cache or warning: %+v, %v", msg.entries, msg.err)
|
||||
}
|
||||
m.Update(msg)
|
||||
view := m.View().Content
|
||||
if !strings.Contains(view, "warning:") || !strings.Contains(view, "lab/web") || !strings.Contains(view, "office/web") {
|
||||
t.Fatalf("partial results hidden: %s", view)
|
||||
}
|
||||
if rows := strings.Count(view, "\n"); rows > 24 {
|
||||
t.Fatalf("outage warning overflowed view: %d", rows)
|
||||
}
|
||||
}
|
||||
+31
-20
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -412,13 +413,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil // discard stale result
|
||||
}
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
return m, nil
|
||||
}
|
||||
m.err = msg.err
|
||||
m.lastResults = msg.entries
|
||||
m.updateListItems(msg.entries)
|
||||
m.err = nil
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -495,9 +492,15 @@ func (m *Model) viewContent() string {
|
||||
|
||||
if m.loading {
|
||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" searching…") + "\n")
|
||||
} else if m.err != nil {
|
||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Render(" error: "+m.err.Error()) + "\n")
|
||||
} else {
|
||||
if m.err != nil {
|
||||
warning := " warning: " + strings.ReplaceAll(m.err.Error(), "\n", "; ")
|
||||
style := lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
|
||||
if m.width > 0 {
|
||||
style = style.MaxWidth(m.width)
|
||||
}
|
||||
sb.WriteString(style.Render(warning) + "\n")
|
||||
}
|
||||
if m.recentMode && m.input.Value() == "" {
|
||||
if len(m.list.Items()) > 0 {
|
||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" Recent connections") + "\n")
|
||||
@@ -534,25 +537,30 @@ func (m *Model) startDebounce() tea.Cmd {
|
||||
func (m *Model) doSearch(query string, seq int) tea.Cmd {
|
||||
opts := m.netboxSearchOpts()
|
||||
return func() tea.Msg {
|
||||
// Return cache hits immediately without a network round-trip.
|
||||
var cached []netbox.HostEntry
|
||||
if m.cache != nil {
|
||||
if cached := m.cache.Search(query); len(cached) > 0 {
|
||||
entries := make([]netbox.HostEntry, len(cached))
|
||||
for i, c := range cached {
|
||||
entries[i] = netbox.HostEntry{Name: c.Name, PrimaryIP4: c.IP, Kind: c.Kind, Tags: c.Tags}
|
||||
}
|
||||
return searchResultMsg{query: query, seq: seq, entries: entries}
|
||||
for _, entry := range m.cache.Search(query) {
|
||||
source, name := netbox.SplitTarget(entry.Name)
|
||||
cached = append(cached, netbox.HostEntry{Source: source, Name: name, PrimaryIP4: entry.IP, Kind: entry.Kind, Tags: entry.Tags})
|
||||
}
|
||||
}
|
||||
|
||||
if m.client == nil {
|
||||
return searchResultMsg{query: query, seq: seq, entries: nil}
|
||||
if m.client == nil || (len(cached) > 0 && !m.client.MultipleSources()) {
|
||||
return searchResultMsg{query: query, seq: seq, entries: cached}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
entries, err := m.client.Search(ctx, query, opts)
|
||||
// A cached hit in one instance must not hide live hosts in other instances.
|
||||
seen := make(map[string]bool)
|
||||
for _, e := range entries {
|
||||
seen[e.Target()+"\x00"+e.Kind] = true
|
||||
}
|
||||
for _, e := range cached {
|
||||
if !seen[e.Target()+"\x00"+e.Kind] {
|
||||
entries = append(entries, e)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(entries, func(i, j int) bool { return entries[i].Target() < entries[j].Target() })
|
||||
return searchResultMsg{query: query, seq: seq, entries: entries, err: err}
|
||||
}
|
||||
}
|
||||
@@ -577,7 +585,7 @@ func (m *Model) updateListItems(entries []netbox.HostEntry) {
|
||||
if ip == "" {
|
||||
ip = e.PrimaryIP6
|
||||
}
|
||||
items[i] = hostItem{id: e.ID, name: e.Name, ip: ip, kind: e.Kind, tags: e.Tags}
|
||||
items[i] = hostItem{id: e.ID, name: e.Target(), ip: ip, kind: e.Kind, tags: e.Tags}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.resizeList()
|
||||
@@ -589,6 +597,9 @@ func (m *Model) resizeList() {
|
||||
return
|
||||
}
|
||||
extraRows := 5 // title, blank line, search, footer separator, footer
|
||||
if m.err != nil {
|
||||
extraRows++
|
||||
}
|
||||
if m.recentMode && m.input.Value() == "" && len(m.list.Items()) > 0 {
|
||||
extraRows++
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user