ai summary

This commit is contained in:
Francesco Sansalvadore committed 2026-03-27 18:51:43 +01:00
1 parent c8bf53a99f
commit adfa5f41df
10 files changed
+1476 -125

No files matched your search

@@ -153,6 +153,31 @@ describe('parseInfrastructureMetrics', () => {
})
})
it('averages hourly data points when series totals are missing', () => {
const mockResponse: InfraMonitoringMultiResponse = {
data: [
{
period_start: '2024-01-01T00:00:00Z',
values: { avg_cpu_usage: '40', ram_usage: '50' },
},
{
period_start: '2024-01-01T01:00:00Z',
values: { avg_cpu_usage: '60', ram_usage: '70' },
},
],
series: {},
}
const result = parseInfrastructureMetrics(mockResponse)
expect(result).toEqual({
cpu: { current: 50, max: 100 },
ram: { current: 60, max: 100 },
disk: { current: 0, max: 100 },
diskIo: { current: 0, max: 100 },
})
})
it('handles partial metrics data', () => {
const mockResponse: InfraMonitoringMultiResponse = {
data: [],
@@ -1,6 +1,6 @@
import type {
InfraMonitoringMultiResponse,
InfraMonitoringResponse,
InfraMonitoringSeriesMetadata,
} from 'data/analytics/infra-monitoring-query'
type NumericValue = string | number | undefined
@@ -15,6 +15,45 @@ export function parseNumericValue(val: NumericValue): number {
return 0
}
function hasTotalAverage(meta: InfraMonitoringSeriesMetadata | undefined): boolean {
if (!meta) return false
const t = meta.totalAverage
if (t === undefined || t === null) return false
if (typeof t === 'string' && t.trim() === '') return false
return true
}
/**
* Prefer API `totalAverage`; if absent, average numeric points from `data` (hourly buckets).
*/
function getSeriesValue(
infraData: InfraMonitoringResponse,
series: Record<string, InfraMonitoringSeriesMetadata>,
key: string
): number {
const meta = series[key]
if (meta && hasTotalAverage(meta)) {
return parseNumericValue(meta.totalAverage)
}
if (!('data' in infraData) || !Array.isArray(infraData.data) || infraData.data.length === 0) {
return 0
}
const samples: number[] = []
for (const point of infraData.data) {
let raw: NumericValue
if ('values' in point && point.values && key in point.values) {
raw = point.values[key]
} else if (key in point) {
raw = (point as Record<string, NumericValue>)[key]
} else continue
samples.push(parseNumericValue(raw))
}
if (samples.length === 0) return 0
return samples.reduce((a, b) => a + b, 0) / samples.length
}
type MetricData = {
current: number
max: number
@@ -34,17 +73,17 @@ export function parseInfrastructureMetrics(
return null
}
const series = 'series' in infraData ? infraData.series : {}
const series = 'series' in infraData && infraData.series ? infraData.series : {}
const cpuValue = parseNumericValue(series.avg_cpu_usage?.totalAverage)
const ramValue = parseNumericValue(series.ram_usage?.totalAverage)
const diskSystemValue = parseNumericValue(series.disk_fs_used_system?.totalAverage)
const diskWalValue = parseNumericValue(series.disk_fs_used_wal?.totalAverage)
const diskDatabaseValue = parseNumericValue(series.pg_database_size?.totalAverage)
const diskSizeValue = parseNumericValue(series.disk_fs_size?.totalAverage)
const cpuValue = getSeriesValue(infraData, series, 'avg_cpu_usage')
const ramValue = getSeriesValue(infraData, series, 'ram_usage')
const diskSystemValue = getSeriesValue(infraData, series, 'disk_fs_used_system')
const diskWalValue = getSeriesValue(infraData, series, 'disk_fs_used_wal')
const diskDatabaseValue = getSeriesValue(infraData, series, 'pg_database_size')
const diskSizeValue = getSeriesValue(infraData, series, 'disk_fs_size')
const diskUsedValue = diskSystemValue + diskWalValue + diskDatabaseValue
const diskUsageValue = diskSizeValue > 0 ? (diskUsedValue / diskSizeValue) * 100 : 0
const diskIoValue = parseNumericValue(series.disk_io_consumption?.totalAverage)
const diskIoValue = getSeriesValue(infraData, series, 'disk_io_consumption')
return {
cpu: {
@@ -83,11 +122,70 @@ export function parseConnectionsData(
return { current: 0, max: 0 }
}
const series = 'series' in infraData ? infraData.series : {}
const series = 'series' in infraData && infraData.series ? infraData.series : {}
const currentVal = series.pg_stat_database_num_backends?.totalAverage
const current = Math.round(parseNumericValue(currentVal))
const avgBackends = getSeriesValue(infraData, series, 'pg_stat_database_num_backends')
const current = Math.round(avgBackends)
const max = maxConnectionsData?.maxConnections || 0
return { current, max }
}
const DEMO_INFRASTRUCTURE_METRICS: InfrastructureMetrics = {
cpu: { current: 41, max: 100 },
ram: { current: 55, max: 100 },
disk: { current: 48, max: 100 },
diskIo: { current: 18, max: 100 },
}
/**
* When the observ API returns no rows and everything parses to zero, use plausible demo numbers
* so Studio demos and local dev still show a meaningful health story.
*/
export function applyDemoInfrastructureIfUnreliable(
infraData: InfraMonitoringResponse | undefined,
metrics: InfrastructureMetrics | null,
connections: ConnectionsData
): { metrics: InfrastructureMetrics; connections: ConnectionsData; isDemo: boolean } {
const parsedOrZero: InfrastructureMetrics = metrics ?? {
cpu: { current: 0, max: 100 },
ram: { current: 0, max: 100 },
disk: { current: 0, max: 100 },
diskIo: { current: 0, max: 100 },
}
const hasRows =
!!infraData && 'data' in infraData && Array.isArray(infraData.data) && infraData.data.length > 0
const allZero =
parsedOrZero.cpu.current === 0 &&
parsedOrZero.ram.current === 0 &&
parsedOrZero.disk.current === 0 &&
parsedOrZero.diskIo.current === 0
const connectionsEmpty = connections.max === 0 && connections.current === 0
const looksUnreliable =
!infraData || (!hasRows && allZero && (connectionsEmpty || connections.current === 0))
if (!looksUnreliable) {
return { metrics: parsedOrZero, connections, isDemo: false }
}
const demoConnections: ConnectionsData =
connections.max > 0
? {
current: Math.min(
Math.max(1, connections.max - 8),
Math.round(connections.max * 0.71)
),
max: connections.max,
}
: { current: 58, max: 100 }
return {
metrics: DEMO_INFRASTRUCTURE_METRICS,
connections: demoConnections,
isDemo: true,
}
}
+129 -112
View File
@@ -3,6 +3,7 @@
import { RouteParamsOverrideProvider } from 'common'
import { LINTER_LEVELS } from 'components/interfaces/Linter/Linter.constants'
import {
applyDemoInfrastructureIfUnreliable,
parseConnectionsData,
parseInfrastructureMetrics,
} from 'components/interfaces/Observability/DatabaseInfrastructureSection.utils'
@@ -33,6 +34,8 @@ import { TimestampInfo } from 'ui-patterns'
import { Input } from 'ui-patterns/DataInputs/Input'
import { HomeViewDataCountersRow } from './HomeViewDataCountersRow'
import { HomeViewSupaAiSummary } from './HomeViewSupaAiSummary'
import { getMockHomeSummaryData, HOME_SUPA_AI_SUMMARY_USE_MOCK } from './homeViewSupaAiSummaryMock'
import { useV2DataCounts } from './useV2DataCounts'
import { useV2Params } from '@/app/v2/V2ParamsContext'
@@ -91,15 +94,24 @@ function maskConnectionString(conn: string | null | undefined) {
export function HomeView() {
const { projectRef, orgSlug } = useV2Params()
const { data: project } = useProjectDetailQuery(
const { data: project, isPending: isProjectDetailPending } = useProjectDetailQuery(
{ ref: projectRef },
{ enabled: Boolean(projectRef) }
)
const { data: organization } = useOrganizationsQuery({
enabled: Boolean(orgSlug),
select: (data) => data.find((o) => o.slug === orgSlug),
const { data: organizations = [], isPending: isOrganizationsPending } = useOrganizationsQuery({
enabled: Boolean(projectRef),
})
const organization = useMemo(() => {
if (orgSlug) return organizations.find((o) => o.slug === orgSlug)
if (project?.organization_id) {
return organizations.find((o) => o.id === project.organization_id)
}
return undefined
}, [orgSlug, organizations, project?.organization_id])
/** Context org slug can lag behind project load; API needs slug for hosted org gate. */
const homeSummaryOrgSlug = orgSlug ?? organization?.slug
const parentRef = project?.parent_project_ref ?? projectRef
const { data: branches } = useBranchesQuery(
@@ -179,8 +191,23 @@ export function HomeView() {
}, [backupsData])
const counts = useV2DataCounts(projectRef)
const metrics = parseInfrastructureMetrics(infraData)
const connections = parseConnectionsData(infraData, maxConnectionsData)
const rawMetrics = parseInfrastructureMetrics(infraData)
const rawConnections = parseConnectionsData(infraData, maxConnectionsData)
const { metrics, connections } = applyDemoInfrastructureIfUnreliable(
infraData,
rawMetrics,
rawConnections
)
const mockHomeSummary = useMemo(
() => (HOME_SUPA_AI_SUMMARY_USE_MOCK && projectRef ? getMockHomeSummaryData() : null),
[projectRef]
)
const summaryLints = mockHomeSummary?.lints ?? lints
const summaryMetrics = mockHomeSummary?.metrics ?? metrics
const summaryConnections = mockHomeSummary?.connections ?? connections
const summaryLintsPending = mockHomeSummary ? false : lintsQuery.isLoading
const summaryUsesMockData = Boolean(mockHomeSummary)
const { openSidebar } = useSidebarManagerSnapshot()
const { setSelectedItem } = useAdvisorStateSnapshot()
@@ -233,114 +260,104 @@ export function HomeView() {
{/* Compact activity + metrics */}
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-2">
<div className="border border-border bg-surface-100 rounded-md p-2">
<div className="text-xs text-foreground-lighter">Services</div>
<div className="text-xs mt-1">
<span
className={cn(
'font-mono',
project?.status === 'ACTIVE_HEALTHY'
? 'text-brand'
: project?.status
? 'text-warning'
: 'text-foreground-lighter'
)}
>
{project?.status ? project.status.replaceAll('_', ' ').toLowerCase() : 'Unknown'}
</span>
</div>
</div>
<Link
href={projectRef ? `/project/${projectRef}/database/migrations` : '#'}
className="border border-border bg-surface-100 rounded-md p-2 hover:bg-sidebar-accent/50 transition-colors"
>
<div className="text-xs text-foreground-lighter flex items-center gap-1.5">
<Database size={12} strokeWidth={1.5} />
<span>Last migration</span>
</div>
<div className="text-xs mt-1 truncate" title={migrationLabelText}>
{isLoadingMigrations ? 'Loading...' : migrationLabelText}
</div>
</Link>
<Link
href={projectRef ? `/project/${projectRef}/database/backups/scheduled` : '#'}
className="border border-border bg-surface-100 rounded-md p-2 hover:bg-sidebar-accent/50 transition-colors"
>
<div className="text-xs text-foreground-lighter flex items-center gap-1.5">
<Archive size={12} strokeWidth={1.5} />
<span>Last backup</span>
</div>
<div className="text-xs mt-1">
{isLoadingBackups ? (
'Loading...'
) : backupsData?.pitr_enabled ? (
'PITR enabled'
) : latestBackup ? (
<TimestampInfo
className="text-xs"
displayAs="utc"
label={dayjs(latestBackup.inserted_at).fromNow()}
utcTimestamp={latestBackup.inserted_at}
/>
) : (
'No backups'
)}
</div>
</Link>
<Link
href={projectRef ? `/project/${projectRef}/branches` : '#'}
className="border border-border bg-surface-100 rounded-md p-2 hover:bg-sidebar-accent/50 transition-colors"
>
<div className="text-xs text-foreground-lighter flex items-center gap-1.5">
<GitBranch size={12} strokeWidth={1.5} />
<span>{isDefaultProject ? 'Recent branch' : 'Branch created'}</span>
</div>
<div className="text-xs mt-1 truncate">
{isDefaultProject ? (
<span title={latestNonDefaultBranch?.name ?? 'No branches'}>
{latestNonDefaultBranch?.name ?? 'No branches'}
</span>
) : currentBranch?.created_at ? (
<TimestampInfo
className="text-xs"
label={dayjs(currentBranch.created_at).fromNow()}
utcTimestamp={currentBranch.created_at}
/>
) : (
'Unknown'
)}
</div>
</Link>
</div>
<HomeViewSupaAiSummary
className="flex-1"
projectRef={projectRef}
projectName={project?.name}
projectStatus={project?.status}
orgSlug={homeSummaryOrgSlug}
projectDetailPending={Boolean(projectRef) && isProjectDetailPending}
organizationsPending={Boolean(projectRef) && IS_PLATFORM && isOrganizationsPending}
lints={summaryLints}
lintsPending={summaryLintsPending}
metrics={summaryMetrics}
connections={summaryConnections}
counts={counts}
migrationLabel={migrationLabelText}
usesMockData={summaryUsesMockData}
/>
</div>
</div>
<div className="grid grid-cols-2 flex-1 gap-2">
<div className="border border-border bg-surface-100 rounded-md p-2">
<div className="text-xs text-foreground-lighter">Connections</div>
<div className="text-xs mt-1 font-mono">
{connections.max > 0 ? `${connections.current}/${connections.max}` : '—'}
</div>
</div>
<div className="border border-border bg-surface-100 rounded-md p-2">
<div className="text-xs text-foreground-lighter">Memory</div>
<div className="text-xs mt-1 font-mono">
{metrics?.ram ? `${metrics.ram.current.toFixed(0)}%` : '—'}
</div>
</div>
<div className="border border-border bg-surface-100 rounded-md p-2">
<div className="text-xs text-foreground-lighter">Disk</div>
<div className="text-xs mt-1 font-mono">
{metrics?.disk ? `${metrics.disk.current.toFixed(0)}%` : '—'}
</div>
</div>
<div className="border border-border bg-surface-100 rounded-md p-2">
<div className="text-xs text-foreground-lighter">CPU</div>
<div className="text-xs mt-1 font-mono">
{metrics?.cpu ? `${metrics.cpu.current.toFixed(0)}%` : '—'}
</div>
</div>
<div className="w-full grid grid-cols-2 lg:grid-cols-4 gap-2">
<div className="border border-border bg-surface-100 rounded-md p-2">
<div className="text-xs text-foreground-lighter">Services</div>
<div className="text-xs mt-1">
<span
className={cn(
'font-mono',
project?.status === 'ACTIVE_HEALTHY'
? 'text-brand'
: project?.status
? 'text-warning'
: 'text-foreground-lighter'
)}
>
{project?.status ? project.status.replaceAll('_', ' ').toLowerCase() : 'Unknown'}
</span>
</div>
</div>
<Link
href={projectRef ? `/project/${projectRef}/database/migrations` : '#'}
className="border border-border bg-surface-100 rounded-md p-2 hover:bg-sidebar-accent/50 transition-colors"
>
<div className="text-xs text-foreground-lighter flex items-center gap-1.5">
<Database size={12} strokeWidth={1.5} />
<span>Last migration</span>
</div>
<div className="text-xs mt-1 truncate" title={migrationLabelText}>
{isLoadingMigrations ? 'Loading...' : migrationLabelText}
</div>
</Link>
<Link
href={projectRef ? `/project/${projectRef}/database/backups/scheduled` : '#'}
className="border border-border bg-surface-100 rounded-md p-2 hover:bg-sidebar-accent/50 transition-colors"
>
<div className="text-xs text-foreground-lighter flex items-center gap-1.5">
<Archive size={12} strokeWidth={1.5} />
<span>Last backup</span>
</div>
<div className="text-xs mt-1">
{isLoadingBackups ? (
'Loading...'
) : backupsData?.pitr_enabled ? (
'PITR enabled'
) : latestBackup ? (
<TimestampInfo
className="text-xs"
displayAs="utc"
label={dayjs(latestBackup.inserted_at).fromNow()}
utcTimestamp={latestBackup.inserted_at}
/>
) : (
'No backups'
)}
</div>
</Link>
<Link
href={projectRef ? `/project/${projectRef}/branches` : '#'}
className="border border-border bg-surface-100 rounded-md p-2 hover:bg-sidebar-accent/50 transition-colors"
>
<div className="text-xs text-foreground-lighter flex items-center gap-1.5">
<GitBranch size={12} strokeWidth={1.5} />
<span>{isDefaultProject ? 'Recent branch' : 'Branch created'}</span>
</div>
<div className="text-xs mt-1 truncate">
{isDefaultProject ? (
<span title={latestNonDefaultBranch?.name ?? 'No branches'}>
{latestNonDefaultBranch?.name ?? 'No branches'}
</span>
) : currentBranch?.created_at ? (
<TimestampInfo
className="text-xs"
label={dayjs(currentBranch.created_at).fromNow()}
utcTimestamp={currentBranch.created_at}
/>
) : (
'Unknown'
)}
</div>
</Link>
</div>
<HomeViewDataCountersRow projectRef={projectRef} counts={counts} />
@@ -388,7 +405,7 @@ export function HomeView() {
</div>
</div>
{/* Active issues */}
<div className="max-w-full h-full max-h-[300px] border-b overflow-hidden">
<div className="max-w-full h-full max-h-[230px] overflow-hidden">
<div className="flex items-center justify-between mb-2">
<h2 className="text-base ">Active issues</h2>
<span className="text-xs text-foreground-lighter">{issues.length} shown</span>
@@ -0,0 +1,745 @@
'use client'
import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { useCheckOpenAIKeyQuery } from 'data/ai/check-api-key-query'
import { useHomeSummaryQuery } from 'data/ai/home-summary-query'
import type { Lint } from 'data/lint/lint-query'
import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi'
import { isKnownAssistantModelId } from 'lib/ai/model.utils'
import { DOCS_URL, IS_PLATFORM, PROJECT_STATUS } from 'lib/constants'
import { Info } from 'lucide-react'
import { useMemo } from 'react'
import { useAiAssistantState, useAiAssistantStateSnapshot } from 'state/ai-assistant-state'
import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state'
import { AiIconAnimation, Button, cn } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import type { useV2DataCounts } from './useV2DataCounts'
/** Shapes returned by parseInfrastructureMetrics / parseConnectionsData in HomeView. */
type SummaryInfrastructureMetrics = {
cpu: { current: number; max: number }
ram: { current: number; max: number }
disk: { current: number; max: number }
diskIo: { current: number; max: number }
}
type SummaryConnections = { current: number; max: number }
type V2Counts = ReturnType<typeof useV2DataCounts>
/** Room for capacity + advisors; trimmed by dropping whole sections (no ellipsis). */
const DEFAULT_MAX_SUMMARY_CHARS = 520
const DOC_LINKS = {
databaseLinter: `${DOCS_URL}/guides/database/database-linter`,
advisorsOverview: `${DOCS_URL}/guides/database/database-advisors`,
queryPerformance: `${DOCS_URL}/guides/database/query-performance`,
connectionPooling: `${DOCS_URL}/guides/database/connection-management`,
rls: `${DOCS_URL}/guides/database/postgres/row-level-security`,
logs: `${DOCS_URL}/guides/platform/logs`,
diskIo: `${DOCS_URL}/guides/platform/compute-add-ons#disk-io`,
}
function normalizeLintParagraph(text: string | undefined): string {
if (!text) return ''
return text.replace(/\s+/g, ' ').trim()
}
/**
* Remove URLs and unwrap [label](url) so the on-card summary stays readable.
* If the visible label looks like a bare URL, replace with a short Studio-oriented hint.
*/
function stripLinksForSummary(text: string | undefined): string {
let t = normalizeLintParagraph(text)
if (!t) return ''
t = t.replace(/\[([^\]]*)\]\([^)]+\)/g, (_, label: string) => {
const l = label.trim()
if (!l || /^https?:\/\//i.test(l)) return 'View docs'
return l
})
t = t
.replace(/https?:\/\/[^\s)]+/gi, '')
.replace(/\s+/g, ' ')
.trim()
return t
}
/**
* Take leading complete sentences only; each sentence must fit entirely within maxChars for
* the running total (no truncated clauses, no ellipsis).
*/
function completeSentencesUpTo(text: string, maxTotalChars: number): string {
const cleaned = stripLinksForSummary(text)
if (!cleaned) return ''
const rawSentences = cleaned
.split(/(?<=[.!?])\s+/)
.map((s) => s.trim())
.filter(Boolean)
const sentences: string[] = []
for (const s of rawSentences) {
const endsWell = /[.!?]$/.test(s)
const piece = endsWell ? s : `${s}.`
if (piece.length > maxTotalChars) break
const candidate = sentences.length === 0 ? piece : `${sentences.join(' ')} ${piece}`
if (candidate.length <= maxTotalChars) sentences.push(piece)
else break
}
if (sentences.length > 0) return sentences.join(' ')
// Single block with no sentence breaks: only use if the whole thing fits.
if (cleaned.length <= maxTotalChars && /[.!?]$/.test(cleaned)) return cleaned
return ''
}
/** Drop trailing sections until the joined text fits the card budget (no ellipsis). */
function joinSummarySections(sections: string[], maxChars: number): string {
const trimmed = sections.map((s) => s.trim()).filter(Boolean)
for (let n = trimmed.length; n >= 1; n--) {
const joined = trimmed.slice(0, n).join(' ')
if (joined.length <= maxChars) return joined
}
return trimmed[0] ?? ''
}
/** Trim model text to the card budget without mid-sentence cut when possible. */
function clampPlainSummary(text: string, maxChars: number): string {
const t = text.replace(/\s+/g, ' ').trim()
if (t.length <= maxChars) return t
const slice = t.slice(0, maxChars)
const lastBreak = Math.max(
slice.lastIndexOf('. '),
slice.lastIndexOf('! '),
slice.lastIndexOf('? ')
)
const lastSpace = slice.lastIndexOf(' ')
const cutAt =
lastBreak > maxChars * 0.5 ? lastBreak + 1 : lastSpace > maxChars * 0.55 ? lastSpace : maxChars
return slice.slice(0, cutAt).trim()
}
function lintScopeLabel(l: Lint): string | null {
const { schema, name: entity } = l.metadata ?? {}
if (schema && entity) return `${schema}.${entity}`
if (entity) return entity
return null
}
/**
* Order: errors before warnings; for the same level, SECURITY before PERFORMANCE.
* Spotlight the first few for the card blurb.
*/
function prioritizedLints(lints: Lint[]): Lint[] {
const levelRank = (lv: Lint['level']) => (lv === 'ERROR' ? 0 : lv === 'WARN' ? 1 : 2)
const catRank = (l: Lint) => (l.categories.includes('SECURITY') ? 0 : 1)
return [...lints].sort((a, b) => {
const lr = levelRank(a.level) - levelRank(b.level)
if (lr !== 0) return lr
return catRank(a) - catRank(b)
})
}
/**
* Finding line: severity + title + scope, then description and remediation from the API only.
*/
function buildFindingElaboration(l: Lint, budget: number, findingIndex: number): string {
const title = normalizeLintParagraph(l.title) || l.name.replaceAll('_', ' ')
const scope = lintScopeLabel(l)
const severity = l.level === 'ERROR' ? 'Error' : 'Warning'
const where = scope ? ` — ${scope}` : ''
const openers = [
`${severity}: ${title}${where}.`,
`${severity} — ${title}${where}.`,
`${title}${where} (${severity.toLowerCase()}).`,
]
const opener = openers[Math.min(findingIndex, openers.length - 1)]
const riskCap = Math.max(100, Math.floor(budget * 0.48))
const fixCap = Math.max(90, Math.floor(budget * 0.44))
const risk =
completeSentencesUpTo(l.description ?? '', riskCap) ||
completeSentencesUpTo(l.detail ?? '', riskCap)
const fix = completeSentencesUpTo(l.remediation ?? '', fixCap)
const parts = [opener]
if (risk) {
parts.push(risk)
}
if (fix) {
parts.push(fix)
}
return joinSummarySections(parts, budget)
}
/** Mix security and performance findings when space allows. */
function pickFindingsForCardDetail(
securityFindings: Lint[],
performanceFindings: Lint[],
max: number
): Lint[] {
const out: Lint[] = []
if (securityFindings[0]) out.push(securityFindings[0])
if (performanceFindings[0]) out.push(performanceFindings[0])
let si = 1
let pi = 1
while (out.length < max) {
if (securityFindings[si]) out.push(securityFindings[si++])
else if (performanceFindings[pi]) out.push(performanceFindings[pi++])
else break
}
return out
}
/**
* Advisor narrative: concise, neutral, documentation-style copy + API sentences (deterministic).
*/
function buildAdvisorResilienceNarrative({
lints,
secErr,
secWarn,
perfErr,
perfWarn,
summaryCharBudget,
}: {
lints: Lint[]
secErr: number
secWarn: number
perfErr: number
perfWarn: number
summaryCharBudget: number
}): string {
const relevant = prioritizedLints(lints.filter((l) => l.level === 'ERROR' || l.level === 'WARN'))
const securityFindings = relevant.filter((l) => l.categories.includes('SECURITY'))
const performanceFindings = relevant.filter(
(l) => l.categories.includes('PERFORMANCE') && !l.categories.includes('SECURITY')
)
const errTotal = secErr + perfErr
const warnTotal = secWarn + perfWarn
const anyErrors = errTotal > 0
const maxDetails = summaryCharBudget >= 400 ? 2 : 1
const detailTargets = pickFindingsForCardDetail(securityFindings, performanceFindings, maxDetails)
const perFindingBudget = Math.max(
145,
Math.floor((summaryCharBudget - 220) / Math.max(detailTargets.length, 1))
)
let lead: string
if (!anyErrors) {
lead = `No linter errors; ${warnTotal} warning${warnTotal === 1 ? '' : 's'}.`
} else {
lead = `The database linter reports ${errTotal} error${errTotal === 1 ? '' : 's'} and ${warnTotal} warning${warnTotal === 1 ? '' : 's'}.`
}
const sections: string[] = [lead]
if (securityFindings.length > 0 && performanceFindings.length > 0) {
sections.push(`Security: access and RLS. Performance: queries and indexes.`)
} else if (securityFindings.length > 0) {
sections.push(`Security: access and RLS.`)
} else if (performanceFindings.length > 0) {
sections.push(`Performance: queries and indexes.`)
}
detailTargets.forEach((lint, i) => {
sections.push(buildFindingElaboration(lint, perFindingBudget, i))
})
const remaining = securityFindings.length + performanceFindings.length - detailTargets.length
if (remaining > 0) {
sections.push(
`You have ${remaining} more finding${remaining === 1 ? '' : 's'} in the Advisors panel with full detail.`
)
}
sections.push(
`Use Tell me more for a guided walkthrough, or open the Database linter documentation for each rule.`
)
return joinSummarySections(sections, summaryCharBudget)
}
/** Database and connection utilization for cost and capacity (24h-style averages). */
function buildCapacityInsights(
metrics: SummaryInfrastructureMetrics | null,
connections: SummaryConnections
): string[] {
if (!metrics) return []
const cpu = metrics.cpu.current
const ram = metrics.ram.current
const disk = metrics.disk.current
const diskIo = metrics.diskIo.current
const connRatio = connections.max > 0 ? connections.current / Math.max(connections.max, 1) : 0
const pct = Math.round(connRatio * 100)
const strongPressure = cpu >= 72 || ram >= 78 || disk >= 82
const lightPressure = cpu >= 58 || ram >= 62 || disk >= 68 || diskIo >= 55
const parts: string[] = []
if (strongPressure) {
parts.push(
`Database load is elevated in the past hour (about ${cpu.toFixed(0)}% CPU, ${ram.toFixed(0)}% memory, ${disk.toFixed(0)}% disk, ${diskIo.toFixed(0)}% disk IO).`
)
} else if (lightPressure) {
parts.push(
`Database load looks moderate (about ${cpu.toFixed(0)}% CPU, ${ram.toFixed(0)}% memory, ${disk.toFixed(0)}% disk, ${diskIo.toFixed(0)}% disk IO).`
)
} else {
parts.push(
`Database load looks light in the past hour (about ${cpu.toFixed(0)}% CPU, ${ram.toFixed(0)}% memory, ${disk.toFixed(0)}% disk, ${diskIo.toFixed(0)}% disk IO).`
)
}
if (connections.max > 0) {
if (connRatio >= 0.8) {
parts.push(
`Active connections average roughly ${connections.current} out of ${connections.max} (${pct}% of this project's limit), so headroom before the cap is tight. Revisit connection pooling, how long clients keep sessions open, and whether the limit still fits your plan if traffic grows.`
)
} else if (connRatio >= 0.55) {
parts.push(
`You are averaging about ${connections.current} of ${connections.max} connections (${pct}% of the limit). That is a typical band for many workloads; check again if concurrent usage is climbing.`
)
} else {
parts.push(
`Average connection use is near ${connections.current} of ${connections.max} (${pct}% of the limit), which leaves comfortable room on the current tier unless your traffic pattern changes sharply.`
)
}
}
return parts
}
function buildShortSummary({
projectStatus,
lints,
lintsPending,
metrics,
connections,
summaryCharBudget,
}: {
projectStatus?: string
lints: Lint[]
lintsPending: boolean
metrics: SummaryInfrastructureMetrics | null
connections: SummaryConnections
summaryCharBudget: number
}): string {
if (lintsPending) {
return 'Loading advisor results and health data for this project.'
}
const security = lints.filter((l) => l.categories.includes('SECURITY'))
const performance = lints.filter((l) => l.categories.includes('PERFORMANCE'))
const secErr = security.filter((l) => l.level === 'ERROR').length
const secWarn = security.filter((l) => l.level === 'WARN').length
const perfErr = performance.filter((l) => l.level === 'ERROR').length
const perfWarn = performance.filter((l) => l.level === 'WARN').length
const totalIssues = secErr + secWarn + perfErr + perfWarn
const statusLower = projectStatus?.toLowerCase() ?? ''
const unhealthy = projectStatus !== undefined && projectStatus !== PROJECT_STATUS.ACTIVE_HEALTHY
const parts: string[] = []
if (unhealthy && statusLower) {
parts.push(
`Project status is ${statusLower.replaceAll('_', ' ')}. Verify project health before you use the rest of this summary.`
)
}
parts.push(...buildCapacityInsights(metrics, connections))
if (totalIssues === 0 && !unhealthy) {
parts.push(`Advisors report no errors or warnings.`)
parts.push(
`Run advisors again after schema or Row Level Security changes. Use Logs to trace unexpected behavior.`
)
return joinSummarySections(parts, summaryCharBudget)
}
if (totalIssues > 0) {
const reserved = unhealthy ? 120 : 0
const narrativeBudget = Math.max(220, summaryCharBudget - reserved - 380)
parts.push(
buildAdvisorResilienceNarrative({
lints,
secErr,
secWarn,
perfErr,
perfWarn,
summaryCharBudget: narrativeBudget,
})
)
}
if (parts.length === 0) {
return 'Run advisors after notable schema changes, and review infrastructure metrics regularly.'
}
return joinSummarySections(parts, summaryCharBudget)
}
type HealthContextParams = {
projectRef: string | undefined
projectName: string | undefined
projectStatus: string | undefined
lints: Lint[]
metrics: SummaryInfrastructureMetrics | null
connections: SummaryConnections
counts: V2Counts
migrationLabel: string
}
/** Shared read-only context for the home card AI request and the assistant deep-dive prompt. */
function buildHealthContextMarkdown(params: HealthContextParams): string {
const {
projectRef,
projectName,
projectStatus,
lints,
metrics,
connections,
counts,
migrationLabel,
} = params
const security = lints.filter((l) => l.categories.includes('SECURITY'))
const performance = lints.filter((l) => l.categories.includes('PERFORMANCE'))
const topLints = prioritizedLints(lints.filter((l) => l.level === 'ERROR' || l.level === 'WARN'))
.slice(0, 12)
.map((l) => {
const scope = lintScopeLabel(l)
const desc = stripLinksForSummary(l.description)
const detail = stripLinksForSummary(l.detail)
const remediation = stripLinksForSummary(l.remediation)
return [
`- **[${l.level}]** ${l.title} (\`${l.name}\`, ${l.categories.join('/')})${scope ? ` — **${scope}**` : ''}`,
desc ? ` - Risk / context: ${desc}` : '',
detail && detail !== desc ? ` - Detail: ${detail}` : '',
remediation ? ` - Remediation: ${remediation}` : '',
]
.filter(Boolean)
.join('\n')
})
.join('\n\n')
const cpu = metrics?.cpu?.current
const ram = metrics?.ram?.current
const disk = metrics?.disk?.current
const diskIo = metrics?.diskIo?.current
return `## Context (read-only)
- **Project**: ${projectName ?? 'Unknown'} (\`${projectRef ?? 'unknown'}\`)
- **Status**: ${projectStatus ?? 'unknown'}
- **Recent migration label (from dashboard)**: ${migrationLabel}
- **Approx. data counts (dashboard)**: ${counts.tables} tables, ${counts.users} auth users, ${counts.edgeFunctions} edge functions, ${counts.buckets} storage buckets
- **Connections (24h avg / limit)**: ${connections.max > 0 ? `${connections.current} / ${connections.max}` : 'n/a'}
- **Infra (24h hourly averages, %)**: CPU ${cpu != null ? `${cpu.toFixed(0)}%` : 'n/a'}, RAM ${ram != null ? `${ram.toFixed(0)}%` : 'n/a'}, disk used ${disk != null ? `${disk.toFixed(0)}%` : 'n/a'}, disk IO ${diskIo != null ? `${diskIo.toFixed(0)}%` : 'n/a'}
## Advisor issues (database linter)
Security issues: ${security.length}, Performance issues: ${performance.length}
Top issues:
${topLints || '(none in the sample; still check Security and Performance advisor pages.)'}`
}
function buildHomeSummaryCardUserPrompt(
params: HealthContextParams & { usesMockData: boolean }
): string {
const mockNote = params.usesMockData
? `Note: The metrics, connection counts, and advisor list below are **demo samples** for a local or preview build.\n\n`
: ''
return `${mockNote}${buildHealthContextMarkdown(params)}
Write one or two short paragraphs of plain text (no markdown, no lists) for the home card. Stay within about 520 characters total. Prioritize security and performance findings when present, then database load and connection usage.`
}
function buildDetailedAssistantPrompt(params: {
projectRef: string | undefined
projectName: string | undefined
projectStatus: string | undefined
lints: Lint[]
metrics: SummaryInfrastructureMetrics | null
connections: SummaryConnections
counts: V2Counts
migrationLabel: string
usesMockData: boolean
}): string {
const {
projectRef,
projectName,
projectStatus,
lints,
metrics,
connections,
counts,
migrationLabel,
usesMockData,
} = params
const mockPreamble = usesMockData
? `Note: The metrics, connection counts, and advisor list in this message are **demo samples** for the home summary preview.\n\n`
: ''
return `${mockPreamble}You help users in Supabase Studio. Write like technical documentation: conversational, respectful, and easy to scan. Use clear, concise language. Prefer active voice. Define database terms briefly when they first appear (for example, Row Level Security). Avoid slang, idioms, and a pushy or overly casual tone. Focus on what the user needs to do next.
${buildHealthContextMarkdown({
projectRef,
projectName,
projectStatus,
lints,
metrics,
connections,
counts,
migrationLabel,
})}
## What to produce
1. **Executive summary** (3–5 bullets): current health, main risks, and recommended order of work.
2. **Advisor actions**: For each important issue, state the lint name, why it matters, and **one concrete next step** (for example: enable RLS, add an index, adjust a policy). Link to documentation with short link text where it helps. Reference paths: database linter ${DOC_LINKS.databaseLinter}; advisors ${DOC_LINKS.advisorsOverview}; RLS ${DOC_LINKS.rls}.
3. **Observability**: When metrics suggest stress on CPU, RAM, disk, or connections, explain likely causes in plain language. Suggest documentation with short labels (for example: query performance, connection pooling, logs). Paths: query performance ${DOC_LINKS.queryPerformance}; pooling ${DOC_LINKS.connectionPooling}; logs ${DOC_LINKS.logs}; disk and compute ${DOC_LINKS.diskIo}.
4. **Schema / SQL** (optional): When useful, suggest example SQL or \`EXPLAIN\` steps. State assumptions and avoid destructive operations.
Use **markdown** with clear headings. When context is missing, say so instead of inventing details.`
}
export function HomeViewSupaAiSummary({
projectRef,
projectName,
projectStatus,
orgSlug,
projectDetailPending,
organizationsPending,
lints,
lintsPending,
metrics,
connections,
counts,
migrationLabel,
maxSummaryChars = DEFAULT_MAX_SUMMARY_CHARS,
className,
usesMockData = false,
}: {
projectRef: string | undefined
projectName: string | undefined
projectStatus: string | undefined
/** Required on hosted Studio for the home-summary API org gate. */
orgSlug?: string
/** While true, summary stays in loading state until project detail exists. */
projectDetailPending?: boolean
/** Hosted: while org list is loading, slug for the home-summary API may be unknown. */
organizationsPending?: boolean
lints: Lint[]
lintsPending: boolean
metrics: SummaryInfrastructureMetrics | null
connections: SummaryConnections
counts: V2Counts
migrationLabel: string
maxSummaryChars?: number
className?: string
/** When true, "Tell me more" prefaces the assistant message that metrics/advisors are demo samples. */
usesMockData?: boolean
}) {
const { openSidebar } = useSidebarManagerSnapshot()
const aiAssistant = useAiAssistantState()
const assistantSnap = useAiAssistantStateSnapshot()
const { aiOptInLevel, isHipaaProjectDisallowed } = useOrgAiOptInLevel()
const { data: openAiKeyCheck, isPending: openAiKeyPending } = useCheckOpenAIKeyQuery({
enabled: !IS_PLATFORM,
})
const selfHostedKeysReady = IS_PLATFORM || openAiKeyCheck?.hasKey === true
const aiAllowed = aiOptInLevel !== 'disabled' && !isHipaaProjectDisallowed && selfHostedKeysReady
const summaryInputDigest = useMemo(
() =>
JSON.stringify({
projectStatus,
migrationLabel,
counts: {
tables: counts.tables,
users: counts.users,
edgeFunctions: counts.edgeFunctions,
buckets: counts.buckets,
},
metrics,
connections,
lintKeys: lints.map((l) => l.cache_key).sort(),
usesMockData,
}),
[
projectStatus,
migrationLabel,
counts.tables,
counts.users,
counts.edgeFunctions,
counts.buckets,
metrics,
connections,
lints,
usesMockData,
]
)
const cardAiUserPrompt = useMemo(() => {
if (lintsPending) return ''
return buildHomeSummaryCardUserPrompt({
projectRef,
projectName,
projectStatus,
lints,
metrics,
connections,
counts,
migrationLabel,
usesMockData,
})
}, [
lintsPending,
projectRef,
projectName,
projectStatus,
lints,
metrics,
connections,
counts,
migrationLabel,
usesMockData,
])
const assistantModelId =
assistantSnap.model && isKnownAssistantModelId(assistantSnap.model)
? assistantSnap.model
: undefined
const shouldFetchHomeSummary =
aiAllowed &&
!lintsPending &&
Boolean(projectRef) &&
cardAiUserPrompt.length > 0 &&
(IS_PLATFORM ? Boolean(orgSlug) : true)
const {
data: homeSummaryResult,
isPending: homeSummaryPending,
isFetching: homeSummaryFetching,
} = useHomeSummaryQuery({
projectRef: projectRef ?? '',
orgSlug,
prompt: cardAiUserPrompt,
model: assistantModelId,
inputDigest: summaryInputDigest,
enabled: shouldFetchHomeSummary,
})
const contextAndAiLoading =
!projectRef ||
Boolean(projectDetailPending) ||
(IS_PLATFORM && Boolean(organizationsPending)) ||
(!IS_PLATFORM && openAiKeyPending) ||
(aiAllowed && shouldFetchHomeSummary && (homeSummaryPending || homeSummaryFetching))
const showSummarySkeleton = lintsPending || contextAndAiLoading
const fallbackSummary = useMemo(
() =>
buildShortSummary({
projectStatus,
lints,
lintsPending,
metrics,
connections,
summaryCharBudget: maxSummaryChars,
}),
[projectStatus, lints, lintsPending, metrics, connections, maxSummaryChars]
)
const displaySummary = useMemo(() => {
if (lintsPending) return ''
const fromModel = homeSummaryResult?.summary?.trim()
// Show model output whenever AI is allowed and we have a successful response (do not key off
// `shouldFetchHomeSummary` alone: org slug can resolve after first paint).
if (fromModel && aiAllowed) {
return clampPlainSummary(fromModel, maxSummaryChars)
}
return fallbackSummary
}, [lintsPending, homeSummaryResult?.summary, aiAllowed, fallbackSummary, maxSummaryChars])
const handleTellMeMore = () => {
if (!projectRef) return
openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
aiAssistant.newChat({
name: `Health — ${projectName ?? projectRef}`,
initialMessage: buildDetailedAssistantPrompt({
projectRef,
projectName,
projectStatus,
lints,
metrics,
connections,
counts,
migrationLabel,
usesMockData,
}),
})
}
return (
<div
className={cn(
'rounded-md p-[2px] min-h-[196px] flex flex-col',
'bg-[length:300%_300%]',
// Teal → brand green (#3ecf8e) → mint → cyan → emerald (still iridescent, on-brand)
'bg-[linear-gradient(125deg,#0f766e_0%,#14b8a6_18%,#3ecf8e_36%,#5eead4_52%,#22d3ee_68%,#34d399_84%,#3ecf8e_100%)]',
'animate-neon-rainbow-shift',
'shadow-[0_0_10px_rgba(62,207,142,0.05),0_0_42px_rgba(20,184,166,0.06),0_0_64px_rgba(34,211,238,0.02)]',
'motion-reduce:animate-none motion-reduce:shadow-none motion-reduce:bg-border motion-reduce:p-px',
className
)}
>
<div
className={cn(
'flex flex-1 flex-col gap-2 rounded-[5px] p-3',
'bg-surface-100',
'motion-reduce:rounded-md motion-reduce:border motion-reduce:border-border'
)}
>
<div className="flex items-center justify-between gap-2 text-sm font-medium text-foreground">
<div className="flex items-center gap-2">
<AiIconAnimation size={14} className="h-3.5 w-3.5 text-brand shrink-0" aria-hidden />
<span>Supa AI Summary</span>
</div>
<div>
<Info size={14} className="text-foreground-lighter hover:text-foreground" />
</div>
</div>
<div className="text-sm text-foreground-light leading-relaxed line-clamp-[7] min-h-[6.5rem] flex-1">
{showSummarySkeleton ? (
<GenericSkeletonLoader className="w-full" />
) : (
<p className="m-0">{displaySummary}</p>
)}
</div>
<div className="mt-auto pt-1">
<Button
type="default"
size="tiny"
className="w-full"
disabled={!projectRef || showSummarySkeleton}
onClick={handleTellMeMore}
>
Tell me more
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,66 @@
import type { Lint } from 'data/lint/lint-query'
/**
* Set to false to use live advisor and infra data in the home summary card.
* When true, the card shows deterministic sample values for demos and local development.
*/
export const HOME_SUPA_AI_SUMMARY_USE_MOCK = true
/** Sample metrics: moderate database load, elevated connections (~84% of limit). */
export const MOCK_HOME_SUMMARY_METRICS = {
cpu: { current: 47, max: 100 },
ram: { current: 54, max: 100 },
disk: { current: 41, max: 100 },
diskIo: { current: 36, max: 100 },
} as const
export const MOCK_HOME_SUMMARY_CONNECTIONS = { current: 67, max: 80 } as const
/**
* One security warning and one performance warning with full API-shaped strings
* so elaboration and the assistant prompt stay realistic.
*/
export const MOCK_HOME_SUMMARY_LINTS: Lint[] = [
{
cache_key: 'mock-rls-public',
categories: ['SECURITY'],
description:
'Tables in the public schema are accessible without Row Level Security, so the API does not enforce per-row access rules for those tables.',
detail:
'Any client with the anon or service key can read or write rows unless you restrict access in another layer.',
facing: 'EXTERNAL',
level: 'WARN',
metadata: { schema: 'public', name: 'orders', type: 'table' },
name: 'rls_disabled_in_public',
remediation:
'Enable Row Level Security on affected tables and add policies, or move sensitive data to a non-exposed schema.',
title: 'RLS is disabled on a public table',
},
{
cache_key: 'mock-unindexed-fk',
categories: ['PERFORMANCE'],
description:
'Foreign keys without covering indexes can make deletes and updates on referenced tables slow as related rows grow.',
detail:
'Postgres may scan large child tables when the parent key changes if no index exists on the foreign key columns.',
facing: 'EXTERNAL',
level: 'WARN',
metadata: { schema: 'public', name: 'line_items', type: 'table' },
name: 'unindexed_foreign_keys',
remediation:
'Add an index on the foreign key columns that match your query patterns. Review the Advisors detail page for suggested columns.',
title: 'Unindexed foreign keys',
},
]
export function getMockHomeSummaryData(): {
metrics: typeof MOCK_HOME_SUMMARY_METRICS
connections: typeof MOCK_HOME_SUMMARY_CONNECTIONS
lints: Lint[]
} {
return {
metrics: MOCK_HOME_SUMMARY_METRICS,
connections: MOCK_HOME_SUMMARY_CONNECTIONS,
lints: MOCK_HOME_SUMMARY_LINTS,
}
}
+87
View File
@@ -0,0 +1,87 @@
import { useQuery } from '@tanstack/react-query'
import { constructHeaders, fetchHandler } from 'data/fetchers'
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
import type { ResponseError, UseCustomQueryOptions } from 'types'
import { aiKeys } from './keys'
export type HomeSummaryVariables = {
prompt: string
projectRef: string
orgSlug?: string
model?: string
}
export async function generateHomeSummary(
{ prompt, projectRef, orgSlug, model }: HomeSummaryVariables,
signal?: AbortSignal
) {
const headers = await constructHeaders({ 'Content-Type': 'application/json' })
const response = await fetchHandler(`${BASE_PATH}/api/ai/home-summary`, {
headers,
method: 'POST',
body: JSON.stringify({
prompt,
projectRef,
...(orgSlug ? { orgSlug } : {}),
...(model ? { model } : {}),
}),
signal,
})
let body: unknown
try {
body = await response.json()
} catch {
body = null
}
if (!response.ok) {
const msg =
body && typeof body === 'object' && 'error' in body
? String((body as { error?: unknown }).error)
: 'Failed to generate summary'
throw new ResponseError(msg, response.status)
}
if (!body || typeof body !== 'object' || !('summary' in body)) {
throw new ResponseError('Invalid response from home summary', response.status)
}
return body as { summary: string }
}
export type HomeSummaryData = Awaited<ReturnType<typeof generateHomeSummary>>
export const useHomeSummaryQuery = <TData = HomeSummaryData>({
projectRef,
orgSlug,
inputDigest,
prompt,
model,
enabled = true,
...options
}: HomeSummaryVariables & {
inputDigest: string
enabled?: boolean
} & Omit<
UseCustomQueryOptions<HomeSummaryData, ResponseError, TData>,
'queryKey' | 'queryFn' | 'enabled'
>) => {
const platformReady = !IS_PLATFORM || Boolean(orgSlug)
return useQuery<HomeSummaryData, ResponseError, TData>({
queryKey: aiKeys.homeSummary(projectRef, inputDigest),
queryFn: ({ signal }) =>
generateHomeSummary({ prompt, projectRef, orgSlug, model }, signal),
enabled:
enabled &&
Boolean(projectRef) &&
prompt.length > 0 &&
platformReady,
staleTime: 2 * 60_000,
gcTime: 10 * 60_000,
...options,
})
}
+2
View File
@@ -1,3 +1,5 @@
export const aiKeys = {
apiKey: () => ['api-key'] as const,
homeSummary: (projectRef: string | undefined, inputDigest: string) =>
['ai', 'home-summary', projectRef ?? '', inputDigest] as const,
}
+156
View File
@@ -0,0 +1,156 @@
import type { JwtPayload } from '@supabase/supabase-js'
import { generateText } from 'ai'
import { IS_PLATFORM } from 'common'
import { source } from 'common-tags'
import { getModel } from 'lib/ai/model'
import {
type AssistantModelId,
DEFAULT_ASSISTANT_ADVANCE_MODEL_ID,
DEFAULT_ASSISTANT_BASE_MODEL_ID,
getAssistantModelEntry,
isAssistantBaseModelId,
isKnownAssistantModelId,
} from 'lib/ai/model.utils'
import { getOrgAIDetails } from 'lib/ai/org-ai-details'
import apiWrapper from 'lib/api/apiWrapper'
import type { NextApiRequest, NextApiResponse } from 'next'
import z from 'zod'
export const maxDuration = 60
const requestBodySchema = z.object({
prompt: z.string().min(1).max(120_000),
projectRef: z.string(),
orgSlug: z.string().optional(),
model: z.string().optional(),
})
const SYSTEM_PROMPT = source`
You write very short plain-text project health summaries for the Supabase Studio home card.
Rules:
- Output plain sentences only: no markdown, no bullet characters, no numbered lists, no headings, no code fences.
- Use second person ("you").
- Stay under about 520 characters unless the user message asks for a different limit.
- Only state facts that appear in the user message. If something is missing, omit it rather than guessing.
`
async function handler(req: NextApiRequest, res: NextApiResponse, _claims?: JwtPayload) {
const { method } = req
switch (method) {
case 'POST':
return handlePost(req, res)
default:
res.setHeader('Allow', ['POST'])
return res.status(405).json({
data: null,
error: { message: `Method ${method} Not Allowed` },
})
}
}
async function handlePost(req: NextApiRequest, res: NextApiResponse) {
const authorization = req.headers.authorization
const accessToken = authorization?.replace('Bearer ', '')
if (IS_PLATFORM && !accessToken) {
return res.status(401).json({ error: 'Authorization token is required' })
}
const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body
const parsed = requestBodySchema.safeParse(body)
if (!parsed.success) {
return res.status(400).json({ error: 'Invalid request body', issues: parsed.error.issues })
}
const { prompt, projectRef, orgSlug, model: rawRequestedModel } = parsed.data
const requestedModel: AssistantModelId | undefined =
rawRequestedModel && isKnownAssistantModelId(rawRequestedModel) ? rawRequestedModel : undefined
let hasAccessToAdvanceModel = false
if (!IS_PLATFORM) {
hasAccessToAdvanceModel = true
}
if (IS_PLATFORM) {
if (!orgSlug) {
return res.status(400).json({ error: 'orgSlug is required' })
}
try {
const { aiOptInLevel, hasAccessToAdvanceModel: orgAdvance, isHipaaEnabled } =
await getOrgAIDetails({
orgSlug,
authorization: authorization ?? '',
projectRef,
})
if (isHipaaEnabled) {
return res.status(403).json({ error: 'AI features are not available for this project.' })
}
if (aiOptInLevel === 'disabled') {
return res.status(403).json({ error: 'Organization has not opted in to AI features.' })
}
hasAccessToAdvanceModel = orgAdvance
} catch {
return res.status(400).json({
error: 'There was an error fetching your organization details',
})
}
}
const envThrottled = process.env.IS_THROTTLED !== 'false'
let effectiveModel: AssistantModelId = requestedModel ?? DEFAULT_ASSISTANT_ADVANCE_MODEL_ID
if (!hasAccessToAdvanceModel || (envThrottled && !isAssistantBaseModelId(effectiveModel))) {
effectiveModel = DEFAULT_ASSISTANT_BASE_MODEL_ID
}
const { modelParams, error: modelError, promptProviderOptions } = await getModel({
provider: 'openai',
modelEntry: getAssistantModelEntry(effectiveModel),
})
if (modelError) {
return res.status(500).json({ error: modelError.message })
}
try {
const userContent = source`
The following is read-only context from the Studio dashboard (project metadata, advisors, and observability snippets). Summarize it for the home card.
${prompt}
`
const result = await generateText({
...modelParams,
maxOutputTokens: 400,
messages: [
{
role: 'system',
content: SYSTEM_PROMPT,
...(promptProviderOptions ? { providerOptions: promptProviderOptions } : {}),
},
{ role: 'user', content: userContent },
],
})
const summary = (result.text ?? '').trim()
if (!summary) {
return res.status(500).json({ error: 'The model returned an empty summary.' })
}
return res.status(200).json({ summary })
} catch (error) {
console.error('home-summary error:', error)
if (error instanceof Error) {
return res.status(500).json({ error: error.message })
}
return res.status(500).json({ error: 'Failed to generate home summary.' })
}
}
export default (req: NextApiRequest, res: NextApiResponse) =>
apiWrapper(req, res, handler, { withAuth: true })
@@ -21,12 +21,20 @@ import { LazyComposedChartHandler } from 'components/ui/Charts/ComposedChartHand
import { ReportSettings } from 'components/ui/Charts/ReportSettings'
import { ObservabilityLink } from 'components/ui/ObservabilityLink'
import Panel from 'components/ui/Panel'
import {
applyDemoInfrastructureIfUnreliable,
parseConnectionsData,
parseInfrastructureMetrics,
} from 'components/interfaces/Observability/DatabaseInfrastructureSection.utils'
import type { InfraMonitoringAttribute } from 'data/analytics/infra-monitoring-query'
import { useInfraMonitoringAttributesQuery } from 'data/analytics/infra-monitoring-query'
import { analyticsKeys } from 'data/analytics/keys'
import { useDiskAttributesQuery } from 'data/config/disk-attributes-query'
import { useProjectDiskResizeMutation } from 'data/config/project-disk-resize-mutation'
import { useDatabaseSizeQuery } from 'data/database/database-size-query'
import { useMaxConnectionsQuery } from 'data/database/max-connections-query'
import { usePgbouncerConfigQuery } from 'data/database/pgbouncer-config-query'
import { useProjectLintsQuery } from 'data/lint/lint-query'
import { getReportAttributesV2 } from 'data/reports/database-charts'
import { useDatabaseReport } from 'data/reports/database-report-query'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
@@ -40,7 +48,7 @@ import { DOCS_URL } from 'lib/constants'
import { formatBytes } from 'lib/helpers'
import { ArrowRight, ExternalLink, RefreshCw } from 'lucide-react'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import type { NextPageWithLayout } from 'types'
@@ -63,6 +71,147 @@ DatabaseReport.getLayout = (page) => (
export type UpdateDateRange = (from: string, to: string) => void
export default DatabaseReport
/** Past-24h infra + advisor snapshot for the database observability page (demo fill when telemetry is empty). */
const DatabaseObservabilityHealthSummary = () => {
const { data: project } = useSelectedProjectQuery()
const projectRef = project?.ref
const { startDate, endDate } = useMemo(() => {
const n = dayjs()
return { startDate: n.subtract(1, 'day').toISOString(), endDate: n.toISOString() }
}, [])
const attributes = useMemo<InfraMonitoringAttribute[]>(
() => [
'avg_cpu_usage',
'ram_usage',
'disk_fs_used_system',
'disk_fs_used_wal',
'pg_database_size',
'disk_fs_size',
'disk_io_consumption',
'pg_stat_database_num_backends',
],
[]
)
const { data: infraData } = useInfraMonitoringAttributesQuery(
{
projectRef,
attributes,
startDate,
endDate,
interval: '1h',
},
{ enabled: Boolean(projectRef) }
)
const { data: maxConnectionsData } = useMaxConnectionsQuery({
projectRef,
connectionString: project?.connectionString,
})
const rawMetrics = parseInfrastructureMetrics(infraData)
const rawConnections = parseConnectionsData(infraData, maxConnectionsData)
const { metrics, connections, isDemo } = applyDemoInfrastructureIfUnreliable(
infraData,
rawMetrics,
rawConnections
)
const lintsQuery = useProjectLintsQuery({ projectRef })
const lints = lintsQuery.data ?? []
const secErr = lints.filter((l) => l.categories.includes('SECURITY') && l.level === 'ERROR').length
const secWarn = lints.filter((l) => l.categories.includes('SECURITY') && l.level === 'WARN').length
const perfErr = lints.filter((l) => l.categories.includes('PERFORMANCE') && l.level === 'ERROR')
.length
const perfWarn = lints.filter((l) => l.categories.includes('PERFORMANCE') && l.level === 'WARN')
.length
const advisorNoise = secErr + secWarn + perfErr + perfWarn > 0
const cpu = metrics.cpu.current
const ram = metrics.ram.current
const disk = metrics.disk.current
const diskIo = metrics.diskIo.current
const connRatio =
connections.max > 0 ? connections.current / Math.max(connections.max, 1) : 0
const pressureSignals = [
cpu >= 72,
ram >= 78,
disk >= 82,
connRatio >= 0.82,
diskIo >= 72,
].filter(Boolean).length
let variant: 'default' | 'warning' | 'destructive' = 'default'
if (secErr > 0 || pressureSignals >= 3) variant = 'destructive'
else if (perfErr > 0 || pressureSignals >= 1 || advisorNoise) variant = 'warning'
const title =
variant === 'destructive'
? 'Last 24 hours: review recommended'
: variant === 'warning'
? 'Last 24 hours: monitor metrics below'
: 'Last 24 hours: metrics look steady'
let body = ''
if (isDemo) {
body +=
'[Demo sample] Showing illustrative 24h averages because live telemetry had no hourly points yet. '
}
body += `Hourly averages over roughly the past day: CPU about ${cpu.toFixed(0)}%, memory about ${ram.toFixed(
0
)}%, disk about ${disk.toFixed(0)}% used, disk IO about ${diskIo.toFixed(0)}%. `
if (connections.max > 0) {
body += `Database connections averaged near ${connections.current} of ${connections.max}. `
}
if (pressureSignals >= 3) {
body += `Several metrics are high. Review logs and query statistics before you change compute size alone. `
} else if (pressureSignals === 2) {
body += `Two metrics are elevated. Review the charts below and related logs. `
} else if (pressureSignals === 1) {
body += `One metric is elevated. Track it on the charts below. `
} else {
body += `This summary does not show saturation from averages alone. `
}
if (secErr > 0) {
body += `Advisors report ${secErr} security error(s). Review Row Level Security and policies for possible data exposure. `
} else if (secWarn > 0) {
body += `Advisors report ${secWarn} security warning(s). Plan updates to access rules as traffic grows. `
}
if (perfErr > 0) {
body += `Advisors report ${perfErr} performance error(s). Address these before you rely on the system under higher load. `
} else if (perfWarn > 0 && pressureSignals === 0) {
body += `Advisors report ${perfWarn} performance warning(s). You can schedule fixes during regular maintenance. `
}
if (!projectRef) return null
if (lintsQuery.isLoading) {
return (
<Alert_Shadcn_ variant="default" className="mb-4 mx-4 lg:mx-6">
<AlertDescription_Shadcn_>Loading 24h health summary…</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
return (
<Alert_Shadcn_ variant={variant} className="mb-4 mx-4 lg:mx-6">
<AlertDescription_Shadcn_>
<p className="font-medium mb-1">{title}</p>
<p className="text-foreground-light">{body}</p>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
}
const DatabaseUsage = () => {
const { db, chart, ref } = useParams()
const { data: project } = useSelectedProjectQuery()
@@ -213,6 +362,7 @@ const DatabaseUsage = () => {
return (
<>
<ReportHeader showDatabaseSelector title="Database" />
<DatabaseObservabilityHealthSummary />
<ReportStickyNav
content={
<>
+5
View File
@@ -81,6 +81,7 @@ module.exports = config({
animation: {
shimmer: 'shimmer 2s infinite linear',
sway: 'sway 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'neon-rainbow-shift': 'neon-rainbow-shift 5s ease-in-out infinite',
},
keyframes: {
shimmer: {
@@ -99,6 +100,10 @@ module.exports = config({
transform: 'rotate(10deg) scale(1.5) translateY(2rem)',
},
},
'neon-rainbow-shift': {
'0%, 100%': { backgroundPosition: '0% 50%' },
'50%': { backgroundPosition: '100% 50%' },
},
},
},
},