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:
+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