SpoolmanSettings.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. import { useState, useEffect } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { Loader2, Check, X, RefreshCw, Link2, Link2Off, Database, ChevronDown } from 'lucide-react';
  4. import { api } from '../api/client';
  5. import type { SpoolmanSyncResult, Printer } from '../api/client';
  6. import { Card, CardContent, CardHeader } from './Card';
  7. import { Button } from './Button';
  8. interface SpoolmanSettingsData {
  9. spoolman_enabled: string;
  10. spoolman_url: string;
  11. spoolman_sync_mode: string;
  12. }
  13. async function getSpoolmanSettings(): Promise<SpoolmanSettingsData> {
  14. const response = await fetch('/api/v1/settings/spoolman');
  15. if (!response.ok) {
  16. throw new Error('Failed to load Spoolman settings');
  17. }
  18. return response.json();
  19. }
  20. async function updateSpoolmanSettings(data: Partial<SpoolmanSettingsData>): Promise<SpoolmanSettingsData> {
  21. const response = await fetch('/api/v1/settings/spoolman', {
  22. method: 'PUT',
  23. headers: { 'Content-Type': 'application/json' },
  24. body: JSON.stringify(data),
  25. });
  26. if (!response.ok) {
  27. throw new Error('Failed to save Spoolman settings');
  28. }
  29. return response.json();
  30. }
  31. export function SpoolmanSettings() {
  32. const queryClient = useQueryClient();
  33. const [localEnabled, setLocalEnabled] = useState(false);
  34. const [localUrl, setLocalUrl] = useState('');
  35. const [localSyncMode, setLocalSyncMode] = useState('auto');
  36. const [showSaved, setShowSaved] = useState(false);
  37. const [selectedPrinterId, setSelectedPrinterId] = useState<number | 'all'>('all');
  38. const [isInitialized, setIsInitialized] = useState(false);
  39. // Fetch Spoolman settings
  40. const { data: settings, isLoading: settingsLoading } = useQuery({
  41. queryKey: ['spoolman-settings'],
  42. queryFn: getSpoolmanSettings,
  43. });
  44. // Fetch Spoolman status
  45. const { data: status, isLoading: statusLoading, refetch: refetchStatus } = useQuery({
  46. queryKey: ['spoolman-status'],
  47. queryFn: api.getSpoolmanStatus,
  48. refetchInterval: 30000, // Refresh every 30 seconds
  49. });
  50. // Fetch printers for the dropdown
  51. const { data: printers } = useQuery({
  52. queryKey: ['printers'],
  53. queryFn: api.getPrinters,
  54. });
  55. // Initialize local state from settings
  56. useEffect(() => {
  57. if (settings) {
  58. setLocalEnabled(settings.spoolman_enabled === 'true');
  59. setLocalUrl(settings.spoolman_url || '');
  60. setLocalSyncMode(settings.spoolman_sync_mode || 'auto');
  61. setIsInitialized(true);
  62. }
  63. }, [settings]);
  64. // Auto-save when settings change (after initial load)
  65. useEffect(() => {
  66. if (!isInitialized || !settings) return;
  67. const hasChanges =
  68. (settings.spoolman_enabled === 'true') !== localEnabled ||
  69. (settings.spoolman_url || '') !== localUrl ||
  70. (settings.spoolman_sync_mode || 'auto') !== localSyncMode;
  71. if (hasChanges) {
  72. const timeoutId = setTimeout(() => {
  73. saveMutation.mutate();
  74. }, 500);
  75. return () => clearTimeout(timeoutId);
  76. }
  77. }, [localEnabled, localUrl, localSyncMode, isInitialized]);
  78. // Save mutation
  79. const saveMutation = useMutation({
  80. mutationFn: () =>
  81. updateSpoolmanSettings({
  82. spoolman_enabled: localEnabled ? 'true' : 'false',
  83. spoolman_url: localUrl,
  84. spoolman_sync_mode: localSyncMode,
  85. }),
  86. onSuccess: () => {
  87. queryClient.invalidateQueries({ queryKey: ['spoolman-settings'] });
  88. queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
  89. setShowSaved(true);
  90. setTimeout(() => setShowSaved(false), 2000);
  91. },
  92. });
  93. // Connect mutation
  94. const connectMutation = useMutation({
  95. mutationFn: api.connectSpoolman,
  96. onSuccess: () => {
  97. refetchStatus();
  98. },
  99. });
  100. // Disconnect mutation
  101. const disconnectMutation = useMutation({
  102. mutationFn: api.disconnectSpoolman,
  103. onSuccess: () => {
  104. refetchStatus();
  105. },
  106. });
  107. // Sync all mutation
  108. const syncAllMutation = useMutation({
  109. mutationFn: api.syncAllPrintersAms,
  110. onSuccess: (data: SpoolmanSyncResult) => {
  111. if (data.success) {
  112. // Show success message
  113. }
  114. },
  115. });
  116. // Sync single printer mutation
  117. const syncPrinterMutation = useMutation({
  118. mutationFn: (printerId: number) => api.syncPrinterAms(printerId),
  119. onSuccess: (data: SpoolmanSyncResult) => {
  120. if (data.success) {
  121. // Show success message
  122. }
  123. },
  124. });
  125. // Helper to handle sync based on selection
  126. const handleSync = () => {
  127. if (selectedPrinterId === 'all') {
  128. syncAllMutation.mutate();
  129. } else {
  130. syncPrinterMutation.mutate(selectedPrinterId);
  131. }
  132. };
  133. // Combine mutation states
  134. const isSyncing = syncAllMutation.isPending || syncPrinterMutation.isPending;
  135. const syncResult = selectedPrinterId === 'all' ? syncAllMutation.data : syncPrinterMutation.data;
  136. const syncSuccess = selectedPrinterId === 'all' ? syncAllMutation.isSuccess : syncPrinterMutation.isSuccess;
  137. if (settingsLoading) {
  138. return (
  139. <Card>
  140. <CardHeader>
  141. <div className="flex items-center gap-2">
  142. <Database className="w-5 h-5 text-bambu-green" />
  143. <h2 className="text-lg font-semibold text-white">Spoolman Integration</h2>
  144. </div>
  145. </CardHeader>
  146. <CardContent>
  147. <div className="flex justify-center py-8">
  148. <Loader2 className="w-6 h-6 text-bambu-green animate-spin" />
  149. </div>
  150. </CardContent>
  151. </Card>
  152. );
  153. }
  154. return (
  155. <Card>
  156. <CardHeader>
  157. <div className="flex items-center justify-between">
  158. <div className="flex items-center gap-2">
  159. <Database className="w-5 h-5 text-bambu-green" />
  160. <h2 className="text-lg font-semibold text-white">Spoolman Integration</h2>
  161. </div>
  162. {saveMutation.isPending && (
  163. <Loader2 className="w-4 h-4 text-bambu-green animate-spin" />
  164. )}
  165. {showSaved && (
  166. <Check className="w-4 h-4 text-bambu-green" />
  167. )}
  168. </div>
  169. </CardHeader>
  170. <CardContent className="space-y-4">
  171. <p className="text-sm text-bambu-gray">
  172. Connect to Spoolman for filament inventory tracking. AMS data will sync automatically.
  173. </p>
  174. {/* Enable toggle */}
  175. <div className="flex items-center justify-between">
  176. <div>
  177. <p className="text-white">Enable Spoolman</p>
  178. <p className="text-sm text-bambu-gray">
  179. Sync filament data with Spoolman server
  180. </p>
  181. </div>
  182. <label className="relative inline-flex items-center cursor-pointer">
  183. <input
  184. type="checkbox"
  185. checked={localEnabled}
  186. onChange={(e) => setLocalEnabled(e.target.checked)}
  187. className="sr-only peer"
  188. />
  189. <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
  190. </label>
  191. </div>
  192. {/* URL input */}
  193. <div>
  194. <label className="block text-sm text-bambu-gray mb-1">
  195. Spoolman URL
  196. </label>
  197. <input
  198. type="text"
  199. placeholder="http://192.168.1.100:7912"
  200. value={localUrl}
  201. onChange={(e) => setLocalUrl(e.target.value)}
  202. disabled={!localEnabled}
  203. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray/50 focus:border-bambu-green focus:outline-none disabled:opacity-50"
  204. />
  205. <p className="text-xs text-bambu-gray mt-1">
  206. URL of your Spoolman server (e.g., http://localhost:7912)
  207. </p>
  208. </div>
  209. {/* Sync mode */}
  210. <div>
  211. <label className="block text-sm text-bambu-gray mb-1">
  212. Sync Mode
  213. </label>
  214. <select
  215. value={localSyncMode}
  216. onChange={(e) => setLocalSyncMode(e.target.value)}
  217. disabled={!localEnabled}
  218. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-50"
  219. >
  220. <option value="auto">Automatic</option>
  221. <option value="manual">Manual Only</option>
  222. </select>
  223. <p className="text-xs text-bambu-gray mt-1">
  224. {localSyncMode === 'auto'
  225. ? 'AMS data syncs automatically when changes are detected'
  226. : 'Only sync when manually triggered'}
  227. </p>
  228. </div>
  229. {/* Connection status */}
  230. {localEnabled && (
  231. <div className="pt-2 border-t border-bambu-dark-tertiary">
  232. <div className="flex items-center justify-between mb-3">
  233. <div className="flex items-center gap-2">
  234. <span className="text-sm text-bambu-gray">Status:</span>
  235. {statusLoading ? (
  236. <Loader2 className="w-4 h-4 text-bambu-gray animate-spin" />
  237. ) : status?.connected ? (
  238. <span className="flex items-center gap-1 text-sm text-green-500">
  239. <Check className="w-4 h-4" />
  240. Connected
  241. </span>
  242. ) : (
  243. <span className="flex items-center gap-1 text-sm text-red-500">
  244. <X className="w-4 h-4" />
  245. Disconnected
  246. </span>
  247. )}
  248. </div>
  249. <div className="flex gap-2">
  250. {status?.connected ? (
  251. <Button
  252. variant="secondary"
  253. size="sm"
  254. onClick={() => disconnectMutation.mutate()}
  255. disabled={disconnectMutation.isPending}
  256. >
  257. {disconnectMutation.isPending ? (
  258. <Loader2 className="w-4 h-4 animate-spin" />
  259. ) : (
  260. <Link2Off className="w-4 h-4" />
  261. )}
  262. Disconnect
  263. </Button>
  264. ) : (
  265. <Button
  266. size="sm"
  267. onClick={() => connectMutation.mutate()}
  268. disabled={connectMutation.isPending || !localUrl}
  269. >
  270. {connectMutation.isPending ? (
  271. <Loader2 className="w-4 h-4 animate-spin" />
  272. ) : (
  273. <Link2 className="w-4 h-4" />
  274. )}
  275. Connect
  276. </Button>
  277. )}
  278. </div>
  279. </div>
  280. {/* Error display */}
  281. {connectMutation.isError && (
  282. <div className="mb-3 p-2 bg-red-500/20 border border-red-500/50 rounded text-sm text-red-400">
  283. {(connectMutation.error as Error).message}
  284. </div>
  285. )}
  286. {/* Manual sync section */}
  287. {status?.connected && (
  288. <div className="space-y-3">
  289. <div>
  290. <p className="text-sm text-white">Sync AMS Data</p>
  291. <p className="text-xs text-bambu-gray">
  292. Manually sync printer AMS data to Spoolman
  293. </p>
  294. </div>
  295. <div className="flex items-center gap-2">
  296. {/* Printer selector */}
  297. <div className="relative flex-1">
  298. <select
  299. value={selectedPrinterId}
  300. onChange={(e) => setSelectedPrinterId(e.target.value === 'all' ? 'all' : Number(e.target.value))}
  301. className="w-full px-3 py-2 pr-8 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none appearance-none cursor-pointer"
  302. >
  303. <option value="all">All Printers</option>
  304. {printers?.map((printer: Printer) => (
  305. <option key={printer.id} value={printer.id}>
  306. {printer.name}
  307. </option>
  308. ))}
  309. </select>
  310. <ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
  311. </div>
  312. {/* Sync button */}
  313. <Button
  314. variant="secondary"
  315. size="sm"
  316. onClick={handleSync}
  317. disabled={isSyncing}
  318. >
  319. {isSyncing ? (
  320. <Loader2 className="w-4 h-4 animate-spin" />
  321. ) : (
  322. <RefreshCw className="w-4 h-4" />
  323. )}
  324. Sync
  325. </Button>
  326. </div>
  327. </div>
  328. )}
  329. {/* Sync result */}
  330. {syncSuccess && syncResult && (
  331. <div
  332. className={`mt-2 p-2 rounded text-sm ${
  333. syncResult.success
  334. ? 'bg-green-500/20 border border-green-500/50 text-green-400'
  335. : 'bg-yellow-500/20 border border-yellow-500/50 text-yellow-400'
  336. }`}
  337. >
  338. {syncResult.success
  339. ? `Synced ${syncResult.synced_count} trays successfully`
  340. : `Synced ${syncResult.synced_count} trays with ${syncResult.errors.length} errors`}
  341. </div>
  342. )}
  343. </div>
  344. )}
  345. </CardContent>
  346. </Card>
  347. );
  348. }