Files
supabase/apps/studio/components/ui/AdvisorPanel/AdvisorPanel.utils.test.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

115 lines
3.8 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import type { AdvisorSignalItem } from './AdvisorPanel.types'
import {
createAdvisorLintItems,
createAdvisorNotificationItems,
getAdvisorItemSecondaryText,
sortAdvisorItems,
} from './AdvisorPanel.utils'
import type { Lint } from '@/data/lint/lint-query'
import type { Notification } from '@/data/notifications/notifications-v2-query'
const createLint = (overrides: Partial<Lint> = {}): Lint =>
({
cache_key: 'lint-1',
name: 'unknown_lint',
detail: 'Critical lint detail',
level: 'ERROR',
categories: ['SECURITY'],
metadata: {},
...overrides,
}) as Lint
const createNotification = (overrides: Partial<Notification> = {}): Notification =>
({
id: 'notification-1',
inserted_at: '2026-03-01T00:00:00.000Z',
priority: 'Info',
status: 'seen',
data: {
title: 'Notification title',
message: 'Notification body',
actions: [],
},
...overrides,
}) as Notification
const createBannedIPSignalItem = (ip: string): AdvisorSignalItem => ({
id: `signal:banned-ip:${ip}:v1`,
dismissalKey: `signal:banned-ip:${ip}:v1`,
source: 'signal',
type: 'banned-ip',
severity: 'warning',
tab: 'security',
title: 'Banned IP address',
summary: `The IP address \`${ip}\` is temporarily blocked.`,
docsUrl: 'https://supabase.com/docs/reference/cli/supabase-network-bans',
actions: [],
sourceData: { type: 'banned-ip', ip },
})
describe('AdvisorPanel.utils', () => {
it('orders mixed lint, signal and notification items by severity and recency', () => {
const lintItems = createAdvisorLintItems([
createLint({ cache_key: 'lint-critical', detail: 'Critical lint detail' }),
])
const signalItems = [createBannedIPSignalItem('203.0.113.10')]
const notificationItems = createAdvisorNotificationItems([
createNotification({
id: 'notification-info',
data: { title: 'Notification title', message: 'Body', actions: [] },
}),
])
const sorted = sortAdvisorItems([...notificationItems, ...signalItems, ...lintItems])
expect(sorted.map((item) => item.source)).toEqual(['lint', 'signal', 'notification'])
})
it('uses database surface-area metadata and the IP address for banned IP signals', () => {
const bannedIpSignal = createBannedIPSignalItem('203.0.113.10')
expect(getAdvisorItemSecondaryText(bannedIpSignal)).toBe('Database · 203.0.113.10')
})
describe('notification secondary text', () => {
const [notificationWithProject] = createAdvisorNotificationItems([
createNotification({
id: 'notification-with-project',
data: {
title: 'CPU usage is high on my-project.',
message: 'Project my-project has high CPU usage.',
project_ref: 'abcd1234',
actions: [],
},
}),
])
const [notificationWithoutProject] = createAdvisorNotificationItems([
createNotification({
id: 'notification-without-project',
data: { title: 'Generic notification', message: 'Body', actions: [] },
}),
])
it('returns the resolved project name when available in the map', () => {
const projectNameByRef = new Map([['abcd1234', 'my-production-db']])
expect(getAdvisorItemSecondaryText(notificationWithProject, projectNameByRef)).toBe(
'my-production-db'
)
})
it('falls back to the project ref when the name is missing from the map', () => {
expect(getAdvisorItemSecondaryText(notificationWithProject, new Map())).toBe('abcd1234')
})
it('falls back to the project ref when no map is provided', () => {
expect(getAdvisorItemSecondaryText(notificationWithProject)).toBe('abcd1234')
})
it('returns undefined for notifications without a project_ref', () => {
expect(getAdvisorItemSecondaryText(notificationWithoutProject)).toBeUndefined()
})
})
})