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

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