SpoolBuddySettings.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. import { useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
  4. import {
  5. Loader2,
  6. Trash2,
  7. Activity,
  8. Cpu,
  9. HardDrive,
  10. Thermometer,
  11. Wifi,
  12. WifiOff,
  13. AlertTriangle,
  14. Info,
  15. CheckCircle2,
  16. XCircle,
  17. Clock,
  18. Download,
  19. Monitor,
  20. RefreshCw,
  21. RotateCw,
  22. Power,
  23. } from 'lucide-react';
  24. import { spoolbuddyApi, type SpoolBuddyDevice } from '../api/client';
  25. import { Card, CardContent, CardHeader } from './Card';
  26. import { Button } from './Button';
  27. import { ConfirmModal } from './ConfirmModal';
  28. import { useToast } from '../contexts/ToastContext';
  29. import { formatRelativeTime } from '../utils/date';
  30. function formatUptime(seconds: number): string {
  31. if (seconds < 60) return `${seconds}s`;
  32. const m = Math.floor(seconds / 60);
  33. if (m < 60) return `${m}m`;
  34. const h = Math.floor(m / 60);
  35. const remM = m % 60;
  36. if (h < 24) return remM ? `${h}h ${remM}m` : `${h}h`;
  37. const d = Math.floor(h / 24);
  38. const remH = h % 24;
  39. return remH ? `${d}d ${remH}h` : `${d}d`;
  40. }
  41. function formatMB(mb?: number): string {
  42. if (mb === undefined || mb === null) return '—';
  43. if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
  44. return `${Math.round(mb)} MB`;
  45. }
  46. interface DeviceCardProps {
  47. device: SpoolBuddyDevice;
  48. onUnregister: (device: SpoolBuddyDevice) => void;
  49. isDeleting: boolean;
  50. }
  51. type ActionKey = 'update' | 'restart_browser' | 'restart_daemon' | 'reboot' | 'shutdown';
  52. function DeviceCard({ device, onUnregister, isDeleting }: DeviceCardProps) {
  53. const { t } = useTranslation();
  54. const { showToast } = useToast();
  55. const stats = device.system_stats;
  56. const mem = stats?.memory;
  57. const disk = stats?.disk;
  58. const online = device.online;
  59. const [pendingAction, setPendingAction] = useState<ActionKey | null>(null);
  60. const [busyAction, setBusyAction] = useState<ActionKey | null>(null);
  61. const runAction = async (action: ActionKey) => {
  62. setBusyAction(action);
  63. try {
  64. if (action === 'update') {
  65. await spoolbuddyApi.triggerUpdate(device.device_id);
  66. } else {
  67. await spoolbuddyApi.systemCommand(device.device_id, action);
  68. }
  69. showToast(t('settings.spoolbuddy.commandQueued'), 'success');
  70. } catch (err) {
  71. const msg = err instanceof Error ? err.message : t('settings.spoolbuddy.commandError');
  72. showToast(msg, 'error');
  73. } finally {
  74. setBusyAction(null);
  75. setPendingAction(null);
  76. }
  77. };
  78. const actions: { key: ActionKey; label: string; icon: typeof Download; variant?: 'danger' }[] = [
  79. { key: 'update', label: t('settings.spoolbuddy.update'), icon: Download },
  80. { key: 'restart_browser', label: t('settings.spoolbuddy.restartBrowser'), icon: Monitor },
  81. { key: 'restart_daemon', label: t('settings.spoolbuddy.restartDaemon'), icon: RefreshCw },
  82. { key: 'reboot', label: t('settings.spoolbuddy.reboot'), icon: RotateCw },
  83. { key: 'shutdown', label: t('settings.spoolbuddy.shutdown'), icon: Power, variant: 'danger' },
  84. ];
  85. const confirmTitles: Record<ActionKey, string> = {
  86. update: t('settings.spoolbuddy.updateConfirmTitle'),
  87. restart_browser: t('settings.spoolbuddy.restartBrowserConfirmTitle'),
  88. restart_daemon: t('settings.spoolbuddy.restartDaemonConfirmTitle'),
  89. reboot: t('settings.spoolbuddy.rebootConfirmTitle'),
  90. shutdown: t('settings.spoolbuddy.shutdownConfirmTitle'),
  91. };
  92. const confirmBodies: Record<ActionKey, string> = {
  93. update: t('settings.spoolbuddy.updateConfirmBody', { hostname: device.hostname }),
  94. restart_browser: t('settings.spoolbuddy.restartBrowserConfirmBody', { hostname: device.hostname }),
  95. restart_daemon: t('settings.spoolbuddy.restartDaemonConfirmBody', { hostname: device.hostname }),
  96. reboot: t('settings.spoolbuddy.rebootConfirmBody', { hostname: device.hostname }),
  97. shutdown: t('settings.spoolbuddy.shutdownConfirmBody', { hostname: device.hostname }),
  98. };
  99. return (
  100. <Card>
  101. <CardHeader>
  102. <div className="flex items-start justify-between gap-3 flex-wrap">
  103. <div className="min-w-0">
  104. <div className="flex items-center gap-2">
  105. <h3 className="text-base font-semibold text-white truncate">{device.hostname}</h3>
  106. <span
  107. className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
  108. online
  109. ? 'bg-green-500/15 text-green-400 border border-green-500/40'
  110. : 'bg-gray-500/15 text-gray-400 border border-gray-500/40'
  111. }`}
  112. >
  113. {online ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
  114. {online ? t('settings.spoolbuddy.online') : t('settings.spoolbuddy.offline')}
  115. </span>
  116. </div>
  117. <p className="text-xs text-bambu-gray font-mono mt-1 truncate">{device.device_id}</p>
  118. </div>
  119. <Button
  120. variant="danger"
  121. size="sm"
  122. onClick={() => onUnregister(device)}
  123. disabled={isDeleting}
  124. aria-label={t('settings.spoolbuddy.unregister')}
  125. >
  126. <Trash2 className="w-3.5 h-3.5" />
  127. <span className="hidden sm:inline">{t('settings.spoolbuddy.unregister')}</span>
  128. </Button>
  129. </div>
  130. </CardHeader>
  131. <CardContent className="space-y-3">
  132. {/* Connection */}
  133. <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
  134. <div>
  135. <div className="text-bambu-gray">{t('settings.spoolbuddy.ipAddress')}</div>
  136. <div className="text-white font-mono">{device.ip_address}</div>
  137. </div>
  138. <div>
  139. <div className="text-bambu-gray">{t('settings.spoolbuddy.firmware')}</div>
  140. <div className="text-white">{device.firmware_version ?? '—'}</div>
  141. </div>
  142. <div>
  143. <div className="text-bambu-gray flex items-center gap-1">
  144. <Clock className="w-3 h-3" />
  145. {t('settings.spoolbuddy.lastSeen')}
  146. </div>
  147. <div className="text-white">
  148. {device.last_seen ? formatRelativeTime(device.last_seen) : t('settings.spoolbuddy.never')}
  149. </div>
  150. </div>
  151. <div>
  152. <div className="text-bambu-gray">{t('settings.spoolbuddy.daemonUptime')}</div>
  153. <div className="text-white">{formatUptime(device.uptime_s)}</div>
  154. </div>
  155. </div>
  156. {/* Action buttons */}
  157. <div className="flex flex-wrap gap-2">
  158. {actions.map(({ key, label, icon: Icon, variant }) => (
  159. <Button
  160. key={key}
  161. variant={variant ?? 'secondary'}
  162. size="sm"
  163. onClick={() => setPendingAction(key)}
  164. disabled={!online || busyAction !== null}
  165. aria-label={label}
  166. >
  167. {busyAction === key ? (
  168. <Loader2 className="w-3.5 h-3.5 animate-spin" />
  169. ) : (
  170. <Icon className="w-3.5 h-3.5" />
  171. )}
  172. <span>{label}</span>
  173. </Button>
  174. ))}
  175. </div>
  176. {/* Hardware flags */}
  177. <div className="flex items-center gap-3 text-xs flex-wrap">
  178. <span className="flex items-center gap-1 text-bambu-gray">
  179. {device.nfc_ok ? (
  180. <CheckCircle2 className="w-3.5 h-3.5 text-green-400" />
  181. ) : (
  182. <XCircle className="w-3.5 h-3.5 text-red-400" />
  183. )}
  184. {t('settings.spoolbuddy.nfc')}
  185. {device.nfc_reader_type && <span className="text-bambu-gray/70">({device.nfc_reader_type})</span>}
  186. </span>
  187. <span className="flex items-center gap-1 text-bambu-gray">
  188. {device.scale_ok ? (
  189. <CheckCircle2 className="w-3.5 h-3.5 text-green-400" />
  190. ) : (
  191. <XCircle className="w-3.5 h-3.5 text-red-400" />
  192. )}
  193. {t('settings.spoolbuddy.scale')}
  194. </span>
  195. </div>
  196. {/* System stats */}
  197. {stats && (
  198. <div className="pt-2 border-t border-bambu-dark-tertiary">
  199. <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 text-xs">
  200. {stats.cpu_temp_c !== undefined && (
  201. <div className="flex items-center gap-2">
  202. <Thermometer className="w-3.5 h-3.5 text-bambu-gray" />
  203. <div>
  204. <div className="text-bambu-gray">{t('settings.spoolbuddy.cpuTemp')}</div>
  205. <div className="text-white">{stats.cpu_temp_c.toFixed(1)}°C</div>
  206. </div>
  207. </div>
  208. )}
  209. {stats.load_avg && stats.load_avg.length > 0 && (
  210. <div className="flex items-center gap-2">
  211. <Activity className="w-3.5 h-3.5 text-bambu-gray" />
  212. <div>
  213. <div className="text-bambu-gray">{t('settings.spoolbuddy.cpuLoad')}</div>
  214. <div className="text-white">
  215. {stats.load_avg[0].toFixed(2)}
  216. {stats.cpu_count
  217. ? ` / ${stats.cpu_count} (${Math.round((stats.load_avg[0] / stats.cpu_count) * 100)}%)`
  218. : ''}
  219. </div>
  220. </div>
  221. </div>
  222. )}
  223. {mem && mem.percent !== undefined && (
  224. <div className="flex items-center gap-2">
  225. <Cpu className="w-3.5 h-3.5 text-bambu-gray" />
  226. <div>
  227. <div className="text-bambu-gray">{t('settings.spoolbuddy.memory')}</div>
  228. <div className="text-white">
  229. {mem.percent.toFixed(0)}% ({formatMB(mem.used_mb)} / {formatMB(mem.total_mb)})
  230. </div>
  231. </div>
  232. </div>
  233. )}
  234. {disk && disk.percent !== undefined && (
  235. <div className="flex items-center gap-2">
  236. <HardDrive className="w-3.5 h-3.5 text-bambu-gray" />
  237. <div>
  238. <div className="text-bambu-gray">{t('settings.spoolbuddy.disk')}</div>
  239. <div className="text-white">
  240. {disk.percent.toFixed(0)}% ({disk.used_gb?.toFixed(1)} / {disk.total_gb?.toFixed(1)} GB)
  241. </div>
  242. </div>
  243. </div>
  244. )}
  245. {stats.system_uptime_s !== undefined && (
  246. <div className="flex items-center gap-2">
  247. <Clock className="w-3.5 h-3.5 text-bambu-gray" />
  248. <div>
  249. <div className="text-bambu-gray">{t('settings.spoolbuddy.systemUptime')}</div>
  250. <div className="text-white">{formatUptime(stats.system_uptime_s)}</div>
  251. </div>
  252. </div>
  253. )}
  254. </div>
  255. {stats.os && (
  256. <div className="mt-3 text-xs text-bambu-gray font-mono truncate">
  257. {[stats.os.os, stats.os.kernel, stats.os.arch, stats.os.python && `Python ${stats.os.python}`]
  258. .filter(Boolean)
  259. .join(' · ')}
  260. </div>
  261. )}
  262. </div>
  263. )}
  264. </CardContent>
  265. {pendingAction && (
  266. <ConfirmModal
  267. variant={pendingAction === 'shutdown' || pendingAction === 'reboot' ? 'danger' : 'default'}
  268. title={confirmTitles[pendingAction]}
  269. message={confirmBodies[pendingAction]}
  270. confirmText={t('settings.spoolbuddy.commandConfirm')}
  271. isLoading={busyAction !== null}
  272. onConfirm={() => runAction(pendingAction)}
  273. onCancel={() => setPendingAction(null)}
  274. />
  275. )}
  276. </Card>
  277. );
  278. }
  279. export function SpoolBuddySettings() {
  280. const { t } = useTranslation();
  281. const queryClient = useQueryClient();
  282. const { showToast } = useToast();
  283. const [pendingDelete, setPendingDelete] = useState<SpoolBuddyDevice | null>(null);
  284. const { data: devices = [], isLoading } = useQuery({
  285. queryKey: ['spoolbuddy-devices'],
  286. queryFn: () => spoolbuddyApi.getDevices(),
  287. refetchInterval: 15000,
  288. });
  289. const deleteMutation = useMutation({
  290. mutationFn: (deviceId: string) => spoolbuddyApi.deleteDevice(deviceId),
  291. onSuccess: () => {
  292. queryClient.invalidateQueries({ queryKey: ['spoolbuddy-devices'] });
  293. showToast(t('settings.spoolbuddy.unregisterSuccess'), 'success');
  294. setPendingDelete(null);
  295. },
  296. onError: (err: Error) => {
  297. showToast(err.message || t('settings.spoolbuddy.unregisterError'), 'error');
  298. },
  299. });
  300. if (isLoading) {
  301. return (
  302. <Card>
  303. <CardContent className="py-8 flex justify-center">
  304. <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
  305. </CardContent>
  306. </Card>
  307. );
  308. }
  309. const hasDuplicates = devices.length > 1;
  310. return (
  311. <div className="space-y-4">
  312. <Card>
  313. <CardContent className="py-3 px-4">
  314. <div className="flex items-start gap-2 text-xs">
  315. <Info className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
  316. <div className="text-bambu-gray">
  317. <p className="text-white font-medium mb-1">{t('settings.spoolbuddy.infoTitle')}</p>
  318. <p>{t('settings.spoolbuddy.infoBody')}</p>
  319. </div>
  320. </div>
  321. </CardContent>
  322. </Card>
  323. {hasDuplicates && (
  324. <Card className="border-l-4 border-l-yellow-500">
  325. <CardContent className="py-3 px-4">
  326. <div className="flex items-start gap-2 text-xs">
  327. <AlertTriangle className="w-4 h-4 text-yellow-500 flex-shrink-0 mt-0.5" />
  328. <div className="text-bambu-gray">
  329. <p className="text-white font-medium mb-1">
  330. {t('settings.spoolbuddy.duplicatesTitle', { count: devices.length })}
  331. </p>
  332. <p>{t('settings.spoolbuddy.duplicatesBody')}</p>
  333. </div>
  334. </div>
  335. </CardContent>
  336. </Card>
  337. )}
  338. {devices.length === 0 ? (
  339. <Card>
  340. <CardContent className="py-8 text-center text-bambu-gray text-sm">
  341. {t('settings.spoolbuddy.empty')}
  342. </CardContent>
  343. </Card>
  344. ) : (
  345. <div className="space-y-3">
  346. {devices.map((device) => (
  347. <DeviceCard
  348. key={device.id}
  349. device={device}
  350. onUnregister={setPendingDelete}
  351. isDeleting={deleteMutation.isPending && deleteMutation.variables === device.device_id}
  352. />
  353. ))}
  354. </div>
  355. )}
  356. {pendingDelete && (
  357. <ConfirmModal
  358. variant="danger"
  359. title={t('settings.spoolbuddy.confirmTitle')}
  360. message={t('settings.spoolbuddy.confirmBody', {
  361. hostname: pendingDelete.hostname,
  362. deviceId: pendingDelete.device_id,
  363. })}
  364. confirmText={t('settings.spoolbuddy.unregister')}
  365. isLoading={deleteMutation.isPending}
  366. onConfirm={() => deleteMutation.mutate(pendingDelete.device_id)}
  367. onCancel={() => setPendingDelete(null)}
  368. />
  369. )}
  370. </div>
  371. );
  372. }