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>
This commit is contained in:
authored and GitHub committed 2026-09-03 11:22:35 +02:00
1 parent ad33b16f8c
commit b5daafd264
10 files changed
+278 -193

No files matched your search

@@ -11,13 +11,14 @@ import { createLintSummaryPrompt } from '@/components/interfaces/Linter/Linter.u
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import type { AdvisorItem } from '@/components/ui/AdvisorPanel/AdvisorPanel.types'
import {
advisorCategoryIcons,
createAdvisorLintItems,
getAdvisorItemDisplayTitle,
getAdvisorItemTelemetryCategory,
MAX_HOMEPAGE_ADVISOR_ITEMS,
severityBadgeVariants,
severityColorClasses,
sortAdvisorItems,
tabIconMap,
} from '@/components/ui/AdvisorPanel/AdvisorPanel.utils'
import { useAdvisorSignals } from '@/components/ui/AdvisorPanel/useAdvisorSignals'
import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown'
@@ -88,12 +89,7 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo
setSelectedItem(item.id, item.source)
openSidebar(SIDEBAR_KEYS.ADVISOR_PANEL)
const advisorCategory =
item.source === 'lint'
? item.original.categories[0]
: item.source === 'signal'
? 'SECURITY'
: undefined
const advisorCategory = getAdvisorItemTelemetryCategory(item)
const advisorType =
item.source === 'signal'
? item.type
@@ -144,8 +140,9 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo
<Row maxColumns={4} minWidth={280}>
{visibleAdvisorItems.map((item) => {
const isLint = item.source === 'lint'
const categoryLabel = item.tab.toUpperCase()
const CategoryIcon = tabIconMap[item.tab]
// Only security, performance and health items reach the homepage row
const categoryLabel = item.category.toUpperCase()
const CategoryIcon = advisorCategoryIcons[item.category]
const title = getAdvisorItemDisplayTitle(item)
const description =
item.source === 'signal' ? item.summary : isLint ? item.original.detail : ''
@@ -200,7 +197,7 @@ export const AdvisorSection = ({ showEmptyState = false }: { showEmptyState?: bo
})
track('advisor_assistant_button_clicked', {
origin: 'homepage',
advisorCategory: item.original.categories[0],
advisorCategory: getAdvisorItemTelemetryCategory(item),
advisorType: item.original.name,
advisorLevel: item.original.level,
})
@@ -244,7 +241,7 @@ function EmptyState() {
<CardContent className="flex flex-col items-center justify-center gap-2 p-16 h-full">
<Shield size={20} strokeWidth={1.5} className="text-foreground-muted" />
<p className="text-sm text-foreground-light text-center">
No security or performance issues found
No security, performance or health issues found
</p>
</CardContent>
</Card>
@@ -1,9 +1,30 @@
import { X } from 'lucide-react'
import { Tabs, TabsList, TabsTrigger } from 'ui'
import { z } from 'zod'
import { advisorCategoryLabels } from './AdvisorPanel.utils'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { FilterPopover } from '@/components/ui/FilterPopover'
import { AdvisorSeverity, AdvisorTab } from '@/state/advisor-state'
import {
AdvisorCategory,
advisorCategorySchema,
AdvisorSeverity,
advisorSeveritySchema,
} from '@/state/advisor-state'
/**
* FilterPopover reports its selection as plain strings, so validate them against the
* schema before they flow back into typed state. Unrecognized values are dropped rather
* than throwing — a stale option should not take the panel down.
*/
const parseFilterValues = <T extends string>(schema: z.ZodType<T>, values: string[]): T[] =>
values.flatMap((value) => {
const result = schema.safeParse(value)
return result.success ? [result.data] : []
})
const platformCategories: AdvisorCategory[] = ['security', 'performance', 'health', 'messages']
// Health runs against platform infrastructure and messages are platform notifications
const selfHostedCategories: AdvisorCategory[] = ['security', 'performance']
const severityOptions = [
{ label: 'Critical', value: 'critical' },
@@ -17,8 +38,8 @@ const statusOptions = [
]
interface AdvisorFiltersProps {
activeTab: AdvisorTab
onTabChange: (tab: string) => void
categoryFilters: AdvisorCategory[]
onCategoryFiltersChange: (filters: AdvisorCategory[]) => void
severityFilters: AdvisorSeverity[]
onSeverityFiltersChange: (filters: AdvisorSeverity[]) => void
statusFilters: string[]
@@ -28,8 +49,8 @@ interface AdvisorFiltersProps {
}
export const AdvisorFilters = ({
activeTab,
onTabChange,
categoryFilters,
onCategoryFiltersChange,
severityFilters,
onSeverityFiltersChange,
statusFilters,
@@ -37,28 +58,25 @@ export const AdvisorFilters = ({
onClose,
isPlatform = false,
}: AdvisorFiltersProps) => {
const categoryOptions = (isPlatform ? platformCategories : selfHostedCategories).map(
(category) => ({ label: advisorCategoryLabels[category], value: category })
)
return (
<div className="border-b overflow-x-auto">
<div className="flex items-center justify-between gap-x-4 h-[calc(var(--header-height)-1px)]">
<Tabs value={activeTab} onValueChange={onTabChange} className="h-full pl-4">
<TabsList className="border-b-0 gap-4 h-full">
<TabsTrigger value="all" className="h-full text-xs">
All
</TabsTrigger>
<TabsTrigger value="security" className="h-full text-xs">
Security
</TabsTrigger>
<TabsTrigger value="performance" className="h-full text-xs">
Performance
</TabsTrigger>
{isPlatform && (
<TabsTrigger value="messages" className="h-full text-xs flex items-center gap-2">
Messages
</TabsTrigger>
)}
</TabsList>
</Tabs>
<div className="flex items-center gap-x-2 pr-3">
<div className="flex items-center gap-x-2 pl-3">
<FilterPopover
name="Category"
options={categoryOptions}
activeOptions={[...categoryFilters]}
valueKey="value"
labelKey="label"
isMinimized={true}
onSaveFilters={(values) => {
onCategoryFiltersChange(parseFilterValues(advisorCategorySchema, values))
}}
/>
{isPlatform && (
<FilterPopover
name="Status"
@@ -78,17 +96,17 @@ export const AdvisorFilters = ({
labelKey="label"
isMinimized={true}
onSaveFilters={(values) => {
onSeverityFiltersChange(values as AdvisorSeverity[])
onSeverityFiltersChange(parseFilterValues(advisorSeveritySchema, values))
}}
/>
<ButtonTooltip
variant="text"
className="w-7 h-7 p-0"
icon={<X strokeWidth={1.5} />}
onClick={onClose}
tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }}
/>
</div>
<ButtonTooltip
variant="text"
className="w-7 h-7 p-0 mr-3"
icon={<X strokeWidth={1.5} />}
onClick={onClose}
tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }}
/>
</div>
</div>
)
@@ -6,12 +6,14 @@ import type { AdvisorItem } from './AdvisorPanel.types'
import {
createAdvisorLintItems,
createAdvisorNotificationItems,
getAdvisorItemTelemetryCategory,
sortAdvisorItems,
} from './AdvisorPanel.utils'
import { AdvisorPanelBody } from './AdvisorPanelBody'
import { AdvisorPanelHeader } from './AdvisorPanelHeader'
import { useAdvisorSignals } from './useAdvisorSignals'
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { useProjectHealthLintsQuery } from '@/data/lint/health-lints-query'
import { useProjectLintsQuery } from '@/data/lint/lint-query'
import { Notification, useNotificationsV2Query } from '@/data/notifications/notifications-v2-query'
import { useNotificationsV2UpdateMutation } from '@/data/notifications/notifications-v2-update-mutation'
@@ -19,24 +21,24 @@ import { useProjectsInfiniteQuery } from '@/data/projects/projects-infinite-quer
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { IS_PLATFORM } from '@/lib/constants'
import { useTrack } from '@/lib/telemetry/track'
import { AdvisorTab, useAdvisorStateSnapshot } from '@/state/advisor-state'
import { AdvisorCategory, useAdvisorStateSnapshot } from '@/state/advisor-state'
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
export const AdvisorPanel = () => {
const track = useTrack()
const {
activeTab,
categoryFilters,
severityFilters,
selectedItemId,
selectedItemSource,
setActiveTab,
setCategoryFilters,
setSeverityFilters,
clearSeverityFilters,
setSelectedItem,
notificationFilterStatuses,
notificationFilterPriorities,
setNotificationFilters,
resetNotificationFilters,
clearFilters,
clearNarrowingFilters,
} = useAdvisorStateSnapshot()
const { data: project } = useSelectedProjectQuery()
const { activeSidebar, closeSidebar } = useSidebarManagerSnapshot()
@@ -44,17 +46,32 @@ export const AdvisorPanel = () => {
const isSidebarOpen = activeSidebar?.id === SIDEBAR_KEYS.ADVISOR_PANEL
const markedRead = useRef<string[]>([])
const hasProjectRef = !!project?.ref
const shouldLoadProjectAdvisorData = isSidebarOpen && hasProjectRef && activeTab !== 'messages'
const isCategorySelected = (category: AdvisorCategory) =>
categoryFilters.length === 0 || categoryFilters.includes(category)
// Each source is fetched only when a category it can produce items for is selected, so
// filtering down to one category doesn't run the checks behind the others. Health in
// particular hits live infrastructure, and signals only ever produce security items.
const canLoadProjectData = isSidebarOpen && hasProjectRef
const shouldLoadLints =
canLoadProjectData && (isCategorySelected('security') || isCategorySelected('performance'))
const shouldLoadHealthLints = canLoadProjectData && isCategorySelected('health')
const shouldLoadSignals = canLoadProjectData && isCategorySelected('security')
const {
data: lintData,
isPending: isLintsLoading,
isError: isLintsError,
} = useProjectLintsQuery({ projectRef: project?.ref }, { enabled: shouldLoadProjectAdvisorData })
} = useProjectLintsQuery({ projectRef: project?.ref }, { enabled: shouldLoadLints })
const {
data: healthLintData,
isPending: isHealthLintsLoading,
isError: isHealthLintsError,
} = useProjectHealthLintsQuery({ projectRef: project?.ref }, { enabled: shouldLoadHealthLints })
const { data: signalItems } = useAdvisorSignals({
projectRef: project?.ref,
enabled: shouldLoadProjectAdvisorData,
enabled: shouldLoadSignals,
})
// Notifications should always load when sidebar is open (shown in both 'all' and 'messages' tabs)
@@ -115,8 +132,8 @@ export const AdvisorPanel = () => {
}
const lintItems = useMemo<AdvisorItem[]>(() => {
return createAdvisorLintItems(lintData ?? [])
}, [lintData])
return createAdvisorLintItems([...(lintData ?? []), ...(healthLintData ?? [])])
}, [lintData, healthLintData])
const notificationItems = useMemo<AdvisorItem[]>(() => {
if (!IS_PLATFORM) return []
@@ -127,40 +144,21 @@ export const AdvisorPanel = () => {
return sortAdvisorItems([...lintItems, ...signalItems, ...notificationItems])
}, [lintItems, signalItems, notificationItems])
const itemsFilteredByCategory = useMemo<AdvisorItem[]>(() => {
return combinedItems.filter((item) => {
// Notifications are the only items that exist without a project
if (!hasProjectRef && item.source !== 'notification') return false
return categoryFilters.length === 0 || categoryFilters.includes(item.category)
})
}, [combinedItems, categoryFilters, hasProjectRef])
const filteredItems = useMemo<AdvisorItem[]>(() => {
return combinedItems.filter((item) => {
// Filter by severity
if (severityFilters.length > 0 && !severityFilters.includes(item.severity)) {
return false
}
if (severityFilters.length === 0) return itemsFilteredByCategory
return itemsFilteredByCategory.filter((item) => severityFilters.includes(item.severity))
}, [itemsFilteredByCategory, severityFilters])
// Filter by tab
if (activeTab === 'all') {
// When no projectRef, only show notifications in 'all' tab
if (!hasProjectRef && item.source !== 'notification') {
return false
}
return true
}
return item.tab === activeTab
})
}, [combinedItems, severityFilters, activeTab, hasProjectRef])
const itemsFilteredByTabOnly = useMemo<AdvisorItem[]>(() => {
return combinedItems.filter((item) => {
if (activeTab === 'all') {
// When no projectRef, only show notifications in 'all' tab
if (!hasProjectRef && item.source !== 'notification') {
return false
}
return true
}
return item.tab === activeTab
})
}, [combinedItems, activeTab, hasProjectRef])
const hiddenItemsCount = itemsFilteredByTabOnly.length - filteredItems.length
const hiddenItemsCount = itemsFilteredByCategory.length - filteredItems.length
const selectedItem = combinedItems.find(
(item) => item.id === selectedItemId && item.source === selectedItemSource
@@ -168,17 +166,20 @@ export const AdvisorPanel = () => {
const isDetailView = !!selectedItem
// Only show loading state if the query is actually enabled
const isLintsActuallyLoading = shouldLoadProjectAdvisorData && isLintsLoading
const isLintsActuallyLoading = shouldLoadLints && isLintsLoading
const isNotificationsActuallyLoading = shouldLoadNotifications && isNotificationsLoading
// Health checks hit live infrastructure and are the slowest of the three. They only block
// the list when health is all the user asked for — otherwise health items just fill in
// once they arrive, rather than holding up everything else.
const isShowingHealthOnly = categoryFilters.length === 1 && categoryFilters[0] === 'health'
const isHealthLintsActuallyLoading =
shouldLoadHealthLints && isHealthLintsLoading && isShowingHealthOnly
// [Joshen] Opting to ignore loading and error state of advisor signals - render lints irregardless of banned ips
const isLoading = isLintsActuallyLoading || isNotificationsActuallyLoading
const isError = isLintsError || isNotificationsError
const handleTabChange = (tab: string) => {
setActiveTab(tab as AdvisorTab)
setSelectedItem(undefined)
}
const isLoading =
isLintsActuallyLoading || isNotificationsActuallyLoading || isHealthLintsActuallyLoading
const isError =
isLintsError || isNotificationsError || (isHealthLintsError && isShowingHealthOnly)
const handleBackToList = () => {
setSelectedItem(undefined)
@@ -200,12 +201,7 @@ export const AdvisorPanel = () => {
}
}
const advisorCategory =
item.source === 'lint'
? item.original.categories[0]
: item.source === 'signal'
? 'SECURITY'
: undefined
const advisorCategory = getAdvisorItemTelemetryCategory(item)
const advisorType =
item.source === 'signal'
? item.type
@@ -227,12 +223,9 @@ export const AdvisorPanel = () => {
updateNotifications({ ids: [id], status })
}
const handleClearAllFilters = () => {
clearSeverityFilters()
resetNotificationFilters()
}
const hasAnyFilters = severityFilters.length > 0 || notificationFilterStatuses.length > 0
// Category selection changes which kinds of item are listed; severity and status hide
// items within them, which is what the empty state and "show more" need to know about.
const hasNarrowingFilters = severityFilters.length > 0 || notificationFilterStatuses.length > 0
return (
<div className="flex h-full flex-col bg-background">
@@ -263,8 +256,11 @@ export const AdvisorPanel = () => {
) : (
<>
<AdvisorFilters
activeTab={activeTab}
onTabChange={handleTabChange}
categoryFilters={[...categoryFilters]}
onCategoryFiltersChange={(categories) => {
setCategoryFilters(categories)
setSelectedItem(undefined)
}}
severityFilters={[...severityFilters]}
onSeverityFiltersChange={setSeverityFilters}
statusFilters={[...notificationFilterStatuses]}
@@ -284,12 +280,13 @@ export const AdvisorPanel = () => {
isLoading={isLoading}
isError={isError}
filteredItems={filteredItems}
activeTab={activeTab}
categoryFilters={[...categoryFilters]}
severityFilters={[...severityFilters]}
onItemClick={handleItemClick}
onClearFilters={handleClearAllFilters}
onClearFilters={clearFilters}
onShowHiddenItems={clearNarrowingFilters}
hiddenItemsCount={hiddenItemsCount}
hasAnyFilters={hasAnyFilters}
hasAnyFilters={hasNarrowingFilters}
hasProjectRef={hasProjectRef}
projectNameByRef={projectNameByRef}
/>
@@ -1,6 +1,6 @@
import type { Lint } from '@/data/lint/lint-query'
import type { Notification } from '@/data/notifications/notifications-v2-query'
import type { AdvisorItemSource, AdvisorSeverity } from '@/state/advisor-state'
import type { AdvisorCategory, AdvisorItemSource, AdvisorSeverity } from '@/state/advisor-state'
export type AdvisorSignalType = 'banned-ip'
@@ -14,7 +14,7 @@ type AdvisorBaseItem = {
title: string
severity: AdvisorSeverity
createdAt?: number
tab: 'security' | 'performance' | 'health' | 'messages'
category: AdvisorCategory
source: AdvisorItemSource
}
@@ -5,6 +5,7 @@ import {
createAdvisorLintItems,
createAdvisorNotificationItems,
getAdvisorItemSecondaryText,
getAdvisorItemTelemetryCategory,
sortAdvisorItems,
} from './AdvisorPanel.utils'
import type { Lint } from '@/data/lint/lint-query'
@@ -41,7 +42,7 @@ const createBannedIPSignalItem = (ip: string): AdvisorSignalItem => ({
source: 'signal',
type: 'banned-ip',
severity: 'warning',
tab: 'security',
category: 'security',
title: 'Banned IP address',
summary: `The IP address \`${ip}\` is temporarily blocked.`,
docsUrl: 'https://supabase.com/docs/reference/cli/supabase-network-bans',
@@ -72,17 +73,35 @@ 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' },
}),
])
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?.tab).toBe('health')
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', () => {
@@ -6,7 +6,7 @@ import type { AdvisorItem, AdvisorLintItem, AdvisorNotificationItem } from './Ad
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 { AdvisorSeverity, AdvisorTab } from '@/state/advisor-state'
import type { AdvisorCategory, AdvisorSeverity } from '@/state/advisor-state'
export const MAX_HOMEPAGE_ADVISOR_ITEMS = 4
@@ -40,28 +40,48 @@ export const notificationPriorityToSeverity = (
}
}
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 categories = lint.categories || []
const tab = categories.includes('SECURITY')
? ('security' as const)
: categories.includes('PERFORMANCE')
? ('performance' as const)
: categories.includes('HEALTH')
? ('health' as const)
: undefined
const category = getLintCategory(lint)
if (!tab) return null
if (!category) return null
return {
category: lintToAdvisorCategory[category],
id: lint.cache_key,
title: lint.detail,
severity: lintLevelToSeverity(lint.level),
createdAt: undefined,
tab,
source: 'lint',
original: lint,
}
@@ -82,7 +102,7 @@ export const createAdvisorNotificationItems = (
title: data.title,
severity: notificationPriorityToSeverity(notification.priority),
createdAt: dayjs(notification.inserted_at).valueOf(),
tab: 'messages' as const,
category: 'messages' as const,
source: 'notification' as const,
original: notification,
project_ref: data.project_ref,
@@ -152,13 +172,20 @@ export const getAdvisorItemSecondaryText = (
return undefined
}
export const tabIconMap: Record<Exclude<AdvisorTab, 'all'>, ElementType> = {
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',
@@ -4,17 +4,17 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import type { AdvisorItem } from './AdvisorPanel.types'
import {
advisorCategoryIcons,
formatItemDate,
getAdvisorItemSecondaryText,
getAdvisorPanelItemDisplayTitle,
severityBadgeVariants,
severityColorClasses,
severityLabels,
tabIconMap,
} from './AdvisorPanel.utils'
import { EmptyAdvisor } from './EmptyAdvisor'
import type { Notification } from '@/data/notifications/notifications-v2-query'
import type { AdvisorSeverity, AdvisorTab } from '@/state/advisor-state'
import type { AdvisorCategory, AdvisorSeverity } from '@/state/advisor-state'
const NoProjectNotice = () => {
return (
@@ -23,7 +23,7 @@ const NoProjectNotice = () => {
<div className="text-center">
<p className="heading-default">Project required</p>
<p className="text-foreground-light text-sm">
Select a project to view security and performance advisories
Select a project to view its security, performance and health advisories
</p>
</div>
</div>
@@ -34,10 +34,11 @@ interface AdvisorPanelBodyProps {
isLoading: boolean
isError: boolean
filteredItems: AdvisorItem[]
activeTab: AdvisorTab
categoryFilters: AdvisorCategory[]
severityFilters: AdvisorSeverity[]
onItemClick: (item: AdvisorItem) => void
onClearFilters: () => void
onShowHiddenItems: () => void
hiddenItemsCount: number
hasAnyFilters: boolean
hasProjectRef?: boolean
@@ -48,17 +49,21 @@ export const AdvisorPanelBody = ({
isLoading,
isError,
filteredItems,
activeTab,
categoryFilters,
severityFilters,
onItemClick,
onClearFilters,
onShowHiddenItems,
hiddenItemsCount,
hasAnyFilters,
hasProjectRef = true,
projectNameByRef,
}: AdvisorPanelBodyProps) => {
// Show notice if no project ref and trying to view project-specific tabs
if (!hasProjectRef && activeTab !== 'messages' && activeTab !== 'all') {
// Notifications are the only items that exist without a project, so the notice replaces
// the list whenever the user has narrowed to categories that need one.
const needsProject =
categoryFilters.length > 0 && categoryFilters.every((category) => category !== 'messages')
if (!hasProjectRef && needsProject) {
return <NoProjectNotice />
}
@@ -85,7 +90,7 @@ export const AdvisorPanelBody = ({
if (filteredItems.length === 0) {
return (
<EmptyAdvisor
activeTab={activeTab}
categoryFilters={categoryFilters}
hasFilters={hasAnyFilters}
onClearFilters={onClearFilters}
/>
@@ -96,7 +101,7 @@ export const AdvisorPanelBody = ({
<>
<div className="flex flex-col">
{filteredItems.map((item) => {
const SeverityIcon = tabIconMap[item.tab as Exclude<AdvisorTab, 'all'>]
const CategoryIcon = advisorCategoryIcons[item.category]
const severityClass = severityColorClasses[item.severity]
const isNotification = item.source === 'notification'
const notification = isNotification ? (item.original as Notification) : null
@@ -122,7 +127,7 @@ export const AdvisorPanelBody = ({
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3 overflow-hidden">
<SeverityIcon
<CategoryIcon
size={16}
strokeWidth={1.5}
className={cn('shrink-0', severityClass)}
@@ -160,7 +165,7 @@ export const AdvisorPanelBody = ({
</div>
{severityFilters.length > 0 && hiddenItemsCount > 0 && (
<div className="px-4 py-3">
<Button variant="text" className="w-full" onClick={onClearFilters}>
<Button variant="text" className="w-full" onClick={onShowHiddenItems}>
Show {hiddenItemsCount} more issue{hiddenItemsCount !== 1 ? 's' : ''}
</Button>
</div>
@@ -1,53 +1,68 @@
import { TextSearch } from 'lucide-react'
import { Button } from 'ui'
import type { AdvisorTab } from '@/state/advisor-state'
import type { AdvisorCategory } from '@/state/advisor-state'
const emptyCopyByCategory: Record<AdvisorCategory, { heading: string; message: string }> = {
security: {
heading: 'No security issues detected',
message: 'Congrats! There are no security issues detected for this project',
},
performance: {
heading: 'No performance issues detected',
message: 'Congrats! There are no performance issues detected for this project',
},
health: {
heading: 'No health issues detected',
message: 'Your database, instance and services are all responding normally',
},
messages: {
heading: 'No messages',
message: 'Messages alert you of upcoming changes or potential issues with your project',
},
}
interface EmptyAdvisorProps {
activeTab: AdvisorTab
categoryFilters: AdvisorCategory[]
/**
* Whether severity or status filters are narrowing the list. Unlike the category filter,
* these can hide items the user would otherwise see, so we can't claim nothing was found.
*/
hasFilters: boolean
onClearFilters: () => void
}
export const EmptyAdvisor = ({ activeTab, hasFilters, onClearFilters }: EmptyAdvisorProps) => {
const getHeading = () => {
if (hasFilters) return 'No items found'
export const EmptyAdvisor = ({
categoryFilters,
hasFilters,
onClearFilters,
}: EmptyAdvisorProps) => {
const singleCategory = categoryFilters.length === 1 ? categoryFilters[0] : undefined
const canClearFilters = hasFilters || categoryFilters.length > 0
switch (activeTab) {
case 'security':
return 'No security issues detected'
case 'performance':
return 'No performance issues detected'
case 'messages':
return 'No messages'
default:
return 'No issues detected'
const getCopy = () => {
if (hasFilters) {
return {
heading: 'No items found',
message: 'No advisor items match your current filters',
}
}
if (singleCategory) return emptyCopyByCategory[singleCategory]
return { heading: 'No issues detected', message: 'Congrats! There are no issues detected' }
}
const getMessage = () => {
if (hasFilters) return 'No advisor items match your current filters'
switch (activeTab) {
case 'security':
return 'Congrats! There are no security issues detected for this project'
case 'performance':
return 'Congrats! There are no performance issues detected for this project'
case 'messages':
return 'Messages alert you of upcoming changes or potential issues with your project'
default:
return 'Congrats! There are no issues detected'
}
}
const { heading, message } = getCopy()
return (
<div className="h-full px-6 flex flex-col items-center justify-center w-full gap-y-2">
<TextSearch className="text-foreground-muted" strokeWidth={1} />
<div className="flex flex-col items-center gap-y-0.5 text-center">
<h3 className="heading-default">{getHeading()}</h3>
<p className="text-foreground-light text-sm text-balance">{getMessage()}</p>
<h3 className="heading-default">{heading}</h3>
<p className="text-foreground-light text-sm text-balance">{message}</p>
</div>
{hasFilters && (
{canClearFilters && (
<Button variant="outline" onClick={onClearFilters}>
Clear filters
</Button>
@@ -26,7 +26,7 @@ const createBannedIPSignalItems = ({
source: 'signal' as const,
type: 'banned-ip' as const,
severity: 'warning' as const,
tab: 'security' as const,
category: 'security' as const,
title: 'Banned IP address',
summary: `The IP address \`${ip}\` is temporarily blocked because of suspicious traffic or repeated failed password attempts.`,
description:
+20 -13
View File
@@ -1,11 +1,16 @@
import { proxy, snapshot, useSnapshot } from 'valtio'
import { z } from 'zod'
export type AdvisorTab = 'all' | 'security' | 'performance' | 'health' | 'messages'
export type AdvisorSeverity = 'critical' | 'warning' | 'info'
export const advisorCategorySchema = z.enum(['security', 'performance', 'health', 'messages'])
export const advisorSeveritySchema = z.enum(['critical', 'warning', 'info'])
export type AdvisorCategory = z.infer<typeof advisorCategorySchema>
export type AdvisorSeverity = z.infer<typeof advisorSeveritySchema>
export type AdvisorItemSource = 'lint' | 'notification' | 'signal'
const createInitialState = () => ({
activeTab: 'all' as AdvisorTab,
// An empty selection means every category is shown, matching the severity filter
categoryFilters: [] as AdvisorCategory[],
severityFilters: ['critical', 'warning'] as AdvisorSeverity[],
selectedItemId: undefined as string | undefined,
selectedItemSource: undefined as AdvisorItemSource | undefined,
@@ -16,23 +21,17 @@ const createInitialState = () => ({
export const advisorState = proxy({
...createInitialState(),
setActiveTab(tab: AdvisorTab) {
advisorState.activeTab = tab
setCategoryFilters(categories: AdvisorCategory[]) {
advisorState.categoryFilters = categories
},
setSeverityFilters(severities: AdvisorSeverity[]) {
advisorState.severityFilters = severities
},
clearSeverityFilters() {
advisorState.severityFilters = []
},
setSelectedItem(id: string | undefined, source?: AdvisorItemSource) {
advisorState.selectedItemId = id
advisorState.selectedItemSource = source
},
focusItem({ id, tab, source }: { id: string; tab?: AdvisorTab; source?: AdvisorItemSource }) {
if (tab) {
advisorState.activeTab = tab
}
focusItem({ id, source }: { id: string; source?: AdvisorItemSource }) {
advisorState.selectedItemId = id
advisorState.selectedItemSource = source
},
@@ -60,7 +59,15 @@ export const advisorState = proxy({
break
}
},
resetNotificationFilters() {
/** Clears the filters that hide items within the selected categories, but not the categories themselves. */
clearNarrowingFilters() {
advisorState.severityFilters = []
advisorState.notificationFilterStatuses = []
advisorState.notificationFilterPriorities = []
},
clearFilters() {
advisorState.categoryFilters = []
advisorState.severityFilters = []
advisorState.notificationFilterStatuses = []
advisorState.notificationFilterPriorities = []
},