generated from Dicken/dickendock
Dashboard-Zahlen (Kategorien, Read Later), Host/IP getrennte Spalten bei Diensten, Scanner-Uebersicht beim Massen-Scan
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
Reference in New Issue
Block a user