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
+821
-56
No files matched your search
@@ -20,7 +20,8 @@ netssh -p 2222 admin@app-server-03 uptime
|
||||
- **Interactive TUI** — fuzzy search with live NetBox queries and 300 ms debouncing (start with `netssh`, no arguments)
|
||||
- **Recently-used list** — TUI opens with your 10 most-recently-connected hosts, no typing needed
|
||||
- **Tag/kind filter** — press `Ctrl+F` in the TUI to filter by `tag:prod` or `kind:vm`
|
||||
- **User/port override** — press `e` in the TUI to override the SSH user or port before connecting
|
||||
- **User/port override** — press `Ctrl+E` in the TUI to override the SSH user or port before connecting
|
||||
- **Server notes** — multiline local notes or optional NetBox comments, available in the TUI and CLI
|
||||
- **Persistent cache** — successful lookups are cached to `~/.cache/netssh/hosts.json` for instant shell completion
|
||||
- **Full pagination** — `cache refresh` fetches all hosts from NetBox (not just the first 50)
|
||||
- **Selective refresh** — `cache refresh --tag prod --kind vm` limits what gets synced
|
||||
@@ -235,7 +236,9 @@ The TUI opens with your 10 most-recently-connected hosts. Start typing to search
|
||||
| `Tab` | autocomplete top result into the search field |
|
||||
| `↑` / `↓` | navigate results |
|
||||
| `Enter` | connect to selected host |
|
||||
| `e` | open inline editor to override user/port before connecting |
|
||||
| `Ctrl+E` | open inline editor to override user/port before connecting |
|
||||
| `Ctrl+N` | edit local notes for the selected server |
|
||||
| `Ctrl+B` | edit NetBox comments for the selected server |
|
||||
| `Ctrl+F` | open/close tag and kind filter (`tag:prod kind:vm`) |
|
||||
| `Esc` / `Ctrl+C` | quit (or close filter/edit if open) |
|
||||
|
||||
@@ -247,13 +250,48 @@ Filter: tag:prod kind:vm
|
||||
|
||||
Multiple `tag:` values are AND-combined. The filter is applied locally against the cache; when doing a live NetBox search the first tag is also forwarded as a query parameter.
|
||||
|
||||
**User/port override** — press `e` on any highlighted host:
|
||||
**User/port override** — press `Ctrl+E` on any highlighted host:
|
||||
|
||||
```
|
||||
Connect as: admin@my-router:22
|
||||
```
|
||||
|
||||
Edit the pre-filled value and press `Enter` to connect. `Esc` cancels. Port 22 is treated as default and omitted from the ssh command.
|
||||
Edit the pre-filled value and press `Enter` to connect. `Esc` cancels. The configured default port is pre-filled. An explicit port 22 overrides a different configured default.
|
||||
|
||||
### Server notes
|
||||
|
||||
Select a server in the TUI and press `Ctrl+N` for local notes, or `Ctrl+B` for
|
||||
NetBox comments. Both open a multiline editor: `Ctrl+S` saves, `Esc` cancels.
|
||||
Existing text is loaded before editing. A failed save keeps your draft in the editor.
|
||||
Local notes and NetBox comments are independent; there is no automatic synchronization.
|
||||
|
||||
The CLI also supports reading, replacing, and appending notes:
|
||||
|
||||
```sh
|
||||
netssh notes set web01 "Maintenance on Friday"
|
||||
netssh notes append web01 "Reboot completed"
|
||||
netssh notes show web01
|
||||
printf 'First line\nSecond line\n' | netssh notes set web01
|
||||
netssh notes append web01 --netbox --kind vm "Maintenance completed"
|
||||
netssh notes show web01 --netbox --kind vm
|
||||
```
|
||||
|
||||
`set` replaces the selected note; `append` preserves existing text and adds a new line.
|
||||
Omitting the text reads it from standard input. An empty string clears the note.
|
||||
For an uncached local host, specify `--kind device` or `--kind vm`; local operations
|
||||
work offline. Use the full server name as displayed in the TUI.
|
||||
|
||||
Local notes live under `~/.config/netssh/notes/` (`$XDG_CONFIG_HOME` is respected),
|
||||
with file permissions `0600` and atomic file replacement. They are keyed by NetBox
|
||||
URL, host kind, and hostname, so cache refreshes and `cache clear` preserve them.
|
||||
Changing the hostname or NetBox URL creates a separate note identity.
|
||||
|
||||
`--netbox` explicitly selects the device/VM's shared `comments` field and requires
|
||||
a token with write permissions. Existing comments are loaded before modification;
|
||||
ambiguous names are rejected (specify `--kind` where appropriate).
|
||||
The client uses the API's ETag/If-Match conflict protection when the server provides
|
||||
it; older NetBox versions without ETags use the server's last-write-wins behavior.
|
||||
See the [NetBox REST API documentation](https://netbox.readthedocs.io/en/stable/integrations/rest-api/).
|
||||
|
||||
### Cache management
|
||||
|
||||
|
||||
+37
-4
@@ -19,6 +19,7 @@ import (
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/hook"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/resolver"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/setup"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/shortcuts"
|
||||
@@ -29,6 +30,7 @@ import (
|
||||
// managedSubcommands are dispatched to cobra; everything else is treated as SSH wrapper mode.
|
||||
var managedSubcommands = map[string]bool{
|
||||
"configure": true,
|
||||
"notes": true,
|
||||
"search": true,
|
||||
"cache": true,
|
||||
"alias": true,
|
||||
@@ -213,7 +215,8 @@ func runTUI() {
|
||||
nbClient = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
||||
}
|
||||
|
||||
m := tui.New(nbClient, c, cfg.SSH.DefaultUser)
|
||||
m := tui.New(nbClient, c, cfg.SSH.DefaultUser, cfg.SSH.DefaultPort)
|
||||
m.SetNotes(notes.New(cfg.NetBox.URL, nbClient))
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
final, err := p.Run()
|
||||
if err != nil {
|
||||
@@ -229,6 +232,29 @@ func runTUI() {
|
||||
return
|
||||
}
|
||||
|
||||
// Revalidate stale or live TUI results through the configured resolver chain.
|
||||
if cached, fresh := c.Get(host.Name); fresh {
|
||||
host.IP = cached.IP
|
||||
} else {
|
||||
if nbClient == nil {
|
||||
fatalf("cannot resolve %q: NetBox is not configured", host.Name)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
target, err := nbClient.FindHost(ctx, host.Name, host.Kind)
|
||||
if err != nil {
|
||||
fatalf("NetBox: %v", err)
|
||||
}
|
||||
chain, err := resolver.New(cfg.Resolver)
|
||||
if err != nil {
|
||||
fatalf("resolver: %v", err)
|
||||
}
|
||||
host.IP, err = chain.Resolve(ctx, &target, nbClient)
|
||||
if err != nil {
|
||||
fatalf("IP resolution: %v", err)
|
||||
}
|
||||
c.Upsert(cache.Entry{Name: target.Name, IP: host.IP, Kind: target.Kind, Tags: target.Tags})
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
|
||||
|
||||
var sshArgs []string
|
||||
@@ -289,7 +315,7 @@ func rootCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
root.AddCommand(configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd())
|
||||
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd())
|
||||
|
||||
// cobra builds the "completion" command lazily; force init so we can extend it.
|
||||
root.InitDefaultCompletionCmd()
|
||||
@@ -493,7 +519,11 @@ func cacheRefreshCmd() *cobra.Command {
|
||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||
_ = c.Load()
|
||||
|
||||
chain, _ := resolver.New(cfg.Resolver)
|
||||
chain, err := resolver.New(cfg.Resolver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolver: %w", err)
|
||||
}
|
||||
written := 0
|
||||
for i := range entries {
|
||||
e := &entries[i]
|
||||
|
||||
@@ -512,21 +542,24 @@ func cacheRefreshCmd() *cobra.Command {
|
||||
|
||||
if ip != "" {
|
||||
c.Upsert(cache.Entry{Name: e.Name, IP: ip, Kind: e.Kind, Tags: e.Tags})
|
||||
written++
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
if filterTag == "" && filterKind == "" {
|
||||
if err := c.SetRefreshed(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: could not update refresh timestamp: %v\n", err)
|
||||
}
|
||||
}
|
||||
if aliasEntries := buildAliasEntries(c, *cfg); len(aliasEntries) > 0 {
|
||||
if err := hook.WriteAliasFiles(aliasEntries); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: could not write alias files: %v\n", err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("%d entries written to cache.\n", len(entries))
|
||||
fmt.Printf("%d entries written to cache.\n", written)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/cache"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func notesCmd() *cobra.Command {
|
||||
var remote bool
|
||||
var kind string
|
||||
cmd := &cobra.Command{Use: "notes", Short: "Read and write server notes (local by default)"}
|
||||
cmd.PersistentFlags().BoolVar(&remote, "netbox", false, "Use the NetBox comments field instead of local notes")
|
||||
cmd.PersistentFlags().StringVar(&kind, "kind", "", "Host kind: device or vm (required for uncached local hosts)")
|
||||
for _, action := range []string{"show", "set", "append"} {
|
||||
sub := &cobra.Command{Use: action + " <host> [text]", Short: action + " a server note", Args: cobra.RangeArgs(1, 2)}
|
||||
if action == "show" {
|
||||
sub.Args = cobra.ExactArgs(1)
|
||||
}
|
||||
sub.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
if kind != "" && kind != "vm" && kind != "device" {
|
||||
return fmt.Errorf("invalid kind %q", kind)
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var client *netbox.Client
|
||||
if cfg.NetBox.URL != "" {
|
||||
client = netbox.NewClient(cfg.NetBox.URL, cfg.NetBox.Token, cfg.NetBox.TokenVersion)
|
||||
}
|
||||
host := netbox.HostEntry{Name: args[0], Kind: kind}
|
||||
c := cache.New(cfg.Cache.Path, cfg.Cache.TTL)
|
||||
if err := c.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
var cached []cache.Entry
|
||||
if !remote {
|
||||
cached = c.All()
|
||||
}
|
||||
for _, entry := range cached {
|
||||
if strings.EqualFold(entry.Name, host.Name) && (kind == "" || kind == entry.Kind) {
|
||||
host.Name = entry.Name
|
||||
host.Kind = entry.Kind
|
||||
break
|
||||
}
|
||||
}
|
||||
if !remote && host.Kind == "" {
|
||||
return fmt.Errorf("host is not cached; specify --kind device or --kind vm")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
service := notes.New(cfg.NetBox.URL, client)
|
||||
note, err := service.Read(ctx, host, remote)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if action == "show" {
|
||||
_, err := fmt.Fprint(cmd.OutOrStdout(), note.Text)
|
||||
return err
|
||||
}
|
||||
var text string
|
||||
if len(args) == 2 {
|
||||
text = args[1]
|
||||
} else {
|
||||
data, err := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1024*1024+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(data) > 1024*1024 {
|
||||
return fmt.Errorf("note exceeds 1 MiB")
|
||||
}
|
||||
text = string(data)
|
||||
}
|
||||
if action == "append" && note.Text != "" {
|
||||
note.Text = strings.TrimRight(note.Text, "\n") + "\n" + text
|
||||
} else {
|
||||
note.Text = text
|
||||
}
|
||||
return service.Write(ctx, note, remote)
|
||||
}
|
||||
cmd.AddCommand(sub)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalNotesCLI(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("NETSSH_CACHE_PATH", filepath.Join(t.TempDir(), "hosts.json"))
|
||||
run := func(args []string, input string) (string, error) {
|
||||
root := rootCmd()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetIn(strings.NewReader(input))
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
return out.String(), err
|
||||
}
|
||||
if _, err := run([]string{"notes", "set", "web01", "--kind", "vm"}, "First\nSecond"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := run([]string{"notes", "append", "web01", "Third", "--kind", "vm"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := run([]string{"notes", "show", "web01", "--kind", "vm"}, "")
|
||||
if err != nil || out != "First\nSecond\nThird" {
|
||||
t.Fatalf("show: %q, %v", out, err)
|
||||
}
|
||||
if _, err := run([]string{"notes", "set", "web01", "", "--kind", "vm"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err = run([]string{"notes", "show", "web01", "--kind", "vm"}, "")
|
||||
if err != nil || out != "" {
|
||||
t.Fatalf("clear: %q, %v", out, err)
|
||||
}
|
||||
}
|
||||
Vendored
+5
-1
@@ -2,6 +2,7 @@ package cache
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -116,12 +117,15 @@ func (c *Cache) Save() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(c.path, data, 0o644)
|
||||
return storage.WriteFile(c.path, data, 0o600)
|
||||
}
|
||||
|
||||
func (c *Cache) Upsert(e Entry) {
|
||||
e.CachedAt = time.Now()
|
||||
c.mu.Lock()
|
||||
if old, ok := c.entries[e.Name]; ok && e.LastUsed.IsZero() {
|
||||
e.LastUsed = old.LastUsed
|
||||
}
|
||||
c.entries[e.Name] = e
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
Vendored
+12
@@ -471,3 +471,15 @@ func tempFile(t *testing.T, content []byte) string {
|
||||
f.Close()
|
||||
return f.Name()
|
||||
}
|
||||
|
||||
func TestUpsertPreservesUsageHistory(t *testing.T) {
|
||||
c := New("", 3600)
|
||||
c.Upsert(Entry{Name: "web01", IP: "10.0.0.1"})
|
||||
c.MarkUsed("web01")
|
||||
before, _ := c.Get("web01")
|
||||
c.Upsert(Entry{Name: "web01", IP: "10.0.0.2"})
|
||||
after, _ := c.Get("web01")
|
||||
if after.LastUsed != before.LastUsed || after.IP != "10.0.0.2" {
|
||||
t.Fatalf("history lost: %+v", after)
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,13 @@ func Load() (*Config, error) {
|
||||
}
|
||||
|
||||
v.SetEnvPrefix("NETSSH")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens"} {
|
||||
if err := v.BindEnv(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
|
||||
@@ -143,3 +143,17 @@ func TestPath_ReturnsNonEmpty(t *testing.T) {
|
||||
t.Errorf("Path() base: got %q, want netssh.yaml", filepath.Base(p))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentOverridesWithoutConfigKeys(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("NETSSH_NETBOX_URL", "https://env.example")
|
||||
t.Setenv("NETSSH_NETBOX_TOKEN", "nbt_env")
|
||||
t.Setenv("NETSSH_SSH_DEFAULT_PORT", "2222")
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.NetBox.URL != "https://env.example" || cfg.NetBox.Token != "nbt_env" || cfg.NetBox.TokenVersion != 2 || cfg.SSH.DefaultPort != 2222 {
|
||||
t.Fatalf("env ignored: %+v", cfg)
|
||||
}
|
||||
}
|
||||
+99
-17
@@ -1,13 +1,17 @@
|
||||
package netbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
@@ -27,13 +31,16 @@ func NewClient(baseURL, token string, tokenVersion int) *Client {
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
tokenVersion: tokenVersion,
|
||||
httpClient: &http.Client{},
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
|
||||
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
|
||||
}
|
||||
var (
|
||||
mu sync.Mutex
|
||||
results []HostEntry
|
||||
@@ -88,16 +95,17 @@ func (c *Client) Search(ctx context.Context, query string, opts SearchOptions) (
|
||||
// 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) {
|
||||
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
|
||||
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
|
||||
}
|
||||
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()
|
||||
@@ -113,7 +121,6 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
|
||||
}
|
||||
|
||||
if opts.Kind != "device" {
|
||||
started++
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
@@ -130,11 +137,8 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
|
||||
|
||||
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])
|
||||
if len(errs) > 0 {
|
||||
return nil, errors.Join(errs...)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
@@ -265,16 +269,31 @@ func TokenVersion(token string) int {
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, apiURL string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
||||
_, err := c.request(ctx, http.MethodGet, apiURL, nil, "", out)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) request(ctx context.Context, method, apiURL string, body io.Reader, etag string, out any) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, apiURL, body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating request: %w", err)
|
||||
return "", fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
scheme := "Token "
|
||||
if c.tokenVersion == 2 {
|
||||
scheme = "Bearer "
|
||||
}
|
||||
req.Header.Set("Authorization", scheme+c.token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if etag != "" {
|
||||
req.Header.Set("If-Match", etag)
|
||||
}
|
||||
req.Header.Set("Authorization", "Token "+c.token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request to %s: %w", apiURL, err)
|
||||
return "", fmt.Errorf("request to %s: %w", apiURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
@@ -283,17 +302,17 @@ func (c *Client) get(ctx context.Context, apiURL string, out any) error {
|
||||
if c.tokenVersion == 1 {
|
||||
hint += " — legacy v1 token detected, consider upgrading to a v2 token (starts with nbt_)"
|
||||
}
|
||||
return fmt.Errorf("%s: %s", apiURL, hint)
|
||||
return "", fmt.Errorf("%s: %s", apiURL, hint)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("netbox returned %d for %s", resp.StatusCode, apiURL)
|
||||
return "", fmt.Errorf("netbox returned %d for %s", resp.StatusCode, apiURL)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("decoding response: %w", err)
|
||||
return "", fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
return nil
|
||||
return resp.Header.Get("ETag"), nil
|
||||
}
|
||||
|
||||
func deviceToEntry(d netboxDevice) HostEntry {
|
||||
@@ -331,3 +350,66 @@ func stripPrefix(cidr string) string {
|
||||
}
|
||||
return cidr
|
||||
}
|
||||
|
||||
// FindHost refuses ambiguous names so writes cannot target an arbitrary server.
|
||||
func (c *Client) FindHost(ctx context.Context, name, kind string) (HostEntry, error) {
|
||||
if kind != "" && kind != "device" && kind != "vm" {
|
||||
return HostEntry{}, fmt.Errorf("invalid kind %q", kind)
|
||||
}
|
||||
entries, err := c.SearchAll(ctx, name, SearchOptions{Kind: kind})
|
||||
if err != nil {
|
||||
return HostEntry{}, err
|
||||
}
|
||||
var matches []HostEntry
|
||||
for _, e := range entries {
|
||||
if strings.EqualFold(e.Name, name) {
|
||||
matches = append(matches, e)
|
||||
}
|
||||
}
|
||||
if len(matches) != 1 {
|
||||
return HostEntry{}, fmt.Errorf("host %q: found %d exact matches; use --kind or a unique hostname", name, len(matches))
|
||||
}
|
||||
return matches[0], nil
|
||||
}
|
||||
|
||||
func (c *Client) hostURL(host HostEntry) (string, error) {
|
||||
var path string
|
||||
switch host.Kind {
|
||||
case "device":
|
||||
path = "dcim/devices"
|
||||
case "vm":
|
||||
path = "virtualization/virtual-machines"
|
||||
default:
|
||||
return "", fmt.Errorf("invalid host kind %q", host.Kind)
|
||||
}
|
||||
if host.ID <= 0 {
|
||||
return "", fmt.Errorf("invalid host ID")
|
||||
}
|
||||
return fmt.Sprintf("%s/api/%s/%d/", c.baseURL, path, host.ID), nil
|
||||
}
|
||||
|
||||
func (c *Client) GetComments(ctx context.Context, host HostEntry) (string, string, error) {
|
||||
u, err := c.hostURL(host)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var out struct {
|
||||
Comments string `json:"comments"`
|
||||
}
|
||||
etag, err := c.request(ctx, http.MethodGet, u, nil, "", &out)
|
||||
return out.Comments, etag, err
|
||||
}
|
||||
|
||||
func (c *Client) SetComments(ctx context.Context, host HostEntry, comments, etag string) error {
|
||||
u, err := c.hostURL(host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(map[string]string{"comments": comments})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var out json.RawMessage
|
||||
_, err = c.request(ctx, http.MethodPatch, u, bytes.NewReader(data), etag, &out)
|
||||
return err
|
||||
}
|
||||
@@ -318,21 +318,26 @@ func Test403_V2Token_NoV1Hint(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGet_SendsAuthorizationHeader(t *testing.T) {
|
||||
var gotAuth string
|
||||
for _, token := range []string{"nbt_mytoken", "legacytoken"} {
|
||||
t.Run(token, func(t *testing.T) {
|
||||
want := "Token " + token
|
||||
if strings.HasPrefix(token, "nbt_") {
|
||||
want = "Bearer " + token
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
if got := r.Header.Get("Authorization"); got != want {
|
||||
t.Errorf("Authorization: got %q, want %q", got, want)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
b, _ := json.Marshal(deviceListResponse())
|
||||
w.Write(b)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewClient(srv.URL, "nbt_mytoken", 2)
|
||||
c.Search(context.Background(), "", SearchOptions{}) //nolint:errcheck
|
||||
|
||||
want := "Token nbt_mytoken"
|
||||
if gotAuth != want {
|
||||
t.Errorf("Authorization header: got %q, want %q", gotAuth, want)
|
||||
c := NewClient(srv.URL, token, 0)
|
||||
if _, err := c.Search(context.Background(), "", SearchOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,3 +439,63 @@ func TestStripPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentsPreserveFieldAndSendConflictHeader(t *testing.T) {
|
||||
for _, kind := range []string{"device", "vm"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer nbt_test" {
|
||||
t.Error("wrong authentication")
|
||||
}
|
||||
if r.Method == http.MethodPatch {
|
||||
if r.Header.Get("If-Match") != `W/"123"` {
|
||||
t.Error("missing conflict protection")
|
||||
}
|
||||
var payload map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if len(payload) != 1 || payload["comments"] != "new\nnotes" {
|
||||
t.Errorf("unexpected patch: %v", payload)
|
||||
}
|
||||
}
|
||||
w.Header().Set("ETag", `W/"123"`)
|
||||
w.Write([]byte(`{"comments":"existing notes"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewClient(srv.URL, "nbt_test", 0)
|
||||
host := HostEntry{ID: 1, Kind: kind}
|
||||
text, etag, err := c.GetComments(context.Background(), host)
|
||||
if err != nil || text != "existing notes" {
|
||||
t.Fatalf("read: %q, %v", text, err)
|
||||
}
|
||||
if err := c.SetComments(context.Background(), host, "new\nnotes", etag); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullRefreshRejectsPartialFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.Path, "virtualization") {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.Write([]byte(`{"count":0,"results":[]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
if _, err := c.SearchAll(context.Background(), "", SearchOptions{}); err == nil {
|
||||
t.Fatal("partial refresh reported as successful")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentConflictIsReported(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusPreconditionFailed) }))
|
||||
defer srv.Close()
|
||||
c := NewClient(srv.URL, "token", 0)
|
||||
if err := c.SetComments(context.Background(), HostEntry{ID: 1, Kind: "device"}, "draft", "old"); err == nil {
|
||||
t.Fatal("conflict ignored")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Package notes stores private server notes independently of the host cache.
|
||||
package notes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/storage"
|
||||
)
|
||||
|
||||
type Note struct {
|
||||
Text, ETag string
|
||||
Host netbox.HostEntry
|
||||
}
|
||||
type Service struct {
|
||||
Dir, Namespace string
|
||||
Client *netbox.Client
|
||||
}
|
||||
|
||||
func New(namespace string, client *netbox.Client) *Service {
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
dir = filepath.Join(os.Getenv("HOME"), ".config")
|
||||
}
|
||||
return &Service{Dir: filepath.Join(dir, "netssh", "notes"), Namespace: strings.TrimRight(namespace, "/"), Client: client}
|
||||
}
|
||||
|
||||
func (s *Service) path(host netbox.HostEntry) string {
|
||||
key := fmt.Sprintf("%s\x00%s\x00%s", s.Namespace, host.Kind, strings.ToLower(host.Name))
|
||||
return filepath.Join(s.Dir, fmt.Sprintf("%x.txt", sha256.Sum256([]byte(key))))
|
||||
}
|
||||
|
||||
func (s *Service) Read(ctx context.Context, host netbox.HostEntry, remote bool) (Note, error) {
|
||||
note := Note{Host: host}
|
||||
if remote {
|
||||
if s.Client == nil {
|
||||
return note, fmt.Errorf("NetBox is not configured")
|
||||
}
|
||||
if host.ID == 0 {
|
||||
resolved, err := s.Client.FindHost(ctx, host.Name, host.Kind)
|
||||
if err != nil {
|
||||
return note, err
|
||||
}
|
||||
note.Host = resolved
|
||||
}
|
||||
var err error
|
||||
note.Text, note.ETag, err = s.Client.GetComments(ctx, note.Host)
|
||||
return note, err
|
||||
}
|
||||
data, err := os.ReadFile(s.path(host))
|
||||
if os.IsNotExist(err) {
|
||||
return note, nil
|
||||
}
|
||||
note.Text = string(data)
|
||||
return note, err
|
||||
}
|
||||
|
||||
func (s *Service) Write(ctx context.Context, note Note, remote bool) error {
|
||||
if remote {
|
||||
if s.Client == nil {
|
||||
return fmt.Errorf("NetBox is not configured")
|
||||
}
|
||||
return s.Client.SetComments(ctx, note.Host, note.Text, note.ETag)
|
||||
}
|
||||
return storage.WriteFile(s.path(note.Host), []byte(note.Text), 0o600)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package notes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalNotesPersistAndStayIsolated(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := &Service{Dir: t.TempDir(), Namespace: "https://netbox.example"}
|
||||
host := netbox.HostEntry{Name: "web01", Kind: "device"}
|
||||
n := Note{Host: host, Text: "Wartung\nGrüße 🔧"}
|
||||
if err := s.Write(ctx, n, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloaded := &Service{Dir: s.Dir, Namespace: s.Namespace}
|
||||
got, err := reloaded.Read(ctx, host, false)
|
||||
if err != nil || got.Text != n.Text {
|
||||
t.Fatalf("round trip: %+v, %v", got, err)
|
||||
}
|
||||
info, err := os.Stat(s.path(host))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("permissions: %v", info.Mode())
|
||||
}
|
||||
host.Kind = "vm"
|
||||
got, err = s.Read(ctx, host, false)
|
||||
if err != nil || got.Text != "" {
|
||||
t.Fatalf("kind isolation: %+v, %v", got, err)
|
||||
}
|
||||
host.Kind = "device"
|
||||
reloaded.Namespace = "https://other.example"
|
||||
got, err = reloaded.Read(ctx, host, false)
|
||||
if err != nil || got.Text != "" {
|
||||
t.Fatalf("instance isolation: %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreadableNoteIsNotTreatedAsEmpty(t *testing.T) {
|
||||
s := &Service{Dir: t.TempDir()}
|
||||
host := netbox.HostEntry{Name: "web01", Kind: "vm"}
|
||||
if err := os.Mkdir(s.path(host), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Read(context.Background(), host, false); err == nil {
|
||||
t.Fatal("expected read error")
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,9 @@ func ReplaceHost(args []string, destIdx int, newHost string) []string {
|
||||
|
||||
// HasPortFlag reports whether a port was specified via -p in args.
|
||||
func HasPortFlag(args []string) bool {
|
||||
if parsed := Parse(args); parsed != nil {
|
||||
args = args[:parsed.DestIdx]
|
||||
}
|
||||
for i, a := range args {
|
||||
if a == "-p" && i+1 < len(args) {
|
||||
return true
|
||||
@@ -102,6 +105,9 @@ func HasPortFlag(args []string) bool {
|
||||
// HasUserFlag reports whether a user was specified via -l in args.
|
||||
// Used to avoid overriding an explicit -l with the configured default user.
|
||||
func HasUserFlag(args []string) bool {
|
||||
if parsed := Parse(args); parsed != nil {
|
||||
args = args[:parsed.DestIdx]
|
||||
}
|
||||
for i, a := range args {
|
||||
if a == "-l" && i+1 < len(args) {
|
||||
return true
|
||||
|
||||
@@ -190,3 +190,10 @@ func assertParsed(t *testing.T, got *ParsedArgs, host, user string, destIdx int)
|
||||
t.Errorf("destIdx: got %d, want %d", got.DestIdx, destIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteCommandFlagsDoNotOverrideDefaults(t *testing.T) {
|
||||
args := []string{"web01", "command", "-p", "1234", "-lroot"}
|
||||
if HasPortFlag(args) || HasUserFlag(args) {
|
||||
t.Fatal("remote command interpreted as SSH options")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Package storage provides atomic file replacement in the destination directory.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func WriteFile(path string, data []byte, mode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".netssh-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
defer f.Close()
|
||||
if err := f.Chmod(mode); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(f.Name(), path)
|
||||
}
|
||||
+161
-21
@@ -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,6 +133,14 @@ func applyFilter(entries []netbox.HostEntry, f filterOpts) []netbox.HostEntry {
|
||||
// --- Model ---
|
||||
|
||||
type Model struct {
|
||||
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
|
||||
@@ -140,7 +161,7 @@ type Model struct {
|
||||
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)
|
||||
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 }
|
||||
@@ -0,0 +1,70 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/netbox"
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/notes"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTypingEDoesNotOpenConnectionEditor(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.updateListItems([]netbox.HostEntry{{Name: "server"}})
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
|
||||
if m.editMode || m.input.Value() != "e" {
|
||||
t.Fatal("e intercepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleResultsAndDebounceAreIgnored(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.input.SetValue("web")
|
||||
m.seq = 3
|
||||
m.Update(searchResultMsg{query: "web", seq: 1, entries: []netbox.HostEntry{{Name: "old"}}})
|
||||
if len(m.list.Items()) != 0 {
|
||||
t.Fatal("stale result accepted")
|
||||
}
|
||||
_, cmd := m.Update(debounceMsg{query: "w", seq: 1})
|
||||
if cmd != nil {
|
||||
t.Fatal("stale debounce accepted")
|
||||
}
|
||||
m.Update(searchResultMsg{query: "web", seq: 3, entries: []netbox.HostEntry{{Name: "web01"}}})
|
||||
if len(m.list.Items()) != 1 {
|
||||
t.Fatal("current result ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditPreservesDefaultAndExplicitPort22(t *testing.T) {
|
||||
m := New(nil, nil, "", 2222)
|
||||
item := hostItem{name: "web01", ip: "10.0.0.1"}
|
||||
m.openEdit(item)
|
||||
if m.editInput.Value() != "web01:2222" {
|
||||
t.Fatal(m.editInput.Value())
|
||||
}
|
||||
m.editInput.SetValue("ops@web01:22")
|
||||
selected := m.buildSelected(item)
|
||||
if selected.Port != "22" || selected.User != "ops" {
|
||||
t.Fatalf("bad override: %+v", selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedNoteLoadCannotBeSaved(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.SetNotes(¬es.Service{Dir: t.TempDir()})
|
||||
m.noteMode = true
|
||||
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
|
||||
if cmd != nil {
|
||||
t.Fatal("save allowed before successful load")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionEditorKeepsOriginalHost(t *testing.T) {
|
||||
m := New(nil, nil, "admin")
|
||||
first := hostItem{name: "first", ip: "10.0.0.1"}
|
||||
m.openEdit(first)
|
||||
m.updateListItems([]netbox.HostEntry{{Name: "second", PrimaryIP4: "10.0.0.2"}})
|
||||
m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.selected == nil || m.selected.Name != "first" {
|
||||
t.Fatalf("edited host changed: %+v", m.selected)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user