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

Merged
developer merged 6 commits from feature/admin-console into development 2026-05-31 19:07:48 +00:00
11 changed files with 1040 additions and 18 deletions
Showing only changes of commit ecfb2d3351 - Show all commits
+7 -4
View File
@@ -375,10 +375,13 @@ func run(ctx context.Context) (err error) {
} }
adminConsoleHandlers := backendserver.NewAdminConsoleHandlers(backendserver.AdminConsoleDeps{ adminConsoleHandlers := backendserver.NewAdminConsoleHandlers(backendserver.AdminConsoleDeps{
CSRF: consoleCSRF, CSRF: consoleCSRF,
Monitor: opsstatus.NewStore(db), Monitor: opsstatus.NewStore(db),
Ready: ready, Ready: ready,
Users: userSvc, Users: userSvc,
Logger: logger, Games: lobbySvc,
Runtime: runtimeSvc,
EngineVersions: engineVersionSvc,
Logger: logger,
}) })
handler, err := backendserver.NewRouter(backendserver.RouterDependencies{ handler, err := backendserver.NewRouter(backendserver.RouterDependencies{
+15 -2
View File
@@ -91,10 +91,23 @@ changes.
| `/_gm/users/{id}/block` | POST | Apply a permanent block (reason required). | | `/_gm/users/{id}/block` | POST | Apply a permanent block (reason required). |
| `/_gm/users/{id}/entitlement` | POST | Set the entitlement tier. | | `/_gm/users/{id}/entitlement` | POST | Set the entitlement tier. |
| `/_gm/users/{id}/soft-delete` | POST | Soft-delete the account (cascades). | | `/_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/*` 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 JSON endpoint; the console adds no business logic. Collection-mutating POSTs are
available because the JSON admin API exposes no remove-sanction endpoint. 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 ## Configuration
@@ -98,3 +98,5 @@ button {
button:hover { filter: brightness(1.1); } button:hover { filter: brightness(1.1); }
button.danger { background: var(--danger); color: #1a0606; } button.danger { background: var(--danger); color: #1a0606; }
code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; } 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; }
+67
View File
@@ -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
}
@@ -0,0 +1,30 @@
{{define "content" -}}
{{$csrf := .CSRFToken}}
<h1>Engine versions</h1>
{{with .Data}}
<table class="list">
<thead><tr><th>Version</th><th>Image</th><th>Enabled</th><th>Created</th><th></th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td>{{.Version}}</td>
<td><code>{{.ImageRef}}</code></td>
<td>{{if .Enabled}}<span class="ok">yes</span>{{else}}<span class="bad">no</span>{{end}}</td>
<td>{{.CreatedAt}}</td>
<td>{{if .Enabled}}<form method="post" action="/_gm/engine-versions/{{.Version}}/disable" onsubmit="return confirm('Disable {{.Version}}?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Disable</button></form>{{end}}</td>
</tr>
{{else}}<tr><td colspan="5"><span class="note">no engine versions</span></td></tr>{{end}}
</tbody>
</table>
{{end}}
<section class="panel">
<h2>Register version</h2>
<form method="post" action="/_gm/engine-versions" class="form">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<label>Version <input type="text" name="version" placeholder="semver e.g. 0.1.0" required></label>
<label>Image ref <input type="text" name="image_ref" required></label>
<label>Enabled <input type="checkbox" name="enabled" value="true" checked></label>
<button type="submit">Register</button>
</form>
</section>
{{- end}}
@@ -0,0 +1,65 @@
{{define "content" -}}
{{$csrf := .CSRFToken}}
{{with .Data}}
<p><a href="/_gm/games">&laquo; all games</a></p>
<h1>{{if .GameName}}{{.GameName}}{{else}}(unnamed){{end}}</h1>
<section class="panel">
<h2>Game</h2>
<ul class="kv">
<li>Game ID: <code>{{.GameID}}</code></li>
<li>Visibility: {{.Visibility}}</li>
<li>Status: {{.Status}}</li>
<li>Owner: {{.Owner}}</li>
<li>Players: {{.MinPlayers}}{{.MaxPlayers}}</li>
<li>Start gap: {{.StartGapHours}}h / {{.StartGapPlayers}} players</li>
<li>Turn schedule: {{.TurnSchedule}}</li>
<li>Target engine: {{.TargetEngineVersion}}</li>
<li>Enrollment ends: {{.EnrollmentEndsAt}}</li>
<li>Created: {{.CreatedAt}}</li>
<li>Started: {{if .StartedAt}}{{.StartedAt}}{{else}}—{{end}}</li>
<li>Finished: {{if .FinishedAt}}{{.FinishedAt}}{{else}}—{{end}}</li>
</ul>
{{if .Description}}<p>{{.Description}}</p>{{end}}
<div class="actions">
<form method="post" action="/_gm/games/{{.GameID}}/force-start" onsubmit="return confirm('Force-start this game?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Force start</button></form>
<form method="post" action="/_gm/games/{{.GameID}}/force-stop" onsubmit="return confirm('Force-stop this game?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Force stop</button></form>
</div>
</section>
<section class="panel">
<h2>Runtime</h2>
{{if .HasRuntime}}
<ul class="kv">
<li>Status: {{.RuntimeStatus}}</li>
<li>Engine version: {{.CurrentEngineVersion}}</li>
<li>Engine health: {{.EngineHealth}}</li>
<li>Current turn: {{.CurrentTurn}}</li>
<li>Next generation: {{if .NextGenerationAt}}{{.NextGenerationAt}}{{else}}—{{end}}</li>
<li>Paused: {{if .Paused}}yes{{else}}no{{end}}</li>
</ul>
<div class="actions">
<form method="post" action="/_gm/games/{{.GameID}}/runtime/restart" onsubmit="return confirm('Restart the engine container?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Restart</button></form>
<form method="post" action="/_gm/games/{{.GameID}}/runtime/force-next-turn" onsubmit="return confirm('Force the next turn now?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Force next turn</button></form>
</div>
<form method="post" action="/_gm/games/{{.GameID}}/runtime/patch" class="form">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<label>Patch to version <input type="text" name="target_version" placeholder="e.g. 0.1.1" required></label>
<button type="submit">Patch</button>
</form>
{{else}}
<p class="note">No runtime record for this game yet.</p>
{{end}}
</section>
<section class="panel">
<h2>Ban member</h2>
<form method="post" action="/_gm/games/{{.GameID}}/ban-member" class="form" onsubmit="return confirm('Ban this member from the game?');">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<label>User ID <input type="text" name="user_id" required></label>
<label>Reason <input type="text" name="reason"></label>
<button type="submit" class="danger">Ban member</button>
</form>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,43 @@
{{define "content" -}}
{{$csrf := .CSRFToken}}
<h1>Games</h1>
{{with .Data}}
<table class="list">
<thead><tr><th>Name</th><th>Visibility</th><th>Status</th><th>Owner</th><th>Players</th><th>Schedule</th><th>Created</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td><a href="/_gm/games/{{.GameID}}">{{if .GameName}}{{.GameName}}{{else}}(unnamed){{end}}</a></td>
<td>{{.Visibility}}</td>
<td>{{.Status}}</td>
<td>{{.Owner}}</td>
<td>{{.Players}}</td>
<td>{{.TurnSchedule}}</td>
<td>{{.CreatedAt}}</td>
</tr>
{{else}}<tr><td colspan="7"><span class="note">no games</span></td></tr>{{end}}
</tbody>
</table>
<nav class="pager">
{{if .HasPrev}}<a href="/_gm/games?page={{.PrevPage}}&amp;page_size={{.PageSize}}">&laquo; prev</a>{{end}}
<span>page {{.Page}} · {{.Total}} total</span>
{{if .HasNext}}<a href="/_gm/games?page={{.NextPage}}&amp;page_size={{.PageSize}}">next &raquo;</a>{{end}}
</nav>
{{end}}
<section class="panel">
<h2>Create public game</h2>
<form method="post" action="/_gm/games" class="form">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<label>Name <input type="text" name="game_name" required></label>
<label>Description <input type="text" name="description"></label>
<label>Min players <input type="number" name="min_players" value="2" min="1"></label>
<label>Max players <input type="number" name="max_players" value="8" min="1"></label>
<label>Start gap hours <input type="number" name="start_gap_hours" value="0" min="0"></label>
<label>Start gap players <input type="number" name="start_gap_players" value="0" min="0"></label>
<label>Enrollment ends <input type="datetime-local" name="enrollment_ends_at" required></label>
<label>Turn schedule <input type="text" name="turn_schedule" placeholder="e.g. @every 24h" required></label>
<label>Engine version <input type="text" name="target_engine_version" placeholder="e.g. 0.1.0" required></label>
<button type="submit">Create</button>
</form>
</section>
{{- end}}
@@ -26,10 +26,13 @@ type AdminConsoleHandlers struct {
renderer *adminconsole.Renderer renderer *adminconsole.Renderer
csrf *adminconsole.CSRF csrf *adminconsole.CSRF
assets http.Handler assets http.Handler
monitor opsstatus.Reader monitor opsstatus.Reader
ready func() bool ready func() bool
users UserAdmin users UserAdmin
logger *zap.Logger games GameAdmin
runtime RuntimeAdmin
engineVersions EngineVersionAdmin
logger *zap.Logger
} }
// AdminConsoleDeps bundles the collaborators for the operator console. Every // AdminConsoleDeps bundles the collaborators for the operator console. Every
@@ -40,10 +43,13 @@ type AdminConsoleHandlers struct {
type AdminConsoleDeps struct { type AdminConsoleDeps struct {
Renderer *adminconsole.Renderer Renderer *adminconsole.Renderer
CSRF *adminconsole.CSRF CSRF *adminconsole.CSRF
Monitor opsstatus.Reader Monitor opsstatus.Reader
Ready func() bool Ready func() bool
Users UserAdmin Users UserAdmin
Logger *zap.Logger Games GameAdmin
Runtime RuntimeAdmin
EngineVersions EngineVersionAdmin
Logger *zap.Logger
} }
// NewAdminConsoleHandlers constructs the console handler set from deps. It // NewAdminConsoleHandlers constructs the console handler set from deps. It
@@ -77,10 +83,13 @@ func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers {
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, monitor: deps.Monitor,
ready: deps.Ready, ready: deps.Ready,
users: deps.Users, users: deps.Users,
logger: logger.Named("http.admin.console"), games: deps.Games,
runtime: deps.Runtime,
engineVersions: deps.EngineVersions,
logger: logger.Named("http.admin.console"),
} }
} }
@@ -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 <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()
}
@@ -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)
}
}
+14
View File
@@ -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/block", deps.AdminConsole.UserBlock())
group.POST("/users/:user_id/entitlement", deps.AdminConsole.UserEntitlement()) group.POST("/users/:user_id/entitlement", deps.AdminConsole.UserEntitlement())
group.POST("/users/:user_id/soft-delete", deps.AdminConsole.UserSoftDelete()) 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 // allowedMethodsForPath returns the comma-separated list of methods