Merge pull request #8112 from supabase/chore/show-upgrade-prompt-in-logs-explorer

Chore/show upgrade prompt in logs explorer
This commit is contained in:
Terry Sutton authored and GitHub committed 2022-10-12 10:37:35 -02:30
commit 3e04442c8d
10 files changed
+263 -158

No files matched your search

@@ -13,9 +13,10 @@ interface Props {
const DatePickers: React.FC<Props> = ({ to, from, onChange, helpers }) => {
const defaultHelper = getDefaultHelper(helpers)
const [helperValue, setHelperValue] = useState<string>(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<Props> = ({ to, from, onChange, helpers }) => {
return false
}
})
useEffect(() => {
if (selectedHelper && helperValue !== selectedHelper.text) {
setHelperValue(selectedHelper.text)
@@ -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,
@@ -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
@@ -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<Props> = ({
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<Props> = ({
{ 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<Props> = ({
}
}, [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<Props> = ({
},
})
} 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<Props> = ({
<Button onClick={loadOlder} icon={<IconRewind />} type="default">
Load older
</Button>
<UpgradePrompt projectRef={projectRef} from={params.iso_timestamp_start || ''} />
<div className="mt-2 flex flex-row justify-end">
<UpgradePrompt show={showUpgradePrompt} setShowUpgradePrompt={setShowUpgradePrompt} />
</div>
</div>
)}
</div>
@@ -51,11 +51,7 @@ const LogsQueryPanel: React.FC<Props> = ({
return (
<div
className="
rounded
rounded-bl-none rounded-br-none border border-panel-border-light bg-panel-header-light
dark:border-panel-border-dark dark:bg-panel-header-dark
className=" rounded rounded-bl-none rounded-br-none border border-panel-border-light bg-panel-header-light dark:border-panel-border-dark dark:bg-panel-header-dark
"
>
<div className="flex w-full items-center justify-between px-5 py-2">
@@ -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<Props> = ({ projectRef, from }) => {
const [showHelperModal, setShowHelperModal] = useState(false)
const { subscription, isLoading, isError } = useProjectSubscription(projectRef)
if (isLoading) return <IconLoader size={16} className="animate-spin" />
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<Props> = ({ show, setShowUpgradePrompt }) => {
const router = useRouter()
const { ref } = router.query
return (
<>
<div
className={`flex flex-row items-center gap-3 px-2 py-1 text-xs transition-all ${
isExceedingLimit
? 'rounded border border-yellow-700 bg-yellow-200 font-semibold text-yellow-1100'
: ''
}`}
>
<span className="text-scale-1100">{`${queryLimit.text} retention`}</span>
<IconHelpCircle
size={16}
strokeWidth={1.5}
className="cursor-pointer text-scale-1100 transition hover:text-scale-1200"
onClick={() => setShowHelperModal(true)}
/>
{queryLimit.promptUpgrade && (
<Link href={`/project/${projectRef}/settings/billing`}>
<Button size="tiny">Upgrade</Button>
</Link>
)}
</div>
<Modal
hideFooter
visible={showHelperModal}
size="medium"
header="Log retention"
onCancel={() => setShowHelperModal(false)}
>
<div className="space-y-4 py-4">
<Modal.Content>
<div className="space-y-4">
<p className="text-sm">
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.
</p>
<div className="rounded border border-scale-600 bg-scale-500">
<div className="flex items-center px-4 pt-2 pb-1">
<p className="w-[40%] text-sm text-scale-1100">Plan</p>
<p className="w-[60%] text-sm text-scale-1100">Retention duration</p>
<Modal
hideFooter
visible={show}
closable
size="medium"
header="Log retention"
onCancel={() => setShowUpgradePrompt(false)}
>
<div className="space-y-4 py-4">
<Modal.Content>
<div className="space-y-4">
<p className="text-sm">
Logs can be retained up to a duration of 3 months depending on the plan that your
project is on.
</p>
<div className="border-scale-600 bg-scale-500 rounded border">
<div className="flex items-center px-4 pt-2 pb-1">
<p className="text-scale-1100 w-[40%] text-sm">Plan</p>
<p className="text-scale-1100 w-[60%] text-sm">Retention duration</p>
</div>
<div className="py-1">
<div className="flex items-center px-4 py-1">
<p className="w-[40%] text-sm">Free</p>
<p className="w-[60%] text-sm">{TIER_QUERY_LIMITS.FREE.text}</p>
</div>
<div className="py-1">
<div className="flex items-center px-4 py-1">
<p className="w-[40%] text-sm">Free</p>
<p className="w-[60%] text-sm">{TIER_QUERY_LIMITS.FREE.text}</p>
</div>
<div className="flex items-center px-4 py-1">
<p className="w-[40%] text-sm">Pro</p>
<p className="w-[60%] text-sm">{TIER_QUERY_LIMITS.PRO.text}</p>
</div>
<div className="flex items-center px-4 py-1">
<p className="w-[40%] text-sm">Enterprise</p>
<p className="w-[60%] text-sm">{TIER_QUERY_LIMITS.ENTERPRISE.text}</p>
</div>
<div className="flex items-center px-4 py-1">
<p className="w-[40%] text-sm">Pro</p>
<p className="w-[60%] text-sm">{TIER_QUERY_LIMITS.PRO.text}</p>
</div>
<div className="flex items-center px-4 py-1">
<p className="w-[40%] text-sm">Enterprise</p>
<p className="w-[60%] text-sm">{TIER_QUERY_LIMITS.ENTERPRISE.text}</p>
</div>
</div>
</div>
</Modal.Content>
<Modal.Seperator />
<Modal.Content>
<div className="flex items-center gap-2">
<Button block type="primary" onClick={() => setShowHelperModal(false)}>
Understood
</Button>
</div>
</Modal.Content>
</div>
</Modal>
</>
</div>
</Modal.Content>
<Modal.Seperator />
<Modal.Content>
<div className="flex justify-between">
<Button type="default" onClick={() => setShowUpgradePrompt(false)}>
Close
</Button>
<Link href={`/project/${ref}/settings/billing`}>
<Button size="tiny">Upgrade</Button>
</Link>
</div>
</Modal.Content>
</div>
</Modal>
)
}
+23
View File
@@ -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 }
}
@@ -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<boolean>(false)
const [warnings, setWarnings] = useState<LogsWarning[]>([])
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}
/>
<div className="h-48 min-h-[7rem]">
<ShimmerLine active={isLoading} />
<CodeEditor
@@ -158,7 +182,7 @@ export const LogsExplorerPage: NextPageWithLayout = () => {
</div>
</LoadingOpacity>
<div className="mt-2 flex flex-row justify-end">
<UpgradePrompt projectRef={ref as string} from={params.iso_timestamp_start || ''} />
<UpgradePrompt show={showUpgradePrompt} setShowUpgradePrompt={setShowUpgradePrompt} />
</div>
</div>
</div>
@@ -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(<LogsPreviewer projectRef="123" tableName={LogsTableName.EDGE} />)
await screen.findByText('Log retention') // assert modal title is present
})
})
+41 -15
View File
@@ -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(<LogsExplorerPage />)
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(<LogsExplorerPage />)
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(<LogsExplorerPage />)
await screen.findByText(/Log retention/) // assert modal title is present
})
test('based on datepicker helpers', async () => {
render(<LogsExplorerPage />)
// 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()
}
})
})