feat(studio): show health advisors on the project home (#49661)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Feature

## Summary

- Add a `useProjectHealthLintsQuery` that runs the live health checks
(database down, unreachable, connection limit, service error rate,
infrastructure alerts)
- Surface those results on the project home advisor row alongside
security and performance errors
- Register health lint metadata (titles, docs links, entity icon) so
homepage cards can render them

Bottom of the stack. The advisor sidebar still uses tabs; health items
show under All until #49662.

## To test

1. Open any project home in Studio.
2. Find the Advisor row (the cards under “Advisor found N issues”).
3. If the project has a real health problem, you should see a **HEALTH**
card (for example “Database process is down” or “Database connection
limit reached”), not only SECURITY / PERFORMANCE.
4. If the project is healthy, you should **not** see a HEALTH card.
Existing security and performance cards should still appear as before.
5. Click a HEALTH card (or any advisor card). Advisor Center should open
on that item.
6. In Advisor Center on this PR, health items only show under the
**All** tab — Health is not its own tab yet.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added a Health category to Advisor, with a dedicated tab and activity
icon.
- Added health checks for database availability, connection limits,
service errors, and infrastructure alerts.
- Health issues now appear alongside security and performance
recommendations with relevant troubleshooting links.

- **Bug Fixes**
- Health-related advisor findings are now correctly categorized and
displayed.

- **Tests**
- Added coverage for health checks, categorization, filtering, and
project health query behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Saxon FletcherandCursor authored and GitHub committed 2026-09-03 11:05:14 +02:00
1 parent 7cf5737d36
commit ad33b16f8c
13 files changed
+312 -28

No files matched your search

@@ -16,7 +16,7 @@ export type LintInfo = {
link: (args: { projectRef: string; metadata: Lint['metadata'] }) => string
linkText: string
docsLink: string
category: 'security' | 'performance'
category: 'security' | 'performance' | 'health'
}
export const LINT_TABS: ReadonlyArray<{
@@ -1,11 +1,16 @@
import {
Activity,
AlertTriangle,
Box,
Clock,
Database,
Eye,
Gauge,
Lock,
LockIcon,
Ruler,
Scaling,
Server,
Table2,
TextSearch,
Unlock,
@@ -393,6 +398,80 @@ export const lintInfoMap: LintInfo[] = [
docsLink: `${DOCS_URL}/guides/database/database-linter?lint=0029_authenticated_security_definer_function_executable`,
category: 'security',
},
// Health lints report on the running project rather than on schema, so they link to the
// page that shows what is happening rather than to an object to edit.
{
name: 'instance_db_down',
title: 'Database process is down',
icon: <Database className="text-foreground-muted" size={15} strokeWidth={1.5} />,
link: ({ projectRef }) => `/project/${projectRef}/logs/postgres-logs`,
linkText: 'View database logs',
docsLink: `${DOCS_URL}/guides/platform/troubleshooting`,
category: 'health',
},
{
name: 'db_not_reachable',
title: 'Database not usable',
icon: <Server className="text-foreground-muted" size={15} strokeWidth={1.5} />,
link: ({ projectRef }) => `/project/${projectRef}/database/settings`,
linkText: 'View connection settings',
docsLink: `${DOCS_URL}/guides/database/connecting-to-postgres`,
category: 'health',
},
{
name: 'db_connection_limit_reached',
title: 'Database connection limit reached',
icon: <Gauge className="text-foreground-muted" size={15} strokeWidth={1.5} />,
link: ({ projectRef }) => `/project/${projectRef}/observability/connections`,
linkText: 'View connections',
docsLink: `${DOCS_URL}/guides/database/connection-management`,
category: 'health',
},
{
name: 'log_data_api_error_rate_high',
title: 'Data API error rate is persistently high',
icon: <Activity className="text-foreground-muted" size={15} strokeWidth={1.5} />,
link: ({ projectRef }) => `/project/${projectRef}/logs/edge-logs`,
linkText: 'View logs',
docsLink: `${DOCS_URL}/guides/platform/troubleshooting`,
category: 'health',
},
{
name: 'log_auth_error_rate_high',
title: 'Auth error rate is persistently high',
icon: <Activity className="text-foreground-muted" size={15} strokeWidth={1.5} />,
link: ({ projectRef }) => `/project/${projectRef}/logs/edge-logs`,
linkText: 'View logs',
docsLink: `${DOCS_URL}/guides/platform/troubleshooting`,
category: 'health',
},
{
name: 'log_storage_error_rate_high',
title: 'Storage error rate is persistently high',
icon: <Activity className="text-foreground-muted" size={15} strokeWidth={1.5} />,
link: ({ projectRef }) => `/project/${projectRef}/logs/edge-logs`,
linkText: 'View logs',
docsLink: `${DOCS_URL}/guides/platform/troubleshooting`,
category: 'health',
},
{
name: 'log_edge_function_error_rate_high',
title: 'Edge Function error rate is persistently high',
icon: <Activity className="text-foreground-muted" size={15} strokeWidth={1.5} />,
link: ({ projectRef }) => `/project/${projectRef}/logs/edge-logs`,
linkText: 'View logs',
docsLink: `${DOCS_URL}/guides/platform/troubleshooting`,
category: 'health',
},
{
name: 'instance_alert_firing',
title: 'Infrastructure alerts firing',
icon: <AlertTriangle className="text-foreground-muted" size={15} strokeWidth={1.5} />,
link: ({ projectRef }) => `/project/${projectRef}/settings/infrastructure`,
linkText: 'View infrastructure',
docsLink: `${DOCS_URL}/guides/platform/troubleshooting`,
category: 'health',
},
]
export const LintCTA = ({
@@ -444,6 +523,8 @@ export const EntityTypeIcon = ({ type }: { type: string | undefined }) => {
return <Eye className="text-foreground-muted" size={15} strokeWidth={1.5} />
case 'auth':
return <Lock className="text-foreground-muted" size={15} strokeWidth={1.5} />
case 'health':
return <Activity className="text-foreground-muted" size={15} strokeWidth={1.5} />
default:
return <Box className="text-foreground-muted" size={15} strokeWidth={1.5} />
}
@@ -1,5 +1,5 @@
import { useParams } from 'common'
import { BarChart, Shield } from 'lucide-react'
import { Shield } from 'lucide-react'
import { useCallback, useMemo } from 'react'
import { AiIconAnimation, Badge, Button, Card, CardContent, CardHeader, CardTitle, cn } from 'ui'
import { Row } from 'ui-patterns/Row'
@@ -17,9 +17,11 @@ import {
severityBadgeVariants,
severityColorClasses,
sortAdvisorItems,
tabIconMap,
} from '@/components/ui/AdvisorPanel/AdvisorPanel.utils'
import { useAdvisorSignals } from '@/components/ui/AdvisorPanel/useAdvisorSignals'
import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown'
import { useProjectHealthLintsQuery } from '@/data/lint/health-lints-query'
import { useProjectLintsQuery } from '@/data/lint/lint-query'
import { useTrack } from '@/lib/telemetry/track'
import { useAdvisorStateSnapshot } from '@/state/advisor-state'
@@ -38,15 +40,21 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo
{ enabled: !showEmptyState }
)
const { data: healthLints } = useProjectHealthLintsQuery(
{ projectRef },
{ enabled: !showEmptyState }
)
const { data: signalItems } = useAdvisorSignals({ projectRef, enabled: !showEmptyState })
const advisorItems = useMemo<AdvisorItem[]>(() => {
const criticalLintItems = createAdvisorLintItems(lints).filter(
(item) => item.source === 'lint' && item.original.level === LINTER_LEVELS.ERROR
)
const criticalLintItems = createAdvisorLintItems([
...(lints ?? []),
...(healthLints ?? []),
]).filter((item) => item.source === 'lint' && item.original.level === LINTER_LEVELS.ERROR)
return sortAdvisorItems([...criticalLintItems, ...signalItems])
}, [lints, signalItems])
}, [lints, healthLints, signalItems])
const visibleAdvisorItems = useMemo(
() => advisorItems.slice(0, MAX_HOMEPAGE_ADVISOR_ITEMS),
@@ -136,7 +144,8 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo
<Row maxColumns={4} minWidth={280}>
{visibleAdvisorItems.map((item) => {
const isLint = item.source === 'lint'
const categoryLabel = item.tab === 'performance' ? 'PERFORMANCE' : 'SECURITY'
const categoryLabel = item.tab.toUpperCase()
const CategoryIcon = tabIconMap[item.tab]
const title = getAdvisorItemDisplayTitle(item)
const description =
item.source === 'signal' ? item.summary : isLint ? item.original.detail : ''
@@ -160,19 +169,11 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo
>
<CardHeader className="border-b-0 shrink-0 flex flex-row gap-2 space-y-0 justify-between items-center">
<div className="flex flex-row items-center gap-3">
{item.tab === 'security' ? (
<Shield
size={16}
strokeWidth={1.5}
className={severityColorClasses[item.severity]}
/>
) : (
<BarChart
size={16}
strokeWidth={1.5}
className={severityColorClasses[item.severity]}
/>
)}
<CategoryIcon
size={16}
strokeWidth={1.5}
className={severityColorClasses[item.severity]}
/>
<CardTitle className="text-foreground-light">{categoryLabel}</CardTitle>
</div>
<div className="flex items-center gap-2">
@@ -14,7 +14,7 @@ type AdvisorBaseItem = {
title: string
severity: AdvisorSeverity
createdAt?: number
tab: 'security' | 'performance' | 'messages'
tab: 'security' | 'performance' | 'health' | 'messages'
source: AdvisorItemSource
}
@@ -72,6 +72,19 @@ describe('AdvisorPanel.utils', () => {
expect(getAdvisorItemSecondaryText(bannedIpSignal)).toBe('Database · 203.0.113.10')
})
it('files a health lint under the health tab', () => {
const [item] = createAdvisorLintItems([
createLint({
cache_key: 'instance_db_down',
name: 'instance_db_down',
categories: ['HEALTH'],
metadata: { type: 'health', entity: 'Database' },
}),
])
expect(item?.tab).toBe('health')
})
describe('notification secondary text', () => {
const [notificationWithProject] = createAdvisorNotificationItems([
createNotification({
@@ -1,5 +1,5 @@
import dayjs from 'dayjs'
import { Gauge, Inbox, Shield } from 'lucide-react'
import { Activity, Gauge, Inbox, Shield } from 'lucide-react'
import type { ElementType } from 'react'
import type { AdvisorItem, AdvisorLintItem, AdvisorNotificationItem } from './AdvisorPanel.types'
@@ -50,7 +50,9 @@ export const createAdvisorLintItems = (lintData?: Lint[]): AdvisorLintItem[] =>
? ('security' as const)
: categories.includes('PERFORMANCE')
? ('performance' as const)
: undefined
: categories.includes('HEALTH')
? ('health' as const)
: undefined
if (!tab) return null
@@ -153,6 +155,7 @@ export const getAdvisorItemSecondaryText = (
export const tabIconMap: Record<Exclude<AdvisorTab, 'all'>, ElementType> = {
security: Shield,
performance: Gauge,
health: Activity,
messages: Inbox,
}
@@ -0,0 +1,81 @@
import { HttpResponse } from 'msw'
import { describe, expect, it } from 'vitest'
import { getProjectHealthLints, HEALTH_LINT_NAMES } from './health-lints-query'
import type { components } from '@/data/api'
import { addAPIMock } from '@/tests/lib/msw'
type AdvisorsResponse = components['schemas']['V2ProjectAdvisorsResponse_Output']
type AdvisorsRequestBody = components['schemas']['V2RunProjectAdvisorsBody']
type AdvisorLint = AdvisorsResponse['data']['attributes']['lints'][number]
const createLint = (overrides: Partial<AdvisorLint> = {}): AdvisorLint =>
({
cache_key: 'instance_db_down',
name: 'instance_db_down',
title: 'Database process is down',
level: 'ERROR',
facing: 'EXTERNAL',
categories: ['HEALTH'],
description: 'The instance is running but Postgres is not accepting connections locally.',
detail: 'The on-host check of Postgres is failing.',
remediation: 'https://supabase.com/docs/guides/platform/troubleshooting',
metadata: { type: 'health', entity: 'Database' },
...overrides,
}) as AdvisorLint
const mockAdvisorsRun = (lints: AdvisorLint[], onRequest?: (body: AdvisorsRequestBody) => void) => {
addAPIMock({
method: 'post',
path: '/v2/projects/:ref/advisors/run',
response: async ({ request }) => {
onRequest?.((await request.json()) as AdvisorsRequestBody)
return HttpResponse.json<AdvisorsResponse>({
data: { type: 'project_advisors', attributes: { lints } },
})
},
})
}
describe('getProjectHealthLints', () => {
it('asks the endpoint for the health lints Studio surfaces', async () => {
let requestBody: AdvisorsRequestBody | undefined
mockAdvisorsRun([], (body) => {
requestBody = body
})
await getProjectHealthLints({ projectRef: 'default' })
expect(requestBody?.data.attributes.lints.map((lint) => lint.name)).toEqual([
...HEALTH_LINT_NAMES,
])
})
it('returns the health issues the project actually has', async () => {
mockAdvisorsRun([createLint()])
const result = await getProjectHealthLints({ projectRef: 'default' })
expect(result.map((lint) => lint.name)).toEqual(['instance_db_down'])
})
it('drops results that report on the check rather than on the project', async () => {
mockAdvisorsRun([
createLint(),
createLint({
cache_key: 'advisor_check_unavailable:metrics_unavailable',
name: 'advisor_check_unavailable',
level: 'INFO',
}),
createLint({ cache_key: 'project_not_active', name: 'project_not_active' }),
])
const result = await getProjectHealthLints({ projectRef: 'default' })
expect(result.map((lint) => lint.name)).toEqual(['instance_db_down'])
})
it('throws when no project ref is given', async () => {
await expect(getProjectHealthLints({})).rejects.toThrow('Project ref is required')
})
})
@@ -0,0 +1,91 @@
import { useQuery } from '@tanstack/react-query'
import type { components } from 'api-types'
import { lintKeys } from './keys'
import type { Lint } from './lint-query'
import { handleError, post } from '@/data/fetchers'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { IS_PLATFORM, PROJECT_STATUS } from '@/lib/constants'
import { EMPTY_ARR } from '@/lib/void'
import type { ResponseError, UseCustomQueryOptions } from '@/types'
type AdvisorLintName =
components['schemas']['V2RunProjectAdvisorsBody']['data']['attributes']['lints'][number]['name']
/**
* Health lints are on-demand only on the API side — each one opens a live database
* connection or queries the metrics/logs store — so the endpoint runs only the lints it
* is asked for. We request the checks that mean the project is actually broken, and leave
* out the diagnostic ones (`db_connection_failing`, `instance_telemetry_lost`), which
* describe the state of our own probes more than the state of the project.
*/
export const HEALTH_LINT_NAMES = [
'instance_db_down',
'db_not_reachable',
'db_connection_limit_reached',
'log_data_api_error_rate_high',
'log_auth_error_rate_high',
'log_storage_error_rate_high',
'log_edge_function_error_rate_high',
'instance_alert_firing',
] as const satisfies readonly AdvisorLintName[]
/**
* Names the API only ever returns as a result: they report that a check could not run
* rather than an issue with the project, and several checks can return the same one, so
* they'd show up as duplicate non-issues in the advisor lists.
*/
const RESULT_ONLY_LINT_NAMES = new Set(['project_not_active', 'advisor_check_unavailable'])
type ProjectHealthLintsVariables = {
projectRef?: string
}
export async function getProjectHealthLints(
{ projectRef }: ProjectHealthLintsVariables,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('Project ref is required')
const { data, error } = await post('/v2/projects/{ref}/advisors/run', {
params: { path: { ref: projectRef } },
body: {
data: {
type: 'project_advisors',
attributes: { lints: HEALTH_LINT_NAMES.map((name) => ({ name })) },
},
},
signal,
})
if (error) handleError(error)
const lints: Lint[] = data?.data.attributes.lints ?? EMPTY_ARR
return lints.filter((lint) => !RESULT_ONLY_LINT_NAMES.has(lint.name))
}
export type ProjectHealthLintsData = Awaited<ReturnType<typeof getProjectHealthLints>>
export type ProjectHealthLintsError = ResponseError
export const useProjectHealthLintsQuery = <TData = ProjectHealthLintsData>(
{ projectRef }: ProjectHealthLintsVariables,
{
enabled = true,
...options
}: UseCustomQueryOptions<ProjectHealthLintsData, ProjectHealthLintsError, TData> = {}
) => {
const { data: project } = useSelectedProjectQuery()
const isActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY
return useQuery<ProjectHealthLintsData, ProjectHealthLintsError, TData>({
queryKey: lintKeys.healthLints(projectRef),
queryFn: ({ signal }) => getProjectHealthLints({ projectRef }, signal),
enabled: enabled && IS_PLATFORM && typeof projectRef !== 'undefined' && isActive,
// Every run costs a live database connection plus a metrics and a logs query, so keep
// repeat mounts (homepage row, advisor panel) on one result and don't retry failures.
staleTime: 60_000,
retry: false,
refetchOnWindowFocus: false,
...options,
})
}
+2
View File
@@ -1,4 +1,6 @@
export const lintKeys = {
lint: (projectRef: string | undefined) => ['projects', projectRef, 'lint'] as const,
healthLints: (projectRef: string | undefined) =>
['projects', projectRef, 'lint', 'health'] as const,
lintRules: (projectRef: string | undefined) => ['projects', projectRef, 'lint-rules'] as const,
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { proxy, snapshot, useSnapshot } from 'valtio'
export type AdvisorTab = 'all' | 'security' | 'performance' | 'messages'
export type AdvisorTab = 'all' | 'security' | 'performance' | 'health' | 'messages'
export type AdvisorSeverity = 'critical' | 'warning' | 'info'
export type AdvisorItemSource = 'lint' | 'notification' | 'signal'
+4 -1
View File
@@ -5469,7 +5469,10 @@ export interface components {
| 'instance_telemetry_lost'
| 'instance_db_down'
| 'instance_alert_firing'
| 'log_service_error_rate_high'
| 'log_data_api_error_rate_high'
| 'log_auth_error_rate_high'
| 'log_storage_error_rate_high'
| 'log_edge_function_error_rate_high'
| 'project_not_active'
| 'advisor_check_unavailable'
/** Format: date-time */
+8 -2
View File
@@ -1865,7 +1865,10 @@ export interface components {
| 'instance_telemetry_lost'
| 'instance_db_down'
| 'instance_alert_firing'
| 'log_service_error_rate_high'
| 'log_data_api_error_rate_high'
| 'log_auth_error_rate_high'
| 'log_storage_error_rate_high'
| 'log_edge_function_error_rate_high'
| 'project_not_active'
| 'advisor_check_unavailable'
/** Format: date-time */
@@ -2082,7 +2085,10 @@ export interface components {
| 'instance_telemetry_lost'
| 'instance_db_down'
| 'instance_alert_firing'
| 'log_service_error_rate_high'
| 'log_data_api_error_rate_high'
| 'log_auth_error_rate_high'
| 'log_storage_error_rate_high'
| 'log_edge_function_error_rate_high'
}[]
}
/**
+4 -1
View File
@@ -7993,7 +7993,10 @@ export interface components {
| 'instance_telemetry_lost'
| 'instance_db_down'
| 'instance_alert_firing'
| 'log_service_error_rate_high'
| 'log_data_api_error_rate_high'
| 'log_auth_error_rate_high'
| 'log_storage_error_rate_high'
| 'log_edge_function_error_rate_high'
| 'project_not_active'
| 'advisor_check_unavailable'
/** Format: date-time */