feat(hosts): Host-Tabelle mit Filtern, CSV-Export und Detailansicht (#007)

Suche ueber Hostname, Key, Server-ID, IP und Rechnungsnummer, fuenf Filter,
Sortierung mit nulls-last, Seitenblaettern und CSV-Export. Detailansicht mit
sechs Tabs, Notizen, Tags und Token-Widerruf. 59 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 21:12:41 +02:00
co-authored by Claude Opus 5
parent 6aa6a20f63
commit 0ae96a50cd
18 changed files with 1289 additions and 286 deletions
+335 -178
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { HostNotizen } from "@/components/hosts/HostNotizen";
import { ContentHeader } from "@/components/layout/ContentHeader";
import {
AblaufBadge,
@@ -12,21 +13,18 @@ import {
import { Card } from "@/components/ui/Card";
import { CopyableCode } from "@/components/ui/CopyableCode";
import { Tabs } from "@/components/ui/Tabs";
import { PRODUKT_TEXT } from "@/lib/anzeige";
import { prisma } from "@/lib/db";
import { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format";
import { HEUTE } from "@/mock/data";
import {
PRODUKT_NAME,
alertsVonHost,
getHost,
getKunde,
hostMeldetNicht,
laufzeitenVonHost,
reportsVonHost,
} from "@/mock/queries";
import { hostKundeSetzen } from "@/server/actions/host-detail";
import { hostTokenWiderrufen } from "@/server/actions/tokens";
import { hostMitDetails } from "@/server/queries/hostliste";
import { meldungsGrenze } from "@/server/queries/hosts";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return { title: getHost(id)?.hostname ?? "Host" };
const host = await prisma.host.findUnique({ where: { id }, select: { hostname: true } });
return { title: host?.hostname ?? "Host" };
}
function Zeile({ bezeichnung, children }: { bezeichnung: string; children: React.ReactNode }) {
@@ -40,51 +38,52 @@ function Zeile({ bezeichnung, children }: { bezeichnung: string; children: React
export default async function HostDetailSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const host = getHost(id);
const host = await hostMitDetails(id);
if (!host) notFound();
const kunde = getKunde(host.kundeId);
const standort = kunde?.standorte.find((s) => s.id === host.standortId);
const laufzeiten = laufzeitenVonHost(host.id);
const reports = reportsVonHost(host.id);
const alerts = alertsVonHost(host.id);
const keys = [...new Set(laufzeiten.map((l) => l.key))];
const kunden = await prisma.kunde.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true },
});
const jetzt = new Date();
const meldetNicht = !host.letzterReportAm || host.letzterReportAm < meldungsGrenze(jetzt);
const socketAbweichung =
host.subLizenzierteSockets !== null &&
host.sockets !== null &&
host.subLizenzierteSockets !== host.sockets;
return (
<>
<ContentHeader
titel={host.hostname}
brotkrumen={[{ titel: "Hosts", href: "/hosts" }, { titel: host.hostname }]}
aktion={<StatusBadge status={host.subscription.status} />}
aktion={<StatusBadge status={host.subStatus} />}
/>
<div className="app-content">
<div className="container-fluid">
{alerts.length > 0 ? (
<div className="row">
<div className="col-12">
<Card titel="Offene Warnungen" rahmen="danger">
<ul className="list-unstyled mb-0">
{alerts.map((alert) => (
<li key={alert.id} className="mb-2">
<Badge
farbe={
alert.schwere === "gefahr"
? "danger"
: alert.schwere === "warnung"
? "warning"
: "info"
}
>
{alert.titel}
</Badge>{" "}
<span className="small text-body-secondary">{alert.beschreibung}</span>
</li>
))}
</ul>
</Card>
</div>
</div>
{host.alerts.length > 0 ? (
<Card titel={`Offene Warnungen (${host.alerts.length})`} rahmen="danger">
<ul className="list-unstyled mb-0">
{host.alerts.map((alert) => (
<li key={alert.id} className="mb-2">
<Badge
farbe={
alert.schwere === "GEFAHR"
? "danger"
: alert.schwere === "WARNUNG"
? "warning"
: "info"
}
>
{alert.titel}
</Badge>{" "}
<span className="small text-body-secondary">{alert.beschreibung}</span>
</li>
))}
</ul>
</Card>
) : null}
<div className="row">
@@ -94,40 +93,69 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
<i className="bi bi-hdd-rack fs-1 text-body-secondary" aria-hidden="true" />
<h2 className="h5 mt-2 mb-1">{host.hostname}</h2>
<div className="text-body-secondary small">{host.fqdn ?? "Kein FQDN gemeldet"}</div>
{hostMeldetNicht(host) ? <KeineMeldungBadge /> : null}
{meldetNicht ? <KeineMeldungBadge /> : null}
</div>
<Zeile bezeichnung="Kunde">
{kunde ? (
<Link href={`/kunden/${kunde.id}`}>{kunde.name}</Link>
{host.kunde ? (
<Link href={`/kunden/${host.kunde.id}`}>{host.kunde.name}</Link>
) : (
<Link href="/hosts/nicht-zugeordnet" className="badge pl-bg-neutral text-decoration-none">
Nicht zugeordnet
</Link>
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)}
</Zeile>
<Zeile bezeichnung="Standort">{standort?.name ?? "–"}</Zeile>
<Zeile bezeichnung="Produkt">{PRODUKT_NAME[host.produkt]}</Zeile>
<Zeile bezeichnung="Standort">{host.standort?.name ?? "–"}</Zeile>
<Zeile bezeichnung="Produkt">{PRODUKT_TEXT[host.produkt]}</Zeile>
<Zeile bezeichnung="Version">{host.produktVersion ?? "–"}</Zeile>
<Zeile bezeichnung="Kernel">{host.kernel ?? "–"}</Zeile>
<Zeile bezeichnung="Server-ID">
<CopyableCode wert={host.serverId} />
</Zeile>
<Zeile bezeichnung="Cluster / Node">
{host.clusterName ? `${host.clusterName} / ${host.nodeName}` : "–"}
{host.clusterName ? `${host.clusterName} / ${host.nodeName ?? "?"}` : "–"}
</Zeile>
<Zeile bezeichnung="IP-Adressen">{host.ips.join(", ")}</Zeile>
<Zeile bezeichnung="IP-Adressen">{host.ips.join(", ") || "–"}</Zeile>
<Zeile bezeichnung="CPU">{host.cpuModell ?? "–"}</Zeile>
<Zeile bezeichnung="Sockets (lizenziert / physisch)">
{host.subscription.lizenzierteSockets ?? "–"} / {host.sockets ?? "–"}
{host.subLizenzierteSockets ?? "–"} / {host.sockets ?? "–"}
{socketAbweichung ? (
<i
className="bi bi-exclamation-triangle-fill pl-text-warning ms-2"
title="Lizenzierte und physische Sockets weichen ab"
/>
) : null}
</Zeile>
<Zeile bezeichnung="Uptime">
{formatUptime(host.uptimeSekunden === null ? null : Number(host.uptimeSekunden))}
</Zeile>
<Zeile bezeichnung="Uptime">{formatUptime(host.uptimeSekunden)}</Zeile>
<Zeile bezeichnung="Paket-Updates">{formatZahl(host.paketUpdates)}</Zeile>
<Zeile bezeichnung="Agent-Version">{host.agentVersion}</Zeile>
<Zeile bezeichnung="Letzte Meldung">{formatDatumZeit(host.letzterReport)}</Zeile>
<div className="d-grid gap-2 mt-3">
<span className="btn btn-outline-secondary disabled">Kunde ändern (ab #006)</span>
</div>
<Zeile bezeichnung="Agent-Version">{host.agentVersion ?? "–"}</Zeile>
<Zeile bezeichnung="Erstmals gesehen">{formatDatum(host.erstmalsGesehenAm)}</Zeile>
<Zeile bezeichnung="Letzte Meldung">{formatDatumZeit(host.letzterReportAm)}</Zeile>
<form action={hostKundeSetzen} className="mt-3">
<input type="hidden" name="hostId" value={host.id} />
<label className="form-label small" htmlFor="kundeId">
Kunde ändern
</label>
<div className="input-group input-group-sm">
<select
id="kundeId"
name="kundeId"
className="form-select"
defaultValue={host.kundeId ?? ""}
>
<option value="">– nicht zugeordnet –</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
<button type="submit" className="btn btn-outline-primary">
Setzen
</button>
</div>
</form>
</Card>
</div>
@@ -141,93 +169,150 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
inhalt: (
<>
<Zeile bezeichnung="Subscription-Key">
<CopyableCode wert={host.subscription.key} />
<CopyableCode wert={host.subKey} />
</Zeile>
<Zeile bezeichnung="Status">
<StatusBadge status={host.subscription.status} />
<StatusBadge status={host.subStatus} />
</Zeile>
<Zeile bezeichnung="Produktname">{host.subscription.produktname ?? "–"}</Zeile>
<Zeile bezeichnung="Level">{host.subscription.level ?? "–"}</Zeile>
<Zeile bezeichnung="Registriert am">{formatDatum(host.subscription.regdate)}</Zeile>
<Zeile bezeichnung="Produktname">{host.subProduktname ?? "–"}</Zeile>
<Zeile bezeichnung="Level">{host.subLevel ?? "–"}</Zeile>
<Zeile bezeichnung="Registriert am">{formatDatum(host.subRegdate)}</Zeile>
<Zeile bezeichnung="Läuft ab am">
<AblaufBadge datum={host.subscription.nextduedate} heute={HEUTE} />
<AblaufBadge datum={host.subNextduedate?.toISOString() ?? null} heute={jetzt} />
</Zeile>
<Zeile bezeichnung="Letzte Prüfung durch Proxmox">
{formatDatumZeit(host.subscription.checktime)}
{formatDatumZeit(host.subChecktime)}
</Zeile>
<div className="mt-3">
{host.tags.map((tag) => (
<span key={tag} className="badge pl-bg-neutral me-1">
{tag}
</span>
))}
{host.tags.length === 0 ? (
<span className="text-body-secondary small">Keine Tags</span>
) : (
host.tags.map((tag) => (
<span key={tag.id} className="badge pl-bg-neutral me-1">
{tag.name}
</span>
))
)}
</div>
</>
),
},
{
id: "keys",
id: "historie",
titel: "Subscription-Historie",
icon: "bi-key",
inhalt: (
<table className="table table-striped align-middle mb-0">
<thead>
<tr>
<th scope="col">Key</th>
<th scope="col">Erste Laufzeit</th>
<th scope="col">Letzte Laufzeit</th>
</tr>
</thead>
<tbody>
{keys.map((key) => {
const zugehoerig = laufzeiten.filter((l) => l.key === key);
return (
<tr key={key}>
<>
<h3 className="h6">Keys</h3>
<table className="table table-striped align-middle mb-4">
<thead>
<tr>
<th scope="col">Key</th>
<th scope="col">Erstmals gemeldet</th>
<th scope="col">Zuletzt gemeldet</th>
</tr>
</thead>
<tbody>
{host.keyHistorie.map((eintrag) => (
<tr key={eintrag.id}>
<td>
<CopyableCode wert={key} />
<CopyableCode wert={eintrag.key} />
</td>
<td>{formatDatum(zugehoerig[zugehoerig.length - 1]?.start)}</td>
<td>{formatDatum(zugehoerig[0]?.ende)}</td>
<td>{formatDatum(eintrag.ersteMeldung)}</td>
<td>{formatDatum(eintrag.letzteMeldung)}</td>
</tr>
);
})}
</tbody>
</table>
))}
{host.keyHistorie.length === 0 ? (
<tr>
<td colSpan={3} className="text-body-secondary">
Noch kein Key gemeldet.
</td>
</tr>
) : null}
</tbody>
</table>
<h3 className="h6">Server-IDs</h3>
<table className="table table-striped align-middle mb-0">
<thead>
<tr>
<th scope="col">Server-ID</th>
<th scope="col">Erstmals gemeldet</th>
<th scope="col">Zuletzt gemeldet</th>
</tr>
</thead>
<tbody>
{host.serverIdHistorie.map((eintrag) => (
<tr key={eintrag.id}>
<td>
<CopyableCode wert={eintrag.serverId} />
</td>
<td>{formatDatum(eintrag.ersteMeldung)}</td>
<td>{formatDatum(eintrag.letzteMeldung)}</td>
</tr>
))}
{host.serverIdHistorie.length === 0 ? (
<tr>
<td colSpan={3} className="text-body-secondary">
Noch keine Server-ID gemeldet.
</td>
</tr>
) : null}
</tbody>
</table>
{host.serverIdHistorie.length > 1 ? (
<p className="small pl-text-warning mt-2 mb-0">
Mehrere Server-IDs bedeuten Hardwaretausch oder Neuinstallation – der Key muss
dann bei Proxmox neu zugewiesen werden.
</p>
) : null}
</>
),
},
{
id: "reports",
titel: "Reports",
titel: `Reports (${host.reports.length})`,
icon: "bi-clock-history",
inhalt: (
<table className="table table-striped align-middle mb-0">
<thead>
<tr>
<th scope="col">Zeitpunkt</th>
<th scope="col">Status</th>
<th scope="col">Key</th>
<th scope="col">Ablauf</th>
<th scope="col">Updates</th>
<th scope="col">Agent</th>
</tr>
</thead>
<tbody>
{reports.map((report) => (
<tr key={report.id}>
<td>{formatDatumZeit(report.zeitpunkt)}</td>
<td>
<StatusBadge status={report.status} />
</td>
<td>
<span className="pl-mono">{report.key ?? "–"}</span>
</td>
<td>{formatDatum(report.nextduedate)}</td>
<td>{formatZahl(report.paketUpdates)}</td>
<td>{report.agentVersion}</td>
<div className="table-responsive">
<table className="table table-striped align-middle mb-0">
<thead>
<tr>
<th scope="col">Erhoben</th>
<th scope="col">Empfangen</th>
<th scope="col">Status</th>
<th scope="col">Key</th>
<th scope="col">Ablauf</th>
<th scope="col">Updates</th>
<th scope="col">Agent</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{host.reports.map((report) => (
<tr key={report.id}>
<td>{formatDatumZeit(report.erhobenAm)}</td>
<td className="small text-body-secondary">
{formatDatumZeit(report.empfangenAm)}
</td>
<td>
<StatusBadge status={report.subStatus} />
</td>
<td className="pl-mono">{report.subKey ?? "–"}</td>
<td>{formatDatum(report.subNextduedate)}</td>
<td>{formatZahl(report.paketUpdates)}</td>
<td>{report.agentVersion ?? "–"}</td>
</tr>
))}
{host.reports.length === 0 ? (
<tr>
<td colSpan={7} className="text-body-secondary">
Noch kein Report eingegangen.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
),
},
{
@@ -235,69 +320,141 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
titel: "Rechnungen",
icon: "bi-receipt",
inhalt: (
<table className="table table-striped align-middle mb-0">
<thead>
<tr>
<th scope="col">Laufzeit</th>
<th scope="col">Proxmox-Rechnung</th>
<th scope="col">Einkauf</th>
<th scope="col">Kundenrechnung</th>
<th scope="col">Verkauf</th>
<th scope="col">Marge</th>
</tr>
</thead>
<tbody>
{laufzeiten.map((laufzeit) => (
<tr key={laufzeit.id}>
<td>
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
{laufzeit.vorschlag ? (
<span className="badge pl-bg-info ms-2">Vorschlag</span>
) : null}
</td>
<td>
<CopyableCode wert={laufzeit.einkauf?.nummer ?? null} />
<div>
<EinkaufBadge
bezahlt={laufzeit.einkauf ? laufzeit.einkauf.bezahlt : null}
/>
</div>
</td>
<td>{formatEuro(laufzeit.einkauf?.nettoBetrag)}</td>
<td>
<CopyableCode wert={laufzeit.verkauf?.nummer ?? null} />
<div>
<VerkaufBadge
erstellt={laufzeit.verkauf?.erstellt ?? false}
bezahlt={laufzeit.verkauf?.bezahlt ?? false}
/>
</div>
</td>
<td>{formatEuro(laufzeit.verkauf?.nettoBetrag)}</td>
<td>
{laufzeit.einkauf && laufzeit.verkauf?.nettoBetrag
? formatEuro(
(laufzeit.verkauf.nettoBetrag ?? 0) -
(laufzeit.einkauf.nettoBetrag ?? 0),
)
: "–"}
</td>
<div className="table-responsive">
<table className="table table-striped align-middle mb-0">
<thead>
<tr>
<th scope="col">Laufzeit</th>
<th scope="col">Proxmox-Rechnung</th>
<th scope="col">Einkauf</th>
<th scope="col">Kundenrechnung</th>
<th scope="col">Verkauf</th>
<th scope="col">Marge</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{host.laufzeiten.map((laufzeit) => {
const einkauf = laufzeit.proxmoxPosition
? Number(laufzeit.proxmoxPosition.nettoBetrag)
: null;
const verkauf = laufzeit.kundenPosition
? Number(laufzeit.kundenPosition.nettoBetrag)
: null;
return (
<tr key={laufzeit.id}>
<td>
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
{laufzeit.status === "VORSCHLAG" ? (
<span className="badge pl-bg-info ms-2">Vorschlag</span>
) : null}
</td>
<td>
<CopyableCode wert={laufzeit.proxmoxPosition?.rechnung.nummer ?? null} />
<div>
<EinkaufBadge
bezahlt={
laufzeit.proxmoxPosition
? laufzeit.proxmoxPosition.rechnung.bezahlt
: null
}
/>
</div>
</td>
<td>{formatEuro(einkauf)}</td>
<td>
<CopyableCode wert={laufzeit.kundenPosition?.rechnung.nummer ?? null} />
<div>
<VerkaufBadge
erstellt={laufzeit.kundenPosition?.rechnung.erstellt ?? false}
bezahlt={laufzeit.kundenPosition?.rechnung.bezahlt ?? false}
/>
</div>
</td>
<td>{formatEuro(verkauf)}</td>
<td>
{einkauf !== null && verkauf !== null
? formatEuro(verkauf - einkauf)
: "–"}
</td>
</tr>
);
})}
{host.laufzeiten.length === 0 ? (
<tr>
<td colSpan={6} className="text-body-secondary">
Noch keine Laufzeit erfasst.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
),
},
{
id: "notizen",
titel: "Notizen",
titel: "Notizen und Tags",
icon: "bi-journal-text",
inhalt: (
<p className="mb-0">
{host.notizen ?? (
<span className="text-body-secondary">Keine Notizen hinterlegt.</span>
)}
</p>
<HostNotizen
hostId={host.id}
notizen={host.notizen}
tags={host.tags.map((tag) => tag.name)}
/>
),
},
{
id: "tokens",
titel: "Tokens",
icon: "bi-key-fill",
inhalt: (
<table className="table table-striped align-middle mb-0">
<thead>
<tr>
<th scope="col">Anfang</th>
<th scope="col">Erstellt</th>
<th scope="col">Zuletzt benutzt</th>
<th scope="col">Zustand</th>
<th scope="col" className="text-end">
Aktion
</th>
</tr>
</thead>
<tbody>
{host.tokens.map((token) => (
<tr key={token.id}>
<td className="pl-mono">{token.praefix}…</td>
<td>{formatDatum(token.erstelltAm)}</td>
<td>{formatDatumZeit(token.letzteNutzungAm)}</td>
<td>
{token.widerrufenAm ? (
<Badge farbe="neutral">Widerrufen</Badge>
) : (
<Badge farbe="success">Aktiv</Badge>
)}
</td>
<td className="text-end">
{token.widerrufenAm ? null : (
<form action={hostTokenWiderrufen}>
<input type="hidden" name="id" value={token.id} />
<input type="hidden" name="hostId" value={host.id} />
<button type="submit" className="btn btn-sm btn-outline-danger">
Widerrufen
</button>
</form>
)}
</td>
</tr>
))}
{host.tokens.length === 0 ? (
<tr>
<td colSpan={5} className="text-body-secondary">
Kein Token vorhanden – dieser Host stammt aus den Demo-Daten.
</td>
</tr>
) : null}
</tbody>
</table>
),
},
]}
+69
View File
@@ -0,0 +1,69 @@
import { PRODUKT_TEXT, STATUS_TEXT, statusAusDatenbank } from "@/lib/anzeige";
import { csvAntwort, csvDatei } from "@/lib/csv";
import { hostListe, type HostFilter } from "@/server/queries/hostliste";
export const dynamic = "force-dynamic";
/** CSV-Export der Host-Tabelle; übernimmt die Filter aus der Adresse. */
export async function GET(request: Request) {
const parameter = new URL(request.url).searchParams;
const filter: HostFilter = {
q: parameter.get("q") ?? undefined,
kunde: parameter.get("kunde") ?? undefined,
status: parameter.get("status") ?? undefined,
produkt: parameter.get("produkt") ?? undefined,
ablauf: (parameter.get("ablauf") as HostFilter["ablauf"]) ?? undefined,
rechnung: (parameter.get("rechnung") as HostFilter["rechnung"]) ?? undefined,
sortierung: parameter.get("sortierung") ?? undefined,
// Der Export enthält alle Treffer, nicht nur die aktuelle Seite.
seite: 1,
};
const { gesamt } = await hostListe({ ...filter }, new Date());
const alle = [];
for (let seite = 1; alle.length < gesamt; seite++) {
const teil = await hostListe({ ...filter, seite }, new Date());
if (teil.hosts.length === 0) break;
alle.push(...teil.hosts);
}
const inhalt = csvDatei(
[
"Hostname",
"FQDN",
"Kunde",
"Standort",
"Produkt",
"Version",
"Status",
"Subscription-Key",
"Server-ID",
"Sockets lizenziert",
"Sockets physisch",
"Ablauf",
"Letzte Meldung",
"IP-Adressen",
"Agent-Version",
],
alle.map((host) => [
host.hostname,
host.fqdn,
host.kunde?.name ?? "Nicht zugeordnet",
host.standort?.name ?? "",
PRODUKT_TEXT[host.produkt],
host.produktVersion,
STATUS_TEXT[statusAusDatenbank(host.subStatus)],
host.subKey,
host.serverId,
host.subLizenzierteSockets,
host.sockets,
host.subNextduedate,
host.letzterReportAm,
host.ips.join(", "),
host.agentVersion,
]),
);
const datum = new Date().toISOString().slice(0, 10);
return csvAntwort(inhalt, `proxledger-hosts-${datum}.csv`);
}
+177 -90
View File
@@ -1,23 +1,96 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { HostFilterLeiste } from "@/components/hosts/HostFilterLeiste";
import { AblaufBadge, KeineMeldungBadge, StatusBadge } from "@/components/ui/Badges";
import { Card } from "@/components/ui/Card";
import { CopyableCode } from "@/components/ui/CopyableCode";
import { HEUTE, hosts } from "@/mock/data";
import { PRODUKT_NAME, getKunde, hostMeldetNicht, suche } from "@/mock/queries";
import { PRODUKT_TEXT } from "@/lib/anzeige";
import { formatDatumZeit } from "@/lib/format";
import { filterOptionen, hostListe, type HostFilter } from "@/server/queries/hostliste";
export const metadata = { title: "Hosts" };
export default async function HostsSeite({
searchParams,
}: {
searchParams: Promise<{ q?: string; status?: string; kunde?: string }>;
}) {
const { q, status, kunde } = await searchParams;
type SuchParameter = Promise<Record<string, string | string[] | undefined>>;
let liste = q ? suche(q).hosts : hosts;
if (status) liste = liste.filter((h) => h.subscription.status === status);
if (kunde) liste = liste.filter((h) => h.kundeId === kunde);
function einzeln(wert: string | string[] | undefined): string | undefined {
const text = Array.isArray(wert) ? wert[0] : wert;
return text && text.length > 0 ? text : undefined;
}
/** Baut eine Adresse mit geänderten Parametern – für Sortierung, Blättern und Export. */
function mitParametern(
basis: Record<string, string | undefined>,
aenderung: Record<string, string | undefined> = {},
pfad = "/hosts",
) {
const params = new URLSearchParams();
for (const [schluessel, wert] of Object.entries({ ...basis, ...aenderung })) {
if (wert) params.set(schluessel, wert);
}
const text = params.toString();
return text ? `${pfad}?${text}` : pfad;
}
/** Spaltenkopf, der beim Klick die Sortierung umschaltet. */
function SortierKopf({
titel,
feld,
basis,
sortierung,
}: {
titel: string;
feld: string;
basis: Record<string, string | undefined>;
sortierung: string | undefined;
}) {
const aktiv = (sortierung ?? "hostname").startsWith(feld);
const absteigend = sortierung === `${feld}-ab`;
return (
<th scope="col">
<Link
href={mitParametern(basis, { sortierung: absteigend ? feld : `${feld}-ab`, seite: undefined })}
className="text-decoration-none text-body d-inline-flex align-items-center gap-1"
>
{titel}
{aktiv ? (
<i
className={`bi ${absteigend ? "bi-caret-down-fill" : "bi-caret-up-fill"} small`}
aria-hidden="true"
/>
) : null}
</Link>
</th>
);
}
export default async function HostsSeite({ searchParams }: { searchParams: SuchParameter }) {
const roh = await searchParams;
const filter: HostFilter = {
q: einzeln(roh.q),
kunde: einzeln(roh.kunde),
status: einzeln(roh.status),
produkt: einzeln(roh.produkt),
ablauf: einzeln(roh.ablauf) as HostFilter["ablauf"],
rechnung: einzeln(roh.rechnung) as HostFilter["rechnung"],
sortierung: einzeln(roh.sortierung),
seite: Number(einzeln(roh.seite) ?? 1),
};
const jetzt = new Date();
const [{ hosts, gesamt, seite, seiten }, optionen] = await Promise.all([
hostListe(filter, jetzt),
filterOptionen(),
]);
const basis: Record<string, string | undefined> = {
q: filter.q,
kunde: filter.kunde,
status: filter.status,
produkt: filter.produkt,
ablauf: filter.ablauf,
rechnung: filter.rechnung,
sortierung: filter.sortierung,
};
return (
<>
@@ -26,7 +99,7 @@ export default async function HostsSeite({
<div className="app-content">
<div className="container-fluid">
<Card
titel={`${liste.length} von ${hosts.length} Hosts`}
titel={`${gesamt} Host${gesamt === 1 ? "" : "s"}${gesamt !== hosts.length ? ` · Seite ${seite} von ${seiten}` : ""}`}
rahmen="primary"
tabelle
werkzeuge={
@@ -34,106 +107,120 @@ export default async function HostsSeite({
<Link href="/hosts/nicht-zugeordnet" className="btn btn-sm btn-outline-primary">
Nicht zugeordnete
</Link>
<span className="btn btn-sm btn-outline-secondary disabled">CSV-Export (ab #007)</span>
<a
href={mitParametern(basis, {}, "/hosts/export")}
className="btn btn-sm btn-outline-secondary"
>
<i className="bi bi-filetype-csv me-1" aria-hidden="true" />
CSV
</a>
</>
}
>
<form className="p-3 border-bottom row g-2" role="search">
<div className="col-md-5">
<input
type="search"
name="q"
defaultValue={q ?? ""}
className="form-control"
placeholder="Hostname, Key, Server-ID oder IP …"
aria-label="Hosts durchsuchen"
/>
</div>
<div className="col-md-3">
<select name="status" defaultValue={status ?? ""} className="form-select" aria-label="Status">
<option value="">Alle Status</option>
<option value="active">Aktiv</option>
<option value="expired">Abgelaufen</option>
<option value="notfound">Keine Subscription</option>
</select>
</div>
<div className="col-md-2">
<button type="submit" className="btn btn-primary w-100">
Filtern
</button>
</div>
</form>
<HostFilterLeiste kunden={optionen.kunden} werte={basis} />
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">Host</th>
<th scope="col">Kunde</th>
<SortierKopf titel="Host" feld="hostname" basis={basis} sortierung={filter.sortierung} />
<SortierKopf titel="Kunde" feld="kunde" basis={basis} sortierung={filter.sortierung} />
<th scope="col">Produkt</th>
<th scope="col">Status</th>
<SortierKopf titel="Status" feld="status" basis={basis} sortierung={filter.sortierung} />
<th scope="col">Key</th>
<th scope="col">Ablauf</th>
<SortierKopf titel="Ablauf" feld="ablauf" basis={basis} sortierung={filter.sortierung} />
<th scope="col">Sockets</th>
<SortierKopf
titel="Letzte Meldung"
feld="meldung"
basis={basis}
sortierung={filter.sortierung}
/>
</tr>
</thead>
<tbody>
{liste.map((host) => {
const kundeDesHosts = getKunde(host.kundeId);
const socketAbweichung =
host.subscription.lizenzierteSockets !== null &&
host.sockets !== null &&
host.subscription.lizenzierteSockets !== host.sockets;
return (
<tr key={host.id}>
<td>
<Link href={`/hosts/${host.id}`} className="fw-semibold text-decoration-none">
{host.hostname}
{hosts.map((host) => (
<tr key={host.id}>
<td>
<Link href={`/hosts/${host.id}`} className="fw-semibold text-decoration-none">
{host.hostname}
</Link>
{host.meldetNicht ? <KeineMeldungBadge /> : null}
<div className="small text-body-secondary">{host.ips.join(", ") || "–"}</div>
</td>
<td>
{host.kunde ? (
<Link href={`/kunden/${host.kunde.id}`} className="text-decoration-none">
{host.kunde.name}
</Link>
{hostMeldetNicht(host) ? <KeineMeldungBadge /> : null}
<div className="small text-body-secondary">{host.ips.join(", ")}</div>
</td>
<td>
{kundeDesHosts ? (
<Link href={`/kunden/${kundeDesHosts.id}`} className="text-decoration-none">
{kundeDesHosts.name}
</Link>
) : (
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)}
</td>
<td>{PRODUKT_NAME[host.produkt]}</td>
<td>
<StatusBadge status={host.subscription.status} />
</td>
<td>
<CopyableCode wert={host.subscription.key} />
</td>
<td>
<AblaufBadge datum={host.subscription.nextduedate} heute={HEUTE} />
</td>
<td>
{host.subscription.lizenzierteSockets ?? "–"} / {host.sockets ?? "–"}
{socketAbweichung ? (
<i
className="bi bi-exclamation-triangle-fill pl-text-warning ms-2"
title="Lizenzierte und physische Sockets weichen ab"
/>
) : null}
</td>
</tr>
);
})}
{liste.length === 0 ? (
) : (
<Link
href="/hosts/nicht-zugeordnet"
className="badge pl-bg-neutral text-decoration-none"
>
Nicht zugeordnet
</Link>
)}
{host.standort ? (
<div className="small text-body-secondary">{host.standort.name}</div>
) : null}
</td>
<td>{PRODUKT_TEXT[host.produkt]}</td>
<td>
<StatusBadge status={host.subStatus} />
</td>
<td>
<CopyableCode wert={host.subKey} />
</td>
<td>
<AblaufBadge datum={host.subNextduedate?.toISOString() ?? null} heute={jetzt} />
</td>
<td>
{host.subLizenzierteSockets ?? "–"} / {host.sockets ?? "–"}
{host.socketAbweichung ? (
<i
className="bi bi-exclamation-triangle-fill pl-text-warning ms-2"
title="Lizenzierte und physische Sockets weichen ab"
/>
) : null}
</td>
<td className="small">{formatDatumZeit(host.letzterReportAm)}</td>
</tr>
))}
{hosts.length === 0 ? (
<tr>
<td colSpan={7} className="text-center text-body-secondary py-4">
Keine Hosts gefunden.
<td colSpan={8} className="text-center text-body-secondary py-4">
Keine Hosts gefunden. <Link href="/hosts">Filter zurücksetzen</Link>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
{seiten > 1 ? (
<nav className="p-3 border-top" aria-label="Seiten">
<ul className="pagination pagination-sm mb-0">
<li className={`page-item ${seite <= 1 ? "disabled" : ""}`}>
<Link className="page-link" href={mitParametern(basis, { seite: String(seite - 1) })}>
Zurück
</Link>
</li>
{Array.from({ length: seiten }, (_, i) => i + 1).map((nummer) => (
<li key={nummer} className={`page-item ${nummer === seite ? "active" : ""}`}>
<Link className="page-link" href={mitParametern(basis, { seite: String(nummer) })}>
{nummer}
</Link>
</li>
))}
<li className={`page-item ${seite >= seiten ? "disabled" : ""}`}>
<Link className="page-link" href={mitParametern(basis, { seite: String(seite + 1) })}>
Weiter
</Link>
</li>
</ul>
</nav>
) : null}
</Card>
</div>
</div>