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

@@ -94,6 +94,111 @@ func (s *SQLiteStore) AppendSessionLog(ctx context.Context, sessionID, input, ou
return nil
}
func (s *SQLiteStore) GetSession(ctx context.Context, sessionID string) (*Session, error) {
var sess Session
var connectedAt string
var disconnectedAt sql.NullString
var humanScore sql.NullFloat64
err := s.db.QueryRowContext(ctx, `
SELECT id, ip, username, shell_name, connected_at, disconnected_at, human_score
FROM sessions WHERE id = ?`, sessionID).Scan(
&sess.ID, &sess.IP, &sess.Username, &sess.ShellName,
&connectedAt, &disconnectedAt, &humanScore,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("querying session: %w", err)
}
sess.ConnectedAt, _ = time.Parse(time.RFC3339, connectedAt)
if disconnectedAt.Valid {
t, _ := time.Parse(time.RFC3339, disconnectedAt.String)
sess.DisconnectedAt = &t
}
if humanScore.Valid {
sess.HumanScore = &humanScore.Float64
}
return &sess, nil
}
func (s *SQLiteStore) GetSessionLogs(ctx context.Context, sessionID string) ([]SessionLog, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, session_id, timestamp, input, output
FROM session_logs WHERE session_id = ?
ORDER BY timestamp`, sessionID)
if err != nil {
return nil, fmt.Errorf("querying session logs: %w", err)
}
defer func() { _ = rows.Close() }()
var logs []SessionLog
for rows.Next() {
var l SessionLog
var ts string
if err := rows.Scan(&l.ID, &l.SessionID, &ts, &l.Input, &l.Output); err != nil {
return nil, fmt.Errorf("scanning session log: %w", err)
}
l.Timestamp, _ = time.Parse(time.RFC3339, ts)
logs = append(logs, l)
}
return logs, rows.Err()
}
func (s *SQLiteStore) AppendSessionEvents(ctx context.Context, events []SessionEvent) error {
if len(events) == 0 {
return nil
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO session_events (session_id, timestamp, direction, data)
VALUES (?, ?, ?, ?)`)
if err != nil {
return fmt.Errorf("preparing statement: %w", err)
}
defer stmt.Close()
for _, e := range events {
_, err := stmt.ExecContext(ctx, e.SessionID, e.Timestamp.UTC().Format(time.RFC3339Nano), e.Direction, e.Data)
if err != nil {
return fmt.Errorf("inserting session event: %w", err)
}
}
return tx.Commit()
}
func (s *SQLiteStore) GetSessionEvents(ctx context.Context, sessionID string) ([]SessionEvent, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT session_id, timestamp, direction, data
FROM session_events WHERE session_id = ?
ORDER BY id`, sessionID)
if err != nil {
return nil, fmt.Errorf("querying session events: %w", err)
}
defer func() { _ = rows.Close() }()
var events []SessionEvent
for rows.Next() {
var e SessionEvent
var ts string
if err := rows.Scan(&e.SessionID, &ts, &e.Direction, &e.Data); err != nil {
return nil, fmt.Errorf("scanning session event: %w", err)
}
e.Timestamp, _ = time.Parse(time.RFC3339Nano, ts)
events = append(events, e)
}
return events, rows.Err()
}
func (s *SQLiteStore) DeleteRecordsBefore(ctx context.Context, cutoff time.Time) (int64, error) {
cutoffStr := cutoff.UTC().Format(time.RFC3339)
@@ -105,15 +210,26 @@ func (s *SQLiteStore) DeleteRecordsBefore(ctx context.Context, cutoff time.Time)
var total int64
// Delete session logs for old sessions.
// Delete session events for old sessions.
res, err := tx.ExecContext(ctx, `
DELETE FROM session_events WHERE session_id IN (
SELECT id FROM sessions WHERE connected_at < ?
)`, cutoffStr)
if err != nil {
return 0, fmt.Errorf("deleting session events: %w", err)
}
n, _ := res.RowsAffected()
total += n
// Delete session logs for old sessions.
res, err = tx.ExecContext(ctx, `
DELETE FROM session_logs WHERE session_id IN (
SELECT id FROM sessions WHERE connected_at < ?
)`, cutoffStr)
if err != nil {
return 0, fmt.Errorf("deleting session logs: %w", err)
}
n, _ := res.RowsAffected()
n, _ = res.RowsAffected()
total += n
// Delete old sessions.