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:
parent
cf5b2f185c
commit
8c5e75b06b
@ -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()
|
||||
|
||||
@ -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">↺ 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 + '\')">↺ 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")
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user