523 lines
22 KiB
HTML
523 lines
22 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: #0b0e11; --panel: #151a20; --line: #232a32;
|
|
--text: #e6eaee; --muted: #97a1ab; --accent: #52b54b; --danger: #e5534b;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0; padding: 28px; background: var(--bg); color: var(--text);
|
|
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
}
|
|
main { width: 100%; margin: 0; display: grid; gap: 20px; }
|
|
h1 { font-size: 24px; margin: 0; }
|
|
h2 { font-size: 15px; margin: 0 0 12px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
|
|
header { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
|
section { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 18px; }
|
|
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] {
|
|
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); }
|
|
@media (max-width: 900px) {
|
|
body { padding:14px; }
|
|
.event-line { grid-template-columns:150px 55px 1fr; }
|
|
.event-attrs { grid-column:1 / -1; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<header>
|
|
<h1>Memby admin</h1>
|
|
<span id="live" class="pill muted">connecting…</span>
|
|
</header>
|
|
|
|
<div id="error" class="banner"></div>
|
|
|
|
<section>
|
|
<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>
|
|
<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>
|
|
</section>
|
|
|
|
<section>
|
|
<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>
|
|
<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>
|
|
<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>
|
|
<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>
|
|
<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>
|
|
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 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.';
|
|
|
|
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 || '';
|
|
|
|
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>';
|
|
}
|
|
|
|
async function refresh() {
|
|
const live = document.getElementById('live');
|
|
try {
|
|
const [status, analytics] = await Promise.all([
|
|
api('/admin/api/status'),
|
|
api('/admin/api/analytics?days=' + document.getElementById('days').value),
|
|
]);
|
|
renderStatus(status);
|
|
renderAnalytics(analytics);
|
|
live.textContent = 'updated ' + new Date().toLocaleTimeString();
|
|
live.className = 'pill ok';
|
|
showError('');
|
|
} catch (err) {
|
|
live.textContent = 'error';
|
|
live.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('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 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);
|
|
pollEvents();
|
|
setInterval(pollEvents, 5 * 60 * 1000);
|
|
</script>
|
|
</body>
|
|
</html>
|