Files
memby/server/internal/api/admin_console.go
T

249 lines
7.9 KiB
Go
Raw Normal View History

2026-08-06 22:33:56 +12:00
package api
import (
"bytes"
"embed"
"fmt"
"html/template"
"io/fs"
"path"
"sort"
)
// The console used to be one HTML file holding every screen at once: a television opening
// /admin/logs was still sent the accounts settings editor, the feature grid and the
// recommendation inspector, all hidden. That is why it read as one page wearing twelve
// hats. It is now a shell plus one fragment per page, composed here at start-up, so a page
// carries only its own markup and its own script — and so the rail, the page titles and
// the set of legal URLs all come from adminNav rather than being written out three times.
//
// The no-build, no-CDN rule is unchanged: everything below is embedded in the binary and
// inlined into the response. Nothing is fetched from anywhere.
//go:embed admin/shell.html admin/admin.css admin/core.js admin/pages
var adminAssets embed.FS
// adminNavItem is one destination. Hidden items are reachable and titled but are not in
// the rail — an account's own page belongs to the person it is about, not to a menu.
type adminNavItem struct {
ID string
Label string
Title string
Intro string
Icon string // the `d` of a single stroked path, drawn on a 24×24 grid
Hidden bool
}
type adminNavGroup struct {
Label string
Items []adminNavItem
}
// adminNav is the console's table of contents and the only place a page is declared.
// Adding a page is an entry here plus admin/pages/<id>.html and admin/pages/<id>.js.
var adminNav = []adminNavGroup{
{
Items: []adminNavItem{
{
ID: "overview", Label: "Overview", Title: "Overview",
Intro: "What the gateway is doing right now.",
Icon: "M4 13h6V4H4zm0 7h6v-4H4zm10 0h6v-9h-6zm0-16v4h6V4z",
},
},
},
{
Label: "People",
Items: []adminNavItem{
{
ID: "accounts", Label: "Users", Title: "Memby users",
Intro: "Who uses Memby, and the devices they are signed in on.",
Icon: "M16 19v-1.5A3.5 3.5 0 0 0 12.5 14h-5A3.5 3.5 0 0 0 4 17.5V19M10 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-2h3m-1.5-1.5v3",
},
{
ID: "account", Title: "User", Hidden: true,
Intro: "Devices, recommendation setup and synced settings for one person.",
},
{
ID: "settings-history", Title: "Settings history", Hidden: true,
Intro: "Every change to one person's synced settings, and which devices took it.",
},
{
ID: "clients", Label: "Devices", Title: "Devices",
Intro: "Which sets have reported in, what they are running and what their build understands.",
Icon: "M4 5h16v10H4zM9 19h6M12 15v4",
},
},
},
{
Label: "Content",
Items: []adminNavItem{
{
ID: "library", Label: "Library", Title: "Library",
Intro: "Import and inspect the catalogue Memby ranks.",
Icon: "M4 5.5h16v13H4zM8 5.5v13M4 10h4",
},
{
ID: "ratings", Label: "Movie ratings", Title: "Movie ratings",
Intro: "Optional MDBList scores on films and shows.",
Icon: "m12 3 2.1 5.4 5.9.4-4.6 3.8 1.5 5.7-4.9-3.2-4.9 3.2 1.5-5.7L4 8.8l5.9-.4L12 3Z",
},
{
ID: "requests", Label: "Media requests", Title: "Media requests",
Intro: "Who can ask for something the library does not have.",
Icon: "M5 4h14v16H5zM8 8h8M8 12h5M15 16h3m-1.5-1.5v3",
},
},
},
{
Label: "Personalisation",
Items: []adminNavItem{
{
ID: "recommendations", Label: "For You", Title: "For You",
Intro: "The prepared pools personalised rows are drawn from.",
Icon: "m12 3 1.5 5 5 .2-4 3 1.4 5-3.9-2.8-3.9 2.8 1.4-5-4-3 5-.2L12 3Z",
},
{
ID: "inspector", Label: "Score inspector", Title: "Score inspector",
Intro: "Re-run the ranker for one person and read every component.",
Icon: "M10.5 17a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Zm4.6-1.9L20 20",
},
},
},
{
Label: "Experience",
Items: []adminNavItem{
{
ID: "features", Label: "Features", Title: "Features",
Intro: "Roll out, stop and recover optional behaviour with no app release.",
Icon: "M4 7h10M18 7h2M4 17h2m4 0h10M14 4v6M6 14v6",
},
{
ID: "playback", Label: "Playback", Title: "Playback",
Intro: "Presentation policy sent with every playback launch.",
Icon: "M8 5v14l11-7zM4 5v14",
},
{
ID: "updates", Label: "App updates", Title: "App updates",
Intro: "Publish an optional or a required client update.",
Icon: "M12 16V4m0 0L8 8m4-4 4 4M5 13v6h14v-6",
},
},
},
{
Label: "Operations",
Items: []adminNavItem{
{
ID: "maintenance", Label: "Maintenance", Title: "Maintenance",
Intro: "Take Memby offline for every television.",
Icon: "m14.5 6.5 3-3 3 3-3 3M9 15l-5.5 5.5M13 4a5 5 0 0 0 6.5 6.5L10 20l-6-6 9.5-9.5Z",
},
{
ID: "imports", Label: "Imports", Title: "Imports",
Intro: "Catalogue synchronisation history.",
Icon: "M4 7h16v13H4zM8 4h8v3M8 12h8M8 16h5",
},
{
ID: "engagement", Label: "Row engagement", Title: "Row engagement",
Intro: "Impressions, focus, dwell and selections per launcher row.",
Icon: "M4 19V9m5 10V5m5 14v-7m5 7V3",
},
{
ID: "logs", Label: "Server logs", Title: "Server logs",
Intro: "Structured gateway events as they happen.",
Icon: "M4 5h16v14H4zM7 9l2 2-2 2m5 1h5",
},
},
},
}
// adminPages is the set of legal /admin/<page> URLs, derived so a page cannot exist in the
// rail and 404 — or be reachable and unnamed. installer_auth reads it to decide which
// destination a sign-in may return to, which is why hidden pages are excluded: they are
// addressed by a route that carries something else in the path, and a sign-in that returned
// to one without it would land on a page about nobody.
var adminPages = func() map[string]bool {
pages := map[string]bool{}
forEachAdminPage(func(item adminNavItem) {
if !item.Hidden {
pages[item.ID] = true
}
})
return pages
}()
func forEachAdminPage(visit func(adminNavItem)) {
for _, group := range adminNav {
for _, item := range group.Items {
visit(item)
}
}
}
type adminShellData struct {
Page adminNavItem
Nav []adminNavGroup
CSS template.CSS
Core template.JS
Body template.HTML
Script template.JS
}
// adminRendered holds every page as finished bytes. Composition happens once, at start-up,
// so serving a page is a write of a []byte exactly as it was when the whole console was
// one file.
var adminRendered = buildAdminPages()
func buildAdminPages() map[string][]byte {
shell := template.Must(template.New("shell").ParseFS(adminAssets, "admin/shell.html"))
css := template.CSS(mustReadAdminAsset("admin/admin.css"))
core := template.JS(mustReadAdminAsset("admin/core.js"))
rendered := map[string][]byte{}
forEachAdminPage(func(item adminNavItem) {
var out bytes.Buffer
err := shell.ExecuteTemplate(&out, "shell.html", adminShellData{
Page: item, Nav: adminNav, CSS: css, Core: core,
Body: template.HTML(mustReadAdminAsset(path.Join("admin/pages", item.ID+".html"))),
Script: template.JS(mustReadAdminAsset(path.Join("admin/pages", item.ID+".js"))),
})
if err != nil {
panic(fmt.Sprintf("admin console: render %s: %v", item.ID, err))
}
rendered[item.ID] = out.Bytes()
})
assertEveryAdminFragmentIsRouted(rendered)
return rendered
}
func mustReadAdminAsset(name string) string {
data, err := adminAssets.ReadFile(name)
if err != nil {
panic(fmt.Sprintf("admin console: %v", err))
}
return string(data)
}
// A fragment nobody routes to is dead weight that still looks maintained. Catching it here
// means a page removed from adminNav takes its files with it, or fails at start-up.
func assertEveryAdminFragmentIsRouted(rendered map[string][]byte) {
entries, err := fs.ReadDir(adminAssets, "admin/pages")
if err != nil {
panic(fmt.Sprintf("admin console: %v", err))
}
var orphaned []string
for _, entry := range entries {
name := entry.Name()
if path.Ext(name) != ".html" {
continue
}
id := name[:len(name)-len(".html")]
if _, ok := rendered[id]; !ok {
orphaned = append(orphaned, id)
}
}
if len(orphaned) > 0 {
sort.Strings(orphaned)
panic(fmt.Sprintf("admin console: pages with no nav entry: %v", orphaned))
}
}