diff --git a/apps/docs/layouts/SiteLayout.tsx b/apps/docs/layouts/SiteLayout.tsx index 1b0f0a75df1..953cb518817 100644 --- a/apps/docs/layouts/SiteLayout.tsx +++ b/apps/docs/layouts/SiteLayout.tsx @@ -8,7 +8,6 @@ import Head from 'next/head' import { PropsWithChildren, memo } from 'react' import Footer from '~/components/Navigation/Footer' import { menuState, useMenuLevelId, useMenuMobileOpen } from '~/hooks/useMenuState' -import { Announcement, LW8CountdownBanner } from 'ui' const levelsData = { home: { @@ -327,9 +326,6 @@ const SiteLayout = ({ children }: PropsWithChildren<{}>) => { Supabase Docs
- - -
diff --git a/apps/www/components/Announcement/Badge.tsx b/apps/www/components/Announcement/Badge.tsx index adbc7accd87..e2a379c8747 100644 --- a/apps/www/components/Announcement/Badge.tsx +++ b/apps/www/components/Announcement/Badge.tsx @@ -3,10 +3,17 @@ import Link from 'next/link' import { Badge } from 'ui' import { ArrowNarrowRightIcon } from '@heroicons/react/outline' -const AnnouncementBadge = () => ( +interface Props { + url: string + announcement: string + badge?: string + target?: '_self' | '_blank' +} +const AnnouncementBadge = ({ url, announcement, badge, target = '_self' }: Props) => (
- + - - Explore - - Launch Week 8 announcements + {badge && ( + + {badge} + + )} + {announcement}
- +
@@ -30,9 +30,9 @@ const Hero = () => { ].join(' ')} >
-
+ {/*
-
+
*/}

Build in a weekend @@ -80,32 +80,7 @@ const Hero = () => {

-
- - stars background - -
-
- Launch Week 8 purple background gradient -
-
) } diff --git a/apps/www/components/Nav/Developers.tsx b/apps/www/components/Nav/Developers.tsx index 9fdc46c12bf..1663d190aca 100644 --- a/apps/www/components/Nav/Developers.tsx +++ b/apps/www/components/Nav/Developers.tsx @@ -32,7 +32,7 @@ const Developers = () => { stroke="currentColor" aria-hidden="true" > - + )} {Svg && } diff --git a/apps/www/components/Nav/index.tsx b/apps/www/components/Nav/index.tsx index 2230165ec95..b36fd57c9dd 100644 --- a/apps/www/components/Nav/index.tsx +++ b/apps/www/components/Nav/index.tsx @@ -2,8 +2,7 @@ import React, { useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/router' -import Announcement from '~/components/Announcement/Announcement' -import { Button, Badge, IconStar, IconChevronDown, LW8CountdownBanner } from 'ui' +import { Button, Badge, IconStar, IconChevronDown } from 'ui' import FlyOut from '~/components/UI/FlyOut' import Transition from 'lib/Transition' @@ -198,9 +197,6 @@ const Nav = () => { return ( <> - - -
{ { y2="21.5" gradientUnits="userSpaceOnUse" > - - - + + + diff --git a/apps/www/pages/pricing/index.tsx b/apps/www/pages/pricing/index.tsx index f780a7b4f91..ed5dc2d0277 100644 --- a/apps/www/pages/pricing/index.tsx +++ b/apps/www/pages/pricing/index.tsx @@ -14,6 +14,7 @@ import { useTheme } from 'common/Providers' import ComputePricingModal from '~/components/Pricing/ComputePricingModal' import { plans } from 'shared-data/plans' import { ArrowNarrowRightIcon } from '@heroicons/react/outline' +import AnnouncementBadge from '../../components/Announcement/Badge' export default function IndexPage() { const router = useRouter() @@ -151,7 +152,7 @@ export default function IndexPage() { />
-
+

Pricing

@@ -159,45 +160,13 @@ export default function IndexPage() {

Start building for free, collaborate with a team, then scale to millions of users.

- diff --git a/packages/shared-data/plans.ts b/packages/shared-data/plans.ts index 5e40e4b5c51..d13c90cb463 100644 --- a/packages/shared-data/plans.ts +++ b/packages/shared-data/plans.ts @@ -94,7 +94,7 @@ export const plans: PricingInformation[] = [ 'Daily backups stored for 14 days', 'Standardised Security Questionnaire', 'SOC2', - 'HIPAA', + 'HIPAA available as paid add-on', 'SSO for Supabase Dashboard', 'Priority email support & SLAs', '28-day log retention', diff --git a/packages/shared-data/pricing.ts b/packages/shared-data/pricing.ts index b955a8cfe2f..3bd10372288 100644 --- a/packages/shared-data/pricing.ts +++ b/packages/shared-data/pricing.ts @@ -469,8 +469,8 @@ export const pricing: Pricing = { plans: { free: false, pro: false, - team: true, - enterprise: true, + team: 'Available as paid add-on', + enterprise: 'Available as paid add-on', }, tooltips: { main: 'Available as a paid add-on on Team plan and above.', diff --git a/studio/components/interfaces/Reports/Reports.constants.ts b/studio/components/interfaces/Reports/Reports.constants.ts index 71b5f65324f..f4c4f69fc39 100644 --- a/studio/components/interfaces/Reports/Reports.constants.ts +++ b/studio/components/interfaces/Reports/Reports.constants.ts @@ -228,6 +228,52 @@ export const PRESET_CONFIG: Record = { title: '', queries: {}, }, + [Presets.STORAGE]: { + title: 'Storage', + queries: { + cacheHitRate: { + queryType: 'logs', + // storage report does not perform any filtering + sql: (_filters) => ` +-- cache-hit-rate +SELECT + timestamp_trunc(timestamp, hour) as timestamp, + countif( h.cf_cache_status in ('HIT', 'STALE', 'REVALIDATED', 'UPDATING') ) as hit_count, + countif( h.cf_cache_status in ('MISS', 'NONE/UNKNOWN', 'EXPIRED', 'BYPASS', 'DYNAMIC') ) as miss_count +from edge_logs f + cross join unnest(f.metadata) as m + cross join unnest(m.request) as r + cross join unnest(m.response) as res + cross join unnest(res.headers) as h +where starts_with(r.path, '/storage/v1/object') and r.method = 'GET' +group by timestamp +order by timestamp desc +`, + }, + topCacheMisses: { + queryType: 'logs', + // storage report does not perform any filtering + sql: (_filters) => ` +-- top-cache-misses +SELECT + r.path as path, + r.search as search, + count(id) as count +from edge_logs f + cross join unnest(f.metadata) as m + cross join unnest(m.request) as r + cross join unnest(m.response) as res + cross join unnest(res.headers) as h +where starts_with(r.path, '/storage/v1/object') + and r.method = 'GET' + and h.cf_cache_status in ('MISS', 'NONE/UNKNOWN', 'EXPIRED', 'BYPASS', 'DYNAMIC') +group by path, search +order by count desc +limit 12 + `, + }, + }, + }, [Presets.QUERY_PERFORMANCE]: { title: 'Query performance', queries: { diff --git a/studio/components/interfaces/Reports/Reports.types.ts b/studio/components/interfaces/Reports/Reports.types.ts index a13240a5125..900a7c0c437 100644 --- a/studio/components/interfaces/Reports/Reports.types.ts +++ b/studio/components/interfaces/Reports/Reports.types.ts @@ -3,6 +3,7 @@ import { DEFAULT_QUERY_PARAMS } from './Reports.constants' export enum Presets { API = 'api', + STORAGE = 'storage', AUTH = 'auth', QUERY_PERFORMANCE = 'query_performance', } diff --git a/studio/components/interfaces/Reports/renderers/StorageRenderers.tsx b/studio/components/interfaces/Reports/renderers/StorageRenderers.tsx new file mode 100644 index 00000000000..cdff467d303 --- /dev/null +++ b/studio/components/interfaces/Reports/renderers/StorageRenderers.tsx @@ -0,0 +1,86 @@ +import { TextFormatter } from 'components/interfaces/Settings/Logs/LogsFormatters' +import Table from 'components/to-be-cleaned/Table' +import StackedBarChart from 'components/ui/Charts/StackedBarChart' +import { Fragment } from 'react' +import { ReportWidgetProps, ReportWidgetRendererProps } from '../ReportWidget' + +export const CacheHitRateChartRenderer = ( + props: ReportWidgetProps<{ + timestamp: string + hit_count: number + miss_count: number + }> +) => { + const stackedData = props.data.flatMap((datum) => [ + { + timestamp: datum.timestamp, + count: datum.hit_count, + type: 'hit', + }, + { + timestamp: datum.timestamp, + count: datum.miss_count, + type: 'miss', + }, + ]) + + return ( + + ) +} + +export const TopCacheMissesRenderer = ( + props: ReportWidgetRendererProps<{ + path: string + search: string + count: number + }> +) => { + if (props.data.length === 0) return null + const headerClasses = '!text-xs !py-2 p-0 font-bold !bg-scale-400' + const cellClasses = '!text-xs !py-2' + + return ( + <> +

Top Cache Misses

+ + Request + Count + + } + body={ + <> + {props.data.map((datum) => ( + + + +
+ + +
+
+ + {datum.count} + +
+
+ ))} + + } + /> + + ) +} diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx index 9a97b7eba14..477252ef4eb 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx @@ -2,7 +2,17 @@ import type { PostgresColumn, PostgresSchema, PostgresTable } from '@supabase/po import { Dictionary } from 'components/grid' import { find, get, isEmpty, sortBy } from 'lodash' import { useEffect, useState } from 'react' -import { IconDatabase, IconHelpCircle, Input, Listbox, SidePanel } from 'ui' +import { + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Alert_Shadcn_, + IconAlertTriangle, + IconDatabase, + IconHelpCircle, + Input, + Listbox, + SidePanel, +} from 'ui' import InformationBox from 'components/ui/InformationBox' import { FOREIGN_KEY_DELETION_ACTION } from 'data/database/database-query-constants' @@ -153,6 +163,8 @@ const ForeignKeySelector = ({ resolve() } + const matchingColumnTypes = selectedColumn?.format === column?.format + return ( )} + {!matchingColumnTypes && ( + + + The column types don't match + + The referenced column + {column?.name && {column.name}} + {column?.format ? ( + <> + is of type + {column.format} + + ) : ( + has no type + )} + while the selected foreign column + + {selectedTable?.name}.{selectedColumn?.name} + + has + {selectedColumn?.data_type}type. These two + columns can't be referenced until they are of the same type. + + + )} } diff --git a/studio/components/layouts/ReportsLayout/ReportsLayout.tsx b/studio/components/layouts/ReportsLayout/ReportsLayout.tsx index f1517357776..0bdd0813a26 100644 --- a/studio/components/layouts/ReportsLayout/ReportsLayout.tsx +++ b/studio/components/layouts/ReportsLayout/ReportsLayout.tsx @@ -4,7 +4,7 @@ import { PropsWithChildren } from 'react' import ProductMenu from 'components/ui/ProductMenu' import { useSelectedProject, withAuth } from 'hooks' import ProjectLayout from '../' -import { generateReportsMenu } from './ReportsMenu.utils' +import { useFlag } from 'hooks' interface ReportsLayoutProps { title?: string @@ -15,12 +15,61 @@ const ReportsLayout = ({ title, children }: PropsWithChildren} + productMenu={} >
{children} diff --git a/studio/components/layouts/ReportsLayout/ReportsMenu.utils.ts b/studio/components/layouts/ReportsLayout/ReportsMenu.utils.ts deleted file mode 100644 index 4e088fd0ae6..00000000000 --- a/studio/components/layouts/ReportsLayout/ReportsMenu.utils.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Project } from 'types' -import { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' - -export const generateReportsMenu = (project?: Project): ProductMenuGroup[] => { - const ref = project?.ref ?? 'default' - - return [ - { - title: '', - items: [ - { - name: 'Custom reports', - key: '', - url: `/project/${ref}/reports`, - items: [], - }, - ], - }, - { - items: [ - { - name: 'API', - key: 'api-overview', - url: `/project/${ref}/reports/api-overview`, - items: [], - label: 'NEW', - }, - { - name: 'Database', - key: 'database', - url: `/project/${ref}/reports/database`, - items: [], - }, - { - name: 'Query Performance', - key: 'query-performance', - url: `/project/${ref}/reports/query-performance`, - items: [], - label: 'NEW', - }, - ], - }, - ] -} diff --git a/studio/components/to-be-cleaned/Storage/StorageExplorer/CustomExpiryModal.tsx b/studio/components/to-be-cleaned/Storage/StorageExplorer/CustomExpiryModal.tsx index d20d137cdbc..82697ff1ce1 100644 --- a/studio/components/to-be-cleaned/Storage/StorageExplorer/CustomExpiryModal.tsx +++ b/studio/components/to-be-cleaned/Storage/StorageExplorer/CustomExpiryModal.tsx @@ -1,11 +1,21 @@ -import { Form, Modal, Input, Button } from 'ui' +import dayjs from 'dayjs' import { observer } from 'mobx-react-lite' +import { Button, Form, Input, Listbox, Modal } from 'ui' + +import { DATETIME_FORMAT } from 'lib/constants' import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore' export interface CustomExpiryModalProps { onCopyUrl: (name: string, url: string) => void } +const unitMap = { + days: 3600 * 24, + weeks: 3600 * 24 * 7, + months: 3600 * 24 * 30, + years: 3600 * 24 * 365, +} + const CustomExpiryModal = ({ onCopyUrl }: CustomExpiryModalProps) => { const storageExplorerStore = useStorageStore() const { getFileUrl, selectedFileCustomExpiry, setSelectedFileCustomExpiry } = storageExplorerStore @@ -25,34 +35,57 @@ const CustomExpiryModal = ({ onCopyUrl }: CustomExpiryModalProps) => { >
{ setSubmitting(true) onCopyUrl( selectedFileCustomExpiry.name, - await getFileUrl(selectedFileCustomExpiry, values.expiresIn) + await getFileUrl( + selectedFileCustomExpiry, + values.expiresIn * unitMap[values.units as 'days' | 'weeks' | 'months' | 'years'] + ) ) setSubmitting(false) onClose() }} validate={(values: any) => { const errors: any = {} - if (values.expiresIn !== '' && values.expiresIn <= 0) + if (values.expiresIn !== '' && values.expiresIn <= 0) { errors.expiresIn = 'Expiry duration cannot be less than 0' + } return errors }} > {({ values, isSubmitting }: { values: any; isSubmitting: boolean }) => ( <> -
+
- seconds

} - /> +

+ Enter the duration for which the URL will be valid for: +

+
+ + + + days + + + weeks + + + months + + + years + + +
+ {values.expiresIn !== '' && ( +

+ URL will expire on{' '} + {dayjs().add(values.expiresIn, values.units).format(DATETIME_FORMAT)} +

+ )}
diff --git a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx index ba4ee7bc678..36a73884997 100644 --- a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx +++ b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx @@ -409,6 +409,7 @@ const FileExplorerRow = ({ /> ) : ( ({ - lighter: `var(--colors-${color}${(n as number) - 1})`, - base: `var(--colors-${color}${n})`, - darker: `var(--colors-${color}${(n as number) + 1})`, -})) +export type ValidStackColor = + | 'brand' + | 'blue' + | 'red' + | 'yellow' + | 'green' + | 'slate' + | 'indigo' + | 'tomato' + | 'orange' + | 'amber' -export const USAGE_COLORS = { - 200: 'hsl(var(--brand-default))', - 201: 'hsl(var(--brand-500))', - 400: 'var(--colors-amber9)', - 401: 'var(--colors-amber8)', - 404: 'var(--colors-amber7)', - 500: 'var(--colors-red9)', -} +export const genStackColorScales = (colors: ValidStackColor[]) => + colors.map((color) => { + // override default base scale for certain colors that do not have good contrast + const scale = + ( + { + slate: 11, + } as any + )[color] ?? 9 + return { + lighter: `var(--colors-${color}${(scale as number) - 1})`, + base: `var(--colors-${color}${scale})`, + darker: `var(--colors-${color}${(scale as number) + 1})`, + } + }) + +export const DEFAULT_STACK_COLORS : ValidStackColor[] = ['brand', 'slate', 'blue', 'yellow', 'indigo'] export enum DateTimeFormats { FULL = 'MMM D, YYYY, hh:mma', diff --git a/studio/components/ui/Charts/Sparkline.stories.tsx b/studio/components/ui/Charts/Sparkline.stories.tsx index 5cb2e257683..1808d40a2d6 100644 --- a/studio/components/ui/Charts/Sparkline.stories.tsx +++ b/studio/components/ui/Charts/Sparkline.stories.tsx @@ -1,9 +1,7 @@ import React from 'react' -import { ComponentStory, ComponentMeta } from '@storybook/react' +import { ComponentMeta } from '@storybook/react' import dayjs from 'dayjs' import Chart from './Sparkline' -import { StackedChartProps } from './Charts.types' -import { STACK_COLORS } from './Charts.constants' export default { title: 'Charts/Sparkline', diff --git a/studio/components/ui/Charts/StackedBarChart.stories.tsx b/studio/components/ui/Charts/StackedBarChart.stories.tsx index 9eb71d42736..8a308ea5ffd 100644 --- a/studio/components/ui/Charts/StackedBarChart.stories.tsx +++ b/studio/components/ui/Charts/StackedBarChart.stories.tsx @@ -3,7 +3,7 @@ import { ComponentStory, ComponentMeta } from '@storybook/react' import dayjs from 'dayjs' import Chart from './StackedBarChart' import { StackedChartProps } from './Charts.types' -import { STACK_COLORS } from './Charts.constants' +import { DEFAULT_STACK_COLORS, genStackColorScales } from './Charts.constants' export default { title: 'Charts/StackedBarChart', @@ -38,7 +38,7 @@ export const StackedBarChart = () => (
Default Stacked Chart Colors
- {STACK_COLORS.map((c) => ( + {genStackColorScales(DEFAULT_STACK_COLORS).map((c) => (
))}
diff --git a/studio/components/ui/Charts/StackedBarChart.tsx b/studio/components/ui/Charts/StackedBarChart.tsx index 4ce2b8e1ee8..3dbf7e214e0 100644 --- a/studio/components/ui/Charts/StackedBarChart.tsx +++ b/studio/components/ui/Charts/StackedBarChart.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { BarChart, Bar, XAxis, Tooltip, Legend, Cell, TooltipProps } from 'recharts' import ChartHeader from './ChartHeader' -import { CHART_COLORS, STACK_COLORS, DateTimeFormats } from './Charts.constants' +import { CHART_COLORS, DateTimeFormats, DEFAULT_STACK_COLORS, genStackColorScales, ValidStackColor } from './Charts.constants' import { CommonChartProps } from './Charts.types' import { timestampFormatter, useChartSize, useStacked } from './Charts.utils' import { precisionFormatter } from './Charts.utils' @@ -16,6 +16,7 @@ interface Props extends CommonChartProps { displayDateInUtc?: boolean hideLegend?: boolean hideHeader?: boolean + stackColors?: ValidStackColor[] } const StackedBarChart: React.FC = ({ size, @@ -33,6 +34,7 @@ const StackedBarChart: React.FC = ({ displayDateInUtc, hideLegend = false, hideHeader = false, + stackColors = DEFAULT_STACK_COLORS }) => { const { Container } = useChartSize(size) const { dataKeys, stackedData, percentagesStackedData } = useStacked({ @@ -44,6 +46,7 @@ const StackedBarChart: React.FC = ({ }) const [focusDataIndex, setFocusDataIndex] = useState(null) if (!data || data.length === 0) return + const stackColorScales = genStackColorScales(stackColors) return (
{!hideHeader && ( @@ -94,7 +97,7 @@ const StackedBarChart: React.FC = ({ dataKey={datum} type="monotone" legendType="circle" - fill={STACK_COLORS[stackIndex].base} + fill={stackColorScales[stackIndex].base} stackId={1} animationDuration={300} maxBarSize={48} diff --git a/studio/pages/api/ai/sql/debug.ts b/studio/pages/api/ai/sql/debug.ts index 353f11b5a87..2a67dc50062 100644 --- a/studio/pages/api/ai/sql/debug.ts +++ b/studio/pages/api/ai/sql/debug.ts @@ -178,7 +178,9 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) { return res.json(debugSqlResult) } catch (error) { console.error( - `AI SQL editing failed: ${isError(error) ? error.message : 'An unknown error occurred'}` + `AI SQL editing failed: ${ + isError(error) ? error.message : 'An unknown error occurred' + }, sqlResponseString: ${sqlResponseString}` ) return res.status(500).json({ diff --git a/studio/pages/api/ai/sql/edit.ts b/studio/pages/api/ai/sql/edit.ts index 70d4ad4e19a..979b13a5a2f 100644 --- a/studio/pages/api/ai/sql/edit.ts +++ b/studio/pages/api/ai/sql/edit.ts @@ -170,7 +170,9 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) { return res.json(editSqlResult) } catch (error) { console.error( - `AI SQL editing failed: ${isError(error) ? error.message : 'An unknown error occurred'}` + `AI SQL editing failed: ${ + isError(error) ? error.message : 'An unknown error occurred' + }, sqlResponseString: ${sqlResponseString}` ) return res.status(500).json({ diff --git a/studio/pages/api/ai/sql/generate.ts b/studio/pages/api/ai/sql/generate.ts index 7fb2dbbfc77..7231c9c953c 100644 --- a/studio/pages/api/ai/sql/generate.ts +++ b/studio/pages/api/ai/sql/generate.ts @@ -158,7 +158,9 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) { return res.json(generateSqlResult) } catch (error) { console.error( - `AI SQL editing failed: ${isError(error) ? error.message : 'An unknown error occurred'}` + `AI SQL editing failed: ${ + isError(error) ? error.message : 'An unknown error occurred' + }, sqlResponseString: ${sqlResponseString}` ) return res.status(500).json({ diff --git a/studio/pages/api/ai/sql/title.ts b/studio/pages/api/ai/sql/title.ts index bde2ee4db7c..7f34a3227bd 100644 --- a/studio/pages/api/ai/sql/title.ts +++ b/studio/pages/api/ai/sql/title.ts @@ -136,7 +136,9 @@ export async function handlePost(req: NextApiRequest, res: NextApiResponse) { return res.json(generateTitleResult) } catch (error) { console.error( - `AI SQL editing failed: ${isError(error) ? error.message : 'An unknown error occurred'}` + `AI SQL editing failed: ${ + isError(error) ? error.message : 'An unknown error occurred' + }, titleResponseString: ${titleResponseString}` ) return res.status(500).json({ diff --git a/studio/pages/project/[ref]/reports/storage.tsx b/studio/pages/project/[ref]/reports/storage.tsx new file mode 100644 index 00000000000..664265e07a0 --- /dev/null +++ b/studio/pages/project/[ref]/reports/storage.tsx @@ -0,0 +1,126 @@ +import { observer } from 'mobx-react-lite' +import { NextPageWithLayout } from 'types' +import { ReportsLayout } from 'components/layouts' +import { + PRESET_CONFIG, + REPORTS_DATEPICKER_HELPERS, +} from 'components/interfaces/Reports/Reports.constants' +import ReportWidget from 'components/interfaces/Reports/ReportWidget' +import { queriesFactory } from 'components/interfaces/Reports/Reports.utils' +import { + CacheHitRateChartRenderer, + TopCacheMissesRenderer, +} from 'components/interfaces/Reports/renderers/StorageRenderers' +import { useEffect, useMemo } from 'react' +import ReportHeader from 'components/interfaces/Reports/ReportHeader' +import { DatePickerToFrom, LogsEndpointParams } from 'components/interfaces/Settings/Logs' +import { useParams } from 'common' +import ShimmerLine from 'components/ui/ShimmerLine' +import ReportPadding from 'components/interfaces/Reports/ReportPadding' +import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query' +import DatePickers from 'components/interfaces/Settings/Logs/Logs.DatePickers' + +export const StorageReport: NextPageWithLayout = () => { + const { ref: projectRef } = useParams() + const report = useStorageReport() + + const { data: subscription } = useProjectSubscriptionV2Query({ projectRef }) + const plan = subscription?.plan + + const handleDatepickerChange = ({ from, to }: DatePickerToFrom) => { + report.mergeParams({ + iso_timestamp_start: from || '', + iso_timestamp_end: to || '', + }) + } + + const datepickerHelpers = useMemo( + () => + REPORTS_DATEPICKER_HELPERS.map((helper, index) => ({ + ...helper, + disabled: (index > 0 && plan?.id === 'free') || (index > 1 && plan?.id !== 'pro'), + })), + [] + ) + + return ( + + +
+
+ +
+
+ +
+
+ + +
+ ) +} + +// hook to fetch data +const useStorageReport = () => { + const { ref: projectRef } = useParams() + + const queryHooks = queriesFactory( + PRESET_CONFIG.storage.queries, + projectRef ?? 'default' + ) + const cacheHitRate = queryHooks.cacheHitRate() + const topCacheMisses = queryHooks.topCacheMisses() + const activeHooks = [cacheHitRate, topCacheMisses] + + const handleRefresh = async () => { + activeHooks.forEach((hook) => hook.runQuery()) + } + const handleSetParams = (params: Partial) => { + activeHooks.forEach((hook) => { + hook.setParams?.((prev: LogsEndpointParams) => ({ ...prev, ...params })) + }) + } + useEffect(()=>{ + + if (cacheHitRate.changeQuery) { + cacheHitRate.changeQuery(PRESET_CONFIG.storage.queries.cacheHitRate.sql([])) + } + + if (topCacheMisses.changeQuery) { + topCacheMisses.changeQuery(PRESET_CONFIG.storage.queries.topCacheMisses.sql([])) + } + + }, []) + const isLoading = activeHooks.some((hook) => hook.isLoading) + return { + data: { + cacheHitRate: cacheHitRate.logData, + topCacheMisses: topCacheMisses.logData, + }, + params: { + cacheHitRate: cacheHitRate.params, + topCacheMisses: topCacheMisses.params, + }, + mergeParams: handleSetParams, + isLoading, + refresh: handleRefresh, + } +} + +StorageReport.getLayout = (page) => {page} + +export default observer(StorageReport) diff --git a/studio/tests/pages/projects/reports/storage-report.test.js b/studio/tests/pages/projects/reports/storage-report.test.js new file mode 100644 index 00000000000..6d24e13ca85 --- /dev/null +++ b/studio/tests/pages/projects/reports/storage-report.test.js @@ -0,0 +1,42 @@ +import { get } from 'lib/common/fetch' +import { render } from '../../../helpers' +import { waitFor, screen } from '@testing-library/react' +import { StorageReport } from 'pages/project/[ref]/reports/storage' +import userEvent from '@testing-library/user-event' + +beforeEach(() => { + // reset mocks between tests + get.mockReset() + get.mockImplementation(async (_url) => [{ result: [] }]) +}) + +test(`static elements`, async () => { + render() + await screen.findByText('Request Caching') + await screen.findByText(/Last 24 hours/) + await screen.findByText(/Custom/) +}) + +test('refresh button', async () => { + render() + await waitFor(() => expect(get).toBeCalled()) + get.mockReset() + userEvent.click(await screen.findByText(/Refresh/)) + await waitFor(() => expect(get).toBeCalled()) +}) + +test('append - top cache misses', async () => { + get.mockImplementation(async (url) => { + if (decodeURIComponent(url).includes('misses')) { + return { + result: [{ path: 'mypath', search: 'some-query', count: 22 }], + } + } + return { result: [{ timestamp: new Date().toISOString(), miss_count: 123, hit_count: 123 }] } + }) + render() + await waitFor(() => expect(get).toBeCalled()) + await screen.findAllByText(/mypath/) + await screen.findAllByText(/some\-query/) + await screen.findAllByText(/22/) +})