Files
c-relay-pg/admin/assets/app.js
T
Laan Tungir ca7a4b6722 v2.1.36 - Unified relay table, caching/backfill separation, inbox defaults, profile gating, and UI fixes
- Added caching_relays unified table with live_enabled/backfill_enabled columns
- Separated --reset-backfill from --start-caching as independent flags
- Removed redundant caching_enabled master setting; daemon derives from live/backfill
- Set caching_inbox_enabled=true by default; removed Inbox toggle from Backfill page
- Set caching_live_strategy=cache_all by default
- Fixed outbox relay discovery to store ALL discovered relays, not just covering set
- Added store_kind_0_information config (default: true) to gate profile sync trigger
- Regenerated pg_schema.h from pg_schema.sql to include caching_relays table
- Fixed process toggle button styling to match monochrome aesthetic
- Fixed radio button styling to match black/white/red theme
- Simplified Backfill page: removed Service Status and Inbox Status sections
- Backfill status now respects config setting, not just daemon state
- Admin config API now bumps caching_config_generation for caching-related changes
- make_and_restart_relay.sh now resets PostgreSQL schema on fresh restart
2026-08-03 19:37:09 -04:00

1664 lines
73 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* admin2 — Full PHP admin for C-Relay-PG.
* Replaces the 7457-line api/index.js with a lightweight AJAX-based
* controller that fetches data from PHP API endpoints (api/*.php)
* instead of Nostr WebSocket admin commands.
*/
const REFRESH_MS = 10000;
let currentPage = 'statistics';
let statsInterval = null;
// HTML-escape helper for safe interpolation into innerHTML.
// Prevents stored XSS from user-controlled data (profile names, event
// content, config values, etc.) being parsed as HTML by the browser.
const esc = (s) => {
const str = String(s ?? '');
return str.replace(/[&<>"']/g, (ch) => {
return '&#' + ch.charCodeAt(0) + ';';
});
};
// Auth state
let nlLite = null;
let userPubkey = null;
let isLoggedIn = false;
let relayAnimationTimer = null;
// Server-rendered ASCII chart state
let currentChartRange = 'hour';
let chartLoadTimer = null;
// ================================
// NAVIGATION
// ================================
function toggleNav() {
document.getElementById('side-nav').classList.toggle('open');
document.getElementById('side-nav-overlay').classList.toggle('show');
}
document.getElementById('side-nav-overlay')?.addEventListener('click', toggleNav);
document.querySelectorAll('.nav-item').forEach(btn => {
btn.addEventListener('click', () => {
const page = btn.getAttribute('data-page');
switchPage(page);
document.getElementById('side-nav').classList.remove('open');
document.getElementById('side-nav-overlay').classList.remove('show');
});
});
function switchPage(pageName) {
currentPage = pageName;
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.remove('active');
if (item.getAttribute('data-page') === pageName) item.classList.add('active');
});
const sections = [
'databaseStatisticsSection', 'subscriptionDetailsSection', 'div_config',
'authRulesSection', 'wotSection', 'ipBansSection', 'relayEventsSection',
'backfillSection', 'cachingSection', 'nip17DMSection', 'cleanupSection', 'sqlQuerySection'
];
sections.forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; });
const pageMap = {
'statistics': 'databaseStatisticsSection',
'subscriptions': 'subscriptionDetailsSection',
'configuration': 'div_config',
'ip-bans': 'ipBansSection',
'relay-events': 'relayEventsSection',
'backfill': 'backfillSection',
'caching': 'cachingSection',
'dm': 'nip17DMSection',
'cleanup': 'cleanupSection',
'database': 'sqlQuerySection'
};
if (pageName === 'authorization') {
document.getElementById('authRulesSection').style.display = 'block';
document.getElementById('wotSection').style.display = 'block';
loadAuthRules();
loadWotStatus();
} else {
const target = pageMap[pageName];
if (target) document.getElementById(target).style.display = 'block';
}
// Load data for the page
const loaders = {
'statistics': loadStats,
'subscriptions': loadSubscriptions,
'configuration': loadConfig,
'ip-bans': loadIpBans,
'relay-events': loadEvents,
'backfill': loadBackfill,
'caching': loadCaching,
'dm': loadDMs,
'cleanup': loadCleanupQueries,
};
if (loaders[pageName]) loaders[pageName]();
// Auto-refresh: one interval that calls the right loader for the current page.
// When switching between statistics, backfill, or caching, the interval keeps running
// but picks the correct loader based on currentPage.
if (pageName === 'statistics' || pageName === 'backfill' || pageName === 'caching') {
if (pageName !== 'statistics') {
// Load immediately on switch for non-statistics pages
if (loaders[pageName]) loaders[pageName]();
}
if (!statsInterval) {
statsInterval = setInterval(() => {
if (currentPage === 'statistics') loadStats();
else if (currentPage === 'backfill') loadBackfill();
else if (currentPage === 'caching') loadCaching();
}, REFRESH_MS);
console.log('[admin2] auto-refresh started, interval:', REFRESH_MS, 'ms');
}
} else {
if (statsInterval) { clearInterval(statsInterval); statsInterval = null; }
}
}
// ================================
// STATISTICS
// ================================
async function loadStats() {
console.log('[admin2] loading stats at', new Date().toLocaleTimeString());
// Fire the RELAY letter animation on every refresh — visual indicator the page is updating
startRelayAnimation();
try {
const res = await fetch('api/stats.php');
if (!res.ok) { console.warn('[admin2] stats.php returned', res.status); return; }
const d = await res.json();
console.log('[admin2] stats loaded — events:', d.total_events, 'rate:', d.events_delta, '/10s');
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
set('db-size', d.db_size || '-');
set('total-events', (d.total_events ?? 0).toLocaleString());
set('process-id', d.process_id || '-');
set('websocket-connections', d.ws_connections ?? '-');
set('active-subscriptions', d.active_subscriptions ?? '-');
set('memory-usage', d.memory_usage || '-');
set('cpu-usage', d.cpu_usage || '-');
set('cpu-core', d.cpu_core || '-');
set('oldest-event', d.oldest_event || '-');
set('newest-event', d.newest_event || '-');
set('events-24h', (d.events_24h ?? 0).toLocaleString());
set('events-7d', (d.events_7d ?? 0).toLocaleString());
set('events-30d', (d.events_30d ?? 0).toLocaleString());
// Kind distribution
if (d.kinds) {
const tbody = document.getElementById('stats-kinds-table-body');
tbody.innerHTML = d.kinds.map(k =>
`<tr><td>${k.kind}</td><td>${k.count.toLocaleString()}</td><td>${k.pct}%</td></tr>`
).join('');
}
// Top pubkeys
if (d.top_pubkeys) {
const tbody = document.getElementById('stats-pubkeys-table-body');
tbody.innerHTML = d.top_pubkeys.map((p, i) =>
`<tr><td>${i+1}</td><td><bdi>${esc(p.name) || '<i>unknown</i>'}</bdi></td><td class="pubkey">${esc(p.pubkey.substring(0,16))}…</td><td>${p.count.toLocaleString()}</td><td>${p.pct}%</td></tr>`
).join('');
}
// Name-field usage stats (from profiles cache)
if (d.name_field_usage && d.name_field_usage.total > 0) {
const u = d.name_field_usage;
const el = document.getElementById('name-field-usage');
if (el) {
el.innerHTML = `
<div class="name-usage-stats">
<span class="name-usage-label">Profile name fields (${u.total} profiles):</span>
<span class="name-usage-item">Both: ${u.both}</span>
<span class="name-usage-item">name only: ${u.name_only}</span>
<span class="name-usage-item">display_name only: ${u.display_only}</span>
<span class="name-usage-item name-usage-differ">Differ: ${u.both_differ}</span>
</div>`;
}
}
// Refresh the chart on every stats poll (server handles caching for non-hour ranges)
loadChart(currentChartRange);
} catch (e) { console.error('[admin2] stats error:', e); }
}
// ================================
// EVENT RATE CHART (server-rendered ASCII via chart.php)
// ================================
// Fetch the ASCII chart from the server and inject it into the div.
// The chart is rendered server-side as plain text — works in both
// browser and terminal (curl http://localhost:8088/api/chart.php?range=hour)
async function loadChart(range) {
const el = document.getElementById('event-rate-chart');
if (!el) return;
try {
const res = await fetch('api/chart.php?range=' + encodeURIComponent(range));
if (!res.ok) { el.textContent = 'Chart load failed (HTTP ' + res.status + ')'; return; }
const text = await res.text();
el.textContent = text;
// Newest data is at the left (index 0) — scroll to start so it's visible
el.scrollLeft = 0;
} catch (e) {
console.error('[admin2] chart load error:', e);
el.textContent = 'Chart load error: ' + e.message;
}
}
// Chart range tab click handlers
document.querySelectorAll('.chart-tab').forEach(tab => {
tab.addEventListener('click', () => {
const range = tab.getAttribute('data-range');
if (!range || range === currentChartRange) return;
currentChartRange = range;
document.querySelectorAll('.chart-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
loadChart(range);
});
});
// ================================
// SUBSCRIPTIONS
// ================================
async function loadSubscriptions() {
try {
const res = await fetch('api/subscriptions.php');
const d = await res.json();
const tbody = document.getElementById('subscription-details-table-body');
if (!d.subscriptions || d.subscriptions.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;font-style:italic">No subscriptions active</td></tr>';
return;
}
tbody.innerHTML = d.subscriptions.map(s =>
`<tr><td>${esc(s.sub_id) || '-'}</td><td class="pubkey">${esc((s.pubkey||'-').substring(0,16))}</td><td>${esc(s.filters) || '-'}</td><td>${esc(s.created) || '-'}</td><td>${s.events_sent ?? 0}</td></tr>`
).join('');
} catch (e) { console.error('[admin2] subscriptions error:', e); }
}
// ================================
// CONFIGURATION
// ================================
async function loadConfig() {
try {
const res = await fetch('api/config.php');
const d = await res.json();
const tbody = document.getElementById('config-table-body');
if (!d.config || d.config.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;font-style:italic">No config entries</td></tr>';
return;
}
tbody.innerHTML = d.config.map(c =>
`<tr><td>${esc(c.key)}</td><td>${esc(c.value)}</td><td><button onclick="editConfig('${esc(c.key)}')">EDIT</button></td></tr>`
).join('');
} catch (e) { console.error('[admin2] config error:', e); }
}
function editConfig(key) {
const newVal = prompt('Enter new value for ' + key + ':');
if (newVal === null) return;
fetch('api/config.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({key, value: newVal})
}).then(r => r.json()).then(d => {
alert(d.message || 'Saved');
loadConfig();
}).catch(e => alert('Error: ' + e));
}
// ================================
// AUTH RULES
// ================================
async function loadAuthRules() {
try {
const res = await fetch('api/auth.php');
const d = await res.json();
const tbody = document.getElementById('authRulesTableBody');
if (!d.rules || d.rules.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;font-style:italic">No auth rules</td></tr>';
return;
}
tbody.innerHTML = d.rules.map(r =>
`<tr><td>${esc(r.rule_type)}</td><td>${esc(r.pattern_type)}</td><td>${esc(r.pattern_value)}</td><td>${esc(r.status) || 'active'}</td><td><button onclick="removeAuthRule(${r.id})">REMOVE</button></td></tr>`
).join('');
} catch (e) { console.error('[admin2] auth error:', e); }
}
async function addAuthRule(type) {
const pk = document.getElementById('authRulePubkey').value.trim();
if (!pk) { alert('Enter a pubkey first'); return; }
const res = await fetch('api/auth.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'add', rule_type: type, pattern_value: pk})
});
const d = await res.json();
alert(d.message || 'Done');
loadAuthRules();
}
async function removeAuthRule(id) {
const res = await fetch('api/auth.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'remove', id})
});
const d = await res.json();
alert(d.message || 'Done');
loadAuthRules();
}
// ================================
// WEB OF TRUST
// ================================
async function loadWotStatus() {
try {
const res = await fetch('api/auth.php?action=wot');
const d = await res.json();
const ind = document.getElementById('wotKind3Indicator');
if (ind) ind.textContent = d.kind3_status || 'Unknown';
const cnt = document.getElementById('wotWhitelistCount');
if (cnt) cnt.textContent = d.whitelist_count ?? '—';
const desc = document.getElementById('wotLevelDescription');
if (desc) desc.textContent = d.level_description || '';
} catch (e) { console.error('[admin2] wot error:', e); }
}
async function setWotLevel(level) {
const res = await fetch('api/auth.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'set_wot_level', level})
});
const d = await res.json();
alert(d.message || 'Done');
loadWotStatus();
}
async function syncWot() {
const res = await fetch('api/auth.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'sync_wot'})
});
const d = await res.json();
alert(d.message || 'Done');
loadWotStatus();
}
// ================================
// IP BANS
// ================================
async function loadIpBans() {
try {
const res = await fetch('api/ipbans.php');
const d = await res.json();
document.getElementById('ip-bans-total').textContent = d.total ?? '-';
document.getElementById('ip-bans-active').textContent = d.active ?? '-';
document.getElementById('ip-bans-issued').textContent = d.issued ?? '-';
const tbody = document.getElementById('ip-bans-tbody');
if (!d.bans || d.bans.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center">No IP bans</td></tr>';
return;
}
tbody.innerHTML = d.bans.map(b =>
`<tr><td>${esc(b.ip)}</td><td>${esc(b.status)}</td><td>${esc(b.banned_until)}</td><td>${b.failures ?? 0}</td><td><button onclick="removeBan('${esc(b.ip)}')">REMOVE</button></td></tr>`
).join('');
} catch (e) { console.error('[admin2] ipbans error:', e); }
}
async function addBan() {
const ip = document.getElementById('ban-ip-input').value.trim();
const duration = document.getElementById('ban-duration-select').value;
if (!ip) { alert('Enter an IP address'); return; }
const res = await fetch('api/ipbans.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'add', ip, duration: parseInt(duration)})
});
const d = await res.json();
document.getElementById('add-ban-status').textContent = d.message || 'Done';
loadIpBans();
}
async function removeBan(ip) {
const res = await fetch('api/ipbans.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'remove', ip})
});
const d = await res.json();
alert(d.message || 'Done');
loadIpBans();
}
// ================================
// RELAY EVENTS
// ================================
async function loadEvents() {
try {
const res = await fetch('api/events.php?limit=50');
const d = await res.json();
const tbody = document.getElementById('live-relay-events-table-body');
if (!d.events || d.events.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center">No events</td></tr>';
return;
}
tbody.innerHTML = d.events.map(e =>
`<tr><td>${esc(e.created_at)}</td><td>${e.kind}</td><td><bdi>${esc(e.display_name || (e.pubkey||'').substring(0,16) + '…')}</bdi></td><td class="pubkey">${esc((e.id||'').substring(0,16))}…</td><td>${esc((e.content||'').substring(0,80))}</td></tr>`
).join('');
} catch (err) { console.error('[admin2] events error:', err); }
}
// ================================
// BACKFILL (renamed from CACHING)
// ================================
let backfillPage = 1;
async function loadBackfill() {
try {
const res = await fetch('api/caching.php?page=' + backfillPage);
const d = await res.json();
// Config toggle state
if (d.config) {
const backfillEnabled = d.config.caching_backfill_enabled === 'true';
const inboxEnabled = d.config.caching_inbox_enabled === 'true';
const serviceRunning = d.state?.service_state === 'running';
const enProcessRunning = backfillEnabled && serviceRunning;
const enBtn = document.getElementById('backfill-toggle-btn');
if (enBtn) {
enBtn.innerHTML = backfillEnabled
? (enProcessRunning
? '<span class="process-spinner" aria-hidden="true"></span>Turn Backfill Off'
: 'Turn Backfill Off')
: 'Turn Backfill On';
enBtn.classList.toggle('process-running', enProcessRunning);
enBtn.setAttribute('aria-pressed', backfillEnabled ? 'true' : 'false');
}
// Show relay status section only when backfill or inbox is enabled
const relayGroup = document.getElementById('backfill-relay-group');
if (relayGroup) {
relayGroup.style.display = (backfillEnabled || inboxEnabled) ? '' : 'none';
}
}
// Upstream relay status window. The API reads the unified
// caching_relays table, keeping status aligned with relay controls.
const rsEl = document.getElementById('backfill-relay-status');
if (rsEl) {
if (d.upstreamRelays && d.upstreamRelays.length > 0) {
const connected = d.upstreamRelays.filter(r => r.status_code == 2).length;
const total = d.upstreamRelays.length;
rsEl.innerHTML = `<div class="relay-status-summary">Connected: ${connected}/${total}</div>` +
'<div class="relay-status-list">' +
d.upstreamRelays.map(r => {
const host = r.relay_url.replace(/^wss?:\/\//, '');
let cls = 'relay-status-unknown';
let label = esc(r.status_text || 'unknown');
if (r.status_code == 2) { cls = 'relay-status-ok'; label = 'connected'; }
else if (r.status_code == 1) { cls = 'relay-status-connecting'; label = 'connecting'; }
else if (r.status_code == 0) { cls = 'relay-status-disconnected'; label = 'disconnected'; }
else if (r.status_code < 0) { cls = 'relay-status-error'; label = esc(r.status_text || 'error'); }
return `<div class="relay-status-row ${cls}"><span class="relay-status-url" title="${esc(r.relay_url)}">${esc(host)}</span><span class="relay-status-badge">${label}</span></div>`;
}).join('') +
'</div>';
} else {
rsEl.innerHTML = '<p style="color:var(--muted-color);font-style:italic">No relay status data yet (waiting for heartbeat)</p>';
}
}
// Follows table
const tbody = document.getElementById('backfill-follows-table-body');
if (tbody && d.follows) {
if (d.follows.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;font-style:italic">No followed pubkeys</td></tr>';
} else {
const activePk = d.active && d.active.pubkey ? d.active.pubkey : '';
tbody.innerHTML = d.follows.map((f, i) => {
const npub = f.npub || f.pubkey.substring(0, 20);
const isActive = activePk && f.pubkey === activePk;
const relaySummary = f.relay_incomplete > 0
? `<span class="status-working">${f.relay_count - f.relay_incomplete}/${f.relay_count} done</span>`
: `${f.relay_count ?? 0} relays`;
// Per-relay detail row (hidden by default, toggle via click)
let relayDetail = '';
if (f.relays && f.relays.length > 0) {
relayDetail = '<div class="relay-progress-list">' +
f.relays.map(r => {
const statusIcon = r.complete ? '✓' : '⏳';
const statusBadge = r.complete
? `<span class="relay-status relay-done">${esc(r.last_status || 'eose')}</span>`
: `<span class="relay-status relay-pending">${esc(r.last_status || 'pending')}</span>`;
const relayHost = r.relay_url.replace(/^wss?:\/\//, '').replace(/\/relay$/, '');
return `<div class="relay-progress-row"><span class="relay-icon">${statusIcon}</span><span class="relay-url" title="${esc(r.relay_url)}">${esc(relayHost)}</span><span class="relay-events">${r.events_fetched} evts</span>${statusBadge}</div>`;
}).join('') +
'</div>';
}
const refreshBtn = `<button type="button" class="refresh-user-btn" onclick="event.stopPropagation(); refreshBackfillUser('${esc(f.pubkey)}', '${esc(f.name || '')}')">↻ Refresh this user</button>`;
const rowClass = isActive ? 'follows-row follows-row-active' : 'follows-row';
const activeIcon = isActive ? '⚡ ' : '';
return `<tr class="${rowClass}" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? '' : 'none'"><td><bdi>${activeIcon}${esc(f.name) || '<i>unknown</i>'}</bdi></td><td class="npub-link">${esc(npub)}…</td><td>${f.is_root ? '✓' : ''}</td><td>${f.total_events ?? 0}</td><td>${f.backfill_complete ? '✓' : '…'}</td><td>${relaySummary}</td></tr><tr class="follows-detail" style="display:none"><td colspan="6"><div class="follows-detail-content">${relayDetail || '<i>No relay progress data</i>'}<div class="follows-detail-actions">${refreshBtn}</div></div></td></tr>`;
}).join('');
}
}
// Pagination controls
const pgEl = document.getElementById('backfill-follows-pagination');
if (pgEl && d.totalFollows > 0) {
const totalPages = Math.ceil(d.totalFollows / (d.perPage || 50));
pgEl.innerHTML = '<span class="pagination-info">Page ' + d.page + ' of ' + totalPages + ' (' + d.totalFollows + ' total)</span>' +
'<div class="pagination-buttons">' +
(d.page > 1 ? '<button type="button" onclick="backfillPage=' + (d.page - 1) + '; loadBackfill()"> Prev</button>' : '') +
(d.page < totalPages ? '<button type="button" onclick="backfillPage=' + (d.page + 1) + '; loadBackfill()">Next </button>' : '') +
'</div>';
} else if (pgEl) {
pgEl.innerHTML = '';
}
// Active target — only show backfill progress when backfill is actually enabled
const fsEl = document.getElementById('backfill-follows-status');
if (fsEl) {
const backfillOn = d.config?.caching_backfill_enabled === 'true';
if (d.active && d.active.pubkey && d.active.relay && backfillOn) {
fsEl.innerHTML = `<span class="status-working">⚡ Backfilling: ${d.active.pubkey.substring(0,16)}… @ ${esc(d.active.relay)}</span>`;
} else if (backfillOn && d.state && d.state.service_state === 'running') {
const incomplete = d.state.backfill_authors_complete < d.state.backfill_authors_total;
fsEl.innerHTML = incomplete
? `<span class="status-working">⚡ Backfill in progress…</span>`
: `<span class="status-complete">✓ Backfill complete</span>`;
} else if (backfillOn) {
fsEl.innerHTML = `<span class="status-complete">✓ Backfill complete</span>`;
} else {
fsEl.innerHTML = `<span class="status-complete">Backfill is off</span>`;
}
}
} catch (e) { console.error('[admin2] backfill error:', e); }
}
// Toggle caching_backfill_enabled config via the config API.
async function toggleBackfillEnabled() {
try {
const res = await fetch('api/caching.php');
const d = await res.json();
const current = d.config?.caching_backfill_enabled === 'true';
const newVal = current ? 'false' : 'true';
const saveRes = await fetch('api/config.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({key: 'caching_backfill_enabled', value: newVal})
});
if (!saveRes.ok) throw new Error('Backfill setting update failed');
loadBackfill();
} catch (e) { console.error('[admin2] toggle backfill error:', e); }
}
// Inbox is always enabled by default and is controlled from Configuration.
// This legacy handler remains unused by the Backfill page.
async function toggleBackfillInboxEnabled() {
try {
const res = await fetch('api/caching.php');
const d = await res.json();
const current = d.config?.caching_inbox_enabled === 'true';
const newVal = current ? 'false' : 'true';
const saveRes = await fetch('api/config.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({key: 'caching_inbox_enabled', value: newVal})
});
if (!saveRes.ok) throw new Error('Inbox setting update failed');
loadBackfill();
} catch (e) { console.error('[admin2] toggle inbox error:', e); }
}
// Re-run all backfill: resets backfill progress for all followed authors and
// bumps caching_config_generation so the running service hot-reloads and
// re-drains from the beginning.
async function rerunAllBackfill() {
if (!confirm('Reset backfill progress for ALL followed authors? The caching service will re-download everything from scratch.')) return;
const btn = document.getElementById('backfill-rerun-all-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Resetting…'; }
try {
const res = await fetch('api/caching.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'reset_all'})
});
const d = await res.json();
if (d.ok) {
if (btn) { btn.textContent = '✓ Reset — re-draining'; }
setTimeout(() => { if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Backfill'; } loadBackfill(); }, 2000);
} else {
alert('Reset failed: ' + (d.error || 'unknown error'));
if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Backfill'; }
}
} catch (e) {
console.error('[admin2] rerunAllBackfill error:', e);
alert('Reset failed: ' + e.message);
if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Backfill'; }
}
}
// Refresh a single user: resets backfill progress for one followed author
// and bumps caching_config_generation so the service re-fetches that author.
async function refreshBackfillUser(pubkey, name) {
if (!confirm('Reset backfill progress for ' + (name || pubkey.substring(0, 16) + '…') + '? The caching service will re-download this user\'s events from scratch.')) return;
try {
const res = await fetch('api/caching.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'reset_user', pubkey: pubkey})
});
const d = await res.json();
if (d.ok) {
loadBackfill();
} else {
alert('Refresh failed: ' + (d.error || 'unknown error'));
}
} catch (e) {
console.error('[admin2] refreshBackfillUser error:', e);
alert('Refresh failed: ' + e.message);
}
}
// ================================
// CACHING (Live Subscription Design)
// ================================
async function loadCaching() {
try {
const res = await fetch('api/live_subscription.php');
const d = await res.json();
// Live subscription config
if (d.config) {
const strategy = d.config.caching_live_strategy || 'whitelist';
const kinds = d.config.caching_live_kinds || d.config.caching_kinds || '';
const since = d.config.caching_live_since_seconds || '0';
const limit = d.config.caching_live_limit || '0';
const liveEnabled = d.config.caching_live_enabled === 'true';
const backfillEnabled = d.config.caching_backfill_enabled === 'true';
const serviceRunning = d.state?.service_state === 'running';
const liveProcessRunning = liveEnabled && serviceRunning;
// Toggle button state: the spinner reflects the running daemon,
// not merely a persisted configuration value.
const enBtn = document.getElementById('caching-live-toggle-btn');
if (enBtn) {
enBtn.innerHTML = liveEnabled
? (liveProcessRunning
? '<span class="process-spinner" aria-hidden="true"></span>Turn Caching Off'
: 'Turn Caching Off')
: 'Turn Caching On';
enBtn.classList.toggle('process-running', liveProcessRunning);
enBtn.setAttribute('aria-pressed', liveEnabled ? 'true' : 'false');
}
// Set strategy radio
document.querySelectorAll('input[name="caching-strategy"]').forEach(r => {
r.checked = r.value === strategy;
});
document.getElementById('caching-kinds').value = kinds;
document.getElementById('caching-since').value = since;
document.getElementById('caching-limit').value = limit;
// Update strategy note text without auto-saving
const note = document.getElementById('caching-strategy-note');
if (note) {
note.textContent = strategy === 'cache_all'
? 'All events on the relay (no authors filter). Use the Cleanup page to periodically remove unwanted data.'
: 'Only events from followed pubkeys (authors filter auto-populated from follow graph).';
}
// Update filter preview
updateCachingFilterPreview();
}
// Live subscription status from service state
const lsEl = document.getElementById('caching-live-status');
if (lsEl) {
if (d.state && d.state.service_state) {
const s = d.state;
const hb = s.heartbeat_at ? new Date(s.heartbeat_at * 1000).toLocaleTimeString() : '—';
const liveEnabled = d.config?.caching_live_enabled === 'true';
lsEl.innerHTML = `<p>Service: <strong>${esc(s.service_state)}</strong> | Live sub: ${liveEnabled ? 'ON' : 'OFF'} | Heartbeat: ${hb}</p>`;
} else {
lsEl.innerHTML = '<p style="color:var(--muted-color);font-style:italic">Waiting for caching service status…</p>';
}
}
// Relay selection list (merged with upstream status)
renderCachingRelaySelection(d);
} catch (e) { console.error('[admin2] caching error:', e); }
}
// Toggle caching_live_enabled config via the live subscription API.
async function toggleCachingLiveEnabled() {
try {
const res = await fetch('api/live_subscription.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'toggle_enabled'})
});
const d = await res.json();
if (d.ok) {
loadCaching();
} else {
alert('Toggle failed: ' + (d.error || 'unknown error'));
}
} catch (e) {
console.error('[admin2] toggleCachingLiveEnabled error:', e);
alert('Toggle failed: ' + e.message);
}
}
// Toggle a relay's live_enabled or backfill_enabled flag via the API.
// The caching service hot-reloads on config generation bump.
async function toggleRelay(relayUrl, column) {
try {
const res = await fetch('api/live_subscription.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'toggle_relay', relay_url: relayUrl, column})
});
const d = await res.json();
if (!d.ok) {
console.warn('[admin2] toggleRelay failed:', d.error);
}
} catch (e) {
console.error('[admin2] toggleRelay error:', e);
}
}
// Render the relay selection list from the unified caching_relays table.
// Each relay has live_enabled and backfill_enabled checkboxes that toggle
// immediately via the API (no "Save" button needed for individual toggles).
function renderCachingRelaySelection(d) {
const el = document.getElementById('caching-relay-selection');
if (!el) return;
// Use unified relays array if available, otherwise fall back to old format
let relays = d.relays;
if (!relays || relays.length === 0) {
// Fallback: build from old upstreamRelays + discoveredRelays
const relayMap = {};
(d.discoveredRelays || []).forEach(r => {
relayMap[r.relay_url] = { follow_count: parseInt(r.follow_count, 10) || 0, status: null, live_enabled: false, backfill_enabled: false, is_bootstrap: false };
});
(d.upstreamRelays || []).forEach(r => {
if (!relayMap[r.relay_url]) relayMap[r.relay_url] = { follow_count: 0, status: null, live_enabled: false, backfill_enabled: false, is_bootstrap: false };
relayMap[r.relay_url].status = r;
});
const bootstrapSet = new Set((d.config?.caching_bootstrap_relays || '').split(',').map(s => s.trim()).filter(s => s));
const liveSet = new Set((d.config?.caching_live_relays || d.config?.caching_bootstrap_relays || '').split(',').map(s => s.trim()).filter(s => s));
relays = Object.entries(relayMap).map(([url, info]) => ({
relay_url: url,
live_enabled: liveSet.has(url) ? 't' : 'f',
backfill_enabled: liveSet.has(url) ? 't' : 'f',
status_code: info.status ? info.status.status_code : 0,
status_text: info.status ? info.status.status_text : '',
follow_count: info.follow_count,
is_bootstrap: bootstrapSet.has(url) ? 't' : 'f'
}));
}
if (relays.length === 0) {
el.innerHTML = '<p style="color:var(--muted-color);font-style:italic">No relays discovered yet (waiting for backfill progress data)</p>';
return;
}
let html = '<div class="relay-selection-list">';
html += '<div class="relay-selection-header">'
+ '<span class="sel-checkbox-label" title="Live subscription">Live</span>'
+ '<span class="sel-url">Relay</span>'
+ '<span class="sel-follows">Follows</span>'
+ '<span class="sel-badge">Status</span>'
+ '</div>';
relays.forEach(r => {
const url = r.relay_url;
const host = url.replace(/^wss?:\/\//, '');
const isBootstrap = r.is_bootstrap === 't' || r.is_bootstrap === true;
const liveChecked = r.live_enabled === 't' || r.live_enabled === true;
const followCount = parseInt(r.follow_count, 10) || 0;
let statusLabel = 'unknown';
let statusClass = 'sel-unknown';
const sc = parseInt(r.status_code, 10);
if (sc == 2) { statusClass = 'sel-ok'; statusLabel = 'connected'; }
else if (sc == 1) { statusClass = 'sel-connecting'; statusLabel = 'connecting'; }
else if (sc == 0) {
// If live_enabled, show as normal text (not dimmed) even before connected
if (liveChecked) {
statusClass = 'sel-enabled-waiting';
statusLabel = 'waiting...';
} else {
statusClass = 'sel-disconnected';
statusLabel = 'disconnected';
}
}
else { statusClass = 'sel-error'; statusLabel = esc(r.status_text || 'error'); }
const bootstrapTag = isBootstrap ? ' <span class="bootstrap-tag">bootstrap</span>' : '';
html += `<div class="relay-selection-row ${statusClass}">
<input type="checkbox" class="sel-checkbox-live" data-url="${esc(url)}" ${liveChecked ? 'checked' : ''} onchange="toggleRelay('${esc(url)}', 'live_enabled')">
<span class="sel-url" title="${esc(url)}">${esc(host)}${bootstrapTag}</span>
<span class="sel-follows">${followCount} follows</span>
<span class="sel-badge">${statusLabel}</span>
</div>`;
});
html += '</div>';
el.innerHTML = html;
}
// Wire up live preview updates on input changes
document.addEventListener('DOMContentLoaded', () => {
['caching-kinds', 'caching-since', 'caching-limit'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('input', updateCachingFilterPreview);
});
});
// Update the strategy description note when radio changes, and auto-save
function onCachingStrategyChange() {
const selected = document.querySelector('input[name="caching-strategy"]:checked');
const note = document.getElementById('caching-strategy-note');
if (selected && note) {
if (selected.value === 'whitelist') {
note.textContent = 'Only events from followed pubkeys (authors filter auto-populated from follow graph).';
} else {
note.textContent = 'All events on the relay (no authors filter). Use the Cleanup page to periodically remove unwanted data.';
}
}
// Auto-save strategy immediately so auto-refresh doesn't revert it
const strategy = selected?.value || 'whitelist';
fetch('api/live_subscription.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'save_config', strategy, kinds: '', since_seconds: '', limit: ''})
}).catch(e => console.error('[admin2] auto-save strategy error:', e));
// Update filter preview
updateCachingFilterPreview();
}
// Build and display the subscription filter preview
function updateCachingFilterPreview() {
const el = document.getElementById('caching-filter-preview');
if (!el) return;
const strategy = document.querySelector('input[name="caching-strategy"]:checked')?.value || 'whitelist';
const kindsRaw = document.getElementById('caching-kinds').value.trim();
const sinceSec = parseInt(document.getElementById('caching-since').value, 10) || 0;
const limitVal = parseInt(document.getElementById('caching-limit').value, 10) || 0;
const filter = {};
// Authors: only in whitelist mode
if (strategy === 'whitelist') {
filter.authors = '[auto-populated from follow graph]';
}
// Kinds
if (kindsRaw) {
const kinds = kindsRaw.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
if (kinds.length > 0) {
filter.kinds = kinds;
}
}
// Since
if (sinceSec > 0) {
filter.since = 'now - ' + sinceSec + 's';
} else {
filter.since = 'now';
}
// Limit
if (limitVal > 0) {
filter.limit = limitVal;
}
el.textContent = JSON.stringify(filter, null, 2);
}
// Save live subscription config
async function saveCachingConfig() {
const strategy = document.querySelector('input[name="caching-strategy"]:checked')?.value || 'whitelist';
const kinds = document.getElementById('caching-kinds').value.trim();
const since = document.getElementById('caching-since').value.trim();
const limit = document.getElementById('caching-limit').value.trim();
const statusEl = document.getElementById('caching-save-status');
if (statusEl) statusEl.textContent = 'Saving…';
try {
const res = await fetch('api/live_subscription.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
action: 'save_config',
strategy,
kinds,
since_seconds: since,
limit
})
});
const d = await res.json();
if (d.ok) {
if (statusEl) {
statusEl.textContent = '✓ Configuration saved.';
statusEl.style.color = 'var(--success-color, #27ae60)';
setTimeout(() => { if (statusEl) statusEl.textContent = ''; }, 3000);
}
} else {
if (statusEl) {
statusEl.textContent = 'Error: ' + (d.error || 'unknown');
statusEl.style.color = 'var(--error-color, #c0392b)';
}
}
} catch (e) {
console.error('[admin2] saveCachingConfig error:', e);
if (statusEl) {
statusEl.textContent = 'Error: ' + e.message;
statusEl.style.color = 'var(--error-color, #c0392b)';
}
}
}
// ================================
// DMs
// ================================
async function loadDMs() {
try {
const res = await fetch('api/dm.php?limit=50');
const d = await res.json();
const el = document.getElementById('dm-inbox');
if (!d.messages || d.messages.length === 0) {
el.innerHTML = '<div class="log-entry">No messages found.</div>';
return;
}
el.innerHTML = d.messages.map(m =>
`<div class="log-entry"><strong>kind ${m.kind}</strong> from <span class="pubkey">${esc((m.pubkey||'').substring(0,16))}…</span> at ${esc(m.created_at)}:<br>${esc((m.content||'').substring(0,200))}</div>`
).join('');
} catch (e) { console.error('[admin2] dm error:', e); }
}
// ================================
// SQL QUERY
// ================================
async function executeQuery() {
const sql = document.getElementById('sql-input').value.trim();
if (!sql) { alert('Enter a SQL query'); return; }
if (!sql.toUpperCase().startsWith('SELECT')) { alert('Only SELECT queries are allowed'); return; }
try {
const res = await fetch('api/query.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({sql})
});
const d = await res.json();
const info = document.getElementById('query-info');
const tableDiv = document.getElementById('query-table');
if (d.error) {
info.textContent = 'Error: ' + d.error;
tableDiv.innerHTML = '';
return;
}
info.textContent = `${d.row_count} rows in ${d.time_ms}ms`;
if (d.rows && d.rows.length > 0) {
const cols = Object.keys(d.rows[0]);
let html = '<table class="config-table"><thead><tr>';
cols.forEach(c => html += `<th>${c}</th>`);
html += '</tr></thead><tbody>';
d.rows.forEach(r => {
html += '<tr>';
cols.forEach(c => html += `<td>${r[c] ?? ''}</td>`);
html += '</tr>';
});
html += '</tbody></table>';
tableDiv.innerHTML = html;
} else {
tableDiv.innerHTML = '<p>No rows returned.</p>';
}
} catch (e) { console.error('[admin2] query error:', e); }
}
// ================================
// EVENT CLEANUP
// ================================
// ── State ──────────────────────────────────────────────────────────────
let cleanupEditId = null; // non-null when editing an existing saved query
// ── Load saved queries table ───────────────────────────────────────────
async function loadCleanupQueries() {
console.log('[admin2] loading cleanup queries');
try {
const res = await fetch('api/cleanup_queries.php');
const d = await res.json();
const tbody = document.getElementById('cleanup-saved-queries-body');
if (!d.queries || d.queries.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;font-style:italic">No saved queries. Create one below.</td></tr>';
return;
}
tbody.innerHTML = d.queries.map(q => {
const filterLabel = q.follows_filter === 'all' ? 'All'
: q.follows_filter === 'follows' ? 'Follows'
: 'Non-follows';
const kindsStr = q.kinds && q.kinds.length > 0 ? q.kinds.join(', ') : 'all';
const dateStr = (q.from_date || q.to_date)
? `${q.from_date || '…'}${q.to_date || '…'}`
: 'no date bound';
const limitStr = q.max_events > 0 ? q.max_events.toLocaleString() : '∞';
const filters = `${filterLabel} | kinds: ${kindsStr} | ${dateStr} | max: ${limitStr}`;
const previewInfo = q.last_preview_count > 0
? `${q.last_preview_count.toLocaleString()} evts (${q.last_preview_size_human})`
: '—';
const executedInfo = q.last_executed_at || '—';
return `<tr>
<td><strong>${esc(q.name)}</strong></td>
<td style="font-size:11px">${esc(filters)}</td>
<td style="font-size:11px">${previewInfo}</td>
<td style="font-size:11px">${esc(executedInfo)}</td>
<td>
<button onclick="runSavedCleanupPreview(${q.id})">Preview</button>
<button onclick="runSavedCleanupExecute(${q.id})">Execute</button>
<button onclick="loadSavedCleanupQuery(${q.id})">Edit</button>
<button onclick="deleteCleanupQuery(${q.id})">Delete</button>
</td>
</tr>`;
}).join('');
} catch (e) { console.error('[admin2] loadCleanupQueries error:', e); }
}
// ── New query (clear builder) ──────────────────────────────────────────
function newCleanupQuery() {
cleanupEditId = null;
document.getElementById('cleanup-name').value = '';
document.querySelectorAll('input[name="cleanup-follows"]').forEach(r => {
r.checked = r.value === 'all';
});
document.getElementById('cleanup-kinds').value = '';
document.getElementById('cleanup-from-date').value = '';
document.getElementById('cleanup-from-time').value = '';
document.getElementById('cleanup-to-date').value = '';
document.getElementById('cleanup-to-time').value = '';
document.getElementById('cleanup-max-events').value = '0';
document.getElementById('cleanup-results-group').style.display = 'none';
document.getElementById('cleanup-name').focus();
}
// ── Combine date + time inputs into a single datetime string ───────────
function combineDateTime(dateId, timeId) {
const dateVal = document.getElementById(dateId).value;
const timeVal = document.getElementById(timeId).value;
if (!dateVal) return '';
return timeVal ? dateVal + ' ' + timeVal : dateVal;
}
// ── Read filter values from the form ───────────────────────────────────
function readCleanupFilters() {
const followsEl = document.querySelector('input[name="cleanup-follows"]:checked');
const follows_filter = followsEl ? followsEl.value : 'all';
const kindsRaw = document.getElementById('cleanup-kinds').value.trim();
const kinds = kindsRaw ? kindsRaw.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n) && n > 0) : [];
const from_date = combineDateTime('cleanup-from-date', 'cleanup-from-time');
const to_date = combineDateTime('cleanup-to-date', 'cleanup-to-time');
const max_events = parseInt(document.getElementById('cleanup-max-events').value, 10) || 0;
const name = document.getElementById('cleanup-name').value.trim();
return { name, follows_filter, kinds, from_date, to_date, max_events };
}
// ── Preview ────────────────────────────────────────────────────────────
async function previewCleanup(queryId) {
const filters = readCleanupFilters();
const params = new URLSearchParams();
params.set('follows_filter', filters.follows_filter);
if (filters.kinds.length > 0) params.set('kinds', filters.kinds.join(','));
if (filters.from_date) params.set('from_date', filters.from_date);
if (filters.to_date) params.set('to_date', filters.to_date);
if (filters.max_events > 0) params.set('max_events', String(filters.max_events));
if (queryId) params.set('query_id', String(queryId));
try {
const res = await fetch('api/cleanup.php?' + params.toString());
const d = await res.json();
if (d.error) { alert('Preview error: ' + d.error); return; }
renderCleanupResults(d);
// Refresh saved queries table so last_preview updates show
if (queryId) loadCleanupQueries();
} catch (e) { console.error('[admin2] previewCleanup error:', e); alert('Preview failed: ' + e.message); }
}
// ── Render preview results ─────────────────────────────────────────────
function renderCleanupResults(d) {
const group = document.getElementById('cleanup-results-group');
group.style.display = 'block';
// Summary
const summary = document.getElementById('cleanup-results-summary');
let html = `<strong>Match count:</strong> ${(d.match_count ?? 0).toLocaleString()} events`;
if (d.total_size_human) {
html += ` &nbsp;|&nbsp; <strong>Estimated size:</strong> ${d.total_size_human}`;
}
if (d.avg_size_per_event) {
html += ` &nbsp;|&nbsp; <strong>Avg/event:</strong> ${d.avg_size_per_event} bytes`;
}
if (d.deleted_count !== undefined) {
html += `<br><strong style="color:#c0392b">Deleted:</strong> ${d.deleted_count.toLocaleString()} events`;
if (d.freed_human) html += ` &nbsp;|&nbsp; <strong>Freed:</strong> ${d.freed_human}`;
if (d.duration_ms) html += ` &nbsp;|&nbsp; <strong>Duration:</strong> ${d.duration_ms}ms`;
}
summary.innerHTML = html;
// Kind breakdown table
const bdEl = document.getElementById('cleanup-results-breakdown');
if (d.kinds_breakdown && d.kinds_breakdown.length > 0) {
let tbl = '<table class="config-table"><thead><tr><th>Kind</th><th>Count</th><th>Size</th><th>% of total</th></tr></thead><tbody>';
const totalBytes = d.total_size_bytes || 1;
d.kinds_breakdown.forEach(k => {
const pct = totalBytes > 0 ? (k.size_bytes / totalBytes * 100).toFixed(1) : 0;
tbl += `<tr><td>${k.kind}</td><td>${k.count.toLocaleString()}</td><td>${formatCleanupBytes(k.size_bytes)}</td><td>${pct}%</td></tr>`;
});
tbl += '</tbody></table>';
bdEl.innerHTML = tbl;
} else {
bdEl.innerHTML = '<p style="font-style:italic;color:var(--text-muted,#888)">No kind breakdown available.</p>';
}
// SQL preview
const sqlEl = document.getElementById('cleanup-sql-preview');
if (d.sql_preview) {
sqlEl.textContent = d.sql_preview;
} else {
sqlEl.textContent = '(SQL preview not available for this response)';
}
}
// ── Format bytes (local helper) ────────────────────────────────────────
function formatCleanupBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(2) + ' GB';
}
// ── Save query ─────────────────────────────────────────────────────────
async function saveCleanupQuery() {
const filters = readCleanupFilters();
if (!filters.name) { alert('Please enter a query name'); return; }
const body = {
action: 'save',
id: cleanupEditId,
name: filters.name,
follows_filter: filters.follows_filter,
kinds: filters.kinds,
from_date: filters.from_date,
to_date: filters.to_date,
max_events: filters.max_events,
};
try {
const res = await fetch('api/cleanup_queries.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
const d = await res.json();
if (d.error) { alert('Save failed: ' + d.error); return; }
cleanupEditId = d.id;
alert('Query saved!');
loadCleanupQueries();
} catch (e) { console.error('[admin2] saveCleanupQuery error:', e); alert('Save failed: ' + e.message); }
}
// ── Execute delete (show confirmation first) ───────────────────────────
let pendingExecuteFilters = null;
async function executeCleanup() {
const filters = readCleanupFilters();
pendingExecuteFilters = filters;
// Run a quick preview to show the user what will be deleted
const params = new URLSearchParams();
params.set('follows_filter', filters.follows_filter);
if (filters.kinds.length > 0) params.set('kinds', filters.kinds.join(','));
if (filters.from_date) params.set('from_date', filters.from_date);
if (filters.to_date) params.set('to_date', filters.to_date);
if (filters.max_events > 0) params.set('max_events', String(filters.max_events));
try {
const res = await fetch('api/cleanup.php?' + params.toString());
const d = await res.json();
const name = filters.name || 'Ad-hoc query';
const msg = `Query: ${esc(name)}\n`
+ `Events to delete: ${(d.match_count ?? 0).toLocaleString()}\n`
+ `Estimated space freed: ${d.total_size_human || 'unknown'}`;
document.getElementById('cleanup-confirm-msg').textContent = msg;
document.getElementById('cleanup-confirm-dialog').style.display = 'block';
} catch (e) {
// If preview fails, still allow delete with a basic confirmation
document.getElementById('cleanup-confirm-msg').textContent = `Events matching current filters.`;
document.getElementById('cleanup-confirm-dialog').style.display = 'block';
}
}
async function executeCleanupConfirmed() {
document.getElementById('cleanup-confirm-dialog').style.display = 'none';
if (!pendingExecuteFilters) return;
const filters = pendingExecuteFilters;
pendingExecuteFilters = null;
const body = {
follows_filter: filters.follows_filter,
kinds: filters.kinds,
from_date: filters.from_date,
to_date: filters.to_date,
max_events: filters.max_events,
dry_run: false,
};
try {
const res = await fetch('api/cleanup.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
const d = await res.json();
if (d.error) { alert('Delete failed: ' + d.error); return; }
renderCleanupResults(d);
// Refresh saved queries in case last_preview_count changed
loadCleanupQueries();
} catch (e) { console.error('[admin2] executeCleanup error:', e); alert('Delete failed: ' + e.message); }
}
// ── Delete saved query ─────────────────────────────────────────────────
async function deleteCleanupQuery(id) {
if (!confirm('Delete this saved query?')) return;
try {
const res = await fetch('api/cleanup_queries.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'delete', id})
});
const d = await res.json();
if (d.error) { alert('Delete failed: ' + d.error); return; }
loadCleanupQueries();
} catch (e) { console.error('[admin2] deleteCleanupQuery error:', e); alert('Delete failed: ' + e.message); }
}
// ── Load saved query into builder ──────────────────────────────────────
async function loadSavedCleanupQuery(id) {
try {
const res = await fetch('api/cleanup_queries.php');
const d = await res.json();
if (!d.queries) return;
const q = d.queries.find(q => q.id === id);
if (!q) { alert('Query not found'); return; }
cleanupEditId = q.id;
document.getElementById('cleanup-name').value = q.name;
document.querySelectorAll('input[name="cleanup-follows"]').forEach(r => {
r.checked = r.value === q.follows_filter;
});
document.getElementById('cleanup-kinds').value = (q.kinds || []).join(', ');
// Split stored datetime into date + time parts
const fromParts = (q.from_date || '').split(' ');
document.getElementById('cleanup-from-date').value = fromParts[0] || '';
document.getElementById('cleanup-from-time').value = fromParts[1] || '';
const toParts = (q.to_date || '').split(' ');
document.getElementById('cleanup-to-date').value = toParts[0] || '';
document.getElementById('cleanup-to-time').value = toParts[1] || '';
document.getElementById('cleanup-max-events').value = q.max_events;
document.getElementById('cleanup-results-group').style.display = 'none';
// Scroll to builder
document.getElementById('cleanup-builder-group').scrollIntoView({ behavior: 'smooth' });
} catch (e) { console.error('[admin2] loadSavedCleanupQuery error:', e); }
}
// ── Run preview for a saved query ──────────────────────────────────────
async function runSavedCleanupPreview(id) {
try {
const res = await fetch('api/cleanup_queries.php');
const d = await res.json();
if (!d.queries) return;
const q = d.queries.find(q => q.id === id);
if (!q) { alert('Query not found'); return; }
// Load into builder first so the user can see the filters
await loadSavedCleanupQuery(id);
// Then run preview with query_id so last_preview_count/size get saved
await previewCleanup(id);
} catch (e) { console.error('[admin2] runSavedCleanupPreview error:', e); }
}
// ── Run execute for a saved query ──────────────────────────────────────
async function runSavedCleanupExecute(id) {
try {
const res = await fetch('api/cleanup_queries.php');
const d = await res.json();
if (!d.queries) return;
const q = d.queries.find(q => q.id === id);
if (!q) { alert('Query not found'); return; }
// Load into builder
await loadSavedCleanupQuery(id);
// Confirm and execute via the saved query execute action
const filters = readCleanupFilters();
const name = q.name || 'Saved query';
const msg = `Query: ${name}\n`
+ `Follows: ${q.follows_filter}\n`
+ `Kinds: ${(q.kinds || []).join(', ') || 'all'}\n`
+ `From: ${q.from_date || 'no bound'}\n`
+ `To: ${q.to_date || 'no bound'}\n`
+ `Max events: ${q.max_events > 0 ? q.max_events.toLocaleString() : 'no limit'}`;
document.getElementById('cleanup-confirm-msg').textContent = msg;
document.getElementById('cleanup-confirm-btn').onclick = async () => {
document.getElementById('cleanup-confirm-dialog').style.display = 'none';
try {
const execRes = await fetch('api/cleanup_queries.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'execute', id})
});
const execD = await execRes.json();
if (execD.error) { alert('Delete failed: ' + execD.error); return; }
renderCleanupResults(execD);
loadCleanupQueries();
} catch (e) { console.error('[admin2] runSavedCleanupExecute error:', e); alert('Delete failed: ' + e.message); }
};
document.getElementById('cleanup-confirm-dialog').style.display = 'block';
} catch (e) { console.error('[admin2] runSavedCleanupExecute error:', e); }
}
// ================================
// DARK MODE
// ================================
document.getElementById('nav-dark-mode-btn')?.addEventListener('click', () => {
document.body.classList.toggle('dark-mode');
localStorage.setItem('admin2-dark-mode', document.body.classList.contains('dark-mode'));
const btn = document.getElementById('nav-dark-mode-btn');
if (btn) btn.textContent = document.body.classList.contains('dark-mode') ? 'LIGHT MODE' : 'DARK MODE';
});
if (localStorage.getItem('admin2-dark-mode') === 'true') {
document.body.classList.add('dark-mode');
const btn = document.getElementById('nav-dark-mode-btn');
if (btn) btn.textContent = 'LIGHT MODE';
}
// ================================
// RELAY LETTER ANIMATION
// ================================
// Animate the RELAY letters with an underline sweep. Fires on every stats
// refresh so the user gets a visual cue that the page is updating.
function startRelayAnimation() {
const letters = document.querySelectorAll('.relay-letter');
if (letters.length === 0) return;
// Cancel any in-flight animation so rapid refreshes don't overlap
if (relayAnimationTimer) { clearTimeout(relayAnimationTimer); relayAnimationTimer = null; }
let currentIndex = 0;
letters.forEach(l => l.classList.remove('underlined'));
function animateLetter() {
letters.forEach(letter => letter.classList.remove('underlined'));
if (letters[currentIndex]) {
letters[currentIndex].classList.add('underlined');
}
currentIndex++;
if (currentIndex > letters.length) {
// Sweep complete — clear underlines and pause before next refresh
letters.forEach(letter => letter.classList.remove('underlined'));
relayAnimationTimer = null;
return;
}
relayAnimationTimer = setTimeout(animateLetter, 100);
}
animateLetter();
}
// ================================
// RELAY PUBKEY COPY-TO-CLIPBOARD
// ================================
document.getElementById('relay-pubkey-container')?.addEventListener('click', async () => {
const el = document.getElementById('relay-pubkey');
if (!el || !el.textContent.trim()) return;
try {
await navigator.clipboard.writeText(el.textContent.replace(/\s+/g, ''));
const container = document.getElementById('relay-pubkey-container');
container.classList.add('copied');
setTimeout(() => container.classList.remove('copied'), 500);
} catch (e) { console.warn('[admin2] clipboard copy failed:', e); }
});
// ================================
// AUTH — nostr_login_lite modal
// ================================
const loginModal = document.getElementById('login-modal');
const loginModalContainer = document.getElementById('login-modal-container');
const profileArea = document.getElementById('profile-area');
const headerUserName = document.getElementById('header-user-name');
const headerUserImage = document.getElementById('header-user-image');
const logoutDropdown = document.getElementById('logout-dropdown');
function showLoginModal() {
if (loginModal && loginModalContainer) {
if (window.NOSTR_LOGIN_LITE && typeof window.NOSTR_LOGIN_LITE.embed === 'function') {
// Clear previous embed before re-embedding
loginModalContainer.innerHTML = '';
window.NOSTR_LOGIN_LITE.embed('#login-modal-container', { seamless: true });
}
loginModal.style.display = 'flex';
}
}
function hideLoginModal() {
if (loginModal) loginModal.style.display = 'none';
}
function showProfileInHeader() {
if (profileArea) profileArea.style.display = 'flex';
}
function hideProfileFromHeader() {
if (profileArea) profileArea.style.display = 'none';
}
// Toggle logout dropdown when clicking the profile area
profileArea?.addEventListener('click', (e) => {
// Only toggle if the click wasn't on the logout button itself
if (e.target.closest('.logout-btn')) return;
if (logoutDropdown) {
logoutDropdown.style.display = (logoutDropdown.style.display === 'none' || !logoutDropdown.style.display) ? 'block' : 'none';
}
});
// Hide logout dropdown when clicking elsewhere
document.addEventListener('click', (e) => {
if (logoutDropdown && logoutDropdown.style.display === 'block' && !e.target.closest('#profile-area')) {
logoutDropdown.style.display = 'none';
}
});
// Update header profile display from logged-in user's pubkey.
// Sets a placeholder from the npub immediately, then fetches the kind 0
// profile event from public relays to populate the name + picture.
function updateProfileDisplay(pubkey) {
if (!pubkey) return;
let npub = '';
try {
if (pubkey.length === 64 && /^[0-9a-fA-F]+$/.test(pubkey)) {
npub = window.NostrTools.nip19.npubEncode(pubkey);
}
} catch (err) { console.warn('[admin2] npub encode failed:', err); }
// Placeholder until the profile fetch resolves
if (headerUserName) {
headerUserName.textContent = npub ? npub.substring(0, 16) + '…' : pubkey.substring(0, 16) + '…';
}
if (headerUserImage) headerUserImage.style.display = 'none';
// Fetch kind 0 profile from public relays (same approach as original api page)
loadUserProfile(pubkey, npub);
}
// Apply profile data to the header name + profile picture.
// Uses best_name from the server (resolved per profile_name_preference).
function applyProfileToHeader(name, picture) {
if (headerUserName) headerUserName.textContent = name || 'Anonymous User';
if (headerUserImage && picture && typeof picture === 'string' &&
(picture.startsWith('http://') || picture.startsWith('https://'))) {
headerUserImage.src = picture;
headerUserImage.style.display = 'block';
headerUserImage.onerror = function() { this.style.display = 'none'; };
} else if (headerUserImage) {
headerUserImage.style.display = 'none';
}
}
// Fetch the user's profile. Tries the local profiles cache first
// (admin/api/profile.php), then falls back to public relays if the
// relay has no cached kind-0 for this pubkey.
async function loadUserProfile(pubkey, npub) {
if (!pubkey) return;
// Try local profiles cache first.
try {
const res = await fetch('api/profile.php?pubkey=' + encodeURIComponent(pubkey));
if (res.ok) {
const profile = await res.json();
if (profile && profile.best_name !== undefined) {
applyProfileToHeader(profile.best_name, profile.picture);
console.log('[admin2] profile loaded from local cache for', profile.best_name);
return;
}
}
} catch (e) {
// Local endpoint not available — fall through to public relays.
}
// Fall back to public relays.
if (!window.NostrTools || !window.NostrTools.SimplePool) {
if (headerUserName) headerUserName.textContent = npub ? npub.substring(0, 16) + '…' : 'Anonymous User';
return;
}
const relays = [
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nos.lol',
'wss://relay.primal.net',
'wss://relay.snort.social'
];
try {
const pool = new window.NostrTools.SimplePool();
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Profile query timeout')), 5000)
);
const queryPromise = pool.querySync(relays, {
kinds: [0],
authors: [pubkey],
limit: 1
});
const events = await Promise.race([queryPromise, timeoutPromise]);
try { await pool.close(relays); } catch (e) {}
if (events && events.length > 0) {
const profile = JSON.parse(events[0].content);
// Use best_name resolution: display_name first, then name.
const name = profile.display_name || profile.name || profile.displayName || 'Anonymous User';
const picture = profile.picture || profile.image || null;
applyProfileToHeader(name, picture);
console.log('[admin2] profile loaded from public relays for', name);
} else {
if (headerUserName) headerUserName.textContent = 'Anonymous User';
console.log('[admin2] no profile event found for', pubkey);
}
} catch (err) {
console.warn('[admin2] profile load failed:', err.message);
if (headerUserName) headerUserName.textContent = npub ? npub.substring(0, 16) + '…' : 'Error loading profile';
}
}
// Initialize nostr_login_lite and show modal if not already authenticated
async function initializeAuth() {
if (!window.NOSTR_LOGIN_LITE) {
console.warn('[admin2] NOSTR_LOGIN_LITE not loaded — skipping auth modal');
return;
}
try {
await window.NOSTR_LOGIN_LITE.init({
theme: 'default',
methods: {
extension: true,
local: true,
seedphrase: true,
readonly: true,
connect: true,
remote: true,
otp: false
},
floatingTab: { enabled: false }
});
nlLite = window.NOSTR_LOGIN_LITE;
console.log('[admin2] nostr_login_lite initialized');
// Check for existing auth state
let alreadyLoggedIn = false;
try {
const stored = localStorage.getItem('nostr_login_lite_auth');
if (stored) {
const parsed = JSON.parse(stored);
if (parsed && parsed.pubkey) {
userPubkey = parsed.pubkey;
isLoggedIn = true;
alreadyLoggedIn = true;
showProfileInHeader();
updateProfileDisplay(userPubkey);
hideLoginModal();
console.log('[admin2] existing auth restored for', userPubkey);
}
}
} catch (e) { /* no stored auth */ }
if (!alreadyLoggedIn) {
console.log('[admin2] no existing auth — showing login modal');
showLoginModal();
}
// Listen for auth events
window.addEventListener('nlMethodSelected', (event) => {
const { pubkey, method, error } = event.detail || {};
if (method && pubkey) {
userPubkey = pubkey;
isLoggedIn = true;
console.log('[admin2] login success via', method, pubkey);
hideLoginModal();
showProfileInHeader();
updateProfileDisplay(pubkey);
} else if (error) {
console.warn('[admin2] auth error:', error);
}
});
window.addEventListener('nlLogout', () => {
console.log('[admin2] logout event received');
userPubkey = null;
isLoggedIn = false;
hideProfileFromHeader();
if (logoutDropdown) logoutDropdown.style.display = 'none';
showLoginModal();
});
} catch (err) {
console.error('[admin2] nostr_login_lite init failed:', err);
}
}
// Logout function — clears auth state and re-shows login modal
async function logout() {
console.log('[admin2] logging out...');
try {
if (nlLite && typeof nlLite.logout === 'function') {
await nlLite.logout();
}
} catch (e) { console.warn('[admin2] nlLite.logout error:', e); }
userPubkey = null;
isLoggedIn = false;
hideProfileFromHeader();
if (logoutDropdown) logoutDropdown.style.display = 'none';
showLoginModal();
console.log('[admin2] logged out');
}
// ================================
// INIT
// ================================
switchPage('statistics');
// Start the RELAY animation immediately on page load
startRelayAnimation();
// Initialize auth + load initial chart on DOM ready
document.addEventListener('DOMContentLoaded', () => {
setTimeout(initializeAuth, 100);
// Load the default (hour) chart immediately
loadChart(currentChartRange);
});