瀏覽代碼

extend logs

tuanchris 1 周之前
父節點
當前提交
282b3418a1
共有 5 個文件被更改,包括 187 次插入32 次删除
  1. 2 1
      .gitignore
  2. 90 14
      frontend/src/components/layout/Layout.tsx
  3. 17 10
      main.py
  4. 52 4
      modules/connection/connection_manager.py
  5. 26 3
      modules/core/log_handler.py

+ 2 - 1
.gitignore

@@ -29,4 +29,5 @@ node_modules/
 static/custom/*
 static/custom/*
 !static/custom/.gitkeep
 !static/custom/.gitkeep
 .claude/
 .claude/
-static/dist/
+static/dist/
+.planning

+ 90 - 14
frontend/src/components/layout/Layout.tsx

@@ -1,5 +1,5 @@
 import { Outlet, Link, useLocation } from 'react-router-dom'
 import { Outlet, Link, useLocation } from 'react-router-dom'
-import { useEffect, useState, useRef } from 'react'
+import { useEffect, useState, useRef, useCallback } from 'react'
 import { toast } from 'sonner'
 import { toast } from 'sonner'
 import { NowPlayingBar } from '@/components/NowPlayingBar'
 import { NowPlayingBar } from '@/components/NowPlayingBar'
 import { Button } from '@/components/ui/button'
 import { Button } from '@/components/ui/button'
@@ -189,8 +189,12 @@ export function Layout() {
   }, [])
   }, [])
   const [logs, setLogs] = useState<Array<{ timestamp: string; level: string; logger: string; message: string }>>([])
   const [logs, setLogs] = useState<Array<{ timestamp: string; level: string; logger: string; message: string }>>([])
   const [logLevelFilter, setLogLevelFilter] = useState<string>('ALL')
   const [logLevelFilter, setLogLevelFilter] = useState<string>('ALL')
+  const [logsTotal, setLogsTotal] = useState(0)
+  const [logsHasMore, setLogsHasMore] = useState(false)
+  const [isLoadingMoreLogs, setIsLoadingMoreLogs] = useState(false)
   const logsWsRef = useRef<WebSocket | null>(null)
   const logsWsRef = useRef<WebSocket | null>(null)
   const logsContainerRef = useRef<HTMLDivElement>(null)
   const logsContainerRef = useRef<HTMLDivElement>(null)
+  const logsLoadedCountRef = useRef(0) // Track how many logs we've loaded (for offset)
 
 
   // Check device connection status via WebSocket
   // Check device connection status via WebSocket
   // This effect runs once on mount and manages its own reconnection logic
   // This effect runs once on mount and manages its own reconnection logic
@@ -348,17 +352,21 @@ export function Layout() {
 
 
     let shouldConnect = true
     let shouldConnect = true
 
 
-    // Fetch initial logs
+    // Fetch initial logs (most recent)
     const fetchInitialLogs = async () => {
     const fetchInitialLogs = async () => {
       try {
       try {
         type LogEntry = { timestamp: string; level: string; logger: string; message: string }
         type LogEntry = { timestamp: string; level: string; logger: string; message: string }
-        const data = await apiClient.get<{ logs: LogEntry[] }>('/api/logs?limit=200')
+        type LogsResponse = { logs: LogEntry[]; total: number; has_more: boolean }
+        const data = await apiClient.get<LogsResponse>('/api/logs?limit=200')
         // Filter out empty/invalid log entries
         // Filter out empty/invalid log entries
         const validLogs = (data.logs || []).filter(
         const validLogs = (data.logs || []).filter(
           (log) => log && log.message && log.message.trim() !== ''
           (log) => log && log.message && log.message.trim() !== ''
         )
         )
         // API returns newest first, reverse to show oldest first (newest at bottom)
         // API returns newest first, reverse to show oldest first (newest at bottom)
         setLogs(validLogs.reverse())
         setLogs(validLogs.reverse())
+        setLogsTotal(data.total || 0)
+        setLogsHasMore(data.has_more || false)
+        logsLoadedCountRef.current = validLogs.length
         // Scroll to bottom after initial load
         // Scroll to bottom after initial load
         setTimeout(() => {
         setTimeout(() => {
           if (logsContainerRef.current) {
           if (logsContainerRef.current) {
@@ -416,18 +424,16 @@ export function Layout() {
           if (!log || !log.message || log.message.trim() === '') {
           if (!log || !log.message || log.message.trim() === '') {
             return
             return
           }
           }
-          setLogs((prev) => {
-            const newLogs = [...prev, log]
-            // Keep only last 500 logs to prevent memory issues
-            if (newLogs.length > 500) {
-              return newLogs.slice(-500)
-            }
-            return newLogs
-          })
-          // Auto-scroll to bottom
+          // Append new log - no limit, lazy loading handles old logs
+          setLogs((prev) => [...prev, log])
+          // Auto-scroll to bottom if user is near the bottom
           setTimeout(() => {
           setTimeout(() => {
             if (logsContainerRef.current) {
             if (logsContainerRef.current) {
-              logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight
+              const { scrollTop, scrollHeight, clientHeight } = logsContainerRef.current
+              // Only auto-scroll if user is within 100px of the bottom
+              if (scrollHeight - scrollTop - clientHeight < 100) {
+                logsContainerRef.current.scrollTop = scrollHeight
+              }
             }
             }
           }, 10)
           }, 10)
         } catch {
         } catch {
@@ -469,6 +475,62 @@ export function Layout() {
     // Also reconnect when active table changes
     // Also reconnect when active table changes
   }, [isLogsOpen, activeTable?.id])
   }, [isLogsOpen, activeTable?.id])
 
 
+  // Load older logs when user scrolls to top (lazy loading)
+  const loadOlderLogs = useCallback(async () => {
+    if (isLoadingMoreLogs || !logsHasMore) return
+
+    setIsLoadingMoreLogs(true)
+    try {
+      type LogEntry = { timestamp: string; level: string; logger: string; message: string }
+      type LogsResponse = { logs: LogEntry[]; total: number; has_more: boolean }
+      const offset = logsLoadedCountRef.current
+      const data = await apiClient.get<LogsResponse>(`/api/logs?limit=100&offset=${offset}`)
+
+      const validLogs = (data.logs || []).filter(
+        (log) => log && log.message && log.message.trim() !== ''
+      )
+
+      if (validLogs.length > 0) {
+        // Prepend older logs (they come newest-first, so reverse them)
+        setLogs((prev) => [...validLogs.reverse(), ...prev])
+        logsLoadedCountRef.current += validLogs.length
+        setLogsHasMore(data.has_more || false)
+        setLogsTotal(data.total || 0)
+
+        // Maintain scroll position after prepending
+        setTimeout(() => {
+          if (logsContainerRef.current) {
+            // Calculate approximate height of new content (rough estimate: 24px per log line)
+            const newContentHeight = validLogs.length * 24
+            logsContainerRef.current.scrollTop = newContentHeight
+          }
+        }, 10)
+      } else {
+        setLogsHasMore(false)
+      }
+    } catch {
+      // Ignore errors
+    } finally {
+      setIsLoadingMoreLogs(false)
+    }
+  }, [isLoadingMoreLogs, logsHasMore])
+
+  // Scroll event handler for lazy loading
+  useEffect(() => {
+    const container = logsContainerRef.current
+    if (!container || !isLogsOpen) return
+
+    const handleScroll = () => {
+      // Load more when scrolled to top (within 50px)
+      if (container.scrollTop < 50 && logsHasMore && !isLoadingMoreLogs) {
+        loadOlderLogs()
+      }
+    }
+
+    container.addEventListener('scroll', handleScroll)
+    return () => container.removeEventListener('scroll', handleScroll)
+  }, [isLogsOpen, logsHasMore, isLoadingMoreLogs, loadOlderLogs])
+
   const handleToggleLogs = () => {
   const handleToggleLogs = () => {
     setIsLogsOpen((prev) => !prev)
     setIsLogsOpen((prev) => !prev)
   }
   }
@@ -1421,7 +1483,8 @@ export function Layout() {
                   <option value="ERROR">Error</option>
                   <option value="ERROR">Error</option>
                 </select>
                 </select>
                 <span className="text-xs text-muted-foreground">
                 <span className="text-xs text-muted-foreground">
-                  {filteredLogs.length} entries
+                  {filteredLogs.length}{logsTotal > 0 ? ` of ${logsTotal}` : ''} entries
+                  {logsHasMore && <span className="text-primary ml-1">↑ scroll for more</span>}
                 </span>
                 </span>
               </div>
               </div>
 
 
@@ -1461,6 +1524,19 @@ export function Layout() {
               ref={logsContainerRef}
               ref={logsContainerRef}
               className="h-[calc(100%-40px)] overflow-auto overscroll-contain p-3 font-mono text-xs space-y-0.5"
               className="h-[calc(100%-40px)] overflow-auto overscroll-contain p-3 font-mono text-xs space-y-0.5"
             >
             >
+              {/* Loading indicator for older logs */}
+              {isLoadingMoreLogs && (
+                <div className="flex items-center justify-center gap-2 py-2 text-muted-foreground">
+                  <span className="material-icons-outlined text-sm animate-spin">sync</span>
+                  <span>Loading older logs...</span>
+                </div>
+              )}
+              {/* Load more hint */}
+              {logsHasMore && !isLoadingMoreLogs && (
+                <div className="text-center py-2 text-muted-foreground text-xs">
+                  ↑ Scroll up to load older logs
+                </div>
+              )}
               {filteredLogs.length > 0 ? (
               {filteredLogs.length > 0 ? (
                 filteredLogs.map((log, i) => (
                 filteredLogs.map((log, i) => (
                   <div key={i} className="py-0.5 flex gap-2">
                   <div key={i} className="py-0.5 flex gap-2">

+ 17 - 10
main.py

@@ -47,7 +47,8 @@ logging.basicConfig(
 )
 )
 
 
 # Initialize memory log handler for web UI log viewer
 # Initialize memory log handler for web UI log viewer
-init_memory_handler(max_entries=500)
+# Increased to 5000 entries to support lazy loading in the UI
+init_memory_handler(max_entries=5000)
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -572,26 +573,32 @@ async def websocket_logs_endpoint(websocket: WebSocket):
 
 
 # API endpoint to retrieve logs
 # API endpoint to retrieve logs
 @app.get("/api/logs", tags=["logs"])
 @app.get("/api/logs", tags=["logs"])
-async def get_logs(limit: int = 100, level: str = None):
+async def get_logs(limit: int = 100, level: str = None, offset: int = 0):
     """
     """
-    Retrieve application logs from memory buffer.
+    Retrieve application logs from memory buffer with pagination.
 
 
     Args:
     Args:
-        limit: Maximum number of log entries to return (default: 100, max: 500)
+        limit: Maximum number of log entries to return (default: 100)
         level: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
         level: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
+        offset: Number of entries to skip from newest (for lazy loading older logs)
 
 
     Returns:
     Returns:
         List of log entries with timestamp, level, logger, and message.
         List of log entries with timestamp, level, logger, and message.
+        Also returns total count and whether there are more logs available.
     """
     """
     handler = get_memory_handler()
     handler = get_memory_handler()
     if not handler:
     if not handler:
-        return {"logs": [], "error": "Log handler not initialized"}
+        return {"logs": [], "count": 0, "total": 0, "has_more": False, "error": "Log handler not initialized"}
 
 
-    # Clamp limit to reasonable range
-    limit = max(1, min(limit, 500))
+    # Clamp limit to reasonable range (no max limit for lazy loading)
+    limit = max(1, limit)
+    offset = max(0, offset)
 
 
-    logs = handler.get_logs(limit=limit, level=level)
-    return {"logs": logs, "count": len(logs)}
+    logs = handler.get_logs(limit=limit, level=level, offset=offset)
+    total = handler.get_total_count(level=level)
+    has_more = offset + len(logs) < total
+
+    return {"logs": logs, "count": len(logs), "total": total, "has_more": has_more}
 
 
 
 
 @app.delete("/api/logs", tags=["logs"])
 @app.delete("/api/logs", tags=["logs"])
@@ -3162,7 +3169,7 @@ async def preview_thr_batch(request: dict):
     # Convert results to dictionary
     # Convert results to dictionary
     results = dict(file_results)
     results = dict(file_results)
 
 
-    logger.info(f"Total batch processing time: {time.time() - start:.2f}s for {len(file_names)} files")
+    logger.debug(f"Total batch processing time: {time.time() - start:.2f}s for {len(file_names)} files")
     return JSONResponse(content=results, headers=headers)
     return JSONResponse(content=results, headers=headers)
 
 
 @app.get("/playlists")
 @app.get("/playlists")

+ 52 - 4
modules/connection/connection_manager.py

@@ -229,6 +229,7 @@ def device_init(homing=True):
     # This clears any pending commands and resets position counters to 0
     # This clears any pending commands and resets position counters to 0
     logger.info("Performing soft reset for clean controller state...")
     logger.info("Performing soft reset for clean controller state...")
     perform_soft_reset_sync()
     perform_soft_reset_sync()
+    time.sleep(1)  # Extra stabilization after controller restart
 
 
     # Reset work coordinate offsets for a clean start
     # Reset work coordinate offsets for a clean start
     # This ensures we're using work coordinates (G54) starting from 0
     # This ensures we're using work coordinates (G54) starting from 0
@@ -988,12 +989,20 @@ def home(timeout=120):
                 state.homed_x = False
                 state.homed_x = False
                 state.homed_y = False
                 state.homed_y = False
 
 
+                # Clear any stale data from previous operations
+                try:
+                    while state.conn.in_waiting() > 0:
+                        stale = state.conn.readline()
+                        logger.debug(f"Cleared stale data before homing: {stale}")
+                except Exception:
+                    pass
+
                 # Send $H command
                 # Send $H command
                 state.conn.send("$H\n")
                 state.conn.send("$H\n")
                 logger.info("Sent $H command, waiting for homing messages...")
                 logger.info("Sent $H command, waiting for homing messages...")
 
 
                 # Wait for [MSG:Homed:X] and [MSG:Homed:Y] messages
                 # Wait for [MSG:Homed:X] and [MSG:Homed:Y] messages
-                max_wait_time = 30  # 30 seconds timeout for homing messages
+                max_wait_time = 60  # 60 seconds - boot recovery needs more time
                 start_time = time.time()
                 start_time = time.time()
 
 
                 while (time.time() - start_time) < max_wait_time:
                 while (time.time() - start_time) < max_wait_time:
@@ -1034,10 +1043,49 @@ def home(timeout=120):
                     homing_complete.set()
                     homing_complete.set()
                     return
                     return
 
 
-                # Skip zeroing if X homed but Y failed - moving Y to 0 would crash it
-                # (Y controls rho/radial position which is unknown if Y didn't home)
+                # If X homed but Y failed, fallback to crash homing for Y
                 if state.homed_x and not state.homed_y:
                 if state.homed_x and not state.homed_y:
-                    logger.warning("Skipping position zeroing - X homed but Y failed (would crash Y axis)")
+                    logger.warning("Sensor homing incomplete (Y failed) - falling back to crash homing")
+
+                    # Perform crash homing as fallback
+                    logger.info(f"Executing crash homing fallback at {homing_speed} mm/min")
+
+                    loop = asyncio.new_event_loop()
+                    asyncio.set_event_loop(loop)
+                    try:
+                        if effective_table_type == 'dune_weaver_mini':
+                            result = loop.run_until_complete(send_grbl_coordinates(0, -30, homing_speed, home=True))
+                            if result == False:
+                                logger.error("Crash homing fallback failed")
+                                homing_complete.set()
+                                return
+                        else:
+                            result = loop.run_until_complete(send_grbl_coordinates(0, -22, homing_speed, home=True))
+                            if result == False:
+                                logger.error("Crash homing fallback failed")
+                                homing_complete.set()
+                                return
+                    finally:
+                        loop.close()
+
+                    # Wait for idle after crash homing
+                    logger.info("Waiting for device to reach idle state after crash homing fallback...")
+                    idle_reached = check_idle()
+                    if not idle_reached:
+                        logger.error("Device did not reach idle state after crash homing fallback")
+                        homing_complete.set()
+                        return
+
+                    # Set position like crash homing does
+                    state.current_theta = 0
+                    state.current_rho = 0
+                    logger.info("Crash homing fallback completed - theta=0, rho=0")
+
+                elif not state.homed_x and not state.homed_y:
+                    # Neither axis homed - this is a failure, don't proceed
+                    logger.error("Sensor homing failed - neither axis homed")
+                    homing_complete.set()
+                    return
                 else:
                 else:
                     # Send x0 y0 to zero both positions using send_grbl_coordinates
                     # Send x0 y0 to zero both positions using send_grbl_coordinates
                     logger.info(f"Zeroing positions with x0 y0 f{homing_speed}")
                     logger.info(f"Zeroing positions with x0 y0 f{homing_speed}")

+ 26 - 3
modules/core/log_handler.py

@@ -75,13 +75,14 @@ class MemoryLogHandler(logging.Handler):
             "module": record.module,
             "module": record.module,
         }
         }
 
 
-    def get_logs(self, limit: int = None, level: str = None) -> List[Dict[str, Any]]:
+    def get_logs(self, limit: int = None, level: str = None, offset: int = 0) -> List[Dict[str, Any]]:
         """
         """
-        Retrieve stored log entries.
+        Retrieve stored log entries with pagination support.
 
 
         Args:
         Args:
             limit: Maximum number of entries to return (newest first).
             limit: Maximum number of entries to return (newest first).
             level: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
             level: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
+            offset: Number of entries to skip from the newest (for pagination).
 
 
         Returns:
         Returns:
             List of log entries as dictionaries.
             List of log entries as dictionaries.
@@ -94,13 +95,35 @@ class MemoryLogHandler(logging.Handler):
             level_upper = level.upper()
             level_upper = level.upper()
             logs = [log for log in logs if log["level"] == level_upper]
             logs = [log for log in logs if log["level"] == level_upper]
 
 
-        # Return newest first, with optional limit
+        # Return newest first
         logs.reverse()
         logs.reverse()
+
+        # Apply offset for pagination
+        if offset > 0:
+            logs = logs[offset:]
+
+        # Apply limit
         if limit:
         if limit:
             logs = logs[:limit]
             logs = logs[:limit]
 
 
         return logs
         return logs
 
 
+    def get_total_count(self, level: str = None) -> int:
+        """
+        Get total count of log entries (optionally filtered by level).
+
+        Args:
+            level: Filter by log level (DEBUG, INFO, WARNING, ERROR, CRITICAL).
+
+        Returns:
+            Total count of matching log entries.
+        """
+        with self._lock:
+            if not level:
+                return len(self._buffer)
+            level_upper = level.upper()
+            return sum(1 for log in self._buffer if log["level"] == level_upper)
+
     def clear(self) -> None:
     def clear(self) -> None:
         """Clear all stored log entries."""
         """Clear all stored log entries."""
         with self._lock:
         with self._lock: