Files
bambuddy/frontend/src/components/FailureDetectionSettings.tsx
T
maziggy 06e5114a03 Do not report a printer as Safe when nothing is checking it (issue #2952)
The printer card's AI badge collapsed every class that was not Warning or
Failure into green Safe, and the service reported `safe` whenever it had no
verdict. The state entry is created when a monitored print is first seen --
before the first snapshot, let alone the first inference -- so a rejected ML
API token, an unreachable ML API, a failed capture and an unset External URL
all rendered as a healthy watched print: green Safe at score 0.000.

For a safety feature that is the worst failure mode available: it asserts the
print is being watched exactly when it is not. The reporter read that badge and
concluded the loop had never started. It had been calling the ML API every ten
seconds and being turned away with a 401 -- invisible because Obico's auth
layer rejects a bad token before its request log sees it, and because
successful checks log nothing there either.

Add two honest states. Not checking (amber) when the last poll produced no
result, carrying the reason; Starting while a monitored print waits for its
first result. Score and frame count are withheld while not checking, since
0.000 beside "Not checking" reads as a measurement rather than its absence.
The reason is per printer, so a card names its own problem rather than
whichever printer failed most recently, and stays behind settings:read because
it can quote configured URLs -- the badge state does not, because whether a
print is watched is not configuration. An unrecognised class now falls back to
Starting, not Safe.

Test Connection saves the form before probing, so a green result describes the
configuration the loop actually runs with rather than what is typed in the
boxes.
2026-08-25 08:49:24 +02:00

417 lines
18 KiB
TypeScript

import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, ScanEye, Check, X, AlertTriangle, Info } from 'lucide-react';
import { api } from '../api/client';
import { Card, CardContent, CardHeader } from './Card';
import { Button } from './Button';
import { Toggle } from './Toggle';
import { useToast } from '../contexts/ToastContext';
type TestResult = { ok: boolean; message: string } | null;
export function FailureDetectionSettings() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { showToast } = useToast();
const [enabled, setEnabled] = useState(false);
const [mlUrl, setMlUrl] = useState('');
const [mlToken, setMlToken] = useState('');
const [sensitivity, setSensitivity] = useState<'low' | 'medium' | 'high'>('medium');
const [action, setAction] = useState<'notify' | 'pause' | 'pause_and_off'>('notify');
const [pollInterval, setPollInterval] = useState(10);
const [enabledPrinters, setEnabledPrinters] = useState<number[] | null>(null); // null = all
const [testResult, setTestResult] = useState<TestResult>(null);
const [initialized, setInitialized] = useState(false);
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: api.getSettings,
});
const { data: status, refetch: refetchStatus } = useQuery({
queryKey: ['obico-status'],
queryFn: api.getObicoStatus,
refetchInterval: 10000,
});
const { data: printers } = useQuery({
queryKey: ['printers'],
queryFn: api.getPrinters,
});
useEffect(() => {
if (!settings) return;
setEnabled(settings.obico_enabled ?? false);
setMlUrl(settings.obico_ml_url ?? '');
setMlToken(settings.obico_ml_token ?? '');
setSensitivity(settings.obico_sensitivity ?? 'medium');
setAction(settings.obico_action ?? 'notify');
setPollInterval(settings.obico_poll_interval ?? 10);
try {
const list = settings.obico_enabled_printers
? (JSON.parse(settings.obico_enabled_printers) as number[])
: null;
setEnabledPrinters(Array.isArray(list) ? list : null);
} catch {
setEnabledPrinters(null);
}
setInitialized(true);
}, [settings]);
const saveMutation = useMutation({
mutationFn: () =>
api.updateSettings({
obico_enabled: enabled,
obico_ml_url: mlUrl,
obico_ml_token: mlToken,
obico_sensitivity: sensitivity,
obico_action: action,
obico_poll_interval: pollInterval,
obico_enabled_printers: enabledPrinters === null ? '' : JSON.stringify(enabledPrinters),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings'] });
queryClient.invalidateQueries({ queryKey: ['obico-status'] });
// Printer-card AI badges (#1546) — refresh immediately on toggle instead
// of waiting for the cards' 10s poll.
queryClient.invalidateQueries({ queryKey: ['obico-printer-status'] });
showToast(t('settings.toast.settingsSaved'));
},
});
const hasUnsavedChanges = useMemo(() => {
if (!initialized || !settings) return false;
return (
settings.obico_enabled !== enabled ||
settings.obico_ml_url !== mlUrl ||
(settings.obico_ml_token ?? '') !== mlToken ||
settings.obico_sensitivity !== sensitivity ||
settings.obico_action !== action ||
settings.obico_poll_interval !== pollInterval ||
settings.obico_enabled_printers !== (enabledPrinters === null ? '' : JSON.stringify(enabledPrinters))
);
}, [settings, initialized, enabled, mlUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters]);
// Auto-save on change (debounced)
useEffect(() => {
if (!hasUnsavedChanges) return;
const id = setTimeout(() => saveMutation.mutate(), 500);
return () => clearTimeout(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasUnsavedChanges, enabled, mlUrl, mlToken, sensitivity, action, pollInterval, enabledPrinters]);
const handleTest = async () => {
setTestResult(null);
try {
// Flush first, so a green result describes the configuration the
// detection loop is actually running with. The loop reads the saved
// settings; this form tests what is typed in the boxes. Inside the 500ms
// auto-save debounce — or after a save that failed — those are different
// values, and "reachable and healthy" for a token the service never
// received is the reassuring-green-light problem all over again (#2952).
if (hasUnsavedChanges) {
await saveMutation.mutateAsync();
}
const res = await api.testObicoConnection(mlUrl, mlToken);
if (res.ok) {
// auth_ok is null when the token could not be checked — don't claim it
// works. It is true both for an accepted token and for a server that
// requires none, which is the same outcome for the user.
setTestResult({
ok: true,
message:
res.auth_ok === null
? t('failureDetection.testSuccessTokenUnknown')
: t('failureDetection.testSuccess'),
});
} else {
setTestResult({
ok: false,
message: res.error || `HTTP ${res.status_code ?? '?'} — ${res.body ?? t('failureDetection.testFailed')}`,
});
}
} catch (e: unknown) {
setTestResult({ ok: false, message: e instanceof Error ? e.message : String(e) });
}
};
const togglePrinter = (printerId: number, checked: boolean) => {
if (enabledPrinters === null) {
// switch from "all" to an explicit list
const allIds = printers?.map((p) => p.id) ?? [];
const next = checked ? allIds : allIds.filter((id) => id !== printerId);
setEnabledPrinters(next);
return;
}
if (checked) {
setEnabledPrinters([...enabledPrinters, printerId]);
} else {
setEnabledPrinters(enabledPrinters.filter((id) => id !== printerId));
}
};
return (
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
<div className="space-y-3 flex-1 lg:max-w-xl">
<Card id="card-fd-ml">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ScanEye className="w-5 h-5 text-bambu-green" />
<h2 className="text-lg font-semibold text-white">{t('failureDetection.title')}</h2>
</div>
<Toggle checked={enabled} onChange={setEnabled} />
</div>
<p className="text-sm text-bambu-gray mt-2">{t('failureDetection.description')}</p>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm text-bambu-gray mb-1">
{t('failureDetection.mlUrl')}
</label>
<div className="flex gap-2">
<input
type="text"
value={mlUrl}
onChange={(e) => setMlUrl(e.target.value)}
placeholder="http://192.168.1.10:3333"
className="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
disabled={!enabled}
/>
<Button
onClick={handleTest}
disabled={!mlUrl || saveMutation.isPending}
variant="secondary"
>
{t('failureDetection.test')}
</Button>
</div>
<p className="text-xs text-bambu-gray mt-1">{t('failureDetection.mlUrlHint')}</p>
<label className="block text-sm text-bambu-gray mb-1 mt-3">
{t('failureDetection.mlToken')}
</label>
<input
type="password"
value={mlToken}
onChange={(e) => setMlToken(e.target.value)}
autoComplete="off"
placeholder={t('failureDetection.mlTokenPlaceholder')}
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
disabled={!enabled}
/>
<p className="text-xs text-bambu-gray mt-1">{t('failureDetection.mlTokenHint')}</p>
{testResult && (
<div
className={`flex items-start gap-2 mt-2 text-sm ${
testResult.ok ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'
}`}
>
{testResult.ok ? <Check className="w-4 h-4 mt-0.5" /> : <X className="w-4 h-4 mt-0.5" />}
<span>{testResult.message}</span>
</div>
)}
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">
{t('failureDetection.sensitivity')}
</label>
<select
value={sensitivity}
onChange={(e) => setSensitivity(e.target.value as 'low' | 'medium' | 'high')}
disabled={!enabled}
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
>
<option value="low">{t('failureDetection.sensitivityLow')}</option>
<option value="medium">{t('failureDetection.sensitivityMedium')}</option>
<option value="high">{t('failureDetection.sensitivityHigh')}</option>
</select>
<p className="text-xs text-bambu-gray mt-1">{t('failureDetection.sensitivityHint')}</p>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">
{t('failureDetection.action')}
</label>
<select
value={action}
onChange={(e) => setAction(e.target.value as 'notify' | 'pause' | 'pause_and_off')}
disabled={!enabled}
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
>
<option value="notify">{t('failureDetection.actionNotify')}</option>
<option value="pause">{t('failureDetection.actionPause')}</option>
<option value="pause_and_off">{t('failureDetection.actionPauseOff')}</option>
</select>
</div>
<div>
<label className="block text-sm text-bambu-gray mb-1">
{t('failureDetection.pollInterval')}
</label>
<input
type="number"
value={pollInterval}
onChange={(e) => setPollInterval(Math.max(5, Math.min(120, Number(e.target.value) || 10)))}
min={5}
max={120}
disabled={!enabled}
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm"
/>
<p className="text-xs text-bambu-gray mt-1">{t('failureDetection.pollIntervalHint')}</p>
</div>
{status && !status.external_url_configured && enabled && (
<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">
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<div>
<div className="font-medium">{t('failureDetection.externalUrlMissing')}</div>
<div className="text-xs mt-1">{t('failureDetection.externalUrlHint')}</div>
</div>
</div>
)}
</CardContent>
</Card>
<Card id="card-fd-perprinter">
<CardHeader>
<h2 className="text-lg font-semibold text-white">{t('failureDetection.perPrinterTitle')}</h2>
<p className="text-sm text-bambu-gray mt-1">{t('failureDetection.perPrinterHint')}</p>
</CardHeader>
<CardContent className="space-y-2">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={enabledPrinters === null}
onChange={(e) => setEnabledPrinters(e.target.checked ? null : printers?.map((p) => p.id) ?? [])}
disabled={!enabled}
/>
<span className="text-white">{t('failureDetection.monitorAll')}</span>
</label>
{enabledPrinters !== null && printers && (
<div className="pl-5 space-y-1 border-l border-gray-700">
{printers.map((p) => (
<label key={p.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={enabledPrinters.includes(p.id)}
onChange={(e) => togglePrinter(p.id, e.target.checked)}
disabled={!enabled}
/>
<span className="text-white">{p.name}</span>
</label>
))}
</div>
)}
</CardContent>
</Card>
</div>
<div className="space-y-3 flex-1 lg:max-w-xl">
<Card id="card-fd-status">
<CardHeader>
<h2 className="text-lg font-semibold text-white">{t('failureDetection.statusTitle')}</h2>
</CardHeader>
<CardContent>
{!status ? (
<div className="flex items-center gap-2 text-bambu-gray">
<Loader2 className="w-4 h-4 animate-spin" />
<span>{t('common.loading')}</span>
</div>
) : (
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-bambu-gray">{t('failureDetection.serviceRunning')}</span>
<span className={status.is_running ? 'text-green-700 dark:text-green-400' : 'text-red-700 dark:text-red-400'}>
{status.is_running ? t('common.yes') : t('common.no')}
</span>
</div>
<div className="flex justify-between">
<span className="text-bambu-gray">{t('failureDetection.thresholds')}</span>
<span className="text-white font-mono">
{status.thresholds.low.toFixed(2)} / {status.thresholds.high.toFixed(2)}
</span>
</div>
{status.last_error && (
<div className="flex items-start gap-2 text-red-700 dark:text-red-400">
<X className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span className="break-words">{status.last_error}</span>
</div>
)}
<div>
<div className="text-bambu-gray mb-1">{t('failureDetection.activePrinters')}</div>
{Object.keys(status.per_printer).length === 0 ? (
<div className="text-bambu-gray italic text-xs">{t('failureDetection.noActivePrints')}</div>
) : (
<div className="space-y-1">
{Object.entries(status.per_printer).map(([pid, info]) => {
const printer = printers?.find((p) => String(p.id) === pid);
const colorClass =
info.class === 'failure'
? 'text-red-700 dark:text-red-400'
: info.class === 'warning'
? 'text-amber-700 dark:text-amber-400'
: 'text-green-700 dark:text-green-400';
return (
<div key={pid} className="flex justify-between">
<span className="text-white">{printer?.name ?? `Printer ${pid}`}</span>
<span className={`font-mono ${colorClass}`}>
{info.class} ({info.score.toFixed(3)}, {info.frame_count}f)
</span>
</div>
);
})}
</div>
)}
</div>
</div>
)}
</CardContent>
</Card>
<Card id="card-fd-history">
<CardHeader>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">{t('failureDetection.historyTitle')}</h2>
<button onClick={() => refetchStatus()} className="text-xs text-bambu-gray hover:text-white">
{t('common.refresh')}
</button>
</div>
</CardHeader>
<CardContent>
{!status || status.history.length === 0 ? (
<div className="flex items-center gap-2 text-bambu-gray text-sm">
<Info className="w-4 h-4" />
<span>{t('failureDetection.noHistory')}</span>
</div>
) : (
<div className="space-y-1 max-h-96 overflow-y-auto text-xs font-mono">
{status.history.map((ev, idx) => {
const printer = printers?.find((p) => p.id === ev.printer_id);
const colorClass =
ev.class === 'failure'
? 'text-red-700 dark:text-red-400'
: ev.class === 'warning'
? 'text-amber-700 dark:text-amber-400'
: 'text-bambu-gray';
return (
<div key={idx} className="flex justify-between gap-2 py-1 border-b border-gray-800">
<span className="text-bambu-gray">{new Date(ev.timestamp).toLocaleTimeString()}</span>
<span className="text-white truncate">{printer?.name ?? `#${ev.printer_id}`}</span>
<span className={colorClass}>
{ev.class} {ev.score.toFixed(3)}
</span>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}