diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go
index 25acf9c..a042347 100644
--- a/backend/cmd/backend/main.go
+++ b/backend/cmd/backend/main.go
@@ -38,6 +38,7 @@ import (
"galaxy/backend/internal/mail"
"galaxy/backend/internal/metricsapi"
"galaxy/backend/internal/notification"
+ "galaxy/backend/internal/opsstatus"
backendpostgres "galaxy/backend/internal/postgres"
"galaxy/backend/push"
"galaxy/backend/internal/runtime"
@@ -357,6 +358,10 @@ func run(ctx context.Context) (err error) {
userGamesHandlers := backendserver.NewUserGamesHandlers(runtimeSvc, engineCli, logger)
userMailHandlers := backendserver.NewUserMailHandlers(diplomailSvc, lobbySvc, userSvc, logger)
+ ready := func() bool {
+ return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready()
+ }
+
var consoleCSRF *adminconsole.CSRF
if cfg.AdminConsole.CSRFKey != "" {
consoleCSRF = adminconsole.NewCSRF([]byte(cfg.AdminConsole.CSRFKey))
@@ -368,11 +373,12 @@ func run(ctx context.Context) (err error) {
logger.Warn("admin console CSRF key not set; using a per-process random key (forms reset on restart, not valid across replicas)",
zap.String("env", "BACKEND_ADMIN_CONSOLE_CSRF_KEY"))
}
- adminConsoleHandlers := backendserver.NewAdminConsoleHandlers(adminconsole.MustNewRenderer(), consoleCSRF, logger)
-
- ready := func() bool {
- return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready()
- }
+ adminConsoleHandlers := backendserver.NewAdminConsoleHandlers(backendserver.AdminConsoleDeps{
+ CSRF: consoleCSRF,
+ Monitor: opsstatus.NewStore(db),
+ Ready: ready,
+ Logger: logger,
+ })
handler, err := backendserver.NewRouter(backendserver.RouterDependencies{
Logger: logger,
diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css
index bc29129..1fcec80 100644
--- a/backend/internal/adminconsole/assets/console.css
+++ b/backend/internal/adminconsole/assets/console.css
@@ -47,3 +47,25 @@ h1 { font-size: 1.4rem; margin: 0 0 0.4rem; }
.card:hover { background: var(--panel-hi); text-decoration: none; }
.card h2 { font-size: 1.05rem; margin: 0 0 0.3rem; color: var(--accent); }
.card p { margin: 0; color: var(--ink-dim); font-size: 0.9rem; }
+
+.panel {
+ padding: 0.9rem 1.1rem;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ margin-bottom: 1rem;
+}
+.panel h2 { font-size: 1rem; margin: 0 0 0.6rem; color: var(--ink); }
+.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 1rem; }
+.grid .panel { margin-bottom: 0; }
+.kv { list-style: none; margin: 0; padding: 0; }
+.kv li { padding: 0.15rem 0; color: var(--ink-dim); }
+.counts { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
+.counts td { padding: 0.2rem 0; border-bottom: 1px solid var(--line); color: var(--ink-dim); }
+.counts td.num { text-align: right; color: var(--ink); font-variant-numeric: tabular-nums; }
+.bignum { font-size: 1.6rem; margin: 0; color: var(--ink); }
+.note { color: var(--ink-dim); font-style: italic; margin: 0.2rem 0; }
+.errors { border-color: var(--danger); }
+.errors ul { margin: 0; padding-left: 1.1rem; color: var(--danger); }
+.ok { color: var(--ok); }
+.bad { color: var(--danger); }
diff --git a/backend/internal/adminconsole/dashboard.go b/backend/internal/adminconsole/dashboard.go
new file mode 100644
index 0000000..ed39ea3
--- /dev/null
+++ b/backend/internal/adminconsole/dashboard.go
@@ -0,0 +1,24 @@
+package adminconsole
+
+// StatusCount pairs a status label with its current row count for the
+// dashboard's per-status tables. It is the view-layer counterpart of the
+// data gathered by the ops-status reader; the server handler maps between
+// them so this package stays free of database concerns.
+type StatusCount struct {
+ Status string
+ Count int64
+}
+
+// DashboardData is the view model for the console landing page. MonitorAvailable
+// is false when no ops-status reader is wired, in which case the monitoring
+// panels are omitted. Errors carries non-fatal probe failures for display.
+type DashboardData struct {
+ MonitorAvailable bool
+ BackendReady bool
+ PostgresHealthy bool
+ Runtimes []StatusCount
+ MailDeliveries []StatusCount
+ NotificationRoutes []StatusCount
+ NotificationMalformed int64
+ Errors []string
+}
diff --git a/backend/internal/adminconsole/templates/pages/dashboard.gohtml b/backend/internal/adminconsole/templates/pages/dashboard.gohtml
index cef9d9f..721323f 100644
--- a/backend/internal/adminconsole/templates/pages/dashboard.gohtml
+++ b/backend/internal/adminconsole/templates/pages/dashboard.gohtml
@@ -1,6 +1,43 @@
{{define "content" -}}
Dashboard
Signed in as {{.Username}}.
+{{with .Data}}
+
+ Health
+
+ - Backend ready: {{if .BackendReady}}yes{{else}}no{{end}}
+ - Postgres: {{if .PostgresHealthy}}healthy{{else}}unreachable{{end}}
+
+
+{{if .MonitorAvailable}}
+
+
+ Game runtimes
+ {{template "statuscounts" .Runtimes}}
+
+
+ Mail deliveries
+ {{template "statuscounts" .MailDeliveries}}
+
+
+ Notification routes
+ {{template "statuscounts" .NotificationRoutes}}
+
+
+ Malformed notifications
+ {{.NotificationMalformed}}
+
+
+{{if .Errors}}
+
+ Collection errors
+ {{range .Errors}}- {{.}}
{{end}}
+
+{{end}}
+{{else}}
+Monitoring is not wired in this deployment.
+{{end}}
+{{end}}
{{- end}}
+
+{{define "statuscounts" -}}
+{{if .}}
+
+{{range .}}| {{.Status}} | {{.Count}} |
{{end}}
+
+{{else}}
+none
+{{end}}
+{{- end}}
diff --git a/backend/internal/opsstatus/opsstatus.go b/backend/internal/opsstatus/opsstatus.go
new file mode 100644
index 0000000..03ed330
--- /dev/null
+++ b/backend/internal/opsstatus/opsstatus.go
@@ -0,0 +1,139 @@
+// Package opsstatus reads point-in-time operational signals from Postgres for
+// the admin console dashboard: database reachability, per-status counts of game
+// runtimes, mail deliveries, and notification routes, plus the malformed
+// notification-intent count.
+//
+// It is a read-only projection built entirely through the go-jet query builder
+// against the generated table bindings; it owns no business logic and mutates
+// nothing. Richer, historical metrics are out of scope — those belong to the
+// Prometheus exporters wired on `backend` and `gateway`.
+package opsstatus
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "time"
+
+ "galaxy/backend/internal/postgres/jet/backend/table"
+
+ "github.com/go-jet/jet/v2/postgres"
+)
+
+// defaultCollectTimeout bounds a single Collect call so a slow or wedged
+// database cannot hang the dashboard request.
+const defaultCollectTimeout = 3 * time.Second
+
+// StatusCount pairs a status value with the number of rows currently in it.
+type StatusCount struct {
+ Status string
+ Count int64
+}
+
+// Snapshot is a point-in-time view of the operational signals rendered on the
+// dashboard. Errors collects per-query failures so a single failing probe
+// degrades to a visible note rather than failing the whole page.
+type Snapshot struct {
+ PostgresHealthy bool
+ Runtimes []StatusCount
+ MailDeliveries []StatusCount
+ NotificationRoutes []StatusCount
+ NotificationMalformed int64
+ Errors []string
+}
+
+// Reader collects an operational Snapshot. The admin console depends on this
+// interface so the dashboard can be tested without a database.
+type Reader interface {
+ Collect(ctx context.Context) Snapshot
+}
+
+// Store is the Postgres-backed Reader.
+type Store struct {
+ db *sql.DB
+ timeout time.Duration
+}
+
+// NewStore constructs a Store reading from db.
+func NewStore(db *sql.DB) *Store {
+ return &Store{db: db, timeout: defaultCollectTimeout}
+}
+
+// Collect gathers the dashboard signals within a bounded timeout. It never
+// returns an error: a failed probe is recorded in Snapshot.Errors and the
+// remaining probes still run, except that a failed Postgres ping short-circuits
+// the rest (the dependent queries would only fail the same way).
+func (s *Store) Collect(ctx context.Context) Snapshot {
+ ctx, cancel := context.WithTimeout(ctx, s.timeout)
+ defer cancel()
+
+ var snap Snapshot
+
+ if err := s.db.PingContext(ctx); err != nil {
+ snap.Errors = append(snap.Errors, fmt.Sprintf("postgres ping: %v", err))
+ return snap
+ }
+ snap.PostgresHealthy = true
+
+ if counts, err := s.statusCounts(ctx, table.RuntimeRecords.Status, table.RuntimeRecords); err != nil {
+ snap.Errors = append(snap.Errors, fmt.Sprintf("runtime status counts: %v", err))
+ } else {
+ snap.Runtimes = counts
+ }
+
+ if counts, err := s.statusCounts(ctx, table.MailDeliveries.Status, table.MailDeliveries); err != nil {
+ snap.Errors = append(snap.Errors, fmt.Sprintf("mail delivery counts: %v", err))
+ } else {
+ snap.MailDeliveries = counts
+ }
+
+ if counts, err := s.statusCounts(ctx, table.NotificationRoutes.Status, table.NotificationRoutes); err != nil {
+ snap.Errors = append(snap.Errors, fmt.Sprintf("notification route counts: %v", err))
+ } else {
+ snap.NotificationRoutes = counts
+ }
+
+ if n, err := s.countAll(ctx, table.NotificationMalformedIntents); err != nil {
+ snap.Errors = append(snap.Errors, fmt.Sprintf("malformed notification count: %v", err))
+ } else {
+ snap.NotificationMalformed = n
+ }
+
+ return snap
+}
+
+// statusCounts runs `SELECT status, COUNT(*) FROM GROUP BY status`
+// through jet and returns the rows ordered by status.
+func (s *Store) statusCounts(ctx context.Context, status postgres.ColumnString, from postgres.ReadableTable) ([]StatusCount, error) {
+ stmt := postgres.SELECT(
+ status.AS("status_count.status"),
+ postgres.COUNT(postgres.STAR).AS("status_count.count"),
+ ).FROM(from).GROUP_BY(status).ORDER_BY(status.ASC())
+
+ var rows []struct {
+ Status string `alias:"status_count.status"`
+ Count int64 `alias:"status_count.count"`
+ }
+ if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
+ return nil, err
+ }
+
+ out := make([]StatusCount, len(rows))
+ for i, row := range rows {
+ out[i] = StatusCount{Status: row.Status, Count: row.Count}
+ }
+ return out, nil
+}
+
+// countAll runs `SELECT COUNT(*) FROM ` through jet.
+func (s *Store) countAll(ctx context.Context, from postgres.ReadableTable) (int64, error) {
+ stmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).FROM(from)
+
+ var row struct {
+ Count int64 `alias:"count"`
+ }
+ if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
+ return 0, err
+ }
+ return row.Count, nil
+}
diff --git a/backend/internal/opsstatus/opsstatus_test.go b/backend/internal/opsstatus/opsstatus_test.go
new file mode 100644
index 0000000..b2edf27
--- /dev/null
+++ b/backend/internal/opsstatus/opsstatus_test.go
@@ -0,0 +1,155 @@
+package opsstatus_test
+
+import (
+ "context"
+ "database/sql"
+ "net/url"
+ "testing"
+ "time"
+
+ "galaxy/backend/internal/mail"
+ "galaxy/backend/internal/opsstatus"
+ backendpg "galaxy/backend/internal/postgres"
+ pgshared "galaxy/postgres"
+
+ "github.com/google/uuid"
+ testcontainers "github.com/testcontainers/testcontainers-go"
+ tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
+ "github.com/testcontainers/testcontainers-go/wait"
+)
+
+const (
+ pgImage = "postgres:16-alpine"
+ pgUser = "galaxy"
+ pgPassword = "galaxy"
+ pgDatabase = "galaxy_backend"
+ pgSchema = "backend"
+ pgStartup = 90 * time.Second
+ pgOpTO = 10 * time.Second
+)
+
+// startPostgres mirrors the per-package scaffolding used by the other store
+// tests: spin up Postgres, apply migrations, return *sql.DB.
+func startPostgres(t *testing.T) *sql.DB {
+ t.Helper()
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
+ t.Cleanup(cancel)
+
+ pgContainer, err := tcpostgres.Run(ctx, pgImage,
+ tcpostgres.WithDatabase(pgDatabase),
+ tcpostgres.WithUsername(pgUser),
+ tcpostgres.WithPassword(pgPassword),
+ testcontainers.WithWaitStrategy(
+ wait.ForLog("database system is ready to accept connections").
+ WithOccurrence(2).
+ WithStartupTimeout(pgStartup),
+ ),
+ )
+ if err != nil {
+ t.Skipf("postgres testcontainer unavailable, skipping: %v", err)
+ }
+ t.Cleanup(func() {
+ if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil {
+ t.Errorf("terminate postgres container: %v", termErr)
+ }
+ })
+
+ baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
+ if err != nil {
+ t.Fatalf("connection string: %v", err)
+ }
+ scopedDSN, err := dsnWithSearchPath(baseDSN, pgSchema)
+ if err != nil {
+ t.Fatalf("scope dsn: %v", err)
+ }
+
+ cfg := pgshared.DefaultConfig()
+ cfg.PrimaryDSN = scopedDSN
+ cfg.OperationTimeout = pgOpTO
+
+ db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
+ if err != nil {
+ t.Fatalf("open primary: %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+
+ if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil {
+ t.Fatalf("ping: %v", err)
+ }
+ if err := backendpg.ApplyMigrations(ctx, db); err != nil {
+ t.Fatalf("apply migrations: %v", err)
+ }
+ return db
+}
+
+func dsnWithSearchPath(baseDSN, schema string) (string, error) {
+ parsed, err := url.Parse(baseDSN)
+ if err != nil {
+ return "", err
+ }
+ values := parsed.Query()
+ values.Set("search_path", schema)
+ if values.Get("sslmode") == "" {
+ values.Set("sslmode", "disable")
+ }
+ parsed.RawQuery = values.Encode()
+ return parsed.String(), nil
+}
+
+func TestStoreCollect(t *testing.T) {
+ db := startPostgres(t)
+ store := opsstatus.NewStore(db)
+ ctx := context.Background()
+
+ // Empty schema: queries must execute cleanly with zero counts.
+ empty := store.Collect(ctx)
+ if !empty.PostgresHealthy {
+ t.Fatal("PostgresHealthy must be true against a reachable database")
+ }
+ if len(empty.Errors) != 0 {
+ t.Fatalf("unexpected collection errors: %v", empty.Errors)
+ }
+ if got := totalCount(empty.MailDeliveries); got != 0 {
+ t.Fatalf("mail deliveries total = %d, want 0", got)
+ }
+ if len(empty.Runtimes) != 0 || len(empty.NotificationRoutes) != 0 {
+ t.Fatalf("expected empty status slices, got runtimes=%v routes=%v", empty.Runtimes, empty.NotificationRoutes)
+ }
+ if empty.NotificationMalformed != 0 {
+ t.Fatalf("malformed notifications = %d, want 0", empty.NotificationMalformed)
+ }
+
+ // Enqueue one mail delivery and confirm the GROUP BY count reflects it.
+ mailStore := mail.NewStore(db)
+ inserted, err := mailStore.InsertEnqueue(ctx, mail.EnqueueArgs{
+ DeliveryID: uuid.New(),
+ TemplateID: mail.TemplateLoginCode,
+ IdempotencyKey: uuid.NewString(),
+ Recipients: []string{"ops@example.test"},
+ ContentType: "text/plain",
+ Subject: "hello",
+ Body: []byte("hi"),
+ })
+ if err != nil {
+ t.Fatalf("insert mail delivery: %v", err)
+ }
+ if !inserted {
+ t.Fatal("expected the delivery to be inserted")
+ }
+
+ after := store.Collect(ctx)
+ if len(after.Errors) != 0 {
+ t.Fatalf("unexpected collection errors after insert: %v", after.Errors)
+ }
+ if got := totalCount(after.MailDeliveries); got != 1 {
+ t.Fatalf("mail deliveries total after insert = %d, want 1 (statuses: %v)", got, after.MailDeliveries)
+ }
+}
+
+func totalCount(counts []opsstatus.StatusCount) int64 {
+ var total int64
+ for _, c := range counts {
+ total += c.Count
+ }
+ return total
+}
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
index fd9b6b5..1846eb8 100644
--- a/backend/internal/server/handlers_admin_console.go
+++ b/backend/internal/server/handlers_admin_console.go
@@ -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)
diff --git a/backend/internal/server/handlers_admin_console_test.go b/backend/internal/server/handlers_admin_console_test.go
index 1c99fc1..0bf8926 100644
--- a/backend/internal/server/handlers_admin_console_test.go
+++ b/backend/internal/server/handlers_admin_console_test.go
@@ -1,6 +1,7 @@
package server
import (
+ "context"
"io"
"net/http"
"net/http/httptest"
@@ -8,18 +9,28 @@ import (
"testing"
"galaxy/backend/internal/adminconsole"
+ "galaxy/backend/internal/opsstatus"
"galaxy/backend/internal/server/middleware/basicauth"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
+// fakeMonitor is a static opsstatus.Reader for dashboard rendering tests.
+type fakeMonitor struct {
+ snapshot opsstatus.Snapshot
+}
+
+func (f fakeMonitor) Collect(context.Context) opsstatus.Snapshot {
+ return f.snapshot
+}
+
func newConsoleTestRouter(t *testing.T) http.Handler {
t.Helper()
handler, err := NewRouter(RouterDependencies{
Logger: zap.NewNop(),
AdminVerifier: basicauth.NewStaticVerifier("secret"),
- AdminConsole: NewAdminConsoleHandlers(nil, adminconsole.NewCSRF([]byte("test-key")), nil),
+ AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: adminconsole.NewCSRF([]byte("test-key"))}),
})
if err != nil {
t.Fatalf("NewRouter: %v", err)
@@ -67,6 +78,68 @@ func TestAdminConsoleDashboardRenders(t *testing.T) {
}
}
+func TestAdminConsoleDashboardShowsMonitoring(t *testing.T) {
+ monitor := fakeMonitor{snapshot: opsstatus.Snapshot{
+ PostgresHealthy: true,
+ Runtimes: []opsstatus.StatusCount{{Status: "running", Count: 3}, {Status: "stopped", Count: 1}},
+ MailDeliveries: []opsstatus.StatusCount{{Status: "pending", Count: 2}},
+ NotificationRoutes: []opsstatus.StatusCount{{Status: "published", Count: 9}},
+ NotificationMalformed: 4,
+ Errors: []string{"notification route counts: boom"},
+ }}
+
+ handler, err := NewRouter(RouterDependencies{
+ Logger: zap.NewNop(),
+ AdminVerifier: basicauth.NewStaticVerifier("secret"),
+ AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{
+ CSRF: adminconsole.NewCSRF([]byte("test-key")),
+ Monitor: monitor,
+ Ready: func() bool { return true },
+ }),
+ })
+ if err != nil {
+ t.Fatalf("NewRouter: %v", err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/_gm/", nil)
+ req.SetBasicAuth("ops", "secret")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ body := rec.Body.String()
+ for _, want := range []string{
+ "Game runtimes", "running", "stopped",
+ "Mail deliveries", "pending",
+ "Notification routes", "published",
+ "Malformed notifications",
+ "notification route counts: boom",
+ "healthy",
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("dashboard body missing %q", want)
+ }
+ }
+}
+
+func TestAdminConsoleDashboardWithoutMonitor(t *testing.T) {
+ router := newConsoleTestRouter(t) // no monitor wired
+
+ req := httptest.NewRequest(http.MethodGet, "/_gm/", nil)
+ req.SetBasicAuth("ops", "secret")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "Monitoring is not wired") {
+ t.Error("dashboard without a monitor should note that monitoring is unavailable")
+ }
+}
+
func TestAdminConsoleServesAsset(t *testing.T) {
router := newConsoleTestRouter(t)
@@ -87,7 +160,7 @@ func TestAdminConsoleRequireCSRF(t *testing.T) {
gin.SetMode(gin.TestMode)
csrf := adminconsole.NewCSRF([]byte("test-key"))
- console := NewAdminConsoleHandlers(nil, csrf, nil)
+ console := NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf})
engine := gin.New()
engine.Use(func(c *gin.Context) {
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index c5096d1..a771037 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -602,7 +602,13 @@ only the edge anti-abuse layer.
State-changing requests are guarded against CSRF by a stateless token
(HMAC-SHA256 over the authenticated username, keyed by
`BACKEND_ADMIN_CONSOLE_CSRF_KEY`; a per-process random key is used when the
-variable is unset) plus a same-origin `Origin`/`Referer` check. See
+variable is unset) plus a same-origin `Origin`/`Referer` check.
+
+The console landing page is a dashboard that surfaces backend-visible
+operational signals — database reachability, per-status game-runtime counts,
+and mail/notification queue depths — read directly through the persistence
+layer; richer historical metrics come from the Prometheus exporters on
+`backend` and `gateway` (see [§17](#17-observability)). See
`backend/docs/admin-console.md` for the console design.
## 15. Transport Security Model (gateway boundary)
diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md
index 5ec8a75..d9b22cf 100644
--- a/docs/FUNCTIONAL.md
+++ b/docs/FUNCTIONAL.md
@@ -1178,6 +1178,12 @@ limiting and request limits as the public API, and it carries an
anti-CSRF token on every change. The JSON admin API stays internal to
the deployment.
+The console landing page is a dashboard that summarises operational
+health: whether the backend is ready and the database reachable, how many
+game runtimes sit in each state, and the depth of the mail and
+notification queues. It is a read-only point-in-time view for quick
+triage, not a metrics history.
+
### 10.3 Admin account management
Existing admins can list other admins, create new ones, look up a
diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md
index 93c2490..a81f6d7 100644
--- a/docs/FUNCTIONAL_ru.md
+++ b/docs/FUNCTIONAL_ru.md
@@ -1214,6 +1214,12 @@ admin-API, либо через серверно-рендеримую веб-ко
анти-CSRF-токен на каждом изменении. JSON admin-API остаётся
внутренним для деплоя.
+Стартовая страница консоли — дашборд, сводящий операционное
+здоровье: готов ли backend и доступна ли БД, сколько игровых рантаймов
+в каждом состоянии, какова глубина очередей почты и уведомлений. Это
+read-only-срез на текущий момент для быстрой диагностики, не история
+метрик.
+
### 10.3 Управление admin-аккаунтами
Существующие админы могут перечислять других админов, создавать