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:
Vendored
+35
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -15,6 +16,7 @@ type Entry struct {
|
||||
Kind string `json:"kind"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
CachedAt time.Time `json:"cached_at"`
|
||||
LastUsed time.Time `json:"last_used,omitempty"`
|
||||
}
|
||||
|
||||
type Cache struct {
|
||||
@@ -86,6 +88,39 @@ func (c *Cache) Upsert(e Entry) {
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// MarkUsed records the current time as LastUsed for the named entry.
|
||||
// It is a no-op if the entry does not exist.
|
||||
func (c *Cache) MarkUsed(name string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if e, ok := c.entries[name]; ok {
|
||||
e.LastUsed = time.Now()
|
||||
c.entries[name] = e
|
||||
}
|
||||
}
|
||||
|
||||
// RecentlyUsed returns the n most recently used entries, sorted by LastUsed desc.
|
||||
// Entries that have never been used (LastUsed zero) are excluded.
|
||||
// If n <= 0, all used entries are returned.
|
||||
func (c *Cache) RecentlyUsed(n int) []Entry {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
var used []Entry
|
||||
for _, e := range c.entries {
|
||||
if !e.LastUsed.IsZero() {
|
||||
used = append(used, e)
|
||||
}
|
||||
}
|
||||
sort.Slice(used, func(i, j int) bool {
|
||||
return used[i].LastUsed.After(used[j].LastUsed)
|
||||
})
|
||||
if n > 0 && len(used) > n {
|
||||
used = used[:n]
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
// Search returns all entries whose name starts with prefix (case-insensitive).
|
||||
// TTL is intentionally ignored — this is used for shell completion.
|
||||
func (c *Cache) Search(prefix string) []Entry {
|
||||
|
||||
Vendored
+80
@@ -220,6 +220,86 @@ func TestSave_ProducesValidJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkUsed_SetsLastUsed(t *testing.T) {
|
||||
c := New("", 60)
|
||||
c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device"})
|
||||
|
||||
before := time.Now()
|
||||
c.MarkUsed("host")
|
||||
|
||||
e, _ := c.Get("host")
|
||||
if e.LastUsed.Before(before) {
|
||||
t.Error("LastUsed should be set to current time by MarkUsed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkUsed_NoopForMissingEntry(t *testing.T) {
|
||||
c := New("", 60)
|
||||
c.MarkUsed("nonexistent") // should not panic
|
||||
}
|
||||
|
||||
func TestRecentlyUsed_ReturnsTopN(t *testing.T) {
|
||||
c := New("", 60)
|
||||
c.Upsert(Entry{Name: "a", IP: "1.1.1.1", Kind: "device"})
|
||||
c.Upsert(Entry{Name: "b", IP: "2.2.2.2", Kind: "device"})
|
||||
c.Upsert(Entry{Name: "c", IP: "3.3.3.3", Kind: "device"})
|
||||
|
||||
c.MarkUsed("c")
|
||||
time.Sleep(time.Millisecond)
|
||||
c.MarkUsed("a")
|
||||
|
||||
results := c.RecentlyUsed(2)
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("RecentlyUsed(2): got %d results, want 2", len(results))
|
||||
}
|
||||
if results[0].Name != "a" {
|
||||
t.Errorf("first result: got %q, want %q", results[0].Name, "a")
|
||||
}
|
||||
if results[1].Name != "c" {
|
||||
t.Errorf("second result: got %q, want %q", results[1].Name, "c")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentlyUsed_ExcludesNeverUsed(t *testing.T) {
|
||||
c := New("", 60)
|
||||
c.Upsert(Entry{Name: "used", IP: "1.1.1.1", Kind: "device"})
|
||||
c.Upsert(Entry{Name: "unused", IP: "2.2.2.2", Kind: "device"})
|
||||
c.MarkUsed("used")
|
||||
|
||||
results := c.RecentlyUsed(10)
|
||||
if len(results) != 1 || results[0].Name != "used" {
|
||||
t.Errorf("RecentlyUsed should exclude entries with zero LastUsed, got %v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecentlyUsed_EmptyCache(t *testing.T) {
|
||||
c := New("", 60)
|
||||
if results := c.RecentlyUsed(10); len(results) != 0 {
|
||||
t.Errorf("RecentlyUsed on empty cache: got %d, want 0", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkUsed_RoundtripViaSave(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "cache.json")
|
||||
c := New(path, 3600)
|
||||
c.Upsert(Entry{Name: "host", IP: "10.0.0.1", Kind: "device"})
|
||||
c.MarkUsed("host")
|
||||
|
||||
if err := c.Save(); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
c2 := New(path, 3600)
|
||||
if err := c2.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
results := c2.RecentlyUsed(1)
|
||||
if len(results) != 1 || results[0].Name != "host" {
|
||||
t.Errorf("LastUsed not persisted: %v", results)
|
||||
}
|
||||
}
|
||||
|
||||
// tempFile writes content to a temp file and returns its path.
|
||||
func tempFile(t *testing.T, content []byte) string {
|
||||
t.Helper()
|
||||
|
||||
+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
|
||||
|
||||
+37
-11
@@ -20,13 +20,13 @@ func RunWizard(cfg *config.Config) error {
|
||||
url := cfg.NetBox.URL
|
||||
token := cfg.NetBox.Token
|
||||
defaultUser := cfg.SSH.DefaultUser
|
||||
strategies := cfg.Resolver.Strategies
|
||||
strategiesRaw := strings.Join(cfg.Resolver.Strategies, ", ")
|
||||
subnets := strings.Join(cfg.Resolver.ManagementSubnets, ", ")
|
||||
interfaceName := cfg.Resolver.InterfaceName
|
||||
cacheTTL := strconv.Itoa(cfg.Cache.TTL)
|
||||
|
||||
if len(strategies) == 0 {
|
||||
strategies = []string{"primary_ip"}
|
||||
if strategiesRaw == "" {
|
||||
strategiesRaw = "primary_ip"
|
||||
}
|
||||
if cacheTTL == "0" {
|
||||
cacheTTL = "3600"
|
||||
@@ -65,15 +65,12 @@ func RunWizard(cfg *config.Config) error {
|
||||
).Title("SSH defaults"),
|
||||
|
||||
huh.NewGroup(
|
||||
huh.NewMultiSelect[string]().
|
||||
huh.NewInput().
|
||||
Title("Resolver strategies").
|
||||
Description("Order matters: first match wins.").
|
||||
Options(
|
||||
huh.NewOption("primary_ip — NetBox primary IPv4/IPv6", "primary_ip"),
|
||||
huh.NewOption("management_subnet — first IP inside a subnet", "management_subnet"),
|
||||
huh.NewOption("interface_name — IP on a named interface", "interface_name"),
|
||||
).
|
||||
Value(&strategies),
|
||||
Description("Comma-separated, in priority order. First match wins.\nAvailable: primary_ip, management_subnet, interface_name").
|
||||
Placeholder("primary_ip, management_subnet").
|
||||
Value(&strategiesRaw).
|
||||
Validate(validateStrategies),
|
||||
huh.NewInput().
|
||||
Title("Management subnets").
|
||||
Description("Comma-separated CIDRs, e.g. 10.0.0.0/8, 192.168.0.0/16\nOnly used when management_subnet strategy is active.").
|
||||
@@ -103,6 +100,7 @@ func RunWizard(cfg *config.Config) error {
|
||||
return err
|
||||
}
|
||||
|
||||
strategies := parseStrategies(strategiesRaw)
|
||||
tokenVersion := netbox.TokenVersion(token)
|
||||
if tokenVersion == 1 {
|
||||
fmt.Fprintln(os.Stderr, "\nHinweis: Du verwendest einen Legacy-Token (v1). Erstelle in NetBox einen v2-Token (beginnt mit nbt_) für bessere Kompatibilität.")
|
||||
@@ -140,6 +138,34 @@ func RunWizard(cfg *config.Config) error {
|
||||
return save(out)
|
||||
}
|
||||
|
||||
var knownStrategies = map[string]bool{
|
||||
"primary_ip": true,
|
||||
"management_subnet": true,
|
||||
"interface_name": true,
|
||||
}
|
||||
|
||||
func validateStrategies(s string) error {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return errors.New("at least one strategy is required")
|
||||
}
|
||||
for _, name := range parseStrategies(s) {
|
||||
if !knownStrategies[name] {
|
||||
return fmt.Errorf("unknown strategy %q — available: primary_ip, management_subnet, interface_name", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseStrategies(s string) []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(s, ",") {
|
||||
if name := strings.TrimSpace(part); name != "" {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func save(cfg config.Config) error {
|
||||
path := config.Path()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
|
||||
@@ -116,6 +116,68 @@ func TestSave_CreatesConfigDir(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStrategies(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"primary_ip", []string{"primary_ip"}},
|
||||
{"management_subnet, primary_ip", []string{"management_subnet", "primary_ip"}},
|
||||
{"primary_ip,management_subnet,interface_name", []string{"primary_ip", "management_subnet", "interface_name"}},
|
||||
{" primary_ip , management_subnet ", []string{"primary_ip", "management_subnet"}},
|
||||
{"", nil},
|
||||
{" , ", nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := parseStrategies(tt.in)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Errorf("parseStrategies(%q): got %v, want %v", tt.in, got, tt.want)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("parseStrategies(%q)[%d]: got %q, want %q", tt.in, i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStrategies_PreservesOrder(t *testing.T) {
|
||||
got := parseStrategies("interface_name, management_subnet, primary_ip")
|
||||
want := []string{"interface_name", "management_subnet", "primary_ip"}
|
||||
for i, s := range got {
|
||||
if s != want[i] {
|
||||
t.Errorf("order not preserved at [%d]: got %q, want %q", i, s, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStrategies_Valid(t *testing.T) {
|
||||
cases := []string{
|
||||
"primary_ip",
|
||||
"management_subnet, primary_ip",
|
||||
"interface_name, management_subnet, primary_ip",
|
||||
}
|
||||
for _, c := range cases {
|
||||
if err := validateStrategies(c); err != nil {
|
||||
t.Errorf("validateStrategies(%q) should be valid, got: %v", c, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStrategies_Invalid(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
"unknown_strategy",
|
||||
"primary_ip, typo",
|
||||
}
|
||||
for _, c := range cases {
|
||||
if err := validateStrategies(c); err == nil {
|
||||
t.Errorf("validateStrategies(%q) should return an error", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSave_RoundtripViaLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
orig := os.Getenv("XDG_CONFIG_HOME")
|
||||
|
||||
+260
-32
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -20,6 +21,8 @@ import (
|
||||
type SelectedHost struct {
|
||||
Name string
|
||||
IP string
|
||||
User string // empty = use config default
|
||||
Port string // empty = use default port
|
||||
}
|
||||
|
||||
// --- bubbletea messages ---
|
||||
@@ -30,6 +33,7 @@ type searchResultMsg struct {
|
||||
query string
|
||||
entries []netbox.HostEntry
|
||||
err error
|
||||
recent bool // true when this is a recently-used list, not a search result
|
||||
}
|
||||
|
||||
// --- list item ---
|
||||
@@ -38,6 +42,7 @@ type hostItem struct {
|
||||
name string
|
||||
ip string
|
||||
kind string
|
||||
tags []string
|
||||
}
|
||||
|
||||
func (h hostItem) Title() string { return h.name }
|
||||
@@ -63,27 +68,90 @@ func (d compactDelegate) Render(w io.Writer, m list.Model, index int, item list.
|
||||
fmt.Fprintln(w, line)
|
||||
}
|
||||
|
||||
// --- filter ---
|
||||
|
||||
type filterOpts struct {
|
||||
tags []string
|
||||
kind string
|
||||
}
|
||||
|
||||
func parseFilter(s string) filterOpts {
|
||||
var f filterOpts
|
||||
for _, part := range strings.Fields(s) {
|
||||
if after, ok := strings.CutPrefix(part, "tag:"); ok {
|
||||
f.tags = append(f.tags, after)
|
||||
} else if after, ok := strings.CutPrefix(part, "kind:"); ok {
|
||||
f.kind = after
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func applyFilter(entries []netbox.HostEntry, f filterOpts) []netbox.HostEntry {
|
||||
if len(f.tags) == 0 && f.kind == "" {
|
||||
return entries
|
||||
}
|
||||
out := make([]netbox.HostEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if f.kind != "" && e.Kind != f.kind {
|
||||
continue
|
||||
}
|
||||
if len(f.tags) > 0 {
|
||||
tagSet := make(map[string]bool, len(e.Tags))
|
||||
for _, t := range e.Tags {
|
||||
tagSet[strings.ToLower(t)] = true
|
||||
}
|
||||
allMatch := true
|
||||
for _, want := range f.tags {
|
||||
if !tagSet[strings.ToLower(want)] {
|
||||
allMatch = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allMatch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Model ---
|
||||
|
||||
type Model struct {
|
||||
input textinput.Model
|
||||
list list.Model
|
||||
client *netbox.Client
|
||||
cache *cache.Cache
|
||||
lastSent string // last query sent to NetBox (or served from cache)
|
||||
seq int // sequence number to discard stale results
|
||||
loading bool
|
||||
err error
|
||||
selected *SelectedHost
|
||||
width int
|
||||
height int
|
||||
input textinput.Model
|
||||
filterInput textinput.Model // tag:X kind:Y filter, toggled with ctrl+f
|
||||
editInput textinput.Model // user@host:port inline editor
|
||||
list list.Model
|
||||
client *netbox.Client
|
||||
cache *cache.Cache
|
||||
defaultUser string
|
||||
lastSent string // last query sent to NetBox (or served from cache)
|
||||
lastResults []netbox.HostEntry // raw results before filter applied
|
||||
seq int // sequence number to discard stale results
|
||||
loading bool
|
||||
err error
|
||||
selected *SelectedHost
|
||||
width int
|
||||
height int
|
||||
recentMode bool // true when showing recent hosts (empty search, initial state)
|
||||
filterOpen bool // Ctrl+F toggles
|
||||
editMode bool // 'e' on selected item
|
||||
}
|
||||
|
||||
func New(client *netbox.Client, c *cache.Cache) *Model {
|
||||
func New(client *netbox.Client, c *cache.Cache, defaultUser string) *Model {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "Search hostname…"
|
||||
ti.Focus()
|
||||
|
||||
fi := textinput.New()
|
||||
fi.Placeholder = "tag:prod kind:vm"
|
||||
fi.Prompt = "Filter: "
|
||||
|
||||
ei := textinput.New()
|
||||
ei.Prompt = ""
|
||||
|
||||
l := list.New(nil, compactDelegate{}, 0, 0)
|
||||
l.SetShowHelp(false)
|
||||
l.SetShowTitle(false)
|
||||
@@ -91,15 +159,34 @@ func New(client *netbox.Client, c *cache.Cache) *Model {
|
||||
l.SetFilteringEnabled(false)
|
||||
|
||||
return &Model{
|
||||
input: ti,
|
||||
list: l,
|
||||
client: client,
|
||||
cache: c,
|
||||
input: ti,
|
||||
filterInput: fi,
|
||||
editInput: ei,
|
||||
list: l,
|
||||
client: client,
|
||||
cache: c,
|
||||
defaultUser: defaultUser,
|
||||
recentMode: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) Init() tea.Cmd {
|
||||
return textinput.Blink
|
||||
return tea.Batch(textinput.Blink, m.loadRecent())
|
||||
}
|
||||
|
||||
// loadRecent returns a Cmd that immediately resolves to the recently-used host list.
|
||||
func (m *Model) loadRecent() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if m.cache == nil {
|
||||
return searchResultMsg{query: "", entries: nil, recent: true}
|
||||
}
|
||||
recent := m.cache.RecentlyUsed(10)
|
||||
entries := make([]netbox.HostEntry, len(recent))
|
||||
for i, e := range recent {
|
||||
entries[i] = netbox.HostEntry{Name: e.Name, PrimaryIP4: e.IP, Kind: e.Kind, Tags: e.Tags}
|
||||
}
|
||||
return searchResultMsg{query: "", entries: entries, recent: true}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -108,22 +195,83 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.list.SetSize(msg.Width, msg.Height-4)
|
||||
extraRows := 4
|
||||
if m.filterOpen {
|
||||
extraRows++
|
||||
}
|
||||
if m.editMode {
|
||||
extraRows += 2
|
||||
}
|
||||
m.list.SetSize(msg.Width, msg.Height-extraRows)
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
// --- Edit mode ---
|
||||
if m.editMode {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.editMode = false
|
||||
m.editInput.Blur()
|
||||
m.input.Focus()
|
||||
return m, nil
|
||||
case "enter":
|
||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||
m.selected = m.buildSelected(item)
|
||||
}
|
||||
return m, tea.Quit
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.editInput, cmd = m.editInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// --- Filter focused ---
|
||||
if m.filterOpen {
|
||||
switch msg.String() {
|
||||
case "ctrl+f", "esc":
|
||||
m.filterOpen = false
|
||||
m.filterInput.Blur()
|
||||
m.filterInput.SetValue("")
|
||||
m.input.Focus()
|
||||
m.updateListItems(m.lastResults)
|
||||
return m, nil
|
||||
case "enter":
|
||||
m.filterOpen = false
|
||||
m.filterInput.Blur()
|
||||
m.input.Focus()
|
||||
m.updateListItems(m.lastResults)
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.filterInput, cmd = m.filterInput.Update(msg)
|
||||
m.updateListItems(m.lastResults)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// --- Normal mode ---
|
||||
switch msg.String() {
|
||||
case "ctrl+c", "esc":
|
||||
return m, tea.Quit
|
||||
|
||||
case "ctrl+f":
|
||||
m.filterOpen = true
|
||||
m.input.Blur()
|
||||
m.filterInput.Focus()
|
||||
return m, nil
|
||||
|
||||
case "enter":
|
||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||
m.selected = &SelectedHost{Name: item.name, IP: item.ip}
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
case "e":
|
||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||
m.openEdit(item)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case "tab":
|
||||
// Copy the top result into the search field.
|
||||
if m.list.Items() != nil && len(m.list.Items()) > 0 {
|
||||
if item, ok := m.list.Items()[0].(hostItem); ok {
|
||||
m.input.SetValue(item.name)
|
||||
@@ -134,18 +282,24 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
case debounceMsg:
|
||||
// Only query if the input has changed since the last request.
|
||||
q := m.input.Value()
|
||||
if q == m.lastSent {
|
||||
return m, nil
|
||||
}
|
||||
m.lastSent = q
|
||||
m.recentMode = false
|
||||
m.loading = true
|
||||
m.seq++
|
||||
seq := m.seq
|
||||
return m, m.doSearch(q, seq)
|
||||
|
||||
case searchResultMsg:
|
||||
if msg.recent {
|
||||
m.recentMode = true
|
||||
m.lastResults = msg.entries
|
||||
m.updateListItems(msg.entries)
|
||||
return m, nil
|
||||
}
|
||||
if msg.query != m.lastSent {
|
||||
return m, nil // discard stale result
|
||||
}
|
||||
@@ -154,20 +308,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.err = msg.err
|
||||
return m, nil
|
||||
}
|
||||
items := make([]list.Item, len(msg.entries))
|
||||
for i, e := range msg.entries {
|
||||
ip := e.PrimaryIP4
|
||||
if ip == "" {
|
||||
ip = e.PrimaryIP6
|
||||
}
|
||||
items[i] = hostItem{name: e.Name, ip: ip, kind: e.Kind}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
m.lastResults = msg.entries
|
||||
m.updateListItems(msg.entries)
|
||||
m.err = nil
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Forward to text input and restart the debounce timer.
|
||||
// Forward to search input and restart debounce timer.
|
||||
var cmds []tea.Cmd
|
||||
var inputCmd tea.Cmd
|
||||
m.input, inputCmd = m.input.Update(msg)
|
||||
@@ -188,14 +335,33 @@ func (m *Model) View() string {
|
||||
sb.WriteString(title + "\n\n")
|
||||
sb.WriteString(m.input.View() + "\n")
|
||||
|
||||
if m.filterOpen {
|
||||
sb.WriteString(m.filterInput.View() + "\n")
|
||||
}
|
||||
|
||||
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.recentMode && m.input.Value() == "" {
|
||||
if len(m.list.Items()) > 0 {
|
||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" Recent connections") + "\n")
|
||||
}
|
||||
}
|
||||
sb.WriteString(m.list.View())
|
||||
}
|
||||
|
||||
if m.editMode {
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render("Connect as: "))
|
||||
sb.WriteString(m.editInput.View() + "\n")
|
||||
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n")
|
||||
} else {
|
||||
hint := "ctrl+c quit enter connect e edit ctrl+f filter"
|
||||
sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -211,13 +377,14 @@ 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.
|
||||
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}
|
||||
entries[i] = netbox.HostEntry{Name: c.Name, PrimaryIP4: c.IP, Kind: c.Kind, Tags: c.Tags}
|
||||
}
|
||||
return searchResultMsg{query: query, entries: entries}
|
||||
}
|
||||
@@ -230,8 +397,69 @@ func (m *Model) doSearch(query string, seq int) tea.Cmd {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
entries, err := m.client.Search(ctx, query)
|
||||
entries, err := m.client.Search(ctx, query, opts)
|
||||
_ = seq
|
||||
return searchResultMsg{query: query, entries: entries, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// netboxSearchOpts derives SearchOptions from the current filter input.
|
||||
func (m *Model) netboxSearchOpts() netbox.SearchOptions {
|
||||
f := parseFilter(m.filterInput.Value())
|
||||
var tag string
|
||||
if len(f.tags) > 0 {
|
||||
tag = f.tags[0]
|
||||
}
|
||||
return netbox.SearchOptions{Tag: tag, Kind: f.kind}
|
||||
}
|
||||
|
||||
// updateListItems applies the active filter and sets the list items.
|
||||
func (m *Model) updateListItems(entries []netbox.HostEntry) {
|
||||
f := parseFilter(m.filterInput.Value())
|
||||
filtered := applyFilter(entries, f)
|
||||
items := make([]list.Item, len(filtered))
|
||||
for i, e := range filtered {
|
||||
ip := e.PrimaryIP4
|
||||
if ip == "" {
|
||||
ip = e.PrimaryIP6
|
||||
}
|
||||
items[i] = hostItem{name: e.Name, ip: ip, kind: e.Kind, tags: e.Tags}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
}
|
||||
|
||||
// openEdit switches to edit mode for the given list item.
|
||||
func (m *Model) openEdit(item hostItem) {
|
||||
m.editMode = true
|
||||
m.input.Blur()
|
||||
|
||||
user := m.defaultUser
|
||||
if user == "" {
|
||||
user = item.name
|
||||
}
|
||||
m.editInput.SetValue(fmt.Sprintf("%s@%s:22", user, item.name))
|
||||
m.editInput.CursorEnd()
|
||||
m.editInput.Focus()
|
||||
}
|
||||
|
||||
// buildSelected parses the editInput to extract user/port overrides.
|
||||
func (m *Model) buildSelected(item hostItem) *SelectedHost {
|
||||
s := strings.TrimSpace(m.editInput.Value())
|
||||
sel := &SelectedHost{Name: item.name, IP: item.ip}
|
||||
|
||||
// Extract user: everything before @
|
||||
if idx := strings.Index(s, "@"); idx != -1 {
|
||||
sel.User = strings.TrimSpace(s[:idx])
|
||||
s = s[idx+1:]
|
||||
}
|
||||
|
||||
// Extract port: after the last colon, if it's a number
|
||||
if idx := strings.LastIndex(s, ":"); idx != -1 {
|
||||
portStr := strings.TrimSpace(s[idx+1:])
|
||||
if _, err := strconv.Atoi(portStr); err == nil && portStr != "22" {
|
||||
sel.Port = portStr
|
||||
}
|
||||
}
|
||||
|
||||
return sel
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user