Files
supabase/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.ts
T
b721a2d780 feat(studio): advisor signal items for banned IPs (#44372)
## What kind of change does this PR introduce?

Feature. Resolves DEPR-430.

## What is the current behaviour?

The homepage Advisor summary, shared Advisor panel, and top-nav Advisor
indicator only surface lints and notifications. Banned IPs are not
represented as dismissible Advisor items, so network bans are easy to
miss unless a user visits Database Settings directly.

The `public bucket allows listing` warning is no longer part of this PR.
That warning will move to a follow-up Splinter `WARN` lint so it can
flow through the standard lint surfaces instead of a bespoke Studio
signal path.

## What is the new behaviour?

- adds a new Advisor `signal` source for banned IPs on the platform
homepage, in the shared Advisor panel, and in the top-nav Advisor
indicator
- keeps dismissals client-side only for now, scoped by project and exact
IP fingerprint
- keeps banned IP signals at `warning` severity because they still
indicate suspicious traffic and remain actionable if a user wants to
review or remove a ban
- leaves `/project/[ref]/advisors/security` as follow-up work because
that surface is still lint-native, and banned IPs are management-plane
signals rather than Splinter lints

| After |
| --- |
| <img width="1728" height="997" alt="Mallet Toolshed
Supabase-65A60B4A-107E-4D79-B9A8-23F754BEAB08"
src="https://github.com/user-attachments/assets/c08ecbbb-c302-43bd-81bb-6ba7eb18b7b3"
/> |

## Reviewer testing notes

1. Use a throwaway project.
2. Get the database connection string for that project.
3. Attempt to connect with the wrong password 3-4 times until you hit an
`ECONNREFUSED`-style error, which should mean your IP has been banned.
4. Refresh Studio and confirm the project overview shows the new `Banned
IP address` signal.
5. Open the Advisor Center and confirm:
   - the top-nav Advisor dot turns warning yellow
   - the signal detail shows `Entity`, `Issue`, and `Resolve`
   - `Edit network bans`, `Dismiss`, and `Learn more` are present
6. Open Database Settings > Network bans and confirm your banned IP
appears there and can be unbanned.
7. Note that `/project/[ref]/advisors/security` will not show this item.
That page is still lint-only, and this banned IP work is a short-term
client-side signal rather than a true lint.

Longer term, we likely want a more durable event model here so banned
IPs can power notifications, webhooks, emails, and other project-level
alerts.

---------

Co-authored-by: kemal <hello@kemal.earth>
Co-authored-by: Charis Lam <26616127+charislam@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
2026-04-20 10:33:56 +10:00

184 lines
4.9 KiB
TypeScript

import dayjs from 'dayjs'
import { Gauge, Inbox, Shield } from 'lucide-react'
import type { ElementType } from 'react'
import type { AdvisorItem, AdvisorLintItem, AdvisorNotificationItem } from './AdvisorPanel.types'
import { lintInfoMap } from '@/components/interfaces/Linter/Linter.utils'
import type { Lint } from '@/data/lint/lint-query'
import type { Notification, NotificationData } from '@/data/notifications/notifications-v2-query'
import type { AdvisorSeverity, AdvisorTab } from '@/state/advisor-state'
export const MAX_HOMEPAGE_ADVISOR_ITEMS = 4
export const severityOrder: Record<AdvisorSeverity, number> = {
critical: 0,
warning: 1,
info: 2,
}
export const lintLevelToSeverity = (level: Lint['level']): AdvisorSeverity => {
switch (level) {
case 'ERROR':
return 'critical'
case 'WARN':
return 'warning'
default:
return 'info'
}
}
export const notificationPriorityToSeverity = (
priority: string | null | undefined
): AdvisorSeverity => {
switch (priority) {
case 'Critical':
return 'critical'
case 'Warning':
return 'warning'
default:
return 'info'
}
}
export 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)
: undefined
if (!tab) return null
return {
id: lint.cache_key,
title: lint.detail,
severity: lintLevelToSeverity(lint.level),
createdAt: undefined,
tab,
source: 'lint',
original: lint,
}
})
.filter((item): item is AdvisorLintItem => item !== null)
}
export const createAdvisorNotificationItems = (
notifications?: Notification[]
): AdvisorNotificationItem[] => {
if (!notifications) return []
return notifications.map((notification) => {
const data = notification.data as NotificationData
return {
id: notification.id,
title: data.title,
severity: notificationPriorityToSeverity(notification.priority),
createdAt: dayjs(notification.inserted_at).valueOf(),
tab: 'messages' as const,
source: 'notification' as const,
original: notification,
}
})
}
export const sortAdvisorItems = <T extends AdvisorItem>(items: T[]) => {
return [...items].sort((a, b) => {
const severityDiff = severityOrder[a.severity] - severityOrder[b.severity]
if (severityDiff !== 0) return severityDiff
const createdDiff = (b.createdAt ?? 0) - (a.createdAt ?? 0)
if (createdDiff !== 0) return createdDiff
return getAdvisorItemDisplayTitle(a).localeCompare(getAdvisorItemDisplayTitle(b))
})
}
export const formatItemDate = (timestamp: number): string => {
const daysFromNow = dayjs().diff(dayjs(timestamp), 'day')
const formattedTimeFromNow = dayjs(timestamp).fromNow()
const formattedInsertedAt = dayjs(timestamp).format('MMM DD, YYYY')
return daysFromNow > 1 ? formattedInsertedAt : formattedTimeFromNow
}
export const getAdvisorItemDisplayTitle = (item: AdvisorItem): string => {
if (item.source === 'lint') {
return (
lintInfoMap.find((info) => info.name === item.original.name)?.title ||
item.title.replace(/[`\\]/g, '')
)
}
if (item.source === 'signal') {
return `${item.title}`
}
return item.title.replace(/[`\\]/g, '')
}
export const getAdvisorPanelItemDisplayTitle = (item: AdvisorItem): string => {
if (item.source === 'signal') {
return item.title
}
return getAdvisorItemDisplayTitle(item)
}
export const getAdvisorItemSecondaryText = (item: AdvisorItem): string | undefined => {
if (item.source === 'lint') {
return getLintEntityString(item.original)
}
if (item.source === 'signal') {
return `Database · ${item.sourceData.ip}`
}
return undefined
}
export const tabIconMap: Record<Exclude<AdvisorTab, 'all'>, ElementType> = {
security: Shield,
performance: Gauge,
messages: Inbox,
}
export const severityColorClasses: Record<AdvisorSeverity, string> = {
critical: 'text-destructive',
warning: 'text-warning',
info: 'text-foreground-light',
}
export const severityBadgeVariants: Record<AdvisorSeverity, 'destructive' | 'warning' | 'default'> =
{
critical: 'destructive',
warning: 'warning',
info: 'default',
}
export const severityLabels: Record<AdvisorSeverity, string> = {
critical: 'Critical',
warning: 'Warning',
info: 'Info',
}
export const getLintEntityString = (lint: Lint | null): string | undefined => {
if (!lint?.metadata) {
return undefined
}
if (lint.metadata.entity) {
return lint.metadata.entity
}
if (lint.metadata.schema && lint.metadata.name) {
return `${lint.metadata.schema}.${lint.metadata.name}`
}
return undefined
}