Просмотр исходного кода

Handle Pi 5 rotation via QML transform

- linuxfb:rotation= parameter requires Qt patch, doesn't work out of box
- Detect Pi 5 in Python and pass rotateDisplay flag to QML
- Wrap main content in rotation container that rotates 180° on Pi 5
- Simplified linuxfb config (no rotation parameter)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris 1 месяц назад
Родитель
Сommit
a8ed8b761a

+ 3 - 9
dune-weaver-touch/install.sh

@@ -64,14 +64,8 @@ setup_systemd() {
     local SERVICE_FILE="/etc/systemd/system/dune-weaver-touch.service"
 
     # Generate service file with linuxfb backend (works on all Pi models)
-    # Pi 5 needs explicit rotation since cmdline.txt rotation doesn't apply to Qt linuxfb
-    local QT_PLATFORM="linuxfb:fb=/dev/fb0"
-    if [ "$IS_PI5" = true ]; then
-        QT_PLATFORM="linuxfb:fb=/dev/fb0:rotation=180"
-        echo "   ℹ️  Using linuxfb backend with 180° rotation (Pi 5)"
-    else
-        echo "   ℹ️  Using linuxfb backend"
-    fi
+    # Pi 5 rotation is handled in QML (linuxfb rotation parameter requires Qt patch)
+    echo "   ℹ️  Using linuxfb backend"
 
     cat > "$SERVICE_FILE" << EOF
 [Unit]
@@ -84,7 +78,7 @@ Type=simple
 User=$ACTUAL_USER
 Group=$ACTUAL_USER
 WorkingDirectory=$SCRIPT_DIR
-Environment=QT_QPA_PLATFORM=$QT_PLATFORM
+Environment=QT_QPA_PLATFORM=linuxfb:fb=/dev/fb0
 Environment=QT_QPA_FB_HIDECURSOR=1
 ExecStart=$SCRIPT_DIR/venv/bin/python $SCRIPT_DIR/main.py
 Restart=always

+ 17 - 1
dune-weaver-touch/main.py

@@ -7,7 +7,7 @@ import signal
 from pathlib import Path
 from PySide6.QtCore import QUrl, QTimer, QObject, QEvent
 from PySide6.QtGui import QGuiApplication, QTouchEvent, QMouseEvent
-from PySide6.QtQml import QQmlApplicationEngine, qmlRegisterType
+from PySide6.QtQml import QQmlApplicationEngine, qmlRegisterType, QQmlContext
 from qasync import QEventLoop
 
 # Load environment variables from .env file if it exists
@@ -87,6 +87,15 @@ async def startup_tasks():
     
     logger.info("✨ dune-weaver-touch startup tasks completed")
 
+def is_pi5():
+    """Check if running on Raspberry Pi 5"""
+    try:
+        with open('/proc/device-tree/model', 'r') as f:
+            model = f.read()
+            return 'Pi 5' in model
+    except:
+        return False
+
 def main():
     # Enable virtual keyboard
     os.environ['QT_IM_MODULE'] = 'qtvirtualkeyboard'
@@ -110,6 +119,13 @@ def main():
     
     # Load QML
     engine = QQmlApplicationEngine()
+
+    # Set rotation flag for Pi 5 (linuxfb doesn't support rotation parameter)
+    rotate_display = is_pi5() and 'linuxfb' in os.environ.get('QT_QPA_PLATFORM', '')
+    engine.rootContext().setContextProperty("rotateDisplay", rotate_display)
+    if rotate_display:
+        logger.info("🔄 Pi 5 detected with linuxfb - enabling QML rotation")
+
     qml_file = Path(__file__).parent / "qml" / "main.qml"
     engine.load(QUrl.fromLocalFile(str(qml_file)))
     

+ 17 - 9
dune-weaver-touch/qml/main.qml

@@ -104,6 +104,13 @@ ApplicationWindow {
         id: patternModel
     }
     
+    // Rotation container for Pi 5 linuxfb
+    Item {
+        id: rotationContainer
+        anchors.fill: parent
+        rotation: typeof rotateDisplay !== 'undefined' && rotateDisplay ? 180 : 0
+        transformOrigin: Item.Center
+
     StackView {
         id: stackView
         anchors.fill: parent
@@ -204,13 +211,7 @@ ApplicationWindow {
             }
         }
     }
-    
-    MessageDialog {
-        id: errorDialog
-        title: "Error"
-        buttons: MessageDialog.Ok
-    }
-    
+
     // Virtual Keyboard Support
     InputPanel {
         id: inputPanel
@@ -218,7 +219,7 @@ ApplicationWindow {
         y: window.height
         anchors.left: parent.left
         anchors.right: parent.right
-        
+
         states: State {
             name: "visible"
             when: inputPanel.active
@@ -227,7 +228,7 @@ ApplicationWindow {
                 y: window.height - inputPanel.height
             }
         }
-        
+
         transitions: Transition {
             from: ""
             to: "visible"
@@ -242,4 +243,11 @@ ApplicationWindow {
             }
         }
     }
+    } // End rotationContainer
+
+    MessageDialog {
+        id: errorDialog
+        title: "Error"
+        buttons: MessageDialog.Ok
+    }
 }

+ 3 - 9
dune-weaver-touch/run.sh

@@ -58,16 +58,10 @@ if [[ "$OSTYPE" == "darwin"* ]]; then
     export QT_QPA_PLATFORM=""  # Let Qt use default
 elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
     # Linux/Raspberry Pi - use linuxfb for all Pi models
+    # Pi 5 rotation is handled in QML (linuxfb rotation parameter requires Qt patch)
     if [ -e /dev/fb0 ]; then
-        # Check if Pi 5 - needs explicit rotation
-        PI_MODEL=$(cat /proc/device-tree/model 2>/dev/null | tr -d '\0' || echo "unknown")
-        if [[ "$PI_MODEL" == *"Pi 5"* ]]; then
-            echo "   Platform: Raspberry Pi 5 (using linuxfb with 180° rotation)"
-            export QT_QPA_PLATFORM=linuxfb:fb=/dev/fb0:rotation=180
-        else
-            echo "   Platform: Linux (using linuxfb backend)"
-            export QT_QPA_PLATFORM=linuxfb:fb=/dev/fb0
-        fi
+        echo "   Platform: Linux (using linuxfb backend)"
+        export QT_QPA_PLATFORM=linuxfb:fb=/dev/fb0
         export QT_QPA_FB_HIDECURSOR=1
     else
         echo "   Platform: Linux (using xcb/X11 backend)"

+ 78 - 0
main.py

@@ -25,6 +25,7 @@ from modules.led.idle_timeout_manager import idle_timeout_manager
 import math
 from modules.core.cache_manager import generate_all_image_previews, get_cache_path, generate_image_preview, get_pattern_metadata
 from modules.core.version_manager import version_manager
+from modules.core.log_handler import init_memory_handler, get_memory_handler
 import json
 import base64
 import time
@@ -53,6 +54,9 @@ logging.basicConfig(
     ]
 )
 
+# Initialize memory log handler for web UI log viewer
+init_memory_handler(max_entries=500)
+
 logger = logging.getLogger(__name__)
 
 
@@ -476,6 +480,80 @@ async def websocket_cache_progress_endpoint(websocket: WebSocket):
         except RuntimeError:
             pass
 
+
+# WebSocket endpoint for real-time log streaming
+@app.websocket("/ws/logs")
+async def websocket_logs_endpoint(websocket: WebSocket):
+    """Stream application logs in real-time via WebSocket."""
+    await websocket.accept()
+
+    handler = get_memory_handler()
+    if not handler:
+        await websocket.close()
+        return
+
+    # Subscribe to log updates
+    log_queue = handler.subscribe()
+
+    try:
+        while True:
+            try:
+                # Wait for new log entry with timeout
+                log_entry = await asyncio.wait_for(log_queue.get(), timeout=30.0)
+                await websocket.send_json({
+                    "type": "log_entry",
+                    "data": log_entry
+                })
+            except asyncio.TimeoutError:
+                # Send heartbeat to keep connection alive
+                await websocket.send_json({"type": "heartbeat"})
+            except RuntimeError as e:
+                if "close message has been sent" in str(e):
+                    break
+                raise
+    except WebSocketDisconnect:
+        pass
+    finally:
+        handler.unsubscribe(log_queue)
+        try:
+            await websocket.close()
+        except RuntimeError:
+            pass
+
+
+# API endpoint to retrieve logs
+@app.get("/api/logs", tags=["logs"])
+async def get_logs(limit: int = 100, level: str = None):
+    """
+    Retrieve application logs from memory buffer.
+
+    Args:
+        limit: Maximum number of log entries to return (default: 100, max: 500)
+        level: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
+
+    Returns:
+        List of log entries with timestamp, level, logger, and message.
+    """
+    handler = get_memory_handler()
+    if not handler:
+        return {"logs": [], "error": "Log handler not initialized"}
+
+    # Clamp limit to reasonable range
+    limit = max(1, min(limit, 500))
+
+    logs = handler.get_logs(limit=limit, level=level)
+    return {"logs": logs, "count": len(logs)}
+
+
+@app.delete("/api/logs", tags=["logs"])
+async def clear_logs():
+    """Clear all logs from the memory buffer."""
+    handler = get_memory_handler()
+    if handler:
+        handler.clear()
+    return {"status": "ok", "message": "Logs cleared"}
+
+
 # FastAPI routes
 @app.get("/")
 async def index(request: Request):

+ 193 - 0
modules/core/log_handler.py

@@ -0,0 +1,193 @@
+"""
+Memory-based log handler for capturing and streaming application logs.
+
+This module provides a circular buffer log handler that captures log messages
+in memory for display in the web UI, with support for real-time streaming
+via WebSocket.
+"""
+
+import logging
+from collections import deque
+from datetime import datetime
+from typing import List, Dict, Any
+import threading
+import asyncio
+
+
+class MemoryLogHandler(logging.Handler):
+    """
+    A logging handler that stores log records in a circular buffer.
+
+    Thread-safe implementation using a lock for concurrent access.
+    Supports async iteration for WebSocket streaming.
+    """
+
+    def __init__(self, max_entries: int = 500):
+        """
+        Initialize the memory log handler.
+
+        Args:
+            max_entries: Maximum number of log entries to keep in memory.
+                        Older entries are automatically discarded.
+        """
+        super().__init__()
+        self.max_entries = max_entries
+        self._buffer: deque = deque(maxlen=max_entries)
+        self._lock = threading.Lock()
+        self._subscribers: List[asyncio.Queue] = []
+        self._subscribers_lock = threading.Lock()
+
+    def emit(self, record: logging.LogRecord) -> None:
+        """
+        Store a log record in the buffer and notify subscribers.
+
+        Args:
+            record: The log record to store.
+        """
+        try:
+            log_entry = self._format_record(record)
+
+            with self._lock:
+                self._buffer.append(log_entry)
+
+            # Notify all subscribers (for WebSocket streaming)
+            self._notify_subscribers(log_entry)
+
+        except Exception:
+            self.handleError(record)
+
+    def _format_record(self, record: logging.LogRecord) -> Dict[str, Any]:
+        """
+        Format a log record into a dictionary for JSON serialization.
+
+        Args:
+            record: The log record to format.
+
+        Returns:
+            Dictionary containing formatted log data.
+        """
+        return {
+            "timestamp": datetime.fromtimestamp(record.created).isoformat(),
+            "level": record.levelname,
+            "logger": record.name,
+            "line": record.lineno,
+            "message": record.getMessage(),
+            "module": record.module,
+        }
+
+    def get_logs(self, limit: int = None, level: str = None) -> List[Dict[str, Any]]:
+        """
+        Retrieve stored log entries.
+
+        Args:
+            limit: Maximum number of entries to return (newest first).
+            level: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
+
+        Returns:
+            List of log entries as dictionaries.
+        """
+        with self._lock:
+            logs = list(self._buffer)
+
+        # Filter by level if specified
+        if level:
+            level_upper = level.upper()
+            logs = [log for log in logs if log["level"] == level_upper]
+
+        # Return newest first, with optional limit
+        logs.reverse()
+        if limit:
+            logs = logs[:limit]
+
+        return logs
+
+    def clear(self) -> None:
+        """Clear all stored log entries."""
+        with self._lock:
+            self._buffer.clear()
+
+    def subscribe(self) -> asyncio.Queue:
+        """
+        Subscribe to real-time log updates.
+
+        Returns:
+            An asyncio Queue that will receive new log entries.
+        """
+        queue = asyncio.Queue(maxsize=100)
+        with self._subscribers_lock:
+            self._subscribers.append(queue)
+        return queue
+
+    def unsubscribe(self, queue: asyncio.Queue) -> None:
+        """
+        Unsubscribe from real-time log updates.
+
+        Args:
+            queue: The queue returned by subscribe().
+        """
+        with self._subscribers_lock:
+            if queue in self._subscribers:
+                self._subscribers.remove(queue)
+
+    def _notify_subscribers(self, log_entry: Dict[str, Any]) -> None:
+        """
+        Notify all subscribers of a new log entry.
+
+        Args:
+            log_entry: The formatted log entry to send.
+        """
+        with self._subscribers_lock:
+            dead_subscribers = []
+            for queue in self._subscribers:
+                try:
+                    queue.put_nowait(log_entry)
+                except asyncio.QueueFull:
+                    # If queue is full, skip this entry
+                    pass
+                except Exception:
+                    dead_subscribers.append(queue)
+
+            # Remove dead subscribers
+            for queue in dead_subscribers:
+                self._subscribers.remove(queue)
+
+
+# Global instance of the memory log handler
+memory_handler: MemoryLogHandler = None
+
+
+def init_memory_handler(max_entries: int = 500) -> MemoryLogHandler:
+    """
+    Initialize and install the memory log handler.
+
+    This should be called once during application startup, after
+    basicConfig but before any logging occurs.
+
+    Args:
+        max_entries: Maximum number of log entries to store.
+
+    Returns:
+        The initialized MemoryLogHandler instance.
+    """
+    global memory_handler
+
+    memory_handler = MemoryLogHandler(max_entries=max_entries)
+    memory_handler.setFormatter(
+        logging.Formatter('%(asctime)s - %(name)s:%(lineno)d - %(levelname)s - %(message)s')
+    )
+
+    # Add to root logger to capture all logs
+    root_logger = logging.getLogger()
+    root_logger.addHandler(memory_handler)
+
+    return memory_handler
+
+
+def get_memory_handler() -> MemoryLogHandler:
+    """
+    Get the global memory log handler instance.
+
+    Returns:
+        The MemoryLogHandler instance, or None if not initialized.
+    """
+    return memory_handler

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/css/tailwind.css


+ 250 - 0
static/js/settings.js

@@ -2406,3 +2406,253 @@ function initializeTableTypeConfig() {
         }
     }
 }
+
+// ============================================================================
+// Application Logs Modal
+// ============================================================================
+
+let logsWebSocket = null;
+let logsEntries = [];
+const MAX_LOG_ENTRIES = 500;
+
+// Color classes for different log levels
+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');
+
+    // Load initial logs
+    loadInitialLogs();
+
+    // Connect to WebSocket for real-time updates
+    connectLogsWebSocket();
+
+    // Close on overlay click
+    modal.addEventListener('click', (e) => {
+        if (e.target === modal) {
+            closeLogsModal();
+        }
+    });
+
+    // Close on Escape key
+    document.addEventListener('keydown', handleLogsEscapeKey);
+}
+
+function closeLogsModal() {
+    const modal = document.getElementById('logsModal');
+    modal.classList.add('hidden');
+
+    // Disconnect WebSocket
+    if (logsWebSocket) {
+        logsWebSocket.close();
+        logsWebSocket = null;
+    }
+
+    // Remove event listener
+    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();
+
+        // Scroll to bottom
+        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);
+            }
+            // Ignore heartbeat messages
+        };
+
+        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) {
+    // Add to beginning (newest first in memory, but we render oldest first in UI)
+    logsEntries.unshift(entry);
+
+    // Trim if exceeds max
+    if (logsEntries.length > MAX_LOG_ENTRIES) {
+        logsEntries.pop();
+    }
+
+    // Add to UI
+    const logsContent = document.getElementById('logsContent');
+    const entryEl = createLogEntryElement(entry);
+
+    // Insert at the end (bottom) for newest
+    logsContent.appendChild(entryEl);
+
+    // Auto-scroll if enabled
+    const autoScroll = document.getElementById('logsAutoScroll').checked;
+    if (autoScroll) {
+        const container = document.getElementById('logsContainer');
+        container.scrollTop = container.scrollHeight;
+    }
+
+    // Apply filter if active
+    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;
+
+    // Format timestamp (show only time part for brevity)
+    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">${escapeHtml(entry.message)}</span>
+    `;
+
+    return div;
+}
+
+function escapeHtml(text) {
+    const div = document.createElement('div');
+    div.textContent = text;
+    return div.innerHTML;
+}
+
+function renderLogs() {
+    const logsContent = document.getElementById('logsContent');
+    logsContent.innerHTML = '';
+
+    // Render in reverse order (oldest first, newest at bottom)
+    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');
+        }
+    });
+
+    // Update count to show filtered amount
+    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);
+    }
+}

+ 72 - 0
templates/settings.html

@@ -1523,9 +1523,81 @@ input:checked + .slider:before {
           <span id="updateText" class="truncate">Update</span>
         </button>
       </div>
+      <div class="flex items-center gap-4 px-6 py-5 border-t border-slate-200">
+        <div
+          class="text-slate-600 flex items-center justify-center rounded-lg bg-slate-100 shrink-0 size-12"
+        >
+          <span class="material-icons text-3xl">article</span>
+        </div>
+        <div class="flex-1">
+          <p class="text-slate-800 text-base font-medium leading-normal">
+            Application Logs
+          </p>
+          <p class="text-slate-500 text-sm font-normal leading-normal">View real-time application logs</p>
+        </div>
+        <button
+          id="openLogsBtn"
+          onclick="openLogsModal()"
+          class="flex items-center justify-center gap-1.5 min-w-[84px] cursor-pointer rounded-lg h-9 px-3 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium leading-normal tracking-[0.015em] transition-colors"
+        >
+          <span class="material-icons text-base">terminal</span>
+          <span class="truncate">View Logs</span>
+        </button>
+      </div>
     </div>
   </section>
 </div>
 {% endblock %} {% block scripts %}
+<!-- Logs Modal - placed in scripts block to be outside main content container -->
+<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-sm rounded-lg border-slate-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 h-8 px-2">
+          <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 src="/static/js/settings.js"></script>
 {% endblock %}

Некоторые файлы не были показаны из-за большого количества измененных файлов