ecfb2d3351
Tests · Go / test (push) Successful in 1m58s
Add the games, runtime, and engine-version pages over the existing lobby,
runtime, and engine-version services (no new business logic).
- GET/POST /_gm/games list + create public game
- GET /_gm/games/{id} detail incl. runtime snapshot
- POST /_gm/games/{id}/force-start|stop game state actions
- POST /_gm/games/{id}/ban-member ban a member (uuid + reason)
- POST /_gm/games/{id}/runtime/restart|patch|force-next-turn
- GET/POST /_gm/engine-versions registry + register
- POST /_gm/engine-versions/{ver}/disable disable a version
Console depends on GameAdmin / RuntimeAdmin / EngineVersionAdmin interfaces
(satisfied by the concrete services) so the pages render in tests without a
database. Collection-mutating POSTs are mounted on the collection path to avoid
a static-vs-param route conflict in gin. Writes flow through the CSRF guard and
redirect back; the create form parses datetime-local as UTC.
Tests: list/detail (with and without a runtime), create (visibility/owner/time
assertions), force-start (+ bad-CSRF), ban-member (+ bad uuid), runtime patch
(+ missing version), engine-version list/register/disable, and unavailable.
Docs: backend/docs/admin-console.md page inventory extended.
424 lines
16 KiB
Go
424 lines
16 KiB
Go
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 <input type="datetime-local">
|
||
// (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()
|
||
}
|