From 050616a5cf3b0096529009821878c280ad010b5a Mon Sep 17 00:00:00 2001 From: Dicken Date: Thu, 23 Jul 2026 13:23:20 +0200 Subject: [PATCH] round16: Geraete-Erkennung per ID statt IP fixen, Schwelle konfigurierbar, Icons auf Font Awesome umgestellt --- apps/backend/src/routes/scan.ts | 32 ++++++++++++------- apps/backend/src/routes/settings.ts | 12 ++++++- apps/frontend/package.json | 4 +++ apps/frontend/src/hooks/useSettings.ts | 1 + .../src/routes/admin/BookmarksPage.tsx | 10 +++--- .../src/routes/admin/CategoriesPage.tsx | 14 ++++---- .../frontend/src/routes/admin/DevicesPage.tsx | 22 +++++++------ .../src/routes/admin/ReadLaterPage.tsx | 15 +++++---- .../frontend/src/routes/admin/ScannerPage.tsx | 18 ++++++----- .../src/routes/admin/ServicesPage.tsx | 10 +++--- .../src/routes/admin/SettingsPage.tsx | 17 ++++++++-- 11 files changed, 101 insertions(+), 54 deletions(-) diff --git a/apps/backend/src/routes/scan.ts b/apps/backend/src/routes/scan.ts index 15c5e45..a4b1972 100644 --- a/apps/backend/src/routes/scan.ts +++ b/apps/backend/src/routes/scan.ts @@ -3,6 +3,7 @@ import * as deviceRepo from "../db/repositories/devices.js"; import * as serviceRepo from "../db/repositories/services.js"; import * as categoryRepo from "../db/repositories/categories.js"; import * as logRepo from "../db/repositories/logs.js"; +import * as settingsRepo from "../db/repositories/settings.js"; import { scanDeviceServices } from "../scanner/networkScanner.js"; import { fetchFritzBoxHosts, type FritzBoxHost } from "../scanner/fritzbox.js"; @@ -208,20 +209,29 @@ export async function scanRoutes(app: FastifyInstance): Promise { ); // Geräte, die die FritzBox früher gemeldet hatte, diesmal aber nicht - // mehr in der Liste sind – werden erst nach mindestens 7 Tagen - // Abwesenheit zur Durchsicht vorgeschlagen (nicht schon beim ersten - // verpassten Scan), damit ein kurzzeitig offline/im Standby befindliches - // Gerät (Reboot, WLAN-Aussetzer, Nacht-Standby) nicht sofort zum - // Löschen vorgeschlagen wird. lastScan wird bei jedem Fund aktualisiert - // (siehe upsertDeviceFromScan) - bleibt es stehen, war das Gerät seither - // nicht mehr da. - const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; + // mehr in der Liste sind – werden erst nach mindestens + // staleDeviceThresholdDays Tagen Abwesenheit zur Durchsicht + // vorgeschlagen (nicht schon beim ersten verpassten Scan), damit ein + // kurzzeitig offline/im Standby befindliches Gerät (Reboot, + // WLAN-Aussetzer, Nacht-Standby) nicht sofort zum Löschen + // vorgeschlagen wird. lastScan wird bei jedem Fund aktualisiert (siehe + // upsertDeviceFromScan) - bleibt es stehen, war das Gerät seither nicht + // mehr da. + // + // "Gefunden" wird über die ID der in DIESEM Scan tatsächlich + // upgeserteten Geräte geprüft (nicht über einen IP-Abgleich!): ändert + // sich die IP eines Geräts (z. B. DHCP-Neuvergabe), wird es trotzdem + // korrekt über seine MAC wiedererkannt und upgeserted - ein reiner + // IP-Vergleich hätte es fälschlich als "nicht mehr gefunden" gemeldet, + // obwohl es im selben Scan noch da war, nur unter neuer IP. + const foundDeviceIds = new Set(devices.map((d) => d.id)); + const staleDeviceThresholdDays = Number(settingsRepo.listSettings().staleDeviceThresholdDays ?? 7); + const staleThresholdMs = staleDeviceThresholdDays * 24 * 60 * 60 * 1000; const now = Date.now(); - const foundIps = new Set(hosts.map((h) => h.ip)); const staleDevices = devicesBeforeScan.filter((d) => { - if (foundIps.has(d.ip)) return false; + if (foundDeviceIds.has(d.id)) return false; const lastSeen = d.lastScan ? new Date(d.lastScan).getTime() : 0; - return now - lastSeen >= SEVEN_DAYS_MS; + return now - lastSeen >= staleThresholdMs; }); logRepo.logScan({ diff --git a/apps/backend/src/routes/settings.ts b/apps/backend/src/routes/settings.ts index 33439bb..a4c9a8f 100644 --- a/apps/backend/src/routes/settings.ts +++ b/apps/backend/src/routes/settings.ts @@ -7,12 +7,13 @@ export async function settingsRoutes(app: FastifyInstance): Promise { return { recentVisitsLimit: Number(all.recentVisitsLimit ?? 5), readLaterLimit: Number(all.readLaterLimit ?? 5), + staleDeviceThresholdDays: Number(all.staleDeviceThresholdDays ?? 7), }; }); app.patch("/api/settings", async (request, reply) => { const body = request.body as - | { recentVisitsLimit?: number; readLaterLimit?: number } + | { recentVisitsLimit?: number; readLaterLimit?: number; staleDeviceThresholdDays?: number } | undefined; if (body?.recentVisitsLimit !== undefined) { @@ -31,10 +32,19 @@ export async function settingsRoutes(app: FastifyInstance): Promise { settingsRepo.setSetting("readLaterLimit", String(Math.round(value))); } + if (body?.staleDeviceThresholdDays !== undefined) { + const value = Number(body.staleDeviceThresholdDays); + if (!Number.isFinite(value) || value < 0 || value > 90) { + return reply.code(400).send({ error: "staleDeviceThresholdDays muss zwischen 0 und 90 liegen" }); + } + settingsRepo.setSetting("staleDeviceThresholdDays", String(Math.round(value))); + } + const all = settingsRepo.listSettings(); return { recentVisitsLimit: Number(all.recentVisitsLimit ?? 5), readLaterLimit: Number(all.readLaterLimit ?? 5), + staleDeviceThresholdDays: Number(all.staleDeviceThresholdDays ?? 7), }; }); } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index a607ca0..425e3f5 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -10,6 +10,10 @@ "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@fortawesome/fontawesome-svg-core": "^7.3.1", + "@fortawesome/free-regular-svg-icons": "^7.3.1", + "@fortawesome/free-solid-svg-icons": "^7.3.1", + "@fortawesome/react-fontawesome": "^3.5.0", "@launchpad/shared": "workspace:*", "@launchpad/ui": "workspace:*", "@tanstack/react-query": "^5.51.23", diff --git a/apps/frontend/src/hooks/useSettings.ts b/apps/frontend/src/hooks/useSettings.ts index 37c2bd6..b4ccc63 100644 --- a/apps/frontend/src/hooks/useSettings.ts +++ b/apps/frontend/src/hooks/useSettings.ts @@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query"; export interface AppSettings { recentVisitsLimit: number; readLaterLimit: number; + staleDeviceThresholdDays: number; } async function fetchSettings(): Promise { diff --git a/apps/frontend/src/routes/admin/BookmarksPage.tsx b/apps/frontend/src/routes/admin/BookmarksPage.tsx index 0ac8cf4..fea3220 100644 --- a/apps/frontend/src/routes/admin/BookmarksPage.tsx +++ b/apps/frontend/src/routes/admin/BookmarksPage.tsx @@ -1,4 +1,6 @@ import { useMemo, useState, type DragEvent, type FormEvent } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faFloppyDisk, faPen, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Button, Favicon } from "@launchpad/ui"; import type { Bookmark } from "@launchpad/shared"; @@ -260,8 +262,8 @@ function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void />
- - + + {mutation.isError ? ( {(mutation.error as Error).message} ) : null} @@ -354,8 +356,8 @@ function BookmarkRow({
- - + +
diff --git a/apps/frontend/src/routes/admin/CategoriesPage.tsx b/apps/frontend/src/routes/admin/CategoriesPage.tsx index a3ff9eb..889f46a 100644 --- a/apps/frontend/src/routes/admin/CategoriesPage.tsx +++ b/apps/frontend/src/routes/admin/CategoriesPage.tsx @@ -1,4 +1,6 @@ import { useState, type DragEvent, type FormEvent } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faFloppyDisk, faPen, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Button } from "@launchpad/ui"; import type { Category } from "@launchpad/shared"; @@ -125,18 +127,14 @@ function CategoryRow({ className="flex-1 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm dark:border-white/10 dark:bg-white/10 dark:text-white" /> - - + + ) : ( <> {category.name} - - + + )} diff --git a/apps/frontend/src/routes/admin/DevicesPage.tsx b/apps/frontend/src/routes/admin/DevicesPage.tsx index 1d00413..a06fb83 100644 --- a/apps/frontend/src/routes/admin/DevicesPage.tsx +++ b/apps/frontend/src/routes/admin/DevicesPage.tsx @@ -1,4 +1,6 @@ import { useMemo, useState, type FormEvent } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faBan, faCheck, faFloppyDisk, faPen, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Button, Favicon } from "@launchpad/ui"; import type { Service } from "@launchpad/shared"; @@ -162,8 +164,8 @@ function EditDeviceForm({ device, onDone }: { device: DeviceWithServices; onDone />
- - + +
@@ -218,12 +220,12 @@ function NameChangesReview({ onClick={() => setHandled((prev) => new Set(prev).add(`${c.serviceId}:${c.field}`))} title="Behalten (Vorschlag verwerfen)" aria-label="Behalten (Vorschlag verwerfen)" - >✕ + > + disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen"> ))} @@ -261,8 +263,8 @@ function DeviceNameSuggestionBanner({ Per Reverse-DNS gefunden: „{suggestion}" als Gerätename?
- - + +
@@ -296,12 +298,12 @@ function StaleServicesReview({ staleServices, onDone }: { staleServices: Service {s.displayName} ({s.hostname}:{s.port})
- + + disabled={deleteMutation.isPending} title="Entfernen" aria-label="Entfernen">
))} @@ -388,7 +390,7 @@ function DeviceRow({ device }: { device: DeviceWithServices }) { {device.services.length}
- + + disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen">
diff --git a/apps/frontend/src/routes/admin/ReadLaterPage.tsx b/apps/frontend/src/routes/admin/ReadLaterPage.tsx index 04989c2..fd699d1 100644 --- a/apps/frontend/src/routes/admin/ReadLaterPage.tsx +++ b/apps/frontend/src/routes/admin/ReadLaterPage.tsx @@ -1,4 +1,6 @@ import { useState } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faBookmark, faPen, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Button, Favicon } from "@launchpad/ui"; import { useReadLater, type ReadLaterItem } from "../../hooks/useReadLater.js"; @@ -77,13 +79,13 @@ function EditRow({ item, onDone }: { item: ReadLaterItem; onDone: () => void }) />
- + + >
{mutation.isError ? ( @@ -136,15 +138,16 @@ function ReadLaterRow({ item }: { item: ReadLaterItem }) { {formatDate(item.savedAt)}
- + + >
diff --git a/apps/frontend/src/routes/admin/ScannerPage.tsx b/apps/frontend/src/routes/admin/ScannerPage.tsx index 1131d6e..1c3e7dd 100644 --- a/apps/frontend/src/routes/admin/ScannerPage.tsx +++ b/apps/frontend/src/routes/admin/ScannerPage.tsx @@ -1,4 +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 { Button } from "@launchpad/ui"; import type { Device, Service } from "@launchpad/shared"; import { useDevices } from "../../hooks/useDevices.js"; @@ -141,12 +143,12 @@ function NameChangesReview({ {c.suggested}"
- + + disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen">
))} @@ -187,12 +189,12 @@ function StaleServicesReview({ {s.displayName} ({s.hostname}:{s.port})
- + + disabled={deleteMutation.isPending} title="Entfernen" aria-label="Entfernen">
))} @@ -234,12 +236,12 @@ function DeviceChangesReview({ {c.deviceHostname} – {fieldLabel(c.field)}: „{c.current}" → „{c.suggested}"
- + + disabled={applyMutation.isPending} title="Übernehmen" aria-label="Übernehmen">
))} @@ -280,12 +282,12 @@ function StaleDevicesReview({ {d.hostname} ({d.ip})
- + + disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen">
))} diff --git a/apps/frontend/src/routes/admin/ServicesPage.tsx b/apps/frontend/src/routes/admin/ServicesPage.tsx index 48c9399..7bf3db0 100644 --- a/apps/frontend/src/routes/admin/ServicesPage.tsx +++ b/apps/frontend/src/routes/admin/ServicesPage.tsx @@ -1,4 +1,6 @@ import { useMemo, useState, type DragEvent } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faFloppyDisk, faPen, faTrash, faXmark } from "@fortawesome/free-solid-svg-icons"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Button, Favicon } from "@launchpad/ui"; import type { Service } from "@launchpad/shared"; @@ -229,8 +231,8 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
- - + + {mutation.isError ? ( {(mutation.error as Error).message} ) : null} @@ -394,8 +396,8 @@ function ServiceRow({
- - + +
diff --git a/apps/frontend/src/routes/admin/SettingsPage.tsx b/apps/frontend/src/routes/admin/SettingsPage.tsx index 717ed27..ced4c5f 100644 --- a/apps/frontend/src/routes/admin/SettingsPage.tsx +++ b/apps/frontend/src/routes/admin/SettingsPage.tsx @@ -20,11 +20,13 @@ function LimitSetting({ hint, settingKey, value: currentValue, + max = 50, }: { label: string; hint: string; - settingKey: "recentVisitsLimit" | "readLaterLimit"; + settingKey: "recentVisitsLimit" | "readLaterLimit" | "staleDeviceThresholdDays"; value: number | undefined; + max?: number; }) { const queryClient = useQueryClient(); const [value, setValue] = useState(null); @@ -54,7 +56,7 @@ function LimitSetting({ setValue(e.target.value)} onBlur={() => { @@ -323,6 +325,17 @@ export function SettingsPage() { />
+
+

Scan

+ +
+

HTTPS