Big changes
This commit is contained in:
+182
-13
@@ -15,7 +15,7 @@
|
||||
margin: 0; padding: 28px; background: var(--bg); color: var(--text);
|
||||
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
main { max-width: 980px; margin: 0 auto; display: grid; gap: 20px; }
|
||||
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; }
|
||||
@@ -47,6 +47,32 @@
|
||||
.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>
|
||||
@@ -54,9 +80,6 @@
|
||||
<header>
|
||||
<h1>Memby admin</h1>
|
||||
<span id="live" class="pill muted">connecting…</span>
|
||||
<span style="flex:1"></span>
|
||||
<input type="password" id="token" placeholder="Admin token" autocomplete="off">
|
||||
<button id="save-token" class="secondary">Save</button>
|
||||
</header>
|
||||
|
||||
<div id="error" class="banner"></div>
|
||||
@@ -71,6 +94,17 @@
|
||||
</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">
|
||||
@@ -147,17 +181,29 @@
|
||||
</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>
|
||||
const tokenInput = document.getElementById('token');
|
||||
tokenInput.value = localStorage.getItem('memby_admin_token') || '';
|
||||
|
||||
document.getElementById('save-token').addEventListener('click', () => {
|
||||
localStorage.setItem('memby_admin_token', tokenInput.value.trim());
|
||||
refresh();
|
||||
});
|
||||
|
||||
function showError(message) {
|
||||
const banner = document.getElementById('error');
|
||||
banner.textContent = message || '';
|
||||
@@ -168,7 +214,6 @@ async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
...options,
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + (localStorage.getItem('memby_admin_token') || ''),
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
@@ -193,6 +238,9 @@ function duration(ms) {
|
||||
}
|
||||
|
||||
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 || {};
|
||||
@@ -211,6 +259,20 @@ function renderStatus(status) {
|
||||
? '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';
|
||||
@@ -311,6 +373,24 @@ document.getElementById('sync-full').addEventListener('click', () => {
|
||||
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', {
|
||||
@@ -346,8 +426,97 @@ document.getElementById('update-disable').addEventListener('click', () =>
|
||||
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user