Add local and NetBox server notes and improve reliability
Release / release (push) Successful in 6m52s
Release / release (push) Successful in 6m52s
This commit is contained in:
1 parent
2852dff2c4
commit
12cc64fdd9
17 files changed
+848
-83
No files matched your search
+180
-40
@@ -8,7 +8,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/bubbles/textarea"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
@@ -19,6 +21,7 @@ import (
|
||||
|
||||
// SelectedHost is returned when the user confirms a host in the TUI.
|
||||
type SelectedHost struct {
|
||||
Kind string
|
||||
Name string
|
||||
IP string
|
||||
User string // empty = use config default
|
||||
@@ -27,10 +30,19 @@ type SelectedHost struct {
|
||||
|
||||
// --- bubbletea messages ---
|
||||
|
||||
type debounceMsg struct{ query string }
|
||||
type debounceMsg struct {
|
||||
query string
|
||||
seq int
|
||||
}
|
||||
type noteResultMsg struct {
|
||||
note notes.Note
|
||||
err error
|
||||
saved bool
|
||||
}
|
||||
|
||||
type searchResultMsg struct {
|
||||
query string
|
||||
seq int
|
||||
entries []netbox.HostEntry
|
||||
err error
|
||||
recent bool // true when this is a recently-used list, not a search result
|
||||
@@ -40,6 +52,7 @@ type searchResultMsg struct {
|
||||
|
||||
type hostItem struct {
|
||||
name string
|
||||
id int
|
||||
ip string
|
||||
kind string
|
||||
tags []string
|
||||
@@ -120,27 +133,35 @@ func applyFilter(entries []netbox.HostEntry, f filterOpts) []netbox.HostEntry {
|
||||
// --- Model ---
|
||||
|
||||
type Model struct {
|
||||
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
|
||||
noteErr error
|
||||
editHost *hostItem
|
||||
noteInput textarea.Model
|
||||
noteService *notes.Service
|
||||
note notes.Note
|
||||
noteMode, noteRemote, noteBusy bool
|
||||
noteLoaded bool
|
||||
defaultPort 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, defaultUser string) *Model {
|
||||
func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ...int) *Model {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = "Search hostname…"
|
||||
ti.Focus()
|
||||
@@ -157,8 +178,18 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string) *Model {
|
||||
l.SetShowTitle(false)
|
||||
l.SetShowStatusBar(false)
|
||||
l.SetFilteringEnabled(false)
|
||||
l.DisableQuitKeybindings()
|
||||
port := 22
|
||||
if len(defaultPorts) > 0 && defaultPorts[0] > 0 {
|
||||
port = defaultPorts[0]
|
||||
}
|
||||
|
||||
ni := textarea.New()
|
||||
ni.CharLimit = 0
|
||||
ni.SetHeight(10)
|
||||
return &Model{
|
||||
noteInput: ni,
|
||||
defaultPort: port,
|
||||
input: ti,
|
||||
filterInput: fi,
|
||||
editInput: ei,
|
||||
@@ -191,6 +222,20 @@ func (m *Model) loadRecent() tea.Cmd {
|
||||
|
||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case noteResultMsg:
|
||||
m.noteBusy = false
|
||||
m.noteErr = msg.err
|
||||
if msg.err == nil {
|
||||
m.noteLoaded = true
|
||||
m.note = msg.note
|
||||
m.noteInput.SetValue(msg.note.Text)
|
||||
if msg.saved {
|
||||
m.noteMode = false
|
||||
m.noteInput.Blur()
|
||||
m.input.Focus()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
@@ -202,10 +247,43 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.editMode {
|
||||
extraRows += 2
|
||||
}
|
||||
m.list.SetSize(msg.Width, msg.Height-extraRows)
|
||||
m.list.SetSize(msg.Width, max(1, msg.Height-extraRows))
|
||||
m.noteInput.SetWidth(max(10, msg.Width-4))
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
if msg.String() == "ctrl+c" {
|
||||
return m, tea.Quit
|
||||
}
|
||||
if m.noteMode {
|
||||
if m.noteBusy {
|
||||
return m, nil
|
||||
}
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.noteMode = false
|
||||
m.noteInput.Blur()
|
||||
m.input.Focus()
|
||||
m.noteErr = nil
|
||||
return m, nil
|
||||
case "ctrl+s":
|
||||
if !m.noteLoaded {
|
||||
return m, nil
|
||||
}
|
||||
note := m.note
|
||||
note.Text = m.noteInput.Value()
|
||||
remote, service := m.noteRemote, m.noteService
|
||||
m.noteBusy = true
|
||||
return m, func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
return noteResultMsg{note: note, saved: true, err: service.Write(ctx, note, remote)}
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.noteInput, cmd = m.noteInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
// --- Edit mode ---
|
||||
if m.editMode {
|
||||
switch msg.String() {
|
||||
@@ -215,7 +293,16 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.input.Focus()
|
||||
return m, nil
|
||||
case "enter":
|
||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||
if m.editHost != nil {
|
||||
item := *m.editHost
|
||||
value := m.editInput.Value()
|
||||
if idx := strings.LastIndex(value, ":"); idx >= 0 {
|
||||
port, err := strconv.Atoi(strings.TrimSpace(value[idx+1:]))
|
||||
if err != nil || port < 1 || port > 65535 {
|
||||
m.err = fmt.Errorf("port must be between 1 and 65535")
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
m.selected = m.buildSelected(item)
|
||||
}
|
||||
return m, tea.Quit
|
||||
@@ -261,21 +348,44 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case "enter":
|
||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||
m.selected = &SelectedHost{Name: item.name, IP: item.ip}
|
||||
m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind}
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
case "e":
|
||||
case "ctrl+e":
|
||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||
m.openEdit(item)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case "ctrl+n", "ctrl+b":
|
||||
if item, ok := m.list.SelectedItem().(hostItem); ok && m.noteService != nil {
|
||||
m.noteMode = true
|
||||
m.noteLoaded = false
|
||||
m.noteBusy = true
|
||||
m.noteRemote = msg.String() == "ctrl+b"
|
||||
m.input.Blur()
|
||||
m.noteInput.Focus()
|
||||
m.noteInput.SetValue("")
|
||||
m.noteErr = nil
|
||||
host := netbox.HostEntry{ID: item.id, Name: item.name, Kind: item.kind}
|
||||
m.note = notes.Note{Host: host}
|
||||
remote, service := m.noteRemote, m.noteService
|
||||
return m, func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
note, err := service.Read(ctx, host, remote)
|
||||
return noteResultMsg{note: note, err: err}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
case "tab":
|
||||
if m.list.Items() != nil && len(m.list.Items()) > 0 {
|
||||
if item, ok := m.list.Items()[0].(hostItem); ok {
|
||||
m.input.SetValue(item.name)
|
||||
m.input.CursorEnd()
|
||||
m.seq++
|
||||
return m, m.startDebounce()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
@@ -283,24 +393,26 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
case debounceMsg:
|
||||
q := m.input.Value()
|
||||
if q == m.lastSent {
|
||||
if msg.seq != m.seq || msg.query != q {
|
||||
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 {
|
||||
if m.input.Value() != "" || m.seq > 0 {
|
||||
return m, nil
|
||||
}
|
||||
m.recentMode = true
|
||||
m.lastResults = msg.entries
|
||||
m.updateListItems(msg.entries)
|
||||
return m, nil
|
||||
}
|
||||
if msg.query != m.lastSent {
|
||||
if msg.seq != m.seq || msg.query != m.input.Value() {
|
||||
return m, nil // discard stale result
|
||||
}
|
||||
m.loading = false
|
||||
@@ -314,12 +426,21 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if m.noteMode {
|
||||
var cmd tea.Cmd
|
||||
m.noteInput, cmd = m.noteInput.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
// Forward to search input and restart debounce timer.
|
||||
var cmds []tea.Cmd
|
||||
var inputCmd tea.Cmd
|
||||
oldQuery := m.input.Value()
|
||||
m.input, inputCmd = m.input.Update(msg)
|
||||
cmds = append(cmds, inputCmd)
|
||||
cmds = append(cmds, m.startDebounce())
|
||||
if m.input.Value() != oldQuery {
|
||||
m.seq++
|
||||
cmds = append(cmds, m.startDebounce())
|
||||
}
|
||||
|
||||
var listCmd tea.Cmd
|
||||
m.list, listCmd = m.list.Update(msg)
|
||||
@@ -329,6 +450,21 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m *Model) View() string {
|
||||
if m.noteMode {
|
||||
target := "local"
|
||||
if m.noteRemote {
|
||||
target = "NetBox comments"
|
||||
}
|
||||
status := "ctrl+s save esc cancel"
|
||||
if m.noteBusy {
|
||||
status = "Loading / saving…"
|
||||
}
|
||||
errText := ""
|
||||
if m.noteErr != nil {
|
||||
errText = "\nError: " + m.noteErr.Error()
|
||||
}
|
||||
return fmt.Sprintf("Notes [%s] — %s\n\n%s\n%s%s\n", target, m.note.Host.Name, m.noteInput.View(), status, errText)
|
||||
}
|
||||
var sb strings.Builder
|
||||
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("netssh")
|
||||
@@ -358,7 +494,7 @@ func (m *Model) View() string {
|
||||
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"
|
||||
hint := "ctrl+c quit enter connect ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox"
|
||||
sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n")
|
||||
}
|
||||
|
||||
@@ -371,8 +507,9 @@ func (m *Model) Selected() *SelectedHost {
|
||||
}
|
||||
|
||||
func (m *Model) startDebounce() tea.Cmd {
|
||||
query, seq := m.input.Value(), m.seq
|
||||
return tea.Tick(300*time.Millisecond, func(_ time.Time) tea.Msg {
|
||||
return debounceMsg{query: m.input.Value()}
|
||||
return debounceMsg{query: query, seq: seq}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -386,20 +523,19 @@ func (m *Model) doSearch(query string, seq int) tea.Cmd {
|
||||
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, entries: entries}
|
||||
return searchResultMsg{query: query, seq: seq, entries: entries}
|
||||
}
|
||||
}
|
||||
|
||||
if m.client == nil {
|
||||
return searchResultMsg{query: query, entries: nil}
|
||||
return searchResultMsg{query: query, seq: seq, entries: nil}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
entries, err := m.client.Search(ctx, query, opts)
|
||||
_ = seq
|
||||
return searchResultMsg{query: query, entries: entries, err: err}
|
||||
return searchResultMsg{query: query, seq: seq, entries: entries, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,7 +559,7 @@ func (m *Model) updateListItems(entries []netbox.HostEntry) {
|
||||
if ip == "" {
|
||||
ip = e.PrimaryIP6
|
||||
}
|
||||
items[i] = hostItem{name: e.Name, ip: ip, kind: e.Kind, tags: e.Tags}
|
||||
items[i] = hostItem{id: e.ID, name: e.Name, ip: ip, kind: e.Kind, tags: e.Tags}
|
||||
}
|
||||
m.list.SetItems(items)
|
||||
}
|
||||
@@ -431,13 +567,15 @@ func (m *Model) updateListItems(entries []netbox.HostEntry) {
|
||||
// openEdit switches to edit mode for the given list item.
|
||||
func (m *Model) openEdit(item hostItem) {
|
||||
m.editMode = true
|
||||
m.editHost = &item
|
||||
m.input.Blur()
|
||||
|
||||
user := m.defaultUser
|
||||
if user == "" {
|
||||
user = item.name
|
||||
prefix := ""
|
||||
if user != "" {
|
||||
prefix = user + "@"
|
||||
}
|
||||
m.editInput.SetValue(fmt.Sprintf("%s@%s:22", user, item.name))
|
||||
m.editInput.SetValue(fmt.Sprintf("%s%s:%d", prefix, item.name, m.defaultPort))
|
||||
m.editInput.CursorEnd()
|
||||
m.editInput.Focus()
|
||||
}
|
||||
@@ -445,7 +583,7 @@ func (m *Model) openEdit(item hostItem) {
|
||||
// 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}
|
||||
sel := &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind}
|
||||
|
||||
// Extract user: everything before @
|
||||
if idx := strings.Index(s, "@"); idx != -1 {
|
||||
@@ -456,10 +594,12 @@ func (m *Model) buildSelected(item hostItem) *SelectedHost {
|
||||
// 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" {
|
||||
if port, err := strconv.Atoi(portStr); err == nil && port > 0 && port <= 65535 {
|
||||
sel.Port = portStr
|
||||
}
|
||||
}
|
||||
|
||||
return sel
|
||||
}
|
||||
|
||||
func (m *Model) SetNotes(service *notes.Service) { m.noteService = service }
|
||||
Reference in new issue
Block a user