ui improvements

This commit is contained in:
2026-05-04 22:25:23 +02:00
parent 87149026c7
commit e0e461502b
4 changed files with 472 additions and 110 deletions
+11 -4
View File
@@ -9,18 +9,25 @@ interface Props {
export function MetricCard({ label, value, subtext }: Props) {
return (
<Card variant="outlined" sx={{ height: "100%" }}>
<CardContent sx={{ p: { xs: 1.5, sm: 2 } }}>
<CardContent
sx={{
p: { xs: 1.5, sm: 2 },
display: "flex",
flexDirection: "column",
gap: 0.5,
height: "100%",
}}
>
<Typography
variant="caption"
color="text.secondary"
sx={{ textTransform: "uppercase" }}
sx={{ textTransform: "uppercase", lineHeight: 1.2 }}
>
{label}
</Typography>
<Typography
variant="h5"
sx={{
mt: 0.5,
fontWeight: 700,
fontSize: { xs: "1.05rem", sm: "1.5rem" },
lineHeight: 1.15,
@@ -32,7 +39,7 @@ export function MetricCard({ label, value, subtext }: Props) {
<Typography
variant="caption"
color="text.secondary"
sx={{ whiteSpace: "pre-line", display: "block", mt: 0.25 }}
sx={{ whiteSpace: "pre-line", display: "block", lineHeight: 1.35 }}
>
{subtext}
</Typography>
+303 -86
View File
@@ -1,4 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import * as d3 from "d3";
import {
Box,
@@ -62,6 +70,7 @@ const MOVING_AVG_WINDOW = 10;
const CHART_HEIGHT = 280;
const BRUSH_HEIGHT = 84;
const BRUSH_LABEL_HEIGHT = 24;
const PERSISTED_SELECTION_KEY = "manage.monitoring.brush.selection";
function formatBytes(bytes: number): string {
if (!bytes) return "0 B/s";
@@ -120,21 +129,39 @@ function MonitoringBrush({ data, selectionRange, onBrush }: BrushProps) {
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const [width, setWidth] = useState(0);
const brushGroupRef = useRef<d3.Selection<
SVGGElement,
unknown,
null,
unknown
> | null>(null);
const brushRef = useRef<d3.BrushBehavior<unknown> | null>(null);
const brushXRef = useRef<d3.ScaleTime<number, number> | null>(null);
const isUserBrushingRef = useRef(false);
const isProgrammaticMoveRef = useRef(false);
const dragRef = useRef<{
mode: "new" | "move" | "left" | "right";
startRange: [number, number];
startTs: number;
pointerId: number;
} | null>(null);
const margin = { top: 18, right: 24, bottom: 22, left: 48 };
const innerHeight = BRUSH_HEIGHT - margin.top - margin.bottom;
const totalHeight = BRUSH_LABEL_HEIGHT + BRUSH_HEIGHT;
const brushedColor = "rgba(99, 102, 241, 0.25)";
const minTs = useMemo(() => d3.min(data, (d) => d.ts) ?? 0, [data]);
const maxTs = useMemo(() => d3.max(data, (d) => d.ts) ?? 0, [data]);
const innerWidth = Math.max(0, width - margin.left - margin.right);
const xScale = useMemo(() => {
if (width === 0 || data.length === 0) return null;
return d3
.scaleTime()
.domain([new Date(minTs * 1000), new Date(maxTs * 1000)])
.range([0, innerWidth]);
}, [data.length, width, minTs, maxTs, innerWidth]);
const visibleSelection =
selectionRange ??
(data.length > 0 ? ([minTs, maxTs] as [number, number]) : null);
const selectionPixels = useMemo(() => {
if (!xScale || !visibleSelection) return null;
return [
xScale(new Date(visibleSelection[0] * 1000)),
xScale(new Date(visibleSelection[1] * 1000)),
] as [number, number];
}, [visibleSelection, xScale]);
useEffect(() => {
if (!containerRef.current) return;
const observer = new ResizeObserver((entries) => {
@@ -144,11 +171,11 @@ function MonitoringBrush({ data, selectionRange, onBrush }: BrushProps) {
return () => observer.disconnect();
}, []);
// Build the brush UI when the available data or layout width changes.
useEffect(() => {
// Draw the mini chart and axes with D3, while the interactive brush UI is
// rendered by React so it stays visible across redraws.
useLayoutEffect(() => {
if (!svgRef.current || width === 0 || data.length === 0) return;
const innerWidth = Math.max(0, width - margin.left - margin.right);
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
@@ -156,13 +183,10 @@ function MonitoringBrush({ data, selectionRange, onBrush }: BrushProps) {
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const minTs = d3.min(data, (d) => d.ts) ?? 0;
const maxTs = d3.max(data, (d) => d.ts) ?? 0;
const x = d3
.scaleTime()
.domain([new Date(minTs * 1000), new Date(maxTs * 1000)])
.range([0, innerWidth]);
brushXRef.current = x;
const yMax = Math.max(1, d3.max(data, (d) => Math.max(d.cpu, d.mem)) ?? 1);
const y = d3
@@ -221,75 +245,129 @@ function MonitoringBrush({ data, selectionRange, onBrush }: BrushProps) {
.attr("opacity", 0.6)
.attr("d", line);
});
}, [data, width, margin.left, margin.top, innerHeight, innerWidth, minTs, maxTs]);
const brush = d3
.brushX()
.handleSize(14)
.extent([
[0, 0],
[innerWidth, innerHeight],
])
.on("start", () => {
isUserBrushingRef.current = true;
})
.on("brush", (event: d3.D3BrushEvent<unknown>) => {
if (isProgrammaticMoveRef.current) return;
if (!event.selection) return;
const sel = event.selection as [number, number];
const start = Math.floor(x.invert(sel[0]).getTime() / 1000);
const end = Math.floor(x.invert(sel[1]).getTime() / 1000);
onBrush([start, end]);
})
.on("end", (event: d3.D3BrushEvent<unknown>) => {
isUserBrushingRef.current = false;
if (isProgrammaticMoveRef.current) return;
if (!event.selection) onBrush(null);
});
const clampRange = useCallback(
(range: [number, number]): [number, number] => {
if (!data.length) return range;
let [start, end] = range[0] <= range[1] ? range : [range[1], range[0]];
const domainStart = minTs;
const domainEnd = maxTs;
if (start < domainStart) {
end += domainStart - start;
start = domainStart;
}
if (end > domainEnd) {
start -= end - domainEnd;
end = domainEnd;
}
start = Math.max(domainStart, start);
end = Math.min(domainEnd, end);
if (end < start) end = start;
return [start, end];
},
[data.length, minTs, maxTs],
);
const brushG = root.append("g").call(brush);
brushGroupRef.current = brushG;
brushRef.current = brush;
const clientXToTs = useCallback(
(clientX: number) => {
if (!containerRef.current || !xScale || innerWidth <= 0) return minTs;
const rect = containerRef.current.getBoundingClientRect();
const chartX = Math.max(
0,
Math.min(innerWidth, clientX - rect.left - margin.left),
);
return Math.floor(xScale.invert(chartX).getTime() / 1000);
},
[xScale, innerWidth, margin.left, minTs],
);
brushG
.selectAll("rect.selection")
.attr("fill", brushedColor)
.attr("stroke", "#6366f1")
.attr("stroke-width", 1);
brushG
.selectAll("rect.handle")
.attr("fill", "#6366f1")
.attr("stroke", "#fff")
.attr("rx", 2)
.attr("ry", 2)
.style("cursor", "ew-resize");
}, [data, width, margin.left, margin.top, innerHeight, onBrush]);
const beginDrag = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!data.length || !xScale) return;
const target = event.target as HTMLElement | null;
const part = (target?.dataset.brushPart as
| "background"
| "selection"
| "left"
| "right"
| undefined) ?? "background";
const mode: "new" | "move" | "left" | "right" =
part === "left"
? "left"
: part === "right"
? "right"
: part === "selection"
? "move"
: "new";
const startRange =
visibleSelection ?? ([minTs, maxTs] as [number, number]);
dragRef.current = {
mode,
startRange,
startTs: clientXToTs(event.clientX),
pointerId: event.pointerId,
};
event.currentTarget.setPointerCapture(event.pointerId);
if (mode === "new") {
const ts = clientXToTs(event.clientX);
onBrush(clampRange([ts, ts]));
}
},
[
clientXToTs,
clampRange,
data.length,
maxTs,
minTs,
onBrush,
visibleSelection,
xScale,
],
);
// Keep the brush selection in sync with external changes (zoom buttons / reset)
useEffect(() => {
if (!brushGroupRef.current || !brushRef.current || !brushXRef.current)
return;
if (isUserBrushingRef.current) return;
const updateDrag = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag) return;
const ts = clientXToTs(event.clientX);
let nextRange: [number, number];
if (drag.mode === "move") {
const delta = ts - drag.startTs;
nextRange = [drag.startRange[0] + delta, drag.startRange[1] + delta];
} else if (drag.mode === "left") {
nextRange = [ts, drag.startRange[1]];
} else if (drag.mode === "right") {
nextRange = [drag.startRange[0], ts];
} else {
nextRange = [drag.startRange[0], ts];
}
onBrush(clampRange(nextRange));
},
[clientXToTs, clampRange, onBrush],
);
const x = brushXRef.current;
const brush = brushRef.current;
const brushG = brushGroupRef.current;
const finishDrag = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag) return;
try {
event.currentTarget.releasePointerCapture(drag.pointerId);
} catch {
// ignore release failures
}
dragRef.current = null;
},
[],
);
const selection = selectionRange
? ([x(selectionRange[0]), x(selectionRange[1])] as [number, number])
: (x.range() as unknown as [number, number]);
isProgrammaticMoveRef.current = true;
const moveBrush = brush.move as unknown as (
group: d3.Selection<SVGGElement, unknown, null, unknown>,
selection: d3.BrushSelection,
) => void;
moveBrush(brushG, selection as d3.BrushSelection);
window.setTimeout(() => {
isProgrammaticMoveRef.current = false;
}, 0);
}, [selectionRange, width, margin.left, margin.right]);
const totalHeight = BRUSH_LABEL_HEIGHT + BRUSH_HEIGHT;
if (!data.length) {
return (
<Typography color="text.secondary">
No monitoring samples available.
</Typography>
);
}
return (
<Box sx={{ width: "100%" }}>
@@ -302,28 +380,167 @@ function MonitoringBrush({ data, selectionRange, onBrush }: BrushProps) {
</Typography>
<Box
ref={containerRef}
sx={{ width: "100%", height: totalHeight, px: 2 }}
sx={{ width: "100%", height: totalHeight, px: 2, position: "relative" }}
>
<svg
ref={svgRef}
width={width}
height={totalHeight}
style={{ overflow: "visible" }}
style={{ overflow: "visible", position: "absolute", inset: 0 }}
/>
<Box
sx={{
position: "absolute",
inset: 0,
pointerEvents: "none",
}}
>
<Box
sx={{
position: "absolute",
left: margin.left,
top: margin.top,
width: innerWidth,
height: innerHeight,
pointerEvents: "auto",
touchAction: "none",
userSelect: "none",
}}
onPointerDown={beginDrag}
onPointerMove={updateDrag}
onPointerUp={finishDrag}
onPointerCancel={finishDrag}
>
<Box
data-brush-part="background"
sx={{
position: "absolute",
inset: 0,
cursor: "crosshair",
backgroundColor: "transparent",
}}
/>
{selectionPixels ? (
<>
<Box
data-brush-part="selection"
sx={{
position: "absolute",
left: Math.min(selectionPixels[0], selectionPixels[1]),
top: 0,
width: Math.max(
1,
Math.abs(selectionPixels[1] - selectionPixels[0]),
),
height: "100%",
backgroundColor: brushedColor,
border: "1px solid #6366f1",
boxSizing: "border-box",
cursor: "move",
}}
/>
<Box
data-brush-part="left"
sx={{
position: "absolute",
left: Math.min(selectionPixels[0], selectionPixels[1]) - 8,
top: 0,
width: 16,
height: "100%",
backgroundColor: "transparent",
cursor: "ew-resize",
pointerEvents: "auto",
}}
>
<Box
sx={{
position: "absolute",
top: 6,
bottom: 6,
left: 6,
width: 4,
borderRadius: 999,
backgroundColor: "#6366f1",
boxShadow: 1,
}}
/>
</Box>
<Box
data-brush-part="right"
sx={{
position: "absolute",
left: Math.max(selectionPixels[0], selectionPixels[1]) - 8,
top: 0,
width: 16,
height: "100%",
backgroundColor: "transparent",
cursor: "ew-resize",
pointerEvents: "auto",
}}
>
<Box
sx={{
position: "absolute",
top: 6,
bottom: 6,
right: 6,
width: 4,
borderRadius: 999,
backgroundColor: "#6366f1",
boxShadow: 1,
}}
/>
</Box>
</>
) : null}
</Box>
</Box>
</Box>
</Box>
);
}
// ══════════════════════════════════════════════════════════════════════════
// Parent: MonitoringCharts
// ══════════════════════════════════════════════════════════════════════════
function loadPersistedSelection(): [number, number] | null {
if (typeof window === "undefined") return null;
const raw = window.localStorage.getItem(PERSISTED_SELECTION_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
if (
Array.isArray(parsed) &&
parsed.length === 2 &&
typeof parsed[0] === "number" &&
typeof parsed[1] === "number"
) {
return [parsed[0], parsed[1]];
}
} catch {
return null;
}
return null;
}
export function MonitoringCharts({ samples }: Props) {
const [showAverages, setShowAverages] = useState(false);
const [selectionRange, setSelectionRange] = useState<[number, number] | null>(
null,
);
const [selectionRangeState, setSelectionRangeState] = useState<
[number, number] | null
>(() => loadPersistedSelection());
const selectionRange = selectionRangeState;
const setSelectionRange = useCallback((range: [number, number] | null) => {
setSelectionRangeState(range);
if (typeof window === "undefined") return;
if (range) {
window.localStorage.setItem(
PERSISTED_SELECTION_KEY,
JSON.stringify(range),
);
} else {
window.localStorage.removeItem(PERSISTED_SELECTION_KEY);
}
}, []);
const baseData = useMemo<DataPoint[]>(
() =>