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()
|
||||
|
||||
Reference in New Issue
Block a user