Add local and NetBox server notes and improve reliability
Release / release (push) Successful in 6m52s

This commit is contained in:
Sebastian Unterschütz committed 2026-10-27 17:51:59 +01:00
1 parent 2852dff2c4
commit 12cc64fdd9
17 files changed
+848 -83

No files matched your search

+180 -40
View File
@@ -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 }