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

432 lines
17 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 { useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@launchpad/ui";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faSun, faMoon } from "@fortawesome/free-solid-svg-icons";
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 }) {
return (
<div className="flex items-center justify-between border-b border-black/5 py-3 last:border-0 dark:border-white/5">
<span className="text-sm text-black/50 dark:text-white/50">{label}</span>
<span className="text-sm font-medium text-black dark:text-white">{value}</span>
</div>
);
}
function LiveStatusToggle({ enabled }: { enabled: boolean }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (next: boolean) => {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ liveStatusEnabled: next }),
});
if (!res.ok) throw new Error(`Speichern fehlgeschlagen (HTTP ${res.status})`);
return res.json();
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
});
return (
<div className="flex items-center justify-between py-1">
<span className="text-sm text-black/50 dark:text-white/50">Aktiviert</span>
<button
onClick={() => mutation.mutate(!enabled)}
disabled={mutation.isPending}
role="switch"
aria-checked={enabled}
className={`relative h-6 w-11 rounded-full transition-colors ${
enabled ? "bg-indigo-500" : "bg-black/15 dark:bg-white/15"
}`}
>
<span
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${
enabled ? "translate-x-[22px]" : "translate-x-0.5"
}`}
/>
</button>
</div>
);
}
function LimitSetting({
label,
hint,
settingKey,
value: currentValue,
max = 50,
}: {
label: string;
hint: string;
settingKey: "recentVisitsLimit" | "readLaterLimit" | "staleDeviceThresholdDays" | "liveStatusIntervalMinutes";
value: number | undefined;
max?: number;
}) {
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({ [settingKey]: limit }),
});
if (!res.ok) throw new Error(`Speichern fehlgeschlagen (HTTP ${res.status})`);
return res.json();
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
});
const displayed = value ?? String(currentValue ?? 5);
return (
<div className="flex items-center justify-between py-2">
<div>
<span className="text-sm text-black/50 dark:text-white/50">{label}</span>
<p className="text-xs text-black/30 dark:text-white/30">{hint}</p>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={0}
max={max}
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",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: true }),
});
const body = await res.json();
if (!res.ok) {
throw new Error(body.error ?? `Reset fehlgeschlagen (HTTP ${res.status})`);
}
return body as { deletedDevices: number };
}
function DangerZone() {
const queryClient = useQueryClient();
const [message, setMessage] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: resetEverything,
onSuccess: (result) => {
setMessage(`${result.deletedDevices} Gerät(e) und alle zugehörigen Dienste gelöscht.`);
queryClient.invalidateQueries({ queryKey: ["devices"] });
queryClient.invalidateQueries({ queryKey: ["services"] });
queryClient.invalidateQueries({ queryKey: ["logs"] });
},
onError: (err: Error) => setMessage(err.message),
});
function handleClick() {
const firstConfirm = window.confirm(
"Wirklich ALLE Geräte und Dienste unwiderruflich löschen? Kategorien bleiben erhalten."
);
if (!firstConfirm) return;
const secondConfirm = window.confirm(
"Ganz sicher? Das kann nicht rückgängig gemacht werden."
);
if (!secondConfirm) return;
setMessage(null);
mutation.mutate();
}
return (
<div className="rounded-2xl border border-red-500/20 p-5">
<h2 className="mb-1 font-medium text-red-600 dark:text-red-400">Gefahrenzone</h2>
<p className="mb-4 text-sm text-black/50 dark:text-white/50">
Löscht alle Geräte und alle zugehörigen Dienste unwiderruflich. Kategorien und Logs
bleiben erhalten.
</p>
<Button variant="danger" onClick={handleClick} disabled={mutation.isPending}>
{mutation.isPending ? "Lösche …" : "Alle Geräte & Dienste löschen"}
</Button>
{message ? (
<p className="mt-3 text-sm text-black/60 dark:text-white/60">{message}</p>
) : null}
</div>
);
}
export function SettingsPage() {
const { health, error } = useBackendHealth();
const [theme, toggleTheme] = useTheme();
const { data: settings } = useSettings();
return (
<div>
<AdminPageHeader title="Einstellungen" />
<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">
<span className="text-sm text-black/50 dark:text-white/50">Theme</span>
<button
onClick={toggleTheme}
className="rounded-lg border border-black/10 px-3 py-1.5 text-sm text-black
transition-colors hover:bg-black/5 dark:border-white/10 dark:text-white
dark:hover:bg-white/10"
>
<FontAwesomeIcon icon={theme === "dark" ? faMoon : faSun} /> {theme === "dark" ? "Dunkel" : "Hell"} wechseln
</button>
</div>
<LimitSetting
label='Anzahl „Zuletzt besucht"'
hint="0 = Leiste ausblenden"
settingKey="recentVisitsLimit"
value={settings?.recentVisitsLimit}
/>
<LimitSetting
label='Anzahl „Später lesen" auf der Startseite'
hint="0 = Box ausblenden. Ändert nichts an der Gesamtliste (siehe Admin -> Später lesen)."
settingKey="readLaterLimit"
value={settings?.readLaterLimit}
/>
</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">Scan</h2>
<LimitSetting
label="Nicht mehr gemeldete Geräte anzeigen nach (Tagen)"
hint="Ein Gerät, das die FritzBox nicht mehr meldet, wird erst nach so vielen Tagen im Scan-Ergebnis zur Durchsicht vorgeschlagen. 0 = sofort beim ersten verpassten Scan."
settingKey="staleDeviceThresholdDays"
value={settings?.staleDeviceThresholdDays}
max={90}
/>
</div>
<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">Live-Status</h2>
<p className="mb-2 text-xs text-black/40 dark:text-white/40">
Prüft in regelmäßigem Abstand per Ping, ob ein Gerät erreichbar ist, und
aktualisiert nur dessen Online-Punkt. Legt nie neue Dienste an und schlägt nie
Namen/Kategorien vor das bleibt weiterhin ausschließlich manuellen Scans
vorbehalten. Standardmäßig aus.
</p>
<LiveStatusToggle enabled={settings?.liveStatusEnabled ?? false} />
{settings?.liveStatusEnabled ? (
<LimitSetting
label="Prüfintervall (Minuten)"
hint="Wie oft alle Geräte angepingt werden."
settingKey="liveStatusIntervalMinutes"
value={settings?.liveStatusIntervalMinutes}
max={1440}
/>
) : null}
</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 ? (
<p className="text-sm text-red-500">Backend nicht erreichbar.</p>
) : health ? (
<div>
<InfoRow label="Status" value={health.status} />
<InfoRow label="Version" value={health.version} />
<InfoRow label="Läuft seit" value={`${health.uptimeSeconds}s`} />
</div>
) : (
<p className="text-sm text-black/40 dark:text-white/40">Lade </p>
)}
</div>
<DangerZone />
</div>
</div>
);
}