perf(frontend): v3-Umbau P0 — Ballast raus, Startbuendel halbiert

Erste Etappe des v3-Umbaus. Bewusst ohne jede Architektur-Aenderung: nur
Entruempeln, damit der Rest des Umbaus auf einem messbar leichten Stand aufsetzt.

Gemessen (vorher -> nachher):
  dist gesamt      27 375 720 B -> 1 477 852 B   (-94,6 %)
  Dateien in dist         135   ->        35
  Start-Chunk gzip    220 278 B ->   110 571 B   (-49,8 %)
  CSS gzip             31 577 B ->    15 121 B   (-52,1 %)
  Schrift-Dateien         112   ->        11     (WOFF 1: 0)

Vier Eingriffe:

1) avatar.vrm (24,5 MB) entfernt. Lag in public/ und dist/, wurde von KEINER
   Zeile des Repos referenziert — der Renderer Avatar3D.tsx war schon vorher
   verschwunden. War 89,5 % der Nutzlast, die auf die Box ging. Damit fallen
   auch die .gitignore-Sonderregel und der Direkt-Deploy-Schritt weg.
   DISASTER_RECOVERY.md §3·D ehrlich auf "ausgebaut" gesetzt (7 Stellen).

2) Schriften: die 11 @fontsource-Sammelimporte zogen ALLE Subsets (latin-ext,
   griechisch, kyrillisch) und je eine WOFF-1-Fassung mit — 112 Dateien, 1,58 MB,
   davon 902 kB WOFF 1, das kein Browser dieser App je abruft. Jetzt stehen die
   @font-face-Regeln direkt in index.css: nur latin, nur WOFF 2, nur die Schnitte,
   die per grep ueber die font-*-Klassen wirklich belegt sind.
   Nebenbei behoben: JetBrains Mono 600/700 fehlten komplett — die 19 Stellen mit
   `font-mono font-bold/semibold` wurden vom Browser synthetisch fettgerechnet.
   Jetzt echte Schnitte; bei Monospace ist die Laufweite gleich, kein Layout-Versatz.

3) Recharts aus dem Startbuendel. Das Cockpit ist die Startseite und laedt daher
   NICHT lazy; ueber SystemStatusCard/TokenPerformanceCard zog es Recharts samt
   d3 in index-*.js. LiveAreaChart ist jetzt eine Lazy-Huelle (Suspense mit
   hoehengleichem Platzhalter, damit nichts springt), die Recharts-Umsetzung liegt
   in LiveAreaChartImpl.tsx und kommt als eigener Chunk nach (105 kB gzip).

4) index.css: height 100dvh mit 100% als Rueckfall. Auf Mobilbrowsern mit
   einfahrender Adressleiste ist 100 % nicht die sichtbare Hoehe.

Dazu ein Buendel-Budget in beiden Ampel-Dateien (.gitea/ = MC2-Fassung,
deploy/ = universelle Vorlage): Start-Chunk und dist-Gesamtgroesse werden am
FRISCHEN Build im Runner gemessen. Bewusst kein Vergleich mit dem committeten
dist — der waere ueber Node-Versionen hinweg flatterhaft und wuerde dauerhaft
rot leuchten, was das Signal zerstoert.

Verifiziert gegen die Box (Frontend-Dev mit MC_API_TARGET=192.168.178.151:9001):
Cockpit rendert mit Live-Daten, keine Konsolenfehler, alle 11 Schriftschnitte
registriert, LiveAreaChartImpl + recharts laden nachweislich als Nachlade-Chunk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 08:27:49 +02:00
co-authored by Claude Opus 5
parent 3d1881f4bc
commit 8aa22e6591
138 changed files with 1099 additions and 993 deletions
@@ -1,109 +1,33 @@
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
import { Suspense, lazy } from "react"
import type { LiveAreaChartProps } from "./chartTypes"
export type ChartSeries = { key: string; label: string; color: string }
export type { ChartSeries } from "./chartTypes"
const GRID = "rgba(130,130,150,0.14)"
const AXIS = "rgba(130,130,150,0.85)"
// Lazy-Hülle um die Recharts-Umsetzung (v3-Umbau P0).
//
// WARUM: Das Cockpit ist die Startseite und wird deshalb NICHT lazy geladen. Über
// SystemStatusCard / TokenPerformanceCard zog es Recharts samt d3-Abhängigkeiten in das
// Start-Bündel — gemessen ~95 kB gzip von 220 kB, für vier Flächendiagramme, die man erst
// sieht, wenn die Seite längst steht. Jetzt kommt Recharts als eigener Chunk nach.
//
// Der Platzhalter hat exakt die Höhe des Diagramms (Prop `height`), damit beim Nachladen
// nichts springt — die Karte ist von der ersten Zeichnung an so hoch wie am Ende.
const Impl = lazy(() => import("./LiveAreaChartImpl"))
function niceCeil(v: number): number {
if (v <= 0) return 10
const mag = Math.pow(10, Math.floor(Math.log10(v)))
const n = v / mag
const step = n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10
return step * mag
}
function fmt(v: number, unit: string): string {
const n = unit === "%" ? Math.round(v) : v >= 1000 ? `${(v / 1000).toFixed(1)}k` : Math.round(v).toString()
return `${n}${unit}`
}
function ChartTooltip({ active, payload, unit, label }: any) {
if (!active || !payload?.length) return null
export function LiveAreaChart(props: LiveAreaChartProps) {
return (
<div className="rounded-lg border border-border/70 bg-popover/95 px-3 py-2 shadow-xl backdrop-blur">
{Number.isFinite(label) && (
<div className="mb-1 border-b border-border/40 pb-1 font-mono text-[10px] text-muted-foreground/80">
{new Date(label).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
</div>
)}
<div className="space-y-1">
{payload.map((p: any) => (
<div key={p.dataKey} className="flex items-center gap-2 text-[11px] font-mono">
<span className="h-2 w-2 rounded-full" style={{ background: p.color }} />
<span className="uppercase tracking-wider text-muted-foreground">{p.name}</span>
<span className="ml-auto pl-3 font-bold tabular-nums text-foreground">{fmt(p.value, unit)}</span>
</div>
))}
</div>
</div>
)
}
/** Wiederverwendbarer Live-Verlaufsgraph (Recharts Area, glatte Splines, Gradient-Fill,
* Hover-Tooltip). yMode='percent' → 0..100 in 25er-Schritten; 'auto' → dynamisch (nice).
* showTime=true blendet die Zeitachse ein (t in ms; Label-Format folgt der Spannweite). */
export function LiveAreaChart({
data, series, unit = "%", yMode = "percent", height = 176, showTime = false,
}: {
data: any[]
series: ChartSeries[]
unit?: string
yMode?: "percent" | "auto"
height?: number
showTime?: boolean
}) {
const peak = data.reduce(
(m, row) => series.reduce((mm, s) => Math.max(mm, Number(row[s.key]) || 0), m), 0
)
const yMax = yMode === "percent"
? Math.min(100, Math.max(25, Math.ceil((peak * 1.2) / 25) * 25))
: Math.max(niceCeil(peak * 1.15), 10)
// Kurze Fenster (Live ≈ 2 min) brauchen Sekunden, lange (1 h / 24 h) nur HH:MM.
const spanMs = data.length > 1 ? Number(data[data.length - 1]?.t) - Number(data[0]?.t) : 0
const fmtTime = (v: number) =>
new Date(v).toLocaleTimeString("de-DE",
spanMs <= 10 * 60_000 ? { hour: "2-digit", minute: "2-digit", second: "2-digit" }
: { hour: "2-digit", minute: "2-digit" })
return (
<div style={{ height }} className="w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 6, bottom: 0, left: -12 }}>
<defs>
{series.map((s) => (
<linearGradient key={s.key} id={`grad-${s.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={s.color} stopOpacity={0.22} />
<stop offset="100%" stopColor={s.color} stopOpacity={0} />
</linearGradient>
))}
</defs>
<CartesianGrid vertical={false} stroke={GRID} />
{showTime ? (
<XAxis
dataKey="t" type="number" domain={["dataMin", "dataMax"]}
tickFormatter={fmtTime} tickCount={5} minTickGap={40} interval="preserveStartEnd"
axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: AXIS }} height={18}
/>
) : (
<XAxis dataKey="t" hide />
)}
<YAxis
domain={[0, yMax]} ticks={[0, yMax / 2, yMax]} tickFormatter={(v) => fmt(v, unit)}
width={42} axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: AXIS }}
/>
<Tooltip content={<ChartTooltip unit={unit} />} cursor={{ stroke: AXIS, strokeOpacity: 0.4, strokeDasharray: "3 3" }} />
{series.map((s) => (
<Area
key={s.key} type="monotone" dataKey={s.key} name={s.label}
stroke={s.color} strokeWidth={2} fill={`url(#grad-${s.key})`}
dot={false} activeDot={{ r: 3, strokeWidth: 0 }}
isAnimationActive={false} connectNulls
/>
))}
</AreaChart>
</ResponsiveContainer>
<Suspense fallback={<ChartPlatzhalter height={props.height ?? 176} />}>
<Impl {...props} />
</Suspense>
)
}
// Ruhiges Skelett statt Spinner: im LAN ist der Chunk in Millisekunden da, ein blinkender
// Lade-Kringel wäre nur Unruhe. Die Linie deutet die Grundachse des Diagramms an.
function ChartPlatzhalter({ height }: { height: number }) {
return (
<div style={{ height }} className="flex w-full items-end" aria-hidden="true">
<div className="h-px w-full bg-border/40" />
</div>
)
}
@@ -0,0 +1,105 @@
import { Area, AreaChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
import type { LiveAreaChartProps } from "./chartTypes"
// Recharts-Umsetzung des Verlaufsgraphen. Diese Datei ist der EINZIGE Ort im Projekt, der
// Recharts importiert — sie wird ausschließlich per lazy() aus LiveAreaChart.tsx geladen und
// landet dadurch in einem eigenen Chunk statt im Start-Bündel (v3-Umbau P0).
const GRID = "rgba(130,130,150,0.14)"
const AXIS = "rgba(130,130,150,0.85)"
function niceCeil(v: number): number {
if (v <= 0) return 10
const mag = Math.pow(10, Math.floor(Math.log10(v)))
const n = v / mag
const step = n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10
return step * mag
}
function fmt(v: number, unit: string): string {
const n = unit === "%" ? Math.round(v) : v >= 1000 ? `${(v / 1000).toFixed(1)}k` : Math.round(v).toString()
return `${n}${unit}`
}
function ChartTooltip({ active, payload, unit, label }: any) {
if (!active || !payload?.length) return null
return (
<div className="rounded-lg border border-border/70 bg-popover/95 px-3 py-2 shadow-xl backdrop-blur">
{Number.isFinite(label) && (
<div className="mb-1 border-b border-border/40 pb-1 font-mono text-[10px] text-muted-foreground/80">
{new Date(label).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
</div>
)}
<div className="space-y-1">
{payload.map((p: any) => (
<div key={p.dataKey} className="flex items-center gap-2 text-[11px] font-mono">
<span className="h-2 w-2 rounded-full" style={{ background: p.color }} />
<span className="uppercase tracking-wider text-muted-foreground">{p.name}</span>
<span className="ml-auto pl-3 font-bold tabular-nums text-foreground">{fmt(p.value, unit)}</span>
</div>
))}
</div>
</div>
)
}
/** Wiederverwendbarer Live-Verlaufsgraph (Recharts Area, glatte Splines, Gradient-Fill,
* Hover-Tooltip). yMode='percent' → 0..100 in 25er-Schritten; 'auto' → dynamisch (nice).
* showTime=true blendet die Zeitachse ein (t in ms; Label-Format folgt der Spannweite). */
export default function LiveAreaChartImpl({
data, series, unit = "%", yMode = "percent", height = 176, showTime = false,
}: LiveAreaChartProps) {
const peak = data.reduce(
(m, row) => series.reduce((mm, s) => Math.max(mm, Number(row[s.key]) || 0), m), 0
)
const yMax = yMode === "percent"
? Math.min(100, Math.max(25, Math.ceil((peak * 1.2) / 25) * 25))
: Math.max(niceCeil(peak * 1.15), 10)
// Kurze Fenster (Live ≈ 2 min) brauchen Sekunden, lange (1 h / 24 h) nur HH:MM.
const spanMs = data.length > 1 ? Number(data[data.length - 1]?.t) - Number(data[0]?.t) : 0
const fmtTime = (v: number) =>
new Date(v).toLocaleTimeString("de-DE",
spanMs <= 10 * 60_000 ? { hour: "2-digit", minute: "2-digit", second: "2-digit" }
: { hour: "2-digit", minute: "2-digit" })
return (
<div style={{ height }} className="w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 6, bottom: 0, left: -12 }}>
<defs>
{series.map((s) => (
<linearGradient key={s.key} id={`grad-${s.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={s.color} stopOpacity={0.22} />
<stop offset="100%" stopColor={s.color} stopOpacity={0} />
</linearGradient>
))}
</defs>
<CartesianGrid vertical={false} stroke={GRID} />
{showTime ? (
<XAxis
dataKey="t" type="number" domain={["dataMin", "dataMax"]}
tickFormatter={fmtTime} tickCount={5} minTickGap={40} interval="preserveStartEnd"
axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: AXIS }} height={18}
/>
) : (
<XAxis dataKey="t" hide />
)}
<YAxis
domain={[0, yMax]} ticks={[0, yMax / 2, yMax]} tickFormatter={(v) => fmt(v, unit)}
width={42} axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: AXIS }}
/>
<Tooltip content={<ChartTooltip unit={unit} />} cursor={{ stroke: AXIS, strokeOpacity: 0.4, strokeDasharray: "3 3" }} />
{series.map((s) => (
<Area
key={s.key} type="monotone" dataKey={s.key} name={s.label}
stroke={s.color} strokeWidth={2} fill={`url(#grad-${s.key})`}
dot={false} activeDot={{ r: 3, strokeWidth: 0 }}
isAnimationActive={false} connectNulls
/>
))}
</AreaChart>
</ResponsiveContainer>
</div>
)
}
@@ -0,0 +1,15 @@
// Typen der Verlaufs-Diagramme. Bewusst eine eigene, winzige Datei: sie wird sowohl vom
// Lazy-Wrapper (LiveAreaChart.tsx) als auch von der Recharts-Umsetzung (LiveAreaChartImpl.tsx)
// gebraucht. Läge sie in einer der beiden, würde ein Typ-Import die Datei aneinander binden —
// und genau das soll die Aufteilung ja verhindern.
export type ChartSeries = { key: string; label: string; color: string }
export interface LiveAreaChartProps {
data: any[]
series: ChartSeries[]
unit?: string
yMode?: "percent" | "auto"
height?: number
showTime?: boolean
}
+42 -12
View File
@@ -1,17 +1,42 @@
/* Fonts lokal gebündelt (@fontsource) — die Box-UI braucht kein Internet für Schrift. */
@import "@fontsource/inter/300.css";
@import "@fontsource/inter/400.css";
@import "@fontsource/inter/500.css";
@import "@fontsource/inter/600.css";
@import "@fontsource/inter/700.css";
@import "@fontsource/space-grotesk/400.css";
@import "@fontsource/space-grotesk/500.css";
@import "@fontsource/space-grotesk/600.css";
@import "@fontsource/space-grotesk/700.css";
@import "@fontsource/jetbrains-mono/400.css";
@import "@fontsource/jetbrains-mono/500.css";
@import "tailwindcss";
/* Fonts lokal gebündelt (@fontsource) — die Box-UI braucht kein Internet für Schrift.
Bewusst KEINE Sammel-Importe (`@fontsource/inter/400.css`): die ziehen alle Subsets
(latin-ext, griechisch, kyrillisch) UND je eine WOFF-1-Fassung mit — 112 Dateien,
1,58 MB, davon 57 % WOFF 1, das kein Browser dieser App je abruft. Hier stehen
deshalb die @font-face-Regeln selbst: nur `latin`, nur WOFF 2, nur die Schnitte,
die im Code wirklich vorkommen (geprüft per grep über die font-*-Klassen).
Neuen Schnitt gebraucht? Zeile ergänzen — nicht auf den Sammel-Import zurückfallen. */
@font-face { font-family: "Inter"; font-style: normal; font-display: swap; font-weight: 400;
src: url("@fontsource/inter/files/inter-latin-400-normal.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-display: swap; font-weight: 500;
src: url("@fontsource/inter/files/inter-latin-500-normal.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-display: swap; font-weight: 600;
src: url("@fontsource/inter/files/inter-latin-600-normal.woff2") format("woff2"); }
@font-face { font-family: "Inter"; font-style: normal; font-display: swap; font-weight: 700;
src: url("@fontsource/inter/files/inter-latin-700-normal.woff2") format("woff2"); }
/* Space Grotesk trägt nur Überschriften (font-space): 400 als Grundschnitt, 600/700 benutzt. */
@font-face { font-family: "Space Grotesk"; font-style: normal; font-display: swap; font-weight: 400;
src: url("@fontsource/space-grotesk/files/space-grotesk-latin-400-normal.woff2") format("woff2"); }
@font-face { font-family: "Space Grotesk"; font-style: normal; font-display: swap; font-weight: 600;
src: url("@fontsource/space-grotesk/files/space-grotesk-latin-600-normal.woff2") format("woff2"); }
@font-face { font-family: "Space Grotesk"; font-style: normal; font-display: swap; font-weight: 700;
src: url("@fontsource/space-grotesk/files/space-grotesk-latin-700-normal.woff2") format("woff2"); }
/* JetBrains Mono: 600 und 700 gab es bisher NICHT — die 19 `font-mono font-bold/semibold`
Stellen wurden vom Browser synthetisch fettgerechnet (verschmiert). Bei Monospace ist die
Laufweite über alle Schnitte gleich, das Nachrüsten verschiebt also kein Layout. */
@font-face { font-family: "JetBrains Mono"; font-style: normal; font-display: swap; font-weight: 400;
src: url("@fontsource/jetbrains-mono/files/jetbrains-mono-latin-400-normal.woff2") format("woff2"); }
@font-face { font-family: "JetBrains Mono"; font-style: normal; font-display: swap; font-weight: 500;
src: url("@fontsource/jetbrains-mono/files/jetbrains-mono-latin-500-normal.woff2") format("woff2"); }
@font-face { font-family: "JetBrains Mono"; font-style: normal; font-display: swap; font-weight: 600;
src: url("@fontsource/jetbrains-mono/files/jetbrains-mono-latin-600-normal.woff2") format("woff2"); }
@font-face { font-family: "JetBrains Mono"; font-style: normal; font-display: swap; font-weight: 700;
src: url("@fontsource/jetbrains-mono/files/jetbrains-mono-latin-700-normal.woff2") format("woff2"); }
/* Design-System: EINE Akzentfarbe Teal, bewusst dark-only (Glassmorphismus-Look).
Karten-Optik lebt in .mc-card / .mc-card-sm (unten) — Komponenten sagen es explizit,
statt dass CSS Utility-Klassen-Kombinationen errät. */
@@ -60,10 +85,15 @@
--font-space: "Space Grotesk", sans-serif;
}
/* Feste Viewport-Höhe: die App scrollt intern (main), nicht als Seite.
100dvh statt 100% — auf Mobilbrowsern mit einfahrender Adressleiste ist 100 % nicht
die sichtbare Höhe, der untere Rand (Statuszeile) verschwand darunter.
Die 100%-Zeile bleibt als Rückfall für Browser ohne dvh-Einheit stehen. */
html,
body,
#root {
height: 100%;
height: 100dvh;
overflow: hidden;
}