Merge pull request #3849 from bluewave-labs/feat/status-page-ranges-client

feat: status page ranges client
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-08-18 10:03:12 -07:00
commit e92fe39e3b
18 files changed
+453 -127

No files matched your search

@@ -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 (
<Icon
size={size}
strokeWidth={strokeWidth}
color={color}
/>
);
};
+26 -3
View File
@@ -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<StatusPageResponse>(
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}
/>
</StatusPageThemeProvider>
);
@@ -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<BoldBarKind, string> = {
up: tokens.up,
degraded: tokens.degraded,
down: tokens.down,
empty: tokens.border,
};
@@ -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<EditorialBarKind, string> = {
up: tokens.up,
degraded: tokens.degraded,
down: tokens.down,
empty: tokens.border,
};
@@ -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<ModernBarKind, string> = {
up: tokens.up,
degraded: tokens.degraded,
down: tokens.down,
empty: tokens.border,
};
@@ -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<RefinedBarKind, string> = {
up: tokens.up,
degraded: tokens.degraded,
down: tokens.down,
empty: tokens.border,
};
@@ -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<any>;
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 && (
<Box sx={styles.chartSwitchWrap}>
<Box
sx={styles.chartSwitch}
role="radiogroup"
>
<Stack>
<Box sx={styles.chartSwitchWrap}>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "heatmap"}
onClick={() => setChartMode("heatmap")}
sx={styles.chartSwitchButton(chartMode === "heatmap")}
sx={styles.chartSwitch}
role="radiogroup"
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
</Box>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "histogram"}
onClick={() => setChartMode("histogram")}
sx={styles.chartSwitchButton(chartMode === "histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "heatmap"}
onClick={() => setChartMode("heatmap")}
sx={styles.chartSwitchButton(chartMode === "heatmap")}
>
{t("pages.statusPages.monitorsList.chartTypeHeatmap")}
</Box>
<Box
component="button"
type="button"
role="radio"
aria-checked={chartMode === "histogram"}
onClick={() => setChartMode("histogram")}
sx={styles.chartSwitchButton(chartMode === "histogram")}
>
{t("pages.statusPages.monitorsList.chartTypeHistogram")}
</Box>
</Box>
</Box>
</Box>
<Box sx={styles.chartSwitchWrap}>
<Box
sx={styles.chartSwitch}
role="radiogroup"
aria-label={t("pages.statusPages.rangeSelectorAria")}
>
{STATUS_PAGE_RANGES.map((value) => (
<Box
component="button"
type="button"
role="radio"
key={value}
aria-checked={range === value}
onClick={() => onRangeChange(value)}
sx={styles.chartSwitchButton(range === value)}
>
{t(`pages.statusPages.range.${value}`)}
</Box>
))}
</Box>
</Box>
{range !== "latest" &&
checkTTLDays &&
STATUS_PAGE_RANGE_DAYS[range] > checkTTLDays && (
<Stack
mb={LAYOUT.XS}
alignSelf="flex-end"
direction="row"
alignItems="center"
gap={LAYOUT.XXS}
>
<Icon
icon={TriangleAlert}
color={theme.palette.warning.main}
/>
<Typography>
{t("pages.statusPages.rangeExceedsRetention", { days: checkTTLDays })}
</Typography>
</Stack>
)}
</Stack>
)}
<Stack
@@ -166,6 +232,16 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
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 (
<Box
component="li"
@@ -226,13 +302,13 @@ export const BaseStatusPage = ({ statusPage, monitors, config }: Props) => {
{showChart &&
(chartMode === "heatmap" ? (
<ThemedHeatmap
checks={monitor.recentChecks ?? []}
cells={cells}
containerSx={styles.heatmap}
cellSx={styles.heatmapCell}
/>
) : (
<ThemedHistogram
checks={monitor.recentChecks ?? []}
cells={cells}
containerSx={styles.histogram}
barSx={styles.bar}
statsSx={styles.chartStats}
@@ -0,0 +1,113 @@
import {
ThemedChartTooltip,
ThemedDailyTooltip,
} from "@/Pages/StatusPage/Status/themes/shared/ThemedChartTooltip";
import type { CheckSnapshot, DailyCheckBucket } from "@/Types/Check";
import { MAX_RECENT_CHECKS } from "@/Types/Monitor";
import {
computeBarHeights,
computeDayBarHeights,
MIN_HEIGHT_PCT,
} from "@/Utils/DataUtils";
import { formatPercentage } from "@/Utils/FormatUtils";
import { enumerateDays, formatMs } from "@/Utils/TimeUtils";
import type { TFunction } from "i18next";
export type BarKind = "up" | "degraded" | "down" | "empty";
export type HeatCellKind = "fast" | "med" | "slow" | "degraded" | "down" | "empty";
export interface ChartCell {
key: string;
barKind: BarKind;
heatKind: HeatCellKind;
heightPct: number; // histogram bar height
responseTime: number; // 0 for empty cells
tooltip: React.ReactNode | null;
ariaLabel: string;
}
const DAY_DOWN_THRESHOLD = 0.5;
const classifyDay = (bucket: DailyCheckBucket): BarKind => {
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<HeatCellKind, "empty"> => {
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: <ThemedChartTooltip check={check} />,
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: <ThemedDailyTooltip bucket={bucket} />,
ariaLabel: t("pages.statusPages.monitorsList.chart.dayAria", {
date,
uptime: formatPercentage(bucket.upChecks / bucket.totalChecks),
}),
};
});
};
@@ -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 }) => {
</Stack>
);
};
export const ThemedDailyTooltip = ({ bucket }: { bucket: DailyCheckBucket }) => {
const { t } = useTranslation();
return (
<Stack gap={0.25}>
<Typography
variant="caption"
fontWeight={600}
>
{bucket.date}
</Typography>
<Typography
variant="caption"
sx={{ opacity: 0.8 }}
>
{t("pages.statusPages.monitorsList.chart.dayUptime", {
uptime: formatPercentage(bucket.upChecks / bucket.totalChecks),
upChecks: bucket.upChecks.toLocaleString(),
totalChecks: bucket.totalChecks.toLocaleString(),
})}
</Typography>
{bucket.avgResponseTime !== null && (
<Typography
variant="caption"
sx={{ opacity: 0.8 }}
>
{t("pages.statusPages.monitorsList.chart.dayAvgResponse", {
value: formatMs(bucket.avgResponseTime),
})}
</Typography>
)}
</Stack>
);
};
@@ -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<Theme>;
cellSx: (kind: HeatCellKind) => SxProps<Theme>;
}
const classify = (check: CheckSnapshot): Exclude<HeatCellKind, "empty"> => {
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 (
<Box
sx={containerSx}
sx={[
...(Array.isArray(containerSx) ? containerSx : [containerSx]),
{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` },
]}
role="img"
aria-label={t("pages.statusPages.monitorsList.chart.heatmapAria")}
>
{padded.map((check, i) => {
if (!check) {
{cells.map((cell) => {
if (cell.heatKind === "empty") {
return (
<Box
key={`empty-${i}`}
key={cell.key}
sx={cellSx("empty")}
/>
);
}
const kind = classify(check);
const tooltipContent = <ThemedChartTooltip check={check} />;
return (
<Tooltip
key={check.id ?? i}
title={tooltipContent}
key={cell.key}
title={cell.tooltip}
arrow
placement="top"
>
<Box
sx={cellSx(kind)}
aria-label={`${formatMs(check.responseTime)}, ${check.status ? "up" : "down"}`}
sx={cellSx(cell.heatKind)}
aria-label={cell.ariaLabel}
/>
</Tooltip>
);
@@ -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<Theme>;
barSx: (kind: BarKind, heightPct: number) => SxProps<Theme>;
statsSx: SxProps<Theme>;
statsGap?: number;
}
const tone = (check: CheckSnapshot): Exclude<BarKind, "empty"> =>
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 (
<Stack gap={statsGap}>
<Box sx={containerSx}>
{padded.map((check, i) => {
if (!check) {
<Box
sx={[
...(Array.isArray(containerSx) ? containerSx : [containerSx]),
{ gridTemplateColumns: `repeat(${cells.length}, 1fr)` },
]}
>
{cells.map((cell) => {
if (cell.barKind === "empty") {
return (
<Box
key={`empty-${i}`}
sx={barSx("empty", MIN_HEIGHT_PCT)}
key={cell.key}
sx={barSx("empty", cell.heightPct)}
/>
);
}
const height = heights[i] ?? MIN_HEIGHT_PCT;
const tooltipContent = <ThemedChartTooltip check={check} />;
return (
<Tooltip
key={check.id ?? i}
title={tooltipContent}
key={cell.key}
title={cell.tooltip}
arrow
placement="top"
>
<Box
sx={barSx(tone(check), height)}
aria-label={formatMs(check.responseTime)}
sx={barSx(cell.barKind, cell.heightPct)}
aria-label={cell.ariaLabel}
/>
</Tooltip>
);
+9
View File
@@ -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
}
+7 -1
View File
@@ -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;
}
+17
View File
@@ -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<StatusPageDayRange, number> = {
"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;
}
+22 -1
View File
@@ -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<string, number> => {
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)),
])
);
};
+15
View File
@@ -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)
);
};
+10 -2
View File
@@ -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<StatusPage, "url">): 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);
+12 -1
View File
@@ -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",