- 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
1664 lines
73 KiB
JavaScript
1664 lines
73 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',
|
||
'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 += ` | <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);
|
||
});
|