Browse Source

Adds a live log viewer component to the Support & Troubleshooting section
that allows viewing and filtering application logs in real-time.
Features:
- Start/Stop live streaming with 2-second auto-refresh
- Filter by log level (DEBUG, INFO, WARNING, ERROR)
- Text search across messages and logger names
- Clear logs with one click
- Expandable multi-line log entries (stack traces, etc.)
- Auto-scroll to follow new entries

Closes #87

maziggy 4 months ago
parent
commit
90249d2367

+ 7 - 0
CHANGELOG.md

@@ -9,6 +9,13 @@ All notable changes to Bambuddy will be documented in this file.
   - Each viewer has its own remembered position and size
   - Each viewer has its own remembered position and size
   - New viewers are automatically offset to prevent stacking
   - New viewers are automatically offset to prevent stacking
   - Printer-specific persistence in localStorage
   - Printer-specific persistence in localStorage
+- **Application Log Viewer** - View and filter application logs in real-time from System Information page:
+  - Start/Stop live streaming with 2-second auto-refresh
+  - Filter by log level (DEBUG, INFO, WARNING, ERROR)
+  - Text search across messages and logger names
+  - Clear logs with one click
+  - Expandable multi-line log entries (stack traces, etc.)
+  - Auto-scroll to follow new entries
 - **Unified Print Modal** - Consolidated three separate modals into one unified component:
 - **Unified Print Modal** - Consolidated three separate modals into one unified component:
   - Single modal handles reprint, add-to-queue, and edit-queue-item operations
   - Single modal handles reprint, add-to-queue, and edit-queue-item operations
   - Consistent UI/UX across all print operations
   - Consistent UI/UX across all print operations

+ 1 - 0
README.md

@@ -127,6 +127,7 @@
 - File manager for printer storage
 - File manager for printer storage
 - Firmware update helper (LAN-only printers)
 - Firmware update helper (LAN-only printers)
 - Debug logging toggle with live indicator
 - Debug logging toggle with live indicator
+- Live application log viewer with filtering
 - Support bundle generator (privacy-filtered)
 - Support bundle generator (privacy-filtered)
 
 
 </td>
 </td>

+ 155 - 2
backend/app/api/routes/support.py

@@ -5,10 +5,11 @@ import json
 import logging
 import logging
 import os
 import os
 import platform
 import platform
+import re
 import zipfile
 import zipfile
 from datetime import datetime
 from datetime import datetime
 
 
-from fastapi import APIRouter, HTTPException
+from fastapi import APIRouter, HTTPException, Query
 from fastapi.responses import StreamingResponse
 from fastapi.responses import StreamingResponse
 from pydantic import BaseModel
 from pydantic import BaseModel
 from sqlalchemy import func, select
 from sqlalchemy import func, select
@@ -149,9 +150,161 @@ async def toggle_debug_logging(toggle: DebugLoggingToggle):
     )
     )
 
 
 
 
+class LogEntry(BaseModel):
+    """A single log entry."""
+
+    timestamp: str
+    level: str
+    logger_name: str
+    message: str
+
+
+class LogsResponse(BaseModel):
+    """Response containing log entries."""
+
+    entries: list[LogEntry]
+    total_in_file: int
+    filtered_count: int
+
+
+# Log line regex pattern: "2024-01-15 10:30:45,123 INFO [module.name] Message here"
+LOG_LINE_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2},\d{3})\s+(\w+)\s+\[([^\]]+)\]\s+(.*)$")
+
+
+def _parse_log_line(line: str) -> LogEntry | None:
+    """Parse a single log line into a LogEntry."""
+    match = LOG_LINE_PATTERN.match(line.strip())
+    if match:
+        return LogEntry(
+            timestamp=match.group(1),
+            level=match.group(2),
+            logger_name=match.group(3),
+            message=match.group(4),
+        )
+    return None
+
+
+def _read_log_entries(
+    limit: int = 200,
+    level_filter: str | None = None,
+    search: str | None = None,
+) -> tuple[list[LogEntry], int]:
+    """Read and parse log entries from file with optional filtering."""
+    log_file = settings.log_dir / "bambuddy.log"
+    if not log_file.exists():
+        return [], 0
+
+    entries: list[LogEntry] = []
+    total_lines = 0
+
+    try:
+        with open(log_file, encoding="utf-8", errors="replace") as f:
+            # Read all lines and process
+            lines = f.readlines()
+            total_lines = len(lines)
+
+            # Parse lines in reverse order (newest first)
+            current_entry: LogEntry | None = None
+            multi_line_buffer: list[str] = []
+
+            for line in reversed(lines):
+                parsed = _parse_log_line(line)
+                if parsed:
+                    # Found a new log entry start
+                    if current_entry:
+                        # Apply filters and add previous entry
+                        should_include = True
+
+                        # Level filter
+                        if level_filter and current_entry.level.upper() != level_filter.upper():
+                            should_include = False
+
+                        # Search filter (case-insensitive)
+                        if search and should_include:
+                            search_lower = search.lower()
+                            if not (
+                                search_lower in current_entry.message.lower()
+                                or search_lower in current_entry.logger_name.lower()
+                            ):
+                                should_include = False
+
+                        if should_include:
+                            # Append any multi-line content
+                            if multi_line_buffer:
+                                current_entry.message += "\n" + "\n".join(reversed(multi_line_buffer))
+                            entries.append(current_entry)
+
+                            if len(entries) >= limit:
+                                break
+
+                    current_entry = parsed
+                    multi_line_buffer = []
+                elif current_entry and line.strip():
+                    # Continuation of multi-line log entry
+                    multi_line_buffer.append(line.rstrip())
+
+            # Don't forget the last (oldest) entry
+            if current_entry and len(entries) < limit:
+                should_include = True
+                if level_filter and current_entry.level.upper() != level_filter.upper():
+                    should_include = False
+                if search and should_include:
+                    search_lower = search.lower()
+                    if not (
+                        search_lower in current_entry.message.lower()
+                        or search_lower in current_entry.logger_name.lower()
+                    ):
+                        should_include = False
+                if should_include:
+                    if multi_line_buffer:
+                        current_entry.message += "\n" + "\n".join(reversed(multi_line_buffer))
+                    entries.append(current_entry)
+
+    except Exception as e:
+        logger.error(f"Error reading log file: {e}")
+        return [], 0
+
+    # Entries are already in newest-first order
+    return entries, total_lines
+
+
+@router.get("/logs", response_model=LogsResponse)
+async def get_logs(
+    limit: int = Query(200, ge=1, le=1000, description="Maximum number of entries to return"),
+    level: str | None = Query(None, description="Filter by log level (DEBUG, INFO, WARNING, ERROR)"),
+    search: str | None = Query(None, description="Search in message or logger name"),
+):
+    """Get recent application log entries with optional filtering."""
+    entries, total_lines = _read_log_entries(limit=limit, level_filter=level, search=search)
+
+    return LogsResponse(
+        entries=entries,
+        total_in_file=total_lines,
+        filtered_count=len(entries),
+    )
+
+
+@router.delete("/logs")
+async def clear_logs():
+    """Clear the application log file."""
+    log_file = settings.log_dir / "bambuddy.log"
+
+    if log_file.exists():
+        try:
+            # Truncate the file instead of deleting (keeps file handles valid)
+            with open(log_file, "w", encoding="utf-8") as f:
+                f.write("")
+            logger.info("Log file cleared by user")
+            return {"message": "Logs cleared successfully"}
+        except Exception as e:
+            logger.error(f"Error clearing log file: {e}")
+            raise HTTPException(status_code=500, detail=f"Failed to clear logs: {e}")
+
+    return {"message": "Log file does not exist"}
+
+
 def _sanitize_path(path: str) -> str:
 def _sanitize_path(path: str) -> str:
     """Remove username from paths for privacy."""
     """Remove username from paths for privacy."""
-    import re
 
 
     # Replace /home/username/ or /Users/username/ with /home/[user]/
     # Replace /home/username/ or /Users/username/ with /home/[user]/
     path = re.sub(r"/home/[^/]+/", "/home/[user]/", path)
     path = re.sub(r"/home/[^/]+/", "/home/[user]/", path)

+ 256 - 0
backend/tests/integration/test_support_api.py

@@ -0,0 +1,256 @@
+"""Integration tests for Support API endpoints.
+
+Tests the full request/response cycle for /api/v1/support/ endpoints.
+"""
+
+import tempfile
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestSupportLogsAPI:
+    """Integration tests for /api/v1/support/logs endpoints."""
+
+    # ========================================================================
+    # GET /api/v1/support/logs
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_logs_empty_file(self, async_client: AsyncClient):
+        """Verify get logs returns empty list when log file doesn't exist."""
+        with patch("backend.app.api.routes.support.settings") as mock_settings:
+            mock_settings.log_dir = Path("/nonexistent/path")
+
+            response = await async_client.get("/api/v1/support/logs")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["entries"] == []
+        assert result["total_in_file"] == 0
+        assert result["filtered_count"] == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_logs_with_entries(self, async_client: AsyncClient):
+        """Verify get logs returns parsed log entries."""
+        log_content = """2024-01-15 10:30:45,123 INFO [backend.app.main] Server started
+2024-01-15 10:30:46,456 DEBUG [backend.app.services.printer] Connecting to printer
+2024-01-15 10:30:47,789 WARNING [backend.app.services.mqtt] Connection timeout
+2024-01-15 10:30:48,012 ERROR [backend.app.services.ftp] Failed to download file
+"""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            log_file = Path(tmpdir) / "bambuddy.log"
+            log_file.write_text(log_content)
+
+            with patch("backend.app.api.routes.support.settings") as mock_settings:
+                mock_settings.log_dir = Path(tmpdir)
+
+                response = await async_client.get("/api/v1/support/logs")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert len(result["entries"]) == 4
+        assert result["total_in_file"] == 4
+        assert result["filtered_count"] == 4
+
+        # Entries are in newest-first order
+        assert result["entries"][0]["level"] == "ERROR"
+        assert result["entries"][1]["level"] == "WARNING"
+        assert result["entries"][2]["level"] == "DEBUG"
+        assert result["entries"][3]["level"] == "INFO"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_logs_with_level_filter(self, async_client: AsyncClient):
+        """Verify get logs filters by log level."""
+        log_content = """2024-01-15 10:30:45,123 INFO [backend.app.main] Server started
+2024-01-15 10:30:46,456 DEBUG [backend.app.services.printer] Connecting to printer
+2024-01-15 10:30:47,789 ERROR [backend.app.services.mqtt] Connection timeout
+2024-01-15 10:30:48,012 ERROR [backend.app.services.ftp] Failed to download file
+"""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            log_file = Path(tmpdir) / "bambuddy.log"
+            log_file.write_text(log_content)
+
+            with patch("backend.app.api.routes.support.settings") as mock_settings:
+                mock_settings.log_dir = Path(tmpdir)
+
+                response = await async_client.get("/api/v1/support/logs?level=ERROR")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert len(result["entries"]) == 2
+        assert result["filtered_count"] == 2
+        assert all(e["level"] == "ERROR" for e in result["entries"])
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_logs_with_search_filter(self, async_client: AsyncClient):
+        """Verify get logs filters by search query."""
+        log_content = """2024-01-15 10:30:45,123 INFO [backend.app.main] Server started
+2024-01-15 10:30:46,456 INFO [backend.app.services.printer] Connecting to printer X1C
+2024-01-15 10:30:47,789 ERROR [backend.app.services.mqtt] Connection to printer failed
+2024-01-15 10:30:48,012 ERROR [backend.app.services.ftp] Failed to download file
+"""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            log_file = Path(tmpdir) / "bambuddy.log"
+            log_file.write_text(log_content)
+
+            with patch("backend.app.api.routes.support.settings") as mock_settings:
+                mock_settings.log_dir = Path(tmpdir)
+
+                response = await async_client.get("/api/v1/support/logs?search=printer")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert len(result["entries"]) == 2
+        assert result["filtered_count"] == 2
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_logs_with_limit(self, async_client: AsyncClient):
+        """Verify get logs respects limit parameter."""
+        log_content = """2024-01-15 10:30:45,123 INFO [backend.app.main] Line 1
+2024-01-15 10:30:46,456 INFO [backend.app.main] Line 2
+2024-01-15 10:30:47,789 INFO [backend.app.main] Line 3
+2024-01-15 10:30:48,012 INFO [backend.app.main] Line 4
+2024-01-15 10:30:49,345 INFO [backend.app.main] Line 5
+"""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            log_file = Path(tmpdir) / "bambuddy.log"
+            log_file.write_text(log_content)
+
+            with patch("backend.app.api.routes.support.settings") as mock_settings:
+                mock_settings.log_dir = Path(tmpdir)
+
+                response = await async_client.get("/api/v1/support/logs?limit=2")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert len(result["entries"]) == 2
+        assert result["filtered_count"] == 2
+        # Should get the newest entries (Line 5 and Line 4)
+        assert "Line 5" in result["entries"][0]["message"]
+        assert "Line 4" in result["entries"][1]["message"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_logs_multiline_entry(self, async_client: AsyncClient):
+        """Verify get logs handles multi-line log entries."""
+        log_content = """2024-01-15 10:30:45,123 INFO [backend.app.main] Server started
+2024-01-15 10:30:46,456 ERROR [backend.app.services.mqtt] Exception occurred
+Traceback (most recent call last):
+  File "test.py", line 10, in test
+    raise ValueError("test error")
+ValueError: test error
+2024-01-15 10:30:47,789 INFO [backend.app.main] Recovery complete
+"""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            log_file = Path(tmpdir) / "bambuddy.log"
+            log_file.write_text(log_content)
+
+            with patch("backend.app.api.routes.support.settings") as mock_settings:
+                mock_settings.log_dir = Path(tmpdir)
+
+                response = await async_client.get("/api/v1/support/logs")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert len(result["entries"]) == 3
+
+        # Find the error entry
+        error_entry = next(e for e in result["entries"] if e["level"] == "ERROR")
+        assert "Exception occurred" in error_entry["message"]
+        assert "Traceback" in error_entry["message"]
+        assert "ValueError" in error_entry["message"]
+
+    # ========================================================================
+    # DELETE /api/v1/support/logs
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_logs_success(self, async_client: AsyncClient):
+        """Verify clear logs truncates the log file."""
+        log_content = """2024-01-15 10:30:45,123 INFO [backend.app.main] Server started
+2024-01-15 10:30:46,456 DEBUG [backend.app.services.printer] Some debug info
+"""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            log_file = Path(tmpdir) / "bambuddy.log"
+            log_file.write_text(log_content)
+
+            with patch("backend.app.api.routes.support.settings") as mock_settings:
+                mock_settings.log_dir = Path(tmpdir)
+
+                response = await async_client.delete("/api/v1/support/logs")
+
+                # Verify file was cleared
+                assert log_file.read_text() == ""
+
+        assert response.status_code == 200
+        result = response.json()
+        assert "cleared" in result["message"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clear_logs_no_file(self, async_client: AsyncClient):
+        """Verify clear logs handles missing log file gracefully."""
+        with patch("backend.app.api.routes.support.settings") as mock_settings:
+            mock_settings.log_dir = Path("/nonexistent/path")
+
+            response = await async_client.delete("/api/v1/support/logs")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert "does not exist" in result["message"].lower()
+
+
+class TestLogParsingHelpers:
+    """Tests for log parsing helper functions."""
+
+    def test_parse_log_line_valid(self):
+        """Verify _parse_log_line handles valid log lines."""
+        from backend.app.api.routes.support import _parse_log_line
+
+        line = "2024-01-15 10:30:45,123 INFO [backend.app.main] Server started"
+        entry = _parse_log_line(line)
+
+        assert entry is not None
+        assert entry.timestamp == "2024-01-15 10:30:45,123"
+        assert entry.level == "INFO"
+        assert entry.logger_name == "backend.app.main"
+        assert entry.message == "Server started"
+
+    def test_parse_log_line_invalid(self):
+        """Verify _parse_log_line returns None for invalid lines."""
+        from backend.app.api.routes.support import _parse_log_line
+
+        line = "This is not a valid log line"
+        entry = _parse_log_line(line)
+
+        assert entry is None
+
+    def test_parse_log_line_with_brackets_in_message(self):
+        """Verify _parse_log_line handles messages with brackets."""
+        from backend.app.api.routes.support import _parse_log_line
+
+        line = "2024-01-15 10:30:45,123 INFO [backend.app.main] Processing [item 1] and [item 2]"
+        entry = _parse_log_line(line)
+
+        assert entry is not None
+        assert entry.message == "Processing [item 1] and [item 2]"
+
+    def test_parse_log_line_all_levels(self):
+        """Verify _parse_log_line handles all log levels."""
+        from backend.app.api.routes.support import _parse_log_line
+
+        levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
+        for level in levels:
+            line = f"2024-01-15 10:30:45,123 {level} [test.module] Test message"
+            entry = _parse_log_line(line)
+            assert entry is not None
+            assert entry.level == level

+ 25 - 0
frontend/src/api/client.ts

@@ -3078,6 +3078,19 @@ export interface DebugLoggingState {
   duration_seconds: number | null;
   duration_seconds: number | null;
 }
 }
 
 
+export interface LogEntry {
+  timestamp: string;
+  level: string;
+  logger_name: string;
+  message: string;
+}
+
+export interface LogsResponse {
+  entries: LogEntry[];
+  total_in_file: number;
+  filtered_count: number;
+}
+
 // Support API
 // Support API
 export const supportApi = {
 export const supportApi = {
   getDebugLoggingState: () =>
   getDebugLoggingState: () =>
@@ -3111,4 +3124,16 @@ export const supportApi = {
     document.body.removeChild(a);
     document.body.removeChild(a);
     window.URL.revokeObjectURL(url);
     window.URL.revokeObjectURL(url);
   },
   },
+
+  getLogs: (params?: { limit?: number; level?: string; search?: string }) => {
+    const searchParams = new URLSearchParams();
+    if (params?.limit) searchParams.set('limit', params.limit.toString());
+    if (params?.level) searchParams.set('level', params.level);
+    if (params?.search) searchParams.set('search', params.search);
+    const query = searchParams.toString();
+    return request<LogsResponse>(`/support/logs${query ? `?${query}` : ''}`);
+  },
+
+  clearLogs: () =>
+    request<{ message: string }>('/support/logs', { method: 'DELETE' }),
 };
 };

+ 352 - 0
frontend/src/components/LogViewer.tsx

@@ -0,0 +1,352 @@
+import { useState, useEffect, useRef, useMemo } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import {
+  Play,
+  Square,
+  Trash2,
+  RefreshCw,
+  Search,
+  X,
+  ChevronDown,
+  ChevronUp,
+  AlertCircle,
+  AlertTriangle,
+  Info,
+  Bug,
+} from 'lucide-react';
+import { supportApi, type LogEntry } from '../api/client';
+
+const LOG_LEVELS = ['DEBUG', 'INFO', 'WARNING', 'ERROR'] as const;
+type LogLevel = (typeof LOG_LEVELS)[number];
+
+const levelColors: Record<LogLevel, string> = {
+  DEBUG: 'text-gray-400',
+  INFO: 'text-blue-400',
+  WARNING: 'text-yellow-400',
+  ERROR: 'text-red-400',
+};
+
+const levelIcons: Record<LogLevel, typeof Info> = {
+  DEBUG: Bug,
+  INFO: Info,
+  WARNING: AlertTriangle,
+  ERROR: AlertCircle,
+};
+
+export function LogViewer() {
+  const queryClient = useQueryClient();
+  const [autoScroll, setAutoScroll] = useState(true);
+  const [expandedLogs, setExpandedLogs] = useState<Set<number>>(new Set());
+  const [searchQuery, setSearchQuery] = useState('');
+  const [levelFilter, setLevelFilter] = useState<LogLevel | 'ALL'>('ALL');
+  const [isExpanded, setIsExpanded] = useState(false);
+  const [isStreaming, setIsStreaming] = useState(false);
+  const logContainerRef = useRef<HTMLDivElement>(null);
+
+  // Fetch logs with polling when streaming is enabled
+  const { data, isLoading, refetch } = useQuery({
+    queryKey: ['application-logs', levelFilter, searchQuery],
+    queryFn: () =>
+      supportApi.getLogs({
+        limit: 200,
+        level: levelFilter === 'ALL' ? undefined : levelFilter,
+        search: searchQuery || undefined,
+      }),
+    refetchInterval: isStreaming ? 2000 : false, // Poll every 2 seconds when streaming
+    enabled: isExpanded, // Only fetch when viewer is expanded
+  });
+
+  // Stop streaming when viewer is collapsed
+  useEffect(() => {
+    if (!isExpanded) {
+      setIsStreaming(false);
+    }
+  }, [isExpanded]);
+
+  const clearMutation = useMutation({
+    mutationFn: () => supportApi.clearLogs(),
+    onSuccess: () => {
+      queryClient.invalidateQueries({ queryKey: ['application-logs'] });
+    },
+  });
+
+  // Auto-scroll to bottom when new logs arrive
+  useEffect(() => {
+    if (autoScroll && logContainerRef.current && data?.entries) {
+      logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
+    }
+  }, [data?.entries, autoScroll]);
+
+  const toggleExpand = (index: number) => {
+    setExpandedLogs((prev) => {
+      const newSet = new Set(prev);
+      if (newSet.has(index)) {
+        newSet.delete(index);
+      } else {
+        newSet.add(index);
+      }
+      return newSet;
+    });
+  };
+
+  const formatTimestamp = (timestamp: string) => {
+    // Input format: "2024-01-15 10:30:45,123"
+    const parts = timestamp.split(' ');
+    if (parts.length >= 2) {
+      return parts[1]; // Return just the time part
+    }
+    return timestamp;
+  };
+
+  const entries = useMemo(() => data?.entries ?? [], [data?.entries]);
+
+  // Reverse to show newest at bottom (better for auto-scroll UX)
+  const displayEntries = useMemo(() => [...entries].reverse(), [entries]);
+
+  const LevelIcon = ({ level }: { level: string }) => {
+    const Icon = levelIcons[level as LogLevel] || Info;
+    return <Icon className={`w-3.5 h-3.5 ${levelColors[level as LogLevel] || 'text-gray-400'}`} />;
+  };
+
+  return (
+    <div className="bg-bambu-dark rounded-lg overflow-hidden">
+      {/* Header - always visible */}
+      <button
+        onClick={() => setIsExpanded(!isExpanded)}
+        className="w-full flex items-center justify-between p-4 hover:bg-bambu-dark-tertiary/50 transition-colors"
+      >
+        <div className="flex items-center gap-3">
+          <div
+            className={`p-2 rounded-lg ${
+              isStreaming
+                ? 'bg-bambu-green/20 text-bambu-green'
+                : 'bg-bambu-dark-tertiary text-bambu-gray'
+            }`}
+          >
+            <Bug className="w-5 h-5" />
+          </div>
+          <div className="text-left">
+            <p className="font-medium text-white">Application Logs</p>
+            <p className="text-sm text-bambu-gray">
+              {isStreaming
+                ? `Live streaming - ${data?.filtered_count ?? 0} entries`
+                : 'View and filter application logs'}
+            </p>
+          </div>
+        </div>
+        <div className="flex items-center gap-2">
+          {isStreaming && (
+            <span className="flex items-center gap-1.5 px-2 py-1 bg-bambu-green/20 rounded text-bambu-green text-xs">
+              <span className="w-1.5 h-1.5 bg-bambu-green rounded-full animate-pulse" />
+              Live
+            </span>
+          )}
+          {isExpanded ? (
+            <ChevronUp className="w-5 h-5 text-bambu-gray" />
+          ) : (
+            <ChevronDown className="w-5 h-5 text-bambu-gray" />
+          )}
+        </div>
+      </button>
+
+      {/* Expanded content */}
+      {isExpanded && (
+        <div className="border-t border-bambu-dark-tertiary">
+          {/* Controls */}
+          <div className="flex flex-col gap-2 p-4 border-b border-bambu-dark-tertiary">
+            <div className="flex items-center gap-2 flex-wrap">
+              {/* Start/Stop streaming button */}
+              {isStreaming ? (
+                <button
+                  onClick={(e) => {
+                    e.stopPropagation();
+                    setIsStreaming(false);
+                  }}
+                  className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded transition-colors"
+                >
+                  <Square className="w-4 h-4" />
+                  Stop
+                </button>
+              ) : (
+                <button
+                  onClick={(e) => {
+                    e.stopPropagation();
+                    setIsStreaming(true);
+                    refetch(); // Immediately fetch when starting
+                  }}
+                  className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-bambu-green/20 text-bambu-green hover:bg-bambu-green/30 rounded transition-colors"
+                >
+                  <Play className="w-4 h-4" />
+                  Start
+                </button>
+              )}
+
+              {/* Clear button */}
+              <button
+                onClick={() => clearMutation.mutate()}
+                disabled={clearMutation.isPending || entries.length === 0}
+                className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-bambu-dark-tertiary text-bambu-gray hover:text-white hover:bg-bambu-dark-secondary rounded transition-colors disabled:opacity-50"
+              >
+                <Trash2 className="w-4 h-4" />
+                Clear
+              </button>
+
+              {/* Refresh button */}
+              <button
+                onClick={() => refetch()}
+                disabled={isLoading}
+                className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-bambu-dark-tertiary text-bambu-gray hover:text-white hover:bg-bambu-dark-secondary rounded transition-colors disabled:opacity-50"
+              >
+                <RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
+              </button>
+
+              <div className="flex-1" />
+
+              {/* Auto-scroll toggle */}
+              <label className="flex items-center gap-2 text-sm text-bambu-gray cursor-pointer">
+                <input
+                  type="checkbox"
+                  checked={autoScroll}
+                  onChange={(e) => setAutoScroll(e.target.checked)}
+                  className="rounded border-bambu-dark-tertiary bg-bambu-dark-tertiary"
+                />
+                Auto-scroll
+              </label>
+
+              {/* Entry count */}
+              <span className="text-sm text-bambu-gray">
+                {data?.filtered_count ?? 0}/{data?.total_in_file ?? 0}
+              </span>
+            </div>
+
+            {/* Search and Filter Row */}
+            <div className="flex items-center gap-2">
+              {/* Search input */}
+              <div className="relative flex-1">
+                <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
+                <input
+                  type="text"
+                  placeholder="Search message or logger name..."
+                  value={searchQuery}
+                  onChange={(e) => setSearchQuery(e.target.value)}
+                  className="w-full pl-8 pr-8 py-1.5 text-sm bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                />
+                {searchQuery && (
+                  <button
+                    onClick={() => setSearchQuery('')}
+                    className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
+                  >
+                    <X className="w-4 h-4" />
+                  </button>
+                )}
+              </div>
+
+              {/* Level filter */}
+              <div className="flex items-center gap-1 bg-bambu-dark-secondary rounded border border-bambu-dark-tertiary">
+                <button
+                  onClick={() => setLevelFilter('ALL')}
+                  className={`px-2 py-1.5 text-xs rounded-l transition-colors ${
+                    levelFilter === 'ALL'
+                      ? 'bg-bambu-green text-white'
+                      : 'text-bambu-gray hover:text-white'
+                  }`}
+                >
+                  All
+                </button>
+                {LOG_LEVELS.map((level, idx) => (
+                  <button
+                    key={level}
+                    onClick={() => setLevelFilter(level)}
+                    className={`px-2 py-1.5 text-xs transition-colors flex items-center gap-1 ${
+                      idx === LOG_LEVELS.length - 1 ? 'rounded-r' : ''
+                    } ${
+                      levelFilter === level
+                        ? `${levelColors[level]} bg-bambu-dark-tertiary`
+                        : 'text-bambu-gray hover:text-white'
+                    }`}
+                  >
+                    {level}
+                  </button>
+                ))}
+              </div>
+            </div>
+          </div>
+
+          {/* Log Content */}
+          <div
+            ref={logContainerRef}
+            className="overflow-auto font-mono text-xs bg-black min-h-[300px] max-h-[500px]"
+          >
+            {entries.length === 0 ? (
+              <div className="flex flex-col items-center justify-center h-[300px] text-bambu-gray">
+                <p className="mb-2">No log entries found</p>
+                <p className="text-sm">Log file may be empty or cleared</p>
+              </div>
+            ) : (
+              <div className="divide-y divide-bambu-dark-tertiary/30">
+                {displayEntries.map((log: LogEntry, index: number) => {
+                  const isEntryExpanded = expandedLogs.has(index);
+                  const hasMultiLine = log.message.includes('\n');
+
+                  return (
+                    <div
+                      key={index}
+                      className={`p-2 cursor-pointer hover:bg-bambu-dark-secondary/50 transition-colors ${
+                        isEntryExpanded ? 'bg-bambu-dark-secondary/30' : ''
+                      }`}
+                      onClick={() => hasMultiLine && toggleExpand(index)}
+                    >
+                      <div className="flex items-start gap-2">
+                        <span className="text-bambu-gray/70 shrink-0 w-20">
+                          {formatTimestamp(log.timestamp)}
+                        </span>
+                        <span className="shrink-0">
+                          <LevelIcon level={log.level} />
+                        </span>
+                        <span className="text-purple-400/80 shrink-0 max-w-[200px] truncate" title={log.logger_name}>
+                          [{log.logger_name}]
+                        </span>
+                        <span
+                          className={`flex-1 ${levelColors[log.level as LogLevel] || 'text-white/80'} ${
+                            !isEntryExpanded && hasMultiLine ? 'truncate' : ''
+                          }`}
+                        >
+                          {isEntryExpanded ? (
+                            <pre className="whitespace-pre-wrap break-all">{log.message}</pre>
+                          ) : (
+                            log.message.split('\n')[0]
+                          )}
+                        </span>
+                        {hasMultiLine && (
+                          <span className="text-bambu-gray/50 shrink-0">
+                            {isEntryExpanded ? (
+                              <ChevronUp className="w-3.5 h-3.5" />
+                            ) : (
+                              <ChevronDown className="w-3.5 h-3.5" />
+                            )}
+                          </span>
+                        )}
+                      </div>
+                    </div>
+                  );
+                })}
+              </div>
+            )}
+          </div>
+
+          {/* Footer */}
+          <div className="flex items-center justify-between p-3 border-t border-bambu-dark-tertiary text-sm text-bambu-gray">
+            {isStreaming ? (
+              <span className="flex items-center gap-2">
+                <span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
+                Auto-refreshing every 2 seconds
+              </span>
+            ) : (
+              <span>Click Start to enable live log streaming</span>
+            )}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}

+ 4 - 0
frontend/src/pages/SystemInfoPage.tsx

@@ -24,6 +24,7 @@ import {
 } from 'lucide-react';
 } from 'lucide-react';
 import { api, supportApi } from '../api/client';
 import { api, supportApi } from '../api/client';
 import { Card } from '../components/Card';
 import { Card } from '../components/Card';
+import { LogViewer } from '../components/LogViewer';
 import { formatDateTime, type TimeFormat } from '../utils/date';
 import { formatDateTime, type TimeFormat } from '../utils/date';
 
 
 function formatBytes(bytes: number): string {
 function formatBytes(bytes: number): string {
@@ -341,6 +342,9 @@ export function SystemInfoPage() {
               {t('support.privacyNote', 'IP addresses in logs are replaced with [IP] and email addresses with [EMAIL].')}
               {t('support.privacyNote', 'IP addresses in logs are replaced with [IP] and email addresses with [EMAIL].')}
             </p>
             </p>
           </div>
           </div>
+
+          {/* Log Viewer */}
+          <LogViewer />
         </div>
         </div>
       </Section>
       </Section>
 
 

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-5D76qqt8.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-C__bvgqz.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DsI-2RLx.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DECM0_oh.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-5D76qqt8.css">
+    <script type="module" crossorigin src="/assets/index-DsI-2RLx.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-C__bvgqz.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff