1308 lines
67 KiB
HTML
1308 lines
67 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; }
|
||
.account-summary {
|
||
display:grid; grid-template-columns:repeat(3,minmax(150px,1fr)); gap:12px; margin-bottom:16px;
|
||
}
|
||
.account-stat { padding:15px; border:1px solid var(--line); border-radius:10px; background:#10151a; }
|
||
.account-stat b { display:block; font-size:24px; }
|
||
.account-stat span { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.06em; }
|
||
.account-list { display:grid; gap:12px; }
|
||
.account-card { border:1px solid var(--line); border-radius:11px; background:#10151a; overflow:hidden; }
|
||
.account-head { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; padding:17px; }
|
||
.account-name { display:flex; align-items:center; gap:12px; }
|
||
.account-avatar { display:grid; place-items:center; width:42px; height:42px; border-radius:50%; background:var(--accent-soft); color:#91df94; font-size:17px; font-weight:800; }
|
||
.account-name h3 { margin:0; font-size:17px; }
|
||
.account-name p { margin:2px 0 0; color:var(--muted); font-size:12px; }
|
||
.account-actions { display:flex; gap:8px; flex-wrap:wrap; justify-content:flex-end; }
|
||
.account-actions button,.device-actions button { padding:7px 10px; font-size:12px; }
|
||
.account-body { display:grid; grid-template-columns:minmax(0,1.2fr) minmax(280px,.8fr); border-top:1px solid var(--line); }
|
||
.account-pane { padding:16px 17px; min-width:0; }
|
||
.account-pane + .account-pane { border-left:1px solid var(--line); }
|
||
.account-pane h4 { margin:0 0 10px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:.07em; }
|
||
.device-row { display:grid; grid-template-columns:minmax(150px,1fr) auto; gap:12px; align-items:center; padding:10px 0; border-top:1px solid #202830; }
|
||
.device-row:first-of-type { border-top:0; padding-top:0; }
|
||
.device-title { font-weight:650; }
|
||
.device-meta { color:var(--muted); font-size:12px; margin-top:2px; }
|
||
.device-actions { display:flex; gap:6px; }
|
||
.choice-list { display:flex; gap:6px; flex-wrap:wrap; }
|
||
.choice { padding:4px 8px; border-radius:6px; background:#202830; color:#dce3e8; font-size:12px; }
|
||
.choice.rating { background:#302b16; color:#f2d575; }
|
||
.account-note { padding:12px 14px; border:1px solid #2b4830; border-radius:8px; background:rgba(82,181,75,.07); color:#b9c6bc; font-size:13px; margin-bottom:16px; }
|
||
.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; }
|
||
.account-body { grid-template-columns:1fr; }
|
||
.account-pane + .account-pane { border-left:0; border-top:1px solid var(--line); }
|
||
}
|
||
@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; }
|
||
.account-summary { grid-template-columns:1fr; }
|
||
.account-head,.device-row { grid-template-columns:1fr; display:grid; }
|
||
.account-actions { justify-content:flex-start; }
|
||
}
|
||
@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/accounts" data-section="accounts" title="Memby accounts">
|
||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 19v-1.5A3.5 3.5 0 0 0 12.5 14h-5A3.5 3.5 0 0 0 4 17.5V19M10 10a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-2h3m-1.5-1.5v3"/></svg><span>Memby accounts</span>
|
||
</a>
|
||
<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="accounts" data-admin-page="accounts">
|
||
<h2>Memby accounts</h2>
|
||
<div class="account-note">
|
||
This is the Memby account 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.
|
||
</div>
|
||
<div id="account-summary" class="account-summary"><span class="muted">Loading accounts…</span></div>
|
||
<div id="account-list" class="account-list"><span class="muted">Loading accounts…</span></div>
|
||
</section>
|
||
|
||
<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 = {
|
||
accounts: ['Memby accounts', 'See who uses Memby, where they are signed in and how they personalised recommendations.'],
|
||
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) => ({
|
||
'&':'&', '<':'<', '>':'>', '"':'"', "'":''',
|
||
}[char]));
|
||
|
||
function initials(name) {
|
||
return String(name || '?').trim().split(/\s+/).slice(0, 2).map((part) => part[0] || '').join('').toUpperCase();
|
||
}
|
||
|
||
function recent(value) {
|
||
if (!value) return false;
|
||
return Date.now() - new Date(value).getTime() < 15 * 60 * 1000;
|
||
}
|
||
|
||
function renderAccounts(payload) {
|
||
const accounts = payload.accounts || [];
|
||
const devices = accounts.flatMap((account) => account.devices || []);
|
||
const online = devices.filter((device) => recent(device.lastSeen)).length;
|
||
const personalised = accounts.filter((account) => account.recommendations?.completed).length;
|
||
document.getElementById('account-summary').innerHTML =
|
||
'<div class="account-stat"><b>' + number(accounts.length) + '</b><span>Memby accounts</span></div>' +
|
||
'<div class="account-stat"><b>' + number(devices.length) + '</b><span>signed-in devices · ' + online + ' active now</span></div>' +
|
||
'<div class="account-stat"><b>' + number(personalised) + '</b><span>recommendation prompts completed</span></div>';
|
||
|
||
document.getElementById('account-list').innerHTML = accounts.length ? accounts.map((account) => {
|
||
const prefs = account.recommendations || {};
|
||
const ratings = prefs.ratings || [];
|
||
const dimensions = [
|
||
['Genres', prefs.genres], ['Studios', prefs.studios], ['Actors', prefs.actors],
|
||
['Directors', prefs.directors], ['Types', prefs.contentTypes],
|
||
].filter((entry) => (entry[1] || []).length);
|
||
const choices = ratings.map((rating) =>
|
||
'<span class="choice rating" title="' + escapeHtml(rating.itemId) + '">' +
|
||
escapeHtml(rating.title) + ' · ' + number(rating.rating) + ' ★</span>').join('') +
|
||
dimensions.flatMap(([label, values]) => (values || []).map((value) =>
|
||
'<span class="choice">' + escapeHtml(label) + ': ' + escapeHtml(value) + '</span>')).join('');
|
||
const deviceRows = (account.devices || []).map((device) => {
|
||
const state = recent(device.lastSeen);
|
||
return '<div class="device-row"><div><div class="device-title">' +
|
||
'<span class="pill ' + (state ? 'ok' : 'muted') + '">' + (state ? 'active' : 'idle') + '</span> ' +
|
||
escapeHtml(device.name || 'Memby TV') + '</div><div class="device-meta">' +
|
||
escapeHtml(device.version ? 'Memby ' + device.version : 'Legacy Memby client') +
|
||
(device.protocol ? ' · protocol ' + escapeHtml(device.protocol) : '') +
|
||
' · last seen ' + escapeHtml(when(device.lastSeen)) +
|
||
' · signed in ' + escapeHtml(when(device.signedInAt)) + '</div></div>' +
|
||
'<div class="device-actions"><button class="secondary" data-account-action="rename-device" data-user-id="' +
|
||
escapeHtml(account.id) + '" data-device-id="' + escapeHtml(device.id) + '" data-device-name="' +
|
||
escapeHtml(device.name) + '"' + (!device.id ? ' disabled' : '') + '>Rename</button>' +
|
||
'<button class="secondary" data-account-action="remove-device" data-user-id="' + escapeHtml(account.id) +
|
||
'" data-device-id="' + escapeHtml(device.id) + '"' + (!device.id ? ' disabled' : '') + '>Sign out</button></div></div>';
|
||
}).join('');
|
||
const promptState = prefs.completed
|
||
? '<span class="pill ok">completed</span>'
|
||
: '<span class="pill warn">not completed</span>';
|
||
return '<article class="account-card"><div class="account-head"><div class="account-name">' +
|
||
'<div class="account-avatar">' + escapeHtml(initials(account.username)) + '</div><div><h3>' +
|
||
escapeHtml(account.username || 'Unnamed user') + '</h3><p>Memby account · last seen ' +
|
||
escapeHtml(when(account.lastSeen)) + ' · ' + number((account.devices || []).length) + ' device' +
|
||
((account.devices || []).length === 1 ? '' : 's') + '</p></div></div>' +
|
||
'<div class="account-actions">' + (prefs.updated
|
||
? '<button class="secondary" data-account-action="reset-recommendations" data-user-id="' +
|
||
escapeHtml(account.id) + '">Clear stored choices</button>' : '') +
|
||
'<button class="danger" data-account-action="remove-account" data-user-id="' + escapeHtml(account.id) +
|
||
'" data-username="' + escapeHtml(account.username) + '">Remove Memby access</button></div></div>' +
|
||
'<div class="account-body"><div class="account-pane"><h4>Signed-in devices</h4>' + deviceRows + '</div>' +
|
||
'<div class="account-pane"><h4>Recommendation prompt · ' + promptState + '</h4>' +
|
||
(choices || '<span class="muted">No recommendation selections have been saved.</span>') +
|
||
'</div></div></article>';
|
||
}).join('') : '<div class="account-note">No one has signed in to Memby yet. Emby-only users are intentionally not listed here.</div>';
|
||
}
|
||
|
||
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, accounts] = await Promise.all([
|
||
api('/admin/api/status'),
|
||
currentAdminPage === 'engagement'
|
||
? api('/admin/api/analytics?days=' + document.getElementById('days').value)
|
||
: Promise.resolve(null),
|
||
currentAdminPage === 'accounts'
|
||
? api('/admin/api/accounts')
|
||
: Promise.resolve(null),
|
||
]);
|
||
renderStatus(status);
|
||
if (analytics) renderAnalytics(analytics);
|
||
if (accounts) renderAccounts(accounts);
|
||
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('account-list').addEventListener('click', (event) => {
|
||
const button = event.target.closest('[data-account-action]');
|
||
if (!button) return;
|
||
const action = button.dataset.accountAction;
|
||
const userId = button.dataset.userId;
|
||
const base = '/admin/api/accounts/' + encodeURIComponent(userId);
|
||
if (action === 'rename-device') {
|
||
const deviceName = prompt('Name this Memby device', button.dataset.deviceName || 'Memby TV');
|
||
if (deviceName === null || !deviceName.trim()) return;
|
||
act(() => api(base + '/devices/' + encodeURIComponent(button.dataset.deviceId), {
|
||
method: 'PUT', body: JSON.stringify({deviceName: deviceName.trim()}),
|
||
}));
|
||
}
|
||
if (action === 'remove-device') {
|
||
if (!confirm('Sign this device out of Memby? Its Emby account will not be changed.')) return;
|
||
act(() => api(base + '/devices/' + encodeURIComponent(button.dataset.deviceId), {method: 'DELETE'}));
|
||
}
|
||
if (action === 'remove-account') {
|
||
if (!confirm('Remove Memby access for ' + (button.dataset.username || 'this user') +
|
||
'? Every Memby device will be signed out. Their Emby account will not be changed.')) return;
|
||
act(() => api(base + '/sessions', {method: 'DELETE'}));
|
||
}
|
||
if (action === 'reset-recommendations') {
|
||
if (!confirm('Clear this user’s stored recommendation choices? Viewing history remains intact.')) return;
|
||
act(() => api(base + '/recommendations', {method: 'DELETE'}));
|
||
}
|
||
});
|
||
|
||
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>
|