mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
feat(system): log-health scanner + Add/Edit-Printer setup pre-flight
Adds a passive log-health check that complements the active Connection Diagnostic. Scans Bambuddy's recent app log against a curated allowlist catalog of known failure signatures (rejected access code, FTPS :990 timeout, FTPS TLS failure, flapping MQTT, unreachable camera, SQLite "database is locked" contention), dedupes and classifies each finding as layer8/environment/bug, and deep-links to the troubleshooting wiki. Sample log lines are sanitized before they leave the process. Exposed via GET /system/health and surfaced on two surfaces sharing one SystemHealthPanel component: a System Health section on the System page, and inline in the bug reporter when the form opens. The Add-Printer and Edit-Printer dialogs gained a setup-time pre-flight: saving runs the connection diagnostic and, on a failed check, warns with a "save anyway" escape hatch instead of silently saving a printer that will immediately show offline. Log read/parse/sanitize primitives extracted from routes/support.py into a shared services/log_reader.py (behaviour-preserving); affected support tests repointed accordingly. Tests: test_log_health.py (11), test_system_api.py (2 new), SystemHealthPanel + BugReportBubble + AddPrinterPreflight + EditPrinterPreflight (8 frontend). All strings translated across the 9 locales. Backend ruff clean, full unit suite green, frontend build + eslint clean, i18n parity green.
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Tests for the Add-Printer setup-time pre-flight.
|
||||
*
|
||||
* On save, the modal runs the connection diagnostic; if any check fails it
|
||||
* warns (rather than blocks) before the printer is added.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '../utils';
|
||||
import { PrintersPage } from '../../pages/PrintersPage';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../mocks/server';
|
||||
|
||||
describe('AddPrinterModal pre-flight', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.get('/api/v1/printers/', () => HttpResponse.json([])),
|
||||
http.get('/api/v1/queue/', () => HttpResponse.json([])),
|
||||
http.get('/api/v1/discovery/info', () =>
|
||||
HttpResponse.json({ is_docker: false, ssdp_running: false, scan_running: false, subnets: [] }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('warns instead of saving when a connection check fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/v1/printers/diagnostic', () =>
|
||||
HttpResponse.json({
|
||||
printer_id: null,
|
||||
ip_address: '192.168.1.55',
|
||||
overall: 'problems',
|
||||
checks: [{ id: 'developer_mode', status: 'fail', params: {} }],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<PrintersPage />);
|
||||
await user.click(await screen.findByText(/add printer/i));
|
||||
|
||||
await user.type(await screen.findByPlaceholderText('My Printer'), 'Test Printer');
|
||||
await user.type(screen.getByPlaceholderText('192.168.1.100 or printer.local'), '192.168.1.55');
|
||||
await user.type(screen.getByPlaceholderText('01P00A000000000'), '01P00A000000000');
|
||||
await user.type(screen.getByPlaceholderText('From printer settings'), '12345678');
|
||||
|
||||
const submit = screen
|
||||
.getAllByRole('button', { name: /add printer/i })
|
||||
.find((b) => b.getAttribute('type') === 'submit')!;
|
||||
await user.click(submit);
|
||||
|
||||
// The failed check surfaces a warning with a "save anyway" escape hatch.
|
||||
expect(await screen.findByText(/Some connection checks failed/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /save anyway/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/LAN Developer Mode/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves directly when all connection checks pass', async () => {
|
||||
const user = userEvent.setup();
|
||||
let created = false;
|
||||
server.use(
|
||||
http.post('/api/v1/printers/diagnostic', () =>
|
||||
HttpResponse.json({
|
||||
printer_id: null,
|
||||
ip_address: '192.168.1.55',
|
||||
overall: 'ok',
|
||||
checks: [{ id: 'developer_mode', status: 'pass', params: {} }],
|
||||
}),
|
||||
),
|
||||
http.post('/api/v1/printers/', async () => {
|
||||
created = true;
|
||||
return HttpResponse.json({ id: 9, name: 'Test Printer' });
|
||||
}),
|
||||
);
|
||||
|
||||
render(<PrintersPage />);
|
||||
await user.click(await screen.findByText(/add printer/i));
|
||||
|
||||
await user.type(await screen.findByPlaceholderText('My Printer'), 'Test Printer');
|
||||
await user.type(screen.getByPlaceholderText('192.168.1.100 or printer.local'), '192.168.1.55');
|
||||
await user.type(screen.getByPlaceholderText('01P00A000000000'), '01P00A000000000');
|
||||
await user.type(screen.getByPlaceholderText('From printer settings'), '12345678');
|
||||
|
||||
const submit = screen
|
||||
.getAllByRole('button', { name: /add printer/i })
|
||||
.find((b) => b.getAttribute('type') === 'submit')!;
|
||||
await user.click(submit);
|
||||
|
||||
await waitFor(() => expect(created).toBe(true));
|
||||
expect(screen.queryByText(/Some connection checks failed/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -282,4 +282,36 @@ describe('BugReportBubble', () => {
|
||||
// Single problem → the checklist is expanded without a click.
|
||||
expect(await screen.findByText(/Found problems that explain/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the log-health panel when the scan finds known issues', async () => {
|
||||
const user = userEvent.setup();
|
||||
setupDiagnosticEndpoints([{ id: 1, name: 'Solo Printer' }], { 1: 'ok' });
|
||||
server.use(
|
||||
http.get('*/system/health', () =>
|
||||
HttpResponse.json({
|
||||
findings: [
|
||||
{
|
||||
signature_id: 'ftp-auth-rejected',
|
||||
severity: 'error',
|
||||
category: 'layer8',
|
||||
wiki_anchor: 'wrong-access-code',
|
||||
count: 3,
|
||||
first_seen: '2026-05-22 09:00:00,000',
|
||||
last_seen: '2026-05-22 10:00:00,000',
|
||||
sample: 'FTP connection permission error to [IP]',
|
||||
},
|
||||
],
|
||||
scanned_entries: 500,
|
||||
log_available: true,
|
||||
summary: { total: 1, layer8: 1, environment: 0, bug: 0 },
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
render(<BugReportBubble />);
|
||||
await user.click(screen.getByRole('button'));
|
||||
|
||||
expect(await screen.findByText('Known issues found in your logs')).toBeInTheDocument();
|
||||
expect(screen.getByText('Printer rejected the access code')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Tests for the Edit-Printer setup-time pre-flight.
|
||||
*
|
||||
* Editing a printer runs the same connection diagnostic on save as the
|
||||
* Add-Printer dialog, and warns (rather than blocks) when a check fails.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '../utils';
|
||||
import { PrintersPage } from '../../pages/PrintersPage';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../mocks/server';
|
||||
|
||||
const mockPrinter = {
|
||||
id: 1,
|
||||
name: 'X1 Carbon',
|
||||
ip_address: '192.168.1.100',
|
||||
serial_number: '00M09A350100001',
|
||||
access_code: '12345678',
|
||||
model: 'X1C',
|
||||
enabled: true,
|
||||
nozzle_diameter: 0.4,
|
||||
nozzle_type: 'hardened_steel',
|
||||
location: null,
|
||||
auto_archive: true,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const mockStatus = {
|
||||
connected: true,
|
||||
state: 'IDLE',
|
||||
progress: 0,
|
||||
layer_num: 0,
|
||||
total_layers: 0,
|
||||
temperatures: { nozzle: 25, bed: 25, chamber: 25 },
|
||||
remaining_time: 0,
|
||||
filename: null,
|
||||
wifi_signal: -50,
|
||||
vt_tray: [],
|
||||
};
|
||||
|
||||
async function openEditModal() {
|
||||
render(<PrintersPage />);
|
||||
await waitFor(() => expect(screen.getByText('X1 Carbon')).toBeInTheDocument());
|
||||
|
||||
// Open the per-printer actions menu (kebab button), then click Edit.
|
||||
const menuBtn = [...document.querySelectorAll('button')].find((b) =>
|
||||
b.querySelector('.lucide-ellipsis-vertical'),
|
||||
)!;
|
||||
await userEvent.click(menuBtn);
|
||||
await userEvent.click(await screen.findByRole('button', { name: /^edit$/i }));
|
||||
await screen.findByText('Edit Printer');
|
||||
}
|
||||
|
||||
describe('EditPrinterModal pre-flight', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
|
||||
http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockStatus)),
|
||||
http.get('/api/v1/queue/', () => HttpResponse.json([])),
|
||||
);
|
||||
});
|
||||
|
||||
it('warns instead of saving when a connection check fails', async () => {
|
||||
server.use(
|
||||
http.post('/api/v1/printers/diagnostic', () =>
|
||||
HttpResponse.json({
|
||||
printer_id: null,
|
||||
ip_address: '192.168.1.100',
|
||||
overall: 'problems',
|
||||
checks: [{ id: 'developer_mode', status: 'fail', params: {} }],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await openEditModal();
|
||||
await userEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
expect(await screen.findByText(/Some connection checks failed/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /save anyway/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves directly when all connection checks pass', async () => {
|
||||
let updated = false;
|
||||
server.use(
|
||||
http.post('/api/v1/printers/diagnostic', () =>
|
||||
HttpResponse.json({
|
||||
printer_id: null,
|
||||
ip_address: '192.168.1.100',
|
||||
overall: 'ok',
|
||||
checks: [{ id: 'developer_mode', status: 'pass', params: {} }],
|
||||
}),
|
||||
),
|
||||
http.patch('/api/v1/printers/:id', async () => {
|
||||
updated = true;
|
||||
return HttpResponse.json({ ...mockPrinter, name: 'X1 Carbon' });
|
||||
}),
|
||||
);
|
||||
|
||||
await openEditModal();
|
||||
await userEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(updated).toBe(true));
|
||||
expect(screen.queryByText(/Some connection checks failed/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Tests for SystemHealthPanel — the shared log-health result renderer used by
|
||||
* the System page and the bug reporter.
|
||||
*
|
||||
* Covers the three states (clean / log unavailable / findings) and that a
|
||||
* finding renders its localized name, cause, fix, category badge, and a wiki
|
||||
* deep-link built from the signature's anchor.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from '../../i18n';
|
||||
import { SystemHealthPanel } from '../../components/SystemHealthPanel';
|
||||
import type { SystemHealthResult } from '../../api/client';
|
||||
|
||||
function renderPanel(result: SystemHealthResult) {
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<SystemHealthPanel result={result} />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const BASE: SystemHealthResult = {
|
||||
findings: [],
|
||||
scanned_entries: 1200,
|
||||
log_available: true,
|
||||
summary: { total: 0, layer8: 0, environment: 0, bug: 0 },
|
||||
};
|
||||
|
||||
describe('SystemHealthPanel', () => {
|
||||
it('shows a healthy message when there are no findings', () => {
|
||||
renderPanel(BASE);
|
||||
expect(screen.getByText(/No known issues found/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a notice when file logging is unavailable', () => {
|
||||
renderPanel({ ...BASE, log_available: false });
|
||||
expect(screen.getByText(/File logging is disabled/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a finding with localized name, cause, fix, badge, and wiki link', () => {
|
||||
renderPanel({
|
||||
...BASE,
|
||||
log_available: true,
|
||||
findings: [
|
||||
{
|
||||
signature_id: 'ftp-auth-rejected',
|
||||
severity: 'error',
|
||||
category: 'layer8',
|
||||
wiki_anchor: 'wrong-access-code',
|
||||
count: 4,
|
||||
first_seen: '2026-05-22 09:00:00,000',
|
||||
last_seen: '2026-05-22 10:00:00,000',
|
||||
sample: 'FTP connection permission error to [IP]',
|
||||
},
|
||||
],
|
||||
summary: { total: 1, layer8: 1, environment: 0, bug: 0 },
|
||||
});
|
||||
|
||||
expect(screen.getByText('Printer rejected the access code')).toBeInTheDocument();
|
||||
expect(screen.getByText(/refused the file-transfer login/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Re-copy the access code/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('You can fix this')).toBeInTheDocument();
|
||||
expect(screen.getByText('FTP connection permission error to [IP]')).toBeInTheDocument();
|
||||
|
||||
const link = screen.getByRole('link', { name: /How to fix/i });
|
||||
expect(link).toHaveAttribute(
|
||||
'href',
|
||||
'https://wiki.bambuddy.cool/reference/troubleshooting/#wrong-access-code',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user