diff --git a/apps/server/src/libs/test/preload.ts b/apps/server/src/libs/test/preload.ts index f4efc7a0..7c119cf8 100644 --- a/apps/server/src/libs/test/preload.ts +++ b/apps/server/src/libs/test/preload.ts @@ -13,11 +13,11 @@ mock.module("@openstatus/upstash", () => ({ mock.module("@openstatus/tinybird", () => ({ OSTinybird: class { - httpStatus45d() { - return Promise.resolve({ data: [] }); + get legacy_httpStatus45d() { + return () => Promise.resolve({ data: [] }); } - tcpStatus45d() { - return Promise.resolve({ data: [] }); + get legacy_tcpStatus45d() { + return () => Promise.resolve({ data: [] }); } }, })); diff --git a/apps/server/src/routes/v1/monitors/summary/get.ts b/apps/server/src/routes/v1/monitors/summary/get.ts index 48159b15..fb964a0f 100644 --- a/apps/server/src/routes/v1/monitors/summary/get.ts +++ b/apps/server/src/routes/v1/monitors/summary/get.ts @@ -77,8 +77,8 @@ export function registerGetMonitorSummary(api: typeof monitorsApi) { console.log("fetching from tinybird"); const res = _monitor.jobType === "http" - ? await tb.httpStatus45d({ monitorId: id }) - : await tb.tcpStatus45d({ monitorId: id }); + ? await tb.legacy_httpStatus45d({ monitorId: id }) + : await tb.legacy_tcpStatus45d({ monitorId: id }); await redis.set(`${id}-daily-stats`, res.data, { ex: 600 }); diff --git a/apps/status-page/src/app/(public)/page.tsx b/apps/status-page/src/app/(public)/page.tsx index b2060a6b..50a0740d 100644 --- a/apps/status-page/src/app/(public)/page.tsx +++ b/apps/status-page/src/app/(public)/page.tsx @@ -1,3 +1,141 @@ +"use client"; + +import { + Section, + SectionDescription, + SectionGroup, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; +import { THEMES } from "@/components/status-page/community-themes"; +import { COMMUNITY_THEME } from "@/components/status-page/floating-button"; +import { + Status, + StatusContent, + StatusDescription, + StatusHeader, + StatusTitle, +} from "@/components/status-page/status"; +import { StatusBanner } from "@/components/status-page/status-banner"; +import { StatusMonitor } from "@/components/status-page/status-monitor"; +import { monitors } from "@/data/monitors"; +import { useTRPC } from "@/lib/trpc/client"; +import { cn } from "@/lib/utils"; +import { useQuery } from "@tanstack/react-query"; + export default function Page() { - return
Status Page
; + return ( + +
+ + Status Page Themes + + View all the current themes you can use. Or contribute your own one. + + +
+ {COMMUNITY_THEME.filter((theme) => theme !== "default").map( + (theme) => { + const t = THEMES[theme]; + return ( +
+ + {t.name} + + by{" "} + + {t.author.name} + + + + + + + +
+ ); + }, + )} +
+
+
+ ); +} + +// TODO: the status-tracker hover card is mounted on the body and looses the theme style context + +function ThemeCard({ + theme, + mode, +}: { + theme: keyof typeof THEMES; + mode: "dark" | "light"; +}) { + const t = THEMES[theme][mode]; + const trpc = useTRPC(); + const { data: uptimeData, isLoading } = useQuery( + trpc.statusPage.getNoopUptime.queryOptions(), + ); + return ( +
+
+ {/* NOTE: we use pointer-events-none to prevent the hover card or tooltip from being interactive - the Portal container is document body and we loose the styles */} +
+ + + Acme Inc. + + Get informed about our services. + + + + + {/* TODO: create mock data */} + + + +
+
+
+ ); +} + +function ThemeGroup({ children, className }: React.ComponentProps<"div">) { + return ( +
+ {children} +
+ ); +} + +function ThemeHeader({ children, className }: React.ComponentProps<"div">) { + return
{children}
; +} + +function ThemeTitle({ children, className }: React.ComponentProps<"div">) { + return
{children}
; +} + +function ThemeAuthor({ children, className }: React.ComponentProps<"div">) { + return ( +
+ {children} +
+ ); } diff --git a/apps/status-page/src/app/(status-page)/[domain]/(private)/layout.tsx b/apps/status-page/src/app/(status-page)/[domain]/(private)/layout.tsx new file mode 100644 index 00000000..40f3219c --- /dev/null +++ b/apps/status-page/src/app/(status-page)/[domain]/(private)/layout.tsx @@ -0,0 +1,42 @@ +import { Footer } from "@/components/nav/footer"; +import { + FloatingButton, + StatusPageProvider, +} from "@/components/status-page/floating-button"; +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; + +export default function Layout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ domain: string }>; +}) { + return ( + + +
+
+ {children} +
+
+
+ +
+
+ ); +} + +async function Hydrate({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ domain: string }>; +}) { + const queryClient = getQueryClient(); + await queryClient.prefetchQuery( + trpc.statusPage.get.queryOptions({ slug: (await params).domain }), + ); + return {children}; +} diff --git a/apps/status-page/src/app/(status-page)/[domain]/(private)/protected/page.tsx b/apps/status-page/src/app/(status-page)/[domain]/(private)/protected/page.tsx new file mode 100644 index 00000000..d765517c --- /dev/null +++ b/apps/status-page/src/app/(status-page)/[domain]/(private)/protected/page.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { + Section, + SectionDescription, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; +import { FormPassword } from "@/components/forms/form-password"; +import { Button } from "@/components/ui/button"; +import { useCookieState } from "@/hooks/use-cookie-state"; +import { createProtectedCookieKey } from "@/lib/protected"; +import { useTRPC } from "@/lib/trpc/client"; +import { useMutation } from "@tanstack/react-query"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; + +export default function PrivatePage() { + const { domain } = useParams<{ domain: string }>(); + const searchParams = useSearchParams(); + const trpc = useTRPC(); + const [_, setPassword] = useCookieState(createProtectedCookieKey(domain)); + const router = useRouter(); + const verifyPasswordMutation = useMutation( + trpc.statusPage.verifyPassword.mutationOptions({}), + ); + + return ( +
+ + Protected Page + + Enter the password to access the status page. + + +
+ { + const result = await verifyPasswordMutation.mutateAsync({ + slug: domain, + password: values.password, + }); + if (result) { + setPassword(values.password); + const redirect = searchParams.get("redirect"); + router.push(redirect ?? "/"); + } + }} + /> + +
+
+ ); +} diff --git a/apps/status-page/src/app/(status-page)/[domain]/(public)/events/(list)/page.tsx b/apps/status-page/src/app/(status-page)/[domain]/(public)/events/(list)/page.tsx new file mode 100644 index 00000000..8b9ab07f --- /dev/null +++ b/apps/status-page/src/app/(status-page)/[domain]/(public)/events/(list)/page.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { + StatusEvent, + StatusEventAffected, + StatusEventAside, + StatusEventContent, + StatusEventTimelineMaintenance, + StatusEventTimelineReport, + StatusEventTitle, +} from "@/components/status-page/status-events"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; + +import { + StatusEmptyState, + StatusEmptyStateDescription, + StatusEmptyStateTitle, +} from "@/components/status-page/status"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { formatDate } from "@/lib/formatter"; +import Link from "next/link"; + +// TODO: include ?filter=maintenance/reports + +export default function Page() { + const { domain } = useParams<{ domain: string }>(); + const trpc = useTRPC(); + const { data: page } = useQuery( + trpc.statusPage.get.queryOptions({ slug: domain }), + ); + + if (!page) return null; + + const { statusReports, maintenances } = page; + + return ( + + + Reports + Maintenances + + + {statusReports.length > 0 ? ( + statusReports.map((report) => { + const startedAt = report.statusReportUpdates[0].date; + return ( + + + + {formatDate(startedAt, { month: "short" })} + + + + + {report.title} + {report.monitorsToStatusReports.length > 0 ? ( + + {report.monitorsToStatusReports.map((affected) => ( + + {affected.monitor.name} + + ))} + + ) : null} + + + + + ); + }) + ) : ( + + No reports found + + No reports found for this status page. + + + )} + + + {maintenances.length > 0 ? ( + maintenances.map((maintenance) => { + const isFuture = maintenance.from > new Date(); + return ( + + + + {formatDate(maintenance.from, { month: "short" })} + + {isFuture ? ( + Upcoming + ) : null} + + + + {maintenance.title} + {maintenance.maintenancesToMonitors.length > 0 ? ( + + {maintenance.maintenancesToMonitors.map((affected) => ( + + {affected.monitor.name} + + ))} + + ) : null} + + + + + ); + }) + ) : ( + + No maintenances found + + No maintenances found for this status page. + + + )} + + + ); +} diff --git a/apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/maintenance/[id]/layout.tsx b/apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/maintenance/[id]/layout.tsx new file mode 100644 index 00000000..ff9fe0c0 --- /dev/null +++ b/apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/maintenance/[id]/layout.tsx @@ -0,0 +1,19 @@ +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; + +export default async function Layout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ id: string; domain: string }>; +}) { + const { id, domain } = await params; + const queryClient = getQueryClient(); + await queryClient.prefetchQuery( + trpc.statusPage.getMaintenance.queryOptions({ + id: Number(id), + slug: domain, + }), + ); + return {children}; +} diff --git a/apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/maintenance/[id]/page.tsx b/apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/maintenance/[id]/page.tsx new file mode 100644 index 00000000..1bf307bf --- /dev/null +++ b/apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/maintenance/[id]/page.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; + +import { formatDate } from "@/lib/formatter"; + +import { ButtonBack } from "@/components/button/button-back"; +import { ButtonCopyLink } from "@/components/button/button-copy-link"; +import { + StatusEvent, + StatusEventAffected, + StatusEventAside, + StatusEventContent, + StatusEventTimelineMaintenance, + StatusEventTitle, +} from "@/components/status-page/status-events"; +import { Badge } from "@/components/ui/badge"; +import { useParams } from "next/navigation"; + +export default function MaintenancePage() { + const trpc = useTRPC(); + const { id, domain } = useParams<{ id: string; domain: string }>(); + const { data: maintenance } = useQuery( + trpc.statusPage.getMaintenance.queryOptions({ + id: Number(id), + slug: domain, + }), + ); + + if (!maintenance) return null; + + const isFuture = maintenance.from > new Date(); + return ( +
+
+ + +
+ + + + {formatDate(maintenance.from, { month: "short" })} + + {isFuture ? ( + Upcoming + ) : null} + + + {maintenance.title} + + {maintenance.maintenancesToMonitors.map((affected) => ( + + {affected.monitor.name} + + ))} + + + + +
+ ); +} diff --git a/apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/report/[id]/layout.tsx b/apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/report/[id]/layout.tsx new file mode 100644 index 00000000..721f855d --- /dev/null +++ b/apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/report/[id]/layout.tsx @@ -0,0 +1,19 @@ +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; + +export default async function Layout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ id: string; domain: string }>; +}) { + const { id, domain } = await params; + const queryClient = getQueryClient(); + await queryClient.prefetchQuery( + trpc.statusPage.getReport.queryOptions({ + id: Number(id), + slug: domain, + }), + ); + return {children}; +} diff --git a/apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/report/[id]/page.tsx b/apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/report/[id]/page.tsx new file mode 100644 index 00000000..d6acef6b --- /dev/null +++ b/apps/status-page/src/app/(status-page)/[domain]/(public)/events/(view)/report/[id]/page.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { formatDate } from "@/lib/formatter"; + +import { ButtonBack } from "@/components/button/button-back"; +import { ButtonCopyLink } from "@/components/button/button-copy-link"; +import { + StatusEvent, + StatusEventAffected, + StatusEventAside, + StatusEventContent, + StatusEventTimelineReport, + StatusEventTitle, +} from "@/components/status-page/status-events"; +import { Badge } from "@/components/ui/badge"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; + +export default function ReportPage() { + const trpc = useTRPC(); + const { id, domain } = useParams<{ id: string; domain: string }>(); + const { data: report } = useQuery( + trpc.statusPage.getReport.queryOptions({ id: Number(id), slug: domain }), + ); + + if (!report) return null; + + const startedAt = report.statusReportUpdates[0].date; + + return ( +
+
+ + +
+ + + + {formatDate(startedAt, { month: "short" })} + + + + {report.title} + {report.monitorsToStatusReports.length > 0 ? ( + + {report.monitorsToStatusReports.map((affected) => ( + + {affected.monitor.name} + + ))} + + ) : null} + + + +
+ ); +} diff --git a/apps/status-page/src/app/(status-page)/[domain]/events/layout.tsx b/apps/status-page/src/app/(status-page)/[domain]/(public)/events/layout.tsx similarity index 52% rename from apps/status-page/src/app/(status-page)/[domain]/events/layout.tsx rename to apps/status-page/src/app/(status-page)/[domain]/(public)/events/layout.tsx index efe38cb1..cee0c82a 100644 --- a/apps/status-page/src/app/(status-page)/[domain]/events/layout.tsx +++ b/apps/status-page/src/app/(status-page)/[domain]/(public)/events/layout.tsx @@ -8,6 +8,9 @@ import { StatusHeader, StatusTitle, } from "@/components/status-page/status"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; export default function EventLayout({ children, @@ -15,11 +18,19 @@ export default function EventLayout({ children: React.ReactNode; }) { const { variant } = useStatusPage(); + const { domain } = useParams<{ domain: string }>(); + const trpc = useTRPC(); + const { data: page } = useQuery( + trpc.statusPage.get.queryOptions({ slug: domain }), + ); + + if (!page) return null; + return ( - Craft - Stay informed about the stability + {page.title} + {page.description} {children} diff --git a/apps/status-page/src/app/(status-page)/[domain]/(public)/layout.tsx b/apps/status-page/src/app/(status-page)/[domain]/(public)/layout.tsx new file mode 100644 index 00000000..d32af526 --- /dev/null +++ b/apps/status-page/src/app/(status-page)/[domain]/(public)/layout.tsx @@ -0,0 +1,92 @@ +import { defaultMetadata, ogMetadata, twitterMetadata } from "@/app/metadata"; +import { Footer } from "@/components/nav/footer"; +import { Header } from "@/components/nav/header"; +import { + FloatingButton, + StatusPageProvider, +} from "@/components/status-page/floating-button"; +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; + +export default function Layout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ domain: string }>; +}) { + return ( + + +
+
+
+ {children} +
+
+
+ +
+
+ ); +} + +async function Hydrate({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ domain: string }>; +}) { + const queryClient = getQueryClient(); + await queryClient.prefetchQuery( + trpc.statusPage.get.queryOptions({ slug: (await params).domain }), + ); + return {children}; +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ domain: string }>; +}): Promise { + const queryClient = getQueryClient(); + const { domain } = await params; + const page = await queryClient.fetchQuery( + trpc.statusPage.get.queryOptions({ slug: domain }), + ); + + if (!page) return notFound(); + + return { + ...defaultMetadata, + title: { + template: `%s | ${page.title}`, + default: page?.title, + }, + description: page?.description, + icons: page?.icon, + alternates: { + canonical: page?.customDomain + ? `https://${page.customDomain}` + : `https://${page.slug}.openstatus.dev`, + }, + twitter: { + ...twitterMetadata, + images: [ + `/api/og/page?slug=${page?.slug}&passwordProtected=${page?.passwordProtected}`, + ], + title: page?.title, + description: page?.description, + }, + openGraph: { + ...ogMetadata, + images: [ + `/api/og/page?slug=${page?.slug}&passwordProtected=${page?.passwordProtected}`, + ], + title: page?.title, + description: page?.description, + }, + }; +} diff --git a/apps/status-page/src/app/(status-page)/[domain]/(public)/monitors/[id]/page.tsx b/apps/status-page/src/app/(status-page)/[domain]/(public)/monitors/[id]/page.tsx new file mode 100644 index 00000000..a5800b13 --- /dev/null +++ b/apps/status-page/src/app/(status-page)/[domain]/(public)/monitors/[id]/page.tsx @@ -0,0 +1,329 @@ +"use client"; + +import { ButtonBack } from "@/components/button/button-back"; +import { ButtonCopyLink } from "@/components/button/button-copy-link"; +import { + ChartAreaPercentiles, + ChartAreaPercentilesSkeleton, +} from "@/components/chart/chart-area-percentiles"; +import { + ChartBarUptime, + ChartBarUptimeSkeleton, +} from "@/components/chart/chart-bar-uptime"; +import { + ChartLineRegions, + ChartLineRegionsSkeleton, +} from "@/components/chart/chart-line-regions"; +import { PopoverQuantile } from "@/components/popover/popover-quantile"; +import { + Status, + StatusContent, + StatusDescription, + StatusHeader, + StatusTitle, +} from "@/components/status-page/status"; +import { + StatusChartContent, + StatusChartDescription, + StatusChartHeader, + StatusChartTitle, +} from "@/components/status-page/status-charts"; +import { + StatusMonitorTabs, + StatusMonitorTabsContent, + StatusMonitorTabsList, + StatusMonitorTabsTrigger, + StatusMonitorTabsTriggerLabel, + StatusMonitorTabsTriggerValue, + StatusMonitorTabsTriggerValueSkeleton, +} from "@/components/status-page/status-monitor-tabs"; +import { Badge } from "@/components/ui/badge"; +import { + formatMillisecondsRange, + formatNumber, + formatPercentage, +} from "@/lib/formatter"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import { TrendingUp } from "lucide-react"; +import { useParams } from "next/navigation"; +import { useMemo } from "react"; + +export default function Page() { + const trpc = useTRPC(); + const { id, domain } = useParams<{ id: string; domain: string }>(); + const { data: page } = useQuery( + trpc.statusPage.get.queryOptions({ slug: domain }), + ); + + const tempMonitor = useMemo(() => { + return page?.monitors.find((monitor) => monitor.id === Number(id)); + }, [page, id]); + + if (!page) return null; + + const { data: monitor, isLoading } = useQuery( + trpc.statusPage.getMonitor.queryOptions({ id: Number(id), slug: domain }), + ); + + const globalLatencyData = useMemo(() => { + if (!monitor?.data.latency?.data) return []; + + return monitor.data.latency.data + .sort((a, b) => a.timestamp - b.timestamp) + .map((item) => ({ + ...item, + timestamp: new Date(item.timestamp).toLocaleString("default", { + day: "numeric", + month: "short", + hour: "numeric", + minute: "numeric", + timeZoneName: "short", + }), + })); + }, [monitor?.data.latency?.data]); + + const regionLatencyData = useMemo(() => { + if (!monitor?.data.regions?.data) return []; + + const grouped = monitor.data.regions.data + .sort((a, b) => a.timestamp - b.timestamp) + .reduce( + (acc, item) => { + const timestamp = new Date(item.timestamp).toLocaleString("default", { + day: "numeric", + month: "short", + hour: "numeric", + minute: "numeric", + timeZoneName: "short", + }); + + if (!acc[timestamp]) { + acc[timestamp] = { timestamp }; + } + acc[timestamp][item.region] = item.p75Latency; + return acc; + }, + {} as Record< + string, + { timestamp: string; [region: string]: number | string | null } + >, + ); + + return Object.values(grouped); + }, [monitor?.data.regions?.data]); + + const uptimeData = useMemo(() => { + if (!monitor?.data.uptime?.data) return []; + return monitor.data.uptime.data + .sort((a, b) => a.interval.getTime() - b.interval.getTime()) + .map((item) => ({ + timestamp: item.interval.toLocaleString("default", { + day: "numeric", + month: "short", + hour: "numeric", + minute: "numeric", + timeZoneName: "short", + }), + ...item, + })); + }, [monitor?.data.uptime?.data]); + + const { totalChecks, uptimePercentage, slowestRegion, p75Range } = + useMemo(() => { + const p75Range = globalLatencyData.reduce( + (acc, item) => ({ + min: Math.min(acc.min, item.p75Latency), + max: Math.max(acc.max, item.p75Latency), + }), + { + min: Number.POSITIVE_INFINITY, + max: Number.NEGATIVE_INFINITY, + }, + ); + + const uptimeStats = uptimeData.reduce( + (acc, item) => { + return { + total: acc.total + item.success + item.degraded + item.error, + success: acc.success + item.success, + degraded: acc.degraded + item.degraded, + error: acc.error + item.error, + }; + }, + { total: 0, success: 0, degraded: 0, error: 0 }, + ); + + const uptimePercentage = + uptimeStats.total > 0 + ? (uptimeStats.success + uptimeStats.degraded) / uptimeStats.total + : 0; + + const regionAverages = regionLatencyData.reduce( + (acc, item) => { + Object.keys(item).forEach((key) => { + if (key !== "timestamp" && typeof item[key] === "number") { + if (!acc[key]) { + acc[key] = { sum: 0, count: 0 }; + } + acc[key].sum += item[key] as number; + acc[key].count += 1; + } + }); + return acc; + }, + {} as Record, + ); + + const slowestRegion = Object.entries(regionAverages) + .map(([region, stats]) => ({ + region, + avgLatency: stats.count > 0 ? stats.sum / stats.count : 0, + })) + .sort((a, b) => b.avgLatency - a.avgLatency)[0]; + + return { + totalChecks: formatNumber(uptimeStats.total, { + notation: "compact", + compactDisplay: "short", + }).replace("K", "k"), + uptimePercentage: + uptimeStats.total > 0 ? formatPercentage(uptimePercentage) : "N/A", + slowestRegion: slowestRegion?.region || "N/A", + p75Range: + p75Range.min !== Number.POSITIVE_INFINITY || + p75Range.max !== Number.NEGATIVE_INFINITY + ? formatMillisecondsRange(p75Range.min, p75Range.max) + : "N/A", + }; + }, [uptimeData, regionLatencyData, globalLatencyData]); + + return ( + + + {tempMonitor?.name} + {tempMonitor?.description} + + +
+ + +
+ + + + + Global Latency + + {isLoading ? ( + + ) : ( + + {p75Range}{" "} + + p75 + + + )} + + + + Region Latency + + {isLoading ? ( + + ) : ( + + {tempMonitor?.regions.length} regions{" "} + + {slowestRegion} + + + )} + + + + Uptime + + {isLoading ? ( + + ) : ( + + {uptimePercentage}{" "} + + {totalChecks} checks + + + )} + + + + + + Global Latency + + The aggregated latency from all active regions based on + different quantiles. + + + {isLoading ? ( + + ) : ( + + )} + + + + + + Latency by Region + + {/* TODO: we could add an information to p95 that it takes the highest selected global latency percentile */} + Region latency per{" "} + p75{" "} + quantile, sorted by slowest + region. Compare up to{" "} + 6{" "} + regions. + + + {isLoading ? ( + + ) : ( + + )} + + + + + + Total Uptime + + Main values of uptime and availability, transparent. + + + {isLoading ? ( + + ) : ( + + )} + + + +
+
+ ); +} diff --git a/apps/status-page/src/app/(status-page)/[domain]/(public)/monitors/page.tsx b/apps/status-page/src/app/(status-page)/[domain]/(public)/monitors/page.tsx new file mode 100644 index 00000000..d7147c44 --- /dev/null +++ b/apps/status-page/src/app/(status-page)/[domain]/(public)/monitors/page.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { + ChartAreaPercentiles, + ChartAreaPercentilesSkeleton, +} from "@/components/chart/chart-area-percentiles"; +import { + EmptyStateContainer, + EmptyStateDescription, + EmptyStateTitle, +} from "@/components/content/empty-state"; +import { useStatusPage } from "@/components/status-page/floating-button"; +import { + Status, + StatusContent, + StatusDescription, + StatusHeader, + StatusTitle, +} from "@/components/status-page/status"; +import { StatusMonitorTitle } from "@/components/status-page/status-monitor"; +import { StatusMonitorDescription } from "@/components/status-page/status-monitor"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { useParams } from "next/navigation"; + +export default function Page() { + const { variant } = useStatusPage(); + const { domain } = useParams<{ domain: string }>(); + const trpc = useTRPC(); + const { data: page } = useQuery( + trpc.statusPage.get.queryOptions({ slug: domain }), + ); + const { data: monitors, isLoading } = useQuery( + trpc.statusPage.getMonitors.queryOptions({ slug: domain }), + ); + + if (!page) return null; + + return ( + + + {page.title} + {page.description} + + + {page.monitors.length > 0 ? ( + page.monitors + .filter((monitor) => monitor.public) + .map((monitor) => { + const data = + monitors + ?.find((item) => item.id === monitor.id) + ?.data?.map((item) => ({ + ...item, + // TODO: create formatter + timestamp: new Date(item.timestamp).toLocaleString( + "default", + { + day: "numeric", + month: "short", + hour: "numeric", + minute: "numeric", + timeZoneName: "short", + }, + ), + })) ?? []; + + return ( + +
+
+ {monitor.name} + + {monitor.description} + +
+ {isLoading ? ( + + ) : ( + + )} +
+ + ); + }) + ) : ( + + No public monitors + + No public monitors have been added to this page. + + + )} +
+
+ ); +} diff --git a/apps/status-page/src/app/(status-page)/[domain]/(public)/page.tsx b/apps/status-page/src/app/(status-page)/[domain]/(public)/page.tsx new file mode 100644 index 00000000..005be0e5 --- /dev/null +++ b/apps/status-page/src/app/(status-page)/[domain]/(public)/page.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useStatusPage } from "@/components/status-page/floating-button"; +import { + Status, + StatusContent, + StatusDescription, + StatusHeader, + StatusTitle, +} from "@/components/status-page/status"; +import { + StatusBanner, + StatusBannerContainer, + StatusBannerContent, + StatusBannerTitle, +} from "@/components/status-page/status-banner"; +import { + StatusEventTimelineMaintenance, + StatusEventTimelineReport, +} from "@/components/status-page/status-events"; +import { StatusFeed } from "@/components/status-page/status-feed"; +import { StatusMonitor } from "@/components/status-page/status-monitor"; +import { Separator } from "@/components/ui/separator"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; + +export default function Page() { + const { domain } = useParams<{ domain: string }>(); + const { cardType, barType, showUptime } = useStatusPage(); + const trpc = useTRPC(); + const { data: page } = useQuery( + trpc.statusPage.get.queryOptions({ slug: domain }), + ); + // NOTE: we can prefetch that to avoid loading state + const { data: uptimeData, isLoading } = useQuery( + trpc.statusPage.getUptime.queryOptions({ + slug: domain, + monitorIds: page?.monitors?.map((monitor) => monitor.id.toString()) || [], + // NOTE: this will be moved to db config + cardType, + barType, + }), + ); + + if (!page) return null; + + return ( +
+ + + {page.title} + {page.description} + + {page.openEvents.length > 0 ? ( + + {page.openEvents.map((e) => { + if (e.type === "maintenance") { + const maintenance = page.maintenances.find( + (maintenance) => maintenance.id === e.id, + ); + if (!maintenance) return null; + return ( + + {e.name} + + + + + ); + } + if (e.type === "report") { + const report = page.statusReports.find( + (report) => report.id === e.id, + ); + if (!report) return null; + return ( + + {e.name} + + + + + ); + } + return null; + })} + + ) : ( + + )} + {/* TODO: check how to display current events */} + + {page.monitors.map((monitor) => { + const { data, uptime } = + uptimeData?.find((m) => m.id === monitor.id) ?? {}; + return ( + + ); + })} + + + + Recent Events + + page.lastEvents.some((event) => event.id === report.id), + ) + .map((report) => ({ + ...report, + affected: report.monitorsToStatusReports.map( + (monitor) => monitor.monitor.name, + ), + updates: report.statusReportUpdates, + }))} + maintenances={page.maintenances + .filter((maintenance) => + page.lastEvents.some((event) => event.id === maintenance.id), + ) + .map((maintenance) => ({ + ...maintenance, + affected: maintenance.maintenancesToMonitors.map( + (monitor) => monitor.monitor.name, + ), + }))} + /> + + +
+ ); +} diff --git a/apps/status-page/src/app/(status-page)/[domain]/(public)/verify/[token]/page.tsx b/apps/status-page/src/app/(status-page)/[domain]/(public)/verify/[token]/page.tsx new file mode 100644 index 00000000..e58f15b7 --- /dev/null +++ b/apps/status-page/src/app/(status-page)/[domain]/(public)/verify/[token]/page.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { ButtonBack } from "@/components/button/button-back"; +import { + Status, + StatusHeader, + StatusTitle, +} from "@/components/status-page/status"; +import { useTRPC } from "@/lib/trpc/client"; +import { cn } from "@/lib/utils"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; +import { useEffect } from "react"; + +export default function VerifyPage() { + const trpc = useTRPC(); + const { token, domain } = useParams<{ token: string; domain: string }>(); + const { data: page } = useQuery( + trpc.statusPage.get.queryOptions({ slug: domain }), + ); + + const verifyEmailMutation = useMutation( + trpc.statusPage.verifyEmail.mutationOptions({}), + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: + useEffect(() => { + verifyEmailMutation.mutate({ slug: domain, token }); + }, [domain, token]); + + if (!page) return null; + + return ( + + + {verifyEmailMutation.isSuccess ? ( + + All set to receive updates from to {verifyEmailMutation.data?.email} + + ) : verifyEmailMutation.isError ? ( + + {verifyEmailMutation.error?.message} + + ) : ( + + Hang tight - we're confirming your subscription + + )} + + + + ); +} diff --git a/apps/status-page/src/app/(status-page)/[domain]/events/(list)/page.tsx b/apps/status-page/src/app/(status-page)/[domain]/events/(list)/page.tsx deleted file mode 100644 index a20d4c46..00000000 --- a/apps/status-page/src/app/(status-page)/[domain]/events/(list)/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -"use client"; - -import { StatusEventsTabs } from "@/components/status-page/status-events"; - -export default function Page() { - return ; -} diff --git a/apps/status-page/src/app/(status-page)/[domain]/events/(view)/maintenance/page.tsx b/apps/status-page/src/app/(status-page)/[domain]/events/(view)/maintenance/page.tsx deleted file mode 100644 index bfa48245..00000000 --- a/apps/status-page/src/app/(status-page)/[domain]/events/(view)/maintenance/page.tsx +++ /dev/null @@ -1,98 +0,0 @@ -"use client"; - -import { formatDate } from "@/lib/formatter"; - -import { - StatusEvent, - StatusEventAffected, - StatusEventAside, - StatusEventContent, - StatusEventTimelineMaintenance, - StatusEventTitle, -} from "@/components/status-page/status-events"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { maintenances } from "@/data/maintenances"; -import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; -import { cn } from "@/lib/utils"; -import { ArrowLeft, Check, Copy } from "lucide-react"; -import Link from "next/link"; - -const maintenance = maintenances[0]; - -export default function EventPage() { - const isFuture = maintenance.startDate > new Date(); - return ( -
-
- - -
- - - - {formatDate(maintenance.startDate, { month: "short" })} - - {isFuture ? ( - Upcoming - ) : null} - - - {maintenance.title} - - {maintenance.affected.map((affected) => ( - - {affected} - - ))} - - - - -
- ); -} - -function BackButton({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function CopyButton({ - className, - ...props -}: React.ComponentProps) { - const { copy, isCopied } = useCopyToClipboard(); - - return ( - - ); -} diff --git a/apps/status-page/src/app/(status-page)/[domain]/events/(view)/report/page.tsx b/apps/status-page/src/app/(status-page)/[domain]/events/(view)/report/page.tsx deleted file mode 100644 index 081785b5..00000000 --- a/apps/status-page/src/app/(status-page)/[domain]/events/(view)/report/page.tsx +++ /dev/null @@ -1,95 +0,0 @@ -"use client"; - -import { formatDate } from "@/lib/formatter"; - -import { - StatusEvent, - StatusEventAffected, - StatusEventAside, - StatusEventContent, - StatusEventTimelineReport, - StatusEventTitle, -} from "@/components/status-page/status-events"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { statusReports } from "@/data/status-reports"; -import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; -import { cn } from "@/lib/utils"; -import { ArrowLeft, Check, Copy } from "lucide-react"; -import Link from "next/link"; - -const report = statusReports[1]; - -export default function EventPage() { - return ( -
-
- - -
- - - - {formatDate(report.startedAt, { month: "short" })} - - - - {report.name} - - {report.affected.map((affected) => ( - // TODO: use StatusEventAffectedBadge component - - {affected} - - ))} - - - - -
- ); -} - -function BackButton({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function CopyButton({ - className, - ...props -}: React.ComponentProps) { - const { copy, isCopied } = useCopyToClipboard(); - - return ( - - ); -} diff --git a/apps/status-page/src/app/(status-page)/[domain]/layout.tsx b/apps/status-page/src/app/(status-page)/[domain]/layout.tsx deleted file mode 100644 index 292c339d..00000000 --- a/apps/status-page/src/app/(status-page)/[domain]/layout.tsx +++ /dev/null @@ -1,149 +0,0 @@ -"use client"; - -/** - * TODO: - * - add different header - * - add different chart/tracker - * - add subscription popover (choose which one you'd like to allow) - * - use the '@/components/status-page` for the components - */ - -import { Link } from "@/components/common/link"; -import { - FloatingButton, - StatusPageProvider, -} from "@/components/status-page/floating-button"; -import { StatusUpdates } from "@/components/status-page/status-updates"; -import { Button } from "@/components/ui/button"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetTrigger, -} from "@/components/ui/sheet"; -import { cn } from "@/lib/utils"; -import { Menu } from "lucide-react"; -import NextLink from "next/link"; -import { usePathname } from "next/navigation"; -import { useState } from "react"; -const nav = [ - { label: "Status", href: "/status-page" }, - { label: "Events", href: "/status-page/events" }, - { label: "Monitors", href: "/status-page/monitors" }, -]; - -export default function Layout({ children }: { children: React.ReactNode }) { - return ( - -
-
- -
-
- {children} -
-
-
-

- Powered by OpenStatus -

-
-
-
- -
- ); -} - -function NavDesktop({ className, ...props }: React.ComponentProps<"ul">) { - const pathname = usePathname(); - return ( -
    - {nav.map((item) => { - const isActive = - item.href === "/status-page" - ? pathname === item.href - : pathname.startsWith(item.href); - return ( -
  • - -
  • - ); - })} -
- ); -} - -function NavMobile({ - className, - ...props -}: React.ComponentProps) { - const pathname = usePathname(); - const [open, setOpen] = useState(false); - return ( - - - - - - - Menu - -
-
    - {nav.map((item) => { - const isActive = - item.href === "/status-page" - ? pathname === item.href - : pathname.startsWith(item.href); - return ( -
  • - -
  • - ); - })} -
-
-
-
- ); -} diff --git a/apps/status-page/src/app/(status-page)/[domain]/monitors/page.tsx b/apps/status-page/src/app/(status-page)/[domain]/monitors/page.tsx deleted file mode 100644 index a014270b..00000000 --- a/apps/status-page/src/app/(status-page)/[domain]/monitors/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"use client"; - -import { ChartAreaPercentiles } from "@/components/chart/chart-area-percentiles"; -import { useStatusPage } from "@/components/status-page/floating-button"; -import { - Status, - StatusContent, - StatusDescription, - StatusHeader, - StatusTitle, -} from "@/components/status-page/status"; -import { StatusMonitorTitle } from "@/components/status-page/status-monitor"; -import { StatusMonitorDescription } from "@/components/status-page/status-monitor"; -import { monitors } from "@/data/monitors"; -import Link from "next/link"; - -export default function Page() { - const { variant } = useStatusPage(); - return ( - - - Craft - Stay informed about the stability - - {/* TODO: create components */} - - {monitors - .filter((monitor) => monitor.public) - .map((monitor) => ( - -
-
- {monitor.name} - - {monitor.description} - -
- -
- - ))} -
-
- ); -} diff --git a/apps/status-page/src/app/(status-page)/[domain]/monitors/view/page.tsx b/apps/status-page/src/app/(status-page)/[domain]/monitors/view/page.tsx deleted file mode 100644 index faf5ffa7..00000000 --- a/apps/status-page/src/app/(status-page)/[domain]/monitors/view/page.tsx +++ /dev/null @@ -1,370 +0,0 @@ -"use client"; - -import { ChartAreaPercentiles } from "@/components/chart/chart-area-percentiles"; -import { ChartLineRegions } from "@/components/chart/chart-line-regions"; -import { - MetricCard, - MetricCardGroup, - MetricCardHeader, - MetricCardTitle, - MetricCardValue, -} from "@/components/content/metric-card"; -import { - Status, - StatusContent, - StatusDescription, - StatusHeader, - StatusTitle, -} from "@/components/status-page/status"; -import { - StatusChartContent, - StatusChartDescription, - StatusChartHeader, - StatusChartTitle, -} from "@/components/status-page/status-charts"; -import { StatusMonitor } from "@/components/status-page/status-monitor"; -import { chartData } from "@/components/status-page/utils"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Separator } from "@/components/ui/separator"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { monitors } from "@/data/monitors"; -import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; -import { formatNumber } from "@/lib/formatter"; -import { cn } from "@/lib/utils"; -import { Check, Copy, TrendingUp } from "lucide-react"; -import { useState } from "react"; - -// TODO: add error range on ChartAreaLatency -// TODO: add timerange (1d, 7d, 14d) or leave as is and have 7d default? -// TODO: how to deal with the latency by region percentiles + interval/resolution - -const metrics = [ - { - label: "UPTIME", - value: "99.99%", - variant: "success" as const, - }, - { - label: "FAILS", - value: "3", - variant: "destructive" as const, - }, - { - label: "DEGRADED", - value: "0", - variant: "warning" as const, - }, - { - label: "CHECKS", - value: "5.102", - variant: "ghost" as const, - }, -]; - -export default function Page() { - return ( - - - OpenStatus 418 - - I'm a teapot - Just random values - - - -
- - -
- - - - - Global Latency - - - 287 - 568ms{" "} - - p75 - - - - - - Region Latency - - - 7 regions{" "} - - arn - - - - - - Uptime - - - 99.99%{" "} - - {formatNumber(5102, { - notation: "compact", - compactDisplay: "short", - }).replace("K", "k")}{" "} - checks - - - - - - - - Global Latency - - The aggregated latency from all active regions based on - different quantiles. - - - - - - - - - Latency by Region - - {/* TODO: we could add an information to p95 that it takes the highest selected global latency percentile */} - Region latency per{" "} - p75{" "} - quantile, sorted by slowest - region. Compare up to{" "} - 3{" "} - regions. - - - - - - - - - Total Uptime - - Main values of uptime and availability, transparent. - - - - {metrics.map((metric) => { - if (metric === null) - return
; - return ( - - - - {metric.label} - - - {metric.value} - - ); - })} - - - - - - - - ); -} - -// Use Link instead of copy (same for reports and maintenance) -function CopyButton({ - className, - ...props -}: React.ComponentProps) { - const { copy, isCopied } = useCopyToClipboard(); - - return ( - - ); -} - -const PERIOD_VALUES = [ - { - value: "1d", - label: "Last day", - }, - { - value: "7d", - label: "Last 7 days", - }, - { - value: "14d", - label: "Last 14 days", - }, -]; - -function DropdownPeriod() { - const [period, setPeriod] = useState("1d"); - return ( - - - - - - - - Period - - {PERIOD_VALUES.map(({ value, label }) => ( - setPeriod(value)}> - {label} - {period === value ? : null} - - ))} - - - - ); -} - -function PopoverQuantile({ - children, - className, - ...props -}: React.ComponentProps) { - return ( - - - {children} - - -

- A quantile represents a specific percentile in your dataset. -

- -

- For example, p50 is the 50th percentile - the point below which 50% of - data falls. Higher percentiles include more data and highlight the - upper range. -

-
-
- ); -} - -function StatusMonitorTabs({ - className, - ...props -}: React.ComponentProps) { - return ; -} - -function StatusMonitorTabsList({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function StatusMonitorTabsTrigger({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function StatusMonitorTabsTriggerLabel({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- ); -} - -function StatusMonitorTabsTriggerValue({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- ); -} - -function StatusMonitorTabsContent({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} diff --git a/apps/status-page/src/app/(status-page)/[domain]/page.tsx b/apps/status-page/src/app/(status-page)/[domain]/page.tsx deleted file mode 100644 index 1390a45f..00000000 --- a/apps/status-page/src/app/(status-page)/[domain]/page.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client"; - -import { useStatusPage } from "@/components/status-page/floating-button"; -import { - Status, - StatusBanner, - StatusContent, - StatusDescription, - StatusEmptyState, - StatusEmptyStateDescription, - StatusEmptyStateTitle, - StatusHeader, - StatusTitle, -} from "@/components/status-page/status"; -import { StatusMonitor } from "@/components/status-page/status-monitor"; -import { StatusTrackerGroup } from "@/components/status-page/status-tracker-group"; -import { chartData } from "@/components/status-page/utils"; -import { monitors } from "@/data/monitors"; -import { Newspaper } from "lucide-react"; - -export default function Page() { - const { variant, cardType, barType, showUptime } = useStatusPage(); - - return ( -
- - - Craft - - Stay informed about the stability - - - - - - - - - - - - - - - - - - No recent reports - - There have been no reports within the last 7 days. - - - - -
- ); -} diff --git a/apps/status-page/src/components/button/button-back.tsx b/apps/status-page/src/components/button/button-back.tsx new file mode 100644 index 00000000..4cf27c90 --- /dev/null +++ b/apps/status-page/src/components/button/button-back.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; + +export function ButtonBack({ + className, + href = "/", + ...props +}: React.ComponentProps & { href?: string }) { + return ( + + ); +} diff --git a/apps/status-page/src/components/button/button-copy-link.tsx b/apps/status-page/src/components/button/button-copy-link.tsx new file mode 100644 index 00000000..a5322dd3 --- /dev/null +++ b/apps/status-page/src/components/button/button-copy-link.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; +import { cn } from "@/lib/utils"; +import { Check, Copy } from "lucide-react"; + +export function ButtonCopyLink({ + className, + ...props +}: React.ComponentProps) { + const { copy, isCopied } = useCopyToClipboard(); + + return ( + + ); +} diff --git a/apps/status-page/src/components/chart/chart-area-percentiles.tsx b/apps/status-page/src/components/chart/chart-area-percentiles.tsx index eed90dee..f7283626 100644 --- a/apps/status-page/src/components/chart/chart-area-percentiles.tsx +++ b/apps/status-page/src/components/chart/chart-area-percentiles.tsx @@ -7,7 +7,7 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; -import { regionPercentile } from "@/data/region-percentile"; +import { Skeleton } from "@/components/ui/skeleton"; import { formatMilliseconds } from "@/lib/formatter"; import { cn } from "@/lib/utils"; import { useState } from "react"; @@ -17,30 +17,26 @@ import { ChartLegendBadge } from "./chart-legend-badge"; import { ChartTooltipNumber } from "./chart-tooltip-number"; const chartConfig = { - p50: { + p50Latency: { label: "p50", color: "var(--chart-1)", }, - p75: { + p75Latency: { label: "p75", color: "var(--chart-2)", }, - p90: { + p90Latency: { label: "p90", color: "var(--chart-4)", }, - p95: { + p95Latency: { label: "p95", color: "var(--chart-3)", }, - p99: { + p99Latency: { label: "p99", color: "var(--chart-5)", }, - error: { - label: "error", - color: "var(--destructive)", - }, } satisfies ChartConfig; function avg(values: number[]) { @@ -49,7 +45,10 @@ function avg(values: number[]) { ); } -const chartData = regionPercentile; +function formatAnnotation(values: number[]) { + if (values.length === 0) return "N/A"; + return formatMilliseconds(avg(values)); +} export function ChartAreaPercentiles({ className, @@ -57,20 +56,35 @@ export function ChartAreaPercentiles({ xAxisHide = true, legendVerticalAlign = "bottom", legendClassName, - withError = false, yAxisDomain = ["dataMin", "dataMax"], + data, }: { className?: string; singleSeries?: boolean; xAxisHide?: boolean; legendVerticalAlign?: "top" | "bottom"; legendClassName?: string; - withError?: boolean; yAxisDomain?: AxisDomain; + data: { + timestamp: string; + p50Latency: number; + p75Latency: number; + p90Latency: number; + p95Latency: number; + p99Latency: number; + }[]; }) { const [activeSeries, setActiveSeries] = useState< Array - >(["p75"]); + >(["p75Latency"]); + + const annotation = { + p50Latency: formatAnnotation(data.map((item) => item.p50Latency)), + p75Latency: formatAnnotation(data.map((item) => item.p75Latency)), + p90Latency: formatAnnotation(data.map((item) => item.p90Latency)), + p95Latency: formatAnnotation(data.map((item) => item.p95Latency)), + p99Latency: formatAnnotation(data.map((item) => item.p99Latency)), + }; return ( item.p50))), - p75: formatMilliseconds(avg(chartData.map((item) => item.p75))), - p90: formatMilliseconds(avg(chartData.map((item) => item.p90))), - p95: formatMilliseconds(avg(chartData.map((item) => item.p95))), - p99: formatMilliseconds(avg(chartData.map((item) => item.p99))), - }} + annotation={annotation} className={cn("overflow-x-scroll", legendClassName)} /> } @@ -125,7 +133,7 @@ export function ChartAreaPercentiles({ cursor={false} content={ ( - - + + - - + + - - + + - - + + - - + + - {withError ? ( - - ) : null} {/* */} `${value}ms`} /> - ); } + +export function ChartAreaPercentilesSkeleton({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} diff --git a/apps/status-page/src/components/chart/chart-bar-uptime.tsx b/apps/status-page/src/components/chart/chart-bar-uptime.tsx index ca7431b1..a7855c56 100644 --- a/apps/status-page/src/components/chart/chart-bar-uptime.tsx +++ b/apps/status-page/src/components/chart/chart-bar-uptime.tsx @@ -6,89 +6,82 @@ import { type ChartConfig, ChartContainer, ChartLegend, - ChartLegendContent, ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; -import { type PERIODS, mapUptime } from "@/data/metrics.client"; -import { useIsMobile } from "@/hooks/use-mobile"; -import { useTRPC } from "@/lib/trpc/client"; -import type { Region } from "@openstatus/db/src/schema/constants"; -import { useQuery } from "@tanstack/react-query"; -import { endOfDay, startOfDay, subDays } from "date-fns"; +import { formatNumber } from "@/lib/formatter"; +import { cn } from "@/lib/utils"; +import { useState } from "react"; +import { Skeleton } from "../ui/skeleton"; +import { ChartLegendBadge } from "./chart-legend-badge"; const chartConfig = { - ok: { - label: "Success", - color: "var(--color-success)", + success: { + label: "success", + // WTF: why is var(--color-success) not working + color: "var(--success)", }, degraded: { - label: "Degraded", + label: "degraded", color: "var(--color-warning)", }, error: { - label: "Error", + label: "failed", color: "var(--color-destructive)", }, } satisfies ChartConfig; -const periodToInterval = { - "1d": 60, - "7d": 240, - "14d": 480, -} satisfies Record<(typeof PERIODS)[number], number>; - -const periodToFromDate = { - "1d": startOfDay(subDays(new Date(), 1)), - "7d": startOfDay(subDays(new Date(), 7)), - "14d": startOfDay(subDays(new Date(), 14)), -} satisfies Record<(typeof PERIODS)[number], Date>; - export function ChartBarUptime({ - monitorId, - period, - type, - regions, + className, + data, }: { - monitorId: string; - period: (typeof PERIODS)[number]; - type: "http" | "tcp"; - regions: Region[]; + className?: string; + data: { + timestamp: string; + success: number; + error: number; + degraded: number; + }[]; }) { - const isMobile = useIsMobile(); - const trpc = useTRPC(); - const fromDate = periodToFromDate[period]; - const toDate = endOfDay(new Date()); - const interval = periodToInterval[period]; + const [activeSeries, setActiveSeries] = useState< + Array + >(["success", "error", "degraded"]); - const { data: uptime } = useQuery( - trpc.tinybird.uptime.queryOptions({ - monitorId, - fromDate: fromDate.toISOString(), - toDate: toDate.toISOString(), - regions, - interval, - type, - }), - ); - - const refinedUptime = uptime ? mapUptime(uptime) : []; + const annotation = { + success: formatNumber(data.reduce((acc, item) => acc + item.success, 0)), + error: formatNumber(data.reduce((acc, item) => acc + item.error, 0)), + degraded: formatNumber(data.reduce((acc, item) => acc + item.degraded, 0)), + }; return ( - - + + } /> - - - + + + - } /> + { + setActiveSeries((prev) => { + if (item.dataKey) { + const key = item.dataKey as keyof typeof chartConfig; + if (prev.includes(key)) { + return prev.filter((item) => item !== key); + } + return [...prev, key]; + } + return prev; + }); + }} + annotation={annotation} + className="justify-start overflow-x-scroll ps-1 pt-1" + /> + } + /> ); } + +export function ChartBarUptimeSkeleton({ className }: { className?: string }) { + return ; +} diff --git a/apps/status-page/src/components/chart/chart-legend-badge.tsx b/apps/status-page/src/components/chart/chart-legend-badge.tsx index 02b67d50..bdf05ca6 100644 --- a/apps/status-page/src/components/chart/chart-legend-badge.tsx +++ b/apps/status-page/src/components/chart/chart-legend-badge.tsx @@ -87,7 +87,7 @@ export function ChartLegendBadge({ /> )} {itemConfig?.label} - {suffix ? ( + {suffix !== undefined ? ( {suffix} diff --git a/apps/status-page/src/components/chart/chart-line-regions.tsx b/apps/status-page/src/components/chart/chart-line-regions.tsx index 4fc75e94..f99d903b 100644 --- a/apps/status-page/src/components/chart/chart-line-regions.tsx +++ b/apps/status-page/src/components/chart/chart-line-regions.tsx @@ -16,6 +16,7 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart"; +import { Skeleton } from "@/components/ui/skeleton"; import { regions } from "@/data/regions"; import { formatMilliseconds } from "@/lib/formatter"; import { cn } from "@/lib/utils"; @@ -23,100 +24,74 @@ import { useState } from "react"; import { ChartLegendBadge } from "./chart-legend-badge"; import { ChartTooltipNumber } from "./chart-tooltip-number"; -const r = regions.filter((r) => - ["ams", "bog", "arn", "atl", "bom", "syd", "fra"].includes(r.code), -); +function avg(values: (number | null | string)[]) { + const n = values.filter((val): val is number => typeof val === "number"); + return Math.round(n.reduce((acc, curr) => acc + curr, 0) / n.length); +} -const randomizer = Math.random() * 50; +function formatAnnotation(values: (number | null | string)[]) { + if (values.length === 0) return "N/A"; + return formatMilliseconds(avg(values)); +} -const chartData = Array.from({ length: 30 }, (_, i) => ({ - timestamp: new Date( - new Date().setMinutes(new Date().getMinutes() - i), - ).toLocaleString("default", { - hour: "numeric", - minute: "numeric", - }), - ams: Math.floor(Math.random() * randomizer) * 100 * 0.75, - bog: Math.floor(Math.random() * randomizer) * 100 * 0.75, - arn: Math.floor(Math.random() * randomizer) * 100 * 0.75, - atl: Math.floor(Math.random() * randomizer) * 100 * 0.75, - bom: Math.floor(Math.random() * randomizer) * 100 * 0.75, - syd: Math.floor(Math.random() * randomizer) * 100 * 0.75, - fra: Math.floor(Math.random() * randomizer) * 100 * 0.75, -})); +function getChartConfig( + data: { + timestamp: string; + [key: string]: string | number | null; + }[], +): ChartConfig { + const regions = + data.length > 0 + ? Object.keys(data[0]).filter((item) => item !== "timestamp") + : []; -const s = r.sort((a, b) => { - const aAvg = avg( - chartData.map((d) => { - const value = d[a.code as keyof typeof d]; - if (typeof value === "number") { - return value; - } - return 0; - }), - ); - const bAvg = avg( - chartData.map((d) => { - const value = d[b.code as keyof typeof d]; - if (typeof value === "number") { - return value; - } - return 0; - }), - ); - return bAvg - aAvg; -}); + return regions + .sort((a, b) => { + return ( + avg(data.map((item) => item[b])) - avg(data.map((item) => item[a])) + ); + }) + .map((region, index) => ({ + code: region, + color: `var(--rainbow-${((index + 5) % 17) + 1})`, + })) + .reduce( + (acc, item) => { + acc[item.code] = { + label: item.code, + color: item.color, + }; + return acc; + }, + {} as Record, + ) satisfies ChartConfig; +} -const chartConfig = s - .map((item, index) => ({ - code: item.code, - label: item.code, - color: `var(--rainbow-${index + 1})`, - })) - .reduce( - (acc, item) => { - acc[item.code] = item; +export function ChartLineRegions({ + className, + data, +}: { + className?: string; + data: { + timestamp: string; + [key: string]: string | number | null; + }[]; +}) { + const chartConfig = getChartConfig(data); + const [activeSeries, setActiveSeries] = useState< + Array + >(Object.keys(chartConfig).slice(0, 2)); + + const annotation = Object.keys(chartConfig).reduce( + (acc, region) => { + acc[region] = formatAnnotation(data.map((item) => item[region])); return acc; }, - {} as Record, - ) satisfies ChartConfig; - -function avg(values: number[]) { - return Math.round( - values.reduce((acc, curr) => acc + curr, 0) / values.length, + {} as Record, ); -} -const annotation = r.reduce( - (acc, item) => { - acc[item.code] = formatMilliseconds( - avg( - chartData.map((d) => { - const value = d[item.code as keyof typeof d]; - if (typeof value === "number") { - return value; - } - return 0; - }), - ), - ); - return acc; - }, - {} as Record, -); - -const tooltip = r.reduce( - (acc, item) => { - acc[item.code] = item.location; - return acc; - }, - {} as Record, -); + // TODO: tooltip -export function ChartLineRegions({ className }: { className?: string }) { - const [activeSeries, setActiveSeries] = useState< - Array - >([s[0].code, s[1].code]); return ( } /> - {r.map((item) => ( + {Object.keys(chartConfig).map((item) => ( ))} - } @@ -206,3 +180,15 @@ export function ChartLineRegions({ className }: { className?: string }) { ); } + +export function ChartLineRegionsSkeleton({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} diff --git a/apps/status-page/src/components/forms/form-password.tsx b/apps/status-page/src/components/forms/form-password.tsx new file mode 100644 index 00000000..f5051ce2 --- /dev/null +++ b/apps/status-page/src/components/forms/form-password.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { Form } from "@/components/ui/form"; +import { + FormControl, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { isTRPCClientError } from "@trpc/client"; +import { useTransition } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +const schema = z.object({ + password: z.string().min(1), +}); + +type FormValues = z.infer; + +export function FormPassword({ + onSubmit, + ...props +}: Omit, "onSubmit"> & { + onSubmit: (values: FormValues) => Promise; +}) { + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + password: "", + }, + }); + const [isPending, startTransition] = useTransition(); + + function submitAction(values: FormValues) { + if (isPending) return; + + startTransition(async () => { + try { + const promise = onSubmit(values); + toast.promise(promise, { + loading: "Confirming...", + success: "Confirmed", + error: (error) => { + if (isTRPCClientError(error)) { + form.setError("password", { message: error.message }); + return error.message; + } + return "Failed to confirm"; + }, + }); + await promise; + } catch (error) { + console.error(error); + } + }); + } + + return ( +
+ + ( + + Password + + + + + )} + /> + + + ); +} diff --git a/apps/status-page/src/components/forms/form-subscribe-email.tsx b/apps/status-page/src/components/forms/form-subscribe-email.tsx new file mode 100644 index 00000000..e9f5e7fd --- /dev/null +++ b/apps/status-page/src/components/forms/form-subscribe-email.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { Form } from "@/components/ui/form"; +import { + FormControl, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { isTRPCClientError } from "@trpc/client"; +import { useTransition } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +const schema = z.object({ + email: z.string().email(), +}); + +type FormValues = z.infer; + +export function FormSubscribeEmail({ + onSubmit, + ...props +}: Omit, "onSubmit"> & { + onSubmit: (values: FormValues) => Promise; + onSubmitCallback?: () => void; +}) { + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + email: "", + }, + }); + const [isPending, startTransition] = useTransition(); + + function submitAction(values: FormValues) { + if (isPending) return; + + startTransition(async () => { + try { + const promise = onSubmit(values); + toast.promise(promise, { + loading: "Subscribing...", + success: "Subscribed", + error: (error) => { + if (isTRPCClientError(error)) { + return error.message; + } + return "Failed to subscribe"; + }, + }); + await promise; + } catch (error) { + console.error(error); + } + }); + } + + return ( +
+ + ( + + Email + + + + + )} + /> + + + ); +} diff --git a/apps/status-page/src/components/nav/footer.tsx b/apps/status-page/src/components/nav/footer.tsx new file mode 100644 index 00000000..e23af954 --- /dev/null +++ b/apps/status-page/src/components/nav/footer.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { Link } from "@/components/common/link"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { useTRPC } from "@/lib/trpc/client"; +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; + +export function Footer(props: React.ComponentProps<"footer">) { + const { domain } = useParams<{ domain: string }>(); + const trpc = useTRPC(); + const { data: page } = useQuery( + trpc.statusPage.get.queryOptions({ slug: domain }), + ); + + if (!page) return null; + + return ( +
+
+ {page.workspacePlan === "team" ? null : ( +

+ Powered by OpenStatus +

+ )} + +
+
+ ); +} diff --git a/apps/status-page/src/components/nav/header.tsx b/apps/status-page/src/components/nav/header.tsx new file mode 100644 index 00000000..32de7694 --- /dev/null +++ b/apps/status-page/src/components/nav/header.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { Link } from "@/components/common/link"; +import { StatusUpdates } from "@/components/status-page/status-updates"; +import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { usePathnamePrefix } from "@/hooks/use-pathname-prefix"; +import { useTRPC } from "@/lib/trpc/client"; +import { cn } from "@/lib/utils"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Menu } from "lucide-react"; +import NextLink from "next/link"; +import { useParams, usePathname } from "next/navigation"; +import { useState } from "react"; + +function useNav() { + const pathname = usePathname(); + const prefix = usePathnamePrefix(); + + return [ + { + label: "Status", + href: `/${prefix}`, + isActive: pathname === `/${prefix}`, + }, + { + label: "Events", + href: `/${prefix}/events`, + isActive: pathname.startsWith(`/${prefix}/events`), + }, + { + label: "Monitors", + href: `/${prefix}/monitors`, + isActive: pathname.startsWith(`/${prefix}/monitors`), + }, + ]; +} + +export function Header(props: React.ComponentProps<"header">) { + const trpc = useTRPC(); + const { domain } = useParams<{ domain: string }>(); + const { data: page } = useQuery( + trpc.statusPage.get.queryOptions({ slug: domain }), + ); + + const sendPageSubscriptionMutation = useMutation( + trpc.emailRouter.sendPageSubscription.mutationOptions({}), + ); + + const subscribeMutation = useMutation( + trpc.statusPage.subscribe.mutationOptions({ + onSuccess: (id) => { + if (!id) return; + sendPageSubscriptionMutation.mutate({ id }); + }, + }), + ); + + const types = ( + page?.workspacePlan === "free" ? ["rss", "atom"] : ["email", "rss", "atom"] + ) satisfies ("email" | "rss" | "atom")[]; + + return ( +
+ +
+ ); +} + +function NavDesktop({ className, ...props }: React.ComponentProps<"ul">) { + const nav = useNav(); + return ( +
    + {nav.map((item) => { + return ( +
  • + +
  • + ); + })} +
+ ); +} + +function NavMobile({ + className, + ...props +}: React.ComponentProps) { + const [open, setOpen] = useState(false); + const nav = useNav(); + return ( + + + + + + + Menu + +
+
    + {nav.map((item) => { + return ( +
  • + +
  • + ); + })} +
+
+
+
+ ); +} diff --git a/apps/status-page/src/components/popover/popover-quantile.tsx b/apps/status-page/src/components/popover/popover-quantile.tsx new file mode 100644 index 00000000..4521562d --- /dev/null +++ b/apps/status-page/src/components/popover/popover-quantile.tsx @@ -0,0 +1,38 @@ +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; + +export function PopoverQuantile({ + children, + className, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + +

+ A quantile represents a specific percentile in your dataset. +

+ +

+ For example, p50 is the 50th percentile - the point below which 50% of + data falls. Higher percentiles include more data and highlight the + upper range. +

+
+
+ ); +} diff --git a/apps/status-page/src/components/status-page/community-themes.ts b/apps/status-page/src/components/status-page/community-themes.ts index b14c3acf..d4ac7867 100644 --- a/apps/status-page/src/components/status-page/community-themes.ts +++ b/apps/status-page/src/components/status-page/community-themes.ts @@ -84,7 +84,7 @@ export const githubTheme = { "--success": "oklch(54.34% 0.1634 145.98)", "--destructive": "oklch(47.1% 0.1909 25.95)", - "--warning": "oklch(40.97% 0.2064 289.57)", + "--warning": "oklch(81.84% 0.1328 85.87)", "--info": "oklch(46.96% 0.2957 264.51)", } as React.CSSProperties, }; @@ -98,7 +98,7 @@ export const THEMES = { }, github: { name: "Github", - author: { name: "@openstatus", url: "https://openstatus.dev" }, + author: { name: "@github", url: "https://github.com" }, ...githubTheme, }, supabase: { diff --git a/apps/status-page/src/components/status-page/floating-button.tsx b/apps/status-page/src/components/status-page/floating-button.tsx index 1c06274c..7399f026 100644 --- a/apps/status-page/src/components/status-page/floating-button.tsx +++ b/apps/status-page/src/components/status-page/floating-button.tsx @@ -26,10 +26,15 @@ import { THEMES } from "./community-themes"; export const VARIANT = ["success", "degraded", "error", "info"] as const; export type VariantType = (typeof VARIANT)[number]; -export const CARD_TYPE = ["duration", "requests", "dominant"] as const; +export const CARD_TYPE = [ + "duration", + "requests", + "dominant", + "manual", +] as const; export type CardType = (typeof CARD_TYPE)[number]; -export const BAR_TYPE = ["absolute", "dominant"] as const; +export const BAR_TYPE = ["absolute", "dominant", "manual"] as const; export type BarType = (typeof BAR_TYPE)[number]; export const COMMUNITY_THEME = ["default", "github", "supabase"] as const; @@ -174,6 +179,7 @@ export function FloatingButton({ className }: { className?: string }) { @@ -205,16 +211,23 @@ export function FloatingButton({ className }: { className?: string }) {
- +
- + -
-
- -
+ {success ? ( + + ) : ( + <> +
+

+ Get email notifications whenever a report has been created + or resolved +

+ { + await onSubscribe?.(values.email); + setSuccess(true); + }} + /> +
+
+ +
{" "} + + )}
@@ -55,14 +93,14 @@ export function StatusUpdates({
@@ -70,7 +108,7 @@ export function StatusUpdates({
@@ -82,15 +120,34 @@ export function StatusUpdates({ function CopyButton({ value, - className, -}: { + onClick, + ...props +}: React.ComponentProps & { value: string; - className?: string; }) { const { copy, isCopied } = useCopyToClipboard(); return ( - ); } + +function SuccessMessage() { + return ( +
+ +

Check your inbox!

+

+ Validate your email to receive updates and you are all set. +

+
+ ); +} diff --git a/apps/status-page/src/components/status-page/status.tsx b/apps/status-page/src/components/status-page/status.tsx index d4b35611..52490692 100644 --- a/apps/status-page/src/components/status-page/status.tsx +++ b/apps/status-page/src/components/status-page/status.tsx @@ -13,7 +13,6 @@ import { TriangleAlertIcon, WrenchIcon, } from "lucide-react"; -import { messages } from "./messages"; export function Status({ children, @@ -97,49 +96,6 @@ export function StatusContent({ return
{children}
; } -export function StatusBanner({ className }: React.ComponentProps<"div">) { - return ( -
- -
- - -
-
- ); -} - -export function StatusBannerMessage({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
- - {messages.long.success} - - - {messages.long.degraded} - - - {messages.long.error} - - - {messages.long.info} - -
- ); -} - export function StatusIcon({ className, ...props @@ -172,7 +128,6 @@ export function StatusTimestamp({ return ( - {/* TODO: add outline focus */} { const date = new Date(); @@ -66,6 +66,10 @@ export const chartConfig = { label: "info", color: "var(--info)", }, + empty: { + label: "empty", + color: "var(--muted)", + }, } satisfies ChartConfig; export const PRIORITY = { @@ -76,9 +80,67 @@ export const PRIORITY = { } as const; // satisfies Record; export function getHighestPriorityStatus(item: ChartData) { + const total = item.success + item.degraded + item.info + item.error; + if (total === 0) return "empty"; return ( VARIANT.filter((status) => item[status] > 0).sort( (a, b) => PRIORITY[b] - PRIORITY[a], - )[0] || "success" + )[0] || "empty" ); } + +export const PERCENTAGE_PRIORITY = { + info: -1, + error: 0, + degraded: 0.75, + success: 0.95, +} as const; + +export function getPercentagePriorityStatus(item: ChartData) { + const total = item.success + item.degraded + item.info + item.error; + if (total === 0) return "empty"; + + const percentage = item.success / total; + if (percentage >= PERCENTAGE_PRIORITY.success) return "success"; + if (percentage >= PERCENTAGE_PRIORITY.degraded) return "degraded"; + if (percentage >= PERCENTAGE_PRIORITY.error) return "error"; + if (percentage >= PERCENTAGE_PRIORITY.info) return "info"; + return "info"; +} + +export function getHighestStatus(items: VariantType[]) { + if (items.some((item) => item === "error")) return "error"; + if (items.some((item) => item === "degraded")) return "degraded"; + if (items.some((item) => item === "info")) return "info"; + return "success"; +} + +export function getTotalUptime(item: ChartData[]) { + const { ok, total } = item.reduce( + (acc, item) => ({ + ok: acc.ok + item.success + item.degraded + item.info, + total: acc.total + item.success + item.degraded + item.info + item.error, + }), + { + ok: 0, + total: 0, + }, + ); + + if (total === 0) return 100; + return Math.round((ok / total) * 10000) / 100; +} + +export function getManualUptime( + items: { from: Date | null; to: Date | null }[], + days: number, +) { + const duration = items.reduce((acc, item) => { + if (!item.from) return acc; + return acc + ((item.to || new Date()).getTime() - item.from.getTime()); + }, 0); + + const total = days * 24 * 60 * 60 * 1000; + + return Math.round(((total - duration) / total) * 10000) / 100; +} diff --git a/apps/status-page/src/hooks/use-pathname-prefix.ts b/apps/status-page/src/hooks/use-pathname-prefix.ts new file mode 100644 index 00000000..81efeafd --- /dev/null +++ b/apps/status-page/src/hooks/use-pathname-prefix.ts @@ -0,0 +1,25 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export function usePathnamePrefix() { + const [prefix, setPrefix] = useState(""); + + useEffect(() => { + if (typeof window !== "undefined") { + const hostnames = window.location.hostname.split("."); + const pathnames = window.location.pathname.split("/"); + if ( + hostnames.length > 2 && + hostnames[0] !== "www" && + !window.location.hostname.endsWith(".vercel.app") + ) { + setPrefix(hostnames[0]); + } else { + setPrefix(pathnames[1]); + } + } + }, []); + + return prefix; +} diff --git a/apps/status-page/src/lib/formatter.ts b/apps/status-page/src/lib/formatter.ts index 781fa29e..f091532a 100644 --- a/apps/status-page/src/lib/formatter.ts +++ b/apps/status-page/src/lib/formatter.ts @@ -15,6 +15,14 @@ export function formatMilliseconds(ms: number) { }).format(ms)}`; } +export function formatMillisecondsRange(min: number, max: number) { + if ((min > 1000 && max > 1000) || (min < 1000 && max < 1000)) { + return `${formatNumber(min / 1000)} - ${formatMilliseconds(max)}`; + } + + return `${formatMilliseconds(min)} - ${formatMilliseconds(max)}`; +} + export function formatPercentage(value: number) { if (Number.isNaN(value)) return "100%"; return `${Intl.NumberFormat("en-US", { diff --git a/apps/status-page/src/lib/protected.ts b/apps/status-page/src/lib/protected.ts new file mode 100644 index 00000000..9f7750c6 --- /dev/null +++ b/apps/status-page/src/lib/protected.ts @@ -0,0 +1,3 @@ +export function createProtectedCookieKey(value: string) { + return `secured-${value}`; +} diff --git a/apps/status-page/src/lib/trpc/shared.ts b/apps/status-page/src/lib/trpc/shared.ts index a3290046..dfa3ca8f 100644 --- a/apps/status-page/src/lib/trpc/shared.ts +++ b/apps/status-page/src/lib/trpc/shared.ts @@ -7,8 +7,8 @@ import superjson from "superjson"; const getBaseUrl = () => { if (typeof window !== "undefined") return ""; const vc = process.env.VERCEL_URL; - // if (vc) return `https://${vc}`; - if (vc) return "https://app.openstatus.dev"; + if (vc) return `https://${vc}`; + // if (vc) return "https://app.openstatus.dev"; return "http://localhost:3000"; }; diff --git a/apps/status-page/src/middleware.ts b/apps/status-page/src/middleware.ts new file mode 100644 index 00000000..32b53a9e --- /dev/null +++ b/apps/status-page/src/middleware.ts @@ -0,0 +1,69 @@ +import { type NextRequest, NextResponse } from "next/server"; + +import { db, eq } from "@openstatus/db"; +import { page } from "@openstatus/db/src/schema"; +import { createProtectedCookieKey } from "./lib/protected"; + +export default async function middleware(req: NextRequest) { + const url = req.nextUrl.clone(); + const response = NextResponse.next(); + const cookies = req.cookies; + + let prefix = ""; + let type: "hostname" | "pathname"; + + const hostnames = url.host.split("."); + const pathnames = url.pathname.split("/"); + if ( + hostnames.length > 2 && + hostnames[0] !== "www" && + !url.host.endsWith(".vercel.app") + ) { + prefix = hostnames[0].toLowerCase(); + type = "hostname"; + } else { + prefix = pathnames[1].toLowerCase(); + type = "pathname"; + } + + if (url.pathname === "/") { + return response; + } + + const _page = await db.select().from(page).where(eq(page.slug, prefix)).get(); + + if (!_page) { + return NextResponse.redirect(new URL("https://openstatus.dev")); + } + + if (_page?.passwordProtected) { + const protectedCookie = cookies.get(createProtectedCookieKey(prefix)); + const password = protectedCookie ? protectedCookie.value : undefined; + if (password !== _page.password && !url.pathname.endsWith("/protected")) { + const url = new URL( + `${req.nextUrl.origin}${ + type === "pathname" ? `/${prefix}` : "" + }/protected?redirect=${encodeURIComponent(req.url)}`, + ); + return NextResponse.redirect(url); + } + if (password === _page.password && url.pathname.endsWith("/protected")) { + const redirect = url.searchParams.get("redirect"); + return NextResponse.redirect( + new URL( + `${req.nextUrl.origin}${ + redirect ?? type === "pathname" ? `/${prefix}` : "/" + }`, + ), + ); + } + } + + return response; +} + +export const config = { + matcher: [ + "/((?!api|assets|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)", + ], +}; diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 1b3be084..3cd7048e 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/src/app/api/og/monitor/route.tsx b/apps/web/src/app/api/og/monitor/route.tsx index d4be6f6a..96c0f082 100644 --- a/apps/web/src/app/api/og/monitor/route.tsx +++ b/apps/web/src/app/api/og/monitor/route.tsx @@ -32,7 +32,7 @@ export async function GET(req: Request) { // TODO: we need to pass the monitor type here const res = (monitorId && - (await tb.httpStatus45d({ + (await tb.legacy_httpStatus45d({ monitorId, }))) || { data: [] }; diff --git a/apps/web/src/components/data-table/status-page/columns.tsx b/apps/web/src/components/data-table/status-page/columns.tsx index 392764f2..ab951816 100644 --- a/apps/web/src/components/data-table/status-page/columns.tsx +++ b/apps/web/src/components/data-table/status-page/columns.tsx @@ -26,7 +26,7 @@ import { DataTableRowActions } from "./data-table-row-actions"; export const columns: ColumnDef< Page & { monitorsToPages: { monitor: { name: string } }[]; - maintenancesToPages: Maintenance[]; // we get only the active maintenances! + maintenances: Maintenance[]; // we get only the active maintenances! statusReports: (StatusReport & { statusReportUpdates: StatusReportUpdate[]; })[]; diff --git a/apps/web/src/lib/tb.ts b/apps/web/src/lib/tb.ts index 64dd91de..371c5e02 100644 --- a/apps/web/src/lib/tb.ts +++ b/apps/web/src/lib/tb.ts @@ -167,8 +167,8 @@ export function prepareStatusByPeriod( } case "45d": { const getData = { - http: tb.httpStatus45d, - tcp: tb.tcpStatus45d, + http: tb.legacy_httpStatus45d, + tcp: tb.legacy_tcpStatus45d, } as const; return { getData: getData[type] }; } diff --git a/packages/api/src/edge.ts b/packages/api/src/edge.ts index 8010ae94..909b70ef 100644 --- a/packages/api/src/edge.ts +++ b/packages/api/src/edge.ts @@ -13,6 +13,7 @@ import { monitorTagRouter } from "./router/monitorTag"; import { notificationRouter } from "./router/notification"; import { pageRouter } from "./router/page"; import { pageSubscriberRouter } from "./router/pageSubscriber"; +import { statusPageRouter } from "./router/statusPage"; import { statusReportRouter } from "./router/statusReport"; import { tinybirdRouter } from "./router/tinybird"; import { userRouter } from "./router/user"; @@ -40,4 +41,5 @@ export const edgeRouter = createTRPCRouter({ checker: checkerRouter, blob: blobRouter, feedback: feedbackRouter, + statusPage: statusPageRouter, }); diff --git a/packages/api/src/router/email/index.ts b/packages/api/src/router/email/index.ts index ce2943b1..dfbb42ff 100644 --- a/packages/api/src/router/email/index.ts +++ b/packages/api/src/router/email/index.ts @@ -92,4 +92,30 @@ export const emailRouter = createTRPCRouter({ }); } }), + + sendPageSubscription: protectedProcedure + .input(z.object({ id: z.number() })) + .mutation(async (opts) => { + const limits = opts.ctx.workspace.limits; + + if (limits["status-subscribers"]) { + const _pageSubscriber = + await opts.ctx.db.query.pageSubscriber.findFirst({ + where: eq(pageSubscriber.id, opts.input.id), + with: { + page: true, + }, + }); + + if (!_pageSubscriber || !_pageSubscriber.token) return; + + await emailClient.sendPageSubscription({ + to: _pageSubscriber.email, + token: _pageSubscriber.token, + page: _pageSubscriber.page.title, + // TODO: or use custom domain + domain: _pageSubscriber.page.slug, + }); + } + }), }); diff --git a/packages/api/src/router/page.test.ts b/packages/api/src/router/page.test.ts index 3caccaae..75ed681f 100644 --- a/packages/api/src/router/page.test.ts +++ b/packages/api/src/router/page.test.ts @@ -23,6 +23,7 @@ test("Get Test Page", async () => { statusReports: expect.any(Array), monitors: expect.any(Array), incidents: expect.any(Array), + maintenances: expect.any(Array), published: expect.any(Boolean), slug: expect.any(String), title: expect.any(String), diff --git a/packages/api/src/router/page.ts b/packages/api/src/router/page.ts index 4eb59e0b..6d659ed7 100644 --- a/packages/api/src/router/page.ts +++ b/packages/api/src/router/page.ts @@ -15,6 +15,7 @@ import { import { incidentTable, insertPageSchema, + legacy_selectPublicPageSchemaWithRelation, maintenance, monitor, monitorsToPages, @@ -23,7 +24,6 @@ import { selectMonitorSchema, selectPageSchema, selectPageSchemaWithMonitorsRelation, - selectPublicPageSchemaWithRelation, statusReport, subdomainSafeList, workspace, @@ -262,7 +262,7 @@ export const pageRouter = createTRPCRouter({ where: and(eq(page.workspaceId, opts.ctx.workspace.id)), with: { monitorsToPages: { with: { monitor: true } }, - maintenancesToPages: { + maintenances: { where: and( lte(maintenance.from, new Date()), gte(maintenance.to, new Date()), @@ -278,14 +278,13 @@ export const pageRouter = createTRPCRouter({ }, }, }); - console.log(allPages.map((page) => page.statusReports)); return z.array(selectPageSchemaWithMonitorsRelation).parse(allPages); }), // public if we use trpc hooks to get the page from the url getPageBySlug: publicProcedure .input(z.object({ slug: z.string().toLowerCase() })) - .output(selectPublicPageSchemaWithRelation.optional()) + .output(legacy_selectPublicPageSchemaWithRelation.nullish()) .query(async (opts) => { if (!opts.input.slug) return; @@ -297,9 +296,7 @@ export const pageRouter = createTRPCRouter({ ) .get(); - if (!result) { - return; - } + if (!result) return; const [workspaceResult, monitorsToPagesResult] = await Promise.all([ opts.ctx.db @@ -354,7 +351,7 @@ export const pageRouter = createTRPCRouter({ const maintenancesQuery = opts.ctx.db.query.maintenance.findMany({ where: eq(maintenance.pageId, result.id), - with: { maintenancesToMonitors: true }, + with: { maintenancesToMonitors: { with: { monitor: true } } }, orderBy: (maintenances, { desc }) => desc(maintenances.from), }); @@ -373,7 +370,7 @@ export const pageRouter = createTRPCRouter({ incidentsQuery, ]); - return selectPublicPageSchemaWithRelation.parse({ + return legacy_selectPublicPageSchemaWithRelation.parse({ ...result, // TODO: improve performance and move into SQLite query monitors: monitors.sort((a, b) => { @@ -482,7 +479,7 @@ export const pageRouter = createTRPCRouter({ where: and(...whereConditions), with: { monitorsToPages: { with: { monitor: true } }, - maintenancesToPages: true, + maintenances: true, }, }); @@ -499,7 +496,7 @@ export const pageRouter = createTRPCRouter({ ...m.monitor, order: m.order, })), - maintenances: data?.maintenancesToPages, + maintenances: data?.maintenances, }); }), diff --git a/packages/api/src/router/statusPage.ts b/packages/api/src/router/statusPage.ts new file mode 100644 index 00000000..1db5a747 --- /dev/null +++ b/packages/api/src/router/statusPage.ts @@ -0,0 +1,637 @@ +import { z } from "zod"; + +import { and, eq, inArray, sql } from "@openstatus/db"; +import { + maintenance, + monitorsToPages, + page, + pageSubscriber, + selectPublicMonitorSchema, + selectPublicPageSchemaWithRelation, + statusReport, +} from "@openstatus/db/src/schema"; + +import { TRPCError } from "@trpc/server"; +import { createTRPCRouter, publicProcedure } from "../trpc"; +import { + fillStatusDataFor45Days, + fillStatusDataFor45DaysNoop, + getEvents, + getUptime, + setDataByType, +} from "./statusPage.utils"; +import { + getMetricsLatencyMultiProcedure, + getMetricsLatencyProcedure, + getMetricsRegionsProcedure, + getStatusProcedure, + getUptimeProcedure, +} from "./tinybird"; + +// NOTE: publicProcedure is used to get the status page +// TODO: improve performance of SQL query (make a single query with joins) + +// IMPORTANT: we cannot use the tinybird procedure because it has protectedProcedure +// instead, we should add TB logic in here!!!! + +// NOTE: this router is used on status pages only - do not confuse with the page router which is used in the dashboard for the config + +export const statusPageRouter = createTRPCRouter({ + get: publicProcedure + .input(z.object({ slug: z.string().toLowerCase() })) + .output(selectPublicPageSchemaWithRelation.nullish()) + .query(async (opts) => { + if (!opts.input.slug) return null; + + const _page = await opts.ctx.db.query.page.findFirst({ + where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, + with: { + workspace: true, + statusReports: { + orderBy: (reports, { desc }) => desc(reports.createdAt), + with: { + statusReportUpdates: { + orderBy: (reports, { desc }) => desc(reports.date), + }, + monitorsToStatusReports: { with: { monitor: true } }, + }, + }, + maintenances: { + with: { + maintenancesToMonitors: { with: { monitor: true } }, + }, + orderBy: (maintenances, { desc }) => desc(maintenances.from), + }, + monitorsToPages: { + with: { + monitor: { + with: { + incidents: true, + }, + }, + }, + orderBy: (monitorsToPages, { asc }) => asc(monitorsToPages.order), + }, + }, + }); + + if (!_page) return null; + + const monitors = _page.monitorsToPages + // NOTE: we cannot nested `where` in drizzle to filter active monitors + .filter((m) => m.monitor.active && !m.monitor.deletedAt) + .map((m) => { + const events = getEvents({ + maintenances: _page.maintenances, + incidents: m.monitor.incidents, + reports: _page.statusReports, + monitorId: m.monitor.id, + }); + const status = events.some((e) => e.type === "incident" && !e.to) + ? "error" + : events.some((e) => e.type === "report" && !e.to) + ? "degraded" + : events.some( + (e) => + e.type === "maintenance" && + e.to && + e.from.getTime() <= new Date().getTime() && + e.to.getTime() >= new Date().getTime(), + ) + ? "info" + : "success"; + return { ...m.monitor, status, events }; + }); + + const status = monitors.some((m) => m.status === "error") + ? "error" + : monitors.some((m) => m.status === "degraded") + ? "degraded" + : monitors.some((m) => m.status === "info") + ? "info" + : "success"; + + // Get page-wide events (not tied to specific monitors) + const pageEvents = getEvents({ + maintenances: _page.maintenances, + incidents: + _page.monitorsToPages.flatMap((m) => m.monitor.incidents) ?? [], + reports: _page.statusReports, + // No monitorId provided, so we get all events for the page + }); + + const threshold = new Date().getTime() - 7 * 24 * 60 * 60 * 1000; + const lastEvents = pageEvents + .filter((e) => { + if (e.type !== "incident") return false; + if (!e.to || e.to.getTime() >= threshold) return true; + return false; + }) + .sort((a, b) => a.from.getTime() - b.from.getTime()); + + const openEvents = pageEvents.filter((event) => { + console.log(event.type, event.from, event.to); + if (event.type === "incident" || event.type === "report") { + if (!event.to) return true; + if (event.to < new Date()) return false; + return false; + } + if (event.type === "maintenance") { + if (!event.to) return false; // NOTE: this never happens + if (event.from <= new Date() && event.to >= new Date()) return true; + return false; + } + return false; + }); + + return selectPublicPageSchemaWithRelation.parse({ + ..._page, + monitors, + incidents: monitors.flatMap((m) => m.incidents) ?? [], + statusReports: _page.statusReports ?? [], + maintenances: _page.maintenances ?? [], + workspacePlan: _page.workspace.plan, + status, + lastEvents, + openEvents, + }); + }), + + getMaintenance: publicProcedure + .input(z.object({ slug: z.string().toLowerCase(), id: z.number() })) + .query(async (opts) => { + if (!opts.input.slug) return null; + + const _page = await opts.ctx.db + .select() + .from(page) + .where( + sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, + ) + .get(); + + if (!_page) return null; + + const _maintenance = await opts.ctx.db.query.maintenance.findFirst({ + where: and( + eq(maintenance.id, opts.input.id), + eq(maintenance.pageId, _page.id), + ), + with: { maintenancesToMonitors: { with: { monitor: true } } }, + }); + + if (!_maintenance) return null; + + return _maintenance; + }), + + getUptime: publicProcedure + .input( + z.object({ + slug: z.string().toLowerCase(), + monitorIds: z.string().array(), + cardType: z + .enum(["requests", "duration", "dominant", "manual"]) + .default("requests"), + barType: z.enum(["absolute", "dominant", "manual"]).default("dominant"), + }), + ) + .query(async (opts) => { + if (!opts.input.slug) return null; + + const _page = await opts.ctx.db.query.page.findFirst({ + where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, + with: { + maintenances: { + with: { + maintenancesToMonitors: true, + }, + }, + statusReports: { + with: { + monitorsToStatusReports: true, + statusReportUpdates: true, + }, + }, + monitorsToPages: { + where: inArray( + monitorsToPages.monitorId, + opts.input.monitorIds.map(Number), + ), + with: { + monitor: { + with: { + incidents: true, + }, + }, + }, + }, + }, + }); + + if (!_page) return null; + + const monitors = _page.monitorsToPages.filter( + (m) => m.monitor.active && !m.monitor.deletedAt, + ); + + if (monitors.length !== opts.input.monitorIds.length) return null; + + const monitorsByType = { + http: monitors.filter((m) => m.monitor.jobType === "http"), + tcp: monitors.filter((m) => m.monitor.jobType === "tcp"), + }; + + const proceduresByType = { + http: getStatusProcedure("45d", "http"), + tcp: getStatusProcedure("45d", "tcp"), + }; + + const [statusHttp, statusTcp] = await Promise.all( + Object.entries(proceduresByType).map(([type, procedure]) => { + const monitorIds = monitorsByType[ + type as keyof typeof proceduresByType + ].map((m) => m.monitor.id.toString()); + if (monitorIds.length === 0) return null; + // NOTE: if manual mode, don't fetch data from tinybird + return opts.input.barType === "manual" + ? null + : procedure({ monitorIds }); + }), + ); + + const statusDataByMonitorId = new Map< + string, + | Awaited>["data"] + | Awaited>["data"] + >(); + + if (statusHttp?.data) { + statusHttp.data.forEach((status) => { + const monitorId = status.monitorId; + if (!statusDataByMonitorId.has(monitorId)) { + statusDataByMonitorId.set(monitorId, []); + } + statusDataByMonitorId.get(monitorId)?.push(status); + }); + } + + if (statusTcp?.data) { + statusTcp.data.forEach((status) => { + const monitorId = status.monitorId; + if (!statusDataByMonitorId.has(monitorId)) { + statusDataByMonitorId.set(monitorId, []); + } + statusDataByMonitorId.get(monitorId)?.push(status); + }); + } + + return monitors.map((m) => { + const monitorId = m.monitor.id.toString(); + const events = getEvents({ + maintenances: _page.maintenances, + incidents: m.monitor.incidents, + reports: _page.statusReports, + monitorId: m.monitor.id, + }); + const rawData = statusDataByMonitorId.get(monitorId) || []; + const filledData = fillStatusDataFor45Days(rawData, monitorId); + const processedData = setDataByType({ + events, + data: filledData, + cardType: opts.input.cardType, + barType: opts.input.barType, + }); + const uptime = getUptime({ + data: filledData, + events, + barType: opts.input.barType, + }); + + return { + ...selectPublicMonitorSchema.parse(m.monitor), + data: processedData, + uptime, + }; + }); + }), + + // NOTE: used for the theme store + getNoopUptime: publicProcedure.query(async () => { + const data = fillStatusDataFor45DaysNoop(); + const processedData = setDataByType({ + events: [ + { + type: "maintenance", + from: new Date(new Date().setDate(new Date().getDate() - 10)), + to: new Date(new Date().setDate(new Date().getDate() - 10)), + name: "", + id: 1, + status: "info", + }, + ], + data, + cardType: "requests", + barType: "dominant", + }); + return { + data: processedData, + uptime: "100%", + }; + }), + + getReport: publicProcedure + .input(z.object({ slug: z.string().toLowerCase(), id: z.number() })) + .query(async (opts) => { + if (!opts.input.slug) return null; + + const _page = await opts.ctx.db + .select() + .from(page) + .where( + sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, + ) + .get(); + + if (!_page) return null; + + const _report = await opts.ctx.db.query.statusReport.findFirst({ + where: and( + eq(statusReport.id, opts.input.id), + eq(statusReport.pageId, _page.id), + ), + with: { + monitorsToStatusReports: { with: { monitor: true } }, + statusReportUpdates: { + orderBy: (reports, { desc }) => desc(reports.date), + }, + }, + }); + + if (!_report) return null; + + return _report; + }), + + getMonitors: publicProcedure + .input(z.object({ slug: z.string().toLowerCase() })) + .query(async (opts) => { + if (!opts.input.slug) return null; + + // NOTE: revalidate the public monitors first + const data = await opts.ctx.db.query.page.findFirst({ + where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, + with: { + monitorsToPages: { + with: { + monitor: true, + }, + }, + }, + }); + + if (!data) return null; + + const publicMonitors = data.monitorsToPages.filter( + (m) => m.monitor.public, + ); + + const monitorsByType = { + http: publicMonitors.filter((m) => m.monitor.jobType === "http"), + tcp: publicMonitors.filter((m) => m.monitor.jobType === "tcp"), + }; + + const proceduresByType = { + http: getMetricsLatencyMultiProcedure("1d", "http"), + tcp: getMetricsLatencyMultiProcedure("1d", "tcp"), + }; + + const [metricsLatencyMultiHttp, metricsLatencyMultiTcp] = + await Promise.all( + Object.entries(proceduresByType).map(([type, procedure]) => { + const monitorIds = monitorsByType[ + type as keyof typeof proceduresByType + ].map((m) => m.monitor.id.toString()); + if (monitorIds.length === 0) return null; + return procedure({ monitorIds }); + }), + ); + + const metricsDataByMonitorId = new Map< + string, + | Awaited>["data"] + | Awaited>["data"] + >(); + + if (metricsLatencyMultiHttp?.data) { + metricsLatencyMultiHttp.data.forEach((metric) => { + const monitorId = metric.monitorId; + if (!metricsDataByMonitorId.has(monitorId)) { + metricsDataByMonitorId.set(monitorId, []); + } + metricsDataByMonitorId.get(monitorId)?.push(metric); + }); + } + + if (metricsLatencyMultiTcp?.data) { + metricsLatencyMultiTcp.data.forEach((metric) => { + const monitorId = metric.monitorId; + if (!metricsDataByMonitorId.has(monitorId)) { + metricsDataByMonitorId.set(monitorId, []); + } + metricsDataByMonitorId.get(monitorId)?.push(metric); + }); + } + + return publicMonitors.map((m) => { + const monitorId = m.monitor.id.toString(); + const data = metricsDataByMonitorId.get(monitorId) || []; + + return { + ...selectPublicMonitorSchema.parse(m.monitor), + data, + }; + }); + }), + + getMonitor: publicProcedure + .input(z.object({ slug: z.string().toLowerCase(), id: z.number() })) + .query(async (opts) => { + if (!opts.input.slug) return null; + + const _page = await opts.ctx.db.query.page.findFirst({ + where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, + with: { + monitorsToPages: { + where: eq(monitorsToPages.monitorId, opts.input.id), + with: { + monitor: true, + }, + }, + }, + }); + + if (!_page) return null; + + const _monitor = _page.monitorsToPages.find( + (m) => m.monitorId === opts.input.id, + )?.monitor; + + if (!_monitor) return null; + if (!_monitor.public) return null; + if (_monitor.deletedAt) return null; + + const type = _monitor.jobType as "http" | "tcp"; + + const proceduresByType = { + http: { + latency: getMetricsLatencyProcedure("7d", "http"), + regions: getMetricsRegionsProcedure("7d", "http"), + uptime: getUptimeProcedure("7d", "http"), + }, + tcp: { + latency: getMetricsLatencyProcedure("7d", "tcp"), + regions: getMetricsRegionsProcedure("7d", "tcp"), + uptime: getUptimeProcedure("7d", "tcp"), + }, + }; + + const [latency, regions, uptime] = await Promise.all([ + await proceduresByType[type].latency({ + monitorId: _monitor.id.toString(), + }), + await proceduresByType[type].regions({ + monitorId: _monitor.id.toString(), + }), + await proceduresByType[type].uptime({ + monitorId: _monitor.id.toString(), + interval: 240, + }), + ]); + + return { + ...selectPublicMonitorSchema.parse(_monitor), + data: { + latency, + regions, + uptime, + }, + }; + }), + + subscribe: publicProcedure + .input( + z.object({ slug: z.string().toLowerCase(), email: z.string().email() }), + ) + .mutation(async (opts) => { + if (!opts.input.slug) return null; + + const _page = await opts.ctx.db.query.page.findFirst({ + where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, + with: { + workspace: true, + }, + }); + + if (!_page) return null; + + if (_page.workspace.plan === "free") return null; + + const _alreadySubscribed = + await opts.ctx.db.query.pageSubscriber.findFirst({ + where: and( + eq(pageSubscriber.pageId, _page.id), + eq(pageSubscriber.email, opts.input.email), + ), + }); + + if (_alreadySubscribed) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Email already subscribed", + }); + } + + const _pageSubscriber = await opts.ctx.db + .insert(pageSubscriber) + .values({ + pageId: _page.id, + email: opts.input.email, + token: crypto.randomUUID(), + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), + }) + .returning() + .get(); + + return _pageSubscriber.id; + }), + + verifyEmail: publicProcedure + .input(z.object({ slug: z.string().toLowerCase(), token: z.string() })) + .mutation(async (opts) => { + if (!opts.input.slug) return null; + + const _page = await opts.ctx.db.query.page.findFirst({ + where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, + }); + + if (!_page) return null; + + const _pageSubscriber = await opts.ctx.db.query.pageSubscriber.findFirst({ + where: and( + eq(pageSubscriber.token, opts.input.token), + eq(pageSubscriber.pageId, _page.id), + ), + }); + + if (_pageSubscriber?.acceptedAt) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Email already verified", + }); + } + + if (!_pageSubscriber) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Subscription not found", + }); + } + + await opts.ctx.db + .update(pageSubscriber) + .set({ + acceptedAt: new Date(), + }) + .where(eq(pageSubscriber.id, _pageSubscriber.id)) + .execute(); + + return _pageSubscriber; + }), + + verifyPassword: publicProcedure + .input(z.object({ slug: z.string().toLowerCase(), password: z.string() })) + .mutation(async (opts) => { + if (!opts.input.slug) return null; + + const _page = await opts.ctx.db.query.page.findFirst({ + where: sql`lower(${page.slug}) = ${opts.input.slug} OR lower(${page.customDomain}) = ${opts.input.slug}`, + }); + + if (!_page) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Page not found", + }); + } + + if (_page.password !== opts.input.password) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid password", + }); + } + + return true; + }), +}); diff --git a/packages/api/src/router/statusPage.utils.ts b/packages/api/src/router/statusPage.utils.ts new file mode 100644 index 00000000..2452e01b --- /dev/null +++ b/packages/api/src/router/statusPage.utils.ts @@ -0,0 +1,581 @@ +import type { + Incident, + Maintenance, + StatusReport, + StatusReportUpdate, +} from "@openstatus/db/src/schema"; + +type StatusData = { + day: string; + count: number; + ok: number; + degraded: number; + error: number; + monitorId: string; +}; + +export function fillStatusDataFor45Days( + data: Array, + monitorId: string, +): Array { + const result = []; + const dataByDay = new Map(); + + // Index existing data by day + data.forEach((item) => { + const dayKey = new Date(item.day).toISOString().split("T")[0]; // YYYY-MM-DD format + dataByDay.set(dayKey, item); + }); + + // Generate all 45 days from today backwards + const now = new Date(); + for (let i = 0; i < 45; i++) { + const date = new Date(now); + date.setUTCDate(date.getUTCDate() - i); + date.setUTCHours(0, 0, 0, 0); // Set to start of day in UTC + + const dayKey = date.toISOString().split("T")[0]; // YYYY-MM-DD format + const isoString = date.toISOString(); + + if (dataByDay.has(dayKey)) { + // Use existing data but ensure the day is properly formatted + const existingData = dataByDay.get(dayKey); + result.push({ + ...existingData, + day: isoString, + }); + } else { + // Fill missing day with default values + result.push({ + day: isoString, + count: 0, + ok: 0, + degraded: 0, + error: 0, + monitorId, + }); + } + } + + // Sort by day (oldest first) + return result.sort( + (a, b) => new Date(a.day).getTime() - new Date(b.day).getTime(), + ); +} + +export function fillStatusDataFor45DaysNoop(): Array { + const data: StatusData[] = Array.from({ length: 45 }, (_, i) => ({ + day: new Date(new Date().setDate(new Date().getDate() - i)).toISOString(), + count: 1, + ok: [4, 40].includes(i) ? 0 : 1, + degraded: i === 40 ? 1 : 0, + error: i === 4 ? 1 : 0, + monitorId: "1", + })); + return fillStatusDataFor45Days(data, "1"); +} + +type Event = { + id: number; + name: string; + from: Date; + to: Date | null; + type: "maintenance" | "incident" | "report"; + status: "success" | "degraded" | "error" | "info"; +}; + +export function getEvents({ + maintenances, + incidents, + reports, + monitorId, + pastDays = 45, +}: { + maintenances: (Maintenance & { + maintenancesToMonitors: { monitorId: number }[]; + })[]; + incidents: Incident[]; + reports: (StatusReport & { + monitorsToStatusReports: { monitorId: number }[]; + statusReportUpdates: StatusReportUpdate[]; + })[]; + monitorId?: number; + pastDays?: number; +}): Event[] { + const events: Event[] = []; + const pastThreshod = new Date(); + pastThreshod.setDate(pastThreshod.getDate() - pastDays); + + // Filter maintenances - if monitorId is provided, filter by monitor, otherwise include all + maintenances + .filter((maintenance) => + monitorId + ? maintenance.maintenancesToMonitors.some( + (m) => m.monitorId === monitorId, + ) + : true, + ) + .forEach((maintenance) => { + if (maintenance.from < pastThreshod) return; + events.push({ + id: maintenance.id, + name: maintenance.title, + from: maintenance.from, + to: maintenance.to, + type: "maintenance", + status: "info" as const, + }); + }); + + // Filter incidents - if monitorId is provided, filter by monitor, otherwise include all + incidents + .filter((incident) => (monitorId ? incident.monitorId === monitorId : true)) + .forEach((incident) => { + if (!incident.createdAt || incident.createdAt < pastThreshod) return; + events.push({ + id: incident.id, + name: incident.title, + from: incident.createdAt, + to: incident.resolvedAt, + type: "incident", + status: "error" as const, + }); + }); + + // Filter reports - if monitorId is provided, filter by monitor, otherwise include all + reports + .filter((report) => + monitorId + ? report.monitorsToStatusReports.some((m) => m.monitorId === monitorId) + : true, + ) + .map((report) => { + const updates = report.statusReportUpdates.sort( + (a, b) => a.date.getTime() - b.date.getTime(), + ); + const firstUpdate = updates[0]; + const lastUpdate = updates[updates.length - 1]; + if (!firstUpdate?.date || firstUpdate.date < pastThreshod) return; + events.push({ + id: report.id, + name: report.title, + from: firstUpdate?.date, + to: + lastUpdate?.status === "resolved" || + lastUpdate?.status === "monitoring" + ? lastUpdate?.date + : null, + type: "report", + status: "degraded" as const, + }); + }); + + return events; +} + +// Keep the old function name for backward compatibility +export const getEventsByMonitorId = getEvents; + +type UptimeData = { + day: string; + events: Event[]; + bar: { + status: "success" | "degraded" | "error" | "info" | "empty"; + height: number; // percentage + }[]; + card: { + status: "success" | "degraded" | "error" | "info" | "empty"; + value: string; + }[]; +}; + +// Priority mapping for status types (higher number = higher priority) +const STATUS_PRIORITY = { + error: 3, + degraded: 2, + info: 1, + success: 0, + empty: -1, +} as const; + +// Helper to get highest priority status from data +function getHighestPriorityStatus( + item: StatusData, +): keyof typeof STATUS_PRIORITY { + if (item.error > 0) return "error"; + if (item.degraded > 0) return "degraded"; + if (item.ok > 0) return "success"; + + return "empty"; +} + +// Helper to format numbers +function formatNumber(num: number): string { + if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`; + if (num >= 1000) return `${(num / 1000).toFixed(1)}k`; + return num.toString(); +} + +// Helper to check if date is today +function isToday(date: Date): boolean { + const today = new Date(); + return ( + date.getDate() === today.getDate() && + date.getMonth() === today.getMonth() && + date.getFullYear() === today.getFullYear() + ); +} + +// Helper to format duration from minutes +function formatDuration(minutes: number): string { + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + if (remainingMinutes === 0) return `${hours}h`; + return `${hours}h ${remainingMinutes}m`; +} + +// Helper to check if date is within event range +function isDateWithinEvent(date: Date, event: Event): boolean { + const startOfDay = new Date(date); + startOfDay.setUTCHours(0, 0, 0, 0); + + const endOfDay = new Date(date); + endOfDay.setUTCHours(23, 59, 59, 999); + + const eventStart = new Date(event.from); + const eventEnd = event.to ? new Date(event.to) : new Date(); + + return ( + eventStart.getTime() <= endOfDay.getTime() && + eventEnd.getTime() >= startOfDay.getTime() + ); +} + +function getTotalEventsDurationMs(events: Event[], date: Date): number { + if (events.length === 0) return 0; + + const startOfDay = new Date(date); + startOfDay.setUTCHours(0, 0, 0, 0); + + const endOfDay = new Date(date); + endOfDay.setUTCHours(23, 59, 59, 999); + + const total = events.reduce((acc, curr) => { + if (!curr.from) return acc; + + const eventStart = new Date(curr.from); + const eventEnd = curr.to ? new Date(curr.to) : new Date(); + + // Only count events that overlap with this date + if ( + eventEnd.getTime() < startOfDay.getTime() || + eventStart.getTime() > endOfDay.getTime() + ) { + return acc; + } + + // Calculate the overlapping duration within the date boundaries + const overlapStart = Math.max(eventStart.getTime(), startOfDay.getTime()); + const overlapEnd = Math.min(eventEnd.getTime(), endOfDay.getTime()); + + const duration = overlapEnd - overlapStart; + return acc + Math.max(0, duration); + }, 0); + + // Cap at 24 hours (86400000 milliseconds) per day + return Math.min(total, 24 * 60 * 60 * 1000); +} + +export function setDataByType({ + events, + data, + cardType, + barType, +}: { + events: Event[]; + data: StatusData[]; + cardType: "requests" | "duration" | "dominant" | "manual"; + barType: "absolute" | "dominant" | "manual"; +}): UptimeData[] { + return data.map((dayData) => { + const date = new Date(dayData.day); + + // Find events for this day + const dayEvents = events.filter((event) => isDateWithinEvent(date, event)); + + // Determine status override based on events + const incidents = dayEvents.filter((e) => e.type === "incident"); + const reports = dayEvents.filter((e) => e.type === "report"); + const maintenances = dayEvents.filter((e) => e.type === "maintenance"); + + const hasIncidents = incidents.length > 0; + const hasReports = reports.length > 0; + const hasMaintenances = maintenances.length > 0; + + const eventStatus = hasIncidents + ? "error" + : hasReports + ? "degraded" + : hasMaintenances + ? "info" + : undefined; + + // Calculate bar data based on barType + // TODO: transform into a new Map(); + let barData: UptimeData["bar"]; + + const total = dayData.ok + dayData.degraded + dayData.error; + const dataStatus = getHighestPriorityStatus(dayData); + + switch (barType) { + case "absolute": + if (eventStatus) { + // If there's an event override, show single status + barData = [ + { + status: eventStatus, + height: 100, + }, + ]; + } else if (total === 0) { + // Empty day + barData = [ + { + status: "empty", + height: 100, + }, + ]; + } else { + // Multiple segments for absolute view + const segments = [ + { status: "success" as const, count: dayData.ok }, + { status: "degraded" as const, count: dayData.degraded }, + { status: "error" as const, count: dayData.error }, + ] + .filter((segment) => segment.count > 0) + .map((segment) => ({ + status: segment.status, + height: (segment.count / total) * 100, + })); + + barData = segments; + } + break; + case "dominant": + barData = [ + { + status: eventStatus ?? dataStatus, + height: 100, + }, + ]; + break; + case "manual": + const manualEventStatus = hasReports + ? "degraded" + : hasMaintenances + ? "info" + : undefined; + barData = [ + { + status: manualEventStatus || "success", + height: 100, + }, + ]; + break; + default: + // Default to dominant behavior + barData = [ + { + status: eventStatus ?? dataStatus, + height: 100, + }, + ]; + break; + } + + // Calculate card data based on cardType + // TODO: transform into a new Map(); + let cardData: UptimeData["card"] = []; + + switch (cardType) { + case "requests": + if (total === 0) { + cardData = [{ status: eventStatus ?? "empty", value: "1 day" }]; + } else { + const entries = [ + { status: "success" as const, count: dayData.ok }, + { status: "degraded" as const, count: dayData.degraded }, + { status: "error" as const, count: dayData.error }, + { status: "info" as const, count: 0 }, + ]; + + cardData = entries + .filter((entry) => entry.count > 0) + .map((entry) => ({ + status: entry.status, + value: `${formatNumber(entry.count)} reqs`, + })); + } + break; + + case "duration": + if (total === 0) { + cardData = [{ status: eventStatus ?? "empty", value: "1 day" }]; + } else { + const entries = [ + { status: "error" as const, count: dayData.error }, + { status: "degraded" as const, count: dayData.degraded }, + { status: "success" as const, count: dayData.ok }, + { status: "info" as const, count: 0 }, + ]; + + const map = new Map< + "error" | "degraded" | "success" | "info", + number + >(); + + cardData = entries + .map((entry) => { + if (entry.status === "error") { + const totalDuration = getTotalEventsDurationMs(incidents, date); + const minutes = Math.round(totalDuration / (1000 * 60)); + map.set("error", minutes); + if (minutes === 0) return null; + return { + status: entry.status, + value: formatDuration(minutes), + }; + } + + if (entry.status === "degraded") { + const totalDuration = getTotalEventsDurationMs(reports, date); + const minutes = Math.round(totalDuration / (1000 * 60)); + map.set("degraded", minutes); + if (minutes === 0) return null; + return { + status: entry.status, + value: formatDuration(minutes), + }; + } + + if (entry.status === "info") { + const totalDuration = getTotalEventsDurationMs( + maintenances, + date, + ); + const minutes = Math.round(totalDuration / (1000 * 60)); + map.set("info", minutes); + if (minutes === 0) return null; + return { + status: entry.status, + value: formatDuration(minutes), + }; + } + + if (entry.status === "success") { + let total = 0; + // biome-ignore lint/suspicious/noAssignInExpressions: + map.forEach((d) => (total += d)); + const day = 24 * 60; + const minutes = Math.max(day - total, 0); + if (minutes === 0) return null; + return { + status: entry.status, + value: formatDuration(minutes), + }; + } + }) + .filter((item): item is NonNullable => item !== null); + } + break; + + case "dominant": + cardData = [ + { + status: eventStatus ?? dataStatus, + value: "", + }, + ]; + break; + + case "manual": + const manualEventStatus = hasReports + ? "degraded" + : hasMaintenances + ? "info" + : undefined; + cardData = [ + { + status: manualEventStatus || "success", + value: "", + }, + ]; + break; + default: + // Default to requests behavior + if (total === 0) { + cardData = [{ status: eventStatus ?? "empty", value: "1 day" }]; + } else { + const entries = [ + { status: "error" as const, count: dayData.error }, + { status: "degraded" as const, count: dayData.degraded }, + { status: "success" as const, count: dayData.ok }, + ]; + + cardData = entries + .filter((entry) => entry.count > 0) + .map((entry) => ({ + status: entry.status, + value: `${formatNumber(entry.count)} reqs`, + })); + } + break; + } + + return { + day: dayData.day, + events: [...reports, ...maintenances], + bar: barData, + card: cardData, + }; + }); +} + +export function getUptime({ + data, + events, + barType, +}: { + data: StatusData[]; + events: Event[]; + barType: "absolute" | "dominant" | "manual"; +}): string { + if (barType === "manual") { + const duration = events + // NOTE: we want only user events + .filter((e) => e.type === "report") + .reduce((acc, item) => { + if (!item.from) return acc; + return acc + ((item.to || new Date()).getTime() - item.from.getTime()); + }, 0); + + const total = data.length * 24 * 60 * 60 * 1000; + + return `${Math.round(((total - duration) / total) * 10000) / 100}%`; + } + + const { ok, total } = data.reduce( + (acc, item) => ({ + ok: acc.ok + item.ok + item.degraded, + total: acc.total + item.ok + item.degraded + item.error, + }), + { + ok: 0, + total: 0, + }, + ); + + if (total === 0) return "100%"; + return `${Math.round((ok / total) * 10000) / 100}%`; +} diff --git a/packages/api/src/router/tinybird/index.ts b/packages/api/src/router/tinybird/index.ts index a91601f1..c312d5c7 100644 --- a/packages/api/src/router/tinybird/index.ts +++ b/packages/api/src/router/tinybird/index.ts @@ -18,11 +18,11 @@ type Period = (typeof periods)[number]; type Type = (typeof types)[number]; // NEW: workspace-level counters helper -function getWorkspace30dProcedure(type: Type) { +export function getWorkspace30dProcedure(type: Type) { return type === "http" ? tb.httpWorkspace30d : tb.tcpWorkspace30d; } // Helper functions to get the right procedure based on period and type -function getListProcedure(period: Period, type: Type) { +export function getListProcedure(period: Period, type: Type) { switch (period) { case "1d": return type === "http" ? tb.httpListDaily : tb.tcpListDaily; @@ -35,7 +35,7 @@ function getListProcedure(period: Period, type: Type) { } } -function getMetricsProcedure(period: Period, type: Type) { +export function getMetricsProcedure(period: Period, type: Type) { switch (period) { case "1d": return type === "http" ? tb.httpMetricsDaily : tb.tcpMetricsDaily; @@ -48,7 +48,7 @@ function getMetricsProcedure(period: Period, type: Type) { } } -function getMetricsByRegionProcedure(period: Period, type: Type) { +export function getMetricsByRegionProcedure(period: Period, type: Type) { switch (period) { case "1d": return type === "http" @@ -69,7 +69,7 @@ function getMetricsByRegionProcedure(period: Period, type: Type) { } } -function getMetricsByIntervalProcedure(period: Period, type: Type) { +export function getMetricsByIntervalProcedure(period: Period, type: Type) { switch (period) { case "1d": return type === "http" @@ -91,7 +91,7 @@ function getMetricsByIntervalProcedure(period: Period, type: Type) { } // FIXME: tb pipes are deprecated, we need new ones -function getMetricsRegionsProcedure(period: Period, type: Type) { +export function getMetricsRegionsProcedure(period: Period, type: Type) { switch (period) { case "1d": return type === "http" @@ -112,18 +112,11 @@ function getMetricsRegionsProcedure(period: Period, type: Type) { } } -function getStatusProcedure(period: "7d" | "45d", type: Type) { - switch (period) { - case "7d": - return type === "http" ? tb.httpStatusWeekly : tb.tcpStatusWeekly; - case "45d": - return type === "http" ? tb.httpStatus45d : tb.tcpStatus45d; - default: - return type === "http" ? tb.httpStatusWeekly : tb.tcpStatusWeekly; - } +export function getStatusProcedure(_period: "45d", type: Type) { + return type === "http" ? tb.httpStatus45d : tb.tcpStatus45d; } -function getGetProcedure(period: "14d", type: Type) { +export function getGetProcedure(period: "14d", type: Type) { switch (period) { case "14d": return type === "http" ? tb.httpGetBiweekly : tb.tcpGetBiweekly; @@ -132,11 +125,11 @@ function getGetProcedure(period: "14d", type: Type) { } } -function getGlobalMetricsProcedure(type: Type) { +export function getGlobalMetricsProcedure(type: Type) { return type === "http" ? tb.httpGlobalMetricsDaily : tb.tcpGlobalMetricsDaily; } -function getUptimeProcedure(period: "7d" | "30d", type: Type) { +export function getUptimeProcedure(period: "7d" | "30d", type: Type) { switch (period) { case "7d": return type === "http" ? tb.httpUptimeWeekly : tb.tcpUptimeWeekly; @@ -148,11 +141,24 @@ function getUptimeProcedure(period: "7d" | "30d", type: Type) { } // TODO: missing pipes for other periods -function getMetricsLatencyProcedure(_period: Period, type: Type) { - return type === "http" ? tb.httpMetricsLatency1d : tb.tcpMetricsLatency1d; +export function getMetricsLatencyProcedure(_period: Period, type: Type) { + switch (_period) { + case "1d": + return type === "http" ? tb.httpMetricsLatency1d : tb.tcpMetricsLatency1d; + case "7d": + return type === "http" ? tb.httpMetricsLatency7d : tb.tcpMetricsLatency7d; + default: + return type === "http" ? tb.httpMetricsLatency1d : tb.tcpMetricsLatency1d; + } } -function getTimingPhasesProcedure(type: Type) { +export function getMetricsLatencyMultiProcedure(_period: Period, type: Type) { + return type === "http" + ? tb.httpMetricsLatency1dMulti + : tb.tcpMetricsLatency1dMulti; +} + +export function getTimingPhasesProcedure(type: Type) { return type === "http" ? tb.httpTimingPhases14d : null; } @@ -420,8 +426,8 @@ export const tinybirdRouter = createTRPCRouter({ status: protectedProcedure .input( z.object({ - monitorId: z.string(), - period: z.enum(["7d", "45d"]), + monitorIds: z.string().array(), + period: z.enum(["45d"]), type: z.enum(types).default("http"), region: z.enum(flyRegions).optional(), cronTimestamp: z.number().int().optional(), @@ -429,18 +435,18 @@ export const tinybirdRouter = createTRPCRouter({ ) .query(async (opts) => { const whereConditions: SQL[] = [ - eq(monitor.id, Number.parseInt(opts.input.monitorId)), + inArray(monitor.id, opts.input.monitorIds.map(Number)), eq(monitor.workspaceId, opts.ctx.workspace.id), ]; - const _monitor = await db.query.monitor.findFirst({ + const _monitors = await db.query.monitor.findMany({ where: and(...whereConditions), }); - if (!_monitor) { + if (_monitors.length !== opts.input.monitorIds.length) { throw new TRPCError({ code: "NOT_FOUND", - message: "Monitor not found", + message: "Some monitors not found", }); } @@ -556,6 +562,22 @@ export const tinybirdRouter = createTRPCRouter({ return await procedure(opts.input); }), + metricsLatencyMulti: protectedProcedure + .input( + z.object({ + monitorIds: z.string().array(), + period: z.enum(["1d"]).default("1d"), + type: z.enum(types).default("http"), + }), + ) + .query(async (opts) => { + const procedure = getMetricsLatencyMultiProcedure( + opts.input.period, + opts.input.type, + ); + return await procedure(opts.input); + }), + workspace30d: protectedProcedure .input( z.object({ diff --git a/packages/db/src/schema/pages/page.ts b/packages/db/src/schema/pages/page.ts index 94df8d37..8692fa70 100644 --- a/packages/db/src/schema/pages/page.ts +++ b/packages/db/src/schema/pages/page.ts @@ -53,7 +53,7 @@ export const page = sqliteTable("page", { export const pageRelations = relations(page, ({ many, one }) => ({ monitorsToPages: many(monitorsToPages), - maintenancesToPages: many(maintenance), + maintenances: many(maintenance), statusReports: many(statusReport), workspace: one(workspace, { fields: [page.workspaceId], diff --git a/packages/db/src/schema/shared.ts b/packages/db/src/schema/shared.ts index c9225a98..712e3ab7 100644 --- a/packages/db/src/schema/shared.ts +++ b/packages/db/src/schema/shared.ts @@ -39,6 +39,7 @@ export const selectMaintenancePageSchema = selectMaintenanceSchema.extend({ z.object({ monitorId: z.number(), maintenanceId: z.number(), + monitor: selectPublicMonitorSchema, }), ) .default([]), @@ -60,19 +61,67 @@ export const selectPageSchemaWithMonitorsRelation = selectPageSchema.extend({ monitor: selectMonitorSchema, }), ), - maintenancesToPages: selectMaintenanceSchema.array().default([]), + maintenances: selectMaintenanceSchema.array().default([]), statusReports: selectStatusReportSchema .extend({ statusReportUpdates: selectStatusReportUpdateSchema.array() }) .array() .default([]), }); +export const legacy_selectPublicPageSchemaWithRelation = selectPageSchema + .extend({ + monitors: z.array(selectPublicMonitorSchema).default([]), + statusReports: z.array(selectStatusReportPageSchema).default([]), + incidents: z.array(selectIncidentSchema).default([]), + maintenances: z.array(selectMaintenancePageSchema).default([]), + workspacePlan: workspacePlanSchema + .nullable() + .default("free") + .transform((val) => val ?? "free"), + }) + .omit({ + // workspaceId: true, + id: true, + }); + export const selectPublicPageSchemaWithRelation = selectPageSchema .extend({ - monitors: z.array(selectPublicMonitorSchema), + // TODO: include status of the monitor + monitors: selectPublicMonitorSchema + .extend({ + status: z + .enum(["success", "degraded", "error", "info"]) + .default("success"), + }) + .array(), + lastEvents: z.array( + z.object({ + id: z.number(), + name: z.string(), + from: z.date(), + to: z.date().nullable(), + status: z + .enum(["success", "degraded", "error", "info"]) + .default("success"), + type: z.enum(["maintenance", "incident", "report"]), + }), + ), + openEvents: z.array( + z.object({ + id: z.number(), + name: z.string(), + from: z.date(), + to: z.date().nullable(), + status: z + .enum(["success", "degraded", "error", "info"]) + .default("success"), + type: z.enum(["maintenance", "incident", "report"]), + }), + ), statusReports: z.array(selectStatusReportPageSchema), incidents: z.array(selectIncidentSchema), maintenances: z.array(selectMaintenancePageSchema), + status: z.enum(["success", "degraded", "error", "info"]).default("success"), workspacePlan: workspacePlanSchema .nullable() .default("free") @@ -81,6 +130,7 @@ export const selectPublicPageSchemaWithRelation = selectPageSchema .omit({ // workspaceId: true, id: true, + password: true, }); export const selectPublicStatusReportSchemaWithRelation = @@ -101,4 +151,6 @@ export type StatusReportWithUpdates = z.infer< typeof selectStatusReportPageSchema >; export type PublicMonitor = z.infer; -export type PublicPage = z.infer; +export type PublicPage = z.infer< + typeof legacy_selectPublicPageSchemaWithRelation +>; diff --git a/packages/tinybird/datasources/mv__http_status_45d__v1.datasource b/packages/tinybird/datasources/mv__http_status_45d__v1.datasource new file mode 100644 index 00000000..627fae3c --- /dev/null +++ b/packages/tinybird/datasources/mv__http_status_45d__v1.datasource @@ -0,0 +1,14 @@ +# Data Source created from Pipe 'aggregate__http_status_45d__v1' + +SCHEMA > + `time` DateTime('UTC'), + `monitorId` String, + `count` AggregateFunction(count), + `success` AggregateFunction(count, Nullable(UInt8)), + `error` AggregateFunction(count, Nullable(UInt8)), + `degraded` AggregateFunction(count, Nullable(UInt8)) + +ENGINE "AggregatingMergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(46)" diff --git a/packages/tinybird/datasources/mv__tcp_status_45d__v1.datasource b/packages/tinybird/datasources/mv__tcp_status_45d__v1.datasource new file mode 100644 index 00000000..3729876a --- /dev/null +++ b/packages/tinybird/datasources/mv__tcp_status_45d__v1.datasource @@ -0,0 +1,14 @@ +# Data Source created from Pipe 'aggregate__tcp_status_45d__v1' + +SCHEMA > + `time` DateTime('UTC'), + `monitorId` Int32, + `count` AggregateFunction(count), + `success` AggregateFunction(count, Nullable(UInt8)), + `error` AggregateFunction(count, Nullable(UInt8)), + `degraded` AggregateFunction(count, Nullable(UInt8)) + +ENGINE "AggregatingMergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(46)" diff --git a/packages/tinybird/pipes/aggregate__http_status_45d__v1.pipe b/packages/tinybird/pipes/aggregate__http_status_45d__v1.pipe new file mode 100644 index 00000000..3b3905ac --- /dev/null +++ b/packages/tinybird/pipes/aggregate__http_status_45d__v1.pipe @@ -0,0 +1,21 @@ +TAGS "http, statuspage" + +NODE aggregate +SQL > + + SELECT + toStartOfDay(toTimeZone(fromUnixTimestamp64Milli(cronTimestamp), 'UTC')) AS time, + monitorId, + countState() AS count, + countState(if(requestStatus = 'success', 1, NULL)) AS success, + countState(if(requestStatus = 'error', 1, NULL)) AS error, + countState(if(requestStatus = 'degraded', 1, NULL)) AS degraded + FROM ping_response__v8 + GROUP BY + time, + monitorId + +TYPE materialized +DATASOURCE mv__http_status_45d__v1 + + diff --git a/packages/tinybird/pipes/aggregate__tcp_status_45d__v1.pipe b/packages/tinybird/pipes/aggregate__tcp_status_45d__v1.pipe new file mode 100644 index 00000000..2b2107c8 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__tcp_status_45d__v1.pipe @@ -0,0 +1,21 @@ +TAGS "tcp, statuspage" + +NODE aggregate +SQL > + + SELECT + toStartOfDay(toTimeZone(fromUnixTimestamp64Milli(cronTimestamp), 'UTC')) AS time, + monitorId, + countState() AS count, + countState(if(requestStatus = 'success', 1, NULL)) AS success, + countState(if(requestStatus = 'error', 1, NULL)) AS error, + countState(if(requestStatus = 'degraded', 1, NULL)) AS degraded + FROM tcp_response__v0 + GROUP BY + time, + monitorId + +TYPE materialized +DATASOURCE mv__tcp_status_45d__v1 + + diff --git a/packages/tinybird/pipes/endpoint__http_metrics_latency_1d_multi__v1.pipe b/packages/tinybird/pipes/endpoint__http_metrics_latency_1d_multi__v1.pipe new file mode 100644 index 00000000..f74f0ea5 --- /dev/null +++ b/packages/tinybird/pipes/endpoint__http_metrics_latency_1d_multi__v1.pipe @@ -0,0 +1,24 @@ +TAGS "http" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + monitorId, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__http_1d__v0 + WHERE + monitorId IN {{ Array(monitorIds, 'String', '1,666') }} + GROUP BY h, monitorId + ORDER BY h DESC + + diff --git a/packages/tinybird/pipes/endpoint__http_metrics_latency_7d__v1.pipe b/packages/tinybird/pipes/endpoint__http_metrics_latency_7d__v1.pipe new file mode 100644 index 00000000..2cd927ee --- /dev/null +++ b/packages/tinybird/pipes/endpoint__http_metrics_latency_7d__v1.pipe @@ -0,0 +1,23 @@ +TAGS "tcp" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__http_7d__v0 + WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + GROUP BY h + ORDER BY h DESC + + diff --git a/packages/tinybird/pipes/endpoint__http_status_45d__v1.pipe b/packages/tinybird/pipes/endpoint__http_status_45d__v1.pipe new file mode 100644 index 00000000..6a73ac5f --- /dev/null +++ b/packages/tinybird/pipes/endpoint__http_status_45d__v1.pipe @@ -0,0 +1,19 @@ +TAGS "http" + +NODE endpoint +SQL > + + % + SELECT + time as day, + monitorId, + countMerge(count) as count, + countMerge(success) as ok, + countMerge(error) as error, + countMerge(degraded) as degraded + FROM mv__http_status_45d__v1 + WHERE monitorId IN {{ Array(monitorIds, 'String', '1,666') }} + GROUP BY day, monitorId + ORDER BY day DESC + + diff --git a/packages/tinybird/pipes/endpoint__tcp_metrics_latency_1d_multi__v1.pipe b/packages/tinybird/pipes/endpoint__tcp_metrics_latency_1d_multi__v1.pipe new file mode 100644 index 00000000..791d9897 --- /dev/null +++ b/packages/tinybird/pipes/endpoint__tcp_metrics_latency_1d_multi__v1.pipe @@ -0,0 +1,24 @@ +TAGS "tcp" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + monitorId, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__tcp_1d__v0 + WHERE + monitorId IN {{ Array(monitorIds, 'String', '4433') }} + GROUP BY h, monitorId + ORDER BY h DESC + + diff --git a/packages/tinybird/pipes/endpoint__tcp_metrics_latency_7d__v1.pipe b/packages/tinybird/pipes/endpoint__tcp_metrics_latency_7d__v1.pipe new file mode 100644 index 00000000..108955e7 --- /dev/null +++ b/packages/tinybird/pipes/endpoint__tcp_metrics_latency_7d__v1.pipe @@ -0,0 +1,23 @@ +TAGS "tcp" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__tcp_7d__v1 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + GROUP BY h + ORDER BY h DESC + + diff --git a/packages/tinybird/pipes/endpoint__tcp_status_45d__v1.pipe b/packages/tinybird/pipes/endpoint__tcp_status_45d__v1.pipe new file mode 100644 index 00000000..846d52a4 --- /dev/null +++ b/packages/tinybird/pipes/endpoint__tcp_status_45d__v1.pipe @@ -0,0 +1,19 @@ +TAGS "tcp" + +NODE endpoint +SQL > + + % + SELECT + time as day, + monitorId, + countMerge(count) as count, + countMerge(success) as ok, + countMerge(error) as error, + countMerge(degraded) as degraded + FROM mv__tcp_status_45d__v1 + WHERE monitorId IN {{ Array(monitorIds, 'String', '4433') }} + GROUP BY day, monitorId + ORDER BY day DESC + + diff --git a/packages/tinybird/src/client.ts b/packages/tinybird/src/client.ts index af130f3d..e210e981 100644 --- a/packages/tinybird/src/client.ts +++ b/packages/tinybird/src/client.ts @@ -16,12 +16,12 @@ export class OSTinybird { private readonly tb: Client; constructor(token: string) { - // this.tb = new Client({ token }); if (process.env.NODE_ENV === "development") { this.tb = new NoopTinybird(); } else { this.tb = new Client({ token }); } + // this.tb = new Client({ token }); } public get homeStats() { @@ -459,7 +459,7 @@ export class OSTinybird { }); } - public get httpStatus45d() { + public get legacy_httpStatus45d() { return this.tb.buildPipe({ pipe: "endpoint__http_status_45d__v0", parameters: z.object({ @@ -482,6 +482,27 @@ export class OSTinybird { }); } + public get httpStatus45d() { + return this.tb.buildPipe({ + pipe: "endpoint__http_status_45d__v1", + parameters: z.object({ + monitorIds: z.string().array(), + }), + data: z.object({ + day: z.string().transform((val) => { + // That's a hack because clickhouse return the date in UTC but in shitty format (2021-09-01 00:00:00) + return new Date(`${val} GMT`).toISOString(); + }), + count: z.number().default(0), + ok: z.number().default(0), + degraded: z.number().default(0), + error: z.number().default(0), + monitorId: z.string(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + public get httpGetBiweekly() { return this.tb.buildPipe({ pipe: "endpoint__http_get_14d__v0", @@ -987,7 +1008,7 @@ export class OSTinybird { }); } - public get tcpStatus45d() { + public get legacy_tcpStatus45d() { return this.tb.buildPipe({ pipe: "endpoint__tcp_status_45d__v0", parameters: z.object({ @@ -1010,6 +1031,32 @@ export class OSTinybird { }); } + public get tcpStatus45d() { + return this.tb.buildPipe({ + pipe: "endpoint__tcp_status_45d__v1", + parameters: z.object({ + monitorIds: z.string().array(), + days: z.number().int().max(45).optional(), + }), + data: z.object({ + day: z.string().transform((val) => { + // That's a hack because clickhouse return the date in UTC but in shitty format (2021-09-01 00:00:00) + return new Date(`${val} GMT`).toISOString(); + }), + count: z.number().default(0), + ok: z.number().default(0), + degraded: z.number().default(0), + error: z.number().default(0), + monitorId: z.coerce.string(), + }), + opts: { + next: { + revalidate: PUBLIC_CACHE, + }, + }, + }); + } + public get httpWorkspace30d() { return this.tb.buildPipe({ pipe: "endpoint__http_workspace_30d__v0", @@ -1360,6 +1407,42 @@ export class OSTinybird { }); } + public get httpMetricsLatency7d() { + return this.tb.buildPipe({ + pipe: "endpoint__http_metrics_latency_7d__v1", + parameters: z.object({ + monitorId: z.string(), + }), + data: z.object({ + timestamp: z.number().int(), + p50Latency: z.number().int(), + p75Latency: z.number().int(), + p90Latency: z.number().int(), + p95Latency: z.number().int(), + p99Latency: z.number().int(), + }), + }); + } + + public get httpMetricsLatency1dMulti() { + return this.tb.buildPipe({ + pipe: "endpoint__http_metrics_latency_1d_multi__v1", + parameters: z.object({ + monitorIds: z.string().array().min(1), + }), + data: z.object({ + timestamp: z.number().int(), + monitorId: z.string(), + p50Latency: z.number().int(), + p75Latency: z.number().int(), + p90Latency: z.number().int(), + p95Latency: z.number().int(), + p99Latency: z.number().int(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + public get tcpMetricsLatency1d() { return this.tb.buildPipe({ pipe: "endpoint__tcp_metrics_latency_1d__v1", @@ -1377,4 +1460,40 @@ export class OSTinybird { }), }); } + + public get tcpMetricsLatency7d() { + return this.tb.buildPipe({ + pipe: "endpoint__tcp_metrics_latency_7d__v1", + parameters: z.object({ + monitorId: z.string(), + }), + data: z.object({ + timestamp: z.number().int(), + p50Latency: z.number().int(), + p75Latency: z.number().int(), + p90Latency: z.number().int(), + p95Latency: z.number().int(), + p99Latency: z.number().int(), + }), + }); + } + + public get tcpMetricsLatency1dMulti() { + return this.tb.buildPipe({ + pipe: "endpoint__tcp_metrics_latency_1d_multi__v1", + parameters: z.object({ + monitorIds: z.string().array().min(1), + }), + data: z.object({ + timestamp: z.number().int(), + monitorId: z.coerce.string(), + p50Latency: z.number().int(), + p75Latency: z.number().int(), + p90Latency: z.number().int(), + p95Latency: z.number().int(), + p99Latency: z.number().int(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } }