feat: add model-aware management workflows

This commit is contained in:
mirivlad 2026-09-05 21:00:15 +08:00
parent f05e8e8e84
commit db25f9011b
14 changed files with 1067 additions and 198 deletions

39
cmd/identity_files.go Normal file
View File

@ -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
}

View File

@ -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)
} }

View File

@ -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
} }

View File

@ -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)
}
}

View File

@ -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 (

View File

@ -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"},

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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,

View File

@ -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",

View File

@ -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") {

View File

@ -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,

View File

@ -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 {

View File

@ -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)
}
}