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:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
@@ -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; }
|
||||
}
|
||||
@@ -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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[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>
|
||||
@@ -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 person’s stored recommendation choices? Viewing history remains intact.')) return;
|
||||
Admin.act(() => Admin.api(base + '/recommendations', { method: 'DELETE' }));
|
||||
}
|
||||
if (action === 'prompt-recommendations') {
|
||||
Admin.act(() => Admin.api(base + '/recommendations/prompt', { method: 'PUT' }));
|
||||
}
|
||||
if (action === 'cancel-recommendation-prompt') {
|
||||
if (!confirm('Cancel this person’s queued recommendation prompt?')) return;
|
||||
Admin.act(() => Admin.api(base + '/recommendations', { method: 'DELETE' }));
|
||||
}
|
||||
if (action === 'push-preferences') {
|
||||
const preferences = collectSettings();
|
||||
message('pushing…');
|
||||
Admin.act(async () => {
|
||||
await Admin.api(base + '/preferences', {
|
||||
method: 'PUT', body: JSON.stringify({ preferences }),
|
||||
});
|
||||
// Cleared before the refresh, so the form is redrawn from what the server actually
|
||||
// stored rather than from what was submitted.
|
||||
dirty = false;
|
||||
message('pushed');
|
||||
});
|
||||
}
|
||||
if (action === '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'));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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) })));
|
||||
});
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user