Parcourir la source

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 il y a 1 mois
Parent
commit
a8ed8b761a

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

@@ -64,14 +64,8 @@ setup_systemd() {
     local SERVICE_FILE="/etc/systemd/system/dune-weaver-touch.service"
     local SERVICE_FILE="/etc/systemd/system/dune-weaver-touch.service"
 
 
     # Generate service file with linuxfb backend (works on all Pi models)
     # 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
     cat > "$SERVICE_FILE" << EOF
 [Unit]
 [Unit]
@@ -84,7 +78,7 @@ Type=simple
 User=$ACTUAL_USER
 User=$ACTUAL_USER
 Group=$ACTUAL_USER
 Group=$ACTUAL_USER
 WorkingDirectory=$SCRIPT_DIR
 WorkingDirectory=$SCRIPT_DIR
-Environment=QT_QPA_PLATFORM=$QT_PLATFORM
+Environment=QT_QPA_PLATFORM=linuxfb:fb=/dev/fb0
 Environment=QT_QPA_FB_HIDECURSOR=1
 Environment=QT_QPA_FB_HIDECURSOR=1
 ExecStart=$SCRIPT_DIR/venv/bin/python $SCRIPT_DIR/main.py
 ExecStart=$SCRIPT_DIR/venv/bin/python $SCRIPT_DIR/main.py
 Restart=always
 Restart=always

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

@@ -7,7 +7,7 @@ import signal
 from pathlib import Path
 from pathlib import Path
 from PySide6.QtCore import QUrl, QTimer, QObject, QEvent
 from PySide6.QtCore import QUrl, QTimer, QObject, QEvent
 from PySide6.QtGui import QGuiApplication, QTouchEvent, QMouseEvent
 from PySide6.QtGui import QGuiApplication, QTouchEvent, QMouseEvent
-from PySide6.QtQml import QQmlApplicationEngine, qmlRegisterType
+from PySide6.QtQml import QQmlApplicationEngine, qmlRegisterType, QQmlContext
 from qasync import QEventLoop
 from qasync import QEventLoop
 
 
 # Load environment variables from .env file if it exists
 # 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")
     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():
 def main():
     # Enable virtual keyboard
     # Enable virtual keyboard
     os.environ['QT_IM_MODULE'] = 'qtvirtualkeyboard'
     os.environ['QT_IM_MODULE'] = 'qtvirtualkeyboard'
@@ -110,6 +119,13 @@ def main():
     
     
     # Load QML
     # Load QML
     engine = QQmlApplicationEngine()
     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"
     qml_file = Path(__file__).parent / "qml" / "main.qml"
     engine.load(QUrl.fromLocalFile(str(qml_file)))
     engine.load(QUrl.fromLocalFile(str(qml_file)))
     
     

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

@@ -104,6 +104,13 @@ ApplicationWindow {
         id: patternModel
         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 {
     StackView {
         id: stackView
         id: stackView
         anchors.fill: parent
         anchors.fill: parent
@@ -204,13 +211,7 @@ ApplicationWindow {
             }
             }
         }
         }
     }
     }
-    
-    MessageDialog {
-        id: errorDialog
-        title: "Error"
-        buttons: MessageDialog.Ok
-    }
-    
+
     // Virtual Keyboard Support
     // Virtual Keyboard Support
     InputPanel {
     InputPanel {
         id: inputPanel
         id: inputPanel
@@ -218,7 +219,7 @@ ApplicationWindow {
         y: window.height
         y: window.height
         anchors.left: parent.left
         anchors.left: parent.left
         anchors.right: parent.right
         anchors.right: parent.right
-        
+
         states: State {
         states: State {
             name: "visible"
             name: "visible"
             when: inputPanel.active
             when: inputPanel.active
@@ -227,7 +228,7 @@ ApplicationWindow {
                 y: window.height - inputPanel.height
                 y: window.height - inputPanel.height
             }
             }
         }
         }
-        
+
         transitions: Transition {
         transitions: Transition {
             from: ""
             from: ""
             to: "visible"
             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
     export QT_QPA_PLATFORM=""  # Let Qt use default
 elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
 elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
     # Linux/Raspberry Pi - use linuxfb for all Pi models
     # 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
     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
         export QT_QPA_FB_HIDECURSOR=1
     else
     else
         echo "   Platform: Linux (using xcb/X11 backend)"
         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
 import math
 from modules.core.cache_manager import generate_all_image_previews, get_cache_path, generate_image_preview, get_pattern_metadata
 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.version_manager import version_manager
+from modules.core.log_handler import init_memory_handler, get_memory_handler
 import json
 import json
 import base64
 import base64
 import time
 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__)
 logger = logging.getLogger(__name__)
 
 
 
 
@@ -476,6 +480,80 @@ async def websocket_cache_progress_endpoint(websocket: WebSocket):
         except RuntimeError:
         except RuntimeError:
             pass
             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
 # FastAPI routes
 @app.get("/")
 @app.get("/")
 async def index(request: Request):
 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

Fichier diff supprimé car celui-ci est trop grand
+ 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>
           <span id="updateText" class="truncate">Update</span>
         </button>
         </button>
       </div>
       </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>
     </div>
   </section>
   </section>
 </div>
 </div>
 {% endblock %} {% block scripts %}
 {% 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>
 <script src="/static/js/settings.js"></script>
 {% endblock %}
 {% endblock %}

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff