Openstatus www.openstatus.dev
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

🚀use incidents instead (#658)

* 🚀use incidents instead

* feat: add incident duration and remove operations from tb

* refactor: color from red to rose for status page items

* fix: accumulate incident length by monitor

---------

Co-authored-by: mxkaske <maximilian@kaske.org>

authored by

Thibault Le Ouay
mxkaske
and committed by
GitHub
f7b34254 73b3e614

+484 -432
+54 -59
apps/server/src/public/status.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { endTime, setMetric, startTime } from "hono/timing"; 3 3 4 - import { and, db, eq, inArray } from "@openstatus/db"; 4 + import { and, db, eq, inArray, isNull } from "@openstatus/db"; 5 5 import { 6 + incidentTable, 6 7 monitor, 7 8 monitorsToPages, 8 9 monitorsToStatusReport, ··· 10 11 pagesToStatusReports, 11 12 statusReport, 12 13 } from "@openstatus/db/src/schema"; 13 - import { getPublicStatus, Tinybird } from "@openstatus/tinybird"; 14 14 import { Redis } from "@openstatus/upstash"; 15 15 16 - import { env } from "../env"; 17 16 import { notEmpty } from "../utils/not-empty"; 18 17 19 18 // TODO: include ratelimiting 20 19 21 - const tb = new Tinybird({ token: env.TINY_BIRD_API_KEY }); 22 20 const redis = Redis.fromEnv(); 23 21 24 22 enum Status { 25 23 Operational = "operational", 26 24 DegradedPerformance = "degraded_performance", 27 - PartialOutage = "partial_outage", 28 - MajorOutage = "major_outage", 29 - UnderMaintenance = "under_maintenance", 25 + PartialOutage = "partial_outage", // not used 26 + MajorOutage = "major_outage", // not used 27 + UnderMaintenance = "under_maintenance", // not used 30 28 Unknown = "unknown", 31 29 Incident = "incident", 32 30 } ··· 44 42 } 45 43 46 44 startTime(c, "database"); 47 - const { monitorData, pageStatusReportData, monitorStatusReportData } = 48 - await getStatusPageData(slug); 45 + const { 46 + monitorData, 47 + pageStatusReportData, 48 + monitorStatusReportData, 49 + ongoingIncidents, 50 + } = await getStatusPageData(slug); 49 51 endTime(c, "database"); 50 52 51 - const isIncident = [...pageStatusReportData, ...monitorStatusReportData].some( 52 - (data) => { 53 - if (!data.status_report) return false; 54 - return !["monitoring", "resolved"].includes(data.status_report.status); 55 - }, 56 - ); 53 + const isStatusReport = [ 54 + ...pageStatusReportData, 55 + ...monitorStatusReportData, 56 + ].some((data) => { 57 + if (!data.status_report) return false; 58 + return !["monitoring", "resolved"].includes(data.status_report.status); 59 + }); 57 60 58 - startTime(c, "clickhouse"); 59 - const lastMonitorPings = await Promise.allSettled( 60 - monitorData.map(async ({ monitors_to_pages }) => { 61 - return await getPublicStatus(tb)({ 62 - monitorId: String(monitors_to_pages.monitorId), 63 - }); 64 - }), 65 - ); 66 - endTime(c, "clickhouse"); 61 + function getStatus() { 62 + if (isStatusReport) return Status.Incident; 63 + // if (monitorData.length === 0) return Status.Unknown; 64 + if (ongoingIncidents.length > 0) return Status.DegradedPerformance; 65 + return Status.Operational; 66 + } 67 67 68 - const data = lastMonitorPings.reduce( 69 - (prev, curr) => { 70 - if (curr.status === "fulfilled") { 71 - const { ok, count } = curr.value.data.reduce( 72 - (p, c) => { 73 - p.ok += c.ok; 74 - p.count += c.count; 75 - return p; 76 - }, 77 - { ok: 0, count: 0 }, 78 - ); 79 - prev.ok += ok; 80 - prev.count += count; 81 - } 82 - return prev; 83 - }, 84 - { ok: 0, count: 0 }, 85 - ); 86 - 87 - const ratio = data.ok / data.count; 88 - 89 - const status: Status = isIncident ? Status.Incident : getStatus(ratio); 90 - 68 + const status = getStatus(); 91 69 await redis.set(slug, status, { ex: 60 }); // 1m cache 92 - 93 70 return c.json({ status }); 94 71 }); 95 - 96 - function getStatus(ratio: number) { 97 - if (isNaN(ratio)) return Status.Unknown; 98 - if (ratio >= 0.98) return Status.Operational; 99 - if (ratio >= 0.6) return Status.DegradedPerformance; 100 - if (ratio >= 0.3) return Status.PartialOutage; 101 - if (ratio >= 0) return Status.MajorOutage; 102 - return Status.Unknown; 103 - } 104 72 105 73 async function getStatusPageData(slug: string) { 106 74 const monitorData = await db ··· 116 84 .all(); 117 85 118 86 const monitorIds = monitorData.map((i) => i.monitor?.id).filter(notEmpty); 87 + if (monitorIds.length === 0) { 88 + return { 89 + monitorData, 90 + pageStatusReportData: [], 91 + monitorStatusReportData: [], 92 + ongoingIncidents: [], 93 + }; 94 + } 119 95 120 - const monitorStatusReportData = await db 96 + const monitorStatusReportQuery = db 121 97 .select() 122 98 .from(monitorsToStatusReport) 123 99 .leftJoin( ··· 128 104 .all(); 129 105 130 106 // REMINDER: the query can overlap with the previous one 131 - const pageStatusReportData = await db 107 + const pageStatusReportDataQuery = db 132 108 .select() 133 109 .from(pagesToStatusReports) 134 110 .leftJoin( ··· 139 115 .where(eq(page.slug, slug)) 140 116 .all(); 141 117 118 + const ongoingIncidentsQuery = db 119 + .select() 120 + .from(incidentTable) 121 + .where( 122 + and( 123 + isNull(incidentTable.resolvedAt), 124 + inArray(incidentTable.monitorId, monitorIds), 125 + ), 126 + ) 127 + .all(); 128 + 129 + const [monitorStatusReportData, pageStatusReportData, ongoingIncidents] = 130 + await Promise.all([ 131 + monitorStatusReportQuery, 132 + pageStatusReportDataQuery, 133 + ongoingIncidentsQuery, 134 + ]); 135 + 142 136 return { 143 137 monitorData, 144 138 pageStatusReportData, 145 139 monitorStatusReportData, 140 + ongoingIncidents, 146 141 }; 147 142 }
+4 -8
apps/web/src/app/api/og/_components/status-check.tsx
··· 1 1 import type { StatusVariant } from "@/lib/tracker"; 2 2 import { cn } from "@/lib/utils"; 3 3 4 - export function StatusCheck({ 5 - variant, 6 - }: { 7 - variant: StatusVariant | "incident"; 8 - }) { 4 + export function StatusCheck({ variant }: { variant: StatusVariant }) { 9 5 function getVariant() { 10 6 switch (variant) { 11 7 case "down": 12 8 return { 13 - color: "bg-red-500", 9 + color: "bg-rose-500", 14 10 label: "Major Outage", 15 11 icon: Minus, 16 12 }; 17 13 case "degraded": 18 14 return { 19 - color: "bg-yellow-500", 15 + color: "bg-amber-500", 20 16 label: "Systems Degraded", 21 17 icon: Minus, 22 18 }; 23 19 case "incident": 24 20 return { 25 - color: "bg-yellow-500", 21 + color: "bg-amber-500", 26 22 label: "Incident Ongoing", 27 23 icon: Alert, 28 24 };
+3 -3
apps/web/src/app/api/og/_components/tracker.tsx
··· 2 2 3 3 import { 4 4 addBlackListInfo, 5 - getStatus, 5 + getStatusByRatio, 6 6 getTotalUptimeString, 7 7 } from "@/lib/tracker"; 8 8 import { cn, formatDate } from "@/lib/utils"; ··· 25 25 })} 26 26 <div tw="flex flex-row-reverse absolute left-0"> 27 27 {_data.map((item, i) => { 28 - const { variant } = getStatus(item.ok / item.count); 28 + const { variant } = getStatusByRatio(item.ok / item.count); 29 29 const isBlackListed = Boolean(item.blacklist); 30 30 if (isBlackListed) { 31 31 return ( ··· 38 38 tw={cn("h-16 w-3 rounded-full mr-1", { 39 39 "bg-green-500": variant === "up", 40 40 "bg-red-500": variant === "down", 41 - "bg-yellow-500": variant === "degraded", 41 + "bg-amber-500": variant === "degraded", 42 42 })} 43 43 /> 44 44 );
+1 -1
apps/web/src/app/api/og/checker/route.tsx
··· 49 49 if (blue) return "border-blue-300 bg-blue-50 text-blue-700"; 50 50 const red = 51 51 String(statusCode).startsWith("4") || String(statusCode).startsWith("5"); 52 - if (red) return "border-red-300 bg-red-50 text-red-700"; 52 + if (red) return "border-rose-300 bg-rose-50 text-rose-700"; 53 53 return "border-gray-300 bg-gray-50 text-gray-700"; 54 54 } 55 55
+9 -16
apps/web/src/app/api/og/page/route.tsx
··· 1 1 import { ImageResponse } from "next/og"; 2 2 3 3 import { DESCRIPTION, TITLE } from "@/app/shared-metadata"; 4 - import { getResponseListData } from "@/lib/tb"; 5 - import { calcStatus } from "@/lib/tracker"; 6 - import { notEmpty } from "@/lib/utils"; 4 + import { getStatusByRatio, incidentStatus } from "@/lib/tracker"; 7 5 import { api } from "@/trpc/server"; 8 6 import { BasicLayout } from "../_components/basic-layout"; 9 7 import { StatusCheck } from "../_components/status-check"; ··· 25 23 const title = page ? page.title : TITLE; 26 24 const description = page ? "" : DESCRIPTION; 27 25 28 - const isIncident = page?.statusReports.some( 26 + const isStatusReport = page?.statusReports.some( 29 27 (incident) => !["monitoring", "resolved"].includes(incident.status), 30 28 ); 31 29 32 - const monitorsData = ( 33 - await Promise.all( 34 - page?.monitors.map((monitor) => { 35 - return getResponseListData({ 36 - monitorId: String(monitor.id), 37 - limit: 10, 38 - }); 39 - }) || [], 40 - ) 41 - ).filter(notEmpty); 30 + const isIncident = page?.incidents.some( 31 + (incident) => incident.resolvedAt === null, 32 + ); 42 33 43 - const status = calcStatus(monitorsData); 34 + const status = isStatusReport 35 + ? incidentStatus 36 + : getStatusByRatio(isIncident ? 0.5 : 1); 44 37 45 38 return new ImageResponse( 46 39 ( 47 40 <BasicLayout title={title} description={description} tw="py-24 px-24"> 48 - <StatusCheck variant={isIncident ? "incident" : status.variant} /> 41 + <StatusCheck variant={status.variant} /> 49 42 </BasicLayout> 50 43 ), 51 44 {
+3 -2
apps/web/src/app/play/checker/[id]/_components/status-badge.tsx
··· 5 5 export function StatusBadge({ statusCode }: { statusCode: number }) { 6 6 const green = String(statusCode).startsWith("2"); 7 7 const blue = String(statusCode).startsWith("3"); 8 - const red = 8 + const rose = 9 9 String(statusCode).startsWith("4") || String(statusCode).startsWith("5"); 10 10 return ( 11 11 <Badge ··· 15 15 green, 16 16 "border-blue-500/20 bg-blue-500/10 text-blue-800 dark:text-blue-300": 17 17 blue, 18 - "border-red-500/20 bg-red-500/10 text-red-800 dark:text-red-300": red, 18 + "border-rose-500/20 bg-rose-500/10 text-rose-800 dark:text-rose-300": 19 + rose, 19 20 })} 20 21 > 21 22 {statusCode}
+2 -9
apps/web/src/app/play/status/_components/status-play.tsx
··· 1 1 import { Label } from "@openstatus/ui"; 2 2 3 3 import { Shell } from "@/components/dashboard/shell"; 4 - import { Tracker } from "@/components/tracker"; 4 + import { Tracker } from "@/components/tracker/tracker"; 5 5 import { getHomeMonitorListData } from "@/lib/tb"; 6 6 import { convertTimezoneToGMT, getRequestHeaderTimezone } from "@/lib/timezone"; 7 7 import { HeaderPlay } from "../../_components/header-play"; ··· 21 21 description="Gain the trust of your users by showing them the uptime of your API or website." 22 22 /> 23 23 <div className="mx-auto w-full max-w-md"> 24 - {data && ( 25 - <Tracker 26 - data={data} 27 - id={1} 28 - name="Ping" 29 - url="https://www.openstatus.dev/api/ping" 30 - /> 31 - )} 24 + {data && <Tracker data={data} name="Ping" description="Pong" />} 32 25 </div> 33 26 <div className="mt-6 flex justify-start"> 34 27 <div className="grid items-center gap-1">
+2 -1
apps/web/src/app/status-page/[domain]/page.tsx
··· 51 51 <> 52 52 <StatusCheck 53 53 statusReports={page.statusReports} 54 - monitors={page.monitors} 54 + incidents={page.incidents} 55 55 /> 56 56 <MonitorList 57 57 monitors={page.monitors} 58 58 statusReports={page.statusReports} 59 + incidents={page.incidents} 59 60 /> 60 61 {/* TODO: rename to StatusReportList */} 61 62 <IncidentList
+4 -4
apps/web/src/app/status/utils.ts
··· 36 36 case "All Systems Operational": 37 37 return "text-green-500"; 38 38 case "Major System Outage": 39 - return "text-red-500"; 39 + return "text-rose-500"; 40 40 case "Partial System Outage": 41 41 return "text-orange-500"; 42 42 case "Minor Service Outage": 43 - return "text-yellow-500"; 43 + return "text-amber-500"; 44 44 case "Degraded System Service": 45 - return "text-yellow-500"; 45 + return "text-amber-500"; 46 46 case "Partially Degraded Service": 47 - return "text-yellow-500"; 47 + return "text-amber-500"; 48 48 case "Service Under Maintenance": 49 49 return "text-blue-500"; 50 50 default:
+5 -1
apps/web/src/components/layout/app-page-layout.tsx
··· 18 18 > 19 19 {children} 20 20 </div> 21 - {withHelpCallout ? <HelpCallout /> : null} 21 + {withHelpCallout ? ( 22 + <div className="mt-4"> 23 + <HelpCallout /> 24 + </div> 25 + ) : null} 22 26 </Shell> 23 27 ); 24 28 }
+2 -2
apps/web/src/components/marketing/alert/timeline.tsx
··· 90 90 message: "3 incoming notifications from Grafana.", 91 91 icon: { 92 92 name: "webhook", 93 - textColor: "text-yellow-500", 94 - borderColor: "border-yellow-500/40", 93 + textColor: "text-amber-500", 94 + borderColor: "border-amber-500/40", 95 95 }, 96 96 }, 97 97 {
+3 -18
apps/web/src/components/marketing/status-page/tracker-example.tsx
··· 3 3 4 4 import { Button } from "@openstatus/ui"; 5 5 6 - import { Tracker } from "@/components/tracker"; 6 + import { Tracker } from "@/components/tracker/tracker"; 7 7 import { getHomeMonitorListData } from "@/lib/tb"; 8 8 import { convertTimezoneToGMT } from "@/lib/timezone"; 9 9 ··· 23 23 } 24 24 25 25 function ExampleTrackerFallback() { 26 - return ( 27 - <Tracker 28 - data={[]} 29 - id={1} 30 - name="Ping" 31 - url="https://www.openstatus.dev/api/ping" 32 - /> 33 - ); 26 + return <Tracker data={[]} name="Ping" description="Pong" />; 34 27 } 35 28 36 29 async function ExampleTracker() { 37 30 const gmt = convertTimezoneToGMT(); 38 31 const data = await getHomeMonitorListData({ timezone: gmt }); 39 32 if (!data) return null; 40 - return ( 41 - <Tracker 42 - data={data} 43 - id={1} 44 - name="Ping" 45 - context="play" 46 - url="https://www.openstatus.dev/api/ping" 47 - /> 48 - ); 33 + return <Tracker data={data} name="Ping" description="Pong" />; 49 34 }
+7
apps/web/src/components/status-page/monitor-list.tsx
··· 1 1 import type { z } from "zod"; 2 2 3 3 import type { 4 + selectIncidentPageSchema, 4 5 selectPublicMonitorSchema, 5 6 selectPublicStatusReportSchemaWithRelation, 6 7 } from "@openstatus/db/src/schema"; ··· 10 11 export const MonitorList = ({ 11 12 monitors, 12 13 statusReports, 14 + incidents, 13 15 }: { 14 16 monitors: z.infer<typeof selectPublicMonitorSchema>[]; 15 17 statusReports: z.infer<typeof selectPublicStatusReportSchemaWithRelation>[]; 18 + incidents: z.infer<typeof selectIncidentPageSchema>; 16 19 }) => { 17 20 return ( 18 21 <div className="grid gap-4"> ··· 22 25 (i) => i.monitor.id === monitor.id, 23 26 ), 24 27 ); 28 + const monitorIncidents = incidents.filter( 29 + (incident) => incident.monitorId === monitor.id, 30 + ); 25 31 return ( 26 32 <Monitor 27 33 key={index} 28 34 monitor={monitor} 29 35 statusReports={monitorStatusReport} 36 + incidents={monitorIncidents} 30 37 /> 31 38 ); 32 39 })}
+12 -2
apps/web/src/components/status-page/monitor.tsx
··· 1 1 import type { z } from "zod"; 2 2 3 3 import type { 4 + selectIncidentPageSchema, 4 5 selectPublicMonitorSchema, 5 6 selectPublicStatusReportSchemaWithRelation, 6 7 } from "@openstatus/db/src/schema"; 7 8 9 + import { Tracker } from "@/components/tracker/tracker"; 8 10 import { getMonitorListData } from "@/lib/tb"; 9 11 import { convertTimezoneToGMT } from "@/lib/timezone"; 10 - import { Tracker } from "../tracker"; 11 12 12 13 export const Monitor = async ({ 13 14 monitor, 14 15 statusReports, 16 + incidents, 15 17 }: { 16 18 monitor: z.infer<typeof selectPublicMonitorSchema>; 17 19 statusReports: z.infer<typeof selectPublicStatusReportSchemaWithRelation>[]; 20 + incidents: z.infer<typeof selectIncidentPageSchema>; 18 21 }) => { 19 22 const gmt = convertTimezoneToGMT(); 20 23 const data = await getMonitorListData({ ··· 27 30 28 31 if (!data) return <div>Something went wrong</div>; 29 32 30 - return <Tracker data={data} reports={statusReports} {...monitor} />; 33 + return ( 34 + <Tracker 35 + data={data} 36 + reports={statusReports} 37 + incidents={incidents} 38 + {...monitor} 39 + /> 40 + ); 31 41 };
+20 -38
apps/web/src/components/status-page/status-check.tsx
··· 2 2 import type { z } from "zod"; 3 3 4 4 import type { 5 - selectPublicMonitorSchema, 5 + selectIncidentPageSchema, 6 6 selectStatusReportPageSchema, 7 7 } from "@openstatus/db/src/schema"; 8 8 9 - import { getResponseListData } from "@/lib/tb"; 9 + import { getStatusByRatio, incidentStatus } from "@/lib/tracker"; 10 10 import type { StatusVariant } from "@/lib/tracker"; 11 - import { calcStatus } from "@/lib/tracker"; 12 - import { cn, notEmpty } from "@/lib/utils"; 11 + import { cn } from "@/lib/utils"; 13 12 import { Icons } from "../icons"; 14 13 15 - const check = cva("border-border rounded-full border p-1.5", { 14 + const check = cva("rounded-full border p-1.5", { 16 15 variants: { 17 16 variant: { 18 17 up: "bg-green-500/80 border-green-500", 19 - down: "bg-red-500/80 border-red-500", 20 - degraded: "bg-yellow-500/80 border-yellow-500", 18 + down: "bg-rose-500/80 border-rose-500", 19 + degraded: "bg-amber-500/80 border-amber-500", 21 20 empty: "bg-gray-500/80 border-gray-500", 22 - incident: "bg-yellow-500/80 border-yellow-500", 21 + incident: "bg-amber-500/80 border-amber-500", 23 22 }, 24 23 }, 25 24 defaultVariants: { ··· 29 28 30 29 export async function StatusCheck({ 31 30 statusReports, 32 - monitors, 31 + incidents, 33 32 }: { 34 33 statusReports: z.infer<typeof selectStatusReportPageSchema>; 35 - monitors: z.infer<typeof selectPublicMonitorSchema>[]; 34 + incidents: z.infer<typeof selectIncidentPageSchema>; 36 35 }) { 37 - const isIncident = statusReports.some( 36 + const isStatusReport = statusReports.some( 38 37 (incident) => !["monitoring", "resolved"].includes(incident.status), 39 38 ); 40 - 41 - const monitorsData = ( 42 - await Promise.all( 43 - monitors.map((monitor) => { 44 - return getResponseListData({ 45 - monitorId: String(monitor.id), 46 - url: monitor.url, 47 - limit: 10, 48 - }); 49 - }), 50 - ) 51 - ).filter(notEmpty); 39 + const isIncident = incidents.some((incident) => incident.resolvedAt === null); 52 40 53 - const status = calcStatus(monitorsData); 41 + // Forcing the status to be either 'degraded' or 'up' 42 + const status = getStatusByRatio(isIncident ? 0.5 : 1); 54 43 55 - const incident = { 56 - label: "Incident", 57 - variant: "incident", 58 - } as const; 59 - 60 - const { label, variant } = isIncident ? incident : status; 44 + const { label, variant } = isStatusReport ? incidentStatus : status; 61 45 62 46 return ( 63 47 <div className="flex flex-col items-center gap-2"> ··· 72 56 ); 73 57 } 74 58 75 - interface StatusIconProps { 59 + export interface StatusIconProps { 76 60 variant: StatusVariant | "incident"; 77 61 className?: string; 78 62 } 79 63 80 - function StatusIcon({ variant, className }: StatusIconProps) { 64 + export function StatusIcon({ variant, className }: StatusIconProps) { 81 65 const rootClassName = cn("h-5 w-5 text-background", className); 82 - const MinusIcon = Icons["minus"]; 83 - const CheckIcon = Icons["check"]; 84 - const AlertTriangleIcon = Icons["alert-triangle"]; 85 66 if (variant === "incident") { 67 + const AlertTriangleIcon = Icons["alert-triangle"]; 86 68 return <AlertTriangleIcon className={rootClassName} />; 87 69 } 88 70 if (variant === "degraded") { 89 - return <MinusIcon className={rootClassName} />; 71 + return <Icons.minus className={rootClassName} />; 90 72 } 91 73 if (variant === "down") { 92 - return <MinusIcon className={rootClassName} />; 74 + return <Icons.minus className={rootClassName} />; 93 75 } 94 - return <CheckIcon className={rootClassName} />; 76 + return <Icons.check className={rootClassName} />; 95 77 }
+2 -2
apps/web/src/components/status-update/status-badge.tsx
··· 19 19 className={cn( 20 20 "font-normal", 21 21 { 22 - "border-red-500/20 bg-red-500/10 text-red-500": 22 + "border-rose-500/20 bg-rose-500/10 text-rose-500": 23 23 status === "investigating", 24 - "border-yellow-500/20 bg-yellow-500/10 text-yellow-500": 24 + "border-amber-500/20 bg-amber-500/10 text-amber-500": 25 25 status === "identified", 26 26 "border-blue-500/20 bg-blue-500/10 text-blue-500": 27 27 status === "monitoring",
-243
apps/web/src/components/tracker.tsx
··· 1 - "use client"; 2 - 3 - import * as React from "react"; 4 - import Link from "next/link"; 5 - import { cva } from "class-variance-authority"; 6 - import { format } from "date-fns"; 7 - import { ChevronRight, Eye, Info } from "lucide-react"; 8 - 9 - import type { 10 - StatusReport, 11 - StatusReportUpdate, 12 - } from "@openstatus/db/src/schema"; 13 - import type { Monitor } from "@openstatus/tinybird"; 14 - import { 15 - HoverCard, 16 - HoverCardContent, 17 - HoverCardTrigger, 18 - Separator, 19 - Tooltip, 20 - TooltipContent, 21 - TooltipProvider, 22 - TooltipTrigger, 23 - } from "@openstatus/ui"; 24 - 25 - import { 26 - addBlackListInfo, 27 - areDatesEqualByDayMonthYear, 28 - getStatus, 29 - getTotalUptimeString, 30 - } from "@/lib/tracker"; 31 - 32 - // What would be cool is tracker that turn from green to red depending on the number of errors 33 - const tracker = cva("h-10 rounded-full flex-1", { 34 - variants: { 35 - variant: { 36 - up: "bg-green-500/90 data-[state=open]:bg-green-500", 37 - down: "bg-red-500/90 data-[state=open]:bg-red-500", 38 - degraded: "bg-yellow-500/90 data-[state=open]:bg-yellow-500", 39 - empty: "bg-muted-foreground/20 data-[state=open]:bg-muted-foreground/30", 40 - blacklist: "bg-green-500/80 data-[state=open]:bg-green-500", 41 - }, 42 - report: { 43 - 0: "", 44 - 30: "bg-gradient-to-t from-blue-500/90 hover:from-blue-500 from-30% to-transparent to-30%", 45 - }, 46 - }, 47 - defaultVariants: { 48 - variant: "empty", 49 - report: 0, 50 - }, 51 - }); 52 - 53 - interface TrackerProps { 54 - data: Monitor[]; 55 - url: string; 56 - id: string | number; 57 - name: string; 58 - description?: string; 59 - context?: "play" | "status-page"; // TODO: we might need to extract those two different use cases - for now it's ok I'd say. 60 - reports?: (StatusReport & { statusReportUpdates: StatusReportUpdate[] })[]; 61 - } 62 - 63 - export function Tracker({ 64 - data, 65 - url, 66 - id, 67 - name, 68 - context = "status-page", 69 - description, 70 - reports, 71 - }: TrackerProps) { 72 - const uptime = getTotalUptimeString(data); 73 - const _data = addBlackListInfo(data); 74 - 75 - return ( 76 - <div className="flex flex-col"> 77 - <div className="mb-2 flex justify-between text-sm"> 78 - <div className="flex items-center gap-2"> 79 - <p className="text-foreground line-clamp-1 font-semibold">{name}</p> 80 - {description ? ( 81 - <MoreInfo {...{ url, id, context, description }} /> 82 - ) : null} 83 - </div> 84 - <p className="text-muted-foreground shrink-0 font-light">{uptime}</p> 85 - </div> 86 - <div className="relative h-full w-full"> 87 - <div className="flex flex-row-reverse gap-px sm:gap-0.5"> 88 - {_data.map((props, i) => { 89 - const dateReports = reports?.filter((report) => { 90 - const firstStatusReportUpdate = report.statusReportUpdates.sort( 91 - (a, b) => a.date.getTime() - b.date.getTime(), 92 - )?.[0]; 93 - 94 - if (!firstStatusReportUpdate) return false; 95 - 96 - return areDatesEqualByDayMonthYear( 97 - firstStatusReportUpdate.date, 98 - new Date(props.day), 99 - ); 100 - }); 101 - 102 - return ( 103 - <Bar key={i} context={context} reports={dateReports} {...props} /> 104 - ); 105 - })} 106 - </div> 107 - </div> 108 - </div> 109 - ); 110 - } 111 - 112 - const MoreInfo = ({ 113 - url, 114 - id, 115 - context, 116 - description, 117 - }: Pick<TrackerProps, "url" | "id" | "context" | "description">) => { 118 - const [open, setOpen] = React.useState(false); 119 - const formattedURL = new URL(url); 120 - const link = `${formattedURL.host}${formattedURL.pathname}`; 121 - 122 - if (description == null && context !== "play") { 123 - return; 124 - } 125 - 126 - return ( 127 - <TooltipProvider> 128 - <Tooltip open={open} onOpenChange={setOpen}> 129 - <TooltipTrigger onClick={() => setOpen(true)} asChild> 130 - <Info className="h-4 w-4" /> 131 - </TooltipTrigger> 132 - <TooltipContent> 133 - <p className="text-muted-foreground"> 134 - {context === "play" ? ( 135 - <Link href={`/monitor/${id}`} className="hover:text-foreground"> 136 - {link} 137 - </Link> 138 - ) : ( 139 - description 140 - )} 141 - </p> 142 - </TooltipContent> 143 - </Tooltip> 144 - </TooltipProvider> 145 - ); 146 - }; 147 - 148 - type BarProps = Monitor & { blacklist?: string } & Pick< 149 - TrackerProps, 150 - "context" | "reports" 151 - >; 152 - 153 - const Bar = ({ 154 - count, 155 - ok, 156 - p95Latency, 157 - day, 158 - blacklist, 159 - context, 160 - reports, 161 - }: BarProps) => { 162 - const [open, setOpen] = React.useState(false); 163 - const ratio = ok / count; 164 - const cronTimestamp = new Date(day).getTime(); 165 - const date = new Date(cronTimestamp); 166 - const toDate = date.setDate(date.getDate() + 1); 167 - const dateFormat = "dd/MM/yy"; 168 - 169 - const className = tracker({ 170 - report: reports && reports.length > 0 ? 30 : undefined, 171 - variant: blacklist ? "blacklist" : getStatus(ratio).variant, 172 - }); 173 - 174 - return ( 175 - <HoverCard 176 - openDelay={100} 177 - closeDelay={100} 178 - open={open} 179 - onOpenChange={setOpen} 180 - > 181 - <HoverCardTrigger onClick={() => setOpen(true)} asChild> 182 - <div className={className} /> 183 - </HoverCardTrigger> 184 - <HoverCardContent side="top" className="w-64"> 185 - {blacklist ? ( 186 - <p className="text-muted-foreground text-xs">{blacklist}</p> 187 - ) : ( 188 - <> 189 - <div className="flex justify-between"> 190 - <p className="text-sm font-semibold">{getStatus(ratio).label}</p> 191 - {context === "play" ? ( 192 - <Link 193 - href={`/monitor/1?fromDate=${cronTimestamp}&toDate=${toDate}`} 194 - className="text-muted-foreground hover:text-foreground" 195 - > 196 - <Eye className="h-4 w-4" /> 197 - </Link> 198 - ) : null} 199 - </div> 200 - <ul className="my-1.5"> 201 - {reports?.map((report) => ( 202 - <li key={report.id} className="text-muted-foreground text-sm"> 203 - <Link 204 - // TODO: include setPrefixUrl for local development 205 - href={`./incidents/${report.id}`} 206 - className="hover:text-foreground group flex items-center justify-between gap-2" 207 - > 208 - <span className="truncate">{report.title}</span> 209 - <ChevronRight className="h-4 w-4" /> 210 - </Link> 211 - </li> 212 - ))} 213 - </ul> 214 - <div className="flex justify-between"> 215 - <p className="text-xs"> 216 - {format(new Date(cronTimestamp), dateFormat)} 217 - </p> 218 - <p className="text-muted-foreground text-xs font-light"> 219 - p95{" "} 220 - <span className="font-mono font-medium">{p95Latency}ms</span> 221 - </p> 222 - </div> 223 - <Separator className="my-1.5" /> 224 - <div className="grid grid-cols-2"> 225 - <p className="text-left text-xs"> 226 - <span className="font-mono text-green-600">{count}</span>{" "} 227 - <span className="text-muted-foreground font-light"> 228 - total requests 229 - </span> 230 - </p> 231 - <p className="text-right text-xs"> 232 - <span className="font-mono text-red-600">{count - ok}</span>{" "} 233 - <span className="text-muted-foreground font-light"> 234 - failed requests 235 - </span> 236 - </p> 237 - </div> 238 - </> 239 - )} 240 - </HoverCardContent> 241 - </HoverCard> 242 - ); 243 - };
+296
apps/web/src/components/tracker/tracker.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import Link from "next/link"; 5 + import { cva } from "class-variance-authority"; 6 + import { endOfDay, format, formatDuration, startOfDay } from "date-fns"; 7 + import { ChevronRight, Info } from "lucide-react"; 8 + import type { z } from "zod"; 9 + 10 + import type { 11 + selectIncidentPageSchema, 12 + StatusReport, 13 + StatusReportUpdate, 14 + } from "@openstatus/db/src/schema"; 15 + import type { Monitor } from "@openstatus/tinybird"; 16 + import { 17 + HoverCard, 18 + HoverCardContent, 19 + HoverCardTrigger, 20 + Separator, 21 + Tooltip, 22 + TooltipContent, 23 + TooltipProvider, 24 + TooltipTrigger, 25 + } from "@openstatus/ui"; 26 + 27 + import { 28 + addBlackListInfo, 29 + areDatesEqualByDayMonthYear, 30 + getStatusByRatio, 31 + getTotalUptimeString, 32 + incidentStatus, 33 + } from "@/lib/tracker"; 34 + import { cn } from "@/lib/utils"; 35 + 36 + // What would be cool is tracker that turn from green to red depending on the number of errors 37 + const tracker = cva("h-10 rounded-full flex-1", { 38 + variants: { 39 + 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 + blacklist: "bg-green-500/80 data-[state=open]:bg-green-500", 45 + incident: "bg-rose-500/90 data-[state=open]:bg-rose-500", 46 + }, 47 + report: { 48 + 0: "", 49 + // IDEA: data-[state=open]:from-40% data-[state=open]:to-40% 50 + 30: "bg-gradient-to-t from-blue-500/90 hover:from-blue-500 from-30% to-transparent to-30%", 51 + }, 52 + }, 53 + defaultVariants: { 54 + variant: "empty", 55 + report: 0, 56 + }, 57 + }); 58 + 59 + // FIXME: 60 + type Incidents = z.infer<typeof selectIncidentPageSchema>; 61 + 62 + interface TrackerProps { 63 + data: Monitor[]; 64 + name: string; 65 + description?: string; 66 + reports?: (StatusReport & { statusReportUpdates: StatusReportUpdate[] })[]; 67 + incidents?: Incidents; 68 + } 69 + 70 + export function Tracker({ 71 + data, 72 + name, 73 + description, 74 + reports, 75 + incidents, 76 + }: TrackerProps) { 77 + const uptime = getTotalUptimeString(data); 78 + const _data = addBlackListInfo(data); 79 + 80 + console.log({ incidents }); 81 + 82 + return ( 83 + <div className="flex flex-col gap-1.5"> 84 + <div className="flex justify-between text-sm"> 85 + <div className="flex items-center gap-2"> 86 + <p className="text-foreground line-clamp-1 font-semibold">{name}</p> 87 + {description ? ( 88 + <TooltipProvider> 89 + <Tooltip> 90 + <TooltipTrigger asChild> 91 + <Info className="h-4 w-4" /> 92 + </TooltipTrigger> 93 + <TooltipContent> 94 + <p className="text-muted-foreground">{description}</p> 95 + </TooltipContent> 96 + </Tooltip> 97 + </TooltipProvider> 98 + ) : null} 99 + </div> 100 + <p className="text-muted-foreground shrink-0 font-light">{uptime}</p> 101 + </div> 102 + <div className="relative h-full w-full"> 103 + <div className="flex flex-row-reverse gap-px sm:gap-0.5"> 104 + {_data.map((props, i) => { 105 + const dateReports = reports?.filter((report) => { 106 + const firstStatusReportUpdate = report.statusReportUpdates.sort( 107 + (a, b) => a.date.getTime() - b.date.getTime(), 108 + )?.[0]; 109 + 110 + if (!firstStatusReportUpdate) return false; 111 + 112 + return areDatesEqualByDayMonthYear( 113 + firstStatusReportUpdate.date, 114 + new Date(props.day), 115 + ); 116 + }); 117 + 118 + const dateIncidents = incidents?.filter((incident) => { 119 + const { startedAt, resolvedAt } = incident; 120 + const day = new Date(props.day); 121 + const eod = endOfDay(day); 122 + const sod = startOfDay(day); 123 + 124 + if (!startedAt) return false; // not started 125 + if (!resolvedAt) return true; // still ongoing 126 + 127 + const hasResolvedBeforeStartOfDay = 128 + resolvedAt.getTime() <= sod.getTime(); 129 + 130 + if (hasResolvedBeforeStartOfDay) return false; 131 + 132 + const hasStartedBeforeEndOfDay = 133 + startedAt.getTime() <= eod.getTime(); 134 + 135 + const hasResolvedBeforeEndOfDay = 136 + resolvedAt.getTime() <= eod.getTime(); 137 + 138 + if (hasStartedBeforeEndOfDay || hasResolvedBeforeEndOfDay) 139 + return true; 140 + 141 + if (hasResolvedBeforeEndOfDay) return true; 142 + 143 + return false; 144 + }); 145 + 146 + return ( 147 + <Bar 148 + key={i} 149 + reports={dateReports} 150 + incidents={dateIncidents} 151 + {...props} 152 + /> 153 + ); 154 + })} 155 + </div> 156 + </div> 157 + <div className="text-muted-foreground flex items-center justify-between text-xs font-light"> 158 + <p>{_data.length - 1} days ago</p> 159 + <p>Today</p> 160 + </div> 161 + </div> 162 + ); 163 + } 164 + 165 + type BarProps = Monitor & { blacklist?: string } & Pick< 166 + TrackerProps, 167 + "reports" | "incidents" 168 + >; 169 + 170 + const Bar = ({ count, ok, day, blacklist, reports, incidents }: BarProps) => { 171 + const [open, setOpen] = React.useState(false); 172 + const status = getStatusByRatio(ok / count); 173 + const isIncident = incidents && incidents.length > 0; 174 + 175 + const { label, variant } = isIncident ? incidentStatus : status; 176 + 177 + const className = tracker({ 178 + report: reports && reports.length > 0 ? 30 : undefined, 179 + variant: blacklist ? "blacklist" : variant, 180 + }); 181 + 182 + return ( 183 + <HoverCard 184 + openDelay={100} 185 + closeDelay={100} 186 + open={open} 187 + onOpenChange={setOpen} 188 + > 189 + <HoverCardTrigger onClick={() => setOpen(true)} asChild> 190 + <div className={className} /> 191 + </HoverCardTrigger> 192 + <HoverCardContent side="top" className="w-auto max-w-[16rem] p-2"> 193 + {blacklist ? ( 194 + <p className="text-muted-foreground text-sm">{blacklist}</p> 195 + ) : ( 196 + <div> 197 + <div className="flex gap-2"> 198 + <div 199 + className={cn(className, "h-auto w-1 flex-none rounded-full")} 200 + /> 201 + <div className="grid flex-1 gap-1"> 202 + <div className="flex justify-between gap-8 text-sm"> 203 + <p className="font-semibold">{label}</p> 204 + <p className="text-muted-foreground"> 205 + {format(new Date(day), "MMM d")} 206 + </p> 207 + </div> 208 + <div className="text-muted-foreground flex justify-between gap-8 text-xs font-light"> 209 + <p> 210 + <code className="text-green-500">{count}</code> requests 211 + </p> 212 + <p> 213 + <code className="text-red-500">{count - ok}</code> failed 214 + </p> 215 + </div> 216 + </div> 217 + </div> 218 + {reports && reports.length > 0 ? ( 219 + <> 220 + <Separator className="my-1.5" /> 221 + <StatusReportList reports={reports} /> 222 + </> 223 + ) : null} 224 + {incidents && incidents.length > 0 ? ( 225 + <> 226 + <Separator className="my-1.5" /> 227 + <DowntimeText incidents={incidents} day={day} /> 228 + </> 229 + ) : null} 230 + </div> 231 + )} 232 + </HoverCardContent> 233 + </HoverCard> 234 + ); 235 + }; 236 + 237 + export function StatusReportList({ reports }: { reports: StatusReport[] }) { 238 + return ( 239 + <ul> 240 + {reports?.map((report) => ( 241 + <li key={report.id} className="text-muted-foreground text-sm"> 242 + <Link 243 + // TODO: include setPrefixUrl for local development 244 + href={`./incidents/${report.id}`} 245 + className="hover:text-foreground group flex items-center justify-between gap-2" 246 + > 247 + <span className="truncate">{report.title}</span> 248 + <ChevronRight className="h-4 w-4" /> 249 + </Link> 250 + </li> 251 + ))} 252 + </ul> 253 + ); 254 + } 255 + 256 + export function DowntimeText({ 257 + incidents, 258 + day, 259 + }: { 260 + incidents: Incidents; 261 + day: string; // TODO: use Date 262 + }) { 263 + const startOfDayDate = startOfDay(new Date(day)); 264 + const endOfDayDate = endOfDay(new Date(day)); 265 + 266 + const incidentLength = incidents 267 + ?.map((incident) => { 268 + const { startedAt, resolvedAt } = incident; 269 + if (!startedAt) return 0; 270 + if (!resolvedAt) 271 + return ( 272 + endOfDayDate.getTime() - 273 + Math.max(startOfDayDate.getTime(), startedAt.getTime()) 274 + ); 275 + return ( 276 + Math.min(resolvedAt.getTime(), endOfDayDate.getTime()) - 277 + Math.max(startOfDayDate.getTime(), startedAt.getTime()) 278 + ); 279 + }) 280 + // add 1 second because end of day is 23:59:59 281 + .reduce((acc, curr) => acc + 1 + curr, 0); 282 + 283 + const days = Math.floor(incidentLength / (1000 * 60 * 60 * 24)); 284 + const minutes = Math.floor((incidentLength / (1000 * 60)) % 60); 285 + const hours = Math.floor((incidentLength / (1000 * 60 * 60)) % 24); 286 + 287 + return ( 288 + <p className="text-muted-foreground text-xs"> 289 + Down for{" "} 290 + {formatDuration( 291 + { minutes, hours, days }, 292 + { format: ["days", "hours", "minutes", "seconds"], zero: false }, 293 + )} 294 + </p> 295 + ); 296 + }
+9 -4
apps/web/src/lib/tracker.ts
··· 1 1 import type { Monitor, Ping } from "@openstatus/tinybird"; 2 2 3 - export type StatusVariant = "up" | "degraded" | "down" | "empty"; 3 + export type StatusVariant = "up" | "degraded" | "down" | "empty" | "incident"; 4 4 5 5 type GetStatusReturnType = { 6 6 label: string; 7 7 variant: StatusVariant; 8 8 }; 9 9 10 + export const incidentStatus: GetStatusReturnType = { 11 + label: "Incident", 12 + variant: "incident", 13 + }; 14 + 10 15 /** 11 16 * Get the status of a monitor based on its ratio 12 17 * @param ratio 13 18 * @returns 14 19 */ 15 - export const getStatus = (ratio: number): GetStatusReturnType => { 20 + export const getStatusByRatio = (ratio: number): GetStatusReturnType => { 16 21 if (isNaN(ratio)) 17 22 return { 18 23 label: "Missing", ··· 103 108 { count: 0, ok: 0 }, 104 109 ); 105 110 const ratio = ok / count; 106 - if (isNaN(ratio)) return getStatus(1); // outsmart caching issue 107 - return getStatus(ratio); 111 + if (isNaN(ratio)) return getStatusByRatio(1); // outsmart caching issue 112 + return getStatusByRatio(ratio); 108 113 } 109 114 110 115 /**
+14
packages/api/src/router/page.ts
··· 3 3 4 4 import { and, eq, inArray, or, sql } from "@openstatus/db"; 5 5 import { 6 + incidentTable, 6 7 insertPageSchema, 7 8 monitor, 8 9 monitorsToPages, ··· 155 156 // public if we use trpc hooks to get the page from the url 156 157 getPageBySlug: publicProcedure 157 158 .input(z.object({ slug: z.string().toLowerCase() })) 159 + .output(selectPublicPageSchemaWithRelation.optional()) 158 160 .query(async (opts) => { 159 161 console.log(opts.input.slug); 160 162 const result = await opts.ctx.db.query.page.findFirst({ ··· 237 239 .all() 238 240 : []; 239 241 242 + const incidents = await opts.ctx.db 243 + .select() 244 + .from(incidentTable) 245 + .where( 246 + inArray( 247 + incidentTable.monitorId, 248 + monitors.map((m) => m.id), 249 + ), 250 + ) 251 + .all(); 252 + 240 253 return selectPublicPageSchemaWithRelation.parse({ 241 254 ...result, 242 255 monitors, 256 + incidents, 243 257 statusReports, 244 258 workspacePlan: workspaceResult?.plan, 245 259 });
+1 -1
packages/db/src/schema/incidents/incident.ts
··· 35 35 startedAt: integer("started_at", { mode: "timestamp" }) 36 36 .notNull() 37 37 .default(sql`(strftime('%s', 'now'))`), 38 - // Who has acknoledge the incident 38 + // Who has acknowledged the incident 39 39 acknowledgedAt: integer("acknowledged_at", { mode: "timestamp" }), 40 40 acknowledgedBy: integer("acknowledged_by").references(() => user.id), 41 41
+15
packages/db/src/schema/shared.ts
··· 1 1 import { z } from "zod"; 2 2 3 + import { selectIncidentSchema } from "./incidents/validation"; 3 4 import { selectMonitorSchema } from "./monitors"; 4 5 import { selectPageSchema } from "./pages"; 5 6 import { ··· 36 37 statusReports: selectStatusReportPageSchema, 37 38 }); 38 39 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 + 39 53 export const selectPublicPageSchemaWithRelation = selectPageSchema 40 54 .extend({ 41 55 monitors: z.array(selectPublicMonitorSchema), 42 56 statusReports: selectStatusReportPageSchema, 57 + incidents: selectIncidentPageSchema, 43 58 workspacePlan: workspacePlanSchema 44 59 .nullable() 45 60 .default("free")
+8 -8
packages/tinybird/pipes/status_timezone.pipe
··· 12 12 toDateTime(cronTimestamp / 1000, 'UTC') AS day, 13 13 toTimezone(day, {{ String(timezone, 'Europe/London') }}) as with_timezone, 14 14 toStartOfDay(with_timezone) as start_of_day, 15 - statusCode, 16 - latency 15 + -- latency, 16 + statusCode 17 17 FROM ping_response__v7 18 18 {% if defined(url) %} AND url = {{ String(url) }} {% end %} 19 19 WHERE monitorId = {{ String(monitorId, '1') }} ··· 28 28 29 29 % 30 30 SELECT 31 + -- round(avg(latency)) as avgLatency, 32 + -- round(quantile(0.75)(latency)) as p75Latency, 33 + -- round(quantile(0.9)(latency)) as p90Latency, 34 + -- round(quantile(0.95)(latency)) as p95Latency, 35 + -- round(quantile(0.99)(latency)) as p99Latency, 31 36 start_of_day as day, 32 37 count() AS count, 33 - count(multiIf((statusCode >= 200) AND (statusCode <= 299), 1, NULL)) AS ok, 34 - round(avg(latency)) as avgLatency, 35 - round(quantile(0.75)(latency)) as p75Latency, 36 - round(quantile(0.9)(latency)) as p90Latency, 37 - round(quantile(0.95)(latency)) as p95Latency, 38 - round(quantile(0.99)(latency)) as p99Latency 38 + count(multiIf((statusCode >= 200) AND (statusCode <= 299), 1, NULL)) AS ok 39 39 FROM group_by_cronTimestamp 40 40 GROUP BY start_of_day 41 41 ORDER BY start_of_day
+8 -10
packages/tinybird/src/validation.ts
··· 154 154 /** 155 155 * Values from the pipe status_timezone 156 156 */ 157 - export const tbBuildMonitorList = z 158 - .object({ 159 - count: z.number().int(), 160 - ok: z.number().int(), 161 - day: z.string().transform((val) => { 162 - // That's a hack because clickhouse return the date in UTC but in shitty format (2021-09-01 00:00:00) 163 - return new Date(`${val} GMT`).toISOString(); 164 - }), 165 - }) 166 - .merge(latencyMetrics); 157 + export const tbBuildMonitorList = z.object({ 158 + count: z.number().int(), 159 + ok: z.number().int(), 160 + day: z.string().transform((val) => { 161 + // That's a hack because clickhouse return the date in UTC but in shitty format (2021-09-01 00:00:00) 162 + return new Date(`${val} GMT`).toISOString(); 163 + }), 164 + }); 167 165 168 166 /** 169 167 * Params for pipe home_stats