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:
Hosteroid
2025-10-10 14:01:19 +03:00
parent a29becc944
commit b50377492c
38 changed files with 3726 additions and 428 deletions

View File

@@ -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
View 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();
}
}

View File

@@ -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]);
}
}