mirror of
https://github.com/supabase/supabase.git
synced 2026-05-08 18:00:20 -04:00
d6a8591019
## Context It isn't clear that the project status on the project home page is clickable to show the status of each service of the project <img width="283" height="454" alt="image" src="https://github.com/user-attachments/assets/c91ade86-b16d-4ea5-9046-5e8f71e7dd6e" /> Which can cause confusion, especially when starting branches - that the project status is just "Coming up" with no additional information. Opting to use a hover card instead of a popover to hopefully mitigate this gap <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **UI Improvements** * Enhanced status tooltip interactions with improved hover behavior and customizable delay controls for a better user experience. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
334 lines
12 KiB
TypeScript
334 lines
12 KiB
TypeScript
import { useParams } from 'common'
|
||
import dayjs from 'dayjs'
|
||
import { ChevronRight, Loader2 } from 'lucide-react'
|
||
import Link from 'next/link'
|
||
import { cn, HoverCard, HoverCardContent, HoverCardTrigger, InfoIcon } from 'ui'
|
||
|
||
import {
|
||
extractDbSchema,
|
||
ProjectServiceStatus,
|
||
StatusIcon,
|
||
StatusMessage,
|
||
} from '../Home/ServiceStatus'
|
||
import { InlineLink } from '@/components/ui/InlineLink'
|
||
import { SingleStat } from '@/components/ui/SingleStat'
|
||
import { useBranchesQuery } from '@/data/branches/branches-query'
|
||
import { useEdgeFunctionServiceStatusQuery } from '@/data/service-status/edge-functions-status-query'
|
||
import { useProjectServiceStatusQuery } from '@/data/service-status/service-status-query'
|
||
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||
import { DOCS_URL } from '@/lib/constants'
|
||
|
||
const SERVICE_STATUS_THRESHOLD = 5 // minutes
|
||
|
||
/**
|
||
* [Joshen] JFYI before we go live with this, we need to revisit the migrations section
|
||
* as I don't think it should live in the ServiceStatus component since its not indicative
|
||
* of a project's "service". ServiceStatus's intention is to be an ongoing health/status check.
|
||
*
|
||
* For context, migrations are meant to be indicative for only when creating branches or projects
|
||
* with an initial SQL, so "healthy" migrations just means that migrations have all been successfully
|
||
* ran. So it might be a matter of decoupling "ready" state vs "health checks"
|
||
* [Edit] Now that migrations are only showing up if the project is a branch, i think its okay for now
|
||
*
|
||
* [Joshen] Another issue that requires investigation before we go live with the changes:
|
||
* We've removed the isProjectNew check in this component which we had that logic cause new
|
||
* projects would show unhealthy as the services are still starting up - but it causes a
|
||
* perceived negative impression as new projects were showing unhealthy, hence the 5 minute
|
||
* threshold check (we’d show “Coming up” instead of “unhealthy” if the project is within 5
|
||
* minutes of when it was created). Might be related to decoupling "ready" state vs "health checks"
|
||
*/
|
||
|
||
export const ServiceStatus = () => {
|
||
const { ref } = useParams()
|
||
const { data: project } = useSelectedProjectQuery()
|
||
|
||
const {
|
||
projectAuthAll: authEnabled,
|
||
projectEdgeFunctionAll: edgeFunctionsEnabled,
|
||
realtimeAll: realtimeEnabled,
|
||
projectStorageAll: storageEnabled,
|
||
} = useIsFeatureEnabled([
|
||
'project_auth:all',
|
||
'project_edge_function:all',
|
||
'realtime:all',
|
||
'project_storage:all',
|
||
])
|
||
|
||
const isBranch = project?.parentRef !== project?.ref
|
||
|
||
// Get branches data when on a branch
|
||
const { data: branches, isPending: isBranchesLoading } = useBranchesQuery(
|
||
{ projectRef: isBranch ? project?.parentRef : undefined },
|
||
{
|
||
enabled: isBranch,
|
||
}
|
||
)
|
||
|
||
const currentBranch = isBranch
|
||
? branches?.find((branch) => branch.project_ref === ref)
|
||
: undefined
|
||
|
||
// [Joshen] Need pooler service check eventually
|
||
const { data: status, isPending: isLoading } = useProjectServiceStatusQuery(
|
||
{ projectRef: ref },
|
||
{
|
||
refetchInterval: (query) => {
|
||
const data = query.state.data
|
||
const isServiceUnhealthy = data?.some((service) => {
|
||
// if the postgrest service has an empty schema, postgrest has been disabled
|
||
if (service.name === 'rest' && extractDbSchema(service) === '') {
|
||
return false
|
||
}
|
||
if (service.status === 'ACTIVE_HEALTHY') {
|
||
return false
|
||
}
|
||
return true
|
||
})
|
||
|
||
return isServiceUnhealthy ? 5000 : false
|
||
},
|
||
}
|
||
)
|
||
const { data: edgeFunctionsStatus } = useEdgeFunctionServiceStatusQuery(
|
||
{ projectRef: ref },
|
||
{ refetchInterval: (query) => (!query.state.data?.healthy ? 5000 : false) }
|
||
)
|
||
|
||
const authStatus = status?.find((service) => service.name === 'auth')
|
||
const restStatus = status?.find((service) => service.name === 'rest')
|
||
const realtimeStatus = status?.find((service) => service.name === 'realtime')
|
||
const storageStatus = status?.find((service) => service.name === 'storage')
|
||
const dbStatus = status?.find((service) => service.name === 'db')
|
||
|
||
const isMigrationLoading =
|
||
project?.status === 'COMING_UP' ||
|
||
(isBranch &&
|
||
(isBranchesLoading ||
|
||
currentBranch?.status === 'CREATING_PROJECT' ||
|
||
currentBranch?.status === 'RUNNING_MIGRATIONS'))
|
||
|
||
// [Joshen] Need individual troubleshooting docs for each service eventually for users to self serve
|
||
const services: {
|
||
name: string
|
||
error?: string
|
||
docsUrl?: string
|
||
isLoading: boolean
|
||
status: ProjectServiceStatus
|
||
logsUrl: string
|
||
}[] = [
|
||
{
|
||
name: 'Database',
|
||
error: undefined,
|
||
docsUrl: undefined,
|
||
isLoading: isLoading,
|
||
status: dbStatus?.status ?? 'UNHEALTHY',
|
||
logsUrl: '/logs/postgres-logs',
|
||
},
|
||
{
|
||
name: 'PostgREST',
|
||
error: restStatus?.error,
|
||
docsUrl: undefined,
|
||
isLoading,
|
||
// If PostgREST has an empty schema, it means it's been disabled
|
||
status: extractDbSchema(restStatus) === '' ? 'DISABLED' : (restStatus?.status ?? 'UNHEALTHY'),
|
||
logsUrl: '/logs/postgrest-logs',
|
||
},
|
||
...(authEnabled
|
||
? [
|
||
{
|
||
name: 'Auth',
|
||
error: authStatus?.error,
|
||
docsUrl: undefined,
|
||
isLoading,
|
||
status: authStatus?.status ?? 'UNHEALTHY',
|
||
logsUrl: '/logs/auth-logs',
|
||
},
|
||
]
|
||
: []),
|
||
...(realtimeEnabled
|
||
? [
|
||
{
|
||
name: 'Realtime',
|
||
error: realtimeStatus?.error,
|
||
docsUrl: undefined,
|
||
isLoading,
|
||
status: realtimeStatus?.status ?? 'UNHEALTHY',
|
||
logsUrl: '/logs/realtime-logs',
|
||
},
|
||
]
|
||
: []),
|
||
...(storageEnabled
|
||
? [
|
||
{
|
||
name: 'Storage',
|
||
error: storageStatus?.error,
|
||
docsUrl: undefined,
|
||
isLoading,
|
||
status: storageStatus?.status ?? 'UNHEALTHY',
|
||
logsUrl: '/logs/storage-logs',
|
||
},
|
||
]
|
||
: []),
|
||
...(edgeFunctionsEnabled
|
||
? [
|
||
{
|
||
name: 'Edge Functions',
|
||
error: undefined,
|
||
docsUrl: `${DOCS_URL}/guides/functions/troubleshooting`,
|
||
isLoading,
|
||
status: edgeFunctionsStatus?.healthy
|
||
? ('ACTIVE_HEALTHY' as const)
|
||
: isLoading
|
||
? ('COMING_UP' as const)
|
||
: ('UNHEALTHY' as const),
|
||
logsUrl: '/logs/edge-functions-logs',
|
||
},
|
||
]
|
||
: []),
|
||
...(isBranch
|
||
? [
|
||
{
|
||
name: 'Migrations',
|
||
error: undefined,
|
||
docsUrl: undefined,
|
||
isLoading: isBranchesLoading,
|
||
status: isBranch
|
||
? currentBranch?.status === 'FUNCTIONS_DEPLOYED'
|
||
? ('ACTIVE_HEALTHY' as const)
|
||
: currentBranch?.status === 'FUNCTIONS_FAILED' ||
|
||
currentBranch?.status === 'MIGRATIONS_FAILED'
|
||
? ('UNHEALTHY' as const)
|
||
: ('COMING_UP' as const)
|
||
: isMigrationLoading
|
||
? 'COMING_UP'
|
||
: 'ACTIVE_HEALTHY',
|
||
logsUrl: isBranch ? '/branches' : '/logs/database-logs',
|
||
},
|
||
]
|
||
: []),
|
||
]
|
||
|
||
const isLoadingChecks = services.some((service) => service.isLoading)
|
||
// We consider a service operational if it's healthy or intentionally disabled
|
||
const allServicesOperational = services.every(
|
||
(service) => service.status === 'ACTIVE_HEALTHY' || service.status === 'DISABLED'
|
||
)
|
||
|
||
// Check if project or branch is in a startup state
|
||
const isProjectNew =
|
||
dayjs.utc().diff(dayjs.utc(project?.inserted_at), 'minute') < SERVICE_STATUS_THRESHOLD ||
|
||
project?.status === 'COMING_UP' ||
|
||
(isBranch &&
|
||
(currentBranch?.status === 'CREATING_PROJECT' ||
|
||
currentBranch?.status === 'RUNNING_MIGRATIONS' ||
|
||
isMigrationLoading))
|
||
|
||
const isProjectComingUp = ['COMING_UP', 'UNKNOWN'].includes(project?.status ?? '')
|
||
|
||
const anyUnhealthy = services.some((service) => service.status === 'UNHEALTHY')
|
||
const anyComingUp =
|
||
isProjectComingUp || services.some((service) => service.status === 'COMING_UP')
|
||
|
||
const getOverallStatusLabel = (): string => {
|
||
if (isLoadingChecks) return 'Checking...'
|
||
if (anyComingUp) return 'Coming up...'
|
||
if (anyUnhealthy) return 'Unhealthy'
|
||
return 'Healthy'
|
||
}
|
||
|
||
const overallStatusLabel = getOverallStatusLabel()
|
||
|
||
return (
|
||
<HoverCard openDelay={200} closeDelay={100}>
|
||
<HoverCardTrigger>
|
||
<SingleStat
|
||
icon={
|
||
// Spinner only while the overall project is in COMING_UP; otherwise show 6-dot grid
|
||
isProjectComingUp ? (
|
||
<Loader2 className="animate-spin" size={18} />
|
||
) : (
|
||
<div className="grid grid-cols-3 gap-1">
|
||
{services.map((service, index) => (
|
||
<div
|
||
key={`${service.name}-${index}`}
|
||
className={cn(
|
||
'w-1.5 h-1.5 rounded-full',
|
||
service.isLoading ||
|
||
service.status === 'COMING_UP' ||
|
||
(isProjectNew && service.status !== 'ACTIVE_HEALTHY')
|
||
? 'bg-foreground-lighter animate-pulse'
|
||
: service.status === 'ACTIVE_HEALTHY'
|
||
? 'bg-brand'
|
||
: 'bg-selection'
|
||
)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
label={<span>Status</span>}
|
||
value={<span>{overallStatusLabel}</span>}
|
||
/>
|
||
</HoverCardTrigger>
|
||
<HoverCardContent className="p-0 w-60" side="bottom" align="start">
|
||
{services.map((service) => (
|
||
<Link
|
||
href={`/project/${ref}${service.logsUrl}`}
|
||
key={service.name}
|
||
className="transition px-3 py-2 text-xs flex items-center justify-between border-b last:border-none group relative hover:bg-surface-300"
|
||
>
|
||
<div className="flex gap-x-2">
|
||
<StatusIcon
|
||
isLoading={service.isLoading}
|
||
isProjectNew={isProjectNew}
|
||
projectStatus={service.status}
|
||
/>
|
||
<div className="flex-1">
|
||
<p>{service.name}</p>
|
||
<p className="text-foreground-light flex items-center gap-1">
|
||
<StatusMessage
|
||
isLoading={service.isLoading}
|
||
isProjectNew={isProjectNew}
|
||
status={
|
||
isProjectComingUp && service.status === 'UNHEALTHY'
|
||
? 'COMING_UP'
|
||
: service.status
|
||
}
|
||
/>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-x-1 transition opacity-0 group-hover:opacity-100">
|
||
<span className="text-xs text-foreground">View logs</span>
|
||
<ChevronRight size={14} className="text-foreground" />
|
||
</div>
|
||
</Link>
|
||
))}
|
||
{!allServicesOperational && (
|
||
<div className="flex gap-2 text-xs text-foreground-light px-3 py-2">
|
||
<div className="mt-0.5">
|
||
<InfoIcon />
|
||
</div>
|
||
<div className="flex flex-col gap-y-1">
|
||
<p>
|
||
{isProjectNew ? 'New' : 'Recently restored'} projects can take up to{' '}
|
||
{SERVICE_STATUS_THRESHOLD} minutes to become fully operational.
|
||
</p>
|
||
<p>
|
||
If services stay unhealthy, refer to our{' '}
|
||
<InlineLink
|
||
href={`${DOCS_URL}/guides/troubleshooting/project-status-reports-unhealthy-services`}
|
||
>
|
||
docs
|
||
</InlineLink>{' '}
|
||
for more information.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</HoverCardContent>
|
||
</HoverCard>
|
||
)
|
||
}
|