diff --git a/CHANGELOG.md b/CHANGELOG.md
index 70157c2..77cafd4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## 0.0.23
+
+- Replace the single-pane docs page with an integrated docs/admin shell (top navigation + summary tiles).
+- Add a built-in SQL explorer panel that surfaces `db/sql/schemas` and `db/sql/queries` files for quick visibility.
+- Add a live runtime log panel with request status/latency history via JSON snapshot and SSE stream endpoints.
+- Make runtime request logging explicit and mux-level via `AttachLogger(...)`; when not attached, docs show a zero-state with the exact enablement snippet.
+- Fix RPC response encoding error handling to avoid superfluous `WriteHeader` warnings on partial/broken client writes.
+
## 0.0.22
- Replace default Swagger UI docs pages with Scalar API Reference for both RPC and legacy HTTP routers.
diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md
index 41c1aad..f4d45aa 100644
--- a/docs/reference/public-api.md
+++ b/docs/reference/public-api.md
@@ -12,6 +12,7 @@ This is a quick index of the primary entry points used in Virtuous apps. For ful
- `(*rpc.Router).HandleRPC(fn any, guards ...rpc.Guard)`
- `(*rpc.Router).ServeDocs(opts ...rpc.DocOpt)`
- `(*rpc.Router).ServeAllDocs(opts ...rpc.ServeAllDocsOpt)`
+- `(*rpc.Router).AttachLogger(next http.Handler)`
- `(*rpc.Router).OpenAPI()`
- `(*rpc.Router).Routes()`
- `(*rpc.Router).SetTypeOverrides(overrides map[string]rpc.TypeOverride)`
@@ -29,6 +30,7 @@ This is a quick index of the primary entry points used in Virtuous apps. For ful
- `httpapi.WrapFunc(handler func(http.ResponseWriter, *http.Request), req any, resp any, meta httpapi.HandlerMeta)`
- `(*httpapi.Router).ServeDocs(opts ...httpapi.DocOpt)`
- `(*httpapi.Router).ServeAllDocs(opts ...httpapi.ServeAllDocsOpt)`
+- `(*httpapi.Router).AttachLogger(next http.Handler)`
- `(*httpapi.Router).OpenAPI()`
- `(*httpapi.Router).Routes()`
- `(*httpapi.Router).SetTypeOverrides(overrides map[string]httpapi.TypeOverride)`
diff --git a/example/byodb-sqlite/router.go b/example/byodb-sqlite/router.go
index 20d1084..d1e0e77 100644
--- a/example/byodb-sqlite/router.go
+++ b/example/byodb-sqlite/router.go
@@ -21,9 +21,10 @@ func NewRouter(cfg config.Config, queries *db.Queries, pool *sql.DB) http.Handle
mux.Handle("/rpc/", rpcRouter)
mux.Handle("/", embedAndServeReact())
- return httpapi.Cors(
+ handler := httpapi.Cors(
httpapi.WithAllowedOrigins(cfg.AllowedOrigins...),
)(mux)
+ return rpcRouter.AttachLogger(handler)
}
func BuildRouter(cfg config.Config, queries *db.Queries, pool *sql.DB) *rpc.Router {
diff --git a/example/byodb/router.go b/example/byodb/router.go
index f96f6cb..5f79765 100644
--- a/example/byodb/router.go
+++ b/example/byodb/router.go
@@ -22,9 +22,10 @@ func NewRouter(cfg config.Config, queries *db.Queries, pool *pgxpool.Pool) http.
mux.Handle("/rpc/", rpcRouter)
mux.Handle("/", embedAndServeReact())
- return httpapi.Cors(
+ handler := httpapi.Cors(
httpapi.WithAllowedOrigins(cfg.AllowedOrigins...),
)(mux)
+ return rpcRouter.AttachLogger(handler)
}
func BuildRouter(cfg config.Config, queries *db.Queries, pool *pgxpool.Pool) *rpc.Router {
diff --git a/httpapi/docs.go b/httpapi/docs.go
index 2ae264c..fbd8483 100644
--- a/httpapi/docs.go
+++ b/httpapi/docs.go
@@ -1,173 +1,26 @@
package httpapi
import (
- "fmt"
+ "encoding/json"
"log"
"net/http"
"os"
"strings"
+ "github.com/swetjen/virtuous/internal/adminui"
"github.com/swetjen/virtuous/internal/textutil"
)
-// DefaultDocsHTML returns a Scalar API Reference HTML page for the provided OpenAPI path.
+// DefaultDocsHTML returns the integrated docs/admin UI HTML.
func DefaultDocsHTML(openAPIPath string) string {
- return fmt.Sprintf(`
-
-
-
-
- Virtuous API Docs
-
-
-
-
-
-
-
-
-
-`, openAPIPath)
+ return adminui.DocsShellHTML(adminui.DocsShellOptions{
+ Title: "Virtuous API Docs",
+ OpenAPIURL: openAPIPath,
+ SQLCatalogURL: "./_admin/sql",
+ EventsURL: "./_admin/events",
+ EventsStreamURL: "./_admin/events.stream",
+ LoggingStatusURL: "./_admin/logging",
+ })
}
// WriteDocsHTMLFile writes the default docs HTML to the path provided.
@@ -181,6 +34,7 @@ type DocsOptions struct {
DocsFile string
OpenAPIPath string
OpenAPIFile string
+ SQLRoot string
}
// DocOpt mutates DocsOptions.
@@ -222,6 +76,15 @@ func WithOpenAPIFile(path string) DocOpt {
}
}
+// WithSQLRoot sets the root folder scanned for db/sql schema and query files.
+func WithSQLRoot(path string) DocOpt {
+ return func(o *DocsOptions) {
+ if path != "" {
+ o.SQLRoot = path
+ }
+ }
+}
+
// HandleDocs registers default docs and OpenAPI routes on the router.
func (r *Router) ServeDocs(opts ...DocOpt) {
config := DocsOptions{
@@ -229,14 +92,19 @@ func (r *Router) ServeDocs(opts ...DocOpt) {
DocsFile: "docs.html",
OpenAPIPath: "/openapi.json",
OpenAPIFile: "openapi.json",
+ SQLRoot: "db/sql",
}
for _, opt := range opts {
opt(&config)
}
- docsHtml := DefaultDocsHTML(config.OpenAPIPath)
- OpenAPI, err := r.OpenAPI()
+ if r.events == nil {
+ r.events = adminui.NewEventFeed(600)
+ }
+
+ docsHTML := DefaultDocsHTML(config.OpenAPIPath)
+ openAPI, err := r.OpenAPI()
if err != nil {
log.Fatal(err)
}
@@ -246,24 +114,56 @@ func (r *Router) ServeDocs(opts ...DocOpt) {
docsBase = "/docs"
}
docsIndex := docsBase + "/"
+ adminSQLPath := docsIndex + "_admin/sql"
+ adminEventsPath := docsIndex + "_admin/events"
+ adminEventsStreamPath := docsIndex + "_admin/events.stream"
+ adminLoggingPath := docsIndex + "_admin/logging"
- r.Handle("GET "+docsBase, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ r.mux.Handle("GET "+docsBase, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, docsIndex, http.StatusMovedPermanently)
}))
- r.Handle("GET "+docsIndex, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ r.mux.Handle("GET "+docsIndex, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
- w.Write([]byte(docsHtml))
+ w.Write([]byte(docsHTML))
+ }))
+
+ r.mux.Handle("GET "+config.OpenAPIPath, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.Write(openAPI)
}))
- r.Handle("GET "+config.OpenAPIPath, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
- w.Header().Set("Content-Type", "text/json; charset=utf-8")
- w.Write(OpenAPI)
+ r.mux.Handle("GET "+adminSQLPath, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ catalog := adminui.LoadSQLCatalog(config.SQLRoot)
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode(catalog)
}))
+
+ r.mux.Handle("GET "+adminEventsPath, http.HandlerFunc(r.events.ServeJSON))
+ r.mux.Handle("GET "+adminEventsStreamPath, http.HandlerFunc(r.events.ServeStream))
+ r.mux.Handle("GET "+adminLoggingPath, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ payload := struct {
+ Enabled bool `json:"enabled"`
+ Active bool `json:"active"`
+ Snippet string `json:"snippet"`
+ }{
+ Enabled: r.loggingEnabled(),
+ Active: r.loggingActive(),
+ Snippet: httpLoggerSnippet(),
+ }
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode(payload)
+ }))
+
+ r.events.RecordSystem("docs online: " + docsIndex)
r.logger.Info(
"docs online",
"path", docsIndex,
"openapi", config.OpenAPIPath,
+ "sql", adminSQLPath,
+ "events", adminEventsPath,
+ "stream", adminEventsStreamPath,
+ "logging", adminLoggingPath,
)
}
diff --git a/httpapi/docs_test.go b/httpapi/docs_test.go
index 164dcac..1f08a91 100644
--- a/httpapi/docs_test.go
+++ b/httpapi/docs_test.go
@@ -10,12 +10,24 @@ func TestDefaultDocsHTMLUsesScalar(t *testing.T) {
if !strings.Contains(html, "https://cdn.jsdelivr.net/npm/@scalar/api-reference") {
t.Fatalf("expected Scalar script in docs HTML")
}
- if !strings.Contains(html, "Scalar.createApiReference(\"#app\"") {
+ if !strings.Contains(html, "Scalar.createApiReference(\"#scalar-root\"") {
t.Fatalf("expected Scalar initialization in docs HTML")
}
if !strings.Contains(html, "const OPENAPI_URL = \"/openapi.json\"") {
t.Fatalf("expected openapi path in docs HTML")
}
+ if !strings.Contains(html, "Database Explorer") {
+ t.Fatalf("expected SQL explorer section in docs HTML")
+ }
+ if !strings.Contains(html, "const EVENTS_URL = \"./_admin/events\"") {
+ t.Fatalf("expected live events endpoint in docs HTML")
+ }
+ if !strings.Contains(html, "const SQL_CATALOG_URL = \"./_admin/sql\"") {
+ t.Fatalf("expected sql catalog endpoint in docs HTML")
+ }
+ if !strings.Contains(html, "const LOGGING_STATUS_URL = \"./_admin/logging\"") {
+ t.Fatalf("expected logging status endpoint in docs HTML")
+ }
if strings.Contains(html, "SwaggerUIBundle") {
t.Fatalf("unexpected Swagger UI bundle in docs HTML")
}
diff --git a/httpapi/logger.go b/httpapi/logger.go
new file mode 100644
index 0000000..2525a95
--- /dev/null
+++ b/httpapi/logger.go
@@ -0,0 +1,60 @@
+package httpapi
+
+import (
+ "net/http"
+ "sync/atomic"
+
+ "github.com/swetjen/virtuous/internal/adminui"
+)
+
+// AttachLogger wraps next with request-event capture for docs live logging.
+//
+// Call this once at the top-level mux/handler boundary for all-or-nothing logging.
+// If next is nil, the router itself is wrapped.
+func (r *Router) AttachLogger(next http.Handler) http.Handler {
+ if r == nil {
+ return next
+ }
+ if next == nil {
+ next = r
+ }
+ if r.events == nil {
+ r.events = adminui.NewEventFeed(600)
+ }
+ atomic.StoreUint32(&r.loggerAttached, 1)
+ captured := r.events.Capture(next, "", "")
+ return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ atomic.StoreUint32(&r.loggerActive, 1)
+ captured.ServeHTTP(w, req)
+ })
+}
+
+func (r *Router) loggingEnabled() bool {
+ if r == nil {
+ return false
+ }
+ return atomic.LoadUint32(&r.loggerAttached) == 1
+}
+
+func (r *Router) loggingActive() bool {
+ if r == nil {
+ return false
+ }
+ return atomic.LoadUint32(&r.loggerActive) == 1
+}
+
+func httpLoggerSnippet() string {
+ return `router := httpapi.NewRouter()
+// register routes...
+router.ServeAllDocs()
+
+mux := http.NewServeMux()
+mux.Handle("/", router)
+// mux.Handle("/assets/", assetsHandler)
+
+handler := router.AttachLogger(mux) // attach once at top-level
+log.Fatal(http.ListenAndServe(":8000", handler))
+
+// If router is already top-level:
+// handler := router.AttachLogger(router)`
+}
diff --git a/httpapi/router.go b/httpapi/router.go
index 5e543e3..ee72943 100644
--- a/httpapi/router.go
+++ b/httpapi/router.go
@@ -4,6 +4,8 @@ import (
"log/slog"
"net/http"
"strings"
+
+ "github.com/swetjen/virtuous/internal/adminui"
)
// HandlerMeta provides optional documentation metadata for a handler.
@@ -39,6 +41,9 @@ type Router struct {
mux *http.ServeMux
routes []Route
logger *slog.Logger
+ events *adminui.EventFeed
+ loggerAttached uint32
+ loggerActive uint32
typeOverrides map[string]TypeOverride
openAPIOptions *OpenAPIOptions
}
@@ -48,6 +53,7 @@ func NewRouter() *Router {
return &Router{
mux: http.NewServeMux(),
logger: slog.Default(),
+ events: adminui.NewEventFeed(600),
}
}
diff --git a/httpapi/router_event_test.go b/httpapi/router_event_test.go
new file mode 100644
index 0000000..41e0825
--- /dev/null
+++ b/httpapi/router_event_test.go
@@ -0,0 +1,79 @@
+package httpapi
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+type eventFeedReq struct {
+ ID string `json:"id"`
+}
+
+type eventFeedResp struct {
+ OK bool `json:"ok"`
+}
+
+type eventFeedHandler struct{}
+
+func (eventFeedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ Encode(w, r, http.StatusOK, eventFeedResp{OK: true})
+}
+
+func (eventFeedHandler) RequestType() any { return eventFeedReq{} }
+
+func (eventFeedHandler) ResponseType() any { return eventFeedResp{} }
+
+func (eventFeedHandler) Metadata() HandlerMeta {
+ return HandlerMeta{Service: "EventFeed", Method: "Get"}
+}
+
+func TestRouterEventFeedCapturesRequest(t *testing.T) {
+ router := NewRouter()
+ router.HandleTyped("GET /events/{id}", eventFeedHandler{})
+
+ req := httptest.NewRequest(http.MethodGet, "/events/42", nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d", rec.Code)
+ }
+
+ if got := len(router.events.Snapshot(10)); got != 0 {
+ t.Fatalf("expected no events before AttachLogger, got %d", got)
+ }
+
+ wrapped := router.AttachLogger(router)
+ req = httptest.NewRequest(http.MethodGet, "/events/42", nil)
+ rec = httptest.NewRecorder()
+ wrapped.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected status 200 from wrapped handler, got %d", rec.Code)
+ }
+
+ events := router.events.Snapshot(10)
+ if len(events) == 0 {
+ t.Fatalf("expected at least one event")
+ }
+ last := events[len(events)-1]
+ if last.Kind != "request" {
+ t.Fatalf("expected request event, got %q", last.Kind)
+ }
+ if last.Method != http.MethodGet {
+ t.Fatalf("expected method GET, got %q", last.Method)
+ }
+ if last.Path != "/events/42" {
+ t.Fatalf("expected request path /events/42, got %q", last.Path)
+ }
+ if last.Status != http.StatusOK {
+ t.Fatalf("expected status 200, got %d", last.Status)
+ }
+ if !router.loggingEnabled() {
+ t.Fatalf("expected logger to be marked enabled")
+ }
+ if !router.loggingActive() {
+ t.Fatalf("expected logger to be marked active")
+ }
+}
diff --git a/internal/adminui/feed.go b/internal/adminui/feed.go
new file mode 100644
index 0000000..1e3d790
--- /dev/null
+++ b/internal/adminui/feed.go
@@ -0,0 +1,326 @@
+package adminui
+
+import (
+ "bufio"
+ "encoding/json"
+ "errors"
+ "net"
+ "net/http"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+const (
+ defaultMaxEvents = 600
+ defaultSnapshotMax = 200
+ hardSnapshotMax = 1000
+)
+
+// Event represents one admin-console activity row.
+type Event struct {
+ ID int64 `json:"id"`
+ Time string `json:"time"`
+ Kind string `json:"kind"`
+ Method string `json:"method,omitempty"`
+ Path string `json:"path,omitempty"`
+ Status int `json:"status,omitempty"`
+ DurationMS int64 `json:"durationMs,omitempty"`
+ Bytes int64 `json:"bytes,omitempty"`
+ Outcome string `json:"outcome,omitempty"`
+ Message string `json:"message,omitempty"`
+}
+
+// EventFeed keeps a bounded in-memory feed and broadcasts live updates via SSE.
+type EventFeed struct {
+ mu sync.RWMutex
+ maxEvents int
+ nextID int64
+ events []Event
+ subscribers map[chan Event]struct{}
+}
+
+// NewEventFeed returns an in-memory event feed with a bounded history.
+func NewEventFeed(maxEvents int) *EventFeed {
+ if maxEvents <= 0 {
+ maxEvents = defaultMaxEvents
+ }
+ return &EventFeed{
+ maxEvents: maxEvents,
+ events: make([]Event, 0, maxEvents),
+ subscribers: make(map[chan Event]struct{}),
+ }
+}
+
+// RecordSystem appends a non-request event to the feed.
+func (f *EventFeed) RecordSystem(message string) {
+ if f == nil || strings.TrimSpace(message) == "" {
+ return
+ }
+ f.append(Event{
+ Kind: "system",
+ Message: message,
+ })
+}
+
+// RecordRequest appends one request outcome to the feed.
+func (f *EventFeed) RecordRequest(method, path string, status int, duration time.Duration, bytes int64) {
+ if f == nil {
+ return
+ }
+ method = strings.ToUpper(strings.TrimSpace(method))
+ if method == "" {
+ method = http.MethodGet
+ }
+ path = strings.TrimSpace(path)
+ if path == "" {
+ path = "/"
+ }
+ if status == 0 {
+ status = http.StatusOK
+ }
+ f.append(Event{
+ Kind: "request",
+ Method: method,
+ Path: path,
+ Status: status,
+ DurationMS: duration.Milliseconds(),
+ Bytes: bytes,
+ Outcome: OutcomeForStatus(status),
+ })
+}
+
+func (f *EventFeed) append(event Event) {
+ event.Time = time.Now().UTC().Format(time.RFC3339Nano)
+
+ f.mu.Lock()
+ f.nextID++
+ event.ID = f.nextID
+ f.events = append(f.events, event)
+ if len(f.events) > f.maxEvents {
+ trim := len(f.events) - f.maxEvents
+ f.events = append([]Event(nil), f.events[trim:]...)
+ }
+ for ch := range f.subscribers {
+ select {
+ case ch <- event:
+ default:
+ }
+ }
+ f.mu.Unlock()
+}
+
+// Snapshot returns up to the latest limit events.
+func (f *EventFeed) Snapshot(limit int) []Event {
+ if f == nil {
+ return nil
+ }
+ if limit <= 0 {
+ limit = defaultSnapshotMax
+ }
+ if limit > hardSnapshotMax {
+ limit = hardSnapshotMax
+ }
+
+ f.mu.RLock()
+ defer f.mu.RUnlock()
+
+ total := len(f.events)
+ if total == 0 {
+ return []Event{}
+ }
+ if limit > total {
+ limit = total
+ }
+ start := total - limit
+ out := make([]Event, limit)
+ copy(out, f.events[start:])
+ return out
+}
+
+// ServeJSON serves a JSON snapshot of recent events.
+func (f *EventFeed) ServeJSON(w http.ResponseWriter, req *http.Request) {
+ limit := parseLimit(req.URL.Query().Get("limit"))
+ payload := struct {
+ Events []Event `json:"events"`
+ }{
+ Events: f.Snapshot(limit),
+ }
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode(payload)
+}
+
+// ServeStream serves live events using Server-Sent Events.
+func (f *EventFeed) ServeStream(w http.ResponseWriter, req *http.Request) {
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ http.Error(w, "streaming unsupported", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+ w.Header().Set("X-Accel-Buffering", "no")
+
+ ch := make(chan Event, 64)
+ f.subscribe(ch)
+ defer f.unsubscribe(ch)
+
+ keepalive := time.NewTicker(20 * time.Second)
+ defer keepalive.Stop()
+
+ for {
+ select {
+ case <-req.Context().Done():
+ return
+ case ev := <-ch:
+ data, err := json.Marshal(ev)
+ if err != nil {
+ continue
+ }
+ if _, err := w.Write([]byte("data: ")); err != nil {
+ return
+ }
+ if _, err := w.Write(data); err != nil {
+ return
+ }
+ if _, err := w.Write([]byte("\n\n")); err != nil {
+ return
+ }
+ flusher.Flush()
+ case <-keepalive.C:
+ if _, err := w.Write([]byte(": ping\n\n")); err != nil {
+ return
+ }
+ flusher.Flush()
+ }
+ }
+}
+
+func (f *EventFeed) subscribe(ch chan Event) {
+ if f == nil {
+ return
+ }
+ f.mu.Lock()
+ f.subscribers[ch] = struct{}{}
+ f.mu.Unlock()
+}
+
+func (f *EventFeed) unsubscribe(ch chan Event) {
+ if f == nil {
+ return
+ }
+ f.mu.Lock()
+ delete(f.subscribers, ch)
+ f.mu.Unlock()
+ close(ch)
+}
+
+func parseLimit(raw string) int {
+ if raw == "" {
+ return defaultSnapshotMax
+ }
+ parsed, err := strconv.Atoi(raw)
+ if err != nil {
+ return defaultSnapshotMax
+ }
+ if parsed <= 0 {
+ return defaultSnapshotMax
+ }
+ if parsed > hardSnapshotMax {
+ return hardSnapshotMax
+ }
+ return parsed
+}
+
+// OutcomeForStatus returns one of ok, invalid, or err for quick UI summaries.
+func OutcomeForStatus(status int) string {
+ switch {
+ case status >= 500:
+ return "err"
+ case status >= 400:
+ return "invalid"
+ default:
+ return "ok"
+ }
+}
+
+// Capture wraps a handler and records method/path/status/duration/bytes.
+func (f *EventFeed) Capture(next http.Handler, methodHint, pathHint string) http.Handler {
+ if f == nil || next == nil {
+ return next
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ started := time.Now()
+ recorder := &statusRecorder{ResponseWriter: w}
+ next.ServeHTTP(recorder, req)
+
+ method := methodHint
+ if method == "" {
+ method = req.Method
+ }
+ path := pathHint
+ if path == "" {
+ path = req.URL.Path
+ }
+ f.RecordRequest(method, path, recorder.Status(), time.Since(started), recorder.BytesWritten())
+ })
+}
+
+type statusRecorder struct {
+ http.ResponseWriter
+ status int
+ bytes int64
+}
+
+func (w *statusRecorder) WriteHeader(status int) {
+ w.status = status
+ w.ResponseWriter.WriteHeader(status)
+}
+
+func (w *statusRecorder) Write(data []byte) (int, error) {
+ if w.status == 0 {
+ w.status = http.StatusOK
+ }
+ n, err := w.ResponseWriter.Write(data)
+ w.bytes += int64(n)
+ return n, err
+}
+
+func (w *statusRecorder) Status() int {
+ if w.status == 0 {
+ return http.StatusOK
+ }
+ return w.status
+}
+
+func (w *statusRecorder) BytesWritten() int64 {
+ return w.bytes
+}
+
+func (w *statusRecorder) Flush() {
+ if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
+ flusher.Flush()
+ }
+}
+
+func (w *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
+ hijacker, ok := w.ResponseWriter.(http.Hijacker)
+ if !ok {
+ return nil, nil, errors.New("hijacker unsupported")
+ }
+ return hijacker.Hijack()
+}
+
+func (w *statusRecorder) Push(target string, opts *http.PushOptions) error {
+ pusher, ok := w.ResponseWriter.(http.Pusher)
+ if !ok {
+ return http.ErrNotSupported
+ }
+ return pusher.Push(target, opts)
+}
+
+func (w *statusRecorder) Unwrap() http.ResponseWriter {
+ return w.ResponseWriter
+}
diff --git a/internal/adminui/html.go b/internal/adminui/html.go
new file mode 100644
index 0000000..7159997
--- /dev/null
+++ b/internal/adminui/html.go
@@ -0,0 +1,1281 @@
+package adminui
+
+import (
+ "html"
+ "strconv"
+ "strings"
+)
+
+// DocsShellOptions configures the integrated docs/admin UI shell.
+type DocsShellOptions struct {
+ Title string
+ OpenAPIURL string
+ SQLCatalogURL string
+ EventsURL string
+ EventsStreamURL string
+ LoggingStatusURL string
+}
+
+// DocsShellHTML renders the docs/admin shell HTML.
+func DocsShellHTML(opts DocsShellOptions) string {
+ title := strings.TrimSpace(opts.Title)
+ if title == "" {
+ title = "Virtuous Docs"
+ }
+ openAPIURL := strings.TrimSpace(opts.OpenAPIURL)
+ if openAPIURL == "" {
+ openAPIURL = "/openapi.json"
+ }
+ sqlCatalogURL := strings.TrimSpace(opts.SQLCatalogURL)
+ if sqlCatalogURL == "" {
+ sqlCatalogURL = "./_admin/sql"
+ }
+ eventsURL := strings.TrimSpace(opts.EventsURL)
+ if eventsURL == "" {
+ eventsURL = "./_admin/events"
+ }
+ eventsStreamURL := strings.TrimSpace(opts.EventsStreamURL)
+ if eventsStreamURL == "" {
+ eventsStreamURL = "./_admin/events.stream"
+ }
+ loggingStatusURL := strings.TrimSpace(opts.LoggingStatusURL)
+ if loggingStatusURL == "" {
+ loggingStatusURL = "./_admin/logging"
+ }
+
+ replacer := strings.NewReplacer(
+ "__TITLE__", html.EscapeString(title),
+ "__OPENAPI_URL__", strconv.Quote(openAPIURL),
+ "__SQL_CATALOG_URL__", strconv.Quote(sqlCatalogURL),
+ "__EVENTS_URL__", strconv.Quote(eventsURL),
+ "__EVENTS_STREAM_URL__", strconv.Quote(eventsStreamURL),
+ "__LOGGING_STATUS_URL__", strconv.Quote(loggingStatusURL),
+ )
+ return replacer.Replace(docsShellTemplate)
+}
+
+const docsShellTemplate = `
+
+
+
+
+ __TITLE__
+
+
+
+
+
+
+
+
+
+ Total
+ 0
+
+
+ OK
+ 0
+
+
+ Invalid
+ 0
+
+
+ Server Err
+ 0
+
+
+
+
+
+
+
+
API Reference
+
Scalar-rendered OpenAPI explorer.
+
+
+
+
+
+
+
+
+
Database Explorer
+
Goose migrations + SQLC queries discovered from db/sql.
+
+
+
+ Loading SQL catalog...
+
+
+
+
+
+ Select a SQL file
+
+
+
+
+
+
+
+
+
+
+
Live Logs
+
Recent events with route method, response code, and latency.
+
+
+
+
Virtuous logger is not attached.
+
Attach it once at your top-level mux/handler boundary to enable live logs.
+
+
+
+
+
+
+ | Time |
+ Method |
+ Path / Message |
+ Status |
+ Duration |
+ Bytes |
+ Outcome |
+
+
+
+ | Waiting for events... |
+
+
+
+
+
+
+
+
+
+
+
+`
diff --git a/internal/adminui/sql_catalog.go b/internal/adminui/sql_catalog.go
new file mode 100644
index 0000000..8164c0a
--- /dev/null
+++ b/internal/adminui/sql_catalog.go
@@ -0,0 +1,153 @@
+package adminui
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+const (
+ defaultSQLRoot = "db/sql"
+ defaultMaxSQLLen = 256 * 1024
+)
+
+// SQLCatalog summarizes schema/query files under db/sql for docs display.
+type SQLCatalog struct {
+ Root string `json:"root"`
+ Missing bool `json:"missing"`
+ Error string `json:"error,omitempty"`
+ Schemas []SQLFile `json:"schemas"`
+ Queries []SQLFile `json:"queries"`
+}
+
+// SQLFile describes one SQL file surfaced in the docs explorer.
+type SQLFile struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Bytes int64 `json:"bytes"`
+ Lines int `json:"lines"`
+ Truncated bool `json:"truncated"`
+ Content string `json:"content"`
+}
+
+// LoadSQLCatalog reads schema/query SQL files from root (defaults to db/sql).
+func LoadSQLCatalog(root string) SQLCatalog {
+ root = strings.TrimSpace(root)
+ if root == "" {
+ root = defaultSQLRoot
+ }
+ catalog := SQLCatalog{
+ Root: root,
+ Schemas: []SQLFile{},
+ Queries: []SQLFile{},
+ }
+
+ info, err := os.Stat(root)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ catalog.Missing = true
+ return catalog
+ }
+ catalog.Error = err.Error()
+ return catalog
+ }
+ if !info.IsDir() {
+ catalog.Error = "sql root is not a directory"
+ return catalog
+ }
+
+ schemas, err := loadSQLDir(root, "schemas")
+ if err != nil {
+ catalog.Error = err.Error()
+ return catalog
+ }
+ queries, err := loadSQLDir(root, "queries")
+ if err != nil {
+ catalog.Error = err.Error()
+ return catalog
+ }
+ catalog.Schemas = schemas
+ catalog.Queries = queries
+ return catalog
+}
+
+func loadSQLDir(root, subdir string) ([]SQLFile, error) {
+ dir := filepath.Join(root, subdir)
+ info, err := os.Stat(dir)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return []SQLFile{}, nil
+ }
+ return nil, err
+ }
+ if !info.IsDir() {
+ return nil, errors.New(subdir + " is not a directory")
+ }
+
+ files := []SQLFile{}
+ err = filepath.WalkDir(dir, func(path string, d os.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if d.IsDir() {
+ return nil
+ }
+ if !strings.EqualFold(filepath.Ext(d.Name()), ".sql") {
+ return nil
+ }
+ item, err := loadSQLFile(root, path)
+ if err != nil {
+ return err
+ }
+ files = append(files, item)
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ sort.Slice(files, func(i, j int) bool {
+ return files[i].Path < files[j].Path
+ })
+ return files, nil
+}
+
+func loadSQLFile(root, path string) (SQLFile, error) {
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return SQLFile{}, err
+ }
+
+ rel := path
+ if relPath, err := filepath.Rel(root, path); err == nil {
+ rel = relPath
+ }
+ rel = filepath.ToSlash(rel)
+
+ truncated := false
+ if int64(len(content)) > defaultMaxSQLLen {
+ content = content[:defaultMaxSQLLen]
+ truncated = true
+ }
+ text := string(content)
+ lines := 0
+ if text != "" {
+ lines = strings.Count(text, "\n") + 1
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ return SQLFile{}, err
+ }
+
+ return SQLFile{
+ Name: filepath.Base(path),
+ Path: rel,
+ Bytes: info.Size(),
+ Lines: lines,
+ Truncated: truncated,
+ Content: text,
+ }, nil
+}
diff --git a/rpc/docs.go b/rpc/docs.go
index a9f8697..c08437f 100644
--- a/rpc/docs.go
+++ b/rpc/docs.go
@@ -1,171 +1,25 @@
package rpc
import (
- "fmt"
+ "encoding/json"
"log"
"net/http"
"os"
"strings"
+
+ "github.com/swetjen/virtuous/internal/adminui"
)
-// DefaultDocsHTML returns a Scalar API Reference HTML page for the provided OpenAPI path.
+// DefaultDocsHTML returns the integrated docs/admin UI HTML.
func DefaultDocsHTML(openAPIPath string) string {
- return fmt.Sprintf(`
-
-
-
-
- Virtuous RPC Docs
-
-
-
-
-
-
-
-
-
-`, openAPIPath)
+ return adminui.DocsShellHTML(adminui.DocsShellOptions{
+ Title: "Virtuous RPC Docs",
+ OpenAPIURL: openAPIPath,
+ SQLCatalogURL: "./_admin/sql",
+ EventsURL: "./_admin/events",
+ EventsStreamURL: "./_admin/events.stream",
+ LoggingStatusURL: "./_admin/logging",
+ })
}
// WriteDocsHTMLFile writes the default docs HTML to the path provided.
@@ -179,6 +33,7 @@ type DocsOptions struct {
DocsFile string
OpenAPIPath string
OpenAPIFile string
+ SQLRoot string
}
// DocOpt mutates DocsOptions.
@@ -220,6 +75,15 @@ func WithOpenAPIFile(path string) DocOpt {
}
}
+// WithSQLRoot sets the root folder scanned for db/sql schema and query files.
+func WithSQLRoot(path string) DocOpt {
+ return func(o *DocsOptions) {
+ if path != "" {
+ o.SQLRoot = path
+ }
+ }
+}
+
// ServeDocs registers default docs and OpenAPI routes on the router.
func (r *Router) ServeDocs(opts ...DocOpt) {
config := DocsOptions{
@@ -227,12 +91,17 @@ func (r *Router) ServeDocs(opts ...DocOpt) {
DocsFile: "docs.html",
OpenAPIPath: "/rpc/openapi.json",
OpenAPIFile: "openapi.json",
+ SQLRoot: "db/sql",
}
for _, opt := range opts {
opt(&config)
}
+ if r.events == nil {
+ r.events = adminui.NewEventFeed(600)
+ }
+
docsHTML := DefaultDocsHTML(config.OpenAPIPath)
openAPI, err := r.OpenAPI()
if err != nil {
@@ -244,6 +113,10 @@ func (r *Router) ServeDocs(opts ...DocOpt) {
docsBase = "/rpc/docs"
}
docsIndex := docsBase + "/"
+ adminSQLPath := docsIndex + "_admin/sql"
+ adminEventsPath := docsIndex + "_admin/events"
+ adminEventsStreamPath := docsIndex + "_admin/events.stream"
+ adminLoggingPath := docsIndex + "_admin/logging"
r.mux.Handle("GET "+docsBase, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, docsIndex, http.StatusMovedPermanently)
@@ -255,12 +128,40 @@ func (r *Router) ServeDocs(opts ...DocOpt) {
}))
r.mux.Handle("GET "+config.OpenAPIPath, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
- w.Header().Set("Content-Type", "text/json; charset=utf-8")
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Write(openAPI)
}))
+
+ r.mux.Handle("GET "+adminSQLPath, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ catalog := adminui.LoadSQLCatalog(config.SQLRoot)
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode(catalog)
+ }))
+
+ r.mux.Handle("GET "+adminEventsPath, http.HandlerFunc(r.events.ServeJSON))
+ r.mux.Handle("GET "+adminEventsStreamPath, http.HandlerFunc(r.events.ServeStream))
+ r.mux.Handle("GET "+adminLoggingPath, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ payload := struct {
+ Enabled bool `json:"enabled"`
+ Active bool `json:"active"`
+ Snippet string `json:"snippet"`
+ }{
+ Enabled: r.loggingEnabled(),
+ Active: r.loggingActive(),
+ Snippet: rpcLoggerSnippet(),
+ }
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode(payload)
+ }))
+
+ r.events.RecordSystem("docs online: " + docsIndex)
r.logger.Info(
"rpc docs online",
"path", docsIndex,
"openapi", config.OpenAPIPath,
+ "sql", adminSQLPath,
+ "events", adminEventsPath,
+ "stream", adminEventsStreamPath,
+ "logging", adminLoggingPath,
)
}
diff --git a/rpc/docs_test.go b/rpc/docs_test.go
index 632c9e5..9232463 100644
--- a/rpc/docs_test.go
+++ b/rpc/docs_test.go
@@ -10,12 +10,24 @@ func TestDefaultDocsHTMLUsesScalar(t *testing.T) {
if !strings.Contains(html, "https://cdn.jsdelivr.net/npm/@scalar/api-reference") {
t.Fatalf("expected Scalar script in docs HTML")
}
- if !strings.Contains(html, "Scalar.createApiReference(\"#app\"") {
+ if !strings.Contains(html, "Scalar.createApiReference(\"#scalar-root\"") {
t.Fatalf("expected Scalar initialization in docs HTML")
}
if !strings.Contains(html, "const OPENAPI_URL = \"/rpc/openapi.json\"") {
t.Fatalf("expected openapi path in docs HTML")
}
+ if !strings.Contains(html, "Database Explorer") {
+ t.Fatalf("expected SQL explorer section in docs HTML")
+ }
+ if !strings.Contains(html, "const EVENTS_URL = \"./_admin/events\"") {
+ t.Fatalf("expected live events endpoint in docs HTML")
+ }
+ if !strings.Contains(html, "const SQL_CATALOG_URL = \"./_admin/sql\"") {
+ t.Fatalf("expected sql catalog endpoint in docs HTML")
+ }
+ if !strings.Contains(html, "const LOGGING_STATUS_URL = \"./_admin/logging\"") {
+ t.Fatalf("expected logging status endpoint in docs HTML")
+ }
if strings.Contains(html, "SwaggerUIBundle") {
t.Fatalf("unexpected Swagger UI bundle in docs HTML")
}
diff --git a/rpc/handler.go b/rpc/handler.go
index f793c22..644ebcf 100644
--- a/rpc/handler.go
+++ b/rpc/handler.go
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"errors"
- "fmt"
"net/http"
"reflect"
"runtime"
@@ -176,10 +175,9 @@ func writeJSON(w http.ResponseWriter, status int, v reflect.Value) {
return
}
enc := json.NewEncoder(w)
- if err := enc.Encode(v.Interface()); err != nil {
- msg := fmt.Sprintf("encode json: %v", err)
- http.Error(w, msg, http.StatusInternalServerError)
- }
+ // At this point headers are already written; do not attempt to write another
+ // status line on encode/write failure.
+ _ = enc.Encode(v.Interface())
}
func buildRPCPath(prefix, pkgName, funcName string) string {
diff --git a/rpc/handler_test.go b/rpc/handler_test.go
index 0c967a6..d570a97 100644
--- a/rpc/handler_test.go
+++ b/rpc/handler_test.go
@@ -3,8 +3,10 @@ package rpc
import (
"context"
"encoding/json"
+ "errors"
"net/http"
"net/http/httptest"
+ "reflect"
"strings"
"testing"
)
@@ -74,3 +76,37 @@ func TestRPCHandleInvalid(t *testing.T) {
t.Fatalf("unexpected error response: %q", body.Error)
}
}
+
+func TestWriteJSONEncodeFailureDoesNotWriteHeaderTwice(t *testing.T) {
+ w := &failingResponseWriter{header: make(http.Header)}
+ writeJSON(w, StatusOK, reflect.ValueOf(testResp{Message: "ok"}))
+
+ if w.writeHeaderCalls != 1 {
+ t.Fatalf("expected exactly one WriteHeader call, got %d", w.writeHeaderCalls)
+ }
+ if w.status != StatusOK {
+ t.Fatalf("expected status %d, got %d", StatusOK, w.status)
+ }
+ if got := w.header.Get("Content-Type"); got != "application/json" {
+ t.Fatalf("expected content type application/json, got %q", got)
+ }
+}
+
+type failingResponseWriter struct {
+ header http.Header
+ status int
+ writeHeaderCalls int
+}
+
+func (w *failingResponseWriter) Header() http.Header {
+ return w.header
+}
+
+func (w *failingResponseWriter) WriteHeader(statusCode int) {
+ w.status = statusCode
+ w.writeHeaderCalls++
+}
+
+func (w *failingResponseWriter) Write(_ []byte) (int, error) {
+ return 0, errors.New("write failed")
+}
diff --git a/rpc/logger.go b/rpc/logger.go
new file mode 100644
index 0000000..7d7f917
--- /dev/null
+++ b/rpc/logger.go
@@ -0,0 +1,60 @@
+package rpc
+
+import (
+ "net/http"
+ "sync/atomic"
+
+ "github.com/swetjen/virtuous/internal/adminui"
+)
+
+// AttachLogger wraps next with request-event capture for docs live logging.
+//
+// Call this once at the top-level mux/handler boundary for all-or-nothing logging.
+// If next is nil, the router itself is wrapped.
+func (r *Router) AttachLogger(next http.Handler) http.Handler {
+ if r == nil {
+ return next
+ }
+ if next == nil {
+ next = r
+ }
+ if r.events == nil {
+ r.events = adminui.NewEventFeed(600)
+ }
+ atomic.StoreUint32(&r.loggerAttached, 1)
+ captured := r.events.Capture(next, "", "")
+ return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+ atomic.StoreUint32(&r.loggerActive, 1)
+ captured.ServeHTTP(w, req)
+ })
+}
+
+func (r *Router) loggingEnabled() bool {
+ if r == nil {
+ return false
+ }
+ return atomic.LoadUint32(&r.loggerAttached) == 1
+}
+
+func (r *Router) loggingActive() bool {
+ if r == nil {
+ return false
+ }
+ return atomic.LoadUint32(&r.loggerActive) == 1
+}
+
+func rpcLoggerSnippet() string {
+ return `router := rpc.NewRouter(rpc.WithPrefix("/rpc"))
+// register routes...
+router.ServeAllDocs()
+
+mux := http.NewServeMux()
+mux.Handle("/rpc/", router)
+// mux.Handle("/", otherHandler)
+
+handler := router.AttachLogger(mux) // attach once at top-level
+log.Fatal(http.ListenAndServe(":8000", handler))
+
+// If router is already top-level:
+// handler := router.AttachLogger(router)`
+}
diff --git a/rpc/pgtype_test.go b/rpc/pgtype_test.go
index accb96b..8fbc681 100644
--- a/rpc/pgtype_test.go
+++ b/rpc/pgtype_test.go
@@ -175,8 +175,8 @@ func TestRPCPgtypeOpenAPIAndClients(t *testing.T) {
t.Fatalf("js client missing %s", name)
}
}
- if !strings.Contains(ts.String(), "raw: any;") {
- t.Fatalf("ts client should type raw as any")
+ if !strings.Contains(ts.String(), "raw: object|any[];") {
+ t.Fatalf("ts client should type raw as object|any[]")
}
if !strings.Contains(py.String(), "raw: Any") {
t.Fatalf("py client should type raw as Any")
diff --git a/rpc/router.go b/rpc/router.go
index 05ebdb5..a9613ec 100644
--- a/rpc/router.go
+++ b/rpc/router.go
@@ -3,6 +3,8 @@ package rpc
import (
"log/slog"
"net/http"
+
+ "github.com/swetjen/virtuous/internal/adminui"
)
// Router registers RPC handlers and exposes documentation metadata.
@@ -12,6 +14,9 @@ type Router struct {
prefix string
guards []Guard
logger *slog.Logger
+ events *adminui.EventFeed
+ loggerAttached uint32
+ loggerActive uint32
typeOverrides map[string]TypeOverride
openAPIOptions *OpenAPIOptions
}
@@ -52,6 +57,7 @@ func NewRouter(opts ...RouterOption) *Router {
prefix: normalizePrefix(config.Prefix),
guards: append([]Guard(nil), config.Guards...),
logger: slog.Default(),
+ events: adminui.NewEventFeed(600),
}
}
diff --git a/rpc/router_test.go b/rpc/router_test.go
index 0348c35..2630c8a 100644
--- a/rpc/router_test.go
+++ b/rpc/router_test.go
@@ -134,6 +134,57 @@ func TestRPCRouterAndHandlerGuardOrder(t *testing.T) {
}
}
+func TestRPCEventFeedRequiresAttachLogger(t *testing.T) {
+ router := NewRouter()
+ router.HandleRPC(testHandler)
+ path := router.Routes()[0].Path
+
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"name":"Virtuous"}`))
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d", rec.Code)
+ }
+
+ if got := len(router.events.Snapshot(10)); got != 0 {
+ t.Fatalf("expected no events before AttachLogger, got %d", got)
+ }
+
+ wrapped := router.AttachLogger(router)
+ req = httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"name":"Virtuous"}`))
+ rec = httptest.NewRecorder()
+ wrapped.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected status 200 from wrapped handler, got %d", rec.Code)
+ }
+
+ events := router.events.Snapshot(10)
+ if len(events) == 0 {
+ t.Fatalf("expected at least one event")
+ }
+ last := events[len(events)-1]
+ if last.Kind != "request" {
+ t.Fatalf("expected request event, got %q", last.Kind)
+ }
+ if last.Path != path {
+ t.Fatalf("expected event path %q, got %q", path, last.Path)
+ }
+ if last.Status != http.StatusOK {
+ t.Fatalf("expected event status 200, got %d", last.Status)
+ }
+ if last.Outcome != "ok" {
+ t.Fatalf("expected outcome ok, got %q", last.Outcome)
+ }
+ if !router.loggingEnabled() {
+ t.Fatalf("expected logger to be marked enabled")
+ }
+ if !router.loggingActive() {
+ t.Fatalf("expected logger to be marked active")
+ }
+}
+
func expectPanic(t *testing.T, fn func()) {
t.Helper()
defer func() {
diff --git a/schema/registry.go b/schema/registry.go
index 137d468..db07cb7 100644
--- a/schema/registry.go
+++ b/schema/registry.go
@@ -156,7 +156,7 @@ func defaultTypeOverrides() map[string]TypeOverride {
OpenAPIFormat: "date-time",
},
"encoding/json.RawMessage": {
- JSType: "any",
+ JSType: "object|any[]",
PyType: "Any",
OpenAPIType: "object",
},