FailureDetectionSettings.tsx 16 KB

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