Enhance error resolution workflow and notification service

Refactored error log model and views to use a unified 'notes' field instead of 'resolution_notes'. Added a modal dialog for entering resolution notes when marking errors as resolved in admin views. Improved stack trace handling in ErrorHandler by storing as JSON and formatting for display. Expanded NotificationService to support multi-channel notifications (email, Telegram, Discord, Slack), group notifications, and improved domain expiration alerts.
This commit is contained in:
Hosteroid
2025-10-11 20:27:46 +03:00
parent 1d80dd282c
commit dcb7f685dd
5 changed files with 342 additions and 29 deletions

View File

@@ -350,7 +350,7 @@ class ErrorLog extends Model
SET is_resolved = 1,
resolved_at = NOW(),
resolved_by = ?,
resolution_notes = ?
notes = ?
WHERE error_id = ?
");
@@ -367,7 +367,7 @@ class ErrorLog extends Model
SET is_resolved = 0,
resolved_at = NULL,
resolved_by = NULL,
resolution_notes = NULL
notes = NULL
WHERE error_id = ?
");

View File

@@ -114,7 +114,7 @@ class ErrorHandler
'error_message' => $exception->getMessage(),
'error_file' => $exception->getFile(),
'error_line' => $exception->getLine(),
'stack_trace' => $exception->getTraceAsString(),
'stack_trace' => json_encode($exception->getTrace()),
'request_method' => $_SERVER['REQUEST_METHOD'] ?? 'CLI',
'request_uri' => $_SERVER['REQUEST_URI'] ?? 'N/A',
'request_data' => json_encode($requestData),
@@ -254,7 +254,11 @@ class ErrorHandler
$error_message = $errorData['error_message'];
$error_file = $errorData['error_file'];
$error_line = $errorData['error_line'];
$stack_trace = $errorData['stack_trace'];
// Convert JSON stack trace back to string format for display
$traceArray = json_decode($errorData['stack_trace'], true) ?? [];
$stack_trace = $this->formatStackTraceAsString($traceArray);
$request_method = $errorData['request_method'];
$request_uri = $errorData['request_uri'];
$user_agent = $errorData['user_agent'];
@@ -293,6 +297,42 @@ class ErrorHandler
];
}
/**
* Format stack trace array as string (similar to getTraceAsString())
*/
private function formatStackTraceAsString(array $trace): string
{
if (empty($trace)) {
return 'No stack trace available';
}
$result = [];
foreach ($trace as $index => $frame) {
$line = "#{$index} ";
if (isset($frame['file'])) {
$line .= $frame['file'];
if (isset($frame['line'])) {
$line .= "({$frame['line']})";
}
$line .= ': ';
}
if (isset($frame['class'])) {
$line .= $frame['class'];
$line .= $frame['type'] ?? '->';
}
if (isset($frame['function'])) {
$line .= $frame['function'] . '()';
}
$result[] = $line;
}
return implode("\n", $result);
}
/**
* Static helper to register global handlers
*/

View File

@@ -2,23 +2,166 @@
namespace App\Services;
use App\Models\Notification;
use App\Services\Channels\EmailChannel;
use App\Services\Channels\TelegramChannel;
use App\Services\Channels\DiscordChannel;
use App\Services\Channels\SlackChannel;
class NotificationService
{
private Notification $notificationModel;
private array $channels = [];
public function __construct()
{
$this->notificationModel = new Notification();
$this->channels = [
'email' => new EmailChannel(),
'telegram' => new TelegramChannel(),
'discord' => new DiscordChannel(),
'slack' => new SlackChannel(),
];
}
/**
* Create a domain expiring notification
* Send notification to specified channel
*/
public function send(string $channelType, array $config, string $message, array $data = []): bool
{
if (!isset($this->channels[$channelType])) {
return false;
}
try {
return $this->channels[$channelType]->send($config, $message, $data);
} catch (\Exception $e) {
error_log("Notification send failed [$channelType]: " . $e->getMessage());
return false;
}
}
/**
* Send notification to all active channels in a group
*/
public function sendToGroup(int $groupId, string $subject, string $message, array $data = []): array
{
// Get active channels for the group
$channelModel = new \App\Models\NotificationChannel();
$channels = $channelModel->getByGroupId($groupId);
$results = [];
foreach ($channels as $channel) {
if (!$channel['is_active']) {
continue; // Skip inactive channels
}
$config = json_decode($channel['channel_config'], true);
// Add subject to data for channels that support it (like email)
$channelData = array_merge(['subject' => $subject], $data);
$success = $this->send(
$channel['channel_type'],
$config,
$message,
$channelData
);
$results[] = [
'channel' => $channel['channel_type'],
'success' => $success
];
}
return $results;
}
/**
* Send domain expiration notification
*/
public function sendDomainExpirationAlert(array $domain, array $notificationChannels): array
{
$daysLeft = $this->calculateDaysLeft($domain['expiration_date']);
$message = $this->formatExpirationMessage($domain, $daysLeft);
$results = [];
foreach ($notificationChannels as $channel) {
$config = json_decode($channel['channel_config'], true);
$success = $this->send(
$channel['channel_type'],
$config,
$message,
[
'domain' => $domain['domain_name'],
'domain_id' => $domain['id'],
'days_left' => $daysLeft,
'expiration_date' => $domain['expiration_date'],
'registrar' => $domain['registrar']
]
);
$results[] = [
'channel' => $channel['channel_type'],
'success' => $success
];
}
return $results;
}
/**
* Format expiration message
*/
private function formatExpirationMessage(array $domain, int $daysLeft): string
{
$domainName = $domain['domain_name'];
$expirationDate = date('F j, Y', strtotime($domain['expiration_date']));
$registrar = $domain['registrar'] ?? 'Unknown';
if ($daysLeft <= 0) {
return "🚨 URGENT: Domain '$domainName' has EXPIRED on $expirationDate!\n\n" .
"Registrar: $registrar\n" .
"Please renew immediately to avoid losing your domain.";
}
if ($daysLeft == 1) {
return "⚠️ CRITICAL: Domain '$domainName' expires TOMORROW ($expirationDate)!\n\n" .
"Registrar: $registrar\n" .
"Please renew as soon as possible.";
}
if ($daysLeft <= 7) {
return "⚠️ WARNING: Domain '$domainName' expires in $daysLeft days ($expirationDate)!\n\n" .
"Registrar: $registrar\n" .
"Please renew soon.";
}
return " REMINDER: Domain '$domainName' expires in $daysLeft days ($expirationDate).\n\n" .
"Registrar: $registrar\n" .
"Please plan for renewal.";
}
/**
* Calculate days left until expiration
*/
private function calculateDaysLeft(string $expirationDate): int
{
$expiration = strtotime($expirationDate);
$now = time();
return (int)floor(($expiration - $now) / 86400);
}
// ========================================
// IN-APP NOTIFICATION METHODS (Bell Icon)
// ========================================
/**
* Create a domain expiring notification (in-app)
*/
public function notifyDomainExpiring(int $userId, string $domainName, int $daysLeft, int $domainId): void
{
$this->notificationModel->createNotification(
$notificationModel = new \App\Models\Notification();
$notificationModel->createNotification(
$userId,
'domain_expiring',
'Domain Expiring Soon',
@@ -28,11 +171,12 @@ class NotificationService
}
/**
* Create a domain expired notification
* Create a domain expired notification (in-app)
*/
public function notifyDomainExpired(int $userId, string $domainName, int $domainId): void
{
$this->notificationModel->createNotification(
$notificationModel = new \App\Models\Notification();
$notificationModel->createNotification(
$userId,
'domain_expired',
'Domain Expired',
@@ -42,15 +186,16 @@ class NotificationService
}
/**
* Create a domain WHOIS updated notification
* Create a domain WHOIS updated notification (in-app)
*/
public function notifyDomainUpdated(int $userId, string $domainName, int $domainId, string $changes = ''): void
{
$notificationModel = new \App\Models\Notification();
$message = !empty($changes) ?
"{$domainName} - {$changes}" :
"{$domainName} WHOIS data updated";
$this->notificationModel->createNotification(
$notificationModel->createNotification(
$userId,
'domain_updated',
'Domain WHOIS Updated',
@@ -60,15 +205,16 @@ class NotificationService
}
/**
* Create a WHOIS lookup failed notification
* Create a WHOIS lookup failed notification (in-app)
*/
public function notifyWhoisFailed(int $userId, string $domainName, int $domainId, string $reason = ''): void
{
$notificationModel = new \App\Models\Notification();
$message = !empty($reason) ?
"Could not refresh {$domainName} - {$reason}" :
"Could not refresh {$domainName}";
$this->notificationModel->createNotification(
$notificationModel->createNotification(
$userId,
'whois_failed',
'WHOIS Lookup Failed',
@@ -78,11 +224,12 @@ class NotificationService
}
/**
* Create a new login notification
* Create a new login notification (in-app)
*/
public function notifyNewLogin(int $userId, string $location, string $ipAddress): void
{
$this->notificationModel->createNotification(
$notificationModel = new \App\Models\Notification();
$notificationModel->createNotification(
$userId,
'session_new',
'New Login Detected',
@@ -92,11 +239,12 @@ class NotificationService
}
/**
* Create welcome notification for new users/fresh install
* Create welcome notification for new users/fresh install (in-app)
*/
public function notifyWelcome(int $userId, string $username): void
{
$this->notificationModel->createNotification(
$notificationModel = new \App\Models\Notification();
$notificationModel->createNotification(
$userId,
'system_welcome',
'Welcome to Domain Monitor! 🎉',
@@ -106,11 +254,12 @@ class NotificationService
}
/**
* Create system upgrade notification for admins
* Create system upgrade notification for admins (in-app)
*/
public function notifySystemUpgrade(int $userId, string $fromVersion, string $toVersion, int $migrationsCount): void
{
$this->notificationModel->createNotification(
$notificationModel = new \App\Models\Notification();
$notificationModel->createNotification(
$userId,
'system_upgrade',
'System Upgraded Successfully',
@@ -120,7 +269,7 @@ class NotificationService
}
/**
* Notify all admins about system upgrade
* Notify all admins about system upgrade (in-app)
*/
public function notifyAdminsUpgrade(string $fromVersion, string $toVersion, int $migrationsCount): void
{

View File

@@ -107,8 +107,8 @@ $errorTypeShort = substr(strrchr($error['error_type'], '\\'), 1) ?: $error['erro
<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 if (!empty($error['notes'])): ?>
<p><strong>Notes:</strong> <?= htmlspecialchars($error['notes']) ?></p>
<?php endif; ?>
</div>
</div>
@@ -254,6 +254,57 @@ $errorTypeShort = substr(strrchr($error['error_type'], '\\'), 1) ?: $error['erro
</div>
</div>
<!-- Resolution Notes Modal -->
<div id="resolutionModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-full max-w-md shadow-lg rounded-lg bg-white">
<div class="mt-3">
<!-- Modal Header -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">
<i class="fas fa-check-circle text-green-600 mr-2"></i>
Mark Error as Resolved
</h3>
<button onclick="closeResolutionModal()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- Modal Body -->
<div class="mb-4">
<label for="resolutionNotes" class="block text-sm font-medium text-gray-700 mb-2">
Resolution Notes (Optional)
</label>
<textarea
id="resolutionNotes"
rows="4"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent resize-none"
placeholder="Describe how you resolved this error or any relevant notes..."
></textarea>
<p class="mt-1 text-xs text-gray-500">
Add any details about the fix or resolution for future reference.
</p>
</div>
<!-- Modal Footer -->
<div class="flex items-center justify-end gap-3">
<button
onclick="closeResolutionModal()"
class="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors text-sm font-medium"
>
Cancel
</button>
<button
onclick="submitResolution()"
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>
</div>
</div>
</div>
</div>
<script>
function switchTab(tabName) {
// Hide all tab contents
@@ -308,8 +359,16 @@ function showCopySuccess() {
}
function markResolved() {
const notes = prompt('Add resolution notes (optional):');
if (notes === null) return; // User cancelled
document.getElementById('resolutionModal').classList.remove('hidden');
}
function closeResolutionModal() {
document.getElementById('resolutionModal').classList.add('hidden');
document.getElementById('resolutionNotes').value = '';
}
function submitResolution() {
const notes = document.getElementById('resolutionNotes').value;
const form = document.createElement('form');
form.method = 'POST';

View File

@@ -361,6 +361,57 @@ $currentFilters = $filters ?? ['resolved' => '', 'type' => '', 'sort' => 'last_o
</div>
<?php endif; ?>
<!-- Resolution Notes Modal -->
<div id="resolutionModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-full max-w-md shadow-lg rounded-lg bg-white">
<div class="mt-3">
<!-- Modal Header -->
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900">
<i class="fas fa-check-circle text-green-600 mr-2"></i>
Mark Error as Resolved
</h3>
<button onclick="closeResolutionModal()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<!-- Modal Body -->
<div class="mb-4">
<label for="resolutionNotes" class="block text-sm font-medium text-gray-700 mb-2">
Resolution Notes (Optional)
</label>
<textarea
id="resolutionNotes"
rows="4"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent resize-none"
placeholder="Describe how you resolved this error or any relevant notes..."
></textarea>
<p class="mt-1 text-xs text-gray-500">
Add any details about the fix or resolution for future reference.
</p>
</div>
<!-- Modal Footer -->
<div class="flex items-center justify-end gap-3">
<button
onclick="closeResolutionModal()"
class="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors text-sm font-medium"
>
Cancel
</button>
<button
onclick="submitResolution()"
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>
</div>
</div>
</div>
</div>
<script>
function copyToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
@@ -393,13 +444,27 @@ function showCopySuccess() {
}, 2000);
}
let currentErrorId = null;
function markResolved(errorId) {
const notes = prompt('Add resolution notes (optional):');
if (notes === null) return; // User cancelled
currentErrorId = errorId;
document.getElementById('resolutionModal').classList.remove('hidden');
}
function closeResolutionModal() {
document.getElementById('resolutionModal').classList.add('hidden');
document.getElementById('resolutionNotes').value = '';
currentErrorId = null;
}
function submitResolution() {
if (!currentErrorId) return;
const notes = document.getElementById('resolutionNotes').value;
const form = document.createElement('form');
form.method = 'POST';
form.action = '/errors/' + errorId + '/resolve';
form.action = '/errors/' + currentErrorId + '/resolve';
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';