diff --git a/apps/status-page/messages/de.json b/apps/status-page/messages/de.json index 54db17e6..f34b0822 100644 --- a/apps/status-page/messages/de.json +++ b/apps/status-page/messages/de.json @@ -137,5 +137,6 @@ "waUHa4": "SSH", "cVqFq/": "Erhalten Sie E-Mail-Benachrichtigungen, wenn ein Bericht erstellt oder gelöst wird", "gczcC5": "Abonnieren", - "45YlLU": "Bestätigen Sie Ihre E-Mail-Adresse, um Updates zu erhalten, und schon sind Sie fertig." + "45YlLU": "Bestätigen Sie Ihre E-Mail-Adresse, um Updates zu erhalten, und schon sind Sie fertig.", + "dcqxLR": "letzter Tag" } diff --git a/apps/status-page/messages/en.json b/apps/status-page/messages/en.json index 3c3fdc8a..12a25ad4 100644 --- a/apps/status-page/messages/en.json +++ b/apps/status-page/messages/en.json @@ -137,5 +137,6 @@ "waUHa4": "SSH", "cVqFq/": "Get email notifications whenever a report has been created or resolved", "gczcC5": "Subscribe", - "45YlLU": "Validate your email to receive updates and you are all set." + "45YlLU": "Validate your email to receive updates and you are all set.", + "dcqxLR": "last day" } diff --git a/apps/status-page/messages/fr.json b/apps/status-page/messages/fr.json index 3fc10855..548bd3d0 100644 --- a/apps/status-page/messages/fr.json +++ b/apps/status-page/messages/fr.json @@ -137,5 +137,6 @@ "waUHa4": "SSH", "cVqFq/": "Recevez des notifications par email à chaque création ou résolution d'un rapport", "gczcC5": "S'abonner", - "45YlLU": "Validez votre email pour recevoir les mises à jour et le tour est joué." + "45YlLU": "Validez votre email pour recevoir les mises à jour et le tour est joué.", + "dcqxLR": "dernier jour" } diff --git a/apps/status-page/messages/hi.json b/apps/status-page/messages/hi.json index dabb7248..80753c36 100644 --- a/apps/status-page/messages/hi.json +++ b/apps/status-page/messages/hi.json @@ -137,5 +137,6 @@ "waUHa4": "SSH", "cVqFq/": "जब भी कोई रिपोर्ट बनाई जाती है या समाधान की जाती है तो ईमेल सूचनाएं प्राप्त करें", "gczcC5": "सदस्यता लें", - "45YlLU": "अपडेट प्राप्त करने के लिए अपने ईमेल की पुष्टि करें और आप तैयार हैं।" + "45YlLU": "अपडेट प्राप्त करने के लिए अपने ईमेल की पुष्टि करें और आप तैयार हैं।", + "dcqxLR": "पिछला दिन" } diff --git a/apps/status-page/messages/ko.json b/apps/status-page/messages/ko.json index 741c937c..6263a9b0 100644 --- a/apps/status-page/messages/ko.json +++ b/apps/status-page/messages/ko.json @@ -137,5 +137,6 @@ "waUHa4": "SSH", "cVqFq/": "리포트가 생성되거나 해결될 때마다 이메일 알림을 받으세요", "gczcC5": "구독", - "45YlLU": "이메일을 인증하면 업데이트를 받을 준비가 완료됩니다." + "45YlLU": "이메일을 인증하면 업데이트를 받을 준비가 완료됩니다.", + "dcqxLR": "지난 하루" } diff --git a/apps/status-page/messages/tr.json b/apps/status-page/messages/tr.json index 5d13c54e..15efc19e 100644 --- a/apps/status-page/messages/tr.json +++ b/apps/status-page/messages/tr.json @@ -137,5 +137,6 @@ "waUHa4": "SSH", "cVqFq/": "Bir rapor oluşturulduğunda veya çözüldüğünde e-posta bildirimi alın", "gczcC5": "Abone Ol", - "45YlLU": "Güncellemeleri almak için e-posta adresinizi doğrulayın, hepsi bu kadar." + "45YlLU": "Güncellemeleri almak için e-posta adresinizi doğrulayın, hepsi bu kadar.", + "dcqxLR": "son gün" } diff --git a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/client.tsx b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/client.tsx index 0dc50174..e2acf3cf 100644 --- a/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/client.tsx +++ b/apps/status-page/src/app/(status-page)/[domain]/[locale]/(public)/client.tsx @@ -9,6 +9,8 @@ import { StatusComponentHeaderLeft, StatusComponentHeaderRight, StatusComponentIcon, + StatusComponentLatency, + StatusComponentLatencySkeleton, StatusComponentStatus, StatusComponentTitle, StatusComponentUptime, @@ -24,6 +26,7 @@ import { import { Separator } from "@openstatus/ui/components/ui/separator"; import { cn } from "@openstatus/ui/lib/utils"; import { skipToken, useQuery } from "@tanstack/react-query"; +import { useExtracted } from "next-intl"; import { notFound, useParams } from "next/navigation"; import { useMemo } from "react"; @@ -50,6 +53,7 @@ import { StatusEventTimelineReportUpdate, } from "../../../../../components/status-page/status-events"; import { StatusFeed } from "../../../../../components/status-page/status-feed"; +import { latencyByMonitorId } from "../../../../../data/metrics.client"; import { useEmbed } from "../../../../../hooks/use-embed"; import { usePathnamePrefix } from "../../../../../hooks/use-pathname-prefix"; import { updatesWithImpactChanges } from "../../../../../lib/report-impacts"; @@ -61,6 +65,7 @@ export function Client() { const { cardType, barType, showUptime, numberOfDays } = useStatusPage(); const embed = useEmbed(); const trpc = useTRPC(); + const t = useExtracted(); // NOTE: we cannot use `cardType` and `barType` here because of queryKey changes // It wouldn't match the server prefetch keys and we would have to refetch the page here @@ -117,6 +122,37 @@ export function Client() { ), ); + // Non-blocking latency read for the per-monitor "p75 ms" tracker chip. Reuses + // the public monitors query (already registers the multi-latency pipe) so the + // tracker rows never wait on it — the chip fills in once resolved. + const { data: monitorsLatency, isFetching: isLatencyFetching } = useQuery( + trpc.statusPage.getMonitors.queryOptions( + componentsVisible && pageInitial && pageInitial.pageComponents.length > 0 + ? { slug: domain } + : skipToken, + ), + ); + const latencyMap = useMemo( + () => latencyByMonitorId(monitorsLatency, "p75"), + [monitorsLatency], + ); + const latencyPending = !monitorsLatency && isLatencyFetching; + + // Only public monitors get a chip: private monitors aren't in `getMonitors` + // (value never arrives) and static components have no monitor. + const buildLatency = ( + row: NonNullable[number] | undefined, + ) => { + if (!row?.monitor?.public) return undefined; + const monitorId = row.monitor.id.toString(); + return { + label: t("last day"), + isLoading: latencyPending, + value: latencyMap.get(monitorId), + href: `${prefix ? `/${prefix}` : ""}/monitors/${monitorId}`, + }; + }; + // NOTE: we need to filter out the incidents as we don't want to show all of them in the banner - a single one is enough // REMINDER: we could move that to the server - but we might wanna have the info of all openEvents actually const events = useMemo(() => { @@ -279,9 +315,10 @@ export function Client() { {page.trackers.map((tracker) => { if (tracker.type === "component") { const component = tracker.component; - const { data, uptime } = - uptimeData?.find((u) => u.pageComponentId === component.id) ?? - {}; + const uptimeRow = uptimeData?.find( + (u) => u.pageComponentId === component.id, + ); + const { data, uptime } = uptimeRow ?? {}; return ( ); } @@ -305,10 +343,10 @@ export function Client() { defaultOpen={tracker.defaultOpen} > {tracker.components.map((component) => { - const { data, uptime } = - uptimeData?.find( - (u) => u.pageComponentId === component.id, - ) ?? {}; + const uptimeRow = uptimeData?.find( + (u) => u.pageComponentId === component.id, + ); + const { data, uptime } = uptimeRow ?? {}; return ( ); })} @@ -377,6 +416,7 @@ function ComponentCard({ uptime, showUptime, isLoading, + latency, }: { name: string; description?: string | null; @@ -385,12 +425,32 @@ function ComponentCard({ uptime?: string; showUptime?: boolean; isLoading?: boolean; + latency?: { label: string; isLoading: boolean; value?: string; href: string }; }) { return ( {name} + {latency ? ( + latency.value ? ( + + + + {latency.label} + + {latency.value} + p75 + + + ) : latency.isLoading ? ( + + ) : null + ) : null} {description} diff --git a/apps/status-page/src/data/metrics.client.ts b/apps/status-page/src/data/metrics.client.ts index 9db66b9e..c7e9dfd2 100644 --- a/apps/status-page/src/data/metrics.client.ts +++ b/apps/status-page/src/data/metrics.client.ts @@ -310,6 +310,27 @@ export function mapLatency( }); } +// Reduce each public monitor's latency time-series to a single averaged +// percentile, keyed by monitorId — for the status-page tracker latency chip. +export function latencyByMonitorId( + monitors: RouterOutputs["statusPage"]["getMonitors"] = [], + percentile: (typeof PERCENTILES)[number] = "p75", +) { + const field = PERCENTILE_MAP[percentile]; + const map = new Map(); + for (const monitor of monitors ?? []) { + const points = + monitor.data + ?.map((metric) => metric[field]) + .filter((value): value is number => value != null) ?? []; + if (points.length === 0) continue; + const avg = points.reduce((sum, value) => sum + value, 0) / points.length; + // compact chip: drop the unit space ("117 ms" -> "117ms") + map.set(monitor.id.toString(), formatMilliseconds(avg).replace(" ", "")); + } + return map; +} + export function mapTimingPhases( timingPhases: RouterOutputs["tinybird"]["metricsTimingPhases"], percentile: (typeof PERCENTILES)[number], diff --git a/apps/status-page/src/lib/formatter.ts b/apps/status-page/src/lib/formatter.ts index e5e554f5..7a8f7c87 100644 --- a/apps/status-page/src/lib/formatter.ts +++ b/apps/status-page/src/lib/formatter.ts @@ -13,6 +13,7 @@ export function formatMilliseconds(ms: number) { return `${Intl.NumberFormat("en-US", { style: "unit", unit: "millisecond", + maximumFractionDigits: 0, }).format(ms)}`; } diff --git a/packages/ui/src/components/blocks/status-component.tsx b/packages/ui/src/components/blocks/status-component.tsx index a43c992b..00cc144e 100644 --- a/packages/ui/src/components/blocks/status-component.tsx +++ b/packages/ui/src/components/blocks/status-component.tsx @@ -16,7 +16,7 @@ import { import { useMediaQuery } from "@openstatus/ui/hooks/use-media-query"; import { cn } from "@openstatus/ui/lib/utils"; import { formatDistanceToNowStrict, subDays } from "date-fns"; -import { InfoIcon } from "lucide-react"; +import { ArrowUpRight, InfoIcon } from "lucide-react"; import { useState } from "react"; // ============================================================================ @@ -118,7 +118,7 @@ export function StatusComponentHeader({ return (
{children} @@ -156,7 +156,7 @@ export function StatusComponentHeaderLeft({ return (
{children} @@ -500,6 +500,50 @@ export function StatusComponentUptimeSkeleton({ return ; } +/** + * StatusComponentLatency - Latency chip shown next to the monitor title. + * + * Presentational only; wrap in the app's link primitive to navigate to the + * monitor page. The caller supplies the label text (e.g. "92ms p75"). + * + * @see StatusComponentLatencySkeleton - For loading state + */ +export function StatusComponentLatency({ + className, + children, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ {children} + +
+ ); +} +StatusComponentLatency.displayName = "StatusComponentLatency"; + +/** + * StatusComponentLatencySkeleton - Loading skeleton sized to the latency chip. + * + * @see StatusComponentLatency - For the actual chip + */ +export function StatusComponentLatencySkeleton({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} +StatusComponentLatencySkeleton.displayName = "StatusComponentLatencySkeleton"; + /** * StatusComponentStatus - Automatic status label display *