Files
Sebastian UnterschützandClaude Opus 5.5 a31aab4110 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>
2026-11-14 12:45:05 +01:00

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) + `"`
}