ServiceStatus.tsx377 lines · main
1import { useParams } from 'common'
2import dayjs from 'dayjs'
3import { AlertTriangle, CheckCircle2, ChevronRight, Loader2 } from 'lucide-react'
4import Link from 'next/link'
5import { useEffect, useState } from 'react'
6import { Button, InfoIcon, Popover, PopoverContent, PopoverTrigger } from 'ui'
7
8import { InlineLink } from '@/components/ui/InlineLink'
9import { useBranchesQuery } from '@/data/branches/branches-query'
10import { useEdgeFunctionServiceStatusQuery } from '@/data/service-status/edge-functions-status-query'
11import {
12 ProjectServiceStatus as APIProjectServiceStatus,
13 ServiceHealthResponse,
14 useProjectServiceStatusQuery,
15} from '@/data/service-status/service-status-query'
16import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
17import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
18import { DOCS_URL } from '@/lib/constants'
19
20const SERVICE_STATUS_THRESHOLD = 5 // minutes
21
22export type ProjectServiceStatus = APIProjectServiceStatus | 'DISABLED'
23
24export const StatusMessage = ({
25 status,
26 isLoading,
27 isProjectNew,
28}: {
29 isLoading: boolean
30 isProjectNew: boolean
31 status?: ProjectServiceStatus
32}) => {
33 if (isLoading) return 'Checking status'
34 if (status === 'DISABLED') return 'Disabled'
35 if (status === 'UNHEALTHY') return 'Unhealthy'
36 if (status === 'COMING_UP') return 'Coming up...'
37 if (status === 'ACTIVE_HEALTHY') return 'Healthy'
38 // isProjectNew has to be after all other statuses
39 if (isProjectNew) return 'Coming up...'
40 if (status) return status
41 return 'Unable to connect'
42}
43
44const iconProps = {
45 size: 18,
46 strokeWidth: 1.5,
47}
48const LoaderIcon = () => <Loader2 {...iconProps} className="animate-spin" />
49const AlertIcon = () => <AlertTriangle {...iconProps} />
50const CheckIcon = () => <CheckCircle2 {...iconProps} className="text-brand" />
51
52export const StatusIcon = ({
53 isLoading,
54 isProjectNew,
55 projectStatus,
56}: {
57 isLoading: boolean
58 isProjectNew: boolean
59 projectStatus?: ProjectServiceStatus
60}) => {
61 //
62 if (projectStatus === 'ACTIVE_HEALTHY') return <CheckIcon />
63 if (projectStatus === 'DISABLED') return <AlertIcon />
64 if (projectStatus === 'COMING_UP') return <LoaderIcon />
65 if (isLoading) return <LoaderIcon />
66 // isProjectNew has to be above UNHEALTHY because in the first few minutes, some services might be starting up and show as UNHEALTHY
67 if (isProjectNew) return <LoaderIcon />
68 if (projectStatus === 'UNHEALTHY') return <AlertIcon />
69 return <AlertIcon />
70}
71
72/*
73 * Extract the db_schema from the response.info object
74 */
75export const extractDbSchema = (response: ServiceHealthResponse | undefined) => {
76 if (response?.info && 'db_schema' in response.info) {
77 return response.info.db_schema
78 }
79 return undefined
80}
81
82export const ServiceStatus = () => {
83 const { ref } = useParams()
84 const { data: project } = useSelectedProjectQuery()
85 const [open, setOpen] = useState(false)
86
87 const {
88 projectAuthAll: authEnabled,
89 projectEdgeFunctionAll: edgeFunctionsEnabled,
90 realtimeAll: realtimeEnabled,
91 projectStorageAll: storageEnabled,
92 } = useIsFeatureEnabled([
93 'project_auth:all',
94 'project_edge_function:all',
95 'realtime:all',
96 'project_storage:all',
97 ])
98
99 const isBranch = project?.parentRef !== project?.ref
100
101 // Get branches data when on a branch
102 const { data: branches, isPending: isBranchesLoading } = useBranchesQuery(
103 { projectRef: isBranch ? project?.parentRef : undefined },
104 {
105 enabled: isBranch,
106 refetchInterval: (query) => {
107 const data = query.state.data
108 if (!data) return false
109 const currentBranch = data.find((branch) => branch.project_ref === ref)
110 return ['FUNCTIONS_DEPLOYED', 'MIGRATIONS_FAILED', 'FUNCTIONS_FAILED'].includes(
111 currentBranch?.status || ''
112 )
113 ? false
114 : 5000
115 },
116 }
117 )
118
119 const currentBranch = isBranch
120 ? branches?.find((branch) => branch.project_ref === ref)
121 : undefined
122
123 // [Joshen] Need pooler service check eventually
124 const {
125 data: status,
126 isPending: isLoading,
127 refetch: refetchServiceStatus,
128 } = useProjectServiceStatusQuery(
129 {
130 projectRef: ref,
131 },
132 {
133 refetchInterval: (query) => {
134 const data = query.state.data
135 const isServiceUnhealthy = data?.some((service) => {
136 // if the postgrest service has an empty schema, the user chose to turn off postgrest during project creation
137 if (service.name === 'rest' && extractDbSchema(service) === '') {
138 return false
139 }
140 if (service.status === 'ACTIVE_HEALTHY') {
141 return false
142 }
143 return true
144 })
145
146 return isServiceUnhealthy ? 5000 : false
147 },
148 }
149 )
150 const { data: edgeFunctionsStatus, refetch: refetchEdgeFunctionServiceStatus } =
151 useEdgeFunctionServiceStatusQuery(
152 {
153 projectRef: ref,
154 },
155 {
156 refetchInterval: (query) => {
157 const data = query.state.data
158 return !data?.healthy ? 5000 : false
159 },
160 }
161 )
162
163 const authStatus = status?.find((service) => service.name === 'auth')
164 const restStatus = status?.find((service) => service.name === 'rest')
165 const realtimeStatus = status?.find((service) => service.name === 'realtime')
166 const storageStatus = status?.find((service) => service.name === 'storage')
167 const dbStatus = status?.find((service) => service.name === 'db')
168
169 // [Joshen] Need individual troubleshooting docs for each service eventually for users to self serve
170 const services: {
171 name: string
172 error?: string
173 docsUrl?: string
174 isLoading: boolean
175 status: ProjectServiceStatus
176 logsUrl: string
177 }[] = [
178 {
179 name: 'Database',
180 error: undefined,
181 docsUrl: undefined,
182 isLoading: isLoading,
183 status: dbStatus?.status ?? 'UNHEALTHY',
184 logsUrl: '/logs/postgres-logs',
185 },
186 {
187 name: 'PostgREST',
188 error: restStatus?.error,
189 docsUrl: undefined,
190 isLoading,
191 // If PostgREST has an empty schema, it means it's been disabled
192 status: extractDbSchema(restStatus) === '' ? 'DISABLED' : (restStatus?.status ?? 'UNHEALTHY'),
193 logsUrl: '/logs/postgrest-logs',
194 },
195 ...(authEnabled
196 ? [
197 {
198 name: 'Auth',
199 error: authStatus?.error,
200 docsUrl: undefined,
201 isLoading,
202 status: authStatus?.status ?? 'UNHEALTHY',
203 logsUrl: '/logs/auth-logs',
204 },
205 ]
206 : []),
207 ...(realtimeEnabled
208 ? [
209 {
210 name: 'Realtime',
211 error: realtimeStatus?.error,
212 docsUrl: undefined,
213 isLoading,
214 status: realtimeStatus?.status ?? 'UNHEALTHY',
215 logsUrl: '/logs/realtime-logs',
216 },
217 ]
218 : []),
219 ...(storageEnabled
220 ? [
221 {
222 name: 'Storage',
223 error: storageStatus?.error,
224 docsUrl: undefined,
225 isLoading,
226 status: storageStatus?.status ?? 'UNHEALTHY',
227 logsUrl: '/logs/storage-logs',
228 },
229 ]
230 : []),
231 ...(edgeFunctionsEnabled
232 ? [
233 {
234 name: 'Edge Functions',
235 error: undefined,
236 docsUrl: `${DOCS_URL}/guides/functions/troubleshooting`,
237 isLoading,
238 status: edgeFunctionsStatus?.healthy
239 ? 'ACTIVE_HEALTHY'
240 : isLoading
241 ? 'COMING_UP'
242 : ('UNHEALTHY' as ProjectServiceStatus),
243 logsUrl: '/logs/edge-functions-logs',
244 },
245 ]
246 : []),
247 ...(isBranch
248 ? [
249 {
250 name: 'Migrations',
251 error: undefined,
252 docsUrl: undefined,
253 isLoading: isBranchesLoading,
254 status: (currentBranch?.status === 'FUNCTIONS_DEPLOYED'
255 ? 'ACTIVE_HEALTHY'
256 : currentBranch?.status === 'FUNCTIONS_FAILED' ||
257 currentBranch?.status === 'MIGRATIONS_FAILED'
258 ? 'UNHEALTHY'
259 : 'COMING_UP') as ProjectServiceStatus,
260 logsUrl: '/branches',
261 },
262 ]
263 : []),
264 ]
265
266 const isMigrationLoading =
267 isBranchesLoading ||
268 currentBranch?.status === 'CREATING_PROJECT' ||
269 currentBranch?.status === 'RUNNING_MIGRATIONS'
270 const isLoadingChecks = services.some((service) => service.isLoading)
271 // We consider a service operational if it's healthy or intentionally disabled
272 const allServicesOperational = services.every(
273 (service) => service.status === 'ACTIVE_HEALTHY' || service.status === 'DISABLED'
274 )
275
276 // If the project is less than 5 minutes old, and status is not operational, then it's likely the service is still starting up
277 const isProjectNew =
278 dayjs.utc().diff(dayjs.utc(project?.inserted_at), 'minute') < SERVICE_STATUS_THRESHOLD ||
279 project?.status === 'COMING_UP'
280
281 useEffect(() => {
282 let timer: any
283
284 if (isProjectNew) {
285 const secondsSinceProjectCreated = dayjs
286 .utc()
287 .diff(dayjs.utc(project?.inserted_at), 'seconds')
288 const remainingTimeTillNextCheck = SERVICE_STATUS_THRESHOLD * 60 - secondsSinceProjectCreated
289
290 timer = setTimeout(() => {
291 refetchServiceStatus()
292 refetchEdgeFunctionServiceStatus()
293 }, remainingTimeTillNextCheck * 1000)
294 }
295
296 return () => {
297 clearTimeout(timer)
298 }
299 // eslint-disable-next-line react-hooks/exhaustive-deps
300 }, [isProjectNew])
301
302 return (
303 <Popover modal={false} open={open} onOpenChange={setOpen}>
304 <PopoverTrigger asChild>
305 <Button
306 type="default"
307 icon={
308 isLoadingChecks || (!allServicesOperational && isProjectNew && isMigrationLoading) ? (
309 <LoaderIcon />
310 ) : (
311 <div
312 className={`w-2 h-2 rounded-full ${
313 allServicesOperational ? 'bg-brand' : 'bg-warning'
314 }`}
315 />
316 )
317 }
318 >
319 {isBranch ? 'Branch' : 'Project'} Status
320 </Button>
321 </PopoverTrigger>
322 <PopoverContent className="p-0 w-56" side="bottom" align="center">
323 {services.map((service) => (
324 <Link
325 href={`/project/${ref}${service.logsUrl}`}
326 key={service.name}
327 className="transition px-3 py-2 text-xs flex items-center justify-between border-b last:border-none group relative hover:bg-surface-300"
328 >
329 <div className="flex gap-x-2">
330 <StatusIcon
331 isLoading={service.isLoading}
332 isProjectNew={isProjectNew}
333 projectStatus={service.status}
334 />
335 <div className="flex-1">
336 <p>{service.name}</p>
337 <p className="text-foreground-light flex items-center gap-1">
338 <StatusMessage
339 isLoading={service.isLoading}
340 isProjectNew={isProjectNew}
341 status={service.status}
342 />
343 </p>
344 </div>
345 </div>
346 <div className="flex items-center gap-x-1 transition opacity-0 group-hover:opacity-100">
347 <span className="text-xs text-foreground">View logs</span>
348 <ChevronRight size={14} className="text-foreground" />
349 </div>
350 </Link>
351 ))}
352 {!allServicesOperational && (
353 <div className="flex gap-2 text-xs text-foreground-light px-3 py-2">
354 <div className="mt-0.5">
355 <InfoIcon />
356 </div>
357 <div className="flex flex-col gap-y-1">
358 <p>
359 {isProjectNew ? 'New' : 'Recently restored'} projects can take up to{' '}
360 {SERVICE_STATUS_THRESHOLD} minutes to become fully operational.
361 </p>
362 <p>
363 If services stay unhealthy, refer to our{' '}
364 <InlineLink
365 href={`${DOCS_URL}/guides/troubleshooting/project-status-reports-unhealthy-services`}
366 >
367 docs
368 </InlineLink>{' '}
369 for more information.
370 </p>
371 </div>
372 </div>
373 )}
374 </PopoverContent>
375 </Popover>
376 )
377}