App v0.2.26 and gateway 0.1.20

Client: seek controls, Bazarr subtitle download and cast panel in the
player; MDBList ratings strip; episode and schedule detail pages; series
pace estimate; what's new panel; install-permission onboarding step;
synced per-profile preferences; Emby outage banner.

Gateway: rebuilt admin console (one fragment per page), preference
history and restore, merged Continue Watching, Emby health probe,
subtitle selection and Bazarr download, structured request logging with
per-request identity, and embedded build version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ponzischeme89
2026-08-06 22:33:56 +12:00
co-authored by Claude Opus 5
parent 2675e6d82b
commit 4a4df7a73c
257 changed files with 24868 additions and 3108 deletions
+486
View File
@@ -0,0 +1,486 @@
/* 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 {
margin: 16px 12px 6px; color: var(--quiet);
font-size: 10px; font-weight: 700; letter-spacing: .14em; text-transform: uppercase;
}
.rail-nav { display: grid; gap: 2px; }
.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: flex; align-items: center; gap: 9px;
padding: 12px 12px 0; border-top: 1px solid var(--line);
}
.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; }
/* ---------- 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)); }
.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 { display: none; }
.rail-link { justify-content: center; padding: 0; min-height: 38px; }
.rail-foot { justify-content: center; padding: 12px 0 0; }
.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; }
}
+289
View File
@@ -0,0 +1,289 @@
/* 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 thirty minutes and slides forward only for requests
// an operator actually caused, so the poll of a tab nobody is reading cannot keep it
// alive. Anything the console does while somebody is working it says so with this
// header; see operatorPresent on the server.
const ACTIVITY_WINDOW_MS = 5 * 60 * 1000;
let lastInteraction = Date.now();
for (const name of ['pointerdown', 'pointermove', 'keydown', 'wheel', 'scroll']) {
window.addEventListener(name, () => { lastInteraction = Date.now(); }, { passive: true });
}
// A 401 is an expired sign-in rather than a wrong token. Reloading re-renders this URL as
// the login form with `next` pointing back at it, so the operator signs in once and lands
// where they were, instead of reading a banner the page can never clear. The timestamp is
// what stops a 401 that survives the reload from looping.
const RELOGIN_KEY = 'memby-admin-relogin';
function reauthenticate() {
try {
if (Date.now() - Number(sessionStorage.getItem(RELOGIN_KEY) || 0) < 30000) return false;
sessionStorage.setItem(RELOGIN_KEY, String(Date.now()));
} catch (err) {
// Private-mode storage refusals must not cost the reload; take the loop risk.
}
window.location.reload();
return true;
}
async function api(path, options = {}) {
const active = Date.now() - lastInteraction < ACTIVITY_WINDOW_MS;
const response = await fetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
...(active ? { 'X-Memby-Admin-Active': '1' } : {}),
...(options.headers || {}),
},
});
if (response.status === 401) {
throw new Error(reauthenticate()
? 'Your sign-in has expired. Signing in again…'
: 'Your sign-in has expired. Reload this page to sign in again.');
}
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || ('Request failed (' + response.status + ')'));
}
return response.status === 204 ? null : response.json();
}
/* ---- formatting ------------------------------------------------------ */
const escape = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[char]));
const number = (value) => (value ?? 0).toLocaleString();
const when = (value) => (value ? new Date(value).toLocaleString() : '—');
const time = (value) => (value ? new Date(value).toLocaleTimeString() : '—');
function duration(ms) {
if (!ms) return '0s';
const seconds = Math.round(ms / 1000);
if (seconds < 60) return seconds + 's';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return minutes + 'm ' + (seconds % 60) + 's';
return Math.floor(minutes / 60) + 'h ' + (minutes % 60) + 'm';
}
function bytes(value) {
const units = ['B', 'KB', 'MB', 'GB'];
let amount = Number(value || 0);
let unit = 0;
while (amount >= 1024 && unit < units.length - 1) { amount /= 1024; unit += 1; }
return (unit === 0 ? amount : amount.toFixed(1)) + ' ' + units[unit];
}
const initials = (name) => String(name || '?').trim().split(/\s+/).slice(0, 2)
.map((part) => part[0] || '').join('').toUpperCase();
// "Seen in the last quarter of an hour" is what the console means by active: a television
// checks in every few seconds while somebody is using it.
const ACTIVE_MS = 15 * 60 * 1000;
const IDLE_MS = 7 * 24 * 60 * 60 * 1000;
const recent = (value) => Boolean(value) && Date.now() - new Date(value).getTime() < ACTIVE_MS;
// Three states rather than two, because "not active this minute" covers both a set
// somebody switched off after breakfast and one that has not been seen since a firmware
// update in March — and only the second is worth an operator's attention. Green is on
// now, amber is a set in ordinary use that happens to be off, red is one that has
// stopped checking in. A device with no timestamp at all is red: never seen is the
// strongest version of not seen.
function presence(value) {
const seen = value ? new Date(value).getTime() : 0;
if (!seen) return { tone: 'bad', label: 'never seen' };
const age = Date.now() - seen;
if (age < ACTIVE_MS) return { tone: 'ok', label: 'active now' };
if (age < IDLE_MS) return { tone: 'warn', label: 'seen recently' };
return { tone: 'bad', label: 'not seen lately' };
}
const fmt = { escape, number, when, time, duration, bytes, initials, recent, presence };
/* ---- markup components ----------------------------------------------- */
/* Icons live here and nowhere else. Each is the `d` of one stroked path on a 24×24 grid,
the same shape the rail's marks take, so a page never carries SVG markup of its own and
two screens showing the same idea cannot draw it two ways. A fragment asks for one by
writing data-icon="…" on any element; a script asks with ui.icon(). An unknown name
draws nothing rather than a broken box — a mark is decoration, and a typo in one must
never be what an operator notices about a page. */
const icons = {
library: 'M3 5h18v14H3zM7 5v14M17 5v14M3 9.5h4M3 14.5h4M17 9.5h4M17 14.5h4',
people: 'M15 19v-1.2a3.3 3.3 0 0 0-3.3-3.3H6.8A3.3 3.3 0 0 0 3.5 17.8V19M9.2 11a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4ZM17 10.6a3 3 0 0 0-1.4-5.7M20.5 19v-1.2a3.3 3.3 0 0 0-2.4-3.2',
person: 'M18 20v-1.5a4 4 0 0 0-4-4h-4a4 4 0 0 0-4 4V20M12 10.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z',
tv: 'M4 5h16v10H4zM9 19h6M12 15v4M8 2.5 12 5l4-2.5',
sliders: 'M4 7h10M18 7h2M4 17h2m4 0h10M14 4v6M6 14v6',
clock: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM12 7.2V12l3 1.8',
pulse: 'M3 12h3.5L9 19l5-14 2.5 7H21',
chart: 'M4 19V9m5 10V5m5 14v-7m5 7V3',
chip: 'M8 8h8v8H8zM4.5 4.5h15v15h-15zM9 2v2.5M15 2v2.5M9 19.5V22M15 19.5V22M2 9h2.5M2 15h2.5M19.5 9H22M19.5 15H22',
database: 'M12 8.2c4.4 0 8-1.2 8-2.6S16.4 3 12 3 4 4.2 4 5.6s3.6 2.6 8 2.6ZM4 5.6v12.8C4 19.8 7.6 21 12 21s8-1.2 8-2.6V5.6M4 12c0 1.4 3.6 2.6 8 2.6s8-1.2 8-2.6',
download: 'M12 3.5v10m0 0 4-4m-4 4-4-4M4.5 18h15',
sync: 'M3.5 12a8.5 8.5 0 0 1 14.6-6M20.5 12a8.5 8.5 0 0 1-14.6 6M18 2.5V6h-3.5M6 21.5V18h3.5',
search: 'M10.5 17a6.5 6.5 0 1 1 0-13 6.5 6.5 0 0 1 0 13Zm4.6-1.9L20 20',
star: 'm12 3.2 2.6 5.4 5.9.8-4.3 4.1 1 5.9-5.2-2.8-5.2 2.8 1-5.9L3.5 9.4l5.9-.8L12 3.2Z',
sparkle: 'm10 3 1.5 4.3L16 8.8l-4.5 1.5L10 14.6 8.5 10.3 4 8.8l4.5-1.5L10 3ZM17.5 14l.9 2.4 2.6.9-2.6.9-.9 2.4-.9-2.4-2.6-.9 2.6-.9.9-2.4Z',
bell: 'M6.2 9.5a5.8 5.8 0 1 1 11.6 0c0 4.6 2.2 5.9 2.2 5.9H4s2.2-1.3 2.2-5.9M10 19.5a2 2 0 0 0 4 0',
shield: 'm12 3 7.5 3v5.4c0 5-3.2 8.2-7.5 9.6-4.3-1.4-7.5-4.6-7.5-9.6V6L12 3Zm-2.6 8.7 1.9 1.9 3.6-3.6',
wrench: 'm14.5 6.5 3-3 3 3-3 3M9 15l-5.5 5.5M13 4a5 5 0 0 0 6.5 6.5L10 20l-6-6 9.5-9.5Z',
play: 'M8 5.2v13.6L19 12 8 5.2ZM4 5v14',
list: 'M4 7h16M4 12h16M4 17h10',
inbox: 'M4 7h16v13H4zM8 4h8v3M8 12h8M8 16h5',
history: 'M3.5 12a8.5 8.5 0 1 0 2.8-6.3M3.5 4v4h4M12 7.5V12l3 1.8',
check: 'm5 12.5 4.5 4.5L19 7.5',
alert: 'M12 8.5v5m0 3.2h.01M10.3 4.4 2.7 17.5a2 2 0 0 0 1.7 3h15.2a2 2 0 0 0 1.7-3L13.7 4.4a2 2 0 0 0-3.4 0Z',
power: 'M12 3v9M7.5 6.2a7.5 7.5 0 1 0 9 0',
key: 'M14.5 3a6.5 6.5 0 1 0 3.4 12L19 14h2v-2h2V9.5l-2.5-2.5A6.5 6.5 0 0 0 14.5 3Zm-2.6 4.6a1.6 1.6 0 1 1-2.3 2.3 1.6 1.6 0 0 1 2.3-2.3Z',
};
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();
});
ready(() => { decorate(); refresh(); schedule(); });
return {
page, api, fmt, ui, $, error, settled, fill, check,
onStatus, onRefresh, ready, refresh, act, decorate,
};
})();
@@ -0,0 +1,58 @@
<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">
<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>
+284
View File
@@ -0,0 +1,284 @@
/* 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 = [];
// 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('');
}
/* ---- 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;
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);
});
/* ---- actions ------------------------------------------------------------ */
function message(text) { $('account-settings-message').textContent = text || ''; }
document.addEventListener('input', (event) => {
if (!$('account-settings').contains(event.target)) return;
dirty = true;
message('unsaved changes');
});
document.addEventListener('click', (event) => {
const button = event.target.closest('[data-account-action]');
if (!button) return;
const action = button.dataset.accountAction;
if (action === 'rename-device') {
const name = prompt('Name this Memby device', button.dataset.deviceName || 'Memby TV');
if (name === null || !name.trim()) return;
Admin.act(() => Admin.api(base + '/devices/' + encodeURIComponent(button.dataset.deviceId), {
method: 'PUT', body: JSON.stringify({ deviceName: name.trim() }),
}));
}
if (action === 'remove-device') {
if (!confirm('Sign this device out of Memby? Its Emby account will not be changed.')) return;
Admin.act(() => Admin.api(base + '/devices/' +
encodeURIComponent(button.dataset.deviceId), { method: 'DELETE' }));
}
if (action === 'remove-account') {
if (!confirm('Remove Memby access for ' + ($('page-title').textContent || 'this user') +
'? Every Memby device will be signed out. Their Emby account will not be changed.')) return;
Admin.act(async () => {
await Admin.api(base + '/sessions', { method: 'DELETE' });
location.href = '/admin/accounts';
});
}
if (action === 'reset-recommendations') {
if (!confirm('Clear this persons stored recommendation choices? Viewing history remains intact.')) return;
Admin.act(() => Admin.api(base + '/recommendations', { method: 'DELETE' }));
}
if (action === 'prompt-recommendations') {
Admin.act(() => Admin.api(base + '/recommendations/prompt', { method: 'PUT' }));
}
if (action === 'cancel-recommendation-prompt') {
if (!confirm('Cancel this persons queued recommendation prompt?')) return;
Admin.act(() => Admin.api(base + '/recommendations', { method: 'DELETE' }));
}
if (action === 'push-preferences') {
const preferences = collectSettings();
message('pushing…');
Admin.act(async () => {
await Admin.api(base + '/preferences', {
method: 'PUT', body: JSON.stringify({ preferences }),
});
// Cleared before the refresh, so the form is redrawn from what the server actually
// stored rather than from what was submitted.
dirty = false;
message('pushed');
});
}
if (action === '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' }));
}
});
@@ -0,0 +1,13 @@
<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>
@@ -0,0 +1,52 @@
/* 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.');
});
@@ -0,0 +1,20 @@
<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>
@@ -0,0 +1,41 @@
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.');
});
@@ -0,0 +1,26 @@
<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" selected>7 days</option>
<option value="30">30 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>
@@ -0,0 +1,18 @@
const { fmt, ui, $ } = Admin;
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(row.rowId) + '</td>' +
'<td class="muted">' + fmt.escape(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));
@@ -0,0 +1,23 @@
<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>
@@ -0,0 +1,93 @@
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'));
});
});
@@ -0,0 +1,16 @@
<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>
@@ -0,0 +1,16 @@
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.');
});
@@ -0,0 +1,39 @@
<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>
@@ -0,0 +1,123 @@
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));
@@ -0,0 +1,15 @@
<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>
@@ -0,0 +1,29 @@
const { fmt, ui, $ } = Admin;
Admin.onStatus((status) => {
const byType = status.library.byType || {};
$('library-tiles').innerHTML = ui.tiles([
['items', fmt.number(status.library.total), { icon: 'library', tone: 'data' }],
...Object.keys(byType).sort().map((type) => [type, fmt.number(byType[type]), { icon: 'list' }]),
['last import', fmt.when(status.library.lastSynced), { small: true, icon: 'clock' }],
]);
const running = status.syncRunning;
$('sync-incremental').disabled = running;
$('sync-full').disabled = running;
$('sync-hint').textContent = running
? 'Import running…'
: 'An incremental import runs automatically every ' + status.syncEvery + '.';
});
const sync = (kind) => Admin.api('/admin/api/sync', {
method: 'POST', body: JSON.stringify({ kind }),
});
Admin.ready(() => {
$('sync-incremental').addEventListener('click', () => Admin.act(() => sync('incremental')));
$('sync-full').addEventListener('click', () => {
if (!confirm('Re-import the entire library? This can take several minutes.')) return;
Admin.act(() => sync('full'));
});
});
+21
View File
@@ -0,0 +1,21 @@
<section class="card">
<div class="row bottom">
<label class="field narrow"><span>Level</span>
<select id="log-level" aria-label="Minimum event level">
<option value="DEBUG">Debug and above</option>
<option value="INFO" selected>Info and above</option>
<option value="WARN">Warnings and errors</option>
<option value="ERROR">Errors only</option>
</select></label>
<label class="field grow"><span>Filter</span>
<input type="search" id="log-search"
placeholder="Person, television, title, component, path…"></label>
<button id="log-pause">Pause</button>
<button id="log-clear">Clear view</button>
<button id="log-export">Export JSON</button>
</div>
<div id="log" class="log" role="log" aria-live="polite">
<div class="log-empty">Waiting for server events…</div>
</div>
<p class="hint" id="log-stats">Connecting…</p>
</section>
+106
View File
@@ -0,0 +1,106 @@
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);
});
@@ -0,0 +1,19 @@
<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>
@@ -0,0 +1,21 @@
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)));
});
@@ -0,0 +1,46 @@
<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>
@@ -0,0 +1,91 @@
/* 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('Radarr', status.radarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) +
row('Sonarr', 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.';
});
@@ -0,0 +1,20 @@
<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>
@@ -0,0 +1,27 @@
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),
}),
}));
});
});
@@ -0,0 +1,45 @@
<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>
@@ -0,0 +1,65 @@
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;
}));
});
});
@@ -0,0 +1,28 @@
<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>
@@ -0,0 +1,33 @@
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')));
});
@@ -0,0 +1,24 @@
<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 film is added to Radarr and a show to Sonarr, both unmonitored.
No download search starts on its own.</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>
@@ -0,0 +1,29 @@
const { fmt, ui, $ } = Admin;
Admin.onStatus((status) => {
$('request-services').innerHTML =
ui.tag('Radarr ' + (status.radarrReady ? 'ready' : 'not configured'),
status.radarrReady ? 'ok' : 'bad') +
ui.tag('Sonarr ' + (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 }),
}));
});
});
@@ -0,0 +1,34 @@
<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>
@@ -0,0 +1,175 @@
/* 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);
});
}
});
@@ -0,0 +1,29 @@
<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="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>
<div class="card-foot">
<button class="primary" id="update-save">Save policy</button>
<button id="update-disable">Turn prompts off</button>
</div>
</section>
@@ -0,0 +1,36 @@
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;
$('update-state').innerHTML = !policy.enabled
? ui.tag('off', 'idle')
: ui.tag((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.check($('update-required'), required);
});
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,
});
Admin.ready(() => {
$('update-save').addEventListener('click', () => {
if ($('update-required').checked && !confirm('Required updates block the home screen on ' +
'every television below this version. Continue?')) 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) })));
});
+56
View File
@@ -0,0 +1,56 @@
<!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}}<p class="rail-group">{{.Label}}</p>{{end}}
<nav class="rail-nav">
{{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">
<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>
</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>