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
   - New viewers are automatically offset to prevent stacking
   - 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:
   - Single modal handles reprint, add-to-queue, and edit-queue-item operations
   - Consistent UI/UX across all print operations

+ 1 - 0
README.md

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

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

@@ -5,10 +5,11 @@ import json
 import logging
 import os
 import platform
+import re
 import zipfile
 from datetime import datetime
 
-from fastapi import APIRouter, HTTPException
+from fastapi import APIRouter, HTTPException, Query
 from fastapi.responses import StreamingResponse
 from pydantic import BaseModel
 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:
     """Remove username from paths for privacy."""
-    import re
 
     # Replace /home/username/ or /Users/username/ with /home/[user]/
     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;
 }
 
+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
 export const supportApi = {
   getDebugLoggingState: () =>
@@ -3111,4 +3124,16 @@ export const supportApi = {
     document.body.removeChild(a);
     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';
 import { api, supportApi } from '../api/client';
 import { Card } from '../components/Card';
+import { LogViewer } from '../components/LogViewer';
 import { formatDateTime, type TimeFormat } from '../utils/date';
 
 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].')}
             </p>
           </div>
+
+          {/* Log Viewer */}
+          <LogViewer />
         </div>
       </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 -->
     <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>
   <body>
     <div id="root"></div>

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