From ad33b16f8c52f33b67400b30061d81bc33d5eefb Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Thu, 3 Sep 2026 19:05:14 +1000 Subject: [PATCH] feat(studio): show health advisors on the project home (#49661) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. ## 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. --------- Co-authored-by: Cursor --- .../interfaces/Linter/Linter.constants.ts | 2 +- .../interfaces/Linter/Linter.utils.tsx | 81 +++++++++++++++++ .../interfaces/ProjectHome/AdvisorSection.tsx | 39 ++++---- .../ui/AdvisorPanel/AdvisorPanel.types.ts | 2 +- .../AdvisorPanel/AdvisorPanel.utils.test.ts | 13 +++ .../ui/AdvisorPanel/AdvisorPanel.utils.ts | 7 +- .../data/lint/health-lints-query.test.ts | 81 +++++++++++++++++ apps/studio/data/lint/health-lints-query.ts | 91 +++++++++++++++++++ apps/studio/data/lint/keys.ts | 2 + apps/studio/state/advisor-state.ts | 2 +- packages/api-types/types/api-v1.d.ts | 5 +- packages/api-types/types/api-v2.d.ts | 10 +- packages/api-types/types/platform.d.ts | 5 +- 13 files changed, 312 insertions(+), 28 deletions(-) create mode 100644 apps/studio/data/lint/health-lints-query.test.ts create mode 100644 apps/studio/data/lint/health-lints-query.ts diff --git a/apps/studio/components/interfaces/Linter/Linter.constants.ts b/apps/studio/components/interfaces/Linter/Linter.constants.ts index a251a0465aa..1644a22f126 100644 --- a/apps/studio/components/interfaces/Linter/Linter.constants.ts +++ b/apps/studio/components/interfaces/Linter/Linter.constants.ts @@ -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<{ diff --git a/apps/studio/components/interfaces/Linter/Linter.utils.tsx b/apps/studio/components/interfaces/Linter/Linter.utils.tsx index bc373167d90..4738d9e18ec 100644 --- a/apps/studio/components/interfaces/Linter/Linter.utils.tsx +++ b/apps/studio/components/interfaces/Linter/Linter.utils.tsx @@ -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: , + 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: , + 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: , + 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: , + 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: , + 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: , + 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: , + 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: , + 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 case 'auth': return + case 'health': + return default: return } diff --git a/apps/studio/components/interfaces/ProjectHome/AdvisorSection.tsx b/apps/studio/components/interfaces/ProjectHome/AdvisorSection.tsx index b0dc49349fa..d4c1508fe32 100644 --- a/apps/studio/components/interfaces/ProjectHome/AdvisorSection.tsx +++ b/apps/studio/components/interfaces/ProjectHome/AdvisorSection.tsx @@ -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(() => { - 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 {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 >
- {item.tab === 'security' ? ( - - ) : ( - - )} + {categoryLabel}
diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.types.ts b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.types.ts index 17f8fd43ea2..e88cca874cc 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.types.ts +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.types.ts @@ -14,7 +14,7 @@ type AdvisorBaseItem = { title: string severity: AdvisorSeverity createdAt?: number - tab: 'security' | 'performance' | 'messages' + tab: 'security' | 'performance' | 'health' | 'messages' source: AdvisorItemSource } diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.test.ts b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.test.ts index 23366adc4ec..bd7c03ed156 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.test.ts +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.test.ts @@ -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({ diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.ts b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.ts index 9ee683d0e20..4772d8dcf32 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.ts +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.ts @@ -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, ElementType> = { security: Shield, performance: Gauge, + health: Activity, messages: Inbox, } diff --git a/apps/studio/data/lint/health-lints-query.test.ts b/apps/studio/data/lint/health-lints-query.test.ts new file mode 100644 index 00000000000..9f5ef69f8e0 --- /dev/null +++ b/apps/studio/data/lint/health-lints-query.test.ts @@ -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 => + ({ + 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({ + 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') + }) +}) diff --git a/apps/studio/data/lint/health-lints-query.ts b/apps/studio/data/lint/health-lints-query.ts new file mode 100644 index 00000000000..2008da4d68c --- /dev/null +++ b/apps/studio/data/lint/health-lints-query.ts @@ -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> +export type ProjectHealthLintsError = ResponseError + +export const useProjectHealthLintsQuery = ( + { projectRef }: ProjectHealthLintsVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => { + const { data: project } = useSelectedProjectQuery() + const isActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY + + return useQuery({ + 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, + }) +} diff --git a/apps/studio/data/lint/keys.ts b/apps/studio/data/lint/keys.ts index d1a110e0ddd..a618628c10d 100644 --- a/apps/studio/data/lint/keys.ts +++ b/apps/studio/data/lint/keys.ts @@ -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, } diff --git a/apps/studio/state/advisor-state.ts b/apps/studio/state/advisor-state.ts index adb66003ef7..6a1db973c55 100644 --- a/apps/studio/state/advisor-state.ts +++ b/apps/studio/state/advisor-state.ts @@ -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' diff --git a/packages/api-types/types/api-v1.d.ts b/packages/api-types/types/api-v1.d.ts index 1a8ef0636fb..8c91525db81 100644 --- a/packages/api-types/types/api-v1.d.ts +++ b/packages/api-types/types/api-v1.d.ts @@ -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 */ diff --git a/packages/api-types/types/api-v2.d.ts b/packages/api-types/types/api-v2.d.ts index 7fc9ee5023c..93193ccfcf1 100644 --- a/packages/api-types/types/api-v2.d.ts +++ b/packages/api-types/types/api-v2.d.ts @@ -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' }[] } /** diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index 6baf4030898..507bbc4ac76 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -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 */