Files
Saxon Fletcher b5daafd264 feat(studio): add health category to the advisor panel (#49662)
## 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

- Replace advisor panel tabs with multi-select category filters,
including Health
- Load health lints in the advisor panel (without blocking other
categories on the slower health request)
- Rename item `tab` to `category` and add empty-state copy for health

Stacked on #49661.

## To test

1. Open any project in Studio.
2. Open Advisor Center from the toolbar (the advisor / lightbulb
control).
3. Confirm the old All / Security / Performance / Messages **tabs are
gone**. You should see **Category**, **Status**, and **Severity**
filters instead.
4. Open Category and confirm **Health** is in the list with Security,
Performance, and Messages.
5. Select only **Health**:
- If the project is healthy: empty state “No health issues detected” /
“Your database, instance and services are all responding normally”.
   - If it is not: only health issues in the list.
6. Clear Health, then filter **Security** and **Performance**
separately. Those lists should still match what you expect from before.
7. With Health selected, also filter Severity to **Info** only. If
nothing matches, you should get “No items found” and a way to clear
filters — not a false “no health issues” message.
8. From project home, click an advisor card. Advisor Center should still
open on that same item.

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

## Summary by CodeRabbit

- **New Features**
- Added category-based filtering for Advisor recommendations, including
Security, Performance, Health, and Messages.
- Added Health issue recommendations and category-specific icons,
labels, and empty-state messaging.
  - Advisor results now load according to the selected categories.
- Added clearer project requirements and hidden-item controls for
filtered results.

- **Bug Fixes**
  - Invalid category and severity filter values are safely ignored.
- Improved categorization and telemetry for Advisor items, including
health and security recommendations.

<!-- 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:22:35 +02:00

147 lines
4.9 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import type { AdvisorSignalItem } from './AdvisorPanel.types'
import {
createAdvisorLintItems,
createAdvisorNotificationItems,
getAdvisorItemSecondaryText,
getAdvisorItemTelemetryCategory,
sortAdvisorItems,
} from './AdvisorPanel.utils'
import type { Lint } from '@/data/lint/lint-query'
import type { Notification } from '@/data/notifications/notifications-v2-query'
const createLint = (overrides: Partial<Lint> = {}): Lint =>
({
cache_key: 'lint-1',
name: 'unknown_lint',
detail: 'Critical lint detail',
level: 'ERROR',
categories: ['SECURITY'],
metadata: {},
...overrides,
}) as Lint
const createNotification = (overrides: Partial<Notification> = {}): Notification =>
({
id: 'notification-1',
inserted_at: '2026-03-01T00:00:00.000Z',
priority: 'Info',
status: 'seen',
data: {
title: 'Notification title',
message: 'Notification body',
actions: [],
},
...overrides,
}) as Notification
const createBannedIPSignalItem = (ip: string): AdvisorSignalItem => ({
id: `signal:banned-ip:${ip}:v1`,
dismissalKey: `signal:banned-ip:${ip}:v1`,
source: 'signal',
type: 'banned-ip',
severity: 'warning',
category: 'security',
title: 'Banned IP address',
summary: `The IP address \`${ip}\` is temporarily blocked.`,
docsUrl: 'https://supabase.com/docs/reference/cli/supabase-network-bans',
actions: [],
sourceData: { type: 'banned-ip', ip },
})
describe('AdvisorPanel.utils', () => {
it('orders mixed lint, signal and notification items by severity and recency', () => {
const lintItems = createAdvisorLintItems([
createLint({ cache_key: 'lint-critical', detail: 'Critical lint detail' }),
])
const signalItems = [createBannedIPSignalItem('203.0.113.10')]
const notificationItems = createAdvisorNotificationItems([
createNotification({
id: 'notification-info',
data: { title: 'Notification title', message: 'Body', actions: [] },
}),
])
const sorted = sortAdvisorItems([...notificationItems, ...signalItems, ...lintItems])
expect(sorted.map((item) => item.source)).toEqual(['lint', 'signal', 'notification'])
})
it('uses database surface-area metadata and the IP address for banned IP signals', () => {
const bannedIpSignal = createBannedIPSignalItem('203.0.113.10')
expect(getAdvisorItemSecondaryText(bannedIpSignal)).toBe('Database · 203.0.113.10')
})
describe('lint categories', () => {
it('files a health lint under the health category', () => {
const [item] = createAdvisorLintItems([
createLint({
cache_key: 'instance_db_down',
name: 'instance_db_down',
categories: ['HEALTH'],
metadata: { type: 'health', entity: 'Database' },
}),
])
expect(item?.category).toBe('health')
expect(getAdvisorItemTelemetryCategory(item!)).toBe('HEALTH')
})
it('keeps security ahead of health when a lint carries both categories', () => {
const [item] = createAdvisorLintItems([
createLint({ cache_key: 'both', categories: ['HEALTH', 'SECURITY'] }),
])
expect(item?.category).toBe('security')
expect(getAdvisorItemTelemetryCategory(item!)).toBe('SECURITY')
})
it('drops lints with no recognised category', () => {
expect(
createAdvisorLintItems([createLint({ categories: [] as Lint['categories'] })])
).toEqual([])
})
})
describe('notification secondary text', () => {
const [notificationWithProject] = createAdvisorNotificationItems([
createNotification({
id: 'notification-with-project',
data: {
title: 'CPU usage is high on my-project.',
message: 'Project my-project has high CPU usage.',
project_ref: 'abcd1234',
actions: [],
},
}),
])
const [notificationWithoutProject] = createAdvisorNotificationItems([
createNotification({
id: 'notification-without-project',
data: { title: 'Generic notification', message: 'Body', actions: [] },
}),
])
it('returns the resolved project name when available in the map', () => {
const projectNameByRef = new Map([['abcd1234', 'my-production-db']])
expect(getAdvisorItemSecondaryText(notificationWithProject, projectNameByRef)).toBe(
'my-production-db'
)
})
it('falls back to the project ref when the name is missing from the map', () => {
expect(getAdvisorItemSecondaryText(notificationWithProject, new Map())).toBe('abcd1234')
})
it('falls back to the project ref when no map is provided', () => {
expect(getAdvisorItemSecondaryText(notificationWithProject)).toBe('abcd1234')
})
it('returns undefined for notifications without a project_ref', () => {
expect(getAdvisorItemSecondaryText(notificationWithoutProject)).toBeUndefined()
})
})
})