feat: netssh multi — several hosts side by side in tmux or zellij
netssh multi opens one pane per host in a tmux window or zellij tab. Hosts come from names, tag:TAG (NetBox and static hosts), cluster:NAME (NetBox virtualization clusters; devices by default, --kind vm|all), and groups from the config (@name, nested, with cycle detection). Every pane runs netssh HOST, so lookup, rules, and jump hosts apply. - tmux: a new window inside tmux, else a new session that is attached; tiled layout, pane borders with the host and a [SYNC] marker. - zellij: a generated grid layout; a new tab inside zellij, else a new session. - --sync broadcasts typing to all panes; netssh sync toggles it in the current tmux window or zellij tab. - TUI: Space marks hosts, Ctrl+A marks all, Enter opens them together. - Configurator: multiplexer, panes per window, groups. NetBox SearchAll gains a cluster filter (name to cluster IDs per instance), and cache entries keep the host's cluster for completion and an offline fallback. Panes skip the 24-hour background refresh (checked once by multi) and get HOME/XDG paths but no NETSSH_* values, which would expose tokens in the process list. testenv/tests/15-multi.sh checks real tmux and zellij sessions, including a broadcast command arriving on all three cluster nodes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dbb99bdded
commit
a31aab4110
24 files changed
+1754
-40
No files matched your search
Vendored
+1
@@ -21,6 +21,7 @@ type Entry struct {
|
||||
IP string `json:"ip"`
|
||||
Kind string `json:"kind"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Cluster string `json:"cluster,omitempty"`
|
||||
CachedAt time.Time `json:"cached_at"`
|
||||
LastUsed time.Time `json:"last_used,omitempty"`
|
||||
// Static marks a host from the config's hosts: list. It never expires;
|
||||
|
||||
@@ -20,6 +20,9 @@ type Config struct {
|
||||
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
|
||||
Completion CompletionConfig `mapstructure:"completion"`
|
||||
Hosts []StaticHost `mapstructure:"hosts"`
|
||||
// Groups name lists of hosts and selectors for netssh multi (@name).
|
||||
Groups map[string][]string `mapstructure:"groups"`
|
||||
Multi MultiConfig `mapstructure:"multi"`
|
||||
}
|
||||
|
||||
type ShellConfig struct {
|
||||
@@ -117,7 +120,7 @@ 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", "resolver.hostname_domain", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias", "ssh.proxy_jump", "ssh.host_key_checking", "ssh.target", "ssh.address_family", "completion.max_results"} {
|
||||
for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "resolver.hostname_domain", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias", "ssh.proxy_jump", "ssh.host_key_checking", "ssh.target", "ssh.address_family", "completion.max_results", "multi.multiplexer", "multi.panes_per_window"} {
|
||||
if err := v.BindEnv(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -163,6 +166,9 @@ func Load() (*Config, error) {
|
||||
if err := cfg.ValidateHosts(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := cfg.ValidateMulti(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range cfg.NetBoxes {
|
||||
source := &cfg.NetBoxes[i]
|
||||
source.Name = strings.ToLower(source.Name)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MultiConfig controls netssh multi.
|
||||
type MultiConfig struct {
|
||||
// Multiplexer is auto, tmux, or zellij; empty means auto.
|
||||
Multiplexer string `mapstructure:"multiplexer"`
|
||||
// PanesPerWindow splits larger host lists into more windows or tabs; 0 = 9.
|
||||
PanesPerWindow int `mapstructure:"panes_per_window"`
|
||||
}
|
||||
|
||||
// DefaultPanesPerWindow keeps panes readable on a normal screen (3×3).
|
||||
const DefaultPanesPerWindow = 9
|
||||
|
||||
// ValidateMulti checks groups and the multi settings.
|
||||
func (c *Config) ValidateMulti() error {
|
||||
switch c.Multi.Multiplexer {
|
||||
case "", "auto", "tmux", "zellij":
|
||||
default:
|
||||
return fmt.Errorf("multi.multiplexer must be auto, tmux, or zellij")
|
||||
}
|
||||
if c.Multi.PanesPerWindow < 0 || c.Multi.PanesPerWindow > 64 {
|
||||
return fmt.Errorf("multi.panes_per_window must be between 1 and 64")
|
||||
}
|
||||
names := make([]string, 0, len(c.Groups))
|
||||
for name := range c.Groups {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
if !instanceName.MatchString(name) {
|
||||
return fmt.Errorf("groups: name %q must contain only letters, digits, underscores, or hyphens", name)
|
||||
}
|
||||
if len(c.Groups[name]) == 0 {
|
||||
return fmt.Errorf("groups.%s: needs at least one host or selector", name)
|
||||
}
|
||||
for _, selector := range c.Groups[name] {
|
||||
if strings.TrimSpace(selector) == "" || strings.ContainsAny(selector, " \t\n") || strings.HasPrefix(selector, "-") {
|
||||
return fmt.Errorf("groups.%s: invalid entry %q", name, selector)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PanesPerWindowOrDefault returns the configured value or the default.
|
||||
func (m MultiConfig) PanesPerWindowOrDefault() int {
|
||||
if m.PanesPerWindow > 0 {
|
||||
return m.PanesPerWindow
|
||||
}
|
||||
return DefaultPanesPerWindow
|
||||
}
|
||||
@@ -190,3 +190,15 @@ func TestStaticHostsValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiConfig(t *testing.T) {
|
||||
cfg, err := loadYAML(t, "groups:\n K8s: [master1, \"tag:worker\"]\nmulti:\n multiplexer: zellij\n")
|
||||
if err != nil || len(cfg.Groups["k8s"]) != 2 || cfg.Multi.Multiplexer != "zellij" || cfg.Multi.PanesPerWindowOrDefault() != 9 {
|
||||
t.Fatalf("%+v %v", cfg, err)
|
||||
}
|
||||
for _, bad := range []string{"multi: {multiplexer: screen}", "multi: {panes_per_window: 100}", "groups: {web: []}", "groups: {web: [\"a b\"]}"} {
|
||||
if _, err := loadYAML(t, bad+"\n"); err == nil {
|
||||
t.Errorf("%s accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// Package multiplex opens several commands side by side in tmux or zellij.
|
||||
package multiplex
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Pane is one command shown in its own pane.
|
||||
type Pane struct {
|
||||
Title string
|
||||
Argv []string
|
||||
}
|
||||
|
||||
// Options control how panes are laid out.
|
||||
type Options struct {
|
||||
Label string // window, session, and tab name
|
||||
Sync bool // type into all panes of a window at once
|
||||
PerWindow int // panes per tmux window or zellij tab
|
||||
LayoutDir string // where zellij layout files are written
|
||||
// Hint receives notes for the user before the process is replaced.
|
||||
Hint func(string)
|
||||
}
|
||||
|
||||
// Runner executes multiplexer commands; tests and --dry-run replace it.
|
||||
type Runner interface {
|
||||
// Output runs argv and returns its trimmed standard output.
|
||||
Output(argv ...string) (string, error)
|
||||
// Exec replaces the current process with argv.
|
||||
Exec(argv ...string) error
|
||||
LookPath(name string) (string, error)
|
||||
Getenv(key string) string
|
||||
WriteFile(path string, data []byte) error
|
||||
}
|
||||
|
||||
// Supported multiplexers.
|
||||
const (
|
||||
Tmux = "tmux"
|
||||
Zellij = "zellij"
|
||||
)
|
||||
|
||||
// ErrNone means neither tmux nor zellij is available.
|
||||
var ErrNone = errors.New("netssh multi needs tmux or zellij; install one of them (e.g. apt install tmux)")
|
||||
|
||||
// Detect picks the multiplexer: the requested one, else the one netssh runs
|
||||
// in, else tmux, else zellij.
|
||||
func Detect(requested string, r Runner) (string, error) {
|
||||
switch requested {
|
||||
case Tmux, Zellij:
|
||||
if _, err := r.LookPath(requested); err != nil {
|
||||
return "", fmt.Errorf("%s is not installed", requested)
|
||||
}
|
||||
return requested, nil
|
||||
case "", "auto":
|
||||
default:
|
||||
return "", fmt.Errorf("unknown multiplexer %q", requested)
|
||||
}
|
||||
if r.Getenv("TMUX") != "" {
|
||||
return Tmux, nil
|
||||
}
|
||||
if r.Getenv("ZELLIJ") != "" {
|
||||
return Zellij, nil
|
||||
}
|
||||
for _, name := range []string{Tmux, Zellij} {
|
||||
if _, err := r.LookPath(name); err == nil {
|
||||
return name, nil
|
||||
}
|
||||
}
|
||||
return "", ErrNone
|
||||
}
|
||||
|
||||
// Open shows panes in the multiplexer. Outside of it, Open replaces the
|
||||
// process with the multiplexer client and only returns on error.
|
||||
func Open(kind string, panes []Pane, opts Options, r Runner) error {
|
||||
if len(panes) == 0 {
|
||||
return errors.New("no hosts to open")
|
||||
}
|
||||
if opts.Hint == nil {
|
||||
opts.Hint = func(string) {}
|
||||
}
|
||||
if opts.PerWindow <= 0 {
|
||||
opts.PerWindow = 9
|
||||
}
|
||||
if opts.Label == "" {
|
||||
opts.Label = "netssh"
|
||||
}
|
||||
switch kind {
|
||||
case Tmux:
|
||||
return openTmux(panes, opts, r)
|
||||
case Zellij:
|
||||
return openZellij(panes, opts, r)
|
||||
}
|
||||
return fmt.Errorf("unknown multiplexer %q", kind)
|
||||
}
|
||||
|
||||
// ToggleSync switches broadcast input in the current tmux window or zellij tab.
|
||||
func ToggleSync(r Runner) (string, error) {
|
||||
switch {
|
||||
case r.Getenv("TMUX") != "":
|
||||
current, err := r.Output("tmux", "show-window-options", "-v", "synchronize-panes")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
next := "on"
|
||||
if current == "on" {
|
||||
next = "off"
|
||||
}
|
||||
if _, err := r.Output("tmux", "set-window-option", "synchronize-panes", next); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "broadcast " + next, nil
|
||||
case r.Getenv("ZELLIJ") != "":
|
||||
if _, err := r.Output("zellij", "action", "toggle-active-sync-tab"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "broadcast toggled", nil
|
||||
}
|
||||
return "", errors.New("netssh sync works inside tmux or zellij")
|
||||
}
|
||||
|
||||
// chunks splits panes into groups of at most n.
|
||||
func chunks(panes []Pane, n int) [][]Pane {
|
||||
var out [][]Pane
|
||||
for len(panes) > n {
|
||||
out = append(out, panes[:n])
|
||||
panes = panes[n:]
|
||||
}
|
||||
return append(out, panes)
|
||||
}
|
||||
|
||||
// windowLabel numbers windows when the panes need more than one.
|
||||
func windowLabel(label string, i, total int) string {
|
||||
if total == 1 {
|
||||
return label
|
||||
}
|
||||
return fmt.Sprintf("%s-%d", label, i+1)
|
||||
}
|
||||
|
||||
// System runs real commands.
|
||||
type System struct{}
|
||||
|
||||
func (System) Output(argv ...string) (string, error) {
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
if msg := strings.TrimSpace(stderr.String()); msg != "" {
|
||||
return "", fmt.Errorf("%s: %s", argv[0], msg)
|
||||
}
|
||||
return "", fmt.Errorf("%s: %w", argv[0], err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func (System) Exec(argv ...string) error {
|
||||
path, err := exec.LookPath(argv[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return syscall.Exec(path, argv, os.Environ())
|
||||
}
|
||||
|
||||
func (System) LookPath(name string) (string, error) { return exec.LookPath(name) }
|
||||
func (System) Getenv(key string) string { return os.Getenv(key) }
|
||||
func (System) WriteFile(path string, data []byte) error {
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
|
||||
// DryRun prints commands instead of running them; Output returns fake IDs.
|
||||
type DryRun struct {
|
||||
W io.Writer
|
||||
Env map[string]string // values for Getenv, e.g. TMUX
|
||||
n int
|
||||
}
|
||||
|
||||
func (d *DryRun) Output(argv ...string) (string, error) {
|
||||
fmt.Fprintln(d.W, shellJoin(argv))
|
||||
d.n++
|
||||
switch {
|
||||
case len(argv) > 1 && (argv[1] == "new-window" || argv[1] == "new-session"):
|
||||
return fmt.Sprintf("@%d %%%d", d.n, d.n), nil
|
||||
case len(argv) > 1 && argv[1] == "split-window":
|
||||
return fmt.Sprintf("%%%d", d.n), nil
|
||||
case len(argv) > 1 && argv[1] == "has-session":
|
||||
return "", errors.New("no session")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (d *DryRun) Exec(argv ...string) error {
|
||||
fmt.Fprintln(d.W, shellJoin(argv))
|
||||
return nil
|
||||
}
|
||||
|
||||
// LookPath pretends every multiplexer is installed, so --dry-run shows the
|
||||
// commands on machines without tmux or zellij.
|
||||
func (d *DryRun) LookPath(name string) (string, error) { return name, nil }
|
||||
func (d *DryRun) Getenv(key string) string {
|
||||
if v, ok := d.Env[key]; ok {
|
||||
return v
|
||||
}
|
||||
return os.Getenv(key)
|
||||
}
|
||||
func (d *DryRun) WriteFile(path string, data []byte) error {
|
||||
fmt.Fprintf(d.W, "# %s\n%s", path, data)
|
||||
return nil
|
||||
}
|
||||
|
||||
// shellJoin quotes argv for display.
|
||||
func shellJoin(argv []string) string {
|
||||
quoted := make([]string, len(argv))
|
||||
for i, arg := range argv {
|
||||
if arg != "" && strings.Trim(arg, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@%+=:,./_-") == "" {
|
||||
quoted[i] = arg
|
||||
continue
|
||||
}
|
||||
quoted[i] = "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'"
|
||||
}
|
||||
return strings.Join(quoted, " ")
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package multiplex
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fake records commands and answers like tmux and zellij would.
|
||||
type fake struct {
|
||||
env map[string]string
|
||||
installed map[string]bool
|
||||
sessions map[string]bool
|
||||
calls []string
|
||||
exec []string
|
||||
files map[string]string
|
||||
syncState string
|
||||
n int
|
||||
}
|
||||
|
||||
func newFake(env map[string]string, installed ...string) *fake {
|
||||
f := &fake{env: env, installed: map[string]bool{}, sessions: map[string]bool{}, files: map[string]string{}, syncState: "off"}
|
||||
for _, name := range installed {
|
||||
f.installed[name] = true
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fake) Output(argv ...string) (string, error) {
|
||||
f.calls = append(f.calls, strings.Join(argv, " "))
|
||||
f.n++
|
||||
switch argv[1] {
|
||||
case "new-window", "new-session":
|
||||
return fmt.Sprintf("@%d %%%d", f.n, f.n), nil
|
||||
case "split-window":
|
||||
return fmt.Sprintf("%%%d", f.n), nil
|
||||
case "has-session":
|
||||
if f.sessions[strings.TrimPrefix(argv[3], "=")] {
|
||||
return "", nil
|
||||
}
|
||||
return "", errors.New("can't find session")
|
||||
case "show-window-options":
|
||||
return f.syncState, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
func (f *fake) Exec(argv ...string) error { f.exec = argv; return nil }
|
||||
func (f *fake) LookPath(name string) (string, error) {
|
||||
if f.installed[name] {
|
||||
return "/usr/bin/" + name, nil
|
||||
}
|
||||
return "", errors.New("not found")
|
||||
}
|
||||
func (f *fake) Getenv(key string) string { return f.env[key] }
|
||||
func (f *fake) WriteFile(path string, data []byte) error {
|
||||
f.files[path] = string(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func panes(names ...string) []Pane {
|
||||
out := make([]Pane, 0, len(names))
|
||||
for _, name := range names {
|
||||
out = append(out, Pane{Title: name, Argv: []string{"/usr/bin/netssh", name}})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestDetect(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
requested string
|
||||
env map[string]string
|
||||
installed []string
|
||||
want string
|
||||
}{
|
||||
{"", map[string]string{"TMUX": "/tmp/tmux"}, []string{"tmux", "zellij"}, Tmux},
|
||||
{"auto", map[string]string{"ZELLIJ": "0"}, []string{"tmux", "zellij"}, Zellij},
|
||||
{"", nil, []string{"tmux", "zellij"}, Tmux},
|
||||
{"", nil, []string{"zellij"}, Zellij},
|
||||
{"zellij", map[string]string{"TMUX": "x"}, []string{"tmux", "zellij"}, Zellij},
|
||||
} {
|
||||
got, err := Detect(tc.requested, newFake(tc.env, tc.installed...))
|
||||
if err != nil || got != tc.want {
|
||||
t.Errorf("%+v: got %q %v", tc, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := Detect("", newFake(nil)); !errors.Is(err, ErrNone) {
|
||||
t.Errorf("nothing installed: %v", err)
|
||||
}
|
||||
if _, err := Detect("tmux", newFake(nil, "zellij")); err == nil {
|
||||
t.Error("missing requested multiplexer accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTmuxInside(t *testing.T) {
|
||||
f := newFake(map[string]string{"TMUX": "/tmp/tmux-1/default"}, "tmux")
|
||||
if err := Open(Tmux, panes("a", "b", "c"), Options{Label: "pve", Sync: true}, f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := "sh -c " + paneScript + " netssh-pane /usr/bin/netssh "
|
||||
want := []string{
|
||||
"tmux new-window -P -F #{window_id} #{pane_id} -n pve " + script + "a",
|
||||
"tmux split-window -t @1 -P -F #{pane_id} " + script + "b",
|
||||
"tmux select-layout -t @1 tiled",
|
||||
"tmux split-window -t @1 -P -F #{pane_id} " + script + "c",
|
||||
"tmux select-layout -t @1 tiled",
|
||||
"tmux set-option -p -t %1 @netssh_host a",
|
||||
"tmux set-option -p -t %2 @netssh_host b",
|
||||
"tmux set-option -p -t %4 @netssh_host c",
|
||||
"tmux set-window-option -t @1 pane-border-status top",
|
||||
"tmux set-window-option -t @1 pane-border-format #{@netssh_host}#{?pane_synchronized, [SYNC],} ",
|
||||
"tmux set-window-option -t @1 synchronize-panes on",
|
||||
"tmux select-window -t @1",
|
||||
}
|
||||
if strings.Join(f.calls, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("calls:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n"))
|
||||
}
|
||||
if f.exec != nil {
|
||||
t.Fatal("inside tmux must not attach")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTmuxOutsideSplitsWindowsAndAvoidsSessionNames(t *testing.T) {
|
||||
f := newFake(nil, "tmux")
|
||||
f.sessions["netssh-pve"] = true
|
||||
var hints []string
|
||||
err := Open(Tmux, panes("a", "b", "c"), Options{Label: "pve", PerWindow: 2, Hint: func(s string) { hints = append(hints, s) }}, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := strings.Join(f.calls, "\n")
|
||||
for _, want := range []string{
|
||||
"tmux has-session -t =netssh-pve-2",
|
||||
"tmux new-session -d -x 240 -y 70 -s netssh-pve-2 -P -F #{window_id} #{pane_id} -n pve-1 ",
|
||||
"tmux new-window -t netssh-pve-2: -P -F #{window_id} #{pane_id} -n pve-2 ",
|
||||
} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("missing %q in\n%s", want, joined)
|
||||
}
|
||||
}
|
||||
if strings.Contains(joined, "synchronize-panes") {
|
||||
t.Error("sync set without --sync")
|
||||
}
|
||||
if strings.Join(f.exec, " ") != "tmux attach-session -t netssh-pve-2" || len(hints) != 1 {
|
||||
t.Fatalf("exec %v hints %v", f.exec, hints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZellijOutsideWritesGridLayout(t *testing.T) {
|
||||
f := newFake(nil, "zellij")
|
||||
if err := Open(Zellij, panes("a", "b", "c", `q"x`), Options{Label: "pve", LayoutDir: "/tmp/l"}, f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
layout := f.files["/tmp/l/netssh-multi.kdl"]
|
||||
for _, want := range []string{
|
||||
`plugin location="zellij:tab-bar"`,
|
||||
`tab name="pve" focus=true {`,
|
||||
`pane split_direction="vertical" {`,
|
||||
`pane name="a" command="/usr/bin/netssh" {`,
|
||||
`args "a"`,
|
||||
`pane name="q\"x" command="/usr/bin/netssh" {`,
|
||||
} {
|
||||
if !strings.Contains(layout, want) {
|
||||
t.Errorf("layout lacks %q:\n%s", want, layout)
|
||||
}
|
||||
}
|
||||
// 4 panes → 2 columns × 2 rows.
|
||||
if n := strings.Count(layout, `split_direction="vertical"`); n != 2 {
|
||||
t.Errorf("%d rows, want 2:\n%s", n, layout)
|
||||
}
|
||||
if strings.Join(f.exec, " ") != "zellij --layout /tmp/l/netssh-multi.kdl" {
|
||||
t.Fatalf("exec %v", f.exec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZellijInsideAddsTabs(t *testing.T) {
|
||||
f := newFake(map[string]string{"ZELLIJ": "0"}, "zellij")
|
||||
if err := Open(Zellij, panes("a", "b", "c"), Options{Label: "pve", PerWindow: 2, Sync: true, LayoutDir: "/tmp/l"}, f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{
|
||||
"zellij action new-tab --layout /tmp/l/netssh-multi-1.kdl",
|
||||
"zellij action toggle-active-sync-tab",
|
||||
"zellij action new-tab --layout /tmp/l/netssh-multi-2.kdl",
|
||||
"zellij action toggle-active-sync-tab",
|
||||
}
|
||||
if strings.Join(f.calls, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("calls:\n%s", strings.Join(f.calls, "\n"))
|
||||
}
|
||||
if !strings.Contains(f.files["/tmp/l/netssh-multi-2.kdl"], `tab name="pve-2"`) {
|
||||
t.Fatalf("second tab: %s", f.files["/tmp/l/netssh-multi-2.kdl"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleSync(t *testing.T) {
|
||||
f := newFake(map[string]string{"TMUX": "x"})
|
||||
if state, err := ToggleSync(f); err != nil || state != "broadcast on" || f.calls[1] != "tmux set-window-option synchronize-panes on" {
|
||||
t.Fatalf("%q %v %v", state, err, f.calls)
|
||||
}
|
||||
f.syncState = "on"
|
||||
if state, _ := ToggleSync(f); state != "broadcast off" {
|
||||
t.Fatalf("%q", state)
|
||||
}
|
||||
z := newFake(map[string]string{"ZELLIJ": "0"})
|
||||
if _, err := ToggleSync(z); err != nil || z.calls[0] != "zellij action toggle-active-sync-tab" {
|
||||
t.Fatalf("%v %v", err, z.calls)
|
||||
}
|
||||
if _, err := ToggleSync(newFake(nil)); err == nil {
|
||||
t.Fatal("outside a multiplexer accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package multiplex
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// paneScript runs the pane's command and keeps a failed one on screen, so an
|
||||
// unreachable host shows its error instead of the pane vanishing.
|
||||
const paneScript = `"$@"; status=$?; if [ "$status" -ne 0 ]; then echo; echo "netssh exited with status $status, press Enter to close"; read -r _; fi`
|
||||
|
||||
// tmuxArgv runs the pane through sh with argv as arguments: no quoting, and
|
||||
// independent of the user's default-shell (which may be fish).
|
||||
func tmuxArgv(p Pane) []string {
|
||||
return append([]string{"sh", "-c", paneScript, "netssh-pane"}, p.Argv...)
|
||||
}
|
||||
|
||||
// openTmux adds a window per chunk of panes: to the current session inside
|
||||
// tmux, else to a new detached session that is attached at the end.
|
||||
func openTmux(panes []Pane, opts Options, r Runner) error {
|
||||
inside := r.Getenv("TMUX") != ""
|
||||
session := ""
|
||||
if !inside {
|
||||
session = freeSession(r, "netssh-"+opts.Label)
|
||||
}
|
||||
groups := chunks(panes, opts.PerWindow)
|
||||
first := ""
|
||||
for i, group := range groups {
|
||||
name := windowLabel(opts.Label, i, len(groups))
|
||||
format := []string{"-P", "-F", "#{window_id} #{pane_id}", "-n", name}
|
||||
var create []string
|
||||
switch {
|
||||
case inside:
|
||||
create = append([]string{"tmux", "new-window"}, format...)
|
||||
case i == 0:
|
||||
// A large virtual size leaves room for the splits; attaching resizes it.
|
||||
create = append([]string{"tmux", "new-session", "-d", "-x", "240", "-y", "70", "-s", session}, format...)
|
||||
default:
|
||||
create = append([]string{"tmux", "new-window", "-t", session + ":"}, format...)
|
||||
}
|
||||
out, err := r.Output(append(create, tmuxArgv(group[0])...)...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
window, pane, _ := strings.Cut(out, " ")
|
||||
if first == "" {
|
||||
first = window
|
||||
}
|
||||
paneIDs := []string{pane}
|
||||
for _, p := range group[1:] {
|
||||
id, err := r.Output(append([]string{"tmux", "split-window", "-t", window, "-P", "-F", "#{pane_id}"}, tmuxArgv(p)...)...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paneIDs = append(paneIDs, id)
|
||||
// Re-tile after every split so the next one still has room.
|
||||
if _, err := r.Output("tmux", "select-layout", "-t", window, "tiled"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for j, id := range paneIDs {
|
||||
// A pane may already be gone (host unreachable and closed); not fatal.
|
||||
_, _ = r.Output("tmux", "set-option", "-p", "-t", id, "@netssh_host", group[j].Title)
|
||||
}
|
||||
windowOptions := [][]string{
|
||||
{"pane-border-status", "top"},
|
||||
{"pane-border-format", " #{@netssh_host}#{?pane_synchronized, [SYNC],} "},
|
||||
}
|
||||
if opts.Sync {
|
||||
windowOptions = append(windowOptions, []string{"synchronize-panes", "on"})
|
||||
}
|
||||
for _, option := range windowOptions {
|
||||
if _, err := r.Output("tmux", "set-window-option", "-t", window, option[0], option[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if inside {
|
||||
_, err := r.Output("tmux", "select-window", "-t", first)
|
||||
return err
|
||||
}
|
||||
opts.Hint("toggle broadcast with: netssh sync (or Ctrl+b :setw synchronize-panes)")
|
||||
return r.Exec("tmux", "attach-session", "-t", session)
|
||||
}
|
||||
|
||||
// freeSession returns name, or name-2, name-3, … if a session already uses it.
|
||||
func freeSession(r Runner, name string) string {
|
||||
candidate := name
|
||||
for n := 2; ; n++ {
|
||||
if _, err := r.Output("tmux", "has-session", "-t", "="+candidate); err != nil {
|
||||
return candidate
|
||||
}
|
||||
candidate = name + "-" + strconv.Itoa(n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package multiplex
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// openZellij writes a layout with one tab per chunk of panes. Inside zellij
|
||||
// each tab is added to the current session; outside, a new session starts.
|
||||
func openZellij(panes []Pane, opts Options, r Runner) error {
|
||||
groups := chunks(panes, opts.PerWindow)
|
||||
if r.Getenv("ZELLIJ") != "" {
|
||||
for i, group := range groups {
|
||||
path := filepath.Join(opts.LayoutDir, fmt.Sprintf("netssh-multi-%d.kdl", i+1))
|
||||
tab := zellijTab(windowLabel(opts.Label, i, len(groups)), group, true)
|
||||
if err := r.WriteFile(path, []byte(zellijLayout(tab))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.Output("zellij", "action", "new-tab", "--layout", path); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Sync {
|
||||
if _, err := r.Output("zellij", "action", "toggle-active-sync-tab"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var tabs strings.Builder
|
||||
for i, group := range groups {
|
||||
tabs.WriteString(zellijTab(windowLabel(opts.Label, i, len(groups)), group, i == 0))
|
||||
}
|
||||
path := filepath.Join(opts.LayoutDir, "netssh-multi.kdl")
|
||||
if err := r.WriteFile(path, []byte(zellijLayout(tabs.String()))); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Sync {
|
||||
opts.Hint("zellij starts without broadcast: press Ctrl+t, then s, to type into all panes")
|
||||
} else {
|
||||
opts.Hint("toggle broadcast with Ctrl+t, then s (or netssh sync)")
|
||||
}
|
||||
return r.Exec("zellij", "--layout", path)
|
||||
}
|
||||
|
||||
// zellijLayout wraps tabs with zellij's tab and status bars.
|
||||
func zellijLayout(tabs string) string {
|
||||
return `layout {
|
||||
default_tab_template {
|
||||
pane size=1 borderless=true {
|
||||
plugin location="zellij:tab-bar"
|
||||
}
|
||||
children
|
||||
pane size=2 borderless=true {
|
||||
plugin location="zellij:status-bar"
|
||||
}
|
||||
}
|
||||
` + tabs + "}\n"
|
||||
}
|
||||
|
||||
// zellijTab arranges panes in a grid: rows of ceil(sqrt(n)) columns.
|
||||
func zellijTab(name string, panes []Pane, focus bool) string {
|
||||
cols := int(math.Ceil(math.Sqrt(float64(len(panes)))))
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, " tab name=%s", kdlString(name))
|
||||
if focus {
|
||||
b.WriteString(" focus=true")
|
||||
}
|
||||
b.WriteString(" {\n")
|
||||
for start := 0; start < len(panes); start += cols {
|
||||
row := panes[start:min(start+cols, len(panes))]
|
||||
indent := " "
|
||||
if len(row) > 1 {
|
||||
b.WriteString(indent + "pane split_direction=\"vertical\" {\n")
|
||||
indent += " "
|
||||
}
|
||||
for _, p := range row {
|
||||
fmt.Fprintf(&b, "%spane name=%s command=%s {\n", indent, kdlString(p.Title), kdlString(p.Argv[0]))
|
||||
if len(p.Argv) > 1 {
|
||||
args := make([]string, 0, len(p.Argv)-1)
|
||||
for _, arg := range p.Argv[1:] {
|
||||
args = append(args, kdlString(arg))
|
||||
}
|
||||
fmt.Fprintf(&b, "%s args %s\n", indent, strings.Join(args, " "))
|
||||
}
|
||||
b.WriteString(indent + "}\n")
|
||||
}
|
||||
if len(row) > 1 {
|
||||
b.WriteString(" }\n")
|
||||
}
|
||||
}
|
||||
b.WriteString(" }\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// kdlString quotes s as a KDL string.
|
||||
func kdlString(s string) string {
|
||||
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`).Replace(s) + `"`
|
||||
}
|
||||
+43
-12
@@ -105,6 +105,22 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
|
||||
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
|
||||
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
|
||||
}
|
||||
filters := ""
|
||||
if opts.Tag != "" {
|
||||
filters += "&tag=" + url.QueryEscape(opts.Tag)
|
||||
}
|
||||
if opts.Cluster != "" {
|
||||
ids, err := c.clusterIDs(ctx, opts.Cluster)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clusters: %w", err)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, nil // no such cluster in this NetBox
|
||||
}
|
||||
for _, id := range ids {
|
||||
filters += fmt.Sprintf("&cluster_id=%d", id)
|
||||
}
|
||||
}
|
||||
var (
|
||||
mu sync.Mutex
|
||||
results []HostEntry
|
||||
@@ -116,7 +132,7 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
devices, err := c.fetchAllDevices(ctx, query, opts.Tag)
|
||||
devices, err := c.fetchAllDevices(ctx, query, filters)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
@@ -131,7 +147,7 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
vms, err := c.fetchAllVMs(ctx, query, opts.Tag)
|
||||
vms, err := c.fetchAllVMs(ctx, query, filters)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
@@ -231,15 +247,27 @@ func (c *Client) searchVMs(ctx context.Context, query, tag string) ([]HostEntry,
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchAllDevices(ctx context.Context, query, tag string) ([]HostEntry, error) {
|
||||
// clusterIDs returns the IDs of the clusters named name (case-insensitive).
|
||||
func (c *Client) clusterIDs(ctx context.Context, name string) ([]int, error) {
|
||||
var resp netboxListResponse[netboxCluster]
|
||||
apiURL := fmt.Sprintf("%s/api/virtualization/clusters/?name__ie=%s&limit=50", c.baseURL, url.QueryEscape(name))
|
||||
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]int, 0, len(resp.Results))
|
||||
for _, cluster := range resp.Results {
|
||||
ids = append(ids, cluster.ID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// fetchAllDevices pages through devices; filters are extra "&key=value" parameters.
|
||||
func (c *Client) fetchAllDevices(ctx context.Context, query, filters string) ([]HostEntry, error) {
|
||||
const pageSize = 50
|
||||
var all []HostEntry
|
||||
for offset := 0; ; offset += pageSize {
|
||||
apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=%d&offset=%d",
|
||||
c.baseURL, url.QueryEscape(query), pageSize, offset)
|
||||
if tag != "" {
|
||||
apiURL += "&tag=" + url.QueryEscape(tag)
|
||||
}
|
||||
c.baseURL, url.QueryEscape(query), pageSize, offset) + filters
|
||||
var resp netboxListResponse[netboxDevice]
|
||||
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||
return nil, err
|
||||
@@ -254,15 +282,12 @@ func (c *Client) fetchAllDevices(ctx context.Context, query, tag string) ([]Host
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchAllVMs(ctx context.Context, query, tag string) ([]HostEntry, error) {
|
||||
func (c *Client) fetchAllVMs(ctx context.Context, query, filters string) ([]HostEntry, error) {
|
||||
const pageSize = 50
|
||||
var all []HostEntry
|
||||
for offset := 0; ; offset += pageSize {
|
||||
apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=%d&offset=%d",
|
||||
c.baseURL, url.QueryEscape(query), pageSize, offset)
|
||||
if tag != "" {
|
||||
apiURL += "&tag=" + url.QueryEscape(tag)
|
||||
}
|
||||
c.baseURL, url.QueryEscape(query), pageSize, offset) + filters
|
||||
var resp netboxListResponse[netboxVM]
|
||||
if err := c.get(ctx, apiURL, &resp); err != nil {
|
||||
return nil, err
|
||||
@@ -343,6 +368,9 @@ func deviceToEntry(d netboxDevice) HostEntry {
|
||||
for _, t := range d.Tags {
|
||||
e.Tags = append(e.Tags, t.Name)
|
||||
}
|
||||
if d.Cluster != nil {
|
||||
e.Cluster = d.Cluster.Name
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
@@ -357,6 +385,9 @@ func vmToEntry(v netboxVM) HostEntry {
|
||||
for _, t := range v.Tags {
|
||||
e.Tags = append(e.Tags, t.Name)
|
||||
}
|
||||
if v.Cluster != nil {
|
||||
e.Cluster = v.Cluster.Name
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package netbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSearchAllByCluster(t *testing.T) {
|
||||
var deviceQuery, vmQuery string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/virtualization/clusters/":
|
||||
if r.URL.Query().Get("name__ie") == "PVE" {
|
||||
fmt.Fprint(w, `{"count":2,"results":[{"id":3,"name":"pve"},{"id":4,"name":"pve"}]}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"count":0,"results":[]}`)
|
||||
case "/api/dcim/devices/":
|
||||
deviceQuery = r.URL.RawQuery
|
||||
fmt.Fprint(w, `{"count":1,"results":[{"id":1,"name":"node1","cluster":{"id":3,"name":"pve"}}]}`)
|
||||
case "/api/virtualization/virtual-machines/":
|
||||
vmQuery = r.URL.RawQuery
|
||||
fmt.Fprint(w, `{"count":1,"results":[{"id":2,"name":"vm1","cluster":{"id":4,"name":"pve"}}]}`)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := NewClient(srv.URL, "t", 1)
|
||||
|
||||
hosts, err := c.SearchAll(context.Background(), "", SearchOptions{Cluster: "PVE", Kind: "device"})
|
||||
if err != nil || len(hosts) != 1 || hosts[0].Name != "node1" || hosts[0].Cluster != "pve" {
|
||||
t.Fatalf("got %+v %v", hosts, err)
|
||||
}
|
||||
if want := "name__ic=&limit=50&offset=0&cluster_id=3&cluster_id=4"; deviceQuery != want {
|
||||
t.Errorf("device query %q, want %q", deviceQuery, want)
|
||||
}
|
||||
if vmQuery != "" {
|
||||
t.Error("VMs queried for kind device")
|
||||
}
|
||||
all, err := c.SearchAll(context.Background(), "", SearchOptions{Cluster: "PVE"})
|
||||
if err != nil || len(all) != 2 {
|
||||
t.Fatalf("all kinds: %+v %v", all, err)
|
||||
}
|
||||
none, err := c.SearchAll(context.Background(), "", SearchOptions{Cluster: "missing"})
|
||||
if err != nil || len(none) != 0 {
|
||||
t.Fatalf("unknown cluster: %+v %v", none, err)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import "strings"
|
||||
type SearchOptions struct {
|
||||
Tag string // filter by tag slug; empty = no filter
|
||||
Kind string // "device" | "vm" | "" (both)
|
||||
// Cluster filters SearchAll by virtualization cluster name (case-insensitive).
|
||||
Cluster string
|
||||
}
|
||||
|
||||
// HostEntry is a unified model for both devices and virtual machines from NetBox.
|
||||
@@ -18,6 +20,13 @@ type HostEntry struct {
|
||||
PrimaryIP4 string // e.g. "10.0.1.5" (prefix length stripped)
|
||||
PrimaryIP6 string
|
||||
Tags []string
|
||||
Cluster string // virtualization cluster name, if assigned
|
||||
}
|
||||
|
||||
// netboxCluster is the nested cluster of a device or virtual machine.
|
||||
type netboxCluster struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// netboxIP represents an IP address as returned by the NetBox API.
|
||||
@@ -32,8 +41,9 @@ type netboxDevice struct {
|
||||
Tags []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"tags"`
|
||||
PrimaryIP4 *netboxIP `json:"primary_ip4"`
|
||||
PrimaryIP6 *netboxIP `json:"primary_ip6"`
|
||||
PrimaryIP4 *netboxIP `json:"primary_ip4"`
|
||||
PrimaryIP6 *netboxIP `json:"primary_ip6"`
|
||||
Cluster *netboxCluster `json:"cluster"`
|
||||
}
|
||||
|
||||
// netboxVM matches the relevant fields of the NetBox /virtualization/virtual-machines/ response.
|
||||
@@ -43,8 +53,9 @@ type netboxVM struct {
|
||||
Tags []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"tags"`
|
||||
PrimaryIP4 *netboxIP `json:"primary_ip4"`
|
||||
PrimaryIP6 *netboxIP `json:"primary_ip6"`
|
||||
PrimaryIP4 *netboxIP `json:"primary_ip4"`
|
||||
PrimaryIP6 *netboxIP `json:"primary_ip6"`
|
||||
Cluster *netboxCluster `json:"cluster"`
|
||||
}
|
||||
|
||||
type netboxListResponse[T any] struct {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
|
||||
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
|
||||
)
|
||||
|
||||
func editMulti(cfg *config.Config) error {
|
||||
multiplexer := cfg.Multi.Multiplexer
|
||||
if multiplexer == "" {
|
||||
multiplexer = "auto"
|
||||
}
|
||||
perWindow := ""
|
||||
if cfg.Multi.PanesPerWindow > 0 {
|
||||
perWindow = strconv.Itoa(cfg.Multi.PanesPerWindow)
|
||||
}
|
||||
groups := formatGroups(cfg.Groups)
|
||||
err := huh.NewForm(huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Multiplexer").
|
||||
Description("netssh multi opens hosts side by side. auto uses the one you are in, else tmux, else zellij.").
|
||||
Options(huh.NewOption("auto", "auto"), huh.NewOption("tmux", "tmux"), huh.NewOption("zellij", "zellij")).
|
||||
Value(&multiplexer),
|
||||
huh.NewInput().
|
||||
Title("Panes per window").
|
||||
Description(fmt.Sprintf("More hosts open in further windows or tabs. Leave empty for %d.", config.DefaultPanesPerWindow)).
|
||||
Value(&perWindow).
|
||||
Validate(func(s string) error {
|
||||
if s = strings.TrimSpace(s); s == "" {
|
||||
return nil
|
||||
}
|
||||
if n, err := strconv.Atoi(s); err != nil || n < 1 || n > 64 {
|
||||
return errors.New("must be a number between 1 and 64")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewText().
|
||||
Title("Groups").
|
||||
Description("One per line: NAME: host, tag:TAG, cluster:NAME, @other — open with netssh multi @NAME.").
|
||||
Lines(4).
|
||||
Value(&groups).
|
||||
Validate(func(s string) error {
|
||||
_, err := parseGroups(s)
|
||||
return err
|
||||
}),
|
||||
).Title("Multi-session")).Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Multi.Multiplexer = multiplexer
|
||||
if multiplexer == "auto" {
|
||||
cfg.Multi.Multiplexer = ""
|
||||
}
|
||||
cfg.Multi.PanesPerWindow, _ = strconv.Atoi(strings.TrimSpace(perWindow))
|
||||
cfg.Groups, _ = parseGroups(groups)
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseGroups reads "NAME: sel1, sel2" lines into groups:.
|
||||
func parseGroups(s string) (map[string][]string, error) {
|
||||
var out map[string][]string
|
||||
for _, line := range parseLines(s) {
|
||||
name, members, ok := strings.Cut(line, ":")
|
||||
name = strings.ToLower(strings.TrimSpace(name))
|
||||
// "tag:x" alone has no group name before the first colon worth keeping.
|
||||
if !ok || name == "" || name == "tag" || name == "cluster" {
|
||||
return nil, fmt.Errorf("%q: expected NAME: host, tag:TAG, …", line)
|
||||
}
|
||||
var selectors []string
|
||||
for _, field := range strings.FieldsFunc(members, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' }) {
|
||||
selectors = append(selectors, field)
|
||||
}
|
||||
if out == nil {
|
||||
out = make(map[string][]string)
|
||||
}
|
||||
out[name] = append(out[name], selectors...)
|
||||
}
|
||||
if err := (&config.Config{Groups: out}).ValidateMulti(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func formatGroups(groups map[string][]string) string {
|
||||
names := make([]string, 0, len(groups))
|
||||
for name := range groups {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
lines := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
lines = append(lines, name+": "+strings.Join(groups[name], ", "))
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -91,6 +91,7 @@ func runMenu(cfg *config.Config) error {
|
||||
huh.NewOption("SSH options — jump host, host keys, target, -o, forwards", "ssh"),
|
||||
huh.NewOption(fmt.Sprintf("SSH rules — per tag, instance, kind, name (%d)", len(cfg.SSH.Rules)), "rules"),
|
||||
huh.NewOption(fmt.Sprintf("Static hosts — hosts not in NetBox (%d)", len(cfg.Hosts)), "hosts"),
|
||||
huh.NewOption(fmt.Sprintf("Multi-session — groups, tmux/zellij (%d groups)", len(cfg.Groups)), "multi"),
|
||||
huh.NewOption("Resolver & cache", "resolver"),
|
||||
huh.NewOption("Shortcuts & command alias", "shortcuts"),
|
||||
huh.NewOption("Completion", "completion"),
|
||||
@@ -115,6 +116,8 @@ func runMenu(cfg *config.Config) error {
|
||||
if hosts, err = configureHosts(cfg.Hosts); err == nil {
|
||||
cfg.Hosts = hosts
|
||||
}
|
||||
case "multi":
|
||||
err = editMulti(cfg)
|
||||
case "resolver":
|
||||
err = editResolverCache(cfg)
|
||||
case "shortcuts":
|
||||
@@ -369,6 +372,9 @@ func save(cfg config.Config) error {
|
||||
if err := cfg.ValidateHosts(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cfg.ValidateMulti(); err != nil {
|
||||
return err
|
||||
}
|
||||
path := config.Path()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("creating config dir: %w", err)
|
||||
@@ -461,6 +467,31 @@ func save(cfg config.Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
if len(cfg.Groups) > 0 {
|
||||
b.WriteString("\ngroups:\n")
|
||||
names := make([]string, 0, len(cfg.Groups))
|
||||
for name := range cfg.Groups {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
quoted := make([]string, 0, len(cfg.Groups[name]))
|
||||
for _, selector := range cfg.Groups[name] {
|
||||
quoted = append(quoted, strconv.Quote(selector))
|
||||
}
|
||||
fmt.Fprintf(&b, " %s: [%s]\n", name, strings.Join(quoted, ", "))
|
||||
}
|
||||
}
|
||||
if cfg.Multi.Multiplexer != "" || cfg.Multi.PanesPerWindow > 0 {
|
||||
b.WriteString("\nmulti:\n")
|
||||
if cfg.Multi.Multiplexer != "" {
|
||||
fmt.Fprintf(&b, " multiplexer: %s\n", cfg.Multi.Multiplexer)
|
||||
}
|
||||
if cfg.Multi.PanesPerWindow > 0 {
|
||||
fmt.Fprintf(&b, " panes_per_window: %d\n", cfg.Multi.PanesPerWindow)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Completion.MaxResults > 0 {
|
||||
fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults)
|
||||
}
|
||||
|
||||
@@ -617,3 +617,37 @@ func TestValidateHostIn(t *testing.T) {
|
||||
t.Fatal("invalid address accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseGroupsAndSaveMulti(t *testing.T) {
|
||||
groups, err := parseGroups("K8s: master1, tag:k8s-worker\npve: cluster:pve-fsn1 @k8s\n")
|
||||
want := map[string][]string{"k8s": {"master1", "tag:k8s-worker"}, "pve": {"cluster:pve-fsn1", "@k8s"}}
|
||||
if err != nil || !reflect.DeepEqual(groups, want) {
|
||||
t.Fatalf("got %v %v", groups, err)
|
||||
}
|
||||
if again, _ := parseGroups(formatGroups(groups)); !reflect.DeepEqual(again, want) {
|
||||
t.Fatalf("roundtrip: %v", again)
|
||||
}
|
||||
for _, bad := range []string{"no colon", "tag:x", "bad name: a", "empty:"} {
|
||||
if _, err := parseGroups(bad); err == nil {
|
||||
t.Errorf("%q accepted", bad)
|
||||
}
|
||||
}
|
||||
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
cfg := config.Config{
|
||||
Hosts: []config.StaticHost{{Name: "a", Address: "10.0.0.1"}},
|
||||
Groups: groups,
|
||||
Multi: config.MultiConfig{Multiplexer: "zellij", PanesPerWindow: 4},
|
||||
}
|
||||
if err := save(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(loaded.Groups, want) || loaded.Multi != cfg.Multi {
|
||||
t.Fatalf("got %v %+v", loaded.Groups, loaded.Multi)
|
||||
}
|
||||
}
|
||||
+74
-9
@@ -27,8 +27,9 @@ type SelectedHost struct {
|
||||
IP string
|
||||
User string // empty = use config default
|
||||
Port string // empty = use default port
|
||||
Action string // ActionSSH, ActionSFTP, or ActionTunnel
|
||||
Action string // ActionSSH, ActionSFTP, ActionTunnel, or ActionMulti
|
||||
Forwards []string // forward names or specs for ActionTunnel
|
||||
Targets []string // marked hosts for ActionMulti
|
||||
}
|
||||
|
||||
// Actions the user can start on a host.
|
||||
@@ -36,6 +37,7 @@ const (
|
||||
ActionSSH = ""
|
||||
ActionSFTP = "sftp"
|
||||
ActionTunnel = "tunnel"
|
||||
ActionMulti = "multi"
|
||||
)
|
||||
|
||||
// --- bubbletea messages ---
|
||||
@@ -74,7 +76,8 @@ func (h hostItem) FilterValue() string { return h.name }
|
||||
|
||||
// --- compact list delegate ---
|
||||
|
||||
type compactDelegate struct{}
|
||||
// compactDelegate shares the model's marks so marked hosts show a dot.
|
||||
type compactDelegate struct{ marked map[string]bool }
|
||||
|
||||
func (d compactDelegate) Height() int { return 1 }
|
||||
func (d compactDelegate) Spacing() int { return 0 }
|
||||
@@ -84,7 +87,11 @@ func (d compactDelegate) Render(w io.Writer, m list.Model, index int, item list.
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
line := fmt.Sprintf(" %s %s", h.name, lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(h.ip))
|
||||
mark := " "
|
||||
if d.marked[h.key()] {
|
||||
mark = "● "
|
||||
}
|
||||
line := fmt.Sprintf(" %s%s %s", mark, h.name, lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(h.ip))
|
||||
if index == m.Index() {
|
||||
line = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("> " + strings.TrimPrefix(line, " "))
|
||||
}
|
||||
@@ -169,10 +176,12 @@ type Model struct {
|
||||
selected *SelectedHost
|
||||
width int
|
||||
height int
|
||||
recentMode bool // true when showing recent hosts (empty search, initial state)
|
||||
filterOpen bool // Ctrl+F toggles
|
||||
editMode bool // Ctrl+E on the selected item
|
||||
forwardMode bool // Ctrl+T on the selected item
|
||||
recentMode bool // true when showing recent hosts (empty search, initial state)
|
||||
filterOpen bool // Ctrl+F toggles
|
||||
editMode bool // Ctrl+E on the selected item
|
||||
forwardMode bool // Ctrl+T on the selected item
|
||||
marked map[string]bool // hostItem.key() of hosts marked with space
|
||||
markOrder []string // targets in the order they were marked
|
||||
}
|
||||
|
||||
func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ...int) *Model {
|
||||
@@ -191,7 +200,8 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts
|
||||
fwd.Prompt = ""
|
||||
fwd.Placeholder = "web, 8080:localhost:80"
|
||||
|
||||
l := list.New(nil, compactDelegate{}, 0, 0)
|
||||
marked := make(map[string]bool)
|
||||
l := list.New(nil, compactDelegate{marked: marked}, 0, 0)
|
||||
l.SetShowHelp(false)
|
||||
l.SetShowTitle(false)
|
||||
l.SetShowStatusBar(false)
|
||||
@@ -212,6 +222,7 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts
|
||||
filterInput: fi,
|
||||
editInput: ei,
|
||||
forwardInput: fwd,
|
||||
marked: marked,
|
||||
list: l,
|
||||
client: client,
|
||||
cache: c,
|
||||
@@ -386,11 +397,26 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
|
||||
case "enter":
|
||||
if len(m.markOrder) > 0 {
|
||||
m.selected = &SelectedHost{Action: ActionMulti, Targets: append([]string(nil), m.markOrder...)}
|
||||
return m, tea.Quit
|
||||
}
|
||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||
m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind}
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
case "space":
|
||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||
m.toggleMark(item)
|
||||
m.list.CursorDown()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case "ctrl+a":
|
||||
m.toggleAllVisible()
|
||||
return m, nil
|
||||
|
||||
case "ctrl+e":
|
||||
if item, ok := m.list.SelectedItem().(hostItem); ok {
|
||||
m.openEdit(item)
|
||||
@@ -584,7 +610,10 @@ func (m *Model) viewContent() 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 ctrl+o sftp ctrl+t forward ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox"
|
||||
hint := "ctrl+c quit enter connect space mark ctrl+o sftp ctrl+t forward ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox"
|
||||
if n := len(m.markOrder); n > 0 {
|
||||
hint = fmt.Sprintf("%d marked — enter open side by side space unmark ctrl+a all esc quit", n)
|
||||
}
|
||||
sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n")
|
||||
}
|
||||
|
||||
@@ -724,6 +753,42 @@ func (m *Model) buildSelected(item hostItem) *SelectedHost {
|
||||
|
||||
func (m *Model) SetNotes(service *notes.Service) { m.noteService = service }
|
||||
|
||||
func (h hostItem) key() string { return h.name + "\x00" + h.kind }
|
||||
|
||||
// toggleMark marks or unmarks a host for opening several side by side.
|
||||
func (m *Model) toggleMark(item hostItem) {
|
||||
key := item.key()
|
||||
if m.marked[key] {
|
||||
delete(m.marked, key)
|
||||
for i, target := range m.markOrder {
|
||||
if target == item.name {
|
||||
m.markOrder = append(m.markOrder[:i], m.markOrder[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
m.marked[key] = true
|
||||
m.markOrder = append(m.markOrder, item.name)
|
||||
}
|
||||
|
||||
// toggleAllVisible marks every listed host, or unmarks them if all are marked.
|
||||
func (m *Model) toggleAllVisible() {
|
||||
var items []hostItem
|
||||
allMarked := true
|
||||
for _, it := range m.list.Items() {
|
||||
if item, ok := it.(hostItem); ok {
|
||||
items = append(items, item)
|
||||
allMarked = allMarked && m.marked[item.key()]
|
||||
}
|
||||
}
|
||||
for _, item := range items {
|
||||
if m.marked[item.key()] == allMarked {
|
||||
m.toggleMark(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetForwards provides the forward names configured for a host (ctrl+t).
|
||||
func (m *Model) SetForwards(forwardsFor func(name, kind string, tags []string) []string) {
|
||||
m.forwardsFor = forwardsFor
|
||||
|
||||
@@ -122,3 +122,38 @@ func TestForwardInputEscCancels(t *testing.T) {
|
||||
t.Fatal("esc did not cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpaceMarksHostsAndEnterOpensThem(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.updateListItems([]netbox.HostEntry{{Name: "a", Kind: "device"}, {Name: "b", Kind: "device"}, {Name: "c", Kind: "vm"}})
|
||||
m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
|
||||
if len(m.markOrder) != 1 || m.markOrder[0] != "a" || m.list.Index() != 1 {
|
||||
t.Fatalf("space: marks %v, cursor %d", m.markOrder, m.list.Index())
|
||||
}
|
||||
if m.input.Value() != "" {
|
||||
t.Fatal("space went into the search field")
|
||||
}
|
||||
m.list.Select(2)
|
||||
m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
|
||||
if !strings.Contains(m.viewContent(), "2 marked") {
|
||||
t.Fatalf("no marked count:\n%s", m.viewContent())
|
||||
}
|
||||
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
|
||||
if m.selected == nil || m.selected.Action != ActionMulti || !reflect.DeepEqual(m.selected.Targets, []string{"a", "c"}) {
|
||||
t.Fatalf("got %+v", m.selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCtrlATogglesAllVisible(t *testing.T) {
|
||||
m := New(nil, nil, "")
|
||||
m.updateListItems([]netbox.HostEntry{{Name: "a"}, {Name: "b"}})
|
||||
m.toggleMark(hostItem{name: "a"})
|
||||
m.Update(tea.KeyPressMsg{Code: 'a', Mod: tea.ModCtrl})
|
||||
if !reflect.DeepEqual(m.markOrder, []string{"a", "b"}) {
|
||||
t.Fatalf("mark all: %v", m.markOrder)
|
||||
}
|
||||
m.Update(tea.KeyPressMsg{Code: 'a', Mod: tea.ModCtrl})
|
||||
if len(m.markOrder) != 0 {
|
||||
t.Fatalf("unmark all: %v", m.markOrder)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user