Files
ip-manager/webapp/index.php
2026-01-17 23:20:33 +00:00

4558 lines
202 KiB
PHP

<?php
// Include config - with error handling
$configFile = __DIR__ . '/config.php';
if (file_exists($configFile)) {
require_once $configFile;
}
// Fallback: Define function if not already defined (in case config.php failed)
if (!function_exists('generateCSRFToken')) {
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
function generateCSRFToken() {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
}
// Require authentication
if (function_exists('requireAuth')) {
requireAuth();
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="theme-color" content="#6B2D7B" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#1a1a2e" media="(prefers-color-scheme: dark)">
<title>Geofeed Manager | Purple Computing</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 258 258'%3E%3Cpath fill='%23474a4c' 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'/%3E%3Cpath fill='%23a23f97' 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'/%3E%3Cpath fill='%23474a4c' d='M221.39 61.32l4.27 7.4a1.09 1.09 0 01-.94 1.63h-.86a3.6 3.59 74.9 01-3.11-1.8l-3.42-5.93a1.73 1.72 74.8 00-1.49-.86h-5.78a.65.65 0 00-.65.65v6.54a1.26 1.26 0 01-1.26 1.26h-1.66a1.51 1.5 0 01-1.51-1.5V43.2a.88.88 0 01.89-.88c4.16.09 11.28-.78 15.02 1.14 5.3 2.72 7.21 7.98 4.13 13.34-.92 1.58-2.43 2.35-3.53 3.56a.82.81 51.2 00-.1.96zm-11.98-14.77l.06 11.22a.61.61 0 00.61.61l5.18-.03a7.25 6.14-.3 006.22-6.17v-.16a7.25 6.14-.3 00-7.28-6.11l-5.18.03a.61.61 0 00-.61.61z'/%3E%3Ccircle fill='%2331b05e' cx='163.95' cy='201.82' r='28.07'/%3E%3C/svg%3E">
<link rel="apple-touch-icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 258 258'%3E%3Crect fill='%236B2D7B' width='258' height='258' rx='40'/%3E%3Cpath fill='%23fff' 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(-50,-10) scale(0.85)'/%3E%3Cpath fill='%23fff' 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(20,20) scale(0.85)'/%3E%3Ccircle fill='%2331b05e' cx='200' cy='200' r='22'/%3E%3C/svg%3E">
<style>
:root {
/* Purple Computing Brand Colors */
--purple-primary: #6B2D7B;
--purple-dark: #4A1F55;
--purple-light: #8B4D9B;
--purple-lighter: #F5EDF7;
--purple-gradient: linear-gradient(135deg, #6B2D7B 0%, #8B4D9B 100%);
/* UI Colors - Light Mode */
--bg-primary: #f8f9fa;
--bg-secondary: #ffffff;
--bg-tertiary: #f1f3f4;
--text-primary: #1a1a2e;
--text-secondary: #6c757d;
--text-tertiary: #868e96;
--border: rgba(0, 0, 0, 0.08);
--border-strong: rgba(0, 0, 0, 0.12);
/* Shadows */
--shadow-sm: 0 1px 3px rgba(107, 45, 123, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 12px rgba(107, 45, 123, 0.1), 0 2px 4px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 10px 40px rgba(107, 45, 123, 0.15), 0 4px 12px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 25px 50px -12px rgba(107, 45, 123, 0.2);
/* Radii */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
/* Status Colors */
--success: #28a745;
--success-bg: rgba(40, 167, 69, 0.1);
--warning: #ffc107;
--warning-bg: rgba(255, 193, 7, 0.1);
--error: #dc3545;
--error-bg: rgba(220, 53, 69, 0.1);
--info: #17a2b8;
--info-bg: rgba(23, 162, 184, 0.1);
/* Transitions */
--transition: all 0.2s ease;
--transition-slow: all 0.3s ease;
/* Safe areas for iOS */
--safe-area-top: env(safe-area-inset-top);
--safe-area-bottom: env(safe-area-inset-bottom);
--safe-area-left: env(safe-area-inset-left);
--safe-area-right: env(safe-area-inset-right);
}
/* Dark Mode */
@media (prefers-color-scheme: dark) {
:root {
--purple-primary: #9B5FAB;
--purple-dark: #7B3F8B;
--purple-light: #BB7FCB;
--purple-lighter: rgba(155, 95, 171, 0.15);
--purple-gradient: linear-gradient(135deg, #4A1F55 0%, #6B2D7B 100%);
--bg-primary: #0d0d14;
--bg-secondary: #1a1a2e;
--bg-tertiary: #252542;
--text-primary: #f0f0f5;
--text-secondary: #a0a0b0;
--text-tertiary: #707080;
--border: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.12);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 10px 40px rgba(0, 0, 0, 0.5), 0 4px 12px rgba(0, 0, 0, 0.3);
--shadow-xl: 0 25px 50px -12px rgba(0, 0, 0, 0.6);
--success-bg: rgba(40, 167, 69, 0.2);
--warning-bg: rgba(255, 193, 7, 0.2);
--error-bg: rgba(220, 53, 69, 0.2);
--info-bg: rgba(23, 162, 184, 0.2);
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
-webkit-tap-highlight-color: transparent;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
min-height: -webkit-fill-available;
padding-top: var(--safe-area-top);
padding-bottom: var(--safe-area-bottom);
padding-left: var(--safe-area-left);
padding-right: var(--safe-area-right);
}
/* Header */
.header {
background: var(--purple-gradient);
color: white;
position: sticky;
top: 0;
z-index: 100;
box-shadow: var(--shadow-md);
}
.header-content {
max-width: 1600px;
margin: 0 auto;
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.logo {
display: flex;
align-items: center;
gap: 14px;
flex-shrink: 0;
}
.logo-icon {
width: 42px;
height: 42px;
background: rgba(255, 255, 255, 0.2);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.logo-icon svg {
width: 28px;
height: 28px;
}
.logo-text {
display: flex;
flex-direction: column;
}
.logo-title {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.02em;
}
.logo-subtitle {
font-size: 11px;
opacity: 0.85;
font-weight: 500;
letter-spacing: 0.02em;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
border-radius: var(--radius-md);
border: none;
cursor: pointer;
transition: var(--transition);
text-decoration: none;
white-space: nowrap;
font-family: inherit;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
.btn:active {
transform: scale(0.98);
}
.btn-primary {
background: var(--purple-primary);
color: white;
}
.btn-primary:hover {
background: var(--purple-dark);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-strong);
}
.btn-secondary:hover {
background: var(--bg-tertiary);
border-color: var(--purple-light);
}
.btn-white {
background: white;
color: var(--purple-primary);
}
.btn-white:hover {
background: var(--purple-lighter);
transform: translateY(-1px);
}
.btn-ghost {
background: transparent;
color: var(--purple-primary);
padding: 8px 16px;
}
.btn-ghost:hover {
background: var(--purple-lighter);
}
.btn-danger {
background: var(--error);
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.btn-sm {
padding: 6px 14px;
font-size: 13px;
}
.btn-icon {
width: 36px;
height: 36px;
padding: 0;
border-radius: var(--radius-sm);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
/* Main Content */
.main {
max-width: 1600px;
margin: 0 auto;
padding: 24px 16px;
}
@media (min-width: 768px) {
.main {
padding: 32px 24px;
}
}
/* Tabs */
.tabs {
display: flex;
gap: 4px;
background: var(--bg-secondary);
padding: 6px;
border-radius: var(--radius-lg);
margin-bottom: 24px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar {
display: none;
}
.tab {
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
background: transparent;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: var(--transition);
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
flex-shrink: 0;
}
.tab:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.tab.active {
background: var(--purple-primary);
color: white;
box-shadow: var(--shadow-sm);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Stats Cards */
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 24px;
}
@media (min-width: 640px) {
.stats-grid {
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
}
.stat-card {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
padding: 16px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
transition: var(--transition);
}
@media (min-width: 768px) {
.stat-card {
padding: 20px;
}
}
.stat-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--purple-light);
}
.stat-label {
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
margin-bottom: 6px;
}
.stat-value {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--purple-primary);
}
@media (min-width: 768px) {
.stat-value {
font-size: 28px;
}
}
/* Search Bar */
.search-container {
margin-bottom: 24px;
}
.search-bar {
display: flex;
align-items: center;
gap: 12px;
background: var(--bg-secondary);
border: 2px solid var(--border);
border-radius: var(--radius-lg);
padding: 12px 16px;
transition: var(--transition);
}
@media (min-width: 768px) {
.search-bar {
padding: 12px 20px;
}
}
.search-bar:focus-within {
border-color: var(--purple-primary);
box-shadow: 0 0 0 4px rgba(107, 45, 123, 0.1);
}
.search-icon {
color: var(--text-secondary);
flex-shrink: 0;
}
.search-input {
flex: 1;
border: none;
background: transparent;
font-size: 16px;
color: var(--text-primary);
outline: none;
font-family: inherit;
min-width: 0;
}
.search-input::placeholder {
color: var(--text-tertiary);
}
/* Table */
.table-container {
background: var(--bg-secondary);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-md);
border: 1px solid var(--border);
overflow: hidden;
}
.table-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--border);
background: var(--bg-tertiary);
gap: 12px;
flex-wrap: wrap;
}
@media (min-width: 768px) {
.table-header {
padding: 20px 24px;
}
}
.table-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.table-actions {
display: flex;
gap: 8px;
}
.table-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 1400px;
}
th, td {
text-align: left;
padding: 12px 16px;
}
@media (min-width: 768px) {
th, td {
padding: 14px 20px;
}
}
th {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
}
td {
font-size: 14px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
tr:last-child td {
border-bottom: none;
}
tbody tr {
transition: var(--transition);
}
tbody tr:hover {
background: var(--purple-lighter);
}
.client-cell {
display: flex;
align-items: center;
justify-content: center;
}
.client-logo {
width: 42px;
height: 42px;
border-radius: 8px;
object-fit: contain;
background: var(--bg-tertiary);
padding: 4px;
transition: transform 0.15s ease;
cursor: pointer;
}
.client-logo:hover {
transform: scale(1.1);
}
.client-logo-placeholder {
width: 42px;
height: 42px;
border-radius: 8px;
background: var(--purple-lighter);
display: flex;
align-items: center;
justify-content: center;
color: var(--purple-primary);
font-size: 16px;
font-weight: 600;
transition: transform 0.15s ease;
cursor: pointer;
}
.client-logo-placeholder:hover {
transform: scale(1.1);
}
/* Security flags */
.flags-cell {
display: flex;
flex-wrap: wrap;
gap: 4px;
max-width: 160px;
}
.flag-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.flag-badge.danger {
background: var(--error-bg);
color: var(--error);
}
.flag-badge.warning {
background: var(--warning-bg);
color: #856404;
}
.flag-badge.info {
background: var(--info-bg);
color: var(--info);
}
.flag-badge svg {
width: 10px;
height: 10px;
}
@media (prefers-color-scheme: dark) {
.flag-badge.warning {
color: #ffc107;
}
}
.ip-prefix {
font-family: 'SF Mono', SFMono-Regular, ui-monospace, Menlo, Monaco, monospace;
font-size: 12px;
font-weight: 600;
color: var(--purple-primary);
background: var(--purple-lighter);
padding: 4px 8px;
border-radius: 6px;
display: inline-block;
white-space: nowrap;
}
@media (min-width: 768px) {
.ip-prefix {
font-size: 13px;
padding: 4px 10px;
}
}
.country-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
}
.country-flag {
font-size: 16px;
}
.row-actions {
display: flex;
gap: 4px;
opacity: 1;
}
@media (min-width: 768px) {
.row-actions {
opacity: 0;
transition: var(--transition);
}
tr:hover .row-actions {
opacity: 1;
}
}
/* Pagination */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 16px;
border-top: 1px solid var(--border);
background: var(--bg-tertiary);
flex-wrap: wrap;
}
@media (min-width: 768px) {
.pagination {
gap: 8px;
padding: 20px;
}
}
.pagination-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: var(--bg-secondary);
color: var(--text-secondary);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: var(--transition);
border: 1px solid var(--border);
}
.pagination-btn:hover:not(:disabled) {
background: var(--purple-lighter);
color: var(--purple-primary);
border-color: var(--purple-light);
}
.pagination-btn.active {
background: var(--purple-primary);
color: white;
border-color: var(--purple-primary);
}
.pagination-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.pagination-info {
color: var(--text-secondary);
font-size: 12px;
margin: 0 8px;
text-align: center;
}
@media (min-width: 768px) {
.pagination-info {
font-size: 13px;
margin: 0 16px;
}
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(26, 26, 46, 0.6);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: var(--transition);
padding: 0;
}
@media (min-width: 640px) {
.modal-overlay {
align-items: center;
padding: 24px;
}
}
.modal-overlay.active {
opacity: 1;
visibility: visible;
}
.modal {
background: var(--bg-secondary);
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
box-shadow: var(--shadow-xl);
width: 100%;
max-height: 90vh;
overflow-y: auto;
transform: translateY(100%);
transition: var(--transition-slow);
-webkit-overflow-scrolling: touch;
}
@media (min-width: 640px) {
.modal {
max-width: 520px;
border-radius: var(--radius-xl);
transform: scale(0.95) translateY(20px);
}
}
.modal-overlay.active .modal {
transform: translateY(0);
}
@media (min-width: 640px) {
.modal-overlay.active .modal {
transform: scale(1) translateY(0);
}
}
.modal-header {
padding: 24px 24px 0;
}
.modal-title {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
margin-bottom: 6px;
color: var(--purple-primary);
}
.modal-subtitle {
font-size: 14px;
color: var(--text-secondary);
}
.modal-body {
padding: 24px;
}
.modal-footer {
padding: 0 24px 24px;
display: flex;
justify-content: flex-end;
gap: 12px;
padding-bottom: calc(24px + var(--safe-area-bottom));
}
@media (min-width: 640px) {
.modal-footer {
padding-bottom: 24px;
}
}
/* Form */
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.form-label .required {
color: var(--error);
}
.form-input, .form-select {
width: 100%;
padding: 12px 16px;
font-size: 16px;
border: 2px solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--bg-tertiary);
color: var(--text-primary);
transition: var(--transition);
font-family: inherit;
-webkit-appearance: none;
}
.form-select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%236c757d' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 16px;
padding-right: 44px;
}
.form-input:focus, .form-select:focus {
outline: none;
border-color: var(--purple-primary);
box-shadow: 0 0 0 4px rgba(107, 45, 123, 0.1);
background: var(--bg-secondary);
}
.form-input::placeholder {
color: var(--text-tertiary);
}
.form-hint {
font-size: 12px;
color: var(--text-secondary);
margin-top: 6px;
}
.form-error {
font-size: 12px;
color: var(--error);
margin-top: 6px;
}
.form-row {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
@media (min-width: 480px) {
.form-row {
grid-template-columns: 1fr 1fr;
}
}
/* Advanced Tab Styles */
.advanced-section {
background: var(--bg-secondary);
border-radius: var(--radius-xl);
padding: 24px;
margin-bottom: 24px;
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
}
@media (min-width: 768px) {
.advanced-section {
padding: 32px;
}
}
.advanced-section-title {
font-size: 18px;
font-weight: 700;
margin-bottom: 8px;
color: var(--purple-primary);
}
.advanced-section-desc {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 24px;
}
.advanced-section-desc code {
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
}
.import-options {
display: grid;
grid-template-columns: 1fr;
gap: 20px;
}
@media (min-width: 640px) {
.import-options {
grid-template-columns: repeat(2, 1fr);
}
}
.import-card {
background: var(--bg-tertiary);
border: 2px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px;
transition: var(--transition);
}
@media (min-width: 768px) {
.import-card {
padding: 24px;
}
}
.import-card:hover {
border-color: var(--purple-light);
}
.import-card-icon {
width: 48px;
height: 48px;
background: var(--purple-lighter);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: var(--purple-primary);
margin-bottom: 16px;
}
.import-card-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
}
.import-card-desc {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 16px;
}
.file-input-wrapper {
position: relative;
}
.file-input {
position: absolute;
opacity: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
.file-input-label {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 20px;
background: var(--bg-secondary);
border: 2px dashed var(--border-strong);
border-radius: var(--radius-md);
cursor: pointer;
transition: var(--transition);
font-size: 14px;
color: var(--text-secondary);
}
.file-input-label:hover {
border-color: var(--purple-primary);
background: var(--purple-lighter);
color: var(--purple-primary);
}
.file-name {
font-size: 13px;
color: var(--purple-primary);
margin-top: 8px;
font-weight: 500;
}
/* Progress Bar */
.progress-container {
margin-top: 16px;
display: none;
}
.progress-container.active {
display: block;
}
.progress-bar {
height: 8px;
background: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--purple-gradient);
border-radius: 4px;
transition: width 0.3s ease;
width: 0%;
}
.progress-text {
font-size: 13px;
color: var(--text-secondary);
margin-top: 8px;
text-align: center;
}
/* Import Results */
.import-results {
margin-top: 20px;
padding: 16px;
border-radius: var(--radius-md);
display: none;
}
.import-results.active {
display: block;
}
.import-results.success {
background: var(--success-bg);
border: 1px solid rgba(40, 167, 69, 0.3);
}
.import-results.error {
background: var(--error-bg);
border: 1px solid rgba(220, 53, 69, 0.3);
}
.import-results-title {
font-weight: 600;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.import-results.success .import-results-title {
color: var(--success);
}
.import-results.error .import-results-title {
color: var(--error);
}
.import-results-text {
font-size: 14px;
color: var(--text-secondary);
}
/* Audit Log Styles */
.audit-log-container {
max-height: 500px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.audit-entry {
display: flex;
gap: 16px;
padding: 16px;
border-bottom: 1px solid var(--border);
transition: var(--transition);
}
.audit-entry:last-child {
border-bottom: none;
}
.audit-entry:hover {
background: var(--bg-tertiary);
}
.audit-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.audit-icon.insert {
background: var(--success-bg);
color: var(--success);
}
.audit-icon.update {
background: var(--info-bg);
color: var(--info);
}
.audit-icon.delete {
background: var(--error-bg);
color: var(--error);
}
.audit-content {
flex: 1;
min-width: 0;
}
.audit-header {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 4px;
}
.audit-action {
font-weight: 600;
font-size: 14px;
}
.audit-prefix {
font-family: 'SF Mono', monospace;
font-size: 12px;
background: var(--purple-lighter);
color: var(--purple-primary);
padding: 2px 8px;
border-radius: 4px;
}
.audit-time {
font-size: 12px;
color: var(--text-tertiary);
}
.audit-details {
font-size: 13px;
color: var(--text-secondary);
}
.audit-by {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 4px;
}
/* Logo Management */
.logo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
margin-top: 20px;
}
.logo-card {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 16px;
display: flex;
align-items: center;
gap: 12px;
transition: var(--transition);
}
.logo-card:hover {
border-color: var(--purple-light);
}
.logo-preview {
width: 48px;
height: 48px;
border-radius: 8px;
object-fit: cover;
background: var(--bg-secondary);
flex-shrink: 0;
}
.logo-card-info {
flex: 1;
min-width: 0;
}
.logo-card-name {
font-weight: 600;
font-size: 14px;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.logo-card-url {
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.logo-card-actions {
display: flex;
gap: 4px;
}
/* Toast */
.toast-container {
position: fixed;
top: 24px;
right: 24px;
z-index: 2000;
display: flex;
flex-direction: column;
gap: 12px;
left: 24px;
}
@media (min-width: 480px) {
.toast-container {
left: auto;
}
}
.toast {
background: var(--bg-secondary);
border-radius: var(--radius-md);
padding: 16px 20px;
box-shadow: var(--shadow-lg);
border: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (min-width: 480px) {
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
}
.toast-icon {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.toast.success .toast-icon {
background: var(--success-bg);
color: var(--success);
}
.toast.error .toast-icon {
background: var(--error-bg);
color: var(--error);
}
.toast-message {
flex: 1;
font-size: 14px;
font-weight: 500;
}
.toast-close {
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary);
padding: 4px;
transition: var(--transition);
}
.toast-close:hover {
color: var(--text-primary);
}
/* Empty State */
.empty-state {
text-align: center;
padding: 40px 24px;
}
@media (min-width: 768px) {
.empty-state {
padding: 60px 24px;
}
}
.empty-icon {
width: 72px;
height: 72px;
background: var(--purple-lighter);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
color: var(--purple-primary);
}
.empty-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.empty-text {
font-size: 14px;
color: var(--text-secondary);
max-width: 400px;
margin: 0 auto 24px;
}
/* Loading */
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 60px;
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid var(--purple-lighter);
border-top-color: var(--purple-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Confirm Delete */
.confirm-delete-text {
font-size: 15px;
line-height: 1.6;
color: var(--text-primary);
}
.confirm-delete-prefix {
font-family: 'SF Mono', monospace;
background: var(--error-bg);
padding: 2px 8px;
border-radius: 4px;
color: var(--error);
font-weight: 600;
}
/* Footer */
.footer {
text-align: center;
padding: 24px;
color: var(--text-secondary);
font-size: 13px;
}
.footer a {
color: var(--purple-primary);
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Responsive table cells */
.cell-truncate {
max-width: 150px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 767px) {
.hide-mobile {
display: none !important;
}
}
</style>
</head>
<body>
<header class="header">
<div class="header-content">
<div class="logo">
<div class="logo-icon">
<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>
</div>
<div class="logo-text">
<span class="logo-title">Geofeed Manager</span>
<span class="logo-subtitle">PURPLE COMPUTING</span>
</div>
</div>
<div class="header-actions">
<button class="btn btn-white btn-sm" onclick="exportCSV()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<span class="hide-mobile">Export</span>
</button>
<button class="btn btn-white btn-sm" onclick="openModal()">
<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>
<span class="hide-mobile">Add Entry</span>
</button>
<button class="btn btn-white btn-sm" onclick="logout()" title="Logout">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
<span class="hide-mobile">Logout</span>
</button>
</div>
</div>
</header>
<main class="main">
<!-- Tabs -->
<div class="tabs">
<button class="tab active" onclick="switchTab('entries')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
Entries
</button>
<button class="tab" onclick="switchTab('advanced')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
Advanced
</button>
<button class="tab" onclick="switchTab('ptr')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
PTR Records
</button>
<button class="tab" onclick="switchTab('developer')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="16 18 22 12 16 6"/>
<polyline points="8 6 2 12 8 18"/>
</svg>
Developer
</button>
</div>
<!-- Entries Tab -->
<div class="tab-content active" id="tab-entries">
<!-- Stats -->
<div class="stats-grid" id="statsGrid">
<div class="stat-card">
<div class="stat-label">Total Entries</div>
<div class="stat-value" id="statTotal">-</div>
</div>
<div class="stat-card">
<div class="stat-label">IPv4 Prefixes</div>
<div class="stat-value" id="statIPv4">-</div>
</div>
<div class="stat-card">
<div class="stat-label">IPv6 Prefixes</div>
<div class="stat-value" id="statIPv6">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Countries</div>
<div class="stat-value" id="statCountries">-</div>
</div>
</div>
<!-- Search -->
<div class="search-container">
<div class="search-bar">
<svg class="search-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input type="text" class="search-input" id="searchInput" placeholder="Search IP prefixes, cities, regions..." oninput="debounceSearch()">
</div>
</div>
<!-- Table -->
<div class="table-container">
<div class="table-header">
<h2 class="table-title">Geofeed Entries</h2>
<div class="table-actions">
<button class="btn btn-ghost btn-sm" onclick="loadEntries()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
Refresh
</button>
</div>
</div>
<div id="tableContent">
<div class="loading">
<div class="spinner"></div>
</div>
</div>
</div>
</div>
<!-- Advanced Tab -->
<div class="tab-content" id="tab-advanced">
<!-- Audit Log Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: inline; vertical-align: middle; margin-right: 8px;">
<path d="M12 20h9"/>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
</svg>
Audit Log
</h2>
<p class="advanced-section-desc">View all changes made to geofeed entries including creates, updates, and deletes.</p>
<div class="table-container" style="margin-top: 16px;">
<div class="audit-log-container" id="auditLogContainer">
<div class="loading">
<div class="spinner"></div>
</div>
</div>
<div class="pagination" id="auditPagination" style="display: none;"></div>
</div>
</div>
<!-- Webhook Settings Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: inline; vertical-align: middle; margin-right: 8px;">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
n8n Webhook Integration
</h2>
<p class="advanced-section-desc">Configure webhooks to notify n8n when geofeed data changes. Updates are debounced to batch multiple changes and reduce API calls.</p>
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="webhookEnabled" style="margin-right: 8px; vertical-align: middle;">
Enable Webhook Notifications
</label>
</div>
<div class="form-group">
<label class="form-label">Webhook URL</label>
<input type="url" class="form-input" id="webhookUrl" placeholder="https://your-n8n-instance.com/webhook/xxx">
<div class="form-hint">The n8n webhook URL to receive notifications</div>
</div>
<div class="form-group">
<label class="form-label">Debounce Delay (minutes)</label>
<input type="number" class="form-input" id="webhookDelay" min="1" max="60" value="3" style="max-width: 120px;">
<div class="form-hint">Wait this many minutes after the last change before triggering the webhook (1-60 minutes)</div>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 20px;">
<button class="btn btn-primary" onclick="saveWebhookSettings()">
<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>
Save Settings
</button>
<button class="btn btn-secondary" onclick="testWebhook()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"/>
</svg>
Test Connection
</button>
<button class="btn btn-secondary" onclick="triggerWebhookNow()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
Trigger Now
</button>
</div>
<!-- Webhook Queue Status -->
<div class="table-container" style="margin-top: 16px;">
<div class="table-header">
<h3 class="table-title">Webhook Queue</h3>
<div class="table-actions">
<button class="btn btn-ghost btn-sm" onclick="loadWebhookQueueStatus()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
Refresh
</button>
</div>
</div>
<div id="webhookQueueContainer" style="padding: 20px;">
<div class="loading">
<div class="spinner"></div>
</div>
</div>
</div>
</div>
<!-- IP Registry Settings Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: inline; vertical-align: middle; margin-right: 8px;">
<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>
IP Registry Integration
</h2>
<p class="advanced-section-desc">Enrich IP entries with ISP, organization, and security flag data from <a href="https://ipregistry.co" target="_blank" rel="noopener" style="color: var(--purple-primary);">ipregistry.co</a>. When enabled, new IPs are automatically enriched on creation.</p>
<div class="form-group">
<label class="form-label">
<input type="checkbox" id="ipRegistryEnabled" style="margin-right: 8px; vertical-align: middle;">
Enable IP Registry Auto-Enrichment
</label>
</div>
<div class="form-group">
<label class="form-label">API Key</label>
<input type="password" class="form-input" id="ipRegistryApiKey" placeholder="Enter your ipregistry.co API key">
<div class="form-hint">Get your API key from <a href="https://ipregistry.co" target="_blank" rel="noopener" style="color: var(--purple-primary);">ipregistry.co</a>. Leave blank to use environment variable.</div>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button class="btn btn-primary" onclick="saveIpRegistrySettings()">
<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>
Save Settings
</button>
<button class="btn btn-secondary" onclick="enrichAllIps(this)">
<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>
Enrich All Un-enriched IPs
</button>
</div>
</div>
<!-- Client Logos Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display: inline; vertical-align: middle; margin-right: 8px;">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
Client Logos
</h2>
<p class="advanced-section-desc">Manage logo images for client shortnames. Logos will appear in the entries table as 1:1 square icons.</p>
<div class="form-row" style="margin-bottom: 16px;">
<div class="form-group" style="margin-bottom: 0;">
<label class="form-label">Client Short Name</label>
<select class="form-select" id="logoShortName">
<option value="">Select or type a shortname...</option>
</select>
</div>
<div class="form-group" style="margin-bottom: 0;">
<label class="form-label">Logo URL (PNG)</label>
<input type="url" class="form-input" id="logoUrl" placeholder="https://example.com/logo.png">
</div>
</div>
<button class="btn btn-primary" onclick="saveLogo()">
<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>
Save Logo
</button>
<div class="logo-grid" id="logoGrid">
<!-- Logos will be loaded here -->
</div>
</div>
<!-- Import Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">Import Geofeed Data</h2>
<p class="advanced-section-desc">Import geofeed entries from a CSV file or a remote URL. The data should follow RFC 8805 format: <code>ip_prefix,country_code,region_code,city,postal_code</code></p>
<div class="import-options">
<!-- File Upload -->
<div class="import-card">
<div class="import-card-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
</div>
<h3 class="import-card-title">Upload CSV File</h3>
<p class="import-card-desc">Upload a geofeed CSV file from your computer</p>
<div class="file-input-wrapper">
<input type="file" class="file-input" id="csvFileInput" accept=".csv,.txt" onchange="handleFileSelect(this)">
<label class="file-input-label" for="csvFileInput">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Choose file or drag here
</label>
</div>
<div class="file-name" id="fileName"></div>
<div class="progress-container" id="fileProgress">
<div class="progress-bar">
<div class="progress-fill" id="fileProgressFill"></div>
</div>
<div class="progress-text" id="fileProgressText">Processing...</div>
</div>
<button class="btn btn-primary" style="width: 100%; margin-top: 16px;" id="uploadBtn" onclick="importFromFile()" disabled>
Import from File
</button>
<div class="import-results" id="fileResults">
<div class="import-results-title">
<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>
Import Complete
</div>
<div class="import-results-text" id="fileResultsText"></div>
</div>
</div>
<!-- URL Import -->
<div class="import-card">
<div class="import-card-icon">
<svg width="24" height="24" 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="import-card-title">Import from URL</h3>
<p class="import-card-desc">Fetch and import a geofeed from a remote URL</p>
<div class="form-group" style="margin-bottom: 0;">
<input type="url" class="form-input" id="importUrl" placeholder="https://example.com/geofeed.csv" value="https://store.prpl.uk/geofeed.csv">
</div>
<div class="progress-container" id="urlProgress">
<div class="progress-bar">
<div class="progress-fill" id="urlProgressFill"></div>
</div>
<div class="progress-text" id="urlProgressText">Fetching...</div>
</div>
<button class="btn btn-primary" style="width: 100%; margin-top: 16px;" id="urlImportBtn" onclick="importFromUrl()">
Import from URL
</button>
<div class="import-results" id="urlResults">
<div class="import-results-title">
<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>
Import Complete
</div>
<div class="import-results-text" id="urlResultsText"></div>
</div>
</div>
</div>
</div>
<!-- AWS Route53 Settings Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
AWS Route53 Settings
</h2>
<p class="advanced-section-desc">Configure AWS credentials and hosted zones for PTR record management.</p>
<div class="form-grid" style="margin-top: 16px;">
<div class="form-group">
<label class="form-label">AWS Access Key ID</label>
<input type="text" class="form-input" id="awsAccessKeyId" placeholder="AKIAIOSFODNN7EXAMPLE">
</div>
<div class="form-group">
<label class="form-label">AWS Secret Access Key</label>
<input type="password" class="form-input" id="awsSecretAccessKey" placeholder="••••••••••••••••">
</div>
</div>
<div class="form-group">
<label class="form-label">AWS Region</label>
<select class="form-select" id="awsRegion">
<option value="us-east-1">US East (N. Virginia)</option>
<option value="us-east-2">US East (Ohio)</option>
<option value="us-west-1">US West (N. California)</option>
<option value="us-west-2">US West (Oregon)</option>
<option value="eu-west-1">EU (Ireland)</option>
<option value="eu-west-2">EU (London)</option>
<option value="eu-central-1">EU (Frankfurt)</option>
<option value="ap-southeast-1">Asia Pacific (Singapore)</option>
<option value="ap-southeast-2">Asia Pacific (Sydney)</option>
<option value="ap-northeast-1">Asia Pacific (Tokyo)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Route53 Hosted Zone IDs (comma separated)</label>
<input type="text" class="form-input" id="awsHostedZones" placeholder="Z1234567890ABC, Z0987654321DEF">
<small style="color: var(--text-tertiary); font-size: 12px; margin-top: 4px; display: block;">Enter the hosted zone IDs for your forward DNS zones (A records)</small>
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 16px;">
<button class="btn btn-primary" onclick="saveAwsSettings()">
<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>
Save AWS Settings
</button>
<button class="btn btn-secondary" onclick="testAwsConnection()">
<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>
Test Connection
</button>
</div>
<div id="awsTestResult" style="margin-top: 16px; display: none;"></div>
</div>
<div class="advanced-section">
<h2 class="advanced-section-title">Danger Zone</h2>
<p class="advanced-section-desc">Irreversible actions - please proceed with caution.</p>
<button class="btn btn-danger" onclick="confirmClearAll()">
<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>
Clear All Entries
</button>
</div>
</div>
<!-- PTR Records Tab -->
<div class="tab-content" id="tab-ptr">
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" 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>
PTR Record Management
</h2>
<p class="advanced-section-desc">View A records from your Route53 hosted zones and check their PTR records at IPXO.</p>
<div id="ptrNotConfigured" style="display: none;">
<div class="alert alert-warning">
<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>AWS Route53 is not configured. Please configure your AWS credentials and hosted zones in the <a href="#" onclick="switchTab('advanced'); return false;" style="color: var(--purple-primary);">Advanced tab</a>.</span>
</div>
</div>
<div id="ptrConfigured">
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; align-items: center;">
<div class="form-group" style="margin-bottom: 0; flex: 1; min-width: 200px;">
<label class="form-label">Select Hosted Zone</label>
<select class="form-select" id="ptrZoneSelect" onchange="loadPtrRecords()">
<option value="">Select a zone...</option>
</select>
</div>
<button class="btn btn-primary" onclick="loadPtrRecords()" style="margin-top: 24px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
Refresh
</button>
<button class="btn btn-secondary" onclick="checkAllPtrs()" style="margin-top: 24px;" id="checkAllPtrsBtn" disabled>
<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
</button>
</div>
<div id="ptrRecordsContainer">
<div class="table-container">
<div class="table-header">
<h3 class="table-title">A Records</h3>
<span id="ptrRecordCount" style="color: var(--text-secondary); font-size: 13px;"></span>
</div>
<div class="table-scroll">
<table style="min-width: 900px;">
<thead>
<tr>
<th style="min-width: 250px;">Hostname</th>
<th style="width: 140px;">IP Address</th>
<th style="width: 80px;">TTL</th>
<th style="min-width: 250px;">Current PTR</th>
<th style="width: 100px;">PTR Status</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody id="ptrRecordsBody">
<tr>
<td colspan="6" style="text-align: center; color: var(--text-tertiary); padding: 40px;">
Select a hosted zone to view A records
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- PTR Legend -->
<div class="advanced-section">
<h2 class="advanced-section-title">PTR Status Legend</h2>
<div style="display: flex; gap: 24px; flex-wrap: wrap; margin-top: 12px;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; background: var(--success); color: white;">MATCH</span>
<span style="font-size: 13px; color: var(--text-secondary);">PTR matches hostname</span>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<span style="display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; background: var(--warning); color: white;">MISMATCH</span>
<span style="font-size: 13px; color: var(--text-secondary);">PTR exists but doesn't match</span>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<span style="display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; background: var(--error); color: white;">MISSING</span>
<span style="font-size: 13px; color: var(--text-secondary);">No PTR record found</span>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<span style="display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; background: var(--text-tertiary); color: white;">UNKNOWN</span>
<span style="font-size: 13px; color: var(--text-secondary);">Not checked yet</span>
</div>
</div>
</div>
</div>
<!-- Developer Tab -->
<div class="tab-content" id="tab-developer">
<!-- Database Backup Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Database Backup
</h2>
<p class="advanced-section-desc">Export a full backup of the database including all entries, settings, and audit logs as a JSON file.</p>
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 16px;">
<button class="btn btn-primary" onclick="downloadDatabaseBackup()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Download Full Backup
</button>
</div>
</div>
<!-- Database Import Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Database Import
</h2>
<p class="advanced-section-desc">Restore the database from a previously exported backup file. This will replace all existing data.</p>
<div style="margin-top: 16px;">
<div class="form-group">
<label class="form-label">Select Backup File (JSON)</label>
<input type="file" id="dbImportFile" accept=".json" class="form-input" style="padding: 8px;">
</div>
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 12px;">
<button class="btn btn-warning" onclick="importDatabaseBackup()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
Import Backup
</button>
</div>
</div>
<div class="alert alert-warning" style="margin-top: 16px;">
<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>Warning: Importing a backup will permanently replace all existing data. Make sure to download a backup first.</span>
</div>
</div>
<!-- Schema Sync Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<ellipse cx="12" cy="5" rx="9" ry="3"/>
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/>
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
</svg>
Database Schema Sync
</h2>
<p class="advanced-section-desc">Check for and apply missing database columns/tables from the latest schema in the repository.</p>
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 16px; align-items: center;">
<button class="btn btn-secondary" onclick="checkSchemaUpdates()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/>
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
Check for Updates
</button>
<button class="btn btn-warning" onclick="applySchemaUpdates()" id="applySchemaBtn" disabled>
<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>
Apply Schema Updates
</button>
</div>
<div id="schemaCheckResult" style="margin-top: 16px; display: none;">
</div>
</div>
<!-- Debug Info Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" 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>
System Information
</h2>
<p class="advanced-section-desc">Current system and database information for debugging purposes.</p>
<div id="systemInfoContent" style="margin-top: 16px;">
<div class="loading"><div class="spinner"></div></div>
</div>
</div>
<!-- PHP Error Logs Section -->
<div class="advanced-section">
<h2 class="advanced-section-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
PHP Error Logs
</h2>
<p class="advanced-section-desc">Recent PHP error log entries for debugging application issues.</p>
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 16px; align-items: center;">
<button class="btn btn-secondary" onclick="loadErrorLogs()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
Refresh Logs
</button>
<button class="btn btn-danger" onclick="clearErrorLogs()">
<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>
Clear Logs
</button>
<select id="errorLogLines" class="form-input" style="width: auto;" onchange="loadErrorLogs()">
<option value="50">Last 50 lines</option>
<option value="100" selected>Last 100 lines</option>
<option value="250">Last 250 lines</option>
<option value="500">Last 500 lines</option>
</select>
</div>
<div id="errorLogsContent" style="margin-top: 16px;">
<div class="loading"><div class="spinner"></div></div>
</div>
</div>
</div>
</main>
<footer class="footer">
<p>Geofeed Manager &copy; 2025 <a href="https://purplecomputing.com" target="_blank">Purple Computing</a> &middot; RFC 8805 Compliant</p>
</footer>
<!-- Add/Edit Modal -->
<div class="modal-overlay" id="entryModal">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title" id="modalTitle">Add Entry</h2>
<p class="modal-subtitle">Enter the geofeed entry details below</p>
</div>
<form id="entryForm" onsubmit="saveEntry(event)">
<input type="hidden" id="entryId" name="id">
<div class="modal-body">
<div class="form-group">
<label class="form-label">IP Prefix <span class="required">*</span></label>
<input type="text" class="form-input" id="ipPrefix" name="ip_prefix" placeholder="e.g., 192.168.1.0/24 or 2001:db8::/32" required>
<div class="form-hint">IPv4 or IPv6 address in CIDR notation</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Country Code</label>
<input type="text" class="form-input" id="countryCode" name="country_code" placeholder="e.g., GB" maxlength="2" style="text-transform: uppercase;">
<div class="form-hint">ISO 3166-1 alpha-2 code</div>
</div>
<div class="form-group">
<label class="form-label">Region Code</label>
<input type="text" class="form-input" id="regionCode" name="region_code" placeholder="e.g., GB-ENG" style="text-transform: uppercase;">
<div class="form-hint">ISO 3166-2 code</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">City</label>
<input type="text" class="form-input" id="city" name="city" placeholder="e.g., London">
</div>
<div class="form-group">
<label class="form-label">Postal Code</label>
<input type="text" class="form-input" id="postalCode" name="postal_code" placeholder="e.g., EC1A 1BB">
</div>
</div>
<div class="form-group">
<label class="form-label">Client Short Name</label>
<input type="text" class="form-input" id="clientShortName" name="client_short_name" placeholder="e.g., ACME Corp">
<div class="form-hint">Internal reference only - not exported to CSV</div>
</div>
<div class="form-group">
<label class="form-label">Notes</label>
<input type="text" class="form-input" id="notes" name="notes" placeholder="Optional notes for internal use">
</div>
<!-- IP Enrichment Section (shown only when editing) -->
<div id="enrichmentSection" style="display: none; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-color);">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
<label class="form-label" style="margin-bottom: 0;">IP Registry Enrichment</label>
<button type="button" class="btn btn-secondary btn-sm" id="reEnrichBtn" onclick="reEnrichCurrentEntry()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M23 4v6h-6M1 20v-6h6"/>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
</svg>
<span id="reEnrichBtnText">Re-enrich IP</span>
</button>
</div>
<div id="enrichmentStatus" class="form-hint" style="font-size: 12px;"></div>
<div id="enrichmentData" style="display: none; margin-top: 8px; font-size: 12px; color: var(--text-secondary);">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 4px 16px;">
<div><strong>ISP:</strong> <span id="enrichIsp">-</span></div>
<div><strong>ASN:</strong> <span id="enrichAsn">-</span></div>
<div><strong>Org:</strong> <span id="enrichOrg">-</span></div>
<div><strong>Type:</strong> <span id="enrichType">-</span></div>
</div>
<div id="enrichFlags" style="margin-top: 8px;"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
<button type="submit" class="btn btn-primary" id="saveBtn">Save Entry</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal-overlay" id="deleteModal">
<div class="modal" style="max-width: 420px;">
<div class="modal-header">
<h2 class="modal-title">Delete Entry</h2>
</div>
<div class="modal-body">
<p class="confirm-delete-text">
Are you sure you want to delete the entry for <span class="confirm-delete-prefix" id="deletePrefix"></span>? This action cannot be undone.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeDeleteModal()">Cancel</button>
<button type="button" class="btn btn-danger" onclick="confirmDelete()">Delete</button>
</div>
</div>
</div>
<!-- Clear All Confirmation Modal -->
<div class="modal-overlay" id="clearAllModal">
<div class="modal" style="max-width: 420px;">
<div class="modal-header">
<h2 class="modal-title">Clear All Entries</h2>
</div>
<div class="modal-body">
<p class="confirm-delete-text">
Are you sure you want to delete <strong>ALL</strong> geofeed entries? This action cannot be undone.
</p>
<div class="form-group" style="margin-top: 16px; margin-bottom: 0;">
<label class="form-label">Type "DELETE" to confirm</label>
<input type="text" class="form-input" id="confirmClearInput" placeholder="DELETE">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeClearAllModal()">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmClearBtn" onclick="executeClearAll()" disabled>Clear All</button>
</div>
</div>
</div>
<!-- Entry Info Modal -->
<div class="modal-overlay" id="infoModal">
<div class="modal" style="max-width: 600px;">
<div class="modal-header">
<h2 class="modal-title" id="infoModalTitle">Entry Details</h2>
<p class="modal-subtitle" id="infoModalSubtitle"></p>
</div>
<div class="modal-body" id="infoModalBody" style="max-height: 70vh; overflow-y: auto;">
<div class="loading"><div class="spinner"></div></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeInfoModal()">Close</button>
</div>
</div>
</div>
<!-- Toast Container -->
<div class="toast-container" id="toastContainer"></div>
<script>
// State
let currentPage = 1;
let totalPages = 1;
let searchTimeout = null;
let deleteEntryId = null;
let selectedFile = null;
let clientLogos = {};
let auditPage = 1;
const csrfToken = '<?php echo generateCSRFToken(); ?>';
// Initialize
document.addEventListener('DOMContentLoaded', () => {
loadEntries();
loadStats();
loadClientLogos();
// Enable clear all button when "DELETE" is typed
document.getElementById('confirmClearInput').addEventListener('input', (e) => {
document.getElementById('confirmClearBtn').disabled = e.target.value !== 'DELETE';
});
});
// Tab switching
function switchTab(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.querySelector(`.tab[onclick="switchTab('${tab}')"]`).classList.add('active');
document.getElementById(`tab-${tab}`).classList.add('active');
// Load data for advanced tab
if (tab === 'advanced') {
loadAuditLog();
loadShortnames();
loadLogosGrid();
loadWebhookSettings();
loadWebhookQueueStatus();
loadIpRegistrySettings();
loadAwsSettings();
}
// Load data for developer tab
if (tab === 'developer') {
loadSystemInfo();
loadErrorLogs();
}
// Load data for PTR tab
if (tab === 'ptr') {
loadPtrZones();
}
}
// 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: csrfToken });
}
const response = await fetch(url, options);
return response.json();
}
// 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) {
currentPage = page;
const searchQuery = document.getElementById('searchInput').value;
document.getElementById('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() {
try {
const result = await api('stats');
if (result.success) {
document.getElementById('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');
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() {
try {
const result = await api('shortnames_list');
const select = document.getElementById('logoShortName');
// 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 logos grid
async function loadLogosGrid() {
const grid = document.getElementById('logoGrid');
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');
}
}
// Load webhook queue status
async function loadWebhookQueueStatus() {
const container = document.getElementById('webhookQueueContainer');
try {
const result = await api('webhook_queue_status');
if (result.success) {
renderWebhookQueueStatus(result.data);
} 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>';
}
}
// 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;
}
// 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');
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';
} else {
title.textContent = 'Add Entry';
document.getElementById('entryId').value = '';
enrichSection.style.display = 'none';
}
modal.classList.add('active');
setTimeout(() => document.getElementById('ipPrefix').focus(), 100);
}
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>
<!-- 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>
`;
} 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>`;
}
}
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';
}
// 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');
}
}
// 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');
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);
});
} 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 A records from selected zone
async function loadPtrRecords() {
const zoneId = document.getElementById('ptrZoneSelect').value;
const tbody = document.getElementById('ptrRecordsBody');
const countSpan = document.getElementById('ptrRecordCount');
const checkAllBtn = document.getElementById('checkAllPtrsBtn');
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;
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('aws_records', { zone_id: zoneId });
if (result.success) {
const records = result.records || [];
countSpan.textContent = records.length + ' record(s)';
checkAllBtn.disabled = records.length === 0;
if (records.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="6" style="text-align: center; color: var(--text-tertiary); padding: 40px;">
No A records found in this zone
</td>
</tr>
`;
return;
}
tbody.innerHTML = records.map((record, index) => `
<tr data-ip="${escapeHtml(record.ip)}" data-hostname="${escapeHtml(record.hostname)}">
<td><span class="cell-truncate" title="${escapeHtml(record.hostname)}">${escapeHtml(record.hostname)}</span></td>
<td><code style="font-size: 12px;">${escapeHtml(record.ip)}</code></td>
<td style="color: var(--text-secondary);">${record.ttl || '-'}</td>
<td class="ptr-value" style="color: var(--text-tertiary);">-</td>
<td class="ptr-status">
<span style="display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; background: var(--text-tertiary); color: white;">UNKNOWN</span>
</td>
<td>
<button class="btn btn-secondary btn-sm" onclick="checkPtrRecord('${escapeHtml(record.ip)}', '${escapeHtml(record.hostname)}', 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>
Check
</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;
}
} 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 = '';
checkAllBtn.disabled = true;
}
}
// Check single PTR record
async function checkPtrRecord(ip, expectedHostname, button) {
const row = button.closest('tr');
const ptrValueCell = row.querySelector('.ptr-value');
const ptrStatusCell = row.querySelector('.ptr-status');
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)';
// Normalize hostnames for comparison (remove trailing dot, lowercase)
const normalizedPtr = ptr.toLowerCase().replace(/\.$/, '');
const normalizedExpected = expectedHostname.toLowerCase().replace(/\.$/, '');
let status, statusColor;
if (!ptr) {
status = 'MISSING';
statusColor = 'var(--error)';
} else if (normalizedPtr === normalizedExpected) {
status = 'MATCH';
statusColor = 'var(--success)';
} else {
status = 'MISMATCH';
statusColor = 'var(--warning)';
}
ptrStatusCell.innerHTML = `<span style="display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; background: ${statusColor}; color: white;">${status}</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
async function checkAllPtrs() {
const rows = document.querySelectorAll('#ptrRecordsBody tr[data-ip]');
const checkAllBtn = document.getElementById('checkAllPtrsBtn');
if (rows.length === 0) {
showToast('No records to check', 'warning');
return;
}
checkAllBtn.disabled = true;
checkAllBtn.innerHTML = '<div class="spinner" style="width: 14px; height: 14px;"></div> Checking...';
let checked = 0;
for (const row of rows) {
const ip = row.getAttribute('data-ip');
const hostname = row.getAttribute('data-hostname');
const button = row.querySelector('button');
await checkPtrRecord(ip, hostname, button);
checked++;
// Small delay to avoid overwhelming the server
await new Promise(resolve => setTimeout(resolve, 100));
}
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
`;
showToast(`Checked ${checked} PTR record(s)`, 'success');
}
// 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();
});
</script>
</body>
</html>