Files
sshkeyvault/src/app/(app)/hosts/page.tsx
T
florian.wildeandClaude Opus 5.5 90a41dc55d 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>
2026-10-12 09:50:34 +02:00

219 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Plus, Search, Server } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { adresseMitPort, HostKeyBadge } from "@/components/hosts/Anzeige";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Auswahlliste, Feld } from "@/components/ui/Feld";
import { Karte } from "@/components/ui/Karte";
import { Leerzustand } from "@/components/ui/Leerzustand";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { Statuspunkt } from "@/components/ui/Statuspunkt";
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
import { datumZeit, relativ } from "@/lib/format";
import { darf } from "@/server/auth/rechte";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { gruppenAuswahl, hostListe, type HostListenEintrag } from "@/server/queries/hosts";
export const metadata: Metadata = { title: "Hosts" };
type Suchparameter = Promise<Record<string, string | string[] | undefined>>;
function einzeln(wert: string | string[] | undefined): string {
return (Array.isArray(wert) ? wert[0] : wert)?.trim() ?? "";
}
function statusText(h: HostListenEintrag): string | undefined {
if (h.status !== "drift" || h.offeneAbweichungen === 0) return undefined;
return h.offeneAbweichungen === 1 ? "1 Abweichung" : `${h.offeneAbweichungen} Abweichungen`;
}
function Abgleich({ wert, jetzt }: { wert: Date | null; jetzt: Date }) {
if (!wert) return <>noch nie</>;
return (
<time dateTime={wert.toISOString()} title={datumZeit(wert)}>
{relativ(wert, jetzt)}
</time>
);
}
/** Hostliste mit Suche und Gruppenfilter (#008). Filter per GET, damit Links teilbar sind. */
export default async function Hosts({ searchParams }: { searchParams: Suchparameter }) {
// Jede Seite prueft selbst; die Huelle ist nur das zweite Netz (CLAUDE.md).
const { benutzer } = await sitzungErforderlich("lesen", "/hosts");
const parameter = await searchParams;
const q = einzeln(parameter["q"]);
const gruppeId = einzeln(parameter["gruppe"]);
const [hosts, gruppen] = await Promise.all([
hostListe({ q, gruppeId: gruppeId || undefined }),
gruppenAuswahl(),
]);
const verwalten = darf(benutzer.rolle, "hostsVerwalten");
const gefiltert = q !== "" || gruppeId !== "";
const jetzt = new Date();
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
titel="Hosts"
untertitel={
gefiltert
? `${hosts.length} Treffer`
: `${hosts.length} ${hosts.length === 1 ? "verwalteter Server" : "verwaltete Server"}`
}
aktionen={
verwalten && (
<Button href="/hosts/neu" variante="primaer" icon={<Plus className="size-4" strokeWidth={2.4} />}>
Host anlegen
</Button>
)
}
/>
<form
method="get"
role="search"
className="grid items-end gap-3 sm:grid-cols-[minmax(0,1fr)_14rem_auto]"
>
<Feld
beschriftung="Suche"
name="q"
type="search"
defaultValue={q}
placeholder="Name, Adresse oder Tag"
autoComplete="off"
spellCheck={false}
/>
<Auswahlliste beschriftung="Hostgruppe" name="gruppe" defaultValue={gruppeId}>
<option value="">Alle Gruppen</option>
{gruppen.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</Auswahlliste>
<div className="flex gap-2.5">
<Button type="submit" icon={<Search className="size-4" />} className="flex-1 sm:flex-none">
Filtern
</Button>
{gefiltert && (
<Button href="/hosts" className="flex-1 sm:flex-none">
Zurücksetzen
</Button>
)}
</div>
</form>
{hosts.length === 0 ? (
gefiltert ? (
<Leerzustand
icon={<Search className="size-5" />}
titel="Keine Treffer"
text="Kein Host passt zu Suche und Gruppe."
aktion={<Button href="/hosts">Filter zurücksetzen</Button>}
/>
) : (
<Leerzustand
icon={<Server className="size-5" />}
titel="Noch keine Hosts"
text="Legen Sie den ersten Server an. Danach ruft sshkv seinen Host-Key ab und richtet den Zugang ein."
aktion={
verwalten && (
<Button
href="/hosts/neu"
variante="primaer"
icon={<Plus className="size-4" strokeWidth={2.4} />}
>
Host anlegen
</Button>
)
}
/>
)
) : (
<Karte>
{/* Unter md eine Liste statt der Tabelle: sechs Spalten passen nicht auf 390 px, und
Status und Host-Key sollen ohne seitliches Wischen sichtbar sein (#008). */}
<ul className="flex flex-col md:hidden">
{hosts.map((h) => (
<li key={h.id} className="border-border border-b last:border-b-0">
<Link
href={`/hosts/${h.id}`}
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 flex-col gap-2 px-2 py-3 transition-colors"
>
<span className="min-w-0">
<span className="text-text block truncate font-mono text-[14px] font-medium">
{h.name}
</span>
<span className="text-muted block truncate font-mono text-[12.5px]">
{adresseMitPort(h.adresse, h.port)}
</span>
</span>
<span className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
<Statuspunkt status={h.status} text={statusText(h)} />
<HostKeyBadge zustand={h.hostKey.zustand} />
{h.gruppen.map((g) => (
<Badge key={g.id}>{g.name}</Badge>
))}
</span>
<span className="text-muted text-[13px]">
Letzter Abgleich: <Abgleich wert={h.letzterAbgleichAm} jetzt={jetzt} />
</span>
</Link>
</li>
))}
</ul>
<Tabelle
className="hidden md:block"
beschriftung="Hosts"
spalten={[
{ titel: "Name" },
{ titel: "Adresse" },
{ titel: "Gruppen" },
{ titel: "Status" },
{ titel: "Host-Key" },
{ titel: "Letzter Abgleich" },
]}
>
{hosts.map((h) => (
<TabellenZeile key={h.id}>
<TabellenZelle className="whitespace-nowrap">
<Link
href={`/hosts/${h.id}`}
className="text-text hover:text-accent -my-2 inline-flex min-h-11 items-center font-mono text-[13.5px] font-medium transition-colors"
>
{h.name}
</Link>
</TabellenZelle>
<TabellenZelle mono className="text-muted whitespace-nowrap">
{adresseMitPort(h.adresse, h.port)}
</TabellenZelle>
<TabellenZelle>
{h.gruppen.length === 0 ? (
<span className="text-muted">–</span>
) : (
<span className="flex flex-wrap gap-1.5">
{h.gruppen.map((g) => (
<Badge key={g.id}>{g.name}</Badge>
))}
</span>
)}
</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">
<Statuspunkt status={h.status} text={statusText(h)} />
</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">
<HostKeyBadge zustand={h.hostKey.zustand} />
</TabellenZelle>
<TabellenZelle className="text-muted whitespace-nowrap">
<Abgleich wert={h.letzterAbgleichAm} jetzt={jetzt} />
</TabellenZelle>
</TabellenZeile>
))}
</Tabelle>
</Karte>
)}
</div>
);
}