feat: enhance host resolution, filtering, and cache management
Release / release (push) Successful in 49s
Release / release (push) Successful in 49s
- **Strategies**: Add resolver strategy input validation and parsing in setup wizard. Support comma-separated input with known strategy mapping. - **Client**: Extend Search and SearchAll to include kind and tag filters. Add pagination for full cache refresh handling large datasets. - **Cache**: Introduce `RecentlyUsed` and `MarkUsed`. Persist `LastUsed` timestamps for entries. - **TUI**: Add recent hosts view, tag/kind filters, and inline editor for user/port override. - **Tests**: Comprehensive unit tests for new features, including strategy validation, cache behavior, and client filtering. - **Docs**: Update README with new TUI features and cache subcommands.
This commit is contained in:
+145
-29
@@ -31,47 +31,111 @@ func NewClient(baseURL, token string, tokenVersion int) *Client {
|
||||
}
|
||||
}
|
||||
|
||||
// Search queries devices and VMs in parallel and merges the results.
|
||||
func (c *Client) Search(ctx context.Context, query string) ([]HostEntry, error) {
|
||||
// Search queries up to 50 devices and VMs in parallel and merges the results.
|
||||
// Use SearchOptions to restrict by kind or tag.
|
||||
func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
results []HostEntry
|
||||
errs []error
|
||||
wg sync.WaitGroup
|
||||
started int
|
||||
)
|
||||
|
||||
wg.Add(2)
|
||||
if opts.Kind != "vm" {
|
||||
started++
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
devices, err := c.searchDevices(ctx, query, opts.Tag)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("devices: %w", err))
|
||||
return
|
||||
}
|
||||
results = append(results, devices...)
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
devices, err := c.searchDevices(ctx, query)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("devices: %w", err))
|
||||
return
|
||||
}
|
||||
results = append(results, devices...)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
vms, err := c.searchVMs(ctx, query)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("vms: %w", err))
|
||||
return
|
||||
}
|
||||
results = append(results, vms...)
|
||||
}()
|
||||
if opts.Kind != "device" {
|
||||
started++
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
vms, err := c.searchVMs(ctx, query, opts.Tag)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("vms: %w", err))
|
||||
return
|
||||
}
|
||||
results = append(results, vms...)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if len(errs) == 2 {
|
||||
if len(errs) == started {
|
||||
if started == 1 {
|
||||
return nil, errs[0]
|
||||
}
|
||||
return nil, fmt.Errorf("netbox search failed: %v; %v", errs[0], errs[1])
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// SearchAll paginates through all matching devices and VMs, fetching every page.
|
||||
// Intended for cache refresh; use Search for interactive queries.
|
||||
func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions) ([]HostEntry, error) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
results []HostEntry
|
||||
errs []error
|
||||
wg sync.WaitGroup
|
||||
started int
|
||||
)
|
||||
|
||||
if opts.Kind != "vm" {
|
||||
started++
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
devices, err := c.fetchAllDevices(ctx, query, opts.Tag)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("devices: %w", err))
|
||||
return
|
||||
}
|
||||
results = append(results, devices...)
|
||||
}()
|
||||
}
|
||||
|
||||
if opts.Kind != "device" {
|
||||
started++
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
vms, err := c.fetchAllVMs(ctx, query, opts.Tag)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("vms: %w", err))
|
||||
return
|
||||
}
|
||||
results = append(results, vms...)
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if len(errs) == started {
|
||||
if started == 1 {
|
||||
return nil, errs[0]
|
||||
}
|
||||
return nil, fmt.Errorf("netbox search failed: %v; %v", errs[0], errs[1])
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
@@ -114,8 +178,11 @@ func (c *Client) GetIPsWithFilter(ctx context.Context, filterParams string) ([]s
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
func (c *Client) searchDevices(ctx context.Context, query string) ([]HostEntry, error) {
|
||||
func (c *Client) searchDevices(ctx context.Context, query, tag string) ([]HostEntry, error) {
|
||||
apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=50", c.baseURL, url.QueryEscape(query))
|
||||
if tag != "" {
|
||||
apiURL += "&tag=" + url.QueryEscape(tag)
|
||||
}
|
||||
var resp netboxListResponse[netboxDevice]
|
||||
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||
return nil, err
|
||||
@@ -127,8 +194,11 @@ func (c *Client) searchDevices(ctx context.Context, query string) ([]HostEntry,
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (c *Client) searchVMs(ctx context.Context, query string) ([]HostEntry, error) {
|
||||
func (c *Client) searchVMs(ctx context.Context, query, tag string) ([]HostEntry, error) {
|
||||
apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=50", c.baseURL, url.QueryEscape(query))
|
||||
if tag != "" {
|
||||
apiURL += "&tag=" + url.QueryEscape(tag)
|
||||
}
|
||||
var resp netboxListResponse[netboxVM]
|
||||
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||
return nil, err
|
||||
@@ -140,6 +210,52 @@ func (c *Client) searchVMs(ctx context.Context, query string) ([]HostEntry, erro
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchAllDevices(ctx context.Context, query, tag string) ([]HostEntry, error) {
|
||||
const pageSize = 50
|
||||
var all []HostEntry
|
||||
for offset := 0; ; offset += pageSize {
|
||||
apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=%d&offset=%d",
|
||||
c.baseURL, url.QueryEscape(query), pageSize, offset)
|
||||
if tag != "" {
|
||||
apiURL += "&tag=" + url.QueryEscape(tag)
|
||||
}
|
||||
var resp netboxListResponse[netboxDevice]
|
||||
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, d := range resp.Results {
|
||||
all = append(all, deviceToEntry(d))
|
||||
}
|
||||
if len(resp.Results) == 0 || len(all) >= resp.Count {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchAllVMs(ctx context.Context, query, tag string) ([]HostEntry, error) {
|
||||
const pageSize = 50
|
||||
var all []HostEntry
|
||||
for offset := 0; ; offset += pageSize {
|
||||
apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=%d&offset=%d",
|
||||
c.baseURL, url.QueryEscape(query), pageSize, offset)
|
||||
if tag != "" {
|
||||
apiURL += "&tag=" + url.QueryEscape(tag)
|
||||
}
|
||||
var resp netboxListResponse[netboxVM]
|
||||
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, v := range resp.Results {
|
||||
all = append(all, vmToEntry(v))
|
||||
}
|
||||
if len(resp.Results) == 0 || len(all) >= resp.Count {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// TokenVersion returns 2 for NetBox v2 tokens (nbt_ prefix) or 1 for legacy tokens.
|
||||
func TokenVersion(token string) int {
|
||||
if strings.HasPrefix(token, "nbt_") {
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestSearch_ReturnsBothDevicesAndVMs(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
results, err := c.Search(context.Background(), "")
|
||||
results, err := c.Search(context.Background(), "", SearchOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func TestSearch_MapsKindCorrectly(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
results, _ := c.Search(context.Background(), "")
|
||||
results, _ := c.Search(context.Background(), "", SearchOptions{})
|
||||
|
||||
for _, r := range results {
|
||||
switch r.Name {
|
||||
@@ -115,7 +115,7 @@ func TestSearch_StripsPrefixFromPrimaryIP(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
results, _ := c.Search(context.Background(), "host")
|
||||
results, _ := c.Search(context.Background(), "host", SearchOptions{})
|
||||
if len(results) == 0 {
|
||||
t.Fatal("expected at least one result")
|
||||
}
|
||||
@@ -140,7 +140,7 @@ func TestSearch_TagsAreMapped(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
results, _ := c.Search(context.Background(), "")
|
||||
results, _ := c.Search(context.Background(), "", SearchOptions{})
|
||||
if len(results[0].Tags) != 2 {
|
||||
t.Errorf("tags: got %v, want [prod mgmt]", results[0].Tags)
|
||||
}
|
||||
@@ -161,7 +161,7 @@ func TestSearch_PartialFailure_ReturnsAvailableResults(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
results, err := c.Search(context.Background(), "")
|
||||
results, err := c.Search(context.Background(), "", SearchOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("partial failure should not return error, got: %v", err)
|
||||
}
|
||||
@@ -179,7 +179,7 @@ func TestSearch_BothFail_ReturnsError(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
_, err := c.Search(context.Background(), "")
|
||||
_, err := c.Search(context.Background(), "", SearchOptions{})
|
||||
if err == nil {
|
||||
t.Error("both endpoints failing should return an error")
|
||||
}
|
||||
@@ -289,7 +289,7 @@ func Test403_V1Token_HintsUpgrade(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "legacytoken", 1)
|
||||
_, err := c.Search(context.Background(), "host")
|
||||
_, err := c.Search(context.Background(), "host", SearchOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on 403")
|
||||
}
|
||||
@@ -305,7 +305,7 @@ func Test403_V2Token_NoV1Hint(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "nbt_secret", 2)
|
||||
_, err := c.Search(context.Background(), "host")
|
||||
_, err := c.Search(context.Background(), "host", SearchOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error on 403")
|
||||
}
|
||||
@@ -328,7 +328,7 @@ func TestGet_SendsAuthorizationHeader(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "nbt_mytoken", 2)
|
||||
c.Search(context.Background(), "") //nolint:errcheck
|
||||
c.Search(context.Background(), "", SearchOptions{}) //nolint:errcheck
|
||||
|
||||
want := "Token nbt_mytoken"
|
||||
if gotAuth != want {
|
||||
@@ -336,6 +336,88 @@ func TestGet_SendsAuthorizationHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchAll_PaginatesResults(t *testing.T) {
|
||||
// Simulate a NetBox endpoint with 3 total devices split across 2 pages.
|
||||
// count=3 throughout; first page has 2 results, second has 1.
|
||||
callCount := 0
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/dcim/devices/", func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
offset := r.URL.Query().Get("offset")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
var resp netboxListResponse[netboxDevice]
|
||||
switch offset {
|
||||
case "", "0":
|
||||
resp = netboxListResponse[netboxDevice]{
|
||||
Count: 3,
|
||||
Results: []netboxDevice{{ID: 1, Name: "d-01"}, {ID: 2, Name: "d-02"}},
|
||||
}
|
||||
default:
|
||||
resp = netboxListResponse[netboxDevice]{
|
||||
Count: 3,
|
||||
Results: []netboxDevice{{ID: 3, Name: "d-03"}},
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(resp)
|
||||
w.Write(b)
|
||||
})
|
||||
mux.HandleFunc("/api/virtualization/virtual-machines/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
b, _ := json.Marshal(vmListResponse())
|
||||
w.Write(b)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
results, err := c.SearchAll(context.Background(), "", SearchOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("SearchAll: %v", err)
|
||||
}
|
||||
if len(results) != 3 {
|
||||
t.Errorf("SearchAll: got %d results, want 3", len(results))
|
||||
}
|
||||
if callCount < 2 {
|
||||
t.Errorf("expected at least 2 device API calls for pagination, got %d", callCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_KindFilterDeviceOnly(t *testing.T) {
|
||||
srv := newTestServer(t, map[string]any{
|
||||
"/api/dcim/devices/": deviceListResponse(
|
||||
netboxDevice{ID: 1, Name: "sw-01"},
|
||||
),
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
results, err := c.Search(context.Background(), "", SearchOptions{Kind: "device"})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if len(results) != 1 || results[0].Name != "sw-01" {
|
||||
t.Errorf("expected 1 device result, got %v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_KindFilterVMOnly(t *testing.T) {
|
||||
srv := newTestServer(t, map[string]any{
|
||||
"/api/virtualization/virtual-machines/": vmListResponse(
|
||||
netboxVM{ID: 1, Name: "vm-01"},
|
||||
),
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
results, err := c.Search(context.Background(), "", SearchOptions{Kind: "vm"})
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if len(results) != 1 || results[0].Name != "vm-01" {
|
||||
t.Errorf("expected 1 vm result, got %v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripPrefix(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
package netbox
|
||||
|
||||
// SearchOptions filters a Search or SearchAll query.
|
||||
// Zero value means no filtering (return all kinds, no tag filter).
|
||||
type SearchOptions struct {
|
||||
Tag string // filter by tag slug; empty = no filter
|
||||
Kind string // "device" | "vm" | "" (both)
|
||||
}
|
||||
|
||||
// HostEntry is a unified model for both devices and virtual machines from NetBox.
|
||||
type HostEntry struct {
|
||||
ID int
|
||||
|
||||
Reference in New Issue
Block a user