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:
80
internal/shell/eventrecorder_test.go
Normal file
80
internal/shell/eventrecorder_test.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.t-juice.club/torjus/oubliette/internal/storage"
|
||||
)
|
||||
|
||||
func TestEventRecorderFlush(t *testing.T) {
|
||||
store := storage.NewMemoryStore()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a session so events have a valid session ID.
|
||||
id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
rec := NewEventRecorder(id, store, slog.Default())
|
||||
rec.Start(ctx)
|
||||
|
||||
// Record some events.
|
||||
now := time.Now()
|
||||
rec.RecordEvent(now, 0, []byte("hello"))
|
||||
rec.RecordEvent(now.Add(100*time.Millisecond), 1, []byte("world"))
|
||||
|
||||
// Close should trigger final flush.
|
||||
rec.Close()
|
||||
|
||||
events, err := store.GetSessionEvents(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSessionEvents: %v", err)
|
||||
}
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(events))
|
||||
}
|
||||
if string(events[0].Data) != "hello" {
|
||||
t.Errorf("events[0].Data = %q, want %q", events[0].Data, "hello")
|
||||
}
|
||||
if events[0].Direction != 0 {
|
||||
t.Errorf("events[0].Direction = %d, want 0", events[0].Direction)
|
||||
}
|
||||
if string(events[1].Data) != "world" {
|
||||
t.Errorf("events[1].Data = %q, want %q", events[1].Data, "world")
|
||||
}
|
||||
if events[1].Direction != 1 {
|
||||
t.Errorf("events[1].Direction = %d, want 1", events[1].Direction)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventRecorderPeriodicFlush(t *testing.T) {
|
||||
store := storage.NewMemoryStore()
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
|
||||
rec := NewEventRecorder(id, store, slog.Default())
|
||||
rec.Start(ctx)
|
||||
|
||||
// Record an event and wait for the periodic flush (2s + some margin).
|
||||
rec.RecordEvent(time.Now(), 1, []byte("periodic"))
|
||||
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
events, err := store.GetSessionEvents(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSessionEvents: %v", err)
|
||||
}
|
||||
if len(events) != 1 {
|
||||
t.Errorf("expected periodic flush, got %d events", len(events))
|
||||
}
|
||||
|
||||
rec.Close()
|
||||
}
|
||||
Reference in New Issue
Block a user