ソースを参照

Add backend connection blocking overlay and fix TypeScript errors

- Add blocking overlay when backend WebSocket disconnects
- Show connection status with reconnection attempts
- Display live logs in blocking overlay (polls every 3s)
- Fix TypeScript error in NowPlayingBar (null index type)
- Fix default dev server port back to 5173 (port 80 requires root)
- Clean up .gitignore for static/dist/

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris 3 週間 前
コミット
563dd5f34c

+ 1 - 1
.gitignore

@@ -16,5 +16,5 @@ node_modules/
 # Custom branding assets (user uploads)
 # Custom branding assets (user uploads)
 static/custom/*
 static/custom/*
 !static/custom/.gitkeep
 !static/custom/.gitkeep
-.claude/static/dist/
 .claude/
 .claude/
+static/dist/

+ 3 - 2
frontend/.env.example

@@ -1,5 +1,6 @@
 # Frontend Development Server Configuration
 # Frontend Development Server Configuration
 # Copy this file to .env to customize
 # Copy this file to .env to customize
 
 
-# Dev server port (default: 80)
-PORT=80
+# Dev server port (default: 5173)
+# Note: Ports below 1024 require root privileges on macOS/Linux
+PORT=5173

+ 5 - 4
frontend/src/components/NowPlayingBar.tsx

@@ -91,16 +91,17 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, onClose }: NowPla
 
 
   // Fetch preview image when current file changes
   // Fetch preview image when current file changes
   useEffect(() => {
   useEffect(() => {
-    if (status?.current_file) {
+    const currentFile = status?.current_file
+    if (currentFile) {
       fetch('/preview_thr_batch', {
       fetch('/preview_thr_batch', {
         method: 'POST',
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },
         headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ file_names: [status.current_file] }),
+        body: JSON.stringify({ file_names: [currentFile] }),
       })
       })
         .then((r) => r.json())
         .then((r) => r.json())
         .then((data) => {
         .then((data) => {
-          if (data[status.current_file]?.image_data) {
-            setPreviewUrl(data[status.current_file].image_data)
+          if (data[currentFile]?.image_data) {
+            setPreviewUrl(data[currentFile].image_data)
           }
           }
         })
         })
         .catch(() => {})
         .catch(() => {})

+ 120 - 0
frontend/src/components/layout/Layout.tsx

@@ -30,6 +30,8 @@ export function Layout() {
 
 
   // Connection status
   // Connection status
   const [isConnected, setIsConnected] = useState(false)
   const [isConnected, setIsConnected] = useState(false)
+  const [isBackendConnected, setIsBackendConnected] = useState(false)
+  const [connectionAttempts, setConnectionAttempts] = useState(0)
   const wsRef = useRef<WebSocket | null>(null)
   const wsRef = useRef<WebSocket | null>(null)
 
 
   // Fetch app settings
   // Fetch app settings
@@ -77,6 +79,11 @@ export function Layout() {
       const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
       const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
       const ws = new WebSocket(`${protocol}//${window.location.host}/ws/status`)
       const ws = new WebSocket(`${protocol}//${window.location.host}/ws/status`)
 
 
+      ws.onopen = () => {
+        setIsBackendConnected(true)
+        setConnectionAttempts(0)
+      }
+
       ws.onmessage = (event) => {
       ws.onmessage = (event) => {
         try {
         try {
           const data = JSON.parse(event.data)
           const data = JSON.parse(event.data)
@@ -90,10 +97,16 @@ export function Layout() {
       }
       }
 
 
       ws.onclose = () => {
       ws.onclose = () => {
+        setIsBackendConnected(false)
+        setConnectionAttempts((prev) => prev + 1)
         // Reconnect after 3 seconds (don't change device status on WS disconnect)
         // Reconnect after 3 seconds (don't change device status on WS disconnect)
         setTimeout(connectWebSocket, 3000)
         setTimeout(connectWebSocket, 3000)
       }
       }
 
 
+      ws.onerror = () => {
+        setIsBackendConnected(false)
+      }
+
       wsRef.current = ws
       wsRef.current = ws
     }
     }
 
 
@@ -296,8 +309,115 @@ export function Layout() {
     }
     }
   }, [isDark])
   }, [isDark])
 
 
+  // Blocking overlay logs state
+  const [blockingLogs, setBlockingLogs] = useState<Array<{ timestamp: string; level: string; message: string }>>([])
+  const blockingLogsRef = useRef<HTMLDivElement>(null)
+
+  // Fetch logs when backend is disconnected (for blocking overlay)
+  useEffect(() => {
+    if (isBackendConnected) {
+      setBlockingLogs([])
+      return
+    }
+
+    // Try to fetch logs even when WebSocket fails (HTTP might work)
+    const fetchLogs = async () => {
+      try {
+        const response = await fetch('/api/logs?limit=50')
+        const data = await response.json()
+        const validLogs = (data.logs || [])
+          .filter((log: { message?: string }) => log && log.message && log.message.trim() !== '')
+          .reverse()
+        setBlockingLogs(validLogs)
+        setTimeout(() => {
+          if (blockingLogsRef.current) {
+            blockingLogsRef.current.scrollTop = blockingLogsRef.current.scrollHeight
+          }
+        }, 100)
+      } catch {
+        // Backend not available
+      }
+    }
+
+    fetchLogs()
+    const interval = setInterval(fetchLogs, 3000)
+    return () => clearInterval(interval)
+  }, [isBackendConnected])
+
   return (
   return (
     <div className="min-h-screen bg-background">
     <div className="min-h-screen bg-background">
+      {/* Backend Connection Blocking Overlay */}
+      {!isBackendConnected && (
+        <div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
+          <div className="w-full max-w-2xl space-y-6">
+            {/* Connection Status */}
+            <div className="text-center space-y-4">
+              <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-amber-500/10 mb-2">
+                <span className="material-icons-outlined text-4xl text-amber-500 animate-pulse">
+                  sync
+                </span>
+              </div>
+              <h2 className="text-2xl font-bold">Connecting to Backend</h2>
+              <p className="text-muted-foreground">
+                {connectionAttempts === 0
+                  ? 'Establishing connection...'
+                  : `Reconnecting... (attempt ${connectionAttempts})`
+                }
+              </p>
+              <div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
+                <span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
+                <span>Waiting for server at {window.location.host}</span>
+              </div>
+            </div>
+
+            {/* Logs Panel */}
+            <div className="bg-muted/50 rounded-lg border overflow-hidden">
+              <div className="flex items-center justify-between px-4 py-2 border-b bg-muted">
+                <div className="flex items-center gap-2">
+                  <span className="material-icons-outlined text-base">article</span>
+                  <span className="text-sm font-medium">Recent Logs</span>
+                </div>
+                <span className="text-xs text-muted-foreground">
+                  {blockingLogs.length > 0 ? `${blockingLogs.length} entries` : 'No logs available'}
+                </span>
+              </div>
+              <div
+                ref={blockingLogsRef}
+                className="h-48 overflow-auto p-3 font-mono text-xs space-y-0.5"
+              >
+                {blockingLogs.length > 0 ? (
+                  blockingLogs.map((log, i) => (
+                    <div key={i} className="py-0.5 flex gap-2">
+                      <span className="text-muted-foreground shrink-0">
+                        {formatTimestamp(log.timestamp)}
+                      </span>
+                      <span className={`shrink-0 font-semibold ${
+                        log.level === 'ERROR' ? 'text-red-500' :
+                        log.level === 'WARNING' ? 'text-amber-500' :
+                        log.level === 'DEBUG' ? 'text-muted-foreground' :
+                        'text-foreground'
+                      }`}>
+                        [{log.level || 'LOG'}]
+                      </span>
+                      <span className="break-all">{log.message || ''}</span>
+                    </div>
+                  ))
+                ) : (
+                  <p className="text-muted-foreground text-center py-8">
+                    Waiting for backend to start...
+                  </p>
+                )}
+              </div>
+            </div>
+
+            {/* Hint */}
+            <p className="text-center text-xs text-muted-foreground">
+              Make sure the backend server is running on port 8080
+            </p>
+          </div>
+        </div>
+      )}
+
       {/* Header */}
       {/* Header */}
       <header className="sticky top-0 z-40 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
       <header className="sticky top-0 z-40 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
         <div className="flex h-14 items-center justify-between px-4">
         <div className="flex h-14 items-center justify-between px-4">

+ 2 - 2
frontend/src/components/ui/dialog.tsx

@@ -19,7 +19,7 @@ const DialogOverlay = React.forwardRef<
   <DialogPrimitive.Overlay
   <DialogPrimitive.Overlay
     ref={ref}
     ref={ref}
     className={cn(
     className={cn(
-      "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+      "fixed inset-0 z-[100] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
       className
       className
     )}
     )}
     {...props}
     {...props}
@@ -36,7 +36,7 @@ const DialogContent = React.forwardRef<
     <DialogPrimitive.Content
     <DialogPrimitive.Content
       ref={ref}
       ref={ref}
       className={cn(
       className={cn(
-        "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
+        "fixed left-[50%] top-[50%] z-[100] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
         className
         className
       )}
       )}
       {...props}
       {...props}

+ 1 - 1
frontend/src/components/ui/popover.tsx

@@ -17,7 +17,7 @@ const PopoverContent = React.forwardRef<
       align={align}
       align={align}
       sideOffset={sideOffset}
       sideOffset={sideOffset}
       className={cn(
       className={cn(
-        "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
+        "z-[100] w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
         className
         className
       )}
       )}
       {...props}
       {...props}

+ 1 - 1
frontend/src/components/ui/select.tsx

@@ -73,7 +73,7 @@ const SelectContent = React.forwardRef<
     <SelectPrimitive.Content
     <SelectPrimitive.Content
       ref={ref}
       ref={ref}
       className={cn(
       className={cn(
-        "relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
+        "relative z-[100] max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
         position === "popper" &&
         position === "popper" &&
           "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
           "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
         className
         className

+ 1 - 1
frontend/src/components/ui/tooltip.tsx

@@ -17,7 +17,7 @@ const TooltipContent = React.forwardRef<
     ref={ref}
     ref={ref}
     sideOffset={sideOffset}
     sideOffset={sideOffset}
     className={cn(
     className={cn(
-      "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
+      "z-[100] overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
       className
       className
     )}
     )}
     {...props}
     {...props}

+ 20 - 12
frontend/src/pages/SettingsPage.tsx

@@ -507,18 +507,26 @@ export function SettingsPage() {
             <div className="space-y-3">
             <div className="space-y-3">
               <Label>Available Serial Ports</Label>
               <Label>Available Serial Ports</Label>
               <div className="flex gap-3">
               <div className="flex gap-3">
-                <Select value={selectedPort} onValueChange={setSelectedPort}>
-                  <SelectTrigger className="flex-1">
-                    <SelectValue placeholder="Select a port..." />
-                  </SelectTrigger>
-                  <SelectContent>
-                    {ports.map((port) => (
-                      <SelectItem key={port.port} value={port.port}>
-                        {port.port} - {port.description}
-                      </SelectItem>
-                    ))}
-                  </SelectContent>
-                </Select>
+                <div className="relative flex-1" style={{ zIndex: 50 }}>
+                  <Select value={selectedPort} onValueChange={setSelectedPort}>
+                    <SelectTrigger>
+                      <SelectValue placeholder="Select a port..." />
+                    </SelectTrigger>
+                    <SelectContent position="popper" sideOffset={4}>
+                      {ports.length === 0 ? (
+                        <SelectItem value="_none" disabled>
+                          No ports available
+                        </SelectItem>
+                      ) : (
+                        ports.map((port) => (
+                          <SelectItem key={port.port} value={port.port}>
+                            {port.port} - {port.description}
+                          </SelectItem>
+                        ))
+                      )}
+                    </SelectContent>
+                  </Select>
+                </div>
                 <Button
                 <Button
                   onClick={handleConnect}
                   onClick={handleConnect}
                   disabled={isLoading === 'connect' || !selectedPort || isConnected}
                   disabled={isLoading === 'connect' || !selectedPort || isConnected}

+ 1 - 1
frontend/vite.config.ts

@@ -11,7 +11,7 @@ export default defineConfig({
     },
     },
   },
   },
   server: {
   server: {
-    port: parseInt(process.env.PORT || '80'),
+    port: parseInt(process.env.PORT || '5173'),
     proxy: {
     proxy: {
       // WebSocket endpoints
       // WebSocket endpoints
       '/ws': {
       '/ws': {