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

226 lines
6.3 KiB
TypeScript

import dayjs from 'dayjs'
import { Activity, Gauge, Inbox, Shield } from 'lucide-react'
import type { ElementType } from 'react'
import type { AdvisorItem, AdvisorLintItem, AdvisorNotificationItem } from './AdvisorPanel.types'
import { lintInfoMap } from '@/components/interfaces/Linter/Linter.utils'
import type { Lint } from '@/data/lint/lint-query'
import type { Notification, NotificationData } from '@/data/notifications/notifications-v2-query'
import type { AdvisorCategory, AdvisorSeverity } from '@/state/advisor-state'
export const MAX_HOMEPAGE_ADVISOR_ITEMS = 4
export const severityOrder: Record<AdvisorSeverity, number> = {
critical: 0,
warning: 1,
info: 2,
}
export const lintLevelToSeverity = (level: Lint['level']): AdvisorSeverity => {
switch (level) {
case 'ERROR':
return 'critical'
case 'WARN':
return 'warning'
default:
return 'info'
}
}
export const notificationPriorityToSeverity = (
priority: string | null | undefined
): AdvisorSeverity => {
switch (priority) {
case 'Critical':
return 'critical'
case 'Warning':
return 'warning'
default:
return 'info'
}
}
export type LintCategory = Lint['categories'][number]
/**
* A lint can carry more than one category, so the order here decides which one it is
* filtered and reported under.
*/
const lintCategoryPriority = ['SECURITY', 'PERFORMANCE', 'HEALTH'] as const
const lintToAdvisorCategory = {
SECURITY: 'security',
PERFORMANCE: 'performance',
HEALTH: 'health',
} as const satisfies Record<LintCategory, AdvisorCategory>
export const getLintCategory = (lint: Lint): LintCategory | undefined =>
lintCategoryPriority.find((category) => (lint.categories || []).includes(category))
/**
* Telemetry reports the API's own category names. Notifications have no API category;
* signals are security-only today.
*/
export const getAdvisorItemTelemetryCategory = (item: AdvisorItem): LintCategory | undefined => {
if (item.source === 'lint') return getLintCategory(item.original)
if (item.source === 'signal') return 'SECURITY'
return undefined
}
export const createAdvisorLintItems = (lintData?: Lint[]): AdvisorLintItem[] => {
if (!lintData) return []
return lintData
.map((lint): AdvisorLintItem | null => {
const category = getLintCategory(lint)
if (!category) return null
return {
category: lintToAdvisorCategory[category],
id: lint.cache_key,
title: lint.detail,
severity: lintLevelToSeverity(lint.level),
createdAt: undefined,
source: 'lint',
original: lint,
}
})
.filter((item): item is AdvisorLintItem => item !== null)
}
export const createAdvisorNotificationItems = (
notifications?: Notification[]
): AdvisorNotificationItem[] => {
if (!notifications) return []
return notifications.map((notification) => {
const data = notification.data as NotificationData
return {
id: notification.id,
title: data.title,
severity: notificationPriorityToSeverity(notification.priority),
createdAt: dayjs(notification.inserted_at).valueOf(),
category: 'messages' as const,
source: 'notification' as const,
original: notification,
project_ref: data.project_ref,
}
})
}
export const sortAdvisorItems = <T extends AdvisorItem>(items: T[]) => {
return [...items].sort((a, b) => {
const severityDiff = severityOrder[a.severity] - severityOrder[b.severity]
if (severityDiff !== 0) return severityDiff
const createdDiff = (b.createdAt ?? 0) - (a.createdAt ?? 0)
if (createdDiff !== 0) return createdDiff
return getAdvisorItemDisplayTitle(a).localeCompare(getAdvisorItemDisplayTitle(b))
})
}
export const formatItemDate = (timestamp: number): string => {
const daysFromNow = dayjs().diff(dayjs(timestamp), 'day')
const formattedTimeFromNow = dayjs(timestamp).fromNow()
const formattedInsertedAt = dayjs(timestamp).format('MMM DD, YYYY')
return daysFromNow > 1 ? formattedInsertedAt : formattedTimeFromNow
}
export const getAdvisorItemDisplayTitle = (item: AdvisorItem): string => {
if (item.source === 'lint') {
return (
lintInfoMap.find((info) => info.name === item.original.name)?.title ||
item.title.replace(/[`\\]/g, '')
)
}
if (item.source === 'signal') {
return `${item.title}`
}
return item.title.replace(/[`\\]/g, '')
}
export const getAdvisorPanelItemDisplayTitle = (item: AdvisorItem): string => {
if (item.source === 'signal') {
return item.title
}
return getAdvisorItemDisplayTitle(item)
}
export const getAdvisorItemSecondaryText = (
item: AdvisorItem,
projectNameByRef?: ReadonlyMap<string, string>
): string | undefined => {
if (item.source === 'lint') {
return getLintEntityString(item.original)
}
if (item.source === 'signal') {
return `Database · ${item.sourceData.ip}`
}
if (item.source === 'notification') {
if (!item.project_ref) return undefined
return projectNameByRef?.get(item.project_ref) ?? item.project_ref
}
return undefined
}
export const advisorCategoryIcons: Record<AdvisorCategory, ElementType> = {
security: Shield,
performance: Gauge,
health: Activity,
messages: Inbox,
}
export const advisorCategoryLabels: Record<AdvisorCategory, string> = {
security: 'Security',
performance: 'Performance',
health: 'Health',
messages: 'Messages',
}
export const severityColorClasses: Record<AdvisorSeverity, string> = {
critical: 'text-destructive',
warning: 'text-warning',
info: 'text-foreground-light',
}
export const severityBadgeVariants: Record<AdvisorSeverity, 'destructive' | 'warning' | 'default'> =
{
critical: 'destructive',
warning: 'warning',
info: 'default',
}
export const severityLabels: Record<AdvisorSeverity, string> = {
critical: 'Critical',
warning: 'Warning',
info: 'Info',
}
export const getLintEntityString = (lint: Lint | null): string | undefined => {
if (!lint?.metadata) {
return undefined
}
if (lint.metadata.entity) {
return lint.metadata.entity
}
if (lint.metadata.schema && lint.metadata.name) {
const extendedMetadata = lint.metadata as typeof lint.metadata & { arguments?: string }
const args =
typeof extendedMetadata.arguments === 'string' ? extendedMetadata.arguments : undefined
return `${lint.metadata.schema}.${lint.metadata.name}${args !== undefined ? `(${args})` : ''}`
}
return undefined
}