mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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:
|
||||
|
||||
@@ -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 = []
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user