mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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 | | --- | --- | | <img width="1816" height="274" alt="CleanShot 2026-09-16 at 13 34 43@2x" src="https://github.com/user-attachments/assets/eceed5bb-8af2-4a3b-83a9-7849f1554fbe" /> | <img width="1830" height="506" alt="CleanShot 2026-09-16 at 13 34 15@2x" src="https://github.com/user-attachments/assets/498c4390-17e2-45e5-a923-cb4a7cfd5978" /> | _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/<ref>/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**. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
3e2d54eccb
commit
a5dcf3b57c
8 files changed
+204
-340
No files matched your search
+39
@@ -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) => (
|
||||
<div className="space-y-1">
|
||||
<dt className="flex items-center gap-1.5 text-sm text-foreground-lighter">
|
||||
{label}
|
||||
{tooltip !== undefined && (
|
||||
<InfoTooltip side="top" className="max-w-56">
|
||||
{tooltip}
|
||||
</InfoTooltip>
|
||||
)}
|
||||
</dt>
|
||||
<dd className="text-sm">{children}</dd>
|
||||
{description !== undefined && <DetailSubtext>{description}</DetailSubtext>}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const PIPELINE_DETAIL_GRID_CLASS_NAME = 'grid grid-cols-1 gap-x-10 gap-y-6 md:grid-cols-2'
|
||||
+36
@@ -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(<PipelineHealthSection metrics={{ ...baseMetrics, safe_wal_size_bytes: null }} />)
|
||||
|
||||
expect(screen.getByText('WAL retention remaining')).toBeInTheDocument()
|
||||
expect(screen.getByText('Unlimited')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('formats a numeric safe WAL size normally', () => {
|
||||
customRender(<PipelineHealthSection metrics={{ ...baseMetrics, safe_wal_size_bytes: 1024 }} />)
|
||||
|
||||
expect(screen.getByText('1 KB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the absolute last check-in time on hover', async () => {
|
||||
customRender(<PipelineHealthSection metrics={{ ...baseMetrics, safe_wal_size_bytes: null }} />)
|
||||
|
||||
await userEvent.hover(screen.getByText('Just now'))
|
||||
|
||||
expect(await screen.findByRole('tooltip')).toHaveTextContent(/\w{3} \d{1,2}, \d{4}/)
|
||||
})
|
||||
})
|
||||
+104
@@ -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 (
|
||||
<PageSection>
|
||||
<PageSectionMeta>
|
||||
<PageSectionSummary>
|
||||
<PageSectionTitle>Pipeline health</PageSectionTitle>
|
||||
</PageSectionSummary>
|
||||
</PageSectionMeta>
|
||||
|
||||
<PageSectionContent className="flex flex-col gap-y-4">
|
||||
{children}
|
||||
|
||||
{metrics !== undefined && (
|
||||
<Card>
|
||||
<CardContent className="pb-5">
|
||||
<dl className={PIPELINE_DETAIL_GRID_CLASS_NAME}>
|
||||
<PipelineDetailItem
|
||||
label="Slot status"
|
||||
tooltip={SLOT_STATUS_TOOLTIP}
|
||||
description={
|
||||
<>
|
||||
{walStatusMeta.description}{' '}
|
||||
<InlineLink
|
||||
className="text-foreground-lighter hover:text-foreground"
|
||||
href={`${DOCS_URL}/guides/database/replication/pipelines-monitoring#slot-statuses`}
|
||||
>
|
||||
Learn more
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SlotWalStatusValue status={metrics.wal_status} />
|
||||
</PipelineDetailItem>
|
||||
|
||||
{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 (
|
||||
<PipelineDetailItem
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
tooltip={field.description}
|
||||
>
|
||||
{valueTooltip !== undefined ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="w-fit cursor-default">{display}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{valueTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
display
|
||||
)}
|
||||
{detail !== undefined && (
|
||||
<span className="block text-xs text-foreground-lighter">{detail}</span>
|
||||
)}
|
||||
</PipelineDetailItem>
|
||||
)
|
||||
})}
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</PageSectionContent>
|
||||
</PageSection>
|
||||
)
|
||||
}
|
||||
+3
-54
@@ -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 && (
|
||||
<div className="border border-default rounded-lg bg-surface-100 px-4 py-4 space-y-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-foreground">Pipeline metrics</h4>
|
||||
<p className="text-xs text-foreground-light">
|
||||
Live metrics on how this pipeline is doing right now.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2.5">
|
||||
<SlotConnectionIndicator isActive={applyLagMetrics.active} />
|
||||
<span className="h-3.5 w-px bg-border" />
|
||||
<SlotStatusBadge status={applyLagMetrics.wal_status} />
|
||||
<SlotStatusLegend />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PipelineHealthSection metrics={applyLagMetrics}>
|
||||
{isStatusError && (
|
||||
<p className="text-xs text-warning-700">
|
||||
Unable to refresh data. Showing the last values we received.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<SlotLagMetricsList metrics={applyLagMetrics} />
|
||||
|
||||
{tablesWithLag.length > 0 && (
|
||||
<>
|
||||
<div className="border-t border-default/40" />
|
||||
<div className="space-y-3 text-xs text-foreground">
|
||||
<div className="flex items-start gap-2 rounded-md border border-default/50 bg-surface-200/60 px-3 py-2 text-foreground-light">
|
||||
<Info size={14} className="mt-0.5" />
|
||||
<span>
|
||||
During initial sync, tables can copy and stream independently before
|
||||
reconciling with the overall pipeline.
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-sm border border-default/50 bg-surface-200/40">
|
||||
<ul className="divide-y divide-default/40">
|
||||
{tablesWithLag.map((table) => (
|
||||
<li key={table.id} className="px-3 py-2">
|
||||
<SlotLagMetricsInline
|
||||
tableName={`${table.schema}.${table.name}`}
|
||||
metrics={table.table_sync_lag as SlotLagMetrics}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PipelineHealthSection>
|
||||
)}
|
||||
|
||||
{!isPipelineLoading && !isStatusLoading && hasTableData && (
|
||||
|
||||
+5
-8
@@ -167,8 +167,7 @@ export const WAL_STATUS_META: Record<SlotWalStatus, WalStatusMeta> = {
|
||||
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<SlotWalStatus, WalStatusMeta> = {
|
||||
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<SlotWalStatus, WalStatusMeta> = {
|
||||
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<SlotWalStatus, WalStatusMeta> = {
|
||||
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<SlotWalStatus, WalStatusMeta> = {
|
||||
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.",
|
||||
},
|
||||
|
||||
-27
@@ -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(<SlotLagMetricsList metrics={{ ...baseMetrics, safe_wal_size_bytes: null }} />)
|
||||
|
||||
expect(screen.getByText('WAL retention remaining')).toBeInTheDocument()
|
||||
expect(screen.getByText('Unlimited')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('formats a numeric safe WAL size normally', () => {
|
||||
customRender(<SlotLagMetricsList metrics={{ ...baseMetrics, safe_wal_size_bytes: 1024 }} />)
|
||||
|
||||
expect(screen.getByText('1 KB')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
+10
-120
@@ -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 (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs text-foreground">
|
||||
<span className="truncate font-medium" title={tableName}>
|
||||
{tableName}
|
||||
</span>
|
||||
<SlotConnectionIndicator isActive={metrics.active} context="table" />
|
||||
<span className="h-3.5 w-px bg-border" />
|
||||
{metrics.wal_status && <SlotStatusBadge status={metrics.wal_status} context="table" />}
|
||||
<span className="h-3.5 w-px bg-border" />
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[11px] text-foreground-light">
|
||||
{SLOT_LAG_FIELDS.map((field) => {
|
||||
const { display } = getFieldDisplay(field, metrics[field.key])
|
||||
return (
|
||||
<span key={`${tableName}-${field.key}`} className="flex items-baseline gap-1">
|
||||
<span className="uppercase tracking-wide text-[10px] text-foreground-lighter">
|
||||
{field.label}
|
||||
</span>
|
||||
<span className="text-foreground">{display}</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<dl className={`grid ${gridClasses}`}>
|
||||
{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 (
|
||||
<div key={field.key} className="flex flex-col gap-0.5">
|
||||
<dt className={labelClasses}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{field.label}
|
||||
{showMetricInfo && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
aria-label={`What is ${field.label}`}
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-surface-200 text-foreground-lighter transition-colors hover:bg-surface-300 hover:text-foreground focus-ring"
|
||||
>
|
||||
<Info size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start" className="max-w-xs text-xs">
|
||||
{field.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
</dt>
|
||||
<dd className={`flex flex-col ${valueClasses}`}>
|
||||
{valueTooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="w-fit cursor-default">{display}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{valueTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span>{display}</span>
|
||||
)}
|
||||
{detail && <span className="text-[11px] text-foreground-lighter">{detail}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
+7
-131
@@ -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<SlotStatusContext, { active: string; inactive: string }> = {
|
||||
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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant={meta.variant} className="cursor-default">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[260px]">
|
||||
{description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Info button opening a legend that explains every possible slot status.
|
||||
*/
|
||||
export const SlotStatusLegend = () => {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
aria-label="What do the slot statuses mean?"
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-surface-200 text-foreground-lighter transition-colors hover:bg-surface-300 hover:text-foreground focus-ring"
|
||||
>
|
||||
<Info size={12} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="bottom" align="center" className="w-[26rem] p-0">
|
||||
<div className="px-4 py-3 border-b border-overlay">
|
||||
<p className="text-sm text-foreground">Slot statuses</p>
|
||||
<p className="text-xs text-foreground-light">
|
||||
How safely your database is keeping the changes the pipeline still needs.
|
||||
</p>
|
||||
</div>
|
||||
<ul className="flex flex-col divide-y divide-overlay">
|
||||
{WAL_STATUS_LEGEND.map((meta) => (
|
||||
<li key={meta.label} className="flex items-start gap-x-3 px-4 py-2.5">
|
||||
<div className="flex h-5 w-28 shrink-0 items-center">
|
||||
<Badge variant={meta.variant}>{meta.label}</Badge>
|
||||
</div>
|
||||
<span className="flex-1 text-xs leading-5 text-foreground-light">
|
||||
{meta.description}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="border-t border-overlay px-4 py-2.5">
|
||||
<InlineLink
|
||||
href={`${DOCS_URL}/guides/database/replication/pipelines-monitoring`}
|
||||
className="text-xs text-foreground-light"
|
||||
>
|
||||
Learn more about monitoring replication
|
||||
</InlineLink>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center gap-x-1.5 text-xs text-foreground-light cursor-default">
|
||||
<span
|
||||
className={cn(
|
||||
'h-1.5 w-1.5 rounded-full shrink-0',
|
||||
isActive ? 'bg-brand' : 'bg-foreground-muted'
|
||||
)}
|
||||
/>
|
||||
{isActive ? 'Connected' : 'Not connected'}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[260px]">
|
||||
{isActive ? text.active : text.inactive}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
return <StateDot variant={meta.variant}>{meta.label}</StateDot>
|
||||
}
|
||||
Reference in new issue
Block a user