mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
feat(obico): AI print-failure detection via self-hosted Obico ML API (#172)
Adds a Failure Detection tab under Settings that wires Bambuddy to a
self-hosted Obico ml_api container — no cloud, no account, no WebSocket.
While a print is running, the detection service periodically hands the
printer's camera snapshot URL to the ML API and smooths scores over
time (30-frame warmup + EWM, alpha=2/13, short/long rolling means) so
one noisy frame can't trigger an action. When the smoothed score
crosses HIGH, the configured action fires exactly once per print:
notify, pause, or pause-and-cut-power (via linked smart plugs).
- Backend: new obico_detection + obico_smoothing + obico_actions
services, /obico/status and /obico/test-connection routes
(SETTINGS_READ / SETTINGS_UPDATE), six obico_* AppSettings fields
with validators for sensitivity/action/enabled_printers.
- Frontend: FailureDetectionSettings component (enable, ML URL + test,
sensitivity, action, poll interval, per-printer monitor list, live
status + detection history), new sidebar tab with service-active
bullet, toast on save.
- Tests: 17 detection unit tests + 15 smoothing unit tests + 4
frontend component tests.
- Docs: README bullet, CHANGELOG entry, wiki page under Analytics,
website features.html entry.
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Tests for the Failure Detection settings component (#172).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '../utils';
|
||||
import { FailureDetectionSettings } from '../../components/FailureDetectionSettings';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../mocks/server';
|
||||
|
||||
const baseSettings = {
|
||||
auto_archive: true,
|
||||
save_thumbnails: true,
|
||||
capture_finish_photo: true,
|
||||
default_filament_cost: 25,
|
||||
currency: 'USD',
|
||||
energy_cost_per_kwh: 0.15,
|
||||
energy_tracking_mode: 'total',
|
||||
check_updates: true,
|
||||
check_printer_firmware: true,
|
||||
include_beta_updates: false,
|
||||
obico_enabled: false,
|
||||
obico_ml_url: '',
|
||||
obico_sensitivity: 'medium',
|
||||
obico_action: 'notify',
|
||||
obico_poll_interval: 10,
|
||||
obico_enabled_printers: '',
|
||||
};
|
||||
|
||||
const baseStatus = {
|
||||
is_running: true,
|
||||
last_error: null,
|
||||
per_printer: {},
|
||||
thresholds: { low: 0.38, high: 0.78 },
|
||||
history: [],
|
||||
enabled: false,
|
||||
ml_url: '',
|
||||
sensitivity: 'medium',
|
||||
action: 'notify',
|
||||
poll_interval: 10,
|
||||
external_url_configured: true,
|
||||
};
|
||||
|
||||
describe('FailureDetectionSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
server.use(
|
||||
http.get('/api/v1/settings/', () => HttpResponse.json(baseSettings)),
|
||||
http.get('/api/v1/obico/status', () => HttpResponse.json(baseStatus)),
|
||||
http.get('/api/v1/printers', () => HttpResponse.json([])),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders headings and fields', async () => {
|
||||
render(<FailureDetectionSettings />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/AI Failure Detection|Failure Detection/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/Obico ML API URL/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Sensitivity/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('warns when external URL is missing and detection is enabled', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/settings/', () => HttpResponse.json({ ...baseSettings, obico_enabled: true })),
|
||||
http.get('/api/v1/obico/status', () =>
|
||||
HttpResponse.json({ ...baseStatus, enabled: true, external_url_configured: false }),
|
||||
),
|
||||
);
|
||||
render(<FailureDetectionSettings />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/External URL is not set/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('test button calls the test-connection endpoint and shows success', async () => {
|
||||
let called = false;
|
||||
server.use(
|
||||
http.get('/api/v1/settings/', () =>
|
||||
HttpResponse.json({ ...baseSettings, obico_enabled: true, obico_ml_url: 'http://obico:3333' }),
|
||||
),
|
||||
http.post('/api/v1/obico/test-connection', async ({ request }) => {
|
||||
called = true;
|
||||
const body = (await request.json()) as { url: string };
|
||||
expect(body.url).toBe('http://obico:3333');
|
||||
return HttpResponse.json({ ok: true, status_code: 200, body: 'ok', error: null });
|
||||
}),
|
||||
);
|
||||
render(<FailureDetectionSettings />);
|
||||
const testBtn = await screen.findByRole('button', { name: /test/i });
|
||||
await userEvent.click(testBtn);
|
||||
await waitFor(() => {
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
expect(await screen.findByText(/ML API reachable/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows failure class history entries with red styling', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/obico/status', () =>
|
||||
HttpResponse.json({
|
||||
...baseStatus,
|
||||
history: [
|
||||
{
|
||||
printer_id: 1,
|
||||
task_name: 'test.3mf',
|
||||
timestamp: '2026-04-13T10:00:00Z',
|
||||
current_p: 0.9,
|
||||
score: 0.85,
|
||||
class: 'failure',
|
||||
detections: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
render(<FailureDetectionSettings />);
|
||||
// Match the history row's score-and-class text, which looks like "failure 0.850"
|
||||
expect(await screen.findByText(/failure\s+0\.850/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -925,6 +925,12 @@ export interface AppSettings {
|
||||
ldap_group_mapping: string;
|
||||
ldap_auto_provision: boolean;
|
||||
ldap_default_group: string;
|
||||
obico_enabled: boolean;
|
||||
obico_ml_url: string;
|
||||
obico_sensitivity: 'low' | 'medium' | 'high';
|
||||
obico_action: 'notify' | 'pause' | 'pause_and_off';
|
||||
obico_poll_interval: number;
|
||||
obico_enabled_printers: string;
|
||||
}
|
||||
|
||||
export type AppSettingsUpdate = Partial<AppSettings>;
|
||||
@@ -1832,6 +1838,37 @@ export interface LocalBackupFile {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ObicoDetectionEvent {
|
||||
printer_id: number;
|
||||
task_name: string;
|
||||
timestamp: string;
|
||||
current_p: number;
|
||||
score: number;
|
||||
class: 'safe' | 'warning' | 'failure';
|
||||
detections: number;
|
||||
}
|
||||
|
||||
export interface ObicoStatus {
|
||||
is_running: boolean;
|
||||
last_error: string | null;
|
||||
per_printer: Record<string, { class: string; frame_count: number; score: number }>;
|
||||
thresholds: { low: number; high: number };
|
||||
history: ObicoDetectionEvent[];
|
||||
enabled: boolean;
|
||||
ml_url: string;
|
||||
sensitivity: 'low' | 'medium' | 'high';
|
||||
action: 'notify' | 'pause' | 'pause_and_off';
|
||||
poll_interval: number;
|
||||
external_url_configured: boolean;
|
||||
}
|
||||
|
||||
export interface ObicoTestConnection {
|
||||
ok: boolean;
|
||||
status_code: number | null;
|
||||
body: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface GitHubTestConnectionResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
@@ -4518,6 +4555,16 @@ export const api = {
|
||||
deleteLocalBackup: (filename: string) =>
|
||||
request<{ success: boolean; message: string }>(`/local-backup/backups/${encodeURIComponent(filename)}`, { method: 'DELETE' }),
|
||||
|
||||
// Obico AI failure detection
|
||||
getObicoStatus: () =>
|
||||
request<ObicoStatus>('/obico/status'),
|
||||
|
||||
testObicoConnection: (url: string) =>
|
||||
request<ObicoTestConnection>('/obico/test-connection', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url }),
|
||||
}),
|
||||
|
||||
// Local Presets (OrcaSlicer imports)
|
||||
getLocalPresets: () =>
|
||||
request<LocalPresetsResponse>('/local-presets/'),
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1308,6 +1308,7 @@ export default {
|
||||
apiKeys: 'API-Schlüssel',
|
||||
virtualPrinter: 'Virtueller Drucker',
|
||||
spoolbuddy: 'SpoolBuddy',
|
||||
failureDetection: 'Fehlererkennung',
|
||||
users: 'Authentifizierung',
|
||||
backup: 'Sicherung',
|
||||
emailAuth: 'E-Mail-Authentifizierung',
|
||||
@@ -4846,4 +4847,36 @@ export default {
|
||||
viewIssue: 'Issue ansehen',
|
||||
unexpectedError: 'Ein unerwarteter Fehler ist aufgetreten',
|
||||
},
|
||||
failureDetection: {
|
||||
title: 'KI-Fehlererkennung',
|
||||
description: 'Überwacht Drucke über eine selbst gehostete Obico-ML-API und reagiert automatisch auf erkannte Fehldrucke.',
|
||||
mlUrl: 'Obico-ML-API-URL',
|
||||
mlUrlHint: 'Basis-URL deines selbst gehosteten Obico-ml_api-Containers (z. B. http://192.168.1.10:3333).',
|
||||
test: 'Testen',
|
||||
testSuccess: 'ML-API erreichbar und funktionsfähig.',
|
||||
testFailed: 'ML-API konnte nicht erreicht werden.',
|
||||
sensitivity: 'Empfindlichkeit',
|
||||
sensitivityLow: 'Niedrig (weniger Fehlalarme)',
|
||||
sensitivityMedium: 'Mittel (ausgewogen)',
|
||||
sensitivityHigh: 'Hoch (frühe Erkennung, mehr Fehlalarme)',
|
||||
sensitivityHint: 'Passt die Konfidenz-Schwellwerte an, die Warnungen und Fehler auslösen.',
|
||||
action: 'Aktion bei erkanntem Fehler',
|
||||
actionNotify: 'Nur benachrichtigen',
|
||||
actionPause: 'Druck pausieren',
|
||||
actionPauseOff: 'Pausieren und Strom abschalten',
|
||||
pollInterval: 'Prüfintervall (Sekunden)',
|
||||
pollIntervalHint: 'Wie oft jeder Drucker während eines laufenden Drucks geprüft wird. Minimum 5 s, Maximum 120 s.',
|
||||
externalUrlMissing: 'Externe URL ist nicht gesetzt.',
|
||||
externalUrlHint: 'Die ML-API lädt das Kamerabild per URL. Setze die Externe URL in den allgemeinen Einstellungen, damit der ML-API-Container Bambuddy erreichen kann.',
|
||||
perPrinterTitle: 'Überwachte Drucker',
|
||||
perPrinterHint: 'Wähle, welche Drucker vom Erkennungsdienst überwacht werden.',
|
||||
monitorAll: 'Alle verbundenen Drucker überwachen',
|
||||
statusTitle: 'Status',
|
||||
serviceRunning: 'Dienst läuft',
|
||||
thresholds: 'Niedrig / Hoch-Schwellwerte',
|
||||
activePrinters: 'Aktive Drucke',
|
||||
noActivePrints: 'Derzeit laufen keine Drucke.',
|
||||
historyTitle: 'Letzte Erkennungen',
|
||||
noHistory: 'Noch keine Erkennungen.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1309,6 +1309,7 @@ export default {
|
||||
apiKeys: 'API Keys',
|
||||
virtualPrinter: 'Virtual Printer',
|
||||
spoolbuddy: 'SpoolBuddy',
|
||||
failureDetection: 'Failure Detection',
|
||||
users: 'Authentication',
|
||||
backup: 'Backup',
|
||||
emailAuth: 'Email Authentication',
|
||||
@@ -4853,4 +4854,36 @@ export default {
|
||||
viewIssue: 'View Issue',
|
||||
unexpectedError: 'An unexpected error occurred',
|
||||
},
|
||||
failureDetection: {
|
||||
title: 'AI Failure Detection',
|
||||
description: 'Monitor prints with a self-hosted Obico ML API and act on detected failures automatically.',
|
||||
mlUrl: 'Obico ML API URL',
|
||||
mlUrlHint: 'Base URL of your self-hosted Obico ml_api container (e.g. http://192.168.1.10:3333).',
|
||||
test: 'Test',
|
||||
testSuccess: 'ML API reachable and healthy.',
|
||||
testFailed: 'Could not reach the ML API.',
|
||||
sensitivity: 'Sensitivity',
|
||||
sensitivityLow: 'Low (fewer false positives)',
|
||||
sensitivityMedium: 'Medium (balanced)',
|
||||
sensitivityHigh: 'High (detect early, more false positives)',
|
||||
sensitivityHint: 'Adjusts the confidence thresholds that trigger warnings and failures.',
|
||||
action: 'Action on detected failure',
|
||||
actionNotify: 'Notify only',
|
||||
actionPause: 'Pause print',
|
||||
actionPauseOff: 'Pause and cut power',
|
||||
pollInterval: 'Poll interval (seconds)',
|
||||
pollIntervalHint: 'How often to check each printer while it is printing. Minimum 5s, maximum 120s.',
|
||||
externalUrlMissing: 'External URL is not set.',
|
||||
externalUrlHint: 'The ML API fetches the camera snapshot by URL. Set the External URL in General settings so the ML API container can reach Bambuddy.',
|
||||
perPrinterTitle: 'Monitored Printers',
|
||||
perPrinterHint: 'Choose which printers the detection service watches.',
|
||||
monitorAll: 'Monitor all connected printers',
|
||||
statusTitle: 'Status',
|
||||
serviceRunning: 'Service running',
|
||||
thresholds: 'Low / High thresholds',
|
||||
activePrinters: 'Active prints',
|
||||
noActivePrints: 'No prints currently running.',
|
||||
historyTitle: 'Recent Detections',
|
||||
noHistory: 'No detections yet.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1307,6 +1307,7 @@ export default {
|
||||
network: 'Réseau',
|
||||
apiKeys: 'Clés API',
|
||||
virtualPrinter: 'Imprimante virtuelle',
|
||||
failureDetection: 'Détection d\'échec',
|
||||
users: 'Authentification',
|
||||
backup: 'Sauvegarde',
|
||||
emailAuth: 'Authentification Email',
|
||||
@@ -4799,4 +4800,36 @@ export default {
|
||||
viewIssue: 'Voir l\'issue',
|
||||
unexpectedError: 'Une erreur inattendue est survenue',
|
||||
},
|
||||
failureDetection: {
|
||||
title: 'Détection d\'échec par IA',
|
||||
description: 'Surveille les impressions via une API ML Obico auto-hébergée et agit automatiquement sur les échecs détectés.',
|
||||
mlUrl: 'URL de l\'API ML Obico',
|
||||
mlUrlHint: 'URL de base de votre conteneur Obico ml_api auto-hébergé (ex. http://192.168.1.10:3333).',
|
||||
test: 'Tester',
|
||||
testSuccess: 'API ML accessible et fonctionnelle.',
|
||||
testFailed: 'Impossible d\'atteindre l\'API ML.',
|
||||
sensitivity: 'Sensibilité',
|
||||
sensitivityLow: 'Basse (moins de faux positifs)',
|
||||
sensitivityMedium: 'Moyenne (équilibrée)',
|
||||
sensitivityHigh: 'Haute (détection précoce, plus de faux positifs)',
|
||||
sensitivityHint: 'Ajuste les seuils de confiance qui déclenchent les avertissements et les échecs.',
|
||||
action: 'Action sur échec détecté',
|
||||
actionNotify: 'Notifier uniquement',
|
||||
actionPause: 'Mettre en pause',
|
||||
actionPauseOff: 'Pause et couper l\'alimentation',
|
||||
pollInterval: 'Intervalle de vérification (secondes)',
|
||||
pollIntervalHint: 'Fréquence de vérification de chaque imprimante pendant l\'impression. Minimum 5s, maximum 120s.',
|
||||
externalUrlMissing: 'L\'URL externe n\'est pas définie.',
|
||||
externalUrlHint: 'L\'API ML récupère l\'image de la caméra via URL. Définissez l\'URL externe dans les paramètres généraux pour que le conteneur ML API puisse atteindre Bambuddy.',
|
||||
perPrinterTitle: 'Imprimantes surveillées',
|
||||
perPrinterHint: 'Choisissez quelles imprimantes le service de détection surveille.',
|
||||
monitorAll: 'Surveiller toutes les imprimantes connectées',
|
||||
statusTitle: 'Statut',
|
||||
serviceRunning: 'Service en cours d\'exécution',
|
||||
thresholds: 'Seuils bas / haut',
|
||||
activePrinters: 'Impressions actives',
|
||||
noActivePrints: 'Aucune impression en cours.',
|
||||
historyTitle: 'Détections récentes',
|
||||
noHistory: 'Aucune détection pour le moment.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1307,6 +1307,7 @@ export default {
|
||||
network: 'Rete',
|
||||
apiKeys: 'Chiavi API',
|
||||
virtualPrinter: 'Stampante virtuale',
|
||||
failureDetection: 'Rilevamento guasti',
|
||||
users: 'Utenti',
|
||||
backup: 'Backup',
|
||||
emailAuth: 'Autenticazione Email',
|
||||
@@ -4798,4 +4799,36 @@ export default {
|
||||
viewIssue: 'Vedi issue',
|
||||
unexpectedError: 'Si è verificato un errore imprevisto',
|
||||
},
|
||||
failureDetection: {
|
||||
title: 'Rilevamento guasti con IA',
|
||||
description: 'Monitora le stampe tramite un\'API ML Obico auto-ospitata e agisce automaticamente sui guasti rilevati.',
|
||||
mlUrl: 'URL API ML Obico',
|
||||
mlUrlHint: 'URL base del tuo container Obico ml_api auto-ospitato (es. http://192.168.1.10:3333).',
|
||||
test: 'Prova',
|
||||
testSuccess: 'API ML raggiungibile e funzionante.',
|
||||
testFailed: 'Impossibile raggiungere l\'API ML.',
|
||||
sensitivity: 'Sensibilità',
|
||||
sensitivityLow: 'Bassa (meno falsi positivi)',
|
||||
sensitivityMedium: 'Media (bilanciata)',
|
||||
sensitivityHigh: 'Alta (rilevamento precoce, più falsi positivi)',
|
||||
sensitivityHint: 'Regola le soglie di confidenza che attivano avvisi e guasti.',
|
||||
action: 'Azione al guasto rilevato',
|
||||
actionNotify: 'Solo notifica',
|
||||
actionPause: 'Metti in pausa',
|
||||
actionPauseOff: 'Pausa e stacca corrente',
|
||||
pollInterval: 'Intervallo di controllo (secondi)',
|
||||
pollIntervalHint: 'Frequenza di controllo di ogni stampante durante la stampa. Minimo 5s, massimo 120s.',
|
||||
externalUrlMissing: 'URL esterno non impostato.',
|
||||
externalUrlHint: 'L\'API ML recupera l\'immagine della fotocamera tramite URL. Imposta l\'URL esterno nelle impostazioni generali affinché il container ML API possa raggiungere Bambuddy.',
|
||||
perPrinterTitle: 'Stampanti monitorate',
|
||||
perPrinterHint: 'Scegli quali stampanti il servizio di rilevamento deve monitorare.',
|
||||
monitorAll: 'Monitora tutte le stampanti connesse',
|
||||
statusTitle: 'Stato',
|
||||
serviceRunning: 'Servizio in esecuzione',
|
||||
thresholds: 'Soglie bassa / alta',
|
||||
activePrinters: 'Stampe attive',
|
||||
noActivePrints: 'Nessuna stampa in corso.',
|
||||
historyTitle: 'Rilevamenti recenti',
|
||||
noHistory: 'Nessun rilevamento finora.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1307,6 +1307,7 @@ export default {
|
||||
apiKeys: 'APIキー',
|
||||
virtualPrinter: '仮想プリンター',
|
||||
spoolbuddy: 'SpoolBuddy',
|
||||
failureDetection: '失敗検出',
|
||||
users: '認証',
|
||||
backup: 'バックアップ',
|
||||
emailAuth: 'メール認証',
|
||||
@@ -4837,4 +4838,36 @@ export default {
|
||||
viewIssue: 'Issueを表示',
|
||||
unexpectedError: '予期しないエラーが発生しました',
|
||||
},
|
||||
failureDetection: {
|
||||
title: 'AI 失敗検出',
|
||||
description: 'セルフホストされた Obico ML API で印刷を監視し、検出された失敗に自動的に対応します。',
|
||||
mlUrl: 'Obico ML API の URL',
|
||||
mlUrlHint: 'セルフホストした Obico ml_api コンテナのベース URL (例: http://192.168.1.10:3333)。',
|
||||
test: 'テスト',
|
||||
testSuccess: 'ML API に接続でき、正常です。',
|
||||
testFailed: 'ML API に接続できませんでした。',
|
||||
sensitivity: '感度',
|
||||
sensitivityLow: '低(誤検出が少ない)',
|
||||
sensitivityMedium: '中(バランス型)',
|
||||
sensitivityHigh: '高(早期検出、誤検出が増加)',
|
||||
sensitivityHint: '警告と失敗をトリガーする信頼度のしきい値を調整します。',
|
||||
action: '失敗検出時の動作',
|
||||
actionNotify: '通知のみ',
|
||||
actionPause: '印刷を一時停止',
|
||||
actionPauseOff: '一時停止して電源を切る',
|
||||
pollInterval: 'ポーリング間隔(秒)',
|
||||
pollIntervalHint: '印刷中に各プリンターをチェックする頻度。最小 5 秒、最大 120 秒。',
|
||||
externalUrlMissing: '外部 URL が設定されていません。',
|
||||
externalUrlHint: 'ML API は URL 経由でカメラ画像を取得します。ML API コンテナが Bambuddy に到達できるよう、一般設定で外部 URL を設定してください。',
|
||||
perPrinterTitle: '監視対象プリンター',
|
||||
perPrinterHint: '検出サービスが監視するプリンターを選択します。',
|
||||
monitorAll: '接続されているすべてのプリンターを監視',
|
||||
statusTitle: 'ステータス',
|
||||
serviceRunning: 'サービス稼働中',
|
||||
thresholds: '低 / 高しきい値',
|
||||
activePrinters: 'アクティブな印刷',
|
||||
noActivePrints: '現在、実行中の印刷はありません。',
|
||||
historyTitle: '最近の検出',
|
||||
noHistory: 'まだ検出はありません。',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1307,6 +1307,7 @@ export default {
|
||||
network: 'Rede',
|
||||
apiKeys: 'Chaves API',
|
||||
virtualPrinter: 'Impressora Virtual',
|
||||
failureDetection: 'Detecção de Falhas',
|
||||
users: 'Autenticação',
|
||||
backup: 'Backup',
|
||||
emailAuth: 'Autenticação por Email',
|
||||
@@ -4798,4 +4799,36 @@ export default {
|
||||
viewIssue: 'Ver issue',
|
||||
unexpectedError: 'Ocorreu um erro inesperado',
|
||||
},
|
||||
failureDetection: {
|
||||
title: 'Detecção de Falhas por IA',
|
||||
description: 'Monitora impressões via API ML do Obico auto-hospedada e age automaticamente em falhas detectadas.',
|
||||
mlUrl: 'URL da API ML do Obico',
|
||||
mlUrlHint: 'URL base do seu contêiner Obico ml_api auto-hospedado (ex.: http://192.168.1.10:3333).',
|
||||
test: 'Testar',
|
||||
testSuccess: 'API ML acessível e operacional.',
|
||||
testFailed: 'Não foi possível acessar a API ML.',
|
||||
sensitivity: 'Sensibilidade',
|
||||
sensitivityLow: 'Baixa (menos falsos positivos)',
|
||||
sensitivityMedium: 'Média (equilibrada)',
|
||||
sensitivityHigh: 'Alta (detecção precoce, mais falsos positivos)',
|
||||
sensitivityHint: 'Ajusta os limiares de confiança que disparam avisos e falhas.',
|
||||
action: 'Ação em falha detectada',
|
||||
actionNotify: 'Apenas notificar',
|
||||
actionPause: 'Pausar impressão',
|
||||
actionPauseOff: 'Pausar e cortar energia',
|
||||
pollInterval: 'Intervalo de verificação (segundos)',
|
||||
pollIntervalHint: 'Frequência de verificação de cada impressora durante a impressão. Mínimo 5s, máximo 120s.',
|
||||
externalUrlMissing: 'URL externa não configurada.',
|
||||
externalUrlHint: 'A API ML busca a imagem da câmera por URL. Defina a URL externa nas configurações gerais para que o contêiner da API ML possa alcançar o Bambuddy.',
|
||||
perPrinterTitle: 'Impressoras monitoradas',
|
||||
perPrinterHint: 'Escolha quais impressoras o serviço de detecção monitora.',
|
||||
monitorAll: 'Monitorar todas as impressoras conectadas',
|
||||
statusTitle: 'Status',
|
||||
serviceRunning: 'Serviço em execução',
|
||||
thresholds: 'Limiares baixo / alto',
|
||||
activePrinters: 'Impressões ativas',
|
||||
noActivePrints: 'Nenhuma impressão em andamento.',
|
||||
historyTitle: 'Detecções recentes',
|
||||
noHistory: 'Nenhuma detecção ainda.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1307,6 +1307,7 @@ export default {
|
||||
network: '网络',
|
||||
apiKeys: 'API 密钥',
|
||||
virtualPrinter: '虚拟打印机',
|
||||
failureDetection: '故障检测',
|
||||
users: '身份验证',
|
||||
backup: '备份',
|
||||
emailAuth: '邮箱认证',
|
||||
@@ -4797,4 +4798,36 @@ export default {
|
||||
viewIssue: '查看Issue',
|
||||
unexpectedError: '发生了意外错误',
|
||||
},
|
||||
failureDetection: {
|
||||
title: 'AI 故障检测',
|
||||
description: '通过自托管的 Obico ML API 监控打印,并对检测到的故障自动采取行动。',
|
||||
mlUrl: 'Obico ML API 地址',
|
||||
mlUrlHint: '您自托管的 Obico ml_api 容器的基础 URL(例如 http://192.168.1.10:3333)。',
|
||||
test: '测试',
|
||||
testSuccess: 'ML API 可访问且正常。',
|
||||
testFailed: '无法访问 ML API。',
|
||||
sensitivity: '灵敏度',
|
||||
sensitivityLow: '低(减少误报)',
|
||||
sensitivityMedium: '中(平衡)',
|
||||
sensitivityHigh: '高(更早检测,更多误报)',
|
||||
sensitivityHint: '调整触发警告和故障的置信度阈值。',
|
||||
action: '检测到故障时的操作',
|
||||
actionNotify: '仅通知',
|
||||
actionPause: '暂停打印',
|
||||
actionPauseOff: '暂停并切断电源',
|
||||
pollInterval: '检查间隔(秒)',
|
||||
pollIntervalHint: '打印过程中每台打印机的检查频率。最小 5 秒,最大 120 秒。',
|
||||
externalUrlMissing: '未设置外部 URL。',
|
||||
externalUrlHint: 'ML API 通过 URL 获取相机截图。请在常规设置中设置外部 URL,以便 ML API 容器可以访问 Bambuddy。',
|
||||
perPrinterTitle: '监控的打印机',
|
||||
perPrinterHint: '选择检测服务要监视哪些打印机。',
|
||||
monitorAll: '监控所有已连接的打印机',
|
||||
statusTitle: '状态',
|
||||
serviceRunning: '服务运行中',
|
||||
thresholds: '低 / 高阈值',
|
||||
activePrinters: '活动打印',
|
||||
noActivePrints: '当前没有正在进行的打印。',
|
||||
historyTitle: '最近检测',
|
||||
noHistory: '暂无检测记录。',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon } from 'lucide-react';
|
||||
import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
@@ -25,6 +25,7 @@ import { ExternalLinksSettings } from '../components/ExternalLinksSettings';
|
||||
import { VirtualPrinterList } from '../components/VirtualPrinterList';
|
||||
import { SpoolBuddySettings } from '../components/SpoolBuddySettings';
|
||||
import { GitHubBackupSettings } from '../components/GitHubBackupSettings';
|
||||
import { FailureDetectionSettings } from '../components/FailureDetectionSettings';
|
||||
import { EmailSettings } from '../components/EmailSettings';
|
||||
import { LDAPSettings } from '../components/LDAPSettings';
|
||||
import { APIBrowser } from '../components/APIBrowser';
|
||||
@@ -37,7 +38,7 @@ import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, t
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Palette } from 'lucide-react';
|
||||
|
||||
const validTabs = ['general', 'plugs', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'users', 'backup'] as const;
|
||||
const validTabs = ['general', 'plugs', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const;
|
||||
type TabType = typeof validTabs[number];
|
||||
type UsersSubTab = 'users' | 'email' | 'ldap';
|
||||
|
||||
@@ -366,6 +367,14 @@ export function SettingsPage() {
|
||||
const spoolbuddyDeviceCount = spoolbuddyDevices?.length ?? 0;
|
||||
const spoolbuddyAnyOnline = spoolbuddyDevices?.some((d) => d.online) ?? false;
|
||||
|
||||
// Obico failure-detection service status for tab indicator
|
||||
const { data: obicoStatus } = useQuery({
|
||||
queryKey: ['obico-status'],
|
||||
queryFn: api.getObicoStatus,
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
const obicoActive = !!(obicoStatus?.is_running && obicoStatus?.enabled);
|
||||
|
||||
const { data: ffmpegStatus } = useQuery({
|
||||
queryKey: ['ffmpeg-status'],
|
||||
queryFn: api.checkFfmpeg,
|
||||
@@ -1096,7 +1105,7 @@ export function SettingsPage() {
|
||||
>
|
||||
<p className="text-sm text-white">{entry.label}</p>
|
||||
<p className="text-xs text-bambu-gray">
|
||||
{t(`settings.tabs.${entry.tab === 'virtual-printer' ? 'virtualPrinter' : entry.tab}`)}
|
||||
{t(`settings.tabs.${entry.tab === 'virtual-printer' ? 'virtualPrinter' : entry.tab === 'failure-detection' ? 'failureDetection' : entry.tab}`)}
|
||||
{entry.subTab ? ` › ${t(`settings.tabs.${entry.subTab}`, entry.subTab)}` : ''}
|
||||
</p>
|
||||
</button>
|
||||
@@ -1236,6 +1245,18 @@ export function SettingsPage() {
|
||||
)}
|
||||
<span className={`w-2 h-2 rounded-full ${spoolbuddyAnyOnline ? 'bg-green-400' : 'bg-gray-500'}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('failure-detection')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
|
||||
activeTab === 'failure-detection'
|
||||
? 'text-bambu-green border-bambu-green'
|
||||
: 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
|
||||
}`}
|
||||
>
|
||||
<ScanEye className="w-4 h-4" />
|
||||
{t('settings.tabs.failureDetection')}
|
||||
<span className={`w-2 h-2 rounded-full ${obicoActive ? 'bg-green-400' : 'bg-gray-500'}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('users')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
|
||||
@@ -5171,6 +5192,12 @@ export function SettingsPage() {
|
||||
)}
|
||||
|
||||
{/* Backup Tab */}
|
||||
{activeTab === 'failure-detection' && (
|
||||
<div id="card-failure-detection">
|
||||
<FailureDetectionSettings />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'backup' && (
|
||||
<div id="card-backup">
|
||||
<GitHubBackupSettings />
|
||||
|
||||
Reference in New Issue
Block a user