Bladeren bron

feat(ui+led): add log search, fix PWA toast positioning, and respect LED power state on Still Sands exit

Add real-time search filtering to the log drawer (by message/logger),
use CSS env(safe-area-inset-top) for Sonner toasts instead of hardcoded
offset to fix Dynamic Island obstruction in PWA, and prevent LEDs from
auto-powering on after Still Sands when no playing/idle effect is configured
so manually-set effects persist through scheduled pause cycles.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris 4 dagen geleden
bovenliggende
commit
ada774d279

+ 33 - 9
frontend/src/components/layout/Layout.tsx

@@ -1,5 +1,5 @@
 import { Outlet, Link, useLocation } from 'react-router-dom'
-import { useEffect, useState, useRef, useCallback } from 'react'
+import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
 import { toast } from 'sonner'
 import { NowPlayingBar } from '@/components/NowPlayingBar'
 import { Button } from '@/components/ui/button'
@@ -128,6 +128,8 @@ export function Layout() {
   const startYRef = useRef(0)
   const startHeightRef = useRef(0)
 
+  const [logSearchQuery, setLogSearchQuery] = useState('')
+
   // Handle drawer resize
   const handleResizeStart = (e: React.MouseEvent | React.TouchEvent) => {
     e.preventDefault()
@@ -562,10 +564,20 @@ export function Layout() {
     setIsLogsOpen((prev) => !prev)
   }
 
-  // Filter logs by level
-  const filteredLogs = logLevelFilter === 'ALL'
-    ? logs
-    : logs.filter((log) => log.level === logLevelFilter)
+  // Filter logs by level and search query
+  const filteredLogs = useMemo(() => {
+    let result = logLevelFilter === 'ALL'
+      ? logs
+      : logs.filter((log) => log.level === logLevelFilter)
+    if (logSearchQuery) {
+      const q = logSearchQuery.toLowerCase()
+      result = result.filter((log) =>
+        log.message?.toLowerCase().includes(q) ||
+        log.logger?.toLowerCase().includes(q)
+      )
+    }
+    return result
+  }, [logs, logLevelFilter, logSearchQuery])
 
   // Format timestamp safely
   const formatTimestamp = (timestamp: string) => {
@@ -1711,9 +1723,9 @@ export function Layout() {
             </div>
 
             {/* Logs Header */}
-            <div className="flex items-center justify-between px-4 py-2 border-b bg-muted/50">
-              <div className="flex items-center gap-3">
-                <span className="text-sm font-medium">Application Logs</span>
+            <div className="flex items-center justify-between px-4 py-2 border-b bg-muted/50 gap-2">
+              <div className="flex items-center gap-2 sm:gap-3 flex-wrap min-w-0">
+                <span className="text-sm font-medium whitespace-nowrap">Application Logs</span>
                 <select
                   value={logLevelFilter}
                   onChange={(e) => setLogLevelFilter(e.target.value)}
@@ -1725,13 +1737,25 @@ export function Layout() {
                   <option value="WARNING">Warning</option>
                   <option value="ERROR">Error</option>
                 </select>
+                <input
+                  type="text"
+                  value={logSearchQuery}
+                  onChange={(e) => setLogSearchQuery(e.target.value)}
+                  placeholder="Search logs..."
+                  className="text-xs bg-background border rounded px-2 py-1 w-28 sm:w-40"
+                />
+                {logSearchQuery && (
+                  <Button variant="ghost" size="icon-sm" onClick={() => setLogSearchQuery('')} className="rounded-full" title="Clear search">
+                    <span className="material-icons-outlined text-sm">close</span>
+                  </Button>
+                )}
                 <span className="text-xs text-muted-foreground">
                   {filteredLogs.length}{logsTotal > 0 ? ` of ${logsTotal}` : ''} entries
                   {logsHasMore && <span className="text-primary ml-1">↑ scroll for more</span>}
                 </span>
               </div>
 
-              <div className="flex items-center gap-1">
+              <div className="flex items-center gap-1 shrink-0">
                 <Button
                   variant="ghost"
                   size="icon-sm"

+ 0 - 10
frontend/src/components/ui/sonner.tsx

@@ -5,18 +5,12 @@ type ToasterProps = React.ComponentProps<typeof Sonner>
 
 const Toaster = ({ ...props }: ToasterProps) => {
   const [theme, setTheme] = useState<"light" | "dark">("light")
-  const [isStandalone, setIsStandalone] = useState(false)
 
   useEffect(() => {
     // Check initial theme
     const isDark = document.documentElement.classList.contains("dark")
     setTheme(isDark ? "dark" : "light")
 
-    // Check if running as PWA (standalone mode)
-    const standalone = window.matchMedia('(display-mode: standalone)').matches ||
-      (window.navigator as unknown as { standalone?: boolean }).standalone === true
-    setIsStandalone(standalone)
-
     // Watch for theme changes
     const observer = new MutationObserver((mutations) => {
       mutations.forEach((mutation) => {
@@ -31,14 +25,10 @@ const Toaster = ({ ...props }: ToasterProps) => {
     return () => observer.disconnect()
   }, [])
 
-  // Use larger offset for PWA to account for Dynamic Island/notch (59px typical + 16px padding)
-  const offset = isStandalone ? 75 : 16
-
   return (
     <Sonner
       theme={theme}
       className="toaster group"
-      offset={offset}
       toastOptions={{
         classNames: {
           toast:

+ 5 - 0
frontend/src/index.css

@@ -103,6 +103,11 @@ body {
   min-height: 100dvh; /* Use dynamic viewport height for mobile */
 }
 
+/* Push Sonner toasts below the Dynamic Island / notch in PWA mode */
+[data-sonner-toaster][data-y-position="top"] {
+  top: env(safe-area-inset-top, 0px) !important;
+}
+
 /* Safe area utilities for iOS notch/Dynamic Island/home indicator */
 .pt-safe {
   padding-top: env(safe-area-inset-top, 0px);

+ 26 - 23
modules/core/pattern_manager.py

@@ -11,8 +11,6 @@ from modules.core.state import state
 from math import pi, isnan, isinf
 import asyncio
 import json
-# Import for legacy support, but we'll use LED interface through state
-from modules.led.led_controller import effect_playing, effect_idle
 from modules.led.idle_timeout_manager import idle_timeout_manager
 import queue
 from dataclasses import dataclass
@@ -361,7 +359,10 @@ async def start_idle_led_timeout(check_still_sands: bool = True):
         await state.led_controller.set_power_async(0)
         return
 
-    # Normal flow: show idle effect
+    # Normal flow: show idle effect (only if one is configured)
+    if not state.dw_led_idle_effect:
+        logger.debug("No idle effect configured, leaving LEDs unchanged")
+        return
     await state.led_controller.effect_idle_async(state.dw_led_idle_effect)
 
     # Start timeout if enabled
@@ -1211,7 +1212,7 @@ async def _execute_pattern_internal(file_path):
 
     start_time = time.time()
     total_pause_time = 0  # Track total time spent paused (manual + scheduled)
-    if state.led_controller:
+    if state.led_controller and state.dw_led_playing_effect:
         logger.info(f"Setting LED to playing effect: {state.dw_led_playing_effect}")
         await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
         # Cancel idle timeout when playing starts
@@ -1323,13 +1324,18 @@ async def _execute_pattern_internal(file_path):
                 logger.info("Execution resumed...")
                 if state.led_controller:
                     # Turn LED controller back on if it was turned off for scheduled pause
+                    # Only power on if a playing effect is configured, otherwise leave LEDs off
                     if wled_was_off_for_scheduled:
-                        logger.info("Turning LED lights back on as Still Sands period ended")
-                        await state.led_controller.set_power_async(1)
-                        # CRITICAL: Give LED controller time to fully power on before sending more commands
-                        # Without this delay, rapid-fire requests can crash controllers on resource-constrained Pis
-                        await asyncio.sleep(0.5)
-                    await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
+                        if state.dw_led_playing_effect:
+                            logger.info("Turning LED lights back on as Still Sands period ended")
+                            await state.led_controller.set_power_async(1)
+                            # CRITICAL: Give LED controller time to fully power on before sending more commands
+                            # Without this delay, rapid-fire requests can crash controllers on resource-constrained Pis
+                            await asyncio.sleep(0.5)
+                        else:
+                            logger.info("No playing effect configured, keeping LEDs off after Still Sands")
+                    if state.dw_led_playing_effect:
+                        await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
                     # Cancel idle timeout when resuming from pause
                     idle_timeout_manager.cancel_timeout()
 
@@ -1429,7 +1435,7 @@ async def run_theta_rho_file(file_path, is_playlist=False, clear_pattern=None, c
             return
 
         # Run the main pattern
-        completed = await _execute_pattern_internal(file_path)
+        await _execute_pattern_internal(file_path)
 
         # Only clear state if not part of a playlist
         if not is_playlist:
@@ -1456,11 +1462,6 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
     """
     state.stop_requested = False
 
-    # Track whether we actually started executing patterns.
-    # If cancelled before execution begins (e.g., by TestClient cleanup),
-    # we should NOT clear state that was set by the caller.
-    task_started_execution = False
-
     # Reset LED idle timeout activity time when playlist starts
     import time as time_module
     state.dw_led_last_activity_time = time_module.time()
@@ -1513,9 +1514,6 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                 file_path = state.current_playlist[idx]
                 logger.info(f"Running pattern {idx + 1}/{len(state.current_playlist)}: {file_path}")
 
-                # Mark that we've started actual execution (for cleanup logic)
-                task_started_execution = True
-
                 # Clear pause state when starting a new pattern (prevents stale "waiting" UI)
                 state.pause_time_remaining = 0
                 state.original_pause_time = None
@@ -1555,11 +1553,16 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                     if result == 'completed':
                         logger.info("Still Sands period ended. Resuming playlist...")
                         if state.led_controller:
+                            # Only power on if a playing effect is configured, otherwise leave LEDs off
                             if wled_was_off_for_scheduled:
-                                logger.info("Turning LED lights back on as Still Sands period ended")
-                                await state.led_controller.set_power_async(1)
-                                await asyncio.sleep(0.5)
-                            await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
+                                if state.dw_led_playing_effect:
+                                    logger.info("Turning LED lights back on as Still Sands period ended")
+                                    await state.led_controller.set_power_async(1)
+                                    await asyncio.sleep(0.5)
+                                else:
+                                    logger.info("No playing effect configured, keeping LEDs off after Still Sands")
+                            if state.dw_led_playing_effect:
+                                await state.led_controller.effect_playing_async(state.dw_led_playing_effect)
                             idle_timeout_manager.cancel_timeout()
 
                 # Handle pause between patterns