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

@@ -15,6 +15,7 @@ type MemoryStore struct {
LoginAttempts []LoginAttempt
Sessions map[string]*Session
SessionLogs []SessionLog
SessionEvents []SessionEvent
}
// NewMemoryStore returns a new empty MemoryStore.
@@ -101,6 +102,55 @@ func (m *MemoryStore) AppendSessionLog(_ context.Context, sessionID, input, outp
return nil
}
func (m *MemoryStore) GetSession(_ context.Context, sessionID string) (*Session, error) {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.Sessions[sessionID]
if !ok {
return nil, nil
}
copy := *s
return &copy, nil
}
func (m *MemoryStore) GetSessionLogs(_ context.Context, sessionID string) ([]SessionLog, error) {
m.mu.Lock()
defer m.mu.Unlock()
var logs []SessionLog
for _, l := range m.SessionLogs {
if l.SessionID == sessionID {
logs = append(logs, l)
}
}
sort.Slice(logs, func(i, j int) bool {
return logs[i].Timestamp.Before(logs[j].Timestamp)
})
return logs, nil
}
func (m *MemoryStore) AppendSessionEvents(_ context.Context, events []SessionEvent) error {
m.mu.Lock()
defer m.mu.Unlock()
m.SessionEvents = append(m.SessionEvents, events...)
return nil
}
func (m *MemoryStore) GetSessionEvents(_ context.Context, sessionID string) ([]SessionEvent, error) {
m.mu.Lock()
defer m.mu.Unlock()
var events []SessionEvent
for _, e := range m.SessionEvents {
if e.SessionID == sessionID {
events = append(events, e)
}
}
return events, nil
}
func (m *MemoryStore) DeleteRecordsBefore(_ context.Context, cutoff time.Time) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
@@ -136,6 +186,16 @@ func (m *MemoryStore) DeleteRecordsBefore(_ context.Context, cutoff time.Time) (
}
m.SessionLogs = keptLogs
keptEvents := m.SessionEvents[:0]
for _, e := range m.SessionEvents {
if _, ok := m.Sessions[e.SessionID]; ok {
keptEvents = append(keptEvents, e)
} else {
total++
}
}
m.SessionEvents = keptEvents
return total, nil
}