Add error log management and bulk admin actions
Introduces error log tracking with new ErrorLog model, controller, views, and migration. Adds admin UI for viewing, resolving, and deleting errors. Implements bulk actions for users and notification groups, refactors domain filtering/pagination, and centralizes admin access checks using Auth::requireAdmin().
This commit is contained in:
@@ -12,13 +12,11 @@ class AuthController extends Controller
|
||||
{
|
||||
private User $userModel;
|
||||
private Setting $settingModel;
|
||||
private $db;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->userModel = new User();
|
||||
$this->settingModel = new Setting();
|
||||
$this->db = \Core\Database::getConnection();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,11 +288,8 @@ class AuthController extends Controller
|
||||
// Generate verification token
|
||||
$token = bin2hex(random_bytes(32));
|
||||
|
||||
// Save token to database
|
||||
$stmt = $this->db->prepare(
|
||||
"UPDATE users SET email_verification_token = ?, email_verification_sent_at = NOW() WHERE id = ?"
|
||||
);
|
||||
$stmt->execute([$token, $userId]);
|
||||
// Save token to database using model
|
||||
$this->userModel->updateEmailVerificationToken($userId, $token);
|
||||
|
||||
// Send verification email
|
||||
$this->sendVerificationEmail($email, $fullName, $token);
|
||||
@@ -303,9 +298,8 @@ class AuthController extends Controller
|
||||
$_SESSION['pending_verification_email'] = $email;
|
||||
$this->redirect('/verify-email');
|
||||
} else {
|
||||
// Mark as verified and log them in
|
||||
$stmt = $this->db->prepare("UPDATE users SET email_verified = 1 WHERE id = ?");
|
||||
$stmt->execute([$userId]);
|
||||
// Mark as verified and log them in using model
|
||||
$this->userModel->markEmailAsVerified($userId);
|
||||
|
||||
$_SESSION['success'] = 'Account created successfully! You can now log in.';
|
||||
$this->redirect('/login');
|
||||
@@ -344,11 +338,8 @@ class AuthController extends Controller
|
||||
private function verifyEmail($token)
|
||||
{
|
||||
try {
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM users WHERE email_verification_token = ? AND email_verified = 0"
|
||||
);
|
||||
$stmt->execute([$token]);
|
||||
$user = $stmt->fetch();
|
||||
// Find user by verification token using model
|
||||
$user = $this->userModel->findByVerificationToken($token);
|
||||
|
||||
if (!$user) {
|
||||
$this->view('auth/verify-email', [
|
||||
@@ -370,11 +361,8 @@ class AuthController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark email as verified
|
||||
$stmt = $this->db->prepare(
|
||||
"UPDATE users SET email_verified = 1, email_verification_token = NULL WHERE id = ?"
|
||||
);
|
||||
$stmt->execute([$user['id']]);
|
||||
// Mark email as verified using model
|
||||
$this->userModel->verifyEmailByToken($user['id']);
|
||||
|
||||
$this->view('auth/verify-email', [
|
||||
'title' => 'Email Verified',
|
||||
@@ -424,10 +412,8 @@ class AuthController extends Controller
|
||||
// Generate new verification token
|
||||
$token = bin2hex(random_bytes(32));
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
"UPDATE users SET email_verification_token = ?, email_verification_sent_at = NOW() WHERE id = ?"
|
||||
);
|
||||
$stmt->execute([$token, $user['id']]);
|
||||
// Update verification token using model
|
||||
$this->userModel->updateEmailVerificationToken($user['id'], $token);
|
||||
|
||||
// Send verification email
|
||||
$this->sendVerificationEmail($user['email'], $user['full_name'], $token);
|
||||
@@ -507,11 +493,8 @@ class AuthController extends Controller
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$expiresAt = date('Y-m-d H:i:s', strtotime('+1 hour'));
|
||||
|
||||
// Save token to database
|
||||
$stmt = $this->db->prepare(
|
||||
"INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)"
|
||||
);
|
||||
$stmt->execute([$user['id'], $token, $expiresAt]);
|
||||
// Save token to database using model
|
||||
$this->userModel->createPasswordResetToken($user['id'], $token, $expiresAt);
|
||||
|
||||
// Send reset email
|
||||
$this->sendPasswordResetEmail($user['email'], $user['full_name'], $token);
|
||||
@@ -538,12 +521,8 @@ class AuthController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify token exists and is not expired
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM password_reset_tokens WHERE token = ? AND used = 0 AND expires_at > NOW()"
|
||||
);
|
||||
$stmt->execute([$token]);
|
||||
$resetToken = $stmt->fetch();
|
||||
// Verify token exists and is not expired using model
|
||||
$resetToken = $this->userModel->findPasswordResetToken($token);
|
||||
|
||||
if (!$resetToken) {
|
||||
$_SESSION['error'] = 'Invalid or expired reset link';
|
||||
@@ -612,12 +591,8 @@ class AuthController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify token
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM password_reset_tokens WHERE token = ? AND used = 0 AND expires_at > NOW()"
|
||||
);
|
||||
$stmt->execute([$token]);
|
||||
$resetToken = $stmt->fetch();
|
||||
// Verify token using model
|
||||
$resetToken = $this->userModel->findPasswordResetToken($token);
|
||||
|
||||
if (!$resetToken) {
|
||||
$_SESSION['error'] = 'Invalid or expired reset link';
|
||||
@@ -628,9 +603,8 @@ class AuthController extends Controller
|
||||
// Update password
|
||||
$this->userModel->changePassword($resetToken['user_id'], $password);
|
||||
|
||||
// Mark token as used
|
||||
$stmt = $this->db->prepare("UPDATE password_reset_tokens SET used = 1 WHERE id = ?");
|
||||
$stmt->execute([$resetToken['id']]);
|
||||
// Mark token as used using model
|
||||
$this->userModel->markPasswordResetTokenAsUsed($resetToken['id']);
|
||||
|
||||
$_SESSION['success'] = 'Password reset successfully! You can now log in.';
|
||||
$this->redirect('/login');
|
||||
@@ -651,10 +625,8 @@ class AuthController extends Controller
|
||||
$expiresAt = date('Y-m-d H:i:s', strtotime('+30 days'));
|
||||
$sessionId = session_id();
|
||||
|
||||
$stmt = $this->db->prepare(
|
||||
"INSERT INTO remember_tokens (user_id, session_id, token, expires_at) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
$stmt->execute([$userId, $sessionId, $token, $expiresAt]);
|
||||
// Create remember token using model
|
||||
$this->userModel->createRememberToken($userId, $sessionId, $token, $expiresAt);
|
||||
|
||||
// Set cookie
|
||||
setcookie('remember_token', $token, [
|
||||
@@ -683,11 +655,8 @@ class AuthController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT user_id FROM remember_tokens WHERE token = ? AND expires_at > NOW()"
|
||||
);
|
||||
$stmt->execute([$token]);
|
||||
$rememberToken = $stmt->fetch();
|
||||
// Find user by remember token using model
|
||||
$rememberToken = $this->userModel->findByRememberToken($token);
|
||||
|
||||
if ($rememberToken) {
|
||||
$user = $this->userModel->find($rememberToken['user_id']);
|
||||
@@ -817,8 +786,8 @@ class AuthController extends Controller
|
||||
$token = $_COOKIE['remember_token'];
|
||||
|
||||
try {
|
||||
$stmt = $this->db->prepare("DELETE FROM remember_tokens WHERE token = ?");
|
||||
$stmt->execute([$token]);
|
||||
// Delete remember token using model
|
||||
$this->userModel->deleteRememberToken($token);
|
||||
} catch (\Exception $e) {
|
||||
// Silently fail
|
||||
}
|
||||
|
||||
@@ -76,13 +76,12 @@ class DashboardController extends Controller
|
||||
$status['database'] = ['status' => 'offline', 'color' => 'red'];
|
||||
}
|
||||
|
||||
// Check WHOIS service (test with a known TLD)
|
||||
// Check TLD Registry (WHOIS service)
|
||||
try {
|
||||
$whoisService = new \App\Services\WhoisService();
|
||||
// Quick test - just check if we can discover TLD servers
|
||||
$tldModel = new \App\Models\TldRegistry();
|
||||
$testTld = $tldModel->find(1); // Get first TLD
|
||||
if ($testTld) {
|
||||
// Check if ANY TLDs exist in registry (not just id=1)
|
||||
$tldStats = $tldModel->getStatistics();
|
||||
if ($tldStats['total'] > 0) {
|
||||
$status['whois'] = ['status' => 'active', 'color' => 'green'];
|
||||
} else {
|
||||
$status['whois'] = ['status' => 'no data', 'color' => 'yellow'];
|
||||
|
||||
@@ -36,61 +36,20 @@ class DomainController extends Controller
|
||||
$notificationDays = $settingModel->getNotificationDays();
|
||||
$expiringThreshold = !empty($notificationDays) ? max($notificationDays) : 30;
|
||||
|
||||
// Get all domains with groups
|
||||
$domains = $this->domainModel->getAllWithGroups();
|
||||
// Prepare filters array
|
||||
$filters = [
|
||||
'search' => $search,
|
||||
'status' => $status,
|
||||
'group' => $groupId
|
||||
];
|
||||
|
||||
// Apply filters
|
||||
if (!empty($search)) {
|
||||
$domains = array_filter($domains, function($domain) use ($search) {
|
||||
return stripos($domain['domain_name'], $search) !== false ||
|
||||
stripos($domain['registrar'] ?? '', $search) !== false;
|
||||
});
|
||||
}
|
||||
|
||||
if (!empty($status)) {
|
||||
$domains = array_filter($domains, function($domain) use ($status, $expiringThreshold) {
|
||||
if ($status === 'expiring_soon') {
|
||||
// Check if domain expires within configured threshold
|
||||
if (!empty($domain['expiration_date'])) {
|
||||
$daysLeft = floor((strtotime($domain['expiration_date']) - time()) / 86400);
|
||||
return $daysLeft <= $expiringThreshold && $daysLeft >= 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return $domain['status'] === $status;
|
||||
});
|
||||
}
|
||||
|
||||
if (!empty($groupId)) {
|
||||
$domains = array_filter($domains, function($domain) use ($groupId) {
|
||||
return $domain['notification_group_id'] == $groupId;
|
||||
});
|
||||
}
|
||||
|
||||
// Get total count after filtering
|
||||
$totalDomains = count($domains);
|
||||
|
||||
// Apply sorting
|
||||
usort($domains, function($a, $b) use ($sortBy, $sortOrder) {
|
||||
$aVal = $a[$sortBy] ?? '';
|
||||
$bVal = $b[$sortBy] ?? '';
|
||||
|
||||
$comparison = strcasecmp($aVal, $bVal);
|
||||
return $sortOrder === 'desc' ? -$comparison : $comparison;
|
||||
});
|
||||
|
||||
// Calculate pagination
|
||||
$totalPages = ceil($totalDomains / $perPage);
|
||||
$page = min($page, max(1, $totalPages)); // Ensure page is within valid range
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
// Slice array for current page
|
||||
$paginatedDomains = array_slice($domains, $offset, $perPage);
|
||||
// Get filtered and paginated domains using model
|
||||
$result = $this->domainModel->getFilteredPaginated($filters, $sortBy, $sortOrder, $page, $perPage, $expiringThreshold);
|
||||
|
||||
$groups = $this->groupModel->all();
|
||||
|
||||
// Format domains for display
|
||||
$formattedDomains = \App\Helpers\DomainHelper::formatMultiple($paginatedDomains);
|
||||
$formattedDomains = \App\Helpers\DomainHelper::formatMultiple($result['domains']);
|
||||
|
||||
$this->view('domains/index', [
|
||||
'domains' => $formattedDomains,
|
||||
@@ -102,14 +61,7 @@ class DomainController extends Controller
|
||||
'sort' => $sortBy,
|
||||
'order' => $sortOrder
|
||||
],
|
||||
'pagination' => [
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total' => $totalDomains,
|
||||
'total_pages' => $totalPages,
|
||||
'showing_from' => $totalDomains > 0 ? $offset + 1 : 0,
|
||||
'showing_to' => min($offset + $perPage, $totalDomains)
|
||||
],
|
||||
'pagination' => $result['pagination'],
|
||||
'title' => 'Domains'
|
||||
]);
|
||||
}
|
||||
|
||||
208
app/Controllers/ErrorLogController.php
Normal file
208
app/Controllers/ErrorLogController.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use Core\Controller;
|
||||
use Core\Auth;
|
||||
use App\Models\ErrorLog;
|
||||
|
||||
class ErrorLogController extends Controller
|
||||
{
|
||||
private $errorLogModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
Auth::requireAdmin();
|
||||
$this->errorLogModel = new ErrorLog();
|
||||
}
|
||||
|
||||
/**
|
||||
* Display list of errors with filters
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
// Get filters from query params
|
||||
$filters = [
|
||||
'resolved' => $_GET['resolved'] ?? '',
|
||||
'type' => $_GET['type'] ?? '',
|
||||
'sort' => $_GET['sort'] ?? 'last_occurred_at',
|
||||
'order' => $_GET['order'] ?? 'desc'
|
||||
];
|
||||
|
||||
// Pagination
|
||||
$page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
|
||||
$perPage = isset($_GET['per_page']) ? (int)$_GET['per_page'] : 25;
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
// Get total count using model
|
||||
$totalErrors = $this->errorLogModel->countUniqueErrors($filters);
|
||||
|
||||
// Get paginated errors using model
|
||||
$errors = $this->errorLogModel->getPaginatedErrors($filters, $perPage, $offset);
|
||||
|
||||
// Get statistics using model
|
||||
$stats = $this->errorLogModel->getAdminStats();
|
||||
|
||||
// Pagination data
|
||||
$totalPages = ceil($totalErrors / $perPage);
|
||||
$pagination = [
|
||||
'current_page' => $page,
|
||||
'total_pages' => $totalPages,
|
||||
'per_page' => $perPage,
|
||||
'total' => $totalErrors,
|
||||
'showing_from' => $totalErrors > 0 ? $offset + 1 : 0,
|
||||
'showing_to' => min($offset + $perPage, $totalErrors)
|
||||
];
|
||||
|
||||
$this->view('errors/admin-index', compact('errors', 'stats', 'filters', 'pagination'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error details
|
||||
*/
|
||||
public function show($params = [])
|
||||
{
|
||||
$errorId = $params['id'] ?? '';
|
||||
|
||||
// Get all occurrences using model
|
||||
$errorOccurrences = $this->errorLogModel->getOccurrencesByErrorId($errorId);
|
||||
|
||||
if (empty($errorOccurrences)) {
|
||||
$_SESSION['error'] = 'Error not found';
|
||||
header('Location: /errors');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Get the most recent occurrence for display
|
||||
$error = $errorOccurrences[0];
|
||||
|
||||
// Parse JSON fields
|
||||
$error['stack_trace_array'] = json_decode($error['stack_trace'], true) ?? [];
|
||||
$error['request_data'] = json_decode($error['request_data'], true) ?? [];
|
||||
$error['session_data'] = json_decode($error['session_data'], true) ?? [];
|
||||
|
||||
$this->view('errors/admin-detail', compact('error', 'errorOccurrences'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark error as resolved
|
||||
*/
|
||||
public function markResolved($params = [])
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header('Location: /errors');
|
||||
exit;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/errors');
|
||||
|
||||
$errorId = $params['id'] ?? '';
|
||||
$notes = $_POST['notes'] ?? null;
|
||||
|
||||
// Mark error as resolved using model
|
||||
$this->errorLogModel->markErrorResolved($errorId, $_SESSION['user_id'], $notes);
|
||||
|
||||
$_SESSION['success'] = 'Error marked as resolved';
|
||||
header('Location: /errors/' . urlencode($errorId));
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark error as unresolved
|
||||
*/
|
||||
public function markUnresolved($params = [])
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header('Location: /errors');
|
||||
exit;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/errors');
|
||||
|
||||
$errorId = $params['id'] ?? '';
|
||||
|
||||
// Mark error as unresolved using model
|
||||
$this->errorLogModel->markErrorUnresolved($errorId);
|
||||
|
||||
$_SESSION['success'] = 'Error marked as unresolved';
|
||||
header('Location: /errors/' . urlencode($errorId));
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete error and all its occurrences
|
||||
*/
|
||||
public function delete($params = [])
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header('Location: /errors');
|
||||
exit;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/errors');
|
||||
|
||||
$errorId = $params['id'] ?? '';
|
||||
|
||||
// Delete error using model
|
||||
$this->errorLogModel->deleteByErrorId($errorId);
|
||||
|
||||
$_SESSION['success'] = 'Error deleted successfully';
|
||||
header('Location: /errors');
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear old resolved errors
|
||||
*/
|
||||
public function clearResolved()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header('Location: /errors');
|
||||
exit;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/errors');
|
||||
|
||||
$daysOld = isset($_POST['days']) ? (int)$_POST['days'] : 30;
|
||||
|
||||
// Clear old errors using model
|
||||
$deletedCount = $this->errorLogModel->clearOldResolved($daysOld);
|
||||
|
||||
$_SESSION['success'] = "Deleted $deletedCount resolved error(s) older than $daysOld days";
|
||||
header('Location: /errors');
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk delete errors
|
||||
*/
|
||||
public function bulkDelete()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
header('Location: /errors');
|
||||
exit;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/errors');
|
||||
|
||||
$errorIdsJson = $_POST['error_ids'] ?? '[]';
|
||||
$errorIds = json_decode($errorIdsJson, true);
|
||||
|
||||
if (empty($errorIds) || !is_array($errorIds)) {
|
||||
$_SESSION['error'] = 'No errors selected for deletion';
|
||||
header('Location: /errors');
|
||||
exit;
|
||||
}
|
||||
|
||||
$deletedCount = 0;
|
||||
foreach ($errorIds as $errorId) {
|
||||
if ($this->errorLogModel->deleteByErrorId($errorId)) {
|
||||
$deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$_SESSION['success'] = "Successfully deleted $deletedCount error(s)";
|
||||
header('Location: /errors');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ class InstallerController extends Controller
|
||||
'012_link_remember_tokens_to_sessions.sql',
|
||||
'013_create_user_notifications_table.sql',
|
||||
'014_add_captcha_settings.sql',
|
||||
'015_create_error_logs_table.sql',
|
||||
];
|
||||
|
||||
try {
|
||||
@@ -105,7 +106,8 @@ class InstallerController extends Controller
|
||||
'011_create_sessions_table.sql',
|
||||
'012_link_remember_tokens_to_sessions.sql',
|
||||
'013_create_user_notifications_table.sql',
|
||||
'014_add_captcha_settings.sql'
|
||||
'014_add_captcha_settings.sql',
|
||||
'015_create_error_logs_table.sql'
|
||||
];
|
||||
}
|
||||
|
||||
@@ -261,7 +263,8 @@ class InstallerController extends Controller
|
||||
'011_create_sessions_table.sql',
|
||||
'012_link_remember_tokens_to_sessions.sql',
|
||||
'013_create_user_notifications_table.sql',
|
||||
'014_add_captcha_settings.sql'
|
||||
'014_add_captcha_settings.sql',
|
||||
'015_create_error_logs_table.sql'
|
||||
];
|
||||
|
||||
$stmt = $pdo->prepare("INSERT INTO migrations (migration) VALUES (?) ON DUPLICATE KEY UPDATE migration=migration");
|
||||
|
||||
@@ -245,5 +245,41 @@ class NotificationGroupController extends Controller
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk delete notification groups
|
||||
*/
|
||||
public function bulkDelete()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/groups');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/groups');
|
||||
|
||||
$groupIdsJson = $_POST['group_ids'] ?? '[]';
|
||||
$groupIds = json_decode($groupIdsJson, true);
|
||||
|
||||
if (empty($groupIds) || !is_array($groupIds)) {
|
||||
$_SESSION['error'] = 'No groups selected for deletion';
|
||||
$this->redirect('/groups');
|
||||
return;
|
||||
}
|
||||
|
||||
$deletedCount = 0;
|
||||
|
||||
foreach ($groupIds as $groupId) {
|
||||
try {
|
||||
$this->groupModel->deleteWithRelations((int)$groupId);
|
||||
$deletedCount++;
|
||||
} catch (\Exception $e) {
|
||||
// Continue with next group
|
||||
}
|
||||
}
|
||||
|
||||
$_SESSION['success'] = "Successfully deleted $deletedCount notification group(s)";
|
||||
$this->redirect('/groups');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Controllers;
|
||||
|
||||
use Core\Controller;
|
||||
use Core\Auth;
|
||||
use App\Models\Setting;
|
||||
|
||||
class SettingsController extends Controller
|
||||
@@ -11,14 +12,8 @@ class SettingsController extends Controller
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
Auth::requireAdmin();
|
||||
$this->settingModel = new Setting();
|
||||
|
||||
// Ensure only admins can access settings
|
||||
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
|
||||
$_SESSION['error'] = 'Access denied. Admin privileges required.';
|
||||
$this->redirect('/');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
public function index()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Controllers;
|
||||
|
||||
use Core\Controller;
|
||||
use Core\Auth;
|
||||
use App\Models\TldRegistry;
|
||||
use App\Models\TldImportLog;
|
||||
use App\Services\TldRegistryService;
|
||||
@@ -22,19 +23,6 @@ class TldRegistryController extends Controller
|
||||
$this->tldService = new TldRegistryService();
|
||||
$this->logger = new Logger('tld_registry_controller');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current user is admin
|
||||
*/
|
||||
private function requireAdmin()
|
||||
{
|
||||
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
|
||||
$_SESSION['error'] = 'Access denied. Admin privileges required.';
|
||||
$this->redirect('/tld-registry');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display TLD registry dashboard
|
||||
*/
|
||||
@@ -91,7 +79,7 @@ class TldRegistryController extends Controller
|
||||
*/
|
||||
public function importTldList()
|
||||
{
|
||||
$this->requireAdmin();
|
||||
Auth::requireAdmin();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/tld-registry');
|
||||
@@ -129,7 +117,7 @@ class TldRegistryController extends Controller
|
||||
*/
|
||||
public function importRdap()
|
||||
{
|
||||
$this->requireAdmin();
|
||||
Auth::requireAdmin();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/tld-registry');
|
||||
@@ -167,7 +155,7 @@ class TldRegistryController extends Controller
|
||||
*/
|
||||
public function importWhois()
|
||||
{
|
||||
$this->requireAdmin();
|
||||
Auth::requireAdmin();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/tld-registry');
|
||||
@@ -209,7 +197,7 @@ class TldRegistryController extends Controller
|
||||
*/
|
||||
public function checkUpdates()
|
||||
{
|
||||
$this->requireAdmin();
|
||||
Auth::requireAdmin();
|
||||
|
||||
try {
|
||||
$updateInfo = $this->tldService->checkForUpdates();
|
||||
@@ -251,7 +239,7 @@ class TldRegistryController extends Controller
|
||||
*/
|
||||
public function startProgressiveImport()
|
||||
{
|
||||
$this->requireAdmin();
|
||||
Auth::requireAdmin();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/tld-registry');
|
||||
@@ -464,7 +452,7 @@ class TldRegistryController extends Controller
|
||||
*/
|
||||
public function bulkDelete()
|
||||
{
|
||||
$this->requireAdmin();
|
||||
Auth::requireAdmin();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/tld-registry');
|
||||
@@ -512,7 +500,7 @@ class TldRegistryController extends Controller
|
||||
*/
|
||||
public function toggleActive($params = [])
|
||||
{
|
||||
$this->requireAdmin();
|
||||
Auth::requireAdmin();
|
||||
|
||||
$id = $params['id'] ?? 0;
|
||||
$tld = $this->tldModel->find($id);
|
||||
@@ -536,7 +524,7 @@ class TldRegistryController extends Controller
|
||||
*/
|
||||
public function refresh($params = [])
|
||||
{
|
||||
$this->requireAdmin();
|
||||
Auth::requireAdmin();
|
||||
|
||||
$id = $params['id'] ?? 0;
|
||||
$tld = $this->tldModel->find($id);
|
||||
|
||||
@@ -12,14 +12,8 @@ class UserController extends Controller
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
Auth::requireAdmin();
|
||||
$this->userModel = new User();
|
||||
|
||||
// Ensure only admins can access user management
|
||||
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
|
||||
$_SESSION['error'] = 'Access denied. Admin privileges required.';
|
||||
$this->redirect('/');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,5 +372,112 @@ class UserController extends Controller
|
||||
$this->redirect('/users');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk toggle user status (activate or deactivate)
|
||||
*/
|
||||
public function bulkToggleStatus()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/users');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/users');
|
||||
|
||||
$userIdsJson = $_POST['user_ids'] ?? '[]';
|
||||
$userIds = json_decode($userIdsJson, true);
|
||||
$action = $_POST['action'] ?? 'activate'; // 'activate' or 'deactivate'
|
||||
|
||||
if (empty($userIds) || !is_array($userIds)) {
|
||||
$_SESSION['error'] = 'No users selected';
|
||||
$this->redirect('/users');
|
||||
return;
|
||||
}
|
||||
|
||||
$newStatus = ($action === 'activate') ? 1 : 0;
|
||||
$updatedCount = 0;
|
||||
$skippedSelf = false;
|
||||
|
||||
foreach ($userIds as $userId) {
|
||||
// Prevent modifying your own account
|
||||
if ($userId == Auth::id()) {
|
||||
$skippedSelf = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->userModel->update((int)$userId, ['is_active' => $newStatus]);
|
||||
$updatedCount++;
|
||||
} catch (\Exception $e) {
|
||||
// Continue with next user
|
||||
}
|
||||
}
|
||||
|
||||
$message = $action === 'activate' ? "Activated $updatedCount user(s)" : "Deactivated $updatedCount user(s)";
|
||||
if ($skippedSelf) {
|
||||
$message .= ' (skipped your own account)';
|
||||
}
|
||||
|
||||
$_SESSION['success'] = $message;
|
||||
$this->redirect('/users');
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk delete users
|
||||
*/
|
||||
public function bulkDelete()
|
||||
{
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$this->redirect('/users');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->verifyCsrf('/users');
|
||||
|
||||
$userIdsJson = $_POST['user_ids'] ?? '[]';
|
||||
$userIds = json_decode($userIdsJson, true);
|
||||
|
||||
if (empty($userIds) || !is_array($userIds)) {
|
||||
$_SESSION['error'] = 'No users selected for deletion';
|
||||
$this->redirect('/users');
|
||||
return;
|
||||
}
|
||||
|
||||
$deletedCount = 0;
|
||||
$skippedSelf = false;
|
||||
|
||||
foreach ($userIds as $userId) {
|
||||
// Prevent deleting your own account
|
||||
if ($userId == Auth::id()) {
|
||||
$skippedSelf = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prevent deleting if this is the last admin
|
||||
$user = $this->userModel->find((int)$userId);
|
||||
if ($user && $user['role'] === 'admin') {
|
||||
$allAdmins = $this->userModel->where('role', 'admin');
|
||||
if (count($allAdmins) <= 1) {
|
||||
continue; // Skip - can't delete last admin
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$this->userModel->delete((int)$userId);
|
||||
$deletedCount++;
|
||||
} catch (\Exception $e) {
|
||||
// Continue with next user
|
||||
}
|
||||
}
|
||||
|
||||
$message = "Successfully deleted $deletedCount user(s)";
|
||||
if ($skippedSelf) {
|
||||
$message .= ' (skipped your own account)';
|
||||
}
|
||||
|
||||
$_SESSION['success'] = $message;
|
||||
$this->redirect('/users');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
163
app/Helpers/ViewHelper.php
Normal file
163
app/Helpers/ViewHelper.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
class ViewHelper
|
||||
{
|
||||
/**
|
||||
* Generate sort URL for table headers
|
||||
*/
|
||||
public static function sortUrl(string $column, string $currentSort, string $currentOrder, array $currentFilters = []): string
|
||||
{
|
||||
$newOrder = ($currentSort === $column && $currentOrder === 'asc') ? 'desc' : 'asc';
|
||||
$params = $currentFilters;
|
||||
$params['sort'] = $column;
|
||||
$params['order'] = $newOrder;
|
||||
|
||||
$currentPath = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
return $currentPath . '?' . http_build_query($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate sort icon HTML for table headers
|
||||
*/
|
||||
public static function sortIcon(string $column, string $currentSort, string $currentOrder): string
|
||||
{
|
||||
if ($currentSort !== $column) {
|
||||
return '<i class="fas fa-sort text-gray-400 ml-1 text-xs"></i>';
|
||||
}
|
||||
|
||||
$icon = $currentOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down';
|
||||
return '<i class="fas ' . $icon . ' text-primary ml-1 text-xs"></i>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate pagination URL
|
||||
*/
|
||||
public static function paginationUrl(int $page, array $filters, int $perPage): string
|
||||
{
|
||||
$params = $filters;
|
||||
$params['page'] = $page;
|
||||
$params['per_page'] = $perPage;
|
||||
|
||||
$currentPath = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
|
||||
return $currentPath . '?' . http_build_query($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format status badge
|
||||
*/
|
||||
public static function statusBadge(string $status): string
|
||||
{
|
||||
$statusClasses = [
|
||||
'active' => 'bg-green-100 text-green-800 border-green-200',
|
||||
'expiring_soon' => 'bg-orange-100 text-orange-800 border-orange-200',
|
||||
'expired' => 'bg-red-100 text-red-800 border-red-200',
|
||||
'inactive' => 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
];
|
||||
|
||||
$statusLabels = [
|
||||
'active' => 'Active',
|
||||
'expiring_soon' => 'Expiring Soon',
|
||||
'expired' => 'Expired',
|
||||
'inactive' => 'Inactive',
|
||||
];
|
||||
|
||||
$class = $statusClasses[$status] ?? $statusClasses['inactive'];
|
||||
$label = $statusLabels[$status] ?? ucfirst($status);
|
||||
|
||||
return '<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border ' . $class . '">' . htmlspecialchars($label) . '</span>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text with ellipsis
|
||||
*/
|
||||
public static function truncate(string $text, int $length = 50, string $suffix = '...'): string
|
||||
{
|
||||
if (mb_strlen($text) <= $length) {
|
||||
return htmlspecialchars($text);
|
||||
}
|
||||
|
||||
return htmlspecialchars(mb_substr($text, 0, $length)) . $suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format bytes to human readable size
|
||||
*/
|
||||
public static function formatBytes(int $bytes, int $precision = 2): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
|
||||
for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
|
||||
$bytes /= 1024;
|
||||
}
|
||||
|
||||
return round($bytes, $precision) . ' ' . $units[$i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate breadcrumb navigation
|
||||
*/
|
||||
public static function breadcrumbs(array $items): string
|
||||
{
|
||||
$html = '<nav class="flex mb-4" aria-label="Breadcrumb"><ol class="inline-flex items-center space-x-1 md:space-x-3">';
|
||||
|
||||
foreach ($items as $index => $item) {
|
||||
$isLast = $index === count($items) - 1;
|
||||
|
||||
if ($index > 0) {
|
||||
$html .= '<li><div class="flex items-center"><i class="fas fa-chevron-right text-gray-400 text-xs"></i></div></li>';
|
||||
}
|
||||
|
||||
$html .= '<li class="inline-flex items-center">';
|
||||
|
||||
if (!$isLast && isset($item['url'])) {
|
||||
$html .= '<a href="' . htmlspecialchars($item['url']) . '" class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-primary">';
|
||||
if (isset($item['icon'])) {
|
||||
$html .= '<i class="' . htmlspecialchars($item['icon']) . ' mr-2 text-xs"></i>';
|
||||
}
|
||||
$html .= htmlspecialchars($item['label']) . '</a>';
|
||||
} else {
|
||||
$html .= '<span class="text-sm font-medium text-gray-500">';
|
||||
if (isset($item['icon'])) {
|
||||
$html .= '<i class="' . htmlspecialchars($item['icon']) . ' mr-2 text-xs"></i>';
|
||||
}
|
||||
$html .= htmlspecialchars($item['label']) . '</span>';
|
||||
}
|
||||
|
||||
$html .= '</li>';
|
||||
}
|
||||
|
||||
$html .= '</ol></nav>';
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate alert message HTML
|
||||
*/
|
||||
public static function alert(string $type, string $message): string
|
||||
{
|
||||
$classes = [
|
||||
'success' => 'bg-green-50 border-green-200 text-green-800',
|
||||
'error' => 'bg-red-50 border-red-200 text-red-800',
|
||||
'warning' => 'bg-orange-50 border-orange-200 text-orange-800',
|
||||
'info' => 'bg-blue-50 border-blue-200 text-blue-800',
|
||||
];
|
||||
|
||||
$icons = [
|
||||
'success' => 'fa-check-circle',
|
||||
'error' => 'fa-exclamation-circle',
|
||||
'warning' => 'fa-exclamation-triangle',
|
||||
'info' => 'fa-info-circle',
|
||||
];
|
||||
|
||||
$class = $classes[$type] ?? $classes['info'];
|
||||
$icon = $icons[$type] ?? $icons['info'];
|
||||
|
||||
return '<div class="border rounded-lg p-4 ' . $class . ' flex items-start">
|
||||
<i class="fas ' . $icon . ' mr-3 mt-0.5"></i>
|
||||
<div class="flex-1">' . htmlspecialchars($message) . '</div>
|
||||
</div>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,5 +139,76 @@ class Domain extends Model
|
||||
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filtered, sorted, and paginated domains
|
||||
*/
|
||||
public function getFilteredPaginated(array $filters, string $sortBy, string $sortOrder, int $page, int $perPage, int $expiringThreshold = 30): array
|
||||
{
|
||||
// Get all domains with groups
|
||||
$domains = $this->getAllWithGroups();
|
||||
|
||||
// Apply search filter
|
||||
if (!empty($filters['search'])) {
|
||||
$domains = array_filter($domains, function($domain) use ($filters) {
|
||||
return stripos($domain['domain_name'], $filters['search']) !== false ||
|
||||
stripos($domain['registrar'] ?? '', $filters['search']) !== false;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply status filter
|
||||
if (!empty($filters['status'])) {
|
||||
$domains = array_filter($domains, function($domain) use ($filters, $expiringThreshold) {
|
||||
if ($filters['status'] === 'expiring_soon') {
|
||||
// Check if domain expires within configured threshold
|
||||
if (!empty($domain['expiration_date'])) {
|
||||
$daysLeft = floor((strtotime($domain['expiration_date']) - time()) / 86400);
|
||||
return $daysLeft <= $expiringThreshold && $daysLeft >= 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return $domain['status'] === $filters['status'];
|
||||
});
|
||||
}
|
||||
|
||||
// Apply group filter
|
||||
if (!empty($filters['group'])) {
|
||||
$domains = array_filter($domains, function($domain) use ($filters) {
|
||||
return $domain['notification_group_id'] == $filters['group'];
|
||||
});
|
||||
}
|
||||
|
||||
// Get total count after filtering
|
||||
$totalDomains = count($domains);
|
||||
|
||||
// Apply sorting
|
||||
usort($domains, function($a, $b) use ($sortBy, $sortOrder) {
|
||||
$aVal = $a[$sortBy] ?? '';
|
||||
$bVal = $b[$sortBy] ?? '';
|
||||
|
||||
$comparison = strcasecmp($aVal, $bVal);
|
||||
return $sortOrder === 'desc' ? -$comparison : $comparison;
|
||||
});
|
||||
|
||||
// Calculate pagination
|
||||
$totalPages = ceil($totalDomains / $perPage);
|
||||
$page = min($page, max(1, $totalPages)); // Ensure page is within valid range
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
// Slice array for current page
|
||||
$paginatedDomains = array_slice($domains, $offset, $perPage);
|
||||
|
||||
return [
|
||||
'domains' => $paginatedDomains,
|
||||
'pagination' => [
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total' => $totalDomains,
|
||||
'total_pages' => $totalPages,
|
||||
'showing_from' => $totalDomains > 0 ? $offset + 1 : 0,
|
||||
'showing_to' => min($offset + $perPage, $totalDomains)
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
400
app/Models/ErrorLog.php
Normal file
400
app/Models/ErrorLog.php
Normal file
@@ -0,0 +1,400 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Core\Model;
|
||||
|
||||
/**
|
||||
* ErrorLog Model
|
||||
*
|
||||
* Manages error log database operations for tracking and debugging
|
||||
*/
|
||||
class ErrorLog extends Model
|
||||
{
|
||||
protected static string $table = 'error_logs';
|
||||
|
||||
/**
|
||||
* Log an error to database
|
||||
* If the same error exists (same file + line + type), increment occurrence count
|
||||
*/
|
||||
public function logError(array $errorData): ?int
|
||||
{
|
||||
// Generate unique error signature for deduplication
|
||||
$signature = md5($errorData['error_type'] . $errorData['error_file'] . $errorData['error_line']);
|
||||
|
||||
// Check if this error already exists
|
||||
$existing = $this->findBySimilar(
|
||||
$errorData['error_type'],
|
||||
$errorData['error_file'],
|
||||
$errorData['error_line']
|
||||
);
|
||||
|
||||
if ($existing) {
|
||||
// Update existing error
|
||||
$this->incrementOccurrence($existing['id']);
|
||||
return $existing['id'];
|
||||
}
|
||||
|
||||
// Create new error log
|
||||
return $this->create($errorData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find similar error (same type, file, line)
|
||||
*/
|
||||
private function findBySimilar(string $type, string $file, int $line): ?array
|
||||
{
|
||||
$sql = "SELECT * FROM error_logs
|
||||
WHERE error_type = ?
|
||||
AND error_file = ?
|
||||
AND error_line = ?
|
||||
AND is_resolved = FALSE
|
||||
LIMIT 1";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([$type, $file, $line]);
|
||||
$result = $stmt->fetch();
|
||||
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment occurrence counter
|
||||
*/
|
||||
private function incrementOccurrence(int $id): void
|
||||
{
|
||||
$sql = "UPDATE error_logs
|
||||
SET occurrences = occurrences + 1,
|
||||
last_occurred_at = NOW()
|
||||
WHERE id = ?";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find error by error_id (unique reference)
|
||||
*/
|
||||
public function findByErrorId(string $errorId): ?array
|
||||
{
|
||||
$sql = "SELECT el.*, u.username, u.full_name, r.username as resolved_by_name
|
||||
FROM error_logs el
|
||||
LEFT JOIN users u ON el.user_id = u.id
|
||||
LEFT JOIN users r ON el.resolved_by = r.id
|
||||
WHERE el.error_id = ?";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([$errorId]);
|
||||
$result = $stmt->fetch();
|
||||
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent errors with pagination
|
||||
*/
|
||||
public function getRecent(int $limit = 50, int $offset = 0, array $filters = []): array
|
||||
{
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
|
||||
// Filter by resolution status
|
||||
if (isset($filters['resolved'])) {
|
||||
$where[] = 'el.is_resolved = ?';
|
||||
$params[] = $filters['resolved'] ? 1 : 0;
|
||||
}
|
||||
|
||||
// Filter by error type
|
||||
if (!empty($filters['type'])) {
|
||||
$where[] = 'el.error_type LIKE ?';
|
||||
$params[] = '%' . $filters['type'] . '%';
|
||||
}
|
||||
|
||||
// Filter by user
|
||||
if (!empty($filters['user_id'])) {
|
||||
$where[] = 'el.user_id = ?';
|
||||
$params[] = $filters['user_id'];
|
||||
}
|
||||
|
||||
$whereClause = implode(' AND ', $where);
|
||||
|
||||
$sql = "SELECT el.*, u.username, u.full_name
|
||||
FROM error_logs el
|
||||
LEFT JOIN users u ON el.user_id = u.id
|
||||
WHERE {$whereClause}
|
||||
ORDER BY el.last_occurred_at DESC
|
||||
LIMIT ? OFFSET ?";
|
||||
|
||||
$params[] = $limit;
|
||||
$params[] = $offset;
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total errors
|
||||
*/
|
||||
public function count(array $filters = []): int
|
||||
{
|
||||
$where = ['1=1'];
|
||||
$params = [];
|
||||
|
||||
if (isset($filters['resolved'])) {
|
||||
$where[] = 'is_resolved = ?';
|
||||
$params[] = $filters['resolved'] ? 1 : 0;
|
||||
}
|
||||
|
||||
if (!empty($filters['type'])) {
|
||||
$where[] = 'error_type LIKE ?';
|
||||
$params[] = '%' . $filters['type'] . '%';
|
||||
}
|
||||
|
||||
if (!empty($filters['user_id'])) {
|
||||
$where[] = 'user_id = ?';
|
||||
$params[] = $filters['user_id'];
|
||||
}
|
||||
|
||||
$whereClause = implode(' AND ', $where);
|
||||
|
||||
$sql = "SELECT COUNT(*) as count FROM error_logs WHERE {$whereClause}";
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
return (int)$stmt->fetch()['count'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error statistics
|
||||
*/
|
||||
public function getStats(): array
|
||||
{
|
||||
$sql = "SELECT
|
||||
COUNT(*) as total_errors,
|
||||
SUM(occurrences) as total_occurrences,
|
||||
COUNT(CASE WHEN is_resolved = FALSE THEN 1 END) as unresolved,
|
||||
COUNT(CASE WHEN is_resolved = TRUE THEN 1 END) as resolved,
|
||||
COUNT(CASE WHEN occurred_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR) THEN 1 END) as last_24h,
|
||||
COUNT(CASE WHEN occurred_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) THEN 1 END) as last_7d
|
||||
FROM error_logs";
|
||||
|
||||
$stmt = $this->db->query($sql);
|
||||
return $stmt->fetch();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark error as resolved
|
||||
*/
|
||||
public function resolve(int $id, int $resolvedBy, ?string $notes = null): bool
|
||||
{
|
||||
return $this->update($id, [
|
||||
'is_resolved' => true,
|
||||
'resolved_at' => date('Y-m-d H:i:s'),
|
||||
'resolved_by' => $resolvedBy,
|
||||
'notes' => $notes
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old resolved errors
|
||||
*/
|
||||
public function deleteOldResolved(int $daysOld = 30): int
|
||||
{
|
||||
$sql = "DELETE FROM error_logs
|
||||
WHERE is_resolved = TRUE
|
||||
AND resolved_at < DATE_SUB(NOW(), INTERVAL ? DAY)";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([$daysOld]);
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get most frequent errors
|
||||
*/
|
||||
public function getMostFrequent(int $limit = 10): array
|
||||
{
|
||||
$sql = "SELECT el.*, u.username, u.full_name
|
||||
FROM error_logs el
|
||||
LEFT JOIN users u ON el.user_id = u.id
|
||||
WHERE el.is_resolved = FALSE
|
||||
ORDER BY el.occurrences DESC, el.last_occurred_at DESC
|
||||
LIMIT ?";
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute([$limit]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated errors with filters for admin panel
|
||||
*/
|
||||
public function getPaginatedErrors(array $filters, int $perPage, int $offset): array
|
||||
{
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if ($filters['resolved'] !== '') {
|
||||
$where[] = 'is_resolved = ?';
|
||||
$params[] = (int)$filters['resolved'];
|
||||
}
|
||||
|
||||
if (!empty($filters['type'])) {
|
||||
$where[] = 'error_type LIKE ?';
|
||||
$params[] = '%' . $filters['type'] . '%';
|
||||
}
|
||||
|
||||
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
$sortColumn = $filters['sort'];
|
||||
$sortOrder = strtoupper($filters['order']) === 'DESC' ? 'DESC' : 'ASC';
|
||||
|
||||
$query = "
|
||||
SELECT
|
||||
error_id,
|
||||
error_type,
|
||||
error_message,
|
||||
error_file,
|
||||
error_line,
|
||||
is_resolved,
|
||||
MIN(occurred_at) as occurred_at,
|
||||
MAX(occurred_at) as last_occurred_at,
|
||||
COUNT(*) as occurrences
|
||||
FROM error_logs
|
||||
$whereClause
|
||||
GROUP BY error_id
|
||||
ORDER BY $sortColumn $sortOrder
|
||||
LIMIT ? OFFSET ?
|
||||
";
|
||||
|
||||
$stmt = $this->db->prepare($query);
|
||||
$stmt->execute([...$params, $perPage, $offset]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total unique errors with filters
|
||||
*/
|
||||
public function countUniqueErrors(array $filters): int
|
||||
{
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
if ($filters['resolved'] !== '') {
|
||||
$where[] = 'is_resolved = ?';
|
||||
$params[] = (int)$filters['resolved'];
|
||||
}
|
||||
|
||||
if (!empty($filters['type'])) {
|
||||
$where[] = 'error_type LIKE ?';
|
||||
$params[] = '%' . $filters['type'] . '%';
|
||||
}
|
||||
|
||||
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
$query = "SELECT COUNT(DISTINCT error_id) as total FROM error_logs $whereClause";
|
||||
$stmt = $this->db->prepare($query);
|
||||
$stmt->execute($params);
|
||||
return (int)$stmt->fetch()['total'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all occurrences of a specific error
|
||||
*/
|
||||
public function getOccurrencesByErrorId(string $errorId): array
|
||||
{
|
||||
$stmt = $this->db->prepare("
|
||||
SELECT * FROM error_logs
|
||||
WHERE error_id = ?
|
||||
ORDER BY occurred_at DESC
|
||||
");
|
||||
$stmt->execute([$errorId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get admin statistics
|
||||
*/
|
||||
public function getAdminStats(): array
|
||||
{
|
||||
// Total unique errors
|
||||
$stmt = $this->db->query("SELECT COUNT(DISTINCT error_id) as total FROM error_logs");
|
||||
$totalErrors = $stmt->fetch()['total'];
|
||||
|
||||
// Unresolved errors
|
||||
$stmt = $this->db->query("SELECT COUNT(DISTINCT error_id) as total FROM error_logs WHERE is_resolved = 0");
|
||||
$unresolved = $stmt->fetch()['total'];
|
||||
|
||||
// Errors in last 24h
|
||||
$stmt = $this->db->query("SELECT COUNT(DISTINCT error_id) as total FROM error_logs WHERE occurred_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)");
|
||||
$last24h = $stmt->fetch()['total'];
|
||||
|
||||
// Total occurrences
|
||||
$stmt = $this->db->query("SELECT COUNT(*) as total FROM error_logs");
|
||||
$totalOccurrences = $stmt->fetch()['total'];
|
||||
|
||||
return [
|
||||
'total_errors' => $totalErrors,
|
||||
'unresolved' => $unresolved,
|
||||
'last_24h' => $last24h,
|
||||
'total_occurrences' => $totalOccurrences
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all occurrences of an error as resolved
|
||||
*/
|
||||
public function markErrorResolved(string $errorId, int $userId, ?string $notes): bool
|
||||
{
|
||||
$stmt = $this->db->prepare("
|
||||
UPDATE error_logs
|
||||
SET is_resolved = 1,
|
||||
resolved_at = NOW(),
|
||||
resolved_by = ?,
|
||||
resolution_notes = ?
|
||||
WHERE error_id = ?
|
||||
");
|
||||
|
||||
return $stmt->execute([$userId, $notes, $errorId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all occurrences of an error as unresolved
|
||||
*/
|
||||
public function markErrorUnresolved(string $errorId): bool
|
||||
{
|
||||
$stmt = $this->db->prepare("
|
||||
UPDATE error_logs
|
||||
SET is_resolved = 0,
|
||||
resolved_at = NULL,
|
||||
resolved_by = NULL,
|
||||
resolution_notes = NULL
|
||||
WHERE error_id = ?
|
||||
");
|
||||
|
||||
return $stmt->execute([$errorId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all occurrences of an error
|
||||
*/
|
||||
public function deleteByErrorId(string $errorId): bool
|
||||
{
|
||||
$stmt = $this->db->prepare("DELETE FROM error_logs WHERE error_id = ?");
|
||||
return $stmt->execute([$errorId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear old resolved errors
|
||||
*/
|
||||
public function clearOldResolved(int $daysOld): int
|
||||
{
|
||||
$stmt = $this->db->prepare("
|
||||
DELETE FROM error_logs
|
||||
WHERE is_resolved = 1
|
||||
AND resolved_at < DATE_SUB(NOW(), INTERVAL ? DAY)
|
||||
");
|
||||
$stmt->execute([$daysOld]);
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,5 +144,115 @@ class User extends Model
|
||||
$stmt->execute($params);
|
||||
return (int)$stmt->fetch(\PDO::FETCH_ASSOC)['total'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update email verification token
|
||||
*/
|
||||
public function updateEmailVerificationToken(int $userId, string $token): bool
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"UPDATE users SET email_verification_token = ?, email_verification_sent_at = NOW() WHERE id = ?"
|
||||
);
|
||||
return $stmt->execute([$token, $userId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark email as verified
|
||||
*/
|
||||
public function markEmailAsVerified(int $userId): bool
|
||||
{
|
||||
$stmt = $this->db->prepare("UPDATE users SET email_verified = 1 WHERE id = ?");
|
||||
return $stmt->execute([$userId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify email by clearing token
|
||||
*/
|
||||
public function verifyEmailByToken(int $userId): bool
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"UPDATE users SET email_verified = 1, email_verification_token = NULL WHERE id = ?"
|
||||
);
|
||||
return $stmt->execute([$userId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by email verification token
|
||||
*/
|
||||
public function findByVerificationToken(string $token): ?array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM users WHERE email_verification_token = ? AND email_verified = 0"
|
||||
);
|
||||
$stmt->execute([$token]);
|
||||
$result = $stmt->fetch();
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create password reset token
|
||||
*/
|
||||
public function createPasswordResetToken(int $userId, string $token, string $expiresAt): bool
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)"
|
||||
);
|
||||
return $stmt->execute([$userId, $token, $expiresAt]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find valid password reset token
|
||||
*/
|
||||
public function findPasswordResetToken(string $token): ?array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT * FROM password_reset_tokens WHERE token = ? AND used = 0 AND expires_at > NOW()"
|
||||
);
|
||||
$stmt->execute([$token]);
|
||||
$result = $stmt->fetch();
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark password reset token as used
|
||||
*/
|
||||
public function markPasswordResetTokenAsUsed(int $tokenId): bool
|
||||
{
|
||||
$stmt = $this->db->prepare("UPDATE password_reset_tokens SET used = 1 WHERE id = ?");
|
||||
return $stmt->execute([$tokenId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create remember token
|
||||
*/
|
||||
public function createRememberToken(int $userId, string $sessionId, string $token, string $expiresAt): bool
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"INSERT INTO remember_tokens (user_id, session_id, token, expires_at) VALUES (?, ?, ?, ?)"
|
||||
);
|
||||
return $stmt->execute([$userId, $sessionId, $token, $expiresAt]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by remember token
|
||||
*/
|
||||
public function findByRememberToken(string $token): ?array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
"SELECT user_id FROM remember_tokens WHERE token = ? AND expires_at > NOW()"
|
||||
);
|
||||
$stmt->execute([$token]);
|
||||
$result = $stmt->fetch();
|
||||
return $result ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete remember token
|
||||
*/
|
||||
public function deleteRememberToken(string $token): bool
|
||||
{
|
||||
$stmt = $this->db->prepare("DELETE FROM remember_tokens WHERE token = ?");
|
||||
return $stmt->execute([$token]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
315
app/Services/ErrorHandler.php
Normal file
315
app/Services/ErrorHandler.php
Normal file
@@ -0,0 +1,315 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ErrorLog;
|
||||
|
||||
/**
|
||||
* ErrorHandler Service
|
||||
*
|
||||
* Centralized error handling system:
|
||||
* - Captures all errors and exceptions
|
||||
* - Logs to files and database
|
||||
* - Generates unique error IDs
|
||||
* - Displays appropriate error pages
|
||||
* - Sanitizes sensitive data
|
||||
*/
|
||||
class ErrorHandler
|
||||
{
|
||||
private Logger $logger;
|
||||
private ?ErrorLog $errorLogModel = null;
|
||||
private bool $isDevelopment;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->logger = new Logger('errors');
|
||||
// Default to development if APP_ENV not set (show debug info for config errors)
|
||||
$this->isDevelopment = ($_ENV['APP_ENV'] ?? 'development') === 'development';
|
||||
|
||||
// Initialize ErrorLog model if database is available
|
||||
try {
|
||||
$this->errorLogModel = new ErrorLog();
|
||||
} catch (\Exception $e) {
|
||||
// Database not available, will only use file logging
|
||||
// Don't use error_log as it might fail too
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an exception
|
||||
*/
|
||||
public function handleException(\Throwable $exception): void
|
||||
{
|
||||
$errorData = $this->captureError($exception);
|
||||
|
||||
// Log to file
|
||||
$this->logToFile($errorData);
|
||||
|
||||
// Log to database if available
|
||||
$dbErrorId = $this->logToDatabase($errorData);
|
||||
|
||||
// Display error page
|
||||
$this->displayError($errorData, $dbErrorId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle PHP errors (convert to exception)
|
||||
*/
|
||||
public function handleError(int $errno, string $errstr, string $errfile, int $errline): bool
|
||||
{
|
||||
// Don't handle suppressed errors (@)
|
||||
if (!(error_reporting() & $errno)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ignore certain non-critical errors during error handling itself
|
||||
if (error_reporting() === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Convert to ErrorException and handle it
|
||||
$exception = new \ErrorException($errstr, 0, $errno, $errfile, $errline);
|
||||
$this->handleException($exception);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle fatal errors on shutdown
|
||||
*/
|
||||
public function handleShutdown(): void
|
||||
{
|
||||
$error = error_get_last();
|
||||
|
||||
if ($error !== null && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
|
||||
$exception = new \ErrorException(
|
||||
$error['message'],
|
||||
0,
|
||||
$error['type'],
|
||||
$error['file'],
|
||||
$error['line']
|
||||
);
|
||||
|
||||
$this->handleException($exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture complete error context
|
||||
*/
|
||||
private function captureError(\Throwable $exception): array
|
||||
{
|
||||
// Generate unique error ID
|
||||
$errorId = $this->generateErrorId();
|
||||
|
||||
// Sanitize request data (remove passwords, tokens, etc.)
|
||||
$requestData = $this->sanitizeArray(array_merge($_GET, $_POST));
|
||||
|
||||
// Sanitize session data
|
||||
$sessionData = $this->sanitizeArray($_SESSION ?? []);
|
||||
|
||||
return [
|
||||
'error_id' => $errorId,
|
||||
'error_type' => get_class($exception),
|
||||
'error_message' => $exception->getMessage(),
|
||||
'error_file' => $exception->getFile(),
|
||||
'error_line' => $exception->getLine(),
|
||||
'stack_trace' => $exception->getTraceAsString(),
|
||||
'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'CLI',
|
||||
'request_uri' => $_SERVER['REQUEST_URI'] ?? 'N/A',
|
||||
'request_data' => json_encode($requestData),
|
||||
'user_id' => $_SESSION['user_id'] ?? null,
|
||||
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown',
|
||||
'ip_address' => $this->getIpAddress(),
|
||||
'session_data' => json_encode($sessionData),
|
||||
'php_version' => PHP_VERSION,
|
||||
'memory_usage' => memory_get_usage(true),
|
||||
'occurred_at' => date('Y-m-d H:i:s')
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique error ID for reference
|
||||
*/
|
||||
private function generateErrorId(): string
|
||||
{
|
||||
return strtoupper(substr(md5(uniqid('error_', true)), 0, 12));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address
|
||||
*/
|
||||
private function getIpAddress(): string
|
||||
{
|
||||
$headers = [
|
||||
'HTTP_CF_CONNECTING_IP', // Cloudflare
|
||||
'HTTP_X_FORWARDED_FOR', // Proxy
|
||||
'HTTP_X_REAL_IP', // Nginx
|
||||
'REMOTE_ADDR' // Direct
|
||||
];
|
||||
|
||||
foreach ($headers as $header) {
|
||||
if (!empty($_SERVER[$header])) {
|
||||
$ip = $_SERVER[$header];
|
||||
// Handle multiple IPs (X-Forwarded-For)
|
||||
if (strpos($ip, ',') !== false) {
|
||||
$ip = trim(explode(',', $ip)[0]);
|
||||
}
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize array to remove sensitive data
|
||||
*/
|
||||
private function sanitizeArray(array $data): array
|
||||
{
|
||||
$sensitive = ['password', 'password_confirm', 'current_password', 'new_password',
|
||||
'token', 'csrf_token', 'api_key', 'secret', 'bot_token',
|
||||
'mail_password', 'webhook_url', 'captcha_secret_key'];
|
||||
|
||||
$sanitized = [];
|
||||
foreach ($data as $key => $value) {
|
||||
$lowerKey = strtolower($key);
|
||||
$isSensitive = false;
|
||||
|
||||
foreach ($sensitive as $pattern) {
|
||||
if (strpos($lowerKey, $pattern) !== false) {
|
||||
$isSensitive = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($isSensitive) {
|
||||
$sanitized[$key] = '***REDACTED***';
|
||||
} elseif (is_array($value)) {
|
||||
$sanitized[$key] = $this->sanitizeArray($value);
|
||||
} else {
|
||||
$sanitized[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error to file
|
||||
*/
|
||||
private function logToFile(array $errorData): void
|
||||
{
|
||||
$this->logger->separator('ERROR CAPTURED');
|
||||
$this->logger->critical('Error occurred', [
|
||||
'error_id' => $errorData['error_id'],
|
||||
'type' => $errorData['error_type'],
|
||||
'message' => $errorData['error_message'],
|
||||
'file' => $errorData['error_file'],
|
||||
'line' => $errorData['error_line'],
|
||||
'uri' => $errorData['request_uri'],
|
||||
'user_id' => $errorData['user_id'],
|
||||
'ip' => $errorData['ip_address']
|
||||
]);
|
||||
|
||||
// Log stack trace separately for readability
|
||||
$this->logger->error('Stack Trace', ['trace' => $errorData['stack_trace']]);
|
||||
$this->logger->separator('END ERROR');
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error to database
|
||||
*/
|
||||
private function logToDatabase(array $errorData): ?int
|
||||
{
|
||||
if ($this->errorLogModel === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->errorLogModel->logError($errorData);
|
||||
} catch (\Exception $e) {
|
||||
// Database logging failed, continue with file logging only
|
||||
error_log("Failed to log error to database: " . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display appropriate error page
|
||||
*/
|
||||
private function displayError(array $errorData, ?int $dbErrorId): void
|
||||
{
|
||||
// Set HTTP status code
|
||||
http_response_code(500);
|
||||
|
||||
// Clean any output buffers
|
||||
while (ob_get_level() > 0) {
|
||||
ob_end_clean();
|
||||
}
|
||||
|
||||
// Extract variables for view (avoid using extract() which might fail)
|
||||
$error_id = $errorData['error_id'];
|
||||
$error_type = $errorData['error_type'];
|
||||
$error_message = $errorData['error_message'];
|
||||
$error_file = $errorData['error_file'];
|
||||
$error_line = $errorData['error_line'];
|
||||
$stack_trace = $errorData['stack_trace'];
|
||||
$request_method = $errorData['request_method'];
|
||||
$request_uri = $errorData['request_uri'];
|
||||
$user_agent = $errorData['user_agent'];
|
||||
$ip_address = $errorData['ip_address'];
|
||||
$php_version = $errorData['php_version'];
|
||||
$memory_usage = $errorData['memory_usage'];
|
||||
$occurred_at = $errorData['occurred_at'];
|
||||
$user_info = $this->getUserInfo($errorData['user_id']);
|
||||
$request_data = json_decode($errorData['request_data'], true);
|
||||
$session_data = json_decode($errorData['session_data'], true);
|
||||
|
||||
// Display debug page in development, clean 500 in production
|
||||
if ($this->isDevelopment) {
|
||||
require __DIR__ . '/../Views/errors/debug.php';
|
||||
} else {
|
||||
require __DIR__ . '/../Views/errors/500.php';
|
||||
}
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user info for error context
|
||||
*/
|
||||
private function getUserInfo(?int $userId): ?array
|
||||
{
|
||||
if ($userId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $userId,
|
||||
'username' => $_SESSION['username'] ?? 'Unknown',
|
||||
'role' => $_SESSION['role'] ?? 'guest',
|
||||
'email' => $_SESSION['email'] ?? 'Unknown'
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Static helper to register global handlers
|
||||
*/
|
||||
public static function register(): self
|
||||
{
|
||||
$handler = new self();
|
||||
|
||||
// Set exception handler
|
||||
set_exception_handler([$handler, 'handleException']);
|
||||
|
||||
// Set error handler (converts errors to exceptions)
|
||||
set_error_handler([$handler, 'handleError']);
|
||||
|
||||
// Set shutdown handler (catch fatal errors)
|
||||
register_shutdown_function([$handler, 'handleShutdown']);
|
||||
|
||||
return $handler;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,11 +161,11 @@ ob_start();
|
||||
</div>
|
||||
<span class="ml-3 text-sm font-medium text-gray-700 group-hover:text-green-700">Create Group</span>
|
||||
</a>
|
||||
<a href="/debug/whois" class="flex items-center p-3 border border-gray-200 hover:border-purple-500 hover:bg-purple-50 rounded-lg transition-all duration-200 group">
|
||||
<div class="w-9 h-9 bg-purple-50 group-hover:bg-purple-500 rounded-lg flex items-center justify-center group-hover:text-white text-purple-600 transition-colors duration-200">
|
||||
<a href="/debug/whois" class="flex items-center p-3 border border-gray-200 hover:border-indigo-500 hover:bg-indigo-50 rounded-lg transition-all duration-200 group">
|
||||
<div class="w-9 h-9 bg-indigo-50 group-hover:bg-indigo-500 rounded-lg flex items-center justify-center group-hover:text-white text-indigo-600 transition-colors duration-200">
|
||||
<i class="fas fa-search text-sm"></i>
|
||||
</div>
|
||||
<span class="ml-3 text-sm font-medium text-gray-700 group-hover:text-purple-700">WHOIS Lookup</span>
|
||||
<span class="ml-3 text-sm font-medium text-gray-700 group-hover:text-indigo-700">WHOIS Lookup</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,60 +28,7 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
|
||||
?>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="mb-4 flex flex-wrap gap-2 justify-between items-center">
|
||||
<div class="flex gap-2">
|
||||
<!-- Bulk Actions Toolbar (Hidden by default, shown when domains are selected) -->
|
||||
<div id="bulk-actions" class="hidden items-center gap-2">
|
||||
<span id="selected-count" class="text-sm font-medium text-gray-700"></span>
|
||||
|
||||
<button type="button" onclick="bulkRefresh()" class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-sync-alt mr-2"></i>
|
||||
Refresh
|
||||
</button>
|
||||
|
||||
<div class="relative inline-block">
|
||||
<button type="button" onclick="toggleAssignGroupDropdown()" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||
<i class="fas fa-bell mr-2"></i>
|
||||
Assign Group
|
||||
<i class="fas fa-chevron-down ml-2 text-xs"></i>
|
||||
</button>
|
||||
<div id="assign-group-dropdown" class="hidden absolute left-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
||||
<form method="POST" action="/domains/bulk-assign-group" id="bulk-assign-form">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="domain_ids" id="bulk-assign-ids">
|
||||
<div class="p-3">
|
||||
<select name="group_id" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<option value="">-- No Group --</option>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<option value="<?= $group['id'] ?>"><?= htmlspecialchars($group['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 p-2 flex gap-2">
|
||||
<button type="submit" class="flex-1 px-3 py-1.5 bg-primary text-white text-xs rounded hover:bg-primary-dark">
|
||||
Assign
|
||||
</button>
|
||||
<button type="button" onclick="toggleAssignGroupDropdown()" class="flex-1 px-3 py-1.5 bg-gray-200 text-gray-700 text-xs rounded hover:bg-gray-300">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" onclick="bulkDelete()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-trash mr-2"></i>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="mb-4 flex gap-2 justify-end">
|
||||
<?php if (!empty($domains)): ?>
|
||||
<form method="POST" action="/domains/bulk-refresh" id="refresh-all-form">
|
||||
<?= csrf_field() ?>
|
||||
@@ -94,7 +41,7 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<a href="/domains/bulk-add" class="inline-flex items-center px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors font-medium">
|
||||
<a href="/domains/bulk-add" class="inline-flex items-center px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700 transition-colors font-medium">
|
||||
<i class="fas fa-layer-group mr-2"></i>
|
||||
Bulk Add
|
||||
</a>
|
||||
@@ -103,7 +50,6 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
|
||||
Add Domain
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters & Search -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
|
||||
@@ -150,6 +96,59 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Toolbar (Hidden by default, shown when domains are selected) -->
|
||||
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
|
||||
|
||||
<button type="button" onclick="bulkRefresh()" class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-sync-alt mr-2"></i>
|
||||
Refresh Selected
|
||||
</button>
|
||||
|
||||
<div class="relative inline-block">
|
||||
<button type="button" onclick="toggleAssignGroupDropdown()" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium">
|
||||
<i class="fas fa-bell mr-2"></i>
|
||||
Assign Group
|
||||
<i class="fas fa-chevron-down ml-2 text-xs"></i>
|
||||
</button>
|
||||
<div id="assign-group-dropdown" class="hidden absolute left-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
||||
<form method="POST" action="/domains/bulk-assign-group" id="bulk-assign-form">
|
||||
<?= csrf_field() ?>
|
||||
<div class="p-3">
|
||||
<select name="group_id" class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
||||
<option value="">-- No Group --</option>
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<option value="<?= $group['id'] ?>"><?= htmlspecialchars($group['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 p-2 flex gap-2">
|
||||
<button type="submit" class="flex-1 px-3 py-1.5 bg-primary text-white text-xs rounded hover:bg-primary-dark">
|
||||
Assign
|
||||
</button>
|
||||
<button type="button" onclick="toggleAssignGroupDropdown()" class="flex-1 px-3 py-1.5 bg-gray-200 text-gray-700 text-xs rounded hover:bg-gray-300">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" onclick="bulkDelete()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-trash mr-2"></i>
|
||||
Delete Selected
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Info & Per Page Selector -->
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-600">
|
||||
@@ -184,8 +183,8 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left w-12">
|
||||
<input type="checkbox" id="select-all" onclick="toggleSelectAll(this)" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary cursor-pointer">
|
||||
<th class="px-6 py-3 text-left">
|
||||
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this)" class="rounded border-gray-300 text-primary focus:ring-primary">
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
<a href="<?= sortUrl('domain_name', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
|
||||
@@ -231,8 +230,8 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
|
||||
$statusIcon = $domain['statusIcon'];
|
||||
?>
|
||||
<tr class="hover:bg-gray-50 transition-colors duration-150 domain-row">
|
||||
<td class="px-4 py-4">
|
||||
<input type="checkbox" class="domain-checkbox w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary cursor-pointer" value="<?= $domain['id'] ?>">
|
||||
<td class="px-6 py-4">
|
||||
<input type="checkbox" class="domain-checkbox rounded border-gray-300 text-primary focus:ring-primary" value="<?= $domain['id'] ?>" onchange="updateBulkActions()">
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
@@ -328,7 +327,7 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
|
||||
<?php foreach ($domains as $domain): ?>
|
||||
<div class="p-6 hover:bg-gray-50 transition-colors duration-150">
|
||||
<div class="flex items-center mb-3">
|
||||
<input type="checkbox" class="domain-checkbox-mobile w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary cursor-pointer mr-3" value="<?= $domain['id'] ?>">
|
||||
<input type="checkbox" class="domain-checkbox-mobile rounded border-gray-300 text-primary focus:ring-primary mr-3" value="<?= $domain['id'] ?>" onchange="updateBulkActions()">
|
||||
<a href="/domains/<?= $domain['id'] ?>" class="text-lg font-semibold text-gray-900 hover:text-primary"><?= htmlspecialchars($domain['domain_name']) ?></a>
|
||||
</div>
|
||||
<!-- Add mobile view content here if needed -->
|
||||
@@ -440,76 +439,47 @@ $currentFilters = $filters ?? ['search' => '', 'status' => '', 'group' => '', 's
|
||||
<script>
|
||||
// Multi-select functionality
|
||||
function toggleSelectAll(checkbox) {
|
||||
// Only select checkboxes that are currently visible
|
||||
const desktopCheckboxes = document.querySelectorAll('.domain-checkbox');
|
||||
const mobileCheckboxes = document.querySelectorAll('.domain-checkbox-mobile');
|
||||
|
||||
// Check if desktop view is visible (lg:block class)
|
||||
const desktopTable = document.querySelector('.hidden.lg\\:block');
|
||||
const isDesktopVisible = desktopTable && !desktopTable.classList.contains('hidden');
|
||||
|
||||
if (isDesktopVisible) {
|
||||
// Desktop view is visible, select desktop checkboxes
|
||||
desktopCheckboxes.forEach(cb => cb.checked = checkbox.checked);
|
||||
} else {
|
||||
// Mobile view is visible, select mobile checkboxes
|
||||
mobileCheckboxes.forEach(cb => cb.checked = checkbox.checked);
|
||||
}
|
||||
|
||||
const checkboxes = document.querySelectorAll('.domain-checkbox, .domain-checkbox-mobile');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = checkbox.checked;
|
||||
});
|
||||
updateBulkActions();
|
||||
}
|
||||
|
||||
function updateBulkActions() {
|
||||
// Only count checkboxes that are currently visible
|
||||
const desktopCheckboxes = document.querySelectorAll('.domain-checkbox:checked');
|
||||
const mobileCheckboxes = document.querySelectorAll('.domain-checkbox-mobile:checked');
|
||||
|
||||
// Check if desktop view is visible
|
||||
const desktopTable = document.querySelector('.hidden.lg\\:block');
|
||||
const isDesktopVisible = desktopTable && !desktopTable.classList.contains('hidden');
|
||||
|
||||
const checkboxes = isDesktopVisible ? desktopCheckboxes : mobileCheckboxes;
|
||||
const checkboxes = document.querySelectorAll('.domain-checkbox:checked, .domain-checkbox-mobile:checked');
|
||||
const bulkActions = document.getElementById('bulk-actions');
|
||||
const selectedCount = document.getElementById('selected-count');
|
||||
const selectAllCheckbox = document.getElementById('select-all');
|
||||
|
||||
if (checkboxes.length > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
bulkActions.classList.add('flex');
|
||||
selectedCount.textContent = `${checkboxes.length} selected`;
|
||||
selectedCount.textContent = `${checkboxes.length} domain(s) selected`;
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
bulkActions.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
const allCheckboxes = document.querySelectorAll('.domain-checkbox, .domain-checkbox-mobile');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.checked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length;
|
||||
selectAllCheckbox.indeterminate = checkboxes.length > 0 && checkboxes.length < allCheckboxes.length;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
const desktopCheckboxes = document.querySelectorAll('.domain-checkbox');
|
||||
const mobileCheckboxes = document.querySelectorAll('.domain-checkbox-mobile');
|
||||
|
||||
// Check if desktop view is visible
|
||||
const desktopTable = document.querySelector('.hidden.lg\\:block');
|
||||
const isDesktopVisible = desktopTable && !desktopTable.classList.contains('hidden');
|
||||
|
||||
if (isDesktopVisible) {
|
||||
desktopCheckboxes.forEach(cb => cb.checked = false);
|
||||
} else {
|
||||
mobileCheckboxes.forEach(cb => cb.checked = false);
|
||||
}
|
||||
|
||||
const checkboxes = document.querySelectorAll('.domain-checkbox, .domain-checkbox-mobile');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = false;
|
||||
});
|
||||
document.getElementById('select-all').checked = false;
|
||||
updateBulkActions();
|
||||
}
|
||||
|
||||
function getSelectedIds() {
|
||||
// Only get IDs from currently visible checkboxes
|
||||
const desktopCheckboxes = document.querySelectorAll('.domain-checkbox:checked');
|
||||
const mobileCheckboxes = document.querySelectorAll('.domain-checkbox-mobile:checked');
|
||||
|
||||
// Check if desktop view is visible
|
||||
const desktopTable = document.querySelector('.hidden.lg\\:block');
|
||||
const isDesktopVisible = desktopTable && !desktopTable.classList.contains('hidden');
|
||||
|
||||
const checkboxes = isDesktopVisible ? desktopCheckboxes : mobileCheckboxes;
|
||||
const checkboxes = document.querySelectorAll('.domain-checkbox:checked, .domain-checkbox-mobile:checked');
|
||||
return Array.from(checkboxes).map(cb => cb.value);
|
||||
}
|
||||
|
||||
@@ -580,37 +550,6 @@ document.getElementById('bulk-assign-form')?.addEventListener('submit', function
|
||||
});
|
||||
});
|
||||
|
||||
// Listen to checkbox changes
|
||||
document.querySelectorAll('.domain-checkbox, .domain-checkbox-mobile').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', function() {
|
||||
// Update the select-all checkbox state
|
||||
const desktopCheckboxes = document.querySelectorAll('.domain-checkbox');
|
||||
const mobileCheckboxes = document.querySelectorAll('.domain-checkbox-mobile');
|
||||
|
||||
// Check if desktop view is visible
|
||||
const desktopTable = document.querySelector('.hidden.lg\\:block');
|
||||
const isDesktopVisible = desktopTable && !desktopTable.classList.contains('hidden');
|
||||
|
||||
const checkboxes = isDesktopVisible ? desktopCheckboxes : mobileCheckboxes;
|
||||
const checkedBoxes = isDesktopVisible ?
|
||||
document.querySelectorAll('.domain-checkbox:checked') :
|
||||
document.querySelectorAll('.domain-checkbox-mobile:checked');
|
||||
|
||||
const selectAllCheckbox = document.getElementById('select-all');
|
||||
if (checkedBoxes.length === 0) {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else if (checkedBoxes.length === checkboxes.length) {
|
||||
selectAllCheckbox.checked = true;
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
} else {
|
||||
selectAllCheckbox.checked = false;
|
||||
selectAllCheckbox.indeterminate = true;
|
||||
}
|
||||
|
||||
updateBulkActions();
|
||||
});
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', function(event) {
|
||||
@@ -622,11 +561,6 @@ document.addEventListener('click', function(event) {
|
||||
}
|
||||
});
|
||||
|
||||
// Handle window resize to sync checkboxes when switching between desktop/mobile views
|
||||
window.addEventListener('resize', function() {
|
||||
// Small delay to allow CSS classes to update
|
||||
setTimeout(updateBulkActions, 100);
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php
|
||||
|
||||
@@ -32,7 +32,7 @@ ob_start();
|
||||
<?= $daysLeft !== null ? $daysLeft . ' days left' : 'No expiry date' ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold bg-purple-100 text-purple-800 border border-purple-200">
|
||||
<span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-semibold bg-indigo-100 text-indigo-800 border border-indigo-200">
|
||||
<i class="fas fa-<?= $domain['is_active'] ? 'check-circle' : 'pause-circle' ?> mr-1.5"></i>
|
||||
<?= $domain['is_active'] ? 'Monitoring Active' : 'Monitoring Paused' ?>
|
||||
</span>
|
||||
@@ -167,8 +167,8 @@ ob_start();
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="flex items-center p-2 bg-purple-50 rounded border border-purple-200">
|
||||
<div class="w-7 h-7 bg-purple-500 rounded flex items-center justify-center mr-2">
|
||||
<div class="flex items-center p-2 bg-indigo-50 rounded border border-indigo-200">
|
||||
<div class="w-7 h-7 bg-indigo-500 rounded flex items-center justify-center mr-2">
|
||||
<i class="fas fa-sync text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
187
app/Views/errors/500.php
Normal file
187
app/Views/errors/500.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>500 - Internal Server Error</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#4A90E2',
|
||||
dark: '#357ABD',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-gradient-to-br from-red-50 to-orange-100 min-h-screen flex items-center justify-center p-6">
|
||||
<div class="max-w-2xl w-full">
|
||||
<div class="bg-white rounded-2xl shadow-2xl p-12 text-center">
|
||||
<!-- Error Icon -->
|
||||
<div class="mb-8">
|
||||
<i class="fas fa-exclamation-circle text-red-500 text-8xl mb-4"></i>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<h1 class="text-9xl font-bold text-gray-800 mb-4">500</h1>
|
||||
<h2 class="text-3xl font-bold text-gray-700 mb-4">Internal Server Error</h2>
|
||||
<p class="text-gray-600 text-lg mb-8 leading-relaxed">
|
||||
Oops! Something went wrong on our end. We're working to fix the issue.
|
||||
</p>
|
||||
|
||||
<!-- Error Reference ID -->
|
||||
<?php if (!empty($error_id)): ?>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-5 mb-8">
|
||||
<div class="flex items-center justify-center space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-fingerprint text-blue-600 text-2xl"></i>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<p class="text-sm font-medium text-gray-700 mb-1">Error Reference ID:</p>
|
||||
<div class="flex items-center space-x-2">
|
||||
<code class="text-lg font-mono font-bold text-primary bg-white px-3 py-1 rounded border border-blue-200">
|
||||
<?= htmlspecialchars($error_id) ?>
|
||||
</code>
|
||||
<button onclick="copyErrorId()"
|
||||
class="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||
title="Copy Error ID">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 mt-2">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Please include this ID when reporting the issue
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a href="/" class="inline-flex items-center justify-center px-8 py-4 bg-primary text-white rounded-lg hover:bg-primary-dark transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5">
|
||||
<i class="fas fa-home mr-2"></i>
|
||||
Go to Dashboard
|
||||
</a>
|
||||
<button onclick="history.back()" class="inline-flex items-center justify-center px-8 py-4 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-all duration-200 shadow-md hover:shadow-lg">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Go Back
|
||||
</button>
|
||||
<button onclick="location.reload()" class="inline-flex items-center justify-center px-8 py-4 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-all duration-200 shadow-md hover:shadow-lg">
|
||||
<i class="fas fa-redo mr-2"></i>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Helpful Links -->
|
||||
<div class="mt-12 pt-8 border-t border-gray-200">
|
||||
<p class="text-sm text-gray-500 mb-4">Quick Links:</p>
|
||||
<div class="flex flex-wrap justify-center gap-4">
|
||||
<a href="/domains" class="text-primary hover:text-primary-dark transition-colors duration-150">
|
||||
<i class="fas fa-globe mr-1"></i>
|
||||
Domains
|
||||
</a>
|
||||
<a href="/groups" class="text-primary hover:text-primary-dark transition-colors duration-150">
|
||||
<i class="fas fa-bell mr-1"></i>
|
||||
Notification Groups
|
||||
</a>
|
||||
<a href="/debug/whois" class="text-primary hover:text-primary-dark transition-colors duration-150">
|
||||
<i class="fas fa-search mr-1"></i>
|
||||
WHOIS Lookup
|
||||
</a>
|
||||
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
||||
<a href="/settings" class="text-primary hover:text-primary-dark transition-colors duration-150">
|
||||
<i class="fas fa-cog mr-1"></i>
|
||||
Settings
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Support Info -->
|
||||
<div class="mt-8 bg-gray-50 rounded-lg p-4">
|
||||
<p class="text-sm text-gray-600">
|
||||
<i class="fas fa-life-ring text-primary mr-1"></i>
|
||||
If this problem persists, please contact your system administrator
|
||||
<?php if (!empty($error_id)): ?>
|
||||
and provide the error reference ID above.
|
||||
<?php else: ?>
|
||||
.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-center mt-8">
|
||||
<p class="text-gray-600">
|
||||
<i class="fas fa-globe text-primary"></i>
|
||||
<span class="ml-2">Domain Monitor © <?= date('Y') ?></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyErrorId() {
|
||||
const errorId = '<?= htmlspecialchars($error_id ?? '') ?>';
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(errorId).then(() => {
|
||||
showSuccess();
|
||||
}).catch(() => {
|
||||
fallbackCopy(errorId);
|
||||
});
|
||||
} else {
|
||||
fallbackCopy(errorId);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text) {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
showSuccess();
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
function showSuccess() {
|
||||
const btn = event.target.closest('button');
|
||||
if (btn) {
|
||||
const originalHTML = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="fas fa-check"></i>';
|
||||
btn.classList.add('bg-green-600', 'hover:bg-green-700');
|
||||
btn.classList.remove('bg-blue-600', 'hover:bg-blue-700');
|
||||
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = originalHTML;
|
||||
btn.classList.remove('bg-green-600', 'hover:bg-green-700');
|
||||
btn.classList.add('bg-blue-600', 'hover:bg-blue-700');
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
360
app/Views/errors/admin-detail.php
Normal file
360
app/Views/errors/admin-detail.php
Normal file
@@ -0,0 +1,360 @@
|
||||
<?php
|
||||
$title = 'Error Details';
|
||||
$pageTitle = 'Error Details';
|
||||
$pageDescription = 'Detailed information about this error';
|
||||
$pageIcon = 'fas fa-bug';
|
||||
ob_start();
|
||||
|
||||
$isResolved = (bool)$error['is_resolved'];
|
||||
$errorTypeShort = substr(strrchr($error['error_type'], '\\'), 1) ?: $error['error_type'];
|
||||
?>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<a href="/errors" class="text-gray-600 hover:text-primary">
|
||||
<i class="fas fa-arrow-left mr-2"></i>
|
||||
Back to Error Logs
|
||||
</a>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<?php if ($isResolved): ?>
|
||||
<form method="POST" action="/errors/<?= htmlspecialchars($error['error_id']) ?>/unresolve" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
|
||||
<button type="submit" class="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-undo mr-2"></i>
|
||||
Mark as Unresolved
|
||||
</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<button onclick="markResolved()" class="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-check mr-2"></i>
|
||||
Mark as Resolved
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
|
||||
<button onclick="deleteError()" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-trash mr-2"></i>
|
||||
Delete Error
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Header Card -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6 mb-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 h-14 w-14 bg-red-100 rounded-lg flex items-center justify-center mr-4">
|
||||
<i class="fas fa-bug text-red-600 text-2xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h2 class="text-2xl font-semibold text-gray-900"><?= htmlspecialchars($errorTypeShort) ?></h2>
|
||||
<?php if ($isResolved): ?>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200">
|
||||
<i class="fas fa-check-circle mr-1"></i>
|
||||
Resolved
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-orange-100 text-orange-800 border border-orange-200">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Unresolved
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<p class="text-gray-600 mb-3"><?= htmlspecialchars($error['error_message']) ?></p>
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-hashtag mr-1.5"></i>
|
||||
<span class="font-mono font-semibold text-primary"><?= htmlspecialchars($error['error_id']) ?></span>
|
||||
<button onclick="copyToClipboard('<?= htmlspecialchars($error['error_id']) ?>')" class="ml-2 text-gray-400 hover:text-primary" title="Copy Error ID">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-redo mr-1.5"></i>
|
||||
<span><?= count($errorOccurrences) ?> occurrence<?= count($errorOccurrences) != 1 ? 's' : '' ?></span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-clock mr-1.5"></i>
|
||||
<span>Last: <?= date('M d, Y H:i:s', strtotime($error['occurred_at'])) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Info -->
|
||||
<div class="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">File</p>
|
||||
<p class="font-mono text-sm text-gray-900 break-all"><?= htmlspecialchars($error['error_file']) ?></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Line</p>
|
||||
<p class="font-mono text-sm text-gray-900"><?= $error['error_line'] ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resolution Info (if resolved) -->
|
||||
<?php if ($isResolved && $error['resolved_at']): ?>
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-check-circle text-green-600 mt-0.5 mr-3"></i>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-semibold text-green-900 mb-2">Resolved</h3>
|
||||
<div class="text-sm text-green-800 space-y-1">
|
||||
<p><strong>Date:</strong> <?= date('M d, Y H:i:s', strtotime($error['resolved_at'])) ?></p>
|
||||
<?php if ($error['resolution_notes']): ?>
|
||||
<p><strong>Notes:</strong> <?= htmlspecialchars($error['resolution_notes']) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden mb-6">
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="-mb-px flex">
|
||||
<button onclick="switchTab('stack-trace')" id="tab-stack-trace" class="tab-button active px-6 py-3 text-sm font-medium border-b-2 border-primary text-primary">
|
||||
<i class="fas fa-layer-group mr-2"></i>
|
||||
Stack Trace
|
||||
</button>
|
||||
<button onclick="switchTab('request')" id="tab-request" class="tab-button px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
|
||||
<i class="fas fa-exchange-alt mr-2"></i>
|
||||
Request Data
|
||||
</button>
|
||||
<button onclick="switchTab('session')" id="tab-session" class="tab-button px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
|
||||
<i class="fas fa-user mr-2"></i>
|
||||
Session Data
|
||||
</button>
|
||||
<button onclick="switchTab('occurrences')" id="tab-occurrences" class="tab-button px-6 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300">
|
||||
<i class="fas fa-history mr-2"></i>
|
||||
All Occurrences (<?= count($errorOccurrences) ?>)
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="p-6">
|
||||
<!-- Stack Trace Tab -->
|
||||
<div id="content-stack-trace" class="tab-content">
|
||||
<?php if (!empty($error['stack_trace_array'])): ?>
|
||||
<div class="space-y-2">
|
||||
<?php foreach ($error['stack_trace_array'] as $index => $trace): ?>
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4 hover:border-primary transition-colors">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 w-8 h-8 bg-primary text-white rounded-full flex items-center justify-center font-semibold text-sm mr-3">
|
||||
<?= $index ?>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<?php if (isset($trace['file'])): ?>
|
||||
<p class="font-mono text-xs text-gray-600 break-all mb-1">
|
||||
<?= htmlspecialchars($trace['file']) ?>
|
||||
<span class="text-primary font-semibold">line <?= $trace['line'] ?? '?' ?></span>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($trace['function'])): ?>
|
||||
<p class="font-mono text-sm text-gray-900">
|
||||
<?php if (isset($trace['class'])): ?>
|
||||
<span class="text-blue-600"><?= htmlspecialchars($trace['class']) ?></span><?= htmlspecialchars($trace['type']) ?>
|
||||
<?php endif; ?>
|
||||
<span class="text-indigo-600"><?= htmlspecialchars($trace['function']) ?></span>()
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p class="text-gray-500 text-center py-8">No stack trace available</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Request Data Tab -->
|
||||
<div id="content-request" class="tab-content hidden">
|
||||
<?php if (!empty($error['request_data'])): ?>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-2">Request Info</h3>
|
||||
<div class="bg-gray-50 rounded-lg p-4 font-mono text-xs">
|
||||
<p><strong>Method:</strong> <?= htmlspecialchars($error['request_method']) ?></p>
|
||||
<p><strong>URI:</strong> <?= htmlspecialchars($error['request_uri']) ?></p>
|
||||
<p><strong>IP:</strong> <?= htmlspecialchars($error['ip_address']) ?></p>
|
||||
<p><strong>User Agent:</strong> <?= htmlspecialchars($error['user_agent']) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
<?php foreach ($error['request_data'] as $key => $value): ?>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-2"><?= htmlspecialchars(strtoupper($key)) ?></h3>
|
||||
<pre class="bg-gray-900 text-green-400 p-4 rounded-lg overflow-x-auto text-xs"><?= htmlspecialchars(json_encode($value, JSON_PRETTY_PRINT)) ?></pre>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p class="text-gray-500 text-center py-8">No request data available</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Session Data Tab -->
|
||||
<div id="content-session" class="tab-content hidden">
|
||||
<?php if (!empty($error['session_data'])): ?>
|
||||
<pre class="bg-gray-900 text-green-400 p-4 rounded-lg overflow-x-auto text-xs"><?= htmlspecialchars(json_encode($error['session_data'], JSON_PRETTY_PRINT)) ?></pre>
|
||||
<?php else: ?>
|
||||
<p class="text-gray-500 text-center py-8">No session data available</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Occurrences Tab -->
|
||||
<div id="content-occurrences" class="tab-content hidden">
|
||||
<div class="space-y-2">
|
||||
<?php foreach ($errorOccurrences as $occurrence): ?>
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-900"><?= date('M d, Y H:i:s', strtotime($occurrence['occurred_at'])) ?></p>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
<?= htmlspecialchars($occurrence['request_method']) ?>
|
||||
<?= htmlspecialchars($occurrence['request_uri']) ?>
|
||||
from <?= htmlspecialchars($occurrence['ip_address']) ?>
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
ID: <span class="font-mono"><?= $occurrence['id'] ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Information -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">System Information</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">PHP Version</p>
|
||||
<p class="text-sm text-gray-900"><?= htmlspecialchars($error['php_version']) ?></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Memory Usage</p>
|
||||
<p class="text-sm text-gray-900"><?= htmlspecialchars($error['memory_usage']) ?></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">First Occurred</p>
|
||||
<p class="text-sm text-gray-900"><?= date('M d, Y H:i:s', strtotime($errorOccurrences[count($errorOccurrences)-1]['occurred_at'])) ?></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchTab(tabName) {
|
||||
// Hide all tab contents
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Remove active class from all tabs
|
||||
document.querySelectorAll('.tab-button').forEach(button => {
|
||||
button.classList.remove('active', 'border-primary', 'text-primary');
|
||||
button.classList.add('border-transparent', 'text-gray-500');
|
||||
});
|
||||
|
||||
// Show selected tab content
|
||||
document.getElementById('content-' + tabName).classList.remove('hidden');
|
||||
|
||||
// Add active class to selected tab
|
||||
const activeTab = document.getElementById('tab-' + tabName);
|
||||
activeTab.classList.add('active', 'border-primary', 'text-primary');
|
||||
activeTab.classList.remove('border-transparent', 'text-gray-500');
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showCopySuccess();
|
||||
});
|
||||
} else {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
showCopySuccess();
|
||||
}
|
||||
}
|
||||
|
||||
function showCopySuccess() {
|
||||
const message = document.createElement('div');
|
||||
message.className = 'fixed top-4 right-4 bg-green-500 text-white px-4 py-3 rounded-lg shadow-lg z-50 flex items-center';
|
||||
message.innerHTML = '<i class="fas fa-check mr-2"></i>Copied to clipboard!';
|
||||
document.body.appendChild(message);
|
||||
|
||||
setTimeout(() => {
|
||||
message.style.opacity = '0';
|
||||
message.style.transition = 'opacity 0.3s';
|
||||
setTimeout(() => message.remove(), 300);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function markResolved() {
|
||||
const notes = prompt('Add resolution notes (optional):');
|
||||
if (notes === null) return; // User cancelled
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/errors/<?= htmlspecialchars($error['error_id']) ?>/resolve';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '<?= csrf_token() ?>';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
if (notes) {
|
||||
const notesInput = document.createElement('input');
|
||||
notesInput.type = 'hidden';
|
||||
notesInput.name = 'notes';
|
||||
notesInput.value = notes;
|
||||
form.appendChild(notesInput);
|
||||
}
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function deleteError() {
|
||||
if (!confirm('Are you sure you want to delete this error and all its occurrences? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/errors/<?= htmlspecialchars($error['error_id']) ?>/delete';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '<?= csrf_token() ?>';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
|
||||
522
app/Views/errors/admin-index.php
Normal file
522
app/Views/errors/admin-index.php
Normal file
@@ -0,0 +1,522 @@
|
||||
<?php
|
||||
$title = 'Error Logs';
|
||||
$pageTitle = 'Error Logs';
|
||||
$pageDescription = 'Monitor and manage application errors';
|
||||
$pageIcon = 'fas fa-bug';
|
||||
ob_start();
|
||||
|
||||
// Helper function to generate sort URL
|
||||
function sortUrl($column, $currentSort, $currentOrder, $filters) {
|
||||
$newOrder = ($currentSort === $column && $currentOrder === 'asc') ? 'desc' : 'asc';
|
||||
$params = $filters;
|
||||
$params['sort'] = $column;
|
||||
$params['order'] = $newOrder;
|
||||
return '/errors?' . http_build_query($params);
|
||||
}
|
||||
|
||||
// Helper function for sort icon
|
||||
function sortIcon($column, $currentSort, $currentOrder) {
|
||||
if ($currentSort !== $column) {
|
||||
return '<i class="fas fa-sort text-gray-400 ml-1 text-xs"></i>';
|
||||
}
|
||||
$icon = $currentOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down';
|
||||
return '<i class="fas ' . $icon . ' text-primary ml-1 text-xs"></i>';
|
||||
}
|
||||
|
||||
// Get current filters
|
||||
$currentFilters = $filters ?? ['resolved' => '', 'type' => '', 'sort' => 'last_occurred_at', 'order' => 'desc'];
|
||||
?>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<!-- Total Errors Card -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Total Errors</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $stats['total_errors'] ?? 0 ?></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-red-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-exclamation-triangle text-red-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unresolved Card -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Unresolved</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $stats['unresolved'] ?? 0 ?></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-orange-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-exclamation-circle text-orange-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last 24h Card -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Last 24h</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $stats['last_24h'] ?? 0 ?></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-clock text-blue-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Occurrences Card -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">Occurrences</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $stats['total_occurrences'] ?? 0 ?></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-indigo-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-layer-group text-indigo-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
|
||||
<form method="GET" action="/errors" id="filter-form">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1.5">Status</label>
|
||||
<select name="resolved" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<option value="">All Errors</option>
|
||||
<option value="0" <?= $currentFilters['resolved'] === '0' ? 'selected' : '' ?>>Unresolved Only</option>
|
||||
<option value="1" <?= $currentFilters['resolved'] === '1' ? 'selected' : '' ?>>Resolved Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1.5">Error Type</label>
|
||||
<input type="text" name="type" value="<?= htmlspecialchars($currentFilters['type']) ?>" placeholder="e.g., PDOException" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1.5">Sort By</label>
|
||||
<select name="sort" class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm">
|
||||
<option value="last_occurred_at" <?= $currentFilters['sort'] === 'last_occurred_at' ? 'selected' : '' ?>>Last Occurred</option>
|
||||
<option value="occurrences" <?= $currentFilters['sort'] === 'occurrences' ? 'selected' : '' ?>>Most Frequent</option>
|
||||
<option value="occurred_at" <?= $currentFilters['sort'] === 'occurred_at' ? 'selected' : '' ?>>First Occurred</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end space-x-2">
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors text-sm font-medium">
|
||||
<i class="fas fa-filter mr-2"></i>
|
||||
Apply
|
||||
</button>
|
||||
<a href="/errors" class="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="order" value="<?= htmlspecialchars($currentFilters['order']) ?>">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Toolbar (Hidden by default, shown when errors are selected) -->
|
||||
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
|
||||
|
||||
<button type="button" onclick="bulkDelete()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-trash mr-2"></i>
|
||||
Delete Selected
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Info -->
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-600">
|
||||
Showing <span class="font-semibold text-gray-900"><?= $pagination['showing_from'] ?></span> to
|
||||
<span class="font-semibold text-gray-900"><?= $pagination['showing_to'] ?></span> of
|
||||
<span class="font-semibold text-gray-900"><?= $pagination['total'] ?></span> error(s)
|
||||
</div>
|
||||
|
||||
<form method="GET" action="/errors" class="flex items-center gap-2">
|
||||
<!-- Preserve filters -->
|
||||
<input type="hidden" name="resolved" value="<?= htmlspecialchars($currentFilters['resolved']) ?>">
|
||||
<input type="hidden" name="type" value="<?= htmlspecialchars($currentFilters['type']) ?>">
|
||||
<input type="hidden" name="sort" value="<?= htmlspecialchars($currentFilters['sort']) ?>">
|
||||
<input type="hidden" name="order" value="<?= htmlspecialchars($currentFilters['order']) ?>">
|
||||
|
||||
<label for="per_page" class="text-sm text-gray-600">Show:</label>
|
||||
<select name="per_page" id="per_page" onchange="this.form.submit()" class="px-3 py-1.5 border border-gray-300 rounded-lg text-sm">
|
||||
<option value="10" <?= $pagination['per_page'] == 10 ? 'selected' : '' ?>>10</option>
|
||||
<option value="25" <?= $pagination['per_page'] == 25 ? 'selected' : '' ?>>25</option>
|
||||
<option value="50" <?= $pagination['per_page'] == 50 ? 'selected' : '' ?>>50</option>
|
||||
<option value="100" <?= $pagination['per_page'] == 100 ? 'selected' : '' ?>>100</option>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Errors List -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<?php if (!empty($errors)): ?>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left">
|
||||
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this)" class="rounded border-gray-300 text-primary focus:ring-primary">
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
Error
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
Location
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
Occurrences
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
Last Occurred
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<?php foreach ($errors as $error): ?>
|
||||
<?php
|
||||
$errorTypeShort = substr(strrchr($error['error_type'], '\\'), 1) ?: $error['error_type'];
|
||||
$isResolved = (bool)$error['is_resolved'];
|
||||
?>
|
||||
<tr class="hover:bg-gray-50 transition-colors duration-150">
|
||||
<td class="px-6 py-4">
|
||||
<input type="checkbox" class="error-checkbox rounded border-gray-300 text-primary focus:ring-primary" value="<?= htmlspecialchars($error['error_id']) ?>" onchange="updateBulkActions()">
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0 h-10 w-10 bg-red-100 rounded-lg flex items-center justify-center mr-3">
|
||||
<i class="fas fa-bug text-red-600"></i>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-mono font-semibold text-primary"><?= htmlspecialchars($error['error_id']) ?></span>
|
||||
<button onclick="copyToClipboard('<?= htmlspecialchars($error['error_id']) ?>')" class="text-gray-400 hover:text-primary" title="Copy Error ID">
|
||||
<i class="fas fa-copy text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm font-semibold text-gray-900"><?= htmlspecialchars($errorTypeShort) ?></p>
|
||||
<p class="text-xs text-gray-600 mt-0.5 truncate" style="max-width: 300px;" title="<?= htmlspecialchars($error['error_message']) ?>">
|
||||
<?= htmlspecialchars($error['error_message']) ?>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-xs">
|
||||
<p class="font-mono text-gray-600 truncate" style="max-width: 200px;" title="<?= htmlspecialchars($error['error_file']) ?>">
|
||||
<?= htmlspecialchars(basename($error['error_file'])) ?>
|
||||
</p>
|
||||
<p class="text-gray-500 mt-0.5">
|
||||
<i class="fas fa-hashtag mr-1"></i>
|
||||
Line <?= $error['error_line'] ?>
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold <?= $error['occurrences'] >= 10 ? 'bg-red-100 text-red-800' : 'bg-gray-100 text-gray-800' ?>">
|
||||
<i class="fas fa-redo mr-1"></i>
|
||||
<?= $error['occurrences'] ?>×
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div class="flex items-center">
|
||||
<i class="far fa-clock mr-2"></i>
|
||||
<?= date('M d, H:i', strtotime($error['last_occurred_at'])) ?>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<?php if ($isResolved): ?>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-100 text-green-800 border border-green-200">
|
||||
<i class="fas fa-check-circle mr-1"></i>
|
||||
Resolved
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-orange-100 text-orange-800 border border-orange-200">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
Unresolved
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex items-center justify-end space-x-2">
|
||||
<a href="/errors/<?= htmlspecialchars($error['error_id']) ?>" class="text-blue-600 hover:text-blue-800" title="View Details">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
<?php if (!$isResolved): ?>
|
||||
<button onclick="markResolved('<?= htmlspecialchars($error['error_id']) ?>')" class="text-green-600 hover:text-green-800" title="Mark as Resolved">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<button onclick="deleteError('<?= htmlspecialchars($error['error_id']) ?>')" class="text-red-600 hover:text-red-800" title="Delete Error">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="text-center py-12 px-6">
|
||||
<div class="mb-4">
|
||||
<i class="fas fa-check-circle text-green-500 text-6xl"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 mb-1">No Errors Found</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
<?php if (!empty($currentFilters['resolved']) || !empty($currentFilters['type'])): ?>
|
||||
No errors match your filter criteria.
|
||||
<?php else: ?>
|
||||
Great! Your application is running smoothly.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<?php if ($pagination['total_pages'] > 1): ?>
|
||||
<div class="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div class="text-sm text-gray-600">
|
||||
Page <span class="font-semibold text-gray-900"><?= $pagination['current_page'] ?></span> of
|
||||
<span class="font-semibold text-gray-900"><?= $pagination['total_pages'] ?></span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<?php
|
||||
$currentPage = $pagination['current_page'];
|
||||
$totalPages = $pagination['total_pages'];
|
||||
|
||||
function paginationUrl($page, $filters, $perPage) {
|
||||
$params = $filters;
|
||||
$params['page'] = $page;
|
||||
$params['per_page'] = $perPage;
|
||||
return '/errors?' . http_build_query($params);
|
||||
}
|
||||
?>
|
||||
|
||||
<?php if ($currentPage > 1): ?>
|
||||
<a href="<?= paginationUrl(1, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
<i class="fas fa-angle-double-left"></i>
|
||||
</a>
|
||||
<a href="<?= paginationUrl($currentPage - 1, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
<i class="fas fa-angle-left"></i> Previous
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$range = 2;
|
||||
$start = max(1, $currentPage - $range);
|
||||
$end = min($totalPages, $currentPage + $range);
|
||||
|
||||
if ($start > 1) {
|
||||
echo '<a href="' . paginationUrl(1, $currentFilters, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">1</a>';
|
||||
if ($start > 2) echo '<span class="px-2 text-gray-500">...</span>';
|
||||
}
|
||||
|
||||
for ($i = $start; $i <= $end; $i++) {
|
||||
if ($i == $currentPage) {
|
||||
echo '<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">' . $i . '</span>';
|
||||
} else {
|
||||
echo '<a href="' . paginationUrl($i, $currentFilters, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">' . $i . '</a>';
|
||||
}
|
||||
}
|
||||
|
||||
if ($end < $totalPages) {
|
||||
if ($end < $totalPages - 1) echo '<span class="px-2 text-gray-500">...</span>';
|
||||
echo '<a href="' . paginationUrl($totalPages, $currentFilters, $pagination['per_page']) . '" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">' . $totalPages . '</a>';
|
||||
}
|
||||
?>
|
||||
|
||||
<?php if ($currentPage < $totalPages): ?>
|
||||
<a href="<?= paginationUrl($currentPage + 1, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
Next <i class="fas fa-angle-right"></i>
|
||||
</a>
|
||||
<a href="<?= paginationUrl($totalPages, $currentFilters, $pagination['per_page']) ?>" class="px-3 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
<i class="fas fa-angle-double-right"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<script>
|
||||
function copyToClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showCopySuccess();
|
||||
});
|
||||
} else {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
showCopySuccess();
|
||||
}
|
||||
}
|
||||
|
||||
function showCopySuccess() {
|
||||
const message = document.createElement('div');
|
||||
message.className = 'fixed top-4 right-4 bg-green-500 text-white px-4 py-3 rounded-lg shadow-lg z-50 flex items-center';
|
||||
message.innerHTML = '<i class="fas fa-check mr-2"></i>Copied to clipboard!';
|
||||
document.body.appendChild(message);
|
||||
|
||||
setTimeout(() => {
|
||||
message.style.opacity = '0';
|
||||
message.style.transition = 'opacity 0.3s';
|
||||
setTimeout(() => message.remove(), 300);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function markResolved(errorId) {
|
||||
const notes = prompt('Add resolution notes (optional):');
|
||||
if (notes === null) return; // User cancelled
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/errors/' + errorId + '/resolve';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '<?= csrf_token() ?>';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
if (notes) {
|
||||
const notesInput = document.createElement('input');
|
||||
notesInput.type = 'hidden';
|
||||
notesInput.name = 'notes';
|
||||
notesInput.value = notes;
|
||||
form.appendChild(notesInput);
|
||||
}
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function deleteError(errorId) {
|
||||
if (!confirm('Are you sure you want to delete this error and all its occurrences? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/errors/' + errorId + '/delete';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '<?= csrf_token() ?>';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
// Checkbox selection functions
|
||||
function toggleSelectAll(checkbox) {
|
||||
const checkboxes = document.querySelectorAll('.error-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = checkbox.checked;
|
||||
});
|
||||
updateBulkActions();
|
||||
}
|
||||
|
||||
function updateBulkActions() {
|
||||
const checkboxes = document.querySelectorAll('.error-checkbox:checked');
|
||||
const bulkActions = document.getElementById('bulk-actions');
|
||||
const selectedCount = document.getElementById('selected-count');
|
||||
const selectAllCheckbox = document.getElementById('select-all');
|
||||
|
||||
if (checkboxes.length > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
bulkActions.classList.add('flex');
|
||||
selectedCount.textContent = checkboxes.length + ' error(s) selected';
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
bulkActions.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
const allCheckboxes = document.querySelectorAll('.error-checkbox');
|
||||
selectAllCheckbox.checked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length;
|
||||
selectAllCheckbox.indeterminate = checkboxes.length > 0 && checkboxes.length < allCheckboxes.length;
|
||||
}
|
||||
|
||||
function getSelectedErrorIds() {
|
||||
const checkboxes = document.querySelectorAll('.error-checkbox:checked');
|
||||
return Array.from(checkboxes).map(cb => cb.value);
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
const checkboxes = document.querySelectorAll('.error-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = false;
|
||||
});
|
||||
document.getElementById('select-all').checked = false;
|
||||
updateBulkActions();
|
||||
}
|
||||
|
||||
function bulkDelete() {
|
||||
const errorIds = getSelectedErrorIds();
|
||||
|
||||
if (errorIds.length === 0) {
|
||||
alert('Please select at least one error to delete');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete ${errorIds.length} error(s) and all their occurrences? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/errors/bulk-delete';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '<?= csrf_token() ?>';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
const idsInput = document.createElement('input');
|
||||
idsInput.type = 'hidden';
|
||||
idsInput.name = 'error_ids';
|
||||
idsInput.value = JSON.stringify(errorIds);
|
||||
form.appendChild(idsInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/../layout/base.php';
|
||||
?>
|
||||
|
||||
565
app/Views/errors/debug.php
Normal file
565
app/Views/errors/debug.php
Normal file
@@ -0,0 +1,565 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Debug Error - <?= htmlspecialchars($error_type ?? 'Application Error') ?></title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#4A90E2',
|
||||
dark: '#357ABD',
|
||||
light: '#6BA3E8',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background-color: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.line-number {
|
||||
color: #858585;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen p-6">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="max-w-7xl mx-auto mb-6">
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-5">
|
||||
<div class="flex items-center justify-between flex-wrap gap-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-bug text-red-600 text-xl"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Debug Mode</h1>
|
||||
<p class="text-sm text-gray-600 mt-0.5">
|
||||
<i class="fas fa-circle text-orange-500 mr-1 text-xs animate-pulse"></i>
|
||||
Development Environment - Detailed Error Information
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="copyErrorReport()"
|
||||
class="inline-flex items-center px-4 py-2.5 bg-primary hover:bg-primary-dark text-white rounded-lg transition-colors font-medium text-sm">
|
||||
<i class="fas fa-clipboard mr-2"></i>
|
||||
Copy Error Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-7xl mx-auto">
|
||||
|
||||
<!-- Primary Error Card -->
|
||||
<div class="bg-white rounded-lg shadow-sm border-l-4 border-red-500 mb-6 animate-fade-in">
|
||||
<div class="p-6">
|
||||
<!-- Error Header -->
|
||||
<div class="flex items-start mb-6">
|
||||
<div class="flex-shrink-0 w-14 h-14 bg-red-100 rounded-lg flex items-center justify-center mr-4">
|
||||
<i class="fas fa-exclamation-triangle text-red-600 text-2xl"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">
|
||||
<?= htmlspecialchars($error_type ?? 'Error') ?>
|
||||
</h2>
|
||||
<p class="text-lg text-gray-700 mb-4"><?= htmlspecialchars($error_message ?? 'An error occurred') ?></p>
|
||||
|
||||
<!-- Error Location - Most Critical -->
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-3 flex items-center">
|
||||
<i class="fas fa-map-marker-alt text-red-500 mr-2 text-xs"></i>
|
||||
Error Location
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<span class="text-xs font-medium text-gray-600">File:</span>
|
||||
<code class="block mt-1 bg-white px-3 py-2 rounded text-sm text-gray-800 border border-gray-200 font-mono break-all">
|
||||
<?= htmlspecialchars($error_file ?? 'Unknown') ?>
|
||||
</code>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-xs font-medium text-gray-600 mr-2">Line:</span>
|
||||
<span class="font-mono text-red-600 font-bold text-lg"><?= htmlspecialchars($error_line ?? '?') ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Info Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Error Reference ID -->
|
||||
<div class="bg-blue-50 rounded-lg border border-blue-200 p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="text-xs font-semibold text-gray-700 uppercase tracking-wide">Error ID</h4>
|
||||
<button onclick="copyToClipboard('<?= htmlspecialchars($error_id ?? 'N/A') ?>')"
|
||||
class="text-primary hover:text-primary-dark" title="Copy Error ID">
|
||||
<i class="fas fa-copy text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
<code class="text-sm font-mono font-bold text-primary"><?= htmlspecialchars($error_id ?? 'N/A') ?></code>
|
||||
<p class="text-xs text-gray-600 mt-2">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
Use for bug reports
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Request Info -->
|
||||
<div class="bg-gray-50 rounded-lg border border-gray-200 p-4">
|
||||
<h4 class="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-2">Request</h4>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm">
|
||||
<span class="font-mono font-bold text-gray-900"><?= htmlspecialchars($request_method ?? 'GET') ?></span>
|
||||
</p>
|
||||
<code class="text-xs text-gray-600 font-mono block truncate" title="<?= htmlspecialchars($request_uri ?? '/') ?>">
|
||||
<?= htmlspecialchars($request_uri ?? '/') ?>
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Context -->
|
||||
<div class="bg-gray-50 rounded-lg border border-gray-200 p-4">
|
||||
<h4 class="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-2">User</h4>
|
||||
<?php if ($user_info): ?>
|
||||
<p class="text-sm font-semibold text-gray-900"><?= htmlspecialchars($user_info['username']) ?></p>
|
||||
<p class="text-xs text-gray-600">
|
||||
<i class="fas fa-user mr-1"></i>
|
||||
<?= htmlspecialchars($user_info['role']) ?>
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<p class="text-sm text-gray-500">
|
||||
<i class="fas fa-user-slash mr-1"></i>
|
||||
Guest (Not logged in)
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<!-- System Info -->
|
||||
<div class="bg-gray-50 rounded-lg border border-gray-200 p-4">
|
||||
<h4 class="text-xs font-semibold text-gray-700 uppercase tracking-wide mb-2">System</h4>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs text-gray-600">
|
||||
<i class="fas fa-code mr-1"></i>
|
||||
PHP <?= htmlspecialchars($php_version ?? PHP_VERSION) ?>
|
||||
</p>
|
||||
<p class="text-xs text-gray-600">
|
||||
<i class="fas fa-memory mr-1"></i>
|
||||
<?= round(($memory_usage ?? memory_get_usage(true)) / 1024 / 1024, 2) ?>MB
|
||||
</p>
|
||||
<p class="text-xs text-gray-600">
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
<?= date('H:i:s', strtotime($occurred_at ?? 'now')) ?>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Two Column Layout -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
<!-- Left Column -->
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- Stack Trace -->
|
||||
<?php if (!empty($stack_trace)): ?>
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden animate-fade-in">
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-layer-group text-primary mr-2 text-sm"></i>
|
||||
Stack Trace
|
||||
</h3>
|
||||
<button onclick="copyStackTrace()"
|
||||
class="text-sm text-primary hover:text-primary-dark font-medium">
|
||||
<i class="fas fa-copy mr-1"></i>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="code-block rounded-lg p-4 overflow-x-auto max-h-96 overflow-y-auto border border-gray-700" id="stack-trace">
|
||||
<?php
|
||||
$traceLines = explode("\n", $stack_trace);
|
||||
foreach ($traceLines as $index => $line) {
|
||||
if (trim($line)) {
|
||||
echo '<div class="flex font-mono text-sm">';
|
||||
echo '<span class="line-number mr-4 text-right" style="min-width: 2rem">' . str_pad($index, 2, '0', STR_PAD_LEFT) . '</span>';
|
||||
echo '<span class="flex-1 text-green-400">' . htmlspecialchars($line) . '</span>';
|
||||
echo '</div>';
|
||||
}
|
||||
}
|
||||
?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Request Data -->
|
||||
<?php if (!empty($request_data)): ?>
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden animate-fade-in">
|
||||
<button onclick="toggleSection('request-data')"
|
||||
class="w-full px-6 py-4 border-b border-gray-200 bg-gray-50 text-left hover:bg-gray-100 transition-colors">
|
||||
<h3 class="text-lg font-semibold text-gray-900 flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-paper-plane text-blue-500 mr-2 text-sm"></i>
|
||||
Request Data
|
||||
<span class="ml-2 text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded font-medium">
|
||||
<?= count($request_data) ?>
|
||||
</span>
|
||||
</span>
|
||||
<i class="fas fa-chevron-down text-gray-400 text-xs transition-transform" id="request-data-chevron"></i>
|
||||
</h3>
|
||||
</button>
|
||||
<div id="request-data" class="hidden p-6">
|
||||
<div class="space-y-3">
|
||||
<?php foreach ($request_data as $key => $value): ?>
|
||||
<div class="bg-gray-50 rounded-lg p-3 border border-gray-200">
|
||||
<span class="text-xs font-semibold text-gray-700 uppercase tracking-wide block mb-1">
|
||||
<?= htmlspecialchars($key) ?>
|
||||
</span>
|
||||
<code class="text-sm text-gray-800 font-mono block break-all">
|
||||
<?= htmlspecialchars(is_array($value) ? json_encode($value, JSON_PRETTY_PRINT) : $value) ?>
|
||||
</code>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- Request Details -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden animate-fade-in">
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-globe text-green-500 mr-2 text-sm"></i>
|
||||
Request Details
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span class="font-medium text-gray-600">Method</span>
|
||||
<span class="font-mono font-bold text-gray-900"><?= htmlspecialchars($request_method ?? 'GET') ?></span>
|
||||
</div>
|
||||
<div class="py-2 border-b border-gray-100">
|
||||
<span class="font-medium text-gray-600 block mb-1">URI</span>
|
||||
<code class="text-xs text-gray-800 font-mono block break-all bg-gray-50 px-2 py-1 rounded">
|
||||
<?= htmlspecialchars($request_uri ?? '/') ?>
|
||||
</code>
|
||||
</div>
|
||||
<div class="py-2 border-b border-gray-100">
|
||||
<span class="font-medium text-gray-600 block mb-1">IP Address</span>
|
||||
<code class="text-xs text-gray-800 font-mono block bg-gray-50 px-2 py-1 rounded">
|
||||
<?= htmlspecialchars($ip_address ?? 'Unknown') ?>
|
||||
</code>
|
||||
</div>
|
||||
<div class="py-2">
|
||||
<span class="font-medium text-gray-600 block mb-1">User Agent</span>
|
||||
<code class="text-xs text-gray-600 font-mono block break-all bg-gray-50 px-2 py-1 rounded">
|
||||
<?= htmlspecialchars($user_agent ?? 'Unknown') ?>
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Information -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden animate-fade-in">
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<h3 class="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<i class="fas fa-server text-indigo-500 mr-2 text-sm"></i>
|
||||
System Information
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span class="font-medium text-gray-600">PHP Version</span>
|
||||
<span class="font-mono text-gray-900"><?= htmlspecialchars($php_version ?? PHP_VERSION) ?></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span class="font-medium text-gray-600">Memory Usage</span>
|
||||
<span class="font-mono text-gray-900"><?= round(($memory_usage ?? memory_get_usage(true)) / 1024 / 1024, 2) ?>MB</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-100">
|
||||
<span class="font-medium text-gray-600">Peak Memory</span>
|
||||
<span class="font-mono text-gray-900"><?= round(memory_get_peak_usage(true) / 1024 / 1024, 2) ?>MB</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-2">
|
||||
<span class="font-medium text-gray-600">Timestamp</span>
|
||||
<span class="font-mono text-gray-900 text-xs"><?= date('Y-m-d H:i:s T', strtotime($occurred_at ?? 'now')) ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Data -->
|
||||
<?php if (!empty($session_data)): ?>
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden animate-fade-in">
|
||||
<button onclick="toggleSection('session-data')"
|
||||
class="w-full px-6 py-4 border-b border-gray-200 bg-gray-50 text-left hover:bg-gray-100 transition-colors">
|
||||
<h3 class="text-lg font-semibold text-gray-900 flex items-center justify-between">
|
||||
<span class="flex items-center">
|
||||
<i class="fas fa-user-lock text-orange-500 mr-2 text-sm"></i>
|
||||
Session Data
|
||||
<span class="ml-2 text-xs bg-orange-100 text-orange-800 px-2 py-1 rounded font-medium">
|
||||
<?= count($session_data) ?>
|
||||
</span>
|
||||
</span>
|
||||
<i class="fas fa-chevron-down text-gray-400 text-xs transition-transform" id="session-data-chevron"></i>
|
||||
</h3>
|
||||
</button>
|
||||
<div id="session-data" class="hidden p-6">
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-600 uppercase">Key</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-semibold text-gray-600 uppercase">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<?php foreach ($session_data as $key => $value): ?>
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-3 py-2 font-mono text-gray-700 align-top"><?= htmlspecialchars($key) ?></td>
|
||||
<td class="px-3 py-2 font-mono text-gray-600 break-all">
|
||||
<?= htmlspecialchars(is_array($value) ? json_encode($value) : $value) ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Card -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-5 mt-6 animate-fade-in">
|
||||
<div class="flex items-start">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-lightbulb text-white"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-2">Debug Mode Active</h3>
|
||||
<p class="text-sm text-gray-700 mb-3">
|
||||
This detailed error page is only shown in development mode. In production, users will see a clean error page with just the error ID.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="/" class="inline-flex items-center px-3 py-2 bg-white border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-home mr-2"></i>
|
||||
Go to Dashboard
|
||||
</a>
|
||||
<button onclick="location.reload()" class="inline-flex items-center px-3 py-2 bg-white border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium">
|
||||
<i class="fas fa-redo mr-2"></i>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="max-w-7xl mx-auto mt-8">
|
||||
<div class="text-center text-sm text-gray-500">
|
||||
<p>
|
||||
<i class="fas fa-globe text-primary mr-1"></i>
|
||||
Domain Monitor © <?= date('Y') ?> • Development Mode
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script>
|
||||
function toggleSection(sectionId) {
|
||||
const section = document.getElementById(sectionId);
|
||||
const chevron = document.getElementById(sectionId + '-chevron');
|
||||
|
||||
if (section.classList.contains('hidden')) {
|
||||
section.classList.remove('hidden');
|
||||
if (chevron) {
|
||||
chevron.style.transform = 'rotate(180deg)';
|
||||
}
|
||||
} else {
|
||||
section.classList.add('hidden');
|
||||
if (chevron) {
|
||||
chevron.style.transform = 'rotate(0deg)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
showCopySuccess();
|
||||
}).catch(err => {
|
||||
fallbackCopy(text);
|
||||
});
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text) {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
showCopySuccess();
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
}
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
function copyStackTrace() {
|
||||
const stackTraceElement = document.getElementById('stack-trace');
|
||||
const lines = stackTraceElement.querySelectorAll('div');
|
||||
let stackText = '';
|
||||
|
||||
lines.forEach(line => {
|
||||
const textSpan = line.querySelector('span:last-child');
|
||||
if (textSpan) {
|
||||
stackText += textSpan.textContent + '\n';
|
||||
}
|
||||
});
|
||||
|
||||
copyToClipboard(stackText.trim());
|
||||
}
|
||||
|
||||
function copyErrorReport() {
|
||||
const errorType = <?= json_encode($error_type ?? 'Error') ?>;
|
||||
const errorMessage = <?= json_encode($error_message ?? 'Unknown error') ?>;
|
||||
const errorFile = <?= json_encode($error_file ?? 'Unknown') ?>;
|
||||
const errorLine = <?= json_encode($error_line ?? '?') ?>;
|
||||
const errorId = <?= json_encode($error_id ?? 'N/A') ?>;
|
||||
const phpVersion = <?= json_encode($php_version ?? PHP_VERSION) ?>;
|
||||
const requestMethod = <?= json_encode($request_method ?? 'GET') ?>;
|
||||
const requestUri = <?= json_encode($request_uri ?? '/') ?>;
|
||||
const userAgent = <?= json_encode($user_agent ?? 'Unknown') ?>;
|
||||
const ipAddress = <?= json_encode($ip_address ?? 'Unknown') ?>;
|
||||
const timestamp = <?= json_encode(date('Y-m-d H:i:s', strtotime($occurred_at ?? 'now'))) ?>;
|
||||
|
||||
const userInfo = <?= json_encode($user_info ?? null) ?>;
|
||||
const userText = userInfo ? `${userInfo.username} (${userInfo.role}, ID: ${userInfo.id})` : 'Guest (Not logged in)';
|
||||
|
||||
// Get stack trace
|
||||
const stackTraceElement = document.getElementById('stack-trace');
|
||||
let stackTrace = 'Not available';
|
||||
if (stackTraceElement) {
|
||||
const lines = stackTraceElement.querySelectorAll('div');
|
||||
let stackText = '';
|
||||
lines.forEach(line => {
|
||||
const textSpan = line.querySelector('span:last-child');
|
||||
if (textSpan) {
|
||||
stackText += textSpan.textContent + '\n';
|
||||
}
|
||||
});
|
||||
stackTrace = stackText.trim();
|
||||
}
|
||||
|
||||
const errorReport = `=== DOMAIN MONITOR ERROR REPORT ===
|
||||
|
||||
ERROR INFORMATION:
|
||||
- Error ID: ${errorId}
|
||||
- Type: ${errorType}
|
||||
- Message: ${errorMessage}
|
||||
|
||||
LOCATION:
|
||||
- File: ${errorFile}
|
||||
- Line: ${errorLine}
|
||||
|
||||
REQUEST DETAILS:
|
||||
- Method: ${requestMethod}
|
||||
- URI: ${requestUri}
|
||||
- Timestamp: ${timestamp}
|
||||
|
||||
USER CONTEXT:
|
||||
- User: ${userText}
|
||||
- IP Address: ${ipAddress}
|
||||
- User Agent: ${userAgent}
|
||||
|
||||
SYSTEM INFORMATION:
|
||||
- PHP Version: ${phpVersion}
|
||||
- Memory Usage: ${<?= round(($memory_usage ?? memory_get_usage(true)) / 1024 / 1024, 2) ?>}MB
|
||||
- Peak Memory: ${<?= round(memory_get_peak_usage(true) / 1024 / 1024, 2) ?>}MB
|
||||
|
||||
STACK TRACE:
|
||||
${stackTrace}
|
||||
|
||||
=== END OF ERROR REPORT ===
|
||||
|
||||
Reference ID: ${errorId}
|
||||
Please include this report when reporting bugs.`;
|
||||
|
||||
copyToClipboard(errorReport);
|
||||
}
|
||||
|
||||
function showCopySuccess() {
|
||||
const message = document.createElement('div');
|
||||
message.className = 'fixed top-4 right-4 bg-green-500 text-white px-4 py-3 rounded-lg shadow-lg z-50 flex items-center animate-fade-in';
|
||||
message.innerHTML = '<i class="fas fa-check mr-2"></i>Copied to clipboard!';
|
||||
document.body.appendChild(message);
|
||||
|
||||
setTimeout(() => {
|
||||
message.style.opacity = '0';
|
||||
message.style.transform = 'translateY(-20px)';
|
||||
message.style.transition = 'all 0.3s ease-out';
|
||||
setTimeout(() => message.remove(), 300);
|
||||
}, 2000);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -79,14 +79,16 @@ ob_start();
|
||||
<?php foreach ($group['channels'] as $channel):
|
||||
$config = json_decode($channel['channel_config'], true);
|
||||
$icons = ['email' => 'fa-envelope', 'telegram' => 'fa-telegram', 'discord' => 'fa-discord', 'slack' => 'fa-slack'];
|
||||
$colors = ['email' => 'blue', 'telegram' => 'blue', 'discord' => 'indigo', 'slack' => 'purple'];
|
||||
$iconClasses = ['email' => 'fas', 'telegram' => 'fab', 'discord' => 'fab', 'slack' => 'fab'];
|
||||
$colors = ['email' => 'blue', 'telegram' => 'blue', 'discord' => 'indigo', 'slack' => 'teal'];
|
||||
$icon = $icons[$channel['channel_type']] ?? 'fa-bell';
|
||||
$iconClass = $iconClasses[$channel['channel_type']] ?? 'fas';
|
||||
$color = $colors[$channel['channel_type']] ?? 'gray';
|
||||
?>
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow duration-200">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="w-12 h-12 bg-<?= $color ?>-100 rounded-lg flex items-center justify-center">
|
||||
<i class="fab <?= $icon ?> text-<?= $color ?>-600 text-xl"></i>
|
||||
<i class="<?= $iconClass ?> <?= $icon ?> text-<?= $color ?>-600 text-xl"></i>
|
||||
</div>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-semibold <?= $channel['is_active'] ? 'bg-green-100 text-green-800' : 'bg-gray-200 text-gray-600' ?>">
|
||||
<?= $channel['is_active'] ? 'Active' : 'Disabled' ?>
|
||||
|
||||
@@ -31,6 +31,25 @@ ob_start();
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Toolbar (Hidden by default, shown when groups are selected) -->
|
||||
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
|
||||
|
||||
<button type="button" onclick="bulkDelete()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-trash mr-2"></i>
|
||||
Delete Selected
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Groups List -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||
<?php if (!empty($groups)): ?>
|
||||
@@ -39,6 +58,9 @@ ob_start();
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left">
|
||||
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this)" class="rounded border-gray-300 text-primary focus:ring-primary">
|
||||
</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Group Name</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Description</th>
|
||||
<th class="px-6 py-4 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Channels</th>
|
||||
@@ -49,6 +71,9 @@ ob_start();
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<?php foreach ($groups as $group): ?>
|
||||
<tr class="hover:bg-gray-50 transition-colors duration-150">
|
||||
<td class="px-6 py-4">
|
||||
<input type="checkbox" class="group-checkbox rounded border-gray-300 text-primary focus:ring-primary" value="<?= $group['id'] ?>" onchange="updateBulkActions()">
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 h-10 w-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center">
|
||||
@@ -150,6 +175,85 @@ ob_start();
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleSelectAll(checkbox) {
|
||||
const checkboxes = document.querySelectorAll('.group-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = checkbox.checked;
|
||||
});
|
||||
updateBulkActions();
|
||||
}
|
||||
|
||||
function updateBulkActions() {
|
||||
const checkboxes = document.querySelectorAll('.group-checkbox:checked');
|
||||
const bulkActions = document.getElementById('bulk-actions');
|
||||
const selectedCount = document.getElementById('selected-count');
|
||||
const selectAllCheckbox = document.getElementById('select-all');
|
||||
|
||||
if (checkboxes.length > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
bulkActions.classList.add('flex');
|
||||
selectedCount.textContent = checkboxes.length + ' group(s) selected';
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
bulkActions.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
const allCheckboxes = document.querySelectorAll('.group-checkbox');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.checked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length;
|
||||
selectAllCheckbox.indeterminate = checkboxes.length > 0 && checkboxes.length < allCheckboxes.length;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
const checkboxes = document.querySelectorAll('.group-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = false;
|
||||
});
|
||||
document.getElementById('select-all').checked = false;
|
||||
updateBulkActions();
|
||||
}
|
||||
|
||||
function getSelectedGroupIds() {
|
||||
const checkboxes = document.querySelectorAll('.group-checkbox:checked');
|
||||
return Array.from(checkboxes).map(cb => cb.value);
|
||||
}
|
||||
|
||||
function bulkDelete() {
|
||||
const groupIds = getSelectedGroupIds();
|
||||
|
||||
if (groupIds.length === 0) {
|
||||
alert('Please select at least one group to delete');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete ${groupIds.length} group(s)? Domains will be unassigned from these groups.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/groups/bulk-delete';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '<?= csrf_token() ?>';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
const idsInput = document.createElement('input');
|
||||
idsInput.type = 'hidden';
|
||||
idsInput.name = 'group_ids';
|
||||
idsInput.value = JSON.stringify(groupIds);
|
||||
form.appendChild(idsInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
include __DIR__ . '/../layout/base.php';
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<div class="mt-4 pt-3 border-t border-gray-800">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider px-3 mb-1">Tools</p>
|
||||
<div class="space-y-0.5">
|
||||
<a href="/debug/whois" class="sidebar-link flex items-center px-3 py-2 rounded-md text-gray-400 hover:bg-gray-800 hover:text-white transition-colors duration-150">
|
||||
<a href="/debug/whois" class="sidebar-link flex items-center px-3 py-2 rounded-md text-gray-400 hover:bg-gray-800 hover:text-white transition-colors duration-150 <?= strpos($_SERVER['REQUEST_URI'], '/debug/whois') !== false ? 'bg-primary text-white' : '' ?>">
|
||||
<i class="fas fa-search text-xs mr-3 w-4"></i>
|
||||
<span class="text-sm">WHOIS Lookup</span>
|
||||
</a>
|
||||
@@ -63,6 +63,10 @@
|
||||
<i class="fas fa-users text-xs mr-3 w-4"></i>
|
||||
<span class="text-sm">Users</span>
|
||||
</a>
|
||||
<a href="/errors" class="sidebar-link flex items-center px-3 py-2 rounded-md text-gray-400 hover:bg-gray-800 hover:text-white transition-colors duration-150 <?= strpos($_SERVER['REQUEST_URI'], '/errors') !== false ? 'bg-primary text-white' : '' ?>">
|
||||
<i class="fas fa-bug text-xs mr-3 w-4"></i>
|
||||
<span class="text-sm">Error Logs</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -129,9 +129,7 @@
|
||||
</div>
|
||||
<div class="hidden lg:block text-left">
|
||||
<p class="text-sm font-medium text-gray-700"><?= htmlspecialchars($_SESSION['username'] ?? 'User') ?></p>
|
||||
<p class="text-xs text-gray-500">
|
||||
<?= ucfirst($_SESSION['role'] ?? 'user') ?>
|
||||
</p>
|
||||
<p class="text-xs text-gray-500"><?= htmlspecialchars($_SESSION['email'] ?? '') ?></p>
|
||||
</div>
|
||||
<i class="fas fa-chevron-down text-gray-400 text-xs hidden md:block"></i>
|
||||
</button>
|
||||
@@ -141,9 +139,16 @@
|
||||
<div class="px-4 py-3 border-b border-gray-200">
|
||||
<p class="text-sm font-medium text-gray-900"><?= htmlspecialchars($_SESSION['full_name'] ?? $_SESSION['username'] ?? 'User') ?></p>
|
||||
<p class="text-xs text-gray-500 mt-1"><?= htmlspecialchars($_SESSION['email'] ?? 'user@example.com') ?></p>
|
||||
<span class="inline-block mt-2 px-2 py-1 bg-green-100 text-green-800 text-xs font-semibold rounded">
|
||||
<i class="fas fa-circle text-xs mr-1"></i>Online
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<span class="inline-flex items-center px-2.5 py-1 bg-<?= $_SESSION['role'] === 'admin' ? 'amber' : 'blue' ?>-100 text-<?= $_SESSION['role'] === 'admin' ? 'amber' : 'blue' ?>-800 text-xs font-semibold rounded border border-<?= $_SESSION['role'] === 'admin' ? 'amber' : 'blue' ?>-200">
|
||||
<i class="fas fa-<?= $_SESSION['role'] === 'admin' ? 'crown' : 'user' ?> mr-1.5"></i>
|
||||
<?= ucfirst($_SESSION['role'] ?? 'user') ?>
|
||||
</span>
|
||||
<span class="inline-flex items-center px-2.5 py-1 bg-green-100 text-green-800 text-xs font-semibold rounded border border-green-200">
|
||||
<i class="fas fa-circle text-xs mr-1"></i>
|
||||
Online
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/profile#profile" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors duration-150">
|
||||
|
||||
@@ -21,7 +21,7 @@ ob_start();
|
||||
<p class="text-sm text-gray-500 mt-1">@<?= htmlspecialchars($user['username'] ?? '') ?></p>
|
||||
|
||||
<!-- Role Badge -->
|
||||
<span class="inline-flex items-center mt-3 px-2.5 py-1 bg-<?= $user['role'] === 'admin' ? 'indigo' : 'blue' ?>-100 text-<?= $user['role'] === 'admin' ? 'indigo' : 'blue' ?>-800 text-xs font-semibold rounded">
|
||||
<span class="inline-flex items-center mt-3 px-2.5 py-1 bg-<?= $user['role'] === 'admin' ? 'amber' : 'blue' ?>-100 text-<?= $user['role'] === 'admin' ? 'amber' : 'blue' ?>-800 text-xs font-semibold rounded border border-<?= $user['role'] === 'admin' ? 'amber' : 'blue' ?>-200">
|
||||
<i class="fas fa-<?= $user['role'] === 'admin' ? 'crown' : 'user' ?> mr-1.5"></i>
|
||||
<?= ucfirst($user['role'] ?? 'user') ?>
|
||||
</span>
|
||||
|
||||
@@ -74,8 +74,8 @@ ob_start();
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-clock text-purple-600 text-lg"></i>
|
||||
<div class="w-12 h-12 bg-indigo-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-clock text-indigo-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,46 +28,40 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
|
||||
?>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="mb-4">
|
||||
<div class="flex flex-wrap gap-2 justify-between items-center">
|
||||
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<form method="POST" action="/tld-registry/start-progressive-import" class="inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="import_type" value="complete_workflow">
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium" title="Complete TLD import workflow: TLD List → RDAP → WHOIS → Registry URLs">
|
||||
<i class="fas fa-rocket mr-2"></i>
|
||||
Import TLDs
|
||||
</button>
|
||||
</form>
|
||||
<div class="mb-4 flex justify-end gap-2">
|
||||
<a href="/tld-registry/import-logs" class="inline-flex items-center px-4 py-2 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-history mr-2"></i>
|
||||
Import Logs
|
||||
</a>
|
||||
<form method="POST" action="/tld-registry/start-progressive-import" class="inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="import_type" value="check_updates">
|
||||
<button type="submit" <?= $stats['total'] == 0 ? 'disabled' : '' ?> class="inline-flex items-center px-4 py-2.5 <?= $stats['total'] == 0 ? 'bg-gray-400 cursor-not-allowed' : 'bg-purple-600 hover:bg-purple-700' ?> text-white text-sm rounded-lg transition-colors font-medium" title="<?= $stats['total'] == 0 ? 'Import TLDs first' : 'Check for IANA updates' ?>">
|
||||
<button type="submit" <?= $stats['total'] == 0 ? 'disabled' : '' ?> class="inline-flex items-center px-4 py-2 <?= $stats['total'] == 0 ? 'bg-gray-400 cursor-not-allowed' : 'bg-indigo-600 hover:bg-indigo-700' ?> text-white text-sm rounded-lg transition-colors font-medium" title="<?= $stats['total'] == 0 ? 'Import TLDs first' : 'Check for IANA updates' ?>">
|
||||
<i class="fas fa-sync-alt mr-2"></i>
|
||||
Check Updates
|
||||
</button>
|
||||
</form>
|
||||
<a href="/tld-registry/import-logs" class="inline-flex items-center px-4 py-2.5 border border-gray-300 text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-history mr-2"></i>
|
||||
Import Logs
|
||||
</a>
|
||||
<form method="POST" action="/tld-registry/start-progressive-import" class="inline">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="import_type" value="complete_workflow">
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors font-medium" title="Complete TLD import workflow: TLD List → RDAP → WHOIS → Registry URLs">
|
||||
<i class="fas fa-rocket mr-2"></i>
|
||||
Import TLDs
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
<div class="mb-4 bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-info-circle text-yellow-600 mr-2"></i>
|
||||
<p class="text-sm text-yellow-800">
|
||||
View-only mode. Contact admin to import or modify TLD data.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<!-- Search and filters will stay visible for all users -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<!-- Total TLDs Card -->
|
||||
@@ -103,8 +97,8 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
|
||||
<p class="text-xs font-medium text-gray-500 uppercase tracking-wide">With RDAP</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 mt-1"><?= $stats['with_rdap'] ?? 0 ?></p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-database text-purple-600 text-lg"></i>
|
||||
<div class="w-12 h-12 bg-indigo-50 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-database text-indigo-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,22 +194,25 @@ $currentFilters = $filters ?? ['search' => '', 'sort' => 'tld', 'order' => 'asc'
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions (Admin Only) -->
|
||||
<?php if (!empty($tlds) && isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-4 mb-4">
|
||||
<!-- Bulk Actions Toolbar (Admin Only - Hidden by default, shown when TLDs are selected) -->
|
||||
<?php if (isset($_SESSION['role']) && $_SESSION['role'] === 'admin'): ?>
|
||||
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-sm text-gray-600">Bulk Actions:</span>
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
|
||||
|
||||
<form method="POST" action="/tld-registry/bulk-delete" id="bulk-delete-form" class="inline">
|
||||
<?= csrf_field() ?>
|
||||
<button type="button" onclick="confirmBulkDelete()" class="inline-flex items-center px-3 py-2 border border-red-300 text-red-700 text-sm rounded-lg hover:bg-red-50 transition-colors font-medium">
|
||||
<button type="button" onclick="confirmBulkDelete()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-trash mr-2"></i>
|
||||
Delete Selected
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
<span id="selected-count">0</span> selected
|
||||
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -546,11 +543,22 @@ function toggleAllCheckboxes(selectAllCheckbox) {
|
||||
function updateSelectedCount() {
|
||||
const checkboxes = document.querySelectorAll('.tld-checkbox:checked');
|
||||
const count = checkboxes.length;
|
||||
document.getElementById('selected-count').textContent = count;
|
||||
const bulkActions = document.getElementById('bulk-actions');
|
||||
const selectedCount = document.getElementById('selected-count');
|
||||
const selectAllCheckbox = document.getElementById('select-all');
|
||||
|
||||
if (count > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
bulkActions.classList.add('flex');
|
||||
selectedCount.textContent = `${count} TLD(s) selected`;
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
bulkActions.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
const selectAllCheckbox = document.getElementById('select-all');
|
||||
const allCheckboxes = document.querySelectorAll('.tld-checkbox');
|
||||
if (selectAllCheckbox) {
|
||||
if (count === 0) {
|
||||
selectAllCheckbox.indeterminate = false;
|
||||
selectAllCheckbox.checked = false;
|
||||
@@ -561,6 +569,19 @@ function updateSelectedCount() {
|
||||
selectAllCheckbox.indeterminate = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
const checkboxes = document.querySelectorAll('.tld-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = false;
|
||||
});
|
||||
const selectAllCheckbox = document.getElementById('select-all');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.checked = false;
|
||||
}
|
||||
updateSelectedCount();
|
||||
}
|
||||
|
||||
function confirmBulkDelete() {
|
||||
const checkboxes = document.querySelectorAll('.tld-checkbox:checked');
|
||||
|
||||
@@ -98,7 +98,7 @@ ob_start();
|
||||
<div class="space-y-1.5">
|
||||
<?php foreach ($rdapServers as $index => $server): ?>
|
||||
<div class="flex items-center p-2 bg-gray-50 rounded hover:bg-gray-100 transition-colors">
|
||||
<div class="w-6 h-6 bg-purple-500 rounded flex items-center justify-center text-white font-bold text-xs mr-2">
|
||||
<div class="w-6 h-6 bg-indigo-500 rounded flex items-center justify-center text-white font-bold text-xs mr-2">
|
||||
<?= $index + 1 ?>
|
||||
</div>
|
||||
<p class="font-mono text-xs text-gray-800"><?= htmlspecialchars($server) ?></p>
|
||||
@@ -168,8 +168,8 @@ ob_start();
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($tld['iana_publication_date']): ?>
|
||||
<div class="flex items-center p-2 bg-purple-50 rounded border border-purple-200">
|
||||
<div class="w-7 h-7 bg-purple-500 rounded flex items-center justify-center mr-2">
|
||||
<div class="flex items-center p-2 bg-indigo-50 rounded border border-indigo-200">
|
||||
<div class="w-7 h-7 bg-indigo-500 rounded flex items-center justify-center mr-2">
|
||||
<i class="fas fa-calendar text-white text-xs"></i>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -38,18 +38,12 @@ $pagination = $pagination ?? [
|
||||
?>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="mb-4 flex flex-wrap gap-2 justify-between items-center">
|
||||
<div class="flex gap-2">
|
||||
<!-- Placeholder for future bulk actions -->
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<div class="mb-4 flex justify-end">
|
||||
<a href="/users/create" class="inline-flex items-center px-4 py-2 bg-primary text-white text-sm rounded-lg hover:bg-primary-dark transition-colors font-medium">
|
||||
<i class="fas fa-user-plus mr-2"></i>
|
||||
Add User
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters & Search -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
|
||||
@@ -101,6 +95,35 @@ $pagination = $pagination ?? [
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions Toolbar (Hidden by default, shown when users are selected) -->
|
||||
<div id="bulk-actions" class="hidden mb-4 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<span id="selected-count" class="text-sm font-medium text-blue-900"></span>
|
||||
|
||||
<button type="button" onclick="bulkToggleStatus('active')" class="inline-flex items-center px-4 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors font-medium">
|
||||
<i class="fas fa-user-check mr-2"></i>
|
||||
Activate Selected
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="bulkToggleStatus('inactive')" class="inline-flex items-center px-4 py-2 bg-orange-600 text-white text-sm rounded-lg hover:bg-orange-700 transition-colors font-medium">
|
||||
<i class="fas fa-user-slash mr-2"></i>
|
||||
Deactivate Selected
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="bulkDeleteUsers()" class="inline-flex items-center px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors font-medium">
|
||||
<i class="fas fa-trash mr-2"></i>
|
||||
Delete Selected
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="clearSelection()" class="inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-gray-700 text-sm rounded-lg hover:bg-gray-50 transition-colors font-medium">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Info & Per Page Selector -->
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-600">
|
||||
@@ -134,6 +157,9 @@ $pagination = $pagination ?? [
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left">
|
||||
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this)" class="rounded border-gray-300 text-primary focus:ring-primary">
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
|
||||
<a href="<?= sortUrl('full_name', $currentFilters['sort'], $currentFilters['order']) ?>" class="hover:text-primary flex items-center">
|
||||
User <?= sortIcon('full_name', $currentFilters['sort'], $currentFilters['order']) ?>
|
||||
@@ -170,6 +196,15 @@ $pagination = $pagination ?? [
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<?php foreach ($users as $user): ?>
|
||||
<tr class="hover:bg-gray-50 transition-colors duration-150">
|
||||
<td class="px-6 py-4">
|
||||
<?php if ($user['id'] != $_SESSION['user_id']): ?>
|
||||
<input type="checkbox" class="user-checkbox rounded border-gray-300 text-primary focus:ring-primary" value="<?= $user['id'] ?>" onchange="updateBulkActions()">
|
||||
<?php else: ?>
|
||||
<span class="text-gray-300" title="Cannot select your own account">
|
||||
<i class="fas fa-lock text-xs"></i>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 h-10 w-10 bg-primary bg-opacity-10 rounded-lg flex items-center justify-center">
|
||||
@@ -188,7 +223,7 @@ $pagination = $pagination ?? [
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold border
|
||||
<?= $user['role'] === 'admin' ? 'bg-purple-100 text-purple-700 border-purple-200' : 'bg-blue-100 text-blue-700 border-blue-200' ?>">
|
||||
<?= $user['role'] === 'admin' ? 'bg-amber-100 text-amber-700 border-amber-200' : 'bg-blue-100 text-blue-700 border-blue-200' ?>">
|
||||
<i class="fas fa-<?= $user['role'] === 'admin' ? 'crown' : 'user' ?> mr-1"></i>
|
||||
<?= ucfirst($user['role']) ?>
|
||||
</span>
|
||||
@@ -348,6 +383,124 @@ $pagination = $pagination ?? [
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<script>
|
||||
function toggleSelectAll(checkbox) {
|
||||
const checkboxes = document.querySelectorAll('.user-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = checkbox.checked;
|
||||
});
|
||||
updateBulkActions();
|
||||
}
|
||||
|
||||
function updateBulkActions() {
|
||||
const checkboxes = document.querySelectorAll('.user-checkbox:checked');
|
||||
const bulkActions = document.getElementById('bulk-actions');
|
||||
const selectedCount = document.getElementById('selected-count');
|
||||
const selectAllCheckbox = document.getElementById('select-all');
|
||||
|
||||
if (checkboxes.length > 0) {
|
||||
bulkActions.classList.remove('hidden');
|
||||
bulkActions.classList.add('flex');
|
||||
selectedCount.textContent = checkboxes.length + ' user(s) selected';
|
||||
} else {
|
||||
bulkActions.classList.add('hidden');
|
||||
bulkActions.classList.remove('flex');
|
||||
}
|
||||
|
||||
// Update select all checkbox state
|
||||
const allCheckboxes = document.querySelectorAll('.user-checkbox');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.checked = allCheckboxes.length > 0 && checkboxes.length === allCheckboxes.length;
|
||||
selectAllCheckbox.indeterminate = checkboxes.length > 0 && checkboxes.length < allCheckboxes.length;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
const checkboxes = document.querySelectorAll('.user-checkbox');
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = false;
|
||||
});
|
||||
document.getElementById('select-all').checked = false;
|
||||
updateBulkActions();
|
||||
}
|
||||
|
||||
function getSelectedUserIds() {
|
||||
const checkboxes = document.querySelectorAll('.user-checkbox:checked');
|
||||
return Array.from(checkboxes).map(cb => cb.value);
|
||||
}
|
||||
|
||||
function bulkToggleStatus(action) {
|
||||
const userIds = getSelectedUserIds();
|
||||
|
||||
if (userIds.length === 0) {
|
||||
alert('Please select at least one user');
|
||||
return;
|
||||
}
|
||||
|
||||
const actionText = action === 'active' ? 'activate' : 'deactivate';
|
||||
if (!confirm(`Are you sure you want to ${actionText} ${userIds.length} user(s)?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/users/bulk-toggle-status';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '<?= csrf_token() ?>';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
const idsInput = document.createElement('input');
|
||||
idsInput.type = 'hidden';
|
||||
idsInput.name = 'user_ids';
|
||||
idsInput.value = JSON.stringify(userIds);
|
||||
form.appendChild(idsInput);
|
||||
|
||||
const actionInput = document.createElement('input');
|
||||
actionInput.type = 'hidden';
|
||||
actionInput.name = 'action';
|
||||
actionInput.value = action;
|
||||
form.appendChild(actionInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
function bulkDeleteUsers() {
|
||||
const userIds = getSelectedUserIds();
|
||||
|
||||
if (userIds.length === 0) {
|
||||
alert('Please select at least one user to delete');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete ${userIds.length} user(s)? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '/users/bulk-delete';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '<?= csrf_token() ?>';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
const idsInput = document.createElement('input');
|
||||
idsInput.type = 'hidden';
|
||||
idsInput.name = 'user_ids';
|
||||
idsInput.value = JSON.stringify(userIds);
|
||||
form.appendChild(idsInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
require __DIR__ . '/../layout/base.php';
|
||||
|
||||
@@ -2,30 +2,30 @@
|
||||
|
||||
namespace Core;
|
||||
|
||||
use App\Services\ErrorHandler;
|
||||
|
||||
class Application
|
||||
{
|
||||
public static Router $router;
|
||||
public static Database $db;
|
||||
private ErrorHandler $errorHandler;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
self::$router = new Router();
|
||||
self::$db = new Database();
|
||||
|
||||
// Initialize error handler
|
||||
$this->errorHandler = new ErrorHandler();
|
||||
}
|
||||
|
||||
public function run()
|
||||
{
|
||||
try {
|
||||
self::$router->resolve();
|
||||
} catch (\Exception $e) {
|
||||
http_response_code(500);
|
||||
if ($_ENV['APP_ENV'] === 'development') {
|
||||
echo '<h1>Error</h1>';
|
||||
echo '<pre>' . $e->getMessage() . '</pre>';
|
||||
echo '<pre>' . $e->getTraceAsString() . '</pre>';
|
||||
} else {
|
||||
echo '<h1>500 - Internal Server Error</h1>';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Use centralized error handler
|
||||
$this->errorHandler->handleException($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,5 +69,37 @@ class Auth
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Require admin role (redirect with error if not admin)
|
||||
*/
|
||||
public static function requireAdmin(): void
|
||||
{
|
||||
// First ensure user is authenticated
|
||||
self::require();
|
||||
|
||||
// Then check for admin role
|
||||
if (!isset($_SESSION['role']) || $_SESSION['role'] !== 'admin') {
|
||||
$_SESSION['error'] = 'Access denied. Admin privileges required.';
|
||||
header('Location: /');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current user is admin
|
||||
*/
|
||||
public static function isAdmin(): bool
|
||||
{
|
||||
return isset($_SESSION['role']) && $_SESSION['role'] === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's role
|
||||
*/
|
||||
public static function role(): ?string
|
||||
{
|
||||
return $_SESSION['role'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,11 +18,23 @@ class Database
|
||||
|
||||
private function connect()
|
||||
{
|
||||
$host = $_ENV['DB_HOST'];
|
||||
$port = $_ENV['DB_PORT'];
|
||||
$database = $_ENV['DB_DATABASE'];
|
||||
$username = $_ENV['DB_USERNAME'];
|
||||
$password = $_ENV['DB_PASSWORD'];
|
||||
$host = $_ENV['DB_HOST'] ?? null;
|
||||
$port = $_ENV['DB_PORT'] ?? '3306';
|
||||
$database = $_ENV['DB_DATABASE'] ?? null;
|
||||
$username = $_ENV['DB_USERNAME'] ?? null;
|
||||
$password = $_ENV['DB_PASSWORD'] ?? '';
|
||||
|
||||
// Validate required credentials
|
||||
if (empty($host) || empty($database) || empty($username)) {
|
||||
throw new \Exception(
|
||||
"Database credentials not configured!\n\n" .
|
||||
"Missing in .env file:\n" .
|
||||
(!empty($host) ? "" : "- DB_HOST\n") .
|
||||
(!empty($database) ? "" : "- DB_DATABASE\n") .
|
||||
(!empty($username) ? "" : "- DB_USERNAME\n") .
|
||||
"\nPlease update your .env file with valid database credentials."
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$dsn = "mysql:host=$host;port=$port;dbname=$database;charset=utf8mb4";
|
||||
@@ -32,7 +44,7 @@ class Database
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
die("Database connection failed: " . $e->getMessage());
|
||||
throw new \Exception("Database connection failed: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
51
database/migrations/015_create_error_logs_table.sql
Normal file
51
database/migrations/015_create_error_logs_table.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
-- Create error logs table for debugging and error tracking
|
||||
-- This table stores all application errors for analysis and troubleshooting
|
||||
|
||||
CREATE TABLE IF NOT EXISTS error_logs (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
error_id VARCHAR(32) UNIQUE NOT NULL COMMENT 'Unique reference ID for user reporting',
|
||||
error_type VARCHAR(100) NOT NULL COMMENT 'Exception class name',
|
||||
error_message TEXT NOT NULL COMMENT 'Error message',
|
||||
error_file VARCHAR(500) NOT NULL COMMENT 'File where error occurred',
|
||||
error_line INT NOT NULL COMMENT 'Line number where error occurred',
|
||||
stack_trace TEXT COMMENT 'Full stack trace',
|
||||
|
||||
-- Request context
|
||||
request_method VARCHAR(10) COMMENT 'HTTP method (GET, POST, etc)',
|
||||
request_uri VARCHAR(500) COMMENT 'Request URI',
|
||||
request_data TEXT COMMENT 'JSON encoded POST/GET data (sanitized)',
|
||||
|
||||
-- User context
|
||||
user_id INT NULL COMMENT 'User who encountered the error',
|
||||
user_agent TEXT COMMENT 'Browser user agent string',
|
||||
ip_address VARCHAR(45) COMMENT 'IP address (IPv4 or IPv6)',
|
||||
session_data TEXT COMMENT 'Session data (sanitized, no passwords)',
|
||||
|
||||
-- System context
|
||||
php_version VARCHAR(20) COMMENT 'PHP version at time of error',
|
||||
memory_usage BIGINT COMMENT 'Memory usage in bytes',
|
||||
|
||||
-- Tracking
|
||||
occurred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT 'First occurrence timestamp',
|
||||
occurrences INT DEFAULT 1 COMMENT 'Number of times this error occurred',
|
||||
last_occurred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Last occurrence timestamp',
|
||||
|
||||
-- Management
|
||||
is_resolved BOOLEAN DEFAULT FALSE COMMENT 'Admin marked as resolved',
|
||||
resolved_at TIMESTAMP NULL COMMENT 'When marked as resolved',
|
||||
resolved_by INT NULL COMMENT 'Admin user who resolved it',
|
||||
notes TEXT COMMENT 'Admin notes about resolution',
|
||||
|
||||
-- Foreign keys
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (resolved_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||
|
||||
-- Indexes for performance
|
||||
KEY idx_error_id (error_id),
|
||||
KEY idx_error_type (error_type),
|
||||
KEY idx_occurred_at (occurred_at),
|
||||
KEY idx_user_id (user_id),
|
||||
KEY idx_is_resolved (is_resolved),
|
||||
KEY idx_occurrences (occurrences)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
@@ -26,6 +26,7 @@ If upgrading from v1.0.0, these incremental migrations will be applied:
|
||||
- `012_link_remember_tokens_to_sessions.sql` - Remember token session linking
|
||||
- `013_create_user_notifications_table.sql` - User notifications table
|
||||
- `014_add_captcha_settings.sql` - CAPTCHA settings (v2, v3, Turnstile)
|
||||
- `015_create_error_logs_table.sql` - Error logging and debugging system
|
||||
|
||||
**Upgrade via:** Web updater at `/install/update`
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# Application
|
||||
# Application Environment
|
||||
# APP_ENV: 'development' (shows detailed debug pages) or 'production' (shows clean error pages)
|
||||
# IMPORTANT: Set to 'production' before deploying to hide sensitive debugging information!
|
||||
APP_ENV=development
|
||||
|
||||
# Security - Encryption key (auto-generated during migration if not set)
|
||||
|
||||
@@ -5,12 +5,32 @@ require_once __DIR__ . '/../vendor/autoload.php';
|
||||
use Core\Application;
|
||||
use Core\Router;
|
||||
use Dotenv\Dotenv;
|
||||
use App\Services\ErrorHandler;
|
||||
|
||||
define('PATH_ROOT', __DIR__ . '/../');
|
||||
|
||||
// Load environment variables
|
||||
// Register global error handlers FIRST (before anything else can fail)
|
||||
ErrorHandler::register();
|
||||
|
||||
// Load environment variables (using safeLoad to not throw if missing)
|
||||
$dotenv = Dotenv::createImmutable(__DIR__ . '/..');
|
||||
try {
|
||||
$dotenv->load();
|
||||
} catch (\Throwable $e) {
|
||||
// If .env is missing, create a minimal one or use defaults
|
||||
if (!file_exists(__DIR__ . '/../.env')) {
|
||||
// Show helpful error about missing .env file
|
||||
throw new \Exception(
|
||||
".env file not found! Please copy env.example.txt to .env and configure your settings.\n\n" .
|
||||
"Quick fix:\n" .
|
||||
"1. Copy env.example.txt to .env\n" .
|
||||
"2. Update database credentials in .env\n" .
|
||||
"3. Set APP_ENV=development or production\n\n" .
|
||||
"Original error: " . $e->getMessage()
|
||||
);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// Configure and start session (with database sessions if available)
|
||||
Core\SessionConfig::configure();
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Controllers\ProfileController;
|
||||
use App\Controllers\UserController;
|
||||
use App\Controllers\InstallerController;
|
||||
use App\Controllers\NotificationController;
|
||||
use App\Controllers\ErrorLogController;
|
||||
|
||||
$router = Application::$router;
|
||||
|
||||
@@ -76,6 +77,7 @@ $router->post('/groups/store', [NotificationGroupController::class, 'store']);
|
||||
$router->get('/groups/edit', [NotificationGroupController::class, 'edit']);
|
||||
$router->post('/groups/update', [NotificationGroupController::class, 'update']);
|
||||
$router->get('/groups/delete', [NotificationGroupController::class, 'delete']);
|
||||
$router->post('/groups/bulk-delete', [NotificationGroupController::class, 'bulkDelete']);
|
||||
|
||||
// Notification Channels
|
||||
$router->post('/channels/add', [NotificationGroupController::class, 'addChannel']);
|
||||
@@ -134,4 +136,15 @@ $router->get('/users/edit', [UserController::class, 'edit']);
|
||||
$router->post('/users/update', [UserController::class, 'update']);
|
||||
$router->get('/users/delete', [UserController::class, 'delete']);
|
||||
$router->get('/users/toggle-status', [UserController::class, 'toggleStatus']);
|
||||
$router->post('/users/bulk-toggle-status', [UserController::class, 'bulkToggleStatus']);
|
||||
$router->post('/users/bulk-delete', [UserController::class, 'bulkDelete']);
|
||||
|
||||
// Error Logs (Admin Only)
|
||||
$router->get('/errors', [ErrorLogController::class, 'index']);
|
||||
$router->get('/errors/{id}', [ErrorLogController::class, 'show']);
|
||||
$router->post('/errors/{id}/resolve', [ErrorLogController::class, 'markResolved']);
|
||||
$router->post('/errors/{id}/unresolve', [ErrorLogController::class, 'markUnresolved']);
|
||||
$router->post('/errors/{id}/delete', [ErrorLogController::class, 'delete']);
|
||||
$router->post('/errors/bulk-delete', [ErrorLogController::class, 'bulkDelete']);
|
||||
$router->post('/errors/clear-resolved', [ErrorLogController::class, 'clearResolved']);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user