feat(notifications): dedicate AI Failure Detection notification event (#1794)

Split Obico failure-detection dispatch out of the multiplexed
  on_printer_error event onto its own on_ai_failure_detection event so
  users can subscribe to AI alerts without also enabling HMS hardware-
  error pages, and so the discoverable label "AI Failure Detection" is
  what subscribes them rather than the unrelated "Printer Error" toggle.

  New column on notification_providers (default False, branched
  SQLite/Postgres migration), new notification_service.on_ai_failure_detection
  method, new ai_failure_detection template, obico_actions._notify swap.
  Frontend gets a summary badge, a toggle row with description, and ntfy
  priority surfacing. 14 new tests pin the routing + the regression guard
  ("Printer Error" alone must NOT receive AI notifications now). 11 locales
  covered.

  Existing providers keep working: HMS hardware errors continue to ride
  on_printer_error unchanged; users who want spaghetti alerts opt in via
  the new toggle.
This commit is contained in:
maziggy
2026-06-22 08:15:29 +02:00
parent 12b21ce0df
commit 4206d675eb
33 changed files with 579 additions and 23 deletions
@@ -40,6 +40,7 @@ function buildProvider(overrides: Partial<NotificationProvider> = {}): Notificat
on_print_missing_spool_assignment: false,
on_printer_offline: false,
on_printer_error: false,
on_ai_failure_detection: false,
on_filament_low: false,
on_maintenance_due: false,
on_ams_humidity_high: false,
@@ -336,3 +337,52 @@ describe('AddNotificationModal — stock alert toggles', () => {
void user; // referenced to avoid unused-var lint warning
});
});
describe('AddNotificationModal — AI Failure Detection toggle (#1794)', () => {
it('renders the toggle in the Printer Status section', async () => {
render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
expect(await screen.findByText('AI Failure Detection')).toBeInTheDocument();
});
it('persists on_ai_failure_detection on save (and does NOT touch on_printer_error)', async () => {
let captured: Record<string, unknown> | null = null;
server.use(
http.patch('*/api/v1/notifications/1', async ({ request }) => {
captured = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ id: 1 });
}),
);
const onClose = vi.fn();
const user = userEvent.setup();
render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
const label = await screen.findByText('AI Failure Detection');
const row = label.closest('div.flex')!;
const toggle = within(row).getByRole('switch');
await user.click(toggle);
await user.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(captured).not.toBeNull();
expect(captured!.on_ai_failure_detection).toBe(true);
// Critical regression guard: don't accidentally flip the legacy multiplexed field.
expect(captured!.on_printer_error).toBe(false);
});
it('AI Failure Detection appears in ntfy priority section when enabled', async () => {
render(
<AddNotificationModal
provider={buildProvider({ on_ai_failure_detection: true })}
onClose={() => undefined}
/>,
);
const priorityHeader = await screen.findByText(/ntfy priority/i);
const priorityRoot = priorityHeader.closest('div')!;
expect(within(priorityRoot).getByText('AI Failure Detection')).toBeInTheDocument();
});
});
@@ -57,6 +57,7 @@ const createMockProvider = (
on_print_progress: false,
on_printer_offline: false,
on_printer_error: false,
on_ai_failure_detection: false,
on_filament_low: false,
on_maintenance_due: false,
on_ams_humidity_high: false,
@@ -0,0 +1,128 @@
/**
* Tests for the AI Failure Detection toggle on NotificationProviderCard (#1794).
*
* Before #1794, Obico failure detection rode the multiplexed
* on_printer_error toggle so users couldn't subscribe to one without the
* other. These tests pin the standalone toggle:
* - Summary badge renders when enabled.
* - The toggle row appears in the expanded settings panel.
* - Flipping the toggle PATCHes the correct field.
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { render } from '../utils';
import { server } from '../mocks/server';
import { NotificationProviderCard } from '../../components/NotificationProviderCard';
import type { NotificationProvider } from '../../api/client';
afterEach(() => {
server.resetHandlers();
vi.restoreAllMocks();
});
function buildProvider(overrides: Partial<NotificationProvider> = {}): NotificationProvider {
return {
id: 1,
name: 'Test Provider',
provider_type: 'ntfy',
enabled: true,
config: { server: 'https://ntfy.sh', topic: 'bambuddy' },
on_print_start: false,
on_print_complete: false,
on_print_failed: false,
on_print_stopped: false,
on_print_progress: false,
on_print_missing_spool_assignment: false,
on_printer_offline: false,
on_printer_error: false,
on_ai_failure_detection: false,
on_filament_low: false,
on_maintenance_due: false,
on_ams_humidity_high: false,
on_ams_temperature_high: false,
on_ams_ht_humidity_high: false,
on_ams_ht_temperature_high: false,
on_plate_not_empty: false,
on_bed_cooled: false,
on_first_layer_complete: false,
on_queue_job_added: false,
on_queue_job_assigned: false,
on_queue_job_started: false,
on_queue_job_waiting: false,
on_queue_job_skipped: false,
on_queue_job_failed: false,
on_queue_completed: false,
on_stock_reorder_alert: false,
on_stock_break_alert: false,
quiet_hours_enabled: false,
quiet_hours_start: null,
quiet_hours_end: null,
daily_digest_enabled: false,
daily_digest_time: null,
printer_id: null,
last_success: null,
last_error: null,
last_error_at: null,
created_at: '2026-06-22T00:00:00Z',
updated_at: '2026-06-22T00:00:00Z',
...overrides,
};
}
describe('NotificationProviderCard — AI Failure Detection badge', () => {
it('renders the badge when on_ai_failure_detection is true', async () => {
render(
<NotificationProviderCard
provider={buildProvider({ on_ai_failure_detection: true })}
onEdit={vi.fn()}
/>,
);
expect(await screen.findByText('AI Failure Detection')).toBeInTheDocument();
});
it('omits the badge when on_ai_failure_detection is false', async () => {
render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
await screen.findByText('Test Provider');
expect(screen.queryByText('AI Failure Detection')).not.toBeInTheDocument();
});
});
describe('NotificationProviderCard — AI Failure Detection toggle', () => {
it('renders the toggle in the expanded settings panel', async () => {
const user = userEvent.setup();
render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
await user.click(await screen.findByText(/event settings/i));
expect(await screen.findByText('AI Failure Detection')).toBeInTheDocument();
});
it('PATCHes on_ai_failure_detection (NOT on_printer_error) when toggled on — #1794 regression guard', async () => {
let captured: Record<string, unknown> | null = null;
server.use(
http.patch('*/api/v1/notifications/1', async ({ request }) => {
captured = (await request.json()) as Record<string, unknown>;
return HttpResponse.json(buildProvider({ on_ai_failure_detection: true }));
}),
);
const user = userEvent.setup();
render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
await user.click(await screen.findByText(/event settings/i));
// The toggle label "AI Failure Detection" is unique to this row.
const label = await screen.findByText('AI Failure Detection');
const row = label.closest('div.flex')!;
const toggle = within(row).getByRole('switch');
await user.click(toggle);
await waitFor(() => expect(captured).not.toBeNull());
expect(captured).toMatchObject({ on_ai_failure_detection: true });
// Critical: must NOT also flip the legacy multiplexed field.
expect(captured).not.toHaveProperty('on_printer_error');
});
});
@@ -37,6 +37,7 @@ function buildProvider(overrides: Partial<NotificationProvider> = {}): Notificat
on_print_missing_spool_assignment: false,
on_printer_offline: false,
on_printer_error: false,
on_ai_failure_detection: false,
on_filament_low: false,
on_maintenance_due: false,
on_ams_humidity_high: false,
+1
View File
@@ -50,6 +50,7 @@ const mockNotificationProviders = [
on_print_progress: false,
on_printer_offline: false,
on_printer_error: false,
on_ai_failure_detection: false,
on_filament_low: false,
on_maintenance_due: false,
on_ams_humidity_high: false,
+3
View File
@@ -2172,6 +2172,7 @@ export interface NotificationProvider {
// Printer status events
on_printer_offline: boolean;
on_printer_error: boolean;
on_ai_failure_detection: boolean;
on_filament_low: boolean;
on_maintenance_due: boolean;
// AMS environmental alarms (regular AMS)
@@ -2230,6 +2231,7 @@ export interface NotificationProviderCreate {
// Printer status events
on_printer_offline?: boolean;
on_printer_error?: boolean;
on_ai_failure_detection?: boolean;
on_filament_low?: boolean;
on_maintenance_due?: boolean;
// AMS environmental alarms (regular AMS)
@@ -2281,6 +2283,7 @@ export interface NotificationProviderUpdate {
// Printer status events
on_printer_offline?: boolean;
on_printer_error?: boolean;
on_ai_failure_detection?: boolean;
on_filament_low?: boolean;
on_maintenance_due?: boolean;
// AMS environmental alarms (regular AMS)
@@ -38,6 +38,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
const [onPrintProgress, setOnPrintProgress] = useState(provider?.on_print_progress ?? false);
const [onPrinterOffline, setOnPrinterOffline] = useState(provider?.on_printer_offline ?? false);
const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false);
const [onAiFailureDetection, setOnAiFailureDetection] = useState(provider?.on_ai_failure_detection ?? false);
const [onFilamentLow, setOnFilamentLow] = useState(provider?.on_filament_low ?? false);
const [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false);
const [onStockReorderAlert, setOnStockReorderAlert] = useState(provider?.on_stock_reorder_alert ?? false);
@@ -167,6 +168,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
on_print_progress: onPrintProgress,
on_printer_offline: onPrinterOffline,
on_printer_error: onPrinterError,
on_ai_failure_detection: onAiFailureDetection,
on_filament_low: onFilamentLow,
on_maintenance_due: onMaintenanceDue,
on_stock_reorder_alert: onStockReorderAlert,
@@ -547,6 +549,10 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
<span className="text-sm text-white">{t('notifications.error')}</span>
<Toggle checked={onPrinterError} onChange={setOnPrinterError} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">{t('notifications.aiFailureDetection')}</span>
<Toggle checked={onAiFailureDetection} onChange={setOnAiFailureDetection} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-white">{t('notifications.lowFilament')}</span>
<Toggle checked={onFilamentLow} onChange={setOnFilamentLow} />
@@ -591,6 +597,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });
if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') });
if (onPrinterError) enabledEvents.push({ key: 'on_printer_error', label: t('notifications.error') });
if (onAiFailureDetection) enabledEvents.push({ key: 'on_ai_failure_detection', label: t('notifications.aiFailureDetection') });
if (onFilamentLow) enabledEvents.push({ key: 'on_filament_low', label: t('notifications.lowFilament') });
if (onMaintenanceDue) enabledEvents.push({ key: 'on_maintenance_due', label: t('notifications.maintenance') });
if (onStockReorderAlert) enabledEvents.push({ key: 'on_stock_reorder_alert', label: t('notifications.stockReorderAlert') });
@@ -138,6 +138,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
{provider.on_printer_error && (
<span className="px-2 py-0.5 bg-rose-500/20 text-rose-400 text-xs rounded">{t('notifications.error')}</span>
)}
{provider.on_ai_failure_detection && (
<span className="px-2 py-0.5 bg-fuchsia-500/20 text-fuchsia-300 text-xs rounded">{t('notifications.aiFailureDetection')}</span>
)}
{provider.on_filament_low && (
<span className="px-2 py-0.5 bg-cyan-500/20 text-cyan-400 text-xs rounded">{t('notifications.lowFilament')}</span>
)}
@@ -366,6 +369,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white">{t('notifications.aiFailureDetection')}</p>
<p className="text-xs text-bambu-gray">{t('notifications.aiFailureDetectionDescription')}</p>
</div>
<Toggle
checked={provider.on_ai_failure_detection ?? false}
onChange={(checked) => updateMutation.mutate({ on_ai_failure_detection: checked })}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-white">{t('notifications.lowFilamentLabel')}</p>
<Toggle
+2
View File
@@ -5018,6 +5018,8 @@ export default {
progressMilestonesDescription: 'Benachrichtigung bei 25%, 50%, 75%',
printerOffline: 'Drucker offline',
printerError: 'Druckerfehler',
aiFailureDetection: 'KI-Fehlererkennung',
aiFailureDetectionDescription: 'Benachrichtigen, wenn die Obico-KI einen möglichen Druckfehler erkennt',
lowFilamentLabel: 'Filament niedrig',
maintenanceDue: 'Wartung fällig',
maintenanceDueDescription: 'Benachrichtigen, wenn Wartung erforderlich ist',
+2
View File
@@ -5043,6 +5043,8 @@ export default {
progressMilestonesDescription: 'Notify at 25%, 50%, 75%',
printerOffline: 'Printer Offline',
printerError: 'Printer Error',
aiFailureDetection: 'AI Failure Detection',
aiFailureDetectionDescription: 'Notify when Obico AI detects a possible print failure',
lowFilamentLabel: 'Low Filament',
maintenanceDue: 'Maintenance Due',
maintenanceDueDescription: 'Notify when maintenance is needed',
+2
View File
@@ -5027,6 +5027,8 @@ export default {
progressMilestonesDescription: 'Notificar al 25%, 50% y 75%',
printerOffline: 'Impresora desconectada',
printerError: 'Error de la impresora',
aiFailureDetection: 'Detección de fallos por IA',
aiFailureDetectionDescription: 'Notificar cuando la IA de Obico detecte un posible fallo de impresión',
lowFilamentLabel: 'Filamento bajo',
maintenanceDue: 'Mantenimiento pendiente',
maintenanceDueDescription: 'Notificar cuando se necesite mantenimiento',
+2
View File
@@ -5008,6 +5008,8 @@ export default {
progressMilestonesDescription: 'Notifier à 25 %, 50 %, 75 %',
printerOffline: 'Imprimante hors ligne',
printerError: 'Erreur de l\'imprimante',
aiFailureDetection: 'Détection de défaillance par IA',
aiFailureDetectionDescription: 'Notifier lorsque l\'IA Obico détecte une défaillance d\'impression possible',
lowFilamentLabel: 'Filament bas',
maintenanceDue: 'Maintenance requise',
maintenanceDueDescription: 'Notifier lorsqu\'une maintenance est nécessaire',
+2
View File
@@ -5007,6 +5007,8 @@ export default {
progressMilestonesDescription: 'Notifica al 25%, 50%, 75%',
printerOffline: 'Stampante offline',
printerError: 'Errore stampante',
aiFailureDetection: 'Rilevamento guasti IA',
aiFailureDetectionDescription: 'Notifica quando l\'IA di Obico rileva un possibile guasto di stampa',
lowFilamentLabel: 'Filamento scarso',
maintenanceDue: 'Manutenzione necessaria',
maintenanceDueDescription: 'Notifica quando è necessaria la manutenzione',
+2
View File
@@ -5019,6 +5019,8 @@ export default {
progressMilestonesDescription: '25%、50%、75%で通知',
printerOffline: 'プリンターオフライン',
printerError: 'プリンターエラー',
aiFailureDetection: 'AI 故障検出',
aiFailureDetectionDescription: 'Obico AI が印刷の不具合の可能性を検出したときに通知',
lowFilamentLabel: 'フィラメント残量低下',
maintenanceDue: 'メンテナンス期限',
maintenanceDueDescription: 'メンテナンスが必要な場合に通知',
+2
View File
@@ -4740,6 +4740,8 @@ export default {
progressMilestonesDescription: '25%, 50%, 75%에서 알림',
printerOffline: '프린터 오프라인',
printerError: '프린터 오류',
aiFailureDetection: 'AI 실패 감지',
aiFailureDetectionDescription: 'Obico AI가 인쇄 실패 가능성을 감지하면 알림',
lowFilamentLabel: '필라멘트 부족',
maintenanceDue: '유지 관리 필요',
maintenanceDueDescription: '유지 관리가 필요할 때 알림',
+2
View File
@@ -5007,6 +5007,8 @@ export default {
progressMilestonesDescription: 'Notificar em 25%, 50%, 75%',
printerOffline: 'Impressora Offline',
printerError: 'Erro da Impressora',
aiFailureDetection: 'Detecção de Falhas por IA',
aiFailureDetectionDescription: 'Notificar quando a IA do Obico detectar uma possível falha de impressão',
lowFilamentLabel: 'Filamento Baixo',
maintenanceDue: 'Manutenção Necessária',
maintenanceDueDescription: 'Notificar quando manutenção for necessária',
+2
View File
@@ -4974,6 +4974,8 @@ export default {
progressMilestonesDescription: '%25, %50, %75\'te bildir',
printerOffline: 'Yazıcı Çevrimdışı',
printerError: 'Yazıcı Hatası',
aiFailureDetection: 'AI Hata Tespiti',
aiFailureDetectionDescription: 'Obico AI olası bir baskı hatası tespit ettiğinde bildir',
lowFilamentLabel: 'Az Filament',
maintenanceDue: 'Bakım Zamanı',
maintenanceDueDescription: 'Bakım gerektiğinde bildir',
+2
View File
@@ -5007,6 +5007,8 @@ export default {
progressMilestonesDescription: '在 25%、50%、75% 时通知',
printerOffline: '打印机离线',
printerError: '打印机错误',
aiFailureDetection: 'AI 失败检测',
aiFailureDetectionDescription: '当 Obico AI 检测到可能的打印失败时通知',
lowFilamentLabel: '耗材不足',
maintenanceDue: '需要维护',
maintenanceDueDescription: '需要维护时通知',
+2
View File
@@ -5007,6 +5007,8 @@ export default {
progressMilestonesDescription: '在 25%、50%、75% 時通知',
printerOffline: '印表機離線',
printerError: '印表機錯誤',
aiFailureDetection: 'AI 失敗偵測',
aiFailureDetectionDescription: '當 Obico AI 偵測到可能的列印失敗時通知',
lowFilamentLabel: '耗材不足',
maintenanceDue: '需要維護',
maintenanceDueDescription: '需要維護時通知',