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:
83
internal/web/static/replay.js
Normal file
83
internal/web/static/replay.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// ReplayPlayer drives xterm.js playback of recorded session events.
|
||||
function ReplayPlayer(containerId, sessionId) {
|
||||
this.terminal = new Terminal({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
convertEol: true,
|
||||
disableStdin: true,
|
||||
theme: {
|
||||
background: '#000000',
|
||||
foreground: '#ffffff'
|
||||
}
|
||||
});
|
||||
this.terminal.open(document.getElementById(containerId));
|
||||
|
||||
this.sessionId = sessionId;
|
||||
this.events = [];
|
||||
this.index = 0;
|
||||
this.speed = 1;
|
||||
this.timers = [];
|
||||
this.playing = false;
|
||||
|
||||
// Fetch events immediately.
|
||||
var self = this;
|
||||
fetch('/api/sessions/' + sessionId + '/events')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
self.events = data.events || [];
|
||||
});
|
||||
}
|
||||
|
||||
ReplayPlayer.prototype.play = function() {
|
||||
if (this.playing) return;
|
||||
if (this.events.length === 0) return;
|
||||
this.playing = true;
|
||||
this._schedule();
|
||||
};
|
||||
|
||||
ReplayPlayer.prototype.pause = function() {
|
||||
this.playing = false;
|
||||
for (var i = 0; i < this.timers.length; i++) {
|
||||
clearTimeout(this.timers[i]);
|
||||
}
|
||||
this.timers = [];
|
||||
};
|
||||
|
||||
ReplayPlayer.prototype.reset = function() {
|
||||
this.pause();
|
||||
this.index = 0;
|
||||
this.terminal.reset();
|
||||
};
|
||||
|
||||
ReplayPlayer.prototype.setSpeed = function(speed) {
|
||||
this.speed = speed;
|
||||
if (this.playing) {
|
||||
this.pause();
|
||||
this.play();
|
||||
}
|
||||
};
|
||||
|
||||
ReplayPlayer.prototype._schedule = function() {
|
||||
var self = this;
|
||||
var baseT = this.index < this.events.length ? this.events[this.index].t : 0;
|
||||
|
||||
for (var i = this.index; i < this.events.length; i++) {
|
||||
(function(idx) {
|
||||
var evt = self.events[idx];
|
||||
var delay = (evt.t - baseT) / self.speed;
|
||||
var timer = setTimeout(function() {
|
||||
if (!self.playing) return;
|
||||
// Only write output events (d=1) to terminal; input is echoed in output.
|
||||
if (evt.d === 1) {
|
||||
var raw = atob(evt.data);
|
||||
self.terminal.write(raw);
|
||||
}
|
||||
self.index = idx + 1;
|
||||
if (self.index >= self.events.length) {
|
||||
self.playing = false;
|
||||
}
|
||||
}, delay);
|
||||
self.timers.push(timer);
|
||||
})(i);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user