feat: add SSH connection history with TUI sorting by last login

This commit is contained in:
Gu1llaum-3 2025-09-02 09:17:34 +02:00
parent 534b7d9a6c
commit 01f2b4e6be
2 changed files with 478 additions and 58 deletions

208
internal/history/history.go Normal file
View 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
}

View File

@ -5,8 +5,10 @@ import (
"os/exec"
"sort"
"strings"
"time"
"sshm/internal/config"
"sshm/internal/history"
"github.com/charmbracelet/bubbles/table"
"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 {
table table.Model
searchInput textinput.Model
hosts []config.SSHHost
filteredHosts []config.SSHHost
searchMode bool
deleteMode bool
deleteHost string
exitAction string
exitHostName string
table table.Model
searchInput textinput.Model
hosts []config.SSHHost
filteredHosts []config.SSHHost
searchMode bool
deleteMode bool
deleteHost string
exitAction string
exitHostName string
historyManager *history.HistoryManager
sortMode SortMode
}
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()
if len(selected) > 0 {
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 tea.Quit()
})
@ -170,24 +202,65 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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
if m.searchMode {
oldValue := m.searchInput.Value()
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 {
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
}
@ -204,11 +277,17 @@ func (m Model) View() string {
// Add search bar (always visible) with appropriate style based on focus
searchPrompt := "Search (/ to focus, Tab to switch): "
if m.searchMode {
view.WriteString(searchStyleFocused.Render(searchPrompt+m.searchInput.View()) + "\n\n")
view.WriteString(searchStyleFocused.Render(searchPrompt+m.searchInput.View()) + "\n")
} 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
if m.searchMode {
// Table is not focused, use gray border
@ -226,7 +305,8 @@ func (m Model) View() string {
// Add help text
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 {
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()
}
// 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
func sortHostsByName(hosts []config.SSHHost) []config.SSHHost {
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
// 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 {
maxLength := 8 // Minimum width to accommodate the "Tags" header
@ -294,17 +390,114 @@ func calculateTagsColumnWidth(hosts []config.SSHHost) int {
maxLength += 2
// Cap the maximum width to avoid extremely wide columns
if maxLength > 50 {
maxLength = 50
if maxLength > 40 {
maxLength = 40
}
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
func NewModel(hosts []config.SSHHost) Model {
// Sort hosts alphabetically by name
sortedHosts := sortHostsByName(hosts)
// Initialize history manager
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
ti := textinput.New()
@ -317,6 +510,9 @@ func NewModel(hosts []config.SSHHost) Model {
// Calculate optimal width for the Tags column
tagsWidth := calculateTagsColumnWidth(sortedHosts)
// Calculate optimal width for the Last Login column
lastLoginWidth := calculateLastLoginColumnWidth(sortedHosts, historyManager)
// Create table columns
columns := []table.Column{
@ -325,6 +521,7 @@ func NewModel(hosts []config.SSHHost) Model {
{Title: "User", Width: 12},
{Title: "Port", Width: 6},
{Title: "Tags", Width: tagsWidth},
{Title: "Last Login", Width: lastLoginWidth},
}
// Convert hosts to table rows
@ -341,12 +538,21 @@ func NewModel(hosts []config.SSHHost) Model {
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{
host.Name,
host.Hostname,
host.User,
host.Port,
tagsStr,
lastLoginStr,
})
}
@ -380,13 +586,12 @@ func NewModel(hosts []config.SSHHost) Model {
Bold(false)
t.SetStyles(s)
return Model{
table: t,
searchInput: ti,
hosts: sortedHosts,
filteredHosts: sortedHosts,
searchMode: false,
}
// Update the model with the table and other properties
m.table = t
m.searchInput = ti
m.filteredHosts = sortedHosts
return m
}
// 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)
func (m Model) filterHosts(query string) []config.SSHHost {
if query == "" {
return sortHostsByName(m.hosts)
}
query = strings.ToLower(query)
var filtered []config.SSHHost
for _, host := range m.hosts {
// Check host name
if strings.Contains(strings.ToLower(host.Name), query) {
filtered = append(filtered, host)
continue
}
if query == "" {
filtered = m.hosts
} else {
query = strings.ToLower(query)
// 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) {
for _, host := range m.hosts {
// Check host name
if strings.Contains(strings.ToLower(host.Name), query) {
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
@ -489,10 +695,7 @@ func (m *Model) updateTableRows() {
hostsToShow = m.hosts
}
// Sort hosts alphabetically by name
sortedHosts := sortHostsByName(hostsToShow)
for _, host := range sortedHosts {
for _, host := range hostsToShow {
// Format tags for display
var tagsStr string
if len(host.Tags) > 0 {
@ -504,12 +707,21 @@ func (m *Model) updateTableRows() {
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{
host.Name,
host.Hostname,
host.User,
host.Port,
tagsStr,
lastLoginStr,
})
}