diff --git a/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.utils.test.ts b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.utils.test.ts index 021e16faced..e22b26ae074 100644 --- a/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.utils.test.ts +++ b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.utils.test.ts @@ -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: [], diff --git a/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.utils.ts b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.utils.ts index 2ad0b5e09b5..9238111d44e 100644 --- a/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.utils.ts +++ b/apps/studio/components/interfaces/Observability/DatabaseInfrastructureSection.utils.ts @@ -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, + 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)[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, + } +} diff --git a/apps/studio/components/v2/HomeView.tsx b/apps/studio/components/v2/HomeView.tsx index d4d902cfd68..038afe7a155 100644 --- a/apps/studio/components/v2/HomeView.tsx +++ b/apps/studio/components/v2/HomeView.tsx @@ -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 */}
-
-
-
Services
-
- - {project?.status ? project.status.replaceAll('_', ' ').toLowerCase() : 'Unknown'} - -
-
- -
- - Last migration -
-
- {isLoadingMigrations ? 'Loading...' : migrationLabelText} -
- - -
- - Last backup -
-
- {isLoadingBackups ? ( - 'Loading...' - ) : backupsData?.pitr_enabled ? ( - 'PITR enabled' - ) : latestBackup ? ( - - ) : ( - 'No backups' - )} -
- - -
- - {isDefaultProject ? 'Recent branch' : 'Branch created'} -
-
- {isDefaultProject ? ( - - {latestNonDefaultBranch?.name ?? 'No branches'} - - ) : currentBranch?.created_at ? ( - - ) : ( - 'Unknown' - )} -
- -
+ +
+ -
-
-
Connections
-
- {connections.max > 0 ? `${connections.current}/${connections.max}` : '—'} -
-
-
-
Memory
-
- {metrics?.ram ? `${metrics.ram.current.toFixed(0)}%` : '—'} -
-
-
-
Disk
-
- {metrics?.disk ? `${metrics.disk.current.toFixed(0)}%` : '—'} -
-
-
-
CPU
-
- {metrics?.cpu ? `${metrics.cpu.current.toFixed(0)}%` : '—'} -
-
+
+
+
Services
+
+ + {project?.status ? project.status.replaceAll('_', ' ').toLowerCase() : 'Unknown'} +
+ +
+ + Last migration +
+
+ {isLoadingMigrations ? 'Loading...' : migrationLabelText} +
+ + +
+ + Last backup +
+
+ {isLoadingBackups ? ( + 'Loading...' + ) : backupsData?.pitr_enabled ? ( + 'PITR enabled' + ) : latestBackup ? ( + + ) : ( + 'No backups' + )} +
+ + +
+ + {isDefaultProject ? 'Recent branch' : 'Branch created'} +
+
+ {isDefaultProject ? ( + + {latestNonDefaultBranch?.name ?? 'No branches'} + + ) : currentBranch?.created_at ? ( + + ) : ( + 'Unknown' + )} +
+
@@ -388,7 +405,7 @@ export function HomeView() {
{/* Active issues */} -
+

Active issues

{issues.length} shown diff --git a/apps/studio/components/v2/HomeViewSupaAiSummary.tsx b/apps/studio/components/v2/HomeViewSupaAiSummary.tsx new file mode 100644 index 00000000000..a6d18713708 --- /dev/null +++ b/apps/studio/components/v2/HomeViewSupaAiSummary.tsx @@ -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 + +/** 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 ( +
+
+
+
+ + Supa AI Summary +
+
+ +
+
+
+ {showSummarySkeleton ? ( + + ) : ( +

{displaySummary}

+ )} +
+
+ +
+
+
+ ) +} diff --git a/apps/studio/components/v2/homeViewSupaAiSummaryMock.ts b/apps/studio/components/v2/homeViewSupaAiSummaryMock.ts new file mode 100644 index 00000000000..74b403fb3fa --- /dev/null +++ b/apps/studio/components/v2/homeViewSupaAiSummaryMock.ts @@ -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, + } +} diff --git a/apps/studio/data/ai/home-summary-query.ts b/apps/studio/data/ai/home-summary-query.ts new file mode 100644 index 00000000000..313328e4dcf --- /dev/null +++ b/apps/studio/data/ai/home-summary-query.ts @@ -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> + +export const useHomeSummaryQuery = ({ + projectRef, + orgSlug, + inputDigest, + prompt, + model, + enabled = true, + ...options +}: HomeSummaryVariables & { + inputDigest: string + enabled?: boolean +} & Omit< + UseCustomQueryOptions, + 'queryKey' | 'queryFn' | 'enabled' + >) => { + const platformReady = !IS_PLATFORM || Boolean(orgSlug) + + return useQuery({ + 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, + }) +} diff --git a/apps/studio/data/ai/keys.ts b/apps/studio/data/ai/keys.ts index 18a143d8a79..550ef7fa8d7 100644 --- a/apps/studio/data/ai/keys.ts +++ b/apps/studio/data/ai/keys.ts @@ -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, } diff --git a/apps/studio/pages/api/ai/home-summary.ts b/apps/studio/pages/api/ai/home-summary.ts new file mode 100644 index 00000000000..8b7a24f1e1f --- /dev/null +++ b/apps/studio/pages/api/ai/home-summary.ts @@ -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 }) diff --git a/apps/studio/pages/project/[ref]/observability/database.tsx b/apps/studio/pages/project/[ref]/observability/database.tsx index cf515da20cf..090c52f841c 100644 --- a/apps/studio/pages/project/[ref]/observability/database.tsx +++ b/apps/studio/pages/project/[ref]/observability/database.tsx @@ -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( + () => [ + '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 ( + + Loading 24h health summary… + + ) + } + + return ( + + +

{title}

+

{body}

+
+
+ ) +} + const DatabaseUsage = () => { const { db, chart, ref } = useParams() const { data: project } = useSelectedProjectQuery() @@ -213,6 +362,7 @@ const DatabaseUsage = () => { return ( <> + diff --git a/apps/studio/tailwind.config.js b/apps/studio/tailwind.config.js index b5fdcf67abc..68bc820f996 100644 --- a/apps/studio/tailwind.config.js +++ b/apps/studio/tailwind.config.js @@ -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%' }, + }, }, }, },