From 3bea8f5bf0af618c94c38c32534d0497f3d49148 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 12 Sep 2025 09:34:28 +1000 Subject: [PATCH] Home New: Project Usage (#38339) * new home top * advisors * add project usage section * fix ts * postgres * add advisor section * Nit * Nit * Attempt to fix ui library build issue * Revert changes to Row/index.tsx * Fix icon positioning --------- Co-authored-by: Joshen Lim --- .../components/interfaces/HomeNew/Home.tsx | 10 +- .../HomeNew/ProjectUsageSection.tsx | 390 ++++++++++++++++++ .../HomeNew/ProjectUsageSection.utils.ts | 92 +++++ .../hooks/analytics/useProjectUsageStats.tsx | 120 ++++++ .../ui-patterns/src/LogsBarChart/index.tsx | 4 +- packages/ui-patterns/src/Row/index.tsx | 16 +- 6 files changed, 621 insertions(+), 11 deletions(-) create mode 100644 apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx create mode 100644 apps/studio/components/interfaces/HomeNew/ProjectUsageSection.utils.ts create mode 100644 apps/studio/hooks/analytics/useProjectUsageStats.tsx diff --git a/apps/studio/components/interfaces/HomeNew/Home.tsx b/apps/studio/components/interfaces/HomeNew/Home.tsx index fc8e75fa97..e2e85de77f 100644 --- a/apps/studio/components/interfaces/HomeNew/Home.tsx +++ b/apps/studio/components/interfaces/HomeNew/Home.tsx @@ -2,7 +2,7 @@ import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from ' import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' import { useEffect, useRef } from 'react' -import { useParams } from 'common' +import { IS_PLATFORM, useParams } from 'common' import { SortableSection } from 'components/interfaces/HomeNew/SortableSection' import { TopSection } from 'components/interfaces/HomeNew/TopSection' import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' @@ -21,6 +21,7 @@ import { GettingStartedSection, type GettingStartedState, } from './GettingStarted/GettingStartedSection' +import { ProjectUsageSection } from './ProjectUsageSection' export const HomeV2 = () => { const { ref, enableBranching } = useParams() @@ -106,6 +107,13 @@ export const HomeV2 = () => { strategy={verticalListSortingStrategy} > {sectionOrder.map((id) => { + if (IS_PLATFORM && id === 'usage') { + return ( + + + + ) + } if (id === 'getting-started') { return gettingStartedState === 'hidden' ? null : ( diff --git a/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx b/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx new file mode 100644 index 0000000000..1c8aa8fbb2 --- /dev/null +++ b/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.tsx @@ -0,0 +1,390 @@ +import dayjs from 'dayjs' +import { Archive, ChevronDown, Code, Database, Key, Zap } from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useMemo, useState } from 'react' + +import { useParams } from 'common' +import NoDataPlaceholder from 'components/ui/Charts/NoDataPlaceholder' +import { InlineLink } from 'components/ui/InlineLink' +import useProjectUsageStats from 'hooks/analytics/useProjectUsageStats' +import { useCurrentOrgPlan } from 'hooks/misc/useCurrentOrgPlan' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import type { ChartIntervals } from 'types' +import { + Button, + Card, + CardContent, + CardHeader, + CardTitle, + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, + Loading, + Tooltip, + TooltipContent, + TooltipTrigger, +} from 'ui' +import { Row } from 'ui-patterns' +import { LogsBarChart } from 'ui-patterns/LogsBarChart' +import { useServiceStats } from './ProjectUsageSection.utils' + +const LOG_RETENTION = { free: 1, pro: 7, team: 28, enterprise: 90 } + +const CHART_INTERVALS: ChartIntervals[] = [ + { + key: '1hr', + label: 'Last 60 minutes', + startValue: 1, + startUnit: 'hour', + format: 'MMM D, h:mma', + availableIn: ['free', 'pro', 'team', 'enterprise'], + }, + { + key: '1day', + label: 'Last 24 hours', + startValue: 24, + startUnit: 'hour', + format: 'MMM D, ha', + availableIn: ['free', 'pro', 'team', 'enterprise'], + }, + { + key: '7day', + label: 'Last 7 days', + startValue: 7, + startUnit: 'day', + format: 'MMM D', + availableIn: ['pro', 'team', 'enterprise'], + }, +] + +type ChartIntervalKey = '1hr' | '1day' | '7day' + +type LogsBarChartDatum = { + timestamp: string + error_count: number + ok_count: number + warning_count: number +} + +type ServiceKey = 'db' | 'functions' | 'auth' | 'storage' | 'realtime' + +type ServiceEntry = { + key: ServiceKey + title: string + icon: React.ReactNode + href?: string + route: string + enabled: boolean +} + +type ServiceComputed = ServiceEntry & { + data: LogsBarChartDatum[] + total: number + warn: number + err: number + stats: ReturnType +} + +export const ProjectUsageSection = () => { + const router = useRouter() + const { ref: projectRef } = useParams() + const { data: organization } = useSelectedOrganizationQuery() + const { projectAuthAll: authEnabled, projectStorageAll: storageEnabled } = useIsFeatureEnabled([ + 'project_auth:all', + 'project_storage:all', + ]) + const { plan } = useCurrentOrgPlan() + + const DEFAULT_INTERVAL: ChartIntervalKey = plan?.id === 'free' ? '1hr' : '1day' + const [interval, setInterval] = useState(DEFAULT_INTERVAL) + + const selectedInterval = CHART_INTERVALS.find((i) => i.key === interval) || CHART_INTERVALS[1] + + const { timestampStart, timestampEnd, datetimeFormat } = useMemo(() => { + const startDateLocal = dayjs().subtract( + selectedInterval.startValue, + selectedInterval.startUnit as dayjs.ManipulateType + ) + const endDateLocal = dayjs() + const format = selectedInterval.format || 'MMM D, ha' + + return { + timestampStart: startDateLocal.toISOString(), + timestampEnd: endDateLocal.toISOString(), + datetimeFormat: format, + } + }, [selectedInterval]) // Only recalculate when interval changes + + const { previousStart, previousEnd } = useMemo(() => { + const currentStart = dayjs(timestampStart) + const currentEnd = dayjs(timestampEnd) + const durationMs = currentEnd.diff(currentStart) + const prevEnd = currentStart + const prevStart = currentStart.subtract(durationMs, 'millisecond') + return { previousStart: prevStart.toISOString(), previousEnd: prevEnd.toISOString() } + }, [timestampStart, timestampEnd]) + + const statsByService = useServiceStats( + projectRef as string, + timestampStart, + timestampEnd, + previousStart, + previousEnd + ) + + const toLogsBarChartData = (rows: any[] = []): LogsBarChartDatum[] => { + return rows.map((r) => ({ + timestamp: String(r.timestamp), + ok_count: Number(r.ok_count || 0), + warning_count: Number(r.warning_count || 0), + error_count: Number(r.error_count || 0), + })) + } + + const sumTotal = (data: LogsBarChartDatum[]) => + data.reduce((acc, r) => acc + r.ok_count + r.warning_count + r.error_count, 0) + const sumWarnings = (data: LogsBarChartDatum[]) => + data.reduce((acc, r) => acc + r.warning_count, 0) + const sumErrors = (data: LogsBarChartDatum[]) => data.reduce((acc, r) => acc + r.error_count, 0) + + const serviceBase: ServiceEntry[] = useMemo( + () => [ + { + key: 'db', + title: 'Database requests', + icon: , + href: `/project/${projectRef}/editor`, + route: '/logs/postgres-logs', + enabled: true, + }, + { + key: 'functions', + title: 'Functions requests', + icon: , + route: '/logs/edge-functions-logs', + enabled: true, + }, + { + key: 'auth', + title: 'Auth requests', + icon: , + href: `/project/${projectRef}/auth/users`, + route: '/logs/auth-logs', + enabled: authEnabled, + }, + { + key: 'storage', + title: 'Storage requests', + icon: , + href: `/project/${projectRef}/storage/buckets`, + route: '/logs/storage-logs', + enabled: storageEnabled, + }, + { + key: 'realtime', + title: 'Realtime requests', + icon: , + route: '/logs/realtime-logs', + enabled: true, + }, + ], + [projectRef, authEnabled, storageEnabled] + ) + + const services: ServiceComputed[] = useMemo( + () => + serviceBase.map((s) => { + const currentStats = statsByService[s.key].current + const data = toLogsBarChartData(currentStats.eventChartData) + const total = sumTotal(data) + const warn = sumWarnings(data) + const err = sumErrors(data) + return { ...s, stats: currentStats, data, total, warn, err } + }), + [serviceBase, statsByService] + ) + + const isLoading = services.some((s) => s.stats.isLoading) + + const handleBarClick = (logRoute: string) => (datum: any) => { + if (!datum?.timestamp) return + + const datumTimestamp = dayjs(datum.timestamp).toISOString() + const start = dayjs(datumTimestamp).subtract(1, 'minute').toISOString() + const end = dayjs(datumTimestamp).add(1, 'minute').toISOString() + + const queryParams = new URLSearchParams({ + iso_timestamp_start: start, + iso_timestamp_end: end, + }) + + router.push(`/project/${projectRef}${logRoute}?${queryParams.toString()}`) + } + + const enabledServices = services.filter((s) => s.enabled) + const totalRequests = enabledServices.reduce((sum, s) => sum + (s.total || 0), 0) + const totalErrors = enabledServices.reduce((sum, s) => sum + (s.err || 0), 0) + const errorRate = totalRequests > 0 ? (totalErrors / totalRequests) * 100 : 0 + + const prevServiceTotals = useMemo( + () => + serviceBase.map((s) => { + const previousStats = statsByService[s.key].previous + const data = toLogsBarChartData(previousStats.eventChartData) + return { + enabled: s.enabled, + total: sumTotal(data), + err: sumErrors(data), + } + }), + [serviceBase, statsByService] + ) + + const enabledPrev = prevServiceTotals.filter((s) => s.enabled) + const prevTotalRequests = enabledPrev.reduce((sum, s) => sum + (s.total || 0), 0) + const prevTotalErrors = enabledPrev.reduce((sum, s) => sum + (s.err || 0), 0) + const prevErrorRate = prevTotalRequests > 0 ? (prevTotalErrors / prevTotalRequests) * 100 : 0 + + const totalRequestsChangePct = + prevTotalRequests === 0 + ? totalRequests > 0 + ? 100 + : 0 + : ((totalRequests - prevTotalRequests) / prevTotalRequests) * 100 + const errorRateChangePct = + prevErrorRate === 0 + ? errorRate > 0 + ? 100 + : 0 + : ((errorRate - prevErrorRate) / prevErrorRate) * 100 + const formatDelta = (v: number) => `${v >= 0 ? '+' : ''}${v.toFixed(1)}%` + const totalDeltaClass = totalRequestsChangePct >= 0 ? 'text-brand' : 'text-destructive' + const errorDeltaClass = errorRateChangePct <= 0 ? 'text-brand' : 'text-destructive' + + return ( +
+
+
+
+ {totalRequests.toLocaleString()} + Total Requests + {formatDelta(totalRequestsChangePct)} +
+ / +
+ {errorRate.toFixed(1)}% + Error Rate + {formatDelta(errorRateChangePct)} +
+
+ + + + + + setInterval(interval as ChartIntervalKey)} + > + {CHART_INTERVALS.map((i) => { + const disabled = !i.availableIn?.includes(plan?.id || 'free') + + if (disabled) { + const retentionDuration = LOG_RETENTION[plan?.id ?? 'free'] + return ( + + + + {i.label} + + + +

+ {plan?.name} plan only includes up to {retentionDuration} day + {retentionDuration > 1 ? 's' : ''} of log retention +

+

+ + Upgrade your plan + {' '} + to increase log retention and view statistics for the{' '} + {i.label.toLowerCase()} +

+
+
+ ) + } else { + return ( + + {i.label} + + ) + } + })} +
+
+
+
+ + {enabledServices.map((s) => ( + + +
+
+
+ + {s.href ? {s.title} : s.title} + +
+ {(s.total || 0).toLocaleString()} +
+
+
+
+
+
+ Warn +
+ {(s.warn || 0).toLocaleString()} +
+
+
+
+ Err +
+ {(s.err || 0).toLocaleString()} +
+
+ + + + + } + /> + + + + ))} + +
+ ) +} diff --git a/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.utils.ts b/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.utils.ts new file mode 100644 index 0000000000..21491443b4 --- /dev/null +++ b/apps/studio/components/interfaces/HomeNew/ProjectUsageSection.utils.ts @@ -0,0 +1,92 @@ +import useProjectUsageStats from 'hooks/analytics/useProjectUsageStats' +import { LogsTableName } from '../Settings/Logs/Logs.constants' + +type ServiceKey = 'db' | 'functions' | 'auth' | 'storage' | 'realtime' +type ServiceStatsMap = Record< + ServiceKey, + { + current: ReturnType + previous: ReturnType + } +> + +export const useServiceStats = ( + projectRef: string, + timestampStart: string, + timestampEnd: string, + previousStart: string, + previousEnd: string +): ServiceStatsMap => { + const dbCurrent = useProjectUsageStats({ + projectRef, + table: LogsTableName.POSTGRES, + timestampStart, + timestampEnd, + }) + const dbPrevious = useProjectUsageStats({ + projectRef, + table: LogsTableName.POSTGRES, + timestampStart: previousStart, + timestampEnd: previousEnd, + }) + + const fnCurrent = useProjectUsageStats({ + projectRef, + table: LogsTableName.FN_EDGE, + timestampStart, + timestampEnd, + }) + const fnPrevious = useProjectUsageStats({ + projectRef, + table: LogsTableName.FN_EDGE, + timestampStart: previousStart, + timestampEnd: previousEnd, + }) + + const authCurrent = useProjectUsageStats({ + projectRef, + table: LogsTableName.AUTH, + timestampStart, + timestampEnd, + }) + const authPrevious = useProjectUsageStats({ + projectRef, + table: LogsTableName.AUTH, + timestampStart: previousStart, + timestampEnd: previousEnd, + }) + + const storageCurrent = useProjectUsageStats({ + projectRef, + table: LogsTableName.STORAGE, + timestampStart, + timestampEnd, + }) + const storagePrevious = useProjectUsageStats({ + projectRef, + table: LogsTableName.STORAGE, + timestampStart: previousStart, + timestampEnd: previousEnd, + }) + + const realtimeCurrent = useProjectUsageStats({ + projectRef, + table: LogsTableName.REALTIME, + timestampStart, + timestampEnd, + }) + const realtimePrevious = useProjectUsageStats({ + projectRef, + table: LogsTableName.REALTIME, + timestampStart: previousStart, + timestampEnd: previousEnd, + }) + + return { + db: { current: dbCurrent, previous: dbPrevious }, + functions: { current: fnCurrent, previous: fnPrevious }, + auth: { current: authCurrent, previous: authPrevious }, + storage: { current: storageCurrent, previous: storagePrevious }, + realtime: { current: realtimeCurrent, previous: realtimePrevious }, + } +} diff --git a/apps/studio/hooks/analytics/useProjectUsageStats.tsx b/apps/studio/hooks/analytics/useProjectUsageStats.tsx new file mode 100644 index 0000000000..9ac129a49e --- /dev/null +++ b/apps/studio/hooks/analytics/useProjectUsageStats.tsx @@ -0,0 +1,120 @@ +import { useQuery } from '@tanstack/react-query' +import { useMemo } from 'react' + +import { LogsTableName } from 'components/interfaces/Settings/Logs/Logs.constants' +import type { + EventChart, + EventChartData, + Filters, + LogsEndpointParams, +} from 'components/interfaces/Settings/Logs/Logs.types' +import { genChartQuery } from 'components/interfaces/Settings/Logs/Logs.utils' +import { get } from 'data/fetchers' +import { useFillTimeseriesSorted } from './useFillTimeseriesSorted' +import useTimeseriesUnixToIso from './useTimeseriesUnixToIso' + +interface ProjectUsageStatsHookResult { + error: string | Object | null + isLoading: boolean + filters: Filters + params: LogsEndpointParams + eventChartData: EventChartData[] + refresh: () => void +} + +function useProjectUsageStats({ + projectRef, + table, + timestampStart, + timestampEnd, + filterOverride, +}: { + projectRef: string + table: LogsTableName + timestampStart: string + timestampEnd: string + filterOverride?: Filters +}): ProjectUsageStatsHookResult { + const filterOverrideString = JSON.stringify(filterOverride) + const mergedFilters = useMemo( + () => ({ + ...filterOverride, + }), + [filterOverrideString] + ) + + const params: LogsEndpointParams = useMemo(() => { + return { iso_timestamp_start: timestampStart, iso_timestamp_end: timestampEnd } + }, [timestampStart, timestampEnd]) + + const chartQuery = useMemo( + () => genChartQuery(table, params, mergedFilters), + [table, params, mergedFilters] + ) + + const chartQueryKey = useMemo( + () => [ + 'projects', + projectRef, + 'logs-chart', + table, + { + projectRef, + sql: chartQuery, + iso_timestamp_start: timestampStart, + iso_timestamp_end: timestampEnd, + }, + ], + [projectRef, chartQuery, timestampStart, timestampEnd, table] + ) + + const { data: eventChartResponse, refetch: refreshEventChart } = useQuery( + chartQueryKey, + async ({ signal }) => { + const { data, error } = await get(`/platform/projects/{ref}/analytics/endpoints/logs.all`, { + params: { + path: { ref: projectRef }, + query: { + iso_timestamp_start: timestampStart, + iso_timestamp_end: timestampEnd, + sql: chartQuery, + }, + }, + signal, + }) + if (error) { + throw error + } + + return data as unknown as EventChart + }, + { + refetchOnWindowFocus: false, + enabled: typeof projectRef !== 'undefined', + } + ) + + const normalizedEventChartData = useTimeseriesUnixToIso( + eventChartResponse?.result ?? [], + 'timestamp' + ) + + const { data: eventChartData, error: eventChartError } = useFillTimeseriesSorted( + normalizedEventChartData, + 'timestamp', + 'count', + 0, + timestampStart, + timestampEnd || new Date().toISOString() + ) + + return { + isLoading: !eventChartResponse, + error: eventChartError, + filters: mergedFilters, + params, + eventChartData, + refresh: refreshEventChart, + } +} +export default useProjectUsageStats diff --git a/packages/ui-patterns/src/LogsBarChart/index.tsx b/packages/ui-patterns/src/LogsBarChart/index.tsx index 264221a75f..6d5e805503 100644 --- a/packages/ui-patterns/src/LogsBarChart/index.tsx +++ b/packages/ui-patterns/src/LogsBarChart/index.tsx @@ -26,11 +26,13 @@ export const LogsBarChart = ({ onBarClick, EmptyState, DateTimeFormat = 'MMM D, YYYY, hh:mma', + height = '80px', }: { data: LogsBarChartDatum[] onBarClick?: (datum: LogsBarChartDatum, tooltipData?: CategoricalChartState) => void EmptyState?: ReactNode DateTimeFormat?: string + height?: string }) => { const [focusDataIndex, setFocusDataIndex] = useState(null) @@ -58,7 +60,7 @@ export const LogsBarChart = ({ }, } satisfies ChartConfig } - className="h-[80px]" + style={{ height }} > { - // columns can be a fixed number or an array [lg, md, sm] + /** columns can be a fixed number or an array [lg, md, sm] */ columns: number | [number, number, number] children: ReactNode className?: string @@ -145,9 +145,8 @@ export const Row = forwardRef(function Row( onClick={scrollLeft} className="absolute w-8 h-8 left-0 top-1/2 -translate-y-1/2 z-10 rounded-full p-2" aria-label="Scroll left" - > - - + icon={} + /> )} {showArrows && canScrollRight && ( @@ -156,9 +155,8 @@ export const Row = forwardRef(function Row( onClick={scrollRight} className="absolute w-8 h-8 right-0 top-1/2 -translate-y-1/2 z-10 rounded-full p-2" aria-label="Scroll right" - > - - + icon={} + /> )}