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",