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 (
{label} {value}
); } 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 (
Aktiviert
); } 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(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 (
{label}

{hint}

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 ? speichere … : null}
); } function readFileAsBase64(file: File): Promise { 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(null); const [result, setResult] = useState(null); const importMutation = useMutation({ mutationFn: async (file: File): Promise => { 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 (

Import / Export

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.

{ const file = e.target.files?.[0]; if (file) importMutation.mutate(file); e.target.value = ""; }} />
{result ? (

{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(" | ")}` : "."}

) : null}
Format-Anleitung für den Import anzeigen

Eine Zeile pro Eintrag, mit einer Spalte{" "} type ={" "} device oder{" "} service. Am einfachsten: erst exportieren, die Datei als Vorlage nehmen.

Geräte-Zeilen brauchen:{" "} hostname,{" "} ip (Pflicht), optional mac,{" "} manufacturer,{" "} model.

Dienst-Zeilen brauchen:{" "} displayName,{" "} hostname,{" "} port,{" "} url (Pflicht), plus deviceHostname{" "} und deviceIp, um sie einem Gerät zuzuordnen (wird bei Bedarf automatisch angelegt). Optional:{" "} category,{" "} alias (mit{" "} ; getrennt),{" "} favorite,{" "} visible,{" "} https (jeweils{" "} true/ false),{" "} order (Zahl).

); } 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(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 (

Gefahrenzone

Löscht alle Geräte und alle zugehörigen Dienste unwiderruflich. Kategorien und Logs bleiben erhalten.

{message ? (

{message}

) : null}
); } export function SettingsPage() { const { health, error } = useBackendHealth(); const [theme, toggleTheme] = useTheme(); const { data: settings } = useSettings(); return (

Darstellung

Theme

Scan

Live-Status

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.

{settings?.liveStatusEnabled ? (

{settings.liveStatusLastRunAt ? `Letzter Durchlauf: ${new Date(settings.liveStatusLastRunAt).toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short", })}` : "Noch kein Durchlauf seit dem Aktivieren."}

) : null} {settings?.liveStatusEnabled ? ( ) : null}

HTTPS

Root-CA-Zertifikat herunterladen und auf deinen Geräten als vertrauenswürdig einstufen, um die Browser-Warnung dauerhaft loszuwerden (einmal pro Gerät).

Backend

{error ? (

Backend nicht erreichbar.

) : health ? (
) : (

Lade …

)}
); }