generated from Dicken/dickendock
round25: Scanner-Haenger behoben (Portpruefung parallelisiert statt sequentiell), Abbrechen-Button fuer beide Scanner
This commit is contained in:
@@ -91,12 +91,22 @@ export async function scanDeviceServices(
|
|||||||
const suggestedHostname = dnsResult ? null : await reverseLookup(device.ip);
|
const suggestedHostname = dnsResult ? null : await reverseLookup(device.ip);
|
||||||
|
|
||||||
const candidatePorts = Array.from(new Set([80, 443, ...extraPorts]));
|
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[] = [];
|
const found: DiscoveredService[] = [];
|
||||||
|
|
||||||
for (const port of candidatePorts) {
|
for (const port of openPorts) {
|
||||||
const open = await isPortOpen(device.ip, port);
|
|
||||||
if (!open) continue;
|
|
||||||
|
|
||||||
let isHttps = port === 443 || port === 9443 || port === 8006 || port === 8443;
|
let isHttps = port === 443 || port === 9443 || port === 8006 || port === 8443;
|
||||||
let baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`;
|
let baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`;
|
||||||
let probe = await probeHttp(baseUrl);
|
let probe = await probeHttp(baseUrl);
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ interface ScanResult {
|
|||||||
deviceNameSuggestion: string | null;
|
deviceNameSuggestion: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanDevice(id: string): Promise<ScanResult> {
|
async function scanDevice(id: string, signal?: AbortSignal): Promise<ScanResult> {
|
||||||
const res = await fetch(`/api/scan/devices/${id}`, { method: "POST" });
|
const res = await fetch(`/api/scan/devices/${id}`, { method: "POST", signal });
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(body.detail ?? body.error ?? `Scan fehlgeschlagen (HTTP ${res.status})`);
|
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 [deviceNameSuggestion, setDeviceNameSuggestion] = useState<string | null>(null);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [scanAbort, setScanAbort] = useState<AbortController | null>(null);
|
||||||
|
|
||||||
const scanMutation = useMutation({
|
const scanMutation = useMutation({
|
||||||
mutationFn: () => scanDevice(device.id),
|
mutationFn: () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
setScanAbort(controller);
|
||||||
|
return scanDevice(device.id, controller.signal);
|
||||||
|
},
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
const portsText = result.ports.length > 0 ? result.ports.join(", ") : "keine";
|
const portsText = result.ports.length > 0 ? result.ports.join(", ") : "keine";
|
||||||
const pending = result.nameChanges.length + result.staleServices.length;
|
const pending = result.nameChanges.length + result.staleServices.length;
|
||||||
@@ -339,7 +344,10 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
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({
|
const deleteMutation = useMutation({
|
||||||
@@ -412,6 +420,17 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
|
|||||||
>
|
>
|
||||||
{scanMutation.isPending ? "Scanne …" : "Jetzt scannen"}
|
{scanMutation.isPending ? "Scanne …" : "Jetzt scannen"}
|
||||||
</Button>
|
</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
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
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 { Button } from "@launchpad/ui";
|
||||||
import type { Device, Service } from "@launchpad/shared";
|
import type { Device, Service } from "@launchpad/shared";
|
||||||
import { useDevices } from "../../hooks/useDevices.js";
|
import { useDevices } from "../../hooks/useDevices.js";
|
||||||
@@ -80,8 +80,8 @@ async function patchService(id: string, patch: Record<string, unknown>) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanDeviceById(id: string) {
|
async function scanDeviceById(id: string, signal?: AbortSignal) {
|
||||||
const res = await fetch(`/api/scan/devices/${id}`, { method: "POST" });
|
const res = await fetch(`/api/scan/devices/${id}`, { method: "POST", signal });
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(body.detail ?? body.error ?? `Scan fehlgeschlagen (HTTP ${res.status})`);
|
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}`;
|
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
|
* Übernimmt einen Namens-/Kategorie-Vorschlag des Scanners. Setzt dabei
|
||||||
* explizit die *EditedManually-Flag auf false zurück, damit der Dienst
|
* explizit die *EditedManually-Flag auf false zurück, damit der Dienst
|
||||||
@@ -336,33 +345,41 @@ export function ScannerPage() {
|
|||||||
onError: (error: Error) => setFritzboxError(error.message),
|
onError: (error: Error) => setFritzboxError(error.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function scanAllDevices() {
|
async function scanAllDevices() {
|
||||||
if (!devices || devices.length === 0) return;
|
if (!devices || devices.length === 0) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
bulkAbortController = controller;
|
||||||
setBulkRunning(true);
|
setBulkRunning(true);
|
||||||
setBulkStaleServices([]);
|
setBulkStaleServices([]);
|
||||||
setBulkNameChanges([]);
|
setBulkNameChanges([]);
|
||||||
let created = 0;
|
let created = 0;
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
|
let scannedCount = 0;
|
||||||
const allStale: Service[] = [];
|
const allStale: Service[] = [];
|
||||||
const allNameChanges: ScanNameChange[] = [];
|
const allNameChanges: ScanNameChange[] = [];
|
||||||
|
|
||||||
for (const device of devices) {
|
for (const device of devices) {
|
||||||
|
if (controller.signal.aborted) break;
|
||||||
try {
|
try {
|
||||||
const result = await scanDeviceById(device.id);
|
const result = await scanDeviceById(device.id, controller.signal);
|
||||||
created += result.created;
|
created += result.created;
|
||||||
updated += result.updated;
|
updated += result.updated;
|
||||||
|
scannedCount++;
|
||||||
allStale.push(...result.staleServices);
|
allStale.push(...result.staleServices);
|
||||||
allNameChanges.push(...result.nameChanges);
|
allNameChanges.push(...result.nameChanges);
|
||||||
setBulkStatus(`Scanne ${device.hostname} … (${created} neu, ${updated} aktualisiert bisher)`);
|
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
|
// einzelnes fehlgeschlagenes Gerät soll den Rest nicht abbrechen
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updated;
|
void updated;
|
||||||
const pending = allNameChanges.length + allStale.length;
|
const pending = allNameChanges.length + allStale.length;
|
||||||
|
const wasAborted = controller.signal.aborted;
|
||||||
setBulkStatus(
|
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 > 0
|
||||||
? ` ${pending} Änderung(en) warten unten auf deine Bestätigung.`
|
? ` ${pending} Änderung(en) warten unten auf deine Bestätigung.`
|
||||||
: " Keine Änderungen an bestehenden Diensten vorgeschlagen.")
|
: " Keine Änderungen an bestehenden Diensten vorgeschlagen.")
|
||||||
@@ -370,6 +387,7 @@ export function ScannerPage() {
|
|||||||
setBulkStaleServices(allStale);
|
setBulkStaleServices(allStale);
|
||||||
setBulkNameChanges(allNameChanges);
|
setBulkNameChanges(allNameChanges);
|
||||||
setBulkRunning(false);
|
setBulkRunning(false);
|
||||||
|
bulkAbortController = null;
|
||||||
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["services"] });
|
queryClient.invalidateQueries({ queryKey: ["services"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["logs"] });
|
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
|
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.
|
den Button in der Geräte-Tabelle nutzen.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className="mt-4"
|
|
||||||
onClick={scanAllDevices}
|
onClick={scanAllDevices}
|
||||||
disabled={bulkRunning || !devices || devices.length === 0}
|
disabled={bulkRunning || !devices || devices.length === 0}
|
||||||
>
|
>
|
||||||
{bulkRunning ? "Scanne …" : "Alle Geräte jetzt scannen"}
|
{bulkRunning ? "Scanne …" : "Alle Geräte jetzt scannen"}
|
||||||
</Button>
|
</Button>
|
||||||
|
{bulkRunning ? (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => bulkAbortController?.abort()}
|
||||||
|
title="Scan abbrechen"
|
||||||
|
aria-label="Scan abbrechen"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faXmark} />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
{bulkStatus ? (
|
{bulkStatus ? (
|
||||||
<p className="mt-2 text-sm text-black/50 dark:text-white/50">{bulkStatus}</p>
|
<p className="mt-2 text-sm text-black/50 dark:text-white/50">{bulkStatus}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
Reference in New Issue
Block a user