feat(discovery): scan a custom subnet for cross-router printers (#1564)

SSDP multicast (239.255.255.250:2021) doesn't traverse routers, so a
  printer behind a router on a different L3 segment was invisible to
  "Discover Printers on Network". Docker mode had a CIDR text input but
  only as a fallback when zero interface subnets were detected; native
  mode had no subnet field at all.

  AddPrinterModal now surfaces an always-visible subnet picker. Detected
  interface subnets stay as dropdown options, plus a "Custom subnet..."
  sentinel reveals a CIDR text input. When custom is picked, discovery
  routes through POST /discovery/scan with the typed CIDR instead of
  SSDP (which would no-op against a foreign subnet anyway). Last custom
  CIDR persisted to localStorage so VLAN users don't retype every time.

  Scan button label and scanning / no-printers-found strings all key off
  (isDocker || useCustomSubnet) so wording stays "Scan Subnet..." /
  "Scanning subnet..." whether the user is on Docker or just picked
  Custom.

  Backend unchanged: SubnetScanner.scan_subnet() already accepts any
  CIDR, already caps at /22 (1024 hosts), POST /discovery/scan already
  takes user-supplied input.
This commit is contained in:
maziggy
2026-06-05 11:52:07 +02:00
parent 342ad31489
commit eb418a5b37
17 changed files with 397 additions and 137 deletions
@@ -109,15 +109,17 @@ describe('AddPrinterModal Discovery', () => {
await userEvent.click(addButton);
await waitFor(() => {
// Should show a select element (dropdown) with both subnets
// Should show a select element (dropdown) with both subnets and
// the trailing "Custom subnet..." sentinel that lets a user enter
// a CIDR for a printer on a different L3 segment (#1564).
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).toHaveLength(3);
expect(options[0].textContent).toBe('192.168.1.0/24');
expect(options[1].textContent).toBe('10.0.0.0/24');
expect(options[2].textContent).toMatch(/custom subnet/i);
});
});
@@ -150,7 +152,11 @@ describe('AddPrinterModal Discovery', () => {
});
});
it('does not show subnet field in non-Docker mode', async () => {
it('shows the subnet picker in non-Docker mode too, with a Custom option (#1564)', async () => {
// Pre-#1564 the subnet picker was gated on isDocker and a native
// install only saw the "Discover Printers" button. SSDP doesn't
// cross routers, so users with a printer on a different L3 segment
// had no path to scan it. The picker is now always visible.
server.use(
http.get('/api/v1/discovery/info', () => {
return HttpResponse.json({
@@ -171,13 +177,19 @@ describe('AddPrinterModal Discovery', () => {
const addButton = screen.getByText(/add printer/i);
await userEvent.click(addButton);
// Detected subnet is the default value; "Custom subnet..." sits
// alongside it so the user can pick a foreign CIDR.
await waitFor(() => {
// Should show the discover button but NOT the subnet field
expect(screen.getByText(/discover printers/i)).toBeInTheDocument();
const selectElement = screen.getByDisplayValue('192.168.1.0/24');
expect(selectElement.tagName).toBe('SELECT');
const options = selectElement.querySelectorAll('option');
expect(options).toHaveLength(2);
expect(options[0].textContent).toBe('192.168.1.0/24');
expect(options[1].textContent).toMatch(/custom subnet/i);
});
// 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();
// Default selection leaves the SSDP path in place — the button
// still reads "Discover Printers on Network", not "Scan Subnet".
expect(screen.getByText(/discover printers/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,161 @@
/**
* Discovery — custom-subnet picker (#1564).
*
* Reporters with a printer behind a router on a different L3 segment
* (e.g. Bambuddy on 192.168.1.0/24, printer on 10.1.1.0/24) couldn't
* scan that subnet because:
* - SSDP multicast doesn't cross routers
* - The Docker-mode subnet input was the only path that accepted a
* CIDR, and it was hidden in native mode
*
* The fix surfaces the subnet picker in native mode too and adds a
* "Custom..." option that reveals a CIDR text input. Picking it routes
* through startSubnetScan(cidr) instead of startDiscovery().
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { AddPrinterModal } from '../../pages/PrintersPage';
import { http, HttpResponse } from 'msw';
import { server } from '../mocks/server';
describe('AddPrinterModal — custom subnet (#1564)', () => {
let scanCalls: { subnet: string; timeout: number }[];
let ssdpStarted: boolean;
beforeEach(() => {
scanCalls = [];
ssdpStarted = false;
// localStorage is a vi.fn() spy in this test env (see setup.ts), so
// clear call history rather than removeItem-ing the absent value.
vi.mocked(localStorage.setItem).mockClear();
vi.mocked(localStorage.getItem).mockClear();
server.use(
// Native install with one detected subnet.
http.get('/api/v1/discovery/info', () =>
HttpResponse.json({
is_docker: false,
ssdp_running: false,
scan_running: false,
subnets: ['192.168.1.0/24'],
}),
),
http.post('/api/v1/discovery/start', () => {
ssdpStarted = true;
return HttpResponse.json({ running: true });
}),
http.post('/api/v1/discovery/stop', () =>
HttpResponse.json({ running: false }),
),
http.post('/api/v1/discovery/scan', async ({ request }) => {
const body = (await request.json()) as { subnet: string; timeout: number };
scanCalls.push(body);
return HttpResponse.json({ running: true, scanned: 0, total: 254 });
}),
http.post('/api/v1/discovery/scan/stop', () =>
HttpResponse.json({ running: false, scanned: 0, total: 0 }),
),
http.get('/api/v1/discovery/scan/status', () =>
HttpResponse.json({ running: false, scanned: 254, total: 254 }),
),
http.get('/api/v1/discovery/printers', () => HttpResponse.json([])),
);
});
it('renders the subnet picker even on a native (non-Docker) install', async () => {
render(
<AddPrinterModal
onClose={() => {}}
onAdd={() => {}}
existingSerials={[]}
/>,
);
// The picker is now ungated; the detected subnet shows in the
// dropdown alongside the "Custom..." sentinel.
await waitFor(() => {
expect(
screen.getByRole('option', { name: '192.168.1.0/24' }),
).toBeInTheDocument();
});
expect(
screen.getByRole('option', { name: /custom subnet/i }),
).toBeInTheDocument();
});
it('routes a custom CIDR through startSubnetScan, not SSDP', async () => {
const user = userEvent.setup();
render(
<AddPrinterModal
onClose={() => {}}
onAdd={() => {}}
existingSerials={[]}
/>,
);
// Wait for discoveryApi.getInfo() to populate the dropdown.
await waitFor(() => {
expect(
screen.getByRole('option', { name: '192.168.1.0/24' }),
).toBeInTheDocument();
});
// Pick the "Custom..." sentinel. Scope by display value because the
// modal also has a model <select>.
const select = screen.getByDisplayValue('192.168.1.0/24') as HTMLSelectElement;
await user.selectOptions(select, '__custom__');
// The CIDR text input appears (aria-labelled "Custom subnet (CIDR)").
const cidrInput = await screen.findByLabelText(/custom subnet \(cidr\)/i);
await user.clear(cidrInput);
await user.type(cidrInput, '10.1.1.0/24');
// Click the scan button — labelled "Scan Subnet..." now, not
// "Discover Printers on Network", because the user picked custom.
const scanButton = screen.getByRole('button', { name: /scan subnet/i });
await user.click(scanButton);
await waitFor(() => {
expect(scanCalls.length).toBe(1);
});
expect(scanCalls[0].subnet).toBe('10.1.1.0/24');
// SSDP must not start when a custom CIDR is in play — multicast
// can't reach the foreign subnet anyway.
expect(ssdpStarted).toBe(false);
// And we persist the choice so the user doesn't retype next time.
expect(localStorage.setItem).toHaveBeenCalledWith(
'bambuddy.discovery.customSubnet',
'10.1.1.0/24',
);
});
it('preserves the default SSDP path when the user keeps a detected subnet', async () => {
const user = userEvent.setup();
render(
<AddPrinterModal
onClose={() => {}}
onAdd={() => {}}
existingSerials={[]}
/>,
);
await waitFor(() => {
expect(
screen.getByRole('option', { name: '192.168.1.0/24' }),
).toBeInTheDocument();
});
// Don't change the selection — default is the detected subnet.
const scanButton = screen.getByRole('button', {
name: /discover printers on network/i,
});
await user.click(scanButton);
await waitFor(() => {
expect(ssdpStarted).toBe(true);
});
expect(scanCalls.length).toBe(0);
});
});