0.1.38 gateway

This commit is contained in:
ponzischeme89
2026-08-14 09:40:03 +12:00
parent abc392d30b
commit 5e2ed3d12e
2847 changed files with 1072928 additions and 3783 deletions
+60 -82
View File
@@ -32,16 +32,7 @@ const adminActivityHeader = "X-Memby-Admin-Active"
func (s *Server) adminRoutes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /admin/{$}", s.handleAdminRoot)
mux.HandleFunc("POST /admin/logout", s.handleAdminLogout)
mux.HandleFunc("GET /admin/{page}", s.handleAdminPage)
// One person's own page. It is a path rather than a query string so it can be linked,
// bookmarked and returned to after a sign-in, like every other page here.
mux.HandleFunc("GET /admin/accounts/{userID}", s.handleAdminAccountPage)
// The settings history is its own page rather than a fifth card on the account: it is
// a table with a row per change and an action per row, and it is read when something
// has gone wrong rather than as part of ordinary account admin.
mux.HandleFunc("GET /admin/accounts/{userID}/settings", s.handleAdminSettingsHistoryPage)
mux.Handle("GET /admin/api/status", s.adminAuth(s.handleAdminStatus))
mux.Handle("GET /admin/api/accounts", s.adminAuth(s.handleAdminAccounts))
mux.Handle("PUT /admin/api/accounts/{userID}/devices/{deviceID}", s.adminAuth(s.handleAdminRenameDevice))
@@ -58,6 +49,7 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("GET /admin/api/recommendations", s.adminAuth(s.handleAdminRecommendations))
mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics))
mux.Handle("GET /admin/api/journeys", s.adminAuth(s.handleAdminJourneys))
mux.Handle("GET /admin/api/views", s.adminAuth(s.handleAdminViews))
mux.Handle("GET /admin/api/searches", s.adminAuth(s.handleAdminSearches))
mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents))
mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime))
@@ -76,15 +68,50 @@ func (s *Server) adminRoutes() http.Handler {
mux.Handle("POST /admin/api/features", s.adminAuth(s.handleAdminFeaturePolicy))
mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish))
return mux
}
// Sign-in history. Three routes rather than one because they answer three questions
// an operator asks separately — what happened, which televisions are connecting, and
// everything about this one set.
mux.Handle("GET /admin/api/logins", s.adminAuth(s.handleAdminLogins))
mux.Handle("GET /admin/api/logins/devices", s.adminAuth(s.handleAdminLoginDevices))
mux.Handle("GET /admin/api/logins/devices/{deviceID}", s.adminAuth(s.handleAdminDeviceDetail))
func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) {
if s.cfg.AdminToken == "" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/admin/overview", http.StatusFound)
// The administrative feed behind the notification bell.
mux.Handle("GET /admin/api/notifications", s.adminAuth(s.handleAdminNotifications))
mux.Handle("POST /admin/api/notifications/read", s.adminAuth(s.handleAdminNotificationsRead))
// The live half. Registered outside adminAuth's activity tracking is deliberate — see
// operatorPresent: a stream held open for an hour must not, on its own, keep an
// abandoned tab's session alive.
mux.Handle("GET /admin/api/notifications/stream", s.adminAuth(s.handleAdminNotificationStream))
mux.Handle("GET /admin/api/integrations", s.adminAuth(s.handleAdminIntegrations))
mux.Handle("POST /admin/api/integrations", s.adminAuth(s.handleAdminSaveIntegration))
mux.Handle("DELETE /admin/api/integrations/{integrationID}", s.adminAuth(s.handleAdminDeleteIntegration))
mux.Handle("POST /admin/api/integrations/{integrationID}/test", s.adminAuth(s.handleAdminTestIntegration))
mux.Handle("GET /admin/api/tasks", s.adminAuth(s.handleAdminTasks))
mux.Handle("POST /admin/api/tasks/{taskID}/run", s.adminAuth(s.handleAdminRunTask))
mux.Handle("PUT /admin/api/tasks/{taskID}", s.adminAuth(s.handleAdminTaskSettings))
// An unmatched API path is a 404, stated rather than left to the catch-all below —
// otherwise a mistyped or removed route would answer with the console's HTML shell,
// and the caller would report "unexpected token < in JSON" instead of "no such route".
mux.HandleFunc("/admin/api/", func(w http.ResponseWriter, r *http.Request) {
if s.cfg.AdminToken == "" {
http.NotFound(w, r)
return
}
writeError(w, http.StatusNotFound, "no such admin route")
})
// Everything else under /admin is the console, which owns its own URLs: a deep link, a
// refresh or the Back button all arrive here as a GET for a path this server has never
// heard of. This intentionally has no method qualifier. `GET /admin/` and the all-method
// `/admin/api/` fallback overlap in Go's ServeMux without either pattern being more
// specific, which makes the gateway panic while registering routes. The API fallback is
// more specific by path, so it continues to win here for every method.
mux.HandleFunc("/admin/", s.handleAdminConsole)
return mux
}
type adminRuntimeStatus struct {
@@ -169,71 +196,6 @@ func operatorPresent(r *http.Request) bool {
strings.TrimSpace(r.Header.Get(adminActivityHeader)) == "1"
}
func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
// A hidden page is reachable only through the route that knows how to address it: an
// account page with nobody to be about is not a page.
page := strings.TrimSpace(r.PathValue("page"))
if !adminPages[page] {
http.NotFound(w, r)
return
}
s.serveAdminPage(w, r, page, "/admin/"+page)
}
// The account page is served from its own route because the identity is in the path. The
// sign-in returns to /admin/accounts rather than to this URL: cleanInstallerDestination only
// admits the pages it can name, and a person's id is not one of them.
func (s *Server) handleAdminAccountPage(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(r.PathValue("userID")) == "" {
http.NotFound(w, r)
return
}
s.serveAdminPage(w, r, "account", "/admin/accounts")
}
// Hidden for the same reason the account page is: a settings history with nobody to be
// about is not a page, so the identity is in the path and a sign-in returns to the account
// list rather than here.
func (s *Server) handleAdminSettingsHistoryPage(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(r.PathValue("userID")) == "" {
http.NotFound(w, r)
return
}
s.serveAdminPage(w, r, "settings-history", "/admin/accounts")
}
func (s *Server) serveAdminPage(w http.ResponseWriter, r *http.Request, page, next string) {
if s.cfg.AdminToken == "" {
http.NotFound(w, r)
return
}
body, ok := adminRendered[page]
if !ok {
http.NotFound(w, r)
return
}
if !s.validInstallerSession(r) {
s.renderAccessLogin(w, r, "", http.StatusOK, next)
return
}
// Opening a page is somebody at the keyboard, so it starts the clock again.
s.renewAdminSession(w, r)
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
http.SetCookie(w, &http.Cookie{
Name: adminCookieName,
Value: s.cfg.AdminToken,
Path: "/admin",
MaxAge: 10 * 365 * 24 * 60 * 60,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
preventDiscovery(w)
_, _ = w.Write(body)
}
func (s *Server) handleAdminLogout(w http.ResponseWriter, r *http.Request) {
if s.cfg.AdminToken == "" {
http.NotFound(w, r)
@@ -271,6 +233,7 @@ type adminStatus struct {
Subtitles subtitleAdminSettings `json:"subtitles"`
Features featureResponse `json:"features"`
RequestUsers []store.KnownUser `json:"requestUsers"`
RequestUsage []store.RequestUsage `json:"requestUsage"`
Clients []store.KnownClient `json:"clients"`
SonarrReady bool `json:"sonarrReady"`
RadarrReady bool `json:"radarrReady"`
@@ -313,6 +276,10 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
if err != nil {
s.loggerFor(ctx).Warn("known clients read failed", "error", err)
}
requestUsage, err := s.store.RequestUsage(ctx)
if err != nil {
s.loggerFor(ctx).Warn("request usage read failed", "error", err)
}
writeJSON(w, http.StatusOK, adminStatus{
ServerVersion: buildinfo.Version(),
Maintenance: s.maintenance.get(),
@@ -336,6 +303,7 @@ func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
Subtitles: s.subtitleAdminSettings(ctx),
Features: featurePayload(s.currentFeaturePolicy(ctx), ProtocolVersion),
RequestUsers: requestUsers,
RequestUsage: requestUsage,
Clients: clients,
MDBList: func() mdblistAdminSettings {
settings, settingsErr := s.store.MDBListSettings(ctx)
@@ -808,6 +776,16 @@ func (s *Server) handleAdminJourneys(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, payload)
}
func (s *Server) handleAdminViews(w http.ResponseWriter, r *http.Request) {
report, err := s.store.ViewsReport(r.Context(), time.Now().UTC())
if err != nil {
s.loggerFor(r.Context()).Error("views report failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read app views")
return
}
writeJSON(w, http.StatusOK, report)
}
// syncerHandle is the slice of the syncer the API needs, so api does not depend on the
// concrete type for testing.
type syncerHandle interface {
-538
View File
@@ -1,538 +0,0 @@
/* One vocabulary for the whole console.
Every screen is built from the components below — card, tile, field, tag, table, list.
A page that needs a look of its own is a missing component, not a licence for a style
attribute: there are no inline styles in the page fragments, on purpose.
The palette is the television app's, raised a step: the console is read on a lit desk
rather than across a dark room, and at the app's near-black the surfaces and the page
behind them were the same colour to anything but a calibrated screen — a card had a
hairline and nothing else saying where it began. Everything is lifted together so the
relationships hold, and the borders lift with them.
The accent is still spent only on what is live, selected or primary. Beside it are three
secondary tones with no verdict attached — info (blue), note (violet), data (teal) —
which is what lets a page distinguish one kind of thing from another without every
coloured element reading as a warning. Green means good, amber means look, red means
wrong; the other three mean nothing at all, which is the point. */
:root {
color-scheme: dark;
--bg: #101418;
--surface: #171d23;
--surface-lift: #1f262d;
--surface-hi: #252d35;
--line: #29323a;
--line-soft: #202830;
--text: #eef2f5;
--muted: #97a2ab;
--quiet: #717c86;
--accent: #52b54b;
--accent-ink: #8fe287;
--accent-wash: rgba(82, 181, 75, .14);
--danger: #e5534b;
--danger-ink: #ff9b94;
--danger-wash: rgba(229, 83, 75, .14);
--warn: #e0ad4e;
--warn-ink: #f2cb78;
--warn-wash: rgba(239, 196, 107, .14);
--info: #4f9cd8;
--info-ink: #93cbef;
--info-wash: rgba(79, 156, 216, .15);
--note: #a07ce8;
--note-ink: #bda1f5;
--note-wash: rgba(160, 124, 232, .15);
--data: #3fc2b6;
--data-ink: #6fdcd0;
--data-wash: rgba(63, 194, 182, .14);
--rail: 232px;
--radius: 12px;
--radius-sm: 8px;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent-ink); }
.skip {
position: absolute; left: -9999px; top: 8px; z-index: 60;
padding: 8px 14px; border-radius: var(--radius-sm);
background: var(--surface-lift); color: var(--text); text-decoration: none;
}
.skip:focus { left: 8px; }
/* ---------- rail ---------- */
/* The rail sits a shade under the page rather than level with it. With everything lifted
together, one border was no longer enough to say where the navigation stopped and the
screen began. */
.rail {
position: fixed; inset: 0 auto 0 0; z-index: 20; width: var(--rail);
display: flex; flex-direction: column;
padding: 20px 10px 12px;
background: #0c1013; border-right: 1px solid var(--line);
}
.rail-scroll { flex: 1; min-height: 0; overflow-y: auto; padding-bottom: 12px; }
.rail-brand {
display: flex; align-items: center; gap: 11px;
padding: 0 10px 18px; color: var(--text); text-decoration: none;
}
.rail-mark {
display: grid; place-items: center; width: 30px; height: 30px; flex: 0 0 30px;
border-radius: 9px; background: var(--accent); color: #06240a;
font-size: 17px; font-weight: 800; letter-spacing: -.06em;
}
.rail-brand-copy b { display: block; font-size: 15px; line-height: 1.15; }
.rail-brand-copy span {
display: block; margin-top: 1px; color: var(--quiet);
font-size: 10px; letter-spacing: .08em; text-transform: uppercase;
}
.rail-group {
width: calc(100% - 18px); margin: 14px 9px 5px; padding: 5px 7px; border: 0;
background: transparent; color: var(--quiet); font: inherit; font-size: 10px;
font-weight: 700; letter-spacing: .14em; text-transform: uppercase;
display: flex; align-items: center; justify-content: space-between; cursor: pointer;
}
.rail-group:hover { color: var(--text); }
.rail-group-arrow { font-size: 15px; line-height: 1; transition: transform .16s ease; }
.rail-group[aria-expanded="false"] .rail-group-arrow { transform: rotate(-90deg); }
.rail-nav { display: grid; gap: 2px; }
.rail-nav[hidden] { display: none; }
.journey-summary-grid, .journey-report-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; margin-top:18px; }
.journey-summary { border:1px solid var(--line); background:var(--surface-lift); border-radius:14px; padding:16px 18px; }
.journey-summary > b, .journey-summary-head b { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.07em; }
.journey-summary > strong { display:block; font-size:20px; margin-top:9px; }
.journey-summary p { color:var(--muted); margin:8px 0 0; font-size:13px; }
.journey-summary-head { display:flex; align-items:center; justify-content:space-between; }
.journey-summary-head strong { font-size:22px; }
.journey-meter { height:8px; margin-top:13px; overflow:hidden; border-radius:99px; background:var(--line); }
.journey-meter span { display:block; height:100%; border-radius:inherit; background:var(--accent); }
.table-sub { display:block; color:var(--muted); font-size:12px; margin-top:3px; }
.route-arrow { color:var(--accent); padding:0 5px; }
.journey-visits { display:grid; gap:14px; }
.journey-visit { border:1px solid var(--line); border-radius:14px; background:var(--surface-lift); overflow:hidden; }
.journey-visit header { display:flex; justify-content:space-between; align-items:center; padding:14px 16px; border-bottom:1px solid var(--line); }
.journey-visit header b, .journey-visit header span { display:block; }
.journey-visit header span { color:var(--muted); font-size:12px; margin-top:3px; }
.journey-visit ol { list-style:none; margin:0; padding:10px 16px 14px; }
.journey-visit li { display:grid; grid-template-columns:12px minmax(0,1fr) auto; gap:10px; align-items:start; padding:8px 0; }
.journey-step-dot { width:8px; height:8px; margin-top:5px; border-radius:50%; background:var(--accent); box-shadow:0 0 0 4px var(--accent-wash); }
.journey-visit li b, .journey-visit li span { display:block; }
.journey-visit li span { color:var(--muted); font-size:12px; margin-top:2px; }
.journey-visit time { color:var(--muted); font-size:11px; }
@media (max-width:900px) { .journey-summary-grid, .journey-report-grid { grid-template-columns:1fr; } }
.rail-link {
position: relative;
display: flex; align-items: center; gap: 11px; min-height: 36px;
padding: 0 12px; border-radius: var(--radius-sm);
color: var(--muted); text-decoration: none; font-size: 13.5px; font-weight: 500;
}
.rail-link svg {
width: 17px; height: 17px; flex: 0 0 17px;
fill: none; stroke: currentColor; stroke-width: 1.7;
stroke-linecap: round; stroke-linejoin: round;
}
.rail-link:hover { color: var(--text); background: var(--surface); }
.rail-link:hover svg { stroke: var(--accent-ink); }
.rail-link.active { color: var(--text); background: var(--accent-wash); font-weight: 600; }
.rail-link.active svg { stroke: var(--accent); }
/* The page you are on is the one thing in the rail worth marking twice: a wash reads at a
glance, the bar survives being looked at sideways on a poor monitor. */
.rail-link.active::before {
content: ""; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
width: 3px; height: 18px; border-radius: 0 3px 3px 0; background: var(--accent);
}
.rail-foot {
display: grid; gap: 8px;
padding: 12px 12px 0; border-top: 1px solid var(--line);
}
.rail-status { display: flex; align-items: center; gap: 9px; min-width: 0; }
.rail-foot-copy { min-width: 0; }
.rail-foot-copy b { display: block; font-size: 12px; font-weight: 600; }
.rail-foot-copy span { display: block; color: var(--quiet); font-size: 11px; }
.rail-foot form { margin: 0; }
.rail-logout { display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; }
.rail-logout .glyph { width: 22px; height: 22px; background: none; }
/* ---------- page ---------- */
.page {
width: calc(100% - var(--rail)); margin-left: var(--rail);
padding: 26px clamp(18px, 2.6vw, 40px) 56px;
display: grid; gap: 16px; align-content: start;
}
.page-head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 16px; flex-wrap: wrap; padding-bottom: 2px;
}
/* The heading wears the same mark as the rail entry that reached it — the one thing on the
page confirming which of twelve screens is open, for somebody who arrived by a link
rather than by the menu. A hidden page (an account, its settings history) has no mark of
its own and the template leaves it out, which is also what keeps the heading safe for the
two pages that rewrite it from their own data. */
.page-head h1 {
margin: 0; font-size: 25px; line-height: 1.2; letter-spacing: -.02em; font-weight: 650;
display: flex; align-items: center; gap: 12px;
}
.page-mark { width: 34px; height: 34px; border-radius: 10px; }
.page-mark .ico { width: 19px; height: 19px; flex: 0 0 19px; }
.page-head p { margin: 5px 0 0; color: var(--muted); font-size: 14px; max-width: 62ch; }
.crumb {
display: inline-flex; align-items: center; gap: 6px;
color: var(--muted); text-decoration: none; font-size: 13px;
}
.crumb:hover { color: var(--text); }
/* ---------- icons ---------- */
/* Every icon on the console is one stroked path on a 24×24 grid, drawn in the current
colour — the rail's marks, a card's heading, a tile, a tag. `Admin.icon` is the only
place a path is written down, and `data-icon` on any element is how a fragment asks for
one, so a page never carries SVG markup of its own. */
.ico {
width: 16px; height: 16px; flex: 0 0 16px;
fill: none; stroke: currentColor; stroke-width: 1.7;
stroke-linecap: round; stroke-linejoin: round;
}
/* An icon in a tinted plate. The tint is the label: it says which of the console's areas a
card belongs to before its heading has been read. */
.glyph {
display: grid; place-items: center; flex: 0 0 auto;
width: 26px; height: 26px; border-radius: 8px;
background: var(--surface-hi); color: var(--muted);
}
.glyph[data-tone=accent] { background: var(--accent-wash); color: var(--accent-ink); }
.glyph[data-tone=ok] { background: var(--accent-wash); color: var(--accent-ink); }
.glyph[data-tone=bad] { background: var(--danger-wash); color: var(--danger-ink); }
.glyph[data-tone=warn] { background: var(--warn-wash); color: var(--warn-ink); }
.glyph[data-tone=info] { background: var(--info-wash); color: var(--info-ink); }
.glyph[data-tone=note] { background: var(--note-wash); color: var(--note-ink); }
.glyph[data-tone=data] { background: var(--data-wash); color: var(--data-ink); }
.glyph .ico { width: 15px; height: 15px; flex: 0 0 15px; }
/* ---------- card ---------- */
.card {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 18px 20px;
display: grid; gap: 14px; align-content: start; min-width: 0;
}
/* A card whose body runs edge to edge — a list or a table. Everything else in it brings
the padding back, or the heading sits against the border. */
.card.flush { padding: 0; }
.card.flush > .card-head { padding: 18px 20px 0; }
.card.flush > .card-foot { padding: 14px 20px 18px; }
.card-head { display: grid; gap: 4px; }
.card-head.split {
grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 12px;
}
.card-title {
margin: 0; font-size: 15px; font-weight: 650; letter-spacing: -.01em;
display: flex; align-items: center; gap: 9px; min-width: 0;
}
.card-note { margin: 0; color: var(--muted); font-size: 13px; max-width: 74ch; }
.card-foot {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding-top: 14px; border-top: 1px solid var(--line-soft);
}
.card-sub {
margin: 0; color: var(--quiet);
font-size: 11px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase;
}
.grid { display: grid; gap: 16px; }
.grid.two { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
.grid.wide { grid-template-columns: minmax(0, 1.4fr) minmax(280px, 1fr); }
/* ---------- tiles ---------- */
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 1px;
background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
.tile { background: var(--surface); padding: 14px 16px; display: grid; gap: 3px; }
.tile b { font-size: 22px; font-weight: 600; letter-spacing: -.02em; line-height: 1.2; }
.tile b.small { font-size: 14px; font-weight: 550; }
.tile span { color: var(--muted); font-size: 12px; }
/* The glyph is the tile's own row, above the number rather than beside it: a strip of
tiles is read down the numbers, and an icon in that column would push them out of line
the moment one tile had no icon to show. */
.tile .glyph { margin-bottom: 5px; }
/* Inside a card the surrounding border is already drawn, so the strip loses its own. */
.tiles.plain { border: 0; background: none; gap: 18px; border-radius: 0; overflow: visible; }
.tiles.plain .tile { padding: 0; background: none; }
.tiles.plain .tile b { font-size: 19px; }
/* Label-and-value rows: the quiet way to state settled facts inside a padded card. */
.kv { display: grid; }
.kv-row {
display: flex; align-items: center; justify-content: space-between; gap: 14px;
padding: 9px 0; border-bottom: 1px solid var(--line-soft); font-size: 13.5px;
}
.kv-row:first-child { padding-top: 0; }
.kv-row:last-child { padding-bottom: 0; border-bottom: 0; }
.kv-row > span:first-child { color: var(--muted); }
.kv-row > :last-child { text-align: right; }
/* ---------- controls ---------- */
button {
background: var(--surface-lift); color: var(--text);
border: 1px solid var(--line); border-radius: var(--radius-sm);
padding: 8px 13px; font: inherit; font-size: 13.5px; font-weight: 550; cursor: pointer;
}
button:hover:not(:disabled) { border-color: #3a444d; background: var(--surface-hi); }
button.primary { background: var(--accent); border-color: var(--accent); color: #06240a; font-weight: 650; }
button.primary:hover:not(:disabled) { background: #5cc554; border-color: #5cc554; }
button.danger { background: var(--danger-wash); border-color: rgba(229, 83, 75, .35); color: var(--danger-ink); }
button.danger:hover:not(:disabled) { background: rgba(229, 83, 75, .2); }
button.quiet { background: none; border-color: transparent; color: var(--muted); padding: 6px 8px; }
button.quiet:hover:not(:disabled) { background: var(--surface-lift); color: var(--text); }
button.small { padding: 5px 10px; font-size: 12.5px; }
button:disabled { opacity: .4; cursor: not-allowed; }
input[type=text], input[type=password], input[type=number], input[type=search],
input[type=datetime-local], select, textarea {
background: var(--bg); color: var(--text);
border: 1px solid var(--line); border-radius: var(--radius-sm);
padding: 8px 11px; font: inherit; font-size: 13.5px; min-width: 0;
}
textarea {
min-height: 62px; resize: vertical;
font: 12px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace;
}
input::placeholder, textarea::placeholder { color: var(--quiet); }
select { padding-right: 8px; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.row.tight { gap: 7px; }
.row.end { justify-content: flex-end; }
/* A row mixing labelled fields with bare buttons: the controls line up, not their tops. */
.row.bottom { align-items: flex-end; }
.field { display: grid; gap: 6px; min-width: 0; }
.field > span { font-size: 13px; font-weight: 550; }
.field > em { font-style: normal; color: var(--muted); font-size: 12px; }
.field input, .field select, .field textarea { width: 100%; }
.field.grow { flex: 1 1 260px; }
.field.narrow { max-width: 200px; flex: 0 0 auto; }
.fields { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
.fields.stack { grid-template-columns: 1fr; }
.check { display: flex; gap: 9px; align-items: flex-start; font-size: 13.5px; cursor: pointer; }
.check input { margin: 3px 0 0; accent-color: var(--accent); flex: 0 0 auto; }
.check em { display: block; font-style: normal; color: var(--muted); font-size: 12px; }
.checks { display: grid; gap: 8px; }
.checks.columns { grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); }
/* A theme, shown as the colours it actually is.
This is the one component whose colours are data rather than vocabulary, so the three
custom properties below are set from a style attribute on the element — the fragment
still declares no *look* of its own, only which palette this row is. Anything else about
how a swatch is drawn belongs here. */
.swatch {
flex: 0 0 auto; width: 46px; height: 30px; border-radius: var(--radius-sm);
border: 1px solid var(--line); overflow: hidden; display: flex; align-items: flex-end;
background: var(--swatch-surface, var(--surface));
}
.swatch i {
display: block; width: 100%; height: 9px;
background: var(--swatch-accent, var(--accent));
border-top: 1px solid var(--swatch-hairline, var(--line));
}
.hint { color: var(--muted); font-size: 12.5px; margin: 0; }
/* ---------- tags, notices ---------- */
.tag {
display: inline-flex; align-items: center; gap: 6px;
padding: 3px 9px; border-radius: 999px;
font-size: 11.5px; font-weight: 600; letter-spacing: .01em;
background: var(--surface-lift); color: var(--muted);
}
.tag[data-tone=ok] { background: var(--accent-wash); color: var(--accent-ink); }
.tag[data-tone=bad] { background: var(--danger-wash); color: var(--danger-ink); }
.tag[data-tone=warn] { background: var(--warn-wash); color: var(--warn-ink); }
.tag[data-tone=info] { background: var(--info-wash); color: var(--info-ink); }
.tag[data-tone=note] { background: var(--note-wash); color: var(--note-ink); }
.tag[data-tone=data] { background: var(--data-wash); color: var(--data-ink); }
.tag[data-tone=idle] { background: var(--surface-lift); color: var(--quiet); }
/* A verdict tag carries a dot in its own colour. Tone alone is a poor signal on a bad
monitor and no signal at all to somebody who cannot separate the green from the amber;
the dot is the shape that says these three are the same kind of statement. */
.tag[data-tone=ok]::before, .tag[data-tone=bad]::before,
.tag[data-tone=warn]::before, .tag[data-tone=idle]::before {
content: ""; width: 6px; height: 6px; border-radius: 50%;
background: currentColor; flex: 0 0 6px;
}
.tag[data-tone=idle]::before { opacity: .55; }
.tag .ico { width: 13px; height: 13px; flex: 0 0 13px; }
.dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 8px; background: var(--quiet); }
.dot[data-tone=ok] { background: var(--accent); }
.dot[data-tone=bad] { background: var(--danger); }
.dot[data-tone=warn] { background: var(--warn-ink); }
.dot[data-tone=info] { background: var(--info); }
/* A notice is bordered on its leading edge in its own tone rather than all the way round:
the strip is what the eye finds down a page of cards, and it survives being one of
several stacked. */
.notice {
padding: 11px 14px; border-radius: var(--radius-sm);
border: 1px solid var(--line); border-left: 3px solid var(--quiet);
background: var(--surface-lift);
color: var(--muted); font-size: 13px;
}
.notice[data-tone=info] { border-left-color: var(--info); background: var(--info-wash); color: var(--info-ink); }
.notice[data-tone=warn] { border-left-color: var(--warn); background: var(--warn-wash); color: var(--warn-ink); }
.notice[data-tone=ok] { border-left-color: var(--accent); background: var(--accent-wash); color: var(--accent-ink); }
.notice[role=alert] {
border-color: rgba(229, 83, 75, .35); border-left-color: var(--danger);
background: var(--danger-wash); color: var(--danger-ink);
}
.empty { color: var(--quiet); font-size: 13px; margin: 0; }
/* A flush card has no padding of its own, so its empty state has to bring some. */
.card.flush > .empty, .card.flush .list > .empty { padding: 18px 20px; }
.score {
font: 650 19px/1 ui-monospace, SFMono-Regular, Consolas, monospace;
color: var(--accent-ink);
}
code, .code {
font: 12px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace;
color: var(--muted);
}
/* ---------- table ---------- */
.table-wrap { overflow-x: auto; margin: 0 -20px -18px; padding: 0 20px 18px; }
.card.flush .table-wrap { margin: 0; padding: 0; }
table { border-collapse: collapse; width: 100%; font-size: 13.5px; }
th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line-soft); white-space: nowrap; }
thead th {
color: var(--quiet); font-size: 11px; font-weight: 600;
letter-spacing: .07em; text-transform: uppercase;
background: var(--surface-lift); border-bottom-color: var(--line);
}
tbody tr:last-child td { border-bottom: 0; }
tbody tr:hover td { background: var(--surface-lift); }
/* The row under the pointer is marked at its leading edge as well as tinted — a long table
read across a wide screen loses a tint of this weight by the time the eye reaches the
far column. */
tbody tr:hover td:first-child { box-shadow: inset 2px 0 0 var(--accent); }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
td.muted { color: var(--muted); white-space: normal; }
/* ---------- list ---------- */
.list { display: grid; }
.list-row {
display: grid; grid-template-columns: minmax(0, 1fr) auto;
gap: 14px; align-items: center;
padding: 13px 20px; border-bottom: 1px solid var(--line-soft);
}
.list-row:last-child { border-bottom: 0; }
.list-row:hover { background: var(--surface-lift); box-shadow: inset 2px 0 0 var(--accent); }
.list-row:focus-within { background: var(--surface-lift); }
a.list-row { color: inherit; text-decoration: none; }
.list-main { display: flex; align-items: center; gap: 12px; min-width: 0; }
.list-main > span { min-width: 0; }
.list-title { font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.list-meta { display: block; color: var(--muted); font-size: 12.5px; margin-top: 2px; }
.list-actions { display: flex; gap: 7px; flex-wrap: wrap; justify-content: flex-end; }
/* The build history under a device row. Held to smaller type than an ordinary chip on
purpose: a set that has been through six releases must not end up outweighing its own
name in the row it belongs to. */
.list-versions { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 6px; }
.list-versions .chip { padding: 1px 7px; font-size: 11.5px; }
/* A person is violet, not green. Green is the console's verdict colour and an avatar is no
verdict — a list of viewers in accent read as a list of things that were working. */
.avatar {
display: grid; place-items: center; width: 34px; height: 34px; flex: 0 0 34px;
border-radius: 50%; background: var(--note-wash); color: var(--note-ink);
font-size: 13px; font-weight: 700;
}
.avatar[data-tone=ok] { background: var(--accent-wash); color: var(--accent-ink); }
.avatar[data-tone=idle] { background: var(--surface-hi); color: var(--quiet); }
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
.chip {
display: inline-flex; align-items: center; gap: 5px;
padding: 3px 8px; border-radius: 6px;
background: var(--surface-lift); color: var(--muted); font-size: 12px;
}
.chip[data-tone=accent] { color: var(--accent-ink); background: var(--accent-wash); }
.chip[data-tone=warn] { color: var(--warn-ink); background: var(--warn-wash); }
.chip[data-tone=bad] { color: var(--danger-ink); background: var(--danger-wash); }
.chip[data-tone=info] { color: var(--info-ink); background: var(--info-wash); }
.chip[data-tone=note] { color: var(--note-ink); background: var(--note-wash); }
.chip[data-tone=data] { color: var(--data-ink); background: var(--data-wash); }
.chip .ico { width: 12px; height: 12px; flex: 0 0 12px; }
.chip.mono { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11.5px; }
details summary { color: var(--muted); cursor: pointer; font-size: 13px; }
details[open] summary { margin-bottom: 8px; }
/* ---------- log viewer ---------- */
.log {
height: min(62vh, 760px); min-height: 340px; overflow: auto;
background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius-sm);
font: 12px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace;
}
.log-line {
display: grid; grid-template-columns: 168px 56px minmax(200px, 260px) minmax(280px, 1fr);
gap: 10px; padding: 4px 11px; border-bottom: 1px solid var(--line-soft);
}
.log-line:hover { background: var(--surface); }
.log-time, .log-attrs { color: var(--quiet); }
.log-level { font-weight: 700; color: var(--info-ink); }
.log-level.ERROR { color: var(--danger-ink); }
.log-level.WARN { color: var(--warn-ink); }
.log-level.DEBUG { color: var(--quiet); }
/* The line is banded in the level's colour. Scrolling a log is looking for the one line
that is not INFO, and a coloured word four columns wide is easy to scroll past. */
.log-line.ERROR { background: rgba(229, 83, 75, .07); box-shadow: inset 2px 0 0 var(--danger); }
.log-line.WARN { background: rgba(239, 196, 107, .06); box-shadow: inset 2px 0 0 var(--warn); }
.log-empty { padding: 18px; color: var(--quiet); }
/* ---------- responsive ---------- */
@media (max-width: 1000px) {
:root { --rail: 62px; }
.rail { padding: 16px 7px 10px; }
.rail-brand { justify-content: center; padding: 0 0 14px; }
.rail-brand-copy, .rail-group, .rail-link span, .rail-foot-copy, .rail-logout span { display: none; }
.rail-nav[hidden] { display: grid !important; }
.rail-link { justify-content: center; padding: 0; min-height: 38px; }
.rail-foot { justify-items: center; padding: 12px 0 0; }
.rail-logout { width: auto; padding: 5px; }
.grid.wide { grid-template-columns: 1fr; }
.page { padding: 20px 14px 44px; }
.table-wrap { margin: 0 -20px -18px; }
}
@media (max-width: 620px) {
.card { padding: 16px; }
.list-row { grid-template-columns: 1fr; }
.list-actions { justify-content: flex-start; }
.log-line { grid-template-columns: 130px 50px 1fr; }
.log-attrs { grid-column: 1 / -1; }
}
-312
View File
@@ -1,312 +0,0 @@
/* The console's shared runtime: one HTTP client, one error banner, one refresh loop and
the handful of formatters and markup helpers every page draws with. A page fragment
should contain the decisions that are its own and nothing else — if two pages need the
same piece of markup it belongs here, beside `ui`.
Pages plug into it rather than reaching around it:
Admin.onStatus(fn) fn(status) on every /admin/api/status poll
Admin.onRefresh(fn) an async task run alongside that poll
Admin.ready(fn) once, after the page has parsed
Admin.act(fn) run a mutation, then refresh, reporting failure in the banner */
const Admin = (() => {
const page = document.querySelector('[data-admin-page]').dataset.adminPage;
const statusHandlers = [];
const refreshTasks = [];
/* ---- transport ------------------------------------------------------- */
// The sign-in behind this page lasts twelve hours and slides forward only for requests
// an operator actually caused, so the poll of a tab nobody is reading cannot keep it
// alive. Anything the console does while somebody is working it says so with this
// header; see operatorPresent on the server.
const ACTIVITY_WINDOW_MS = 5 * 60 * 1000;
let lastInteraction = Date.now();
for (const name of ['pointerdown', 'pointermove', 'keydown', 'wheel', 'scroll']) {
window.addEventListener(name, () => { lastInteraction = Date.now(); }, { passive: true });
}
// A 401 is an expired sign-in rather than a wrong token. Reloading re-renders this URL as
// the login form with `next` pointing back at it, so the operator signs in once and lands
// where they were, instead of reading a banner the page can never clear. The timestamp is
// what stops a 401 that survives the reload from looping.
const RELOGIN_KEY = 'memby-admin-relogin';
function reauthenticate() {
try {
if (Date.now() - Number(sessionStorage.getItem(RELOGIN_KEY) || 0) < 30000) return false;
sessionStorage.setItem(RELOGIN_KEY, String(Date.now()));
} catch (err) {
// Private-mode storage refusals must not cost the reload; take the loop risk.
}
window.location.reload();
return true;
}
async function api(path, options = {}) {
const active = Date.now() - lastInteraction < ACTIVITY_WINDOW_MS;
const response = await fetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
...(active ? { 'X-Memby-Admin-Active': '1' } : {}),
...(options.headers || {}),
},
});
if (response.status === 401) {
throw new Error(reauthenticate()
? 'Your sign-in has expired. Signing in again…'
: 'Your sign-in has expired. Reload this page to sign in again.');
}
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || ('Request failed (' + response.status + ')'));
}
return response.status === 204 ? null : response.json();
}
/* ---- formatting ------------------------------------------------------ */
const escape = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[char]));
const number = (value) => (value ?? 0).toLocaleString();
const when = (value) => (value ? new Date(value).toLocaleString() : '—');
const time = (value) => (value ? new Date(value).toLocaleTimeString() : '—');
function duration(ms) {
if (!ms) return '0s';
const seconds = Math.round(ms / 1000);
if (seconds < 60) return seconds + 's';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return minutes + 'm ' + (seconds % 60) + 's';
return Math.floor(minutes / 60) + 'h ' + (minutes % 60) + 'm';
}
function bytes(value) {
const units = ['B', 'KB', 'MB', 'GB'];
let amount = Number(value || 0);
let unit = 0;
while (amount >= 1024 && unit < units.length - 1) { amount /= 1024; unit += 1; }
return (unit === 0 ? amount : amount.toFixed(1)) + ' ' + units[unit];
}
const initials = (name) => String(name || '?').trim().split(/\s+/).slice(0, 2)
.map((part) => part[0] || '').join('').toUpperCase();
// "Seen in the last quarter of an hour" is what the console means by active: a television
// checks in every few seconds while somebody is using it.
const ACTIVE_MS = 15 * 60 * 1000;
const IDLE_MS = 7 * 24 * 60 * 60 * 1000;
const recent = (value) => Boolean(value) && Date.now() - new Date(value).getTime() < ACTIVE_MS;
// Three states rather than two, because "not active this minute" covers both a set
// somebody switched off after breakfast and one that has not been seen since a firmware
// update in March — and only the second is worth an operator's attention. Green is on
// now, amber is a set in ordinary use that happens to be off, red is one that has
// stopped checking in. A device with no timestamp at all is red: never seen is the
// strongest version of not seen.
function presence(value) {
const seen = value ? new Date(value).getTime() : 0;
if (!seen) return { tone: 'bad', label: 'never seen' };
const age = Date.now() - seen;
if (age < ACTIVE_MS) return { tone: 'ok', label: 'active now' };
if (age < IDLE_MS) return { tone: 'warn', label: 'seen recently' };
return { tone: 'bad', label: 'not seen lately' };
}
const fmt = { escape, number, when, time, duration, bytes, initials, recent, presence };
/* ---- markup components ----------------------------------------------- */
/* Icons live here and nowhere else. Each is the `d` of one stroked path on a 24×24 grid,
the same shape the rail's marks take, so a page never carries SVG markup of its own and
two screens showing the same idea cannot draw it two ways. A fragment asks for one by
writing data-icon="…" on any element; a script asks with ui.icon(). An unknown name
draws nothing rather than a broken box — a mark is decoration, and a typo in one must
never be what an operator notices about a page. */
const icons = {
library: 'M3 5h18v14H3zM7 5v14M17 5v14M3 9.5h4M3 14.5h4M17 9.5h4M17 14.5h4',
people: 'M15 19v-1.2a3.3 3.3 0 0 0-3.3-3.3H6.8A3.3 3.3 0 0 0 3.5 17.8V19M9.2 11a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4ZM17 10.6a3 3 0 0 0-1.4-5.7M20.5 19v-1.2a3.3 3.3 0 0 0-2.4-3.2',
person: 'M18 20v-1.5a4 4 0 0 0-4-4h-4a4 4 0 0 0-4 4V20M12 10.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z',
tv: 'M4 5h16v10H4zM9 19h6M12 15v4M8 2.5 12 5l4-2.5',
sliders: 'M4 7h10M18 7h2M4 17h2m4 0h10M14 4v6M6 14v6',
clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM12 7.2V12l3 1.8',
pulse: 'M3 12h3.5L9 19l5-14 2.5 7H21',
chart: 'M4 19V9m5 10V5m5 14v-7m5 7V3',
chip: 'M8 8h8v8H8zM4.5 4.5h15v15h-15zM9 2v2.5M15 2v2.5M9 19.5V22M15 19.5V22M2 9h2.5M2 15h2.5M19.5 9H22M19.5 15H22',
database: 'M12 8.2c4.4 0 8-1.2 8-2.6S16.4 3 12 3 4 4.2 4 5.6s3.6 2.6 8 2.6ZM4 5.6v12.8C4 19.8 7.6 21 12 21s8-1.2 8-2.6V5.6M4 12c0 1.4 3.6 2.6 8 2.6s8-1.2 8-2.6',
download: 'M12 3.5v10m0 0 4-4m-4 4-4-4M4.5 18h15',
sync: 'M3.5 12a8.5 8.5 0 0 1 14.6-6M20.5 12a8.5 8.5 0 0 1-14.6 6M18 2.5V6h-3.5M6 21.5V18h3.5',
search: 'M10.5 17a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Zm4.6-1.9L20 20',
star: 'm12 3.2 2.6 5.4 5.9.8-4.3 4.1 1 5.9-5.2-2.8-5.2 2.8 1-5.9L3.5 9.4l5.9-.8L12 3.2Z',
sparkle: 'm10 3 1.5 4.3L16 8.8l-4.5 1.5L10 14.6 8.5 10.3 4 8.8l4.5-1.5L10 3ZM17.5 14l.9 2.4 2.6.9-2.6.9-.9 2.4-.9-2.4-2.6-.9 2.6-.9.9-2.4Z',
bell: 'M6.2 9.5a5.8 5.8 0 1 1 11.6 0c0 4.6 2.2 5.9 2.2 5.9H4s2.2-1.3 2.2-5.9M10 19.5a2 2 0 0 0 4 0',
shield: 'm12 3 7.5 3v5.4c0 5-3.2 8.2-7.5 9.6-4.3-1.4-7.5-4.6-7.5-9.6V6L12 3Zm-2.6 8.7 1.9 1.9 3.6-3.6',
wrench: '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',
play: 'M8 5.2v13.6L19 12 8 5.2ZM4 5v14',
list: 'M4 7h16M4 12h16M4 17h10',
inbox: 'M4 7h16v13H4zM8 4h8v3M8 12h8M8 16h5',
history: 'M3.5 12a8.5 8.5 0 1 0 2.8-6.3M3.5 4v4h4M12 7.5V12l3 1.8',
check: 'm5 12.5 4.5 4.5L19 7.5',
alert: 'M12 8.5v5m0 3.2h.01M10.3 4.4 2.7 17.5a2 2 0 0 0 1.7 3h15.2a2 2 0 0 0 1.7-3L13.7 4.4a2 2 0 0 0-3.4 0Z',
power: 'M12 3v9M7.5 6.2a7.5 7.5 0 1 0 9 0',
key: 'M14.5 3a6.5 6.5 0 1 0 3.4 12L19 14h2v-2h2V9.5l-2.5-2.5A6.5 6.5 0 0 0 14.5 3Zm-2.6 4.6a1.6 1.6 0 1 1-2.3 2.3 1.6 1.6 0 0 1 2.3-2.3Z',
captions: 'M4 5.5h16v13H4zM7 11h3m2 0h5M7 15h5m3 0h2',
};
const icon = (name) => (icons[name]
? '<svg class="ico" viewBox="0 0 24 24" aria-hidden="true"><path d="' + icons[name] + '"/></svg>'
: '');
// An icon in a tinted plate — the tile and card-heading mark. The tone is what says which
// area a thing belongs to, so it is passed rather than derived.
const glyph = (name, tone) => (icons[name]
? '<span class="glyph"' + (tone ? ' data-tone="' + tone + '"' : '') + '>' + icon(name) + '</span>'
: '');
const ui = {
icon,
glyph,
// [label, value, options?] → the tile strip used at the top of most pages. The options
// are { small, icon, tone }: `small` for a value that is a sentence rather than a
// number, the other two for the mark above it.
tiles: (entries) => entries.map(([label, value, options]) => {
const opts = typeof options === 'object' && options !== null ? options : { small: options };
return '<div class="tile">' + (opts.icon ? glyph(opts.icon, opts.tone) : '') +
'<b' + (opts.small ? ' class="small"' : '') + '>' + escape(String(value)) +
'</b><span>' + escape(label) + '</span></div>';
}).join(''),
tag: (label, tone) => '<span class="tag"' + (tone ? ' data-tone="' + tone + '"' : '') +
'>' + escape(label) + '</span>',
chip: (label, tone) => '<span class="chip"' + (tone ? ' data-tone="' + tone + '"' : '') +
'>' + escape(label) + '</span>',
empty: (message) => '<p class="empty">' + escape(message) + '</p>',
emptyRow: (columns, message) => '<tr><td colspan="' + columns + '" class="muted">' +
escape(message) + '</td></tr>',
};
/* ---- page plumbing --------------------------------------------------- */
const $ = (id) => document.getElementById(id);
// `data-icon` (with an optional `data-icon-tone`) on any element is how a fragment asks
// for a mark without writing SVG. It is applied once, when the page has parsed, because
// what carries it is the static markup a fragment ships — anything a poll redraws asks
// with ui.glyph instead, or the mark would be wiped on the first refresh. The attribute
// is consumed, so running this again over the same tree cannot double the icon.
function decorate(root = document) {
for (const element of root.querySelectorAll('[data-icon]')) {
element.insertAdjacentHTML('afterbegin', glyph(element.dataset.icon, element.dataset.iconTone));
delete element.dataset.icon;
}
}
function error(message) {
const banner = $('error');
banner.textContent = message || '';
banner.hidden = !message;
}
// Never redraw markup the operator is working inside. Every poll would otherwise take a
// half-typed field, an open select or a scrolled list away mid-edit.
const settled = (element) => element && !element.contains(document.activeElement);
// The same rule for a single control: fill it in unless it is the one being used.
function fill(element, value) {
if (element && document.activeElement !== element) element.value = value;
return element;
}
function check(element, value) {
if (element && document.activeElement !== element) element.checked = Boolean(value);
return element;
}
const onStatus = (fn) => statusHandlers.push(fn);
const onRefresh = (fn) => refreshTasks.push(fn);
const ready = (fn) => (document.readyState === 'loading'
? document.addEventListener('DOMContentLoaded', fn) : fn());
function live(ok, label) {
const tag = $('live');
tag.textContent = label;
tag.dataset.tone = ok ? 'ok' : 'bad';
$('rail-live').dataset.tone = ok ? 'ok' : 'bad';
$('rail-live-label').textContent = ok ? 'online' : 'unreachable';
}
async function refresh() {
try {
const status = await api('/admin/api/status');
$('rail-version').textContent = 'gateway ' + (status.serverVersion || 'unknown');
statusHandlers.forEach((handler) => handler(status));
await Promise.all(refreshTasks.map((task) => task()));
live(true, 'updated ' + new Date().toLocaleTimeString());
error('');
} catch (err) {
live(false, 'not responding');
error(err.message);
}
}
async function act(fn) {
try {
await fn();
await refresh();
} catch (err) {
error(err.message);
}
}
/* ---- refresh loop ----------------------------------------------------- */
// Someone leaves this open on a second monitor, which makes its poll the gateway's most
// frequent caller by a wide margin. It stops entirely on a hidden tab and catches up the
// moment the tab is looked at again — a background tab nobody is reading has no status
// worth fetching.
const REFRESH_MS = 30000;
let timer = null;
function schedule() {
clearInterval(timer);
timer = document.hidden ? null : setInterval(refresh, REFRESH_MS);
}
document.addEventListener('visibilitychange', () => {
schedule();
if (!document.hidden) refresh();
});
/* ---- collapsible navigation ----------------------------------------- */
function prepareNavigation() {
document.querySelectorAll('[data-nav-group]').forEach((button) => {
const id = button.dataset.navGroup;
const panel = document.querySelector('[data-nav-panel="' + id + '"]');
if (!panel) return;
const active = panel.querySelector('[aria-current="page"]');
let expanded = active || localStorage.getItem('memby-admin-nav-' + id) !== 'closed';
const apply = () => {
button.setAttribute('aria-expanded', expanded ? 'true' : 'false');
panel.hidden = !expanded;
};
apply();
button.addEventListener('click', () => {
expanded = !expanded;
localStorage.setItem('memby-admin-nav-' + id, expanded ? 'open' : 'closed');
apply();
});
});
}
ready(() => { prepareNavigation(); decorate(); refresh(); schedule(); });
return {
page, api, fmt, ui, $, error, settled, fill, check,
onStatus, onRefresh, ready, refresh, act, decorate,
};
})();
@@ -1,79 +0,0 @@
<a class="crumb" href="/admin/accounts">← All users</a>
<section class="card" id="account-identity">
<p class="empty">Loading this user…</p>
</section>
<div class="grid wide">
<section class="card flush">
<div class="card-head">
<h2 class="card-title" data-icon="tv" data-icon-tone="info">Devices</h2>
<p class="card-note">Every build a set has been seen running is listed under it.
Signing one out revokes its Memby session, drops that history and removes it from
Emby's own device list. Its Emby account is not changed.</p>
</div>
<div class="list" id="account-devices"></div>
</section>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="sparkle" data-icon-tone="note">Recommendation setup</h2>
<p class="card-note">The prompt appears the next time this person opens Memby on any
of their televisions.</p>
</div>
<div id="account-recommendations"></div>
<div class="card-foot" id="account-recommendation-actions"></div>
</section>
</div>
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="sliders" data-icon-tone="ok">Settings</h2>
<p class="card-note">These live on the server and follow the person, so a change here
reaches every television they use — usually within a few seconds, and on the next
launch for a set that is switched off.</p>
</div>
<span id="account-settings-state"></span>
</div>
<div class="fields" id="account-settings"></div>
<div class="card-foot">
<button class="primary" data-account-action="push-preferences">Push to their televisions</button>
<button data-account-action="reload-preferences">Discard changes</button>
<button data-account-action="reset-preferences">Restore defaults</button>
<a class="crumb" id="account-settings-history">History and rollback →</a>
<span class="hint" id="account-settings-message"></span>
</div>
</section>
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="sparkle" data-icon-tone="note">Colour schemes</h2>
<p class="card-note">Which palettes this person may choose between in Settings →
Appearance. Tick everything to leave them unrestricted. Their current choice is
an ordinary setting above; withdrawing it here puts them back on Midnight.</p>
<p class="card-note">Seasonal themes are not listed. They apply to every television in
the house for their dates and nobody can decline one — the only switch is
<em>Seasonal themes</em> on the features page.</p>
</div>
<span id="account-themes-state"></span>
</div>
<div class="checks columns" id="account-themes"></div>
<div class="card-foot">
<button class="primary" data-account-action="save-themes">Save colour schemes</button>
<button data-account-action="all-themes">Allow all</button>
<span class="hint" id="account-themes-message"></span>
</div>
</section>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="alert" data-icon-tone="bad">Remove Memby access</h2>
<p class="card-note">Signs every one of this person's Memby devices out. Their Emby
account, viewing history and library permissions are untouched.</p>
</div>
<div class="row">
<button class="danger" data-account-action="remove-account">Remove Memby access</button>
</div>
</section>
-364
View File
@@ -1,364 +0,0 @@
/* One person: their televisions, their recommendation setup and the settings that follow
them to every set. The identity is in the URL rather than in a query string so the page
can be linked, bookmarked and returned to after a sign-in. */
const { fmt, ui, $ } = Admin;
const userId = decodeURIComponent(location.pathname.split('/').filter(Boolean).pop() || '');
const base = '/admin/api/accounts/' + encodeURIComponent(userId);
// The accounts endpoint answers for the whole household in one read and carries the
// preference catalogue with it, so this page asks for that rather than adding a per-user
// endpoint that would return a slice of the same query.
let catalogue = [];
// The selectable themes, likewise carried by that endpoint rather than written out here.
let themeCatalogue = [];
// The same dirty rule the settings form follows, kept separate so saving one does not
// discard an unsaved edit to the other.
let themesDirty = false;
// True while the operator has edited the settings form without saving. The page polls every
// thirty seconds and a redraw would take a half-finished change away mid-sentence, so a
// dirty form keeps the DOM it already has until it is saved, discarded or reloaded.
let dirty = false;
/* ---- settings controls ------------------------------------------------- */
function settingControl(definition, value) {
const key = fmt.escape(definition.key);
const name = fmt.escape(definition.name);
const description = fmt.escape(definition.description);
if (definition.kind === 'toggle') {
return '<label class="check"><input type="checkbox" data-pref-key="' + key +
'" data-pref-kind="toggle"' + (value ? ' checked' : '') + '><span>' + name +
'<em>' + description + '</em></span></label>';
}
if (definition.kind === 'choice' || definition.kind === 'number') {
// A number's unit comes from the catalogue. Assuming minutes was safe while the only
// number was a time budget, and wrong the moment a second one counted anything else.
const unit = definition.unit || '';
const options = definition.kind === 'number'
? (definition.numbers || []).map((amount) =>
[String(amount), amount === 0 ? 'No limit' : (unit ? amount + ' ' + unit : String(amount))])
: (definition.options || []).map((option) => [option.value, option.label]);
return '<label class="field"><span>' + name + '</span><em>' + description + '</em>' +
'<select data-pref-key="' + key + '" data-pref-kind="' + fmt.escape(definition.kind) + '">' +
options.map(([optionValue, optionLabel]) => '<option value="' + fmt.escape(optionValue) + '"' +
(String(value) === optionValue ? ' selected' : '') + '>' +
fmt.escape(String(optionLabel)) + '</option>').join('') + '</select></label>';
}
if (definition.kind === 'multi') {
// Rendered in the viewer's own order, then whatever they have not selected. The order
// of these rows is the order the launcher draws them in, so preserving it matters as
// much as which are ticked.
const selected = Array.isArray(value) ? value : [];
const ordered = selected.concat((definition.options || [])
.map((option) => option.value).filter((option) => !selected.includes(option)));
return '<div class="field"><span>' + name + '</span><em>' + description + '</em>' +
'<div class="checks" data-pref-key="' + key + '" data-pref-kind="multi">' +
ordered.map((option) => {
const match = (definition.options || []).find((entry) => entry.value === option);
if (!match) return '';
return '<label class="check"><input type="checkbox" data-pref-option="' +
fmt.escape(option) + '"' + (selected.includes(option) ? ' checked' : '') + '><span>' +
fmt.escape(match.label) + '</span></label>';
}).join('') + '</div></div>';
}
// A free-form list of server row ids: one per line, which is also how the television
// stores them. There is no vocabulary to offer, because these ids ship from the gateway
// without an app release.
const entries = Array.isArray(value) ? value : [];
return '<label class="field"><span>' + name + '</span><em>' + description + '</em>' +
'<textarea data-pref-key="' + key + '" data-pref-kind="list" spellcheck="false" ' +
'placeholder="One row id per line">' + fmt.escape(entries.join('\n')) + '</textarea></label>';
}
// Reads the form back into the document shape the server normalises. Unknown keys and
// illegal values are the server's problem by design — this only has to be honest about what
// the operator selected.
function collectSettings() {
const preferences = {};
$('account-settings').querySelectorAll('[data-pref-key]').forEach((control) => {
const key = control.dataset.prefKey;
switch (control.dataset.prefKind) {
case 'toggle': preferences[key] = control.checked; break;
case 'number': preferences[key] = Number(control.value); break;
case 'multi':
preferences[key] = Array.from(control.querySelectorAll('input:checked'))
.map((input) => input.dataset.prefOption);
break;
case 'list':
preferences[key] = control.value.split('\n').map((entry) => entry.trim()).filter(Boolean);
break;
default: preferences[key] = control.value;
}
});
return preferences;
}
function renderSettings(account) {
const settings = account.settings || {};
const values = settings.preferences || {};
const areas = [];
catalogue.forEach((definition) => {
let area = areas.find((entry) => entry.name === definition.area);
if (!area) areas.push(area = { name: definition.area, definitions: [] });
area.definitions.push(definition);
});
$('account-settings-state').innerHTML = settings.saved
? ui.tag('r' + fmt.number(settings.revision || 0) + ' · ' + (settings.source || 'device') +
' · ' + fmt.when(settings.updatedAt), settings.source === 'admin' ? 'warn' : 'ok')
: ui.tag('defaults · never synced', 'idle');
$('account-settings').innerHTML = areas.map((area) =>
'<div class="field"><p class="card-sub">' + fmt.escape(area.name) + '</p>' +
'<div class="checks">' + area.definitions.map((definition) =>
settingControl(definition, values[definition.key])).join('') + '</div></div>').join('');
}
/* ---- colour schemes ------------------------------------------------------ */
// The palette is written the way Android reads it, #AARRGGBB, and CSS reads #RRGGBBAA. The
// conversion lives here rather than on the wire because the television is the end that has
// to parse thousands of these and the console is the end that parses eight.
function cssColour(value) {
const hex = String(value || '').replace('#', '');
if (hex.length !== 8) return '#' + hex;
return '#' + hex.slice(2) + hex.slice(0, 2);
}
function themeRow(theme, allowed) {
const palette = theme.palette || {};
// Only the palette itself is set inline; see the .swatch note in admin.css.
const style = '--swatch-surface:' + cssColour(palette.surface) + ';' +
'--swatch-accent:' + cssColour(palette.accent) + ';' +
'--swatch-hairline:' + cssColour(palette.hairline);
return '<label class="check"><input type="checkbox" data-theme-id="' + fmt.escape(theme.id) +
'"' + (allowed ? ' checked' : '') + '>' +
'<span class="swatch" style="' + fmt.escape(style) + '"><i></i></span>' +
'<span>' + fmt.escape(theme.name) + '<em>' + fmt.escape(theme.description) +
'</em></span></label>';
}
function renderThemes(account) {
// An empty list from the server means unrestricted, so it draws as every box ticked.
// Storing "all" and "never configured" identically is deliberate — they are the same
// decision — and this is the one place an operator would notice if it were not.
const allowed = account.themes || [];
const unrestricted = allowed.length === 0;
$('account-themes-state').innerHTML = unrestricted
? ui.tag('all schemes', 'idle')
: ui.tag(fmt.number(allowed.length) + ' of ' + fmt.number(themeCatalogue.length), 'note');
$('account-themes').innerHTML = themeCatalogue.map((theme) =>
themeRow(theme, unrestricted || allowed.includes(theme.id))).join('');
}
function collectThemes() {
return Array.from($('account-themes').querySelectorAll('input:checked'))
.map((input) => input.dataset.themeId);
}
/* ---- the rest of the page ---------------------------------------------- */
// Every build this set has been seen running, newest first and the current one flagged.
// One television that has been through four releases is a different thing from four
// televisions, and the history is what says which of those an operator is looking at.
function versionHistory(device) {
const versions = device.versions || [];
if (!versions.length) return '';
return '<span class="list-versions">' + versions.map((entry) =>
ui.chip(entry.version + (entry.version === device.version ? ' · now' : ''),
entry.version === device.version ? 'accent' : null)).join('') + '</span>';
}
function renderDevices(account) {
const devices = account.devices || [];
$('account-devices').innerHTML = devices.length ? devices.map((device) => {
const seen = fmt.presence(device.lastSeen);
return '<div class="list-row"><span class="list-main"><span>' +
'<span class="list-title">' +
'<span class="dot" data-tone="' + seen.tone + '" title="' + seen.label + '"></span>' +
fmt.escape(device.name || 'Memby TV') + '</span>' +
'<span class="list-meta">' +
fmt.escape(device.version ? 'Memby ' + device.version : 'Legacy Memby client') +
' · ' + fmt.escape(seen.label) +
' · last seen ' + fmt.escape(fmt.when(device.lastSeen)) +
' · signed in ' + fmt.escape(fmt.when(device.signedInAt)) +
'</span>' + versionHistory(device) + '</span></span>' +
'<span class="list-actions">' +
'<button class="small" data-account-action="rename-device" data-device-id="' +
fmt.escape(device.id) + '" data-device-name="' + fmt.escape(device.name) + '"' +
(device.id ? '' : ' disabled') + '>Rename</button>' +
'<button class="small danger" data-account-action="remove-device" data-device-id="' +
fmt.escape(device.id) + '"' + (device.id ? '' : ' disabled') + '>Sign out</button>' +
'</span></div>';
}).join('') : ui.empty('No devices are signed in to this user.');
}
function renderRecommendations(account) {
const prompt = account.recommendations || {};
const ratings = prompt.ratings || [];
const dimensions = [
['Genres', prompt.genres], ['Studios', prompt.studios], ['Actors', prompt.actors],
['Actresses', prompt.actresses], ['Directors', prompt.directors],
['Types', prompt.contentTypes],
].filter((entry) => (entry[1] || []).length);
const chips = ratings.map((rating) =>
ui.chip(rating.title + ' · ' + fmt.number(rating.rating) + ' ★', 'warn')).join('') +
dimensions.flatMap(([label, values]) => (values || [])
.map((value) => ui.chip(label + ': ' + value))).join('');
const state = prompt.completed
? ui.tag('completed', 'ok')
: prompt.prompted ? ui.tag('prompt queued', 'warn') : ui.tag('not invited', 'idle');
$('account-recommendations').innerHTML = '<div class="row tight">' + state + '</div>' +
(chips ? '<div class="chips">' + chips + '</div>'
: ui.empty('No recommendation selections have been saved.'));
$('account-recommendation-actions').innerHTML = prompt.completed
? '<button data-account-action="reset-recommendations">Clear stored choices</button>'
: prompt.prompted
? '<button data-account-action="cancel-recommendation-prompt">Cancel prompt</button>'
: '<button class="primary" data-account-action="prompt-recommendations">Send setup prompt</button>';
}
function renderIdentity(account) {
const devices = account.devices || [];
const active = devices.filter((device) => fmt.recent(device.lastSeen)).length;
$('page-title').textContent = account.username || 'Unnamed user';
$('page-intro').textContent = 'Memby user · ' + fmt.number(devices.length) + ' device' +
(devices.length === 1 ? '' : 's') + ' · last seen ' + fmt.when(account.lastSeen);
document.title = (account.username || 'User') + ' · Memby admin';
$('account-identity').innerHTML = '<div class="row">' +
'<span class="avatar">' + fmt.escape(fmt.initials(account.username)) + '</span>' +
'<span class="list-title">' + fmt.escape(account.username || 'Unnamed user') + '</span>' +
(active ? ui.tag(active + ' active now', 'ok') : ui.tag('idle', 'idle')) +
'<span class="chip mono">' + fmt.escape(account.id) + '</span></div>';
}
Admin.onRefresh(async () => {
$('account-settings-history').href = '/admin/accounts/' + encodeURIComponent(userId) + '/settings';
const payload = await Admin.api('/admin/api/accounts');
catalogue = payload.catalogue || catalogue;
themeCatalogue = payload.themes || themeCatalogue;
const account = (payload.accounts || []).find((entry) => entry.id === userId);
if (!account) {
$('account-identity').innerHTML =
ui.empty('This user is no longer signed in to Memby.');
return;
}
renderIdentity(account);
renderDevices(account);
renderRecommendations(account);
if (!dirty) renderSettings(account);
if (!themesDirty) renderThemes(account);
});
/* ---- actions ------------------------------------------------------------ */
function message(text) { $('account-settings-message').textContent = text || ''; }
function themeMessage(text) { $('account-themes-message').textContent = text || ''; }
document.addEventListener('input', (event) => {
if ($('account-themes').contains(event.target)) {
themesDirty = true;
themeMessage('unsaved changes');
return;
}
if (!$('account-settings').contains(event.target)) return;
dirty = true;
message('unsaved changes');
});
document.addEventListener('click', (event) => {
const button = event.target.closest('[data-account-action]');
if (!button) return;
const action = button.dataset.accountAction;
if (action === 'rename-device') {
const name = prompt('Name this Memby device', button.dataset.deviceName || 'Memby TV');
if (name === null || !name.trim()) return;
Admin.act(() => Admin.api(base + '/devices/' + encodeURIComponent(button.dataset.deviceId), {
method: 'PUT', body: JSON.stringify({ deviceName: name.trim() }),
}));
}
if (action === 'remove-device') {
if (!confirm('Sign this device out of Memby? Its Emby account will not be changed.')) return;
Admin.act(() => Admin.api(base + '/devices/' +
encodeURIComponent(button.dataset.deviceId), { method: 'DELETE' }));
}
if (action === 'remove-account') {
if (!confirm('Remove Memby access for ' + ($('page-title').textContent || 'this user') +
'? Every Memby device will be signed out. Their Emby account will not be changed.')) return;
Admin.act(async () => {
await Admin.api(base + '/sessions', { method: 'DELETE' });
location.href = '/admin/accounts';
});
}
if (action === 'reset-recommendations') {
if (!confirm('Clear this persons stored recommendation choices? Viewing history remains intact.')) return;
Admin.act(() => Admin.api(base + '/recommendations', { method: 'DELETE' }));
}
if (action === 'prompt-recommendations') {
Admin.act(() => Admin.api(base + '/recommendations/prompt', { method: 'PUT' }));
}
if (action === 'cancel-recommendation-prompt') {
if (!confirm('Cancel this persons queued recommendation prompt?')) return;
Admin.act(() => Admin.api(base + '/recommendations', { method: 'DELETE' }));
}
if (action === 'push-preferences') {
const preferences = collectSettings();
message('pushing…');
Admin.act(async () => {
await Admin.api(base + '/preferences', {
method: 'PUT', body: JSON.stringify({ preferences }),
});
// Cleared before the refresh, so the form is redrawn from what the server actually
// stored rather than from what was submitted.
dirty = false;
message('pushed');
});
}
if (action === 'save-themes') {
const themes = collectThemes();
if (!themes.length &&
!confirm('Allow this person no colour schemes? They will be left on Midnight with ' +
'nothing to choose between.')) return;
themeMessage('saving…');
Admin.act(async () => {
await Admin.api(base + '/themes', {
method: 'PUT', body: JSON.stringify({ themes }),
});
// Cleared before the refresh so the boxes are redrawn from what was stored, which is
// how "every box ticked" comes back as the unrestricted state rather than as a list.
themesDirty = false;
themeMessage('saved');
});
}
if (action === 'all-themes') {
$('account-themes').querySelectorAll('input[data-theme-id]')
.forEach((input) => { input.checked = true; });
themesDirty = true;
themeMessage('unsaved changes');
}
if (action === 'reload-preferences') {
dirty = false;
message('');
Admin.refresh();
}
if (action === 'reset-preferences') {
if (!confirm('Restore the Memby defaults for this person? Their televisions will pick ' +
'the change up the next time they check in.')) return;
dirty = false;
Admin.act(() => Admin.api(base + '/preferences', { method: 'DELETE' }));
}
});
@@ -1,13 +0,0 @@
<p class="notice" data-tone="info">
This is the Memby user list, not the Emby user directory. A person appears here only
after signing in to the Memby app. Removing access signs their Memby devices out and does
not delete or change their Emby account.
</p>
<div class="tiles" id="account-tiles"></div>
<section class="card flush">
<div class="list" id="account-list">
<p class="empty">Loading users…</p>
</div>
</section>
@@ -1,52 +0,0 @@
/* A directory, and only a directory. Everything you can *do* to a person lives on their own
page: this list used to render a full seventeen-control settings editor for every account
at once, which meant the page grew with the household and an operator scrolled past four
other people's preferences to reach the one they came for. */
const { fmt, ui, $ } = Admin;
function accountRow(account) {
const devices = account.devices || [];
const active = devices.filter((device) => fmt.recent(device.lastSeen)).length;
const prompt = account.recommendations || {};
const state = prompt.completed
? ui.tag('personalised', 'ok')
: prompt.prompted ? ui.tag('prompt queued', 'warn') : ui.tag('not invited', 'idle');
const seen = fmt.presence(account.lastSeen);
return '<a class="list-row" href="/admin/accounts/' + encodeURIComponent(account.id) + '">' +
'<span class="list-main">' +
'<span class="avatar">' + fmt.escape(fmt.initials(account.username)) + '</span>' +
'<span>' +
'<span class="list-title">' + fmt.escape(account.username || 'Unnamed user') +
'<span class="dot" data-tone="' + seen.tone + '" title="' + seen.label + '"></span>' +
'</span>' +
'<span class="list-meta">' + fmt.number(devices.length) + ' device' +
(devices.length === 1 ? '' : 's') +
(active ? ' · ' + active + ' active now' : '') +
' · last seen ' + fmt.escape(fmt.when(account.lastSeen)) + '</span>' +
'</span>' +
'</span>' +
'<span class="list-actions">' + state + '<span class="crumb">Manage</span></span></a>';
}
Admin.onRefresh(async () => {
const payload = await Admin.api('/admin/api/accounts');
const accounts = payload.accounts || [];
const devices = accounts.flatMap((account) => account.devices || []);
const prompts = accounts.filter((account) => account.recommendations?.completed).length;
const queued = accounts.filter((account) =>
account.recommendations?.prompted && !account.recommendations?.completed).length;
$('account-tiles').innerHTML = ui.tiles([
['Memby users', fmt.number(accounts.length), { icon: 'people', tone: 'note' }],
['signed-in devices', fmt.number(devices.length), { icon: 'tv', tone: 'info' }],
['active in the last quarter hour', fmt.number(devices.filter((device) =>
fmt.recent(device.lastSeen)).length), { icon: 'pulse', tone: 'ok' }],
['recommendation setups completed', fmt.number(prompts), { icon: 'check', tone: 'ok' }],
['setup prompts queued', fmt.number(queued), { icon: 'sparkle', tone: 'note' }],
]);
$('account-list').innerHTML = accounts.length
? accounts.map(accountRow).join('')
: ui.empty('No one has signed in to Memby yet. Emby-only accounts are intentionally not listed here.');
});
@@ -1,20 +0,0 @@
<div class="tiles" id="client-tiles"></div>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="tv" data-icon-tone="info">Devices</h2>
<p class="card-note">Every request carries what that build understands. A feature is
only presented to a device that declares its contract, which is what lets an older
set keep working while a new one gets the new behaviour. Status is whether the set is
reporting that list at all; a build old enough to say nothing is served the fallback.</p>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>Device</th><th>Person</th><th>App</th>
<th>Builds seen</th><th>Status</th><th>Last seen</th>
</tr></thead>
<tbody id="client-rows"></tbody>
</table>
</div>
</section>
@@ -1,41 +0,0 @@
const { fmt, ui, $ } = Admin;
// Every build this set has been seen running, newest first, with the one it is on now in
// accent. The current version is already its own column; this is the column that says
// whether a row is one television with a history or one of several rows a single set left
// behind, which is the question duplicates used to make unanswerable.
function versionHistory(client) {
const versions = client.versions || [];
if (!versions.length) return '<span class="muted">—</span>';
return '<span class="list-versions">' + versions.map((entry) =>
ui.chip(entry.version, entry.version === client.version ? 'accent' : null)).join('') +
'</span>';
}
Admin.onStatus((status) => {
const clients = status.clients || [];
const capable = clients.filter((client) =>
(client.capabilities || []).includes('server_features_v1'));
const versions = new Set(clients.map((client) => client.version).filter(Boolean));
$('client-tiles').innerHTML = ui.tiles([
['devices known', fmt.number(clients.length), { icon: 'tv', tone: 'info' }],
['active in the last quarter hour', fmt.number(clients.filter((client) =>
fmt.recent(client.lastSeen)).length), { icon: 'pulse', tone: 'ok' }],
['reporting their capabilities', fmt.number(capable.length), { icon: 'sliders', tone: 'ok' }],
['app builds in service', fmt.number(versions.size), { icon: 'download', tone: 'note' }],
]);
$('client-rows').innerHTML = clients.length ? clients.map((client) => {
const declares = (client.capabilities || []).includes('server_features_v1');
const seen = fmt.presence(client.lastSeen);
return '<tr><td><span class="row tight">' +
'<span class="dot" data-tone="' + seen.tone + '" title="' + seen.label + '"></span>' +
fmt.escape(client.deviceName || 'Memby TV') + '</span></td>' +
'<td>' + fmt.escape(client.username) + '</td>' +
'<td>' + fmt.escape(client.version || 'legacy') + '</td>' +
'<td>' + versionHistory(client) + '</td>' +
'<td>' + ui.tag(declares ? 'reported' : 'missing', declares ? 'ok' : 'warn') + '</td>' +
'<td>' + fmt.escape(fmt.when(client.lastSeen)) + '</td></tr>';
}).join('') : ui.emptyRow(6, 'No devices have signed in yet.');
});
@@ -1,27 +0,0 @@
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="chart" data-icon-tone="info">Launcher rows</h2>
<p class="card-note">Impressions are rows drawn, focuses are rows the D-pad reached,
and dwell is how long it stayed there. Open rate is what a row was worth.</p>
</div>
<label class="field narrow"><span>Window</span>
<select id="engagement-days">
<option value="1">24 hours</option>
<option value="7">7 days</option>
<option value="30" selected>30 days</option>
<option value="90">90 days</option>
</select></label>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>Row</th><th>Kind</th>
<th class="num">Dwell</th><th class="num">Impressions</th>
<th class="num">Focuses</th><th class="num">Opened</th>
<th class="num">Open rate</th><th class="num">Viewers</th>
</tr></thead>
<tbody id="engagement-rows"></tbody>
</table>
</div>
</section>
@@ -1,26 +0,0 @@
const { fmt, ui, $ } = Admin;
const label = (value) => {
const text = String(value || '—').replaceAll('_', ' ');
if (text === 'favorites') return 'Favourites';
if (text === 'abandoned') return 'Abandoned / interrupted';
return text;
};
Admin.onRefresh(async () => {
const payload = await Admin.api('/admin/api/analytics?days=' + $('engagement-days').value);
const rows = payload.rows || [];
$('engagement-rows').innerHTML = rows.length ? rows.map((row) =>
'<tr><td>' + fmt.escape(label(row.rowId)) + '</td>' +
'<td class="muted">' + fmt.escape(label(row.rowKind)) + '</td>' +
'<td class="num">' + fmt.duration(row.dwellMs) + '</td>' +
'<td class="num">' + fmt.number(row.impressions) + '</td>' +
'<td class="num">' + fmt.number(row.focuses) + '</td>' +
'<td class="num">' + fmt.number(row.selects) + '</td>' +
'<td class="num">' + Math.round((row.selectRate || 0) * 100) + '%</td>' +
'<td class="num">' + fmt.number(row.viewers) + '</td></tr>').join('')
: ui.emptyRow(8, 'No events in this window.');
});
Admin.ready(() => $('engagement-days').addEventListener('change', Admin.refresh));
@@ -1,23 +0,0 @@
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="sliders" data-icon-tone="ok">Control plane</h2>
<p class="card-note">Every optional feature has a safe default, an explicit override
and a remote recovery path. Safe mode turns all of them off at once; sign-in,
browsing and playback are never optional.</p>
</div>
<button class="danger" id="feature-safe-mode">Enable safe mode</button>
</div>
<div class="tiles plain" id="feature-health"></div>
</section>
<div class="grid two" id="feature-list"></div>
<section class="card">
<div class="row">
<button class="primary" id="feature-save">Publish changes</button>
<button id="feature-rollback">Roll back one revision</button>
<button id="feature-reset">Clear all overrides</button>
<span id="feature-state"></span>
</div>
</section>
@@ -1,93 +0,0 @@
const { fmt, ui, $ } = Admin;
// The revision the page was last drawn from. Every mutation carries it, so two operators
// working at once cannot silently overwrite each other's publish.
let revision = 0;
function featureCard(feature) {
const mode = feature.source === 'override' ? (feature.enabled ? 'on' : 'off') : 'default';
return '<section class="card"><div class="card-head split"><div>' +
'<h2 class="card-title">' + fmt.escape(feature.name) + '</h2>' +
'<p class="card-note">' + fmt.escape(feature.description) + '</p></div>' +
ui.tag(feature.enabled ? 'active' : 'off', feature.enabled ? 'ok' : 'idle') + '</div>' +
'<label class="field"><span>Mode</span>' +
'<select data-feature-key="' + fmt.escape(feature.key) + '">' +
'<option value="default"' + (mode === 'default' ? ' selected' : '') + '>Safe default</option>' +
'<option value="on"' + (mode === 'on' ? ' selected' : '') + '>Forced on</option>' +
'<option value="off"' + (mode === 'off' ? ' selected' : '') + '>Forced off</option>' +
'</select></label>' +
'<div class="chips"><span class="chip mono">' + fmt.escape(feature.key) + '</span>' +
ui.chip('protocol ' + fmt.number(feature.minimumProtocol) + '+') +
ui.chip(feature.compatible ? 'server compatible' : 'compatibility blocked',
feature.compatible ? 'accent' : 'warn') +
ui.chip(feature.area) + '</div>' +
'<p class="hint">↳ ' + fmt.escape(feature.recovery) + '</p></section>';
}
Admin.onStatus((status) => {
const payload = status.features || {};
const clients = status.clients || [];
revision = Number(payload.revision || 0);
const features = payload.features || [];
const capable = clients.filter((client) =>
(client.capabilities || []).includes('server_features_v1')).length;
$('feature-health').innerHTML = ui.tiles([
['features active', features.filter((feature) => feature.enabled).length + ' / ' + features.length,
{ icon: 'sliders', tone: 'ok' }],
['explicit overrides', features.filter((feature) => feature.source === 'override').length,
{ icon: 'wrench', tone: 'warn' }],
['televisions reporting the control plane', capable + ' / ' + clients.length,
{ icon: 'tv', tone: 'info' }],
['published revision', 'r' + revision, { icon: 'history', tone: 'note' }],
]);
const safe = $('feature-safe-mode');
safe.textContent = payload.safeMode ? 'Leave safe mode' : 'Enable safe mode';
safe.className = payload.safeMode ? '' : 'danger';
$('feature-state').innerHTML = payload.safeMode
? ui.tag('safe mode · optional features off', 'warn')
: ui.tag('live · revision r' + revision, 'ok');
$('feature-rollback').disabled = !payload.canRollback;
// A redraw would take a half-made choice out from under the operator, and the selects are
// read back wholesale when Publish is pressed.
const list = $('feature-list');
if (!Admin.settled(list)) return;
list.innerHTML = features.map(featureCard).join('') ||
ui.empty('No server features are registered.');
});
const featureAction = (action, overrides = {}) => Admin.api('/admin/api/features', {
method: 'POST',
body: JSON.stringify({ action, expectedRevision: revision, overrides }),
});
Admin.ready(() => {
$('feature-save').addEventListener('click', () => {
const overrides = {};
document.querySelectorAll('[data-feature-key]').forEach((select) => {
if (select.value === 'on') overrides[select.dataset.featureKey] = true;
if (select.value === 'off') overrides[select.dataset.featureKey] = false;
});
Admin.act(() => featureAction('save', overrides));
});
$('feature-safe-mode').addEventListener('click', () => {
const leaving = $('feature-safe-mode').textContent.startsWith('Leave');
if (!leaving && !confirm('Disable every optional feature immediately? Core sign-in, ' +
'browsing and playback remain available.')) return;
Admin.act(() => featureAction(leaving ? 'leave-safe-mode' : 'safe-mode'));
});
$('feature-rollback').addEventListener('click', () => {
if (!confirm('Restore the previous published feature revision?')) return;
Admin.act(() => featureAction('rollback'));
});
$('feature-reset').addEventListener('click', () => {
if (!confirm('Clear every override and return all features to their safe software defaults?')) return;
Admin.act(() => featureAction('reset'));
});
});
-30
View File
@@ -1,30 +0,0 @@
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="star" data-icon-tone="note">Pinned titles</h2>
<p class="card-note">Pinned films and series lead the four-card launcher grid in this order. Empty
places are filled by Membys existing mix of recent digital releases, premieres and
highly rated library titles. Pinning changes placement only; labels and reasons remain natural.</p>
</div>
<div id="hero-pins"></div>
<label class="field"><span>Prime-card subtitle</span>
<em>Optional wording under the large first card. Leave blank to use Membys natural release or rating reason.</em>
<input id="hero-prime-subtitle" type="text" maxlength="160"
placeholder="Leave blank for the automatic reason"></label>
<div class="card-foot">
<button class="primary" id="hero-save">Save hero</button>
<button id="hero-clear">Clear pins</button>
</div>
</section>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="search" data-icon-tone="info">Find a title</h2>
<p class="card-note">Search the imported Emby catalogue. Up to four films or series can be pinned.</p>
</div>
<div class="row">
<label class="field"><span>Title</span>
<input id="hero-query" type="search" placeholder="Search films and television shows"></label>
<button id="hero-search">Search</button>
</div>
<div class="grid" id="hero-results"></div>
</section>
-75
View File
@@ -1,75 +0,0 @@
const { fmt, ui, $ } = Admin;
let pins = [];
let pinsDirty = false;
function renderPins() {
$('hero-pins').innerHTML = pins.length
? '<div class="chips">' + pins.map((item, index) =>
'<button class="quiet small" data-remove="' + fmt.escape(item.id) + '">' +
(index + 1) + '. ' + fmt.escape(item.name) + (item.year ? ' (' + item.year + ')' : '') +
' · remove</button>').join('') + '</div>'
: ui.empty('No titles are pinned. The hero is entirely release-aware and automatic.');
for (const button of $('hero-pins').querySelectorAll('[data-remove]')) {
button.addEventListener('click', () => {
pins = pins.filter((item) => item.id !== button.dataset.remove);
pinsDirty = true;
renderPins();
});
}
}
Admin.onStatus((status) => {
if (pinsDirty || !Admin.settled($('hero-pins'))) return;
pins = (status.heroPolicy?.pinnedItems || []).slice(0, 4);
Admin.fill($('hero-prime-subtitle'), status.heroPolicy?.primeSubtitle || '');
renderPins();
});
async function search() {
const query = $('hero-query').value.trim();
if (!query) return;
const payload = await Admin.api('/admin/api/hero/search?q=' + encodeURIComponent(query));
const items = payload.items || [];
$('hero-results').innerHTML = items.length ? items.map((item) =>
'<section class="card"><h2 class="card-title">' + fmt.escape(item.name) + '</h2>' +
'<p class="card-note">' + fmt.escape(item.type || 'Title') + ' · ' +
(item.year || 'Year unknown') + '</p>' +
'<button data-add="' + fmt.escape(item.id) + '">Add to hero</button></section>').join('')
: ui.empty('No playable films or series matched that search.');
for (const button of $('hero-results').querySelectorAll('[data-add]')) {
button.addEventListener('click', () => {
const selected = items.find((item) => item.id === button.dataset.add);
if (!selected || pins.some((item) => item.id === selected.id)) return;
if (pins.length >= 4) {
Admin.error('Remove a pinned film before adding another.');
return;
}
pins.push(selected);
pinsDirty = true;
renderPins();
Admin.error('');
});
}
}
Admin.ready(() => {
$('hero-search').addEventListener('click', () => Admin.act(search));
$('hero-query').addEventListener('keydown', (event) => {
if (event.key === 'Enter') Admin.act(search);
});
$('hero-save').addEventListener('click', () => Admin.act(async () => {
await Admin.api('/admin/api/hero-policy', {
method: 'POST', body: JSON.stringify({
pinnedItemIds: pins.map((item) => item.id),
primeSubtitle: $('hero-prime-subtitle').value.trim(),
}),
});
pinsDirty = false;
}));
$('hero-clear').addEventListener('click', () => {
pins = [];
pinsDirty = true;
renderPins();
});
$('hero-prime-subtitle').addEventListener('input', () => { pinsDirty = true; });
});
@@ -1,16 +0,0 @@
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="sync" data-icon-tone="data">Synchronisation history</h2>
<p class="card-note">A full import mark-and-sweeps the catalogue; an incremental one asks
Emby for what changed, with a minute of overlap so nothing falls between two runs.</p>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>Started</th><th>Kind</th><th>Trigger</th><th>Status</th>
<th class="num">Seen</th><th class="num">Written</th><th class="num">Removed</th><th>Notes</th>
</tr></thead>
<tbody id="import-rows"></tbody>
</table>
</div>
</section>
@@ -1,16 +0,0 @@
const { fmt, ui, $ } = Admin;
Admin.onStatus((status) => {
const runs = status.runs || [];
$('import-rows').innerHTML = runs.length ? runs.map((run) => {
const tone = run.status === 'success' ? 'ok' : run.status === 'running' ? 'warn' : 'bad';
return '<tr><td>' + fmt.escape(fmt.when(run.startedAt)) + '</td>' +
'<td>' + fmt.escape(run.kind) + '</td>' +
'<td>' + fmt.escape(run.trigger) + '</td>' +
'<td>' + ui.tag(run.status, tone) + '</td>' +
'<td class="num">' + fmt.number(run.itemsSeen) + '</td>' +
'<td class="num">' + fmt.number(run.itemsUpserted) + '</td>' +
'<td class="num">' + fmt.number(run.itemsRemoved) + '</td>' +
'<td class="muted">' + fmt.escape(run.error || '') + '</td></tr>';
}).join('') : ui.emptyRow(8, 'Nothing has been imported yet.');
});
@@ -1,39 +0,0 @@
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="search" data-icon-tone="info">Run a pressure test</h2>
<p class="card-note">Nothing is changed by running this. It scores the person's prepared
pool as the launcher would, in the context you choose.</p>
</div>
<div class="fields">
<label class="field"><span>Person</span>
<select id="inspector-user"><option value="">Choose a person…</option></select></label>
<label class="field"><span>Context</span>
<select id="inspector-context">
<option value="default">Default</option>
<option value="bedtime">One episode before bed</option>
<option value="hidden">Hidden library</option>
<option value="new-releases">Recent new releases</option>
</select></label>
<label class="field"><span>Available minutes</span>
<input id="inspector-minutes" type="number" min="0" max="360" value="0"></label>
<label class="field"><span>Evaluate at</span>
<input id="inspector-at" type="datetime-local"></label>
</div>
<div class="row">
<button class="primary" id="inspector-run">Run pressure test</button>
<span class="hint" id="inspector-hint">Choose a person to inspect their recommendations.</span>
</div>
</section>
<div class="tiles" id="inspector-tiles" hidden></div>
<section class="card" id="inspector-profile-card" hidden>
<div class="card-head">
<h2 class="card-title" data-icon="sparkle" data-icon-tone="note">Profile evidence</h2>
<p class="card-note">The strongest learned affinities, and every explicit action this
person has taken.</p>
</div>
<div id="inspector-profile"></div>
</section>
<div class="grid" id="inspector-results"></div>
@@ -1,123 +0,0 @@
const { fmt, ui, $ } = Admin;
// The user list rides along on the ordinary status poll; the inspection itself is only ever
// run on request, because it re-scores a whole pool.
Admin.onStatus((status) => {
const select = $('inspector-user');
if (!Admin.settled(select.parentElement)) return;
const chosen = select.value;
select.innerHTML = '<option value="">Choose a person…</option>' +
(status.requestUsers || []).map((user) => '<option value="' + fmt.escape(user.id) + '">' +
fmt.escape(user.username) + '</option>').join('');
select.value = chosen;
});
function affinities(profile) {
return [
['Genre', profile.genres], ['Studio', profile.studios], ['Actor', profile.actors],
['Director', profile.directors], ['Franchise', profile.franchises],
['Runtime', profile.runtimeRanges], ['Age rating', profile.ageRatings],
['Community rating', profile.communityRatings], ['Release period', profile.releasePeriods],
['Content type', profile.contentTypes],
].flatMap(([dimension, values]) => Object.entries(values || {}).map(([name, value]) => ({
dimension, name, weight: value.weight || 0, evidence: value.evidence || 0,
}))).sort((a, b) => Math.abs(b.weight) - Math.abs(a.weight));
}
function component(name, value) {
return ui.chip(name + '=' + (value >= 0 ? '+' : '') + Number(value).toFixed(3),
value < 0 ? 'bad' : undefined);
}
function resultCard(item, index) {
const explanation = item.explanation || {};
const components = Object.entries(explanation.components || {})
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]));
const exposure = item.exposure || {};
const facts = [item.type, item.year, item.runtimeMinutes ? item.runtimeMinutes + ' min' : null]
.concat(item.genres || []).filter(Boolean).join(' · ');
return '<section class="card"><div class="card-head split"><div>' +
'<h2 class="card-title">#' + (index + 1) + ' · ' + fmt.escape(item.title) + '</h2>' +
'<p class="card-note">' + fmt.escape(facts) + '</p></div>' +
'<span class="score">' + Number(explanation.total || 0).toFixed(3) + '</span></div>' +
'<p class="hint">' + fmt.escape(item.preparedReason || 'No legacy prepared explanation') +
(item.compatibilityLabel ? ' · ' + fmt.escape(item.compatibilityLabel) : '') + '</p>' +
'<div class="chips">' +
(explanation.reasonCodes || []).map((code) => ui.chip(code, 'accent')).join('') +
components.map(([name, value]) => component(name, value)).join('') + '</div>' +
'<details><summary>Pool, row and exposure detail</summary><p class="hint">' +
'Base rank ' + fmt.number(item.baseRank) +
' · base ' + Number(item.baseScore || 0).toFixed(3) +
' · affinity ' + Number(item.affinityScore || 0).toFixed(3) +
' · compatibility ' + Number(item.compatibilityScore || 0).toFixed(3) +
' · impressions ' + fmt.number(exposure.impressions) +
' · focuses ' + fmt.number(exposure.focuses) +
' · selects ' + fmt.number(exposure.selects) + '</p>' +
'<div class="chips">' + (item.eligibleRows || [])
.map((row) => ui.chip(row, 'accent')).join('') + '</div>' +
(item.preparedEvidenceTitle
? '<p class="hint">Prepared evidence: ' + fmt.escape(item.preparedEvidenceTitle) + '</p>'
: '') +
'</details></section>';
}
function render(payload) {
const meta = payload.profileMeta || {};
const tiles = $('inspector-tiles');
tiles.hidden = false;
tiles.innerHTML = ui.tiles([
['prepared pool', fmt.number(payload.poolCandidates), { icon: 'database', tone: 'data' }],
['permission eligible', fmt.number(payload.permissionEligible), { icon: 'shield', tone: 'ok' }],
['ranked result', fmt.number((payload.items || []).length), { icon: 'sparkle', tone: 'note' }],
['source events', fmt.number(meta.sourceEvents), { icon: 'pulse', tone: 'info' }],
['algorithm', meta.algorithmVersion || '—', { small: true, icon: 'chip' }],
['pool built', fmt.when(meta.poolBuiltAt), { small: true, icon: 'clock' }],
]);
const top = affinities(payload.profile || {}).slice(0, 24);
const actions = payload.actions || [];
$('inspector-profile-card').hidden = false;
$('inspector-profile').innerHTML =
'<div class="chips">' + (top.length
? top.map((entry) => ui.chip(entry.dimension + ': ' + entry.name + ' ' +
(entry.weight >= 0 ? '+' : '') + entry.weight.toFixed(3) +
' · n=' + fmt.number(entry.evidence), entry.weight < 0 ? 'bad' : undefined)).join('')
: ui.empty('No repeated affinity evidence yet; cold-start priors apply.')) + '</div>' +
'<div class="chips">' + (actions.length
? actions.map((action) => ui.chip(action.action + ': ' +
(action.title || action.itemId), 'accent')).join('')
: ui.empty('No explicit recommendation actions.')) + '</div>';
const items = payload.items || [];
$('inspector-results').innerHTML = items.length
? items.map(resultCard).join('')
: ui.empty('No candidates survived this context, the explicit exclusions and the permission filter.');
}
async function run() {
const userId = $('inspector-user').value;
if (!userId) {
Admin.error('Choose a person to pressure-test.');
return;
}
const params = new URLSearchParams({
userId,
context: $('inspector-context').value,
minutes: $('inspector-minutes').value || '0',
limit: '100',
});
const at = $('inspector-at').value;
if (at) params.set('at', new Date(at).toISOString());
$('inspector-hint').textContent = 'Running the permission check and the scorer…';
try {
render(await Admin.api('/admin/api/recommendations?' + params.toString()));
$('inspector-hint').textContent = 'Scored at ' + new Date().toLocaleTimeString() + '.';
Admin.error('');
} catch (err) {
$('inspector-hint').textContent = 'Pressure test failed.';
Admin.error(err.message);
}
}
Admin.ready(() => $('inspector-run').addEventListener('click', run));
@@ -1,66 +0,0 @@
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="people" data-icon-tone="info">Journey health</h2>
<p class="card-note">Server-derived foreground visits, completion and interruption.
Search text, content titles and setting values are never stored.</p>
</div>
<div class="row bottom">
<label class="field narrow"><span>Window</span>
<select id="journeys-days">
<option value="1">24 hours</option><option value="7">7 days</option>
<option value="30" selected>30 days</option><option value="90">90 days</option>
</select></label>
<label class="field narrow"><span>User</span>
<select id="journeys-user"><option value="">All users</option></select></label>
</div>
</div>
<div class="tiles" id="journeys-tiles"></div>
<div class="journey-summary-grid">
<div class="journey-summary" id="journeys-completion"></div>
<div class="journey-summary" id="journeys-insight"></div>
</div>
</section>
<div class="journey-report-grid">
<section class="card">
<div class="card-head"><div>
<h2 class="card-title" data-icon="chart" data-icon-tone="info">What people do</h2>
<p class="card-note">Actions show total use and how many separate visits included them.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>Action</th><th class="num">Uses</th><th class="num">Visits</th></tr></thead>
<tbody id="journeys-actions"></tbody>
</table></div>
</section>
<section class="card">
<div class="card-head"><div>
<h2 class="card-title" data-icon="list" data-icon-tone="note">Where people go</h2>
<p class="card-note">The most common steps between screens, including where quiet visits ended.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>Route</th><th class="num">Times</th></tr></thead>
<tbody id="journeys-paths"></tbody>
</table></div>
</section>
</div>
<section class="card">
<div class="card-head"><div>
<h2 class="card-title" data-icon="pulse" data-icon-tone="data">Feature use</h2>
<p class="card-note">Rare and unused features are shown against Memby's major feature catalogue.</p>
</div></div>
<div class="table-wrap"><table>
<thead><tr><th>Feature</th><th class="num">Uses</th><th>Last used</th><th>Status</th></tr></thead>
<tbody id="journeys-features"></tbody>
</table></div>
</section>
<section class="card" id="journeys-events-card" hidden>
<div class="card-head"><div>
<h2 class="card-title" data-icon="clock" data-icon-tone="info">Individual visits</h2>
<p class="card-note">Each card is one visit, read from its first step to its last.</p>
</div></div>
<div class="journey-visits" id="journeys-events"></div>
</section>
-111
View File
@@ -1,111 +0,0 @@
const { fmt, ui, $ } = Admin;
const featureCatalogue = [
'home', 'movies', 'shows', 'favorites', 'search', 'recent_searches',
'genre_browse', 'for_you', 'for_you_time', 'recommendation', 'continue',
'latest', 'my_shows', 'details', 'playback', 'notifications', 'profiles', 'settings',
];
const label = (value) => {
const text = String(value || '—').replaceAll('_', ' ');
if (text === 'favorites') return 'Favourites';
if (text === 'abandoned') return 'Abandoned / interrupted';
return text;
};
function renderEvents(events) {
const grouped = new Map();
events.forEach((event) => {
if (!grouped.has(event.journeyId)) grouped.set(event.journeyId, []);
grouped.get(event.journeyId).push(event);
});
const visits = [];
grouped.forEach((steps) => {
steps.sort((a, b) => a.sequence - b.sequence);
const first = steps[0];
const completed = steps.some((event) => event.action === 'journey_end');
const actions = steps.filter((event) => !['journey_start', 'journey_end'].includes(event.action));
const timeline = actions.map((event) => {
const path = event.source && event.target ? label(event.source) + ' → ' + label(event.target)
: label(event.target || event.screen);
const content = event.itemName ? label(event.itemType) + ' · ' + event.itemName : '—';
return '<li><span class="journey-step-dot"></span><div><b>' + fmt.escape(label(event.action)) +
'</b><span>' + fmt.escape(path) + (content === '—' ? '' : ' · ' + fmt.escape(content)) +
'</span></div><time>' + fmt.when(event.occurredAt) + '</time></li>';
}).join('');
visits.push('<article class="journey-visit"><header><div><b>' + fmt.when(first.occurredAt) +
'</b><span>' + actions.length + ' significant step' + (actions.length === 1 ? '' : 's') +
'</span></div>' + ui.tag(completed ? 'completed' : 'unfinished', completed ? 'ok' : 'warn') +
'</header><ol>' + (timeline || '<li class="muted">No significant actions recorded.</li>') +
'</ol></article>');
});
$('journeys-events').innerHTML = visits.length ? visits.join('') : '<p class="empty">No visits in this window.</p>';
}
Admin.onRefresh(async () => {
const selected = $('journeys-user').value;
const payload = await Admin.api('/admin/api/journeys?days=' + $('journeys-days').value +
(selected ? '&userId=' + encodeURIComponent(selected) : ''));
const users = payload.users || [];
const existing = $('journeys-user').value;
$('journeys-user').innerHTML = '<option value="">All users</option>' + users.map((user) =>
'<option value="' + fmt.escape(user.userId) + '">' + fmt.escape(user.username || user.userId) + '</option>').join('');
if (users.some((user) => user.userId === existing)) $('journeys-user').value = existing;
const stats = payload.stats || {};
$('journeys-tiles').innerHTML = ui.tiles([
['journeys', fmt.number(stats.journeys || 0), { icon: 'list', tone: 'data' }],
['viewers', fmt.number(stats.viewers || 0), { icon: 'people', tone: 'info' }],
['completion', Math.round((stats.completionRate || 0) * 100) + '%', { icon: 'check', tone: 'ok' }],
['abandoned', fmt.number(stats.abandoned || 0), { icon: 'alert', tone: 'note' }],
['active now', fmt.number(stats.active || 0), { icon: 'pulse', tone: 'info' }],
['average steps', Number(stats.averageSteps || 0).toFixed(1), { icon: 'chart' }],
['average visit', fmt.duration(stats.averageTimeMs || 0), { icon: 'clock', small: true }],
['history kept', payload.retentionDays + ' days', { icon: 'clock', small: true }],
]);
const completion = Math.round((stats.completionRate || 0) * 100);
$('journeys-completion').innerHTML = '<div class="journey-summary-head"><b>Visit completion</b><strong>' +
completion + '%</strong></div><div class="journey-meter"><span style="width:' + completion +
'%"></span></div><p>' + fmt.number(stats.completed || 0) + ' completed · ' +
fmt.number(stats.abandoned || 0) + ' abandoned · ' + fmt.number(stats.active || 0) + ' active</p>';
const used = new Map((payload.features || []).map((feature) => [feature.feature, feature]));
const cataloguePosition = new Map(featureCatalogue.map((name, index) => [name, index]));
const features = [...new Set([...featureCatalogue, ...used.keys()])].sort((left, right) => {
const useDifference = (used.get(right)?.uses || 0) - (used.get(left)?.uses || 0);
if (useDifference) return useDifference;
return (cataloguePosition.get(left) ?? Number.MAX_SAFE_INTEGER) -
(cataloguePosition.get(right) ?? Number.MAX_SAFE_INTEGER);
});
$('journeys-features').innerHTML = features.map((name) => {
const stat = used.get(name);
const uses = stat?.uses || 0;
const status = uses === 0 ? ui.tag('not used', 'warn') : uses < 3 ? ui.tag('rare', 'note') : ui.tag('used', 'ok');
return '<tr><td>' + fmt.escape(label(name)) + '</td><td class="num">' + fmt.number(uses) +
'</td><td class="muted">' + (stat ? fmt.when(stat.lastUsedAt) : '—') + '</td><td>' + status + '</td></tr>';
}).join('');
const actions = payload.actions || [];
$('journeys-actions').innerHTML = actions.length ? actions.map((action) =>
'<tr><td><b>' + fmt.escape(label(action.action)) + '</b><span class="table-sub">' +
fmt.escape(label(action.category)) + '</span></td><td class="num">' + fmt.number(action.events) +
'</td><td class="num">' + fmt.number(action.journeys) + '</td></tr>').join('')
: ui.emptyRow(3, 'No significant actions in this window.');
const paths = payload.paths || [];
$('journeys-paths').innerHTML = paths.length ? paths.map((path) =>
'<tr><td>' + fmt.escape(label(path.from)) + ' <span class="route-arrow">→</span> ' +
fmt.escape(label(path.to)) + '</td><td class="num">' + fmt.number(path.count) + '</td></tr>').join('')
: ui.emptyRow(2, 'No repeated paths in this window.');
const topPath = paths[0];
$('journeys-insight').innerHTML = '<b>Most common route</b><strong>' +
(topPath ? fmt.escape(label(topPath.from)) + ' → ' + fmt.escape(label(topPath.to)) : 'Not enough data') +
'</strong><p>' + (topPath ? fmt.number(topPath.count) + ' times in this window' :
'Journeys will appear here as viewers move through Memby.') + '</p>';
$('journeys-events-card').hidden = !selected;
if (selected) renderEvents(payload.events || []);
});
Admin.ready(() => $('journeys-days').addEventListener('change', Admin.refresh));
Admin.ready(() => $('journeys-user').addEventListener('change', Admin.refresh));
@@ -1,15 +0,0 @@
<div class="tiles" id="library-tiles"></div>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="library" data-icon-tone="data">Import the catalogue</h2>
<p class="card-note">Emby's catalogue is copied here so search and the recommendation
candidate pool can be answered from one indexed table. Watched, favourite and resume
state is deliberately not stored — that is per person and still comes from Emby live.</p>
</div>
<div class="row">
<button class="primary" id="sync-incremental">Sync new items</button>
<button id="sync-full">Full re-import</button>
<span class="hint" id="sync-hint"></span>
</div>
</section>
@@ -1,29 +0,0 @@
const { fmt, ui, $ } = Admin;
Admin.onStatus((status) => {
const byType = status.library.byType || {};
$('library-tiles').innerHTML = ui.tiles([
['items', fmt.number(status.library.total), { icon: 'library', tone: 'data' }],
...Object.keys(byType).sort().map((type) => [type, fmt.number(byType[type]), { icon: 'list' }]),
['last import', fmt.when(status.library.lastSynced), { small: true, icon: 'clock' }],
]);
const running = status.syncRunning;
$('sync-incremental').disabled = running;
$('sync-full').disabled = running;
$('sync-hint').textContent = running
? 'Import running…'
: 'An incremental import runs automatically every ' + status.syncEvery + '.';
});
const sync = (kind) => Admin.api('/admin/api/sync', {
method: 'POST', body: JSON.stringify({ kind }),
});
Admin.ready(() => {
$('sync-incremental').addEventListener('click', () => Admin.act(() => sync('incremental')));
$('sync-full').addEventListener('click', () => {
if (!confirm('Re-import the entire library? This can take several minutes.')) return;
Admin.act(() => sync('full'));
});
});
-21
View File
@@ -1,21 +0,0 @@
<section class="card">
<div class="row bottom">
<label class="field narrow"><span>Level</span>
<select id="log-level" aria-label="Minimum event level">
<option value="DEBUG">Debug and above</option>
<option value="INFO" selected>Info and above</option>
<option value="WARN">Warnings and errors</option>
<option value="ERROR">Errors only</option>
</select></label>
<label class="field grow"><span>Filter</span>
<input type="search" id="log-search"
placeholder="Person, television, title, component, path…"></label>
<button id="log-pause">Pause</button>
<button id="log-clear">Clear view</button>
<button id="log-export">Export JSON</button>
</div>
<div id="log" class="log" role="log" aria-live="polite">
<div class="log-empty">Waiting for server events…</div>
</div>
<p class="hint" id="log-stats">Connecting…</p>
</section>
-106
View File
@@ -1,106 +0,0 @@
const { fmt, $ } = Admin;
const state = { cursor: 0, records: [], dropped: 0, paused: false, fetching: false };
const ranks = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
// The same order the server's own console lines use — who and where first, the reason for
// the line last — so a log read here and a log read over SSH look alike. `version` is
// dropped: it is the same on every line and is reported once in the rail instead.
const fieldOrder = [
'component', 'user', 'device', 'client', 'protocol',
'method', 'path', 'status', 'duration',
];
function orderedFields(attributes) {
const rank = (key) => {
const at = fieldOrder.indexOf(key);
if (at >= 0) return at;
return key === 'error' ? 1000 : 100;
};
return Object.entries(attributes)
.filter(([key]) => key !== 'version')
.sort((a, b) => rank(a[0]) - rank(b[0]));
}
const text = (event) =>
[event.message, ...Object.entries(event.attributes || {}).flat()].join(' ').toLowerCase();
function render() {
const minimum = ranks[$('log-level').value] || 20;
const search = $('log-search').value.trim().toLowerCase();
const filtered = state.records.filter((event) =>
(ranks[event.level] || 0) >= minimum && (!search || text(event).includes(search)));
// Rendering every retained record at once can freeze a browser during an incident. Keep
// all of them available for filtering and export, but draw only the visible tail.
const visible = filtered.slice(-2500);
const log = $('log');
const pinned = log.scrollHeight - log.scrollTop - log.clientHeight < 50;
log.innerHTML = visible.length ? visible.map((event) => {
const attrs = orderedFields(event.attributes || {})
.map(([key, value]) => fmt.escape(key) + '=' + fmt.escape(value)).join(' ');
// The level is a class on the line as well as on its own column: scrolling a log is
// looking for the one line that is not INFO, and a coloured word four columns in is
// easy to scroll past.
return '<div class="log-line ' + fmt.escape(event.level) + '">' +
'<span class="log-time">' + fmt.escape(fmt.when(event.occurredAt)) + '</span>' +
'<span class="log-level ' + fmt.escape(event.level) + '">' + fmt.escape(event.level) + '</span>' +
'<span>' + fmt.escape(event.message) + '</span>' +
'<span class="log-attrs">' + attrs + '</span></div>';
}).join('') : '<div class="log-empty">No events match this filter.</div>';
if (pinned) log.scrollTop = log.scrollHeight;
$('log-stats').textContent =
fmt.number(state.records.length) + ' retained · ' + fmt.number(filtered.length) + ' matching' +
(visible.length < filtered.length ? ' · showing the latest ' + fmt.number(visible.length) : '') +
(state.dropped ? ' · ' + fmt.number(state.dropped) + ' overwritten before delivery' : '');
}
// The ring buffer is drained in pages until it is caught up, so a console opened after an
// incident sees what happened rather than only what happens next.
Admin.onRefresh(async () => {
if (state.paused || state.fetching) return;
state.fetching = true;
try {
let pages = 0;
let page;
do {
page = await Admin.api('/admin/api/events?after=' + state.cursor + '&limit=1000');
state.cursor = page.next || state.cursor;
state.dropped += page.dropped || 0;
if ((page.events || []).length) {
state.records.push(...page.events);
if (state.records.length > 20000) {
state.records.splice(0, state.records.length - 20000);
}
}
pages += 1;
} while (page.hasMore && pages < 20 && !state.paused);
render();
} finally {
state.fetching = false;
}
});
Admin.ready(() => {
$('log-pause').addEventListener('click', () => {
state.paused = !state.paused;
$('log-pause').textContent = state.paused ? 'Resume' : 'Pause';
if (!state.paused) Admin.refresh();
});
$('log-clear').addEventListener('click', () => {
state.records = [];
state.dropped = 0;
render();
});
$('log-export').addEventListener('click', () => {
const blob = new Blob([JSON.stringify(state.records, null, 2)], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'memby-events-' + new Date().toISOString().replace(/[:.]/g, '-') + '.json';
link.click();
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
});
$('log-level').addEventListener('change', render);
$('log-search').addEventListener('input', render);
});
@@ -1,19 +0,0 @@
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="power" data-icon-tone="warn">Gateway availability</h2>
<p class="card-note">Takes Memby offline for every television, independently of Emby.
Sign-in and all content calls answer 503 with the message below, and the television
shows it in place of the launcher rows. This console keeps working.</p>
</div>
<span id="maintenance-state"></span>
</div>
<label class="field"><span>Message shown on the television</span>
<em>Say what is happening and when it will be back. It is the only thing the viewer is
told.</em>
<input type="text" id="maintenance-message" placeholder="Back shortly — upgrading the server"></label>
<div class="card-foot">
<button class="danger" id="maintenance-on">Go offline</button>
<button id="maintenance-off">Bring back online</button>
</div>
</section>
@@ -1,21 +0,0 @@
const { ui, $ } = Admin;
Admin.onStatus((status) => {
const maintenance = status.maintenance || {};
$('maintenance-state').innerHTML = maintenance.enabled
? ui.tag('offline', 'bad') : ui.tag('online', 'ok');
Admin.fill($('maintenance-message'), maintenance.message || '');
});
const set = (enabled) => Admin.api('/admin/api/maintenance', {
method: 'POST',
body: JSON.stringify({ enabled, message: $('maintenance-message').value }),
});
Admin.ready(() => {
$('maintenance-on').addEventListener('click', () => {
if (!confirm('Take Memby offline for every television?')) return;
Admin.act(() => set(true));
});
$('maintenance-off').addEventListener('click', () => Admin.act(() => set(false)));
});
@@ -1,46 +0,0 @@
<div class="tiles" id="overview-tiles"></div>
<div class="grid two">
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="tv" data-icon-tone="info">What televisions are being told</h2>
<p class="card-note">The answers the gateway is giving every set right now.</p>
</div>
<div class="kv" id="overview-state"></div>
</section>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="wrench" data-icon-tone="note">Integrations</h2>
<p class="card-note">The services this gateway leans on, and whether they answered.</p>
</div>
<div class="kv" id="overview-services"></div>
</section>
</div>
<div class="grid two">
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="sync" data-icon-tone="data">Latest imports</h2>
<p class="card-note">The last few catalogue synchronisations.</p>
</div>
<a class="crumb" href="/admin/imports">All imports</a>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Started</th><th>Kind</th><th>Status</th><th class="num">Written</th></tr></thead>
<tbody id="overview-runs"></tbody>
</table>
</div>
</section>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="chip" data-icon-tone="info">Process</h2>
<p class="card-note">The container this console is served from.</p>
</div>
<div class="tiles plain" id="overview-runtime"></div>
<p class="hint" id="overview-memory">Reading process statistics…</p>
</section>
</div>
@@ -1,91 +0,0 @@
/* The page an operator lands on. It answers one question is anything wrong and hands
off to the page that can do something about it. Nothing here is editable on purpose:
somewhere that both summarises and changes state is where an accidental click lives. */
const { fmt, ui, $ } = Admin;
function row(label, value) {
return '<div class="kv-row"><span>' + fmt.escape(label) + '</span><span>' + value + '</span></div>';
}
Admin.onStatus((status) => {
const features = status.features || {};
const featureList = features.features || [];
const clients = status.clients || [];
const online = clients.filter((client) => fmt.recent(client.lastSeen)).length;
// The marks are the areas these numbers belong to, in the tones the rest of the console
// uses for them: the library is teal wherever it is counted, a person is violet, a
// television is blue. They are the same on the pages these tiles link through to.
$('overview-tiles').innerHTML = ui.tiles([
['items in the library', fmt.number(status.library.total), { icon: 'library', tone: 'data' }],
['people signed in', fmt.number((status.requestUsers || []).length),
{ icon: 'people', tone: 'note' }],
['devices · ' + online + ' active now', fmt.number(clients.length),
{ icon: 'tv', tone: 'info' }],
['optional features on', featureList.filter((feature) => feature.enabled).length +
' / ' + featureList.length, { icon: 'sliders', tone: 'ok' }],
['last import', fmt.when(status.library.lastSynced), { small: true, icon: 'clock' }],
]);
const maintenance = status.maintenance || {};
const policy = status.updatePolicy || {};
const required = Boolean(policy.minimumVersion) && policy.minimumVersion === policy.latestVersion;
const playback = status.playbackPolicy || {};
$('overview-state').innerHTML =
row('Availability', maintenance.enabled
? ui.tag('offline for maintenance', 'bad')
: ui.tag('online', 'ok')) +
row('Feature control plane', features.safeMode
? ui.tag('safe mode · optional features off', 'warn')
: ui.tag('revision r' + fmt.number(features.revision || 0), 'ok')) +
row('App update prompt', !policy.enabled
? ui.tag('off', 'idle')
: ui.tag((required ? 'required · ' : 'optional · ') + policy.latestVersion,
required ? 'warn' : 'ok')) +
row('Catalogue import', status.syncRunning
? ui.tag('running', 'warn')
: ui.tag('every ' + status.syncEvery, 'idle')) +
row('Playback preroll', playback.prerollEnabled === false
? ui.tag('off', 'idle')
: ui.tag(((playback.prerollDurationMs || 6500) / 1000) + 's', 'ok'));
const mdblist = status.mdblist || {};
const forYou = status.forYou || {};
$('overview-services').innerHTML =
row('Movies', status.radarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) +
row('Series', status.sonarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) +
row('MDBList ratings', mdblist.enabled
? ui.tag(fmt.number(mdblist.cachedTitles) + ' titles stored', 'ok')
: ui.tag(mdblist.apiKeyConfigured ? 'off · key saved' : 'off · no key', 'idle')) +
row('For You pools', status.forYouRunning
? ui.tag('rebuilding', 'warn')
: ui.tag(fmt.number(forYou.candidates) + ' ranked candidates', 'idle')) +
row('Recommendation profiles', '<span class="code">' + fmt.number(forYou.profiles) + '</span>');
const runs = (status.runs || []).slice(0, 5);
$('overview-runs').innerHTML = runs.length ? runs.map((run) => {
const tone = run.status === 'success' ? 'ok' : run.status === 'running' ? 'warn' : 'bad';
return '<tr><td>' + fmt.escape(fmt.when(run.startedAt)) + '</td>' +
'<td>' + fmt.escape(run.kind) + '</td>' +
'<td>' + ui.tag(run.status, tone) + '</td>' +
'<td class="num">' + fmt.number(run.itemsUpserted) + '</td></tr>';
}).join('') : ui.emptyRow(4, 'No imports have run yet.');
});
// Process statistics are their own endpoint and their own tick: they are the one thing here
// that says nothing about the household and everything about the container.
Admin.onRefresh(async () => {
const runtime = await Admin.api('/admin/api/runtime');
$('overview-runtime').innerHTML = ui.tiles([
['goroutines', fmt.number(runtime.goroutines), { icon: 'pulse', tone: 'info' }],
['heap in use', fmt.bytes(runtime.heapInuse), { icon: 'chip', tone: 'info' }],
['reserved from the OS', fmt.bytes(runtime.sys), { icon: 'chip', tone: 'info' }],
['collections', fmt.number(runtime.numGc), { icon: 'sync', tone: 'info' }],
]);
const limit = runtime.memoryLimit > 0 && runtime.memoryLimit < Number.MAX_SAFE_INTEGER
? fmt.bytes(runtime.memoryLimit) + (runtime.configuredLimit ? ' (GOMEMLIMIT)' : '')
: 'no limit set';
$('overview-memory').textContent = 'Next collection at ' + fmt.bytes(runtime.nextGc) +
' · memory limit ' + limit + ' · ' + runtime.gomaxprocs + ' processors available.';
});
@@ -1,20 +0,0 @@
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="play" data-icon-tone="info">Upcoming-show preroll</h2>
<p class="card-note">Sent with every playback launch. A change applies to the next
title opened on every gateway-connected television; no app release is required.</p>
</div>
<span id="playback-state"></span>
</div>
<label class="check">
<input type="checkbox" id="preroll-enabled">
<span>Show the preroll before a title starts</span>
</label>
<label class="field narrow"><span>Duration</span>
<em>Between 1 and 30 seconds. The stream is already playing behind it.</em>
<input type="number" id="preroll-duration" min="1" max="30" step="0.5" value="6.5"></label>
<div class="card-foot">
<button class="primary" id="playback-save">Save playback policy</button>
</div>
</section>
@@ -1,27 +0,0 @@
const { ui, $ } = Admin;
Admin.onStatus((status) => {
const playback = status.playbackPolicy || {};
Admin.check($('preroll-enabled'), playback.prerollEnabled !== false);
Admin.fill($('preroll-duration'), ((playback.prerollDurationMs || 6500) / 1000).toString());
$('playback-state').innerHTML = $('preroll-enabled').checked
? ui.tag('on · ' + $('preroll-duration').value + 's', 'ok')
: ui.tag('off', 'idle');
});
Admin.ready(() => {
$('playback-save').addEventListener('click', () => {
const seconds = Number($('preroll-duration').value);
if (!Number.isFinite(seconds) || seconds < 1 || seconds > 30) {
Admin.error('The preroll duration must be between 1 and 30 seconds.');
return;
}
Admin.act(() => Admin.api('/admin/api/playback-policy', {
method: 'POST',
body: JSON.stringify({
prerollEnabled: $('preroll-enabled').checked,
prerollDurationMs: Math.round(seconds * 1000),
}),
}));
});
});
@@ -1,45 +0,0 @@
<div class="tiles" id="ratings-tiles"></div>
<div class="grid two">
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="star" data-icon-tone="note">MDBList connection</h2>
<p class="card-note">The key stays on this server and a failure never blocks a
television. Every rating fetched is stored here permanently and re-checked about
once a month, so browsing the library costs nothing after the first look at a
title.</p>
</div>
<span id="mdblist-state"></span>
</div>
<label class="check">
<input type="checkbox" id="mdblist-enabled">
<span>Show external ratings on televisions<em>Off leaves the stored ratings in place.</em></span>
</label>
<label class="field"><span>API key</span>
<em>Leave blank to keep the key that is already saved.</em>
<input type="password" id="mdblist-api-key" autocomplete="new-password"
placeholder="Paste an API key"></label>
<label class="check">
<input type="checkbox" id="mdblist-clear-key"><span>Remove the saved key</span>
</label>
</section>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="list" data-icon-tone="data">Sources shown on televisions</h2>
<p class="card-note">A title with none of these has no ratings strip at all, which is
the honest answer — nothing stands in for a score that was never fetched.</p>
</div>
<div class="checks columns" id="mdblist-sources">
<p class="empty">Loading sources…</p>
</div>
</section>
</div>
<section class="card">
<div class="row">
<button class="primary" id="mdblist-save">Save ratings settings</button>
<span class="hint" id="mdblist-cache"></span>
</div>
</section>
@@ -1,65 +0,0 @@
const { fmt, ui, $ } = Admin;
// Display names for the providers MDBList answers with. An unknown key is shown as itself
// rather than hidden — a source the server offers and the console cannot name is still a
// source the operator may want on.
const sourceNames = {
imdb: 'IMDb', tomatoes: 'Rotten Tomatoes', audience: 'Rotten Tomatoes Audience',
metacritic: 'Metacritic', letterboxd: 'Letterboxd', rogerebert: 'Roger Ebert',
tmdb: 'TMDb', trakt: 'Trakt', mal: 'MyAnimeList', anilist: 'AniList',
anidb: 'AniDB', kitsu: 'Kitsu', score: 'MDBList Score', score_average: 'MDBList Average',
};
Admin.onStatus((status) => {
const mdblist = status.mdblist || {};
const cached = Number(mdblist.cachedTitles || 0);
$('ratings-tiles').innerHTML = ui.tiles([
['titles stored', fmt.number(cached), { icon: 'database', tone: 'data' }],
['due to be re-checked', fmt.number(mdblist.staleTitles), { icon: 'sync', tone: 'warn' }],
['sources shown', fmt.number((mdblist.sources || []).length), { icon: 'star', tone: 'note' }],
['API key', mdblist.apiKeyConfigured ? 'saved' : 'not set',
{ small: true, icon: 'key', tone: mdblist.apiKeyConfigured ? 'ok' : undefined }],
]);
Admin.check($('mdblist-enabled'), mdblist.enabled);
$('mdblist-state').innerHTML = mdblist.enabled
? ui.tag('on · ' + (mdblist.sources || []).length + ' sources', 'ok')
: ui.tag(mdblist.apiKeyConfigured ? 'off · key saved' : 'off · no key', 'idle');
$('mdblist-cache').textContent = cached
? 'Ratings are fetched as televisions browse, never on the request path.'
: 'No ratings stored yet. They are saved as televisions browse the library.';
const keyField = $('mdblist-api-key');
keyField.placeholder = mdblist.apiKeyConfigured
? 'Saved key (leave blank to keep)' : 'Paste an API key';
const sources = $('mdblist-sources');
if (!Admin.settled(sources)) return;
const selected = new Set(mdblist.sources || []);
sources.innerHTML = (mdblist.availableSources || []).map((source) =>
'<label class="check"><input type="checkbox" data-mdblist-source="' + fmt.escape(source) + '"' +
(selected.has(source) ? ' checked' : '') + '><span>' +
fmt.escape(sourceNames[source] || source) + '</span></label>').join('') ||
ui.empty('No rating sources are available.');
});
Admin.ready(() => {
$('mdblist-save').addEventListener('click', () => {
const sources = [...document.querySelectorAll('[data-mdblist-source]:checked')]
.map((box) => box.dataset.mdblistSource);
Admin.act(() => Admin.api('/admin/api/mdblist-settings', {
method: 'POST',
body: JSON.stringify({
enabled: $('mdblist-enabled').checked,
apiKey: $('mdblist-api-key').value.trim(),
clearApiKey: $('mdblist-clear-key').checked,
sources,
}),
}).then(() => {
$('mdblist-api-key').value = '';
$('mdblist-clear-key').checked = false;
}));
});
});
@@ -1,28 +0,0 @@
<div class="tiles" id="for-you-tiles"></div>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="sparkle" data-icon-tone="note">Pool maintenance</h2>
<p class="card-note">Prepared pools refresh in the background; these are the manual
versions of the same work. A rebuild is safe at any time — televisions read the last
finished pool until a new one lands.</p>
</div>
<div class="row">
<button class="primary" id="for-you-import">Import recent sessions</button>
<button id="for-you-full">Full Tracearr backfill</button>
<button id="for-you-rebuild">Rebuild all pools</button>
<span class="hint" id="for-you-hint"></span>
</div>
</section>
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="search" data-icon-tone="info">Reading a person's scores</h2>
<p class="card-note">The inspector re-runs the shared weighted scorer over one
person's prepared pool, after Emby permission and parental-control filtering, and
shows every component and evidence reason behind the order.</p>
</div>
<a class="crumb" href="/admin/inspector">Open the inspector</a>
</div>
</section>
@@ -1,33 +0,0 @@
const { fmt, ui, $ } = Admin;
Admin.onStatus((status) => {
const forYou = status.forYou || {};
$('for-you-tiles').innerHTML = ui.tiles([
['Tracearr sessions', fmt.number(forYou.tracearrSessions), { icon: 'play', tone: 'info' }],
['user profiles', fmt.number(forYou.profiles), { icon: 'people', tone: 'note' }],
['ranked candidates', fmt.number(forYou.candidates), { icon: 'sparkle', tone: 'note' }],
['last full import', fmt.when(forYou.lastFullImport), { small: true, icon: 'clock' }],
]);
const running = Boolean(status.forYouRunning);
['for-you-import', 'for-you-full', 'for-you-rebuild'].forEach((id) => {
$(id).disabled = running;
});
$('for-you-hint').textContent = running
? 'For You maintenance running…'
: 'Prepared pools normally refresh in the background.';
});
const action = (name) => Admin.api('/admin/api/for-you', {
method: 'POST', body: JSON.stringify({ action: name }),
});
Admin.ready(() => {
$('for-you-import').addEventListener('click', () =>
Admin.act(() => action('incremental-import')));
$('for-you-full').addEventListener('click', () => {
if (!confirm('Backfill all Tracearr history and rebuild every active user pool?')) return;
Admin.act(() => action('full-import'));
});
$('for-you-rebuild').addEventListener('click', () => Admin.act(() => action('rebuild-all')));
});
@@ -1,24 +0,0 @@
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="inbox" data-icon-tone="info">Where a request goes</h2>
<p class="card-note">A movie or series is monitored and searched for immediately.
The configured download service handles it from there.</p>
</div>
<span class="row tight" id="request-services"></span>
</div>
</section>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="people" data-icon-tone="note">Who sees “Request it”</h2>
<p class="card-note">The button appears at the end of a library search that found
nothing. Everyone else simply sees an empty result.</p>
</div>
<div class="checks columns" id="request-users">
<p class="empty">Loading people…</p>
</div>
<div class="card-foot">
<button class="primary" id="request-save">Save access</button>
</div>
</section>
@@ -1,29 +0,0 @@
const { fmt, ui, $ } = Admin;
Admin.onStatus((status) => {
$('request-services').innerHTML =
ui.tag('Movies ' + (status.radarrReady ? 'ready' : 'not configured'),
status.radarrReady ? 'ok' : 'bad') +
ui.tag('Series ' + (status.sonarrReady ? 'ready' : 'not configured'),
status.sonarrReady ? 'ok' : 'bad');
const box = $('request-users');
if (!Admin.settled(box)) return;
const allowed = new Set((status.requestPolicy || {}).allowedUserIds || []);
box.innerHTML = (status.requestUsers || []).length
? status.requestUsers.map((user) =>
'<label class="check"><input type="checkbox" data-request-user="' + fmt.escape(user.id) + '"' +
(allowed.has(user.id) ? ' checked' : '') + '><span>' + fmt.escape(user.username) +
'<em>last seen ' + fmt.escape(fmt.when(user.lastSeen)) + '</em></span></label>').join('')
: ui.empty('No one has signed in yet.');
});
Admin.ready(() => {
$('request-save').addEventListener('click', () => {
const allowedUserIds = [...document.querySelectorAll('[data-request-user]:checked')]
.map((box) => box.dataset.requestUser);
Admin.act(() => Admin.api('/admin/api/request-policy', {
method: 'POST', body: JSON.stringify({ allowedUserIds }),
}));
});
});
@@ -1,47 +0,0 @@
<div class="tiles" id="searches-tiles"></div>
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="search" data-icon-tone="info">What the house looks for</h2>
<p class="card-note">Queries the search tab ran, grouped without regard to case and
labelled with the most recent spelling. Instant search asks from the second
character, so a title typed slowly leaves its prefixes here too.</p>
</div>
<label class="field narrow"><span>Window</span>
<select id="searches-days">
<option value="1">24 hours</option>
<option value="7" selected>7 days</option>
<option value="30">30 days</option>
</select></label>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>Query</th>
<th class="num">Searches</th>
<th class="num">Viewers</th>
<th>Last searched</th>
</tr></thead>
<tbody id="searches-terms"></tbody>
</table>
</div>
</section>
<section class="card">
<div class="card-head">
<h2 class="card-title" data-icon="history" data-icon-tone="note">As it happened</h2>
<p class="card-note">The log, newest first — the query exactly as it was typed, and who
typed it. This is the one to read when somebody says search is not finding something.</p>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>When</th>
<th>Viewer</th>
<th>Query</th>
</tr></thead>
<tbody id="searches-recent"></tbody>
</table>
</div>
</section>
@@ -1,37 +0,0 @@
const { fmt, ui, $ } = Admin;
Admin.onRefresh(async () => {
const payload = await Admin.api('/admin/api/searches?days=' + $('searches-days').value);
const terms = payload.terms || [];
const recent = payload.recent || [];
const totals = payload.totals || {};
$('searches-tiles').innerHTML = ui.tiles([
['searches', fmt.number(totals.searches), { icon: 'search', tone: 'info' }],
['distinct queries', fmt.number(totals.queries), { icon: 'list', tone: 'data' }],
['viewers searching', fmt.number(totals.viewers), { icon: 'people', tone: 'note' }],
// Stated rather than assumed: every figure on this page is bounded by how long the
// table keeps a row, and an operator reading a quiet week has no other way to tell a
// household that stopped searching from one whose history has aged out.
['history kept', payload.retentionDays + ' days', { small: true, icon: 'clock' }],
]);
$('searches-terms').innerHTML = terms.length ? terms.map((term) =>
'<tr><td>' + fmt.escape(term.query) + '</td>' +
'<td class="num">' + fmt.number(term.searches) + '</td>' +
'<td class="num">' + fmt.number(term.viewers) + '</td>' +
'<td class="muted">' + fmt.when(term.lastAt) + '</td></tr>').join('')
: ui.emptyRow(4, 'Nothing searched in this window.');
// An unattributed search keeps its row and shows the id: the query is the point, and a
// viewer whose sessions have all expired is still one searcher rather than nobody.
$('searches-recent').innerHTML = recent.length ? recent.map((event) =>
'<tr><td class="muted">' + fmt.when(event.occurredAt) + '</td>' +
'<td>' + (event.username
? fmt.escape(event.username)
: ui.tag(event.userId || 'unknown', 'warn')) + '</td>' +
'<td>' + fmt.escape(event.query) + '</td></tr>').join('')
: ui.emptyRow(3, 'No searches in this window.');
});
Admin.ready(() => $('searches-days').addEventListener('change', Admin.refresh));
@@ -1,34 +0,0 @@
<a class="crumb" href="/admin/accounts" id="history-crumb">← Back to the user</a>
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="tv" data-icon-tone="info">Where each device has got to</h2>
<p class="card-note">A set takes a change by fetching it, which it does within a few
seconds of being told — so anything still behind is switched off, mid-film, or
cannot reach the gateway.</p>
</div>
<span id="history-current"></span>
</div>
<div class="list" id="history-devices"></div>
</section>
<section class="card flush">
<div class="card-head">
<h2 class="card-title" data-icon="history" data-icon-tone="note">Change history</h2>
<p class="card-note">Restoring puts an earlier version back as a new change, so the
televisions notice it and the version it replaced stays here to return to.</p>
</div>
<div class="table-wrap">
<table>
<thead><tr>
<th>When</th><th>Rev</th><th>Changed by</th><th>What changed</th>
<th class="num">Taken by</th><th></th>
</tr></thead>
<tbody id="history-rows"></tbody>
</table>
</div>
<div class="card-foot">
<span class="hint" id="history-message"></span>
</div>
</section>
@@ -1,175 +0,0 @@
/* One person's settings, over time. The identity is in the path /admin/accounts/<id>/
settings so the page can be linked and returned to after a sign-in, like every other
page here.
The two questions this answers are different and both need the whole page. "What did I
change and can I undo it" is the table; "why is that television still wrong" is the
device list above it, because a revision the server wrote is not a revision a set has
taken. */
const { fmt, ui, $ } = Admin;
const segments = location.pathname.split('/').filter(Boolean);
// .../accounts/<id>/settings — the id is the segment before the page name.
const userId = decodeURIComponent(segments[segments.length - 2] || '');
const base = '/admin/api/accounts/' + encodeURIComponent(userId);
// Which revisions have their full document open. Kept out of the DOM so the thirty-second
// poll can redraw the table without closing what the operator opened.
const expanded = new Set();
let catalogue = [];
let latest = null;
function message(text) { $('history-message').textContent = text || ''; }
/* ---- where each television has got to ---------------------------------- */
function renderDevices(payload) {
const devices = payload.devices || [];
const current = payload.currentRevision || 0;
$('history-current').innerHTML = payload.saved
? ui.tag('now on r' + fmt.number(current) + ' · ' + (payload.currentSource || 'device'),
payload.currentSource === 'admin' ? 'warn' : 'ok')
: ui.tag('defaults · never synced', 'idle');
$('history-devices').innerHTML = devices.length ? devices.map((device) => {
const tone = device.never ? 'idle' : device.behind ? 'bad' : 'ok';
const state = device.never
? ui.tag('never taken one', 'idle')
: device.behind
? ui.tag(fmt.number(device.behind) + ' behind', 'bad')
: ui.tag('up to date', 'ok');
const held = device.never
? 'Has not fetched these settings yet'
: 'Holding r' + fmt.number(device.revision) + ' · taken ' + fmt.when(device.ackedAt);
return '<div class="list-row"><span class="list-main"><span>' +
'<span class="list-title">' +
'<span class="dot" data-tone="' + tone + '"></span>' +
fmt.escape(device.name || 'Memby TV') +
(device.signedOut ? ' ' + ui.tag('signed out', 'idle') : '') +
'</span>' +
'<span class="list-meta">' + fmt.escape(held) +
(device.clientVersion ? ' · Memby ' + fmt.escape(device.clientVersion) : '') +
(device.signedOut ? '' : ' · last seen ' + fmt.escape(fmt.when(device.lastSeen))) +
'</span></span></span>' +
'<span class="list-actions">' + state + '</span></div>';
}).join('') : ui.empty('No television has been signed in to this account.');
}
/* ---- the history table -------------------------------------------------- */
function changeChips(revision) {
if (revision.initial) return '<span class="muted">First recorded settings</span>';
const changes = revision.changes || [];
if (!changes.length) {
// A write that changed nothing an operator can see: a television pushing the document
// it already held, usually. Saying so is more useful than an empty cell.
return '<span class="muted">No visible change</span>';
}
return '<div class="chips">' + changes.map((change) =>
ui.chip(change.name + ': ' + change.before + ' → ' + change.after)).join('') + '</div>';
}
function takenBy(revision) {
const acks = revision.acks || [];
if (!acks.length) return '<span class="muted">—</span>';
const names = acks.map((ack) => ack.deviceName || ack.deviceId).join(', ');
return '<span title="' + fmt.escape(names) + '">' + fmt.number(acks.length) + '</span>';
}
// The whole document at one revision, in the catalogue's own order and wording. This is
// what makes a restore a decision rather than a guess.
function documentRow(revision) {
const values = revision.preferences || {};
const chips = catalogue.map((definition) =>
ui.chip(definition.name + ': ' + describe(definition, values[definition.key])));
return '<tr class="detail"><td colspan="6" class="muted"><div class="chips">' +
chips.join('') + '</div></td></tr>';
}
// The same wording the server puts in a change line, so a document and a diff never
// describe one value two ways.
function describe(definition, value) {
if (definition.kind === 'toggle') return value ? 'On' : 'Off';
if (definition.kind === 'choice') {
const match = (definition.options || []).find((option) => option.value === value);
return match ? match.label : String(value ?? '');
}
if (definition.kind === 'number') {
if (Number(value) === 0 && definition.unit) return 'No limit';
return definition.unit ? value + ' ' + definition.unit : String(value ?? '');
}
const entries = Array.isArray(value) ? value : [];
if (!entries.length) return 'None';
return entries.map((entry) => {
const match = (definition.options || []).find((option) => option.value === entry);
return match ? match.label : entry;
}).join(', ');
}
function renderHistory(payload) {
const revisions = payload.revisions || [];
$('history-rows').innerHTML = revisions.length ? revisions.map((revision) => {
const open = expanded.has(revision.revision);
const source = revision.source === 'admin' ? 'warn' : 'ok';
const row = '<tr>' +
'<td>' + fmt.escape(fmt.when(revision.createdAt)) + '</td>' +
'<td class="num">r' + fmt.number(revision.revision) +
(revision.current ? ' ' + ui.tag('current', 'ok') : '') + '</td>' +
'<td>' + ui.tag(revision.author, source) +
(revision.restoredFrom
? ' <span class="muted">restored r' + fmt.number(revision.restoredFrom) + '</span>'
: '') + '</td>' +
'<td class="muted">' + changeChips(revision) + '</td>' +
'<td class="num">' + takenBy(revision) + '</td>' +
'<td class="num"><span class="list-actions">' +
'<button class="small" data-history-action="toggle" data-revision="' +
revision.revision + '">' + (open ? 'Hide' : 'Show') + '</button>' +
(revision.current ? ''
: '<button class="small" data-history-action="restore" data-revision="' +
revision.revision + '">Restore</button>') +
'</span></td></tr>';
return open ? row + documentRow(revision) : row;
}).join('') : ui.emptyRow(6, 'Nothing has been changed on this account yet.');
}
/* ---- page --------------------------------------------------------------- */
Admin.onRefresh(async () => {
const payload = await Admin.api(base + '/preferences/history');
latest = payload;
catalogue = payload.catalogue || catalogue;
const name = payload.username || 'this account';
$('page-title').textContent = 'Settings history';
$('page-intro').textContent = 'Every change to ' + name + 's synced settings, and which ' +
'of their televisions has taken it.';
document.title = name + ' · settings history · Memby admin';
$('history-crumb').href = '/admin/accounts/' + encodeURIComponent(userId);
$('history-crumb').textContent = '← ' + name;
renderDevices(payload);
renderHistory(payload);
});
document.addEventListener('click', (event) => {
const button = event.target.closest('[data-history-action]');
if (!button) return;
const revision = Number(button.dataset.revision);
if (button.dataset.historyAction === 'toggle') {
if (expanded.has(revision)) expanded.delete(revision); else expanded.add(revision);
if (latest) renderHistory(latest);
return;
}
if (button.dataset.historyAction === 'restore') {
if (!confirm('Restore revision ' + revision + '? It goes out as a new change, so every ' +
'one of their televisions will pick it up, and the current version stays in this ' +
'history to return to.')) return;
message('restoring…');
Admin.act(async () => {
await Admin.api(base + '/preferences/revisions/' + revision + '/restore',
{ method: 'POST' });
message('restored r' + revision);
});
}
});
@@ -1,84 +0,0 @@
<div class="tiles" id="subtitle-tiles"></div>
<div class="grid two">
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="wrench" data-icon-tone="data">Bazarr</h2>
<p class="card-note">Bazarr writes the subtitle file beside the media file, so Emby
finds it and the track behaves like one that was always there. Its address is
deployment configuration; this switch only decides whether viewers may use it.</p>
</div>
<span id="bazarr-state"></span>
</div>
<label class="check">
<input type="checkbox" id="bazarr-enabled">
<span>Offer Bazarr in the player<em>Off leaves every subtitle it has already
written in place.</em></span>
</label>
<p class="hint" id="bazarr-address"></p>
</section>
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="captions" data-icon-tone="note">OpenSubtitles</h2>
<p class="card-note">OpenSubtitles hands back a file rather than writing one, so
Memby keeps what it fetches and serves it to the television itself. Titles are
matched on their IMDb or TMDb id, which is exact — there is no guessing at a name.</p>
</div>
<span id="opensubtitles-state"></span>
</div>
<label class="check">
<input type="checkbox" id="opensubtitles-enabled">
<span>Offer OpenSubtitles in the player<em>Needs an API key. It cannot be
switched on without one.</em></span>
</label>
<label class="field"><span>API key</span>
<em>From your consumer at opensubtitles.com. Leave blank to keep the saved key.</em>
<input type="password" id="opensubtitles-key" autocomplete="new-password"
placeholder="Paste an API key"></label>
<label class="check">
<input type="checkbox" id="opensubtitles-clear-key"><span>Remove the saved key</span>
</label>
<div class="fields">
<label class="field"><span>Account username</span>
<em>Optional, and the difference between a working feature and one that stops
after a few files: without an account, downloads come out of the small
anonymous allowance.</em>
<input type="text" id="opensubtitles-username" autocomplete="off"
placeholder="Not signed in"></label>
<label class="field"><span>Account password</span>
<em>Leave blank to keep the saved one.</em>
<input type="password" id="opensubtitles-password" autocomplete="new-password"></label>
</div>
<label class="check">
<input type="checkbox" id="opensubtitles-clear-login"><span>Sign out and forget the account</span>
</label>
</section>
</div>
<section class="card">
<div class="row">
<button class="primary" id="subtitle-save">Save subtitle settings</button>
<button id="subtitle-test">Test the providers</button>
<span class="hint" id="subtitle-hint"></span>
</div>
<div id="subtitle-test-results"></div>
</section>
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="database" data-icon-tone="data">Subtitles Memby is holding</h2>
<p class="card-note">Only files fetched from a provider that cannot write beside the
media file are kept here; they are served to televisions as ordinary tracks on every
later playback. Emptying this is safe — each one can be fetched again, at the cost of
the download allowance that fetched it.</p>
</div>
<span id="stored-state"></span>
</div>
<div class="card-foot">
<button id="stored-clear">Delete every stored subtitle</button>
</div>
</section>
@@ -1,98 +0,0 @@
const { fmt, ui, $ } = Admin;
Admin.onStatus((status) => {
const subtitles = status.subtitles || {};
const stored = subtitles.stored || {};
$('subtitle-tiles').innerHTML = ui.tiles([
['offered on televisions', subtitles.available ? 'yes' : 'no',
{ small: true, icon: 'captions', tone: subtitles.available ? 'ok' : undefined }],
['providers on',
fmt.number((subtitles.bazarrEnabled && subtitles.bazarrConfigured ? 1 : 0) +
(subtitles.openSubtitlesEnabled ? 1 : 0)),
{ icon: 'list', tone: 'note' }],
['subtitles held', fmt.number(stored.count), { icon: 'database', tone: 'data' }],
['last fetched', fmt.when(stored.latest), { small: true, icon: 'clock' }],
]);
Admin.check($('bazarr-enabled'), subtitles.bazarrEnabled);
$('bazarr-enabled').disabled = !subtitles.bazarrConfigured;
$('bazarr-state').innerHTML = !subtitles.bazarrConfigured
? ui.tag('not configured', 'idle')
: (subtitles.bazarrEnabled ? ui.tag('on', 'ok') : ui.tag('off', 'idle'));
// The address is worth printing: it is the one thing on this page an operator cannot
// change here, so seeing which Bazarr is meant is how they find out it is the wrong one.
$('bazarr-address').textContent = subtitles.bazarrConfigured
? 'Configured at ' + subtitles.bazarrUrl
: 'Set MEMBY_BAZARR_URL and MEMBY_BAZARR_API_KEY to use Bazarr.';
Admin.check($('opensubtitles-enabled'), subtitles.openSubtitlesEnabled);
const keyField = $('opensubtitles-key');
keyField.placeholder = subtitles.openSubtitlesKeyConfigured
? 'Saved key (leave blank to keep)' : 'Paste an API key';
Admin.fill($('opensubtitles-username'), subtitles.openSubtitlesUsername || '');
$('opensubtitles-state').innerHTML = subtitles.openSubtitlesEnabled
? ui.tag(subtitles.openSubtitlesAccount ? 'on · signed in' : 'on · anonymous',
subtitles.openSubtitlesAccount ? 'ok' : 'warn')
: ui.tag(subtitles.openSubtitlesKeyConfigured ? 'off · key saved' : 'off · no key', 'idle');
// The feature flag overrides both switches, so a page that stayed silent about it would
// be showing two controls that visibly do nothing.
$('subtitle-hint').textContent = subtitles.featureEnabled
? 'A change applies to the next title opened; no app release is required.'
: 'Downloading subtitles is switched off on the Features page, so nothing here is offered.';
$('stored-state').innerHTML = stored.count
? ui.tag(fmt.number(stored.count) + ' files · ' + fmt.bytes(stored.bytes), 'data')
: ui.tag('nothing held', 'idle');
$('stored-clear').disabled = !stored.count;
});
const save = () => Admin.api('/admin/api/subtitle-settings', {
method: 'POST',
body: JSON.stringify({
bazarrEnabled: $('bazarr-enabled').checked,
openSubtitlesEnabled: $('opensubtitles-enabled').checked,
openSubtitlesApiKey: $('opensubtitles-key').value.trim(),
clearOpenSubtitlesApiKey: $('opensubtitles-clear-key').checked,
openSubtitlesUsername: $('opensubtitles-username').value.trim(),
openSubtitlesPassword: $('opensubtitles-password').value,
clearOpenSubtitlesLogin: $('opensubtitles-clear-login').checked,
}),
}).then(() => {
// The credential fields are emptied on the way out, so a saved page never has a secret
// sitting in a form somebody could walk past.
$('opensubtitles-key').value = '';
$('opensubtitles-password').value = '';
$('opensubtitles-clear-key').checked = false;
$('opensubtitles-clear-login').checked = false;
});
Admin.ready(() => {
$('subtitle-save').addEventListener('click', () => Admin.act(save));
$('subtitle-test').addEventListener('click', () => {
const results = $('subtitle-test-results');
results.innerHTML = ui.empty('Asking each provider…');
Admin.api('/admin/api/subtitle-test', { method: 'POST' }).then((answer) => {
const rows = answer.results || [];
results.innerHTML = rows.length
? '<div class="list">' + rows.map((row) =>
'<div class="list-row"><span class="list-main"><span>' +
'<span class="list-title">' + fmt.escape(row.provider) + '</span>' +
'<span class="list-meta">' + fmt.escape(row.message) + '</span></span></span>' +
'<span class="list-actions">' + ui.tag(row.ok ? 'reachable' : 'not reachable',
row.ok ? 'ok' : 'bad') + '</span></div>').join('') + '</div>'
: ui.empty('No provider is switched on, so there was nothing to ask.');
}).catch((error) => {
results.innerHTML = ui.empty(String(error.message || error));
});
});
$('stored-clear').addEventListener('click', () => {
if (!confirm('Delete every subtitle Memby is holding? Each can be fetched again.')) return;
Admin.act(() => Admin.api('/admin/api/subtitle-settings', {
method: 'POST', body: JSON.stringify({ action: 'clear-stored' }),
}));
});
});
@@ -1,38 +0,0 @@
<section class="card">
<div class="card-head split">
<div>
<h2 class="card-title" data-icon="download" data-icon-tone="info">Update policy</h2>
<p class="card-note">Televisions check on every launch. An optional update is a prompt
the viewer can dismiss; a required one covers the home screen until they update, so
it needs a download URL that actually works.</p>
</div>
<span id="update-state"></span>
</div>
<div class="fields">
<label class="field"><span>Latest version</span>
<input type="text" id="update-version" placeholder="0.1.54"></label>
<label class="field"><span>APK URL</span>
<input type="text" id="update-url" placeholder="https://nas/memby/memby-0.1.54.apk"></label>
</div>
<label class="field"><span>What's new</span>
<em>Shown on the television above the update button.</em>
<input type="text" id="update-notes" placeholder="One line the viewer reads"></label>
<label class="field"><span>Sign out builds below</span>
<em>The destructive compatibility floor. Leave blank to keep every supported viewer
signed in.</em>
<input type="text" id="update-retire-below" placeholder="0.2.44"></label>
<label class="check">
<input type="checkbox" id="update-required">
<span>Require this update<em>Blocks the home screen on every television below this
version.</em></span>
</label>
<label class="check">
<input type="checkbox" id="update-destructive">
<span>Set the destructive floor to this update<em>Deletes sessions on every older
television when it next uses Memby, then shows the required update screen.</em></span>
</label>
<div class="card-foot">
<button class="primary" id="update-save">Save policy</button>
<button id="update-disable">Turn prompts off</button>
</div>
</section>
@@ -1,53 +0,0 @@
const { ui, $ } = Admin;
Admin.onStatus((status) => {
const policy = status.updatePolicy || {};
// "Required" is not a field of its own: it is the minimum and the latest being the same
// version, which is what the client compares against.
const required = Boolean(policy.minimumVersion) && policy.minimumVersion === policy.latestVersion;
const destructive = Boolean(policy.retireBelowVersion) &&
policy.retireBelowVersion === policy.latestVersion;
$('update-state').innerHTML = !policy.enabled
? ui.tag('off', 'idle')
: ui.tag((destructive ? 'sign-out · ' : required ? 'required · ' : 'optional · ') +
policy.latestVersion, required ? 'warn' : 'ok');
Admin.fill($('update-version'), policy.latestVersion || '');
Admin.fill($('update-url'), policy.downloadUrl || '');
Admin.fill($('update-notes'), policy.notes || '');
Admin.fill($('update-retire-below'), policy.retireBelowVersion || '');
Admin.check($('update-required'), required);
Admin.check($('update-destructive'), destructive);
});
const body = (enabled) => JSON.stringify({
enabled,
latestVersion: $('update-version').value.trim(),
downloadUrl: $('update-url').value.trim(),
notes: $('update-notes').value.trim(),
required: $('update-required').checked,
destructive: $('update-destructive').checked,
retireBelowVersion: $('update-retire-below').value.trim(),
});
Admin.ready(() => {
$('update-destructive').addEventListener('change', () => {
if ($('update-destructive').checked) {
$('update-required').checked = true;
$('update-retire-below').value = $('update-version').value.trim();
} else if ($('update-retire-below').value.trim() === $('update-version').value.trim()) {
$('update-retire-below').value = '';
}
});
$('update-save').addEventListener('click', () => {
const destructive = $('update-destructive').checked;
const warning = destructive
? 'This will delete sessions on every older television and force viewers to sign in again after updating. Continue?'
: 'Required updates block the home screen on every television below this version. Continue?';
if ($('update-required').checked && !confirm(warning)) return;
Admin.act(() => Admin.api('/admin/api/update-policy', { method: 'POST', body: body(true) }));
});
$('update-disable').addEventListener('click', () =>
Admin.act(() => Admin.api('/admin/api/update-policy', { method: 'POST', body: body(false) })));
});
-64
View File
@@ -1,64 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Page.Title}} · Memby admin</title>
<style>{{.CSS}}</style>
</head>
<body>
<a class="skip" href="#page">Skip to content</a>
<aside class="rail" aria-label="Admin navigation">
<a class="rail-brand" href="/admin/overview">
<span class="rail-mark" aria-hidden="true">M</span>
<span class="rail-brand-copy"><b>Memby</b><span>Gateway admin</span></span>
</a>
<div class="rail-scroll">
{{range .Nav}}
{{if .Label}}<button class="rail-group" type="button" data-nav-group="{{.ID}}"
aria-expanded="true" aria-controls="rail-nav-{{.ID}}"><span>{{.Label}}</span><span class="rail-group-arrow" aria-hidden="true"></span></button>{{end}}
<nav class="rail-nav" id="rail-nav-{{.ID}}" data-nav-panel="{{.ID}}">
{{range .Items}}{{if not .Hidden}}
<a class="rail-link{{if eq .ID $.Page.ID}} active{{end}}" href="/admin/{{.ID}}"
{{if eq .ID $.Page.ID}}aria-current="page"{{end}} title="{{.Label}}">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="{{.Icon}}"/></svg><span>{{.Label}}</span>
</a>
{{end}}{{end}}
</nav>
{{end}}
</div>
<div class="rail-foot">
<div class="rail-status">
<span class="dot" id="rail-live" data-tone="idle"></span>
<span class="rail-foot-copy"><b id="rail-live-label">connecting</b><span id="rail-version">gateway …</span></span>
</div>
<form method="post" action="/admin/logout">
<button class="quiet small rail-logout" type="submit" data-icon="power" aria-label="Log out">
<span>Log out</span>
</button>
</form>
</div>
</aside>
<main id="page" class="page" data-admin-page="{{.Page.ID}}">
<header class="page-head">
<div class="page-head-copy">
<h1 id="page-title">
{{if .Page.Icon}}<span class="glyph page-mark" data-tone="accent" aria-hidden="true">
<svg class="ico" viewBox="0 0 24 24"><path d="{{.Page.Icon}}"/></svg>
</span>{{end}}{{.Page.Title}}
</h1>
<p id="page-intro">{{.Page.Intro}}</p>
</div>
<span class="tag" id="live" data-tone="idle">connecting…</span>
</header>
<div id="error" class="notice" role="alert" hidden></div>
{{.Body}}
</main>
<script>{{.Core}}</script>
<script>{{.Script}}</script>
</body>
</html>
-275
View File
@@ -1,275 +0,0 @@
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 {
ID string
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{
{
ID: "overview",
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",
},
},
},
{
ID: "people",
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",
},
},
},
{
ID: "content",
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",
},
},
},
{
ID: "personalisation",
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",
},
},
},
{
ID: "experience",
Label: "Experience",
Items: []adminNavItem{
{
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",
},
{
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",
},
},
},
{
ID: "operations",
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))
}
}
+262
View File
@@ -0,0 +1,262 @@
package api
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"net/url"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/adminevents"
"github.com/ponzischeme89/memby/server/internal/store"
)
// integrationEvent is one selectable event type, as the console renders it.
//
// The catalogue is here rather than in the integrations package for the reason the
// preference catalogue is in the API: it is *wording*, and the dispatcher must not care
// what an event is called in order to deliver it. A type published by something this list
// predates is still deliverable — the selection is a string — it simply has no friendly
// label until somebody adds one.
type integrationEvent struct {
Type string `json:"type"`
Label string `json:"label"`
Description string `json:"description"`
Group string `json:"group"`
}
var integrationEventCatalogue = []integrationEvent{
{adminevents.TypeLogin, "User signed in", "A television signed in with a known device.", "Access"},
{adminevents.TypeDeviceRegistered, "New device", "A television signed in for the first time.", "Access"},
{adminevents.TypeLoginFailed, "Sign-in refused", "Emby refused the credentials offered.", "Access"},
{adminevents.TypeLogout, "User signed out", "A television signed itself out.", "Access"},
{adminevents.TypeDeviceRemoved, "Device removed", "A television was removed from an account.", "Access"},
{adminevents.TypeDeviceRenamed, "Device renamed", "A television was given a new name.", "Access"},
{adminevents.TypeAdminSignIn, "Admin sign-in", "Somebody signed into this console.", "Access"},
{adminevents.TypeServerStarted, "Server started", "The gateway came up, usually after a deployment.", "System"},
{adminevents.TypeMaintenanceChanged, "Maintenance changed", "Memby was taken offline or brought back.", "System"},
{adminevents.TypeTaskCompleted, "Scheduled task finished", "A background job did some work.", "System"},
{adminevents.TypeTaskFailed, "Scheduled task failed", "A background job could not complete.", "System"},
{adminevents.TypeIntegrationFailed, "Integration failed", "An outgoing webhook could not be delivered.", "System"},
{adminevents.TypeLibrarySync, "Library synchronised", "The catalogue import added or changed titles.", "Content"},
{adminevents.TypeEmbyUnreachable, "Emby unreachable", "The Emby server stopped answering.", "Content"},
{adminevents.TypeEmbyRecovered, "Emby recovered", "The Emby server started answering again.", "Content"},
}
type adminIntegrationRow struct {
store.RedactedIntegration
Health store.IntegrationHealth `json:"health"`
Deliveries []store.IntegrationDelivery `json:"deliveries"`
}
type adminIntegrationsResponse struct {
Integrations []adminIntegrationRow `json:"integrations"`
Catalogue []integrationEvent `json:"catalogue"`
Dropped int64 `json:"dropped"`
}
// handleAdminIntegrations lists every configured destination with its health and its last
// few attempts.
//
// The delivery history rides along rather than being a route of its own, because the two
// questions an operator has — "is it working" and "why did that one not arrive" — are
// asked at the same moment, and a page that had to fetch twice would show a healthy tick
// above a table of failures for as long as the second request took.
func (s *Server) handleAdminIntegrations(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.Integrations(r.Context())
if err != nil {
s.log.Error("integration settings read failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read the integrations")
return
}
response := adminIntegrationsResponse{
Integrations: []adminIntegrationRow{},
Catalogue: integrationEventCatalogue,
Dropped: s.integrations.Dropped(),
}
for _, integration := range settings.Integrations {
row := adminIntegrationRow{
RedactedIntegration: integration.Redact(),
Deliveries: []store.IntegrationDelivery{},
}
if health, err := s.store.IntegrationHealthFor(r.Context(), integration.ID); err == nil {
row.Health = health
}
if deliveries, err := s.store.IntegrationDeliveries(r.Context(), integration.ID, 10); err == nil {
row.Deliveries = deliveries
}
response.Integrations = append(response.Integrations, row)
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, response)
}
type saveIntegrationRequest struct {
ID string `json:"id"`
Kind string `json:"kind"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
URL string `json:"url"`
Events []string `json:"events"`
}
// handleAdminSaveIntegration creates or updates one destination.
//
// The URL is *optional on an update*, and that is the whole reason this is not a plain
// overwrite: the console is never sent the webhook address back (it is the credential),
// so a form that submitted what it was showing would replace a working webhook with an
// empty string on every unrelated edit. An absent URL therefore means "leave it as it
// is", and clearing one is done by removing the integration.
func (s *Server) handleAdminSaveIntegration(w http.ResponseWriter, r *http.Request) {
var req saveIntegrationRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
req.URL = strings.TrimSpace(req.URL)
if req.URL != "" {
if err := validateWebhookURL(req.URL); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
settings, err := s.store.Integrations(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "could not read the integrations")
return
}
id := strings.TrimSpace(req.ID)
found := false
for i := range settings.Integrations {
if settings.Integrations[i].ID != id || id == "" {
continue
}
found = true
settings.Integrations[i].Name = req.Name
settings.Integrations[i].Enabled = req.Enabled
settings.Integrations[i].Events = req.Events
if req.URL != "" {
settings.Integrations[i].URL = req.URL
}
settings.Integrations[i].UpdatedAt = time.Now().UTC()
}
if !found {
if req.URL == "" {
writeError(w, http.StatusBadRequest, "a new integration needs a webhook address")
return
}
if len(settings.Integrations) >= store.MaxIntegrations {
writeError(w, http.StatusBadRequest, "too many integrations")
return
}
kind := strings.TrimSpace(req.Kind)
if kind == "" {
kind = store.IntegrationDiscord
}
settings.Integrations = append(settings.Integrations, store.Integration{
ID: newIntegrationID(), Kind: kind, Name: req.Name,
Enabled: req.Enabled, URL: req.URL, Events: req.Events,
CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
})
}
if err := s.store.SetIntegrations(r.Context(), settings); err != nil {
s.log.Error("integration save failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save the integration")
return
}
// The dispatcher caches configuration for a few seconds; without this an operator's
// save would appear not to have taken until the cache aged out, which reads as the
// switch not working.
s.integrations.Invalidate()
s.loggerFor(r.Context()).Info("integration saved",
"integration", req.Name, "enabled", req.Enabled, "events", len(req.Events))
s.handleAdminIntegrations(w, r)
}
// handleAdminDeleteIntegration removes a destination and its delivery history.
func (s *Server) handleAdminDeleteIntegration(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.PathValue("integrationID"))
settings, err := s.store.Integrations(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "could not read the integrations")
return
}
kept := make([]store.Integration, 0, len(settings.Integrations))
removed := false
for _, integration := range settings.Integrations {
if integration.ID == id {
removed = true
continue
}
kept = append(kept, integration)
}
if !removed {
writeError(w, http.StatusNotFound, "no such integration")
return
}
settings.Integrations = kept
if err := s.store.SetIntegrations(r.Context(), settings); err != nil {
writeError(w, http.StatusInternalServerError, "could not remove the integration")
return
}
s.integrations.Invalidate()
s.loggerFor(r.Context()).Info("integration removed", "integration_id", id)
s.handleAdminIntegrations(w, r)
}
// handleAdminTestIntegration posts a synthetic message and answers with what happened.
//
// Synchronous on purpose: a test is a question, and an operator who pressed it needs the
// answer here rather than on a delivery history they would have to go and refresh.
func (s *Server) handleAdminTestIntegration(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.PathValue("integrationID"))
if err := s.integrations.Test(r.Context(), id); err != nil {
s.loggerFor(r.Context()).Warn("integration test failed",
"integration_id", id, "error", err)
writeJSON(w, http.StatusOK, map[string]any{
"ok": false, "message": err.Error(),
})
return
}
s.loggerFor(r.Context()).Info("integration test delivered", "integration_id", id)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true, "message": "Delivered. Check the channel.",
})
}
// validateWebhookURL refuses anything that is not an https webhook.
//
// Not defence in depth so much as the one check worth making: this address is fetched by
// the gateway, from inside the household's network, so a plain-http or non-absolute URL
// is either a typo or an attempt to point the gateway at something local. Discord's own
// webhooks are https by definition, so nothing legitimate is refused.
func validateWebhookURL(raw string) error {
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
return errBadWebhook
}
if parsed.Scheme != "https" {
return errBadWebhook
}
return nil
}
var errBadWebhook = &webhookError{"a webhook address must be a full https:// URL"}
type webhookError struct{ message string }
func (e *webhookError) Error() string { return e.message }
func newIntegrationID() string {
raw := make([]byte, 8)
if _, err := rand.Read(raw); err != nil {
// A collision is the only consequence, and the caller checks for a duplicate id.
return "integration"
}
return hex.EncodeToString(raw)
}
+217
View File
@@ -0,0 +1,217 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/adminevents"
"github.com/ponzischeme89/memby/server/internal/store"
)
// streamHeartbeat keeps the notification stream's connection open through anything that
// drops an idle one — a reverse proxy's read timeout being the usual culprit. It is a
// comment line rather than an event, so a client that has been told nothing has still
// been told the connection is alive.
const streamHeartbeat = 25 * time.Second
type adminNotificationsResponse struct {
Events []store.AdminEvent `json:"events"`
Total int `json:"total"`
Unread int `json:"unread"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Types []store.AdminEventTypeCount `json:"types"`
Subscribers int `json:"subscribers"`
}
// handleAdminNotifications is the bell's dropdown and the full activity page alike — the
// same feed, read with a bigger limit.
func (s *Server) handleAdminNotifications(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
filter := store.AdminEventFilter{
Types: splitCSV(query.Get("type")),
Severities: splitCSV(query.Get("severity")),
UnreadOnly: query.Get("unread") == "true",
Limit: queryInt(r, "limit", 50, 200),
Offset: queryInt(r, "offset", 0, 10000),
}
if days := queryInt(r, "days", 0, 90); days > 0 {
filter.Since = time.Now().Add(-time.Duration(days) * 24 * time.Hour)
}
page, err := s.store.AdminEvents(r.Context(), filter)
if err != nil {
s.log.Error("admin notifications failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read the activity feed")
return
}
response := adminNotificationsResponse{
Events: page.Events, Total: page.Total, Unread: page.Unread,
Limit: page.Limit, Offset: page.Offset,
Types: []store.AdminEventTypeCount{}, Subscribers: s.adminEvents.Subscribers(),
}
// The type list is built from what has been published rather than from the constants
// in adminevents, so the filter can neither offer a type that matches nothing nor miss
// one a service added after this handler was written.
if types, err := s.store.AdminEventTypes(r.Context(), time.Time{}); err == nil {
response.Types = types
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, response)
}
type markReadRequest struct {
IDs []int64 `json:"ids"`
All bool `json:"all"`
}
// handleAdminNotificationsRead marks events read. An empty id list with `all` marks the
// whole feed, which is the "mark all read" button; anything else marks exactly what was
// named, which is what opening the dropdown does for the rows it showed.
func (s *Server) handleAdminNotificationsRead(w http.ResponseWriter, r *http.Request) {
var req markReadRequest
if r.Body != nil {
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req)
}
if len(req.IDs) == 0 && !req.All {
req.IDs = queryInt64s(r, "id")
}
if len(req.IDs) == 0 && !req.All {
writeError(w, http.StatusBadRequest, "name some events, or ask for all of them")
return
}
marked, err := s.store.MarkAdminEventsRead(r.Context(), req.IDs)
if err != nil {
s.log.Error("marking notifications read failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not update the activity feed")
return
}
unread, _ := s.store.UnreadAdminEvents(r.Context())
writeJSON(w, http.StatusOK, map[string]any{"marked": marked, "unread": unread})
}
// handleAdminNotificationStream is the live feed: server-sent events, not a WebSocket.
//
// The traffic is one-way and low-volume, which is exactly what SSE is for — and it rides
// ordinary HTTP, so it needs nothing from the reverse proxy in front of the gateway, uses
// the same admin cookie every other route does, and reconnects by itself when a laptop
// wakes up. A WebSocket would have bought a second protocol to authenticate and proxy for
// no capability this feature wants.
//
// It opens by replaying everything after the id the client last saw, which is what makes
// the dropped-event handling in adminevents.Bus recoverable: a reader that fell behind, or
// was asleep, catches up on reconnect rather than having a hole in its feed.
func (s *Server) handleAdminNotificationStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming is not available")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Connection", "keep-alive")
// Nginx buffers a proxied response by default, which for a stream means the browser
// receives nothing until the connection closes. This is the one header that stops it.
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher.Flush()
// Subscribe *before* the replay, or an event published between the two is delivered to
// nobody: it would be too late for the replay and too early for the subscription.
live, unsubscribe := s.adminEvents.Subscribe()
defer unsubscribe()
after := lastEventID(r)
if page, err := s.store.AdminEvents(r.Context(), store.AdminEventFilter{Limit: 50}); err == nil {
// Oldest first, so the client applies them in the order they happened.
for i := len(page.Events) - 1; i >= 0; i-- {
if page.Events[i].ID > after {
writeSSE(w, flusher, page.Events[i])
}
after = max(after, page.Events[i].ID)
}
}
heartbeat := time.NewTicker(streamHeartbeat)
defer heartbeat.Stop()
for {
select {
case <-r.Context().Done():
return
case <-heartbeat.C:
fmt.Fprint(w, ": keep-alive\n\n")
flusher.Flush()
case event, open := <-live:
if !open {
return
}
// The replay may overlap the live feed by an event or two; the id filter is
// what stops the client seeing one twice.
if event.ID != 0 && event.ID <= after {
continue
}
after = max(after, event.ID)
writeSSE(w, flusher, event)
}
}
}
// lastEventID reads where a reconnecting client got to. The browser sends it back in
// Last-Event-ID automatically after a dropped connection; the query parameter is for a
// first connection that already has a feed on screen.
func lastEventID(r *http.Request) int64 {
if header := strings.TrimSpace(r.Header.Get("Last-Event-ID")); header != "" {
if ids := parseInt64(header); ids > 0 {
return ids
}
}
return int64(queryInt(r, "after", 0, 1<<62))
}
func parseInt64(raw string) int64 {
var value int64
if _, err := fmt.Sscanf(raw, "%d", &value); err != nil {
return 0
}
return value
}
func writeSSE(w http.ResponseWriter, flusher http.Flusher, event store.AdminEvent) {
payload, err := json.Marshal(event)
if err != nil {
return
}
fmt.Fprintf(w, "id: %d\nevent: admin\ndata: %s\n\n", event.ID, payload)
flusher.Flush()
}
func splitCSV(raw string) []string {
values := []string{}
for _, part := range strings.Split(raw, ",") {
if trimmed := strings.TrimSpace(part); trimmed != "" {
values = append(values, trimmed)
}
}
return values
}
// AnnounceServerStart puts the gateway coming up into the feed.
//
// It is a method on Server rather than a call in main so it carries the same build
// information the rest of the console reports, and it is deliberately published *after*
// the listener is up: an event announcing a start that then fails to bind would be the
// one misleading row in the feed.
func (s *Server) AnnounceServerStart(version string) {
s.publishAdmin(context.Background(), adminevents.Event{
Type: adminevents.TypeServerStarted,
Severity: adminevents.SeverityInfo,
Title: "Memby server started",
Summary: "The gateway is running version " + version + ".",
Actor: "memby-server",
Link: "/admin",
Metadata: adminevents.Meta(map[string]any{"version": version}),
})
}
+12 -33
View File
@@ -5,24 +5,24 @@ import (
"fmt"
"net/http"
"os"
"strings"
"testing"
"time"
)
// A local preview of the admin console with no gateway behind it.
// A canned API for a local React console preview with no gateway behind it.
//
// In one terminal:
//
// cd server && ADMIN_PREVIEW=1 go test ./internal/api -run TestAdminPreview -timeout 0
// → http://127.0.0.1:7777/admin/overview
//
// It serves the same finished bytes the real console serves — adminRendered, composed from
// the embedded shell, stylesheet and fragments — so what appears here is what a deployment
// would show. What it fakes is only the data: /admin/api/* answers from the canned status
// below, which is what makes it runnable without Postgres, Redis or an Emby to talk to.
// In another:
//
// It is a test so that it can reach adminRendered, which is unexported for the good reason
// that composing the console is nobody else's business. It skips unless ADMIN_PREVIEW is
// set, so an ordinary `go test ./...` never blocks on a server that runs until interrupted.
// cd admin-ui && npm run dev
//
// Vite owns the React shell at http://127.0.0.1:5180/admin/ and proxies its /admin/api/*
// requests here. The preview therefore fakes only data, leaving the same built console
// responsible for every route and asset it will own in deployment. It skips unless
// ADMIN_PREVIEW is set, so an ordinary `go test ./...` never blocks on this server.
func TestAdminPreview(t *testing.T) {
if os.Getenv("ADMIN_PREVIEW") == "" {
t.Skip("set ADMIN_PREVIEW=1 to serve the console locally")
@@ -34,35 +34,14 @@ func TestAdminPreview(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/admin/api/", func(w http.ResponseWriter, r *http.Request) {
body, ok := adminPreviewData()[strings.TrimSuffix(r.URL.Path, "/")]
body, ok := adminPreviewData()[r.URL.Path]
if !ok {
body = map[string]any{}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(body)
})
mux.HandleFunc("/admin/", func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/admin/")
if id == "" {
id = "overview"
}
// A hidden page is addressed by a route carrying something else in the path, so the
// preview takes the first segment and lets /admin/accounts/42 render the account page.
id, _, _ = strings.Cut(id, "/")
page, ok := adminRendered[id]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(page)
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/overview", http.StatusFound)
})
fmt.Printf("\nadmin console preview → http://%s/admin/overview (ctrl-c to stop)\n\n", address)
fmt.Printf("\ncanned admin API → http://%s; open Vite at http://127.0.0.1:5180/admin/ (ctrl-c to stop)\n\n", address)
if err := http.ListenAndServe(address, mux); err != nil {
t.Fatal(err)
}
+161
View File
@@ -0,0 +1,161 @@
package api
import (
"net/http"
"net/http/httputil"
"net/url"
"strings"
"sync"
"time"
)
// The console is a React application served by its own container, `memby-admin`, and the
// gateway reverse-proxies it.
//
// Why a proxy rather than a second published port: the household's reverse proxy sends one
// hostname to this gateway and nothing else, and the admin session is a cookie on that
// origin. A console on a port of its own would be a second origin — a second proxy rule to
// add by hand, CORS on every /admin/api route, and a cookie that has to be relaxed to
// SameSite=None to survive the crossing. Proxying keeps all of that as it was: same
// origin, same cookie, same single ingress, and `memby-admin` never needs to be reachable
// from outside the compose network.
//
// The /admin/api routes are *not* proxied. They are the gateway's own, matched by the mux
// before this ever sees them, which is what keeps the data path in-process and makes the
// console a purely presentational container that can be rebuilt and replaced on its own.
// adminUIRequestTimeout bounds a fetch of the console's own assets. Generous, because it
// covers a cold start where the memby-admin container is still coming up behind us, and
// bounded because a hung upstream must not hold a browser connection open indefinitely.
const adminUIRequestTimeout = 20 * time.Second
// adminUI builds the proxy lazily and once. It is lazy because the address is
// configuration and a gateway with no console configured must not fail to start, and it is
// once because a ReverseProxy carries a connection pool worth keeping.
func (s *Server) adminUI() *httputil.ReverseProxy {
s.adminUIOnce.Do(func() {
target := strings.TrimSpace(s.cfg.AdminUIURL)
if target == "" {
return
}
parsed, err := url.Parse(target)
if err != nil || parsed.Host == "" {
s.log.Error("admin console address is not a URL", "url", target)
return
}
proxy := httputil.NewSingleHostReverseProxy(parsed)
proxy.Transport = &http.Transport{
ResponseHeaderTimeout: adminUIRequestTimeout,
MaxIdleConnsPerHost: 4,
}
// The console is static files; a failure to fetch them is an operational fault
// worth a log line and a plain page, never a Go stack trace in the browser.
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
s.loggerFor(r.Context()).Error("admin console unreachable",
"path", r.URL.Path, "error", err)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(adminUnavailablePage))
}
s.adminUIProxy = proxy
})
return s.adminUIProxy
}
// adminUnavailablePage is what an operator sees when the gateway is up and the console
// container is not. It says which half is missing, because "502 Bad Gateway" over an admin
// URL reads as the whole server being down — which, this page being visible at all,
// it is not.
const adminUnavailablePage = `<!doctype html><meta charset="utf-8">` +
`<title>Memby admin</title>` +
`<style>body{margin:0;display:grid;place-items:center;min-height:100vh;` +
`background:#0a0c10;color:#e7ecf1;font:15px/1.6 system-ui,sans-serif}` +
`div{max-width:34rem;padding:2rem}h1{font-size:1.25rem;margin:0 0 .5rem}` +
`p{color:#8e99a6;margin:.4rem 0}code{color:#86dd7e}</style>` +
`<div><h1>The admin console is not answering</h1>` +
`<p>The Memby gateway is running — this page came from it — but the ` +
`<code>memby-admin</code> container that serves the console did not respond.</p>` +
`<p>Televisions are unaffected: the console is a separate container and the client ` +
`API is served from this process.</p>` +
`<p>Check <code>docker compose ps memby-admin</code> on the host.</p></div>`
// handleAdminConsole serves the single-page console for every /admin path that is not an
// API route.
//
// Client-side routing is why this is a catch-all rather than a route per page: the console
// owns its own URLs now, and a deep link, a refresh or the Back button all arrive here as
// an ordinary GET for a path this server has never heard of. The nav therefore lives in
// the React application and no longer has to be declared in Go as well — which is a real
// simplification, since the previous console had to keep adminNav, the rail, the page
// titles and the set of legal URLs agreeing with each other.
func (s *Server) handleAdminConsole(w http.ResponseWriter, r *http.Request) {
if s.cfg.AdminToken == "" {
http.NotFound(w, r)
return
}
proxy := s.adminUI()
if proxy == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(adminUnavailablePage))
return
}
if !s.validInstallerSession(r) {
// The sign-in form is still the gateway's, server-rendered, and returns to
// wherever the operator was trying to go. It is deliberately not part of the SPA:
// a login page that has to be downloaded from the thing it guards is one more
// moving part between an operator and a console they need precisely when
// something is wrong.
s.renderAccessLogin(w, r, "", http.StatusOK, r.URL.Path)
return
}
// Opening a page is somebody at the keyboard, so it starts the twelve-hour clock again.
s.renewAdminSession(w, r)
s.setAdminTokenCookie(w, r)
preventDiscovery(w)
// The shell must never be cached: it carries the asset hashes, so a stale copy points
// at JavaScript a deployment has already replaced. The hashed assets underneath it are
// cached hard by the console's own nginx, which is the usual arrangement and the
// reason those two rules must not be swapped.
if isAdminDocumentRequest(r) {
w.Header().Set("Cache-Control", "no-store")
}
proxy.ServeHTTP(w, r)
}
// isAdminDocumentRequest distinguishes the SPA shell from the assets it pulls in. Anything
// with a file extension is an asset; everything else is a console route, which the
// console's nginx answers with index.html.
func isAdminDocumentRequest(r *http.Request) bool {
path := r.URL.Path
if slash := strings.LastIndex(path, "/"); slash >= 0 {
path = path[slash+1:]
}
return !strings.Contains(path, ".")
}
// setAdminTokenCookie hands the browser the shared admin token, scoped to /admin.
//
// Unchanged from the previous console and worth restating: the cookie is HttpOnly, so the
// console's JavaScript never holds the token — it is attached by the browser to the API
// requests it makes, and adminAuth additionally requires a valid Emby-verified session
// alongside it. Neither half is sufficient on its own.
func (s *Server) setAdminTokenCookie(w http.ResponseWriter, r *http.Request) {
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
http.SetCookie(w, &http.Cookie{
Name: adminCookieName,
Value: s.cfg.AdminToken,
Path: "/admin",
MaxAge: 10 * 365 * 24 * 60 * 60,
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteStrictMode,
})
}
// adminUIOnce/adminUIProxy live here rather than on the Server literal so this file holds
// the whole of the console-proxy concern.
type adminUIHandle struct {
adminUIOnce sync.Once
adminUIProxy *httputil.ReverseProxy
}
+88
View File
@@ -0,0 +1,88 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/scheduler"
"github.com/ponzischeme89/memby/server/internal/store"
)
type adminTasksResponse struct {
Tasks []scheduler.Status `json:"tasks"`
Groups []string `json:"groups"`
Runs []store.TaskRun `json:"runs"`
}
// handleAdminTasks is the scheduled-tasks page: the registry as it stands plus the most
// recent runs across every task.
//
// The recent-runs list is included rather than being a second request because the useful
// reading of this page is chronological — "what has the gateway been doing overnight" —
// and per-task last-run cells alone cannot show two jobs interfering with each other.
func (s *Server) handleAdminTasks(w http.ResponseWriter, r *http.Request) {
response := adminTasksResponse{
Tasks: s.scheduler.Snapshot(), Groups: s.scheduler.Groups(),
Runs: []store.TaskRun{},
}
if runs, err := s.store.TaskRuns(r.Context(), strings.TrimSpace(r.URL.Query().Get("task")),
queryInt(r, "limit", 40, 500)); err == nil {
response.Runs = runs
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, response)
}
// handleAdminRunTask starts one task by hand.
//
// It answers as soon as the run has *started*, not when it finishes: the console watches
// the run history for the outcome, and a button that blocked for the length of an
// overnight job would look like a page that had hung.
func (s *Server) handleAdminRunTask(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.PathValue("taskID"))
if err := s.scheduler.RunNow(r.Context(), id); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.loggerFor(r.Context()).Info("scheduled task started by hand", "task", id)
writeJSON(w, http.StatusAccepted, map[string]any{"started": true, "taskId": id})
}
type taskSettingsRequest struct {
Enabled *bool `json:"enabled"`
IntervalSeconds *int `json:"intervalSeconds"`
}
// handleAdminTaskSettings changes a task's enabled state or its cadence.
//
// Both fields are pointers so an unset one means "leave it alone": the console has two
// separate controls on a row, and a payload that carried the whole task would have each
// control silently reasserting whatever the page happened to be showing for the other.
func (s *Server) handleAdminTaskSettings(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.PathValue("taskID"))
var req taskSettingsRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "malformed request body")
return
}
if req.Enabled != nil {
if err := s.scheduler.SetEnabled(r.Context(), id, *req.Enabled); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.loggerFor(r.Context()).Info("scheduled task switched",
"task", id, "enabled", *req.Enabled)
}
if req.IntervalSeconds != nil {
interval := time.Duration(*req.IntervalSeconds) * time.Second
if err := s.scheduler.SetInterval(r.Context(), id, interval); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.loggerFor(r.Context()).Info("scheduled task interval changed",
"task", id, "interval_seconds", *req.IntervalSeconds)
}
s.handleAdminTasks(w, r)
}
+18
View File
@@ -22,6 +22,13 @@ func testServer(cfg config.Config) *Server {
return New(cfg, Deps{Log: slog.New(slog.NewTextHandler(io.Discard, nil))})
}
func TestStatusRecorderPreservesStreaming(t *testing.T) {
recorder := &statusRecorder{ResponseWriter: httptest.NewRecorder()}
if _, ok := any(recorder).(http.Flusher); !ok {
t.Fatal("logging response writer must preserve http.Flusher for Server-Sent Events")
}
}
func TestMaintenanceGatePassesTrafficWhenOnline(t *testing.T) {
server := testServer(config.Config{})
var reached bool
@@ -83,6 +90,17 @@ func TestMaintenanceGateFallsBackToADefaultMessage(t *testing.T) {
}
}
func TestBareAdminURLRedirectsToTheConsoleRoot(t *testing.T) {
server := testServer(config.Config{AdminToken: "secret"})
rec := httptest.NewRecorder()
server.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/admin", nil))
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/admin/" {
t.Fatalf("bare admin URL = %d %q, want 302 to /admin/", rec.Code, rec.Header().Get("Location"))
}
}
func TestHealthAndAdminStayReachableDuringMaintenance(t *testing.T) {
// Health checks and the admin page sit outside the gate on purpose: they are what
// you need most while the app is deliberately down.
+1 -1
View File
@@ -55,7 +55,7 @@ type journeyAnalyticsRequest struct {
var journeyCategories = allowedAnalyticsValues("session", "navigation", "content", "search", "playback", "settings", "recommendations", "library", "profile", "notifications")
var journeyActions = allowedAnalyticsValues(
"journey_start", "journey_end", "screen_view", "open", "close", "select",
"journey_start", "home_open", "journey_end", "screen_view", "open", "close", "select",
"submit", "request", "start", "stop", "complete", "abandon", "change",
"toggle", "follow", "unfollow", "favourite", "unfavourite", "mark_played",
"mark_unplayed", "retry", "dismiss", "switch",
+48 -1
View File
@@ -22,17 +22,20 @@ import (
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/adminevents"
"github.com/ponzischeme89/memby/server/internal/appupdate"
"github.com/ponzischeme89/memby/server/internal/bazarr"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/foryou"
"github.com/ponzischeme89/memby/server/internal/integrations"
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
"github.com/ponzischeme89/memby/server/internal/mdblist"
"github.com/ponzischeme89/memby/server/internal/opensubtitles"
"github.com/ponzischeme89/memby/server/internal/radarr"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/scheduler"
"github.com/ponzischeme89/memby/server/internal/sonarr"
"github.com/ponzischeme89/memby/server/internal/store"
)
@@ -51,7 +54,14 @@ type Server struct {
syncer syncerHandle
log *slog.Logger
events *serverlogging.Buffer
sonarrMu sync.Mutex
// adminEvents is the administrative feed: the console's notification bell and every
// outgoing integration read from it. Distinct from `events` above, which is the
// structured log ring — a log line is what the gateway did, an admin event is
// something an operator would want to be told.
adminEvents *adminevents.Bus
scheduler *scheduler.Scheduler
integrations *integrations.Dispatcher
sonarrMu sync.Mutex
// sonarrSeriesMu guards the catalogue cache separately from the calendar's, so an add
// to My Shows never waits behind a launcher rebuilding the schedule row.
sonarrSeriesMu sync.Mutex
@@ -85,6 +95,9 @@ type Server struct {
// embyHealth is the reachability probe's live finding, which /v1/status publishes so
// a TV can show why playback stopped even if it missed the announcement.
embyHealth embyHealth
// The console is a separate container the gateway proxies; see admin_spa.go.
adminUIHandle
}
// Deps are the collaborators the API needs. A struct rather than positional arguments:
@@ -102,6 +115,10 @@ type Deps struct {
Syncer syncerHandle
Log *slog.Logger
Events *serverlogging.Buffer
AdminEvents *adminevents.Bus
Scheduler *scheduler.Scheduler
Integrations *integrations.Dispatcher
}
func New(cfg config.Config, deps Deps) *Server {
@@ -119,9 +136,23 @@ func New(cfg config.Config, deps Deps) *Server {
syncer: deps.Syncer,
log: deps.Log,
events: deps.Events,
adminEvents: deps.AdminEvents,
scheduler: deps.Scheduler,
integrations: deps.Integrations,
}
}
// publishAdmin reports something an operator would want to know about.
//
// Every caller treats it as fire-and-forget, which is why it returns nothing: the feed is
// a convenience over things that are already logged, and a bell that could fail a sign-in
// would be worse than no bell. A server built without a bus — every unit test in this
// package — publishes into a nil receiver, which is a no-op.
func (s *Server) publishAdmin(ctx context.Context, event adminevents.Event) {
s.adminEvents.Publish(ctx, event)
}
func (s *Server) Routes() http.Handler {
// The client API lives on its own mux so maintenance mode can gate all of it at
// once, without the gate ever touching health checks or the admin page.
@@ -225,6 +256,12 @@ func (s *Server) Routes() http.Handler {
// Radarr pushes here when an import finishes. Outside the gate on purpose: an event
// arriving during maintenance would otherwise be lost rather than delayed.
mux.HandleFunc("POST /hooks/radarr", s.handleRadarrWebhook)
// State the canonical console URL explicitly. The console and its assets live below
// /admin/, while a bare /admin is routinely typed and some reverse proxies do not
// preserve ServeMux's implicit trailing-slash redirect for a mounted subtree.
mux.HandleFunc("GET /admin", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/", http.StatusFound)
})
mux.Handle("/admin/", s.adminRoutes())
mux.HandleFunc("GET /{$}", s.handleInstallPage)
mux.HandleFunc("GET /install", s.handleInstallPage)
@@ -441,6 +478,16 @@ func (r *statusRecorder) WriteHeader(code int) {
r.ResponseWriter.WriteHeader(code)
}
// Flush preserves streaming support through the request logger. In particular, the admin
// notification feed is Server-Sent Events and correctly refuses to start unless its writer
// implements http.Flusher. Embedding ResponseWriter alone does not promote optional
// interfaces, so the old recorder turned every stream request into a 500.
func (r *statusRecorder) Flush() {
if flusher, ok := r.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
// --- sessions ---------------------------------------------------------------
func bearerToken(r *http.Request) string {
+107
View File
@@ -3,10 +3,13 @@ package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/adminevents"
"github.com/ponzischeme89/memby/server/internal/cache"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
@@ -82,10 +85,47 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
"username", req.Username, "device", req.DeviceName, "device_id", req.DeviceID,
"reason", "emby refused the credentials",
)
// A refused attempt has no verified identity, so it carries the name that was
// typed and no user id. It is recorded precisely because a run of these against
// one name is the thing worth noticing, and nothing else in the gateway keeps it.
s.recordLogin(r, store.LoginEvent{
Username: req.Username,
DeviceID: req.DeviceID,
DeviceName: req.DeviceName,
Success: false,
Method: store.LoginMethodPassword,
// Emby's reason is deliberately not carried through: it distinguishes
// "no such user" from "wrong password", which is more than an operator's
// console should restate about somebody else's failed attempt.
FailureReason: "credentials refused",
})
s.publishAdmin(r.Context(), adminevents.Event{
Type: adminevents.TypeLoginFailed,
Severity: adminevents.SeverityWarning,
Title: "Sign-in refused",
Summary: fmt.Sprintf("%s was refused on %s",
displayName(req.Username), displayName(req.DeviceName)),
Actor: req.Username, Target: req.DeviceName,
Link: "/admin/logins",
Metadata: adminevents.Meta(map[string]any{
"deviceId": req.DeviceID, "ip": requestClientIP(r),
}),
})
writeError(w, http.StatusUnauthorized, "sign-in failed")
return
}
// Asked before the attempt is recorded, so this sign-in cannot answer for itself:
// "new device registered" is only distinguishable from every later sign-in by the
// same set if the history is consulted while it still predates this one.
knownDevice, lookupErr := s.store.DeviceHasLoggedIn(r.Context(), auth.User.ID, req.DeviceID)
if lookupErr != nil {
s.loggerFor(r.Context()).Warn("device history lookup failed", "error", lookupErr)
// Assume known. Announcing a device as new because a query failed is a claim; not
// announcing one is a missed line.
knownDevice = true
}
token, err := newToken()
if err != nil {
s.log.Error("token generation failed", "error", err)
@@ -143,6 +183,45 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
"replaced_session", len(created.ReplacedHash) > 0,
)
address := requestClientIP(r)
s.recordLogin(r, store.LoginEvent{
EmbyUserID: sess.EmbyUserID, Username: sess.Username,
DeviceID: sess.DeviceID, DeviceName: sess.DeviceName,
ClientVersion: sess.ClientVersion, ClientProtocol: sess.ClientProtocol,
Success: true, Method: store.LoginMethodPassword, NewDevice: !knownDevice,
})
// A television arriving for the first time and one signing in again are the same
// request and different news, which is why they are different event types rather than
// one type with a flag: an operator subscribing a Discord channel to new devices is
// asking for the rare one, and would not want the other.
if knownDevice {
s.publishAdmin(r.Context(), adminevents.Event{
Type: adminevents.TypeLogin,
Title: "Signed in",
Summary: fmt.Sprintf("%s signed in on %s",
displayName(sess.Username), displayName(sess.DeviceName)),
Actor: sess.Username, Target: sess.DeviceName,
Link: "/admin/devices/" + url.PathEscape(sess.DeviceID),
Metadata: adminevents.Meta(map[string]any{
"deviceId": sess.DeviceID, "userId": sess.EmbyUserID,
"ip": address, "version": sess.ClientVersion,
}),
})
} else {
s.publishAdmin(r.Context(), adminevents.Event{
Type: adminevents.TypeDeviceRegistered,
Title: "New device registered",
Summary: fmt.Sprintf("%s signed in on %s for the first time",
displayName(sess.Username), displayName(sess.DeviceName)),
Actor: sess.Username, Target: sess.DeviceName,
Link: "/admin/devices/" + url.PathEscape(sess.DeviceID),
Metadata: adminevents.Meta(map[string]any{
"deviceId": sess.DeviceID, "userId": sess.EmbyUserID,
"ip": address, "version": sess.ClientVersion,
}),
})
}
writeJSON(w, http.StatusOK, loginResponse{
Token: token, UserID: sess.EmbyUserID, Username: sess.Username, ServerID: sess.ServerID,
})
@@ -155,6 +234,15 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, sess store
_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(sess.TokenHash)))
_ = s.cache.InvalidateUser(r.Context(), sess.EmbyUserID)
s.loggerFor(r.Context()).Info("signed out", "device_id", sess.DeviceID)
s.publishAdmin(r.Context(), adminevents.Event{
Type: adminevents.TypeLogout,
Title: "Signed out",
Summary: fmt.Sprintf("%s signed out on %s",
displayName(sess.Username), displayName(sess.DeviceName)),
Actor: sess.Username, Target: sess.DeviceName,
Link: "/admin/devices/" + url.PathEscape(sess.DeviceID),
Metadata: adminevents.Meta(map[string]any{"deviceId": sess.DeviceID}),
})
w.WriteHeader(http.StatusNoContent)
}
@@ -212,6 +300,16 @@ func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request, curr
// A device disappearing from a household is worth a line: the next thing that TV
// reports is a sign-in, and the two together explain each other.
s.loggerFor(r.Context()).Info("device signed out remotely", "removed_device_id", deviceID)
s.publishAdmin(r.Context(), adminevents.Event{
Type: adminevents.TypeDeviceRemoved,
Severity: adminevents.SeverityWarning,
Title: "Device removed",
Summary: fmt.Sprintf("%s removed a device from their account",
displayName(current.Username)),
Actor: current.Username, Target: deviceID,
Link: "/admin/devices",
Metadata: adminevents.Meta(map[string]any{"deviceId": deviceID}),
})
w.WriteHeader(http.StatusNoContent)
}
@@ -289,5 +387,14 @@ func (s *Server) handleRenameDevice(w http.ResponseWriter, r *http.Request, curr
s.loggerFor(r.Context()).Info("device renamed",
"renamed_device_id", deviceID, "new_name", req.DeviceName,
)
s.publishAdmin(r.Context(), adminevents.Event{
Type: adminevents.TypeDeviceRenamed,
Title: "Device renamed",
Summary: fmt.Sprintf("%s renamed a device to %s",
displayName(current.Username), req.DeviceName),
Actor: current.Username, Target: req.DeviceName,
Link: "/admin/devices/" + url.PathEscape(deviceID),
Metadata: adminevents.Meta(map[string]any{"deviceId": deviceID}),
})
w.WriteHeader(http.StatusNoContent)
}
+44 -1
View File
@@ -679,6 +679,10 @@ func (s *Server) heroRow(
policy = store.HeroPolicy{}
}
pinned := s.pinnedHeroCandidates(ctx, policy.PinnedItemIDs)
// Manual pins always lead. Schedules resolve on the gateway (never on a television),
// then the existing automatic/release-aware selection fills any remaining places.
scheduledIDs := activeHeroScheduleIDs(policy.Schedules, userID, now, location)
scheduled := s.pinnedHeroCandidates(ctx, scheduledIDs)
// Rank a pool, then draw the row out of it. Ranking straight to the row's length is
// what made the hero the same four cards for a week — see rotateHeroCandidates.
pool := rankHeroCandidates(candidates, now, heroPoolLimit)
@@ -687,7 +691,7 @@ func (s *Server) heroRow(
heroVariationSeed(userID, heroRotationSlot(now, location)),
heroRowLimit,
)
ranked := mergePinnedHeroCandidates(pinned, candidates, organic, heroRowLimit)
ranked := mergePinnedHeroCandidates(append(pinned, scheduled...), candidates, organic, heroRowLimit)
if len(ranked) == 0 {
return nil
}
@@ -746,6 +750,45 @@ func mergePinnedHeroCandidates(
return out
}
func activeHeroScheduleIDs(schedules []store.HeroSchedule, userID string, now time.Time, location *time.Location) []string {
type active struct {
id string
priority int
start time.Time
}
matched := []active{}
for _, schedule := range schedules {
if !schedule.Enabled || (schedule.UserID != "" && schedule.UserID != userID) || now.Before(schedule.StartAt) || !now.Before(schedule.EndAt) {
continue
}
if len(schedule.Weekdays) > 0 {
weekday := int(now.In(location).Weekday())
found := false
for _, day := range schedule.Weekdays {
if day == weekday {
found = true
break
}
}
if !found {
continue
}
}
matched = append(matched, active{schedule.ItemID, schedule.Priority, schedule.StartAt})
}
sort.SliceStable(matched, func(i, j int) bool {
if matched[i].priority != matched[j].priority {
return matched[i].priority > matched[j].priority
}
return matched[i].start.After(matched[j].start)
})
ids := make([]string, 0, len(matched))
for _, item := range matched {
ids = append(ids, item.id)
}
return ids
}
// pinnedHeroCandidates resolves policy against the imported catalogue. A deleted or
// unsupported id quietly drops out, so an old admin choice can never make Home fail.
func (s *Server) pinnedHeroCandidates(ctx context.Context, ids []string) []heroCandidate {
+8 -5
View File
@@ -17,8 +17,9 @@ type heroAdminItem struct {
}
type heroAdminPolicy struct {
PinnedItems []heroAdminItem `json:"pinnedItems"`
PrimeSubtitle string `json:"primeSubtitle"`
PinnedItems []heroAdminItem `json:"pinnedItems"`
PrimeSubtitle string `json:"primeSubtitle"`
Schedules []store.HeroSchedule `json:"schedules"`
}
func adminHeroItem(raw json.RawMessage) (heroAdminItem, bool) {
@@ -50,6 +51,7 @@ func (s *Server) heroAdminPolicy(ctx context.Context) heroAdminPolicy {
out := heroAdminPolicy{
PinnedItems: make([]heroAdminItem, 0, len(policy.PinnedItemIDs)),
PrimeSubtitle: policy.PrimeSubtitle,
Schedules: policy.Schedules,
}
for _, id := range policy.PinnedItemIDs {
if item, ok := byID[id]; ok {
@@ -77,8 +79,9 @@ func (s *Server) handleAdminHeroSearch(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) {
var request struct {
PinnedItemIDs []string `json:"pinnedItemIds"`
PrimeSubtitle string `json:"primeSubtitle"`
PinnedItemIDs []string `json:"pinnedItemIds"`
PrimeSubtitle string `json:"primeSubtitle"`
Schedules []store.HeroSchedule `json:"schedules"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
writeError(w, http.StatusBadRequest, "invalid hero policy")
@@ -107,7 +110,7 @@ func (s *Server) handleAdminHeroPolicy(w http.ResponseWriter, r *http.Request) {
return
}
}
policy := store.HeroPolicy{PinnedItemIDs: ids, PrimeSubtitle: request.PrimeSubtitle}
policy := store.HeroPolicy{PinnedItemIDs: ids, PrimeSubtitle: request.PrimeSubtitle, Schedules: request.Schedules}
if err := s.store.SetHeroPolicy(r.Context(), policy); err != nil {
s.loggerFor(r.Context()).Error("hero policy write failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not save hero policy")
+167
View File
@@ -0,0 +1,167 @@
package api
import (
"context"
"fmt"
"time"
"github.com/ponzischeme89/memby/server/internal/scheduler"
"github.com/ponzischeme89/memby/server/internal/store"
)
// RegisterHousekeeping declares the gateway's background jobs.
//
// This list is deliberately readable top to bottom: it is the answer to "what does the
// server do when nobody is watching", and before the scheduler existed that answer was
// spread across four `go someTicker(ctx, …)` calls in main with no shared vocabulary,
// no history and no way for an operator to run one by hand. Two of the jobs below —
// analytics retention and the idle-session sweep — were exactly those tickers.
//
// Each Run returns the sentence the console prints beside the run, and returns an *empty*
// one when nothing happened. That emptiness is what keeps the notification bell quiet:
// see scheduler.announce.
func (s *Server) RegisterHousekeeping(sched *scheduler.Scheduler) {
if sched == nil {
return
}
sched.Register(scheduler.Task{
ID: "login-retention",
Name: "Login history retention",
Group: "Housekeeping",
Description: fmt.Sprintf("Removes sign-in records older than %d days.",
int(store.LoginRetention/(24*time.Hour))),
Interval: 24 * time.Hour,
Run: func(ctx context.Context) (string, error) {
removed, err := s.store.PruneLoginEvents(ctx, store.LoginRetention)
return countDetail(removed, "sign-in record"), err
},
})
sched.Register(scheduler.Task{
ID: "notification-cleanup",
Name: "Notification cleanup",
Group: "Housekeeping",
Description: fmt.Sprintf("Removes administrative events older than %d days.",
int(store.AdminEventRetention/(24*time.Hour))),
Interval: 24 * time.Hour,
Run: func(ctx context.Context) (string, error) {
removed, err := s.store.PruneAdminEvents(ctx, store.AdminEventRetention)
return countDetail(removed, "notification"), err
},
})
sched.Register(scheduler.Task{
ID: "integration-cleanup",
Name: "Integration delivery cleanup",
Group: "Housekeeping",
Description: "Keeps the most recent hundred delivery attempts for each integration.",
Interval: 12 * time.Hour,
Run: func(ctx context.Context) (string, error) {
removed, err := s.store.PruneIntegrationDeliveries(ctx, 100)
return countDetail(removed, "delivery record"), err
},
})
sched.Register(scheduler.Task{
ID: "task-history-cleanup",
Name: "Task history cleanup",
Group: "Housekeeping",
Description: "Removes this table's own old rows, so the scheduler cannot outgrow the database it reports from.",
Interval: 24 * time.Hour,
Run: func(ctx context.Context) (string, error) {
removed, err := s.store.PruneTaskRuns(ctx, store.TaskRunRetention)
return countDetail(removed, "task run"), err
},
})
// Was pruneAnalytics in main. Retention is configuration rather than a constant here
// because engagement telemetry is the one dataset an operator might genuinely want to
// keep for a season or discard within a week.
if s.cfg.AnalyticsRetention > 0 {
sched.Register(scheduler.Task{
ID: "analytics-retention",
Name: "Analytics retention",
Group: "Analytics",
Description: fmt.Sprintf(
"Removes row engagement and journey events older than %d days.",
int(s.cfg.AnalyticsRetention/(24*time.Hour))),
Interval: 24 * time.Hour,
Run: func(ctx context.Context) (string, error) {
removed, err := s.store.PruneRowEvents(ctx, s.cfg.AnalyticsRetention)
return countDetail(removed, "analytics event"), err
},
})
}
// Was sweepIdleSessions in main. It matters more than it looks: a session row holds a
// live Emby token, so a television that was factory-reset leaves working upstream
// credentials in the database until this runs.
sched.Register(scheduler.Task{
ID: "session-sweep",
Name: "Idle session sweep",
Group: "Housekeeping",
Description: fmt.Sprintf(
"Retires gateway tokens unused for %d days, along with the Emby token each one holds.",
int(s.cfg.SessionIdleExpiry/(24*time.Hour))),
Interval: 6 * time.Hour,
Run: func(ctx context.Context) (string, error) {
removed, err := s.store.DeleteIdleSessions(ctx, s.cfg.SessionIdleExpiry)
return countDetail(removed, "idle session"), err
},
})
// Not a prune: Redis is configured with no persistence and an LRU eviction policy, so
// nothing here has to delete anything. What it checks is that the cache is *reachable*
// — a gateway whose Redis has gone away still serves every page, slowly, with no error
// anybody sees, and this is the one thing that would say so.
sched.Register(scheduler.Task{
ID: "cache-check",
Name: "Cache health check",
Group: "System",
Description: "Confirms Redis is answering. Nothing is deleted: the cache evicts by itself.",
Interval: 30 * time.Minute,
Timeout: 30 * time.Second,
Run: func(ctx context.Context) (string, error) {
if s.cache == nil {
return "", nil
}
if err := s.cache.Ping(ctx); err != nil {
return "", fmt.Errorf("redis did not answer: %w", err)
}
// A successful check reports nothing, so a healthy cache is silent and only a
// failure reaches the notification feed.
return "", nil
},
})
sched.Register(scheduler.Task{
ID: "database-check",
Name: "Database health check",
Group: "System",
Description: "Confirms Postgres is answering.",
Interval: 30 * time.Minute,
Timeout: 30 * time.Second,
Run: func(ctx context.Context) (string, error) {
if err := s.store.Ping(ctx); err != nil {
return "", fmt.Errorf("postgres did not answer: %w", err)
}
return "", nil
},
})
}
// countDetail is the one line a housekeeping run reports, and returns empty for zero.
//
// Empty is not a formatting nicety — it is what stops a job running every six hours
// announcing "0 removed" into the operator's notification bell every six hours, which is
// how a feed becomes something nobody reads.
func countDetail(count int64, noun string) string {
if count <= 0 {
return ""
}
if count == 1 {
return "1 " + noun + " removed"
}
return fmt.Sprintf("%d %ss removed", count, noun)
}
+24 -7
View File
@@ -261,16 +261,33 @@ func (s *Server) handleInstallLogout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/install", http.StatusSeeOther)
}
// cleanInstallerDestination decides where a sign-in may return to.
//
// It admits any /admin path other than the API, because the console is a single-page
// application now and its routes are its own: there is no list in Go to check them
// against, and there should not be — a page added to the console would otherwise have to
// be declared here as well, and the failure when somebody forgot would be a sign-in that
// silently landed on the wrong screen. That was already the case for the account and
// settings-history pages, whose URLs carry an id: neither could be named here, so signing
// in from either dropped the operator back on the user list.
//
// What it must still refuse is anything that is not a path on this origin — an absolute
// URL, a scheme-relative //host, or a backslash some browsers normalise into one — since
// this value ends up in a redirect and an open redirect from an admin sign-in is a real
// one. Everything that is not clearly an admin path falls back to the installer.
func cleanInstallerDestination(value string) string {
value = strings.TrimSpace(value)
if value == "/admin/" {
if !strings.HasPrefix(value, "/admin") {
return "/install"
}
if strings.HasPrefix(value, "//") || strings.ContainsAny(value, "\\\r\n") {
return "/install"
}
if strings.HasPrefix(value, "/admin/api/") {
return "/admin/"
}
if strings.HasPrefix(value, "/admin/") {
page := strings.TrimPrefix(value, "/admin/")
if adminPages[page] {
return value
}
if value == "/admin" {
return "/admin/"
}
return "/install"
return value
}
+264
View File
@@ -0,0 +1,264 @@
package api
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
// displayName is what an event summary calls somebody or something that did not say.
// A sentence reading " signed in on " is worse than one naming the gap.
func displayName(value string) string {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
return "an unnamed device"
}
// recordLogin writes one attempt to the durable history.
//
// Detached from the request context and fire-and-forget, for the reason every audit write
// on a request path is: a sign-in that succeeded must not be undone because the history
// row could not be written, and a sign-in being refused is already being refused. The
// address and the build are filled in here rather than by each caller, so an attempt
// recorded from a new route cannot quietly omit them.
func (s *Server) recordLogin(r *http.Request, event store.LoginEvent) {
if s.store == nil {
return
}
if event.IPAddress == "" {
event.IPAddress = requestClientIP(r)
}
if event.ClientVersion == "" {
event.ClientVersion = clientVersion(r)
}
if event.ClientProtocol == "" {
event.ClientProtocol = clientProtocol(r)
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second)
go func() {
defer cancel()
if err := s.store.RecordLogin(ctx, event); err != nil {
s.log.Warn("login not recorded",
"device_id", event.DeviceID, "success", event.Success, "error", err)
}
}()
}
// loginFilterFrom reads the console's filter bar off the query string.
//
// Every control is optional and an absent one means "any", which is what lets the page
// combine them freely — user and device and address and a date range — without the server
// needing a case per combination. Dates arrive as RFC3339 or as a plain YYYY-MM-DD, and a
// plain date is read in the *household's* zone: an operator asking for "13 August" means
// their own day, and reading it as UTC would silently shift the window by twelve hours.
func (s *Server) loginFilterFrom(r *http.Request) store.LoginFilter {
query := r.URL.Query()
filter := store.LoginFilter{
EmbyUserID: strings.TrimSpace(query.Get("user")),
DeviceID: strings.TrimSpace(query.Get("device")),
IPAddress: strings.TrimSpace(query.Get("ip")),
Query: strings.TrimSpace(query.Get("q")),
Outcome: strings.TrimSpace(query.Get("outcome")),
Method: strings.TrimSpace(query.Get("method")),
Limit: queryInt(r, "limit", 100, 500),
Offset: queryInt(r, "offset", 0, 100000),
}
filter.From = s.parseFilterDate(query.Get("from"), false)
filter.To = s.parseFilterDate(query.Get("to"), true)
if days := queryInt(r, "days", 0, 365); days > 0 && filter.From.IsZero() {
filter.From = time.Now().Add(-time.Duration(days) * 24 * time.Hour)
}
return filter
}
// parseFilterDate reads one end of a range. endOfDay pushes a plain date to the following
// midnight, because "to: 13 August" means through the end of the 13th — the alternative
// silently excludes everything that happened on the day the operator asked about.
func (s *Server) parseFilterDate(raw string, endOfDay bool) time.Time {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}
}
if parsed, err := time.Parse(time.RFC3339, raw); err == nil {
return parsed
}
location := s.cfg.SonarrLocation
if location == nil {
location = time.UTC
}
parsed, err := time.ParseInLocation("2006-01-02", raw, location)
if err != nil {
return time.Time{}
}
if endOfDay {
return parsed.AddDate(0, 0, 1)
}
return parsed
}
// zoneName is the household's timezone, which every day-grouping query needs. Postgres is
// asked to do the grouping in it rather than the console doing it in the browser's zone,
// for the reason store.LoginDays gives.
func (s *Server) zoneName() string {
if s.cfg.SonarrLocation != nil {
return s.cfg.SonarrLocation.String()
}
return "UTC"
}
type adminLoginsResponse struct {
Events []store.LoginEvent `json:"events"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Totals store.LoginTotals `json:"totals"`
Days []store.LoginDay `json:"days"`
Addresses []store.LoginAddress `json:"addresses"`
Users []store.KnownUser `json:"users"`
Retention int `json:"retentionDays"`
Zone string `json:"timezone"`
}
// handleAdminLogins is the login history page: the filtered log, the totals over the same
// filter, the daily shape and where the attempts came from, in one response.
//
// One response rather than four routes because they are four views of one filter, and a
// page that fetched them separately could show a chart of one window beside a table of
// another the moment a filter changed between requests.
func (s *Server) handleAdminLogins(w http.ResponseWriter, r *http.Request) {
filter := s.loginFilterFrom(r)
page, err := s.store.LoginEvents(r.Context(), filter)
if err != nil {
s.log.Error("login history failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read the login history")
return
}
response := adminLoginsResponse{
Events: page.Events, Total: page.Total, Limit: page.Limit, Offset: page.Offset,
Retention: int(store.LoginRetention / (24 * time.Hour)), Zone: s.zoneName(),
Days: []store.LoginDay{}, Addresses: []store.LoginAddress{},
Users: []store.KnownUser{},
}
// The three summaries are decoration on the table: a failure in any of them costs a
// chart, never the history the operator opened the page for.
if totals, err := s.store.LoginTotals(r.Context(), filter); err == nil {
response.Totals = totals
}
if days, err := s.store.LoginDays(r.Context(), filter, s.zoneName()); err == nil {
response.Days = days
}
if addresses, err := s.store.LoginAddresses(r.Context(), filter, 25); err == nil {
response.Addresses = addresses
}
if users, err := s.store.KnownUsers(r.Context()); err == nil {
response.Users = users
}
writeJSON(w, http.StatusOK, response)
}
type adminLoginDevicesResponse struct {
Devices []store.LoginDeviceSummary `json:"devices"`
Totals store.LoginTotals `json:"totals"`
Users []store.KnownUser `json:"users"`
Zone string `json:"timezone"`
Retention int `json:"retentionDays"`
}
// handleAdminLoginDevices is the devices table, built from the history rather than from
// the session list — see store.LoginDeviceSummaries for why a removed television still
// belongs on it.
func (s *Server) handleAdminLoginDevices(w http.ResponseWriter, r *http.Request) {
filter := s.loginFilterFrom(r)
devices, err := s.store.LoginDeviceSummaries(r.Context(), filter, s.zoneName())
if err != nil {
s.log.Error("login device summary failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not summarise devices")
return
}
response := adminLoginDevicesResponse{
Devices: devices, Zone: s.zoneName(), Users: []store.KnownUser{},
Retention: int(store.LoginRetention / (24 * time.Hour)),
}
if totals, err := s.store.LoginTotals(r.Context(), filter); err == nil {
response.Totals = totals
}
if users, err := s.store.KnownUsers(r.Context()); err == nil {
response.Users = users
}
writeJSON(w, http.StatusOK, response)
}
type adminDeviceDetailResponse struct {
DeviceID string `json:"deviceId"`
Summary *store.LoginDeviceSummary `json:"summary,omitempty"`
Events []store.LoginEvent `json:"events"`
Total int `json:"total"`
Days []store.LoginDay `json:"days"`
Addresses []store.LoginAddress `json:"addresses"`
Versions []store.DeviceVersion `json:"versions"`
Zone string `json:"timezone"`
}
// handleAdminDeviceDetail answers the question the whole feature exists for: how many
// times did this television connect, at what times, and from which addresses.
func (s *Server) handleAdminDeviceDetail(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimSpace(r.PathValue("deviceID"))
if deviceID == "" {
writeError(w, http.StatusBadRequest, "device id is required")
return
}
filter := s.loginFilterFrom(r)
// The path wins over the query string: this route is *about* one device, and a filter
// naming another would render a page describing something other than its own URL.
filter.DeviceID = deviceID
page, err := s.store.LoginEvents(r.Context(), filter)
if err != nil {
s.log.Error("device login history failed", "error", err)
writeError(w, http.StatusInternalServerError, "could not read the device history")
return
}
response := adminDeviceDetailResponse{
DeviceID: deviceID, Events: page.Events, Total: page.Total,
Zone: s.zoneName(), Days: []store.LoginDay{},
Addresses: []store.LoginAddress{}, Versions: []store.DeviceVersion{},
}
// The summary is over the *unfiltered* history for this device, so the headline
// counts describe the television rather than whatever window is being looked at.
if summaries, err := s.store.LoginDeviceSummaries(
r.Context(), store.LoginFilter{DeviceID: deviceID}, s.zoneName(),
); err == nil && len(summaries) > 0 {
response.Summary = &summaries[0]
}
if days, err := s.store.LoginDays(r.Context(), filter, s.zoneName()); err == nil {
response.Days = days
}
if addresses, err := s.store.LoginAddresses(r.Context(), filter, 25); err == nil {
response.Addresses = addresses
}
if history, err := s.store.DeviceVersions(r.Context(), []string{deviceID}); err == nil {
if versions := history[deviceID]; versions != nil {
response.Versions = versions
}
}
writeJSON(w, http.StatusOK, response)
}
// queryInt64s reads a repeated or comma-separated integer parameter, which is how the
// notification feed is told which events to mark read.
func queryInt64s(r *http.Request, name string) []int64 {
values := []int64{}
for _, raw := range r.URL.Query()[name] {
for _, part := range strings.Split(raw, ",") {
if parsed, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64); err == nil {
values = append(values, parsed)
}
}
}
return values
}