ui improvements
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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[]>(
|
||||
() =>
|
||||
|
||||
Reference in New Issue
Block a user