feat: add model-aware management workflows
This commit is contained in:
parent
f05e8e8e84
commit
db25f9011b
|
|
@ -0,0 +1,39 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func listIdentityFiles() ([]string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sshDir := filepath.Join(home, ".ssh")
|
||||||
|
entries, err := os.ReadDir(sshDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var paths []string
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
lower := strings.ToLower(name)
|
||||||
|
if strings.HasSuffix(lower, ".pub") || lower == "config" || strings.HasPrefix(lower, "known_hosts") || lower == "authorized_keys" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(name, "id_") || strings.HasSuffix(lower, ".pem") || strings.HasSuffix(lower, ".key") {
|
||||||
|
paths = append(paths, filepath.Join(sshDir, name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(paths)
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
@ -93,9 +93,16 @@ func runTUI() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tui.ListIdentityFiles = listIdentityFiles
|
||||||
tui.GetGroups = func() ([]string, error) {
|
tui.GetGroups = func() ([]string, error) {
|
||||||
return appDB.GetGroups()
|
return appDB.GetGroups()
|
||||||
}
|
}
|
||||||
|
tui.ListGroups = func() ([]*model.Group, error) {
|
||||||
|
return appDB.ListGroups()
|
||||||
|
}
|
||||||
|
tui.CreateGroup = func(name string) error {
|
||||||
|
return appDB.CreateGroup(name)
|
||||||
|
}
|
||||||
tui.ResolveRouteAlias = func(alias string) (int64, bool) {
|
tui.ResolveRouteAlias = func(alias string) (int64, bool) {
|
||||||
return appDB.ResolveAlias(alias)
|
return appDB.ResolveAlias(alias)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11
cmd/vault.go
11
cmd/vault.go
|
|
@ -250,7 +250,16 @@ func formatVaultSecretsList(v *vault.Vault) (string, error) {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
fmt.Fprintf(&b, "%-24s %-18s\n", "ALIAS", "TYPE")
|
fmt.Fprintf(&b, "%-24s %-18s\n", "ALIAS", "TYPE")
|
||||||
for _, meta := range metas {
|
for _, meta := range metas {
|
||||||
fmt.Fprintf(&b, "%-24s %-18s\n", meta.Alias, meta.Type)
|
alias := meta.Alias
|
||||||
|
if alias == "" && meta.ServerID > 0 {
|
||||||
|
alias = fmt.Sprintf("#%d", meta.ServerID)
|
||||||
|
if appDB != nil {
|
||||||
|
if server, err := appDB.GetServerByID(meta.ServerID); err == nil && server != nil {
|
||||||
|
alias = server.Alias
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "%-24s %-18s\n", alias, meta.Type)
|
||||||
}
|
}
|
||||||
return b.String(), nil
|
return b.String(), nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mirivlad/sshkeeper/internal/model"
|
"github.com/mirivlad/sshkeeper/internal/model"
|
||||||
|
|
@ -286,3 +287,73 @@ func TestSearchServersMatchesTagsRoutesAndForwardPorts(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRouteProfileReferenceSurvivesAliasRename(t *testing.T) {
|
||||||
|
db, err := Open(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
bastion := &model.Server{Alias: "bastion", Host: "gw.example", Port: 22, User: "root", AuthMethod: model.AuthKey}
|
||||||
|
if err := db.CreateServer(bastion); err != nil {
|
||||||
|
t.Fatalf("create bastion: %v", err)
|
||||||
|
}
|
||||||
|
target := &model.Server{Alias: "prod", Host: "10.0.0.10", Port: 22, User: "ops", AuthMethod: model.AuthKey, Route: model.Route{Hops: []model.RouteHop{{ServerID: bastion.ID, Alias: bastion.Alias, IsProfile: true}}}}
|
||||||
|
if err := db.CreateServer(target); err != nil {
|
||||||
|
t.Fatalf("create target: %v", err)
|
||||||
|
}
|
||||||
|
bastion.Alias = "edge-gw"
|
||||||
|
if err := db.UpdateServerByAlias("bastion", bastion); err != nil {
|
||||||
|
t.Fatalf("rename bastion: %v", err)
|
||||||
|
}
|
||||||
|
got, err := db.GetServer("prod")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load target: %v", err)
|
||||||
|
}
|
||||||
|
if len(got.Route.Hops) != 1 || got.Route.Hops[0].ServerID != bastion.ID || got.Route.Hops[0].Alias != "edge-gw" {
|
||||||
|
t.Fatalf("route did not follow renamed profile: %#v", got.Route.Hops)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteServerRejectsReferencedRouteProfile(t *testing.T) {
|
||||||
|
db, err := Open(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
bastion := &model.Server{Alias: "bastion", Host: "gw.example", Port: 22, AuthMethod: model.AuthKey}
|
||||||
|
if err := db.CreateServer(bastion); err != nil {
|
||||||
|
t.Fatalf("create bastion: %v", err)
|
||||||
|
}
|
||||||
|
target := &model.Server{Alias: "prod", Host: "10.0.0.10", Port: 22, AuthMethod: model.AuthKey, Route: model.Route{Hops: []model.RouteHop{{ServerID: bastion.ID, Alias: bastion.Alias, IsProfile: true}}}}
|
||||||
|
if err := db.CreateServer(target); err != nil {
|
||||||
|
t.Fatalf("create target: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.DeleteServer("bastion"); err == nil || !strings.Contains(err.Error(), "prod") {
|
||||||
|
t.Fatalf("expected dependent-route delete error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteCycleIsRejected(t *testing.T) {
|
||||||
|
db, err := Open(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
a := &model.Server{Alias: "a", Host: "a.example", Port: 22, AuthMethod: model.AuthKey}
|
||||||
|
b := &model.Server{Alias: "b", Host: "b.example", Port: 22, AuthMethod: model.AuthKey}
|
||||||
|
if err := db.CreateServer(a); err != nil {
|
||||||
|
t.Fatalf("create a: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.CreateServer(b); err != nil {
|
||||||
|
t.Fatalf("create b: %v", err)
|
||||||
|
}
|
||||||
|
a.Route = model.Route{Hops: []model.RouteHop{{ServerID: b.ID, Alias: b.Alias, IsProfile: true}}}
|
||||||
|
if err := db.UpdateServer(a); err != nil {
|
||||||
|
t.Fatalf("set a route: %v", err)
|
||||||
|
}
|
||||||
|
b.Route = model.Route{Hops: []model.RouteHop{{ServerID: a.ID, Alias: a.Alias, IsProfile: true}}}
|
||||||
|
if err := db.UpdateServer(b); err == nil || !strings.Contains(strings.ToLower(err.Error()), "cycle") {
|
||||||
|
t.Fatalf("expected cycle error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,22 +48,6 @@ type Server struct {
|
||||||
LastTestError string `json:"last_test_error"`
|
LastTestError string `json:"last_test_error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SecretType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
SecretSSHPassword SecretType = "ssh_password"
|
|
||||||
SecretKeyPassphrase SecretType = "key_passphrase"
|
|
||||||
SecretSudoPassword SecretType = "sudo_password"
|
|
||||||
SecretCustom SecretType = "custom_secret"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Secret struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Type SecretType `json:"type"`
|
|
||||||
Nonce []byte `json:"nonce"`
|
|
||||||
Data []byte `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ForwardType string
|
type ForwardType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,13 @@ type tagsLoadedMsg struct {
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type groupsLoadedMsg struct {
|
||||||
|
groups []*model.Group
|
||||||
|
deleted bool
|
||||||
|
deletedName string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
type backgroundRunDoneMsg struct {
|
type backgroundRunDoneMsg struct {
|
||||||
results []templateRunResult
|
results []templateRunResult
|
||||||
}
|
}
|
||||||
|
|
@ -146,6 +153,16 @@ func (i serverItem) FilterValue() string {
|
||||||
return strings.Join(parts, " ")
|
return strings.Join(parts, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type groupManagerItem struct {
|
||||||
|
group *model.Group
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i groupManagerItem) Title() string { return i.group.Name }
|
||||||
|
func (i groupManagerItem) Description() string {
|
||||||
|
return fmt.Sprintf("%d servers", i.group.ServerCount)
|
||||||
|
}
|
||||||
|
func (i groupManagerItem) FilterValue() string { return i.group.Name }
|
||||||
|
|
||||||
type templateItem struct {
|
type templateItem struct {
|
||||||
template *model.CommandTemplate
|
template *model.CommandTemplate
|
||||||
}
|
}
|
||||||
|
|
@ -179,7 +196,10 @@ var (
|
||||||
SaveServer func(server *model.Server, password string, oldAlias string) error
|
SaveServer func(server *model.Server, password string, oldAlias string) error
|
||||||
UpdateTestResult func(alias string, status model.TestStatus, testErr string) error
|
UpdateTestResult func(alias string, status model.TestStatus, testErr string) error
|
||||||
HasSecret func(alias string, secretType string) bool
|
HasSecret func(alias string, secretType string) bool
|
||||||
|
ListIdentityFiles func() ([]string, error)
|
||||||
GetGroups func() ([]string, error)
|
GetGroups func() ([]string, error)
|
||||||
|
ListGroups func() ([]*model.Group, error)
|
||||||
|
CreateGroup func(name string) error
|
||||||
ResolveRouteAlias func(alias string) (int64, bool)
|
ResolveRouteAlias func(alias string) (int64, bool)
|
||||||
RenameGroup func(oldName, newName string) error
|
RenameGroup func(oldName, newName string) error
|
||||||
DeleteGroup func(name string) error
|
DeleteGroup func(name string) error
|
||||||
|
|
@ -210,6 +230,8 @@ const (
|
||||||
screenSearch
|
screenSearch
|
||||||
screenTags
|
screenTags
|
||||||
screenTagInput
|
screenTagInput
|
||||||
|
screenGroups
|
||||||
|
screenGroupInput
|
||||||
screenTemplates
|
screenTemplates
|
||||||
screenTemplateForm
|
screenTemplateForm
|
||||||
screenTemplatePicker
|
screenTemplatePicker
|
||||||
|
|
@ -217,6 +239,7 @@ const (
|
||||||
screenBackgroundResults
|
screenBackgroundResults
|
||||||
screenHelp
|
screenHelp
|
||||||
screenActionMenu
|
screenActionMenu
|
||||||
|
screenManageMenu
|
||||||
screenForwardList
|
screenForwardList
|
||||||
screenForwardForm
|
screenForwardForm
|
||||||
screenTunnelManager
|
screenTunnelManager
|
||||||
|
|
@ -271,6 +294,11 @@ type tuiModel struct {
|
||||||
tagInput textinput.Model
|
tagInput textinput.Model
|
||||||
tagMode string
|
tagMode string
|
||||||
tagOldName string
|
tagOldName string
|
||||||
|
groups []*model.Group
|
||||||
|
groupList list.Model
|
||||||
|
groupInput textinput.Model
|
||||||
|
groupMode string
|
||||||
|
groupOldName string
|
||||||
selected map[string]bool
|
selected map[string]bool
|
||||||
tunnelScreen *tunnelScreenModel
|
tunnelScreen *tunnelScreenModel
|
||||||
bgResults []templateRunResult
|
bgResults []templateRunResult
|
||||||
|
|
@ -281,6 +309,7 @@ type tuiModel struct {
|
||||||
result *TUIResult
|
result *TUIResult
|
||||||
helpScreen *helpScreenModel
|
helpScreen *helpScreenModel
|
||||||
actionMenu *actionMenuModel
|
actionMenu *actionMenuModel
|
||||||
|
manageMenu *actionMenuModel
|
||||||
forwardScreen *forwardScreenModel
|
forwardScreen *forwardScreenModel
|
||||||
forwardForm *forwardFormModel
|
forwardForm *forwardFormModel
|
||||||
confirm *confirmState
|
confirm *confirmState
|
||||||
|
|
@ -308,11 +337,19 @@ func New(servers []*model.Server) *tuiModel {
|
||||||
tagInput := textinput.New()
|
tagInput := textinput.New()
|
||||||
tagInput.Placeholder = "tag"
|
tagInput.Placeholder = "tag"
|
||||||
tagInput.CharLimit = 64
|
tagInput.CharLimit = 64
|
||||||
|
|
||||||
|
groupInput := textinput.New()
|
||||||
|
groupInput.Placeholder = "group"
|
||||||
|
groupInput.CharLimit = 64
|
||||||
templateList := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0)
|
templateList := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0)
|
||||||
templateList.SetShowStatusBar(false)
|
templateList.SetShowStatusBar(false)
|
||||||
templateList.SetFilteringEnabled(false)
|
templateList.SetFilteringEnabled(false)
|
||||||
templateList.SetShowHelp(false)
|
templateList.SetShowHelp(false)
|
||||||
tagList := newStringList(nil, "Tags", 0, 0)
|
tagList := newStringList(nil, "Tags", 0, 0)
|
||||||
|
groupList := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0)
|
||||||
|
groupList.SetShowStatusBar(false)
|
||||||
|
groupList.SetFilteringEnabled(false)
|
||||||
|
groupList.SetShowHelp(false)
|
||||||
|
|
||||||
vaultIsUnlocked := true
|
vaultIsUnlocked := true
|
||||||
if VaultUnlocked != nil {
|
if VaultUnlocked != nil {
|
||||||
|
|
@ -326,8 +363,10 @@ func New(servers []*model.Server) *tuiModel {
|
||||||
searchInput: search,
|
searchInput: search,
|
||||||
selected: map[string]bool{},
|
selected: map[string]bool{},
|
||||||
tagInput: tagInput,
|
tagInput: tagInput,
|
||||||
|
groupInput: groupInput,
|
||||||
templateList: templateList,
|
templateList: templateList,
|
||||||
tagList: tagList,
|
tagList: tagList,
|
||||||
|
groupList: groupList,
|
||||||
vaultUnlocked: vaultIsUnlocked,
|
vaultUnlocked: vaultIsUnlocked,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -385,6 +424,7 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
}
|
}
|
||||||
m.templateList.SetSize(msg.Width, managerListHeight(msg.Height))
|
m.templateList.SetSize(msg.Width, managerListHeight(msg.Height))
|
||||||
m.tagList.SetSize(msg.Width, managerListHeight(msg.Height))
|
m.tagList.SetSize(msg.Width, managerListHeight(msg.Height))
|
||||||
|
m.groupList.SetSize(msg.Width, managerListHeight(msg.Height))
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case serversLoadedMsg:
|
case serversLoadedMsg:
|
||||||
|
|
@ -434,6 +474,23 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
m.setTags(msg.tags)
|
m.setTags(msg.tags)
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case groupsLoadedMsg:
|
||||||
|
if m.confirm != nil && m.confirm.pending && m.confirm.parent == screenGroups {
|
||||||
|
m.finishConfirm()
|
||||||
|
}
|
||||||
|
if msg.err != nil {
|
||||||
|
if msg.deleted {
|
||||||
|
m.removeGroup(msg.deletedName)
|
||||||
|
m.err = nil
|
||||||
|
m.success = fmt.Sprintf("Deleted %q; refresh failed: %v", msg.deletedName, msg.err)
|
||||||
|
} else {
|
||||||
|
m.err = msg.err
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.setGroups(msg.groups)
|
||||||
|
return m, nil
|
||||||
|
|
||||||
case connectRequestMsg:
|
case connectRequestMsg:
|
||||||
m.result = &TUIResult{
|
m.result = &TUIResult{
|
||||||
Server: msg.server,
|
Server: msg.server,
|
||||||
|
|
@ -707,6 +764,10 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return m.updateTags(msg)
|
return m.updateTags(msg)
|
||||||
case screenTagInput:
|
case screenTagInput:
|
||||||
return m.updateTagInput(msg)
|
return m.updateTagInput(msg)
|
||||||
|
case screenGroups:
|
||||||
|
return m.updateGroups(msg)
|
||||||
|
case screenGroupInput:
|
||||||
|
return m.updateGroupInput(msg)
|
||||||
case screenTemplates:
|
case screenTemplates:
|
||||||
return m.updateTemplates(msg)
|
return m.updateTemplates(msg)
|
||||||
case screenTemplateForm:
|
case screenTemplateForm:
|
||||||
|
|
@ -721,6 +782,8 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return m.updateHelp(msg)
|
return m.updateHelp(msg)
|
||||||
case screenActionMenu:
|
case screenActionMenu:
|
||||||
return m.updateActionMenu(msg)
|
return m.updateActionMenu(msg)
|
||||||
|
case screenManageMenu:
|
||||||
|
return m.updateManageMenu(msg)
|
||||||
case screenForwardList:
|
case screenForwardList:
|
||||||
return m.updateForwardList(msg)
|
return m.updateForwardList(msg)
|
||||||
case screenForwardForm:
|
case screenForwardForm:
|
||||||
|
|
@ -813,6 +876,11 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
return m.openTemplatePicker()
|
return m.openTemplatePicker()
|
||||||
|
|
||||||
case tea.KeyRunes:
|
case tea.KeyRunes:
|
||||||
|
if msg.String() == "m" || msg.String() == "M" {
|
||||||
|
m.manageMenu = newManageMenuModel(m.width, m.height)
|
||||||
|
m.screen = screenManageMenu
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
if msg.String() == "?" {
|
if msg.String() == "?" {
|
||||||
m.helpParent = m.screen
|
m.helpParent = m.screen
|
||||||
m.helpScreen = newHelpScreenModel(m.width, m.height)
|
m.helpScreen = newHelpScreenModel(m.width, m.height)
|
||||||
|
|
@ -1014,6 +1082,101 @@ func (m *tuiModel) updateTagInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) updateGroups(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.Type {
|
||||||
|
case tea.KeyEsc:
|
||||||
|
m.screen = screenList
|
||||||
|
return m, m.reloadServersCmd()
|
||||||
|
case tea.KeyCtrlA:
|
||||||
|
m.groupMode = "add"
|
||||||
|
m.groupOldName = ""
|
||||||
|
m.groupInput.SetValue("")
|
||||||
|
m.groupInput.Focus()
|
||||||
|
m.screen = screenGroupInput
|
||||||
|
return m, nil
|
||||||
|
case tea.KeyCtrlE:
|
||||||
|
if item, ok := m.groupList.SelectedItem().(groupManagerItem); ok && item.group != nil {
|
||||||
|
m.groupMode = "rename"
|
||||||
|
m.groupOldName = item.group.Name
|
||||||
|
m.groupInput.SetValue(item.group.Name)
|
||||||
|
m.groupInput.Focus()
|
||||||
|
m.screen = screenGroupInput
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case tea.KeyCtrlD:
|
||||||
|
if item, ok := m.groupList.SelectedItem().(groupManagerItem); ok && item.group != nil {
|
||||||
|
name := item.group.Name
|
||||||
|
count := item.group.ServerCount
|
||||||
|
m.beginConfirm(confirmState{
|
||||||
|
title: "Delete group?",
|
||||||
|
target: fmt.Sprintf("%q", name),
|
||||||
|
consequence: fmt.Sprintf("The group is removed; %d server profile(s) become ungrouped.", count),
|
||||||
|
verb: "Delete",
|
||||||
|
parent: screenGroups,
|
||||||
|
action: func() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if DeleteGroup == nil {
|
||||||
|
return groupsLoadedMsg{err: fmt.Errorf("group deletion is unavailable")}
|
||||||
|
}
|
||||||
|
if err := DeleteGroup(name); err != nil {
|
||||||
|
return groupsLoadedMsg{err: err}
|
||||||
|
}
|
||||||
|
if ListGroups == nil {
|
||||||
|
return groupsLoadedMsg{deleted: true, deletedName: name, err: fmt.Errorf("group reload is unavailable")}
|
||||||
|
}
|
||||||
|
groups, err := ListGroups()
|
||||||
|
return groupsLoadedMsg{groups: groups, deleted: true, deletedName: name, err: err}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.groupList, cmd = m.groupList.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) updateGroupInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.Type {
|
||||||
|
case tea.KeyEsc:
|
||||||
|
m.screen = screenGroups
|
||||||
|
m.groupInput.Blur()
|
||||||
|
return m, nil
|
||||||
|
case tea.KeyEnter:
|
||||||
|
value := strings.TrimSpace(m.groupInput.Value())
|
||||||
|
if value == "" {
|
||||||
|
m.screen = screenGroups
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
mode := m.groupMode
|
||||||
|
oldName := m.groupOldName
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
switch mode {
|
||||||
|
case "rename":
|
||||||
|
if RenameGroup == nil {
|
||||||
|
return groupsLoadedMsg{err: fmt.Errorf("group rename is unavailable")}
|
||||||
|
}
|
||||||
|
if err := RenameGroup(oldName, value); err != nil {
|
||||||
|
return groupsLoadedMsg{err: err}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if CreateGroup == nil {
|
||||||
|
return groupsLoadedMsg{err: fmt.Errorf("group creation is unavailable")}
|
||||||
|
}
|
||||||
|
if err := CreateGroup(value); err != nil {
|
||||||
|
return groupsLoadedMsg{err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groups, err := ListGroups()
|
||||||
|
return groupsLoadedMsg{groups: groups, err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.groupInput, cmd = m.groupInput.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
func (m *tuiModel) updateTemplates(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *tuiModel) updateTemplates(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case tea.KeyEsc:
|
case tea.KeyEsc:
|
||||||
|
|
@ -1226,6 +1389,10 @@ func (m *tuiModel) View() string {
|
||||||
|
|
||||||
case screenTagInput:
|
case screenTagInput:
|
||||||
b.WriteString(m.viewTagInput())
|
b.WriteString(m.viewTagInput())
|
||||||
|
case screenGroups:
|
||||||
|
b.WriteString(m.viewGroups())
|
||||||
|
case screenGroupInput:
|
||||||
|
b.WriteString(m.viewGroupInput())
|
||||||
|
|
||||||
case screenTemplates:
|
case screenTemplates:
|
||||||
b.WriteString(m.viewTemplates())
|
b.WriteString(m.viewTemplates())
|
||||||
|
|
@ -1256,6 +1423,10 @@ func (m *tuiModel) View() string {
|
||||||
if m.actionMenu != nil {
|
if m.actionMenu != nil {
|
||||||
b.WriteString(m.actionMenu.View())
|
b.WriteString(m.actionMenu.View())
|
||||||
}
|
}
|
||||||
|
case screenManageMenu:
|
||||||
|
if m.manageMenu != nil {
|
||||||
|
b.WriteString(m.manageMenu.View())
|
||||||
|
}
|
||||||
|
|
||||||
case screenForwardList:
|
case screenForwardList:
|
||||||
if m.forwardScreen != nil {
|
if m.forwardScreen != nil {
|
||||||
|
|
@ -1350,11 +1521,6 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
m.actionMenu = nil
|
m.actionMenu = nil
|
||||||
return m, m.forwardScreen.loadForwards()
|
return m, m.forwardScreen.loadForwards()
|
||||||
}
|
}
|
||||||
case "tunnels":
|
|
||||||
m.tunnelScreen = newTunnelScreenModel(m.width, m.height)
|
|
||||||
m.screen = screenTunnelManager
|
|
||||||
m.actionMenu = nil
|
|
||||||
return m, m.tunnelScreen.loadTunnels()
|
|
||||||
case "route":
|
case "route":
|
||||||
if item, ok := m.list.SelectedItem().(serverItem); ok {
|
if item, ok := m.list.SelectedItem().(serverItem); ok {
|
||||||
m.form = newEditFormModel(item.server, m.width, m.height)
|
m.form = newEditFormModel(item.server, m.width, m.height)
|
||||||
|
|
@ -1387,39 +1553,6 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
m.confirmServerDelete(item.server)
|
m.confirmServerDelete(item.server)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
case "import":
|
|
||||||
m.screen = screenList
|
|
||||||
m.actionMenu = nil
|
|
||||||
return m, func() tea.Msg {
|
|
||||||
if ImportServers == nil {
|
|
||||||
return importDoneMsg{err: fmt.Errorf("import is unavailable")}
|
|
||||||
}
|
|
||||||
count, err := ImportServers()
|
|
||||||
if err != nil {
|
|
||||||
return importDoneMsg{err: err}
|
|
||||||
}
|
|
||||||
servers, err := ListServers()
|
|
||||||
return importDoneMsg{servers: servers, count: count, err: err}
|
|
||||||
}
|
|
||||||
case "export":
|
|
||||||
m.actionMenu = nil
|
|
||||||
m.result = &TUIResult{Action: "export"}
|
|
||||||
return m, tea.Quit
|
|
||||||
case "vault_lock":
|
|
||||||
m.screen = screenList
|
|
||||||
m.actionMenu = nil
|
|
||||||
if LockVault == nil {
|
|
||||||
m.err = fmt.Errorf("vault lock is unavailable")
|
|
||||||
} else if err := LockVault(); err != nil {
|
|
||||||
m.err = err
|
|
||||||
} else {
|
|
||||||
m.vaultUnlocked = false
|
|
||||||
m.success = "Vault locked."
|
|
||||||
}
|
|
||||||
case "vault_change_pw":
|
|
||||||
m.actionMenu = nil
|
|
||||||
m.result = &TUIResult{Action: "vault_change_pw"}
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
@ -1427,6 +1560,65 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) updateManageMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
updated, action := m.manageMenu.Update(msg)
|
||||||
|
m.manageMenu = updated
|
||||||
|
if msg.Type == tea.KeyEsc {
|
||||||
|
m.screen = screenList
|
||||||
|
m.manageMenu = nil
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if action == nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.manageMenu = nil
|
||||||
|
switch *action {
|
||||||
|
case "groups":
|
||||||
|
m.screen = screenGroups
|
||||||
|
return m, m.loadGroupsCmd()
|
||||||
|
case "tags":
|
||||||
|
m.screen = screenTags
|
||||||
|
return m, m.loadTagsCmd()
|
||||||
|
case "templates":
|
||||||
|
m.screen = screenTemplates
|
||||||
|
return m, m.loadTemplatesCmd()
|
||||||
|
case "tunnels":
|
||||||
|
m.tunnelScreen = newTunnelScreenModel(m.width, m.height)
|
||||||
|
m.screen = screenTunnelManager
|
||||||
|
return m, m.tunnelScreen.loadTunnels()
|
||||||
|
case "import":
|
||||||
|
m.screen = screenList
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
if ImportServers == nil {
|
||||||
|
return importDoneMsg{err: fmt.Errorf("import is unavailable")}
|
||||||
|
}
|
||||||
|
count, err := ImportServers()
|
||||||
|
if err != nil {
|
||||||
|
return importDoneMsg{err: err}
|
||||||
|
}
|
||||||
|
servers, err := ListServers()
|
||||||
|
return importDoneMsg{servers: servers, count: count, err: err}
|
||||||
|
}
|
||||||
|
case "export":
|
||||||
|
m.result = &TUIResult{Action: "export"}
|
||||||
|
return m, tea.Quit
|
||||||
|
case "vault_lock":
|
||||||
|
m.screen = screenList
|
||||||
|
if LockVault == nil {
|
||||||
|
m.err = fmt.Errorf("vault lock is unavailable")
|
||||||
|
} else if err := LockVault(); err != nil {
|
||||||
|
m.err = err
|
||||||
|
} else {
|
||||||
|
m.vaultUnlocked = false
|
||||||
|
m.success = "Vault locked."
|
||||||
|
}
|
||||||
|
case "vault_change_pw":
|
||||||
|
m.result = &TUIResult{Action: "vault_change_pw"}
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m *tuiModel) updateForwardList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case tea.KeyEsc:
|
case tea.KeyEsc:
|
||||||
|
|
@ -1758,7 +1950,7 @@ func (m *tuiModel) updateFullHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
|
||||||
func (m *tuiModel) screenOwnsPrintableInput() bool {
|
func (m *tuiModel) screenOwnsPrintableInput() bool {
|
||||||
switch m.screen {
|
switch m.screen {
|
||||||
case screenForm, screenSearch, screenTagInput, screenTemplateForm, screenForwardForm:
|
case screenForm, screenSearch, screenTagInput, screenGroupInput, screenTemplateForm, screenForwardForm:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|
@ -1991,6 +2183,54 @@ func (m *tuiModel) viewTagInput() string {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) viewGroups() string {
|
||||||
|
return renderScreenShell(screenShell{
|
||||||
|
breadcrumb: "Groups",
|
||||||
|
status: shellStatus(m.vaultUnlocked, fmt.Sprintf("%d groups", len(m.groups))),
|
||||||
|
notification: m.rootNotification(),
|
||||||
|
width: m.width,
|
||||||
|
height: m.height,
|
||||||
|
body: func(width, height int) string {
|
||||||
|
if len(m.groups) == 0 {
|
||||||
|
return renderPaddedPanel(width, height, []string{dashboardHelp("No groups yet. Ctrl+A creates one.")})
|
||||||
|
}
|
||||||
|
capacity := max(1, height-2)
|
||||||
|
start, end := visibleServerRange(len(m.groupList.Items()), m.groupList.Index(), capacity)
|
||||||
|
lines := make([]string, 0, capacity)
|
||||||
|
for index := start; index < end; index++ {
|
||||||
|
item, ok := m.groupList.Items()[index].(groupManagerItem)
|
||||||
|
if !ok || item.group == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
marker := " "
|
||||||
|
if index == m.groupList.Index() {
|
||||||
|
marker = "> "
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("%s%-28s %d server(s)", marker, item.group.Name, item.group.ServerCount))
|
||||||
|
}
|
||||||
|
return renderPaddedPanel(width, height, lines)
|
||||||
|
},
|
||||||
|
footer: []helpItem{{Key: "Ctrl+A", Action: "add"}, {Key: "Ctrl+E", Action: "rename"}, {Key: "Ctrl+D", Action: "delete"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "back"}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) viewGroupInput() string {
|
||||||
|
title := "Add Group"
|
||||||
|
if m.groupMode == "rename" {
|
||||||
|
title = "Rename Group"
|
||||||
|
}
|
||||||
|
return renderScreenShell(screenShell{
|
||||||
|
breadcrumb: title,
|
||||||
|
status: shellStatus(m.vaultUnlocked, "Group editor"),
|
||||||
|
width: m.width,
|
||||||
|
height: m.height,
|
||||||
|
body: func(width, height int) string {
|
||||||
|
return renderPaddedPanel(width, height, []string{dashboardSection(title), "", m.groupInput.View()})
|
||||||
|
},
|
||||||
|
footer: []helpItem{{Key: "Enter", Action: "save"}, {Key: "Ctrl+H", Action: "help"}, {Key: "Esc", Action: "cancel"}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (m *tuiModel) viewTemplates() string {
|
func (m *tuiModel) viewTemplates() string {
|
||||||
return renderScreenShell(screenShell{
|
return renderScreenShell(screenShell{
|
||||||
breadcrumb: "Command Templates",
|
breadcrumb: "Command Templates",
|
||||||
|
|
@ -2144,6 +2384,41 @@ func (m *tuiModel) reloadServersCmd() tea.Cmd {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) loadGroupsCmd() tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if ListGroups == nil {
|
||||||
|
return groupsLoadedMsg{err: fmt.Errorf("group storage is unavailable")}
|
||||||
|
}
|
||||||
|
groups, err := ListGroups()
|
||||||
|
return groupsLoadedMsg{groups: groups, err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) setGroups(groups []*model.Group) {
|
||||||
|
m.groups = groups
|
||||||
|
items := make([]list.Item, len(groups))
|
||||||
|
for i, group := range groups {
|
||||||
|
items[i] = groupManagerItem{group: group}
|
||||||
|
}
|
||||||
|
l := list.New(items, list.NewDefaultDelegate(), m.width, managerListHeight(m.height))
|
||||||
|
l.SetShowStatusBar(false)
|
||||||
|
l.SetFilteringEnabled(false)
|
||||||
|
l.SetShowHelp(false)
|
||||||
|
l.Title = "Groups"
|
||||||
|
l.Styles.Title = titleStyle
|
||||||
|
m.groupList = l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *tuiModel) removeGroup(name string) {
|
||||||
|
groups := make([]*model.Group, 0, len(m.groups))
|
||||||
|
for _, group := range m.groups {
|
||||||
|
if group != nil && group.Name != name {
|
||||||
|
groups = append(groups, group)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.setGroups(groups)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *tuiModel) loadTemplatesCmd() tea.Cmd {
|
func (m *tuiModel) loadTemplatesCmd() tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
if ListCommandTemplates == nil {
|
if ListCommandTemplates == nil {
|
||||||
|
|
@ -2230,7 +2505,8 @@ func (m *tuiModel) listHelpItems(selectedCount int, hasBackgroundResult bool) []
|
||||||
}
|
}
|
||||||
items = append(items,
|
items = append(items,
|
||||||
helpItem{Key: "Enter", Action: "connect"},
|
helpItem{Key: "Enter", Action: "connect"},
|
||||||
helpItem{Key: "Ctrl+X", Action: "actions"},
|
helpItem{Key: "Ctrl+X", Action: "server actions"},
|
||||||
|
helpItem{Key: "m", Action: "manage"},
|
||||||
helpItem{Key: "Ctrl+A", Action: "add"},
|
helpItem{Key: "Ctrl+A", Action: "add"},
|
||||||
helpItem{Key: "Ctrl+E", Action: "edit"},
|
helpItem{Key: "Ctrl+E", Action: "edit"},
|
||||||
helpItem{Key: "Ctrl+F", Action: "search"},
|
helpItem{Key: "Ctrl+F", Action: "search"},
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ func TestServerListHelpWrapsSelectionAndResultHints(t *testing.T) {
|
||||||
plainLines = append(plainLines, plainHelpLine(line))
|
plainLines = append(plainLines, plainHelpLine(line))
|
||||||
}
|
}
|
||||||
joined := strings.Join(plainLines, "\n")
|
joined := strings.Join(plainLines, "\n")
|
||||||
for _, want := range []string{"Ins: select (2 selected)", "Esc: clear result", "Ctrl+X: actions", "Ctrl+Q: quit"} {
|
for _, want := range []string{"Ins: select (2 selected)", "Esc: clear result", "Ctrl+X: server actions", "m: manage", "Ctrl+Q: quit"} {
|
||||||
if !strings.Contains(joined, want) {
|
if !strings.Contains(joined, want) {
|
||||||
t.Fatalf("expected wrapped help to contain %q\nlines:%#v", want, lines)
|
t.Fatalf("expected wrapped help to contain %q\nlines:%#v", want, lines)
|
||||||
}
|
}
|
||||||
|
|
@ -444,7 +444,7 @@ func TestFormViewUsesSectionsAndStableLabels(t *testing.T) {
|
||||||
"Alias",
|
"Alias",
|
||||||
"Display Name",
|
"Display Name",
|
||||||
"Auth Method",
|
"Auth Method",
|
||||||
"Password / Passphrase",
|
"Identity File",
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(view, want) {
|
if !strings.Contains(view, want) {
|
||||||
t.Fatalf("expected form view to contain %q\nview:\n%s", want, view)
|
t.Fatalf("expected form view to contain %q\nview:\n%s", want, view)
|
||||||
|
|
@ -452,6 +452,28 @@ func TestFormViewUsesSectionsAndStableLabels(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFormAuthFieldsAreContextual(t *testing.T) {
|
||||||
|
fm := newFormModel(100, 30)
|
||||||
|
|
||||||
|
fm.inputs[5].SetValue(string(model.AuthPassword))
|
||||||
|
view := fm.View()
|
||||||
|
if !strings.Contains(view, "Password") || strings.Contains(view, "Identity File") {
|
||||||
|
t.Fatalf("password auth fields are not contextual:\n%s", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
fm.inputs[5].SetValue(string(model.AuthKeyPassphrase))
|
||||||
|
view = fm.View()
|
||||||
|
if !strings.Contains(view, "Key passphrase") || !strings.Contains(view, "Identity File") {
|
||||||
|
t.Fatalf("key passphrase auth fields are not contextual:\n%s", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
fm.inputs[5].SetValue(string(model.AuthAgent))
|
||||||
|
view = fm.View()
|
||||||
|
if strings.Contains(view, "Identity File") || strings.Contains(view, "Password") || strings.Contains(view, "passphrase") {
|
||||||
|
t.Fatalf("agent auth still shows credential fields:\n%s", view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFormTestResultDoesNotUpdateSelectedListServer(t *testing.T) {
|
func TestFormTestResultDoesNotUpdateSelectedListServer(t *testing.T) {
|
||||||
oldUpdateTestResult := UpdateTestResult
|
oldUpdateTestResult := UpdateTestResult
|
||||||
oldListServers := ListServers
|
oldListServers := ListServers
|
||||||
|
|
@ -791,7 +813,12 @@ func TestActionMenuClosesOnAllActions(t *testing.T) {
|
||||||
// Test delete closes menu
|
// Test delete closes menu
|
||||||
m.actionMenu = newActionMenuModel(m.width, m.height)
|
m.actionMenu = newActionMenuModel(m.width, m.height)
|
||||||
m.screen = screenActionMenu
|
m.screen = screenActionMenu
|
||||||
m.actionMenu.list.Select(3) // Delete
|
for i := 0; i < len(m.actionMenu.list.Items()); i++ {
|
||||||
|
m.actionMenu.list.Select(i)
|
||||||
|
if item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "delete" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
DeleteServer = func(alias string) error { return nil }
|
DeleteServer = func(alias string) error { return nil }
|
||||||
ListServers = func() ([]*model.Server, error) { return []*model.Server{server}, nil }
|
ListServers = func() ([]*model.Server, error) { return []*model.Server{server}, nil }
|
||||||
updated, _ := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter})
|
updated, _ := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
|
@ -848,7 +875,7 @@ func TestActionMenuManageRouteOpensRouteField(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestActionMenuImportUsesCallbackAndRefreshesList(t *testing.T) {
|
func TestManageMenuImportUsesCallbackAndRefreshesList(t *testing.T) {
|
||||||
server := &model.Server{ID: 1, Alias: "web", Host: "web.example.org", Port: 22, User: "root", AuthMethod: model.AuthKey}
|
server := &model.Server{ID: 1, Alias: "web", Host: "web.example.org", Port: 22, User: "root", AuthMethod: model.AuthKey}
|
||||||
imported := false
|
imported := false
|
||||||
ImportServers = func() (int, error) {
|
ImportServers = func() (int, error) {
|
||||||
|
|
@ -866,16 +893,16 @@ func TestActionMenuImportUsesCallbackAndRefreshesList(t *testing.T) {
|
||||||
m := New([]*model.Server{})
|
m := New([]*model.Server{})
|
||||||
m.width = 100
|
m.width = 100
|
||||||
m.height = 30
|
m.height = 30
|
||||||
m.actionMenu = newActionMenuModel(m.width, m.height)
|
m.manageMenu = newManageMenuModel(m.width, m.height)
|
||||||
m.screen = screenActionMenu
|
m.screen = screenManageMenu
|
||||||
for i := 0; i < len(m.actionMenu.list.Items()); i++ {
|
for i := 0; i < len(m.manageMenu.list.Items()); i++ {
|
||||||
m.actionMenu.list.Select(i)
|
m.manageMenu.list.Select(i)
|
||||||
if item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "import" {
|
if item, ok := m.manageMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "import" {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updated, cmd := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter})
|
updated, cmd := m.updateManageMenu(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
m = updated.(*tuiModel)
|
m = updated.(*tuiModel)
|
||||||
if cmd == nil {
|
if cmd == nil {
|
||||||
t.Fatal("expected import command")
|
t.Fatal("expected import command")
|
||||||
|
|
@ -895,23 +922,23 @@ func TestActionMenuImportUsesCallbackAndRefreshesList(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestActionMenuExportAndVaultChangePasswordExitTUI(t *testing.T) {
|
func TestManageMenuExportAndVaultChangePasswordExitTUI(t *testing.T) {
|
||||||
server := &model.Server{ID: 1, Alias: "web", Host: "web.example.org", Port: 22, User: "root", AuthMethod: model.AuthKey}
|
server := &model.Server{ID: 1, Alias: "web", Host: "web.example.org", Port: 22, User: "root", AuthMethod: model.AuthKey}
|
||||||
for _, action := range []string{"export", "vault_change_pw"} {
|
for _, action := range []string{"export", "vault_change_pw"} {
|
||||||
t.Run(action, func(t *testing.T) {
|
t.Run(action, func(t *testing.T) {
|
||||||
m := New([]*model.Server{server})
|
m := New([]*model.Server{server})
|
||||||
m.width = 100
|
m.width = 100
|
||||||
m.height = 30
|
m.height = 30
|
||||||
m.actionMenu = newActionMenuModel(m.width, m.height)
|
m.manageMenu = newManageMenuModel(m.width, m.height)
|
||||||
m.screen = screenActionMenu
|
m.screen = screenManageMenu
|
||||||
for i := 0; i < len(m.actionMenu.list.Items()); i++ {
|
for i := 0; i < len(m.manageMenu.list.Items()); i++ {
|
||||||
m.actionMenu.list.Select(i)
|
m.manageMenu.list.Select(i)
|
||||||
if item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem); ok && item.action == action {
|
if item, ok := m.manageMenu.list.SelectedItem().(actionMenuItem); ok && item.action == action {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updated, cmd := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter})
|
updated, cmd := m.updateManageMenu(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
m = updated.(*tuiModel)
|
m = updated.(*tuiModel)
|
||||||
if cmd == nil {
|
if cmd == nil {
|
||||||
t.Fatalf("expected %s to quit TUI", action)
|
t.Fatalf("expected %s to quit TUI", action)
|
||||||
|
|
@ -923,7 +950,7 @@ func TestActionMenuExportAndVaultChangePasswordExitTUI(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestActionMenuVaultLockUsesCallback(t *testing.T) {
|
func TestManageMenuVaultLockUsesCallback(t *testing.T) {
|
||||||
server := &model.Server{ID: 1, Alias: "web", Host: "web.example.org", Port: 22, User: "root", AuthMethod: model.AuthKey}
|
server := &model.Server{ID: 1, Alias: "web", Host: "web.example.org", Port: 22, User: "root", AuthMethod: model.AuthKey}
|
||||||
locked := false
|
locked := false
|
||||||
LockVault = func() error {
|
LockVault = func() error {
|
||||||
|
|
@ -935,16 +962,16 @@ func TestActionMenuVaultLockUsesCallback(t *testing.T) {
|
||||||
m := New([]*model.Server{server})
|
m := New([]*model.Server{server})
|
||||||
m.width = 100
|
m.width = 100
|
||||||
m.height = 30
|
m.height = 30
|
||||||
m.actionMenu = newActionMenuModel(m.width, m.height)
|
m.manageMenu = newManageMenuModel(m.width, m.height)
|
||||||
m.screen = screenActionMenu
|
m.screen = screenManageMenu
|
||||||
for i := 0; i < len(m.actionMenu.list.Items()); i++ {
|
for i := 0; i < len(m.manageMenu.list.Items()); i++ {
|
||||||
m.actionMenu.list.Select(i)
|
m.manageMenu.list.Select(i)
|
||||||
if item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "vault_lock" {
|
if item, ok := m.manageMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "vault_lock" {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updated, _ := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter})
|
updated, _ := m.updateManageMenu(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
m = updated.(*tuiModel)
|
m = updated.(*tuiModel)
|
||||||
|
|
||||||
if !locked {
|
if !locked {
|
||||||
|
|
@ -954,3 +981,60 @@ func TestActionMenuVaultLockUsesCallback(t *testing.T) {
|
||||||
t.Fatalf("expected vault lock success, got %q", m.success)
|
t.Fatalf("expected vault lock success, got %q", m.success)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServerActionMenuContainsOnlyServerScopedActions(t *testing.T) {
|
||||||
|
menu := newActionMenuModel(100, 30)
|
||||||
|
for _, raw := range menu.list.Items() {
|
||||||
|
item := raw.(actionMenuItem)
|
||||||
|
switch item.action {
|
||||||
|
case "import", "export", "vault_lock", "vault_change_pw", "groups", "tags", "templates", "tunnels":
|
||||||
|
t.Fatalf("global action %q leaked into server actions", item.action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManageMenuOpensGroups(t *testing.T) {
|
||||||
|
oldList := ListGroups
|
||||||
|
t.Cleanup(func() { ListGroups = oldList })
|
||||||
|
ListGroups = func() ([]*model.Group, error) { return []*model.Group{{ID: 1, Name: "Prod", ServerCount: 3}}, nil }
|
||||||
|
m := New(nil)
|
||||||
|
m.width, m.height = 100, 30
|
||||||
|
m.manageMenu = newManageMenuModel(m.width, m.height)
|
||||||
|
m.screen = screenManageMenu
|
||||||
|
for i := range m.manageMenu.list.Items() {
|
||||||
|
m.manageMenu.list.Select(i)
|
||||||
|
if item := m.manageMenu.list.SelectedItem().(actionMenuItem); item.action == "groups" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updated, cmd := m.updateManageMenu(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if m.screen != screenGroups || cmd == nil {
|
||||||
|
t.Fatalf("manage groups did not open: screen=%v cmd=%v", m.screen, cmd)
|
||||||
|
}
|
||||||
|
updated, _ = m.Update(cmd())
|
||||||
|
m = updated.(*tuiModel)
|
||||||
|
if len(m.groups) != 1 || m.groups[0].ServerCount != 3 {
|
||||||
|
t.Fatalf("groups were not loaded: %#v", m.groups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartupTemplatePickerCopiesCommand(t *testing.T) {
|
||||||
|
oldList := ListCommandTemplates
|
||||||
|
t.Cleanup(func() { ListCommandTemplates = oldList })
|
||||||
|
ListCommandTemplates = func() ([]*model.CommandTemplate, error) {
|
||||||
|
return []*model.CommandTemplate{{ID: 1, Name: "Ops", Command: "tmux attach -t ops"}}, nil
|
||||||
|
}
|
||||||
|
fm := newFormModel(100, 30)
|
||||||
|
fm.focusIdx = 10
|
||||||
|
updated, _ := fm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}})
|
||||||
|
fm = updated.(*formModel)
|
||||||
|
if !fm.showStartupList {
|
||||||
|
t.Fatal("startup template picker did not open")
|
||||||
|
}
|
||||||
|
updated, _ = fm.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
|
fm = updated.(*formModel)
|
||||||
|
if got := fm.inputs[10].Value(); got != "tmux attach -t ops" {
|
||||||
|
t.Fatalf("startup command = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,35 +78,46 @@ func (fm *formModel) setRouteProfiles(servers []*model.Server) {
|
||||||
// --- Form model ---
|
// --- Form model ---
|
||||||
|
|
||||||
type formModel struct {
|
type formModel struct {
|
||||||
edit bool
|
edit bool
|
||||||
server *model.Server
|
server *model.Server
|
||||||
inputs []textinput.Model
|
inputs []textinput.Model
|
||||||
labels []string
|
labels []string
|
||||||
password textinput.Model
|
password textinput.Model
|
||||||
passwordLabel string
|
passwordLabel string
|
||||||
focusIdx int
|
focusIdx int
|
||||||
testResult string
|
testResult string
|
||||||
testOK bool
|
testOK bool
|
||||||
testResultTime time.Time
|
testResultTime time.Time
|
||||||
testing bool
|
testing bool
|
||||||
saving bool
|
saving bool
|
||||||
saved bool
|
saved bool
|
||||||
savedTime time.Time
|
savedTime time.Time
|
||||||
err error
|
err error
|
||||||
spinner spinner.Model
|
spinner spinner.Model
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
groups []string
|
groups []string
|
||||||
groupList list.Model
|
groupList list.Model
|
||||||
showGroupList bool
|
showGroupList bool
|
||||||
authList list.Model
|
authList list.Model
|
||||||
showAuthList bool
|
showAuthList bool
|
||||||
routeProfiles []*model.Server
|
identityFiles []string
|
||||||
routeList list.Model
|
identityList list.Model
|
||||||
showRouteList bool
|
showIdentityList bool
|
||||||
routePane int // 0=current route, 1=available profiles
|
tagValues []string
|
||||||
routeCursor int
|
tagList list.Model
|
||||||
initial formSnapshot
|
showTagList bool
|
||||||
|
startupTemplates []*model.CommandTemplate
|
||||||
|
startupList list.Model
|
||||||
|
showStartupList bool
|
||||||
|
hasSavedPassword bool
|
||||||
|
hasSavedPassphrase bool
|
||||||
|
routeProfiles []*model.Server
|
||||||
|
routeList list.Model
|
||||||
|
showRouteList bool
|
||||||
|
routePane int // 0=current route, 1=available profiles
|
||||||
|
routeCursor int
|
||||||
|
initial formSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
type formSnapshot struct {
|
type formSnapshot struct {
|
||||||
|
|
@ -136,6 +147,7 @@ func newFormModel(w, h int) *formModel {
|
||||||
inputs[i].CharLimit = 128
|
inputs[i].CharLimit = 128
|
||||||
}
|
}
|
||||||
inputs[3].SetValue("22")
|
inputs[3].SetValue("22")
|
||||||
|
inputs[5].SetValue(string(model.AuthKey))
|
||||||
|
|
||||||
pw := textinput.New()
|
pw := textinput.New()
|
||||||
pw.Placeholder = "optional"
|
pw.Placeholder = "optional"
|
||||||
|
|
@ -231,18 +243,8 @@ func newEditFormModel(s *model.Server, w, h int) *formModel {
|
||||||
fm.inputs[10].SetValue(s.StartupCommand)
|
fm.inputs[10].SetValue(s.StartupCommand)
|
||||||
fm.inputs[11].SetValue(strings.Join(s.Tags, ", "))
|
fm.inputs[11].SetValue(strings.Join(s.Tags, ", "))
|
||||||
if HasSecret != nil {
|
if HasSecret != nil {
|
||||||
switch s.AuthMethod {
|
fm.hasSavedPassword = HasSecret(s.Alias, "ssh_password")
|
||||||
case model.AuthPassword:
|
fm.hasSavedPassphrase = HasSecret(s.Alias, "key_passphrase")
|
||||||
if HasSecret(s.Alias, "ssh_password") {
|
|
||||||
fm.passwordLabel = "Password (secret saved; leave blank to keep)"
|
|
||||||
fm.password.Placeholder = ""
|
|
||||||
}
|
|
||||||
case model.AuthKeyPassphrase:
|
|
||||||
if HasSecret(s.Alias, "key_passphrase") {
|
|
||||||
fm.passwordLabel = "Key passphrase (secret saved; leave blank to keep)"
|
|
||||||
fm.password.Placeholder = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fm.updateFocus()
|
fm.updateFocus()
|
||||||
fm.initial = fm.snapshot()
|
fm.initial = fm.snapshot()
|
||||||
|
|
@ -270,6 +272,197 @@ func (fm *formModel) Dirty() bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) authMethodValue() model.AuthMethod {
|
||||||
|
method := model.AuthMethod(strings.TrimSpace(fm.inputs[5].Value()))
|
||||||
|
if method == "" {
|
||||||
|
return model.AuthKey
|
||||||
|
}
|
||||||
|
return method
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) usesIdentityFile() bool {
|
||||||
|
switch fm.authMethodValue() {
|
||||||
|
case model.AuthKey, model.AuthKeyPassphrase:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) usesSecretInput() bool {
|
||||||
|
switch fm.authMethodValue() {
|
||||||
|
case model.AuthPassword, model.AuthKeyPassphrase:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) currentPasswordLabel() string {
|
||||||
|
switch fm.authMethodValue() {
|
||||||
|
case model.AuthPassword:
|
||||||
|
if fm.edit && fm.hasSavedPassword {
|
||||||
|
return "Password (secret saved; leave blank to keep)"
|
||||||
|
}
|
||||||
|
return "Password"
|
||||||
|
case model.AuthKeyPassphrase:
|
||||||
|
if fm.edit && fm.hasSavedPassphrase {
|
||||||
|
return "Key passphrase (secret saved; leave blank to keep)"
|
||||||
|
}
|
||||||
|
return "Key passphrase"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) focusAllowed(index int) bool {
|
||||||
|
if index == 6 && !fm.usesIdentityFile() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if index == len(fm.inputs) && !fm.usesSecretInput() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return index >= 0 && index < len(fm.inputs)+3
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) advanceFocus(delta int) {
|
||||||
|
total := len(fm.inputs) + 3
|
||||||
|
for tries := 0; tries < total; tries++ {
|
||||||
|
fm.focusIdx = (fm.focusIdx + delta + total) % total
|
||||||
|
if fm.focusAllowed(fm.focusIdx) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fm.updateFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) loadIdentityPicker() {
|
||||||
|
fm.identityFiles = nil
|
||||||
|
if ListIdentityFiles != nil {
|
||||||
|
if files, err := ListIdentityFiles(); err == nil {
|
||||||
|
fm.identityFiles = files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fm.identityList = newStringList(fm.identityFiles, "Select SSH private key", 52, 14)
|
||||||
|
fm.identityList.SetFilteringEnabled(true)
|
||||||
|
fm.showIdentityList = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) loadTagPicker() {
|
||||||
|
fm.tagValues = nil
|
||||||
|
if ListTags != nil {
|
||||||
|
if tags, err := ListTags(); err == nil {
|
||||||
|
fm.tagValues = tags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fm.tagList = newStringList(fm.tagValues, "Select tags", 40, 14)
|
||||||
|
fm.tagList.SetFilteringEnabled(true)
|
||||||
|
fm.showTagList = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) loadStartupPicker() {
|
||||||
|
fm.startupTemplates = nil
|
||||||
|
if ListCommandTemplates != nil {
|
||||||
|
if templates, err := ListCommandTemplates(); err == nil {
|
||||||
|
fm.startupTemplates = templates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items := make([]list.Item, 0, len(fm.startupTemplates))
|
||||||
|
for _, template := range fm.startupTemplates {
|
||||||
|
if template != nil {
|
||||||
|
items = append(items, templateItem{template: template})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
l := list.New(items, list.NewDefaultDelegate(), 64, 14)
|
||||||
|
l.Title = "Insert command template"
|
||||||
|
l.SetShowStatusBar(false)
|
||||||
|
l.SetShowHelp(false)
|
||||||
|
l.SetFilteringEnabled(true)
|
||||||
|
l.Styles.Title = titleStyle
|
||||||
|
fm.startupList = l
|
||||||
|
fm.showStartupList = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) updateStartupPicker(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
switch key.Type {
|
||||||
|
case tea.KeyEsc:
|
||||||
|
fm.showStartupList = false
|
||||||
|
return fm, nil
|
||||||
|
case tea.KeyEnter:
|
||||||
|
if item, ok := fm.startupList.SelectedItem().(templateItem); ok && item.template != nil {
|
||||||
|
fm.inputs[10].SetValue(item.template.Command)
|
||||||
|
}
|
||||||
|
fm.showStartupList = false
|
||||||
|
return fm, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
fm.startupList, cmd = fm.startupList.Update(msg)
|
||||||
|
return fm, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsString(values []string, value string) bool {
|
||||||
|
for _, item := range values {
|
||||||
|
if item == value {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleTagValue(values []string, value string) []string {
|
||||||
|
if containsString(values, value) {
|
||||||
|
result := make([]string, 0, len(values)-1)
|
||||||
|
for _, item := range values {
|
||||||
|
if item != value {
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
return append(values, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) updateIdentityPicker(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
switch key.Type {
|
||||||
|
case tea.KeyEsc:
|
||||||
|
fm.showIdentityList = false
|
||||||
|
return fm, nil
|
||||||
|
case tea.KeyEnter:
|
||||||
|
if item, ok := fm.identityList.SelectedItem().(groupItem); ok {
|
||||||
|
fm.inputs[6].SetValue(item.name)
|
||||||
|
}
|
||||||
|
fm.showIdentityList = false
|
||||||
|
return fm, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
fm.identityList, cmd = fm.identityList.Update(msg)
|
||||||
|
return fm, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) updateTagPicker(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
if key, ok := msg.(tea.KeyMsg); ok {
|
||||||
|
switch key.Type {
|
||||||
|
case tea.KeyEsc:
|
||||||
|
fm.showTagList = false
|
||||||
|
return fm, nil
|
||||||
|
case tea.KeySpace, tea.KeyEnter:
|
||||||
|
if item, ok := fm.tagList.SelectedItem().(groupItem); ok {
|
||||||
|
tags := splitCSV(fm.inputs[11].Value())
|
||||||
|
tags = toggleTagValue(tags, item.name)
|
||||||
|
fm.inputs[11].SetValue(strings.Join(tags, ", "))
|
||||||
|
}
|
||||||
|
return fm, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var cmd tea.Cmd
|
||||||
|
fm.tagList, cmd = fm.tagList.Update(msg)
|
||||||
|
return fm, cmd
|
||||||
|
}
|
||||||
|
|
||||||
func (fm *formModel) resolveRouteAlias(alias string) (int64, bool) {
|
func (fm *formModel) resolveRouteAlias(alias string) (int64, bool) {
|
||||||
alias = strings.TrimSpace(alias)
|
alias = strings.TrimSpace(alias)
|
||||||
for _, server := range fm.routeProfiles {
|
for _, server := range fm.routeProfiles {
|
||||||
|
|
@ -440,6 +633,15 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return fm, cmd
|
return fm, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if fm.showIdentityList {
|
||||||
|
return fm.updateIdentityPicker(msg)
|
||||||
|
}
|
||||||
|
if fm.showTagList {
|
||||||
|
return fm.updateTagPicker(msg)
|
||||||
|
}
|
||||||
|
if fm.showStartupList {
|
||||||
|
return fm.updateStartupPicker(msg)
|
||||||
|
}
|
||||||
if fm.showRouteList {
|
if fm.showRouteList {
|
||||||
return fm.updateRouteEditor(msg)
|
return fm.updateRouteEditor(msg)
|
||||||
}
|
}
|
||||||
|
|
@ -476,6 +678,7 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
fm.inputs[5].SetValue(item.name)
|
fm.inputs[5].SetValue(item.name)
|
||||||
}
|
}
|
||||||
fm.showAuthList = false
|
fm.showAuthList = false
|
||||||
|
fm.updateFocus()
|
||||||
return fm, nil
|
return fm, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -488,21 +691,11 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
switch msg.Type {
|
switch msg.Type {
|
||||||
case tea.KeyTab:
|
case tea.KeyTab:
|
||||||
fm.focusIdx++
|
fm.advanceFocus(1)
|
||||||
total := len(fm.inputs) + 3
|
|
||||||
if fm.focusIdx >= total {
|
|
||||||
fm.focusIdx = 0
|
|
||||||
}
|
|
||||||
fm.updateFocus()
|
|
||||||
return fm, nil
|
return fm, nil
|
||||||
|
|
||||||
case tea.KeyShiftTab:
|
case tea.KeyShiftTab:
|
||||||
fm.focusIdx--
|
fm.advanceFocus(-1)
|
||||||
if fm.focusIdx < 0 {
|
|
||||||
total := len(fm.inputs) + 3
|
|
||||||
fm.focusIdx = total - 1
|
|
||||||
}
|
|
||||||
fm.updateFocus()
|
|
||||||
return fm, nil
|
return fm, nil
|
||||||
|
|
||||||
case tea.KeyRunes:
|
case tea.KeyRunes:
|
||||||
|
|
@ -510,6 +703,10 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
fm.showAuthList = true
|
fm.showAuthList = true
|
||||||
return fm, nil
|
return fm, nil
|
||||||
}
|
}
|
||||||
|
if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 6 && fm.usesIdentityFile() {
|
||||||
|
fm.loadIdentityPicker()
|
||||||
|
return fm, nil
|
||||||
|
}
|
||||||
if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 7 {
|
if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 7 {
|
||||||
fm.showRouteList = true
|
fm.showRouteList = true
|
||||||
fm.routePane = 1
|
fm.routePane = 1
|
||||||
|
|
@ -523,6 +720,14 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
fm.showGroupList = true
|
fm.showGroupList = true
|
||||||
return fm, nil
|
return fm, nil
|
||||||
}
|
}
|
||||||
|
if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 10 {
|
||||||
|
fm.loadStartupPicker()
|
||||||
|
return fm, nil
|
||||||
|
}
|
||||||
|
if len(msg.Runes) == 1 && msg.Runes[0] == '/' && !msg.Alt && fm.focusIdx == 11 {
|
||||||
|
fm.loadTagPicker()
|
||||||
|
return fm, nil
|
||||||
|
}
|
||||||
|
|
||||||
case tea.KeyEnter:
|
case tea.KeyEnter:
|
||||||
switch {
|
switch {
|
||||||
|
|
@ -531,12 +736,7 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
case fm.focusIdx == len(fm.inputs)+2:
|
case fm.focusIdx == len(fm.inputs)+2:
|
||||||
return fm, fm.runSave()
|
return fm, fm.runSave()
|
||||||
default:
|
default:
|
||||||
fm.focusIdx++
|
fm.advanceFocus(1)
|
||||||
total := len(fm.inputs) + 3
|
|
||||||
if fm.focusIdx >= total {
|
|
||||||
fm.focusIdx = 0
|
|
||||||
}
|
|
||||||
fm.updateFocus()
|
|
||||||
return fm, nil
|
return fm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -544,21 +744,11 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
return fm, nil
|
return fm, nil
|
||||||
|
|
||||||
case tea.KeyDown:
|
case tea.KeyDown:
|
||||||
fm.focusIdx++
|
fm.advanceFocus(1)
|
||||||
total := len(fm.inputs) + 3
|
|
||||||
if fm.focusIdx >= total {
|
|
||||||
fm.focusIdx = 0
|
|
||||||
}
|
|
||||||
fm.updateFocus()
|
|
||||||
return fm, nil
|
return fm, nil
|
||||||
|
|
||||||
case tea.KeyUp:
|
case tea.KeyUp:
|
||||||
fm.focusIdx--
|
fm.advanceFocus(-1)
|
||||||
if fm.focusIdx < 0 {
|
|
||||||
total := len(fm.inputs) + 3
|
|
||||||
fm.focusIdx = total - 1
|
|
||||||
}
|
|
||||||
fm.updateFocus()
|
|
||||||
return fm, nil
|
return fm, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -599,7 +789,24 @@ func (fm *formModel) applySaveError(err error) {
|
||||||
fm.updateFocus()
|
fm.updateFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) updateCredentialPresentation() {
|
||||||
|
fm.passwordLabel = fm.currentPasswordLabel()
|
||||||
|
if !fm.usesSecretInput() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fm.password.Placeholder = "optional"
|
||||||
|
if (fm.authMethodValue() == model.AuthPassword && fm.hasSavedPassword) || (fm.authMethodValue() == model.AuthKeyPassphrase && fm.hasSavedPassphrase) {
|
||||||
|
fm.password.Placeholder = ""
|
||||||
|
}
|
||||||
|
prompt := blurredStyle.Render(fm.passwordLabel + ": ")
|
||||||
|
if fm.focusIdx == len(fm.inputs) {
|
||||||
|
prompt = focusedStyle.Render(fm.passwordLabel + "> ")
|
||||||
|
}
|
||||||
|
fm.password.Prompt = prompt
|
||||||
|
}
|
||||||
|
|
||||||
func (fm *formModel) updateFocus() {
|
func (fm *formModel) updateFocus() {
|
||||||
|
fm.updateCredentialPresentation()
|
||||||
for i := range fm.inputs {
|
for i := range fm.inputs {
|
||||||
fm.inputs[i].Blur()
|
fm.inputs[i].Blur()
|
||||||
fm.inputs[i].Prompt = blurredStyle.Render(fm.labelAt(i) + ": ")
|
fm.inputs[i].Prompt = blurredStyle.Render(fm.labelAt(i) + ": ")
|
||||||
|
|
@ -612,8 +819,8 @@ func (fm *formModel) updateFocus() {
|
||||||
fm.inputs[fm.focusIdx].Prompt = focusedStyle.Render(fm.labelAt(fm.focusIdx) + "> ")
|
fm.inputs[fm.focusIdx].Prompt = focusedStyle.Render(fm.labelAt(fm.focusIdx) + "> ")
|
||||||
} else if fm.focusIdx == len(fm.inputs) {
|
} else if fm.focusIdx == len(fm.inputs) {
|
||||||
fm.password.Focus()
|
fm.password.Focus()
|
||||||
fm.password.Prompt = focusedStyle.Render(fm.passwordLabel + "> ")
|
|
||||||
}
|
}
|
||||||
|
fm.updateCredentialPresentation()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fm *formModel) labelAt(index int) string {
|
func (fm *formModel) labelAt(index int) string {
|
||||||
|
|
@ -629,15 +836,24 @@ func (fm *formModel) labelAt(index int) string {
|
||||||
if index == 5 {
|
if index == 5 {
|
||||||
return "Auth Method (/ pick)"
|
return "Auth Method (/ pick)"
|
||||||
}
|
}
|
||||||
|
if index == 6 {
|
||||||
|
return "Identity File (/ pick)"
|
||||||
|
}
|
||||||
if index == 7 {
|
if index == 7 {
|
||||||
return "Route (/ edit)"
|
return "Route (/ edit)"
|
||||||
}
|
}
|
||||||
|
if index == 10 {
|
||||||
|
return "Startup Command (/ template)"
|
||||||
|
}
|
||||||
if index == 8 {
|
if index == 8 {
|
||||||
if len(fm.groups) > 0 {
|
if len(fm.groups) > 0 {
|
||||||
return "Group (/ pick)"
|
return "Group (/ pick)"
|
||||||
}
|
}
|
||||||
return "Group"
|
return "Group"
|
||||||
}
|
}
|
||||||
|
if index == 11 {
|
||||||
|
return "Tags (/ pick, type to create)"
|
||||||
|
}
|
||||||
return fm.labels[index]
|
return fm.labels[index]
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -773,10 +989,53 @@ func parsePort(value string) (int, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (fm *formModel) View() string {
|
func (fm *formModel) View() string {
|
||||||
|
fm.updateCredentialPresentation()
|
||||||
title := "Add Server"
|
title := "Add Server"
|
||||||
if fm.edit {
|
if fm.edit {
|
||||||
title = "Edit Server: " + fm.server.Alias
|
title = "Edit Server: " + fm.server.Alias
|
||||||
}
|
}
|
||||||
|
if fm.showIdentityList {
|
||||||
|
return renderScreenShell(screenShell{
|
||||||
|
breadcrumb: title + " / Identity File", status: "Choose a private key", width: fm.width, height: fm.height,
|
||||||
|
body: func(width, height int) string {
|
||||||
|
return renderPaddedPanel(width, height, splitBlock(renderDropdown(fm.identityList)))
|
||||||
|
},
|
||||||
|
footer: []helpItem{{Key: "/", Action: "filter"}, {Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "select"}, {Key: "Esc", Action: "cancel"}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if fm.showStartupList {
|
||||||
|
return renderScreenShell(screenShell{
|
||||||
|
breadcrumb: title + " / Startup Command",
|
||||||
|
status: "Choose a command template",
|
||||||
|
width: fm.width,
|
||||||
|
height: fm.height,
|
||||||
|
body: func(width, height int) string {
|
||||||
|
lines := []string{fm.inputs[10].View(), ""}
|
||||||
|
if len(fm.startupList.Items()) == 0 {
|
||||||
|
lines = append(lines, dashboardHelp("No command templates yet. Use Manage → Command templates to create one."))
|
||||||
|
} else {
|
||||||
|
capacity := max(1, height-4)
|
||||||
|
start, end := visibleServerRange(len(fm.startupList.Items()), fm.startupList.Index(), capacity)
|
||||||
|
for index := start; index < end; index++ {
|
||||||
|
item, ok := fm.startupList.Items()[index].(templateItem)
|
||||||
|
if !ok || item.template == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
marker := " "
|
||||||
|
if index == fm.startupList.Index() {
|
||||||
|
marker = "> "
|
||||||
|
}
|
||||||
|
lines = append(lines, marker+item.template.Name+" "+item.template.Command)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return renderPaddedPanel(width, height, lines)
|
||||||
|
},
|
||||||
|
footer: []helpItem{{Key: "↑/↓", Action: "move"}, {Key: "Enter", Action: "insert copy"}, {Key: "Esc", Action: "cancel"}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if fm.showTagList {
|
||||||
|
return fm.tagPickerView(title)
|
||||||
|
}
|
||||||
if fm.showRouteList {
|
if fm.showRouteList {
|
||||||
return fm.routeEditorView(title)
|
return fm.routeEditorView(title)
|
||||||
}
|
}
|
||||||
|
|
@ -818,6 +1077,9 @@ func (fm *formModel) View() string {
|
||||||
allFields := make([]string, 0, len(fm.inputs)+5)
|
allFields := make([]string, 0, len(fm.inputs)+5)
|
||||||
focusRows := make([]int, len(fm.inputs)+1)
|
focusRows := make([]int, len(fm.inputs)+1)
|
||||||
for i := range fm.inputs {
|
for i := range fm.inputs {
|
||||||
|
if i == 6 && !fm.usesIdentityFile() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if richLayout {
|
if richLayout {
|
||||||
if section := formSectionTitle(i); section != "" {
|
if section := formSectionTitle(i); section != "" {
|
||||||
allFields = append(allFields, sectionStyle.Copy().MarginTop(0).Render(section))
|
allFields = append(allFields, sectionStyle.Copy().MarginTop(0).Render(section))
|
||||||
|
|
@ -832,10 +1094,12 @@ func (fm *formModel) View() string {
|
||||||
focusRows[i] = len(allFields)
|
focusRows[i] = len(allFields)
|
||||||
allFields = append(allFields, fm.inputs[i].View())
|
allFields = append(allFields, fm.inputs[i].View())
|
||||||
}
|
}
|
||||||
focusRows[len(fm.inputs)] = len(allFields)
|
if fm.usesSecretInput() {
|
||||||
allFields = append(allFields, fm.password.View())
|
focusRows[len(fm.inputs)] = len(allFields)
|
||||||
focusField := len(allFields) - 1
|
allFields = append(allFields, fm.password.View())
|
||||||
if fm.focusIdx <= len(fm.inputs) {
|
}
|
||||||
|
focusField := max(0, len(allFields)-1)
|
||||||
|
if fm.focusIdx <= len(fm.inputs) && fm.focusAllowed(fm.focusIdx) {
|
||||||
focusField = focusRows[fm.focusIdx]
|
focusField = focusRows[fm.focusIdx]
|
||||||
}
|
}
|
||||||
actionRows := 1
|
actionRows := 1
|
||||||
|
|
@ -875,6 +1139,43 @@ func (fm *formModel) View() string {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (fm *formModel) tagPickerView(title string) string {
|
||||||
|
selected := splitCSV(fm.inputs[11].Value())
|
||||||
|
body := func(width, height int) string {
|
||||||
|
lines := []string{dashboardSection("Existing tags")}
|
||||||
|
if len(fm.tagList.Items()) == 0 {
|
||||||
|
lines = append(lines, dashboardHelp("No saved tags yet. Esc and type a new tag in the field."))
|
||||||
|
} else {
|
||||||
|
capacity := max(1, height-4)
|
||||||
|
start, end := visibleServerRange(len(fm.tagList.Items()), fm.tagList.Index(), capacity)
|
||||||
|
for index := start; index < end; index++ {
|
||||||
|
item, ok := fm.tagList.Items()[index].(groupItem)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mark := "[ ]"
|
||||||
|
if containsString(selected, item.name) {
|
||||||
|
mark = "[x]"
|
||||||
|
}
|
||||||
|
line := " " + mark + " " + item.name
|
||||||
|
if index == fm.tagList.Index() {
|
||||||
|
line = selectedRowStyle.Render("> " + mark + " " + item.name)
|
||||||
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return renderPaddedPanel(width, height, lines)
|
||||||
|
}
|
||||||
|
return renderScreenShell(screenShell{
|
||||||
|
breadcrumb: title + " / Tags",
|
||||||
|
status: fmt.Sprintf("%d selected", len(selected)),
|
||||||
|
width: fm.width,
|
||||||
|
height: fm.height,
|
||||||
|
body: body,
|
||||||
|
footer: []helpItem{{Key: "/", Action: "filter"}, {Key: "↑/↓", Action: "move"}, {Key: "Space/Enter", Action: "toggle"}, {Key: "Esc", Action: "done"}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (fm *formModel) routeEditorView(title string) string {
|
func (fm *formModel) routeEditorView(title string) string {
|
||||||
route := fm.currentRoute()
|
route := fm.currentRoute()
|
||||||
body := func(width, height int) string {
|
body := func(width, height int) string {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ func newHelpScreenModel(w, h int) *helpScreenModel {
|
||||||
helpScreenItem{key: "Ctrl+A", action: "Add server", section: "Server list"},
|
helpScreenItem{key: "Ctrl+A", action: "Add server", section: "Server list"},
|
||||||
helpScreenItem{key: "Ctrl+E", action: "Edit server", section: "Server list"},
|
helpScreenItem{key: "Ctrl+E", action: "Edit server", section: "Server list"},
|
||||||
helpScreenItem{key: "Ctrl+F", action: "Search", section: "Server list"},
|
helpScreenItem{key: "Ctrl+F", action: "Search", section: "Server list"},
|
||||||
helpScreenItem{key: "Ctrl+X", action: "Action menu", section: "Server list"},
|
helpScreenItem{key: "Ctrl+X", action: "Server actions", section: "Server list"},
|
||||||
|
helpScreenItem{key: "m", action: "Manage groups / tags / templates / tunnels / vault", section: "Server list"},
|
||||||
helpScreenItem{key: "Ins", action: "Select / deselect", section: "Server list"},
|
helpScreenItem{key: "Ins", action: "Select / deselect", section: "Server list"},
|
||||||
helpScreenItem{key: "Ctrl+W", action: "Manage port forwards", section: "Forwards"},
|
helpScreenItem{key: "Ctrl+W", action: "Manage port forwards", section: "Forwards"},
|
||||||
helpScreenItem{key: "?", action: "This quick help", section: "Other"},
|
helpScreenItem{key: "?", action: "This quick help", section: "Other"},
|
||||||
|
|
@ -207,21 +208,29 @@ func (m *fullHelpModel) View() string {
|
||||||
{"Ctrl+A", "Add server"},
|
{"Ctrl+A", "Add server"},
|
||||||
{"Ctrl+E", "Edit server"},
|
{"Ctrl+E", "Edit server"},
|
||||||
{"Ctrl+F", "Search"},
|
{"Ctrl+F", "Search"},
|
||||||
{"Ctrl+X", "Action menu"},
|
{"Ctrl+X", "Server actions"},
|
||||||
|
{"m", "Manage global entities"},
|
||||||
{"Ins", "Select / deselect"},
|
{"Ins", "Select / deselect"},
|
||||||
}},
|
}},
|
||||||
{"Action menu (Ctrl+X)", [][2]string{
|
{"Server actions (Ctrl+X)", [][2]string{
|
||||||
{"Connect", "Standard SSH session"},
|
{"Connect", "Standard SSH session"},
|
||||||
{"Connect with tunnels", "SSH + all enabled forwards"},
|
{"Connect with tunnels", "SSH + all enabled forwards"},
|
||||||
{"Start tunnels only", "Forwards without shell"},
|
{"Start tunnels only", "Forwards without shell"},
|
||||||
{"Start tunnels in bg", "Background tunnel process"},
|
{"Start tunnels in bg", "Background tunnel process"},
|
||||||
{"Manage port forwards", "Add / edit / delete forwards"},
|
{"Port forwards", "Add / edit / enable / delete forwards"},
|
||||||
{"Manage tunnels", "View and stop running tunnels"},
|
{"Route", "Configure ordered bastions"},
|
||||||
{"Manage route", "Configure ProxyJump / bastions"},
|
|
||||||
{"Test connection", "Check if server is reachable"},
|
{"Test connection", "Check if server is reachable"},
|
||||||
{"Edit", "Edit server profile"},
|
{"Edit", "Edit server profile"},
|
||||||
{"Delete", "Remove server profile"},
|
{"Delete", "Remove server profile"},
|
||||||
}},
|
}},
|
||||||
|
{"Manage (m)", [][2]string{
|
||||||
|
{"Groups", "Create / rename / remove groups"},
|
||||||
|
{"Tags", "Manage and apply tags"},
|
||||||
|
{"Command templates", "Manage reusable commands"},
|
||||||
|
{"Running tunnels", "View and stop tracked tunnels"},
|
||||||
|
{"Import / Export", "Move server profile data"},
|
||||||
|
{"Vault", "Lock or change master password"},
|
||||||
|
}},
|
||||||
{"Routes / ProxyJump", [][2]string{
|
{"Routes / ProxyJump", [][2]string{
|
||||||
{"", "Routes define how to reach a server through jump hosts."},
|
{"", "Routes define how to reach a server through jump hosts."},
|
||||||
{"● direct", "No jump host"},
|
{"● direct", "No jump host"},
|
||||||
|
|
@ -296,6 +305,7 @@ func (i actionMenuItem) FilterValue() string { return i.label }
|
||||||
|
|
||||||
type actionMenuModel struct {
|
type actionMenuModel struct {
|
||||||
list list.Model
|
list list.Model
|
||||||
|
title string
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
}
|
}
|
||||||
|
|
@ -306,26 +316,37 @@ func newActionMenuModel(w, h int) *actionMenuModel {
|
||||||
actionMenuItem{label: "Connect with tunnels", action: "tunnel", description: "Open SSH and activate enabled port forwards."},
|
actionMenuItem{label: "Connect with tunnels", action: "tunnel", description: "Open SSH and activate enabled port forwards."},
|
||||||
actionMenuItem{label: "Start tunnels only", action: "tunnel_n", description: "Activate enabled forwards without a shell."},
|
actionMenuItem{label: "Start tunnels only", action: "tunnel_n", description: "Activate enabled forwards without a shell."},
|
||||||
actionMenuItem{label: "Start tunnels in background", action: "tunnel_bg", description: "Run enabled forwards as a background process."},
|
actionMenuItem{label: "Start tunnels in background", action: "tunnel_bg", description: "Run enabled forwards as a background process."},
|
||||||
actionMenuItem{label: "Manage port forwards", action: "forwards", description: "Add, edit, enable, or remove forwarding rules."},
|
actionMenuItem{label: "Port forwards", action: "forwards", description: "Add, edit, enable, or remove forwarding rules for this server."},
|
||||||
actionMenuItem{label: "Manage tunnels", action: "tunnels", description: "Inspect and stop running tunnel processes."},
|
actionMenuItem{label: "Route", action: "route", description: "Configure direct or bastion routing for this server."},
|
||||||
actionMenuItem{label: "Manage route", action: "route", description: "Configure direct or ProxyJump routing."},
|
|
||||||
actionMenuItem{label: "Test connection", action: "test", description: "Check SSH reachability for this profile."},
|
actionMenuItem{label: "Test connection", action: "test", description: "Check SSH reachability for this profile."},
|
||||||
actionMenuItem{label: "Edit", action: "edit", description: "Change this server profile."},
|
actionMenuItem{label: "Edit", action: "edit", description: "Change this server profile."},
|
||||||
actionMenuItem{label: "Delete", action: "delete", description: "Permanently remove this server profile."},
|
actionMenuItem{label: "Delete", action: "delete", description: "Permanently remove this server profile."},
|
||||||
actionMenuItem{label: "Import", action: "import", description: "Import profiles from a supported source."},
|
}
|
||||||
actionMenuItem{label: "Export", action: "export", description: "Export selected server profiles."},
|
return newMenuModel("Server Actions", items, w, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newManageMenuModel(w, h int) *actionMenuModel {
|
||||||
|
items := []list.Item{
|
||||||
|
actionMenuItem{label: "Groups", action: "groups", description: "Create, rename, and remove server groups."},
|
||||||
|
actionMenuItem{label: "Tags", action: "tags", description: "Manage tags and apply them to selected servers."},
|
||||||
|
actionMenuItem{label: "Command templates", action: "templates", description: "Manage reusable commands."},
|
||||||
|
actionMenuItem{label: "Running tunnels", action: "tunnels", description: "Inspect and stop tracked background tunnels."},
|
||||||
|
actionMenuItem{label: "Import SSH config", action: "import", description: "Import profiles from ~/.ssh/config."},
|
||||||
|
actionMenuItem{label: "Export", action: "export", description: "Export server profiles."},
|
||||||
actionMenuItem{label: "Vault: lock", action: "vault_lock", description: "Lock secrets for the current session."},
|
actionMenuItem{label: "Vault: lock", action: "vault_lock", description: "Lock secrets for the current session."},
|
||||||
actionMenuItem{label: "Vault: change password", action: "vault_change_pw", description: "Change the password protecting stored secrets."},
|
actionMenuItem{label: "Vault: change password", action: "vault_change_pw", description: "Change the password protecting stored secrets."},
|
||||||
}
|
}
|
||||||
|
return newMenuModel("Manage", items, w, h)
|
||||||
|
}
|
||||||
|
|
||||||
l := list.New(items, list.NewDefaultDelegate(), 30, len(items)+2)
|
func newMenuModel(title string, items []list.Item, w, h int) *actionMenuModel {
|
||||||
l.Title = "Actions"
|
l := list.New(items, list.NewDefaultDelegate(), 34, len(items)+2)
|
||||||
|
l.Title = title
|
||||||
l.SetShowStatusBar(false)
|
l.SetShowStatusBar(false)
|
||||||
l.SetFilteringEnabled(false)
|
l.SetFilteringEnabled(false)
|
||||||
l.SetShowHelp(false)
|
l.SetShowHelp(false)
|
||||||
l.Styles.Title = titleStyle
|
l.Styles.Title = titleStyle
|
||||||
|
return &actionMenuModel{list: l, title: title, width: w, height: h}
|
||||||
return &actionMenuModel{list: l, width: w, height: h}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
|
func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
|
||||||
|
|
@ -368,7 +389,7 @@ func (m *actionMenuModel) View() string {
|
||||||
return renderPaddedPanel(width, height, listLines)
|
return renderPaddedPanel(width, height, listLines)
|
||||||
}
|
}
|
||||||
return renderScreenShell(screenShell{
|
return renderScreenShell(screenShell{
|
||||||
breadcrumb: "Actions",
|
breadcrumb: m.title,
|
||||||
status: fmt.Sprintf("%d actions", len(m.list.Items())),
|
status: fmt.Sprintf("%d actions", len(m.list.Items())),
|
||||||
width: m.width,
|
width: m.width,
|
||||||
height: m.height,
|
height: m.height,
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@ func TestActionMenuFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
view := menu.View()
|
view := menu.View()
|
||||||
assertViewFits(t, view, size.width, size.height)
|
assertViewFits(t, view, size.width, size.height)
|
||||||
assertUnifiedScreen(t, view, size.width, size.height)
|
assertUnifiedScreen(t, view, size.width, size.height)
|
||||||
for _, want := range []string{"Actions", "Connect", "Manage port forwards", "Esc"} {
|
for _, want := range []string{"Server Actions", "Connect", "Port forwards", "Esc"} {
|
||||||
if !strings.Contains(view, want) {
|
if !strings.Contains(view, want) {
|
||||||
t.Fatalf("action menu at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
t.Fatalf("action menu at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
||||||
}
|
}
|
||||||
|
|
@ -148,6 +148,20 @@ func TestActionMenuFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestManageMenuFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
|
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||||
|
menu := newManageMenuModel(size.width, size.height)
|
||||||
|
view := menu.View()
|
||||||
|
assertViewFits(t, view, size.width, size.height)
|
||||||
|
assertUnifiedScreen(t, view, size.width, size.height)
|
||||||
|
for _, want := range []string{"Manage", "Groups", "Command templates", "Vault", "Esc"} {
|
||||||
|
if !strings.Contains(view, want) {
|
||||||
|
t.Fatalf("manage menu at %dx%d missing %q:\n%s", size.width, size.height, want, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConfirmationFitsSupportedTerminalSizes(t *testing.T) {
|
func TestConfirmationFitsSupportedTerminalSizes(t *testing.T) {
|
||||||
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
|
||||||
m := New(nil)
|
m := New(nil)
|
||||||
|
|
@ -214,6 +228,7 @@ func TestManagerScreensUseUnifiedShell(t *testing.T) {
|
||||||
template := &model.CommandTemplate{Name: "Disk usage", Command: "df -h", Description: "Show mounted filesystems"}
|
template := &model.CommandTemplate{Name: "Disk usage", Command: "df -h", Description: "Show mounted filesystems"}
|
||||||
m.setTemplates([]*model.CommandTemplate{template})
|
m.setTemplates([]*model.CommandTemplate{template})
|
||||||
m.setTags([]string{"production"})
|
m.setTags([]string{"production"})
|
||||||
|
m.setGroups([]*model.Group{{ID: 1, Name: "Production", ServerCount: 1}})
|
||||||
m.pendingTemplate = template
|
m.pendingTemplate = template
|
||||||
m.bgResults = []templateRunResult{{Alias: "prod", Output: "ok\n数据库 ready"}}
|
m.bgResults = []templateRunResult{{Alias: "prod", Output: "ok\n数据库 ready"}}
|
||||||
|
|
||||||
|
|
@ -224,6 +239,8 @@ func TestManagerScreensUseUnifiedShell(t *testing.T) {
|
||||||
{"search", screenSearch},
|
{"search", screenSearch},
|
||||||
{"tags", screenTags},
|
{"tags", screenTags},
|
||||||
{"tag-input", screenTagInput},
|
{"tag-input", screenTagInput},
|
||||||
|
{"groups", screenGroups},
|
||||||
|
{"group-input", screenGroupInput},
|
||||||
{"templates", screenTemplates},
|
{"templates", screenTemplates},
|
||||||
{"template-picker", screenTemplatePicker},
|
{"template-picker", screenTemplatePicker},
|
||||||
{"template-mode", screenTemplateMode},
|
{"template-mode", screenTemplateMode},
|
||||||
|
|
@ -248,6 +265,8 @@ func TestLayoutMatrixInventoriesEveryScreen(t *testing.T) {
|
||||||
screenSearch: "manager matrix",
|
screenSearch: "manager matrix",
|
||||||
screenTags: "manager matrix",
|
screenTags: "manager matrix",
|
||||||
screenTagInput: "manager matrix",
|
screenTagInput: "manager matrix",
|
||||||
|
screenGroups: "manager matrix",
|
||||||
|
screenGroupInput: "manager matrix",
|
||||||
screenTemplates: "manager matrix",
|
screenTemplates: "manager matrix",
|
||||||
screenTemplateForm: "template form",
|
screenTemplateForm: "template form",
|
||||||
screenTemplatePicker: "manager matrix",
|
screenTemplatePicker: "manager matrix",
|
||||||
|
|
@ -255,6 +274,7 @@ func TestLayoutMatrixInventoriesEveryScreen(t *testing.T) {
|
||||||
screenBackgroundResults: "manager matrix",
|
screenBackgroundResults: "manager matrix",
|
||||||
screenHelp: "help matrix",
|
screenHelp: "help matrix",
|
||||||
screenActionMenu: "action matrix",
|
screenActionMenu: "action matrix",
|
||||||
|
screenManageMenu: "manage matrix",
|
||||||
screenForwardList: "forward matrix",
|
screenForwardList: "forward matrix",
|
||||||
screenForwardForm: "forward form matrix",
|
screenForwardForm: "forward form matrix",
|
||||||
screenTunnelManager: "manager matrix",
|
screenTunnelManager: "manager matrix",
|
||||||
|
|
|
||||||
|
|
@ -18,16 +18,16 @@ func TestVaultStatusTracksSuccessfulLock(t *testing.T) {
|
||||||
if !strings.Contains(m.View(), "Vault unlocked") {
|
if !strings.Contains(m.View(), "Vault unlocked") {
|
||||||
t.Fatalf("initial status is not unlocked:\n%s", m.View())
|
t.Fatalf("initial status is not unlocked:\n%s", m.View())
|
||||||
}
|
}
|
||||||
m.actionMenu = newActionMenuModel(80, 24)
|
m.manageMenu = newManageMenuModel(80, 24)
|
||||||
m.screen = screenActionMenu
|
m.screen = screenManageMenu
|
||||||
for i := range m.actionMenu.list.Items() {
|
for i := range m.manageMenu.list.Items() {
|
||||||
m.actionMenu.list.Select(i)
|
m.manageMenu.list.Select(i)
|
||||||
item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem)
|
item, ok := m.manageMenu.list.SelectedItem().(actionMenuItem)
|
||||||
if ok && item.action == "vault_lock" {
|
if ok && item.action == "vault_lock" {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updated, _ := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter})
|
updated, _ := m.updateManageMenu(tea.KeyMsg{Type: tea.KeyEnter})
|
||||||
m = updated.(*tuiModel)
|
m = updated.(*tuiModel)
|
||||||
view := m.View()
|
view := m.View()
|
||||||
if !strings.Contains(view, "Vault locked") || strings.Contains(view, "Vault unlocked") {
|
if !strings.Contains(view, "Vault locked") || strings.Contains(view, "Vault unlocked") {
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,16 @@ func (m *tunnelScreenModel) stopSelected() tea.Cmd {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *tunnelScreenModel) runningCount() int {
|
||||||
|
count := 0
|
||||||
|
for _, state := range m.tunnels {
|
||||||
|
if state != nil && tunnel.IsRunning(state.ID) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
func (m *tunnelScreenModel) View() string {
|
func (m *tunnelScreenModel) View() string {
|
||||||
notification := ""
|
notification := ""
|
||||||
if m.err != nil {
|
if m.err != nil {
|
||||||
|
|
@ -100,7 +110,7 @@ func (m *tunnelScreenModel) View() string {
|
||||||
}
|
}
|
||||||
body := func(width, height int) string {
|
body := func(width, height int) string {
|
||||||
if len(m.tunnels) == 0 {
|
if len(m.tunnels) == 0 {
|
||||||
return renderPaddedPanel(width, height, []string{dashboardHelp("No running tunnels.")})
|
return renderPaddedPanel(width, height, []string{dashboardHelp("No tracked tunnels.")})
|
||||||
}
|
}
|
||||||
capacity := max(1, height-2)
|
capacity := max(1, height-2)
|
||||||
start, end := visibleServerRange(len(m.tunnels), m.list.Index(), max(1, capacity/3))
|
start, end := visibleServerRange(len(m.tunnels), m.list.Index(), max(1, capacity/3))
|
||||||
|
|
@ -120,7 +130,7 @@ func (m *tunnelScreenModel) View() string {
|
||||||
}
|
}
|
||||||
return renderScreenShell(screenShell{
|
return renderScreenShell(screenShell{
|
||||||
breadcrumb: "Tunnel Manager",
|
breadcrumb: "Tunnel Manager",
|
||||||
status: fmt.Sprintf("%d running", len(m.tunnels)),
|
status: fmt.Sprintf("%d running · %d tracked", m.runningCount(), len(m.tunnels)),
|
||||||
notification: notification,
|
notification: notification,
|
||||||
width: m.width,
|
width: m.width,
|
||||||
height: m.height,
|
height: m.height,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -67,9 +68,10 @@ type derivedKey struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type SecretMeta struct {
|
type SecretMeta struct {
|
||||||
ID string
|
ID string
|
||||||
Alias string
|
Alias string
|
||||||
Type string
|
ServerID int64
|
||||||
|
Type string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(path string) *Vault {
|
func New(path string) *Vault {
|
||||||
|
|
@ -271,24 +273,32 @@ func (v *Vault) ListSecrets() ([]SecretMeta, error) {
|
||||||
|
|
||||||
metas := make([]SecretMeta, 0, len(v.records))
|
metas := make([]SecretMeta, 0, len(v.records))
|
||||||
for id, record := range v.records {
|
for id, record := range v.records {
|
||||||
alias, secretType, ok := parseServerSecretID(id)
|
alias, secretType, legacy := parseServerSecretID(id)
|
||||||
if !ok {
|
serverID := int64(0)
|
||||||
continue
|
if !legacy {
|
||||||
|
var ok bool
|
||||||
|
serverID, secretType, ok = parseStableServerSecretID(id)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if record.secretType != "" {
|
if record.secretType != "" {
|
||||||
secretType = record.secretType
|
secretType = record.secretType
|
||||||
}
|
}
|
||||||
metas = append(metas, SecretMeta{
|
metas = append(metas, SecretMeta{ID: id, Alias: alias, ServerID: serverID, Type: secretType})
|
||||||
ID: id,
|
|
||||||
Alias: alias,
|
|
||||||
Type: secretType,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
sort.Slice(metas, func(i, j int) bool {
|
sort.Slice(metas, func(i, j int) bool {
|
||||||
if metas[i].Alias == metas[j].Alias {
|
left, right := metas[i].Alias, metas[j].Alias
|
||||||
|
if left == "" {
|
||||||
|
left = fmt.Sprintf("#%d", metas[i].ServerID)
|
||||||
|
}
|
||||||
|
if right == "" {
|
||||||
|
right = fmt.Sprintf("#%d", metas[j].ServerID)
|
||||||
|
}
|
||||||
|
if left == right {
|
||||||
return metas[i].Type < metas[j].Type
|
return metas[i].Type < metas[j].Type
|
||||||
}
|
}
|
||||||
return metas[i].Alias < metas[j].Alias
|
return left < right
|
||||||
})
|
})
|
||||||
return metas, nil
|
return metas, nil
|
||||||
}
|
}
|
||||||
|
|
@ -495,10 +505,14 @@ func inferSecretType(id string, recordType string) string {
|
||||||
return recordType
|
return recordType
|
||||||
}
|
}
|
||||||
_, secretType, ok := parseServerSecretID(id)
|
_, secretType, ok := parseServerSecretID(id)
|
||||||
if !ok {
|
if ok {
|
||||||
return ""
|
return secretType
|
||||||
}
|
}
|
||||||
return secretType
|
_, secretType, ok = parseStableServerSecretID(id)
|
||||||
|
if ok {
|
||||||
|
return secretType
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseServerSecretID(id string) (string, string, bool) {
|
func parseServerSecretID(id string) (string, string, bool) {
|
||||||
|
|
@ -509,6 +523,18 @@ func parseServerSecretID(id string) (string, string, bool) {
|
||||||
return parts[1], parts[2], true
|
return parts[1], parts[2], true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseStableServerSecretID(id string) (int64, string, bool) {
|
||||||
|
parts := strings.Split(id, ":")
|
||||||
|
if len(parts) != 3 || parts[0] != "server-id" || parts[1] == "" || parts[2] == "" {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
serverID, err := strconv.ParseInt(parts[1], 10, 64)
|
||||||
|
if err != nil || serverID <= 0 {
|
||||||
|
return 0, "", false
|
||||||
|
}
|
||||||
|
return serverID, parts[2], true
|
||||||
|
}
|
||||||
|
|
||||||
func decryptRecord(key []byte, rec Record) ([]byte, error) {
|
func decryptRecord(key []byte, rec Record) ([]byte, error) {
|
||||||
aead, err := chacha20poly1305.NewX(key)
|
aead, err := chacha20poly1305.NewX(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -266,3 +266,24 @@ func TestHasSecretReportsPresenceWithoutReturningValue(t *testing.T) {
|
||||||
t.Fatal("expected missing passphrase to be reported absent")
|
t.Fatal("expected missing passphrase to be reported absent")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListSecretsIncludesStableServerIDs(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "vault.bin")
|
||||||
|
if err := Create(path, "master"); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
v := New(path)
|
||||||
|
if err := v.Unlock("master"); err != nil {
|
||||||
|
t.Fatalf("unlock: %v", err)
|
||||||
|
}
|
||||||
|
if err := v.Put("server-id:42:ssh_password", "ssh_password", []byte("secret")); err != nil {
|
||||||
|
t.Fatalf("put: %v", err)
|
||||||
|
}
|
||||||
|
metas, err := v.ListSecrets()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(metas) != 1 || metas[0].ServerID != 42 || metas[0].Type != "ssh_password" || metas[0].Alias != "" {
|
||||||
|
t.Fatalf("unexpected stable metadata: %#v", metas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue