Files
Danny White a4be167491 fix(studio): clarify Warehouse status and navigation (#50554)
## What kind of change does this PR introduce?

Bug fix and UI polish.

## What is the current behavior?

The Warehouse Connect option repeats its own name, and a configured
Warehouse has no direct route back to its management page. Warehouse
table states also use badges instead of the status-dot pattern used by
Replication, and a backfilling table can misleadingly appear as “Caught
up”.

## What is the new behavior?

- Describes Warehouse as an analytical endpoint and adds a low-emphasis
“Manage Warehouse” link from the Connect sheet.
- Shares Replication’s status-dot presentation with Warehouse while
keeping feature-specific state mapping separate.
- Shows replication lag only for live tables, so backfilling and “Caught
up” are never presented together.

| Before | After |
| --- | --- |
| <img width="1244" height="1156" alt="CleanShot 2026-09-18 at 14 03
49@2x"
src="https://github.com/user-attachments/assets/60aa3496-6930-491a-af9e-9ffcfb035a0f"
/> | <img width="1216" height="1214" alt="CleanShot 2026-09-18 at 14 04
33@2x"
src="https://github.com/user-attachments/assets/84a8c80e-d8d1-4ee5-9cce-352d1f6477c2"
/> |
| <img width="1314" height="1414" alt="CleanShot 2026-09-18 at 14 02
50@2x"
src="https://github.com/user-attachments/assets/8871a4a5-0543-4290-91a7-9da949ec4c49"
/> | <img width="1308" height="1498" alt="CleanShot 2026-09-18 at 14 02
43@2x"
src="https://github.com/user-attachments/assets/ac3fc6d1-5b4d-4a3a-8ab7-bb54025e2145"
/> |

## To test

1. Open `/project/<ref>?showConnect=true&connectTab=warehouse` for a
project with Warehouse configured. Confirm the mode subtitle says
“Analytical endpoint”. Confirm the new “Manage Warehouse” button opens
`/project/<ref>/integrations/warehouse/overview`.
2. Open `/project/<ref>/integrations/warehouse/overview` while a table
is backfilling. Confirm it has a pulsing amber status dot and does not
show “Caught up”.
3. Once the table is live, confirm it has a green “Live” status dot and
its lag appears normally.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added clearer Warehouse setup progress messaging with a “View
progress” action while setup is running.
- Connection details are shown once the Warehouse is provisioned or has
live tables.
- Added a Cancel action when editing changed Warehouse table selections.
- Updated table statuses with live, syncing, and warning indicators,
including animated syncing states.
  - Lag details are displayed for live tables when available.
  - Renamed the Warehouse connection option to “Analytical endpoint.”

- **Style**
  - Improved Warehouse management controls and table name readability.
  - Removed the table Size column from the Warehouse overview.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-22 12:21:40 +10:00

301 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration'
import { getPipelineDisplayState, normalizePipelineStatusName } from '../Pipeline.utils'
import {
RetryPolicy,
SlotLagMetrics,
SlotWalStatus,
TableState,
} from './ReplicationPipelineStatus.types'
import type { StateDotVariant } from '@/components/ui/StateDot'
import { ReplicationPipelineStatusData } from '@/data/replication/pipeline-status-query'
import { formatBytes } from '@/lib/helpers'
import { PipelineStatusRequestStatus } from '@/state/replication-pipeline-request-status'
dayjs.extend(duration)
export const getStatusConfig = (
state: TableState['state']
): { variant: StateDotVariant; label: string; description: string; isPulsing?: boolean } => {
switch (state.name) {
case 'queued':
return { variant: 'default', label: 'Queued', description: 'Waiting to copy' }
case 'copying_table':
return {
variant: 'default',
label: 'Copying',
description: 'Copying existing rows',
isPulsing: true,
}
case 'copied_table':
return {
variant: 'default',
label: 'Copied',
description: 'Copy finished, about to start streaming',
}
case 'following_wal':
return { variant: 'success', label: 'Live', description: 'Streaming changes as they happen' }
case 'error':
return { variant: 'destructive', label: 'Error', description: 'Stopped after an error' }
default:
return { variant: 'warning', label: 'Unknown', description: 'Table status is unavailable' }
}
}
export const getDisabledStateConfig = ({
requestStatus,
statusName,
}: {
requestStatus: PipelineStatusRequestStatus
statusName?: ReplicationPipelineStatusData['status']['name']
}) => {
const normalizedStatusName = normalizePipelineStatusName(statusName)
const displayState = getPipelineDisplayState(requestStatus, normalizedStatusName)
const { title, message } = displayState
return { title, message }
}
export const isValidRetryPolicy = (policy: any): policy is RetryPolicy => {
if (!policy || typeof policy !== 'object' || !policy.policy) return false
switch (policy.policy) {
case 'no_retry':
case 'manual_retry':
return true
case 'timed_retry':
return typeof policy.next_retry === 'string'
default:
return false
}
}
const formatLagBytesValue = (value?: number) => {
if (typeof value !== 'number' || Number.isNaN(value)) {
return { display: 'n/a', detail: undefined }
}
// Scale to the most readable unit (e.g. "4 GB"). We intentionally don't surface the raw byte
// count as a detail line, since it's unreadable at GB scale (e.g. "4,294,967,296 bytes").
const decimals = value < 1024 ? 0 : value < 1024 * 1024 ? 1 : 2
return { display: formatBytes(value, decimals), detail: undefined }
}
// Scale to a single readable unit (ms, s, min, h) based on size, with no precise sub-line.
const formatLagDurationValue = (value?: number) => {
if (typeof value !== 'number' || Number.isNaN(value)) {
return { display: 'n/a', detail: undefined }
}
const sign = value < 0 ? '-' : ''
const absMilliseconds = Math.abs(value)
const duration = dayjs.duration(absMilliseconds, 'milliseconds')
if (absMilliseconds < 1000) {
return { display: `${value} ms`, detail: undefined }
}
const seconds = duration.asSeconds()
if (seconds < 60) {
return { display: `${sign}${seconds.toFixed(seconds >= 10 ? 1 : 2)} s`, detail: undefined }
}
const minutes = duration.asMinutes()
if (minutes < 60) {
return { display: `${sign}${minutes.toFixed(minutes >= 10 ? 1 : 2)} min`, detail: undefined }
}
const hours = duration.asHours()
return { display: `${sign}${hours.toFixed(hours >= 10 ? 1 : 2)} h`, detail: undefined }
}
export const getFormattedLagValue = (type: 'bytes' | 'duration', value?: number) =>
type === 'bytes' ? formatLagBytesValue(value) : formatLagDurationValue(value)
const COPYING_STATES: TableState['state']['name'][] = ['queued', 'copying_table', 'copied_table']
/**
* How much of the initial copy is left. A pipeline can be caught up on its ongoing change stream
* while tables are still copying, so several surfaces need to know this to stay honest.
*/
export const getInitialSyncProgress = (
tableStatuses: { state: { name: TableState['state']['name'] } }[]
) => {
const count = (name: TableState['state']['name']) =>
tableStatuses.filter((table) => table.state.name === name).length
return {
// Everything not yet streaming, whatever stage of the initial sync it is at
syncingCount: tableStatuses.filter((table) => COPYING_STATES.includes(table.state.name)).length,
copyingCount: count('copying_table'),
queuedCount: count('queued'),
totalCount: tableStatuses.length,
}
}
export type LagSeverity = 'normal' | 'warning' | 'critical'
type SlotStatusBadgeVariant = 'success' | 'warning' | 'destructive' | 'default'
interface WalStatusMeta {
label: string
variant: SlotStatusBadgeVariant
severity: LagSeverity
// Shown in the pipeline-level metrics panel.
description: string
// Shown in the per-table inline sync view where the slot belongs to a single table.
tableDescription: string
}
// Plain-language meaning, color, and severity for each WAL status Postgres can report for a slot.
// `variant` drives the badge color; `severity` drives whether the list view raises a warning icon
// (e.g. "extended" is shown amber as a heads-up but isn't alarming on its own).
export const WAL_STATUS_META: Record<SlotWalStatus, WalStatusMeta> = {
reserved: {
label: 'Reserved',
variant: 'success',
severity: 'normal',
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.",
},
extended: {
label: 'Extended',
variant: 'warning',
severity: 'normal',
description:
'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.",
},
unreserved: {
label: 'Unreserved',
variant: 'warning',
severity: 'warning',
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.",
},
lost: {
label: 'Lost',
variant: 'destructive',
severity: 'critical',
description:
'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.",
},
unknown: {
label: 'Unknown',
variant: 'default',
severity: 'normal',
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.",
},
}
// Postgres reports no WAL status (restart_lsn is null) as "unknown" too, so fall back to it.
export const getWalStatusMeta = (status?: SlotWalStatus | null): WalStatusMeta =>
WAL_STATUS_META[status ?? 'unknown']
// Legend entries from healthiest to most severe, ending with the unknown/unavailable case.
export const WAL_STATUS_LEGEND: WalStatusMeta[] = [
WAL_STATUS_META.reserved,
WAL_STATUS_META.extended,
WAL_STATUS_META.unreserved,
WAL_STATUS_META.lost,
WAL_STATUS_META.unknown,
]
export const getWalStatusSeverity = (status?: SlotWalStatus | null): LagSeverity =>
getWalStatusMeta(status).severity
// Slot-loss risk from how much of the slot's WAL budget has been consumed, rather than fixed byte
// thresholds: max_slot_wal_keep_size ≈ retained WAL (restart_lsn_bytes) + remaining headroom
// (safe_wal_size_bytes), so the consumed fraction is how close the slot is to the "lost" state.
// A null/absent safe_wal_size_bytes now means unlimited retention, so it carries no budget risk.
export const SLOT_LOSS_WARNING_RATIO = 0.75
export const SLOT_LOSS_CRITICAL_RATIO = 0.9
export const getSlotBudgetSeverity = (
retainedBytes?: number,
safeWalSizeBytes?: number | null
): LagSeverity => {
if (
typeof retainedBytes !== 'number' ||
typeof safeWalSizeBytes !== 'number' ||
!Number.isFinite(retainedBytes) ||
!Number.isFinite(safeWalSizeBytes)
) {
return 'normal'
}
const total = retainedBytes + safeWalSizeBytes
// Nothing retained and no headroom left: nothing to flag (also avoids a 0/0 division). When the
// headroom is 0 but WAL is still retained, the ratio is 1 and the slot is correctly critical.
if (total <= 0) return 'normal'
const consumedRatio = retainedBytes / total
if (consumedRatio >= SLOT_LOSS_CRITICAL_RATIO) return 'critical'
if (consumedRatio >= SLOT_LOSS_WARNING_RATIO) return 'warning'
return 'normal'
}
const SEVERITY_RANK: Record<LagSeverity, number> = { normal: 0, warning: 1, critical: 2 }
const maxSeverity = (a: LagSeverity, b: LagSeverity): LagSeverity =>
SEVERITY_RANK[a] >= SEVERITY_RANK[b] ? a : b
// Overall slot health = the worse of the reported WAL status and how close the WAL budget is to
// running out. Used to color/flag the lag value in the destinations list.
export const getSlotHealthSeverity = (slot?: {
restart_lsn_bytes?: number
safe_wal_size_bytes?: number | null
wal_status?: SlotWalStatus
}): LagSeverity => {
if (!slot) return 'normal'
return maxSeverity(
getWalStatusSeverity(slot.wal_status),
getSlotBudgetSeverity(slot.restart_lsn_bytes, slot.safe_wal_size_bytes)
)
}
/**
* A table's own replication slot, as a short list of phrases for one table cell. Skips anything
* that carries no signal, such as a zero backlog or a reserved WAL status, so the line only ever
* says what's worth reading. Connection is skipped on purpose: a copying table's slot is inactive
* until the copy finishes, so flagging it would look like a fault.
*/
export const getTableSyncLagLabel = (metrics: SlotLagMetrics): string[] => {
const parts: string[] = []
const pendingBytes = metrics.confirmed_flush_lsn_bytes
if (typeof pendingBytes === 'number' && pendingBytes > 0) {
parts.push(`${formatBytes(pendingBytes, pendingBytes < 1024 ? 0 : 1)} waiting to sync`)
}
const safeWalSizeBytes = metrics.safe_wal_size_bytes
if (safeWalSizeBytes === null) {
parts.push('Unlimited WAL retention')
} else if (
typeof safeWalSizeBytes === 'number' &&
Number.isFinite(safeWalSizeBytes) &&
safeWalSizeBytes >= 0
) {
const formattedBytes = formatBytes(safeWalSizeBytes, safeWalSizeBytes < 1024 ? 0 : 1)
parts.push(`${formattedBytes} WAL retention remaining`)
}
if (metrics.wal_status === 'unreserved') parts.push('Some changes at risk')
if (metrics.wal_status === 'lost') parts.push('Some changes lost')
const replyLag = metrics.reply_time_lag
if (typeof replyLag === 'number' && replyLag > 0) {
parts.push(`Last check-in ${getFormattedLagValue('duration', replyLag).display}`)
}
return parts
}