From b5b647422aa015184d9b4e84f4db3e86431f8fb1 Mon Sep 17 00:00:00 2001 From: bonamin Date: Tue, 9 Jun 2026 11:14:37 +0300 Subject: [PATCH] feat: subnet printer discovery (Feature 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New GET /api/system/printers/scan SSE endpoint: parallel async TCP scan of a /24 subnet, streams found/progress/done events - New GET /api/system/printers/scan-hints: returns auto-detected local subnets from socket.getaddrinfo, excludes loopback/docker ranges - FindPrintersModal in Settings > Print: shows subnet+port inputs, live progress bar, found printers list; selecting a result pre-fills the new printer form (user still names it before saving) - "Εύρεση εκτυπωτών" button added next to "+ Νέος εκτυπωτής" Co-Authored-By: Claude Sonnet 4.6 --- local_backend/routers/system.py | 104 +++++++++- .../src/pages/Settings/tabs/PrintFontsTab.jsx | 192 +++++++++++++++++- 2 files changed, 290 insertions(+), 6 deletions(-) diff --git a/local_backend/routers/system.py b/local_backend/routers/system.py index c73d72e..49e14a4 100644 --- a/local_backend/routers/system.py +++ b/local_backend/routers/system.py @@ -1,5 +1,10 @@ +import asyncio +import ipaddress +import json +import socket import time -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from typing import List @@ -164,6 +169,103 @@ def delete_printer(printer_id: int, db: Session = Depends(get_db), user: User = return {"ok": True} +def _detect_local_subnets() -> list[str]: + """Return plausible /24 subnets based on local interface IPs, excluding loopback/docker.""" + subnets = [] + try: + hostname = socket.gethostname() + for info in socket.getaddrinfo(hostname, None): + ip_str = info[4][0] + try: + addr = ipaddress.IPv4Address(ip_str) + if addr.is_loopback or addr.is_link_local: + continue + # Exclude docker bridge ranges (172.16-31.x.x) + if addr.packed[0] == 172 and 16 <= addr.packed[1] <= 31: + continue + net = str(ipaddress.IPv4Network(f"{ip_str}/24", strict=False)) + if net not in subnets: + subnets.append(net) + except (ValueError, OSError): + continue + except OSError: + pass + return subnets + + +async def _check_port(ip: str, port: int, timeout: float) -> bool: + try: + _, writer = await asyncio.wait_for( + asyncio.open_connection(ip, port), timeout=timeout + ) + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + return True + except Exception: + return False + + +async def _scan_subnet_sse(subnet: str, port: int, token: str): + """Async generator: yields SSE lines as printers are found.""" + try: + network = ipaddress.IPv4Network(subnet, strict=False) + except ValueError as e: + yield f"data: {json.dumps({'error': str(e)})}\n\n" + return + + hosts = list(network.hosts()) + semaphore = asyncio.Semaphore(50) # max 50 concurrent connects + + async def check(ip_str: str): + async with semaphore: + return ip_str, await _check_port(ip_str, port, timeout=0.4) + + tasks = [asyncio.create_task(check(str(h))) for h in hosts] + yield f"data: {json.dumps({'type': 'start', 'total': len(tasks), 'subnet': subnet, 'port': port})}\n\n" + + done_count = 0 + for coro in asyncio.as_completed(tasks): + ip_str, reachable = await coro + done_count += 1 + if reachable: + yield f"data: {json.dumps({'type': 'found', 'ip': ip_str, 'port': port})}\n\n" + if done_count % 20 == 0 or done_count == len(tasks): + yield f"data: {json.dumps({'type': 'progress', 'done': done_count, 'total': len(tasks)})}\n\n" + + yield f"data: {json.dumps({'type': 'done'})}\n\n" + + +@router.get("/printers/scan") +async def scan_printers( + subnet: str = Query(default=""), + port: int = Query(default=9100), + user: User = Depends(require_manager), +): + """SSE endpoint: scan a subnet for devices responding on the given port.""" + # Fall back to auto-detected subnet if none provided + if not subnet: + detected = _detect_local_subnets() + subnet = detected[0] if detected else "192.168.1.0/24" + + return StreamingResponse( + _scan_subnet_sse(subnet, port, ""), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + + +@router.get("/printers/scan-hints") +def scan_hints(user: User = Depends(require_manager)): + """Return auto-detected local subnets to suggest in the scan UI.""" + return {"subnets": _detect_local_subnets()} + + @router.get("/stats") def system_stats(db: Session = Depends(get_db), user: User = Depends(get_current_user)): return { diff --git a/manager_dashboard/src/pages/Settings/tabs/PrintFontsTab.jsx b/manager_dashboard/src/pages/Settings/tabs/PrintFontsTab.jsx index a87ca6a..63b5423 100644 --- a/manager_dashboard/src/pages/Settings/tabs/PrintFontsTab.jsx +++ b/manager_dashboard/src/pages/Settings/tabs/PrintFontsTab.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useRef, useCallback } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import toast from 'react-hot-toast' import client from '../../../api/client' @@ -774,10 +774,174 @@ function PrinterRow({ printer, onEdit, onDelete, onTest, onToggle, testPending } ) } +function FindPrintersModal({ onClose, onSelect }) { + const [subnet, setSubnet] = useState('') + const [port, setPort] = useState(9100) + const [scanning, setScanning] = useState(false) + const [found, setFound] = useState([]) + const [progress, setProgress] = useState(null) // { done, total } + const [error, setError] = useState(null) + const esRef = useRef(null) + + // Fetch auto-detected subnets on mount + useEffect(() => { + client.get('/api/system/printers/scan-hints') + .then(r => { if (r.data.subnets?.length) setSubnet(r.data.subnets[0]) }) + .catch(() => {}) + }, []) + + const stopScan = useCallback(() => { + if (esRef.current) { esRef.current.close(); esRef.current = null } + setScanning(false) + }, []) + + useEffect(() => () => stopScan(), [stopScan]) + + function startScan() { + stopScan() + setFound([]) + setProgress(null) + setError(null) + setScanning(true) + + const token = localStorage.getItem('access_token') || '' + const params = new URLSearchParams({ subnet, port }) + // Use fetch with ReadableStream to consume SSE (EventSource doesn't support auth headers) + fetch(`/api/system/printers/scan?${params}`, { + headers: { Authorization: `Bearer ${token}` }, + }).then(async res => { + if (!res.ok) { setError('Σφάλμα σύνδεσης'); setScanning(false); return } + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buf = '' + while (true) { + const { done, value } = await reader.read() + if (done) break + buf += decoder.decode(value, { stream: true }) + const lines = buf.split('\n') + buf = lines.pop() + for (const line of lines) { + if (!line.startsWith('data: ')) continue + try { + const msg = JSON.parse(line.slice(6)) + if (msg.type === 'found') setFound(prev => [...prev, { ip: msg.ip, port: msg.port }]) + else if (msg.type === 'progress') setProgress({ done: msg.done, total: msg.total }) + else if (msg.type === 'done') setScanning(false) + else if (msg.error) { setError(msg.error); setScanning(false) } + } catch {} + } + } + setScanning(false) + }).catch(e => { setError(String(e)); setScanning(false) }) + } + + const pct = progress ? Math.round((progress.done / progress.total) * 100) : 0 + + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+
+

Εύρεση εκτυπωτών

+

+ Σκανάρει το δίκτυο για συσκευές που ακούνε στη θύρα εκτύπωσης. +

+
+ +
+
+ + setSubnet(e.target.value)} + placeholder="192.168.1.0/24" + style={inputStyle} + disabled={scanning} + /> +
+
+ + setPort(parseInt(e.target.value) || 9100)} + type="number" + style={inputStyle} + disabled={scanning} + /> +
+ +
+ + {scanning && ( +
+
+
+
+

+ {progress ? `${progress.done} / ${progress.total} IPs ελέγχθηκαν` : 'Εκκίνηση…'} +

+
+ )} + + {error && ( +

{error}

+ )} + +
+ {found.length === 0 && !scanning && ( +

+ {progress ? 'Κανένας εκτυπωτής δεν βρέθηκε.' : 'Πατήστε «Σκανάρισμα» για να ξεκινήσει η αναζήτηση.'} +

+ )} + {found.map(item => ( +
+
+ {item.ip} + :{item.port} +
+ +
+ ))} +
+ +
+ +
+
+
+ ) +} + function PrintersSection() { const qc = useQueryClient() const [showNew, setShowNew] = useState(false) const [editingId, setEditingId] = useState(null) + const [showFinder, setShowFinder] = useState(false) + const [newFormInitial, setNewFormInitial] = useState(null) const { data: printers = [], isLoading } = useQuery({ queryKey: ['printers-all'], @@ -810,23 +974,41 @@ function PrintersSection() { updateMut.mutate({ id: printer.id, is_active: !printer.is_active }) } + function handleFinderSelect(prefill) { + setNewFormInitial({ ...EMPTY_FORM, ...prefill }) + setShowNew(true) + setEditingId(null) + } + return (
+ {showFinder && ( + setShowFinder(false)} + onSelect={handleFinderSelect} + /> + )}

Εκτυπωτές

Διαχείριση εκτυπωτών του συστήματος

- +
+ + +
{showNew && (
createMut.mutate(form)} - onCancel={() => setShowNew(false)} + onCancel={() => { setShowNew(false); setNewFormInitial(null) }} isPending={createMut.isPending} />