FailureDetectionSettings.tsx 17 KB

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