feat(hosts): Seiten fuer Hosts und Hostgruppen (#008)

Ersetzt die Platzhalter: Hostliste mit Suche und Gruppenfilter per GET
(teilbare Links), Formulare fuer Hosts und Gruppen, Hostdetail als Geruest
und Gruppendetail mit Mitgliederauswahl. Bearbeiten-Knoepfe entscheidet der
Server per darf(); jede Seite prueft die Sitzung selbst.

Die Detailseite rendert HostKeyAktionen (#009) und BootstrapAktionen (#010)
als feste Platzhalter mit fertigen Props, damit die Folge-Issues nur ihre
eigene Datei ersetzen. Formulare senden per onSubmit, weil React ein
<form action> nach jeder Antwort zuruecksetzt und Eingaben bei Feldfehlern
sonst verloren gingen. Unter md zeigt die Hostliste Karten statt Tabelle,
damit Status und Host-Key auf 390 px ohne Wischen sichtbar bleiben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:50:34 +02:00
co-authored by Claude Opus 5.5
parent 4f9d5d9680
commit 90a41dc55d
16 changed files with 1272 additions and 28 deletions
+227
View File
@@ -0,0 +1,227 @@
import { KeyRound, Pencil, ShieldQuestion } from "lucide-react";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import type { ReactNode } from "react";
import { ArchivierenDialog } from "@/components/hosts/ArchivierenDialog";
import { adresseMitPort, AnmeldeartBadge, HostSchluesselStatusBadge } from "@/components/hosts/Anzeige";
import { Badge } from "@/components/ui/Badge";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Button } from "@/components/ui/Button";
import { Karte } from "@/components/ui/Karte";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { MonoKopieren } from "@/components/ui/MonoKopieren";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { Statuspunkt } from "@/components/ui/Statuspunkt";
import { datum, datumZeit, relativ } from "@/lib/format";
import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { hostDetail, type HostDetail } from "@/server/queries/hosts";
import { BootstrapAktionen } from "./BootstrapAktionen";
import { HostKeyAktionen } from "./HostKeyAktionen";
export const metadata: Metadata = { title: "Host" };
function Zeitpunkt({ wert, jetzt, sonst }: { wert: Date | null; jetzt: Date; sonst: string }) {
if (!wert) return <span className="text-muted">{sonst}</span>;
return (
<time dateTime={wert.toISOString()} title={datumZeit(wert)}>
{relativ(wert, jetzt)}
</time>
);
}
function Info({ titel, children }: { titel: string; children: ReactNode }) {
return (
<div className="min-w-0">
<dt className="text-muted text-[13px] font-medium">{titel}</dt>
<dd className="text-text mt-1 text-[14.5px] break-words">{children}</dd>
</div>
);
}
function Erreichbarkeit({ host, jetzt }: { host: HostDetail; jetzt: Date }) {
const pruefung = host.zuletztGeprueftAm ? ` · ${relativ(host.zuletztGeprueftAm, jetzt)}` : "";
if (host.erreichbar === true) return <Statuspunkt status="synchron" text={`erreichbar${pruefung}`} />;
if (host.erreichbar === false)
return <Statuspunkt status="unerreichbar" text={`nicht erreichbar${pruefung}`} />;
return <Statuspunkt status="unbekannt" text="noch nicht geprüft" />;
}
/** `sshd -T`-Auszug (#010 fuellt ihn); hier nur flach als Schluessel/Wert gezeigt. */
function SshdBefund({ befund }: { befund: unknown }) {
if (!befund || typeof befund !== "object" || Array.isArray(befund)) return null;
const eintraege = Object.entries(befund as Record<string, unknown>);
if (eintraege.length === 0) return null;
return (
<div className="border-border mt-5 border-t pt-4">
<p className="text-muted text-[13px] font-medium">sshd-Befund</p>
<dl className="mt-2 grid gap-x-6 gap-y-1 font-mono text-[13px] sm:grid-cols-2">
{eintraege.map(([k, v]) => (
<div key={k} className="flex min-w-0 gap-2">
<dt className="text-muted shrink-0">{k}</dt>
<dd className="text-text min-w-0 break-all">{String(v)}</dd>
</div>
))}
</dl>
</div>
);
}
/** Hostdetail (#008): Gerüst, in das #009 (Host-Key), #010 (Bootstrap) und #016 einziehen. */
export default async function HostSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { benutzer } = await sitzungErforderlich("lesen", `/hosts/${id}`);
const host = await hostDetail(id);
if (!host) notFound();
const archiviert = host.archiviertAm !== null;
const verwalten = darf(benutzer.rolle, "hostsVerwalten") && !archiviert;
const jetzt = new Date();
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={<Breadcrumb pfad={[{ titel: "Hosts", href: "/hosts" }, { titel: host.name }]} />}
titel={<span className="font-mono">{host.name}</span>}
untertitel={
<>
<span className="text-text font-mono text-[13.5px] break-all">
{adresseMitPort(host.adresse, host.port)}
</span>
{host.beschreibung && <span className="mt-1.5 block">{host.beschreibung}</span>}
</>
}
badges={
<>
{archiviert && host.archiviertAm && (
<Badge variante="gefahr">archiviert am {datum(host.archiviertAm)}</Badge>
)}
<AnmeldeartBadge anmeldeart={host.anmeldeart} />
{host.gruppen.map((g) => (
<Badge key={g.id}>{g.name}</Badge>
))}
{host.tags.map((t) => (
<Badge key={t} mono className="text-muted">
#{t}
</Badge>
))}
</>
}
aktionen={
verwalten && (
<Button href={`/hosts/${host.id}/bearbeiten`} icon={<Pencil className="size-4" />}>
Bearbeiten
</Button>
)
}
/>
<Karte as="div">
<dl className="grid grid-cols-1 gap-x-6 gap-y-4 min-[480px]:grid-cols-2 xl:grid-cols-4">
<Info titel="Erreichbar">
<Erreichbarkeit host={host} jetzt={jetzt} />
</Info>
<Info titel="Bootstrap">
{host.bootstrapAm ? (
<time dateTime={host.bootstrapAm.toISOString()}>{datumZeit(host.bootstrapAm)}</time>
) : (
<span className="text-accent">noch nicht eingerichtet</span>
)}
</Info>
<Info titel="Letzter Abgleich">
<span className="flex flex-col gap-0.5">
<Zeitpunkt wert={host.letzterAbgleichAm} jetzt={jetzt} sonst="noch nie" />
{host.letzterAbgleichAm && <Statuspunkt status={host.status} className="text-[13px]" />}
</span>
</Info>
<Info titel="Kunde">{host.kunde ? host.kunde.name : <span className="text-muted">–</span>}</Info>
</dl>
<SshdBefund befund={host.sshdBefund} />
<BootstrapAktionen
hostId={host.id}
darfVerwalten={verwalten}
eingerichtet={host.bootstrapAm !== null}
hostKeyVertraut={host.hostKey.vertraut > 0}
/>
</Karte>
<Karte
titel="Host-Key"
untertitel="Fingerprints, gegen die sshkv jede Verbindung prüft"
aktionen={
<HostKeyAktionen
hostId={host.id}
darfEntscheiden={darf(benutzer.rolle, "hostKeyEntscheiden") && !archiviert}
status={host.hostKey}
/>
}
>
{host.hostSchluessel.length === 0 ? (
<Leerzustand
icon={<ShieldQuestion className="size-5" />}
titel="Noch kein Host-Key abgerufen"
text="Ohne bestätigten Host-Key verbindet sich sshkv nicht mit diesem Host."
/>
) : (
<ul className="flex flex-col">
{host.hostSchluessel.map((k) => (
<li
key={k.id}
className="border-border grid gap-x-4 gap-y-1 border-b py-3 first:pt-0 last:border-b-0 last:pb-0 md:grid-cols-[12rem_minmax(0,1fr)_auto] md:items-center"
>
<span className="flex items-center justify-between gap-3 md:block">
<span className="text-text font-mono text-[13px]">{k.algorithmus}</span>
<span className="md:hidden">
<HostSchluesselStatusBadge status={k.status} />
</span>
</span>
<span className="min-w-0">
<MonoKopieren wert={k.fingerprint} beschriftung="Fingerprint" umbrechen />
<span className="text-muted block text-[12.5px]">
zuerst gesehen {datumZeit(k.erstmalsGesehen)} · zuletzt {relativ(k.zuletztGesehen, jetzt)}
</span>
</span>
<span className="hidden md:block">
<HostSchluesselStatusBadge status={k.status} />
</span>
</li>
))}
</ul>
)}
</Karte>
<Karte
titel="Schlüssel auf diesem Host"
untertitel={`${host.publicVerwendungen} Public-Key-Verwendungen · ${host.privateVerwendungen} Private-Key-Verwendungen`}
>
{host.zielkonten.length > 0 && (
<p className="text-muted mb-4 text-[14px]">
Zielkonten:{" "}
{host.zielkonten.map((z, i) => (
<span key={z.id}>
{i > 0 && ", "}
<code className="text-text font-mono text-[13px]">{z.benutzername}</code>
</span>
))}
</p>
)}
<Leerzustand
icon={<KeyRound className="size-5" />}
titel="Folgt mit #016"
text="Hier erscheinen die Public und Private Keys, die sshkv auf diesem Host verwaltet."
/>
</Karte>
{verwalten && (
<Karte titel="Archivieren" untertitel="Host stilllegen, ohne die Historie zu verlieren">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-muted max-w-xl text-[14px]">
Archivierte Hosts verschwinden aus Listen und Hostgruppen. Name und Protokoll bleiben erhalten.
</p>
<ArchivierenDialog hostId={host.id} hostName={host.name} />
</div>
</Karte>
)}
</div>
);
}