diff --git a/apps/backend/src/db/repositories/services.ts b/apps/backend/src/db/repositories/services.ts index a173ad0..c2d4849 100644 --- a/apps/backend/src/db/repositories/services.ts +++ b/apps/backend/src/db/repositories/services.ts @@ -15,6 +15,7 @@ function mapRow(row: typeof services.$inferSelect): Service { displayName: row.displayName, hostname: row.hostname, url: row.url, + updatedAt: row.updatedAt, https: row.https, port: row.port, category: row.category, diff --git a/apps/backend/src/routes/scan.ts b/apps/backend/src/routes/scan.ts index bae9c96..49e8f7f 100644 --- a/apps/backend/src/routes/scan.ts +++ b/apps/backend/src/routes/scan.ts @@ -71,7 +71,11 @@ export async function scanRoutes(app: FastifyInstance): Promise { // erkennen, welche davon diesmal nicht mehr gefunden wurden ("stale"). 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 // sie unter Admin -> Kategorien auftaucht und dort umbenannt/sortiert @@ -112,9 +116,20 @@ export async function scanRoutes(app: FastifyInstance): Promise { // Dienste, die es vorher gab, aber diesmal nicht mehr gefunden wurden // (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 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 // einsammeln (siehe upsertServiceFromScan) - werden NICHT automatisch diff --git a/apps/backend/src/scanner/networkScanner.ts b/apps/backend/src/scanner/networkScanner.ts index 767ce1e..5dbb10d 100644 --- a/apps/backend/src/scanner/networkScanner.ts +++ b/apps/backend/src/scanner/networkScanner.ts @@ -78,7 +78,8 @@ export interface DeviceScanResult { */ export async function scanDeviceServices( device: ScanTarget, - extraPorts: number[] = TYPICAL_PORTS + extraPorts: number[] = TYPICAL_PORTS, + priorityPorts: number[] = [] ): Promise { const dnsResult = await resolveHostname(device.hostname); // 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. 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 // einem nicht erreichbaren/gefilterten Gerät (z. B. ein schlafender @@ -102,7 +103,21 @@ export async function scanDeviceServices( 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); + 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[] = []; diff --git a/apps/frontend/src/hooks/useScanActivity.ts b/apps/frontend/src/hooks/useScanActivity.ts new file mode 100644 index 0000000..fa3f0eb --- /dev/null +++ b/apps/frontend/src/hooks/useScanActivity.ts @@ -0,0 +1,48 @@ +import { useEffect, useState } from "react"; + +interface ScanActivity { + running: boolean; + pendingCount: number; +} + +function readJson(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("scanner:bulkStaleServices", []); + const nameChanges = readJson("scanner:bulkNameChanges", []); + const staleDevices = readJson("scanner:staleDevices", []); + const deviceChanges = readJson("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(() => computeActivity()); + + useEffect(() => { + const interval = setInterval(() => setActivity(computeActivity()), 1500); + return () => clearInterval(interval); + }, []); + + return activity; +} diff --git a/apps/frontend/src/routes/admin/AdminLayout.tsx b/apps/frontend/src/routes/admin/AdminLayout.tsx index f4f495c..40a0c76 100644 --- a/apps/frontend/src/routes/admin/AdminLayout.tsx +++ b/apps/frontend/src/routes/admin/AdminLayout.tsx @@ -20,8 +20,34 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { StatusBadge } from "@launchpad/ui"; import { useBackendHealth } from "../../hooks/useBackendHealth.js"; +import { useScanActivity } from "../../hooks/useScanActivity.js"; import { ScrollToTopButton } from "../../components/ScrollToTopButton.js"; +function ScanActivityBadge() { + const { running, pendingCount } = useScanActivity(); + + if (running) { + return ( + + ); + } + if (pendingCount > 0) { + return ( + + {pendingCount} + + ); + } + return null; +} + const NAV_ITEMS: { to: string; label: string; icon: IconDefinition }[] = [ { to: "/", label: "Startseite", icon: faHouse }, { to: "/admin/dashboard", label: "Dashboard", icon: faChartLine }, @@ -124,6 +150,7 @@ export function AdminLayout() { {item.label} + {item.to === "/admin/scanner" ? : null} ); })} diff --git a/apps/frontend/src/routes/admin/SettingsPage.tsx b/apps/frontend/src/routes/admin/SettingsPage.tsx index 191704c..f2dffa7 100644 --- a/apps/frontend/src/routes/admin/SettingsPage.tsx +++ b/apps/frontend/src/routes/admin/SettingsPage.tsx @@ -422,8 +422,8 @@ export function SettingsPage() {

Scan