generated from Dicken/dickendock
442 lines
18 KiB
TypeScript
442 lines
18 KiB
TypeScript
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 left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform ${
|
||
enabled ? "translate-x-[20px]" : "translate-x-0"
|
||
}`}
|
||
/>
|
||
</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 ? (
|
||
<p className="mb-2 text-xs text-black/30 dark:text-white/30">
|
||
{settings.liveStatusLastRunAt
|
||
? `Letzter Durchlauf: ${new Date(settings.liveStatusLastRunAt).toLocaleString("de-DE", {
|
||
dateStyle: "medium",
|
||
timeStyle: "short",
|
||
})}`
|
||
: "Noch kein Durchlauf seit dem Aktivieren."}
|
||
</p>
|
||
) : null}
|
||
{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>
|
||
);
|
||
}
|