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

+ +
+{{if .MonitorAvailable}} +
+
+

Game runtimes

+ {{template "statuscounts" .Runtimes}} +
+
+

Mail deliveries

+ {{template "statuscounts" .MailDeliveries}} +
+
+

Notification routes

+ {{template "statuscounts" .NotificationRoutes}} +
+
+

Malformed notifications

+

{{.NotificationMalformed}}

+
+
+{{if .Errors}} +
+

Collection errors

+ +
+{{end}} +{{else}} +

Monitoring is not wired in this deployment.

+{{end}} +{{end}}

Users

@@ -20,3 +57,13 @@
{{- end}} + +{{define "statuscounts" -}} +{{if .}} + +{{range .}}{{end}} +
{{.Status}}{{.Count}}
+{{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-аккаунтами Существующие админы могут перечислять других админов, создавать