feat: add session indicators and top exec commands to dashboard

Add visual indicators to session tables (replay badge when events exist,
exec badge for exec sessions) and a new "Top Exec Commands" table on the
dashboard. Includes EventCount field on Session, GetTopExecCommands on
Store interface, and truncateCommand template function.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 19:38:10 +01:00
parent 0b44d1c83f
commit 4f10a8a422
9 changed files with 243 additions and 25 deletions

View File

@@ -336,12 +336,20 @@ func (m *MemoryStore) GetRecentSessions(_ context.Context, limit int, activeOnly
m.mu.Lock()
defer m.mu.Unlock()
// Count events per session.
eventCounts := make(map[string]int)
for _, e := range m.SessionEvents {
eventCounts[e.SessionID]++
}
var sessions []Session
for _, s := range m.Sessions {
if activeOnly && s.DisconnectedAt != nil {
continue
}
sessions = append(sessions, *s)
sess := *s
sess.EventCount = eventCounts[s.ID]
sessions = append(sessions, sess)
}
sort.Slice(sessions, func(i, j int) bool {
return sessions[i].ConnectedAt.After(sessions[j].ConnectedAt)
@@ -352,6 +360,30 @@ func (m *MemoryStore) GetRecentSessions(_ context.Context, limit int, activeOnly
return sessions, nil
}
func (m *MemoryStore) GetTopExecCommands(_ context.Context, limit int) ([]TopEntry, error) {
m.mu.Lock()
defer m.mu.Unlock()
counts := make(map[string]int64)
for _, s := range m.Sessions {
if s.ExecCommand != nil {
counts[*s.ExecCommand]++
}
}
entries := make([]TopEntry, 0, len(counts))
for k, v := range counts {
entries = append(entries, TopEntry{Value: k, Count: v})
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Count > entries[j].Count
})
if limit > 0 && len(entries) > limit {
entries = entries[:limit]
}
return entries, nil
}
func (m *MemoryStore) CloseActiveSessions(_ context.Context, disconnectedAt time.Time) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()

View File

@@ -382,11 +382,11 @@ func (s *SQLiteStore) queryTopN(ctx context.Context, column string, limit int) (
}
func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOnly bool) ([]Session, error) {
query := `SELECT id, ip, country, username, shell_name, connected_at, disconnected_at, human_score, exec_command FROM sessions`
query := `SELECT s.id, s.ip, s.country, s.username, s.shell_name, s.connected_at, s.disconnected_at, s.human_score, s.exec_command, COUNT(e.id) as event_count FROM sessions s LEFT JOIN session_events e ON s.id = e.session_id`
if activeOnly {
query += ` WHERE disconnected_at IS NULL`
query += ` WHERE s.disconnected_at IS NULL`
}
query += ` ORDER BY connected_at DESC LIMIT ?`
query += ` GROUP BY s.id ORDER BY s.connected_at DESC LIMIT ?`
rows, err := s.db.QueryContext(ctx, query, limit)
if err != nil {
@@ -401,7 +401,7 @@ func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOn
var disconnectedAt sql.NullString
var humanScore sql.NullFloat64
var execCommand sql.NullString
if err := rows.Scan(&s.ID, &s.IP, &s.Country, &s.Username, &s.ShellName, &connectedAt, &disconnectedAt, &humanScore, &execCommand); err != nil {
if err := rows.Scan(&s.ID, &s.IP, &s.Country, &s.Username, &s.ShellName, &connectedAt, &disconnectedAt, &humanScore, &execCommand, &s.EventCount); err != nil {
return nil, fmt.Errorf("scanning session: %w", err)
}
s.ConnectedAt, _ = time.Parse(time.RFC3339, connectedAt)
@@ -420,6 +420,30 @@ func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOn
return sessions, rows.Err()
}
func (s *SQLiteStore) GetTopExecCommands(ctx context.Context, limit int) ([]TopEntry, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT exec_command, COUNT(*) as total
FROM sessions
WHERE exec_command IS NOT NULL
GROUP BY exec_command
ORDER BY total DESC
LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("querying top exec commands: %w", err)
}
defer func() { _ = rows.Close() }()
var entries []TopEntry
for rows.Next() {
var e TopEntry
if err := rows.Scan(&e.Value, &e.Count); err != nil {
return nil, fmt.Errorf("scanning top exec commands: %w", err)
}
entries = append(entries, e)
}
return entries, rows.Err()
}
func (s *SQLiteStore) CloseActiveSessions(ctx context.Context, disconnectedAt time.Time) (int64, error) {
res, err := s.db.ExecContext(ctx, `
UPDATE sessions SET disconnected_at = ? WHERE disconnected_at IS NULL`,

View File

@@ -204,6 +204,79 @@ func TestDeleteRecordsBefore(t *testing.T) {
}
}
func TestGetTopExecCommands(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
// Create sessions with exec commands.
for range 3 {
id, err := store.CreateSession(ctx, "10.0.0.1", "root", "", "")
if err != nil {
t.Fatalf("creating session: %v", err)
}
if err := store.SetExecCommand(ctx, id, "uname -a"); err != nil {
t.Fatalf("setting exec command: %v", err)
}
}
for range 2 {
id, err := store.CreateSession(ctx, "10.0.0.2", "admin", "", "")
if err != nil {
t.Fatalf("creating session: %v", err)
}
if err := store.SetExecCommand(ctx, id, "cat /etc/passwd"); err != nil {
t.Fatalf("setting exec command: %v", err)
}
}
// Session without exec command — should not appear.
if _, err := store.CreateSession(ctx, "10.0.0.3", "test", "bash", ""); err != nil {
t.Fatalf("creating session: %v", err)
}
entries, err := store.GetTopExecCommands(ctx, 10)
if err != nil {
t.Fatalf("GetTopExecCommands: %v", err)
}
if len(entries) != 2 {
t.Fatalf("len = %d, want 2", len(entries))
}
if entries[0].Value != "uname -a" || entries[0].Count != 3 {
t.Errorf("entries[0] = %+v, want uname -a:3", entries[0])
}
if entries[1].Value != "cat /etc/passwd" || entries[1].Count != 2 {
t.Errorf("entries[1] = %+v, want cat /etc/passwd:2", entries[1])
}
}
func TestGetRecentSessionsEventCount(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "")
if err != nil {
t.Fatalf("creating session: %v", err)
}
// Add some events.
events := []SessionEvent{
{SessionID: id, Timestamp: time.Now(), Direction: 0, Data: []byte("ls\n")},
{SessionID: id, Timestamp: time.Now(), Direction: 1, Data: []byte("file1\n")},
}
if err := store.AppendSessionEvents(ctx, events); err != nil {
t.Fatalf("appending events: %v", err)
}
sessions, err := store.GetRecentSessions(ctx, 10, false)
if err != nil {
t.Fatalf("GetRecentSessions: %v", err)
}
if len(sessions) != 1 {
t.Fatalf("len = %d, want 1", len(sessions))
}
if sessions[0].EventCount != 2 {
t.Errorf("EventCount = %d, want 2", sessions[0].EventCount)
}
}
func TestNewSQLiteStoreCreatesFile(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "test.db")
store, err := NewSQLiteStore(dbPath)

View File

@@ -28,6 +28,7 @@ type Session struct {
DisconnectedAt *time.Time
HumanScore *float64
ExecCommand *string
EventCount int
}
// SessionLog represents a single log entry for a session.
@@ -102,6 +103,9 @@ type Store interface {
// GetTopCountries returns the top N countries by total attempt count.
GetTopCountries(ctx context.Context, limit int) ([]TopEntry, error)
// GetTopExecCommands returns the top N exec commands by session count.
GetTopExecCommands(ctx context.Context, limit int) ([]TopEntry, error)
// GetRecentSessions returns the most recent sessions ordered by connected_at DESC.
// If activeOnly is true, only sessions with no disconnected_at are returned.
GetRecentSessions(ctx context.Context, limit int, activeOnly bool) ([]Session, error)