round25: Scanner-Haenger behoben (Portpruefung parallelisiert statt sequentiell), Abbrechen-Button fuer beide Scanner

This commit is contained in:
2026-07-24 01:59:18 +02:00
parent 6a2bf24498
commit 458f58af6f
3 changed files with 82 additions and 23 deletions

View File

@@ -91,12 +91,22 @@ export async function scanDeviceServices(
const suggestedHostname = dnsResult ? null : await reverseLookup(device.ip);
const candidatePorts = Array.from(new Set([80, 443, ...extraPorts]));
// Die offenen Ports werden PARALLEL geprüft, nicht nacheinander - bei
// einem nicht erreichbaren/gefilterten Gerät (z. B. ein schlafender
// Laptop) würde ein sequenzieller Durchlauf sonst bis zu
// Portanzahl × Timeout dauern (bei 20 Ports und 800ms Timeout: 16+
// Sekunden PRO GERÄT) und den Scan wie hängengeblieben wirken lassen.
// Danach wird nur noch für die tatsächlich offenen Ports (meist wenige)
// die eigentliche HTTP-Abfrage gemacht.
const openChecks = await Promise.all(
candidatePorts.map(async (port) => ({ port, open: await isPortOpen(device.ip, port) }))
);
const openPorts = openChecks.filter((c) => c.open).map((c) => c.port);
const found: DiscoveredService[] = [];
for (const port of candidatePorts) {
const open = await isPortOpen(device.ip, port);
if (!open) continue;
for (const port of openPorts) {
let isHttps = port === 443 || port === 9443 || port === 8006 || port === 8443;
let baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`;
let probe = await probeHttp(baseUrl);

View File

@@ -79,8 +79,8 @@ interface ScanResult {
deviceNameSuggestion: string | null;
}
async function scanDevice(id: string): Promise<ScanResult> {
const res = await fetch(`/api/scan/devices/${id}`, { method: "POST" });
async function scanDevice(id: string, signal?: AbortSignal): Promise<ScanResult> {
const res = await fetch(`/api/scan/devices/${id}`, { method: "POST", signal });
const body = await res.json();
if (!res.ok) {
throw new Error(body.detail ?? body.error ?? `Scan fehlgeschlagen (HTTP ${res.status})`);
@@ -323,9 +323,14 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
const [deviceNameSuggestion, setDeviceNameSuggestion] = useState<string | null>(null);
const [expanded, setExpanded] = useState(false);
const [editing, setEditing] = useState(false);
const [scanAbort, setScanAbort] = useState<AbortController | null>(null);
const scanMutation = useMutation({
mutationFn: () => scanDevice(device.id),
mutationFn: () => {
const controller = new AbortController();
setScanAbort(controller);
return scanDevice(device.id, controller.signal);
},
onSuccess: (result) => {
const portsText = result.ports.length > 0 ? result.ports.join(", ") : "keine";
const pending = result.nameChanges.length + result.staleServices.length;
@@ -339,7 +344,10 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
queryClient.invalidateQueries({ queryKey: ["devices"] });
queryClient.invalidateQueries({ queryKey: ["services"] });
},
onError: (err: Error) => setScanMessage(err.message),
onError: (err: Error) => {
setScanMessage(err.name === "AbortError" ? "Scan abgebrochen." : err.message);
},
onSettled: () => setScanAbort(null),
});
const deleteMutation = useMutation({
@@ -412,6 +420,17 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
>
{scanMutation.isPending ? "Scanne " : "Jetzt scannen"}
</Button>
{scanMutation.isPending && scanAbort ? (
<Button
size="icon"
variant="ghost"
onClick={() => scanAbort.abort()}
title="Scan abbrechen"
aria-label="Scan abbrechen"
>
<FontAwesomeIcon icon={faXmark} />
</Button>
) : null}
<Button
size="icon"
variant="danger"

View File

@@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBan, faCheck, faTrash } from "@fortawesome/free-solid-svg-icons";
import { faBan, faCheck, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons";
import { Button } from "@launchpad/ui";
import type { Device, Service } from "@launchpad/shared";
import { useDevices } from "../../hooks/useDevices.js";
@@ -80,8 +80,8 @@ async function patchService(id: string, patch: Record<string, unknown>) {
return res.json();
}
async function scanDeviceById(id: string) {
const res = await fetch(`/api/scan/devices/${id}`, { method: "POST" });
async function scanDeviceById(id: string, signal?: AbortSignal) {
const res = await fetch(`/api/scan/devices/${id}`, { method: "POST", signal });
const body = await res.json();
if (!res.ok) {
throw new Error(body.detail ?? body.error ?? `Scan fehlgeschlagen (HTTP ${res.status})`);
@@ -99,6 +99,15 @@ function nameChangeKey(c: ScanNameChange): string {
return `${c.serviceId}:${c.field}`;
}
/**
* Modul-Ebene statt useRef: der Scan-Loop selbst ist eine normale
* JS-Funktion, kein an die Komponente gebundener Zustand - läuft weiter,
* auch wenn man die Seite verlässt und zurückkommt (siehe usePersistedState
* weiter unten). Ein useRef würde bei einem Seitenwechsel zurückgesetzt und
* der Abbrechen-Button hätte dann keinen gültigen Controller mehr.
*/
let bulkAbortController: AbortController | null = null;
/**
* Übernimmt einen Namens-/Kategorie-Vorschlag des Scanners. Setzt dabei
* explizit die *EditedManually-Flag auf false zurück, damit der Dienst
@@ -336,33 +345,41 @@ export function ScannerPage() {
onError: (error: Error) => setFritzboxError(error.message),
});
async function scanAllDevices() {
async function scanAllDevices() {
if (!devices || devices.length === 0) return;
const controller = new AbortController();
bulkAbortController = controller;
setBulkRunning(true);
setBulkStaleServices([]);
setBulkNameChanges([]);
let created = 0;
let updated = 0;
let scannedCount = 0;
const allStale: Service[] = [];
const allNameChanges: ScanNameChange[] = [];
for (const device of devices) {
if (controller.signal.aborted) break;
try {
const result = await scanDeviceById(device.id);
const result = await scanDeviceById(device.id, controller.signal);
created += result.created;
updated += result.updated;
scannedCount++;
allStale.push(...result.staleServices);
allNameChanges.push(...result.nameChanges);
setBulkStatus(`Scanne ${device.hostname} … (${created} neu, ${updated} aktualisiert bisher)`);
} catch {
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") break;
// einzelnes fehlgeschlagenes Gerät soll den Rest nicht abbrechen
}
}
void updated;
const pending = allNameChanges.length + allStale.length;
const wasAborted = controller.signal.aborted;
setBulkStatus(
`Fertig: ${devices.length} Gerät(e) gescannt, ${created} neue(r) Dienst(e) gefunden.` +
(wasAborted ? `Abgebrochen nach ${scannedCount} von ${devices.length} Gerät(en). ` : `Fertig: ${devices.length} Gerät(e) gescannt. `) +
`${created} neue(r) Dienst(e) gefunden.` +
(pending > 0
? ` ${pending} Änderung(en) warten unten auf deine Bestätigung.`
: " Keine Änderungen an bestehenden Diensten vorgeschlagen.")
@@ -370,6 +387,7 @@ export function ScannerPage() {
setBulkStaleServices(allStale);
setBulkNameChanges(allNameChanges);
setBulkRunning(false);
bulkAbortController = null;
queryClient.invalidateQueries({ queryKey: ["devices"] });
queryClient.invalidateQueries({ queryKey: ["services"] });
queryClient.invalidateQueries({ queryKey: ["logs"] });
@@ -428,14 +446,26 @@ export function ScannerPage() {
für alle {devices?.length ?? 0} bekannten Geräte aus. Für ein einzelnes Gerät lieber
den Button in der Geräte-Tabelle nutzen.
</p>
<Button
variant="primary"
className="mt-4"
onClick={scanAllDevices}
disabled={bulkRunning || !devices || devices.length === 0}
>
{bulkRunning ? "Scanne …" : "Alle Geräte jetzt scannen"}
</Button>
<div className="mt-4 flex items-center gap-2">
<Button
variant="primary"
onClick={scanAllDevices}
disabled={bulkRunning || !devices || devices.length === 0}
>
{bulkRunning ? "Scanne …" : "Alle Geräte jetzt scannen"}
</Button>
{bulkRunning ? (
<Button
size="icon"
variant="ghost"
onClick={() => bulkAbortController?.abort()}
title="Scan abbrechen"
aria-label="Scan abbrechen"
>
<FontAwesomeIcon icon={faXmark} />
</Button>
) : null}
</div>
{bulkStatus ? (
<p className="mt-2 text-sm text-black/50 dark:text-white/50">{bulkStatus}</p>
) : null}