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
This commit is contained in:
maziggy
2026-01-20 16:11:45 +01:00
parent 42baa7ed0f
commit 90249d2367
11 changed files with 1236 additions and 436 deletions
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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)
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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>