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

@@ -1,6 +1,8 @@
package web
import (
"encoding/base64"
"encoding/json"
"net/http"
"git.t-juice.club/torjus/oubliette/internal/storage"
@@ -70,7 +72,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, "layout.html", data); err != nil {
if err := s.tmpl.dashboard.ExecuteTemplate(w, "layout.html", data); err != nil {
s.logger.Error("failed to render dashboard", "err", err)
}
}
@@ -84,7 +86,7 @@ func (s *Server) handleFragmentStats(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, "stats", stats); err != nil {
if err := s.tmpl.dashboard.ExecuteTemplate(w, "stats", stats); err != nil {
s.logger.Error("failed to render stats fragment", "err", err)
}
}
@@ -98,7 +100,95 @@ func (s *Server) handleFragmentActiveSessions(w http.ResponseWriter, r *http.Req
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, "active_sessions", sessions); err != nil {
if err := s.tmpl.dashboard.ExecuteTemplate(w, "active_sessions", sessions); err != nil {
s.logger.Error("failed to render active sessions fragment", "err", err)
}
}
type sessionDetailData struct {
Session *storage.Session
Logs []storage.SessionLog
EventCount int
}
func (s *Server) handleSessionDetail(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sessionID := r.PathValue("id")
session, err := s.store.GetSession(ctx, sessionID)
if err != nil {
s.logger.Error("failed to get session", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if session == nil {
http.NotFound(w, r)
return
}
logs, err := s.store.GetSessionLogs(ctx, sessionID)
if err != nil {
s.logger.Error("failed to get session logs", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
events, err := s.store.GetSessionEvents(ctx, sessionID)
if err != nil {
s.logger.Error("failed to get session events", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
data := sessionDetailData{
Session: session,
Logs: logs,
EventCount: len(events),
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.sessionDetail.ExecuteTemplate(w, "layout.html", data); err != nil {
s.logger.Error("failed to render session detail", "err", err)
}
}
type apiEvent struct {
T int64 `json:"t"`
D int `json:"d"`
Data string `json:"data"`
}
type apiEventsResponse struct {
Events []apiEvent `json:"events"`
}
func (s *Server) handleAPISessionEvents(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
sessionID := r.PathValue("id")
events, err := s.store.GetSessionEvents(ctx, sessionID)
if err != nil {
s.logger.Error("failed to get session events", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
resp := apiEventsResponse{Events: make([]apiEvent, len(events))}
var baseTime int64
for i, e := range events {
ms := e.Timestamp.UnixMilli()
if i == 0 {
baseTime = ms
}
resp.Events[i] = apiEvent{
T: ms - baseTime,
D: e.Direction,
Data: base64.StdEncoding.EncodeToString(e.Data),
}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
s.logger.Error("failed to encode session events", "err", err)
}
}

View 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);
}
};

View File

@@ -0,0 +1,209 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility,
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}

8
internal/web/static/xterm.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -10,8 +10,13 @@ import (
//go:embed templates/*.html templates/fragments/*.html
var templateFS embed.FS
func loadTemplates() (*template.Template, error) {
funcMap := template.FuncMap{
type templateSet struct {
dashboard *template.Template
sessionDetail *template.Template
}
func templateFuncMap() template.FuncMap {
return template.FuncMap{
"formatTime": func(t time.Time) string {
return t.Format("2006-01-02 15:04:05 UTC")
},
@@ -40,11 +45,31 @@ func loadTemplates() (*template.Template, error) {
return fmt.Sprintf("%.0f%%", *f*100)
},
}
}
return template.New("").Funcs(funcMap).ParseFS(templateFS,
func loadTemplates() (*templateSet, error) {
funcMap := templateFuncMap()
dashboard, err := template.New("").Funcs(funcMap).ParseFS(templateFS,
"templates/layout.html",
"templates/dashboard.html",
"templates/fragments/stats.html",
"templates/fragments/active_sessions.html",
)
if err != nil {
return nil, fmt.Errorf("parsing dashboard templates: %w", err)
}
sessionDetail, err := template.New("").Funcs(funcMap).ParseFS(templateFS,
"templates/layout.html",
"templates/session_detail.html",
)
if err != nil {
return nil, fmt.Errorf("parsing session detail templates: %w", err)
}
return &templateSet{
dashboard: dashboard,
sessionDetail: sessionDetail,
}, nil
}

View File

@@ -78,7 +78,7 @@
<tbody>
{{range .RecentSessions}}
<tr>
<td><code>{{truncateID .ID}}</code></td>
<td><a href="/sessions/{{.ID}}"><code>{{truncateID .ID}}</code></a></td>
<td>{{.IP}}</td>
<td>{{.Username}}</td>
<td>{{.ShellName}}</td>

View File

@@ -13,7 +13,7 @@
<tbody>
{{range .}}
<tr>
<td><code>{{truncateID .ID}}</code></td>
<td><a href="/sessions/{{.ID}}"><code>{{truncateID .ID}}</code></a></td>
<td>{{.IP}}</td>
<td>{{.Username}}</td>
<td>{{.ShellName}}</td>

View File

@@ -0,0 +1,79 @@
{{define "content"}}
<section>
<h3>Session {{.Session.ID}}</h3>
<div class="top-grid">
<article>
<header>Session Info</header>
<table>
<tbody>
<tr><td><strong>IP</strong></td><td>{{.Session.IP}}</td></tr>
<tr><td><strong>Username</strong></td><td>{{.Session.Username}}</td></tr>
<tr><td><strong>Shell</strong></td><td>{{.Session.ShellName}}</td></tr>
<tr><td><strong>Score</strong></td><td>{{formatScore .Session.HumanScore}}</td></tr>
<tr><td><strong>Connected</strong></td><td>{{formatTime .Session.ConnectedAt}}</td></tr>
<tr>
<td><strong>Disconnected</strong></td>
<td>{{if .Session.DisconnectedAt}}{{formatTime (derefTime .Session.DisconnectedAt)}}{{else}}<mark>active</mark>{{end}}</td>
</tr>
</tbody>
</table>
</article>
</div>
</section>
{{if gt .EventCount 0}}
<section>
<h3>Session Replay</h3>
<div style="margin-bottom: 1rem;">
<button id="btn-play" onclick="replayPlayer.play()">Play</button>
<button id="btn-pause" onclick="replayPlayer.pause()">Pause</button>
<button id="btn-reset" onclick="replayPlayer.reset()">Reset</button>
<label for="speed-select" style="margin-left: 1rem;">Speed:</label>
<select id="speed-select" onchange="replayPlayer.setSpeed(parseFloat(this.value))">
<option value="0.5">0.5x</option>
<option value="1" selected>1x</option>
<option value="2">2x</option>
<option value="5">5x</option>
<option value="10">10x</option>
</select>
</div>
<div id="terminal" style="background: #000; padding: 4px; border-radius: 4px;"></div>
</section>
<link rel="stylesheet" href="/static/xterm.css">
<script src="/static/xterm.min.js"></script>
<script src="/static/replay.js"></script>
<script>
var replayPlayer = new ReplayPlayer("terminal", "{{.Session.ID}}");
</script>
{{else}}
<section>
<p>No recorded events for this session.</p>
</section>
{{end}}
{{if .Logs}}
<section>
<h3>Command Log</h3>
<table>
<thead>
<tr>
<th>Time</th>
<th>Input</th>
<th>Output</th>
</tr>
</thead>
<tbody>
{{range .Logs}}
<tr>
<td>{{formatTime .Timestamp}}</td>
<td><code>{{.Input}}</code></td>
<td><pre style="margin:0; white-space:pre-wrap;">{{.Output}}</pre></td>
</tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
<p><a href="/">&larr; Back to dashboard</a></p>
{{end}}

View File

@@ -2,7 +2,6 @@ package web
import (
"embed"
"html/template"
"log/slog"
"net/http"
@@ -17,7 +16,7 @@ type Server struct {
store storage.Store
logger *slog.Logger
mux *http.ServeMux
tmpl *template.Template
tmpl *templateSet
}
// NewServer creates a new web Server with routes registered.
@@ -35,6 +34,8 @@ func NewServer(store storage.Store, logger *slog.Logger) (*Server, error) {
}
s.mux.Handle("GET /static/", http.FileServerFS(staticFS))
s.mux.HandleFunc("GET /sessions/{id}", s.handleSessionDetail)
s.mux.HandleFunc("GET /api/sessions/{id}/events", s.handleAPISessionEvents)
s.mux.HandleFunc("GET /", s.handleDashboard)
s.mux.HandleFunc("GET /fragments/stats", s.handleFragmentStats)
s.mux.HandleFunc("GET /fragments/active-sessions", s.handleFragmentActiveSessions)

View File

@@ -2,11 +2,13 @@ package web
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.t-juice.club/torjus/oubliette/internal/storage"
)
@@ -131,6 +133,109 @@ func TestFragmentActiveSessions(t *testing.T) {
}
}
func TestSessionDetailHandler(t *testing.T) {
t.Run("not found", func(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/sessions/nonexistent", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
})
t.Run("found", func(t *testing.T) {
store := storage.NewMemoryStore()
ctx := context.Background()
id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash")
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
srv, err := NewServer(store, slog.Default())
if err != nil {
t.Fatalf("NewServer: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/sessions/"+id, nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "10.0.0.1") {
t.Error("response should contain IP")
}
if !strings.Contains(body, "root") {
t.Error("response should contain username")
}
})
}
func TestAPISessionEvents(t *testing.T) {
store := storage.NewMemoryStore()
ctx := context.Background()
id, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash")
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
now := time.Now().UTC()
events := []storage.SessionEvent{
{SessionID: id, Timestamp: now, Direction: 0, Data: []byte("ls\n")},
{SessionID: id, Timestamp: now.Add(500 * time.Millisecond), Direction: 1, Data: []byte("file1\n")},
}
if err := store.AppendSessionEvents(ctx, events); err != nil {
t.Fatalf("AppendSessionEvents: %v", err)
}
srv, err := NewServer(store, slog.Default())
if err != nil {
t.Fatalf("NewServer: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/api/sessions/"+id+"/events", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, "application/json") {
t.Errorf("Content-Type = %q, want application/json", ct)
}
var resp apiEventsResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decoding response: %v", err)
}
if len(resp.Events) != 2 {
t.Fatalf("len = %d, want 2", len(resp.Events))
}
// First event should have t=0 (relative).
if resp.Events[0].T != 0 {
t.Errorf("events[0].T = %d, want 0", resp.Events[0].T)
}
// Second event should have t=500 (500ms later).
if resp.Events[1].T != 500 {
t.Errorf("events[1].T = %d, want 500", resp.Events[1].T)
}
if resp.Events[0].D != 0 {
t.Errorf("events[0].D = %d, want 0", resp.Events[0].D)
}
if resp.Events[1].D != 1 {
t.Errorf("events[1].D = %d, want 1", resp.Events[1].D)
}
}
func TestStaticAssets(t *testing.T) {
srv := newTestServer(t)