ServiceStatus.tsx333 lines · main
| 1 | import { useParams } from 'common' |
| 2 | import dayjs from 'dayjs' |
| 3 | import { ChevronRight, Loader2 } from 'lucide-react' |
| 4 | import Link from 'next/link' |
| 5 | import { cn, HoverCard, HoverCardContent, HoverCardTrigger, InfoIcon } from 'ui' |
| 6 | |
| 7 | import { |
| 8 | extractDbSchema, |
| 9 | ProjectServiceStatus, |
| 10 | StatusIcon, |
| 11 | StatusMessage, |
| 12 | } from '../Home/ServiceStatus' |
| 13 | import { InlineLink } from '@/components/ui/InlineLink' |
| 14 | import { SingleStat } from '@/components/ui/SingleStat' |
| 15 | import { useBranchesQuery } from '@/data/branches/branches-query' |
| 16 | import { useEdgeFunctionServiceStatusQuery } from '@/data/service-status/edge-functions-status-query' |
| 17 | import { useProjectServiceStatusQuery } from '@/data/service-status/service-status-query' |
| 18 | import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' |
| 19 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 20 | import { DOCS_URL } from '@/lib/constants' |
| 21 | |
| 22 | const SERVICE_STATUS_THRESHOLD = 5 // minutes |
| 23 | |
| 24 | /** |
| 25 | * [Joshen] JFYI before we go live with this, we need to revisit the migrations section |
| 26 | * as I don't think it should live in the ServiceStatus component since its not indicative |
| 27 | * of a project's "service". ServiceStatus's intention is to be an ongoing health/status check. |
| 28 | * |
| 29 | * For context, migrations are meant to be indicative for only when creating branches or projects |
| 30 | * with an initial SQL, so "healthy" migrations just means that migrations have all been successfully |
| 31 | * ran. So it might be a matter of decoupling "ready" state vs "health checks" |
| 32 | * [Edit] Now that migrations are only showing up if the project is a branch, i think its okay for now |
| 33 | * |
| 34 | * [Joshen] Another issue that requires investigation before we go live with the changes: |
| 35 | * We've removed the isProjectNew check in this component which we had that logic cause new |
| 36 | * projects would show unhealthy as the services are still starting up - but it causes a |
| 37 | * perceived negative impression as new projects were showing unhealthy, hence the 5 minute |
| 38 | * threshold check (we’d show “Coming up” instead of “unhealthy” if the project is within 5 |
| 39 | * minutes of when it was created). Might be related to decoupling "ready" state vs "health checks" |
| 40 | */ |
| 41 | |
| 42 | export const ServiceStatus = () => { |
| 43 | const { ref } = useParams() |
| 44 | const { data: project } = useSelectedProjectQuery() |
| 45 | |
| 46 | const { |
| 47 | projectAuthAll: authEnabled, |
| 48 | projectEdgeFunctionAll: edgeFunctionsEnabled, |
| 49 | realtimeAll: realtimeEnabled, |
| 50 | projectStorageAll: storageEnabled, |
| 51 | } = useIsFeatureEnabled([ |
| 52 | 'project_auth:all', |
| 53 | 'project_edge_function:all', |
| 54 | 'realtime:all', |
| 55 | 'project_storage:all', |
| 56 | ]) |
| 57 | |
| 58 | const isBranch = project?.parentRef !== project?.ref |
| 59 | |
| 60 | // Get branches data when on a branch |
| 61 | const { data: branches, isPending: isBranchesLoading } = useBranchesQuery( |
| 62 | { projectRef: isBranch ? project?.parentRef : undefined }, |
| 63 | { |
| 64 | enabled: isBranch, |
| 65 | } |
| 66 | ) |
| 67 | |
| 68 | const currentBranch = isBranch |
| 69 | ? branches?.find((branch) => branch.project_ref === ref) |
| 70 | : undefined |
| 71 | |
| 72 | // [Joshen] Need pooler service check eventually |
| 73 | const { data: status, isPending: isLoading } = useProjectServiceStatusQuery( |
| 74 | { projectRef: ref }, |
| 75 | { |
| 76 | refetchInterval: (query) => { |
| 77 | const data = query.state.data |
| 78 | const isServiceUnhealthy = data?.some((service) => { |
| 79 | // if the postgrest service has an empty schema, postgrest has been disabled |
| 80 | if (service.name === 'rest' && extractDbSchema(service) === '') { |
| 81 | return false |
| 82 | } |
| 83 | if (service.status === 'ACTIVE_HEALTHY') { |
| 84 | return false |
| 85 | } |
| 86 | return true |
| 87 | }) |
| 88 | |
| 89 | return isServiceUnhealthy ? 5000 : false |
| 90 | }, |
| 91 | } |
| 92 | ) |
| 93 | const { data: edgeFunctionsStatus } = useEdgeFunctionServiceStatusQuery( |
| 94 | { projectRef: ref }, |
| 95 | { refetchInterval: (query) => (!query.state.data?.healthy ? 5000 : false) } |
| 96 | ) |
| 97 | |
| 98 | const authStatus = status?.find((service) => service.name === 'auth') |
| 99 | const restStatus = status?.find((service) => service.name === 'rest') |
| 100 | const realtimeStatus = status?.find((service) => service.name === 'realtime') |
| 101 | const storageStatus = status?.find((service) => service.name === 'storage') |
| 102 | const dbStatus = status?.find((service) => service.name === 'db') |
| 103 | |
| 104 | const isMigrationLoading = |
| 105 | project?.status === 'COMING_UP' || |
| 106 | (isBranch && |
| 107 | (isBranchesLoading || |
| 108 | currentBranch?.status === 'CREATING_PROJECT' || |
| 109 | currentBranch?.status === 'RUNNING_MIGRATIONS')) |
| 110 | |
| 111 | // [Joshen] Need individual troubleshooting docs for each service eventually for users to self serve |
| 112 | const services: { |
| 113 | name: string |
| 114 | error?: string |
| 115 | docsUrl?: string |
| 116 | isLoading: boolean |
| 117 | status: ProjectServiceStatus |
| 118 | logsUrl: string |
| 119 | }[] = [ |
| 120 | { |
| 121 | name: 'Database', |
| 122 | error: undefined, |
| 123 | docsUrl: undefined, |
| 124 | isLoading: isLoading, |
| 125 | status: dbStatus?.status ?? 'UNHEALTHY', |
| 126 | logsUrl: '/logs/postgres-logs', |
| 127 | }, |
| 128 | { |
| 129 | name: 'PostgREST', |
| 130 | error: restStatus?.error, |
| 131 | docsUrl: undefined, |
| 132 | isLoading, |
| 133 | // If PostgREST has an empty schema, it means it's been disabled |
| 134 | status: extractDbSchema(restStatus) === '' ? 'DISABLED' : (restStatus?.status ?? 'UNHEALTHY'), |
| 135 | logsUrl: '/logs/postgrest-logs', |
| 136 | }, |
| 137 | ...(authEnabled |
| 138 | ? [ |
| 139 | { |
| 140 | name: 'Auth', |
| 141 | error: authStatus?.error, |
| 142 | docsUrl: undefined, |
| 143 | isLoading, |
| 144 | status: authStatus?.status ?? 'UNHEALTHY', |
| 145 | logsUrl: '/logs/auth-logs', |
| 146 | }, |
| 147 | ] |
| 148 | : []), |
| 149 | ...(realtimeEnabled |
| 150 | ? [ |
| 151 | { |
| 152 | name: 'Realtime', |
| 153 | error: realtimeStatus?.error, |
| 154 | docsUrl: undefined, |
| 155 | isLoading, |
| 156 | status: realtimeStatus?.status ?? 'UNHEALTHY', |
| 157 | logsUrl: '/logs/realtime-logs', |
| 158 | }, |
| 159 | ] |
| 160 | : []), |
| 161 | ...(storageEnabled |
| 162 | ? [ |
| 163 | { |
| 164 | name: 'Storage', |
| 165 | error: storageStatus?.error, |
| 166 | docsUrl: undefined, |
| 167 | isLoading, |
| 168 | status: storageStatus?.status ?? 'UNHEALTHY', |
| 169 | logsUrl: '/logs/storage-logs', |
| 170 | }, |
| 171 | ] |
| 172 | : []), |
| 173 | ...(edgeFunctionsEnabled |
| 174 | ? [ |
| 175 | { |
| 176 | name: 'Edge Functions', |
| 177 | error: undefined, |
| 178 | docsUrl: `${DOCS_URL}/guides/functions/troubleshooting`, |
| 179 | isLoading, |
| 180 | status: edgeFunctionsStatus?.healthy |
| 181 | ? ('ACTIVE_HEALTHY' as const) |
| 182 | : isLoading |
| 183 | ? ('COMING_UP' as const) |
| 184 | : ('UNHEALTHY' as const), |
| 185 | logsUrl: '/logs/edge-functions-logs', |
| 186 | }, |
| 187 | ] |
| 188 | : []), |
| 189 | ...(isBranch |
| 190 | ? [ |
| 191 | { |
| 192 | name: 'Migrations', |
| 193 | error: undefined, |
| 194 | docsUrl: undefined, |
| 195 | isLoading: isBranchesLoading, |
| 196 | status: isBranch |
| 197 | ? currentBranch?.status === 'FUNCTIONS_DEPLOYED' |
| 198 | ? ('ACTIVE_HEALTHY' as const) |
| 199 | : currentBranch?.status === 'FUNCTIONS_FAILED' || |
| 200 | currentBranch?.status === 'MIGRATIONS_FAILED' |
| 201 | ? ('UNHEALTHY' as const) |
| 202 | : ('COMING_UP' as const) |
| 203 | : isMigrationLoading |
| 204 | ? 'COMING_UP' |
| 205 | : 'ACTIVE_HEALTHY', |
| 206 | logsUrl: isBranch ? '/branches' : '/logs/database-logs', |
| 207 | }, |
| 208 | ] |
| 209 | : []), |
| 210 | ] |
| 211 | |
| 212 | const isLoadingChecks = services.some((service) => service.isLoading) |
| 213 | // We consider a service operational if it's healthy or intentionally disabled |
| 214 | const allServicesOperational = services.every( |
| 215 | (service) => service.status === 'ACTIVE_HEALTHY' || service.status === 'DISABLED' |
| 216 | ) |
| 217 | |
| 218 | // Check if project or branch is in a startup state |
| 219 | const isProjectNew = |
| 220 | dayjs.utc().diff(dayjs.utc(project?.inserted_at), 'minute') < SERVICE_STATUS_THRESHOLD || |
| 221 | project?.status === 'COMING_UP' || |
| 222 | (isBranch && |
| 223 | (currentBranch?.status === 'CREATING_PROJECT' || |
| 224 | currentBranch?.status === 'RUNNING_MIGRATIONS' || |
| 225 | isMigrationLoading)) |
| 226 | |
| 227 | const isProjectComingUp = ['COMING_UP', 'UNKNOWN'].includes(project?.status ?? '') |
| 228 | |
| 229 | const anyUnhealthy = services.some((service) => service.status === 'UNHEALTHY') |
| 230 | const anyComingUp = |
| 231 | isProjectComingUp || services.some((service) => service.status === 'COMING_UP') |
| 232 | |
| 233 | const getOverallStatusLabel = (): string => { |
| 234 | if (isLoadingChecks) return 'Checking...' |
| 235 | if (anyComingUp) return 'Coming up...' |
| 236 | if (anyUnhealthy) return 'Unhealthy' |
| 237 | return 'Healthy' |
| 238 | } |
| 239 | |
| 240 | const overallStatusLabel = getOverallStatusLabel() |
| 241 | |
| 242 | return ( |
| 243 | <HoverCard openDelay={200} closeDelay={100}> |
| 244 | <HoverCardTrigger> |
| 245 | <SingleStat |
| 246 | icon={ |
| 247 | // Spinner only while the overall project is in COMING_UP; otherwise show 6-dot grid |
| 248 | isProjectComingUp ? ( |
| 249 | <Loader2 className="animate-spin" size={18} /> |
| 250 | ) : ( |
| 251 | <div className="grid grid-cols-3 gap-1"> |
| 252 | {services.map((service, index) => ( |
| 253 | <div |
| 254 | key={`${service.name}-${index}`} |
| 255 | className={cn( |
| 256 | 'w-1.5 h-1.5 rounded-full', |
| 257 | service.isLoading || |
| 258 | service.status === 'COMING_UP' || |
| 259 | (isProjectNew && service.status !== 'ACTIVE_HEALTHY') |
| 260 | ? 'bg-foreground-lighter animate-pulse' |
| 261 | : service.status === 'ACTIVE_HEALTHY' |
| 262 | ? 'bg-brand' |
| 263 | : 'bg-selection' |
| 264 | )} |
| 265 | /> |
| 266 | ))} |
| 267 | </div> |
| 268 | ) |
| 269 | } |
| 270 | label={<span>Status</span>} |
| 271 | value={<span>{overallStatusLabel}</span>} |
| 272 | /> |
| 273 | </HoverCardTrigger> |
| 274 | <HoverCardContent className="p-0 w-60" side="bottom" align="start"> |
| 275 | {services.map((service) => ( |
| 276 | <Link |
| 277 | href={`/project/${ref}${service.logsUrl}`} |
| 278 | key={service.name} |
| 279 | className="transition px-3 py-2 text-xs flex items-center justify-between border-b last:border-none group relative hover:bg-surface-300" |
| 280 | > |
| 281 | <div className="flex gap-x-2"> |
| 282 | <StatusIcon |
| 283 | isLoading={service.isLoading} |
| 284 | isProjectNew={isProjectNew} |
| 285 | projectStatus={service.status} |
| 286 | /> |
| 287 | <div className="flex-1"> |
| 288 | <p>{service.name}</p> |
| 289 | <p className="text-foreground-light flex items-center gap-1"> |
| 290 | <StatusMessage |
| 291 | isLoading={service.isLoading} |
| 292 | isProjectNew={isProjectNew} |
| 293 | status={ |
| 294 | isProjectComingUp && service.status === 'UNHEALTHY' |
| 295 | ? 'COMING_UP' |
| 296 | : service.status |
| 297 | } |
| 298 | /> |
| 299 | </p> |
| 300 | </div> |
| 301 | </div> |
| 302 | <div className="flex items-center gap-x-1 transition opacity-0 group-hover:opacity-100"> |
| 303 | <span className="text-xs text-foreground">View logs</span> |
| 304 | <ChevronRight size={14} className="text-foreground" /> |
| 305 | </div> |
| 306 | </Link> |
| 307 | ))} |
| 308 | {!allServicesOperational && ( |
| 309 | <div className="flex gap-2 text-xs text-foreground-light px-3 py-2"> |
| 310 | <div className="mt-0.5"> |
| 311 | <InfoIcon /> |
| 312 | </div> |
| 313 | <div className="flex flex-col gap-y-1"> |
| 314 | <p> |
| 315 | {isProjectNew ? 'New' : 'Recently restored'} projects can take up to{' '} |
| 316 | {SERVICE_STATUS_THRESHOLD} minutes to become fully operational. |
| 317 | </p> |
| 318 | <p> |
| 319 | If services stay unhealthy, refer to our{' '} |
| 320 | <InlineLink |
| 321 | href={`${DOCS_URL}/guides/troubleshooting/project-status-reports-unhealthy-services`} |
| 322 | > |
| 323 | docs |
| 324 | </InlineLink>{' '} |
| 325 | for more information. |
| 326 | </p> |
| 327 | </div> |
| 328 | </div> |
| 329 | )} |
| 330 | </HoverCardContent> |
| 331 | </HoverCard> |
| 332 | ) |
| 333 | } |