feat: subnet printer discovery (Feature 3)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 11:14:37 +03:00
parent f3d03bf85f
commit b5b647422a
2 changed files with 290 additions and 6 deletions

View File

@@ -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 {

View File

@@ -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 (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20,
}} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div style={{
background: 'white', borderRadius: 14, width: '100%', maxWidth: 520,
boxShadow: '0 20px 60px rgba(0,0,0,0.2)', overflow: 'hidden',
}}>
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid #f0f1f3' }}>
<h2 style={{ fontSize: 17, fontWeight: 700, color: '#111315', margin: 0 }}>Εύρεση εκτυπωτών</h2>
<p style={{ fontSize: 12, color: '#9ca3af', margin: '4px 0 0' }}>
Σκανάρει το δίκτυο για συσκευές που ακούνε στη θύρα εκτύπωσης.
</p>
</div>
<div style={{ padding: '16px 24px', display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 220px', display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>
ΥΠΟΔΙΚΤΥΟ <span style={{ fontWeight: 400, color: '#b8bdc4' }}>(π.χ. 192.168.1.0/24)</span>
</label>
<input
value={subnet}
onChange={e => setSubnet(e.target.value)}
placeholder="192.168.1.0/24"
style={inputStyle}
disabled={scanning}
/>
</div>
<div style={{ flex: '0 0 90px', display: 'flex', flexDirection: 'column', gap: 4 }}>
<label style={{ fontSize: 11, fontWeight: 600, color: '#6b7280' }}>PORT</label>
<input
value={port}
onChange={e => setPort(parseInt(e.target.value) || 9100)}
type="number"
style={inputStyle}
disabled={scanning}
/>
</div>
<button
onClick={scanning ? stopScan : startScan}
style={{
...btnPrimary,
background: scanning ? '#dc2626' : '#3758c9',
flexShrink: 0,
}}
>
{scanning ? 'Διακοπή' : 'Σκανάρισμα'}
</button>
</div>
{scanning && (
<div style={{ padding: '0 24px 12px' }}>
<div style={{ height: 6, background: '#f3f4f6', borderRadius: 99, overflow: 'hidden' }}>
<div style={{ height: '100%', background: '#3758c9', width: `${pct}%`, transition: 'width 0.3s' }} />
</div>
<p style={{ fontSize: 11, color: '#9ca3af', marginTop: 4 }}>
{progress ? `${progress.done} / ${progress.total} IPs ελέγχθηκαν` : 'Εκκίνηση…'}
</p>
</div>
)}
{error && (
<p style={{ padding: '0 24px 12px', fontSize: 12, color: '#dc2626' }}>{error}</p>
)}
<div style={{ minHeight: 120, maxHeight: 280, overflowY: 'auto', borderTop: '1px solid #f0f1f3' }}>
{found.length === 0 && !scanning && (
<p style={{ padding: '24px', textAlign: 'center', fontSize: 13, color: '#b8bdc4' }}>
{progress ? 'Κανένας εκτυπωτής δεν βρέθηκε.' : 'Πατήστε «Σκανάρισμα» για να ξεκινήσει η αναζήτηση.'}
</p>
)}
{found.map(item => (
<div key={item.ip} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 24px', borderBottom: '1px solid #f9fafb',
}}>
<div>
<span style={{ fontSize: 14, fontWeight: 600, color: '#111315' }}>{item.ip}</span>
<span style={{ fontSize: 12, color: '#9ca3af', marginLeft: 8 }}>:{item.port}</span>
</div>
<button
onClick={() => { onSelect({ ip_address: item.ip, port: item.port }); onClose() }}
style={{ ...btnPrimary, height: 30, padding: '0 14px', fontSize: 12 }}
>
Χρήση
</button>
</div>
))}
</div>
<div style={{ padding: '14px 24px', borderTop: '1px solid #f0f1f3', display: 'flex', justifyContent: 'flex-end' }}>
<button onClick={onClose} style={btnSecondary}>Κλείσιμο</button>
</div>
</div>
</div>
)
}
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 (
<div className="card divide-y divide-gray-100">
{showFinder && (
<FindPrintersModal
onClose={() => setShowFinder(false)}
onSelect={handleFinderSelect}
/>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 20px' }}>
<div>
<h2 className="font-semibold text-gray-700">Εκτυπωτές</h2>
<p className="text-xs text-gray-400 mt-0.5">Διαχείριση εκτυπωτών του συστήματος</p>
</div>
<button onClick={() => { setShowNew(v => !v); setEditingId(null) }} style={btnSecondary}>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => setShowFinder(true)} style={{ ...btnSecondary, fontSize: 13 }}>
Εύρεση εκτυπωτών
</button>
<button onClick={() => { setNewFormInitial(null); setShowNew(v => !v); setEditingId(null) }} style={btnSecondary}>
+ Νέος εκτυπωτής
</button>
</div>
</div>
{showNew && (
<div style={{ padding: '12px 20px' }}>
<PrinterForm
initial={newFormInitial ?? undefined}
onSave={form => createMut.mutate(form)}
onCancel={() => setShowNew(false)}
onCancel={() => { setShowNew(false); setNewFormInitial(null) }}
isPending={createMut.isPending}
/>
</div>