import { useState, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Loader2, ScanEye, Check, X, Info, AlertTriangle } from 'lucide-react'; import { api, type OctoEverywhereConfidence } from '../api/client'; import { Card, CardContent, CardHeader } from './Card'; import { Button } from './Button'; import { Toggle } from './Toggle'; import { OctoEverywhereLimitNotice } from './OctoEverywhereLimitNotice'; import { useToast } from '../contexts/ToastContext'; import { useAuth } from '../contexts/AuthContext'; import { aiDetectionClass, hasVerdict, OCTOEVERYWHERE_USAGE_LIMIT, type AiDetectionClass } from '../utils/aiDetection'; type TestResult = { ok: boolean; message: string; errorCode?: string | null } | null; function detectionColor(cls: AiDetectionClass) { return cls === 'failure' ? 'text-red-700 dark:text-red-400' : cls === 'warning' || cls === 'error' ? 'text-amber-700 dark:text-amber-400' : cls === 'safe' ? 'text-green-700 dark:text-green-400' : 'text-bambu-gray'; } export function OctoEverywhereSettings() { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const { hasPermission, loading: authLoading } = useAuth(); const canUpdate = !authLoading && hasPermission('settings:update'); const [enabled, setEnabled] = useState(false); const [apiKey, setApiKey] = useState(''); // The server never returns a saved key, so the field keeps showing the key // this page saved (until the page is left) instead of clearing after a save. const [savedApiKey, setSavedApiKey] = useState(''); const [editingApiKey, setEditingApiKey] = useState(false); const [confidence, setConfidence] = useState('medium'); const [action, setAction] = useState<'notify' | 'pause' | 'pause_and_off'>('notify'); const [pollInterval, setPollInterval] = useState(20); const [enabledPrinters, setEnabledPrinters] = useState(null); // null = all const [testResult, setTestResult] = useState(null); const [testing, setTesting] = useState(false); const [initialized, setInitialized] = useState(false); const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings, }); const { data: status, refetch: refetchStatus } = useQuery({ queryKey: ['octoeverywhere-status'], queryFn: api.getOctoEverywhereStatus, refetchInterval: 10000, }); const { data: printers } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters, }); useEffect(() => { if (!settings) return; setEnabled(settings.octoeverywhere_enabled ?? false); setConfidence(settings.octoeverywhere_confidence ?? 'medium'); setAction(settings.octoeverywhere_action ?? 'notify'); setPollInterval(settings.octoeverywhere_poll_interval ?? 20); try { const list = settings.octoeverywhere_enabled_printers ? (JSON.parse(settings.octoeverywhere_enabled_printers) as number[]) : null; setEnabledPrinters(Array.isArray(list) ? list : null); } catch { setEnabledPrinters(null); } setInitialized(true); }, [settings]); const saveMutation = useMutation({ mutationFn: api.updateSettings, onSuccess: (saved, update) => { if (update.octoeverywhere_api_key !== undefined) setSavedApiKey(update.octoeverywhere_api_key); queryClient.setQueryData(['settings'], saved); queryClient.invalidateQueries({ queryKey: ['settings'] }); queryClient.invalidateQueries({ queryKey: ['octoeverywhere-status'] }); queryClient.invalidateQueries({ queryKey: ['octoeverywhere-printer-status'] }); queryClient.invalidateQueries({ queryKey: ['obico-status'] }); queryClient.invalidateQueries({ queryKey: ['obico-printer-status'] }); showToast(t('settings.toast.settingsSaved')); }, onError: (error: Error) => showToast(error.message, 'error'), }); // An empty field keeps the saved key, and a key already saved from this // field is not resent with every other change. const pendingApiKey = apiKey.trim() === savedApiKey ? '' : apiKey.trim(); const settingsToSave = useMemo(() => ({ octoeverywhere_enabled: enabled, ...(pendingApiKey ? { octoeverywhere_api_key: pendingApiKey } : {}), octoeverywhere_confidence: confidence, octoeverywhere_action: action, octoeverywhere_poll_interval: pollInterval, octoeverywhere_enabled_printers: enabledPrinters === null ? '' : JSON.stringify(enabledPrinters), }), [enabled, pendingApiKey, confidence, action, pollInterval, enabledPrinters]); const hasUnsavedChanges = useMemo(() => { if (!initialized || !settings) return false; return ( (settings.octoeverywhere_enabled ?? false) !== enabled || pendingApiKey.length > 0 || (settings.octoeverywhere_confidence ?? 'medium') !== confidence || (settings.octoeverywhere_action ?? 'notify') !== action || (settings.octoeverywhere_poll_interval ?? 20) !== pollInterval || (settings.octoeverywhere_enabled_printers ?? '') !== (enabledPrinters === null ? '' : JSON.stringify(enabledPrinters)) ); }, [settings, initialized, enabled, pendingApiKey, confidence, action, pollInterval, enabledPrinters]); // A failed payload waits for another edit or an explicit Test. Compare the // values so an equivalent payload recreated after a refetch cannot retry it. const currentSaveFailed = saveMutation.isError && JSON.stringify(saveMutation.variables) === JSON.stringify(settingsToSave); // Auto-save on change (debounced), matching the other detection provider. useEffect(() => { if (!hasUnsavedChanges || currentSaveFailed || testing || editingApiKey || saveMutation.isPending || !canUpdate) return; const id = setTimeout(() => saveMutation.mutate(settingsToSave), 500); return () => clearTimeout(id); // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasUnsavedChanges, currentSaveFailed, settingsToSave, testing, editingApiKey, saveMutation.isPending, canUpdate]); const handleTest = async () => { if (!canUpdate) return; setTestResult(null); setTesting(true); try { // A successful test must describe the configuration used by the service, // including edits still inside the auto-save debounce. if (hasUnsavedChanges) { await saveMutation.mutateAsync(settingsToSave); } const res = await api.testOctoEverywhereConnection(undefined, confidence); setTestResult({ ok: res.ok, message: res.ok ? t('octoeverywhere.testSuccess') : res.error || t('failureDetection.testFailed'), errorCode: res.error_code, }); } catch (e: unknown) { setTestResult({ ok: false, message: e instanceof Error ? e.message : String(e) }); } finally { setTesting(false); } }; const togglePrinter = (printerId: number, checked: boolean) => { const selected = enabledPrinters ?? printers?.map((p) => p.id) ?? []; setEnabledPrinters(checked ? [...selected, printerId] : selected.filter((id) => id !== printerId)); }; const keyConfigured = settings?.octoeverywhere_api_key_configured ?? false; const busy = testing || saveMutation.isPending; const monitoringState = !status?.enabled ? 'monitoringDisabled' : !status.api_key_configured ? 'monitoringMissingKey' : !status.is_running ? 'monitoringStopped' : status.last_error || status.last_error_code || Object.values(status.per_printer).some((info) => info.class === 'error') ? 'monitoringError' : Object.values(status.per_printer).some((info) => hasVerdict(aiDetectionClass(info))) ? 'monitoringObserved' : 'monitoringWaiting'; const notifications = status?.notifications; const uncoveredPrinters = notifications?.uncovered_printers.map((id) => printers?.find((p) => p.id === id)?.name ?? `#${id}`); return (

{t('octoeverywhere.title')}

{t('octoeverywhere.description')}{' '} {t('octoeverywhere.learnMore')}

{ setApiKey(e.target.value); setTestResult(null); }} onFocus={() => setEditingApiKey(true)} onBlur={() => setEditingApiKey(false)} autoComplete="off" placeholder={t(keyConfigured ? 'octoeverywhere.apiKeyConfigured' : 'octoeverywhere.apiKeyPlaceholder')} className="flex-1 min-w-0 bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white text-sm" disabled={!initialized || busy || !canUpdate} />

{t('octoeverywhere.apiKeyHint')}{' '} {t('octoeverywhere.privacyPolicy')}

{t('octoeverywhere.getApiKey')} {testResult?.errorCode === OCTOEVERYWHERE_USAGE_LIMIT ? (
) : testResult && (
{testResult.ok ? : } {testResult.message}
)}

{t('octoeverywhere.confidenceHint')}

{pollInterval} {t('common.seconds')}
setPollInterval(Math.max(5, Math.min(30, Math.round(Number(e.target.value) || 20))))} min={5} max={30} step={1} aria-valuetext={`${pollInterval} ${t('common.seconds')}`} aria-describedby="oe-interval-hint" disabled={!enabled || busy || !canUpdate} className="w-full h-1 accent-bambu-green cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" />

{t('octoeverywhere.inspectionIntervalHint')}

{notifications && (!notifications.configured || notifications.uncovered_printers.length > 0) && (

{notifications.configured ? t('octoeverywhere.notificationsUncovered', { printers: uncoveredPrinters?.join(', ') }) : t('octoeverywhere.notificationsMissing')}

{hasPermission('notifications:read') && ( {t('octoeverywhere.configureNotifications')} )}
)}

{t('failureDetection.perPrinterTitle')}

{t('failureDetection.perPrinterHint')}

{enabledPrinters !== null && printers && (
{printers.map((p) => ( ))}
)}

{t('failureDetection.statusTitle')}

{!status ? (
{t('common.loading')}
) : (
{t('octoeverywhere.monitoringStatus')} {t(`octoeverywhere.${monitoringState}`)}
{status.last_error_code === OCTOEVERYWHERE_USAGE_LIMIT ? ( ) : status.last_error && (
{status.last_error}
)}
{t('failureDetection.activePrinters')}
{Object.keys(status.per_printer).length === 0 ? (
{t('failureDetection.noActivePrints')}
) : (
{Object.entries(status.per_printer).map(([pid, info]) => { const printer = printers?.find((p) => String(p.id) === pid); const cls = aiDetectionClass(info); return (
{printer?.name ?? `#${pid}`} {t(`printers.aiDetection.${cls}`)}
{hasVerdict(cls) && info.print_quality !== null && (
{t('octoeverywhere.printQuality')}: {info.print_quality}/10
)} {info.error_code === OCTOEVERYWHERE_USAGE_LIMIT ? ( ) : info.error &&

{info.error}

}
); })}
)}
)}

{t('failureDetection.historyTitle')}

{!status || status.history.length === 0 ? (
{t('failureDetection.noHistory')}
) : (
{status.history.map((ev, idx) => { const printer = printers?.find((p) => p.id === ev.printer_id); const cls = ev.class; return (
{new Date(ev.timestamp).toLocaleTimeString()} {printer?.name ?? `#${ev.printer_id}`} {t(`printers.aiDetection.${cls}`)} {hasVerdict(cls) && ev.print_quality !== null && ` ${ev.print_quality}/10`}
); })}
)}
); }