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
+14
-5
@@ -46,6 +46,8 @@ var managedSubcommands = map[string]bool{
|
||||
"scp": true,
|
||||
"tunnel": true,
|
||||
"hosts": true,
|
||||
"multi": true,
|
||||
"sync": true,
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -291,7 +293,7 @@ func resolveTarget(cfg *config.Config, host string) resolvedHost {
|
||||
fatalf("IP resolution for %q: %v", host, err)
|
||||
}
|
||||
|
||||
entry := cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags}
|
||||
entry := cache.Entry{Name: target.Target(), IP: ip, Kind: target.Kind, Tags: target.Tags, Cluster: target.Cluster}
|
||||
c.Upsert(entry)
|
||||
return used(entry)
|
||||
}
|
||||
@@ -389,6 +391,12 @@ func runTUI() {
|
||||
if host == nil {
|
||||
return
|
||||
}
|
||||
if host.Action == tui.ActionMulti {
|
||||
if err := runMulti(cfg, host.Targets, multiFlags{}); err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Revalidate stale or live TUI results through the configured resolver chain.
|
||||
var tags []string
|
||||
@@ -413,7 +421,7 @@ func runTUI() {
|
||||
if err != nil {
|
||||
fatalf("IP resolution: %v", err)
|
||||
}
|
||||
c.Upsert(cache.Entry{Name: target.Target(), IP: host.IP, Kind: target.Kind, Tags: target.Tags})
|
||||
c.Upsert(cache.Entry{Name: target.Target(), IP: host.IP, Kind: target.Kind, Tags: target.Tags, Cluster: target.Cluster})
|
||||
tags = target.Tags
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Connecting to %s (%s)…\n", host.Name, host.IP)
|
||||
@@ -499,7 +507,7 @@ func rootCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd(), sftpCmd(), scpCmd(), tunnelCmd(), hostsCmd())
|
||||
root.AddCommand(notesCmd(), configureCmd(), searchCmd(), cacheCmd(), aliasCmd(), hookCmd(), shellInitCmd(), versionCmd(), uninstallCmd(), sftpCmd(), scpCmd(), tunnelCmd(), hostsCmd(), multiCmd(), syncCmd())
|
||||
|
||||
// cobra builds the "completion" command lazily; force init so we can extend it.
|
||||
root.InitDefaultCompletionCmd()
|
||||
@@ -754,7 +762,7 @@ func cacheRefreshCmd() *cobra.Command {
|
||||
fmt.Fprintf(os.Stderr, "warning: skipping %s: %v\n", e.Name, err)
|
||||
continue
|
||||
}
|
||||
c.Upsert(cache.Entry{Name: e.Target(), IP: ip, Kind: e.Kind, Tags: e.Tags})
|
||||
c.Upsert(cache.Entry{Name: e.Target(), IP: ip, Kind: e.Kind, Tags: e.Tags, Cluster: e.Cluster})
|
||||
written++
|
||||
}
|
||||
|
||||
@@ -995,7 +1003,8 @@ func hookUninstallCmd() *cobra.Command {
|
||||
// maybeBackgroundRefresh starts a background `netssh cache refresh` if the cache
|
||||
// has not been fully refreshed within the last 24 hours.
|
||||
func maybeBackgroundRefresh(cfg *config.Config, c *cache.Cache) {
|
||||
if !cfg.HasNetBox() {
|
||||
// netssh multi checks once and starts its panes with NETSSH_NO_REFRESH.
|
||||
if !cfg.HasNetBox() || os.Getenv("NETSSH_NO_REFRESH") != "" {
|
||||
return
|
||||
}
|
||||
if !c.NeedsRefresh(24 * time.Hour) {
|
||||
|
||||
Reference in new issue
Block a user