Layout.tsx 66 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733
  1. import { Outlet, Link, useLocation } from 'react-router-dom'
  2. import { useEffect, useState, useRef, useCallback } from 'react'
  3. import { toast } from 'sonner'
  4. import {
  5. Power,
  6. RotateCcw,
  7. RefreshCw,
  8. Loader2,
  9. CheckCircle,
  10. Terminal,
  11. Copy,
  12. Eye,
  13. EyeOff,
  14. X,
  15. Menu,
  16. FileText,
  17. Download,
  18. HardDriveDownload,
  19. Grid,
  20. ListMusic,
  21. SlidersHorizontal,
  22. Lightbulb,
  23. Settings as SettingsIcon,
  24. Wifi,
  25. WifiOff,
  26. AlertCircle,
  27. Home,
  28. Sun,
  29. Moon,
  30. ChevronDown,
  31. PlayCircle,
  32. StopCircle,
  33. } from 'lucide-react'
  34. import { NowPlayingBar } from '@/components/NowPlayingBar'
  35. import { Button } from '@/components/ui/button'
  36. import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
  37. import { Separator } from '@/components/ui/separator'
  38. import { cacheAllPreviews } from '@/lib/previewCache'
  39. import { TableSelector } from '@/components/TableSelector'
  40. import { useTable } from '@/contexts/TableContext'
  41. import { apiClient } from '@/lib/apiClient'
  42. import ShinyText from '@/components/ShinyText'
  43. const navItems = [
  44. { path: '/', label: 'Browse', icon: Grid, title: 'Browse Patterns' },
  45. { path: '/playlists', label: 'Playlists', icon: ListMusic, title: 'Playlists' },
  46. { path: '/table-control', label: 'Control', icon: SlidersHorizontal, title: 'Table Control' },
  47. { path: '/led', label: 'LED', icon: Lightbulb, title: 'LED Control' },
  48. { path: '/settings', label: 'Settings', icon: SettingsIcon, title: 'Settings' },
  49. ]
  50. const DEFAULT_APP_NAME = 'Dune Weaver'
  51. export function Layout() {
  52. const location = useLocation()
  53. // Scroll to top on route change
  54. useEffect(() => {
  55. window.scrollTo(0, 0)
  56. }, [location.pathname])
  57. // Multi-table context - must be called before any hooks that depend on activeTable
  58. const { activeTable, tables } = useTable()
  59. // Use table name as app name when multiple tables exist
  60. const hasMultipleTables = tables.length > 1
  61. const [isDark, setIsDark] = useState(() => {
  62. if (typeof window !== 'undefined') {
  63. const saved = localStorage.getItem('theme')
  64. if (saved) return saved === 'dark'
  65. return window.matchMedia('(prefers-color-scheme: dark)').matches
  66. }
  67. return false
  68. })
  69. // App customization
  70. const [appName, setAppName] = useState(DEFAULT_APP_NAME)
  71. const [customLogo, setCustomLogo] = useState<string | null>(null)
  72. // Display name: when multiple tables exist, use the active table's name; otherwise use app settings
  73. // Get the table from the tables array (most up-to-date source) to ensure we have current data
  74. const activeTableData = tables.find(t => t.id === activeTable?.id)
  75. const tableName = activeTableData?.name || activeTable?.name
  76. const displayName = hasMultipleTables && tableName ? tableName : appName
  77. // Connection status
  78. const [isConnected, setIsConnected] = useState(false)
  79. const [isBackendConnected, setIsBackendConnected] = useState(false)
  80. const [isHoming, setIsHoming] = useState(false)
  81. const [homingDismissed, setHomingDismissed] = useState(false)
  82. const [homingJustCompleted, setHomingJustCompleted] = useState(false)
  83. const [homingCountdown, setHomingCountdown] = useState(0)
  84. const [keepHomingLogsOpen, setKeepHomingLogsOpen] = useState(false)
  85. const wasHomingRef = useRef(false)
  86. const [connectionAttempts, setConnectionAttempts] = useState(0)
  87. const wsRef = useRef<WebSocket | null>(null)
  88. // Sensor homing failure state
  89. const [sensorHomingFailed, setSensorHomingFailed] = useState(false)
  90. const [isRecoveringHoming, setIsRecoveringHoming] = useState(false)
  91. // Fetch app settings
  92. const fetchAppSettings = () => {
  93. apiClient.get<{ app?: { name?: string; custom_logo?: string } }>('/api/settings')
  94. .then((settings) => {
  95. if (settings.app?.name) {
  96. setAppName(settings.app.name)
  97. } else {
  98. setAppName(DEFAULT_APP_NAME)
  99. }
  100. setCustomLogo(settings.app?.custom_logo || null)
  101. })
  102. .catch(() => {})
  103. }
  104. useEffect(() => {
  105. fetchAppSettings()
  106. // Listen for branding updates from Settings page
  107. const handleBrandingUpdate = () => {
  108. fetchAppSettings()
  109. }
  110. window.addEventListener('branding-updated', handleBrandingUpdate)
  111. return () => {
  112. window.removeEventListener('branding-updated', handleBrandingUpdate)
  113. }
  114. // Refetch when active table changes
  115. }, [activeTable?.id])
  116. // Homing completion countdown timer
  117. useEffect(() => {
  118. if (!homingJustCompleted || keepHomingLogsOpen) return
  119. if (homingCountdown <= 0) {
  120. // Countdown finished, dismiss the overlay
  121. setHomingJustCompleted(false)
  122. setKeepHomingLogsOpen(false)
  123. return
  124. }
  125. const timer = setTimeout(() => {
  126. setHomingCountdown((prev) => prev - 1)
  127. }, 1000)
  128. return () => clearTimeout(timer)
  129. }, [homingJustCompleted, homingCountdown, keepHomingLogsOpen])
  130. // Mobile menu state
  131. const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
  132. // Logs drawer state
  133. const [isLogsOpen, setIsLogsOpen] = useState(false)
  134. const [logsDrawerHeight, setLogsDrawerHeight] = useState(256) // Default 256px (h-64)
  135. const [isResizing, setIsResizing] = useState(false)
  136. const isResizingRef = useRef(false)
  137. const startYRef = useRef(0)
  138. const startHeightRef = useRef(0)
  139. // Handle drawer resize
  140. const handleResizeStart = (e: React.MouseEvent | React.TouchEvent) => {
  141. e.preventDefault()
  142. isResizingRef.current = true
  143. setIsResizing(true)
  144. startYRef.current = 'touches' in e ? e.touches[0].clientY : e.clientY
  145. startHeightRef.current = logsDrawerHeight
  146. document.body.style.cursor = 'ns-resize'
  147. document.body.style.userSelect = 'none'
  148. }
  149. useEffect(() => {
  150. const handleResizeMove = (e: MouseEvent | TouchEvent) => {
  151. if (!isResizingRef.current) return
  152. const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY
  153. const delta = startYRef.current - clientY
  154. const newHeight = Math.min(Math.max(startHeightRef.current + delta, 150), window.innerHeight - 150)
  155. setLogsDrawerHeight(newHeight)
  156. }
  157. const handleResizeEnd = () => {
  158. if (isResizingRef.current) {
  159. isResizingRef.current = false
  160. setIsResizing(false)
  161. document.body.style.cursor = ''
  162. document.body.style.userSelect = ''
  163. }
  164. }
  165. window.addEventListener('mousemove', handleResizeMove)
  166. window.addEventListener('mouseup', handleResizeEnd)
  167. window.addEventListener('touchmove', handleResizeMove)
  168. window.addEventListener('touchend', handleResizeEnd)
  169. return () => {
  170. window.removeEventListener('mousemove', handleResizeMove)
  171. window.removeEventListener('mouseup', handleResizeEnd)
  172. window.removeEventListener('touchmove', handleResizeMove)
  173. window.removeEventListener('touchend', handleResizeEnd)
  174. }
  175. }, [])
  176. // Now Playing bar state
  177. const [isNowPlayingOpen, setIsNowPlayingOpen] = useState(false)
  178. const [openNowPlayingExpanded, setOpenNowPlayingExpanded] = useState(false)
  179. const [currentPlayingFile, setCurrentPlayingFile] = useState<string | null>(null) // Track current file for header button
  180. const wasPlayingRef = useRef<boolean | null>(null) // Track previous playing state (null = first message)
  181. // Derive isCurrentlyPlaying from currentPlayingFile
  182. const isCurrentlyPlaying = Boolean(currentPlayingFile)
  183. // Listen for playback-started event (dispatched when user starts a pattern)
  184. useEffect(() => {
  185. const handlePlaybackStarted = () => {
  186. setIsNowPlayingOpen(true)
  187. setOpenNowPlayingExpanded(true)
  188. setIsLogsOpen(false)
  189. // Reset expanded flag after animation
  190. setTimeout(() => setOpenNowPlayingExpanded(false), 500)
  191. }
  192. window.addEventListener('playback-started', handlePlaybackStarted)
  193. return () => window.removeEventListener('playback-started', handlePlaybackStarted)
  194. }, [])
  195. const [logs, setLogs] = useState<Array<{ timestamp: string; level: string; logger: string; message: string }>>([])
  196. const [logLevelFilter, setLogLevelFilter] = useState<string>('ALL')
  197. const [logsTotal, setLogsTotal] = useState(0)
  198. const [logsHasMore, setLogsHasMore] = useState(false)
  199. const [isLoadingMoreLogs, setIsLoadingMoreLogs] = useState(false)
  200. const logsWsRef = useRef<WebSocket | null>(null)
  201. const logsContainerRef = useRef<HTMLDivElement>(null)
  202. const logsLoadedCountRef = useRef(0) // Track how many logs we've loaded (for offset)
  203. // Check device connection status via WebSocket
  204. // This effect runs once on mount and manages its own reconnection logic
  205. useEffect(() => {
  206. let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
  207. let isMounted = true
  208. const connectWebSocket = () => {
  209. if (!isMounted) return
  210. // Only close existing connection if it's open (not still connecting)
  211. // This prevents "WebSocket closed before connection established" errors
  212. if (wsRef.current) {
  213. if (wsRef.current.readyState === WebSocket.OPEN) {
  214. wsRef.current.close()
  215. wsRef.current = null
  216. } else if (wsRef.current.readyState === WebSocket.CONNECTING) {
  217. // Already connecting, don't interrupt
  218. return
  219. }
  220. }
  221. const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/status'))
  222. // Assign to ref IMMEDIATELY so concurrent calls see it's connecting
  223. wsRef.current = ws
  224. ws.onopen = () => {
  225. if (!isMounted) {
  226. // Component unmounted while connecting - close the WebSocket now
  227. ws.close()
  228. return
  229. }
  230. setIsBackendConnected(true)
  231. setConnectionAttempts(0)
  232. // Dispatch event so pages can refetch data
  233. window.dispatchEvent(new CustomEvent('backend-connected'))
  234. }
  235. ws.onmessage = (event) => {
  236. if (!isMounted) return
  237. try {
  238. const data = JSON.parse(event.data)
  239. // Handle status updates
  240. if (data.type === 'status_update' && data.data) {
  241. // Update device connection status from the status message
  242. if (data.data.connection_status !== undefined) {
  243. setIsConnected(data.data.connection_status)
  244. }
  245. // Update homing status and detect completion
  246. if (data.data.is_homing !== undefined) {
  247. const newIsHoming = data.data.is_homing
  248. // Detect transition from not homing to homing - reset dismissal
  249. if (!wasHomingRef.current && newIsHoming) {
  250. setHomingDismissed(false)
  251. }
  252. // Detect transition from homing to not homing
  253. if (wasHomingRef.current && !newIsHoming) {
  254. // Homing just completed - show completion state with countdown
  255. // But not if sensor homing failed (that shows a different dialog)
  256. if (!data.data.sensor_homing_failed) {
  257. setHomingJustCompleted(true)
  258. setHomingCountdown(5)
  259. setHomingDismissed(false)
  260. }
  261. }
  262. wasHomingRef.current = newIsHoming
  263. setIsHoming(newIsHoming)
  264. }
  265. // Update sensor homing failure status
  266. if (data.data.sensor_homing_failed !== undefined) {
  267. setSensorHomingFailed(data.data.sensor_homing_failed)
  268. }
  269. // Auto-open/close Now Playing bar based on playback state
  270. // Track current file - this is the most reliable indicator of playback
  271. const currentFile = data.data.current_file || null
  272. setCurrentPlayingFile(currentFile)
  273. const isPlaying = Boolean(currentFile) || Boolean(data.data.is_running) || Boolean(data.data.is_paused)
  274. // Skip auto-open on first message (page refresh) - only react to state changes
  275. if (wasPlayingRef.current !== null) {
  276. if (isPlaying && !wasPlayingRef.current) {
  277. // Playback just started - open the Now Playing bar in expanded mode
  278. setIsNowPlayingOpen(true)
  279. setOpenNowPlayingExpanded(true)
  280. // Close the logs drawer if open
  281. setIsLogsOpen(false)
  282. // Reset the expanded flag after a short delay
  283. setTimeout(() => setOpenNowPlayingExpanded(false), 500)
  284. // Dispatch event so pages can close their sidebars/panels
  285. window.dispatchEvent(new CustomEvent('playback-started'))
  286. } else if (!isPlaying && wasPlayingRef.current) {
  287. // Playback just stopped - close the Now Playing bar
  288. setIsNowPlayingOpen(false)
  289. }
  290. }
  291. wasPlayingRef.current = isPlaying
  292. }
  293. } catch {
  294. // Ignore parse errors
  295. }
  296. }
  297. ws.onclose = () => {
  298. if (!isMounted) return
  299. wsRef.current = null
  300. setIsBackendConnected(false)
  301. setConnectionAttempts((prev) => prev + 1)
  302. // Reconnect after 3 seconds (don't change device status on WS disconnect)
  303. reconnectTimeout = setTimeout(connectWebSocket, 3000)
  304. }
  305. ws.onerror = () => {
  306. if (!isMounted) return
  307. setIsBackendConnected(false)
  308. }
  309. }
  310. // Reset playing state on mount
  311. wasPlayingRef.current = null
  312. // Connect on mount
  313. connectWebSocket()
  314. // Subscribe to base URL changes (when user switches tables)
  315. // This triggers reconnection to the new backend
  316. const unsubscribe = apiClient.onBaseUrlChange(() => {
  317. if (isMounted) {
  318. wasPlayingRef.current = null // Reset playing state for new table
  319. setCurrentPlayingFile(null) // Reset playback state for new table
  320. setIsConnected(false) // Reset connection status until new table reports
  321. setIsBackendConnected(false) // Show connecting state
  322. setSensorHomingFailed(false) // Reset sensor homing failure state for new table
  323. connectWebSocket()
  324. }
  325. })
  326. return () => {
  327. isMounted = false
  328. unsubscribe()
  329. if (reconnectTimeout) {
  330. clearTimeout(reconnectTimeout)
  331. }
  332. if (wsRef.current) {
  333. // Only close if already OPEN - CONNECTING WebSockets will close in onopen
  334. if (wsRef.current.readyState === WebSocket.OPEN) {
  335. wsRef.current.close()
  336. }
  337. wsRef.current = null
  338. }
  339. }
  340. }, []) // Empty deps - runs once on mount, reconnects via apiClient listener
  341. // Connect to logs WebSocket when drawer opens
  342. useEffect(() => {
  343. if (!isLogsOpen) {
  344. // Close WebSocket when drawer closes - only if OPEN (CONNECTING will close in onopen)
  345. if (logsWsRef.current && logsWsRef.current.readyState === WebSocket.OPEN) {
  346. logsWsRef.current.close()
  347. }
  348. logsWsRef.current = null
  349. return
  350. }
  351. let shouldConnect = true
  352. // Fetch initial logs (most recent)
  353. const fetchInitialLogs = async () => {
  354. try {
  355. type LogEntry = { timestamp: string; level: string; logger: string; message: string }
  356. type LogsResponse = { logs: LogEntry[]; total: number; has_more: boolean }
  357. const data = await apiClient.get<LogsResponse>('/api/logs?limit=200')
  358. // Filter out empty/invalid log entries
  359. const validLogs = (data.logs || []).filter(
  360. (log) => log && log.message && log.message.trim() !== ''
  361. )
  362. // API returns newest first, reverse to show oldest first (newest at bottom)
  363. setLogs(validLogs.reverse())
  364. setLogsTotal(data.total || 0)
  365. setLogsHasMore(data.has_more || false)
  366. logsLoadedCountRef.current = validLogs.length
  367. // Scroll to bottom after initial load
  368. setTimeout(() => {
  369. if (logsContainerRef.current) {
  370. logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight
  371. }
  372. }, 100)
  373. } catch {
  374. // Ignore errors
  375. }
  376. }
  377. fetchInitialLogs()
  378. // Connect to WebSocket for real-time updates
  379. let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
  380. const connectLogsWebSocket = () => {
  381. // Don't interrupt an existing connection that's still connecting
  382. if (logsWsRef.current) {
  383. if (logsWsRef.current.readyState === WebSocket.CONNECTING) {
  384. return // Already connecting, wait for it
  385. }
  386. if (logsWsRef.current.readyState === WebSocket.OPEN) {
  387. logsWsRef.current.close()
  388. }
  389. logsWsRef.current = null
  390. }
  391. const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/logs'))
  392. // Assign to ref IMMEDIATELY so concurrent calls see it's connecting
  393. logsWsRef.current = ws
  394. ws.onopen = () => {
  395. if (!shouldConnect) {
  396. // Effect cleanup ran while connecting - close now
  397. ws.close()
  398. return
  399. }
  400. console.log('Logs WebSocket connected')
  401. }
  402. ws.onmessage = (event) => {
  403. try {
  404. const message = JSON.parse(event.data)
  405. // Skip heartbeat messages
  406. if (message.type === 'heartbeat') {
  407. return
  408. }
  409. // Extract log from wrapped structure
  410. const log = message.type === 'log_entry' ? message.data : message
  411. // Skip empty or invalid log entries
  412. if (!log || !log.message || log.message.trim() === '') {
  413. return
  414. }
  415. // Append new log - no limit, lazy loading handles old logs
  416. setLogs((prev) => [...prev, log])
  417. // Auto-scroll to bottom if user is near the bottom
  418. setTimeout(() => {
  419. if (logsContainerRef.current) {
  420. const { scrollTop, scrollHeight, clientHeight } = logsContainerRef.current
  421. // Only auto-scroll if user is within 100px of the bottom
  422. if (scrollHeight - scrollTop - clientHeight < 100) {
  423. logsContainerRef.current.scrollTop = scrollHeight
  424. }
  425. }
  426. }, 10)
  427. } catch {
  428. // Ignore parse errors
  429. }
  430. }
  431. ws.onclose = () => {
  432. if (!shouldConnect) return
  433. console.log('Logs WebSocket closed, reconnecting...')
  434. // Reconnect after 3 seconds if drawer is still open
  435. reconnectTimeout = setTimeout(() => {
  436. if (shouldConnect && logsWsRef.current === ws) {
  437. connectLogsWebSocket()
  438. }
  439. }, 3000)
  440. }
  441. ws.onerror = (error) => {
  442. console.error('Logs WebSocket error:', error)
  443. }
  444. }
  445. connectLogsWebSocket()
  446. return () => {
  447. shouldConnect = false
  448. if (reconnectTimeout) {
  449. clearTimeout(reconnectTimeout)
  450. }
  451. if (logsWsRef.current) {
  452. // Only close if already OPEN - CONNECTING WebSockets will close in onopen
  453. if (logsWsRef.current.readyState === WebSocket.OPEN) {
  454. logsWsRef.current.close()
  455. }
  456. logsWsRef.current = null
  457. }
  458. }
  459. // Also reconnect when active table changes
  460. }, [isLogsOpen, activeTable?.id])
  461. // Load older logs when user scrolls to top (lazy loading)
  462. const loadOlderLogs = useCallback(async () => {
  463. if (isLoadingMoreLogs || !logsHasMore) return
  464. setIsLoadingMoreLogs(true)
  465. try {
  466. type LogEntry = { timestamp: string; level: string; logger: string; message: string }
  467. type LogsResponse = { logs: LogEntry[]; total: number; has_more: boolean }
  468. const offset = logsLoadedCountRef.current
  469. const data = await apiClient.get<LogsResponse>(`/api/logs?limit=100&offset=${offset}`)
  470. const validLogs = (data.logs || []).filter(
  471. (log) => log && log.message && log.message.trim() !== ''
  472. )
  473. if (validLogs.length > 0) {
  474. // Prepend older logs (they come newest-first, so reverse them)
  475. setLogs((prev) => [...validLogs.reverse(), ...prev])
  476. logsLoadedCountRef.current += validLogs.length
  477. setLogsHasMore(data.has_more || false)
  478. setLogsTotal(data.total || 0)
  479. // Maintain scroll position after prepending
  480. setTimeout(() => {
  481. if (logsContainerRef.current) {
  482. // Calculate approximate height of new content (rough estimate: 24px per log line)
  483. const newContentHeight = validLogs.length * 24
  484. logsContainerRef.current.scrollTop = newContentHeight
  485. }
  486. }, 10)
  487. } else {
  488. setLogsHasMore(false)
  489. }
  490. } catch {
  491. // Ignore errors
  492. } finally {
  493. setIsLoadingMoreLogs(false)
  494. }
  495. }, [isLoadingMoreLogs, logsHasMore])
  496. // Scroll event handler for lazy loading
  497. useEffect(() => {
  498. const container = logsContainerRef.current
  499. if (!container || !isLogsOpen) return
  500. const handleScroll = () => {
  501. // Load more when scrolled to top (within 50px)
  502. if (container.scrollTop < 50 && logsHasMore && !isLoadingMoreLogs) {
  503. loadOlderLogs()
  504. }
  505. }
  506. container.addEventListener('scroll', handleScroll)
  507. return () => container.removeEventListener('scroll', handleScroll)
  508. }, [isLogsOpen, logsHasMore, isLoadingMoreLogs, loadOlderLogs])
  509. const handleToggleLogs = () => {
  510. setIsLogsOpen((prev) => !prev)
  511. }
  512. // Filter logs by level
  513. const filteredLogs = logLevelFilter === 'ALL'
  514. ? logs
  515. : logs.filter((log) => log.level === logLevelFilter)
  516. // Format timestamp safely
  517. const formatTimestamp = (timestamp: string) => {
  518. if (!timestamp) return '--:--:--'
  519. try {
  520. const date = new Date(timestamp)
  521. if (isNaN(date.getTime())) return '--:--:--'
  522. return date.toLocaleTimeString()
  523. } catch {
  524. return '--:--:--'
  525. }
  526. }
  527. // Copy logs to clipboard (with fallback for non-HTTPS)
  528. const handleCopyLogs = () => {
  529. const text = filteredLogs
  530. .map((log) => `${formatTimestamp(log.timestamp)} [${log.level}] ${log.message}`)
  531. .join('\n')
  532. copyToClipboard(text)
  533. }
  534. // Helper to copy text with fallback for non-secure contexts
  535. const copyToClipboard = (text: string) => {
  536. if (navigator.clipboard && window.isSecureContext) {
  537. navigator.clipboard.writeText(text).then(() => {
  538. toast.success('Logs copied to clipboard')
  539. }).catch(() => {
  540. toast.error('Failed to copy logs')
  541. })
  542. } else {
  543. // Fallback for non-secure contexts (http://)
  544. const textArea = document.createElement('textarea')
  545. textArea.value = text
  546. textArea.style.position = 'fixed'
  547. textArea.style.left = '-9999px'
  548. document.body.appendChild(textArea)
  549. textArea.select()
  550. try {
  551. document.execCommand('copy')
  552. toast.success('Logs copied to clipboard')
  553. } catch {
  554. toast.error('Failed to copy logs')
  555. }
  556. document.body.removeChild(textArea)
  557. }
  558. }
  559. // Download logs as file
  560. const handleDownloadLogs = () => {
  561. const text = filteredLogs
  562. .map((log) => `${log.timestamp} [${log.level}] [${log.logger}] ${log.message}`)
  563. .join('\n')
  564. const blob = new Blob([text], { type: 'text/plain' })
  565. const url = URL.createObjectURL(blob)
  566. const a = document.createElement('a')
  567. a.href = url
  568. a.download = `dune-weaver-logs-${new Date().toISOString().split('T')[0]}.txt`
  569. a.click()
  570. URL.revokeObjectURL(url)
  571. }
  572. const handleRestart = async () => {
  573. if (!confirm('Are you sure you want to restart Docker containers?')) return
  574. try {
  575. await apiClient.post('/api/system/restart')
  576. toast.success('Docker containers are restarting...')
  577. } catch {
  578. toast.error('Failed to restart Docker containers')
  579. }
  580. }
  581. const handleShutdown = async () => {
  582. if (!confirm('Are you sure you want to shutdown the system?')) return
  583. try {
  584. await apiClient.post('/api/system/shutdown')
  585. toast.success('System is shutting down...')
  586. } catch {
  587. toast.error('Failed to shutdown system')
  588. }
  589. }
  590. // Handle sensor homing recovery
  591. const handleSensorHomingRecovery = async (switchToCrashHoming: boolean) => {
  592. setIsRecoveringHoming(true)
  593. try {
  594. const response = await apiClient.post<{
  595. success: boolean
  596. sensor_homing_failed?: boolean
  597. message?: string
  598. }>('/recover_sensor_homing', {
  599. switch_to_crash_homing: switchToCrashHoming
  600. })
  601. if (response.success) {
  602. toast.success(response.message || 'Homing completed successfully')
  603. setSensorHomingFailed(false)
  604. } else if (response.sensor_homing_failed) {
  605. // Sensor homing failed again
  606. toast.error(response.message || 'Sensor homing failed again')
  607. } else {
  608. toast.error(response.message || 'Recovery failed')
  609. }
  610. } catch {
  611. toast.error('Failed to recover from sensor homing failure')
  612. } finally {
  613. setIsRecoveringHoming(false)
  614. }
  615. }
  616. // Update document title based on current page
  617. useEffect(() => {
  618. const currentNav = navItems.find((item) => item.path === location.pathname)
  619. if (currentNav) {
  620. document.title = `${currentNav.title} | ${displayName}`
  621. } else {
  622. document.title = displayName
  623. }
  624. }, [location.pathname, displayName])
  625. useEffect(() => {
  626. if (isDark) {
  627. document.documentElement.classList.add('dark')
  628. localStorage.setItem('theme', 'dark')
  629. } else {
  630. document.documentElement.classList.remove('dark')
  631. localStorage.setItem('theme', 'light')
  632. }
  633. }, [isDark])
  634. // Blocking overlay logs state - shows connection attempts
  635. const [connectionLogs, setConnectionLogs] = useState<Array<{ timestamp: string; level: string; message: string }>>([])
  636. const blockingLogsRef = useRef<HTMLDivElement>(null)
  637. // Cache progress state
  638. const [cacheProgress, setCacheProgress] = useState<{
  639. is_running: boolean
  640. stage: string
  641. processed_files: number
  642. total_files: number
  643. current_file: string
  644. error?: string
  645. } | null>(null)
  646. const cacheWsRef = useRef<WebSocket | null>(null)
  647. // Cache All Previews prompt state
  648. const [showCacheAllPrompt, setShowCacheAllPrompt] = useState(false)
  649. const [cacheAllProgress, setCacheAllProgress] = useState<{
  650. inProgress: boolean
  651. completed: number
  652. total: number
  653. done: boolean
  654. } | null>(null)
  655. // Blocking overlay logs WebSocket ref
  656. const blockingLogsWsRef = useRef<WebSocket | null>(null)
  657. // Add connection/homing logs when overlay is shown
  658. useEffect(() => {
  659. const showOverlay = !isBackendConnected || isHoming || homingJustCompleted
  660. if (!showOverlay) {
  661. setConnectionLogs([])
  662. // Close WebSocket if open - only if OPEN (CONNECTING will close in onopen)
  663. if (blockingLogsWsRef.current && blockingLogsWsRef.current.readyState === WebSocket.OPEN) {
  664. blockingLogsWsRef.current.close()
  665. }
  666. blockingLogsWsRef.current = null
  667. return
  668. }
  669. // Don't clear logs or reconnect WebSocket during completion state
  670. if (homingJustCompleted && !isHoming) {
  671. return
  672. }
  673. // Add log entry helper
  674. const addLog = (level: string, message: string, timestamp?: string) => {
  675. setConnectionLogs((prev) => {
  676. const newLog = {
  677. timestamp: timestamp || new Date().toISOString(),
  678. level,
  679. message,
  680. }
  681. const newLogs = [...prev, newLog].slice(-100) // Keep last 100 entries
  682. return newLogs
  683. })
  684. // Auto-scroll to bottom
  685. setTimeout(() => {
  686. if (blockingLogsRef.current) {
  687. blockingLogsRef.current.scrollTop = blockingLogsRef.current.scrollHeight
  688. }
  689. }, 10)
  690. }
  691. // If homing, connect to logs WebSocket to stream real logs
  692. if (isHoming && isBackendConnected) {
  693. addLog('INFO', 'Homing started...')
  694. let shouldConnect = true
  695. // Don't interrupt an existing connection that's still connecting
  696. if (blockingLogsWsRef.current) {
  697. if (blockingLogsWsRef.current.readyState === WebSocket.CONNECTING) {
  698. return // Already connecting, wait for it
  699. }
  700. if (blockingLogsWsRef.current.readyState === WebSocket.OPEN) {
  701. blockingLogsWsRef.current.close()
  702. }
  703. blockingLogsWsRef.current = null
  704. }
  705. const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/logs'))
  706. // Assign to ref IMMEDIATELY so concurrent calls see it's connecting
  707. blockingLogsWsRef.current = ws
  708. ws.onopen = () => {
  709. if (!shouldConnect) {
  710. // Effect cleanup ran while connecting - close now
  711. ws.close()
  712. }
  713. }
  714. ws.onmessage = (event) => {
  715. try {
  716. const message = JSON.parse(event.data)
  717. if (message.type === 'heartbeat') return
  718. const log = message.type === 'log_entry' ? message.data : message
  719. if (!log || !log.message || log.message.trim() === '') return
  720. // Filter for homing-related logs
  721. const msg = log.message.toLowerCase()
  722. const isHomingLog =
  723. msg.includes('homing') ||
  724. msg.includes('home') ||
  725. msg.includes('$h') ||
  726. msg.includes('idle') ||
  727. msg.includes('unlock') ||
  728. msg.includes('alarm') ||
  729. msg.includes('grbl') ||
  730. msg.includes('connect') ||
  731. msg.includes('serial') ||
  732. msg.includes('device') ||
  733. msg.includes('position') ||
  734. msg.includes('zeroing') ||
  735. msg.includes('movement') ||
  736. log.logger?.includes('connection')
  737. if (isHomingLog) {
  738. addLog(log.level, log.message, log.timestamp)
  739. }
  740. } catch {
  741. // Ignore parse errors
  742. }
  743. }
  744. return () => {
  745. shouldConnect = false
  746. // Only close if already OPEN - CONNECTING WebSockets will close in onopen
  747. if (ws.readyState === WebSocket.OPEN) {
  748. ws.close()
  749. }
  750. blockingLogsWsRef.current = null
  751. }
  752. }
  753. // If backend disconnected, show connection retry logs
  754. if (!isBackendConnected) {
  755. addLog('INFO', `Attempting to connect to backend at ${window.location.host}...`)
  756. const interval = setInterval(() => {
  757. addLog('INFO', `Retrying connection to WebSocket /ws/status...`)
  758. apiClient.get('/api/settings')
  759. .then(() => {
  760. addLog('INFO', 'HTTP endpoint responding, waiting for WebSocket...')
  761. })
  762. .catch(() => {
  763. // Still down
  764. })
  765. }, 3000)
  766. return () => clearInterval(interval)
  767. }
  768. }, [isBackendConnected, isHoming, homingJustCompleted])
  769. // Cache progress WebSocket connection - always connected to monitor cache generation
  770. useEffect(() => {
  771. if (!isBackendConnected) return
  772. let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
  773. let shouldConnect = true
  774. const connectCacheWebSocket = () => {
  775. if (!shouldConnect) return
  776. // Don't interrupt an existing connection that's still connecting
  777. if (cacheWsRef.current) {
  778. if (cacheWsRef.current.readyState === WebSocket.CONNECTING) {
  779. return // Already connecting, wait for it
  780. }
  781. if (cacheWsRef.current.readyState === WebSocket.OPEN) {
  782. return // Already connected
  783. }
  784. // CLOSING or CLOSED state - clear the ref
  785. cacheWsRef.current = null
  786. }
  787. const ws = new WebSocket(apiClient.getWebSocketUrl('/ws/cache-progress'))
  788. // Assign to ref IMMEDIATELY so concurrent calls see it's connecting
  789. cacheWsRef.current = ws
  790. ws.onopen = () => {
  791. if (!shouldConnect) {
  792. // Effect cleanup ran while connecting - close now
  793. ws.close()
  794. }
  795. }
  796. ws.onmessage = (event) => {
  797. try {
  798. const message = JSON.parse(event.data)
  799. if (message.type === 'cache_progress') {
  800. const data = message.data
  801. if (data.is_running) {
  802. // Cache generation is running - show splash screen
  803. setCacheProgress(data)
  804. } else if (data.stage === 'complete') {
  805. // Cache generation just completed
  806. if (cacheProgress?.is_running) {
  807. // Was running before, now complete - show cache all prompt
  808. const promptShown = localStorage.getItem('cacheAllPromptShown')
  809. if (!promptShown) {
  810. setTimeout(() => {
  811. setCacheAllProgress(null) // Reset to clean state
  812. setShowCacheAllPrompt(true)
  813. }, 500)
  814. }
  815. }
  816. setCacheProgress(null)
  817. } else {
  818. // Not running and not complete (idle state)
  819. setCacheProgress(null)
  820. }
  821. }
  822. } catch {
  823. // Ignore parse errors
  824. }
  825. }
  826. ws.onclose = () => {
  827. if (!shouldConnect) return
  828. cacheWsRef.current = null
  829. // Reconnect after 3 seconds
  830. if (shouldConnect && isBackendConnected) {
  831. reconnectTimeout = setTimeout(connectCacheWebSocket, 3000)
  832. }
  833. }
  834. ws.onerror = () => {
  835. // Will trigger onclose
  836. }
  837. }
  838. connectCacheWebSocket()
  839. return () => {
  840. shouldConnect = false
  841. if (reconnectTimeout) {
  842. clearTimeout(reconnectTimeout)
  843. }
  844. if (cacheWsRef.current) {
  845. // Only close if already OPEN - CONNECTING WebSockets will close in onopen
  846. if (cacheWsRef.current.readyState === WebSocket.OPEN) {
  847. cacheWsRef.current.close()
  848. }
  849. cacheWsRef.current = null
  850. }
  851. }
  852. }, [isBackendConnected]) // Only reconnect based on backend connection, not cache state
  853. // Calculate cache progress percentage
  854. const cachePercentage = cacheProgress?.total_files
  855. ? Math.round((cacheProgress.processed_files / cacheProgress.total_files) * 100)
  856. : 0
  857. const getCacheStageText = () => {
  858. if (!cacheProgress) return ''
  859. switch (cacheProgress.stage) {
  860. case 'starting':
  861. return 'Initializing...'
  862. case 'metadata':
  863. return 'Processing pattern metadata'
  864. case 'images':
  865. return 'Generating pattern previews'
  866. default:
  867. return 'Processing...'
  868. }
  869. }
  870. // Cache all previews in browser using IndexedDB
  871. const handleCacheAllPreviews = async () => {
  872. setCacheAllProgress({ inProgress: true, completed: 0, total: 0, done: false })
  873. const result = await cacheAllPreviews((progress) => {
  874. setCacheAllProgress({ inProgress: !progress.done, ...progress })
  875. })
  876. if (result.success) {
  877. if (result.cached === 0) {
  878. toast.success('All patterns are already cached!')
  879. } else {
  880. toast.success(`Cached ${result.cached} pattern previews`)
  881. }
  882. } else {
  883. setCacheAllProgress(null)
  884. toast.error('Failed to cache previews')
  885. }
  886. }
  887. const handleSkipCacheAll = () => {
  888. localStorage.setItem('cacheAllPromptShown', 'true')
  889. setShowCacheAllPrompt(false)
  890. setCacheAllProgress(null)
  891. }
  892. const handleCloseCacheAllDone = () => {
  893. localStorage.setItem('cacheAllPromptShown', 'true')
  894. setShowCacheAllPrompt(false)
  895. setCacheAllProgress(null)
  896. }
  897. const cacheAllPercentage = cacheAllProgress?.total
  898. ? Math.round((cacheAllProgress.completed / cacheAllProgress.total) * 100)
  899. : 0
  900. return (
  901. <div className="min-h-dvh bg-background flex flex-col">
  902. {/* Sensor Homing Failure Popup */}
  903. {sensorHomingFailed && (
  904. <div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
  905. <div className="bg-background rounded-lg shadow-xl w-full max-w-md border border-destructive/30">
  906. <div className="p-6">
  907. <div className="text-center space-y-4">
  908. <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-destructive/10 mb-2">
  909. <AlertCircle className="h-10 w-10 text-destructive" />
  910. </div>
  911. <h2 className="text-xl font-semibold">Sensor Homing Failed</h2>
  912. <p className="text-muted-foreground text-sm">
  913. The sensor homing process could not complete. The limit sensors may not be positioned correctly or may be malfunctioning.
  914. </p>
  915. <div className="bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-sm text-left">
  916. <p className="text-amber-600 dark:text-amber-400 font-medium mb-2">
  917. Troubleshooting steps:
  918. </p>
  919. <ul className="text-amber-600 dark:text-amber-400 space-y-1 list-disc list-inside">
  920. <li>Check that the limit sensors are properly connected</li>
  921. <li>Verify the sensor positions are correct</li>
  922. <li>Ensure nothing is blocking the sensor path</li>
  923. <li>Check for loose wiring connections</li>
  924. </ul>
  925. </div>
  926. <p className="text-muted-foreground text-sm">
  927. Connection will not be established until this is resolved.
  928. </p>
  929. {/* Action Buttons */}
  930. {!isRecoveringHoming ? (
  931. <div className="flex flex-col gap-2 pt-2">
  932. <Button
  933. onClick={() => handleSensorHomingRecovery(false)}
  934. className="w-full gap-2"
  935. >
  936. <RefreshCw className="h-4 w-4" />
  937. Retry Sensor Homing
  938. </Button>
  939. <Button
  940. variant="secondary"
  941. onClick={() => handleSensorHomingRecovery(true)}
  942. className="w-full gap-2"
  943. >
  944. <RefreshCw className="h-4 w-4" />
  945. Switch to Crash Homing
  946. </Button>
  947. <p className="text-xs text-muted-foreground">
  948. Crash homing moves the arm to a physical stop without using sensors.
  949. </p>
  950. </div>
  951. ) : (
  952. <div className="flex items-center justify-center gap-2 py-4">
  953. <Loader2 className="h-5 w-5 text-primary animate-spin" />
  954. <span className="text-muted-foreground">Attempting recovery...</span>
  955. </div>
  956. )}
  957. </div>
  958. </div>
  959. </div>
  960. </div>
  961. )}
  962. {/* Cache Progress Blocking Overlay */}
  963. {cacheProgress?.is_running && (
  964. <div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
  965. <div className="w-full max-w-md space-y-6">
  966. <div className="text-center space-y-4">
  967. <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-2">
  968. <RefreshCw className="h-10 w-10 text-primary animate-pulse" />
  969. </div>
  970. <h2 className="text-2xl font-bold">Initializing Pattern Cache</h2>
  971. <p className="text-muted-foreground">
  972. Preparing your pattern previews...
  973. </p>
  974. </div>
  975. {/* Progress Bar */}
  976. <div className="space-y-2">
  977. <div className="w-full bg-muted rounded-full h-2 overflow-hidden">
  978. <div
  979. className="bg-primary h-2 rounded-full transition-all duration-300"
  980. style={{ width: `${cachePercentage}%` }}
  981. />
  982. </div>
  983. <div className="flex justify-between text-sm text-muted-foreground">
  984. <span>
  985. {cacheProgress.processed_files} of {cacheProgress.total_files} patterns
  986. </span>
  987. <span>{cachePercentage}%</span>
  988. </div>
  989. </div>
  990. {/* Stage Info */}
  991. <div className="text-center space-y-1">
  992. <p className="text-sm font-medium">{getCacheStageText()}</p>
  993. {cacheProgress.current_file && (
  994. <p className="text-xs text-muted-foreground truncate max-w-full">
  995. {cacheProgress.current_file}
  996. </p>
  997. )}
  998. </div>
  999. {/* Hint */}
  1000. <p className="text-center text-xs text-muted-foreground">
  1001. This only happens once after updates or when new patterns are added
  1002. </p>
  1003. </div>
  1004. </div>
  1005. )}
  1006. {/* Cache All Previews Prompt Modal */}
  1007. {showCacheAllPrompt && (
  1008. <div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
  1009. <div className="bg-background rounded-lg shadow-xl w-full max-w-md">
  1010. <div className="p-6">
  1011. <div className="text-center space-y-4">
  1012. <div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-primary/10 mb-2">
  1013. <HardDriveDownload className="h-6 w-6 text-primary" />
  1014. </div>
  1015. <h2 className="text-xl font-semibold">Cache All Pattern Previews?</h2>
  1016. <p className="text-muted-foreground text-sm">
  1017. Would you like to cache all pattern previews for faster browsing? This will download and store preview images in your browser for instant loading.
  1018. </p>
  1019. <div className="bg-amber-500/10 border border-amber-500/20 p-3 rounded-lg text-sm">
  1020. <p className="text-amber-600 dark:text-amber-400">
  1021. <strong>Note:</strong> This cache is browser-specific. You'll need to repeat this for each browser you use.
  1022. </p>
  1023. </div>
  1024. {/* Initial state - show buttons */}
  1025. {!cacheAllProgress && (
  1026. <div className="flex gap-3 justify-center">
  1027. <Button variant="ghost" onClick={handleSkipCacheAll}>
  1028. Skip for now
  1029. </Button>
  1030. <Button variant="secondary" onClick={handleCacheAllPreviews} className="gap-2">
  1031. <RefreshCw className="h-5 w-5" />
  1032. Cache All
  1033. </Button>
  1034. </div>
  1035. )}
  1036. {/* Progress section */}
  1037. {cacheAllProgress && !cacheAllProgress.done && (
  1038. <div className="space-y-2">
  1039. <div className="w-full bg-muted rounded-full h-2 overflow-hidden">
  1040. <div
  1041. className="bg-primary h-2 rounded-full transition-all duration-300"
  1042. style={{ width: `${cacheAllPercentage}%` }}
  1043. />
  1044. </div>
  1045. <div className="flex justify-between text-sm text-muted-foreground">
  1046. <span>
  1047. {cacheAllProgress.completed} of {cacheAllProgress.total} previews
  1048. </span>
  1049. <span>{cacheAllPercentage}%</span>
  1050. </div>
  1051. </div>
  1052. )}
  1053. {/* Completion message */}
  1054. {cacheAllProgress?.done && (
  1055. <div className="space-y-4">
  1056. <p className="text-green-600 dark:text-green-400 flex items-center justify-center gap-2">
  1057. <CheckCircle className="h-4 w-4" />
  1058. All {cacheAllProgress.total} previews cached successfully!
  1059. </p>
  1060. <Button onClick={handleCloseCacheAllDone} className="w-full">
  1061. Done
  1062. </Button>
  1063. </div>
  1064. )}
  1065. </div>
  1066. </div>
  1067. </div>
  1068. </div>
  1069. )}
  1070. {/* Backend Connection / Homing Blocking Overlay */}
  1071. {/* Don't show this overlay when sensor homing failed - that has its own dialog */}
  1072. {!sensorHomingFailed && (!isBackendConnected || (isHoming && !homingDismissed) || homingJustCompleted) && (
  1073. <div className="fixed inset-0 z-50 bg-background/95 backdrop-blur-sm flex flex-col items-center justify-center p-4">
  1074. <div className="w-full max-w-2xl space-y-6">
  1075. {/* Status Header */}
  1076. <div className="text-center space-y-4">
  1077. <div className={`inline-flex items-center justify-center w-16 h-16 rounded-full mb-2 ${
  1078. homingJustCompleted
  1079. ? 'bg-green-500/10'
  1080. : isHoming
  1081. ? 'bg-primary/10'
  1082. : 'bg-amber-500/10'
  1083. }`}>
  1084. {homingJustCompleted ? (
  1085. <CheckCircle className="h-10 w-10 text-green-500" />
  1086. ) : isHoming ? (
  1087. <Loader2 className="h-10 w-10 text-primary animate-spin" />
  1088. ) : (
  1089. <Home className="h-10 w-10 text-amber-500 animate-pulse" />
  1090. )}
  1091. </div>
  1092. <h2 className="text-2xl font-bold">
  1093. {homingJustCompleted
  1094. ? 'Homing Complete'
  1095. : isHoming
  1096. ? 'Homing in Progress'
  1097. : 'Connecting to Backend'
  1098. }
  1099. </h2>
  1100. <p className="text-muted-foreground">
  1101. {homingJustCompleted
  1102. ? 'Table is ready to use'
  1103. : isHoming
  1104. ? 'Moving to home position... This may take up to 90 seconds.'
  1105. : connectionAttempts === 0
  1106. ? 'Establishing connection...'
  1107. : `Reconnecting... (attempt ${connectionAttempts})`
  1108. }
  1109. </p>
  1110. <div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
  1111. <span className={`w-2 h-2 rounded-full ${
  1112. homingJustCompleted
  1113. ? 'bg-green-500'
  1114. : isHoming
  1115. ? 'bg-primary animate-pulse'
  1116. : 'bg-amber-500 animate-pulse'
  1117. }`} />
  1118. <span>
  1119. {homingJustCompleted
  1120. ? keepHomingLogsOpen
  1121. ? 'Viewing logs'
  1122. : `Closing in ${homingCountdown}s...`
  1123. : isHoming
  1124. ? 'Do not interrupt the homing process'
  1125. : `Waiting for server at ${window.location.host}`
  1126. }
  1127. </span>
  1128. </div>
  1129. </div>
  1130. {/* Logs Panel */}
  1131. <div className="bg-muted/50 rounded-lg border overflow-hidden">
  1132. <div className="flex items-center justify-between px-4 py-2 border-b bg-muted">
  1133. <div className="flex items-center gap-2">
  1134. <Terminal className="h-4 w-4" />
  1135. <span className="text-sm font-medium">
  1136. {isHoming || homingJustCompleted ? 'Homing Log' : 'Connection Log'}
  1137. </span>
  1138. </div>
  1139. <div className="flex items-center gap-2">
  1140. <button
  1141. onClick={() => {
  1142. const logText = connectionLogs
  1143. .map((log) => `[${new Date(log.timestamp).toLocaleTimeString()}] [${log.level}] ${log.message}`)
  1144. .join('\n')
  1145. copyToClipboard(logText)
  1146. }}
  1147. className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
  1148. title="Copy logs to clipboard"
  1149. >
  1150. <Copy className="h-3.5 w-3.5" />
  1151. Copy
  1152. </button>
  1153. <span className="text-xs text-muted-foreground">
  1154. {connectionLogs.length} entries
  1155. </span>
  1156. </div>
  1157. </div>
  1158. <div
  1159. ref={blockingLogsRef}
  1160. className="h-48 overflow-auto p-3 font-mono text-xs space-y-0.5"
  1161. >
  1162. {connectionLogs.map((log, i) => (
  1163. <div key={i} className="py-0.5 flex gap-2">
  1164. <span className="text-muted-foreground shrink-0">
  1165. {formatTimestamp(log.timestamp)}
  1166. </span>
  1167. <span className={`shrink-0 font-semibold ${
  1168. log.level === 'ERROR' ? 'text-red-500' :
  1169. log.level === 'WARNING' ? 'text-amber-500' :
  1170. log.level === 'DEBUG' ? 'text-muted-foreground' :
  1171. 'text-foreground'
  1172. }`}>
  1173. [{log.level}]
  1174. </span>
  1175. <span className="break-all">{log.message}</span>
  1176. </div>
  1177. ))}
  1178. </div>
  1179. </div>
  1180. {/* Action buttons for homing completion */}
  1181. {homingJustCompleted && (
  1182. <div className="flex justify-center gap-3">
  1183. {!keepHomingLogsOpen ? (
  1184. <>
  1185. <Button
  1186. variant="secondary"
  1187. onClick={() => setKeepHomingLogsOpen(true)}
  1188. className="gap-2"
  1189. >
  1190. <Eye className="h-4 w-4" />
  1191. Keep Open
  1192. </Button>
  1193. <Button
  1194. onClick={() => {
  1195. setHomingJustCompleted(false)
  1196. setKeepHomingLogsOpen(false)
  1197. }}
  1198. className="gap-2"
  1199. >
  1200. <X className="h-4 w-4" />
  1201. Dismiss
  1202. </Button>
  1203. </>
  1204. ) : (
  1205. <Button
  1206. onClick={() => {
  1207. setHomingJustCompleted(false)
  1208. setKeepHomingLogsOpen(false)
  1209. }}
  1210. className="gap-2"
  1211. >
  1212. <X className="h-4 w-4" />
  1213. Close Logs
  1214. </Button>
  1215. )}
  1216. </div>
  1217. )}
  1218. {/* Dismiss button during homing */}
  1219. {isHoming && !homingJustCompleted && (
  1220. <div className="flex justify-center">
  1221. <Button
  1222. variant="ghost"
  1223. onClick={() => setHomingDismissed(true)}
  1224. className="gap-2 text-muted-foreground"
  1225. >
  1226. <EyeOff className="h-4 w-4" />
  1227. Dismiss
  1228. </Button>
  1229. </div>
  1230. )}
  1231. {/* Hint */}
  1232. {!homingJustCompleted && (
  1233. <p className="text-center text-xs text-muted-foreground">
  1234. {isHoming
  1235. ? 'Homing will continue in the background'
  1236. : 'Make sure the backend server is running on port 8080'
  1237. }
  1238. </p>
  1239. )}
  1240. </div>
  1241. </div>
  1242. )}
  1243. {/* Header - Floating Pill */}
  1244. <header className="fixed top-0 left-0 right-0 z-40 pt-safe">
  1245. {/* Blurry backdrop behind header - only on Browse page where content scrolls under */}
  1246. {location.pathname === '/' && (
  1247. <div className="absolute inset-0 bg-background/80 backdrop-blur-md supports-[backdrop-filter]:bg-background/50" style={{ height: 'calc(5rem + env(safe-area-inset-top, 0px))' }} />
  1248. )}
  1249. <div className="relative w-full max-w-5xl mx-auto px-3 sm:px-4 pt-3 pointer-events-none">
  1250. <div className="flex h-12 items-center justify-between px-4 rounded-full bg-card shadow-lg border border-border pointer-events-auto">
  1251. <div className="flex items-center gap-2">
  1252. <Link to="/">
  1253. <img
  1254. src={customLogo ? apiClient.getAssetUrl(`/static/custom/${customLogo}`) : apiClient.getAssetUrl('/static/android-chrome-192x192.png')}
  1255. alt={displayName}
  1256. className="w-8 h-8 rounded-full object-cover"
  1257. />
  1258. </Link>
  1259. <TableSelector>
  1260. <button className="flex items-center gap-1.5 hover:opacity-80 transition-opacity group">
  1261. <ShinyText
  1262. text={displayName}
  1263. className="font-semibold text-lg"
  1264. speed={4}
  1265. color={isDark ? '#a8a8a8' : '#555555'}
  1266. shineColor={isDark ? '#ffffff' : '#999999'}
  1267. spread={75}
  1268. />
  1269. <ChevronDown className="h-3.5 w-3.5 text-muted-foreground group-hover:text-foreground transition-colors" />
  1270. <span
  1271. className={`w-2 h-2 rounded-full ${
  1272. !isBackendConnected
  1273. ? 'bg-gray-400'
  1274. : isConnected
  1275. ? 'bg-green-500 animate-pulse'
  1276. : 'bg-red-500'
  1277. }`}
  1278. title={
  1279. !isBackendConnected
  1280. ? 'Backend not connected'
  1281. : isConnected
  1282. ? 'Table connected'
  1283. : 'Table disconnected'
  1284. }
  1285. />
  1286. </button>
  1287. </TableSelector>
  1288. </div>
  1289. {/* Desktop actions */}
  1290. <div className="hidden md:flex items-center gap-0 ml-2">
  1291. <Popover>
  1292. <PopoverTrigger asChild>
  1293. <Button
  1294. variant="ghost"
  1295. size="icon"
  1296. className="rounded-full"
  1297. aria-label="Open menu"
  1298. >
  1299. <Menu className="h-5 w-5" />
  1300. </Button>
  1301. </PopoverTrigger>
  1302. <PopoverContent align="end" className="w-56 p-2">
  1303. <div className="flex flex-col gap-1">
  1304. <button
  1305. onClick={() => setIsDark(!isDark)}
  1306. className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
  1307. >
  1308. {isDark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
  1309. {isDark ? 'Light Mode' : 'Dark Mode'}
  1310. </button>
  1311. <button
  1312. onClick={handleToggleLogs}
  1313. className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
  1314. >
  1315. <FileText className="h-5 w-5" />
  1316. View Logs
  1317. </button>
  1318. <Separator className="my-1" />
  1319. <button
  1320. onClick={handleRestart}
  1321. className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-amber-500"
  1322. >
  1323. <RotateCcw className="h-5 w-5" />
  1324. Restart Docker
  1325. </button>
  1326. <button
  1327. onClick={handleShutdown}
  1328. className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-red-500"
  1329. >
  1330. <Power className="h-5 w-5" />
  1331. Shutdown
  1332. </button>
  1333. </div>
  1334. </PopoverContent>
  1335. </Popover>
  1336. </div>
  1337. {/* Mobile actions */}
  1338. <div className="flex md:hidden items-center gap-0 ml-2">
  1339. <Popover open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
  1340. <PopoverTrigger asChild>
  1341. <Button
  1342. variant="ghost"
  1343. size="icon"
  1344. className="rounded-full"
  1345. aria-label="Open menu"
  1346. >
  1347. {isMobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
  1348. </Button>
  1349. </PopoverTrigger>
  1350. <PopoverContent align="end" className="w-56 p-2">
  1351. <div className="flex flex-col gap-1">
  1352. <button
  1353. onClick={() => {
  1354. setIsDark(!isDark)
  1355. setIsMobileMenuOpen(false)
  1356. }}
  1357. className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
  1358. >
  1359. {isDark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
  1360. {isDark ? 'Light Mode' : 'Dark Mode'}
  1361. </button>
  1362. <button
  1363. onClick={() => {
  1364. handleToggleLogs()
  1365. setIsMobileMenuOpen(false)
  1366. }}
  1367. className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors"
  1368. >
  1369. <FileText className="h-5 w-5" />
  1370. View Logs
  1371. </button>
  1372. <Separator className="my-1" />
  1373. <button
  1374. onClick={() => {
  1375. handleRestart()
  1376. setIsMobileMenuOpen(false)
  1377. }}
  1378. className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-amber-500"
  1379. >
  1380. <RotateCcw className="h-5 w-5" />
  1381. Restart Docker
  1382. </button>
  1383. <button
  1384. onClick={() => {
  1385. handleShutdown()
  1386. setIsMobileMenuOpen(false)
  1387. }}
  1388. className="flex items-center gap-3 w-full px-3 py-2 text-sm rounded-md hover:bg-accent transition-colors text-red-500"
  1389. >
  1390. <Power className="h-5 w-5" />
  1391. Shutdown
  1392. </button>
  1393. </div>
  1394. </PopoverContent>
  1395. </Popover>
  1396. </div>
  1397. </div>
  1398. </div>
  1399. </header>
  1400. {/* Main Content */}
  1401. <main
  1402. className={`container mx-auto px-4 transition-all duration-300 ${
  1403. !isLogsOpen && !isNowPlayingOpen ? 'pb-20' :
  1404. !isLogsOpen && isNowPlayingOpen ? 'pb-80' : ''
  1405. }`}
  1406. style={{
  1407. paddingTop: 'calc(4.5rem + env(safe-area-inset-top, 0px))',
  1408. paddingBottom: isLogsOpen
  1409. ? isNowPlayingOpen
  1410. ? logsDrawerHeight + 256 + 64 // drawer + now playing + nav
  1411. : logsDrawerHeight + 64 // drawer + nav
  1412. : undefined
  1413. }}
  1414. >
  1415. <Outlet />
  1416. </main>
  1417. {/* Now Playing Bar */}
  1418. <NowPlayingBar
  1419. isLogsOpen={isLogsOpen}
  1420. logsDrawerHeight={logsDrawerHeight}
  1421. isVisible={isNowPlayingOpen}
  1422. openExpanded={openNowPlayingExpanded}
  1423. onClose={() => setIsNowPlayingOpen(false)}
  1424. />
  1425. {/* Logs Drawer */}
  1426. <div
  1427. className={`fixed left-0 right-0 z-30 bg-background border-t border-border ${
  1428. isResizing ? '' : 'transition-[height] duration-300'
  1429. }`}
  1430. style={{
  1431. height: isLogsOpen ? logsDrawerHeight : 0,
  1432. bottom: 'calc(4rem + env(safe-area-inset-bottom, 0px))'
  1433. }}
  1434. >
  1435. {isLogsOpen && (
  1436. <>
  1437. {/* Resize Handle */}
  1438. <div
  1439. className="absolute top-0 left-0 right-0 h-2 cursor-ns-resize flex items-center justify-center group hover:bg-primary/10 -translate-y-1/2 z-10"
  1440. onMouseDown={handleResizeStart}
  1441. onTouchStart={handleResizeStart}
  1442. >
  1443. <div className="w-12 h-1 rounded-full bg-border group-hover:bg-primary transition-colors" />
  1444. </div>
  1445. {/* Logs Header */}
  1446. <div className="flex items-center justify-between px-4 py-2 border-b bg-muted/50">
  1447. <div className="flex items-center gap-3">
  1448. <span className="text-sm font-medium">Application Logs</span>
  1449. <select
  1450. value={logLevelFilter}
  1451. onChange={(e) => setLogLevelFilter(e.target.value)}
  1452. className="text-xs bg-background border rounded px-2 py-1"
  1453. >
  1454. <option value="ALL">All Levels</option>
  1455. <option value="DEBUG">Debug</option>
  1456. <option value="INFO">Info</option>
  1457. <option value="WARNING">Warning</option>
  1458. <option value="ERROR">Error</option>
  1459. </select>
  1460. <span className="text-xs text-muted-foreground">
  1461. {filteredLogs.length}{logsTotal > 0 ? ` of ${logsTotal}` : ''} entries
  1462. {logsHasMore && <span className="text-primary ml-1">↑ scroll for more</span>}
  1463. </span>
  1464. </div>
  1465. <div className="flex items-center gap-1">
  1466. <Button
  1467. variant="ghost"
  1468. size="icon-sm"
  1469. onClick={handleCopyLogs}
  1470. className="rounded-full"
  1471. title="Copy logs"
  1472. >
  1473. <Copy className="h-4 w-4" />
  1474. </Button>
  1475. <Button
  1476. variant="ghost"
  1477. size="icon-sm"
  1478. onClick={handleDownloadLogs}
  1479. className="rounded-full"
  1480. title="Download logs"
  1481. >
  1482. <Download className="h-4 w-4" />
  1483. </Button>
  1484. <Button
  1485. variant="ghost"
  1486. size="icon-sm"
  1487. onClick={() => setIsLogsOpen(false)}
  1488. className="rounded-full"
  1489. title="Close"
  1490. >
  1491. <X className="h-4 w-4" />
  1492. </Button>
  1493. </div>
  1494. </div>
  1495. {/* Logs Content */}
  1496. <div
  1497. ref={logsContainerRef}
  1498. className="h-[calc(100%-40px)] overflow-auto overscroll-contain p-3 font-mono text-xs space-y-0.5"
  1499. >
  1500. {/* Loading indicator for older logs */}
  1501. {isLoadingMoreLogs && (
  1502. <div className="flex items-center justify-center gap-2 py-2 text-muted-foreground">
  1503. <Loader2 className="h-3.5 w-3.5 animate-spin" />
  1504. <span>Loading older logs...</span>
  1505. </div>
  1506. )}
  1507. {/* Load more hint */}
  1508. {logsHasMore && !isLoadingMoreLogs && (
  1509. <div className="text-center py-2 text-muted-foreground text-xs">
  1510. ↑ Scroll up to load older logs
  1511. </div>
  1512. )}
  1513. {filteredLogs.length > 0 ? (
  1514. filteredLogs.map((log, i) => (
  1515. <div key={i} className="py-0.5 flex gap-2">
  1516. <span className="text-muted-foreground shrink-0">
  1517. {formatTimestamp(log.timestamp)}
  1518. </span>
  1519. <span className={`shrink-0 font-semibold ${
  1520. log.level === 'ERROR' ? 'text-red-500' :
  1521. log.level === 'WARNING' ? 'text-amber-500' :
  1522. log.level === 'DEBUG' ? 'text-muted-foreground' :
  1523. 'text-foreground'
  1524. }`}>
  1525. [{log.level || 'LOG'}]
  1526. </span>
  1527. <span className="break-all">{log.message || ''}</span>
  1528. </div>
  1529. ))
  1530. ) : (
  1531. <p className="text-muted-foreground text-center py-4">No logs available</p>
  1532. )}
  1533. </div>
  1534. </>
  1535. )}
  1536. </div>
  1537. {/* Floating Now Playing Button - hidden when Now Playing bar is open */}
  1538. {!isNowPlayingOpen && (
  1539. <button
  1540. onClick={() => setIsNowPlayingOpen(true)}
  1541. className="fixed z-40 left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 rounded-full bg-card border border-border shadow-lg transition-all hover:shadow-xl hover:scale-105 active:scale-95"
  1542. style={{ bottom: 'calc(4.5rem + env(safe-area-inset-bottom, 0px))' }}
  1543. aria-label={isCurrentlyPlaying ? 'Now Playing' : 'Not Playing'}
  1544. >
  1545. {isCurrentlyPlaying ? (
  1546. <PlayCircle className="h-5 w-5 text-primary" />
  1547. ) : (
  1548. <StopCircle className="h-5 w-5 text-muted-foreground" />
  1549. )}
  1550. <span className="text-sm font-medium">
  1551. {isCurrentlyPlaying ? 'Now Playing' : 'Not Playing'}
  1552. </span>
  1553. </button>
  1554. )}
  1555. {/* Bottom Navigation */}
  1556. <nav className="fixed bottom-0 left-0 right-0 z-40 border-t border-border bg-card pb-safe">
  1557. <div className="max-w-5xl mx-auto grid grid-cols-5 h-16">
  1558. {navItems.map((item) => {
  1559. const isActive = location.pathname === item.path
  1560. return (
  1561. <Link
  1562. key={item.path}
  1563. to={item.path}
  1564. className={`relative flex flex-col items-center justify-center gap-1 transition-all duration-200 ${
  1565. isActive
  1566. ? 'text-primary'
  1567. : 'text-muted-foreground hover:text-foreground active:scale-95'
  1568. }`}
  1569. >
  1570. {/* Active indicator pill */}
  1571. {isActive && (
  1572. <span className="absolute -top-0.5 w-8 h-1 rounded-full bg-primary" />
  1573. )}
  1574. <item.icon className="h-5 w-5" strokeWidth={isActive ? 2.5 : 1.5} />
  1575. <span className="text-xs font-medium">{item.label}</span>
  1576. </Link>
  1577. )
  1578. })}
  1579. </div>
  1580. </nav>
  1581. </div>
  1582. )
  1583. }