mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2025-09-07 13:20:40 +02:00
feat: add SSH connection history with TUI sorting by last login
This commit is contained in:
parent
534b7d9a6c
commit
01f2b4e6be
208
internal/history/history.go
Normal file
208
internal/history/history.go
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
package history
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sshm/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConnectionHistory represents the history of SSH connections
|
||||||
|
type ConnectionHistory struct {
|
||||||
|
Connections map[string]ConnectionInfo `json:"connections"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectionInfo stores information about a specific connection
|
||||||
|
type ConnectionInfo struct {
|
||||||
|
HostName string `json:"host_name"`
|
||||||
|
LastConnect time.Time `json:"last_connect"`
|
||||||
|
ConnectCount int `json:"connect_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HistoryManager manages the connection history
|
||||||
|
type HistoryManager struct {
|
||||||
|
historyPath string
|
||||||
|
history *ConnectionHistory
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHistoryManager creates a new history manager
|
||||||
|
func NewHistoryManager() (*HistoryManager, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
historyPath := filepath.Join(homeDir, ".ssh", "sshm_history.json")
|
||||||
|
|
||||||
|
hm := &HistoryManager{
|
||||||
|
historyPath: historyPath,
|
||||||
|
history: &ConnectionHistory{Connections: make(map[string]ConnectionInfo)},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing history if it exists
|
||||||
|
err = hm.loadHistory()
|
||||||
|
if err != nil {
|
||||||
|
// If file doesn't exist, that's okay - we'll create it when needed
|
||||||
|
if !os.IsNotExist(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadHistory loads the connection history from the JSON file
|
||||||
|
func (hm *HistoryManager) loadHistory() error {
|
||||||
|
data, err := os.ReadFile(hm.historyPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Unmarshal(data, hm.history)
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveHistory saves the connection history to the JSON file
|
||||||
|
func (hm *HistoryManager) saveHistory() error {
|
||||||
|
// Ensure the directory exists
|
||||||
|
dir := filepath.Dir(hm.historyPath)
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(hm.history, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(hm.historyPath, data, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordConnection records a new connection for the specified host
|
||||||
|
func (hm *HistoryManager) RecordConnection(hostName string) error {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if conn, exists := hm.history.Connections[hostName]; exists {
|
||||||
|
// Update existing connection
|
||||||
|
conn.LastConnect = now
|
||||||
|
conn.ConnectCount++
|
||||||
|
hm.history.Connections[hostName] = conn
|
||||||
|
} else {
|
||||||
|
// Create new connection record
|
||||||
|
hm.history.Connections[hostName] = ConnectionInfo{
|
||||||
|
HostName: hostName,
|
||||||
|
LastConnect: now,
|
||||||
|
ConnectCount: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hm.saveHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLastConnectionTime returns the last connection time for a host
|
||||||
|
func (hm *HistoryManager) GetLastConnectionTime(hostName string) (time.Time, bool) {
|
||||||
|
if conn, exists := hm.history.Connections[hostName]; exists {
|
||||||
|
return conn.LastConnect, true
|
||||||
|
}
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConnectionCount returns the total number of connections for a host
|
||||||
|
func (hm *HistoryManager) GetConnectionCount(hostName string) int {
|
||||||
|
if conn, exists := hm.history.Connections[hostName]; exists {
|
||||||
|
return conn.ConnectCount
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortHostsByLastUsed sorts hosts by their last connection time (most recent first)
|
||||||
|
func (hm *HistoryManager) SortHostsByLastUsed(hosts []config.SSHHost) []config.SSHHost {
|
||||||
|
sorted := make([]config.SSHHost, len(hosts))
|
||||||
|
copy(sorted, hosts)
|
||||||
|
|
||||||
|
sort.Slice(sorted, func(i, j int) bool {
|
||||||
|
timeI, existsI := hm.GetLastConnectionTime(sorted[i].Name)
|
||||||
|
timeJ, existsJ := hm.GetLastConnectionTime(sorted[j].Name)
|
||||||
|
|
||||||
|
// If both have history, sort by most recent first
|
||||||
|
if existsI && existsJ {
|
||||||
|
return timeI.After(timeJ)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hosts with history come before hosts without history
|
||||||
|
if existsI && !existsJ {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !existsI && existsJ {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If neither has history, sort alphabetically
|
||||||
|
return sorted[i].Name < sorted[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortHostsByMostUsed sorts hosts by their connection count (most used first)
|
||||||
|
func (hm *HistoryManager) SortHostsByMostUsed(hosts []config.SSHHost) []config.SSHHost {
|
||||||
|
sorted := make([]config.SSHHost, len(hosts))
|
||||||
|
copy(sorted, hosts)
|
||||||
|
|
||||||
|
sort.Slice(sorted, func(i, j int) bool {
|
||||||
|
countI := hm.GetConnectionCount(sorted[i].Name)
|
||||||
|
countJ := hm.GetConnectionCount(sorted[j].Name)
|
||||||
|
|
||||||
|
// If counts are different, sort by count (highest first)
|
||||||
|
if countI != countJ {
|
||||||
|
return countI > countJ
|
||||||
|
}
|
||||||
|
|
||||||
|
// If counts are equal, sort by most recent
|
||||||
|
timeI, existsI := hm.GetLastConnectionTime(sorted[i].Name)
|
||||||
|
timeJ, existsJ := hm.GetLastConnectionTime(sorted[j].Name)
|
||||||
|
|
||||||
|
if existsI && existsJ {
|
||||||
|
return timeI.After(timeJ)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If neither has history, sort alphabetically
|
||||||
|
return sorted[i].Name < sorted[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupOldEntries removes connection history for hosts that no longer exist
|
||||||
|
func (hm *HistoryManager) CleanupOldEntries(currentHosts []config.SSHHost) error {
|
||||||
|
// Create a set of current host names
|
||||||
|
currentHostNames := make(map[string]bool)
|
||||||
|
for _, host := range currentHosts {
|
||||||
|
currentHostNames[host.Name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove entries for hosts that no longer exist
|
||||||
|
for hostName := range hm.history.Connections {
|
||||||
|
if !currentHostNames[hostName] {
|
||||||
|
delete(hm.history.Connections, hostName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hm.saveHistory()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllConnectionsInfo returns all connection information sorted by last connection time
|
||||||
|
func (hm *HistoryManager) GetAllConnectionsInfo() []ConnectionInfo {
|
||||||
|
var connections []ConnectionInfo
|
||||||
|
for _, conn := range hm.history.Connections {
|
||||||
|
connections = append(connections, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(connections, func(i, j int) bool {
|
||||||
|
return connections[i].LastConnect.After(connections[j].LastConnect)
|
||||||
|
})
|
||||||
|
|
||||||
|
return connections
|
||||||
|
}
|
@ -5,8 +5,10 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"sshm/internal/config"
|
"sshm/internal/config"
|
||||||
|
"sshm/internal/history"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/table"
|
"github.com/charmbracelet/bubbles/table"
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
@ -36,16 +38,36 @@ const asciiTitle = `
|
|||||||
|_____|_____|__|__|_|_|_|
|
|_____|_____|__|__|_|_|_|
|
||||||
`
|
`
|
||||||
|
|
||||||
|
type SortMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
SortByName SortMode = iota
|
||||||
|
SortByLastUsed
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s SortMode) String() string {
|
||||||
|
switch s {
|
||||||
|
case SortByName:
|
||||||
|
return "Name (A-Z)"
|
||||||
|
case SortByLastUsed:
|
||||||
|
return "Last Login"
|
||||||
|
default:
|
||||||
|
return "Name (A-Z)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
table table.Model
|
table table.Model
|
||||||
searchInput textinput.Model
|
searchInput textinput.Model
|
||||||
hosts []config.SSHHost
|
hosts []config.SSHHost
|
||||||
filteredHosts []config.SSHHost
|
filteredHosts []config.SSHHost
|
||||||
searchMode bool
|
searchMode bool
|
||||||
deleteMode bool
|
deleteMode bool
|
||||||
deleteHost string
|
deleteHost string
|
||||||
exitAction string
|
exitAction string
|
||||||
exitHostName string
|
exitHostName string
|
||||||
|
historyManager *history.HistoryManager
|
||||||
|
sortMode SortMode
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
func (m Model) Init() tea.Cmd {
|
||||||
@ -135,6 +157,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
selected := m.table.SelectedRow()
|
selected := m.table.SelectedRow()
|
||||||
if len(selected) > 0 {
|
if len(selected) > 0 {
|
||||||
hostName := selected[0] // Host name is in the first column
|
hostName := selected[0] // Host name is in the first column
|
||||||
|
|
||||||
|
// Record the connection in history
|
||||||
|
if m.historyManager != nil {
|
||||||
|
err := m.historyManager.RecordConnection(hostName)
|
||||||
|
if err != nil {
|
||||||
|
// Log error but don't prevent connection
|
||||||
|
fmt.Printf("Warning: Could not record connection history: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return m, tea.ExecProcess(exec.Command("ssh", hostName), func(err error) tea.Msg {
|
return m, tea.ExecProcess(exec.Command("ssh", hostName), func(err error) tea.Msg {
|
||||||
return tea.Quit()
|
return tea.Quit()
|
||||||
})
|
})
|
||||||
@ -170,24 +202,65 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "s":
|
||||||
|
if !m.searchMode && !m.deleteMode {
|
||||||
|
// Cycle through sort modes (only 2 modes now)
|
||||||
|
m.sortMode = (m.sortMode + 1) % 2
|
||||||
|
// Re-apply current filter with new sort mode
|
||||||
|
if m.searchInput.Value() != "" {
|
||||||
|
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||||
|
} else {
|
||||||
|
m.filteredHosts = m.sortHosts(m.hosts)
|
||||||
|
}
|
||||||
|
m.updateTableRows()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
case "r":
|
||||||
|
if !m.searchMode && !m.deleteMode {
|
||||||
|
// Switch to sort by recent (last used)
|
||||||
|
m.sortMode = SortByLastUsed
|
||||||
|
// Re-apply current filter with new sort mode
|
||||||
|
if m.searchInput.Value() != "" {
|
||||||
|
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||||
|
} else {
|
||||||
|
m.filteredHosts = m.sortHosts(m.hosts)
|
||||||
|
}
|
||||||
|
m.updateTableRows()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
case "n":
|
||||||
|
if !m.searchMode && !m.deleteMode {
|
||||||
|
// Switch to sort by name
|
||||||
|
m.sortMode = SortByName
|
||||||
|
// Re-apply current filter with new sort mode
|
||||||
|
if m.searchInput.Value() != "" {
|
||||||
|
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||||
|
} else {
|
||||||
|
m.filteredHosts = m.sortHosts(m.hosts)
|
||||||
|
}
|
||||||
|
m.updateTableRows()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update components based on mode
|
// Update components based on mode
|
||||||
if m.searchMode {
|
if m.searchMode {
|
||||||
|
oldValue := m.searchInput.Value()
|
||||||
m.searchInput, cmd = m.searchInput.Update(msg)
|
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||||
|
// Only update filtered hosts if search value changed
|
||||||
|
if m.searchInput.Value() != oldValue {
|
||||||
|
if m.searchInput.Value() != "" {
|
||||||
|
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
||||||
|
} else {
|
||||||
|
m.filteredHosts = m.sortHosts(m.hosts)
|
||||||
|
}
|
||||||
|
m.updateTableRows()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
m.table, cmd = m.table.Update(msg)
|
m.table, cmd = m.table.Update(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always filter hosts when search input changes (regardless of mode)
|
|
||||||
if m.searchInput.Value() != "" {
|
|
||||||
m.filteredHosts = m.filterHosts(m.searchInput.Value())
|
|
||||||
} else {
|
|
||||||
m.filteredHosts = m.hosts
|
|
||||||
}
|
|
||||||
m.updateTableRows()
|
|
||||||
|
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -204,11 +277,17 @@ func (m Model) View() string {
|
|||||||
// Add search bar (always visible) with appropriate style based on focus
|
// Add search bar (always visible) with appropriate style based on focus
|
||||||
searchPrompt := "Search (/ to focus, Tab to switch): "
|
searchPrompt := "Search (/ to focus, Tab to switch): "
|
||||||
if m.searchMode {
|
if m.searchMode {
|
||||||
view.WriteString(searchStyleFocused.Render(searchPrompt+m.searchInput.View()) + "\n\n")
|
view.WriteString(searchStyleFocused.Render(searchPrompt+m.searchInput.View()) + "\n")
|
||||||
} else {
|
} else {
|
||||||
view.WriteString(searchStyleUnfocused.Render(searchPrompt+m.searchInput.View()) + "\n\n")
|
view.WriteString(searchStyleUnfocused.Render(searchPrompt+m.searchInput.View()) + "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add sort mode indicator
|
||||||
|
sortInfo := fmt.Sprintf("Sort: %s", m.sortMode.String())
|
||||||
|
view.WriteString(lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("240")).
|
||||||
|
Render(sortInfo) + "\n\n")
|
||||||
|
|
||||||
// Add table with appropriate style based on focus
|
// Add table with appropriate style based on focus
|
||||||
if m.searchMode {
|
if m.searchMode {
|
||||||
// Table is not focused, use gray border
|
// Table is not focused, use gray border
|
||||||
@ -226,7 +305,8 @@ func (m Model) View() string {
|
|||||||
|
|
||||||
// Add help text
|
// Add help text
|
||||||
if !m.searchMode {
|
if !m.searchMode {
|
||||||
view.WriteString("\nUse ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • / to search • Tab to switch • q/ESC to quit")
|
view.WriteString("\nUse ↑/↓ to navigate • Enter to connect • (a)dd • (e)dit • (d)elete • / to search • Tab to switch")
|
||||||
|
view.WriteString("\nSort: (s)witch • (r)ecent • (n)ame • q/ESC to quit")
|
||||||
} else {
|
} else {
|
||||||
view.WriteString("\nType to filter hosts • Enter to validate search • Tab to switch to table • ESC to quit")
|
view.WriteString("\nType to filter hosts • Enter to validate search • Tab to switch to table • ESC to quit")
|
||||||
}
|
}
|
||||||
@ -234,6 +314,22 @@ func (m Model) View() string {
|
|||||||
return view.String()
|
return view.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sortHosts sorts hosts based on the current sort mode
|
||||||
|
func (m Model) sortHosts(hosts []config.SSHHost) []config.SSHHost {
|
||||||
|
if m.historyManager == nil {
|
||||||
|
return sortHostsByName(hosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch m.sortMode {
|
||||||
|
case SortByLastUsed:
|
||||||
|
return m.historyManager.SortHostsByLastUsed(hosts)
|
||||||
|
case SortByName:
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
return sortHostsByName(hosts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// sortHostsByName sorts a slice of SSH hosts alphabetically by name
|
// sortHostsByName sorts a slice of SSH hosts alphabetically by name
|
||||||
func sortHostsByName(hosts []config.SSHHost) []config.SSHHost {
|
func sortHostsByName(hosts []config.SSHHost) []config.SSHHost {
|
||||||
sorted := make([]config.SSHHost, len(hosts))
|
sorted := make([]config.SSHHost, len(hosts))
|
||||||
@ -269,7 +365,7 @@ func calculateNameColumnWidth(hosts []config.SSHHost) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// calculateTagsColumnWidth calculates the optimal width for the Tags column
|
// calculateTagsColumnWidth calculates the optimal width for the Tags column
|
||||||
// based on the longest tags string, with a minimum of 8 and maximum of 50 characters
|
// based on the longest tags string, with a minimum of 8 and maximum of 40 characters
|
||||||
func calculateTagsColumnWidth(hosts []config.SSHHost) int {
|
func calculateTagsColumnWidth(hosts []config.SSHHost) int {
|
||||||
maxLength := 8 // Minimum width to accommodate the "Tags" header
|
maxLength := 8 // Minimum width to accommodate the "Tags" header
|
||||||
|
|
||||||
@ -294,17 +390,114 @@ func calculateTagsColumnWidth(hosts []config.SSHHost) int {
|
|||||||
maxLength += 2
|
maxLength += 2
|
||||||
|
|
||||||
// Cap the maximum width to avoid extremely wide columns
|
// Cap the maximum width to avoid extremely wide columns
|
||||||
if maxLength > 50 {
|
if maxLength > 40 {
|
||||||
maxLength = 50
|
maxLength = 40
|
||||||
}
|
}
|
||||||
|
|
||||||
return maxLength
|
return maxLength
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatTimeAgo formats a time into a human-readable "time ago" string
|
||||||
|
func formatTimeAgo(t time.Time) string {
|
||||||
|
now := time.Now()
|
||||||
|
duration := now.Sub(t)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case duration < time.Minute:
|
||||||
|
seconds := int(duration.Seconds())
|
||||||
|
if seconds <= 1 {
|
||||||
|
return "1 second ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d seconds ago", seconds)
|
||||||
|
case duration < time.Hour:
|
||||||
|
minutes := int(duration.Minutes())
|
||||||
|
if minutes == 1 {
|
||||||
|
return "1 minute ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d minutes ago", minutes)
|
||||||
|
case duration < 24*time.Hour:
|
||||||
|
hours := int(duration.Hours())
|
||||||
|
if hours == 1 {
|
||||||
|
return "1 hour ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d hours ago", hours)
|
||||||
|
case duration < 7*24*time.Hour:
|
||||||
|
days := int(duration.Hours() / 24)
|
||||||
|
if days == 1 {
|
||||||
|
return "1 day ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d days ago", days)
|
||||||
|
case duration < 30*24*time.Hour:
|
||||||
|
weeks := int(duration.Hours() / (24 * 7))
|
||||||
|
if weeks == 1 {
|
||||||
|
return "1 week ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d weeks ago", weeks)
|
||||||
|
case duration < 365*24*time.Hour:
|
||||||
|
months := int(duration.Hours() / (24 * 30))
|
||||||
|
if months == 1 {
|
||||||
|
return "1 month ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d months ago", months)
|
||||||
|
default:
|
||||||
|
years := int(duration.Hours() / (24 * 365))
|
||||||
|
if years == 1 {
|
||||||
|
return "1 year ago"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d years ago", years)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateLastLoginColumnWidth calculates the optimal width for the Last Login column
|
||||||
|
// based on the longest time format, with a minimum of 12 and maximum of 20 characters
|
||||||
|
func calculateLastLoginColumnWidth(hosts []config.SSHHost, historyManager *history.HistoryManager) int {
|
||||||
|
maxLength := 12 // Minimum width to accommodate the "Last Login" header
|
||||||
|
|
||||||
|
if historyManager != nil {
|
||||||
|
for _, host := range hosts {
|
||||||
|
if lastConnect, exists := historyManager.GetLastConnectionTime(host.Name); exists {
|
||||||
|
timeStr := formatTimeAgo(lastConnect)
|
||||||
|
if len(timeStr) > maxLength {
|
||||||
|
maxLength = len(timeStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add some padding (2 characters) for better visual spacing
|
||||||
|
maxLength += 2
|
||||||
|
|
||||||
|
// Cap the maximum width to avoid extremely wide columns
|
||||||
|
if maxLength > 20 {
|
||||||
|
maxLength = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxLength
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateInfoColumnWidth calculates the optimal width for the combined Info column
|
||||||
|
// based on the longest combined tags and history string, with a minimum of 12 and maximum of 60 characters
|
||||||
|
// enterEditMode initializes edit mode for a specific host
|
||||||
|
|
||||||
// NewModel creates a new TUI model with the given SSH hosts
|
// NewModel creates a new TUI model with the given SSH hosts
|
||||||
func NewModel(hosts []config.SSHHost) Model {
|
func NewModel(hosts []config.SSHHost) Model {
|
||||||
// Sort hosts alphabetically by name
|
// Initialize history manager
|
||||||
sortedHosts := sortHostsByName(hosts)
|
historyManager, err := history.NewHistoryManager()
|
||||||
|
if err != nil {
|
||||||
|
// Log error but continue without history functionality
|
||||||
|
fmt.Printf("Warning: Could not initialize history manager: %v\n", err)
|
||||||
|
historyManager = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the model with default sorting by name
|
||||||
|
m := Model{
|
||||||
|
hosts: hosts,
|
||||||
|
historyManager: historyManager,
|
||||||
|
sortMode: SortByName,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort hosts based on default sort mode
|
||||||
|
sortedHosts := m.sortHosts(hosts)
|
||||||
|
|
||||||
// Create search input
|
// Create search input
|
||||||
ti := textinput.New()
|
ti := textinput.New()
|
||||||
@ -317,6 +510,9 @@ func NewModel(hosts []config.SSHHost) Model {
|
|||||||
|
|
||||||
// Calculate optimal width for the Tags column
|
// Calculate optimal width for the Tags column
|
||||||
tagsWidth := calculateTagsColumnWidth(sortedHosts)
|
tagsWidth := calculateTagsColumnWidth(sortedHosts)
|
||||||
|
|
||||||
|
// Calculate optimal width for the Last Login column
|
||||||
|
lastLoginWidth := calculateLastLoginColumnWidth(sortedHosts, historyManager)
|
||||||
|
|
||||||
// Create table columns
|
// Create table columns
|
||||||
columns := []table.Column{
|
columns := []table.Column{
|
||||||
@ -325,6 +521,7 @@ func NewModel(hosts []config.SSHHost) Model {
|
|||||||
{Title: "User", Width: 12},
|
{Title: "User", Width: 12},
|
||||||
{Title: "Port", Width: 6},
|
{Title: "Port", Width: 6},
|
||||||
{Title: "Tags", Width: tagsWidth},
|
{Title: "Tags", Width: tagsWidth},
|
||||||
|
{Title: "Last Login", Width: lastLoginWidth},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert hosts to table rows
|
// Convert hosts to table rows
|
||||||
@ -341,12 +538,21 @@ func NewModel(hosts []config.SSHHost) Model {
|
|||||||
tagsStr = strings.Join(formattedTags, " ")
|
tagsStr = strings.Join(formattedTags, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format last login info
|
||||||
|
var lastLoginStr string
|
||||||
|
if historyManager != nil {
|
||||||
|
if lastConnect, exists := historyManager.GetLastConnectionTime(host.Name); exists {
|
||||||
|
lastLoginStr = formatTimeAgo(lastConnect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rows = append(rows, table.Row{
|
rows = append(rows, table.Row{
|
||||||
host.Name,
|
host.Name,
|
||||||
host.Hostname,
|
host.Hostname,
|
||||||
host.User,
|
host.User,
|
||||||
host.Port,
|
host.Port,
|
||||||
tagsStr,
|
tagsStr,
|
||||||
|
lastLoginStr,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -380,13 +586,12 @@ func NewModel(hosts []config.SSHHost) Model {
|
|||||||
Bold(false)
|
Bold(false)
|
||||||
t.SetStyles(s)
|
t.SetStyles(s)
|
||||||
|
|
||||||
return Model{
|
// Update the model with the table and other properties
|
||||||
table: t,
|
m.table = t
|
||||||
searchInput: ti,
|
m.searchInput = ti
|
||||||
hosts: sortedHosts,
|
m.filteredHosts = sortedHosts
|
||||||
filteredHosts: sortedHosts,
|
|
||||||
searchMode: false,
|
return m
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunInteractiveMode starts the interactive TUI
|
// RunInteractiveMode starts the interactive TUI
|
||||||
@ -449,36 +654,37 @@ func RunInteractiveMode(hosts []config.SSHHost) error {
|
|||||||
|
|
||||||
// filterHosts filters hosts based on search query (name or tags)
|
// filterHosts filters hosts based on search query (name or tags)
|
||||||
func (m Model) filterHosts(query string) []config.SSHHost {
|
func (m Model) filterHosts(query string) []config.SSHHost {
|
||||||
if query == "" {
|
|
||||||
return sortHostsByName(m.hosts)
|
|
||||||
}
|
|
||||||
|
|
||||||
query = strings.ToLower(query)
|
|
||||||
var filtered []config.SSHHost
|
var filtered []config.SSHHost
|
||||||
|
|
||||||
for _, host := range m.hosts {
|
if query == "" {
|
||||||
// Check host name
|
filtered = m.hosts
|
||||||
if strings.Contains(strings.ToLower(host.Name), query) {
|
} else {
|
||||||
filtered = append(filtered, host)
|
query = strings.ToLower(query)
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check hostname
|
for _, host := range m.hosts {
|
||||||
if strings.Contains(strings.ToLower(host.Hostname), query) {
|
// Check host name
|
||||||
filtered = append(filtered, host)
|
if strings.Contains(strings.ToLower(host.Name), query) {
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check tags
|
|
||||||
for _, tag := range host.Tags {
|
|
||||||
if strings.Contains(strings.ToLower(tag), query) {
|
|
||||||
filtered = append(filtered, host)
|
filtered = append(filtered, host)
|
||||||
break
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check hostname
|
||||||
|
if strings.Contains(strings.ToLower(host.Hostname), query) {
|
||||||
|
filtered = append(filtered, host)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check tags
|
||||||
|
for _, tag := range host.Tags {
|
||||||
|
if strings.Contains(strings.ToLower(tag), query) {
|
||||||
|
filtered = append(filtered, host)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sortHostsByName(filtered)
|
return m.sortHosts(filtered)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateTableRows updates the table with filtered hosts
|
// updateTableRows updates the table with filtered hosts
|
||||||
@ -489,10 +695,7 @@ func (m *Model) updateTableRows() {
|
|||||||
hostsToShow = m.hosts
|
hostsToShow = m.hosts
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort hosts alphabetically by name
|
for _, host := range hostsToShow {
|
||||||
sortedHosts := sortHostsByName(hostsToShow)
|
|
||||||
|
|
||||||
for _, host := range sortedHosts {
|
|
||||||
// Format tags for display
|
// Format tags for display
|
||||||
var tagsStr string
|
var tagsStr string
|
||||||
if len(host.Tags) > 0 {
|
if len(host.Tags) > 0 {
|
||||||
@ -504,12 +707,21 @@ func (m *Model) updateTableRows() {
|
|||||||
tagsStr = strings.Join(formattedTags, " ")
|
tagsStr = strings.Join(formattedTags, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format last login info
|
||||||
|
var lastLoginStr string
|
||||||
|
if m.historyManager != nil {
|
||||||
|
if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists {
|
||||||
|
lastLoginStr = formatTimeAgo(lastConnect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rows = append(rows, table.Row{
|
rows = append(rows, table.Row{
|
||||||
host.Name,
|
host.Name,
|
||||||
host.Hostname,
|
host.Hostname,
|
||||||
host.User,
|
host.User,
|
||||||
host.Port,
|
host.Port,
|
||||||
tagsStr,
|
tagsStr,
|
||||||
|
lastLoginStr,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user