round28: Portscan-Retry fuer bekannte Dienste (Echo/Samsung-Falschmeldungen behoben), 7-Tage-Schwelle jetzt auch fuer einzelne Dienste, geraeteuebergreifender Scan-Status in der Navigation

This commit is contained in:
2026-07-24 12:49:26 +02:00
parent 430b79763c
commit f5845ec762
7 changed files with 116 additions and 8 deletions

View File

@@ -15,6 +15,7 @@ function mapRow(row: typeof services.$inferSelect): Service {
displayName: row.displayName, displayName: row.displayName,
hostname: row.hostname, hostname: row.hostname,
url: row.url, url: row.url,
updatedAt: row.updatedAt,
https: row.https, https: row.https,
port: row.port, port: row.port,
category: row.category, category: row.category,

View File

@@ -71,7 +71,11 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
// erkennen, welche davon diesmal nicht mehr gefunden wurden ("stale"). // erkennen, welche davon diesmal nicht mehr gefunden wurden ("stale").
const servicesBeforeScan = serviceRepo.listServicesByDevice(device.id); const servicesBeforeScan = serviceRepo.listServicesByDevice(device.id);
const { services: discovered, suggestedHostname } = await scanDeviceServices(device); const { services: discovered, suggestedHostname } = await scanDeviceServices(
device,
undefined,
servicesBeforeScan.map((s) => s.port)
);
// Jede erkannte Kategorie auch in der categories-Tabelle anlegen, damit // Jede erkannte Kategorie auch in der categories-Tabelle anlegen, damit
// sie unter Admin -> Kategorien auftaucht und dort umbenannt/sortiert // sie unter Admin -> Kategorien auftaucht und dort umbenannt/sortiert
@@ -112,9 +116,20 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
// Dienste, die es vorher gab, aber diesmal nicht mehr gefunden wurden // Dienste, die es vorher gab, aber diesmal nicht mehr gefunden wurden
// (Port nicht mehr offen) werden NICHT automatisch gelöscht, sondern // (Port nicht mehr offen) werden NICHT automatisch gelöscht, sondern
// zur manuellen Durchsicht zurückgegeben (siehe Admin -> Scanner). // erst nach dem konfigurierten Schwellenwert (gleiche Einstellung wie
// bei nicht mehr gemeldeten Geräten, siehe Einstellungen) zur
// manuellen Durchsicht zurückgegeben. Ein einzelner verpasster Scan
// (z. B. Gerät kurz neu gestartet) soll nicht sofort als "verschwunden"
// gemeldet werden.
const foundPorts = new Set(discovered.map((d) => d.port)); const foundPorts = new Set(discovered.map((d) => d.port));
const staleServices = servicesBeforeScan.filter((s) => !foundPorts.has(s.port)); const staleThresholdDays = Number(settingsRepo.listSettings().staleDeviceThresholdDays ?? 7);
const staleThresholdMs = staleThresholdDays * 24 * 60 * 60 * 1000;
const now = Date.now();
const staleServices = servicesBeforeScan.filter((s) => {
if (foundPorts.has(s.port)) return false;
const lastSeen = s.updatedAt ? new Date(s.updatedAt).getTime() : 0;
return now - lastSeen >= staleThresholdMs;
});
// Vorschläge für abweichende Namen/Kategorien bereits bekannter Dienste // Vorschläge für abweichende Namen/Kategorien bereits bekannter Dienste
// einsammeln (siehe upsertServiceFromScan) - werden NICHT automatisch // einsammeln (siehe upsertServiceFromScan) - werden NICHT automatisch

View File

@@ -78,7 +78,8 @@ export interface DeviceScanResult {
*/ */
export async function scanDeviceServices( export async function scanDeviceServices(
device: ScanTarget, device: ScanTarget,
extraPorts: number[] = TYPICAL_PORTS extraPorts: number[] = TYPICAL_PORTS,
priorityPorts: number[] = []
): Promise<DeviceScanResult> { ): Promise<DeviceScanResult> {
const dnsResult = await resolveHostname(device.hostname); const dnsResult = await resolveHostname(device.hostname);
// Fällt auf die IP zurück, falls keine DNS-Variante auflösbar ist. // Fällt auf die IP zurück, falls keine DNS-Variante auflösbar ist.
@@ -90,7 +91,7 @@ export async function scanDeviceServices(
// gemeldeten Namen ein. // gemeldeten Namen ein.
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, ...priorityPorts]));
// Die offenen Ports werden PARALLEL geprüft, nicht nacheinander - bei // Die offenen Ports werden PARALLEL geprüft, nicht nacheinander - bei
// einem nicht erreichbaren/gefilterten Gerät (z. B. ein schlafender // einem nicht erreichbaren/gefilterten Gerät (z. B. ein schlafender
@@ -102,7 +103,21 @@ export async function scanDeviceServices(
const openChecks = await Promise.all( const openChecks = await Promise.all(
candidatePorts.map(async (port) => ({ port, open: await isPortOpen(device.ip, port) })) candidatePorts.map(async (port) => ({ port, open: await isPortOpen(device.ip, port) }))
); );
const openPorts = openChecks.filter((c) => c.open).map((c) => c.port); let openPorts = openChecks.filter((c) => c.open).map((c) => c.port);
// Bereits bekannte Dienst-Ports (priorityPorts, vom Aufrufer übergeben -
// siehe scan.ts), die im ersten, stark parallelen Durchlauf NICHT als
// offen erkannt wurden, bekommen einen zweiten, einzelnen Versuch mit
// etwas mehr Zeit. Grund: ~20 gleichzeitige Verbindungsversuche können
// schwächere Geräte (z. B. Echo-Lautsprecher, Smart-TVs) kurzzeitig
// überfordern und dabei ausgerechnet den einen tatsächlich offenen Port
// verpassen - das führte zu falschen "nicht mehr gefunden"-Meldungen,
// obwohl sich am Gerät nichts geändert hatte.
const missedPriorityPorts = priorityPorts.filter((p) => !openPorts.includes(p));
for (const port of missedPriorityPorts) {
const open = await isPortOpen(device.ip, port, 1500);
if (open) openPorts.push(port);
}
const found: DiscoveredService[] = []; const found: DiscoveredService[] = [];

View File

@@ -0,0 +1,48 @@
import { useEffect, useState } from "react";
interface ScanActivity {
running: boolean;
pendingCount: number;
}
function readJson<T>(key: string, fallback: T): T {
try {
const raw = sessionStorage.getItem(`launchpad:${key}`);
return raw !== null ? (JSON.parse(raw) as T) : fallback;
} catch {
return fallback;
}
}
function computeActivity(): ScanActivity {
const running = readJson("scanner:bulkRunning", false);
const staleServices = readJson<unknown[]>("scanner:bulkStaleServices", []);
const nameChanges = readJson<unknown[]>("scanner:bulkNameChanges", []);
const staleDevices = readJson<unknown[]>("scanner:staleDevices", []);
const deviceChanges = readJson<unknown[]>("scanner:deviceChanges", []);
const pendingCount =
staleServices.length + nameChanges.length + staleDevices.length + deviceChanges.length;
return { running, pendingCount };
}
/**
* Geräteübergreifender Hinweis, ob gerade ein Scan läuft oder es
* unbestätigte Scan-Ergebnisse gibt (siehe Scanner-Seite, dort alles über
* usePersistedState/sessionStorage gehalten) - für ein Abzeichen am
* "Scanner"-Navigationspunkt, sichtbar von jeder Admin-Seite aus.
*
* sessionStorage-Änderungen aus einer ANDEREN Komponente im selben Tab
* lösen kein "storage"-Event aus (das feuert nur tab-übergreifend) - daher
* hier bewusst ein einfaches, günstiges Polling statt eines echten
* Reaktivitäts-Mechanismus.
*/
export function useScanActivity(): ScanActivity {
const [activity, setActivity] = useState<ScanActivity>(() => computeActivity());
useEffect(() => {
const interval = setInterval(() => setActivity(computeActivity()), 1500);
return () => clearInterval(interval);
}, []);
return activity;
}

View File

@@ -20,8 +20,34 @@ import {
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { StatusBadge } from "@launchpad/ui"; import { StatusBadge } from "@launchpad/ui";
import { useBackendHealth } from "../../hooks/useBackendHealth.js"; import { useBackendHealth } from "../../hooks/useBackendHealth.js";
import { useScanActivity } from "../../hooks/useScanActivity.js";
import { ScrollToTopButton } from "../../components/ScrollToTopButton.js"; import { ScrollToTopButton } from "../../components/ScrollToTopButton.js";
function ScanActivityBadge() {
const { running, pendingCount } = useScanActivity();
if (running) {
return (
<span
title="Ein Scan läuft gerade"
className="ml-auto h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-blue-500"
/>
);
}
if (pendingCount > 0) {
return (
<span
title={`${pendingCount} Scan-Ergebnis(se) warten auf Bestätigung`}
className="ml-auto flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full
bg-amber-500 px-1 text-[10px] font-medium text-white"
>
{pendingCount}
</span>
);
}
return null;
}
const NAV_ITEMS: { to: string; label: string; icon: IconDefinition }[] = [ const NAV_ITEMS: { to: string; label: string; icon: IconDefinition }[] = [
{ to: "/", label: "Startseite", icon: faHouse }, { to: "/", label: "Startseite", icon: faHouse },
{ to: "/admin/dashboard", label: "Dashboard", icon: faChartLine }, { to: "/admin/dashboard", label: "Dashboard", icon: faChartLine },
@@ -124,6 +150,7 @@ export function AdminLayout() {
<FontAwesomeIcon icon={item.icon} /> <FontAwesomeIcon icon={item.icon} />
</span> </span>
{item.label} {item.label}
{item.to === "/admin/scanner" ? <ScanActivityBadge /> : null}
</Link> </Link>
); );
})} })}

View File

@@ -422,8 +422,8 @@ export function SettingsPage() {
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10"> <div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<h2 className="mb-2 font-medium text-black dark:text-white">Scan</h2> <h2 className="mb-2 font-medium text-black dark:text-white">Scan</h2>
<LimitSetting <LimitSetting
label="Nicht mehr gemeldete Geräte anzeigen nach (Tagen)" label="Nicht mehr gemeldete Geräte/Dienste anzeigen nach (Tagen)"
hint="Ein Gerät, das die FritzBox nicht mehr meldet, wird erst nach so vielen Tagen im Scan-Ergebnis zur Durchsicht vorgeschlagen. 0 = sofort beim ersten verpassten Scan." hint="Gilt für Geräte, die die FritzBox nicht mehr meldet, UND für einzelne Dienste, deren Port bei einem Scan nicht mehr offen ist. 0 = sofort beim ersten verpassten Scan."
settingKey="staleDeviceThresholdDays" settingKey="staleDeviceThresholdDays"
value={settings?.staleDeviceThresholdDays} value={settings?.staleDeviceThresholdDays}
max={90} max={90}

View File

@@ -32,6 +32,8 @@ export interface Service {
displayName: string; displayName: string;
hostname: string; hostname: string;
url: string; url: string;
/** Zuletzt geändert (auch durch reinen Scan-Fund, siehe upsertServiceFromScan) - ISO-8601. */
updatedAt: string;
https: boolean; https: boolean;
port: number; port: number;
category: string | null; category: string | null;