From cad28d1417136ba7431e7dc151445e18b30e95a4 Mon Sep 17 00:00:00 2001 From: pycook Date: Mon, 11 Aug 2025 20:13:00 +0800 Subject: [PATCH] feat(backend): add recent command for ssh srv --- backend/internal/repository/session.go | 51 +++++++ backend/internal/sshsrv/assetlist/table.go | 164 +++++++++++++++++++-- backend/internal/sshsrv/view.go | 70 ++++++++- 3 files changed, 271 insertions(+), 14 deletions(-) diff --git a/backend/internal/repository/session.go b/backend/internal/repository/session.go index edb2502..b86957c 100644 --- a/backend/internal/repository/session.go +++ b/backend/internal/repository/session.go @@ -22,6 +22,8 @@ type SessionRepository interface { GetSessionCmdCounts(ctx context.Context, sessionIds []string) (map[string]int64, error) GetOnlineSessionByID(ctx context.Context, sessionID string) (*gsession.Session, error) GetSshParserCommands(ctx context.Context, cmdIDs []int) ([]*model.Command, error) + // GetRecentSessionsByUser retrieves recent sessions deduplicated by asset_id and account_id combination + GetRecentSessionsByUser(ctx context.Context, uid int, limit int) ([]*model.Session, error) } type sessionRepository struct{} @@ -173,3 +175,52 @@ func (r *sessionRepository) GetSshParserCommands(ctx context.Context, cmdIDs []i Error return commands, err } + +// GetRecentSessionsByUser retrieves recent sessions for a user, deduplicated by asset_id and account_id +func (r *sessionRepository) GetRecentSessionsByUser(ctx context.Context, uid int, limit int) ([]*model.Session, error) { + var sessions []*model.Session + + // First, get the MAX session ID for each asset+account combination + // This approach avoids LIMIT in subquery which MySQL doesn't support + type MaxSession struct { + AssetId int + AccountId int + MaxId int + } + + var maxSessions []MaxSession + err := dbpkg.DB.Model(&model.Session{}). + Select("asset_id, account_id, MAX(id) as max_id"). + Where("uid = ?", uid). + Where("asset_id > 0"). + Where("account_id > 0"). + Group("asset_id, account_id"). + Find(&maxSessions).Error + + if err != nil { + return nil, err + } + + // Extract the IDs + var sessionIds []int + for _, ms := range maxSessions { + sessionIds = append(sessionIds, ms.MaxId) + } + + if len(sessionIds) == 0 { + return sessions, nil + } + + // Get the full session records for those IDs + err = dbpkg.DB.Model(&model.Session{}). + Where("id IN ?", sessionIds). + Order("created_at DESC"). + Limit(limit). + Find(&sessions).Error + + if err != nil { + return nil, err + } + + return sessions, nil +} diff --git a/backend/internal/sshsrv/assetlist/table.go b/backend/internal/sshsrv/assetlist/table.go index 9c73b23..0563057 100644 --- a/backend/internal/sshsrv/assetlist/table.go +++ b/backend/internal/sshsrv/assetlist/table.go @@ -2,7 +2,9 @@ package assetlist import ( "fmt" + "strconv" "strings" + "time" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/table" @@ -10,6 +12,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/samber/lo" + "github.com/veops/oneterm/internal/model" "github.com/veops/oneterm/internal/sshsrv/icons" ) @@ -84,12 +87,13 @@ var DefaultTableKeyMap = TableKeyMap{ // Asset represents a connection asset type Asset struct { - Protocol string - Command string - User string - Host string - Port string - Info [3]int // [accountId, assetId, port] + Protocol string + Command string + User string + Host string + Port string + Info [3]int // [accountId, assetId, port] + LastLogin *time.Time // Optional: last login time for recent sessions } // Model represents the asset list table model @@ -104,6 +108,7 @@ type Model struct { focused bool keyMap TableKeyMap showHelp bool + isRecent bool // Whether this is a recent sessions table } // New creates a new asset list table @@ -364,7 +369,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { // View renders the table func (m Model) View() string { resetCursor := "\r\033[0G" - title := titleStyle.Render("🗂️ Available Assets") + title := titleStyle.Render(lo.Ternary(m.isRecent, "📊 Recent Sessions", "🗂️ Available Assets")) // Filter indicator or input filterInfo := "" @@ -379,9 +384,10 @@ func (m Model) View() string { } // Asset count - count := fmt.Sprintf("%d assets", len(m.filteredAssets)) + countText := lo.Ternary(m.isRecent, "sessions", "assets") + count := fmt.Sprintf("%d %s", len(m.filteredAssets), countText) if m.filter != "" && len(m.filteredAssets) != len(m.assets) { - count = fmt.Sprintf("%d of %d assets", len(m.filteredAssets), len(m.assets)) + count = fmt.Sprintf("%d of %d %s", len(m.filteredAssets), len(m.assets), countText) } countStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#8c8c8c")). // Secondary text @@ -529,3 +535,143 @@ func (m *Model) SetFocus(focused bool) { func (m Model) IsFilterActive() bool { return m.filterModel.Active() } + +// NewRecentSessions creates a new asset list table from recent sessions +func NewRecentSessions(sessions []*model.Session, combines map[string][3]int, width, height int) Model { + // Convert sessions to asset list with last login time + assetList := make([]Asset, 0, len(sessions)) + for _, session := range sessions { + // Parse protocol + protocolParts := strings.Split(session.Protocol, ":") + protocol := protocolParts[0] + port := "" + if len(protocolParts) > 1 { + port = protocolParts[1] + } + + // Parse asset name + assetName := session.AssetInfo + if parts := strings.Split(assetName, "("); len(parts) > 0 { + assetName = strings.TrimSpace(parts[0]) + } + + // Parse account name + userName := session.AccountInfo + if parts := strings.Split(userName, "("); len(parts) > 0 { + userName = strings.TrimSpace(parts[0]) + } + + // Build command string + cmd := fmt.Sprintf("%s %s@%s", protocol, userName, assetName) + if port != "" && port != lo.Ternary(protocol == "ssh", "22", lo.Ternary(protocol == "telnet", "23", lo.Ternary(protocol == "redis", "6379", lo.Ternary(protocol == "mysql", "3306", lo.Ternary(protocol == "postgresql", "5432", lo.Ternary(protocol == "mongodb", "27017", port)))))) { + cmd = fmt.Sprintf("%s:%s", cmd, port) + } + + // Look up the asset info from combines map if available + info := [3]int{session.AccountId, session.AssetId, lo.Ternary(port != "", lo.Must(strconv.Atoi(port)), 0)} + if val, ok := combines[cmd]; ok { + info = val + } + + assetList = append(assetList, Asset{ + Protocol: protocol, + Command: cmd, + User: userName, + Host: assetName, + Port: port, + Info: info, + LastLogin: &session.CreatedAt, // Store last login time + }) + } + + // Create table columns with Last Login column + columns := []table.Column{ + {Title: "Protocol", Width: 12}, + {Title: "User", Width: 15}, + {Title: "Host", Width: 25}, + {Title: "Port", Width: 8}, + {Title: "Last Login", Width: 18}, + {Title: "Command", Width: 35}, + } + + // Create table rows + rows := make([]table.Row, len(assetList)) + for i, asset := range assetList { + icon := icons.GetProtocolIcon(asset.Protocol) + timeAgo := formatTimeAgo(*asset.LastLogin) + rows[i] = table.Row{ + fmt.Sprintf("%s %s", icon, strings.ToUpper(asset.Protocol)), + asset.User, + asset.Host, + lo.Ternary(asset.Port != "", asset.Port, icons.GetDefaultPort(asset.Protocol)), + timeAgo, + asset.Command, + } + } + + // Calculate viewport height + viewportHeight := len(assetList) + 2 + if viewportHeight < 3 { + viewportHeight = 3 + } + + maxViewportHeight := height - 10 + if maxViewportHeight < 5 { + maxViewportHeight = 5 + } + + if viewportHeight > maxViewportHeight { + viewportHeight = maxViewportHeight + } + + t := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(viewportHeight), + ) + + // Style the table + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#b1c9ff")). + BorderBottom(true). + Bold(true). + Foreground(lipgloss.Color("#2f54eb")) + s.Selected = selectedStyle + t.SetStyles(s) + + return Model{ + table: t, + assets: assetList, + filteredAssets: assetList, + filterModel: NewFilter(), + width: width, + height: height, + focused: false, + keyMap: DefaultTableKeyMap, + showHelp: true, + isRecent: true, // Mark as recent sessions table + } +} + +// formatTimeAgo formats time as relative time +func formatTimeAgo(t time.Time) string { + duration := time.Since(t) + + if duration < time.Minute { + return "just now" + } else if duration < time.Hour { + minutes := int(duration.Minutes()) + return fmt.Sprintf("%d min%s ago", minutes, lo.Ternary(minutes > 1, "s", "")) + } else if duration < 24*time.Hour { + hours := int(duration.Hours()) + return fmt.Sprintf("%d hour%s ago", hours, lo.Ternary(hours > 1, "s", "")) + } else if duration < 7*24*time.Hour { + days := int(duration.Hours() / 24) + return fmt.Sprintf("%d day%s ago", days, lo.Ternary(days > 1, "s", "")) + } else { + return t.Format("2006-01-02 15:04") + } +} diff --git a/backend/internal/sshsrv/view.go b/backend/internal/sshsrv/view.go index 31a9451..a901587 100644 --- a/backend/internal/sshsrv/view.go +++ b/backend/internal/sshsrv/view.go @@ -236,6 +236,42 @@ func (m *view) Update(msg tea.Msg) (tea.Model, tea.Cmd) { sizeMsg := tea.WindowSizeMsg{Width: width, Height: height} m.assetTable, _ = m.assetTable.Update(sizeMsg) return m, tea.ClearScreen + case cmd == "recent" || cmd == "r" || cmd == `\r`: + // Show recent sessions in table mode + pty, _, _ := m.Sess.Pty() + width := pty.Window.Width + height := pty.Window.Height + if width <= 0 { + width = 80 + } + if height <= 0 { + height = 24 + } + + // Get recent sessions + sessions, err := m.getRecentSessions() + if err != nil { + return m, tea.Sequence( + hisCmd, + tea.Printf("\n %s Failed to fetch recent sessions: %v\n\n", errStyle.Render("⚠️"), err), + tea.Printf("%s", prompt), + ) + } + + if len(sessions) == 0 { + return m, tea.Sequence( + hisCmd, + tea.Printf("\n %s\n\n", hintStyle.Render("📋 No recent sessions found")), + tea.Printf("%s", prompt), + ) + } + + // Create recent sessions table + m.assetTable = assetlist.NewRecentSessions(sessions, m.combines, width, height) + m.mode = modeTable + sizeMsg := tea.WindowSizeMsg{Width: width, Height: height} + m.assetTable, _ = m.assetTable.Update(sizeMsg) + return m, tea.ClearScreen } // Try to handle as connection command @@ -475,6 +511,7 @@ func (m *view) helpText() string { • postgresql user@host - Connect to PostgreSQL database • telnet user@host - Connect via Telnet • list/ls/table - Show assets in interactive table + • recent or r or \r - Show recent sessions with last login time • help or \h or \? - Show this help message • clear or \c - Clear screen • exit/quit or \q - Exit OneTerm @@ -553,11 +590,28 @@ func (m *view) assetOverview() string { } // Provide a better tip with modern styling - tipStyle := lipgloss.NewStyle(). - Foreground(colors.PrimaryColor2). - PaddingTop(1) - - return tipStyle.Render("→ Type 'ls' for interactive mode or start typing to connect") + textStyle := lipgloss.NewStyle(). + Foreground(colors.TextSecondary) + + cmdStyle := lipgloss.NewStyle(). + Foreground(colors.PrimaryColor9). + Bold(true) + + arrowStyle := lipgloss.NewStyle(). + Foreground(colors.PrimaryColor2) + + // Build the tip text with each part styled correctly + parts := []string{ + arrowStyle.Render("→"), + textStyle.Render("Type"), + cmdStyle.Render("'ls'"), + textStyle.Render("for interactive mode,"), + cmdStyle.Render("'recent'"), + textStyle.Render("for recent sessions, or start typing to connect"), + } + + fullTip := strings.Join(parts, " ") + return lipgloss.NewStyle().PaddingTop(1).Render(fullTip) } func (m *view) refresh() { @@ -648,6 +702,12 @@ func (m *view) magicn() tea.Msg { return nil } +func (m *view) getRecentSessions() ([]*model.Session, error) { + // Use repository to get recent sessions, deduplicated by asset_id and account_id + sessionRepo := repository.NewSessionRepository() + return sessionRepo.GetRecentSessionsByUser(m.gctx, m.currentUser.GetUid(), 20) +} + func (m *view) RecordHisCmd() { k := fmt.Sprintf(hisCmdsFmt, m.currentUser.GetUid()) cache.RC.RPush(m.Ctx, k, m.cmds)