From a5dcf3b57c7f361fa35b870b0bc554f9a7e97b2e Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:52:46 +1000 Subject: [PATCH] feat(studio): rebuild pipeline health summary (#50445) ## What kind of change does this PR introduce? Studio UI improvement. ## What is the current behavior? Pipeline health is presented as a dense custom metrics panel with repeated connection information and per-table lag details mixed into the pipeline summary. ## What is the new behavior? Moves the pipeline-level slot status, lag, WAL retention, and last check-in into a standard detail section. It removes repeated connection content and keeps table-specific state with the replicated tables. | Before | After | | --- | --- | | CleanShot 2026-09-16 at 13 34
43@2x | CleanShot 2026-09-16 at 13 34
15@2x | _Note that the page spacing may feel a bit funny. This is handled in https://github.com/supabase/supabase/pull/49630_ This is an independent slice extracted from #49630. The related review series is #50443, #50444, this PR, #50446, then #49630. ## To test 1. Open `/project//database/replication` and select a running pipeline. 2. Confirm **Pipeline health** shows slot status, lag, WAL retention remaining, and last check-in. 3. Confirm unlimited WAL retention is labelled **Unlimited** and a caught-up pipeline is labelled **Caught up**. ## Summary by CodeRabbit ## UI Improvements - Added a dedicated Pipeline Health section summarizing WAL status, slot status, and replication lag. - Replaced the inline metrics layout with responsive detail cards and clearer supporting descriptions. - Added tooltips for lag values and relative reply times, including precise timestamps. - Updated lag labels and status indicators for improved clarity. - Added concise explanations for reserved, extended, unreserved, lost, and unknown WAL states. - Improved presentation of pipeline details with optional contextual descriptions. --- .../PipelineDetailItem.tsx | 39 +++++ .../PipelineHealthSection.test.tsx | 36 +++++ .../PipelineHealthSection.tsx | 104 +++++++++++++ .../ReplicationPipelineStatus.tsx | 57 +------- .../ReplicationPipelineStatus.utils.tsx | 13 +- .../SlotLagMetrics.test.tsx | 27 ---- .../SlotLagMetrics.tsx | 130 ++--------------- .../ReplicationPipelineStatus/SlotStatus.tsx | 138 +----------------- 8 files changed, 204 insertions(+), 340 deletions(-) create mode 100644 apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/PipelineDetailItem.tsx create mode 100644 apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/PipelineHealthSection.test.tsx create mode 100644 apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/PipelineHealthSection.tsx delete mode 100644 apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotLagMetrics.test.tsx diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/PipelineDetailItem.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/PipelineDetailItem.tsx new file mode 100644 index 00000000000..a831db7e4dd --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/PipelineDetailItem.tsx @@ -0,0 +1,39 @@ +import { type ReactNode } from 'react' +import { InfoTooltip } from 'ui-patterns/info-tooltip' + +import { DetailSubtext } from '../DetailSubtext' + +interface PipelineDetailItemProps { + label: string + /** A fixed explanation of what this field is. Never the current value's meaning. */ + tooltip?: ReactNode + /** Explains what the current value means, when the value alone isn't enough. */ + description?: ReactNode + children: ReactNode +} + +/** + * One label and value inside a pipeline detail card. Shared so the configuration and health + * cards read as the same grid rather than two different treatments of the same idea. + */ +export const PipelineDetailItem = ({ + label, + tooltip, + description, + children, +}: PipelineDetailItemProps) => ( +
+
+ {label} + {tooltip !== undefined && ( + + {tooltip} + + )} +
+
{children}
+ {description !== undefined && {description}} +
+) + +export const PIPELINE_DETAIL_GRID_CLASS_NAME = 'grid grid-cols-1 gap-x-10 gap-y-6 md:grid-cols-2' diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/PipelineHealthSection.test.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/PipelineHealthSection.test.tsx new file mode 100644 index 00000000000..a73130e1bf7 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/PipelineHealthSection.test.tsx @@ -0,0 +1,36 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it } from 'vitest' + +import { PipelineHealthSection } from './PipelineHealthSection' +import { customRender } from '@/tests/lib/custom-render' + +const baseMetrics = { + active: true, + confirmed_flush_lsn_bytes: 0, + restart_lsn_bytes: 0, + reply_time_lag: 0, +} + +describe('PipelineHealthSection', () => { + it('renders null safe WAL size as unlimited retention', () => { + customRender() + + expect(screen.getByText('WAL retention remaining')).toBeInTheDocument() + expect(screen.getByText('Unlimited')).toBeInTheDocument() + }) + + it('formats a numeric safe WAL size normally', () => { + customRender() + + expect(screen.getByText('1 KB')).toBeInTheDocument() + }) + + it('shows the absolute last check-in time on hover', async () => { + customRender() + + await userEvent.hover(screen.getByText('Just now')) + + expect(await screen.findByRole('tooltip')).toHaveTextContent(/\w{3} \d{1,2}, \d{4}/) + }) +}) diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/PipelineHealthSection.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/PipelineHealthSection.tsx new file mode 100644 index 00000000000..ed95594c80c --- /dev/null +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/PipelineHealthSection.tsx @@ -0,0 +1,104 @@ +import { type ReactNode } from 'react' +import { Card, CardContent, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { + PageSection, + PageSectionContent, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' + +import { PIPELINE_DETAIL_GRID_CLASS_NAME, PipelineDetailItem } from './PipelineDetailItem' +import { type SlotLagMetrics as SlotLagMetricsType } from './ReplicationPipelineStatus.types' +import { getWalStatusMeta } from './ReplicationPipelineStatus.utils' +import { getFieldDisplay, SLOT_LAG_FIELDS } from './SlotLagMetrics' +import { SLOT_STATUS_TOOLTIP, SlotWalStatusValue } from './SlotStatus' +import { InlineLink } from '@/components/ui/InlineLink' +import { DOCS_URL } from '@/lib/constants' + +interface PipelineHealthSectionProps { + /** Absent while the pipeline is stopped or failed and reports no slot metrics. */ + metrics?: SlotLagMetricsType + /** + * Every notice about the pipeline's state, in priority order. This section is the single place + * they render, so a reader always finds "what needs my attention" at the top of Pipeline health. + */ + children?: ReactNode +} + +export const PipelineHealthSection = ({ metrics, children }: PipelineHealthSectionProps) => { + const walStatusMeta = getWalStatusMeta(metrics?.wal_status) + + return ( + + + + Pipeline health + + + + + {children} + + {metrics !== undefined && ( + + +
+ + {walStatusMeta.description}{' '} + + Learn more + + + } + > + + + + {SLOT_LAG_FIELDS.map((field) => { + const rawValue = metrics[field.key] + const { display, detail } = getFieldDisplay(field, rawValue) + const valueTooltip = + field.getValueTooltip && typeof rawValue === 'number' + ? field.getValueTooltip(rawValue) + : undefined + + return ( + + {valueTooltip !== undefined ? ( + + + {display} + + + {valueTooltip} + + + ) : ( + display + )} + {detail !== undefined && ( + {detail} + )} + + ) + })} +
+
+
+ )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx index 6efa5ed4cb1..05feac6a0ea 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.tsx @@ -24,10 +24,8 @@ import { ErrorDetailsDialog } from '../ErrorDetailsDialog' import { getStatusName } from '../Pipeline.utils' import { PipelineStatusName, STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants' import { RestartTableDialog } from '../RestartTableDialog' -import { SlotLagMetrics } from './ReplicationPipelineStatus.types' +import { PipelineHealthSection } from './PipelineHealthSection' import { getDisabledStateConfig } from './ReplicationPipelineStatus.utils' -import { SlotLagMetricsInline, SlotLagMetricsList } from './SlotLagMetrics' -import { SlotConnectionIndicator, SlotStatusBadge, SlotStatusLegend } from './SlotStatus' import { TableReplicationRow } from './TableReplicationRow' import { AlertError } from '@/components/ui/AlertError' import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip' @@ -122,11 +120,6 @@ export const ReplicationPipelineStatus = () => { [tableStatuses, searchString] ) - const tablesWithLag = useMemo( - () => tableStatuses.filter((table) => Boolean(table.table_sync_lag)), - [tableStatuses] - ) - const erroredTables = useMemo( () => tableStatuses.filter((table) => table.state.name === 'error'), [tableStatuses] @@ -183,57 +176,13 @@ export const ReplicationPipelineStatus = () => { )} {applyLagMetrics && ( -
-
-
-

Pipeline metrics

-

- Live metrics on how this pipeline is doing right now. -

-
-
- - - - -
-
- + {isStatusError && (

Unable to refresh data. Showing the last values we received.

)} - - - - {tablesWithLag.length > 0 && ( - <> -
-
-
- - - During initial sync, tables can copy and stream independently before - reconciling with the overall pipeline. - -
-
-
    - {tablesWithLag.map((table) => ( -
  • - -
  • - ))} -
-
-
- - )} -
+
)} {!isPipelineLoading && !isStatusLoading && hasTableData && ( diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx index 928bb3dcd55..515273449fe 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/ReplicationPipelineStatus.utils.tsx @@ -167,8 +167,7 @@ export const WAL_STATUS_META: Record = { label: 'Reserved', variant: 'success', severity: 'normal', - description: - "Healthy. Your database is keeping the WAL files this pipeline's replication slot needs, and they are within the normal WAL size limit.", + description: 'Postgres will keep the WAL for every change until this pipeline sends it.', tableDescription: "Healthy. Your database is keeping the WAL files this table's replication slot needs, and they are within the normal WAL size limit.", }, @@ -177,7 +176,7 @@ export const WAL_STATUS_META: Record = { variant: 'warning', severity: 'normal', description: - "Healthy, but growing. This pipeline's replication slot is holding on to more WAL than usual, but your database is still keeping everything it needs.", + 'The pipeline is behind. Postgres is retaining more WAL than usual, but nothing is discarded yet.', tableDescription: "Healthy, but growing. This table's replication slot is holding on to more WAL than usual, but your database is still keeping everything it needs.", }, @@ -185,8 +184,7 @@ export const WAL_STATUS_META: Record = { label: 'Unreserved', variant: 'warning', severity: 'warning', - description: - "At risk. Your database is no longer reserving all WAL files this pipeline's replication slot needs. If the pipeline does not catch up soon, those files may be removed.", + description: 'Postgres may discard WAL this pipeline has not sent yet.', tableDescription: "At risk. Your database is no longer reserving all WAL files this table's replication slot needs. If the pipeline does not catch up soon, those files may be removed.", }, @@ -195,7 +193,7 @@ export const WAL_STATUS_META: Record = { variant: 'destructive', severity: 'critical', description: - "Broken. Some WAL files this pipeline's replication slot needs have already been removed. The pipeline can no longer continue from this slot. You can recreate a new pipeline, or set the invalidation behavior to recreate and restart the pipeline.", + 'Postgres already discarded WAL this pipeline needed. Replication cannot continue from here.', tableDescription: "Broken. Some WAL files this table's replication slot needs have already been removed. The pipeline can no longer continue from this slot. You can recreate a new pipeline, or set the invalidation behavior to recreate and restart the pipeline.", }, @@ -203,8 +201,7 @@ export const WAL_STATUS_META: Record = { label: 'Unknown', variant: 'default', severity: 'normal', - description: - "Unknown. Your database reported an unknown state for this pipeline's replication slot.", + description: 'Postgres did not report a recognized status for this pipeline’s slot.', tableDescription: "Unknown. Your database reported an unknown state for this table's replication slot.", }, diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotLagMetrics.test.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotLagMetrics.test.tsx deleted file mode 100644 index 859a6454129..00000000000 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotLagMetrics.test.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { screen } from '@testing-library/react' -import { describe, expect, it } from 'vitest' - -import { SlotLagMetricsList } from './SlotLagMetrics' -import { customRender } from '@/tests/lib/custom-render' - -const baseMetrics = { - active: true, - confirmed_flush_lsn_bytes: 0, - restart_lsn_bytes: 0, - reply_time_lag: 0, -} - -describe('SlotLagMetricsList', () => { - it('renders null safe WAL size as unlimited retention', () => { - customRender() - - expect(screen.getByText('WAL retention remaining')).toBeInTheDocument() - expect(screen.getByText('Unlimited')).toBeInTheDocument() - }) - - it('formats a numeric safe WAL size normally', () => { - customRender() - - expect(screen.getByText('1 KB')).toBeInTheDocument() - }) -}) diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotLagMetrics.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotLagMetrics.tsx index 767322c381a..9a7e31028f9 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotLagMetrics.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotLagMetrics.tsx @@ -1,13 +1,10 @@ import dayjs from 'dayjs' -import { Info } from 'lucide-react' import { type ReactNode } from 'react' -import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { SlotLagMetricKey, SlotLagMetrics } from './ReplicationPipelineStatus.types' +import { SlotLagMetricKey } from './ReplicationPipelineStatus.types' import { getFormattedLagValue } from './ReplicationPipelineStatus.utils' -import { SlotConnectionIndicator, SlotStatusBadge } from './SlotStatus' -interface SlotLagField { +export interface SlotLagField { key: SlotLagMetricKey label: string type: 'bytes' | 'duration' @@ -20,12 +17,15 @@ interface SlotLagField { getValueTooltip?: (value: number) => string } -const SLOT_LAG_FIELDS: SlotLagField[] = [ +export const SLOT_LAG_FIELDS: SlotLagField[] = [ { key: 'confirmed_flush_lsn_bytes', - label: 'Waiting to sync', + // Same label as the list column. Scoped to the main slot's ongoing change stream, so "Caught + // up" stays true while tables are still doing their initial copy. + label: 'Lag', type: 'bytes', - description: "Changes in your database the pipeline hasn't synced yet.", + description: + 'Changes still on their way to the destination, measured on the pipeline’s main slot. Tables in their initial sync use their own slots and aren’t counted.', zeroLabel: 'Caught up', }, { @@ -44,7 +44,7 @@ const SLOT_LAG_FIELDS: SlotLagField[] = [ key: 'reply_time_lag', label: 'Last check-in', type: 'duration', - description: 'Time since the pipeline last reported back to your database.', + description: 'Time since the pipeline last reported back to your database', zeroLabel: 'Just now', // reply_time_lag is "milliseconds ago", so the absolute time is now minus that, in local time. getValueTooltip: (ms) => dayjs().subtract(ms, 'millisecond').format('MMM D, YYYY, h:mm:ss A'), @@ -53,118 +53,8 @@ const SLOT_LAG_FIELDS: SlotLagField[] = [ // Resolves a field's value into a display string (+ optional precise detail), honoring the // friendly zero/null labels before falling back to the formatted byte/duration value. -const getFieldDisplay = (field: SlotLagField, value: number | null | undefined) => { +export const getFieldDisplay = (field: SlotLagField, value: number | null | undefined) => { if (value == null) return { display: field.nullLabel ?? 'n/a', detail: undefined } if (field.zeroLabel && value === 0) return { display: field.zeroLabel, detail: undefined } return getFormattedLagValue(field.type, value) } - -export const SlotLagMetricsInline = ({ - tableName, - metrics, -}: { - tableName: string - metrics: SlotLagMetrics -}) => { - return ( -
- - {tableName} - - - - {metrics.wal_status && } - -
- {SLOT_LAG_FIELDS.map((field) => { - const { display } = getFieldDisplay(field, metrics[field.key]) - return ( - - - {field.label} - - {display} - - ) - })} -
-
- ) -} - -export const SlotLagMetricsList = ({ - metrics, - size = 'default', - showMetricInfo = true, -}: { - metrics: SlotLagMetrics - size?: 'default' | 'compact' - showMetricInfo?: boolean -}) => { - const gridClasses = - size === 'default' - ? 'grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-y-4 gap-x-6' - : 'grid-cols-2 gap-y-2 gap-x-4' - - const labelClasses = - size === 'default' ? 'text-xs text-foreground-light' : 'text-[11px] text-foreground-lighter' - - const valueClasses = - size === 'default' - ? 'text-sm font-medium text-foreground' - : 'text-xs font-medium text-foreground' - - return ( -
- {SLOT_LAG_FIELDS.map((field) => { - const rawValue = metrics[field.key] - const { display, detail } = getFieldDisplay(field, rawValue) - const valueTooltip = - field.getValueTooltip && typeof rawValue === 'number' - ? field.getValueTooltip(rawValue) - : undefined - return ( -
-
- - {field.label} - {showMetricInfo && ( - - - - - - {field.description} - - - )} - -
-
- {valueTooltip ? ( - - - {display} - - - {valueTooltip} - - - ) : ( - {display} - )} - {detail && {detail}} -
-
- ) - })} -
- ) -} diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotStatus.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotStatus.tsx index bd2a54f913d..a136fca8efb 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotStatus.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationPipelineStatus/SlotStatus.tsx @@ -1,136 +1,12 @@ -import { Info } from 'lucide-react' -import { - Badge, - cn, - Popover, - PopoverContent, - PopoverTrigger, - Tooltip, - TooltipContent, - TooltipTrigger, -} from 'ui' - +import { StateDot } from '../StateDot' import { SlotWalStatus } from './ReplicationPipelineStatus.types' -import { getWalStatusMeta, WAL_STATUS_LEGEND } from './ReplicationPipelineStatus.utils' -import { InlineLink } from '@/components/ui/InlineLink' -import { DOCS_URL } from '@/lib/constants' +import { getWalStatusMeta } from './ReplicationPipelineStatus.utils' -export type SlotStatusContext = 'pipeline' | 'table' +export const SLOT_STATUS_TOOLTIP = + 'How safely your database is keeping the changes this pipeline’s main replication slot still needs' -const CONNECTION_TEXT: Record = { - pipeline: { - active: "This pipeline's replication slot is active and being used right now.", - inactive: "This pipeline's replication slot is not active right now.", - }, - table: { - active: "This table's replication slot is active and being used right now.", - inactive: "This table's replication slot is not active right now.", - }, -} - -/** - * Colored badge for a slot's WAL status, with the plain-language meaning on hover. - * Pass `context="table"` in the per-table inline view to show table-specific descriptions. - */ -export const SlotStatusBadge = ({ - status, - context = 'pipeline', -}: { - status?: SlotWalStatus | null - context?: SlotStatusContext -}) => { +/** How safely Postgres is keeping the changes the slot still needs. */ +export const SlotWalStatusValue = ({ status }: { status?: SlotWalStatus | null }) => { const meta = getWalStatusMeta(status) - const description = context === 'table' ? meta.tableDescription : meta.description - return ( - - - - {meta.label} - - - - {description} - - - ) -} - -/** - * Info button opening a legend that explains every possible slot status. - */ -export const SlotStatusLegend = () => { - return ( - - - - - -
-

Slot statuses

-

- How safely your database is keeping the changes the pipeline still needs. -

-
-
    - {WAL_STATUS_LEGEND.map((meta) => ( -
  • -
    - {meta.label} -
    - - {meta.description} - -
  • - ))} -
-
- - Learn more about monitoring replication - -
-
-
- ) -} - -/** - * Small dot + label indicating whether the slot has a live replication connection. - * Pass `context="table"` in the per-table inline view to show table-specific descriptions. - */ -export const SlotConnectionIndicator = ({ - isActive, - context = 'pipeline', -}: { - isActive?: boolean - context?: SlotStatusContext -}) => { - const text = CONNECTION_TEXT[context] - return ( - - - - - {isActive ? 'Connected' : 'Not connected'} - - - - {isActive ? text.active : text.inactive} - - - ) + return {meta.label} }