generated from Dicken/dickendock
221 lines
7.6 KiB
TypeScript
221 lines
7.6 KiB
TypeScript
import { useState, type FormEvent } from "react";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { Button } from "@launchpad/ui";
|
|
import { useDevices, type DeviceWithServices } from "../../hooks/useDevices.js";
|
|
import { AdminPageHeader } from "./AdminPageHeader.js";
|
|
|
|
async function createDevice(input: { hostname: string; ip: string }) {
|
|
const res = await fetch("/api/devices", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(input),
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
throw new Error(body.error ?? `Gerät konnte nicht angelegt werden (HTTP ${res.status})`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
async function deleteDevice(id: string) {
|
|
const res = await fetch(`/api/devices/${id}`, { method: "DELETE" });
|
|
if (!res.ok && res.status !== 404) {
|
|
throw new Error(`Gerät konnte nicht gelöscht werden (HTTP ${res.status})`);
|
|
}
|
|
}
|
|
|
|
interface ScanResult {
|
|
scannedPorts: number;
|
|
created: number;
|
|
updated: number;
|
|
}
|
|
|
|
async function scanDevice(id: string): Promise<ScanResult> {
|
|
const res = await fetch(`/api/scan/devices/${id}`, { method: "POST" });
|
|
const body = await res.json();
|
|
if (!res.ok) {
|
|
throw new Error(body.detail ?? body.error ?? `Scan fehlgeschlagen (HTTP ${res.status})`);
|
|
}
|
|
return body;
|
|
}
|
|
|
|
function DeviceRow({ device }: { device: DeviceWithServices }) {
|
|
const queryClient = useQueryClient();
|
|
const [scanMessage, setScanMessage] = useState<string | null>(null);
|
|
|
|
const scanMutation = useMutation({
|
|
mutationFn: () => scanDevice(device.id),
|
|
onSuccess: (result) => {
|
|
setScanMessage(
|
|
`${result.scannedPorts} Port(s) offen · ${result.created} neu · ${result.updated} aktualisiert`
|
|
);
|
|
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
|
queryClient.invalidateQueries({ queryKey: ["services"] });
|
|
},
|
|
onError: (err: Error) => setScanMessage(err.message),
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: () => deleteDevice(device.id),
|
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["devices"] }),
|
|
});
|
|
|
|
return (
|
|
<tr className="border-b border-black/5 last:border-0 dark:border-white/5">
|
|
<td className="px-4 py-3">
|
|
<div className="font-medium text-black dark:text-white">{device.hostname}</div>
|
|
<div className="text-xs text-black/40 dark:text-white/40">{device.ip}</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span
|
|
className={`inline-flex items-center gap-1.5 text-xs ${
|
|
device.online ? "text-emerald-600 dark:text-emerald-400" : "text-black/40 dark:text-white/40"
|
|
}`}
|
|
>
|
|
<span
|
|
className={`h-1.5 w-1.5 rounded-full ${device.online ? "bg-emerald-500" : "bg-black/20 dark:bg-white/20"}`}
|
|
/>
|
|
{device.online ? "Online" : "Offline"}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-black/60 dark:text-white/60">{device.services.length}</td>
|
|
<td className="px-4 py-3 text-xs text-black/40 dark:text-white/40">
|
|
{device.lastScan ? new Date(device.lastScan).toLocaleString("de-DE") : "nie gescannt"}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center justify-end gap-2">
|
|
{scanMessage ? (
|
|
<span className="max-w-[16rem] truncate text-xs text-black/40 dark:text-white/40" title={scanMessage}>
|
|
{scanMessage}
|
|
</span>
|
|
) : null}
|
|
<Button
|
|
size="sm"
|
|
onClick={() => {
|
|
setScanMessage(null);
|
|
scanMutation.mutate();
|
|
}}
|
|
disabled={scanMutation.isPending}
|
|
>
|
|
{scanMutation.isPending ? "Scanne …" : "Jetzt scannen"}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="danger"
|
|
onClick={() => deleteMutation.mutate()}
|
|
disabled={deleteMutation.isPending}
|
|
>
|
|
Löschen
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
function AddDeviceForm() {
|
|
const queryClient = useQueryClient();
|
|
const [hostname, setHostname] = useState("");
|
|
const [ip, setIp] = useState("");
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: createDevice,
|
|
onSuccess: () => {
|
|
setHostname("");
|
|
setIp("");
|
|
queryClient.invalidateQueries({ queryKey: ["devices"] });
|
|
},
|
|
});
|
|
|
|
function handleSubmit(e: FormEvent) {
|
|
e.preventDefault();
|
|
if (!hostname.trim() || !ip.trim()) return;
|
|
mutation.mutate({ hostname: hostname.trim(), ip: ip.trim() });
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="flex items-end gap-2">
|
|
<div>
|
|
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">
|
|
Hostname
|
|
</label>
|
|
<input
|
|
value={hostname}
|
|
onChange={(e) => setHostname(e.target.value)}
|
|
placeholder="z. B. synology"
|
|
className="rounded-lg border border-black/10 bg-white px-3 py-1.5 text-sm
|
|
text-black outline-none focus:border-black/30 dark:border-white/10
|
|
dark:bg-white/5 dark:text-white dark:focus:border-white/30"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">
|
|
IP-Adresse
|
|
</label>
|
|
<input
|
|
value={ip}
|
|
onChange={(e) => setIp(e.target.value)}
|
|
placeholder="192.168.1.10"
|
|
className="rounded-lg border border-black/10 bg-white px-3 py-1.5 text-sm
|
|
text-black outline-none focus:border-black/30 dark:border-white/10
|
|
dark:bg-white/5 dark:text-white dark:focus:border-white/30"
|
|
/>
|
|
</div>
|
|
<Button type="submit" variant="primary" disabled={mutation.isPending}>
|
|
Gerät hinzufügen
|
|
</Button>
|
|
{mutation.isError ? (
|
|
<span className="text-xs text-red-500">{(mutation.error as Error).message}</span>
|
|
) : null}
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export function DevicesPage() {
|
|
const { data: devices, isLoading, isError } = useDevices();
|
|
|
|
return (
|
|
<div>
|
|
<AdminPageHeader
|
|
title="Geräte"
|
|
description="Alle bekannten Geräte in deinem Netzwerk. Scans laufen nur auf Knopfdruck."
|
|
/>
|
|
|
|
<div className="mb-6">
|
|
<AddDeviceForm />
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<p className="text-sm text-black/40 dark:text-white/40">Lade Geräte …</p>
|
|
) : isError ? (
|
|
<p className="text-sm text-red-500">Geräte konnten nicht geladen werden.</p>
|
|
) : devices && devices.length > 0 ? (
|
|
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<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">
|
|
<th className="px-4 py-2 font-medium">Gerät</th>
|
|
<th className="px-4 py-2 font-medium">Status</th>
|
|
<th className="px-4 py-2 font-medium">Dienste</th>
|
|
<th className="px-4 py-2 font-medium">Letzter Scan</th>
|
|
<th className="px-4 py-2" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{devices.map((device) => (
|
|
<DeviceRow key={device.id} device={device} />
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-black/40 dark:text-white/40">
|
|
Noch keine Geräte angelegt. Füge oben ein Gerät hinzu oder nutze den FritzBox-Scan
|
|
unter „Scanner“.
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|