feat(hosts): Schlagworte und Notizen sichtbar machen (#040)

Gleichnamige Hosts in verschiedenen Kundennetzen liessen sich in der Liste
nicht auseinanderhalten. Schlagworte und Notizen gab es zwar schon, aber nur
im Reiter "Notizen und Tags" - und damit nutzlos zum Unterscheiden.

Jetzt stehen die Schlagworte als anklickbare Abzeichen in der Liste und oben
im Host-Detail, es gibt einen Filter dafuer, eine Notiz zeigt ein
Zettel-Symbol mit dem Text beim Ueberfahren, und beides wird mitdurchsucht -
auch von der globalen Suche.

Bewusst kein zweites Feld "Kommentar": Das haette dieselbe Angabe an zwei
Orten gehalten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-03 17:43:55 +02:00
co-authored by Claude Opus 5
parent dfb2118bac
commit b2664de62f
25 changed files with 243 additions and 9 deletions
+21
View File
@@ -108,6 +108,27 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
<h2 className="h5 mt-2 mb-1">{host.hostname}</h2>
<div className="text-body-secondary small">{host.fqdn ?? "Kein FQDN gemeldet"}</div>
{meldetNicht ? <KeineMeldungBadge /> : null}
{/* Schlagworte und Notiz stehen hier, nicht nur im Reiter – bei gleichnamigen
Hosts ist das die Stelle, an der man sie braucht (#040). */}
{host.tags.length > 0 ? (
<div className="mt-2 d-flex flex-wrap gap-1 justify-content-center">
{host.tags.map((tag) => (
<Link
key={tag.id}
href={`/hosts?tag=${encodeURIComponent(tag.name)}`}
className="badge pl-bg-neutral text-decoration-none"
>
{tag.name}
</Link>
))}
</div>
) : null}
{host.notizen ? (
<p className="small text-body-secondary mt-2 mb-0 text-start border-start ps-2">
{host.notizen}
</p>
) : null}
</div>
<Zeile bezeichnung="Kunde">
+24 -1
View File
@@ -68,6 +68,7 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
const filter: HostFilter = {
q: einzeln(roh.q),
kunde: einzeln(roh.kunde),
tag: einzeln(roh.tag),
status: einzeln(roh.status),
produkt: einzeln(roh.produkt),
ablauf: einzeln(roh.ablauf) as HostFilter["ablauf"],
@@ -85,6 +86,7 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
const basis: Record<string, string | undefined> = {
q: filter.q,
kunde: filter.kunde,
tag: filter.tag,
status: filter.status,
produkt: filter.produkt,
ablauf: filter.ablauf,
@@ -117,7 +119,7 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
</>
}
>
<HostFilterLeiste kunden={optionen.kunden} werte={basis} />
<HostFilterLeiste kunden={optionen.kunden} tags={optionen.tags} werte={basis} />
<div className="table-responsive">
<table className="table table-striped align-middle">
@@ -152,7 +154,28 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
{host.hostname}
</Link>
{host.meldetNicht ? <KeineMeldungBadge /> : null}
{host.notizen ? (
// Gerade bei gleichnamigen Hosts hilft die Notiz beim Auseinanderhalten (#040).
<i
className="bi bi-sticky ms-2 text-body-secondary"
title={host.notizen}
aria-label={`Notiz: ${host.notizen}`}
/>
) : null}
<div className="small text-body-secondary">{host.ips.join(", ") || "–"}</div>
{host.tags.length > 0 ? (
<div className="mt-1 d-flex flex-wrap gap-1">
{host.tags.map((tag) => (
<Link
key={tag.id}
href={`/hosts?tag=${encodeURIComponent(tag.name)}`}
className="badge pl-bg-neutral text-decoration-none"
>
{tag.name}
</Link>
))}
</div>
) : null}
</td>
<td>
{host.kunde ? (
+24 -1
View File
@@ -9,9 +9,11 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
*/
export function HostFilterLeiste({
kunden,
tags,
werte,
}: {
kunden: { id: string; name: string }[];
tags: string[];
werte: Record<string, string | undefined>;
}) {
const router = useRouter();
@@ -26,7 +28,7 @@ export function HostFilterLeiste({
router.push(`${pathname}?${neu.toString()}`);
}
const aktiveFilter = ["kunde", "status", "produkt", "ablauf", "rechnung", "q"].filter(
const aktiveFilter = ["kunde", "tag", "status", "produkt", "ablauf", "rechnung", "q"].filter(
(schluessel) => werte[schluessel],
).length;
@@ -76,6 +78,27 @@ export function HostFilterLeiste({
</select>
</div>
{tags.length > 0 ? (
<div className="col-6 col-md-2">
<label className="visually-hidden" htmlFor="tag">
Schlagwort
</label>
<select
className="form-select"
id="tag"
value={werte.tag ?? ""}
onChange={(e) => setzen("tag", e.target.value)}
>
<option value="">Alle Schlagworte</option>
{tags.map((tag) => (
<option key={tag} value={tag}>
{tag}
</option>
))}
</select>
</div>
) : null}
<div className="col-6 col-md-2">
<select
className="form-select"
+17 -1
View File
@@ -6,6 +6,8 @@ import { sichtbarerKunde } from "@/server/auth/sitzungspflicht";
export interface HostFilter {
q?: string;
kunde?: string;
/** Schlagwort, nach dem gefiltert wird (#040). */
tag?: string;
status?: string;
produkt?: string;
ablauf?: "30" | "14" | "abgelaufen";
@@ -20,6 +22,10 @@ export const SEITENGROESSE = 25;
function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput {
const und: Prisma.HostWhereInput[] = [];
if (filter.tag) {
und.push({ tags: { some: { name: filter.tag } } });
}
if (filter.q && filter.q.trim().length > 0) {
const suche = filter.q.trim();
und.push({
@@ -30,6 +36,8 @@ function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput {
{ subKey: { contains: suche, mode: "insensitive" } },
{ ips: { has: suche } },
{ kunde: { name: { contains: suche, mode: "insensitive" } } },
{ notizen: { contains: suche, mode: "insensitive" } },
{ tags: { some: { name: { contains: suche, mode: "insensitive" } } } },
// Suche nach einer Proxmox-Rechnungsnummer führt ebenfalls zum Host.
{
laufzeiten: {
@@ -113,6 +121,7 @@ export async function hostListe(filter: HostFilter, jetzt: Date = new Date()) {
include: {
kunde: { select: { id: true, name: true } },
standort: { select: { name: true } },
tags: { orderBy: { name: "asc" }, select: { id: true, name: true } },
},
}),
]);
@@ -181,5 +190,12 @@ export async function filterOptionen() {
orderBy: { name: "asc" },
select: { id: true, name: true },
});
return { kunden };
// Nur Schlagworte, die auch an einem sichtbaren Host hängen – sonst stünden leere zur Auswahl.
const tags = await prisma.tag.findMany({
where: { hosts: { some: kundeId ? { kundeId } : {} } },
orderBy: { name: "asc" },
select: { name: true },
});
return { kunden, tags: tags.map((tag) => tag.name) };
}
+12 -2
View File
@@ -103,7 +103,9 @@ export async function suchIndex() {
subKey: true,
ips: true,
produkt: true,
notizen: true,
kunde: { select: { name: true } },
tags: { select: { name: true } },
},
}),
prisma.kunde.findMany({
@@ -128,9 +130,17 @@ export async function suchIndex() {
titel: host.hostname,
untertitel: `${host.produkt} · ${host.kunde?.name ?? "Nicht zugeordnet"}${
host.subKey ? ` · ${host.subKey}` : ""
}`,
}${host.tags.length > 0 ? ` · ${host.tags.map((tag) => tag.name).join(", ")}` : ""}`,
href: `/hosts/${host.id}`,
begriffe: [host.hostname, host.fqdn, host.serverId, host.subKey, ...host.ips]
begriffe: [
host.hostname,
host.fqdn,
host.serverId,
host.subKey,
host.notizen,
...host.tags.map((tag) => tag.name),
...host.ips,
]
.filter((w): w is string => Boolean(w))
.map((w) => w.toLowerCase()),
})),