From b5daafd2643b2ae2e0e66aa0f1ab090de56191ca Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Thu, 3 Sep 2026 19:22:35 +1000 Subject: [PATCH] feat(studio): add health category to the advisor panel (#49662) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. ## 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. --------- Co-authored-by: Cursor Co-authored-by: Claude Opus 5 --- .../interfaces/ProjectHome/AdvisorSection.tsx | 19 ++- .../ui/AdvisorPanel/AdvisorFilters.tsx | 84 ++++++----- .../ui/AdvisorPanel/AdvisorPanel.tsx | 131 +++++++++--------- .../ui/AdvisorPanel/AdvisorPanel.types.ts | 4 +- .../AdvisorPanel/AdvisorPanel.utils.test.ts | 41 ++++-- .../ui/AdvisorPanel/AdvisorPanel.utils.ts | 53 +++++-- .../ui/AdvisorPanel/AdvisorPanelBody.tsx | 27 ++-- .../ui/AdvisorPanel/EmptyAdvisor.tsx | 77 +++++----- .../ui/AdvisorPanel/useAdvisorSignals.ts | 2 +- apps/studio/state/advisor-state.ts | 33 +++-- 10 files changed, 278 insertions(+), 193 deletions(-) diff --git a/apps/studio/components/interfaces/ProjectHome/AdvisorSection.tsx b/apps/studio/components/interfaces/ProjectHome/AdvisorSection.tsx index d4c1508fe32..9b9b99ee0c8 100644 --- a/apps/studio/components/interfaces/ProjectHome/AdvisorSection.tsx +++ b/apps/studio/components/interfaces/ProjectHome/AdvisorSection.tsx @@ -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 {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() {

- No security or performance issues found + No security, performance or health issues found

diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorFilters.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorFilters.tsx index 7a15ddf8dfd..3c919aaad5f 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorFilters.tsx +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorFilters.tsx @@ -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 = (schema: z.ZodType, 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 (
- - - - All - - - Security - - - Performance - - {isPlatform && ( - - Messages - - )} - - -
+
+ { + onCategoryFiltersChange(parseFilterValues(advisorCategorySchema, values)) + }} + /> {isPlatform && ( { - onSeverityFiltersChange(values as AdvisorSeverity[]) + onSeverityFiltersChange(parseFilterValues(advisorSeveritySchema, values)) }} /> - } - onClick={onClose} - tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }} - />
+ } + onClick={onClose} + tooltip={{ content: { side: 'bottom', text: 'Close Advisor Center' } }} + />
) diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx index e19bd4b1674..1ea6e46cdfc 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.tsx @@ -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([]) 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(() => { - return createAdvisorLintItems(lintData ?? []) - }, [lintData]) + return createAdvisorLintItems([...(lintData ?? []), ...(healthLintData ?? [])]) + }, [lintData, healthLintData]) const notificationItems = useMemo(() => { if (!IS_PLATFORM) return [] @@ -127,40 +144,21 @@ export const AdvisorPanel = () => { return sortAdvisorItems([...lintItems, ...signalItems, ...notificationItems]) }, [lintItems, signalItems, notificationItems]) + const itemsFilteredByCategory = useMemo(() => { + 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(() => { - 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(() => { - 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 (
@@ -263,8 +256,11 @@ export const AdvisorPanel = () => { ) : ( <> { + 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} /> diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.types.ts b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.types.ts index e88cca874cc..75820e7eadb 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.types.ts +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.types.ts @@ -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 } diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.test.ts b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.test.ts index bd7c03ed156..13178bf372d 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.test.ts +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.test.ts @@ -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', () => { diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.ts b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.ts index 4772d8dcf32..efdd02e2efd 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.ts +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.ts @@ -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 + +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, ElementType> = { +export const advisorCategoryIcons: Record = { security: Shield, performance: Gauge, health: Activity, messages: Inbox, } +export const advisorCategoryLabels: Record = { + security: 'Security', + performance: 'Performance', + health: 'Health', + messages: 'Messages', +} + export const severityColorClasses: Record = { critical: 'text-destructive', warning: 'text-warning', diff --git a/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx index dc231bd5080..78015273dec 100644 --- a/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx +++ b/apps/studio/components/ui/AdvisorPanel/AdvisorPanelBody.tsx @@ -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 = () => {

Project required

- Select a project to view security and performance advisories + Select a project to view its security, performance and health advisories

@@ -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 } @@ -85,7 +90,7 @@ export const AdvisorPanelBody = ({ if (filteredItems.length === 0) { return ( @@ -96,7 +101,7 @@ export const AdvisorPanelBody = ({ <>
{filteredItems.map((item) => { - const SeverityIcon = tabIconMap[item.tab as Exclude] + 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 = ({ >
- {severityFilters.length > 0 && hiddenItemsCount > 0 && (
-
diff --git a/apps/studio/components/ui/AdvisorPanel/EmptyAdvisor.tsx b/apps/studio/components/ui/AdvisorPanel/EmptyAdvisor.tsx index c8a2773fe32..5f195753203 100644 --- a/apps/studio/components/ui/AdvisorPanel/EmptyAdvisor.tsx +++ b/apps/studio/components/ui/AdvisorPanel/EmptyAdvisor.tsx @@ -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 = { + 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 (
-

{getHeading()}

-

{getMessage()}

+

{heading}

+

{message}

- {hasFilters && ( + {canClearFilters && ( diff --git a/apps/studio/components/ui/AdvisorPanel/useAdvisorSignals.ts b/apps/studio/components/ui/AdvisorPanel/useAdvisorSignals.ts index 08592d73b24..19f5561ee8b 100644 --- a/apps/studio/components/ui/AdvisorPanel/useAdvisorSignals.ts +++ b/apps/studio/components/ui/AdvisorPanel/useAdvisorSignals.ts @@ -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: diff --git a/apps/studio/state/advisor-state.ts b/apps/studio/state/advisor-state.ts index 6a1db973c55..0916c4a288a 100644 --- a/apps/studio/state/advisor-state.ts +++ b/apps/studio/state/advisor-state.ts @@ -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 +export type AdvisorSeverity = z.infer 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 = [] },