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 {
|
2026-08-12 08:25:15 +12:00
|
|
|
|
ID string
|
2026-08-06 22:33:56 +12:00
|
|
|
|
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{
|
|
|
|
|
|
{
|
2026-08-12 08:25:15 +12:00
|
|
|
|
ID: "overview",
|
2026-08-06 22:33:56 +12:00
|
|
|
|
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",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-08-12 08:25:15 +12:00
|
|
|
|
ID: "people",
|
2026-08-06 22:33:56 +12:00
|
|
|
|
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",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-08-12 08:25:15 +12:00
|
|
|
|
ID: "content",
|
2026-08-06 22:33:56 +12:00
|
|
|
|
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",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-08-12 08:25:15 +12:00
|
|
|
|
ID: "personalisation",
|
2026-08-06 22:33:56 +12:00
|
|
|
|
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",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-08-12 08:25:15 +12:00
|
|
|
|
ID: "experience",
|
2026-08-06 22:33:56 +12:00
|
|
|
|
Label: "Experience",
|
|
|
|
|
|
Items: []adminNavItem{
|
2026-08-11 23:41:10 +12:00
|
|
|
|
{
|
|
|
|
|
|
ID: "hero", Label: "Home hero", Title: "Home hero",
|
|
|
|
|
|
Intro: "Choose films or television shows for the launcher spotlight while recent releases fill the remaining places.",
|
|
|
|
|
|
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",
|
|
|
|
|
|
},
|
2026-08-06 22:33:56 +12:00
|
|
|
|
{
|
|
|
|
|
|
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",
|
|
|
|
|
|
},
|
2026-08-09 08:25:50 +12:00
|
|
|
|
{
|
|
|
|
|
|
ID: "subtitles", Label: "Subtitles", Title: "Subtitles",
|
|
|
|
|
|
Intro: "Which providers a viewer may fetch a missing subtitle from.",
|
|
|
|
|
|
Icon: "M4 5.5h16v13H4zM7 15h5m3 0h2M7 11h3m2 0h5",
|
|
|
|
|
|
},
|
2026-08-06 22:33:56 +12:00
|
|
|
|
{
|
|
|
|
|
|
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",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-08-12 08:25:15 +12:00
|
|
|
|
ID: "operations",
|
2026-08-06 22:33:56 +12:00
|
|
|
|
Label: "Operations",
|
|
|
|
|
|
Items: []adminNavItem{
|
2026-08-10 20:39:26 +12:00
|
|
|
|
{
|
|
|
|
|
|
ID: "journeys", Label: "Journeys", Title: "User journeys",
|
|
|
|
|
|
Intro: "How viewers move through Memby, use features and complete flows.",
|
|
|
|
|
|
Icon: "M4 6h5v5h6v7h5M7 3 4 6l3 3m10 6 3 3-3 3",
|
|
|
|
|
|
},
|
2026-08-06 22:33:56 +12:00
|
|
|
|
{
|
|
|
|
|
|
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",
|
|
|
|
|
|
},
|
2026-08-09 08:25:50 +12:00
|
|
|
|
{
|
|
|
|
|
|
ID: "searches", Label: "Searches", Title: "Searches",
|
|
|
|
|
|
Intro: "What the household has been looking for, and what it searched just now.",
|
|
|
|
|
|
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",
|
|
|
|
|
|
},
|
2026-08-06 22:33:56 +12:00
|
|
|
|
{
|
|
|
|
|
|
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))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|