Add DNS monitoring and refresh functionality

Introduce DNS monitoring: add DnsService (comprehensive DNS lookup, crt.sh discovery, Cloudflare detection, IP enrichment) and a new DnsRecord model to persist snapshots, manage diffs, and provide queries/stats. Update DomainController to support a dns_monitoring_enabled flag, refactor WHOIS/DNS refresh logic into performWhoisRefresh/performDnsRefresh, and add endpoints for refreshWhois, refreshDns and refreshAll; send notifications when DNS monitoring is toggled. Add UI templates/tabs for DNS, billing, notifications, overview, SSL and WHOIS and wire DNS data into the domain view; expose cached IP details. Add cron/check_dns.php and migration 027_add_dns_monitoring.sql (and include it in installer migration lists). Other tweaks: safer EmailHelper subject handling, TldRegistry search improvements, domain sorting using an effective status (expiring_soon), Discord channel null-safe fields, settings UI additions (domain_view_template and cron staleness warnings), and route/migration updates. This enables scheduled and manual DNS scans with persistent records and notifications.
This commit is contained in:
Hosteroid
2026-03-08 14:32:05 +02:00
parent db094d6d8b
commit 8559e903b9
29 changed files with 4493 additions and 100 deletions

View File

@@ -608,6 +608,7 @@ class DomainController extends Controller
$groupId = !empty($_POST['notification_group_id']) ? (int)$_POST['notification_group_id'] : null;
$isActive = isset($_POST['is_active']) ? 1 : 0;
$dnsMonitoringEnabled = isset($_POST['dns_monitoring_enabled']) ? 1 : 0;
$tagsInput = trim($_POST['tags'] ?? '');
$manualExpirationDate = !empty($_POST['manual_expiration_date']) ? $_POST['manual_expiration_date'] : null;
@@ -642,6 +643,7 @@ class DomainController extends Controller
$this->domainModel->update($id, [
'notification_group_id' => $groupId,
'is_active' => $isActive,
'dns_monitoring_enabled' => $dnsMonitoringEnabled,
'expiration_date' => $manualExpirationDate
]);
@@ -663,6 +665,24 @@ class DomainController extends Controller
$notificationService->sendToGroup($groupId, $subject, $message);
}
// Send notification if DNS monitoring changed and has notification group
$dnsMonitoringChanged = (($domain['dns_monitoring_enabled'] ?? 1) != $dnsMonitoringEnabled);
if ($dnsMonitoringChanged && $groupId) {
$notificationService = new \App\Services\NotificationService();
if ($dnsMonitoringEnabled) {
$message = "🟢 DNS monitoring has been ENABLED for {$domain['domain_name']}\n\n" .
"DNS records will be checked for changes and you'll receive alerts when they change.";
$subject = "✅ DNS Monitoring Enabled: {$domain['domain_name']}";
} else {
$message = "🔴 DNS monitoring has been DISABLED for {$domain['domain_name']}\n\n" .
"DNS records will no longer be checked. You will not receive DNS change alerts.";
$subject = "⏸️ DNS Monitoring Disabled: {$domain['domain_name']}";
}
$notificationService->sendToGroup($groupId, $subject, $message);
}
// Also send notification if group changed and monitoring is active
if (!$statusChanged && $isActive && $oldGroupId != $groupId) {
@@ -697,50 +717,26 @@ class DomainController extends Controller
$this->redirect('/domains/' . $id);
}
public function refresh($params = [])
/**
* Perform WHOIS lookup and persist results.
* @return string|null Status message on success, null on failure.
*/
private function performWhoisRefresh(int $id, array $domain): ?string
{
$id = $params['id'] ?? 0;
$domain = $this->checkDomainAccess($id);
if (!$domain) {
$_SESSION['error'] = 'Domain not found';
$this->redirect('/domains');
return;
}
// Log domain refresh start
$logger = new \App\Services\Logger();
$logger->info('Domain refresh started', [
'domain_id' => $id,
'domain_name' => $domain['domain_name'],
'user_id' => \Core\Auth::id(),
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? 'unknown'
]);
// Get fresh WHOIS information
$whoisData = $this->whoisService->getDomainInfo($domain['domain_name']);
if (!$whoisData) {
$logger->error('Domain refresh failed - WHOIS data not retrieved', [
$logger->error('WHOIS refresh failed', [
'domain_id' => $id,
'domain_name' => $domain['domain_name'],
'user_id' => \Core\Auth::id()
]);
$_SESSION['error'] = 'Could not retrieve WHOIS information';
// Check if we came from view page
$referer = $_SERVER['HTTP_REFERER'] ?? '';
if (strpos($referer, '/domains/' . $id) !== false) {
$this->redirect('/domains/' . $id);
} else {
$this->redirect('/domains');
}
return;
return null;
}
// Use WHOIS expiration date if available, otherwise preserve manual expiration date
$expirationDate = $whoisData['expiration_date'] ?? $domain['expiration_date'];
$status = $this->whoisService->getDomainStatus($expirationDate, $whoisData['status'] ?? [], $whoisData);
$this->domainModel->update($id, [
@@ -754,8 +750,7 @@ class DomainController extends Controller
'whois_data' => json_encode($whoisData)
]);
// Log successful domain refresh
$logger->info('Domain refresh completed successfully', [
$logger->info('WHOIS refresh completed', [
'domain_id' => $id,
'domain_name' => $domain['domain_name'],
'new_status' => $status,
@@ -764,19 +759,129 @@ class DomainController extends Controller
'user_id' => \Core\Auth::id()
]);
$_SESSION['success'] = 'Domain information refreshed';
// Check if we came from view page or list page
return 'WHOIS updated';
}
/**
* Perform DNS lookup and persist results.
* @return string Status message (always returns, even on zero records).
*/
private function performDnsRefresh(int $id, array $domain): string
{
$logger = new \App\Services\Logger('dns');
$dnsService = new \App\Services\DnsService();
$dnsModel = new \App\Models\DnsRecord();
// Feed previously known hosts so manual refresh doesn't lose crt.sh-discovered subdomains
$existingHosts = $dnsModel->getDistinctHosts($id);
$records = $dnsService->lookup($domain['domain_name'], $existingHosts);
$totalRecords = array_sum(array_map('count', $records));
if ($totalRecords === 0) {
$logger->warning('DNS refresh returned no records', [
'domain_name' => $domain['domain_name'],
]);
return 'DNS: no records found';
}
// Enrich A/AAAA records with IP details (PTR, ASN, geo) and store in raw_data
$ips = [];
foreach (['A', 'AAAA'] as $type) {
if (!empty($records[$type])) {
foreach ($records[$type] as $r) {
if (!empty($r['value'])) {
$ips[] = $r['value'];
}
}
}
}
if (!empty($ips)) {
$ipDetails = $dnsService->lookupIpDetails($ips);
foreach (['A', 'AAAA'] as $type) {
if (!empty($records[$type])) {
foreach ($records[$type] as &$rec) {
if (!empty($rec['value']) && isset($ipDetails[$rec['value']])) {
$rec['raw']['_ip_info'] = $ipDetails[$rec['value']];
}
}
unset($rec);
}
}
}
$stats = $dnsModel->saveSnapshot($id, $records);
$this->domainModel->update($id, ['dns_last_checked' => date('Y-m-d H:i:s')]);
$logger->info('DNS refresh completed', [
'domain_name' => $domain['domain_name'],
'total' => $totalRecords,
'added' => $stats['added'],
'updated' => $stats['updated'],
'removed' => $stats['removed'],
]);
return "DNS updated ({$totalRecords} records)";
}
/**
* Redirect back to the originating page (domain view or list).
*/
private function redirectBackToDomain(int $id, string $hash = ''): void
{
$referer = $_SERVER['HTTP_REFERER'] ?? '';
if (strpos($referer, '/domains/' . $id) !== false) {
// Came from view page, go back to view page
$this->redirect('/domains/' . $id);
$this->redirect('/domains/' . $id . $hash);
} else {
// Came from list page, stay on list page
$this->redirect('/domains');
}
}
public function refreshWhois($params = [])
{
$id = (int)($params['id'] ?? 0);
$domain = $this->checkDomainAccess($id);
if (!$domain) {
$_SESSION['error'] = 'Domain not found';
$this->redirect('/domains');
return;
}
$result = $this->performWhoisRefresh($id, $domain);
if ($result === null) {
$_SESSION['error'] = 'Could not retrieve WHOIS information';
} else {
$_SESSION['success'] = 'WHOIS information refreshed';
}
$this->redirectBackToDomain($id);
}
public function refreshAll($params = [])
{
$id = (int)($params['id'] ?? 0);
$domain = $this->checkDomainAccess($id);
if (!$domain) {
$_SESSION['error'] = 'Domain not found';
$this->redirect('/domains');
return;
}
$messages = [];
$messages[] = $this->performWhoisRefresh($id, $domain) ?? 'WHOIS failed';
if (!empty($domain['dns_monitoring_enabled'])) {
$messages[] = $this->performDnsRefresh($id, $domain);
} else {
$messages[] = 'DNS skipped (monitoring disabled)';
}
$_SESSION['success'] = 'Domain refreshed: ' . implode(', ', $messages);
$this->redirectBackToDomain($id);
}
public function delete($params = [])
{
$id = $params['id'] ?? 0;
@@ -842,11 +947,39 @@ class DomainController extends Controller
$availableTags = $tagModel->getAllWithUsage();
}
$this->view('domains/view', [
// Get DNS records for the DNS tab
$dnsModel = new \App\Models\DnsRecord();
$dnsRecords = $dnsModel->getByDomainGrouped($id);
$dnsRecordCount = $dnsModel->countByDomain($id);
$dnsHasCloudflare = $dnsModel->hasCloudflare($id);
// Extract cached IP details (PTR, ASN, geo) from stored raw_data
$dnsIpDetails = [];
foreach (['A', 'AAAA'] as $type) {
if (!empty($dnsRecords[$type])) {
foreach ($dnsRecords[$type] as $r) {
if (!empty($r['raw_data']) && !empty($r['value'])) {
$raw = json_decode($r['raw_data'], true);
if (!empty($raw['_ip_info'])) {
$dnsIpDetails[$r['value']] = $raw['_ip_info'];
}
}
}
}
}
$viewTemplate = $settingModel->getValue('domain_view_template', 'detailed');
$templateName = $viewTemplate === 'detailed' ? 'domains/view-detailed' : 'domains/view';
$this->view($templateName, [
'domain' => $formattedDomain,
'whoisData' => $whoisData,
'logs' => $logs,
'availableTags' => $availableTags,
'dnsRecords' => $dnsRecords,
'dnsRecordCount' => $dnsRecordCount,
'dnsHasCloudflare' => $dnsHasCloudflare,
'dnsIpDetails' => $dnsIpDetails,
'title' => $domain['domain_name']
]);
}
@@ -1279,9 +1412,14 @@ class DomainController extends Controller
// Validate notes length
$lengthError = \App\Helpers\InputValidator::validateLength($notes, 5000, 'Notes');
$settingModel = new \App\Models\Setting();
$viewTemplate = $settingModel->getValue('domain_view_template', 'detailed');
$redirect = '/domains/' . $id . ($viewTemplate === 'detailed' ? '#overview' : '');
if ($lengthError) {
$_SESSION['error'] = $lengthError;
$this->redirect('/domains/' . $id);
$this->redirect($redirect);
return;
}
@@ -1290,7 +1428,7 @@ class DomainController extends Controller
]);
$_SESSION['success'] = 'Notes updated successfully';
$this->redirect('/domains/' . $id);
$this->redirect($redirect);
}
public function bulkAddTags()
@@ -1615,6 +1753,32 @@ class DomainController extends Controller
$this->redirect('/domains');
}
// ========================================
// DNS MONITORING
// ========================================
public function refreshDns($params = [])
{
$id = (int)($params['id'] ?? 0);
$domain = $this->checkDomainAccess($id);
if (!$domain) {
$_SESSION['error'] = 'Domain not found';
$this->redirect('/domains');
return;
}
$result = $this->performDnsRefresh($id, $domain);
if (strpos($result, 'no records') !== false) {
$_SESSION['warning'] = 'No DNS records found for this domain';
} else {
$_SESSION['success'] = $result;
}
$this->redirectBackToDomain($id, '#dns');
}
/**
* Get tags for specific domains (API endpoint)
*/

View File

@@ -57,6 +57,7 @@ class InstallerController extends Controller
'024_add_status_notifications_v1.1.2.sql',
'025_add_update_system_v1.1.3.sql',
'026_update_app_version_v1.1.4.sql',
'027_add_dns_monitoring.sql',
];
try {
@@ -200,6 +201,7 @@ class InstallerController extends Controller
'024_add_status_notifications_v1.1.2.sql',
'025_add_update_system_v1.1.3.sql',
'026_update_app_version_v1.1.4.sql',
'027_add_dns_monitoring.sql',
];
}
@@ -423,6 +425,7 @@ class InstallerController extends Controller
'024_add_status_notifications_v1.1.2.sql',
'025_add_update_system_v1.1.3.sql',
'026_update_app_version_v1.1.4.sql',
'027_add_dns_monitoring.sql',
];
$stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration");

View File

@@ -136,6 +136,21 @@ class SettingsController extends Controller
// Rollback availability
$rollbackAvailable = !empty($updateSettings['update_backup_path']) && file_exists($updateSettings['update_backup_path']);
// Cron staleness: show warning if last run is overdue
$intervalHours = (int)($settings['check_interval_hours'] ?? 24);
$domainStaleThreshold = $intervalHours * 1.5; // e.g. 36h for 24h interval
$dnsStaleThreshold = 24; // DNS cron runs every 6h, 24h = overdue
$domainCronStale = false;
$dnsCronStale = false;
if (!empty($settings['last_check_run'])) {
$hoursSince = (time() - strtotime($settings['last_check_run'])) / 3600;
$domainCronStale = $hoursSince > $domainStaleThreshold;
}
if (!empty($settings['last_dns_check_run'])) {
$hoursSince = (time() - strtotime($settings['last_dns_check_run'])) / 3600;
$dnsCronStale = $hoursSince > $dnsStaleThreshold;
}
$this->view('settings/index', [
'settings' => $settings,
'appSettings' => $appSettings,
@@ -154,6 +169,8 @@ class SettingsController extends Controller
'cachedUpdateAvailable' => $cachedUpdateAvailable,
'cachedUpdateData' => $cachedUpdateData,
'rollbackAvailable' => $rollbackAvailable,
'domainCronStale' => $domainCronStale,
'dnsCronStale' => $dnsCronStale,
'title' => 'Settings'
]);
}
@@ -316,9 +333,16 @@ class SettingsController extends Controller
// Update registration settings
$registrationEnabled = isset($_POST['registration_enabled']) ? '1' : '0';
$requireEmailVerification = isset($_POST['require_email_verification']) ? '1' : '0';
$this->settingModel->setValue('registration_enabled', $registrationEnabled);
$this->settingModel->setValue('require_email_verification', $requireEmailVerification);
// Update domain view template
$viewTemplate = trim($_POST['domain_view_template'] ?? 'detailed');
if (!in_array($viewTemplate, ['legacy', 'detailed'])) {
$viewTemplate = 'detailed';
}
$this->settingModel->setValue('domain_view_template', $viewTemplate);
$_SESSION['success'] = 'Application settings updated successfully';
$this->redirect('/settings#app');

View File

@@ -205,6 +205,7 @@ class TwoFactorController extends Controller
$this->view('2fa/verify', [
'user' => $user,
'canSendEmailCode' => !empty($user['email_verified']),
'title' => 'Two-Factor Authentication'
]);
}

View File

@@ -511,7 +511,10 @@ class EmailHelper
public static function getEmailSubject(array $data): string
{
if (isset($data['domain'])) {
$daysLeft = $data['days_left'];
$daysLeft = $data['days_left'] ?? null;
if ($daysLeft === null) {
return "⚠️ Domain Expiration Alert: {$data['domain']}";
}
if ($daysLeft <= 0) {
return "🚨 URGENT: Domain {$data['domain']} has EXPIRED";
}

216
app/Models/DnsRecord.php Normal file
View File

@@ -0,0 +1,216 @@
<?php
namespace App\Models;
use Core\Model;
class DnsRecord extends Model
{
protected static string $table = 'dns_records';
/**
* Get all DNS records for a domain, grouped by type
*/
public function getByDomainGrouped(int $domainId): array
{
$stmt = $this->db->prepare(
"SELECT * FROM dns_records WHERE domain_id = ? ORDER BY record_type ASC, host ASC, priority ASC"
);
$stmt->execute([$domainId]);
$rows = $stmt->fetchAll();
$grouped = [];
foreach ($rows as $row) {
$type = $row['record_type'];
if (!isset($grouped[$type])) {
$grouped[$type] = [];
}
$grouped[$type][] = $row;
}
return $grouped;
}
/**
* Get all DNS records for a domain (flat list)
*/
public function getByDomain(int $domainId): array
{
$stmt = $this->db->prepare(
"SELECT * FROM dns_records WHERE domain_id = ? ORDER BY record_type ASC, host ASC"
);
$stmt->execute([$domainId]);
return $stmt->fetchAll();
}
/**
* Count DNS records for a domain
*/
public function countByDomain(int $domainId): int
{
$stmt = $this->db->prepare("SELECT COUNT(*) FROM dns_records WHERE domain_id = ? AND record_type != 'SOA'");
$stmt->execute([$domainId]);
return (int)$stmt->fetchColumn();
}
/**
* Get distinct non-root host labels for a domain.
* Used to preserve previously discovered subdomains across refreshes.
*/
public function getDistinctHosts(int $domainId): array
{
$stmt = $this->db->prepare(
"SELECT DISTINCT host FROM dns_records WHERE domain_id = ? AND host != '@'"
);
$stmt->execute([$domainId]);
return array_column($stmt->fetchAll(), 'host');
}
/**
* Check if a domain has any Cloudflare-proxied records
*/
public function hasCloudflare(int $domainId): bool
{
$stmt = $this->db->prepare(
"SELECT COUNT(*) FROM dns_records WHERE domain_id = ? AND is_cloudflare = 1"
);
$stmt->execute([$domainId]);
return (int)$stmt->fetchColumn() > 0;
}
/**
* Save a snapshot of DNS records for a domain.
* Updates existing records, inserts new ones, removes stale ones.
* @return array{added: int, updated: int, removed: int}
*/
public function saveSnapshot(int $domainId, array $groupedRecords): array
{
$stats = ['added' => 0, 'updated' => 0, 'removed' => 0];
$now = date('Y-m-d H:i:s');
$seenIds = [];
foreach ($groupedRecords as $type => $records) {
foreach ($records as $record) {
$host = $record['host'] ?? '@';
$value = $record['value'] ?? '';
$ttl = $record['ttl'] ?? null;
$priority = $record['priority'] ?? null;
$isCloudflare = !empty($record['is_cloudflare']) ? 1 : 0;
$rawData = isset($record['raw']) ? json_encode($record['raw']) : null;
$existing = $this->findExisting($domainId, $type, $host, $value, $priority);
if ($existing) {
$this->db->prepare(
"UPDATE dns_records SET ttl = ?, is_cloudflare = ?, raw_data = ?, last_seen_at = ?, updated_at = ? WHERE id = ?"
)->execute([$ttl, $isCloudflare, $rawData, $now, $now, $existing['id']]);
$seenIds[] = $existing['id'];
$stats['updated']++;
} else {
$stmt = $this->db->prepare(
"INSERT INTO dns_records (domain_id, record_type, host, value, ttl, priority, is_cloudflare, raw_data, first_seen_at, last_seen_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
);
$stmt->execute([$domainId, $type, $host, $value, $ttl, $priority, $isCloudflare, $rawData, $now, $now, $now, $now]);
$seenIds[] = (int)$this->db->lastInsertId();
$stats['added']++;
}
}
}
// Remove records that no longer exist
if (!empty($seenIds)) {
$placeholders = implode(',', array_fill(0, count($seenIds), '?'));
$deleteStmt = $this->db->prepare(
"DELETE FROM dns_records WHERE domain_id = ? AND id NOT IN ({$placeholders})"
);
$deleteStmt->execute(array_merge([$domainId], $seenIds));
$stats['removed'] = $deleteStmt->rowCount();
} else {
// No records found at all — remove everything
$deleteStmt = $this->db->prepare("DELETE FROM dns_records WHERE domain_id = ?");
$deleteStmt->execute([$domainId]);
$stats['removed'] = $deleteStmt->rowCount();
}
return $stats;
}
/**
* Find an existing record by its natural key
*/
private function findExisting(int $domainId, string $type, string $host, string $value, ?int $priority): ?array
{
if ($priority !== null) {
$stmt = $this->db->prepare(
"SELECT * FROM dns_records WHERE domain_id = ? AND record_type = ? AND host = ? AND value = ? AND priority = ? LIMIT 1"
);
$stmt->execute([$domainId, $type, $host, $value, $priority]);
} else {
$stmt = $this->db->prepare(
"SELECT * FROM dns_records WHERE domain_id = ? AND record_type = ? AND host = ? AND value = ? AND priority IS NULL LIMIT 1"
);
$stmt->execute([$domainId, $type, $host, $value]);
}
$result = $stmt->fetch();
return $result ?: null;
}
/**
* Delete all DNS records for a domain
*/
public function deleteByDomain(int $domainId): bool
{
$stmt = $this->db->prepare("DELETE FROM dns_records WHERE domain_id = ?");
return $stmt->execute([$domainId]);
}
/**
* Get record counts grouped by type for a domain
*/
public function getCountsByType(int $domainId): array
{
$stmt = $this->db->prepare(
"SELECT record_type, COUNT(*) as count FROM dns_records WHERE domain_id = ? GROUP BY record_type ORDER BY record_type"
);
$stmt->execute([$domainId]);
$rows = $stmt->fetchAll();
$counts = [];
foreach ($rows as $row) {
$counts[$row['record_type']] = (int)$row['count'];
}
return $counts;
}
/**
* Get previous snapshot as grouped records (for diff comparison).
* Reconstructs the same format that DnsService::lookup() returns.
*/
public function getPreviousSnapshot(int $domainId): array
{
$records = $this->getByDomainGrouped($domainId);
$grouped = [];
foreach ($records as $type => $rows) {
$grouped[$type] = [];
foreach ($rows as $row) {
$entry = [
'host' => $row['host'],
'value' => $row['value'],
'ttl' => $row['ttl'] ? (int)$row['ttl'] : null,
];
if ($row['priority'] !== null) {
$entry['priority'] = (int)$row['priority'];
}
if ($row['is_cloudflare']) {
$entry['is_cloudflare'] = true;
}
$grouped[$type][] = $entry;
}
}
return $grouped;
}
}

View File

@@ -259,6 +259,24 @@ class Domain extends Model
return $stats;
}
/**
* Get effective status for sorting (active + daysLeft within threshold = expiring_soon)
*/
private static function getEffectiveStatusForSort(array $domain, int $expiringThreshold): string
{
$status = $domain['status'] ?? '';
if ($status === 'inactive') {
return 'inactive';
}
if ($status === 'active' && !empty($domain['expiration_date'])) {
$daysLeft = (int) floor((strtotime($domain['expiration_date']) - time()) / 86400);
if ($daysLeft <= $expiringThreshold && $daysLeft >= 0) {
return 'expiring_soon';
}
}
return $status ?: 'error';
}
/**
* Get filtered, sorted, and paginated domains
*/
@@ -332,10 +350,33 @@ class Domain extends Model
$totalDomains = count($domains);
// Apply sorting
usort($domains, function($a, $b) use ($sortBy, $sortOrder) {
usort($domains, function($a, $b) use ($sortBy, $sortOrder, $expiringThreshold) {
$aVal = $a[$sortBy] ?? '';
$bVal = $b[$sortBy] ?? '';
// When sorting by status: use effective status (active + daysLeft<=threshold = expiring_soon) and logical priority order
if ($sortBy === 'status') {
$aVal = self::getEffectiveStatusForSort($a, $expiringThreshold);
$bVal = self::getEffectiveStatusForSort($b, $expiringThreshold);
$priority = [
'expired' => 1,
'redemption_period' => 2,
'pending_delete' => 3,
'expiring_soon' => 4,
'active' => 5,
'available' => 6,
'error' => 7,
'inactive' => 8,
];
$aOrder = $priority[$aVal] ?? 99;
$bOrder = $priority[$bVal] ?? 99;
$comparison = $aOrder <=> $bOrder;
if ($comparison === 0) {
$comparison = strcasecmp($a['domain_name'] ?? '', $b['domain_name'] ?? '');
}
return $sortOrder === 'desc' ? -$comparison : $comparison;
}
$comparison = strcasecmp($aVal, $bVal);
return $sortOrder === 'desc' ? -$comparison : $comparison;
});

View File

@@ -114,13 +114,14 @@ class TldRegistry extends Model
*/
public function search(string $search): array
{
$search = '%' . $search . '%';
$tldNorm = strtolower(trim(trim($search), '.'));
$suffix = '%.' . $tldNorm;
$sql = "SELECT * FROM tld_registry
WHERE (LOWER(tld) LIKE LOWER(?) OR LOWER(whois_server) LIKE LOWER(?) OR LOWER(registry_url) LIKE LOWER(?))
WHERE (LOWER(TRIM(BOTH '.' FROM tld)) = ? OR LOWER(tld) LIKE ?)
ORDER BY tld ASC";
$stmt = $this->db->prepare($sql);
$stmt->execute([$search, $search, $search]);
$stmt->execute([$tldNorm, $suffix]);
return $stmt->fetchAll();
}
@@ -144,11 +145,12 @@ class TldRegistry extends Model
$whereConditions = [];
$params = [];
// Search filter
// Search filter: exact match OR TLDs ending with .{search} (e.g. "za" -> .za, .co.za, .net.za)
if (!empty($search)) {
$searchParam = '%' . $search . '%';
$whereConditions[] = "(LOWER(tld) LIKE LOWER(?) OR LOWER(whois_server) LIKE LOWER(?) OR LOWER(registry_url) LIKE LOWER(?))";
$params = array_merge($params, [$searchParam, $searchParam, $searchParam]);
$tldNorm = strtolower(trim(trim($search), '.'));
$suffix = '%.' . $tldNorm;
$whereConditions[] = "(LOWER(TRIM(BOTH '.' FROM tld)) = ? OR LOWER(tld) LIKE ?)";
$params = array_merge($params, [$tldNorm, $suffix]);
}
// Status filter

View File

@@ -73,12 +73,12 @@ class DiscordChannel implements NotificationChannelInterface
],
[
'name' => 'Days Left',
'value' => $data['days_left'],
'value' => (string) ($data['days_left'] ?? 'N/A'),
'inline' => true
],
[
'name' => 'Expiration Date',
'value' => $data['expiration_date'],
'value' => $data['expiration_date'] ?? 'N/A',
'inline' => true
]
];

766
app/Services/DnsService.php Normal file
View File

@@ -0,0 +1,766 @@
<?php
namespace App\Services;
class DnsService
{
private Logger $logger;
// https://www.cloudflare.com/ips-v4/ and /ips-v6/
private const CLOUDFLARE_IPV4_RANGES = [
'173.245.48.0/20',
'103.21.244.0/22',
'103.22.200.0/22',
'103.31.4.0/22',
'141.101.64.0/18',
'108.162.192.0/18',
'190.93.240.0/20',
'188.114.96.0/20',
'197.234.240.0/22',
'198.41.128.0/17',
'162.158.0.0/15',
'104.16.0.0/13',
'104.24.0.0/14',
'172.64.0.0/13',
'131.0.72.0/22',
];
private const CLOUDFLARE_IPV6_RANGES = [
'2400:cb00::/32',
'2606:4700::/32',
'2803:f800::/32',
'2405:b500::/32',
'2405:8100::/32',
'2a06:98c0::/29',
'2c0f:f248::/32',
];
private const SUBDOMAIN_WORDLIST = [
'www', 'mail', 'ftp', 'smtp', 'pop', 'pop3', 'imap', 'webmail', 'email',
'ns1', 'ns2', 'ns3', 'ns4', 'dns', 'dns1', 'dns2',
'mx', 'mx1', 'mx2', 'relay', 'gateway', 'mailgw',
'vpn', 'vpn1', 'vpn2', 'remote', 'access', 'proxy', 'fw', 'firewall',
'api', 'api2', 'app', 'app1', 'app2', 'dev', 'dev2',
'stage', 'staging', 'test', 'beta', 'demo', 'sandbox',
'admin', 'panel', 'portal', 'dashboard', 'cms', 'cpanel', 'whm', 'plesk',
'db', 'db1', 'db2', 'mysql', 'postgres', 'redis',
'cdn', 'cdn1', 'cdn2', 'static', 'assets', 'media', 'img', 'images', 'files',
'shop', 'store', 'pay', 'billing',
'blog', 'forum', 'wiki', 'docs',
'help', 'support', 'kb',
'git', 'gitlab', 'ci', 'jenkins',
'monitor', 'status', 'grafana',
'sso', 'auth', 'login', 'id', 'oauth',
'm', 'mobile',
'intranet', 'internal', 'corp',
'backup', 'old', 'legacy',
'cloud', 'autodiscover', 'autoconfig', 'lyncdiscover', 'sip',
'server', 'server1', 'server2', 'host', 'node1', 'node2',
'web', 'web1', 'web2', 'www1', 'www2',
'mail1', 'mail2', 'mail3', 'smtp1', 'smtp2', 'mta',
'lb', 'haproxy', 'nginx', 'cache',
'owa', 'exchange', 'outlook',
'ns', 'mx0',
];
private const SPECIAL_TXT_SUBDOMAINS = [
'_dmarc',
'_mta-sts',
];
private const ROOT_RECORD_TYPES = [
DNS_A => 'A',
DNS_AAAA => 'AAAA',
DNS_MX => 'MX',
DNS_TXT => 'TXT',
DNS_NS => 'NS',
DNS_CNAME => 'CNAME',
DNS_SOA => 'SOA',
DNS_SRV => 'SRV',
DNS_CAA => 'CAA',
];
public function __construct()
{
$this->logger = new Logger('dns');
}
// ========================================================================
// MAIN LOOKUP
// ========================================================================
/**
* Comprehensive DNS lookup for a domain.
* Scans root + common subdomains + targets extracted from NS/MX/CNAME.
* Resolves NS/MX targets to A/AAAA IPs.
*
* @param string $domain The domain to scan
* @param array $extraSubdomains Additional subdomain candidates (e.g. from crt.sh or previous scans)
*/
public function lookup(string $domain, array $extraSubdomains = []): array
{
$this->logger->info("DNS lookup started", ['domain' => $domain]);
$records = [
'A' => [], 'AAAA' => [], 'MX' => [], 'TXT' => [],
'NS' => [], 'CNAME' => [], 'SOA' => [], 'SRV' => [], 'CAA' => [],
];
$seen = []; // "TYPE:host:value" dedup keys
// Phase 1: Root domain — query each type individually
foreach (self::ROOT_RECORD_TYPES as $dnsConst => $typeName) {
$this->queryAndCollect($domain, $dnsConst, $typeName, $domain, $records, $seen);
}
// Phase 1b: DNS_ALL fallback to catch anything we missed
$this->queryAllFallback($domain, $domain, $records, $seen);
// Phase 1c: gethostbynamel fallback for A records
if (empty($records['A'])) {
$ips = @gethostbynamel($domain);
if (is_array($ips)) {
foreach ($ips as $ip) {
$this->addIfNew('A', [
'host' => '@', 'value' => $ip, 'ttl' => 0,
'is_cloudflare' => $this->isCloudflareIp($ip),
'raw' => ['host' => $domain, 'type' => 'A', 'ip' => $ip, 'ttl' => 0],
], $records, $seen);
}
}
}
// Phase 2: Build subdomain candidates from wordlist + extras + targets found in NS/MX/CNAME/SRV
$candidates = array_merge(self::SUBDOMAIN_WORDLIST, $extraSubdomains);
foreach (['NS', 'MX', 'CNAME', 'SRV'] as $type) {
foreach ($records[$type] as $rec) {
$target = rtrim($rec['value'] ?? '', '.');
if ($target && str_ends_with(strtolower($target), '.' . strtolower($domain))) {
$sub = str_replace('.' . $domain, '', strtolower($target));
if ($sub && !in_array($sub, $candidates)) {
$candidates[] = $sub;
}
}
}
}
$candidates = array_unique($candidates);
// Phase 3: Probe subdomains — fast checkdnsrr existence test first
$discovered = [];
foreach ($candidates as $sub) {
$fqdn = "{$sub}.{$domain}";
if ($this->subdomainExists($fqdn)) {
$discovered[] = $sub;
}
}
// Phase 4: Deep scan discovered subdomains (A, AAAA, CNAME, TXT)
foreach ($discovered as $sub) {
$fqdn = "{$sub}.{$domain}";
$this->queryAndCollect($fqdn, DNS_A, 'A', $domain, $records, $seen);
$this->queryAndCollect($fqdn, DNS_AAAA, 'AAAA', $domain, $records, $seen);
$this->queryAndCollect($fqdn, DNS_CNAME, 'CNAME', $domain, $records, $seen);
// TXT only for known useful subdomains
if (in_array($sub, ['_dmarc', '_mta-sts', '_domainkey']) || str_starts_with($sub, '_')) {
$this->queryAndCollect($fqdn, DNS_TXT, 'TXT', $domain, $records, $seen);
}
}
// Phase 4b: Special TXT subdomains (always query even if not "discovered")
foreach (self::SPECIAL_TXT_SUBDOMAINS as $sub) {
$fqdn = "{$sub}.{$domain}";
$this->queryAndCollect($fqdn, DNS_TXT, 'TXT', $domain, $records, $seen);
}
// Phase 5: Resolve MX targets that are under this domain — add their A/AAAA records
foreach ($records['MX'] as $mxRec) {
$target = rtrim($mxRec['value'] ?? '', '.');
if ($target && str_ends_with(strtolower($target), '.' . strtolower($domain))) {
$this->queryAndCollect($target, DNS_A, 'A', $domain, $records, $seen);
$this->queryAndCollect($target, DNS_AAAA, 'AAAA', $domain, $records, $seen);
}
}
// Phase 6: Resolve NS server IPs — store in raw data for display
foreach ($records['NS'] as &$nsRec) {
$nsHost = rtrim($nsRec['value'] ?? '', '.');
if ($nsHost) {
$nsIps = $this->resolveHostIps($nsHost);
$nsRec['raw']['_ns_ips'] = $nsIps;
}
}
unset($nsRec);
// Sort A/AAAA: root first, then alphabetical
foreach (['A', 'AAAA'] as $type) {
usort($records[$type], function ($a, $b) {
if ($a['host'] === '@') return -1;
if ($b['host'] === '@') return 1;
return strcmp($a['host'], $b['host']);
});
}
$totalRecords = array_sum(array_map('count', $records));
$this->logger->info("DNS lookup completed", [
'domain' => $domain,
'total_records' => $totalRecords,
'subdomains_discovered' => count($discovered),
]);
return $records;
}
// ========================================================================
// LOOKUP HELPERS
// ========================================================================
/**
* Query a FQDN for a specific record type and collect deduplicated results.
*/
private function queryAndCollect(
string $fqdn, int $dnsConst, string $typeName,
string $baseDomain, array &$records, array &$seen
): void {
try {
$raw = @dns_get_record($fqdn, $dnsConst);
if ($raw === false || empty($raw)) {
return;
}
foreach ($raw as $entry) {
$parsed = $this->parseRecord($typeName, $entry, $baseDomain);
if ($parsed) {
$this->addIfNew($typeName, $parsed, $records, $seen);
}
}
} catch (\Throwable $e) {
// Non-existent subdomain or network issue — not worth logging
}
}
/**
* DNS_ALL fallback to catch records missed by individual queries.
*/
private function queryAllFallback(string $fqdn, string $baseDomain, array &$records, array &$seen): void
{
try {
$all = @dns_get_record($fqdn, DNS_ALL);
if (!is_array($all) || empty($all)) {
return;
}
foreach ($all as $entry) {
$type = strtoupper($entry['type'] ?? '');
if ($type && isset($records[$type])) {
$parsed = $this->parseRecord($type, $entry, $baseDomain);
if ($parsed) {
$this->addIfNew($type, $parsed, $records, $seen);
}
}
}
} catch (\Throwable $e) {
// Ignore
}
}
/**
* Add a record only if it hasn't been seen before (dedup).
*/
private function addIfNew(string $type, array $parsed, array &$records, array &$seen): void
{
$priority = $parsed['priority'] ?? '';
$dedupKey = "{$type}:{$parsed['host']}:{$parsed['value']}:{$priority}";
if (isset($seen[$dedupKey])) {
return;
}
$seen[$dedupKey] = true;
$records[$type][] = $parsed;
}
/**
* Fast existence check for a subdomain.
*/
private function subdomainExists(string $fqdn): bool
{
if (@checkdnsrr($fqdn, 'A')) return true;
if (@checkdnsrr($fqdn, 'AAAA')) return true;
if (@checkdnsrr($fqdn, 'CNAME')) return true;
$ip = @gethostbyname($fqdn);
return ($ip !== $fqdn);
}
/**
* Resolve a hostname to its A and AAAA IPs.
*/
private function resolveHostIps(string $hostname): array
{
$ips = ['ipv4' => [], 'ipv6' => []];
$a = @dns_get_record($hostname, DNS_A);
if (is_array($a)) {
foreach ($a as $r) {
if (!empty($r['ip'])) {
$ips['ipv4'][] = $r['ip'];
}
}
}
$aaaa = @dns_get_record($hostname, DNS_AAAA);
if (is_array($aaaa)) {
foreach ($aaaa as $r) {
if (!empty($r['ipv6'])) {
$ips['ipv6'][] = $r['ipv6'];
}
}
}
return $ips;
}
// ========================================================================
// CERTIFICATE TRANSPARENCY (crt.sh)
// ========================================================================
/**
* Discover subdomains via crt.sh Certificate Transparency logs.
* Returns an array of subdomain labels (e.g. ['www', 'mail', 'api']).
* Slow/unreliable — use only in cron, not on manual refresh.
*/
public function crtshSubdomains(string $domain): array
{
$url = 'https://crt.sh/?q=' . urlencode("%.$domain") . '&output=json';
$ctx = stream_context_create([
'http' => [
'timeout' => 30,
'ignore_errors' => true,
'header' => "User-Agent: DomainMonitor/1.0\r\n",
],
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
],
]);
$json = @file_get_contents($url, false, $ctx);
if ($json === false) {
$this->logger->warning('crt.sh request failed', ['domain' => $domain]);
return [];
}
$entries = @json_decode($json, true);
if (!is_array($entries)) {
return [];
}
$subdomains = [];
$domainLower = strtolower($domain);
foreach ($entries as $entry) {
$name = $entry['name_value'] ?? '';
foreach (explode("\n", $name) as $n) {
$n = strtolower(trim($n));
$n = ltrim($n, '*.');
if (empty($n)) continue;
if ($n === $domainLower) continue;
if (str_ends_with($n, '.' . $domainLower)) {
$sub = str_replace('.' . $domainLower, '', $n);
if ($sub !== '' && !isset($subdomains[$sub])) {
$subdomains[$sub] = true;
}
}
}
}
$result = array_keys($subdomains);
$this->logger->info('crt.sh discovery completed', [
'domain' => $domain,
'subdomains_found' => count($result),
]);
return $result;
}
// ========================================================================
// RECORD PARSING
// ========================================================================
/**
* Parse a raw dns_get_record entry into a normalized record.
*/
private function parseRecord(string $type, array $entry, string $domain): ?array
{
$host = $entry['host'] ?? $domain;
$hostLower = strtolower($host);
$domainLower = strtolower($domain);
// Skip records that resolved to external domains (e.g. CNAME target chains)
if ($hostLower !== $domainLower && !str_ends_with($hostLower, '.' . $domainLower)) {
return null;
}
$hostLabel = ($hostLower === $domainLower)
? '@'
: str_ireplace('.' . $domain, '', $host);
$ttl = $entry['ttl'] ?? null;
switch ($type) {
case 'A':
$ip = $entry['ip'] ?? '';
if (empty($ip)) return null;
return [
'host' => $hostLabel,
'value' => $ip,
'ttl' => $ttl,
'is_cloudflare' => $this->isCloudflareIp($ip),
'raw' => $entry,
];
case 'AAAA':
$ip = $entry['ipv6'] ?? '';
if (empty($ip)) return null;
return [
'host' => $hostLabel,
'value' => $ip,
'ttl' => $ttl,
'is_cloudflare' => $this->isCloudflareIpv6($ip),
'raw' => $entry,
];
case 'MX':
$target = $entry['target'] ?? '';
if (empty($target)) return null;
return [
'host' => $hostLabel,
'value' => $target,
'priority' => $entry['pri'] ?? 0,
'ttl' => $ttl,
'raw' => $entry,
];
case 'TXT':
$txt = $entry['txt'] ?? '';
if (empty($txt)) return null;
return [
'host' => $hostLabel,
'value' => $txt,
'ttl' => $ttl,
'txt_type' => $this->classifyTxtRecord($txt),
'raw' => $entry,
];
case 'NS':
$target = $entry['target'] ?? '';
if (empty($target)) return null;
return [
'host' => $hostLabel,
'value' => $target,
'ttl' => $ttl,
'raw' => $entry,
];
case 'CNAME':
$target = $entry['target'] ?? '';
if (empty($target)) return null;
return [
'host' => $hostLabel,
'value' => $target,
'ttl' => $ttl,
'raw' => $entry,
];
case 'SOA':
return [
'host' => $hostLabel,
'value' => $entry['mname'] ?? '',
'rname' => $entry['rname'] ?? '',
'serial' => $entry['serial'] ?? 0,
'refresh' => $entry['refresh'] ?? 0,
'retry' => $entry['retry'] ?? 0,
'expire' => $entry['expire'] ?? 0,
'minimum' => $entry['minimum-ttl'] ?? 0,
'ttl' => $ttl,
'raw' => $entry,
];
case 'SRV':
$target = $entry['target'] ?? '';
if (empty($target)) return null;
return [
'host' => $hostLabel,
'value' => $target,
'priority' => $entry['pri'] ?? 0,
'weight' => $entry['weight'] ?? 0,
'port' => $entry['port'] ?? 0,
'ttl' => $ttl,
'raw' => $entry,
];
case 'CAA':
$value = ($entry['flags'] ?? 0) . ' ' . ($entry['tag'] ?? '') . ' "' . ($entry['value'] ?? '') . '"';
return [
'host' => $hostLabel,
'value' => $value,
'flags' => $entry['flags'] ?? 0,
'tag' => $entry['tag'] ?? '',
'ca' => $entry['value'] ?? '',
'ttl' => $ttl,
'raw' => $entry,
];
default:
return null;
}
}
/**
* Classify a TXT record's purpose.
*/
private function classifyTxtRecord(string $value): string
{
$lower = strtolower($value);
if (str_starts_with($lower, 'v=spf1')) return 'SPF';
if (str_starts_with($lower, 'v=dkim1')) return 'DKIM';
if (str_starts_with($lower, 'v=dmarc1')) return 'DMARC';
if (str_contains($lower, 'google-site-verification')) return 'Google Verification';
if (str_contains($lower, 'ms=')) return 'Microsoft Verification';
if (str_contains($lower, 'facebook-domain-verification')) return 'Facebook Verification';
if (str_contains($lower, 'apple-domain-verification')) return 'Apple Verification';
if (str_contains($lower, 'amazonses:')) return 'Amazon SES';
if (str_contains($lower, 'docusign')) return 'DocuSign';
if (str_contains($lower, 'atlassian-domain-verification')) return 'Atlassian Verification';
if (str_contains($lower, '_mta-sts')) return 'MTA-STS';
return 'TXT';
}
// ========================================================================
// CLOUDFLARE DETECTION
// ========================================================================
public function isCloudflareIp(string $ip): bool
{
if (empty($ip) || !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return false;
}
$ipLong = ip2long($ip);
if ($ipLong === false) {
return false;
}
foreach (self::CLOUDFLARE_IPV4_RANGES as $cidr) {
[$subnet, $mask] = explode('/', $cidr);
$subnetLong = ip2long($subnet);
$maskLong = ~((1 << (32 - (int)$mask)) - 1);
if (($ipLong & $maskLong) === ($subnetLong & $maskLong)) {
return true;
}
}
return false;
}
public function isCloudflareIpv6(string $ip): bool
{
if (empty($ip) || !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
return false;
}
$ipBin = inet_pton($ip);
if ($ipBin === false) {
return false;
}
foreach (self::CLOUDFLARE_IPV6_RANGES as $cidr) {
[$subnet, $prefixLen] = explode('/', $cidr);
$subnetBin = inet_pton($subnet);
if ($subnetBin === false) {
continue;
}
$prefixLen = (int)$prefixLen;
$fullBytes = intdiv($prefixLen, 8);
$remainingBits = $prefixLen % 8;
$match = true;
for ($i = 0; $i < $fullBytes; $i++) {
if ($ipBin[$i] !== $subnetBin[$i]) {
$match = false;
break;
}
}
if ($match && $remainingBits > 0 && $fullBytes < 16) {
$bitmask = 0xFF << (8 - $remainingBits) & 0xFF;
if ((ord($ipBin[$fullBytes]) & $bitmask) !== (ord($subnetBin[$fullBytes]) & $bitmask)) {
$match = false;
}
}
if ($match) {
return true;
}
}
return false;
}
// ========================================================================
// IP DETAILS (PTR, ASN, GEO)
// ========================================================================
/**
* Batch-lookup IP details (ASN, PTR, org, country) for a list of IPs.
* PTR via gethostbyaddr(); ASN/geo via ip-api.com batch.
*/
public function lookupIpDetails(array $ips): array
{
$unique = array_values(array_unique(array_filter($ips)));
if (empty($unique)) {
return [];
}
$result = [];
foreach ($unique as $ip) {
$ptr = @gethostbyaddr($ip);
$result[$ip] = [
'reverse' => ($ptr !== false && $ptr !== $ip) ? $ptr : '',
];
}
$requestBody = [];
foreach ($unique as $ip) {
$requestBody[] = [
'query' => $ip,
'fields' => 'status,query,as,asname,isp,org,country,countryCode,regionName,city,hosting',
];
}
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nUser-Agent: DomainMonitor/1.0",
'content' => json_encode($requestBody),
'timeout' => 5,
],
]);
$response = @file_get_contents('http://ip-api.com/batch', false, $context);
if ($response !== false) {
$data = json_decode($response, true);
if (is_array($data)) {
foreach ($data as $item) {
if (($item['status'] ?? '') === 'success' && isset($item['query'])) {
$result[$item['query']] = array_merge($result[$item['query']] ?? [], $item);
}
}
}
} else {
$this->logger->warning('ip-api.com batch request failed');
}
return $result;
}
// ========================================================================
// DIFF / NOTIFICATIONS
// ========================================================================
/**
* Compare two sets of DNS records and return changes.
*/
public function diffRecords(array $oldRecords, array $newRecords): array
{
$changes = ['added' => [], 'removed' => [], 'changed' => []];
$oldFlat = $this->flattenRecords($oldRecords);
$newFlat = $this->flattenRecords($newRecords);
foreach ($newFlat as $key => $record) {
if (!isset($oldFlat[$key])) {
$changes['added'][] = $record;
} elseif ($oldFlat[$key]['value'] !== $record['value']) {
$changes['changed'][] = [
'record' => $record,
'old_value' => $oldFlat[$key]['value'],
'new_value' => $record['value'],
];
}
}
foreach ($oldFlat as $key => $record) {
if (!isset($newFlat[$key])) {
$changes['removed'][] = $record;
}
}
return $changes;
}
private function flattenRecords(array $grouped): array
{
$flat = [];
foreach ($grouped as $type => $records) {
foreach ($records as $record) {
$host = $record['host'] ?? '@';
$value = $record['value'] ?? '';
$priority = $record['priority'] ?? '';
$key = "{$type}:{$host}:{$value}:{$priority}";
$flat[$key] = array_merge($record, ['record_type' => $type]);
}
}
return $flat;
}
public function formatChangesSummary(array $changes, string $domain): string
{
$parts = [];
if (!empty($changes['added'])) {
$parts[] = count($changes['added']) . " new record(s) added";
}
if (!empty($changes['removed'])) {
$parts[] = count($changes['removed']) . " record(s) removed";
}
if (!empty($changes['changed'])) {
$parts[] = count($changes['changed']) . " record(s) changed";
}
return empty($parts) ? '' : "DNS changes detected for {$domain}: " . implode(', ', $parts);
}
public function formatChangesDetail(array $changes, string $domain): string
{
$lines = ["🔄 DNS Changes Detected: {$domain}\n"];
if (!empty($changes['added'])) {
$lines[] = " New Records:";
foreach ($changes['added'] as $r) {
$type = $r['record_type'] ?? 'UNKNOWN';
$lines[] = " {$type} {$r['host']}{$r['value']}";
}
$lines[] = '';
}
if (!empty($changes['removed'])) {
$lines[] = " Removed Records:";
foreach ($changes['removed'] as $r) {
$type = $r['record_type'] ?? 'UNKNOWN';
$lines[] = " {$type} {$r['host']}{$r['value']}";
}
$lines[] = '';
}
if (!empty($changes['changed'])) {
$lines[] = "✏️ Changed Records:";
foreach ($changes['changed'] as $c) {
$type = $c['record']['record_type'] ?? 'UNKNOWN';
$lines[] = " {$type} {$c['record']['host']}: {$c['old_value']}{$c['new_value']}";
}
$lines[] = '';
}
return implode("\n", $lines);
}
}

View File

@@ -665,6 +665,54 @@ class NotificationService
}
}
// ========================================
// DNS MONITORING NOTIFICATIONS
// ========================================
/**
* Create a DNS change notification (in-app / bell icon)
*/
public function notifyDnsChange(int $userId, string $domainName, int $domainId, string $summary): void
{
$notificationModel = new \App\Models\Notification();
$notificationModel->createNotification(
$userId,
'dns_change',
'DNS Records Changed',
"{$domainName} - {$summary}",
$domainId
);
}
/**
* Send DNS change alert to external channels
*/
public function sendDnsChangeAlert(array $domain, array $notificationChannels, string $detailMessage): array
{
$results = [];
foreach ($notificationChannels as $channel) {
$config = json_decode($channel['channel_config'], true);
$success = $this->send(
$channel['channel_type'],
$config,
$detailMessage,
[
'subject' => "DNS Changes: {$domain['domain_name']}",
'domain' => $domain['domain_name'],
'domain_id' => $domain['id'],
]
);
$results[] = [
'channel' => $channel['channel_type'],
'success' => $success,
];
}
return $results;
}
/**
* Delete old read notifications (cleanup)
*/

View File

@@ -848,7 +848,7 @@ class WhoisService
// Check if domain is not found/available
$whoisDataLower = strtolower($whoisData);
// More specific patterns to avoid false positives
// Exact line match (original patterns)
if (preg_match('/^(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered|available)$/m', $whoisDataLower) ||
preg_match('/^status:\s*(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered|available)$/m', $whoisDataLower) ||
preg_match('/^domain status:\s*(not found|no match|no entries found|no data found|domain not found|no such domain|available for registration|does not exist|queried object does not exist|is free|not registered|available)$/m', $whoisDataLower)) {
@@ -856,6 +856,12 @@ class WhoisService
$data['registrar'] = 'Not Registered';
return $data;
}
// Broader patterns for formats that don't match exact line (e.g. .rs "%ERROR:103: Domain is not registered", .io "Domain not found.", .co "The queried object does not exist: DOMAIN NOT FOUND")
if (preg_match('/domain\s+is\s+not\s+registered|domain\s+not\s+found\.?(\s|$)|queried\s+object\s+does\s+not\s+exist|%error:\d+:\s*domain/i', $whoisDataLower)) {
$data['status'][] = 'AVAILABLE';
$data['registrar'] = 'Not Registered';
return $data;
}
// Special handling for .eu domains that are available
// EURid returns "Status: AVAILABLE" in a specific format
@@ -1196,7 +1202,10 @@ class WhoisService
}
// No expiration date and no clear status indicators
// This should only happen for newly added domains or error cases
// Fallback: registrar "Not Registered" means domain is available (e.g. parseWhoisData set it but status was lost in merge)
if (!empty($whoisData['registrar']) && $whoisData['registrar'] === 'Not Registered') {
return 'available';
}
// Return error to avoid incorrectly marking registered domains as available
return 'error';
}

View File

@@ -140,15 +140,25 @@
</div>
<!-- Active Monitoring -->
<div class="bg-gray-50 dark:bg-slate-900 rounded-lg p-4 border border-gray-200 dark:border-slate-700">
<label class="flex items-center cursor-pointer">
<div id="dns-monitoring" class="bg-gray-50 dark:bg-slate-900 rounded-lg p-4 border border-gray-200 dark:border-slate-700 space-y-4">
<label class="flex items-start cursor-pointer">
<input type="checkbox"
name="is_active"
{{ domain.is_active ? 'checked' : '' }}
class="w-4 h-4 text-primary border-gray-300 dark:border-slate-600 rounded focus:ring-primary cursor-pointer">
class="w-4 h-4 mt-0.5 text-primary border-gray-300 dark:border-slate-600 rounded focus:ring-primary cursor-pointer">
<div class="ml-3">
<span class="text-sm font-medium text-gray-900 dark:text-white">Enable Active Monitoring</span>
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5">When enabled, this domain will be checked regularly and notifications will be sent</p>
<p class="text-xs text-gray-600 dark:text-slate-400 mt-1">When enabled, this domain will be checked regularly and notifications will be sent</p>
</div>
</label>
<label class="flex items-start cursor-pointer pt-2 border-t border-gray-200 dark:border-slate-700">
<input type="checkbox"
name="dns_monitoring_enabled"
{{ domain.dns_monitoring_enabled|default(1) ? 'checked' : '' }}
class="w-4 h-4 mt-0.5 text-primary border-gray-300 dark:border-slate-600 rounded focus:ring-primary cursor-pointer">
<div class="ml-3">
<span class="text-sm font-medium text-gray-900 dark:text-white">Enable DNS Monitoring</span>
<p class="text-xs text-gray-600 dark:text-slate-400 mt-1">When enabled, DNS records will be checked for changes and you'll receive alerts</p>
</div>
</label>
</div>
@@ -177,7 +187,7 @@
<i class="fas fa-eye text-blue-600 dark:text-blue-400 mr-2 text-sm"></i>
<span class="text-sm font-medium text-gray-700 dark:text-slate-300">View Details</span>
</a>
<form method="POST" action="/domains/{{ domain.id }}/refresh" class="m-0">
<form method="POST" action="/domains/{{ domain.id }}/refresh-whois" class="m-0">
{{ csrf_field() }}
<button type="submit"
class="w-full flex items-center justify-center p-3 bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg hover:border-green-300 dark:hover:border-green-700 hover:bg-green-50 dark:hover:bg-green-500/10 transition-colors group">

View File

@@ -369,7 +369,7 @@
<a href="/domains/{{ domain.id }}" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" title="View">
<i class="fas fa-eye"></i>
</a>
<form method="POST" action="/domains/{{ domain.id }}/refresh" class="inline">
<form method="POST" action="/domains/{{ domain.id }}/refresh-whois" class="inline">
{{ csrf_field() }}
<button type="submit" class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300" title="Refresh WHOIS">
<i class="fas fa-sync-alt"></i>

View File

@@ -0,0 +1,279 @@
<!-- BILLING TAB CONTENT -->
<!-- Preview Banner -->
<div class="mb-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
<div class="flex items-center">
<i class="fas fa-flask text-amber-600 dark:text-amber-400 mr-2" style="font-size: 12px;"></i>
<div>
<p class="text-xs font-semibold text-amber-900 dark:text-amber-300">Preview</p>
<p class="text-xs text-amber-800 dark:text-amber-400 mt-0.5">Financial tracking is coming soon. This is a design preview with sample data and might change in the future.</p>
</div>
</div>
</div>
<!-- Purchase Source Info -->
<div class="bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-3">
<div class="flex items-center justify-between">
<div class="flex items-center">
<i class="fas fa-shopping-cart text-blue-600 dark:text-blue-400 mr-2" style="font-size: 14px;"></i>
<div>
<p class="text-xs font-semibold text-blue-900 dark:text-blue-300">Purchased From</p>
<p class="text-xs text-blue-700 dark:text-blue-400 mt-0.5">Sedo Marketplace - $1,200.00 on Jan 15, 2020</p>
</div>
</div>
<a href="https://sedo.com/account" target="_blank" class="inline-flex items-center px-3 py-1.5 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 transition-colors font-medium">
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
Go to Seller
</a>
</div>
</div>
<!-- Financial Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-3 mb-3">
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Purchase Price</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-0.5">$1,200.00</p>
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">Jan 15, 2020</p>
</div>
<div class="w-8 h-8 bg-blue-50 dark:bg-blue-500/10 rounded flex items-center justify-center">
<i class="fas fa-shopping-cart text-blue-600 dark:text-blue-400" style="font-size: 14px;"></i>
</div>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Total Renewals</p>
<p class="text-lg font-semibold text-orange-600 dark:text-orange-400 mt-0.5">$450.00</p>
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">3 payments</p>
</div>
<div class="w-8 h-8 bg-orange-50 dark:bg-orange-500/10 rounded flex items-center justify-center">
<i class="fas fa-redo text-orange-600 dark:text-orange-400" style="font-size: 14px;"></i>
</div>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Total Invested</p>
<p class="text-lg font-semibold text-indigo-600 dark:text-indigo-400 mt-0.5">$1,700.00</p>
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">All expenses</p>
</div>
<div class="w-8 h-8 bg-indigo-50 dark:bg-indigo-500/10 rounded flex items-center justify-center">
<i class="fas fa-wallet text-indigo-600 dark:text-indigo-400" style="font-size: 14px;"></i>
</div>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Profit/Loss</p>
<p class="text-lg font-semibold text-gray-400 dark:text-slate-500 mt-0.5">-</p>
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">Not sold</p>
</div>
<div class="w-8 h-8 bg-gray-50 dark:bg-slate-700 rounded flex items-center justify-center">
<i class="fas fa-chart-line text-gray-600 dark:text-slate-400" style="font-size: 14px;"></i>
</div>
</div>
</div>
</div>
<!-- Next Renewal Alert -->
<div class="bg-orange-50 dark:bg-orange-500/10 border border-orange-200 dark:border-orange-800 rounded-lg p-3 mb-3">
<div class="flex items-center justify-between">
<div class="flex items-center">
<i class="fas fa-calendar-alt text-orange-600 dark:text-orange-400 mr-2" style="font-size: 14px;"></i>
<div>
<p class="text-xs font-semibold text-orange-900 dark:text-orange-300">Next Renewal Due</p>
<p class="text-xs text-orange-700 dark:text-orange-400 mt-0.5">Jan 15, 2026 <span class="font-semibold">(65 days)</span> - Estimated: $150.00</p>
</div>
</div>
<button class="inline-flex items-center px-3 py-1.5 bg-orange-600 text-white text-xs rounded hover:bg-orange-700 transition-colors font-medium">
<i class="fas fa-plus mr-1" style="font-size: 9px;"></i>
Add Payment
</button>
</div>
</div>
<!-- Action Buttons -->
<div class="flex gap-2 mb-3">
<button class="inline-flex items-center px-3 py-2 bg-primary text-white text-xs rounded-lg hover:bg-primary-dark transition-colors font-medium"><i class="fas fa-plus mr-1.5" style="font-size: 10px;"></i>Add Transaction</button>
<button class="inline-flex items-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium"><i class="fas fa-hand-holding-usd mr-1.5" style="font-size: 10px;"></i>Record Sale</button>
<button class="inline-flex items-center px-3 py-2 bg-indigo-600 text-white text-xs rounded-lg hover:bg-indigo-700 transition-colors font-medium"><i class="fas fa-file-invoice-dollar mr-1.5" style="font-size: 10px;"></i>Export Report</button>
</div>
<!-- Transaction History (static sample) -->
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
<i class="fas fa-history text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
Transaction History
<span class="ml-2 px-1.5 py-0.5 bg-primary text-white text-xs font-semibold rounded">5</span>
</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full">
<thead class="bg-gray-50 dark:bg-slate-900">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Date</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Type</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Amount</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Company/Seller</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Invoice</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Payment Method</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Status</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Notes</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
<td class="px-4 py-3 text-xs text-gray-900 dark:text-white whitespace-nowrap">Jan 15, 2020</td>
<td class="px-4 py-3 whitespace-nowrap"><span class="inline-flex items-center px-2 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400 text-xs font-semibold rounded"><i class="fas fa-shopping-cart mr-1" style="font-size: 9px;"></i>Purchase</span></td>
<td class="px-4 py-3 text-xs font-semibold text-gray-900 dark:text-white whitespace-nowrap">$1,200.00</td>
<td class="px-4 py-3 text-xs text-gray-900 dark:text-white whitespace-nowrap">Sedo Marketplace</td>
<td class="px-4 py-3 text-xs font-mono text-blue-600 dark:text-blue-400 whitespace-nowrap">SEDO-2020-001234</td>
<td class="px-4 py-3 text-xs text-gray-600 dark:text-slate-400 whitespace-nowrap">Credit Card</td>
<td class="px-4 py-3 whitespace-nowrap"><span class="inline-flex items-center px-2 py-0.5 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 text-xs font-semibold rounded"><i class="fas fa-check-circle mr-1" style="font-size: 8px;"></i>Paid</span></td>
<td class="px-4 py-3 text-xs text-gray-600 dark:text-slate-400">Initial domain purchase from Sedo marketplace</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Financial Charts -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 mt-3">
<!-- Expense Breakdown (Donut Chart) -->
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
<i class="fas fa-chart-pie text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
Expense Breakdown
</h3>
</div>
<div class="p-4">
<canvas id="expenseChart" height="200"></canvas>
</div>
</div>
<!-- Expense Timeline (Line Chart) -->
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
<i class="fas fa-chart-line text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
Expense Timeline
</h3>
</div>
<div class="p-4">
<canvas id="timelineChart" height="200"></canvas>
</div>
</div>
</div>
<!-- Chart.js CDN -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>
// Expense Breakdown Donut Chart
const expenseCtx = document.getElementById('expenseChart')?.getContext('2d');
if (expenseCtx) {
new Chart(expenseCtx, {
type: 'doughnut',
data: {
labels: ['Initial Purchase', 'Renewals', 'Transfers', 'Other'],
datasets: [{
data: [1200, 450, 50, 0],
backgroundColor: [
'rgba(59, 130, 246, 0.8)',
'rgba(251, 146, 60, 0.8)',
'rgba(168, 85, 247, 0.8)',
'rgba(156, 163, 175, 0.8)'
],
borderColor: [
'rgb(59, 130, 246)',
'rgb(251, 146, 60)',
'rgb(168, 85, 247)',
'rgb(156, 163, 175)'
],
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 15,
font: { size: 11 },
generateLabels: function(chart) {
const data = chart.data;
if (data.labels.length && data.datasets.length) {
return data.labels.map((label, i) => {
const value = data.datasets[0].data[i];
return { text: `${label}: $${value.toFixed(2)}`, fillStyle: data.datasets[0].backgroundColor[i], hidden: false, index: i };
});
}
return [];
}
}
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.parsed || 0;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: $${value.toFixed(2)} (${percentage}%)`;
}
}
}
}
}
});
}
// Expense Timeline Chart
const timelineCtx = document.getElementById('timelineChart')?.getContext('2d');
if (timelineCtx) {
new Chart(timelineCtx, {
type: 'line',
data: {
labels: ['2020', '2021', '2022', '2023', '2024'],
datasets: [{
label: 'Cumulative Investment',
data: [1200, 1350, 1500, 1650, 1700],
borderColor: 'rgb(99, 102, 241)',
backgroundColor: 'rgba(99, 102, 241, 0.1)',
tension: 0.3,
fill: true,
pointRadius: 4,
pointHoverRadius: 6,
pointBackgroundColor: 'rgb(99, 102, 241)',
pointBorderColor: '#fff',
pointBorderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false }, tooltip: { callbacks: { label: function(context) { return `Total Invested: $${context.parsed.y.toFixed(2)}`; } } } },
scales: {
y: {
beginAtZero: true,
ticks: { callback: function(value) { return '$' + value; }, font: { size: 10 } },
grid: { color: 'rgba(0, 0, 0, 0.05)' }
},
x: {
ticks: { font: { size: 10 } },
grid: { display: false }
}
}
}
});
}
</script>

View File

@@ -0,0 +1,507 @@
{# DNS TAB CONTENT #}
{% set totalDnsRecords = dnsRecordCount|default(0) %}
{% set dnsMonitoringEnabled = domain.dns_monitoring_enabled|default(1) %}
{% if not dnsMonitoringEnabled %}
<!-- DNS Monitoring Disabled - show only message, no records -->
<div class="mb-4 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
<div class="flex items-start">
<i class="fas fa-pause-circle text-amber-500 dark:text-amber-400 mt-0.5 mr-3" style="font-size: 18px;"></i>
<div>
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-300">DNS monitoring is disabled</h3>
<p class="text-xs text-amber-700 dark:text-amber-400 mt-1">This domain is not checked by the DNS cron. Enable it in Edit to track DNS changes.</p>
<a href="/domains/{{ domain.id }}/edit#dns-monitoring" class="inline-flex items-center mt-2 text-xs font-medium text-amber-700 dark:text-amber-300 hover:text-amber-900 dark:hover:text-amber-100">
<i class="fas fa-edit mr-1"></i>Enable DNS monitoring in Edit
</a>
</div>
</div>
</div>
{% else %}
{% if totalDnsRecords == 0 %}
<!-- No DNS Data Yet -->
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-8 text-center">
<i class="fas fa-network-wired text-gray-300 dark:text-slate-600 mb-3" style="font-size: 36px;"></i>
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">No DNS Records Yet</h3>
<p class="text-xs text-gray-500 dark:text-slate-400 mb-4">Click "Refresh DNS" to fetch the current DNS records for this domain.</p>
{% if domain %}
<form method="POST" action="/domains/{{ domain.id }}/refresh-dns" class="inline" onsubmit="return handleDnsRefresh(this)">
{{ csrf_field()|raw }}
<button type="submit" class="dns-refresh-btn inline-flex items-center px-4 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i>
<span class="btn-label">Refresh DNS</span>
</button>
</form>
{% endif %}
</div>
{% else %}
<!-- Action Bar -->
<div class="flex justify-between items-center mb-3">
<div class="flex items-center gap-3">
<p class="text-xs text-gray-600 dark:text-slate-400">
<i class="far fa-clock mr-1"></i>
Last checked: {{ domain.dns_last_checked ? domain.dns_last_checked|date('M d, Y H:i') : 'Never' }}
</p>
{% if dnsHasCloudflare|default(false) %}
<span class="inline-flex items-center px-2 py-1 bg-orange-100 dark:bg-orange-500/10 text-orange-800 dark:text-orange-400 text-xs font-semibold rounded border border-orange-200 dark:border-orange-800">
<i class="fas fa-cloud mr-1" style="font-size: 10px;"></i>
Cloudflare Detected
</span>
{% endif %}
</div>
{% if domain and dnsMonitoringEnabled %}
<form method="POST" action="/domains/{{ domain.id }}/refresh-dns" class="inline" onsubmit="return handleDnsRefresh(this)">
{{ csrf_field()|raw }}
<button type="submit" class="dns-refresh-btn inline-flex items-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i>
<span class="btn-label">Refresh DNS</span>
</button>
</form>
{% endif %}
</div>
<!-- DNS Records by Type -->
<div class="space-y-3">
{# ===== SOA Record (Start of Authority) — shown first ===== #}
{% if dnsRecords['SOA'] is defined and dnsRecords['SOA']|length > 0 %}
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 bg-gray-100 dark:bg-slate-700 border-b border-gray-200 dark:border-slate-600">
<h3 class="text-xs font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-info-circle text-gray-500 dark:text-slate-400 mr-2" style="font-size: 10px;"></i>
SOA Record (Start of Authority)
</h3>
</div>
{% for record in dnsRecords['SOA'] %}
{% set rawData = record.raw_data ? record.raw_data|from_json : null %}
<div class="p-4">
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div>
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Primary NS</label>
<p class="text-xs font-mono text-gray-900 dark:text-white mt-1">{{ record.value }}</p>
</div>
<div>
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Admin</label>
<p class="text-xs font-mono text-gray-900 dark:text-white mt-1">{{ rawData.rname|default('N/A') }}</p>
</div>
<div>
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Serial</label>
<p class="text-xs font-mono text-gray-900 dark:text-white mt-1">{{ rawData.serial|default('N/A') }}</p>
</div>
<div>
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">TTL</label>
<p class="text-xs font-mono text-gray-900 dark:text-white mt-1">{{ record.ttl }}s</p>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mt-3 pt-3 border-t border-gray-100 dark:border-slate-700">
<div>
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Refresh</label>
<p class="text-xs font-mono text-gray-900 dark:text-white mt-1">{{ rawData.refresh|default('N/A') }}s</p>
</div>
<div>
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Retry</label>
<p class="text-xs font-mono text-gray-900 dark:text-white mt-1">{{ rawData.retry|default('N/A') }}s</p>
</div>
<div>
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Expire</label>
<p class="text-xs font-mono text-gray-900 dark:text-white mt-1">{{ rawData.expire|default('N/A') }}s</p>
</div>
<div>
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Min TTL</label>
<p class="text-xs font-mono text-gray-900 dark:text-white mt-1">{{ rawData['minimum-ttl']|default('N/A') }}s</p>
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{# ===== A Records ===== #}
{% if dnsRecords['A'] is defined and dnsRecords['A']|length > 0 %}
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 bg-blue-50 dark:bg-blue-500/10 border-b border-blue-200 dark:border-blue-800">
<h3 class="text-xs font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-circle text-blue-600 dark:text-blue-400 mr-2" style="font-size: 8px;"></i>
A Records (IPv4)
<span class="ml-2 px-1.5 py-0.5 bg-blue-600 text-white text-xs font-semibold rounded">{{ dnsRecords['A']|length }}</span>
</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full">
<thead class="bg-gray-50 dark:bg-slate-900">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Host</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IP Address</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">PTR</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">ASN</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
{% for record in dnsRecords['A'] %}
{% set ipInfo = dnsIpDetails[record.value]|default(null) %}
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
<td class="px-4 py-2 text-xs font-medium text-gray-900 dark:text-white">
{% if record.host == '@' %}
<span class="text-blue-600 dark:text-blue-400">@ (root)</span>
{% else %}
{{ record.host }}
{% endif %}
</td>
<td class="px-4 py-2 text-xs">
<span class="font-mono text-gray-900 dark:text-white">{{ record.value }}</span>
{% if record.is_cloudflare %}
<i class="fas fa-cloud text-orange-500 dark:text-orange-400 ml-1.5" style="font-size: 10px;" title="Cloudflare"></i>
{% endif %}
</td>
<td class="px-4 py-2 text-xs font-mono text-gray-600 dark:text-slate-400 max-w-[200px] truncate" title="{{ ipInfo.reverse|default('') }}">
{{ ipInfo.reverse|default('-') }}
</td>
<td class="px-4 py-2.5">
{% if ipInfo and ipInfo.as %}
<div class="flex items-center gap-2">
{% if ipInfo.countryCode %}
<span class="fi fi-{{ ipInfo.countryCode|lower }}" style="font-size: 16px;"></span>
{% endif %}
<div class="text-xs">
<div class="font-semibold text-gray-900 dark:text-white">{{ ipInfo.as|split(' ')|first }}</div>
<div class="text-gray-600 dark:text-slate-400">{{ ipInfo.org|default(ipInfo.isp|default('')) }}</div>
{% if ipInfo.city or ipInfo.regionName %}
<div class="text-gray-500 dark:text-slate-500">{{ ipInfo.city|default('') }}{% if ipInfo.city and ipInfo.regionName %}, {% endif %}{{ ipInfo.regionName|default('') }}</div>
{% endif %}
</div>
</div>
{% else %}
<span class="text-gray-400 dark:text-slate-500 text-xs">-</span>
{% endif %}
</td>
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ===== AAAA Records ===== #}
{% if dnsRecords['AAAA'] is defined and dnsRecords['AAAA']|length > 0 %}
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 bg-indigo-50 dark:bg-indigo-500/10 border-b border-indigo-200 dark:border-indigo-800">
<h3 class="text-xs font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-circle text-indigo-600 dark:text-indigo-400 mr-2" style="font-size: 8px;"></i>
AAAA Records (IPv6)
<span class="ml-2 px-1.5 py-0.5 bg-indigo-600 text-white text-xs font-semibold rounded">{{ dnsRecords['AAAA']|length }}</span>
</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full">
<thead class="bg-gray-50 dark:bg-slate-900">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Host</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IPv6 Address</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">PTR</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">ASN</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
{% for record in dnsRecords['AAAA'] %}
{% set ipInfo = dnsIpDetails[record.value]|default(null) %}
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
<td class="px-4 py-2 text-xs font-medium text-gray-900 dark:text-white">
{% if record.host == '@' %}
<span class="text-indigo-600 dark:text-indigo-400">@ (root)</span>
{% else %}
{{ record.host }}
{% endif %}
</td>
<td class="px-4 py-2 text-xs">
<span class="font-mono text-gray-900 dark:text-white">{{ record.value }}</span>
{% if record.is_cloudflare %}
<i class="fas fa-cloud text-orange-500 dark:text-orange-400 ml-1.5" style="font-size: 10px;" title="Cloudflare"></i>
{% endif %}
</td>
<td class="px-4 py-2 text-xs font-mono text-gray-600 dark:text-slate-400 max-w-[200px] truncate" title="{{ ipInfo.reverse|default('') }}">
{{ ipInfo.reverse|default('-') }}
</td>
<td class="px-4 py-2.5">
{% if ipInfo and ipInfo.as %}
<div class="flex items-center gap-2">
{% if ipInfo.countryCode %}
<span class="fi fi-{{ ipInfo.countryCode|lower }}" style="font-size: 16px;"></span>
{% endif %}
<div class="text-xs">
<div class="font-semibold text-gray-900 dark:text-white">{{ ipInfo.as|split(' ')|first }}</div>
<div class="text-gray-600 dark:text-slate-400">{{ ipInfo.org|default(ipInfo.isp|default('')) }}</div>
{% if ipInfo.city or ipInfo.regionName %}
<div class="text-gray-500 dark:text-slate-500">{{ ipInfo.city|default('') }}{% if ipInfo.city and ipInfo.regionName %}, {% endif %}{{ ipInfo.regionName|default('') }}</div>
{% endif %}
</div>
</div>
{% else %}
<span class="text-gray-400 dark:text-slate-500 text-xs">-</span>
{% endif %}
</td>
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ===== CNAME Records ===== #}
{% if dnsRecords['CNAME'] is defined and dnsRecords['CNAME']|length > 0 %}
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 bg-amber-50 dark:bg-amber-500/10 border-b border-amber-200 dark:border-amber-800">
<h3 class="text-xs font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-link text-amber-600 dark:text-amber-400 mr-2" style="font-size: 10px;"></i>
CNAME Records (Aliases)
<span class="ml-2 px-1.5 py-0.5 bg-amber-600 text-white text-xs font-semibold rounded">{{ dnsRecords['CNAME']|length }}</span>
</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full">
<thead class="bg-gray-50 dark:bg-slate-900">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Alias</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Target</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
{% for record in dnsRecords['CNAME'] %}
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.host }}</td>
<td class="px-4 py-2 text-xs font-mono text-blue-600 dark:text-blue-400">{{ record.value }}</td>
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ===== MX Records ===== #}
{% if dnsRecords['MX'] is defined and dnsRecords['MX']|length > 0 %}
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 bg-green-50 dark:bg-green-500/10 border-b border-green-200 dark:border-green-800">
<h3 class="text-xs font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-envelope text-green-600 dark:text-green-400 mr-2" style="font-size: 10px;"></i>
MX Records (Mail Exchange)
<span class="ml-2 px-1.5 py-0.5 bg-green-600 text-white text-xs font-semibold rounded">{{ dnsRecords['MX']|length }}</span>
</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full">
<thead class="bg-gray-50 dark:bg-slate-900">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Priority</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Mail Server</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
{% for record in dnsRecords['MX'] %}
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
<td class="px-4 py-2">
<span class="inline-flex items-center justify-center w-6 h-6 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 font-bold rounded-full text-xs">{{ record.priority }}</span>
</td>
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.value }}</td>
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ===== TXT Records ===== #}
{% if dnsRecords['TXT'] is defined and dnsRecords['TXT']|length > 0 %}
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 bg-purple-50 dark:bg-purple-500/10 border-b border-purple-200 dark:border-purple-800">
<h3 class="text-xs font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-file-alt text-purple-600 dark:text-purple-400 mr-2" style="font-size: 10px;"></i>
TXT Records
<span class="ml-2 px-1.5 py-0.5 bg-purple-600 text-white text-xs font-semibold rounded">{{ dnsRecords['TXT']|length }}</span>
</h3>
</div>
<div class="p-4 space-y-2">
{% for record in dnsRecords['TXT'] %}
<div class="bg-gray-50 dark:bg-slate-700 rounded p-2 border border-gray-200 dark:border-slate-600">
<div class="flex items-start">
{% set val = record.value|lower %}
{% if val starts with 'v=spf1' %}
{% set txtType = 'SPF' %}
{% elseif val starts with 'v=dkim1' %}
{% set txtType = 'DKIM' %}
{% elseif val starts with 'v=dmarc1' %}
{% set txtType = 'DMARC' %}
{% elseif 'google-site-verification' in val %}
{% set txtType = 'Google' %}
{% elseif 'ms=' in val %}
{% set txtType = 'Microsoft' %}
{% elseif 'facebook-domain-verification' in val %}
{% set txtType = 'Facebook' %}
{% else %}
{% set txtType = 'TXT' %}
{% endif %}
<span class="inline-flex items-center px-1.5 py-0.5 bg-purple-100 dark:bg-purple-500/10 text-purple-800 dark:text-purple-400 text-xs font-semibold rounded mr-2 flex-shrink-0">{{ txtType }}</span>
<p class="flex-1 text-xs font-mono text-gray-900 dark:text-white break-all">{{ record.value }}</p>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{# ===== NS Records ===== #}
{% if dnsRecords['NS'] is defined and dnsRecords['NS']|length > 0 %}
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 bg-teal-50 dark:bg-teal-500/10 border-b border-teal-200 dark:border-teal-800">
<h3 class="text-xs font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-server text-teal-600 dark:text-teal-400 mr-2" style="font-size: 10px;"></i>
NS Records (Name Servers)
<span class="ml-2 px-1.5 py-0.5 bg-teal-600 text-white text-xs font-semibold rounded">{{ dnsRecords['NS']|length }}</span>
</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full">
<thead class="bg-gray-50 dark:bg-slate-900">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">#</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Nameserver</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IPv4</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">IPv6</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
{% for record in dnsRecords['NS'] %}
{% set rawData = record.raw_data ? record.raw_data|from_json : null %}
{% set nsIps = rawData ? rawData._ns_ips|default(null) : null %}
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
<td class="px-4 py-2">
<div class="w-6 h-6 bg-teal-500 rounded flex items-center justify-center text-white font-bold text-xs">{{ loop.index }}</div>
</td>
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.value }}</td>
<td class="px-4 py-2 text-xs font-mono text-gray-600 dark:text-slate-400">
{% if nsIps and nsIps.ipv4|default([])|length > 0 %}
{{ nsIps.ipv4|join(', ') }}
{% else %}-{% endif %}
</td>
<td class="px-4 py-2 text-xs font-mono text-gray-600 dark:text-slate-400">
{% if nsIps and nsIps.ipv6|default([])|length > 0 %}
{{ nsIps.ipv6|join(', ') }}
{% else %}-{% endif %}
</td>
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ===== SRV Records ===== #}
{% if dnsRecords['SRV'] is defined and dnsRecords['SRV']|length > 0 %}
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 bg-violet-50 dark:bg-violet-500/10 border-b border-violet-200 dark:border-violet-800">
<h3 class="text-xs font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-project-diagram text-violet-600 dark:text-violet-400 mr-2" style="font-size: 10px;"></i>
SRV Records (Services)
<span class="ml-2 px-1.5 py-0.5 bg-violet-600 text-white text-xs font-semibold rounded">{{ dnsRecords['SRV']|length }}</span>
</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full">
<thead class="bg-gray-50 dark:bg-slate-900">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Service</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Target</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Port</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Priority</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Weight</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
{% for record in dnsRecords['SRV'] %}
{% set rawData = record.raw_data ? record.raw_data|from_json : {} %}
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ record.host }}</td>
<td class="px-4 py-2 text-xs font-mono text-blue-600 dark:text-blue-400">{{ record.value }}</td>
<td class="px-4 py-2 text-xs text-gray-900 dark:text-white font-semibold">{{ rawData.port|default('-') }}</td>
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.priority|default('-') }}</td>
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ rawData.weight|default('-') }}</td>
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ===== CAA Records ===== #}
{% if dnsRecords['CAA'] is defined and dnsRecords['CAA']|length > 0 %}
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 bg-orange-50 dark:bg-orange-500/10 border-b border-orange-200 dark:border-orange-800">
<h3 class="text-xs font-semibold text-gray-900 dark:text-white flex items-center">
<i class="fas fa-certificate text-orange-600 dark:text-orange-400 mr-2" style="font-size: 10px;"></i>
CAA Records (Certificate Authority)
<span class="ml-2 px-1.5 py-0.5 bg-orange-600 text-white text-xs font-semibold rounded">{{ dnsRecords['CAA']|length }}</span>
</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full">
<thead class="bg-gray-50 dark:bg-slate-900">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Tag</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Value (CA)</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Flags</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">TTL</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-slate-700">
{% for record in dnsRecords['CAA'] %}
{% set rawData = record.raw_data ? record.raw_data|from_json : {} %}
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700">
<td class="px-4 py-2">
<span class="inline-flex items-center px-1.5 py-0.5 bg-orange-100 dark:bg-orange-500/10 text-orange-800 dark:text-orange-400 text-xs font-semibold rounded">{{ rawData.tag|default('-') }}</span>
</td>
<td class="px-4 py-2 text-xs font-mono text-gray-900 dark:text-white">{{ rawData.value|default(record.value) }}</td>
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ rawData.flags|default('0') }}</td>
<td class="px-4 py-2 text-xs text-gray-600 dark:text-slate-400">{{ record.ttl }}s</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
{% endif %}
{% endif %}
<script>
function handleDnsRefresh(form) {
var btn = form.querySelector('.dns-refresh-btn');
if (btn.disabled) return false;
btn.disabled = true;
btn.classList.remove('bg-green-600', 'hover:bg-green-700');
btn.classList.add('bg-gray-400', 'cursor-not-allowed');
var icon = btn.querySelector('i');
var label = btn.querySelector('.btn-label');
if (icon) icon.classList.add('fa-spin');
if (label) label.textContent = 'Scanning DNS...';
return true;
}
</script>

View File

@@ -0,0 +1,131 @@
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex flex-wrap items-center justify-between gap-2">
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
<i class="fas fa-history text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
Notification History
<span id="notification-count" class="ml-1.5 text-gray-600 dark:text-slate-400">({{ logs|length }})</span>
</h3>
{% if logs is not empty %}
<div class="flex flex-wrap items-center gap-2" id="notification-filters">
<select id="filter-channel" class="notification-filter text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-white dark:bg-slate-800 text-gray-700 dark:text-slate-300 focus:ring-1 focus:ring-primary focus:border-primary">
<option value="">All channels</option>
<option value="email">Email</option>
<option value="telegram">Telegram</option>
<option value="discord">Discord</option>
<option value="slack">Slack</option>
<option value="mattermost">Mattermost</option>
<option value="webhook">Webhook</option>
<option value="pushover">Pushover</option>
</select>
<select id="filter-status" class="notification-filter text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-white dark:bg-slate-800 text-gray-700 dark:text-slate-300 focus:ring-1 focus:ring-primary focus:border-primary">
<option value="">All statuses</option>
<option value="sent">Sent</option>
<option value="failed">Failed</option>
</select>
<select id="filter-type" class="notification-filter text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-white dark:bg-slate-800 text-gray-700 dark:text-slate-300 focus:ring-1 focus:ring-primary focus:border-primary">
<option value="">All types</option>
<option value="expiration">Expiration</option>
<option value="status">Status change</option>
<option value="dns">DNS change</option>
</select>
<input type="text" id="filter-search" placeholder="Search message..." class="notification-filter text-xs border border-gray-300 dark:border-slate-600 rounded px-2 py-1 bg-white dark:bg-slate-800 text-gray-700 dark:text-slate-300 w-32 focus:ring-1 focus:ring-primary focus:border-primary" />
<button type="button" id="filter-reset" class="text-xs text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 px-2 py-1" title="Reset filters">Clear</button>
</div>
{% endif %}
</div>
<div class="overflow-hidden">
{% if logs is empty %}
<div class="p-8 text-center">
<i class="fas fa-bell-slash text-gray-300 dark:text-slate-600 text-3xl mb-2"></i>
<p class="text-xs text-gray-500 dark:text-slate-400">No notifications sent yet</p>
</div>
{% else %}
<div id="notification-table-wrap" class="max-h-96 overflow-y-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700 text-xs">
<thead class="bg-gray-50 dark:bg-slate-900 sticky top-0">
<tr>
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Channel</th>
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Status</th>
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Date</th>
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase">Message</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700" id="notification-log-tbody">
{% for log in logs %}
{% set nt = log.notification_type|default('') %}
{% set logType = (nt == 'expired' or (nt|slice(0, 13)) == 'expiring_in_') ? 'expiration' : (((nt|slice(0, 7)) == 'domain_') ? 'status' : ((nt == 'dns_change') ? 'dns' : 'other')) %}
<tr class="notification-log-row hover:bg-gray-50 dark:hover:bg-slate-700" data-channel="{{ log.channel_type }}" data-status="{{ log.status }}" data-type="{{ logType }}" data-message="{{ log.message|e('html_attr') }}">
<td class="px-3 py-2 whitespace-nowrap">
<span class="px-2 py-0.5 rounded text-xs font-medium bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400">
{{ log.channel_type|capitalize }}
</span>
</td>
<td class="px-3 py-2 whitespace-nowrap">
{% set logStatusClass = log.status == 'sent' ? 'bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400' : 'bg-red-100 dark:bg-red-500/10 text-red-800 dark:text-red-400' %}
<span class="px-2 py-0.5 rounded text-xs font-medium {{ logStatusClass }}">
{{ log.status|capitalize }}
</span>
</td>
<td class="px-3 py-2 whitespace-nowrap text-gray-600 dark:text-slate-400">{{ log.sent_at|date('M j, H:i') }}</td>
<td class="px-3 py-2 text-gray-700 dark:text-slate-300 max-w-xs truncate" title="{{ log.message }}">
{{ log.message }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div id="notification-empty-filter" class="hidden p-8 text-center">
<i class="fas fa-filter text-gray-300 dark:text-slate-600 text-3xl mb-2"></i>
<p class="text-xs text-gray-500 dark:text-slate-400">No notifications match the current filters</p>
</div>
{% endif %}
</div>
</div>
{% if logs is not empty %}
<script>
(function() {
var rows = document.querySelectorAll('.notification-log-row');
var countEl = document.getElementById('notification-count');
var emptyFilterEl = document.getElementById('notification-empty-filter');
function applyFilters() {
var channel = document.getElementById('filter-channel').value;
var status = document.getElementById('filter-status').value;
var type = document.getElementById('filter-type').value;
var search = (document.getElementById('filter-search').value || '').toLowerCase();
var visible = 0;
rows.forEach(function(row) {
var match = true;
if (channel && row.dataset.channel !== channel) match = false;
if (status && row.dataset.status !== status) match = false;
if (type && row.dataset.type !== type) match = false;
if (search && row.dataset.message.toLowerCase().indexOf(search) === -1) match = false;
row.style.display = match ? '' : 'none';
if (match) visible++;
});
countEl.textContent = '(' + visible + (visible !== rows.length ? ' of ' + rows.length + ')' : ')');
emptyFilterEl.classList.toggle('hidden', visible > 0);
document.getElementById('notification-table-wrap').classList.toggle('hidden', visible === 0);
}
function resetFilters() {
document.getElementById('filter-channel').value = '';
document.getElementById('filter-status').value = '';
document.getElementById('filter-type').value = '';
document.getElementById('filter-search').value = '';
applyFilters();
}
document.querySelectorAll('.notification-filter').forEach(function(el) {
el.addEventListener('change', applyFilters);
el.addEventListener('input', function() { if (el.id === 'filter-search') applyFilters(); });
});
document.getElementById('filter-reset').addEventListener('click', resetFilters);
})();
</script>
{% endif %}

View File

@@ -0,0 +1,284 @@
<!-- OVERVIEW TAB CONTENT -->
<!-- Main 2-Column Layout -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<!-- LEFT COLUMN -->
<div class="space-y-3">
<!-- Domain Info Card -->
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
<i class="fas fa-info-circle text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
Domain Information
</h3>
</div>
<div class="p-4">
<div class="space-y-2 text-xs">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-slate-400">Registrar:</span>
<span class="text-gray-900 dark:text-white font-medium">{{ domain.registrar ?? 'Unknown' }}</span>
</div>
{% if domain.registrar_url is not empty %}
<div class="flex justify-between">
<span class="text-gray-500 dark:text-slate-400">Registrar URL:</span>
<a href="{{ domain.registrar_url }}" target="_blank" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 flex items-center">
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
Visit
</a>
</div>
{% endif %}
<div class="flex justify-between">
<span class="text-gray-500 dark:text-slate-400">Expires:</span>
{% set expiryColor = domain.expiryColor|default('gray') %}
<span class="text-{{ expiryColor }}-600 dark:text-{{ expiryColor }}-400 font-semibold">
{% if domain.expiration_date is defined and domain.expiration_date %}
{{ domain.expiration_date|date('M d, Y') }}{% if domain.daysLeft is defined and domain.daysLeft is not null %} ({{ domain.daysLeft }} days){% endif %}
{% if domain.isManualExpiration %}
<span class="ml-1 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 dark:bg-amber-500/10 text-amber-800 dark:text-amber-400">
<i class="fas fa-edit mr-0.5" style="font-size: 8px;"></i>Manual
</span>
{% endif %}
{% else %}
Unknown
{% endif %}
</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-slate-400">Created:</span>
<span class="text-gray-900 dark:text-white">{% if whoisData.creation_date is defined %}{{ whoisData.creation_date|date('M d, Y') }}{% else %}-{% endif %}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-slate-400">Last Updated:</span>
<span class="text-gray-900 dark:text-white">{% if domain.updated_date is defined and domain.updated_date %}{{ domain.updated_date|date('M d, Y') }}{% else %}-{% endif %}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-slate-400">Last Checked:</span>
<span class="text-gray-900 dark:text-white">{% if domain.last_checked is defined and domain.last_checked %}{{ domain.last_checked|date('M d, Y H:i') }}{% else %}-{% endif %}</span>
</div>
</div>
</div>
</div>
<!-- Financial Summary (Mockup) -->
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex items-center justify-between">
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
<i class="fas fa-dollar-sign text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
Financial Summary
</h3>
<button onclick="switchTab('billing')" class="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
Details
<i class="fas fa-arrow-right ml-1" style="font-size: 8px;"></i>
</button>
</div>
<div class="p-4">
<div class="space-y-2 text-xs">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-slate-400">Purchase Price:</span>
<span class="text-gray-900 dark:text-white font-medium">$12.99</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-slate-400">Renewal Cost:</span>
<span class="text-gray-900 dark:text-white font-medium">$14.99 / yr</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-slate-400">Total Spent:</span>
<span class="text-gray-900 dark:text-white font-semibold">$42.97</span>
</div>
<div class="border-t border-gray-100 dark:border-slate-700 pt-2 mt-2">
<div class="flex justify-between items-center">
<span class="text-gray-500 dark:text-slate-400">Next Renewal:</span>
{% if domain.expiration_date is defined and domain.expiration_date %}
<span class="inline-flex items-center px-2 py-0.5 bg-{{ (domain.expiryColor ?? 'gray') }}-100 dark:bg-{{ (domain.expiryColor ?? 'gray') }}-500/10 text-{{ (domain.expiryColor ?? 'gray') }}-800 dark:text-{{ (domain.expiryColor ?? 'gray') }}-400 text-xs font-semibold rounded">
{{ domain.expiration_date|date('M d, Y') }}
</span>
{% else %}
<span class="text-gray-400 dark:text-slate-500">-</span>
{% endif %}
</div>
</div>
</div>
<div class="mt-3 px-2 py-1.5 bg-amber-50 dark:bg-amber-500/10 rounded border border-amber-200 dark:border-amber-800">
<p class="text-xs text-amber-700 dark:text-amber-400 flex items-center">
<i class="fas fa-info-circle mr-1.5" style="font-size: 9px;"></i>
Sample data &mdash; billing features coming soon
</p>
</div>
</div>
</div>
<!-- Notes (Inline Editable) -->
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex items-center justify-between">
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
<i class="fas fa-sticky-note text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
Notes
</h3>
<button id="notes-edit-btn" onclick="toggleNotesEdit(true)" class="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
<i class="fas fa-edit mr-1" style="font-size: 9px;"></i>
Edit
</button>
</div>
<div class="p-4">
<!-- View Mode -->
<div id="notes-view-mode">
{% if domain.notes is not empty %}
<div class="text-xs text-gray-900 dark:text-white whitespace-pre-wrap font-mono bg-gray-50 dark:bg-slate-900 rounded p-2 border border-gray-200 dark:border-slate-700 max-h-40 overflow-y-auto">{{ domain.notes }}</div>
{% else %}
<p class="text-xs text-gray-500 dark:text-slate-400 italic">No notes yet. <button onclick="toggleNotesEdit(true)" class="text-blue-600 dark:text-blue-400 hover:underline">Add notes</button></p>
{% endif %}
</div>
<!-- Edit Mode -->
<div id="notes-edit-mode" class="hidden">
<form method="POST" action="/domains/{{ domain.id }}/update-notes" id="overview-notes-form">
{{ csrf_field()|raw }}
<textarea
name="notes"
id="overview-notes-textarea"
rows="6"
class="w-full px-3 py-2 text-xs border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
placeholder="Add notes about this domain...">{{ domain.notes|default('') }}</textarea>
<div class="flex gap-2 mt-2">
<button
type="submit"
class="flex-1 inline-flex items-center justify-center px-3 py-1.5 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors font-medium">
<i class="fas fa-save mr-1.5"></i>
Save
</button>
<button
type="button"
onclick="toggleNotesEdit(false)"
class="flex-1 inline-flex items-center justify-center px-3 py-1.5 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 text-xs rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
<i class="fas fa-times mr-1.5"></i>
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- RIGHT COLUMN -->
<div class="space-y-3">
<!-- Monitoring Status -->
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
<i class="fas fa-heartbeat text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
Monitoring Status
</h3>
</div>
<div class="p-4">
<div class="space-y-2">
<div class="flex items-center justify-between">
<div class="flex items-center">
<i class="fas fa-file-alt text-blue-500 dark:text-blue-400 mr-2" style="font-size: 10px;"></i>
<span class="text-xs text-gray-700 dark:text-slate-300">WHOIS</span>
</div>
{% if domain.is_active %}
<span class="inline-flex items-center px-2 py-0.5 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 text-xs font-semibold rounded">
<i class="fas fa-check-circle mr-1" style="font-size: 9px;"></i>Active
</span>
{% else %}
<span class="inline-flex items-center px-2 py-0.5 bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-400 text-xs font-semibold rounded">
<i class="fas fa-pause-circle mr-1" style="font-size: 9px;"></i>Disabled
</span>
{% endif %}
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<i class="fas fa-lock text-indigo-500 dark:text-indigo-400 mr-2" style="font-size: 10px;"></i>
<span class="text-xs text-gray-700 dark:text-slate-300">SSL</span>
</div>
<span class="inline-flex items-center px-2 py-0.5 bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400 text-xs font-semibold rounded">
<i class="fas fa-minus-circle mr-1" style="font-size: 9px;"></i>Coming Soon
</span>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<i class="fas fa-network-wired text-blue-500 dark:text-blue-400 mr-2" style="font-size: 10px;"></i>
<span class="text-xs text-gray-700 dark:text-slate-300">DNS</span>
</div>
{% if domain.dns_monitoring_enabled|default(1) %}
<span class="inline-flex items-center px-2 py-0.5 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 text-xs font-semibold rounded">
<i class="fas fa-check-circle mr-1" style="font-size: 9px;"></i>Active
</span>
{% else %}
<span class="inline-flex items-center px-2 py-0.5 bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-400 text-xs font-semibold rounded">
<i class="fas fa-pause-circle mr-1" style="font-size: 9px;"></i>Disabled
</span>
{% endif %}
</div>
<div class="flex items-center justify-between">
<div class="flex items-center">
<i class="fas fa-bell text-orange-500 dark:text-orange-400 mr-2" style="font-size: 10px;"></i>
<span class="text-xs text-gray-700 dark:text-slate-300">Notification Group</span>
</div>
{% if domain.group_name is not empty %}
<span class="inline-flex items-center px-2 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400 text-xs font-semibold rounded">
<i class="fas fa-bell mr-1" style="font-size: 9px;"></i>{{ domain.group_name }}
</span>
{% else %}
<a href="/domains/{{ domain.id }}/edit?from=/domains/{{ domain.id }}" class="inline-flex items-center px-2 py-0.5 bg-orange-100 dark:bg-orange-500/10 text-orange-800 dark:text-orange-400 text-xs font-semibold rounded hover:bg-orange-200 dark:hover:bg-orange-500/20 transition-colors">
<i class="fas fa-plus-circle mr-1" style="font-size: 9px;"></i>Assign Group
</a>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Active Channels -->
{% if domain.group_name is not empty %}
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
<i class="fas fa-bell text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
Active Channels
<span class="ml-auto text-xs font-medium text-gray-500 dark:text-slate-400 normal-case tracking-normal">{{ domain.group_name }}</span>
</h3>
</div>
<div class="p-4">
{% if domain.channels is not empty %}
<div class="grid grid-cols-2 gap-2">
{% for channel in domain.channels %}
<div class="flex items-center p-2 rounded {{ channel.is_active ? 'bg-green-50 dark:bg-green-500/10 border border-green-200 dark:border-green-800' : 'bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-slate-700' }}">
<i class="fas fa-{{ channel.is_active ? 'check-circle text-green-600 dark:text-green-400' : 'times-circle text-gray-400 dark:text-slate-500' }} mr-2 text-xs"></i>
<span class="text-xs font-medium text-gray-700 dark:text-slate-300">{{ channel.channel_type|capitalize }}</span>
</div>
{% endfor %}
</div>
<p class="text-xs text-gray-500 dark:text-slate-400 mt-2">{{ domain.activeChannelCount|default(0) }} of {{ domain.channels|length }} channels active</p>
{% else %}
<p class="text-xs text-gray-500 dark:text-slate-400 italic">No channels configured for this group</p>
{% endif %}
</div>
</div>
{% endif %}
<!-- Domain Status Codes -->
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
<i class="fas fa-shield-alt text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
Domain Status Codes
{% if domain is defined and domain.parsedStatuses is defined and domain.parsedStatuses is not empty %}
<span class="ml-1.5 px-1.5 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400 text-xs font-semibold rounded">{{ domain.parsedStatuses|length }}</span>
{% endif %}
</h3>
</div>
<div class="p-4">
<div class="flex flex-wrap gap-1.5">
{% if domain is defined and domain.parsedStatuses is defined and domain.parsedStatuses is not empty %}
{% for status in domain.parsedStatuses %}
<span class="px-2 py-1 bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400 rounded text-xs font-medium" title="{{ status }}">{{ status|replace({'_':' '})|title }}</span>
{% endfor %}
{% else %}
<span class="px-2 py-1 bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-slate-300 rounded text-xs font-medium">No status codes</span>
{% endif %}
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,379 @@
<!-- SSL TAB CONTENT -->
<!-- Preview Banner -->
<div class="mb-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
<div class="flex items-center">
<i class="fas fa-flask text-amber-600 dark:text-amber-400 mr-2" style="font-size: 12px;"></i>
<div>
<p class="text-xs font-semibold text-amber-900 dark:text-amber-300">Preview</p>
<p class="text-xs text-amber-800 dark:text-amber-400 mt-0.5">SSL certificate monitoring is coming soon. This is a design preview with sample data.</p>
</div>
</div>
</div>
<!-- Filters & Actions Bar -->
<div class="mb-3 flex flex-wrap gap-3 justify-between items-center">
<div class="flex-1 max-w-md">
<div class="relative">
<input type="text" id="ssl-search" placeholder="Search certificates..." class="w-full pl-9 pr-3 py-2 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
<i class="fas fa-search absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 dark:text-slate-500 text-xs"></i>
</div>
</div>
<div class="flex gap-2">
<select id="ssl-filter" class="px-3 py-2 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-gray-900 dark:text-white rounded-lg text-sm">
<option value="all">All Certificates</option>
<option value="valid">Valid Only</option>
<option value="expiring">Expiring Soon</option>
<option value="expired">Expired</option>
<option value="invalid">Invalid</option>
</select>
<button onclick="checkAllCertificates()" class="inline-flex items-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
<i class="fas fa-sync-alt mr-1.5" style="font-size: 10px;"></i>
Check All
</button>
<button class="inline-flex items-center px-3 py-2 bg-primary text-white text-xs rounded-lg hover:bg-primary-dark transition-colors font-medium">
<i class="fas fa-plus mr-1.5" style="font-size: 10px;"></i>
Add Subdomain
</button>
</div>
</div>
<!-- Bulk Actions Toolbar (Hidden by default) -->
<div id="ssl-bulk-actions" class="hidden mb-3 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span id="ssl-selected-count" class="text-xs font-medium text-blue-900 dark:text-blue-300"></span>
<button type="button" onclick="bulkCheckSSL()" class="inline-flex items-center px-3 py-1.5 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium">
<i class="fas fa-sync-alt mr-1.5" style="font-size: 9px;"></i>
Check Selected
</button>
<button type="button" onclick="bulkDeleteSSL()" class="inline-flex items-center px-3 py-1.5 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium">
<i class="fas fa-trash mr-1.5" style="font-size: 9px;"></i>
Delete Selected
</button>
<button type="button" onclick="clearSSLSelection()" class="inline-flex items-center px-3 py-1.5 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-gray-700 dark:text-slate-300 text-xs rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium">
<i class="fas fa-times mr-1.5" style="font-size: 9px;"></i>
Clear Selection
</button>
</div>
</div>
</div>
<!-- SSL Statistics -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-3 mb-3">
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Total</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-0.5">3</p>
</div>
<i class="fas fa-lock text-gray-400 dark:text-slate-500" style="font-size: 18px;"></i>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Valid</p>
<p class="text-lg font-semibold text-green-600 dark:text-green-400 mt-0.5">2</p>
</div>
<i class="fas fa-check-circle text-green-500 dark:text-green-400" style="font-size: 18px;"></i>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Expiring Soon</p>
<p class="text-lg font-semibold text-orange-600 dark:text-orange-400 mt-0.5">1</p>
</div>
<i class="fas fa-exclamation-triangle text-orange-500 dark:text-orange-400" style="font-size: 18px;"></i>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-medium text-gray-500 dark:text-slate-400 uppercase">Invalid</p>
<p class="text-lg font-semibold text-red-600 dark:text-red-400 mt-0.5">1</p>
</div>
<i class="fas fa-times-circle text-red-500 dark:text-red-400" style="font-size: 18px;"></i>
</div>
</div>
</div>
<!-- Pagination Info -->
<div class="mb-3 flex justify-between items-center">
<div class="text-xs text-gray-600 dark:text-slate-400">
Showing <span class="font-semibold text-gray-900 dark:text-white">1</span> to <span class="font-semibold text-gray-900 dark:text-white">3</span> of <span class="font-semibold text-gray-900 dark:text-white">3</span> certificate(s)
</div>
<div class="flex items-center gap-2">
<label class="text-xs text-gray-600 dark:text-slate-400">Show:</label>
<select class="px-2 py-1 border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-gray-900 dark:text-white rounded text-xs">
<option>10</option>
<option selected>25</option>
<option>50</option>
</select>
</div>
</div>
<!-- SSL Certificates List -->
<div class="space-y-3">
<!-- Cert 1 (root) -->
<div class="bg-white dark:bg-slate-800 rounded-lg border-2 border-green-200 dark:border-green-800 overflow-hidden ssl-cert-item" data-cert-id="1" data-status="valid">
<div class="px-4 py-2 bg-green-50 dark:bg-green-500/10 border-b border-green-200 dark:border-green-800">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<i class="fas fa-lock text-green-600 dark:text-green-400 mr-2" style="font-size: 14px;"></i>
<div>
<h3 class="text-xs font-semibold text-gray-900 dark:text-white">example.com <span class="ml-2 px-1.5 py-0.5 bg-primary text-white text-xs font-semibold rounded">Root</span></h3>
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">Certificate monitoring active</p>
</div>
</div>
<span class="inline-flex items-center px-2 py-1 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 text-xs font-semibold rounded border border-green-200 dark:border-green-800">
<i class="fas fa-check-circle mr-1" style="font-size: 9px;"></i>
Valid & Trusted
</span>
</div>
</div>
<div class="p-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-3">
<div>
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Validity Period</label>
<div class="mt-1.5 space-y-1.5">
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Issued:</span><span class="text-xs font-medium text-gray-900 dark:text-white">Oct 05, 2025</span></div>
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Expires:</span><span class="text-xs font-semibold text-green-700 dark:text-green-400">Jan 08, 2026</span></div>
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Valid for:</span><span class="text-xs font-bold text-green-600 dark:text-green-400">65 days</span></div>
</div>
</div>
<div>
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Certificate Authority</label>
<div class="mt-1.5">
<p class="text-xs text-gray-900 dark:text-white font-medium">Let's Encrypt Authority X3</p>
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">✓ Trusted CA</p>
</div>
</div>
</div>
<div class="space-y-3">
<div>
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Covered Domains (SANs)</label>
<div class="mt-1.5 space-y-1">
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">example.com</span></div>
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">www.example.com</span></div>
</div>
</div>
<div>
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Security Details</label>
<div class="mt-1.5 space-y-1.5">
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Signature:</span><span class="text-xs font-medium text-gray-900 dark:text-white">SHA256-RSA</span></div>
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Key Size:</span><span class="text-xs font-medium text-gray-900 dark:text-white">2048 bits</span></div>
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Version:</span><span class="text-xs font-medium text-gray-900 dark:text-white">v3</span></div>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between mt-4 pt-4 border-t border-gray-200 dark:border-slate-700">
<div class="text-xs text-gray-500 dark:text-slate-400"><i class="far fa-clock mr-1"></i>Last checked: Today 10:00</div>
<div class="flex gap-2">
<button class="inline-flex items-center px-2 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 transition-colors font-medium"><i class="fas fa-sync-alt mr-1" style="font-size: 9px;"></i>Check Now</button>
</div>
</div>
</div>
</div>
<!-- Cert 2 (mail subdomain) -->
<div class="bg-white dark:bg-slate-800 rounded-lg border-2 border-green-200 dark:border-green-800 overflow-hidden ssl-cert-item" data-cert-id="2" data-status="valid">
<div class="px-4 py-2 bg-green-50 dark:bg-green-500/10 border-b border-green-200 dark:border-green-800">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<input type="checkbox" class="ssl-checkbox rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary" value="2" onchange="updateSSLBulkActions()">
<i class="fas fa-lock text-green-600 dark:text-green-400 mr-2" style="font-size: 14px;"></i>
<div>
<h3 class="text-xs font-semibold text-gray-900 dark:text-white">mail.example.com</h3>
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">Certificate monitoring active</p>
</div>
</div>
<span class="inline-flex items-center px-2 py-1 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 text-xs font-semibold rounded border border-green-200 dark:border-green-800">
<i class="fas fa-check-circle mr-1" style="font-size: 9px;"></i>
Valid & Trusted
</span>
</div>
</div>
<div class="p-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-3">
<div>
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Validity Period</label>
<div class="mt-1.5 space-y-1.5">
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Issued:</span><span class="text-xs font-medium text-gray-900 dark:text-white">Aug 01, 2025</span></div>
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Expires:</span><span class="text-xs font-semibold text-green-700 dark:text-green-400">Jul 28, 2026</span></div>
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Valid for:</span><span class="text-xs font-bold text-green-600 dark:text-green-400">270 days</span></div>
</div>
</div>
<div>
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Certificate Authority</label>
<div class="mt-1.5">
<p class="text-xs text-gray-900 dark:text-white font-medium">DigiCert Inc.</p>
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">✓ Trusted CA</p>
</div>
</div>
</div>
<div class="space-y-3">
<div>
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Covered Domains (SANs)</label>
<div class="mt-1.5 space-y-1">
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">mail.example.com</span></div>
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">smtp.example.com</span></div>
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">imap.example.com</span></div>
</div>
</div>
<div>
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Security Details</label>
<div class="mt-1.5 space-y-1.5">
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Signature:</span><span class="text-xs font-medium text-gray-900 dark:text-white">SHA256-RSA</span></div>
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Key Size:</span><span class="text-xs font-medium text-gray-900 dark:text-white">2048 bits</span></div>
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Version:</span><span class="text-xs font-medium text-gray-900 dark:text-white">v3</span></div>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between mt-4 pt-4 border-t border-gray-200 dark:border-slate-700">
<div class="text-xs text-gray-500 dark:text-slate-400"><i class="far fa-clock mr-1"></i>Last checked: Today 10:00</div>
<div class="flex gap-2">
<button class="inline-flex items-center px-2 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 transition-colors font-medium"><i class="fas fa-sync-alt mr-1" style="font-size: 9px;"></i>Check Now</button>
<button class="inline-flex items-center px-2 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700 transition-colors font-medium"><i class="fas fa-trash mr-1" style="font-size: 9px;"></i>Remove</button>
</div>
</div>
</div>
</div>
<!-- Cert 3 (api - expired) -->
<div class="bg-white dark:bg-slate-800 rounded-lg border-2 border-red-200 dark:border-red-800 overflow-hidden ssl-cert-item" data-cert-id="3" data-status="expired">
<div class="px-4 py-2 bg-red-50 dark:bg-red-500/10 border-b border-red-200 dark:border-red-800">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<input type="checkbox" class="ssl-checkbox rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary" value="3" onchange="updateSSLBulkActions()">
<i class="fas fa-lock text-red-600 dark:text-red-400 mr-2" style="font-size: 14px;"></i>
<div>
<h3 class="text-xs font-semibold text-gray-900 dark:text-white">api.example.com</h3>
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">Certificate monitoring active</p>
</div>
</div>
<span class="inline-flex items-center px-2 py-1 bg-red-100 dark:bg-red-500/10 text-red-800 dark:text-red-400 text-xs font-semibold rounded border border-red-200 dark:border-red-800">
<i class="fas fa-times-circle mr-1" style="font-size: 9px;"></i>
EXPIRED
</span>
</div>
</div>
<div class="p-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-3">
<div>
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Validity Period</label>
<div class="mt-1.5 space-y-1.5">
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Issued:</span><span class="text-xs font-medium text-gray-900 dark:text-white">Sep 26, 2024</span></div>
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Expires:</span><span class="text-xs font-semibold text-red-700 dark:text-red-400">Sep 30, 2025</span></div>
<div class="flex justify-between items-center"><span class="text-xs text-gray-600 dark:text-slate-400">Valid for:</span><span class="text-xs font-bold text-red-600 dark:text-red-400">30 days (expired)</span></div>
</div>
</div>
<div>
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Certificate Authority</label>
<div class="mt-1.5">
<p class="text-xs text-gray-900 dark:text-white font-medium">Self-Signed</p>
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">⚠️ Not Trusted</p>
</div>
</div>
<div class="bg-red-50 dark:bg-red-500/10 border border-red-200 dark:border-red-800 rounded p-2">
<p class="text-xs font-semibold text-red-900 dark:text-red-300 mb-0.5">Error Details</p>
<p class="text-xs text-red-700 dark:text-red-400">Certificate has expired</p>
</div>
</div>
<div class="space-y-3">
<div>
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Covered Domains (SANs)</label>
<div class="mt-1.5 space-y-1">
<div class="flex items-center text-xs"><i class="fas fa-check text-green-500 dark:text-green-400 mr-1.5" style="font-size: 9px;"></i><span class="text-gray-900 dark:text-white font-mono">api.example.com</span></div>
</div>
</div>
<div>
<label class="text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Security Details</label>
<div class="mt-1.5 space-y-1.5">
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Signature:</span><span class="text-xs font-medium text-gray-900 dark:text-white">SHA256-RSA</span></div>
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Key Size:</span><span class="text-xs font-medium text-gray-900 dark:text-white">2048 bits</span></div>
<div class="flex justify-between"><span class="text-xs text-gray-600 dark:text-slate-400">Version:</span><span class="text-xs font-medium text-gray-900 dark:text-white">v3</span></div>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between mt-4 pt-4 border-t border-gray-200 dark:border-slate-700">
<div class="text-xs text-gray-500 dark:text-slate-400"><i class="far fa-clock mr-1"></i>Last checked: Today 11:00</div>
<div class="flex gap-2">
<button class="inline-flex items-center px-2 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 transition-colors font-medium"><i class="fas fa-sync-alt mr-1" style="font-size: 9px;"></i>Check Now</button>
<button class="inline-flex items-center px-2 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700 transition-colors font-medium"><i class="fas fa-trash mr-1" style="font-size: 9px;"></i>Remove</button>
</div>
</div>
</div>
</div>
</div>
<!-- Pagination Controls -->
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
<div class="text-sm text-gray-600 dark:text-slate-400">Page <span class="font-semibold text-gray-900 dark:text-white">1</span> of <span class="font-semibold text-gray-900 dark:text-white">1</span></div>
<div class="flex items-center gap-1">
<button disabled class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-gray-100 dark:bg-slate-700 text-gray-400 dark:text-slate-500 cursor-not-allowed"><i class="fas fa-angle-double-left"></i></button>
<button disabled class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-gray-100 dark:bg-slate-700 text-gray-400 dark:text-slate-500 cursor-not-allowed"><i class="fas fa-angle-left"></i> Previous</button>
<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">1</span>
<button disabled class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-gray-100 dark:bg-slate-700 text-gray-400 dark:text-slate-500 cursor-not-allowed">Next <i class="fas fa-angle-right"></i></button>
<button disabled class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-gray-100 dark:bg-slate-700 text-gray-400 dark:text-slate-500 cursor-not-allowed"><i class="fas fa-angle-double-right"></i></button>
</div>
</div>
<script>
function updateSSLBulkActions() {
const checkboxes = document.querySelectorAll('.ssl-checkbox:checked');
const bulkActions = document.getElementById('ssl-bulk-actions');
const selectedCount = document.getElementById('ssl-selected-count');
if (checkboxes.length > 0) {
bulkActions.classList.remove('hidden');
selectedCount.textContent = `${checkboxes.length} certificate(s) selected`;
} else {
bulkActions.classList.add('hidden');
}
}
function clearSSLSelection() {
document.querySelectorAll('.ssl-checkbox').forEach(cb => cb.checked = false);
updateSSLBulkActions();
}
function getSelectedSSLIds() {
return Array.from(document.querySelectorAll('.ssl-checkbox:checked')).map(cb => cb.value);
}
function bulkCheckSSL() {
const ids = getSelectedSSLIds();
console.log('Checking SSL certificates:', ids);
}
function bulkDeleteSSL() {
const ids = getSelectedSSLIds();
if (confirm(`Delete ${ids.length} certificate(s)? This action cannot be undone.`)) {
console.log('Deleting SSL certificates:', ids);
}
}
function checkAllCertificates() {
console.log('Checking all certificates...');
}
document.getElementById('ssl-search')?.addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
document.querySelectorAll('.ssl-cert-item').forEach(item => {
const text = item.textContent.toLowerCase();
item.style.display = text.includes(searchTerm) ? '' : 'none';
});
});
document.getElementById('ssl-filter')?.addEventListener('change', function(e) {
const filter = e.target.value;
document.querySelectorAll('.ssl-cert-item').forEach(item => {
if (filter === 'all') {
item.style.display = '';
} else {
const status = item.dataset.status;
item.style.display = status === filter ? '' : 'none';
}
});
});
</script>

View File

@@ -0,0 +1,241 @@
<!-- WHOIS TAB CONTENT -->
{% if not domain.is_active %}
<!-- Active Monitoring Disabled Banner -->
<div class="mb-4 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-lg p-4">
<div class="flex items-start">
<i class="fas fa-pause-circle text-amber-500 dark:text-amber-400 mt-0.5 mr-3" style="font-size: 18px;"></i>
<div>
<h3 class="text-sm font-semibold text-amber-800 dark:text-amber-300">Active monitoring is disabled</h3>
<p class="text-xs text-amber-700 dark:text-amber-400 mt-1">This domain is not checked by the cron. You will not receive status or expiration alerts.</p>
<a href="/domains/{{ domain.id }}/edit#dns-monitoring" class="inline-flex items-center mt-2 text-xs font-medium text-amber-700 dark:text-amber-300 hover:text-amber-900 dark:hover:text-amber-100">
<i class="fas fa-edit mr-1"></i>Enable active monitoring in Edit
</a>
</div>
</div>
</div>
{% endif %}
<!-- 3-Column Layout: Registration, Registrant, Dates -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-3">
<!-- Registration Information -->
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900 flex items-center justify-between">
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
<i class="fas fa-building text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
Registration
</h3>
<form method="POST" action="/domains/{{ domain.id }}/refresh-whois" class="inline" onsubmit="prepareReturnTo(event)">
{{ csrf_field()|raw }}
<input type="hidden" name="return_to" value="">
<button type="submit" class="text-xs px-2 py-1 bg-green-600 text-white rounded hover:bg-green-700 transition-colors">
<i class="fas fa-sync-alt mr-1" style="font-size: 9px;"></i>
Refresh WHOIS
</button>
</form>
</div>
<div class="p-4">
<div class="space-y-2.5 text-xs">
<div>
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Registrar</label>
<p class="text-gray-900 dark:text-white font-semibold">{{ domain.registrar ?? 'Unknown' }}</p>
</div>
<div>
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Registrar URL</label>
{% if domain.registrar_url is defined and domain.registrar_url %}
<a href="{{ domain.registrar_url }}" target="_blank" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 flex items-center">
<i class="fas fa-external-link-alt mr-1" style="font-size: 9px;"></i>
Visit Registrar
</a>
{% else %}
<span class="text-gray-400 dark:text-slate-500">-</span>
{% endif %}
</div>
<div>
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">IANA ID</label>
<p class="text-gray-900 dark:text-white">{{ whoisData.iana_id ?? '-' }}</p>
</div>
<div>
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Abuse Contact</label>
{% if domain.abuse_email %}
<a href="mailto:{{ domain.abuse_email }}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 block break-all">{{ domain.abuse_email }}</a>
{% else %}
<span class="text-gray-400 dark:text-slate-500">-</span>
{% endif %}
</div>
<div>
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">WHOIS Server</label>
<p class="text-gray-900 dark:text-white font-mono break-all">{{ whoisData.whois_server ?? '-' }}</p>
</div>
</div>
</div>
</div>
<!-- Registrant Information -->
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
<i class="fas fa-user text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
Registrant
</h3>
</div>
<div class="p-4">
<div class="space-y-2.5 text-xs">
<div>
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Name</label>
<p class="text-gray-900 dark:text-white font-medium">{{ whoisData.owner ?? '-' }}</p>
</div>
<div>
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Organization</label>
<p class="text-gray-900 dark:text-white">{{ whoisData.organization ?? '-' }}</p>
</div>
<div>
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Email</label>
{% if whoisData.email is defined and whoisData.email %}
<a href="mailto:{{ whoisData.email }}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 break-all">{{ whoisData.email }}</a>
{% else %}
<span class="text-gray-400 dark:text-slate-500">-</span>
{% endif %}
</div>
<div>
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Country</label>
<p class="text-gray-900 dark:text-white">{{ whoisData.country ?? '-' }}</p>
</div>
<div>
<label class="text-gray-500 dark:text-slate-400 font-medium block mb-0.5">Privacy Protection</label>
<span class="inline-flex items-center px-2 py-0.5 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 text-xs font-semibold rounded">
<i class="fas fa-shield-alt mr-1" style="font-size: 9px;"></i>
Enabled
</span>
</div>
</div>
</div>
</div>
<!-- Important Dates -->
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
<i class="fas fa-calendar-alt text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
Important Dates
</h3>
</div>
<div class="p-4">
<div class="space-y-2">
<div class="flex items-center p-2 bg-green-50 dark:bg-green-500/10 rounded border border-green-200 dark:border-green-800">
<div class="w-7 h-7 bg-green-500 rounded flex items-center justify-center mr-2 flex-shrink-0">
<i class="fas fa-plus text-white text-xs"></i>
</div>
<div>
<p class="text-xs text-gray-600 dark:text-slate-400 font-medium">Created</p>
<p class="text-xs font-semibold text-gray-900 dark:text-white">{% if whoisData.creation_date is defined %}{{ whoisData.creation_date|date('M d, Y') }}{% else %}-{% endif %}</p>
</div>
</div>
<div class="flex items-center p-2 bg-blue-50 dark:bg-blue-500/10 rounded border border-blue-200 dark:border-blue-800">
<div class="w-7 h-7 bg-blue-500 rounded flex items-center justify-center mr-2 flex-shrink-0">
<i class="fas fa-edit text-white text-xs"></i>
</div>
<div>
<p class="text-xs text-gray-600 dark:text-slate-400 font-medium">Last Updated</p>
<p class="text-xs font-semibold text-gray-900 dark:text-white">{% if domain.updated_date is defined and domain.updated_date %}{{ domain.updated_date|date('M d, Y') }}{% else %}-{% endif %}</p>
</div>
</div>
<div class="flex items-center p-2 bg-orange-50 dark:bg-orange-500/10 rounded border border-orange-200 dark:border-orange-800">
<div class="w-7 h-7 bg-orange-500 rounded flex items-center justify-center mr-2 flex-shrink-0">
<i class="fas fa-calendar-times text-white text-xs"></i>
</div>
<div>
<p class="text-xs text-gray-600 dark:text-slate-400 font-medium">Expires</p>
<p class="text-xs font-semibold text-orange-700 dark:text-orange-400">
{% if domain.expiration_date is defined and domain.expiration_date %}
{{ domain.expiration_date|date('M d, Y') }}{% if domain.daysLeft is defined and domain.daysLeft is not null %} ({{ domain.daysLeft }} days){% endif %}
{% else %}-{% endif %}
</p>
</div>
</div>
<div class="flex items-center p-2 bg-indigo-50 dark:bg-indigo-500/10 rounded border border-indigo-200 dark:border-indigo-800">
<div class="w-7 h-7 bg-indigo-500 rounded flex items-center justify-center mr-2 flex-shrink-0">
<i class="fas fa-sync text-white text-xs"></i>
</div>
<div>
<p class="text-xs text-gray-600 dark:text-slate-400 font-medium">Last Checked</p>
<p class="text-xs font-semibold text-gray-900 dark:text-white">{% if domain.last_checked is defined and domain.last_checked %}{{ domain.last_checked|date('M d, Y H:i') }}{% else %}-{% endif %}</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Nameservers & Domain Status (2-col) -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 mt-3">
<!-- Nameservers -->
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
<i class="fas fa-server text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
Name Servers
{% if whoisData.nameservers is defined and whoisData.nameservers is not empty %}
<span class="ml-1.5 px-1.5 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400 text-xs font-semibold rounded">{{ whoisData.nameservers|length }}</span>
{% endif %}
</h3>
</div>
<div class="p-4">
<div class="space-y-1.5">
{% if whoisData.nameservers is defined and whoisData.nameservers is not empty %}
{% for ns in whoisData.nameservers %}
<div class="flex items-center p-2 bg-gray-50 dark:bg-slate-700 rounded hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
<div class="w-5 h-5 bg-primary rounded flex items-center justify-center text-white font-bold text-xs mr-2 flex-shrink-0">{{ loop.index }}</div>
<span class="font-mono text-xs text-gray-900 dark:text-slate-200 break-all">{{ ns }}</span>
</div>
{% endfor %}
{% else %}
<div class="text-center py-3">
<i class="fas fa-server text-gray-300 dark:text-slate-600 text-lg mb-1"></i>
<p class="text-xs text-gray-500 dark:text-slate-400">No nameservers</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Domain Status Codes -->
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-900">
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 uppercase tracking-wider flex items-center">
<i class="fas fa-shield-alt text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
Domain Status Codes
{% if domain is defined and domain.parsedStatuses is defined and domain.parsedStatuses is not empty %}
<span class="ml-1.5 px-1.5 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400 text-xs font-semibold rounded">{{ domain.parsedStatuses|length }}</span>
{% endif %}
</h3>
</div>
<div class="p-4">
<div class="flex flex-wrap gap-1.5">
{% if domain is defined and domain.parsedStatuses is defined and domain.parsedStatuses is not empty %}
{% for status in domain.parsedStatuses %}
<span class="px-2 py-1 bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400 rounded text-xs font-medium" title="{{ status }}">{{ status|replace({'_':' '})|title }}</span>
{% endfor %}
{% else %}
<span class="px-2 py-1 bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-slate-300 rounded text-xs font-medium">No status codes</span>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Raw WHOIS (Collapsible) -->
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden mt-3">
<button onclick="toggleRawWhois()" class="w-full px-4 py-2 bg-gray-50 dark:bg-slate-900 text-left hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors">
<h3 class="text-xs font-semibold text-gray-700 dark:text-slate-300 flex items-center justify-between">
<span class="flex items-center uppercase tracking-wider">
<i class="fas fa-code text-gray-400 dark:text-slate-500 mr-2" style="font-size: 10px;"></i>
Raw WHOIS Data
</span>
<i class="fas fa-chevron-down text-gray-400 dark:text-slate-500 transition-transform text-xs" id="raw-whois-icon"></i>
</h3>
</button>
<div id="raw-whois-content" class="hidden p-4 bg-gray-900 max-h-64 overflow-y-auto">
<pre class="text-xs text-green-400 font-mono">{{ whoisData|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre>
</div>
</div>

View File

@@ -0,0 +1,269 @@
{% extends "layout/base.twig" %}
{% set title = 'Domain Details' %}
{% set pageTitle = domain.domain_name|default('Domain Details') %}
{% set pageDescription = 'Domain information and monitoring status' %}
{% set pageIcon = 'fas fa-globe' %}
{% set daysLeft = domain.daysLeft %}
{% set domainStatus = domain.displayStatus %}
{% set expiryColor = domain.expiryColor %}
{% block content %}
<!-- Top Action Bar -->
<div class="mb-3 flex flex-wrap gap-2 justify-between items-center">
<div class="flex flex-wrap gap-2">
{% if domain %}
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold {{ domain.statusClass }}">
<i class="fas {{ domain.statusIcon }} mr-1.5"></i>
{{ domain.statusText }}
</span>
{% if domain.displayStatus != 'available' %}
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold bg-{{ domain.expiryColor }}-100 dark:bg-{{ domain.expiryColor }}-500/10 text-{{ domain.expiryColor }}-800 dark:text-{{ domain.expiryColor }}-400 border border-{{ domain.expiryColor }}-200 dark:border-{{ domain.expiryColor }}-800">
<i class="fas fa-calendar-alt mr-1.5"></i>
{{ domain.daysLeft is not null ? domain.daysLeft ~ ' days left' : 'No expiry date' }}
</span>
{% endif %}
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold bg-indigo-100 dark:bg-indigo-500/10 text-indigo-800 dark:text-indigo-400 border border-indigo-200 dark:border-indigo-800">
<i class="fas fa-{{ domain.is_active ? 'check-circle' : 'pause-circle' }} mr-1.5"></i>
{{ domain.is_active ? 'Monitoring Active' : 'Monitoring Paused' }}
</span>
{# Tags Display (match view.twig) #}
{% if domain.tags is not empty %}
{% set tags = domain.tags|split(',') %}
{% set tagColors = domain.tag_colors is not empty ? domain.tag_colors|split('|') : [] %}
{% set tagColorMap = {} %}
{% for availableTag in availableTags %}
{% set tagColorMap = tagColorMap|merge({(availableTag.name): availableTag.color}) %}
{% endfor %}
{% for tag in tags %}
{% set tag = tag|trim %}
{% set colorClass = tagColorMap[tag] ?? (tagColors[loop.index0] ?? 'bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-slate-300 border-gray-200 dark:border-slate-700') %}
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold border {{ colorClass }}">
<i class="fas fa-tag mr-1.5" style="font-size: 10px;"></i>
{{ tag|capitalize }}
</span>
{% endfor %}
{% endif %}
{% else %}
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 border border-green-200 dark:border-green-800">
<i class="fas fa-check-circle mr-1.5"></i>
Active
</span>
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold bg-orange-100 dark:bg-orange-500/10 text-orange-800 dark:text-orange-400 border border-orange-200 dark:border-orange-800">
<i class="fas fa-calendar-alt mr-1.5"></i>
65 days left
</span>
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold bg-indigo-100 dark:bg-indigo-500/10 text-indigo-800 dark:text-indigo-400 border border-indigo-200 dark:border-indigo-800">
<i class="fas fa-check-circle mr-1.5"></i>
Monitoring Active
</span>
{% endif %}
</div>
<div class="flex gap-2 items-center">
{% if domain %}
<form method="POST" action="/domains/{{ domain.id }}/refresh-all" class="inline" onsubmit="prepareReturnTo(event); return handleRefreshBtn(this);">
{{ csrf_field()|raw }}
<input type="hidden" name="return_to" id="return_to_input">
<button type="submit" class="refresh-all-btn inline-flex items-center justify-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium min-w-[80px] h-[32px]">
<i class="fas fa-sync-alt mr-1.5"></i>
<span class="btn-label">Refresh All</span>
</button>
</form>
<a href="/domains/{{ domain.id }}/edit?from=/domains/{{ domain.id }}" class="inline-flex items-center justify-center px-3 py-2 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors font-medium min-w-[80px] h-[32px]">
<i class="fas fa-edit mr-1.5"></i>
Edit
</a>
<form method="POST" action="/domains/{{ domain.id }}/delete" onsubmit="return confirm('Delete this domain?')" class="inline">
{{ csrf_field()|raw }}
<button type="submit" class="inline-flex items-center justify-center px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium min-w-[80px] h-[32px]">
<i class="fas fa-trash mr-1.5"></i>
Delete
</button>
</form>
{% else %}
<button class="inline-flex items-center justify-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium min-w-[80px] h-[32px]" disabled>
<i class="fas fa-sync-alt mr-1.5"></i>
Refresh All
</button>
<a href="#" class="inline-flex items-center justify-center px-3 py-2 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors font-medium min-w-[80px] h-[32px]">
<i class="fas fa-edit mr-1.5"></i>
Edit
</a>
<button class="inline-flex items-center justify-center px-3 py-2 bg-red-600 text-white text-xs rounded-lg hover:bg-red-700 transition-colors font-medium min-w-[80px] h-[32px]">
<i class="fas fa-trash mr-1.5"></i>
Delete
</button>
{% endif %}
<a href="/domains" class="inline-flex items-center justify-center px-3 py-2 border border-gray-300 dark:border-slate-600 text-gray-700 dark:text-slate-300 text-xs rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors font-medium min-w-[80px] h-[32px]">
<i class="fas fa-arrow-left mr-1.5"></i>
Back
</a>
</div>
</div>
<!-- Tab Navigation -->
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden mb-3">
<div class="border-b border-gray-200 dark:border-slate-700">
<nav class="-mb-px flex">
<button onclick="switchTab('overview')" id="tab-overview" class="tab-button active flex-1 px-4 py-2.5 text-xs font-medium border-b-2 border-primary text-primary bg-blue-50 dark:bg-slate-700">
<i class="fas fa-chart-line mr-1.5" style="font-size: 10px;"></i>
Overview
</button>
<button onclick="switchTab('whois')" id="tab-whois" class="tab-button flex-1 px-4 py-2.5 text-xs font-medium border-b-2 border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 hover:border-gray-300 dark:hover:border-slate-500">
<i class="fas fa-file-alt mr-1.5" style="font-size: 10px;"></i>
WHOIS
{% if not domain.is_active %}
<i class="fas fa-pause-circle text-amber-500 dark:text-amber-400 ml-1" style="font-size: 10px;" title="Active monitoring disabled"></i>
{% endif %}
</button>
<button onclick="switchTab('ssl')" id="tab-ssl" class="tab-button flex-1 px-4 py-2.5 text-xs font-medium border-b-2 border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 hover:border-gray-300 dark:hover:border-slate-500">
<i class="fas fa-lock mr-1.5" style="font-size: 10px;"></i>
SSL
<span class="ml-1.5 px-1.5 py-0.5 bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 text-xs font-semibold rounded">2</span>
<span class="ml-1 px-1.5 py-0.5 bg-red-100 dark:bg-red-500/10 text-red-800 dark:text-red-400 text-xs font-semibold rounded">1</span>
</button>
<button onclick="switchTab('dns')" id="tab-dns" class="tab-button flex-1 px-4 py-2.5 text-xs font-medium border-b-2 border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 hover:border-gray-300 dark:hover:border-slate-500">
<i class="fas fa-network-wired mr-1.5" style="font-size: 10px;"></i>
DNS
{% if not (domain.dns_monitoring_enabled|default(1)) %}
<i class="fas fa-pause-circle text-amber-500 dark:text-amber-400 ml-1" style="font-size: 10px;" title="DNS monitoring disabled"></i>
{% else %}
{% if dnsRecordCount|default(0) > 0 %}
<span class="ml-1.5 px-1.5 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400 text-xs font-semibold rounded">{{ dnsRecordCount }}</span>
{% endif %}
{% if dnsHasCloudflare|default(false) %}
<i class="fas fa-cloud text-orange-500 dark:text-orange-400 ml-1" style="font-size: 10px;" title="Behind Cloudflare"></i>
{% endif %}
{% endif %}
</button>
<button onclick="switchTab('billing')" id="tab-billing" class="tab-button flex-1 px-4 py-2.5 text-xs font-medium border-b-2 border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 hover:border-gray-300 dark:hover:border-slate-500">
<i class="fas fa-dollar-sign mr-1.5" style="font-size: 10px;"></i>
Billing
</button>
<button onclick="switchTab('notifications')" id="tab-notifications" class="tab-button flex-1 px-4 py-2.5 text-xs font-medium border-b-2 border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-200 hover:border-gray-300 dark:hover:border-slate-500">
<i class="fas fa-bell mr-1.5" style="font-size: 10px;"></i>
Notifications
{% if logs is defined and logs|length > 0 %}
<span class="ml-1.5 px-1.5 py-0.5 bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-400 text-xs font-semibold rounded">{{ logs|length }}</span>
{% endif %}
</button>
</nav>
</div>
</div>
<!-- Tab Content -->
<div class="tab-content-wrapper">
<div id="content-overview" class="tab-content">
{% include 'domains/tabs/overview.twig' %}
</div>
<div id="content-whois" class="tab-content hidden">
{% include 'domains/tabs/whois.twig' %}
</div>
<div id="content-ssl" class="tab-content hidden">
{% include 'domains/tabs/ssl.twig' %}
</div>
<div id="content-dns" class="tab-content hidden">
{% include 'domains/tabs/dns.twig' %}
</div>
<div id="content-billing" class="tab-content hidden">
{% include 'domains/tabs/billing.twig' %}
</div>
<div id="content-notifications" class="tab-content hidden">
{% include 'domains/tabs/notification.twig' %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const hash = (window.location.hash || '#overview').replace('#','');
switchTab(hash);
});
function updateTabHash(tabName) {
const url = new URL(window.location.href);
url.hash = '#' + tabName;
window.history.replaceState({}, '', url);
}
function switchTab(tabName) {
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.add('hidden');
});
document.querySelectorAll('.tab-button').forEach(button => {
button.classList.remove('active', 'border-primary', 'text-primary', 'bg-blue-50', 'dark:bg-slate-700');
button.classList.add('border-transparent', 'text-gray-500', 'dark:text-slate-400');
});
document.getElementById('content-' + tabName).classList.remove('hidden');
const activeTab = document.getElementById('tab-' + tabName);
activeTab.classList.add('active', 'border-primary', 'text-primary', 'bg-blue-50', 'dark:bg-slate-700');
activeTab.classList.remove('border-transparent', 'text-gray-500', 'dark:text-slate-400');
updateTabHash(tabName);
}
function toggleRawWhois() {
const content = document.getElementById('raw-whois-content');
const icon = document.getElementById('raw-whois-icon');
content.classList.toggle('hidden');
icon.classList.toggle('rotate-180');
}
function toggleDnsHistory() {
const content = document.getElementById('dns-history-content');
const icon = document.getElementById('dns-history-icon');
content.classList.toggle('hidden');
icon.classList.toggle('rotate-180');
}
function toggleNotesEdit(editMode) {
const viewMode = document.getElementById('notes-view-mode');
const editModeEl = document.getElementById('notes-edit-mode');
const editBtn = document.getElementById('notes-edit-btn');
if (editMode) {
viewMode.classList.add('hidden');
editModeEl.classList.remove('hidden');
editBtn.classList.add('hidden');
document.getElementById('overview-notes-textarea').focus();
} else {
viewMode.classList.remove('hidden');
editModeEl.classList.add('hidden');
editBtn.classList.remove('hidden');
document.getElementById('overview-notes-textarea').value = {{ domain.notes|default('')|json_encode|raw }};
}
}
function prepareReturnTo(e) {
const form = e.target;
const input = form.querySelector('input[name="return_to"]') || document.getElementById('return_to_input');
if (!input) return;
const url = new URL(window.location.href);
if (!url.hash) {
const active = document.querySelector('.tab-button.active');
if (active && active.id) {
url.hash = '#' + active.id.replace('tab-','');
}
}
input.value = url.pathname + (url.hash ? url.hash : '');
}
function handleRefreshBtn(form) {
var btn = form.querySelector('.refresh-all-btn');
if (!btn || btn.disabled) return false;
btn.disabled = true;
btn.classList.remove('bg-green-600', 'hover:bg-green-700');
btn.classList.add('bg-gray-400', 'cursor-not-allowed');
var icon = btn.querySelector('i');
var label = btn.querySelector('.btn-label');
if (icon) icon.classList.add('fa-spin');
if (label) label.textContent = 'Refreshing...';
return true;
}
</script>
{% endblock %}

View File

@@ -50,11 +50,11 @@
{% endfor %}
</div>
<div class="flex gap-2 items-center">
<form method="POST" action="/domains/{{ domain.id }}/refresh" class="inline">
<form method="POST" action="/domains/{{ domain.id }}/refresh-whois" class="inline">
{{ csrf_field() }}
<button type="submit" class="inline-flex items-center justify-center px-3 py-2 bg-green-600 text-white text-xs rounded-lg hover:bg-green-700 transition-colors font-medium min-w-[80px] h-[32px]">
<i class="fas fa-sync-alt mr-1.5"></i>
Refresh
Refresh WHOIS
</button>
</form>
<a href="/domains/{{ domain.id }}/edit?from=/domains/{{ domain.id }}" class="inline-flex items-center justify-center px-3 py-2 bg-blue-600 text-white text-xs rounded-lg hover:bg-blue-700 transition-colors font-medium min-w-[80px] h-[32px]">

View File

@@ -238,6 +238,7 @@
<!-- Add Domain Button -->
<div class="mt-6 pt-6 border-t border-gray-200 dark:border-slate-700">
<form method="POST" action="/domains/store" class="flex items-center justify-between">
{{ csrf_field()|raw }}
<input type="hidden" name="domain_name" value="{{ whoisData.domain }}">
<p class="text-sm text-gray-600 dark:text-slate-400">Want to monitor this domain?</p>
<button type="submit" class="inline-flex items-center px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">

View File

@@ -8,6 +8,7 @@
{% set currentNotificationDays = settings.notification_days_before|default('30,15,7,3,1') %}
{% set currentCheckInterval = settings.check_interval_hours|default('24') %}
{% set lastCheckRun = settings.last_check_run|default(null) %}
{% set lastDnsCheckRun = settings.last_dns_check_run|default(null) %}
{% set currentVer = appSettings.app_version|default('0') %}
{% set updateChannel = updateSettings.update_channel|default('stable') %}
@@ -148,6 +149,28 @@
</div>
</div>
<!-- Domain View Template -->
<div class="border-t border-gray-200 dark:border-slate-700 pt-4 mt-6">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-4">Domain View</h4>
<div>
<label for="domain_view_template" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
Domain Detail Page Template
</label>
<select id="domain_view_template" name="domain_view_template"
class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
<option value="legacy" {{ settings.domain_view_template == 'legacy' ? 'selected' : '' }}>
Legacy &mdash; Classic single-page layout
</option>
<option value="detailed" {{ settings.domain_view_template == 'detailed' or settings.domain_view_template is not defined ? 'selected' : '' }}>
Detailed &mdash; Tabbed layout with extended information
</option>
</select>
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">
Choose which template to use when viewing domain details at <code class="bg-gray-100 dark:bg-slate-600 px-1 rounded">/domains/{id}</code>
</p>
</div>
</div>
<div class="flex items-center justify-between pt-6 mt-6 border-t border-gray-200 dark:border-slate-700">
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
<i class="fas fa-save mr-2"></i>
@@ -464,25 +487,7 @@
</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
Last Check Run
</label>
<div class="px-3 py-2 bg-gray-50 dark:bg-slate-700 border border-gray-200 dark:border-slate-600 rounded-lg">
{% if lastCheckRun %}
<div class="flex items-center text-sm">
<i class="fas fa-check-circle text-green-500 dark:text-green-400 mr-2"></i>
<span class="text-gray-700 dark:text-slate-300">{{ lastCheckRun|date('M d, Y H:i') }}</span>
</div>
{% else %}
<div class="flex items-center text-sm">
<i class="fas fa-minus-circle text-gray-400 dark:text-slate-500 mr-2"></i>
<span class="text-gray-500 dark:text-slate-400">Never run</span>
</div>
{% endif %}
</div>
<p class="text-xs text-gray-500 dark:text-slate-400 mt-1">Last cronjob run times are shown in the System tab</p>
</div>
</div>
</div>
@@ -772,27 +777,99 @@
</div>
<div class="p-6 space-y-6">
<!-- Cron Command -->
<!-- Cron Commands -->
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2 flex items-center">
<i class="fas fa-terminal text-blue-500 dark:text-blue-400 mr-2"></i>
Cron Job Command
Cron Job Commands
</h4>
<div class="bg-gray-900 text-gray-100 px-4 py-3 rounded-lg font-mono text-sm">
<code>php cron/check_domains.php</code>
<div class="space-y-2">
<div class="bg-gray-900 text-gray-100 px-4 py-3 rounded-lg font-mono text-sm">
<p class="text-xs text-gray-400 dark:text-slate-500 mb-1">Domain / WHOIS check</p>
<code>php cron/check_domains.php</code>
</div>
<div class="bg-gray-900 text-gray-100 px-4 py-3 rounded-lg font-mono text-sm">
<p class="text-xs text-gray-400 dark:text-slate-500 mb-1">DNS record check</p>
<code>php cron/check_dns.php</code>
</div>
</div>
</div>
<!-- Crontab Entry -->
<!-- Crontab Entries -->
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2 flex items-center">
<i class="fas fa-calendar-alt text-green-500 dark:text-green-400 mr-2"></i>
Recommended Crontab Entry
Recommended Crontab Entries
</h4>
<div class="bg-gray-900 text-gray-100 px-4 py-3 rounded-lg font-mono text-sm break-all">
<code>0 */{{ currentCheckInterval }} * * * php {{ cronPath }}</code>
<div class="space-y-3">
<div>
<p class="text-xs text-gray-500 dark:text-slate-400 mb-1">Domain check (every {{ currentCheckInterval }}h)</p>
<div class="bg-gray-900 text-gray-100 px-4 py-3 rounded-lg font-mono text-sm break-all">
<code>0 */{{ currentCheckInterval }} * * * php {{ cronPath }}</code>
</div>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-slate-400 mb-1">DNS check (every 6 hours)</p>
<div class="bg-gray-900 text-gray-100 px-4 py-3 rounded-lg font-mono text-sm break-all">
<code>0 0,6,12,18 * * php {{ cronPath|replace({'check_domains.php': 'check_dns.php'}) }}</code>
</div>
</div>
</div>
<p class="text-xs text-gray-500 dark:text-slate-400 mt-2">Update the paths to match your server installation</p>
</div>
<!-- Last Cronjob Run -->
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2 flex items-center">
<i class="fas fa-history text-purple-500 dark:text-purple-400 mr-2"></i>
Last Cronjob Run
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700 rounded-lg border border-gray-200 dark:border-slate-600">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">Domain / WHOIS</p>
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">check_domains.php</p>
</div>
{% if lastCheckRun %}
<div class="flex items-center text-sm" title="{{ domainCronStale|default(false) ? 'Cron has not run within expected interval' : '' }}">
{% if domainCronStale|default(false) %}
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400 mr-2"></i>
<span class="text-amber-700 dark:text-amber-300">{{ lastCheckRun|date('M d, Y H:i') }}</span>
{% else %}
<i class="fas fa-check-circle text-green-500 dark:text-green-400 mr-2"></i>
<span class="text-gray-700 dark:text-slate-300">{{ lastCheckRun|date('M d, Y H:i') }}</span>
{% endif %}
</div>
{% else %}
<div class="flex items-center text-sm">
<i class="fas fa-minus-circle text-gray-400 dark:text-slate-500 mr-2"></i>
<span class="text-gray-500 dark:text-slate-400">Never run</span>
</div>
{% endif %}
</div>
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700 rounded-lg border border-gray-200 dark:border-slate-600">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">DNS</p>
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">check_dns.php</p>
</div>
{% if lastDnsCheckRun %}
<div class="flex items-center text-sm" title="{{ dnsCronStale|default(false) ? 'Cron has not run within expected interval (24h)' : '' }}">
{% if dnsCronStale|default(false) %}
<i class="fas fa-exclamation-triangle text-amber-500 dark:text-amber-400 mr-2"></i>
<span class="text-amber-700 dark:text-amber-300">{{ lastDnsCheckRun|date('M d, Y H:i') }}</span>
{% else %}
<i class="fas fa-check-circle text-green-500 dark:text-green-400 mr-2"></i>
<span class="text-gray-700 dark:text-slate-300">{{ lastDnsCheckRun|date('M d, Y H:i') }}</span>
{% endif %}
</div>
{% else %}
<div class="flex items-center text-sm">
<i class="fas fa-minus-circle text-gray-400 dark:text-slate-500 mr-2"></i>
<span class="text-gray-500 dark:text-slate-400">Never run</span>
</div>
{% endif %}
</div>
</div>
<p class="text-xs text-gray-500 dark:text-slate-400 mt-2">Update the path to match your server installation</p>
</div>
<!-- Log Files -->
@@ -804,17 +881,24 @@
<div class="space-y-2">
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700 rounded-lg border border-gray-200 dark:border-slate-600">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">Cron Log</p>
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">Domain check execution logs</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">Domain Cron Log</p>
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">WHOIS / expiration check logs</p>
</div>
<code class="text-xs bg-gray-900 text-gray-100 px-2 py-1 rounded">logs/cron.log</code>
</div>
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700 rounded-lg border border-gray-200 dark:border-slate-600">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">DNS Cron Log</p>
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">DNS record check logs</p>
</div>
<code class="text-xs bg-gray-900 text-gray-100 px-2 py-1 rounded">logs/dns_cron.log</code>
</div>
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700 rounded-lg border border-gray-200 dark:border-slate-600">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">TLD Import Log</p>
<p class="text-xs text-gray-500 dark:text-slate-400 mt-0.5">TLD registry import logs</p>
</div>
<code class="text-xs bg-gray-900 text-gray-100 px-2 py-1 rounded">logs/tld_import_*.log</code>
<code class="text-xs bg-gray-900 text-gray-100 px-2 py-1 rounded">logs/tld_import.log</code>
</div>
</div>
</div>

View File

@@ -213,7 +213,7 @@
<a href="/domains/{{ domain.id }}" class="text-blue-600 hover:text-blue-800" title="View">
<i class="fas fa-eye"></i>
</a>
<form method="POST" action="/domains/{{ domain.id }}/refresh" class="inline">
<form method="POST" action="/domains/{{ domain.id }}/refresh-whois" class="inline">
{{ csrf_field() }}
<button type="submit" class="text-green-600 hover:text-green-800" title="Refresh WHOIS">
<i class="fas fa-sync-alt"></i>
@@ -267,10 +267,10 @@
<a href="/domains/{{ domain.id }}" class="flex-1 px-3 py-1.5 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded text-center text-sm hover:bg-blue-100 dark:hover:bg-blue-500/20 transition-colors">
<i class="fas fa-eye mr-1"></i> View
</a>
<form method="POST" action="/domains/{{ domain.id }}/refresh" class="flex-1">
<form method="POST" action="/domains/{{ domain.id }}/refresh-whois" class="flex-1">
{{ csrf_field() }}
<button type="submit" class="w-full px-3 py-1.5 bg-green-50 dark:bg-green-500/10 text-green-600 dark:text-green-400 rounded text-center text-sm hover:bg-green-100 dark:hover:bg-green-500/20 transition-colors">
<i class="fas fa-sync-alt mr-1"></i> Refresh
<i class="fas fa-sync-alt mr-1"></i> Refresh WHOIS
</button>
</form>
</div>