feat: add model-aware management workflows
This commit is contained in:
parent
f05e8e8e84
commit
db25f9011b
|
|
@ -0,0 +1,39 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func listIdentityFiles() ([]string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sshDir := filepath.Join(home, ".ssh")
|
||||
entries, err := os.ReadDir(sshDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var paths []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
lower := strings.ToLower(name)
|
||||
if strings.HasSuffix(lower, ".pub") || lower == "config" || strings.HasPrefix(lower, "known_hosts") || lower == "authorized_keys" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(name, "id_") || strings.HasSuffix(lower, ".pem") || strings.HasSuffix(lower, ".key") {
|
||||
paths = append(paths, filepath.Join(sshDir, name))
|
||||
}
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths, nil
|
||||
}
|
||||
|
|
@ -93,9 +93,16 @@ func runTUI() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
11
cmd/vault.go
11
cmd/vault.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue