added hostname option
This commit is contained in:
263
webapp/api.php
263
webapp/api.php
@@ -142,6 +142,18 @@ try {
|
||||
handleLogout();
|
||||
break;
|
||||
|
||||
case 'database_backup':
|
||||
handleDatabaseBackup($db);
|
||||
break;
|
||||
|
||||
case 'database_import':
|
||||
handleDatabaseImport($db);
|
||||
break;
|
||||
|
||||
case 'system_info':
|
||||
handleSystemInfo($db);
|
||||
break;
|
||||
|
||||
default:
|
||||
jsonResponse(['error' => 'Invalid action'], 400);
|
||||
}
|
||||
@@ -1429,3 +1441,254 @@ function handleLogout() {
|
||||
logoutUser();
|
||||
jsonResponse(['success' => true, 'redirect' => 'login.php']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export full database backup as JSON
|
||||
*/
|
||||
function handleDatabaseBackup($db) {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all geofeed entries
|
||||
$entries = $db->query("SELECT * FROM geofeed_entries ORDER BY id")->fetchAll();
|
||||
|
||||
// Get all settings
|
||||
$settings = $db->query("SELECT * FROM settings ORDER BY setting_key")->fetchAll();
|
||||
|
||||
// Get audit log (last 1000 entries)
|
||||
$auditLog = $db->query("SELECT * FROM geofeed_audit_log ORDER BY id DESC LIMIT 1000")->fetchAll();
|
||||
|
||||
// Get client logos
|
||||
$logos = $db->query("SELECT * FROM client_logos ORDER BY short_name")->fetchAll();
|
||||
|
||||
$backup = [
|
||||
'backup_info' => [
|
||||
'created_at' => date('c'),
|
||||
'app_version' => APP_VERSION,
|
||||
'app_name' => APP_NAME,
|
||||
'entry_count' => count($entries),
|
||||
'settings_count' => count($settings),
|
||||
'audit_log_count' => count($auditLog),
|
||||
'logos_count' => count($logos)
|
||||
],
|
||||
'geofeed_entries' => $entries,
|
||||
'settings' => $settings,
|
||||
'audit_log' => $auditLog,
|
||||
'client_logos' => $logos
|
||||
];
|
||||
|
||||
// Set headers for file download
|
||||
header('Content-Type: application/json');
|
||||
header('Content-Disposition: attachment; filename="geofeed_backup_' . date('Y-m-d_His') . '.json"');
|
||||
header('Cache-Control: no-cache, must-revalidate');
|
||||
|
||||
echo json_encode($backup, JSON_PRETTY_PRINT);
|
||||
exit;
|
||||
|
||||
} catch (Exception $e) {
|
||||
jsonResponse(['error' => 'Backup failed: ' . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import database from JSON backup
|
||||
*/
|
||||
function handleDatabaseImport($db) {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonResponse(['error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
$input = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
// Validate CSRF
|
||||
if (!validateCSRFToken($input['csrf_token'] ?? '')) {
|
||||
jsonResponse(['error' => 'Invalid CSRF token'], 403);
|
||||
}
|
||||
|
||||
if (empty($input['backup_data'])) {
|
||||
jsonResponse(['error' => 'No backup data provided'], 400);
|
||||
}
|
||||
|
||||
$backup = $input['backup_data'];
|
||||
|
||||
// Validate backup structure
|
||||
if (!isset($backup['backup_info']) || !isset($backup['geofeed_entries'])) {
|
||||
jsonResponse(['error' => 'Invalid backup file format'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$db->beginTransaction();
|
||||
|
||||
$importedEntries = 0;
|
||||
$importedSettings = 0;
|
||||
$importedLogos = 0;
|
||||
|
||||
// Clear existing entries if backup contains entries
|
||||
if (!empty($backup['geofeed_entries'])) {
|
||||
$db->exec("DELETE FROM geofeed_entries");
|
||||
|
||||
// Re-insert entries
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO geofeed_entries
|
||||
(ip_prefix, country_code, region_code, city, postal_code, client_short_name, notes, sort_order,
|
||||
ipr_enriched_at, ipr_hostname, ipr_isp, ipr_org, ipr_asn, ipr_asn_name, ipr_connection_type,
|
||||
ipr_country_name, ipr_region_name, ipr_timezone, ipr_latitude, ipr_longitude,
|
||||
flag_abuser, flag_attacker, flag_bogon, flag_cloud_provider, flag_proxy,
|
||||
flag_relay, flag_tor, flag_tor_exit, flag_vpn, flag_anonymous, flag_threat,
|
||||
created_at, updated_at)
|
||||
VALUES
|
||||
(:ip_prefix, :country_code, :region_code, :city, :postal_code, :client_short_name, :notes, :sort_order,
|
||||
:ipr_enriched_at, :ipr_hostname, :ipr_isp, :ipr_org, :ipr_asn, :ipr_asn_name, :ipr_connection_type,
|
||||
:ipr_country_name, :ipr_region_name, :ipr_timezone, :ipr_latitude, :ipr_longitude,
|
||||
:flag_abuser, :flag_attacker, :flag_bogon, :flag_cloud_provider, :flag_proxy,
|
||||
:flag_relay, :flag_tor, :flag_tor_exit, :flag_vpn, :flag_anonymous, :flag_threat,
|
||||
:created_at, :updated_at)
|
||||
");
|
||||
|
||||
foreach ($backup['geofeed_entries'] as $entry) {
|
||||
$stmt->execute([
|
||||
':ip_prefix' => $entry['ip_prefix'] ?? null,
|
||||
':country_code' => $entry['country_code'] ?? null,
|
||||
':region_code' => $entry['region_code'] ?? null,
|
||||
':city' => $entry['city'] ?? null,
|
||||
':postal_code' => $entry['postal_code'] ?? null,
|
||||
':client_short_name' => $entry['client_short_name'] ?? null,
|
||||
':notes' => $entry['notes'] ?? null,
|
||||
':sort_order' => $entry['sort_order'] ?? 0,
|
||||
':ipr_enriched_at' => $entry['ipr_enriched_at'] ?? null,
|
||||
':ipr_hostname' => $entry['ipr_hostname'] ?? null,
|
||||
':ipr_isp' => $entry['ipr_isp'] ?? null,
|
||||
':ipr_org' => $entry['ipr_org'] ?? null,
|
||||
':ipr_asn' => $entry['ipr_asn'] ?? null,
|
||||
':ipr_asn_name' => $entry['ipr_asn_name'] ?? null,
|
||||
':ipr_connection_type' => $entry['ipr_connection_type'] ?? null,
|
||||
':ipr_country_name' => $entry['ipr_country_name'] ?? null,
|
||||
':ipr_region_name' => $entry['ipr_region_name'] ?? null,
|
||||
':ipr_timezone' => $entry['ipr_timezone'] ?? null,
|
||||
':ipr_latitude' => $entry['ipr_latitude'] ?? null,
|
||||
':ipr_longitude' => $entry['ipr_longitude'] ?? null,
|
||||
':flag_abuser' => $entry['flag_abuser'] ?? 0,
|
||||
':flag_attacker' => $entry['flag_attacker'] ?? 0,
|
||||
':flag_bogon' => $entry['flag_bogon'] ?? 0,
|
||||
':flag_cloud_provider' => $entry['flag_cloud_provider'] ?? 0,
|
||||
':flag_proxy' => $entry['flag_proxy'] ?? 0,
|
||||
':flag_relay' => $entry['flag_relay'] ?? 0,
|
||||
':flag_tor' => $entry['flag_tor'] ?? 0,
|
||||
':flag_tor_exit' => $entry['flag_tor_exit'] ?? 0,
|
||||
':flag_vpn' => $entry['flag_vpn'] ?? 0,
|
||||
':flag_anonymous' => $entry['flag_anonymous'] ?? 0,
|
||||
':flag_threat' => $entry['flag_threat'] ?? 0,
|
||||
':created_at' => $entry['created_at'] ?? date('Y-m-d H:i:s'),
|
||||
':updated_at' => $entry['updated_at'] ?? date('Y-m-d H:i:s')
|
||||
]);
|
||||
$importedEntries++;
|
||||
}
|
||||
}
|
||||
|
||||
// Import settings
|
||||
if (!empty($backup['settings'])) {
|
||||
foreach ($backup['settings'] as $setting) {
|
||||
saveSetting($db, $setting['setting_key'], $setting['setting_value']);
|
||||
$importedSettings++;
|
||||
}
|
||||
}
|
||||
|
||||
// Import client logos
|
||||
if (!empty($backup['client_logos'])) {
|
||||
$db->exec("DELETE FROM client_logos");
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO client_logos (short_name, logo_data, mime_type, created_at, updated_at)
|
||||
VALUES (:short_name, :logo_data, :mime_type, :created_at, :updated_at)
|
||||
");
|
||||
|
||||
foreach ($backup['client_logos'] as $logo) {
|
||||
$stmt->execute([
|
||||
':short_name' => $logo['short_name'],
|
||||
':logo_data' => $logo['logo_data'],
|
||||
':mime_type' => $logo['mime_type'] ?? 'image/png',
|
||||
':created_at' => $logo['created_at'] ?? date('Y-m-d H:i:s'),
|
||||
':updated_at' => $logo['updated_at'] ?? date('Y-m-d H:i:s')
|
||||
]);
|
||||
$importedLogos++;
|
||||
}
|
||||
}
|
||||
|
||||
// Log the import action
|
||||
logAudit($db, null, 'database_import', null, [
|
||||
'entries_imported' => $importedEntries,
|
||||
'settings_imported' => $importedSettings,
|
||||
'logos_imported' => $importedLogos,
|
||||
'backup_date' => $backup['backup_info']['created_at'] ?? 'unknown'
|
||||
]);
|
||||
|
||||
$db->commit();
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Database restored successfully',
|
||||
'imported' => [
|
||||
'entries' => $importedEntries,
|
||||
'settings' => $importedSettings,
|
||||
'logos' => $importedLogos
|
||||
]
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
$db->rollBack();
|
||||
jsonResponse(['error' => 'Import failed: ' . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get system information for developer tab
|
||||
*/
|
||||
function handleSystemInfo($db) {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||
jsonResponse(['error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
try {
|
||||
// Get database stats
|
||||
$entryCount = $db->query("SELECT COUNT(*) FROM geofeed_entries")->fetchColumn();
|
||||
$enrichedCount = $db->query("SELECT COUNT(*) FROM geofeed_entries WHERE ipr_enriched_at IS NOT NULL")->fetchColumn();
|
||||
$settingsCount = $db->query("SELECT COUNT(*) FROM settings")->fetchColumn();
|
||||
$auditCount = $db->query("SELECT COUNT(*) FROM geofeed_audit_log")->fetchColumn();
|
||||
$logosCount = $db->query("SELECT COUNT(*) FROM client_logos")->fetchColumn();
|
||||
|
||||
// Get database size
|
||||
$dbSize = $db->query("
|
||||
SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) as size_mb
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = '" . DB_NAME . "'
|
||||
")->fetchColumn();
|
||||
|
||||
jsonResponse([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'app_version' => APP_VERSION,
|
||||
'php_version' => PHP_VERSION,
|
||||
'database' => [
|
||||
'name' => DB_NAME,
|
||||
'host' => DB_HOST,
|
||||
'size_mb' => $dbSize ?: 0,
|
||||
'entries' => (int)$entryCount,
|
||||
'enriched_entries' => (int)$enrichedCount,
|
||||
'settings' => (int)$settingsCount,
|
||||
'audit_log_entries' => (int)$auditCount,
|
||||
'client_logos' => (int)$logosCount
|
||||
],
|
||||
'server' => [
|
||||
'software' => $_SERVER['SERVER_SOFTWARE'] ?? 'Unknown',
|
||||
'time' => date('c'),
|
||||
'timezone' => date_default_timezone_get()
|
||||
]
|
||||
]
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
jsonResponse(['error' => 'Failed to get system info: ' . $e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
242
webapp/index.php
242
webapp/index.php
@@ -1556,6 +1556,13 @@ if (function_exists('requireAuth')) {
|
||||
</svg>
|
||||
Advanced
|
||||
</button>
|
||||
<button class="tab" onclick="switchTab('developer')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="16 18 22 12 16 6"/>
|
||||
<polyline points="8 6 2 12 8 18"/>
|
||||
</svg>
|
||||
Developer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Entries Tab -->
|
||||
@@ -1900,6 +1907,89 @@ if (function_exists('requireAuth')) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Developer Tab -->
|
||||
<div class="tab-content" id="tab-developer">
|
||||
<!-- Database Backup Section -->
|
||||
<div class="advanced-section">
|
||||
<h2 class="advanced-section-title">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Database Backup
|
||||
</h2>
|
||||
<p class="advanced-section-desc">Export a full backup of the database including all entries, settings, and audit logs as a JSON file.</p>
|
||||
|
||||
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 16px;">
|
||||
<button class="btn btn-primary" onclick="downloadDatabaseBackup()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" y1="15" x2="12" y2="3"/>
|
||||
</svg>
|
||||
Download Full Backup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database Import Section -->
|
||||
<div class="advanced-section">
|
||||
<h2 class="advanced-section-title">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
Database Import
|
||||
</h2>
|
||||
<p class="advanced-section-desc">Restore the database from a previously exported backup file. This will replace all existing data.</p>
|
||||
|
||||
<div style="margin-top: 16px;">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Select Backup File (JSON)</label>
|
||||
<input type="file" id="dbImportFile" accept=".json" class="form-input" style="padding: 8px;">
|
||||
</div>
|
||||
<div style="display: flex; gap: 12px; flex-wrap: wrap; margin-top: 12px;">
|
||||
<button class="btn btn-warning" onclick="importDatabaseBackup()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="17 8 12 3 7 8"/>
|
||||
<line x1="12" y1="3" x2="12" y2="15"/>
|
||||
</svg>
|
||||
Import Backup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning" style="margin-top: 16px;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||
<line x1="12" y1="9" x2="12" y2="13"/>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||
</svg>
|
||||
<span>Warning: Importing a backup will permanently replace all existing data. Make sure to download a backup first.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
@@ -2068,6 +2158,11 @@ if (function_exists('requireAuth')) {
|
||||
loadWebhookQueueStatus();
|
||||
loadIpRegistrySettings();
|
||||
}
|
||||
|
||||
// Load data for developer tab
|
||||
if (tab === 'developer') {
|
||||
loadSystemInfo();
|
||||
}
|
||||
}
|
||||
|
||||
// API Helper
|
||||
@@ -3226,6 +3321,153 @@ if (function_exists('requireAuth')) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Logout function
|
||||
async function logout() {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user