mirror of
https://github.com/supabase/supabase.git
synced 2026-10-12 04:45:08 +03:00
Merge branch 'master' into patch-3
This commit is contained in:
commit
f3716668d0
28 files changed
+539
-199
No files matched your search
@@ -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<{}>) => {
|
||||
<title>Supabase Docs</title>
|
||||
</Head>
|
||||
<main>
|
||||
<Announcement>
|
||||
<LW8CountdownBanner />
|
||||
</Announcement>
|
||||
<div className="flex flex-row h-screen">
|
||||
<NavContainer />
|
||||
<Container>
|
||||
|
||||
@@ -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) => (
|
||||
<div className="w-full max-w-xl flex justify-center opacity-0 !animate-[fadeIn_0.5s_cubic-bezier(0.25,0.25,0,1)_0.5s_both]">
|
||||
<Link href="/launch-week">
|
||||
<Link href={url}>
|
||||
<a
|
||||
target={target}
|
||||
className="
|
||||
group
|
||||
relative
|
||||
@@ -26,10 +33,12 @@ const AnnouncementBadge = () => (
|
||||
focus:outline-none focus:ring-brand-600 focus:ring-2 focus:rounded-full
|
||||
"
|
||||
>
|
||||
<Badge color="brand" size="large" className="py-1">
|
||||
Explore
|
||||
</Badge>
|
||||
<span className="text-foreground">Launch Week 8 announcements</span>
|
||||
{badge && (
|
||||
<Badge color="brand" size="large" className="py-1">
|
||||
{badge}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-foreground">{announcement}</span>
|
||||
<ArrowNarrowRightIcon className="h-4 ml-2 -translate-x-1 transition-transform group-hover:translate-x-0" />
|
||||
<div
|
||||
className="absolute inset-0 -z-10 bg-gradient-to-br
|
||||
|
||||
@@ -8,7 +8,7 @@ import SectionContainer from '~/components/Layouts/SectionContainer'
|
||||
import HeroFrameworks from './HeroFrameworks'
|
||||
import styles from './hero.module.css'
|
||||
import Image from 'next/image'
|
||||
import AnnouncementBadge from '../Announcement/Badge'
|
||||
// import AnnouncementBadge from '../Announcement/Badge'
|
||||
|
||||
const Hero = () => {
|
||||
const router = useRouter()
|
||||
@@ -19,7 +19,7 @@ const Hero = () => {
|
||||
|
||||
return (
|
||||
<div className="relative -mt-[65px]">
|
||||
<SectionContainer className="pt-8 md:pt-16 lg:pt-24 overflow-hidden">
|
||||
<SectionContainer className="pt-8 md:pt-16 overflow-hidden">
|
||||
<div className="relative">
|
||||
<div className="mx-auto">
|
||||
<div className="mx-auto max-w-2xl lg:col-span-6 lg:flex lg:items-center justify-center text-center">
|
||||
@@ -30,9 +30,9 @@ const Hero = () => {
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="z-40 w-full flex justify-center mb-8 lg:mb-8">
|
||||
{/* <div className="z-40 w-full flex justify-center mb-8 lg:mb-8">
|
||||
<AnnouncementBadge />
|
||||
</div>
|
||||
</div> */}
|
||||
<h1 className="text-scale-1200 text-4xl sm:text-5xl sm:leading-none lg:text-7xl">
|
||||
<span className="block text-[#F4FFFA00] bg-clip-text bg-gradient-to-b from-scale-1200 to-scale-1200 dark:to-scale-1100">
|
||||
Build in a weekend
|
||||
@@ -80,32 +80,7 @@ const Hero = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -top-2 md:top-0 -left-10 -right-10 md:left-0 md:right-0 h-[500px] lg:h-[550px] z-0 flex items-center justify-center">
|
||||
<span className="absolute inset-0">
|
||||
<Image
|
||||
src="/images/launchweek/8/stars.svg"
|
||||
alt="stars background"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
className="opacity-70"
|
||||
draggable={false}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</SectionContainer>
|
||||
<div className="absolute w-full max-w-[1600px] mx-auto h-[500px] lg:h-[750px] inset-0 z-0 flex items-center justify-center">
|
||||
<Image
|
||||
src="/images/launchweek/8/LW8-gradient.png"
|
||||
alt="Launch Week 8 purple background gradient"
|
||||
className="not-sr-only"
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
objectPosition="top"
|
||||
priority
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute top-0 left-0 right-0 h-screen bg-gradient-to-b from-[#020405] to-transparent -z-10" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ const Developers = () => {
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" strokeLinejoin="round" strokeWidth="1.5" d={icon} />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d={icon} />
|
||||
</svg>
|
||||
)}
|
||||
{Svg && <Svg />}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Announcement link="/launch-week">
|
||||
<LW8CountdownBanner />
|
||||
</Announcement>
|
||||
<div className="sticky top-0 z-40 transform" style={{ transform: 'translate3d(0,0,999px)' }}>
|
||||
<div
|
||||
className={[
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
[
|
||||
{
|
||||
"type": "Announcement",
|
||||
"title": "Supabase Beta August 2023",
|
||||
"description": "Launch Week 8 review and more things we shipped 🚀",
|
||||
"imgUrl": "/images/blog/2023-09-07-beta-update-august-2023/monthly-update-august-2023.jpg",
|
||||
"logoUrl": "/images/blog/2023-09-07-beta-update-august-2023/monthly-update-august-2023.jpg",
|
||||
"organization": "Supabase",
|
||||
"url": "/blog/beta-update-august-2023",
|
||||
"postMeta": {
|
||||
"name": "Ant Wilson",
|
||||
"publishDate": "Sep 08, 2023",
|
||||
"readLength": 5
|
||||
},
|
||||
"ctaText": "Learn more"
|
||||
},
|
||||
{
|
||||
"type": "Announcement",
|
||||
"title": "Launch Week 8 Hackathon Winners",
|
||||
@@ -13,20 +28,5 @@
|
||||
"readLength": 3
|
||||
},
|
||||
"ctaText": "See the winners"
|
||||
},
|
||||
{
|
||||
"type": "Announcement",
|
||||
"title": "Supabase Launch Week 8",
|
||||
"description": "One new feature (or more) every day during a week.",
|
||||
"imgUrl": "/images/launchweek/8/lw8-og.jpg",
|
||||
"logoUrl": "/images/launchweek/8/lw8-og.jpg",
|
||||
"organization": "Supabase",
|
||||
"url": "/launch-week",
|
||||
"postMeta": {
|
||||
"name": "Paul Copplestone",
|
||||
"publishDate": "Aug 07, 2023",
|
||||
"readLength": 5
|
||||
},
|
||||
"ctaText": "Learn more"
|
||||
}
|
||||
]
|
||||
@@ -51,8 +51,8 @@ const Partners = () => {
|
||||
<path
|
||||
d="M91 1C120.005 1 145.841 14.5702 162.505 35.708"
|
||||
stroke="url(#paint0_linear_4766_6117)"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
@@ -63,9 +63,9 @@ const Partners = () => {
|
||||
y2="21.5"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#1CF7C3" stop-opacity="0" />
|
||||
<stop offset="0.510417" stop-color="#1CF7C3" />
|
||||
<stop offset="1" stop-color="#1CF7C3" stop-opacity="0" />
|
||||
<stop stopColor="#1CF7C3" stopOpacity="0" />
|
||||
<stop offset="0.510417" stopColor="#1CF7C3" />
|
||||
<stop offset="1" stopColor="#1CF7C3" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className="relative z-10 py-16 lg:py-28">
|
||||
<div className="relative z-10 py-16 lg:py-20">
|
||||
<div className="mx-auto max-w-7xl px-4 text-center sm:px-6 lg:px-8">
|
||||
<div className="mx-auto max-w-3xl space-y-2 lg:max-w-none">
|
||||
<h1 className="text-brand text-base">Pricing</h1>
|
||||
@@ -159,45 +160,13 @@ export default function IndexPage() {
|
||||
<p className="p text-lg">
|
||||
Start building for free, collaborate with a team, then scale to millions of users.
|
||||
</p>
|
||||
<div className="w-full flex justify-center items-center opacity-0 !animate-[fadeIn_0.5s_cubic-bezier(0.25,0.25,0,1)_0.5s_both]">
|
||||
<Link href="/blog/organization-based-billing" passHref>
|
||||
<a
|
||||
target="_blank"
|
||||
className="
|
||||
group
|
||||
relative
|
||||
flex flex-row
|
||||
items-center
|
||||
pr-3 p-1
|
||||
text-sm
|
||||
w-auto
|
||||
gap-2
|
||||
text-left
|
||||
rounded-full
|
||||
bg-opacity-20
|
||||
border
|
||||
border-background-surface-100
|
||||
hover:border-background-surface-300
|
||||
overflow-hidden
|
||||
focus:outline-none focus:ring-brand-600 focus:ring-2 focus:rounded-full
|
||||
"
|
||||
>
|
||||
<Badge color="brand" size="large" className="py-1">
|
||||
Update
|
||||
</Badge>
|
||||
<span className="text-foreground">Changes to how we bill</span>
|
||||
<ArrowNarrowRightIcon className="h-4 ml-2 -translate-x-1 transition-transform group-hover:translate-x-0" />
|
||||
<div
|
||||
className="absolute inset-0 -z-10 bg-gradient-to-br
|
||||
opacity-70
|
||||
overflow-hidden rounded-full
|
||||
from-background-surface-100
|
||||
to-background-surface-300
|
||||
backdrop-blur-md
|
||||
"
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
<div className="w-full inline-flex justify-center items-center pt-3 pb-6">
|
||||
<AnnouncementBadge
|
||||
url="/blog/organization-based-billing"
|
||||
badge="Update"
|
||||
announcement="Changes to how we bill"
|
||||
target="_blank"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -228,6 +228,52 @@ export const PRESET_CONFIG: Record<Presets, PresetConfig> = {
|
||||
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: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DEFAULT_QUERY_PARAMS } from './Reports.constants'
|
||||
|
||||
export enum Presets {
|
||||
API = 'api',
|
||||
STORAGE = 'storage',
|
||||
AUTH = 'auth',
|
||||
QUERY_PERFORMANCE = 'query_performance',
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<StackedBarChart
|
||||
hideHeader
|
||||
variant="percentages"
|
||||
data={stackedData}
|
||||
xAxisKey="timestamp"
|
||||
yAxisKey="count"
|
||||
stackKey="type"
|
||||
stackColors={['brand', 'amber']}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<h4>Top Cache Misses</h4>
|
||||
<Table
|
||||
head={
|
||||
<>
|
||||
<Table.th className={headerClasses}>Request</Table.th>
|
||||
<Table.th className={headerClasses + ' text-right'}>Count</Table.th>
|
||||
</>
|
||||
}
|
||||
body={
|
||||
<>
|
||||
{props.data.map((datum) => (
|
||||
<Fragment key={datum.path + (datum.search || '')}>
|
||||
<Table.tr className="p-0">
|
||||
<Table.td className={[cellClasses].join(' ')}>
|
||||
<div className=" truncate max-w-sm lg:max-w-lg">
|
||||
<TextFormatter className="text-scale-1100" value={datum.path} />
|
||||
<TextFormatter
|
||||
className="max-w-sm text-scale-900 truncate "
|
||||
value={decodeURIComponent(datum.search || '')}
|
||||
/>
|
||||
</div>
|
||||
</Table.td>
|
||||
<Table.td className={[cellClasses, 'text-right align-top'].join(' ')}>
|
||||
{datum.count}
|
||||
</Table.td>
|
||||
</Table.tr>
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+40
-1
@@ -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 (
|
||||
<SidePanel
|
||||
key="ForeignKeySelector"
|
||||
@@ -173,6 +185,8 @@ const ForeignKeySelector = ({
|
||||
customFooter={
|
||||
<ActionBar
|
||||
backButtonLabel="Cancel"
|
||||
// if the type of the two columns don't match, disable the save button
|
||||
disableApply={!matchingColumnTypes}
|
||||
applyButtonLabel="Save"
|
||||
closePanel={closePanel}
|
||||
applyFunction={onSaveChanges}
|
||||
@@ -285,6 +299,31 @@ const ForeignKeySelector = ({
|
||||
))}
|
||||
</Listbox>
|
||||
)}
|
||||
{!matchingColumnTypes && (
|
||||
<Alert_Shadcn_ variant="warning">
|
||||
<IconAlertTriangle strokeWidth={2} />
|
||||
<AlertTitle_Shadcn_>The column types don't match</AlertTitle_Shadcn_>
|
||||
<AlertDescription_Shadcn_ className="leading-6">
|
||||
<span>The referenced column</span>
|
||||
{column?.name && <span className="text-code">{column.name}</span>}
|
||||
{column?.format ? (
|
||||
<>
|
||||
<span> is of type </span>
|
||||
<span className="text-code">{column.format}</span>
|
||||
</>
|
||||
) : (
|
||||
<span> has no type</span>
|
||||
)}
|
||||
<span> while the selected foreign column </span>
|
||||
<span className="text-code">
|
||||
{selectedTable?.name}.{selectedColumn?.name}
|
||||
</span>
|
||||
<span> has </span>
|
||||
<span className="text-code">{selectedColumn?.data_type}</span>type. These two
|
||||
columns can't be referenced until they are of the same type.
|
||||
</AlertDescription_Shadcn_>
|
||||
</Alert_Shadcn_>
|
||||
)}
|
||||
<SidePanel.Separator />
|
||||
<InformationBox
|
||||
icon={<IconHelpCircle size="large" strokeWidth={1.5} />}
|
||||
|
||||
@@ -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<ReportsLayoutProps
|
||||
|
||||
const project = useSelectedProject()
|
||||
const page = router.pathname.split('/')[4] || ''
|
||||
const ref = project?.ref ?? 'default'
|
||||
|
||||
const storageReportFlag = useFlag('storageReport')
|
||||
const menuItems = [
|
||||
{
|
||||
title: '',
|
||||
items: [
|
||||
{
|
||||
name: 'Custom reports',
|
||||
key: '',
|
||||
url: `/project/${ref}/reports`,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{
|
||||
name: 'API',
|
||||
key: 'api-overview',
|
||||
url: `/project/${ref}/reports/api-overview`,
|
||||
items: [],
|
||||
},
|
||||
...(storageReportFlag
|
||||
? [
|
||||
{
|
||||
name: 'Storage',
|
||||
key: 'storage',
|
||||
url: `/project/${ref}/reports/storage`,
|
||||
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: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<ProjectLayout
|
||||
title={title}
|
||||
product="Reports"
|
||||
productMenu={<ProductMenu page={page} menu={generateReportsMenu(project)} />}
|
||||
productMenu={<ProductMenu page={page} menu={menuItems} />}
|
||||
>
|
||||
<main style={{ maxHeight: '100vh' }} className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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) => {
|
||||
>
|
||||
<Form
|
||||
validateOnBlur
|
||||
initialValues={{ expiresIn: '' }}
|
||||
initialValues={{ expiresIn: '', units: 'days' }}
|
||||
onSubmit={async (values: any, { setSubmitting }: any) => {
|
||||
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 }) => (
|
||||
<>
|
||||
<div className="py-6">
|
||||
<div className="pt-4 pb-2">
|
||||
<Modal.Content>
|
||||
<Input
|
||||
disabled={isSubmitting}
|
||||
type="number"
|
||||
id="expiresIn"
|
||||
label="Enter the duration for which the URL will be valid:"
|
||||
actions={<p className="text-sm text-scale-1000 mr-2">seconds</p>}
|
||||
/>
|
||||
<p className="text-sm text-light mb-2">
|
||||
Enter the duration for which the URL will be valid for:
|
||||
</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input disabled={isSubmitting} type="number" id="expiresIn" className="w-full" />
|
||||
<Listbox id="units" className="w-[150px]">
|
||||
<Listbox.Option id="days" label="days" value="days">
|
||||
days
|
||||
</Listbox.Option>
|
||||
<Listbox.Option id="weeks" label="weeks" value="weeks">
|
||||
weeks
|
||||
</Listbox.Option>
|
||||
<Listbox.Option id="months" label="months" value="months">
|
||||
months
|
||||
</Listbox.Option>
|
||||
<Listbox.Option id="years" label="years" value="years">
|
||||
years
|
||||
</Listbox.Option>
|
||||
</Listbox>
|
||||
</div>
|
||||
{values.expiresIn !== '' && (
|
||||
<p className="text-sm text-light mt-2">
|
||||
URL will expire on{' '}
|
||||
{dayjs().add(values.expiresIn, values.units).format(DATETIME_FORMAT)}
|
||||
</p>
|
||||
)}
|
||||
</Modal.Content>
|
||||
</div>
|
||||
<Modal.Separator />
|
||||
|
||||
@@ -409,6 +409,7 @@ const FileExplorerRow = ({
|
||||
/>
|
||||
) : (
|
||||
<Dropdown
|
||||
modal={false}
|
||||
side="bottom"
|
||||
align="end"
|
||||
overlay={[
|
||||
|
||||
@@ -10,28 +10,35 @@ export const CHART_COLORS = {
|
||||
}
|
||||
|
||||
// refer to packages/ui/radix-colors.js for full list of colors
|
||||
export const STACK_COLORS = [
|
||||
['brand', 9],
|
||||
['mint', 9],
|
||||
['blue', 9],
|
||||
['sky', 9],
|
||||
['lime', 9],
|
||||
['yellow', 9],
|
||||
['orange', 9],
|
||||
].map(([color, n]) => ({
|
||||
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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = () => (
|
||||
<div className="flex flex-col gap-4 ">
|
||||
<ExampleName>Default Stacked Chart Colors</ExampleName>
|
||||
<div className="flex flex-row ">
|
||||
{STACK_COLORS.map((c) => (
|
||||
{genStackColorScales(DEFAULT_STACK_COLORS).map((c) => (
|
||||
<div className="w-10 h-10" key={c.base} style={{ background: c.base }}></div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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<any> {
|
||||
displayDateInUtc?: boolean
|
||||
hideLegend?: boolean
|
||||
hideHeader?: boolean
|
||||
stackColors?: ValidStackColor[]
|
||||
}
|
||||
const StackedBarChart: React.FC<Props> = ({
|
||||
size,
|
||||
@@ -33,6 +34,7 @@ const StackedBarChart: React.FC<Props> = ({
|
||||
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<Props> = ({
|
||||
})
|
||||
const [focusDataIndex, setFocusDataIndex] = useState<number | null>(null)
|
||||
if (!data || data.length === 0) return <NoDataPlaceholder size={size} />
|
||||
const stackColorScales = genStackColorScales(stackColors)
|
||||
return (
|
||||
<div className="w-full">
|
||||
{!hideHeader && (
|
||||
@@ -94,7 +97,7 @@ const StackedBarChart: React.FC<Props> = ({
|
||||
dataKey={datum}
|
||||
type="monotone"
|
||||
legendType="circle"
|
||||
fill={STACK_COLORS[stackIndex].base}
|
||||
fill={stackColorScales[stackIndex].base}
|
||||
stackId={1}
|
||||
animationDuration={300}
|
||||
maxBarSize={48}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 (
|
||||
<ReportPadding>
|
||||
<ReportHeader title="Storage" isLoading={report.isLoading} onRefresh={report.refresh} />
|
||||
<div className="w-full flex flex-col gap-1">
|
||||
<div>
|
||||
<DatePickers
|
||||
onChange={handleDatepickerChange}
|
||||
to={report.params.cacheHitRate.iso_timestamp_end || ''}
|
||||
from={report.params.cacheHitRate.iso_timestamp_start || ''}
|
||||
helpers={datepickerHelpers}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-2 w-full">
|
||||
<ShimmerLine active={report.isLoading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReportWidget
|
||||
isLoading={report.isLoading}
|
||||
params={report.params.cacheHitRate}
|
||||
title="Request Caching"
|
||||
tooltip="The number of storage requests that are cached at the edge level. A higher number of hits is better."
|
||||
data={report.data.cacheHitRate || []}
|
||||
renderer={CacheHitRateChartRenderer}
|
||||
append={TopCacheMissesRenderer}
|
||||
appendProps={{ data: report.data.topCacheMisses || [] }}
|
||||
/>
|
||||
</ReportPadding>
|
||||
)
|
||||
}
|
||||
|
||||
// hook to fetch data
|
||||
const useStorageReport = () => {
|
||||
const { ref: projectRef } = useParams()
|
||||
|
||||
const queryHooks = queriesFactory<keyof typeof PRESET_CONFIG.storage.queries>(
|
||||
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<LogsEndpointParams>) => {
|
||||
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) => <ReportsLayout>{page}</ReportsLayout>
|
||||
|
||||
export default observer(StorageReport)
|
||||
@@ -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(<StorageReport />)
|
||||
await screen.findByText('Request Caching')
|
||||
await screen.findByText(/Last 24 hours/)
|
||||
await screen.findByText(/Custom/)
|
||||
})
|
||||
|
||||
test('refresh button', async () => {
|
||||
render(<StorageReport />)
|
||||
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(<StorageReport />)
|
||||
await waitFor(() => expect(get).toBeCalled())
|
||||
await screen.findAllByText(/mypath/)
|
||||
await screen.findAllByText(/some\-query/)
|
||||
await screen.findAllByText(/22/)
|
||||
})
|
||||
Reference in new issue
Block a user