mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-08 15:11:21 +02:00
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.
417 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|