OverviewMetrics.tsx408 lines · main
| 1 | import { useQuery } from '@tanstack/react-query' |
| 2 | import { useParams } from 'common' |
| 3 | import dayjs from 'dayjs' |
| 4 | import { BarChart2, ChevronRight, ExternalLink, Telescope } from 'lucide-react' |
| 5 | import Link from 'next/link' |
| 6 | import { useRouter } from 'next/router' |
| 7 | import { AiIconAnimation, Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui' |
| 8 | import { StatusCode } from 'ui-patterns' |
| 9 | import { |
| 10 | Chart, |
| 11 | ChartActions, |
| 12 | ChartCard, |
| 13 | ChartContent, |
| 14 | ChartEmptyState, |
| 15 | ChartHeader, |
| 16 | ChartLoadingState, |
| 17 | ChartMetric, |
| 18 | ChartTitle, |
| 19 | } from 'ui-patterns/Chart' |
| 20 | import { |
| 21 | PageSection, |
| 22 | PageSectionContent, |
| 23 | PageSectionMeta, |
| 24 | PageSectionSummary, |
| 25 | PageSectionTitle, |
| 26 | } from 'ui-patterns/PageSection' |
| 27 | |
| 28 | import { |
| 29 | AuthErrorCodeRow, |
| 30 | fetchTopAuthErrorCodes, |
| 31 | fetchTopResponseErrors, |
| 32 | ResponseErrorRow, |
| 33 | } from './OverviewErrors.constants' |
| 34 | import { OverviewTable } from './OverviewTable' |
| 35 | import { |
| 36 | AuthMetricsResponse, |
| 37 | calculatePercentageChange, |
| 38 | getApiSuccessRates, |
| 39 | getAuthSuccessRates, |
| 40 | getMetricValues, |
| 41 | } from './OverviewUsage.constants' |
| 42 | import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' |
| 43 | import AlertError from '@/components/ui/AlertError' |
| 44 | import { ErrorCodeTooltip } from '@/components/ui/ErrorCodeTooltip/ErrorCodeTooltip' |
| 45 | import { Service } from '@/data/graphql/graphql' |
| 46 | import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' |
| 47 | import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' |
| 48 | |
| 49 | const StatCard = ({ |
| 50 | title, |
| 51 | current, |
| 52 | previous, |
| 53 | loading, |
| 54 | suffix = '', |
| 55 | href, |
| 56 | tooltip, |
| 57 | }: { |
| 58 | title: string |
| 59 | current: number |
| 60 | previous: number |
| 61 | loading: boolean |
| 62 | suffix?: string |
| 63 | invert?: boolean |
| 64 | href?: string |
| 65 | tooltip?: string |
| 66 | }) => { |
| 67 | const router = useRouter() |
| 68 | const formattedCurrent = |
| 69 | suffix === 'ms' |
| 70 | ? current.toFixed(2) |
| 71 | : suffix === '%' |
| 72 | ? current.toFixed(1) |
| 73 | : Math.round(current).toLocaleString() |
| 74 | // const signChar = previous > 0 ? '+' : previous < 0 ? '-' : '' |
| 75 | |
| 76 | const actions = [ |
| 77 | { |
| 78 | label: 'Go to Auth Report', |
| 79 | icon: <ExternalLink size={12} />, |
| 80 | onClick: href ? () => router.push(href) : undefined, |
| 81 | }, |
| 82 | ] |
| 83 | |
| 84 | return ( |
| 85 | <Chart isLoading={loading}> |
| 86 | <ChartCard> |
| 87 | <ChartHeader align="start"> |
| 88 | <ChartMetric |
| 89 | className="pb-4" |
| 90 | label={title} |
| 91 | tooltip={tooltip} |
| 92 | diffValue={`${previous.toFixed(1)}%`} |
| 93 | value={`${formattedCurrent}${suffix}`} |
| 94 | /> |
| 95 | <ChartActions actions={actions} /> |
| 96 | </ChartHeader> |
| 97 | </ChartCard> |
| 98 | </Chart> |
| 99 | ) |
| 100 | } |
| 101 | |
| 102 | const LogsLink = ({ href }: { href: string }) => ( |
| 103 | <Tooltip> |
| 104 | <TooltipTrigger asChild> |
| 105 | <Button |
| 106 | type="text" |
| 107 | size="tiny" |
| 108 | className="p-1.5 text-foreground-lighter hover:text-foreground" |
| 109 | asChild |
| 110 | > |
| 111 | <Link href={href} aria-label="Go to Logs"> |
| 112 | <ChevronRight size={12} /> |
| 113 | </Link> |
| 114 | </Button> |
| 115 | </TooltipTrigger> |
| 116 | <TooltipContent>Go to Logs</TooltipContent> |
| 117 | </Tooltip> |
| 118 | ) |
| 119 | |
| 120 | function isResponseErrorRow(row: unknown): row is ResponseErrorRow { |
| 121 | if (!row || typeof row !== 'object') return false |
| 122 | const r = row as Record<string, unknown> |
| 123 | return ( |
| 124 | typeof r.method === 'string' && |
| 125 | typeof r.path === 'string' && |
| 126 | typeof r.status_code === 'number' && |
| 127 | typeof r.count === 'number' |
| 128 | ) |
| 129 | } |
| 130 | |
| 131 | function isAuthErrorCodeRow(row: unknown): row is AuthErrorCodeRow { |
| 132 | if (!row || typeof row !== 'object') return false |
| 133 | const r = row as Record<string, unknown> |
| 134 | return typeof r.error_code === 'string' && typeof r.count === 'number' |
| 135 | } |
| 136 | |
| 137 | interface OverviewMetricsProps { |
| 138 | metrics?: AuthMetricsResponse |
| 139 | isLoading: boolean |
| 140 | error: unknown |
| 141 | } |
| 142 | |
| 143 | export const OverviewMetrics = ({ metrics, isLoading, error }: OverviewMetricsProps) => { |
| 144 | const { ref } = useParams() |
| 145 | const endDate = dayjs().toISOString() |
| 146 | const startDate = dayjs().subtract(24, 'hour').toISOString() |
| 147 | const aiSnap = useAiAssistantStateSnapshot() |
| 148 | const { openSidebar } = useSidebarManagerSnapshot() |
| 149 | |
| 150 | const { current: activeUsersCurrent, previous: activeUsersPrevious } = getMetricValues( |
| 151 | metrics, |
| 152 | 'activeUsers' |
| 153 | ) |
| 154 | |
| 155 | const { current: signUpsCurrent, previous: signUpsPrevious } = getMetricValues( |
| 156 | metrics, |
| 157 | 'signUpCount' |
| 158 | ) |
| 159 | |
| 160 | const activeUsersChange = calculatePercentageChange(activeUsersCurrent, activeUsersPrevious) |
| 161 | const signUpsChange = calculatePercentageChange(signUpsCurrent, signUpsPrevious) |
| 162 | |
| 163 | const { current: apiSuccessRateCurrent, previous: apiSuccessRatePrevious } = |
| 164 | getApiSuccessRates(metrics) |
| 165 | const { current: authSuccessRateCurrent, previous: authSuccessRatePrevious } = |
| 166 | getAuthSuccessRates(metrics) |
| 167 | |
| 168 | const apiSuccessRateChange = calculatePercentageChange( |
| 169 | apiSuccessRateCurrent, |
| 170 | apiSuccessRatePrevious |
| 171 | ) |
| 172 | const authSuccessRateChange = calculatePercentageChange( |
| 173 | authSuccessRateCurrent, |
| 174 | authSuccessRatePrevious |
| 175 | ) |
| 176 | |
| 177 | const { data: respErrData, isPending: isLoadingResp } = useQuery({ |
| 178 | queryKey: ['auth-overview', ref, 'top-response-errors'], |
| 179 | queryFn: () => fetchTopResponseErrors(ref as string), |
| 180 | enabled: !!ref, |
| 181 | }) |
| 182 | |
| 183 | const { data: codeErrData, isPending: isLoadingCodes } = useQuery({ |
| 184 | queryKey: ['auth-overview', ref, 'top-auth-error-codes'], |
| 185 | queryFn: () => fetchTopAuthErrorCodes(ref as string), |
| 186 | enabled: !!ref, |
| 187 | }) |
| 188 | |
| 189 | const responseErrors: ResponseErrorRow[] = Array.isArray(respErrData?.result) |
| 190 | ? (respErrData?.result as unknown[]).filter(isResponseErrorRow) |
| 191 | : [] |
| 192 | const errorCodes: AuthErrorCodeRow[] = Array.isArray(codeErrData?.result) |
| 193 | ? (codeErrData?.result as unknown[]).filter(isAuthErrorCodeRow) |
| 194 | : [] |
| 195 | |
| 196 | const errorCodesActions = [ |
| 197 | { |
| 198 | label: 'Ask Assistant about Error Codes', |
| 199 | icon: <AiIconAnimation size={12} />, |
| 200 | onClick: () => { |
| 201 | openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) |
| 202 | aiSnap.newChat({ |
| 203 | name: 'Auth Help', |
| 204 | initialInput: `Can you explain to me what the authentication error codes mean?`, |
| 205 | }) |
| 206 | }, |
| 207 | }, |
| 208 | ] |
| 209 | |
| 210 | return ( |
| 211 | <> |
| 212 | <PageSection> |
| 213 | {!!error && ( |
| 214 | <AlertError |
| 215 | className="mb-4" |
| 216 | subject="Error fetching auth metrics" |
| 217 | error={{ |
| 218 | message: 'There was an error fetching the auth metrics.', |
| 219 | }} |
| 220 | /> |
| 221 | )} |
| 222 | <PageSectionMeta> |
| 223 | <PageSectionSummary> |
| 224 | <div className="flex items-center justify-between"> |
| 225 | <PageSectionTitle>Usage</PageSectionTitle> |
| 226 | <Link |
| 227 | href={`/project/${ref}/reports/auth?its=${startDate}&ite=${endDate}&isHelper=true&helperText=Last+24+hours`} |
| 228 | className="text-foreground underline underline-offset-2 decoration-foreground-muted hover:decoration-foreground transition-all text-sm inline-flex items-center gap-x-1.5" |
| 229 | > |
| 230 | <Telescope size={14} className="text-foreground-lighter" /> |
| 231 | <span>Go to observability</span> |
| 232 | <ChevronRight size={14} className="text-foreground-lighter" /> |
| 233 | </Link> |
| 234 | </div> |
| 235 | </PageSectionSummary> |
| 236 | </PageSectionMeta> |
| 237 | <PageSectionContent> |
| 238 | <div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> |
| 239 | <StatCard |
| 240 | title="Auth Activity" |
| 241 | current={activeUsersCurrent} |
| 242 | previous={activeUsersChange} |
| 243 | loading={isLoading} |
| 244 | href={`/project/${ref}/reports/auth?its=${startDate}&ite=${endDate}#usage`} |
| 245 | tooltip="Users who generated any Auth event in this period. This metric tracks authentication activity, not total product usage. Some active users won't appear here if their session stayed valid." |
| 246 | /> |
| 247 | <StatCard |
| 248 | title="Sign ups" |
| 249 | current={signUpsCurrent} |
| 250 | previous={signUpsChange} |
| 251 | loading={isLoading} |
| 252 | href={`/project/${ref}/reports/auth?its=${startDate}&ite=${endDate}#usage`} |
| 253 | /> |
| 254 | </div> |
| 255 | </PageSectionContent> |
| 256 | </PageSection> |
| 257 | |
| 258 | <PageSection> |
| 259 | <PageSectionMeta> |
| 260 | <PageSectionSummary> |
| 261 | <PageSectionTitle>Monitoring</PageSectionTitle> |
| 262 | </PageSectionSummary> |
| 263 | </PageSectionMeta> |
| 264 | <PageSectionContent> |
| 265 | <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4"> |
| 266 | <StatCard |
| 267 | title="Auth API Success Rate" |
| 268 | current={apiSuccessRateCurrent} |
| 269 | previous={apiSuccessRateChange} |
| 270 | loading={isLoading} |
| 271 | suffix="%" |
| 272 | href={`/project/${ref}/reports/auth?its=${startDate}&ite=${endDate}#monitoring`} |
| 273 | /> |
| 274 | <StatCard |
| 275 | title="Auth Server Success Rate" |
| 276 | current={authSuccessRateCurrent} |
| 277 | previous={authSuccessRateChange} |
| 278 | loading={isLoading} |
| 279 | suffix="%" |
| 280 | href={`/project/${ref}/reports/auth?its=${startDate}&ite=${endDate}#monitoring`} |
| 281 | /> |
| 282 | </div> |
| 283 | |
| 284 | <div className="grid grid-cols-1 gap-4"> |
| 285 | <Chart isLoading={isLoadingResp}> |
| 286 | <ChartCard> |
| 287 | <ChartHeader> |
| 288 | <ChartTitle>Auth API Errors</ChartTitle> |
| 289 | </ChartHeader> |
| 290 | <ChartContent |
| 291 | className="p-0!" |
| 292 | isEmpty={responseErrors.length === 0} |
| 293 | emptyState={ |
| 294 | <div className="p-6"> |
| 295 | <ChartEmptyState |
| 296 | icon={<BarChart2 size={16} />} |
| 297 | title="No data to show" |
| 298 | description="It may take up to 24 hours for data to refresh" |
| 299 | /> |
| 300 | </div> |
| 301 | } |
| 302 | loadingState={ |
| 303 | <div className="p-6"> |
| 304 | <ChartLoadingState /> |
| 305 | </div> |
| 306 | } |
| 307 | > |
| 308 | <OverviewTable<ResponseErrorRow> |
| 309 | isLoading={isLoadingResp} |
| 310 | data={responseErrors} |
| 311 | columns={[ |
| 312 | { |
| 313 | key: 'request', |
| 314 | header: 'Request', |
| 315 | className: 'w-auto pr-0!', |
| 316 | render: (row) => { |
| 317 | return <StatusCode method={row.method} statusCode={row.status_code} /> |
| 318 | }, |
| 319 | }, |
| 320 | { |
| 321 | key: 'path', |
| 322 | header: 'Path', |
| 323 | className: 'w-full', |
| 324 | render: (row) => ( |
| 325 | <span className="line-clamp-1 font-mono text-foreground-light text-xs"> |
| 326 | {row.path} |
| 327 | </span> |
| 328 | ), |
| 329 | }, |
| 330 | { |
| 331 | key: 'count', |
| 332 | header: 'Count', |
| 333 | className: 'text-right shrink-0 ml-auto justify-end', |
| 334 | render: (row) => ( |
| 335 | <div className="flex justify-end items-center gap-2"> |
| 336 | <div className="text-right text-xs tabular-nums">{row.count}</div> |
| 337 | <LogsLink href={`/project/${ref}/logs/edge-logs?s=${row.path}`} /> |
| 338 | </div> |
| 339 | ), |
| 340 | }, |
| 341 | ]} |
| 342 | /> |
| 343 | </ChartContent> |
| 344 | </ChartCard> |
| 345 | </Chart> |
| 346 | |
| 347 | <Chart isLoading={isLoadingCodes}> |
| 348 | <ChartCard> |
| 349 | <ChartHeader> |
| 350 | <ChartTitle>Auth Server Errors</ChartTitle> |
| 351 | <ChartActions actions={errorCodesActions} /> |
| 352 | </ChartHeader> |
| 353 | <ChartContent |
| 354 | className="p-0!" |
| 355 | isEmpty={errorCodes.length === 0} |
| 356 | emptyState={ |
| 357 | <div className="p-6"> |
| 358 | <ChartEmptyState |
| 359 | icon={<BarChart2 size={16} />} |
| 360 | title="No data to show" |
| 361 | description="It may take up to 24 hours for data to refresh" |
| 362 | /> |
| 363 | </div> |
| 364 | } |
| 365 | loadingState={ |
| 366 | <div className="p-6"> |
| 367 | <ChartLoadingState /> |
| 368 | </div> |
| 369 | } |
| 370 | > |
| 371 | <OverviewTable<AuthErrorCodeRow> |
| 372 | isLoading={isLoadingCodes} |
| 373 | data={errorCodes} |
| 374 | columns={[ |
| 375 | { |
| 376 | key: 'error_code', |
| 377 | header: 'Error code', |
| 378 | className: 'w-full', |
| 379 | render: (row) => ( |
| 380 | <ErrorCodeTooltip errorCode={row.error_code} service={Service.Auth}> |
| 381 | <span className="line-clamp-1 font-mono uppercase text-xs inline-flex text-foreground-light cursor-default"> |
| 382 | {row.error_code} |
| 383 | </span> |
| 384 | </ErrorCodeTooltip> |
| 385 | ), |
| 386 | }, |
| 387 | { |
| 388 | key: 'count', |
| 389 | header: 'Count', |
| 390 | className: 'text-right', |
| 391 | render: (row) => ( |
| 392 | <div className="flex justify-end items-center gap-2"> |
| 393 | <div className="text-right text-xs tabular-nums">{row.count}</div> |
| 394 | <LogsLink href={`/project/${ref}/logs/auth-logs?s=${row.error_code}`} /> |
| 395 | </div> |
| 396 | ), |
| 397 | }, |
| 398 | ]} |
| 399 | /> |
| 400 | </ChartContent> |
| 401 | </ChartCard> |
| 402 | </Chart> |
| 403 | </div> |
| 404 | </PageSectionContent> |
| 405 | </PageSection> |
| 406 | </> |
| 407 | ) |
| 408 | } |