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:
@@ -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,
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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" -}}
|
||||
<h1>Dashboard</h1>
|
||||
<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">
|
||||
<a class="card" href="/_gm/users">
|
||||
<h2>Users</h2>
|
||||
@@ -20,3 +57,13 @@
|
||||
</a>
|
||||
</section>
|
||||
{{- 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}}
|
||||
|
||||
@@ -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"
|
||||
|
||||
"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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1214,6 +1214,12 @@ admin-API, либо через серверно-рендеримую веб-ко
|
||||
анти-CSRF-токен на каждом изменении. JSON admin-API остаётся
|
||||
внутренним для деплоя.
|
||||
|
||||
Стартовая страница консоли — дашборд, сводящий операционное
|
||||
здоровье: готов ли backend и доступна ли БД, сколько игровых рантаймов
|
||||
в каждом состоянии, какова глубина очередей почты и уведомлений. Это
|
||||
read-only-срез на текущий момент для быстрой диагностики, не история
|
||||
метрик.
|
||||
|
||||
### 10.3 Управление admin-аккаунтами
|
||||
|
||||
Существующие админы могут перечислять других админов, создавать
|
||||
|
||||
Reference in New Issue
Block a user