diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 07435f9..3c2ab5a 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -375,10 +375,13 @@ func run(ctx context.Context) (err error) { } adminConsoleHandlers := backendserver.NewAdminConsoleHandlers(backendserver.AdminConsoleDeps{ CSRF: consoleCSRF, - Monitor: opsstatus.NewStore(db), - Ready: ready, - Users: userSvc, - Logger: logger, + Monitor: opsstatus.NewStore(db), + Ready: ready, + Users: userSvc, + Games: lobbySvc, + Runtime: runtimeSvc, + EngineVersions: engineVersionSvc, + Logger: logger, }) handler, err := backendserver.NewRouter(backendserver.RouterDependencies{ diff --git a/backend/docs/admin-console.md b/backend/docs/admin-console.md index 929234f..d181a81 100644 --- a/backend/docs/admin-console.md +++ b/backend/docs/admin-console.md @@ -91,10 +91,23 @@ changes. | `/_gm/users/{id}/block` | POST | Apply a permanent block (reason required). | | `/_gm/users/{id}/entitlement` | POST | Set the entitlement tier. | | `/_gm/users/{id}/soft-delete` | POST | Soft-delete the account (cascades). | +| `/_gm/games` | GET/POST | Paginated game list; POST creates a public game. | +| `/_gm/games/{id}` | GET | Game detail with the runtime snapshot. | +| `/_gm/games/{id}/force-start` | POST | Force-start the game. | +| `/_gm/games/{id}/force-stop` | POST | Force-stop the game. | +| `/_gm/games/{id}/ban-member` | POST | Ban a member (user id + reason). | +| `/_gm/games/{id}/runtime/restart` | POST | Restart the engine container. | +| `/_gm/games/{id}/runtime/patch` | POST | Patch the runtime to a target version. | +| `/_gm/games/{id}/runtime/force-next-turn` | POST | Force the next turn now. | +| `/_gm/engine-versions` | GET/POST | Version registry; POST registers a version. | +| `/_gm/engine-versions/{ver}/disable` | POST | Disable a registered version. | Each page reuses the same service layer as the corresponding `/api/v1/admin/*` -JSON endpoint; the console adds no business logic. Unblocking a user is not yet -available because the JSON admin API exposes no remove-sanction endpoint. +JSON endpoint; the console adds no business logic. Collection-mutating POSTs are +mounted on the collection path (`POST /_gm/games`, `POST /_gm/engine-versions`) +so a static action segment never collides with a path parameter in the gin +router. Unblocking a user is not yet available because the JSON admin API +exposes no remove-sanction endpoint. ## Configuration diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css index a34bc8d..b4550df 100644 --- a/backend/internal/adminconsole/assets/console.css +++ b/backend/internal/adminconsole/assets/console.css @@ -98,3 +98,5 @@ button { button:hover { filter: brightness(1.1); } button.danger { background: var(--danger); color: #1a0606; } code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; } +.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; } +.actions form { margin: 0; } diff --git a/backend/internal/adminconsole/games.go b/backend/internal/adminconsole/games.go new file mode 100644 index 0000000..eb733ff --- /dev/null +++ b/backend/internal/adminconsole/games.go @@ -0,0 +1,67 @@ +package adminconsole + +// GameRow is one line in the games list table. +type GameRow struct { + GameID string + GameName string + Visibility string + Status string + Owner string + Players string + TurnSchedule string + CreatedAt string +} + +// GamesListData is the view model for the paginated games list. +type GamesListData struct { + Items []GameRow + Page int + PageSize int + Total int + HasPrev bool + HasNext bool + PrevPage int + NextPage int +} + +// GameDetailData is the view model for a single game, combining the lobby +// record with the runtime snapshot and the available actions. +type GameDetailData struct { + GameID string + GameName string + Description string + Visibility string + Status string + Owner string + MinPlayers int32 + MaxPlayers int32 + StartGapHours int32 + StartGapPlayers int32 + TurnSchedule string + TargetEngineVersion string + EnrollmentEndsAt string + CreatedAt string + StartedAt string + FinishedAt string + + HasRuntime bool + RuntimeStatus string + CurrentEngineVersion string + EngineHealth string + CurrentTurn int32 + NextGenerationAt string + Paused bool +} + +// EngineVersionRow is one line in the engine-version registry table. +type EngineVersionRow struct { + Version string + ImageRef string + Enabled bool + CreatedAt string +} + +// EngineVersionsData is the view model for the engine-version registry page. +type EngineVersionsData struct { + Items []EngineVersionRow +} diff --git a/backend/internal/adminconsole/templates/pages/engine_versions.gohtml b/backend/internal/adminconsole/templates/pages/engine_versions.gohtml new file mode 100644 index 0000000..c68e7c3 --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/engine_versions.gohtml @@ -0,0 +1,30 @@ +{{define "content" -}} +{{$csrf := .CSRFToken}} +

Engine versions

+{{with .Data}} + + + +{{range .Items}} + + + + + + + +{{else}}{{end}} + +
VersionImageEnabledCreated
{{.Version}}{{.ImageRef}}{{if .Enabled}}yes{{else}}no{{end}}{{.CreatedAt}}{{if .Enabled}}
{{end}}
no engine versions
+{{end}} +
+

Register version

+
+ + + + + +
+
+{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/game_detail.gohtml b/backend/internal/adminconsole/templates/pages/game_detail.gohtml new file mode 100644 index 0000000..990a96b --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/game_detail.gohtml @@ -0,0 +1,65 @@ +{{define "content" -}} +{{$csrf := .CSRFToken}} +{{with .Data}} +

« all games

+

{{if .GameName}}{{.GameName}}{{else}}(unnamed){{end}}

+ +
+

Game

+ +{{if .Description}}

{{.Description}}

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

Runtime

+{{if .HasRuntime}} + +
+
+
+
+
+ + + +
+{{else}} +

No runtime record for this game yet.

+{{end}} +
+ +
+

Ban member

+
+ + + + +
+
+{{end}} +{{- end}} diff --git a/backend/internal/adminconsole/templates/pages/games.gohtml b/backend/internal/adminconsole/templates/pages/games.gohtml new file mode 100644 index 0000000..d213cdb --- /dev/null +++ b/backend/internal/adminconsole/templates/pages/games.gohtml @@ -0,0 +1,43 @@ +{{define "content" -}} +{{$csrf := .CSRFToken}} +

Games

+{{with .Data}} + + + +{{range .Items}} + + + + + + + + + +{{else}}{{end}} + +
NameVisibilityStatusOwnerPlayersScheduleCreated
{{if .GameName}}{{.GameName}}{{else}}(unnamed){{end}}{{.Visibility}}{{.Status}}{{.Owner}}{{.Players}}{{.TurnSchedule}}{{.CreatedAt}}
no games
+ +{{end}} +
+

Create public game

+
+ + + + + + + + + + + +
+
+{{- end}} diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 6821728..cf9b834 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -26,10 +26,13 @@ type AdminConsoleHandlers struct { renderer *adminconsole.Renderer csrf *adminconsole.CSRF assets http.Handler - monitor opsstatus.Reader - ready func() bool - users UserAdmin - logger *zap.Logger + monitor opsstatus.Reader + ready func() bool + users UserAdmin + games GameAdmin + runtime RuntimeAdmin + engineVersions EngineVersionAdmin + logger *zap.Logger } // AdminConsoleDeps bundles the collaborators for the operator console. Every @@ -40,10 +43,13 @@ type AdminConsoleHandlers struct { type AdminConsoleDeps struct { Renderer *adminconsole.Renderer CSRF *adminconsole.CSRF - Monitor opsstatus.Reader - Ready func() bool - Users UserAdmin - Logger *zap.Logger + Monitor opsstatus.Reader + Ready func() bool + Users UserAdmin + Games GameAdmin + Runtime RuntimeAdmin + EngineVersions EngineVersionAdmin + Logger *zap.Logger } // NewAdminConsoleHandlers constructs the console handler set from deps. It @@ -77,10 +83,13 @@ func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers { renderer: renderer, csrf: csrf, assets: http.StripPrefix("/_gm/assets/", http.FileServer(http.FS(assetsFS))), - monitor: deps.Monitor, - ready: deps.Ready, - users: deps.Users, - logger: logger.Named("http.admin.console"), + monitor: deps.Monitor, + ready: deps.Ready, + users: deps.Users, + games: deps.Games, + runtime: deps.Runtime, + engineVersions: deps.EngineVersions, + logger: logger.Named("http.admin.console"), } } diff --git a/backend/internal/server/handlers_admin_console_games.go b/backend/internal/server/handlers_admin_console_games.go new file mode 100644 index 0000000..b1ac5b9 --- /dev/null +++ b/backend/internal/server/handlers_admin_console_games.go @@ -0,0 +1,423 @@ +package server + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "galaxy/backend/internal/adminconsole" + "galaxy/backend/internal/lobby" + "galaxy/backend/internal/runtime" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" +) + +// GameAdmin is the subset of the lobby service the console uses for games. +type GameAdmin interface { + ListAdminGames(ctx context.Context, page, pageSize int) (lobby.GamePage, error) + GetGame(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error) + CreateGame(ctx context.Context, input lobby.CreateGameInput) (lobby.GameRecord, error) + AdminForceStart(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error) + AdminForceStop(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error) + AdminBanMember(ctx context.Context, gameID, userID uuid.UUID, reason string) (lobby.Membership, error) +} + +// RuntimeAdmin is the subset of the runtime service the console uses. +type RuntimeAdmin interface { + GetRuntime(ctx context.Context, gameID uuid.UUID) (runtime.RuntimeRecord, error) + AdminRestart(ctx context.Context, gameID uuid.UUID) (runtime.OperationLog, error) + AdminPatch(ctx context.Context, gameID uuid.UUID, targetVersion string) (runtime.OperationLog, error) + AdminForceNextTurn(ctx context.Context, gameID uuid.UUID) (runtime.OperationLog, error) +} + +// EngineVersionAdmin is the subset of the engine-version service the console uses. +type EngineVersionAdmin interface { + List(ctx context.Context) ([]runtime.EngineVersion, error) + Register(ctx context.Context, in runtime.RegisterInput) (runtime.EngineVersion, error) + Disable(ctx context.Context, version string) (runtime.EngineVersion, error) +} + +// GamesList renders GET /_gm/games. +func (h *AdminConsoleHandlers) GamesList() gin.HandlerFunc { + return func(c *gin.Context) { + if h.games == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/") + return + } + page := parsePositiveQueryInt(c.Query("page"), 1) + pageSize := parsePositiveQueryInt(c.Query("page_size"), 50) + result, err := h.games.ListAdminGames(c.Request.Context(), page, pageSize) + if err != nil { + h.logger.Error("admin console: list games", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "games", "Games", "Failed to load games.", "bad", "/_gm/") + return + } + h.render(c, http.StatusOK, "games", "games", "Games", toGamesListData(result)) + } +} + +// GameCreate handles POST /_gm/games — create a public game. +func (h *AdminConsoleHandlers) GameCreate() gin.HandlerFunc { + return func(c *gin.Context) { + if h.games == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/") + return + } + enrollmentEndsAt, err := parseConsoleDateTime(c.PostForm("enrollment_ends_at")) + if err != nil { + h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "Enrollment end must be a valid date/time.", "bad", "/_gm/games") + return + } + game, err := h.games.CreateGame(c.Request.Context(), lobby.CreateGameInput{ + OwnerUserID: nil, + Visibility: lobby.VisibilityPublic, + GameName: strings.TrimSpace(c.PostForm("game_name")), + Description: strings.TrimSpace(c.PostForm("description")), + MinPlayers: formInt32(c, "min_players"), + MaxPlayers: formInt32(c, "max_players"), + StartGapHours: formInt32(c, "start_gap_hours"), + StartGapPlayers: formInt32(c, "start_gap_players"), + EnrollmentEndsAt: enrollmentEndsAt, + TurnSchedule: strings.TrimSpace(c.PostForm("turn_schedule")), + TargetEngineVersion: strings.TrimSpace(c.PostForm("target_engine_version")), + }) + if err != nil { + if errors.Is(err, lobby.ErrInvalidInput) { + h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "The game could not be created: check the fields.", "bad", "/_gm/games") + return + } + h.logger.Error("admin console: create game", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "games", "Create failed", "Failed to create the game.", "bad", "/_gm/games") + return + } + c.Redirect(http.StatusSeeOther, "/_gm/games/"+game.GameID.String()) + } +} + +// GameDetail renders GET /_gm/games/:game_id with the runtime snapshot. +func (h *AdminConsoleHandlers) GameDetail() gin.HandlerFunc { + return func(c *gin.Context) { + if h.games == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/") + return + } + gameID, ok := parseGameIDParam(c) + if !ok { + return + } + game, err := h.games.GetGame(c.Request.Context(), gameID) + if err != nil { + if errors.Is(err, lobby.ErrNotFound) { + h.renderMessage(c, http.StatusNotFound, "games", "Game not found", "No such game.", "bad", "/_gm/games") + return + } + h.logger.Error("admin console: get game", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "games", "Games", "Failed to load the game.", "bad", "/_gm/games") + return + } + + var runtimeRecord *runtime.RuntimeRecord + if h.runtime != nil { + if record, rtErr := h.runtime.GetRuntime(c.Request.Context(), gameID); rtErr == nil { + runtimeRecord = &record + } + } + h.render(c, http.StatusOK, "game_detail", "games", game.GameName, toGameDetailData(game, runtimeRecord)) + } +} + +// GameForceStart handles POST /_gm/games/:game_id/force-start. +func (h *AdminConsoleHandlers) GameForceStart() gin.HandlerFunc { + return h.gameAction("force-start", func(ctx context.Context, gameID uuid.UUID) error { + _, err := h.games.AdminForceStart(ctx, gameID) + return err + }) +} + +// GameForceStop handles POST /_gm/games/:game_id/force-stop. +func (h *AdminConsoleHandlers) GameForceStop() gin.HandlerFunc { + return h.gameAction("force-stop", func(ctx context.Context, gameID uuid.UUID) error { + _, err := h.games.AdminForceStop(ctx, gameID) + return err + }) +} + +// GameBanMember handles POST /_gm/games/:game_id/ban-member. +func (h *AdminConsoleHandlers) GameBanMember() gin.HandlerFunc { + return func(c *gin.Context) { + if h.games == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/") + return + } + gameID, ok := parseGameIDParam(c) + if !ok { + return + } + back := "/_gm/games/" + gameID.String() + userID, err := uuid.Parse(strings.TrimSpace(c.PostForm("user_id"))) + if err != nil { + h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "User ID must be a valid UUID.", "bad", back) + return + } + if _, err := h.games.AdminBanMember(c.Request.Context(), gameID, userID, strings.TrimSpace(c.PostForm("reason"))); err != nil { + h.logger.Error("admin console: ban member", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "games", "Ban failed", "Failed to ban the member.", "bad", back) + return + } + c.Redirect(http.StatusSeeOther, back) + } +} + +// RuntimeRestart handles POST /_gm/games/:game_id/runtime/restart. +func (h *AdminConsoleHandlers) RuntimeRestart() gin.HandlerFunc { + return h.runtimeAction("restart", func(ctx context.Context, gameID uuid.UUID) error { + _, err := h.runtime.AdminRestart(ctx, gameID) + return err + }) +} + +// RuntimeForceNextTurn handles POST /_gm/games/:game_id/runtime/force-next-turn. +func (h *AdminConsoleHandlers) RuntimeForceNextTurn() gin.HandlerFunc { + return h.runtimeAction("force-next-turn", func(ctx context.Context, gameID uuid.UUID) error { + _, err := h.runtime.AdminForceNextTurn(ctx, gameID) + return err + }) +} + +// RuntimePatch handles POST /_gm/games/:game_id/runtime/patch. +func (h *AdminConsoleHandlers) RuntimePatch() gin.HandlerFunc { + return func(c *gin.Context) { + if h.runtime == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "games", "Runtime", "Runtime administration is not available.", "bad", "/_gm/games") + return + } + gameID, ok := parseGameIDParam(c) + if !ok { + return + } + back := "/_gm/games/" + gameID.String() + target := strings.TrimSpace(c.PostForm("target_version")) + if target == "" { + h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "A target version is required.", "bad", back) + return + } + if _, err := h.runtime.AdminPatch(c.Request.Context(), gameID, target); err != nil { + h.logger.Error("admin console: runtime patch", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "games", "Patch failed", "Failed to patch the runtime.", "bad", back) + return + } + c.Redirect(http.StatusSeeOther, back) + } +} + +// gameAction is the shared shape for game-state POST actions that take only the +// game id and redirect back to the detail page. +func (h *AdminConsoleHandlers) gameAction(label string, run func(context.Context, uuid.UUID) error) gin.HandlerFunc { + return func(c *gin.Context) { + if h.games == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/") + return + } + gameID, ok := parseGameIDParam(c) + if !ok { + return + } + back := "/_gm/games/" + gameID.String() + if err := run(c.Request.Context(), gameID); err != nil { + h.logger.Error("admin console: game "+label, zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "games", "Action failed", "The "+label+" action failed.", "bad", back) + return + } + c.Redirect(http.StatusSeeOther, back) + } +} + +// runtimeAction is the shared shape for runtime POST actions that take only the +// game id and redirect back to the detail page. +func (h *AdminConsoleHandlers) runtimeAction(label string, run func(context.Context, uuid.UUID) error) gin.HandlerFunc { + return func(c *gin.Context) { + if h.runtime == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "games", "Runtime", "Runtime administration is not available.", "bad", "/_gm/games") + return + } + gameID, ok := parseGameIDParam(c) + if !ok { + return + } + back := "/_gm/games/" + gameID.String() + if err := run(c.Request.Context(), gameID); err != nil { + h.logger.Error("admin console: runtime "+label, zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "games", "Action failed", "The runtime "+label+" action failed.", "bad", back) + return + } + c.Redirect(http.StatusSeeOther, back) + } +} + +// EngineVersionsList renders GET /_gm/engine-versions. +func (h *AdminConsoleHandlers) EngineVersionsList() gin.HandlerFunc { + return func(c *gin.Context) { + if h.engineVersions == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/") + return + } + items, err := h.engineVersions.List(c.Request.Context()) + if err != nil { + h.logger.Error("admin console: list engine versions", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Engine versions", "Failed to load engine versions.", "bad", "/_gm/") + return + } + h.render(c, http.StatusOK, "engine_versions", "games", "Engine versions", toEngineVersionsData(items)) + } +} + +// EngineVersionRegister handles POST /_gm/engine-versions. +func (h *AdminConsoleHandlers) EngineVersionRegister() gin.HandlerFunc { + return func(c *gin.Context) { + if h.engineVersions == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/") + return + } + enabled := c.PostForm("enabled") == "true" + _, err := h.engineVersions.Register(c.Request.Context(), runtime.RegisterInput{ + Version: strings.TrimSpace(c.PostForm("version")), + ImageRef: strings.TrimSpace(c.PostForm("image_ref")), + Enabled: &enabled, + }) + if err != nil { + if errors.Is(err, runtime.ErrInvalidInput) || errors.Is(err, runtime.ErrEngineVersionTaken) { + h.renderMessage(c, http.StatusBadRequest, "engine-versions", "Invalid input", "The version could not be registered (invalid semver, missing image, or duplicate).", "bad", "/_gm/engine-versions") + return + } + h.logger.Error("admin console: register engine version", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Register failed", "Failed to register the engine version.", "bad", "/_gm/engine-versions") + return + } + c.Redirect(http.StatusSeeOther, "/_gm/engine-versions") + } +} + +// EngineVersionDisable handles POST /_gm/engine-versions/:version/disable. +func (h *AdminConsoleHandlers) EngineVersionDisable() gin.HandlerFunc { + return func(c *gin.Context) { + if h.engineVersions == nil { + h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/") + return + } + version := strings.TrimSpace(c.Param("version")) + if _, err := h.engineVersions.Disable(c.Request.Context(), version); err != nil { + h.logger.Error("admin console: disable engine version", zap.Error(err)) + h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Disable failed", "Failed to disable the engine version.", "bad", "/_gm/engine-versions") + return + } + c.Redirect(http.StatusSeeOther, "/_gm/engine-versions") + } +} + +// formInt32 reads a non-negative int32 form field, defaulting to 0. +func formInt32(c *gin.Context, name string) int32 { + parsed, err := strconv.Atoi(strings.TrimSpace(c.PostForm(name))) + if err != nil || parsed < 0 { + return 0 + } + return int32(parsed) +} + +// parseConsoleDateTime parses the value of an +// (or an RFC 3339 timestamp) as UTC. +func parseConsoleDateTime(raw string) (time.Time, error) { + raw = strings.TrimSpace(raw) + for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05", time.RFC3339} { + if t, err := time.ParseInLocation(layout, raw, time.UTC); err == nil { + return t.UTC(), nil + } + } + return time.Time{}, errors.New("invalid date/time") +} + +// toGamesListData maps a game page into the games list view model. +func toGamesListData(page lobby.GamePage) adminconsole.GamesListData { + data := adminconsole.GamesListData{ + Items: make([]adminconsole.GameRow, 0, len(page.Items)), + Page: page.Page, + PageSize: page.PageSize, + Total: page.Total, + PrevPage: page.Page - 1, + NextPage: page.Page + 1, + HasPrev: page.Page > 1, + HasNext: page.Page*page.PageSize < page.Total, + } + for _, game := range page.Items { + data.Items = append(data.Items, adminconsole.GameRow{ + GameID: game.GameID.String(), + GameName: game.GameName, + Visibility: game.Visibility, + Status: game.Status, + Owner: ownerLabel(game.OwnerUserID), + Players: strconv.Itoa(int(game.MinPlayers)) + "–" + strconv.Itoa(int(game.MaxPlayers)), + TurnSchedule: game.TurnSchedule, + CreatedAt: fmtConsoleTime(game.CreatedAt), + }) + } + return data +} + +// toGameDetailData maps a game record and optional runtime record into the +// detail view model. +func toGameDetailData(game lobby.GameRecord, rec *runtime.RuntimeRecord) adminconsole.GameDetailData { + data := adminconsole.GameDetailData{ + GameID: game.GameID.String(), + GameName: game.GameName, + Description: game.Description, + Visibility: game.Visibility, + Status: game.Status, + Owner: ownerLabel(game.OwnerUserID), + MinPlayers: game.MinPlayers, + MaxPlayers: game.MaxPlayers, + StartGapHours: game.StartGapHours, + StartGapPlayers: game.StartGapPlayers, + TurnSchedule: game.TurnSchedule, + TargetEngineVersion: game.TargetEngineVersion, + EnrollmentEndsAt: fmtConsoleTime(game.EnrollmentEndsAt), + CreatedAt: fmtConsoleTime(game.CreatedAt), + StartedAt: fmtConsoleTimePtr(game.StartedAt), + FinishedAt: fmtConsoleTimePtr(game.FinishedAt), + } + if rec != nil { + data.HasRuntime = true + data.RuntimeStatus = rec.Status + data.CurrentEngineVersion = rec.CurrentEngineVersion + data.EngineHealth = rec.EngineHealth + data.CurrentTurn = rec.CurrentTurn + data.NextGenerationAt = fmtConsoleTimePtr(rec.NextGenerationAt) + data.Paused = rec.Paused + } + return data +} + +// toEngineVersionsData maps engine versions into the registry view model. +func toEngineVersionsData(items []runtime.EngineVersion) adminconsole.EngineVersionsData { + data := adminconsole.EngineVersionsData{Items: make([]adminconsole.EngineVersionRow, 0, len(items))} + for _, v := range items { + data.Items = append(data.Items, adminconsole.EngineVersionRow{ + Version: v.Version, + ImageRef: v.ImageRef, + Enabled: v.Enabled, + CreatedAt: fmtConsoleTime(v.CreatedAt), + }) + } + return data +} + +// ownerLabel renders an optional owner id; public games have no owner. +func ownerLabel(ownerID *uuid.UUID) string { + if ownerID == nil { + return "—" + } + return ownerID.String() +} diff --git a/backend/internal/server/handlers_admin_console_games_test.go b/backend/internal/server/handlers_admin_console_games_test.go new file mode 100644 index 0000000..3e31f42 --- /dev/null +++ b/backend/internal/server/handlers_admin_console_games_test.go @@ -0,0 +1,353 @@ +package server + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "galaxy/backend/internal/adminconsole" + "galaxy/backend/internal/lobby" + "galaxy/backend/internal/runtime" + "galaxy/backend/internal/server/middleware/basicauth" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +type fakeGameAdmin struct { + page lobby.GamePage + game lobby.GameRecord + getErr error + created lobby.CreateGameInput + + createCalls int + forceStartCalls int + forceStopCalls int + banCalls int + lastBanUser uuid.UUID + lastBanReason string +} + +func (f *fakeGameAdmin) ListAdminGames(context.Context, int, int) (lobby.GamePage, error) { + return f.page, nil +} +func (f *fakeGameAdmin) GetGame(context.Context, uuid.UUID) (lobby.GameRecord, error) { + return f.game, f.getErr +} +func (f *fakeGameAdmin) CreateGame(_ context.Context, in lobby.CreateGameInput) (lobby.GameRecord, error) { + f.createCalls++ + f.created = in + return f.game, nil +} +func (f *fakeGameAdmin) AdminForceStart(context.Context, uuid.UUID) (lobby.GameRecord, error) { + f.forceStartCalls++ + return f.game, nil +} +func (f *fakeGameAdmin) AdminForceStop(context.Context, uuid.UUID) (lobby.GameRecord, error) { + f.forceStopCalls++ + return f.game, nil +} +func (f *fakeGameAdmin) AdminBanMember(_ context.Context, _, userID uuid.UUID, reason string) (lobby.Membership, error) { + f.banCalls++ + f.lastBanUser = userID + f.lastBanReason = reason + return lobby.Membership{}, nil +} + +type fakeRuntimeAdmin struct { + record runtime.RuntimeRecord + getErr error + restartCalls int + forceNextCalls int + patchCalls int + lastPatchVersion string +} + +func (f *fakeRuntimeAdmin) GetRuntime(context.Context, uuid.UUID) (runtime.RuntimeRecord, error) { + return f.record, f.getErr +} +func (f *fakeRuntimeAdmin) AdminRestart(context.Context, uuid.UUID) (runtime.OperationLog, error) { + f.restartCalls++ + return runtime.OperationLog{}, nil +} +func (f *fakeRuntimeAdmin) AdminPatch(_ context.Context, _ uuid.UUID, target string) (runtime.OperationLog, error) { + f.patchCalls++ + f.lastPatchVersion = target + return runtime.OperationLog{}, nil +} +func (f *fakeRuntimeAdmin) AdminForceNextTurn(context.Context, uuid.UUID) (runtime.OperationLog, error) { + f.forceNextCalls++ + return runtime.OperationLog{}, nil +} + +type fakeEngineVersionAdmin struct { + list []runtime.EngineVersion + registered runtime.RegisterInput + registerCalls int + disableCalls int + lastDisabled string +} + +func (f *fakeEngineVersionAdmin) List(context.Context) ([]runtime.EngineVersion, error) { + return f.list, nil +} +func (f *fakeEngineVersionAdmin) Register(_ context.Context, in runtime.RegisterInput) (runtime.EngineVersion, error) { + f.registerCalls++ + f.registered = in + return runtime.EngineVersion{}, nil +} +func (f *fakeEngineVersionAdmin) Disable(_ context.Context, version string) (runtime.EngineVersion, error) { + f.disableCalls++ + f.lastDisabled = version + return runtime.EngineVersion{}, nil +} + +func newGamesConsoleRouter(t *testing.T, games GameAdmin, rt RuntimeAdmin, ev EngineVersionAdmin) (http.Handler, *adminconsole.CSRF) { + t.Helper() + csrf := adminconsole.NewCSRF([]byte("test-key")) + handler, err := NewRouter(RouterDependencies{ + Logger: zap.NewNop(), + AdminVerifier: basicauth.NewStaticVerifier("secret"), + AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{ + CSRF: csrf, Games: games, Runtime: rt, EngineVersions: ev, + }), + }) + if err != nil { + t.Fatalf("NewRouter: %v", err) + } + return handler, csrf +} + +func consoleGet(t *testing.T, router http.Handler, path string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func consolePost(t *testing.T, router http.Handler, path, form string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan"+path, strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", "https://galaxy.lan") + req.SetBasicAuth("ops", "secret") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func TestConsoleGamesList(t *testing.T) { + games := &fakeGameAdmin{page: lobby.GamePage{ + Items: []lobby.GameRecord{{GameID: uuid.New(), GameName: "Nova", Visibility: "public", Status: "enrollment_open"}}, + Page: 1, PageSize: 50, Total: 1, + }} + router, _ := newGamesConsoleRouter(t, games, nil, nil) + + rec := consoleGet(t, router, "/_gm/games") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + for _, want := range []string{"Nova", "public", "enrollment_open", "Create public game"} { + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("games list missing %q", want) + } + } +} + +func TestConsoleGameDetailWithRuntime(t *testing.T) { + id := uuid.New() + games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id, GameName: "Nova", Status: "running"}} + rt := &fakeRuntimeAdmin{record: runtime.RuntimeRecord{GameID: id, Status: "running", CurrentEngineVersion: "0.1.0", CurrentTurn: 7}} + router, csrf := newGamesConsoleRouter(t, games, rt, nil) + + rec := consoleGet(t, router, "/_gm/games/"+id.String()) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + for _, want := range []string{"Nova", "Force start", "Force stop", "0.1.0", "Patch", "Ban member", csrf.Token("ops")} { + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("game detail missing %q", want) + } + } +} + +func TestConsoleGameDetailNoRuntime(t *testing.T) { + id := uuid.New() + games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id, GameName: "Nova"}} + rt := &fakeRuntimeAdmin{getErr: errors.New("not found")} + router, _ := newGamesConsoleRouter(t, games, rt, nil) + + rec := consoleGet(t, router, "/_gm/games/"+id.String()) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "No runtime record") { + t.Error("expected a no-runtime note") + } +} + +func TestConsoleGameCreate(t *testing.T) { + id := uuid.New() + games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}} + router, csrf := newGamesConsoleRouter(t, games, nil, nil) + + form := "_csrf=" + csrf.Token("ops") + + "&game_name=Nova&description=d&min_players=2&max_players=8&start_gap_hours=0&start_gap_players=0" + + "&enrollment_ends_at=2030-01-02T15:04&turn_schedule=@every+24h&target_engine_version=0.1.0" + rec := consolePost(t, router, "/_gm/games", form) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Location"); got != "/_gm/games/"+id.String() { + t.Errorf("redirect = %q, want detail page", got) + } + if games.createCalls != 1 { + t.Fatalf("CreateGame called %d times, want 1", games.createCalls) + } + if games.created.Visibility != lobby.VisibilityPublic { + t.Errorf("visibility = %q, want public", games.created.Visibility) + } + if games.created.GameName != "Nova" { + t.Errorf("game name = %q", games.created.GameName) + } + if games.created.EnrollmentEndsAt.Year() != 2030 { + t.Errorf("enrollment year = %d, want 2030", games.created.EnrollmentEndsAt.Year()) + } + if games.created.OwnerUserID != nil { + t.Error("public game must have a nil owner") + } +} + +func TestConsoleGameForceStart(t *testing.T) { + id := uuid.New() + games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}} + router, csrf := newGamesConsoleRouter(t, games, nil, nil) + + rec := consolePost(t, router, "/_gm/games/"+id.String()+"/force-start", "_csrf="+csrf.Token("ops")) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", rec.Code) + } + if games.forceStartCalls != 1 { + t.Errorf("AdminForceStart called %d times, want 1", games.forceStartCalls) + } +} + +func TestConsoleGameForceStartRejectsBadCSRF(t *testing.T) { + id := uuid.New() + games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}} + router, _ := newGamesConsoleRouter(t, games, nil, nil) + + rec := consolePost(t, router, "/_gm/games/"+id.String()+"/force-start", "") + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + if games.forceStartCalls != 0 { + t.Error("force-start must not run without a CSRF token") + } +} + +func TestConsoleGameBanMember(t *testing.T) { + gameID := uuid.New() + target := uuid.New() + games := &fakeGameAdmin{game: lobby.GameRecord{GameID: gameID}} + router, csrf := newGamesConsoleRouter(t, games, nil, nil) + + form := "_csrf=" + csrf.Token("ops") + "&user_id=" + target.String() + "&reason=cheating" + rec := consolePost(t, router, "/_gm/games/"+gameID.String()+"/ban-member", form) + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String()) + } + if games.banCalls != 1 || games.lastBanUser != target || games.lastBanReason != "cheating" { + t.Errorf("ban recorded %d user=%s reason=%q", games.banCalls, games.lastBanUser, games.lastBanReason) + } +} + +func TestConsoleGameBanMemberRejectsBadUUID(t *testing.T) { + gameID := uuid.New() + games := &fakeGameAdmin{game: lobby.GameRecord{GameID: gameID}} + router, csrf := newGamesConsoleRouter(t, games, nil, nil) + + rec := consolePost(t, router, "/_gm/games/"+gameID.String()+"/ban-member", "_csrf="+csrf.Token("ops")+"&user_id=not-a-uuid") + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if games.banCalls != 0 { + t.Error("ban must not run with an invalid user id") + } +} + +func TestConsoleRuntimePatch(t *testing.T) { + id := uuid.New() + games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}} + rt := &fakeRuntimeAdmin{} + router, csrf := newGamesConsoleRouter(t, games, rt, nil) + + rec := consolePost(t, router, "/_gm/games/"+id.String()+"/runtime/patch", "_csrf="+csrf.Token("ops")+"&target_version=0.1.1") + if rec.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303", rec.Code) + } + if rt.patchCalls != 1 || rt.lastPatchVersion != "0.1.1" { + t.Errorf("patch recorded %d version=%q", rt.patchCalls, rt.lastPatchVersion) + } +} + +func TestConsoleRuntimePatchMissingVersion(t *testing.T) { + id := uuid.New() + games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}} + rt := &fakeRuntimeAdmin{} + router, csrf := newGamesConsoleRouter(t, games, rt, nil) + + rec := consolePost(t, router, "/_gm/games/"+id.String()+"/runtime/patch", "_csrf="+csrf.Token("ops")) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + if rt.patchCalls != 0 { + t.Error("patch must not run without a target version") + } +} + +func TestConsoleEngineVersions(t *testing.T) { + ev := &fakeEngineVersionAdmin{list: []runtime.EngineVersion{{Version: "0.1.0", ImageRef: "img:0.1.0", Enabled: true}}} + router, csrf := newGamesConsoleRouter(t, nil, nil, ev) + + rec := consoleGet(t, router, "/_gm/engine-versions") + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + for _, want := range []string{"0.1.0", "img:0.1.0", "Register version", "Disable"} { + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("engine versions page missing %q", want) + } + } + + rec = consolePost(t, router, "/_gm/engine-versions", "_csrf="+csrf.Token("ops")+"&version=0.2.0&image_ref=img:0.2.0&enabled=true") + if rec.Code != http.StatusSeeOther { + t.Fatalf("register status = %d, want 303", rec.Code) + } + if ev.registerCalls != 1 || ev.registered.Version != "0.2.0" || ev.registered.Enabled == nil || !*ev.registered.Enabled { + t.Errorf("register recorded %d version=%q enabled=%v", ev.registerCalls, ev.registered.Version, ev.registered.Enabled) + } + + rec = consolePost(t, router, "/_gm/engine-versions/0.1.0/disable", "_csrf="+csrf.Token("ops")) + if rec.Code != http.StatusSeeOther { + t.Fatalf("disable status = %d, want 303", rec.Code) + } + if ev.disableCalls != 1 || ev.lastDisabled != "0.1.0" { + t.Errorf("disable recorded %d version=%q", ev.disableCalls, ev.lastDisabled) + } +} + +func TestConsoleGamesUnavailable(t *testing.T) { + router, _ := newGamesConsoleRouter(t, nil, nil, nil) + + rec := consoleGet(t, router, "/_gm/games") + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", rec.Code) + } +} diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 6568617..bab38d2 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -394,6 +394,20 @@ func registerAdminConsoleRoutes(router *gin.Engine, deps RouterDependencies) { group.POST("/users/:user_id/block", deps.AdminConsole.UserBlock()) group.POST("/users/:user_id/entitlement", deps.AdminConsole.UserEntitlement()) group.POST("/users/:user_id/soft-delete", deps.AdminConsole.UserSoftDelete()) + + group.GET("/games", deps.AdminConsole.GamesList()) + group.POST("/games", deps.AdminConsole.GameCreate()) + group.GET("/games/:game_id", deps.AdminConsole.GameDetail()) + group.POST("/games/:game_id/force-start", deps.AdminConsole.GameForceStart()) + group.POST("/games/:game_id/force-stop", deps.AdminConsole.GameForceStop()) + group.POST("/games/:game_id/ban-member", deps.AdminConsole.GameBanMember()) + group.POST("/games/:game_id/runtime/restart", deps.AdminConsole.RuntimeRestart()) + group.POST("/games/:game_id/runtime/patch", deps.AdminConsole.RuntimePatch()) + group.POST("/games/:game_id/runtime/force-next-turn", deps.AdminConsole.RuntimeForceNextTurn()) + + group.GET("/engine-versions", deps.AdminConsole.EngineVersionsList()) + group.POST("/engine-versions", deps.AdminConsole.EngineVersionRegister()) + group.POST("/engine-versions/:version/disable", deps.AdminConsole.EngineVersionDisable()) } // allowedMethodsForPath returns the comma-separated list of methods