Merge pull request #3849 from bluewave-labs/feat/status-page-ranges-client
feat: status page ranges client
This commit is contained in:
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)),
|
||||
])
|
||||
);
|
||||
};
|
||||
@@ -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)
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user