mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-06 14:11:35 +02:00
The 0.2.3b4 #1003 "fix" POSTed JPEG bytes as multipart form data,
but Obico's /p/ endpoint is declared methods=['GET'] upstream and
reads ?img=URL from the query string. Every POST was 405'd by
Flask's router before any handler ran, which is why the Obico
container logs were silent while Bambuddy kept reporting
"ML API call failed for printer N:" with a blank suffix —
raise_for_status() on the 405 produced an exception whose str()
rendered empty.
Restored the pre-#1003 nonce-URL approach (commit 3e434458):
capture locally with a 20s timeout we control, stash the JPEG
under a single-use 32-byte nonce, hand Obico a
GET /api/v1/obico/cached-frame/{nonce} URL that resolves in
<50ms so its hardcoded 5s read timeout never races RTSP.
Also guards against future silent exceptions: the error format
now falls back to type(exc).__name__ when str(exc) is empty.
Detection also early-returns with an explicit error if
external_url is unset instead of handing Obico a URL it can't
resolve.
The #1003 reverse-proxy scenario (Authelia/Authentik/CF Access
in front of Bambuddy) is addressed by documenting that the
/api/v1/obico/cached-frame/ path must be whitelisted from
external auth at the proxy layer — it is already public on
Bambuddy's side.
Backend: services/obico_detection.py, api/routes/obico.py,
main.py (PUBLIC_API_PATTERNS).
Frontend: FailureDetectionSettings banner + client.ts type +
all 7 locales restored.
Tests: 15 unit + 5 integration tests pass.
375 lines
16 KiB
TypeScript
375 lines
16 KiB
TypeScript
import { useState, useEffect } 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 [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 ?? '');
|
|
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_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'] });
|
|
showToast(t('settings.toast.settingsSaved'));
|
|
},
|
|
});
|
|
|
|
// Auto-save on change (debounced)
|
|
useEffect(() => {
|
|
if (!initialized || !settings) return;
|
|
const changed =
|
|
settings.obico_enabled !== enabled ||
|
|
settings.obico_ml_url !== mlUrl ||
|
|
settings.obico_sensitivity !== sensitivity ||
|
|
settings.obico_action !== action ||
|
|
settings.obico_poll_interval !== pollInterval ||
|
|
settings.obico_enabled_printers !== (enabledPrinters === null ? '' : JSON.stringify(enabledPrinters));
|
|
if (!changed) return;
|
|
const id = setTimeout(() => saveMutation.mutate(), 500);
|
|
return () => clearTimeout(id);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [enabled, mlUrl, sensitivity, action, pollInterval, enabledPrinters, initialized]);
|
|
|
|
const handleTest = async () => {
|
|
setTestResult(null);
|
|
try {
|
|
const res = await api.testObicoConnection(mlUrl);
|
|
if (res.ok) {
|
|
setTestResult({ ok: true, message: 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>
|
|
{testResult && (
|
|
<div
|
|
className={`flex items-start gap-2 mt-2 text-sm ${
|
|
testResult.ok ? 'text-green-400' : '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-900/30 border border-amber-700 rounded text-sm 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-400' : '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-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-400'
|
|
: info.class === 'warning'
|
|
? 'text-amber-400'
|
|
: '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-400'
|
|
: ev.class === 'warning'
|
|
? '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>
|
|
);
|
|
}
|