mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 10:55:06 +03:00
## 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>
299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
import { useMemo, useRef } from 'react'
|
|
|
|
import { AdvisorDetail } from './AdvisorDetail'
|
|
import { AdvisorFilters } from './AdvisorFilters'
|
|
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'
|
|
import { useProjectsInfiniteQuery } from '@/data/projects/projects-infinite-query'
|
|
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
|
import { IS_PLATFORM } from '@/lib/constants'
|
|
import { useTrack } from '@/lib/telemetry/track'
|
|
import { AdvisorCategory, useAdvisorStateSnapshot } from '@/state/advisor-state'
|
|
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
|
|
|
|
export const AdvisorPanel = () => {
|
|
const track = useTrack()
|
|
const {
|
|
categoryFilters,
|
|
severityFilters,
|
|
selectedItemId,
|
|
selectedItemSource,
|
|
setCategoryFilters,
|
|
setSeverityFilters,
|
|
setSelectedItem,
|
|
notificationFilterStatuses,
|
|
notificationFilterPriorities,
|
|
setNotificationFilters,
|
|
clearFilters,
|
|
clearNarrowingFilters,
|
|
} = useAdvisorStateSnapshot()
|
|
const { data: project } = useSelectedProjectQuery()
|
|
const { activeSidebar, closeSidebar } = useSidebarManagerSnapshot()
|
|
|
|
const isSidebarOpen = activeSidebar?.id === SIDEBAR_KEYS.ADVISOR_PANEL
|
|
const markedRead = useRef<string[]>([])
|
|
const hasProjectRef = !!project?.ref
|
|
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: shouldLoadLints })
|
|
|
|
const {
|
|
data: healthLintData,
|
|
isPending: isHealthLintsLoading,
|
|
isError: isHealthLintsError,
|
|
} = useProjectHealthLintsQuery({ projectRef: project?.ref }, { enabled: shouldLoadHealthLints })
|
|
|
|
const { data: signalItems } = useAdvisorSignals({
|
|
projectRef: project?.ref,
|
|
enabled: shouldLoadSignals,
|
|
})
|
|
|
|
// Notifications should always load when sidebar is open (shown in both 'all' and 'messages' tabs)
|
|
const shouldLoadNotifications = isSidebarOpen && IS_PLATFORM
|
|
|
|
const notificationStatus = useMemo(() => {
|
|
if (notificationFilterStatuses.includes('archived')) {
|
|
return 'archived'
|
|
}
|
|
if (notificationFilterStatuses.includes('unread')) {
|
|
return 'new'
|
|
}
|
|
return undefined
|
|
}, [notificationFilterStatuses])
|
|
|
|
// Memoize filters to prevent query key changes on every render
|
|
// Use selected organization and project if they exist
|
|
const notificationFilters = useMemo(
|
|
() => ({ priority: notificationFilterPriorities }),
|
|
[notificationFilterPriorities]
|
|
)
|
|
|
|
const {
|
|
data: notificationsData,
|
|
isPending: isNotificationsLoading,
|
|
isError: isNotificationsError,
|
|
} = useNotificationsV2Query(
|
|
{
|
|
status: notificationStatus,
|
|
filters: notificationFilters,
|
|
limit: 20,
|
|
},
|
|
{ enabled: shouldLoadNotifications }
|
|
)
|
|
|
|
const { mutate: updateNotifications } = useNotificationsV2UpdateMutation()
|
|
|
|
const notifications = useMemo(() => {
|
|
return notificationsData?.pages.flatMap((page) => page) ?? []
|
|
}, [notificationsData?.pages])
|
|
|
|
const { data: projectsData } = useProjectsInfiniteQuery({}, { enabled: shouldLoadNotifications })
|
|
|
|
const projectNameByRef = useMemo(() => {
|
|
const map = new Map<string, string>()
|
|
projectsData?.pages.forEach((page) => {
|
|
page.projects.forEach((project) => {
|
|
if (project.ref) map.set(project.ref, project.name)
|
|
})
|
|
})
|
|
return map
|
|
}, [projectsData?.pages])
|
|
|
|
const markNotificationsRead = () => {
|
|
if (markedRead.current.length > 0) {
|
|
updateNotifications({ ids: markedRead.current, status: 'seen' })
|
|
}
|
|
}
|
|
|
|
const lintItems = useMemo<AdvisorItem[]>(() => {
|
|
return createAdvisorLintItems([...(lintData ?? []), ...(healthLintData ?? [])])
|
|
}, [lintData, healthLintData])
|
|
|
|
const notificationItems = useMemo<AdvisorItem[]>(() => {
|
|
if (!IS_PLATFORM) return []
|
|
return createAdvisorNotificationItems(notifications)
|
|
}, [notifications])
|
|
|
|
const combinedItems = useMemo<AdvisorItem[]>(() => {
|
|
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[]>(() => {
|
|
if (severityFilters.length === 0) return itemsFilteredByCategory
|
|
return itemsFilteredByCategory.filter((item) => severityFilters.includes(item.severity))
|
|
}, [itemsFilteredByCategory, severityFilters])
|
|
|
|
const hiddenItemsCount = itemsFilteredByCategory.length - filteredItems.length
|
|
|
|
const selectedItem = combinedItems.find(
|
|
(item) => item.id === selectedItemId && item.source === selectedItemSource
|
|
)
|
|
const isDetailView = !!selectedItem
|
|
|
|
// Only show loading state if the query is actually enabled
|
|
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 || isHealthLintsActuallyLoading
|
|
const isError =
|
|
isLintsError || isNotificationsError || (isHealthLintsError && isShowingHealthOnly)
|
|
|
|
const handleBackToList = () => {
|
|
setSelectedItem(undefined)
|
|
markNotificationsRead()
|
|
}
|
|
|
|
const handleClose = () => {
|
|
markNotificationsRead()
|
|
closeSidebar(SIDEBAR_KEYS.ADVISOR_PANEL)
|
|
}
|
|
|
|
const handleItemClick = (item: AdvisorItem) => {
|
|
setSelectedItem(item.id, item.source)
|
|
|
|
if (item.source === 'notification') {
|
|
const notification = item.original as Notification
|
|
if (notification.status === 'new' && !markedRead.current.includes(notification.id)) {
|
|
markedRead.current.push(notification.id)
|
|
}
|
|
}
|
|
|
|
const advisorCategory = getAdvisorItemTelemetryCategory(item)
|
|
const advisorType =
|
|
item.source === 'signal'
|
|
? item.type
|
|
: item.source === 'lint'
|
|
? item.original.name
|
|
: item.title
|
|
const advisorLevel = item.source === 'lint' ? item.original.level : undefined
|
|
|
|
track('advisor_detail_opened', {
|
|
origin: 'advisor_panel',
|
|
advisorCategory,
|
|
advisorSource: item.source,
|
|
advisorType,
|
|
advisorLevel,
|
|
})
|
|
}
|
|
|
|
const handleUpdateNotificationStatus = (id: string, status: 'archived' | 'seen') => {
|
|
updateNotifications({ ids: [id], status })
|
|
}
|
|
|
|
// 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">
|
|
{isDetailView ? (
|
|
<>
|
|
<AdvisorPanelHeader
|
|
selectedItem={selectedItem}
|
|
onBack={handleBackToList}
|
|
onClose={handleClose}
|
|
/>
|
|
<div className="flex-1 overflow-y-auto">
|
|
{selectedItem ? (
|
|
<AdvisorDetail
|
|
item={selectedItem}
|
|
projectRef={project?.ref ?? ''}
|
|
onUpdateNotificationStatus={handleUpdateNotificationStatus}
|
|
onAfterLintAction={handleBackToList}
|
|
/>
|
|
) : (
|
|
<div className="px-6 py-8">
|
|
<p className="text-sm text-foreground-light">
|
|
Select an advisor item to view more details.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<AdvisorFilters
|
|
categoryFilters={[...categoryFilters]}
|
|
onCategoryFiltersChange={(categories) => {
|
|
setCategoryFilters(categories)
|
|
setSelectedItem(undefined)
|
|
}}
|
|
severityFilters={[...severityFilters]}
|
|
onSeverityFiltersChange={setSeverityFilters}
|
|
statusFilters={[...notificationFilterStatuses]}
|
|
onStatusFiltersChange={(values) => {
|
|
notificationFilterStatuses
|
|
.filter((status) => !values.includes(status))
|
|
.forEach((status) => setNotificationFilters(status, 'status'))
|
|
values
|
|
.filter((status) => !notificationFilterStatuses.includes(status))
|
|
.forEach((status) => setNotificationFilters(status, 'status'))
|
|
}}
|
|
onClose={handleClose}
|
|
isPlatform={IS_PLATFORM}
|
|
/>
|
|
<div className="flex-1 overflow-y-auto">
|
|
<AdvisorPanelBody
|
|
isLoading={isLoading}
|
|
isError={isError}
|
|
filteredItems={filteredItems}
|
|
categoryFilters={[...categoryFilters]}
|
|
severityFilters={[...severityFilters]}
|
|
onItemClick={handleItemClick}
|
|
onClearFilters={clearFilters}
|
|
onShowHiddenItems={clearNarrowingFilters}
|
|
hiddenItemsCount={hiddenItemsCount}
|
|
hasAnyFilters={hasNarrowingFilters}
|
|
hasProjectRef={hasProjectRef}
|
|
projectNameByRef={projectNameByRef}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|