TableControlPage.tsx 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682
  1. import { useState, useEffect, useRef } from 'react'
  2. import { toast } from 'sonner'
  3. import { Button } from '@/components/ui/button'
  4. import {
  5. Card,
  6. CardContent,
  7. CardDescription,
  8. CardHeader,
  9. CardTitle,
  10. } from '@/components/ui/card'
  11. import { Input } from '@/components/ui/input'
  12. import { Separator } from '@/components/ui/separator'
  13. import { Badge } from '@/components/ui/badge'
  14. import { Alert, AlertDescription } from '@/components/ui/alert'
  15. import {
  16. Dialog,
  17. DialogContent,
  18. DialogDescription,
  19. DialogHeader,
  20. DialogTitle,
  21. DialogTrigger,
  22. DialogFooter,
  23. } from '@/components/ui/dialog'
  24. import {
  25. Tooltip,
  26. TooltipContent,
  27. TooltipProvider,
  28. TooltipTrigger,
  29. } from '@/components/ui/tooltip'
  30. import { apiClient } from '@/lib/apiClient'
  31. import { useStatusStore } from '@/stores/useStatusStore'
  32. export function TableControlPage() {
  33. const [speedInput, setSpeedInput] = useState('')
  34. const [currentSpeed, setCurrentSpeed] = useState<number | null>(null)
  35. const [currentTheta, setCurrentTheta] = useState(0)
  36. const [isLoading, setIsLoading] = useState<string | null>(null)
  37. // Subscribe to shared status WebSocket via store
  38. const speed = useStatusStore((s) => s.status?.speed ?? null)
  39. const isPatternRunning = useStatusStore((s) =>
  40. (s.status?.is_running || s.status?.is_paused) ?? false
  41. )
  42. // Sync speed from store into local state (for the badge display)
  43. useEffect(() => {
  44. if (speed !== null) setCurrentSpeed(speed)
  45. }, [speed])
  46. // Serial terminal state
  47. const [consoleCommand, setConsoleCommand] = useState('')
  48. const [consoleHistory, setConsoleHistory] = useState<Array<{ type: 'cmd' | 'resp' | 'error'; text: string; time: string }>>([])
  49. const [consoleLoading, setConsoleLoading] = useState(false)
  50. const consoleOutputRef = useRef<HTMLDivElement>(null)
  51. const consoleInputRef = useRef<HTMLInputElement>(null)
  52. const handleAction = async (
  53. action: string,
  54. endpoint: string,
  55. body?: object
  56. ) => {
  57. setIsLoading(action)
  58. try {
  59. const data = await apiClient.post<{ success?: boolean; detail?: string }>(endpoint, body)
  60. if (data.success !== false) {
  61. return { success: true, data }
  62. }
  63. throw new Error(data.detail || 'Action failed')
  64. } catch (error) {
  65. console.error(`Error with ${action}:`, error)
  66. throw error
  67. } finally {
  68. setIsLoading(null)
  69. }
  70. }
  71. // Helper to check if pattern is running and show warning
  72. const checkPatternRunning = (actionName: string): boolean => {
  73. if (isPatternRunning) {
  74. toast.error(`Cannot ${actionName} while a pattern is running. Stop the pattern first.`, {
  75. action: {
  76. label: 'Stop',
  77. onClick: () => handleStop(),
  78. },
  79. })
  80. return true
  81. }
  82. return false
  83. }
  84. const handleHome = async () => {
  85. try {
  86. await handleAction('home', '/send_home')
  87. toast.success('Moving to home position...')
  88. } catch {
  89. toast.error('Failed to move to home position')
  90. }
  91. }
  92. const handleStop = async () => {
  93. try {
  94. await handleAction('stop', '/stop_execution')
  95. toast.success('Execution stopped')
  96. } catch {
  97. // Normal stop failed, try force stop
  98. try {
  99. await handleAction('stop', '/force_stop')
  100. toast.success('Force stopped')
  101. } catch {
  102. toast.error('Failed to stop execution')
  103. }
  104. }
  105. }
  106. const handleReset = async () => {
  107. try {
  108. await handleAction('reset', '/soft_reset')
  109. toast.success('Reset sent. Please home the table.')
  110. } catch {
  111. toast.error('Failed to send reset command')
  112. }
  113. }
  114. const handleMoveToCenter = async () => {
  115. if (checkPatternRunning('move to center')) return
  116. try {
  117. await handleAction('center', '/move_to_center')
  118. toast.success('Moving to center...')
  119. } catch {
  120. toast.error('Failed to move to center')
  121. }
  122. }
  123. const handleMoveToPerimeter = async () => {
  124. if (checkPatternRunning('move to perimeter')) return
  125. try {
  126. await handleAction('perimeter', '/move_to_perimeter')
  127. toast.success('Moving to perimeter...')
  128. } catch {
  129. toast.error('Failed to move to perimeter')
  130. }
  131. }
  132. const handleSetSpeed = async () => {
  133. const speed = parseFloat(speedInput)
  134. if (isNaN(speed) || speed <= 0) {
  135. toast.error('Please enter a valid speed value')
  136. return
  137. }
  138. try {
  139. await handleAction('speed', '/set_speed', { speed })
  140. setCurrentSpeed(speed)
  141. toast.success(`Speed set to ${speed} mm/s`)
  142. setSpeedInput('')
  143. } catch {
  144. toast.error('Failed to set speed')
  145. }
  146. }
  147. const handleClearPattern = async (patternFile: string, label: string) => {
  148. try {
  149. await handleAction(patternFile, '/run_theta_rho', {
  150. file_name: patternFile,
  151. pre_execution: 'none',
  152. })
  153. toast.success(`Running ${label}...`)
  154. } catch (error) {
  155. if (error instanceof Error && error.message.includes('409')) {
  156. toast.error('Another pattern is already running')
  157. } else {
  158. toast.error(`Failed to run ${label}`)
  159. }
  160. }
  161. }
  162. const handleRotate = async (degrees: number) => {
  163. if (checkPatternRunning('align')) return
  164. try {
  165. const radians = degrees * (Math.PI / 180)
  166. const newTheta = currentTheta + radians
  167. await handleAction('rotate', '/send_coordinate', { theta: newTheta, rho: 1 })
  168. setCurrentTheta(newTheta)
  169. toast.info(`Rotated ${degrees}°`)
  170. } catch {
  171. toast.error('Failed to rotate')
  172. }
  173. }
  174. // Board command console: sends $-commands to the FluidNC firmware through
  175. // the backend (/api/board/command); output comes from the board's reply and
  176. // its rolling session log.
  177. const addConsoleHistory = (type: 'cmd' | 'resp' | 'error', text: string) => {
  178. const time = new Date().toLocaleTimeString()
  179. setConsoleHistory((prev) => [...prev.slice(-200), { type, text, time }])
  180. setTimeout(() => {
  181. if (consoleOutputRef.current) {
  182. consoleOutputRef.current.scrollTop = consoleOutputRef.current.scrollHeight
  183. }
  184. }, 10)
  185. }
  186. const handleConsoleSend = async () => {
  187. if (!consoleCommand.trim() || consoleLoading) return
  188. const cmd = consoleCommand.trim()
  189. setConsoleCommand('')
  190. setConsoleLoading(true)
  191. addConsoleHistory('cmd', cmd)
  192. try {
  193. const data = await apiClient.post<{ responses?: string[]; log?: string; detail?: string }>(
  194. '/api/board/command', { command: cmd })
  195. const lines = data.responses ?? []
  196. if (lines.length > 0) {
  197. lines.forEach((line: string) => addConsoleHistory('resp', line))
  198. } else if (data.log) {
  199. data.log.split('\n').slice(-5).forEach((line: string) => addConsoleHistory('resp', line))
  200. } else {
  201. addConsoleHistory('resp', '(no response — check the board log)')
  202. }
  203. } catch (error) {
  204. addConsoleHistory('error', `Error: ${error}`)
  205. } finally {
  206. setConsoleLoading(false)
  207. setTimeout(() => consoleInputRef.current?.focus(), 0)
  208. }
  209. }
  210. const handleConsoleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
  211. if (e.key === 'Enter' && !e.shiftKey) {
  212. e.preventDefault()
  213. if (!consoleLoading) {
  214. handleConsoleSend()
  215. }
  216. }
  217. }
  218. return (
  219. <TooltipProvider>
  220. <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-3 sm:py-6 px-0 sm:px-4">
  221. {/* Page Header */}
  222. <div className="space-y-0.5 sm:space-y-1 pl-1">
  223. <h1 className="text-xl font-semibold tracking-tight">Table Control</h1>
  224. <p className="text-xs text-muted-foreground">
  225. Manual controls for your sand table
  226. </p>
  227. </div>
  228. <Separator />
  229. {/* Main Controls Grid - 2x2 */}
  230. <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
  231. {/* Primary Actions */}
  232. <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
  233. <CardHeader className="pb-3">
  234. <CardTitle className="text-lg">Primary Actions</CardTitle>
  235. <CardDescription>Calibrate or stop the table</CardDescription>
  236. </CardHeader>
  237. <CardContent>
  238. <div className="grid grid-cols-3 gap-3">
  239. <Tooltip>
  240. <TooltipTrigger asChild>
  241. <Button
  242. onClick={handleHome}
  243. disabled={isLoading === 'home'}
  244. variant="primary"
  245. className="h-16 gap-1 flex-col items-center justify-center"
  246. >
  247. {isLoading === 'home' ? (
  248. <span className="material-icons-outlined animate-spin text-2xl">sync</span>
  249. ) : (
  250. <span className="material-icons-outlined text-2xl">home</span>
  251. )}
  252. <span className="text-xs">Home</span>
  253. </Button>
  254. </TooltipTrigger>
  255. <TooltipContent>Return to home position</TooltipContent>
  256. </Tooltip>
  257. <Tooltip>
  258. <TooltipTrigger asChild>
  259. <Button
  260. onClick={handleStop}
  261. disabled={isLoading === 'stop'}
  262. variant="destructive"
  263. className="h-16 gap-1 flex-col items-center justify-center"
  264. >
  265. {isLoading === 'stop' ? (
  266. <span className="material-icons-outlined animate-spin text-2xl">sync</span>
  267. ) : (
  268. <span className="material-icons-outlined text-2xl">stop_circle</span>
  269. )}
  270. <span className="text-xs">Stop</span>
  271. </Button>
  272. </TooltipTrigger>
  273. <TooltipContent>Gracefully stop</TooltipContent>
  274. </Tooltip>
  275. <Dialog>
  276. <Tooltip>
  277. <TooltipTrigger asChild>
  278. <DialogTrigger asChild>
  279. <Button
  280. disabled={isLoading === 'reset'}
  281. variant="secondary"
  282. className="h-16 gap-1 flex-col items-center justify-center"
  283. >
  284. {isLoading === 'reset' ? (
  285. <span className="material-icons-outlined animate-spin text-2xl">sync</span>
  286. ) : (
  287. <span className="material-icons-outlined text-2xl">restart_alt</span>
  288. )}
  289. <span className="text-xs">Reset</span>
  290. </Button>
  291. </DialogTrigger>
  292. </TooltipTrigger>
  293. <TooltipContent>Send soft reset to controller</TooltipContent>
  294. </Tooltip>
  295. <DialogContent className="sm:max-w-md">
  296. <DialogHeader>
  297. <DialogTitle>Reset Controller?</DialogTitle>
  298. <DialogDescription>
  299. This will send a soft reset to the controller.
  300. </DialogDescription>
  301. </DialogHeader>
  302. <Alert className="flex items-center border-amber-500/50">
  303. <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">warning</span>
  304. <AlertDescription className="text-amber-600 dark:text-amber-400">
  305. Homing is required after resetting. The table will lose its position reference.
  306. </AlertDescription>
  307. </Alert>
  308. <DialogFooter className="gap-2 sm:gap-0">
  309. <DialogTrigger asChild>
  310. <Button variant="outline">Cancel</Button>
  311. </DialogTrigger>
  312. <DialogTrigger asChild>
  313. <Button variant="destructive" onClick={handleReset}>
  314. Reset Controller
  315. </Button>
  316. </DialogTrigger>
  317. </DialogFooter>
  318. </DialogContent>
  319. </Dialog>
  320. </div>
  321. </CardContent>
  322. </Card>
  323. {/* Speed Control */}
  324. <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
  325. <CardHeader className="pb-3">
  326. <div className="flex items-center justify-between">
  327. <div>
  328. <CardTitle className="text-lg">Speed</CardTitle>
  329. <CardDescription>Ball movement speed</CardDescription>
  330. </div>
  331. <Badge variant="secondary" className="font-mono">
  332. {currentSpeed !== null ? `${currentSpeed} mm/s` : '-- mm/s'}
  333. </Badge>
  334. </div>
  335. </CardHeader>
  336. <CardContent>
  337. <div className="flex gap-2">
  338. <Input
  339. type="number"
  340. value={speedInput}
  341. onChange={(e) => setSpeedInput(e.target.value)}
  342. placeholder="mm/s"
  343. min="1"
  344. step="1"
  345. className="flex-1"
  346. onKeyDown={(e) => e.key === 'Enter' && handleSetSpeed()}
  347. />
  348. <Button
  349. onClick={handleSetSpeed}
  350. disabled={isLoading === 'speed' || !speedInput}
  351. className="gap-2"
  352. >
  353. {isLoading === 'speed' ? (
  354. <span className="material-icons-outlined animate-spin">sync</span>
  355. ) : (
  356. <span className="material-icons-outlined">check</span>
  357. )}
  358. Set
  359. </Button>
  360. </div>
  361. </CardContent>
  362. </Card>
  363. {/* Position */}
  364. <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
  365. <CardHeader className="pb-3">
  366. <CardTitle className="text-lg">Position</CardTitle>
  367. <CardDescription>Move ball to a specific location</CardDescription>
  368. </CardHeader>
  369. <CardContent>
  370. <div className="grid grid-cols-3 gap-3">
  371. <Tooltip>
  372. <TooltipTrigger asChild>
  373. <Button
  374. onClick={handleMoveToCenter}
  375. disabled={isLoading === 'center'}
  376. variant="secondary"
  377. className="h-16 gap-1 flex-col items-center justify-center"
  378. >
  379. {isLoading === 'center' ? (
  380. <span className="material-icons-outlined animate-spin text-2xl">sync</span>
  381. ) : (
  382. <span className="material-icons-outlined text-2xl">center_focus_strong</span>
  383. )}
  384. <span className="text-xs">Center</span>
  385. </Button>
  386. </TooltipTrigger>
  387. <TooltipContent>Move ball to center</TooltipContent>
  388. </Tooltip>
  389. <Tooltip>
  390. <TooltipTrigger asChild>
  391. <Button
  392. onClick={handleMoveToPerimeter}
  393. disabled={isLoading === 'perimeter'}
  394. variant="secondary"
  395. className="h-16 gap-1 flex-col items-center justify-center"
  396. >
  397. {isLoading === 'perimeter' ? (
  398. <span className="material-icons-outlined animate-spin text-2xl">sync</span>
  399. ) : (
  400. <span className="material-icons-outlined text-2xl">trip_origin</span>
  401. )}
  402. <span className="text-xs">Perimeter</span>
  403. </Button>
  404. </TooltipTrigger>
  405. <TooltipContent>Move ball to edge</TooltipContent>
  406. </Tooltip>
  407. <Dialog>
  408. <Tooltip>
  409. <TooltipTrigger asChild>
  410. <DialogTrigger asChild>
  411. <Button
  412. variant="secondary"
  413. className="h-16 gap-1 flex-col items-center justify-center"
  414. >
  415. <span className="material-icons-outlined text-2xl">screen_rotation</span>
  416. <span className="text-xs">Align</span>
  417. </Button>
  418. </DialogTrigger>
  419. </TooltipTrigger>
  420. <TooltipContent>Align pattern orientation</TooltipContent>
  421. </Tooltip>
  422. <DialogContent className="sm:max-w-md">
  423. <DialogHeader>
  424. <DialogTitle>Pattern Orientation Alignment</DialogTitle>
  425. <DialogDescription>
  426. Follow these steps to align your patterns with their previews
  427. </DialogDescription>
  428. </DialogHeader>
  429. <div className="space-y-4 py-4">
  430. <ol className="space-y-3 text-sm">
  431. {[
  432. 'Home the table then select move to perimeter. Look at your pattern preview and decide where the "bottom" should be.',
  433. 'Manually move the radial arm or use the rotation buttons below to point 90° to the right of where you want the pattern bottom.',
  434. 'Click the "Home" button to establish this as the reference position.',
  435. 'All patterns will now be oriented according to their previews!',
  436. ].map((step, i) => (
  437. <li key={i} className="flex gap-3">
  438. <Badge
  439. variant="secondary"
  440. className="h-6 w-6 shrink-0 items-center justify-center rounded-full p-0"
  441. >
  442. {i + 1}
  443. </Badge>
  444. <span className="text-muted-foreground">{step}</span>
  445. </li>
  446. ))}
  447. </ol>
  448. <Separator />
  449. <Alert className="flex items-start border-amber-500/50">
  450. <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">
  451. warning
  452. </span>
  453. <AlertDescription className="text-amber-600 dark:text-amber-400">
  454. Only perform this when you want to change the orientation reference.
  455. </AlertDescription>
  456. </Alert>
  457. <div className="space-y-3">
  458. <p className="text-sm font-medium text-center">Fine Adjustment</p>
  459. <div className="flex justify-center gap-2">
  460. <Button
  461. variant="secondary"
  462. onClick={() => handleRotate(-10)}
  463. disabled={isLoading === 'rotate'}
  464. >
  465. <span className="material-icons text-lg mr-1">rotate_left</span>
  466. CCW 10°
  467. </Button>
  468. <Button
  469. variant="secondary"
  470. onClick={() => handleRotate(10)}
  471. disabled={isLoading === 'rotate'}
  472. >
  473. CW 10°
  474. <span className="material-icons text-lg ml-1">rotate_right</span>
  475. </Button>
  476. </div>
  477. <p className="text-xs text-muted-foreground text-center">
  478. Each click rotates 10 degrees
  479. </p>
  480. </div>
  481. </div>
  482. <DialogFooter>
  483. <DialogTrigger asChild>
  484. <Button>Got it</Button>
  485. </DialogTrigger>
  486. </DialogFooter>
  487. </DialogContent>
  488. </Dialog>
  489. </div>
  490. </CardContent>
  491. </Card>
  492. {/* Clear Patterns */}
  493. <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
  494. <CardHeader className="pb-3">
  495. <CardTitle className="text-lg">Clear Sand</CardTitle>
  496. <CardDescription>Erase current pattern from the table</CardDescription>
  497. </CardHeader>
  498. <CardContent>
  499. <div className="grid grid-cols-3 gap-3">
  500. <Tooltip>
  501. <TooltipTrigger asChild>
  502. <Button
  503. onClick={() => handleClearPattern('clear_from_in.thr', 'clear from center')}
  504. disabled={isLoading === 'clear_from_in.thr'}
  505. variant="secondary"
  506. className="h-16 gap-1 flex-col items-center justify-center"
  507. >
  508. {isLoading === 'clear_from_in.thr' ? (
  509. <span className="material-icons-outlined animate-spin text-2xl">sync</span>
  510. ) : (
  511. <span className="material-icons-outlined text-2xl">center_focus_strong</span>
  512. )}
  513. <span className="text-xs">Clear Center</span>
  514. </Button>
  515. </TooltipTrigger>
  516. <TooltipContent>Spiral outward from center</TooltipContent>
  517. </Tooltip>
  518. <Tooltip>
  519. <TooltipTrigger asChild>
  520. <Button
  521. onClick={() => handleClearPattern('clear_from_out.thr', 'clear from perimeter')}
  522. disabled={isLoading === 'clear_from_out.thr'}
  523. variant="secondary"
  524. className="h-16 gap-1 flex-col items-center justify-center"
  525. >
  526. {isLoading === 'clear_from_out.thr' ? (
  527. <span className="material-icons-outlined animate-spin text-2xl">sync</span>
  528. ) : (
  529. <span className="material-icons-outlined text-2xl">all_out</span>
  530. )}
  531. <span className="text-xs">Clear Edge</span>
  532. </Button>
  533. </TooltipTrigger>
  534. <TooltipContent>Spiral inward from edge</TooltipContent>
  535. </Tooltip>
  536. <Tooltip>
  537. <TooltipTrigger asChild>
  538. <Button
  539. onClick={() => handleClearPattern('clear_sideway.thr', 'clear sideways')}
  540. disabled={isLoading === 'clear_sideway.thr'}
  541. variant="secondary"
  542. className="h-16 gap-1 flex-col items-center justify-center"
  543. >
  544. {isLoading === 'clear_sideway.thr' ? (
  545. <span className="material-icons-outlined animate-spin text-2xl">sync</span>
  546. ) : (
  547. <span className="material-icons-outlined text-2xl">swap_horiz</span>
  548. )}
  549. <span className="text-xs">Clear Sideways</span>
  550. </Button>
  551. </TooltipTrigger>
  552. <TooltipContent>Clear with side-to-side motion</TooltipContent>
  553. </Tooltip>
  554. </div>
  555. </CardContent>
  556. </Card>
  557. </div>
  558. {/* Board Command Console */}
  559. <Card className="transition-all duration-200 hover:shadow-md hover:border-primary/20">
  560. <CardHeader className="pb-3 space-y-3">
  561. <div className="flex items-start justify-between gap-2">
  562. <div className="min-w-0 space-y-2">
  563. <CardTitle className="text-lg flex items-center gap-2">
  564. <span className="material-icons-outlined text-xl">terminal</span>
  565. Command Console
  566. </CardTitle>
  567. <CardDescription className="hidden sm:block">
  568. Send $-commands to the table's firmware (e.g. $Sand/HomingMode, $LED/Effect=fire)
  569. </CardDescription>
  570. <Alert className="flex items-center border-amber-500/50 py-2">
  571. <span className="material-icons-outlined text-amber-500 text-base mr-2 shrink-0">warning</span>
  572. <AlertDescription className="text-xs text-amber-600 dark:text-amber-400">
  573. For advanced use. Motion commands sent here can interfere with a running pattern.
  574. </AlertDescription>
  575. </Alert>
  576. </div>
  577. {consoleHistory.length > 0 && (
  578. <Button
  579. variant="ghost"
  580. size="icon"
  581. onClick={() => setConsoleHistory([])}
  582. title="Clear history"
  583. >
  584. <span className="material-icons-outlined">delete_sweep</span>
  585. </Button>
  586. )}
  587. </div>
  588. </CardHeader>
  589. <CardContent>
  590. {/* Output area */}
  591. <div
  592. ref={consoleOutputRef}
  593. className="bg-black/90 rounded-md p-3 h-48 overflow-y-auto font-mono text-sm mb-3"
  594. >
  595. {consoleHistory.length > 0 ? (
  596. consoleHistory.map((entry, i) => (
  597. <div
  598. key={i}
  599. className={`${
  600. entry.type === 'cmd'
  601. ? 'text-cyan-400'
  602. : entry.type === 'error'
  603. ? 'text-red-400'
  604. : 'text-green-400'
  605. }`}
  606. >
  607. <span className="text-gray-500 text-xs mr-2">{entry.time}</span>
  608. {entry.type === 'cmd' ? '> ' : ''}
  609. {entry.text}
  610. </div>
  611. ))
  612. ) : (
  613. <div className="text-gray-500 italic">
  614. Ready. Enter a firmware command (e.g. $Sand/HomingMode, $Playlist/List)
  615. </div>
  616. )}
  617. </div>
  618. {/* Input area */}
  619. <div className="flex gap-2">
  620. <Input
  621. ref={consoleInputRef}
  622. value={consoleCommand}
  623. onChange={(e) => setConsoleCommand(e.target.value)}
  624. onKeyDown={handleConsoleKeyDown}
  625. readOnly={consoleLoading}
  626. placeholder="Enter command (e.g. $Sand/HomingMode)"
  627. className="font-mono text-base h-11"
  628. />
  629. <Button
  630. onClick={handleConsoleSend}
  631. disabled={!consoleCommand.trim() || consoleLoading}
  632. className="h-11 px-6"
  633. >
  634. {consoleLoading ? (
  635. <span className="material-icons-outlined animate-spin">sync</span>
  636. ) : (
  637. <span className="material-icons-outlined">send</span>
  638. )}
  639. </Button>
  640. </div>
  641. </CardContent>
  642. </Card>
  643. </div>
  644. </TooltipProvider>
  645. )
  646. }