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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
209
internal/web/static/xterm.css
Normal file
209
internal/web/static/xterm.css
Normal 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
8
internal/web/static/xterm.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
79
internal/web/templates/session_detail.html
Normal file
79
internal/web/templates/session_detail.html
Normal 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="/">← Back to dashboard</a></p>
|
||||
{{end}}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user