Openstatus www.openstatus.dev

feat: tracker class (#689)

* feat: tracker class

* refactor: naming and tracker

* fix: small stuff

* fix: empty data bar

* fix: missing data info

authored by

Maximilian Kaske and committed by
GitHub
acd61eb5 a016bdf8

+615 -418
+1
apps/server/package.json
··· 21 21 "@openstatus/notification-twillio-sms": "workspace:*", 22 22 "@openstatus/plans": "workspace:*", 23 23 "@openstatus/tinybird": "workspace:*", 24 + "@openstatus/tracker": "workspace:*", 24 25 "@openstatus/upstash": "workspace:*", 25 26 "@openstatus/utils": "workspace:*", 26 27 "@t3-oss/env-core": "0.7.1",
+8 -22
apps/server/src/public/status.ts
··· 11 11 pagesToStatusReports, 12 12 statusReport, 13 13 } from "@openstatus/db/src/schema"; 14 + import { Status, Tracker } from "@openstatus/tracker"; 14 15 import { Redis } from "@openstatus/upstash"; 15 16 16 17 import { notEmpty } from "../utils/not-empty"; ··· 19 20 20 21 const redis = Redis.fromEnv(); 21 22 22 - enum Status { 23 - Operational = "operational", 24 - DegradedPerformance = "degraded_performance", 25 - PartialOutage = "partial_outage", // not used 26 - MajorOutage = "major_outage", // not used 27 - UnderMaintenance = "under_maintenance", // not used 28 - Unknown = "unknown", 29 - Incident = "incident", 30 - } 31 - 32 23 export const status = new Hono(); 33 24 34 25 status.get("/:slug", async (c) => { ··· 49 40 .where(eq(page.slug, slug)) 50 41 .get(); 51 42 52 - console.log(currentPage); 53 43 if (!currentPage) { 54 44 return c.json({ status: Status.Unknown }); 55 45 } ··· 58 48 await getStatusPageData(currentPage.id); 59 49 endTime(c, "database"); 60 50 61 - const isStatusReport = [ 51 + const statusReports = [ 62 52 ...pageStatusReportData, 63 53 ...monitorStatusReportData, 64 - ].some((data) => { 65 - if (!data.status_report) return false; 66 - return !["monitoring", "resolved"].includes(data.status_report.status); 54 + ].map((item) => { 55 + return item.status_report; 67 56 }); 68 - function getStatus() { 69 - if (isStatusReport) return Status.Incident; 70 - // if (monitorData.length === 0) return Status.Unknown; 71 - if (ongoingIncidents.length > 0) return Status.DegradedPerformance; 72 - return Status.Operational; 73 - } 57 + 58 + const tracker = new Tracker({ incidents: ongoingIncidents, statusReports }); 74 59 75 - const status = getStatus(); 60 + const status = tracker.currentStatus; 76 61 await redis.set(slug, status, { ex: 60 }); // 1m cache 62 + 77 63 return c.json({ status }); 78 64 }); 79 65
+1
apps/web/package.json
··· 24 24 "@openstatus/plans": "workspace:*", 25 25 "@openstatus/react": "workspace:*", 26 26 "@openstatus/tinybird": "workspace:*", 27 + "@openstatus/tracker": "workspace:*", 27 28 "@openstatus/ui": "workspace:*", 28 29 "@openstatus/upstash": "workspace:*", 29 30 "@openstatus/utils": "workspace:*",
+17 -26
apps/web/src/app/api/og/_components/status-check.tsx
··· 1 - import type { StatusVariant } from "@/lib/tracker"; 1 + import type { Tracker } from "@openstatus/tracker"; 2 + 2 3 import { cn } from "@/lib/utils"; 3 4 4 - export function StatusCheck({ variant }: { variant: StatusVariant }) { 5 + export function StatusCheck({ tracker }: { tracker: Tracker }) { 6 + const details = tracker.currentDetails; 7 + const className = tracker.currentClassName; 8 + 9 + // FIXME: move icons into @openstatus/tracker lib 5 10 function getVariant() { 6 - switch (variant) { 11 + switch (details.variant) { 7 12 case "down": 8 - return { 9 - color: "bg-rose-500", 10 - label: "Major Outage", 11 - icon: Minus, 12 - }; 13 + return Minus; 13 14 case "degraded": 14 - return { 15 - color: "bg-amber-500", 16 - label: "Systems Degraded", 17 - icon: Minus, 18 - }; 15 + return Minus; 19 16 case "incident": 20 - return { 21 - color: "bg-amber-500", 22 - label: "Incident Ongoing", 23 - icon: Alert, 24 - }; 17 + return Alert; 25 18 default: 26 - return { 27 - color: "bg-green-500", 28 - label: "All Systems Operational", 29 - icon: Check, 30 - }; 19 + return Check; 31 20 } 32 21 } 33 22 34 - const { icon, color, label } = getVariant(); 23 + const Icon = getVariant(); 35 24 36 25 return ( 37 26 <div tw="flex flex-col justify-center items-center gap-2 w-full"> 38 - <div tw={cn("flex text-white rounded-full p-3", color)}>{icon()}</div> 27 + <div tw={cn("flex text-white rounded-full p-3 border-2", className)}> 28 + <Icon /> 29 + </div> 39 30 <p style={{ fontFamily: "Cal" }} tw="text-4xl"> 40 - {label} 31 + {details.long} 41 32 </p> 42 33 </div> 43 34 );
+8 -15
apps/web/src/app/api/og/_components/tracker.tsx
··· 1 1 import type { Monitor } from "@openstatus/tinybird"; 2 + import { classNames, Tracker as OSTracker } from "@openstatus/tracker"; 2 3 3 - import { 4 - addBlackListInfo, 5 - getStatusByRatio, 6 - getTotalUptimeString, 7 - } from "@/lib/tracker"; 8 4 import { cn, formatDate } from "@/lib/utils"; 9 5 10 6 export function Tracker({ data }: { data: Monitor[] }) { 11 - const _data = addBlackListInfo(data); 12 - const uptime = getTotalUptimeString(data); 7 + const tracker = new OSTracker({ data }); 13 8 14 9 return ( 15 10 <div tw="flex flex-col w-full my-12"> 16 11 <div tw="flex flex-col mx-auto"> 17 12 <div tw="flex flex-row items-center justify-between -mb-1 text-black font-light"> 18 13 <p></p> 19 - <p tw="font-medium">{uptime}</p> 14 + <p tw="font-medium">{tracker.totalUptime}%</p> 20 15 </div> 21 16 {/* Empty State */} 22 17 <div tw="flex flex-row relative"> ··· 24 19 return <div key={i} tw="h-16 w-3 rounded-full mr-1 bg-black/20" />; 25 20 })} 26 21 <div tw="flex flex-row-reverse absolute left-0"> 27 - {_data.map((item, i) => { 28 - const { variant } = getStatusByRatio(item.ok / item.count); 22 + {tracker.days.map((item, i) => { 29 23 const isBlackListed = Boolean(item.blacklist); 30 24 if (isBlackListed) { 31 25 return ( ··· 35 29 return ( 36 30 <div 37 31 key={i} 38 - tw={cn("h-16 w-3 rounded-full mr-1", { 39 - "bg-green-500": variant === "up", 40 - "bg-red-500": variant === "down", 41 - "bg-amber-500": variant === "degraded", 42 - })} 32 + tw={cn( 33 + "h-16 w-3 rounded-full mr-1", 34 + classNames[item.variant], 35 + )} 43 36 /> 44 37 ); 45 38 })}
+8 -12
apps/web/src/app/api/og/page/route.tsx
··· 1 1 import { ImageResponse } from "next/og"; 2 2 3 + import { Tracker } from "@openstatus/tracker"; 4 + 3 5 import { DESCRIPTION, TITLE } from "@/app/shared-metadata"; 4 - import { getStatusByRatio, incidentStatus } from "@/lib/tracker"; 5 6 import { api } from "@/trpc/server"; 6 7 import { BasicLayout } from "../_components/basic-layout"; 7 8 import { StatusCheck } from "../_components/status-check"; ··· 23 24 const title = page ? page.title : TITLE; 24 25 const description = page ? "" : DESCRIPTION; 25 26 26 - const isStatusReport = page?.statusReports.some( 27 - (incident) => !["monitoring", "resolved"].includes(incident.status), 28 - ); 27 + const tracker = new Tracker({ 28 + incidents: page?.incidents, 29 + statusReports: page?.statusReports, 30 + }); 29 31 30 - const isIncident = page?.incidents.some( 31 - (incident) => incident.resolvedAt === null, 32 - ); 33 - 34 - const status = isStatusReport 35 - ? incidentStatus 36 - : getStatusByRatio(isIncident ? 0.5 : 1); 32 + // const status = tracker.currentStatus; 37 33 38 34 return new ImageResponse( 39 35 ( 40 36 <BasicLayout title={title} description={description} tw="py-24 px-24"> 41 - <StatusCheck variant={status.variant} /> 37 + <StatusCheck tracker={tracker} /> 42 38 </BasicLayout> 43 39 ), 44 40 {
+3 -3
apps/web/src/app/app/[workspaceSlug]/(dashboard)/incidents/(overview)/page.tsx
··· 6 6 import { api } from "@/trpc/server"; 7 7 8 8 export default async function IncidentPage() { 9 - const incidents = await api.incident.getAllIncidents.query(); 9 + const incidents = await api.incident.getIncidentsByWorkspace.query(); 10 10 11 11 if (incidents?.length === 0) 12 12 return ( 13 13 <EmptyState 14 - icon="activity" 15 - title="No Incidents" 14 + icon="siren" 15 + title="No incidents" 16 16 description="Hopefully you will see this screen for a long time." 17 17 action={undefined} 18 18 />
+8 -2
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/(overview)/page.tsx
··· 31 31 32 32 const gmt = convertTimezoneToGMT(); 33 33 34 + const _incidents = await api.incident.getIncidentsByWorkspace.query(); 35 + 34 36 // maybe not very efficient? 35 37 // use Suspense and Client call instead? 36 38 const monitorsWithData = await Promise.all( ··· 41 43 interval: 24, 42 44 }); 43 45 44 - const tracker = await getMonitorListData({ 46 + const data = await getMonitorListData({ 45 47 monitorId: String(monitor.id), 46 48 url: monitor.url, 47 49 timezone: gmt, ··· 51 53 ? metrics.sort((a, b) => (a.time - b.time < 0 ? 1 : -1)) 52 54 : [undefined]; 53 55 54 - return { monitor, metrics: current, tracker }; 56 + const incidents = _incidents.filter( 57 + (incident) => incident.monitorId === monitor.id, 58 + ); 59 + 60 + return { monitor, metrics: current, data, incidents }; 55 61 }), 56 62 ); 57 63
+5 -2
apps/web/src/app/status-page/[domain]/incidents/page.tsx
··· 1 1 import { notFound } from "next/navigation"; 2 2 3 3 import { Header } from "@/components/dashboard/header"; 4 - import { IncidentList } from "@/components/status-page/incident-list"; 4 + import { StatusReportList } from "@/components/status-page/status-report-list"; 5 5 import { api } from "@/trpc/server"; 6 6 7 7 type Props = { ··· 22 22 description={page.description} 23 23 className="text-left" 24 24 /> 25 - <IncidentList incidents={page.statusReports} monitors={page.monitors} /> 25 + <StatusReportList 26 + statusReports={page.statusReports} 27 + monitors={page.monitors} 28 + /> 26 29 </div> 27 30 ); 28 31 }
+3 -4
apps/web/src/app/status-page/[domain]/page.tsx
··· 5 5 6 6 import { EmptyState } from "@/components/dashboard/empty-state"; 7 7 import { Header } from "@/components/dashboard/header"; 8 - import { IncidentList } from "@/components/status-page/incident-list"; 9 8 import { MonitorList } from "@/components/status-page/monitor-list"; 10 9 import { StatusCheck } from "@/components/status-page/status-check"; 10 + import { StatusReportList } from "@/components/status-page/status-report-list"; 11 11 import { api } from "@/trpc/server"; 12 12 13 13 const url = ··· 58 58 statusReports={page.statusReports} 59 59 incidents={page.incidents} 60 60 /> 61 - {/* TODO: rename to StatusReportList */} 62 - <IncidentList 63 - incidents={page.statusReports} 61 + <StatusReportList 62 + statusReports={page.statusReports} 64 63 monitors={page.monitors} 65 64 context="latest" 66 65 />
+17 -11
apps/web/src/components/data-table/monitor/columns.tsx
··· 2 2 3 3 import Link from "next/link"; 4 4 import type { ColumnDef } from "@tanstack/react-table"; 5 - import { formatDistanceToNow } from "date-fns"; 5 + import { formatDistanceToNowStrict } from "date-fns"; 6 6 7 - import type { Monitor } from "@openstatus/db/src/schema"; 7 + import type { Incident, Monitor } from "@openstatus/db/src/schema"; 8 8 import type { 9 9 Monitor as MonitorTracker, 10 10 ResponseTimeMetrics, 11 11 } from "@openstatus/tinybird"; 12 + import { Tracker } from "@openstatus/tracker"; 12 13 import { 13 14 Tooltip, 14 15 TooltipContent, ··· 23 24 export const columns: ColumnDef<{ 24 25 monitor: Monitor; 25 26 metrics?: ResponseTimeMetrics; 26 - tracker?: MonitorTracker[]; 27 + data?: MonitorTracker[]; 28 + incidents?: Incident[]; 27 29 }>[] = [ 28 30 { 29 31 accessorKey: "name", ··· 45 47 accessorKey: "tracker", 46 48 header: "Last 7 days", 47 49 cell: ({ row }) => { 48 - const tracker = row.original.tracker?.slice(0, 7).reverse(); 50 + const tracker = new Tracker({ 51 + data: row.original.data?.slice(0, 7).reverse(), 52 + incidents: row.original.incidents, 53 + }); 49 54 return ( 50 55 <div className="flex w-24 gap-1"> 51 - {tracker?.map((tracker) => ( 56 + {tracker.days?.map((tracker) => ( 52 57 <Bar key={tracker.day} className="h-5" {...tracker} /> 53 58 ))} 54 59 </div> ··· 61 66 cell: ({ row }) => { 62 67 const timestamp = row.original.metrics?.lastTimestamp; 63 68 if (timestamp) { 64 - const distance = formatDistanceToNow(new Date(timestamp)); 69 + const distance = formatDistanceToNowStrict(new Date(timestamp), { 70 + addSuffix: true, 71 + }); 65 72 return ( 66 - <Number 67 - value={parseInt(distance.split(" ")[0])} 68 - suffix={`${distance.split(" ")[1]} ago`} 69 - /> 73 + <div className="text-muted-foreground flex max-w-[84px] sm:max-w-none"> 74 + <span className="truncate">{distance}</span> 75 + </div> 70 76 ); 71 77 } 72 78 return <span className="text-muted-foreground">-</span>; ··· 129 135 function Number({ value, suffix }: { value: number; suffix: string }) { 130 136 return ( 131 137 <span className="font-mono"> 132 - {new Intl.NumberFormat("us").format(value).toString()}{" "} 138 + {new Intl.NumberFormat("us").format(value).toString()} 133 139 <span className="text-muted-foreground text-xs font-normal"> 134 140 {suffix} 135 141 </span>
+17 -18
apps/web/src/components/status-page/incident-list.tsx apps/web/src/components/status-page/status-report-list.tsx
··· 3 3 import Link from "next/link"; 4 4 import { useParams } from "next/navigation"; 5 5 import { ChevronRight } from "lucide-react"; 6 - import type { z } from "zod"; 7 6 8 7 import type { 9 - selectPublicMonitorSchema, 10 - selectStatusReportPageSchema, 8 + PublicMonitor, 9 + StatusReportWithUpdates, 11 10 } from "@openstatus/db/src/schema"; 12 11 import { Button, Separator } from "@openstatus/ui"; 13 12 ··· 18 17 19 18 // TODO: change layout - it is too packed with data rn 20 19 21 - export const IncidentList = ({ 22 - incidents, 20 + export const StatusReportList = ({ 21 + statusReports, 23 22 monitors, 24 23 context = "all", 25 24 }: { 26 - incidents: z.infer<typeof selectStatusReportPageSchema>; 27 - monitors: z.infer<typeof selectPublicMonitorSchema>[]; 25 + statusReports: StatusReportWithUpdates[]; 26 + monitors: PublicMonitor[]; 28 27 context?: "all" | "latest"; // latest 7 days 29 28 }) => { 30 29 const params = useParams<{ domain: string }>(); 31 30 const lastWeek = Date.now() - 1000 * 60 * 60 * 24 * 7; 32 31 33 32 function getLastWeeksIncidents() { 34 - return incidents.filter((incident) => { 33 + return statusReports.filter((incident) => { 35 34 return incident.statusReportUpdates.some( 36 35 (update) => update.date.getTime() > lastWeek, 37 36 ); 38 37 }); 39 38 } 40 39 41 - const _incidents = context === "all" ? incidents : getLastWeeksIncidents(); 40 + const reports = context === "all" ? statusReports : getLastWeeksIncidents(); 42 41 43 - _incidents.sort((a, b) => { 42 + reports.sort((a, b) => { 44 43 if (a.updatedAt == undefined) return 1; 45 44 if (b.updatedAt == undefined) return -1; 46 45 return b.updatedAt.getTime() - a.updatedAt.getTime(); ··· 48 47 49 48 return ( 50 49 <> 51 - {_incidents?.length > 0 ? ( 50 + {reports?.length > 0 ? ( 52 51 <div className="grid gap-3"> 53 52 <p className="text-muted-foreground text-sm font-light"> 54 53 {context === "all" ? "All incidents" : "Latest incidents"} 55 54 </p> 56 55 <div className="grid gap-8"> 57 - {_incidents.map((incident) => { 58 - const affectedMonitors = incident.monitorsToStatusReports 56 + {reports.map((report) => { 57 + const affectedMonitors = report.monitorsToStatusReports 59 58 .map(({ monitorId }) => { 60 59 const monitor = monitors.find(({ id }) => monitorId === id); 61 60 return monitor || undefined; 62 61 }) 63 62 .filter(notEmpty); 64 63 return ( 65 - <div key={incident.id} className="group grid gap-4 text-left"> 64 + <div key={report.id} className="group grid gap-4 text-left"> 66 65 <div className="flex items-center gap-1"> 67 - <h3 className="text-xl font-semibold">{incident.title}</h3> 66 + <h3 className="text-xl font-semibold">{report.title}</h3> 68 67 <Button 69 68 variant="ghost" 70 69 size="icon" ··· 72 71 asChild 73 72 > 74 73 <Link 75 - href={setPrefixUrl(`/incidents/${incident.id}`, params)} 74 + href={setPrefixUrl(`/incidents/${report.id}`, params)} 76 75 > 77 76 <ChevronRight className="h-4 w-4" /> 78 77 </Link> 79 78 </Button> 80 79 </div> 81 - <Summary report={incident} monitors={affectedMonitors} /> 80 + <Summary report={report} monitors={affectedMonitors} /> 82 81 <Separator /> 83 82 <Events 84 - statusReportUpdates={incident.statusReportUpdates} 83 + statusReportUpdates={report.statusReportUpdates} 85 84 collabsible 86 85 /> 87 86 </div>
+4 -4
apps/web/src/components/status-page/monitor-list.tsx
··· 1 1 import type { z } from "zod"; 2 2 3 3 import type { 4 - selectIncidentPageSchema, 5 - selectPublicMonitorSchema, 4 + Incident, 5 + PublicMonitor, 6 6 selectPublicStatusReportSchemaWithRelation, 7 7 } from "@openstatus/db/src/schema"; 8 8 ··· 13 13 statusReports, 14 14 incidents, 15 15 }: { 16 - monitors: z.infer<typeof selectPublicMonitorSchema>[]; 16 + monitors: PublicMonitor[]; 17 17 statusReports: z.infer<typeof selectPublicStatusReportSchemaWithRelation>[]; 18 - incidents: z.infer<typeof selectIncidentPageSchema>; 18 + incidents: Incident[]; 19 19 }) => { 20 20 return ( 21 21 <div className="grid gap-4">
+4 -4
apps/web/src/components/status-page/monitor.tsx
··· 1 1 import type { z } from "zod"; 2 2 3 3 import type { 4 - selectIncidentPageSchema, 5 - selectPublicMonitorSchema, 4 + Incident, 5 + PublicMonitor, 6 6 selectPublicStatusReportSchemaWithRelation, 7 7 } from "@openstatus/db/src/schema"; 8 8 ··· 15 15 statusReports, 16 16 incidents, 17 17 }: { 18 - monitor: z.infer<typeof selectPublicMonitorSchema>; 18 + monitor: PublicMonitor; 19 19 statusReports: z.infer<typeof selectPublicStatusReportSchemaWithRelation>[]; 20 - incidents: z.infer<typeof selectIncidentPageSchema>; 20 + incidents: Incident[]; 21 21 }) => { 22 22 const gmt = convertTimezoneToGMT(); 23 23 const data = await getMonitorListData({
+16 -49
apps/web/src/components/status-page/status-check.tsx
··· 1 - import { cva } from "class-variance-authority"; 2 - import type { z } from "zod"; 1 + import type { Incident, StatusReport } from "@openstatus/db/src/schema"; 2 + import type { StatusVariant } from "@openstatus/tracker"; 3 + import { Tracker } from "@openstatus/tracker"; 3 4 4 - import type { 5 - selectIncidentPageSchema, 6 - selectStatusReportPageSchema, 7 - } from "@openstatus/db/src/schema"; 8 - 9 - import { getStatusByRatio, incidentStatus } from "@/lib/tracker"; 10 - import type { StatusVariant } from "@/lib/tracker"; 11 5 import { cn } from "@/lib/utils"; 12 6 import { Icons } from "../icons"; 13 7 14 - const check = cva("rounded-full border p-1.5", { 15 - variants: { 16 - variant: { 17 - up: "bg-green-500/80 border-green-500", 18 - down: "bg-rose-500/80 border-rose-500", 19 - degraded: "bg-amber-500/80 border-amber-500", 20 - empty: "bg-gray-500/80 border-gray-500", 21 - incident: "bg-amber-500/80 border-amber-500", 22 - }, 23 - }, 24 - defaultVariants: { 25 - variant: "up", 26 - }, 27 - }); 28 - 29 8 export async function StatusCheck({ 30 9 statusReports, 31 10 incidents, 32 11 }: { 33 - statusReports: z.infer<typeof selectStatusReportPageSchema>; 34 - incidents: z.infer<typeof selectIncidentPageSchema>; 12 + statusReports: StatusReport[]; 13 + incidents: Incident[]; 35 14 }) { 36 - const isStatusReport = statusReports.some( 37 - (incident) => !["monitoring", "resolved"].includes(incident.status), 38 - ); 39 - const isIncident = incidents.some((incident) => incident.resolvedAt === null); 40 - 41 - // Forcing the status to be either 'degraded' or 'up' 42 - const status = getStatusByRatio(isIncident ? 0.5 : 1); 43 - 44 - const { label, variant } = isStatusReport ? incidentStatus : status; 15 + const tracker = new Tracker({ statusReports, incidents }); 16 + const className = tracker.currentClassName; 17 + const details = tracker.currentDetails; 45 18 46 19 return ( 47 20 <div className="flex flex-col items-center gap-2"> 48 21 <div className="flex items-center gap-3"> 49 - <p className="text-lg font-semibold">{label}</p> 50 - <span className={check({ variant })}> 51 - <StatusIcon variant={variant} /> 22 + <p className="text-lg font-semibold">{details.long}</p> 23 + <span className={cn("rounded-full border p-1.5", className)}> 24 + <StatusIcon variant={details.variant} /> 52 25 </span> 53 26 </div> 54 27 <p className="text-muted-foreground text-xs">Status Check</p> ··· 56 29 ); 57 30 } 58 31 59 - export interface StatusIconProps { 60 - variant: StatusVariant | "incident"; 61 - className?: string; 62 - } 63 - 64 - export function StatusIcon({ variant, className }: StatusIconProps) { 65 - const rootClassName = cn("h-5 w-5 text-background", className); 32 + export function StatusIcon({ variant }: { variant: StatusVariant }) { 66 33 if (variant === "incident") { 67 34 const AlertTriangleIcon = Icons["alert-triangle"]; 68 - return <AlertTriangleIcon className={rootClassName} />; 35 + return <AlertTriangleIcon className="text-background h-5 w-5" />; 69 36 } 70 37 if (variant === "degraded") { 71 - return <Icons.minus className={rootClassName} />; 38 + return <Icons.minus className="text-background h-5 w-5" />; 72 39 } 73 40 if (variant === "down") { 74 - return <Icons.minus className={rootClassName} />; 41 + return <Icons.minus className="text-background h-5 w-5" />; 75 42 } 76 - return <Icons.check className={rootClassName} />; 43 + return <Icons.check className="text-background h-5 w-5" />; 77 44 }
+26 -90
apps/web/src/components/tracker/tracker.tsx
··· 5 5 import { cva } from "class-variance-authority"; 6 6 import { endOfDay, format, formatDuration, startOfDay } from "date-fns"; 7 7 import { ChevronRight, Info } from "lucide-react"; 8 - import type { z } from "zod"; 9 8 10 9 import type { 11 - selectIncidentPageSchema, 10 + Incident, 12 11 StatusReport, 13 12 StatusReportUpdate, 14 13 } from "@openstatus/db/src/schema"; 15 14 import type { Monitor } from "@openstatus/tinybird"; 15 + import { classNames, Tracker as OSTracker } from "@openstatus/tracker"; 16 16 import { 17 17 HoverCard, 18 18 HoverCardContent, ··· 24 24 TooltipTrigger, 25 25 } from "@openstatus/ui"; 26 26 27 - import { 28 - addBlackListInfo, 29 - areDatesEqualByDayMonthYear, 30 - getStatusByRatio, 31 - getTotalUptimeString, 32 - incidentStatus, 33 - } from "@/lib/tracker"; 34 27 import { cn } from "@/lib/utils"; 35 28 36 - // What would be cool is tracker that turn from green to red depending on the number of errors 37 29 const tracker = cva("h-10 rounded-full flex-1", { 38 30 variants: { 39 31 variant: { 40 - up: "bg-green-500/90 data-[state=open]:bg-green-500", 41 - down: "bg-rose-500/90 data-[state=open]:bg-rose-500", 42 - degraded: "bg-amber-500/90 data-[state=open]:bg-amber-500", 43 - empty: "bg-muted-foreground/20 data-[state=open]:bg-muted-foreground/30", 44 32 blacklist: "bg-green-500/80 data-[state=open]:bg-green-500", 45 - incident: "bg-rose-500/90 data-[state=open]:bg-rose-500", 33 + ...classNames, 46 34 }, 47 35 report: { 48 36 0: "", ··· 56 44 }, 57 45 }); 58 46 59 - // FIXME: 60 - type Incidents = z.infer<typeof selectIncidentPageSchema>; 61 - 62 47 interface TrackerProps { 63 48 data: Monitor[]; 64 49 name: string; 65 50 description?: string; 66 51 reports?: (StatusReport & { statusReportUpdates: StatusReportUpdate[] })[]; 67 - incidents?: Incidents; 52 + incidents?: Incident[]; 68 53 } 69 54 70 55 export function Tracker({ ··· 74 59 reports, 75 60 incidents, 76 61 }: TrackerProps) { 77 - const uptime = getTotalUptimeString(data); 78 - const _data = addBlackListInfo(data); 62 + const tracker = new OSTracker({ data, statusReports: reports, incidents }); 63 + const uptime = tracker.totalUptime; 64 + const isMissing = tracker.isDataMissing; 79 65 80 66 return ( 81 67 <div className="flex flex-col gap-1.5"> ··· 95 81 </TooltipProvider> 96 82 ) : null} 97 83 </div> 98 - <p className="text-muted-foreground shrink-0 font-light">{uptime}</p> 84 + {!isMissing ? ( 85 + <p className="text-muted-foreground shrink-0 font-light">{uptime}%</p> 86 + ) : null} 99 87 </div> 100 88 <div className="relative h-full w-full"> 101 89 <div className="flex flex-row-reverse gap-px sm:gap-0.5"> 102 - {_data.map((props, i) => { 103 - const dateReports = reports?.filter((report) => { 104 - const firstStatusReportUpdate = report.statusReportUpdates.sort( 105 - (a, b) => a.date.getTime() - b.date.getTime(), 106 - )?.[0]; 107 - 108 - if (!firstStatusReportUpdate) return false; 109 - 110 - return areDatesEqualByDayMonthYear( 111 - firstStatusReportUpdate.date, 112 - new Date(props.day), 113 - ); 114 - }); 115 - 116 - const dateIncidents = incidents?.filter((incident) => { 117 - const { startedAt, resolvedAt } = incident; 118 - const day = new Date(props.day); 119 - const eod = endOfDay(day); 120 - const sod = startOfDay(day); 121 - 122 - if (!startedAt) return false; // not started 123 - if (!resolvedAt) return true; // still ongoing 124 - 125 - const hasResolvedBeforeStartOfDay = 126 - resolvedAt.getTime() <= sod.getTime(); 127 - 128 - if (hasResolvedBeforeStartOfDay) return false; 129 - 130 - const hasStartedBeforeEndOfDay = 131 - startedAt.getTime() <= eod.getTime(); 132 - 133 - const hasResolvedBeforeEndOfDay = 134 - resolvedAt.getTime() <= eod.getTime(); 135 - 136 - if (hasStartedBeforeEndOfDay || hasResolvedBeforeEndOfDay) 137 - return true; 138 - 139 - if (hasResolvedBeforeEndOfDay) return true; 140 - 141 - return false; 142 - }); 143 - 144 - return ( 145 - <Bar 146 - key={i} 147 - reports={dateReports} 148 - incidents={dateIncidents} 149 - {...props} 150 - /> 151 - ); 90 + {tracker.days.map((props, i) => { 91 + return <Bar key={i} {...props} />; 152 92 })} 153 93 </div> 154 94 </div> 155 95 <div className="text-muted-foreground flex items-center justify-between text-xs font-light"> 156 - <p>{_data.length - 1} days ago</p> 96 + <p>{tracker.days.length - 1} days ago</p> 157 97 <p>Today</p> 158 98 </div> 159 99 </div> 160 100 ); 161 101 } 162 102 163 - type BarProps = Monitor & { blacklist?: string } & Pick< 164 - TrackerProps, 165 - "reports" | "incidents" 166 - > & { 167 - className?: string; 168 - }; 103 + type BarProps = OSTracker["days"][number] & { 104 + className?: string; 105 + }; 169 106 170 107 export const Bar = ({ 171 108 count, 172 109 ok, 173 110 day, 111 + variant, 112 + label, 174 113 blacklist, 175 - reports, 114 + statusReports, 176 115 incidents, 177 116 className, 178 117 }: BarProps) => { 179 118 const [open, setOpen] = React.useState(false); 180 - const status = getStatusByRatio(ok / count); 181 - const isIncident = incidents && incidents.length > 0; 182 - 183 - const { label, variant } = isIncident ? incidentStatus : status; 184 119 185 120 const rootClassName = tracker({ 186 - report: reports && reports.length > 0 ? 30 : undefined, 121 + report: statusReports && statusReports.length > 0 ? 30 : undefined, 187 122 variant: blacklist ? "blacklist" : variant, 188 123 }); 189 124 ··· 212 147 <div className="grid flex-1 gap-1"> 213 148 <div className="flex justify-between gap-8 text-sm"> 214 149 <p className="font-semibold">{label}</p> 215 - <p className="text-muted-foreground"> 150 + <p className="text-muted-foreground flex-shrink-0"> 216 151 {format(new Date(day), "MMM d")} 217 152 </p> 218 153 </div> ··· 226 161 </div> 227 162 </div> 228 163 </div> 229 - {reports && reports.length > 0 ? ( 164 + {statusReports && statusReports.length > 0 ? ( 230 165 <> 231 166 <Separator className="my-1.5" /> 232 - <StatusReportList reports={reports} /> 167 + <StatusReportList reports={statusReports} /> 233 168 </> 234 169 ) : null} 235 170 {incidents && incidents.length > 0 ? ( ··· 268 203 incidents, 269 204 day, 270 205 }: { 271 - incidents: Incidents; 206 + incidents: Incident[]; 272 207 day: string; // TODO: use Date 273 208 }) { 209 + // TODO: MOVE INTO TRACKER CLASS? 274 210 const startOfDayDate = startOfDay(new Date(day)); 275 211 const endOfDayDate = endOfDay(new Date(day)); 276 212 ··· 280 216 if (!startedAt) return 0; 281 217 if (!resolvedAt) 282 218 return ( 283 - endOfDayDate.getTime() - 219 + Math.min(endOfDayDate.getTime(), new Date().getTime()) - 284 220 Math.max(startOfDayDate.getTime(), startedAt.getTime()) 285 221 ); 286 222 return (
-125
apps/web/src/lib/tracker.ts
··· 1 - import type { Monitor, Ping } from "@openstatus/tinybird"; 2 - 3 - export type StatusVariant = "up" | "degraded" | "down" | "empty" | "incident"; 4 - 5 - type GetStatusReturnType = { 6 - label: string; 7 - variant: StatusVariant; 8 - }; 9 - 10 - export const incidentStatus: GetStatusReturnType = { 11 - label: "Incident", 12 - variant: "incident", 13 - }; 14 - 15 - /** 16 - * Get the status of a monitor based on its ratio 17 - * @param ratio 18 - * @returns 19 - */ 20 - export const getStatusByRatio = (ratio: number): GetStatusReturnType => { 21 - if (isNaN(ratio)) 22 - return { 23 - label: "Missing", 24 - variant: "empty", 25 - }; 26 - if (ratio >= 0.98) 27 - return { 28 - label: "Operational", 29 - variant: "up", 30 - }; 31 - if (ratio >= 0.5) 32 - return { 33 - label: "Degraded", 34 - variant: "degraded", 35 - }; 36 - return { 37 - label: "Downtime", 38 - variant: "down", 39 - }; 40 - }; 41 - 42 - // TODO: move into Class component sharing the same `data` 43 - 44 - /** 45 - * equal days - fixes issue with daylight saving 46 - * @param date1 47 - * @param date2 48 - * @returns 49 - */ 50 - export function areDatesEqualByDayMonthYear(date1: Date, date2: Date) { 51 - date1.setDate(date1.getDate()); 52 - date1.setHours(0, 0, 0, 0); 53 - 54 - date2.setDate(date2.getDate()); 55 - date2.setHours(0, 0, 0, 0); 56 - 57 - return date1.toUTCString() === date2.toUTCString(); 58 - } 59 - 60 - export function addBlackListInfo(data: Monitor[]) { 61 - return data.map((monitor) => { 62 - const blacklist = isInBlacklist(new Date(monitor.day).getTime()); 63 - return { ...monitor, blacklist }; 64 - }); 65 - } 66 - 67 - export function getTotalUptime(data: { ok: number; count: number }[]) { 68 - const reducedData = data.reduce( 69 - (prev, curr) => { 70 - prev.ok += curr.ok; 71 - prev.count += curr.count; 72 - return prev; 73 - }, 74 - { 75 - count: 0, 76 - ok: 0, 77 - }, 78 - ); 79 - return reducedData; 80 - } 81 - 82 - export function getTotalUptimeString(data: { ok: number; count: number }[]) { 83 - const reducedData = getTotalUptime(data); 84 - const uptime = (reducedData.ok / reducedData.count) * 100; 85 - 86 - if (isNaN(uptime)) return ""; 87 - 88 - return `${uptime.toFixed(2)}% uptime`; 89 - } 90 - 91 - export function isInBlacklist(timestamp: number) { 92 - const el = Object.keys(blacklistDates).find((date) => 93 - areDatesEqualByDayMonthYear(new Date(date), new Date(timestamp)), 94 - ); 95 - return el ? blacklistDates[el] : undefined; 96 - } 97 - 98 - /** 99 - * Calculate the overall status of a page based on all the monitor data 100 - */ 101 - export function calcStatus(data: Ping[][]) { 102 - const { count, ok } = data.flat(1).reduce( 103 - (prev, curr) => { 104 - if (!curr.statusCode) return prev; // TODO: handle this better 105 - const isOk = curr.statusCode <= 299 && curr.statusCode >= 200; 106 - return { count: prev.count + 1, ok: prev.ok + (isOk ? 1 : 0) }; 107 - }, 108 - { count: 0, ok: 0 }, 109 - ); 110 - const ratio = ok / count; 111 - if (isNaN(ratio)) return getStatusByRatio(1); // outsmart caching issue 112 - return getStatusByRatio(ratio); 113 - } 114 - 115 - /** 116 - * Blacklist dates where we had issues with data collection 117 - */ 118 - export const blacklistDates: Record<string, string> = { 119 - "Fri Aug 25 2023": 120 - "OpenStatus faced issues between 24.08. and 27.08., preventing data collection.", 121 - "Sat Aug 26 2023": 122 - "OpenStatus faced issues between 24.08. and 27.08., preventing data collection.", 123 - "Wed Oct 18 2023": 124 - "OpenStatus migrated from Vercel to Fly to improve the performance of the checker.", 125 - };
+1
apps/web/tailwind.config.ts
··· 14 14 // our vercel integration 15 15 "../../packages/integrations/**/*.{ts,tsx}", 16 16 "../../packages/ui/**/*.{ts,tsx}", 17 + "../../packages/tracker/**/*.{ts,tsx}", 17 18 "./node_modules/@openstatus/react/**/*.{js,ts,jsx,tsx}", 18 19 ], 19 20 theme: {
+2 -1
packages/api/src/router/incident.ts
··· 6 6 import { createTRPCRouter, protectedProcedure } from "../trpc"; 7 7 8 8 export const incidentRouter = createTRPCRouter({ 9 - getAllIncidents: protectedProcedure 9 + // TODO: rename getIncidentsByWorkspace to make it consistent with the other methods 10 + getIncidentsByWorkspace: protectedProcedure 10 11 .output(z.array(selectIncidentSchema)) 11 12 .query(async (opts) => { 12 13 const result = await opts.ctx.db
+21 -30
packages/db/src/schema/shared.ts
··· 18 18 method: true, 19 19 }); 20 20 21 - export const selectStatusReportPageSchema = z.array( 22 - selectStatusReportSchema.extend({ 23 - statusReportUpdates: z.array(selectStatusReportUpdateSchema).default([]), 24 - monitorsToStatusReports: z 25 - .array( 26 - z.object({ 27 - monitorId: z.number(), 28 - statusReportId: z.number(), 29 - monitor: selectPublicMonitorSchema, 30 - }), 31 - ) 32 - .default([]), 33 - }), 34 - ); 21 + export const selectStatusReportPageSchema = selectStatusReportSchema.extend({ 22 + statusReportUpdates: z.array(selectStatusReportUpdateSchema).default([]), 23 + monitorsToStatusReports: z 24 + .array( 25 + z.object({ 26 + monitorId: z.number(), 27 + statusReportId: z.number(), 28 + monitor: selectPublicMonitorSchema, 29 + }), 30 + ) 31 + .default([]), 32 + }); 33 + 35 34 export const selectPageSchemaWithRelation = selectPageSchema.extend({ 36 35 monitors: z.array(selectMonitorSchema), 37 - statusReports: selectStatusReportPageSchema, 36 + statusReports: z.array(selectStatusReportPageSchema), 38 37 }); 39 38 40 - export const selectIncidentPageSchema = z 41 - .array( 42 - selectIncidentSchema.pick({ 43 - id: true, 44 - monitorId: true, 45 - status: true, 46 - startedAt: true, 47 - acknowledgedAt: true, 48 - resolvedAt: true, 49 - }), 50 - ) 51 - .default([]); 52 - 53 39 export const selectPublicPageSchemaWithRelation = selectPageSchema 54 40 .extend({ 55 41 monitors: z.array(selectPublicMonitorSchema), 56 - statusReports: selectStatusReportPageSchema, 57 - incidents: selectIncidentPageSchema, 42 + statusReports: z.array(selectStatusReportPageSchema), 43 + incidents: z.array(selectIncidentSchema), 58 44 workspacePlan: workspacePlanSchema 59 45 .nullable() 60 46 .default("free") ··· 72 58 .default([]), 73 59 statusReportUpdates: z.array(selectStatusReportUpdateSchema), 74 60 }); 61 + 62 + export type StatusReportWithUpdates = z.infer< 63 + typeof selectStatusReportPageSchema 64 + >; 65 + export type PublicMonitor = z.infer<typeof selectPublicMonitorSchema>;
+2
packages/tinybird/src/client.ts
··· 28 28 event: tbIngestPingResponse, 29 29 }); 30 30 31 + // TODO: add longer cache for NODE_ENV === "development" 32 + 31 33 export function getResponseList(tb: Tinybird) { 32 34 return tb.buildPipe({ 33 35 pipe: "response_list__v2",
+9
packages/tracker/README.md
··· 1 + TODO: Update the different component/files to use the package as source of 2 + truth! 3 + 4 + - [x] public/status 5 + - [ ] package/react dev deps 6 + - [x] `status-check` on status page 7 + - [x] tracker `bar` on status page 8 + - [x] og image api `status-check` 9 + - [x] monitor (overview) dasboard
+19
packages/tracker/package.json
··· 1 + { 2 + "name": "@openstatus/tracker", 3 + "version": "1.0.0", 4 + "description": "", 5 + "main": "src/index.ts", 6 + "scripts": {}, 7 + "dependencies": { 8 + "@openstatus/db": "workspace:*", 9 + "@openstatus/tinybird": "workspace:*", 10 + "zod": "3.22.2" 11 + }, 12 + "devDependencies": { 13 + "@openstatus/tsconfig": "workspace:*", 14 + "typescript": "5.2.2" 15 + }, 16 + "keywords": [], 17 + "author": "", 18 + "license": "ISC" 19 + }
+20
packages/tracker/src/blacklist.ts
··· 1 + import { isSameDay } from "./utils"; 2 + 3 + /** 4 + * Blacklist dates where we had issues with data collection 5 + */ 6 + export const blacklistDates: Record<string, string> = { 7 + "Fri Aug 25 2023": 8 + "OpenStatus faced issues between 24.08. and 27.08., preventing data collection.", 9 + "Sat Aug 26 2023": 10 + "OpenStatus faced issues between 24.08. and 27.08., preventing data collection.", 11 + "Wed Oct 18 2023": 12 + "OpenStatus migrated from Vercel to Fly to improve the performance of the checker.", 13 + }; 14 + 15 + export function isInBlacklist(day: Date) { 16 + const el = Object.keys(blacklistDates).find((date) => 17 + isSameDay(new Date(date), day), 18 + ); 19 + return el ? blacklistDates[el] : undefined; 20 + }
+49
packages/tracker/src/config.ts
··· 1 + import type { StatusDetails, StatusVariant } from "./types"; 2 + import { Status } from "./types"; 3 + 4 + export const statusDetails: Record<Status, StatusDetails> = { 5 + [Status.Operational]: { 6 + long: "All Systems Operational", 7 + short: "Operational", 8 + variant: "up", 9 + }, 10 + [Status.DegradedPerformance]: { 11 + long: "Degraded Performance", 12 + short: "Degraded", 13 + variant: "degraded", 14 + }, 15 + [Status.PartialOutage]: { 16 + long: "Partial Outage", 17 + short: "Outage", 18 + variant: "down", 19 + }, 20 + [Status.MajorOutage]: { 21 + long: "Major Outage", 22 + short: "Outage", 23 + variant: "down", 24 + }, 25 + [Status.UnderMaintenance]: { 26 + long: "Under Maintenance", 27 + short: "Maintenance", 28 + variant: "empty", 29 + }, 30 + [Status.Unknown]: { 31 + long: "Unknown", 32 + short: "Unknown", 33 + variant: "empty", 34 + }, 35 + [Status.Incident]: { 36 + long: "Downtime", 37 + short: "Downtime", 38 + variant: "incident", 39 + }, 40 + }; 41 + 42 + // REMINDER: add `@openstatus/tracker/src/**/*.ts into tailwindcss content prop */ 43 + export const classNames: Record<StatusVariant, string> = { 44 + up: "bg-green-500/90 data-[state=open]:bg-green-500 border-green-500", 45 + degraded: "bg-amber-500/90 data-[state=open]:bg-amber-500 border-amber-500", 46 + down: "bg-red-500/90 data-[state=open]:bg-red-500 border-red-500", 47 + empty: "bg-muted-foreground/20 data-[state=open]:bg-muted-foreground/30", 48 + incident: "bg-red-500/90 data-[state=open]:bg-red-500 border-red-500", 49 + };
+3
packages/tracker/src/index.ts
··· 1 + export * from "./tracker"; 2 + export * from "./types"; 3 + export * from "./config";
+55
packages/tracker/src/mock.ts
··· 1 + import type { Monitor } from "@openstatus/tinybird"; 2 + 3 + import { Tracker } from "./tracker"; 4 + 5 + export const mockMonitor: Monitor[] = [ 6 + { day: "2024-02-21 00:00:00", count: 762, ok: 762 }, 7 + { day: "2024-02-20 00:00:00", count: 864, ok: 864 }, 8 + { day: "2024-02-19 00:00:00", count: 864, ok: 864 }, 9 + { day: "2024-02-18 00:00:00", count: 834, ok: 834 }, 10 + { day: "2024-02-17 00:00:00", count: 864, ok: 864 }, 11 + { day: "2024-02-16 00:00:00", count: 863, ok: 863 }, 12 + { day: "2024-02-15 00:00:00", count: 862, ok: 862 }, 13 + { day: "2024-02-14 00:00:00", count: 876, ok: 876 }, 14 + { day: "2024-02-13 00:00:00", count: 876, ok: 876 }, 15 + { day: "2024-02-12 00:00:00", count: 882, ok: 882 }, 16 + { day: "2024-02-11 00:00:00", count: 864, ok: 864 }, 17 + { day: "2024-02-10 00:00:00", count: 864, ok: 864 }, 18 + { day: "2024-02-09 00:00:00", count: 846, ok: 846 }, 19 + { day: "2024-02-08 00:00:00", count: 870, ok: 870 }, 20 + { day: "2024-02-07 00:00:00", count: 864, ok: 864 }, 21 + { day: "2024-02-06 00:00:00", count: 864, ok: 864 }, 22 + { day: "2024-02-05 00:00:00", count: 864, ok: 864 }, 23 + { day: "2024-02-04 00:00:00", count: 864, ok: 864 }, 24 + { day: "2024-02-03 00:00:00", count: 858, ok: 858 }, 25 + { day: "2024-02-02 00:00:00", count: 864, ok: 864 }, 26 + { day: "2024-02-01 00:00:00", count: 870, ok: 870 }, 27 + { day: "2024-01-31 00:00:00", count: 864, ok: 864 }, 28 + { day: "2024-01-30 00:00:00", count: 864, ok: 864 }, 29 + { day: "2024-01-29 00:00:00", count: 859, ok: 859 }, 30 + { day: "2024-01-28 00:00:00", count: 860, ok: 860 }, 31 + { day: "2024-01-27 00:00:00", count: 864, ok: 864 }, 32 + { day: "2024-01-26 00:00:00", count: 864, ok: 864 }, 33 + { day: "2024-01-25 00:00:00", count: 864, ok: 864 }, 34 + { day: "2024-01-24 00:00:00", count: 864, ok: 864 }, 35 + { day: "2024-01-23 00:00:00", count: 864, ok: 864 }, 36 + { day: "2024-01-22 00:00:00", count: 864, ok: 864 }, 37 + { day: "2024-01-21 00:00:00", count: 864, ok: 864 }, 38 + { day: "2024-01-20 00:00:00", count: 864, ok: 864 }, 39 + { day: "2024-01-19 00:00:00", count: 864, ok: 864 }, 40 + { day: "2024-01-18 00:00:00", count: 864, ok: 864 }, 41 + { day: "2024-01-17 00:00:00", count: 863, ok: 862 }, 42 + { day: "2024-01-16 00:00:00", count: 795, ok: 795 }, 43 + { day: "2024-01-15 00:00:00", count: 846, ok: 846 }, 44 + { day: "2024-01-14 00:00:00", count: 864, ok: 864 }, 45 + { day: "2024-01-13 00:00:00", count: 852, ok: 852 }, 46 + { day: "2024-01-12 00:00:00", count: 864, ok: 857 }, 47 + { day: "2024-01-11 00:00:00", count: 864, ok: 864 }, 48 + { day: "2024-01-10 00:00:00", count: 865, ok: 864 }, 49 + { day: "2024-01-09 00:00:00", count: 864, ok: 864 }, 50 + { day: "2024-01-08 00:00:00", count: 864, ok: 864 }, 51 + { day: "2024-01-07 00:00:00", count: 671, ok: 671 }, 52 + ]; 53 + 54 + const tracker = new Tracker({ data: mockMonitor }); 55 + console.log(tracker.totalUptime);
+187
packages/tracker/src/tracker.ts
··· 1 + import type { 2 + Incident, 3 + StatusReport, 4 + StatusReportUpdate, 5 + } from "@openstatus/db/src/schema"; 6 + import type { Monitor } from "@openstatus/tinybird"; 7 + 8 + import { isInBlacklist } from "./blacklist"; 9 + import { classNames, statusDetails } from "./config"; 10 + import type { StatusDetails, StatusVariant } from "./types"; 11 + import { Status } from "./types"; 12 + import { endOfDay, isSameDay, startOfDay } from "./utils"; 13 + 14 + type Monitors = Monitor[]; 15 + type StatusReports = (StatusReport & { 16 + statusReportUpdates?: StatusReportUpdate[]; 17 + })[]; 18 + type Incidents = Incident[]; 19 + 20 + /** 21 + * Tracker Class is supposed to handle the data and calculate from a single monitor. 22 + * But we use it to handle the StatusCheck as well (with no data for a single monitor). 23 + * We can create Inheritence to handle the StatusCheck and Monitor separately and even 24 + * StatusPage with multiple Monitors. 25 + */ 26 + export class Tracker { 27 + private data: Monitors = []; 28 + private statusReports: StatusReports = []; 29 + private incidents: Incidents = []; 30 + 31 + constructor(arg: { 32 + data?: Monitors; 33 + statusReports?: StatusReports; 34 + incidents?: Incidents; 35 + }) { 36 + this.data = arg.data || []; // TODO: use another Class to handle a single Day 37 + this.statusReports = arg.statusReports || []; 38 + this.incidents = arg.incidents || []; 39 + } 40 + 41 + private calculateUptime(data: { ok: number; count: number }[]) { 42 + const { count, ok } = this.aggregatedData(data); 43 + if (count === 0) return 100; // starting with 100% uptime 44 + return Math.round((ok / count) * 10_000) / 100; // round to 2 decimal places 45 + } 46 + 47 + private aggregatedData(data: { ok: number; count: number }[]) { 48 + return data.reduce( 49 + (prev, curr) => { 50 + prev.ok += curr.ok; 51 + prev.count += curr.count; 52 + return prev; 53 + }, 54 + { count: 0, ok: 0 }, 55 + ); 56 + } 57 + 58 + get isDataMissing() { 59 + const { count } = this.aggregatedData(this.data); 60 + return count === 0; 61 + } 62 + 63 + private calculateUptimeStatus(data: { ok: number; count: number }[]): Status { 64 + const uptime = this.calculateUptime(data); 65 + if (uptime >= 99.8) return Status.Operational; 66 + if (uptime >= 95) return Status.DegradedPerformance; 67 + if (uptime > 50) return Status.PartialOutage; 68 + return Status.MajorOutage; 69 + } 70 + 71 + private isOngoingIncident() { 72 + return this.incidents.some((incident) => !incident.resolvedAt); 73 + } 74 + 75 + private isOngoingReport() { 76 + const resolved: StatusReport["status"][] = ["monitoring", "resolved"]; 77 + return this.statusReports.some( 78 + (report) => !resolved.includes(report.status), 79 + ); 80 + } 81 + 82 + get totalUptime(): number { 83 + return this.calculateUptime(this.data); 84 + } 85 + 86 + get currentStatus(): Status { 87 + if (this.isOngoingReport()) return Status.DegradedPerformance; 88 + if (this.isOngoingIncident()) return Status.Incident; 89 + return this.calculateUptimeStatus(this.data); 90 + } 91 + 92 + get currentVariant(): StatusVariant { 93 + return statusDetails[this.currentStatus].variant; 94 + } 95 + 96 + get currentDetails(): StatusDetails { 97 + return statusDetails[this.currentStatus]; 98 + } 99 + 100 + get currentClassName(): string { 101 + return classNames[this.currentVariant]; 102 + } 103 + 104 + // HACK: this is a temporary solution to get the incidents 105 + private getIncidentsByDay(day: Date): Incidents { 106 + const incidents = this.incidents?.filter((incident) => { 107 + const { startedAt, resolvedAt } = incident; 108 + const eod = endOfDay(day); 109 + const sod = startOfDay(day); 110 + 111 + if (!startedAt) return false; // not started 112 + 113 + const hasStartedAfterEndOfDay = startedAt.getTime() >= eod.getTime(); 114 + 115 + if (hasStartedAfterEndOfDay) return false; 116 + 117 + if (!resolvedAt) return true; // still ongoing 118 + 119 + const hasResolvedBeforeStartOfDay = resolvedAt.getTime() <= sod.getTime(); 120 + 121 + if (hasResolvedBeforeStartOfDay) return false; 122 + 123 + const hasStartedBeforeEndOfDay = startedAt.getTime() <= eod.getTime(); 124 + 125 + const hasResolvedBeforeEndOfDay = resolvedAt.getTime() <= eod.getTime(); 126 + 127 + if (hasStartedBeforeEndOfDay || hasResolvedBeforeEndOfDay) return true; 128 + 129 + return false; 130 + }); 131 + 132 + return incidents; 133 + } 134 + 135 + // HACK: this is a temporary solution to get the status reports 136 + private getStatusReportsByDay(props: Monitor): StatusReports { 137 + const statusReports = this.statusReports?.filter((report) => { 138 + const firstStatusReportUpdate = report?.statusReportUpdates?.sort( 139 + (a, b) => a.date.getTime() - b.date.getTime(), 140 + )?.[0]; 141 + 142 + if (!firstStatusReportUpdate) return false; 143 + 144 + const day = new Date(props.day); 145 + return isSameDay(firstStatusReportUpdate.date, day); 146 + }); 147 + return statusReports; 148 + } 149 + 150 + // TODO: it would be great to create a class to handle a single day 151 + // FIXME: will be always generated on each tracker.days call - needs to be in the constructor? 152 + get days() { 153 + const data = this.data.map((props) => { 154 + const day = new Date(props.day); 155 + const blacklist = isInBlacklist(day); 156 + const incidents = this.getIncidentsByDay(day); 157 + const statusReports = this.getStatusReportsByDay(props); 158 + 159 + const isMissingData = props.count === 0; 160 + 161 + // FIXME: 162 + const status = incidents.length 163 + ? Status.Incident 164 + : isMissingData 165 + ? Status.Unknown 166 + : this.calculateUptimeStatus([props]); 167 + 168 + const variant = statusDetails[status].variant; 169 + const label = statusDetails[status].short; 170 + 171 + return { 172 + ...props, 173 + blacklist, 174 + incidents, 175 + statusReports, 176 + status, 177 + variant, 178 + label: isMissingData ? "Missing" : label, 179 + }; 180 + }); 181 + return data; 182 + } 183 + 184 + get toString() { 185 + return statusDetails[this.currentStatus].short; 186 + } 187 + }
+33
packages/tracker/src/types.ts
··· 1 + import type { Incident, StatusReport } from "@openstatus/db/src/schema"; 2 + 3 + // DO NOT CHANGE! 4 + export enum Status { 5 + Operational = "operational", 6 + DegradedPerformance = "degraded_performance", 7 + PartialOutage = "partial_outage", 8 + MajorOutage = "major_outage", 9 + UnderMaintenance = "under_maintenance", // not used 10 + Unknown = "unknown", 11 + Incident = "incident", 12 + } 13 + 14 + export type StatusVariant = "up" | "degraded" | "down" | "empty" | "incident"; 15 + 16 + export type StatusDetails = { 17 + long: string; 18 + short: string; 19 + variant: StatusVariant; 20 + }; 21 + 22 + /** 23 + * Data used for the `Bar` component within the `Tracker` component. 24 + */ 25 + export type TrackerData = { 26 + ok: number; 27 + count: number; 28 + date: Date; 29 + incidents: Incident[]; 30 + statusReports: StatusReport[]; 31 + status: Status; 32 + variant: StatusVariant; 33 + };
+32
packages/tracker/src/utils.ts
··· 1 + export function endOfDay(date: Date): Date { 2 + // Create a new Date object to avoid mutating the original date 3 + const newDate = new Date(date); 4 + 5 + // Set hours, minutes, seconds, and milliseconds to end of day 6 + newDate.setHours(23, 59, 59, 999); 7 + 8 + return newDate; 9 + } 10 + 11 + export function startOfDay(date: Date): Date { 12 + // Create a new Date object to avoid mutating the original date 13 + const newDate = new Date(date); 14 + 15 + // Set hours, minutes, seconds, and milliseconds to start of day 16 + newDate.setHours(0, 0, 0, 0); 17 + 18 + return newDate; 19 + } 20 + 21 + export function isSameDay(date1: Date, date2: Date) { 22 + const newDate1 = new Date(date1); 23 + const newDate2 = new Date(date2); 24 + 25 + newDate1.setDate(newDate1.getDate()); 26 + newDate1.setHours(0, 0, 0, 0); 27 + 28 + newDate2.setDate(newDate2.getDate()); 29 + newDate2.setHours(0, 0, 0, 0); 30 + 31 + return newDate1.toUTCString() === newDate2.toUTCString(); 32 + }
+7
packages/tracker/tsconfig.json
··· 1 + { 2 + "extends": "@openstatus/tsconfig/base.json", 3 + "include": ["src", "*.ts"], 4 + "compilerOptions": { 5 + "target": "ES2021" 6 + } 7 + }
+29
pnpm-lock.yaml
··· 67 67 '@openstatus/tinybird': 68 68 specifier: workspace:* 69 69 version: link:../../packages/tinybird 70 + '@openstatus/tracker': 71 + specifier: workspace:* 72 + version: link:../../packages/tracker 70 73 '@openstatus/upstash': 71 74 specifier: workspace:* 72 75 version: link:../../packages/upstash ··· 146 149 '@openstatus/tinybird': 147 150 specifier: workspace:* 148 151 version: link:../../packages/tinybird 152 + '@openstatus/tracker': 153 + specifier: workspace:* 154 + version: link:../../packages/tracker 149 155 '@openstatus/ui': 150 156 specifier: workspace:* 151 157 version: link:../../packages/ui ··· 739 745 specifier: 5.2.2 740 746 version: 5.2.2 741 747 748 + packages/tracker: 749 + dependencies: 750 + '@openstatus/db': 751 + specifier: workspace:* 752 + version: link:../db 753 + '@openstatus/tinybird': 754 + specifier: workspace:* 755 + version: link:../tinybird 756 + zod: 757 + specifier: 3.22.2 758 + version: 3.22.2 759 + devDependencies: 760 + '@openstatus/tsconfig': 761 + specifier: workspace:* 762 + version: link:../tsconfig 763 + typescript: 764 + specifier: 5.2.2 765 + version: 5.2.2 766 + 742 767 packages/tsconfig: {} 743 768 744 769 packages/ui: ··· 1358 1383 peerDependencies: 1359 1384 '@effect-ts/otel-node': '*' 1360 1385 peerDependenciesMeta: 1386 + '@effect-ts/core': 1387 + optional: true 1388 + '@effect-ts/otel': 1389 + optional: true 1361 1390 '@effect-ts/otel-node': 1362 1391 optional: true 1363 1392 dependencies: