mirror of
https://github.com/veops/oneterm.git
synced 2026-09-02 22:56:21 +08:00
feat(backend): add recent command for ssh srv
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user