Menu

Change language

GitHub repo

Etl Pipeline Dag

blockdevops

Airflow and Dagster style directed acyclic graph (DAG) pipeline visualizer with interactive node execution topologies, health metrics, and terminal stdout log inspector.

Installation

$npx uipkge-ng@latest add etl-pipeline-dag
Manifest:https://uipkge.dev/r/angular/etl-pipeline-dag.jsonInstalls to:src/app/components/blocks/etl-pipeline-dag/

Variants

Loading interactive previews…

Schema

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

NodeCardMeta
interface NodeCardMeta {
  id: string
  icon: LucideIconData
  iconBoxClass: string
  subtitle: string
  footerTarget: string
  footerDone?: boolean
  chips: { label: string; toneClass: string }[]
  durationLabel: string
  centered?: boolean
}
DagStage
interface DagStage {
  heading: string
  badge: string
  badgeVariant: 'outline' | 'secondary'
  nodeIds: string[]
}
QualityCheck
interface QualityCheck {
  id: string
  assertion: string
  column: string
  status: 'passed' | 'warning' | 'failed'
  observed: string
  threshold: string
}
SchemaColumn
interface SchemaColumn {
  name: string
  type: string
  nullable: boolean
  description: string
}
LogLine
interface LogLine {
  timestamp: string
  level: 'INFO' | 'WARN' | 'SQL' | 'SUCCESS' | 'ERROR'
  source: string
  message: string
}
DagNode
interface DagNode {
  id: string
  name: string
  category: NodeCategory
  categoryLabel: string
  stageNumber: number
  stageTitle: string
  operator: string
  status: NodeStatus
  duration: string
  durationSec: number
  records: string
  bytes: string
  startedAt: string
  finishedAt: string
  target: string
  engine: string
  upstream: string[]
  downstream: string[]
  retries: string
  memoryPeak: string
  cpuPeak: string
  configParams: Record<string, string>
  qualityChecks: QualityCheck[]
  schemaColumns: SchemaColumn[]
  logs: LogLine[]
}

Files installed (4)

  • src/app/components/blocks/etl-pipeline-dag/etl-pipeline-dag.component.ts22.6 kB
    // Airflow / Dagster style directed acyclic graph (DAG) pipeline visualizer: pipeline header
    // with trigger + export actions, four health metric cards, a five-stage execution topology
    // with selectable task nodes, and the per-node log inspector. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      ArrowRight,
      Check,
      CheckCircle2,
      Clock,
      Cpu,
      CreditCard,
      Database,
      FileDown,
      GitBranch,
      Globe,
      Layers,
      Loader2,
      LucideAngularModule,
      Play,
      ShieldCheck,
      Workflow,
      Zap,
      type LucideIconData,
    } 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 { UiEtlNodeDetailComponent } from './etl-node-detail.component'
    import { pipelineNodes, type DagNode, type NodeCategory } from './etl-pipeline-dag-data'
    
    export type { DagNode, NodeCategory }
    
    interface NodeCardMeta {
      id: string
      icon: LucideIconData
      iconBoxClass: string
      subtitle: string
      footerTarget: string
      footerDone?: boolean
      chips: { label: string; toneClass: string }[]
      durationLabel: string
      centered?: boolean
    }
    
    const NODE_CARDS: NodeCardMeta[] = [
      {
        id: 'extract_stripe_charges',
        icon: CreditCard,
        iconBoxClass: 'border-info/30 bg-info/10 text-info',
        subtitle: '148.3k records · 42.6 MB',
        footerTarget: 'raw_stripe.charges',
        chips: [],
        durationLabel: '18s',
      },
      {
        id: 'extract_currency_rates',
        icon: Globe,
        iconBoxClass: 'border-info/30 bg-info/10 text-info',
        subtitle: '34 FX pairs · 128 KB',
        footerTarget: 'raw_currency.rates',
        chips: [],
        durationLabel: '4s',
      },
      {
        id: 'transform_normalize_fx',
        icon: Cpu,
        iconBoxClass: 'border-chart-1/30 bg-chart-1/10 text-chart-1',
        subtitle: '148,290 rows · PySpark 3.5',
        footerTarget: 'stg_payments_fx',
        chips: [
          { label: '2 Upstreams', toneClass: 'border-border bg-muted/60 text-muted-foreground' },
          { label: 'Snappy', toneClass: 'border-border bg-muted/60 text-muted-foreground' },
        ],
        durationLabel: '42s',
        centered: true,
      },
      {
        id: 'validate_schema_anomalies',
        icon: ShieldCheck,
        iconBoxClass: 'border-warning/30 bg-warning/10 text-warning',
        subtitle: '18/18 checks passed (100%)',
        footerTarget: 'anomalies_audit',
        chips: [
          { label: '0 Anomalies', toneClass: 'border-success/30 bg-success/10 text-success' },
          { label: 'Z-Score 3σ', toneClass: 'border-border bg-muted/60 text-muted-foreground' },
        ],
        durationLabel: '14s',
        centered: true,
      },
      {
        id: 'load_snowflake_warehouse',
        icon: Database,
        iconBoxClass: 'border-cyan-500/30 bg-cyan-500/10 text-cyan-500',
        subtitle: '148,290 rows merged',
        footerTarget: 'fct_stripe_charges',
        chips: [
          { label: '4X-Large', toneClass: 'border-border bg-muted/60 text-muted-foreground' },
          { label: 'Clustered', toneClass: 'border-border bg-muted/60 text-muted-foreground' },
        ],
        durationLabel: '1m 20s',
        centered: true,
      },
      {
        id: 'refresh_dbt_analytics_marts',
        icon: Layers,
        iconBoxClass: 'border-success/30 bg-success/10 text-success',
        subtitle: '12 models · 48 tests',
        footerTarget: 'mart_finance_revenue',
        footerDone: true,
        chips: [
          { label: '8 Threads', toneClass: 'border-border bg-muted/60 text-muted-foreground' },
          { label: 'BI Synced', toneClass: 'border-success/30 bg-success/10 text-success' },
        ],
        durationLabel: '1m 40s',
        centered: true,
      },
    ]
    
    interface DagStage {
      heading: string
      badge: string
      badgeVariant: 'outline' | 'secondary'
      nodeIds: string[]
    }
    
    const DAG_STAGES: DagStage[] = [
      {
        heading: '01 · Extract (2)',
        badge: 'Parallel',
        badgeVariant: 'outline',
        nodeIds: ['extract_stripe_charges', 'extract_currency_rates'],
      },
      { heading: '02 · Transform', badge: 'Join 2→1', badgeVariant: 'secondary', nodeIds: ['transform_normalize_fx'] },
      { heading: '03 · Quality', badge: '18 Checks', badgeVariant: 'outline', nodeIds: ['validate_schema_anomalies'] },
      { heading: '04 · Load', badge: 'Snowflake', badgeVariant: 'outline', nodeIds: ['load_snowflake_warehouse'] },
      { heading: '05 · Marts', badge: 'dbt Core', badgeVariant: 'outline', nodeIds: ['refresh_dbt_analytics_marts'] },
    ]
    
    function getNodeCategoryBadgeClass(category: NodeCategory): string {
      switch (category) {
        case 'ingest':
          return 'border-info/30 bg-info/10 text-info'
        case 'transform':
          return 'border-chart-1/30 bg-chart-1/10 text-chart-1'
        case 'validate':
          return 'border-warning/30 bg-warning/10 text-warning'
        case 'load':
          return 'border-chart-2/30 bg-chart-2/10 text-chart-2'
        case 'model':
          return 'border-success/30 bg-success/10 text-success'
        default:
          return 'border-border bg-muted text-muted-foreground'
      }
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-etl-pipeline-dag, [ui-etl-pipeline-dag]',
      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,
        UiEtlNodeDetailComponent,
      ],
      template: `
        <div data-slot="etl-pipeline-dag" [class]="rootClass">
          <!-- Top Pipeline Header Card -->
          <div ui-card class="border-border bg-card shadow-xs">
            <div ui-card-content class="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between">
              <!-- Left Title and DAG Badges -->
              <div class="flex min-w-0 items-start gap-3.5 sm:items-center">
                <div
                  class="border-primary/20 bg-primary/10 text-primary flex size-11 shrink-0 items-center justify-center rounded-xl border shadow-xs"
                >
                  <lucide-icon [img]="Workflow" class="size-5" />
                </div>
    
                <div class="min-w-0 space-y-1.5">
                  <div class="flex flex-wrap items-center gap-2">
                    <h2 class="text-foreground font-mono text-base font-semibold tracking-tight break-all sm:text-lg">
                      stripe_payments_daily_etl
                    </h2>
                    <span ui-badge variant="secondary" class="font-mono text-xs">v2.4.1</span>
                  </div>
    
                  <div class="flex flex-wrap items-center gap-2">
                    <!-- DAG Schedule Badge -->
                    <span ui-badge variant="outline" class="gap-1.5 font-mono text-xs shadow-xs">
                      <lucide-icon [img]="Clock" class="text-muted-foreground size-3" />
                      <span>0 2 * * * · Daily at 02:00 UTC</span>
                    </span>
    
                    <!-- State Badge -->
                    <span ui-badge variant="success" class="gap-1.5 font-mono text-xs shadow-xs">
                      <lucide-icon [img]="CheckCircle2" class="size-3" />
                      <span>State: Success · Run ID: #run_90412</span>
                    </span>
    
                    <!-- Live Queue Toast -->
                    @if (triggerSuccessToast()) {
                      <span ui-badge variant="info" class="animate-in fade-in-0 gap-1.5 font-mono text-xs duration-300">
                        <lucide-icon [img]="Zap" class="size-3" />
                        <span>Triggered Run #run_90413 queued!</span>
                      </span>
                    }
                  </div>
                </div>
              </div>
    
              <!-- Right Action Buttons -->
              <div class="flex flex-wrap items-center gap-2.5">
                <button
                  ui-button
                  variant="outline"
                  size="sm"
                  class="h-8.5 gap-1.5 text-xs font-medium"
                  [disabled]="copiedAllLogs()"
                  (click)="exportFullRunLog()"
                >
                  @if (copiedAllLogs()) {
                    <lucide-icon [img]="Check" class="size-3.5" />
                  } @else {
                    <lucide-icon [img]="FileDown" class="size-3.5" />
                  }
                  <span>{{ copiedAllLogs() ? 'Run Log Copied!' : 'Export Run Log' }}</span>
                </button>
    
                <button
                  ui-button
                  variant="default"
                  size="sm"
                  class="h-8.5 gap-1.5 text-xs font-medium"
                  [disabled]="isTriggering()"
                  (click)="triggerDag()"
                >
                  @if (isTriggering()) {
                    <lucide-icon [img]="Loader2" class="size-3.5 animate-spin" />
                  } @else {
                    <lucide-icon [img]="Play" class="size-3.5 fill-current" />
                  }
                  <span>{{ isTriggering() ? 'Triggering DAG Run...' : 'Trigger DAG Run' }}</span>
                </button>
              </div>
            </div>
          </div>
    
          <!-- 4 Pipeline Health Metric Cards -->
          <div class="grid grid-cols-1 gap-3.5 sm:grid-cols-2 lg:grid-cols-4">
            <!-- Total Duration -->
            <div ui-card class="border-border bg-card shadow-xs">
              <div ui-card-content class="flex flex-col justify-between p-4.5">
                <div class="flex items-center justify-between">
                  <span class="text-muted-foreground text-xs font-medium">Total Duration</span>
                  <div
                    class="border-border bg-muted/60 text-muted-foreground flex size-7 items-center justify-center rounded-md border"
                  >
                    <lucide-icon [img]="Clock" class="size-3.5" />
                  </div>
                </div>
                <div class="mt-2 space-y-1">
                  <div class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums">04m:18s</div>
                  <div class="flex items-center gap-1.5 text-xs">
                    <span class="text-success font-medium">On schedule</span>
                    <span class="text-muted-foreground font-mono tabular-nums">-14s vs 7d avg</span>
                  </div>
                </div>
              </div>
            </div>
    
            <!-- Tasks Completed -->
            <div ui-card class="border-border bg-card shadow-xs">
              <div ui-card-content class="flex flex-col justify-between p-4.5">
                <div class="flex items-center justify-between">
                  <span class="text-muted-foreground text-xs font-medium">Tasks Completed</span>
                  <div
                    class="border-success/30 bg-success/10 text-success flex size-7 items-center justify-center rounded-md border"
                  >
                    <lucide-icon [img]="CheckCircle2" class="size-3.5" />
                  </div>
                </div>
                <div class="mt-2 space-y-1.5">
                  <div class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums">
                    8 / 8 tasks succeeded
                  </div>
                  <div class="space-y-1">
                    <ui-progress [value]="100" class="h-1.5" />
                    <p class="text-muted-foreground text-xs">100% completion · 0 retries · 0 failed</p>
                  </div>
                </div>
              </div>
            </div>
    
            <!-- Records Ingested -->
            <div ui-card class="border-border bg-card shadow-xs">
              <div ui-card-content class="flex flex-col justify-between p-4.5">
                <div class="flex items-center justify-between">
                  <span class="text-muted-foreground text-xs font-medium">Records Ingested</span>
                  <div
                    class="border-border bg-muted/60 text-muted-foreground flex size-7 items-center justify-center rounded-md border"
                  >
                    <lucide-icon [img]="Database" class="size-3.5" />
                  </div>
                </div>
                <div class="mt-2 space-y-1">
                  <div class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums">
                    148,290 records
                  </div>
                  <div class="flex items-center gap-1.5 text-xs">
                    <span class="text-success font-medium">100% data fidelity</span>
                    <span class="text-muted-foreground">0 dropped rows</span>
                  </div>
                </div>
              </div>
            </div>
    
            <!-- Data Freshness -->
            <div ui-card class="border-border bg-card shadow-xs">
              <div ui-card-content class="flex flex-col justify-between p-4.5">
                <div class="flex items-center justify-between">
                  <span class="text-muted-foreground text-xs font-medium">Data Freshness</span>
                  <div
                    class="border-border bg-muted/60 text-muted-foreground flex size-7 items-center justify-center rounded-md border"
                  >
                    <lucide-icon [img]="Zap" class="size-3.5" />
                  </div>
                </div>
                <div class="mt-2 space-y-1">
                  <div class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums">
                    Lag: 12m · SLA &lt; 1h
                  </div>
                  <div class="flex items-center gap-1.5 text-xs">
                    <span class="text-success font-medium">SLA Healthy</span>
                    <span class="text-muted-foreground">Target: &lt; 60m sync</span>
                  </div>
                </div>
              </div>
            </div>
          </div>
    
          <!-- Interactive DAG Node Pipeline Graph -->
          <div ui-card class="border-border bg-card shadow-xs">
            <div ui-card-header class="pb-3">
              <div class="flex flex-col gap-2 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">DAG Execution Topology</h3>
                    <span ui-badge variant="outline" class="font-mono text-xs">6 Connected Tasks</span>
                  </div>
                  <p ui-card-description class="text-xs">
                    Directed acyclic graph with dependency streams. Select any node to inspect telemetry and execution logs.
                  </p>
                </div>
    
                <!-- Quick Topology Status Info -->
                <div class="flex flex-wrap items-center gap-2 text-xs">
                  <div
                    class="border-border bg-muted/30 text-muted-foreground flex items-center gap-1.5 rounded-md border px-2 py-1 font-mono"
                  >
                    <span class="bg-success inline-block size-2 rounded-full"></span>
                    <span>All Upstreams Resolved</span>
                  </div>
                  <div
                    class="border-border bg-muted/30 text-muted-foreground flex items-center gap-1.5 rounded-md border px-2 py-1 font-mono"
                  >
                    <lucide-icon [img]="GitBranch" class="size-3" />
                    <span>Topology: 2 Branches → Converged</span>
                  </div>
                </div>
              </div>
            </div>
    
            <div ui-card-content class="p-4 pt-1 sm:p-6">
              <!-- DAG Canvas Box -->
              <div class="border-border bg-muted/10 relative rounded-xl border p-4 sm:p-5">
                <!-- Desktop 5-Stage Topology Pipeline Layout -->
                <div class="grid grid-cols-1 gap-5 lg:grid-cols-5">
                  @for (stage of dagStages; track stage.heading) {
                    <div class="flex flex-col justify-between gap-3.5">
                      <div class="flex items-center justify-between px-1">
                        <span class="text-muted-foreground font-mono text-xs font-semibold tracking-wider uppercase">
                          {{ stage.heading }}
                        </span>
                        <span ui-badge [variant]="stage.badgeVariant" class="font-mono text-xs">{{ stage.badge }}</span>
                      </div>
    
                      @for (meta of stageMetas(stage); track meta.id) {
                        <div
                          role="button"
                          tabindex="0"
                          [class]="nodeCardClass(meta)"
                          (click)="selectedNodeId.set(meta.id)"
                          (keydown.enter)="selectedNodeId.set(meta.id)"
                          (keydown.space)="selectedNodeId.set(meta.id); $event.preventDefault()"
                        >
                          <div class="flex items-start justify-between gap-2">
                            <div class="flex items-center gap-2">
                              <div
                                [class]="cn('flex size-7 items-center justify-center rounded-md border', meta.iconBoxClass)"
                              >
                                <lucide-icon [img]="meta.icon" class="size-3.5" />
                              </div>
                              <span ui-badge [class]="cn('font-mono text-xs', categoryClass(nodeOf(meta.id).category))">
                                {{ nodeOf(meta.id).categoryLabel }}
                              </span>
                            </div>
                            <span ui-badge variant="success" class="gap-1 font-mono text-xs">
                              <lucide-icon [img]="Check" class="size-3" />
                              {{ meta.durationLabel }}
                            </span>
                          </div>
    
                          <div class="mt-2.5">
                            <div class="text-foreground font-mono text-xs font-semibold break-all">{{ meta.id }}</div>
                            <div class="text-muted-foreground mt-0.5 font-mono text-xs">{{ meta.subtitle }}</div>
                          </div>
    
                          @if (meta.chips.length > 0) {
                            <div class="mt-2 flex flex-wrap gap-1">
                              @for (chip of meta.chips; track chip.label) {
                                <span [class]="cn('rounded px-1.5 py-0.5 font-mono text-xs', chip.toneClass)">
                                  {{ chip.label }}
                                </span>
                              }
                            </div>
                          }
    
                          <div class="border-border/60 mt-3 flex items-center justify-between border-t pt-2 text-xs">
                            <span class="text-muted-foreground truncate font-mono">{{ meta.footerTarget }}</span>
                            @if (meta.footerDone) {
                              <lucide-icon [img]="CheckCircle2" class="text-success size-3 shrink-0" />
                            } @else {
                              <lucide-icon
                                [img]="ArrowRight"
                                class="text-muted-foreground group-hover:text-primary size-3 shrink-0 transition-colors"
                              />
                            }
                          </div>
                        </div>
                      }
                    </div>
                  }
                </div>
              </div>
            </div>
          </div>
    
          <!-- Selected Node Log Inspector Panel -->
          <ui-etl-node-detail
            [node]="selectedNode()"
            [categoryBadgeClass]="cn('font-mono text-xs', categoryClass(selectedNode().category))"
            [icon]="nodeIcon(selectedNode().id)"
            (selectNode)="selectedNodeId.set($event)"
          />
        </div>
      `,
    })
    export class UiEtlPipelineDagComponent {
      protected readonly ArrowRight = ArrowRight
      protected readonly Check = Check
      protected readonly CheckCircle2 = CheckCircle2
      protected readonly Clock = Clock
      protected readonly Database = Database
      protected readonly FileDown = FileDown
      protected readonly GitBranch = GitBranch
      protected readonly Loader2 = Loader2
      protected readonly Play = Play
      protected readonly Workflow = Workflow
      protected readonly Zap = Zap
      protected readonly cn = cn
    
      @Input() set initialNodeId(v: string | undefined) {
        this.selectedNodeId.set(v ?? 'extract_stripe_charges')
      }
      @Input('class') className?: string
    
      readonly selectedNodeId = signal('extract_stripe_charges')
      readonly copiedAllLogs = signal(false)
      readonly isTriggering = signal(false)
      readonly triggerSuccessToast = signal(false)
      private copyTimer: ReturnType<typeof setTimeout> | undefined
      private triggerTimers: ReturnType<typeof setTimeout>[] = []
    
      protected readonly dagStages = DAG_STAGES
      private readonly nodeCards = NODE_CARDS
      private readonly nodes: DagNode[] = pipelineNodes
    
      readonly selectedNode = computed<DagNode>(() => {
        return this.nodes.find((n) => n.id === this.selectedNodeId()) ?? this.nodes[0]!
      })
    
      get rootClass(): string {
        return cn('w-full space-y-6', this.className)
      }
    
      nodeOf(id: string): DagNode {
        return this.nodes.find((n) => n.id === id) ?? this.nodes[0]!
      }
    
      stageMetas(stage: DagStage): NodeCardMeta[] {
        return stage.nodeIds.map((id) => this.nodeCards.find((m) => m.id === id)).filter((m): m is NodeCardMeta => !!m)
      }
    
      categoryClass(category: NodeCategory): string {
        return getNodeCategoryBadgeClass(category)
      }
    
      nodeIcon(id: string): LucideIconData {
        switch (id) {
          case 'extract_stripe_charges':
            return CreditCard
          case 'extract_currency_rates':
            return Globe
          case 'transform_normalize_fx':
            return Cpu
          case 'validate_schema_anomalies':
            return ShieldCheck
          case 'load_snowflake_warehouse':
            return Database
          case 'refresh_dbt_analytics_marts':
            return Layers
          default:
            return Workflow
        }
      }
    
      nodeCardClass(meta: NodeCardMeta): string {
        return cn(
          'group focus-visible:ring-ring relative cursor-pointer rounded-xl border p-3.5 transition-colors duration-200 focus-visible:ring-2 focus-visible:outline-none',
          meta.centered && 'my-auto',
          this.selectedNodeId() === meta.id
            ? 'border-primary bg-primary/[0.04] ring-primary/30 shadow-xs ring-2'
            : 'border-border bg-card hover:border-primary/50 hover:bg-muted/30',
        )
      }
    
      triggerDag(): void {
        if (this.isTriggering()) return
        this.isTriggering.set(true)
        this.triggerSuccessToast.set(false)
        this.triggerTimers.push(
          setTimeout(() => {
            this.isTriggering.set(false)
            this.triggerSuccessToast.set(true)
            this.triggerTimers.push(
              setTimeout(() => {
                this.triggerSuccessToast.set(false)
              }, 4000),
            )
          }, 1600),
        )
      }
    
      async exportFullRunLog(): Promise<void> {
        const fullLog = this.nodes
          .map((node) => {
            const header = `=== Task: ${node.name} (${node.operator}) - Duration: ${node.duration} ===`
            const body = node.logs.map((l) => `[${l.timestamp}] [${l.level}] [${l.source}] ${l.message}`).join('\n')
            return `${header}\n${body}`
          })
          .join('\n\n')
        try {
          await navigator.clipboard.writeText(fullLog)
          this.copiedAllLogs.set(true)
          clearTimeout(this.copyTimer)
          this.copyTimer = setTimeout(() => {
            this.copiedAllLogs.set(false)
          }, 2500)
        } catch {
          // Clipboard fallback
        }
      }
    }
    
  • src/app/components/blocks/etl-pipeline-dag/etl-node-detail.component.ts21.1 kB
  • src/app/components/blocks/etl-pipeline-dag/etl-pipeline-dag-data.ts28.7 kB
  • src/app/components/blocks/etl-pipeline-dag/index.ts0.3 kB

Raw manifest:https://uipkge.dev/r/angular/etl-pipeline-dag.json