diff --git a/client/src/Components/design-elements/Icon.tsx b/client/src/Components/design-elements/Icon.tsx index 22f939750..95e68826b 100644 --- a/client/src/Components/design-elements/Icon.tsx +++ b/client/src/Components/design-elements/Icon.tsx @@ -5,13 +5,15 @@ interface IconProps { size?: number; strokeWidth?: number; stroke?: string; + color?: string; } -const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5 }: IconProps) => { +const Icon = ({ icon: Icon, size = 20, strokeWidth = 1.5, color }: IconProps) => { return ( ); }; diff --git a/client/src/Pages/StatusPage/Status/index.tsx b/client/src/Pages/StatusPage/Status/index.tsx index 3c0408628..87a2de7a1 100644 --- a/client/src/Pages/StatusPage/Status/index.tsx +++ b/client/src/Pages/StatusPage/Status/index.tsx @@ -1,6 +1,6 @@ import { BasePage, BaseFallback } from "@/Components/design-elements"; import Typography from "@mui/material/Typography"; -import { Link } from "react-router-dom"; +import { Link, useSearchParams } from "react-router-dom"; import Stack from "@mui/material/Stack"; import Box from "@mui/material/Box"; @@ -9,7 +9,11 @@ import { useTranslation } from "react-i18next"; import { useIsAdmin } from "@/Hooks/useIsAdmin"; import { useLocation, useParams } from "react-router-dom"; import { useGet } from "@/Hooks/UseApi"; -import { resolveStatusPageTheme } from "@/Types/StatusPage"; +import { + isStatusPageRange, + resolveStatusPageTheme, + type StatusPageRange, +} from "@/Types/StatusPage"; import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPageResponse, @@ -80,21 +84,36 @@ const StatusPageView = () => { const { url } = useParams(); const isAdmin = useIsAdmin(); const location = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); + + const onRangeChange = (next: StatusPageRange) => { + const updated = new URLSearchParams(searchParams); + if (next === "latest") { + updated.delete("range"); + } else { + updated.set("range", next); + } + setSearchParams(updated, { replace: true }); + }; const onCustomDomainHost = isCustomDomainHost(); const isPublic = onCustomDomainHost || location.pathname.startsWith(PUBLIC_STATUS_PAGE_PREFIX); + const rawRange = searchParams.get("range"); + const range = isStatusPageRange(rawRange) ? rawRange : "latest"; + const apiUrl = buildStatusPageApiPath({ url, useCustomDomain: onCustomDomainHost, + range, }); const { data, isLoading, error } = useGet( apiUrl, {}, { - refreshInterval: 10000, + refreshInterval: range === "latest" ? 10000 : 60000, } ); @@ -150,6 +169,10 @@ const StatusPageView = () => { statusPage={statusPage} monitors={monitors} config={themeConfig} + range={range} + onRangeChange={onRangeChange} + bucketTimezone={data.bucketTimezone ?? "Etc/UTC"} + checkTTLDays={data.checkTTLDays} /> ); diff --git a/client/src/Pages/StatusPage/Status/themes/bold/styles.ts b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts index c4df4e207..86208d41b 100644 --- a/client/src/Pages/StatusPage/Status/themes/bold/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/bold/styles.ts @@ -4,8 +4,10 @@ import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; import { BOLD_SANS_STACK, MONO_STACK } from "../shared/fontStacks"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; -export type BoldHeatCell = "fast" | "med" | "slow" | "down" | "empty"; -export type BoldBarKind = "up" | "down" | "empty"; +import type { BarKind, HeatCellKind } from "../shared/ChartCells"; + +export type BoldHeatCell = HeatCellKind; +export type BoldBarKind = BarKind; export type BoldGaugeFill = "ok" | "warm" | "hot"; export interface BoldStyles { @@ -55,6 +57,7 @@ export const boldStyles = ( fast: tokens.up, med: `color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%)`, slow: tokens.warn, + degraded: tokens.degraded, down: tokens.down, empty: tokens.border, }; @@ -62,12 +65,14 @@ export const boldStyles = ( fast: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`, med: `0 0 6px color-mix(in srgb, ${tokens.up} 18%, transparent)`, slow: `0 0 6px color-mix(in srgb, ${tokens.warn} 20%, transparent)`, + degraded: `0 0 6px color-mix(in srgb, ${tokens.degraded} 20%, transparent)`, down: `0 0 6px color-mix(in srgb, ${tokens.down} 20%, transparent)`, empty: "none", }; const barBg: Record = { up: tokens.up, + degraded: tokens.degraded, down: tokens.down, empty: tokens.border, }; diff --git a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts index 868ebf186..9de2c96fb 100644 --- a/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/editorial/styles.ts @@ -8,8 +8,10 @@ import { } from "../shared/fontStacks"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; -export type EditorialHeatCell = "fast" | "med" | "slow" | "down" | "empty"; -export type EditorialBarKind = "up" | "down" | "empty"; +import type { BarKind, HeatCellKind } from "../shared/ChartCells"; + +export type EditorialHeatCell = HeatCellKind; +export type EditorialBarKind = BarKind; export type EditorialGaugeFill = "ok" | "warm" | "hot"; export interface EditorialStyles { @@ -59,12 +61,14 @@ export const editorialStyles = ( fast: tokens.up, med: `color-mix(in srgb, ${tokens.up} 60%, ${tokens.bg})`, slow: tokens.warn, + degraded: tokens.degraded, down: tokens.down, empty: tokens.border, }; const barBg: Record = { up: tokens.up, + degraded: tokens.degraded, down: tokens.down, empty: tokens.border, }; diff --git a/client/src/Pages/StatusPage/Status/themes/modern/styles.ts b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts index 2f730ee63..7bb108f51 100644 --- a/client/src/Pages/StatusPage/Status/themes/modern/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/modern/styles.ts @@ -5,8 +5,10 @@ import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; import { MONO_STACK, SANS_STACK } from "../shared/fontStacks"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; -export type ModernHeatCell = "fast" | "med" | "slow" | "down" | "empty"; -export type ModernBarKind = "up" | "down" | "empty"; +import type { BarKind, HeatCellKind } from "../shared/ChartCells"; + +export type ModernHeatCell = HeatCellKind; +export type ModernBarKind = BarKind; export type ModernGaugeFill = "ok" | "warm" | "hot"; export interface ModernStyles { @@ -86,12 +88,14 @@ export const modernStyles = ( fast: `linear-gradient(180deg, ${tokens.up}, ${tokens.upStrong})`, med: `linear-gradient(180deg, color-mix(in srgb, ${tokens.up} 70%, #ffffff 30%), ${tokens.up})`, slow: `linear-gradient(180deg, ${tokens.warn}, ${tokens.degraded})`, + degraded: `linear-gradient(180deg, ${tokens.degraded}, color-mix(in srgb, ${tokens.degraded} 70%, #000000 30%))`, down: `linear-gradient(180deg, ${tokens.down}, color-mix(in srgb, ${tokens.down} 70%, #000000 30%))`, empty: tokens.border, }; const barBg: Record = { up: tokens.up, + degraded: tokens.degraded, down: tokens.down, empty: tokens.border, }; diff --git a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts index 457b86ede..38f972c95 100644 --- a/client/src/Pages/StatusPage/Status/themes/refined/styles.ts +++ b/client/src/Pages/StatusPage/Status/themes/refined/styles.ts @@ -3,8 +3,10 @@ import type { StatusPageThemeTokens } from "../tokens"; import { type OverallTone, toneColor, toneSoft } from "../shared/overallStatus"; import { MONO_STACK, SANS_STACK } from "../shared/fontStacks"; -export type RefinedHeatCell = "fast" | "med" | "slow" | "down" | "empty"; -export type RefinedBarKind = "up" | "down" | "empty"; +import type { BarKind, HeatCellKind } from "../shared/ChartCells"; + +export type RefinedHeatCell = HeatCellKind; +export type RefinedBarKind = BarKind; export type RefinedGaugeFill = "ok" | "warm" | "hot"; import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; @@ -71,12 +73,14 @@ export const refinedStyles = ( fast: tokens.up, med: `color-mix(in srgb, ${tokens.up} 60%, #ffffff 40%)`, slow: tokens.warn, + degraded: tokens.degraded, down: tokens.down, empty: tokens.border, }; const barBg: Record = { up: tokens.up, + degraded: tokens.degraded, down: tokens.down, empty: tokens.border, }; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx index 96beeb173..cf08ca62f 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/BaseStatusPage.tsx @@ -1,20 +1,19 @@ -import { useMemo, useState } from "react"; import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; -import { useTranslation } from "react-i18next"; +import Typography from "@mui/material/Typography"; +import { TriangleAlert } from "lucide-react"; + import type { SxProps, Theme } from "@mui/material/styles"; import type { Monitor } from "@/Types/Monitor"; -import type { StatusPage } from "@/Types/StatusPage"; -import { getMonitorTypeLabel } from "@/Types/StatusPage"; +import type { StatusPage, StatusPageRange } from "@/Types/StatusPage"; +import { + getMonitorTypeLabel, + STATUS_PAGE_RANGE_DAYS, + STATUS_PAGE_RANGES, +} from "@/Types/StatusPage"; import type { StatusPageThemeTokens } from "@/Pages/StatusPage/Status/themes/tokens"; -import { - ThemedHeatmap, - type HeatCellKind, -} from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap"; -import { - ThemedHistogram, - type BarKind, -} from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram"; +import { ThemedHeatmap } from "@/Pages/StatusPage/Status/themes/shared/ThemedHeatmap"; +import { ThemedHistogram } from "@/Pages/StatusPage/Status/themes/shared/ThemedHistogram"; import { ThemedInfrastructure, type GaugeFillLevel, @@ -26,8 +25,20 @@ import { resolveOverallStatus, statusBadgeKey, } from "@/Pages/StatusPage/Status/themes/shared/overallStatus"; -import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; import { formatPercentage } from "@/Utils/FormatUtils"; +import { + checksToCells, + dailyBucketsToCells, + type BarKind, + type HeatCellKind, +} from "@/Pages/StatusPage/Status/themes/shared/ChartCells"; +import { LAYOUT } from "@/Utils/Theme/constants"; +import { Icon } from "@/Components/design-elements"; + +import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useTheme } from "@mui/material"; type StatusPageMonitor = Monitor; @@ -82,9 +93,22 @@ interface Props { statusPage: StatusPage; monitors: StatusPageMonitor[]; config: ThemeConfig; + range: StatusPageRange; + onRangeChange: (range: StatusPageRange) => void; + bucketTimezone: string; + checkTTLDays?: number; } -export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => { +export const BaseStatusPage = ({ + statusPage, + monitors, + config, + range, + onRangeChange, + bucketTimezone, + checkTTLDays, +}: Props) => { + const theme = useTheme(); const { t } = useTranslation(); const { tokens, mode } = useStatusPageTheme(); const styles = useMemo( @@ -126,33 +150,75 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => { /> {statusPage.showCharts && ( - - + + setChartMode("heatmap")} - sx={styles.chartSwitchButton(chartMode === "heatmap")} + sx={styles.chartSwitch} + role="radiogroup" > - {t("pages.statusPages.monitorsList.chartTypeHeatmap")} - - setChartMode("histogram")} - sx={styles.chartSwitchButton(chartMode === "histogram")} - > - {t("pages.statusPages.monitorsList.chartTypeHistogram")} + setChartMode("heatmap")} + sx={styles.chartSwitchButton(chartMode === "heatmap")} + > + {t("pages.statusPages.monitorsList.chartTypeHeatmap")} + + setChartMode("histogram")} + sx={styles.chartSwitchButton(chartMode === "histogram")} + > + {t("pages.statusPages.monitorsList.chartTypeHistogram")} + - + + + {STATUS_PAGE_RANGES.map((value) => ( + onRangeChange(value)} + sx={styles.chartSwitchButton(range === value)} + > + {t(`pages.statusPages.range.${value}`)} + + ))} + + + {range !== "latest" && + checkTTLDays && + STATUS_PAGE_RANGE_DAYS[range] > checkTTLDays && ( + + + + {t("pages.statusPages.rangeExceedsRetention", { days: checkTTLDays })} + + + )} + )} { const badgeTone = monitorBadgeTone(monitor.status); const uptimePercentage = formatPercentage(monitor.uptimePercentage ?? 0); + const cells = + range === "latest" + ? checksToCells(monitor.recentChecks ?? []) + : dailyBucketsToCells( + monitor.dailyChecks ?? [], + STATUS_PAGE_RANGE_DAYS[range], + bucketTimezone, + t + ); + return ( { {showChart && (chartMode === "heatmap" ? ( ) : ( { + if (bucket.totalChecks === 0) return "empty"; + const upRatio = bucket.upChecks / bucket.totalChecks; + if (upRatio >= 1) return "up"; + if (upRatio < DAY_DOWN_THRESHOLD) return "down"; + return "degraded"; +}; + +export const classifyResponseTime = ( + status: boolean, + responseTime: number | undefined +): Exclude => { + if (!status) return "down"; + const rt = responseTime ?? 0; + if (rt > 500) return "slow"; + if (rt > 250) return "med"; + return "fast"; +}; + +const emptyCell = (key: string): ChartCell => ({ + key, + barKind: "empty", + heatKind: "empty", + heightPct: MIN_HEIGHT_PCT, + responseTime: 0, + tooltip: null, + ariaLabel: "", +}); + +const padCells = (cells: ChartCell[], length: number): ChartCell[] => [ + ...cells, + ...Array.from({ length: Math.max(0, length - cells.length) }, (_, i) => + emptyCell(`empty-${cells.length + i}`) + ), +]; + +export const checksToCells = (checks: CheckSnapshot[]): ChartCell[] => { + const source = checks.slice(-MAX_RECENT_CHECKS); + const heights = computeBarHeights(source); + const cells = source.map( + (check, i): ChartCell => ({ + key: check.id ?? String(i), + barKind: check.status ? "up" : "down", + heatKind: classifyResponseTime(check.status, check.responseTime), + heightPct: heights[i] ?? MIN_HEIGHT_PCT, + responseTime: check.responseTime ?? 0, + tooltip: , + ariaLabel: `${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`, + }) + ); + return padCells(cells, MAX_RECENT_CHECKS); +}; + +export const dailyBucketsToCells = ( + buckets: DailyCheckBucket[], + days: number, + bucketTimezone: string, + t: TFunction +): ChartCell[] => { + const byDate = new Map(buckets.map((b) => [b.date, b])); + const heights = computeDayBarHeights(buckets); + const enumerated = enumerateDays(days, bucketTimezone); + return enumerated.map((date) => { + const bucket = byDate.get(date); + if (!bucket) return emptyCell(`day-${date}`); + const barKind = classifyDay(bucket); + return { + key: `day-${date}`, + barKind, + heatKind: + barKind === "up" + ? classifyResponseTime(true, bucket.avgResponseTime ?? 0) + : barKind, + heightPct: heights.get(date) ?? MIN_HEIGHT_PCT, + responseTime: bucket.avgResponseTime ?? 0, + tooltip: , + ariaLabel: t("pages.statusPages.monitorsList.chart.dayAria", { + date, + uptime: formatPercentage(bucket.upChecks / bucket.totalChecks), + }), + }; + }); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx index bc3def0d4..8f6550460 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip.tsx @@ -1,7 +1,8 @@ -import type { CheckSnapshot } from "@/Types/Check"; +import type { CheckSnapshot, DailyCheckBucket } from "@/Types/Check"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; import { formatDateWithTz, formatMs } from "@/Utils/TimeUtils"; +import { formatPercentage } from "@/Utils/FormatUtils"; import { useStatusPageTheme } from "@/Pages/StatusPage/Status/themes/StatusPageThemeProvider"; import { useTranslation } from "react-i18next"; @@ -27,3 +28,37 @@ export const ThemedChartTooltip = ({ check }: { check: CheckSnapshot }) => { ); }; + +export const ThemedDailyTooltip = ({ bucket }: { bucket: DailyCheckBucket }) => { + const { t } = useTranslation(); + return ( + + + {bucket.date} + + + {t("pages.statusPages.monitorsList.chart.dayUptime", { + uptime: formatPercentage(bucket.upChecks / bucket.totalChecks), + upChecks: bucket.upChecks.toLocaleString(), + totalChecks: bucket.totalChecks.toLocaleString(), + })} + + {bucket.avgResponseTime !== null && ( + + {t("pages.statusPages.monitorsList.chart.dayAvgResponse", { + value: formatMs(bucket.avgResponseTime), + })} + + )} + + ); +}; diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx index 96a501fbb..390f70667 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHeatmap.tsx @@ -2,65 +2,48 @@ import Tooltip from "@mui/material/Tooltip"; import Box from "@mui/material/Box"; import type { SxProps, Theme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; -import type { CheckSnapshot } from "@/Types/Check"; -import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; -import { ThemedChartTooltip } from "@/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip"; -import { formatMs } from "@/Utils/TimeUtils"; - -const CELLS = MAX_RECENT_CHECKS; - -export type HeatCellKind = "fast" | "med" | "slow" | "down" | "empty"; +import type { + ChartCell, + HeatCellKind, +} from "@/Pages/StatusPage/Status/themes/shared/ChartCells"; interface Props { - checks: CheckSnapshot[]; + cells: ChartCell[]; containerSx: SxProps; cellSx: (kind: HeatCellKind) => SxProps; } -const classify = (check: CheckSnapshot): Exclude => { - if (!check.status) return "down"; - const rt = check.responseTime ?? 0; - if (rt > 500) return "slow"; - if (rt > 250) return "med"; - return "fast"; -}; - -export const ThemedHeatmap = ({ checks, containerSx, cellSx }: Props) => { +export const ThemedHeatmap = ({ cells, containerSx, cellSx }: Props) => { const { t } = useTranslation(); - const source = checks.slice(-CELLS); - const padded: (CheckSnapshot | null)[] = [ - ...source, - ...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null), - ]; - return ( - {padded.map((check, i) => { - if (!check) { + {cells.map((cell) => { + if (cell.heatKind === "empty") { return ( ); } - const kind = classify(check); - const tooltipContent = ; return ( ); diff --git a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx index a5c0b71f1..3643d901c 100644 --- a/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx +++ b/client/src/Pages/StatusPage/Status/themes/shared/ThemedHistogram.tsx @@ -4,29 +4,22 @@ import Box from "@mui/material/Box"; import Stack from "@mui/material/Stack"; import type { SxProps, Theme } from "@mui/material/styles"; import { useTranslation } from "react-i18next"; -import type { CheckSnapshot } from "@/Types/Check"; -import { MAX_RECENT_CHECKS } from "@/Types/Monitor"; -import { computeBarHeights } from "@/Utils/DataUtils"; import { formatMs } from "@/Utils/TimeUtils"; -import { ThemedChartTooltip } from "@/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip"; -const CELLS = MAX_RECENT_CHECKS; -const MIN_HEIGHT_PCT = 6; - -export type BarKind = "up" | "down" | "empty"; +import type { + BarKind, + ChartCell, +} from "@/Pages/StatusPage/Status/themes/shared/ChartCells"; interface Props { - checks: CheckSnapshot[]; + cells: ChartCell[]; containerSx: SxProps; barSx: (kind: BarKind, heightPct: number) => SxProps; statsSx: SxProps; statsGap?: number; } -const tone = (check: CheckSnapshot): Exclude => - check.status ? "up" : "down"; - export const ThemedHistogram = ({ - checks, + cells, containerSx, barSx, statsSx, @@ -34,45 +27,42 @@ export const ThemedHistogram = ({ }: Props) => { const { t } = useTranslation(); - const { padded, heights, avg, peak } = useMemo(() => { - const source = checks.slice(-CELLS); - const out: (CheckSnapshot | null)[] = [ - ...source, - ...Array.from({ length: Math.max(0, CELLS - source.length) }, () => null), - ]; - const heights = computeBarHeights(source); - const valid = out.filter((c): c is CheckSnapshot => c !== null && c.responseTime > 0); - const maxRt = valid.length ? Math.max(...valid.map((c) => c.responseTime)) : 0; + const { avg, peak } = useMemo(() => { + const valid = cells.filter((cell) => cell.responseTime > 0); + const maxRt = valid.length ? Math.max(...valid.map((cell) => cell.responseTime)) : 0; const avgRt = valid.length - ? valid.reduce((s, c) => s + c.responseTime, 0) / valid.length + ? valid.reduce((sum, cell) => sum + cell.responseTime, 0) / valid.length : 0; - return { padded: out, heights, avg: formatMs(avgRt), peak: formatMs(maxRt) }; - }, [checks]); + return { avg: formatMs(avgRt), peak: formatMs(maxRt) }; + }, [cells]); return ( - - {padded.map((check, i) => { - if (!check) { + + {cells.map((cell) => { + if (cell.barKind === "empty") { return ( ); } - const height = heights[i] ?? MIN_HEIGHT_PCT; - const tooltipContent = ; return ( ); diff --git a/client/src/Types/Check.ts b/client/src/Types/Check.ts index e32762d95..78e970f04 100644 --- a/client/src/Types/Check.ts +++ b/client/src/Types/Check.ts @@ -291,3 +291,12 @@ export type CheckSnapshot = Pick< export interface HasResponseTime { responseTime: number; } + +export interface DailyCheckBucket { + monitorId: string; + date: string; + totalChecks: number; + upChecks: number; + downChecks: number; + avgResponseTime: number | null; // null when no check that day recorded a response time +} diff --git a/client/src/Types/Monitor.ts b/client/src/Types/Monitor.ts index ef328ebdb..6eaa280d7 100644 --- a/client/src/Types/Monitor.ts +++ b/client/src/Types/Monitor.ts @@ -1,4 +1,9 @@ -import type { GroupedCheck, CheckSnapshot, GroupedUptimeCheck } from "@/Types/Check"; +import type { + GroupedCheck, + CheckSnapshot, + GroupedUptimeCheck, + DailyCheckBucket, +} from "@/Types/Check"; import type { PageSpeedGroupedCheck } from "@/Types/Check"; import type { GeoContinent } from "@/Types/GeoCheck"; export type { GeoContinent } from "@/Types/GeoCheck"; @@ -149,6 +154,7 @@ export interface Monitor { dnsServer?: string; dnsRecordType?: DnsRecordType; recentChecks: CheckSnapshot[]; + dailyChecks?: DailyCheckBucket[]; createdAt: string; updatedAt: string; } diff --git a/client/src/Types/StatusPage.ts b/client/src/Types/StatusPage.ts index ea0db5292..a9adb9271 100644 --- a/client/src/Types/StatusPage.ts +++ b/client/src/Types/StatusPage.ts @@ -24,6 +24,20 @@ export const STATUS_PAGE_THEME_MODES = ["auto", "light", "dark"] as const; export type StatusPageThemeMode = (typeof STATUS_PAGE_THEME_MODES)[number]; export const DEFAULT_STATUS_PAGE_THEME_MODE: StatusPageThemeMode = "auto"; +export const STATUS_PAGE_DAY_RANGES = ["30d", "60d", "90d"] as const; +export type StatusPageDayRange = (typeof STATUS_PAGE_DAY_RANGES)[number]; +export const STATUS_PAGE_RANGES = ["latest", ...STATUS_PAGE_DAY_RANGES] as const; +export type StatusPageRange = (typeof STATUS_PAGE_RANGES)[number]; + +export const STATUS_PAGE_RANGE_DAYS: Record = { + "30d": 30, + "60d": 60, + "90d": 90, +}; + +export const isStatusPageRange = (value: string | null): value is StatusPageRange => + STATUS_PAGE_RANGES.some((range) => range === value); + export const resolveStatusPageTheme = ( value: string | null | undefined ): StatusPageTheme => @@ -67,4 +81,7 @@ export interface StatusPage { export interface StatusPageResponse { statusPage: StatusPage; monitors: Monitor[]; + range?: StatusPageDayRange; // present only when range !== "latest" + bucketTimezone?: string; + checkTTLDays?: number; } diff --git a/client/src/Utils/DataUtils.ts b/client/src/Utils/DataUtils.ts index 28fdfb7b5..3562d86ed 100644 --- a/client/src/Utils/DataUtils.ts +++ b/client/src/Utils/DataUtils.ts @@ -86,7 +86,7 @@ export const getResponseColor = ( return normalizeToHex(safe.end); }; -const MIN_HEIGHT_PCT = 8; +export const MIN_HEIGHT_PCT = 8; const CAP_PERCENTILE = 95; const percentile = (sortedValues: number[], p: number): number => { @@ -114,3 +114,24 @@ export const computeBarHeights = ( return Math.max(MIN_HEIGHT_PCT, Math.min(100, (value / cap) * 100)); }); }; + +export const computeDayBarHeights = ( + buckets: { date: string; avgResponseTime: number | null }[] +): Map => { + const values = buckets.map((bucket) => + typeof bucket.avgResponseTime === "number" && Number.isFinite(bucket.avgResponseTime) + ? Math.max(0, bucket.avgResponseTime) + : 0 + ); + + const sorted = values.slice().sort((a, b) => a - b); + const cap = percentile(sorted, CAP_PERCENTILE); + return new Map( + buckets.map((bucket, i) => [ + bucket.date, + cap <= 0 + ? MIN_HEIGHT_PCT + : Math.max(MIN_HEIGHT_PCT, Math.min(100, (values[i] / cap) * 100)), + ]) + ); +}; diff --git a/client/src/Utils/TimeUtils.ts b/client/src/Utils/TimeUtils.ts index 4112967ab..9b809e1da 100644 --- a/client/src/Utils/TimeUtils.ts +++ b/client/src/Utils/TimeUtils.ts @@ -120,3 +120,18 @@ export const formatDuration = (ms: number, verbose: boolean = false, hasSpace = } return formatted; }; + +export const enumerateDays = (days: number, timeZone: string): string[] => { + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + const [year, month, day] = formatter.format(new Date()).split("-").map(Number); + const todayUtc = Date.UTC(year, month - 1, day); + const DAY_MS = 24 * 60 * 60 * 1000; + return Array.from({ length: days }, (_, i) => + new Date(todayUtc - (days - 1 - i) * DAY_MS).toISOString().slice(0, 10) + ); +}; diff --git a/client/src/Utils/statusPageUrl.ts b/client/src/Utils/statusPageUrl.ts index 65109c198..d015edf8b 100644 --- a/client/src/Utils/statusPageUrl.ts +++ b/client/src/Utils/statusPageUrl.ts @@ -1,4 +1,8 @@ -import { PUBLIC_STATUS_PAGE_PREFIX, type StatusPage } from "@/Types/StatusPage"; +import { + PUBLIC_STATUS_PAGE_PREFIX, + type StatusPage, + type StatusPageRange, +} from "@/Types/StatusPage"; import { runtimeConfig } from "@/Utils/runtimeConfig"; const CLIENT_HOST = @@ -86,8 +90,12 @@ export const getStatusPagePreviewUrl = (statusPage: Pick): st export const buildStatusPageApiPath = (options: { url?: string; useCustomDomain?: boolean; + range?: StatusPageRange; }): string | null => { - const query = "type=uptime&type=infrastructure"; + let query = "type=uptime&type=infrastructure"; + if (options.range && options.range !== "latest") { + query += `&range=${options.range}`; + } if (options.useCustomDomain && typeof window !== "undefined") { const domain = encodeURIComponent(window.location.hostname); diff --git a/client/src/locales/en.json b/client/src/locales/en.json index f6d405d86..fe8f0319e 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -1442,6 +1442,14 @@ }, "statusPages": { "deleteSuccess": "Status page deleted successfully", + "rangeSelectorAria": "History range", + "rangeExceedsRetention": "Check history retention is set to {{days}} days. Increase retention for more data.", + "range": { + "latest": "Latest", + "30d": "30 days", + "60d": "60 days", + "90d": "90 days" + }, "fallback": { "title": "No status pages yet", "description": "Publish a public page that shows real-time uptime and incident history to your customers and stakeholders.", @@ -1463,7 +1471,10 @@ "avg": "avg {{value}}", "max": "max {{value}}", "downTooltip": "Down", - "heatmapAria": "Response time heatmap" + "heatmapAria": "Response time heatmap", + "dayAria": "{{date}}, {{uptime}} uptime", + "dayUptime": "Uptime: {{uptime}} ({{upChecks}}/{{totalChecks}} checks)", + "dayAvgResponse": "Avg response: {{value}}" }, "infrastructure": { "title": "Infrastructure",