feat(admin-console): Stage 2 — dashboard monitoring
Tests · Go / test (push) Successful in 1m58s

Turn the console landing page into an operational dashboard.

- new internal/opsstatus: read-only Postgres projection via go-jet — ping +
  per-status COUNT/GROUP BY on runtime_records, mail_deliveries,
  notification_routes, and a malformed-intent count; degrades per-probe into
  Snapshot.Errors rather than failing the page
- dashboard renders backend readiness, database health, the three status
  tables, the malformed count, and any collection errors; falls back to a
  "monitoring not wired" note when no reader is injected
- AdminConsoleHandlers now takes an AdminConsoleDeps struct (Monitor + Ready
  added) so later stages add service refs without churning the signature

Tests: opsstatus store test against a Postgres testcontainer (empty schema +
one enqueued delivery); dashboard render tests with a fake reader (with and
without monitoring).

Docs: ARCHITECTURE 14.1 + FUNCTIONAL 10.2.1 (+ru) describe the dashboard.
(Prometheus /metrics exporters were already enabled in dev-deploy in Stage 1.)
This commit is contained in:
Ilia Denisov
2026-05-31 20:04:48 +02:00
parent 27916bbe61
commit 985e51d25e
11 changed files with 544 additions and 14 deletions
@@ -7,6 +7,7 @@ import (
"strings"
"galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/opsstatus"
"galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/server/middleware/basicauth"
@@ -25,22 +26,38 @@ type AdminConsoleHandlers struct {
renderer *adminconsole.Renderer
csrf *adminconsole.CSRF
assets http.Handler
monitor opsstatus.Reader
ready func() bool
logger *zap.Logger
}
// NewAdminConsoleHandlers constructs the console handler set. A nil renderer
// falls back to the embedded default templates; a nil csrf falls back to a
// fresh per-process random key; a nil logger falls back to zap.NewNop. It
// AdminConsoleDeps bundles the collaborators for the operator console. Every
// field is optional: a nil Renderer or CSRF falls back to the embedded default
// templates and a per-process random key; a nil Monitor renders the dashboard
// without the monitoring panels; a nil Ready reports backend readiness as not
// ready; a nil Logger falls back to zap.NewNop.
type AdminConsoleDeps struct {
Renderer *adminconsole.Renderer
CSRF *adminconsole.CSRF
Monitor opsstatus.Reader
Ready func() bool
Logger *zap.Logger
}
// NewAdminConsoleHandlers constructs the console handler set from deps. It
// panics only on conditions that are unrecoverable at startup (template parse
// failure or crypto/rand failure), both of which indicate a broken build or
// host rather than a runtime input.
func NewAdminConsoleHandlers(renderer *adminconsole.Renderer, csrf *adminconsole.CSRF, logger *zap.Logger) *AdminConsoleHandlers {
func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers {
logger := deps.Logger
if logger == nil {
logger = zap.NewNop()
}
renderer := deps.Renderer
if renderer == nil {
renderer = adminconsole.MustNewRenderer()
}
csrf := deps.CSRF
if csrf == nil {
generated, err := adminconsole.NewRandomCSRF()
if err != nil {
@@ -58,17 +75,46 @@ func NewAdminConsoleHandlers(renderer *adminconsole.Renderer, csrf *adminconsole
renderer: renderer,
csrf: csrf,
assets: http.StripPrefix("/_gm/assets/", http.FileServer(http.FS(assetsFS))),
monitor: deps.Monitor,
ready: deps.Ready,
logger: logger.Named("http.admin.console"),
}
}
// Dashboard renders the console landing page (GET /_gm and GET /_gm/).
// Dashboard renders the console landing page (GET /_gm and GET /_gm/),
// including the monitoring panels when an ops-status reader is wired.
func (h *AdminConsoleHandlers) Dashboard() gin.HandlerFunc {
return func(c *gin.Context) {
h.render(c, http.StatusOK, "dashboard", "dashboard", "Dashboard", nil)
data := adminconsole.DashboardData{}
if h.ready != nil {
data.BackendReady = h.ready()
}
if h.monitor != nil {
data.MonitorAvailable = true
snapshot := h.monitor.Collect(c.Request.Context())
data.PostgresHealthy = snapshot.PostgresHealthy
data.Runtimes = toViewCounts(snapshot.Runtimes)
data.MailDeliveries = toViewCounts(snapshot.MailDeliveries)
data.NotificationRoutes = toViewCounts(snapshot.NotificationRoutes)
data.NotificationMalformed = snapshot.NotificationMalformed
data.Errors = snapshot.Errors
}
h.render(c, http.StatusOK, "dashboard", "dashboard", "Dashboard", data)
}
}
// toViewCounts maps ops-status counts to the console's view-layer counts.
func toViewCounts(in []opsstatus.StatusCount) []adminconsole.StatusCount {
if len(in) == 0 {
return nil
}
out := make([]adminconsole.StatusCount, len(in))
for i, sc := range in {
out[i] = adminconsole.StatusCount{Status: sc.Status, Count: sc.Count}
}
return out
}
// Asset serves the embedded console static assets under `/_gm/assets/`.
func (h *AdminConsoleHandlers) Asset() gin.HandlerFunc {
return gin.WrapH(h.assets)