Auth Report tests and improvements (#38550)
* move to new ReportConfig * separate types * add auth report config * add auth report transofmration test * add fetchlogs util for reports * rm auth-charts * rm auth-report-query * rm dupped code * simplify upsell compo * rm unnecessary hook * use upsell in database report instead of old component * rip ReportChart you wont be missed * more tests * rm unnecessary import * Fix SQL in active users * support hide inside report chart component * add max bar size to avoid having big ass bars when theres little data * fix incorrect timestamp key passed on to fillTimeseries * add tooltip to auth report * fix bug with timestamp key inside tooltips * add report settings * rm debugging check * Update apps/studio/data/reports/v2/auth.config.ts Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> * safely access result * fix sign in latency query * add toggleable attributes * handle toggleable attributes * pass on hideHighlightedValue * fix signup latency * pass hideHighlighted * report chart pass on hide highlighted value * cleanup finalChartData * simplify tests, use timestamp for everything * fix test * rm dupped attribute * add tooltip to auth errors * Update apps/studio/data/reports/v2/auth.config.ts Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> * Update apps/studio/components/ui/Charts/ComposedChart.tsx Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> * prettify --------- Co-authored-by: Charis <26616127+charislam@users.noreply.github.com>
This commit is contained in:
@@ -1,96 +0,0 @@
|
||||
import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils'
|
||||
import LogChartHandler from 'components/ui/Charts/LogChartHandler'
|
||||
import { useFillTimeseriesSorted } from 'hooks/analytics/useFillTimeseriesSorted'
|
||||
import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan'
|
||||
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
|
||||
import { useChartData } from 'hooks/useChartData'
|
||||
import type { UpdateDateRange } from 'pages/project/[ref]/reports/database'
|
||||
import { ReportChartUpsell } from './v2/ReportChartUpsell'
|
||||
|
||||
interface ReportChartProps {
|
||||
chart: any
|
||||
startDate: string
|
||||
endDate: string
|
||||
interval: string
|
||||
updateDateRange: UpdateDateRange
|
||||
functionIds?: string[]
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper component that uses the useChartData hook to fetch data for a chart
|
||||
* and then passes the data and loading state to the ComposedChartHandler.
|
||||
*
|
||||
* This component acts as a bridge between the data-fetching logic and the
|
||||
* presentational chart component.
|
||||
*/
|
||||
export const ReportChart = ({
|
||||
chart,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
updateDateRange,
|
||||
functionIds,
|
||||
isLoading,
|
||||
}: ReportChartProps) => {
|
||||
const { data: org } = useSelectedOrganizationQuery()
|
||||
const { plan: orgPlan } = useCurrentOrgPlan()
|
||||
const orgPlanId = orgPlan?.id
|
||||
|
||||
const isAvailable =
|
||||
chart.availableIn === undefined || (orgPlanId && chart.availableIn.includes(orgPlanId))
|
||||
|
||||
const canFetch = orgPlanId !== undefined
|
||||
const {
|
||||
data,
|
||||
isLoading: isLoadingChart,
|
||||
highlightedValue,
|
||||
} = useChartData({
|
||||
attributes: chart.attributes,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
functionIds,
|
||||
data: undefined,
|
||||
enabled: canFetch,
|
||||
highlightedValue:
|
||||
chart.id === 'client-connections' || chart.id === 'pgbouncer-connections'
|
||||
? true
|
||||
: chart.showMaxValue,
|
||||
})
|
||||
|
||||
const isTopListChart = chart.id === 'top-api-routes' || chart.id === 'top-rpc-functions'
|
||||
|
||||
const chartDataArray = Array.isArray(data) ? data : []
|
||||
|
||||
const { data: filledData, isError: isFillError } = useFillTimeseriesSorted(
|
||||
chartDataArray,
|
||||
'period_start',
|
||||
chart.attributes.map((attr: any) => attr.attribute),
|
||||
0,
|
||||
startDate,
|
||||
endDate,
|
||||
undefined,
|
||||
interval
|
||||
)
|
||||
|
||||
const finalData =
|
||||
chartDataArray.length > 0 && chartDataArray.length < 20 && !isFillError && !isTopListChart
|
||||
? filledData
|
||||
: chartDataArray
|
||||
|
||||
if (!isAvailable && !isLoading) {
|
||||
return <ReportChartUpsell report={chart} orgSlug={org?.slug ?? ''} />
|
||||
}
|
||||
|
||||
return (
|
||||
<LogChartHandler
|
||||
{...chart}
|
||||
attributes={chart.attributes as MultiAttribute[]}
|
||||
data={finalData}
|
||||
isLoading={isLoadingChart || isLoading}
|
||||
highlightedValue={highlightedValue as any}
|
||||
updateDateRange={updateDateRange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,16 @@ import { LogChartHandler } from 'components/ui/Charts/LogChartHandler'
|
||||
import { ReportConfig } from 'data/reports/v2/reports.types'
|
||||
import { Button, Card, cn } from 'ui'
|
||||
|
||||
export function ReportChartUpsell({ report, orgSlug }: { report: ReportConfig; orgSlug: string }) {
|
||||
export function ReportChartUpsell({
|
||||
report,
|
||||
orgSlug,
|
||||
}: {
|
||||
report: {
|
||||
label: string
|
||||
availableIn: string[]
|
||||
}
|
||||
orgSlug: string
|
||||
}) {
|
||||
const [isHoveringUpgrade, setIsHoveringUpgrade] = useState(false)
|
||||
|
||||
const startDate = '2025-01-01'
|
||||
|
||||
@@ -58,7 +58,7 @@ export const ReportChartV2 = ({
|
||||
return await report.dataProvider(projectRef, startDate, endDate, interval, filters)
|
||||
},
|
||||
{
|
||||
enabled: Boolean(projectRef && canFetch && isAvailable),
|
||||
enabled: Boolean(projectRef && canFetch && isAvailable && !report.hide),
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 0,
|
||||
}
|
||||
@@ -67,9 +67,15 @@ export const ReportChartV2 = ({
|
||||
const chartData = queryResult?.data || []
|
||||
const dynamicAttributes = queryResult?.attributes || []
|
||||
|
||||
/**
|
||||
* Depending on the source the timestamp key could be 'timestamp' or 'period_start'
|
||||
*/
|
||||
const firstItem = chartData[0]
|
||||
const timestampKey = firstItem?.hasOwnProperty('timestamp') ? 'timestamp' : 'period_start'
|
||||
|
||||
const { data: filledChartData, isError: isFillError } = useFillTimeseriesSorted(
|
||||
chartData,
|
||||
'timestamp',
|
||||
timestampKey,
|
||||
(dynamicAttributes as any[]).map((attr: any) => attr.attribute),
|
||||
0,
|
||||
startDate,
|
||||
@@ -78,9 +84,6 @@ export const ReportChartV2 = ({
|
||||
interval
|
||||
)
|
||||
|
||||
const finalChartData =
|
||||
filledChartData && filledChartData.length > 0 && !isFillError ? filledChartData : chartData
|
||||
|
||||
const [chartStyle, setChartStyle] = useState<string>(report.defaultChartStyle)
|
||||
|
||||
if (!isAvailable) {
|
||||
@@ -88,7 +91,8 @@ export const ReportChartV2 = ({
|
||||
}
|
||||
|
||||
const isErrorState = error && !isLoadingChart
|
||||
const showEmptyState = (!finalChartData || finalChartData.length === 0) && !isLoadingChart
|
||||
|
||||
if (report.hide) return null
|
||||
|
||||
return (
|
||||
<Card id={report.id} className={cn('relative w-full overflow-hidden scroll-mt-16', className)}>
|
||||
@@ -100,10 +104,6 @@ export const ReportChartV2 = ({
|
||||
>
|
||||
{isLoadingChart ? (
|
||||
<Loader2 className="size-5 animate-spin text-foreground-light" />
|
||||
) : showEmptyState ? (
|
||||
<p className="text-sm text-foreground-light text-center h-full flex items-center justify-center">
|
||||
No data available for the selected time range and filters
|
||||
</p>
|
||||
) : isErrorState ? (
|
||||
<p className="text-sm text-foreground-light text-center h-full flex items-center justify-center">
|
||||
Error loading chart data
|
||||
@@ -112,10 +112,11 @@ export const ReportChartV2 = ({
|
||||
<div className="w-full">
|
||||
<ComposedChart
|
||||
attributes={dynamicAttributes}
|
||||
data={finalChartData}
|
||||
data={filledChartData}
|
||||
format={report.format ?? undefined}
|
||||
xAxisKey={report.xAxisKey ?? 'timestamp'}
|
||||
yAxisKey={report.yAxisKey ?? dynamicAttributes[0]?.attribute}
|
||||
hideHighlightedValue={report.hideHighlightedValue}
|
||||
highlightedValue={0}
|
||||
title={report.label}
|
||||
customDateFormat={undefined}
|
||||
|
||||
@@ -111,11 +111,12 @@ export function ComposedChart({
|
||||
const [focusDataIndex, setFocusDataIndex] = useState<number | null>(null)
|
||||
const [hoveredLabel, setHoveredLabel] = useState<string | null>(null)
|
||||
const [isActiveHoveredChart, setIsActiveHoveredChart] = useState(false)
|
||||
const [hiddenAttributes, setHiddenAttributes] = useState<Set<string>>(new Set())
|
||||
const isDarkMode = resolvedTheme?.includes('dark')
|
||||
|
||||
useEffect(() => {
|
||||
updateStackedChartColors(isDarkMode ?? false)
|
||||
}, [resolvedTheme])
|
||||
}, [isDarkMode])
|
||||
|
||||
const { Container } = useChartSize(size)
|
||||
|
||||
@@ -174,6 +175,7 @@ export function ComposedChart({
|
||||
...attributesToIgnore,
|
||||
...(referenceLines?.map((a: MultiAttribute) => a.attribute) ?? []),
|
||||
...(maxAttribute?.attribute ? [maxAttribute?.attribute] : []),
|
||||
...Array.from(hiddenAttributes),
|
||||
]
|
||||
|
||||
const lastDataPoint = data[data.length - 1]
|
||||
@@ -265,6 +267,7 @@ export function ComposedChart({
|
||||
const attribute = attributes.find((attr) => attr.attribute === att.name)
|
||||
return !attribute?.isMaxValue
|
||||
})
|
||||
const visibleAttributes = stackedAttributes.filter((att) => !hiddenAttributes.has(att.name))
|
||||
const isPercentage = format === '%'
|
||||
const isRamChart =
|
||||
!chartData?.some((att: any) => att.name.toLowerCase() === 'ram_usage') &&
|
||||
@@ -282,10 +285,11 @@ export function ComposedChart({
|
||||
// to the highest value in the chart data for percentage charts
|
||||
// to vertically zoom in on the data
|
||||
// */
|
||||
const yDomain = [
|
||||
const yMaxFromVisible = Math.max(
|
||||
0,
|
||||
Math.max(...chartData.map((att) => (typeof att.value === 'number' ? att.value : 0))),
|
||||
]
|
||||
...visibleAttributes.map((att) => (typeof att.value === 'number' ? att.value : 0))
|
||||
)
|
||||
const yDomain = [0, yMaxFromVisible]
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
@@ -303,6 +307,7 @@ export function ComposedChart({
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-3', className)}>
|
||||
<ChartHeader
|
||||
hideHighlightedValue={hideHighlightedValue}
|
||||
title={title}
|
||||
format={format}
|
||||
customDateFormat={customDateFormat}
|
||||
@@ -314,7 +319,6 @@ export function ComposedChart({
|
||||
onChartStyleChange={onChartStyleChange}
|
||||
showMaxValue={_showMaxValue}
|
||||
setShowMaxValue={maxAttribute ? setShowMaxValue : undefined}
|
||||
hideHighlightedValue={hideHighlightedValue}
|
||||
docsUrl={docsUrl}
|
||||
syncId={syncId}
|
||||
data={data}
|
||||
@@ -403,29 +407,28 @@ export function ComposedChart({
|
||||
}
|
||||
/>
|
||||
{chartStyle === 'bar'
|
||||
? stackedAttributes.map((attribute) => (
|
||||
? visibleAttributes.map((attribute) => (
|
||||
<Bar
|
||||
key={attribute.name}
|
||||
dataKey={attribute.name}
|
||||
stackId={attributes?.find((a) => a.attribute === attribute?.name)?.stackId ?? '1'}
|
||||
fill={attribute.color}
|
||||
fillOpacity={hoveredLabel && hoveredLabel !== attribute?.name ? 0.25 : 1}
|
||||
radius={0.75}
|
||||
opacity={hoveredLabel && hoveredLabel !== attribute?.name ? 0.5 : 1}
|
||||
opacity={1}
|
||||
name={
|
||||
attributes?.find((a) => a.attribute === attribute?.name)?.label ||
|
||||
attribute?.name
|
||||
}
|
||||
maxBarSize={24}
|
||||
/>
|
||||
))
|
||||
: stackedAttributes.map((attribute, i) => (
|
||||
: visibleAttributes.map((attribute, i) => (
|
||||
<Area
|
||||
key={attribute.name}
|
||||
type="step"
|
||||
dataKey={attribute.name}
|
||||
stackId={attributes?.find((a) => a.attribute === attribute.name)?.stackId ?? '1'}
|
||||
fill={attribute.color}
|
||||
strokeOpacity={hoveredLabel && hoveredLabel !== attribute.name ? 0.4 : 1}
|
||||
stroke={attribute.color}
|
||||
radius={20}
|
||||
animationDuration={375}
|
||||
@@ -509,6 +512,28 @@ export function ComposedChart({
|
||||
attributes={attributes}
|
||||
showMaxValue={_showMaxValue}
|
||||
onLabelHover={setHoveredLabel}
|
||||
onToggleAttribute={(attribute, options) => {
|
||||
setHiddenAttributes((prev) => {
|
||||
if (options?.exclusive) {
|
||||
const next = new Set<string>()
|
||||
// Hide every attribute except the selected one. If all but one are hidden, clicking again will reset to all visible.
|
||||
const allNames = chartData.map((c) => c.name)
|
||||
const allHiddenExcept = allNames.filter((n) => n !== attribute)
|
||||
const isAlreadyExclusive =
|
||||
allHiddenExcept.every((n) => prev.has(n)) && !prev.has(attribute)
|
||||
return isAlreadyExclusive ? new Set() : new Set(allHiddenExcept)
|
||||
}
|
||||
|
||||
const next = new Set(prev)
|
||||
if (next.has(attribute)) {
|
||||
next.delete(attribute)
|
||||
} else {
|
||||
next.add(attribute)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
hiddenAttributes={hiddenAttributes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -141,7 +141,12 @@ const CustomTooltip = ({
|
||||
isActiveHoveredChart,
|
||||
}: TooltipProps) => {
|
||||
if (active && payload && payload.length) {
|
||||
const timestamp = payload[0].payload.timestamp
|
||||
/**
|
||||
* Depending on the data source, the timestamp key could be 'timestamp' or 'period_start'
|
||||
*/
|
||||
const firstItem = payload[0].payload
|
||||
const timestampKey = firstItem?.hasOwnProperty('timestamp') ? 'timestamp' : 'period_start'
|
||||
const timestamp = payload[0].payload[timestampKey]
|
||||
const maxValueAttribute = isMaxAttribute(attributes)
|
||||
const maxValueData =
|
||||
maxValueAttribute && payload?.find((p: any) => p.dataKey === maxValueAttribute.attribute)
|
||||
@@ -250,9 +255,18 @@ interface CustomLabelProps {
|
||||
attributes?: MultiAttribute[]
|
||||
showMaxValue?: boolean
|
||||
onLabelHover?: (label: string | null) => void
|
||||
onToggleAttribute?: (attribute: string, options?: { exclusive?: boolean }) => void
|
||||
hiddenAttributes?: Set<string>
|
||||
}
|
||||
|
||||
const CustomLabel = ({ payload, attributes, showMaxValue, onLabelHover }: CustomLabelProps) => {
|
||||
const CustomLabel = ({
|
||||
payload,
|
||||
attributes,
|
||||
showMaxValue,
|
||||
onLabelHover,
|
||||
onToggleAttribute,
|
||||
hiddenAttributes,
|
||||
}: CustomLabelProps) => {
|
||||
const items = payload ?? []
|
||||
const maxValueAttribute = isMaxAttribute(attributes)
|
||||
const [hoveredLabel, setHoveredLabel] = useState<string | null>(null)
|
||||
@@ -280,16 +294,12 @@ const CustomLabel = ({ payload, attributes, showMaxValue, onLabelHover }: Custom
|
||||
const attribute = attributes?.find((a) => a.attribute === entry.name)
|
||||
const isMax = entry.name === maxValueAttribute?.attribute
|
||||
const isHovered = hoveredLabel === entry.name
|
||||
const isHidden = hiddenAttributes?.has(entry.name)
|
||||
|
||||
const Label = () => (
|
||||
<div className="flex items-center gap-1 p-1">
|
||||
<div className="flex items-center gap-1">
|
||||
{getIcon(entry.name, entry.color)}
|
||||
<span
|
||||
className={cn(
|
||||
'text-nowrap text-foreground-lighter cursor-default select-none',
|
||||
hoveredLabel && !isHovered && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<span className={cn('text-nowrap text-foreground-lighter', isHidden && 'opacity-50')}>
|
||||
{attribute?.label || entry.name}
|
||||
</span>
|
||||
</div>
|
||||
@@ -298,11 +308,12 @@ const CustomLabel = ({ payload, attributes, showMaxValue, onLabelHover }: Custom
|
||||
if (!showMaxValue && isMax) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
key={entry.name}
|
||||
className="inline-flex md:flex-col gap-1 md:gap-0 w-fit text-foreground"
|
||||
className="flex md:flex-col gap-1 md:gap-0 w-fit text-foreground rounded-lg p-1.5 hover:bg-background-overlay-hover"
|
||||
onMouseOver={() => handleMouseEnter(entry.name)}
|
||||
onMouseOutCapture={handleMouseLeave}
|
||||
onClick={(e) => onToggleAttribute?.(entry.name, { exclusive: e.metaKey || e.ctrlKey })}
|
||||
>
|
||||
{!!attribute?.tooltip ? (
|
||||
<Tooltip>
|
||||
@@ -316,12 +327,12 @@ const CustomLabel = ({ payload, attributes, showMaxValue, onLabelHover }: Custom
|
||||
) : (
|
||||
<Label />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative z-0 mx-auto flex flex-col items-center gap-1 text-xs w-full">
|
||||
<div className="relative z-10 mx-auto flex flex-col items-center gap-1 text-xs w-full">
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
{items?.map((entry, index) => <LabelItem key={`${entry.name}-${index}`} entry={entry} />)}
|
||||
</div>
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
export const getAuthReportAttributes = () => [
|
||||
{
|
||||
id: 'active-users',
|
||||
label: 'Active Users',
|
||||
valuePrecision: 0,
|
||||
hide: false,
|
||||
showTooltip: false,
|
||||
showLegend: false,
|
||||
showMaxValue: false,
|
||||
hideChartType: false,
|
||||
defaultChartStyle: 'bar',
|
||||
attributes: [
|
||||
{ attribute: 'ActiveUsers', provider: 'logs', label: 'Active Users', enabled: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sign-in-attempts',
|
||||
label: 'Sign In Attempts by Type',
|
||||
valuePrecision: 0,
|
||||
hide: false,
|
||||
showTooltip: true,
|
||||
showLegend: true,
|
||||
showMaxValue: false,
|
||||
hideChartType: false,
|
||||
defaultChartStyle: 'bar',
|
||||
titleTooltip: 'The total number of sign in attempts by grant type.',
|
||||
attributes: [
|
||||
{
|
||||
attribute: 'SignInAttempts',
|
||||
provider: 'logs',
|
||||
label: 'Password',
|
||||
login_type_provider: 'password',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
attribute: 'SignInAttempts',
|
||||
provider: 'logs',
|
||||
label: 'PKCE',
|
||||
login_type_provider: 'pkce',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
attribute: 'SignInAttempts',
|
||||
provider: 'logs',
|
||||
label: 'Refresh Token',
|
||||
login_type_provider: 'token',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
attribute: 'SignInAttempts',
|
||||
provider: 'logs',
|
||||
label: 'ID Token',
|
||||
login_type_provider: 'id_token',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'signups',
|
||||
label: 'Sign Ups',
|
||||
valuePrecision: 0,
|
||||
hide: false,
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
showMaxValue: false,
|
||||
hideChartType: false,
|
||||
defaultChartStyle: 'bar',
|
||||
titleTooltip: 'The total number of sign ups.',
|
||||
attributes: [
|
||||
{
|
||||
attribute: 'TotalSignUps',
|
||||
provider: 'logs',
|
||||
label: 'Sign Ups',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'auth-errors',
|
||||
label: 'Auth Errors',
|
||||
valuePrecision: 0,
|
||||
hide: false,
|
||||
showTooltip: true,
|
||||
showLegend: true,
|
||||
showMaxValue: false,
|
||||
hideChartType: false,
|
||||
defaultChartStyle: 'bar',
|
||||
titleTooltip: 'The total number of auth errors by status code.',
|
||||
attributes: [
|
||||
{
|
||||
attribute: 'ErrorsByStatus',
|
||||
provider: 'logs',
|
||||
label: 'Auth Errors',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'password-reset-requests',
|
||||
label: 'Password Reset Requests',
|
||||
valuePrecision: 0,
|
||||
hide: false,
|
||||
showTooltip: false,
|
||||
showLegend: false,
|
||||
showMaxValue: false,
|
||||
hideChartType: false,
|
||||
defaultChartStyle: 'bar',
|
||||
attributes: [
|
||||
{
|
||||
attribute: 'PasswordResetRequests',
|
||||
provider: 'logs',
|
||||
label: 'Password Reset Requests',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sign-in-latency',
|
||||
label: 'Sign In Latency',
|
||||
valuePrecision: 2,
|
||||
hide: true, // Jordi: Hidden until we can fix the query
|
||||
showTooltip: true,
|
||||
showLegend: true,
|
||||
showMaxValue: false,
|
||||
hideChartType: false,
|
||||
defaultChartStyle: 'line',
|
||||
titleTooltip: 'Average latency for sign in operations by grant type.',
|
||||
attributes: [
|
||||
{
|
||||
attribute: 'SignInLatency',
|
||||
provider: 'logs',
|
||||
label: 'Password',
|
||||
grantType: 'password',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
attribute: 'SignInLatency',
|
||||
provider: 'logs',
|
||||
label: 'PKCE',
|
||||
grantType: 'pkce',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
attribute: 'SignInLatency',
|
||||
provider: 'logs',
|
||||
label: 'Refresh Token',
|
||||
grantType: 'refresh_token',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
attribute: 'SignInLatency',
|
||||
provider: 'logs',
|
||||
label: 'ID Token',
|
||||
grantType: 'id_token',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sign-up-latency',
|
||||
label: 'Sign Up Latency',
|
||||
valuePrecision: 2,
|
||||
hide: true, // Jordi: Hidden until we can fix the query
|
||||
showTooltip: true,
|
||||
showLegend: true,
|
||||
showMaxValue: false,
|
||||
hideChartType: false,
|
||||
defaultChartStyle: 'line',
|
||||
titleTooltip: 'Average latency for sign up operations by provider.',
|
||||
attributes: [
|
||||
{
|
||||
attribute: 'SignUpLatency',
|
||||
provider: 'logs',
|
||||
label: 'Email',
|
||||
providerType: 'email',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
attribute: 'SignUpLatency',
|
||||
provider: 'logs',
|
||||
label: 'Google',
|
||||
providerType: 'google',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
attribute: 'SignUpLatency',
|
||||
provider: 'logs',
|
||||
label: 'GitHub',
|
||||
providerType: 'github',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
attribute: 'SignUpLatency',
|
||||
provider: 'logs',
|
||||
label: 'Apple',
|
||||
providerType: 'apple',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -1,345 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { get } from 'data/fetchers'
|
||||
import { AnalyticsInterval } from 'data/analytics/constants'
|
||||
import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils'
|
||||
import { getHttpStatusCodeInfo } from 'lib/http-status-codes'
|
||||
import { analyticsIntervalToGranularity } from './report.utils'
|
||||
|
||||
/**
|
||||
* METRICS
|
||||
* Each chart in the UI has a corresponding metric key.
|
||||
*/
|
||||
|
||||
const METRIC_KEYS = [
|
||||
'ActiveUsers',
|
||||
'SignInAttempts',
|
||||
'PasswordResetRequests',
|
||||
'TotalSignUps',
|
||||
'SignInLatency',
|
||||
'SignUpLatency',
|
||||
'ErrorsByStatus',
|
||||
]
|
||||
|
||||
const STATUS_CODE_COLORS: { [key: string]: { light: string; dark: string } } = {
|
||||
'400': { light: '#FFD54F', dark: '#FFF176' },
|
||||
'401': { light: '#FF8A65', dark: '#FFAB91' },
|
||||
'403': { light: '#FFB74D', dark: '#FFCC80' },
|
||||
'404': { light: '#90A4AE', dark: '#B0BEC5' },
|
||||
'409': { light: '#BA68C8', dark: '#CE93D8' },
|
||||
'410': { light: '#A1887F', dark: '#BCAAA4' },
|
||||
'422': { light: '#FF9800', dark: '#FFB74D' },
|
||||
'429': { light: '#E65100', dark: '#F57C00' },
|
||||
'500': { light: '#B71C1C', dark: '#D32F2F' },
|
||||
'502': { light: '#9575CD', dark: '#B39DDB' },
|
||||
'503': { light: '#0097A7', dark: '#4DD0E1' },
|
||||
'504': { light: '#C0CA33', dark: '#D4E157' },
|
||||
default: { light: '#757575', dark: '#9E9E9E' },
|
||||
}
|
||||
|
||||
type MetricKey = (typeof METRIC_KEYS)[number]
|
||||
|
||||
/**
|
||||
* SQL
|
||||
* Each metric has a corresponding SQL query.
|
||||
*/
|
||||
|
||||
const METRIC_SQL: Record<MetricKey, (interval: AnalyticsInterval) => string> = {
|
||||
ActiveUsers: (interval) => {
|
||||
const granularity = analyticsIntervalToGranularity(interval)
|
||||
return `
|
||||
--active-users
|
||||
select
|
||||
timestamp_trunc(timestamp, ${granularity}) as timestamp,
|
||||
count(distinct json_value(f.event_message, "$.auth_event.actor_id")) as count
|
||||
from auth_logs f
|
||||
where json_value(f.event_message, "$.auth_event.action") in (
|
||||
'login', 'user_signedup', 'token_refreshed', 'user_modified',
|
||||
'user_recovery_requested', 'user_reauthenticate_requested'
|
||||
)
|
||||
group by timestamp
|
||||
order by timestamp desc
|
||||
`
|
||||
},
|
||||
SignInAttempts: (interval) => {
|
||||
const granularity = analyticsIntervalToGranularity(interval)
|
||||
return `
|
||||
--sign-in-attempts
|
||||
SELECT
|
||||
timestamp_trunc(timestamp, ${granularity}) as timestamp,
|
||||
CASE
|
||||
WHEN JSON_VALUE(event_message, "$.provider") IS NOT NULL
|
||||
AND JSON_VALUE(event_message, "$.provider") != ''
|
||||
THEN CONCAT(
|
||||
JSON_VALUE(event_message, "$.login_method"),
|
||||
' (',
|
||||
JSON_VALUE(event_message, "$.provider"),
|
||||
')'
|
||||
)
|
||||
ELSE JSON_VALUE(event_message, "$.login_method")
|
||||
END as login_type_provider,
|
||||
COUNT(*) as count
|
||||
FROM
|
||||
auth_logs
|
||||
WHERE
|
||||
JSON_VALUE(event_message, "$.action") = 'login'
|
||||
AND JSON_VALUE(event_message, "$.metering") = "true"
|
||||
GROUP BY
|
||||
timestamp, login_type_provider
|
||||
ORDER BY
|
||||
timestamp desc, login_type_provider
|
||||
`
|
||||
},
|
||||
PasswordResetRequests: (interval) => {
|
||||
const granularity = analyticsIntervalToGranularity(interval)
|
||||
return `
|
||||
--password-reset-requests
|
||||
select
|
||||
timestamp_trunc(timestamp, ${granularity}) as timestamp,
|
||||
count(*) as count
|
||||
from auth_logs f
|
||||
where json_value(f.event_message, "$.auth_event.action") = 'user_recovery_requested'
|
||||
group by timestamp
|
||||
order by timestamp desc
|
||||
`
|
||||
},
|
||||
TotalSignUps: (interval) => {
|
||||
const granularity = analyticsIntervalToGranularity(interval)
|
||||
return `
|
||||
--total-signups
|
||||
select
|
||||
timestamp_trunc(timestamp, ${granularity}) as timestamp,
|
||||
count(*) as count
|
||||
from auth_logs
|
||||
where json_value(event_message, "$.auth_event.action") = 'user_signedup'
|
||||
group by timestamp
|
||||
order by timestamp desc
|
||||
`
|
||||
},
|
||||
SignInLatency: (interval) => {
|
||||
const granularity = analyticsIntervalToGranularity(interval)
|
||||
return `
|
||||
--signin-latency
|
||||
select
|
||||
timestamp_trunc(timestamp, ${granularity}) as timestamp,
|
||||
json_value(event_message, "$.grant_type") as grant_type,
|
||||
count(*) as request_count,
|
||||
round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_latency_ms,
|
||||
round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_latency_ms,
|
||||
round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_latency_ms,
|
||||
round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_latency_ms,
|
||||
round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_latency_ms,
|
||||
round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(99)] / 1000000, 2) as p99_latency_ms
|
||||
from auth_logs
|
||||
where json_value(event_message, "$.path") = '/token'
|
||||
group by timestamp, grant_type
|
||||
order by timestamp desc, grant_type
|
||||
`
|
||||
},
|
||||
SignUpLatency: (interval) => {
|
||||
const granularity = analyticsIntervalToGranularity(interval)
|
||||
return `
|
||||
--signup-latency
|
||||
select
|
||||
timestamp_trunc(timestamp, ${granularity}) as timestamp,
|
||||
json_value(event_message, "$.auth_event.traits.provider") as provider,
|
||||
round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_latency_ms,
|
||||
round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_latency_ms,
|
||||
round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_latency_ms,
|
||||
round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_latency_ms,
|
||||
round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_latency_ms
|
||||
from auth_logs
|
||||
where json_value(event_message, "$.auth_event.action") = 'user_signedup'
|
||||
and json_value(event_message, "$.status") = '200'
|
||||
group by timestamp, provider
|
||||
order by timestamp desc, provider
|
||||
`
|
||||
},
|
||||
ErrorsByStatus: (interval) => {
|
||||
const granularity = analyticsIntervalToGranularity(interval)
|
||||
return `
|
||||
--auth-errors-by-status
|
||||
select
|
||||
timestamp_trunc(timestamp, ${granularity}) as timestamp,
|
||||
count(*) as count,
|
||||
response.status_code
|
||||
from edge_logs
|
||||
cross join unnest(metadata) as m
|
||||
cross join unnest(m.request) as request
|
||||
cross join unnest(m.response) as response
|
||||
where path like '%/auth%'
|
||||
and response.status_code >= 400 and response.status_code <= 599
|
||||
group by timestamp, status_code
|
||||
order by timestamp desc
|
||||
`
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* FORMATTERS.
|
||||
* Metrics need to be formatted before being passed on to the UI charts.
|
||||
*/
|
||||
|
||||
function defaultFormatter(rawData: any, attributes: MultiAttribute[]) {
|
||||
const chartAttributes = attributes
|
||||
if (!rawData) return { data: undefined, chartAttributes }
|
||||
const result = rawData.result || []
|
||||
const timestamps = new Set<string>(result.map((p: any) => p.timestamp))
|
||||
const data = Array.from(timestamps)
|
||||
.sort()
|
||||
.map((timestamp) => {
|
||||
const point: any = { period_start: timestamp }
|
||||
chartAttributes.forEach((attr) => {
|
||||
point[attr.attribute] = 0
|
||||
})
|
||||
const matchingPoints = result.filter((p: any) => p.timestamp === timestamp)
|
||||
matchingPoints.forEach((p: any) => {
|
||||
point[attributes[0].attribute] = p.count
|
||||
})
|
||||
return point
|
||||
})
|
||||
return { data, chartAttributes }
|
||||
}
|
||||
|
||||
const METRIC_FORMATTER: Record<
|
||||
MetricKey,
|
||||
(
|
||||
rawData: any,
|
||||
attributes: MultiAttribute[],
|
||||
logsMetric: string
|
||||
) => { data: any; chartAttributes: any }
|
||||
> = {
|
||||
ActiveUsers: (rawData, attributes) => defaultFormatter(rawData, attributes),
|
||||
SignInAttempts: (rawData, attributes) => {
|
||||
const chartAttributes = attributes.map((attr) => {
|
||||
if (attr.attribute === 'SignInAttempts' && attr.login_type_provider) {
|
||||
return { ...attr, attribute: `${attr.attribute}_${attr.login_type_provider}` }
|
||||
}
|
||||
return attr
|
||||
})
|
||||
if (!rawData) return { data: undefined, chartAttributes }
|
||||
const result = rawData.result || []
|
||||
const timestamps = new Set<string>(result.map((p: any) => p.timestamp))
|
||||
const data = Array.from(timestamps)
|
||||
.sort()
|
||||
.map((timestamp) => {
|
||||
const point: any = { period_start: timestamp }
|
||||
chartAttributes.forEach((attr) => {
|
||||
point[attr.attribute] = 0
|
||||
})
|
||||
const matchingPoints = result.filter((p: any) => p.timestamp === timestamp)
|
||||
matchingPoints.forEach((p: any) => {
|
||||
point[`SignInAttempts_${p.login_type_provider}`] = p.count
|
||||
})
|
||||
return point
|
||||
})
|
||||
return { data, chartAttributes }
|
||||
},
|
||||
PasswordResetRequests: (rawData, attributes) => defaultFormatter(rawData, attributes),
|
||||
TotalSignUps: (rawData, attributes) => defaultFormatter(rawData, attributes),
|
||||
SignInLatency: (rawData, attributes) => defaultFormatter(rawData, attributes),
|
||||
SignUpLatency: (rawData, attributes) => defaultFormatter(rawData, attributes),
|
||||
ErrorsByStatus: (rawData, attributes) => {
|
||||
if (!rawData) return { data: undefined, chartAttributes: attributes }
|
||||
const result = rawData.result || []
|
||||
|
||||
const statusCodes = Array.from(new Set(result.map((p: any) => p.status_code)))
|
||||
|
||||
const chartAttributes = statusCodes.map((statusCode) => {
|
||||
const statusCodeInfo = getHttpStatusCodeInfo(Number(statusCode))
|
||||
const color = STATUS_CODE_COLORS[String(statusCode)] || STATUS_CODE_COLORS.default
|
||||
|
||||
return {
|
||||
attribute: `status_${statusCode}`,
|
||||
label: `${statusCode} ${statusCodeInfo.label}`,
|
||||
provider: 'logs',
|
||||
enabled: true,
|
||||
color: color,
|
||||
statusCode: String(statusCode),
|
||||
}
|
||||
})
|
||||
|
||||
const timestamps = new Set<string>(result.map((p: any) => p.timestamp))
|
||||
const data = Array.from(timestamps)
|
||||
.sort()
|
||||
.map((timestamp) => {
|
||||
const point: any = { period_start: timestamp }
|
||||
chartAttributes.forEach((attr) => {
|
||||
point[attr.attribute] = 0
|
||||
})
|
||||
const matchingPoints = result.filter((p: any) => p.timestamp === timestamp)
|
||||
matchingPoints.forEach((p: any) => {
|
||||
point[`status_${p.status_code}`] = p.count
|
||||
})
|
||||
return point
|
||||
})
|
||||
|
||||
return { data, chartAttributes }
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* REPORT QUERY.
|
||||
* Fetching and state management for the report.
|
||||
*/
|
||||
|
||||
export function useAuthLogsReport({
|
||||
projectRef,
|
||||
attributes,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
enabled = true,
|
||||
}: {
|
||||
projectRef: string
|
||||
attributes: MultiAttribute[]
|
||||
startDate: string
|
||||
endDate: string
|
||||
interval: AnalyticsInterval
|
||||
enabled?: boolean
|
||||
}) {
|
||||
const logsMetric = attributes.length > 0 ? attributes[0].attribute : ''
|
||||
|
||||
const isAuthMetric = METRIC_KEYS.includes(logsMetric)
|
||||
|
||||
const sql = isAuthMetric ? METRIC_SQL[logsMetric](interval) : ''
|
||||
|
||||
const {
|
||||
data: rawData,
|
||||
error,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useQuery(
|
||||
['auth-logs-report', projectRef, logsMetric, startDate, endDate, interval, sql],
|
||||
async () => {
|
||||
const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, {
|
||||
params: {
|
||||
path: { ref: projectRef },
|
||||
query: {
|
||||
sql,
|
||||
iso_timestamp_start: startDate,
|
||||
iso_timestamp_end: endDate,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
return data
|
||||
},
|
||||
{
|
||||
enabled: Boolean(projectRef && sql && enabled && isAuthMetric),
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
)
|
||||
|
||||
// Use formatter if available
|
||||
const formatter =
|
||||
(isAuthMetric ? METRIC_FORMATTER[logsMetric as MetricKey] : undefined) || defaultFormatter
|
||||
const { data, chartAttributes } = formatter(rawData, attributes, logsMetric)
|
||||
|
||||
return {
|
||||
data,
|
||||
attributes: chartAttributes,
|
||||
isLoading,
|
||||
error,
|
||||
isFetching,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AnalyticsInterval } from 'data/analytics/constants'
|
||||
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
|
||||
import { get } from 'data/fetchers'
|
||||
|
||||
export type Granularity = 'minute' | 'hour' | 'day'
|
||||
export function analyticsIntervalToGranularity(interval: AnalyticsInterval): Granularity {
|
||||
@@ -51,3 +52,39 @@ export const useEdgeFnIdToName = ({ projectRef }: { projectRef: string }) => {
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchLogs(
|
||||
projectRef: string,
|
||||
sql: string,
|
||||
startDate: string,
|
||||
endDate: string
|
||||
) {
|
||||
const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, {
|
||||
params: {
|
||||
path: { ref: projectRef },
|
||||
query: {
|
||||
sql,
|
||||
iso_timestamp_start: startDate,
|
||||
iso_timestamp_end: endDate,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
export const STATUS_CODE_COLORS: { [key: string]: { light: string; dark: string } } = {
|
||||
'400': { light: '#FFD54F', dark: '#FFF176' },
|
||||
'401': { light: '#FF8A65', dark: '#FFAB91' },
|
||||
'403': { light: '#FFB74D', dark: '#FFCC80' },
|
||||
'404': { light: '#90A4AE', dark: '#B0BEC5' },
|
||||
'409': { light: '#BA68C8', dark: '#CE93D8' },
|
||||
'410': { light: '#A1887F', dark: '#BCAAA4' },
|
||||
'422': { light: '#FF9800', dark: '#FFB74D' },
|
||||
'429': { light: '#E65100', dark: '#F57C00' },
|
||||
'500': { light: '#B71C1C', dark: '#D32F2F' },
|
||||
'502': { light: '#9575CD', dark: '#B39DDB' },
|
||||
'503': { light: '#0097A7', dark: '#4DD0E1' },
|
||||
'504': { light: '#C0CA33', dark: '#D4E157' },
|
||||
default: { light: '#757575', dark: '#9E9E9E' },
|
||||
}
|
||||
|
||||
60
apps/studio/data/reports/v2/auth-report.test.tsx
Normal file
60
apps/studio/data/reports/v2/auth-report.test.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { defaultAuthReportFormatter } from './auth.config'
|
||||
|
||||
describe('defaultAuthReportFormatter', () => {
|
||||
const timestamp = new Date('2021-01-01').getTime()
|
||||
|
||||
it('should format the data correctly', () => {
|
||||
const data = { result: [{ timestamp: String(timestamp), count: 1 }] }
|
||||
|
||||
const attributes = [
|
||||
{ attribute: 'ActiveUsers', provider: 'logs', label: 'Active Users', enabled: true },
|
||||
]
|
||||
|
||||
const result = defaultAuthReportFormatter(data, attributes)
|
||||
|
||||
expect(result).toEqual({
|
||||
data: [{ timestamp: String(timestamp), ActiveUsers: 1 }],
|
||||
chartAttributes: attributes,
|
||||
})
|
||||
})
|
||||
|
||||
it('should format the data correctly with multiple attributes', () => {
|
||||
const data = {
|
||||
result: [
|
||||
{ timestamp: String(timestamp), active_users: 1 },
|
||||
{ timestamp: String(timestamp + 1), sign_in_attempts: 2 },
|
||||
],
|
||||
}
|
||||
|
||||
const attributes = [
|
||||
{ attribute: 'active_users', label: 'Active Users' },
|
||||
{ attribute: 'sign_in_attempts', label: 'Sign In Attempts' },
|
||||
]
|
||||
|
||||
const result = defaultAuthReportFormatter(data, attributes)
|
||||
|
||||
expect(result).toEqual({
|
||||
data: [
|
||||
{ timestamp: String(timestamp), active_users: 1, sign_in_attempts: 0 },
|
||||
{ timestamp: String(timestamp + 1), active_users: 0, sign_in_attempts: 2 },
|
||||
],
|
||||
chartAttributes: attributes,
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle empty data', () => {
|
||||
const data = { result: [] }
|
||||
|
||||
const attributes = [
|
||||
{ attribute: 'ActiveUsers', provider: 'logs', label: 'Active Users', enabled: true },
|
||||
]
|
||||
|
||||
const result = defaultAuthReportFormatter(data, attributes)
|
||||
|
||||
expect(result).toEqual({
|
||||
data: [],
|
||||
chartAttributes: attributes,
|
||||
})
|
||||
})
|
||||
})
|
||||
514
apps/studio/data/reports/v2/auth.config.ts
Normal file
514
apps/studio/data/reports/v2/auth.config.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
import type { AnalyticsInterval } from 'data/analytics/constants'
|
||||
|
||||
import { analyticsIntervalToGranularity } from 'data/reports/report.utils'
|
||||
import { ReportConfig, ReportDataProviderAttribute } from './reports.types'
|
||||
import { NumericFilter } from 'components/interfaces/Reports/v2/ReportsNumericFilter'
|
||||
import { fetchLogs } from 'data/reports/report.utils'
|
||||
import z from 'zod'
|
||||
|
||||
const METRIC_KEYS = [
|
||||
'ActiveUsers',
|
||||
'SignInAttempts',
|
||||
'PasswordResetRequests',
|
||||
'TotalSignUps',
|
||||
'SignInLatency',
|
||||
'SignUpLatency',
|
||||
'ErrorsByStatus',
|
||||
]
|
||||
|
||||
type MetricKey = (typeof METRIC_KEYS)[number]
|
||||
|
||||
const AUTH_REPORT_SQL: Record<
|
||||
MetricKey,
|
||||
(interval: AnalyticsInterval, filters?: AuthReportFilters) => string
|
||||
> = {
|
||||
ActiveUsers: (interval) => {
|
||||
const granularity = analyticsIntervalToGranularity(interval)
|
||||
return `
|
||||
--active-users
|
||||
select
|
||||
timestamp_trunc(timestamp, ${granularity}) as timestamp,
|
||||
count(distinct json_value(f.event_message, "$.auth_event.actor_id")) as count
|
||||
from auth_logs f
|
||||
where json_value(f.event_message, "$.auth_event.action") in (
|
||||
'login', 'user_signedup', 'token_refreshed', 'user_modified',
|
||||
'user_recovery_requested', 'user_reauthenticate_requested'
|
||||
)
|
||||
group by timestamp
|
||||
order by timestamp desc
|
||||
`
|
||||
},
|
||||
SignInAttempts: (interval) => {
|
||||
const granularity = analyticsIntervalToGranularity(interval)
|
||||
return `
|
||||
--sign-in-attempts
|
||||
SELECT
|
||||
timestamp_trunc(timestamp, ${granularity}) as timestamp,
|
||||
CASE
|
||||
WHEN JSON_VALUE(event_message, "$.provider") IS NOT NULL
|
||||
AND JSON_VALUE(event_message, "$.provider") != ''
|
||||
THEN CONCAT(
|
||||
JSON_VALUE(event_message, "$.login_method"),
|
||||
' (',
|
||||
JSON_VALUE(event_message, "$.provider"),
|
||||
')'
|
||||
)
|
||||
ELSE JSON_VALUE(event_message, "$.login_method")
|
||||
END as login_type_provider,
|
||||
COUNT(*) as count
|
||||
FROM
|
||||
auth_logs
|
||||
WHERE
|
||||
JSON_VALUE(event_message, "$.action") = 'login'
|
||||
AND JSON_VALUE(event_message, "$.metering") = "true"
|
||||
GROUP BY
|
||||
timestamp, login_type_provider
|
||||
ORDER BY
|
||||
timestamp desc, login_type_provider
|
||||
`
|
||||
},
|
||||
PasswordResetRequests: (interval) => {
|
||||
const granularity = analyticsIntervalToGranularity(interval)
|
||||
return `
|
||||
--password-reset-requests
|
||||
select
|
||||
timestamp_trunc(timestamp, ${granularity}) as timestamp,
|
||||
count(*) as count
|
||||
from auth_logs f
|
||||
where json_value(f.event_message, "$.auth_event.action") = 'user_recovery_requested'
|
||||
group by timestamp
|
||||
order by timestamp desc
|
||||
`
|
||||
},
|
||||
TotalSignUps: (interval) => {
|
||||
const granularity = analyticsIntervalToGranularity(interval)
|
||||
return `
|
||||
--total-signups
|
||||
select
|
||||
timestamp_trunc(timestamp, ${granularity}) as timestamp,
|
||||
count(*) as count
|
||||
from auth_logs
|
||||
where json_value(event_message, "$.auth_event.action") = 'user_signedup'
|
||||
group by timestamp
|
||||
order by timestamp desc
|
||||
`
|
||||
},
|
||||
SignInLatency: (interval) => {
|
||||
const granularity = analyticsIntervalToGranularity(interval)
|
||||
return `
|
||||
--signin-latency
|
||||
select
|
||||
timestamp_trunc(timestamp, ${granularity}) as timestamp,
|
||||
count(*) as count,
|
||||
round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_latency_ms,
|
||||
round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_latency_ms,
|
||||
round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_latency_ms,
|
||||
round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_latency_ms,
|
||||
round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_latency_ms,
|
||||
round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(99)] / 1000000, 2) as p99_latency_ms
|
||||
from auth_logs
|
||||
where json_value(event_message, "$.auth_event.action") = 'login'
|
||||
group by timestamp
|
||||
order by timestamp desc
|
||||
`
|
||||
},
|
||||
SignUpLatency: (interval) => {
|
||||
const granularity = analyticsIntervalToGranularity(interval)
|
||||
return `
|
||||
--signup-latency
|
||||
select
|
||||
timestamp_trunc(timestamp, ${granularity}) as timestamp,
|
||||
count(*) as count,
|
||||
round(avg(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as avg_latency_ms,
|
||||
round(min(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as min_latency_ms,
|
||||
round(max(cast(json_value(event_message, "$.duration") as int64)) / 1000000, 2) as max_latency_ms,
|
||||
round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(50)] / 1000000, 2) as p50_latency_ms,
|
||||
round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(95)] / 1000000, 2) as p95_latency_ms,
|
||||
round(approx_quantiles(cast(json_value(event_message, "$.duration") as int64), 100)[offset(99)] / 1000000, 2) as p99_latency_ms
|
||||
from auth_logs
|
||||
where json_value(event_message, "$.auth_event.action") = 'user_signedup'
|
||||
group by timestamp
|
||||
order by timestamp desc
|
||||
`
|
||||
},
|
||||
ErrorsByStatus: (interval) => {
|
||||
const granularity = analyticsIntervalToGranularity(interval)
|
||||
return `
|
||||
--auth-errors-by-status
|
||||
select
|
||||
timestamp_trunc(timestamp, ${granularity}) as timestamp,
|
||||
count(*) as count,
|
||||
response.status_code
|
||||
from edge_logs
|
||||
cross join unnest(metadata) as m
|
||||
cross join unnest(m.request) as request
|
||||
cross join unnest(m.response) as response
|
||||
where path like '%/auth%'
|
||||
and response.status_code >= 400 and response.status_code <= 599
|
||||
group by timestamp, status_code
|
||||
order by timestamp desc
|
||||
`
|
||||
},
|
||||
}
|
||||
|
||||
type AuthReportFilters = {
|
||||
status_code: NumericFilter | null
|
||||
}
|
||||
|
||||
function filterToWhereClause(filters?: AuthReportFilters): string {
|
||||
if (!filters) return ''
|
||||
return ``
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms raw analytics data into a chart-ready format by ensuring data consistency and completeness.
|
||||
*
|
||||
* This function addresses several key requirements for chart rendering:
|
||||
* 1. Fills missing timestamps with zero values to prevent gaps in charts
|
||||
* 2. Creates a consistent data structure with `period_start` as the time axis
|
||||
* 3. Initializes all chart attributes to 0, then populates actual values
|
||||
* 4. Sorts timestamps chronologically for proper chart ordering
|
||||
*
|
||||
* @param rawData - Raw analytics data from backend queries containing timestamp and count fields
|
||||
* @param attributes - Chart attribute configuration defining what metrics to display
|
||||
* @returns Formatted data object with consistent time series data and chart attributes
|
||||
*/
|
||||
export function defaultAuthReportFormatter(
|
||||
rawData: unknown,
|
||||
attributes: ReportDataProviderAttribute[]
|
||||
) {
|
||||
const chartAttributes = attributes
|
||||
|
||||
const rawDataSchema = z.object({
|
||||
result: z.array(
|
||||
z
|
||||
.object({
|
||||
timestamp: z.coerce.number(),
|
||||
})
|
||||
.catchall(z.any())
|
||||
),
|
||||
})
|
||||
|
||||
const parsedRawData = rawDataSchema.parse(rawData)
|
||||
const result = parsedRawData.result
|
||||
|
||||
if (!result) return { data: undefined, chartAttributes }
|
||||
|
||||
const timestamps = new Set<string>(result.map((p: any) => String(p.timestamp)))
|
||||
const data = Array.from(timestamps)
|
||||
.sort()
|
||||
.map((timestamp) => {
|
||||
const point: any = { timestamp }
|
||||
chartAttributes.forEach((attr) => {
|
||||
point[attr.attribute] = 0
|
||||
})
|
||||
const matchingPoints = result.filter((p: any) => String(p.timestamp) === timestamp)
|
||||
|
||||
matchingPoints.forEach((p: any) => {
|
||||
chartAttributes.forEach((attr) => {
|
||||
// Optional dimension filters used by some reports
|
||||
if ('login_type_provider' in (attr as any)) {
|
||||
if (p.login_type_provider !== (attr as any).login_type_provider) return
|
||||
}
|
||||
if ('providerType' in (attr as any)) {
|
||||
if (p.provider !== (attr as any).providerType) return
|
||||
}
|
||||
|
||||
const valueFromField =
|
||||
typeof p[attr.attribute] === 'number'
|
||||
? p[attr.attribute]
|
||||
: typeof p.count === 'number'
|
||||
? p.count
|
||||
: undefined
|
||||
|
||||
if (typeof valueFromField === 'number') {
|
||||
point[attr.attribute] = (point[attr.attribute] ?? 0) + valueFromField
|
||||
}
|
||||
})
|
||||
})
|
||||
return point
|
||||
})
|
||||
return { data, chartAttributes }
|
||||
}
|
||||
|
||||
export const createAuthReportConfig = ({
|
||||
projectRef,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
filters,
|
||||
}: {
|
||||
projectRef: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
interval: AnalyticsInterval
|
||||
filters: AuthReportFilters
|
||||
}): ReportConfig<AuthReportFilters>[] => [
|
||||
{
|
||||
id: 'active-user',
|
||||
label: 'Active Users',
|
||||
valuePrecision: 0,
|
||||
hide: false,
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
showMaxValue: false,
|
||||
hideChartType: false,
|
||||
defaultChartStyle: 'line',
|
||||
titleTooltip: 'The total number of active users over time.',
|
||||
availableIn: ['free', 'pro', 'team', 'enterprise'],
|
||||
dataProvider: async () => {
|
||||
const attributes = [
|
||||
{ attribute: 'ActiveUsers', provider: 'logs', label: 'Active Users', enabled: true },
|
||||
]
|
||||
|
||||
const sql = AUTH_REPORT_SQL.ActiveUsers(interval, filters)
|
||||
|
||||
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
|
||||
|
||||
const transformedData = defaultAuthReportFormatter(rawData, attributes)
|
||||
|
||||
return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql }
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sign-in-attempts',
|
||||
label: 'Sign In Attempts by Type',
|
||||
valuePrecision: 0,
|
||||
hide: false,
|
||||
showTooltip: true,
|
||||
showLegend: true,
|
||||
showMaxValue: false,
|
||||
hideChartType: false,
|
||||
defaultChartStyle: 'line',
|
||||
titleTooltip: 'The total number of sign in attempts by type.',
|
||||
availableIn: ['free', 'pro', 'team', 'enterprise'],
|
||||
dataProvider: async () => {
|
||||
const attributes = [
|
||||
{
|
||||
attribute: 'SignInAttempts',
|
||||
provider: 'logs',
|
||||
label: 'Password',
|
||||
login_type_provider: 'password',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
attribute: 'SignInAttempts',
|
||||
provider: 'logs',
|
||||
label: 'PKCE',
|
||||
login_type_provider: 'pkce',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
attribute: 'SignInAttempts',
|
||||
provider: 'logs',
|
||||
label: 'Refresh Token',
|
||||
login_type_provider: 'token',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
attribute: 'SignInAttempts',
|
||||
provider: 'logs',
|
||||
label: 'ID Token',
|
||||
login_type_provider: 'id_token',
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
|
||||
const sql = AUTH_REPORT_SQL.SignInAttempts(interval, filters)
|
||||
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
|
||||
const transformedData = defaultAuthReportFormatter(rawData, attributes)
|
||||
|
||||
return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql }
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'signups',
|
||||
label: 'Sign Ups',
|
||||
valuePrecision: 0,
|
||||
hide: false,
|
||||
showTooltip: true,
|
||||
showLegend: true,
|
||||
showMaxValue: false,
|
||||
hideChartType: false,
|
||||
defaultChartStyle: 'line',
|
||||
titleTooltip: 'The total number of sign ups.',
|
||||
availableIn: ['free', 'pro', 'team', 'enterprise'],
|
||||
dataProvider: async () => {
|
||||
const attributes = [
|
||||
{
|
||||
attribute: 'TotalSignUps',
|
||||
provider: 'logs',
|
||||
label: 'Sign Ups',
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
|
||||
const sql = AUTH_REPORT_SQL.TotalSignUps(interval, filters)
|
||||
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
|
||||
const transformedData = defaultAuthReportFormatter(rawData, attributes)
|
||||
|
||||
return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql }
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'auth-errors',
|
||||
label: 'Auth Errors',
|
||||
valuePrecision: 0,
|
||||
hide: false,
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
showMaxValue: false,
|
||||
hideChartType: false,
|
||||
defaultChartStyle: 'line',
|
||||
titleTooltip: 'The total number of auth errors by status code.',
|
||||
availableIn: ['free', 'pro', 'team', 'enterprise'],
|
||||
dataProvider: async () => {
|
||||
const attributes = [
|
||||
{
|
||||
attribute: 'ErrorsByStatus',
|
||||
provider: 'logs',
|
||||
label: 'Auth Errors',
|
||||
},
|
||||
]
|
||||
|
||||
const sql = AUTH_REPORT_SQL.ErrorsByStatus(interval, filters)
|
||||
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
|
||||
const transformedData = defaultAuthReportFormatter(rawData, attributes)
|
||||
|
||||
return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql }
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'password-reset-requests',
|
||||
label: 'Password Reset Requests',
|
||||
valuePrecision: 0,
|
||||
hide: false,
|
||||
showTooltip: true,
|
||||
showLegend: true,
|
||||
showMaxValue: false,
|
||||
hideChartType: false,
|
||||
defaultChartStyle: 'line',
|
||||
titleTooltip: 'The total number of password reset requests.',
|
||||
availableIn: ['free', 'pro', 'team', 'enterprise'],
|
||||
dataProvider: async () => {
|
||||
const attributes = [
|
||||
{
|
||||
attribute: 'PasswordResetRequests',
|
||||
provider: 'logs',
|
||||
label: 'Password Reset Requests',
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
|
||||
const sql = AUTH_REPORT_SQL.PasswordResetRequests(interval, filters)
|
||||
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
|
||||
const transformedData = defaultAuthReportFormatter(rawData, attributes)
|
||||
|
||||
return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql }
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sign-in-latency',
|
||||
label: 'Sign In Latency',
|
||||
valuePrecision: 2,
|
||||
hide: false,
|
||||
hideHighlightedValue: true,
|
||||
showTooltip: true,
|
||||
showLegend: true,
|
||||
showMaxValue: false,
|
||||
hideChartType: false,
|
||||
defaultChartStyle: 'line',
|
||||
titleTooltip: 'The average latency for sign in operations by grant type.',
|
||||
availableIn: ['pro', 'team', 'enterprise'],
|
||||
dataProvider: async () => {
|
||||
const attributes = [
|
||||
{
|
||||
attribute: 'avg_latency_ms',
|
||||
label: 'Avg. Latency (ms)',
|
||||
},
|
||||
{
|
||||
attribute: 'max_latency_ms',
|
||||
label: 'Max. Latency (ms)',
|
||||
},
|
||||
{
|
||||
attribute: 'min_latency_ms',
|
||||
label: 'Min. Latency (ms)',
|
||||
},
|
||||
{
|
||||
attribute: 'p50_latency_ms',
|
||||
label: 'P50 Latency (ms)',
|
||||
},
|
||||
{
|
||||
attribute: 'p95_latency_ms',
|
||||
label: 'P95 Latency (ms)',
|
||||
},
|
||||
{
|
||||
attribute: 'p99_latency_ms',
|
||||
label: 'P99 Latency (ms)',
|
||||
},
|
||||
{
|
||||
attribute: 'request_count',
|
||||
label: 'Request Count',
|
||||
},
|
||||
]
|
||||
|
||||
const sql = AUTH_REPORT_SQL.SignInLatency(interval, filters)
|
||||
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
|
||||
const transformedData = defaultAuthReportFormatter(rawData, attributes)
|
||||
|
||||
return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql }
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sign-up-latency',
|
||||
label: 'Sign Up Latency',
|
||||
valuePrecision: 2,
|
||||
hide: false,
|
||||
hideHighlightedValue: true,
|
||||
showTooltip: true,
|
||||
showLegend: true,
|
||||
showMaxValue: false,
|
||||
hideChartType: false,
|
||||
defaultChartStyle: 'line',
|
||||
titleTooltip: 'The average latency for sign up operations by provider.',
|
||||
availableIn: ['pro', 'team', 'enterprise'],
|
||||
dataProvider: async () => {
|
||||
const attributes = [
|
||||
{
|
||||
attribute: 'avg_latency_ms',
|
||||
label: 'Avg. Latency (ms)',
|
||||
},
|
||||
{
|
||||
attribute: 'max_latency_ms',
|
||||
label: 'Max. Latency (ms)',
|
||||
},
|
||||
{
|
||||
attribute: 'min_latency_ms',
|
||||
label: 'Min. Latency (ms)',
|
||||
},
|
||||
{
|
||||
attribute: 'p50_latency_ms',
|
||||
label: 'P50 Latency (ms)',
|
||||
},
|
||||
{
|
||||
attribute: 'p95_latency_ms',
|
||||
label: 'P95 Latency (ms)',
|
||||
},
|
||||
{
|
||||
attribute: 'p99_latency_ms',
|
||||
label: 'P99 Latency (ms)',
|
||||
},
|
||||
{
|
||||
attribute: 'request_count',
|
||||
label: 'Request Count',
|
||||
},
|
||||
]
|
||||
|
||||
const sql = AUTH_REPORT_SQL.SignUpLatency(interval, filters)
|
||||
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
|
||||
const transformedData = defaultAuthReportFormatter(rawData, attributes)
|
||||
|
||||
return { data: transformedData.data, attributes: transformedData.chartAttributes, query: sql }
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -14,6 +14,7 @@ import { getHttpStatusCodeInfo } from 'lib/http-status-codes'
|
||||
import { ReportConfig } from './reports.types'
|
||||
import { NumericFilter } from 'components/interfaces/Reports/v2/ReportsNumericFilter'
|
||||
import { SelectFilters } from 'components/interfaces/Reports/v2/ReportsSelectFilter'
|
||||
import { fetchLogs } from 'data/reports/report.utils'
|
||||
|
||||
type EdgeFunctionReportFilters = {
|
||||
status_code: NumericFilter | null
|
||||
@@ -151,21 +152,6 @@ order by
|
||||
},
|
||||
}
|
||||
|
||||
async function runQuery(projectRef: string, sql: string, startDate: string, endDate: string) {
|
||||
const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, {
|
||||
params: {
|
||||
path: { ref: projectRef },
|
||||
query: {
|
||||
sql,
|
||||
iso_timestamp_start: startDate,
|
||||
iso_timestamp_end: endDate,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
export function extractStatusCodesFromData(data: any[]): string[] {
|
||||
const statusCodes = new Set<string>()
|
||||
|
||||
@@ -272,7 +258,7 @@ export const edgeFunctionReports = ({
|
||||
availableIn: ['free', 'pro', 'team', 'enterprise'],
|
||||
dataProvider: async () => {
|
||||
const sql = METRIC_SQL.TotalInvocations(interval, filters)
|
||||
const response = await runQuery(projectRef, sql, startDate, endDate)
|
||||
const response = await fetchLogs(projectRef, sql, startDate, endDate)
|
||||
|
||||
if (!response?.result) return { data: [] }
|
||||
|
||||
@@ -304,7 +290,7 @@ export const edgeFunctionReports = ({
|
||||
availableIn: ['free', 'pro', 'team', 'enterprise'],
|
||||
dataProvider: async () => {
|
||||
const sql = METRIC_SQL.ExecutionStatusCodes(interval, filters)
|
||||
const rawData = await runQuery(projectRef, sql, startDate, endDate)
|
||||
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
|
||||
|
||||
if (!rawData?.result) return { data: [] }
|
||||
|
||||
@@ -341,7 +327,7 @@ export const edgeFunctionReports = ({
|
||||
format: (value: unknown) => `${Number(value).toFixed(0)}ms`,
|
||||
dataProvider: async () => {
|
||||
const sql = METRIC_SQL.ExecutionTime(interval, filters)
|
||||
const rawData = await runQuery(projectRef, sql, startDate, endDate)
|
||||
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
|
||||
|
||||
if (!rawData?.result) return { data: [] }
|
||||
|
||||
@@ -398,7 +384,7 @@ export const edgeFunctionReports = ({
|
||||
availableIn: ['pro', 'team', 'enterprise'],
|
||||
dataProvider: async () => {
|
||||
const sql = METRIC_SQL.InvocationsByRegion(interval, filters)
|
||||
const rawData = await runQuery(projectRef, sql, startDate, endDate)
|
||||
const rawData = await fetchLogs(projectRef, sql, startDate, endDate)
|
||||
const data = rawData.result?.map((point: any) => ({
|
||||
...point,
|
||||
timestamp: isUnixMicro(point.timestamp)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { AnalyticsInterval } from 'data/analytics/constants'
|
||||
import { YAxisProps } from 'recharts'
|
||||
|
||||
export type ReportDataProviderAttribute = {
|
||||
attribute: string
|
||||
label: string
|
||||
color?: { light: string; dark: string }
|
||||
}
|
||||
|
||||
export interface ReportDataProvider<FiltersType> {
|
||||
(
|
||||
projectRef: string,
|
||||
@@ -10,13 +16,9 @@ export interface ReportDataProvider<FiltersType> {
|
||||
filters?: FiltersType
|
||||
): Promise<{
|
||||
data: any
|
||||
attributes?: {
|
||||
attribute: string
|
||||
label: string
|
||||
color?: { light: string; dark: string }
|
||||
}[]
|
||||
attributes?: ReportDataProviderAttribute[]
|
||||
query?: string // The SQL used to fetch the data if any
|
||||
}> // [jordi] would be cool to have a type that forces data keys to match the attributes
|
||||
}>
|
||||
}
|
||||
|
||||
export interface ReportConfig<FiltersType = any> {
|
||||
@@ -25,6 +27,7 @@ export interface ReportConfig<FiltersType = any> {
|
||||
dataProvider: ReportDataProvider<FiltersType>
|
||||
valuePrecision: number
|
||||
hide: boolean
|
||||
hideHighlightedValue?: boolean
|
||||
showTooltip: boolean
|
||||
showLegend: boolean
|
||||
showMaxValue: boolean
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* useChartData
|
||||
*
|
||||
* A hook for fetching and processing data for a chart.
|
||||
* This hook is responsible for all the data fetching, combining, and state management logic
|
||||
* that was previously inside ComposedChartHandler.
|
||||
*
|
||||
* It takes all necessary parameters like project reference, date range, and attributes,
|
||||
* and returns the final chart data, loading state, and derived attributes.
|
||||
*/
|
||||
import { useMemo } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
import type { AnalyticsInterval, DataPoint } from 'data/analytics/constants'
|
||||
import { useAuthLogsReport } from 'data/reports/auth-report-query'
|
||||
import type { ChartData } from 'components/ui/Charts/Charts.types'
|
||||
import type { MultiAttribute } from 'components/ui/Charts/ComposedChart.utils'
|
||||
|
||||
export const useChartData = ({
|
||||
attributes,
|
||||
startDate,
|
||||
endDate,
|
||||
interval,
|
||||
data,
|
||||
highlightedValue,
|
||||
functionIds,
|
||||
enabled = true,
|
||||
}: {
|
||||
attributes: MultiAttribute[]
|
||||
startDate: string
|
||||
endDate: string
|
||||
interval: string
|
||||
data?: ChartData
|
||||
highlightedValue?: string | number
|
||||
functionIds?: string[]
|
||||
enabled?: boolean
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const { ref } = router.query
|
||||
|
||||
const logsAttributes = attributes.filter((attr) => attr.provider === 'logs')
|
||||
|
||||
const isEdgeFunctionRoute = router.asPath.includes('/reports/edge-functions')
|
||||
|
||||
const {
|
||||
data: authData,
|
||||
attributes: authChartAttributes,
|
||||
isLoading: isAuthLoading,
|
||||
} = useAuthLogsReport({
|
||||
projectRef: ref as string,
|
||||
attributes: logsAttributes,
|
||||
startDate,
|
||||
endDate,
|
||||
interval: interval as AnalyticsInterval,
|
||||
enabled: enabled && logsAttributes.length > 0 && !isEdgeFunctionRoute,
|
||||
})
|
||||
|
||||
const logsData = authData
|
||||
const logsChartAttributes = authChartAttributes
|
||||
const isLogsLoading = isAuthLoading
|
||||
|
||||
const combinedData = useMemo(() => {
|
||||
if (data) return data
|
||||
|
||||
// Get all unique timestamps from all datasets
|
||||
const timestamps = new Set<string>()
|
||||
if (logsData) {
|
||||
logsData.forEach((point: any) => {
|
||||
if (point?.period_start) {
|
||||
timestamps.add(point.period_start)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Combine data points for each timestamp
|
||||
const combined = Array.from(timestamps)
|
||||
.sort()
|
||||
.map((timestamp) => {
|
||||
const point: any = { period_start: timestamp }
|
||||
|
||||
const logPoint = logsData?.find((p: any) => p.period_start === timestamp) || {}
|
||||
Object.assign(point, logPoint)
|
||||
|
||||
return point as DataPoint
|
||||
})
|
||||
|
||||
return combined as DataPoint[]
|
||||
}, [data, attributes, isLogsLoading, logsData, logsAttributes])
|
||||
|
||||
const loading = logsAttributes.length > 0 && isLogsLoading
|
||||
|
||||
// Calculate highlighted value based on the first attribute's data
|
||||
const _highlightedValue = useMemo(() => {
|
||||
if (highlightedValue !== undefined) return highlightedValue
|
||||
|
||||
const firstAttr = attributes[0]
|
||||
const firstData = logsChartAttributes.find((p: any) => p.attribute === firstAttr.attribute)
|
||||
|
||||
if (!firstData) return undefined
|
||||
|
||||
const shouldHighlightMaxValue =
|
||||
firstAttr.provider === 'daily-stats' &&
|
||||
!firstAttr.attribute.includes('ingress') &&
|
||||
!firstAttr.attribute.includes('egress') &&
|
||||
'maximum' in firstData
|
||||
|
||||
const shouldHighlightTotalGroupedValue = 'totalGrouped' in firstData
|
||||
|
||||
return shouldHighlightMaxValue
|
||||
? firstData.maximum
|
||||
: firstAttr.provider === 'daily-stats'
|
||||
? firstData.total
|
||||
: shouldHighlightTotalGroupedValue
|
||||
? firstData.totalGrouped?.[firstAttr.attribute as keyof typeof firstData.totalGrouped]
|
||||
: (firstData.data?.[firstData.data?.length - 1] as any)?.[firstAttr.attribute]
|
||||
}, [highlightedValue, attributes])
|
||||
|
||||
return {
|
||||
data: combinedData,
|
||||
isLoading: loading,
|
||||
highlightedValue: _highlightedValue,
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import dayjs from 'dayjs'
|
||||
import { ArrowRight, RefreshCw } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { ReportChart } from 'components/interfaces/Reports/ReportChart'
|
||||
import { ReportChartV2 } from 'components/interfaces/Reports/v2/ReportChartV2'
|
||||
import ReportHeader from 'components/interfaces/Reports/ReportHeader'
|
||||
import ReportPadding from 'components/interfaces/Reports/ReportPadding'
|
||||
import ReportStickyNav from 'components/interfaces/Reports/ReportStickyNav'
|
||||
@@ -18,9 +18,10 @@ import { REPORT_DATERANGE_HELPER_LABELS } from 'components/interfaces/Reports/Re
|
||||
import { SharedAPIReport } from 'components/interfaces/Reports/SharedAPIReport/SharedAPIReport'
|
||||
import { useSharedAPIReport } from 'components/interfaces/Reports/SharedAPIReport/SharedAPIReport.constants'
|
||||
import UpgradePrompt from 'components/interfaces/Settings/Logs/UpgradePrompt'
|
||||
import { getAuthReportAttributes } from 'data/reports/auth-charts'
|
||||
import { useReportDateRange } from 'hooks/misc/useReportDateRange'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import { createAuthReportConfig } from 'data/reports/v2/auth.config'
|
||||
import { ReportSettings } from 'components/ui/Charts/ReportSettings'
|
||||
|
||||
const AuthReport: NextPageWithLayout = () => {
|
||||
return (
|
||||
@@ -41,6 +42,7 @@ export default AuthReport
|
||||
|
||||
const AuthUsage = () => {
|
||||
const { ref } = useParams()
|
||||
const chartSyncId = `auth-report`
|
||||
|
||||
const {
|
||||
selectedDateRange,
|
||||
@@ -71,17 +73,19 @@ const AuthUsage = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
|
||||
const AUTH_REPORT_ATTRIBUTES = getAuthReportAttributes()
|
||||
const authReportConfig = createAuthReportConfig({
|
||||
projectRef: ref || '',
|
||||
startDate: selectedDateRange?.period_start?.date,
|
||||
endDate: selectedDateRange?.period_end?.date,
|
||||
interval: selectedDateRange?.interval,
|
||||
filters: { status_code: null },
|
||||
})
|
||||
|
||||
const onRefreshReport = async () => {
|
||||
if (!selectedDateRange) return
|
||||
|
||||
setIsRefreshing(true)
|
||||
AUTH_REPORT_ATTRIBUTES.forEach((attr) => {
|
||||
attr.attributes.forEach((subAttr) => {
|
||||
queryClient.invalidateQueries(['auth-logs-report', 'auth-metrics'])
|
||||
})
|
||||
})
|
||||
|
||||
refetch()
|
||||
setTimeout(() => setIsRefreshing(false), 1000)
|
||||
}
|
||||
@@ -100,6 +104,7 @@ const AuthUsage = () => {
|
||||
tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }}
|
||||
onClick={onRefreshReport}
|
||||
/>
|
||||
<ReportSettings chartId={chartSyncId} />
|
||||
<div className="flex items-center gap-3">
|
||||
<LogsDatePicker
|
||||
onSubmit={handleDatePickerChange}
|
||||
@@ -130,18 +135,21 @@ const AuthUsage = () => {
|
||||
</>
|
||||
}
|
||||
>
|
||||
{selectedDateRange &&
|
||||
AUTH_REPORT_ATTRIBUTES.filter((attr) => !attr.hide).map((attr, i) => (
|
||||
<ReportChart
|
||||
key={`${attr.id}-${i}`}
|
||||
chart={attr}
|
||||
interval={selectedDateRange.interval}
|
||||
startDate={selectedDateRange?.period_start?.date}
|
||||
endDate={selectedDateRange?.period_end?.date}
|
||||
updateDateRange={updateDateRange}
|
||||
isLoading={isRefreshing}
|
||||
/>
|
||||
))}
|
||||
{authReportConfig.map((metric, i) => (
|
||||
<ReportChartV2
|
||||
key={`${metric.id}`}
|
||||
report={metric}
|
||||
projectRef={ref!}
|
||||
interval={selectedDateRange.interval}
|
||||
startDate={selectedDateRange?.period_start?.date}
|
||||
endDate={selectedDateRange?.period_end?.date}
|
||||
updateDateRange={updateDateRange}
|
||||
syncId={chartSyncId}
|
||||
filters={{
|
||||
status_code: null,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h5 className="text-foreground mb-2">Auth API Gateway</h5>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useFlag, useParams } from 'common'
|
||||
import { ReportChart } from 'components/interfaces/Reports/ReportChart'
|
||||
import ReportHeader from 'components/interfaces/Reports/ReportHeader'
|
||||
import ReportPadding from 'components/interfaces/Reports/ReportPadding'
|
||||
import { REPORT_DATERANGE_HELPER_LABELS } from 'components/interfaces/Reports/Reports.constants'
|
||||
@@ -42,6 +41,7 @@ import { formatBytes } from 'lib/helpers'
|
||||
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
import { AlertDescription_Shadcn_, Alert_Shadcn_, Button } from 'ui'
|
||||
import { ReportChartUpsell } from 'components/interfaces/Reports/v2/ReportChartUpsell'
|
||||
|
||||
const DatabaseReport: NextPageWithLayout = () => {
|
||||
return (
|
||||
@@ -307,13 +307,13 @@ const DatabaseUsage = () => {
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ReportChart
|
||||
key={`${chart.id}-${i}`}
|
||||
chart={chart}
|
||||
interval={selectedDateRange.interval}
|
||||
startDate={selectedDateRange?.period_start?.date}
|
||||
endDate={selectedDateRange?.period_end?.date}
|
||||
updateDateRange={updateDateRange}
|
||||
<ReportChartUpsell
|
||||
key={chart.id}
|
||||
report={{
|
||||
label: chart.label,
|
||||
availableIn: chart.availableIn ?? [],
|
||||
}}
|
||||
orgSlug={org?.slug ?? ''}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user