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

@@ -2,11 +2,13 @@ package web
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.t-juice.club/torjus/oubliette/internal/storage"
)
@@ -131,6 +133,109 @@ func TestFragmentActiveSessions(t *testing.T) {
}
}
func TestSessionDetailHandler(t *testing.T) {
t.Run("not found", func(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/sessions/nonexistent", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
})
t.Run("found", func(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)
}
srv, err := NewServer(store, slog.Default())
if err != nil {
t.Fatalf("NewServer: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/sessions/"+id, nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "10.0.0.1") {
t.Error("response should contain IP")
}
if !strings.Contains(body, "root") {
t.Error("response should contain username")
}
})
}
func TestAPISessionEvents(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)
}
now := time.Now().UTC()
events := []storage.SessionEvent{
{SessionID: id, Timestamp: now, Direction: 0, Data: []byte("ls\n")},
{SessionID: id, Timestamp: now.Add(500 * time.Millisecond), Direction: 1, Data: []byte("file1\n")},
}
if err := store.AppendSessionEvents(ctx, events); err != nil {
t.Fatalf("AppendSessionEvents: %v", err)
}
srv, err := NewServer(store, slog.Default())
if err != nil {
t.Fatalf("NewServer: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+id+"/events", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, "application/json") {
t.Errorf("Content-Type = %q, want application/json", ct)
}
var resp apiEventsResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if len(resp.Events) != 2 {
t.Fatalf("len = %d, want 2", len(resp.Events))
}
// First event should have t=0 (relative).
if resp.Events[0].T != 0 {
t.Errorf("events[0].T = %d, want 0", resp.Events[0].T)
}
// Second event should have t=500 (500ms later).
if resp.Events[1].T != 500 {
t.Errorf("events[1].T = %d, want 500", resp.Events[1].T)
}
if resp.Events[0].D != 0 {
t.Errorf("events[0].D = %d, want 0", resp.Events[0].D)
}
if resp.Events[1].D != 1 {
t.Errorf("events[1].D = %d, want 1", resp.Events[1].D)
}
}
func TestStaticAssets(t *testing.T) {
srv := newTestServer(t)