Menu

Change language

GitHub repo

Data Quality Metrics

blockdevops

Great Expectations and Soda style automated data quality assertions, schema validation, and drift detector dashboard: top header with overall quality score and run triggers, 4 assertion metric cards, data quality assertions table with category filters, and an interactive anomaly inspector drawer with SQL quarantine remediation.

Installation

$npx uipkge-ng@latest add data-quality-metrics
Manifest:https://uipkge.dev/r/angular/data-quality-metrics.jsonInstalls to:src/app/components/blocks/data-quality-metrics/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

AnomalyRecord
interface AnomalyRecord {
  recordId: string
  column: string
  value: string
  reason: string
}
AssertionItem
interface AssertionItem {
  id: string
  assertionType: string
  columnName: string
  category: AssertionCategory
  ruleDefinition: string
  targetThreshold: string
  observedValue: string
  observedPercent: number
  failedCount: number
  totalEvaluated: number
  status: AssertionStatus
  statusVariant: 'success' | 'warning' | 'destructive'
  executionTime: string
  expectationConfig: Record<string, unknown>
  remediationQuery: string
  anomalies: AnomalyRecord[]
}

Files installed (2)

  • src/app/components/blocks/data-quality-metrics/data-quality-metrics.component.ts43.4 kB
    import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges, computed, signal } from '@angular/core'
    import {
      Activity,
      AlertCircle,
      AlertTriangle,
      Check,
      CheckCircle2,
      Clock,
      Copy,
      Database,
      Download,
      FileCode2,
      Filter,
      GitBranch,
      Layers,
      LucideAngularModule,
      RefreshCw,
      Search,
      ShieldCheck,
    } 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 {
      UiSheetCloseComponent,
      UiSheetComponent,
      UiSheetContentComponent,
      UiSheetDescriptionComponent,
      UiSheetFooterComponent,
      UiSheetHeaderComponent,
      UiSheetTitleComponent,
    } from '@/ui/sheet/sheet.component'
    import {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    
    export type AssertionStatus = 'Passing' | 'Warning' | 'Failed'
    export type AssertionCategory = 'Completeness' | 'Uniqueness' | 'Validity' | 'Volume' | 'Schema'
    
    export interface AnomalyRecord {
      recordId: string
      column: string
      value: string
      reason: string
    }
    
    export interface AssertionItem {
      id: string
      assertionType: string
      columnName: string
      category: AssertionCategory
      ruleDefinition: string
      targetThreshold: string
      observedValue: string
      observedPercent: number
      failedCount: number
      totalEvaluated: number
      status: AssertionStatus
      statusVariant: 'success' | 'warning' | 'destructive'
      executionTime: string
      expectationConfig: Record<string, unknown>
      remediationQuery: string
      anomalies: AnomalyRecord[]
    }
    
    export const defaultAssertions: AssertionItem[] = [
      {
        id: 'dq-assert-1',
        assertionType: 'expect_column_values_to_not_be_null',
        columnName: 'user_id',
        category: 'Completeness',
        ruleDefinition: 'Primary key non-null identity constraint verification across all partition shards.',
        targetThreshold: '100.0%',
        observedValue: '100.0%',
        observedPercent: 100.0,
        failedCount: 0,
        totalEvaluated: 1480000,
        status: 'Passing',
        statusVariant: 'success',
        executionTime: '142ms',
        expectationConfig: {
          expectation_type: 'expect_column_values_to_not_be_null',
          kwargs: {
            column: 'user_id',
            mostly: 1.0,
          },
        },
        remediationQuery: `-- Primary key check passed cleanly. No quarantine needed.\nSELECT COUNT(*) FROM production_analytics.dim_users WHERE user_id IS NULL;`,
        anomalies: [],
      },
      {
        id: 'dq-assert-2',
        assertionType: 'expect_column_values_to_be_unique',
        columnName: 'email',
        category: 'Uniqueness',
        ruleDefinition: 'Global case-insensitive deduplication check across customer directory.',
        targetThreshold: '100.0%',
        observedValue: '100.0%',
        observedPercent: 100.0,
        failedCount: 0,
        totalEvaluated: 1480000,
        status: 'Passing',
        statusVariant: 'success',
        executionTime: '380ms',
        expectationConfig: {
          expectation_type: 'expect_column_values_to_be_unique',
          kwargs: {
            column: 'email',
            mostly: 1.0,
          },
        },
        remediationQuery: `-- Zero duplicate emails detected in active partition.\nSELECT LOWER(email), COUNT(*) FROM production_analytics.dim_users GROUP BY 1 HAVING COUNT(*) > 1;`,
        anomalies: [],
      },
      {
        id: 'dq-assert-3',
        assertionType: 'expect_column_values_to_match_regex',
        columnName: 'phone_e164',
        category: 'Validity',
        ruleDefinition: 'International telecommunications ITU-T E.164 format pattern matching (^\\+[1-9]\\d{1,14}$).',
        targetThreshold: '≥ 98.0%',
        observedValue: '98.8%',
        observedPercent: 98.8,
        failedCount: 17760,
        totalEvaluated: 1480000,
        status: 'Warning',
        statusVariant: 'warning',
        executionTime: '890ms',
        expectationConfig: {
          expectation_type: 'expect_column_values_to_match_regex',
          kwargs: {
            column: 'phone_e164',
            regex: '^\\+[1-9]\\d{1,14}$',
            mostly: 0.98,
          },
        },
        remediationQuery: `-- Quarantine 17,760 legacy unformatted phone numbers for libphonenumber normalization:\nCREATE OR REPLACE TABLE staging.unformatted_phones AS\nSELECT user_id, email, phone_e164, 'MALFORMED_E164' AS failure_reason\nFROM production_analytics.dim_users\nWHERE phone_e164 NOT RLIKE '^\\+[1-9]\\d{1,14}$';`,
        anomalies: [
          {
            recordId: 'usr_9941a8',
            column: 'phone_e164',
            value: '+1-555-019',
            reason: 'Invalid E.164 length (incomplete national number)',
          },
          {
            recordId: 'usr_1082fc',
            column: 'phone_e164',
            value: '0784910293',
            reason: 'Missing international country dial prefix (+)',
          },
          {
            recordId: 'usr_7729de',
            column: 'phone_e164',
            value: '+44 (0)20 7946',
            reason: 'Unstripped parentheses and whitespace delimiter',
          },
          {
            recordId: 'usr_4019ab',
            column: 'phone_e164',
            value: 'NULL',
            reason: 'Unexpected empty field on SMS-enrolled user',
          },
        ],
      },
      {
        id: 'dq-assert-4',
        assertionType: 'expect_table_row_count_to_be_between',
        columnName: 'table: dim_users',
        category: 'Volume',
        ruleDefinition: 'Daily snapshot table row count volumetric boundary expectation between 1.0M and 2.0M rows.',
        targetThreshold: '1.0M .. 2.0M',
        observedValue: '1,480,000',
        observedPercent: 100.0,
        failedCount: 0,
        totalEvaluated: 1480000,
        status: 'Passing',
        statusVariant: 'success',
        executionTime: '95ms',
        expectationConfig: {
          expectation_type: 'expect_table_row_count_to_be_between',
          kwargs: {
            min_value: 1000000,
            max_value: 2000000,
          },
        },
        remediationQuery: `-- Row count volumetric validation passed within safety boundaries (1.48M in [1.0M, 2.0M]).\nSELECT COUNT(*) AS total_rows FROM production_analytics.dim_users;`,
        anomalies: [],
      },
      {
        id: 'dq-assert-5',
        assertionType: 'expect_column_values_to_be_between',
        columnName: 'age',
        category: 'Validity',
        ruleDefinition: 'Demographic account age boundary distribution constraint between 18 and 120 years.',
        targetThreshold: '100.0%',
        observedValue: '100.0%',
        observedPercent: 100.0,
        failedCount: 0,
        totalEvaluated: 1480000,
        status: 'Passing',
        statusVariant: 'success',
        executionTime: '210ms',
        expectationConfig: {
          expectation_type: 'expect_column_values_to_be_between',
          kwargs: {
            column: 'age',
            min_value: 18,
            max_value: 120,
            mostly: 1.0,
          },
        },
        remediationQuery: `-- All verified user ages lie cleanly between min 18 and max 94 (100.0% compliant).\nSELECT MIN(age), MAX(age), AVG(age) FROM production_analytics.dim_users;`,
        anomalies: [],
      },
    ]
    
    const PILL_BASE =
      'focus-visible:ring-ring inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none'
    const PILL_IDLE = 'border-border bg-card text-muted-foreground hover:bg-accent hover:text-foreground'
    const CAT_BASE =
      'focus-visible:ring-ring inline-flex items-center rounded-md border px-2 py-1 font-mono text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none'
    
    /**
     * Angular port of the React DataQualityMetrics block: dataset header with quality score and
     * run / export actions, four assertion metric cards, a filterable assertions table and an
     * anomaly inspector sheet with the expectation config and remediation SQL.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-data-quality-metrics, [ui-data-quality-metrics]',
      standalone: true,
      host: { class: 'contents' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiProgressComponent,
        UiSeparatorComponent,
        UiSheetComponent,
        UiSheetCloseComponent,
        UiSheetContentComponent,
        UiSheetDescriptionComponent,
        UiSheetFooterComponent,
        UiSheetHeaderComponent,
        UiSheetTitleComponent,
        UiTableComponent,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
      ],
      template: `
        <div data-slot="data-quality-metrics" [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-6 lg:flex-row lg:items-center lg:justify-between">
                <!-- Left: Dataset and Suite Metadata -->
                <div class="space-y-2">
                  <div class="flex flex-wrap items-center gap-2.5">
                    <div
                      class="bg-muted text-foreground border-border flex size-9 shrink-0 items-center justify-center rounded-lg border shadow-xs"
                    >
                      <lucide-icon [img]="Database" class="text-success size-4.5" />
                    </div>
                    <div>
                      <div class="flex flex-wrap items-center gap-2">
                        <h1 class="text-foreground font-mono text-lg font-bold tracking-tight break-all sm:text-xl">
                          {{ datasetTitle }}
                        </h1>
                        <span ui-badge wrap variant="secondary" class="font-mono text-xs">{{ suiteName }}</span>
                      </div>
                      <div class="text-muted-foreground flex flex-wrap items-center gap-2 pt-1 text-xs">
                        <span class="flex items-center gap-1">
                          <lucide-icon [img]="Clock" class="size-3.5" />
                          {{ currentLastRun() }}
                        </span>
                        <span class="opacity-40">·</span>
                        <span class="font-mono">Snowflake DW</span>
                        <span class="opacity-40">·</span>
                        <span class="font-mono">dbt Core v1.8</span>
                      </div>
                    </div>
                  </div>
                </div>
    
                <!-- Right: Overall Score Card & Action Buttons -->
                <div class="flex flex-col gap-4 sm:flex-row sm:items-center">
                  <div
                    class="border-success/30 bg-success/10 flex flex-wrap items-center gap-3.5 rounded-xl border p-3.5 shadow-xs"
                  >
                    <div class="bg-success/20 text-success flex size-11 shrink-0 items-center justify-center rounded-lg">
                      <lucide-icon [img]="CheckCircle2" class="size-6" />
                    </div>
                    <div class="min-w-0 space-y-0.5">
                      <div class="flex flex-wrap items-baseline gap-2">
                        <span class="text-success text-2xl font-bold tracking-tight tabular-nums sm:text-3xl"
                          >{{ qualityScore }}%</span
                        >
                        <span ui-badge wrap variant="success" class="gap-1 px-2 text-xs font-semibold">
                          <lucide-icon [img]="ShieldCheck" class="size-3" />
                          Quality Score
                        </span>
                      </div>
                      <p class="text-muted-foreground text-xs">Automated Sodacl / GX Engine</p>
                    </div>
                  </div>
    
                  <div class="flex flex-wrap gap-2.5 sm:flex-col">
                    <button
                      ui-button
                      variant="default"
                      size="sm"
                      class="gap-2 shadow-xs"
                      [disabled]="isRunningSuite()"
                      (click)="handleRunSuite()"
                    >
                      <lucide-icon [img]="RefreshCw" [class]="refreshIconClass()" />
                      <span>{{ isRunningSuite() ? 'Evaluating Suite...' : 'Run Assertions Suite' }}</span>
                    </button>
    
                    <button ui-button variant="outline" size="sm" class="gap-2 shadow-xs" (click)="exportQualityReport()">
                      @if (exportSuccess()) {
                        <lucide-icon [img]="Check" class="text-success size-4" />
                      } @else {
                        <lucide-icon [img]="Download" class="size-4" />
                      }
                      <span>{{ exportSuccess() ? 'Report Downloaded!' : 'Export Quality Report' }}</span>
                    </button>
                  </div>
                </div>
              </div>
            </div>
    
            <!-- Sub-bar Contract Guarantee -->
            <div
              class="bg-muted/40 border-border 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>Schema Contract: Locked v3.2 · Zero Breaking Schema Drift</span>
              </div>
              <div class="flex items-center gap-3">
                <span>Engine SLA: <strong class="text-foreground font-medium">Strict Production</strong></span>
                <ui-separator orientation="vertical" class="hidden h-3 sm:block" />
                <span>48 assertions evaluated</span>
              </div>
            </div>
          </div>
    
          <!-- 4 Assertion Metric Cards -->
          <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
            <!-- Card 1: Passed Assertions -->
            <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 items-start justify-between gap-2">
                  <p class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Passed Assertions</p>
                  <div
                    class="border-success/20 bg-success/10 text-success flex size-7 items-center justify-center rounded-md border"
                  >
                    <lucide-icon [img]="CheckCircle2" 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">47 / 48</span>
                  <span ui-badge wrap variant="success" class="text-xs font-medium">98% Passing</span>
                </div>
              </div>
              <div ui-card-content class="space-y-2 p-4 pt-1">
                <div ui-progress [value]="98" class="h-1.5 w-full"></div>
                <p class="text-muted-foreground text-xs">{{ passedAssertions }}</p>
              </div>
            </div>
    
            <!-- Card 2: Failed / Warnings -->
            <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 items-start justify-between gap-2">
                  <p class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Failed / Warnings</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">1 Warning</span>
                  <span ui-badge wrap variant="warning" class="text-xs font-medium">1.2% Non-conformant</span>
                </div>
              </div>
              <div ui-card-content class="space-y-2 p-4 pt-1">
                <div ui-progress [value]="1.2" class="h-1.5 w-full"></div>
                <p class="text-muted-foreground text-xs">{{ failedWarnings }}</p>
              </div>
            </div>
    
            <!-- Card 3: Schema Drift Status -->
            <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 items-start justify-between gap-2">
                  <p class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Schema Drift Status</p>
                  <div
                    class="border-info/20 bg-info/10 text-info flex size-7 items-center justify-center rounded-md border"
                  >
                    <lucide-icon [img]="GitBranch" 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 Breaking</span>
                  <span ui-badge wrap variant="outline" class="text-success text-xs font-medium">No Drift</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 gap-x-2 rounded-md px-2 py-1 text-xs">
                  <span class="text-muted-foreground">Columns Synced</span>
                  <span class="text-foreground font-mono font-medium">14 / 14 matched</span>
                </div>
                <p class="text-muted-foreground text-xs">{{ schemaDriftStatus }}</p>
              </div>
            </div>
    
            <!-- Card 4: Total Rows Audited -->
            <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 items-start justify-between gap-2">
                  <p class="text-muted-foreground text-xs font-medium tracking-wider uppercase">Total Rows Audited</p>
                  <div
                    class="bg-muted text-foreground border-border flex size-7 items-center justify-center rounded-md border"
                  >
                    <lucide-icon [img]="Layers" 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">1,480,000</span>
                  <span ui-badge wrap variant="outline" class="font-mono text-xs font-normal">1.48M Rows</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 gap-x-2 rounded-md px-2 py-1 text-xs">
                  <span class="text-muted-foreground">Partition Range</span>
                  <span class="text-foreground font-mono font-medium">1.0M .. 2.0M</span>
                </div>
                <p class="text-muted-foreground text-xs">{{ totalRowsAudited }}</p>
              </div>
            </div>
          </div>
    
          <!-- Data Quality Test Assertions Table Card -->
          <div ui-card class="border-border bg-card shadow-xs">
            <div ui-card-header class="flex flex-col gap-4 pb-4 sm:flex-row sm:items-center sm:justify-between">
              <div>
                <div class="flex items-center gap-2">
                  <div ui-card-title class="text-base font-semibold">Data Quality Test Assertions</div>
                  <span ui-badge wrap variant="secondary" class="font-mono text-xs"
                    >{{ filteredAssertions().length }} of {{ assertionList().length }} Assertions</span
                  >
                </div>
                <div ui-card-description class="text-xs">
                  Automated expectations, boundary validations, regex format rules, and volume anomaly checks.
                </div>
              </div>
    
              <!-- Filter Pills by Status -->
              <div class="flex flex-wrap items-center gap-1.5">
                <button type="button" [class]="statusPillClass('all')" (click)="selectedStatus.set('all')">
                  All Assertions ({{ assertionList().length }})
                </button>
                <button type="button" [class]="statusPillClass('passing')" (click)="selectedStatus.set('passing')">
                  Passing (4)
                </button>
                <button type="button" [class]="statusPillClass('warning')" (click)="selectedStatus.set('warning')">
                  Warnings (1)
                </button>
              </div>
            </div>
    
            <!-- Filter & Search Toolbar -->
            <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)="onSearch($event)"
                    type="text"
                    placeholder="Search by assertion type, column name, or rule definition..."
                    class="border-border bg-background placeholder:text-muted-foreground focus-visible:ring-ring h-8 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" />
                    Category:
                  </span>
                  @for (cat of availableCategories(); track cat) {
                    <button type="button" [class]="categoryClass(cat)" (click)="selectedCategory.set(cat)">
                      {{ cat === 'all' ? 'All Categories' : cat }}
                    </button>
                  }
                </div>
              </div>
            </div>
    
            <!-- Table Container -->
            <div ui-card-content class="p-0">
              <div class="border-border/60 overflow-x-auto border-t">
                <div ui-table>
                  <thead ui-table-header class="bg-muted/50">
                    <tr ui-table-row>
                      <th ui-table-head class="min-w-[260px] text-xs">Assertion &amp; Target Column</th>
                      <th ui-table-head class="min-w-[100px] text-xs">Category</th>
                      <th ui-table-head class="min-w-[120px] text-xs">Target Threshold</th>
                      <th ui-table-head class="min-w-[120px] text-xs">Observed Value</th>
                      <th ui-table-head class="min-w-[110px] text-xs">Status</th>
                      <th ui-table-head class="min-w-[130px] text-right text-xs">Actions</th>
                    </tr>
                  </thead>
                  <tbody ui-table-body>
                    @for (assertion of filteredAssertions(); track assertion.id) {
                      <tr ui-table-row class="hover:bg-muted/20 transition-colors">
                        <td ui-table-cell class="py-3">
                          <div class="space-y-1">
                            <div class="flex flex-wrap items-center gap-2">
                              <span ui-badge wrap variant="outline" class="border-border font-mono text-xs font-semibold">{{
                                assertion.columnName
                              }}</span>
                              <span class="text-foreground font-mono text-xs font-medium">{{
                                assertion.assertionType
                              }}</span>
                            </div>
                            <p class="text-muted-foreground max-w-sm text-xs leading-relaxed sm:max-w-md">
                              {{ assertion.ruleDefinition }}
                            </p>
                          </div>
                        </td>
    
                        <td ui-table-cell class="py-3">
                          <span ui-badge wrap variant="secondary" class="font-normal">{{ assertion.category }}</span>
                        </td>
    
                        <td ui-table-cell class="py-3">
                          <span class="text-muted-foreground font-mono text-xs font-medium">{{
                            assertion.targetThreshold
                          }}</span>
                        </td>
    
                        <td ui-table-cell class="py-3">
                          <div class="space-y-0.5 font-mono text-xs">
                            <span [class]="observedClass(assertion, 'font-bold tabular-nums')">{{
                              assertion.observedValue
                            }}</span>
                            <span class="text-muted-foreground/70 block text-xs">{{ assertion.executionTime }} eval</span>
                          </div>
                        </td>
    
                        <td ui-table-cell class="py-3">
                          <span ui-badge wrap [variant]="assertion.statusVariant" class="gap-1 text-xs font-medium">
                            @if (assertion.status === 'Passing') {
                              <lucide-icon [img]="CheckCircle2" class="size-3" />
                            } @else if (assertion.status === 'Warning') {
                              <lucide-icon [img]="AlertTriangle" class="size-3" />
                            } @else {
                              <lucide-icon [img]="AlertCircle" class="size-3" />
                            }
                            {{ assertion.status }}
                          </span>
                        </td>
    
                        <td ui-table-cell class="py-3 text-right">
                          <button
                            ui-button
                            variant="outline"
                            size="xs"
                            class="h-7 gap-1 text-xs font-medium shadow-xs"
                            (click)="handleInspectAnomalies(assertion)"
                          >
                            <lucide-icon [img]="Activity" class="size-3" />
                            <span>Inspect Anomalies</span>
                          </button>
                        </td>
                      </tr>
                    }
    
                    @if (filteredAssertions().length === 0) {
                      <tr ui-table-row>
                        <td ui-table-cell colspan="6" class="py-8 text-center">
                          <div class="flex flex-col items-center justify-center gap-1.5">
                            <lucide-icon [img]="ShieldCheck" class="text-success size-7" />
                            <p class="text-foreground text-sm font-medium">No assertions match your filter query</p>
                            <p class="text-muted-foreground text-xs">All assertions meet standard quality constraints.</p>
                          </div>
                        </td>
                      </tr>
                    }
                  </tbody>
                </div>
              </div>
    
              <!-- Table Footer -->
              <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>Great Expectations Assertions Runner: 100% evaluated</span>
                </div>
                <span class="font-mono text-xs">Dataset: {{ datasetTitle }} · Last Batch: 1,480,000 rows</span>
              </div>
            </div>
          </div>
    
          <!-- Anomaly Inspector Drawer / Sheet -->
          <div ui-sheet [open]="isDrawerOpen()" (openChange)="isDrawerOpen.set($event)">
            <div ui-sheet-content class="w-full space-y-6 overflow-y-auto p-6 sm:max-w-xl md:max-w-2xl">
              @if (selectedAssertion(); as sel) {
                <div ui-sheet-header class="space-y-2 p-0 text-left">
                  <div class="flex items-center justify-between gap-3 pr-6">
                    <div class="flex items-center gap-2">
                      <span ui-badge wrap [variant]="sel.statusVariant" class="gap-1 font-mono text-xs font-semibold">
                        @if (sel.status === 'Passing') {
                          <lucide-icon [img]="CheckCircle2" class="size-3" />
                        } @else {
                          <lucide-icon [img]="AlertTriangle" class="size-3" />
                        }
                        {{ sel.status.toUpperCase() }}
                      </span>
                      <span ui-badge wrap variant="outline" class="font-mono text-xs font-semibold">{{
                        sel.category
                      }}</span>
                    </div>
    
                    <span class="text-muted-foreground font-mono text-xs">Latency: {{ sel.executionTime }}</span>
                  </div>
    
                  <h2 ui-sheet-title class="text-lg font-bold tracking-tight">{{ sel.assertionType }}</h2>
                  <p ui-sheet-description class="text-foreground/90 font-mono text-xs">
                    Target Column: <strong class="text-foreground font-semibold">{{ sel.columnName }}</strong>
                  </p>
                </div>
    
                <div class="space-y-5 text-xs">
                  <!-- Summary Metrics Cards Grid -->
                  <div class="grid grid-cols-2 gap-2.5 sm:grid-cols-4">
                    <div class="bg-muted/40 border-border/80 rounded-lg border p-3">
                      <span class="text-muted-foreground text-xs">Target Threshold</span>
                      <p class="text-foreground mt-1 font-mono text-sm font-bold">{{ sel.targetThreshold }}</p>
                    </div>
                    <div class="bg-muted/40 border-border/80 rounded-lg border p-3">
                      <span class="text-muted-foreground text-xs">Observed Pass Rate</span>
                      <p [class]="observedClass(sel, 'mt-1 font-mono text-sm font-bold tabular-nums')">
                        {{ sel.observedValue }}
                      </p>
                    </div>
                    <div class="bg-muted/40 border-border/80 rounded-lg border p-3">
                      <span class="text-muted-foreground text-xs">Audited Records</span>
                      <p class="text-foreground mt-1 font-mono text-sm font-bold tabular-nums">
                        {{ formatNumber(sel.totalEvaluated) }}
                      </p>
                    </div>
                    <div class="bg-muted/40 border-border/80 rounded-lg border p-3">
                      <span class="text-muted-foreground text-xs">Failed Anomalies</span>
                      <p [class]="failedClass(sel)">{{ formatNumber(sel.failedCount) }}</p>
                    </div>
                  </div>
    
                  <!-- Expectation Configuration DSL Box -->
                  <div class="space-y-2">
                    <div class="flex items-center justify-between gap-x-2">
                      <h3 class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
                        Great Expectations Specification
                      </h3>
                      <button
                        ui-button
                        variant="ghost"
                        size="xs"
                        class="h-6 gap-1 px-2 text-xs"
                        (click)="copyCodeSnippet('dsl', configJson(sel))"
                      >
                        @if (copiedSnippetKey() === 'dsl') {
                          <lucide-icon [img]="Check" class="text-success size-3" />
                        } @else {
                          <lucide-icon [img]="Copy" class="size-3" />
                        }
                        <span>{{ copiedSnippetKey() === 'dsl' ? 'Copied' : 'Copy Config' }}</span>
                      </button>
                    </div>
                    <div class="overflow-hidden rounded-md border bg-zinc-950 font-mono text-xs text-zinc-100 shadow-inner">
                      <div
                        class="text-muted-foreground flex items-center gap-2 border-b border-zinc-800 bg-zinc-900/90 px-3 py-1.5 text-xs"
                      >
                        <lucide-icon [img]="FileCode2" class="size-3.5" />
                        <span>expectation_suite.json</span>
                      </div>
                      <pre class="overflow-x-auto p-3 text-xs leading-relaxed text-zinc-300 select-text">{{
                        configJson(sel)
                      }}</pre>
                    </div>
                  </div>
    
                  <!-- Anomalous Samples Table or Clean Pass State -->
                  <div class="space-y-2">
                    <div class="flex items-center justify-between gap-x-2">
                      <h3 class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
                        Anomalous Sample Records
                      </h3>
                      @if (sel.anomalies.length > 0) {
                        <span class="text-muted-foreground font-mono text-xs"
                          >Showing {{ sel.anomalies.length }} failure samples</span
                        >
                      }
                    </div>
    
                    @if (sel.anomalies.length > 0) {
                      <div class="border-border/80 overflow-hidden rounded-md border shadow-xs">
                        <div ui-table>
                          <thead ui-table-header class="bg-muted/40">
                            <tr ui-table-row>
                              <th ui-table-head class="text-xs">Record ID</th>
                              <th ui-table-head class="text-xs">Column</th>
                              <th ui-table-head class="text-xs">Observed Value</th>
                              <th ui-table-head class="text-xs">Diagnostic Reason</th>
                            </tr>
                          </thead>
                          <tbody ui-table-body>
                            @for (anomaly of sel.anomalies; track anomaly.recordId) {
                              <tr ui-table-row>
                                <td ui-table-cell class="font-mono text-xs font-medium">{{ anomaly.recordId }}</td>
                                <td ui-table-cell class="font-mono text-xs">{{ anomaly.column }}</td>
                                <td ui-table-cell class="text-destructive font-mono text-xs font-semibold">
                                  {{ anomaly.value }}
                                </td>
                                <td ui-table-cell class="text-muted-foreground text-xs">{{ anomaly.reason }}</td>
                              </tr>
                            }
                          </tbody>
                        </div>
                      </div>
                    } @else {
                      <div
                        class="bg-muted/20 border-border/80 flex flex-col items-center justify-center gap-1.5 rounded-md border p-6 text-center"
                      >
                        <lucide-icon [img]="CheckCircle2" class="text-success size-6" />
                        <p class="text-foreground text-xs font-semibold">Zero Anomalies Detected</p>
                        <p class="text-muted-foreground text-xs">
                          All {{ formatNumber(sel.totalEvaluated) }} audited records strictly satisfied the expectation
                          rule.
                        </p>
                      </div>
                    }
                  </div>
    
                  <!-- Quarantine SQL Script -->
                  <div class="space-y-2">
                    <div class="flex items-center justify-between gap-x-2">
                      <h3 class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
                        Quarantine &amp; Remediation SQL
                      </h3>
                      <button
                        ui-button
                        variant="ghost"
                        size="xs"
                        class="h-6 gap-1 px-2 text-xs"
                        (click)="copyCodeSnippet('sql', sel.remediationQuery)"
                      >
                        @if (copiedSnippetKey() === 'sql') {
                          <lucide-icon [img]="Check" class="text-success size-3" />
                        } @else {
                          <lucide-icon [img]="Copy" class="size-3" />
                        }
                        <span>{{ copiedSnippetKey() === 'sql' ? 'Copied' : 'Copy SQL' }}</span>
                      </button>
                    </div>
                    <div
                      class="border-border bg-muted/60 rounded-md border p-3 font-mono text-xs leading-relaxed select-text"
                    >
                      <pre class="text-foreground overflow-x-auto whitespace-pre-wrap">{{ sel.remediationQuery }}</pre>
                    </div>
                  </div>
                </div>
    
                <div ui-sheet-footer class="border-border/80 flex flex-row items-center justify-end gap-2 border-t pt-4">
                  <button ui-button ui-sheet-close variant="outline" size="sm" class="text-xs">Close</button>
    
                  @if (sel.anomalies.length > 0) {
                    <button
                      ui-button
                      variant="default"
                      size="sm"
                      class="gap-1.5 text-xs font-medium shadow-xs"
                      (click)="exportAnomalyCsv()"
                    >
                      @if (anomalyExportSuccess()) {
                        <lucide-icon [img]="Check" class="text-success size-3.5" />
                      } @else {
                        <lucide-icon [img]="Download" class="size-3.5" />
                      }
                      <span>{{ anomalyExportSuccess() ? 'Anomalies Exported!' : 'Export Anomaly CSV' }}</span>
                    </button>
                  }
                </div>
              }
            </div>
          </div>
        </div>
      `,
    })
    export class UiDataQualityMetricsComponent implements OnChanges {
      @Input() datasetTitle = 'production_analytics.dim_users'
      @Input() suiteName = 'user_profile_integrity_v3'
      @Input() qualityScore = 98.4
      @Input() lastRun = 'Ran 14m ago · 48 assertions evaluated'
      @Input() passedAssertions = '47 / 48 Passing · 98%'
      @Input() failedWarnings = '1 Warning · Null phone numbers 1.2%'
      @Input() schemaDriftStatus = '0 breaking schema changes'
      @Input() totalRowsAudited = '1,480,000 rows'
      @Input() set assertions(value: AssertionItem[] | null | undefined) {
        this.assertionList.set(value ?? defaultAssertions)
      }
      @Input('class') className?: string
    
      protected readonly Activity = Activity
      protected readonly AlertCircle = AlertCircle
      protected readonly AlertTriangle = AlertTriangle
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Clock = Clock
      protected readonly Copy = Copy
      protected readonly Database = Database
      protected readonly Download = Download
      protected readonly FileCode2 = FileCode2
      protected readonly Filter = Filter
      protected readonly GitBranch = GitBranch
      protected readonly Layers = Layers
      protected readonly RefreshCw = RefreshCw
      protected readonly Search = Search
      protected readonly ShieldCheck = ShieldCheck
    
      readonly assertionList = signal<AssertionItem[]>(defaultAssertions)
      readonly searchQuery = signal('')
      readonly selectedStatus = signal<string>('all')
      readonly selectedCategory = signal<string>('all')
      readonly isRunningSuite = signal(false)
      readonly currentLastRun = signal(this.lastRun)
      readonly selectedAssertion = signal<AssertionItem | null>(null)
      readonly isDrawerOpen = signal(false)
      readonly copiedSnippetKey = signal<string | null>(null)
      readonly exportSuccess = signal(false)
      readonly anomalyExportSuccess = signal(false)
    
      readonly availableCategories = computed(() => {
        const categories = new Set<string>()
        this.assertionList().forEach((item) => categories.add(item.category))
        return ['all', ...Array.from(categories)]
      })
    
      readonly filteredAssertions = computed(() => {
        const query = this.searchQuery().trim().toLowerCase()
        const statusFilter = this.selectedStatus()
        const categoryFilter = this.selectedCategory()
    
        return this.assertionList().filter((item) => {
          const matchesStatus = statusFilter === 'all' || item.status.toLowerCase() === statusFilter.toLowerCase()
          const matchesCategory = categoryFilter === 'all' || item.category.toLowerCase() === categoryFilter.toLowerCase()
          const matchesQuery =
            !query ||
            item.assertionType.toLowerCase().includes(query) ||
            item.columnName.toLowerCase().includes(query) ||
            item.ruleDefinition.toLowerCase().includes(query) ||
            item.category.toLowerCase().includes(query)
    
          return matchesStatus && matchesCategory && matchesQuery
        })
      })
    
      // React seeds currentLastRun from the lastRun prop once (useState initial value).
      private lastRunSeeded = false
      ngOnChanges(changes: SimpleChanges): void {
        if (changes['lastRun'] && !this.lastRunSeeded) {
          this.currentLastRun.set(this.lastRun)
        }
        this.lastRunSeeded = true
      }
    
      get rootClass(): string {
        return cn('w-full space-y-6', this.className)
      }
    
      refreshIconClass(): string {
        return cn('size-4', this.isRunningSuite() && 'animate-spin')
      }
    
      statusPillClass(status: 'all' | 'passing' | 'warning'): string {
        const active = {
          all: 'border-primary bg-primary text-primary-foreground shadow-xs',
          passing: 'border-success bg-success bg-success text-white shadow-xs',
          warning: 'border-warning bg-warning bg-warning text-white shadow-xs',
        }[status]
        return cn(PILL_BASE, this.selectedStatus() === status ? active : PILL_IDLE)
      }
    
      categoryClass(cat: string): string {
        return cn(
          CAT_BASE,
          this.selectedCategory() === cat ? 'border-foreground bg-foreground text-background shadow-xs' : PILL_IDLE,
        )
      }
    
      observedClass(assertion: AssertionItem, base: string): string {
        return cn(base, assertion.status === 'Passing' ? 'text-success' : 'text-warning')
      }
    
      failedClass(assertion: AssertionItem): string {
        return cn(
          'mt-1 font-mono text-sm font-bold tabular-nums',
          assertion.failedCount > 0 ? 'text-warning' : 'text-success',
        )
      }
    
      formatNumber(value: number): string {
        return value.toLocaleString()
      }
    
      configJson(assertion: AssertionItem): string {
        return JSON.stringify(assertion.expectationConfig, null, 2)
      }
    
      onSearch(event: Event): void {
        this.searchQuery.set((event.target as HTMLInputElement).value)
      }
    
      handleRunSuite(): void {
        if (this.isRunningSuite()) return
        this.isRunningSuite.set(true)
    
        setTimeout(() => {
          this.isRunningSuite.set(false)
          this.currentLastRun.set('Ran just now · 48 assertions evaluated')
        }, 750)
      }
    
      handleInspectAnomalies(assertion: AssertionItem): void {
        this.selectedAssertion.set(assertion)
        this.isDrawerOpen.set(true)
      }
    
      copyCodeSnippet(key: string, content: string): void {
        if (typeof navigator !== 'undefined' && navigator.clipboard) {
          void navigator.clipboard.writeText(content)
          this.copiedSnippetKey.set(key)
          setTimeout(() => {
            if (this.copiedSnippetKey() === key) this.copiedSnippetKey.set(null)
          }, 2000)
        }
      }
    
      exportQualityReport(): void {
        const report = {
          dataset: this.datasetTitle,
          suite: this.suiteName,
          overallQualityScore: this.qualityScore,
          evaluatedAt: new Date().toISOString(),
          engine: 'Great Expectations v0.18 / SodaCL',
          summary: {
            passed: this.passedAssertions,
            warnings: this.failedWarnings,
            schemaDrift: this.schemaDriftStatus,
            totalRows: this.totalRowsAudited,
          },
          assertions: this.assertionList(),
        }
    
        const jsonStr = 'data:text/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(report, null, 2))
        if (typeof document !== 'undefined') {
          const link = document.createElement('a')
          link.setAttribute('href', jsonStr)
          link.setAttribute('download', `dq-report-${this.suiteName}-${new Date().toISOString().slice(0, 10)}.json`)
          document.body.appendChild(link)
          link.click()
          link.remove()
    
          this.exportSuccess.set(true)
          setTimeout(() => {
            this.exportSuccess.set(false)
          }, 2000)
        }
      }
    
      exportAnomalyCsv(): void {
        const selected = this.selectedAssertion()
        if (!selected || selected.anomalies.length === 0) return
    
        const headers = ['Record ID', 'Column', 'Observed Value', 'Failure Reason']
        const rows = selected.anomalies.map((a) => [a.recordId, a.column, `"${a.value}"`, `"${a.reason}"`])
        const csvContent = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n')
    
        const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
        const url = URL.createObjectURL(blob)
        const link = document.createElement('a')
        link.setAttribute('href', url)
        link.setAttribute('download', `anomalies-${selected.columnName}.csv`)
        document.body.appendChild(link)
        link.click()
        link.remove()
    
        this.anomalyExportSuccess.set(true)
        setTimeout(() => {
          this.anomalyExportSuccess.set(false)
        }, 2000)
      }
    }
    
  • src/app/components/blocks/data-quality-metrics/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/data-quality-metrics.json