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