Dashboard-Zahlen (Kategorien, Read Later), Host/IP getrennte Spalten bei Diensten, Scanner-Uebersicht beim Massen-Scan

This commit is contained in:
2026-07-20 17:51:58 +02:00
parent 0580d46000
commit 724f076c8c
3 changed files with 111 additions and 18 deletions

View File

@@ -3,6 +3,7 @@ import { useServices } from "../../hooks/useServices.js";
import { useDevices } from "../../hooks/useDevices.js"; import { useDevices } from "../../hooks/useDevices.js";
import { useCategories } from "../../hooks/useCategories.js"; import { useCategories } from "../../hooks/useCategories.js";
import { useBookmarks } from "../../hooks/useBookmarks.js"; import { useBookmarks } from "../../hooks/useBookmarks.js";
import { useReadLater } from "../../hooks/useReadLater.js";
import { AdminPageHeader } from "./AdminPageHeader.js"; import { AdminPageHeader } from "./AdminPageHeader.js";
function StatCard({ label, value }: { label: string; value: number | string }) { function StatCard({ label, value }: { label: string; value: number | string }) {
@@ -19,6 +20,7 @@ export function DashboardPage() {
const { data: devices } = useDevices(); const { data: devices } = useDevices();
const { data: categories } = useCategories(); const { data: categories } = useCategories();
const { data: bookmarks } = useBookmarks(); const { data: bookmarks } = useBookmarks();
const { data: readLaterItems } = useReadLater();
const onlineDevices = devices?.filter((d) => d.online).length ?? 0; const onlineDevices = devices?.filter((d) => d.online).length ?? 0;
const favoriteServices = services?.filter((s) => s.favorite).length ?? 0; const favoriteServices = services?.filter((s) => s.favorite).length ?? 0;
@@ -46,11 +48,13 @@ export function DashboardPage() {
} }
/> />
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6"> <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-8">
<StatCard label="Geräte" value={devices?.length ?? 0} /> <StatCard label="Geräte" value={devices?.length ?? 0} />
<StatCard label="davon online" value={onlineDevices} /> <StatCard label="davon online" value={onlineDevices} />
<StatCard label="Dienste" value={services?.length ?? 0} /> <StatCard label="Dienste" value={services?.length ?? 0} />
<StatCard label="Lesezeichen" value={bookmarks?.length ?? 0} /> <StatCard label="Lesezeichen" value={bookmarks?.length ?? 0} />
<StatCard label="Kategorien" value={categories?.length ?? 0} />
<StatCard label="Später lesen" value={readLaterItems?.length ?? 0} />
<StatCard label="Favoriten (Dienste)" value={favoriteServices} /> <StatCard label="Favoriten (Dienste)" value={favoriteServices} />
<StatCard label="Favoriten (Lesezeichen)" value={favoriteBookmarks} /> <StatCard label="Favoriten (Lesezeichen)" value={favoriteBookmarks} />
</div> </div>
@@ -88,15 +92,6 @@ export function DashboardPage() {
</div> </div>
)} )}
</div> </div>
<div className="mt-8">
<h2 className="mb-3 text-sm font-semibold text-black/60 dark:text-white/60">
Kategorien
</h2>
<p className="text-sm text-black/40 dark:text-white/40">
{categories?.length ?? 0} Kategorie(n) angelegt.
</p>
</div>
</div> </div>
); );
} }

View File

@@ -1,7 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@launchpad/ui"; import { Button } from "@launchpad/ui";
import type { Device } from "@launchpad/shared"; import type { Device, Service } from "@launchpad/shared";
import { useDevices } from "../../hooks/useDevices.js"; import { useDevices } from "../../hooks/useDevices.js";
import { AdminPageHeader } from "./AdminPageHeader.js"; import { AdminPageHeader } from "./AdminPageHeader.js";
@@ -26,13 +26,76 @@ async function deleteDeviceRequest(id: string) {
} }
} }
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 scanDeviceById(id: string) { async function scanDeviceById(id: string) {
const res = await fetch(`/api/scan/devices/${id}`, { method: "POST" }); const res = await fetch(`/api/scan/devices/${id}`, { method: "POST" });
const body = await res.json(); const body = await res.json();
if (!res.ok) { if (!res.ok) {
throw new Error(body.detail ?? body.error ?? `Scan fehlgeschlagen (HTTP ${res.status})`); throw new Error(body.detail ?? body.error ?? `Scan fehlgeschlagen (HTTP ${res.status})`);
} }
return body as { created: number; updated: number }; return body as { created: number; updated: number; staleServices: Service[] };
}
function StaleServicesReview({
staleServices,
onDone,
}: {
staleServices: Service[];
onDone: () => void;
}) {
const queryClient = useQueryClient();
const [handled, setHandled] = useState<Set<string>>(new Set());
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteServiceRequest(id),
onSuccess: (_data, id) => {
setHandled((prev) => new Set(prev).add(id));
queryClient.invalidateQueries({ queryKey: ["services"] });
},
});
const remaining = staleServices.filter((s) => !handled.has(s.id));
if (remaining.length === 0) return null;
return (
<div className="mt-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
<p className="mb-2 font-medium text-amber-700 dark:text-amber-400">
{remaining.length} Dienst(e) über alle gescannten Geräte hinweg nicht mehr gefunden (Port
nicht mehr offen):
</p>
<ul className="max-h-60 space-y-1 overflow-y-auto">
{remaining.map((s) => (
<li key={s.id} className="flex items-center justify-between gap-2">
<span className="text-black/70 dark:text-white/70">
{s.displayName} ({s.hostname}:{s.port})
</span>
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={() => setHandled((prev) => new Set(prev).add(s.id))}>
Behalten
</Button>
<Button
size="sm"
variant="danger"
onClick={() => deleteMutation.mutate(s.id)}
disabled={deleteMutation.isPending}
>
Entfernen
</Button>
</div>
</li>
))}
</ul>
<button onClick={onDone} className="mt-2 text-black/40 underline dark:text-white/40">
Hinweis schließen
</button>
</div>
);
} }
function StaleDevicesReview({ staleDevices, onDone }: { staleDevices: Device[]; onDone: () => void }) { function StaleDevicesReview({ staleDevices, onDone }: { staleDevices: Device[]; onDone: () => void }) {
@@ -90,6 +153,7 @@ export function ScannerPage() {
const { data: devices } = useDevices(); const { data: devices } = useDevices();
const [bulkStatus, setBulkStatus] = useState<string | null>(null); const [bulkStatus, setBulkStatus] = useState<string | null>(null);
const [bulkRunning, setBulkRunning] = useState(false); const [bulkRunning, setBulkRunning] = useState(false);
const [bulkStaleServices, setBulkStaleServices] = useState<Service[]>([]);
const [staleDevices, setStaleDevices] = useState<Device[]>([]); const [staleDevices, setStaleDevices] = useState<Device[]>([]);
const fritzboxMutation = useMutation({ const fritzboxMutation = useMutation({
@@ -105,21 +169,29 @@ export function ScannerPage() {
async function scanAllDevices() { async function scanAllDevices() {
if (!devices || devices.length === 0) return; if (!devices || devices.length === 0) return;
setBulkRunning(true); setBulkRunning(true);
setBulkStaleServices([]);
let created = 0; let created = 0;
let updated = 0; let updated = 0;
const allStale: Service[] = [];
for (const device of devices) { for (const device of devices) {
try { try {
const result = await scanDeviceById(device.id); const result = await scanDeviceById(device.id);
created += result.created; created += result.created;
updated += result.updated; updated += result.updated;
allStale.push(...result.staleServices);
setBulkStatus(`Scanne ${device.hostname} … (${created} neu, ${updated} aktualisiert bisher)`); setBulkStatus(`Scanne ${device.hostname} … (${created} neu, ${updated} aktualisiert bisher)`);
} catch { } catch {
// einzelnes fehlgeschlagenes Gerät soll den Rest nicht abbrechen // einzelnes fehlgeschlagenes Gerät soll den Rest nicht abbrechen
} }
} }
setBulkStatus(`Fertig: ${devices.length} Gerät(e) gescannt, ${created} neue Dienste, ${updated} aktualisiert.`); setBulkStatus(
`Fertig: ${devices.length} Gerät(e) gescannt, ${created} neue Dienste, ${updated} aktualisiert${
allStale.length > 0 ? `, ${allStale.length} nicht mehr gefunden (siehe unten)` : ""
}.`
);
setBulkStaleServices(allStale);
setBulkRunning(false); setBulkRunning(false);
queryClient.invalidateQueries({ queryKey: ["devices"] }); queryClient.invalidateQueries({ queryKey: ["devices"] });
queryClient.invalidateQueries({ queryKey: ["services"] }); queryClient.invalidateQueries({ queryKey: ["services"] });
@@ -182,6 +254,12 @@ export function ScannerPage() {
{bulkStatus ? ( {bulkStatus ? (
<p className="mt-2 text-sm text-black/50 dark:text-white/50">{bulkStatus}</p> <p className="mt-2 text-sm text-black/50 dark:text-white/50">{bulkStatus}</p>
) : null} ) : null}
{bulkStaleServices.length > 0 ? (
<StaleServicesReview
staleServices={bulkStaleServices}
onDone={() => setBulkStaleServices([])}
/>
) : null}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -103,7 +103,7 @@ function CategorySelect({
); );
} }
const EDIT_FORM_COLSPAN = 11; const EDIT_FORM_COLSPAN = 12;
function EditForm({ service, onDone }: { service: Service; onDone: () => void }) { function EditForm({ service, onDone }: { service: Service; onDone: () => void }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -239,6 +239,7 @@ function EditForm({ service, onDone }: { service: Service; onDone: () => void })
function ServiceRow({ function ServiceRow({
service, service,
deviceMac, deviceMac,
deviceIp,
draggable, draggable,
onDragStart, onDragStart,
onDragOver, onDragOver,
@@ -247,6 +248,7 @@ function ServiceRow({
}: { }: {
service: Service; service: Service;
deviceMac: string | null; deviceMac: string | null;
deviceIp: string | null;
draggable: boolean; draggable: boolean;
onDragStart: (e: DragEvent<HTMLTableRowElement>) => void; onDragStart: (e: DragEvent<HTMLTableRowElement>) => void;
onDragOver: (e: DragEvent<HTMLTableRowElement>) => void; onDragOver: (e: DragEvent<HTMLTableRowElement>) => void;
@@ -328,6 +330,9 @@ function ServiceRow({
<td className="px-4 py-3 font-mono text-xs text-black/60 dark:text-white/60"> <td className="px-4 py-3 font-mono text-xs text-black/60 dark:text-white/60">
{service.hostname} {service.hostname}
</td> </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">{service.category ?? ""}</td> <td className="px-4 py-3 text-black/60 dark:text-white/60">{service.category ?? ""}</td>
<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">
{deviceMac ?? ""} {deviceMac ?? ""}
@@ -371,7 +376,7 @@ function ServiceRow({
); );
} }
type SortColumn = "displayName" | "hostname" | "category" | "alias" | "port" | "https" | null; type SortColumn = "displayName" | "hostname" | "ip" | "category" | "alias" | "port" | "https" | null;
function SortableHeader({ function SortableHeader({
label, label,
@@ -417,6 +422,12 @@ export function ServicesPage() {
return map; return map;
}, [devices]); }, [devices]);
const ipByDeviceId = useMemo(() => {
const map: Record<string, string | null> = {};
for (const d of devices ?? []) map[d.id] = d.ip;
return map;
}, [devices]);
const reorderMutation = useMutation({ const reorderMutation = useMutation({
mutationFn: reorderServicesRequest, mutationFn: reorderServicesRequest,
onSuccess: () => { onSuccess: () => {
@@ -440,6 +451,13 @@ export function ServicesPage() {
case "hostname": case "hostname":
cmp = a.hostname.localeCompare(b.hostname); cmp = a.hostname.localeCompare(b.hostname);
break; break;
case "ip":
cmp = (ipByDeviceId[a.deviceId] ?? "").localeCompare(
ipByDeviceId[b.deviceId] ?? "",
undefined,
{ numeric: true }
);
break;
case "category": case "category":
cmp = (a.category ?? "").localeCompare(b.category ?? ""); cmp = (a.category ?? "").localeCompare(b.category ?? "");
break; break;
@@ -456,7 +474,7 @@ export function ServicesPage() {
return sortDirection === "asc" ? cmp : -cmp; return sortDirection === "asc" ? cmp : -cmp;
}); });
return sorted; return sorted;
}, [baseList, sortColumn, sortDirection]); }, [baseList, sortColumn, sortDirection, ipByDeviceId]);
function handleHeaderClick(column: SortColumn) { function handleHeaderClick(column: SortColumn) {
if (sortColumn === column) { if (sortColumn === column) {
@@ -526,14 +544,15 @@ export function ServicesPage() {
) : 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-[1020px] text-sm"> <table className="w-full min-w-[1140px] 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">
<th className="px-2 py-2" /> <th className="px-2 py-2" />
<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="Dienst" column="displayName" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<SortableHeader label="Host/IP" column="hostname" 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} /> <SortableHeader label="Kategorie" column="category" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<th className="px-4 py-2 font-medium">MAC</th> <th className="px-4 py-2 font-medium">MAC</th>
<SortableHeader label="Alias" column="alias" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} /> <SortableHeader label="Alias" column="alias" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
@@ -549,6 +568,7 @@ export function ServicesPage() {
key={service.id} key={service.id}
service={service} service={service}
deviceMac={macByDeviceId[service.deviceId] ?? null} deviceMac={macByDeviceId[service.deviceId] ?? null}
deviceIp={ipByDeviceId[service.deviceId] ?? null}
draggable={dragEnabled} draggable={dragEnabled}
isDragging={draggedId === service.id} isDragging={draggedId === service.id}
onDragStart={handleDragStart(service.id)} onDragStart={handleDragStart(service.id)}