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>
123 lines
2.7 KiB
Go
123 lines
2.7 KiB
Go
package shell
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// nopCloser wraps a ReadWriter with a no-op Close.
|
|
type nopCloser struct {
|
|
io.ReadWriter
|
|
}
|
|
|
|
func (nopCloser) Close() error { return nil }
|
|
|
|
func TestRecordingChannelPassthrough(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
rc := NewRecordingChannel(nopCloser{&buf})
|
|
|
|
// Write through the recorder.
|
|
msg := []byte("hello")
|
|
n, err := rc.Write(msg)
|
|
if err != nil {
|
|
t.Fatalf("Write: %v", err)
|
|
}
|
|
if n != len(msg) {
|
|
t.Errorf("Write n = %d, want %d", n, len(msg))
|
|
}
|
|
|
|
// Read through the recorder.
|
|
out := make([]byte, 16)
|
|
n, err = rc.Read(out)
|
|
if err != nil {
|
|
t.Fatalf("Read: %v", err)
|
|
}
|
|
if string(out[:n]) != "hello" {
|
|
t.Errorf("Read = %q, want %q", out[:n], "hello")
|
|
}
|
|
|
|
if err := rc.Close(); err != nil {
|
|
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")
|
|
}
|
|
}
|