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/.html and admin/pages/.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: "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/ 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)) } }