generated from Dicken/dickendock
Hersteller-Auto-Erkennung ueber MAC (verifiziert), Geraete-Tabelle Hersteller/Modell-Spalten, Dropdown-Klick-Bug, Kategorie-Farbe als Hintergrund
This commit is contained in:
@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
|
|||||||
import type { Device, DeviceCreateInput, DeviceUpdateInput } from "@launchpad/shared";
|
import type { Device, DeviceCreateInput, DeviceUpdateInput } from "@launchpad/shared";
|
||||||
import { db } from "../client.js";
|
import { db } from "../client.js";
|
||||||
import { devices } from "../schema.js";
|
import { devices } from "../schema.js";
|
||||||
|
import { lookupVendorFromMac } from "../../scanner/macVendors.js";
|
||||||
|
|
||||||
function nowIso(): string {
|
function nowIso(): string {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
@@ -34,6 +35,8 @@ export function getDevice(id: string): Device | null {
|
|||||||
export function createDevice(input: DeviceCreateInput): Device {
|
export function createDevice(input: DeviceCreateInput): Device {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const timestamp = nowIso();
|
const timestamp = nowIso();
|
||||||
|
const manufacturer =
|
||||||
|
input.manufacturer ?? (input.mac ? lookupVendorFromMac(input.mac) : null);
|
||||||
|
|
||||||
db.insert(devices)
|
db.insert(devices)
|
||||||
.values({
|
.values({
|
||||||
@@ -41,7 +44,7 @@ export function createDevice(input: DeviceCreateInput): Device {
|
|||||||
hostname: input.hostname,
|
hostname: input.hostname,
|
||||||
ip: input.ip,
|
ip: input.ip,
|
||||||
mac: input.mac ?? null,
|
mac: input.mac ?? null,
|
||||||
manufacturer: input.manufacturer ?? null,
|
manufacturer,
|
||||||
model: input.model ?? null,
|
model: input.model ?? null,
|
||||||
online: input.online ?? false,
|
online: input.online ?? false,
|
||||||
source: input.source ?? "manual",
|
source: input.source ?? "manual",
|
||||||
@@ -62,12 +65,22 @@ export function updateDevice(id: string, input: DeviceUpdateInput): Device | nul
|
|||||||
const existing = getDevice(id);
|
const existing = getDevice(id);
|
||||||
if (!existing) return null;
|
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)
|
db.update(devices)
|
||||||
.set({
|
.set({
|
||||||
...(input.hostname !== undefined && { hostname: input.hostname }),
|
...(input.hostname !== undefined && { hostname: input.hostname }),
|
||||||
...(input.ip !== undefined && { ip: input.ip }),
|
...(input.ip !== undefined && { ip: input.ip }),
|
||||||
...(input.mac !== undefined && { mac: input.mac }),
|
...(input.mac !== undefined && { mac: input.mac }),
|
||||||
...(input.manufacturer !== undefined && { manufacturer: input.manufacturer }),
|
...(input.manufacturer !== undefined && { manufacturer: input.manufacturer }),
|
||||||
|
...(autoManufacturer && { manufacturer: autoManufacturer }),
|
||||||
...(input.model !== undefined && { model: input.model }),
|
...(input.model !== undefined && { model: input.model }),
|
||||||
...(input.online !== undefined && { online: input.online }),
|
...(input.online !== undefined && { online: input.online }),
|
||||||
...(input.source !== undefined && { source: input.source }),
|
...(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 {
|
export function upsertDeviceFromScan(input: DeviceScanInput): Device {
|
||||||
const existing = findByMacOrIp(input.mac, input.ip);
|
const existing = findByMacOrIp(input.mac, input.ip);
|
||||||
const timestamp = nowIso();
|
const timestamp = nowIso();
|
||||||
|
const mac = input.mac ?? existing?.mac ?? null;
|
||||||
|
const manufacturer =
|
||||||
|
input.manufacturer ??
|
||||||
|
existing?.manufacturer ??
|
||||||
|
(mac ? lookupVendorFromMac(mac) : null);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
db.update(devices)
|
db.update(devices)
|
||||||
.set({
|
.set({
|
||||||
hostname: input.hostname,
|
hostname: input.hostname,
|
||||||
ip: input.ip,
|
ip: input.ip,
|
||||||
mac: input.mac ?? existing.mac,
|
mac,
|
||||||
manufacturer: input.manufacturer ?? existing.manufacturer,
|
manufacturer,
|
||||||
model: input.model ?? existing.model,
|
model: input.model ?? existing.model,
|
||||||
online: input.online ?? existing.online,
|
online: input.online ?? existing.online,
|
||||||
source: input.source,
|
source: input.source,
|
||||||
@@ -143,8 +161,8 @@ export function upsertDeviceFromScan(input: DeviceScanInput): Device {
|
|||||||
id,
|
id,
|
||||||
hostname: input.hostname,
|
hostname: input.hostname,
|
||||||
ip: input.ip,
|
ip: input.ip,
|
||||||
mac: input.mac ?? null,
|
mac,
|
||||||
manufacturer: input.manufacturer ?? null,
|
manufacturer,
|
||||||
model: input.model ?? null,
|
model: input.model ?? null,
|
||||||
online: input.online ?? false,
|
online: input.online ?? false,
|
||||||
source: input.source,
|
source: input.source,
|
||||||
|
|||||||
61
apps/backend/src/scanner/macVendors.ts
Normal file
61
apps/backend/src/scanner/macVendors.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
@@ -97,6 +97,7 @@ export function HomePage() {
|
|||||||
const { data: readLaterItems } = useReadLater();
|
const { data: readLaterItems } = useReadLater();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const searchContainerRef = useRef<HTMLDivElement>(null);
|
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const resultsContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isSearching = query.trim().length > 0;
|
const isSearching = query.trim().length > 0;
|
||||||
const isLoading = servicesLoading || bookmarksLoading;
|
const isLoading = servicesLoading || bookmarksLoading;
|
||||||
@@ -175,11 +176,16 @@ export function HomePage() {
|
|||||||
}, [isSearching]);
|
}, [isSearching]);
|
||||||
|
|
||||||
// Klick außerhalb von Suchfeld+Trefferliste klappt das Dropdown wieder ein
|
// 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(() => {
|
useEffect(() => {
|
||||||
function onPointerDown(e: MouseEvent) {
|
function onPointerDown(e: MouseEvent) {
|
||||||
if (!searchContainerRef.current) return;
|
const target = e.target as Node;
|
||||||
if (!searchContainerRef.current.contains(e.target as Node)) {
|
const insideSearch = searchContainerRef.current?.contains(target);
|
||||||
|
const insideResults = resultsContainerRef.current?.contains(target);
|
||||||
|
if (!insideSearch && !insideResults) {
|
||||||
setResultsVisible(false);
|
setResultsVisible(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,7 +346,7 @@ export function HomePage() {
|
|||||||
|
|
||||||
{/* Scrollender Bereich: NUR die Trefferliste scrollt, nicht die ganze Seite */}
|
{/* Scrollender Bereich: NUR die Trefferliste scrollt, nicht die ganze Seite */}
|
||||||
{isSearching && resultsVisible ? (
|
{isSearching && resultsVisible ? (
|
||||||
<div className="min-h-0 flex-1 px-6 pb-4">
|
<div ref={resultsContainerRef} className="min-h-0 flex-1 px-6 pb-4">
|
||||||
<div className="mx-auto h-full max-w-xl">
|
<div className="mx-auto h-full max-w-xl">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="text-center text-sm text-black/40 dark:text-white/40">Lade …</p>
|
<p className="text-center text-sm text-black/40 dark:text-white/40">Lade …</p>
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ function EditDeviceForm({ device, onDone }: { device: DeviceWithServices; onDone
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="border-b border-black/5 bg-black/[0.02] last:border-0 dark:border-white/5 dark:bg-white/5">
|
<tr className="border-b border-black/5 bg-black/[0.02] last:border-0 dark:border-white/5 dark:bg-white/5">
|
||||||
<td colSpan={6} className="px-4 py-3">
|
<td colSpan={8} className="px-4 py-3">
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Hostname</label>
|
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Hostname</label>
|
||||||
@@ -246,6 +246,8 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
|
|||||||
<td className="px-4 py-3 font-mono text-xs text-black/40 dark:text-white/40">
|
<td className="px-4 py-3 font-mono text-xs text-black/40 dark:text-white/40">
|
||||||
{device.mac ?? "–"}
|
{device.mac ?? "–"}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3 text-black/60 dark:text-white/60">{device.manufacturer ?? "–"}</td>
|
||||||
|
<td className="px-4 py-3 text-black/60 dark:text-white/60">{device.model ?? "–"}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1.5 text-xs ${
|
className={`inline-flex items-center gap-1.5 text-xs ${
|
||||||
@@ -293,7 +295,7 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
|
|||||||
|
|
||||||
{(scanMessage || staleServices.length > 0) && (
|
{(scanMessage || staleServices.length > 0) && (
|
||||||
<tr className="border-b border-black/5 dark:border-white/5">
|
<tr className="border-b border-black/5 dark:border-white/5">
|
||||||
<td colSpan={6} className="px-4 pb-3">
|
<td colSpan={8} className="px-4 pb-3">
|
||||||
{scanMessage ? (
|
{scanMessage ? (
|
||||||
<p className="text-xs text-black/40 dark:text-white/40">{scanMessage}</p>
|
<p className="text-xs text-black/40 dark:text-white/40">{scanMessage}</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -306,7 +308,7 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
|
|||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<tr className="border-b border-black/5 bg-black/[0.015] dark:border-white/5 dark:bg-white/[0.02]">
|
<tr className="border-b border-black/5 bg-black/[0.015] dark:border-white/5 dark:bg-white/[0.02]">
|
||||||
<td colSpan={6} className="px-4 py-3">
|
<td colSpan={8} className="px-4 py-3">
|
||||||
{device.services.length === 0 ? (
|
{device.services.length === 0 ? (
|
||||||
<p className="text-xs text-black/40 dark:text-white/40">Keine Dienste auf diesem Gerät.</p>
|
<p className="text-xs text-black/40 dark:text-white/40">Keine Dienste auf diesem Gerät.</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -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({
|
function SortableHeader({
|
||||||
label,
|
label,
|
||||||
@@ -444,6 +446,12 @@ export function DevicesPage() {
|
|||||||
case "mac":
|
case "mac":
|
||||||
cmp = (a.mac ?? "").localeCompare(b.mac ?? "");
|
cmp = (a.mac ?? "").localeCompare(b.mac ?? "");
|
||||||
break;
|
break;
|
||||||
|
case "manufacturer":
|
||||||
|
cmp = (a.manufacturer ?? "").localeCompare(b.manufacturer ?? "");
|
||||||
|
break;
|
||||||
|
case "model":
|
||||||
|
cmp = (a.model ?? "").localeCompare(b.model ?? "");
|
||||||
|
break;
|
||||||
case "online":
|
case "online":
|
||||||
cmp = Number(a.online) - Number(b.online);
|
cmp = Number(a.online) - Number(b.online);
|
||||||
break;
|
break;
|
||||||
@@ -483,13 +491,15 @@ export function DevicesPage() {
|
|||||||
) : list.length > 0 ? (
|
) : list.length > 0 ? (
|
||||||
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
|
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[760px] text-sm">
|
<table className="w-full min-w-[980px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-black/10 bg-black/[0.02] text-left text-xs
|
<tr className="border-b border-black/10 bg-black/[0.02] text-left text-xs
|
||||||
uppercase tracking-wide text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white/40">
|
uppercase tracking-wide text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white/40">
|
||||||
<SortableHeader label="Gerät" column="hostname" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
<SortableHeader label="Gerät" column="hostname" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||||||
<SortableHeader label="IP" column="ip" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
<SortableHeader label="IP" column="ip" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||||||
<SortableHeader label="MAC" column="mac" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
<SortableHeader label="MAC" column="mac" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||||||
|
<SortableHeader label="Hersteller" column="manufacturer" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||||||
|
<SortableHeader label="Modell" column="model" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||||||
<SortableHeader label="Status" column="online" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
<SortableHeader label="Status" column="online" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||||||
<SortableHeader label="Dienste" column="services" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
<SortableHeader label="Dienste" column="services" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||||||
<th className="px-4 py-2" />
|
<th className="px-4 py-2" />
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ import { useState, type KeyboardEvent } from "react";
|
|||||||
import type { SearchResult } from "@launchpad/shared";
|
import type { SearchResult } from "@launchpad/shared";
|
||||||
import { Favicon } from "./Favicon.js";
|
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 {
|
export interface ResultsListProps {
|
||||||
results: SearchResult[];
|
results: SearchResult[];
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
@@ -64,11 +74,18 @@ export function ResultsList({
|
|||||||
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
|
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
if (e.key === "Enter") onOpen(item);
|
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
|
className={`flex w-full cursor-pointer items-center gap-3 px-5 py-3 text-left
|
||||||
transition-colors ${
|
transition-colors ${
|
||||||
active
|
categoryColor
|
||||||
? "bg-black/5 dark:bg-white/10"
|
? ""
|
||||||
: "hover:bg-black/[0.03] dark:hover:bg-white/5"
|
: active
|
||||||
|
? "bg-black/5 dark:bg-white/10"
|
||||||
|
: "hover:bg-black/[0.03] dark:hover:bg-white/5"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Favicon src={item.favicon} fallbackLetter={item.displayName} />
|
<Favicon src={item.favicon} fallbackLetter={item.displayName} />
|
||||||
|
|||||||
Reference in New Issue
Block a user