Add comprehensive DNS management and input validation, plus safer transfer and logging behavior. - Add CronHelper utilities for cron scripts and unify logging/formatting. - Improve InputValidator: sanitizeDomainInput and validateRootDomain (handles multi-level TLDs) and use throughout domain import/create flows to reject subdomains. - DomainController: refactor DNS refresh to support quick/deep discovery (background deep scans), add endpoints to discover, add/delete/bulk-delete DNS records, import BIND zone files, enrich IP metadata via enrichIpDetails, and strengthen bulk import/reporting messages. - DnsRecord model: add source column handling (discovered/manual/imported), avoid auto-deleting manual/imported records, and add helpers for deleting, bulk deleting, manual adding and importing zone records. - Tag, NotificationGroup and Domain transfer logic: unlink groups when ownership changes, remove tags that belong to other users, add audit logging via Logger and improved bulk transfer reporting. TagController/View: show transferable users for admins and skip global tags on transfer. - Notification channels (Discord, Mattermost, etc.) and EmailHelper: allow explicit subjects and improve payload fields based on notification type. - Add new migration 029_add_dns_record_source.sql and wire it into the installer; update migrations detection. - Add new views/partials for confirm/import/transfer modals, update various domain/group/tag templates, and update cron scripts and routes for discovery. These changes preserve manual/imported DNS records, improve root-domain validation, enable background deep discovery, and add better logging/audit trails for transfers and imports.
484 lines
30 KiB
Twig
484 lines
30 KiB
Twig
{% extends 'layout/base.twig' %}
|
|
|
|
{% set title = 'Error Logs' %}
|
|
{% set pageTitle = 'Error Logs' %}
|
|
{% set pageDescription = 'Monitor and manage application errors' %}
|
|
{% set pageIcon = 'fas fa-bug' %}
|
|
|
|
{% set currentFilters = filters|default({resolved: '', type: '', sort: 'last_occurred_at', order: 'desc'}) %}
|
|
|
|
{% block content %}
|
|
<!-- Statistics Cards -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 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 dark:text-slate-400 uppercase tracking-wide">Total Errors</p>
|
|
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{{ errorStats.total_errors|default(0) }}</p>
|
|
</div>
|
|
<div class="w-12 h-12 bg-red-50 dark:bg-red-500/10 rounded-lg flex items-center justify-center">
|
|
<i class="fas fa-exclamation-triangle text-red-600 dark:text-red-400 text-lg"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 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 dark:text-slate-400 uppercase tracking-wide">Unresolved</p>
|
|
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{{ errorStats.unresolved|default(0) }}</p>
|
|
</div>
|
|
<div class="w-12 h-12 bg-orange-50 dark:bg-orange-500/10 rounded-lg flex items-center justify-center">
|
|
<i class="fas fa-exclamation-circle text-orange-600 dark:text-orange-400 text-lg"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 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 dark:text-slate-400 uppercase tracking-wide">Last 24h</p>
|
|
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{{ errorStats.last_24h|default(0) }}</p>
|
|
</div>
|
|
<div class="w-12 h-12 bg-blue-50 dark:bg-blue-500/10 rounded-lg flex items-center justify-center">
|
|
<i class="fas fa-clock text-blue-600 dark:text-blue-400 text-lg"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 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 dark:text-slate-400 uppercase tracking-wide">Occurrences</p>
|
|
<p class="text-2xl font-semibold text-gray-900 dark:text-white mt-1">{{ errorStats.total_occurrences|default(0) }}</p>
|
|
</div>
|
|
<div class="w-12 h-12 bg-indigo-50 dark:bg-indigo-500/10 rounded-lg flex items-center justify-center">
|
|
<i class="fas fa-layer-group text-indigo-600 dark:text-indigo-400 text-lg"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="bg-white dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 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 dark:text-slate-300 mb-1.5">Status</label>
|
|
<select name="resolved" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
|
|
<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 dark:text-slate-300 mb-1.5">Error Type</label>
|
|
<input type="text" name="type" value="{{ currentFilters.type }}" placeholder="e.g., PDOException" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-700 dark:text-slate-300 mb-1.5">Sort By</label>
|
|
<select name="sort" class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
|
|
<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 dark:border-slate-600 text-gray-700 dark:text-slate-300 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors text-sm font-medium">
|
|
<i class="fas fa-times mr-2"></i>
|
|
Clear
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<input type="hidden" name="order" value="{{ currentFilters.order }}">
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Pagination Info -->
|
|
<div class="mb-4 flex justify-between items-center">
|
|
<div class="text-sm text-gray-600 dark:text-slate-400">
|
|
Showing <span class="font-semibold text-gray-900 dark:text-white">{{ pagination.showing_from }}</span> to
|
|
<span class="font-semibold text-gray-900 dark:text-white">{{ pagination.showing_to }}</span> of
|
|
<span class="font-semibold text-gray-900 dark:text-white">{{ pagination.total }}</span> error(s)
|
|
</div>
|
|
|
|
<form method="GET" action="/errors" class="flex items-center gap-2">
|
|
<input type="hidden" name="resolved" value="{{ currentFilters.resolved }}">
|
|
<input type="hidden" name="type" value="{{ currentFilters.type }}">
|
|
<input type="hidden" name="sort" value="{{ currentFilters.sort }}">
|
|
<input type="hidden" name="order" value="{{ currentFilters.order }}">
|
|
|
|
<label for="per_page" class="text-sm text-gray-600 dark:text-slate-400">Show:</label>
|
|
<select name="per_page" id="per_page" onchange="this.form.submit()" class="px-3 py-1.5 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-900 text-gray-900 dark:text-white">
|
|
<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 dark:bg-slate-800 rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
|
<div id="bulk-actions" class="hidden px-6 py-3 bg-blue-50 dark:bg-blue-500/10 border-b border-blue-200 dark:border-blue-500/20 flex items-center justify-between">
|
|
<div class="flex items-center gap-4">
|
|
<span id="selected-count" class="text-sm font-medium text-gray-700 dark:text-slate-300"></span>
|
|
<div class="flex items-center gap-3 flex-wrap">
|
|
<div class="flex items-center gap-2">
|
|
<button type="button" onclick="bulkDelete()" class="inline-flex items-center px-4 py-1.5 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium">
|
|
<i class="fas fa-trash mr-1"></i> Delete Selected
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button type="button" onclick="clearSelection()" class="inline-flex items-center text-sm font-medium text-gray-600 dark:text-slate-400 hover:text-gray-900 dark:hover:text-white hover:bg-blue-100 dark:hover:bg-blue-500/20 px-3 py-1.5 rounded-lg transition-colors">
|
|
<i class="fas fa-times mr-1.5"></i> Clear Selection
|
|
</button>
|
|
</div>
|
|
{% if errors is defined and errors is not empty %}
|
|
<div class="overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-slate-700">
|
|
<thead class="bg-gray-50 dark:bg-slate-900">
|
|
<tr>
|
|
<th class="px-6 py-3 text-left">
|
|
<input type="checkbox" id="select-all" onchange="toggleSelectAll(this)" class="rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary">
|
|
</th>
|
|
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Error</th>
|
|
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Location</th>
|
|
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Occurrences</th>
|
|
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Last Occurred</th>
|
|
<th class="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Status</th>
|
|
<th class="px-6 py-3 text-right text-xs font-semibold text-gray-600 dark:text-slate-400 uppercase tracking-wider">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-slate-700">
|
|
{% for error in errors %}
|
|
{% set errorTypeShort = error.error_type|split('\\')|last %}
|
|
{% set isResolved = error.is_resolved %}
|
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors duration-150">
|
|
<td class="px-6 py-4">
|
|
<input type="checkbox" class="error-checkbox rounded border-gray-300 dark:border-slate-600 text-primary focus:ring-primary" value="{{ 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 dark:bg-red-500/10 rounded-lg flex items-center justify-center mr-3">
|
|
<i class="fas fa-bug text-red-600 dark:text-red-400"></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">{{ error.error_id }}</span>
|
|
<button onclick="copyToClipboard('{{ error.error_id }}')" class="text-gray-400 dark:text-slate-500 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 dark:text-white">{{ errorTypeShort }}</p>
|
|
<p class="text-xs text-gray-600 dark:text-slate-400 mt-0.5 truncate" style="max-width: 300px;" title="{{ error.error_message }}">
|
|
{{ error.error_message }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class="px-6 py-4">
|
|
<div class="text-xs">
|
|
<p class="font-mono text-gray-600 dark:text-slate-400 truncate" style="max-width: 200px;" title="{{ error.error_file }}">
|
|
{{ error.error_file|split('/')|last|split('\\')|last }}
|
|
</p>
|
|
<p class="text-gray-500 dark:text-slate-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 dark:bg-red-500/10 text-red-800 dark:text-red-400' : 'bg-gray-100 dark:bg-slate-700 text-gray-800 dark:text-slate-300' }}">
|
|
<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 dark:text-slate-400">
|
|
<div class="flex items-center">
|
|
<i class="far fa-clock mr-2"></i>
|
|
{{ error.last_occurred_at|date("M d, H:i") }}
|
|
</div>
|
|
</td>
|
|
<td class="px-6 py-4 whitespace-nowrap">
|
|
{% if isResolved %}
|
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-400 border border-green-200 dark:border-green-500/20">
|
|
<i class="fas fa-check-circle mr-1"></i>
|
|
Resolved
|
|
</span>
|
|
{% else %}
|
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-orange-100 dark:bg-orange-500/10 text-orange-800 dark:text-orange-400 border border-orange-200 dark:border-orange-500/20">
|
|
<i class="fas fa-exclamation-triangle mr-1"></i>
|
|
Unresolved
|
|
</span>
|
|
{% 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/{{ error.error_id }}" class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300" title="View Details">
|
|
<i class="fas fa-eye"></i>
|
|
</a>
|
|
{% if not isResolved %}
|
|
<button onclick="markResolved('{{ error.error_id }}')" class="text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300" title="Mark as Resolved">
|
|
<i class="fas fa-check"></i>
|
|
</button>
|
|
{% endif %}
|
|
<button onclick="deleteError('{{ error.error_id }}')" class="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300" title="Delete Error">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% else %}
|
|
<div class="text-center py-12 px-6">
|
|
<div class="mb-4">
|
|
<i class="fas fa-check-circle text-green-500 dark:text-green-400 text-6xl"></i>
|
|
</div>
|
|
<h3 class="text-lg font-semibold text-gray-700 dark:text-slate-300 mb-1">No Errors Found</h3>
|
|
<p class="text-sm text-gray-500 dark:text-slate-400 mb-4">
|
|
{% if currentFilters.resolved or currentFilters.type %}
|
|
No errors match your filter criteria.
|
|
{% else %}
|
|
Great! Your application is running smoothly.
|
|
{% endif %}
|
|
</p>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Pagination Controls -->
|
|
{% 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 dark:text-slate-400">
|
|
Page <span class="font-semibold text-gray-900 dark:text-white">{{ pagination.current_page }}</span> of
|
|
<span class="font-semibold text-gray-900 dark:text-white">{{ pagination.total_pages }}</span>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-1">
|
|
{% if pagination.current_page > 1 %}
|
|
<a href="{{ pagination_url(1, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 transition-colors">
|
|
<i class="fas fa-angle-double-left"></i>
|
|
</a>
|
|
<a href="{{ pagination_url(pagination.current_page - 1, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 transition-colors">
|
|
<i class="fas fa-angle-left"></i> Previous
|
|
</a>
|
|
{% endif %}
|
|
|
|
{% set range = 2 %}
|
|
{% set startPage = max(1, pagination.current_page - range) %}
|
|
{% set endPage = min(pagination.total_pages, pagination.current_page + range) %}
|
|
|
|
{% if startPage > 1 %}
|
|
<a href="{{ pagination_url(1, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 transition-colors">1</a>
|
|
{% if startPage > 2 %}
|
|
<span class="px-2 text-gray-500 dark:text-slate-400">...</span>
|
|
{% endif %}
|
|
{% endif %}
|
|
|
|
{% for i in startPage..endPage %}
|
|
{% if i == pagination.current_page %}
|
|
<span class="px-3 py-2 text-sm bg-primary text-white rounded-lg font-semibold">{{ i }}</span>
|
|
{% else %}
|
|
<a href="{{ pagination_url(i, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 transition-colors">{{ i }}</a>
|
|
{% endif %}
|
|
{% endfor %}
|
|
|
|
{% if endPage < pagination.total_pages %}
|
|
{% if endPage < pagination.total_pages - 1 %}
|
|
<span class="px-2 text-gray-500 dark:text-slate-400">...</span>
|
|
{% endif %}
|
|
<a href="{{ pagination_url(pagination.total_pages, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 transition-colors">{{ pagination.total_pages }}</a>
|
|
{% endif %}
|
|
|
|
{% if pagination.current_page < pagination.total_pages %}
|
|
<a href="{{ pagination_url(pagination.current_page + 1, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 transition-colors">
|
|
Next <i class="fas fa-angle-right"></i>
|
|
</a>
|
|
<a href="{{ pagination_url(pagination.total_pages, currentFilters, pagination.per_page) }}" class="px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg hover:bg-gray-50 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300 transition-colors">
|
|
<i class="fas fa-angle-double-right"></i>
|
|
</a>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<!-- Resolution Notes Modal -->
|
|
<div id="resolutionModal" class="hidden fixed inset-0 bg-gray-600/50 dark:bg-black/50 overflow-y-auto h-full w-full z-50">
|
|
<div class="relative top-20 mx-auto p-5 border border-gray-200 dark:border-slate-700 w-full max-w-md shadow-lg rounded-lg bg-white dark:bg-slate-800">
|
|
<div class="mt-3">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
<i class="fas fa-check-circle text-green-600 dark:text-green-400 mr-2"></i>
|
|
Mark Error as Resolved
|
|
</h3>
|
|
<button onclick="closeResolutionModal()" class="text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300">
|
|
<i class="fas fa-times text-xl"></i>
|
|
</button>
|
|
</div>
|
|
<div class="mb-4">
|
|
<label for="resolutionNotes" class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
|
Resolution Notes (Optional)
|
|
</label>
|
|
<textarea id="resolutionNotes" rows="4"
|
|
class="w-full px-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent resize-none bg-white dark:bg-slate-900 text-gray-900 dark:text-white"
|
|
placeholder="Describe how you resolved this error or any relevant notes..."></textarea>
|
|
<p class="mt-1 text-xs text-gray-500 dark:text-slate-400">Add any details about the fix or resolution for future reference.</p>
|
|
</div>
|
|
<div class="flex items-center justify-end gap-3">
|
|
<button onclick="closeResolutionModal()" class="px-4 py-2 bg-gray-200 dark:bg-slate-700 text-gray-800 dark:text-slate-200 rounded-lg hover:bg-gray-300 dark:hover:bg-slate-600 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>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<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() {
|
|
let container = document.getElementById('toast-container');
|
|
if (!container) {
|
|
container = document.createElement('div');
|
|
container.id = 'toast-container';
|
|
container.className = 'fixed bottom-4 right-4 z-[9999] space-y-3 max-w-sm';
|
|
document.body.appendChild(container);
|
|
}
|
|
const toast = document.createElement('div');
|
|
toast.className = 'toast bg-white dark:bg-slate-800 border-l-4 border-green-500 rounded-lg shadow-lg p-4 flex items-start animate-slide-in';
|
|
toast.innerHTML = '<div class="flex-shrink-0"><div class="w-8 h-8 bg-green-100 dark:bg-green-500/10 rounded-full flex items-center justify-center"><i class="fas fa-check text-green-600 dark:text-green-400 text-sm"></i></div></div><div class="ml-3 flex-1"><p class="text-sm font-medium text-gray-900 dark:text-white">Success</p><p class="text-sm text-gray-600 dark:text-slate-400 mt-0.5">Copied to clipboard!</p></div><button onclick="this.parentElement.remove()" class="ml-3 flex-shrink-0 text-gray-400 dark:text-slate-500 hover:text-gray-600 dark:hover:text-slate-300 transition-colors"><i class="fas fa-times text-sm"></i></button>';
|
|
container.appendChild(toast);
|
|
setTimeout(() => { toast.style.transition = 'opacity 0.3s ease-out, transform 0.3s ease-out'; toast.style.opacity = '0'; toast.style.transform = 'translateX(100%)'; setTimeout(() => toast.remove(), 300); }, 3000);
|
|
}
|
|
|
|
let currentErrorId = null;
|
|
|
|
function markResolved(errorId) {
|
|
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/' + currentErrorId + '/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();
|
|
}
|
|
|
|
async function deleteError(errorId) {
|
|
var ok = await confirmAction({ message: 'Are you sure you want to delete this error and all its occurrences? This action cannot be undone.' });
|
|
if (!ok) 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();
|
|
}
|
|
|
|
function toggleSelectAll(checkbox) {
|
|
document.querySelectorAll('.error-checkbox').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');
|
|
selectedCount.textContent = checkboxes.length + ' error(s) selected';
|
|
} else {
|
|
bulkActions.classList.add('hidden');
|
|
}
|
|
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() {
|
|
return Array.from(document.querySelectorAll('.error-checkbox:checked')).map(cb => cb.value);
|
|
}
|
|
|
|
function clearSelection() {
|
|
document.querySelectorAll('.error-checkbox').forEach(cb => { cb.checked = false; });
|
|
document.getElementById('select-all').checked = false;
|
|
updateBulkActions();
|
|
}
|
|
|
|
async function bulkDelete() {
|
|
const errorIds = getSelectedErrorIds();
|
|
if (errorIds.length === 0) { alert('Please select at least one error to delete'); return; }
|
|
var ok = await confirmAction({ message: 'Are you sure you want to delete ' + errorIds.length + ' error(s) and all their occurrences? This action cannot be undone.' });
|
|
if (!ok) 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>
|
|
{% endblock %}
|