design v5: alle Seiten im neuen Look

- Gemeinsamer Lagekopf für beide Cockpits (Statusblock mit Schein, Kacheln mit
  Logo/Symbol und Zustandspunkt); KI-Box-Lampen als Dienst-Kacheln, Texte in
  normaler Schreibung. Alte Lampen-Komponente und Leuchtenfarben entfernt.
- Instrumente, Zählwerk, Checkliste, Flugplan, Radar-Kasten im neuen Stil;
  Instrumente ruhig in Indigo, Farbe für Zustände erst ab der Grenze.
- Übrige Seiten (Updates, Modelle, Nutzung, Suche, Monitoring, Dienste,
  Einstellungen, Protokoll, Dialoge): Glasflächen, feine Linien, Etiketten
  statt Instrumenten-Schild, Glas-Eingabefelder, deckende Dialoge.
- Dienste der KI-Box und Geräte im Monitoring mit Symbol bzw. Logo.
- Einstellungen: Karten mit Bereichs-Schein; Lizenzhinweis der Logos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 15:46:35 +02:00
co-authored by Claude Opus 5.5
parent db452aa38a
commit 0d2687436e
30 changed files with 301 additions and 262 deletions
+2 -2
View File
@@ -4,12 +4,12 @@ import type { ErrorComponentProps } from "@tanstack/react-router"
export function Absturz({ error }: ErrorComponentProps) { export function Absturz({ error }: ErrorComponentProps) {
return ( return (
<div role="alert" className="flex flex-col items-start gap-3 rounded-2xl border border-rot-rand bg-rot-grund p-6 text-rot-text"> <div role="alert" className="flex flex-col items-start gap-3 rounded-2xl border border-rot-rand bg-rot-grund p-6 text-rot-text">
<p className="schild text-lg text-rot">Diese Ansicht ist abgestürzt</p> <p className="text-lg font-semibold text-rot-text">Diese Ansicht ist abgestürzt</p>
<p className="text-[15px]">{error instanceof Error ? error.message : String(error)}</p> <p className="text-[15px]">{error instanceof Error ? error.message : String(error)}</p>
<button <button
type="button" type="button"
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
className="schild h-11 rounded-lg border border-rot-rand px-5 text-sm text-foreground hover:bg-[#3a1512]" className="h-11 rounded-xl border border-rot/40 bg-rot/10 px-5 text-sm font-semibold text-rot-text hover:bg-rot/[0.18]"
> >
Neu laden Neu laden
</button> </button>
+2 -2
View File
@@ -44,7 +44,7 @@ function Verbindung() {
<span <span
role="status" role="status"
title={live ? "Verbunden" : lage === "verbinde" ? "Verbinde" : "Getrennt"} title={live ? "Verbunden" : lage === "verbinde" ? "Verbinde" : "Getrennt"}
className={cn("schild flex items-center gap-1.5 text-[13px]", live ? "text-text-3" : "text-bernstein")} className={cn("etikett flex items-center gap-1.5 text-[11px] tracking-[0.12em]", live ? "text-text-3" : "text-bernstein")}
> >
<span aria-hidden className={cn("size-2 rounded-full", live ? "bg-gruen shadow-[0_0_8px_var(--gruen)]" : "bg-bernstein")} /> <span aria-hidden className={cn("size-2 rounded-full", live ? "bg-gruen shadow-[0_0_8px_var(--gruen)]" : "bg-bernstein")} />
<span className={live ? "sr-only" : undefined}>{live ? "Verbunden" : lage === "verbinde" ? "Verbinde" : "Getrennt"}</span> <span className={live ? "sr-only" : undefined}>{live ? "Verbunden" : lage === "verbinde" ? "Verbinde" : "Getrennt"}</span>
@@ -79,7 +79,7 @@ export function Shell() {
<button <button
type="button" type="button"
onClick={() => setMenue(true)} onClick={() => setMenue(true)}
className="-ml-2 flex size-11 shrink-0 items-center justify-center rounded-lg text-text-2 hover:bg-erhaben hover:text-foreground lg:hidden" className="-ml-2 flex size-11 shrink-0 items-center justify-center rounded-lg text-text-2 hover:bg-white/[0.06] hover:text-foreground lg:hidden"
aria-label="Menü öffnen" aria-label="Menü öffnen"
> >
<Menu className="size-6" /> <Menu className="size-6" />
@@ -38,7 +38,7 @@ export function Auftraege({ alsPanel = false }: { alsPanel?: boolean }) {
if (alsPanel) return <Panel titel="Aufträge">{inhalt}</Panel> if (alsPanel) return <Panel titel="Aufträge">{inhalt}</Panel>
return ( return (
<section aria-label="Aufträge" className="flex flex-col gap-2.5"> <section aria-label="Aufträge" className="flex flex-col gap-2.5">
<h3 className="schild m-0 text-sm text-text-3">Aufträge</h3> <h3 className="etikett m-0 text-[11.5px] tracking-[0.16em] text-text-3">Aufträge</h3>
{inhalt} {inhalt}
</section> </section>
) )
@@ -26,13 +26,13 @@ export function Bestaetigen({ offen, titel, text, knopf, neinKnopf = "Abbrechen"
{/* Nie höher als der Bildschirm: Auf kleinen Handys (320 × 568) lagen die Knöpfe sonst außerhalb {/* Nie höher als der Bildschirm: Auf kleinen Handys (320 × 568) lagen die Knöpfe sonst außerhalb
und ließen sich nicht erreichen. Der Text scrollt, die Knöpfe bleiben unten stehen — bündig, und ließen sich nicht erreichen. Der Text scrollt, die Knöpfe bleiben unten stehen — bündig,
darum ohne Bodenpolster (sticky rechnet das Polster ein, darunter schien sonst Text durch). */} darum ohne Bodenpolster (sticky rechnet das Polster ein, darunter schien sonst Text durch). */}
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto border-linie bg-panel pb-0 sm:max-w-lg"> <DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto border-white/10 bg-[#0e121a] pb-0 sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle className="schild pr-10 text-xl break-words">{titel}</DialogTitle> <DialogTitle className="pr-10 text-[19px] leading-snug font-semibold break-words">{titel}</DialogTitle>
<DialogDescription className="text-[15px] leading-relaxed text-text-2">{text}</DialogDescription> <DialogDescription className="text-[15px] leading-relaxed text-text-2">{text}</DialogDescription>
</DialogHeader> </DialogHeader>
{children} {children}
<DialogFooter className="sticky bottom-0 mb-0 gap-2 bg-erhaben sm:gap-2"> <DialogFooter className="sticky bottom-0 mb-0 gap-2 bg-[#121722] sm:gap-2">
<Button variant="outline" onClick={onNein}> <Button variant="outline" onClick={onNein}>
{neinKnopf} {neinKnopf}
</Button> </Button>
@@ -42,11 +42,12 @@ function Punkt({ h }: { h: Hinweis }) {
} }
return ( return (
<article className="flex flex-col gap-2.5 border-t border-linie pt-4"> <article className={cn("flex flex-col gap-2.5 rounded-2xl px-4 py-3.5 ring-1 ring-inset",
rot ? "bg-rot/[0.07] ring-rot/35" : "bg-bernstein/[0.06] ring-bernstein/30")}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h3 className="min-w-0 font-sans text-[17px] leading-snug font-semibold">{h.titel}</h3> <h3 className="min-w-0 flex-1 font-sans text-[16.5px] leading-snug font-semibold">{h.titel}</h3>
<span aria-hidden className="hidden h-0 flex-grow border-b-2 border-dotted border-linie-stark sm:block" /> <span className={cn("etikett shrink-0 rounded-full px-2.5 py-0.5 text-[10.5px] tracking-[0.12em] ring-1 ring-inset",
<span className={cn("schild shrink-0 text-base", rot ? "text-rot" : "text-bernstein")}> rot ? "bg-rot/12 text-rot-text ring-rot/40" : "bg-bernstein/10 text-bernstein ring-bernstein/35")}>
{rot ? "Jetzt" : "Prüfen"} {rot ? "Jetzt" : "Prüfen"}
</span> </span>
</div> </div>
@@ -101,7 +102,7 @@ export function Checkliste({ hinweise, verlauf }: { hinweise: Hinweis[]; verlauf
) )
if (hinweise.length === 0) { if (hinweise.length === 0) {
return ( return (
<div className="flex flex-col gap-2 border-t border-linie pt-4"> <div className="flex flex-col gap-2">
<p className="text-[15px] text-text-2">Keine offenen Punkte. Der Wächter prüft jede Minute.</p> <p className="text-[15px] text-text-2">Keine offenen Punkte. Der Wächter prüft jede Minute.</p>
{erledigtHeute.length > 0 && ( {erledigtHeute.length > 0 && (
<ul className="flex flex-col gap-1 text-sm text-text-3"> <ul className="flex flex-col gap-1 text-sm text-text-3">
@@ -116,7 +117,7 @@ export function Checkliste({ hinweise, verlauf }: { hinweise: Hinweis[]; verlauf
) )
} }
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-3">
{hinweise.map((h) => ( {hinweise.map((h) => (
<Punkt key={h.id} h={h} /> <Punkt key={h.id} h={h} />
))} ))}
+10 -7
View File
@@ -2,10 +2,10 @@ import { cn } from "cn"
import type { FlugplanEintrag } from "@/lib/typen" import type { FlugplanEintrag } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit" import { flugplanZeit } from "@/lib/zeit"
const STATUS: Record<FlugplanEintrag["status"], { text: string; farbe: string }> = { const STATUS: Record<FlugplanEintrag["status"], { text: string; farbe: string; punkt: string }> = {
erledigt: { text: "Erledigt", farbe: "text-gruen" }, erledigt: { text: "Erledigt", farbe: "text-gruen-text", punkt: "bg-gruen" },
fehler: { text: "Fehler", farbe: "text-rot" }, fehler: { text: "Fehler", farbe: "text-rot-text", punkt: "bg-rot" },
geplant: { text: "Geplant", farbe: "text-text-3" }, geplant: { text: "Geplant", farbe: "text-text-3", punkt: "bg-white/25" },
} }
// Die Liste richtet sich nach der Breite ihres Feldes, nicht des Bildschirms (@container): Schmal — // Die Liste richtet sich nach der Breite ihres Feldes, nicht des Bildschirms (@container): Schmal —
@@ -14,15 +14,18 @@ const STATUS: Record<FlugplanEintrag["status"], { text: string; farbe: string }>
function Zeile({ e }: { e: FlugplanEintrag }) { function Zeile({ e }: { e: FlugplanEintrag }) {
const s = STATUS[e.status] ?? STATUS.geplant const s = STATUS[e.status] ?? STATUS.geplant
// Updates sind das Wichtigste im Plan — sie stehen in Cyan. // Updates sind das Wichtigste im Plan — sie stehen in Cyan.
const farbe = e.status === "geplant" && /update/i.test(e.titel) ? "text-cyan" : s.farbe const farbe = e.status === "geplant" && /update/i.test(e.titel) ? "text-cyan-text" : s.farbe
return ( return (
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 border-t border-linie py-2.5 @sm:grid-cols-[112px_minmax(0,1fr)_auto]"> <li className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 border-t border-white/[0.06] py-2.5 first:border-t-0 @sm:grid-cols-[112px_minmax(0,1fr)_auto]">
<span className="ziffern col-start-1 row-start-1 text-sm text-text-3">{flugplanZeit(e.zeit)}</span> <span className="ziffern col-start-1 row-start-1 text-sm text-text-3">{flugplanZeit(e.zeit)}</span>
<span className="col-span-2 row-start-2 min-w-0 text-base break-words @sm:col-span-1 @sm:col-start-2 @sm:row-start-1"> <span className="col-span-2 row-start-2 min-w-0 text-base break-words @sm:col-span-1 @sm:col-start-2 @sm:row-start-1">
{e.titel} {e.titel}
{e.text && <span className="text-text-3">, {e.text}</span>} {e.text && <span className="text-text-3">, {e.text}</span>}
</span> </span>
<span className={cn("schild col-start-2 row-start-1 justify-self-end text-[13px] @sm:col-start-3", farbe)}>{s.text}</span> <span className={cn("etikett col-start-2 row-start-1 flex items-center gap-1.5 justify-self-end text-[10.5px] tracking-[0.12em] @sm:col-start-3", farbe)}>
<span aria-hidden className={cn("size-1.5 rounded-full", s.punkt)} />
{s.text}
</span>
</li> </li>
) )
} }
@@ -9,7 +9,7 @@ export interface Zone {
export function Instrument({ export function Instrument({
anteil, anteil,
farbe = "var(--cyan)", farbe = "var(--box)",
zonen = [], zonen = [],
mitte, mitte,
unter, unter,
@@ -28,23 +28,24 @@ export function Instrument({
return ( return (
<figure className="m-0 flex min-w-0 flex-col items-center gap-1"> <figure className="m-0 flex min-w-0 flex-col items-center gap-1">
<svg viewBox="0 0 200 150" className="h-auto w-full max-w-[200px]" role="img" aria-label={ariaText}> <svg viewBox="0 0 200 150" className="h-auto w-full max-w-[200px]" role="img" aria-label={ariaText}>
<path d={bogenPfad(0, 1, 75)} fill="none" stroke="var(--linie)" strokeWidth={12} strokeLinecap="round" /> <path d={bogenPfad(0, 1, 75)} fill="none" stroke="rgb(255 255 255 / 0.07)" strokeWidth={12} strokeLinecap="round" />
{zonen.map((z) => ( {zonen.map((z) => (
<path key={`${z.von}-${z.bis}`} d={bogenPfad(z.von, z.bis, 86)} fill="none" stroke={z.farbe} strokeWidth={4} strokeLinecap="round" /> <path key={`${z.von}-${z.bis}`} d={bogenPfad(z.von, z.bis, 86)} fill="none" stroke={z.farbe} strokeWidth={4} strokeLinecap="round" />
))} ))}
{zeigen && ( {zeigen && (
<path d={bogenPfad(0, anteil, 75)} fill="none" stroke={farbe} strokeWidth={12} strokeLinecap="round" /> <path d={bogenPfad(0, anteil, 75)} fill="none" stroke={farbe} strokeWidth={12} strokeLinecap="round"
style={{ filter: `drop-shadow(0 0 7px color-mix(in srgb, ${farbe} 60%, transparent))` }} />
)} )}
<text x="100" y="100" textAnchor="middle" className="ziffern" style={{ fontSize: 34, fontWeight: 500, fill: "var(--foreground)" }}> <text x="100" y="102" textAnchor="middle" style={{ fontFamily: "var(--font-anzeige)", fontSize: 40, fontWeight: 600, fill: "var(--foreground)" }}>
{mitte} {mitte}
</text> </text>
{unter && ( {unter && (
<text x="100" y="122" textAnchor="middle" style={{ fontFamily: "var(--font-anzeige)", fontSize: 14, fontWeight: 600, letterSpacing: "0.12em", fill: "var(--text-3)" }}> <text x="100" y="124" textAnchor="middle" style={{ fontFamily: "var(--font-sans)", fontSize: 13, fontWeight: 500, fill: "var(--text-3)" }}>
{unter.toUpperCase()} {unter}
</text> </text>
)} )}
</svg> </svg>
<figcaption className="schild text-[15px] text-text-3">{beschriftung}</figcaption> <figcaption className="etikett text-[11.5px] tracking-[0.14em] text-text-3">{beschriftung}</figcaption>
</figure> </figure>
) )
} }
+1 -1
View File
@@ -62,7 +62,7 @@ export function JobKarte({ job, onAbbrechen, onQuittieren, abbrechenGesperrt, eb
const Titel = ebene === 3 ? "h3" : "h4" const Titel = ebene === 3 ? "h3" : "h4"
return ( return (
<article aria-label={`Auftrag: ${titel}`} className="flex flex-col gap-2.5 rounded-xl border border-linie bg-erhaben/50 p-3.5"> <article aria-label={`Auftrag: ${titel}`} className="flex flex-col gap-2.5 flaeche-innen rounded-2xl p-3.5">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<Titel className="m-0 min-w-0 text-[15px] leading-snug font-semibold break-words">{titel}</Titel> <Titel className="m-0 min-w-0 text-[15px] leading-snug font-semibold break-words">{titel}</Titel>
<Chip art={zustand.art}>{zustand.text}</Chip> <Chip art={zustand.art}>{zustand.text}</Chip>
@@ -0,0 +1,97 @@
import type { ReactNode } from "react"
import { cn } from "cn"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import type { Geraetebild, Punktzustand } from "@/lib/geraetebild"
import type { LampenZustand } from "@/lib/typen"
// Der Kopf beider Cockpits (Design v5, 25.09.2026): links der Statusblock mit Schein in der Farbe der Lage — drücken
// führt dorthin, wo etwas zu tun ist —, rechts die Kacheln der Geräte bzw. Dienste. Zustand immer auch in Worten.
export type Lageart = "ok" | "info" | "gelb" | "rot" | "stumm"
const ART: Record<Lageart, { block: string; glut: string; text: string }> = {
ok: { block: "bg-gruen/[0.07] ring-gruen/30", glut: "var(--gruen)", text: "text-gruen" },
info: { block: "bg-cyan/[0.07] ring-cyan/30", glut: "var(--cyan)", text: "text-cyan-text" },
gelb: { block: "bg-bernstein/[0.08] ring-bernstein/35", glut: "var(--bernstein)", text: "text-bernstein" },
rot: { block: "bg-rot/10 ring-rot/40", glut: "var(--rot)", text: "text-rot-text" },
stumm: { block: "bg-white/[0.04] ring-white/15", glut: "var(--text-3)", text: "text-text-2" },
}
export function Lagekopf({ art, oben, mitte, unten, aktiv, onAnsehen, label, children }: {
art: Lageart
oben: string
mitte: string
unten: string
aktiv: boolean
onAnsehen: () => void
/** Name des Abschnitts für Screenreader („Lage im Homelab“). */
label: string
/** Die Kacheln rechts (eine Liste von <li>). */
children: ReactNode
}) {
const a = ART[art]
return (
<section aria-label={label} className="auftauchen flaeche relative overflow-hidden rounded-3xl">
<div aria-hidden className="glut-atmet pointer-events-none absolute -top-40 -left-32 size-[460px] rounded-full"
style={{ background: `radial-gradient(closest-side, color-mix(in srgb, ${a.glut} 20%, transparent), transparent)` }} />
<div className="relative grid gap-4 p-4 sm:p-5 lg:grid-cols-[280px_minmax(0,1fr)] lg:gap-5">
<button
type="button"
onClick={onAnsehen}
disabled={!aktiv}
aria-label={`${oben}: ${mitte}`}
className={cn(
"flex min-h-[132px] flex-col items-start justify-center gap-1.5 rounded-2xl px-5 py-4 text-left ring-1 ring-inset transition-[filter]",
a.block, aktiv ? "cursor-pointer hover:brightness-125" : "cursor-default",
art === "gelb" && "lampe-atmet",
)}
>
<span className="etikett text-[11px] tracking-[0.18em] text-text-3">{oben}</span>
<span className={cn("text-[30px] leading-tight font-semibold tracking-[-0.01em] sm:text-[34px]", a.text)}>{mitte}</span>
<span className="text-[13.5px] text-text-2">{unten}</span>
</button>
<ul className="m-0 grid list-none grid-cols-1 gap-2 p-0 min-[480px]:grid-cols-2 xl:grid-cols-4">{children}</ul>
</div>
</section>
)
}
const LAMPE_PUNKT: Record<LampenZustand, Punktzustand> = { ok: "ok", aus: "aus", warn: "warn", info: "info", fehler: "rot" }
const WERT: Record<LampenZustand, string> = {
ok: "text-text-3", aus: "text-text-3", warn: "text-bernstein", info: "text-cyan-text", fehler: "text-rot-text",
}
const ZUSTAND_TEXT: Record<LampenZustand, string> = {
ok: "in Ordnung", aus: "aus", warn: "braucht Aufmerksamkeit", info: "Info", fehler: "Störung",
}
/** Eine Kachel im Lagekopf: Bild mit Zustandspunkt, Name, darunter der Wert in der Farbe des Zustands. */
export function Lagekachel({ bild, name, wert, zustand, onClick }: {
bild: Geraetebild
name: string
wert: string
zustand: LampenZustand
onClick?: () => void
}) {
const inhalt = (
<>
<Geraetesymbol bild={bild} punkt={LAMPE_PUNKT[zustand]} />
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-[14.5px] leading-snug font-medium">{name}</span>
<span className={cn("truncate text-[12.5px] leading-snug", WERT[zustand])}>{wert}</span>
<span className="sr-only">{ZUSTAND_TEXT[zustand]}</span>
</span>
</>
)
const stil = "flaeche-innen flex h-full w-full min-w-0 items-center gap-3 rounded-xl px-3 py-2.5 text-left"
return (
<li>
{onClick ? (
<button type="button" onClick={onClick} className={cn(stil, "transition-colors hover:border-white/20 hover:bg-white/[0.05]")}>
{inhalt}
</button>
) : (
<div className={stil}>{inhalt}</div>
)}
</li>
)
}
-42
View File
@@ -1,42 +0,0 @@
import { cn } from "cn"
import type { Lampe as LampenDaten, LampenZustand } from "@/lib/typen"
// Warnlampen wie im Flugzeug: Grün = läuft, dunkel = aus/auf Abruf, Bernstein = kümmern,
// Cyan = Info, Rot = Störung. Bernstein und Rot leuchten, alles andere bleibt ruhig.
const LAMPEN_STIL: Record<LampenZustand, { feld: string; name: string; wert: string }> = {
ok: { feld: "border-gruen-rand bg-gruen-grund", name: "text-gruen", wert: "text-gruen-text" },
aus: { feld: "border-aus-rand bg-aus-grund", name: "text-aus-text", wert: "text-text-3" },
warn: { feld: "border-bernstein bg-bernstein-lampe lampe-atmet", name: "text-bernstein", wert: "text-[#f2c46b]" },
info: { feld: "border-cyan-rand bg-cyan-grund", name: "text-cyan", wert: "text-cyan-text" },
fehler: {
feld: "border-rot bg-rot-grund shadow-[0_0_18px_rgba(255,90,79,0.35)]",
name: "text-rot",
wert: "text-rot-text",
},
}
const ZUSTAND_TEXT: Record<LampenZustand, string> = {
ok: "in Ordnung",
aus: "aus",
warn: "braucht Aufmerksamkeit",
info: "Info",
fehler: "Störung",
}
export function Lampe({ lampe }: { lampe: LampenDaten }) {
const stil = LAMPEN_STIL[lampe.zustand] ?? LAMPEN_STIL.aus
return (
<li
className={cn(
"flex min-h-[58px] flex-col items-center justify-center gap-0.5 rounded-lg border px-2 py-2 text-center",
stil.feld,
)}
>
<span className={cn("schild text-[17px] leading-none font-bold tracking-[0.18em]", stil.name)}>
{lampe.label}
</span>
<span className={cn("ziffern text-xs uppercase", stil.wert)}>{lampe.wert}</span>
<span className="sr-only">{ZUSTAND_TEXT[lampe.zustand]}</span>
</li>
)
}
@@ -11,12 +11,12 @@ export function Protokollfenster({ offen, titel, text, onSchliessen }: {
}) { }) {
return ( return (
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}> <Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
<SheetContent side="right" className="border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl"> <SheetContent side="right" className="border-white/10 bg-[#0e121a] data-[side=right]:w-full data-[side=right]:sm:max-w-2xl">
<SheetHeader> <SheetHeader>
<SheetTitle className="schild text-lg">Protokoll</SheetTitle> <SheetTitle className="text-[19px] font-semibold">Protokoll</SheetTitle>
<SheetDescription className="text-text-2">{titel}</SheetDescription> <SheetDescription className="text-text-2">{titel}</SheetDescription>
</SheetHeader> </SheetHeader>
<pre className="ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2"> <pre className="ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg bg-black/30 ring-1 ring-white/[0.07] ring-inset p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2">
{text} {text}
</pre> </pre>
</SheetContent> </SheetContent>
+11 -24
View File
@@ -1,39 +1,26 @@
import { cn } from "cn" import { Lagekachel, Lagekopf } from "@/components/cockpit/Lagekopf"
import { hauptleuchte } from "@/lib/anzeige"
import { boxbild } from "@/lib/geraetebild"
import type { Lampe as LampenDaten, Start } from "@/lib/typen" import type { Lampe as LampenDaten, Start } from "@/lib/typen"
import { hauptleuchte, LEUCHTE } from "@/lib/anzeige"
import { Lampe } from "./Lampe"
type Zustand = Start["zustand"] type Zustand = Start["zustand"]
/**
* Der Kopf des KI-Box-Cockpits: links die Hauptleuchte (drücken führt zur Checkliste, wenn etwas offen ist), rechts
* je Dienst eine Kachel — Motor, Hirn, Coder, Hermes, Aufträge, Sicherung, Platte, Updates.
*/
export function Warnpanel({ zustand, lampen, onAnsehen }: { export function Warnpanel({ zustand, lampen, onAnsehen }: {
zustand: Zustand zustand: Zustand
lampen: LampenDaten[] lampen: LampenDaten[]
onAnsehen: () => void onAnsehen: () => void
}) { }) {
const h = hauptleuchte(zustand) const h = hauptleuchte(zustand)
const aktiv = h.art === "gelb" || h.art === "rot"
return ( return (
<section aria-label="Warnpanel" className="grid gap-4 md:grid-cols-[260px_minmax(0,1fr)] md:gap-5"> <Lagekopf art={h.art} oben={h.oben} mitte={h.mitte} unten={h.unten} aktiv={h.art === "gelb" || h.art === "rot"}
<button onAnsehen={onAnsehen} label="Warnpanel">
type="button"
onClick={onAnsehen}
disabled={!aktiv}
aria-label={`${h.oben} ${h.mitte}`}
className={cn(
"flex min-h-[130px] flex-col items-center justify-center gap-1 rounded-2xl border px-4 py-4 font-anzeige transition-colors",
LEUCHTE[h.art],
aktiv ? "cursor-pointer" : "cursor-default",
)}
>
<span className="schild text-sm tracking-[0.26em]">{h.oben}</span>
<span className="text-[40px] leading-none font-bold tracking-[0.04em] sm:text-[44px]">{h.mitte}</span>
<span className="font-sans text-sm opacity-80">{h.unten}</span>
</button>
<ul className="grid grid-cols-2 gap-2.5 rounded-2xl border border-linie bg-panel p-3 sm:grid-cols-4">
{lampen.map((l) => ( {lampen.map((l) => (
<Lampe key={l.id} lampe={l} /> <Lagekachel key={l.id} bild={boxbild(l.id)} name={l.label} wert={l.wert} zustand={l.zustand} />
))} ))}
</ul> </Lagekopf>
</section>
) )
} }
@@ -5,7 +5,7 @@ import { laufzeit } from "@/lib/zeit"
// es bei den alten 38 × 56 px je Ziffer. // es bei den alten 38 × 56 px je Ziffer.
function Ziffer({ z }: { z: string }) { function Ziffer({ z }: { z: string }) {
return ( return (
<span className="ziffern inline-flex h-[1.75em] w-[1.1875em] shrink-0 items-center justify-center rounded-md border border-[#2a3036] bg-[#0b0d0f] font-medium"> <span className="ziffern inline-flex h-[1.75em] w-[1.1875em] shrink-0 items-center justify-center rounded-lg border border-white/10 bg-gradient-to-b from-white/[0.07] to-white/[0.02] font-medium shadow-[inset_0_1px_0_rgb(255_255_255/0.06)]">
{z} {z}
</span> </span>
) )
@@ -27,12 +27,12 @@ export function Zaehlwerk({ sekunden }: { sekunden: number | null | undefined })
> >
<Ziffer z={lz ? tage[0] : "–"} /> <Ziffer z={lz ? tage[0] : "–"} />
<Ziffer z={lz ? tage[1] : "–"} /> <Ziffer z={lz ? tage[1] : "–"} />
<span className="schild mr-[0.444em] ml-[0.111em] text-[0.5625em] font-bold text-text-3">T</span> <span className="etikett mr-[0.444em] ml-[0.111em] text-[0.5625em] text-text-3">T</span>
<Ziffer z={lz ? std[0] : "–"} /> <Ziffer z={lz ? std[0] : "–"} />
<Ziffer z={lz ? std[1] : "–"} /> <Ziffer z={lz ? std[1] : "–"} />
<span className="schild ml-[0.111em] text-[0.5625em] font-bold text-text-3">H</span> <span className="etikett ml-[0.111em] text-[0.5625em] text-text-3">H</span>
</div> </div>
<figcaption className="schild text-[15px] text-text-3">Ohne Neustart</figcaption> <figcaption className="etikett text-[11.5px] tracking-[0.14em] text-text-3">Ohne Neustart</figcaption>
</figure> </figure>
) )
} }
@@ -22,12 +22,12 @@ describe("bogenPfad", () => {
describe("hauptleuchte", () => { describe("hauptleuchte", () => {
it("bleibt ruhig, wenn nichts offen ist", () => { it("bleibt ruhig, wenn nichts offen ist", () => {
expect(hauptleuchte(ruhig)).toMatchObject({ art: "ok", mitte: "IN ORDNUNG" }) expect(hauptleuchte(ruhig)).toMatchObject({ art: "ok", mitte: "In Ordnung" })
}) })
it("zeigt Bernstein bei gelben und Rot bei roten Hinweisen", () => { it("zeigt Bernstein bei gelben und Rot bei roten Hinweisen", () => {
expect(hauptleuchte({ ...ruhig, stufe: "gelb", anzahl: 2 })).toMatchObject({ art: "gelb", mitte: "2 HINWEISE" }) expect(hauptleuchte({ ...ruhig, stufe: "gelb", anzahl: 2 })).toMatchObject({ art: "gelb", mitte: "2 Hinweise" })
expect(hauptleuchte({ ...ruhig, stufe: "rot", anzahl: 1 })).toMatchObject({ art: "rot", mitte: "1 HINWEIS" }) expect(hauptleuchte({ ...ruhig, stufe: "rot", anzahl: 1 })).toMatchObject({ art: "rot", mitte: "1 Hinweis" })
}) })
it("meldet einen schweigenden Wächter vor allem anderen", () => { it("meldet einen schweigenden Wächter vor allem anderen", () => {
@@ -50,6 +50,6 @@ describe("Warnpanel", () => {
expect(screen.getByText("Motor")).toBeInTheDocument() expect(screen.getByText("Motor")).toBeInTheDocument()
expect(screen.getByText("b11057")).toBeInTheDocument() expect(screen.getByText("b11057")).toBeInTheDocument()
expect(screen.getByText("braucht Aufmerksamkeit")).toBeInTheDocument() expect(screen.getByText("braucht Aufmerksamkeit")).toBeInTheDocument()
expect(screen.getByRole("button", { name: /Achtung 2 HINWEISE/ })).toBeEnabled() expect(screen.getByRole("button", { name: /Achtung: 2 Hinweise/ })).toBeEnabled()
}) })
}) })
@@ -31,7 +31,7 @@ function Anzeige({ saeule, reihen, summe, className, links }: {
links?: number links?: number
}) { }) {
return ( return (
<div role="status" className={cn("flex flex-col gap-1 rounded-lg border border-linie-stark bg-erhaben px-3 py-2", className)} <div role="status" className={cn("flex flex-col gap-1 rounded-xl border border-white/12 bg-white/[0.04] px-3 py-2", className)}
style={links === undefined ? undefined : { left: links }}> style={links === undefined ? undefined : { left: links }}>
<span className="ziffern text-xs text-text-3">{saeule.lang}</span> <span className="ziffern text-xs text-text-3">{saeule.lang}</span>
{reihen.map((r) => ( {reihen.map((r) => (
@@ -42,7 +42,7 @@ function Anzeige({ saeule, reihen, summe, className, links }: {
</span> </span>
))} ))}
{reihen.length > 1 && ( {reihen.length > 1 && (
<span className="flex items-baseline gap-2 border-t border-linie pt-1 text-sm"> <span className="flex items-baseline gap-2 border-t border-white/[0.06] pt-1 text-sm">
<span className="ziffern font-semibold text-foreground">{zahl.format(summe)}</span> <span className="ziffern font-semibold text-foreground">{zahl.format(summe)}</span>
<span className="text-text-3">zusammen</span> <span className="text-text-3">zusammen</span>
</span> </span>
@@ -208,7 +208,7 @@ export function Saeulen({ saeulen, reihen, titel, summen = "alle", hoehe = 200,
</thead> </thead>
<tbody> <tbody>
{saeulen.map((s, i) => ( {saeulen.map((s, i) => (
<tr key={s.schluessel} className="border-t border-linie"> <tr key={s.schluessel} className="border-t border-white/[0.06]">
<th scope="row" className="py-1 pr-3 text-left font-normal text-text-3">{s.lang}</th> <th scope="row" className="py-1 pr-3 text-left font-normal text-text-3">{s.lang}</th>
{sichtbar.map((r) => ( {sichtbar.map((r) => (
<td key={r.name} className="ziffern py-1 pr-3 text-right text-text-2">{zahl.format(s.werte[r.name] ?? 0)}</td> <td key={r.name} className="ziffern py-1 pr-3 text-right text-text-2">{zahl.format(s.werte[r.name] ?? 0)}</td>
@@ -132,7 +132,7 @@ export function Verlauf({ reihen, einheit, langerZeitraum, hoehe = 180, titel }:
{aktiv !== null && ( {aktiv !== null && (
<div <div
role="status" role="status"
className="pointer-events-none absolute top-1 z-10 flex min-w-36 flex-col gap-1 rounded-lg border border-linie-stark bg-erhaben px-3 py-2 shadow-lg" className="pointer-events-none absolute top-1 z-10 flex min-w-36 flex-col gap-1 rounded-xl border border-white/12 bg-white/[0.04] px-3 py-2 shadow-lg"
style={tipLinks > breite / 2 ? { right: breite - tipLinks + 10 } : { left: tipLinks + 10 }} style={tipLinks > breite / 2 ? { right: breite - tipLinks + 10 } : { left: tipLinks + 10 }}
> >
<span className="ziffern text-xs text-text-3">{zeitText(aktiv, true)}</span> <span className="ziffern text-xs text-text-3">{zeitText(aktiv, true)}</span>
@@ -157,7 +157,7 @@ export function Verlauf({ reihen, einheit, langerZeitraum, hoehe = 180, titel }:
</thead> </thead>
<tbody> <tbody>
{letzte.map(([t], i) => ( {letzte.map(([t], i) => (
<tr key={t} className="border-t border-linie"> <tr key={t} className="border-t border-white/[0.06]">
<td className="py-1 pr-4 text-text-3">{zeitText(t, langerZeitraum)}</td> <td className="py-1 pr-4 text-text-3">{zeitText(t, langerZeitraum)}</td>
{reihen.map((r) => ( {reihen.map((r) => (
<td key={r.name} className="py-1 pr-4 text-text-2"> <td key={r.name} className="py-1 pr-4 text-text-2">
+8 -52
View File
@@ -1,22 +1,7 @@
import { cn } from "cn" import { Lagekachel, Lagekopf } from "@/components/cockpit/Lagekopf"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol" import { geraetebild } from "@/lib/geraetebild"
import { geraetebild, type Punktzustand } from "@/lib/geraetebild"
import { type Lage, kurzname, lampeFuer } from "@/lib/homelab" import { type Lage, kurzname, lampeFuer } from "@/lib/homelab"
import type { LampenZustand, ZielStand } from "@/lib/typen" import type { ZielStand } from "@/lib/typen"
// Farbe der Lage (Design v5): Tönung und Schein des Statusblocks. Zustand immer auch in Worten (oben/mitte/unten).
const ART: Record<Lage["art"], { block: string; glut: string; text: string }> = {
ok: { block: "bg-gruen/[0.07] ring-gruen/30", glut: "var(--gruen)", text: "text-gruen" },
info: { block: "bg-cyan/[0.07] ring-cyan/30", glut: "var(--cyan)", text: "text-cyan-text" },
gelb: { block: "bg-bernstein/[0.08] ring-bernstein/35", glut: "var(--bernstein)", text: "text-bernstein" },
rot: { block: "bg-rot/10 ring-rot/40", glut: "var(--rot)", text: "text-rot-text" },
stumm: { block: "bg-white/[0.04] ring-white/15", glut: "var(--text-3)", text: "text-text-2" },
}
const PUNKT: Record<LampenZustand, Punktzustand> = { ok: "ok", aus: "aus", warn: "warn", info: "info", fehler: "rot" }
const WERT: Record<LampenZustand, string> = {
ok: "text-text-3", aus: "text-text-3", warn: "text-bernstein", info: "text-cyan-text", fehler: "text-rot-text",
}
/** /**
* Die Lage im Homelab auf einen Blick: links der Statusblock (drücken führt dorthin, wo etwas zu tun ist), rechts je * Die Lage im Homelab auf einen Blick: links der Statusblock (drücken führt dorthin, wo etwas zu tun ist), rechts je
@@ -30,45 +15,16 @@ export function Lagebild({ lage, ziele, imUpdate, onAnsehen, onGeraet }: {
/** Klick auf eine Kachel: Updates, wenn etwas ansteht, sonst die Zeile in der Geräte-Tabelle. */ /** Klick auf eine Kachel: Updates, wenn etwas ansteht, sonst die Zeile in der Geräte-Tabelle. */
onGeraet: (ziel: ZielStand, hatUpdate: boolean) => void onGeraet: (ziel: ZielStand, hatUpdate: boolean) => void
}) { }) {
const a = ART[lage.art]
const aktiv = lage.ziel !== null
return ( return (
<section aria-label="Lage im Homelab" className="auftauchen flaeche relative overflow-hidden rounded-3xl"> <Lagekopf art={lage.art} oben={lage.oben} mitte={lage.mitte} unten={lage.unten} aktiv={lage.ziel !== null}
<div aria-hidden className="glut-atmet pointer-events-none absolute -top-40 -left-32 size-[460px] rounded-full" onAnsehen={onAnsehen} label="Lage im Homelab">
style={{ background: `radial-gradient(closest-side, color-mix(in srgb, ${a.glut} 20%, transparent), transparent)` }} />
<div className="relative grid gap-4 p-4 sm:p-5 lg:grid-cols-[280px_minmax(0,1fr)] lg:gap-5">
<button
type="button"
onClick={onAnsehen}
disabled={!aktiv}
aria-label={`${lage.oben}: ${lage.mitte}`}
className={cn(
"flex min-h-[132px] flex-col items-start justify-center gap-1.5 rounded-2xl px-5 py-4 text-left ring-1 ring-inset transition-colors",
a.block, aktiv ? "cursor-pointer hover:brightness-125" : "cursor-default",
)}
>
<span className="etikett text-[11px] tracking-[0.18em] text-text-3">{lage.oben}</span>
<span className={cn("text-[30px] leading-tight font-semibold tracking-[-0.01em] sm:text-[34px]", a.text)}>{lage.mitte}</span>
<span className="text-[13.5px] text-text-2">{lage.unten}</span>
</button>
<ul aria-label="Geräte" className="m-0 grid list-none grid-cols-1 gap-2 p-0 min-[480px]:grid-cols-2 xl:grid-cols-4">
{ziele.map((z) => { {ziele.map((z) => {
const l = lampeFuer(z, imUpdate.has(z.id)) const l = lampeFuer(z, imUpdate.has(z.id))
return ( return (
<li key={z.id}> <Lagekachel key={z.id} bild={geraetebild(z)} name={kurzname(z)} wert={l.wert} zustand={l.zustand}
<button type="button" onClick={() => onGeraet(z, l.zustand === "info")} onClick={() => onGeraet(z, l.zustand === "info")} />
className="flaeche-innen group flex h-full w-full min-w-0 items-center gap-3 rounded-xl px-3 py-2.5 text-left transition-colors hover:border-white/20 hover:bg-white/[0.05]">
<Geraetesymbol bild={geraetebild(z)} punkt={PUNKT[l.zustand]} />
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-[14.5px] leading-snug font-medium">{kurzname(z)}</span>
<span className={cn("truncate text-[12.5px] leading-snug", WERT[l.zustand])}>{l.wert}</span>
</span>
</button>
</li>
) )
})} })}
</ul> </Lagekopf>
</div>
</section>
) )
} }
@@ -61,7 +61,7 @@ export function AlleKnopf({ anzahl, gesperrt }: { anzahl: number; gesperrt: bool
const SCHRITT: Record<SammelSchrittStatus, { Symbol: typeof CircleCheck; farbe: string; text: string }> = { const SCHRITT: Record<SammelSchrittStatus, { Symbol: typeof CircleCheck; farbe: string; text: string }> = {
wartet: { Symbol: CircleDashed, farbe: "text-text-3", text: "wartet" }, wartet: { Symbol: CircleDashed, farbe: "text-text-3", text: "wartet" },
laeuft: { Symbol: LoaderCircle, farbe: "text-cyan", text: "läuft" }, laeuft: { Symbol: LoaderCircle, farbe: "text-cyan-text", text: "läuft" },
eingespielt: { Symbol: CircleCheck, farbe: "text-gruen", text: "eingespielt" }, eingespielt: { Symbol: CircleCheck, farbe: "text-gruen", text: "eingespielt" },
zurueckgerollt: { Symbol: Undo2, farbe: "text-bernstein", text: "zurückgerollt" }, zurueckgerollt: { Symbol: Undo2, farbe: "text-bernstein", text: "zurückgerollt" },
fehler: { Symbol: CircleX, farbe: "text-rot", text: "Fehler" }, fehler: { Symbol: CircleX, farbe: "text-rot", text: "Fehler" },
+6 -14
View File
@@ -32,32 +32,24 @@ export function temperaturFarbe(grad: number | null | undefined) {
if (grad == null) return "var(--text-3)" if (grad == null) return "var(--text-3)"
if (grad >= 85) return "var(--rot)" if (grad >= 85) return "var(--rot)"
if (grad >= 70) return "var(--bernstein)" if (grad >= 70) return "var(--bernstein)"
return "var(--gruen)" // Im Normalfall ruhig in der Farbe der KI-Box (Design v5) — Farbe für Zustände erst ab der Grenze.
return "var(--box)"
} }
// --- Hauptwarnleuchte --------------------------------------------------------------- // --- Hauptwarnleuchte ---------------------------------------------------------------
/** Farben der großen Leuchte (Warnpanel der Startseite, Lage im Homelab). */
export const LEUCHTE: Record<"ok" | "info" | "stumm" | "gelb" | "rot", string> = {
ok: "border-gruen-rand bg-gruen-grund text-gruen",
info: "border-cyan-rand bg-cyan-grund text-cyan",
stumm: "border-aus-rand bg-aus-grund text-aus-text",
gelb: "border-2 border-bernstein bg-bernstein-grund text-bernstein-hell lampe-atmet",
rot: "border-2 border-rot bg-rot-grund text-rot shadow-[0_0_32px_rgba(255,90,79,0.3)]",
}
/** Was die Hauptwarnleuchte sagt. Der schweigende Wächter geht allem anderen vor. */ /** Was die Hauptwarnleuchte sagt. Der schweigende Wächter geht allem anderen vor. */
export function hauptleuchte(z: Start["zustand"]) { export function hauptleuchte(z: Start["zustand"]) {
if (!z.waechter_wach) { if (!z.waechter_wach) {
return { art: "stumm" as const, oben: "Wächter", mitte: "SCHWEIGT", unten: "Seit über 5 Minuten kein Prüflauf" } return { art: "stumm" as const, oben: "Wächter", mitte: "Schweigt", unten: "Seit über 5 Minuten kein Prüflauf" }
} }
if (z.update_laeuft) { if (z.update_laeuft) {
return { art: "info" as const, oben: "Wartung", mitte: "UPDATE LÄUFT", unten: "Hinweise ruhen bis zum Ende" } return { art: "info" as const, oben: "Wartung", mitte: "Update läuft", unten: "Hinweise ruhen bis zum Ende" }
} }
if (z.anzahl === 0) { if (z.anzahl === 0) {
return { art: "ok" as const, oben: "Alles", mitte: "IN ORDNUNG", unten: "Keine offenen Hinweise" } return { art: "ok" as const, oben: "Alles", mitte: "In Ordnung", unten: "Keine offenen Hinweise" }
} }
const mitte = `${z.anzahl} HINWEIS${z.anzahl === 1 ? "" : "E"}` const mitte = `${z.anzahl} Hinweis${z.anzahl === 1 ? "" : "e"}`
return z.stufe === "rot" return z.stufe === "rot"
? { art: "rot" as const, oben: "Störung", mitte, unten: "Drücken zum Ansehen" } ? { art: "rot" as const, oben: "Störung", mitte, unten: "Drücken zum Ansehen" }
: { art: "gelb" as const, oben: "Achtung", mitte, unten: "Drücken zum Ansehen" } : { art: "gelb" as const, oben: "Achtung", mitte, unten: "Drücken zum Ansehen" }
+22 -2
View File
@@ -1,6 +1,7 @@
import { import {
Bird, Bot, Box, BrainCircuit, Clapperboard, Code, Container, DatabaseBackup, FileCode2, GitBranch, HardDrive, House, AudioLines, Bird, Bot, Box, BrainCircuit, Clapperboard, Code, Container, DatabaseBackup, FileCode2, GitBranch, HardDrive,
Layers, type LucideIcon, Monitor, Route, Server, ShieldCheck, Shuffle, Workflow, Zap, House, Layers, LayoutDashboard, type LucideIcon, Mic, Monitor, RefreshCw, Route, Server, ShieldCheck, Shuffle, Workflow,
Zap,
} from "lucide-react" } from "lucide-react"
import { LOGOS } from "@/lib/logos-liste" import { LOGOS } from "@/lib/logos-liste"
import type { Bereich } from "@/lib/navigation" import type { Bereich } from "@/lib/navigation"
@@ -81,6 +82,25 @@ const KI_BOX: Record<string, [LucideIcon, string?]> = {
jobs: [Workflow], jobs: [Workflow],
sicherung: [DatabaseBackup], sicherung: [DatabaseBackup],
platte: [HardDrive], platte: [HardDrive],
updates: [RefreshCw],
}
// Die Dienste der KI-Box (Seite „Dienste und Protokolle“), nach ihrer systemd-Unit.
const DIENSTE: [RegExp, LucideIcon, string?][] = [
[/^hermes/, Bot, "hermes-agent"],
[/^llama-swap/, Shuffle],
[/^mc2-gateway/, Route],
[/^mc2-steward/, ShieldCheck],
[/^mission-control/, LayoutDashboard],
[/^voice/, Mic],
[/^lucy-stimme/, AudioLines],
]
/** Das Bild eines Dienstes der KI-Box. */
export function dienstbild(unit: string): Geraetebild {
const treffer = DIENSTE.find(([muster]) => muster.test(unit))
const logo = treffer?.[2]
return { Icon: treffer?.[1] ?? Box, bereich: "box", logo: logo && LOGOS.has(logo) ? logo : undefined }
} }
/** Das Bild eines Bausteins oder einer Lampe der KI-Box. */ /** Das Bild eines Bausteins oder einer Lampe der KI-Box. */
+1 -1
View File
@@ -36,7 +36,7 @@ describe("kleine Helfer", () => {
}) })
it("Temperaturfarbe wechselt bei 70 und 85 Grad", () => { it("Temperaturfarbe wechselt bei 70 und 85 Grad", () => {
expect(temperaturFarbe(37)).toBe("var(--gruen)") expect(temperaturFarbe(37)).toBe("var(--box)") // ruhig in der Farbe der KI-Box (Design v5)
expect(temperaturFarbe(70)).toBe("var(--bernstein)") expect(temperaturFarbe(70)).toBe("var(--bernstein)")
expect(temperaturFarbe(85)).toBe("var(--rot)") expect(temperaturFarbe(85)).toBe("var(--rot)")
}) })
+15 -7
View File
@@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"
import { cn } from "cn" import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen" import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip, Zustandsfeld } from "@/components/cockpit/Chip" import { Chip, Zustandsfeld } from "@/components/cockpit/Chip"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { Panel } from "@/components/cockpit/Panel" import { Panel } from "@/components/cockpit/Panel"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { type Dienst, type DiensteStand, useDienste, useProtokoll } from "@/lib/abfragen" import { type Dienst, type DiensteStand, useDienste, useProtokoll } from "@/lib/abfragen"
@@ -12,9 +13,16 @@ import {
dauer, erfolgText, groesse, istProblem, knopfText, neustartFrage, prozent, rang, sortiert, speicherAnteil, ZUSTAND, dauer, erfolgText, groesse, istProblem, knopfText, neustartFrage, prozent, rang, sortiert, speicherAnteil, ZUSTAND,
zeitpunkt, zustandVon, zustandZusatz, zeitpunkt, zustandVon, zustandZusatz,
} from "@/lib/dienste" } from "@/lib/dienste"
import { dienstbild, type Punktzustand } from "@/lib/geraetebild"
import { melden } from "@/lib/meldungen" import { melden } from "@/lib/meldungen"
import type { AktionsErgebnis } from "@/lib/typen" import type { AktionsErgebnis } from "@/lib/typen"
import { gbText } from "@/lib/zeit" import { gbText } from "@/lib/zeit"
import type { DienstZustand } from "@/lib/abfragen"
// Zustand eines Dienstes als Punkt am Symbol (Design v5): Wort und Farbe stehen zusätzlich in der Spalte „Zustand“.
const PUNKT_DIENST: Record<DienstZustand, Punktzustand> = {
laeuft: "ok", startet: "warn", stoppt: "warn", antwortet_nicht: "rot", gestoppt: "rot", gescheitert: "rot", schlaeft: "aus",
}
// Die Dienste der KI-Box als Tabelle wie die Geräte im Homelab-Cockpit (seit 25.09.2026 eine eigene Seite statt der // Die Dienste der KI-Box als Tabelle wie die Geräte im Homelab-Cockpit (seit 25.09.2026 eine eigene Seite statt der
// Schublade): Zustand, CPU, Speicher, Laufzeit und Neustarts je Dienst, das Protokoll klappt unter der Zeile auf. // Schublade): Zustand, CPU, Speicher, Laufzeit und Neustarts je Dienst, das Protokoll klappt unter der Zeile auf.
@@ -42,7 +50,7 @@ function Balken({ anteil, children }: { anteil: number | null; children: ReactNo
<span className="flex min-w-0 flex-col gap-1"> <span className="flex min-w-0 flex-col gap-1">
<span className="ziffern text-sm text-text-2">{children}</span> <span className="ziffern text-sm text-text-2">{children}</span>
{anteil !== null && ( {anteil !== null && (
<span className="h-1.5 w-full max-w-40 overflow-hidden rounded-full bg-erhaben" aria-hidden> <span className="h-1.5 w-full max-w-40 overflow-hidden rounded-full bg-white/[0.07]" aria-hidden>
<span className="block h-full rounded-full" style={{ width: `${Math.min(100, Math.max(2, anteil))}%`, background: "var(--reihe-1)" }} /> <span className="block h-full rounded-full" style={{ width: `${Math.min(100, Math.max(2, anteil))}%`, background: "var(--reihe-1)" }} />
</span> </span>
)} )}
@@ -69,7 +77,7 @@ function Protokoll({ d, id }: { d: Dienst; id: string }) {
ref={feld} ref={feld}
tabIndex={0} tabIndex={0}
aria-label={`Letzte Protokollzeilen von ${d.name}`} aria-label={`Letzte Protokollzeilen von ${d.name}`}
className="ziffern m-0 max-h-[50vh] w-full overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap break-words text-text-2" className="ziffern m-0 max-h-[50vh] w-full overflow-auto rounded-lg bg-black/30 ring-1 ring-white/[0.07] ring-inset p-3 text-xs leading-relaxed whitespace-pre-wrap break-words text-text-2"
> >
{text} {text}
</pre> </pre>
@@ -90,9 +98,9 @@ function Zeile({ d, gesamt, offen, startet, onProtokoll, onFrage }: {
const neustarts = d.neustarts ?? 0 const neustarts = d.neustarts ?? 0
const protokollId = `protokoll-${d.unit}` const protokollId = `protokoll-${d.unit}`
return ( return (
<div role="row" className={cn("grid gap-y-2.5 border-t border-linie py-3 xl:items-center", SPALTEN)}> <div role="row" className={cn("grid gap-y-2.5 border-t border-white/[0.06] py-3 xl:items-center", SPALTEN)}>
<div role="rowheader" className="col-span-2 flex min-w-0 items-start gap-2.5 xl:col-span-1"> <div role="rowheader" className="col-span-2 flex min-w-0 items-center gap-3 xl:col-span-1">
<span aria-hidden className={cn("mt-1.5 size-2.5 shrink-0 rounded-full", z.punkt)} /> <Geraetesymbol bild={dienstbild(d.unit)} groesse="sm" punkt={PUNKT_DIENST[zustandVon(d)]} />
<span className="flex min-w-0 flex-col"> <span className="flex min-w-0 flex-col">
<span className="text-[15px] font-semibold break-words">{d.name}</span> <span className="text-[15px] font-semibold break-words">{d.name}</span>
<span className="ziffern text-xs break-words text-text-3"> <span className="ziffern text-xs break-words text-text-3">
@@ -103,7 +111,7 @@ function Zeile({ d, gesamt, offen, startet, onProtokoll, onFrage }: {
</div> </div>
<span role="cell" className="flex min-w-0 flex-col gap-0.5"> <span role="cell" className="flex min-w-0 flex-col gap-0.5">
<Beschriftung>Zustand</Beschriftung> <Beschriftung>Zustand</Beschriftung>
<span className={cn("schild text-xs", z.text)}>{z.wort}</span> <span className={cn("etikett text-[10.5px] tracking-[0.12em]", z.text)}>{z.wort}</span>
{zusatz && <span className="text-xs text-text-3">{zusatz}</span>} {zusatz && <span className="text-xs text-text-3">{zusatz}</span>}
{/* Ohne Tabelle stehen die Neustarts hier — und nur, wenn es welche gab. */} {/* Ohne Tabelle stehen die Neustarts hier — und nur, wenn es welche gab. */}
{neustarts > 0 && ( {neustarts > 0 && (
@@ -187,7 +195,7 @@ function Tabelle({ stand, gewaehlt, startet, onProtokoll, onFrage }: {
}) { }) {
return ( return (
<div role="table" aria-label="Dienste der KI-Box" className="flex flex-col"> <div role="table" aria-label="Dienste der KI-Box" className="flex flex-col">
<div role="row" className={cn("schild hidden pb-2 text-xs font-semibold text-text-3 xl:grid", SPALTEN)}> <div role="row" className={cn("etikett hidden pb-2 text-[10.5px] tracking-[0.14em] text-text-3 xl:grid", SPALTEN)}>
<span role="columnheader">Dienst</span> <span role="columnheader">Dienst</span>
<span role="columnheader">Zustand</span> <span role="columnheader">Zustand</span>
<span role="columnheader">CPU</span> <span role="columnheader">CPU</span>
+23 -13
View File
@@ -53,30 +53,35 @@ function neustartFenster(tag: number, uhrzeit: string): string {
function Abschnitt({ titel, children }: { titel: string; children: ReactNode }) { function Abschnitt({ titel, children }: { titel: string; children: ReactNode }) {
return ( return (
<section aria-label={titel} className="flex flex-col gap-3 border-t border-linie pt-4 first:border-t-0 first:pt-0"> <section aria-label={titel} className="flex flex-col gap-3 border-t border-white/[0.06] pt-4 first:border-t-0 first:pt-0">
<h3 className="schild text-sm tracking-[0.18em] text-text-2">{titel}</h3> <h3 className="etikett text-[11.5px] tracking-[0.16em] text-text-2">{titel}</h3>
{children} {children}
</section> </section>
) )
} }
/** Eine Karte der Einstellungsseite; KI-Box und Homelab tragen ihre Bereichsfarbe als Streifen oben. */ /** Eine Karte der Einstellungsseite; KI-Box und Homelab tragen ihre Bereichsfarbe als Schein und Rand (Design v5). */
function Karte({ titel, text, farbe, children }: { function Karte({ titel, text, farbe, children }: {
titel: string titel: string
text: string text: string
farbe?: "box" | "homelab" farbe?: "box" | "homelab"
children: ReactNode children: ReactNode
}) { }) {
const streifen = farbe === "box" ? "bg-box" : farbe === "homelab" ? "bg-lab" : "bg-linie-stark" const glut = farbe === "box" ? "var(--box)" : farbe === "homelab" ? "var(--lab)" : null
const kopf = farbe === "box" ? "text-box-text" : farbe === "homelab" ? "text-lab-text" : "text-foreground" const kopf = farbe === "box" ? "text-box-text" : farbe === "homelab" ? "text-lab-text" : "text-foreground"
const rand = farbe === "box" ? "[--rand:color-mix(in_srgb,var(--box)_28%,transparent)]"
: farbe === "homelab" ? "[--rand:color-mix(in_srgb,var(--lab)_28%,transparent)]" : ""
return ( return (
<section aria-label={titel} className="relative flex min-w-0 flex-col gap-4 overflow-hidden rounded-2xl border border-linie bg-panel p-5 sm:p-6"> <section aria-label={titel} className={cn("flaeche relative flex min-w-0 flex-col gap-4 overflow-hidden rounded-3xl p-5 sm:p-6", rand)}>
<span aria-hidden className={cn("absolute inset-x-0 top-0 h-1", streifen)} /> {glut && (
<div className="flex flex-col gap-1"> <div aria-hidden className="pointer-events-none absolute -top-40 -left-32 size-[400px] rounded-full"
<h2 className={cn("schild text-lg font-bold tracking-[0.22em]", kopf)}>{titel}</h2> style={{ background: `radial-gradient(closest-side, color-mix(in srgb, ${glut} 16%, transparent), transparent)` }} />
)}
<div className="relative flex flex-col gap-1">
<h2 className={cn("text-[19px] leading-tight font-semibold", kopf)}>{titel}</h2>
<p className="text-sm text-text-3">{text}</p> <p className="text-sm text-text-3">{text}</p>
</div> </div>
{children} <div className="relative flex min-w-0 flex-col gap-4">{children}</div>
</section> </section>
) )
} }
@@ -151,7 +156,7 @@ function HfZugang({ offen }: { offen: boolean }) {
value={token} value={token}
onChange={(e) => setToken(e.target.value)} onChange={(e) => setToken(e.target.value)}
placeholder="hf_…" placeholder="hf_…"
className="h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan" className="h-11 min-w-0 flex-1 rounded-xl border border-white/12 bg-white/[0.04] px-3 text-[15px] outline-none focus:border-cyan/60 focus:bg-white/[0.06]"
/> />
<Button type="submit" disabled={!token.trim() || s?.schreibbar === false || speichert}> <Button type="submit" disabled={!token.trim() || s?.schreibbar === false || speichert}>
{speichert ? "Speichert …" : "Speichern"} {speichert ? "Speichert …" : "Speichern"}
@@ -240,7 +245,7 @@ function Zeitfenster({ e, laedt, fehler }: { e: EinstellungenStand | undefined;
const wahl = entwurf ?? { tag: f.tag, uhrzeit: f.uhrzeit } const wahl = entwurf ?? { tag: f.tag, uhrzeit: f.uhrzeit }
const geaendert = wahl.tag !== f.tag || wahl.uhrzeit !== f.uhrzeit const geaendert = wahl.tag !== f.tag || wahl.uhrzeit !== f.uhrzeit
const zeiten = ZEITEN.includes(wahl.uhrzeit) ? ZEITEN : [...ZEITEN, wahl.uhrzeit].sort() const zeiten = ZEITEN.includes(wahl.uhrzeit) ? ZEITEN : [...ZEITEN, wahl.uhrzeit].sort()
const feld = "h-11 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] text-foreground outline-none focus:border-cyan" const feld = "h-11 rounded-xl border border-white/12 bg-white/[0.04] px-3 text-[15px] text-foreground outline-none focus:border-cyan/60 focus:bg-white/[0.06]"
return ( return (
<Abschnitt titel="Update-Zeitfenster"> <Abschnitt titel="Update-Zeitfenster">
<p className="text-sm text-text-2"> <p className="text-sm text-text-2">
@@ -450,7 +455,7 @@ function ArcaneSchluessel({ h }: { h: HomelabEinstellungen }) {
value={wert} value={wert}
onChange={(e) => setWert(e.target.value)} onChange={(e) => setWert(e.target.value)}
placeholder={a.schluessel === "hinterlegt" ? "Neuen Schlüssel eintragen …" : "Schlüssel aus Arcane einfügen …"} placeholder={a.schluessel === "hinterlegt" ? "Neuen Schlüssel eintragen …" : "Schlüssel aus Arcane einfügen …"}
className="h-11 w-full rounded-lg border border-linie-stark bg-erhaben pr-3 pl-9 text-[15px] outline-none focus:border-lab" className="h-11 w-full rounded-xl border border-white/12 bg-white/[0.04] pr-3 pl-9 text-[15px] outline-none focus:border-lab/60 focus:bg-white/[0.06]"
/> />
</span> </span>
<Button type="submit" disabled={!wert.trim() || speichern.isPending}> <Button type="submit" disabled={!wert.trim() || speichern.isPending}>
@@ -559,7 +564,7 @@ export function EinstellungenSeite() {
<RadarSchalter {...boxProps} /> <RadarSchalter {...boxProps} />
<HfZugang offen /> <HfZugang offen />
<Abschnitt titel="Hermes"> <Abschnitt titel="Hermes">
<a href="/hermes-ui/" target="_blank" rel="noopener" className="flex h-11 items-center gap-2 self-start rounded-lg border border-linie-stark bg-erhaben px-4 text-[15px] hover:border-text-3"> <a href="/hermes-ui/" target="_blank" rel="noopener" className="flex h-11 items-center gap-2 self-start rounded-xl border border-white/12 bg-white/[0.04] px-4 text-[15px] font-semibold hover:border-white/20 hover:bg-white/[0.08]">
<ExternalLink className="size-4" aria-hidden /> Hermes-Dashboard öffnen <ExternalLink className="size-4" aria-hidden /> Hermes-Dashboard öffnen
</a> </a>
<p className="text-xs text-text-3">Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.</p> <p className="text-xs text-text-3">Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.</p>
@@ -572,6 +577,11 @@ export function EinstellungenSeite() {
<Karte titel="Software-Stand" text="Welcher Stand auf beiden Instanzen läuft."> <Karte titel="Software-Stand" text="Welcher Stand auf beiden Instanzen läuft.">
<Stand offen /> <Stand offen />
</Karte> </Karte>
{/* Pflicht der Lizenz CC BY 4.0 (frontend/public/logos/QUELLE.md). */}
<p className="text-xs text-text-3 xl:col-span-2">
Logos der Dienste: <a href="https://selfh.st/icons" target="_blank" rel="noopener" className="underline-offset-4 hover:underline">selfh.st/icons</a>,
Lizenz CC BY 4.0. Die Marken gehören ihren Inhabern und dienen hier nur dem Wiedererkennen.
</p>
</div> </div>
) )
} }
+4 -4
View File
@@ -53,7 +53,7 @@ function Eintrag({ e, geraet }: { e: ProtokollEintrag; geraet: string | null })
// mit Symbol.for(…) zurück — eine gleichnamige Konstante darunter ließ die Seite dort abstürzen. // mit Symbol.for(…) zurück — eine gleichnamige Konstante darunter ließ die Seite dort abstürzen.
const Zeichen = SYMBOL[e.art] ?? Cog const Zeichen = SYMBOL[e.art] ?? Cog
return ( return (
<li className="grid grid-cols-[64px_18px_minmax(0,1fr)] gap-x-3 border-t border-linie py-2.5 first:border-t-0"> <li className="grid grid-cols-[64px_18px_minmax(0,1fr)] gap-x-3 border-t border-white/[0.06] py-2.5 first:border-t-0">
<span className="ziffern pt-0.5 text-xs text-text-3">{uhr.format(new Date(e.zeit * 1000))}</span> <span className="ziffern pt-0.5 text-xs text-text-3">{uhr.format(new Date(e.zeit * 1000))}</span>
<span className="flex flex-col items-center pt-1"> <span className="flex flex-col items-center pt-1">
<span aria-hidden className={cn("size-2.5 rounded-full", s.punkt)} /> <span aria-hidden className={cn("size-2.5 rounded-full", s.punkt)} />
@@ -74,7 +74,7 @@ function Eintrag({ e, geraet }: { e: ProtokollEintrag; geraet: string | null })
{offen ? "Ausgabe verbergen" : "Ausgabe zeigen"} {offen ? "Ausgabe verbergen" : "Ausgabe zeigen"}
</button> </button>
{offen && ( {offen && (
<pre className="ziffern m-0 max-h-72 overflow-auto rounded-md border border-linie bg-[#0b0d0f] p-2.5 text-xs whitespace-pre-wrap text-text-2"> <pre className="ziffern m-0 max-h-72 overflow-auto rounded-xl bg-black/30 ring-1 ring-white/[0.07] ring-inset p-2.5 text-xs whitespace-pre-wrap text-text-2">
{e.details} {e.details}
</pre> </pre>
)} )}
@@ -118,7 +118,7 @@ export function HomelabProtokollSeite() {
<button key={a.id} type="button" onClick={() => setArt(a.id)} aria-pressed={art === a.id} <button key={a.id} type="button" onClick={() => setArt(a.id)} aria-pressed={art === a.id}
className={cn( className={cn(
"h-9 rounded-full border px-3.5 text-sm", "h-9 rounded-full border px-3.5 text-sm",
art === a.id ? "border-lab-rand bg-lab-grund text-lab-text" : "border-linie text-text-2 hover:border-linie-stark hover:text-foreground", art === a.id ? "border-lab-rand bg-lab-grund text-lab-text" : "border-white/10 text-text-2 hover:border-white/20 hover:text-foreground",
)}> )}>
{a.name} {a.name}
</button> </button>
@@ -138,7 +138,7 @@ export function HomelabProtokollSeite() {
<div className={cn("flex flex-col gap-5", protokoll.isFetching && "opacity-80")}> <div className={cn("flex flex-col gap-5", protokoll.isFetching && "opacity-80")}>
{tage.map((t) => ( {tage.map((t) => (
<section key={t.titel} aria-label={t.titel} className="flex flex-col gap-1"> <section key={t.titel} aria-label={t.titel} className="flex flex-col gap-1">
<h3 className="schild text-sm tracking-[0.18em] text-lab-text">{t.titel}</h3> <h3 className="etikett text-[11.5px] tracking-[0.16em] text-lab-text">{t.titel}</h3>
<ol className="m-0 flex list-none flex-col p-0"> <ol className="m-0 flex list-none flex-col p-0">
{t.eintraege.map((e, i) => { {t.eintraege.map((e, i) => {
// Das Gerät als Schild — außer, der Titel nennt es schon. // Das Gerät als Schild — außer, der Titel nennt es schon.
+10 -10
View File
@@ -23,8 +23,8 @@ function Kachel({ label, wert, zusatz, art = "grau" }: {
art?: keyof typeof WERT_FARBE art?: keyof typeof WERT_FARBE
}) { }) {
return ( return (
<div className="flex min-w-0 flex-col gap-1.5 rounded-xl border border-linie bg-erhaben/50 px-3.5 py-3"> <div className="flex min-w-0 flex-col gap-1.5 flaeche-innen rounded-2xl px-3.5 py-3">
<span className="schild text-xs tracking-[0.18em] text-text-3">{label}</span> <span className="etikett text-[10.5px] tracking-[0.14em] text-text-3">{label}</span>
<span className={cn("font-anzeige text-[28px] leading-none font-bold", WERT_FARBE[art])}>{wert}</span> <span className={cn("font-anzeige text-[28px] leading-none font-bold", WERT_FARBE[art])}>{wert}</span>
{zusatz && <span className="text-xs text-text-3">{zusatz}</span>} {zusatz && <span className="text-xs text-text-3">{zusatz}</span>}
</div> </div>
@@ -47,7 +47,7 @@ function Zelle({ label, wert, zusatz, leise }: { label: string; wert: string; zu
function AbsenderTabelle({ zeilen }: { zeilen: AbsenderZeile[] }) { function AbsenderTabelle({ zeilen }: { zeilen: AbsenderZeile[] }) {
return ( return (
<div role="table" aria-label="Nutzung je Absender" className="flex flex-col"> <div role="table" aria-label="Nutzung je Absender" className="flex flex-col">
<div role="row" className={cn("schild hidden gap-x-4 pb-2 text-xs text-text-3 lg:grid", SPALTEN)}> <div role="row" className={cn("etikett hidden gap-x-4 pb-2 text-[10.5px] tracking-[0.14em] text-text-3 lg:grid", SPALTEN)}>
<span role="columnheader">Absender</span> <span role="columnheader">Absender</span>
<span role="columnheader">Anfragen</span> <span role="columnheader">Anfragen</span>
<span role="columnheader">Typische Antwort</span> <span role="columnheader">Typische Antwort</span>
@@ -56,7 +56,7 @@ function AbsenderTabelle({ zeilen }: { zeilen: AbsenderZeile[] }) {
<span role="columnheader">Zuletzt</span> <span role="columnheader">Zuletzt</span>
</div> </div>
{zeilen.map((a) => ( {zeilen.map((a) => (
<div key={a.name} role="row" className={cn("grid grid-cols-2 gap-x-4 gap-y-2.5 border-t border-linie py-3 lg:items-start", SPALTEN)}> <div key={a.name} role="row" className={cn("grid grid-cols-2 gap-x-4 gap-y-2.5 border-t border-white/[0.06] py-3 lg:items-start", SPALTEN)}>
<span role="rowheader" className="col-span-2 flex min-w-0 items-center gap-2 text-[15px] font-medium lg:col-span-1"> <span role="rowheader" className="col-span-2 flex min-w-0 items-center gap-2 text-[15px] font-medium lg:col-span-1">
<span aria-hidden className="size-2.5 shrink-0 rounded-[2px]" style={{ background: a.farbe }} /> <span aria-hidden className="size-2.5 shrink-0 rounded-[2px]" style={{ background: a.farbe }} />
<span className="min-w-0 break-words">{a.name}</span> <span className="min-w-0 break-words">{a.name}</span>
@@ -66,7 +66,7 @@ function AbsenderTabelle({ zeilen }: { zeilen: AbsenderZeile[] }) {
<span className="ziffern">{anzahlText(a.anfragen)}</span> <span className="ziffern">{anzahlText(a.anfragen)}</span>
<span className="text-text-3"> · {anteilText(a.anteil)} aller Anfragen</span> <span className="text-text-3"> · {anteilText(a.anteil)} aller Anfragen</span>
</span> </span>
<span className="h-1.5 w-full max-w-56 overflow-hidden rounded-full bg-erhaben" aria-hidden> <span className="h-1.5 w-full max-w-56 overflow-hidden rounded-full bg-white/[0.07]" aria-hidden>
<span className="block h-full rounded-full" style={{ width: `${Math.max(1, a.anteil * 100)}%`, background: a.farbe }} /> <span className="block h-full rounded-full" style={{ width: `${Math.max(1, a.anteil * 100)}%`, background: a.farbe }} />
</span> </span>
</span> </span>
@@ -86,9 +86,9 @@ function AbsenderTabelle({ zeilen }: { zeilen: AbsenderZeile[] }) {
function Diagramm({ titel, zusatz, children }: { titel: string; zusatz: string; children: ReactNode }) { function Diagramm({ titel, zusatz, children }: { titel: string; zusatz: string; children: ReactNode }) {
return ( return (
<section aria-label={titel} className="flex min-w-0 flex-col gap-3 rounded-xl border border-linie p-4"> <section aria-label={titel} className="flex min-w-0 flex-col gap-3 flaeche-innen rounded-2xl p-4">
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1"> <div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<h3 className="schild text-sm tracking-[0.18em] text-text-2">{titel}</h3> <h3 className="etikett text-[11.5px] tracking-[0.16em] text-text-2">{titel}</h3>
<span className="text-xs text-text-3">{zusatz}</span> <span className="text-xs text-text-3">{zusatz}</span>
</div> </div>
{children} {children}
@@ -101,7 +101,7 @@ function Ladevorgaenge({ modelle, rollen }: { modelle: ModellLadungen[]; rollen:
if (modelle.length === 0) return null if (modelle.length === 0) return null
const gesamt = modelle.every((m) => m.anzahl !== null) ? modelle.reduce((s, m) => s + (m.anzahl ?? 0), 0) : null const gesamt = modelle.every((m) => m.anzahl !== null) ? modelle.reduce((s, m) => s + (m.anzahl ?? 0), 0) : null
return ( return (
<details className="group rounded-xl border border-linie bg-erhaben/40 px-4 open:pb-2"> <details className="group flaeche-innen rounded-2xl px-4 open:pb-2">
<summary className="flex min-h-12 cursor-pointer list-none flex-wrap items-center justify-between gap-x-3 gap-y-1 py-2 [&::-webkit-details-marker]:hidden"> <summary className="flex min-h-12 cursor-pointer list-none flex-wrap items-center justify-between gap-x-3 gap-y-1 py-2 [&::-webkit-details-marker]:hidden">
<span className="flex items-center gap-2 text-[15px] font-medium"> <span className="flex items-center gap-2 text-[15px] font-medium">
<ChevronRight className="size-4 shrink-0 text-text-3 transition-transform group-open:rotate-90" aria-hidden /> <ChevronRight className="size-4 shrink-0 text-text-3 transition-transform group-open:rotate-90" aria-hidden />
@@ -116,7 +116,7 @@ function Ladevorgaenge({ modelle, rollen }: { modelle: ModellLadungen[]; rollen:
wenn es auf Abruf gebraucht wurde. wenn es auf Abruf gebraucht wurde.
</p> </p>
<div role="table" aria-label="Ladevorgänge je Modell" className="flex flex-col"> <div role="table" aria-label="Ladevorgänge je Modell" className="flex flex-col">
<div role="row" className="schild hidden gap-x-4 py-2 text-xs text-text-3 sm:grid sm:grid-cols-[minmax(0,1.5fr)_70px_130px_minmax(0,1.5fr)]"> <div role="row" className="etikett hidden gap-x-4 py-2 text-[10.5px] tracking-[0.14em] text-text-3 sm:grid sm:grid-cols-[minmax(0,1.5fr)_70px_130px_minmax(0,1.5fr)]">
<span role="columnheader">Modell</span> <span role="columnheader">Modell</span>
<span role="columnheader">Geladen</span> <span role="columnheader">Geladen</span>
<span role="columnheader">Zuletzt</span> <span role="columnheader">Zuletzt</span>
@@ -124,7 +124,7 @@ function Ladevorgaenge({ modelle, rollen }: { modelle: ModellLadungen[]; rollen:
</div> </div>
{modelle.map((m) => ( {modelle.map((m) => (
<div key={m.modell} role="row" <div key={m.modell} role="row"
className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1 border-t border-linie py-2.5 sm:grid-cols-[minmax(0,1.5fr)_70px_130px_minmax(0,1.5fr)] sm:items-baseline"> className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1 border-t border-white/[0.06] py-2.5 sm:grid-cols-[minmax(0,1.5fr)_70px_130px_minmax(0,1.5fr)] sm:items-baseline">
<span role="rowheader" className="flex min-w-0 flex-col"> <span role="rowheader" className="flex min-w-0 flex-col">
<span className="ziffern text-sm break-all">{m.modell}</span> <span className="ziffern text-sm break-all">{m.modell}</span>
{rollen[m.modell] && <span className="text-xs text-text-3">{rollen[m.modell]}</span>} {rollen[m.modell] && <span className="text-xs text-text-3">{rollen[m.modell]}</span>}
+6 -6
View File
@@ -72,9 +72,9 @@ export function ModellSuche({ offen, onSchliessen, rollenJetzt }: {
return ( return (
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}> <Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
<SheetContent side="right" className="gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-xl"> <SheetContent side="right" className="gap-3 overflow-y-auto border-white/10 bg-[#0e121a] data-[side=right]:w-full data-[side=right]:sm:max-w-xl">
<SheetHeader> <SheetHeader>
<SheetTitle className="schild text-lg">Modelle selbst suchen</SheetTitle> <SheetTitle className="text-[19px] font-semibold">Modelle selbst suchen</SheetTitle>
<SheetDescription className="text-text-2">Auf Hugging Face suchen, Quantisierung wählen, installieren.</SheetDescription> <SheetDescription className="text-text-2">Auf Hugging Face suchen, Quantisierung wählen, installieren.</SheetDescription>
</SheetHeader> </SheetHeader>
<form <form
@@ -91,7 +91,7 @@ export function ModellSuche({ offen, onSchliessen, rollenJetzt }: {
value={eingabe} value={eingabe}
onChange={(e) => setEingabe(e.target.value)} onChange={(e) => setEingabe(e.target.value)}
placeholder="z. B. Qwen3.8 GGUF" placeholder="z. B. Qwen3.8 GGUF"
className="h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan" className="h-11 min-w-0 flex-1 rounded-xl border border-white/12 bg-white/[0.04] px-3 text-[15px] outline-none focus:border-cyan/60 focus:bg-white/[0.06]"
/> />
<Button type="submit">Suchen</Button> <Button type="submit">Suchen</Button>
</form> </form>
@@ -112,7 +112,7 @@ export function ModellSuche({ offen, onSchliessen, rollenJetzt }: {
key={t.repo} key={t.repo}
type="button" type="button"
onClick={() => setRepo(t.repo)} onClick={() => setRepo(t.repo)}
className="flex min-h-11 items-center justify-between gap-3 border-t border-linie py-2 text-left hover:text-cyan" className="flex min-h-11 items-center justify-between gap-3 border-t border-white/[0.06] py-2 text-left hover:text-cyan"
> >
<span className="ziffern min-w-0 text-sm break-all">{t.repo}</span> <span className="ziffern min-w-0 text-sm break-all">{t.repo}</span>
<span className="ziffern shrink-0 text-xs text-text-3">{zahl(t.downloads)} ↓</span> <span className="ziffern shrink-0 text-xs text-text-3">{zahl(t.downloads)} ↓</span>
@@ -131,7 +131,7 @@ export function ModellSuche({ offen, onSchliessen, rollenJetzt }: {
<select <select
value={rolle} value={rolle}
onChange={(e) => setRolle(e.target.value)} onChange={(e) => setRolle(e.target.value)}
className="h-11 min-w-0 rounded-lg border border-linie-stark bg-erhaben px-3 text-foreground" className="h-11 min-w-0 rounded-xl border border-white/12 bg-white/[0.04] px-3 text-foreground"
> >
<option value="">ohne Rolle (nur installieren)</option> <option value="">ohne Rolle (nur installieren)</option>
{Object.entries(ROLLEN).map(([wert, name]) => ( {Object.entries(ROLLEN).map(([wert, name]) => (
@@ -155,7 +155,7 @@ export function ModellSuche({ offen, onSchliessen, rollenJetzt }: {
const name = q.quant ?? q.name ?? "?" const name = q.quant ?? q.name ?? "?"
const bytes = q.total_bytes ?? q.size_bytes const bytes = q.total_bytes ?? q.size_bytes
return ( return (
<div key={name} className="flex items-center justify-between gap-3 border-t border-linie py-2"> <div key={name} className="flex items-center justify-between gap-3 border-t border-white/[0.06] py-2">
<span className="ziffern text-sm">{name}</span> <span className="ziffern text-sm">{name}</span>
<span className="flex items-center gap-3"> <span className="flex items-center gap-3">
{bytes ? <span className="ziffern text-xs text-text-3">{gbText(bytes, 1)}</span> : null} {bytes ? <span className="ziffern text-xs text-text-3">{gbText(bytes, 1)}</span> : null}
+14 -14
View File
@@ -34,7 +34,7 @@ function Speicher({ modelle, laufend }: { modelle: Modell[]; laufend: string[] }
const pct = (g: number) => `${Math.min(100, (g / gesamt) * 100)}%` const pct = (g: number) => `${Math.min(100, (g / gesamt) * 100)}%`
return ( return (
<Panel titel="Speicher" rechts={<span className="ziffern text-sm text-text-2">{Math.round(belegt)} von {Math.round(gesamt)} GB belegt · Grenze ~{GRENZE_GB} GB</span>}> <Panel titel="Speicher" rechts={<span className="ziffern text-sm text-text-2">{Math.round(belegt)} von {Math.round(gesamt)} GB belegt · Grenze ~{GRENZE_GB} GB</span>}>
<div className="relative h-8 rounded-lg bg-erhaben" role="img" aria-label={`Speicher: ${Math.round(belegt)} von ${Math.round(gesamt)} GB belegt`}> <div className="relative h-8 overflow-hidden rounded-xl bg-white/[0.06]" role="img" aria-label={`Speicher: ${Math.round(belegt)} von ${Math.round(gesamt)} GB belegt`}>
<div className="absolute inset-y-0 left-0 flex overflow-hidden rounded-lg" style={{ width: pct(belegt) }}> <div className="absolute inset-y-0 left-0 flex overflow-hidden rounded-lg" style={{ width: pct(belegt) }}>
<div className="h-full bg-cyan" style={{ width: `${belegt ? (dateien / belegt) * 100 : 0}%` }} /> <div className="h-full bg-cyan" style={{ width: `${belegt ? (dateien / belegt) * 100 : 0}%` }} />
<div className="h-full bg-cyan/35" style={{ width: `${belegt ? (rest / belegt) * 100 : 0}%` }} /> <div className="h-full bg-cyan/35" style={{ width: `${belegt ? (rest / belegt) * 100 : 0}%` }} />
@@ -69,8 +69,8 @@ function Rolle({ titel, modell, zwilling, laeuft, nutzer }: {
const laden = useModellLaden() const laden = useModellLaden()
if (!modell) { if (!modell) {
return ( return (
<article className="flex flex-col gap-3.5 rounded-2xl border border-dashed border-linie-stark p-5"> <article className="flex flex-col gap-3.5 rounded-2xl border border-dashed border-white/15 p-5">
<div className="flex items-center justify-between"><span className="schild text-sm text-text-3">{titel}</span><Chip>Frei</Chip></div> <div className="flex items-center justify-between"><span className="etikett text-[11px] tracking-[0.14em] text-text-3">{titel}</span><Chip>Frei</Chip></div>
<h3 className="font-anzeige text-xl font-semibold text-text-2">Nicht belegt</h3> <h3 className="font-anzeige text-xl font-semibold text-text-2">Nicht belegt</h3>
<p className="text-sm text-text-2">Nur belegen, wenn ein Modell hier echten Mehrwert bringt. Das Radar meldet Kandidaten.</p> <p className="text-sm text-text-2">Nur belegen, wenn ein Modell hier echten Mehrwert bringt. Das Radar meldet Kandidaten.</p>
</article> </article>
@@ -80,9 +80,9 @@ function Rolle({ titel, modell, zwilling, laeuft, nutzer }: {
// Seit 24.09.: Hirn und Coder sehen über ihren Bild-Zwilling (gleiche Gewichte, ohne Draft). // Seit 24.09.: Hirn und Coder sehen über ihren Bild-Zwilling (gleiche Gewichte, ohne Draft).
const bilder = Boolean(modell.capabilities?.vision) || Boolean(zwilling) const bilder = Boolean(modell.capabilities?.vision) || Boolean(zwilling)
return ( return (
<article className="flex flex-col gap-3.5 rounded-2xl border border-linie bg-panel p-5"> <article className="flex flex-col gap-3.5 flaeche rounded-2xl p-5">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="schild text-sm text-text-3">{titel}</span> <span className="etikett text-[11px] tracking-[0.14em] text-text-3">{titel}</span>
<Chip art={laeuft ? "gruen" : "grau"}>{laeuft ? "Geladen" : "Auf Abruf"}</Chip> <Chip art={laeuft ? "gruen" : "grau"}>{laeuft ? "Geladen" : "Auf Abruf"}</Chip>
</div> </div>
<h3 className="ziffern m-0 text-lg font-medium break-all">{modell.name}</h3> <h3 className="ziffern m-0 text-lg font-medium break-all">{modell.name}</h3>
@@ -116,14 +116,14 @@ function Vergleich({ test }: { test: RadarTest }) {
const zeilen = Object.entries(test.vergleich ?? {}) as [string, Vergleichszeile][] const zeilen = Object.entries(test.vergleich ?? {}) as [string, Vergleichszeile][]
if (zeilen.length === 0) return null if (zeilen.length === 0) return null
return ( return (
<div role="table" aria-label="Messwerte gegen das heutige Modell" className="rounded-lg border border-linie bg-erhaben/50 px-3 py-2 text-sm"> <div role="table" aria-label="Messwerte gegen das heutige Modell" className="flaeche-innen rounded-xl px-3 py-2 text-sm">
<div role="row" className="schild grid grid-cols-[minmax(0,1fr)_auto_auto] gap-x-4 pb-1 text-xs text-text-3"> <div role="row" className="etikett grid grid-cols-[minmax(0,1fr)_auto_auto] gap-x-4 pb-1 text-[10.5px] tracking-[0.14em] text-text-3">
<span role="columnheader">Messwert</span> <span role="columnheader">Messwert</span>
<span role="columnheader" className="text-right">Kandidat</span> <span role="columnheader" className="text-right">Kandidat</span>
<span role="columnheader" className="text-right">Heute</span> <span role="columnheader" className="text-right">Heute</span>
</div> </div>
{zeilen.map(([name, z]) => ( {zeilen.map(([name, z]) => (
<div key={name} role="row" className="grid grid-cols-[minmax(0,1fr)_auto_auto] gap-x-4 border-t border-linie py-1"> <div key={name} role="row" className="grid grid-cols-[minmax(0,1fr)_auto_auto] gap-x-4 border-t border-white/[0.06] py-1">
<span role="rowheader" className="text-text-2">{name}</span> <span role="rowheader" className="text-text-2">{name}</span>
<span role="cell" className={`ziffern text-right ${URTEIL_FARBE[z.urteil ?? ""] ?? "text-text-2"}`}> <span role="cell" className={`ziffern text-right ${URTEIL_FARBE[z.urteil ?? ""] ?? "text-text-2"}`}>
{messwert(z.kandidat, z.einheit)} {messwert(z.kandidat, z.einheit)}
@@ -142,7 +142,7 @@ export function Kandidat({ k, test, heute }: { k: RadarKandidat; test: RadarTest
const offen = ["bestanden", "wartet", "neu", "getestet"].includes(k.status) const offen = ["bestanden", "wartet", "neu", "getestet"].includes(k.status)
const hirn = k.rolle === "hirn" const hirn = k.rolle === "hirn"
return ( return (
<li className="flex flex-col gap-2 border-t border-linie py-3.5"> <li className="flex flex-col gap-2 border-t border-white/[0.06] py-3.5">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="ziffern text-[15px] font-medium">{k.name}</span> <span className="ziffern text-[15px] font-medium">{k.name}</span>
<Chip art="cyan">{hirn ? "fürs Hirn" : "fürs Coden"}</Chip> <Chip art="cyan">{hirn ? "fürs Hirn" : "fürs Coden"}</Chip>
@@ -294,7 +294,7 @@ function RadarPanel({ hirn, coder }: { hirn: string | undefined; coder: string |
)} )}
{d && d.tests.length > 0 && ( {d && d.tests.length > 0 && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h3 className="schild text-xs text-text-3">Letzte Tests</h3> <h3 className="etikett text-[10.5px] tracking-[0.12em] text-text-3">Letzte Tests</h3>
{d.tests.slice(0, 3).map((t) => ( {d.tests.slice(0, 3).map((t) => (
<p key={t.id} className="text-sm text-text-2"> <p key={t.id} className="text-sm text-text-2">
<span className="ziffern text-text-3">{flugplanZeit(t.datum)}</span> · {t.zusammenfassung} <span className="ziffern text-text-3">{flugplanZeit(t.datum)}</span> · {t.zusammenfassung}
@@ -312,7 +312,7 @@ const LOESCHKNOPF = "text-rot-text hover:bg-rot-grund hover:text-rot-text"
/** Eine eingeklappte Liste mit Zusammenfassung — lange Aufräum-Listen erst auf Wunsch. */ /** Eine eingeklappte Liste mit Zusammenfassung — lange Aufräum-Listen erst auf Wunsch. */
function Klappe({ titel, zusatz, children }: { titel: string; zusatz: string; children: ReactNode }) { function Klappe({ titel, zusatz, children }: { titel: string; zusatz: string; children: ReactNode }) {
return ( return (
<details className="group rounded-xl border border-linie bg-erhaben/40 px-4 open:pb-2"> <details className="group flaeche-innen rounded-2xl px-4 open:pb-2">
<summary className="flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 [&::-webkit-details-marker]:hidden"> <summary className="flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 [&::-webkit-details-marker]:hidden">
<span className="flex items-center gap-2 text-[15px] font-medium"> <span className="flex items-center gap-2 text-[15px] font-medium">
<ChevronRight className="size-4 text-text-3 transition-transform group-open:rotate-90" aria-hidden /> <ChevronRight className="size-4 text-text-3 transition-transform group-open:rotate-90" aria-hidden />
@@ -358,7 +358,7 @@ function AufraeumenPanel() {
<Klappe titel="Modelle und Ordner" zusatz={`${kandidaten.length} Einträge · bis zu ${summe.toLocaleString("de-DE", { maximumFractionDigits: 0 })} GB`}> <Klappe titel="Modelle und Ordner" zusatz={`${kandidaten.length} Einträge · bis zu ${summe.toLocaleString("de-DE", { maximumFractionDigits: 0 })} GB`}>
<ul className="m-0 flex list-none flex-col p-0"> <ul className="m-0 flex list-none flex-col p-0">
{kandidaten.map((k) => ( {kandidaten.map((k) => (
<li key={`${k.art}-${k.name}`} className="flex flex-wrap items-center justify-between gap-3 border-t border-linie py-3"> <li key={`${k.art}-${k.name}`} className="flex flex-wrap items-center justify-between gap-3 border-t border-white/[0.06] py-3">
<span className="flex min-w-0 flex-1 flex-col gap-1"> <span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex flex-wrap items-center gap-2"> <span className="flex flex-wrap items-center gap-2">
<span className="ziffern text-sm break-all">{k.name}</span> <span className="ziffern text-sm break-all">{k.name}</span>
@@ -426,7 +426,7 @@ function Altreste() {
</p> </p>
<ul className="m-0 flex list-none flex-col p-0"> <ul className="m-0 flex list-none flex-col p-0">
{reste.map((a) => ( {reste.map((a) => (
<li key={a.id} className="flex flex-wrap items-center justify-between gap-3 border-t border-linie py-3"> <li key={a.id} className="flex flex-wrap items-center justify-between gap-3 border-t border-white/[0.06] py-3">
<span className="flex min-w-0 flex-1 flex-col gap-1"> <span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex flex-wrap items-center gap-2"> <span className="flex flex-wrap items-center gap-2">
<span className="text-sm">{a.name}</span> <span className="text-sm">{a.name}</span>
@@ -521,7 +521,7 @@ export function ModelleSeite() {
{weitere.length === 0 && <p className="text-[15px] text-text-2">Keine weiteren Einträge.</p>} {weitere.length === 0 && <p className="text-[15px] text-text-2">Keine weiteren Einträge.</p>}
<ul className="m-0 flex list-none flex-col p-0"> <ul className="m-0 flex list-none flex-col p-0">
{weitere.map((m) => ( {weitere.map((m) => (
<li key={m.name} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-t border-linie py-2.5 sm:grid-cols-[minmax(0,1fr)_120px_160px]"> <li key={m.name} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-t border-white/[0.06] py-2.5 sm:grid-cols-[minmax(0,1fr)_120px_160px]">
<span className="min-w-0"> <span className="min-w-0">
<span className="ziffern block text-sm break-all">{m.name}</span> <span className="ziffern block text-sm break-all">{m.name}</span>
<span className="text-xs text-text-3">{m.aliases.length ? m.aliases.join(", ") : "ohne Rolle"}</span> <span className="text-xs text-text-3">{m.aliases.length ? m.aliases.join(", ") : "ohne Rolle"}</span>
+10 -5
View File
@@ -2,10 +2,12 @@ import { type ReactNode, useEffect, useState } from "react"
import { useRouterState } from "@tanstack/react-router" import { useRouterState } from "@tanstack/react-router"
import { cn } from "cn" import { cn } from "cn"
import { Zustandsfeld } from "@/components/cockpit/Chip" import { Zustandsfeld } from "@/components/cockpit/Chip"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { Panel } from "@/components/cockpit/Panel" import { Panel } from "@/components/cockpit/Panel"
import { Kennzahl } from "@/components/diagramme/Kennzahl" import { Kennzahl } from "@/components/diagramme/Kennzahl"
import { Verlauf } from "@/components/diagramme/Verlauf" import { Verlauf } from "@/components/diagramme/Verlauf"
import { useBoxMesswerte, useHomelabMesswerte, useInstanz, usePartner } from "@/lib/abfragen" import { useBoxMesswerte, useHomelabMesswerte, useInstanz, usePartner } from "@/lib/abfragen"
import { geraetebild } from "@/lib/geraetebild"
import { laufzeit, letzterWert, zustandBei } from "@/lib/messung" import { laufzeit, letzterWert, zustandBei } from "@/lib/messung"
import { useLetzteMetrik } from "@/lib/strom" import { useLetzteMetrik } from "@/lib/strom"
import type { GeraetMesswerte, Zeitraum } from "@/lib/typen" import type { GeraetMesswerte, Zeitraum } from "@/lib/typen"
@@ -25,7 +27,7 @@ function ZeitraumWahl({ wert, onWahl, farbe }: { wert: Zeitraum; onWahl: (z: Zei
<div role="group" aria-label="Zeitraum" className="flex flex-wrap gap-2"> <div role="group" aria-label="Zeitraum" className="flex flex-wrap gap-2">
{ZEITRAEUME.map((z) => ( {ZEITRAEUME.map((z) => (
<button key={z.id} type="button" onClick={() => onWahl(z.id)} aria-pressed={wert === z.id} <button key={z.id} type="button" onClick={() => onWahl(z.id)} aria-pressed={wert === z.id}
className={cn("h-9 rounded-full border px-4 text-sm", wert === z.id ? aktiv : "border-linie text-text-2 hover:border-linie-stark hover:text-foreground")}> className={cn("h-9 rounded-full border px-4 text-sm", wert === z.id ? aktiv : "border-white/10 text-text-2 hover:border-white/20 hover:text-foreground")}>
{z.name} {z.name}
</button> </button>
))} ))}
@@ -35,9 +37,9 @@ function ZeitraumWahl({ wert, onWahl, farbe }: { wert: Zeitraum; onWahl: (z: Zei
function Diagrammkarte({ titel, rechts, children }: { titel: string; rechts?: ReactNode; children: ReactNode }) { function Diagrammkarte({ titel, rechts, children }: { titel: string; rechts?: ReactNode; children: ReactNode }) {
return ( return (
<section aria-label={titel} className="flex min-w-0 flex-col gap-3 rounded-2xl border border-linie bg-panel p-4 sm:p-5"> <section aria-label={titel} className="flex min-w-0 flex-col gap-3 flaeche rounded-2xl p-4 sm:p-5">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h3 className="schild text-sm tracking-[0.18em] text-text-2">{titel}</h3> <h3 className="etikett text-[11.5px] tracking-[0.16em] text-text-2">{titel}</h3>
{rechts} {rechts}
</div> </div>
{children} {children}
@@ -95,9 +97,12 @@ export function BoxMonitoringSeite() {
function GastKarte({ g, zeitraum }: { g: GeraetMesswerte; zeitraum: Zeitraum }) { function GastKarte({ g, zeitraum }: { g: GeraetMesswerte; zeitraum: Zeitraum }) {
const a = g.aktuell const a = g.aktuell
return ( return (
<section id={g.id} aria-label={g.name} className="flex min-w-0 scroll-mt-6 flex-col gap-3 rounded-2xl border border-linie bg-panel p-4 sm:p-5"> <section id={g.id} aria-label={g.name} className="flex min-w-0 scroll-mt-6 flex-col gap-3 flaeche rounded-2xl p-4 sm:p-5">
<div className="flex flex-wrap items-baseline justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-3">
<Geraetesymbol bild={geraetebild({ art: g.art, name: g.name })} groesse="sm" />
<h3 className="text-[15px] font-semibold">{g.name}</h3> <h3 className="text-[15px] font-semibold">{g.name}</h3>
</span>
<span className="ziffern text-xs text-text-3"> <span className="ziffern text-xs text-text-3">
CPU {a.cpu != null ? `${Math.round(a.cpu)} %` : "–"} · RAM {a.ram != null ? `${Math.round(a.ram)} %` : "–"} · läuft {laufzeit(a.uptime_s)} CPU {a.cpu != null ? `${Math.round(a.cpu)} %` : "–"} · RAM {a.ram != null ? `${Math.round(a.ram)} %` : "–"} · läuft {laufzeit(a.uptime_s)}
</span> </span>
+6 -5
View File
@@ -1,3 +1,4 @@
import type { CSSProperties } from "react"
import { Link } from "@tanstack/react-router" import { Link } from "@tanstack/react-router"
import { Checkliste } from "@/components/cockpit/Checkliste" import { Checkliste } from "@/components/cockpit/Checkliste"
import { Chip, Zustandsfeld } from "@/components/cockpit/Chip" import { Chip, Zustandsfeld } from "@/components/cockpit/Chip"
@@ -25,7 +26,7 @@ function Instrumente({ box }: { box: Start["box"] }) {
const ramAnteil = ramBelegt != null && ramGesamt ? ramBelegt / ramGesamt : null const ramAnteil = ramBelegt != null && ramGesamt ? ramBelegt / ramGesamt : null
return ( return (
<section aria-label="Instrumente" className="grid grid-cols-2 gap-x-4 gap-y-6 rounded-2xl border border-linie bg-panel px-4 py-5 lg:grid-cols-4 lg:px-6"> <section aria-label="Instrumente" className="auftauchen flaeche grid grid-cols-2 gap-x-4 gap-y-6 rounded-3xl px-4 py-5 lg:grid-cols-4 lg:px-6" style={{ "--i": 1 } as CSSProperties}>
<Instrument <Instrument
beschriftung="Speicher" beschriftung="Speicher"
anteil={ramAnteil} anteil={ramAnteil}
@@ -48,7 +49,7 @@ function Instrumente({ box }: { box: Start["box"] }) {
<Instrument <Instrument
beschriftung="Platte" beschriftung="Platte"
anteil={platte ? platte.percent / 100 : null} anteil={platte ? platte.percent / 100 : null}
farbe={platte && platte.percent >= 90 ? "var(--rot)" : platte && platte.percent >= 80 ? "var(--bernstein)" : "var(--cyan)"} farbe={platte && platte.percent >= 90 ? "var(--rot)" : platte && platte.percent >= 80 ? "var(--bernstein)" : "var(--box)"}
mitte={platte ? `${Math.round(platte.percent)}%` : "–"} mitte={platte ? `${Math.round(platte.percent)}%` : "–"}
unter={platte ? `${gbText(platte.used)} / ${gbText(platte.total)}` : undefined} unter={platte ? `${gbText(platte.used)} / ${gbText(platte.total)}` : undefined}
ariaText={platte ? `Platte zu ${Math.round(platte.percent)} Prozent belegt` : "Platte unbekannt"} ariaText={platte ? `Platte zu ${Math.round(platte.percent)} Prozent belegt` : "Platte unbekannt"}
@@ -65,9 +66,9 @@ function RadarKasten({ radar }: { radar: Radar | undefined }) {
const offen = radar.kandidaten.filter((k) => ["bestanden", "wartet", "neu"].includes(k.status)) const offen = radar.kandidaten.filter((k) => ["bestanden", "wartet", "neu"].includes(k.status))
const vorn = offen.find((k) => k.status === "bestanden") ?? offen[0] const vorn = offen.find((k) => k.status === "bestanden") ?? offen[0]
return ( return (
<div className="mt-auto flex flex-wrap items-center justify-between gap-3 rounded-xl border border-cyan-rand bg-cyan-grund px-4 py-3.5"> <div className="mt-auto flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-cyan/[0.06] px-4 py-3.5 ring-1 ring-cyan/30 ring-inset">
<div className="flex min-w-0 flex-col gap-1"> <div className="flex min-w-0 flex-col gap-1">
<span className="schild text-sm text-cyan"> <span className="etikett text-[11px] tracking-[0.14em] text-cyan-text">
Radar · {offen.length} Kandidat{offen.length === 1 ? "" : "en"} Radar · {offen.length} Kandidat{offen.length === 1 ? "" : "en"}
</span> </span>
{vorn ? ( {vorn ? (
@@ -109,7 +110,7 @@ export function StartSeite() {
<Panel <Panel
titel="Checkliste" titel="Checkliste"
id="checkliste" id="checkliste"
rechts={<span className={offen ? "ziffern text-sm text-bernstein" : "ziffern text-sm text-gruen"}>{offen ? `${offen} offen` : "alles erledigt"}</span>} rechts={<Chip art={offen ? "bernstein" : "gruen"}>{offen ? `${offen} offen` : "alles erledigt"}</Chip>}
> >
<Checkliste hinweise={start.hinweise} verlauf={start.verlauf} /> <Checkliste hinweise={start.hinweise} verlauf={start.verlauf} />
</Panel> </Panel>
+9 -9
View File
@@ -53,7 +53,7 @@ function Zusammenfassung({ text }: { text: string }) {
type="button" type="button"
aria-expanded={offen} aria-expanded={offen}
onClick={() => setOffen(!offen)} onClick={() => setOffen(!offen)}
className="-mb-2 inline-flex h-11 items-center text-sm text-cyan hover:underline" className="-mb-2 inline-flex h-11 items-center text-sm text-cyan-text hover:underline"
> >
{offen ? "Weniger zeigen" : "Ganze Zusammenfassung zeigen"} {offen ? "Weniger zeigen" : "Ganze Zusammenfassung zeigen"}
</button> </button>
@@ -69,13 +69,13 @@ function Paketliste({ d }: { d: UpdateDetails }) {
if (pakete.length === 0 && zurueck.length === 0) return null if (pakete.length === 0 && zurueck.length === 0) return null
return ( return (
<details className="group"> <details className="group">
<summary className="inline-flex h-11 cursor-pointer items-center gap-1 text-sm text-cyan hover:underline"> <summary className="inline-flex h-11 cursor-pointer items-center gap-1 text-sm text-cyan-text hover:underline">
<ChevronRight aria-hidden className="size-4 transition-transform group-open:rotate-90" /> <ChevronRight aria-hidden className="size-4 transition-transform group-open:rotate-90" />
{pakete.length === 0 {pakete.length === 0
? `Was Ubuntu zurückhält (${zurueck.length})` ? `Was Ubuntu zurückhält (${zurueck.length})`
: pakete.length === 1 ? "Das Paket ansehen" : `Alle ${pakete.length} Pakete ansehen`} : pakete.length === 1 ? "Das Paket ansehen" : `Alle ${pakete.length} Pakete ansehen`}
</summary> </summary>
<ul className="ziffern m-0 flex max-h-64 list-none flex-col gap-1 overflow-auto rounded-md border border-linie bg-[#0b0d0f] p-2.5 text-xs"> <ul className="ziffern m-0 flex max-h-64 list-none flex-col gap-1 overflow-auto rounded-xl bg-black/30 ring-1 ring-white/[0.07] ring-inset p-2.5 text-xs">
{pakete.map((p) => ( {pakete.map((p) => (
<li key={p.name} className="break-all"> <li key={p.name} className="break-all">
<span className="text-foreground">{p.name}</span>{" "} <span className="text-foreground">{p.name}</span>{" "}
@@ -139,7 +139,7 @@ function BausteinZeile({ id, name, details, hatUpdate, neuLautStart, fest, gespe
const neuZelle = laedt ? ( const neuZelle = laedt ? (
<Skelett breit="w-20" /> <Skelett breit="w-20" />
) : neu ? ( ) : neu ? (
<span className="text-cyan">{neu}</span> <span className="text-cyan-text">{neu}</span>
) : fehler ? ( ) : fehler ? (
<span className="text-bernstein">nicht geprüft</span> <span className="text-bernstein">nicht geprüft</span>
) : ( ) : (
@@ -176,7 +176,7 @@ function BausteinZeile({ id, name, details, hatUpdate, neuLautStart, fest, gespe
<div <div
role="row" role="row"
aria-busy={laedt || undefined} aria-busy={laedt || undefined}
className="grid grid-cols-1 gap-x-4 gap-y-2 border-t border-linie py-3.5 md:grid-cols-[170px_100px_170px_minmax(0,1fr)_auto] md:items-start" className="grid grid-cols-1 gap-x-4 gap-y-2 border-t border-white/[0.06] py-3.5 md:grid-cols-[170px_100px_170px_minmax(0,1fr)_auto] md:items-start"
> >
<span role="rowheader" className="text-[15px] font-semibold">{name}</span> <span role="rowheader" className="text-[15px] font-semibold">{name}</span>
<span role="cell" className="ziffern -mt-1 text-sm text-text-2 md:hidden"> <span role="cell" className="ziffern -mt-1 text-sm text-text-2 md:hidden">
@@ -221,7 +221,7 @@ const PUNKT: Record<UpdateLauf["stufe"], string> = {
/** Ein Update-Lauf mit dem Ergebnis je Baustein — so, wie autoupdate.sh es gemeldet hat. */ /** Ein Update-Lauf mit dem Ergebnis je Baustein — so, wie autoupdate.sh es gemeldet hat. */
function LaufEintrag({ lauf }: { lauf: UpdateLauf }) { function LaufEintrag({ lauf }: { lauf: UpdateLauf }) {
return ( return (
<li className="flex flex-col gap-2 border-t border-linie py-3"> <li className="flex flex-col gap-2 border-t border-white/[0.06] py-3">
{/* Schmales Feld: Zeit und Ergebnis oben, der Anlass darunter über die volle Breite. */} {/* Schmales Feld: Zeit und Ergebnis oben, der Anlass darunter über die volle Breite. */}
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 @sm:grid-cols-[112px_minmax(0,1fr)_auto]"> <div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 @sm:grid-cols-[112px_minmax(0,1fr)_auto]">
<span className="ziffern col-start-1 row-start-1 text-sm text-text-3">{flugplanZeit(lauf.start)}</span> <span className="ziffern col-start-1 row-start-1 text-sm text-text-3">{flugplanZeit(lauf.start)}</span>
@@ -297,7 +297,7 @@ export function UpdatesSeite() {
<div role="table" aria-label="Bausteine und ihre Updates" className="flex flex-col"> <div role="table" aria-label="Bausteine und ihre Updates" className="flex flex-col">
<div <div
role="row" role="row"
className="schild hidden grid-cols-[170px_100px_170px_minmax(0,1fr)_auto] gap-x-4 pb-2 text-xs font-semibold text-text-3 md:grid" className="etikett hidden grid-cols-[170px_100px_170px_minmax(0,1fr)_auto] gap-x-4 pb-2 text-[10.5px] tracking-[0.14em] text-text-3 md:grid"
> >
<span role="columnheader">Baustein</span> <span role="columnheader">Baustein</span>
<span role="columnheader">Läuft</span> <span role="columnheader">Läuft</span>
@@ -352,7 +352,7 @@ export function UpdatesSeite() {
sicherungsInhalt = ( sicherungsInhalt = (
<ul className="m-0 flex list-none flex-col p-0"> <ul className="m-0 flex list-none flex-col p-0">
{sicherungen.data.backups.slice(0, 5).map((s) => ( {sicherungen.data.backups.slice(0, 5).map((s) => (
<li key={s.file} className="flex items-center justify-between gap-3 border-t border-linie py-2.5"> <li key={s.file} className="flex items-center justify-between gap-3 border-t border-white/[0.06] py-2.5">
<span className="ziffern text-sm">{sicherungsZeit(s)}</span> <span className="ziffern text-sm">{sicherungsZeit(s)}</span>
<span className="flex items-center gap-3"> <span className="flex items-center gap-3">
<span className="ziffern text-xs text-text-3">{s.size_mb.toLocaleString("de-DE")} MB</span> <span className="ziffern text-xs text-text-3">{s.size_mb.toLocaleString("de-DE")} MB</span>
@@ -395,7 +395,7 @@ export function UpdatesSeite() {
wird gesichert, danach geprüft. wird gesichert, danach geprüft.
</p> </p>
{updateLaeuft && ( {updateLaeuft && (
<p role="status" className="text-[15px] text-cyan"> <p role="status" className="text-[15px] text-cyan-text">
Gerade läuft ein Update. Weitere lassen sich erst starten, wenn es fertig ist. Gerade läuft ein Update. Weitere lassen sich erst starten, wenn es fertig ist.
</p> </p>
)} )}