Openstatus www.openstatus.dev

chore: feedback v2 (#1300)

* chore: include weekly uptime in overview

* hide light chart

* chore: send email batches in chunks

* chore: discord and github onboarding card

* wip: chart line regions

* wip: tabs

* chore: quantile and resolution popover

* fix: onboarding workspace refresh on submit

* chore: learned from message format

* fix: typo

authored by

Maximilian Kaske and committed by
GitHub
12eeb446 ec07a472

+639 -35
+58 -7
apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx
··· 3 3 import { ChartAreaLatency } from "@/components/chart/chart-area-latency"; 4 4 import { ChartAreaTimingPhases } from "@/components/chart/chart-area-timing-phases"; 5 5 import { ChartBarUptime } from "@/components/chart/chart-bar-uptime"; 6 + import { ChartLineRegions } from "@/components/chart/chart-line-regions"; 6 7 import { 7 8 Section, 8 9 SectionDescription, ··· 18 19 import { AuditLogsWrapper } from "@/components/data-table/audit-logs/wrapper"; 19 20 import { columns as regionColumns } from "@/components/data-table/response-logs/regions/columns"; 20 21 import { GlobalUptimeSection } from "@/components/metric/global-uptime/section"; 22 + import { PopoverQuantile } from "@/components/popovers/popover-quantile"; 23 + import { PopoverResolution } from "@/components/popovers/popover-resolution"; 21 24 import { DataTable } from "@/components/ui/data-table/data-table"; 25 + import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 22 26 import { mapRegionMetrics } from "@/data/metrics.client"; 23 27 import type { RegionMetric } from "@/data/region-metrics"; 24 28 import { useTRPC } from "@/lib/trpc/client"; ··· 54 58 const { data: regionTimeline } = useQuery(regionTimelineQuery); 55 59 56 60 const regionMetrics: RegionMetric[] = React.useMemo(() => { 57 - return mapRegionMetrics(regionTimeline, monitor?.regions ?? []); 58 - }, [regionTimeline, monitor]); 61 + return mapRegionMetrics(regionTimeline, monitor?.regions ?? [], percentile); 62 + }, [regionTimeline, monitor, percentile]); 59 63 60 64 if (!monitor) return null; 61 65 ··· 94 98 <SectionHeader> 95 99 <SectionTitle>Uptime</SectionTitle> 96 100 <SectionDescription> 97 - Uptime accross all the regions 101 + Uptime accross all the selected regions 98 102 </SectionDescription> 99 103 </SectionHeader> 100 104 <ChartBarUptime ··· 116 120 </SectionHeader> 117 121 <div className="flex flex-wrap gap-2"> 118 122 <div> 119 - The <DropdownPercentile /> quantile within a <DropdownInterval />{" "} 120 - resolution 123 + The <DropdownPercentile />{" "} 124 + <PopoverQuantile>quantile</PopoverQuantile> within a{" "} 125 + <DropdownInterval />{" "} 126 + <PopoverResolution>resolution</PopoverResolution> 121 127 </div> 122 128 <div> 123 129 <ButtonReset only={["percentile", "interval"]} /> ··· 148 154 <SectionHeader> 149 155 <SectionTitle>Regions</SectionTitle> 150 156 <SectionDescription> 151 - Every region&apos;s latency over the last 24 hours 157 + Every selected region&apos;s latency trend 152 158 </SectionDescription> 153 159 </SectionHeader> 154 - <DataTable data={regionMetrics} columns={regionColumns} /> 160 + <div className="flex flex-wrap gap-2"> 161 + <div> 162 + The <DropdownPercentile />{" "} 163 + <PopoverQuantile>quantile</PopoverQuantile> trend over the{" "} 164 + <DropdownPeriod /> 165 + </div> 166 + <div> 167 + <ButtonReset only={["percentile", "period"]} /> 168 + </div> 169 + </div> 170 + <Tabs defaultValue="table"> 171 + <TabsList> 172 + <TabsTrigger value="table">Table</TabsTrigger> 173 + <TabsTrigger value="chart">Chart</TabsTrigger> 174 + </TabsList> 175 + <TabsContent value="table"> 176 + <DataTable data={regionMetrics} columns={regionColumns} /> 177 + </TabsContent> 178 + <TabsContent value="chart"> 179 + <ChartLineRegions 180 + className="mt-3" 181 + regions={monitor.regions.filter((region) => 182 + selectedRegions.includes(region), 183 + )} 184 + data={regionMetrics.reduce( 185 + (acc, region) => { 186 + region.trend.forEach((t) => { 187 + const existing = acc.find( 188 + (d) => d.timestamp === t.timestamp, 189 + ); 190 + if (existing) { 191 + existing[region.region] = t[region.region]; 192 + } else { 193 + acc.push({ 194 + timestamp: t.timestamp, 195 + [region.region]: t[region.region], 196 + }); 197 + } 198 + }); 199 + return acc; 200 + }, 201 + [] as { timestamp: number; [key: string]: number }[], 202 + )} 203 + /> 204 + </TabsContent> 205 + </Tabs> 155 206 </Section> 156 207 <Section> 157 208 <SectionHeader>
+20 -2
apps/dashboard/src/app/(dashboard)/onboarding/client.tsx
··· 70 70 description: "See what's new in OpenStatus.", 71 71 href: "https://openstatus.dev/changelog", 72 72 }, 73 + { 74 + id: "discord", 75 + title: "Discord", 76 + description: "Join our Discord server if you get stuck.", 77 + href: "https://discord.gg/openstatus", 78 + }, 79 + { 80 + id: "github", 81 + title: "GitHub", 82 + description: "Leave a star on GitHub, request features or report issues.", 83 + href: "https://github.com/openstatus-dev/openstatus", 84 + }, 73 85 ]; 74 86 75 87 export function Client() { 76 88 const [{ step }, setSearchParams] = useQueryStates(searchParamsParsers); 77 89 const trpc = useTRPC(); 78 90 const queryClient = useQueryClient(); 79 - const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); 91 + const { data: workspace, refetch } = useQuery( 92 + trpc.workspace.get.queryOptions(), 93 + ); 80 94 const createMonitorMutation = useMutation( 81 95 trpc.monitor.create.mutationOptions({ 82 96 onSuccess: () => { 83 97 setSearchParams({ step: "2" }); 98 + refetch(); 84 99 queryClient.invalidateQueries({ 85 100 queryKey: trpc.monitor.list.queryKey(), 86 101 }); ··· 91 106 trpc.page.create.mutationOptions({ 92 107 onSuccess: () => { 93 108 setSearchParams({ step: "next" }); 109 + refetch(); 94 110 queryClient.invalidateQueries({ 95 111 queryKey: trpc.page.list.queryKey(), 96 112 }); ··· 203 219 <LearnFromForm 204 220 onSubmit={async (values) => { 205 221 await createFeedbackMutation.mutateAsync({ 206 - message: `I learned about OpenStatus from ${values.from}${values.other ? `: ${values.other}` : ""}`, 222 + message: `I learned about OpenStatus from *${values.from}${ 223 + values.other ? `: ${values.other}` : "" 224 + }*`, 207 225 }); 208 226 }} 209 227 />
-1
apps/dashboard/src/components/chart/chart-area-timing-phases.tsx
··· 103 103 /> 104 104 <ChartTooltip 105 105 cursor={false} 106 - defaultIndex={10} 107 106 content={ 108 107 <ChartTooltipContent 109 108 indicator="dot"
+88
apps/dashboard/src/components/chart/chart-bar-uptime-light.tsx
··· 1 + "use client"; 2 + 3 + import { Skeleton } from "@/components/ui/skeleton"; 4 + import { Bar, BarChart, XAxis } from "recharts"; 5 + 6 + import { 7 + type ChartConfig, 8 + ChartContainer, 9 + ChartTooltip, 10 + ChartTooltipContent, 11 + } from "@/components/ui/chart"; 12 + import { mapUptime } from "@/data/metrics.client"; 13 + import { useTRPC } from "@/lib/trpc/client"; 14 + import type { Region } from "@openstatus/db/src/schema/constants"; 15 + import { useQuery } from "@tanstack/react-query"; 16 + // import { startOfDay, subDays } from "date-fns"; 17 + 18 + const chartConfig = { 19 + ok: { 20 + label: "Success", 21 + color: "var(--color-success)", 22 + }, 23 + degraded: { 24 + label: "Degraded", 25 + color: "var(--color-warning)", 26 + }, 27 + error: { 28 + label: "Error", 29 + color: "var(--color-destructive)", 30 + }, 31 + } satisfies ChartConfig; 32 + 33 + export function ChartBarUptimeLight({ 34 + monitorId, 35 + type, 36 + regions, 37 + }: { 38 + monitorId: string; 39 + type: "http" | "tcp"; 40 + regions?: Region[]; 41 + }) { 42 + const trpc = useTRPC(); 43 + 44 + const { data: uptime, isLoading } = useQuery( 45 + trpc.tinybird.uptime.queryOptions({ 46 + interval: 60 * 24, 47 + // fromDate: startOfDay(subDays(new Date(), 7)).toISOString(), // FIXME: 48 + period: "7d", 49 + monitorId, 50 + regions, 51 + type, 52 + }), 53 + ); 54 + 55 + if (isLoading) { 56 + return <Skeleton className=" my-auto h-5 w-full" />; 57 + } 58 + 59 + const refinedUptime = uptime ? mapUptime(uptime) : []; 60 + 61 + if (refinedUptime.length === 0) { 62 + return <span className="text-muted-foreground">-</span>; 63 + } 64 + 65 + return ( 66 + <ChartContainer config={chartConfig} className="h-[28px] w-full"> 67 + <BarChart accessibilityLayer data={refinedUptime} barCategoryGap={1}> 68 + <ChartTooltip 69 + cursor={false} 70 + allowEscapeViewBox={{ x: false, y: true }} 71 + wrapperStyle={{ zIndex: 1 }} 72 + content={<ChartTooltipContent indicator="dot" />} 73 + /> 74 + <Bar dataKey="ok" stackId="a" fill="var(--color-ok)" /> 75 + <Bar dataKey="error" stackId="a" fill="var(--color-error)" /> 76 + <Bar dataKey="degraded" stackId="a" fill="var(--color-degraded)" /> 77 + <XAxis 78 + dataKey="interval" 79 + tickLine={false} 80 + tickMargin={8} 81 + minTickGap={10} 82 + axisLine={false} 83 + hide 84 + /> 85 + </BarChart> 86 + </ChartContainer> 87 + ); 88 + }
+140
apps/dashboard/src/components/chart/chart-line-regions.tsx
··· 1 + "use client"; 2 + 3 + import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; 4 + 5 + import { 6 + type ChartConfig, 7 + ChartContainer, 8 + ChartLegend, 9 + ChartLegendContent, 10 + ChartTooltip, 11 + ChartTooltipContent, 12 + } from "@/components/ui/chart"; 13 + import { regionColors } from "@/data/regions"; 14 + import { cn } from "@/lib/utils"; 15 + import { flyRegionsDict } from "@openstatus/utils"; 16 + import { ChartTooltipNumber } from "./chart-tooltip-number"; 17 + 18 + const chartConfig = { 19 + ams: { label: flyRegionsDict.ams.location, color: regionColors.ams }, 20 + arn: { label: flyRegionsDict.arn.location, color: regionColors.arn }, 21 + atl: { label: flyRegionsDict.atl.location, color: regionColors.atl }, 22 + bog: { label: flyRegionsDict.bog.location, color: regionColors.bog }, 23 + bom: { label: flyRegionsDict.bom.location, color: regionColors.bom }, 24 + bos: { label: flyRegionsDict.bos.location, color: regionColors.bos }, 25 + cdg: { label: flyRegionsDict.cdg.location, color: regionColors.cdg }, 26 + den: { label: flyRegionsDict.den.location, color: regionColors.den }, 27 + dfw: { label: flyRegionsDict.dfw.location, color: regionColors.dfw }, 28 + ewr: { label: flyRegionsDict.ewr.location, color: regionColors.ewr }, 29 + eze: { label: flyRegionsDict.eze.location, color: regionColors.eze }, 30 + fra: { label: flyRegionsDict.fra.location, color: regionColors.fra }, 31 + gdl: { label: flyRegionsDict.gdl.location, color: regionColors.gdl }, 32 + gig: { label: flyRegionsDict.gig.location, color: regionColors.gig }, 33 + gru: { label: flyRegionsDict.gru.location, color: regionColors.gru }, 34 + hkg: { label: flyRegionsDict.hkg.location, color: regionColors.hkg }, 35 + iad: { label: flyRegionsDict.iad.location, color: regionColors.iad }, 36 + jnb: { label: flyRegionsDict.jnb.location, color: regionColors.jnb }, 37 + lax: { label: flyRegionsDict.lax.location, color: regionColors.lax }, 38 + lhr: { label: flyRegionsDict.lhr.location, color: regionColors.lhr }, 39 + mad: { label: flyRegionsDict.mad.location, color: regionColors.mad }, 40 + mia: { label: flyRegionsDict.mia.location, color: regionColors.mia }, 41 + nrt: { label: flyRegionsDict.nrt.location, color: regionColors.nrt }, 42 + ord: { label: flyRegionsDict.ord.location, color: regionColors.ord }, 43 + otp: { label: flyRegionsDict.otp.location, color: regionColors.otp }, 44 + phx: { label: flyRegionsDict.phx.location, color: regionColors.phx }, 45 + qro: { label: flyRegionsDict.qro.location, color: regionColors.qro }, 46 + scl: { label: flyRegionsDict.scl.location, color: regionColors.scl }, 47 + sjc: { label: flyRegionsDict.sjc.location, color: regionColors.sjc }, 48 + sea: { label: flyRegionsDict.sea.location, color: regionColors.sea }, 49 + sin: { label: flyRegionsDict.sin.location, color: regionColors.sin }, 50 + syd: { label: flyRegionsDict.syd.location, color: regionColors.syd }, 51 + waw: { label: flyRegionsDict.waw.location, color: regionColors.waw }, 52 + yul: { label: flyRegionsDict.yul.location, color: regionColors.yul }, 53 + yyz: { label: flyRegionsDict.yyz.location, color: regionColors.yyz }, 54 + } satisfies ChartConfig; 55 + 56 + export type TrendPoint = { 57 + timestamp: number; // unix millis 58 + [key: string]: number; // milliseconds 59 + }; 60 + 61 + export function ChartLineRegions({ 62 + className, 63 + data, 64 + regions, 65 + }: { 66 + className?: string; 67 + data: TrendPoint[]; 68 + regions: string[]; 69 + }) { 70 + const trendData = data ?? []; 71 + 72 + console.log({ data, regions }); 73 + 74 + const chartData = trendData.map((d) => ({ 75 + ...d, 76 + timestamp: new Date(d.timestamp).toLocaleString("default", { 77 + hour: "numeric", 78 + minute: "numeric", 79 + day: "numeric", 80 + month: "short", 81 + }), 82 + })); 83 + 84 + return ( 85 + <ChartContainer 86 + config={chartConfig} 87 + className={cn("h-[250px] w-full", className)} 88 + > 89 + <LineChart 90 + accessibilityLayer 91 + data={chartData} 92 + margin={{ 93 + left: 12, 94 + right: 12, 95 + }} 96 + > 97 + <CartesianGrid vertical={false} /> 98 + <XAxis dataKey="timestamp" /> 99 + <ChartTooltip 100 + cursor={false} 101 + content={ 102 + <ChartTooltipContent 103 + formatter={(value, name) => ( 104 + <ChartTooltipNumber 105 + chartConfig={chartConfig} 106 + value={value} 107 + name={name} 108 + /> 109 + )} 110 + /> 111 + } 112 + /> 113 + {regions.map((region) => { 114 + return ( 115 + <Line 116 + key={region} 117 + dataKey={region} 118 + type="monotone" 119 + stroke={`var(--color-${region})`} 120 + strokeWidth={2} 121 + dot={false} 122 + /> 123 + ); 124 + })} 125 + <YAxis 126 + domain={["dataMin", "dataMax"]} 127 + tickLine={false} 128 + axisLine={false} 129 + tickMargin={8} 130 + orientation="right" 131 + tickFormatter={(value) => `${value}ms`} 132 + /> 133 + <ChartLegend 134 + className="flex-wrap" 135 + content={<ChartLegendContent className="text-nowrap" />} 136 + /> 137 + </LineChart> 138 + </ChartContainer> 139 + ); 140 + }
+16 -3
apps/dashboard/src/components/data-table/monitors/columns.tsx
··· 1 1 "use client"; 2 2 3 3 import { TableCellLink } from "@/components/data-table/table-cell-link"; 4 - // import { TableCellNumber } from "@/components/data-table/table-cell-number"; 5 4 import { Badge } from "@/components/ui/badge"; 6 5 import { Checkbox } from "@/components/ui/checkbox"; 7 - // import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; 8 6 import type { ColumnDef } from "@tanstack/react-table"; 9 7 import { DataTableRowActions } from "./data-table-row-actions"; 10 8 ··· 176 174 enableHiding: false, 177 175 enableGlobalFilter: false, 178 176 }, 177 + // { 178 + // id: "uptime", 179 + // accessorFn: (row) => `uptime-${row.id}`, 180 + // header: "Last Week", 181 + // cell: ({ row }) => { 182 + // return ( 183 + // <ChartBarUptimeLight 184 + // monitorId={String(row.original.id)} 185 + // type={row.original.jobType as "http" | "tcp"} 186 + // /> 187 + // ); 188 + // }, 189 + // enableHiding: false, 190 + // enableGlobalFilter: false, 191 + // }, 179 192 { 180 193 id: "lastTimestamp", 181 194 header: "Last Checked", ··· 185 198 : row.globalMetrics, 186 199 cell: ({ row }) => { 187 200 const value = row.getValue("lastTimestamp"); 188 - if (value === undefined) return <TableCellSkeleton />; 201 + if (value === undefined) return <TableCellSkeleton className="w-full" />; 189 202 return ( 190 203 <TableCellDate 191 204 value={
+38
apps/dashboard/src/components/popovers/popover-quantile.tsx
··· 1 + import { 2 + Popover, 3 + PopoverContent, 4 + PopoverTrigger, 5 + } from "@/components/ui/popover"; 6 + import { Separator } from "@/components/ui/separator"; 7 + import { cn } from "@/lib/utils"; 8 + 9 + export function PopoverQuantile({ 10 + children, 11 + className, 12 + ...props 13 + }: React.ComponentProps<typeof PopoverTrigger>) { 14 + return ( 15 + <Popover> 16 + <PopoverTrigger 17 + className={cn( 18 + "shrink-0 rounded-md p-0 underline decoration-muted-foreground/70 decoration-dotted underline-offset-2 outline-none transition-all hover:decoration-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=open]:decoration-foreground dark:aria-invalid:ring-destructive/40", 19 + className, 20 + )} 21 + {...props} 22 + > 23 + {children} 24 + </PopoverTrigger> 25 + <PopoverContent side="top" className="p-0 text-sm"> 26 + <p className="px-3 py-2 font-medium"> 27 + A quantile represents a specific percentile in your dataset. 28 + </p> 29 + <Separator /> 30 + <p className="px-3 py-2 text-muted-foreground"> 31 + For example, p50 is the 50th percentile - the point below which 50% of 32 + data falls. Higher percentiles include more data and highlight the 33 + upper range. 34 + </p> 35 + </PopoverContent> 36 + </Popover> 37 + ); 38 + }
+37
apps/dashboard/src/components/popovers/popover-resolution.tsx
··· 1 + import { 2 + Popover, 3 + PopoverContent, 4 + PopoverTrigger, 5 + } from "@/components/ui/popover"; 6 + import { Separator } from "@/components/ui/separator"; 7 + import { cn } from "@/lib/utils"; 8 + 9 + export function PopoverResolution({ 10 + children, 11 + className, 12 + ...props 13 + }: React.ComponentProps<typeof PopoverTrigger>) { 14 + return ( 15 + <Popover> 16 + <PopoverTrigger 17 + className={cn( 18 + "shrink-0 rounded-md p-0 underline decoration-muted-foreground/70 decoration-dotted underline-offset-2 outline-none transition-all hover:decoration-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=open]:decoration-foreground dark:aria-invalid:ring-destructive/40", 19 + className, 20 + )} 21 + {...props} 22 + > 23 + {children} 24 + </PopoverTrigger> 25 + <PopoverContent side="top" className="p-0 text-sm"> 26 + <p className="px-3 py-2 font-medium"> 27 + Run data aggregation on fixed time boundaries. 28 + </p> 29 + <Separator /> 30 + <p className="px-3 py-2 text-muted-foreground"> 31 + A 30-minute resolution aligns to the top or bottom of the hour (e.g., 32 + 00:00, 00:30) so all intervals are consistent for analysis. 33 + </p> 34 + </PopoverContent> 35 + </Popover> 36 + ); 37 + }
+1 -1
apps/dashboard/src/components/ui/data-table/data-table.tsx
··· 66 66 defaultSorting = [], 67 67 defaultColumnVisibility = {}, 68 68 defaultColumnFilters = [], 69 - defaultPagination = { pageIndex: 0, pageSize: 20 }, 69 + defaultPagination = { pageIndex: 0, pageSize: 10 }, 70 70 autoResetPageIndex = true, 71 71 columnFilters, 72 72 setColumnFilters,
+14 -3
apps/dashboard/src/data/metrics.client.ts
··· 109 109 export function mapRegionMetrics( 110 110 timeline: RouterOutputs["tinybird"]["metricsRegions"] | undefined, 111 111 regions: Region[], 112 + percentile: (typeof PERCENTILES)[number], 112 113 ): RegionMetric[] { 113 114 if (!timeline) 114 115 return (regions ··· 118 119 p50: 0, 119 120 p90: 0, 120 121 p99: 0, 121 - trend: [] as { latency: number; timestamp: number }[], 122 + trend: [] as { 123 + latency: number; 124 + timestamp: number; 125 + [key: string]: number; 126 + }[], 122 127 })) ?? []) as RegionMetric[]; 123 128 124 129 type TimelineRow = (typeof timeline.data)[number]; ··· 130 135 p50: number; 131 136 p90: number; 132 137 p99: number; 133 - trend: { latency: number; timestamp: number }[]; 138 + trend: { 139 + latency: number; 140 + timestamp: number; 141 + [key: string]: number; 142 + }[]; 134 143 } 135 144 >(); 136 145 ··· 148 157 }; 149 158 150 159 entry.trend.push({ 151 - latency: row.p50Latency ?? 0, 160 + latency: row[PERCENTILE_MAP[percentile]] ?? 0, 152 161 timestamp: row.timestamp, 162 + [region]: row[PERCENTILE_MAP[percentile]] ?? 0, 153 163 }); 154 164 155 165 entry.p50 += row.p50Latency ?? 0; ··· 161 171 162 172 map.forEach((entry) => { 163 173 const count = entry.trend.length || 1; 174 + entry.trend.reverse(); 164 175 entry.p50 = Math.round(entry.p50 / count); 165 176 entry.p90 = Math.round(entry.p90 / count); 166 177 entry.p99 = Math.round(entry.p99 / count);
+15 -3
apps/dashboard/src/data/region-metrics.ts
··· 6 6 p50: 100, 7 7 p90: 150, 8 8 p99: 200, 9 - trend: [{ latency: 100, timestamp: 1716729600 }], 9 + trend: [{ ams: 100, timestamp: 1716729600, latency: 100 }] as { 10 + [key: string]: number; 11 + timestamp: number; 12 + latency: number; 13 + }[], 10 14 }, 11 15 { 12 16 region: "fra" as const satisfies Region, 13 17 p50: 110, 14 18 p90: 155, 15 19 p99: 220, 16 - trend: [{ latency: 100, timestamp: 1716729600 }], 20 + trend: [{ fra: 100, timestamp: 1716729600, latency: 100 }] as { 21 + [key: string]: number; 22 + timestamp: number; 23 + latency: number; 24 + }[], 17 25 }, 18 26 { 19 27 region: "gru" as const satisfies Region, 20 28 p50: 120, 21 29 p90: 160, 22 30 p99: 230, 23 - trend: [{ latency: 100, timestamp: 1716729600 }], 31 + trend: [{ gru: 100, timestamp: 1716729600, latency: 100 }] as { 32 + [key: string]: number; 33 + timestamp: number; 34 + latency: number; 35 + }[], 24 36 }, 25 37 ]; 26 38
+38
apps/dashboard/src/data/regions.ts
··· 224 224 }, 225 225 {} as Record<string, Region[]>, 226 226 ); 227 + 228 + export const regionColors = { 229 + ams: "hsl(217.2 91.2% 59.8%)", 230 + arn: "hsl(238.7 83.5% 66.7%)", 231 + atl: "hsl(258.3 89.5% 66.3%)", 232 + bog: "hsl(270.7 91% 65.1%)", 233 + bom: "hsl(292.2 84.1% 60.6%)", 234 + bos: "hsl(330.4 81.2% 60.4%)", 235 + cdg: "hsl(349.7 89.2% 60.2%)", 236 + den: "hsl(215.4 16.3% 46.9%)", 237 + dfw: "hsl(220 8.9% 46.1%)", 238 + ewr: "hsl(240 3.8% 46.1%)", 239 + eze: "hsl(0 0% 45.1%)", 240 + fra: "hsl(25 5.3% 44.7%)", 241 + gdl: "hsl(0 84.2% 60.2%)", 242 + gig: "hsl(24.6 95% 53.1%)", 243 + gru: "hsl(37.7 92.1% 50.2%)", 244 + hkg: "hsl(45.4 93.4% 47.5%)", 245 + iad: "hsl(83.7 80.5% 44.3%)", 246 + jnb: "hsl(142.1 70.6% 45.3%)", 247 + lax: "hsl(160.1 84.1% 39.4%)", 248 + lhr: "hsl(173.4 80.4% 40%)", 249 + mad: "hsl(188.7 94.5% 42.7%)", 250 + mia: "hsl(198.6 88.7% 48.4%)", 251 + nrt: "hsl(217.2 91.2% 59.8%)", 252 + ord: "hsl(238.7 83.5% 66.7%)", 253 + otp: "hsl(258.3 89.5% 66.3%)", 254 + phx: "hsl(270.7 91% 65.1%)", 255 + qro: "hsl(292.2 84.1% 60.6%)", 256 + scl: "hsl(330.4 81.2% 60.4%)", 257 + sjc: "hsl(349.7 89.2% 60.2%)", 258 + sea: "hsl(215.4 16.3% 46.9%)", 259 + sin: "hsl(220 8.9% 46.1%)", 260 + syd: "hsl(240 3.8% 46.1%)", 261 + waw: "hsl(0 0% 45.1%)", 262 + yul: "hsl(25 5.3% 44.7%)", 263 + yyz: "hsl(0 84.2% 60.2%)", 264 + } satisfies Record<Region, string>;
+11 -3
packages/api/src/router/tinybird/index.ts
··· 136 136 return type === "http" ? tb.httpGlobalMetricsDaily : tb.tcpGlobalMetricsDaily; 137 137 } 138 138 139 - function getUptime30dProcedure(type: Type) { 140 - return type === "http" ? tb.httpUptime30d : tb.tcpUptime30d; 139 + function getUptimeProcedure(period: "7d" | "30d", type: Type) { 140 + switch (period) { 141 + case "7d": 142 + return type === "http" ? tb.httpUptimeWeekly : tb.tcpUptimeWeekly; 143 + case "30d": 144 + return type === "http" ? tb.httpUptime30d : tb.tcpUptime30d; 145 + default: 146 + return type === "http" ? tb.httpUptime30d : tb.httpUptime30d; 147 + } 141 148 } 142 149 143 150 // TODO: missing pipes for other periods ··· 213 220 interval: z.number().int().optional(), // in minutes, default 30 214 221 regions: z.enum(flyRegions).array().optional(), 215 222 type: z.enum(types).default("http"), 223 + period: z.enum(["7d", "30d"]).default("30d"), 216 224 }), 217 225 ) 218 226 .query(async (opts) => { ··· 232 240 }); 233 241 } 234 242 235 - const procedure = getUptime30dProcedure(opts.input.type); 243 + const procedure = getUptimeProcedure(opts.input.period, opts.input.type); 236 244 return await procedure(opts.input); 237 245 }), 238 246
+27 -12
packages/emails/src/client.tsx
··· 12 12 import TeamInvitationEmail from "../emails/team-invitation"; 13 13 import type { TeamInvitationProps } from "../emails/team-invitation"; 14 14 15 + // split an array into chunks of a given size. 16 + function chunk<T>(array: T[], size: number): T[][] { 17 + const result: T[][] = []; 18 + for (let i = 0; i < array.length; i += size) { 19 + result.push(array.slice(i, i + size)); 20 + } 21 + return result; 22 + } 23 + 15 24 export class EmailClient { 16 25 public readonly client: Resend; 17 26 ··· 88 97 89 98 try { 90 99 const html = await render(<StatusReportEmail {...req} />); 91 - const result = await this.client.batch.send( 92 - req.to.map((subscriber) => ({ 93 - from: `${req.pageTitle} <notifications@notifications.openstatus.dev>`, 94 - subject: req.reportTitle, 95 - to: subscriber, 96 - html, 97 - })), 98 - ); 99 100 100 - if (!result.error) { 101 - console.log(`Sent status report update email to ${req.to}`); 102 - return; 101 + for (const recipients of chunk(req.to, 100)) { 102 + const result = await this.client.batch.send( 103 + recipients.map((subscriber) => ({ 104 + from: `${req.pageTitle} <notifications@notifications.openstatus.dev>`, 105 + subject: req.reportTitle, 106 + to: subscriber, 107 + html, 108 + })), 109 + ); 110 + 111 + if (result.error) { 112 + console.error( 113 + `Error sending status report update batch to ${recipients}: ${result.error}`, 114 + ); 115 + } 103 116 } 104 117 105 - throw result.error; 118 + console.log( 119 + `Sent status report update email to ${req.to.length} subscribers`, 120 + ); 106 121 } catch (err) { 107 122 console.error( 108 123 `Error sending status report update email to ${req.to}`,
+13
packages/tinybird/datasources/mv__http_uptime_7d__v1.datasource
··· 1 + # Data Source created from Pipe 'aggregate__http_uptime_7d__v1' 2 + 3 + SCHEMA > 4 + `time` DateTime, 5 + `region` LowCardinality(String), 6 + `requestStatus` Nullable(String), 7 + `monitorId` String, 8 + `workspaceId` String 9 + 10 + ENGINE "MergeTree" 11 + ENGINE_PARTITION_KEY "toYYYYMM(time)" 12 + ENGINE_SORTING_KEY "monitorId, time" 13 + ENGINE_TTL "time + toIntervalDay(7)"
+13
packages/tinybird/datasources/mv__tcp_uptime_7d__v1.datasource
··· 1 + # Data Source created from Pipe 'aggregate__tcp_uptime_7d__v1' 2 + 3 + SCHEMA > 4 + `time` DateTime, 5 + `region` String, 6 + `requestStatus` Nullable(String), 7 + `monitorId` Int32, 8 + `workspaceId` Int32 9 + 10 + ENGINE "MergeTree" 11 + ENGINE_PARTITION_KEY "toYYYYMM(time)" 12 + ENGINE_SORTING_KEY "monitorId, time" 13 + ENGINE_TTL "time + toIntervalDay(7)"
+15
packages/tinybird/pipes/aggregate__http_uptime_7d__v1.pipe
··· 1 + NODE aggregate 2 + SQL > 3 + 4 + SELECT 5 + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, 6 + region, 7 + requestStatus, 8 + monitorId, 9 + workspaceId 10 + FROM ping_response__v8 11 + 12 + TYPE materialized 13 + DATASOURCE mv__http_uptime_7d__v1 14 + 15 +
+15
packages/tinybird/pipes/aggregate__tcp_uptime_7d__v1.pipe
··· 1 + NODE aggregate 2 + SQL > 3 + 4 + SELECT 5 + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, 6 + region, 7 + requestStatus, 8 + monitorId, 9 + workspaceId 10 + FROM tcp_response__v0 11 + 12 + TYPE materialized 13 + DATASOURCE mv__tcp_uptime_7d__v1 14 + 15 +
+21
packages/tinybird/pipes/endpoint__http_uptime_7d__v1.pipe
··· 1 + TAGS "http" 2 + 3 + NODE endpoint 4 + SQL > 5 + 6 + % 7 + SELECT 8 + toStartOfInterval(time, INTERVAL {{ String(interval, '30', required=True) }} minute) AS interval, 9 + countIf(requestStatus = 'success') AS success, 10 + countIf(requestStatus = 'degraded') AS degraded, 11 + countIf(requestStatus = 'error') AS error 12 + FROM mv__http_uptime_7d__v1 13 + WHERE 14 + monitorId = {{ String(monitorId, '1', required=True) }} 15 + {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} 16 + {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} 17 + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} 18 + GROUP BY interval 19 + ORDER BY interval DESC 20 + 21 +
+21
packages/tinybird/pipes/endpoint__tcp_uptime_7d__v1.pipe
··· 1 + TAGS "http" 2 + 3 + NODE endpoint 4 + SQL > 5 + 6 + % 7 + SELECT 8 + toStartOfInterval(time, INTERVAL {{ String(interval, '30', required=True) }} minute) AS interval, 9 + countIf(requestStatus = 'success') AS success, 10 + countIf(requestStatus = 'degraded') AS degraded, 11 + countIf(requestStatus = 'error') AS error 12 + FROM mv__tcp_uptime_7d__v1 13 + WHERE 14 + monitorId = {{ String(monitorId, '4433', required=True) }} 15 + {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} 16 + {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} 17 + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} 18 + GROUP BY interval 19 + ORDER BY interval DESC 20 + 21 +
+38
packages/tinybird/src/client.ts
··· 1144 1144 }); 1145 1145 } 1146 1146 1147 + public get httpUptimeWeekly() { 1148 + return this.tb.buildPipe({ 1149 + pipe: "endpoint__http_uptime_7d__v1", 1150 + parameters: z.object({ 1151 + monitorId: z.string(), 1152 + fromDate: z.string().optional(), 1153 + toDate: z.string().optional(), 1154 + regions: z.enum(flyRegions).array().optional(), 1155 + interval: z.number().int().optional(), 1156 + }), 1157 + data: z.object({ 1158 + interval: z.coerce.date(), 1159 + success: z.number().int(), 1160 + degraded: z.number().int(), 1161 + error: z.number().int(), 1162 + }), 1163 + }); 1164 + } 1165 + 1147 1166 public get httpUptime30d() { 1148 1167 return this.tb.buildPipe({ 1149 1168 pipe: "endpoint__http_uptime_30d__v1", 1169 + parameters: z.object({ 1170 + monitorId: z.string(), 1171 + fromDate: z.string().optional(), 1172 + toDate: z.string().optional(), 1173 + regions: z.enum(flyRegions).array().optional(), 1174 + interval: z.number().int().optional(), 1175 + }), 1176 + data: z.object({ 1177 + interval: z.coerce.date(), 1178 + success: z.number().int(), 1179 + degraded: z.number().int(), 1180 + error: z.number().int(), 1181 + }), 1182 + }); 1183 + } 1184 + 1185 + public get tcpUptimeWeekly() { 1186 + return this.tb.buildPipe({ 1187 + pipe: "endpoint__tcp_uptime_7d__v1", 1150 1188 parameters: z.object({ 1151 1189 monitorId: z.string(), 1152 1190 fromDate: z.string().optional(),