Просмотр исходного кода

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.
maziggy 3 месяцев назад
Родитель
Сommit
eb418a5b37

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
CHANGELOG.md


+ 21 - 9
frontend/src/__tests__/components/AddPrinterDiscovery.test.tsx

@@ -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();
   });
 });

+ 161 - 0
frontend/src/__tests__/pages/PrintersPageDiscoveryCustomSubnet.test.tsx

@@ -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);
+  });
+});

+ 3 - 0
frontend/src/i18n/locales/de.ts

@@ -497,6 +497,9 @@ export default {
       serialRequired: 'Seriennummer erforderlich',
       unknown: 'Unbekannt',
       failedToStart: 'Erkennung konnte nicht gestartet werden',
+      customSubnetOption: 'Eigenes Subnetz...',
+      customSubnetLabel: 'Eigenes Subnetz (CIDR)',
+      customSubnetNote: 'Wähle ein eigenes Subnetz, wenn dein Drucker in einem anderen Netzwerk als dieser Server steht. Die Ports FTP (990) und MQTT (8883) müssen über die Routinggrenze erreichbar sein.',
     },
     // AMS Drying
     drying: {

+ 3 - 0
frontend/src/i18n/locales/en.ts

@@ -497,6 +497,9 @@ export default {
       serialRequired: 'Serial required',
       unknown: 'Unknown',
       failedToStart: 'Failed to start discovery',
+      customSubnetOption: 'Custom subnet...',
+      customSubnetLabel: 'Custom subnet (CIDR)',
+      customSubnetNote: 'Use a custom subnet if your printer is on a different network than this server. The FTP (990) and MQTT (8883) ports must be reachable across the routing boundary.',
     },
     // AMS Drying
     drying: {

+ 3 - 0
frontend/src/i18n/locales/es.ts

@@ -497,6 +497,9 @@ export default {
       serialRequired: 'Número de serie obligatorio',
       unknown: 'Desconocido',
       failedToStart: 'Error al iniciar la detección',
+      customSubnetOption: 'Subred personalizada...',
+      customSubnetLabel: 'Subred personalizada (CIDR)',
+      customSubnetNote: 'Usa una subred personalizada si tu impresora está en una red distinta a la de este servidor. Los puertos FTP (990) y MQTT (8883) deben ser accesibles a través del límite de enrutamiento.',
     },
     // AMS Drying
     drying: {

+ 3 - 0
frontend/src/i18n/locales/fr.ts

@@ -497,6 +497,9 @@ export default {
       serialRequired: 'Série requis',
       unknown: 'Inconnu',
       failedToStart: 'Échec du démarrage de la découverte',
+      customSubnetOption: 'Sous-réseau personnalisé...',
+      customSubnetLabel: 'Sous-réseau personnalisé (CIDR)',
+      customSubnetNote: 'Utilisez un sous-réseau personnalisé si votre imprimante se trouve sur un réseau différent de celui de ce serveur. Les ports FTP (990) et MQTT (8883) doivent être accessibles à travers la limite de routage.',
     },
     // AMS Drying
     drying: {

+ 3 - 0
frontend/src/i18n/locales/it.ts

@@ -497,6 +497,9 @@ export default {
       serialRequired: 'Seriale richiesto',
       unknown: 'Sconosciuto',
       failedToStart: 'Avvio ricerca non riuscito',
+      customSubnetOption: 'Sottorete personalizzata...',
+      customSubnetLabel: 'Sottorete personalizzata (CIDR)',
+      customSubnetNote: 'Usa una sottorete personalizzata se la tua stampante è su una rete diversa da questo server. Le porte FTP (990) e MQTT (8883) devono essere raggiungibili attraverso il confine di routing.',
     },
     // AMS Drying
     drying: {

+ 3 - 0
frontend/src/i18n/locales/ja.ts

@@ -496,6 +496,9 @@ export default {
       serialRequired: 'シリアル番号が必要です',
       unknown: '不明',
       failedToStart: '印刷の開始に失敗しました',
+      customSubnetOption: 'カスタムサブネット...',
+      customSubnetLabel: 'カスタムサブネット (CIDR)',
+      customSubnetNote: 'プリンターがこのサーバーと別のネットワークにある場合は、カスタムサブネットを使用してください。FTP (990) と MQTT (8883) のポートが、ルーティング境界を越えて到達可能である必要があります。',
     },
     // AMS Drying
     drying: {

+ 4 - 1
frontend/src/i18n/locales/ko.ts

@@ -460,7 +460,10 @@ export default {
       scanningNetwork: '네트워크 스캔 중...',
       serialRequired: '일련번호 필수',
       unknown: '알 수 없음',
-      failedToStart: '검색 시작 실패'
+      failedToStart: '검색 시작 실패',
+      customSubnetOption: '사용자 지정 서브넷...',
+      customSubnetLabel: '사용자 지정 서브넷 (CIDR)',
+      customSubnetNote: '프린터가 이 서버와 다른 네트워크에 있는 경우 사용자 지정 서브넷을 사용하세요. FTP (990) 및 MQTT (8883) 포트가 라우팅 경계를 통해 접근 가능해야 합니다.',
     },
     drying: {
       start: '건조 시작',

+ 3 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -497,6 +497,9 @@ export default {
       serialRequired: 'Serial necessário',
       unknown: 'Desconhecido',
       failedToStart: 'Falha ao iniciar a descoberta',
+      customSubnetOption: 'Sub-rede personalizada...',
+      customSubnetLabel: 'Sub-rede personalizada (CIDR)',
+      customSubnetNote: 'Use uma sub-rede personalizada se sua impressora estiver em uma rede diferente deste servidor. As portas FTP (990) e MQTT (8883) devem estar acessíveis através do limite de roteamento.',
     },
     // AMS Drying
     drying: {

+ 3 - 0
frontend/src/i18n/locales/tr.ts

@@ -497,6 +497,9 @@ export default {
       serialRequired: 'Seri numarası gerekli',
       unknown: 'Bilinmiyor',
       failedToStart: 'Keşif başlatılamadı',
+      customSubnetOption: 'Özel alt ağ...',
+      customSubnetLabel: 'Özel alt ağ (CIDR)',
+      customSubnetNote: 'Yazıcınız bu sunucudan farklı bir ağda ise özel bir alt ağ kullanın. FTP (990) ve MQTT (8883) bağlantı noktaları yönlendirme sınırı boyunca erişilebilir olmalıdır.',
     },
     // AMS Kurutma
     drying: {

+ 3 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -497,6 +497,9 @@ export default {
       serialRequired: '需要序列号',
       unknown: '未知',
       failedToStart: '启动发现失败',
+      customSubnetOption: '自定义子网...',
+      customSubnetLabel: '自定义子网 (CIDR)',
+      customSubnetNote: '如果您的打印机与此服务器位于不同的网络中,请使用自定义子网。FTP (990) 和 MQTT (8883) 端口必须能够跨路由边界访问。',
     },
     // AMS Drying
     drying: {

+ 3 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -497,6 +497,9 @@ export default {
       serialRequired: '需要序列號',
       unknown: '未知',
       failedToStart: '啟動發現失敗',
+      customSubnetOption: '自訂子網路...',
+      customSubnetLabel: '自訂子網路 (CIDR)',
+      customSubnetNote: '如果您的印表機與此伺服器位於不同的網路中,請使用自訂子網路。FTP (990) 和 MQTT (8883) 連接埠必須能夠跨越路由邊界存取。',
     },
     // AMS Drying
     drying: {

+ 91 - 40
frontend/src/pages/PrintersPage.tsx

@@ -5564,7 +5564,7 @@ function PrinterCard({
   );
 }
 
-function AddPrinterModal({
+export function AddPrinterModal({
   onClose,
   onAdd,
   existingSerials,
@@ -5592,6 +5592,12 @@ function AddPrinterModal({
   const [isDocker, setIsDocker] = useState(false);
   const [detectedSubnets, setDetectedSubnets] = useState<string[]>([]);
   const [subnet, setSubnet] = useState('');
+  // Custom subnet — `__custom__` sentinel in the dropdown reveals a CIDR
+  // text input so users can scan a subnet Bambuddy isn't directly on
+  // (printer behind a router on a different L3 segment — SSDP multicast
+  // won't cross that boundary, only an active unicast scan will). #1564
+  const [customSubnet, setCustomSubnet] = useState('');
+  const [useCustomSubnet, setUseCustomSubnet] = useState(false);
   const [scanProgress, setScanProgress] = useState({ scanned: 0, total: 0 });
   const [showDiagnostic, setShowDiagnostic] = useState(false);
 
@@ -5602,7 +5608,9 @@ function AddPrinterModal({
   const [checkingSave, setCheckingSave] = useState(false);
   const [saveWarning, setSaveWarning] = useState<PrinterDiagnosticResult | null>(null);
 
-  // Fetch discovery info on mount
+  // Fetch discovery info on mount + restore the last custom CIDR the user
+  // typed (kept in localStorage so they don't retype `10.1.1.0/24` every
+  // time they open this modal).
   useEffect(() => {
     discoveryApi.getInfo().then(info => {
       setIsDocker(info.is_docker);
@@ -5613,6 +5621,12 @@ function AddPrinterModal({
     }).catch(() => {
       // Ignore errors, assume not Docker
     });
+    try {
+      const saved = localStorage.getItem('bambuddy.discovery.customSubnet');
+      if (saved) setCustomSubnet(saved);
+    } catch {
+      // localStorage unavailable (private mode, quota); recall is opportunistic
+    }
   }, []);
 
   // Filter out already-added printers
@@ -5646,10 +5660,23 @@ function AddPrinterModal({
     setHasScanned(false);
     setScanProgress({ scanned: 0, total: 0 });
 
+    // Native installs fall back to subnet scanning when the user picks
+    // "Custom" — SSDP can't reach a printer on a different L3 segment
+    // (#1564). Docker mode always uses subnet scan (multicast unavailable).
+    const scanCidr = useCustomSubnet ? customSubnet.trim() : subnet;
+    const wantsSubnetScan = isDocker || useCustomSubnet;
+
+    if (wantsSubnetScan && useCustomSubnet) {
+      try {
+        localStorage.setItem('bambuddy.discovery.customSubnet', scanCidr);
+      } catch {
+        // localStorage write best-effort; user just retypes next time
+      }
+    }
+
     try {
-      if (isDocker) {
-        // Use subnet scanning for Docker
-        await discoveryApi.startSubnetScan(subnet);
+      if (wantsSubnetScan) {
+        await discoveryApi.startSubnetScan(scanCidr);
 
         // Poll for scan status and results
         const pollInterval = setInterval(async () => {
@@ -5755,37 +5782,61 @@ function AddPrinterModal({
 
           {/* Discovery Section */}
           <div className="mb-4 pb-4 border-b border-bambu-dark-tertiary">
-            {isDocker && (
-              <div className="mb-3">
-                <label className="block text-sm text-bambu-gray mb-1">
-                  {t('printers.discovery.subnetToScan')}
-                </label>
-                {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>
-              </div>
-            )}
+            {/* Subnet picker — always visible. The dropdown lists detected
+                interface subnets and a "Custom..." sentinel that reveals
+                a CIDR text input for printers on a different L3 segment
+                (router, VLAN, etc.). #1564 */}
+            <div className="mb-3">
+              <label className="block text-sm text-bambu-gray mb-1">
+                {t('printers.discovery.subnetToScan')}
+              </label>
+              {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={useCustomSubnet ? '__custom__' : subnet}
+                  onChange={(e) => {
+                    if (e.target.value === '__custom__') {
+                      setUseCustomSubnet(true);
+                    } else {
+                      setUseCustomSubnet(false);
+                      setSubnet(e.target.value);
+                    }
+                  }}
+                  disabled={discovering}
+                >
+                  {detectedSubnets.map(s => (
+                    <option key={s} value={s}>{s}</option>
+                  ))}
+                  <option value="__custom__">{t('printers.discovery.customSubnetOption')}</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}
+                />
+              )}
+              {useCustomSubnet && (
+                <input
+                  type="text"
+                  aria-label={t('printers.discovery.customSubnetLabel')}
+                  className="mt-2 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={customSubnet}
+                  onChange={(e) => setCustomSubnet(e.target.value)}
+                  placeholder="10.1.1.0/24"
+                  disabled={discovering}
+                />
+              )}
+              <p className="mt-1 text-xs text-bambu-gray">
+                {isDocker
+                  ? t('printers.discovery.dockerNote')
+                  : t('printers.discovery.customSubnetNote')}
+              </p>
+            </div>
+
 
             <Button
               type="button"
@@ -5797,14 +5848,14 @@ function AddPrinterModal({
               {discovering ? (
                 <>
                   <Loader2 className="w-4 h-4 animate-spin" />
-                  {isDocker && scanProgress.total > 0
+                  {(isDocker || useCustomSubnet) && scanProgress.total > 0
                     ? t('printers.discovery.scanProgress', { scanned: scanProgress.scanned, total: scanProgress.total })
                     : t('printers.discovery.scanning')}
                 </>
               ) : (
                 <>
                   <Search className="w-4 h-4" />
-                  {isDocker ? t('printers.discovery.scanSubnet') : t('printers.discovery.discoverNetwork')}
+                  {(isDocker || useCustomSubnet) ? t('printers.discovery.scanSubnet') : t('printers.discovery.discoverNetwork')}
                 </>
               )}
             </Button>
@@ -5840,13 +5891,13 @@ function AddPrinterModal({
 
             {discovering && (
               <p className="mt-2 text-sm text-bambu-gray text-center">
-                {isDocker ? t('printers.discovery.scanningSubnet') : t('printers.discovery.scanningNetwork')}
+                {(isDocker || useCustomSubnet) ? t('printers.discovery.scanningSubnet') : t('printers.discovery.scanningNetwork')}
               </p>
             )}
 
             {hasScanned && !discovering && discovered.length === 0 && (
               <p className="mt-2 text-sm text-bambu-gray text-center">
-                {isDocker ? t('printers.discovery.noPrintersFoundSubnet') : t('printers.discovery.noPrintersFoundNetwork')}
+                {(isDocker || useCustomSubnet) ? t('printers.discovery.noPrintersFoundSubnet') : t('printers.discovery.noPrintersFoundNetwork')}
               </p>
             )}
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-Dai0-twV.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CyGvoJrx.js"></script>
+    <script type="module" crossorigin src="/assets/index-Dai0-twV.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-Df3XYvpK.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов