1354 lines
60 KiB
JavaScript
1354 lines
60 KiB
JavaScript
/*
|
||
* 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',
|
||
'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',
|
||
'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,
|
||
'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 and caching, the interval keeps running
|
||
// but picks the correct loader based on currentPage.
|
||
if (pageName === 'statistics' || pageName === 'caching') {
|
||
const loader = pageName === 'statistics' ? loadStats : loadCaching;
|
||
loader(); // always load immediately on switch
|
||
if (!statsInterval) {
|
||
statsInterval = setInterval(() => {
|
||
const l = currentPage === 'statistics' ? loadStats : loadCaching;
|
||
l();
|
||
}, 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); }
|
||
}
|
||
|
||
// ================================
|
||
// CACHING
|
||
// ================================
|
||
|
||
let cachingPage = 1;
|
||
|
||
async function loadCaching() {
|
||
try {
|
||
const res = await fetch('api/caching.php?page=' + cachingPage);
|
||
const d = await res.json();
|
||
// Config toggle state
|
||
if (d.config) {
|
||
const enabled = d.config.caching_enabled === 'true';
|
||
const inboxEnabled = d.config.caching_inbox_enabled === 'true';
|
||
const enLabel = document.getElementById('caching-enabled-label');
|
||
const enBtn = document.getElementById('caching-toggle-btn');
|
||
const inLabel = document.getElementById('caching-inbox-enabled-label');
|
||
const inBtn = document.getElementById('caching-inbox-toggle-btn');
|
||
if (enLabel) enLabel.textContent = 'Caching: ' + (enabled ? 'ON' : 'OFF');
|
||
if (enBtn) enBtn.textContent = enabled ? 'Turn OFF' : 'Turn ON';
|
||
if (inLabel) inLabel.textContent = 'Inbox: ' + (inboxEnabled ? 'ON' : 'OFF');
|
||
if (inBtn) inBtn.textContent = inboxEnabled ? 'Turn OFF' : 'Turn ON';
|
||
}
|
||
// Service status
|
||
const ssEl = document.getElementById('caching-service-status');
|
||
if (ssEl && d.state) {
|
||
const s = d.state;
|
||
const hb = s.heartbeat_at ? new Date(s.heartbeat_at * 1000).toLocaleTimeString() : '—';
|
||
ssEl.innerHTML = `<p>State: <strong>${esc(s.service_state)}</strong> | Follows: ${s.followed_author_count ?? 0} | Connected relays: ${s.connected_relay_count ?? 0}/${s.selected_relay_count ?? 0} | Backfill: ${s.backfill_authors_complete ?? 0}/${s.backfill_authors_total ?? 0} | Events fetched: ${s.events_fetched ?? 0} | Inbox inserts: ${s.inbox_inserts ?? 0} | Heartbeat: ${hb}</p>`;
|
||
}
|
||
// Inbox status
|
||
const isEl = document.getElementById('caching-inbox-status');
|
||
if (isEl && d.inbox) {
|
||
isEl.innerHTML = `<p>Pending: ${d.inbox.pending} | Live: ${d.inbox.live} | Backfill: ${d.inbox.backfill}</p>`;
|
||
}
|
||
// Upstream relay status window
|
||
const rsEl = document.getElementById('caching-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?:\/\//, '').replace(/\/relay$/, '');
|
||
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('caching-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(); refreshCachingUser('${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('caching-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="cachingPage=' + (d.page - 1) + '; loadCaching()">‹ Prev</button>' : '') +
|
||
(d.page < totalPages ? '<button type="button" onclick="cachingPage=' + (d.page + 1) + '; loadCaching()">Next ›</button>' : '') +
|
||
'</div>';
|
||
} else if (pgEl) {
|
||
pgEl.innerHTML = '';
|
||
}
|
||
// Active target
|
||
const fsEl = document.getElementById('caching-follows-status');
|
||
if (fsEl) {
|
||
if (d.active && d.active.pubkey && d.active.relay) {
|
||
fsEl.innerHTML = `<span class="status-working">⚡ Backfilling: ${d.active.pubkey.substring(0,16)}… @ ${esc(d.active.relay)}</span>`;
|
||
} else if (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 — listening for live events</span>`;
|
||
} else {
|
||
fsEl.innerHTML = `<span class="status-complete">✓ Caching complete</span>`;
|
||
}
|
||
}
|
||
} catch (e) { console.error('[admin2] caching error:', e); }
|
||
}
|
||
|
||
// Toggle caching_enabled config via the config API.
|
||
async function toggleCachingEnabled() {
|
||
try {
|
||
const res = await fetch('api/caching.php');
|
||
const d = await res.json();
|
||
const current = d.config?.caching_enabled === 'true';
|
||
const newVal = current ? 'false' : 'true';
|
||
await fetch('api/config.php', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({key: 'caching_enabled', value: newVal})
|
||
});
|
||
loadCaching();
|
||
} catch (e) { console.error('[admin2] toggle caching error:', e); }
|
||
}
|
||
|
||
// Toggle caching_inbox_enabled config via the config API.
|
||
async function toggleCachingInboxEnabled() {
|
||
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';
|
||
await fetch('api/config.php', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({key: 'caching_inbox_enabled', value: newVal})
|
||
});
|
||
loadCaching();
|
||
} catch (e) { console.error('[admin2] toggle inbox error:', e); }
|
||
}
|
||
|
||
// Re-run all caching: 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 rerunAllCaching() {
|
||
if (!confirm('Reset backfill progress for ALL followed authors? The caching service will re-download everything from scratch.')) return;
|
||
const btn = document.getElementById('caching-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 Caching'; } loadCaching(); }, 2000);
|
||
} else {
|
||
alert('Reset failed: ' + (d.error || 'unknown error'));
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Caching'; }
|
||
}
|
||
} catch (e) {
|
||
console.error('[admin2] rerunAllCaching error:', e);
|
||
alert('Reset failed: ' + e.message);
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Caching'; }
|
||
}
|
||
}
|
||
|
||
// 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 refreshCachingUser(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) {
|
||
loadCaching();
|
||
} else {
|
||
alert('Refresh failed: ' + (d.error || 'unknown error'));
|
||
}
|
||
} catch (e) {
|
||
console.error('[admin2] refreshCachingUser error:', e);
|
||
alert('Refresh failed: ' + e.message);
|
||
}
|
||
}
|
||
|
||
// ================================
|
||
// 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 += ` | <strong>Estimated size:</strong> ${d.total_size_human}`;
|
||
}
|
||
if (d.avg_size_per_event) {
|
||
html += ` | <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 += ` | <strong>Freed:</strong> ${d.freed_human}`;
|
||
if (d.duration_ms) html += ` | <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);
|
||
});
|