FailureDetectionSettings.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. import { useState, useEffect } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  4. import { Loader2, ScanEye, Check, X, AlertTriangle, Info } from 'lucide-react';
  5. import { api } from '../api/client';
  6. import { Card, CardContent, CardHeader } from './Card';
  7. import { Button } from './Button';
  8. import { Toggle } from './Toggle';
  9. import { useToast } from '../contexts/ToastContext';
  10. type TestResult = { ok: boolean; message: string } | null;
  11. export function FailureDetectionSettings() {
  12. const { t } = useTranslation();
  13. const queryClient = useQueryClient();
  14. const { showToast } = useToast();
  15. const [enabled, setEnabled] = useState(false);
  16. const [mlUrl, setMlUrl] = useState('');
  17. const [sensitivity, setSensitivity] = useState<'low' | 'medium' | 'high'>('medium');
  18. const [action, setAction] = useState<'notify' | 'pause' | 'pause_and_off'>('notify');
  19. const [pollInterval, setPollInterval] = useState(10);
  20. const [enabledPrinters, setEnabledPrinters] = useState<number[] | null>(null); // null = all
  21. const [testResult, setTestResult] = useState<TestResult>(null);
  22. const [initialized, setInitialized] = useState(false);
  23. const { data: settings } = useQuery({
  24. queryKey: ['settings'],
  25. queryFn: api.getSettings,
  26. });
  27. const { data: status, refetch: refetchStatus } = useQuery({
  28. queryKey: ['obico-status'],
  29. queryFn: api.getObicoStatus,
  30. refetchInterval: 10000,
  31. });
  32. const { data: printers } = useQuery({
  33. queryKey: ['printers'],
  34. queryFn: api.getPrinters,
  35. });
  36. useEffect(() => {
  37. if (!settings) return;
  38. setEnabled(settings.obico_enabled ?? false);
  39. setMlUrl(settings.obico_ml_url ?? '');
  40. setSensitivity(settings.obico_sensitivity ?? 'medium');
  41. setAction(settings.obico_action ?? 'notify');
  42. setPollInterval(settings.obico_poll_interval ?? 10);
  43. try {
  44. const list = settings.obico_enabled_printers
  45. ? (JSON.parse(settings.obico_enabled_printers) as number[])
  46. : null;
  47. setEnabledPrinters(Array.isArray(list) ? list : null);
  48. } catch {
  49. setEnabledPrinters(null);
  50. }
  51. setInitialized(true);
  52. }, [settings]);
  53. const saveMutation = useMutation({
  54. mutationFn: () =>
  55. api.updateSettings({
  56. obico_enabled: enabled,
  57. obico_ml_url: mlUrl,
  58. obico_sensitivity: sensitivity,
  59. obico_action: action,
  60. obico_poll_interval: pollInterval,
  61. obico_enabled_printers: enabledPrinters === null ? '' : JSON.stringify(enabledPrinters),
  62. }),
  63. onSuccess: () => {
  64. queryClient.invalidateQueries({ queryKey: ['settings'] });
  65. queryClient.invalidateQueries({ queryKey: ['obico-status'] });
  66. // Printer-card AI badges (#1546) — refresh immediately on toggle instead
  67. // of waiting for the cards' 10s poll.
  68. queryClient.invalidateQueries({ queryKey: ['obico-printer-status'] });
  69. showToast(t('settings.toast.settingsSaved'));
  70. },
  71. });
  72. // Auto-save on change (debounced)
  73. useEffect(() => {
  74. if (!initialized || !settings) return;
  75. const changed =
  76. settings.obico_enabled !== enabled ||
  77. settings.obico_ml_url !== mlUrl ||
  78. settings.obico_sensitivity !== sensitivity ||
  79. settings.obico_action !== action ||
  80. settings.obico_poll_interval !== pollInterval ||
  81. settings.obico_enabled_printers !== (enabledPrinters === null ? '' : JSON.stringify(enabledPrinters));
  82. if (!changed) return;
  83. const id = setTimeout(() => saveMutation.mutate(), 500);
  84. return () => clearTimeout(id);
  85. // eslint-disable-next-line react-hooks/exhaustive-deps
  86. }, [enabled, mlUrl, sensitivity, action, pollInterval, enabledPrinters, initialized]);
  87. const handleTest = async () => {
  88. setTestResult(null);
  89. try {
  90. const res = await api.testObicoConnection(mlUrl);
  91. if (res.ok) {
  92. setTestResult({ ok: true, message: t('failureDetection.testSuccess') });
  93. } else {
  94. setTestResult({
  95. ok: false,
  96. message: res.error || `HTTP ${res.status_code ?? '?'} — ${res.body ?? t('failureDetection.testFailed')}`,
  97. });
  98. }
  99. } catch (e: unknown) {
  100. setTestResult({ ok: false, message: e instanceof Error ? e.message : String(e) });
  101. }
  102. };
  103. const togglePrinter = (printerId: number, checked: boolean) => {
  104. if (enabledPrinters === null) {
  105. // switch from "all" to an explicit list
  106. const allIds = printers?.map((p) => p.id) ?? [];
  107. const next = checked ? allIds : allIds.filter((id) => id !== printerId);
  108. setEnabledPrinters(next);
  109. return;
  110. }
  111. if (checked) {
  112. setEnabledPrinters([...enabledPrinters, printerId]);
  113. } else {
  114. setEnabledPrinters(enabledPrinters.filter((id) => id !== printerId));
  115. }
  116. };
  117. return (
  118. <div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
  119. <div className="space-y-3 flex-1 lg:max-w-xl">
  120. <Card id="card-fd-ml">
  121. <CardHeader>
  122. <div className="flex items-center justify-between">
  123. <div className="flex items-center gap-2">
  124. <ScanEye className="w-5 h-5 text-bambu-green" />
  125. <h2 className="text-lg font-semibold text-white">{t('failureDetection.title')}</h2>
  126. </div>
  127. <Toggle checked={enabled} onChange={setEnabled} />
  128. </div>
  129. <p className="text-sm text-bambu-gray mt-2">{t('failureDetection.description')}</p>
  130. </CardHeader>
  131. <CardContent className="space-y-4">
  132. <div>
  133. <label className="block text-sm text-bambu-gray mb-1">
  134. {t('failureDetection.mlUrl')}
  135. </label>
  136. <div className="flex gap-2">
  137. <input
  138. type="text"
  139. value={mlUrl}
  140. onChange={(e) => setMlUrl(e.target.value)}
  141. placeholder="http://192.168.1.10:3333"
  142. className="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
  143. disabled={!enabled}
  144. />
  145. <Button
  146. onClick={handleTest}
  147. disabled={!mlUrl || saveMutation.isPending}
  148. variant="secondary"
  149. >
  150. {t('failureDetection.test')}
  151. </Button>
  152. </div>
  153. <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.mlUrlHint')}</p>
  154. {testResult && (
  155. <div
  156. className={`flex items-start gap-2 mt-2 text-sm ${
  157. testResult.ok ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'
  158. }`}
  159. >
  160. {testResult.ok ? <Check className="w-4 h-4 mt-0.5" /> : <X className="w-4 h-4 mt-0.5" />}
  161. <span>{testResult.message}</span>
  162. </div>
  163. )}
  164. </div>
  165. <div>
  166. <label className="block text-sm text-bambu-gray mb-1">
  167. {t('failureDetection.sensitivity')}
  168. </label>
  169. <select
  170. value={sensitivity}
  171. onChange={(e) => setSensitivity(e.target.value as 'low' | 'medium' | 'high')}
  172. disabled={!enabled}
  173. className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
  174. >
  175. <option value="low">{t('failureDetection.sensitivityLow')}</option>
  176. <option value="medium">{t('failureDetection.sensitivityMedium')}</option>
  177. <option value="high">{t('failureDetection.sensitivityHigh')}</option>
  178. </select>
  179. <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.sensitivityHint')}</p>
  180. </div>
  181. <div>
  182. <label className="block text-sm text-bambu-gray mb-1">
  183. {t('failureDetection.action')}
  184. </label>
  185. <select
  186. value={action}
  187. onChange={(e) => setAction(e.target.value as 'notify' | 'pause' | 'pause_and_off')}
  188. disabled={!enabled}
  189. className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
  190. >
  191. <option value="notify">{t('failureDetection.actionNotify')}</option>
  192. <option value="pause">{t('failureDetection.actionPause')}</option>
  193. <option value="pause_and_off">{t('failureDetection.actionPauseOff')}</option>
  194. </select>
  195. </div>
  196. <div>
  197. <label className="block text-sm text-bambu-gray mb-1">
  198. {t('failureDetection.pollInterval')}
  199. </label>
  200. <input
  201. type="number"
  202. value={pollInterval}
  203. onChange={(e) => setPollInterval(Math.max(5, Math.min(120, Number(e.target.value) || 10)))}
  204. min={5}
  205. max={120}
  206. disabled={!enabled}
  207. className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
  208. />
  209. <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.pollIntervalHint')}</p>
  210. </div>
  211. {status && !status.external_url_configured && enabled && (
  212. <div className="flex items-start gap-2 p-3 bg-amber-50 dark:bg-amber-900/30 border border-amber-300 dark:border-amber-700 rounded text-sm text-amber-800 dark:text-amber-200">
  213. <AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
  214. <div>
  215. <div className="font-medium">{t('failureDetection.externalUrlMissing')}</div>
  216. <div className="text-xs mt-1">{t('failureDetection.externalUrlHint')}</div>
  217. </div>
  218. </div>
  219. )}
  220. </CardContent>
  221. </Card>
  222. <Card id="card-fd-perprinter">
  223. <CardHeader>
  224. <h2 className="text-lg font-semibold text-white">{t('failureDetection.perPrinterTitle')}</h2>
  225. <p className="text-sm text-bambu-gray mt-1">{t('failureDetection.perPrinterHint')}</p>
  226. </CardHeader>
  227. <CardContent className="space-y-2">
  228. <label className="flex items-center gap-2 text-sm">
  229. <input
  230. type="checkbox"
  231. checked={enabledPrinters === null}
  232. onChange={(e) => setEnabledPrinters(e.target.checked ? null : printers?.map((p) => p.id) ?? [])}
  233. disabled={!enabled}
  234. />
  235. <span className="text-white">{t('failureDetection.monitorAll')}</span>
  236. </label>
  237. {enabledPrinters !== null && printers && (
  238. <div className="pl-5 space-y-1 border-l border-gray-700">
  239. {printers.map((p) => (
  240. <label key={p.id} className="flex items-center gap-2 text-sm">
  241. <input
  242. type="checkbox"
  243. checked={enabledPrinters.includes(p.id)}
  244. onChange={(e) => togglePrinter(p.id, e.target.checked)}
  245. disabled={!enabled}
  246. />
  247. <span className="text-white">{p.name}</span>
  248. </label>
  249. ))}
  250. </div>
  251. )}
  252. </CardContent>
  253. </Card>
  254. </div>
  255. <div className="space-y-3 flex-1 lg:max-w-xl">
  256. <Card id="card-fd-status">
  257. <CardHeader>
  258. <h2 className="text-lg font-semibold text-white">{t('failureDetection.statusTitle')}</h2>
  259. </CardHeader>
  260. <CardContent>
  261. {!status ? (
  262. <div className="flex items-center gap-2 text-bambu-gray">
  263. <Loader2 className="w-4 h-4 animate-spin" />
  264. <span>{t('common.loading')}</span>
  265. </div>
  266. ) : (
  267. <div className="space-y-3 text-sm">
  268. <div className="flex justify-between">
  269. <span className="text-bambu-gray">{t('failureDetection.serviceRunning')}</span>
  270. <span className={status.is_running ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}>
  271. {status.is_running ? t('common.yes') : t('common.no')}
  272. </span>
  273. </div>
  274. <div className="flex justify-between">
  275. <span className="text-bambu-gray">{t('failureDetection.thresholds')}</span>
  276. <span className="text-white font-mono">
  277. {status.thresholds.low.toFixed(2)} / {status.thresholds.high.toFixed(2)}
  278. </span>
  279. </div>
  280. {status.last_error && (
  281. <div className="flex items-start gap-2 text-red-700 dark:text-red-400">
  282. <X className="w-4 h-4 mt-0.5 flex-shrink-0" />
  283. <span className="break-words">{status.last_error}</span>
  284. </div>
  285. )}
  286. <div>
  287. <div className="text-bambu-gray mb-1">{t('failureDetection.activePrinters')}</div>
  288. {Object.keys(status.per_printer).length === 0 ? (
  289. <div className="text-bambu-gray italic text-xs">{t('failureDetection.noActivePrints')}</div>
  290. ) : (
  291. <div className="space-y-1">
  292. {Object.entries(status.per_printer).map(([pid, info]) => {
  293. const printer = printers?.find((p) => String(p.id) === pid);
  294. const colorClass =
  295. info.class === 'failure'
  296. ? 'text-red-700 dark:text-red-400'
  297. : info.class === 'warning'
  298. ? 'text-amber-700 dark:text-amber-400'
  299. : 'text-green-700 dark:text-green-400';
  300. return (
  301. <div key={pid} className="flex justify-between">
  302. <span className="text-white">{printer?.name ?? `Printer ${pid}`}</span>
  303. <span className={`font-mono ${colorClass}`}>
  304. {info.class} ({info.score.toFixed(3)}, {info.frame_count}f)
  305. </span>
  306. </div>
  307. );
  308. })}
  309. </div>
  310. )}
  311. </div>
  312. </div>
  313. )}
  314. </CardContent>
  315. </Card>
  316. <Card id="card-fd-history">
  317. <CardHeader>
  318. <div className="flex items-center justify-between">
  319. <h2 className="text-lg font-semibold text-white">{t('failureDetection.historyTitle')}</h2>
  320. <button onClick={() => refetchStatus()} className="text-xs text-bambu-gray hover:text-white">
  321. {t('common.refresh')}
  322. </button>
  323. </div>
  324. </CardHeader>
  325. <CardContent>
  326. {!status || status.history.length === 0 ? (
  327. <div className="flex items-center gap-2 text-bambu-gray text-sm">
  328. <Info className="w-4 h-4" />
  329. <span>{t('failureDetection.noHistory')}</span>
  330. </div>
  331. ) : (
  332. <div className="space-y-1 max-h-96 overflow-y-auto text-xs font-mono">
  333. {status.history.map((ev, idx) => {
  334. const printer = printers?.find((p) => p.id === ev.printer_id);
  335. const colorClass =
  336. ev.class === 'failure'
  337. ? 'text-red-700 dark:text-red-400'
  338. : ev.class === 'warning'
  339. ? 'text-amber-700 dark:text-amber-400'
  340. : 'text-bambu-gray';
  341. return (
  342. <div key={idx} className="flex justify-between gap-2 py-1 border-b border-gray-800">
  343. <span className="text-bambu-gray">{new Date(ev.timestamp).toLocaleTimeString()}</span>
  344. <span className="text-white truncate">{printer?.name ?? `#${ev.printer_id}`}</span>
  345. <span className={colorClass}>
  346. {ev.class} {ev.score.toFixed(3)}
  347. </span>
  348. </div>
  349. );
  350. })}
  351. </div>
  352. )}
  353. </CardContent>
  354. </Card>
  355. </div>
  356. </div>
  357. );
  358. }