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:
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user