Stage 10: admin console & dictionary ops (complaint review, hot-reload, broadcasts)
Server-rendered admin console in the backend at /_gm (internal/adminconsole), fronted on the gateway's public listener by Basic-Auth + a verbatim reverse proxy (mounted on the edge mux below the h2c wrap). A same-origin check guards its POSTs; no operator identity is tracked. This supersedes the Stage 6 gateway-fronts- /api/v1/admin model: GATEWAY_ADMIN_ADDR and the backend /api/v1/admin ping are dropped and gateway/internal/admin is repurposed to the verbatim proxy. - Complaints: migration 00008 (+ jetgen) adds disposition/resolution_note/ resolved_at/applied_in_version + the deferred status CHECK; resolution feeds a query-derived pending dictionary-change pipeline (marked applied after a reload). - Dictionary hot-reload: per-version subdir BACKEND_DICT_DIR/<version>/ via the new Registry.LoadAvailable; engine.OpenWithVersions restores resident versions on restart. Partially addresses TODO-2. - Broadcasts: a backend Telegram-connector client (internal/connector, BACKEND_CONNECTOR_ADDR) for SendToUser / SendToGameChannel (discharges the Stage 9 forward-note). - Admin reads: account.ListAccounts/CountAccounts/Identities and game.ListGames/CountGames/GameByID/ListComplaints/GetComplaint/CountComplaints/ ResolveComplaint/DictionaryChanges/MarkChangesApplied. - Tests: adminconsole render, engine reload, same-origin guard, gateway verbatim proxy + h2c console mount, inttest complaint pipeline + list/count + /_gm console. - Docs: PLAN (Stage 10 done + refinements + TODO-2), ARCHITECTURE §1/§5/§6/§12/§13, FUNCTIONAL (+_ru), TESTING, backend/gateway READMEs.
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
{{define "content" -}}
|
||||
<h1>Broadcast</h1>
|
||||
{{with .Data}}
|
||||
<section class="panel"><h2>Post to the game channel</h2>
|
||||
{{if .ConnectorEnabled}}
|
||||
<form class="form col" method="post" action="/_gm/broadcast">
|
||||
<label>Message <textarea name="text" required></textarea></label>
|
||||
<div><button type="submit">Post to channel</button></div>
|
||||
</form>
|
||||
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
||||
</section>
|
||||
<p class="note">To message a single user, open their <a href="/_gm/users">user page</a>.</p>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,32 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<h1>Complaint: {{.Word}}</h1>
|
||||
<nav class="subnav"><a href="/_gm/complaints">« complaints</a></nav>
|
||||
<section class="panel"><h2>Details</h2>
|
||||
<ul class="kv">
|
||||
<li><b>Word</b> <code>{{.Word}}</code></li>
|
||||
<li><b>Variant</b> {{.Variant}}</li>
|
||||
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
||||
<li><b>Lookup at filing</b> {{if .WasValid}}<span class="ok">valid</span>{{else}}<span class="bad">invalid</span>{{end}}</li>
|
||||
<li><b>Filer note</b> {{if .Note}}{{.Note}}{{else}}<span class="note">none</span>{{end}}</li>
|
||||
<li><b>Game</b> <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a></li>
|
||||
<li><b>Filed</b> {{.CreatedAt}}</li>
|
||||
<li><b>Status</b> {{.Status}}</li>
|
||||
{{if .Resolved}}<li><b>Disposition</b> {{.Disposition}}</li><li><b>Resolution note</b> {{.ResolutionNote}}</li><li><b>Resolved</b> {{.ResolvedAt}}</li>{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
<section class="panel"><h2>{{if .Resolved}}Re-resolve{{else}}Resolve{{end}}</h2>
|
||||
<form class="form col" method="post" action="/_gm/complaints/{{.ID}}/resolve">
|
||||
<label>Disposition
|
||||
<select name="disposition">
|
||||
<option value="reject">reject — dictionary is correct</option>
|
||||
<option value="accept_add">accept — add word to the dictionary</option>
|
||||
<option value="accept_remove">accept — remove word from the dictionary</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Note <textarea name="note"></textarea></label>
|
||||
<div><button type="submit">Resolve</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,30 @@
|
||||
{{define "content" -}}
|
||||
<h1>Complaints</h1>
|
||||
{{with .Data}}
|
||||
<nav class="subnav">
|
||||
<a href="/_gm/complaints?status=open"{{if eq .Status "open"}} class="active"{{end}}>open</a> ·
|
||||
<a href="/_gm/complaints?status=resolved"{{if eq .Status "resolved"}} class="active"{{end}}>resolved</a> ·
|
||||
<a href="/_gm/complaints"{{if eq .Status ""}} class="active"{{end}}>all</a>
|
||||
</nav>
|
||||
<table class="list">
|
||||
<thead><tr><th>Word</th><th>Variant</th><th>Was valid</th><th>Status</th><th>Disposition</th><th>Filed</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td><a href="/_gm/complaints/{{.ID}}">{{.Word}}</a></td>
|
||||
<td>{{.Variant}}</td>
|
||||
<td>{{if .WasValid}}<span class="ok">valid</span>{{else}}<span class="bad">invalid</span>{{end}}</td>
|
||||
<td>{{.Status}}</td>
|
||||
<td>{{.Disposition}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
</tr>
|
||||
{{else}}<tr><td colspan="6"><span class="note">no complaints</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav class="pager">
|
||||
{{if .Pager.HasPrev}}<a href="/_gm/complaints?status={{.Status}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||
{{if .Pager.HasNext}}<a href="/_gm/complaints?status={{.Status}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,24 @@
|
||||
{{define "content" -}}
|
||||
<h1>Dashboard</h1>
|
||||
<p class="lede">Operator console for users, games, complaints and dictionaries.</p>
|
||||
{{with .Data}}
|
||||
<div class="cards">
|
||||
<a class="card" href="/_gm/users"><h2>Users</h2><p class="bignum">{{.Accounts}}</p></a>
|
||||
<a class="card" href="/_gm/games"><h2>Games</h2><p class="bignum">{{.Games}}</p></a>
|
||||
<a class="card" href="/_gm/games?status=active"><h2>Active games</h2><p class="bignum">{{.ActiveGames}}</p></a>
|
||||
<a class="card" href="/_gm/complaints?status=open"><h2>Open complaints</h2><p class="bignum">{{.OpenComplaints}}</p></a>
|
||||
<a class="card" href="/_gm/dictionary"><h2>Pending dict changes</h2><p class="bignum">{{.PendingChanges}}</p></a>
|
||||
</div>
|
||||
<section class="panel">
|
||||
<h2>Dictionaries</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Variant</th><th>Latest</th><th>Resident versions</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Variants}}
|
||||
<tr><td>{{.Variant}}</td><td>{{.Latest}}</td><td>{{range .Versions}}<span class="pill">{{.}}</span> {{end}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,43 @@
|
||||
{{define "content" -}}
|
||||
<h1>Dictionary</h1>
|
||||
{{with .Data}}
|
||||
<section class="panel"><h2>Resident versions</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Variant</th><th>Latest</th><th>Resident</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Variants}}
|
||||
<tr><td>{{.Variant}}</td><td>{{.Latest}}</td><td>{{range .Versions}}<span class="pill">{{.}}</span> {{end}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="panel"><h2>Hot-reload a version</h2>
|
||||
<p class="note">Drop the rebuilt DAWG set into BACKEND_DICT_DIR/<version>/ first, then load it here.</p>
|
||||
<form class="form" method="post" action="/_gm/dictionary/reload">
|
||||
<label>Version <input type="text" name="version" placeholder="v2" required></label>
|
||||
<div><button type="submit">Reload</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel"><h2>Pending dictionary changes</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Variant</th><th>Action</th><th>Word</th><th>Resolved</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Changes}}
|
||||
<tr><td>{{.Variant}}</td><td>{{.Action}}</td><td><code>{{.Word}}</code></td><td>{{.ResolvedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="4"><span class="note">no pending changes</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<form class="form" method="post" action="/_gm/dictionary/changes/apply">
|
||||
<label>Mark applied for variant
|
||||
<select name="variant">
|
||||
<option value="english">english</option>
|
||||
<option value="russian_scrabble">russian_scrabble</option>
|
||||
<option value="erudit">erudit</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>In version <input type="text" name="version" placeholder="v2" required></label>
|
||||
<div><button type="submit">Mark applied</button></div>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,29 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<h1>Game {{.ID}}</h1>
|
||||
<nav class="subnav"><a href="/_gm/games">« games</a></nav>
|
||||
<section class="panel"><h2>Summary</h2>
|
||||
<ul class="kv">
|
||||
<li><b>Variant</b> {{.Variant}}</li>
|
||||
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
||||
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
|
||||
<li><b>Players</b> {{.Players}}</li>
|
||||
<li><b>To move</b> seat {{.ToMove}}</li>
|
||||
<li><b>Moves</b> {{.MoveCount}}</li>
|
||||
<li><b>Created</b> {{.CreatedAt}}</li>
|
||||
<li><b>Updated</b> {{.UpdatedAt}}</li>
|
||||
{{if .FinishedAt}}<li><b>Finished</b> {{.FinishedAt}}</li>{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
<section class="panel"><h2>Seats</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th class="num">Seat</th><th>Player</th><th class="num">Score</th><th class="num">Hints</th><th>Winner</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Seats}}
|
||||
<tr><td class="num">{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td class="num">{{.Score}}</td><td class="num">{{.HintsUsed}}</td><td>{{if .Winner}}<span class="ok">winner</span>{{end}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,23 @@
|
||||
{{define "content" -}}
|
||||
<h1>Games</h1>
|
||||
{{with .Data}}
|
||||
<nav class="subnav">
|
||||
<a href="/_gm/games"{{if eq .Status ""}} class="active"{{end}}>all</a> ·
|
||||
<a href="/_gm/games?status=active"{{if eq .Status "active"}} class="active"{{end}}>active</a> ·
|
||||
<a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a>
|
||||
</nav>
|
||||
<table class="list">
|
||||
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav class="pager">
|
||||
{{if .Pager.HasPrev}}<a href="/_gm/games?status={{.Status}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||
{{if .Pager.HasNext}}<a href="/_gm/games?status={{.Status}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,7 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<h1>{{.Heading}}</h1>
|
||||
<p>{{.Body}}</p>
|
||||
<p><a href="{{.Back}}">« back</a></p>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,60 @@
|
||||
{{define "content" -}}
|
||||
{{with .Data}}
|
||||
<h1>{{.DisplayName}}</h1>
|
||||
<nav class="subnav"><a href="/_gm/users">« users</a></nav>
|
||||
<div class="cards">
|
||||
<section class="panel"><h2>Account</h2>
|
||||
<ul class="kv">
|
||||
<li><b>ID</b> {{.ID}}</li>
|
||||
<li><b>Language</b> {{.Language}}</li>
|
||||
<li><b>Timezone</b> {{.TimeZone}}</li>
|
||||
<li><b>Guest</b> {{if .Guest}}yes{{else}}no{{end}}</li>
|
||||
<li><b>Push</b> {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}</li>
|
||||
<li><b>Hint wallet</b> {{.HintBalance}}</li>
|
||||
<li><b>Created</b> {{.CreatedAt}}</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="panel"><h2>Statistics</h2>
|
||||
{{if .HasStats}}
|
||||
<ul class="kv">
|
||||
<li><b>Wins</b> {{.Stats.Wins}}</li>
|
||||
<li><b>Losses</b> {{.Stats.Losses}}</li>
|
||||
<li><b>Draws</b> {{.Stats.Draws}}</li>
|
||||
<li><b>Best game</b> {{.Stats.MaxGamePoints}}</li>
|
||||
<li><b>Best move</b> {{.Stats.MaxWordPoints}}</li>
|
||||
</ul>
|
||||
{{else}}<p class="note">no statistics</p>{{end}}
|
||||
</section>
|
||||
</div>
|
||||
<section class="panel"><h2>Identities</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Kind</th><th>External ID</th><th>Confirmed</th><th>Created</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Identities}}
|
||||
<tr><td>{{.Kind}}</td><td><code>{{.ExternalID}}</code></td><td>{{if .Confirmed}}<span class="ok">yes</span>{{else}}<span class="warn">no</span>{{end}}</td><td>{{.CreatedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="4"><span class="note">no identities (guest)</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{if .TelegramID}}
|
||||
<section class="panel"><h2>Send Telegram message</h2>
|
||||
{{if .ConnectorEnabled}}
|
||||
<form class="form col" method="post" action="/_gm/users/{{.ID}}/message">
|
||||
<label>Message <textarea name="text" required></textarea></label>
|
||||
<div><button type="submit">Send to user</button></div>
|
||||
</form>
|
||||
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
<section class="panel"><h2>Games</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Games}}
|
||||
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
|
||||
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
@@ -0,0 +1,26 @@
|
||||
{{define "content" -}}
|
||||
<h1>Users</h1>
|
||||
{{with .Data}}
|
||||
<table class="list">
|
||||
<thead><tr><th>Account</th><th>Display name</th><th>Kind</th><th>Lang</th><th>Created</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td>
|
||||
<td>{{.DisplayName}}{{if .Guest}} <span class="pill">guest</span>{{end}}</td>
|
||||
<td>{{.Kind}}</td>
|
||||
<td>{{.Language}}</td>
|
||||
<td>{{.CreatedAt}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="5"><span class="note">no users</span></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<nav class="pager">
|
||||
{{if .Pager.HasPrev}}<a href="/_gm/users?page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
||||
{{if .Pager.HasNext}}<a href="/_gm/users?page={{.Pager.NextPage}}">next »</a>{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{- end}}
|
||||
Reference in New Issue
Block a user