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

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

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

Pricing

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

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

- diff --git a/package-lock.json b/package-lock.json index 288adec0c22..54e70da1f98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/packages/common/gotrue.ts b/packages/common/gotrue.ts index 22b0b68aeb8..11507565ad1 100644 --- a/packages/common/gotrue.ts +++ b/packages/common/gotrue.ts @@ -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((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, }) diff --git a/packages/common/package.json b/packages/common/package.json index b31b97019b8..e425cf31945 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -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" diff --git a/studio/__mocks__/data/fetchers.js b/studio/__mocks__/data/fetchers.js new file mode 100644 index 00000000000..2331502c3d9 --- /dev/null +++ b/studio/__mocks__/data/fetchers.js @@ -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() diff --git a/studio/components/interfaces/BillingV2/Subscription/AddOns/AddOns.tsx b/studio/components/interfaces/BillingV2/Subscription/AddOns/AddOns.tsx index 3d7c6d34e0d..444317ed056 100644 --- a/studio/components/interfaces/BillingV2/Subscription/AddOns/AddOns.tsx +++ b/studio/components/interfaces/BillingV2/Subscription/AddOns/AddOns.tsx @@ -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') diff --git a/studio/components/interfaces/BillingV2/Usage/Infrastructure.tsx b/studio/components/interfaces/BillingV2/Usage/Infrastructure.tsx index fdb0948c26c..39c3c24459a 100644 --- a/studio/components/interfaces/BillingV2/Usage/Infrastructure.tsx +++ b/studio/components/interfaces/BillingV2/Usage/Infrastructure.tsx @@ -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 = ({ {attribute.key === 'disk_io_consumption' && ( <> - {hasLatest && latestIoBudgetConsumption >= 100 ? ( - -

- 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. -

- -
- - - - - ) : hasLatest && latestIoBudgetConsumption >= 80 ? ( - -

- 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. -

- - - - - -
- ) : currentBillingCycleSelected && highestIoBudgetConsumption >= 100 ? ( - -

- 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. -

- - - - - -
- ) : currentBillingCycleSelected && highestIoBudgetConsumption >= 80 ? ( - -

- 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. -

- - - - - -
- ) : null} +

Disk IO Bandwidth

@@ -246,6 +182,21 @@ const Infrastructure = ({
)} + {attribute.key === 'max_cpu_usage' && ( + + )} + {attribute.key === 'ram_usage' && ( + + )} +
{attribute.key === 'disk_io_consumption' ? ( diff --git a/studio/components/interfaces/BillingV2/Usage/Usage.tsx b/studio/components/interfaces/BillingV2/Usage/Usage.tsx index d768880161b..3f1d04a8f8d 100644 --- a/studio/components/interfaces/BillingV2/Usage/Usage.tsx +++ b/studio/components/interfaces/BillingV2/Usage/Usage.tsx @@ -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' diff --git a/studio/components/interfaces/BillingV2/Usage/UsageWarningAlerts/CPUWarnings.tsx b/studio/components/interfaces/BillingV2/Usage/UsageWarningAlerts/CPUWarnings.tsx new file mode 100644 index 00000000000..8a207e12518 --- /dev/null +++ b/studio/components/interfaces/BillingV2/Usage/UsageWarningAlerts/CPUWarnings.tsx @@ -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 ( + + + Your max CPU usage has exceeded 80% + + 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. + + + + ) + } + + if (severity === 'critical') { + return ( + + + Your max CPU usage has reached 100% + + 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. + + + + ) + } + + return null +} + +export default CPUWarnings diff --git a/studio/components/interfaces/BillingV2/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings.tsx b/studio/components/interfaces/BillingV2/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings.tsx new file mode 100644 index 00000000000..6d257ac3f10 --- /dev/null +++ b/studio/components/interfaces/BillingV2/Usage/UsageWarningAlerts/DiskIOBandwidthWarnings.tsx @@ -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 ( + +

+ 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. +

+ + + + + +
+ ) + } + + if (hasLatest && latestIoBudgetConsumption >= 80) { + return ( + +

+ 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. +

+ + + + + +
+ ) + } + + if (currentBillingCycleSelected && highestIoBudgetConsumption >= 100) { + return ( + +

+ 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. +

+ + + + + +
+ ) + } + + if (currentBillingCycleSelected && highestIoBudgetConsumption >= 80) { + return ( + +

+ 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. +

+ + + + + +
+ ) + } + + return null +} + +export default DiskIOBandwidthWarnings diff --git a/studio/components/interfaces/BillingV2/Usage/UsageWarningAlerts/RAMWarnings.tsx b/studio/components/interfaces/BillingV2/Usage/UsageWarningAlerts/RAMWarnings.tsx new file mode 100644 index 00000000000..fe233f08695 --- /dev/null +++ b/studio/components/interfaces/BillingV2/Usage/UsageWarningAlerts/RAMWarnings.tsx @@ -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 ( + + + Your memory usage has exceeded 80% + + 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. + + + + ) + } + + if (severity === 'critical') { + return ( + + + Your memory usage has reached 100% + + 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. + + + + ) + } + + return null +} + +export default RAMWarnings diff --git a/studio/components/interfaces/BillingV2/Usage/UsageWarningAlerts/index.ts b/studio/components/interfaces/BillingV2/Usage/UsageWarningAlerts/index.ts new file mode 100644 index 00000000000..3b3afe3f30e --- /dev/null +++ b/studio/components/interfaces/BillingV2/Usage/UsageWarningAlerts/index.ts @@ -0,0 +1,5 @@ +import CPUWarnings from './CPUWarnings' +import DiskIOBandwidthWarnings from './DiskIOBandwidthWarnings' +import RAMWarnings from './RAMWarnings' + +export { CPUWarnings, DiskIOBandwidthWarnings, RAMWarnings } diff --git a/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx b/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx index 28719add668..a97bc84152e 100644 --- a/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx +++ b/studio/components/interfaces/Home/ProjectList/ProjectCard.tsx @@ -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 (
  • -

    {name}

    +
    +

    {name}

    + {desc}
    {isVercelIntegrated && (
    @@ -77,55 +68,16 @@ const ProjectCard = ({
    } footer={ -
    - {desc} - - {isHealthy && isReadonly && ( -
    - -
    - - Read-only mode -
    -
    -
    - )} - - {isRestoring && ( -
    - -
    - - Restoring -
    -
    -
    - )} - - {isPausing && ( -
    - -
    - - Pausing -
    -
    -
    - )} - - {isPaused && ( -
    - -
    - - Paused -
    -
    -
    +
    + {resourceWarnings && ( + )}
    } - /> + >
  • ) } diff --git a/studio/components/interfaces/Home/ProjectList/ProjectCard.utils.ts b/studio/components/interfaces/Home/ProjectList/ProjectCard.utils.ts new file mode 100644 index 00000000000..ea1ab6b0872 --- /dev/null +++ b/studio/components/interfaces/Home/ProjectList/ProjectCard.utils.ts @@ -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 diff --git a/studio/components/interfaces/Home/ProjectList/ProjectCardStatus.tsx b/studio/components/interfaces/Home/ProjectList/ProjectCardStatus.tsx new file mode 100644 index 00000000000..bc889b3c721 --- /dev/null +++ b/studio/components/interfaces/Home/ProjectList/ProjectCardStatus.tsx @@ -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
    + + return ( +
    + + {projectStatus === 'isPaused' || projectStatus === 'isPausing' ? ( + + ) : projectStatus === 'isRestoring' || projectStatus === 'isComingUp' ? ( + + ) : ( + + )} + {alertTitle} + {alertDescription} + +
    + ) +} diff --git a/studio/components/interfaces/Home/ProjectList/ProjectList.tsx b/studio/components/interfaces/Home/ProjectList/ProjectList.tsx index 2ba1bdf6de7..ab938e7de38 100644 --- a/studio/components/interfaces/Home/ProjectList/ProjectList.tsx +++ b/studio/components/interfaces/Home/ProjectList/ProjectList.tsx @@ -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 + )} /> )) )} diff --git a/studio/components/interfaces/Organization/BillingSettingsV2/BillingBreakdown/BillingBreakdown.tsx b/studio/components/interfaces/Organization/BillingSettingsV2/BillingBreakdown/BillingBreakdown.tsx index 44049b6780a..c806533ad1b 100644 --- a/studio/components/interfaces/Organization/BillingSettingsV2/BillingBreakdown/BillingBreakdown.tsx +++ b/studio/components/interfaces/Organization/BillingSettingsV2/BillingBreakdown/BillingBreakdown.tsx @@ -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={[ - , ]} diff --git a/studio/components/interfaces/Organization/Usage/Usage.tsx b/studio/components/interfaces/Organization/Usage/Usage.tsx index 66157592ef6..34942cc2873 100644 --- a/studio/components/interfaces/Organization/Usage/Usage.tsx +++ b/studio/components/interfaces/Organization/Usage/Usage.tsx @@ -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 ( <> @@ -156,6 +167,33 @@ const Usage = () => {
    + {!selectedProject && subscription && hasExceededAnyLimits && ( + + + + , + ]} + > + 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.'} + + + )} + {selectedProjectRef && ( = { title: '', queries: {}, }, + [Presets.STORAGE]: { + title: 'Storage', + queries: { + cacheHitRate: { + queryType: 'logs', + // storage report does not perform any filtering + sql: (_filters) => ` +-- cache-hit-rate +SELECT + timestamp_trunc(timestamp, hour) as timestamp, + countif( h.cf_cache_status in ('HIT', 'STALE', 'REVALIDATED', 'UPDATING') ) as hit_count, + countif( h.cf_cache_status in ('MISS', 'NONE/UNKNOWN', 'EXPIRED', 'BYPASS', 'DYNAMIC') ) as miss_count +from edge_logs f + cross join unnest(f.metadata) as m + cross join unnest(m.request) as r + cross join unnest(m.response) as res + cross join unnest(res.headers) as h +where starts_with(r.path, '/storage/v1/object') and r.method = 'GET' +group by timestamp +order by timestamp desc +`, + }, + topCacheMisses: { + queryType: 'logs', + // storage report does not perform any filtering + sql: (_filters) => ` +-- top-cache-misses +SELECT + r.path as path, + r.search as search, + count(id) as count +from edge_logs f + cross join unnest(f.metadata) as m + cross join unnest(m.request) as r + cross join unnest(m.response) as res + cross join unnest(res.headers) as h +where starts_with(r.path, '/storage/v1/object') + and r.method = 'GET' + and h.cf_cache_status in ('MISS', 'NONE/UNKNOWN', 'EXPIRED', 'BYPASS', 'DYNAMIC') +group by path, search +order by count desc +limit 12 + `, + }, + }, + }, [Presets.QUERY_PERFORMANCE]: { title: 'Query performance', queries: { diff --git a/studio/components/interfaces/Reports/Reports.types.ts b/studio/components/interfaces/Reports/Reports.types.ts index a13240a5125..900a7c0c437 100644 --- a/studio/components/interfaces/Reports/Reports.types.ts +++ b/studio/components/interfaces/Reports/Reports.types.ts @@ -3,6 +3,7 @@ import { DEFAULT_QUERY_PARAMS } from './Reports.constants' export enum Presets { API = 'api', + STORAGE = 'storage', AUTH = 'auth', QUERY_PERFORMANCE = 'query_performance', } diff --git a/studio/components/interfaces/Reports/renderers/StorageRenderers.tsx b/studio/components/interfaces/Reports/renderers/StorageRenderers.tsx new file mode 100644 index 00000000000..cdff467d303 --- /dev/null +++ b/studio/components/interfaces/Reports/renderers/StorageRenderers.tsx @@ -0,0 +1,86 @@ +import { TextFormatter } from 'components/interfaces/Settings/Logs/LogsFormatters' +import Table from 'components/to-be-cleaned/Table' +import StackedBarChart from 'components/ui/Charts/StackedBarChart' +import { Fragment } from 'react' +import { ReportWidgetProps, ReportWidgetRendererProps } from '../ReportWidget' + +export const CacheHitRateChartRenderer = ( + props: ReportWidgetProps<{ + timestamp: string + hit_count: number + miss_count: number + }> +) => { + const stackedData = props.data.flatMap((datum) => [ + { + timestamp: datum.timestamp, + count: datum.hit_count, + type: 'hit', + }, + { + timestamp: datum.timestamp, + count: datum.miss_count, + type: 'miss', + }, + ]) + + return ( + + ) +} + +export const TopCacheMissesRenderer = ( + props: ReportWidgetRendererProps<{ + path: string + search: string + count: number + }> +) => { + if (props.data.length === 0) return null + const headerClasses = '!text-xs !py-2 p-0 font-bold !bg-scale-400' + const cellClasses = '!text-xs !py-2' + + return ( + <> +

    Top Cache Misses

    + + Request + Count + + } + body={ + <> + {props.data.map((datum) => ( + + + +
    + + +
    +
    + + {datum.count} + +
    +
    + ))} + + } + /> + + ) +} diff --git a/studio/components/interfaces/Settings/Addons/Addons.tsx b/studio/components/interfaces/Settings/Addons/Addons.tsx index 88035a87b70..edff68aac31 100644 --- a/studio/components/interfaces/Settings/Addons/Addons.tsx +++ b/studio/components/interfaces/Settings/Addons/Addons.tsx @@ -134,14 +134,14 @@ const Addons = () => {
    -

    Optimized compute

    +

    Optimized compute

    -

    More information

    +

    More information

    -

    About compute add-ons

    +

    About compute add-ons

    @@ -151,7 +151,7 @@ const Addons = () => {
    -

    Connection Pooler

    +

    Connection Pooler

    @@ -339,14 +339,14 @@ const Addons = () => {
    -

    Point in time recovery

    +

    Point in time recovery

    -

    More information

    +

    More information

    -

    About PITR backups

    +

    About PITR backups

    @@ -403,14 +403,14 @@ const Addons = () => {
    -

    Custom domain

    +

    Custom domain

    -

    More information

    +

    More information

    -

    About custom domains

    +

    About custom domains

    diff --git a/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx b/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx index caacdeee269..9a077356b21 100644 --- a/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx +++ b/studio/components/interfaces/Settings/Infrastructure/InfrastructureActivity.tsx @@ -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() - 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 = () => {
    -

    {attribute.name}

    +

    {attribute.name}

    {attribute.description.split('\n').map((value, idx) => ( -

    +

    {value}

    ))} @@ -199,7 +233,7 @@ const InfrastructureActivity = () => {
    -

    {link.name}

    +

    {link.name}

    @@ -212,46 +246,14 @@ const InfrastructureActivity = () => { {attribute.key === 'disk_io_consumption' && ( <> - {currentBillingCycleSelected && highestIoBudgetConsumption >= 100 ? ( - -

    - 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. -

    - - - - - -
    - ) : currentBillingCycleSelected && highestIoBudgetConsumption >= 80 ? ( - -

    - 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. -

    - - - - - -
    - ) : null} +

    Disk IO Bandwidth

    @@ -302,6 +304,21 @@ const InfrastructureActivity = () => {
    )} + {attribute.key === 'max_cpu_usage' && ( + + )} + {attribute.key === 'ram_usage' && ( + + )} +
    {attribute.key === 'disk_io_consumption' ? ( diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx index 55a3c587b11..ddecb240dce 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx @@ -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 ( { +const BuildingState = () => { const { ref } = useParams() + const project = useSelectedProject() const queryClient = useQueryClient() const checkServerInterval = useRef() @@ -48,6 +45,8 @@ const BuildingState = ({ project }: BuildingStateProps) => { } }, []) + if (project === undefined) return null + return (
    diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx index 05088113ee6..065d6f80e93 100644 --- a/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx +++ b/studio/components/layouts/ProjectLayout/LayoutHeader/LayoutHeader.tsx @@ -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 (
    - {/* [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 && ( - - )} - {showOverUsageBadge && (
    - + Exceeding usage limits diff --git a/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index 92bab3341d3..d5fb5038a7f 100644 --- a/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -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 = ({
    {/* Left-most navigation side bar to access products */} {!hideIconBar && } - {/* Product menu bar */} {!showPausedState && ( {productMenu} )} -
    {!navLayoutV2 && !hideHeader && IS_PLATFORM && } {showPausedState ? ( @@ -113,7 +113,10 @@ const ProjectLayout = ({
    ) : ( - {children} + + {showResourceExhaustionWarnings && } + {children} + )}
    @@ -194,7 +197,7 @@ const ContentWrapper = ({ isLoading, children }: ContentWrapperProps) => { ) : requiresDbConnection && isProjectRestoring ? ( ) : requiresDbConnection && isProjectBuilding ? ( - + ) : ( {children} )} @@ -224,6 +227,7 @@ export const ProjectLayoutNonBlocking = ({ const showPausedState = isPaused && !ignorePausedState const navLayoutV2 = useFlag('navigationLayoutV2') + const showResourceExhaustionWarnings = useFlag('resourceExhaustionWarnings') return ( @@ -251,7 +255,10 @@ export const ProjectLayoutNonBlocking = ({
    ) : ( - children + <> + {showResourceExhaustionWarnings && } + {children} + )}
    diff --git a/studio/components/layouts/ReportsLayout/ReportsLayout.tsx b/studio/components/layouts/ReportsLayout/ReportsLayout.tsx index f1517357776..0bdd0813a26 100644 --- a/studio/components/layouts/ReportsLayout/ReportsLayout.tsx +++ b/studio/components/layouts/ReportsLayout/ReportsLayout.tsx @@ -4,7 +4,7 @@ import { PropsWithChildren } from 'react' import ProductMenu from 'components/ui/ProductMenu' import { useSelectedProject, withAuth } from 'hooks' import ProjectLayout from '../' -import { generateReportsMenu } from './ReportsMenu.utils' +import { useFlag } from 'hooks' interface ReportsLayoutProps { title?: string @@ -15,12 +15,61 @@ const ReportsLayout = ({ title, children }: PropsWithChildren} + productMenu={} >
    {children} diff --git a/studio/components/layouts/ReportsLayout/ReportsMenu.utils.ts b/studio/components/layouts/ReportsLayout/ReportsMenu.utils.ts deleted file mode 100644 index 4e088fd0ae6..00000000000 --- a/studio/components/layouts/ReportsLayout/ReportsMenu.utils.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Project } from 'types' -import { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' - -export const generateReportsMenu = (project?: Project): ProductMenuGroup[] => { - const ref = project?.ref ?? 'default' - - return [ - { - title: '', - items: [ - { - name: 'Custom reports', - key: '', - url: `/project/${ref}/reports`, - items: [], - }, - ], - }, - { - items: [ - { - name: 'API', - key: 'api-overview', - url: `/project/${ref}/reports/api-overview`, - items: [], - label: 'NEW', - }, - { - name: 'Database', - key: 'database', - url: `/project/${ref}/reports/database`, - items: [], - }, - { - name: 'Query Performance', - key: 'query-performance', - url: `/project/${ref}/reports/query-performance`, - items: [], - label: 'NEW', - }, - ], - }, - ] -} diff --git a/studio/components/to-be-cleaned/Storage/StorageExplorer/CustomExpiryModal.tsx b/studio/components/to-be-cleaned/Storage/StorageExplorer/CustomExpiryModal.tsx index d20d137cdbc..82697ff1ce1 100644 --- a/studio/components/to-be-cleaned/Storage/StorageExplorer/CustomExpiryModal.tsx +++ b/studio/components/to-be-cleaned/Storage/StorageExplorer/CustomExpiryModal.tsx @@ -1,11 +1,21 @@ -import { Form, Modal, Input, Button } from 'ui' +import dayjs from 'dayjs' import { observer } from 'mobx-react-lite' +import { Button, Form, Input, Listbox, Modal } from 'ui' + +import { DATETIME_FORMAT } from 'lib/constants' import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore' export interface CustomExpiryModalProps { onCopyUrl: (name: string, url: string) => void } +const unitMap = { + days: 3600 * 24, + weeks: 3600 * 24 * 7, + months: 3600 * 24 * 30, + years: 3600 * 24 * 365, +} + const CustomExpiryModal = ({ onCopyUrl }: CustomExpiryModalProps) => { const storageExplorerStore = useStorageStore() const { getFileUrl, selectedFileCustomExpiry, setSelectedFileCustomExpiry } = storageExplorerStore @@ -25,34 +35,57 @@ const CustomExpiryModal = ({ onCopyUrl }: CustomExpiryModalProps) => { >
    { setSubmitting(true) onCopyUrl( selectedFileCustomExpiry.name, - await getFileUrl(selectedFileCustomExpiry, values.expiresIn) + await getFileUrl( + selectedFileCustomExpiry, + values.expiresIn * unitMap[values.units as 'days' | 'weeks' | 'months' | 'years'] + ) ) setSubmitting(false) onClose() }} validate={(values: any) => { const errors: any = {} - if (values.expiresIn !== '' && values.expiresIn <= 0) + if (values.expiresIn !== '' && values.expiresIn <= 0) { errors.expiresIn = 'Expiry duration cannot be less than 0' + } return errors }} > {({ values, isSubmitting }: { values: any; isSubmitting: boolean }) => ( <> -
    +
    - seconds

    } - /> +

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

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

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

    + )}
    diff --git a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx index ba4ee7bc678..36a73884997 100644 --- a/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx +++ b/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerRow.tsx @@ -409,6 +409,7 @@ const FileExplorerRow = ({ /> ) : ( ) => { 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, ] diff --git a/studio/components/ui/Charts/Charts.constants.ts b/studio/components/ui/Charts/Charts.constants.ts index d760c7bf7e6..f9161102054 100644 --- a/studio/components/ui/Charts/Charts.constants.ts +++ b/studio/components/ui/Charts/Charts.constants.ts @@ -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', diff --git a/studio/components/ui/Charts/Sparkline.stories.tsx b/studio/components/ui/Charts/Sparkline.stories.tsx index 5cb2e257683..1808d40a2d6 100644 --- a/studio/components/ui/Charts/Sparkline.stories.tsx +++ b/studio/components/ui/Charts/Sparkline.stories.tsx @@ -1,9 +1,7 @@ import React from 'react' -import { ComponentStory, ComponentMeta } from '@storybook/react' +import { ComponentMeta } from '@storybook/react' import dayjs from 'dayjs' import Chart from './Sparkline' -import { StackedChartProps } from './Charts.types' -import { STACK_COLORS } from './Charts.constants' export default { title: 'Charts/Sparkline', diff --git a/studio/components/ui/Charts/StackedBarChart.stories.tsx b/studio/components/ui/Charts/StackedBarChart.stories.tsx index 9eb71d42736..8a308ea5ffd 100644 --- a/studio/components/ui/Charts/StackedBarChart.stories.tsx +++ b/studio/components/ui/Charts/StackedBarChart.stories.tsx @@ -3,7 +3,7 @@ import { ComponentStory, ComponentMeta } from '@storybook/react' import dayjs from 'dayjs' import Chart from './StackedBarChart' import { StackedChartProps } from './Charts.types' -import { STACK_COLORS } from './Charts.constants' +import { DEFAULT_STACK_COLORS, genStackColorScales } from './Charts.constants' export default { title: 'Charts/StackedBarChart', @@ -38,7 +38,7 @@ export const StackedBarChart = () => (
    Default Stacked Chart Colors
    - {STACK_COLORS.map((c) => ( + {genStackColorScales(DEFAULT_STACK_COLORS).map((c) => (
    ))}
    diff --git a/studio/components/ui/Charts/StackedBarChart.tsx b/studio/components/ui/Charts/StackedBarChart.tsx index 4ce2b8e1ee8..3dbf7e214e0 100644 --- a/studio/components/ui/Charts/StackedBarChart.tsx +++ b/studio/components/ui/Charts/StackedBarChart.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { BarChart, Bar, XAxis, Tooltip, Legend, Cell, TooltipProps } from 'recharts' import ChartHeader from './ChartHeader' -import { CHART_COLORS, STACK_COLORS, DateTimeFormats } from './Charts.constants' +import { CHART_COLORS, DateTimeFormats, DEFAULT_STACK_COLORS, genStackColorScales, ValidStackColor } from './Charts.constants' import { CommonChartProps } from './Charts.types' import { timestampFormatter, useChartSize, useStacked } from './Charts.utils' import { precisionFormatter } from './Charts.utils' @@ -16,6 +16,7 @@ interface Props extends CommonChartProps { displayDateInUtc?: boolean hideLegend?: boolean hideHeader?: boolean + stackColors?: ValidStackColor[] } const StackedBarChart: React.FC = ({ size, @@ -33,6 +34,7 @@ const StackedBarChart: React.FC = ({ displayDateInUtc, hideLegend = false, hideHeader = false, + stackColors = DEFAULT_STACK_COLORS }) => { const { Container } = useChartSize(size) const { dataKeys, stackedData, percentagesStackedData } = useStacked({ @@ -44,6 +46,7 @@ const StackedBarChart: React.FC = ({ }) const [focusDataIndex, setFocusDataIndex] = useState(null) if (!data || data.length === 0) return + const stackColorScales = genStackColorScales(stackColors) return (
    {!hideHeader && ( @@ -94,7 +97,7 @@ const StackedBarChart: React.FC = ({ dataKey={datum} type="monotone" legendType="circle" - fill={STACK_COLORS[stackIndex].base} + fill={stackColorScales[stackIndex].base} stackId={1} animationDuration={300} maxBarSize={48} diff --git a/studio/components/ui/OveragesBanner/OveragesBanner.utils.ts b/studio/components/ui/OveragesBanner/OveragesBanner.utils.ts index 6c66916241f..69438ce4b20 100644 --- a/studio/components/ui/OveragesBanner/OveragesBanner.utils.ts +++ b/studio/components/ui/OveragesBanner/OveragesBanner.utils.ts @@ -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) +} diff --git a/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.constants.ts b/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.constants.ts new file mode 100644 index 00000000000..5ed9d61b5f9 --- /dev/null +++ b/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.constants.ts @@ -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, + }, +} diff --git a/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.tsx b/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.tsx new file mode 100644 index 00000000000..37df8e4bb7a --- /dev/null +++ b/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.tsx @@ -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 ( + + + {title} + {description} +
    + {learnMoreUrl !== undefined && ( + + + + + + )} + {correctionUrl !== undefined && ( + + + + + + )} +
    +
    + ) +} + +export default ResourceExhaustionWarningBanner diff --git a/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.utils.ts b/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.utils.ts new file mode 100644 index 00000000000..0b3dd8b77e7 --- /dev/null +++ b/studio/components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner.utils.ts @@ -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' + ] +} diff --git a/studio/data/projects/project-readonly-status-query.ts b/studio/data/projects/project-readonly-status-query.ts deleted file mode 100644 index bcd6274f02c..00000000000 --- a/studio/data/projects/project-readonly-status-query.ts +++ /dev/null @@ -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> -export type ProjectReadOnlyStatusError = unknown - -export const useProjectReadOnlyStatusQuery = ( - { projectRef }: ProjectReadOnlyStatusVariables, - { - enabled = true, - ...options - }: UseQueryOptions = {} -) => - useQuery( - 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]) -} diff --git a/studio/data/projects/projects-readonly-status-query.ts b/studio/data/projects/projects-readonly-status-query.ts deleted file mode 100644 index 0cae487cb70..00000000000 --- a/studio/data/projects/projects-readonly-status-query.ts +++ /dev/null @@ -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> -export type ProjectsReadonlyStatusesError = ResponseError - -export const useProjectsReadonlyStatusesQuery = ({ - enabled = true, - ...options -}: UseQueryOptions = {}) => - useQuery( - 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(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( - 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( - 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( - projectKeys.detail(projectRef), - (old) => { - if (!old) return old - - return { ...old, postgrestStatus: status } - }, - { updatedAt: Date.now() } - ) -} diff --git a/studio/data/usage/keys.ts b/studio/data/usage/keys.ts index aac6590a78e..54be56a1858 100644 --- a/studio/data/usage/keys.ts +++ b/studio/data/usage/keys.ts @@ -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, } diff --git a/studio/data/usage/resource-warnings-query.ts b/studio/data/usage/resource-warnings-query.ts new file mode 100644 index 00000000000..b1d56c12cfe --- /dev/null +++ b/studio/data/usage/resource-warnings-query.ts @@ -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> +export type ResourceWarningsError = ResponseError + +export const useResourceWarningsQuery = ({ + enabled = true, + ...options +}: UseQueryOptions = {}) => + useQuery( + usageKeys.resourceWarnings(), + ({ signal }) => getResourceWarnings(signal), + { + enabled, + staleTime: 1000 * 60 * 30, // default 30 minutes + ...options, + } + ) diff --git a/studio/hooks/misc/useProjectReadOnlyStatus.ts b/studio/hooks/misc/useProjectReadOnlyStatus.ts deleted file mode 100644 index 4c889b54528..00000000000 --- a/studio/hooks/misc/useProjectReadOnlyStatus.ts +++ /dev/null @@ -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 -} diff --git a/studio/pages/project/[ref]/reports/storage.tsx b/studio/pages/project/[ref]/reports/storage.tsx new file mode 100644 index 00000000000..664265e07a0 --- /dev/null +++ b/studio/pages/project/[ref]/reports/storage.tsx @@ -0,0 +1,126 @@ +import { observer } from 'mobx-react-lite' +import { NextPageWithLayout } from 'types' +import { ReportsLayout } from 'components/layouts' +import { + PRESET_CONFIG, + REPORTS_DATEPICKER_HELPERS, +} from 'components/interfaces/Reports/Reports.constants' +import ReportWidget from 'components/interfaces/Reports/ReportWidget' +import { queriesFactory } from 'components/interfaces/Reports/Reports.utils' +import { + CacheHitRateChartRenderer, + TopCacheMissesRenderer, +} from 'components/interfaces/Reports/renderers/StorageRenderers' +import { useEffect, useMemo } from 'react' +import ReportHeader from 'components/interfaces/Reports/ReportHeader' +import { DatePickerToFrom, LogsEndpointParams } from 'components/interfaces/Settings/Logs' +import { useParams } from 'common' +import ShimmerLine from 'components/ui/ShimmerLine' +import ReportPadding from 'components/interfaces/Reports/ReportPadding' +import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query' +import DatePickers from 'components/interfaces/Settings/Logs/Logs.DatePickers' + +export const StorageReport: NextPageWithLayout = () => { + const { ref: projectRef } = useParams() + const report = useStorageReport() + + const { data: subscription } = useProjectSubscriptionV2Query({ projectRef }) + const plan = subscription?.plan + + const handleDatepickerChange = ({ from, to }: DatePickerToFrom) => { + report.mergeParams({ + iso_timestamp_start: from || '', + iso_timestamp_end: to || '', + }) + } + + const datepickerHelpers = useMemo( + () => + REPORTS_DATEPICKER_HELPERS.map((helper, index) => ({ + ...helper, + disabled: (index > 0 && plan?.id === 'free') || (index > 1 && plan?.id !== 'pro'), + })), + [] + ) + + return ( + + +
    +
    + +
    +
    + +
    +
    + + +
    + ) +} + +// hook to fetch data +const useStorageReport = () => { + const { ref: projectRef } = useParams() + + const queryHooks = queriesFactory( + PRESET_CONFIG.storage.queries, + projectRef ?? 'default' + ) + const cacheHitRate = queryHooks.cacheHitRate() + const topCacheMisses = queryHooks.topCacheMisses() + const activeHooks = [cacheHitRate, topCacheMisses] + + const handleRefresh = async () => { + activeHooks.forEach((hook) => hook.runQuery()) + } + const handleSetParams = (params: Partial) => { + activeHooks.forEach((hook) => { + hook.setParams?.((prev: LogsEndpointParams) => ({ ...prev, ...params })) + }) + } + useEffect(()=>{ + + if (cacheHitRate.changeQuery) { + cacheHitRate.changeQuery(PRESET_CONFIG.storage.queries.cacheHitRate.sql([])) + } + + if (topCacheMisses.changeQuery) { + topCacheMisses.changeQuery(PRESET_CONFIG.storage.queries.topCacheMisses.sql([])) + } + + }, []) + const isLoading = activeHooks.some((hook) => hook.isLoading) + return { + data: { + cacheHitRate: cacheHitRate.logData, + topCacheMisses: topCacheMisses.logData, + }, + params: { + cacheHitRate: cacheHitRate.params, + topCacheMisses: topCacheMisses.params, + }, + mergeParams: handleSetParams, + isLoading, + refresh: handleRefresh, + } +} + +StorageReport.getLayout = (page) => {page} + +export default observer(StorageReport) diff --git a/studio/tests/pages/projects/reports/storage-report.test.js b/studio/tests/pages/projects/reports/storage-report.test.js new file mode 100644 index 00000000000..6d24e13ca85 --- /dev/null +++ b/studio/tests/pages/projects/reports/storage-report.test.js @@ -0,0 +1,42 @@ +import { get } from 'lib/common/fetch' +import { render } from '../../../helpers' +import { waitFor, screen } from '@testing-library/react' +import { StorageReport } from 'pages/project/[ref]/reports/storage' +import userEvent from '@testing-library/user-event' + +beforeEach(() => { + // reset mocks between tests + get.mockReset() + get.mockImplementation(async (_url) => [{ result: [] }]) +}) + +test(`static elements`, async () => { + render() + await screen.findByText('Request Caching') + await screen.findByText(/Last 24 hours/) + await screen.findByText(/Custom/) +}) + +test('refresh button', async () => { + render() + await waitFor(() => expect(get).toBeCalled()) + get.mockReset() + userEvent.click(await screen.findByText(/Refresh/)) + await waitFor(() => expect(get).toBeCalled()) +}) + +test('append - top cache misses', async () => { + get.mockImplementation(async (url) => { + if (decodeURIComponent(url).includes('misses')) { + return { + result: [{ path: 'mypath', search: 'some-query', count: 22 }], + } + } + return { result: [{ timestamp: new Date().toISOString(), miss_count: 123, hit_count: 123 }] } + }) + render() + await waitFor(() => expect(get).toBeCalled()) + await screen.findAllByText(/mypath/) + await screen.findAllByText(/some\-query/) + await screen.findAllByText(/22/) +})