Schema Drift Alert Board
blockdevopsAutomated data warehouse schema drift and structural anomaly detector with downstream blast radius impact analysis, interactive multi-tier lineage graph, schema mutation contract diffs, and remediation workflows.
Installation
$pnpm dlx uipkge-ng@latest add schema-drift-alert-board$npx uipkge-ng@latest add schema-drift-alert-board$yarn dlx uipkge-ng@latest add schema-drift-alert-board$bunx uipkge-ng@latest add schema-drift-alert-boardManifest:
https://uipkge.dev/r/angular/schema-drift-alert-board.jsonInstalls to:src/app/components/blocks/schema-drift-alert-board/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.
BlastRadiusNodeinterface BlastRadiusNode {
id: string
name: string
layer: 'origin' | 'transform' | 'mart' | 'consumer'
layerLabel: string
status: 'mutation' | 'impacted' | 'healthy' | 'warning'
statusLabel: string
type: string
description: string
owner: string
runtime?: string
}SchemaDriftIncidentinterface SchemaDriftIncident {
id: string
targetTable: string
environment: 'Snowflake Prod' | 'Postgres OLTP' | 'Kafka Topics'
changeType: 'Unexpected Column Added' | 'Data Type Alteration' | 'Nullability Relaxation' | 'Enum Definition Extended'
driftDiff: string
diffMode: 'added' | 'altered' | 'relaxed'
severity: 'low' | 'medium' | 'high'
severityLabel: string
blastRadiusSummary: string
brokenModelsCount: number
impactedPipelinesCount: number
detectedAt: string
actionLabel: string
isAcknowledged: boolean
isContractSynced: boolean
contractYaml: string
remediationDdl: string
lineageNodes: BlastRadiusNode[]
}Files installed (2)
src/app/components/blocks/schema-drift-alert-board/schema-drift-alert-board.component.ts51.5 kB
// Data warehouse schema drift and anomaly alert board: header with acknowledge-all, // 4 telemetry metric cards, a filterable incidents table, and a blast-radius lineage // tree with contract-YAML / remediation-DDL tabs. Port of the React block 1:1. import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core' import { Activity, AlertCircle, AlertTriangle, ArrowUpRight, BellOff, BellRing, Check, CheckCircle2, Clock, Copy, Database, Eye, FileCode2, Filter, Layers, LucideAngularModule, Network, RefreshCw, Search, ShieldAlert, ShieldCheck, Table2, Terminal, Workflow, } 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 { 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 interface BlastRadiusNode { id: string name: string layer: 'origin' | 'transform' | 'mart' | 'consumer' layerLabel: string status: 'mutation' | 'impacted' | 'healthy' | 'warning' statusLabel: string type: string description: string owner: string runtime?: string } export interface SchemaDriftIncident { id: string targetTable: string environment: 'Snowflake Prod' | 'Postgres OLTP' | 'Kafka Topics' changeType: 'Unexpected Column Added' | 'Data Type Alteration' | 'Nullability Relaxation' | 'Enum Definition Extended' driftDiff: string diffMode: 'added' | 'altered' | 'relaxed' severity: 'low' | 'medium' | 'high' severityLabel: string blastRadiusSummary: string brokenModelsCount: number impactedPipelinesCount: number detectedAt: string actionLabel: string isAcknowledged: boolean isContractSynced: boolean contractYaml: string remediationDdl: string lineageNodes: BlastRadiusNode[] } const defaultIncidents: SchemaDriftIncident[] = [ { id: 'drift-inc-1', targetTable: 'public.users', environment: 'Postgres OLTP', changeType: 'Unexpected Column Added', driftDiff: '+ phone_country_code VARCHAR(8)', diffMode: 'added', severity: 'low', severityLabel: 'Low Non-Breaking', blastRadiusSummary: '0 models broken', brokenModelsCount: 0, impactedPipelinesCount: 0, detectedAt: '14m ago', actionLabel: 'Add to Data Contract', isAcknowledged: false, isContractSynced: false, contractYaml: `version: 2 models: - name: dim_users config: contract: enforced: true columns: - name: user_id data_type: varchar constraints: - type: not_null # Newly detected column added upstream in Postgres OLTP - name: phone_country_code data_type: varchar(8) description: "ISO-3166-1 alpha-2 / E.164 dial prefix added by auth service"`, remediationDdl: `-- Automated dbt / Snowflake staging sync ALTER TABLE raw_postgres.users ADD COLUMN IF NOT EXISTS phone_country_code VARCHAR(8);`, lineageNodes: [ { id: 'node-users-origin', name: 'public.users', layer: 'origin', layerLabel: 'Postgres Source Table', status: 'mutation', statusLabel: 'Column Added (+1 col)', type: 'Postgres Table', description: 'OLTP user management table with new phone_country_code attribute.', owner: '#team-auth', }, { id: 'node-users-ingest', name: 'fivetran.raw_postgres_users', layer: 'transform', layerLabel: 'CDC Connector Stream', status: 'healthy', statusLabel: 'CDC Auto-Synced', type: 'Fivetran Log-Based CDC', description: 'Automated column replication synced to Snowflake bronze raw zone.', owner: '#data-platform', runtime: '<2m latency', }, { id: 'node-users-stg', name: 'dbt.stg_users', layer: 'transform', layerLabel: 'dbt Staging View', status: 'healthy', statusLabel: '0 Breaking (Explicit Select)', type: 'dbt View Model', description: 'Explicit column projection prevents unmapped column propagation.', owner: '#data-eng', runtime: '12s run', }, { id: 'node-users-dim', name: 'marts.dim_customers', layer: 'mart', layerLabel: 'Core Dimension Mart', status: 'healthy', statusLabel: 'Contract Compliant', type: 'Snowflake Dynamic Table', description: 'Certified dimension table feeding marketing cohorts and customer 360.', owner: '#data-eng', }, { id: 'node-users-crm', name: 'HubSpot Reverse ETL Sync', layer: 'consumer', layerLabel: 'Operational Reverse ETL', status: 'healthy', statusLabel: 'Active · 0 Failures', type: 'Census Sync Pipeline', description: 'Outbound sync to HubSpot Marketing Hub CRM contacts.', owner: '#rev-ops', }, ], }, { id: 'drift-inc-2', targetTable: 'analytics.fct_orders', environment: 'Snowflake Prod', changeType: 'Data Type Alteration', driftDiff: 'discount_amount FLOAT -> DECIMAL(18,2)', diffMode: 'altered', severity: 'medium', severityLabel: 'Medium Warning', blastRadiusSummary: '2 dbt models affected', brokenModelsCount: 0, impactedPipelinesCount: 2, detectedAt: '32m ago', actionLabel: 'Inspect Model Lineage', isAcknowledged: false, isContractSynced: false, contractYaml: `version: 2 models: - name: fct_orders config: contract: enforced: true columns: - name: order_id data_type: varchar - name: gross_amount data_type: decimal(18,2) # Altered type: FLOAT replaced with exact high-precision DECIMAL(18,2) - name: discount_amount data_type: decimal(18,2) # Previously float64`, remediationDdl: `-- Model Migration Patch for dbt Intermediate Layers: ALTER TABLE production_dw.analytics.fct_orders ALTER COLUMN discount_amount SET DATA TYPE DECIMAL(18,2);`, lineageNodes: [ { id: 'node-orders-origin', name: 'analytics.fct_orders', layer: 'origin', layerLabel: 'Warehouse Fact Table', status: 'mutation', statusLabel: 'Type Alteration (FLOAT -> DECIMAL)', type: 'Snowflake Incremental', description: 'Core financial transactions mart table receiving monetary precision fix.', owner: '#finance-data', }, { id: 'node-orders-int-rev', name: 'dbt.int_daily_revenue', layer: 'transform', layerLabel: 'dbt Intermediate Model', status: 'impacted', statusLabel: 'Impacted (Sum Float Warning)', type: 'dbt Incremental Model', description: 'Aggregates daily gross/net sales; needs explicit decimal cast in SUM expression.', owner: '#data-eng', runtime: '1m 18s', }, { id: 'node-orders-int-disc', name: 'dbt.int_order_discounts', layer: 'transform', layerLabel: 'dbt Intermediate Model', status: 'impacted', statusLabel: 'Impacted (Join Key Precision)', type: 'dbt View Model', description: 'Promotional discount matrix model joined on discount_amount thresholds.', owner: '#data-eng', runtime: '45s', }, { id: 'node-orders-mart', name: 'marts.fct_monthly_financials', layer: 'mart', layerLabel: 'Gold Financial Mart', status: 'healthy', statusLabel: 'Sanitized Output (Safe)', type: 'Snowflake Mart Table', description: 'GAAP revenue mart used for board reporting and audit ledger.', owner: '#finance-ops', }, { id: 'node-orders-looker', name: 'Looker ARR & Margin Dashboard', layer: 'consumer', layerLabel: 'Executive BI Dashboard', status: 'warning', statusLabel: '1 Tile Check Advised', type: 'Looker Explore Tile', description: 'C-Suite executive dashboard tracking daily net margin & discount rates.', owner: '#bi-analytics', }, { id: 'node-orders-netsuite', name: 'Finance NetSuite Journal Sync', layer: 'consumer', layerLabel: 'Accounting ERP Pipeline', status: 'healthy', statusLabel: 'Decimal Compatible (Safe)', type: 'Census Batch Sync', description: 'Hourly journal entry creation into Oracle NetSuite ERP general ledger.', owner: '#accounting', }, ], }, { id: 'drift-inc-3', targetTable: 'kafka.events.checkout', environment: 'Kafka Topics', changeType: 'Nullability Relaxation', driftDiff: 'zip_code became nullable', diffMode: 'relaxed', severity: 'low', severityLabel: 'Low', blastRadiusSummary: '0 broken', brokenModelsCount: 0, impactedPipelinesCount: 0, detectedAt: '2h ago', actionLabel: 'Add to Data Contract', isAcknowledged: false, isContractSynced: false, contractYaml: `version: 2 sources: - name: kafka_stream tables: - name: events_checkout columns: - name: session_id data_type: varchar # Nullability relaxed for international orders lacking postal codes - name: zip_code data_type: varchar(16)`, remediationDdl: `-- Event Stream Deserialization Schema Update (Avro/JSON Schema Registry): { "name": "zip_code", "type": ["null", "string"], "default": null }`, lineageNodes: [ { id: 'node-kafka-origin', name: 'kafka.events.checkout', layer: 'origin', layerLabel: 'Event Stream Topic', status: 'mutation', statusLabel: 'Nullability Relaxed', type: 'Kafka Partition Stream', description: 'Real-time e-commerce checkout session payload stream.', owner: '#checkout-squad', }, { id: 'node-kafka-flink', name: 'flink.checkout_stream_enrichment', layer: 'transform', layerLabel: 'Flink Stream Processing', status: 'healthy', statusLabel: 'COALESCE Handler Active', type: 'Apache Flink Job', description: 'Streaming enrichment with geo-IP fallback when zip_code is null.', owner: '#stream-eng', runtime: '<500ms streaming', }, { id: 'node-kafka-mart', name: 'analytics.fct_checkout_events', layer: 'mart', layerLabel: 'Realtime Bronze Table', status: 'healthy', statusLabel: 'Schema Compatible', type: 'Snowflake Raw Event Table', description: 'Append-only event log capturing checkout telemetry.', owner: '#analytics-eng', }, { id: 'node-kafka-bi', name: 'Realtime Checkout Conversion Board', layer: 'consumer', layerLabel: 'Operations Monitor', status: 'healthy', statusLabel: 'Active · 0 Failures', type: 'Grafana Live Dashboard', description: 'Ops NOC wallboard monitoring conversion rates per minute.', owner: '#growth-team', }, ], }, ] const ENVIRONMENTS = ['all', 'Snowflake Prod', 'Postgres OLTP', 'Kafka Topics'] @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-schema-drift-alert-board, [ui-schema-drift-alert-board]', standalone: true, // React renders the root <div> itself: the host stays out of layout and `class` goes to the root. host: { '[attr.class]': '"contents"' }, imports: [ LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardHeaderComponent, UiCardTitleComponent, UiProgressComponent, UiSeparatorComponent, UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, ], template: ` <div data-slot="schema-drift-alert-board" [class]="rootClass"> <!-- Header Section --> <div ui-card class="border-border bg-card shadow-xs"> <div ui-card-content class="p-5 sm:p-6"> <div class="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between"> <div class="space-y-2"> <div class="flex flex-wrap items-center gap-2"> <div class="border-warning/30 bg-warning/10 text-warning flex size-9 shrink-0 items-center justify-center rounded-lg border shadow-xs" > <lucide-icon [img]="ShieldAlert" class="size-4.5" /> </div> <div> <div class="flex flex-wrap items-center gap-2.5"> <h1 class="text-foreground font-mono text-lg font-bold tracking-tight break-all sm:text-xl"> {{ title() }} </h1> <span ui-badge [variant]="allAcknowledged() ? 'success' : 'warning'" class="gap-1.5 px-2.5 py-0.5 font-mono text-xs font-semibold" > @if (!allAcknowledged()) { <span class="relative flex size-2"> <span class="bg-warning absolute inline-flex size-full rounded-full opacity-75"></span> <span class="bg-warning relative inline-flex size-2 rounded-full"></span> </span> } @else { <lucide-icon [img]="CheckCircle2" class="text-success size-3" /> } <span>{{ allAcknowledged() ? 'All Contracts Synced' : activeMutationsSummary() }}</span> </span> </div> <div class="text-muted-foreground flex flex-wrap items-center gap-2 pt-1 text-xs"> <span class="text-muted-foreground font-medium">Monitored Environments:</span> <div class="flex flex-wrap items-center gap-1.5"> @for (env of monitoredEnvironments(); track env) { <span class="border-border bg-muted/60 text-foreground inline-flex items-center gap-1 rounded-md border px-2 py-0.5 font-mono text-xs" > @if (env.includes('Snowflake')) { <lucide-icon [img]="Database" class="text-info size-3" /> } @else if (env.includes('Postgres')) { <lucide-icon [img]="Layers" class="text-chart-1 size-3" /> } @else { <lucide-icon [img]="Activity" class="text-warning size-3" /> } {{ env }} </span> } </div> </div> </div> </div> </div> <div class="flex flex-wrap items-center gap-2.5"> <button ui-button variant="outline" size="sm" [class]="muteClass" (click)="handleToggleMute()"> @if (isMutedNonBreaking()) { <lucide-icon [img]="BellOff" class="size-3.5" /> } @else { <lucide-icon [img]="BellRing" class="size-3.5" /> } <span>{{ isMutedNonBreaking() ? 'Muted (Non-Breaking Hidden)' : 'Mute Non-Breaking Alerts' }}</span> </button> <button ui-button variant="default" size="sm" class="h-9 gap-1.5 text-xs font-semibold shadow-xs" [disabled]="isSyncingAll()" (click)="handleAcknowledgeAndSync()" > <lucide-icon [img]="RefreshCw" [class]="syncIconClass" /> @if (allAcknowledged()) { <lucide-icon [img]="Check" class="text-success size-3.5" /> } <span>{{ syncLabel() }}</span> </button> </div> </div> </div> <div class="border-border/80 bg-muted/30 text-muted-foreground flex flex-col gap-2 border-t px-5 py-2.5 text-xs sm:flex-row sm:items-center sm:justify-between" > <div class="flex items-center gap-2 font-mono"> <span class="bg-success size-2 rounded-full"></span> <span>Realtime Schema Contract Gate: Active (CDC Triggers & dbt Artifact Audits)</span> </div> <div class="flex items-center gap-3"> <span >Detection Engine: <strong class="text-foreground font-mono font-medium">UIPKGE Drift Sentinel v2.4</strong></span > <ui-separator orientation="vertical" class="hidden h-3 sm:block" /> <span class="font-mono">Checked 14s ago</span> </div> </div> </div> <!-- 4 Drift Telemetry Metric Cards --> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div ui-card class="border-border bg-card hover:border-border shadow-xs transition-colors"> <div ui-card-header class="p-4 pb-2"> <div class="flex flex-wrap items-start justify-between gap-2"> <p class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Active Drift Incidents</p> <div class="border-warning/20 bg-warning/10 text-warning flex size-7 items-center justify-center rounded-md border" > <lucide-icon [img]="AlertTriangle" class="size-4" /> </div> </div> <div class="flex items-baseline gap-2 pt-1"> <span class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums" >2 Anomalies Active</span > </div> </div> <div ui-card-content class="space-y-2 p-4 pt-1"> <div class="bg-muted/40 flex items-center justify-between rounded-md px-2 py-1 text-xs"> <span class="text-muted-foreground">Severity Split</span> <span class="text-foreground font-mono font-medium">1 Warning · 1 Low</span> </div> <p class="text-muted-foreground text-xs">2 Active Schema Mutations Detected</p> </div> </div> <div ui-card class="border-border bg-card hover:border-border shadow-xs transition-colors"> <div ui-card-header class="p-4 pb-2"> <div class="flex flex-wrap items-start justify-between gap-2"> <p class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Broken Downstream Models</p> <div class="border-success/20 bg-success/10 text-success flex size-7 items-center justify-center rounded-md border" > <lucide-icon [img]="ShieldCheck" class="size-4" /> </div> </div> <div class="flex items-baseline gap-2 pt-1"> <span class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums" >0 Broken Models</span > </div> </div> <div ui-card-content class="space-y-2 p-4 pt-1"> <div class="bg-muted/40 flex items-center justify-between rounded-md px-2 py-1 text-xs"> <span class="text-muted-foreground">Impacted Pipelines</span> <span class="text-warning font-mono font-medium">2 Impacted Pipelines</span> </div> <p class="text-muted-foreground text-xs">Zero outages · 100% DAG pipeline uptime maintained</p> </div> </div> <div ui-card class="border-border bg-card hover:border-border shadow-xs transition-colors"> <div ui-card-header class="p-4 pb-2"> <div class="flex flex-wrap items-start justify-between gap-2"> <p class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Schema Audits in 24h</p> <div class="border-border bg-muted text-foreground flex size-7 items-center justify-center rounded-md border" > <lucide-icon [img]="Activity" class="text-info size-4" /> </div> </div> <div class="flex items-baseline gap-2 pt-1"> <span class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums">1,840</span> <span ui-badge variant="outline" class="font-mono text-xs">100% evaluated</span> </div> </div> <div ui-card-content class="space-y-2 p-4 pt-1"> <ui-progress [value]="100" class="h-1.5 w-full" /> <p class="text-muted-foreground text-xs">Continuous CDC & dbt schema contract verification</p> </div> </div> <div ui-card class="border-border bg-card hover:border-border shadow-xs transition-colors"> <div ui-card-header class="p-4 pb-2"> <div class="flex flex-wrap items-start justify-between gap-2"> <p class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Mean Time to Acknowledge</p> <div class="border-chart-2/20 bg-chart-2/10 text-chart-2 flex size-7 items-center justify-center rounded-md border" > <lucide-icon [img]="Clock" class="size-4" /> </div> </div> <div class="flex items-baseline gap-2 pt-1"> <span class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums">12m</span> <span ui-badge variant="success" class="text-xs font-medium">-4.2m vs 30d</span> </div> </div> <div ui-card-content class="space-y-2 p-4 pt-1"> <div class="bg-muted/40 flex items-center justify-between rounded-md px-2 py-1 text-xs"> <span class="text-muted-foreground">SLA Compliance</span> <span class="text-success font-mono font-medium">98.4% within SLA</span> </div> <p class="text-muted-foreground text-xs">Target < 30m response for Tier-1 Gold Marts</p> </div> </div> </div> <!-- Schema Drift Incidents Table Card --> <div ui-card class="border-border bg-card shadow-xs"> <div ui-card-header class="flex flex-col gap-4 pb-3 sm:flex-row sm:items-center sm:justify-between"> <div> <div class="flex items-center gap-2"> <h3 ui-card-title class="text-base font-semibold">Schema Drift & Mutation Incidents</h3> <span ui-badge variant="secondary" class="font-mono text-xs"> {{ filteredIncidents().length }} of {{ incidentsList().length }} Tracked </span> </div> <p ui-card-description class="text-xs"> Detected structural schema variances, missing or unmapped columns, type widening, and nullability relaxations. </p> </div> <div class="flex flex-wrap items-center gap-1.5"> <button ui-button variant="outline" size="xs" [class]="sevPillClass('all')" (click)="selectedSeverity.set('all')" > All Severities ({{ incidentsList().length }}) </button> <button ui-button variant="outline" size="xs" [class]="sevPillClass('medium')" (click)="selectedSeverity.set('medium')" > Medium Warning (1) </button> <button ui-button variant="outline" size="xs" [class]="sevPillClass('low')" (click)="selectedSeverity.set('low')" > Low / Non-Breaking (2) </button> </div> </div> <div class="border-border/60 border-t p-4 pt-3 pb-3"> <div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <div class="relative flex-1"> <lucide-icon [img]="Search" class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-3.5 -translate-y-1/2" /> <input [value]="searchQuery()" (input)="searchQuery.set($any($event.target).value)" type="text" placeholder="Search table, column, environment, or mutation diff..." class="border-border bg-background placeholder:text-muted-foreground focus-visible:ring-ring h-8.5 w-full rounded-md border px-3 pl-8 text-xs focus-visible:ring-2 focus-visible:outline-none" /> </div> <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" /> <span>Environment:</span> </span> @for (env of envOptions; track env) { <button ui-button variant="outline" size="xs" [class]="envPillClass(env)" (click)="selectedEnvironment.set(env)" > {{ env === 'all' ? 'All Environments' : env }} </button> } </div> </div> </div> <div ui-card-content class="p-0"> <div class="border-border/60 overflow-x-auto border-t"> <table ui-table> <thead ui-table-header class="bg-muted/40"> <tr ui-table-row> <th ui-table-head class="min-w-[200px] text-xs font-semibold">Target Entity & Env</th> <th ui-table-head class="min-w-[170px] text-xs font-semibold">Mutation Type</th> <th ui-table-head class="min-w-[260px] text-xs font-semibold">Schema Drift Diff</th> <th ui-table-head class="min-w-[130px] text-xs font-semibold">Severity</th> <th ui-table-head class="min-w-[140px] text-xs font-semibold">Blast Radius</th> <th ui-table-head class="min-w-[90px] text-xs font-semibold">Detected</th> <th ui-table-head class="min-w-[160px] text-right text-xs font-semibold">Action</th> </tr> </thead> <tbody ui-table-body> @for (incident of filteredIncidents(); track incident.id) { <tr ui-table-row [class]="rowClass(incident.id)" (click)="selectedIncidentId.set(incident.id)"> <td ui-table-cell class="py-3"> <div class="space-y-1"> <div class="text-foreground flex items-center gap-1.5 font-mono text-xs font-bold"> @if (incident.environment.includes('Snowflake')) { <lucide-icon [img]="Database" class="text-info size-3.5 shrink-0" /> } @else if (incident.environment.includes('Postgres')) { <lucide-icon [img]="Layers" class="text-chart-1 size-3.5 shrink-0" /> } @else { <lucide-icon [img]="Activity" class="text-warning size-3.5 shrink-0" /> } <span>{{ incident.targetTable }}</span> </div> <span ui-badge variant="outline" class="border-border/80 text-muted-foreground font-mono text-xs font-normal" > {{ incident.environment }} </span> </div> </td> <td ui-table-cell class="py-3"> <span class="text-foreground text-xs font-medium">{{ incident.changeType }}</span> </td> <td ui-table-cell class="py-3"> <div [class]="diffClass(incident.diffMode)"> <code>{{ incident.driftDiff }}</code> </div> </td> <td ui-table-cell class="py-3"> <span ui-badge [variant]="incident.severity === 'medium' ? 'warning' : 'secondary'" class="gap-1 text-xs font-medium" > @if (incident.severity === 'medium') { <lucide-icon [img]="AlertTriangle" class="text-warning size-3" /> } @else { <lucide-icon [img]="AlertCircle" class="text-info size-3" /> } <span>{{ incident.severityLabel }}</span> </span> </td> <td ui-table-cell class="py-3"> <div class="space-y-0.5"> <span [class]="blastClass(incident.impactedPipelinesCount)"> {{ incident.blastRadiusSummary }} </span> <span class="text-muted-foreground text-xs"> {{ incident.brokenModelsCount }} broken · {{ incident.impactedPipelinesCount }} impacted </span> </div> </td> <td ui-table-cell class="text-muted-foreground py-3 font-mono text-xs"> {{ incident.detectedAt }} </td> <td ui-table-cell class="py-3 text-right"> <button ui-button variant="outline" size="xs" [class]="actionClass(incident.isContractSynced)" (click)="handleRowAction(incident, $event)" > @if (incident.isContractSynced) { <lucide-icon [img]="Check" class="size-3" /> } @else if (incident.changeType === 'Data Type Alteration') { <lucide-icon [img]="Eye" class="text-primary size-3" /> } @else { <lucide-icon [img]="Workflow" class="size-3" /> } <span>{{ incident.isContractSynced ? 'Contract Synced' : incident.actionLabel }}</span> </button> </td> </tr> } @if (filteredIncidents().length === 0) { <tr ui-table-row> <td ui-table-cell [attr.colspan]="7" class="py-8 text-center"> <div class="flex flex-col items-center justify-center gap-1.5"> <lucide-icon [img]="CheckCircle2" class="text-success size-7" /> <p class="text-foreground text-sm font-medium"> No schema drift incidents match active filters </p> <p class="text-muted-foreground text-xs"> All active monitored entities adhere strictly to baseline contracts. </p> </div> </td> </tr> } </tbody> </table> </div> <div class="border-border/60 bg-muted/20 text-muted-foreground flex flex-col gap-2 border-t px-4 py-2.5 text-xs sm:flex-row sm:items-center sm:justify-between" > <div class="flex items-center gap-2"> <span class="bg-success size-2 rounded-full"></span> <span>Click any incident row to trace downstream blast radius lineage and contract YAML.</span> </div> <span class="font-mono text-xs"> Active Incident Focus: <strong class="text-foreground font-bold">{{ selectedIncident().targetTable }}</strong> </span> </div> </div> </div> <!-- Blast Radius Lineage Tree Card --> <div ui-card class="border-border bg-card shadow-xs"> <div ui-card-header class="border-border/80 border-b p-4 sm:p-5"> <div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <div class="space-y-1"> <div class="flex flex-wrap items-center gap-2"> <lucide-icon [img]="Workflow" class="text-primary size-5" /> <h3 ui-card-title class="text-base font-semibold">Downstream Blast Radius & Lineage Impact Tree</h3> <span ui-badge variant="outline" class="font-mono text-xs font-semibold"> {{ selectedIncident().targetTable }} </span> <span ui-badge [variant]="selectedIncident().severity === 'medium' ? 'warning' : 'secondary'" class="font-mono text-xs" > {{ selectedIncident().changeType }} </span> </div> <p ui-card-description class="text-xs"> Automated topological DAG trace evaluating impacted dbt models, Snowflake views, Looker semantic tiles, and operational syncs. </p> </div> <div class="flex flex-wrap items-center gap-1.5"> <button ui-button variant="outline" size="sm" [class]="tabClass('lineage')" (click)="activeTab.set('lineage')" > <lucide-icon [img]="Network" class="size-3.5" /> <span>Lineage Tree</span> </button> <button ui-button variant="outline" size="sm" [class]="tabClass('yaml')" (click)="activeTab.set('yaml')"> <lucide-icon [img]="FileCode2" class="size-3.5" /> <span>Contract YAML Diff</span> </button> <button ui-button variant="outline" size="sm" [class]="tabClass('sql')" (click)="activeTab.set('sql')"> <lucide-icon [img]="Terminal" class="size-3.5" /> <span>Remediation DDL</span> </button> </div> </div> </div> <div ui-card-content class="p-4 sm:p-5"> @if (activeTab() === 'lineage') { <div class="space-y-4"> <div class="grid grid-cols-2 gap-2.5 sm:grid-cols-4"> <div class="border-border/80 bg-muted/30 space-y-1 rounded-lg border p-3 text-xs"> <span class="text-muted-foreground block">Mutation Origin</span> <span class="text-foreground block truncate font-mono font-bold">{{ selectedIncident().targetTable }}</span> <span class="text-muted-foreground text-xs">{{ selectedIncident().environment }}</span> </div> <div class="border-border/80 bg-muted/30 space-y-1 rounded-lg border p-3 text-xs"> <span class="text-muted-foreground block">Total Nodes in DAG</span> <span class="text-foreground block font-mono text-base font-bold" >{{ selectedIncident().lineageNodes.length }} Tiers</span > <span class="text-muted-foreground text-xs">Topologically ordered</span> </div> <div class="border-border/80 bg-muted/30 space-y-1 rounded-lg border p-3 text-xs"> <span class="text-muted-foreground block">Impacted Pipelines</span> <span [class]="impactedClass(selectedIncident().impactedPipelinesCount)"> {{ selectedIncident().impactedPipelinesCount }} Models Impacted </span> <span class="text-muted-foreground text-xs">Requires cast adjustment</span> </div> <div class="border-border/80 bg-muted/30 space-y-1 rounded-lg border p-3 text-xs"> <span class="text-muted-foreground block">Broken Models</span> <span class="text-success block font-mono text-base font-bold">0 Outages</span> <span class="text-muted-foreground text-xs">Zero pipeline failures</span> </div> </div> <div class="border-border bg-muted/10 space-y-3 rounded-xl border p-4"> <div class="border-border/60 flex items-center justify-between border-b pb-2 text-xs"> <span class="text-muted-foreground font-semibold tracking-wider uppercase"> Downstream Dependency DAG Flow (Left to Right) </span> <span class="text-muted-foreground font-mono text-xs"> Active Drift: <code class="text-foreground font-bold">{{ selectedIncident().driftDiff }}</code> </span> </div> <div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3"> @for (node of selectedIncident().lineageNodes; track node.id; let i = $index) { <div [class]="nodeCardClass(node.status)"> <div class="flex flex-wrap items-start justify-between gap-2"> <div class="flex min-w-0 items-center gap-2"> <div [class]="nodeIconClass(node.status)"> @if (node.layer === 'origin') { <lucide-icon [img]="Database" class="size-3.5" /> } @else if (node.layer === 'transform') { <lucide-icon [img]="Layers" class="size-3.5" /> } @else if (node.layer === 'mart') { <lucide-icon [img]="Table2" class="size-3.5" /> } @else { <lucide-icon [img]="ArrowUpRight" class="size-3.5" /> } </div> <div class="min-w-0"> <div class="flex items-center gap-1.5"> <span class="text-muted-foreground font-mono text-xs">#{{ i + 1 }}</span> <span class="text-foreground block min-w-0 truncate font-mono font-bold">{{ node.name }}</span> </div> <span class="text-muted-foreground block truncate text-xs">{{ node.layerLabel }}</span> </div> </div> <span ui-badge [variant]="nodeBadgeVariant(node.status)" class="shrink-0 font-mono text-xs font-medium" > @if (node.status === 'healthy') { <lucide-icon [img]="CheckCircle2" class="text-success size-3" /> } @else { <lucide-icon [img]="AlertTriangle" class="size-3" /> } <span>{{ node.statusLabel }}</span> </span> </div> <p class="text-muted-foreground text-xs leading-relaxed">{{ node.description }}</p> <div class="border-border/60 text-muted-foreground flex items-center justify-between border-t pt-2 font-mono text-xs" > <span>{{ node.type }}</span> <span>{{ node.owner }}</span> </div> </div> } </div> </div> </div> } @else if (activeTab() === 'yaml') { <div class="space-y-3"> <div class="flex items-center justify-between"> <div class="flex items-center gap-2"> <lucide-icon [img]="FileCode2" class="text-primary size-4" /> <span class="text-foreground font-mono text-xs font-semibold"> models/contracts/{{ selectedIncident().targetTable.replace('.', '_') }}_contract.yml </span> <span ui-badge variant="secondary" class="font-mono text-xs">dbt v1.8 Contract Definition</span> </div> <button ui-button variant="ghost" size="xs" class="h-7 gap-1 text-xs" (click)="copyCode('yaml', selectedIncident().contractYaml)" > @if (copiedKey() === 'yaml') { <lucide-icon [img]="Check" class="text-success size-3" /> } @else { <lucide-icon [img]="Copy" class="size-3" /> } <span>{{ copiedKey() === 'yaml' ? 'Copied YAML!' : 'Copy YAML Contract' }}</span> </button> </div> <div class="border-border overflow-x-auto rounded-lg border bg-neutral-950 p-4 font-mono text-xs leading-relaxed text-neutral-100 dark:bg-neutral-950" > <pre class="whitespace-pre"><code>{{ selectedIncident().contractYaml }}</code></pre> </div> </div> } @else { <div class="space-y-3"> <div class="flex items-center justify-between"> <div class="flex items-center gap-2"> <lucide-icon [img]="Terminal" class="text-primary size-4" /> <span class="text-foreground font-mono text-xs font-semibold"> remediation_migration_{{ selectedIncident().targetTable.replace('.', '_') }}.sql </span> <span ui-badge variant="outline" class="font-mono text-xs">Warehouse DDL Patch</span> </div> <button ui-button variant="ghost" size="xs" class="h-7 gap-1 text-xs" (click)="copyCode('sql', selectedIncident().remediationDdl)" > @if (copiedKey() === 'sql') { <lucide-icon [img]="Check" class="text-success size-3" /> } @else { <lucide-icon [img]="Copy" class="size-3" /> } <span>{{ copiedKey() === 'sql' ? 'Copied SQL!' : 'Copy Migration SQL' }}</span> </button> </div> <div class="border-border overflow-x-auto rounded-lg border bg-neutral-950 p-4 font-mono text-xs leading-relaxed text-neutral-100 dark:bg-neutral-950" > <pre class="whitespace-pre"><code>{{ selectedIncident().remediationDdl }}</code></pre> </div> </div> } </div> </div> </div> `, }) export class UiSchemaDriftAlertBoardComponent { protected readonly Activity = Activity protected readonly AlertCircle = AlertCircle protected readonly AlertTriangle = AlertTriangle protected readonly ArrowUpRight = ArrowUpRight protected readonly BellOff = BellOff protected readonly BellRing = BellRing protected readonly Check = Check protected readonly CheckCircle2 = CheckCircle2 protected readonly Clock = Clock protected readonly Copy = Copy protected readonly Database = Database protected readonly Eye = Eye protected readonly FileCode2 = FileCode2 protected readonly Filter = Filter protected readonly Layers = Layers protected readonly Network = Network protected readonly RefreshCw = RefreshCw protected readonly Search = Search protected readonly ShieldAlert = ShieldAlert protected readonly ShieldCheck = ShieldCheck protected readonly Table2 = Table2 protected readonly Terminal = Terminal protected readonly Workflow = Workflow protected readonly envOptions = ENVIRONMENTS @Input('title') set titleInput(v: string | undefined) { this.title.set(v ?? 'Data Warehouse Schema Drift & Anomaly Alerts') } @Input('monitoredEnvironments') set monitoredEnvironmentsInput(v: string[] | undefined) { this.monitoredEnvironments.set(v ?? ['Snowflake Prod', 'Postgres OLTP', 'Kafka Topics']) } @Input() set incidents(v: SchemaDriftIncident[] | undefined) { this.incidentsList.set( (v ?? defaultIncidents).map((i) => ({ ...i, lineageNodes: i.lineageNodes.map((n) => ({ ...n })) })), ) } @Input() set initialSelectedIncident(v: string | undefined) { if (v) this.selectedIncidentId.set(v) } @Input('class') className?: string readonly title = signal('Data Warehouse Schema Drift & Anomaly Alerts') readonly monitoredEnvironments = signal<string[]>(['Snowflake Prod', 'Postgres OLTP', 'Kafka Topics']) readonly incidentsList = signal<SchemaDriftIncident[]>( defaultIncidents.map((i) => ({ ...i, lineageNodes: i.lineageNodes.map((n) => ({ ...n })) })), ) readonly selectedIncidentId = signal('drift-inc-2') readonly searchQuery = signal('') readonly selectedEnvironment = signal('all') readonly selectedSeverity = signal('all') readonly isMutedNonBreaking = signal(false) readonly isSyncingAll = signal(false) readonly allAcknowledged = signal(false) readonly copiedKey = signal<string | null>(null) readonly activeTab = signal<'lineage' | 'yaml' | 'sql'>('lineage') readonly selectedIncident = computed(() => { const list = this.incidentsList() return list.find((inc) => inc.id === this.selectedIncidentId()) ?? list[0]! }) readonly filteredIncidents = computed(() => { const query = this.searchQuery().trim().toLowerCase() const env = this.selectedEnvironment() const sev = this.selectedSeverity() const muted = this.isMutedNonBreaking() return this.incidentsList().filter((item) => { if (muted && item.severity === 'low') return false if (env !== 'all' && item.environment !== env) return false if (sev !== 'all' && item.severity !== sev) return false if ( query && !item.targetTable.toLowerCase().includes(query) && !item.changeType.toLowerCase().includes(query) && !item.driftDiff.toLowerCase().includes(query) && !item.environment.toLowerCase().includes(query) ) return false return true }) }) readonly activeMutationsSummary = computed(() => { const unacknowledged = this.incidentsList().filter((i) => !i.isAcknowledged) return `${unacknowledged.length} Active Schema Mutations Detected` }) get rootClass(): string { return cn('text-foreground w-full space-y-6', this.className) } get muteClass(): string { return cn( 'h-9 gap-1.5 text-xs shadow-xs', this.isMutedNonBreaking() && 'border-warning/40 bg-warning/10 text-warning font-medium', ) } get syncIconClass(): string { return cn('size-3.5', this.isSyncingAll() && 'animate-spin') } syncLabel(): string { if (this.isSyncingAll()) return 'Syncing Schema Contracts...' if (this.allAcknowledged()) return 'Contracts Synced & Locked' return 'Acknowledge & Sync Contracts' } sevPillClass(sev: string): string { if (this.selectedSeverity() !== sev) return 'h-7 rounded-full text-xs' if (sev === 'medium') return cn('h-7 rounded-full text-xs', 'border-warning bg-warning font-medium text-white') if (sev === 'low') return cn('h-7 rounded-full text-xs', 'border-info bg-info font-medium text-white') return cn('h-7 rounded-full text-xs', 'border-primary bg-primary text-primary-foreground font-medium') } envPillClass(env: string): string { return cn( 'h-7 font-mono text-xs', this.selectedEnvironment() === env && 'border-foreground bg-foreground text-background font-semibold', ) } rowClass(id: string): string { return cn( 'hover:bg-muted/30 cursor-pointer transition-colors', this.selectedIncidentId() === id && 'bg-primary/5 ring-primary/20 ring-1', ) } diffClass(mode: SchemaDriftIncident['diffMode']): string { return cn( 'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 font-mono text-xs font-medium', mode === 'added' && 'border-success/30 bg-success/10 text-success', mode === 'altered' && 'border-warning/30 bg-warning/10 text-warning', mode === 'relaxed' && 'border-info/30 bg-info/10 text-info', ) } blastClass(impacted: number): string { return cn('block font-mono text-xs font-medium', impacted > 0 ? 'text-warning font-semibold' : 'text-success') } impactedClass(impacted: number): string { return cn('block font-mono text-base font-bold', impacted > 0 ? 'text-warning' : 'text-success') } actionClass(synced: boolean): string { return cn('h-7 gap-1 text-xs font-medium shadow-xs', synced && 'border-success/40 bg-success/10 text-success') } tabClass(tab: 'lineage' | 'yaml' | 'sql'): string { return cn( 'h-8 gap-1.5 text-xs', this.activeTab() === tab && 'border-primary bg-primary/10 text-primary font-semibold', ) } nodeCardClass(status: BlastRadiusNode['status']): string { return cn( 'space-y-2 rounded-lg border p-3 text-xs shadow-xs transition-colors', status === 'mutation' && 'border-warning/40 bg-warning/5', status === 'impacted' && 'border-warning/50 bg-warning/10 ring-warning/30 ring-1', status === 'warning' && 'border-yellow-500/40 bg-yellow-500/5', status === 'healthy' && 'border-border bg-card', ) } nodeIconClass(status: BlastRadiusNode['status']): string { return cn( 'flex size-7 shrink-0 items-center justify-center rounded-md border text-xs', status === 'mutation' && 'border-warning/30 bg-warning/20 text-warning', status === 'impacted' && 'border-warning/40 bg-warning/20 text-warning', status === 'warning' && 'border-yellow-500/30 bg-yellow-500/20 text-yellow-600 dark:text-yellow-400', status === 'healthy' && 'border-border bg-muted text-foreground', ) } nodeBadgeVariant(status: BlastRadiusNode['status']): 'outline' | 'secondary' | 'warning' { if (status === 'healthy') return 'outline' if (status === 'warning') return 'secondary' return 'warning' } handleAcknowledgeAndSync(): void { if (this.isSyncingAll()) return this.isSyncingAll.set(true) setTimeout(() => { this.incidentsList.update((prev) => prev.map((item) => ({ ...item, isAcknowledged: true, isContractSynced: true })), ) this.isSyncingAll.set(false) this.allAcknowledged.set(true) }, 900) } handleToggleMute(): void { this.isMutedNonBreaking.update((v) => !v) } handleRowAction(incident: SchemaDriftIncident, event: MouseEvent): void { event.stopPropagation() this.selectedIncidentId.set(incident.id) if (incident.changeType === 'Data Type Alteration') { this.activeTab.set('lineage') } else { this.incidentsList.update((prev) => prev.map((item) => item.id === incident.id ? { ...item, isContractSynced: !item.isContractSynced, isAcknowledged: true } : item, ), ) } } copyCode(key: string, code: string): void { try { navigator.clipboard.writeText(code) this.copiedKey.set(key) setTimeout(() => { if (this.copiedKey() === key) this.copiedKey.set(null) }, 2000) } catch { // Clipboard unavailable in this context. } } }src/app/components/blocks/schema-drift-alert-board/index.ts0.1 kB
export { UiSchemaDriftAlertBoardComponent, type BlastRadiusNode, type SchemaDriftIncident, } from './schema-drift-alert-board.component'
Raw manifest:https://uipkge.dev/r/angular/schema-drift-alert-board.json