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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user