feat(admin-console): server-rendered operator console at /_gm #87

Merged
developer merged 6 commits from feature/admin-console into development 2026-05-31 19:07:48 +00:00
11 changed files with 544 additions and 14 deletions
Showing only changes of commit 985e51d25e - Show all commits
+11 -5
View File
@@ -38,6 +38,7 @@ import (
"galaxy/backend/internal/mail" "galaxy/backend/internal/mail"
"galaxy/backend/internal/metricsapi" "galaxy/backend/internal/metricsapi"
"galaxy/backend/internal/notification" "galaxy/backend/internal/notification"
"galaxy/backend/internal/opsstatus"
backendpostgres "galaxy/backend/internal/postgres" backendpostgres "galaxy/backend/internal/postgres"
"galaxy/backend/push" "galaxy/backend/push"
"galaxy/backend/internal/runtime" "galaxy/backend/internal/runtime"
@@ -357,6 +358,10 @@ func run(ctx context.Context) (err error) {
userGamesHandlers := backendserver.NewUserGamesHandlers(runtimeSvc, engineCli, logger) userGamesHandlers := backendserver.NewUserGamesHandlers(runtimeSvc, engineCli, logger)
userMailHandlers := backendserver.NewUserMailHandlers(diplomailSvc, lobbySvc, userSvc, 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 var consoleCSRF *adminconsole.CSRF
if cfg.AdminConsole.CSRFKey != "" { if cfg.AdminConsole.CSRFKey != "" {
consoleCSRF = adminconsole.NewCSRF([]byte(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)", 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")) zap.String("env", "BACKEND_ADMIN_CONSOLE_CSRF_KEY"))
} }
adminConsoleHandlers := backendserver.NewAdminConsoleHandlers(adminconsole.MustNewRenderer(), consoleCSRF, logger) adminConsoleHandlers := backendserver.NewAdminConsoleHandlers(backendserver.AdminConsoleDeps{
CSRF: consoleCSRF,
ready := func() bool { Monitor: opsstatus.NewStore(db),
return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready() Ready: ready,
} Logger: logger,
})
handler, err := backendserver.NewRouter(backendserver.RouterDependencies{ handler, err := backendserver.NewRouter(backendserver.RouterDependencies{
Logger: logger, Logger: logger,
@@ -47,3 +47,25 @@ h1 { font-size: 1.4rem; margin: 0 0 0.4rem; }
.card:hover { background: var(--panel-hi); text-decoration: none; } .card:hover { background: var(--panel-hi); text-decoration: none; }
.card h2 { font-size: 1.05rem; margin: 0 0 0.3rem; color: var(--accent); } .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; } .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); }
@@ -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
}
@@ -1,6 +1,43 @@
{{define "content" -}} {{define "content" -}}
<h1>Dashboard</h1> <h1>Dashboard</h1>
<p class="lede">Signed in as <strong>{{.Username}}</strong>.</p> <p class="lede">Signed in as <strong>{{.Username}}</strong>.</p>
{{with .Data}}
<section class="panel">
<h2>Health</h2>
<ul class="kv">
<li>Backend ready: {{if .BackendReady}}<span class="ok">yes</span>{{else}}<span class="bad">no</span>{{end}}</li>
<li>Postgres: {{if .PostgresHealthy}}<span class="ok">healthy</span>{{else}}<span class="bad">unreachable</span>{{end}}</li>
</ul>
</section>
{{if .MonitorAvailable}}
<div class="grid">
<section class="panel">
<h2>Game runtimes</h2>
{{template "statuscounts" .Runtimes}}
</section>
<section class="panel">
<h2>Mail deliveries</h2>
{{template "statuscounts" .MailDeliveries}}
</section>
<section class="panel">
<h2>Notification routes</h2>
{{template "statuscounts" .NotificationRoutes}}
</section>
<section class="panel">
<h2>Malformed notifications</h2>
<p class="bignum {{if gt .NotificationMalformed 0}}bad{{end}}">{{.NotificationMalformed}}</p>
</section>
</div>
{{if .Errors}}
<section class="panel errors">
<h2>Collection errors</h2>
<ul>{{range .Errors}}<li>{{.}}</li>{{end}}</ul>
</section>
{{end}}
{{else}}
<p class="note">Monitoring is not wired in this deployment.</p>
{{end}}
{{end}}
<section class="cards"> <section class="cards">
<a class="card" href="/_gm/users"> <a class="card" href="/_gm/users">
<h2>Users</h2> <h2>Users</h2>
@@ -20,3 +57,13 @@
</a> </a>
</section> </section>
{{- end}} {{- end}}
{{define "statuscounts" -}}
{{if .}}
<table class="counts"><tbody>
{{range .}}<tr><td>{{.Status}}</td><td class="num">{{.Count}}</td></tr>{{end}}
</tbody></table>
{{else}}
<p class="note">none</p>
{{end}}
{{- end}}
+139
View File
@@ -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 <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 <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
}
@@ -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
}
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"galaxy/backend/internal/adminconsole" "galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/opsstatus"
"galaxy/backend/internal/server/httperr" "galaxy/backend/internal/server/httperr"
"galaxy/backend/internal/server/middleware/basicauth" "galaxy/backend/internal/server/middleware/basicauth"
@@ -25,22 +26,38 @@ type AdminConsoleHandlers struct {
renderer *adminconsole.Renderer renderer *adminconsole.Renderer
csrf *adminconsole.CSRF csrf *adminconsole.CSRF
assets http.Handler assets http.Handler
monitor opsstatus.Reader
ready func() bool
logger *zap.Logger logger *zap.Logger
} }
// NewAdminConsoleHandlers constructs the console handler set. A nil renderer // AdminConsoleDeps bundles the collaborators for the operator console. Every
// falls back to the embedded default templates; a nil csrf falls back to a // field is optional: a nil Renderer or CSRF falls back to the embedded default
// fresh per-process random key; a nil logger falls back to zap.NewNop. It // 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 // panics only on conditions that are unrecoverable at startup (template parse
// failure or crypto/rand failure), both of which indicate a broken build or // failure or crypto/rand failure), both of which indicate a broken build or
// host rather than a runtime input. // 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 { if logger == nil {
logger = zap.NewNop() logger = zap.NewNop()
} }
renderer := deps.Renderer
if renderer == nil { if renderer == nil {
renderer = adminconsole.MustNewRenderer() renderer = adminconsole.MustNewRenderer()
} }
csrf := deps.CSRF
if csrf == nil { if csrf == nil {
generated, err := adminconsole.NewRandomCSRF() generated, err := adminconsole.NewRandomCSRF()
if err != nil { if err != nil {
@@ -58,17 +75,46 @@ func NewAdminConsoleHandlers(renderer *adminconsole.Renderer, csrf *adminconsole
renderer: renderer, renderer: renderer,
csrf: csrf, csrf: csrf,
assets: http.StripPrefix("/_gm/assets/", http.FileServer(http.FS(assetsFS))), assets: http.StripPrefix("/_gm/assets/", http.FileServer(http.FS(assetsFS))),
monitor: deps.Monitor,
ready: deps.Ready,
logger: logger.Named("http.admin.console"), 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 { func (h *AdminConsoleHandlers) Dashboard() gin.HandlerFunc {
return func(c *gin.Context) { 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/`. // Asset serves the embedded console static assets under `/_gm/assets/`.
func (h *AdminConsoleHandlers) Asset() gin.HandlerFunc { func (h *AdminConsoleHandlers) Asset() gin.HandlerFunc {
return gin.WrapH(h.assets) return gin.WrapH(h.assets)
@@ -1,6 +1,7 @@
package server package server
import ( import (
"context"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -8,18 +9,28 @@ import (
"testing" "testing"
"galaxy/backend/internal/adminconsole" "galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/opsstatus"
"galaxy/backend/internal/server/middleware/basicauth" "galaxy/backend/internal/server/middleware/basicauth"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.uber.org/zap" "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 { func newConsoleTestRouter(t *testing.T) http.Handler {
t.Helper() t.Helper()
handler, err := NewRouter(RouterDependencies{ handler, err := NewRouter(RouterDependencies{
Logger: zap.NewNop(), Logger: zap.NewNop(),
AdminVerifier: basicauth.NewStaticVerifier("secret"), 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 { if err != nil {
t.Fatalf("NewRouter: %v", err) 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) { func TestAdminConsoleServesAsset(t *testing.T) {
router := newConsoleTestRouter(t) router := newConsoleTestRouter(t)
@@ -87,7 +160,7 @@ func TestAdminConsoleRequireCSRF(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
csrf := adminconsole.NewCSRF([]byte("test-key")) csrf := adminconsole.NewCSRF([]byte("test-key"))
console := NewAdminConsoleHandlers(nil, csrf, nil) console := NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf})
engine := gin.New() engine := gin.New()
engine.Use(func(c *gin.Context) { engine.Use(func(c *gin.Context) {
+7 -1
View File
@@ -602,7 +602,13 @@ only the edge anti-abuse layer.
State-changing requests are guarded against CSRF by a stateless token State-changing requests are guarded against CSRF by a stateless token
(HMAC-SHA256 over the authenticated username, keyed by (HMAC-SHA256 over the authenticated username, keyed by
`BACKEND_ADMIN_CONSOLE_CSRF_KEY`; a per-process random key is used when the `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. `backend/docs/admin-console.md` for the console design.
## 15. Transport Security Model (gateway boundary) ## 15. Transport Security Model (gateway boundary)
+6
View File
@@ -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 anti-CSRF token on every change. The JSON admin API stays internal to
the deployment. 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 ### 10.3 Admin account management
Existing admins can list other admins, create new ones, look up a Existing admins can list other admins, create new ones, look up a
+6
View File
@@ -1214,6 +1214,12 @@ admin-API, либо через серверно-рендеримую веб-ко
анти-CSRF-токен на каждом изменении. JSON admin-API остаётся анти-CSRF-токен на каждом изменении. JSON admin-API остаётся
внутренним для деплоя. внутренним для деплоя.
Стартовая страница консоли — дашборд, сводящий операционное
здоровье: готов ли backend и доступна ли БД, сколько игровых рантаймов
в каждом состоянии, какова глубина очередей почты и уведомлений. Это
read-only-срез на текущий момент для быстрой диагностики, не история
метрик.
### 10.3 Управление admin-аккаунтами ### 10.3 Управление admin-аккаунтами
Существующие админы могут перечислять других админов, создавать Существующие админы могут перечислять других админов, создавать