Auto-detect subnet for printer discovery

The Add Printer dialog previously required users to manually enter their
network subnet for scanning (defaulting to 192.168.1.0/24). Now the
backend detects available network interfaces and returns their subnets
via the /discovery/info endpoint. The frontend auto-selects the first
detected subnet and shows a dropdown when multiple subnets are available,
falling back to a text input if none are detected.
This commit is contained in:
maziggy
2026-02-08 12:17:20 +01:00
parent 1e15595f45
commit d73da5e0ef
8 changed files with 255 additions and 10 deletions
@@ -0,0 +1,182 @@
/**
* Tests for AddPrinterModal discovery subnet auto-detection.
*/
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 mockPrinters = [
{
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 mockPrinterStatus = {
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,
};
describe('AddPrinterModal Discovery', () => {
beforeEach(() => {
server.use(
http.get('/api/v1/printers/', () => {
return HttpResponse.json(mockPrinters);
}),
http.get('/api/v1/printers/:id/status', () => {
return HttpResponse.json(mockPrinterStatus);
}),
http.get('/api/v1/queue/', () => {
return HttpResponse.json([]);
})
);
});
it('auto-populates subnet from discovery info in Docker mode', async () => {
server.use(
http.get('/api/v1/discovery/info', () => {
return HttpResponse.json({
is_docker: true,
ssdp_running: false,
scan_running: false,
subnets: ['10.0.0.0/24'],
});
})
);
render(<PrintersPage />);
// Wait for printer page to load
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
});
// Click the Add Printer button
const addButton = screen.getByText(/add printer/i);
await userEvent.click(addButton);
// Wait for the modal and discovery info to load
await waitFor(() => {
// Should show subnet dropdown with detected subnet
const subnetSelect = screen.getByDisplayValue('10.0.0.0/24');
expect(subnetSelect).toBeInTheDocument();
});
});
it('shows dropdown when multiple subnets detected in Docker mode', async () => {
server.use(
http.get('/api/v1/discovery/info', () => {
return HttpResponse.json({
is_docker: true,
ssdp_running: false,
scan_running: false,
subnets: ['192.168.1.0/24', '10.0.0.0/24'],
});
})
);
render(<PrintersPage />);
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
});
const addButton = screen.getByText(/add printer/i);
await userEvent.click(addButton);
await waitFor(() => {
// Should show a select element (dropdown) with both subnets
const selectElement = screen.getByDisplayValue('192.168.1.0/24');
expect(selectElement.tagName).toBe('SELECT');
// Both options should be available
const options = selectElement.querySelectorAll('option');
expect(options).toHaveLength(2);
expect(options[0].textContent).toBe('192.168.1.0/24');
expect(options[1].textContent).toBe('10.0.0.0/24');
});
});
it('shows text input when no subnets detected in Docker mode', async () => {
server.use(
http.get('/api/v1/discovery/info', () => {
return HttpResponse.json({
is_docker: true,
ssdp_running: false,
scan_running: false,
subnets: [],
});
})
);
render(<PrintersPage />);
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
});
const addButton = screen.getByText(/add printer/i);
await userEvent.click(addButton);
await waitFor(() => {
// Should show a text input with placeholder
const textInput = screen.getByPlaceholderText('192.168.1.0/24');
expect(textInput).toBeInTheDocument();
expect(textInput.tagName).toBe('INPUT');
});
});
it('does not show subnet field in non-Docker mode', async () => {
server.use(
http.get('/api/v1/discovery/info', () => {
return HttpResponse.json({
is_docker: false,
ssdp_running: false,
scan_running: false,
subnets: ['192.168.1.0/24'],
});
})
);
render(<PrintersPage />);
await waitFor(() => {
expect(screen.getByText('X1 Carbon')).toBeInTheDocument();
});
const addButton = screen.getByText(/add printer/i);
await userEvent.click(addButton);
await waitFor(() => {
// Should show the discover button but NOT the subnet field
expect(screen.getByText(/discover printers/i)).toBeInTheDocument();
});
// Subnet field should not exist
expect(screen.queryByPlaceholderText('192.168.1.0/24')).not.toBeInTheDocument();
expect(screen.queryByDisplayValue('192.168.1.0/24')).not.toBeInTheDocument();
});
});
+13
View File
@@ -364,6 +364,19 @@ export const handlers = [
return new HttpResponse(null, { status: 204 });
}),
// ========================================================================
// Discovery
// ========================================================================
http.get('/api/v1/discovery/info', () => {
return HttpResponse.json({
is_docker: false,
ssdp_running: false,
scan_running: false,
subnets: ['192.168.1.0/24'],
});
}),
// ========================================================================
// Version / Health
// ========================================================================
+1
View File
@@ -3934,6 +3934,7 @@ export interface DiscoveryInfo {
is_docker: boolean;
ssdp_running: boolean;
scan_running: boolean;
subnets: string[];
}
export interface SubnetScanStatus {
+27 -9
View File
@@ -3385,13 +3385,18 @@ function AddPrinterModal({
const [discoveryError, setDiscoveryError] = useState('');
const [hasScanned, setHasScanned] = useState(false);
const [isDocker, setIsDocker] = useState(false);
const [subnet, setSubnet] = useState('192.168.1.0/24');
const [detectedSubnets, setDetectedSubnets] = useState<string[]>([]);
const [subnet, setSubnet] = useState('');
const [scanProgress, setScanProgress] = useState({ scanned: 0, total: 0 });
// Fetch discovery info on mount
useEffect(() => {
discoveryApi.getInfo().then(info => {
setIsDocker(info.is_docker);
if (info.subnets.length > 0) {
setDetectedSubnets(info.subnets);
setSubnet(info.subnets[0]);
}
}).catch(() => {
// Ignore errors, assume not Docker
});
@@ -3556,14 +3561,27 @@ function AddPrinterModal({
<label className="block text-sm text-bambu-gray mb-1">
{t('printers.discovery.subnetToScan')}
</label>
<input
type="text"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
value={subnet}
onChange={(e) => setSubnet(e.target.value)}
placeholder="192.168.1.0/24"
disabled={discovering}
/>
{detectedSubnets.length > 0 ? (
<select
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
value={subnet}
onChange={(e) => setSubnet(e.target.value)}
disabled={discovering}
>
{detectedSubnets.map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
) : (
<input
type="text"
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none text-sm"
value={subnet}
onChange={(e) => setSubnet(e.target.value)}
placeholder="192.168.1.0/24"
disabled={discovering}
/>
)}
<p className="mt-1 text-xs text-bambu-gray">
{t('printers.discovery.dockerNote')}
</p>