Files
supabase/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx
supabase-vercel-tedd[bot]andAli Waseem d72211556b feat(studio): link overview Machine size card to Infrastructure settings (#51402)
## Problem

The project overview shows the project's compute size in a card labeled
"Compute", but the card is not clickable. Compute changes are managed on
Infrastructure settings, so users have to find that page on their own.
The adjacent cards (GitHub, Recent branch, Last migration, Last backup)
already link to where you manage them.

## Solution

- Rename the card label from "Compute" to "Machine size" and pass
`href={getInfrastructurePath(ref)}` to `SingleStat`. That renders the
same `Link` wrapper as the adjacent cards, so it gets the same link
semantics, keyboard focus and hover style. The value (compute badge or
"Unknown", plus the High Availability badge) is unchanged.
- Add `onClick={(e) => e.stopPropagation()}` to the
`HighAvailabilityBadge` hover card content. This is the same pattern
`ComputeBadgeWrapper` already uses. The hover card is portaled, but
React still bubbles its clicks to the new card link. Without this, the
link's `onClick` would intercept clicks inside the HA hover card.
Clicking "Read more" would then go to Infrastructure instead of opening
the docs in a new tab.

Out of scope: "Compute" labels elsewhere, the Infrastructure page and
compute provisioning behavior.

Notes:
- The compute badge's hover card trigger already calls `stopPropagation`
on click, because it also sits inside clickable project cards and table
rows. When you click the badge itself, the browser still follows the
native anchor to Infrastructure settings, but it does a full page load
instead of a client-side navigation. This PR leaves that shared
component unchanged.
- While the project is resizing, the overview is replaced by the
resizing state, so the card is not shown then. Settings routes stay
reachable while a project is building, so the link doesn't bounce users
back home.
- No new component test: rendering `ActivityStats` needs mocks for about
eight queries, including `ServiceStatus`. The change only sets an `href`
on an existing component, so a browser check is a better fit.

## Review instructions

1. Open a platform project's overview (`/project/<ref>`).
2. The card next to Status reads "Machine size" and still shows the
compute badge (and the HA badge on HA projects).
3. Click the card, or Tab to it and press Enter. You land on
`/project/<ref>/settings/infrastructure`.
4. Hover the compute badge: its hover card still opens, and "Upgrade
compute" still goes to Infrastructure.
5. On an HA project, hover the High Availability badge and click "Read
more". The docs open in a new tab and the page does not navigate.
6. GitHub, Recent branch, Last migration and Last backup keep their
labels and destinations.

Checks: `tsc --noEmit` for Studio (no new errors; one unrelated error in
`packages/ui-patterns/.../InstructionBlocks.tsx` was already there),
`eslint` on the touched files (no new warnings), Prettier check, `vitest
components/interfaces/ProjectHome` (34 passed).

## Checklist

- [x] I have read
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
- [ ] If I wrote a new docs topic or edited an existing topic, I used
the `/write-the-docs` or `/edit-the-docs` skill, which applies the docs
[style
guide](https://github.com/supabase/supabase/tree/master/apps/docs/style-guide)

---------

Co-authored-by: supabase-vercel-tedd[bot] <336548405+supabase-vercel-tedd[bot]@users.noreply.github.com>
Co-authored-by: Ali Waseem <waseema393@gmail.com>
2026-10-07 14:37:52 -06:00

276 lines
9.6 KiB
TypeScript

import { useParams } from 'common'
import dayjs from 'dayjs'
import { Archive, Cpu, Database, GitBranch, Github } from 'lucide-react'
import { useMemo } from 'react'
import { cn, Skeleton } from 'ui'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { HighAvailabilityBadge } from './HighAvailabilityBadge'
import { ServiceStatus } from './ServiceStatus'
import { getInfrastructurePath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils'
import { ComputeBadgeWrapper } from '@/components/ui/ComputeBadgeWrapper'
import { DisableInteraction } from '@/components/ui/DisableInteraction'
import { SingleStat } from '@/components/ui/SingleStat'
import { useBranchesQuery } from '@/data/branches/branches-query'
import { useBackupsQuery } from '@/data/database/backups-query'
import { DatabaseMigration, useMigrationsQuery } from '@/data/database/migrations-query'
import { useGitHubConnectionsQuery } from '@/data/integrations/github-connections-query'
import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query'
import { useHighAvailability } from '@/hooks/misc/useHighAvailability'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from '@/lib/constants'
import { EMPTY_ARR } from '@/lib/void'
interface BranchStatValueProps {
currentBranch?: { created_at?: string }
isDefaultProject: boolean
isError: boolean
isHighAvailability: boolean
isLoading: boolean
latestNonDefaultBranch?: { name?: string }
}
export const BranchStatValue = ({
currentBranch,
isDefaultProject,
isError,
isHighAvailability,
isLoading,
latestNonDefaultBranch,
}: BranchStatValueProps) => {
if (isHighAvailability) {
return <p className="text-foreground-lighter">Unavailable</p>
}
if (isLoading) {
return <Skeleton className="h-6 w-24" />
}
if (isError) {
return <p className="text-foreground-lighter">Unable to load</p>
}
if (isDefaultProject) {
return (
<p
className={cn('truncate min-w-0', !latestNonDefaultBranch && 'text-foreground-lighter')}
title={latestNonDefaultBranch?.name ?? 'No branches'}
>
{latestNonDefaultBranch?.name ?? 'No branches'}
</p>
)
}
if (currentBranch?.created_at) {
return (
<TimestampInfo
className="text-base"
label={dayjs(currentBranch.created_at).fromNow()}
utcTimestamp={currentBranch.created_at}
/>
)
}
return <p className="text-foreground-lighter">Unknown</p>
}
export const ActivityStats = () => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const { isHighAvailability } = useHighAvailability()
const { data: organization } = useSelectedOrganizationQuery()
const { data: resourceWarnings } = useResourceWarningsQuery({ slug: organization?.slug })
const projectResourceWarnings = resourceWarnings?.find((warning) => warning.project === ref)
const parentProjectRef = project?.parent_project_ref ?? project?.ref
const {
data: branchesData,
isPending: isLoadingBranches,
isError: isBranchesError,
} = useBranchesQuery(
{
projectRef: parentProjectRef,
},
{ enabled: !isHighAvailability }
)
const isDefaultProject = project?.parent_project_ref === undefined
const currentBranch = useMemo(
() => (branchesData ?? []).find((b) => b.project_ref === ref),
[branchesData, ref]
)
const latestNonDefaultBranch = useMemo(() => {
const list = (branchesData ?? []).filter((b) => !b.is_default)
if (list.length === 0) return undefined
return list
.slice()
.sort(
(a, b) =>
new Date(b.created_at ?? b.updated_at).valueOf() -
new Date(a.created_at ?? a.updated_at).valueOf()
)[0]
}, [branchesData])
const { data: migrationsData = EMPTY_ARR, isPending: isLoadingMigrations } = useMigrationsQuery({
projectRef: project?.ref,
projectStatus: project?.status,
connectionString: project?.connectionString,
})
const latestMigration = useMemo<DatabaseMigration | undefined>(
() => migrationsData[0],
[migrationsData]
)
const migrationLabelText =
migrationsData.length === 0 ? 'No migrations' : (latestMigration?.name ?? 'Unknown')
const { data: backupsData, isPending: isLoadingBackups } = useBackupsQuery({
projectRef: project?.ref,
projectStatus: project?.status,
})
const latestBackup = useMemo(() => {
const list = backupsData?.backups ?? []
if (list.length === 0) return undefined
return list
.slice()
.sort((a, b) => new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf())[0]
}, [backupsData])
const { data: githubConnections, isPending: isLoadingGithubConnections } =
useGitHubConnectionsQuery({ organizationId: organization?.id }, { enabled: !!organization?.id })
const githubConnection = githubConnections?.find(
(connection) => connection.project.ref === parentProjectRef
)
const isProjectComingUp = [PROJECT_STATUS.COMING_UP, PROJECT_STATUS.UNKNOWN].includes(
project?.status ?? PROJECT_STATUS.UNKNOWN
)
const githubLabelText = githubConnection?.repository.name
? githubConnection.repository.name
: isProjectComingUp
? 'Waiting for project...'
: 'No repository connected'
const integrationsPath = parentProjectRef
? `/project/${parentProjectRef}/settings/integrations`
: undefined
return (
<div className="@container">
<div className="grid grid-cols-1 @md:grid-cols-2 gap-2 @md:gap-6 flex-wrap">
<ServiceStatus />
<SingleStat
href={getInfrastructurePath(ref)}
icon={<Cpu size={18} strokeWidth={1.5} className="text-foreground" />}
label={<span>Compute size</span>}
value={
<div className="flex items-center gap-x-2 gap-y-1 flex-wrap">
{project?.infra_compute_size ? (
<ComputeBadgeWrapper
projectRef={project?.ref}
slug={organization?.slug}
cloudProvider={project?.cloud_provider}
computeSize={project.infra_compute_size}
resourceWarnings={projectResourceWarnings}
/>
) : (
<p className="text-foreground-lighter">Unknown</p>
)}
{project?.high_availability && <HighAvailabilityBadge />}
</div>
}
/>
<SingleStat
href={integrationsPath}
icon={<Github size={18} strokeWidth={1.5} className="text-foreground" />}
label={<span>GitHub</span>}
value={
isLoadingGithubConnections ? (
<Skeleton className="h-6 w-24" />
) : (
<p
className={cn('truncate min-w-0', !githubConnection && 'text-foreground-lighter')}
title={githubLabelText}
>
{githubLabelText}
</p>
)
}
/>
<DisableInteraction disabled={isHighAvailability} aria-disabled={isHighAvailability}>
<SingleStat
href={isHighAvailability ? undefined : `/project/${ref}/branches`}
icon={<GitBranch size={18} strokeWidth={1.5} className="text-foreground" />}
label={<span>{isDefaultProject ? 'Recent branch' : 'Branch Created'}</span>}
trackingProperties={{
stat_type: 'branches',
stat_value: branchesData?.length ?? 0,
}}
value={
<BranchStatValue
currentBranch={currentBranch}
isDefaultProject={isDefaultProject}
isError={isBranchesError}
isHighAvailability={isHighAvailability}
isLoading={isLoadingBranches}
latestNonDefaultBranch={latestNonDefaultBranch}
/>
}
/>
</DisableInteraction>
<SingleStat
href={`/project/${ref}/database/migrations`}
icon={<Database size={18} strokeWidth={1.5} className="text-foreground" />}
label={<span>Last migration</span>}
trackingProperties={{
stat_type: 'migrations',
stat_value: migrationsData?.length ?? 0,
}}
value={
isLoadingMigrations ? (
<Skeleton className="h-6 w-24" />
) : (
<p
className={cn(
'truncate min-w-0',
!!latestMigration ? 'text-foreground' : 'text-foreground-lighter'
)}
title={migrationLabelText}
>
{migrationLabelText}
</p>
)
}
/>
<SingleStat
href={`/project/${ref}/database/backups/scheduled`}
icon={<Archive size={18} strokeWidth={1.5} className="text-foreground" />}
label={<span>Last backup</span>}
trackingProperties={{
stat_type: 'backups',
stat_value: backupsData?.backups?.length ?? 0,
}}
value={
isLoadingBackups ? (
<Skeleton className="h-6 w-24" />
) : backupsData?.pitr_enabled ? (
<p>PITR enabled</p>
) : latestBackup ? (
<TimestampInfo
className="text-base"
displayAs="utc"
label={dayjs(latestBackup.inserted_at).fromNow()}
utcTimestamp={latestBackup.inserted_at}
/>
) : (
<p className="text-foreground-lighter">No backups</p>
)
}
/>
</div>
</div>
)
}