feat: add server-side session filtering with input bytes and human score
Replace client-side session table filtering with server-side filtering via a new /fragments/recent-sessions htmx endpoint. Add InputBytes column to session tables, Human score > 0 checkbox filter, and Sort by Input Bytes option to help identify sessions with actual shell interaction. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -336,10 +336,26 @@ func (m *MemoryStore) GetRecentSessions(_ context.Context, limit int, activeOnly
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
// Count events per session.
|
return m.collectSessions(limit, activeOnly, DashboardFilter{}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MemoryStore) GetFilteredSessions(_ context.Context, limit int, activeOnly bool, f DashboardFilter) ([]Session, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
return m.collectSessions(limit, activeOnly, f), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectSessions gathers sessions matching filter criteria. Must be called with m.mu held.
|
||||||
|
func (m *MemoryStore) collectSessions(limit int, activeOnly bool, f DashboardFilter) []Session {
|
||||||
|
// Compute event counts and input bytes per session.
|
||||||
eventCounts := make(map[string]int)
|
eventCounts := make(map[string]int)
|
||||||
|
inputBytes := make(map[string]int64)
|
||||||
for _, e := range m.SessionEvents {
|
for _, e := range m.SessionEvents {
|
||||||
eventCounts[e.SessionID]++
|
eventCounts[e.SessionID]++
|
||||||
|
if e.Direction == 0 {
|
||||||
|
inputBytes[e.SessionID] += int64(len(e.Data))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var sessions []Session
|
var sessions []Session
|
||||||
@@ -347,17 +363,54 @@ func (m *MemoryStore) GetRecentSessions(_ context.Context, limit int, activeOnly
|
|||||||
if activeOnly && s.DisconnectedAt != nil {
|
if activeOnly && s.DisconnectedAt != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if !matchesSessionFilter(s, f) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
sess := *s
|
sess := *s
|
||||||
sess.EventCount = eventCounts[s.ID]
|
sess.EventCount = eventCounts[s.ID]
|
||||||
|
sess.InputBytes = inputBytes[s.ID]
|
||||||
sessions = append(sessions, sess)
|
sessions = append(sessions, sess)
|
||||||
}
|
}
|
||||||
sort.Slice(sessions, func(i, j int) bool {
|
|
||||||
return sessions[i].ConnectedAt.After(sessions[j].ConnectedAt)
|
if f.SortBy == "input_bytes" {
|
||||||
})
|
sort.Slice(sessions, func(i, j int) bool {
|
||||||
|
return sessions[i].InputBytes > sessions[j].InputBytes
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
sort.Slice(sessions, func(i, j int) bool {
|
||||||
|
return sessions[i].ConnectedAt.After(sessions[j].ConnectedAt)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if limit > 0 && len(sessions) > limit {
|
if limit > 0 && len(sessions) > limit {
|
||||||
sessions = sessions[:limit]
|
sessions = sessions[:limit]
|
||||||
}
|
}
|
||||||
return sessions, nil
|
return sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesSessionFilter returns true if the session matches the given filter.
|
||||||
|
func matchesSessionFilter(s *Session, f DashboardFilter) bool {
|
||||||
|
if f.Since != nil && s.ConnectedAt.Before(*f.Since) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if f.Until != nil && s.ConnectedAt.After(*f.Until) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if f.IP != "" && s.IP != f.IP {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if f.Country != "" && s.Country != f.Country {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if f.Username != "" && s.Username != f.Username {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if f.HumanScoreAboveZero {
|
||||||
|
if s.HumanScore == nil || *s.HumanScore <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MemoryStore) GetTopExecCommands(_ context.Context, limit int) ([]TopEntry, error) {
|
func (m *MemoryStore) GetTopExecCommands(_ context.Context, limit int) ([]TopEntry, error) {
|
||||||
|
|||||||
@@ -383,40 +383,104 @@ func (s *SQLiteStore) queryTopN(ctx context.Context, column string, limit int) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOnly bool) ([]Session, error) {
|
func (s *SQLiteStore) GetRecentSessions(ctx context.Context, limit int, activeOnly bool) ([]Session, error) {
|
||||||
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`
|
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, COALESCE(SUM(CASE WHEN e.direction = 0 THEN LENGTH(e.data) ELSE 0 END), 0) as input_bytes FROM sessions s LEFT JOIN session_events e ON s.id = e.session_id`
|
||||||
if activeOnly {
|
if activeOnly {
|
||||||
query += ` WHERE s.disconnected_at IS NULL`
|
query += ` WHERE s.disconnected_at IS NULL`
|
||||||
}
|
}
|
||||||
query += ` GROUP BY s.id ORDER BY s.connected_at DESC LIMIT ?`
|
query += ` GROUP BY s.id ORDER BY s.connected_at DESC LIMIT ?`
|
||||||
|
|
||||||
rows, err := s.db.QueryContext(ctx, query, limit)
|
return s.scanSessions(ctx, query, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSessionWhereClause builds a dynamic WHERE clause for session filtering.
|
||||||
|
func buildSessionWhereClause(f DashboardFilter, activeOnly bool) (string, []any) {
|
||||||
|
var clauses []string
|
||||||
|
var args []any
|
||||||
|
|
||||||
|
if activeOnly {
|
||||||
|
clauses = append(clauses, "s.disconnected_at IS NULL")
|
||||||
|
}
|
||||||
|
if f.Since != nil {
|
||||||
|
clauses = append(clauses, "s.connected_at >= ?")
|
||||||
|
args = append(args, f.Since.UTC().Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if f.Until != nil {
|
||||||
|
clauses = append(clauses, "s.connected_at <= ?")
|
||||||
|
args = append(args, f.Until.UTC().Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
if f.IP != "" {
|
||||||
|
clauses = append(clauses, "s.ip = ?")
|
||||||
|
args = append(args, f.IP)
|
||||||
|
}
|
||||||
|
if f.Country != "" {
|
||||||
|
clauses = append(clauses, "s.country = ?")
|
||||||
|
args = append(args, f.Country)
|
||||||
|
}
|
||||||
|
if f.Username != "" {
|
||||||
|
clauses = append(clauses, "s.username = ?")
|
||||||
|
args = append(args, f.Username)
|
||||||
|
}
|
||||||
|
if f.HumanScoreAboveZero {
|
||||||
|
clauses = append(clauses, "s.human_score > 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(clauses) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return " WHERE " + strings.Join(clauses, " AND "), args
|
||||||
|
}
|
||||||
|
|
||||||
|
// validSessionSorts maps allowed SortBy values to SQL ORDER BY clauses.
|
||||||
|
var validSessionSorts = map[string]string{
|
||||||
|
"connected_at": "s.connected_at DESC",
|
||||||
|
"input_bytes": "input_bytes DESC",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLiteStore) GetFilteredSessions(ctx context.Context, limit int, activeOnly bool, f DashboardFilter) ([]Session, error) {
|
||||||
|
where, args := buildSessionWhereClause(f, activeOnly)
|
||||||
|
args = append(args, limit)
|
||||||
|
|
||||||
|
orderBy := validSessionSorts["connected_at"]
|
||||||
|
if mapped, ok := validSessionSorts[f.SortBy]; ok {
|
||||||
|
orderBy = mapped
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gosec // where/order clauses built from allowlisted constants, not raw user input
|
||||||
|
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, COALESCE(SUM(CASE WHEN e.direction = 0 THEN LENGTH(e.data) ELSE 0 END), 0) as input_bytes FROM sessions s LEFT JOIN session_events e ON s.id = e.session_id` + where + ` GROUP BY s.id ORDER BY ` + orderBy + ` LIMIT ?`
|
||||||
|
|
||||||
|
return s.scanSessions(ctx, query, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanSessions executes a session query and scans the results.
|
||||||
|
func (s *SQLiteStore) scanSessions(ctx context.Context, query string, args ...any) ([]Session, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("querying recent sessions: %w", err)
|
return nil, fmt.Errorf("querying sessions: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = rows.Close() }()
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
var sessions []Session
|
var sessions []Session
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var s Session
|
var sess Session
|
||||||
var connectedAt string
|
var connectedAt string
|
||||||
var disconnectedAt sql.NullString
|
var disconnectedAt sql.NullString
|
||||||
var humanScore sql.NullFloat64
|
var humanScore sql.NullFloat64
|
||||||
var execCommand sql.NullString
|
var execCommand sql.NullString
|
||||||
if err := rows.Scan(&s.ID, &s.IP, &s.Country, &s.Username, &s.ShellName, &connectedAt, &disconnectedAt, &humanScore, &execCommand, &s.EventCount); err != nil {
|
if err := rows.Scan(&sess.ID, &sess.IP, &sess.Country, &sess.Username, &sess.ShellName, &connectedAt, &disconnectedAt, &humanScore, &execCommand, &sess.EventCount, &sess.InputBytes); err != nil {
|
||||||
return nil, fmt.Errorf("scanning session: %w", err)
|
return nil, fmt.Errorf("scanning session: %w", err)
|
||||||
}
|
}
|
||||||
s.ConnectedAt, _ = time.Parse(time.RFC3339, connectedAt)
|
sess.ConnectedAt, _ = time.Parse(time.RFC3339, connectedAt)
|
||||||
if disconnectedAt.Valid {
|
if disconnectedAt.Valid {
|
||||||
t, _ := time.Parse(time.RFC3339, disconnectedAt.String)
|
t, _ := time.Parse(time.RFC3339, disconnectedAt.String)
|
||||||
s.DisconnectedAt = &t
|
sess.DisconnectedAt = &t
|
||||||
}
|
}
|
||||||
if humanScore.Valid {
|
if humanScore.Valid {
|
||||||
s.HumanScore = &humanScore.Float64
|
sess.HumanScore = &humanScore.Float64
|
||||||
}
|
}
|
||||||
if execCommand.Valid {
|
if execCommand.Valid {
|
||||||
s.ExecCommand = &execCommand.String
|
sess.ExecCommand = &execCommand.String
|
||||||
}
|
}
|
||||||
sessions = append(sessions, s)
|
sessions = append(sessions, sess)
|
||||||
}
|
}
|
||||||
return sessions, rows.Err()
|
return sessions, rows.Err()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type Session struct {
|
|||||||
HumanScore *float64
|
HumanScore *float64
|
||||||
ExecCommand *string
|
ExecCommand *string
|
||||||
EventCount int
|
EventCount int
|
||||||
|
InputBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionLog represents a single log entry for a session.
|
// SessionLog represents a single log entry for a session.
|
||||||
@@ -76,11 +77,13 @@ type CountryCount struct {
|
|||||||
|
|
||||||
// DashboardFilter contains optional filters for dashboard queries.
|
// DashboardFilter contains optional filters for dashboard queries.
|
||||||
type DashboardFilter struct {
|
type DashboardFilter struct {
|
||||||
Since *time.Time
|
Since *time.Time
|
||||||
Until *time.Time
|
Until *time.Time
|
||||||
IP string
|
IP string
|
||||||
Country string
|
Country string
|
||||||
Username string
|
Username string
|
||||||
|
HumanScoreAboveZero bool
|
||||||
|
SortBy string
|
||||||
}
|
}
|
||||||
|
|
||||||
// TopEntry represents a value and its count for top-N queries.
|
// TopEntry represents a value and its count for top-N queries.
|
||||||
@@ -137,6 +140,10 @@ type Store interface {
|
|||||||
// If activeOnly is true, only sessions with no disconnected_at are returned.
|
// If activeOnly is true, only sessions with no disconnected_at are returned.
|
||||||
GetRecentSessions(ctx context.Context, limit int, activeOnly bool) ([]Session, error)
|
GetRecentSessions(ctx context.Context, limit int, activeOnly bool) ([]Session, error)
|
||||||
|
|
||||||
|
// GetFilteredSessions returns sessions matching the given filter, ordered
|
||||||
|
// by the filter's SortBy field (default: connected_at DESC).
|
||||||
|
GetFilteredSessions(ctx context.Context, limit int, activeOnly bool, f DashboardFilter) ([]Session, error)
|
||||||
|
|
||||||
// GetSession returns a single session by ID.
|
// GetSession returns a single session by ID.
|
||||||
GetSession(ctx context.Context, sessionID string) (*Session, error)
|
GetSession(ctx context.Context, sessionID string) (*Session, error)
|
||||||
|
|
||||||
|
|||||||
@@ -700,3 +700,192 @@ func TestGetRecentSessions(t *testing.T) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInputBytes(t *testing.T) {
|
||||||
|
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||||
|
t.Run("counts only input direction", func(t *testing.T) {
|
||||||
|
store := newStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
events := []SessionEvent{
|
||||||
|
{SessionID: id, Timestamp: now, Direction: 0, Data: []byte("ls\n")}, // 3 bytes input
|
||||||
|
{SessionID: id, Timestamp: now.Add(100 * time.Millisecond), Direction: 1, Data: []byte("file1\nfile2\n")}, // 11 bytes output
|
||||||
|
{SessionID: id, Timestamp: now.Add(200 * time.Millisecond), Direction: 0, Data: []byte("pwd\n")}, // 4 bytes input
|
||||||
|
}
|
||||||
|
if err := store.AppendSessionEvents(ctx, events); err != nil {
|
||||||
|
t.Fatalf("AppendSessionEvents: %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))
|
||||||
|
}
|
||||||
|
// Only direction=0 data: "ls\n" (3) + "pwd\n" (4) = 7
|
||||||
|
if sessions[0].InputBytes != 7 {
|
||||||
|
t.Errorf("InputBytes = %d, want 7", sessions[0].InputBytes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("zero when no events", func(t *testing.T) {
|
||||||
|
store := newStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %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].InputBytes != 0 {
|
||||||
|
t.Errorf("InputBytes = %d, want 0", sessions[0].InputBytes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetFilteredSessions(t *testing.T) {
|
||||||
|
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||||
|
t.Run("filter by human score", func(t *testing.T) {
|
||||||
|
store := newStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create two sessions, one with human score > 0.
|
||||||
|
id1, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "CN")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.UpdateHumanScore(ctx, id1, 0.75); err != nil {
|
||||||
|
t.Fatalf("UpdateHumanScore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = store.CreateSession(ctx, "10.0.0.2", "admin", "bash", "US")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions, err := store.GetFilteredSessions(ctx, 50, false, DashboardFilter{HumanScoreAboveZero: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFilteredSessions: %v", err)
|
||||||
|
}
|
||||||
|
if len(sessions) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1", len(sessions))
|
||||||
|
}
|
||||||
|
if sessions[0].ID != id1 {
|
||||||
|
t.Errorf("expected session %s, got %s", id1, sessions[0].ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("sort by input bytes", func(t *testing.T) {
|
||||||
|
store := newStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Session with more input (created first).
|
||||||
|
id1, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := store.AppendSessionEvents(ctx, []SessionEvent{
|
||||||
|
{SessionID: id1, Timestamp: now, Direction: 0, Data: []byte("ls -la /tmp\n")},
|
||||||
|
{SessionID: id1, Timestamp: now.Add(time.Millisecond), Direction: 0, Data: []byte("cat /etc/passwd\n")},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("AppendSessionEvents: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session with less input (created after id1, so would be first by connected_at).
|
||||||
|
// Sleep >1s to ensure different RFC3339 timestamps in SQLite.
|
||||||
|
time.Sleep(1100 * time.Millisecond)
|
||||||
|
id2, err := store.CreateSession(ctx, "10.0.0.2", "admin", "bash", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.AppendSessionEvents(ctx, []SessionEvent{
|
||||||
|
{SessionID: id2, Timestamp: now.Add(2 * time.Second), Direction: 0, Data: []byte("x\n")},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("AppendSessionEvents: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default sort (connected_at DESC) should show id2 first.
|
||||||
|
sessions, err := store.GetFilteredSessions(ctx, 50, false, DashboardFilter{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFilteredSessions: %v", err)
|
||||||
|
}
|
||||||
|
if len(sessions) != 2 {
|
||||||
|
t.Fatalf("len = %d, want 2", len(sessions))
|
||||||
|
}
|
||||||
|
if sessions[0].ID != id2 {
|
||||||
|
t.Errorf("default sort: expected %s first, got %s", id2, sessions[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by input_bytes should show id1 first (more input).
|
||||||
|
sessions, err = store.GetFilteredSessions(ctx, 50, false, DashboardFilter{SortBy: "input_bytes"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFilteredSessions: %v", err)
|
||||||
|
}
|
||||||
|
if len(sessions) != 2 {
|
||||||
|
t.Fatalf("len = %d, want 2", len(sessions))
|
||||||
|
}
|
||||||
|
if sessions[0].ID != id1 {
|
||||||
|
t.Errorf("input_bytes sort: expected %s first, got %s", id1, sessions[0].ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("combined filters", func(t *testing.T) {
|
||||||
|
store := newStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
id1, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash", "CN")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.UpdateHumanScore(ctx, id1, 0.5); err != nil {
|
||||||
|
t.Fatalf("UpdateHumanScore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different country, also has score.
|
||||||
|
id2, err := store.CreateSession(ctx, "10.0.0.2", "admin", "bash", "US")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.UpdateHumanScore(ctx, id2, 0.8); err != nil {
|
||||||
|
t.Fatalf("UpdateHumanScore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same country CN but no score.
|
||||||
|
_, err = store.CreateSession(ctx, "10.0.0.3", "test", "bash", "CN")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter: CN + human score > 0 -> only id1.
|
||||||
|
sessions, err := store.GetFilteredSessions(ctx, 50, false, DashboardFilter{
|
||||||
|
Country: "CN",
|
||||||
|
HumanScoreAboveZero: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetFilteredSessions: %v", err)
|
||||||
|
}
|
||||||
|
if len(sessions) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1", len(sessions))
|
||||||
|
}
|
||||||
|
if sessions[0].ID != id1 {
|
||||||
|
t.Errorf("expected session %s, got %s", id1, sessions[0].ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -125,6 +125,21 @@ func (s *Server) handleFragmentActiveSessions(w http.ResponseWriter, r *http.Req
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleFragmentRecentSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f := parseDashboardFilter(r)
|
||||||
|
sessions, err := s.store.GetFilteredSessions(r.Context(), 50, false, f)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("failed to get filtered sessions", "err", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := s.tmpl.dashboard.ExecuteTemplate(w, "recent_sessions", sessions); err != nil {
|
||||||
|
s.logger.Error("failed to render recent sessions fragment", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type sessionDetailData struct {
|
type sessionDetailData struct {
|
||||||
Session *storage.Session
|
Session *storage.Session
|
||||||
Logs []storage.SessionLog
|
Logs []storage.SessionLog
|
||||||
@@ -201,11 +216,13 @@ func parseDateParam(r *http.Request, name string) *time.Time {
|
|||||||
|
|
||||||
func parseDashboardFilter(r *http.Request) storage.DashboardFilter {
|
func parseDashboardFilter(r *http.Request) storage.DashboardFilter {
|
||||||
return storage.DashboardFilter{
|
return storage.DashboardFilter{
|
||||||
Since: parseDateParam(r, "since"),
|
Since: parseDateParam(r, "since"),
|
||||||
Until: parseDateParam(r, "until"),
|
Until: parseDateParam(r, "until"),
|
||||||
IP: r.URL.Query().Get("ip"),
|
IP: r.URL.Query().Get("ip"),
|
||||||
Country: r.URL.Query().Get("country"),
|
Country: r.URL.Query().Get("country"),
|
||||||
Username: r.URL.Query().Get("username"),
|
Username: r.URL.Query().Get("username"),
|
||||||
|
HumanScoreAboveZero: r.URL.Query().Get("human_score") == "1",
|
||||||
|
SortBy: r.URL.Query().Get("sort"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
var until = form.elements['until'].value;
|
var until = form.elements['until'].value;
|
||||||
if (since) params.set('since', since);
|
if (since) params.set('since', since);
|
||||||
if (until) params.set('until', until);
|
if (until) params.set('until', until);
|
||||||
|
var humanScore = form.elements['human_score'];
|
||||||
|
if (humanScore && humanScore.checked) params.set('human_score', '1');
|
||||||
|
var sortBy = form.elements['sort'];
|
||||||
|
if (sortBy && sortBy.value) params.set('sort', sortBy.value);
|
||||||
return params.toString();
|
return params.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,33 +232,20 @@
|
|||||||
if (val) params.set(name, val);
|
if (val) params.set(name, val);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var humanScore = form.elements['human_score'];
|
||||||
|
if (humanScore && humanScore.checked) params.set('human_score', '1');
|
||||||
|
var sortBy = form.elements['sort'];
|
||||||
|
if (sortBy && sortBy.value) params.set('sort', sortBy.value);
|
||||||
|
|
||||||
var target = document.getElementById('dashboard-content');
|
var target = document.getElementById('dashboard-content');
|
||||||
if (target) {
|
if (target) {
|
||||||
var url = '/fragments/dashboard-content?' + params.toString();
|
var url = '/fragments/dashboard-content?' + params.toString();
|
||||||
htmx.ajax('GET', url, {target: '#dashboard-content', swap: 'innerHTML'});
|
htmx.ajax('GET', url, {target: '#dashboard-content', swap: 'innerHTML'});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client-side filter for recent sessions table
|
// Server-side filter for recent sessions table
|
||||||
filterSessionsTable(form);
|
var sessionsUrl = '/fragments/recent-sessions?' + params.toString();
|
||||||
}
|
htmx.ajax('GET', sessionsUrl, {target: '#recent-sessions-table tbody', swap: 'innerHTML'});
|
||||||
|
|
||||||
function filterSessionsTable(form) {
|
|
||||||
var ip = form.elements['ip'].value.toLowerCase();
|
|
||||||
var country = form.elements['country'].value.toLowerCase();
|
|
||||||
var username = form.elements['username'].value.toLowerCase();
|
|
||||||
|
|
||||||
var rows = document.querySelectorAll('#recent-sessions-table tbody tr');
|
|
||||||
rows.forEach(function(row) {
|
|
||||||
var cells = row.querySelectorAll('td');
|
|
||||||
if (cells.length < 4) { row.style.display = ''; return; }
|
|
||||||
|
|
||||||
var show = true;
|
|
||||||
if (ip && cells[1].textContent.toLowerCase().indexOf(ip) === -1) show = false;
|
|
||||||
if (country && cells[2].textContent.toLowerCase().indexOf(country) === -1) show = false;
|
|
||||||
if (username && cells[3].textContent.toLowerCase().indexOf(username) === -1) show = false;
|
|
||||||
|
|
||||||
row.style.display = show ? '' : 'none';
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.clearFilters = function() {
|
window.clearFilters = function() {
|
||||||
|
|||||||
@@ -56,6 +56,20 @@ func templateFuncMap() template.FuncMap {
|
|||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
},
|
},
|
||||||
|
"formatBytes": func(b int64) string {
|
||||||
|
const (
|
||||||
|
kb = 1024
|
||||||
|
mb = 1024 * kb
|
||||||
|
)
|
||||||
|
switch {
|
||||||
|
case b >= mb:
|
||||||
|
return fmt.Sprintf("%.1f MB", float64(b)/float64(mb))
|
||||||
|
case b >= kb:
|
||||||
|
return fmt.Sprintf("%.1f KB", float64(b)/float64(kb))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d B", b)
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +81,7 @@ func loadTemplates() (*templateSet, error) {
|
|||||||
"templates/dashboard.html",
|
"templates/dashboard.html",
|
||||||
"templates/fragments/stats.html",
|
"templates/fragments/stats.html",
|
||||||
"templates/fragments/active_sessions.html",
|
"templates/fragments/active_sessions.html",
|
||||||
|
"templates/fragments/recent_sessions.html",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("parsing dashboard templates: %w", err)
|
return nil, fmt.Errorf("parsing dashboard templates: %w", err)
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
<label>Country <input type="text" name="country" placeholder="CN" maxlength="2"></label>
|
<label>Country <input type="text" name="country" placeholder="CN" maxlength="2"></label>
|
||||||
<label>Username <input type="text" name="username" placeholder="root"></label>
|
<label>Username <input type="text" name="username" placeholder="root"></label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<label><input type="checkbox" name="human_score" value="1"> Human score > 0</label>
|
||||||
|
<label>Sort by <select name="sort"><option value="connected_at">Recent</option><option value="input_bytes">Input Bytes</option></select></label>
|
||||||
|
</div>
|
||||||
<button type="submit">Apply</button>
|
<button type="submit">Apply</button>
|
||||||
<button type="button" class="secondary" onclick="clearFilters()">Clear</button>
|
<button type="button" class="secondary" onclick="clearFilters()">Clear</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -61,25 +65,13 @@
|
|||||||
<th>Username</th>
|
<th>Username</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Score</th>
|
<th>Score</th>
|
||||||
|
<th>Input</th>
|
||||||
<th>Connected</th>
|
<th>Connected</th>
|
||||||
<th>Disconnected</th>
|
<th>Disconnected</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .RecentSessions}}
|
{{template "recent_sessions" .RecentSessions}}
|
||||||
<tr>
|
|
||||||
<td><a href="/sessions/{{.ID}}"><code>{{truncateID .ID}}</code></a>{{if gt .EventCount 0}} <mark>replay</mark>{{end}}</td>
|
|
||||||
<td>{{.IP}}</td>
|
|
||||||
<td>{{.Country}}</td>
|
|
||||||
<td>{{.Username}}</td>
|
|
||||||
<td>{{if .ExecCommand}}<mark>exec</mark>{{else}}{{.ShellName}}{{end}}</td>
|
|
||||||
<td>{{if .HumanScore}}{{if gt (derefFloat .HumanScore) 0.6}}<mark>{{formatScore .HumanScore}}</mark>{{else}}{{formatScore .HumanScore}}{{end}}{{else}}-{{end}}</td>
|
|
||||||
<td>{{formatTime .ConnectedAt}}</td>
|
|
||||||
<td>{{if .DisconnectedAt}}{{formatTime (derefTime .DisconnectedAt)}}{{else}}<mark>active</mark>{{end}}</td>
|
|
||||||
</tr>
|
|
||||||
{{else}}
|
|
||||||
<tr><td colspan="8">No sessions</td></tr>
|
|
||||||
{{end}}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<th>Username</th>
|
<th>Username</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Score</th>
|
<th>Score</th>
|
||||||
|
<th>Input</th>
|
||||||
<th>Connected</th>
|
<th>Connected</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -20,10 +21,11 @@
|
|||||||
<td>{{.Username}}</td>
|
<td>{{.Username}}</td>
|
||||||
<td>{{if .ExecCommand}}<mark>exec</mark>{{else}}{{.ShellName}}{{end}}</td>
|
<td>{{if .ExecCommand}}<mark>exec</mark>{{else}}{{.ShellName}}{{end}}</td>
|
||||||
<td>{{if .HumanScore}}{{if gt (derefFloat .HumanScore) 0.6}}<mark>{{formatScore .HumanScore}}</mark>{{else}}{{formatScore .HumanScore}}{{end}}{{else}}-{{end}}</td>
|
<td>{{if .HumanScore}}{{if gt (derefFloat .HumanScore) 0.6}}<mark>{{formatScore .HumanScore}}</mark>{{else}}{{formatScore .HumanScore}}{{end}}{{else}}-{{end}}</td>
|
||||||
|
<td>{{formatBytes .InputBytes}}</td>
|
||||||
<td>{{formatTime .ConnectedAt}}</td>
|
<td>{{formatTime .ConnectedAt}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="7">No active sessions</td></tr>
|
<tr><td colspan="8">No active sessions</td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
17
internal/web/templates/fragments/recent_sessions.html
Normal file
17
internal/web/templates/fragments/recent_sessions.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{{define "recent_sessions"}}
|
||||||
|
{{range .}}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/sessions/{{.ID}}"><code>{{truncateID .ID}}</code></a>{{if gt .EventCount 0}} <mark>replay</mark>{{end}}</td>
|
||||||
|
<td>{{.IP}}</td>
|
||||||
|
<td>{{.Country}}</td>
|
||||||
|
<td>{{.Username}}</td>
|
||||||
|
<td>{{if .ExecCommand}}<mark>exec</mark>{{else}}{{.ShellName}}{{end}}</td>
|
||||||
|
<td>{{if .HumanScore}}{{if gt (derefFloat .HumanScore) 0.6}}<mark>{{formatScore .HumanScore}}</mark>{{else}}{{formatScore .HumanScore}}{{end}}{{else}}-{{end}}</td>
|
||||||
|
<td>{{formatBytes .InputBytes}}</td>
|
||||||
|
<td>{{formatTime .ConnectedAt}}</td>
|
||||||
|
<td>{{if .DisconnectedAt}}{{formatTime (derefTime .DisconnectedAt)}}{{else}}<mark>active</mark>{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="9">No sessions</td></tr>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -47,6 +47,7 @@ func NewServer(store storage.Store, logger *slog.Logger, metricsHandler http.Han
|
|||||||
s.mux.HandleFunc("GET /fragments/stats", s.handleFragmentStats)
|
s.mux.HandleFunc("GET /fragments/stats", s.handleFragmentStats)
|
||||||
s.mux.HandleFunc("GET /fragments/active-sessions", s.handleFragmentActiveSessions)
|
s.mux.HandleFunc("GET /fragments/active-sessions", s.handleFragmentActiveSessions)
|
||||||
s.mux.HandleFunc("GET /fragments/dashboard-content", s.handleFragmentDashboardContent)
|
s.mux.HandleFunc("GET /fragments/dashboard-content", s.handleFragmentDashboardContent)
|
||||||
|
s.mux.HandleFunc("GET /fragments/recent-sessions", s.handleFragmentRecentSessions)
|
||||||
|
|
||||||
if metricsHandler != nil {
|
if metricsHandler != nil {
|
||||||
h := metricsHandler
|
h := metricsHandler
|
||||||
|
|||||||
Reference in New Issue
Block a user