Files
supabase/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.ts
Mert YEREKAPAN 4c07df1a48 feat(studio): surface affected project in metric advisories (#46203)
## Summary

Resolves [GROWTH-865](https://linear.app/supabase/issue/GROWTH-865) on
the Studio side. Companion backend PR:
[supabase/platform#33086](https://github.com/supabase/platform/pull/33086).

Resource-exhaustion advisories (CPU, Disk IO, Memory) currently give
users a list of identical-looking messages with no project context. This
PR makes the affected project visible in the advisor panel and hardens
the "Check consumption" deep-link.

### Changes

**Advisor list view** — `AdvisorPanel.utils.ts`,
`AdvisorPanel.types.ts`, `AdvisorPanel.tsx`, `AdvisorPanelBody.tsx`
- `AdvisorNotificationItem` now carries `project_ref`.
- `getAdvisorItemSecondaryText` accepts an optional `projectNameByRef`
map and returns the resolved project name (falling back to the ref if
the lookup hasn't loaded). Falls through to the existing date string for
notifications without a project.
- `AdvisorPanel.tsx` builds the map from `useProjectsInfiniteQuery` and
threads it through `AdvisorPanelBody`.

**NotificationDetail** — `NotificationDetail.tsx`
- `[ref]` / `[slug]` substitution in action URLs falls back to
`data.project_ref` / `data.org_slug` before the `_` literal. This fixes
the universal-link bug Tim reported where "Check consumption" sometimes
resolved to `/project/_/...` while `useProjectDetailQuery` was still
loading.

The companion backend PR updates the notification copy so the
title/message also name the project. Both PRs degrade gracefully if
landed independently.

## Test plan

- [x] `pnpm test:studio -- AdvisorPanel.utils.test.ts` — 6/6 pass (4 new
tests cover the notification branch of `getAdvisorItemSecondaryText`)
- [x] `pnpm typecheck` passes for apps/studio
- [x] `pnpm exec eslint components/ui/AdvisorPanel/` passes
- [ ] Local Studio smoke test: open Advisor → Messages, confirm project
name shows under each notification and "Check consumption" deep-links to
the correct project even if the project detail query is slow

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Advisor Panel notifications now display resolved project names when
available for clearer context.

* **Bug Fixes**
* Notification action URLs now prefer stored project and organization
refs/slugs with improved fallbacks, making action links more reliable.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46203?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-03 09:18:36 +00:00

196 lines
5.4 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,
project_ref: data.project_ref,
}
})
}
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,
projectNameByRef?: ReadonlyMap<string, string>
): string | undefined => {
if (item.source === 'lint') {
return getLintEntityString(item.original)
}
if (item.source === 'signal') {
return `Database · ${item.sourceData.ip}`
}
if (item.source === 'notification') {
if (!item.project_ref) return undefined
return projectNameByRef?.get(item.project_ref) ?? item.project_ref
}
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) {
const extendedMetadata = lint.metadata as typeof lint.metadata & { arguments?: string }
const args =
typeof extendedMetadata.arguments === 'string' ? extendedMetadata.arguments : undefined
return `${lint.metadata.schema}.${lint.metadata.name}${args !== undefined ? `(${args})` : ''}`
}
return undefined
}