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
}
tui.ListIdentityFiles = listIdentityFiles
tui.GetGroups = func() ([]string, error) {
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) {
return appDB.ResolveAlias(alias)
}

View File

@ -250,7 +250,16 @@ func formatVaultSecretsList(v *vault.Vault) (string, error) {
var b strings.Builder
fmt.Fprintf(&b, "%-24s %-18s\n", "ALIAS", "TYPE")
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
}

View File

@ -1,6 +1,7 @@
package db
import (
"strings"
"testing"
"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"`
}
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
const (

View File

@ -74,6 +74,13 @@ type tagsLoadedMsg struct {
err error
}
type groupsLoadedMsg struct {
groups []*model.Group
deleted bool
deletedName string
err error
}
type backgroundRunDoneMsg struct {
results []templateRunResult
}
@ -146,6 +153,16 @@ func (i serverItem) FilterValue() string {
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 {
template *model.CommandTemplate
}
@ -179,7 +196,10 @@ var (
SaveServer func(server *model.Server, password string, oldAlias string) error
UpdateTestResult func(alias string, status model.TestStatus, testErr string) error
HasSecret func(alias string, secretType string) bool
ListIdentityFiles func() ([]string, error)
GetGroups func() ([]string, error)
ListGroups func() ([]*model.Group, error)
CreateGroup func(name string) error
ResolveRouteAlias func(alias string) (int64, bool)
RenameGroup func(oldName, newName string) error
DeleteGroup func(name string) error
@ -210,6 +230,8 @@ const (
screenSearch
screenTags
screenTagInput
screenGroups
screenGroupInput
screenTemplates
screenTemplateForm
screenTemplatePicker
@ -217,6 +239,7 @@ const (
screenBackgroundResults
screenHelp
screenActionMenu
screenManageMenu
screenForwardList
screenForwardForm
screenTunnelManager
@ -271,6 +294,11 @@ type tuiModel struct {
tagInput textinput.Model
tagMode string
tagOldName string
groups []*model.Group
groupList list.Model
groupInput textinput.Model
groupMode string
groupOldName string
selected map[string]bool
tunnelScreen *tunnelScreenModel
bgResults []templateRunResult
@ -281,6 +309,7 @@ type tuiModel struct {
result *TUIResult
helpScreen *helpScreenModel
actionMenu *actionMenuModel
manageMenu *actionMenuModel
forwardScreen *forwardScreenModel
forwardForm *forwardFormModel
confirm *confirmState
@ -308,11 +337,19 @@ func New(servers []*model.Server) *tuiModel {
tagInput := textinput.New()
tagInput.Placeholder = "tag"
tagInput.CharLimit = 64
groupInput := textinput.New()
groupInput.Placeholder = "group"
groupInput.CharLimit = 64
templateList := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0)
templateList.SetShowStatusBar(false)
templateList.SetFilteringEnabled(false)
templateList.SetShowHelp(false)
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
if VaultUnlocked != nil {
@ -326,8 +363,10 @@ func New(servers []*model.Server) *tuiModel {
searchInput: search,
selected: map[string]bool{},
tagInput: tagInput,
groupInput: groupInput,
templateList: templateList,
tagList: tagList,
groupList: groupList,
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.tagList.SetSize(msg.Width, managerListHeight(msg.Height))
m.groupList.SetSize(msg.Width, managerListHeight(msg.Height))
return m, nil
case serversLoadedMsg:
@ -434,6 +474,23 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.setTags(msg.tags)
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:
m.result = &TUIResult{
Server: msg.server,
@ -707,6 +764,10 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateTags(msg)
case screenTagInput:
return m.updateTagInput(msg)
case screenGroups:
return m.updateGroups(msg)
case screenGroupInput:
return m.updateGroupInput(msg)
case screenTemplates:
return m.updateTemplates(msg)
case screenTemplateForm:
@ -721,6 +782,8 @@ func (m *tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateHelp(msg)
case screenActionMenu:
return m.updateActionMenu(msg)
case screenManageMenu:
return m.updateManageMenu(msg)
case screenForwardList:
return m.updateForwardList(msg)
case screenForwardForm:
@ -813,6 +876,11 @@ func (m *tuiModel) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.openTemplatePicker()
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() == "?" {
m.helpParent = m.screen
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
}
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) {
switch msg.Type {
case tea.KeyEsc:
@ -1226,6 +1389,10 @@ func (m *tuiModel) View() string {
case screenTagInput:
b.WriteString(m.viewTagInput())
case screenGroups:
b.WriteString(m.viewGroups())
case screenGroupInput:
b.WriteString(m.viewGroupInput())
case screenTemplates:
b.WriteString(m.viewTemplates())
@ -1256,6 +1423,10 @@ func (m *tuiModel) View() string {
if m.actionMenu != nil {
b.WriteString(m.actionMenu.View())
}
case screenManageMenu:
if m.manageMenu != nil {
b.WriteString(m.manageMenu.View())
}
case screenForwardList:
if m.forwardScreen != nil {
@ -1350,11 +1521,6 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.actionMenu = nil
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":
if item, ok := m.list.SelectedItem().(serverItem); ok {
m.form = newEditFormModel(item.server, m.width, m.height)
@ -1387,9 +1553,41 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.confirmServerDelete(item.server)
return m, nil
}
}
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
m.actionMenu = nil
return m, func() tea.Msg {
if ImportServers == nil {
return importDoneMsg{err: fmt.Errorf("import is unavailable")}
@ -1402,12 +1600,10 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
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 {
@ -1417,13 +1613,9 @@ func (m *tuiModel) updateActionMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
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
}
@ -1758,7 +1950,7 @@ func (m *tuiModel) updateFullHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m *tuiModel) screenOwnsPrintableInput() bool {
switch m.screen {
case screenForm, screenSearch, screenTagInput, screenTemplateForm, screenForwardForm:
case screenForm, screenSearch, screenTagInput, screenGroupInput, screenTemplateForm, screenForwardForm:
return true
default:
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 {
return renderScreenShell(screenShell{
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 {
return func() tea.Msg {
if ListCommandTemplates == nil {
@ -2230,7 +2505,8 @@ func (m *tuiModel) listHelpItems(selectedCount int, hasBackgroundResult bool) []
}
items = append(items,
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+E", Action: "edit"},
helpItem{Key: "Ctrl+F", Action: "search"},

View File

@ -148,7 +148,7 @@ func TestServerListHelpWrapsSelectionAndResultHints(t *testing.T) {
plainLines = append(plainLines, plainHelpLine(line))
}
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) {
t.Fatalf("expected wrapped help to contain %q\nlines:%#v", want, lines)
}
@ -444,7 +444,7 @@ func TestFormViewUsesSectionsAndStableLabels(t *testing.T) {
"Alias",
"Display Name",
"Auth Method",
"Password / Passphrase",
"Identity File",
} {
if !strings.Contains(view, want) {
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) {
oldUpdateTestResult := UpdateTestResult
oldListServers := ListServers
@ -791,7 +813,12 @@ func TestActionMenuClosesOnAllActions(t *testing.T) {
// Test delete closes menu
m.actionMenu = newActionMenuModel(m.width, m.height)
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 }
ListServers = func() ([]*model.Server, error) { return []*model.Server{server}, nil }
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}
imported := false
ImportServers = func() (int, error) {
@ -866,16 +893,16 @@ func TestActionMenuImportUsesCallbackAndRefreshesList(t *testing.T) {
m := New([]*model.Server{})
m.width = 100
m.height = 30
m.actionMenu = newActionMenuModel(m.width, m.height)
m.screen = screenActionMenu
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 == "import" {
m.manageMenu = newManageMenuModel(m.width, m.height)
m.screen = screenManageMenu
for i := 0; i < len(m.manageMenu.list.Items()); i++ {
m.manageMenu.list.Select(i)
if item, ok := m.manageMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "import" {
break
}
}
updated, cmd := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter})
updated, cmd := m.updateManageMenu(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
if cmd == nil {
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}
for _, action := range []string{"export", "vault_change_pw"} {
t.Run(action, func(t *testing.T) {
m := New([]*model.Server{server})
m.width = 100
m.height = 30
m.actionMenu = newActionMenuModel(m.width, m.height)
m.screen = screenActionMenu
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 == action {
m.manageMenu = newManageMenuModel(m.width, m.height)
m.screen = screenManageMenu
for i := 0; i < len(m.manageMenu.list.Items()); i++ {
m.manageMenu.list.Select(i)
if item, ok := m.manageMenu.list.SelectedItem().(actionMenuItem); ok && item.action == action {
break
}
}
updated, cmd := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter})
updated, cmd := m.updateManageMenu(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
if cmd == nil {
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}
locked := false
LockVault = func() error {
@ -935,16 +962,16 @@ func TestActionMenuVaultLockUsesCallback(t *testing.T) {
m := New([]*model.Server{server})
m.width = 100
m.height = 30
m.actionMenu = newActionMenuModel(m.width, m.height)
m.screen = screenActionMenu
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 == "vault_lock" {
m.manageMenu = newManageMenuModel(m.width, m.height)
m.screen = screenManageMenu
for i := 0; i < len(m.manageMenu.list.Items()); i++ {
m.manageMenu.list.Select(i)
if item, ok := m.manageMenu.list.SelectedItem().(actionMenuItem); ok && item.action == "vault_lock" {
break
}
}
updated, _ := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter})
updated, _ := m.updateManageMenu(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
if !locked {
@ -954,3 +981,60 @@ func TestActionMenuVaultLockUsesCallback(t *testing.T) {
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

@ -101,6 +101,17 @@ type formModel struct {
showGroupList bool
authList list.Model
showAuthList bool
identityFiles []string
identityList list.Model
showIdentityList bool
tagValues []string
tagList list.Model
showTagList bool
startupTemplates []*model.CommandTemplate
startupList list.Model
showStartupList bool
hasSavedPassword bool
hasSavedPassphrase bool
routeProfiles []*model.Server
routeList list.Model
showRouteList bool
@ -136,6 +147,7 @@ func newFormModel(w, h int) *formModel {
inputs[i].CharLimit = 128
}
inputs[3].SetValue("22")
inputs[5].SetValue(string(model.AuthKey))
pw := textinput.New()
pw.Placeholder = "optional"
@ -231,18 +243,8 @@ func newEditFormModel(s *model.Server, w, h int) *formModel {
fm.inputs[10].SetValue(s.StartupCommand)
fm.inputs[11].SetValue(strings.Join(s.Tags, ", "))
if HasSecret != nil {
switch s.AuthMethod {
case model.AuthPassword:
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.hasSavedPassword = HasSecret(s.Alias, "ssh_password")
fm.hasSavedPassphrase = HasSecret(s.Alias, "key_passphrase")
}
fm.updateFocus()
fm.initial = fm.snapshot()
@ -270,6 +272,197 @@ func (fm *formModel) Dirty() bool {
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) {
alias = strings.TrimSpace(alias)
for _, server := range fm.routeProfiles {
@ -440,6 +633,15 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.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 {
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.showAuthList = false
fm.updateFocus()
return fm, nil
}
}
@ -488,21 +691,11 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
switch msg.Type {
case tea.KeyTab:
fm.focusIdx++
total := len(fm.inputs) + 3
if fm.focusIdx >= total {
fm.focusIdx = 0
}
fm.updateFocus()
fm.advanceFocus(1)
return fm, nil
case tea.KeyShiftTab:
fm.focusIdx--
if fm.focusIdx < 0 {
total := len(fm.inputs) + 3
fm.focusIdx = total - 1
}
fm.updateFocus()
fm.advanceFocus(-1)
return fm, nil
case tea.KeyRunes:
@ -510,6 +703,10 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
fm.showAuthList = true
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 {
fm.showRouteList = true
fm.routePane = 1
@ -523,6 +720,14 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
fm.showGroupList = true
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:
switch {
@ -531,12 +736,7 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case fm.focusIdx == len(fm.inputs)+2:
return fm, fm.runSave()
default:
fm.focusIdx++
total := len(fm.inputs) + 3
if fm.focusIdx >= total {
fm.focusIdx = 0
}
fm.updateFocus()
fm.advanceFocus(1)
return fm, nil
}
@ -544,21 +744,11 @@ func (fm *formModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return fm, nil
case tea.KeyDown:
fm.focusIdx++
total := len(fm.inputs) + 3
if fm.focusIdx >= total {
fm.focusIdx = 0
}
fm.updateFocus()
fm.advanceFocus(1)
return fm, nil
case tea.KeyUp:
fm.focusIdx--
if fm.focusIdx < 0 {
total := len(fm.inputs) + 3
fm.focusIdx = total - 1
}
fm.updateFocus()
fm.advanceFocus(-1)
return fm, nil
}
}
@ -599,7 +789,24 @@ func (fm *formModel) applySaveError(err error) {
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() {
fm.updateCredentialPresentation()
for i := range fm.inputs {
fm.inputs[i].Blur()
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) + "> ")
} else if fm.focusIdx == len(fm.inputs) {
fm.password.Focus()
fm.password.Prompt = focusedStyle.Render(fm.passwordLabel + "> ")
}
fm.updateCredentialPresentation()
}
func (fm *formModel) labelAt(index int) string {
@ -629,15 +836,24 @@ func (fm *formModel) labelAt(index int) string {
if index == 5 {
return "Auth Method (/ pick)"
}
if index == 6 {
return "Identity File (/ pick)"
}
if index == 7 {
return "Route (/ edit)"
}
if index == 10 {
return "Startup Command (/ template)"
}
if index == 8 {
if len(fm.groups) > 0 {
return "Group (/ pick)"
}
return "Group"
}
if index == 11 {
return "Tags (/ pick, type to create)"
}
return fm.labels[index]
}
return ""
@ -773,10 +989,53 @@ func parsePort(value string) (int, error) {
}
func (fm *formModel) View() string {
fm.updateCredentialPresentation()
title := "Add Server"
if fm.edit {
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 {
return fm.routeEditorView(title)
}
@ -818,6 +1077,9 @@ func (fm *formModel) View() string {
allFields := make([]string, 0, len(fm.inputs)+5)
focusRows := make([]int, len(fm.inputs)+1)
for i := range fm.inputs {
if i == 6 && !fm.usesIdentityFile() {
continue
}
if richLayout {
if section := formSectionTitle(i); section != "" {
allFields = append(allFields, sectionStyle.Copy().MarginTop(0).Render(section))
@ -832,10 +1094,12 @@ func (fm *formModel) View() string {
focusRows[i] = len(allFields)
allFields = append(allFields, fm.inputs[i].View())
}
if fm.usesSecretInput() {
focusRows[len(fm.inputs)] = len(allFields)
allFields = append(allFields, fm.password.View())
focusField := len(allFields) - 1
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]
}
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 {
route := fm.currentRoute()
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+E", action: "Edit server", 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: "Ctrl+W", action: "Manage port forwards", section: "Forwards"},
helpScreenItem{key: "?", action: "This quick help", section: "Other"},
@ -207,21 +208,29 @@ func (m *fullHelpModel) View() string {
{"Ctrl+A", "Add server"},
{"Ctrl+E", "Edit server"},
{"Ctrl+F", "Search"},
{"Ctrl+X", "Action menu"},
{"Ctrl+X", "Server actions"},
{"m", "Manage global entities"},
{"Ins", "Select / deselect"},
}},
{"Action menu (Ctrl+X)", [][2]string{
{"Server actions (Ctrl+X)", [][2]string{
{"Connect", "Standard SSH session"},
{"Connect with tunnels", "SSH + all enabled forwards"},
{"Start tunnels only", "Forwards without shell"},
{"Start tunnels in bg", "Background tunnel process"},
{"Manage port forwards", "Add / edit / delete forwards"},
{"Manage tunnels", "View and stop running tunnels"},
{"Manage route", "Configure ProxyJump / bastions"},
{"Port forwards", "Add / edit / enable / delete forwards"},
{"Route", "Configure ordered bastions"},
{"Test connection", "Check if server is reachable"},
{"Edit", "Edit 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 define how to reach a server through jump hosts."},
{"● direct", "No jump host"},
@ -296,6 +305,7 @@ func (i actionMenuItem) FilterValue() string { return i.label }
type actionMenuModel struct {
list list.Model
title string
width 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: "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: "Manage port forwards", action: "forwards", description: "Add, edit, enable, or remove forwarding rules."},
actionMenuItem{label: "Manage tunnels", action: "tunnels", description: "Inspect and stop running tunnel processes."},
actionMenuItem{label: "Manage route", action: "route", description: "Configure direct or ProxyJump routing."},
actionMenuItem{label: "Port forwards", action: "forwards", description: "Add, edit, enable, or remove forwarding rules for this server."},
actionMenuItem{label: "Route", action: "route", description: "Configure direct or bastion routing for this server."},
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: "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: 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)
l.Title = "Actions"
func newMenuModel(title string, items []list.Item, w, h int) *actionMenuModel {
l := list.New(items, list.NewDefaultDelegate(), 34, len(items)+2)
l.Title = title
l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
l.SetShowHelp(false)
l.Styles.Title = titleStyle
return &actionMenuModel{list: l, width: w, height: h}
return &actionMenuModel{list: l, title: title, width: w, height: h}
}
func (m *actionMenuModel) Update(msg tea.Msg) (*actionMenuModel, *string) {
@ -368,7 +389,7 @@ func (m *actionMenuModel) View() string {
return renderPaddedPanel(width, height, listLines)
}
return renderScreenShell(screenShell{
breadcrumb: "Actions",
breadcrumb: m.title,
status: fmt.Sprintf("%d actions", len(m.list.Items())),
width: m.width,
height: m.height,

View File

@ -140,7 +140,7 @@ func TestActionMenuFitsSupportedTerminalSizes(t *testing.T) {
view := menu.View()
assertViewFits(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) {
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) {
for _, size := range []struct{ width, height int }{{120, 40}, {80, 24}, {60, 16}} {
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"}
m.setTemplates([]*model.CommandTemplate{template})
m.setTags([]string{"production"})
m.setGroups([]*model.Group{{ID: 1, Name: "Production", ServerCount: 1}})
m.pendingTemplate = template
m.bgResults = []templateRunResult{{Alias: "prod", Output: "ok\n数据库 ready"}}
@ -224,6 +239,8 @@ func TestManagerScreensUseUnifiedShell(t *testing.T) {
{"search", screenSearch},
{"tags", screenTags},
{"tag-input", screenTagInput},
{"groups", screenGroups},
{"group-input", screenGroupInput},
{"templates", screenTemplates},
{"template-picker", screenTemplatePicker},
{"template-mode", screenTemplateMode},
@ -248,6 +265,8 @@ func TestLayoutMatrixInventoriesEveryScreen(t *testing.T) {
screenSearch: "manager matrix",
screenTags: "manager matrix",
screenTagInput: "manager matrix",
screenGroups: "manager matrix",
screenGroupInput: "manager matrix",
screenTemplates: "manager matrix",
screenTemplateForm: "template form",
screenTemplatePicker: "manager matrix",
@ -255,6 +274,7 @@ func TestLayoutMatrixInventoriesEveryScreen(t *testing.T) {
screenBackgroundResults: "manager matrix",
screenHelp: "help matrix",
screenActionMenu: "action matrix",
screenManageMenu: "manage matrix",
screenForwardList: "forward matrix",
screenForwardForm: "forward form matrix",
screenTunnelManager: "manager matrix",

View File

@ -18,16 +18,16 @@ func TestVaultStatusTracksSuccessfulLock(t *testing.T) {
if !strings.Contains(m.View(), "Vault unlocked") {
t.Fatalf("initial status is not unlocked:\n%s", m.View())
}
m.actionMenu = newActionMenuModel(80, 24)
m.screen = screenActionMenu
for i := range m.actionMenu.list.Items() {
m.actionMenu.list.Select(i)
item, ok := m.actionMenu.list.SelectedItem().(actionMenuItem)
m.manageMenu = newManageMenuModel(80, 24)
m.screen = screenManageMenu
for i := range m.manageMenu.list.Items() {
m.manageMenu.list.Select(i)
item, ok := m.manageMenu.list.SelectedItem().(actionMenuItem)
if ok && item.action == "vault_lock" {
break
}
}
updated, _ := m.updateActionMenu(tea.KeyMsg{Type: tea.KeyEnter})
updated, _ := m.updateManageMenu(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(*tuiModel)
view := m.View()
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
}
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 {
notification := ""
if m.err != nil {
@ -100,7 +110,7 @@ func (m *tunnelScreenModel) View() string {
}
body := func(width, height int) string {
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)
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{
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,
width: m.width,
height: m.height,

View File

@ -9,6 +9,7 @@ import (
"io"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
@ -69,6 +70,7 @@ type derivedKey struct {
type SecretMeta struct {
ID string
Alias string
ServerID int64
Type string
}
@ -271,24 +273,32 @@ func (v *Vault) ListSecrets() ([]SecretMeta, error) {
metas := make([]SecretMeta, 0, len(v.records))
for id, record := range v.records {
alias, secretType, ok := parseServerSecretID(id)
alias, secretType, legacy := parseServerSecretID(id)
serverID := int64(0)
if !legacy {
var ok bool
serverID, secretType, ok = parseStableServerSecretID(id)
if !ok {
continue
}
}
if record.secretType != "" {
secretType = record.secretType
}
metas = append(metas, SecretMeta{
ID: id,
Alias: alias,
Type: secretType,
})
metas = append(metas, SecretMeta{ID: id, Alias: alias, ServerID: serverID, Type: secretType})
}
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].Alias < metas[j].Alias
return left < right
})
return metas, nil
}
@ -495,10 +505,14 @@ func inferSecretType(id string, recordType string) string {
return recordType
}
_, secretType, ok := parseServerSecretID(id)
if !ok {
return ""
}
if ok {
return secretType
}
_, secretType, ok = parseStableServerSecretID(id)
if ok {
return secretType
}
return ""
}
func parseServerSecretID(id string) (string, string, bool) {
@ -509,6 +523,18 @@ func parseServerSecretID(id string) (string, string, bool) {
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) {
aead, err := chacha20poly1305.NewX(key)
if err != nil {

View File

@ -266,3 +266,24 @@ func TestHasSecretReportsPresenceWithoutReturningValue(t *testing.T) {
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)
}
}