ObicoSettings.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. import { useState, useEffect, useMemo } 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, ApiError } from '../api/client';
  6. import type { AppSettings } from '../api/client';
  7. import { Card, CardContent, CardHeader } from './Card';
  8. import { Button } from './Button';
  9. import { Toggle } from './Toggle';
  10. import { useToast } from '../contexts/ToastContext';
  11. import { NumberInput } from './NumberInput';
  12. import { SECRET_PLACEHOLDER, selectPlaceholderOnFocus } from '../utils/secrets';
  13. type TestResult = { ok: boolean; message: string } | null;
  14. // The ML server fetches snapshots from this address, so it needs a scheme.
  15. // Empty falls back to External URL. Mirrors the backend validator, so a value
  16. // accepted here is never refused on save.
  17. function isValidInternalUrl(value: string): boolean {
  18. const trimmed = value.trim();
  19. if (trimmed === '') return true;
  20. if (/\s/.test(trimmed) || !/^https?:\/\/[^\s/?#]/i.test(trimmed)) return false;
  21. try {
  22. return new URL(trimmed).hostname !== '';
  23. } catch {
  24. return false;
  25. }
  26. }
  27. // The printers Obico watches as stored; null is all of them.
  28. function storedPrinterList(stored: AppSettings): number[] | null {
  29. try {
  30. const list = stored.obico_enabled_printers ? (JSON.parse(stored.obico_enabled_printers) as number[]) : null;
  31. return Array.isArray(list) ? list : null;
  32. } catch {
  33. return null;
  34. }
  35. }
  36. export function ObicoSettings() {
  37. const { t } = useTranslation();
  38. const queryClient = useQueryClient();
  39. const { showToast } = useToast();
  40. const [enabled, setEnabled] = useState(false);
  41. const [mlUrl, setMlUrl] = useState('');
  42. const [bambuddyInternalUrl, setBambuddyInternalUrl] = useState('');
  43. const [mlToken, setMlToken] = useState('');
  44. const [sensitivity, setSensitivity] = useState<'low' | 'medium' | 'high'>('medium');
  45. const [action, setAction] = useState<'notify' | 'pause' | 'pause_and_off'>('notify');
  46. const [pollInterval, setPollInterval] = useState(10);
  47. const [enabledPrinters, setEnabledPrinters] = useState<number[] | null>(null); // null = all
  48. const [testResult, setTestResult] = useState<TestResult>(null);
  49. const [initialized, setInitialized] = useState(false);
  50. const internalUrlValid = isValidInternalUrl(bambuddyInternalUrl);
  51. const { data: settings } = useQuery({
  52. queryKey: ['settings'],
  53. queryFn: api.getSettings,
  54. });
  55. const { data: status, refetch: refetchStatus } = useQuery({
  56. queryKey: ['obico-status'],
  57. queryFn: api.getObicoStatus,
  58. refetchInterval: 10000,
  59. });
  60. const { data: printers } = useQuery({
  61. queryKey: ['printers'],
  62. queryFn: api.getPrinters,
  63. });
  64. useEffect(() => {
  65. if (!settings) return;
  66. setEnabled(settings.obico_enabled ?? false);
  67. setMlUrl(settings.obico_ml_url ?? '');
  68. setBambuddyInternalUrl(settings.bambuddy_internal_url ?? '');
  69. setMlToken(settings.obico_ml_token ?? '');
  70. setSensitivity(settings.obico_sensitivity ?? 'medium');
  71. setAction(settings.obico_action ?? 'notify');
  72. setPollInterval(settings.obico_poll_interval ?? 10);
  73. setEnabledPrinters(storedPrinterList(settings));
  74. setInitialized(true);
  75. }, [settings]);
  76. const saveMutation = useMutation({
  77. mutationFn: () =>
  78. api.updateSettings({
  79. obico_enabled: enabled,
  80. obico_ml_url: mlUrl,
  81. // An invalid address is left out so the other fields still save; the
  82. // field shows why it was not applied.
  83. ...(internalUrlValid ? { bambuddy_internal_url: bambuddyInternalUrl.trim() } : {}),
  84. obico_ml_token: mlToken,
  85. obico_sensitivity: sensitivity,
  86. obico_action: action,
  87. obico_poll_interval: pollInterval,
  88. obico_enabled_printers: enabledPrinters === null ? '' : JSON.stringify(enabledPrinters),
  89. }),
  90. onError: async (error: Error) => {
  91. showToast(t('settings.toast.settingsSaveFailed', { message: error.message }), 'error');
  92. // A 400 is a printer deleted while this page was open. The printer list
  93. // goes back to what the server kept, or every later edit would send the
  94. // refused id again; set here, as unchanged settings keep their reference
  95. // and the effect would not run. Not filtered against the printers list,
  96. // which holds only the caller's printers. Other fields keep what was
  97. // typed: a half-typed address is refused with 422 until it is finished.
  98. if (!(error instanceof ApiError) || error.status !== 400) return;
  99. queryClient.invalidateQueries({ queryKey: ['printers'] });
  100. try {
  101. const stored = await queryClient.fetchQuery({ queryKey: ['settings'], queryFn: api.getSettings, staleTime: 0 });
  102. setEnabledPrinters(storedPrinterList(stored));
  103. } catch {
  104. // Server not reachable: the list keeps the edit, and the next one tries again.
  105. }
  106. },
  107. onSuccess: () => {
  108. queryClient.invalidateQueries({ queryKey: ['settings'] });
  109. queryClient.invalidateQueries({ queryKey: ['obico-status'] });
  110. // Printer-card AI badges (#1546) — refresh immediately on toggle instead
  111. // of waiting for the cards' 10s poll.
  112. queryClient.invalidateQueries({ queryKey: ['obico-printer-status'] });
  113. queryClient.invalidateQueries({ queryKey: ['octoeverywhere-status'] });
  114. queryClient.invalidateQueries({ queryKey: ['octoeverywhere-printer-status'] });
  115. showToast(t('settings.toast.settingsSaved'));
  116. },
  117. });
  118. const hasUnsavedChanges = useMemo(() => {
  119. if (!initialized || !settings) return false;
  120. return (
  121. settings.obico_enabled !== enabled ||
  122. settings.obico_ml_url !== mlUrl ||
  123. (internalUrlValid && (settings.bambuddy_internal_url ?? '') !== bambuddyInternalUrl.trim()) ||
  124. (settings.obico_ml_token ?? '') !== mlToken ||
  125. settings.obico_sensitivity !== sensitivity ||
  126. settings.obico_action !== action ||
  127. settings.obico_poll_interval !== pollInterval ||
  128. settings.obico_enabled_printers !== (enabledPrinters === null ? '' : JSON.stringify(enabledPrinters))
  129. );
  130. }, [settings, initialized, enabled, mlUrl, bambuddyInternalUrl, internalUrlValid, mlToken, sensitivity, action, pollInterval, enabledPrinters]);
  131. // A saved token does not follow a changed URL: it must be entered again, and
  132. // nothing is saved until it is (the backend would refuse it).
  133. const tokenNeedsReentry =
  134. mlToken === SECRET_PLACEHOLDER && (settings?.obico_ml_url ?? '') !== mlUrl;
  135. // Auto-save on change (debounced)
  136. useEffect(() => {
  137. if (!hasUnsavedChanges || tokenNeedsReentry) return;
  138. const id = setTimeout(() => saveMutation.mutate(), 500);
  139. return () => clearTimeout(id);
  140. // eslint-disable-next-line react-hooks/exhaustive-deps
  141. }, [hasUnsavedChanges, tokenNeedsReentry, enabled, mlUrl, bambuddyInternalUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters]);
  142. const handleTest = async () => {
  143. setTestResult(null);
  144. try {
  145. // Flush first, so a green result describes the configuration the
  146. // detection loop is actually running with. The loop reads the saved
  147. // settings; this form tests what is typed in the boxes. Inside the 500ms
  148. // auto-save debounce — or after a save that failed — those are different
  149. // values, and "reachable and healthy" for a token the service never
  150. // received is the reassuring-green-light problem all over again (#2952).
  151. if (hasUnsavedChanges && !tokenNeedsReentry) {
  152. await saveMutation.mutateAsync();
  153. }
  154. const res = await api.testObicoConnection(mlUrl, mlToken);
  155. if (res.ok) {
  156. // auth_ok is null when the token could not be checked — don't claim it
  157. // works. It is true both for an accepted token and for a server that
  158. // requires none, which is the same outcome for the user.
  159. setTestResult({
  160. ok: true,
  161. message:
  162. res.auth_ok === null
  163. ? t('failureDetection.testSuccessTokenUnknown')
  164. : t('failureDetection.testSuccess'),
  165. });
  166. } else {
  167. setTestResult({
  168. ok: false,
  169. message: res.error || `HTTP ${res.status_code ?? '?'} — ${res.body ?? t('failureDetection.testFailed')}`,
  170. });
  171. }
  172. } catch (e: unknown) {
  173. setTestResult({ ok: false, message: e instanceof Error ? e.message : String(e) });
  174. }
  175. };
  176. const togglePrinter = (printerId: number, checked: boolean) => {
  177. if (enabledPrinters === null) {
  178. // switch from "all" to an explicit list
  179. const allIds = printers?.map((p) => p.id) ?? [];
  180. const next = checked ? allIds : allIds.filter((id) => id !== printerId);
  181. setEnabledPrinters(next);
  182. return;
  183. }
  184. if (checked) {
  185. setEnabledPrinters([...enabledPrinters, printerId]);
  186. } else {
  187. setEnabledPrinters(enabledPrinters.filter((id) => id !== printerId));
  188. }
  189. };
  190. return (
  191. <div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
  192. <div className="space-y-3 flex-1 lg:max-w-xl">
  193. <Card id="card-fd-ml">
  194. <CardHeader>
  195. <div className="flex items-center justify-between">
  196. <div className="flex items-center gap-2">
  197. <ScanEye className="w-5 h-5 text-bambu-green" />
  198. <h2 className="text-lg font-semibold text-white">{t('failureDetection.title')}</h2>
  199. </div>
  200. <Toggle checked={enabled} onChange={setEnabled} />
  201. </div>
  202. <p className="text-sm text-bambu-gray mt-2">{t('failureDetection.description')}</p>
  203. </CardHeader>
  204. <CardContent className="space-y-4">
  205. <div>
  206. <label className="block text-sm text-bambu-gray mb-1">
  207. {t('failureDetection.mlUrl')}
  208. </label>
  209. <div className="flex gap-2">
  210. <input
  211. type="text"
  212. value={mlUrl}
  213. onChange={(e) => setMlUrl(e.target.value)}
  214. placeholder="http://192.168.1.10:3333"
  215. className="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
  216. disabled={!enabled}
  217. />
  218. <Button
  219. onClick={handleTest}
  220. disabled={!mlUrl || saveMutation.isPending}
  221. variant="secondary"
  222. >
  223. {t('failureDetection.test')}
  224. </Button>
  225. </div>
  226. <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.mlUrlHint')}</p>
  227. <label className="block text-sm text-bambu-gray mb-1 mt-3">
  228. {t('failureDetection.mlToken')}
  229. </label>
  230. <input
  231. type="password"
  232. value={mlToken}
  233. onChange={(e) => setMlToken(e.target.value)}
  234. onFocus={selectPlaceholderOnFocus}
  235. autoComplete="off"
  236. placeholder={t('failureDetection.mlTokenPlaceholder')}
  237. className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
  238. disabled={!enabled}
  239. />
  240. {mlToken === SECRET_PLACEHOLDER && (
  241. <p className={`text-xs mt-1 ${tokenNeedsReentry ? 'text-yellow-400' : 'text-bambu-gray'}`}>
  242. {t(tokenNeedsReentry ? 'common.secretReenter' : 'common.secretKept')}
  243. </p>
  244. )}
  245. <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.mlTokenHint')}</p>
  246. {testResult && (
  247. <div
  248. className={`flex items-start gap-2 mt-2 text-sm ${
  249. testResult.ok ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'
  250. }`}
  251. >
  252. {testResult.ok ? <Check className="w-4 h-4 mt-0.5" /> : <X className="w-4 h-4 mt-0.5" />}
  253. <span>{testResult.message}</span>
  254. </div>
  255. )}
  256. </div>
  257. <div>
  258. <label className="block text-sm text-bambu-gray mb-1">
  259. {t('failureDetection.bambuddyInternalUrl')}
  260. </label>
  261. <input
  262. type="text"
  263. value={bambuddyInternalUrl}
  264. onChange={(e) => setBambuddyInternalUrl(e.target.value)}
  265. placeholder="http://bambuddy:8000"
  266. className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
  267. disabled={!enabled}
  268. aria-invalid={!internalUrlValid}
  269. />
  270. {!internalUrlValid && (
  271. <p className="text-xs text-red-700 dark:text-red-400 mt-1">
  272. {t('failureDetection.bambuddyInternalUrlInvalid')}
  273. </p>
  274. )}
  275. <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.bambuddyInternalUrlHint')}</p>
  276. </div>
  277. <div>
  278. <label className="block text-sm text-bambu-gray mb-1">
  279. {t('failureDetection.sensitivity')}
  280. </label>
  281. <select
  282. value={sensitivity}
  283. onChange={(e) => setSensitivity(e.target.value as 'low' | 'medium' | 'high')}
  284. disabled={!enabled}
  285. className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
  286. >
  287. <option value="low">{t('failureDetection.sensitivityLow')}</option>
  288. <option value="medium">{t('failureDetection.sensitivityMedium')}</option>
  289. <option value="high">{t('failureDetection.sensitivityHigh')}</option>
  290. </select>
  291. <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.sensitivityHint')}</p>
  292. </div>
  293. <div>
  294. <label className="block text-sm text-bambu-gray mb-1">
  295. {t('failureDetection.action')}
  296. </label>
  297. <select
  298. value={action}
  299. onChange={(e) => setAction(e.target.value as 'notify' | 'pause' | 'pause_and_off')}
  300. disabled={!enabled}
  301. className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
  302. >
  303. <option value="notify">{t('failureDetection.actionNotify')}</option>
  304. <option value="pause">{t('failureDetection.actionPause')}</option>
  305. <option value="pause_and_off">{t('failureDetection.actionPauseOff')}</option>
  306. </select>
  307. </div>
  308. <div>
  309. <label className="block text-sm text-bambu-gray mb-1">
  310. {t('failureDetection.pollInterval')}
  311. </label>
  312. <NumberInput
  313. value={pollInterval}
  314. onChange={setPollInterval}
  315. fallback={10}
  316. min={5}
  317. max={120}
  318. disabled={!enabled}
  319. className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
  320. />
  321. <p className="text-xs text-bambu-gray mt-1">{t('failureDetection.pollIntervalHint')}</p>
  322. </div>
  323. {status && !status.external_url_configured && enabled && (
  324. <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">
  325. <AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
  326. <div>
  327. <div className="font-medium">{t('failureDetection.externalUrlMissing')}</div>
  328. <div className="text-xs mt-1">{t('failureDetection.externalUrlHint')}</div>
  329. </div>
  330. </div>
  331. )}
  332. </CardContent>
  333. </Card>
  334. <Card id="card-fd-perprinter">
  335. <CardHeader>
  336. <h2 className="text-lg font-semibold text-white">{t('failureDetection.perPrinterTitle')}</h2>
  337. <p className="text-sm text-bambu-gray mt-1">{t('failureDetection.perPrinterHint')}</p>
  338. </CardHeader>
  339. <CardContent className="space-y-2">
  340. <label className="flex items-center gap-2 text-sm">
  341. <input
  342. type="checkbox"
  343. checked={enabledPrinters === null}
  344. onChange={(e) => setEnabledPrinters(e.target.checked ? null : printers?.map((p) => p.id) ?? [])}
  345. disabled={!enabled}
  346. />
  347. <span className="text-white">{t('failureDetection.monitorAll')}</span>
  348. </label>
  349. {enabledPrinters !== null && printers && (
  350. <div className="pl-5 space-y-1 border-l border-gray-700">
  351. {printers.map((p) => (
  352. <label key={p.id} className="flex items-center gap-2 text-sm">
  353. <input
  354. type="checkbox"
  355. checked={enabledPrinters.includes(p.id)}
  356. onChange={(e) => togglePrinter(p.id, e.target.checked)}
  357. disabled={!enabled}
  358. />
  359. <span className="text-white">{p.name}</span>
  360. </label>
  361. ))}
  362. </div>
  363. )}
  364. </CardContent>
  365. </Card>
  366. </div>
  367. <div className="space-y-3 flex-1 lg:max-w-xl">
  368. <Card id="card-fd-status">
  369. <CardHeader>
  370. <h2 className="text-lg font-semibold text-white">{t('failureDetection.statusTitle')}</h2>
  371. </CardHeader>
  372. <CardContent>
  373. {!status ? (
  374. <div className="flex items-center gap-2 text-bambu-gray">
  375. <Loader2 className="w-4 h-4 animate-spin" />
  376. <span>{t('common.loading')}</span>
  377. </div>
  378. ) : (
  379. <div className="space-y-3 text-sm">
  380. <div className="flex justify-between">
  381. <span className="text-bambu-gray">{t('failureDetection.serviceRunning')}</span>
  382. <span className={status.is_running ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}>
  383. {status.is_running ? t('common.yes') : t('common.no')}
  384. </span>
  385. </div>
  386. <div className="flex justify-between">
  387. <span className="text-bambu-gray">{t('failureDetection.thresholds')}</span>
  388. <span className="text-white font-mono">
  389. {status.thresholds.low.toFixed(2)} / {status.thresholds.high.toFixed(2)}
  390. </span>
  391. </div>
  392. {status.thresholds.pause != null && (
  393. <div className="flex justify-between">
  394. <span className="text-bambu-gray">{t('failureDetection.pauseThreshold')}</span>
  395. <span className="text-white font-mono">{status.thresholds.pause.toFixed(2)}</span>
  396. </div>
  397. )}
  398. {status.last_error && (
  399. <div className="flex items-start gap-2 text-red-700 dark:text-red-400">
  400. <X className="w-4 h-4 mt-0.5 flex-shrink-0" />
  401. <span className="break-words">{status.last_error}</span>
  402. </div>
  403. )}
  404. <div>
  405. <div className="text-bambu-gray mb-1">{t('failureDetection.activePrinters')}</div>
  406. {Object.keys(status.per_printer).length === 0 ? (
  407. <div className="text-bambu-gray italic text-xs">{t('failureDetection.noActivePrints')}</div>
  408. ) : (
  409. <div className="space-y-1">
  410. {Object.entries(status.per_printer).map(([pid, info]) => {
  411. const printer = printers?.find((p) => String(p.id) === pid);
  412. const colorClass =
  413. info.class === 'failure'
  414. ? 'text-red-700 dark:text-red-400'
  415. : info.class === 'warning'
  416. ? 'text-amber-700 dark:text-amber-400'
  417. : 'text-green-700 dark:text-green-400';
  418. return (
  419. <div key={pid} className="flex justify-between">
  420. <span className="text-white">{printer?.name ?? `Printer ${pid}`}</span>
  421. <span className={`font-mono ${colorClass}`}>
  422. {info.class} ({info.score.toFixed(3)}, {info.frame_count}f)
  423. </span>
  424. </div>
  425. );
  426. })}
  427. </div>
  428. )}
  429. </div>
  430. </div>
  431. )}
  432. </CardContent>
  433. </Card>
  434. <Card id="card-fd-history">
  435. <CardHeader>
  436. <div className="flex items-center justify-between">
  437. <h2 className="text-lg font-semibold text-white">{t('failureDetection.historyTitle')}</h2>
  438. <button onClick={() => refetchStatus()} className="text-xs text-bambu-gray hover:text-white">
  439. {t('common.refresh')}
  440. </button>
  441. </div>
  442. </CardHeader>
  443. <CardContent>
  444. {!status || status.history.length === 0 ? (
  445. <div className="flex items-center gap-2 text-bambu-gray text-sm">
  446. <Info className="w-4 h-4" />
  447. <span>{t('failureDetection.noHistory')}</span>
  448. </div>
  449. ) : (
  450. <div className="space-y-1 max-h-96 overflow-y-auto text-xs font-mono">
  451. {status.history.map((ev, idx) => {
  452. const printer = printers?.find((p) => p.id === ev.printer_id);
  453. const colorClass =
  454. ev.class === 'failure'
  455. ? 'text-red-700 dark:text-red-400'
  456. : ev.class === 'warning'
  457. ? 'text-amber-700 dark:text-amber-400'
  458. : 'text-bambu-gray';
  459. return (
  460. <div key={idx} className="flex justify-between gap-2 py-1 border-b border-gray-800">
  461. <span className="text-bambu-gray">{new Date(ev.timestamp).toLocaleTimeString()}</span>
  462. <span className="text-white truncate">{printer?.name ?? `#${ev.printer_id}`}</span>
  463. <span className={colorClass}>
  464. {ev.class} {ev.score.toFixed(3)}
  465. </span>
  466. </div>
  467. );
  468. })}
  469. </div>
  470. )}
  471. </CardContent>
  472. </Card>
  473. </div>
  474. </div>
  475. );
  476. }