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>
219 lines
8.3 KiB
TypeScript
219 lines
8.3 KiB
TypeScript
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>
|
||
);
|
||
}
|