Menu

Change language

GitHub repo

Data Product Card

blockdevops

Data Mesh architectural Data Product Card (DPC) featuring SLA compliance health, multi-modal output ports (Snowflake, Kafka, GraphQL, Parquet S3), domain ownership, consumer counts, and access request triggers.

Installation

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

Variants

Loading interactive previews…

Schema

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

OutputPort
interface OutputPort {
  id: string
  name: string
  type: 'snowflake' | 'kafka' | 'graphql' | 's3' | 'postgres'
  typeLabel: string
  uri: string
  latency: string
  status: 'online' | 'degraded' | 'syncing'
}
DataProductData
interface DataProductData {
  id: string
  name: string
  version: string
  domain: string
  tier: 'Gold Tier 1' | 'Silver Tier 2' | 'Bronze Tier 3'
  description: string
  slaCompliance: number
  freshness: string
  completenessPct: number
  activeConsumersCount: number
  owner: {
    name: string
    role: string
    team: string
    avatar?: string
    fallback: string
  }
  outputPorts: OutputPort[]
  tags: string[]
  certifiedCompliance: string
  lastUpdated: string
}

Files installed (2)

  • src/app/components/blocks/data-product-card/data-product-card.component.ts18.9 kB
    import { ChangeDetectionStrategy, Component, Input, OnDestroy, signal } from '@angular/core'
    import {
      Activity,
      Award,
      Check,
      ChevronRight,
      Copy,
      Database,
      HardDrive,
      KeyRound,
      Layers,
      LucideAngularModule,
      Radio,
      Server,
      ShieldCheck,
      Users,
      Zap,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiAvatarComponent, UiAvatarFallbackComponent } from '@/ui/avatar/avatar.component'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardFooterComponent,
      UiCardHeaderComponent,
    } from '@/ui/card/card.component'
    import { UiProgressComponent } from '@/ui/progress/progress.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    
    export interface OutputPort {
      id: string
      name: string
      type: 'snowflake' | 'kafka' | 'graphql' | 's3' | 'postgres'
      typeLabel: string
      uri: string
      latency: string
      status: 'online' | 'degraded' | 'syncing'
    }
    
    export interface DataProductData {
      id: string
      name: string
      version: string
      domain: string
      tier: 'Gold Tier 1' | 'Silver Tier 2' | 'Bronze Tier 3'
      description: string
      slaCompliance: number
      freshness: string
      completenessPct: number
      activeConsumersCount: number
      owner: {
        name: string
        role: string
        team: string
        avatar?: string
        fallback: string
      }
      outputPorts: OutputPort[]
      tags: string[]
      certifiedCompliance: string
      lastUpdated: string
    }
    
    export const DEFAULT_DATA_PRODUCT: DataProductData = {
      id: 'dp-cust-360',
      name: 'dp_customer_360_profile',
      version: 'v3.2.0',
      domain: 'Customer Intelligence & Growth',
      tier: 'Gold Tier 1',
      description:
        'Unified customer identity graph consolidating CRM accounts, Stripe transaction volumes, and behavioral product telemetry into clean semantic marts.',
      slaCompliance: 99.8,
      freshness: 'Updated 8m ago · Hourly sync',
      completenessPct: 99.4,
      activeConsumersCount: 14,
      owner: {
        name: 'Marcus Vance',
        role: 'Staff Data Architect',
        team: 'Data Platform Squad',
        fallback: 'MV',
      },
      outputPorts: [
        {
          id: 'port-snow',
          name: 'analytics.gold_customer_360',
          type: 'snowflake',
          typeLabel: 'Snowflake Table',
          uri: 'snowflake://analytics_prod.dw/analytics.gold_customer_360',
          latency: '< 1h Batch SLA',
          status: 'online',
        },
        {
          id: 'port-kafka',
          name: 'events.customer.updates.v2',
          type: 'kafka',
          typeLabel: 'Kafka Stream',
          uri: 'kafka://events.kafka.internal:9092/events.customer.updates.v2',
          latency: '< 100ms Event Time',
          status: 'online',
        },
        {
          id: 'port-gql',
          name: 'GraphQL Query Port',
          type: 'graphql',
          typeLabel: 'GraphQL API',
          uri: 'https://api.uipkge.dev/graphql?query=customer360',
          latency: '< 45ms p99',
          status: 'online',
        },
        {
          id: 'port-s3',
          name: 'Parquet Lake Export',
          type: 's3',
          typeLabel: 'Parquet S3 Lake',
          uri: 's3://lakehouse-gold/customer_360/snapshot_latest.parquet',
          latency: '24h Partitioned',
          status: 'online',
        },
      ],
      tags: ['#customer360', '#revenue', '#identity', '#gold_marts', '#dbt_certified'],
      certifiedCompliance: 'SOC2 Type II & GDPR Art. 15 Compliant',
      lastUpdated: 'Aug 21, 2026',
    }
    
    /**
     * DataProductCard -- the React block, 1:1: a Data Mesh data product card with the
     * governance ribbon, telemetry strip, selectable output ports, a URI inspector with
     * copy, the owner/tags bar and Query Port / Request Access actions.
     */
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-data-product-card, [ui-data-product-card]',
      standalone: true,
      host: { class: 'block' },
      imports: [
        LucideAngularModule,
        UiAvatarComponent,
        UiAvatarFallbackComponent,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardHeaderComponent,
        UiCardDescriptionComponent,
        UiCardContentComponent,
        UiCardFooterComponent,
        UiProgressComponent,
        UiSeparatorComponent,
      ],
      template: `
        <div data-slot="data-product-card" [class]="cn('w-full', className)">
          <div
            ui-card
            class="border-border bg-card text-card-foreground hover:border-primary/40 relative flex flex-col overflow-hidden shadow-xs transition-colors duration-200 hover:shadow-sm"
          >
            <div ui-card-header class="space-y-3 p-5 pb-3">
              <!-- Badges & Governance Ribbon -->
              <div class="flex flex-wrap items-center justify-between gap-2">
                <div class="flex flex-wrap items-center gap-1.5">
                  <!-- Domain Tag -->
                  <span
                    ui-badge
                    wrap
                    variant="outline"
                    class="border-border bg-muted/30 text-muted-foreground gap-1 text-xs font-normal"
                  >
                    <lucide-icon [img]="Layers" class="text-primary size-3" aria-hidden="true" />
                    <span>{{ product.domain }}</span>
                  </span>
    
                  <!-- Tier Tag -->
                  <span
                    ui-badge
                    wrap
                    variant="secondary"
                    class="border-success/30 bg-success/10 text-success font-mono text-xs font-semibold"
                  >
                    <lucide-icon [img]="Award" class="size-3" aria-hidden="true" />
                    {{ product.tier }}
                  </span>
                </div>
    
                <!-- Compliance Seal -->
                <div class="text-muted-foreground flex items-center gap-1 text-xs">
                  <lucide-icon [img]="ShieldCheck" class="text-success size-3.5" aria-hidden="true" />
                  <span class="hidden font-medium sm:inline">{{ product.certifiedCompliance }}</span>
                  <span class="font-medium sm:hidden">SOC2 Certified</span>
                </div>
              </div>
    
              <!-- Title & Version Lockup -->
              <div class="space-y-1">
                <div class="flex flex-wrap items-center justify-between gap-2">
                  <div class="flex items-baseline gap-2">
                    <h3 class="text-foreground font-mono text-base font-bold tracking-tight break-all sm:text-lg">
                      {{ product.name }}
                    </h3>
                    <span class="text-muted-foreground font-mono text-xs font-medium">{{ product.version }}</span>
                  </div>
    
                  <span ui-badge wrap variant="outline" class="text-muted-foreground gap-1 text-xs font-normal">
                    <lucide-icon [img]="Activity" class="text-success size-3 animate-pulse" aria-hidden="true" />
                    <span class="font-mono tabular-nums">{{ product.slaCompliance }}% SLA</span>
                  </span>
                </div>
    
                <!-- Business Value Description -->
                <p ui-card-description class="text-muted-foreground text-xs leading-relaxed sm:text-sm">
                  {{ product.description }}
                </p>
              </div>
            </div>
    
            <div ui-card-content class="flex-1 space-y-4 p-5 pt-0">
              <!-- 4 Key Telemetry Metrics Strip -->
              <div
                class="border-border/80 bg-muted/20 grid grid-cols-2 gap-2 rounded-lg border p-2.5 text-xs sm:grid-cols-4 sm:p-3"
              >
                <div class="space-y-0.5">
                  <span class="text-muted-foreground block">SLA Compliance</span>
                  <div class="flex items-center gap-1.5">
                    <span class="text-success text-success font-mono text-sm font-bold tabular-nums">
                      {{ product.slaCompliance }}%
                    </span>
                    <span class="text-success font-semibold">✓</span>
                  </div>
                  <ui-progress [value]="product.slaCompliance" class="bg-muted h-1.5" />
                </div>
    
                <div class="space-y-0.5">
                  <span class="text-muted-foreground block">Data Freshness</span>
                  <span class="text-foreground block truncate font-mono text-xs font-semibold" [title]="product.freshness">
                    {{ product.freshness }}
                  </span>
                  <span class="text-muted-foreground text-xs">SLA: &lt; 1h Target</span>
                </div>
    
                <div class="space-y-0.5">
                  <span class="text-muted-foreground block">Completeness</span>
                  <span class="text-foreground block font-mono text-sm font-bold tabular-nums">
                    {{ product.completenessPct }}%
                  </span>
                  <span class="text-muted-foreground text-xs">0.6% Max Nulls</span>
                </div>
    
                <div class="space-y-0.5">
                  <span class="text-muted-foreground block">Active Consumers</span>
                  <div class="flex items-center gap-1">
                    <lucide-icon [img]="Users" class="text-primary size-3.5" aria-hidden="true" />
                    <span class="text-foreground font-mono text-sm font-bold tabular-nums">
                      {{ product.activeConsumersCount }} Apps
                    </span>
                  </div>
                  <span class="text-muted-foreground text-xs">Certified Live Ports</span>
                </div>
              </div>
    
              <!-- Multi-Modal Output Ports Selector -->
              <div class="space-y-2">
                <div class="flex items-center justify-between">
                  <span class="text-foreground text-xs font-semibold tracking-wide uppercase">
                    Certified Multi-Modal Output Ports ({{ product.outputPorts.length }})
                  </span>
                  <span class="text-muted-foreground text-xs">Click port to inspect URI</span>
                </div>
    
                <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
                  @for (port of product.outputPorts; track port.id) {
                    <button
                      type="button"
                      [class]="portClass(port)"
                      [attr.aria-pressed]="isSelected(port)"
                      (click)="selectedPortId.set(port.id)"
                    >
                      <div class="flex min-w-0 items-start gap-2.5">
                        <div
                          class="bg-muted text-foreground mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md"
                        >
                          @switch (port.type) {
                            @case ('snowflake') {
                              <lucide-icon [img]="Database" class="text-info size-3.5" aria-hidden="true" />
                            }
                            @case ('kafka') {
                              <lucide-icon [img]="Radio" class="text-warning size-3.5" aria-hidden="true" />
                            }
                            @case ('graphql') {
                              <lucide-icon [img]="Server" class="text-chart-1 size-3.5" aria-hidden="true" />
                            }
                            @default {
                              <lucide-icon [img]="HardDrive" class="text-success size-3.5" aria-hidden="true" />
                            }
                          }
                        </div>
                        <div class="min-w-0 space-y-0.5">
                          <div class="flex items-center gap-1.5">
                            <span class="text-foreground truncate text-xs font-semibold">{{ port.typeLabel }}</span>
                            <span class="bg-success size-1.5 shrink-0 rounded-full" aria-hidden="true"></span>
                          </div>
                          <p class="text-muted-foreground truncate font-mono text-xs">{{ port.name }}</p>
                          <p class="text-muted-foreground text-xs font-normal">{{ port.latency }}</p>
                        </div>
                      </div>
    
                      <div class="shrink-0 pt-0.5 pl-1">
                        @if (isSelected(port)) {
                          <span
                            class="bg-primary text-primary-foreground flex size-4 items-center justify-center rounded-full text-xs font-bold"
                          >
                            ✓
                          </span>
                        } @else {
                          <lucide-icon
                            [img]="ChevronRight"
                            class="text-muted-foreground/40 group-hover:text-foreground size-4"
                            aria-hidden="true"
                          />
                        }
                      </div>
                    </button>
                  }
                </div>
              </div>
    
              <!-- Active Selected Port URI Inspector Box -->
              <div
                class="border-border bg-muted/40 flex flex-col gap-2 rounded-lg border p-3 text-xs sm:flex-row sm:items-center sm:justify-between"
              >
                <div class="min-w-0 space-y-0.5">
                  <span class="text-muted-foreground text-xs font-medium">Selected Endpoint URI:</span>
                  <p class="text-foreground truncate font-mono text-xs font-medium select-all">{{ selectedPort()?.uri }}</p>
                </div>
    
                <div class="flex shrink-0 items-center gap-1.5 pt-1 sm:pt-0">
                  <button
                    ui-button
                    variant="outline"
                    size="sm"
                    class="h-7 gap-1 px-2 text-xs transition-transform duration-150 active:scale-95"
                    (click)="copySelected()"
                  >
                    @if (isCopied()) {
                      <lucide-icon [img]="Check" class="text-success size-3" aria-hidden="true" />
                    } @else {
                      <lucide-icon [img]="Copy" class="size-3" aria-hidden="true" />
                    }
                    <span>{{ isCopied() ? 'Copied' : 'Copy URI' }}</span>
                  </button>
                </div>
              </div>
    
              <!-- Product Owner & Tags Bar -->
              <div class="flex flex-wrap items-center justify-between gap-3 pt-1">
                <!-- Owner Profile -->
                <div class="flex items-center gap-2">
                  <span ui-avatar class="border-border size-7 border">
                    <span ui-avatar-fallback class="bg-primary/10 text-primary text-xs font-semibold">
                      {{ product.owner.fallback }}
                    </span>
                  </span>
                  <div>
                    <p class="text-foreground text-xs leading-none font-medium">{{ product.owner.name }}</p>
                    <p class="text-muted-foreground text-xs">{{ product.owner.team }}</p>
                  </div>
                </div>
    
                <!-- Tags -->
                <div class="flex flex-wrap items-center gap-1">
                  @for (tag of product.tags.slice(0, 3); track tag) {
                    <span
                      ui-badge
                      wrap
                      variant="outline"
                      class="text-muted-foreground border-border/70 text-xs font-normal"
                    >
                      {{ tag }}
                    </span>
                  }
                </div>
              </div>
            </div>
    
            <ui-separator />
    
            <!-- Footer Action Controls -->
            <div ui-card-footer class="flex flex-wrap items-center justify-between gap-2 p-4 sm:px-5">
              <div class="text-muted-foreground text-xs">
                Last contract verified: <span class="text-foreground font-medium">{{ product.lastUpdated }}</span>
              </div>
    
              <div class="flex items-center gap-2">
                <button
                  ui-button
                  variant="outline"
                  size="sm"
                  class="h-8 gap-1.5 text-xs font-medium active:scale-[0.98]"
                  [disabled]="isQueryingPort()"
                  (click)="handleQueryPort()"
                >
                  @if (!isQueryingPort()) {
                    <lucide-icon [img]="Zap" class="text-warning size-3.5" aria-hidden="true" />
                  }
                  @if (isQueryingPort()) {
                    <span>Running Test Query...</span>
                  } @else {
                    <span>Query Port</span>
                  }
                </button>
    
                <button
                  ui-button
                  size="sm"
                  class="h-8 gap-1.5 text-xs font-semibold shadow-xs active:scale-[0.98]"
                  [disabled]="accessRequested()"
                  (click)="handleRequestAccess()"
                >
                  <lucide-icon [img]="KeyRound" class="size-3.5" aria-hidden="true" />
                  <span>{{ accessRequested() ? 'Access Requested ✓' : 'Request Access' }}</span>
                </button>
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiDataProductCardComponent implements OnDestroy {
      @Input() product: DataProductData = DEFAULT_DATA_PRODUCT
      @Input() variant: 'full' | 'compact' | 'minimal' = 'full'
      @Input('class') className?: string
    
      protected readonly cn = cn
      protected readonly Activity = Activity
      protected readonly Award = Award
      protected readonly Check = Check
      protected readonly ChevronRight = ChevronRight
      protected readonly Copy = Copy
      protected readonly Database = Database
      protected readonly HardDrive = HardDrive
      protected readonly KeyRound = KeyRound
      protected readonly Layers = Layers
      protected readonly Radio = Radio
      protected readonly Server = Server
      protected readonly ShieldCheck = ShieldCheck
      protected readonly Users = Users
      protected readonly Zap = Zap
    
      readonly copiedPortId = signal<string | null>(null)
      readonly accessRequested = signal(false)
      readonly isQueryingPort = signal(false)
      /** null until the user picks a port: React seeds it from the first port (or 'port-snow'). */
      readonly selectedPortId = signal<string | null>(null)
    
      private readonly timers = new Set<ReturnType<typeof setTimeout>>()
    
      private currentPortId(): string {
        return this.selectedPortId() ?? this.product.outputPorts[0]?.id ?? 'port-snow'
      }
    
      selectedPort(): OutputPort | undefined {
        const id = this.currentPortId()
        return this.product.outputPorts.find((p) => p.id === id) ?? this.product.outputPorts[0]
      }
    
      isSelected(port: OutputPort): boolean {
        return this.currentPortId() === port.id
      }
    
      isCopied(): boolean {
        const port = this.selectedPort()
        return this.copiedPortId() === port?.id
      }
    
      portClass(port: OutputPort): string {
        return cn(
          'group flex items-start justify-between rounded-lg border p-2.5 text-left transition-[colors,transform] duration-150 active:scale-[0.98]',
          this.isSelected(port)
            ? 'border-primary/50 bg-primary/5 ring-primary/30 ring-1'
            : 'border-border bg-card hover:border-primary/25 hover:bg-muted/40',
        )
      }
    
      private later(fn: () => void, ms: number): void {
        const t = setTimeout(() => {
          this.timers.delete(t)
          fn()
        }, ms)
        this.timers.add(t)
      }
    
      copyUri(port: OutputPort): void {
        if (typeof navigator !== 'undefined' && navigator.clipboard) {
          void navigator.clipboard.writeText(port.uri)
          this.copiedPortId.set(port.id)
          this.later(() => this.copiedPortId.set(null), 2000)
        }
      }
    
      copySelected(): void {
        const port = this.selectedPort()
        if (port) this.copyUri(port)
      }
    
      handleRequestAccess(): void {
        this.accessRequested.set(true)
        this.later(() => this.accessRequested.set(false), 4000)
      }
    
      handleQueryPort(): void {
        this.isQueryingPort.set(true)
        this.later(() => this.isQueryingPort.set(false), 1200)
      }
    
      ngOnDestroy(): void {
        for (const t of this.timers) clearTimeout(t)
        this.timers.clear()
      }
    }
    
  • src/app/components/blocks/data-product-card/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/data-product-card.json