0.1.38 gateway
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[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>
|
||||
@@ -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 person’s 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 person’s 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'));
|
||||
});
|
||||
});
|
||||
@@ -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 Memby’s 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 Memby’s 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>
|
||||
@@ -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>
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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) })));
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}),
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user