feat(ui): Prototyp Uebersicht und Schluesseldetail (#002)

Klickbarer Prototyp mit Beispieldaten aus src/beispiel/, sichtbar als
Beispiel gekennzeichnet. Er zeigt das verbindliche Design an echten
Bildschirmen, bevor die Lesemodelle (#012, #021, #026) existieren; die
Seiten tauschen spaeter nur die Datenquelle. Die bisherige Startseite
entfaellt zugunsten der Route-Gruppe (app).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 09:20:34 +02:00
co-authored by Claude Opus 5.5
parent 419e8ff8bd
commit 445f5c5ebd
5 changed files with 532 additions and 3 deletions
+187
View File
@@ -0,0 +1,187 @@
import { ArrowRight, Plus, RefreshCw, Route } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import {
abweichungen,
BEISPIEL_JETZT,
baldAblaufend,
kennzahlen,
letzteAktionen,
letzterAbgleich,
type AktionsArt,
} from "@/beispiel/uebersicht";
import { Badge } from "@/components/ui/Badge";
import { Button } from "@/components/ui/Button";
import { Karte } from "@/components/ui/Karte";
import { Kennzahl } from "@/components/ui/Kennzahl";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { Statuspunkt } from "@/components/ui/Statuspunkt";
import { Tabelle, TabellenZeile, TabellenZelle } from "@/components/ui/Tabelle";
import { datum, datumZeit, relativ, tageBis, uhrzeit } from "@/lib/format";
export const metadata: Metadata = { title: "Übersicht" };
const AKTION: Record<AktionsArt, { text: string; farbe: string }> = {
ausgerollt: { text: "Ausgerollt", farbe: "text-ok" },
widerrufen: { text: "Widerrufen", farbe: "text-danger" },
erstellt: { text: "Erstellt", farbe: "text-text" },
abgleich: { text: "Abgleich", farbe: "text-info" },
rotiert: { text: "Rotiert", farbe: "text-accent" },
};
/**
* Prototyp der Übersicht mit Beispieldaten (#002). Die echte Übersicht mit Lesemodellen
* folgt in #026; Aufbau und Komponenten bleiben.
*/
export default function Uebersicht() {
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
titel="Übersicht"
badges={<Badge variante="messing">Prototyp · Beispieldaten</Badge>}
untertitel={
<>
Letzter Abgleich {relativ(letzterAbgleich, BEISPIEL_JETZT)} ·{" "}
<time dateTime={letzterAbgleich} className="font-mono text-[13px]">
{datumZeit(letzterAbgleich)}
</time>
</>
}
aktionen={
<>
<Button icon={<RefreshCw className="size-4" />}>Jetzt abgleichen</Button>
<Button href="/zuweisungen" icon={<Route className="size-4" />}>
Host-zu-Host-Assistent
</Button>
<Button
href="/schluessel"
variante="primaer"
icon={<Plus className="size-4" strokeWidth={2.4} />}
>
Neuer Schlüssel
</Button>
</>
}
/>
<div className="grid grid-cols-2 gap-3 sm:gap-5 xl:grid-cols-4">
<Kennzahl
titel="Hosts"
wert={kennzahlen.hosts.wert}
hinweis={kennzahlen.hosts.hinweis}
href="/hosts"
/>
<Kennzahl
titel="Schlüssel"
wert={kennzahlen.schluessel.wert}
hinweis={kennzahlen.schluessel.hinweis}
href="/schluessel"
/>
<Kennzahl
titel="Abweichungen"
variante="gefahr"
wert={kennzahlen.abweichungen.wert}
hinweis={kennzahlen.abweichungen.hinweis}
href="/hosts"
/>
<Kennzahl
titel="Läuft in 30 Tagen ab"
variante="messing"
wert={kennzahlen.ablaufend.wert}
hinweis={kennzahlen.ablaufend.hinweis}
href="/schluessel"
/>
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.55fr)_minmax(0,1fr)]">
<Karte titel="Hosts mit Abweichung" untertitel="Ergebnis des letzten Abgleichs">
<Tabelle
beschriftung="Hosts mit Abweichung"
spalten={[
{ titel: "Host" },
{ titel: "Benutzer" },
{ titel: "Befund" },
{ titel: "Aktion", rechts: true, versteckt: true },
]}
>
{abweichungen.map((a) => (
<TabellenZeile key={`${a.host}-${a.benutzer}`}>
<TabellenZelle mono className="whitespace-nowrap">
{a.host}
</TabellenZelle>
<TabellenZelle mono>{a.benutzer}</TabellenZelle>
<TabellenZelle className="whitespace-nowrap">
<Statuspunkt status={a.status} text={a.befund} />
</TabellenZelle>
<TabellenZelle rechts>
<Link
href="/hosts"
className="text-accent -my-2 inline-flex min-h-11 items-center px-1 font-medium hover:underline"
>
Prüfen<span className="sr-only"> – {a.host}</span>
</Link>
</TabellenZelle>
</TabellenZeile>
))}
</Tabelle>
</Karte>
<Karte titel="Bald ablaufend" untertitel="Schlüssel mit Ablaufdatum in den nächsten 30 Tagen">
<ul className="flex flex-col">
{baldAblaufend.map((s) => {
const tage = tageBis(s.ablauf, BEISPIEL_JETZT);
return (
<li key={s.zweck} className="border-border border-b last:border-b-0">
<Link
href="/schluessel/beispiel"
className="hover:bg-raised/50 rounded-box -mx-2 flex min-h-11 items-center justify-between gap-3 px-2 py-2.5 transition-colors"
>
<span className="min-w-0">
<span className="text-text block truncate text-[14.5px] font-medium">{s.zweck}</span>
<span className="text-muted block truncate text-[13px]">
{s.person} · <span className="font-mono">{s.typ}</span>
</span>
</span>
<Badge variante="messing" className="shrink-0">
<time dateTime={s.ablauf}>{tage === 1 ? "morgen" : `in ${tage} Tagen`}</time>
</Badge>
</Link>
</li>
);
})}
</ul>
</Karte>
</div>
<Karte
titel="Letzte Aktionen"
aktionen={
<Link
href="/protokoll"
className="text-accent inline-flex min-h-11 items-center gap-1.5 text-[14px] font-medium hover:underline"
>
Zum Audit-Log <ArrowRight className="size-4" aria-hidden />
</Link>
}
>
<ol className="flex flex-col">
{letzteAktionen.map((a) => (
<li
key={a.zeit}
className="border-border grid grid-cols-[auto_1fr] gap-x-4 gap-y-0.5 border-b py-3 text-[14px] last:border-b-0 md:grid-cols-[8.5rem_6.5rem_1fr_auto] md:items-baseline"
>
<time dateTime={a.zeit} className="text-muted font-mono text-[12.5px] whitespace-nowrap">
{datum(a.zeit)} {uhrzeit(a.zeit)}
</time>
<span className={`${AKTION[a.art].farbe} font-medium md:order-none`}>{AKTION[a.art].text}</span>
<span className="text-text col-span-2 min-w-0 md:col-span-1">{a.beschreibung}</span>
<span className="text-muted col-span-2 text-[13px] md:col-span-1 md:text-right">
{a.benutzer}
</span>
</li>
))}
</ol>
</Karte>
</div>
);
}
+181
View File
@@ -0,0 +1,181 @@
import { Ban, Check, Lock, PlugZap, Plus, RotateCw } from "lucide-react";
import type { Metadata } from "next";
import type { ReactNode } from "react";
import {
beispielSchluessel as s,
dryRunDatei,
dryRunDiff,
privatAusgerollt,
publicAusgerollt,
zusatzaktionen,
type Ausrollung,
} from "@/beispiel/schluessel";
import { BEISPIEL_JETZT } from "@/beispiel/uebersicht";
import { Badge } from "@/components/ui/Badge";
import { Breadcrumb } from "@/components/ui/Breadcrumb";
import { Button } from "@/components/ui/Button";
import { DiffBox } from "@/components/ui/DiffBox";
import { InnereBox, Karte } from "@/components/ui/Karte";
import { MonoKopieren } from "@/components/ui/MonoKopieren";
import { Seitenkopf } from "@/components/ui/Seitenkopf";
import { Statuspunkt } from "@/components/ui/Statuspunkt";
import { datum, datumZeit, tageBis } from "@/lib/format";
export const metadata: Metadata = { title: `${s.zweck} (Beispiel)` };
/** Prototyp des Schlüsseldetails mit Beispieldaten (#002); echte Daten ab #012/#016/#017. */
export default function SchluesselBeispiel() {
const tage = tageBis(s.ablauf, BEISPIEL_JETZT);
return (
<div className="flex flex-col gap-6 sm:gap-7">
<Seitenkopf
oben={<Breadcrumb pfad={[{ titel: "Schlüssel", href: "/schluessel" }, { titel: s.zweck }]} />}
titel={s.zweck}
badges={
<>
<Badge mono>{s.typ}</Badge>
<Badge>{s.kategorie}</Badge>
<Badge>Vorlage: {s.vorlage}</Badge>
<Badge variante="messing">
<span>
läuft ab am <time dateTime={s.ablauf}>{datum(s.ablauf)}</time> · in {tage} Tagen
</span>
</Badge>
<Badge variante="neutral" className="text-muted">
Beispieldaten
</Badge>
</>
}
aktionen={
<>
<Button icon={<PlugZap className="size-4" />}>Verbindung testen</Button>
<Button icon={<RotateCw className="size-4" />}>Rotieren</Button>
<Button variante="gefahr" icon={<Ban className="size-4" />}>
Widerrufen
</Button>
</>
}
/>
<Karte as="div" innen="ohne">
<dl className="divide-border grid divide-y md:grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,1.1fr)] md:divide-x md:divide-y-0">
<InfoFeld titel="Fingerprint">
<MonoKopieren wert={s.fingerprint} beschriftung="Fingerprint" umbrechen className="-my-[11px]" />
</InfoFeld>
<InfoFeld titel="Erstellt">
<time dateTime={s.erstellt} className="font-mono text-[13px]">
{datumZeit(s.erstellt)}
</time>
<span className="text-muted block text-[13px]">von {s.erstelltVon}</span>
</InfoFeld>
<InfoFeld titel="Private Key">
<span className="inline-flex flex-wrap items-center gap-x-1.5 text-[14px]">
<Lock className="text-ok size-3.5" aria-hidden />
verschlüsselt gespeichert ·
<button
type="button"
className="text-accent -my-3 inline-flex min-h-11 items-center font-medium hover:underline"
>
anzeigen (Re-Auth)
</button>
</span>
</InfoFeld>
</dl>
</Karte>
<div className="grid gap-6 lg:grid-cols-2">
<AusrollKarte
titel="Private Key ausgerollt auf"
untertitel="Hosts, die sich mit diesem Schlüssel anmelden"
eintraege={privatAusgerollt}
/>
<AusrollKarte
titel="Public Key ausgerollt auf"
untertitel="Konten, die diesen Schlüssel annehmen"
eintraege={publicAusgerollt}
/>
</div>
<Karte
titel="Dry-Run-Vorschau"
untertitel={
<>
Abgleich für <span className="font-mono">backup@srv-pve02</span> – noch nichts geschrieben
</>
}
>
<DiffBox datei={dryRunDatei} zeilen={dryRunDiff} />
<fieldset className="mt-5">
<legend className="text-muted mb-1 text-[13px] font-medium">Zusätzlich ausführen</legend>
<ul className="flex flex-col">
{zusatzaktionen.map((z) => (
<li key={z.id}>
<label className="flex min-h-11 cursor-pointer items-center gap-3 text-[14.5px]">
<input
type="checkbox"
name={z.id}
defaultChecked={z.an}
className="accent-accent size-[18px] shrink-0"
/>
<span>{z.text}</span>
</label>
</li>
))}
</ul>
</fieldset>
<div className="border-border mt-5 flex flex-wrap gap-2.5 border-t pt-5">
<Button variante="primaer" icon={<Check className="size-4" strokeWidth={2.4} />}>
Änderung anwenden
</Button>
<Button>Verwerfen</Button>
</div>
</Karte>
</div>
);
}
function InfoFeld({ titel, children }: { titel: string; children: ReactNode }) {
return (
<div className="min-w-0 px-5 py-4 sm:px-6">
<dt className="text-muted mb-1.5 text-[13px] font-medium">{titel}</dt>
<dd className="min-w-0">{children}</dd>
</div>
);
}
function AusrollKarte({
titel,
untertitel,
eintraege,
}: {
titel: string;
untertitel: string;
eintraege: Ausrollung[];
}) {
return (
<Karte
titel={titel}
untertitel={untertitel}
aktionen={
<Button klein icon={<Plus className="size-4" strokeWidth={2.4} />}>
Ausrollen
</Button>
}
>
<ul className="flex flex-col gap-3">
{eintraege.map((e) => (
<li key={`${e.ziel}${e.pfad}`}>
<InnereBox className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="text-text font-mono text-[13.5px] font-medium break-all">{e.ziel}</p>
<p className="text-muted mt-1 font-mono text-[12.5px] break-all">{e.pfad}</p>
{e.zusatz && <p className="text-muted font-mono text-[12.5px] break-all">{e.zusatz}</p>}
</div>
<Statuspunkt status={e.status} text={e.statusText} className="shrink-0 sm:pt-0.5" />
</InnereBox>
</li>
))}
</ul>
</Karte>
);
}
-3
View File
@@ -1,3 +0,0 @@
export default function Startseite() {
return <main>SSHKeyVault</main>;
}