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 */