feat: add session replay with terminal playback via xterm.js
Persist byte-level I/O events from SSH sessions to SQLite and add a web
UI to replay them with original timing. Events are buffered in memory
and flushed every 2s to avoid blocking SSH I/O on database writes.
- Add session_events table (migration 002)
- Add SessionEvent type and storage methods (SQLite + MemoryStore)
- Change RecordingChannel to support multiple callbacks
- Add EventRecorder for buffered event persistence
- Add session detail page with xterm.js terminal replay
- Add /api/sessions/{id}/events JSON endpoint
- Linkify session IDs in dashboard and active sessions
- Vendor xterm.js v5.3.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -194,6 +194,128 @@ func TestGetTopIPs(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetSession(t *testing.T) {
|
||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||
t.Run("not found", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
s, err := store.GetSession(context.Background(), "nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if s != nil {
|
||||
t.Errorf("expected nil, got %+v", s)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("found", 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)
|
||||
}
|
||||
s, err := store.GetSession(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if s == nil {
|
||||
t.Fatal("expected session, got nil")
|
||||
}
|
||||
if s.ID != id || s.IP != "10.0.0.1" || s.Username != "root" || s.ShellName != "bash" {
|
||||
t.Errorf("unexpected session: %+v", s)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetSessionLogs(t *testing.T) {
|
||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||
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)
|
||||
}
|
||||
if err := store.AppendSessionLog(ctx, id, "ls", "file1\nfile2"); err != nil {
|
||||
t.Fatalf("AppendSessionLog: %v", err)
|
||||
}
|
||||
if err := store.AppendSessionLog(ctx, id, "pwd", "/home/root"); err != nil {
|
||||
t.Fatalf("AppendSessionLog: %v", err)
|
||||
}
|
||||
|
||||
logs, err := store.GetSessionLogs(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSessionLogs: %v", err)
|
||||
}
|
||||
if len(logs) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(logs))
|
||||
}
|
||||
if logs[0].Input != "ls" {
|
||||
t.Errorf("logs[0].Input = %q, want %q", logs[0].Input, "ls")
|
||||
}
|
||||
if logs[1].Input != "pwd" {
|
||||
t.Errorf("logs[1].Input = %q, want %q", logs[1].Input, "pwd")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSessionEvents(t *testing.T) {
|
||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
events, err := store.GetSessionEvents(context.Background(), "nonexistent")
|
||||
if err != nil {
|
||||
t.Fatalf("GetSessionEvents: %v", err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Errorf("expected empty, got %d", len(events))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("append and retrieve", 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")},
|
||||
{SessionID: id, Timestamp: now.Add(100 * time.Millisecond), Direction: 1, Data: []byte("file1\nfile2\n")},
|
||||
{SessionID: id, Timestamp: now.Add(200 * time.Millisecond), Direction: 0, Data: []byte("pwd\n")},
|
||||
}
|
||||
if err := store.AppendSessionEvents(ctx, events); err != nil {
|
||||
t.Fatalf("AppendSessionEvents: %v", err)
|
||||
}
|
||||
|
||||
got, err := store.GetSessionEvents(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSessionEvents: %v", err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len = %d, want 3", len(got))
|
||||
}
|
||||
if got[0].Direction != 0 || string(got[0].Data) != "ls\n" {
|
||||
t.Errorf("got[0] = %+v", got[0])
|
||||
}
|
||||
if got[1].Direction != 1 || string(got[1].Data) != "file1\nfile2\n" {
|
||||
t.Errorf("got[1] = %+v", got[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("append empty", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
if err := store.AppendSessionEvents(context.Background(), nil); err != nil {
|
||||
t.Fatalf("AppendSessionEvents(nil): %v", err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetRecentSessions(t *testing.T) {
|
||||
testStores(t, func(t *testing.T, newStore storeFactory) {
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user