ComputeBadgeWrapper.tsx215 lines · main
| 1 | import Link from 'next/link' |
| 2 | import { useState } from 'react' |
| 3 | import { Button, cn, HoverCard, HoverCardContent, HoverCardTrigger, Separator } from 'ui' |
| 4 | import { ComputeBadge } from 'ui-patterns/ComputeBadge' |
| 5 | import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' |
| 6 | |
| 7 | import { getAvailableComputeOptions } from '@/components/interfaces/DiskManagement/DiskManagement.utils' |
| 8 | import { ProjectDetail } from '@/data/projects/project-detail-query' |
| 9 | import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' |
| 10 | import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query' |
| 11 | import { ResourceWarning } from '@/data/usage/resource-warnings-query' |
| 12 | import { getCloudProviderArchitecture } from '@/lib/cloudprovider-utils' |
| 13 | import { useTrack } from '@/lib/telemetry/track' |
| 14 | |
| 15 | export const ChevronsUpAnimated = () => ( |
| 16 | <svg |
| 17 | width={10} |
| 18 | height={10} |
| 19 | viewBox="0 0 24 24" |
| 20 | fill="none" |
| 21 | stroke="currentColor" |
| 22 | strokeWidth="2.5" |
| 23 | strokeLinecap="round" |
| 24 | strokeLinejoin="round" |
| 25 | > |
| 26 | <polyline |
| 27 | points="17 18 12 13 7 18" |
| 28 | className="animate-chevron-up" |
| 29 | style={{ animationDelay: '0s' }} |
| 30 | /> |
| 31 | <polyline |
| 32 | points="17 11 12 6 7 11" |
| 33 | className="animate-chevron-up" |
| 34 | style={{ animationDelay: '0.3s' }} |
| 35 | /> |
| 36 | </svg> |
| 37 | ) |
| 38 | |
| 39 | const Row = ({ label, stat }: { label: string; stat: React.ReactNode | string }) => { |
| 40 | return ( |
| 41 | <div className="flex flex-row gap-2"> |
| 42 | <span className="text-sm text-foreground-light w-16">{label}</span> |
| 43 | <span className="text-sm">{stat}</span> |
| 44 | </div> |
| 45 | ) |
| 46 | } |
| 47 | |
| 48 | interface ComputeBadgeWrapperProps { |
| 49 | slug?: string |
| 50 | projectRef?: string |
| 51 | cloudProvider?: string |
| 52 | computeSize?: ProjectDetail['infra_compute_size'] |
| 53 | resourceWarnings?: ResourceWarning |
| 54 | badgeClassName?: string |
| 55 | } |
| 56 | |
| 57 | export const ComputeBadgeWrapper = ({ |
| 58 | slug, |
| 59 | projectRef, |
| 60 | cloudProvider, |
| 61 | computeSize, |
| 62 | resourceWarnings, |
| 63 | badgeClassName, |
| 64 | }: ComputeBadgeWrapperProps) => { |
| 65 | // handles the state of the hover card |
| 66 | // once open it will fetch the addons |
| 67 | const [open, setOpenState] = useState(false) |
| 68 | |
| 69 | // returns hardcoded values for infra |
| 70 | const cpuArchitecture = getCloudProviderArchitecture(cloudProvider) |
| 71 | |
| 72 | // fetches addons |
| 73 | const { data: addons, isPending: isLoadingAddons } = useProjectAddonsQuery( |
| 74 | { projectRef }, |
| 75 | { enabled: open } |
| 76 | ) |
| 77 | |
| 78 | // Derive cores/memory from the same source as the badge (infra_compute_size) by looking up |
| 79 | // the matching variant in available_addons. Sourcing from selected_addons can drift out of |
| 80 | // sync with infra_compute_size and produce a card that contradicts its own badge. |
| 81 | const computeOptions = getAvailableComputeOptions(addons?.available_addons ?? [], cloudProvider) |
| 82 | const meta = computeOptions.find((variant) => variant.identifier === `ci_${computeSize}`)?.meta |
| 83 | |
| 84 | const highestComputeAvailable = computeOptions[computeOptions.length - 1]?.identifier |
| 85 | const isHighestCompute = computeSize === highestComputeAvailable?.replace('ci_', '') |
| 86 | |
| 87 | const { data, isPending: isLoadingSubscriptions } = useOrgSubscriptionQuery( |
| 88 | { orgSlug: slug }, |
| 89 | { enabled: open } |
| 90 | ) |
| 91 | |
| 92 | const isEligibleForFreeUpgrade = data?.plan.id !== 'free' && computeSize === 'nano' |
| 93 | const isComputeNearExhaustion = |
| 94 | !!resourceWarnings?.cpu_exhaustion || |
| 95 | !!resourceWarnings?.memory_and_swap_exhaustion || |
| 96 | !!resourceWarnings?.disk_space_exhaustion || |
| 97 | !!resourceWarnings?.disk_io_exhaustion |
| 98 | const showUpgradeGlow = isEligibleForFreeUpgrade && isComputeNearExhaustion |
| 99 | |
| 100 | const track = useTrack() |
| 101 | |
| 102 | const isLoading = isLoadingAddons || isLoadingSubscriptions |
| 103 | |
| 104 | if (!computeSize) return null |
| 105 | |
| 106 | return ( |
| 107 | <HoverCard onOpenChange={() => setOpenState(!open)} openDelay={280}> |
| 108 | <HoverCardTrigger asChild className="group" onClick={(e) => e.stopPropagation()}> |
| 109 | <div className={cn('flex items-center', showUpgradeGlow && 'animate-badge-pulse')}> |
| 110 | <div |
| 111 | className={cn( |
| 112 | 'flex', |
| 113 | showUpgradeGlow && 'relative inline-flex overflow-hidden rounded-sm' |
| 114 | )} |
| 115 | > |
| 116 | <ComputeBadge |
| 117 | infraComputeSize={computeSize} |
| 118 | icon={showUpgradeGlow && <ChevronsUpAnimated />} |
| 119 | className={cn( |
| 120 | showUpgradeGlow && 'text-brand-600 border-brand-500 bg-brand/10 gap-1', |
| 121 | badgeClassName |
| 122 | )} |
| 123 | /> |
| 124 | {showUpgradeGlow && ( |
| 125 | <span className="animate-badge-shimmer pointer-events-none absolute inset-0 bg-linear-to-br from-transparent via-brand/20 to-transparent blur-md" /> |
| 126 | )} |
| 127 | </div> |
| 128 | </div> |
| 129 | </HoverCardTrigger> |
| 130 | <HoverCardContent |
| 131 | side="bottom" |
| 132 | align="start" |
| 133 | className="p-0 overflow-hidden w-96" |
| 134 | onClick={(e) => e.stopPropagation()} |
| 135 | > |
| 136 | <div className="p-2 px-5 text-xs text-foreground-lighter">Compute size</div> |
| 137 | <Separator /> |
| 138 | <div className="p-3 px-5 flex flex-row gap-4"> |
| 139 | <div> |
| 140 | <ComputeBadge infraComputeSize={computeSize} /> |
| 141 | </div> |
| 142 | <div className="flex flex-col gap-4"> |
| 143 | {isLoading ? ( |
| 144 | <> |
| 145 | <div className="flex flex-col gap-1"> |
| 146 | <ShimmeringLoader className="h-[20px] py-0 w-32" /> |
| 147 | <ShimmeringLoader className="h-[20px] py-0 w-32" /> |
| 148 | </div> |
| 149 | </> |
| 150 | ) : ( |
| 151 | <> |
| 152 | <div className="flex flex-col gap-1"> |
| 153 | {computeSize === 'nano' ? ( |
| 154 | <> |
| 155 | <Row label="CPU" stat="Shared" /> |
| 156 | <Row label="Memory" stat="Up to 0.5 GB" /> |
| 157 | </> |
| 158 | ) : meta !== undefined ? ( |
| 159 | <> |
| 160 | <Row |
| 161 | label="CPU" |
| 162 | stat={`${meta.cpu_cores ?? '?'}-core ${cpuArchitecture} ${meta.cpu_dedicated ? '(Dedicated)' : '(Shared)'}`} |
| 163 | /> |
| 164 | <Row label="Memory" stat={`${meta.memory_gb ?? '-'} GB`} /> |
| 165 | </> |
| 166 | ) : null} |
| 167 | </div> |
| 168 | </> |
| 169 | )} |
| 170 | </div> |
| 171 | </div> |
| 172 | {(!isHighestCompute || isEligibleForFreeUpgrade) && ( |
| 173 | <> |
| 174 | <Separator /> |
| 175 | <div className="p-3 px-5 text-sm flex flex-col gap-2 bg-studio"> |
| 176 | <div className="flex flex-col gap-0"> |
| 177 | <p className="text-foreground"> |
| 178 | {isEligibleForFreeUpgrade |
| 179 | ? 'Free upgrade to Micro available' |
| 180 | : 'Unlock more compute'} |
| 181 | </p> |
| 182 | <p className="text-foreground-light"> |
| 183 | {isEligibleForFreeUpgrade |
| 184 | ? 'Paid plans include a free upgrade to Micro compute.' |
| 185 | : 'Scale your project up to 64 cores and 256 GB RAM.'} |
| 186 | </p> |
| 187 | </div> |
| 188 | <div> |
| 189 | <Button |
| 190 | asChild |
| 191 | type="default" |
| 192 | htmlType="button" |
| 193 | role="button" |
| 194 | onClick={() => { |
| 195 | track('compute_badge_upgrade_clicked', { |
| 196 | computeSize: computeSize ?? 'unknown', |
| 197 | planId: data?.plan.id ?? 'unknown', |
| 198 | upgradeType: isEligibleForFreeUpgrade |
| 199 | ? 'free_micro_upgrade' |
| 200 | : 'compute_upgrade', |
| 201 | }) |
| 202 | }} |
| 203 | > |
| 204 | <Link href={`/project/${projectRef}/settings/compute-and-disk`}> |
| 205 | Upgrade compute |
| 206 | </Link> |
| 207 | </Button> |
| 208 | </div> |
| 209 | </div> |
| 210 | </> |
| 211 | )} |
| 212 | </HoverCardContent> |
| 213 | </HoverCard> |
| 214 | ) |
| 215 | } |