feat(admin-console): server-rendered operator console at /_gm #87
@@ -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}}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1214,6 +1214,12 @@ admin-API, либо через серверно-рендеримую веб-ко
|
|||||||
анти-CSRF-токен на каждом изменении. JSON admin-API остаётся
|
анти-CSRF-токен на каждом изменении. JSON admin-API остаётся
|
||||||
внутренним для деплоя.
|
внутренним для деплоя.
|
||||||
|
|
||||||
|
Стартовая страница консоли — дашборд, сводящий операционное
|
||||||
|
здоровье: готов ли backend и доступна ли БД, сколько игровых рантаймов
|
||||||
|
в каждом состоянии, какова глубина очередей почты и уведомлений. Это
|
||||||
|
read-only-срез на текущий момент для быстрой диагностики, не история
|
||||||
|
метрик.
|
||||||
|
|
||||||
### 10.3 Управление admin-аккаунтами
|
### 10.3 Управление admin-аккаунтами
|
||||||
|
|
||||||
Существующие админы могут перечислять других админов, создавать
|
Существующие админы могут перечислять других админов, создавать
|
||||||
|
|||||||
Reference in New Issue
Block a user