5506 lines
256 KiB
PHP
5506 lines
256 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>ISP IP Manager</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;
|
|
}
|
|
|
|
/* Settings Dropdown */
|
|
.settings-dropdown {
|
|
position: relative;
|
|
display: inline-block;
|
|
}
|
|
|
|
.settings-btn {
|
|
background: rgba(255, 255, 255, 0.15);
|
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
|
color: white;
|
|
padding: 8px;
|
|
border-radius: var(--radius-md);
|
|
cursor: pointer;
|
|
transition: var(--transition);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.settings-btn:hover {
|
|
background: rgba(255, 255, 255, 0.25);
|
|
}
|
|
|
|
.settings-btn.active {
|
|
background: white;
|
|
color: var(--purple-primary);
|
|
}
|
|
|
|
.settings-menu {
|
|
position: absolute;
|
|
top: calc(100% + 8px);
|
|
right: 0;
|
|
background: var(--bg-secondary);
|
|
border-radius: var(--radius-md);
|
|
box-shadow: var(--shadow-lg);
|
|
border: 1px solid var(--border);
|
|
min-width: 200px;
|
|
z-index: 1000;
|
|
opacity: 0;
|
|
visibility: hidden;
|
|
transform: translateY(-10px);
|
|
transition: var(--transition);
|
|
}
|
|
|
|
.settings-menu.active {
|
|
opacity: 1;
|
|
visibility: visible;
|
|
transform: translateY(0);
|
|
}
|
|
|
|
.settings-menu-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 12px 16px;
|
|
color: var(--text-primary);
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: var(--transition);
|
|
border: none;
|
|
background: none;
|
|
width: 100%;
|
|
text-align: left;
|
|
}
|
|
|
|
.settings-menu-item:first-child {
|
|
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
|
}
|
|
|
|
.settings-menu-item:last-child {
|
|
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
|
}
|
|
|
|
.settings-menu-item:hover {
|
|
background: var(--bg-tertiary);
|
|
color: var(--purple-primary);
|
|
}
|
|
|
|
.settings-menu-item svg {
|
|
flex-shrink: 0;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.settings-menu-item:hover svg {
|
|
color: var(--purple-primary);
|
|
}
|
|
|
|
.settings-divider {
|
|
height: 1px;
|
|
background: var(--border);
|
|
margin: 4px 0;
|
|
}
|
|
|
|
/* 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">ISP IP Manager</span>
|
|
<span class="logo-subtitle"></span>
|
|
</div>
|
|
</div>
|
|
<div class="header-actions">
|
|
<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 class="settings-dropdown">
|
|
<button class="settings-btn" onclick="toggleSettingsMenu()" id="settingsBtn" title="Settings">
|
|
<svg width="20" height="20" 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>
|
|
</button>
|
|
<div class="settings-menu" id="settingsMenu">
|
|
<button class="settings-menu-item" onclick="switchTab('integrations'); closeSettingsMenu();">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M18 20V10"/>
|
|
<path d="M12 20V4"/>
|
|
<path d="M6 20v-6"/>
|
|
</svg>
|
|
Integrations
|
|
</button>
|
|
<button class="settings-menu-item" onclick="switchTab('audit'); closeSettingsMenu();">
|
|
<svg width="18" height="18" 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"/>
|
|
<polyline points="10 9 9 9 8 9"/>
|
|
</svg>
|
|
Audit Log
|
|
</button>
|
|
<button class="settings-menu-item" onclick="switchTab('advanced'); closeSettingsMenu();">
|
|
<svg width="18" height="18" 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="settings-menu-item" onclick="switchTab('whitelabel'); closeSettingsMenu();">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<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>
|
|
Whitelabel
|
|
</button>
|
|
<button class="settings-menu-item" onclick="switchTab('developer'); closeSettingsMenu();">
|
|
<svg width="18" height="18" 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>
|
|
</div>
|
|
</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>
|
|
Geofeed Entries
|
|
</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('audit')">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<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
|
|
</button>
|
|
<button class="tab" onclick="switchTab('integrations')">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<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>
|
|
Integrations
|
|
</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>
|
|
<button class="btn btn-secondary btn-sm" onclick="exportCSV()">
|
|
<svg width="14" height="14" 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>
|
|
Export
|
|
</button>
|
|
<button class="btn btn-primary btn-sm" onclick="openModal()">
|
|
<svg width="14" height="14" 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>
|
|
Add Entry
|
|
</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">
|
|
<!-- Settings Header -->
|
|
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 24px;">
|
|
<button class="btn btn-secondary btn-sm" onclick="switchTab('entries')">
|
|
<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>
|
|
Back to Entries
|
|
</button>
|
|
<h1 style="font-size: 20px; font-weight: 600; color: var(--text-primary);">Advanced Settings</h1>
|
|
</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>
|
|
|
|
<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. Records are cached locally for faster access.</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">
|
|
<!-- Zone selector and main action buttons -->
|
|
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; align-items: flex-end;">
|
|
<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="loadCachedPtrRecords()">
|
|
<option value="">Select a zone...</option>
|
|
</select>
|
|
</div>
|
|
<button class="btn btn-primary" onclick="refreshFromAws()" id="refreshAwsBtn" disabled title="Sync A records from AWS Route53">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
|
<path d="M3 3v5h5"/>
|
|
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
|
|
<path d="M16 21h5v-5"/>
|
|
</svg>
|
|
Sync from AWS
|
|
</button>
|
|
<button class="btn btn-secondary" onclick="checkAllPtrs()" id="checkAllPtrsBtn" disabled title="Check PTR records for all IPs">
|
|
<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>
|
|
|
|
<!-- Sync status bar -->
|
|
<div id="ptrSyncStatus" style="display: none; margin-bottom: 16px; padding: 12px 16px; background: var(--bg-tertiary); border-radius: var(--radius-md); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px;">
|
|
<div style="display: flex; gap: 16px; flex-wrap: wrap;">
|
|
<span style="font-size: 13px; color: var(--text-secondary);">
|
|
<strong>Last AWS Sync:</strong> <span id="lastAwsSync">-</span>
|
|
</span>
|
|
<span style="font-size: 13px; color: var(--text-secondary);">
|
|
<strong>Last PTR Check:</strong> <span id="lastPtrCheck">-</span>
|
|
</span>
|
|
</div>
|
|
<div id="ptrStatsBar" style="display: flex; gap: 12px; flex-wrap: wrap;">
|
|
<span style="font-size: 12px; padding: 2px 8px; border-radius: 4px; background: var(--success); color: white;" id="statMatch">0 Match</span>
|
|
<span style="font-size: 12px; padding: 2px 8px; border-radius: 4px; background: var(--warning); color: white;" id="statMismatch">0 Mismatch</span>
|
|
<span style="font-size: 12px; padding: 2px 8px; border-radius: 4px; background: var(--error); color: white;" id="statMissing">0 Missing</span>
|
|
<span style="font-size: 12px; padding: 2px 8px; border-radius: 4px; background: var(--text-tertiary); color: white;" id="statUnknown">0 Unchecked</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="ptrRecordsContainer">
|
|
<div class="table-container">
|
|
<div class="table-header">
|
|
<h3 class="table-title">A Records</h3>
|
|
<div style="display: flex; align-items: center; gap: 12px;">
|
|
<span id="ptrRecordCount" style="color: var(--text-secondary); font-size: 13px;"></span>
|
|
<button class="btn btn-secondary btn-sm" onclick="exportIpxoJson()" id="exportIpxoBtn" disabled title="Export mismatched/missing PTRs for IPXO">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
|
</svg>
|
|
Export IPXO JSON
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="table-scroll">
|
|
<table style="min-width: 1000px;">
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 140px;">IP Address</th>
|
|
<th style="min-width: 250px;">Hostname</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: 150px;">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>
|
|
|
|
<!-- Audit Log Tab -->
|
|
<div class="tab-content" id="tab-audit">
|
|
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 24px;">
|
|
<button class="btn btn-secondary btn-sm" onclick="switchTab('entries')">
|
|
<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>
|
|
Back to Entries
|
|
</button>
|
|
<h1 style="font-size: 20px; font-weight: 600; color: var(--text-primary);">Audit Log</h1>
|
|
</div>
|
|
|
|
<div class="advanced-section">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
|
<p class="advanced-section-desc" style="margin: 0;">View all changes made to geofeed entries including creates, updates, and deletes.</p>
|
|
<button class="btn btn-secondary btn-sm" onclick="exportAuditLog()">
|
|
<svg width="14" height="14" 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>
|
|
Export CSV
|
|
</button>
|
|
</div>
|
|
|
|
<div class="table-container">
|
|
<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>
|
|
</div>
|
|
|
|
<!-- Integrations Tab -->
|
|
<div class="tab-content" id="tab-integrations">
|
|
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 24px;">
|
|
<button class="btn btn-secondary btn-sm" onclick="switchTab('entries')">
|
|
<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>
|
|
Back to Entries
|
|
</button>
|
|
<h1 style="font-size: 20px; font-weight: 600; color: var(--text-primary);">Integrations</h1>
|
|
</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>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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>
|
|
</div>
|
|
|
|
<!-- Developer Tab -->
|
|
<div class="tab-content" id="tab-developer">
|
|
<!-- Developer Header -->
|
|
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 24px;">
|
|
<button class="btn btn-secondary btn-sm" onclick="switchTab('entries')">
|
|
<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>
|
|
Back to Entries
|
|
</button>
|
|
<h1 style="font-size: 20px; font-weight: 600; color: var(--text-primary);">Developer Tools</h1>
|
|
</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">
|
|
</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>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- Whitelabel Tab -->
|
|
<div class="tab-content" id="tab-whitelabel">
|
|
<!-- Whitelabel Header -->
|
|
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 24px;">
|
|
<button class="btn btn-secondary btn-sm" onclick="switchTab('entries')">
|
|
<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>
|
|
Back to Entries
|
|
</button>
|
|
<h1 style="font-size: 20px; font-weight: 600; color: var(--text-primary);">Whitelabel Settings</h1>
|
|
</div>
|
|
|
|
<!-- Whitelabel 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>
|
|
Branding
|
|
</h2>
|
|
<p class="advanced-section-desc">Customize the appearance of the application with your company branding.</p>
|
|
|
|
<div class="form-grid" style="margin-top: 16px;">
|
|
<div class="form-group">
|
|
<label class="form-label">App Name</label>
|
|
<input type="text" class="form-input" id="whitelabelAppName" placeholder="ISP IP Manager">
|
|
<div class="form-hint">Displayed in the header and browser tab title</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Company Name</label>
|
|
<input type="text" class="form-input" id="whitelabelCompanyName" placeholder="Your Company">
|
|
<div class="form-hint">Displayed as subtitle in the header</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Webapp Icon URL</label>
|
|
<input type="text" class="form-input" id="whitelabelIconUrl" placeholder="https://example.com/logo.svg">
|
|
<div class="form-hint">URL to an SVG or PNG logo for the header (recommended: 40x40px)</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Favicon URL</label>
|
|
<input type="text" class="form-input" id="whitelabelFaviconUrl" placeholder="https://example.com/favicon.ico">
|
|
<div class="form-hint">URL to a favicon (.ico, .png, or .svg)</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">Default Geofeed Import URL</label>
|
|
<input type="text" class="form-input" id="whitelabelDefaultImportUrl" placeholder="https://example.com/geofeed.csv">
|
|
<div class="form-hint">Pre-populated URL when importing from URL in Developer tab</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display: flex; gap: 12px; margin-top: 16px;">
|
|
<button class="btn btn-primary" onclick="saveWhitelabelSettings()">
|
|
<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>
|
|
</div>
|
|
|
|
<!-- Preview Section -->
|
|
<div style="margin-top: 24px; padding: 16px; background: var(--bg-tertiary); border-radius: var(--radius-md);">
|
|
<h3 style="font-size: 14px; font-weight: 600; color: var(--text-secondary); margin-bottom: 12px;">Preview</h3>
|
|
<div style="display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--bg-secondary); border-radius: var(--radius-md);">
|
|
<div id="whitelabelPreviewIcon" style="width: 40px; height: 40px; background: var(--primary); border-radius: 8px; display: flex; align-items: center; justify-content: center; overflow: hidden;">
|
|
<svg viewBox="0 0 258 258" fill="none" width="24" height="24">
|
|
<path fill="white" d="M241.13 56.2A26.53 26.53 0 11188.07 56.2a26.53 26.53 0 0153.06 0zm-5.34-.05a21.19 21.19 0 10-42.38 0 21.19 21.19 0 0042.38 0z" transform="translate(-30,-5) scale(0.75)"/>
|
|
<path fill="white" d="M21.42 37.38h55.28a.32.32 0 01.32.32v12.21a.46.46 0 00.8.3c13.2-14.73 32.09-17.47 50.68-12.7 35.19 9.03 47.69 43.89 45.07 77C170.91 148.16 150.93 173.81 115.1 175.14q-22.52.84-37.38-15.22a.65.65 0 00-1.13.47c.06 1.2.49 2.44.49 4.15q-.04 23.9.01 56.37a.42.41 0 01-.42.41H21.66a.88.88 0 01-.88-.88V38.01a.64.63 0 01.64-.63zM77.02 104.64c0 12.43 5.67 26.28 20.24 26.28s20.25-13.85 20.25-26.28-5.67-26.28-20.25-26.28-20.24 13.85-20.24 26.28z" transform="translate(30,30) scale(0.75)"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<span id="whitelabelPreviewAppName" style="font-weight: 600; color: var(--text-primary);">ISP IP Manager</span>
|
|
<div id="whitelabelPreviewCompanyName" style="font-size: 11px; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px;"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<footer class="footer">
|
|
<p>ISP IP Manager © 2025 <a href="https://purplecomputing.com" target="_blank">Purple Computing</a> · 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>
|
|
|
|
<!-- Route53 A Records Section (shown only when editing) -->
|
|
<div id="route53Section" 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;">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 4px;">
|
|
<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>
|
|
Route53 A Records
|
|
</label>
|
|
<button type="button" class="btn btn-ghost btn-sm" onclick="switchTab('ptr')" style="font-size: 11px;">
|
|
View All
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
|
<polyline points="15 3 21 3 21 9"/>
|
|
<line x1="10" y1="14" x2="21" y2="3"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div id="route53Content" style="font-size: 12px; color: var(--text-secondary);">
|
|
<div style="color: var(--text-tertiary);">Loading...</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>
|
|
|
|
<!-- Edit A Record Modal -->
|
|
<div class="modal-overlay" id="editARecordModal">
|
|
<div class="modal" style="max-width: 500px;">
|
|
<div class="modal-header">
|
|
<h2 class="modal-title">Edit A Record</h2>
|
|
<p class="modal-subtitle">Update the A record in Route53</p>
|
|
</div>
|
|
<form id="editARecordForm" onsubmit="saveARecord(event)">
|
|
<input type="hidden" id="editARecordId">
|
|
<input type="hidden" id="editARecordOldHostname">
|
|
<div class="modal-body">
|
|
<div class="form-group">
|
|
<label class="form-label">Hostname <span class="required">*</span></label>
|
|
<input type="text" class="form-input" id="editARecordHostname" required>
|
|
<div class="form-hint">The DNS hostname (e.g., server1.example.com)</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">IP Address</label>
|
|
<input type="text" class="form-input" id="editARecordIp" readonly style="background: var(--bg-tertiary); cursor: not-allowed;">
|
|
<div class="form-hint">IP address cannot be changed</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">TTL (seconds)</label>
|
|
<input type="number" class="form-input" id="editARecordTtl" min="60" max="86400" value="3600">
|
|
<div class="form-hint">Time to live (60-86400 seconds)</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" onclick="closeEditARecordModal()">Cancel</button>
|
|
<button type="submit" class="btn btn-primary" id="saveARecordBtn">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
|
<polyline points="17 21 17 13 7 13 7 21"/>
|
|
<polyline points="7 3 7 8 15 8"/>
|
|
</svg>
|
|
Update A Record
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</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();
|
|
loadAndApplyWhitelabel();
|
|
|
|
// Enable clear all button when "DELETE" is typed
|
|
document.getElementById('confirmClearInput').addEventListener('input', (e) => {
|
|
document.getElementById('confirmClearBtn').disabled = e.target.value !== 'DELETE';
|
|
});
|
|
});
|
|
|
|
// Load and apply whitelabel settings on page load
|
|
async function loadAndApplyWhitelabel() {
|
|
try {
|
|
const result = await api('whitelabel_get');
|
|
if (result.success && result.settings) {
|
|
applyWhitelabelSettings(result.settings);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load whitelabel settings:', error);
|
|
}
|
|
}
|
|
|
|
// Tab switching
|
|
function switchTab(tab) {
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
|
|
// Try to find and activate the corresponding tab button (may not exist for settings tabs)
|
|
const tabButton = document.querySelector(`.tab[onclick="switchTab('${tab}')"]`);
|
|
if (tabButton) {
|
|
tabButton.classList.add('active');
|
|
}
|
|
|
|
document.getElementById(`tab-${tab}`).classList.add('active');
|
|
|
|
// Load data for advanced tab
|
|
if (tab === 'advanced') {
|
|
loadShortnames();
|
|
loadLogosGrid();
|
|
}
|
|
|
|
// Load data for integrations tab
|
|
if (tab === 'integrations') {
|
|
loadWebhookSettings();
|
|
loadWebhookQueueStatus();
|
|
loadIpRegistrySettings();
|
|
loadAwsSettings();
|
|
}
|
|
|
|
// Load data for audit tab
|
|
if (tab === 'audit') {
|
|
loadAuditLog();
|
|
}
|
|
|
|
// Load data for developer tab
|
|
if (tab === 'developer') {
|
|
loadSystemInfo();
|
|
loadErrorLogs();
|
|
}
|
|
|
|
// Load data for PTR tab
|
|
if (tab === 'ptr') {
|
|
loadPtrZones();
|
|
}
|
|
|
|
// Load data for whitelabel tab
|
|
if (tab === 'whitelabel') {
|
|
loadWhitelabelSettings();
|
|
}
|
|
}
|
|
|
|
// Settings menu functions
|
|
function toggleSettingsMenu() {
|
|
const menu = document.getElementById('settingsMenu');
|
|
const btn = document.getElementById('settingsBtn');
|
|
menu.classList.toggle('active');
|
|
btn.classList.toggle('active');
|
|
}
|
|
|
|
function closeSettingsMenu() {
|
|
const menu = document.getElementById('settingsMenu');
|
|
const btn = document.getElementById('settingsBtn');
|
|
menu.classList.remove('active');
|
|
btn.classList.remove('active');
|
|
}
|
|
|
|
// Close settings menu when clicking outside
|
|
document.addEventListener('click', function(e) {
|
|
const dropdown = document.querySelector('.settings-dropdown');
|
|
if (dropdown && !dropdown.contains(e.target)) {
|
|
closeSettingsMenu();
|
|
}
|
|
});
|
|
|
|
// API Helper
|
|
async function api(action, params = {}, method = 'GET', body = null) {
|
|
const url = new URL('api.php', window.location.href);
|
|
url.searchParams.set('action', action);
|
|
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
|
|
|
|
const options = { method };
|
|
if (body) {
|
|
options.headers = { 'Content-Type': 'application/json' };
|
|
options.body = JSON.stringify({ ...body, csrf_token: csrfToken });
|
|
}
|
|
|
|
const response = await fetch(url, options);
|
|
const text = await response.text();
|
|
|
|
if (!text || text.trim() === '') {
|
|
return { success: false, error: 'Empty response from server' };
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch (e) {
|
|
console.error('API response parse error:', text);
|
|
return { success: false, error: 'Invalid JSON response: ' + text.substring(0, 200) };
|
|
}
|
|
}
|
|
|
|
// Load client logos
|
|
async function loadClientLogos() {
|
|
try {
|
|
const result = await api('logos_list');
|
|
if (result.success) {
|
|
clientLogos = {};
|
|
result.data.forEach(logo => {
|
|
clientLogos[logo.short_name] = logo.logo_url;
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load logos:', error);
|
|
}
|
|
}
|
|
|
|
// Load entries
|
|
async function loadEntries(page = 1) {
|
|
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');
|
|
const route53Section = document.getElementById('route53Section');
|
|
const route53Content = document.getElementById('route53Content');
|
|
|
|
form.reset();
|
|
|
|
if (entry) {
|
|
title.textContent = 'Edit Entry';
|
|
document.getElementById('entryId').value = entry.id;
|
|
document.getElementById('ipPrefix').value = entry.ip_prefix || '';
|
|
document.getElementById('countryCode').value = entry.country_code || '';
|
|
document.getElementById('regionCode').value = entry.region_code || '';
|
|
document.getElementById('city').value = entry.city || '';
|
|
document.getElementById('postalCode').value = entry.postal_code || '';
|
|
document.getElementById('clientShortName').value = entry.client_short_name || '';
|
|
document.getElementById('notes').value = entry.notes || '';
|
|
|
|
// Show enrichment section when editing
|
|
enrichSection.style.display = 'block';
|
|
|
|
if (entry.ipr_enriched_at) {
|
|
const enrichedDate = new Date(entry.ipr_enriched_at).toLocaleString();
|
|
enrichStatus.innerHTML = `<span style="color: var(--success);">Enriched on ${enrichedDate}</span>`;
|
|
enrichData.style.display = 'block';
|
|
|
|
// Populate enrichment data
|
|
document.getElementById('enrichIsp').textContent = entry.ipr_isp || '-';
|
|
document.getElementById('enrichAsn').textContent = entry.ipr_asn ? `AS${entry.ipr_asn}` : '-';
|
|
document.getElementById('enrichOrg').textContent = entry.ipr_org || '-';
|
|
document.getElementById('enrichType').textContent = entry.ipr_connection_type || '-';
|
|
|
|
// Show active flags
|
|
const flags = [];
|
|
if (entry.flag_abuser == 1) flags.push('Abuser');
|
|
if (entry.flag_attacker == 1) flags.push('Attacker');
|
|
if (entry.flag_bogon == 1) flags.push('Bogon');
|
|
if (entry.flag_cloud_provider == 1) flags.push('Cloud');
|
|
if (entry.flag_proxy == 1) flags.push('Proxy');
|
|
if (entry.flag_relay == 1) flags.push('Relay');
|
|
if (entry.flag_tor == 1) flags.push('Tor');
|
|
if (entry.flag_tor_exit == 1) flags.push('Tor Exit');
|
|
if (entry.flag_vpn == 1) flags.push('VPN');
|
|
if (entry.flag_anonymous == 1) flags.push('Anonymous');
|
|
if (entry.flag_threat == 1) flags.push('Threat');
|
|
|
|
const flagsEl = document.getElementById('enrichFlags');
|
|
if (flags.length > 0) {
|
|
flagsEl.innerHTML = '<strong>Flags:</strong> ' + flags.map(f => `<span style="background: var(--danger); color: white; padding: 1px 6px; border-radius: 4px; font-size: 11px; margin-left: 4px;">${f}</span>`).join('');
|
|
} else {
|
|
flagsEl.innerHTML = '<strong>Flags:</strong> <span style="color: var(--success);">None</span>';
|
|
}
|
|
} else {
|
|
enrichStatus.innerHTML = '<span style="color: var(--text-tertiary);">Not enriched yet</span>';
|
|
enrichData.style.display = 'none';
|
|
}
|
|
|
|
// Reset button state
|
|
document.getElementById('reEnrichBtn').disabled = false;
|
|
document.getElementById('reEnrichBtnText').textContent = 'Re-enrich IP';
|
|
|
|
// Show Route53 section and load records
|
|
route53Section.style.display = 'block';
|
|
route53Content.innerHTML = '<div style="color: var(--text-tertiary);">Loading...</div>';
|
|
loadRoute53RecordsForEdit(entry.ip_prefix);
|
|
} else {
|
|
title.textContent = 'Add Entry';
|
|
document.getElementById('entryId').value = '';
|
|
enrichSection.style.display = 'none';
|
|
route53Section.style.display = 'none';
|
|
}
|
|
|
|
modal.classList.add('active');
|
|
setTimeout(() => document.getElementById('ipPrefix').focus(), 100);
|
|
}
|
|
|
|
// Load Route53 records for edit modal
|
|
async function loadRoute53RecordsForEdit(ipPrefix) {
|
|
const container = document.getElementById('route53Content');
|
|
if (!container) return;
|
|
|
|
try {
|
|
const result = await api('route53_records_by_prefix', { ip_prefix: ipPrefix });
|
|
|
|
if (result.success && result.records && result.records.length > 0) {
|
|
const maxDisplay = 5;
|
|
const displayRecords = result.records.slice(0, maxDisplay);
|
|
const hasMore = result.records.length > maxDisplay;
|
|
|
|
container.innerHTML = `
|
|
<div style="display: flex; flex-direction: column; gap: 6px;">
|
|
${displayRecords.map(rec => `
|
|
<div style="display: flex; align-items: center; justify-content: space-between; padding: 6px 8px; background: var(--bg-tertiary); border-radius: 4px;">
|
|
<div style="display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1;">
|
|
<code style="font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(rec.hostname)}</code>
|
|
<span style="color: var(--text-tertiary);">→</span>
|
|
<code style="font-size: 11px;">${escapeHtml(rec.ip_address)}</code>
|
|
</div>
|
|
<span class="ptr-status-badge ${rec.ptr_status}" style="font-size: 10px; margin-left: 8px;">${rec.ptr_status || 'unknown'}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
${hasMore ? `<div style="margin-top: 8px; font-size: 11px; color: var(--text-tertiary);">+ ${result.records.length - maxDisplay} more records</div>` : ''}
|
|
<div style="margin-top: 4px; font-size: 11px; color: var(--text-tertiary);">
|
|
${result.count} A record${result.count !== 1 ? 's' : ''} in this prefix
|
|
</div>
|
|
`;
|
|
} else {
|
|
container.innerHTML = `<div style="color: var(--text-tertiary); font-style: italic;">No Route53 A records found for this prefix.</div>`;
|
|
}
|
|
} catch (error) {
|
|
container.innerHTML = `<div style="color: var(--text-tertiary); font-style: italic;">Could not load Route53 records.</div>`;
|
|
}
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('entryModal').classList.remove('active');
|
|
}
|
|
|
|
// Edit entry
|
|
async function editEntry(id) {
|
|
try {
|
|
const result = await api('get', { id });
|
|
|
|
if (result.success) {
|
|
openModal(result.data);
|
|
} else {
|
|
showToast(result.error || 'Failed to load entry', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Network error', 'error');
|
|
}
|
|
}
|
|
|
|
// Save entry
|
|
async function saveEntry(event) {
|
|
event.preventDefault();
|
|
|
|
const id = document.getElementById('entryId').value;
|
|
const action = id ? 'update' : 'create';
|
|
|
|
const data = {
|
|
id: id || undefined,
|
|
ip_prefix: document.getElementById('ipPrefix').value,
|
|
country_code: document.getElementById('countryCode').value.toUpperCase(),
|
|
region_code: document.getElementById('regionCode').value.toUpperCase(),
|
|
city: document.getElementById('city').value,
|
|
postal_code: document.getElementById('postalCode').value,
|
|
client_short_name: document.getElementById('clientShortName').value,
|
|
notes: document.getElementById('notes').value
|
|
};
|
|
|
|
try {
|
|
const result = await api(action, {}, 'POST', data);
|
|
|
|
if (result.success) {
|
|
showToast(result.message || 'Entry saved successfully', 'success');
|
|
closeModal();
|
|
loadEntries(currentPage);
|
|
loadStats();
|
|
} else {
|
|
showToast(result.error || 'Failed to save entry', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Network error', 'error');
|
|
}
|
|
}
|
|
|
|
// Show entry info modal
|
|
async function showEntryInfo(id) {
|
|
const modal = document.getElementById('infoModal');
|
|
const body = document.getElementById('infoModalBody');
|
|
const title = document.getElementById('infoModalTitle');
|
|
const subtitle = document.getElementById('infoModalSubtitle');
|
|
|
|
modal.classList.add('active');
|
|
body.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
|
|
|
try {
|
|
const result = await api('get', { id: id });
|
|
|
|
if (result.success) {
|
|
const entry = result.data;
|
|
title.textContent = entry.ip_prefix;
|
|
subtitle.textContent = entry.client_short_name ? `Client: ${entry.client_short_name}` : '';
|
|
|
|
const formatDate = (dateStr) => {
|
|
if (!dateStr) return '-';
|
|
const d = new Date(dateStr);
|
|
return d.toLocaleString();
|
|
};
|
|
|
|
const flagIcon = (value) => {
|
|
return value ?
|
|
'<span style="color: var(--error); font-weight: bold;">✗</span>' :
|
|
'<span style="color: var(--success);">✓</span>';
|
|
};
|
|
|
|
const infoRow = (label, value, isCode = false) => {
|
|
const displayVal = value || '<span style="color: var(--text-tertiary);">-</span>';
|
|
return `
|
|
<tr>
|
|
<td style="font-weight: 500; width: 160px; color: var(--text-secondary);">${label}</td>
|
|
<td>${isCode ? `<code style="font-size: 12px;">${escapeHtml(value || '-')}</code>` : displayVal}</td>
|
|
</tr>
|
|
`;
|
|
};
|
|
|
|
body.innerHTML = `
|
|
<div style="display: flex; flex-direction: column; gap: 20px;">
|
|
<!-- Basic Info -->
|
|
<div>
|
|
<h4 style="margin: 0 0 12px 0; font-size: 14px; color: var(--text-primary); border-bottom: 1px solid var(--border); padding-bottom: 8px;">
|
|
Basic Information
|
|
</h4>
|
|
<table style="width: 100%; font-size: 13px; min-width: auto;">
|
|
<tbody>
|
|
${infoRow('IP Prefix', entry.ip_prefix, true)}
|
|
${infoRow('Country', entry.country_code ? `${getFlagEmoji(entry.country_code)} ${entry.country_code}` : null)}
|
|
${infoRow('Region', entry.region_code)}
|
|
${infoRow('City', entry.city)}
|
|
${infoRow('Postal Code', entry.postal_code)}
|
|
${infoRow('Client', entry.client_short_name)}
|
|
${infoRow('Notes', entry.notes)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Enrichment Data -->
|
|
<div>
|
|
<h4 style="margin: 0 0 12px 0; font-size: 14px; color: var(--text-primary); border-bottom: 1px solid var(--border); padding-bottom: 8px;">
|
|
IP Enrichment Data
|
|
${entry.ipr_enriched_at ? `<span style="font-weight: normal; font-size: 11px; color: var(--text-tertiary);"> (Last updated: ${formatDate(entry.ipr_enriched_at)})</span>` : ''}
|
|
</h4>
|
|
${!entry.ipr_enriched_at ? `
|
|
<p style="color: var(--text-tertiary); font-style: italic; margin: 0;">Not enriched yet. Click the globe icon to enrich this entry.</p>
|
|
` : `
|
|
<table style="width: 100%; font-size: 13px; min-width: auto;">
|
|
<tbody>
|
|
${infoRow('Hostname', entry.ipr_hostname, true)}
|
|
${infoRow('ISP', entry.ipr_isp)}
|
|
${infoRow('Organization', entry.ipr_org)}
|
|
${infoRow('ASN', entry.ipr_asn)}
|
|
${infoRow('ASN Domain', entry.ipr_asn_name)}
|
|
${infoRow('Connection Type', entry.ipr_connection_type)}
|
|
${infoRow('Country (IPR)', entry.ipr_country_name)}
|
|
${infoRow('Region (IPR)', entry.ipr_region_name)}
|
|
${infoRow('Timezone', entry.ipr_timezone)}
|
|
${infoRow('Coordinates', entry.ipr_latitude && entry.ipr_longitude ? `${entry.ipr_latitude}, ${entry.ipr_longitude}` : null)}
|
|
</tbody>
|
|
</table>
|
|
`}
|
|
</div>
|
|
|
|
<!-- Security Flags -->
|
|
<div>
|
|
<h4 style="margin: 0 0 12px 0; font-size: 14px; color: var(--text-primary); border-bottom: 1px solid var(--border); padding-bottom: 8px;">
|
|
Security Flags
|
|
</h4>
|
|
${!entry.ipr_enriched_at ? `
|
|
<p style="color: var(--text-tertiary); font-style: italic; margin: 0;">Security flags will be available after enrichment.</p>
|
|
` : `
|
|
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px;">
|
|
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-tertiary); border-radius: 6px;">
|
|
${flagIcon(entry.flag_vpn)}
|
|
<span style="font-size: 13px;">VPN</span>
|
|
</div>
|
|
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-tertiary); border-radius: 6px;">
|
|
${flagIcon(entry.flag_proxy)}
|
|
<span style="font-size: 13px;">Proxy</span>
|
|
</div>
|
|
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-tertiary); border-radius: 6px;">
|
|
${flagIcon(entry.flag_tor)}
|
|
<span style="font-size: 13px;">Tor</span>
|
|
</div>
|
|
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-tertiary); border-radius: 6px;">
|
|
${flagIcon(entry.flag_tor_exit)}
|
|
<span style="font-size: 13px;">Tor Exit</span>
|
|
</div>
|
|
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-tertiary); border-radius: 6px;">
|
|
${flagIcon(entry.flag_relay)}
|
|
<span style="font-size: 13px;">Relay</span>
|
|
</div>
|
|
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-tertiary); border-radius: 6px;">
|
|
${flagIcon(entry.flag_cloud_provider)}
|
|
<span style="font-size: 13px;">Cloud Provider</span>
|
|
</div>
|
|
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-tertiary); border-radius: 6px;">
|
|
${flagIcon(entry.flag_abuser)}
|
|
<span style="font-size: 13px;">Abuser</span>
|
|
</div>
|
|
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-tertiary); border-radius: 6px;">
|
|
${flagIcon(entry.flag_attacker)}
|
|
<span style="font-size: 13px;">Attacker</span>
|
|
</div>
|
|
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-tertiary); border-radius: 6px;">
|
|
${flagIcon(entry.flag_bogon)}
|
|
<span style="font-size: 13px;">Bogon</span>
|
|
</div>
|
|
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-tertiary); border-radius: 6px;">
|
|
${flagIcon(entry.flag_anonymous)}
|
|
<span style="font-size: 13px;">Anonymous</span>
|
|
</div>
|
|
<div style="display: flex; align-items: center; gap: 8px; padding: 8px; background: var(--bg-tertiary); border-radius: 6px;">
|
|
${flagIcon(entry.flag_threat)}
|
|
<span style="font-size: 13px;">Threat</span>
|
|
</div>
|
|
</div>
|
|
`}
|
|
</div>
|
|
|
|
<!-- Route53 A Records -->
|
|
<div id="infoRoute53Section">
|
|
<h4 style="margin: 0 0 12px 0; font-size: 14px; color: var(--text-primary); border-bottom: 1px solid var(--border); padding-bottom: 8px;">
|
|
Route53 A Records
|
|
</h4>
|
|
<div id="infoRoute53Content">
|
|
<div style="color: var(--text-tertiary); font-size: 13px;">Loading...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Timestamps -->
|
|
<div>
|
|
<h4 style="margin: 0 0 12px 0; font-size: 14px; color: var(--text-primary); border-bottom: 1px solid var(--border); padding-bottom: 8px;">
|
|
Record Info
|
|
</h4>
|
|
<table style="width: 100%; font-size: 13px; min-width: auto;">
|
|
<tbody>
|
|
${infoRow('Created', formatDate(entry.created_at))}
|
|
${infoRow('Updated', formatDate(entry.updated_at))}
|
|
${infoRow('Enriched', formatDate(entry.ipr_enriched_at))}
|
|
${infoRow('Record ID', entry.id)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Load Route53 records for this prefix
|
|
loadRoute53RecordsForInfo(entry.ip_prefix);
|
|
} else {
|
|
body.innerHTML = `<div class="alert alert-danger">Failed to load entry: ${escapeHtml(result.error)}</div>`;
|
|
}
|
|
} catch (error) {
|
|
body.innerHTML = `<div class="alert alert-danger">Failed to load entry details</div>`;
|
|
}
|
|
}
|
|
|
|
// Load Route53 records for info modal
|
|
async function loadRoute53RecordsForInfo(ipPrefix) {
|
|
const container = document.getElementById('infoRoute53Content');
|
|
if (!container) return;
|
|
|
|
try {
|
|
const result = await api('route53_records_by_prefix', { ip_prefix: ipPrefix });
|
|
|
|
if (result.success && result.records && result.records.length > 0) {
|
|
container.innerHTML = `
|
|
<div style="max-height: 200px; overflow-y: auto;">
|
|
<table style="width: 100%; font-size: 12px; min-width: auto;">
|
|
<thead>
|
|
<tr style="border-bottom: 1px solid var(--border);">
|
|
<th style="text-align: left; padding: 4px 8px; font-weight: 500; color: var(--text-secondary);">Hostname</th>
|
|
<th style="text-align: left; padding: 4px 8px; font-weight: 500; color: var(--text-secondary);">IP Address</th>
|
|
<th style="text-align: left; padding: 4px 8px; font-weight: 500; color: var(--text-secondary);">PTR Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${result.records.map(rec => `
|
|
<tr style="border-bottom: 1px solid var(--border);">
|
|
<td style="padding: 6px 8px;">
|
|
<code style="font-size: 11px; background: var(--bg-tertiary); padding: 2px 4px; border-radius: 3px;">${escapeHtml(rec.hostname)}</code>
|
|
</td>
|
|
<td style="padding: 6px 8px;">
|
|
<code style="font-size: 11px;">${escapeHtml(rec.ip_address)}</code>
|
|
</td>
|
|
<td style="padding: 6px 8px;">
|
|
<span class="ptr-status-badge ${rec.ptr_status}">${rec.ptr_status || 'unknown'}</span>
|
|
</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div style="margin-top: 8px; font-size: 11px; color: var(--text-tertiary);">
|
|
${result.count} record${result.count !== 1 ? 's' : ''} found in Route53 for this prefix
|
|
</div>
|
|
`;
|
|
} else {
|
|
container.innerHTML = `<p style="color: var(--text-tertiary); font-style: italic; margin: 0; font-size: 13px;">No Route53 A records found for this prefix.</p>`;
|
|
}
|
|
} catch (error) {
|
|
container.innerHTML = `<p style="color: var(--text-tertiary); font-style: italic; margin: 0; font-size: 13px;">Could not load Route53 records.</p>`;
|
|
}
|
|
}
|
|
|
|
function closeInfoModal() {
|
|
document.getElementById('infoModal').classList.remove('active');
|
|
}
|
|
|
|
// Delete entry
|
|
function deleteEntry(id, prefix) {
|
|
deleteEntryId = id;
|
|
document.getElementById('deletePrefix').textContent = prefix;
|
|
document.getElementById('deleteModal').classList.add('active');
|
|
}
|
|
|
|
function closeDeleteModal() {
|
|
document.getElementById('deleteModal').classList.remove('active');
|
|
deleteEntryId = null;
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
if (!deleteEntryId) return;
|
|
|
|
try {
|
|
const result = await api('delete', {}, 'POST', { id: deleteEntryId });
|
|
|
|
if (result.success) {
|
|
showToast('Entry deleted successfully', 'success');
|
|
closeDeleteModal();
|
|
loadEntries(currentPage);
|
|
loadStats();
|
|
} else {
|
|
showToast(result.error || 'Failed to delete entry', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Network error', 'error');
|
|
}
|
|
}
|
|
|
|
// Export CSV
|
|
function exportCSV() {
|
|
window.location.href = 'api.php?action=export&format=download';
|
|
}
|
|
|
|
// Export Audit Log as CSV
|
|
async function exportAuditLog() {
|
|
showToast('Preparing audit log export...', 'info');
|
|
|
|
try {
|
|
// Fetch all audit log entries (high limit to get all)
|
|
const result = await api('audit_log', { page: 1, limit: 10000 });
|
|
|
|
if (!result.success || !result.data || result.data.length === 0) {
|
|
showToast('No audit log entries to export', 'error');
|
|
return;
|
|
}
|
|
|
|
// Build CSV content
|
|
const headers = ['Date/Time', 'Action', 'IP Prefix', 'Details', 'Changed By'];
|
|
const rows = result.data.map(entry => {
|
|
const date = new Date(entry.changed_at).toISOString();
|
|
const action = entry.action;
|
|
|
|
let ipPrefix = '';
|
|
let details = '';
|
|
|
|
if (entry.new_values?.type === 'bulk_import') {
|
|
details = `Bulk import: ${entry.new_values.inserted} inserted, ${entry.new_values.updated} updated`;
|
|
} else if (entry.new_values?.type === 'url_import') {
|
|
details = `URL import from ${entry.new_values.url}: ${entry.new_values.inserted} inserted, ${entry.new_values.updated} updated`;
|
|
} else if (entry.new_values?.type === 'clear_all') {
|
|
details = `Cleared ${entry.old_values?.count || 0} entries`;
|
|
} else {
|
|
ipPrefix = entry.ip_prefix || entry.old_values?.ip_prefix || entry.new_values?.ip_prefix || '';
|
|
|
|
// Build details from old/new values
|
|
if (entry.action === 'UPDATE' && entry.old_values && entry.new_values) {
|
|
const changes = [];
|
|
for (const key of Object.keys(entry.new_values)) {
|
|
if (entry.old_values[key] !== entry.new_values[key]) {
|
|
changes.push(`${key}: ${entry.old_values[key] || '(empty)'} → ${entry.new_values[key] || '(empty)'}`);
|
|
}
|
|
}
|
|
details = changes.join('; ');
|
|
} else if (entry.action === 'INSERT' && entry.new_values) {
|
|
details = Object.entries(entry.new_values)
|
|
.filter(([k, v]) => v && k !== 'ip_prefix')
|
|
.map(([k, v]) => `${k}: ${v}`)
|
|
.join('; ');
|
|
} else if (entry.action === 'DELETE' && entry.old_values) {
|
|
details = Object.entries(entry.old_values)
|
|
.filter(([k, v]) => v && k !== 'ip_prefix')
|
|
.map(([k, v]) => `${k}: ${v}`)
|
|
.join('; ');
|
|
}
|
|
}
|
|
|
|
const changedBy = entry.changed_by || 'Unknown';
|
|
|
|
return [date, action, ipPrefix, details, changedBy];
|
|
});
|
|
|
|
// Create CSV string
|
|
const csvContent = [
|
|
headers.join(','),
|
|
...rows.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
|
].join('\n');
|
|
|
|
// Download file
|
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
const link = document.createElement('a');
|
|
link.href = URL.createObjectURL(blob);
|
|
link.download = `audit-log-${new Date().toISOString().split('T')[0]}.csv`;
|
|
link.click();
|
|
URL.revokeObjectURL(link.href);
|
|
|
|
showToast(`Exported ${result.data.length} audit log entries`, 'success');
|
|
} catch (error) {
|
|
console.error('Audit log export error:', error);
|
|
showToast('Failed to export audit log', 'error');
|
|
}
|
|
}
|
|
|
|
// File handling
|
|
function handleFileSelect(input) {
|
|
if (input.files && input.files[0]) {
|
|
selectedFile = input.files[0];
|
|
document.getElementById('fileName').textContent = selectedFile.name;
|
|
document.getElementById('uploadBtn').disabled = false;
|
|
|
|
// Reset results
|
|
document.getElementById('fileResults').classList.remove('active', 'success', 'error');
|
|
}
|
|
}
|
|
|
|
// Import from file
|
|
async function importFromFile() {
|
|
if (!selectedFile) return;
|
|
|
|
const btn = document.getElementById('uploadBtn');
|
|
const progress = document.getElementById('fileProgress');
|
|
const progressFill = document.getElementById('fileProgressFill');
|
|
const progressText = document.getElementById('fileProgressText');
|
|
const results = document.getElementById('fileResults');
|
|
|
|
btn.disabled = true;
|
|
progress.classList.add('active');
|
|
results.classList.remove('active', 'success', 'error');
|
|
|
|
try {
|
|
progressText.textContent = 'Reading file...';
|
|
progressFill.style.width = '20%';
|
|
|
|
const text = await selectedFile.text();
|
|
|
|
progressText.textContent = 'Parsing CSV...';
|
|
progressFill.style.width = '40%';
|
|
|
|
const entries = parseCSV(text);
|
|
|
|
progressText.textContent = `Importing ${entries.length} entries...`;
|
|
progressFill.style.width = '60%';
|
|
|
|
const result = await api('import', {}, 'POST', { entries, csrf_token: csrfToken });
|
|
|
|
progressFill.style.width = '100%';
|
|
|
|
if (result.success) {
|
|
results.classList.add('active', 'success');
|
|
document.getElementById('fileResultsText').textContent =
|
|
`Successfully imported ${result.inserted} new entries, updated ${result.updated} existing entries.`;
|
|
showToast('Import completed successfully', 'success');
|
|
loadEntries();
|
|
loadStats();
|
|
} else {
|
|
results.classList.add('active', 'error');
|
|
document.getElementById('fileResultsText').textContent = result.error || 'Import failed';
|
|
showToast(result.error || 'Import failed', 'error');
|
|
}
|
|
} catch (error) {
|
|
results.classList.add('active', 'error');
|
|
document.getElementById('fileResultsText').textContent = error.message;
|
|
showToast('Import failed: ' + error.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
setTimeout(() => {
|
|
progress.classList.remove('active');
|
|
progressFill.style.width = '0%';
|
|
}, 1000);
|
|
}
|
|
}
|
|
|
|
// Import from URL
|
|
async function importFromUrl() {
|
|
const url = document.getElementById('importUrl').value.trim();
|
|
if (!url) {
|
|
showToast('Please enter a URL', 'error');
|
|
return;
|
|
}
|
|
|
|
const btn = document.getElementById('urlImportBtn');
|
|
const progress = document.getElementById('urlProgress');
|
|
const progressFill = document.getElementById('urlProgressFill');
|
|
const progressText = document.getElementById('urlProgressText');
|
|
const results = document.getElementById('urlResults');
|
|
|
|
btn.disabled = true;
|
|
progress.classList.add('active');
|
|
results.classList.remove('active', 'success', 'error');
|
|
|
|
try {
|
|
progressText.textContent = 'Fetching data...';
|
|
progressFill.style.width = '30%';
|
|
|
|
const result = await api('import_url', {}, 'POST', { url, csrf_token: csrfToken });
|
|
|
|
progressFill.style.width = '100%';
|
|
|
|
if (result.success) {
|
|
results.classList.add('active', 'success');
|
|
document.getElementById('urlResultsText').textContent =
|
|
`Successfully imported ${result.inserted} new entries, updated ${result.updated} existing entries.`;
|
|
showToast('Import completed successfully', 'success');
|
|
loadEntries();
|
|
loadStats();
|
|
} else {
|
|
results.classList.add('active', 'error');
|
|
document.getElementById('urlResultsText').textContent = result.error || 'Import failed';
|
|
showToast(result.error || 'Import failed', 'error');
|
|
}
|
|
} catch (error) {
|
|
results.classList.add('active', 'error');
|
|
document.getElementById('urlResultsText').textContent = error.message;
|
|
showToast('Import failed: ' + error.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
setTimeout(() => {
|
|
progress.classList.remove('active');
|
|
progressFill.style.width = '0%';
|
|
}, 1000);
|
|
}
|
|
}
|
|
|
|
// Parse CSV
|
|
function parseCSV(text) {
|
|
const lines = text.split(/\r?\n/);
|
|
const entries = [];
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
|
|
const parts = trimmed.split(',');
|
|
if (parts.length < 1 || !parts[0].trim()) continue;
|
|
|
|
entries.push({
|
|
ip_prefix: parts[0]?.trim() || '',
|
|
country_code: parts[1]?.trim().toUpperCase() || '',
|
|
region_code: parts[2]?.trim().toUpperCase() || '',
|
|
city: parts[3]?.trim() || '',
|
|
postal_code: parts[4]?.trim() || ''
|
|
});
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
// Clear all
|
|
function confirmClearAll() {
|
|
document.getElementById('confirmClearInput').value = '';
|
|
document.getElementById('confirmClearBtn').disabled = true;
|
|
document.getElementById('clearAllModal').classList.add('active');
|
|
}
|
|
|
|
function closeClearAllModal() {
|
|
document.getElementById('clearAllModal').classList.remove('active');
|
|
}
|
|
|
|
async function executeClearAll() {
|
|
if (document.getElementById('confirmClearInput').value !== 'DELETE') return;
|
|
|
|
try {
|
|
const result = await api('clear_all', {}, 'POST', { csrf_token: csrfToken });
|
|
|
|
if (result.success) {
|
|
showToast('All entries cleared successfully', 'success');
|
|
closeClearAllModal();
|
|
loadEntries();
|
|
loadStats();
|
|
} else {
|
|
showToast(result.error || 'Failed to clear entries', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Network error', 'error');
|
|
}
|
|
}
|
|
|
|
// Toast notifications
|
|
function showToast(message, type = 'success') {
|
|
const container = document.getElementById('toastContainer');
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
|
|
const icon = type === 'success'
|
|
? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>'
|
|
: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>';
|
|
|
|
toast.innerHTML = `
|
|
<div class="toast-icon">${icon}</div>
|
|
<span class="toast-message">${escapeHtml(message)}</span>
|
|
<button class="toast-close" onclick="this.parentElement.remove()">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<line x1="18" y1="6" x2="6" y2="18"/>
|
|
<line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
</button>
|
|
`;
|
|
|
|
container.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.style.animation = 'slideIn 0.3s ease reverse';
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, 4000);
|
|
}
|
|
|
|
// Helpers
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function getFlagEmoji(countryCode) {
|
|
if (!countryCode || countryCode.length !== 2) return '';
|
|
const codePoints = countryCode
|
|
.toUpperCase()
|
|
.split('')
|
|
.map(char => 127397 + char.charCodeAt());
|
|
return String.fromCodePoint(...codePoints);
|
|
}
|
|
|
|
// Render security flags
|
|
function renderSecurityFlags(entry) {
|
|
const flags = [];
|
|
|
|
// Danger flags (red)
|
|
if (entry.flag_abuser == 1) flags.push({label: 'Abuser', type: 'danger'});
|
|
if (entry.flag_attacker == 1) flags.push({label: 'Attacker', type: 'danger'});
|
|
if (entry.flag_threat == 1) flags.push({label: 'Threat', type: 'danger'});
|
|
if (entry.flag_tor_exit == 1) flags.push({label: 'Tor Exit', type: 'danger'});
|
|
|
|
// Warning flags (yellow)
|
|
if (entry.flag_proxy == 1) flags.push({label: 'Proxy', type: 'warning'});
|
|
if (entry.flag_vpn == 1) flags.push({label: 'VPN', type: 'warning'});
|
|
if (entry.flag_tor == 1 && entry.flag_tor_exit != 1) flags.push({label: 'Tor', type: 'warning'});
|
|
if (entry.flag_relay == 1) flags.push({label: 'Relay', type: 'warning'});
|
|
if (entry.flag_anonymous == 1) flags.push({label: 'Anon', type: 'warning'});
|
|
|
|
// Info flags (blue)
|
|
if (entry.flag_cloud_provider == 1) flags.push({label: 'Cloud', type: 'info'});
|
|
if (entry.flag_bogon == 1) flags.push({label: 'Bogon', type: 'info'});
|
|
|
|
if (flags.length === 0) {
|
|
return entry.ipr_enriched_at ? '<span style="color: var(--success);">Clean</span>' : '<span style="color: var(--text-tertiary)">-</span>';
|
|
}
|
|
|
|
return flags.map(f => `<span class="flag-badge ${f.type}">${f.label}</span>`).join('');
|
|
}
|
|
|
|
// Enrich single IP
|
|
async function enrichIp(id) {
|
|
try {
|
|
const result = await api('enrich_ip', {}, 'POST', { id });
|
|
if (result.success) {
|
|
showToast('IP enriched successfully', 'success');
|
|
loadEntries(currentPage);
|
|
} else {
|
|
showToast(result.error || 'Failed to enrich IP', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Network error', 'error');
|
|
}
|
|
}
|
|
|
|
// Enrich all un-enriched IPs
|
|
async function enrichAllIps(btn) {
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner" style="display:inline-block"></span> Enriching...';
|
|
|
|
try {
|
|
const result = await api('enrich_all', {}, 'POST', {});
|
|
if (result.success) {
|
|
showToast(`Enriched ${result.enriched} IPs. ${result.failed || 0} failed.`, 'success');
|
|
loadEntries(currentPage);
|
|
} else {
|
|
showToast(result.error || 'Failed to enrich IPs', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Network error', 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = 'Enrich All Un-enriched IPs';
|
|
}
|
|
}
|
|
|
|
// Re-enrich current entry from edit modal
|
|
async function reEnrichCurrentEntry() {
|
|
const id = document.getElementById('entryId').value;
|
|
if (!id) return;
|
|
|
|
const btn = document.getElementById('reEnrichBtn');
|
|
const btnText = document.getElementById('reEnrichBtnText');
|
|
|
|
btn.disabled = true;
|
|
btnText.textContent = 'Enriching...';
|
|
|
|
try {
|
|
const result = await api('enrich_ip', {}, 'POST', { id: parseInt(id) });
|
|
if (result.success) {
|
|
showToast('IP enriched successfully', 'success');
|
|
|
|
// Reload the entry data to update the modal
|
|
const entryResult = await api('get', { id });
|
|
if (entryResult.success) {
|
|
// Update enrichment display without closing modal
|
|
const entry = entryResult.data;
|
|
const enrichStatus = document.getElementById('enrichmentStatus');
|
|
const enrichData = document.getElementById('enrichmentData');
|
|
|
|
const enrichedDate = new Date(entry.ipr_enriched_at).toLocaleString();
|
|
enrichStatus.innerHTML = `<span style="color: var(--success);">Enriched on ${enrichedDate}</span>`;
|
|
enrichData.style.display = 'block';
|
|
|
|
document.getElementById('enrichIsp').textContent = entry.ipr_isp || '-';
|
|
document.getElementById('enrichAsn').textContent = entry.ipr_asn ? `AS${entry.ipr_asn}` : '-';
|
|
document.getElementById('enrichOrg').textContent = entry.ipr_org || '-';
|
|
document.getElementById('enrichType').textContent = entry.ipr_connection_type || '-';
|
|
|
|
// Update flags
|
|
const flags = [];
|
|
if (entry.flag_abuser == 1) flags.push('Abuser');
|
|
if (entry.flag_attacker == 1) flags.push('Attacker');
|
|
if (entry.flag_bogon == 1) flags.push('Bogon');
|
|
if (entry.flag_cloud_provider == 1) flags.push('Cloud');
|
|
if (entry.flag_proxy == 1) flags.push('Proxy');
|
|
if (entry.flag_relay == 1) flags.push('Relay');
|
|
if (entry.flag_tor == 1) flags.push('Tor');
|
|
if (entry.flag_tor_exit == 1) flags.push('Tor Exit');
|
|
if (entry.flag_vpn == 1) flags.push('VPN');
|
|
if (entry.flag_anonymous == 1) flags.push('Anonymous');
|
|
if (entry.flag_threat == 1) flags.push('Threat');
|
|
|
|
const flagsEl = document.getElementById('enrichFlags');
|
|
if (flags.length > 0) {
|
|
flagsEl.innerHTML = '<strong>Flags:</strong> ' + flags.map(f => `<span style="background: var(--danger); color: white; padding: 1px 6px; border-radius: 4px; font-size: 11px; margin-left: 4px;">${f}</span>`).join('');
|
|
} else {
|
|
flagsEl.innerHTML = '<strong>Flags:</strong> <span style="color: var(--success);">None</span>';
|
|
}
|
|
}
|
|
|
|
// Also refresh the main table
|
|
loadEntries(currentPage);
|
|
} else {
|
|
showToast(result.error || 'Failed to enrich IP', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Network error', 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btnText.textContent = 'Re-enrich IP';
|
|
}
|
|
}
|
|
|
|
// Developer Tab Functions
|
|
|
|
// Download database backup
|
|
function downloadDatabaseBackup() {
|
|
const url = `api.php?action=database_backup`;
|
|
window.location.href = url;
|
|
showToast('Downloading backup...', 'success');
|
|
}
|
|
|
|
// Import database backup
|
|
async function importDatabaseBackup() {
|
|
const fileInput = document.getElementById('dbImportFile');
|
|
const file = fileInput.files[0];
|
|
|
|
if (!file) {
|
|
showToast('Please select a backup file', 'error');
|
|
return;
|
|
}
|
|
|
|
if (!file.name.endsWith('.json')) {
|
|
showToast('Please select a valid JSON backup file', 'error');
|
|
return;
|
|
}
|
|
|
|
if (!confirm('WARNING: This will replace ALL existing data with the backup. Are you sure you want to continue?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const text = await file.text();
|
|
const backupData = JSON.parse(text);
|
|
|
|
// Validate basic structure
|
|
if (!backupData.backup_info || !backupData.geofeed_entries) {
|
|
showToast('Invalid backup file format', 'error');
|
|
return;
|
|
}
|
|
|
|
const result = await api('database_import', {}, 'POST', { backup_data: backupData });
|
|
|
|
if (result.success) {
|
|
showToast(`Import successful: ${result.imported.entries} entries, ${result.imported.settings} settings, ${result.imported.logos} logos`, 'success');
|
|
fileInput.value = '';
|
|
loadSystemInfo();
|
|
loadEntries();
|
|
loadStats();
|
|
} else {
|
|
showToast(result.error || 'Import failed', 'error');
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof SyntaxError) {
|
|
showToast('Invalid JSON file', 'error');
|
|
} else {
|
|
showToast('Import failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Load system information
|
|
async function loadSystemInfo() {
|
|
const container = document.getElementById('systemInfoContent');
|
|
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
|
|
|
try {
|
|
const result = await api('system_info');
|
|
|
|
if (result.success) {
|
|
const data = result.data;
|
|
container.innerHTML = `
|
|
<div style="display: grid; gap: 16px;">
|
|
<div class="table-container" style="margin: 0;">
|
|
<table>
|
|
<tbody>
|
|
<tr>
|
|
<td style="width: 200px; font-weight: 600;">App Version</td>
|
|
<td>${escapeHtml(data.app_version)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-weight: 600;">PHP Version</td>
|
|
<td>${escapeHtml(data.php_version)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-weight: 600;">Server Software</td>
|
|
<td>${escapeHtml(data.server.software)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-weight: 600;">Server Time</td>
|
|
<td>${escapeHtml(data.server.time)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-weight: 600;">Timezone</td>
|
|
<td>${escapeHtml(data.server.timezone)}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="table-container" style="margin: 0;">
|
|
<div class="table-header">
|
|
<h3 class="table-title">Database Statistics</h3>
|
|
</div>
|
|
<table>
|
|
<tbody>
|
|
<tr>
|
|
<td style="width: 200px; font-weight: 600;">Database Name</td>
|
|
<td>${escapeHtml(data.database.name)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-weight: 600;">Database Host</td>
|
|
<td>${escapeHtml(data.database.host)}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-weight: 600;">Database Size</td>
|
|
<td>${data.database.size_mb} MB</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-weight: 600;">Geofeed Entries</td>
|
|
<td>${data.database.entries.toLocaleString()}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-weight: 600;">Enriched Entries</td>
|
|
<td>${data.database.enriched_entries.toLocaleString()}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-weight: 600;">Settings</td>
|
|
<td>${data.database.settings.toLocaleString()}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-weight: 600;">Audit Log Entries</td>
|
|
<td>${data.database.audit_log_entries.toLocaleString()}</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="font-weight: 600;">Client Logos</td>
|
|
<td>${data.database.client_logos.toLocaleString()}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
`;
|
|
} else {
|
|
container.innerHTML = `<div class="alert alert-danger">Failed to load system info: ${escapeHtml(result.error)}</div>`;
|
|
}
|
|
} catch (error) {
|
|
container.innerHTML = `<div class="alert alert-danger">Failed to load system info</div>`;
|
|
}
|
|
}
|
|
|
|
// Schema sync variables
|
|
let pendingSchemaUpdates = null;
|
|
|
|
// Check for schema updates
|
|
async function checkSchemaUpdates() {
|
|
const resultDiv = document.getElementById('schemaCheckResult');
|
|
const applyBtn = document.getElementById('applySchemaBtn');
|
|
|
|
resultDiv.style.display = 'block';
|
|
resultDiv.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
|
applyBtn.disabled = true;
|
|
|
|
try {
|
|
const result = await api('schema_check');
|
|
|
|
if (result.success) {
|
|
pendingSchemaUpdates = result;
|
|
|
|
if (!result.has_updates) {
|
|
resultDiv.innerHTML = `
|
|
<div class="alert alert-success" style="display: flex; align-items: center; gap: 8px;">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="20 6 9 17 4 12"/>
|
|
</svg>
|
|
<span>Database schema is up to date. No changes needed.</span>
|
|
</div>
|
|
`;
|
|
applyBtn.disabled = true;
|
|
} else {
|
|
let html = `
|
|
<div class="alert alert-warning" style="margin-bottom: 12px;">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
|
<line x1="12" y1="9" x2="12" y2="13"/>
|
|
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
|
</svg>
|
|
<span>Schema updates available. Review the changes below and click "Apply Schema Updates" to apply them.</span>
|
|
</div>
|
|
`;
|
|
|
|
if (result.missing_tables.length > 0) {
|
|
html += `
|
|
<div class="table-container" style="margin: 12px 0;">
|
|
<div class="table-header">
|
|
<h4 class="table-title">Missing Tables (${result.missing_tables.length})</h4>
|
|
</div>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Table Name</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${result.missing_tables.map(t => `<tr><td><code>${escapeHtml(t)}</code></td></tr>`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (result.missing_columns.length > 0) {
|
|
html += `
|
|
<div class="table-container" style="margin: 12px 0;">
|
|
<div class="table-header">
|
|
<h4 class="table-title">Missing Columns (${result.missing_columns.length})</h4>
|
|
</div>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Table</th>
|
|
<th>Column</th>
|
|
<th>Definition</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${result.missing_columns.map(c => `
|
|
<tr>
|
|
<td><code>${escapeHtml(c.table)}</code></td>
|
|
<td><code>${escapeHtml(c.column)}</code></td>
|
|
<td><code style="font-size: 11px;">${escapeHtml(c.definition)}</code></td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (result.missing_indexes.length > 0) {
|
|
html += `
|
|
<div class="table-container" style="margin: 12px 0;">
|
|
<div class="table-header">
|
|
<h4 class="table-title">Missing Indexes (${result.missing_indexes.length})</h4>
|
|
</div>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Table</th>
|
|
<th>Index Name</th>
|
|
<th>Columns</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${result.missing_indexes.map(i => `
|
|
<tr>
|
|
<td><code>${escapeHtml(i.table)}</code></td>
|
|
<td><code>${escapeHtml(i.index)}</code></td>
|
|
<td><code>${escapeHtml(i.columns)}</code></td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
resultDiv.innerHTML = html;
|
|
applyBtn.disabled = false;
|
|
}
|
|
} else {
|
|
resultDiv.innerHTML = `<div class="alert alert-danger">Failed to check schema: ${escapeHtml(result.error)}</div>`;
|
|
}
|
|
} catch (error) {
|
|
resultDiv.innerHTML = `<div class="alert alert-danger">Failed to check schema: ${error.message}</div>`;
|
|
}
|
|
}
|
|
|
|
// Apply schema updates
|
|
async function applySchemaUpdates() {
|
|
if (!pendingSchemaUpdates || !pendingSchemaUpdates.has_updates) {
|
|
showToast('No updates to apply', 'error');
|
|
return;
|
|
}
|
|
|
|
if (!confirm('Are you sure you want to apply the schema updates? This will modify your database structure.')) {
|
|
return;
|
|
}
|
|
|
|
const resultDiv = document.getElementById('schemaCheckResult');
|
|
const applyBtn = document.getElementById('applySchemaBtn');
|
|
|
|
applyBtn.disabled = true;
|
|
resultDiv.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
|
|
|
try {
|
|
const result = await api('schema_apply', {}, 'POST', {});
|
|
|
|
if (result.success) {
|
|
let html = '';
|
|
|
|
if (result.applied.length > 0) {
|
|
html += `
|
|
<div class="alert alert-success" style="margin-bottom: 12px;">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="20 6 9 17 4 12"/>
|
|
</svg>
|
|
<span>${escapeHtml(result.message)}</span>
|
|
</div>
|
|
<div class="table-container" style="margin: 12px 0;">
|
|
<div class="table-header">
|
|
<h4 class="table-title">Applied Changes (${result.applied.length})</h4>
|
|
</div>
|
|
<table>
|
|
<tbody>
|
|
${result.applied.map(a => `<tr><td style="color: var(--success);">${escapeHtml(a)}</td></tr>`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (result.failed.length > 0) {
|
|
html += `
|
|
<div class="table-container" style="margin: 12px 0;">
|
|
<div class="table-header">
|
|
<h4 class="table-title" style="color: var(--error);">Failed (${result.failed.length})</h4>
|
|
</div>
|
|
<table>
|
|
<tbody>
|
|
${result.failed.map(f => `<tr><td style="color: var(--error);">${escapeHtml(f)}</td></tr>`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (result.applied.length === 0 && result.failed.length === 0) {
|
|
html = `
|
|
<div class="alert alert-success">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="20 6 9 17 4 12"/>
|
|
</svg>
|
|
<span>No updates needed - schema is already up to date.</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
resultDiv.innerHTML = html;
|
|
pendingSchemaUpdates = null;
|
|
showToast(result.message, 'success');
|
|
|
|
// Refresh system info
|
|
loadSystemInfo();
|
|
} else {
|
|
resultDiv.innerHTML = `<div class="alert alert-danger">Failed to apply schema: ${escapeHtml(result.error)}</div>`;
|
|
applyBtn.disabled = false;
|
|
}
|
|
} catch (error) {
|
|
resultDiv.innerHTML = `<div class="alert alert-danger">Failed to apply schema: ${error.message}</div>`;
|
|
applyBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// Logout function
|
|
async function logout() {
|
|
try {
|
|
await api('logout', {});
|
|
} catch (e) {}
|
|
window.location.href = 'login.php';
|
|
}
|
|
|
|
// Load error logs
|
|
async function loadErrorLogs() {
|
|
const container = document.getElementById('errorLogsContent');
|
|
const lines = document.getElementById('errorLogLines').value;
|
|
|
|
container.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
|
|
|
|
try {
|
|
const result = await api('error_logs', { lines: lines });
|
|
|
|
if (result.success) {
|
|
if (result.lines.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="alert alert-success" style="display: flex; align-items: center; gap: 8px;">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="20 6 9 17 4 12"/>
|
|
</svg>
|
|
<span>${result.message || 'No error log entries found.'}</span>
|
|
</div>
|
|
<p style="margin-top: 8px; color: var(--text-secondary); font-size: 13px;">
|
|
Log path: <code>${escapeHtml(result.log_path)}</code>
|
|
</p>
|
|
`;
|
|
} else {
|
|
const getTypeColor = (type) => {
|
|
switch(type) {
|
|
case 'fatal': return 'var(--error)';
|
|
case 'error': return 'var(--error)';
|
|
case 'warning': return 'var(--warning)';
|
|
case 'notice': return '#17a2b8';
|
|
case 'deprecated': return '#6c757d';
|
|
default: return 'var(--text-secondary)';
|
|
}
|
|
};
|
|
|
|
const getTypeBadge = (type) => {
|
|
const color = getTypeColor(type);
|
|
return `<span style="display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 600; text-transform: uppercase; background: ${color}20; color: ${color};">${type}</span>`;
|
|
};
|
|
|
|
container.innerHTML = `
|
|
<div style="margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px;">
|
|
<span style="color: var(--text-secondary); font-size: 13px;">
|
|
Showing ${result.total_lines} entries from <code>${escapeHtml(result.log_path)}</code>
|
|
(${escapeHtml(result.log_size_formatted)})
|
|
</span>
|
|
</div>
|
|
<div class="table-container" style="margin: 0;">
|
|
<div style="max-height: 500px; overflow-y: auto;">
|
|
<table style="font-size: 12px;">
|
|
<thead style="position: sticky; top: 0; background: var(--bg-primary);">
|
|
<tr>
|
|
<th style="width: 80px;">Type</th>
|
|
<th style="width: 180px;">Timestamp</th>
|
|
<th>Message</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${result.lines.map(entry => `
|
|
<tr>
|
|
<td>${getTypeBadge(entry.type)}</td>
|
|
<td style="white-space: nowrap; color: var(--text-secondary); font-size: 11px;">${entry.timestamp ? escapeHtml(entry.timestamp) : '-'}</td>
|
|
<td style="font-family: monospace; font-size: 11px; word-break: break-all; color: ${getTypeColor(entry.type)};">${escapeHtml(entry.message)}</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
} else {
|
|
container.innerHTML = `<div class="alert alert-danger">Failed to load error logs: ${escapeHtml(result.error)}</div>`;
|
|
}
|
|
} catch (error) {
|
|
container.innerHTML = `<div class="alert alert-danger">Failed to load error logs: ${error.message}</div>`;
|
|
}
|
|
}
|
|
|
|
// =============================================
|
|
// AWS Route53 / PTR Record Functions
|
|
// =============================================
|
|
|
|
// Load AWS settings
|
|
async function loadAwsSettings() {
|
|
try {
|
|
const result = await api('aws_settings_get');
|
|
if (result.success && result.data) {
|
|
document.getElementById('awsAccessKeyId').value = result.data.aws_access_key_id || '';
|
|
document.getElementById('awsSecretAccessKey').value = result.data.aws_secret_access_key || '';
|
|
document.getElementById('awsRegion').value = result.data.aws_region || 'us-east-1';
|
|
document.getElementById('awsHostedZones').value = result.data.aws_hosted_zones || '';
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load AWS settings:', error);
|
|
}
|
|
}
|
|
|
|
// Save AWS settings
|
|
async function saveAwsSettings() {
|
|
const data = {
|
|
aws_access_key_id: document.getElementById('awsAccessKeyId').value,
|
|
aws_secret_access_key: document.getElementById('awsSecretAccessKey').value,
|
|
aws_region: document.getElementById('awsRegion').value,
|
|
aws_hosted_zones: document.getElementById('awsHostedZones').value
|
|
};
|
|
|
|
try {
|
|
const result = await api('aws_settings_save', {}, 'POST', data);
|
|
if (result.success) {
|
|
showToast('AWS settings saved successfully', 'success');
|
|
} else {
|
|
showToast(result.error || 'Failed to save AWS settings', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Failed to save AWS settings: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Load whitelabel settings
|
|
async function loadWhitelabelSettings() {
|
|
try {
|
|
const result = await api('whitelabel_get');
|
|
if (result.success && result.settings) {
|
|
document.getElementById('whitelabelAppName').value = result.settings.app_name || '';
|
|
document.getElementById('whitelabelCompanyName').value = result.settings.company_name || '';
|
|
document.getElementById('whitelabelIconUrl').value = result.settings.icon_url || '';
|
|
document.getElementById('whitelabelFaviconUrl').value = result.settings.favicon_url || '';
|
|
document.getElementById('whitelabelDefaultImportUrl').value = result.settings.default_import_url || '';
|
|
updateWhitelabelPreview();
|
|
applyWhitelabelSettings(result.settings);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load whitelabel settings:', error);
|
|
}
|
|
}
|
|
|
|
// Save whitelabel settings
|
|
async function saveWhitelabelSettings() {
|
|
const data = {
|
|
app_name: document.getElementById('whitelabelAppName').value,
|
|
company_name: document.getElementById('whitelabelCompanyName').value,
|
|
icon_url: document.getElementById('whitelabelIconUrl').value,
|
|
favicon_url: document.getElementById('whitelabelFaviconUrl').value,
|
|
default_import_url: document.getElementById('whitelabelDefaultImportUrl').value
|
|
};
|
|
|
|
try {
|
|
const result = await api('whitelabel_save', {}, 'POST', data);
|
|
if (result.success) {
|
|
showToast('Whitelabel settings saved successfully', 'success');
|
|
applyWhitelabelSettings(data);
|
|
} else {
|
|
showToast(result.error || 'Failed to save whitelabel settings', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Failed to save whitelabel settings: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Update the preview in the whitelabel tab
|
|
function updateWhitelabelPreview() {
|
|
const appName = document.getElementById('whitelabelAppName').value || 'ISP IP Manager';
|
|
const companyName = document.getElementById('whitelabelCompanyName').value || '';
|
|
const iconUrl = document.getElementById('whitelabelIconUrl').value;
|
|
|
|
document.getElementById('whitelabelPreviewAppName').textContent = appName;
|
|
document.getElementById('whitelabelPreviewCompanyName').textContent = companyName;
|
|
|
|
const previewIcon = document.getElementById('whitelabelPreviewIcon');
|
|
if (iconUrl) {
|
|
previewIcon.innerHTML = `<img src="${escapeHtml(iconUrl)}" style="width: 100%; height: 100%; object-fit: contain;" onerror="this.parentElement.innerHTML='<svg viewBox=\\'0 0 258 258\\' fill=\\'none\\' width=\\'24\\' height=\\'24\\'><path fill=\\'white\\' d=\\'M241.13 56.2A26.53 26.53 0 11188.07 56.2a26.53 26.53 0 0153.06 0zm-5.34-.05a21.19 21.19 0 10-42.38 0 21.19 21.19 0 0042.38 0z\\' transform=\\'translate(-30,-5) scale(0.75)\\'/><path fill=\\'white\\' d=\\'M21.42 37.38h55.28a.32.32 0 01.32.32v12.21a.46.46 0 00.8.3c13.2-14.73 32.09-17.47 50.68-12.7 35.19 9.03 47.69 43.89 45.07 77C170.91 148.16 150.93 173.81 115.1 175.14q-22.52.84-37.38-15.22a.65.65 0 00-1.13.47c.06 1.2.49 2.44.49 4.15q-.04 23.9.01 56.37a.42.41 0 01-.42.41H21.66a.88.88 0 01-.88-.88V38.01a.64.63 0 01.64-.63zM77.02 104.64c0 12.43 5.67 26.28 20.24 26.28s20.25-13.85 20.25-26.28-5.67-26.28-20.25-26.28-20.24 13.85-20.24 26.28z\\' transform=\\'translate(30,30) scale(0.75)\\'/></svg>'">`;
|
|
} else {
|
|
previewIcon.innerHTML = `<svg viewBox="0 0 258 258" fill="none" width="24" height="24"><path fill="white" d="M241.13 56.2A26.53 26.53 0 11188.07 56.2a26.53 26.53 0 0153.06 0zm-5.34-.05a21.19 21.19 0 10-42.38 0 21.19 21.19 0 0042.38 0z" transform="translate(-30,-5) scale(0.75)"/><path fill="white" d="M21.42 37.38h55.28a.32.32 0 01.32.32v12.21a.46.46 0 00.8.3c13.2-14.73 32.09-17.47 50.68-12.7 35.19 9.03 47.69 43.89 45.07 77C170.91 148.16 150.93 173.81 115.1 175.14q-22.52.84-37.38-15.22a.65.65 0 00-1.13.47c.06 1.2.49 2.44.49 4.15q-.04 23.9.01 56.37a.42.41 0 01-.42.41H21.66a.88.88 0 01-.88-.88V38.01a.64.63 0 01.64-.63zM77.02 104.64c0 12.43 5.67 26.28 20.24 26.28s20.25-13.85 20.25-26.28-5.67-26.28-20.25-26.28-20.24 13.85-20.24 26.28z" transform="translate(30,30) scale(0.75)"/></svg>`;
|
|
}
|
|
}
|
|
|
|
// Apply whitelabel settings to the page
|
|
function applyWhitelabelSettings(settings) {
|
|
// Update page title and header
|
|
if (settings.app_name) {
|
|
document.title = settings.app_name;
|
|
document.querySelector('.logo-title').textContent = settings.app_name;
|
|
}
|
|
|
|
// Update company name subtitle
|
|
const logoSubtitle = document.querySelector('.logo-subtitle');
|
|
if (logoSubtitle) {
|
|
logoSubtitle.textContent = settings.company_name || '';
|
|
}
|
|
|
|
// Update favicon
|
|
if (settings.favicon_url) {
|
|
let link = document.querySelector("link[rel*='icon']") || document.createElement('link');
|
|
link.type = 'image/x-icon';
|
|
link.rel = 'shortcut icon';
|
|
link.href = settings.favicon_url;
|
|
document.getElementsByTagName('head')[0].appendChild(link);
|
|
}
|
|
|
|
// Update header icon
|
|
if (settings.icon_url) {
|
|
const logoIcon = document.querySelector('.logo-icon');
|
|
if (logoIcon) {
|
|
logoIcon.innerHTML = `<img src="${escapeHtml(settings.icon_url)}" style="width: 100%; height: 100%; object-fit: contain;" onerror="this.parentElement.innerHTML='<svg viewBox=\\'0 0 258 258\\' fill=\\'none\\'><path fill=\\'white\\' d=\\'M241.13 56.2A26.53 26.53 0 11188.07 56.2a26.53 26.53 0 0153.06 0zm-5.34-.05a21.19 21.19 0 10-42.38 0 21.19 21.19 0 0042.38 0z\\' transform=\\'translate(-30,-5) scale(0.75)\\'/><path fill=\\'white\\' d=\\'M21.42 37.38h55.28a.32.32 0 01.32.32v12.21a.46.46 0 00.8.3c13.2-14.73 32.09-17.47 50.68-12.7 35.19 9.03 47.69 43.89 45.07 77C170.91 148.16 150.93 173.81 115.1 175.14q-22.52.84-37.38-15.22a.65.65 0 00-1.13.47c.06 1.2.49 2.44.49 4.15q-.04 23.9.01 56.37a.42.41 0 01-.42.41H21.66a.88.88 0 01-.88-.88V38.01a.64.63 0 01.64-.63zM77.02 104.64c0 12.43 5.67 26.28 20.24 26.28s20.25-13.85 20.25-26.28-5.67-26.28-20.25-26.28-20.24 13.85-20.24 26.28z\\' transform=\\'translate(30,30) scale(0.75)\\'/><circle fill=\\'#31b05e\\' cx=\\'195\\' cy=\\'195\\' r=\\'22\\' transform=\\'scale(0.85)\\'/></svg>'">`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add event listeners for preview updates
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
const appNameInput = document.getElementById('whitelabelAppName');
|
|
const companyNameInput = document.getElementById('whitelabelCompanyName');
|
|
const iconUrlInput = document.getElementById('whitelabelIconUrl');
|
|
if (appNameInput) appNameInput.addEventListener('input', updateWhitelabelPreview);
|
|
if (companyNameInput) companyNameInput.addEventListener('input', updateWhitelabelPreview);
|
|
if (iconUrlInput) iconUrlInput.addEventListener('input', updateWhitelabelPreview);
|
|
});
|
|
|
|
// Test AWS connection
|
|
async function testAwsConnection() {
|
|
const resultDiv = document.getElementById('awsTestResult');
|
|
resultDiv.style.display = 'block';
|
|
resultDiv.innerHTML = '<div class="loading"><div class="spinner"></div> Testing connection...</div>';
|
|
|
|
try {
|
|
const result = await api('aws_test');
|
|
if (result.success) {
|
|
resultDiv.innerHTML = `
|
|
<div class="alert alert-success">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="20 6 9 17 4 12"/>
|
|
</svg>
|
|
<span>Connection successful! Found ${result.zones_count} hosted zone(s).</span>
|
|
</div>
|
|
`;
|
|
} else {
|
|
resultDiv.innerHTML = `
|
|
<div class="alert alert-danger">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<circle cx="12" cy="12" r="10"/>
|
|
<line x1="15" y1="9" x2="9" y2="15"/>
|
|
<line x1="9" y1="9" x2="15" y2="15"/>
|
|
</svg>
|
|
<span>${escapeHtml(result.error || 'Connection failed')}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
} catch (error) {
|
|
resultDiv.innerHTML = `
|
|
<div class="alert alert-danger">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<circle cx="12" cy="12" r="10"/>
|
|
<line x1="15" y1="9" x2="9" y2="15"/>
|
|
<line x1="9" y1="9" x2="15" y2="15"/>
|
|
</svg>
|
|
<span>Connection failed: ${escapeHtml(error.message)}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
// Load PTR zones into dropdown
|
|
async function loadPtrZones() {
|
|
const select = document.getElementById('ptrZoneSelect');
|
|
const notConfigured = document.getElementById('ptrNotConfigured');
|
|
const configured = document.getElementById('ptrConfigured');
|
|
const refreshAwsBtn = document.getElementById('refreshAwsBtn');
|
|
|
|
try {
|
|
const result = await api('aws_zones');
|
|
if (result.success && result.zones && result.zones.length > 0) {
|
|
notConfigured.style.display = 'none';
|
|
configured.style.display = 'block';
|
|
|
|
select.innerHTML = '<option value="">Select a zone...</option>';
|
|
result.zones.forEach(zone => {
|
|
const option = document.createElement('option');
|
|
option.value = zone.id;
|
|
option.textContent = zone.name + ' (' + zone.id + ')';
|
|
select.appendChild(option);
|
|
});
|
|
|
|
// Auto-select if only one zone is configured
|
|
if (result.zones.length === 1) {
|
|
select.value = result.zones[0].id;
|
|
loadCachedPtrRecords();
|
|
}
|
|
} else {
|
|
notConfigured.style.display = 'block';
|
|
configured.style.display = 'none';
|
|
}
|
|
} catch (error) {
|
|
notConfigured.style.display = 'block';
|
|
configured.style.display = 'none';
|
|
console.error('Failed to load PTR zones:', error);
|
|
}
|
|
}
|
|
|
|
// Load cached PTR records from database
|
|
async function loadCachedPtrRecords() {
|
|
const zoneId = document.getElementById('ptrZoneSelect').value;
|
|
const tbody = document.getElementById('ptrRecordsBody');
|
|
const countSpan = document.getElementById('ptrRecordCount');
|
|
const checkAllBtn = document.getElementById('checkAllPtrsBtn');
|
|
const refreshAwsBtn = document.getElementById('refreshAwsBtn');
|
|
const syncStatus = document.getElementById('ptrSyncStatus');
|
|
|
|
refreshAwsBtn.disabled = !zoneId;
|
|
|
|
if (!zoneId) {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="6" style="text-align: center; color: var(--text-tertiary); padding: 40px;">
|
|
Select a hosted zone to view A records
|
|
</td>
|
|
</tr>
|
|
`;
|
|
countSpan.textContent = '';
|
|
checkAllBtn.disabled = true;
|
|
syncStatus.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="6" style="text-align: center; padding: 40px;">
|
|
<div class="loading"><div class="spinner"></div></div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
|
|
try {
|
|
const result = await api('ptr_cache_get', { zone_id: zoneId });
|
|
if (result.success) {
|
|
const records = result.records || [];
|
|
countSpan.textContent = records.length + ' record(s)';
|
|
checkAllBtn.disabled = records.length === 0;
|
|
|
|
// Update sync status
|
|
if (records.length > 0) {
|
|
syncStatus.style.display = 'flex';
|
|
document.getElementById('lastAwsSync').textContent = result.last_sync ? formatDateTime(result.last_sync) : 'Never';
|
|
|
|
// Calculate stats
|
|
let matches = 0, mismatches = 0, missing = 0, unknown = 0;
|
|
let lastPtrCheck = null;
|
|
records.forEach(r => {
|
|
if (r.ptr_status === 'match') matches++;
|
|
else if (r.ptr_status === 'mismatch') mismatches++;
|
|
else if (r.ptr_status === 'missing') missing++;
|
|
else unknown++;
|
|
if (r.ptr_checked_at && (!lastPtrCheck || r.ptr_checked_at > lastPtrCheck)) {
|
|
lastPtrCheck = r.ptr_checked_at;
|
|
}
|
|
});
|
|
document.getElementById('lastPtrCheck').textContent = lastPtrCheck ? formatDateTime(lastPtrCheck) : 'Never';
|
|
document.getElementById('statMatch').textContent = matches + ' Match';
|
|
document.getElementById('statMismatch').textContent = mismatches + ' Mismatch';
|
|
document.getElementById('statMissing').textContent = missing + ' Missing';
|
|
document.getElementById('statUnknown').textContent = unknown + ' Unchecked';
|
|
} else {
|
|
syncStatus.style.display = 'none';
|
|
}
|
|
|
|
if (records.length === 0) {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="6" style="text-align: center; color: var(--text-tertiary); padding: 40px;">
|
|
No cached records. Click "Sync from AWS" to fetch A records.
|
|
</td>
|
|
</tr>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
// Store records globally for IPXO export
|
|
window.cachedPtrRecords = records;
|
|
document.getElementById('exportIpxoBtn').disabled = !records.some(r => r.ptr_status === 'mismatch' || r.ptr_status === 'missing');
|
|
|
|
tbody.innerHTML = records.map((record) => {
|
|
const statusColor = getStatusColor(record.ptr_status);
|
|
const statusLabel = (record.ptr_status || 'unknown').toUpperCase();
|
|
return `
|
|
<tr data-id="${record.id}" data-ip="${escapeHtml(record.ip_address)}" data-hostname="${escapeHtml(record.hostname)}" data-ttl="${record.ttl || 3600}" data-zone-id="${escapeHtml(record.zone_id || '')}">
|
|
<td><code style="font-size: 12px;">${escapeHtml(record.ip_address)}</code></td>
|
|
<td><span class="cell-truncate" title="${escapeHtml(record.hostname)}">${escapeHtml(record.hostname)}</span></td>
|
|
<td style="color: var(--text-secondary);">${record.ttl || '-'}</td>
|
|
<td class="ptr-value" style="color: ${record.ptr_record ? 'var(--text-primary)' : 'var(--text-tertiary)'};">${record.ptr_record || '-'}</td>
|
|
<td class="ptr-status">
|
|
<span style="display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; background: ${statusColor}; color: white;">${statusLabel}</span>
|
|
</td>
|
|
<td style="display: flex; gap: 4px;">
|
|
<button class="btn btn-secondary btn-sm" onclick="checkSinglePtr(${record.id}, this)" title="Check PTR">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
|
</svg>
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" onclick="editARecord(${record.id}, '${escapeHtml(record.hostname)}', '${escapeHtml(record.ip_address)}', ${record.ttl || 3600})" title="Edit A Record">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
|
</svg>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`}).join('');
|
|
} else {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="6" style="text-align: center; color: var(--error); padding: 40px;">
|
|
${escapeHtml(result.error || 'Failed to load records')}
|
|
</td>
|
|
</tr>
|
|
`;
|
|
countSpan.textContent = '';
|
|
checkAllBtn.disabled = true;
|
|
syncStatus.style.display = 'none';
|
|
}
|
|
} catch (error) {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="6" style="text-align: center; color: var(--error); padding: 40px;">
|
|
Failed to load records: ${escapeHtml(error.message)}
|
|
</td>
|
|
</tr>
|
|
`;
|
|
countSpan.textContent = '';
|
|
syncStatus.style.display = 'none';
|
|
checkAllBtn.disabled = true;
|
|
}
|
|
}
|
|
|
|
// Helper function to get status color
|
|
function getStatusColor(status) {
|
|
switch (status) {
|
|
case 'match': return 'var(--success)';
|
|
case 'mismatch': return 'var(--warning)';
|
|
case 'missing': return 'var(--error)';
|
|
case 'error': return 'var(--error)';
|
|
default: return 'var(--text-tertiary)';
|
|
}
|
|
}
|
|
|
|
// Format date time for display
|
|
function formatDateTime(dateStr) {
|
|
if (!dateStr) return '-';
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleString();
|
|
}
|
|
|
|
// Refresh from AWS - sync A records from Route53
|
|
async function refreshFromAws() {
|
|
const zoneId = document.getElementById('ptrZoneSelect').value;
|
|
if (!zoneId) {
|
|
showToast('Please select a zone first', 'warning');
|
|
return;
|
|
}
|
|
|
|
const refreshAwsBtn = document.getElementById('refreshAwsBtn');
|
|
refreshAwsBtn.disabled = true;
|
|
refreshAwsBtn.innerHTML = '<div class="spinner" style="width: 14px; height: 14px;"></div> Syncing...';
|
|
|
|
try {
|
|
const result = await api('ptr_cache_refresh', { zone_id: zoneId });
|
|
if (result.success) {
|
|
showToast(`AWS sync complete: ${result.message}`, 'success');
|
|
await loadCachedPtrRecords();
|
|
} else {
|
|
showToast(result.error || 'Failed to sync from AWS', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Failed to sync from AWS: ' + error.message, 'error');
|
|
}
|
|
|
|
refreshAwsBtn.disabled = false;
|
|
refreshAwsBtn.innerHTML = `
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
|
<path d="M3 3v5h5"/>
|
|
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
|
|
<path d="M16 21h5v-5"/>
|
|
</svg>
|
|
Sync from AWS
|
|
`;
|
|
}
|
|
|
|
// Check single PTR record (for cached records)
|
|
async function checkSinglePtr(recordId, button) {
|
|
const row = button.closest('tr');
|
|
const ptrValueCell = row.querySelector('.ptr-value');
|
|
const ptrStatusCell = row.querySelector('.ptr-status');
|
|
const ip = row.getAttribute('data-ip');
|
|
|
|
button.disabled = true;
|
|
button.innerHTML = '<div class="spinner" style="width: 14px; height: 14px;"></div>';
|
|
|
|
try {
|
|
const result = await api('ptr_lookup', { ip: ip });
|
|
if (result.success) {
|
|
const ptr = result.ptr || '';
|
|
ptrValueCell.textContent = ptr || '(none)';
|
|
ptrValueCell.style.color = ptr ? 'var(--text-primary)' : 'var(--text-tertiary)';
|
|
|
|
const status = result.status || 'unknown';
|
|
const statusColor = getStatusColor(status);
|
|
const statusLabel = status.toUpperCase();
|
|
|
|
ptrStatusCell.innerHTML = `<span style="display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; background: ${statusColor}; color: white;">${statusLabel}</span>`;
|
|
} else {
|
|
ptrValueCell.textContent = 'Error';
|
|
ptrValueCell.style.color = 'var(--error)';
|
|
ptrStatusCell.innerHTML = `<span style="display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; background: var(--error); color: white;">ERROR</span>`;
|
|
}
|
|
} catch (error) {
|
|
ptrValueCell.textContent = 'Error';
|
|
ptrValueCell.style.color = 'var(--error)';
|
|
ptrStatusCell.innerHTML = `<span style="display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; background: var(--error); color: white;">ERROR</span>`;
|
|
}
|
|
|
|
button.disabled = false;
|
|
button.innerHTML = `
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
|
</svg>
|
|
Check
|
|
`;
|
|
}
|
|
|
|
// Check all PTR records (batch via API)
|
|
async function checkAllPtrs() {
|
|
const zoneId = document.getElementById('ptrZoneSelect').value;
|
|
if (!zoneId) {
|
|
showToast('Please select a zone first', 'warning');
|
|
return;
|
|
}
|
|
|
|
const checkAllBtn = document.getElementById('checkAllPtrsBtn');
|
|
checkAllBtn.disabled = true;
|
|
checkAllBtn.innerHTML = '<div class="spinner" style="width: 14px; height: 14px;"></div> Checking...';
|
|
|
|
try {
|
|
const result = await api('ptr_check_all', { zone_id: zoneId });
|
|
if (result.success) {
|
|
showToast(`Checked ${result.checked} PTR(s): ${result.matches} match, ${result.mismatches} mismatch, ${result.missing} missing`, 'success');
|
|
await loadCachedPtrRecords();
|
|
} else {
|
|
showToast(result.error || 'Failed to check PTRs', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Failed to check PTRs: ' + error.message, 'error');
|
|
}
|
|
|
|
checkAllBtn.disabled = false;
|
|
checkAllBtn.innerHTML = `
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
|
</svg>
|
|
Check All PTRs
|
|
`;
|
|
}
|
|
|
|
// Edit A Record modal functions
|
|
function editARecord(id, hostname, ip, ttl) {
|
|
document.getElementById('editARecordId').value = id;
|
|
document.getElementById('editARecordOldHostname').value = hostname;
|
|
document.getElementById('editARecordHostname').value = hostname;
|
|
document.getElementById('editARecordIp').value = ip;
|
|
document.getElementById('editARecordTtl').value = ttl || 3600;
|
|
document.getElementById('editARecordModal').classList.add('active');
|
|
}
|
|
|
|
function closeEditARecordModal() {
|
|
document.getElementById('editARecordModal').classList.remove('active');
|
|
}
|
|
|
|
async function saveARecord(event) {
|
|
event.preventDefault();
|
|
|
|
const zoneId = document.getElementById('ptrZoneSelect').value;
|
|
if (!zoneId) {
|
|
showToast('No zone selected', 'error');
|
|
return;
|
|
}
|
|
|
|
const id = document.getElementById('editARecordId').value;
|
|
const oldHostname = document.getElementById('editARecordOldHostname').value;
|
|
const hostname = document.getElementById('editARecordHostname').value;
|
|
const ip = document.getElementById('editARecordIp').value;
|
|
const ttl = parseInt(document.getElementById('editARecordTtl').value) || 3600;
|
|
|
|
const saveBtn = document.getElementById('saveARecordBtn');
|
|
saveBtn.disabled = true;
|
|
saveBtn.innerHTML = '<div class="spinner" style="width: 14px; height: 14px;"></div> Updating...';
|
|
|
|
try {
|
|
const result = await api('aws_update_a_record', {}, 'POST', {
|
|
zone_id: zoneId,
|
|
old_hostname: oldHostname,
|
|
hostname: hostname,
|
|
ip: ip,
|
|
ttl: ttl
|
|
});
|
|
|
|
if (result.success) {
|
|
showToast('A record updated successfully', 'success');
|
|
closeEditARecordModal();
|
|
// Refresh the records from AWS
|
|
await refreshFromAws();
|
|
} else {
|
|
showToast(result.error || 'Failed to update A record', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Failed to update A record: ' + error.message, 'error');
|
|
}
|
|
|
|
saveBtn.disabled = false;
|
|
saveBtn.innerHTML = `
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
|
|
<polyline points="17 21 17 13 7 13 7 21"/>
|
|
<polyline points="7 3 7 8 15 8"/>
|
|
</svg>
|
|
Update A Record
|
|
`;
|
|
}
|
|
|
|
// Export IPXO JSON for mismatched/missing PTRs
|
|
function exportIpxoJson() {
|
|
const records = window.cachedPtrRecords || [];
|
|
const mismatchedOrMissing = records.filter(r => r.ptr_status === 'mismatch' || r.ptr_status === 'missing');
|
|
|
|
if (mismatchedOrMissing.length === 0) {
|
|
showToast('No mismatched or missing PTR records to export', 'warning');
|
|
return;
|
|
}
|
|
|
|
// Group records by /24 subnet
|
|
const subnets = {};
|
|
mismatchedOrMissing.forEach(record => {
|
|
const ip = record.ip_address;
|
|
const parts = ip.split('.');
|
|
if (parts.length === 4) {
|
|
const subnet = `${parts[0]}.${parts[1]}.${parts[2]}.0/24`;
|
|
|
|
if (!subnets[subnet]) {
|
|
subnets[subnet] = [];
|
|
}
|
|
subnets[subnet].push({
|
|
address: ip,
|
|
dname: record.hostname,
|
|
ttl: record.ttl || 3600
|
|
});
|
|
}
|
|
});
|
|
|
|
// Download a separate JSON file for each /24 subnet
|
|
const subnetKeys = Object.keys(subnets).sort();
|
|
let downloadCount = 0;
|
|
|
|
subnetKeys.forEach((subnet, index) => {
|
|
const ipxoData = {
|
|
subnets: [{
|
|
prefix: subnet,
|
|
records: subnets[subnet]
|
|
}]
|
|
};
|
|
|
|
const jsonStr = JSON.stringify(ipxoData, null, 2);
|
|
const blob = new Blob([jsonStr], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
// Create filename from subnet (replace / and . for valid filename)
|
|
const filename = `ipxo-ptr-${subnet.replace(/\//g, '-').replace(/\./g, '_')}.json`;
|
|
|
|
// Stagger downloads slightly to avoid browser blocking
|
|
setTimeout(() => {
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
downloadCount++;
|
|
|
|
if (downloadCount === subnetKeys.length) {
|
|
showToast(`Downloaded ${subnetKeys.length} IPXO JSON file(s) for ${mismatchedOrMissing.length} PTR record(s)`, 'success');
|
|
}
|
|
}, index * 100);
|
|
});
|
|
}
|
|
|
|
// Clear error logs
|
|
async function clearErrorLogs() {
|
|
if (!confirm('Are you sure you want to clear the error log? This action cannot be undone.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await api('error_logs_clear', {}, 'POST', {});
|
|
|
|
if (result.success) {
|
|
showToast(result.message, 'success');
|
|
loadErrorLogs();
|
|
} else {
|
|
showToast(result.error || 'Failed to clear error logs', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Failed to clear error logs: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Close modals on escape key
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
closeModal();
|
|
closeDeleteModal();
|
|
closeClearAllModal();
|
|
closeInfoModal();
|
|
}
|
|
});
|
|
|
|
// Close modals on overlay click
|
|
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
|
overlay.addEventListener('click', (e) => {
|
|
if (e.target === overlay) {
|
|
closeModal();
|
|
closeDeleteModal();
|
|
closeClearAllModal();
|
|
closeInfoModal();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Prevent zoom on iOS
|
|
document.addEventListener('gesturestart', function(e) {
|
|
e.preventDefault();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|