Merge branch 'master' into docs/updates_on_a_plane

This commit is contained in:
Copple authored and GitHub committed 2023-09-13 18:41:02 +02:00
commit 0dea61a356
55 files changed
+1613 -648

No files matched your search

+3
View File
@@ -1,3 +1,6 @@
/packages/shared-data/pricing.ts @roryw10 @kevcodez
/packages/shared-data/plans.ts @roryw10 @kevcodez
/studio/ @supabase/Dashboard
/apps/www/ @supabase/Website
/apps/docs/ @supabase/docs
-4
View File
@@ -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>
+15 -6
View File
@@ -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
+4 -29
View File
@@ -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>
)
}
+1 -1
View File
@@ -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 />}
+1 -5
View File
@@ -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={[
+15 -15
View File
@@ -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"
}
]
+5 -5
View File
@@ -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>
+9 -40
View File
@@ -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>
+8 -8
View File
@@ -14121,17 +14121,17 @@
}
},
"node_modules/@supabase/gotrue-js": {
"version": "2.48.0",
"resolved": "https://registry.npmjs.org/@supabase/gotrue-js/-/gotrue-js-2.48.0.tgz",
"integrity": "sha512-+6g5aFL6+iaZC6mFVpRLauSkU5MrQ52QZA1TlY0/evvz9Rmhqc315tIeUfUK87ZLeX7sOL6XYVGkI6ZxgeyAUA==",
"version": "2.52.0",
"resolved": "https://registry.npmjs.org/@supabase/gotrue-js/-/gotrue-js-2.52.0.tgz",
"integrity": "sha512-UFCbydMYFn/LhPW08aeZ9sgFDA3kOQCjA2ieFj/sccye9m8Tv0pimMfnXh3A9TqBJ0/0utpkaGSad3XdpJ+Mbw==",
"dependencies": {
"@supabase/node-fetch": "^2.6.13"
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/node-fetch": {
"version": "2.6.13",
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.13.tgz",
"integrity": "sha512-rEHQaDVzxLZMCK3p+JW2nzEsK4AJpOQhetppaqAzrFum0Ub8wcnoM/8f1dWRZSulY5fRDP6rJaWT/8X3VleCzg==",
"version": "2.6.14",
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.14.tgz",
"integrity": "sha512-w/Tsd22e/5fAeoxqQ4P2MX6EyF+iM6rc9kmlMVFkHuG0rAltt2TLhFbDJfemnHbtvnazWaRfy5KnFU/SYT37dQ==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
@@ -40754,7 +40754,7 @@
"license": "MIT",
"dependencies": {
"@headlessui/react": "^1.7.14",
"@supabase/gotrue-js": "^2.48.0",
"@supabase/gotrue-js": "^2.52.0",
"@supabase/ui": "^0.37.0-alpha.50",
"@types/react": "^17.0.39",
"react-use": "^17.4.0"
+82 -2
View File
@@ -3,13 +3,20 @@ import { GoTrueClient, navigatorLock } from '@supabase/gotrue-js'
export const STORAGE_KEY = process.env.NEXT_PUBLIC_STORAGE_KEY || 'supabase.dashboard.auth.token'
export const AUTH_DEBUG_KEY =
process.env.NEXT_PUBLIC_AUTH_DEBUG_KEY || 'supabase.dashboard.auth.debug'
export const AUTH_DEBUG_PERSISTED_KEY =
process.env.NEXT_PUBLIC_AUTH_DEBUG_PERSISTED_KEY || 'supabase.dashboard.auth.debug.persist'
export const AUTH_NAVIGATOR_LOCK_DISABLED_KEY =
process.env.NEXT_PUBLIC_AUTH_NAVIGATOR_LOCK_KEY || 'supabase.dashboard.auth.navigatorLock.disabled'
process.env.NEXT_PUBLIC_AUTH_NAVIGATOR_LOCK_KEY ||
'supabase.dashboard.auth.navigatorLock.disabled'
const debug =
process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' &&
globalThis?.localStorage?.getItem(AUTH_DEBUG_KEY) === 'true'
const persistedDebug =
process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' &&
globalThis?.localStorage?.getItem(AUTH_DEBUG_PERSISTED_KEY) === 'true'
const navigatorLockEnabled = !!(
process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' &&
!(globalThis?.localStorage?.getItem(AUTH_NAVIGATOR_LOCK_DISABLED_KEY) === 'true') &&
@@ -20,10 +27,83 @@ if (!globalThis?.navigator?.locks) {
console.warn('This browser does not support the Navigator Locks API. Please update it.')
}
const tabId = Math.random().toString(16).substring(2)
let dbHandle = new Promise<IDBDatabase | null>((accept, _) => {
if (!persistedDebug) {
accept(null)
return
}
const request = indexedDB.open('auth-debug-log', 1)
request.onupgradeneeded = (event: any) => {
const db = event?.target?.result
if (!db) {
return
}
db.createObjectStore('events', { autoIncrement: true })
}
request.onsuccess = (event: any) => {
console.log('Opened persisted auth debug log IndexedDB database', tabId)
accept(event.target.result)
}
request.onerror = (event: any) => {
console.error('Failed to open persisted auth debug log IndexedDB database', event)
accept(null)
}
})
const logIndexedDB = (message: string, ...args: any[]) => {
console.log(message, ...args)
const copyArgs = structuredClone(args)
copyArgs.forEach((value) => {
if (typeof value === 'object' && value !== null) {
delete value.user
delete value.access_token
delete value.token_type
delete value.provider_token
}
})
;(async () => {
try {
const db = await dbHandle
if (!db) {
return
}
const tx = db.transaction(['events'], 'readwrite')
tx.onerror = (event: any) => {
console.error('Failed to write to persisted auth debug log IndexedDB database', event)
dbHandle = Promise.resolve(null)
}
const events = tx.objectStore('events')
events.add({
m: message.replace(/^GoTrueClient@/i, ''),
a: copyArgs,
l: window.location.pathname,
t: tabId,
})
} catch (e: any) {
console.error('Failed to log to persisted auth debug log IndexedDB database', e)
dbHandle = Promise.resolve(null)
}
})()
}
export const gotrueClient = new GoTrueClient({
url: process.env.NEXT_PUBLIC_GOTRUE_URL,
storageKey: STORAGE_KEY,
detectSessionInUrl: true,
debug,
debug: debug ? (persistedDebug ? logIndexedDB : true) : false,
lock: navigatorLockEnabled ? navigatorLock : undefined,
})
+1 -1
View File
@@ -6,7 +6,7 @@
"license": "MIT",
"dependencies": {
"@headlessui/react": "^1.7.14",
"@supabase/gotrue-js": "^2.48.0",
"@supabase/gotrue-js": "^2.52.0",
"@supabase/ui": "^0.37.0-alpha.50",
"@types/react": "^17.0.39",
"react-use": "^17.4.0"
+8
View File
@@ -0,0 +1,8 @@
export const get = jest.fn()
export const post = jest.fn()
export const patch = jest.fn()
export const del = jest.fn()
export const put = jest.fn()
export const head = jest.fn()
export const trace = jest.fn()
export const options = jest.fn()
@@ -22,9 +22,7 @@ import {
} from 'components/layouts/ProjectLayout/ProjectContext'
import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils'
export interface AddOnsProps {}
const AddOns = ({}: AddOnsProps) => {
const AddOns = () => {
const { ref: projectRef } = useParams()
const snap = useSubscriptionPageStateSnapshot()
const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate')
@@ -1,18 +1,20 @@
import dayjs from 'dayjs'
import { IconBarChart2 } from 'ui'
import Panel from 'components/ui/Panel'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { DataPoint } from 'data/analytics/constants'
import { useInfraMonitoringQuery } from 'data/analytics/infra-monitoring-query'
import dayjs from 'dayjs'
import Link from 'next/link'
import { Alert, Button, IconBarChart2 } from 'ui'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
import { getAddons } from '../Subscription/Subscription.utils'
import SectionContent from './SectionContent'
import SectionHeader from './SectionHeader'
import { USAGE_CATEGORIES } from './Usage.constants'
import { getUpgradeUrl } from './Usage.utils'
import UsageBarChart from './UsageBarChart'
import Panel from 'components/ui/Panel'
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { getAddons } from '../Subscription/Subscription.utils'
import { CPUWarnings, DiskIOBandwidthWarnings, RAMWarnings } from './UsageWarningAlerts'
import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query'
export interface InfrastructureProps {
projectRef: string
@@ -28,11 +30,12 @@ const Infrastructure = ({
currentBillingCycleSelected,
}: InfrastructureProps) => {
const { data: subscription } = useProjectSubscriptionV2Query({ projectRef })
const { data: resourceWarnings } = useResourceWarningsQuery()
const projectResourceWarnings = resourceWarnings?.find((x) => x.project === projectRef)
const categoryMeta = USAGE_CATEGORIES.find((category) => category.key === 'infra')
const upgradeUrl = getUpgradeUrl(projectRef, subscription)
const isFreeTier = subscription?.plan?.id === 'free'
const isFreePlan = subscription?.plan?.id === 'free'
const { data: addons, isLoading } = useProjectAddonsQuery({ projectRef })
const selectedAddons = addons?.selected_addons ?? []
@@ -125,81 +128,14 @@ const Infrastructure = ({
<SectionContent section={attribute}>
{attribute.key === 'disk_io_consumption' && (
<>
{hasLatest && latestIoBudgetConsumption >= 100 ? (
<Alert withIcon variant="danger" title="Your Disk IO Budget has been used up">
<p className="mb-4">
Your workload has used up all your Disk IO Budget and is now running at the
baseline performance. If you need consistent disk performance, consider
upgrading to a larger compute add-on.
</p>
<Link href={upgradeUrl}>
<a>
<Button type="danger">
{isFreeTier ? 'Upgrade project' : 'Change compute add-on'}
</Button>
</a>
</Link>
</Alert>
) : hasLatest && latestIoBudgetConsumption >= 80 ? (
<Alert
withIcon
variant="danger"
title="You are close to running out of Disk IO Budget"
>
<p className="mb-4">
Your workload has consumed {latestIoBudgetConsumption}% of your Disk IO
Budget. If you use up all your Disk IO Budget, your instance will reverted
to baseline performance. If you need consistent disk performance, consider
upgrading to a larger compute add-on.
</p>
<Link href={upgradeUrl}>
<a>
<Button type="danger">
{isFreeTier ? 'Upgrade project' : 'Change compute add-on'}
</Button>
</a>
</Link>
</Alert>
) : currentBillingCycleSelected && highestIoBudgetConsumption >= 100 ? (
<Alert
withIcon
variant="warning"
title="You ran out of IO Budget at least once"
>
<p className="mb-4">
Your workload has used up all your Disk IO Budget and reverted to baseline
performance at least once during this billing cycle. If you need consistent
disk performance, consider upgrading to a larger compute add-on.
</p>
<Link href={upgradeUrl}>
<a>
<Button type="warning">
{isFreeTier ? 'Upgrade project' : 'Change compute add-on'}
</Button>
</a>
</Link>
</Alert>
) : currentBillingCycleSelected && highestIoBudgetConsumption >= 80 ? (
<Alert
withIcon
variant="warning"
title="You were close to using all your IO Budget at least once"
>
<p className="mb-4">
Your workload has consumed {highestIoBudgetConsumption}% of your Disk IO
budget during this billing cycle. If you use up all your Disk IO Budget,
your instance will reverted to baseline performance. If you need consistent
disk performance, consider upgrading to a larger compute add-on.
</p>
<Link href={upgradeUrl}>
<a>
<Button type="warning">
{isFreeTier ? 'Upgrade project' : 'Change compute add-on'}
</Button>
</a>
</Link>
</Alert>
) : null}
<DiskIOBandwidthWarnings
upgradeUrl={upgradeUrl}
isFreePlan={isFreePlan}
hasLatest={hasLatest}
currentBillingCycleSelected={currentBillingCycleSelected}
latestIoBudgetConsumption={latestIoBudgetConsumption}
highestIoBudgetConsumption={highestIoBudgetConsumption}
/>
<div className="space-y-1">
<p>Disk IO Bandwidth</p>
@@ -246,6 +182,21 @@ const Infrastructure = ({
</div>
</>
)}
{attribute.key === 'max_cpu_usage' && (
<CPUWarnings
isFreePlan={isFreePlan}
upgradeUrl={upgradeUrl}
severity={projectResourceWarnings?.cpu_exhaustion}
/>
)}
{attribute.key === 'ram_usage' && (
<RAMWarnings
isFreePlan={isFreePlan}
upgradeUrl={upgradeUrl}
severity={projectResourceWarnings?.memory_and_swap_exhaustion}
/>
)}
<div className="space-y-1">
<div className="flex flex-row justify-between">
{attribute.key === 'disk_io_consumption' ? (
@@ -1,15 +1,16 @@
import clsx from 'clsx'
import { useParams } from 'common'
import dayjs from 'dayjs'
import { useMemo, useRef, useState } from 'react'
import { InView } from 'react-intersection-observer'
import { Button, IconAlertCircle, IconExternalLink, IconInfo, IconLoader, cn } from 'ui'
import DateRangePicker from 'components/to-be-cleaned/DateRangePicker'
import { useInfraMonitoringQuery } from 'data/analytics/infra-monitoring-query'
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
import { useProjectUsageQuery } from 'data/usage/project-usage-query'
import dayjs from 'dayjs'
import { useSelectedOrganization } from 'hooks'
import { TIME_PERIODS_BILLING, TIME_PERIODS_REPORTS } from 'lib/constants'
import { useMemo, useRef, useState } from 'react'
import { InView } from 'react-intersection-observer'
import { Button, IconAlertCircle, IconExternalLink, IconInfo, IconLoader, cn } from 'ui'
import Activity from './Activity'
import Bandwidth from './Bandwidth'
import Infrastructure from './Infrastructure'
@@ -0,0 +1,71 @@
import { AlertTitle } from '@ui/components/shadcn/ui/alert'
import Link from 'next/link'
import { AlertDescription_Shadcn_, Alert_Shadcn_, Button, IconAlertCircle } from 'ui'
interface CPUWarningsProps {
isFreePlan: boolean
upgradeUrl: string
severity?: 'warning' | 'critical' | null
}
const CPUWarnings = ({ isFreePlan, upgradeUrl, severity }: CPUWarningsProps) => {
if (severity === 'warning') {
return (
<Alert_Shadcn_ variant="warning">
<IconAlertCircle />
<AlertTitle>Your max CPU usage has exceeded 80%</AlertTitle>
<AlertDescription_Shadcn_>
High CPU usage could result in slower queries, disruption of daily back up routines, and
in rare cases, your instance may become unresponsive. If you need more resources, consider
upgrading to a larger compute add-on.
</AlertDescription_Shadcn_>
<div className="mt-3 flex items-center space-x-2">
<Link href="https://supabase.com/docs/guides/platform/exhaust-cpu">
<a>
<Button type="default">Learn more</Button>
</a>
</Link>
<Link href={upgradeUrl}>
<a>
<Button type="warning">
{isFreePlan ? 'Upgrade project' : 'Change compute add-on'}
</Button>
</a>
</Link>
</div>
</Alert_Shadcn_>
)
}
if (severity === 'critical') {
return (
<Alert_Shadcn_ variant="destructive">
<IconAlertCircle />
<AlertTitle>Your max CPU usage has reached 100%</AlertTitle>
<AlertDescription_Shadcn_>
High CPU usage could result in slower queries, disruption of daily back up routines, and
in rare cases, your instance may become unresponsive. If you need more resources, consider
upgrading to a larger compute add-on.
</AlertDescription_Shadcn_>
<div className="mt-3 flex items-center space-x-2">
<Link href="https://supabase.com/docs/guides/platform/exhaust-cpu">
<a>
<Button type="default">Learn more</Button>
</a>
</Link>
<Link href={upgradeUrl}>
<a>
<Button type="danger">
{isFreePlan ? 'Upgrade project' : 'Change compute add-on'}
</Button>
</a>
</Link>
</div>
</Alert_Shadcn_>
)
}
return null
}
export default CPUWarnings
@@ -0,0 +1,106 @@
import Link from 'next/link'
import { Alert, Button } from 'ui'
// [Joshen] In the future, conditionals should be from resource exhaustion endpoint as single source of truth
interface DiskIOBandwidthWarningsProps {
isFreePlan: boolean
hasLatest: boolean
upgradeUrl: string
currentBillingCycleSelected: boolean
latestIoBudgetConsumption: number
highestIoBudgetConsumption: number
}
const DiskIOBandwidthWarnings = ({
isFreePlan,
hasLatest,
currentBillingCycleSelected,
upgradeUrl,
latestIoBudgetConsumption,
highestIoBudgetConsumption,
}: DiskIOBandwidthWarningsProps) => {
if (hasLatest && latestIoBudgetConsumption >= 100) {
return (
<Alert withIcon variant="danger" title="Your Disk IO Budget has been used up">
<p className="mb-4">
Your workload has used up all your Disk IO Budget and is now running at the baseline
performance. If you need consistent disk performance, consider upgrading to a larger
compute add-on.
</p>
<Link href={upgradeUrl}>
<a>
<Button type="danger">
{isFreePlan ? 'Upgrade project' : 'Change compute add-on'}
</Button>
</a>
</Link>
</Alert>
)
}
if (hasLatest && latestIoBudgetConsumption >= 80) {
return (
<Alert withIcon variant="danger" title="You are close to running out of Disk IO Budget">
<p className="mb-4">
Your workload has consumed {latestIoBudgetConsumption}% of your Disk IO Budget. If you use
up all your Disk IO Budget, your instance will reverted to baseline performance. If you
need consistent disk performance, consider upgrading to a larger compute add-on.
</p>
<Link href={upgradeUrl}>
<a>
<Button type="danger">
{isFreePlan ? 'Upgrade project' : 'Change compute add-on'}
</Button>
</a>
</Link>
</Alert>
)
}
if (currentBillingCycleSelected && highestIoBudgetConsumption >= 100) {
return (
<Alert withIcon variant="warning" title="You ran out of IO Budget at least once">
<p className="mb-4">
Your workload has used up all your Disk IO Budget and reverted to baseline performance at
least once during this billing cycle. If you need consistent disk performance, consider
upgrading to a larger compute add-on.
</p>
<Link href={upgradeUrl}>
<a>
<Button type="warning">
{isFreePlan ? 'Upgrade project' : 'Change compute add-on'}
</Button>
</a>
</Link>
</Alert>
)
}
if (currentBillingCycleSelected && highestIoBudgetConsumption >= 80) {
return (
<Alert
withIcon
variant="warning"
title="You were close to using all your IO Budget at least once"
>
<p className="mb-4">
Your workload has consumed {highestIoBudgetConsumption}% of your Disk IO budget during
this billing cycle. If you use up all your Disk IO Budget, your instance will reverted to
baseline performance. If you need consistent disk performance, consider upgrading to a
larger compute add-on.
</p>
<Link href={upgradeUrl}>
<a>
<Button type="warning">
{isFreePlan ? 'Upgrade project' : 'Change compute add-on'}
</Button>
</a>
</Link>
</Alert>
)
}
return null
}
export default DiskIOBandwidthWarnings
@@ -0,0 +1,71 @@
import { AlertTitle } from '@ui/components/shadcn/ui/alert'
import Link from 'next/link'
import { AlertDescription_Shadcn_, Alert_Shadcn_, Button, IconAlertCircle } from 'ui'
interface RAMWarningsProps {
isFreePlan: boolean
upgradeUrl: string
severity?: 'warning' | 'critical' | null
}
const RAMWarnings = ({ isFreePlan, upgradeUrl, severity }: RAMWarningsProps) => {
if (severity === 'warning') {
return (
<Alert_Shadcn_ variant="warning">
<IconAlertCircle />
<AlertTitle>Your memory usage has exceeded 80%</AlertTitle>
<AlertDescription_Shadcn_>
High memory usage could result in overall degraded performance, and in rare cases, your
instance may become unresponsive. If you need more resources, consider upgrading to a
larger compute add-on.
</AlertDescription_Shadcn_>
<div className="mt-3 flex items-center space-x-2">
<Link href="https://supabase.com/docs/guides/platform/exhaust-ram">
<a>
<Button type="default">Learn more</Button>
</a>
</Link>
<Link href={upgradeUrl}>
<a>
<Button type="warning">
{isFreePlan ? 'Upgrade project' : 'Change compute add-on'}
</Button>
</a>
</Link>
</div>
</Alert_Shadcn_>
)
}
if (severity === 'critical') {
return (
<Alert_Shadcn_ variant="destructive">
<IconAlertCircle />
<AlertTitle>Your memory usage has reached 100%</AlertTitle>
<AlertDescription_Shadcn_>
High memory usage could result in overall degraded performance, and in rare cases, your
instance may become unresponsive. If you need more resources, consider upgrading to a
larger compute add-on.
</AlertDescription_Shadcn_>
<div className="mt-3 flex items-center space-x-2">
<Link href="https://supabase.com/docs/guides/platform/exhaust-cpu">
<a>
<Button type="default">Learn more</Button>
</a>
</Link>
<Link href={upgradeUrl}>
<a>
<Button type="danger">
{isFreePlan ? 'Upgrade project' : 'Change compute add-on'}
</Button>
</a>
</Link>
</div>
</Alert_Shadcn_>
)
}
return null
}
export default RAMWarnings
@@ -0,0 +1,5 @@
import CPUWarnings from './CPUWarnings'
import DiskIOBandwidthWarnings from './DiskIOBandwidthWarnings'
import RAMWarnings from './RAMWarnings'
export { CPUWarnings, DiskIOBandwidthWarnings, RAMWarnings }
@@ -1,24 +1,19 @@
import {
Badge,
IconAlertTriangle,
IconGitBranch,
IconGitHub,
IconLoader,
IconPauseCircle,
IconTriangle,
} from 'ui'
import { IconGitBranch, IconGitHub } from 'ui'
import CardButton from 'components/ui/CardButton'
import { useProjectReadOnlyStatus } from 'hooks/misc/useProjectReadOnlyStatus'
import { BASE_PATH, PROJECT_STATUS } from 'lib/constants'
import { BASE_PATH } from 'lib/constants'
import { Project } from 'types'
import { IntegrationProjectConnection } from 'data/integrations/integrations.types'
import { ResourceWarning } from 'data/usage/resource-warnings-query'
import { ProjectCardStatus } from './ProjectCardStatus'
import { inferProjectStatus } from './ProjectCard.utils'
export interface ProjectCardProps {
project: Project
rewriteHref?: string
githubIntegration?: IntegrationProjectConnection
vercelIntegration?: IntegrationProjectConnection
resourceWarnings?: ResourceWarning
}
const ProjectCard = ({
@@ -26,30 +21,26 @@ const ProjectCard = ({
rewriteHref,
githubIntegration,
vercelIntegration,
resourceWarnings,
}: ProjectCardProps) => {
const { name, ref: projectRef } = project
const desc = `${project.cloud_provider} | ${project.region}`
const isReadonly = useProjectReadOnlyStatus(projectRef)
const isBranchingEnabled = project.preview_branch_refs?.length > 0
const isBranchingEnabled = project.preview_branch_refs.length > 0
const isGithubIntegrated = githubIntegration !== undefined
const isVercelIntegrated = vercelIntegration !== undefined
const githubRepository = githubIntegration?.metadata.name ?? undefined
// Project status should supersede is read only status
const isHealthy = project.status === PROJECT_STATUS.ACTIVE_HEALTHY
const isPausing =
project.status === PROJECT_STATUS.GOING_DOWN || project.status === PROJECT_STATUS.PAUSING
const isPaused = project.status === PROJECT_STATUS.INACTIVE
const isRestoring = project.status === PROJECT_STATUS.RESTORING
const projectStatus = inferProjectStatus(project)
return (
<li className="col-span-1 list-none">
<CardButton
linkHref={rewriteHref ? rewriteHref : `/project/${projectRef}`}
containerHeightClassName="h-44"
title={
<div className="w-full justify-between space-y-1">
<p className="flex-shrink truncate">{name}</p>
<div className="w-full justify-between space-y-1.5 px-6">
<p className="flex-shrink truncate text-base">{name}</p>
<span className="text-sm lowercase text-scale-1000">{desc}</span>
<div className="flex items-center space-x-1.5">
{isVercelIntegrated && (
<div className="w-fit p-1 border rounded-md flex items-center border-scale-600">
@@ -77,55 +68,16 @@ const ProjectCard = ({
</div>
}
footer={
<div className="flex items-end justify-between">
<span className="text-sm lowercase text-scale-1000">{desc}</span>
{isHealthy && isReadonly && (
<div className="grow text-right">
<Badge color="yellow">
<div className="flex items-center gap-2">
<IconAlertTriangle size={14} strokeWidth={2} />
<span className="truncate">Read-only mode</span>
</div>
</Badge>
</div>
)}
{isRestoring && (
<div className="grow text-right">
<Badge color="brand">
<div className="flex items-center gap-2">
<IconLoader className="animate-spin" size={14} strokeWidth={2} />
<span className="truncate">Restoring</span>
</div>
</Badge>
</div>
)}
{isPausing && (
<div className="grow text-right">
<Badge color="scale">
<div className="flex items-center gap-2">
<IconLoader className="animate-spin" size={14} strokeWidth={2} />
<span className="truncate">Pausing</span>
</div>
</Badge>
</div>
)}
{isPaused && (
<div className="grow text-right">
<Badge color="scale">
<div className="flex items-center gap-2">
<IconPauseCircle size={14} strokeWidth={2} />
<span className="truncate">Paused</span>
</div>
</Badge>
</div>
<div className="mb-[-26px]">
{resourceWarnings && (
<ProjectCardStatus
projectStatus={projectStatus}
resourceWarnings={resourceWarnings}
/>
)}
</div>
}
/>
></CardButton>
</li>
)
}
@@ -0,0 +1,34 @@
import { PROJECT_STATUS } from 'lib/constants'
import { Project } from 'types'
export const inferProjectStatus = (project: Project) => {
let status = undefined
switch (project.status) {
case PROJECT_STATUS.ACTIVE_HEALTHY:
status = 'isHealthy'
break
case PROJECT_STATUS.GOING_DOWN:
case PROJECT_STATUS.PAUSING:
status = 'isPausing'
break
case PROJECT_STATUS.INACTIVE:
status = 'isPaused'
break
case PROJECT_STATUS.RESTORING:
status = 'isRestoring'
break
case PROJECT_STATUS.UNKNOWN:
case PROJECT_STATUS.COMING_UP:
status = 'isComingUp'
break
}
return status as InferredProjectStatus
}
export type InferredProjectStatus =
| 'isHealthy'
| 'isPausing'
| 'isPaused'
| 'isRestoring'
| 'isComingUp'
| undefined
@@ -0,0 +1,98 @@
import { RESOURCE_WARNING_MESSAGES } from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.constants'
import { ResourceWarning } from 'data/usage/resource-warnings-query'
import {
Alert_Shadcn_,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
IconAlertTriangle,
IconLoader,
IconPauseCircle,
IconRefreshCw,
} from 'ui'
import { InferredProjectStatus } from './ProjectCard.utils'
import { getWarningContent } from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.utils'
export interface ProjectCardWarningsProps {
resourceWarnings: ResourceWarning
projectStatus: InferredProjectStatus
}
export const ProjectCardStatus = ({
resourceWarnings,
projectStatus,
}: ProjectCardWarningsProps) => {
//const showResourceExhaustionWarnings = useFlag('resourceExhaustionWarnings')
const showResourceExhaustionWarnings = false
// [Joshen] Read only takes higher precedence over multiple resource warnings
const activeWarnings = resourceWarnings.is_readonly_mode_enabled
? ['is_readonly_mode_enabled']
: Object.keys(resourceWarnings).filter(
(property) =>
property !== 'project' &&
property !== 'is_readonly_mode_enabled' &&
resourceWarnings[property as keyof typeof resourceWarnings] !== null
)
const hasCriticalWarning = activeWarnings.some(
(x) => resourceWarnings[x as keyof typeof resourceWarnings] === 'critical'
)
const isCritical = activeWarnings.includes('is_readonly_mode_enabled') || hasCriticalWarning
const getTitle = () => {
if (projectStatus === 'isPaused') return 'Project is paused'
if (projectStatus === 'isPausing') return 'Project is pausing'
if (projectStatus === 'isComingUp') return 'Project is coming up'
if (projectStatus === 'isRestoring') return 'Project is restoring'
// If none of the paused/restoring states match, proceed with the default logic
return activeWarnings.length > 1
? RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.cardContent[
hasCriticalWarning ? 'critical' : 'warning'
].title
: getWarningContent(resourceWarnings, activeWarnings[0], 'cardContent')?.title
}
const getDescription = () => {
if (projectStatus === 'isPaused') return 'This project will not accept requests until resumed'
if (projectStatus === 'isPausing') return 'The pause process will complete in a few minutes'
if (projectStatus === 'isComingUp') return 'Your project will be ready in a few minutes'
if (projectStatus === 'isRestoring') return 'Your project will be ready in a few minutes'
// If none of the paused/restoring states match, proceed with the default logic
return activeWarnings.length > 1 && showResourceExhaustionWarnings
? RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.cardContent[
hasCriticalWarning ? 'critical' : 'warning'
].description
: getWarningContent(resourceWarnings, activeWarnings[0], 'cardContent')?.description
}
const alertTitle = getTitle()
const alertDescription = getDescription()
const alertType = isCritical
? 'destructive'
: projectStatus === 'isPaused'
? 'default'
: 'warning'
if (activeWarnings.length === 0 && projectStatus === 'isHealthy') return <div className="py-2" />
return (
<div>
<Alert_Shadcn_
variant={alertType}
className="border-0 rounded-none rounded-b-md my-2 mb-2.5 [&>svg]:w-[28px] [&>svg]:h-[28px] [&>svg]:mt-1 [&>svg]:p-1.5 [&>svg]:left-6 pl-6"
>
{projectStatus === 'isPaused' || projectStatus === 'isPausing' ? (
<IconPauseCircle strokeWidth={2} />
) : projectStatus === 'isRestoring' || projectStatus === 'isComingUp' ? (
<IconRefreshCw size={14} strokeWidth={2} />
) : (
<IconAlertTriangle strokeWidth={2} />
)}
<AlertTitle_Shadcn_ className="text-sm mb-0.5">{alertTitle}</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_ className="text-xs">{alertDescription}</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</div>
)
}
@@ -11,6 +11,7 @@ import {
import { useOrganizationsQuery } from 'data/organizations/organizations-query'
import { usePermissionsQuery } from 'data/permissions/permissions-query'
import { useProjectsQuery } from 'data/projects/projects-query'
import { ResourceWarning, useResourceWarningsQuery } from 'data/usage/resource-warnings-query'
import { IS_PLATFORM } from 'lib/constants'
import { makeRandomString } from 'lib/helpers'
import { Organization, Project, ResponseError } from 'types'
@@ -35,6 +36,7 @@ const ProjectList = ({ rewriteHref }: ProjectListProps) => {
isError: isErrorPermissions,
error: permissionsError,
} = usePermissionsQuery()
const { data: resourceWarnings } = useResourceWarningsQuery()
const { data: allOverdueInvoices } = useOverdueInvoicesQuery({ enabled: IS_PLATFORM })
const projectsByOrg = groupBy(allProjects, 'organization_id')
const isLoadingPermissions = IS_PLATFORM ? _isLoadingPermissions : false
@@ -50,6 +52,7 @@ const ProjectList = ({ rewriteHref }: ProjectListProps) => {
overdueInvoices={(allOverdueInvoices ?? []).filter(
(it) => it.organization_id === organization.id
)}
resourceWarnings={resourceWarnings ?? []}
rewriteHref={rewriteHref}
isLoadingPermissions={isLoadingPermissions}
isErrorPermissions={isErrorPermissions}
@@ -72,6 +75,7 @@ type OrganizationProjectsProps = {
organization: Organization
projects: Project[]
overdueInvoices: OverdueInvoicesResponse[]
resourceWarnings: ResourceWarning[]
isLoadingPermissions: boolean
isErrorPermissions: boolean
permissionsError: ResponseError | null
@@ -82,9 +86,10 @@ type OrganizationProjectsProps = {
}
const OrganizationProjects = ({
organization: { id, name, slug, subscription_id },
organization: { name, slug, subscription_id },
projects,
overdueInvoices,
resourceWarnings,
isLoadingPermissions,
isErrorPermissions,
permissionsError,
@@ -150,6 +155,9 @@ const OrganizationProjects = ({
key={makeRandomString(5)}
project={project}
rewriteHref={rewriteHref ? rewriteHref(project.ref) : undefined}
resourceWarnings={resourceWarnings.find(
(resourceWarning) => resourceWarning.project === project.ref
)}
/>
))
)}
@@ -14,8 +14,10 @@ import { Alert, Button } from 'ui'
import { BILLING_BREAKDOWN_METRICS } from './BillingBreakdown.constants'
import BillingMetric from './BillingMetric'
import UpcomingInvoice from './UpcomingInvoice'
import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings'
const BillingBreakdown = () => {
const snap = useOrgSettingsPageStateSnapshot()
const { slug: orgSlug } = useParams()
const {
data: usage,
@@ -93,7 +95,16 @@ const BillingBreakdown = () => {
variant="danger"
title="Your organization's usage has exceeded its included quota"
actions={[
<Button key="upgrade-button" type="default" className="ml-8" onClick={() => {}}>
<Button
key="upgrade-button"
type="default"
className="ml-8"
onClick={() =>
snap.setPanelKey(
currentPlan?.id === 'free' ? 'subscriptionPlan' : 'costControl'
)
}
>
{currentPlan?.id === 'free' ? 'Upgrade plan' : 'Change spend cap'}
</Button>,
]}
@@ -11,12 +11,13 @@ import { useProjectsQuery } from 'data/projects/projects-query'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useSelectedOrganization } from 'hooks'
import { TIME_PERIODS_BILLING, TIME_PERIODS_REPORTS } from 'lib/constants'
import { Button, IconExternalLink, IconInfo, Listbox } from 'ui'
import { Alert, Button, IconExternalLink, IconInfo, Listbox } from 'ui'
import Activity from './Activity'
import Bandwidth from './Bandwidth'
import SizeAndCounts from './SizeAndCounts'
import InformationBox from 'components/ui/InformationBox'
import Link from 'next/link'
import { useOrgUsageQuery } from 'data/usage/org-usage-query'
const Usage = () => {
const { slug, projectRef } = useParams()
@@ -32,6 +33,9 @@ const Usage = () => {
isError: isErrorSubscription,
isSuccess: isSuccessSubscription,
} = useOrgSubscriptionQuery({ orgSlug: slug })
const { data: usage } = useOrgUsageQuery({ orgSlug: slug })
const orgProjects = projects?.filter((project) => project.organization_id === organization?.id)
useEffect(() => {
@@ -88,6 +92,13 @@ const Usage = () => {
? orgProjects?.find((it) => it.ref === selectedProjectRef)
: undefined
const hasExceededAnyLimits = Boolean(
usage?.usages.find(
(metric) =>
!metric.unlimited && metric.capped && metric.usage > (metric?.pricing_free_units ?? 0)
)
)
return (
<>
<ScaffoldContainer className="sticky top-0 border-b bg-scale-200 z-10 overflow-hidden">
@@ -156,6 +167,33 @@ const Usage = () => {
</div>
</ScaffoldContainer>
{!selectedProject && subscription && hasExceededAnyLimits && (
<ScaffoldContainer className="mt-5">
<Alert
withIcon
variant="danger"
title="Your organization's usage has exceeded its included quota"
actions={[
<Link
key="upgrade-button"
href={`/org/${slug}/billing?panel=${
subscription.plan.id === 'free' ? 'subscriptionPlan' : 'costControl'
}`}
>
<Button asChild key="upgrade-button" type="default" className="ml-8">
<a>{subscription.plan.id === 'free' ? 'Upgrade plan' : 'Change spend cap'}</a>
</Button>
</Link>,
]}
>
Your projects can become unresponsive or enter read only mode.{' '}
{subscription.plan.id === 'free'
? 'Please upgrade to the Pro plan to ensure that your projects remain available.'
: 'Please disable spend cap to ensure that your projects remain available.'}
</Alert>
</ScaffoldContainer>
)}
{selectedProjectRef && (
<ScaffoldContainer className="mt-5">
<InformationBox
@@ -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>
))}
</>
}
/>
</>
)
}
@@ -134,14 +134,14 @@ const Addons = () => {
<ScaffoldSection>
<ScaffoldSectionDetail>
<div className="space-y-6">
<p>Optimized compute</p>
<p className="m-0">Optimized compute</p>
<div className="space-y-2">
<p className="text-sm text-scale-1100">More information</p>
<p className="text-sm text-scale-1100 m-0">More information</p>
<div>
<Link href="https://supabase.com/docs/guides/platform/compute-add-ons">
<a target="_blank" rel="noreferrer">
<div className="flex items-center space-x-2 opacity-50 hover:opacity-100 transition">
<p className="text-sm">About compute add-ons</p>
<p className="text-sm m-0">About compute add-ons</p>
<IconExternalLink size={16} strokeWidth={1.5} />
</div>
</a>
@@ -151,7 +151,7 @@ const Addons = () => {
<Link href="https://supabase.com/docs/guides/database/connecting-to-postgres#connection-pooler">
<a target="_blank" rel="noreferrer">
<div className="flex items-center space-x-2 opacity-50 hover:opacity-100 transition">
<p className="text-sm">Connection Pooler</p>
<p className="text-sm m-0">Connection Pooler</p>
<IconExternalLink size={16} strokeWidth={1.5} />
</div>
</a>
@@ -339,14 +339,14 @@ const Addons = () => {
<ScaffoldSection>
<ScaffoldSectionDetail>
<div className="space-y-6">
<p>Point in time recovery</p>
<p className="m-0">Point in time recovery</p>
<div className="space-y-2">
<p className="text-sm text-scale-1100">More information</p>
<p className="text-sm text-scale-1100 m-0">More information</p>
<div>
<Link href="https://supabase.com/docs/guides/platform/backups#point-in-time-recovery">
<a target="_blank" rel="noreferrer">
<div className="flex items-center space-x-2 opacity-50 hover:opacity-100 transition">
<p className="text-sm">About PITR backups</p>
<p className="text-sm m-0">About PITR backups</p>
<IconExternalLink size={16} strokeWidth={1.5} />
</div>
</a>
@@ -403,14 +403,14 @@ const Addons = () => {
<ScaffoldSection>
<ScaffoldSectionDetail>
<div className="space-y-6">
<p>Custom domain</p>
<p className="m-0">Custom domain</p>
<div className="space-y-2">
<p className="text-sm text-scale-1100">More information</p>
<p className="text-sm text-scale-1100 m-0">More information</p>
<div>
<Link href="https://supabase.com/docs/guides/platform/custom-domains">
<a target="_blank" rel="noreferrer">
<div className="flex items-center space-x-2 opacity-50 hover:opacity-100 transition">
<p className="text-sm">About custom domains</p>
<p className="text-sm m-0">About custom domains</p>
<IconExternalLink size={16} strokeWidth={1.5} />
</div>
</a>
@@ -1,8 +1,16 @@
import { useParams } from 'common'
import dayjs from 'dayjs'
import Link from 'next/link'
import { Fragment, useMemo, useState } from 'react'
import { IconBarChart2, IconExternalLink } from 'ui'
import { useParams } from 'common'
import { getAddons } from 'components/interfaces/BillingV2/Subscription/Subscription.utils'
import {
CPUWarnings,
DiskIOBandwidthWarnings,
RAMWarnings,
} from 'components/interfaces/BillingV2/Usage/UsageWarningAlerts'
import UsageBarChart from 'components/interfaces/Organization/Usage/UsageBarChart'
import {
ScaffoldContainer,
ScaffoldDivider,
@@ -19,10 +27,8 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { useSelectedOrganization } from 'hooks'
import { TIME_PERIODS_BILLING, TIME_PERIODS_REPORTS } from 'lib/constants'
import { Alert, Button, IconBarChart2, IconExternalLink } from 'ui'
import { getAddons } from '../../BillingV2/Subscription/Subscription.utils'
import UsageBarChart from '../../Organization/Usage/UsageBarChart'
import { INFRA_ACTIVITY_METRICS } from './Infrastructure.constants'
import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query'
const InfrastructureActivity = () => {
const { ref: projectRef } = useParams()
@@ -30,17 +36,17 @@ const InfrastructureActivity = () => {
const isOrgBilling = !!organization?.subscription_id
const [dateRange, setDateRange] = useState<any>()
const currentBillingCycleSelected = true
const upgradeUrl = '/'
const categoryMeta = INFRA_ACTIVITY_METRICS.find((category) => category.key === 'infra')
const { data: subscription, isLoading: isLoadingSubscription } = useOrgSubscriptionQuery({
orgSlug: organization?.slug,
})
const isFreePlan = subscription?.plan?.id === 'free'
const { data: resourceWarnings } = useResourceWarningsQuery()
const projectResourceWarnings = resourceWarnings?.find((x) => x.project === projectRef)
const { data: addons, isLoading } = useProjectAddonsQuery({ projectRef })
const selectedAddons = addons?.selected_addons ?? []
const { computeInstance } = getAddons(selectedAddons)
const currentComputeInstanceSpecs = computeInstance?.variant?.meta ?? {
baseline_disk_io_mbs: 87,
@@ -50,6 +56,27 @@ const InfrastructureActivity = () => {
memory_gb: 1,
}
const currentBillingCycleSelected = useMemo(() => {
// Selected by default
if (!dateRange?.period_start || !dateRange?.period_end || !subscription) return true
const { current_period_start, current_period_end } = subscription
return (
dayjs(dateRange.period_start.date).isSame(new Date(current_period_start * 1000)) &&
dayjs(dateRange.period_end.date).isSame(new Date(current_period_end * 1000))
)
}, [dateRange, subscription])
const upgradeUrl =
subscription === undefined
? `/`
: subscription.plan.id === 'free'
? `/org/${organization?.slug ?? '[slug]'}/billing#subscription`
: `/project/${projectRef}/settings/addons`
const categoryMeta = INFRA_ACTIVITY_METRICS.find((category) => category.key === 'infra')
const startDate = useMemo(() => {
if (dateRange?.period_start?.date === 'Invalid Date') return undefined
@@ -116,6 +143,13 @@ const InfrastructureActivity = () => {
dateFormat,
})
const hasLatest = dayjs(endDate!).isAfter(dayjs().startOf('day'))
const latestIoBudgetConsumption =
hasLatest && ioBudgetData?.data?.slice(-1)?.[0]
? Number(ioBudgetData.data.slice(-1)[0].disk_io_consumption)
: 0
const highestIoBudgetConsumption = Math.max(
...(ioBudgetData?.data || []).map((x) => Number(x.disk_io_consumption) ?? 0),
0
@@ -180,11 +214,11 @@ const InfrastructureActivity = () => {
<div className="sticky top-32 space-y-6">
<div className="space-y-1">
<div className="flex items-center space-x-2">
<p className="text-base capitalize">{attribute.name}</p>
<h4 className="text-base capitalize m-0">{attribute.name}</h4>
</div>
<div className="grid gap-4">
{attribute.description.split('\n').map((value, idx) => (
<p key={`desc-${idx}`} className="text-sm text-scale-1000 pr-8">
<p key={`desc-${idx}`} className="text-sm text-scale-1000 pr-8 m-0">
{value}
</p>
))}
@@ -199,7 +233,7 @@ const InfrastructureActivity = () => {
<Link href={link.url}>
<a target="_blank" rel="noreferrer">
<div className="flex items-center space-x-2 opacity-50 hover:opacity-100 transition">
<p className="text-sm">{link.name}</p>
<p className="text-sm m-0">{link.name}</p>
<IconExternalLink size={16} strokeWidth={1.5} />
</div>
</a>
@@ -212,46 +246,14 @@ const InfrastructureActivity = () => {
<ScaffoldSectionContent>
{attribute.key === 'disk_io_consumption' && (
<>
{currentBillingCycleSelected && highestIoBudgetConsumption >= 100 ? (
<Alert
withIcon
variant="danger"
title="IO Budget for today has been used up"
>
<p className="mb-4">
Your workload has used up all the burst IO throughput minutes and ran at
the baseline performance. If you need consistent disk performance,
consider upgrading to a larger compute add-on.
</p>
<Link href={upgradeUrl}>
<a>
<Button type="danger">
{isFreePlan ? 'Upgrade project' : 'Change compute add-on'}
</Button>
</a>
</Link>
</Alert>
) : currentBillingCycleSelected && highestIoBudgetConsumption >= 80 ? (
<Alert
withIcon
variant="warning"
title="IO Budget for today is running out"
>
<p className="mb-4">
Your workload is about to use up all the burst IO throughput minutes
during the day. Once this is completely used up, your workload will run
at the baseline performance. If you need consistent disk performance,
consider upgrading to a larger compute add-on.
</p>
<Link href={upgradeUrl}>
<a>
<Button type="warning">
{isFreePlan ? 'Upgrade project' : 'Change compute add-on'}
</Button>
</a>
</Link>
</Alert>
) : null}
<DiskIOBandwidthWarnings
upgradeUrl={upgradeUrl}
isFreePlan={isFreePlan}
hasLatest={hasLatest}
currentBillingCycleSelected={currentBillingCycleSelected}
latestIoBudgetConsumption={latestIoBudgetConsumption}
highestIoBudgetConsumption={highestIoBudgetConsumption}
/>
<div className="space-y-1">
<p>Disk IO Bandwidth</p>
@@ -302,6 +304,21 @@ const InfrastructureActivity = () => {
</div>
</>
)}
{attribute.key === 'max_cpu_usage' && (
<CPUWarnings
isFreePlan={isFreePlan}
upgradeUrl={upgradeUrl}
severity={projectResourceWarnings?.cpu_exhaustion}
/>
)}
{attribute.key === 'ram_usage' && (
<RAMWarnings
isFreePlan={isFreePlan}
upgradeUrl={upgradeUrl}
severity={projectResourceWarnings?.memory_and_swap_exhaustion}
/>
)}
<div className="space-y-1">
<div className="flex flex-row justify-between">
{attribute.key === 'disk_io_consumption' ? (
@@ -202,7 +202,7 @@ const InputField = ({
...(field.isNullable ? [{ value: 'null', label: 'NULL' }] : []),
]
const defaultValue = field.value === null ? 'null' : field.value
const defaultValue = field.defaultValue !== null ? field.defaultValue : 'null'
return (
<Listbox
@@ -6,20 +6,17 @@ import Link from 'next/link'
import { useEffect, useRef } from 'react'
import { Badge, Button, IconArrowRight, IconLoader } from 'ui'
import { useParams } from 'common'
import { DisplayApiSettings, DisplayConfigSettings } from 'components/ui/ProjectSettings'
import { invalidateProjectDetailsQuery } from 'data/projects/project-detail-query'
import { invalidateProjectsQuery } from 'data/projects/projects-query'
import { useSelectedProject } from 'hooks'
import { getWithTimeout } from 'lib/common/fetch'
import { API_URL, PROJECT_STATUS } from 'lib/constants'
import { Project } from 'types'
import { invalidateProjectDetailsQuery } from 'data/projects/project-detail-query'
import { useParams } from 'common'
export interface BuildingStateProps {
project: Project
}
const BuildingState = ({ project }: BuildingStateProps) => {
const BuildingState = () => {
const { ref } = useParams()
const project = useSelectedProject()
const queryClient = useQueryClient()
const checkServerInterval = useRef<number>()
@@ -48,6 +45,8 @@ const BuildingState = ({ project }: BuildingStateProps) => {
}
}, [])
if (project === undefined) return null
return (
<div className="mx-auto my-16 w-full max-w-6xl items-center justify-center">
<div className="mx-6 flex flex-col space-y-16">
@@ -1,9 +1,14 @@
import { useParams } from 'common'
import Link from 'next/link'
import { useMemo } from 'react'
import { Badge } from 'ui'
import { getResourcesExceededLimits } from 'components/ui/OveragesBanner/OveragesBanner.utils'
import { useProjectReadOnlyQuery } from 'data/config/project-read-only-query'
import {
getResourcesExceededLimits,
getResourcesExceededLimitsOrg,
} from 'components/ui/OveragesBanner/OveragesBanner.utils'
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
import { useOrgUsageQuery } from 'data/usage/org-usage-query'
import { useProjectUsageQuery } from 'data/usage/project-usage-query'
import { useFlag, useSelectedOrganization, useSelectedProject } from 'hooks'
import { IS_PLATFORM } from 'lib/constants'
@@ -13,39 +18,55 @@ import HelpPopover from './HelpPopover'
import NotificationsPopover from './NotificationsPopover'
import OrgDropdown from './OrgDropdown'
import ProjectDropdown from './ProjectDropdown'
import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query'
const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder = true }: any) => {
const selectedOrganization = useSelectedOrganization()
const selectedProject = useSelectedProject()
const { ref: projectRef } = useParams()
const { data: isReadOnlyMode } = useProjectReadOnlyQuery({
projectRef: selectedProject?.ref,
connectionString: selectedProject?.connectionString,
})
const selectedProject = useSelectedProject()
const selectedOrganization = useSelectedOrganization()
// Skip with org-based-billing, as quota is for the entire org
const { data: usage } = useProjectUsageQuery(
const { data: projectUsage } = useProjectUsageQuery(
{ projectRef },
{ enabled: Boolean(selectedOrganization && !selectedOrganization.subscription_id) }
)
const { data: orgUsage } = useOrgUsageQuery(
{ orgSlug: selectedOrganization?.slug },
{ enabled: Boolean(selectedOrganization && selectedOrganization.subscription_id) }
)
const exceedingLimits = useMemo(() => {
if (orgUsage) {
return getResourcesExceededLimitsOrg(orgUsage?.usages || []).length > 0
} else if (projectUsage) {
return getResourcesExceededLimits(projectUsage).length > 0
} else {
return false
}
}, [projectUsage, orgUsage])
// Skip with org-based-billing, as quota is for the entire org
const { data: projectSubscription } = useProjectSubscriptionV2Query(
{ projectRef },
{ enabled: selectedOrganization && !selectedOrganization.subscription_id }
)
const resourcesExceededLimits = getResourcesExceededLimits(usage)
// Skip with org-based-billing, as quota is for the entire org
const { data: subscription } = useProjectSubscriptionV2Query(
const { data: orgSubscription } = useProjectSubscriptionV2Query(
{ projectRef },
{ enabled: selectedOrganization && !selectedOrganization.subscription_id }
{ enabled: Boolean(selectedOrganization && selectedOrganization.subscription_id) }
)
const subscription = useMemo(() => {
return projectSubscription || orgSubscription
}, [projectSubscription, orgSubscription])
const projectHasNoLimits = subscription?.usage_billing_enabled === true
const showOverUsageBadge =
useFlag('overusageBadge') &&
subscription !== undefined &&
(subscription.plan.id === 'free' || subscription?.plan.id === 'pro') &&
(subscription.plan.id === 'free' || subscription.plan.id === 'pro') &&
!projectHasNoLimits &&
resourcesExceededLimits.length > 0
exceedingLimits
return (
<div
@@ -81,21 +102,15 @@ const LayoutHeader = ({ customHeaderComponents, breadcrumbs = [], headerBorder =
{/* Project Dropdown */}
<ProjectDropdown />
{/* [Terry] Temporary until we figure out how we want to display this permanently */}
{/* context: https://www.notion.so/supabase/DB-Disk-Size-Free-tier-Read-only-Critical-f2b8937c13a149e3ac769fe5888f6db0*/}
{isReadOnlyMode && (
<div className="ml-2">
<Link href={`/project/${projectRef}/settings/billing/usage`}>
<a>
<Badge color="red">Project is in read-only mode</Badge>
</a>
</Link>
</div>
)}
{showOverUsageBadge && (
<div className="ml-2">
<Link href={`/project/${projectRef}/settings/billing/usage`}>
<Link
href={
selectedOrganization?.subscription_id
? `/org/${selectedOrganization.slug}/usage`
: `/project/${projectRef}/settings/billing/usage`
}
>
<a>
<Badge color="red">Exceeding usage limits</Badge>
</a>
@@ -4,8 +4,10 @@ import { Fragment, PropsWithChildren, ReactNode } from 'react'
import { useParams } from 'common/hooks'
import Connecting from 'components/ui/Loading'
import ResourceExhaustionWarningBanner from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner'
import { useFlag, useSelectedOrganization, useSelectedProject, withAuth } from 'hooks'
import { PROJECT_STATUS, IS_PLATFORM } from 'lib/constants'
import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants'
import AppLayout from '../AppLayout/AppLayout'
import BuildingState from './BuildingState'
import ConnectingState from './ConnectingState'
import LayoutHeader from './LayoutHeader'
@@ -16,7 +18,6 @@ import { ProjectContextProvider } from './ProjectContext'
import ProjectPausedState from './ProjectPausedState'
import RestoringState from './RestoringState'
import UpgradingState from './UpgradingState'
import AppLayout from '../AppLayout/AppLayout'
// [Joshen] This is temporary while we unblock users from managing their project
// if their project is not responding well for any reason. Eventually needs a bit of an overhaul
@@ -70,6 +71,7 @@ const ProjectLayout = ({
const organizationName = selectedOrganization?.name
const navLayoutV2 = useFlag('navigationLayoutV2')
const showResourceExhaustionWarnings = useFlag('resourceExhaustionWarnings')
const isPaused = selectedProject?.status === PROJECT_STATUS.INACTIVE
const ignorePausedState =
@@ -96,14 +98,12 @@ const ProjectLayout = ({
<div className="flex h-full">
{/* Left-most navigation side bar to access products */}
{!hideIconBar && <NavigationBar />}
{/* Product menu bar */}
{!showPausedState && (
<MenuBarWrapper isLoading={isLoading} productMenu={productMenu}>
<ProductMenuBar title={product}>{productMenu}</ProductMenuBar>
</MenuBarWrapper>
)}
<main className="flex flex-col flex-1 w-full overflow-x-hidden">
{!navLayoutV2 && !hideHeader && IS_PLATFORM && <LayoutHeader />}
{showPausedState ? (
@@ -113,7 +113,10 @@ const ProjectLayout = ({
</div>
</div>
) : (
<ContentWrapper isLoading={isLoading}>{children}</ContentWrapper>
<ContentWrapper isLoading={isLoading}>
{showResourceExhaustionWarnings && <ResourceExhaustionWarningBanner />}
{children}
</ContentWrapper>
)}
</main>
</div>
@@ -194,7 +197,7 @@ const ContentWrapper = ({ isLoading, children }: ContentWrapperProps) => {
) : requiresDbConnection && isProjectRestoring ? (
<RestoringState />
) : requiresDbConnection && isProjectBuilding ? (
<BuildingState project={selectedProject} />
<BuildingState />
) : (
<Fragment key={selectedProject?.ref}>{children}</Fragment>
)}
@@ -224,6 +227,7 @@ export const ProjectLayoutNonBlocking = ({
const showPausedState = isPaused && !ignorePausedState
const navLayoutV2 = useFlag('navigationLayoutV2')
const showResourceExhaustionWarnings = useFlag('resourceExhaustionWarnings')
return (
<AppLayout>
@@ -251,7 +255,10 @@ export const ProjectLayoutNonBlocking = ({
</div>
</div>
) : (
children
<>
{showResourceExhaustionWarnings && <ResourceExhaustionWarningBanner />}
{children}
</>
)}
</main>
</div>
@@ -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={[
+2 -2
View File
@@ -27,7 +27,7 @@ const CardButton = ({
imgAlt,
onClick,
icon,
containerHeightClassName = 'h-32',
containerHeightClassName = '',
loading = false,
}: PropsWithChildren<CardButtonProps>) => {
const LinkContainer = ({ children }: { children: React.ReactNode }) => (
@@ -47,7 +47,7 @@ const CardButton = ({
'group relative text-left',
'bg-panel-header-light dark:bg-panel-header-dark',
'border border-panel-border-light dark:border-panel-border-dark',
'rounded-md py-4 px-6 flex flex-row',
'rounded-md py-4 flex flex-row',
'transition ease-in-out duration-150',
containerHeightClassName,
]
+28 -21
View File
@@ -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}
@@ -1,5 +1,6 @@
import { compact } from 'lodash'
import { USAGE_APPROACHING_THRESHOLD } from 'lib/constants'
import { OrgUsageResponse, UsageMetric } from 'data/usage/org-usage-query'
export const getResourcesApproachingLimits = (usages: any) => {
if (!usages) return []
@@ -22,7 +23,21 @@ export const getResourcesExceededLimits = (usages: any) => {
.filter((resourceName) => usages[resourceName] !== null)
.map((resourceName) => {
const resource = usages[resourceName]
if (resource.limit > 0 && (resource.usage / resource.limit) > 1) return resourceName
if (resource.limit > 0 && resource.usage / resource.limit > 1) return resourceName
})
)
}
export const getResourcesExceededLimitsOrg = (usageMetrics: UsageMetric[]): string[] => {
if (!usageMetrics.length) return []
return usageMetrics
.filter((usageMetric) => {
if (!usageMetric.capped || !usageMetric.available_in_plan || usageMetric.unlimited) return false
const freeUnits = usageMetric.pricing_free_units || 0
return usageMetric.usage > freeUnits
})
.map((it) => it.metric)
}
@@ -0,0 +1,170 @@
export const RESOURCE_WARNING_MESSAGES = {
is_readonly_mode_enabled: {
bannerContent: {
warning: {
title:
'Your project is currently in read-only mode and is no longer accepting write requests',
description:
'You will need to manually override read-only mode and reduce the disk size to below 95%',
},
critical: {
title:
'Your project is currently in read-only mode and is no longer accepting write requests',
description:
'You will need to manually override read-only mode and reduce the disk size to below 95%',
},
},
cardContent: {
warning: {
title: 'Project is in read-only mode',
description: 'Database is no longer accepting write requests.',
},
critical: {
title: 'Project is in read-only mode',
description: 'Database is no longer accepting write requests.',
},
},
docsUrl: 'https://supabase.com/docs/guides/platform/database-size#disabling-read-only-mode',
buttonText: undefined,
metric: undefined,
},
disk_io_exhaustion: {
bannerContent: {
warning: {
title:
'Your project is about to deplete its Disk IO Budget, and may become unresponsive once fully exhausted',
description:
'You will need to optimize your performance or upgrade your compute. Check the usage page for more recent and detailed statistics.',
},
critical: {
title: 'Your project has depleted its Disk IO Budget, and may become unresponsive',
description:
'You will need to optimize your performance or upgrade your compute. Check the usage page for more recent and detailed statistics.',
},
},
cardContent: {
warning: {
title: 'Project is depleting its Disk IO Budget',
description: 'It may become unresponsive if fully exhausted',
},
critical: {
title: 'Project has depleted its Disk IO Budget',
description: 'It may become unresponsive',
},
},
docsUrl: 'https://supabase.com/docs/guides/platform/exhaust-disk-io',
buttonText: 'Check usage',
metric: 'disk_io',
},
disk_space_exhaustion: {
bannerContent: {
warning: {
title:
'Your project is about to exhaust its disk space budget, and may become unresponsive once fully exhausted',
description:
'You can opt to increase your disk size up to 200GB on the database settings page.',
},
critical: {
title: 'Your project has exhausted its disk space budget, and may become unresponsive',
description:
'You can opt to increase your disk size up to 200GB on the database settings page.',
},
},
cardContent: {
warning: {
title: 'Project is exhausting disk space budget',
description: 'It may become unresponsive if fully exhausted',
},
critical: {
title: 'Project has exhausted disk space budget',
description: 'It may become unresponsive',
},
},
docsUrl: 'https://supabase.com/docs/guides/platform/database-size#disk-management',
buttonText: undefined,
metric: 'disk_space',
},
cpu_exhaustion: {
bannerContent: {
warning: {
title: 'Your project is currently facing high CPU usage, and its performance is affected',
description:
'You will need to optimize your performance or upgrade your compute. Check the usage page for more recent and detailed statistics.',
},
critical: {
title: "Your project's CPU usage is at 100% and its performance is affected",
description:
'You will need to optimize your performance or upgrade your compute. Check the usage page for more recent and detailed statistics.',
},
},
cardContent: {
warning: {
title: 'Project has high CPU usage',
description: `Performance is affected`,
},
critical: {
title: 'Project CPU usage is at 100%',
description: `Performance is affected`,
},
},
docsUrl: 'https://supabase.com/docs/guides/platform/exhaust-cpu',
buttonText: 'Check usage',
metric: 'cpu',
},
memory_and_swap_exhaustion: {
bannerContent: {
warning: {
title:
'Your project is currently facing high memory usage, and its performance is affected',
description:
'You will need to optimize your performance or upgrade your compute. Check the usage page for more recent and detailed statistics.',
},
critical: {
title: "Your project's memory usage is at 100%, and its performance is affected",
description:
'You will need to optimize your performance or upgrade your compute. Check the usage page for more recent and detailed statistics.',
},
},
cardContent: {
warning: {
title: 'Project has high memory usage',
description: `Performance is affected`,
},
critical: {
title: 'Project memory usage is at 100%',
description: `Performance is affected`,
},
},
docsUrl: 'https://supabase.com/docs/guides/platform/exhaust-ram',
buttonText: 'Check usage',
metric: 'ram',
},
multiple_resource_warnings: {
bannerContent: {
warning: {
title:
'Your project is currently exhausting multiple resources, and its performance is affected',
description:
"Check which resources are reaching their threshold on your project's usage page.",
},
critical: {
title: 'Your project has exhausted multiple resources, and its performance is affected',
description:
"Check which resources have reached their threshold on your project's usage page.",
},
},
cardContent: {
warning: {
title: 'Project is exhausting multiple resources',
description: `Performance is affected.`,
},
critical: {
title: 'Project has exhausted multiple resources',
description: `Performance is affected.`,
},
},
docsUrl: undefined,
buttonText: 'Check usage',
metric: null,
},
}
@@ -0,0 +1,137 @@
import { useParams } from 'common'
import Link from 'next/link'
import { useRouter } from 'next/router'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
IconAlertTriangle,
IconExternalLink,
} from 'ui'
import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query'
import { useSelectedOrganization } from 'hooks'
import { RESOURCE_WARNING_MESSAGES } from './ResourceExhaustionWarningBanner.constants'
import { getWarningContent } from './ResourceExhaustionWarningBanner.utils'
const ResourceExhaustionWarningBanner = () => {
const { ref } = useParams()
const router = useRouter()
const organization = useSelectedOrganization()
const { data: resourceWarnings } = useResourceWarningsQuery()
const projectResourceWarnings = (resourceWarnings ?? [])?.find(
(warning) => warning.project === ref
)
const isOrgBilling = !!organization?.subscription_id
// [Joshen] Read only takes higher precedence over multiple resource warnings
const activeWarnings =
projectResourceWarnings !== undefined
? projectResourceWarnings.is_readonly_mode_enabled
? ['is_readonly_mode_enabled']
: Object.keys(projectResourceWarnings).filter(
(property) =>
property !== 'project' &&
property !== 'is_readonly_mode_enabled' &&
projectResourceWarnings[property as keyof typeof projectResourceWarnings] !== null
)
: []
const hasCriticalWarning =
projectResourceWarnings !== undefined
? activeWarnings.some(
(x) => projectResourceWarnings[x as keyof typeof projectResourceWarnings] === 'critical'
)
: false
const isCritical = activeWarnings.includes('is_readonly_mode_enabled') || hasCriticalWarning
const title =
activeWarnings.length > 1
? RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.bannerContent[
hasCriticalWarning ? 'critical' : 'warning'
].title
: projectResourceWarnings !== undefined
? getWarningContent(projectResourceWarnings, activeWarnings[0], 'bannerContent')?.title
: null
const description =
activeWarnings.length > 1
? RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.bannerContent[
hasCriticalWarning ? 'critical' : 'warning'
].description
: projectResourceWarnings !== undefined
? getWarningContent(projectResourceWarnings, activeWarnings[0], 'bannerContent')?.description
: null
const learnMoreUrl =
activeWarnings.length > 1
? RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.docsUrl
: RESOURCE_WARNING_MESSAGES[activeWarnings[0] as keyof typeof RESOURCE_WARNING_MESSAGES]
?.docsUrl
const metric =
activeWarnings.length > 1
? RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.metric
: RESOURCE_WARNING_MESSAGES[activeWarnings[0] as keyof typeof RESOURCE_WARNING_MESSAGES]
?.metric
const correctionUrl = (
metric === undefined
? undefined
: metric === null
? '/project/[ref]/settings/[infra-path]'
: metric === 'disk_space'
? '/project/[ref]/settings/database'
: `/project/[ref]/settings/[infra-path]#${metric}`
)
?.replace('[ref]', ref ?? 'default')
?.replace('[infra-path]', isOrgBilling ? 'infrastructure' : 'billing/usage')
const buttonText =
activeWarnings.length > 1
? RESOURCE_WARNING_MESSAGES.multiple_resource_warnings.buttonText
: RESOURCE_WARNING_MESSAGES[activeWarnings[0] as keyof typeof RESOURCE_WARNING_MESSAGES]
?.buttonText
// Don't show banner if no warnings, or on usage/infra page
if (
activeWarnings.length === 0 ||
((router.pathname.endsWith('/usage') || router.pathname.endsWith('/infrastructure')) &&
!activeWarnings.includes('is_readonly_mode_enabled'))
)
return null
return (
<Alert_Shadcn_
variant={isCritical ? 'destructive' : 'warning'}
className="border-0 border-r-0 rounded-none [&>svg]:left-6 px-6 [&>svg]:w-[26px]
[&>svg]:h-[26px]"
>
<IconAlertTriangle />
<AlertTitle_Shadcn_>{title}</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>{description}</AlertDescription_Shadcn_>
<div className="absolute top-5 right-5 flex items-center space-x-2">
{learnMoreUrl !== undefined && (
<Link passHref href={learnMoreUrl}>
<a>
<Button type="default" icon={<IconExternalLink />}>
Learn more
</Button>
</a>
</Link>
)}
{correctionUrl !== undefined && (
<Link passHref href={correctionUrl}>
<a>
<Button type="default">{buttonText ?? 'Check'}</Button>
</a>
</Link>
)}
</div>
</Alert_Shadcn_>
)
}
export default ResourceExhaustionWarningBanner
@@ -0,0 +1,19 @@
import { ResourceWarning } from 'data/usage/resource-warnings-query'
import { RESOURCE_WARNING_MESSAGES } from './ResourceExhaustionWarningBanner.constants'
export const getWarningContent = (
resourceWarnings: ResourceWarning,
metric: string,
contentType: 'cardContent' | 'bannerContent'
) => {
if (metric === 'is_readonly_mode_enabled') {
return RESOURCE_WARNING_MESSAGES.is_readonly_mode_enabled.cardContent.warning
}
const severity = resourceWarnings[metric as keyof typeof resourceWarnings]
if (typeof severity !== 'string') return undefined
return RESOURCE_WARNING_MESSAGES[metric as keyof typeof RESOURCE_WARNING_MESSAGES][contentType][
severity as 'warning' | 'critical'
]
}
@@ -1,55 +0,0 @@
import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query'
import { get } from 'lib/common/fetch'
import { API_ADMIN_URL } from 'lib/constants'
import { useCallback } from 'react'
import { projectKeys } from './keys'
export type ProjectReadOnlyStatusVariables = { projectRef?: string }
export type ProjectReadOnlyStatus = {
enabled: boolean
override_enabled: boolean
override_active_until: string
}
export async function getProjectReadOnlyStatus(
{ projectRef }: ProjectReadOnlyStatusVariables,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('Project ref is required')
const data = await get(`${API_ADMIN_URL}/projects/${projectRef}/readonly`, { signal })
if (data.error) throw data.error
return data as ProjectReadOnlyStatus
}
export type ProjectReadOnlyStatusData = Awaited<ReturnType<typeof getProjectReadOnlyStatus>>
export type ProjectReadOnlyStatusError = unknown
export const useProjectReadOnlyStatusQuery = <TData = ProjectReadOnlyStatusData>(
{ projectRef }: ProjectReadOnlyStatusVariables,
{
enabled = true,
...options
}: UseQueryOptions<ProjectReadOnlyStatusData, ProjectReadOnlyStatusError, TData> = {}
) =>
useQuery<ProjectReadOnlyStatusData, ProjectReadOnlyStatusError, TData>(
projectKeys.readonlyStatus(projectRef),
({ signal }) => getProjectReadOnlyStatus({ projectRef }, signal),
{
enabled: enabled && typeof projectRef !== 'undefined',
...options,
}
)
export const useProjectReadOnlyStatusPrefetch = ({
projectRef,
}: ProjectReadOnlyStatusVariables) => {
const client = useQueryClient()
return useCallback(() => {
if (projectRef) {
client.prefetchQuery(projectKeys.readonlyStatus(projectRef), ({ signal }) =>
getProjectReadOnlyStatus({ projectRef }, signal)
)
}
}, [projectRef])
}
@@ -1,110 +0,0 @@
import { QueryClient, useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query'
import { useCallback, useRef } from 'react'
import { get } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import { Project, ResponseError } from 'types'
import { projectKeys } from './keys'
export type ProjectsReadonlyStatusesVariables = {
ref?: string
}
export async function getProjectsReadonlyStatuses(signal?: AbortSignal) {
const data = await get(`${API_URL}/projects?include_readonly_mode_status=true`, { signal })
if (data.error) throw data.error
return Object.fromEntries(
data.map((project: any) => [project.ref, project.is_readonly_mode_enabled])
) as {
[k: string]: boolean
}
}
export type ProjectsReadonlyStatusesData = Awaited<ReturnType<typeof getProjectsReadonlyStatuses>>
export type ProjectsReadonlyStatusesError = ResponseError
export const useProjectsReadonlyStatusesQuery = <TData = ProjectsReadonlyStatusesData>({
enabled = true,
...options
}: UseQueryOptions<ProjectsReadonlyStatusesData, ProjectsReadonlyStatusesError, TData> = {}) =>
useQuery<ProjectsReadonlyStatusesData, ProjectsReadonlyStatusesError, TData>(
projectKeys.readonlyStatusList(),
({ signal }) => getProjectsReadonlyStatuses(signal),
{ enabled: enabled, ...options }
)
export function prefetchProjectsReadonlyStatuses(client: QueryClient) {
return client.prefetchQuery(projectKeys.readonlyStatusList(), ({ signal }) =>
getProjectsReadonlyStatuses(signal)
)
}
export function useProjectsReadonlyStatusesPrefetch() {
const client = useQueryClient()
return useCallback(() => {
prefetchProjectsReadonlyStatuses(client)
}, [client])
}
export function useAutoProjectsReadonlyStatusesPrefetch() {
const prefetch = useProjectsReadonlyStatusesPrefetch()
const called = useRef<boolean>(false)
if (called.current === false) {
called.current = true
prefetch()
}
}
export function invalidateProjectsReadonlyStatusesQuery(client: QueryClient) {
return client.invalidateQueries(projectKeys.readonlyStatusList())
}
export function setProjectStatus(
client: QueryClient,
projectRef: Project['ref'],
status: Project['status']
) {
client.setQueriesData<Project[] | undefined>(
projectKeys.readonlyStatusList(),
(old) => {
if (!old) return old
return old.map((project) => {
if (project.ref === projectRef) {
return { ...project, status }
}
return project
})
},
{ updatedAt: Date.now() }
)
client.setQueriesData<Project>(
projectKeys.detail(projectRef),
(old) => {
if (!old) return old
return { ...old, status }
},
{ updatedAt: Date.now() }
)
}
export function setProjectPostgrestStatus(
client: QueryClient,
projectRef: Project['ref'],
status: Project['postgrestStatus']
) {
client.setQueriesData<Project>(
projectKeys.detail(projectRef),
(old) => {
if (!old) return old
return { ...old, postgrestStatus: status }
},
{ updatedAt: Date.now() }
)
}
+1
View File
@@ -1,4 +1,5 @@
export const usageKeys = {
usage: (projectRef: string | undefined) => ['projects', projectRef, 'usage'] as const,
orgUsage: (orgSlug: string | undefined) => ['organizations', orgSlug, 'usage'] as const,
resourceWarnings: () => ['projects', 'resource-warnings'] as const,
}
@@ -0,0 +1,31 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { get } from 'data/fetchers'
import { usageKeys } from './keys'
import { ResponseError } from 'types'
import { components } from 'data/api'
export async function getResourceWarnings(signal?: AbortSignal) {
const { data, error } = await get(`/platform/projects-resource-warnings`, { signal })
if (error) throw error
return data
}
export type ResourceWarning = components['schemas']['ProjectResourceWarningsResponse']
export type ResourceWarningsData = Awaited<ReturnType<typeof getResourceWarnings>>
export type ResourceWarningsError = ResponseError
export const useResourceWarningsQuery = <TData = ResourceWarningsData>({
enabled = true,
...options
}: UseQueryOptions<ResourceWarningsData, ResourceWarningsError, TData> = {}) =>
useQuery<ResourceWarningsData, ResourceWarningsError, TData>(
usageKeys.resourceWarnings(),
({ signal }) => getResourceWarnings(signal),
{
enabled,
staleTime: 1000 * 60 * 30, // default 30 minutes
...options,
}
)
@@ -1,9 +0,0 @@
import { useProjectsReadonlyStatusesQuery } from 'data/projects/projects-readonly-status-query'
export function useProjectReadOnlyStatus(projectRef?: string) {
const { data } = useProjectsReadonlyStatusesQuery()
if (!projectRef) return false
return data?.[projectRef] ?? false
}
@@ -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/)
})