Merge pull request #3189 from bluewave-labs/feat/pagespeed-details

feat: v2 pagespeed details
This commit is contained in:
Alexander Holliday
2026-01-26 14:34:56 -08:00
committed by GitHub
13 changed files with 588 additions and 47 deletions
+2 -2
View File
@@ -26,7 +26,7 @@
"joi": "17.13.3",
"lucide-react": "^0.562.0",
"mui-color-input": "^6.0.0",
"pretty-ms": "9.3.0",
"pretty-ms": "^9.3.0",
"react": "18.3.1",
"react-dom": "^18.2.0",
"react-hook-form": "7.63.0",
@@ -38,7 +38,7 @@
"react-toastify": "^10.0.5",
"recharts": "2.15.2",
"redux-persist": "6.0.0",
"swr": "2.3.6",
"swr": "^2.3.6",
"vite-plugin-svgr": "^4.2.0",
"zod": "4.1.11"
},
+1 -1
View File
@@ -31,7 +31,7 @@
"joi": "17.13.3",
"lucide-react": "^0.562.0",
"mui-color-input": "^6.0.0",
"pretty-ms": "9.3.0",
"pretty-ms": "^9.3.0",
"react": "18.3.1",
"react-dom": "^18.2.0",
"react-hook-form": "7.63.0",
@@ -34,7 +34,6 @@ export const MonitorStatus = ({ monitor }: { monitor: Monitor }) => {
>
<PulseDot color={getStatusColor(monitor.status, theme)} />
<Typography
color={theme.palette.text.secondary}
fontSize={typographyLevels.l}
fontWeight={"bolder"}
fontFamily={"monospace"}
@@ -0,0 +1,144 @@
import { BaseChart } from "@/Components/v2/design-elements";
import { TrendingUp } from "lucide-react";
import { XTick } from "@/Components/v2/monitors/";
import {
XAxis,
AreaChart,
Area,
Tooltip,
CartesianGrid,
ResponsiveContainer,
} from "recharts";
import { HistogramPageSpeedScoresTooltip } from "@/Components/v2/monitors";
import { useTheme } from "@mui/material/styles";
import type { Check } from "@/Types/Check";
import type { Palette } from "@mui/material/styles";
type PaletteColorKey = Extract<
keyof Palette,
"primary" | "error" | "success" | "warning"
>;
export interface ConfigItem {
id: string;
text: string;
palette: PaletteColorKey;
}
const config: Record<string, ConfigItem> = {
seo: {
id: "seo",
text: "SEO",
palette: "primary",
},
performance: {
id: "performance",
text: "performance",
palette: "success",
},
bestPractices: {
id: "bestPractices",
text: "best practices",
palette: "warning",
},
accessibility: {
id: "accessibility",
text: "accessibility",
palette: "error",
},
};
export const HistogramPageSpeedDetails = ({
checks,
range,
}: {
checks: Check[];
range: string;
}) => {
const theme = useTheme();
return (
<BaseChart
icon={
<TrendingUp
size={20}
strokeWidth={1.5}
/>
}
title="Score history"
>
<ResponsiveContainer
width="100%"
minWidth={25}
height={215}
>
<AreaChart data={checks}>
<XAxis
dataKey={"createdAt"}
tick={(props) => (
<XTick
{...props}
range={range}
/>
)}
/>
<CartesianGrid
stroke={theme.palette.divider}
strokeWidth={1}
strokeOpacity={1}
fill="transparent"
vertical={false}
/>
<Tooltip
cursor={{ stroke: theme.palette.divider }}
content={<HistogramPageSpeedScoresTooltip config={config} />}
/>
<defs>
{Object.values(config).map(({ id, palette }) => {
const startColor = theme.palette[palette].main;
const endColor = theme.palette[palette].light;
return (
<linearGradient
id={id}
x1="0"
y1="0"
x2="0"
y2="1"
key={id}
>
<stop
offset="0%"
stopColor={startColor}
stopOpacity={0.8}
/>
<stop
offset="100%"
stopColor={endColor}
stopOpacity={0}
/>
</linearGradient>
);
})}
</defs>
{Object.keys(config).map((key) => {
const { palette } = config[key];
const strokeColor = theme.palette[palette].main;
const bgColor = theme.palette.primary.main;
return (
<Area
connectNulls
key={key}
dataKey={key}
stackId={1}
stroke={strokeColor}
fill={`url(#${config[key].id})`}
activeDot={{ stroke: bgColor, fill: strokeColor, r: 4.5 }}
/>
);
})}
</AreaChart>
</ResponsiveContainer>
</BaseChart>
);
};
@@ -0,0 +1,78 @@
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
import { formatDateWithTz } from "@/Utils/TimeUtils";
import type { ConfigItem } from "@/Components/v2/monitors";
import type { TooltipProps } from "recharts";
import { useSelector } from "react-redux";
import type { RootState } from "@/Types/state";
interface HistogramPageSpeedScoresTooltipProps
extends Partial<TooltipProps<number, string>> {
config: Record<string, ConfigItem>;
}
export const HistogramPageSpeedScoresTooltip = ({
active,
payload,
label,
config,
}: HistogramPageSpeedScoresTooltipProps) => {
const theme = useTheme();
const uiTimezone = useSelector((state: RootState) => state.ui.timezone);
if (active && payload && payload.length) {
return (
<Box
sx={{
backgroundColor: theme.palette.background.paper,
border: 1,
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
py: theme.spacing(2),
px: theme.spacing(4),
}}
>
<Typography
sx={{
color: theme.palette.text.secondary,
fontSize: 12,
fontWeight: 500,
}}
>
{formatDateWithTz(label, "ddd, MMMM D, YYYY, h:mm A", uiTimezone)}
</Typography>
{Object.keys(config)
.reverse()
.map((key) => {
const { palette } = config[key];
const dotColor = theme.palette[palette].main;
return (
<Stack
key={`${key}-tooltip`}
direction="row"
alignItems="center"
gap={theme.spacing(3)}
mt={theme.spacing(1)}
>
<Box
width={theme.spacing(4)}
height={theme.spacing(4)}
sx={{ borderRadius: "50%", backgroundColor: dotColor }}
/>
<Typography
textTransform="capitalize"
sx={{ opacity: 0.8 }}
>
{config[key].text}
</Typography>
<Typography>{Math.floor(payload[0].payload[key])}</Typography>
</Stack>
);
})}
</Box>
);
}
return null;
};
@@ -0,0 +1,161 @@
import { BaseChart } from "@/Components/v2/design-elements";
import { FileText } from "lucide-react";
import type { Check, CheckAudits } from "@/Types/Check";
import { Pie, PieChart, ResponsiveContainer, Label } from "recharts";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
import { getPageSpeedPalette } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
import { alpha } from "@mui/material/styles";
import { useState } from "react";
import Tooltip from "@mui/material/Tooltip";
import { useTranslation } from "react-i18next";
const CenterLabel = ({ viewBox, value }: any) => {
const { cx, cy } = viewBox;
return (
<foreignObject
x={cx - 25}
y={cy - 10}
width={50}
height={50}
>
<Typography
variant="h1"
align="center"
>
{value}
</Typography>
</foreignObject>
);
};
export const PiePageSpeed = ({ latestCheck }: { latestCheck?: Check }) => {
const { t } = useTranslation();
const theme = useTheme();
const [hoverTitle, setHoverTitle] = useState<string | null>(null);
if (!latestCheck) return null;
const LABELS: Record<string, string> = {
FCP: t("pages.pageSpeed.charts.common.fcp"),
SI: t("pages.pageSpeed.charts.common.si"),
LCP: t("pages.pageSpeed.charts.common.lcp"),
TBT: t("pages.pageSpeed.charts.common.tbt"),
CLS: t("pages.pageSpeed.charts.common.cls"),
};
const metrics: { key: keyof CheckAudits; color: string; weight: number }[] = [
{ key: "fcp", color: alpha("#1DE9B6", 0.6), weight: 0.1 },
{ key: "si", color: alpha("#7C4DFF", 0.6), weight: 0.1 },
{ key: "lcp", color: alpha("#FFB200", 0.6), weight: 0.25 },
{ key: "tbt", color: alpha("#00AFFE", 0.6), weight: 0.3 },
{ key: "cls", color: alpha("#FF4181", 0.6), weight: 0.25 },
];
const scores = metrics.flatMap(({ key, color, weight }) => {
const audit = latestCheck?.audits?.[key];
const val = Math.floor((audit?.score ?? 0) * 100);
const inverse = 100 - val;
return [
{
name: `${key.toUpperCase()} Inverse`,
value: inverse * weight,
fill: "transparent",
stroke: color,
weight,
},
{
name: key.toUpperCase(),
value: val * weight,
fill: color,
stroke: color,
weight,
},
];
});
const totalScore =
(latestCheck.audits?.fcp?.score || 0) * 0.1 +
(latestCheck.audits?.si?.score || 0) * 0.1 +
(latestCheck.audits?.lcp?.score || 0) * 0.25 +
(latestCheck.audits?.tbt?.score || 0) * 0.3 +
(latestCheck.audits?.cls?.score || 0) * 0.25;
const pageSpeedPalette = getPageSpeedPalette(Math.floor(totalScore * 100));
const score = [
{
value: 100,
fill: alpha(theme.palette[pageSpeedPalette].light || "#ffffff", 0.6),
stroke: "none",
},
];
return (
<BaseChart
icon={
<FileText
size={20}
strokeWidth={1.5}
/>
}
title={t("pages.pageSpeed.charts.pie.title")}
>
<Tooltip
open={Boolean(hoverTitle)}
title={hoverTitle || ""}
arrow
followCursor
placement="top"
>
<Box
sx={{
"& .recharts-wrapper:focus, & .recharts-surface:focus": {
outline: "none",
},
"& .recharts-wrapper *:focus": { outline: "none" },
"& svg:focus, & g:focus, & path:focus": { outline: "none" },
position: "relative",
}}
>
<ResponsiveContainer
width="100%"
height={250}
>
<PieChart>
<Pie
data={score}
dataKey="value"
cx="50%"
cy="50%"
outerRadius="65%"
>
<Label
position={"center"}
content={<CenterLabel value={Math.floor(totalScore * 100)} />}
/>
</Pie>
<Pie
data={scores}
innerRadius="70%"
outerRadius="80%"
label={({ name, value }) => `${name}: ${Math.round(value)}`}
dataKey="value"
stroke="none"
onMouseEnter={(_, index: number) => {
const d = scores[index];
if (!d) return;
const isInverse = String(d.name).includes("Inverse");
const pair = isInverse && scores[index + 1] ? scores[index + 1] : d;
const base = String(pair.name).replace(" Inverse", "");
const full = LABELS[base] ?? base;
const displayVal = Math.round(Number(pair.value) || 0);
setHoverTitle(`${full}: ${displayVal}`);
}}
onMouseLeave={() => setHoverTitle(null)}
/>
</PieChart>
</ResponsiveContainer>
</Box>
</Tooltip>
</BaseChart>
);
};
@@ -0,0 +1,105 @@
import Stack from "@mui/material/Stack";
import Box from "@mui/material/Box";
import { BarChart3 } from "lucide-react";
import { BaseChart } from "@/Components/v2/design-elements";
import Typography from "@mui/material/Typography";
import type { Check } from "@/Types/Check";
import { useTranslation } from "react-i18next";
import { getPageSpeedPalette } from "@/Utils/MonitorUtils";
import { useTheme } from "@mui/material/styles";
const MetricBox = ({
label,
value,
weight,
}: {
label: string;
value: number;
weight: number;
}) => {
const { t } = useTranslation();
const theme = useTheme();
const palette = getPageSpeedPalette(value);
return (
<Stack
direction={"row"}
sx={{
border: 1,
borderStyle: "solid",
borderColor: theme.palette.divider,
borderRadius: theme.shape.borderRadius,
}}
>
<Stack
flex={1}
p={theme.spacing(4)}
>
<Typography textTransform={"uppercase"}>{label}</Typography>
<Stack
direction="row"
justifyContent={"space-between"}
>
<Typography>{`${value}%`}</Typography>
<Typography>{`${t("pages.pageSpeed.charts.legend.weight")}: ${weight}%`}</Typography>
</Stack>
</Stack>
<Box
width={4}
bgcolor={theme.palette[palette].light}
sx={{
borderTopRightRadius: theme.shape.borderRadius,
borderBottomRightRadius: theme.shape.borderRadius,
}}
/>
</Stack>
);
};
export const PiePageSpeedLegend = ({ latestCheck }: { latestCheck?: Check }) => {
const theme = useTheme();
const { t } = useTranslation();
if (!latestCheck) {
return null;
}
return (
<BaseChart
icon={
<BarChart3
size={20}
strokeWidth={1.5}
/>
}
title={t("pages.pageSpeed.charts.legend.title")}
>
<Stack gap={theme.spacing(4)}>
<MetricBox
label={t("pages.pageSpeed.charts.common.si")}
value={Math.floor((latestCheck.audits?.si?.score || 0) * 100)}
weight={10}
/>
<MetricBox
label={t("pages.pageSpeed.charts.common.fcp")}
value={Math.floor((latestCheck.audits?.fcp?.score || 0) * 100)}
weight={10}
/>
<MetricBox
label={t("pages.pageSpeed.charts.common.cls")}
value={Math.floor((latestCheck.audits?.cls?.score || 0) * 100)}
weight={25}
/>
<MetricBox
label={t("pages.pageSpeed.charts.common.tbt")}
value={Math.floor((latestCheck.audits?.tbt?.score || 0) * 100)}
weight={30}
/>
<MetricBox
label={t("pages.pageSpeed.charts.common.lcp")}
value={Math.floor((latestCheck.audits?.lcp?.score || 0) * 100)}
weight={25}
/>
</Stack>
</BaseChart>
);
};
@@ -5,3 +5,7 @@ export * from "./charts/RadialAvgResponse";
export * from "./charts/HistogramDetails";
export * from "./charts/HistogramPageSpeed";
export * from "./charts/HistogramPageSpeedTooltip";
export * from "./charts/PiePageSpeed";
export * from "./charts/PiePageSpeedLegend";
export * from "./charts/HistogramPageSpeedDetails";
export * from "./charts/HistogramPageSpeedDetailsTooltip";
@@ -0,0 +1,64 @@
import { BasePage } from "@/Components/v2/design-elements";
import type { PageSpeedDetailsResponse } from "@/Types/Monitor";
import { HeaderMonitorControls } from "@/Components/v2/common";
import Stack from "@mui/material/Stack";
import {
HistogramPageSpeedDetails,
PiePageSpeed,
PiePageSpeedLegend,
MonitorStatBoxes,
} from "@/Components/v2/monitors";
import { useIsAdmin } from "@/Hooks/useIsAdmin";
import { useGet } from "@/Hooks/UseApi";
import { useParams } from "react-router-dom";
import { useTheme } from "@mui/material";
const PageSpeedDetails = () => {
const { monitorId } = useParams();
const isAdmin = useIsAdmin();
const theme = useTheme();
const {
data: monitorData,
isLoading,
error,
refetch,
} = useGet<PageSpeedDetailsResponse>(
monitorId ? `/monitors/pagespeed/details/${monitorId}?dateRange=day` : null,
{},
{ keepPreviousData: true, refreshInterval: 30000 }
);
const monitor = monitorData?.monitor;
const monitorStats = monitorData?.monitorStats || null;
return (
<BasePage
loading={isLoading}
error={error}
>
<HeaderMonitorControls
path="pagespeed"
monitor={monitor}
isAdmin={isAdmin}
refetch={refetch}
/>
<MonitorStatBoxes
monitor={monitor}
monitorStats={monitorStats}
/>
<HistogramPageSpeedDetails
checks={monitor?.checks || []}
range="day"
/>
<Stack
direction={{ xs: "column", md: "row" }}
gap={theme.spacing(10)}
>
<PiePageSpeed latestCheck={monitor?.checks?.[0]} />
<PiePageSpeedLegend latestCheck={monitor?.checks?.[0]} />
</Stack>
</BasePage>
);
};
export default PageSpeedDetails;
+8 -2
View File
@@ -21,7 +21,7 @@ import UptimeCreate from "../Pages/Uptime/Create/index.jsx";
// PageSpeed
import PageSpeed from "../Pages/PageSpeed/Monitors/index";
import PageSpeedDetails from "../Pages/PageSpeed/Details/index.jsx";
import PageSpeedDetails from "../Pages/PageSpeed/Details/";
import PageSpeedCreate from "../Pages/PageSpeed/Create/index.jsx";
// Infrastructure
@@ -133,7 +133,13 @@ const Routes = () => {
/>
<Route
path="pagespeed/:monitorId"
element={<PageSpeedDetails />}
element={
<>
<ThemeProvider theme={v2theme}>
<PageSpeedDetails />
</ThemeProvider>
</>
}
/>
<Route
path="pagespeed/configure/:monitorId"
+5
View File
@@ -76,3 +76,8 @@ export interface MonitorDetailsResponse {
monitorData: MonitorData;
monitorStats: MonitorStats | null;
}
export interface PageSpeedDetailsResponse {
monitor: MonitorWithChecks;
monitorStats: MonitorStats | null;
}
+16 -41
View File
@@ -39,7 +39,7 @@
"status": "Status",
"type": "Type"
},
"empty": "Nothing h"
"empty": "Nothing here"
},
"charts": {
"labels": {
@@ -53,8 +53,6 @@
},
"submit": "Submit",
"title": "Title",
"distributedStatusHeaderText": "Real-time, real-device coverage",
"distributedStatusSubHeaderText": "Powered by millions devices worldwide, view a system performance by global region, country or city",
"settingsDisabled": "Disabled",
"settingsSuccessSaved": "Settings saved successfully",
"settingsFailedToSave": "Failed to save settings",
@@ -90,18 +88,11 @@
"webhookSendSuccess": "Webhook notification sent successfully",
"webhookSendError": "Error sending webhook notification to {platform}",
"webhookUnsupportedPlatform": "Unsupported platform: {platform}",
"distributedRightCategoryTitle": "Monitor",
"distributedStatusServerMonitors": "Server Monitors",
"distributedStatusServerMonitorsDescription": "Monitor status of related servers",
"distributedUptimeCreateSelectURL": "Here you can select the URL of the host, together with the type of monitor.",
"distributedUptimeCreateChecks": "Checks to perform",
"distributedUptimeCreateChecksDescription": "You can always add or remove checks after adding your site.",
"distributedUptimeCreateIncidentNotification": "Incident notifications",
"distributedUptimeCreateIncidentDescription": "When there is an incident, notify users.",
"distributedUptimeCreateAdvancedSettings": "Advanced settings",
"distributedUptimeDetailsNoMonitorHistory": "There is no check history for this monitor yet.",
"distributedUptimeDetailsStatusHeaderUptime": "Uptime:",
"distributedUptimeDetailsStatusHeaderLastUpdate": "Last updated",
"notifications": {
"enableNotifications": "Enable {{platform}} notifications",
"testNotification": "Test notification",
@@ -174,7 +165,6 @@
"failed": "Failed to send test notification"
}
},
"testLocale": "testLocale",
"add": "Add",
"monitors": "monitors",
"pages": {
@@ -238,37 +228,24 @@
"headers": {
"pageSpeedScore": "PageSpeed score"
}
},
"charts": {
"common": {
"cls": "Cumulative Layout Shift (CLS)",
"fcp": "First Contentful Paint (FCP)",
"lcp": "Largest Contentful Paint (LCP)",
"si": "Speed Index (SI)",
"tbt": "Total Blocking Time (TBT)"
},
"pie": { "title": "Performance report" },
"legend": {
"title": "PageSpeed report",
"weight": "Weight"
}
}
}
},
"distributedUptimeStatusCreateStatusPage": "status page",
"distributedUptimeStatusCreateStatusPageAccess": "Access",
"distributedUptimeStatusCreateStatusPageReady": "If your status page is ready, you can mark it as published.",
"distributedUptimeStatusBasicInfoHeader": "Basic Information",
"distributedUptimeStatusBasicInfoDescription": "Define company name and the subdomain that your status page points to.",
"distributedUptimeStatusLogoHeader": "Logo",
"distributedUptimeStatusLogoDescription": "Upload a logo for your status page",
"distributedUptimeStatusLogoUploadButton": "Upload logo",
"distributedUptimeStatusStandardMonitorsHeader": "Standard Monitors",
"distributedUptimeStatusStandardMonitorsDescription": "Attach standard monitors to your status page.",
"distributedUptimeStatusCreateYour": "Create your",
"distributedUptimeStatusEditYour": "Edit your",
"distributedUptimeStatusPublishedLabel": "Published and visible to the public",
"distributedUptimeStatusCompanyNameLabel": "Company name",
"distributedUptimeStatusPageAddressLabel": "Your status page address",
"distributedUptimeStatus30Days": "30 days",
"distributedUptimeStatus60Days": "60 days",
"distributedUptimeStatus90Days": "90 days",
"distributedUptimeStatusPageNotSetUp": "A status page is not set up.",
"distributedUptimeStatusContactAdmin": "Please contact your administrator",
"distributedUptimeStatusPageNotPublic": "This status page is not public.",
"distributedUptimeStatusPageDeleteDialog": "Do you want to delete this status page?",
"distributedUptimeStatusPageDeleteConfirm": "Yes, delete status page",
"distributedUptimeStatusPageDeleteDescription": "Once deleted, your status page cannot be retrieved.",
"distributedUptimeStatusDevices": "Devices",
"distributedUptimeStatusUpt": "UPT",
"distributedUptimeStatusUptBurned": "UPT Burned",
"distributedUptimeStatusUptLogo": "Upt Logo",
"incidentsTableNoIncidents": "No incidents recorded",
"incidentsTablePaginationLabel": "incidents",
"incidentsTableMonitorName": "Monitor Name",
@@ -279,7 +256,6 @@
"incidentsOptionsHeader": "Incidents for:",
"incidentsOptionsHeaderFilterBy": "Filter by:",
"incidentsOptionsHeaderFilterAll": "All",
"incidentsOptionsHeaderFilterActive": "Active",
"incidentsOptionsHeaderFilterDown": "Down",
"incidentsOptionsHeaderFilterCannotResolve": "Cannot Resolve",
"incidentsOptionsHeaderShow": "Show:",
@@ -625,7 +601,6 @@
"game": "Enter the IP address or hostname and the port number to ping (e.g., 192.168.1.100 or example.com) and choose game type.",
"https": "Enter the URL or IP to monitor (e.g., https://example.com/ or 192.168.1.100) and add a clear display name that appears on the dashboard."
},
"auth": {
"common": {
"navigation": {