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:
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
})),
|
||||
|
||||
Reference in New Issue
Block a user