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:
@@ -9,12 +9,11 @@ import (
|
||||
// direction is 0 for input (client→server) and 1 for output (server→client).
|
||||
type EventCallback func(ts time.Time, direction int, data []byte)
|
||||
|
||||
// RecordingChannel wraps an io.ReadWriteCloser and optionally invokes a callback
|
||||
// on every Read (input) and Write (output). Phase 2.3 will add byte-level
|
||||
// keystroke recording here without changing any shell code.
|
||||
// RecordingChannel wraps an io.ReadWriteCloser and optionally invokes callbacks
|
||||
// on every Read (input) and Write (output).
|
||||
type RecordingChannel struct {
|
||||
inner io.ReadWriteCloser
|
||||
callback EventCallback
|
||||
inner io.ReadWriteCloser
|
||||
callbacks []EventCallback
|
||||
}
|
||||
|
||||
// NewRecordingChannel returns a RecordingChannel wrapping rw.
|
||||
@@ -22,28 +21,40 @@ func NewRecordingChannel(rw io.ReadWriteCloser) *RecordingChannel {
|
||||
return &RecordingChannel{inner: rw}
|
||||
}
|
||||
|
||||
// WithCallback sets the event callback and returns the RecordingChannel for chaining.
|
||||
// WithCallback clears existing callbacks, sets the given one, and returns the
|
||||
// RecordingChannel for chaining. Kept for backward compatibility.
|
||||
func (r *RecordingChannel) WithCallback(cb EventCallback) *RecordingChannel {
|
||||
r.callback = cb
|
||||
r.callbacks = []EventCallback{cb}
|
||||
return r
|
||||
}
|
||||
|
||||
// AddCallback appends an additional event callback.
|
||||
func (r *RecordingChannel) AddCallback(cb EventCallback) {
|
||||
r.callbacks = append(r.callbacks, cb)
|
||||
}
|
||||
|
||||
func (r *RecordingChannel) Read(p []byte) (int, error) {
|
||||
n, err := r.inner.Read(p)
|
||||
if n > 0 && r.callback != nil {
|
||||
if n > 0 && len(r.callbacks) > 0 {
|
||||
ts := time.Now()
|
||||
cp := make([]byte, n)
|
||||
copy(cp, p[:n])
|
||||
r.callback(time.Now(), 0, cp)
|
||||
for _, cb := range r.callbacks {
|
||||
cb(ts, 0, cp)
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *RecordingChannel) Write(p []byte) (int, error) {
|
||||
n, err := r.inner.Write(p)
|
||||
if n > 0 && r.callback != nil {
|
||||
if n > 0 && len(r.callbacks) > 0 {
|
||||
ts := time.Now()
|
||||
cp := make([]byte, n)
|
||||
copy(cp, p[:n])
|
||||
r.callback(time.Now(), 1, cp)
|
||||
for _, cb := range r.callbacks {
|
||||
cb(ts, 1, cp)
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user