Skip to content
Open
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.
191 changes: 187 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'
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,102 @@ 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.status === 'ok' ? 'd-badge-success' : 'd-badge-error'">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Show disabled subscriptions as disabled.

The backend excludes disabled subscriptions from aggregate health, but this cell renders every non-ok row as a red KO badge. A healthy summary can therefore show a failed-looking disabled row. Render a neutral DISABLED state when sub.enabled is false, or omit disabled subscriptions from this table.

Proposed fix
-                        <span class="d-badge" :class="sub.status === 'ok' ? 'd-badge-success' : 'd-badge-error'">
-                          {{ sub.status.toUpperCase() }}
+                        <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>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<span class="d-badge" :class="sub.status === 'ok' ? 'd-badge-success' : 'd-badge-error'">
<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>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pages/admin/dashboard/replication.vue` at line 367, Update the
subscription status cell in the replication dashboard to check sub.enabled
before health status: render a neutral DISABLED badge for disabled
subscriptions, while preserving the existing success/error rendering for enabled
subscriptions.

{{ sub.status.toUpperCase() }}
</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?.status === 'skipped' ? 'skipped' : (data.data_canary?.cached ? 'cached' : 'fresh') }}</span>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: When data_canary is absent, the Cache row reports fresh even though the other canary fields show no data, which can imply that a count query ran successfully. Preserve the missing state (for example, -) before selecting cached or fresh.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/pages/admin/dashboard/replication.vue, line 406:

<comment>When `data_canary` is absent, the Cache row reports `fresh` even though the other canary fields show no data, which can imply that a count query ran successfully. Preserve the missing state (for example, `-`) before selecting `cached` or `fresh`.</comment>

<file context>
@@ -230,6 +317,102 @@ displayStore.defaultBack = '/dashboard'
+                </div>
+                <div class="flex justify-between gap-4">
+                  <span>Cache</span>
+                  <span class="font-semibold">{{ data.data_canary?.status === 'skipped' ? 'skipped' : (data.data_canary?.cached ? 'cached' : 'fresh') }}</span>
+                </div>
+                <div class="flex justify-between gap-4">
</file context>
Suggested change
<span class="font-semibold">{{ data.data_canary?.status === 'skipped' ? 'skipped' : (data.data_canary?.cached ? 'cached' : 'fresh') }}</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 +481,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