Files
Purple a8e3be2e66 Fix webhook queue processing with automatic polling
- Call webhook_process endpoint when loading queue status
- Add automatic polling every 10 seconds when webhooks are pending
- Stop polling automatically when queue is empty
- Ensures debounced webhooks actually fire when due

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 12:31:29 +00:00

2865 lines
126 KiB
JavaScript

// State
let currentPage = 1;
let totalPages = 1;
let searchTimeout = null;
let deleteEntryId = null;
let selectedFile = null;
let clientLogos = {};
let auditPage = 1;
// Initialize
document.addEventListener('DOMContentLoaded', () => {
// Only run on index page (has entries table)
if (document.getElementById('entriesTableBody')) {
loadEntries();
loadStats();
}
loadClientLogos();
loadAndApplyWhitelabel();
// Enable clear all button when "DELETE" is typed
const confirmClearInput = document.getElementById('confirmClearInput');
if (confirmClearInput) {
confirmClearInput.addEventListener('input', (e) => {
document.getElementById('confirmClearBtn').disabled = e.target.value !== 'DELETE';
});
}
});
// Load and apply whitelabel settings on page load
async function loadAndApplyWhitelabel() {
try {
const result = await api('whitelabel_get');
if (result.success && result.settings) {
applyWhitelabelSettings(result.settings);
}
} catch (error) {
console.error('Failed to load whitelabel settings:', error);
}
}
// Tab switching
function switchTab(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
// Try to find and activate the corresponding tab button (may not exist for settings tabs)
const tabButton = document.querySelector(`.tab[onclick="switchTab('${tab}')"]`);
if (tabButton) {
tabButton.classList.add('active');
}
document.getElementById(`tab-${tab}`).classList.add('active');
// Load data for advanced tab
if (tab === 'advanced') {
loadShortnames();
loadLogosGrid();
}
// Load data for integrations tab
if (tab === 'integrations') {
loadWebhookSettings();
loadWebhookQueueStatus();
loadIpRegistrySettings();
loadAwsSettings();
}
// Load data for audit tab
if (tab === 'audit') {
loadAuditLog();
}
// Load data for developer tab
if (tab === 'developer') {
loadSystemInfo();
loadErrorLogs();
}
// Load data for PTR tab
if (tab === 'ptr') {
loadPtrZones();
}
// Load data for whitelabel tab
if (tab === 'whitelabel') {
loadWhitelabelSettings();
}
}
// Settings menu functions
function toggleSettingsMenu() {
const menu = document.getElementById('settingsMenu');
const btn = document.getElementById('settingsBtn');
menu.classList.toggle('active');
btn.classList.toggle('active');
}
function closeSettingsMenu() {
const menu = document.getElementById('settingsMenu');
const btn = document.getElementById('settingsBtn');
menu.classList.remove('active');
btn.classList.remove('active');
}
// Close settings menu when clicking outside
document.addEventListener('click', function(e) {
const dropdown = document.querySelector('.settings-dropdown');
if (dropdown && !dropdown.contains(e.target)) {
closeSettingsMenu();
}
});
// API Helper
async function api(action, params = {}, method = 'GET', body = null) {
const url = new URL('api.php', window.location.href);
url.searchParams.set('action', action);
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
const options = { method };
if (body) {
options.headers = { 'Content-Type': 'application/json' };
options.body = JSON.stringify({ ...body, csrf_token: (typeof CSRF_TOKEN !== 'undefined' ? CSRF_TOKEN : '') });
}
const response = await fetch(url, options);
const text = await response.text();
if (!text || text.trim() === '') {
return { success: false, error: 'Empty response from server' };
}
try {
return JSON.parse(text);
} catch (e) {
console.error('API response parse error:', text);
return { success: false, error: 'Invalid JSON response: ' + text.substring(0, 200) };
}
}
// Load client logos
async function loadClientLogos() {
try {
const result = await api('logos_list');
if (result.success) {
clientLogos = {};
result.data.forEach(logo => {
clientLogos[logo.short_name] = logo.logo_url;
});
}
} catch (error) {
console.error('Failed to load logos:', error);
}
}
// Load entries
async function loadEntries(page = 1) {
const tableContent = document.getElementById('tableContent');
const searchInput = document.getElementById('searchInput');
if (!tableContent) return; // Not on index page
currentPage = page;
const searchQuery = searchInput ? searchInput.value : '';
tableContent.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
try {
const result = await api('list', { page, search: searchQuery });
if (result.success) {
renderTable(result.data, result.pagination);
} else {
showToast(result.error || 'Failed to load entries', 'error');
}
} catch (error) {
showToast('Network error', 'error');
}
}
// Render table
function renderTable(entries, pagination) {
totalPages = pagination.pages;
if (entries.length === 0) {
document.getElementById('tableContent').innerHTML = `
<div class="empty-state">
<div class="empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
</div>
<h3 class="empty-title">No entries found</h3>
<p class="empty-text">Get started by adding your first geofeed entry or import from the Advanced tab.</p>
<button class="btn btn-primary" onclick="openModal()">Add Entry</button>
</div>
`;
return;
}
const tableHTML = `
<div class="table-scroll">
<table>
<thead>
<tr>
<th style="min-width: 160px;">IP Prefix</th>
<th style="width: 70px; text-align: center;">Client</th>
<th class="hide-mobile" style="min-width: 220px;">Hostname</th>
<th style="width: 90px;">Country</th>
<th class="hide-mobile" style="width: 80px;">Region</th>
<th class="hide-mobile" style="min-width: 120px;">City</th>
<th class="hide-mobile" style="min-width: 180px;">ISP</th>
<th class="hide-mobile" style="width: 80px;">Flags</th>
<th style="width: 140px;">Actions</th>
</tr>
</thead>
<tbody>
${entries.map(entry => `
<tr>
<td><span class="ip-prefix">${escapeHtml(entry.ip_prefix)}</span></td>
<td style="text-align: center;">
${entry.client_short_name ? `
<div class="client-cell" title="${escapeHtml(entry.client_short_name)}">
${clientLogos[entry.client_short_name]
? `<img src="${escapeHtml(clientLogos[entry.client_short_name])}" class="client-logo" alt="${escapeHtml(entry.client_short_name)}" title="${escapeHtml(entry.client_short_name)}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"><span class="client-logo-placeholder" style="display:none" title="${escapeHtml(entry.client_short_name)}">${escapeHtml(entry.client_short_name.charAt(0).toUpperCase())}</span>`
: `<span class="client-logo-placeholder" title="${escapeHtml(entry.client_short_name)}">${escapeHtml(entry.client_short_name.charAt(0).toUpperCase())}</span>`
}
</div>
` : '<span style="color: var(--text-tertiary)">-</span>'}
</td>
<td class="hide-mobile">${entry.ipr_hostname ? `<span class="cell-truncate" title="${escapeHtml(entry.ipr_hostname)}">${escapeHtml(entry.ipr_hostname)}</span>` : '<span style="color: var(--text-tertiary)">-</span>'}</td>
<td>
${entry.country_code ? `
<span class="country-badge">
<span class="country-flag">${getFlagEmoji(entry.country_code)}</span>
${escapeHtml(entry.country_code)}
</span>
` : '<span style="color: var(--text-tertiary)">-</span>'}
</td>
<td class="hide-mobile">${entry.region_code ? `<span class="cell-truncate">${escapeHtml(entry.region_code)}</span>` : '<span style="color: var(--text-tertiary)">-</span>'}</td>
<td class="hide-mobile">${entry.city ? `<span class="cell-truncate">${escapeHtml(entry.city)}</span>` : '<span style="color: var(--text-tertiary)">-</span>'}</td>
<td class="hide-mobile">${entry.ipr_isp ? `<span class="cell-truncate" title="${escapeHtml(entry.ipr_org || entry.ipr_isp)}">${escapeHtml(entry.ipr_isp)}</span>` : '<span style="color: var(--text-tertiary)">-</span>'}</td>
<td class="hide-mobile">
<div class="flags-cell">
${renderSecurityFlags(entry)}
</div>
</td>
<td>
<div class="row-actions">
<button class="btn btn-ghost btn-icon" onclick="showEntryInfo(${entry.id})" title="View Details" style="color: var(--primary);">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
</button>
<button class="btn btn-ghost btn-icon" onclick="editEntry(${entry.id})" title="Edit">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button class="btn btn-ghost btn-icon" onclick="enrichIp(${entry.id})" title="Enrich IP" ${entry.ipr_enriched_at ? 'style="color: var(--success);"' : ''}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
</button>
<button class="btn btn-ghost btn-icon" onclick="deleteEntry(${entry.id}, '${escapeHtml(entry.ip_prefix)}')" title="Delete" style="color: var(--error);">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
<div class="pagination">
<button class="pagination-btn" onclick="loadEntries(1)" ${currentPage === 1 ? 'disabled' : ''}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="11 17 6 12 11 7"/>
<polyline points="18 17 13 12 18 7"/>
</svg>
</button>
<button class="pagination-btn" onclick="loadEntries(${currentPage - 1})" ${currentPage === 1 ? 'disabled' : ''}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"/>
</svg>
</button>
<span class="pagination-info">Page ${currentPage} of ${totalPages || 1} (${pagination.total} entries)</span>
<button class="pagination-btn" onclick="loadEntries(${currentPage + 1})" ${currentPage >= totalPages ? 'disabled' : ''}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
<button class="pagination-btn" onclick="loadEntries(${totalPages})" ${currentPage >= totalPages ? 'disabled' : ''}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="13 17 18 12 13 7"/>
<polyline points="6 17 11 12 6 7"/>
</svg>
</button>
</div>
`;
document.getElementById('tableContent').innerHTML = tableHTML;
}
// Load stats
async function loadStats() {
const statTotal = document.getElementById('statTotal');
if (!statTotal) return; // Not on index page
try {
const result = await api('stats');
if (result.success) {
statTotal.textContent = result.data.total_entries.toLocaleString();
document.getElementById('statIPv4').textContent = (result.data.ip_versions?.ipv4 || 0).toLocaleString();
document.getElementById('statIPv6').textContent = (result.data.ip_versions?.ipv6 || 0).toLocaleString();
document.getElementById('statCountries').textContent = result.data.by_country?.length || 0;
}
} catch (error) {
console.error('Failed to load stats:', error);
}
}
// Load audit log
async function loadAuditLog(page = 1) {
auditPage = page;
const container = document.getElementById('auditLogContainer');
if (!container) return;
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
try {
const result = await api('audit_log', { page, limit: 20 });
if (result.success) {
renderAuditLog(result.data, result.pagination);
} else {
container.innerHTML = '<div class="empty-state"><p class="empty-text">Failed to load audit log</p></div>';
}
} catch (error) {
container.innerHTML = '<div class="empty-state"><p class="empty-text">Network error</p></div>';
}
}
// Render audit log
function renderAuditLog(entries, pagination) {
const container = document.getElementById('auditLogContainer');
if (entries.length === 0) {
container.innerHTML = '<div class="empty-state"><p class="empty-text">No audit log entries yet</p></div>';
document.getElementById('auditPagination').style.display = 'none';
return;
}
container.innerHTML = entries.map(entry => {
const actionText = {
'INSERT': 'Created',
'UPDATE': 'Updated',
'DELETE': 'Deleted'
}[entry.action] || entry.action;
const icon = {
'INSERT': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>',
'UPDATE': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>',
'DELETE': '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>'
}[entry.action] || '';
let details = '';
if (entry.new_values?.type === 'bulk_import') {
details = `Bulk import: ${entry.new_values.inserted} inserted, ${entry.new_values.updated} updated`;
} else if (entry.new_values?.type === 'url_import') {
details = `URL import from ${entry.new_values.url}: ${entry.new_values.inserted} inserted, ${entry.new_values.updated} updated`;
} else if (entry.new_values?.type === 'clear_all') {
details = `Cleared ${entry.old_values?.count || 0} entries`;
} else if (entry.ip_prefix) {
details = entry.ip_prefix;
} else if (entry.old_values?.ip_prefix) {
details = entry.old_values.ip_prefix;
} else if (entry.new_values?.ip_prefix) {
details = entry.new_values.ip_prefix;
}
const date = new Date(entry.changed_at);
const timeAgo = getTimeAgo(date);
return `
<div class="audit-entry">
<div class="audit-icon ${entry.action.toLowerCase()}">${icon}</div>
<div class="audit-content">
<div class="audit-header">
<span class="audit-action">${actionText}</span>
${details && !entry.new_values?.type ? `<span class="audit-prefix">${escapeHtml(details)}</span>` : ''}
<span class="audit-time">${timeAgo}</span>
</div>
${entry.new_values?.type ? `<div class="audit-details">${escapeHtml(details)}</div>` : ''}
<div class="audit-by">by ${escapeHtml(entry.changed_by || 'Unknown')}</div>
</div>
</div>
`;
}).join('');
// Render pagination
const paginationEl = document.getElementById('auditPagination');
if (pagination.pages > 1) {
paginationEl.style.display = 'flex';
paginationEl.innerHTML = `
<button class="pagination-btn" onclick="loadAuditLog(${auditPage - 1})" ${auditPage === 1 ? 'disabled' : ''}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"/>
</svg>
</button>
<span class="pagination-info">Page ${auditPage} of ${pagination.pages}</span>
<button class="pagination-btn" onclick="loadAuditLog(${auditPage + 1})" ${auditPage >= pagination.pages ? 'disabled' : ''}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
`;
} else {
paginationEl.style.display = 'none';
}
}
// Load shortnames for logo dropdown
async function loadShortnames() {
const select = document.getElementById('logoShortName');
if (!select) return;
try {
const result = await api('shortnames_list');
// Keep first option
select.innerHTML = '<option value="">Select or type a shortname...</option>';
if (result.success && result.data.length > 0) {
result.data.forEach(name => {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
select.appendChild(option);
});
}
} catch (error) {
console.error('Failed to load shortnames:', error);
}
}
// Load shortnames grid for settings page
async function loadShortnamesGrid() {
const container = document.getElementById('shortnamesContainer');
if (!container) return;
try {
const result = await api('shortnames_list');
if (result.success && result.data.length > 0) {
container.innerHTML = `
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
${result.data.map(name => `
<span class="badge badge-secondary" style="padding: 6px 12px; font-size: 13px;">
${escapeHtml(name)}
</span>
`).join('')}
</div>
<p style="margin-top: 12px; font-size: 12px; color: var(--text-tertiary);">
Shortnames are automatically created when you add entries with a client short name.
</p>
`;
} else {
container.innerHTML = '<p style="color: var(--text-secondary); font-size: 14px;">No client shortnames found. Add entries with a client short name to create shortnames.</p>';
}
} catch (error) {
container.innerHTML = '<p style="color: var(--error); font-size: 14px;">Failed to load shortnames</p>';
}
}
// Load logos grid
async function loadLogosGrid() {
const grid = document.getElementById('logoGrid');
if (!grid) return;
try {
const result = await api('logos_list');
if (result.success && result.data.length > 0) {
grid.innerHTML = result.data.map(logo => `
<div class="logo-card">
<img src="${escapeHtml(logo.logo_url)}" class="logo-preview" alt="${escapeHtml(logo.short_name)}" onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 48 48%27%3E%3Crect fill=%27%23f1f3f4%27 width=%2748%27 height=%2748%27/%3E%3Ctext x=%2724%27 y=%2732%27 text-anchor=%27middle%27 font-size=%2720%27 fill=%27%236c757d%27%3E?%3C/text%3E%3C/svg%3E'">
<div class="logo-card-info">
<div class="logo-card-name">${escapeHtml(logo.short_name)}</div>
<div class="logo-card-url">${escapeHtml(logo.logo_url)}</div>
</div>
<div class="logo-card-actions">
<button class="btn btn-ghost btn-icon" onclick="editLogo('${escapeHtml(logo.short_name)}', '${escapeHtml(logo.logo_url)}')" title="Edit">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button class="btn btn-ghost btn-icon" onclick="deleteLogo('${escapeHtml(logo.short_name)}')" title="Delete" style="color: var(--error);">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
`).join('');
} else {
grid.innerHTML = '<p style="color: var(--text-secondary); font-size: 14px;">No logos configured yet. Add a logo above to get started.</p>';
}
} catch (error) {
grid.innerHTML = '<p style="color: var(--error); font-size: 14px;">Failed to load logos</p>';
}
}
// Save logo
async function saveLogo() {
const shortName = document.getElementById('logoShortName').value.trim();
const logoUrl = document.getElementById('logoUrl').value.trim();
if (!shortName || !logoUrl) {
showToast('Please enter both short name and logo URL', 'error');
return;
}
try {
const result = await api('logo_save', {}, 'POST', { short_name: shortName, logo_url: logoUrl });
if (result.success) {
showToast('Logo saved successfully', 'success');
document.getElementById('logoShortName').value = '';
document.getElementById('logoUrl').value = '';
loadLogosGrid();
loadClientLogos();
loadEntries(currentPage);
} else {
showToast(result.error || 'Failed to save logo', 'error');
}
} catch (error) {
showToast('Network error', 'error');
}
}
// Edit logo
function editLogo(shortName, logoUrl) {
document.getElementById('logoShortName').value = shortName;
document.getElementById('logoUrl').value = logoUrl;
document.getElementById('logoShortName').scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// Delete logo
async function deleteLogo(shortName) {
if (!confirm(`Delete logo for "${shortName}"?`)) return;
try {
const result = await api('logo_delete', {}, 'POST', { short_name: shortName });
if (result.success) {
showToast('Logo deleted successfully', 'success');
loadLogosGrid();
loadClientLogos();
loadEntries(currentPage);
} else {
showToast(result.error || 'Failed to delete logo', 'error');
}
} catch (error) {
showToast('Network error', 'error');
}
}
// Load webhook settings
async function loadWebhookSettings() {
try {
const result = await api('webhook_settings_get');
if (result.success) {
document.getElementById('webhookUrl').value = result.data.webhook_url || '';
document.getElementById('webhookEnabled').checked = result.data.webhook_enabled;
document.getElementById('webhookDelay').value = result.data.webhook_delay_minutes || 3;
}
} catch (error) {
console.error('Failed to load webhook settings:', error);
}
}
// Load IP Registry settings
async function loadIpRegistrySettings() {
try {
const result = await api('ipregistry_settings_get');
if (result.success) {
document.getElementById('ipRegistryEnabled').checked = result.data.enabled;
if (result.data.api_key_masked) {
document.getElementById('ipRegistryApiKey').placeholder = `Current: ${result.data.api_key_masked}`;
} else if (result.data.has_env_key) {
document.getElementById('ipRegistryApiKey').placeholder = 'Using environment variable';
}
}
} catch (error) {
console.error('Failed to load IP Registry settings:', error);
}
}
// Save IP Registry settings
async function saveIpRegistrySettings() {
const enabled = document.getElementById('ipRegistryEnabled').checked;
const apiKey = document.getElementById('ipRegistryApiKey').value.trim();
try {
const result = await api('ipregistry_settings_save', {}, 'POST', {
enabled: enabled,
api_key: apiKey
});
if (result.success) {
showToast('IP Registry settings saved successfully', 'success');
document.getElementById('ipRegistryApiKey').value = '';
loadIpRegistrySettings();
} else {
showToast(result.error || 'Failed to save settings', 'error');
}
} catch (error) {
showToast('Network error', 'error');
}
}
// Save webhook settings
async function saveWebhookSettings() {
const webhookUrl = document.getElementById('webhookUrl').value.trim();
const webhookEnabled = document.getElementById('webhookEnabled').checked;
const webhookDelay = parseInt(document.getElementById('webhookDelay').value) || 3;
try {
const result = await api('webhook_settings_save', {}, 'POST', {
webhook_url: webhookUrl,
webhook_enabled: webhookEnabled,
webhook_delay_minutes: webhookDelay
});
if (result.success) {
showToast('Webhook settings saved successfully', 'success');
} else {
showToast(result.error || 'Failed to save settings', 'error');
}
} catch (error) {
showToast('Network error', 'error');
}
}
// Test webhook connection
async function testWebhook() {
const webhookUrl = document.getElementById('webhookUrl').value.trim();
if (!webhookUrl) {
showToast('Please enter a webhook URL first', 'error');
return;
}
try {
showToast('Testing webhook...', 'success');
const result = await api('webhook_test', {}, 'POST', {});
if (result.success) {
showToast(`Webhook test successful (HTTP ${result.http_code})`, 'success');
} else {
showToast(result.error || 'Webhook test failed', 'error');
}
} catch (error) {
showToast('Network error', 'error');
}
}
// Trigger webhook immediately
async function triggerWebhookNow() {
if (!confirm('This will immediately trigger the n8n webhook to update the CDN. Continue?')) return;
try {
const result = await api('webhook_trigger', {}, 'POST', {});
if (result.success) {
showToast('Webhook triggered successfully', 'success');
loadWebhookQueueStatus();
} else {
showToast(result.error || 'Failed to trigger webhook', 'error');
}
} catch (error) {
showToast('Network error', 'error');
}
}
// Webhook queue polling interval
let webhookQueueInterval = null;
// Load webhook queue status
async function loadWebhookQueueStatus() {
const container = document.getElementById('webhookQueueContainer');
try {
// First, trigger processing of any due webhooks
await api('webhook_process');
const result = await api('webhook_queue_status');
if (result.success) {
renderWebhookQueueStatus(result.data);
// Set up auto-refresh if there are pending webhooks
if (result.data.counts.pending > 0) {
startWebhookQueuePolling();
} else {
stopWebhookQueuePolling();
}
} else {
container.innerHTML = '<p style="color: var(--text-secondary);">Failed to load queue status</p>';
}
} catch (error) {
container.innerHTML = '<p style="color: var(--text-secondary);">Network error</p>';
}
}
// Start polling for webhook queue updates
function startWebhookQueuePolling() {
if (webhookQueueInterval) return; // Already polling
webhookQueueInterval = setInterval(async () => {
// Process any due webhooks
await api('webhook_process');
// Reload status
const result = await api('webhook_queue_status');
if (result.success) {
renderWebhookQueueStatus(result.data);
// Stop polling if no more pending
if (result.data.counts.pending === 0) {
stopWebhookQueuePolling();
}
}
}, 10000); // Check every 10 seconds
}
// Stop polling
function stopWebhookQueuePolling() {
if (webhookQueueInterval) {
clearInterval(webhookQueueInterval);
webhookQueueInterval = null;
}
}
// Render webhook queue status
function renderWebhookQueueStatus(data) {
const container = document.getElementById('webhookQueueContainer');
// Stats row
let html = `
<div style="display: flex; gap: 24px; margin-bottom: 20px; flex-wrap: wrap;">
<div>
<span style="font-size: 24px; font-weight: 700; color: var(--warning);">${data.counts.pending}</span>
<span style="font-size: 13px; color: var(--text-secondary); display: block;">Pending</span>
</div>
<div>
<span style="font-size: 24px; font-weight: 700; color: var(--success);">${data.counts.completed_24h}</span>
<span style="font-size: 13px; color: var(--text-secondary); display: block;">Completed (24h)</span>
</div>
<div>
<span style="font-size: 24px; font-weight: 700; color: var(--error);">${data.counts.failed_24h}</span>
<span style="font-size: 13px; color: var(--text-secondary); display: block;">Failed (24h)</span>
</div>
</div>
`;
// Pending webhooks
if (data.pending.length > 0) {
html += '<h4 style="font-size: 14px; font-weight: 600; margin-bottom: 12px; color: var(--text-primary);">Pending Webhooks</h4>';
html += '<div style="margin-bottom: 20px;">';
data.pending.forEach(item => {
const scheduledFor = new Date(item.scheduled_for);
const timeUntil = getTimeUntil(scheduledFor);
html += `
<div style="display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--warning-bg); border-radius: 8px; margin-bottom: 8px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--warning)" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<div style="flex: 1;">
<div style="font-weight: 500; font-size: 14px;">${escapeHtml(item.trigger_reason || 'Queued update')}</div>
<div style="font-size: 12px; color: var(--text-secondary);">${item.entries_affected} entries affected • fires ${timeUntil}</div>
</div>
</div>
`;
});
html += '</div>';
}
// Recent webhooks
if (data.recent.length > 0) {
html += '<h4 style="font-size: 14px; font-weight: 600; margin-bottom: 12px; color: var(--text-primary);">Recent Webhooks</h4>';
data.recent.forEach(item => {
const isSuccess = item.status === 'completed';
const processedAt = item.processed_at ? getTimeAgo(new Date(item.processed_at)) : '-';
html += `
<div style="display: flex; align-items: center; gap: 12px; padding: 12px; background: ${isSuccess ? 'var(--success-bg)' : 'var(--error-bg)'}; border-radius: 8px; margin-bottom: 8px;">
${isSuccess
? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--success)" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>'
: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--error)" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>'
}
<div style="flex: 1;">
<div style="font-weight: 500; font-size: 14px;">${escapeHtml(item.trigger_reason || 'Update')}</div>
<div style="font-size: 12px; color: var(--text-secondary);">${item.entries_affected} entries • HTTP ${item.response_code || '-'}${processedAt}</div>
</div>
</div>
`;
});
}
if (data.pending.length === 0 && data.recent.length === 0) {
html += '<p style="color: var(--text-secondary); font-size: 14px; text-align: center; padding: 20px 0;">No webhook activity yet</p>';
}
container.innerHTML = html;
}
// Clear webhook queue
async function clearWebhookQueue() {
const clearType = document.getElementById('clearQueueType')?.value || 'pending';
const typeLabels = {
'pending': 'pending webhooks',
'failed': 'failed webhooks',
'all': 'all webhook history'
};
if (!confirm(`Are you sure you want to clear ${typeLabels[clearType]}?`)) {
return;
}
try {
const result = await api('webhook_queue_clear', {}, 'POST', {
clear_type: clearType
});
if (result.success) {
showToast(`${result.message} (${result.deleted} removed)`, 'success');
loadWebhookQueueStatus();
} else {
showToast(result.error || 'Failed to clear queue', 'error');
}
} catch (error) {
showToast('Network error', 'error');
}
}
// Get time until a future date
function getTimeUntil(date) {
const seconds = Math.floor((date - new Date()) / 1000);
if (seconds < 0) return 'now';
if (seconds < 60) return `in ${seconds}s`;
if (seconds < 3600) return `in ${Math.floor(seconds / 60)}m`;
return `in ${Math.floor(seconds / 3600)}h`;
}
// Time ago helper
function getTimeAgo(date) {
const seconds = Math.floor((new Date() - date) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago';
if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ago';
if (seconds < 604800) return Math.floor(seconds / 86400) + 'd ago';
return date.toLocaleDateString();
}
// Search with debounce
function debounceSearch() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => loadEntries(1), 300);
}
// Modal functions
function openModal(entry = null) {
const modal = document.getElementById('entryModal');
const form = document.getElementById('entryForm');
const title = document.getElementById('modalTitle');
const enrichSection = document.getElementById('enrichmentSection');
const enrichStatus = document.getElementById('enrichmentStatus');
const enrichData = document.getElementById('enrichmentData');
const route53Section = document.getElementById('route53Section');
const route53Content = document.getElementById('route53Content');
form.reset();
if (entry) {
title.textContent = 'Edit Entry';
document.getElementById('entryId').value = entry.id;
document.getElementById('ipPrefix').value = entry.ip_prefix || '';
document.getElementById('countryCode').value = entry.country_code || '';
document.getElementById('regionCode').value = entry.region_code || '';
document.getElementById('city').value = entry.city || '';
document.getElementById('postalCode').value = entry.postal_code || '';
document.getElementById('clientShortName').value = entry.client_short_name || '';
document.getElementById('notes').value = entry.notes || '';
// Show enrichment section when editing
enrichSection.style.display = 'block';
if (entry.ipr_enriched_at) {
const enrichedDate = new Date(entry.ipr_enriched_at).toLocaleString();
enrichStatus.innerHTML = `<span style="color: var(--success);">Enriched on ${enrichedDate}</span>`;
enrichData.style.display = 'block';
// Populate enrichment data
document.getElementById('enrichIsp').textContent = entry.ipr_isp || '-';
document.getElementById('enrichAsn').textContent = entry.ipr_asn ? `AS${entry.ipr_asn}` : '-';
document.getElementById('enrichOrg').textContent = entry.ipr_org || '-';
document.getElementById('enrichType').textContent = entry.ipr_connection_type || '-';
// Show active flags
const flags = [];
if (entry.flag_abuser == 1) flags.push('Abuser');
if (entry.flag_attacker == 1) flags.push('Attacker');
if (entry.flag_bogon == 1) flags.push('Bogon');
if (entry.flag_cloud_provider == 1) flags.push('Cloud');
if (entry.flag_proxy == 1) flags.push('Proxy');
if (entry.flag_relay == 1) flags.push('Relay');
if (entry.flag_tor == 1) flags.push('Tor');
if (entry.flag_tor_exit == 1) flags.push('Tor Exit');
if (entry.flag_vpn == 1) flags.push('VPN');
if (entry.flag_anonymous == 1) flags.push('Anonymous');
if (entry.flag_threat == 1) flags.push('Threat');
const flagsEl = document.getElementById('enrichFlags');
if (flags.length > 0) {
flagsEl.innerHTML = '<strong>Flags:</strong> ' + flags.map(f => `<span style="background: var(--danger); color: white; padding: 1px 6px; border-radius: 4px; font-size: 11px; margin-left: 4px;">${f}</span>`).join('');
} else {
flagsEl.innerHTML = '<strong>Flags:</strong> <span style="color: var(--success);">None</span>';
}
} else {
enrichStatus.innerHTML = '<span style="color: var(--text-tertiary);">Not enriched yet</span>';
enrichData.style.display = 'none';
}
// Reset button state
document.getElementById('reEnrichBtn').disabled = false;
document.getElementById('reEnrichBtnText').textContent = 'Re-enrich IP';
// Show Route53 section and load records
route53Section.style.display = 'block';
route53Content.innerHTML = '<div style="color: var(--text-tertiary);">Loading...</div>';
loadRoute53RecordsForEdit(entry.ip_prefix);
} else {
title.textContent = 'Add Entry';
document.getElementById('entryId').value = '';
enrichSection.style.display = 'none';
route53Section.style.display = 'none';
}
modal.classList.add('active');
setTimeout(() => document.getElementById('ipPrefix').focus(), 100);
}
// Load Route53 records for edit modal
async function loadRoute53RecordsForEdit(ipPrefix) {
const container = document.getElementById('route53Content');
if (!container) return;
try {
const result = await api('route53_records_by_prefix', { ip_prefix: ipPrefix });
if (result.success && result.records && result.records.length > 0) {
const maxDisplay = 5;
const displayRecords = result.records.slice(0, maxDisplay);
const hasMore = result.records.length > maxDisplay;
container.innerHTML = `
<div style="display: flex; flex-direction: column; gap: 6px;">
${displayRecords.map(rec => `
<div style="display: flex; align-items: center; justify-content: space-between; padding: 6px 8px; background: var(--bg-tertiary); border-radius: 4px;">
<div style="display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1;">
<code style="font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(rec.hostname)}</code>
<span style="color: var(--text-tertiary);">→</span>
<code style="font-size: 11px;">${escapeHtml(rec.ip_address)}</code>
</div>
<span class="ptr-status-badge ${rec.ptr_status}" style="font-size: 10px; margin-left: 8px;">${rec.ptr_status || 'unknown'}</span>
</div>
`).join('')}
</div>
${hasMore ? `<div style="margin-top: 8px; font-size: 11px; color: var(--text-tertiary);">+ ${result.records.length - maxDisplay} more records</div>` : ''}
<div style="margin-top: 4px; font-size: 11px; color: var(--text-tertiary);">
${result.count} A record${result.count !== 1 ? 's' : ''} in this prefix
</div>
`;
} else {
container.innerHTML = `<div style="color: var(--text-tertiary); font-style: italic;">No Route53 A records found for this prefix.</div>`;
}
} catch (error) {
container.innerHTML = `<div style="color: var(--text-tertiary); font-style: italic;">Could not load Route53 records.</div>`;
}
}
function closeModal() {
document.getElementById('entryModal').classList.remove('active');
}
// Edit entry
async function editEntry(id) {
try {
const result = await api('get', { id });
if (result.success) {
openModal(result.data);
} else {
showToast(result.error || 'Failed to load entry', 'error');
}
} catch (error) {
showToast('Network error', 'error');
}
}
// Save entry
async function saveEntry(event) {
event.preventDefault();
const id = document.getElementById('entryId').value;
const action = id ? 'update' : 'create';
const data = {
id: id || undefined,
ip_prefix: document.getElementById('ipPrefix').value,
country_code: document.getElementById('countryCode').value.toUpperCase(),
region_code: document.getElementById('regionCode').value.toUpperCase(),
city: document.getElementById('city').value,
postal_code: document.getElementById('postalCode').value,
client_short_name: document.getElementById('clientShortName').value,
notes: document.getElementById('notes').value
};
try {
const result = await api(action, {}, 'POST', data);
if (result.success) {
showToast(result.message || 'Entry saved successfully', 'success');
closeModal();
loadEntries(currentPage);
loadStats();
} else {
showToast(result.error || 'Failed to save entry', 'error');
}
} catch (error) {
showToast('Network error', 'error');
}
}
// Show entry info modal
async function showEntryInfo(id) {
const modal = document.getElementById('infoModal');
const body = document.getElementById('infoModalBody');
const title = document.getElementById('infoModalTitle');
const subtitle = document.getElementById('infoModalSubtitle');
modal.classList.add('active');
body.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
try {
const result = await api('get', { id: id });
if (result.success) {
const entry = result.data;
title.textContent = entry.ip_prefix;
subtitle.textContent = entry.client_short_name ? `Client: ${entry.client_short_name}` : '';
const formatDate = (dateStr) => {
if (!dateStr) return '-';
const d = new Date(dateStr);
return d.toLocaleString();
};
const flagIcon = (value) => {
return value ?
'<span style="color: var(--error); font-weight: bold;">✗</span>' :
'<span style="color: var(--success);">✓</span>';
};
const infoRow = (label, value, isCode = false) => {
const displayVal = value || '<span style="color: var(--text-tertiary);">-</span>';
return `
<tr>
<td style="font-weight: 500; width: 160px; color: var(--text-secondary);">${label}</td>
<td>${isCode ? `<code style="font-size: 12px;">${escapeHtml(value || '-')}</code>` : displayVal}</td>
</tr>
`;
};
body.innerHTML = `
<div style="display: flex; flex-direction: column; gap: 20px;">
<!-- Basic Info -->
<div>
<h4 style="margin: 0 0 12px 0; font-size: 14px; color: var(--text-primary); border-bottom: 1px solid var(--border); padding-bottom: 8px;">
Basic Information
</h4>
<table style="width: 100%; font-size: 13px; min-width: auto;">
<tbody>
${infoRow('IP Prefix', entry.ip_prefix, true)}
${infoRow('Country', entry.country_code ? `${getFlagEmoji(entry.country_code)} ${entry.country_code}` : null)}
${infoRow('Region', entry.region_code)}
${infoRow('City', entry.city)}
${infoRow('Postal Code', entry.postal_code)}
${infoRow('Client', entry.client_short_name)}
${infoRow('Notes', entry.notes)}
</tbody>
</table>
</div>
<!-- Enrichment Data -->
<div>
<h4 style="margin: 0 0 12px 0; font-size: 14px; color: var(--text-primary); border-bottom: 1px solid var(--border); padding-bottom: 8px;">
IP Enrichment Data
${entry.ipr_enriched_at ? `<span style="font-weight: normal; font-size: 11px; color: var(--text-tertiary);"> (Last updated: ${formatDate(entry.ipr_enriched_at)})</span>` : ''}
</h4>
${!entry.ipr_enriched_at ? `
<p style="color: var(--text-tertiary); font-style: italic; margin: 0;">Not enriched yet. Click the globe icon to enrich this entry.</p>
` : `
<table style="width: 100%; font-size: 13px; min-width: auto;">
<tbody>
${infoRow('Hostname', entry.ipr_hostname, true)}
${infoRow('ISP', entry.ipr_isp)}
${infoRow('Organization', entry.ipr_org)}
${infoRow('ASN', entry.ipr_asn)}
${infoRow('ASN Domain', entry.ipr_asn_name)}
${infoRow('Connection Type', entry.ipr_connection_type)}
${infoRow('Country (IPR)', entry.ipr_country_name)}
${infoRow('Region (IPR)', entry.ipr_region_name)}
${infoRow('Timezone', entry.ipr_timezone)}
${infoRow('Coordinates', entry.ipr_latitude && entry.ipr_longitude ? `${entry.ipr_latitude}, ${entry.ipr_longitude}` : null)}
</tbody>
</table>
`}
</div>
<!-- Security Flags -->
<div>
<h4 style="margin: 0 0 12px 0; font-size: 14px; color: var(--text-primary); border-bottom: 1px solid var(--border); padding-bottom: 8px;">
Security Flags
</h4>
${!entry.ipr_enriched_at ? `
<p style="color: var(--text-tertiary); font-style: italic; margin: 0;">Security flags will be available after enrichment.</p>
` : `
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px;">
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-tertiary); border-radius: 6px;">
${flagIcon(entry.flag_vpn)}
<span style="font-size: 13px;">VPN</span>
</div>
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-tertiary); border-radius: 6px;">
${flagIcon(entry.flag_proxy)}
<span style="font-size: 13px;">Proxy</span>
</div>
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-tertiary); border-radius: 6px;">
${flagIcon(entry.flag_tor)}
<span style="font-size: 13px;">Tor</span>
</div>
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-tertiary); border-radius: 6px;">
${flagIcon(entry.flag_tor_exit)}
<span style="font-size: 13px;">Tor Exit</span>
</div>
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-tertiary); border-radius: 6px;">
${flagIcon(entry.flag_relay)}
<span style="font-size: 13px;">Relay</span>
</div>
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-tertiary); border-radius: 6px;">
${flagIcon(entry.flag_cloud_provider)}
<span style="font-size: 13px;">Cloud Provider</span>
</div>
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-tertiary); border-radius: 6px;">
${flagIcon(entry.flag_abuser)}
<span style="font-size: 13px;">Abuser</span>
</div>
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-tertiary); border-radius: 6px;">
${flagIcon(entry.flag_attacker)}
<span style="font-size: 13px;">Attacker</span>
</div>
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-tertiary); border-radius: 6px;">
${flagIcon(entry.flag_bogon)}
<span style="font-size: 13px;">Bogon</span>
</div>
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-tertiary); border-radius: 6px;">
${flagIcon(entry.flag_anonymous)}
<span style="font-size: 13px;">Anonymous</span>
</div>
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-tertiary); border-radius: 6px;">
${flagIcon(entry.flag_threat)}
<span style="font-size: 13px;">Threat</span>
</div>
</div>
`}
</div>
<!-- Route53 A Records -->
<div id="infoRoute53Section">
<h4 style="margin: 0 0 12px 0; font-size: 14px; color: var(--text-primary); border-bottom: 1px solid var(--border); padding-bottom: 8px;">
Route53 A Records
</h4>
<div id="infoRoute53Content">
<div style="color: var(--text-tertiary); font-size: 13px;">Loading...</div>
</div>
</div>
<!-- Timestamps -->
<div>
<h4 style="margin: 0 0 12px 0; font-size: 14px; color: var(--text-primary); border-bottom: 1px solid var(--border); padding-bottom: 8px;">
Record Info
</h4>
<table style="width: 100%; font-size: 13px; min-width: auto;">
<tbody>
${infoRow('Created', formatDate(entry.created_at))}
${infoRow('Updated', formatDate(entry.updated_at))}
${infoRow('Enriched', formatDate(entry.ipr_enriched_at))}
${infoRow('Record ID', entry.id)}
</tbody>
</table>
</div>
</div>
`;
// Load Route53 records for this prefix
loadRoute53RecordsForInfo(entry.ip_prefix);
} else {
body.innerHTML = `<div class="alert alert-danger">Failed to load entry: ${escapeHtml(result.error)}</div>`;
}
} catch (error) {
body.innerHTML = `<div class="alert alert-danger">Failed to load entry details</div>`;
}
}
// Load Route53 records for info modal
async function loadRoute53RecordsForInfo(ipPrefix) {
const container = document.getElementById('infoRoute53Content');
if (!container) return;
try {
const result = await api('route53_records_by_prefix', { ip_prefix: ipPrefix });
if (result.success && result.records && result.records.length > 0) {
container.innerHTML = `
<div style="max-height: 200px; overflow-y: auto;">
<table style="width: 100%; font-size: 12px; min-width: auto;">
<thead>
<tr style="border-bottom: 1px solid var(--border);">
<th style="text-align: left; padding: 4px 8px; font-weight: 500; color: var(--text-secondary);">Hostname</th>
<th style="text-align: left; padding: 4px 8px; font-weight: 500; color: var(--text-secondary);">IP Address</th>
<th style="text-align: left; padding: 4px 8px; font-weight: 500; color: var(--text-secondary);">PTR Status</th>
</tr>
</thead>
<tbody>
${result.records.map(rec => `
<tr style="border-bottom: 1px solid var(--border);">
<td style="padding: 6px 8px;">
<code style="font-size: 11px; background: var(--bg-tertiary); padding: 2px 4px; border-radius: 3px;">${escapeHtml(rec.hostname)}</code>
</td>
<td style="padding: 6px 8px;">
<code style="font-size: 11px;">${escapeHtml(rec.ip_address)}</code>
</td>
<td style="padding: 6px 8px;">
<span class="ptr-status-badge ${rec.ptr_status}">${rec.ptr_status || 'unknown'}</span>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
<div style="margin-top: 8px; font-size: 11px; color: var(--text-tertiary);">
${result.count} record${result.count !== 1 ? 's' : ''} found in Route53 for this prefix
</div>
`;
} else {
container.innerHTML = `<p style="color: var(--text-tertiary); font-style: italic; margin: 0; font-size: 13px;">No Route53 A records found for this prefix.</p>`;
}
} catch (error) {
container.innerHTML = `<p style="color: var(--text-tertiary); font-style: italic; margin: 0; font-size: 13px;">Could not load Route53 records.</p>`;
}
}
function closeInfoModal() {
document.getElementById('infoModal').classList.remove('active');
}
// Delete entry
function deleteEntry(id, prefix) {
deleteEntryId = id;
document.getElementById('deletePrefix').textContent = prefix;
document.getElementById('deleteModal').classList.add('active');
}
function closeDeleteModal() {
document.getElementById('deleteModal').classList.remove('active');
deleteEntryId = null;
}
async function confirmDelete() {
if (!deleteEntryId) return;
try {
const result = await api('delete', {}, 'POST', { id: deleteEntryId });
if (result.success) {
showToast('Entry deleted successfully', 'success');
closeDeleteModal();
loadEntries(currentPage);
loadStats();
} else {
showToast(result.error || 'Failed to delete entry', 'error');
}
} catch (error) {
showToast('Network error', 'error');
}
}
// Export CSV
function exportCSV() {
window.location.href = 'api.php?action=export&format=download';
}
// Export Audit Log as CSV
async function exportAuditLog() {
showToast('Preparing audit log export...', 'info');
try {
// Fetch all audit log entries (high limit to get all)
const result = await api('audit_log', { page: 1, limit: 10000 });
if (!result.success || !result.data || result.data.length === 0) {
showToast('No audit log entries to export', 'error');
return;
}
// Build CSV content
const headers = ['Date/Time', 'Action', 'IP Prefix', 'Details', 'Changed By'];
const rows = result.data.map(entry => {
const date = new Date(entry.changed_at).toISOString();
const action = entry.action;
let ipPrefix = '';
let details = '';
if (entry.new_values?.type === 'bulk_import') {
details = `Bulk import: ${entry.new_values.inserted} inserted, ${entry.new_values.updated} updated`;
} else if (entry.new_values?.type === 'url_import') {
details = `URL import from ${entry.new_values.url}: ${entry.new_values.inserted} inserted, ${entry.new_values.updated} updated`;
} else if (entry.new_values?.type === 'clear_all') {
details = `Cleared ${entry.old_values?.count || 0} entries`;
} else {
ipPrefix = entry.ip_prefix || entry.old_values?.ip_prefix || entry.new_values?.ip_prefix || '';
// Build details from old/new values
if (entry.action === 'UPDATE' && entry.old_values && entry.new_values) {
const changes = [];
for (const key of Object.keys(entry.new_values)) {
if (entry.old_values[key] !== entry.new_values[key]) {
changes.push(`${key}: ${entry.old_values[key] || '(empty)'}${entry.new_values[key] || '(empty)'}`);
}
}
details = changes.join('; ');
} else if (entry.action === 'INSERT' && entry.new_values) {
details = Object.entries(entry.new_values)
.filter(([k, v]) => v && k !== 'ip_prefix')
.map(([k, v]) => `${k}: ${v}`)
.join('; ');
} else if (entry.action === 'DELETE' && entry.old_values) {
details = Object.entries(entry.old_values)
.filter(([k, v]) => v && k !== 'ip_prefix')
.map(([k, v]) => `${k}: ${v}`)
.join('; ');
}
}
const changedBy = entry.changed_by || 'Unknown';
return [date, action, ipPrefix, details, changedBy];
});
// Create CSV string
const csvContent = [
headers.join(','),
...rows.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(','))
].join('\n');
// Download file
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `audit-log-${new Date().toISOString().split('T')[0]}.csv`;
link.click();
URL.revokeObjectURL(link.href);
showToast(`Exported ${result.data.length} audit log entries`, 'success');
} catch (error) {
console.error('Audit log export error:', error);
showToast('Failed to export audit log', 'error');
}
}
// File handling
function handleFileSelect(input) {
if (input.files && input.files[0]) {
selectedFile = input.files[0];
document.getElementById('fileName').textContent = selectedFile.name;
document.getElementById('uploadBtn').disabled = false;
// Reset results
document.getElementById('fileResults').classList.remove('active', 'success', 'error');
}
}
// Import from file
async function importFromFile() {
if (!selectedFile) return;
const btn = document.getElementById('uploadBtn');
const progress = document.getElementById('fileProgress');
const progressFill = document.getElementById('fileProgressFill');
const progressText = document.getElementById('fileProgressText');
const results = document.getElementById('fileResults');
btn.disabled = true;
progress.classList.add('active');
results.classList.remove('active', 'success', 'error');
try {
progressText.textContent = 'Reading file...';
progressFill.style.width = '20%';
const text = await selectedFile.text();
progressText.textContent = 'Parsing CSV...';
progressFill.style.width = '40%';
const entries = parseCSV(text);
progressText.textContent = `Importing ${entries.length} entries...`;
progressFill.style.width = '60%';
const result = await api('import', {}, 'POST', { entries, csrf_token: csrfToken });
progressFill.style.width = '100%';
if (result.success) {
results.classList.add('active', 'success');
document.getElementById('fileResultsText').textContent =
`Successfully imported ${result.inserted} new entries, updated ${result.updated} existing entries.`;
showToast('Import completed successfully', 'success');
loadEntries();
loadStats();
} else {
results.classList.add('active', 'error');
document.getElementById('fileResultsText').textContent = result.error || 'Import failed';
showToast(result.error || 'Import failed', 'error');
}
} catch (error) {
results.classList.add('active', 'error');
document.getElementById('fileResultsText').textContent = error.message;
showToast('Import failed: ' + error.message, 'error');
} finally {
btn.disabled = false;
setTimeout(() => {
progress.classList.remove('active');
progressFill.style.width = '0%';
}, 1000);
}
}
// Import from URL
async function importFromUrl() {
const url = document.getElementById('importUrl').value.trim();
if (!url) {
showToast('Please enter a URL', 'error');
return;
}
const btn = document.getElementById('urlImportBtn');
const progress = document.getElementById('urlProgress');
const progressFill = document.getElementById('urlProgressFill');
const progressText = document.getElementById('urlProgressText');
const results = document.getElementById('urlResults');
btn.disabled = true;
progress.classList.add('active');
results.classList.remove('active', 'success', 'error');
try {
progressText.textContent = 'Fetching data...';
progressFill.style.width = '30%';
const result = await api('import_url', {}, 'POST', { url, csrf_token: csrfToken });
progressFill.style.width = '100%';
if (result.success) {
results.classList.add('active', 'success');
document.getElementById('urlResultsText').textContent =
`Successfully imported ${result.inserted} new entries, updated ${result.updated} existing entries.`;
showToast('Import completed successfully', 'success');
loadEntries();
loadStats();
} else {
results.classList.add('active', 'error');
document.getElementById('urlResultsText').textContent = result.error || 'Import failed';
showToast(result.error || 'Import failed', 'error');
}
} catch (error) {
results.classList.add('active', 'error');
document.getElementById('urlResultsText').textContent = error.message;
showToast('Import failed: ' + error.message, 'error');
} finally {
btn.disabled = false;
setTimeout(() => {
progress.classList.remove('active');
progressFill.style.width = '0%';
}, 1000);
}
}
// Parse CSV
function parseCSV(text) {
const lines = text.split(/\r?\n/);
const entries = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const parts = trimmed.split(',');
if (parts.length < 1 || !parts[0].trim()) continue;
entries.push({
ip_prefix: parts[0]?.trim() || '',
country_code: parts[1]?.trim().toUpperCase() || '',
region_code: parts[2]?.trim().toUpperCase() || '',
city: parts[3]?.trim() || '',
postal_code: parts[4]?.trim() || ''
});
}
return entries;
}
// Clear all
function confirmClearAll() {
document.getElementById('confirmClearInput').value = '';
document.getElementById('confirmClearBtn').disabled = true;
document.getElementById('clearAllModal').classList.add('active');
}
function closeClearAllModal() {
document.getElementById('clearAllModal').classList.remove('active');
}
async function executeClearAll() {
if (document.getElementById('confirmClearInput').value !== 'DELETE') return;
try {
const result = await api('clear_all', {}, 'POST', { csrf_token: csrfToken });
if (result.success) {
showToast('All entries cleared successfully', 'success');
closeClearAllModal();
loadEntries();
loadStats();
} else {
showToast(result.error || 'Failed to clear entries', 'error');
}
} catch (error) {
showToast('Network error', 'error');
}
}
// Toast notifications
function showToast(message, type = 'success') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const icon = type === 'success'
? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>'
: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>';
toast.innerHTML = `
<div class="toast-icon">${icon}</div>
<span class="toast-message">${escapeHtml(message)}</span>
<button class="toast-close" onclick="this.parentElement.remove()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
`;
container.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideIn 0.3s ease reverse';
setTimeout(() => toast.remove(), 300);
}, 4000);
}
// Helpers
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function getFlagEmoji(countryCode) {
if (!countryCode || countryCode.length !== 2) return '';
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt());
return String.fromCodePoint(...codePoints);
}
// Render security flags
function renderSecurityFlags(entry) {
const flags = [];
// Danger flags (red)
if (entry.flag_abuser == 1) flags.push({label: 'Abuser', type: 'danger'});
if (entry.flag_attacker == 1) flags.push({label: 'Attacker', type: 'danger'});
if (entry.flag_threat == 1) flags.push({label: 'Threat', type: 'danger'});
if (entry.flag_tor_exit == 1) flags.push({label: 'Tor Exit', type: 'danger'});
// Warning flags (yellow)
if (entry.flag_proxy == 1) flags.push({label: 'Proxy', type: 'warning'});
if (entry.flag_vpn == 1) flags.push({label: 'VPN', type: 'warning'});
if (entry.flag_tor == 1 && entry.flag_tor_exit != 1) flags.push({label: 'Tor', type: 'warning'});
if (entry.flag_relay == 1) flags.push({label: 'Relay', type: 'warning'});
if (entry.flag_anonymous == 1) flags.push({label: 'Anon', type: 'warning'});
// Info flags (blue)
if (entry.flag_cloud_provider == 1) flags.push({label: 'Cloud', type: 'info'});
if (entry.flag_bogon == 1) flags.push({label: 'Bogon', type: 'info'});
if (flags.length === 0) {
return entry.ipr_enriched_at ? '<span style="color: var(--success);">Clean</span>' : '<span style="color: var(--text-tertiary)">-</span>';
}
return flags.map(f => `<span class="flag-badge ${f.type}">${f.label}</span>`).join('');
}
// Enrich single IP
async function enrichIp(id) {
try {
const result = await api('enrich_ip', {}, 'POST', { id });
if (result.success) {
showToast('IP enriched successfully', 'success');
loadEntries(currentPage);
} else {
showToast(result.error || 'Failed to enrich IP', 'error');
}
} catch (error) {
showToast('Network error', 'error');
}
}
// Enrich all un-enriched IPs
async function enrichAllIps(btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner" style="display:inline-block"></span> Enriching...';
try {
const result = await api('enrich_all', {}, 'POST', {});
if (result.success) {
showToast(`Enriched ${result.enriched} IPs. ${result.failed || 0} failed.`, 'success');
loadEntries(currentPage);
} else {
showToast(result.error || 'Failed to enrich IPs', 'error');
}
} catch (error) {
showToast('Network error', 'error');
} finally {
btn.disabled = false;
btn.innerHTML = 'Enrich All Un-enriched IPs';
}
}
// Re-enrich current entry from edit modal
async function reEnrichCurrentEntry() {
const id = document.getElementById('entryId').value;
if (!id) return;
const btn = document.getElementById('reEnrichBtn');
const btnText = document.getElementById('reEnrichBtnText');
btn.disabled = true;
btnText.textContent = 'Enriching...';
try {
const result = await api('enrich_ip', {}, 'POST', { id: parseInt(id) });
if (result.success) {
showToast('IP enriched successfully', 'success');
// Reload the entry data to update the modal
const entryResult = await api('get', { id });
if (entryResult.success) {
// Update enrichment display without closing modal
const entry = entryResult.data;
const enrichStatus = document.getElementById('enrichmentStatus');
const enrichData = document.getElementById('enrichmentData');
const enrichedDate = new Date(entry.ipr_enriched_at).toLocaleString();
enrichStatus.innerHTML = `<span style="color: var(--success);">Enriched on ${enrichedDate}</span>`;
enrichData.style.display = 'block';
document.getElementById('enrichIsp').textContent = entry.ipr_isp || '-';
document.getElementById('enrichAsn').textContent = entry.ipr_asn ? `AS${entry.ipr_asn}` : '-';
document.getElementById('enrichOrg').textContent = entry.ipr_org || '-';
document.getElementById('enrichType').textContent = entry.ipr_connection_type || '-';
// Update flags
const flags = [];
if (entry.flag_abuser == 1) flags.push('Abuser');
if (entry.flag_attacker == 1) flags.push('Attacker');
if (entry.flag_bogon == 1) flags.push('Bogon');
if (entry.flag_cloud_provider == 1) flags.push('Cloud');
if (entry.flag_proxy == 1) flags.push('Proxy');
if (entry.flag_relay == 1) flags.push('Relay');
if (entry.flag_tor == 1) flags.push('Tor');
if (entry.flag_tor_exit == 1) flags.push('Tor Exit');
if (entry.flag_vpn == 1) flags.push('VPN');
if (entry.flag_anonymous == 1) flags.push('Anonymous');
if (entry.flag_threat == 1) flags.push('Threat');
const flagsEl = document.getElementById('enrichFlags');
if (flags.length > 0) {
flagsEl.innerHTML = '<strong>Flags:</strong> ' + flags.map(f => `<span style="background: var(--danger); color: white; padding: 1px 6px; border-radius: 4px; font-size: 11px; margin-left: 4px;">${f}</span>`).join('');
} else {
flagsEl.innerHTML = '<strong>Flags:</strong> <span style="color: var(--success);">None</span>';
}
}
// Also refresh the main table
loadEntries(currentPage);
} else {
showToast(result.error || 'Failed to enrich IP', 'error');
}
} catch (error) {
showToast('Network error', 'error');
} finally {
btn.disabled = false;
btnText.textContent = 'Re-enrich IP';
}
}
// Developer Tab Functions
// Download database backup
function downloadDatabaseBackup() {
const url = `api.php?action=database_backup`;
window.location.href = url;
showToast('Downloading backup...', 'success');
}
// Import database backup
async function importDatabaseBackup() {
const fileInput = document.getElementById('dbImportFile');
const file = fileInput.files[0];
if (!file) {
showToast('Please select a backup file', 'error');
return;
}
if (!file.name.endsWith('.json')) {
showToast('Please select a valid JSON backup file', 'error');
return;
}
if (!confirm('WARNING: This will replace ALL existing data with the backup. Are you sure you want to continue?')) {
return;
}
try {
const text = await file.text();
const backupData = JSON.parse(text);
// Validate basic structure
if (!backupData.backup_info || !backupData.geofeed_entries) {
showToast('Invalid backup file format', 'error');
return;
}
const result = await api('database_import', {}, 'POST', { backup_data: backupData });
if (result.success) {
showToast(`Import successful: ${result.imported.entries} entries, ${result.imported.settings} settings, ${result.imported.logos} logos`, 'success');
fileInput.value = '';
loadSystemInfo();
loadEntries();
loadStats();
} else {
showToast(result.error || 'Import failed', 'error');
}
} catch (e) {
if (e instanceof SyntaxError) {
showToast('Invalid JSON file', 'error');
} else {
showToast('Import failed: ' + e.message, 'error');
}
}
}
// Load system information
async function loadSystemInfo() {
const container = document.getElementById('systemInfoContent');
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
try {
const result = await api('system_info');
if (result.success) {
const data = result.data;
container.innerHTML = `
<div style="display: grid; gap: 16px;">
<div class="table-container" style="margin: 0;">
<table>
<tbody>
<tr>
<td style="width: 200px; font-weight: 600;">App Version</td>
<td>${escapeHtml(data.app_version)}</td>
</tr>
<tr>
<td style="font-weight: 600;">PHP Version</td>
<td>${escapeHtml(data.php_version)}</td>
</tr>
<tr>
<td style="font-weight: 600;">Server Software</td>
<td>${escapeHtml(data.server.software)}</td>
</tr>
<tr>
<td style="font-weight: 600;">Server Time</td>
<td>${escapeHtml(data.server.time)}</td>
</tr>
<tr>
<td style="font-weight: 600;">Timezone</td>
<td>${escapeHtml(data.server.timezone)}</td>
</tr>
</tbody>
</table>
</div>
<div class="table-container" style="margin: 0;">
<div class="table-header">
<h3 class="table-title">Database Statistics</h3>
</div>
<table>
<tbody>
<tr>
<td style="width: 200px; font-weight: 600;">Database Name</td>
<td>${escapeHtml(data.database.name)}</td>
</tr>
<tr>
<td style="font-weight: 600;">Database Host</td>
<td>${escapeHtml(data.database.host)}</td>
</tr>
<tr>
<td style="font-weight: 600;">Database Size</td>
<td>${data.database.size_mb} MB</td>
</tr>
<tr>
<td style="font-weight: 600;">Geofeed Entries</td>
<td>${data.database.entries.toLocaleString()}</td>
</tr>
<tr>
<td style="font-weight: 600;">Enriched Entries</td>
<td>${data.database.enriched_entries.toLocaleString()}</td>
</tr>
<tr>
<td style="font-weight: 600;">Settings</td>
<td>${data.database.settings.toLocaleString()}</td>
</tr>
<tr>
<td style="font-weight: 600;">Audit Log Entries</td>
<td>${data.database.audit_log_entries.toLocaleString()}</td>
</tr>
<tr>
<td style="font-weight: 600;">Client Logos</td>
<td>${data.database.client_logos.toLocaleString()}</td>
</tr>
</tbody>
</table>
</div>
</div>
`;
} else {
container.innerHTML = `<div class="alert alert-danger">Failed to load system info: ${escapeHtml(result.error)}</div>`;
}
} catch (error) {
container.innerHTML = `<div class="alert alert-danger">Failed to load system info</div>`;
}
}
// Schema sync variables
let pendingSchemaUpdates = null;
// Check for schema updates
async function checkSchemaUpdates() {
const resultDiv = document.getElementById('schemaCheckResult');
const applyBtn = document.getElementById('applySchemaBtn');
resultDiv.style.display = 'block';
resultDiv.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
applyBtn.disabled = true;
try {
const result = await api('schema_check');
if (result.success) {
pendingSchemaUpdates = result;
if (!result.has_updates) {
resultDiv.innerHTML = `
<div class="alert alert-success" style="display: flex; align-items: center; gap: 8px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
<span>Database schema is up to date. No changes needed.</span>
</div>
`;
applyBtn.disabled = true;
} else {
let html = `
<div class="alert alert-warning" style="margin-bottom: 12px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<span>Schema updates available. Review the changes below and click "Apply Schema Updates" to apply them.</span>
</div>
`;
if (result.missing_tables.length > 0) {
html += `
<div class="table-container" style="margin: 12px 0;">
<div class="table-header">
<h4 class="table-title">Missing Tables (${result.missing_tables.length})</h4>
</div>
<table>
<thead>
<tr>
<th>Table Name</th>
</tr>
</thead>
<tbody>
${result.missing_tables.map(t => `<tr><td><code>${escapeHtml(t)}</code></td></tr>`).join('')}
</tbody>
</table>
</div>
`;
}
if (result.missing_columns.length > 0) {
html += `
<div class="table-container" style="margin: 12px 0;">
<div class="table-header">
<h4 class="table-title">Missing Columns (${result.missing_columns.length})</h4>
</div>
<table>
<thead>
<tr>
<th>Table</th>
<th>Column</th>
<th>Definition</th>
</tr>
</thead>
<tbody>
${result.missing_columns.map(c => `
<tr>
<td><code>${escapeHtml(c.table)}</code></td>
<td><code>${escapeHtml(c.column)}</code></td>
<td><code style="font-size: 11px;">${escapeHtml(c.definition)}</code></td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}
if (result.missing_indexes.length > 0) {
html += `
<div class="table-container" style="margin: 12px 0;">
<div class="table-header">
<h4 class="table-title">Missing Indexes (${result.missing_indexes.length})</h4>
</div>
<table>
<thead>
<tr>
<th>Table</th>
<th>Index Name</th>
<th>Columns</th>
</tr>
</thead>
<tbody>
${result.missing_indexes.map(i => `
<tr>
<td><code>${escapeHtml(i.table)}</code></td>
<td><code>${escapeHtml(i.index)}</code></td>
<td><code>${escapeHtml(i.columns)}</code></td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}
resultDiv.innerHTML = html;
applyBtn.disabled = false;
}
} else {
resultDiv.innerHTML = `<div class="alert alert-danger">Failed to check schema: ${escapeHtml(result.error)}</div>`;
}
} catch (error) {
resultDiv.innerHTML = `<div class="alert alert-danger">Failed to check schema: ${error.message}</div>`;
}
}
// Apply schema updates
async function applySchemaUpdates() {
if (!pendingSchemaUpdates || !pendingSchemaUpdates.has_updates) {
showToast('No updates to apply', 'error');
return;
}
if (!confirm('Are you sure you want to apply the schema updates? This will modify your database structure.')) {
return;
}
const resultDiv = document.getElementById('schemaCheckResult');
const applyBtn = document.getElementById('applySchemaBtn');
applyBtn.disabled = true;
resultDiv.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
try {
const result = await api('schema_apply', {}, 'POST', {});
if (result.success) {
let html = '';
if (result.applied.length > 0) {
html += `
<div class="alert alert-success" style="margin-bottom: 12px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
<span>${escapeHtml(result.message)}</span>
</div>
<div class="table-container" style="margin: 12px 0;">
<div class="table-header">
<h4 class="table-title">Applied Changes (${result.applied.length})</h4>
</div>
<table>
<tbody>
${result.applied.map(a => `<tr><td style="color: var(--success);">${escapeHtml(a)}</td></tr>`).join('')}
</tbody>
</table>
</div>
`;
}
if (result.failed.length > 0) {
html += `
<div class="table-container" style="margin: 12px 0;">
<div class="table-header">
<h4 class="table-title" style="color: var(--error);">Failed (${result.failed.length})</h4>
</div>
<table>
<tbody>
${result.failed.map(f => `<tr><td style="color: var(--error);">${escapeHtml(f)}</td></tr>`).join('')}
</tbody>
</table>
</div>
`;
}
if (result.applied.length === 0 && result.failed.length === 0) {
html = `
<div class="alert alert-success">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
<span>No updates needed - schema is already up to date.</span>
</div>
`;
}
resultDiv.innerHTML = html;
pendingSchemaUpdates = null;
showToast(result.message, 'success');
// Refresh system info
loadSystemInfo();
} else {
resultDiv.innerHTML = `<div class="alert alert-danger">Failed to apply schema: ${escapeHtml(result.error)}</div>`;
applyBtn.disabled = false;
}
} catch (error) {
resultDiv.innerHTML = `<div class="alert alert-danger">Failed to apply schema: ${error.message}</div>`;
applyBtn.disabled = false;
}
}
// Logout function
async function logout() {
try {
await api('logout', {});
} catch (e) {}
window.location.href = 'login.php';
}
// Load error logs
async function loadErrorLogs() {
const container = document.getElementById('errorLogsContent');
const lines = document.getElementById('errorLogLines').value;
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
try {
const result = await api('error_logs', { lines: lines });
if (result.success) {
if (result.lines.length === 0) {
container.innerHTML = `
<div class="alert alert-success" style="display: flex; align-items: center; gap: 8px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
<span>${result.message || 'No error log entries found.'}</span>
</div>
<p style="margin-top: 8px; color: var(--text-secondary); font-size: 13px;">
Log path: <code>${escapeHtml(result.log_path)}</code>
</p>
`;
} else {
const getTypeColor = (type) => {
switch(type) {
case 'fatal': return 'var(--error)';
case 'error': return 'var(--error)';
case 'warning': return 'var(--warning)';
case 'notice': return '#17a2b8';
case 'deprecated': return '#6c757d';
default: return 'var(--text-secondary)';
}
};
const getTypeBadge = (type) => {
const color = getTypeColor(type);
return `<span style="display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 600; text-transform: uppercase; background: ${color}20; color: ${color};">${type}</span>`;
};
container.innerHTML = `
<div style="margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px;">
<span style="color: var(--text-secondary); font-size: 13px;">
Showing ${result.total_lines} entries from <code>${escapeHtml(result.log_path)}</code>
(${escapeHtml(result.log_size_formatted)})
</span>
</div>
<div class="table-container" style="margin: 0;">
<div style="max-height: 500px; overflow-y: auto;">
<table style="font-size: 12px;">
<thead style="position: sticky; top: 0; background: var(--bg-primary);">
<tr>
<th style="width: 80px;">Type</th>
<th style="width: 180px;">Timestamp</th>
<th>Message</th>
</tr>
</thead>
<tbody>
${result.lines.map(entry => `
<tr>
<td>${getTypeBadge(entry.type)}</td>
<td style="white-space: nowrap; color: var(--text-secondary); font-size: 11px;">${entry.timestamp ? escapeHtml(entry.timestamp) : '-'}</td>
<td style="font-family: monospace; font-size: 11px; word-break: break-all; color: ${getTypeColor(entry.type)};">${escapeHtml(entry.message)}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
`;
}
} else {
container.innerHTML = `<div class="alert alert-danger">Failed to load error logs: ${escapeHtml(result.error)}</div>`;
}
} catch (error) {
container.innerHTML = `<div class="alert alert-danger">Failed to load error logs: ${error.message}</div>`;
}
}
// =============================================
// AWS Route53 / PTR Record Functions
// =============================================
// Load AWS settings
async function loadAwsSettings() {
try {
const result = await api('aws_settings_get');
if (result.success && result.data) {
document.getElementById('awsAccessKeyId').value = result.data.aws_access_key_id || '';
document.getElementById('awsSecretAccessKey').value = result.data.aws_secret_access_key || '';
document.getElementById('awsRegion').value = result.data.aws_region || 'us-east-1';
document.getElementById('awsHostedZones').value = result.data.aws_hosted_zones || '';
}
} catch (error) {
console.error('Failed to load AWS settings:', error);
}
}
// Save AWS settings
async function saveAwsSettings() {
const data = {
aws_access_key_id: document.getElementById('awsAccessKeyId').value,
aws_secret_access_key: document.getElementById('awsSecretAccessKey').value,
aws_region: document.getElementById('awsRegion').value,
aws_hosted_zones: document.getElementById('awsHostedZones').value
};
try {
const result = await api('aws_settings_save', {}, 'POST', data);
if (result.success) {
showToast('AWS settings saved successfully', 'success');
} else {
showToast(result.error || 'Failed to save AWS settings', 'error');
}
} catch (error) {
showToast('Failed to save AWS settings: ' + error.message, 'error');
}
}
// Load whitelabel settings
async function loadWhitelabelSettings() {
try {
const result = await api('whitelabel_get');
if (result.success && result.settings) {
document.getElementById('whitelabelAppName').value = result.settings.app_name || '';
document.getElementById('whitelabelCompanyName').value = result.settings.company_name || '';
document.getElementById('whitelabelIconUrl').value = result.settings.icon_url || '';
document.getElementById('whitelabelFaviconUrl').value = result.settings.favicon_url || '';
document.getElementById('whitelabelDefaultImportUrl').value = result.settings.default_import_url || '';
updateWhitelabelPreview();
applyWhitelabelSettings(result.settings);
}
} catch (error) {
console.error('Failed to load whitelabel settings:', error);
}
}
// Save whitelabel settings
async function saveWhitelabelSettings() {
const data = {
app_name: document.getElementById('whitelabelAppName').value,
company_name: document.getElementById('whitelabelCompanyName').value,
icon_url: document.getElementById('whitelabelIconUrl').value,
favicon_url: document.getElementById('whitelabelFaviconUrl').value,
default_import_url: document.getElementById('whitelabelDefaultImportUrl').value
};
try {
const result = await api('whitelabel_save', {}, 'POST', data);
if (result.success) {
showToast('Whitelabel settings saved successfully', 'success');
applyWhitelabelSettings(data);
} else {
showToast(result.error || 'Failed to save whitelabel settings', 'error');
}
} catch (error) {
showToast('Failed to save whitelabel settings: ' + error.message, 'error');
}
}
// Update the preview in the whitelabel tab
function updateWhitelabelPreview() {
const appName = document.getElementById('whitelabelAppName').value || 'ISP IP Manager';
const companyName = document.getElementById('whitelabelCompanyName').value || '';
const iconUrl = document.getElementById('whitelabelIconUrl').value;
document.getElementById('whitelabelPreviewAppName').textContent = appName;
document.getElementById('whitelabelPreviewCompanyName').textContent = companyName;
const previewIcon = document.getElementById('whitelabelPreviewIcon');
if (iconUrl) {
previewIcon.innerHTML = `<img src="${escapeHtml(iconUrl)}" style="width: 100%; height: 100%; object-fit: contain;" onerror="this.parentElement.innerHTML='<svg viewBox=\\'0 0 258 258\\' fill=\\'none\\' width=\\'24\\' height=\\'24\\'><path fill=\\'white\\' d=\\'M241.13 56.2A26.53 26.53 0 11188.07 56.2a26.53 26.53 0 0153.06 0zm-5.34-.05a21.19 21.19 0 10-42.38 0 21.19 21.19 0 0042.38 0z\\' transform=\\'translate(-30,-5) scale(0.75)\\'/><path fill=\\'white\\' d=\\'M21.42 37.38h55.28a.32.32 0 01.32.32v12.21a.46.46 0 00.8.3c13.2-14.73 32.09-17.47 50.68-12.7 35.19 9.03 47.69 43.89 45.07 77C170.91 148.16 150.93 173.81 115.1 175.14q-22.52.84-37.38-15.22a.65.65 0 00-1.13.47c.06 1.2.49 2.44.49 4.15q-.04 23.9.01 56.37a.42.41 0 01-.42.41H21.66a.88.88 0 01-.88-.88V38.01a.64.63 0 01.64-.63zM77.02 104.64c0 12.43 5.67 26.28 20.24 26.28s20.25-13.85 20.25-26.28-5.67-26.28-20.25-26.28-20.24 13.85-20.24 26.28z\\' transform=\\'translate(30,30) scale(0.75)\\'/></svg>'">`;
} else {
previewIcon.innerHTML = `<svg viewBox="0 0 258 258" fill="none" width="24" height="24"><path fill="white" d="M241.13 56.2A26.53 26.53 0 11188.07 56.2a26.53 26.53 0 0153.06 0zm-5.34-.05a21.19 21.19 0 10-42.38 0 21.19 21.19 0 0042.38 0z" transform="translate(-30,-5) scale(0.75)"/><path fill="white" d="M21.42 37.38h55.28a.32.32 0 01.32.32v12.21a.46.46 0 00.8.3c13.2-14.73 32.09-17.47 50.68-12.7 35.19 9.03 47.69 43.89 45.07 77C170.91 148.16 150.93 173.81 115.1 175.14q-22.52.84-37.38-15.22a.65.65 0 00-1.13.47c.06 1.2.49 2.44.49 4.15q-.04 23.9.01 56.37a.42.41 0 01-.42.41H21.66a.88.88 0 01-.88-.88V38.01a.64.63 0 01.64-.63zM77.02 104.64c0 12.43 5.67 26.28 20.24 26.28s20.25-13.85 20.25-26.28-5.67-26.28-20.25-26.28-20.24 13.85-20.24 26.28z" transform="translate(30,30) scale(0.75)"/></svg>`;
}
}
// Apply whitelabel settings to the page
function applyWhitelabelSettings(settings) {
// Update page title and header
if (settings.app_name) {
document.title = settings.app_name;
document.querySelector('.logo-title').textContent = settings.app_name;
}
// Update company name subtitle
const logoSubtitle = document.querySelector('.logo-subtitle');
if (logoSubtitle) {
logoSubtitle.textContent = settings.company_name || '';
}
// Update favicon
if (settings.favicon_url) {
let link = document.querySelector("link[rel*='icon']") || document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'shortcut icon';
link.href = settings.favicon_url;
document.getElementsByTagName('head')[0].appendChild(link);
}
// Update header icon
if (settings.icon_url) {
const logoIcon = document.querySelector('.logo-icon');
if (logoIcon) {
logoIcon.innerHTML = `<img src="${escapeHtml(settings.icon_url)}" style="width: 100%; height: 100%; object-fit: contain;" onerror="this.parentElement.innerHTML='<svg viewBox=\\'0 0 258 258\\' fill=\\'none\\'><path fill=\\'white\\' d=\\'M241.13 56.2A26.53 26.53 0 11188.07 56.2a26.53 26.53 0 0153.06 0zm-5.34-.05a21.19 21.19 0 10-42.38 0 21.19 21.19 0 0042.38 0z\\' transform=\\'translate(-30,-5) scale(0.75)\\'/><path fill=\\'white\\' d=\\'M21.42 37.38h55.28a.32.32 0 01.32.32v12.21a.46.46 0 00.8.3c13.2-14.73 32.09-17.47 50.68-12.7 35.19 9.03 47.69 43.89 45.07 77C170.91 148.16 150.93 173.81 115.1 175.14q-22.52.84-37.38-15.22a.65.65 0 00-1.13.47c.06 1.2.49 2.44.49 4.15q-.04 23.9.01 56.37a.42.41 0 01-.42.41H21.66a.88.88 0 01-.88-.88V38.01a.64.63 0 01.64-.63zM77.02 104.64c0 12.43 5.67 26.28 20.24 26.28s20.25-13.85 20.25-26.28-5.67-26.28-20.25-26.28-20.24 13.85-20.24 26.28z\\' transform=\\'translate(30,30) scale(0.75)\\'/><circle fill=\\'#31b05e\\' cx=\\'195\\' cy=\\'195\\' r=\\'22\\' transform=\\'scale(0.85)\\'/></svg>'">`;
}
}
}
// Add event listeners for preview updates
document.addEventListener('DOMContentLoaded', function() {
const appNameInput = document.getElementById('whitelabelAppName');
const companyNameInput = document.getElementById('whitelabelCompanyName');
const iconUrlInput = document.getElementById('whitelabelIconUrl');
if (appNameInput) appNameInput.addEventListener('input', updateWhitelabelPreview);
if (companyNameInput) companyNameInput.addEventListener('input', updateWhitelabelPreview);
if (iconUrlInput) iconUrlInput.addEventListener('input', updateWhitelabelPreview);
});
// Test AWS connection
async function testAwsConnection() {
const resultDiv = document.getElementById('awsTestResult');
resultDiv.style.display = 'block';
resultDiv.innerHTML = '<div class="loading"><div class="spinner"></div> Testing connection...</div>';
try {
const result = await api('aws_test');
if (result.success) {
resultDiv.innerHTML = `
<div class="alert alert-success">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
<span>Connection successful! Found ${result.zones_count} hosted zone(s).</span>
</div>
`;
} else {
resultDiv.innerHTML = `
<div class="alert alert-danger">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
<span>${escapeHtml(result.error || 'Connection failed')}</span>
</div>
`;
}
} catch (error) {
resultDiv.innerHTML = `
<div class="alert alert-danger">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
<span>Connection failed: ${escapeHtml(error.message)}</span>
</div>
`;
}
}
// Load PTR zones into dropdown
async function loadPtrZones() {
const select = document.getElementById('ptrZoneSelect');
const notConfigured = document.getElementById('ptrNotConfigured');
const configured = document.getElementById('ptrConfigured');
const refreshAwsBtn = document.getElementById('refreshAwsBtn');
try {
const result = await api('aws_zones');
if (result.success && result.zones && result.zones.length > 0) {
notConfigured.style.display = 'none';
configured.style.display = 'block';
select.innerHTML = '<option value="">Select a zone...</option>';
result.zones.forEach(zone => {
const option = document.createElement('option');
option.value = zone.id;
option.textContent = zone.name + ' (' + zone.id + ')';
select.appendChild(option);
});
// Auto-select if only one zone is configured
if (result.zones.length === 1) {
select.value = result.zones[0].id;
loadCachedPtrRecords();
}
} else {
notConfigured.style.display = 'block';
configured.style.display = 'none';
}
} catch (error) {
notConfigured.style.display = 'block';
configured.style.display = 'none';
console.error('Failed to load PTR zones:', error);
}
}
// Load cached PTR records from database
async function loadCachedPtrRecords() {
const zoneId = document.getElementById('ptrZoneSelect').value;
const tbody = document.getElementById('ptrRecordsBody');
const countSpan = document.getElementById('ptrRecordCount');
const checkAllBtn = document.getElementById('checkAllPtrsBtn');
const refreshAwsBtn = document.getElementById('refreshAwsBtn');
const syncStatus = document.getElementById('ptrSyncStatus');
refreshAwsBtn.disabled = !zoneId;
if (!zoneId) {
tbody.innerHTML = `
<tr>
<td colspan="6" style="text-align: center; color: var(--text-tertiary); padding: 40px;">
Select a hosted zone to view A records
</td>
</tr>
`;
countSpan.textContent = '';
checkAllBtn.disabled = true;
syncStatus.style.display = 'none';
return;
}
tbody.innerHTML = `
<tr>
<td colspan="6" style="text-align: center; padding: 40px;">
<div class="loading"><div class="spinner"></div></div>
</td>
</tr>
`;
try {
const result = await api('ptr_cache_get', { zone_id: zoneId });
if (result.success) {
const records = result.records || [];
countSpan.textContent = records.length + ' record(s)';
checkAllBtn.disabled = records.length === 0;
// Update sync status
if (records.length > 0) {
syncStatus.style.display = 'flex';
document.getElementById('lastAwsSync').textContent = result.last_sync ? formatDateTime(result.last_sync) : 'Never';
// Calculate stats
let matches = 0, mismatches = 0, missing = 0, unknown = 0;
let lastPtrCheck = null;
records.forEach(r => {
if (r.ptr_status === 'match') matches++;
else if (r.ptr_status === 'mismatch') mismatches++;
else if (r.ptr_status === 'missing') missing++;
else unknown++;
if (r.ptr_checked_at && (!lastPtrCheck || r.ptr_checked_at > lastPtrCheck)) {
lastPtrCheck = r.ptr_checked_at;
}
});
document.getElementById('lastPtrCheck').textContent = lastPtrCheck ? formatDateTime(lastPtrCheck) : 'Never';
document.getElementById('statMatch').textContent = matches + ' Match';
document.getElementById('statMismatch').textContent = mismatches + ' Mismatch';
document.getElementById('statMissing').textContent = missing + ' Missing';
document.getElementById('statUnknown').textContent = unknown + ' Unchecked';
} else {
syncStatus.style.display = 'none';
}
if (records.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6" style="text-align: center; color: var(--text-tertiary); padding: 40px;">
No cached records. Click "Sync from AWS" to fetch A records.
</td>
</tr>
`;
return;
}
// Store records globally for IPXO export
window.cachedPtrRecords = records;
document.getElementById('exportIpxoBtn').disabled = !records.some(r => r.ptr_status === 'mismatch' || r.ptr_status === 'missing');
tbody.innerHTML = records.map((record) => {
const statusColor = getStatusColor(record.ptr_status);
const statusLabel = (record.ptr_status || 'unknown').toUpperCase();
return `
<tr data-id="${record.id}" data-ip="${escapeHtml(record.ip_address)}" data-hostname="${escapeHtml(record.hostname)}" data-ttl="${record.ttl || 3600}" data-zone-id="${escapeHtml(record.zone_id || '')}">
<td><code style="font-size: 12px;">${escapeHtml(record.ip_address)}</code></td>
<td><span class="cell-truncate" title="${escapeHtml(record.hostname)}">${escapeHtml(record.hostname)}</span></td>
<td style="color: var(--text-secondary);">${record.ttl || '-'}</td>
<td class="ptr-value" style="color: ${record.ptr_record ? 'var(--text-primary)' : 'var(--text-tertiary)'};">${record.ptr_record || '-'}</td>
<td class="ptr-status">
<span style="display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; background: ${statusColor}; color: white;">${statusLabel}</span>
</td>
<td style="display: flex; gap: 4px;">
<button class="btn btn-secondary btn-sm" onclick="checkSinglePtr(${record.id}, this)" title="Check PTR">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
</button>
<button class="btn btn-ghost btn-sm" onclick="editARecord(${record.id}, '${escapeHtml(record.hostname)}', '${escapeHtml(record.ip_address)}', ${record.ttl || 3600})" title="Edit A Record">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
</td>
</tr>
`}).join('');
} else {
tbody.innerHTML = `
<tr>
<td colspan="6" style="text-align: center; color: var(--error); padding: 40px;">
${escapeHtml(result.error || 'Failed to load records')}
</td>
</tr>
`;
countSpan.textContent = '';
checkAllBtn.disabled = true;
syncStatus.style.display = 'none';
}
} catch (error) {
tbody.innerHTML = `
<tr>
<td colspan="6" style="text-align: center; color: var(--error); padding: 40px;">
Failed to load records: ${escapeHtml(error.message)}
</td>
</tr>
`;
countSpan.textContent = '';
syncStatus.style.display = 'none';
checkAllBtn.disabled = true;
}
}
// Helper function to get status color
function getStatusColor(status) {
switch (status) {
case 'match': return 'var(--success)';
case 'mismatch': return 'var(--warning)';
case 'missing': return 'var(--error)';
case 'error': return 'var(--error)';
default: return 'var(--text-tertiary)';
}
}
// Format date time for display
function formatDateTime(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleString();
}
// Refresh from AWS - sync A records from Route53
async function refreshFromAws() {
const zoneId = document.getElementById('ptrZoneSelect').value;
if (!zoneId) {
showToast('Please select a zone first', 'warning');
return;
}
const refreshAwsBtn = document.getElementById('refreshAwsBtn');
refreshAwsBtn.disabled = true;
refreshAwsBtn.innerHTML = '<div class="spinner" style="width: 14px; height: 14px;"></div> Syncing...';
try {
const result = await api('ptr_cache_refresh', { zone_id: zoneId });
if (result.success) {
showToast(`AWS sync complete: ${result.message}`, 'success');
await loadCachedPtrRecords();
} else {
showToast(result.error || 'Failed to sync from AWS', 'error');
}
} catch (error) {
showToast('Failed to sync from AWS: ' + error.message, 'error');
}
refreshAwsBtn.disabled = false;
refreshAwsBtn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
<path d="M3 3v5h5"/>
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
<path d="M16 21h5v-5"/>
</svg>
Sync from AWS
`;
}
// Check single PTR record (for cached records)
async function checkSinglePtr(recordId, button) {
const row = button.closest('tr');
const ptrValueCell = row.querySelector('.ptr-value');
const ptrStatusCell = row.querySelector('.ptr-status');
const ip = row.getAttribute('data-ip');
button.disabled = true;
button.innerHTML = '<div class="spinner" style="width: 14px; height: 14px;"></div>';
try {
const result = await api('ptr_lookup', { ip: ip });
if (result.success) {
const ptr = result.ptr || '';
ptrValueCell.textContent = ptr || '(none)';
ptrValueCell.style.color = ptr ? 'var(--text-primary)' : 'var(--text-tertiary)';
const status = result.status || 'unknown';
const statusColor = getStatusColor(status);
const statusLabel = status.toUpperCase();
ptrStatusCell.innerHTML = `<span style="display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; background: ${statusColor}; color: white;">${statusLabel}</span>`;
} else {
ptrValueCell.textContent = 'Error';
ptrValueCell.style.color = 'var(--error)';
ptrStatusCell.innerHTML = `<span style="display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; background: var(--error); color: white;">ERROR</span>`;
}
} catch (error) {
ptrValueCell.textContent = 'Error';
ptrValueCell.style.color = 'var(--error)';
ptrStatusCell.innerHTML = `<span style="display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; background: var(--error); color: white;">ERROR</span>`;
}
button.disabled = false;
button.innerHTML = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
Check
`;
}
// Check all PTR records (batch via API)
async function checkAllPtrs() {
const zoneId = document.getElementById('ptrZoneSelect').value;
if (!zoneId) {
showToast('Please select a zone first', 'warning');
return;
}
const checkAllBtn = document.getElementById('checkAllPtrsBtn');
checkAllBtn.disabled = true;
checkAllBtn.innerHTML = '<div class="spinner" style="width: 14px; height: 14px;"></div> Checking...';
try {
const result = await api('ptr_check_all', { zone_id: zoneId });
if (result.success) {
showToast(`Checked ${result.checked} PTR(s): ${result.matches} match, ${result.mismatches} mismatch, ${result.missing} missing`, 'success');
await loadCachedPtrRecords();
} else {
showToast(result.error || 'Failed to check PTRs', 'error');
}
} catch (error) {
showToast('Failed to check PTRs: ' + error.message, 'error');
}
checkAllBtn.disabled = false;
checkAllBtn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
Check All PTRs
`;
}
// Edit A Record modal functions
function editARecord(id, hostname, ip, ttl) {
document.getElementById('editARecordId').value = id;
document.getElementById('editARecordOldHostname').value = hostname;
document.getElementById('editARecordHostname').value = hostname;
document.getElementById('editARecordIp').value = ip;
document.getElementById('editARecordTtl').value = ttl || 3600;
document.getElementById('editARecordModal').classList.add('active');
}
function closeEditARecordModal() {
document.getElementById('editARecordModal').classList.remove('active');
}
async function saveARecord(event) {
event.preventDefault();
const zoneId = document.getElementById('ptrZoneSelect').value;
if (!zoneId) {
showToast('No zone selected', 'error');
return;
}
const id = document.getElementById('editARecordId').value;
const oldHostname = document.getElementById('editARecordOldHostname').value;
const hostname = document.getElementById('editARecordHostname').value;
const ip = document.getElementById('editARecordIp').value;
const ttl = parseInt(document.getElementById('editARecordTtl').value) || 3600;
const saveBtn = document.getElementById('saveARecordBtn');
saveBtn.disabled = true;
saveBtn.innerHTML = '<div class="spinner" style="width: 14px; height: 14px;"></div> Updating...';
try {
const result = await api('aws_update_a_record', {}, 'POST', {
zone_id: zoneId,
old_hostname: oldHostname,
hostname: hostname,
ip: ip,
ttl: ttl
});
if (result.success) {
showToast('A record updated successfully', 'success');
closeEditARecordModal();
// Refresh the records from AWS
await refreshFromAws();
} else {
showToast(result.error || 'Failed to update A record', 'error');
}
} catch (error) {
showToast('Failed to update A record: ' + error.message, 'error');
}
saveBtn.disabled = false;
saveBtn.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
Update A Record
`;
}
// Export IPXO JSON for mismatched/missing PTRs
function exportIpxoJson() {
const records = window.cachedPtrRecords || [];
const mismatchedOrMissing = records.filter(r => r.ptr_status === 'mismatch' || r.ptr_status === 'missing');
if (mismatchedOrMissing.length === 0) {
showToast('No mismatched or missing PTR records to export', 'warning');
return;
}
// Group records by /24 subnet
const subnets = {};
mismatchedOrMissing.forEach(record => {
const ip = record.ip_address;
const parts = ip.split('.');
if (parts.length === 4) {
const subnet = `${parts[0]}.${parts[1]}.${parts[2]}.0/24`;
if (!subnets[subnet]) {
subnets[subnet] = [];
}
subnets[subnet].push({
address: ip,
dname: record.hostname,
ttl: record.ttl || 3600
});
}
});
// Download a separate JSON file for each /24 subnet
const subnetKeys = Object.keys(subnets).sort();
let downloadCount = 0;
subnetKeys.forEach((subnet, index) => {
const ipxoData = {
subnets: [{
prefix: subnet,
records: subnets[subnet]
}]
};
const jsonStr = JSON.stringify(ipxoData, null, 2);
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
// Create filename from subnet (replace / and . for valid filename)
const filename = `ipxo-ptr-${subnet.replace(/\//g, '-').replace(/\./g, '_')}.json`;
// Stagger downloads slightly to avoid browser blocking
setTimeout(() => {
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
downloadCount++;
if (downloadCount === subnetKeys.length) {
showToast(`Downloaded ${subnetKeys.length} IPXO JSON file(s) for ${mismatchedOrMissing.length} PTR record(s)`, 'success');
}
}, index * 100);
});
}
// Clear error logs
async function clearErrorLogs() {
if (!confirm('Are you sure you want to clear the error log? This action cannot be undone.')) {
return;
}
try {
const result = await api('error_logs_clear', {}, 'POST', {});
if (result.success) {
showToast(result.message, 'success');
loadErrorLogs();
} else {
showToast(result.error || 'Failed to clear error logs', 'error');
}
} catch (error) {
showToast('Failed to clear error logs: ' + error.message, 'error');
}
}
// Close modals on escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
closeDeleteModal();
closeClearAllModal();
closeInfoModal();
}
});
// Close modals on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
closeModal();
closeDeleteModal();
closeClearAllModal();
closeInfoModal();
}
});
});
// Prevent zoom on iOS
document.addEventListener('gesturestart', function(e) {
e.preventDefault();
});