From 8c5e75b06b4788f091c2d17f373a320c237d5298 Mon Sep 17 00:00:00 2001 From: SoftwareEngineer Date: Mon, 22 Jun 2026 19:09:58 +0200 Subject: [PATCH] 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 --- internal/handler/handler_test.go | 126 +++++++++++++++++++++++++++++++ internal/handler/inspect.go | 69 ++++++++++++++++- internal/handler/sse.go | 25 +++++- 3 files changed, 216 insertions(+), 4 deletions(-) diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go index 86d5ead2..2bb54859 100644 --- a/internal/handler/handler_test.go +++ b/internal/handler/handler_test.go @@ -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() diff --git a/internal/handler/inspect.go b/internal/handler/inspect.go index c0cb4acd..03cbafb1 100644 --- a/internal/handler/inspect.go +++ b/internal/handler/inspect.go @@ -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(` @@ -88,7 +91,7 @@ var inspectTemplate = template.Must(template.New("inspect").Funcs(template.FuncM
{{.Method}} {{.Path}} - {{.CreatedAt}} + {{formatTime .CreatedAt}}
@@ -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 += '
' + escapeHtml(JSON.stringify(h, null, 2)) + '
'; + } + } + if (req.query) { + html += '
' + escapeHtml(req.query) + '
'; + } + if (req.body) { + var b = typeof req.body === 'string' ? req.body : ''; + if (b) { + html += '
' + escapeHtml(b) + '
'; + } + } + return html; +} + function escapeHtml(str) { var div = document.createElement('div'); div.appendChild(document.createTextNode(str)); @@ -222,10 +258,10 @@ function escapeHtml(str) { div.innerHTML = '
' + '' + req.method + '' + '' + req.path + '' + - '' + req.created_at + '' + + '' + formatTimeStr(req.created_at) + '' + '' + '
' + - '
' + + '
' + buildRequestBody(req) + '
' + '
' + '' + '
' + @@ -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") + } +} diff --git a/internal/handler/sse.go b/internal/handler/sse.go index 46c1c5f5..3dc3081d 100644 --- a/internal/handler/sse.go +++ b/internal/handler/sse.go @@ -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 }