Cdc Replication Stream
blockdevopsDebezium and Fivetran style Change Data Capture (CDC) replication monitor and binlog sync pipeline with live telemetry metrics, table operation breakdowns, and real-time before/after row diff streaming.
Installation
$pnpm dlx uipkge-ng@latest add cdc-replication-stream$npx uipkge-ng@latest add cdc-replication-stream$yarn dlx uipkge-ng@latest add cdc-replication-stream$bunx uipkge-ng@latest add cdc-replication-streamManifest:
https://uipkge.dev/r/angular/cdc-replication-stream.jsonInstalls to:src/app/components/blocks/cdc-replication-stream/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
CdcTableMetricinterface CdcTableMetric {
name: string
schema: string
primaryKey: string
syncedRows: number
replicationDelayMs: number
status: 'synchronized' | 'syncing' | 'paused'
operations: {
insertPercent: number
updatePercent: number
deletePercent: number
}
mode: string
}CdcStreamEventinterface CdcStreamEvent {
id: string
timestamp: string
table: string
operation: CdcOperation
primaryKey: string
binlogPosition: string
lsn: string
txId: string
summary: string
modifiedFields?: string[]
before?: Record<string, unknown>
after?: Record<string, unknown>
}Files installed (2)
src/app/components/blocks/cdc-replication-stream/cdc-replication-stream.component.ts51.4 kB
import { ChangeDetectionStrategy, Component, DestroyRef, Input, OnInit, computed, inject, signal } from '@angular/core' import { Activity, ArrowRight, ArrowRightLeft, Check, ChevronDown, ChevronRight, Copy, Database, Download, Filter, Layers, LucideAngularModule, Pause, Play, RefreshCw, Search, Server, ShieldCheck, Terminal, Zap, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardHeaderComponent, UiCardTitleComponent, } from '@/ui/card/card.component' import { UiInputComponent } from '@/ui/input/input.component' import { UiProgressComponent } from '@/ui/progress/progress.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' import { UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, } from '@/ui/table/table.component' export type CdcOperation = 'INSERT' | 'UPDATE' | 'DELETE' export type CdcOperationFilter = 'all' | CdcOperation export interface CdcTableMetric { name: string schema: string primaryKey: string syncedRows: number replicationDelayMs: number status: 'synchronized' | 'syncing' | 'paused' operations: { insertPercent: number updatePercent: number deletePercent: number } mode: string } export interface CdcStreamEvent { id: string timestamp: string table: string operation: CdcOperation primaryKey: string binlogPosition: string lsn: string txId: string summary: string modifiedFields?: string[] before?: Record<string, unknown> after?: Record<string, unknown> } const defaultTables: CdcTableMetric[] = [ { name: 'users', schema: 'public', primaryKey: 'id [UUID]', syncedRows: 1842910, replicationDelayMs: 12, status: 'synchronized', operations: { insertPercent: 65, updatePercent: 30, deletePercent: 5, }, mode: 'Continuous WAL', }, { name: 'orders', schema: 'public', primaryKey: 'order_id [BIGINT]', syncedRows: 2145820, replicationDelayMs: 16, status: 'synchronized', operations: { insertPercent: 78, updatePercent: 20, deletePercent: 2, }, mode: 'Continuous WAL', }, { name: 'transactions', schema: 'public', primaryKey: 'tx_hash [VARCHAR]', syncedRows: 624300, replicationDelayMs: 18, status: 'synchronized', operations: { insertPercent: 92, updatePercent: 7, deletePercent: 1, }, mode: 'Continuous WAL', }, { name: 'subscriptions', schema: 'public', primaryKey: 'sub_id [UUID]', syncedRows: 208440, replicationDelayMs: 14, status: 'synchronized', operations: { insertPercent: 45, updatePercent: 50, deletePercent: 5, }, mode: 'Continuous WAL', }, ] const defaultEvents: CdcStreamEvent[] = [ { id: 'evt-cdc-104', timestamp: '2026-08-21T14:32:05.812Z', table: 'public.subscriptions', operation: 'UPDATE', primaryKey: 'sub_99a8b1c4', binlogPosition: 'mysql-bin.000412 : 8492014', lsn: '0/16B38E0', txId: 'tx_883019', summary: 'status: "trialing" → "active", plan: "starter_monthly" → "enterprise_annual", seats: 5 → 25', modifiedFields: ['status', 'plan', 'seats', 'monthly_amount', 'trial_ends_at', 'updated_at'], before: { id: 'sub_99a8b1c4', user_id: 'usr_44018', plan: 'starter_monthly', status: 'trialing', seats: 5, monthly_amount: 49.0, auto_renew: true, trial_ends_at: '2026-08-21T23:59:59Z', updated_at: '2026-08-14T10:00:00Z', }, after: { id: 'sub_99a8b1c4', user_id: 'usr_44018', plan: 'enterprise_annual', status: 'active', seats: 25, monthly_amount: 490.0, auto_renew: true, trial_ends_at: null, updated_at: '2026-08-21T14:32:05Z', }, }, { id: 'evt-cdc-103', timestamp: '2026-08-21T14:32:04.195Z', table: 'public.orders', operation: 'INSERT', primaryKey: 'ord_8819203', binlogPosition: 'mysql-bin.000412 : 8491820', lsn: '0/16B3698', txId: 'tx_883018', summary: 'New checkout order created for $1,280.00 (Customer: usr_99120, 3 line items)', modifiedFields: [], after: { order_id: 'ord_8819203', customer_id: 'usr_99120', currency: 'USD', total_amount: 1280.0, tax_amount: 102.4, status: 'paid', payment_gateway: 'stripe_card', shipping_country: 'US', created_at: '2026-08-21T14:32:04Z', }, }, { id: 'evt-cdc-102', timestamp: '2026-08-21T14:31:59.604Z', table: 'public.transactions', operation: 'UPDATE', primaryKey: 'tx_0x99b1f41', binlogPosition: 'mysql-bin.000412 : 8491412', lsn: '0/16B3440', txId: 'tx_883017', summary: 'status: "processing" → "settled", settled_at timestamp set', modifiedFields: ['status', 'settled_at'], before: { tx_hash: 'tx_0x99b1f41', order_id: 'ord_8819198', amount: 349.5, status: 'processing', settled_at: null, fee_cents: 1045, }, after: { tx_hash: 'tx_0x99b1f41', order_id: 'ord_8819198', amount: 349.5, status: 'settled', settled_at: '2026-08-21T14:31:59Z', fee_cents: 1045, }, }, { id: 'evt-cdc-101', timestamp: '2026-08-21T14:31:52.410Z', table: 'public.users', operation: 'INSERT', primaryKey: 'usr_99304', binlogPosition: 'mysql-bin.000412 : 8490980', lsn: '0/16B31F0', txId: 'tx_883016', summary: 'New user registration: [email protected] (Org: org_7720)', modifiedFields: [], after: { id: 'usr_99304', email: '[email protected]', full_name: 'Elena Rostova', org_id: 'org_7720', role: 'data_engineer', email_verified: true, mfa_enabled: true, created_at: '2026-08-21T14:31:52Z', }, }, { id: 'evt-cdc-100', timestamp: '2026-08-21T14:31:40.118Z', table: 'public.subscriptions', operation: 'DELETE', primaryKey: 'sub_7718021', binlogPosition: 'mysql-bin.000412 : 8490410', lsn: '0/16B2FA0', txId: 'tx_883015', summary: 'Hard purge of cancelled expired trial subscription sub_7718021', modifiedFields: [], before: { id: 'sub_7718021', user_id: 'usr_22910', plan: 'developer_free', status: 'cancelled', seats: 1, deleted_at: '2026-08-21T14:31:40Z', }, }, ] const currentBinlog = 'mysql-bin.000412 : 8492014' export function matchesSearch(event: CdcStreamEvent, query: string): boolean { if (!query.trim()) return true const q = query.trim() if (q.startsWith('/') && q.length > 1) { const lastSlash = q.lastIndexOf('/') if (lastSlash > 0) { const pattern = q.slice(1, lastSlash) const flags = q.slice(lastSlash + 1) try { const re = new RegExp(pattern, flags) return ( re.test(event.table) || re.test(event.operation) || re.test(event.primaryKey) || re.test(event.binlogPosition) || re.test(event.lsn) || re.test(event.txId) || re.test(event.summary) || re.test(JSON.stringify(event.before ?? {})) || re.test(JSON.stringify(event.after ?? {})) ) } catch { // Fall back to plain search on invalid regex } } } const lower = q.toLowerCase() return ( event.table.toLowerCase().includes(lower) || event.operation.toLowerCase().includes(lower) || event.primaryKey.toLowerCase().includes(lower) || event.binlogPosition.toLowerCase().includes(lower) || event.lsn.toLowerCase().includes(lower) || event.txId.toLowerCase().includes(lower) || event.summary.toLowerCase().includes(lower) || JSON.stringify(event.before ?? {}) .toLowerCase() .includes(lower) || JSON.stringify(event.after ?? {}) .toLowerCase() .includes(lower) ) } function formatTimestamp(iso: string): string { try { const d = new Date(iso) return d.toISOString().replace('T', ' ').replace('Z', ' UTC') } catch { return iso } } function formatNumber(num: number): string { return new Intl.NumberFormat('en-US').format(num) } const pillBase = 'focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none' @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-cdc-replication-stream, [ui-cdc-replication-stream]', standalone: true, // React renders the root <div> itself: keep this wrapper out of the layout. host: { class: 'contents' }, imports: [ LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardHeaderComponent, UiCardTitleComponent, UiInputComponent, UiProgressComponent, UiSeparatorComponent, UiTableComponent, UiTableHeaderComponent, UiTableBodyComponent, UiTableRowComponent, UiTableHeadComponent, UiTableCellComponent, ], template: ` <div data-slot="cdc-replication-stream" [class]="rootClass"> <!-- Header: Sync Pipeline Overview & Global Controls --> <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div class="flex items-start gap-3 sm:items-center"> <div class="bg-card flex size-10 shrink-0 items-center justify-center rounded-lg border shadow-xs"> <lucide-icon [img]="ArrowRightLeft" class="text-primary size-5" aria-hidden="true" /> </div> <div class="space-y-1"> <div class="flex flex-wrap items-center gap-2"> <h2 class="text-foreground text-2xl font-bold tracking-tight">CDC Replication Stream</h2> <!-- Real-Time Status Badge --> @if (isSnapshotting()) { <div class="border-info/30 bg-info/10 text-info inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium" > <lucide-icon [img]="RefreshCw" class="size-3 animate-spin" aria-hidden="true" /> <span>Snapshot Ingest Active</span> </div> } @else if (!isPaused()) { <div class="border-success/30 bg-success/10 text-success inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium" > <span class="relative flex size-2 shrink-0"> <span class="bg-success absolute inline-flex h-full w-full rounded-full opacity-75"></span> <span class="bg-success relative inline-flex size-2 rounded-full"></span> </span> <span>Replication Streaming · 18ms Latency</span> </div> } @else { <div class="border-warning/30 bg-warning/10 text-warning inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium" > <span class="bg-warning relative inline-flex size-2 rounded-full"></span> <span>Replication Paused</span> </div> } </div> <!-- Pipeline Architecture Breadcrumb --> <div class="flex flex-wrap items-center gap-1.5 text-xs"> <span class="text-foreground font-semibold">Postgres Source (OLTP)</span> <lucide-icon [img]="ArrowRight" class="text-muted-foreground size-3.5" aria-hidden="true" /> <span class="text-primary font-semibold">Snowflake DW (OLAP)</span> <span class="text-muted-foreground hidden font-mono sm:inline"> Debezium 2.7 · Apache Kafka · pgoutput </span> </div> </div> </div> <!-- Header Action Controls & Binlog Pointer --> <div class="flex flex-wrap items-center gap-2"> <!-- Binlog Position Indicator Chip --> <button type="button" aria-label="Copy current binlog position" class="bg-muted/50 hover:bg-muted focus-visible:ring-ring flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none" (click)="copyBinlogPosition()" > <lucide-icon [img]="Terminal" class="text-muted-foreground size-3.5" aria-hidden="true" /> <span class="text-muted-foreground">Binlog:</span> <span class="text-foreground font-mono font-medium">{{ currentBinlog }}</span> @if (copiedBinlog()) { <lucide-icon [img]="Check" class="text-success size-3" aria-hidden="true" /> } @else { <lucide-icon [img]="Copy" class="text-muted-foreground size-3 opacity-70" aria-hidden="true" /> } </button> <button ui-button variant="outline" size="sm" [disabled]="isSnapshotting()" class="gap-1.5" (click)="triggerSnapshot()" > <lucide-icon [img]="RefreshCw" [class]="snapshotIconClass()" aria-hidden="true" /> <span>{{ isSnapshotting() ? 'Syncing Snapshot...' : 'Force Sync Snapshot' }}</span> </button> <button ui-button variant="outline" size="sm" [class]="isPaused() ? 'border-warning/40 text-warning' : ''" (click)="togglePause()" > @if (isPaused()) { <lucide-icon [img]="Play" class="size-3.5 fill-current" aria-hidden="true" /> } @else { <lucide-icon [img]="Pause" class="size-3.5" aria-hidden="true" /> } <span>{{ isPaused() ? 'Resume Stream' : 'Pause Stream' }}</span> </button> <button ui-button aria-label="Download attachment" variant="default" size="sm" class="gap-1.5" (click)="exportJson()" > @if (exportedNotice()) { <lucide-icon [img]="Check" class="text-success size-3.5" aria-hidden="true" /> } @else { <lucide-icon [img]="Download" class="size-3.5" aria-hidden="true" /> } <span>{{ exportedNotice() ? 'Exported' : 'Export JSON' }}</span> </button> </div> </div> <!-- Snapshot Notification Banner --> @if (snapshotNotice()) { <div class="text-foreground border-info/30 bg-info/10 flex items-center justify-between rounded-lg border p-3 text-xs shadow-xs" > <div class="flex items-center gap-2.5"> <lucide-icon [img]="RefreshCw" class="text-info size-4 shrink-0" aria-hidden="true" /> <div> <span class="text-info font-semibold">Snapshot sync initiated:</span> <span> Executing non-blocking logical schema snapshot across 4 tables at WAL offset <code class="font-mono font-medium">mysql-bin.000412 : 8492014</code>. </span> </div> </div> <span ui-badge variant="outline" class="font-mono text-xs">LSN: 0/16B38E0</span> </div> } <!-- 4 CDC Telemetry Metric Cards --> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> <!-- 1. Replication Lag --> <ui-card class="border-border shadow-xs"> <ui-card-content class="p-4"> <div class="flex items-center justify-between"> <span class="text-muted-foreground text-xs font-medium">Replication Lag</span> <div class="bg-primary/10 text-primary border-primary/20 flex size-8 items-center justify-center rounded-md border" > <lucide-icon [img]="Zap" class="size-4" aria-hidden="true" /> </div> </div> <div class="mt-3 space-y-1.5"> <div class="flex items-baseline gap-2"> <span class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums">18ms</span> <span class="text-muted-foreground text-xs">p99: 24ms</span> </div> <div class="flex items-center gap-2"> <span ui-badge variant="success" class="font-mono text-xs">Sub-second real-time</span> </div> <div class="pt-1"> <div class="text-muted-foreground flex justify-between text-xs"> <span>SLA Headroom</span> <span class="font-mono">96.4%</span> </div> <ui-progress [value]="3.6" class="mt-1 h-1.5" /> </div> </div> </ui-card-content> </ui-card> <!-- 2. Throughput --> <ui-card class="border-border shadow-xs"> <ui-card-content class="p-4"> <div class="flex items-center justify-between"> <span class="text-muted-foreground text-xs font-medium">Throughput</span> <div class="border-info/20 bg-info/10 text-info flex size-8 items-center justify-center rounded-md border" > <lucide-icon [img]="Activity" class="size-4" aria-hidden="true" /> </div> </div> <div class="mt-3 space-y-1.5"> <div class="flex items-baseline gap-1.5"> <span class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums">1,420</span> <span class="text-muted-foreground text-xs">events/sec</span> </div> <div class="flex items-center gap-2"> <span ui-badge variant="info" class="font-mono text-xs">380 KB/s bandwidth</span> </div> <div class="text-muted-foreground flex items-center justify-between pt-1 text-xs"> <span>Hourly Pace</span> <span class="text-foreground font-mono font-medium">5.11M evts/hr</span> </div> </div> </ui-card-content> </ui-card> <!-- 3. Total Synced Today --> <ui-card class="border-border shadow-xs"> <ui-card-content class="p-4"> <div class="flex items-center justify-between"> <span class="text-muted-foreground text-xs font-medium">Total Synced Today</span> <div class="border-success/20 bg-success/10 text-success flex size-8 items-center justify-center rounded-md border" > <lucide-icon [img]="Database" class="size-4" aria-hidden="true" /> </div> </div> <div class="mt-3 space-y-1.5"> <div class="flex items-baseline gap-1.5"> <span class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums">4.82M</span> <span class="text-muted-foreground text-xs">Row Changes</span> </div> <div class="flex items-center gap-2"> <span ui-badge variant="outline" class="border-success/30 text-success font-mono text-xs"> +14.2% vs yesterday </span> </div> <div class="text-muted-foreground flex items-center justify-between pt-1 text-xs"> <span>WAL Integrity</span> <span class="text-success font-mono font-medium">100% Consistent</span> </div> </div> </ui-card-content> </ui-card> <!-- 4. Error / Dead Letter Queue --> <ui-card class="border-border shadow-xs"> <ui-card-content class="p-4"> <div class="flex items-center justify-between"> <span class="text-muted-foreground text-xs font-medium">Error / DLQ</span> <div class="border-success/20 bg-success/10 text-success flex size-8 items-center justify-center rounded-md border" > <lucide-icon [img]="ShieldCheck" class="size-4" aria-hidden="true" /> </div> </div> <div class="mt-3 space-y-1.5"> <div class="flex items-baseline gap-1.5"> <span class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums">0</span> <span class="text-muted-foreground text-xs">Failed Events</span> </div> <div class="flex items-center gap-2"> <span ui-badge variant="success" class="font-mono text-xs">DLQ Empty · Healthy</span> </div> <div class="text-muted-foreground flex items-center justify-between pt-1 text-xs"> <span>Schema Drift</span> <span class="text-foreground font-mono font-medium">0 Violations</span> </div> </div> </ui-card-content> </ui-card> </div> <!-- Active Synced Tables Table --> <ui-card class="border-border shadow-xs"> <ui-card-header class="pb-3"> <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <div> <ui-card-title class="text-base font-semibold" >Active Synced Tables ({{ tables().length }})</ui-card-title > <ui-card-description class="text-xs"> Per-table WAL change capture distribution, row synchronization counts, and per-table replication latency </ui-card-description> </div> <div class="text-muted-foreground flex items-center gap-2 text-xs"> <span class="flex items-center gap-1"> <span class="bg-success size-2 rounded-full"></span> INSERT </span> <span class="flex items-center gap-1"> <span class="bg-info size-2 rounded-full"></span> UPDATE </span> <span class="flex items-center gap-1"> <span class="bg-destructive size-2 rounded-full"></span> DELETE </span> </div> </div> </ui-card-header> <ui-card-content class="p-0"> <div class="overflow-x-auto"> <div ui-table density="cozy"> <thead ui-table-header> <tr ui-table-row> <th ui-table-head class="text-xs font-semibold">Table Name & Schema</th> <th ui-table-head class="text-xs font-semibold">Change Operations Breakdown</th> <th ui-table-head class="text-right text-xs font-semibold">Synced Rows Count</th> <th ui-table-head class="text-right text-xs font-semibold">Replication Delay</th> <th ui-table-head class="text-center text-xs font-semibold">Status</th> </tr> </thead> <tbody ui-table-body> @for (table of tables(); track table.name) { <tr ui-table-row class="group"> <!-- Table Name & Schema --> <td ui-table-cell class="py-3"> <div class="space-y-0.5"> <div class="flex items-center gap-2"> <span class="text-foreground font-mono text-xs font-semibold" >{{ table.schema }}.{{ table.name }}</span > <span ui-badge variant="outline" class="font-mono text-xs">{{ table.primaryKey }}</span> </div> <p class="text-muted-foreground text-xs font-normal">Mode: {{ table.mode }}</p> </div> </td> <!-- Operations Multi-Segment Stacked Progress Bar & Percentages --> <td ui-table-cell class="py-3"> <div class="w-full max-w-xs space-y-1.5"> <!-- Stacked Progress Bar --> <div class="bg-muted flex h-2 w-full overflow-hidden rounded-full border"> <div class="bg-success transition-colors duration-300" [style.width.%]="table.operations.insertPercent" [attr.title]="'INSERT: ' + table.operations.insertPercent + '%'" ></div> <div class="bg-info transition-colors duration-300" [style.width.%]="table.operations.updatePercent" [attr.title]="'UPDATE: ' + table.operations.updatePercent + '%'" ></div> <div class="bg-destructive transition-colors duration-300" [style.width.%]="table.operations.deletePercent" [attr.title]="'DELETE: ' + table.operations.deletePercent + '%'" ></div> </div> <!-- Breakdown Text --> <div class="flex items-center gap-2 font-mono text-xs"> <span class="text-success font-medium">INS {{ table.operations.insertPercent }}%</span> <span class="text-info font-medium">UPD {{ table.operations.updatePercent }}%</span> <span class="text-destructive font-medium">DEL {{ table.operations.deletePercent }}%</span> </div> </div> </td> <!-- Synced Rows Count --> <td ui-table-cell class="py-3 text-right"> <span class="text-foreground font-mono text-xs font-semibold tabular-nums">{{ formatNumber(table.syncedRows) }}</span> <p class="text-muted-foreground text-xs">rows committed</p> </td> <!-- Replication Delay --> <td ui-table-cell class="py-3 text-right"> <span class="text-success font-mono text-xs font-semibold tabular-nums" >{{ table.replicationDelayMs }}ms</span > <p class="text-muted-foreground text-xs">sub-second</p> </td> <!-- Status Badge --> <td ui-table-cell class="py-3 text-center"> <span ui-badge variant="success" class="gap-1 font-mono text-xs"> <span class="bg-success size-1.5 animate-pulse rounded-full"></span> Synchronized </span> </td> </tr> } </tbody> </div> </div> </ui-card-content> </ui-card> <!-- Live CDC Event Stream Ticker Card --> <ui-card class="border-border shadow-xs"> <!-- Terminal Header Bar --> <div class="bg-muted/40 flex flex-col gap-3 border-b p-3 sm:flex-row sm:items-center sm:justify-between"> <div class="flex items-center gap-2"> <div class="flex items-center gap-1.5"> <span class="bg-destructive/80 size-2.5 rounded-full"></span> <span class="bg-warning/80 size-2.5 rounded-full"></span> <span class="bg-success/80 size-2.5 rounded-full"></span> </div> <div> <span class="text-foreground text-xs font-semibold">Live CDC Event Stream Ticker</span> <span class="text-muted-foreground hidden font-mono text-xs sm:inline"> · wal_level=logical · slot: debezium_cdc_01 </span> </div> </div> <div class="flex items-center gap-2"> <span class="text-muted-foreground hidden font-mono text-xs sm:inline"> Matches: {{ filteredEvents().length }} / {{ events().length }} </span> <button ui-button variant="ghost" size="xs" class="h-7 gap-1 px-2 text-xs" [disabled]="filteredEvents().length === 0" (click)="toggleAllExpand()" > @if (allExpanded()) { <lucide-icon [img]="ChevronDown" class="size-3.5" aria-hidden="true" /> } @else { <lucide-icon [img]="ChevronRight" class="size-3.5" aria-hidden="true" /> } <span>{{ allExpanded() ? 'Collapse All' : 'Expand All' }}</span> </button> </div> </div> <!-- Stream Filter Toolbar --> <div class="border-b p-3"> <div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <!-- Search Input --> <div class="relative flex-1"> <!-- [class] (not class=) styles only the svg: the absolute offsets must not apply twice. --> <lucide-icon [img]="Search" [class]="'text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2'" aria-hidden="true" /> <ui-input [value]="searchQuery()" (valueChange)="searchQuery.set($event)" placeholder="Search table, operation, primary key, column, or /regex/..." class="pl-9 text-xs" /> </div> <!-- Operation Filter Buttons --> <div class="flex flex-wrap items-center gap-1.5"> <span class="text-muted-foreground mr-1 flex items-center gap-1 text-xs font-medium"> <lucide-icon [img]="Filter" class="size-3" aria-hidden="true" /> Op: </span> @for (op of operationFilters; track op) { <button type="button" [class]="pillClass(op)" (click)="selectedOperation.set(op)"> @if (op !== 'all') { <span [class]="pillDotClass(op)"></span> } {{ op === 'all' ? 'All' : op }} <span [class]="pillCountClass(op)">{{ operationCount(op) }}</span> </button> } </div> </div> </div> <!-- Event Rows Feed --> <ui-card-content class="p-0"> @if (filteredEvents().length === 0) { <!-- Empty State --> <div class="flex flex-col items-center justify-center gap-3 px-4 py-16 text-center"> <div class="bg-muted flex size-12 items-center justify-center rounded-full"> <lucide-icon [img]="Database" class="text-muted-foreground size-6 opacity-60" aria-hidden="true" /> </div> <div class="space-y-1"> <p class="text-foreground text-sm font-semibold">No CDC events match current criteria</p> <p class="text-muted-foreground text-xs"> {{ events().length === 0 ? 'The replication event stream buffer was cleared.' : 'Try adjusting your operation filter or search query.' }} </p> </div> <button ui-button variant="outline" size="sm" class="mt-2 text-xs" (click)="resetStream()"> <lucide-icon [img]="RefreshCw" class="size-3.5" aria-hidden="true" /> Restore CDC stream data </button> </div> } @else { <ul class="divide-border/60 divide-y"> @for (event of filteredEvents(); track event.id) { <li [class]="rowClass(event.id)"> <!-- Primary Row Trigger --> <div role="button" tabindex="0" [attr.aria-expanded]="isExpanded(event.id)" class="focus-visible:ring-ring flex cursor-pointer flex-col gap-3 p-3.5 focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset sm:flex-row sm:items-center sm:justify-between" (click)="toggleExpand(event.id)" (keydown)="onRowKeydown($event, event.id)" > <!-- Left Rail: Chevron + Timestamp + Operation Badge + Table + Summary --> <div class="flex min-w-0 items-start gap-3 sm:items-center"> <button type="button" aria-label="Toggle before and after payload diff" [class]="chevronClass(event.id)" > <lucide-icon [img]="ChevronRight" class="size-4" aria-hidden="true" /> </button> <div class="space-y-1 sm:space-y-0.5"> <div class="flex flex-wrap items-center gap-2"> <span class="text-muted-foreground font-mono text-xs whitespace-nowrap">{{ formatTimestamp(event.timestamp) }}</span> @if (event.operation === 'INSERT') { <span ui-badge class="bg-success/10 text-success border-transparent font-mono text-xs" >INSERT</span > } @if (event.operation === 'UPDATE') { <span ui-badge class="bg-info/10 text-info border-transparent font-mono text-xs" >UPDATE</span > } @if (event.operation === 'DELETE') { <span ui-badge class="bg-destructive/10 text-destructive border-transparent font-mono text-xs" >DELETE</span > } <span class="text-foreground font-mono text-xs font-semibold">{{ event.table }}</span> <span class="bg-muted text-muted-foreground rounded px-1.5 py-0.5 font-mono text-xs">{{ event.primaryKey }}</span> </div> <p class="text-muted-foreground text-xs font-normal">{{ event.summary }}</p> </div> </div> <!-- Right Rail: Binlog Offset & Tx ID --> <div class="flex shrink-0 flex-wrap items-center gap-3 sm:justify-end"> <div class="bg-muted/60 flex items-center gap-1.5 rounded-md border px-2 py-1 font-mono text-xs"> <span class="text-muted-foreground">Tx:</span> <span class="text-foreground font-medium">{{ event.txId }}</span> </div> <div class="text-muted-foreground flex items-center gap-1.5 font-mono text-xs"> <span>{{ event.binlogPosition }}</span> <span class="opacity-50">·</span> <span class="text-foreground/80 font-medium">{{ event.lsn }}</span> </div> </div> </div> <!-- Expanded Payload Diff View --> @if (isExpanded(event.id)) { <div class="bg-muted/15 border-t px-4 py-3.5"> <div class="space-y-3"> <!-- Modified Fields Tag Bar --> @if (event.modifiedFields && event.modifiedFields.length > 0) { <div class="bg-card flex flex-wrap items-center gap-1.5 rounded-md border p-2.5 text-xs"> <span class="text-muted-foreground font-medium">Modified Columns:</span> @for (field of event.modifiedFields; track field) { <span ui-badge variant="outline" class="text-info font-mono text-xs">{{ field }}</span> } </div> } <!-- Side-by-Side or Stacked JSON Diff --> <div class="grid grid-cols-1 gap-3 lg:grid-cols-2"> <!-- Before Payload (For UPDATE and DELETE) --> @if (event.before; as before) { <div class="overflow-hidden rounded-md border border-zinc-800 bg-zinc-950 font-mono text-xs text-zinc-100 shadow-inner" > <div class="flex items-center justify-between border-b border-zinc-800 bg-zinc-900/90 px-3 py-1.5" > <div class="flex items-center gap-2"> <span class="bg-destructive size-2 rounded-full"></span> <span class="text-destructive text-xs font-semibold">before_image.json</span> <span class="text-muted-foreground text-xs font-normal">(Previous State)</span> </div> <button ui-button variant="ghost" size="xs" class="h-6 gap-1 px-2 text-xs text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100" (click)="copyPayload(event.id + '-before', before)" > @if (copiedEventId() === event.id + '-before') { <lucide-icon [img]="Check" class="text-success size-3" aria-hidden="true" /> } @else { <lucide-icon [img]="Copy" class="size-3" aria-hidden="true" /> } <span>{{ copiedEventId() === event.id + '-before' ? 'Copied' : 'Copy' }}</span> </button> </div> <pre class="max-h-60 overflow-x-auto overflow-y-auto p-3 text-xs leading-relaxed select-text" ><code class="text-destructive/90">{{ json(before) }}</code></pre> </div> } <!-- After Payload (For INSERT and UPDATE) --> @if (event.after; as after) { <div [class]="afterPanelClass(event)"> <div class="flex items-center justify-between border-b border-zinc-800 bg-zinc-900/90 px-3 py-1.5" > <div class="flex items-center gap-2"> <span class="bg-success size-2 rounded-full"></span> <span class="text-success text-xs font-semibold">after_image.json</span> <span class="text-muted-foreground text-xs font-normal" >(Replicated Target State)</span > </div> <button ui-button variant="ghost" size="xs" class="h-6 gap-1 px-2 text-xs text-zinc-300 hover:bg-zinc-800 hover:text-zinc-100" (click)="copyPayload(event.id + '-after', after)" > @if (copiedEventId() === event.id + '-after') { <lucide-icon [img]="Check" class="text-success size-3" aria-hidden="true" /> } @else { <lucide-icon [img]="Copy" class="size-3" aria-hidden="true" /> } <span>{{ copiedEventId() === event.id + '-after' ? 'Copied' : 'Copy' }}</span> </button> </div> <pre class="max-h-60 overflow-x-auto overflow-y-auto p-3 text-xs leading-relaxed select-text" ><code class="text-success">{{ json(after) }}</code></pre> </div> } </div> </div> </div> } </li> } </ul> } </ui-card-content> <!-- Bottom Engine & Ingest Protocol Telemetry Bar --> <div class="bg-muted/40 flex flex-col gap-2.5 border-t px-4 py-3 text-xs sm:flex-row sm:items-center sm:justify-between" > <div class="text-muted-foreground flex flex-wrap items-center gap-4"> <div class="text-foreground flex items-center gap-1.5 font-medium"> <lucide-icon [img]="Activity" class="text-success size-3.5" aria-hidden="true" /> <span>CDC Pipeline:</span> <span class="font-mono">{{ isPaused() ? '0 evt/s (Paused)' : '1,420 events/sec' }}</span> </div> <ui-separator orientation="vertical" class="hidden h-3.5 sm:block" /> <div class="flex items-center gap-1.5"> <lucide-icon [img]="Layers" class="size-3.5" aria-hidden="true" /> <span>Sink Topic:</span> <span class="text-foreground font-mono">snowflake.cdc.raw_events</span> </div> <ui-separator orientation="vertical" class="hidden h-3.5 sm:block" /> <div class="flex items-center gap-1.5"> <lucide-icon [img]="Server" class="text-info size-3.5" aria-hidden="true" /> <span>Format:</span> <span class="text-foreground font-mono">JSON + Schema Registry (v2)</span> </div> </div> <div class="text-muted-foreground flex items-center gap-2 font-mono"> <span class="bg-success size-1.5 rounded-full"></span> <span>Postgres WAL2JSON · TLS 1.3 · Heartbeat: 1,000ms OK</span> </div> </div> </ui-card> </div> `, }) export class UiCdcReplicationStreamComponent implements OnInit { @Input() initialEvents?: CdcStreamEvent[] @Input() initialTables?: CdcTableMetric[] @Input() initialPaused = false @Input() initialOperationFilter: CdcOperationFilter = 'all' @Input() initialSearch = '' @Input('class') className?: string protected readonly ArrowRightLeft = ArrowRightLeft protected readonly ArrowRight = ArrowRight protected readonly RefreshCw = RefreshCw protected readonly Terminal = Terminal protected readonly Check = Check protected readonly Copy = Copy protected readonly Play = Play protected readonly Pause = Pause protected readonly Download = Download protected readonly Zap = Zap protected readonly Activity = Activity protected readonly Database = Database protected readonly ShieldCheck = ShieldCheck protected readonly ChevronDown = ChevronDown protected readonly ChevronRight = ChevronRight protected readonly Search = Search protected readonly Filter = Filter protected readonly Layers = Layers protected readonly Server = Server protected readonly currentBinlog = currentBinlog protected readonly operationFilters: CdcOperationFilter[] = ['all', 'INSERT', 'UPDATE', 'DELETE'] protected readonly formatNumber = formatNumber protected readonly formatTimestamp = formatTimestamp readonly tables = signal<CdcTableMetric[]>([...defaultTables]) readonly events = signal<CdcStreamEvent[]>([...defaultEvents]) readonly isPaused = signal(false) readonly isSnapshotting = signal(false) readonly snapshotNotice = signal(false) readonly selectedOperation = signal<CdcOperationFilter>('all') readonly searchQuery = signal('') readonly expandedIds = signal<Record<string, boolean>>({ 'evt-cdc-104': true }) readonly copiedEventId = signal<string | null>(null) readonly copiedBinlog = signal(false) readonly exportedNotice = signal(false) readonly filteredEvents = computed(() => { const op = this.selectedOperation() const q = this.searchQuery() return this.events().filter((event) => (op === 'all' || event.operation === op) && matchesSearch(event, q)) }) readonly operationCounts = computed(() => { const events = this.events() return { all: events.length, INSERT: events.filter((e) => e.operation === 'INSERT').length, UPDATE: events.filter((e) => e.operation === 'UPDATE').length, DELETE: events.filter((e) => e.operation === 'DELETE').length, } }) readonly allExpanded = computed(() => { const filtered = this.filteredEvents() const expanded = this.expandedIds() return filtered.length > 0 && filtered.every((e) => expanded[e.id]) }) private readonly timers = new Set<ReturnType<typeof setTimeout>>() constructor() { inject(DestroyRef).onDestroy(() => this.timers.forEach((t) => clearTimeout(t))) } ngOnInit(): void { if (this.initialTables) this.tables.set([...this.initialTables]) if (this.initialEvents) this.events.set([...this.initialEvents]) this.isPaused.set(this.initialPaused) this.selectedOperation.set(this.initialOperationFilter) this.searchQuery.set(this.initialSearch) } protected get rootClass(): string { return cn('w-full space-y-4', this.className) } private later(fn: () => void, ms: number): void { const t = setTimeout(() => { this.timers.delete(t) fn() }, ms) this.timers.add(t) } isExpanded(id: string): boolean { return !!this.expandedIds()[id] } operationCount(op: CdcOperationFilter): number { return this.operationCounts()[op] } togglePause(): void { this.isPaused.update((prev) => !prev) } toggleExpand(id: string): void { this.expandedIds.update((prev) => ({ ...prev, [id]: !prev[id] })) } toggleAllExpand(): void { if (this.allExpanded()) { this.expandedIds.set({}) } else { const next: Record<string, boolean> = {} this.filteredEvents().forEach((e) => { next[e.id] = true }) this.expandedIds.set(next) } } onRowKeydown(e: KeyboardEvent, id: string): void { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() this.toggleExpand(id) } } triggerSnapshot(): void { if (this.isSnapshotting()) return this.isSnapshotting.set(true) this.snapshotNotice.set(true) this.later(() => this.isSnapshotting.set(false), 2200) this.later(() => this.snapshotNotice.set(false), 6000) } clearStream(): void { this.events.set([]) this.expandedIds.set({}) } resetStream(): void { this.events.set(this.initialEvents ? [...this.initialEvents] : [...defaultEvents]) this.searchQuery.set('') this.selectedOperation.set('all') this.expandedIds.set({ 'evt-cdc-104': true }) } copyBinlogPosition(): void { if (typeof navigator !== 'undefined' && navigator.clipboard) { navigator.clipboard.writeText(currentBinlog).catch(() => {}) this.copiedBinlog.set(true) this.later(() => this.copiedBinlog.set(false), 2000) } } copyPayload(id: string, payload: Record<string, unknown>): void { if (typeof navigator !== 'undefined' && navigator.clipboard) { navigator.clipboard.writeText(JSON.stringify(payload, null, 2)).catch(() => {}) this.copiedEventId.set(id) this.later(() => this.copiedEventId.update((current) => (current === id ? null : current)), 2000) } } exportJson(): void { const dataStr = 'data:text/json;charset=utf-8,' + encodeURIComponent( JSON.stringify( { pipeline: 'Postgres Source (OLTP) -> Snowflake DW (OLAP)', binlog_position: currentBinlog, timestamp: new Date().toISOString(), tables: this.tables(), events: this.filteredEvents(), }, null, 2, ), ) if (typeof document !== 'undefined') { const downloadAnchor = document.createElement('a') downloadAnchor.setAttribute('href', dataStr) downloadAnchor.setAttribute( 'download', `cdc-replication-stream-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`, ) document.body.appendChild(downloadAnchor) downloadAnchor.click() downloadAnchor.remove() this.exportedNotice.set(true) this.later(() => this.exportedNotice.set(false), 2000) } } json(value: Record<string, unknown>): string { return JSON.stringify(value, null, 2) } snapshotIconClass(): string { return cn('size-3.5', this.isSnapshotting() && 'text-primary animate-spin') } pillClass(op: CdcOperationFilter): string { const active = this.selectedOperation() === op if (op === 'all') { return cn( pillBase, active ? 'border-primary bg-primary text-primary-foreground shadow-xs' : 'border-border bg-card text-muted-foreground hover:bg-accent hover:text-foreground', ) } if (op === 'INSERT') { return cn( pillBase, active ? 'border-success bg-success text-white shadow-xs' : 'border-border bg-card text-muted-foreground hover:border-success/40 hover:text-success dark:hover:text-success', ) } if (op === 'UPDATE') { return cn( pillBase, active ? 'border-info bg-info text-white shadow-xs' : 'border-border bg-card text-muted-foreground hover:border-info/40 hover:text-info dark:hover:text-info', ) } return cn( pillBase, active ? 'border-destructive bg-destructive text-white shadow-xs' : 'border-border bg-card text-muted-foreground hover:border-destructive/40 hover:text-destructive dark:hover:text-destructive', ) } pillDotClass(op: CdcOperationFilter): string { if (op === 'INSERT') return 'bg-success size-1.5 rounded-full' if (op === 'UPDATE') return 'bg-info size-1.5 rounded-full' return 'bg-destructive size-1.5 rounded-full' } pillCountClass(op: CdcOperationFilter): string { const active = this.selectedOperation() === op if (op === 'all') { return cn( 'py-0.2 rounded-full px-1.5 text-xs', active ? 'bg-primary-foreground/20 text-primary-foreground' : 'bg-muted text-muted-foreground', ) } const idle = op === 'INSERT' ? 'bg-success/10 text-success' : op === 'UPDATE' ? 'bg-info/10 text-info' : 'bg-destructive/10 text-destructive' return cn('py-0.2 rounded-full px-1.5 text-xs', active ? 'bg-white/20 text-white' : idle) } rowClass(id: string): string { return cn('group transition-colors', this.isExpanded(id) ? 'bg-muted/30' : 'hover:bg-muted/20') } chevronClass(id: string): string { return cn( 'text-muted-foreground group-hover:text-foreground mt-0.5 shrink-0 transition-transform sm:mt-0', this.isExpanded(id) && 'text-foreground rotate-90', ) } afterPanelClass(event: CdcStreamEvent): string { return cn( 'overflow-hidden rounded-md border border-zinc-800 bg-zinc-950 font-mono text-xs text-zinc-100 shadow-inner', !event.before && 'lg:col-span-2', ) } }src/app/components/blocks/cdc-replication-stream/index.ts0.2 kB
export { UiCdcReplicationStreamComponent, matchesSearch, type CdcOperation, type CdcOperationFilter, type CdcTableMetric, type CdcStreamEvent, } from './cdc-replication-stream.component'
Raw manifest:https://uipkge.dev/r/angular/cdc-replication-stream.json