Bugfixes (Dark Mode, Dropdown-Kontrast, IP-Suche), Lesezeichen-Ausbau (Farbe, Beschreibung, Favicon), Zuletzt-besucht, Spaeter-lesen, kombinierter Import-Export, Scanner-Reconciliation, Admin-Ueberarbeitung

This commit is contained in:
2026-07-20 07:52:38 +02:00
parent dc91a9aba9
commit 96cf29fef4
37 changed files with 1988 additions and 494 deletions

View File

@@ -1,8 +1,9 @@
import { useState } from "react";
import { useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@launchpad/ui";
import { useBackendHealth } from "../../hooks/useBackendHealth.js";
import { useTheme } from "../../hooks/useTheme.js";
import { useSettings } from "../../hooks/useSettings.js";
import { AdminPageHeader } from "./AdminPageHeader.js";
function InfoRow({ label, value }: { label: string; value: string }) {
@@ -14,6 +15,210 @@ function InfoRow({ label, value }: { label: string; value: string }) {
);
}
function RecentVisitsLimitSetting() {
const { data: settings } = useSettings();
const queryClient = useQueryClient();
const [value, setValue] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: async (limit: number) => {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ recentVisitsLimit: limit }),
});
if (!res.ok) throw new Error(`Speichern fehlgeschlagen (HTTP ${res.status})`);
return res.json();
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
});
const displayed = value ?? String(settings?.recentVisitsLimit ?? 5);
return (
<div className="flex items-center justify-between py-2">
<div>
<span className="text-sm text-black/50 dark:text-white/50">
Anzahl Zuletzt besucht"
</span>
<p className="text-xs text-black/30 dark:text-white/30">0 = Leiste ausblenden</p>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={50}
value={displayed}
onChange={(e) => setValue(e.target.value)}
onBlur={() => {
const n = Number(value);
if (value !== null && Number.isFinite(n)) mutation.mutate(n);
}}
className="w-20 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm
text-black dark:border-white/10 dark:bg-white/10 dark:text-white"
/>
{mutation.isPending ? <span className="text-xs text-black/30 dark:text-white/30">speichere …</span> : null}
</div>
</div>
);
}
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve((reader.result as string).split(",")[1] ?? "");
reader.onerror = () => reject(new Error("Datei konnte nicht gelesen werden"));
reader.readAsDataURL(file);
});
}
function detectFormat(filename: string): "csv" | "xlsx" | "json" {
if (filename.toLowerCase().endsWith(".xlsx")) return "xlsx";
if (filename.toLowerCase().endsWith(".json")) return "json";
return "csv";
}
interface TransferResult {
devicesImported: number;
devicesSkipped: number;
servicesImported: number;
servicesSkipped: number;
errors: string[];
}
function ImportExportSection() {
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [result, setResult] = useState<TransferResult | null>(null);
const importMutation = useMutation({
mutationFn: async (file: File): Promise<TransferResult> => {
const content = await readFileAsBase64(file);
const format = detectFormat(file.name);
const res = await fetch("/api/transfer/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ format, content }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error ?? `Import fehlgeschlagen (HTTP ${res.status})`);
}
return res.json();
},
onSuccess: (data) => {
setResult(data);
queryClient.invalidateQueries({ queryKey: ["devices"] });
queryClient.invalidateQueries({ queryKey: ["services"] });
queryClient.invalidateQueries({ queryKey: ["categories"] });
},
onError: (err: Error) =>
setResult({
devicesImported: 0,
devicesSkipped: 0,
servicesImported: 0,
servicesSkipped: 0,
errors: [err.message],
}),
});
return (
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<h2 className="mb-1 font-medium text-black dark:text-white">Import / Export</h2>
<p className="mb-4 text-sm text-black/50 dark:text-white/50">
Exportiert Geräte und Dienste in einer Datei. Import legt ausschließlich neue Einträge
an bereits vorhandene Geräte (Abgleich über IP/Hostname) und Dienste (Abgleich über
Gerät + Port) werden übersprungen, nie überschrieben oder verdoppelt.
</p>
<div className="mb-4 flex flex-wrap gap-2">
<a href="/api/transfer/export?format=csv" download>
<Button size="sm">CSV exportieren</Button>
</a>
<a href="/api/transfer/export?format=xlsx" download>
<Button size="sm">Excel exportieren</Button>
</a>
<a href="/api/transfer/export?format=json" download>
<Button size="sm">JSON exportieren</Button>
</a>
</div>
<div className="mb-4">
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.json"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) importMutation.mutate(file);
e.target.value = "";
}}
/>
<Button
variant="primary"
onClick={() => fileInputRef.current?.click()}
disabled={importMutation.isPending}
>
{importMutation.isPending ? "Importiere " : "Datei importieren (CSV/Excel/JSON)"}
</Button>
</div>
{result ? (
<p className="mb-4 text-xs text-black/50 dark:text-white/50">
{result.devicesImported} Gerät(e) + {result.servicesImported} Dienst(e) importiert,{" "}
{result.devicesSkipped + result.servicesSkipped} übersprungen (bereits vorhanden)
{result.errors.length > 0
? `, ${result.errors.length} Fehler: ${result.errors.join(" | ")}`
: "."}
</p>
) : null}
<details className="text-xs text-black/50 dark:text-white/50">
<summary className="cursor-pointer font-medium text-black/70 dark:text-white/70">
Format-Anleitung für den Import anzeigen
</summary>
<div className="mt-2 space-y-2">
<p>
Eine Zeile pro Eintrag, mit einer Spalte{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">type</code> ={" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">device</code> oder{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">service</code>. Am
einfachsten: erst exportieren, die Datei als Vorlage nehmen.
</p>
<p>
<strong>Geräte-Zeilen</strong> brauchen:{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">hostname</code>,{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">ip</code> (Pflicht),
optional <code className="rounded bg-black/5 px-1 dark:bg-white/10">mac</code>,{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">manufacturer</code>,{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">model</code>.
</p>
<p>
<strong>Dienst-Zeilen</strong> brauchen:{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">displayName</code>,{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">hostname</code>,{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">port</code>,{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">url</code> (Pflicht),
plus <code className="rounded bg-black/5 px-1 dark:bg-white/10">deviceHostname</code>{" "}
und <code className="rounded bg-black/5 px-1 dark:bg-white/10">deviceIp</code>, um
sie einem Gerät zuzuordnen (wird bei Bedarf automatisch angelegt). Optional:{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">category</code>,{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">alias</code> (mit{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">;</code> getrennt),{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">favorite</code>,{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">visible</code>,{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">https</code> (jeweils{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">true</code>/
<code className="rounded bg-black/5 px-1 dark:bg-white/10">false</code>),{" "}
<code className="rounded bg-black/5 px-1 dark:bg-white/10">order</code> (Zahl).
</p>
</div>
</details>
</div>
);
}
async function resetEverything() {
const res = await fetch("/api/reset", {
method: "POST",
@@ -82,7 +287,7 @@ export function SettingsPage() {
<div>
<AdminPageHeader title="Einstellungen" />
<div className="max-w-lg space-y-6">
<div className="max-w-2xl space-y-6">
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<h2 className="mb-2 font-medium text-black dark:text-white">Darstellung</h2>
<div className="flex items-center justify-between py-2">
@@ -96,8 +301,22 @@ export function SettingsPage() {
{theme === "dark" ? "🌙 Dunkel" : " Hell"} wechseln
</button>
</div>
<RecentVisitsLimitSetting />
</div>
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<h2 className="mb-2 font-medium text-black dark:text-white">HTTPS</h2>
<p className="mb-3 text-sm text-black/50 dark:text-white/50">
Root-CA-Zertifikat herunterladen und auf deinen Geräten als vertrauenswürdig
einstufen, um die Browser-Warnung dauerhaft loszuwerden (einmal pro Gerät).
</p>
<a href="/ca.crt" download>
<Button variant="primary">Root-Zertifikat herunterladen (ca.crt)</Button>
</a>
</div>
<ImportExportSection />
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
<h2 className="mb-2 font-medium text-black dark:text-white">Backend</h2>
{error ? (