feat(inspect): polish server-rendered request list page

- Add formatTime template func for human-readable timestamps (just now, 2m ago, etc.)
- Add client-side formatTimeStr for SSE live-update timestamps
- Fix SSE body rendering: buildRequestBody() populates headers/query/body for live-added requests
- Fix SSE body encoding: use string in sseRequest to avoid base64 encoding of []byte
- Add inspect handler tests: renders HTML, empty state, auto-creates endpoint
- Timestamps include title attr with full ISO timestamp for hover detail
This commit is contained in:
SoftwareEngineer 2026-06-22 19:09:58 +02:00
parent cf5b2f185c
commit 8c5e75b06b
3 changed files with 216 additions and 4 deletions

View File

@ -204,6 +204,132 @@ func TestSSEStreamReceivesEventOnCapture(t *testing.T) {
cancel()
}
func TestInspectReturnsHTML(t *testing.T) {
repo := newFakeRepo()
repo.CreateEndpoint(nil, "ep")
repo.AppendRequest(nil, "ep", &store.Request{
Method: "POST",
Path: "/ep/webhook",
Headers: `{"Content-Type":"application/json"}`,
Query: "foo=bar",
Body: []byte(`{"msg":"hello"}`),
})
r := testRouter(repo)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/e/ep", nil))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, "text/html") {
t.Fatalf("expected text/html Content-Type, got %q", ct)
}
body := w.Body.String()
// Should contain the endpoint ID.
if !strings.Contains(body, "ep") {
t.Error("missing endpoint ID in HTML")
}
// Should contain the request method badge.
if !strings.Contains(body, "POST") {
t.Error("missing POST method in HTML")
}
// Should contain the request path.
if !strings.Contains(body, "/ep/webhook") {
t.Error("missing request path in HTML")
}
// Should contain the header JSON.
if !strings.Contains(body, "Content-Type") {
t.Error("missing Content-Type header in HTML")
}
// Should contain the query string.
if !strings.Contains(body, "foo=bar") {
t.Error("missing query string in HTML")
}
// Should contain the body content (html/template escapes quotes).
if !strings.Contains(body, "msg") || !strings.Contains(body, "hello") {
t.Error("missing request body content in HTML")
}
// Should contain the replay button.
if !strings.Contains(body, "Replay") {
t.Error("missing Replay button in HTML")
}
// Should contain SSE EventSource script.
if !strings.Contains(body, "EventSource") {
t.Error("missing EventSource in HTML")
}
}
func TestInspectEmptyState(t *testing.T) {
repo := newFakeRepo()
repo.CreateEndpoint(nil, "new-ep")
r := testRouter(repo)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/e/new-ep", nil))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body := w.Body.String()
// Should show the "waiting for requests" empty state.
if !strings.Contains(body, "Waiting for requests") {
t.Error("missing empty state message")
}
// Should show the usage hint with the endpoint ID.
if !strings.Contains(body, "/new-ep") {
t.Error("missing usage hint with endpoint ID")
}
// No request cards should be rendered.
if strings.Contains(body, `class="request"`) {
t.Error("unexpected request card in empty state")
}
}
func TestInspectAutoCreatesEndpoint(t *testing.T) {
repo := newFakeRepo()
// The endpoint doesn't exist yet.
r := testRouter(repo)
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/e/auto-ep", nil))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
// The endpoint should now exist in the repo.
ep, err := repo.GetEndpoint(nil, "auto-ep")
if err != nil {
t.Fatalf("endpoint was not auto-created: %v", err)
}
if ep.ID != "auto-ep" {
t.Errorf("expected endpoint ID 'auto-ep', got %q", ep.ID)
}
body := w.Body.String()
if !strings.Contains(body, "auto-ep") {
t.Error("missing endpoint ID in auto-created page")
}
if !strings.Contains(body, "Waiting for requests") {
t.Error("missing empty state for auto-created endpoint")
}
}
func TestCaptureReturnsJSON200(t *testing.T) {
r := testRouter(newFakeRepo())
w := httptest.NewRecorder()

View File

@ -2,8 +2,10 @@ package handler
import (
"encoding/json"
"fmt"
"html/template"
"net/http"
"time"
"github.com/elfoundation/hatch/internal/store"
)
@ -17,6 +19,7 @@ type inspectPageData struct {
// inspectTemplate is the server-rendered HTML for the inspect page.
var inspectTemplate = template.Must(template.New("inspect").Funcs(template.FuncMap{
"prettyJSON": prettyJSON,
"formatTime": formatTime,
}).Parse(`<!DOCTYPE html>
<html lang="en">
<head>
@ -88,7 +91,7 @@ var inspectTemplate = template.Must(template.New("inspect").Funcs(template.FuncM
<div class="request-header" onclick="toggleRequest('{{.ID}}')">
<span class="method {{.Method}}">{{.Method}}</span>
<span class="path">{{.Path}}</span>
<span class="time">{{.CreatedAt}}</span>
<span class="time" title="{{.CreatedAt}}">{{formatTime .CreatedAt}}</span>
<button class="replay-btn" onclick="event.stopPropagation(); openReplay('{{.ID}}')" title="Replay this request">&#x21BA; Replay</button>
</div>
<div class="request-body" id="body-{{.ID}}">
@ -200,6 +203,39 @@ function renderReplayResult(el, data) {
el.innerHTML = html;
}
function formatTimeStr(ts) {
try { var d = new Date(ts); if (isNaN(d.getTime())) return ts; } catch(_) { return ts; }
var now = new Date();
var diff = Math.floor((now - d) / 1000);
if (diff < 5) return 'just now';
if (diff < 60) return diff + 's ago';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
if (diff < 604800) return Math.floor(diff / 86400) + 'd ago';
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
}
function buildRequestBody(req) {
var html = '';
if (req.headers) {
var h = req.headers;
if (typeof h === 'string') { try { h = JSON.parse(h); } catch(_) { h = null; } }
if (h && Object.keys(h).length > 0) {
html += '<div class="section"><div class="section-label">Headers</div><pre>' + escapeHtml(JSON.stringify(h, null, 2)) + '</pre></div>';
}
}
if (req.query) {
html += '<div class="section"><div class="section-label">Query</div><pre>' + escapeHtml(req.query) + '</pre></div>';
}
if (req.body) {
var b = typeof req.body === 'string' ? req.body : '';
if (b) {
html += '<div class="section"><div class="section-label">Body</div><pre>' + escapeHtml(b) + '</pre></div>';
}
}
return html;
}
function escapeHtml(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
@ -222,10 +258,10 @@ function escapeHtml(str) {
div.innerHTML = '<div class="request-header" onclick="toggleRequest(\'' + req.id + '\')">' +
'<span class="method ' + req.method + '">' + req.method + '</span>' +
'<span class="path">' + req.path + '</span>' +
'<span class="time">' + req.created_at + '</span>' +
'<span class="time" title="' + req.created_at + '">' + formatTimeStr(req.created_at) + '</span>' +
'<button class="replay-btn" onclick="event.stopPropagation(); openReplay(\'' + req.id + '\')">&#x21BA; Replay</button>' +
'</div>' +
'<div class="request-body" id="body-' + req.id + '"></div>' +
'<div class="request-body" id="body-' + req.id + '">' + buildRequestBody(req) + '</div>' +
'<div class="replay-panel" id="replay-' + req.id + '">' +
'<input type="text" class="replay-url-input" id="replay-url-' + req.id + '" placeholder="https://myapp.local/webhook" value="">' +
'<div class="replay-actions">' +
@ -279,3 +315,30 @@ func prettyJSON(raw string) string {
}
return string(b)
}
// formatTime displays an ISO 8601 timestamp as a short relative or absolute string.
func formatTime(raw string) string {
t, err := time.Parse("2006-01-02T15:04:05.000Z07:00", raw)
if err != nil {
// Try a few common variants.
t, err = time.Parse("2006-01-02T15:04:05Z07:00", raw)
}
if err != nil {
return raw
}
d := time.Since(t)
switch {
case d < 5*time.Second:
return "just now"
case d < time.Minute:
return fmt.Sprintf("%ds ago", int(d.Seconds()))
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
case d < 7*24*time.Hour:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
default:
return t.Format("Jan 2, 15:04")
}
}

View File

@ -39,6 +39,19 @@ func (h *sseHub) unsubscribe(endpointID string, ch chan []byte) {
}
}
// sseRequest is the JSON payload sent over SSE.
// It uses string for Body to avoid base64 encoding.
type sseRequest struct {
ID string `json:"id"`
EndpointID string `json:"endpoint_id"`
Method string `json:"method"`
Path string `json:"path"`
Headers string `json:"headers"`
Query string `json:"query"`
Body string `json:"body"`
CreatedAt string `json:"created_at"`
}
func (h *sseHub) broadcast(endpointID string, req *store.Request) {
h.mu.RLock()
defer h.mu.RUnlock()
@ -46,7 +59,17 @@ func (h *sseHub) broadcast(endpointID string, req *store.Request) {
if subs == nil {
return
}
data, err := json.Marshal(req)
sseReq := sseRequest{
ID: req.ID,
EndpointID: req.EndpointID,
Method: req.Method,
Path: req.Path,
Headers: req.Headers,
Query: req.Query,
Body: string(req.Body),
CreatedAt: req.CreatedAt,
}
data, err := json.Marshal(sseReq)
if err != nil {
return
}