diff --git a/apps/web/package.json b/apps/web/package.json index 82634c85..f1cabcac 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -30,6 +30,7 @@ "@openstatus/ui": "workspace:*", "@openstatus/upstash": "workspace:*", "@openstatus/utils": "workspace:*", + "@openstatus/rum": "workspace:*", "@sentry/integrations": "7.100.1", "@sentry/nextjs": "7.100.1", "@stripe/stripe-js": "2.1.6", diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/category-bar.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/category-bar.tsx new file mode 100644 index 00000000..a5b0a51a --- /dev/null +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/category-bar.tsx @@ -0,0 +1,112 @@ +// TODO: move to @/components folder + +import React from "react"; + +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@openstatus/ui"; + +import { cn } from "@/lib/utils"; + +const MAX_VALUE_RATIO = 1.3; // avoiding Infinity as number + +interface CategoryBarProps { + values: { + color: string; + min: number; + max: number; + }[]; + marker: number; +} + +export function CategoryBar({ values, marker }: CategoryBarProps) { + const getMarkerColor = React.useCallback(() => { + for (const value of values) { + if (marker >= value.min && marker <= value.max) { + return value.color; + } + } + return "bg-gray-500"; + }, [values, marker]); + + /** + * Get the max value from the values array + * If the max value is not finite, calculate the max value based on the ratio + */ + const getMaxValue = React.useCallback(() => { + const maxValue = values.reduce((acc, value) => { + if (Number.isFinite(value.max)) return Math.max(acc, value.max); + return acc * MAX_VALUE_RATIO; + }, 0); + return Math.max(maxValue, marker); + }, [values, marker]); + + const valuesWithPercentage = React.useMemo( + () => + values.map((value) => { + const max = Number.isFinite(value.max) ? value.max : getMaxValue(); + return { + ...value, + percentage: (max - value.min) / getMaxValue(), + }; + }), + [values, getMaxValue], + ); + + return ( +
+
+
+ 0 +
+ {valuesWithPercentage.slice(0, values.length - 1).map((value, i) => { + const width = `${(value.percentage * 100).toFixed(2)}%`; + return ( +
+ + {value.max} + +
+ ); + })} + {/* REMINDER: could be a thing - only display if maxValue !== Infinity */} +
+ {getMaxValue()} +
+
+
+ {valuesWithPercentage.map((value, i) => { + const width = `${(value.percentage * 100).toFixed(2)}%`; + return
; + })} +
+
+ + + +
+ + +

{marker}

+
+ + +
+
+ ); +} diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/rum-metric-card.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/rum-metric-card.tsx index f05afec6..f7e4d8f5 100644 --- a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/rum-metric-card.tsx +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/rum-metric-card.tsx @@ -1,21 +1,33 @@ import { Card } from "@tremor/react"; +import { getColorByType, webVitalsConfig } from "@openstatus/rum"; +import type { WebVitalEvents, WebVitalsValues } from "@openstatus/rum"; + import { api } from "@/trpc/server"; +import { CategoryBar } from "./category-bar"; + +function prepareWebVitalValues(values: WebVitalsValues) { + return values.map((value) => ({ + ...value, + color: getColorByType(value.type), + })); +} -export const RUMMetricCard = async ({ - event, -}: { - event: "CLS" | "FCP" | "FID" | "INP" | "LCP" | "TTFB"; -}) => { - const data = await api.rumRouter.GetEventMetricsForWorkspace.query({ - event: event, - }); +export const RUMMetricCard = async ({ event }: { event: WebVitalEvents }) => { + const data = await api.rumRouter.GetEventMetricsForWorkspace.query({ event }); + const eventConfig = webVitalsConfig[event]; return ( -

{event}

+

+ {eventConfig.label} ({event}) +

{data?.median || 0}

+
); }; diff --git a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/page.tsx b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/page.tsx index 83e56058..bff1dab6 100644 --- a/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/page.tsx +++ b/apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/page.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import Link from "next/link"; import { notFound } from "next/navigation"; +import { webVitalEvents } from "@openstatus/rum"; import { Button } from "@openstatus/ui"; import { EmptyState } from "@/components/dashboard/empty-state"; @@ -38,13 +39,10 @@ export default async function RUMPage() { } return ( -
- - - - - - +
+ {webVitalEvents.map((event) => ( + + ))}
); } diff --git a/packages/rum/package.json b/packages/rum/package.json new file mode 100644 index 00000000..41dcc509 --- /dev/null +++ b/packages/rum/package.json @@ -0,0 +1,17 @@ +{ + "name": "@openstatus/rum", + "version": "1.0.0", + "description": "", + "main": "src/index.ts", + "scripts": {}, + "dependencies": { + "zod": "3.22.4" + }, + "devDependencies": { + "@openstatus/tsconfig": "workspace:*", + "typescript": "5.4.5" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/packages/rum/src/config.ts b/packages/rum/src/config.ts new file mode 100644 index 00000000..08963a08 --- /dev/null +++ b/packages/rum/src/config.ts @@ -0,0 +1,169 @@ +import type { WebVitalsConfig } from "./types"; + +export const webVitalEvents = [ + "CLS", + "FCP", + "FID", + "INP", + "LCP", + "TTFB", +] as const; + +export const webVitalsConfig: WebVitalsConfig = { + CLS: { + unit: "", + label: "Cumulative Layout Shift", + description: + "CLS measures the sum of all individual layout shift scores for every unexpected layout shift that occurs during the entire lifespan of the page.", + values: [ + { + type: "good", + label: "Good", + min: 0, + max: 0.1, + }, + { + type: "needs-improvement", + label: "Needs Improvement", + min: 0.1, + max: 0.25, + }, + { + type: "poor", + label: "Poor", + min: 0.25, + max: Infinity, + }, + ], + }, + FCP: { + unit: "ms", + label: "First Contentful Paint", + description: + "FCP measures the time from when the page starts loading to when any part of the page's content is rendered on the screen.", + values: [ + { + type: "good", + label: "Good", + min: 0, + max: 1000, + }, + { + type: "needs-improvement", + label: "Needs Improvement", + min: 1000, + max: 2500, + }, + { + type: "poor", + label: "Poor", + min: 2500, + max: Infinity, + }, + ], + }, + FID: { + unit: "ms", + label: "First Input Delay", + description: + "FID measures the time from when a user first interacts with a page to the time when the browser is actually able to respond to that interaction.", + values: [ + { + type: "good", + label: "Good", + min: 0, + max: 100, + }, + { + type: "needs-improvement", + label: "Needs Improvement", + min: 100, + max: 300, + }, + { + type: "poor", + label: "Poor", + min: 300, + max: Infinity, + }, + ], + }, + INP: { + unit: "ms", + label: "Input Delay", + description: + "INP measures the time from when a user first interacts with a page to the time when the browser is actually able to respond to that interaction.", + values: [ + { + type: "good", + label: "Good", + min: 0, + max: 50, + }, + { + type: "needs-improvement", + label: "Needs Improvement", + min: 50, + max: 250, + }, + { + type: "poor", + label: "Poor", + min: 250, + max: Infinity, + }, + ], + }, + LCP: { + unit: "ms", + label: "Largest Contentful Paint", + description: + "LCP measures the time from when the page starts loading to when the largest content element is rendered on the screen.", + values: [ + { + type: "good", + label: "Good", + min: 0, + max: 2500, + }, + { + type: "needs-improvement", + label: "Needs Improvement", + min: 2500, + max: 4000, + }, + { + type: "poor", + label: "Poor", + min: 4000, + max: Infinity, + }, + ], + }, + TTFB: { + unit: "ms", + label: "Time to First Byte", + description: + "TTFB measures the time from when the browser starts requesting a page to when the first byte of the page is received by the browser.", + values: [ + { + type: "good", + label: "Good", + min: 0, + max: 200, + }, + { + type: "needs-improvement", + label: "Needs Improvement", + min: 200, + max: 500, + }, + { + type: "poor", + label: "Poor", + min: 500, + max: Infinity, + }, + ], + }, +}; diff --git a/packages/rum/src/index.ts b/packages/rum/src/index.ts new file mode 100644 index 00000000..2d33a833 --- /dev/null +++ b/packages/rum/src/index.ts @@ -0,0 +1,3 @@ +export * from "./config"; +export * from "./utils"; +export * from "./types"; diff --git a/packages/rum/src/types.ts b/packages/rum/src/types.ts new file mode 100644 index 00000000..5888906d --- /dev/null +++ b/packages/rum/src/types.ts @@ -0,0 +1,22 @@ +import type { webVitalEvents } from "./config"; + +export type WebVitalEvents = (typeof webVitalEvents)[number]; + +export type WebVitalsValueTypes = "good" | "needs-improvement" | "poor"; + +export type WebVitalsValues = { + type: WebVitalsValueTypes; + label: string; + min: number; + max: number; +}[]; + +export type WebVitalsConfig = Record< + WebVitalEvents, + { + unit: string; + label: string; + description: string; + values: WebVitalsValues; + } +>; diff --git a/packages/rum/src/utils.ts b/packages/rum/src/utils.ts new file mode 100644 index 00000000..38bb727d --- /dev/null +++ b/packages/rum/src/utils.ts @@ -0,0 +1,15 @@ +import type { WebVitalsValueTypes } from "./types"; + +export function getColorByType(type: WebVitalsValueTypes) { + switch (type) { + case "good": + return "bg-green-500"; + case "needs-improvement": + return "bg-yellow-500"; + case "poor": + return "bg-rose-500"; + default: + const _check: never = type; + return _check; + } +} diff --git a/packages/rum/tsconfig.json b/packages/rum/tsconfig.json new file mode 100644 index 00000000..aed15c74 --- /dev/null +++ b/packages/rum/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@openstatus/tsconfig/base.json", + "include": ["src", "*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ba57838..2fe7f11b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -272,6 +272,9 @@ importers: '@openstatus/react': specifier: workspace:* version: link:../../packages/react + '@openstatus/rum': + specifier: workspace:* + version: link:../../packages/rum '@openstatus/tinybird': specifier: workspace:* version: link:../../packages/tinybird @@ -894,6 +897,19 @@ importers: specifier: 5.4.5 version: 5.4.5 + packages/rum: + dependencies: + zod: + specifier: 3.22.4 + version: 3.22.4 + devDependencies: + '@openstatus/tsconfig': + specifier: workspace:* + version: link:../tsconfig + typescript: + specifier: 5.4.5 + version: 5.4.5 + packages/tinybird: dependencies: '@chronark/zod-bird':