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:
2026-02-14 22:09:24 +01:00
parent d4380c0aea
commit 24c166b86b
22 changed files with 1224 additions and 28 deletions

View File

@@ -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")
}
}