diff --git a/client/src/Components/design-elements/Gauge.tsx b/client/src/Components/design-elements/Gauge.tsx index fda42df3c6..fdc92db74f 100644 --- a/client/src/Components/design-elements/Gauge.tsx +++ b/client/src/Components/design-elements/Gauge.tsx @@ -3,7 +3,7 @@ import Stack from "@mui/material/Stack"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; -import { useTheme } from "@mui/material/styles"; +import { useTheme, type Theme } from "@mui/material/styles"; import { useMemo, useState, useEffect } from "react"; import { getInfraGaugeColor } from "@/Utils/MonitorUtils"; @@ -17,6 +17,10 @@ export const Gauge = ({ strokeWidth = 15, precision = 1, unit = "%", + // colorFn lets callers override the default color logic. + // The Gauge was originally designed for metrics where higher values are worse (e.g., CPU usage). + // PageSpeed scores are the opposite—higher is better—so a custom colorFn can be used to invert the behavior. + colorFn, }: { isLoading?: boolean; progress?: number; @@ -24,6 +28,7 @@ export const Gauge = ({ strokeWidth?: number; precision?: number; unit?: string; + colorFn?: (val: number, theme: Theme) => string; }) => { const theme = useTheme(); const progressWithinRange = Math.max(MINIMUM_VALUE, Math.min(progress, MAXIMUM_VALUE)); @@ -48,7 +53,8 @@ export const Gauge = ({ return () => clearTimeout(timer); }, [progress, circumference, strokeLength]); - const fillColor = getInfraGaugeColor(progressWithinRange, theme); + const resolvedColorFn = colorFn ?? getInfraGaugeColor; + const fillColor = resolvedColorFn(progressWithinRange, theme); if (isLoading) { return; diff --git a/client/src/Hooks/useStatusPageForm.ts b/client/src/Hooks/useStatusPageForm.ts index 0011ea7612..64936dbf35 100644 --- a/client/src/Hooks/useStatusPageForm.ts +++ b/client/src/Hooks/useStatusPageForm.ts @@ -35,6 +35,7 @@ export const useStatusPageForm = ({ showUptimePercentage: data?.showUptimePercentage ?? true, showAdminLoginLink: data?.showAdminLoginLink ?? false, showInfrastructure: data?.showInfrastructure ?? false, + showPageSpeed: data?.showPageSpeed ?? false, customCSS: data?.customCSS || "", logo: transformLogo(data?.logo), }; diff --git a/client/src/Pages/StatusPage/Create/index.tsx b/client/src/Pages/StatusPage/Create/index.tsx index f09d9e47d1..f127fb8521 100644 --- a/client/src/Pages/StatusPage/Create/index.tsx +++ b/client/src/Pages/StatusPage/Create/index.tsx @@ -28,7 +28,7 @@ import { useStatusPageForm } from "@/Hooks/useStatusPageForm"; import type { StatusPageFormData } from "@/Validation/statusPage"; import { useGet, usePost, usePut, useDelete } from "@/Hooks/UseApi"; import type { Monitor } from "@/Types/Monitor"; -import type { MonitorDisplayType, StatusPageResponse } from "@/Types/StatusPage"; +import type { StatusPageType, StatusPageResponse } from "@/Types/StatusPage"; import { getMonitorTypeLabel } from "@/Types/StatusPage"; import timezones from "@/Utils/timezones.json"; import { useNavigate, useParams } from "react-router-dom"; @@ -37,9 +37,17 @@ import { HeaderConfigStatusControls } from "./Components/HeaderConfigStatusContr const monitorsUrl = (() => { const params = new URLSearchParams(); - ["http", "ping", "port", "docker", "game", "grpc", "websocket", "hardware"].forEach( - (type) => params.append("type", type) - ); + [ + "http", + "ping", + "port", + "docker", + "game", + "grpc", + "websocket", + "hardware", + "pagespeed", + ].forEach((type) => params.append("type", type)); return `/monitors/team?${params.toString()}`; })(); @@ -59,7 +67,9 @@ const CreateStatusPage = () => { // Fetch existing status page data when configuring const { data: statusPageData, isLoading: isLoadingStatusPage } = useGet( - isCreate ? null : `/status-page/${url}?type=uptime&type=infrastructure` + isCreate + ? null + : `/status-page/${url}?type=uptime&type=infrastructure&type=pagespeed` ); const { data: monitorsResponse } = useGet(monitorsUrl); @@ -91,14 +101,16 @@ const CreateStatusPage = () => { }, [defaults, reset]); const watchedMonitorIds: string[] = form.watch("monitors") ?? []; - const computedTypes: MonitorDisplayType[] = useMemo(() => { + const computedTypes: StatusPageType[] = useMemo(() => { const selectedMonitors = (watchedMonitorIds ?? []) .map((id) => monitors.find((m) => m.id === id)) .filter((m): m is Monitor => m !== undefined); - const typesSet = new Set(); + const typesSet = new Set(); selectedMonitors.forEach((m) => { - typesSet.add(m.type === "hardware" ? "infrastructure" : "uptime"); + if (m.type === "hardware") typesSet.add("infrastructure"); + else if (m.type === "pagespeed") typesSet.add("pagespeed"); + else typesSet.add("uptime"); }); return typesSet.size ? Array.from(typesSet) : ["uptime"]; @@ -139,6 +151,7 @@ const CreateStatusPage = () => { fd.append("showUptimePercentage", String(data.showUptimePercentage)); fd.append("showAdminLoginLink", String(data.showAdminLoginLink)); fd.append("showInfrastructure", String(data.showInfrastructure)); + fd.append("showPageSpeed", String(data.showPageSpeed)); data.monitors.forEach((monitorId) => { fd.append("monitors[]", monitorId); @@ -475,6 +488,21 @@ const CreateStatusPage = () => { /> )} /> + ( + + } + label={t("pages.statusPages.form.features.option.showPageSpeed.label")} + /> + )} + /> {/* { - const bg = - monitorType === "hardware" ? theme.palette.info.light : theme.palette.success.light; + const monitorColors: Record = { + hardware: theme.palette.info.light, + pagespeed: theme.palette.warning.light, + }; + + const bg = monitorColors[monitorType] || theme.palette.success.light; return { backgroundColor: bg, color: theme.palette.background.paper, @@ -105,6 +110,12 @@ const MonitorContent = ({ return ; } + if (monitor.type === "pagespeed") { + return statusPage.showPageSpeed === false ? null : ( + + ); + } + if (statusPage.showCharts === false) return null; const checks = monitor.checks?.slice().reverse() ?? []; diff --git a/client/src/Pages/StatusPage/Status/Components/PageSpeedMetrics.tsx b/client/src/Pages/StatusPage/Status/Components/PageSpeedMetrics.tsx new file mode 100644 index 0000000000..8bbe341e7e --- /dev/null +++ b/client/src/Pages/StatusPage/Status/Components/PageSpeedMetrics.tsx @@ -0,0 +1,122 @@ +import Typography from "@mui/material/Typography"; +import { Gauge } from "@/Components/design-elements"; +import { useTheme } from "@mui/material/styles"; +import { useTranslation } from "react-i18next"; +import type { Monitor } from "@/Types/Monitor"; +import type { CheckSnapshot } from "@/Types/Check"; +import Grid from "@mui/material/Grid"; +import Box from "@mui/material/Box"; +import { LAYOUT, SPACING } from "@/Utils/Theme/constants"; +import { getPageSpeedGaugeColor } from "@/Utils/MonitorUtils"; + +const GAUGE_RADIUS = 60; +const GAUGE_STROKE_WIDTH = 12; + +interface StatusPageMonitor extends Monitor { + checks?: Monitor["recentChecks"]; +} + +interface MetricConfig { + key: string; + label: string; + progress: number; +} + +const MetricItem = ({ label, progress }: MetricConfig) => { + const theme = useTheme(); + return ( + + + + {label} + + + ); +}; + +const buildMetrics = (check: CheckSnapshot, t: (key: string) => string): MetricConfig[] => + [ + { + key: "performance", + label: t("pages.statusPages.monitorsList.pagespeed.performance"), + progress: check.performance, + }, + { + key: "accessibility", + label: t("pages.statusPages.monitorsList.pagespeed.accessibility"), + progress: check.accessibility, + }, + { + key: "bestPractices", + label: t("pages.statusPages.monitorsList.pagespeed.bestPractices"), + progress: check.bestPractices, + }, + { + key: "seo", + label: t("pages.statusPages.monitorsList.pagespeed.seo"), + progress: check.seo, + }, + ].filter((m): m is MetricConfig => m.progress !== undefined); + +export const PageSpeedMetrics = ({ monitor }: { monitor: StatusPageMonitor }) => { + const theme = useTheme(); + const { t } = useTranslation(); + + const latestCheck = monitor.recentChecks?.[0] ?? monitor.checks?.[0]; + + if (!latestCheck) { + return ( + + {t("pages.statusPages.monitorsList.noData")} + + ); + } + + const metrics = buildMetrics(latestCheck, t); + + return ( + + {metrics.map((metric) => ( + + ))} + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 78b7cba418..36028249d4 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -23,7 +23,9 @@ const StatusPageView = () => { const isSmall = useMediaQuery(theme.breakpoints.down("md")); const isPublic = location.pathname.startsWith("/status/public"); - const apiUrl = url ? `/status-page/${url}?type=uptime&type=infrastructure` : null; + const apiUrl = url + ? `/status-page/${url}?type=uptime&type=infrastructure&type=pagespeed` + : null; const { data, isLoading, error } = useGet( apiUrl, diff --git a/client/src/Types/StatusPage.ts b/client/src/Types/StatusPage.ts index 6fe6412108..cf72aaa147 100644 --- a/client/src/Types/StatusPage.ts +++ b/client/src/Types/StatusPage.ts @@ -1,5 +1,6 @@ import type { Monitor, MonitorType } from "@/Types/Monitor"; -export type MonitorDisplayType = "uptime" | "infrastructure"; +export const StatusPageTypes = ["uptime", "infrastructure", "pagespeed"] as const; +export type StatusPageType = (typeof StatusPageTypes)[number]; export const MONITOR_TYPE_KEYS: Partial> = { http: "pages.common.monitors.monitorTypes.optionHttp", @@ -25,7 +26,7 @@ export interface StatusPage { id: string; userId: string; teamId: string; - type: MonitorDisplayType[]; + type: StatusPageType[]; companyName: string; url: string; timezone?: string; @@ -42,6 +43,7 @@ export interface StatusPage { showUptimePercentage: boolean; showAdminLoginLink: boolean; showInfrastructure: boolean; + showPageSpeed: boolean; customCSS: string; createdAt: string; updatedAt: string; diff --git a/client/src/Utils/MonitorUtils.ts b/client/src/Utils/MonitorUtils.ts index d0ede67e12..934e28bebe 100644 --- a/client/src/Utils/MonitorUtils.ts +++ b/client/src/Utils/MonitorUtils.ts @@ -78,6 +78,12 @@ export const getPageSpeedPalette = (score: number): PaletteKey => { else return "error"; }; +export const getPageSpeedGaugeColor = (val: number, theme: any) => { + if (val >= 90) return theme.palette.success.main; + else if (val >= 50) return theme.palette.warning.light; + else return theme.palette.error.light; +}; + export const formatUrl = (url: string, maxLength: number = 55) => { if (!url) return ""; diff --git a/client/src/Validation/statusPage.ts b/client/src/Validation/statusPage.ts index 43d7198f00..28b6671ebf 100644 --- a/client/src/Validation/statusPage.ts +++ b/client/src/Validation/statusPage.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { StatusPageTypes } from "@/Types/StatusPage"; export const statusPageSchema = z.object({ companyName: z @@ -14,9 +15,7 @@ export const statusPageSchema = z.object({ "URL can only contain lowercase letters, numbers, and hyphens" ), timezone: z.string().optional(), - type: z - .array(z.enum(["uptime", "infrastructure"])) - .min(1, "At least one type is required"), + type: z.array(z.enum(StatusPageTypes)).min(1, "At least one type is required"), color: z.string().min(1, "Color is required"), monitors: z.array(z.string()).min(1, "At least one monitor is required"), isPublished: z.boolean(), @@ -24,6 +23,7 @@ export const statusPageSchema = z.object({ showUptimePercentage: z.boolean(), showAdminLoginLink: z.boolean(), showInfrastructure: z.boolean(), + showPageSpeed: z.boolean(), customCSS: z.string().optional(), logo: z .object({ diff --git a/client/src/Validation/validation.js b/client/src/Validation/validation.js index c11543cad6..11e3d5b1c1 100644 --- a/client/src/Validation/validation.js +++ b/client/src/Validation/validation.js @@ -324,6 +324,7 @@ const statusPageValidation = joi.object({ showCharts: joi.boolean(), showAdminLoginLink: joi.boolean(), showInfrastructure: joi.boolean(), + showPageSpeed: joi.boolean(), }); const settingsValidation = joi.object({ diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 83ca4fa332..a199f27179 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1202,6 +1202,13 @@ "used": "Used", "total": "Total" }, + "pagespeed": { + "title": "PageSpeed", + "performance": "Performance", + "accessibility": "Accessibility", + "bestPractices": "Best Practices", + "seo": "SEO" + }, "uptime": { "title": "Uptime", "responseTime": "Response time" @@ -1300,6 +1307,9 @@ }, "showInfrastructure": { "label": "Show infrastructure metrics" + }, + "showPageSpeed": { + "label": "Show PageSpeed scores" } } } diff --git a/server/src/db/migration/timescaledb/0022_add_show_page_speed_to_status_pages.ts b/server/src/db/migration/timescaledb/0022_add_show_page_speed_to_status_pages.ts new file mode 100644 index 0000000000..e04e7c96ca --- /dev/null +++ b/server/src/db/migration/timescaledb/0022_add_show_page_speed_to_status_pages.ts @@ -0,0 +1,15 @@ +import type { Pool } from "pg"; + +export const addShowPageSpeedToStatusPages = async (pool: Pool) => { + await pool.query(` + ALTER TABLE status_pages + ADD COLUMN IF NOT EXISTS show_page_speed BOOLEAN DEFAULT FALSE; + `); +}; + +export const dropShowPageSpeedFromStatusPages = async (pool: Pool) => { + await pool.query(` + ALTER TABLE status_pages + DROP COLUMN IF EXISTS show_page_speed; + `); +}; diff --git a/server/src/db/migration/timescaledb/index.ts b/server/src/db/migration/timescaledb/index.ts index 14084ebf3d..750addb5b0 100644 --- a/server/src/db/migration/timescaledb/index.ts +++ b/server/src/db/migration/timescaledb/index.ts @@ -21,6 +21,7 @@ import { createStatusPages, dropStatusPages } from "./0018_create_status_pages.j import { createAppSettings, dropAppSettings } from "./0019_create_app_settings.js"; import { createContinuousAggregates, dropContinuousAggregates } from "./0020_create_continuous_aggregates.js"; import { createRetentionCompression, dropRetentionCompression } from "./0021_create_retention_compression.js"; +import { addShowPageSpeedToStatusPages, dropShowPageSpeedFromStatusPages } from "./0022_add_show_page_speed_to_status_pages.js"; const SERVICE_NAME = "TimescaleDB Migrations"; @@ -52,6 +53,7 @@ const migrations: MigrationEntry[] = [ { name: "0019_create_app_settings", up: createAppSettings, down: dropAppSettings }, { name: "0020_create_continuous_aggregates", up: createContinuousAggregates, down: dropContinuousAggregates }, { name: "0021_create_retention_compression", up: createRetentionCompression, down: dropRetentionCompression }, + { name: "0022_add_show_page_speed_to_status_pages", up: addShowPageSpeedToStatusPages, down: dropShowPageSpeedFromStatusPages }, ]; const ensureMigrationsTable = async (pool: Pool) => { diff --git a/server/src/db/models/StatusPage.ts b/server/src/db/models/StatusPage.ts index e9b5b59aa9..6fcd5cd10e 100644 --- a/server/src/db/models/StatusPage.ts +++ b/server/src/db/models/StatusPage.ts @@ -104,6 +104,10 @@ const StatusPageSchema = new Schema( type: Boolean, default: false, }, + showPageSpeed: { + type: Boolean, + default: false, + }, customCSS: { type: String, default: "", diff --git a/server/src/repositories/status-pages/MongoStatusPagesRepository.ts b/server/src/repositories/status-pages/MongoStatusPagesRepository.ts index d8e942271b..25a6cbfa0e 100644 --- a/server/src/repositories/status-pages/MongoStatusPagesRepository.ts +++ b/server/src/repositories/status-pages/MongoStatusPagesRepository.ts @@ -58,6 +58,7 @@ class MongoStatusPagesRepository implements IStatusPagesRepository { showUptimePercentage: doc.showUptimePercentage, showAdminLoginLink: doc.showAdminLoginLink, showInfrastructure: doc.showInfrastructure, + showPageSpeed: doc.showPageSpeed, customCSS: doc.customCSS, createdAt: this.toDateString(doc.createdAt), updatedAt: this.toDateString(doc.updatedAt), diff --git a/server/src/repositories/status-pages/TimescaleStatusPagesRepository.ts b/server/src/repositories/status-pages/TimescaleStatusPagesRepository.ts index 583e775b56..55435b582f 100644 --- a/server/src/repositories/status-pages/TimescaleStatusPagesRepository.ts +++ b/server/src/repositories/status-pages/TimescaleStatusPagesRepository.ts @@ -26,6 +26,7 @@ interface StatusPageRow { show_uptime_percentage: boolean; show_admin_login_link: boolean; show_infrastructure: boolean; + show_page_speed: boolean; custom_css: string | null; created_at: Date; updated_at: Date; @@ -33,7 +34,7 @@ interface StatusPageRow { const COLUMNS = `id, user_id, team_id, types, company_name, url, timezone, color, logo_data, logo_content_type, is_published, show_charts, show_uptime_percentage, - show_admin_login_link, show_infrastructure, custom_css, created_at, updated_at`; + show_admin_login_link, show_infrastructure, show_page_speed, custom_css, created_at, updated_at`; export class TimescaleStatusPagesRepository implements IStatusPagesRepository { constructor(private pool: Pool) {} @@ -42,8 +43,8 @@ export class TimescaleStatusPagesRepository implements IStatusPagesRepository { const result = await this.pool.query( `INSERT INTO status_pages (user_id, team_id, types, company_name, url, timezone, color, logo_data, logo_content_type, is_published, show_charts, show_uptime_percentage, - show_admin_login_link, show_infrastructure, custom_css) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + show_admin_login_link, show_infrastructure, show_page_speed, custom_css) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) RETURNING ${COLUMNS}`, [ userId, @@ -60,6 +61,7 @@ export class TimescaleStatusPagesRepository implements IStatusPagesRepository { data.showUptimePercentage ?? false, data.showAdminLoginLink ?? false, data.showInfrastructure ?? false, + data.showPageSpeed ?? false, data.customCSS ?? null, ] ); @@ -128,6 +130,7 @@ export class TimescaleStatusPagesRepository implements IStatusPagesRepository { ["showUptimePercentage", "show_uptime_percentage"], ["showAdminLoginLink", "show_admin_login_link"], ["showInfrastructure", "show_infrastructure"], + ["showPageSpeed", "show_page_speed"], ["customCSS", "custom_css"], ]; @@ -252,6 +255,7 @@ export class TimescaleStatusPagesRepository implements IStatusPagesRepository { showUptimePercentage: row.show_uptime_percentage, showAdminLoginLink: row.show_admin_login_link, showInfrastructure: row.show_infrastructure, + showPageSpeed: row.show_page_speed, customCSS: row.custom_css ?? "", createdAt: row.created_at.toISOString(), updatedAt: row.updated_at.toISOString(), diff --git a/server/src/types/statusPage.ts b/server/src/types/statusPage.ts index 0329f4e6f5..112bba1698 100644 --- a/server/src/types/statusPage.ts +++ b/server/src/types/statusPage.ts @@ -1,4 +1,4 @@ -export const StatusPageTypes = ["uptime", "infrastructure"] as const; +export const StatusPageTypes = ["uptime", "infrastructure", "pagespeed"] as const; export type StatusPageType = (typeof StatusPageTypes)[number]; export interface StatusPageLogo { @@ -29,6 +29,7 @@ export interface StatusPage { showUptimePercentage: boolean; showAdminLoginLink: boolean; showInfrastructure: boolean; + showPageSpeed: boolean; customCSS: string; createdAt: string; updatedAt: string; diff --git a/server/src/validation/statusPageValidation.ts b/server/src/validation/statusPageValidation.ts index 8b2ab3f20e..8af932691b 100644 --- a/server/src/validation/statusPageValidation.ts +++ b/server/src/validation/statusPageValidation.ts @@ -32,6 +32,7 @@ export const createStatusPageBodyValidation = z showUptimePercentage: booleanCoercion, showAdminLoginLink: booleanCoercion.optional(), showInfrastructure: booleanCoercion.optional(), + showPageSpeed: booleanCoercion.optional(), removeLogo: z.union([z.literal("true"), z.literal("false")]).optional(), }) .strip();