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>
102 lines
3.0 KiB
Go
102 lines
3.0 KiB
Go
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) + `"`
|
|
}
|