aafdd46a4b
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.
102 lines
3.0 KiB
Go
102 lines
3.0 KiB
Go
package adminconsole
|
|
|
|
import (
|
|
"bytes"
|
|
"embed"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"io/fs"
|
|
"path"
|
|
"strings"
|
|
)
|
|
|
|
//go:embed templates
|
|
var templatesFS embed.FS
|
|
|
|
//go:embed assets
|
|
var assetsFS embed.FS
|
|
|
|
// Renderer holds the parsed admin console templates. It composes one template set
|
|
// per content page, each combining the shared layout (the page chrome and the
|
|
// "layout" entry template) with that page's "content" block, so rendering a page
|
|
// is a single ExecuteTemplate call against "layout".
|
|
type Renderer struct {
|
|
pages map[string]*template.Template
|
|
}
|
|
|
|
// PageData is the view model passed to every admin console page. Title is the
|
|
// document title; ActiveNav marks the highlighted navigation entry; Data carries
|
|
// the page-specific payload (one of the *View types in views.go).
|
|
type PageData struct {
|
|
Title string
|
|
ActiveNav string
|
|
Data any
|
|
}
|
|
|
|
// NewRenderer parses the embedded layout and every content page under
|
|
// templates/pages. It fails when a template cannot be parsed.
|
|
func NewRenderer() (*Renderer, error) {
|
|
base, err := template.New("layout").ParseFS(templatesFS, "templates/layout.gohtml")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse admin console layout: %w", err)
|
|
}
|
|
|
|
pageFiles, err := fs.Glob(templatesFS, "templates/pages/*.gohtml")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("enumerate admin console pages: %w", err)
|
|
}
|
|
if len(pageFiles) == 0 {
|
|
return nil, fmt.Errorf("admin console: no page templates found under templates/pages")
|
|
}
|
|
|
|
pages := make(map[string]*template.Template, len(pageFiles))
|
|
for _, file := range pageFiles {
|
|
name := strings.TrimSuffix(path.Base(file), ".gohtml")
|
|
clone, err := base.Clone()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("clone admin console layout for %q: %w", name, err)
|
|
}
|
|
if _, err := clone.ParseFS(templatesFS, file); err != nil {
|
|
return nil, fmt.Errorf("parse admin console page %q: %w", name, err)
|
|
}
|
|
pages[name] = clone
|
|
}
|
|
|
|
return &Renderer{pages: pages}, nil
|
|
}
|
|
|
|
// MustNewRenderer is like NewRenderer but panics on error. The templates are
|
|
// embedded at build time, so a parse failure is a programmer error.
|
|
func MustNewRenderer() *Renderer {
|
|
renderer, err := NewRenderer()
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return renderer
|
|
}
|
|
|
|
// Render writes the named page, wrapped in the shared layout, to w using data. It
|
|
// renders into an intermediate buffer first, so a mid-render failure never emits
|
|
// a partial document. It returns an error for an unknown page or a failed render.
|
|
func (r *Renderer) Render(w io.Writer, page string, data PageData) error {
|
|
tmpl, ok := r.pages[page]
|
|
if !ok {
|
|
return fmt.Errorf("admin console: unknown page %q", page)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil {
|
|
return fmt.Errorf("render admin console page %q: %w", page, err)
|
|
}
|
|
|
|
_, err := buf.WriteTo(w)
|
|
return err
|
|
}
|
|
|
|
// Assets returns the embedded static asset tree rooted at the assets directory,
|
|
// suitable for serving under /_gm/assets/.
|
|
func Assets() (fs.FS, error) {
|
|
return fs.Sub(assetsFS, "assets")
|
|
}
|