diff --git a/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx b/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx index c048cb59d2c..9a4e472c322 100644 --- a/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx +++ b/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx @@ -13,9 +13,10 @@ interface Props { const DatePickers: React.FC = ({ to, from, onChange, helpers }) => { const defaultHelper = getDefaultHelper(helpers) const [helperValue, setHelperValue] = useState(to || from ? '' : defaultHelper.text) + const handleHelperChange = (newValue: string) => { - setHelperValue(newValue) const selectedHelper = helpers.find((h) => h.text === newValue) + if (onChange && selectedHelper) { onChange({ to: selectedHelper.calcTo(), from: selectedHelper.calcFrom() }) } @@ -28,6 +29,7 @@ const DatePickers: React.FC = ({ to, from, onChange, helpers }) => { return false } }) + useEffect(() => { if (selectedHelper && helperValue !== selectedHelper.text) { setHelperValue(selectedHelper.text) diff --git a/studio/components/interfaces/Settings/Logs/Logs.constants.ts b/studio/components/interfaces/Settings/Logs/Logs.constants.ts index b54c691a328..d3a6d2de484 100644 --- a/studio/components/interfaces/Settings/Logs/Logs.constants.ts +++ b/studio/components/interfaces/Settings/Logs/Logs.constants.ts @@ -21,7 +21,7 @@ export const TEMPLATES: LogTemplate[] = [ description: 'Count of commits made by users on the database', mode: 'custom', searchString: `select - p.user_name, + p.user_name, count(*) as count from postgres_logs left join unnest(metadata) as m on true @@ -38,7 +38,7 @@ group by description: 'List all IP addresses that used the Supabase API', mode: 'custom', searchString: `select - cast(timestamp as datetime) as timestamp, + cast(timestamp as datetime) as timestamp, h.x_real_ip from edge_logs left join unnest(metadata) as m on true @@ -52,8 +52,8 @@ where h.x_real_ip is not null label: 'Requests by Country', description: 'List all ISO 3166-1 alpha-2 country codes that used the Supabase API', mode: 'custom', - searchString: `select - cf.country, + searchString: `select + cf.country, count(*) as count from edge_logs left join unnest(metadata) as m on true @@ -71,11 +71,11 @@ order by mode: 'custom', description: 'List all Supabase API requests that are slow', searchString: `select - cast(timestamp as datetime) as timestamp, + cast(timestamp as datetime) as timestamp, event_message, r.origin_time from edge_logs - cross join unnest(metadata) as m + cross join unnest(metadata) as m cross join unnest(m.response) as r where r.origin_time > 1000 @@ -90,11 +90,11 @@ limit 100 description: 'List all Supabase API requests that responded witha 5XX status code', mode: 'custom', searchString: `select - cast(timestamp as datetime) as timestamp, + cast(timestamp as datetime) as timestamp, event_message, r.status_code from edge_logs - cross join unnest(metadata) as m + cross join unnest(metadata) as m cross join unnest(m.response) as r where r.status_code >= 500 @@ -113,11 +113,11 @@ limit 100 r.search as params, count(timestamp) as c from edge_logs - cross join unnest(metadata) as m + cross join unnest(metadata) as m cross join unnest(m.request) as r -group by +group by path, - params + params order by c desc limit 100 @@ -132,7 +132,7 @@ limit 100 cast(timestamp as datetime) as timestamp, event_message from edge_logs - cross join unnest(metadata) as m + cross join unnest(metadata) as m cross join unnest(m.request) as r where path like '%rest/v1%' @@ -453,14 +453,14 @@ export const PREVIEWER_DATEPICKER_HELPERS: DatetimeHelper[] = [ calcTo: () => '', }, { - text: 'Last day', + text: 'Last 24 hours', calcFrom: () => dayjs().subtract(1, 'day').startOf('day').toISOString(), calcTo: () => '', }, ] export const EXPLORER_DATEPICKER_HELPERS: DatetimeHelper[] = [ { - text: 'Last day', + text: 'Last 24 hours', calcFrom: () => dayjs().subtract(1, 'day').startOf('day').toISOString(), calcTo: () => '', default: true, diff --git a/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/studio/components/interfaces/Settings/Logs/Logs.utils.ts index eb910277bc9..22c495dd261 100644 --- a/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -1,6 +1,7 @@ import { Filters, LogData, LogsEndpointParams, LogsTableName, SQL_FILTER_TEMPLATES } from '.' import dayjs, { Dayjs } from 'dayjs' import { get } from 'lodash' +import { StripeSubscription } from 'components/interfaces/Billing' /** * Convert a micro timestamp from number/string to iso timestamp @@ -130,10 +131,10 @@ export const genDefaultQuery = (table: LogsTableName, filters: Filters) => { ` case 'postgres_logs': - return `select postgres_logs.timestamp, id, event_message, parsed.error_severity from ${table} - cross join unnest(metadata) as m - cross join unnest(m.parsed) as parsed - ${where} + return `select postgres_logs.timestamp, id, event_message, parsed.error_severity from ${table} + cross join unnest(metadata) as m + cross join unnest(m.parsed) as parsed + ${where} limit 100 ` @@ -145,7 +146,7 @@ export const genDefaultQuery = (table: LogsTableName, filters: Filters) => { ` case 'function_edge_logs': - return `select id, ${table}.timestamp, event_message, response.status_code, request.method, m.function_id, m.execution_time_ms, m.deployment_id, m.version from ${table} + return `select id, ${table}.timestamp, event_message, response.status_code, request.method, m.function_id, m.execution_time_ms, m.deployment_id, m.version from ${table} cross join unnest(metadata) as m cross join unnest(m.response) as response cross join unnest(m.request) as request @@ -156,7 +157,7 @@ export const genDefaultQuery = (table: LogsTableName, filters: Filters) => { default: return `select id, ${table}.timestamp, event_message from ${table} ${where} - limit 100 + limit 100 ` } } @@ -167,6 +168,18 @@ export const genDefaultQuery = (table: LogsTableName, filters: Filters) => { export const genSingleLogQuery = (table: LogsTableName, id: string) => `select id, timestamp, event_message, metadata from ${table} where id = '${id}' limit 1` +/** + * Determine if we should show the user an upgrade prompt while browsing logs + */ +export const maybeShowUpgradePrompt = ( + from: string | null | undefined, + tierKey?: StripeSubscription['tier']["key"] +) => { + const day = Math.abs(dayjs().diff(dayjs(from), 'day')) + + return (day > 1 && tierKey === 'FREE') || (day > 7 && tierKey === 'PRO') || day > 90 && tierKey === 'ENTERPRISE' +} + export const genCountQuery = (table: string): string => `SELECT count(*) as count FROM ${table}` /** calculates how much the chart start datetime should be offset given the current datetime filter params */ @@ -209,11 +222,10 @@ SELECT FROM ${table} t cross join unnest(t.metadata) as metadata - ${ - where + ${where ? where + ` and t.timestamp > '${startOffset.toISOString()}'` : `where t.timestamp > '${startOffset.toISOString()}'` - } + } GROUP BY timestamp ORDER BY diff --git a/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx b/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx index 47616568488..9b12ccac9e7 100644 --- a/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx +++ b/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react' import { useRouter } from 'next/router' -import { IconAlertCircle, IconRewind, Button, Card, Input } from 'ui' +import { IconRewind, Button } from 'ui' import { LogTable, @@ -12,6 +12,7 @@ import { LogEventChart, Filters, ensureNoTimestampConflict, + maybeShowUpgradePrompt, } from 'components/interfaces/Settings/Logs' import useLogsPreview from 'hooks/analytics/useLogsPreview' import PreviewFilterPanel from 'components/interfaces/Settings/Logs/PreviewFilterPanel' @@ -19,6 +20,9 @@ import PreviewFilterPanel from 'components/interfaces/Settings/Logs/PreviewFilte import { LOGS_TABLES } from './Logs.constants' import ShimmerLine from 'components/ui/ShimmerLine' import LoadingOpacity from 'components/ui/LoadingOpacity' +import { useProjectSubscription } from 'hooks' +import { useUpgradePrompt } from 'hooks/misc/useUpgradePrompt' +import { StripeProduct } from 'components/interfaces/Billing' import UpgradePrompt from './UpgradePrompt' /** @@ -46,8 +50,10 @@ export const LogsPreviewer: React.FC = ({ tableName, }) => { const router = useRouter() - const { s, ite, its } = router.query + const { s, ite, its, ref } = router.query const [showChart, setShowChart] = useState(true) + const { subscription } = useProjectSubscription(ref as string) + const tier = subscription?.tier const table = !tableName ? LOGS_TABLES[queryType] : tableName @@ -56,6 +62,10 @@ export const LogsPreviewer: React.FC = ({ { loadOlder, setFilters, refresh, setParams }, ] = useLogsPreview(projectRef as string, table, filterOverride) + const { showUpgradePrompt, setShowUpgradePrompt } = useUpgradePrompt( + params.iso_timestamp_start as string + ) + useEffect(() => { setFilters((prev) => ({ ...prev, search_query: s as string })) if (ite || its) { @@ -67,6 +77,16 @@ export const LogsPreviewer: React.FC = ({ } }, [s, ite, its]) + // Show the prompt on page load based on query params + useEffect(() => { + if (its) { + const shouldShowUpgradePrompt = maybeShowUpgradePrompt(its as string, tier?.key) + if (shouldShowUpgradePrompt) { + setShowUpgradePrompt(!showUpgradePrompt) + } + } + }, [its, tier]) + const onSelectTemplate = (template: LogTemplate) => { setFilters((prev: any) => ({ ...prev, search_query: template.searchString })) } @@ -109,19 +129,25 @@ export const LogsPreviewer: React.FC = ({ }, }) } else if (event === 'datepicker-change') { - setParams((prev) => ({ - ...prev, - iso_timestamp_start: from || '', - iso_timestamp_end: to || '', - })) - router.push({ - pathname: router.pathname, - query: { - ...router.query, - its: from || '', - ite: to || '', - }, - }) + const shouldShowUpgradePrompt = maybeShowUpgradePrompt(from, tier?.key) + + if (shouldShowUpgradePrompt) { + setShowUpgradePrompt(!showUpgradePrompt) + } else { + setParams((prev) => ({ + ...prev, + iso_timestamp_start: from || '', + iso_timestamp_end: to || '', + })) + router.push({ + pathname: router.pathname, + query: { + ...router.query, + its: from || '', + ite: to || '', + }, + }) + } } } @@ -198,7 +224,9 @@ export const LogsPreviewer: React.FC = ({ - +
+ +
)} diff --git a/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx b/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx index 6f4b738c665..d29400e1a1b 100644 --- a/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx +++ b/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx @@ -51,11 +51,7 @@ const LogsQueryPanel: React.FC = ({ return (
diff --git a/studio/components/interfaces/Settings/Logs/UpgradePrompt.tsx b/studio/components/interfaces/Settings/Logs/UpgradePrompt.tsx index c0a3c28abb9..0d3ca8a64ca 100644 --- a/studio/components/interfaces/Settings/Logs/UpgradePrompt.tsx +++ b/studio/components/interfaces/Settings/Logs/UpgradePrompt.tsx @@ -1,101 +1,70 @@ -import Link from 'next/link' -import dayjs from 'dayjs' -import { useState } from 'react' -import { Button, IconHelpCircle, IconLoader, Modal } from 'ui' +import { Button, Modal } from 'ui' import { TIER_QUERY_LIMITS } from '.' -import { useProjectSubscription } from 'hooks' +import Link from 'next/link' +import { useRouter } from 'next/router' interface Props { - projectRef: string - from: string + show: boolean + setShowUpgradePrompt: (value: boolean) => void } -const UpgradePrompt: React.FC = ({ projectRef, from }) => { - const [showHelperModal, setShowHelperModal] = useState(false) - const { subscription, isLoading, isError } = useProjectSubscription(projectRef) - - if (isLoading) return - if (isError) console.error('Error fetching project subscription') - - const tier = subscription?.tier - const queryLimit = TIER_QUERY_LIMITS[(tier?.key || 'FREE') as keyof typeof TIER_QUERY_LIMITS] - - const fromValue = from ? dayjs(from) : dayjs() - const fromMax = dayjs().startOf('day').subtract(queryLimit.value, queryLimit.unit) - const isExceedingLimit = fromValue.isBefore(fromMax) +const UpgradePrompt: React.FC = ({ show, setShowUpgradePrompt }) => { + const router = useRouter() + const { ref } = router.query return ( - <> -
- {`${queryLimit.text} retention`} - setShowHelperModal(true)} - /> - {queryLimit.promptUpgrade && ( - - - - )} -
- - setShowHelperModal(false)} - > -
- -
-

- Logs can be retained up to a duration of 3 months depending on the plan that your - project is on. The table below shows an overview of the duration for which your logs - will be retained for based on each plan. -

-
-
-

Plan

-

Retention duration

+ setShowUpgradePrompt(false)} + > +
+ +
+

+ Logs can be retained up to a duration of 3 months depending on the plan that your + project is on. +

+
+
+

Plan

+

Retention duration

+
+
+
+

Free

+

{TIER_QUERY_LIMITS.FREE.text}

-
-
-

Free

-

{TIER_QUERY_LIMITS.FREE.text}

-
-
-

Pro

-

{TIER_QUERY_LIMITS.PRO.text}

-
-
-

Enterprise

-

{TIER_QUERY_LIMITS.ENTERPRISE.text}

-
+
+

Pro

+

{TIER_QUERY_LIMITS.PRO.text}

+
+
+

Enterprise

+

{TIER_QUERY_LIMITS.ENTERPRISE.text}

- - - -
- -
-
-
- - +
+
+ + +
+ + + + + +
+
+
+
) } diff --git a/studio/hooks/misc/useUpgradePrompt.tsx b/studio/hooks/misc/useUpgradePrompt.tsx new file mode 100644 index 00000000000..71aa956ca23 --- /dev/null +++ b/studio/hooks/misc/useUpgradePrompt.tsx @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react' +import { useProjectSubscription } from 'hooks' +import { useRouter } from 'next/router' +import { maybeShowUpgradePrompt } from 'components/interfaces/Settings/Logs' +import { StripeProduct } from 'components/interfaces/Billing' + +export const useUpgradePrompt = (from: string) => { + const router = useRouter() + const { ref } = router.query + const { subscription } = useProjectSubscription(ref as string) + const tier = subscription?.tier + const [showUpgradePrompt, setShowUpgradePrompt] = useState(false) + + const shouldShowUpgradePrompt = maybeShowUpgradePrompt(from, tier?.key) + + useEffect(() => { + if (shouldShowUpgradePrompt) { + setShowUpgradePrompt(true) + } + }, [from]) + + return { showUpgradePrompt, setShowUpgradePrompt, shouldShowUpgradePrompt } +} diff --git a/studio/pages/project/[ref]/logs-explorer/index.tsx b/studio/pages/project/[ref]/logs-explorer/index.tsx index e4db358c99a..c4bd0350289 100644 --- a/studio/pages/project/[ref]/logs-explorer/index.tsx +++ b/studio/pages/project/[ref]/logs-explorer/index.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/router' import { observer } from 'mobx-react-lite' import { Input, Modal, Form, Button } from 'ui' -import { useStore } from 'hooks' +import { useProjectSubscription, useStore } from 'hooks' import useLogsQuery from 'hooks/analytics/useLogsQuery' import { NextPageWithLayout, UserContent } from 'types' import { uuidv4 } from 'lib/helpers' @@ -21,8 +21,11 @@ import { LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD, LogTable, LogTemplate, + maybeShowUpgradePrompt, TEMPLATES, } from 'components/interfaces/Settings/Logs' +import { useUpgradePrompt } from 'hooks/misc/useUpgradePrompt' +import { StripeProduct } from 'components/interfaces/Billing' import UpgradePrompt from 'components/interfaces/Settings/Logs/UpgradePrompt' export const LogsExplorerPage: NextPageWithLayout = () => { @@ -33,6 +36,8 @@ export const LogsExplorerPage: NextPageWithLayout = () => { const [saveModalOpen, setSaveModalOpen] = useState(false) const [warnings, setWarnings] = useState([]) const { content } = useStore() + const { subscription } = useProjectSubscription(ref as string) + const tier = subscription?.tier const [{ params, logData, error, isLoading }, { changeQuery, runQuery, setParams }] = useLogsQuery(ref as string, { @@ -40,6 +45,10 @@ export const LogsExplorerPage: NextPageWithLayout = () => { iso_timestamp_end: ite ? (ite as string) : undefined, }) + const { showUpgradePrompt, setShowUpgradePrompt } = useUpgradePrompt( + params.iso_timestamp_start as string + ) + useEffect(() => { // on mount, set initial values if (q) { @@ -65,6 +74,16 @@ export const LogsExplorerPage: NextPageWithLayout = () => { setWarnings(newWarnings) }, [editorValue, params.iso_timestamp_start, params.iso_timestamp_end]) + // Show the prompt on page load based on query params + useEffect(() => { + if (its) { + const shouldShowUpgradePrompt = maybeShowUpgradePrompt(its as string, tier?.key) + if (shouldShowUpgradePrompt) { + setShowUpgradePrompt(!showUpgradePrompt) + } + } + }, [its, tier]) + const onSelectTemplate = (template: LogTemplate) => { setEditorValue(template.searchString) changeQuery(template.searchString) @@ -110,15 +129,21 @@ export const LogsExplorerPage: NextPageWithLayout = () => { } const handleDateChange = ({ to, from }: DatePickerToFrom) => { - setParams((prev) => ({ - ...prev, - iso_timestamp_start: from || '', - iso_timestamp_end: to || '', - })) - router.push({ - pathname: router.pathname, - query: { ...router.query, its: from || '', ite: to || '' }, - }) + const shouldShowUpgradePrompt = maybeShowUpgradePrompt(from, tier?.key) + + if (shouldShowUpgradePrompt) { + setShowUpgradePrompt(!showUpgradePrompt) + } else { + setParams((prev) => ({ + ...prev, + iso_timestamp_start: from || '', + iso_timestamp_end: to || '', + })) + router.push({ + pathname: router.pathname, + query: { ...router.query, its: from || '', ite: to || '' }, + }) + } } return ( @@ -139,7 +164,6 @@ export const LogsExplorerPage: NextPageWithLayout = () => { isLoading={isLoading} warnings={warnings} /> -
{
- +
diff --git a/studio/tests/pages/projects/LogsPreviewer.test.js b/studio/tests/pages/projects/LogsPreviewer.test.js index 6e4a6722dc0..86cc684f691 100644 --- a/studio/tests/pages/projects/LogsPreviewer.test.js +++ b/studio/tests/pages/projects/LogsPreviewer.test.js @@ -393,3 +393,28 @@ test('filters accept filterOverride', async () => { expect(get).toHaveBeenCalledWith(expect.stringContaining('myvalue')) }) }) + +describe.each(['FREE', 'PRO', 'ENTERPRISE'])('upgrade modal for %s', (key) => { + beforeEach(() => { + useProjectSubscription.mockReturnValue({ + subscription: { + tier: { + supabase_prod_id: `tier_${key.toLocaleLowerCase()}`, + key, + }, + }, + }) + }) + test('based on query params', async () => { + const router = defaultRouterMock() + router.query = { + ...router.query, + q: 'some_query', + its: dayjs().subtract(4, 'months').toISOString(), + ite: dayjs().toISOString(), + } + useRouter.mockReturnValue(router) + render() + await screen.findByText('Log retention') // assert modal title is present + }) +}) diff --git a/studio/tests/pages/projects/logs-query.test.js b/studio/tests/pages/projects/logs-query.test.js index 84d63e92f85..562616fa426 100644 --- a/studio/tests/pages/projects/logs-query.test.js +++ b/studio/tests/pages/projects/logs-query.test.js @@ -7,6 +7,8 @@ import userEvent from '@testing-library/user-event' import { logDataFixture } from '../../fixtures' import { clickDropdown } from 'tests/helpers' import dayjs from 'dayjs' +import { useProjectSubscription } from 'hooks' +import UpgradePrompt from 'components/interfaces/Settings/Logs/UpgradePrompt' const defaultRouterMock = () => { const router = jest.fn() @@ -137,21 +139,6 @@ test('custom sql querying', async () => { await expect(screen.findByText(/Load older/)).rejects.toThrow() }) -test('datepicker interaction updates query params', async () => { - render() - clickDropdown(await screen.findByText(/Last day/)) - userEvent.click(await screen.findByText(/Last 3 days/)) - - const router = useRouter() - expect(router.push).toBeCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ - its: expect.any(String), - }), - }) - ) -}) - test('query warnings', async () => { const router = defaultRouterMock() router.query = { @@ -164,3 +151,42 @@ test('query warnings', async () => { render() await screen.findByText('1 warning') }) + +describe.each(['FREE', 'PRO', 'ENTERPRISE'])('upgrade modal for %s', (key) => { + beforeEach(() => { + useProjectSubscription.mockReturnValue({ + subscription: { + tier: { + supabase_prod_id: `tier_${key.toLocaleLowerCase()}`, + key, + }, + }, + }) + }) + test('based on query params', async () => { + const router = defaultRouterMock() + router.query = { + ...router.query, + q: 'some_query', + its: dayjs().subtract(5, 'month').toISOString(), + ite: dayjs().toISOString(), + } + useRouter.mockReturnValue(router) + render() + await screen.findByText(/Log retention/) // assert modal title is present + }) + + test('based on datepicker helpers', async () => { + render() + // click on the dropdown + clickDropdown(await screen.findByText('Last 24 hours')) + userEvent.click(await screen.findByText('Last 3 days')) + + // only free tier will show modal + if (key === 'FREE') { + await screen.findByText('Log retention') // assert modal title is present + } else { + await expect(screen.findByText('Log retention')).rejects.toThrow() + } + }) +})