Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/pr-screenshots/replication-data-canary.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
194 changes: 190 additions & 4 deletions src/pages/admin/dashboard/replication.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,37 @@ interface ReplicationSlotLag {
reasons: string[]
}

interface SubscriptionHealth {
status: 'ok' | 'ko' | 'skipped'
checked_at: string
threshold_seconds: number
subscriptions: Array<{
subname: string
enabled: boolean
has_apply_worker: boolean
has_recent_receipt: boolean
apply_lag_seconds: number | null
last_msg_receipt_time: string | null
status: 'ok' | 'ko' | 'disabled'
reasons: string[]
}>
reasons: string[]
}

interface DataCanary {
status: 'ok' | 'ko' | 'skipped'
table: string
primary_count: number | null
replica_count: number | null
diff: number | null
diff_percent: number | null
threshold_percent: number
checked_at: string
expires_at: string
cached: boolean
reasons: string[]
}

interface ReplicationStatusResponse {
status: 'ok' | 'ko'
threshold_seconds: number
Expand All @@ -44,6 +75,8 @@ interface ReplicationStatusResponse {
max_lag_minutes?: number | null
max_lag_slot: string | null
slots: ReplicationSlotLag[]
subscription?: SubscriptionHealth
data_canary?: DataCanary
error?: string
message?: string
error_message?: string
Expand Down Expand Up @@ -100,6 +133,48 @@ const checkedAt = computed(() => {
return formatLocalDateTime(data.value.checked_at)
})

const subscriptionStatus = computed(() => data.value?.subscription?.status?.toUpperCase() ?? '-')
const subscriptionColor = computed(() => {
const status = data.value?.subscription?.status
if (status === 'ok')
return 'text-emerald-500'
if (status === 'ko')
return 'text-rose-500'
return 'text-slate-500'
})
const subscriptionSubtitle = computed(() => {
const sub = data.value?.subscription
if (!sub)
return 'No subscription data'
if (sub.reasons.length)
return sub.reasons.join(', ')
const healthy = sub.subscriptions.find(item => item.status === 'ok')
return healthy?.subname ?? `${sub.subscriptions.length} subscription(s)`
})

const canaryStatus = computed(() => data.value?.data_canary?.status?.toUpperCase() ?? '-')
const canaryColor = computed(() => {
const status = data.value?.data_canary?.status
if (status === 'ok')
return 'text-emerald-500'
if (status === 'ko')
return 'text-rose-500'
return 'text-slate-500'
})
const canarySubtitle = computed(() => {
const canary = data.value?.data_canary
if (!canary)
return 'No canary data'
if (canary.reasons.length)
return canary.reasons.join(', ')
const primary = canary.primary_count == null ? '-' : formatNumberValue(canary.primary_count)
const replica = canary.replica_count == null ? '-' : formatNumberValue(canary.replica_count)
if (canary.status === 'skipped')
return canary.reasons.join(', ') || 'skipped'
const cacheLabel = canary.cached ? 'cached' : 'fresh'
return `${canary.table}: ${primary} / ${replica} (${cacheLabel})`
})

async function loadReplicationStatus() {
isLoading.value = true
errorMessage.value = null
Expand Down Expand Up @@ -176,7 +251,7 @@ displayStore.defaultBack = '/dashboard'
{{ t('replication') }}
</h1>
<p class="text-sm text-slate-500 dark:text-slate-300">
Logical replication slot lag monitoring
Slot lag, subscription workers, and app_versions count canary
</p>
</div>
<div class="flex flex-wrap gap-2">
Expand Down Expand Up @@ -206,13 +281,25 @@ displayStore.defaultBack = '/dashboard'
<PageLoader v-else-if="isLoading && !data" />

<div v-else-if="data" class="space-y-6">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-4">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
<AdminStatsCard
title="Status"
:value="statusLabel"
:color-class="statusColor"
:subtitle="`Threshold ${formatNumberValue(thresholdMinutes)} min`"
/>
<AdminStatsCard
title="Subscription"
:value="subscriptionStatus"
:color-class="subscriptionColor"
:subtitle="subscriptionSubtitle"
/>
<AdminStatsCard
title="Data canary"
:value="canaryStatus"
:color-class="canaryColor"
:subtitle="canarySubtitle"
/>
<AdminStatsCard
title="Max lag"
:value="maxLagMinutes"
Expand All @@ -230,6 +317,105 @@ displayStore.defaultBack = '/dashboard'
/>
</div>

<div class="grid grid-cols-1 gap-6 xl:grid-cols-2">
<div class="rounded-lg border border-slate-300 bg-white shadow-lg dark:border-slate-900 dark:bg-gray-800">
<div class="border-b border-slate-200 px-6 py-4 dark:border-slate-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Subscription workers
</h2>
<p class="text-xs text-gray-500 dark:text-gray-400">
Live apply worker check on the read replica
</p>
</div>
<div v-if="!(data.subscription?.subscriptions?.length)" class="px-6 py-8 text-sm text-gray-500 dark:text-gray-400">
{{ data.subscription?.reasons?.join(', ') || 'No subscription rows' }}
</div>
<div v-else class="-mx-4 overflow-x-auto sm:mx-0">
<table class="min-w-full divide-y divide-slate-200 text-sm dark:divide-slate-700">
<thead class="bg-slate-50 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:bg-slate-900 dark:text-slate-400">
<tr>
<th scope="col" class="px-4 py-3">
Name
</th>
<th scope="col" class="px-4 py-3">
Worker
</th>
<th scope="col" class="px-4 py-3">
Lag (s)
</th>
<th scope="col" class="px-4 py-3">
Status
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
<tr
v-for="sub in data.subscription?.subscriptions ?? []"
:key="sub.subname"
class="transition hover:bg-slate-50 dark:hover:bg-slate-700/60"
>
<td class="whitespace-nowrap px-4 py-3 font-semibold text-gray-900 dark:text-white">
{{ sub.subname }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-gray-700 dark:text-gray-200">
{{ sub.has_apply_worker ? 'Yes' : 'No' }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-gray-700 dark:text-gray-200">
{{ sub.apply_lag_seconds == null ? '-' : formatNumberValue(sub.apply_lag_seconds, { maximumFractionDigits: 1 }) }}
</td>
<td class="whitespace-nowrap px-4 py-3">
<span
class="d-badge"
:class="!sub.enabled ? 'd-badge-neutral' : sub.status === 'ok' ? 'd-badge-success' : 'd-badge-error'"
>
{{ sub.enabled ? sub.status.toUpperCase() : 'DISABLED' }}
</span>
</td>
Comment thread
riderx marked this conversation as resolved.
</tr>
</tbody>
</table>
</div>
</div>

<div class="rounded-lg border border-slate-300 bg-white shadow-lg dark:border-slate-900 dark:bg-gray-800">
<div class="border-b border-slate-200 px-6 py-4 dark:border-slate-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
app_versions canary
</h2>
<p class="text-xs text-gray-500 dark:text-gray-400">
Count compare cached at most every 5 minutes
</p>
</div>
<div class="space-y-3 px-6 py-6 text-sm text-gray-700 dark:text-gray-200">
<div class="flex justify-between gap-4">
<span>Primary</span>
<span class="font-semibold">{{ data.data_canary?.primary_count == null ? '-' : formatNumberValue(data.data_canary.primary_count) }}</span>
</div>
<div class="flex justify-between gap-4">
<span>Replica</span>
<span class="font-semibold">{{ data.data_canary?.replica_count == null ? '-' : formatNumberValue(data.data_canary.replica_count) }}</span>
</div>
<div class="flex justify-between gap-4">
<span>Diff</span>
<span class="font-semibold">
{{ data.data_canary?.diff == null ? '-' : formatNumberValue(data.data_canary.diff) }}
<template v-if="data.data_canary?.diff_percent != null">
({{ formatNumberValue((data.data_canary.diff_percent || 0) * 100, { maximumFractionDigits: 2 }) }}%)
</template>
</span>
</div>
<div class="flex justify-between gap-4">
<span>Cache</span>
<span class="font-semibold">{{ !data.data_canary ? '-' : data.data_canary.status === 'skipped' ? 'skipped' : (data.data_canary.cached ? 'cached' : 'fresh') }}</span>
</div>
<div class="flex justify-between gap-4">
<span>Notes</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ data.data_canary?.reasons?.length ? data.data_canary.reasons.join(', ') : '-' }}</span>
</div>
</div>
</div>
</div>

<div class="rounded-lg border border-slate-300 bg-white shadow-lg dark:border-slate-900 dark:bg-gray-800">
<div class="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-6 py-4 dark:border-slate-700">
<div>
Expand Down Expand Up @@ -298,8 +484,8 @@ displayStore.defaultBack = '/dashboard'
</td>
<td class="whitespace-nowrap px-4 py-3">
<span
class="badge"
:class="slot.status === 'ok' ? 'badge-success' : 'badge-error'"
class="d-badge"
:class="slot.status === 'ok' ? 'd-badge-success' : 'd-badge-error'"
>
{{ slot.status.toUpperCase() }}
</span>
Expand Down
Loading
Loading