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

264 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: "subtitles", Label: "Subtitles", Title: "Subtitles",
Intro: "Which providers a viewer may fetch a missing subtitle from.",
Icon: "M4 5.5h16v13H4zM7 15h5m3 0h2M7 11h3m2 0h5",
},
{
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: "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",
},
{
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: "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",
},
{
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))
}
}