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:
@@ -3,7 +3,9 @@ package shell
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// nopCloser wraps a ReadWriter with a no-op Close.
|
||||
@@ -41,3 +43,80 @@ func TestRecordingChannelPassthrough(t *testing.T) {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordingChannelMultiCallback(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
rc := NewRecordingChannel(nopCloser{&buf})
|
||||
|
||||
type event struct {
|
||||
ts time.Time
|
||||
direction int
|
||||
data string
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var events1, events2 []event
|
||||
|
||||
rc.AddCallback(func(ts time.Time, direction int, data []byte) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
events1 = append(events1, event{ts, direction, string(data)})
|
||||
})
|
||||
rc.AddCallback(func(ts time.Time, direction int, data []byte) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
events2 = append(events2, event{ts, direction, string(data)})
|
||||
})
|
||||
|
||||
// Write triggers both callbacks with direction=1.
|
||||
rc.Write([]byte("hello"))
|
||||
|
||||
// Read triggers both callbacks with direction=0.
|
||||
out := make([]byte, 16)
|
||||
rc.Read(out)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if len(events1) != 2 {
|
||||
t.Fatalf("callback1 got %d events, want 2", len(events1))
|
||||
}
|
||||
if len(events2) != 2 {
|
||||
t.Fatalf("callback2 got %d events, want 2", len(events2))
|
||||
}
|
||||
|
||||
// Write event should be direction=1.
|
||||
if events1[0].direction != 1 {
|
||||
t.Errorf("write direction = %d, want 1", events1[0].direction)
|
||||
}
|
||||
// Read event should be direction=0.
|
||||
if events1[1].direction != 0 {
|
||||
t.Errorf("read direction = %d, want 0", events1[1].direction)
|
||||
}
|
||||
|
||||
// Both callbacks should get the same timestamp for a single operation.
|
||||
if events1[0].ts != events2[0].ts {
|
||||
t.Error("callbacks should receive the same timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordingChannelWithCallbackClearsExisting(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
rc := NewRecordingChannel(nopCloser{&buf})
|
||||
|
||||
called1 := false
|
||||
called2 := false
|
||||
|
||||
rc.AddCallback(func(_ time.Time, _ int, _ []byte) { called1 = true })
|
||||
// WithCallback should clear existing and set new.
|
||||
rc.WithCallback(func(_ time.Time, _ int, _ []byte) { called2 = true })
|
||||
|
||||
rc.Write([]byte("x"))
|
||||
|
||||
if called1 {
|
||||
t.Error("first callback should not be called after WithCallback")
|
||||
}
|
||||
if !called2 {
|
||||
t.Error("second callback should be called")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user