|
@@ -298,6 +298,15 @@
|
|
|
>
|
|
>
|
|
|
<span class="material-icons" id="theme-toggle-icon">dark_mode</span>
|
|
<span class="material-icons" id="theme-toggle-icon">dark_mode</span>
|
|
|
</button>
|
|
</button>
|
|
|
|
|
+ <button
|
|
|
|
|
+ id="view-logs-button"
|
|
|
|
|
+ class="p-1.5 flex rounded-lg hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
|
|
|
+ aria-label="View logs"
|
|
|
|
|
+ title="View Application Logs"
|
|
|
|
|
+ onclick="openLogsModal()"
|
|
|
|
|
+ >
|
|
|
|
|
+ <span class="material-icons">article</span>
|
|
|
|
|
+ </button>
|
|
|
<button
|
|
<button
|
|
|
id="restart-button"
|
|
id="restart-button"
|
|
|
class="p-1.5 flex rounded-lg hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-amber-500"
|
|
class="p-1.5 flex rounded-lg hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-amber-500"
|
|
@@ -890,5 +899,246 @@
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- Logs Modal -->
|
|
|
|
|
+ <div id="logsModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden p-4">
|
|
|
|
|
+ <div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-5xl max-h-[90vh] flex flex-col">
|
|
|
|
|
+ <!-- Modal Header -->
|
|
|
|
|
+ <div class="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-gray-600">
|
|
|
|
|
+ <div class="flex items-center gap-3">
|
|
|
|
|
+ <span class="material-icons text-2xl text-slate-600 dark:text-gray-300">article</span>
|
|
|
|
|
+ <h3 class="text-lg font-semibold text-slate-800 dark:text-gray-100">Application Logs</h3>
|
|
|
|
|
+ <span id="logsConnectionStatus" class="text-xs px-2 py-1 rounded-full bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300">Connecting...</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="flex items-center gap-2">
|
|
|
|
|
+ <select id="logLevelFilter" onchange="filterLogs()" class="form-select text-xs rounded-lg border-slate-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 py-2 pl-3 pr-10">
|
|
|
|
|
+ <option value="">All Levels</option>
|
|
|
|
|
+ <option value="DEBUG">Debug</option>
|
|
|
|
|
+ <option value="INFO">Info</option>
|
|
|
|
|
+ <option value="WARNING">Warning</option>
|
|
|
|
|
+ <option value="ERROR">Error</option>
|
|
|
|
|
+ <option value="CRITICAL">Critical</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onclick="clearLogs()"
|
|
|
|
|
+ class="flex items-center justify-center gap-1 rounded-lg h-8 px-3 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 text-xs font-medium transition-colors"
|
|
|
|
|
+ >
|
|
|
|
|
+ <span class="material-icons text-sm">delete_sweep</span>
|
|
|
|
|
+ Clear
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <label class="flex items-center gap-1.5 text-sm text-slate-600 dark:text-gray-300">
|
|
|
|
|
+ <input type="checkbox" id="logsAutoScroll" checked class="rounded border-slate-300 dark:border-gray-600">
|
|
|
|
|
+ Auto-scroll
|
|
|
|
|
+ </label>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onclick="closeLogsModal()"
|
|
|
|
|
+ class="flex items-center justify-center rounded-lg size-8 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-500 dark:text-gray-400 transition-colors"
|
|
|
|
|
+ >
|
|
|
|
|
+ <span class="material-icons">close</span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <!-- Modal Body -->
|
|
|
|
|
+ <div id="logsContainer" class="flex-1 overflow-y-auto p-4 font-mono text-xs bg-slate-50 dark:bg-gray-900">
|
|
|
|
|
+ <div id="logsContent" class="space-y-1">
|
|
|
|
|
+ <!-- Log entries will be inserted here -->
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <!-- Modal Footer -->
|
|
|
|
|
+ <div class="px-6 py-3 border-t border-slate-200 dark:border-gray-600 flex items-center justify-between text-xs text-slate-500 dark:text-gray-400">
|
|
|
|
|
+ <span id="logsCount">0 entries</span>
|
|
|
|
|
+ <span id="logsInfo">Showing last 500 log entries</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <script>
|
|
|
|
|
+ // ============================================================================
|
|
|
|
|
+ // Application Logs Modal
|
|
|
|
|
+ // ============================================================================
|
|
|
|
|
+ let logsWebSocket = null;
|
|
|
|
|
+ let logsEntries = [];
|
|
|
|
|
+ const MAX_LOG_ENTRIES = 500;
|
|
|
|
|
+
|
|
|
|
|
+ const LOG_LEVEL_COLORS = {
|
|
|
|
|
+ DEBUG: 'text-gray-500 dark:text-gray-400',
|
|
|
|
|
+ INFO: 'text-blue-600 dark:text-blue-400',
|
|
|
|
|
+ WARNING: 'text-amber-600 dark:text-amber-400',
|
|
|
|
|
+ ERROR: 'text-red-600 dark:text-red-400',
|
|
|
|
|
+ CRITICAL: 'text-red-700 dark:text-red-300 font-bold'
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const LOG_LEVEL_BG = {
|
|
|
|
|
+ DEBUG: 'bg-gray-100 dark:bg-gray-700',
|
|
|
|
|
+ INFO: 'bg-blue-50 dark:bg-blue-900/30',
|
|
|
|
|
+ WARNING: 'bg-amber-50 dark:bg-amber-900/30',
|
|
|
|
|
+ ERROR: 'bg-red-50 dark:bg-red-900/30',
|
|
|
|
|
+ CRITICAL: 'bg-red-100 dark:bg-red-900/50'
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ function openLogsModal() {
|
|
|
|
|
+ const modal = document.getElementById('logsModal');
|
|
|
|
|
+ modal.classList.remove('hidden');
|
|
|
|
|
+ loadInitialLogs();
|
|
|
|
|
+ connectLogsWebSocket();
|
|
|
|
|
+ modal.addEventListener('click', (e) => {
|
|
|
|
|
+ if (e.target === modal) closeLogsModal();
|
|
|
|
|
+ });
|
|
|
|
|
+ document.addEventListener('keydown', handleLogsEscapeKey);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function closeLogsModal() {
|
|
|
|
|
+ const modal = document.getElementById('logsModal');
|
|
|
|
|
+ modal.classList.add('hidden');
|
|
|
|
|
+ if (logsWebSocket) {
|
|
|
|
|
+ logsWebSocket.close();
|
|
|
|
|
+ logsWebSocket = null;
|
|
|
|
|
+ }
|
|
|
|
|
+ document.removeEventListener('keydown', handleLogsEscapeKey);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function handleLogsEscapeKey(e) {
|
|
|
|
|
+ if (e.key === 'Escape') closeLogsModal();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function loadInitialLogs() {
|
|
|
|
|
+ const logsContent = document.getElementById('logsContent');
|
|
|
|
|
+ logsContent.innerHTML = '<div class="text-gray-500 dark:text-gray-400">Loading logs...</div>';
|
|
|
|
|
+ try {
|
|
|
|
|
+ const response = await fetch('/api/logs?limit=500');
|
|
|
|
|
+ const data = await response.json();
|
|
|
|
|
+ logsEntries = data.logs || [];
|
|
|
|
|
+ renderLogs();
|
|
|
|
|
+ updateLogsCount();
|
|
|
|
|
+ const container = document.getElementById('logsContainer');
|
|
|
|
|
+ container.scrollTop = container.scrollHeight;
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('Failed to load logs:', error);
|
|
|
|
|
+ logsContent.innerHTML = '<div class="text-red-500">Failed to load logs</div>';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function connectLogsWebSocket() {
|
|
|
|
|
+ const statusEl = document.getElementById('logsConnectionStatus');
|
|
|
|
|
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
|
|
|
+ const wsUrl = `${protocol}//${window.location.host}/ws/logs`;
|
|
|
|
|
+ try {
|
|
|
|
|
+ logsWebSocket = new WebSocket(wsUrl);
|
|
|
|
|
+ logsWebSocket.onopen = () => {
|
|
|
|
|
+ statusEl.textContent = 'Live';
|
|
|
|
|
+ statusEl.className = 'text-xs px-2 py-1 rounded-full bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300';
|
|
|
|
|
+ };
|
|
|
|
|
+ logsWebSocket.onmessage = (event) => {
|
|
|
|
|
+ const message = JSON.parse(event.data);
|
|
|
|
|
+ if (message.type === 'log_entry') addLogEntry(message.data);
|
|
|
|
|
+ };
|
|
|
|
|
+ logsWebSocket.onclose = () => {
|
|
|
|
|
+ statusEl.textContent = 'Disconnected';
|
|
|
|
|
+ statusEl.className = 'text-xs px-2 py-1 rounded-full bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-300';
|
|
|
|
|
+ };
|
|
|
|
|
+ logsWebSocket.onerror = (error) => {
|
|
|
|
|
+ console.error('Logs WebSocket error:', error);
|
|
|
|
|
+ statusEl.textContent = 'Error';
|
|
|
|
|
+ statusEl.className = 'text-xs px-2 py-1 rounded-full bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300';
|
|
|
|
|
+ };
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('Failed to connect to logs WebSocket:', error);
|
|
|
|
|
+ statusEl.textContent = 'Failed';
|
|
|
|
|
+ statusEl.className = 'text-xs px-2 py-1 rounded-full bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-300';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function addLogEntry(entry) {
|
|
|
|
|
+ logsEntries.unshift(entry);
|
|
|
|
|
+ if (logsEntries.length > MAX_LOG_ENTRIES) logsEntries.pop();
|
|
|
|
|
+ const logsContent = document.getElementById('logsContent');
|
|
|
|
|
+ const entryEl = createLogEntryElement(entry);
|
|
|
|
|
+ logsContent.appendChild(entryEl);
|
|
|
|
|
+ const autoScroll = document.getElementById('logsAutoScroll').checked;
|
|
|
|
|
+ if (autoScroll) {
|
|
|
|
|
+ const container = document.getElementById('logsContainer');
|
|
|
|
|
+ container.scrollTop = container.scrollHeight;
|
|
|
|
|
+ }
|
|
|
|
|
+ const levelFilter = document.getElementById('logLevelFilter').value;
|
|
|
|
|
+ if (levelFilter && entry.level !== levelFilter) entryEl.classList.add('hidden');
|
|
|
|
|
+ updateLogsCount();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function createLogEntryElement(entry) {
|
|
|
|
|
+ const div = document.createElement('div');
|
|
|
|
|
+ div.className = `log-entry flex gap-2 py-1 px-2 rounded ${LOG_LEVEL_BG[entry.level] || 'bg-gray-50 dark:bg-gray-800'}`;
|
|
|
|
|
+ div.dataset.level = entry.level;
|
|
|
|
|
+ const timestamp = new Date(entry.timestamp);
|
|
|
|
|
+ const timeStr = timestamp.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
|
|
|
+ const msStr = timestamp.getMilliseconds().toString().padStart(3, '0');
|
|
|
|
|
+ div.innerHTML = `
|
|
|
|
|
+ <span class="text-gray-400 dark:text-gray-500 shrink-0">${timeStr}.${msStr}</span>
|
|
|
|
|
+ <span class="${LOG_LEVEL_COLORS[entry.level] || 'text-gray-600'} shrink-0 w-16">${entry.level}</span>
|
|
|
|
|
+ <span class="text-purple-600 dark:text-purple-400 shrink-0 truncate max-w-32" title="${entry.logger}:${entry.line}">${entry.module}:${entry.line}</span>
|
|
|
|
|
+ <span class="text-slate-700 dark:text-gray-200 break-all">${escapeHtmlLogs(entry.message)}</span>
|
|
|
|
|
+ `;
|
|
|
|
|
+ return div;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function escapeHtmlLogs(text) {
|
|
|
|
|
+ const div = document.createElement('div');
|
|
|
|
|
+ div.textContent = text;
|
|
|
|
|
+ return div.innerHTML;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function renderLogs() {
|
|
|
|
|
+ const logsContent = document.getElementById('logsContent');
|
|
|
|
|
+ logsContent.innerHTML = '';
|
|
|
|
|
+ const reversedLogs = [...logsEntries].reverse();
|
|
|
|
|
+ const levelFilter = document.getElementById('logLevelFilter').value;
|
|
|
|
|
+ for (const entry of reversedLogs) {
|
|
|
|
|
+ const entryEl = createLogEntryElement(entry);
|
|
|
|
|
+ if (levelFilter && entry.level !== levelFilter) entryEl.classList.add('hidden');
|
|
|
|
|
+ logsContent.appendChild(entryEl);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function filterLogs() {
|
|
|
|
|
+ const levelFilter = document.getElementById('logLevelFilter').value;
|
|
|
|
|
+ const entries = document.querySelectorAll('#logsContent .log-entry');
|
|
|
|
|
+ let visibleCount = 0;
|
|
|
|
|
+ entries.forEach(entry => {
|
|
|
|
|
+ if (!levelFilter || entry.dataset.level === levelFilter) {
|
|
|
|
|
+ entry.classList.remove('hidden');
|
|
|
|
|
+ visibleCount++;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ entry.classList.add('hidden');
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ const countEl = document.getElementById('logsCount');
|
|
|
|
|
+ if (levelFilter) {
|
|
|
|
|
+ countEl.textContent = `${visibleCount} of ${logsEntries.length} entries (filtered)`;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ countEl.textContent = `${logsEntries.length} entries`;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function updateLogsCount() {
|
|
|
|
|
+ const countEl = document.getElementById('logsCount');
|
|
|
|
|
+ const levelFilter = document.getElementById('logLevelFilter').value;
|
|
|
|
|
+ if (levelFilter) {
|
|
|
|
|
+ const filteredCount = logsEntries.filter(e => e.level === levelFilter).length;
|
|
|
|
|
+ countEl.textContent = `${filteredCount} of ${logsEntries.length} entries (filtered)`;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ countEl.textContent = `${logsEntries.length} entries`;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function clearLogs() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await fetch('/api/logs', { method: 'DELETE' });
|
|
|
|
|
+ logsEntries = [];
|
|
|
|
|
+ document.getElementById('logsContent').innerHTML = '<div class="text-gray-500 dark:text-gray-400 text-center py-4">Logs cleared</div>';
|
|
|
|
|
+ updateLogsCount();
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.error('Failed to clear logs:', error);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ </script>
|
|
|
</body>
|
|
</body>
|
|
|
</html>
|
|
</html>
|