Files
memby/server/internal/api/admin.html
T

1162 lines
57 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Memby admin</title>
<style>
:root {
color-scheme: dark;
--bg: #080b0e; --panel: #151a20; --panel-raised: #1a2027; --line: #252d35;
--text: #edf1f4; --muted: #98a3ad; --accent: #52b54b; --accent-soft: #18351b;
--danger: #e5534b; --rail-width: 242px;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html { scroll-behavior: smooth; scroll-padding-top: 28px; }
body {
margin: 0; background:
radial-gradient(circle at 75% -20%, rgba(82,181,75,.09), transparent 34rem),
var(--bg); color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
.side-rail {
position: fixed; inset: 0 auto 0 0; z-index: 20; width: var(--rail-width);
display: flex; flex-direction: column; padding: 22px 12px 16px;
background: rgba(14,18,22,.97); border-right: 1px solid var(--line);
box-shadow: 12px 0 36px rgba(0,0,0,.18); overflow-y: auto;
}
.brand {
display: flex; align-items: center; gap: 12px; min-height: 46px; padding: 0 10px;
color: var(--text); text-decoration: none;
}
.brand-mark {
display: grid; place-items: center; width: 34px; height: 34px; flex: 0 0 34px;
border-radius: 11px; background: var(--accent); color: #071608;
font-size: 19px; font-weight: 900; letter-spacing: -.08em;
box-shadow: 0 5px 18px rgba(82,181,75,.25);
}
.brand-copy { min-width: 0; }
.brand-copy b { display: block; font-size: 17px; line-height: 1.1; }
.brand-copy span { display: block; margin-top: 3px; color: var(--muted); font-size: 10px; letter-spacing: .04em; }
.rail-label {
margin: 24px 12px 8px; color: #69747e; font-size: 10px; font-weight: 700;
letter-spacing: .13em; text-transform: uppercase;
}
.rail-nav { display: grid; gap: 4px; }
.rail-link {
position: relative; display: flex; align-items: center; gap: 13px; min-height: 44px;
padding: 0 12px; border-radius: 9px; color: #aeb7bf; text-decoration: none;
font-size: 13px; font-weight: 600; transition: color .14s, background .14s, transform .14s;
}
.rail-link svg {
width: 19px; height: 19px; flex: 0 0 19px; fill: none; stroke: currentColor;
stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round;
}
.rail-link:hover { color: var(--text); background: #171d23; transform: translateX(2px); }
.rail-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.rail-link.active { color: #fff; background: var(--accent-soft); }
.rail-link.active::before {
content: ""; position: absolute; left: -12px; width: 3px; height: 24px;
border-radius: 0 4px 4px 0; background: var(--accent);
}
.rail-footer { margin-top: auto; padding: 15px 10px 0; border-top: 1px solid var(--line); }
.rail-footer .pill { display: inline-block; }
main {
width: calc(100% - var(--rail-width)); margin-left: var(--rail-width);
padding: 28px clamp(22px, 3vw, 48px) 60px; display: grid; gap: 20px;
}
h1 { font-size: clamp(24px, 3vw, 32px); line-height: 1.15; margin: 0; letter-spacing: -.025em; }
h2 { font-size: 15px; margin: 0 0 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
header { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; flex-wrap: wrap; padding: 6px 2px 10px; }
.eyebrow { margin: 0 0 5px; color: var(--accent); font-size: 11px; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; }
.page-intro { margin: 7px 0 0; color: var(--muted); }
section {
scroll-margin-top: 24px; background: linear-gradient(145deg, var(--panel-raised), var(--panel));
border: 1px solid var(--line); border-radius: 13px; padding: 20px;
box-shadow: 0 12px 34px rgba(0,0,0,.12);
}
button {
background: var(--accent); color: #06240a; border: 0; border-radius: 7px;
padding: 9px 15px; font-weight: 600; font-size: 14px; cursor: pointer;
}
button.secondary { background: #2a323b; color: var(--text); }
button.danger { background: var(--danger); color: #fff; }
button:disabled { opacity: .45; cursor: not-allowed; }
input[type=text], input[type=password], input[type=number], input[type=datetime-local] {
background: #0e1216; border: 1px solid var(--line); border-radius: 7px;
color: var(--text); padding: 9px 11px; font-size: 14px; min-width: 260px;
}
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.stats { display: flex; gap: 26px; flex-wrap: wrap; margin-bottom: 14px; }
.stat b { display: block; font-size: 22px; font-weight: 600; }
.stat span { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
.scroll { overflow-x: auto; }
table { border-collapse: collapse; width: 100%; font-size: 14px; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; }
th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .05em; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
.pill { padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.ok { background: #16351a; color: #7bd88f; }
.bad { background: #3a1d1c; color: #ff8a80; }
.warn { background: #3a3320; color: #f0c674; }
.muted { color: var(--muted); }
.banner { padding: 11px 14px; border-radius: 8px; background: #3a1d1c; color: #ffb3ad; display: none; }
.banner.show { display: block; }
.event-toolbar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-bottom:12px; }
.event-toolbar input[type=search] {
background:#0e1216; border:1px solid var(--line); border-radius:7px;
color:var(--text); padding:8px 10px; min-width:300px;
}
select {
background:#0e1216; color:var(--text); border:1px solid var(--line);
border-radius:7px; padding:8px;
}
.event-log {
height: min(58vh, 720px); min-height: 360px; overflow: auto; background:#090c0f;
border:1px solid var(--line); border-radius:7px; font:12px/1.45 ui-monospace, Consolas, monospace;
}
.event-line { display:grid; grid-template-columns:190px 62px 240px minmax(340px,1fr); gap:10px; padding:5px 9px; border-bottom:1px solid #171c21; }
.event-line:hover { background:#12171c; }
.event-time,.event-attrs { color:var(--muted); }
.event-level { font-weight:700; }
.event-level.ERROR { color:#ff8a80; }
.event-level.WARN { color:#f0c674; }
.event-level.DEBUG { color:#88a4bd; }
.event-empty { padding:20px; color:var(--muted); }
.recommendation-controls { display:grid; grid-template-columns:repeat(4,minmax(150px,1fr)) auto; gap:10px; align-items:end; margin:14px 0; }
.recommendation-controls label { display:grid; gap:5px; color:var(--muted); font-size:12px; }
.recommendation-controls input { min-width:0; width:100%; }
.recommendation-list { display:grid; gap:10px; }
.recommendation-card { border:1px solid var(--line); border-radius:10px; background:#10151a; padding:14px; }
.recommendation-head { display:flex; gap:14px; justify-content:space-between; align-items:flex-start; }
.recommendation-title { font-size:16px; font-weight:700; }
.recommendation-score { color:#8ee39a; font:700 20px ui-monospace,Consolas,monospace; }
.reason-code,.component { display:inline-block; margin:5px 5px 0 0; padding:3px 7px; border-radius:5px; font-size:11px; }
.reason-code { color:#a9d7ff; background:#152a3b; }
.component { color:#d6dde3; background:#252d35; font-family:ui-monospace,Consolas,monospace; }
.component.negative { color:#ffaaa3; background:#3a1d1c; }
.recommendation-meta { color:var(--muted); font-size:12px; margin-top:4px; }
.reason-text { margin-top:9px; color:#dce3e8; }
.feature-hero {
display:grid; grid-template-columns:minmax(0,1fr) auto; gap:22px; align-items:center;
padding:20px; margin-bottom:16px; border:1px solid #2b4830; border-radius:11px;
background:linear-gradient(120deg,rgba(82,181,75,.15),rgba(16,21,26,.7));
}
.feature-hero.safe { border-color:#705d2b; background:linear-gradient(120deg,rgba(240,198,116,.13),rgba(16,21,26,.7)); }
.feature-health { display:flex; gap:24px; flex-wrap:wrap; margin-top:13px; }
.feature-health b { display:block; font-size:20px; }
.feature-health span { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.06em; }
.feature-grid { display:grid; grid-template-columns:repeat(2,minmax(260px,1fr)); gap:12px; margin:16px 0; }
.feature-card { display:grid; gap:11px; padding:16px; border:1px solid var(--line); border-radius:10px; background:#10151a; }
.feature-card-head { display:flex; justify-content:space-between; gap:12px; align-items:flex-start; }
.feature-card h3 { margin:0; font-size:16px; }
.feature-card p { margin:3px 0 0; color:var(--muted); font-size:13px; }
.feature-meta { display:flex; gap:7px; flex-wrap:wrap; align-items:center; }
.feature-meta code { color:#b7c1c9; font-size:11px; }
.feature-select { min-width:128px; font-weight:650; }
.recovery-note { color:#86ad8b; font-size:12px; }
.feature-actions { display:flex; gap:9px; align-items:center; flex-wrap:wrap; padding-top:15px; border-top:1px solid var(--line); }
details { margin-top:9px; }
summary { color:var(--muted); cursor:pointer; }
button:focus-visible, input:focus-visible, select:focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px;
}
@media (max-width: 900px) {
:root { --rail-width: 72px; }
.side-rail { padding: 16px 8px; align-items: stretch; }
.brand { justify-content: center; padding: 0; }
.brand-copy, .rail-label, .rail-link span, .rail-footer span { display: none; }
.rail-nav { margin-top: 22px; gap: 6px; }
.rail-link { justify-content: center; padding: 0; min-height: 45px; }
.rail-link.active::before { left: -8px; }
.rail-footer { padding: 14px 0 0; text-align: center; }
main { padding: 20px 14px 44px; }
.event-line { grid-template-columns:150px 55px 1fr; }
.event-attrs { grid-column:1 / -1; }
.recommendation-controls { grid-template-columns:repeat(2,minmax(150px,1fr)); }
.feature-grid { grid-template-columns:1fr; }
}
@media (max-width: 560px) {
input[type=text], input[type=password], input[type=number], input[type=datetime-local],
.event-toolbar input[type=search] { min-width: 100% !important; width: 100%; }
section { padding: 16px; }
.stats { gap: 18px; }
.recommendation-controls { grid-template-columns:1fr; }
.feature-hero { grid-template-columns:1fr; }
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.rail-link { transition: none; }
}
</style>
</head>
<body>
<aside class="side-rail" aria-label="Admin navigation">
<a class="brand" href="/admin/features" aria-label="Memby admin home">
<span class="brand-mark" aria-hidden="true">M</span>
<span class="brand-copy"><b>Memby</b><span>ADMIN CONSOLE</span></span>
</a>
<div class="rail-label">Manage</div>
<nav class="rail-nav">
<a class="rail-link" href="/admin/features" data-section="features" title="Features">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2m4 0h10M14 4v6M6 14v6"/></svg><span>Features</span>
</a>
<a class="rail-link" href="/admin/ratings" data-section="ratings" title="Movie ratings">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 3 2.1 5.4 5.9.4-4.6 3.8 1.5 5.7-4.9-3.2-4.9 3.2 1.5-5.7L4 8.8l5.9-.4L12 3Z"/></svg><span>Movie ratings</span>
</a>
<a class="rail-link" href="/admin/library" data-section="library" title="Library">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5.5h16v13H4zM8 5.5v13M4 10h4"/></svg><span>Library</span>
</a>
<a class="rail-link" href="/admin/recommendations" data-section="recommendations" title="Recommendations">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 3 1.5 5 5 .2-4 3 1.4 5-3.9-2.8-3.9 2.8 1.4-5-4-3 5-.2L12 3Z"/></svg><span>Recommendations</span>
</a>
<a class="rail-link" href="/admin/requests" data-section="requests" title="Media requests">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 4h14v16H5zM8 8h8M8 12h5M15 16h3m-1.5-1.5v3"/></svg><span>Media requests</span>
</a>
<a class="rail-link" href="/admin/playback" data-section="playback" title="Playback">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7zM4 5v14"/></svg><span>Playback</span>
</a>
<a class="rail-link" href="/admin/maintenance" data-section="maintenance" title="Maintenance">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="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"/></svg><span>Maintenance</span>
</a>
<a class="rail-link" href="/admin/updates" data-section="updates" title="App updates">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 16V4m0 0L8 8m4-4 4 4M5 13v6h14v-6"/></svg><span>App updates</span>
</a>
</nav>
<div class="rail-label">Observe</div>
<nav class="rail-nav">
<a class="rail-link" href="/admin/engagement" data-section="engagement" title="Row engagement">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 19V9m5 10V5m5 14v-7m5 7V3"/></svg><span>Row engagement</span>
</a>
<a class="rail-link" href="/admin/imports" data-section="imports" title="Recent imports">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16v13H4zM8 4h8v3M8 12h8M8 16h5"/></svg><span>Recent imports</span>
</a>
<a class="rail-link" href="/admin/logs" data-section="logs" title="Server logs">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16v14H4zM7 9l2 2-2 2m5 1h5"/></svg><span>Server logs</span>
</a>
</nav>
<div class="rail-footer">
<span class="muted">Server status</span>
<div id="rail-live" class="pill muted" style="margin-top:7px">connecting…</div>
</div>
</aside>
<main>
<header id="top">
<div>
<p class="eyebrow">Memby gateway</p>
<h1 id="page-title">Admin console</h1>
<p class="page-intro" id="page-intro">Manage the library, TV experience and server health.</p>
</div>
<span id="live" class="pill muted">connecting…</span>
</header>
<div id="error" class="banner"></div>
<section id="library" data-admin-page="library">
<h2>Library</h2>
<div class="stats" id="library-stats"><span class="muted">Loading…</span></div>
<div class="row">
<button id="sync-incremental">Sync new items</button>
<button id="sync-full" class="secondary">Full re-import</button>
<span class="muted" id="sync-hint"></span>
</div>
</section>
<section id="for-you" data-admin-page="recommendations">
<h2>For You</h2>
<div class="stats" id="for-you-stats"><span class="muted">Loading…</span></div>
<div class="row">
<button id="for-you-import">Import recent sessions</button>
<button id="for-you-full" class="secondary">Full Tracearr backfill</button>
<button id="for-you-rebuild" class="secondary">Rebuild all pools</button>
<span class="muted" id="for-you-hint"></span>
</div>
<hr style="border:0;border-top:1px solid var(--line);margin:20px 0">
<h2 style="margin-bottom:4px">Per-user pressure test</h2>
<p class="muted" style="margin-top:0">
Re-runs the shared weighted scorer over the user's prepared pool, after Emby
permission and parental-control filtering. Inspect every component and evidence reason.
</p>
<div class="recommendation-controls">
<label>User<select id="recommendation-user"><option value="">Choose a user…</option></select></label>
<label>Context<select id="recommendation-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>Available minutes<input id="recommendation-minutes" type="number" min="0" max="360" value="0"></label>
<label>Evaluate at<input id="recommendation-at" type="datetime-local"></label>
<button id="recommendation-load">Run pressure test</button>
</div>
<div id="recommendation-summary" class="stats"></div>
<div id="recommendation-profile"></div>
<div id="recommendation-results" class="recommendation-list">
<span class="muted">Choose a user to inspect their recommendations.</span>
</div>
</section>
<section id="requests" data-admin-page="requests">
<h2>Media requests</h2>
<p class="muted" style="margin-top:0">
Choose who sees “Request it” when a library search has no matches. Movies are added
to Radarr and shows to Sonarr as unmonitored; no download search starts automatically.
</p>
<div id="request-services" class="row" style="margin-bottom:12px"></div>
<div id="request-users" style="display:grid;gap:8px;margin-bottom:12px">
<span class="muted">Loading users…</span>
</div>
<button id="request-save">Save access</button>
</section>
<section id="maintenance" data-admin-page="maintenance">
<h2>Maintenance</h2>
<p class="muted" style="margin-top:0">
Takes Memby offline for every TV, independently of Emby. Sign-in and all content
calls return 503 with the message below; this page keeps working.
</p>
<div class="row">
<input type="text" id="maintenance-message" placeholder="Message shown on the TV">
<button id="maintenance-on" class="danger">Go offline</button>
<button id="maintenance-off" class="secondary">Bring back online</button>
<span id="maintenance-state" class="pill"></span>
</div>
</section>
<section id="features" data-admin-page="features">
<div id="feature-hero" class="feature-hero">
<div>
<p class="eyebrow" style="margin-bottom:4px">Server control plane</p>
<h2 style="color:var(--text);font-size:20px;text-transform:none;letter-spacing:-.01em;margin:0">Features without an app release</h2>
<p class="muted" style="margin:5px 0 0">Every optional feature has a safe default, an explicit override and a remote recovery path.</p>
<div id="feature-health" class="feature-health"><span>Loading feature state…</span></div>
</div>
<button id="feature-safe-mode" class="danger">Enable safe mode</button>
</div>
<div id="feature-list" class="feature-grid"><span class="muted">Loading flags…</span></div>
<div class="feature-actions">
<button id="feature-save">Publish changes</button>
<button id="feature-rollback" class="secondary">Roll back previous revision</button>
<button id="feature-reset" class="secondary">Clear all overrides</button>
<span id="feature-state" class="pill muted">loading…</span>
</div>
<h2 style="margin-top:24px">Client compatibility</h2>
<p class="muted">Capability reports are sent on every request. A feature is only presented to clients that declare its contract.</p>
<div class="scroll"><table>
<thead><tr><th>TV</th><th>User</th><th>App</th><th>Protocol</th><th>Control plane</th><th>Last seen</th></tr></thead>
<tbody id="feature-clients"><tr><td colspan="6" class="muted">No clients reported yet.</td></tr></tbody>
</table></div>
</section>
<section id="ratings" data-admin-page="ratings">
<h2>MDBList movie ratings</h2>
<p class="muted" style="margin-top:0">
Optionally enrich movie details with ratings fetched by the gateway. The API key
stays on this server, responses are cached for 24 hours, and failures never block a TV.
</p>
<div class="row" style="margin-bottom:14px">
<label style="display:flex;align-items:center;gap:9px">
<input type="checkbox" id="mdblist-enabled">
<span>Show external movie ratings</span>
</label>
<span id="mdblist-state" class="pill muted">off</span>
</div>
<div style="display:grid;gap:8px;margin-bottom:16px">
<label class="muted" for="mdblist-api-key">MDBList API key</label>
<div class="row">
<input type="password" id="mdblist-api-key" autocomplete="new-password" placeholder="Paste an API key">
<label style="display:flex;align-items:center;gap:7px">
<input type="checkbox" id="mdblist-clear-key"> Remove saved key
</label>
</div>
<span class="muted" style="font-size:12px">Leave the field blank to keep the currently saved key.</span>
</div>
<h2 style="margin-bottom:8px">Sources shown on TVs</h2>
<div id="mdblist-sources" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:9px;margin-bottom:16px">
<span class="muted">Loading sources…</span>
</div>
<button id="mdblist-save">Save ratings settings</button>
</section>
<section id="playback" data-admin-page="playback">
<h2>Playback experience</h2>
<p class="muted" style="margin-top:0">
Controls presentation policy returned with every playback launch. Changes apply to
the next title opened on every gateway-connected TV; no app release is required.
</p>
<div class="row">
<label style="display:flex;align-items:center;gap:8px">
<input type="checkbox" id="preroll-enabled"> Show the upcoming-show preroll
</label>
<label class="muted" style="display:flex;align-items:center;gap:8px">
Duration
<input type="number" id="preroll-duration" min="1" max="30" step="0.5" value="6.5" style="min-width:90px;width:90px">
seconds
</label>
<button id="playback-save">Save playback policy</button>
<span id="playback-state" class="pill"></span>
</div>
</section>
<section id="updates" data-admin-page="updates">
<h2>App updates</h2>
<p class="muted" style="margin-top:0">
TVs check on every launch. <strong>Optional</strong> shows a dismissable prompt;
<strong>required</strong> blocks the home screen until the viewer updates.
</p>
<div class="row" style="margin-bottom:10px">
<input type="text" id="update-version" placeholder="Latest version, e.g. 0.1.54" style="min-width:220px">
<input type="text" id="update-url" placeholder="APK URL, e.g. https://nas/memby/memby-0.1.54.apk" style="min-width:380px">
</div>
<div class="row" style="margin-bottom:10px">
<input type="text" id="update-notes" placeholder="What's new (shown on the TV)" style="min-width:480px">
</div>
<div class="row">
<label class="muted" style="display:flex;align-items:center;gap:8px">
<input type="checkbox" id="update-required"> Require this update
</label>
<button id="update-save">Save policy</button>
<button id="update-disable" class="secondary">Turn off prompts</button>
<span id="update-state" class="pill"></span>
</div>
</section>
<section id="engagement" data-admin-page="engagement">
<h2>Row engagement</h2>
<div class="row" style="margin-bottom:12px">
<label class="muted" for="days">Window</label>
<select id="days" style="background:#0e1216;color:var(--text);border:1px solid var(--line);border-radius:7px;padding:8px">
<option value="1">24 hours</option>
<option value="7" selected>7 days</option>
<option value="30">30 days</option>
</select>
</div>
<div class="scroll">
<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="analytics"><tr><td colspan="8" class="muted">No data yet.</td></tr></tbody>
</table>
</div>
</section>
<section id="imports" data-admin-page="imports">
<h2>Recent imports</h2>
<div class="scroll">
<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="runs"><tr><td colspan="8" class="muted">Nothing yet.</td></tr></tbody>
</table>
</div>
</section>
<section id="events" data-admin-page="logs">
<h2>Live server events</h2>
<div class="event-toolbar">
<select id="event-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>
<input type="search" id="event-search" placeholder="Filter message, path, version, status…">
<button id="event-pause" class="secondary">Pause</button>
<button id="event-clear" class="secondary">Clear view</button>
<button id="event-export" class="secondary">Export JSON</button>
<span id="event-stats" class="muted">Connecting…</span>
</div>
<div id="event-log" class="event-log" role="log" aria-live="polite">
<div class="event-empty">Waiting for server events…</div>
</div>
</section>
</main>
<script>
const railLinks = [...document.querySelectorAll('.rail-link[data-section]')];
const currentAdminPage = location.pathname.split('/').filter(Boolean).pop() || 'library';
const pageCopy = {
library: ['Library', 'Import and inspect the catalogue Memby ranks.'],
recommendations: ['Recommendations', 'Pressure-test personalised rows and title scores per user.'],
requests: ['Media requests', 'Control who can request missing movies and shows.'],
features: ['Features', 'Roll out, stop and recover optional TV behaviour from the server.'],
ratings: ['Movie ratings', 'Configure optional server-side MDBList ratings for movie details.'],
playback: ['Playback', 'Control server-driven playback presentation on every TV.'],
maintenance: ['Maintenance', 'Control gateway availability for every television.'],
updates: ['App updates', 'Publish optional or mandatory client update policy.'],
engagement: ['Row engagement', 'Inspect impressions, focus, dwell and selections.'],
imports: ['Recent imports', 'Review catalogue synchronization history.'],
logs: ['Server logs', 'Follow structured gateway events in real time.'],
};
document.querySelectorAll('[data-admin-page]').forEach((section) => {
section.hidden = section.dataset.adminPage !== currentAdminPage;
});
railLinks.forEach((link) => {
const selected = link.dataset.section === currentAdminPage;
link.classList.toggle('active', selected);
if (selected) link.setAttribute('aria-current', 'page');
});
const copy = pageCopy[currentAdminPage] || pageCopy.library;
document.getElementById('page-title').textContent = copy[0];
document.getElementById('page-intro').textContent = copy[1];
document.title = copy[0] + ' · Memby admin';
function showError(message) {
const banner = document.getElementById('error');
banner.textContent = message || '';
banner.classList.toggle('show', Boolean(message));
}
async function api(path, options = {}) {
const response = await fetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
});
if (response.status === 401) throw new Error('Invalid admin token.');
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();
}
const number = (value) => (value ?? 0).toLocaleString();
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';
}
const when = (value) => (value ? new Date(value).toLocaleString() : '—');
const escapeHtml = (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;',
}[char]));
let featureRevision = 0;
function renderFeatures(payload, clients) {
featureRevision = Number(payload.revision || 0);
const features = payload.features || [];
const enabled = features.filter((feature) => feature.enabled).length;
const overrides = features.filter((feature) => feature.source === 'override').length;
const capableClients = clients.filter((client) =>
(client.capabilities || []).includes('server_features_v1')).length;
const hero = document.getElementById('feature-hero');
hero.classList.toggle('safe', Boolean(payload.safeMode));
document.getElementById('feature-health').innerHTML =
'<div><b>' + enabled + ' / ' + features.length + '</b><span>features active</span></div>' +
'<div><b>' + overrides + '</b><span>explicit overrides</span></div>' +
'<div><b>' + capableClients + ' / ' + clients.length + '</b><span>TVs reporting control plane</span></div>' +
'<div><b>r' + featureRevision + '</b><span>published revision</span></div>';
const safeButton = document.getElementById('feature-safe-mode');
safeButton.textContent = payload.safeMode ? 'Leave safe mode' : 'Enable safe mode';
safeButton.className = payload.safeMode ? 'secondary' : 'danger';
const featureState = document.getElementById('feature-state');
featureState.textContent = payload.safeMode ? 'SAFE MODE · optional features off' : 'live · revision ' + featureRevision;
featureState.className = 'pill ' + (payload.safeMode ? 'warn' : 'ok');
document.getElementById('feature-rollback').disabled = !payload.canRollback;
const list = document.getElementById('feature-list');
if (!list.contains(document.activeElement)) {
list.innerHTML = features.map((feature) => {
const mode = feature.source === 'override' ? (feature.enabled ? 'on' : 'off') : 'default';
const effective = feature.enabled ? 'ACTIVE' : 'OFF';
return '<article class="feature-card">' +
'<div class="feature-card-head"><div><div class="feature-meta">' +
'<span class="pill ' + (feature.enabled ? 'ok' : 'muted') + '">' + effective + '</span>' +
'<span class="muted">' + escapeHtml(feature.area) + '</span></div>' +
'<h3>' + escapeHtml(feature.name) + '</h3><p>' + escapeHtml(feature.description) + '</p></div>' +
'<select class="feature-select" data-feature-key="' + escapeHtml(feature.key) + '" aria-label="' + escapeHtml(feature.name) + ' mode">' +
'<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></div>' +
'<div class="feature-meta"><code>' + escapeHtml(feature.key) + '</code>' +
'<span class="pill muted">protocol ' + number(feature.minimumProtocol) + '+</span>' +
'<span class="pill ' + (feature.compatible ? 'ok' : 'warn') + '">' +
(feature.compatible ? 'server compatible' : 'compatibility blocked') + '</span></div>' +
'<div class="recovery-note">↳ ' + escapeHtml(feature.recovery) + '</div></article>';
}).join('') || '<span class="muted">No server features are registered.</span>';
}
document.getElementById('feature-clients').innerHTML = clients.length
? clients.map((client) => {
const capable = (client.capabilities || []).includes('server_features_v1');
return '<tr><td>' + escapeHtml(client.deviceName || 'Memby TV') + '</td>' +
'<td>' + escapeHtml(client.username) + '</td><td>' + escapeHtml(client.version || 'legacy') + '</td>' +
'<td>' + escapeHtml(client.protocol || 'unknown') + '</td>' +
'<td><span class="pill ' + (capable ? 'ok' : 'warn') + '">' +
(capable ? 'reported' : 'legacy fallback') + '</span></td><td>' + escapeHtml(when(client.lastSeen)) + '</td></tr>';
}).join('')
: '<tr><td colspan="6" class="muted">No clients have signed in yet.</td></tr>';
}
function renderStatus(status) {
const byType = status.library.byType || {};
const types = Object.keys(byType).sort();
document.getElementById('library-stats').innerHTML =
'<div class="stat"><b>' + number(status.library.total) + '</b><span>items</span></div>' +
types.map((type) =>
'<div class="stat"><b>' + number(byType[type]) + '</b><span>' + type + '</span></div>').join('') +
'<div class="stat"><b style="font-size:15px">' + when(status.library.lastSynced) +
'</b><span>last import</span></div>';
const running = status.syncRunning;
document.getElementById('sync-incremental').disabled = running;
document.getElementById('sync-full').disabled = running;
document.getElementById('sync-hint').textContent = running
? 'Import running…'
: 'Automatic incremental import every ' + status.syncEvery + '.';
const forYou = status.forYou || {};
document.getElementById('for-you-stats').innerHTML =
'<div class="stat"><b>' + number(forYou.tracearrSessions) + '</b><span>Tracearr sessions</span></div>' +
'<div class="stat"><b>' + number(forYou.profiles) + '</b><span>user profiles</span></div>' +
'<div class="stat"><b>' + number(forYou.candidates) + '</b><span>ranked candidates</span></div>' +
'<div class="stat"><b style="font-size:15px">' + when(forYou.lastFullImport) +
'</b><span>last full import</span></div>';
const forYouRunning = Boolean(status.forYouRunning);
document.getElementById('for-you-import').disabled = forYouRunning;
document.getElementById('for-you-full').disabled = forYouRunning;
document.getElementById('for-you-rebuild').disabled = forYouRunning;
document.getElementById('for-you-hint').textContent =
forYouRunning ? 'For You maintenance running…' : 'Prepared pools normally refresh in the background.';
document.getElementById('request-services').innerHTML =
'<span class="pill ' + (status.radarrReady ? 'ok' : 'bad') + '">Radarr ' +
(status.radarrReady ? 'ready' : 'not configured') + '</span>' +
'<span class="pill ' + (status.sonarrReady ? 'ok' : 'bad') + '">Sonarr ' +
(status.sonarrReady ? 'ready' : 'not configured') + '</span>';
const allowedUsers = new Set((status.requestPolicy || {}).allowedUserIds || []);
const userBox = document.getElementById('request-users');
if (!userBox.contains(document.activeElement)) {
userBox.innerHTML = (status.requestUsers || []).length
? status.requestUsers.map((user) =>
'<label style="display:flex;align-items:center;gap:9px">' +
'<input type="checkbox" data-request-user="' + escapeHtml(user.id) + '"' +
(allowedUsers.has(user.id) ? ' checked' : '') + '>' +
'<span>' + escapeHtml(user.username) + '</span>' +
'<span class="muted">last seen ' + escapeHtml(when(user.lastSeen)) + '</span></label>'
).join('')
: '<span class="muted">No users have signed in yet.</span>';
}
const recommendationUser = document.getElementById('recommendation-user');
if (!recommendationUser.contains(document.activeElement)) {
const selectedUser = recommendationUser.value;
recommendationUser.innerHTML = '<option value="">Choose a user…</option>' +
(status.requestUsers || []).map((user) =>
'<option value="' + escapeHtml(user.id) + '">' +
escapeHtml(user.username) + '</option>').join('');
recommendationUser.value = selectedUser;
}
const maintenance = status.maintenance || {};
const state = document.getElementById('maintenance-state');
state.textContent = maintenance.enabled ? 'OFFLINE' : 'online';
state.className = 'pill ' + (maintenance.enabled ? 'bad' : 'ok');
const messageField = document.getElementById('maintenance-message');
if (document.activeElement !== messageField) messageField.value = maintenance.message || '';
renderFeatures(status.features || {}, status.clients || []);
const mdblist = status.mdblist || {};
const mdblistEnabled = document.getElementById('mdblist-enabled');
if (document.activeElement !== mdblistEnabled) mdblistEnabled.checked = Boolean(mdblist.enabled);
const mdblistState = document.getElementById('mdblist-state');
mdblistState.textContent = mdblist.enabled
? 'on · ' + ((mdblist.sources || []).length) + ' sources'
: (mdblist.apiKeyConfigured ? 'off · key saved' : 'off · no key');
mdblistState.className = 'pill ' + (mdblist.enabled ? 'ok' : 'muted');
const keyField = document.getElementById('mdblist-api-key');
keyField.placeholder = mdblist.apiKeyConfigured ? 'Saved key (leave blank to keep)' : 'Paste an API key';
const sourceNames = {
imdb:'IMDb', tomatoes:'Rotten Tomatoes', audience:'Rotten Tomatoes Audience',
metacritic:'Metacritic', letterboxd:'Letterboxd', rogerebert:'Roger Ebert',
tmdb:'TMDb', trakt:'Trakt', mal:'MyAnimeList',
score:'MDBList Score', score_average:'MDBList Average',
};
const sourceBox = document.getElementById('mdblist-sources');
if (!sourceBox.contains(document.activeElement)) {
const selectedSources = new Set(mdblist.sources || []);
sourceBox.innerHTML = (mdblist.availableSources || []).map((source) =>
'<label style="display:flex;align-items:center;gap:8px">' +
'<input type="checkbox" data-mdblist-source="' + escapeHtml(source) + '"' +
(selectedSources.has(source) ? ' checked' : '') + '>' +
'<span>' + escapeHtml(sourceNames[source] || source) + '</span></label>'
).join('') || '<span class="muted">No rating sources available.</span>';
}
const playback = status.playbackPolicy || {};
const prerollEnabled = document.getElementById('preroll-enabled');
const prerollDuration = document.getElementById('preroll-duration');
if (document.activeElement !== prerollEnabled) {
prerollEnabled.checked = playback.prerollEnabled !== false;
}
if (document.activeElement !== prerollDuration) {
prerollDuration.value = ((playback.prerollDurationMs || 6500) / 1000).toString();
}
const playbackState = document.getElementById('playback-state');
playbackState.textContent = prerollEnabled.checked
? 'on · ' + prerollDuration.value + 's'
: 'off';
playbackState.className = 'pill ' + (prerollEnabled.checked ? 'ok' : 'muted');
const policy = status.updatePolicy || {};
const policyState = document.getElementById('update-state');
const required = Boolean(policy.minimumVersion) && policy.minimumVersion === policy.latestVersion;
if (!policy.enabled) {
policyState.textContent = 'off';
policyState.className = 'pill muted';
} else {
policyState.textContent = required ? 'REQUIRED ' + policy.latestVersion : 'optional ' + policy.latestVersion;
policyState.className = 'pill ' + (required ? 'warn' : 'ok');
}
// Do not fight the operator for the field they are typing in.
const fields = {
'update-version': policy.latestVersion || '',
'update-url': policy.downloadUrl || '',
'update-notes': policy.notes || '',
};
for (const [id, value] of Object.entries(fields)) {
const el = document.getElementById(id);
if (document.activeElement !== el) el.value = value;
}
const requiredBox = document.getElementById('update-required');
if (document.activeElement !== requiredBox) requiredBox.checked = required;
document.getElementById('runs').innerHTML = (status.runs || []).length
? status.runs.map((run) => {
const pill = run.status === 'success' ? 'ok' : run.status === 'running' ? 'warn' : 'bad';
return '<tr>' +
'<td>' + when(run.startedAt) + '</td>' +
'<td>' + run.kind + '</td>' +
'<td>' + run.trigger + '</td>' +
'<td><span class="pill ' + pill + '">' + run.status + '</span></td>' +
'<td class="num">' + number(run.itemsSeen) + '</td>' +
'<td class="num">' + number(run.itemsUpserted) + '</td>' +
'<td class="num">' + number(run.itemsRemoved) + '</td>' +
'<td class="muted">' + (run.error || '') + '</td>' +
'</tr>';
}).join('')
: '<tr><td colspan="8" class="muted">Nothing yet.</td></tr>';
}
function renderAnalytics(payload) {
const rows = payload.rows || [];
document.getElementById('analytics').innerHTML = rows.length
? rows.map((row) =>
'<tr>' +
'<td>' + row.rowId + '</td>' +
'<td class="muted">' + (row.rowKind || '—') + '</td>' +
'<td class="num">' + duration(row.dwellMs) + '</td>' +
'<td class="num">' + number(row.impressions) + '</td>' +
'<td class="num">' + number(row.focuses) + '</td>' +
'<td class="num">' + number(row.selects) + '</td>' +
'<td class="num">' + Math.round((row.selectRate || 0) * 100) + '%</td>' +
'<td class="num">' + number(row.viewers) + '</td>' +
'</tr>').join('')
: '<tr><td colspan="8" class="muted">No events in this window.</td></tr>';
}
function affinityEntries(profile) {
const dimensions = [
['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],
];
return dimensions.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 renderRecommendationInspection(payload) {
const meta = payload.profileMeta || {};
document.getElementById('recommendation-summary').innerHTML =
'<div class="stat"><b>' + number(payload.poolCandidates) + '</b><span>prepared pool</span></div>' +
'<div class="stat"><b>' + number(payload.permissionEligible) + '</b><span>permission eligible</span></div>' +
'<div class="stat"><b>' + number((payload.items || []).length) + '</b><span>ranked result</span></div>' +
'<div class="stat"><b>' + number(meta.sourceEvents) + '</b><span>source events</span></div>' +
'<div class="stat"><b style="font-size:14px">' + escapeHtml(meta.algorithmVersion || '—') +
'</b><span>algorithm</span></div>' +
'<div class="stat"><b style="font-size:14px">' + when(meta.poolBuiltAt) +
'</b><span>pool built</span></div>';
const affinities = affinityEntries(payload.profile || {}).slice(0, 24);
const actions = payload.actions || [];
document.getElementById('recommendation-profile').innerHTML =
'<details><summary>Profile evidence · top affinities and explicit actions</summary>' +
'<div style="margin-top:8px">' +
(affinities.length ? affinities.map((entry) =>
'<span class="component ' + (entry.weight < 0 ? 'negative' : '') + '">' +
escapeHtml(entry.dimension + ': ' + entry.name) + ' ' +
(entry.weight >= 0 ? '+' : '') + entry.weight.toFixed(3) +
' · n=' + number(entry.evidence) + '</span>').join('') :
'<span class="muted">No repeated affinity evidence yet; cold-start priors apply.</span>') +
'</div><div style="margin-top:8px">' +
(actions.length ? actions.map((action) =>
'<span class="reason-code">' +
escapeHtml(action.action + ': ' + (action.title || action.itemId)) + '</span>').join('') :
'<span class="muted">No explicit recommendation actions.</span>') +
'</div></details>';
const results = payload.items || [];
document.getElementById('recommendation-results').innerHTML = results.length
? results.map((item, index) => {
const explanation = item.explanation || {};
const components = Object.entries(explanation.components || {})
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]));
const codes = explanation.reasonCodes || [];
const exposure = item.exposure || {};
return '<article class="recommendation-card">' +
'<div class="recommendation-head"><div>' +
'<div class="recommendation-title">#' + (index + 1) + ' · ' + escapeHtml(item.title) + '</div>' +
'<div class="recommendation-meta">' +
escapeHtml(item.type) + (item.year ? ' · ' + item.year : '') +
(item.runtimeMinutes ? ' · ' + item.runtimeMinutes + ' min' : '') +
(item.genres || []).map((genre) => ' · ' + escapeHtml(genre)).join('') +
'</div></div>' +
'<div class="recommendation-score">' + Number(explanation.total || 0).toFixed(3) + '</div>' +
'</div>' +
'<div class="reason-text">' +
escapeHtml(item.preparedReason || 'No legacy prepared explanation') +
(item.compatibilityLabel ? ' · ' + escapeHtml(item.compatibilityLabel) : '') +
'</div><div>' +
codes.map((code) => '<span class="reason-code">' + escapeHtml(code) + '</span>').join('') +
'</div><div>' +
components.map(([name, value]) =>
'<span class="component ' + (value < 0 ? 'negative' : '') + '">' +
escapeHtml(name) + '=' + (value >= 0 ? '+' : '') + Number(value).toFixed(3) +
'</span>').join('') +
'</div><details><summary>Pool, row and exposure details</summary>' +
'<div class="recommendation-meta">Base rank ' + 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 ' + number(exposure.impressions) +
' · focuses ' + number(exposure.focuses) +
' · selects ' + number(exposure.selects) + '</div>' +
'<div style="margin-top:6px">' + (item.eligibleRows || []).map((row) =>
'<span class="reason-code">' + escapeHtml(row) + '</span>').join('') + '</div>' +
(item.preparedEvidenceTitle ?
'<div class="recommendation-meta">Prepared evidence: ' +
escapeHtml(item.preparedEvidenceTitle) + '</div>' : '') +
'</details></article>';
}).join('')
: '<span class="muted">No candidates survived this context, explicit exclusions and permission filter.</span>';
}
async function loadRecommendationInspection() {
const userId = document.getElementById('recommendation-user').value;
if (!userId) {
showError('Choose a user to pressure-test.');
return;
}
const params = new URLSearchParams({
userId,
context: document.getElementById('recommendation-context').value,
minutes: document.getElementById('recommendation-minutes').value || '0',
limit: '100',
});
const localAt = document.getElementById('recommendation-at').value;
if (localAt) params.set('at', new Date(localAt).toISOString());
document.getElementById('recommendation-results').innerHTML =
'<span class="muted">Running permission check and scorer…</span>';
try {
renderRecommendationInspection(await api('/admin/api/recommendations?' + params.toString()));
showError('');
} catch (err) {
showError(err.message);
document.getElementById('recommendation-results').innerHTML =
'<span class="muted">Pressure test failed.</span>';
}
}
async function refresh() {
const live = document.getElementById('live');
const railLive = document.getElementById('rail-live');
try {
const [status, analytics] = await Promise.all([
api('/admin/api/status'),
currentAdminPage === 'engagement'
? api('/admin/api/analytics?days=' + document.getElementById('days').value)
: Promise.resolve(null),
]);
renderStatus(status);
if (analytics) renderAnalytics(analytics);
const updatedAt = new Date().toLocaleTimeString();
live.textContent = 'updated ' + updatedAt;
live.className = 'pill ok';
railLive.textContent = 'online';
railLive.className = 'pill ok';
showError('');
} catch (err) {
live.textContent = 'error';
live.className = 'pill bad';
railLive.textContent = 'offline';
railLive.className = 'pill bad';
showError(err.message);
}
}
async function act(fn) {
try {
await fn();
await refresh();
} catch (err) {
showError(err.message);
}
}
document.getElementById('sync-incremental').addEventListener('click', () =>
act(() => api('/admin/api/sync', { method: 'POST', body: JSON.stringify({ kind: 'incremental' }) })));
document.getElementById('sync-full').addEventListener('click', () => {
if (!confirm('Re-import the entire library? This can take several minutes.')) return;
act(() => api('/admin/api/sync', { method: 'POST', body: JSON.stringify({ kind: 'full' }) }));
});
function forYouAction(action) {
return api('/admin/api/for-you', {
method: 'POST',
body: JSON.stringify({ action }),
});
}
document.getElementById('for-you-import').addEventListener('click', () =>
act(() => forYouAction('incremental-import')));
document.getElementById('for-you-full').addEventListener('click', () => {
if (!confirm('Backfill all Tracearr history and rebuild every active user pool?')) return;
act(() => forYouAction('full-import'));
});
document.getElementById('for-you-rebuild').addEventListener('click', () =>
act(() => forYouAction('rebuild-all')));
document.getElementById('recommendation-load').addEventListener('click', loadRecommendationInspection);
document.getElementById('request-save').addEventListener('click', () => {
const allowedUserIds = [...document.querySelectorAll('[data-request-user]:checked')]
.map((box) => box.dataset.requestUser);
act(() => api('/admin/api/request-policy', {
method: 'POST',
body: JSON.stringify({ allowedUserIds }),
}));
});
document.getElementById('maintenance-on').addEventListener('click', () => {
if (!confirm('Take Memby offline for every TV?')) return;
act(() => api('/admin/api/maintenance', {
method: 'POST',
body: JSON.stringify({ enabled: true, message: document.getElementById('maintenance-message').value }),
}));
});
document.getElementById('maintenance-off').addEventListener('click', () =>
act(() => api('/admin/api/maintenance', {
method: 'POST',
body: JSON.stringify({ enabled: false, message: document.getElementById('maintenance-message').value }),
})));
function featureAction(action, overrides = {}) {
return api('/admin/api/features', {
method: 'POST',
body: JSON.stringify({ action, expectedRevision: featureRevision, overrides }),
});
}
document.getElementById('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;
});
act(() => featureAction('save', overrides));
});
document.getElementById('feature-safe-mode').addEventListener('click', () => {
const leaving = document.getElementById('feature-safe-mode').textContent.startsWith('Leave');
if (!leaving && !confirm('Disable every optional feature immediately? Core sign-in, browsing and playback remain available.')) return;
act(() => featureAction(leaving ? 'leave-safe-mode' : 'safe-mode'));
});
document.getElementById('feature-rollback').addEventListener('click', () => {
if (!confirm('Restore the previous published feature revision?')) return;
act(() => featureAction('rollback'));
});
document.getElementById('feature-reset').addEventListener('click', () => {
if (!confirm('Clear every override and return all features to their safe software defaults?')) return;
act(() => featureAction('reset'));
});
document.getElementById('playback-save').addEventListener('click', () => {
const durationSeconds = Number(document.getElementById('preroll-duration').value);
if (!Number.isFinite(durationSeconds) || durationSeconds < 1 || durationSeconds > 30) {
showError('Preroll duration must be between 1 and 30 seconds.');
return;
}
act(() => api('/admin/api/playback-policy', {
method: 'POST',
body: JSON.stringify({
prerollEnabled: document.getElementById('preroll-enabled').checked,
prerollDurationMs: Math.round(durationSeconds * 1000),
}),
}));
});
document.getElementById('mdblist-save').addEventListener('click', () => {
const sources = [...document.querySelectorAll('[data-mdblist-source]:checked')]
.map((box) => box.dataset.mdblistSource);
act(() => api('/admin/api/mdblist-settings', {
method: 'POST',
body: JSON.stringify({
enabled: document.getElementById('mdblist-enabled').checked,
apiKey: document.getElementById('mdblist-api-key').value.trim(),
clearApiKey: document.getElementById('mdblist-clear-key').checked,
sources,
}),
}).then(() => {
document.getElementById('mdblist-api-key').value = '';
document.getElementById('mdblist-clear-key').checked = false;
}));
});
function updatePolicyBody(enabled) {
return JSON.stringify({
enabled,
latestVersion: document.getElementById('update-version').value.trim(),
downloadUrl: document.getElementById('update-url').value.trim(),
notes: document.getElementById('update-notes').value.trim(),
required: document.getElementById('update-required').checked,
});
}
document.getElementById('update-save').addEventListener('click', () => {
if (document.getElementById('update-required').checked &&
!confirm('Required updates block the home screen on every TV below this version. Continue?')) return;
act(() => api('/admin/api/update-policy', { method: 'POST', body: updatePolicyBody(true) }));
});
document.getElementById('update-disable').addEventListener('click', () =>
act(() => api('/admin/api/update-policy', { method: 'POST', body: updatePolicyBody(false) })));
document.getElementById('days').addEventListener('change', refresh);
const eventState = {
cursor: 0,
records: [],
dropped: 0,
paused: false,
fetching: false,
};
const eventRanks = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
function eventText(event) {
return [event.message, ...Object.entries(event.attributes || {}).flat()].join(' ').toLowerCase();
}
function renderEvents() {
const minimum = eventRanks[document.getElementById('event-level').value] || 20;
const search = document.getElementById('event-search').value.trim().toLowerCase();
const filtered = eventState.records.filter((event) =>
(eventRanks[event.level] || 0) >= minimum && (!search || eventText(event).includes(search)));
// Rendering every retained record at once can freeze a browser during an incident.
// Keep all records available for filtering/export, but virtualise the visible tail.
const visible = filtered.slice(-2500);
const log = document.getElementById('event-log');
const pinned = log.scrollHeight - log.scrollTop - log.clientHeight < 50;
log.innerHTML = visible.length ? visible.map((event) => {
const attrs = Object.entries(event.attributes || {})
.map(([key, value]) => escapeHtml(key) + '=' + escapeHtml(value)).join(' ');
return '<div class="event-line">' +
'<span class="event-time">' + escapeHtml(when(event.occurredAt)) + '</span>' +
'<span class="event-level ' + escapeHtml(event.level) + '">' + escapeHtml(event.level) + '</span>' +
'<span>' + escapeHtml(event.message) + '</span>' +
'<span class="event-attrs">' + attrs + '</span></div>';
}).join('') : '<div class="event-empty">No events match this filter.</div>';
if (pinned) log.scrollTop = log.scrollHeight;
document.getElementById('event-stats').textContent =
number(eventState.records.length) + ' retained · ' + number(filtered.length) + ' matching' +
(visible.length < filtered.length ? ' · showing latest ' + number(visible.length) : '') +
(eventState.dropped ? ' · ' + number(eventState.dropped) + ' overwritten before delivery' : '');
}
async function pollEvents() {
if (eventState.paused || eventState.fetching) return;
eventState.fetching = true;
try {
let pages = 0;
let page;
do {
page = await api('/admin/api/events?after=' + eventState.cursor + '&limit=1000');
eventState.cursor = page.next || eventState.cursor;
eventState.dropped += page.dropped || 0;
if ((page.events || []).length) {
eventState.records.push(...page.events);
if (eventState.records.length > 20000) {
eventState.records.splice(0, eventState.records.length - 20000);
}
}
pages += 1;
} while (page.hasMore && pages < 20 && !eventState.paused);
renderEvents();
showError('');
} catch (err) {
showError(err.message);
} finally {
eventState.fetching = false;
}
}
document.getElementById('event-pause').addEventListener('click', () => {
eventState.paused = !eventState.paused;
document.getElementById('event-pause').textContent = eventState.paused ? 'Resume' : 'Pause';
if (!eventState.paused) pollEvents();
});
document.getElementById('event-clear').addEventListener('click', () => {
eventState.records = [];
eventState.dropped = 0;
renderEvents();
});
document.getElementById('event-export').addEventListener('click', () => {
const blob = new Blob([JSON.stringify(eventState.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);
});
document.getElementById('event-level').addEventListener('change', renderEvents);
document.getElementById('event-search').addEventListener('input', renderEvents);
refresh();
setInterval(refresh, 5000);
if (currentAdminPage === 'logs') {
pollEvents();
setInterval(pollEvents, 5 * 60 * 1000);
}
</script>
</body>
</html>