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:
co-authored by
Claude Opus 5
parent
3d1881f4bc
commit
8aa22e6591
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user