From 0580d46000f4aadae38c616e72a5deadf77daf78 Mon Sep 17 00:00:00 2001 From: Dicken Date: Mon, 20 Jul 2026 14:02:19 +0200 Subject: [PATCH] Hersteller-Auto-Erkennung ueber MAC (verifiziert), Geraete-Tabelle Hersteller/Modell-Spalten, Dropdown-Klick-Bug, Kategorie-Farbe als Hintergrund --- apps/backend/src/db/repositories/devices.ts | 28 +++++++-- apps/backend/src/scanner/macVendors.ts | 61 +++++++++++++++++++ apps/frontend/src/routes/HomePage.tsx | 14 +++-- .../frontend/src/routes/admin/DevicesPage.tsx | 20 ++++-- packages/ui/src/ResultsList.tsx | 23 ++++++- 5 files changed, 129 insertions(+), 17 deletions(-) create mode 100644 apps/backend/src/scanner/macVendors.ts diff --git a/apps/backend/src/db/repositories/devices.ts b/apps/backend/src/db/repositories/devices.ts index 62ee22f..3ffd29a 100644 --- a/apps/backend/src/db/repositories/devices.ts +++ b/apps/backend/src/db/repositories/devices.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; import type { Device, DeviceCreateInput, DeviceUpdateInput } from "@launchpad/shared"; import { db } from "../client.js"; import { devices } from "../schema.js"; +import { lookupVendorFromMac } from "../../scanner/macVendors.js"; function nowIso(): string { return new Date().toISOString(); @@ -34,6 +35,8 @@ export function getDevice(id: string): Device | null { export function createDevice(input: DeviceCreateInput): Device { const id = randomUUID(); const timestamp = nowIso(); + const manufacturer = + input.manufacturer ?? (input.mac ? lookupVendorFromMac(input.mac) : null); db.insert(devices) .values({ @@ -41,7 +44,7 @@ export function createDevice(input: DeviceCreateInput): Device { hostname: input.hostname, ip: input.ip, mac: input.mac ?? null, - manufacturer: input.manufacturer ?? null, + manufacturer, model: input.model ?? null, online: input.online ?? false, source: input.source ?? "manual", @@ -62,12 +65,22 @@ export function updateDevice(id: string, input: DeviceUpdateInput): Device | nul const existing = getDevice(id); if (!existing) return null; + // Wenn der Nutzer eine MAC einträgt/ändert, aber selbst keinen Hersteller + // angibt UND noch keiner gespeichert ist, automatisch versuchen zu + // erkennen (siehe scanner/macVendors.ts) - überschreibt nie einen bereits + // gesetzten oder gerade explizit eingegebenen Wert. + const autoManufacturer = + input.manufacturer === undefined && !existing.manufacturer && input.mac + ? lookupVendorFromMac(input.mac) + : undefined; + db.update(devices) .set({ ...(input.hostname !== undefined && { hostname: input.hostname }), ...(input.ip !== undefined && { ip: input.ip }), ...(input.mac !== undefined && { mac: input.mac }), ...(input.manufacturer !== undefined && { manufacturer: input.manufacturer }), + ...(autoManufacturer && { manufacturer: autoManufacturer }), ...(input.model !== undefined && { model: input.model }), ...(input.online !== undefined && { online: input.online }), ...(input.source !== undefined && { source: input.source }), @@ -118,14 +131,19 @@ function findByMacOrIp(mac: string | null | undefined, ip: string): Device | nul export function upsertDeviceFromScan(input: DeviceScanInput): Device { const existing = findByMacOrIp(input.mac, input.ip); const timestamp = nowIso(); + const mac = input.mac ?? existing?.mac ?? null; + const manufacturer = + input.manufacturer ?? + existing?.manufacturer ?? + (mac ? lookupVendorFromMac(mac) : null); if (existing) { db.update(devices) .set({ hostname: input.hostname, ip: input.ip, - mac: input.mac ?? existing.mac, - manufacturer: input.manufacturer ?? existing.manufacturer, + mac, + manufacturer, model: input.model ?? existing.model, online: input.online ?? existing.online, source: input.source, @@ -143,8 +161,8 @@ export function upsertDeviceFromScan(input: DeviceScanInput): Device { id, hostname: input.hostname, ip: input.ip, - mac: input.mac ?? null, - manufacturer: input.manufacturer ?? null, + mac, + manufacturer, model: input.model ?? null, online: input.online ?? false, source: input.source, diff --git a/apps/backend/src/scanner/macVendors.ts b/apps/backend/src/scanner/macVendors.ts new file mode 100644 index 0000000..9c3ec50 --- /dev/null +++ b/apps/backend/src/scanner/macVendors.ts @@ -0,0 +1,61 @@ +/** + * Ordnet die ersten drei Oktette einer MAC-Adresse (OUI-Präfix) einem + * Hersteller zu. Bewusst eine KLEINE, aber jeweils einzeln online + * verifizierte Auswahl (nicht aus dem Gedächtnis geraten) - NICHT die + * vollständige IEEE-OUI-Registrierung (die hat >50.000 Einträge). Komplett + * offline zur Laufzeit, kein externer Dienst. + * + * Deckt bewusst die im Homelab-Kontext häufigsten "Kern"-Geräte ab: Single- + * Board-Computer, NAS-Systeme und Virtualisierung. Router/Switches/IoT + * wurden nicht aufgenommen, weil sich die exakten Präfixe nicht ohne + * weitere ausführliche Recherche verlässlich verifizieren ließen - lieber + * gar keinen Treffer als einen falschen. + * + * Unbekannte Präfixe liefern null, das Feld bleibt dann leer und frei + * editierbar. Bereits gesetzte Werte werden nie überschrieben (siehe + * Aufrufer in devices.ts). + */ +const OUI_VENDORS: Record = { + // Raspberry Pi Foundation / Raspberry Pi Trading Ltd + // Quelle: forums.raspberrypi.com/viewtopic.php?t=347926, maclookup.app/macaddress/b827eb + "28CDC1": "Raspberry Pi", + B827EB: "Raspberry Pi", + D83ADD: "Raspberry Pi", + DCA632: "Raspberry Pi", + E45F01: "Raspberry Pi", + + // VMware (virtuelle Netzwerkkarten) + // Quelle: maclookup.app/macaddress/005056, maclookup.app/macaddress/000c29 + "005056": "VMware (VM)", + "000C29": "VMware (VM)", + + // Synology + // Quelle: maclookup.app/vendors/synology-incorporated + "001132": "Synology", + "9009D0": "Synology", + + // QNAP + // Quelle: maclookup.app/vendors/qnap-systems-inc + "245EBE": "QNAP", + E843B6: "QNAP", + + // QEMU/KVM (Standard-Präfix für automatisch generierte VM-MACs, + // u. a. von Proxmox VE verwendet) - allgemein bekannte Konvention. + "525400": "QEMU/Proxmox (VM)", + + // Docker (docker0-Bridge-Netzwerk) - Software-Konvention, kein + // IEEE-registriertes OUI, aber in der Praxis sehr zuverlässig. + "0242AC": "Docker (virtuell)", +}; + +function normalizeOui(mac: string): string | null { + const cleaned = mac.replace(/[^0-9A-Fa-f]/g, "").toUpperCase(); + if (cleaned.length < 6) return null; + return cleaned.slice(0, 6); +} + +export function lookupVendorFromMac(mac: string): string | null { + const oui = normalizeOui(mac); + if (!oui) return null; + return OUI_VENDORS[oui] ?? null; +} diff --git a/apps/frontend/src/routes/HomePage.tsx b/apps/frontend/src/routes/HomePage.tsx index 02f8d20..9553a7b 100644 --- a/apps/frontend/src/routes/HomePage.tsx +++ b/apps/frontend/src/routes/HomePage.tsx @@ -97,6 +97,7 @@ export function HomePage() { const { data: readLaterItems } = useReadLater(); const inputRef = useRef(null); const searchContainerRef = useRef(null); + const resultsContainerRef = useRef(null); const queryClient = useQueryClient(); const isSearching = query.trim().length > 0; const isLoading = servicesLoading || bookmarksLoading; @@ -175,11 +176,16 @@ export function HomePage() { }, [isSearching]); // Klick außerhalb von Suchfeld+Trefferliste klappt das Dropdown wieder ein - // (Desktop-Verhalten, Text bleibt erhalten). + // (Desktop-Verhalten, Text bleibt erhalten). Trefferliste liegt als + // eigener Scroll-Container außerhalb von searchContainerRef, daher beide + // prüfen - sonst schließt ein Klick auf einen Treffer das Dropdown, bevor + // dessen eigener onClick überhaupt feuert. useEffect(() => { function onPointerDown(e: MouseEvent) { - if (!searchContainerRef.current) return; - if (!searchContainerRef.current.contains(e.target as Node)) { + const target = e.target as Node; + const insideSearch = searchContainerRef.current?.contains(target); + const insideResults = resultsContainerRef.current?.contains(target); + if (!insideSearch && !insideResults) { setResultsVisible(false); } } @@ -340,7 +346,7 @@ export function HomePage() { {/* Scrollender Bereich: NUR die Trefferliste scrollt, nicht die ganze Seite */} {isSearching && resultsVisible ? ( -
+
{isLoading ? (

Lade …

diff --git a/apps/frontend/src/routes/admin/DevicesPage.tsx b/apps/frontend/src/routes/admin/DevicesPage.tsx index 3301cf9..1bb67cb 100644 --- a/apps/frontend/src/routes/admin/DevicesPage.tsx +++ b/apps/frontend/src/routes/admin/DevicesPage.tsx @@ -86,7 +86,7 @@ function EditDeviceForm({ device, onDone }: { device: DeviceWithServices; onDone return ( - +
@@ -246,6 +246,8 @@ function DeviceRow({ device }: { device: DeviceWithServices }) { {device.mac ?? "–"} + {device.manufacturer ?? "–"} + {device.model ?? "–"} 0) && ( - + {scanMessage ? (

{scanMessage}

) : null} @@ -306,7 +308,7 @@ function DeviceRow({ device }: { device: DeviceWithServices }) { {expanded && ( - + {device.services.length === 0 ? (

Keine Dienste auf diesem Gerät.

) : ( @@ -393,7 +395,7 @@ function AddDeviceForm() { ); } -type SortColumn = "hostname" | "ip" | "mac" | "online" | "services" | null; +type SortColumn = "hostname" | "ip" | "mac" | "manufacturer" | "model" | "online" | "services" | null; function SortableHeader({ label, @@ -444,6 +446,12 @@ export function DevicesPage() { case "mac": cmp = (a.mac ?? "").localeCompare(b.mac ?? ""); break; + case "manufacturer": + cmp = (a.manufacturer ?? "").localeCompare(b.manufacturer ?? ""); + break; + case "model": + cmp = (a.model ?? "").localeCompare(b.model ?? ""); + break; case "online": cmp = Number(a.online) - Number(b.online); break; @@ -483,13 +491,15 @@ export function DevicesPage() { ) : list.length > 0 ? (
- +
+ +
diff --git a/packages/ui/src/ResultsList.tsx b/packages/ui/src/ResultsList.tsx index 44c2f51..63176cc 100644 --- a/packages/ui/src/ResultsList.tsx +++ b/packages/ui/src/ResultsList.tsx @@ -2,6 +2,16 @@ import { useState, type KeyboardEvent } from "react"; import type { SearchResult } from "@launchpad/shared"; import { Favicon } from "./Favicon.js"; +function hexToRgba(hex: string, alpha: number): string | null { + const clean = hex.replace("#", ""); + if (clean.length !== 6) return null; + const r = parseInt(clean.slice(0, 2), 16); + const g = parseInt(clean.slice(2, 4), 16); + const b = parseInt(clean.slice(4, 6), 16); + if ([r, g, b].some((n) => Number.isNaN(n))) return null; + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + export interface ResultsListProps { results: SearchResult[]; selectedIndex: number; @@ -64,11 +74,18 @@ export function ResultsList({ onKeyDown={(e: KeyboardEvent) => { if (e.key === "Enter") onOpen(item); }} + style={ + categoryColor + ? { backgroundColor: hexToRgba(categoryColor, active ? 0.18 : 0.1) ?? undefined } + : undefined + } className={`flex w-full cursor-pointer items-center gap-3 px-5 py-3 text-left transition-colors ${ - active - ? "bg-black/5 dark:bg-white/10" - : "hover:bg-black/[0.03] dark:hover:bg-white/5" + categoryColor + ? "" + : active + ? "bg-black/5 dark:bg-white/10" + : "hover:bg-black/[0.03] dark:hover:bg-white/5" }`} >