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) {
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">
<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>
<button
type="button"
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
</button>
+2 -2
View File
@@ -44,7 +44,7 @@ function Verbindung() {
<span
role="status"
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 className={live ? "sr-only" : undefined}>{live ? "Verbunden" : lage === "verbinde" ? "Verbinde" : "Getrennt"}</span>
@@ -79,7 +79,7 @@ export function Shell() {
<button
type="button"
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"
>
<Menu className="size-6" />
@@ -38,7 +38,7 @@ export function Auftraege({ alsPanel = false }: { alsPanel?: boolean }) {
if (alsPanel) return <Panel titel="Aufträge">{inhalt}</Panel>
return (
<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}
</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
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). */}
<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>
<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>
</DialogHeader>
{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}>
{neinKnopf}
</Button>
@@ -42,11 +42,12 @@ function Punkt({ h }: { h: Hinweis }) {
}
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">
<h3 className="min-w-0 font-sans text-[17px] 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("schild shrink-0 text-base", rot ? "text-rot" : "text-bernstein")}>
<h3 className="min-w-0 flex-1 font-sans text-[16.5px] leading-snug font-semibold">{h.titel}</h3>
<span className={cn("etikett shrink-0 rounded-full px-2.5 py-0.5 text-[10.5px] tracking-[0.12em] ring-1 ring-inset",
rot ? "bg-rot/12 text-rot-text ring-rot/40" : "bg-bernstein/10 text-bernstein ring-bernstein/35")}>
{rot ? "Jetzt" : "Prüfen"}
</span>
</div>
@@ -101,7 +102,7 @@ export function Checkliste({ hinweise, verlauf }: { hinweise: Hinweis[]; verlauf
)
if (hinweise.length === 0) {
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>
{erledigtHeute.length > 0 && (
<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 (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3">
{hinweise.map((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 { flugplanZeit } from "@/lib/zeit"
const STATUS: Record<FlugplanEintrag["status"], { text: string; farbe: string }> = {
erledigt: { text: "Erledigt", farbe: "text-gruen" },
fehler: { text: "Fehler", farbe: "text-rot" },
geplant: { text: "Geplant", farbe: "text-text-3" },
const STATUS: Record<FlugplanEintrag["status"], { text: string; farbe: string; punkt: string }> = {
erledigt: { text: "Erledigt", farbe: "text-gruen-text", punkt: "bg-gruen" },
fehler: { text: "Fehler", farbe: "text-rot-text", punkt: "bg-rot" },
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 —
@@ -14,15 +14,18 @@ const STATUS: Record<FlugplanEintrag["status"], { text: string; farbe: string }>
function Zeile({ e }: { e: FlugplanEintrag }) {
const s = STATUS[e.status] ?? STATUS.geplant
// 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 (
<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="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.text && <span className="text-text-3">, {e.text}</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>
)
}
@@ -9,7 +9,7 @@ export interface Zone {
export function Instrument({
anteil,
farbe = "var(--cyan)",
farbe = "var(--box)",
zonen = [],
mitte,
unter,
@@ -28,23 +28,24 @@ export function Instrument({
return (
<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}>
<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) => (
<path key={`${z.von}-${z.bis}`} d={bogenPfad(z.von, z.bis, 86)} fill="none" stroke={z.farbe} strokeWidth={4} strokeLinecap="round" />
))}
{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}
</text>
{unter && (
<text x="100" y="122" textAnchor="middle" style={{ fontFamily: "var(--font-anzeige)", fontSize: 14, fontWeight: 600, letterSpacing: "0.12em", fill: "var(--text-3)" }}>
{unter.toUpperCase()}
<text x="100" y="124" textAnchor="middle" style={{ fontFamily: "var(--font-sans)", fontSize: 13, fontWeight: 500, fill: "var(--text-3)" }}>
{unter}
</text>
)}
</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>
)
}
+1 -1
View File
@@ -62,7 +62,7 @@ export function JobKarte({ job, onAbbrechen, onQuittieren, abbrechenGesperrt, eb
const Titel = ebene === 3 ? "h3" : "h4"
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">
<Titel className="m-0 min-w-0 text-[15px] leading-snug font-semibold break-words">{titel}</Titel>
<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 (
<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>
<SheetTitle className="schild text-lg">Protokoll</SheetTitle>
<SheetTitle className="text-[19px] font-semibold">Protokoll</SheetTitle>
<SheetDescription className="text-text-2">{titel}</SheetDescription>
</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}
</pre>
</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 { hauptleuchte, LEUCHTE } from "@/lib/anzeige"
import { Lampe } from "./Lampe"
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 }: {
zustand: Zustand
lampen: LampenDaten[]
onAnsehen: () => void
}) {
const h = hauptleuchte(zustand)
const aktiv = h.art === "gelb" || h.art === "rot"
return (
<section aria-label="Warnpanel" className="grid gap-4 md:grid-cols-[260px_minmax(0,1fr)] md:gap-5">
<button
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">
<Lagekopf art={h.art} oben={h.oben} mitte={h.mitte} unten={h.unten} aktiv={h.art === "gelb" || h.art === "rot"}
onAnsehen={onAnsehen} label="Warnpanel">
{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>
</section>
</Lagekopf>
)
}
@@ -5,7 +5,7 @@ import { laufzeit } from "@/lib/zeit"
// es bei den alten 38 × 56 px je Ziffer.
function Ziffer({ z }: { z: string }) {
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}
</span>
)
@@ -27,12 +27,12 @@ export function Zaehlwerk({ sekunden }: { sekunden: number | null | undefined })
>
<Ziffer z={lz ? tage[0] : "–"} />
<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[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>
<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>
)
}
@@ -22,12 +22,12 @@ describe("bogenPfad", () => {
describe("hauptleuchte", () => {
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", () => {
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: "gelb", anzahl: 2 })).toMatchObject({ art: "gelb", mitte: "2 Hinweise" })
expect(hauptleuchte({ ...ruhig, stufe: "rot", anzahl: 1 })).toMatchObject({ art: "rot", mitte: "1 Hinweis" })
})
it("meldet einen schweigenden Wächter vor allem anderen", () => {
@@ -50,6 +50,6 @@ describe("Warnpanel", () => {
expect(screen.getByText("Motor")).toBeInTheDocument()
expect(screen.getByText("b11057")).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
}) {
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 }}>
<span className="ziffern text-xs text-text-3">{saeule.lang}</span>
{reihen.map((r) => (
@@ -42,7 +42,7 @@ function Anzeige({ saeule, reihen, summe, className, links }: {
</span>
))}
{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="text-text-3">zusammen</span>
</span>
@@ -208,7 +208,7 @@ export function Saeulen({ saeulen, reihen, titel, summen = "alle", hoehe = 200,
</thead>
<tbody>
{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>
{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>
@@ -132,7 +132,7 @@ export function Verlauf({ reihen, einheit, langerZeitraum, hoehe = 180, titel }:
{aktiv !== null && (
<div
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 }}
>
<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>
<tbody>
{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>
{reihen.map((r) => (
<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 { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { geraetebild, type Punktzustand } from "@/lib/geraetebild"
import { Lagekachel, Lagekopf } from "@/components/cockpit/Lagekopf"
import { geraetebild } from "@/lib/geraetebild"
import { type Lage, kurzname, lampeFuer } from "@/lib/homelab"
import type { LampenZustand, 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",
}
import type { ZielStand } from "@/lib/typen"
/**
* 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. */
onGeraet: (ziel: ZielStand, hatUpdate: boolean) => void
}) {
const a = ART[lage.art]
const aktiv = lage.ziel !== null
return (
<section aria-label="Lage im Homelab" 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={`${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">
<Lagekopf art={lage.art} oben={lage.oben} mitte={lage.mitte} unten={lage.unten} aktiv={lage.ziel !== null}
onAnsehen={onAnsehen} label="Lage im Homelab">
{ziele.map((z) => {
const l = lampeFuer(z, imUpdate.has(z.id))
return (
<li key={z.id}>
<button type="button" 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>
<Lagekachel key={z.id} bild={geraetebild(z)} name={kurzname(z)} wert={l.wert} zustand={l.zustand}
onClick={() => onGeraet(z, l.zustand === "info")} />
)
})}
</ul>
</div>
</section>
</Lagekopf>
)
}
@@ -61,7 +61,7 @@ export function AlleKnopf({ anzahl, gesperrt }: { anzahl: number; gesperrt: bool
const SCHRITT: Record<SammelSchrittStatus, { Symbol: typeof CircleCheck; farbe: string; text: string }> = {
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" },
zurueckgerollt: { Symbol: Undo2, farbe: "text-bernstein", text: "zurückgerollt" },
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 >= 85) return "var(--rot)"
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 ---------------------------------------------------------------
/** 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. */
export function hauptleuchte(z: Start["zustand"]) {
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) {
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) {
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"
? { 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" }
+22 -2
View File
@@ -1,6 +1,7 @@
import {
Bird, Bot, Box, BrainCircuit, Clapperboard, Code, Container, DatabaseBackup, FileCode2, GitBranch, HardDrive, House,
Layers, type LucideIcon, Monitor, Route, Server, ShieldCheck, Shuffle, Workflow, Zap,
AudioLines, Bird, Bot, Box, BrainCircuit, Clapperboard, Code, Container, DatabaseBackup, FileCode2, GitBranch, HardDrive,
House, Layers, LayoutDashboard, type LucideIcon, Mic, Monitor, RefreshCw, Route, Server, ShieldCheck, Shuffle, Workflow,
Zap,
} from "lucide-react"
import { LOGOS } from "@/lib/logos-liste"
import type { Bereich } from "@/lib/navigation"
@@ -81,6 +82,25 @@ const KI_BOX: Record<string, [LucideIcon, string?]> = {
jobs: [Workflow],
sicherung: [DatabaseBackup],
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. */
+1 -1
View File
@@ -36,7 +36,7 @@ describe("kleine Helfer", () => {
})
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(85)).toBe("var(--rot)")
})
+15 -7
View File
@@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"
import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip, Zustandsfeld } from "@/components/cockpit/Chip"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { Panel } from "@/components/cockpit/Panel"
import { Button } from "@/components/ui/button"
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,
zeitpunkt, zustandVon, zustandZusatz,
} from "@/lib/dienste"
import { dienstbild, type Punktzustand } from "@/lib/geraetebild"
import { melden } from "@/lib/meldungen"
import type { AktionsErgebnis } from "@/lib/typen"
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
// 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="ziffern text-sm text-text-2">{children}</span>
{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>
)}
@@ -69,7 +77,7 @@ function Protokoll({ d, id }: { d: Dienst; id: string }) {
ref={feld}
tabIndex={0}
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}
</pre>
@@ -90,9 +98,9 @@ function Zeile({ d, gesamt, offen, startet, onProtokoll, onFrage }: {
const neustarts = d.neustarts ?? 0
const protokollId = `protokoll-${d.unit}`
return (
<div role="row" className={cn("grid gap-y-2.5 border-t border-linie 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">
<span aria-hidden className={cn("mt-1.5 size-2.5 shrink-0 rounded-full", z.punkt)} />
<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-center gap-3 xl:col-span-1">
<Geraetesymbol bild={dienstbild(d.unit)} groesse="sm" punkt={PUNKT_DIENST[zustandVon(d)]} />
<span className="flex min-w-0 flex-col">
<span className="text-[15px] font-semibold break-words">{d.name}</span>
<span className="ziffern text-xs break-words text-text-3">
@@ -103,7 +111,7 @@ function Zeile({ d, gesamt, offen, startet, onProtokoll, onFrage }: {
</div>
<span role="cell" className="flex min-w-0 flex-col gap-0.5">
<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>}
{/* Ohne Tabelle stehen die Neustarts hier — und nur, wenn es welche gab. */}
{neustarts > 0 && (
@@ -187,7 +195,7 @@ function Tabelle({ stand, gewaehlt, startet, onProtokoll, onFrage }: {
}) {
return (
<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">Zustand</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 }) {
return (
<section aria-label={titel} className="flex flex-col gap-3 border-t border-linie pt-4 first:border-t-0 first:pt-0">
<h3 className="schild text-sm tracking-[0.18em] text-text-2">{titel}</h3>
<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="etikett text-[11.5px] tracking-[0.16em] text-text-2">{titel}</h3>
{children}
</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 }: {
titel: string
text: string
farbe?: "box" | "homelab"
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 rand = farbe === "box" ? "[--rand:color-mix(in_srgb,var(--box)_28%,transparent)]"
: farbe === "homelab" ? "[--rand:color-mix(in_srgb,var(--lab)_28%,transparent)]" : ""
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">
<span aria-hidden className={cn("absolute inset-x-0 top-0 h-1", streifen)} />
<div className="flex flex-col gap-1">
<h2 className={cn("schild text-lg font-bold tracking-[0.22em]", kopf)}>{titel}</h2>
<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)}>
{glut && (
<div aria-hidden className="pointer-events-none absolute -top-40 -left-32 size-[400px] rounded-full"
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>
</div>
{children}
<div className="relative flex min-w-0 flex-col gap-4">{children}</div>
</section>
)
}
@@ -151,7 +156,7 @@ function HfZugang({ offen }: { offen: boolean }) {
value={token}
onChange={(e) => setToken(e.target.value)}
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}>
{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 geaendert = wahl.tag !== f.tag || wahl.uhrzeit !== f.uhrzeit
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 (
<Abschnitt titel="Update-Zeitfenster">
<p className="text-sm text-text-2">
@@ -450,7 +455,7 @@ function ArcaneSchluessel({ h }: { h: HomelabEinstellungen }) {
value={wert}
onChange={(e) => setWert(e.target.value)}
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>
<Button type="submit" disabled={!wert.trim() || speichern.isPending}>
@@ -559,7 +564,7 @@ export function EinstellungenSeite() {
<RadarSchalter {...boxProps} />
<HfZugang offen />
<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
</a>
<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.">
<Stand offen />
</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>
)
}
+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.
const Zeichen = SYMBOL[e.art] ?? Cog
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="flex flex-col items-center pt-1">
<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"}
</button>
{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}
</pre>
)}
@@ -118,7 +118,7 @@ export function HomelabProtokollSeite() {
<button key={a.id} type="button" onClick={() => setArt(a.id)} aria-pressed={art === a.id}
className={cn(
"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}
</button>
@@ -138,7 +138,7 @@ export function HomelabProtokollSeite() {
<div className={cn("flex flex-col gap-5", protokoll.isFetching && "opacity-80")}>
{tage.map((t) => (
<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">
{t.eintraege.map((e, i) => {
// 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
}) {
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">
<span className="schild text-xs tracking-[0.18em] text-text-3">{label}</span>
<div className="flex min-w-0 flex-col gap-1.5 flaeche-innen rounded-2xl px-3.5 py-3">
<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>
{zusatz && <span className="text-xs text-text-3">{zusatz}</span>}
</div>
@@ -47,7 +47,7 @@ function Zelle({ label, wert, zusatz, leise }: { label: string; wert: string; zu
function AbsenderTabelle({ zeilen }: { zeilen: AbsenderZeile[] }) {
return (
<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">Anfragen</span>
<span role="columnheader">Typische Antwort</span>
@@ -56,7 +56,7 @@ function AbsenderTabelle({ zeilen }: { zeilen: AbsenderZeile[] }) {
<span role="columnheader">Zuletzt</span>
</div>
{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 aria-hidden className="size-2.5 shrink-0 rounded-[2px]" style={{ background: a.farbe }} />
<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="text-text-3"> · {anteilText(a.anteil)} aller Anfragen</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>
</span>
@@ -86,9 +86,9 @@ function AbsenderTabelle({ zeilen }: { zeilen: AbsenderZeile[] }) {
function Diagramm({ titel, zusatz, children }: { titel: string; zusatz: string; children: ReactNode }) {
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">
<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>
</div>
{children}
@@ -101,7 +101,7 @@ function Ladevorgaenge({ modelle, rollen }: { modelle: ModellLadungen[]; rollen:
if (modelle.length === 0) return null
const gesamt = modelle.every((m) => m.anzahl !== null) ? modelle.reduce((s, m) => s + (m.anzahl ?? 0), 0) : null
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">
<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 />
@@ -116,7 +116,7 @@ function Ladevorgaenge({ modelle, rollen }: { modelle: ModellLadungen[]; rollen:
wenn es auf Abruf gebraucht wurde.
</p>
<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">Geladen</span>
<span role="columnheader">Zuletzt</span>
@@ -124,7 +124,7 @@ function Ladevorgaenge({ modelle, rollen }: { modelle: ModellLadungen[]; rollen:
</div>
{modelle.map((m) => (
<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 className="ziffern text-sm break-all">{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 (
<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>
<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>
</SheetHeader>
<form
@@ -91,7 +91,7 @@ export function ModellSuche({ offen, onSchliessen, rollenJetzt }: {
value={eingabe}
onChange={(e) => setEingabe(e.target.value)}
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>
</form>
@@ -112,7 +112,7 @@ export function ModellSuche({ offen, onSchliessen, rollenJetzt }: {
key={t.repo}
type="button"
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 shrink-0 text-xs text-text-3">{zahl(t.downloads)} ↓</span>
@@ -131,7 +131,7 @@ export function ModellSuche({ offen, onSchliessen, rollenJetzt }: {
<select
value={rolle}
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>
{Object.entries(ROLLEN).map(([wert, name]) => (
@@ -155,7 +155,7 @@ export function ModellSuche({ offen, onSchliessen, rollenJetzt }: {
const name = q.quant ?? q.name ?? "?"
const bytes = q.total_bytes ?? q.size_bytes
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="flex items-center gap-3">
{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)}%`
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>}>
<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="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}%` }} />
@@ -69,8 +69,8 @@ function Rolle({ titel, modell, zwilling, laeuft, nutzer }: {
const laden = useModellLaden()
if (!modell) {
return (
<article className="flex flex-col gap-3.5 rounded-2xl border border-dashed border-linie-stark p-5">
<div className="flex items-center justify-between"><span className="schild text-sm text-text-3">{titel}</span><Chip>Frei</Chip></div>
<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="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>
<p className="text-sm text-text-2">Nur belegen, wenn ein Modell hier echten Mehrwert bringt. Das Radar meldet Kandidaten.</p>
</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).
const bilder = Boolean(modell.capabilities?.vision) || Boolean(zwilling)
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">
<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>
</div>
<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][]
if (zeilen.length === 0) return null
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="row" className="schild grid grid-cols-[minmax(0,1fr)_auto_auto] gap-x-4 pb-1 text-xs text-text-3">
<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="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" className="text-right">Kandidat</span>
<span role="columnheader" className="text-right">Heute</span>
</div>
{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="cell" className={`ziffern text-right ${URTEIL_FARBE[z.urteil ?? ""] ?? "text-text-2"}`}>
{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 hirn = k.rolle === "hirn"
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">
<span className="ziffern text-[15px] font-medium">{k.name}</span>
<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 && (
<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) => (
<p key={t.id} className="text-sm text-text-2">
<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. */
function Klappe({ titel, zusatz, children }: { titel: string; zusatz: string; children: ReactNode }) {
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">
<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 />
@@ -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`}>
<ul className="m-0 flex list-none flex-col p-0">
{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 flex-wrap items-center gap-2">
<span className="ziffern text-sm break-all">{k.name}</span>
@@ -426,7 +426,7 @@ function Altreste() {
</p>
<ul className="m-0 flex list-none flex-col p-0">
{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 flex-wrap items-center gap-2">
<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>}
<ul className="m-0 flex list-none flex-col p-0">
{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="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>
+10 -5
View File
@@ -2,10 +2,12 @@ import { type ReactNode, useEffect, useState } from "react"
import { useRouterState } from "@tanstack/react-router"
import { cn } from "cn"
import { Zustandsfeld } from "@/components/cockpit/Chip"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { Panel } from "@/components/cockpit/Panel"
import { Kennzahl } from "@/components/diagramme/Kennzahl"
import { Verlauf } from "@/components/diagramme/Verlauf"
import { useBoxMesswerte, useHomelabMesswerte, useInstanz, usePartner } from "@/lib/abfragen"
import { geraetebild } from "@/lib/geraetebild"
import { laufzeit, letzterWert, zustandBei } from "@/lib/messung"
import { useLetzteMetrik } from "@/lib/strom"
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">
{ZEITRAEUME.map((z) => (
<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}
</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 }) {
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">
<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}
</div>
{children}
@@ -95,9 +97,12 @@ export function BoxMonitoringSeite() {
function GastKarte({ g, zeitraum }: { g: GeraetMesswerte; zeitraum: Zeitraum }) {
const a = g.aktuell
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">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<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-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>
</span>
<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)}
</span>
+6 -5
View File
@@ -1,3 +1,4 @@
import type { CSSProperties } from "react"
import { Link } from "@tanstack/react-router"
import { Checkliste } from "@/components/cockpit/Checkliste"
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
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
beschriftung="Speicher"
anteil={ramAnteil}
@@ -48,7 +49,7 @@ function Instrumente({ box }: { box: Start["box"] }) {
<Instrument
beschriftung="Platte"
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)}%` : "–"}
unter={platte ? `${gbText(platte.used)} / ${gbText(platte.total)}` : undefined}
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 vorn = offen.find((k) => k.status === "bestanden") ?? offen[0]
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">
<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"}
</span>
{vorn ? (
@@ -109,7 +110,7 @@ export function StartSeite() {
<Panel
titel="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} />
</Panel>
+9 -9
View File
@@ -53,7 +53,7 @@ function Zusammenfassung({ text }: { text: string }) {
type="button"
aria-expanded={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"}
</button>
@@ -69,13 +69,13 @@ function Paketliste({ d }: { d: UpdateDetails }) {
if (pakete.length === 0 && zurueck.length === 0) return null
return (
<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" />
{pakete.length === 0
? `Was Ubuntu zurückhält (${zurueck.length})`
: pakete.length === 1 ? "Das Paket ansehen" : `Alle ${pakete.length} Pakete ansehen`}
</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) => (
<li key={p.name} className="break-all">
<span className="text-foreground">{p.name}</span>{" "}
@@ -139,7 +139,7 @@ function BausteinZeile({ id, name, details, hatUpdate, neuLautStart, fest, gespe
const neuZelle = laedt ? (
<Skelett breit="w-20" />
) : neu ? (
<span className="text-cyan">{neu}</span>
<span className="text-cyan-text">{neu}</span>
) : fehler ? (
<span className="text-bernstein">nicht geprüft</span>
) : (
@@ -176,7 +176,7 @@ function BausteinZeile({ id, name, details, hatUpdate, neuLautStart, fest, gespe
<div
role="row"
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="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. */
function LaufEintrag({ lauf }: { lauf: UpdateLauf }) {
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. */}
<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>
@@ -297,7 +297,7 @@ export function UpdatesSeite() {
<div role="table" aria-label="Bausteine und ihre Updates" className="flex flex-col">
<div
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">Läuft</span>
@@ -352,7 +352,7 @@ export function UpdatesSeite() {
sicherungsInhalt = (
<ul className="m-0 flex list-none flex-col p-0">
{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="flex items-center gap-3">
<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.
</p>
{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.
</p>
)}