Files
LaunchPad/apps/frontend/src/routes/admin/ServicesPage.tsx

721 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState, type DragEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faFloppyDisk, faPen, faTrash, faXmark, faSort, faSortUp, faSortDown, faStar as faStarSolid, faEye, faEyeSlash, faArrowLeft, faSignature } from "@fortawesome/free-solid-svg-icons";
import { faStar } from "@fortawesome/free-regular-svg-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button, Favicon, FaviconPicker } from "@launchpad/ui";
import type { Service } from "@launchpad/shared";
import { useServices } from "../../hooks/useServices.js";
import { useBookmarks } from "../../hooks/useBookmarks.js";
import { useCategories } from "../../hooks/useCategories.js";
import { useDevices } from "../../hooks/useDevices.js";
import { AdminPageHeader } from "./AdminPageHeader.js";
interface ServicePatch {
displayName?: string;
category?: string | null;
alias?: string[];
order?: number;
favorite?: boolean;
visible?: boolean;
hostname?: string;
port?: number;
https?: boolean;
url?: string;
}
async function patchService(id: string, patch: ServicePatch): Promise<Service> {
const res = await fetch(`/api/services/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `Dienst konnte nicht aktualisiert werden (HTTP ${res.status})`);
}
return res.json();
}
async function deleteServiceRequest(id: string) {
const res = await fetch(`/api/services/${id}`, { method: "DELETE" });
if (!res.ok && res.status !== 404) {
throw new Error(`Dienst konnte nicht gelöscht werden (HTTP ${res.status})`);
}
}
async function reorderServicesRequest(entries: { id: string; order: number }[]) {
const res = await fetch("/api/services/reorder", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(entries),
});
if (!res.ok) {
throw new Error(`Reihenfolge konnte nicht gespeichert werden (HTTP ${res.status})`);
}
return res.json();
}
const NEW_CATEGORY_VALUE = "__new__";
function CategorySelect({
value,
onChange,
}: {
value: string;
onChange: (value: string) => void;
}) {
const { data: categories } = useCategories();
const isKnown = !value || categories?.some((c) => c.name === value);
const [isNew, setIsNew] = useState(!isKnown);
return (
<div className="flex flex-col gap-1">
<select
value={isNew ? NEW_CATEGORY_VALUE : value}
onChange={(e) => {
if (e.target.value === NEW_CATEGORY_VALUE) {
setIsNew(true);
onChange("");
} else {
setIsNew(false);
onChange(e.target.value);
}
}}
className="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"
>
<option value=""> Keine </option>
{categories?.map((c) => (
<option key={c.id} value={c.name}>
{c.name}
</option>
))}
<option value={NEW_CATEGORY_VALUE}>+ Neue Kategorie </option>
</select>
{isNew ? (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Name der neuen Kategorie"
autoFocus
className="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"
/>
) : null}
</div>
);
}
const EDIT_FORM_COLSPAN = 12;
function EditForm({ service, onDone }: { service: Service; onDone: () => void }) {
const queryClient = useQueryClient();
const { data: allServices } = useServices();
const { data: allBookmarks } = useBookmarks();
const [displayName, setDisplayName] = useState(service.displayName);
const [category, setCategory] = useState(service.category ?? "");
const [alias, setAlias] = useState(service.alias.join(", "));
const [order, setOrder] = useState(String(service.order));
const [hostname, setHostname] = useState(service.hostname);
const [port, setPort] = useState(String(service.port));
const [https, setHttps] = useState(service.https);
// undefined = unverändert (Feld nicht anfassen), null = Nutzer hat's
// explizit entfernt (fällt zurück auf automatische Erkennung/Buchstabe),
// string = neu hochgeladenes Favicon als data:-URL.
const [favicon, setFavicon] = useState<string | null | undefined>(undefined);
const [faviconError, setFaviconError] = useState<string | null>(null);
// Bereits verwendete Favicons (Dienste + Lesezeichen), zur Auswahl im
// "Vorhandenes Favicon wählen"-Picker - z. B. wenn mehrere Dienste
// desselben Geräts eigentlich dasselbe Icon zeigen sollten, der Scan aber
// nur bei einem davon eins gefunden hat.
const existingFavicons = useMemo(() => {
const seen = new Map<string, string>(); // favicon-URL -> Anzeigename für Tooltip
for (const s of allServices ?? []) {
if (s.favicon && s.id !== service.id && !seen.has(s.favicon)) seen.set(s.favicon, s.displayName);
}
for (const b of allBookmarks ?? []) {
if (b.favicon && !seen.has(b.favicon)) seen.set(b.favicon, b.displayName);
}
return Array.from(seen.entries());
}, [allServices, allBookmarks, service.id]);
const FAVICON_MAX_BYTES = 300 * 1024;
function handleFaviconFile(file: File | undefined) {
setFaviconError(null);
if (!file) return;
if (file.size > FAVICON_MAX_BYTES) {
setFaviconError("Datei zu groß (max. 300 KB) bitte ein kleineres Bild wählen.");
return;
}
const reader = new FileReader();
reader.onload = () => setFavicon(reader.result as string);
reader.onerror = () => setFaviconError("Datei konnte nicht gelesen werden.");
reader.readAsDataURL(file);
}
const portNumber = Number(port) || service.port;
const previewUrl = `${https ? "https" : "http"}://${hostname || service.hostname}:${portNumber}`;
const faviconPreview = favicon === undefined ? service.favicon : favicon;
const mutation = useMutation({
mutationFn: () => {
const patch: Record<string, unknown> = {
alias: alias
.split(",")
.map((a) => a.trim())
.filter(Boolean),
order: Number(order) || 0,
hostname: hostname.trim(),
port: portNumber,
https,
url: previewUrl,
};
// displayName/category nur mitschicken, wenn sich der Wert tatsächlich
// geändert hat - sonst würde jedes Speichern (z. B. nur um den Port zu
// korrigieren) die *EditedManually-Flag fälschlich setzen, obwohl der
// Nutzer diese beiden Felder gar nicht angefasst hat (siehe
// upsertServiceFromScan/updateService-Doku in services.ts).
const trimmedName = displayName.trim();
if (trimmedName !== service.displayName) patch.displayName = trimmedName;
const trimmedCategory = category.trim() || null;
if (trimmedCategory !== service.category) patch.category = trimmedCategory;
if (favicon !== undefined) patch.favicon = favicon;
return patchService(service.id, patch);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["services"] });
queryClient.invalidateQueries({ queryKey: ["categories"] });
onDone();
},
});
return (
<tr className="border-b border-black/5 bg-black/[0.02] last:border-0 dark:border-white/5 dark:bg-white/5">
<td colSpan={EDIT_FORM_COLSPAN} className="px-4 py-3">
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Favicon</label>
<div className="flex items-center gap-2">
<Favicon src={faviconPreview} fallbackLetter={displayName} size="sm" />
<label className="cursor-pointer rounded-lg border border-black/10 px-2 py-1 text-xs
text-black/70 hover:bg-black/5 dark:border-white/10 dark:text-white/70 dark:hover:bg-white/10">
Hochladen
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleFaviconFile(e.target.files?.[0])}
/>
</label>
<FaviconPicker existingFavicons={existingFavicons} onSelect={(url) => setFavicon(url)} />
{faviconPreview ? (
<button
type="button"
onClick={() => setFavicon(null)}
className="text-xs text-black/40 underline dark:text-white/40"
>
Entfernen
</button>
) : null}
</div>
{faviconError ? <p className="mt-1 text-xs text-red-500">{faviconError}</p> : null}
</div>
<div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="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"
/>
</div>
<div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Kategorie</label>
<CategorySelect value={category} onChange={setCategory} />
</div>
<div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">
Alias (kommagetrennt)
</label>
<input
value={alias}
onChange={(e) => setAlias(e.target.value)}
className="w-48 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"
/>
</div>
<div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Reihenfolge</label>
<input
value={order}
onChange={(e) => setOrder(e.target.value)}
type="number"
className="w-20 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"
/>
</div>
<div className="w-full border-t border-black/10 pt-3 dark:border-white/10" />
<div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">
Hostname / IP
</label>
<input
value={hostname}
onChange={(e) => setHostname(e.target.value)}
className="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"
/>
</div>
<div>
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Port</label>
<input
value={port}
onChange={(e) => setPort(e.target.value)}
type="number"
min={1}
max={65535}
className="w-24 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"
/>
</div>
<div>
<label className="mb-1 flex items-center gap-1.5 text-xs text-black/50 dark:text-white/50">
<input
type="checkbox"
checked={https}
onChange={(e) => setHttps(e.target.checked)}
className="rounded"
/>
HTTPS
</label>
</div>
<div className="text-xs text-black/40 dark:text-white/40">
Öffnet: <span className="font-mono">{previewUrl}</span>
</div>
<div className="flex w-full items-center gap-2 pt-1">
<Button size="icon" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending} title="Speichern" aria-label="Speichern"><FontAwesomeIcon icon={faFloppyDisk} /></Button>
<Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen"><FontAwesomeIcon icon={faXmark} /></Button>
{mutation.isError ? (
<span className="text-xs text-red-500">{(mutation.error as Error).message}</span>
) : null}
</div>
</div>
</td>
</tr>
);
}
function ServiceRow({
service,
deviceMac,
deviceIp,
deviceHostname,
draggable,
onDragStart,
onDragOver,
onDrop,
isDragging,
}: {
service: Service;
deviceMac: string | null;
deviceIp: string | null;
deviceHostname: string | null;
draggable: boolean;
onDragStart: (e: DragEvent<HTMLTableRowElement>) => void;
onDragOver: (e: DragEvent<HTMLTableRowElement>) => void;
onDrop: (e: DragEvent<HTMLTableRowElement>) => void;
isDragging: boolean;
}) {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const favoriteMutation = useMutation({
mutationFn: () => patchService(service.id, { favorite: !service.favorite }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }),
});
const visibleMutation = useMutation({
mutationFn: () => patchService(service.id, { visible: !service.visible }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }),
});
const deleteMutation = useMutation({
mutationFn: () => deleteServiceRequest(service.id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["services"] }),
});
if (editing) {
return <EditForm service={service} onDone={() => setEditing(false)} />;
}
return (
<tr
draggable={draggable}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
className={`border-b border-black/5 last:border-0 dark:border-white/5 ${
service.visible ? "" : "opacity-50"
} ${isDragging ? "opacity-40" : ""}`}
>
<td className="px-2 py-3 text-center">
<span
className={`select-none ${draggable ? "cursor-grab text-black/30 dark:text-white/30" : "text-black/10 dark:text-white/10"}`}
aria-hidden
>
</span>
</td>
<td className="px-2 py-3">
<div className="flex items-center gap-1.5">
<button
onClick={() => favoriteMutation.mutate()}
aria-label={service.favorite ? "Favorit entfernen" : "Als Favorit markieren"}
className={`text-lg ${service.favorite ? "text-amber-500" : "text-black/15 hover:text-amber-400 dark:text-white/15"}`}
>
<FontAwesomeIcon icon={service.favorite ? faStarSolid : faStar} />
</button>
<button
onClick={() => visibleMutation.mutate()}
aria-label={service.visible ? "In der Suche ausblenden" : "In der Suche einblenden"}
title={service.visible ? "In der Suche sichtbar" : "In der Suche ausgeblendet"}
className={`text-base ${service.visible ? "text-black/40 dark:text-white/40" : "text-black/60 dark:text-white/60"}`}
>
{service.visible ? <FontAwesomeIcon icon={faEye} /> : <FontAwesomeIcon icon={faEyeSlash} />}
</button>
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Favicon src={service.favicon} fallbackLetter={service.displayName} size="sm" />
<div className="flex items-center gap-2">
<span className="font-medium text-black dark:text-white">{service.displayName}</span>
{service.displayNameEditedManually ? (
<span title="Name wurde manuell angepasst Scan-Vorschläge dafür werden nicht mehr angezeigt">
<FontAwesomeIcon icon={faSignature} className="text-black/40 dark:text-white/40" />
</span>
) : null}
{!service.visible ? (
<span className="rounded-full bg-black/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-black/50 dark:bg-white/10 dark:text-white/50">
ausgeblendet
</span>
) : null}
</div>
</div>
</td>
<td className="px-4 py-3 font-mono text-xs text-black/60 dark:text-white/60">
{/* Der gespeicherte Hostname ist oft nur die IP (wenn DNS nichts
Besseres auflösen konnte) - dann lieber den bekannten Gerätenamen
zeigen statt der IP doppelt (die steht schon in der Spalte
daneben). Der technische Wert bleibt beim Bearbeiten unverändert
editierbar/verwendbar, hier geht es nur um die Anzeige. */}
{service.hostname === deviceIp && deviceHostname ? (
<span title={`Technisch: ${service.hostname}`}>{deviceHostname}</span>
) : (
service.hostname
)}
</td>
<td className="px-4 py-3 font-mono text-xs text-black/60 dark:text-white/60">
{deviceIp ?? ""}
</td>
<td className="px-4 py-3 text-black/60 dark:text-white/60">
<span className="inline-flex items-center gap-1">
{service.category ?? ""}
{service.categoryEditedManually ? (
<span title="Kategorie wurde manuell angepasst Scan-Vorschläge dafür werden nicht mehr angezeigt">
<FontAwesomeIcon icon={faSignature} className="text-black/40 dark:text-white/40" />
</span>
) : null}
</span>
</td>
<td className="px-4 py-3 font-mono text-xs text-black/40 dark:text-white/40">
{deviceMac ?? ""}
</td>
<td className="px-4 py-3 text-black/60 dark:text-white/60">
{service.alias.length > 0 ? service.alias.join(", ") : ""}
</td>
<td className="px-4 py-3 font-mono text-black/60 dark:text-white/60">{service.port}</td>
<td className="px-4 py-3">
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
service.https
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "bg-black/5 text-black/50 dark:bg-white/10 dark:text-white/50"
}`}
>
{service.https ? "https" : "http"}
</span>
</td>
<td className="px-4 py-3">
<a
href={service.url}
target="_blank"
rel="noopener noreferrer"
className="text-black/60 underline decoration-black/20 hover:text-black dark:text-white/60 dark:decoration-white/20 dark:hover:text-white"
>
öffnen
</a>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten"><FontAwesomeIcon icon={faPen} /></Button>
<Button size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen"><FontAwesomeIcon icon={faTrash} /></Button>
</div>
</td>
</tr>
);
}
type SortColumn = "displayName" | "hostname" | "ip" | "category" | "alias" | "port" | "https" | null;
function SortableHeader({
label,
column,
activeColumn,
direction,
onClick,
}: {
label: string;
column: SortColumn;
activeColumn: SortColumn;
direction: "asc" | "desc";
onClick: (column: SortColumn) => void;
}) {
const active = activeColumn === column;
return (
<th className="px-4 py-2 font-medium">
<button
onClick={() => onClick(column)}
className={`flex items-center gap-1 hover:text-black dark:hover:text-white ${
active ? "text-black dark:text-white" : ""
}`}
>
{label}
<span className="text-[10px]"><FontAwesomeIcon icon={active ? (direction === "asc" ? faSortUp : faSortDown) : faSort} /></span>
</button>
</th>
);
}
export function ServicesPage() {
const { data: services, isLoading, isError } = useServices();
const { data: devices } = useDevices();
const queryClient = useQueryClient();
const [draggedId, setDraggedId] = useState<string | null>(null);
const [localOrder, setLocalOrder] = useState<Service[] | null>(null);
const [sortColumn, setSortColumn] = useState<SortColumn>(null);
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [activeTab, setActiveTab] = useState<"visible" | "hidden">("visible");
const macByDeviceId = useMemo(() => {
const map: Record<string, string | null> = {};
for (const d of devices ?? []) map[d.id] = d.mac;
return map;
}, [devices]);
const ipByDeviceId = useMemo(() => {
const map: Record<string, string | null> = {};
for (const d of devices ?? []) map[d.id] = d.ip;
return map;
}, [devices]);
const hostnameByDeviceId = useMemo(() => {
const map: Record<string, string | null> = {};
for (const d of devices ?? []) map[d.id] = d.hostname;
return map;
}, [devices]);
const reorderMutation = useMutation({
mutationFn: reorderServicesRequest,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["services"] });
setLocalOrder(null);
},
onError: () => setLocalOrder(null),
});
const baseList = localOrder ?? services ?? [];
const hiddenCount = services?.filter((s) => !s.visible).length ?? 0;
const visibleCount = services ? services.length - hiddenCount : 0;
const tabFilteredBase = useMemo(
() => baseList.filter((s) => (activeTab === "visible" ? s.visible : !s.visible)),
[baseList, activeTab]
);
const list = useMemo(() => {
if (!sortColumn) return tabFilteredBase;
const sorted = [...tabFilteredBase].sort((a, b) => {
let cmp = 0;
switch (sortColumn) {
case "displayName":
cmp = a.displayName.localeCompare(b.displayName);
break;
case "hostname":
cmp = a.hostname.localeCompare(b.hostname);
break;
case "ip":
cmp = (ipByDeviceId[a.deviceId] ?? "").localeCompare(
ipByDeviceId[b.deviceId] ?? "",
undefined,
{ numeric: true }
);
break;
case "category":
cmp = (a.category ?? "").localeCompare(b.category ?? "");
break;
case "alias":
cmp = a.alias.join(",").localeCompare(b.alias.join(","));
break;
case "port":
cmp = a.port - b.port;
break;
case "https":
cmp = Number(a.https) - Number(b.https);
break;
}
return sortDirection === "asc" ? cmp : -cmp;
});
return sorted;
}, [tabFilteredBase, sortColumn, sortDirection, ipByDeviceId]);
function handleHeaderClick(column: SortColumn) {
if (sortColumn === column) {
setSortDirection((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortColumn(column);
setSortDirection("asc");
}
}
const dragEnabled = sortColumn === null;
function handleDragStart(id: string) {
return (_e: DragEvent<HTMLTableRowElement>) => setDraggedId(id);
}
function handleDragOver(targetId: string) {
return (e: DragEvent<HTMLTableRowElement>) => {
e.preventDefault();
if (!dragEnabled || !draggedId || draggedId === targetId) return;
const current = localOrder ?? services ?? [];
const fromIndex = current.findIndex((s) => s.id === draggedId);
const toIndex = current.findIndex((s) => s.id === targetId);
if (fromIndex === -1 || toIndex === -1) return;
const next = [...current];
const [moved] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, moved);
setLocalOrder(next);
};
}
function handleDrop() {
return (e: DragEvent<HTMLTableRowElement>) => {
e.preventDefault();
if (!dragEnabled) return;
setDraggedId(null);
const current = localOrder ?? services ?? [];
reorderMutation.mutate(current.map((s, index) => ({ id: s.id, order: index })));
};
}
return (
<div>
<AdminPageHeader
title="Dienste"
description="Spaltenköpfe anklickbar zum Sortieren; Drag & Drop (⠿⠿) nur in der Standard-Reihenfolge. Name, Kategorie, Alias, IP/Port/Protokoll und Reihenfolge bleiben bei erneuten Scans erhalten."
/>
<div className="mb-4 flex gap-1 border-b border-black/10 dark:border-white/10">
<button
onClick={() => setActiveTab("visible")}
className={`px-3 py-2 text-sm font-medium ${
activeTab === "visible"
? "border-b-2 border-black text-black dark:border-white dark:text-white"
: "text-black/40 hover:text-black/70 dark:text-white/40 dark:hover:text-white/70"
}`}
>
Sichtbar ({visibleCount})
</button>
<button
onClick={() => setActiveTab("hidden")}
className={`px-3 py-2 text-sm font-medium ${
activeTab === "hidden"
? "border-b-2 border-black text-black dark:border-white dark:text-white"
: "text-black/40 hover:text-black/70 dark:text-white/40 dark:hover:text-white/70"
}`}
>
<FontAwesomeIcon icon={faEyeSlash} /> Ausgeblendet ({hiddenCount})
</button>
</div>
{sortColumn ? (
<div className="mb-3">
<Button size="sm" variant="ghost" onClick={() => setSortColumn(null)}>
<FontAwesomeIcon icon={faArrowLeft} /> Zur manuellen Reihenfolge (Drag & Drop) zurück
</Button>
</div>
) : null}
{isLoading ? (
<p className="text-sm text-black/40 dark:text-white/40">Lade Dienste </p>
) : isError ? (
<p className="text-sm text-red-500">Dienste konnten nicht geladen werden.</p>
) : list.length > 0 ? (
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
<div className="overflow-x-auto">
<table className="w-full min-w-[1140px] text-sm">
<thead>
<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">
<th className="px-2 py-2" />
<th className="px-2 py-2" />
<SortableHeader label="Dienst" column="displayName" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<SortableHeader label="Host" column="hostname" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<SortableHeader label="IP" column="ip" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<SortableHeader label="Kategorie" column="category" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<th className="px-4 py-2 font-medium">MAC</th>
<SortableHeader label="Alias" column="alias" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<SortableHeader label="Port" column="port" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<SortableHeader label="Protokoll" column="https" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<th className="px-4 py-2 font-medium">URL</th>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody>
{list.map((service) => (
<ServiceRow
key={service.id}
service={service}
deviceMac={macByDeviceId[service.deviceId] ?? null}
deviceIp={ipByDeviceId[service.deviceId] ?? null}
deviceHostname={hostnameByDeviceId[service.deviceId] ?? null}
draggable={dragEnabled}
isDragging={draggedId === service.id}
onDragStart={handleDragStart(service.id)}
onDragOver={handleDragOver(service.id)}
onDrop={handleDrop()}
/>
))}
</tbody>
</table>
</div>
</div>
) : (
<p className="text-sm text-black/40 dark:text-white/40">
{activeTab === "hidden"
? "Keine ausgeblendeten Dienste."
: "Noch keine Dienste vorhanden. Scanne ein Gerät unter „Geräte“, um automatisch welche zu finden, oder importiere eine Liste oben."}
</p>
)}
</div>
);
}