Files
6738dded80 feat(studio): add Health Advisor page (#49663)
## 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 Health Advisor page at `/project/[ref]/advisors/health`
- Put Health Advisor first in the Advisors left nav (above Security),
platform-only
- Register `V` then `H` and a command-menu entry

Stacked on #49662. Top of the stack.

## To test

1. Open any project in Studio.
2. Click **Advisors** in the main nav (or go to
`/project/<ref>/advisors/security`).
3. In the left nav, confirm the order is **Health Advisor**, then
Security Advisor, then Performance Advisor, then Query Performance.
4. Click **Health Advisor**. You should land on a page titled “Health
Advisor” with Errors / Warnings / Info tabs, same layout as Security
Advisor.
5. If the project is healthy, Errors should say no errors were detected.
If it is not, the failing checks should list here (database down,
connection limit, and so on).
6. Click **Refresh** (or Shift+R) and confirm the list reloads.
7. Click a row and confirm the detail panel opens with a link through to
logs, connections, or infrastructure.
8. While still in Advisors, press **V** then **H**. You should jump back
to Health Advisor.
9. Open the command menu and search **Health Advisor**. Choosing it
should navigate to this page.

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

## Summary by CodeRabbit

- **New Features**
- Added a Health Advisor page for reviewing project health findings by
severity and category.
- Added Health Advisor navigation in the advisor menu and a keyboard
shortcut (`V`, then `H`) on supported platforms.
- Added refresh, filtering, selection, and lint detail navigation for
health findings.

- **Bug Fixes**
- Added validation for linter severity values, safely handling
unsupported or missing inputs.

- **Documentation**
- Updated migration and shortcut documentation to include the Health
Advisor.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 11:42:39 +02:00

101 lines
4.2 KiB
TypeScript

import { Ruler } from 'lucide-react'
import { isValidElement } from 'react'
import { describe, expect, it } from 'vitest'
import { lintInfoMap, parseLinterLevel } from './Linter.utils'
import { LINTER_LEVELS } from '@/components/interfaces/Linter/Linter.constants'
import { Lint } from '@/data/lint/lint-query'
const projectRef = 'abc'
const trickySchema = 'a&b=c'
const trickyName = 'd e+f'
describe('Linter.utils lintInfoMap link encoding', () => {
const cases: Array<{ name: string; nameParam?: string; hasSchema: boolean }> = [
{ name: 'unindexed_foreign_keys', hasSchema: true },
{ name: 'unused_index', nameParam: 'table', hasSchema: true },
{ name: 'multiple_permissive_policies', nameParam: 'search', hasSchema: true },
{ name: 'policy_exists_rls_disabled', nameParam: 'search', hasSchema: true },
{ name: 'rls_enabled_no_policy', nameParam: 'search', hasSchema: true },
{ name: 'duplicate_index', nameParam: 'table', hasSchema: true },
{ name: 'function_search_path_mutable', nameParam: 'search', hasSchema: true },
{ name: 'rls_disabled_in_public', nameParam: 'search', hasSchema: true },
{ name: 'extension_in_public', nameParam: 'filter', hasSchema: false },
{ name: 'sensitive_columns_exposed', nameParam: 'table', hasSchema: true },
{ name: 'rls_policy_always_true', nameParam: 'search', hasSchema: true },
{ name: 'pg_graphql_anon_table_exposed', nameParam: 'table', hasSchema: true },
{ name: 'pg_graphql_authenticated_table_exposed', nameParam: 'table', hasSchema: true },
{ name: 'anon_security_definer_function_executable', nameParam: 'search', hasSchema: true },
{
name: 'authenticated_security_definer_function_executable',
nameParam: 'search',
hasSchema: true,
},
]
for (const { name, nameParam, hasSchema } of cases) {
it(`preserves special characters in metadata for ${name}`, () => {
const info = lintInfoMap.find((entry) => entry.name === name)
expect(info, `expected ${name} in lintInfoMap`).toBeDefined()
const url = info!.link({
projectRef,
metadata: {
schema: trickySchema,
name: trickyName,
} as unknown as Lint['metadata'],
})
const parsed = new URL(url, 'http://example.com')
if (hasSchema) {
expect(parsed.searchParams.get('schema')).toBe(trickySchema)
}
if (nameParam) {
expect(parsed.searchParams.get(nameParam)).toBe(trickyName)
}
})
}
it('preserves slash and space in bucket_id for public_bucket_allows_listing', () => {
const info = lintInfoMap.find((entry) => entry.name === 'public_bucket_allows_listing')
expect(info).toBeDefined()
const url = info!.link({
projectRef,
metadata: {
bucket_id: 'a/b c',
} as unknown as Lint['metadata'],
})
expect(url).toBe('/project/abc/storage/files/buckets/a%2Fb%20c')
})
})
describe('Linter.utils lintInfoMap pitr_archiving_stale entry', () => {
it('registers a security entry linking to the PITR settings page', () => {
const info = lintInfoMap.find((entry) => entry.name === 'pitr_archiving_stale')
expect(info, 'expected pitr_archiving_stale in lintInfoMap').toBeDefined()
expect(info!.title).toBe('PITR archiving may be broken')
expect(isValidElement(info!.icon) && info!.icon.type).toBe(Ruler)
expect(info!.category).toBe('security')
expect(info!.linkText).toBe('View settings')
// metadata is unused by this entry's link(), and every field on Lint['metadata'] is optional, so {} needs no cast
expect(info!.link({ projectRef, metadata: {} })).toBe('/project/abc/database/backups/pitr')
expect(info!.docsLink).toContain('/guides/platform/backups#point-in-time-recovery')
})
})
describe('parseLinterLevel', () => {
it('returns the matching level', () => {
expect(parseLinterLevel('ERROR')).toBe(LINTER_LEVELS.ERROR)
expect(parseLinterLevel('WARN')).toBe(LINTER_LEVELS.WARN)
expect(parseLinterLevel('INFO')).toBe(LINTER_LEVELS.INFO)
})
it('returns undefined for values that are not a level', () => {
expect(parseLinterLevel('error')).toBeUndefined()
expect(parseLinterLevel('SOMETHING_ELSE')).toBeUndefined()
expect(parseLinterLevel(undefined)).toBeUndefined()
})
})