Dbt Model Graph
blockdevopsdbt Cloud style data modeling DAG dependency graph, compiled SQL view, and model documentation with lineage tree, Jinja/SQL source editor, compiled warehouse query, and column contract test suite.
Installation
$pnpm dlx uipkge-ng@latest add dbt-model-graph$npx uipkge-ng@latest add dbt-model-graph$yarn dlx uipkge-ng@latest add dbt-model-graph$bunx uipkge-ng@latest add dbt-model-graphManifest:
https://uipkge.dev/r/angular/dbt-model-graph.jsonInstalls to:src/app/components/blocks/dbt-model-graph/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
ModelColumninterface ModelColumn {
name: string
dataType: string
nullable: boolean
isPrimaryKey?: boolean
isForeignKey?: boolean
foreignKeyRef?: string
isClusterKey?: boolean
tests: Array<{
name: 'unique' | 'not_null' | 'relationships' | 'accepted_values'
status: 'pass' | 'warn' | 'fail'
detail?: string
}>
testDuration: string
description: string
}UpstreamNodeinterface UpstreamNode {
id: string
name: string
type: 'view' | 'table' | 'source'
package: string
rowCount: string
freshness: string
freshnessStatus: 'pass' | 'warn'
description: string
}DownstreamNodeinterface DownstreamNode {
id: string
name: string
type: 'table' | 'incremental' | 'exposure'
package: string
consumers: string
sla: string
description: string
}Files installed (2)
src/app/components/blocks/dbt-model-graph/dbt-model-graph.component.ts52.2 kB
import { Component, DestroyRef, Input, computed, inject, signal, ChangeDetectionStrategy } from '@angular/core' import { ArrowUpRight, Check, CheckCircle2, Clock, Code2, Copy, Database, FileCode, GitBranch, HardDrive, Info, Key, Layers, Link2, Loader2, LucideAngularModule, Play, Search, ShieldCheck, Table2, Tag, User, Workflow, Zap, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiCardComponent } from '@/ui/card/card.component' import { UiInputComponent } from '@/ui/input/input.component' import { UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, } from '@/ui/table/table.component' import { UiTabsComponent, UiTabsContentComponent, UiTabsListComponent, UiTabsTriggerComponent, } from '@/ui/tabs/tabs.component' export type DbtModelGraphTab = 'model-sql' | 'compiled-sql' | 'contract' export interface ModelColumn { name: string dataType: string nullable: boolean isPrimaryKey?: boolean isForeignKey?: boolean foreignKeyRef?: string isClusterKey?: boolean tests: Array<{ name: 'unique' | 'not_null' | 'relationships' | 'accepted_values' status: 'pass' | 'warn' | 'fail' detail?: string }> testDuration: string description: string } export interface UpstreamNode { id: string name: string type: 'view' | 'table' | 'source' package: string rowCount: string freshness: string freshnessStatus: 'pass' | 'warn' description: string } export interface DownstreamNode { id: string name: string type: 'table' | 'incremental' | 'exposure' package: string consumers: string sla: string description: string } const modelSqlSource = `{{ config( materialized = 'incremental', unique_key = 'order_id', on_schema_change = 'sync_all_columns', incremental_strategy = 'merge', cluster_by = ['order_date', 'customer_id'], tags = ['finance', 'core', 'daily_sla'] ) }} WITH payments AS ( SELECT payment_id, order_id, payment_method, amount_usd, gateway_fee_usd, status AS payment_status, created_at AS payment_created_at FROM {{ ref('stg_stripe__payments') }} {% if is_incremental() %} WHERE updated_at >= (SELECT coalesce(max(payment_created_at), '1970-01-01') FROM {{ this }}) {% endif %} ), orders_source AS ( SELECT order_id, customer_id, order_number, order_status, total_amount_usd, tax_amount_usd, discount_amount_usd, currency_code, order_date, created_at, updated_at FROM {{ ref('stg_shopify__orders') }} {% if is_incremental() %} WHERE updated_at >= (SELECT coalesce(max(updated_at), '1970-01-01') FROM {{ this }}) {% endif %} ), customers AS ( SELECT customer_id, customer_tier, country_code, lifetime_order_count FROM {{ ref('dim_customers') }} ), aggregated_payments AS ( SELECT order_id, sum(case when payment_status = 'succeeded' then amount_usd else 0 end) as total_paid_usd, sum(gateway_fee_usd) as total_fees_usd, count(payment_id) as payment_attempts_count, max(payment_created_at) as last_payment_at FROM payments GROUP BY 1 ), final_model AS ( SELECT ord.order_id, ord.order_number, ord.customer_id, cust.customer_tier, cust.country_code, ord.order_status, ord.order_date, ord.currency_code, ord.total_amount_usd, ord.tax_amount_usd, ord.discount_amount_usd, coalesce(pay.total_paid_usd, 0.00) as total_paid_usd, coalesce(pay.total_fees_usd, 0.00) as payment_fees_usd, round(ord.total_amount_usd - coalesce(pay.total_fees_usd, 0.00), 2) as net_revenue_usd, case when pay.total_paid_usd >= ord.total_amount_usd then 'fully_paid' when pay.total_paid_usd > 0 then 'partially_paid' else 'unpaid' end as payment_settlement_status, coalesce(pay.payment_attempts_count, 0) as payment_attempts, ord.created_at, ord.updated_at FROM orders_source ord LEFT JOIN aggregated_payments pay ON ord.order_id = pay.order_id LEFT JOIN customers cust ON ord.customer_id = cust.customer_id ) SELECT * FROM final_model` const compiledSqlSource = `-- Compiled target warehouse query: Snowflake / AWS us-east-1 -- Execution target: "ANALYTICS_PROD"."MARTS"."fct_orders" -- Incremental Merge Strategy with cluster keys [order_date, customer_id] MERGE INTO "ANALYTICS_PROD"."MARTS"."fct_orders" AS target USING ( WITH payments AS ( SELECT payment_id, order_id, payment_method, amount_usd, gateway_fee_usd, status AS payment_status, created_at AS payment_created_at FROM "ANALYTICS_PROD"."STAGING"."stg_stripe__payments" WHERE updated_at >= (SELECT coalesce(max(payment_created_at), '1970-01-01') FROM "ANALYTICS_PROD"."MARTS"."fct_orders") ), orders_source AS ( SELECT order_id, customer_id, order_number, order_status, total_amount_usd, tax_amount_usd, discount_amount_usd, currency_code, order_date, created_at, updated_at FROM "ANALYTICS_PROD"."STAGING"."stg_shopify__orders" WHERE updated_at >= (SELECT coalesce(max(updated_at), '1970-01-01') FROM "ANALYTICS_PROD"."MARTS"."fct_orders") ), customers AS ( SELECT customer_id, customer_tier, country_code, lifetime_order_count FROM "ANALYTICS_PROD"."CORE"."dim_customers" ), aggregated_payments AS ( SELECT order_id, sum(case when payment_status = 'succeeded' then amount_usd else 0 end) as total_paid_usd, sum(gateway_fee_usd) as total_fees_usd, count(payment_id) as payment_attempts_count, max(payment_created_at) as last_payment_at FROM payments GROUP BY 1 ), final_model AS ( SELECT ord.order_id, ord.order_number, ord.customer_id, cust.customer_tier, cust.country_code, ord.order_status, ord.order_date, ord.currency_code, ord.total_amount_usd, ord.tax_amount_usd, ord.discount_amount_usd, coalesce(pay.total_paid_usd, 0.00) as total_paid_usd, coalesce(pay.total_fees_usd, 0.00) as payment_fees_usd, round(ord.total_amount_usd - coalesce(pay.total_fees_usd, 0.00), 2) as net_revenue_usd, case when pay.total_paid_usd >= ord.total_amount_usd then 'fully_paid' when pay.total_paid_usd > 0 then 'partially_paid' else 'unpaid' end as payment_settlement_status, coalesce(pay.payment_attempts_count, 0) as payment_attempts, ord.created_at, ord.updated_at FROM orders_source ord LEFT JOIN aggregated_payments pay ON ord.order_id = pay.order_id LEFT JOIN customers cust ON ord.customer_id = cust.customer_id ) SELECT * FROM final_model ) AS source ON target.order_id = source.order_id WHEN MATCHED THEN UPDATE SET order_number = source.order_number, customer_id = source.customer_id, customer_tier = source.customer_tier, country_code = source.country_code, order_status = source.order_status, order_date = source.order_date, total_amount_usd = source.total_amount_usd, tax_amount_usd = source.tax_amount_usd, discount_amount_usd = source.discount_amount_usd, total_paid_usd = source.total_paid_usd, payment_fees_usd = source.payment_fees_usd, net_revenue_usd = source.net_revenue_usd, payment_settlement_status = source.payment_settlement_status, payment_attempts = source.payment_attempts, updated_at = source.updated_at WHEN NOT MATCHED THEN INSERT ( order_id, order_number, customer_id, customer_tier, country_code, order_status, order_date, currency_code, total_amount_usd, tax_amount_usd, discount_amount_usd, total_paid_usd, payment_fees_usd, net_revenue_usd, payment_settlement_status, payment_attempts, created_at, updated_at ) VALUES ( source.order_id, source.order_number, source.customer_id, source.customer_tier, source.country_code, source.order_status, source.order_date, source.currency_code, source.total_amount_usd, source.tax_amount_usd, source.discount_amount_usd, source.total_paid_usd, source.payment_fees_usd, source.net_revenue_usd, source.payment_settlement_status, source.payment_attempts, source.created_at, source.updated_at );` const upstreamNodes: UpstreamNode[] = [ { id: 'stg_stripe__payments', name: 'stg_stripe__payments', type: 'view', package: 'analytics_dw.staging.stripe', rowCount: '1,420,800 rows', freshness: 'Pass · 12m ago', freshnessStatus: 'pass', description: 'Raw webhook payment charges, refunds, and gateway fee captures from Stripe API.', }, { id: 'stg_shopify__orders', name: 'stg_shopify__orders', type: 'view', package: 'analytics_dw.staging.shopify', rowCount: '984,200 rows', freshness: 'Pass · 5m ago', freshnessStatus: 'pass', description: 'E-commerce transactional headers, tax summaries, discount allocations, and checkout states.', }, { id: 'dim_customers', name: 'dim_customers', type: 'table', package: 'analytics_dw.core', rowCount: '248,500 rows', freshness: 'Pass · 1h ago', freshnessStatus: 'pass', description: 'Type-2 slowly changing dimension master record for authenticated customer accounts.', }, ] const downstreamNodes: DownstreamNode[] = [ { id: 'mart_finance_mrr', name: 'mart_finance_mrr', type: 'table', package: 'analytics_dw.marts.finance', consumers: 'Finance Team & NetSuite Sync', sla: 'Tier 1 · Daily 06:00 UTC', description: 'Monthly recurring revenue waterfall and cohort retention analysis table.', }, { id: 'mart_executive_kpis', name: 'mart_executive_kpis', type: 'table', package: 'analytics_dw.marts.executive', consumers: 'Executive Dashboard & Board Reports', sla: 'Tier 1 · Daily 07:00 UTC', description: 'High-level business health rollup metrics: GMV, net revenue, CAC payback, and refund rates.', }, { id: 'tableau_revenue_dashboard', name: 'tableau_revenue_dashboard', type: 'exposure', package: 'exposures.bi.tableau', consumers: '120 Active BI Users', sla: 'Hourly Sync', description: 'Production executive revenue tracking workbook and daily pacing alerts.', }, ] const columns: ModelColumn[] = [ { name: 'order_id', dataType: 'STRING (VARCHAR)', nullable: false, isPrimaryKey: true, tests: [ { name: 'unique', status: 'pass' }, { name: 'not_null', status: 'pass' }, ], testDuration: '12.4ms', description: 'Surrogate primary key generated for each transactional order record.', }, { name: 'order_number', dataType: 'STRING (VARCHAR)', nullable: false, tests: [ { name: 'unique', status: 'pass' }, { name: 'not_null', status: 'pass' }, ], testDuration: '14.1ms', description: 'Human-readable sequential invoice reference number from checkout.', }, { name: 'customer_id', dataType: 'STRING (VARCHAR)', nullable: false, isForeignKey: true, foreignKeyRef: 'dim_customers.customer_id', tests: [ { name: 'not_null', status: 'pass' }, { name: 'relationships', status: 'pass', detail: 'dim_customers' }, ], testDuration: '28.6ms', description: 'Foreign key reference linking order to the verified master customer account.', }, { name: 'customer_tier', dataType: 'STRING (VARCHAR)', nullable: true, tests: [ { name: 'accepted_values', status: 'pass', detail: 'enterprise, pro, starter, free', }, ], testDuration: '9.2ms', description: 'Customer loyalty and billing subscription tier at the time of purchase.', }, { name: 'country_code', dataType: 'STRING (CHAR(2))', nullable: false, tests: [{ name: 'not_null', status: 'pass' }], testDuration: '8.4ms', description: 'ISO 3166-1 alpha-2 two-letter billing destination country code.', }, { name: 'order_status', dataType: 'STRING (VARCHAR)', nullable: false, tests: [ { name: 'accepted_values', status: 'pass', detail: 'completed, processing, shipped, cancelled, refunded', }, { name: 'not_null', status: 'pass' }, ], testDuration: '15.3ms', description: 'Current order fulfillment and delivery lifecycle state.', }, { name: 'order_date', dataType: 'DATE', nullable: false, isClusterKey: true, tests: [{ name: 'not_null', status: 'pass' }], testDuration: '7.9ms', description: 'Calendar transaction date used as primary cluster and pruning key.', }, { name: 'currency_code', dataType: 'STRING (CHAR(3))', nullable: false, tests: [ { name: 'accepted_values', status: 'pass', detail: 'USD, EUR, GBP, CAD', }, ], testDuration: '6.8ms', description: 'ISO 4217 three-letter currency code in which the charge was denominated.', }, { name: 'total_amount_usd', dataType: 'DECIMAL(12,2)', nullable: false, tests: [{ name: 'not_null', status: 'pass' }], testDuration: '10.1ms', description: 'Gross order amount in base USD currency including tax and discounts.', }, { name: 'tax_amount_usd', dataType: 'DECIMAL(10,2)', nullable: false, tests: [{ name: 'not_null', status: 'pass' }], testDuration: '9.0ms', description: 'Total sales tax and VAT amount captured for remittance.', }, { name: 'discount_amount_usd', dataType: 'DECIMAL(10,2)', nullable: false, tests: [{ name: 'not_null', status: 'pass' }], testDuration: '8.1ms', description: 'Promotional discount and coupon deductions applied at checkout.', }, { name: 'total_paid_usd', dataType: 'DECIMAL(12,2)', nullable: false, tests: [{ name: 'not_null', status: 'pass' }], testDuration: '11.3ms', description: 'Sum of all verified cleared customer payments from Stripe ledger.', }, { name: 'payment_fees_usd', dataType: 'DECIMAL(10,2)', nullable: false, tests: [{ name: 'not_null', status: 'pass' }], testDuration: '9.8ms', description: 'Merchant interchange and gateway transaction processing fees.', }, { name: 'net_revenue_usd', dataType: 'DECIMAL(12,2)', nullable: false, tests: [{ name: 'not_null', status: 'pass' }], testDuration: '10.5ms', description: 'Net recognized revenue after deducting gateway processing fees.', }, { name: 'payment_settlement_status', dataType: 'STRING (VARCHAR)', nullable: false, tests: [ { name: 'accepted_values', status: 'pass', detail: 'fully_paid, partially_paid, unpaid', }, { name: 'not_null', status: 'pass' }, ], testDuration: '8.7ms', description: 'Payment clearing and reconciliation flag calculated against order total.', }, { name: 'payment_attempts', dataType: 'INTEGER', nullable: false, tests: [{ name: 'not_null', status: 'pass' }], testDuration: '6.2ms', description: 'Number of authorization attempts recorded in the payment gateway.', }, { name: 'created_at', dataType: 'TIMESTAMP_TZ', nullable: false, tests: [{ name: 'not_null', status: 'pass' }], testDuration: '8.0ms', description: 'Initial order creation timestamp in UTC timezone.', }, { name: 'updated_at', dataType: 'TIMESTAMP_TZ', nullable: false, tests: [{ name: 'not_null', status: 'pass' }], testDuration: '8.3ms', description: 'Last modified timestamp used as incremental watermark boundary.', }, ] function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } export function highlightJinjaSqlLine(line: string): string { if (!line) return '' const escaped = escapeHtml(line) // Comments if (escaped.trim().startsWith('--') || escaped.trim().startsWith('{#')) { return `<span class="text-muted-foreground/60 italic">${escaped}</span>` } // Highlight Jinja tags: {{ ... }} and {% ... %} let result = escaped.replace(/(\{\{[\s\S]*?\}\}|\{%[\s\S]*?%\})/g, (jinjaMatch) => { const inner = jinjaMatch .replace( /\b(config|ref|source|is_incremental|this|var|materialized|unique_key|on_schema_change|incremental_strategy|cluster_by|tags)\b/g, '<span class="text-warning font-semibold">$1</span>', ) .replace(/(\{\{|\}\}|\{%|%\})/g, '<span class="text-chart-1 font-bold">$1</span>') return `<span class="bg-chart-1/10 px-1 py-0.5 rounded text-chart-1">${inner}</span>` }) // SQL Keywords result = result.replace( /\b(WITH|SELECT|FROM|JOIN|LEFT JOIN|INNER JOIN|RIGHT JOIN|FULL JOIN|ON|WHERE|GROUP BY|ORDER BY|AND|OR|NOT|AS|CASE|WHEN|THEN|ELSE|END|MERGE INTO|USING|WHEN MATCHED|WHEN NOT MATCHED|INSERT|UPDATE|SET|VALUES|OVER|PARTITION BY)\b/g, '<span class="text-info font-semibold">$1</span>', ) // SQL Functions result = result.replace( /\b(coalesce|sum|count|max|min|avg|round|cast|date_trunc|concat)\b/gi, '<span class="text-chart-2 font-medium">$1</span>', ) // Strings result = result.replace(/('[^']*')/g, '<span class="text-success">$1</span>') return result } export function highlightCompiledSqlLine(line: string): string { if (!line) return '' const escaped = escapeHtml(line) // Comments if (escaped.trim().startsWith('--')) { return `<span class="text-muted-foreground/60 italic">${escaped}</span>` } let result = escaped.replace( /\b(MERGE INTO|USING|WHEN MATCHED|WHEN NOT MATCHED|THEN UPDATE SET|THEN INSERT|VALUES|WITH|SELECT|FROM|LEFT JOIN|INNER JOIN|RIGHT JOIN|JOIN|ON|WHERE|GROUP BY|ORDER BY|AND|OR|NOT|AS|CASE|WHEN|THEN|ELSE|END)\b/g, '<span class="text-info font-semibold">$1</span>', ) result = result.replace( /\b(coalesce|sum|count|max|min|avg|round|cast)\b/gi, '<span class="text-chart-2 font-medium">$1</span>', ) // Identifiers in quotes result = result.replace(/("[A-Z0-9_]+")/g, '<span class="text-warning font-medium">$1</span>') // Strings result = result.replace(/('[^']*')/g, '<span class="text-success">$1</span>') return result } const NODE_BASE = 'focus-visible:ring-ring flex w-full cursor-pointer flex-col rounded-md border p-1.5 text-left transition-[background-color,box-shadow] focus-visible:ring-2 focus-visible:outline-none' /** * dbt Cloud style model studio: DAG lineage, model docs, Jinja / compiled SQL viewers and * the column contract test suite. Port of the React `DbtModelGraph` block, 1:1. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-dbt-model-graph, [ui-dbt-model-graph]', standalone: true, // React renders the root <div> itself: keep this wrapper out of the layout. host: { class: 'contents' }, imports: [ LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiInputComponent, UiTableComponent, UiTableHeaderComponent, UiTableBodyComponent, UiTableRowComponent, UiTableHeadComponent, UiTableCellComponent, UiTabsComponent, UiTabsListComponent, UiTabsTriggerComponent, UiTabsContentComponent, ], template: ` <div data-slot="dbt-model-graph" [class]="rootClass"> <!-- TOP HEADER --> <header class="border-border bg-card/70 border-b px-4 py-3 sm:px-6"> <div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <!-- Left: Model Identity & Badges --> <div class="flex flex-wrap items-center gap-2.5"> <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-lg shadow-xs"> <lucide-icon [img]="Workflow" class="size-4" /> </div> <div class="flex flex-wrap items-center gap-2"> <div class="flex items-center gap-1.5 font-mono text-sm font-semibold tracking-tight"> <span class="text-muted-foreground font-normal">model.analytics.</span> <span class="text-foreground">fct_orders</span> </div> <!-- Copy ref pill --> <button type="button" class="border-border bg-muted/50 text-muted-foreground hover:text-foreground hover:bg-muted inline-flex min-h-6 items-center gap-1 rounded-md border px-2 py-0.5 font-mono text-xs transition-colors" title="Copy dbt ref() macro" (click)="copyModelRef()" > @if (copiedRef()) { <lucide-icon [img]="Check" class="text-success size-3" /> } @else { <lucide-icon [img]="Copy" class="size-3" /> } <span>{{ copiedRef() ? 'Copied Ref!' : dbtMacroRef }}</span> </button> </div> <div class="flex flex-wrap items-center gap-1.5"> <ui-badge wrap variant="outline" class="gap-1 font-mono text-xs font-normal"> <lucide-icon [img]="Layers" class="text-info size-3" /> Table · Incremental </ui-badge> <ui-badge wrap variant="secondary" class="font-mono text-xs font-normal">analytics_dw v1.8</ui-badge> <ui-badge wrap variant="outline" class="border-success/30 bg-success/10 text-success gap-1.5 font-mono text-xs font-medium" > <span class="bg-success size-1.5 animate-pulse rounded-full"></span> {{ buildStatusText() }} </ui-badge> </div> </div> <!-- Right: Primary & Secondary Actions --> <div class="flex flex-wrap items-center gap-2"> <button ui-button variant="outline" size="sm" class="h-8 gap-1.5 text-xs font-medium" [disabled]="isTesting()" (click)="handleTestModel()" > @if (isTesting()) { <lucide-icon [img]="Loader2" class="size-3.5 animate-spin" /> } @else { <lucide-icon [img]="CheckCircle2" class="text-success size-3.5" /> } {{ isTesting() ? 'Running 7 Tests...' : 'Test Model (dbt test)' }} </button> <button ui-button variant="default" size="sm" class="bg-primary text-primary-foreground hover:bg-primary/90 h-8 gap-1.5 text-xs font-semibold shadow-xs" [disabled]="isBuilding()" (click)="handleBuildModel()" > @if (isBuilding()) { <lucide-icon [img]="Loader2" class="size-3.5 animate-spin" /> } @else { <lucide-icon [img]="Play" class="size-3.5 fill-current" /> } {{ isBuilding() ? 'Building (dbt run)...' : 'Build Model (dbt run)' }} </button> </div> </div> <!-- Secondary Metadata Strip --> <div class="border-border/60 text-muted-foreground mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 border-t pt-2.5 font-mono text-xs" > <div class="flex items-center gap-1.5"> <lucide-icon [img]="Database" class="text-info size-3" /> <span>Warehouse:</span> <span class="text-foreground font-medium">Snowflake PROD_WH (XS)</span> </div> <div class="flex items-center gap-1.5"> <lucide-icon [img]="HardDrive" class="text-warning size-3" /> <span>Target Schema:</span> <span class="text-foreground font-medium">ANALYTICS_PROD.MARTS</span> </div> <div class="flex items-center gap-1.5"> <lucide-icon [img]="Clock" class="text-muted-foreground size-3" /> <span>Last Built:</span> <span class="text-foreground">{{ lastBuildTime() }}</span> </div> <div class="flex items-center gap-1.5"> <lucide-icon [img]="ShieldCheck" class="text-success size-3" /> <span>Contract Tests:</span> <span class="text-success font-medium">7/7 Passing</span> </div> <div class="flex items-center gap-1.5"> <lucide-icon [img]="Table2" class="text-chart-1 size-3" /> <span>Row Count:</span> <span class="text-foreground">2,840,190 rows</span> </div> </div> </header> <!-- 2-COLUMN MODEL STUDIO LAYOUT --> <div class="divide-border grid flex-1 grid-cols-1 divide-y lg:grid-cols-12 lg:divide-x lg:divide-y-0"> <!-- LEFT PANEL: METADATA & DAG LINEAGE --> <aside class="bg-muted/15 flex flex-col space-y-4 p-4 sm:p-5 lg:col-span-5"> <!-- Interactive Lineage DAG Card --> <ui-card class="border-border bg-card overflow-hidden border p-3.5 shadow-none"> <div class="flex flex-wrap items-center justify-between pb-2.5"> <div class="flex items-center gap-1.5 text-xs font-semibold"> <lucide-icon [img]="GitBranch" class="text-primary size-3.5" /> <span>DAG Lineage Dependency Graph</span> </div> <ui-badge wrap variant="secondary" class="font-mono text-xs font-normal"> -1 Upstream · +1 Downstream </ui-badge> </div> <!-- Visual DAG Node Canvas --> <div class="border-border/70 bg-muted/30 relative rounded-lg border p-3"> <div class="grid grid-cols-3 items-center gap-2 text-xs"> <!-- Upstream Column --> <div class="space-y-1.5"> <span class="text-muted-foreground block text-center font-mono text-xs font-medium"> Upstream (3) </span> @for (node of upstreamNodes; track node.id) { <button type="button" [class]="nodeClass(node.id)" [attr.aria-pressed]="selectedNodeId() === node.id" (click)="selectedNodeId.set(node.id)" > <div class="flex flex-wrap items-center justify-between"> <span class="truncate font-mono text-xs font-medium">{{ node.name }}</span> </div> <div class="text-muted-foreground flex flex-wrap items-center justify-between pt-0.5 font-mono text-xs" > <span class="uppercase">{{ node.type }}</span> <span class="bg-success size-1.5 rounded-full"></span> </div> </button> } </div> <!-- Center Current Node --> <div class="flex flex-col items-center justify-center space-y-1 px-1"> <span class="text-primary font-mono text-xs font-semibold">Current Model</span> <button type="button" [class]="currentNodeClass()" [attr.aria-pressed]="selectedNodeId() === 'fct_orders'" (click)="selectedNodeId.set('fct_orders')" > <lucide-icon [img]="Workflow" class="text-primary mb-1 size-4" /> <span class="text-foreground font-mono text-xs font-bold">fct_orders</span> <ui-badge wrap variant="outline" class="border-primary/40 mt-1 font-mono text-xs"> incremental </ui-badge> </button> </div> <!-- Downstream Column --> <div class="space-y-1.5"> <span class="text-muted-foreground block text-center font-mono text-xs font-medium"> Downstream (3) </span> @for (node of downstreamNodes; track node.id) { <button type="button" [class]="nodeClass(node.id)" [attr.aria-pressed]="selectedNodeId() === node.id" (click)="selectedNodeId.set(node.id)" > <div class="flex flex-wrap items-center justify-between"> <span class="truncate font-mono text-xs font-medium">{{ node.name }}</span> </div> <div class="text-muted-foreground flex flex-wrap items-center justify-between pt-0.5 font-mono text-xs" > <span class="uppercase">{{ node.type }}</span> <span class="bg-success size-1.5 rounded-full"></span> </div> </button> } </div> </div> </div> </ui-card> <!-- Model Documentation & Metadata Card --> <ui-card class="border-border bg-card space-y-3 border p-4 shadow-none"> <div class="border-border flex flex-wrap items-center justify-between border-b pb-2"> <div class="flex items-center gap-1.5 text-xs font-semibold"> <lucide-icon [img]="Info" class="text-primary size-3.5" /> <span>Model Documentation & Metadata</span> </div> <ui-badge wrap variant="outline" class="font-mono text-xs">Contract Enforced</ui-badge> </div> <p class="text-muted-foreground text-xs leading-relaxed"> Core transactional orders fact table at individual order grain. Captures gross revenue metrics, sales tax breakdowns, promotional discount allocations, payment gateway fees, and settlement lifecycle stages. </p> <div class="grid grid-cols-1 gap-2 sm:grid-cols-2"> <div class="border-border/60 bg-muted/20 space-y-0.5 rounded-md border p-2 text-xs"> <span class="text-muted-foreground text-xs">Model Owner</span> <div class="flex items-center gap-1.5 font-medium"> <lucide-icon [img]="User" class="text-primary size-3" /> <span>Analytics Engineering</span> </div> </div> <div class="border-border/60 bg-muted/20 space-y-0.5 rounded-md border p-2 text-xs"> <span class="text-muted-foreground text-xs">Freshness SLA</span> <div class="text-success flex items-center gap-1.5 font-medium"> <lucide-icon [img]="Zap" class="text-warning size-3" /> <span>< 2 hours (Compliant)</span> </div> </div> </div> <!-- Tags --> <div class="space-y-1.5 pt-1"> <span class="text-muted-foreground flex items-center gap-1 text-xs"> <lucide-icon [img]="Tag" class="size-3" /> Model Tags </span> <div class="flex flex-wrap items-center gap-1.5"> <ui-badge wrap variant="secondary" class="font-mono text-xs font-normal">#finance</ui-badge> <ui-badge wrap variant="secondary" class="font-mono text-xs font-normal">#core</ui-badge> <ui-badge wrap variant="secondary" class="font-mono text-xs font-normal">#daily_sla</ui-badge> <ui-badge wrap variant="secondary" class="font-mono text-xs font-normal">#tier-1-kpi</ui-badge> </div> </div> <!-- Cluster Keys & Strategy --> <div class="border-border/60 bg-muted/30 space-y-1.5 rounded-lg border p-2.5 font-mono text-xs"> <div class="flex justify-between"> <span class="text-muted-foreground">Incremental Strategy:</span> <span class="text-foreground font-semibold">Merge (unique_key: order_id)</span> </div> <div class="flex justify-between"> <span class="text-muted-foreground">Cluster Keys:</span> <span class="text-foreground font-semibold">order_date, customer_id</span> </div> </div> </ui-card> <!-- Upstream Sources & References List --> <ui-card class="border-border bg-card space-y-2.5 border p-4 shadow-none"> <div class="flex flex-wrap items-center justify-between"> <div class="flex items-center gap-1.5 text-xs font-semibold"> <lucide-icon [img]="Link2" class="text-info size-3.5" /> <span>Upstream Sources & References ({{ upstreamNodes.length }})</span> </div> <ui-badge wrap variant="secondary" class="font-mono text-xs font-normal"> {{ upstreamNodes.length }} models </ui-badge> </div> <div class="space-y-2"> @for (node of upstreamNodes; track node.id) { <div class="border-border/70 bg-muted/20 hover:bg-muted/40 rounded-lg border p-2.5 text-xs transition-colors" > <div class="flex flex-wrap items-center justify-between"> <span class="text-foreground font-mono font-semibold">{{ node.name }}</span> <ui-badge wrap variant="outline" class="font-mono text-xs uppercase">{{ node.type }}</ui-badge> </div> <p class="text-muted-foreground mt-1 text-xs">{{ node.description }}</p> <div class="text-muted-foreground mt-2 flex flex-wrap items-center justify-between font-mono text-xs"> <span>{{ node.rowCount }}</span> <span class="text-success font-medium">{{ node.freshness }}</span> </div> </div> } </div> </ui-card> <!-- Downstream Marts & BI Exposures List --> <ui-card class="border-border bg-card space-y-2.5 border p-4 shadow-none"> <div class="flex flex-wrap items-center justify-between"> <div class="flex items-center gap-1.5 text-xs font-semibold"> <lucide-icon [img]="ArrowUpRight" class="text-warning size-3.5" /> <span>Downstream Marts & BI Exposures ({{ downstreamNodes.length }})</span> </div> <ui-badge wrap variant="secondary" class="font-mono text-xs font-normal"> {{ downstreamNodes.length }} targets </ui-badge> </div> <div class="space-y-2"> @for (node of downstreamNodes; track node.id) { <div class="border-border/70 bg-muted/20 hover:bg-muted/40 rounded-lg border p-2.5 text-xs transition-colors" > <div class="flex flex-wrap items-center justify-between"> <span class="text-foreground font-mono font-semibold">{{ node.name }}</span> <ui-badge wrap variant="secondary" class="font-mono text-xs uppercase">{{ node.type }}</ui-badge> </div> <p class="text-muted-foreground mt-1 text-xs">{{ node.description }}</p> <div class="text-muted-foreground mt-2 flex flex-wrap items-center justify-between font-mono text-xs"> <span class="truncate pr-2">Consumers: {{ node.consumers }}</span> <span class="text-foreground shrink-0 font-medium">{{ node.sla }}</span> </div> </div> } </div> </ui-card> </aside> <!-- RIGHT PANEL: TABS STUDIO --> <main class="bg-card flex flex-col overflow-hidden lg:col-span-7"> <ui-tabs [value]="activeTab()" (valueChange)="setActiveTab($event)" class="flex flex-1 flex-col"> <!-- Right Tab Header --> <div class="border-border bg-muted/30 flex flex-wrap items-center justify-between gap-2 border-b px-4 py-2"> <div ui-tabs-list class="grid h-8 grid-cols-3"> <button ui-tabs-trigger value="model-sql" class="gap-1.5 text-xs"> <lucide-icon [img]="Code2" class="text-primary size-3.5" /> <span>Model SQL</span> </button> <button ui-tabs-trigger value="compiled-sql" class="gap-1.5 text-xs"> <lucide-icon [img]="FileCode" class="text-info size-3.5" /> <span>Compiled SQL</span> </button> <button ui-tabs-trigger value="contract" class="gap-1.5 text-xs"> <lucide-icon [img]="ShieldCheck" class="text-success size-3.5" /> <span>Model Contract & Tests</span> </button> </div> <!-- Actions per active tab --> <div class="flex items-center gap-2"> @if (activeTab() === 'model-sql') { <button ui-button variant="ghost" size="sm" class="h-7 gap-1 text-xs" (click)="copySourceSql()"> @if (copiedSql()) { <lucide-icon [img]="Check" class="text-success size-3" /> } @else { <lucide-icon [img]="Copy" class="size-3" /> } <span>{{ copiedSql() ? 'Copied' : 'Copy SQL' }}</span> </button> } @if (activeTab() === 'compiled-sql') { <button ui-button variant="ghost" size="sm" class="h-7 gap-1 text-xs" (click)="copyCompiledSql()"> @if (copiedCompiled()) { <lucide-icon [img]="Check" class="text-success size-3" /> } @else { <lucide-icon [img]="Copy" class="size-3" /> } <span>{{ copiedCompiled() ? 'Copied' : 'Copy Compiled' }}</span> </button> } @if (activeTab() === 'contract') { <ui-badge wrap variant="outline" class="text-success font-mono text-xs"> {{ passingTestsCount }} Tests Passing </ui-badge> } </div> </div> <!-- TAB 1: MODEL SQL --> <ui-tabs-content value="model-sql" class="mt-0 flex flex-1 flex-col overflow-hidden"> <div class="border-border bg-muted/15 text-muted-foreground flex flex-wrap items-center justify-between border-b px-4 py-1.5 font-mono text-xs" > <div class="flex items-center gap-2"> <span class="text-foreground font-medium">fct_orders.sql</span> <span>·</span> <span>Jinja + Snowflake Dialect</span> </div> <div class="flex items-center gap-3"> <span>{{ modelSqlLines.length }} lines · {{ modelSqlSource.length }} chars</span> </div> </div> <div class="relative flex flex-1 overflow-auto bg-neutral-950 text-neutral-100 dark:bg-neutral-950"> <div class="w-11 shrink-0 overflow-hidden border-r border-neutral-800 bg-neutral-900/60 py-3 pr-2 text-right font-mono text-xs leading-relaxed text-neutral-500 select-none" > @for (line of modelSqlHtml; track $index) { <div>{{ $index + 1 }}</div> } </div> <div class="flex-1 overflow-auto p-3 font-mono text-xs leading-relaxed"> @for (html of modelSqlHtml; track $index) { <div class="font-mono whitespace-pre" [innerHTML]="html"></div> } </div> </div> </ui-tabs-content> <!-- TAB 2: COMPILED SQL --> <ui-tabs-content value="compiled-sql" class="mt-0 flex flex-1 flex-col overflow-hidden"> <div class="border-border bg-muted/15 text-muted-foreground flex flex-wrap items-center justify-between border-b px-4 py-1.5 font-mono text-xs" > <div class="flex items-center gap-2"> <span class="text-foreground font-medium"> target/compiled/analytics_dw/models/marts/fct_orders.sql </span> </div> <div class="flex items-center gap-2"> <ui-badge wrap variant="outline" class="font-mono text-xs"> Est. Cost: 0.14 Credits · 1.4 GB Scan </ui-badge> </div> </div> <div class="relative flex flex-1 overflow-auto bg-neutral-950 text-neutral-100 dark:bg-neutral-950"> <div class="w-11 shrink-0 overflow-hidden border-r border-neutral-800 bg-neutral-900/60 py-3 pr-2 text-right font-mono text-xs leading-relaxed text-neutral-500 select-none" > @for (line of compiledSqlHtml; track $index) { <div>{{ $index + 1 }}</div> } </div> <div class="flex-1 overflow-auto p-3 font-mono text-xs leading-relaxed"> @for (html of compiledSqlHtml; track $index) { <div class="font-mono whitespace-pre" [innerHTML]="html"></div> } </div> </div> </ui-tabs-content> <!-- TAB 3: MODEL CONTRACT & TESTS --> <ui-tabs-content value="contract" class="mt-0 flex flex-1 flex-col space-y-4 overflow-hidden p-4 sm:p-5"> <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div class="relative w-full sm:w-72"> <lucide-icon [img]="Search" class="text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" /> <ui-input [value]="columnSearch()" (valueChange)="columnSearch.set($event)" placeholder="Filter columns or tests..." class="h-8 pl-8 font-mono text-xs" /> </div> <div class="flex flex-wrap items-center gap-2"> <ui-badge wrap variant="outline" class="text-success gap-1 font-mono text-xs"> <lucide-icon [img]="ShieldCheck" class="size-3" /> contract.enforced: true </ui-badge> <ui-badge wrap variant="secondary" class="font-mono text-xs"> {{ filteredColumns().length }} of {{ columns.length }} columns </ui-badge> </div> </div> <ui-card class="border-border flex-1 overflow-hidden border shadow-none"> <div class="overflow-x-auto"> <div ui-table> <thead ui-table-header> <tr ui-table-row class="bg-muted/40 hover:bg-muted/40"> <th ui-table-head class="text-xs font-semibold">Column Name</th> <th ui-table-head class="text-xs font-semibold">Data Type</th> <th ui-table-head class="text-xs font-semibold">Nullability</th> <th ui-table-head class="text-xs font-semibold">Active Tests & Constraints</th> <th ui-table-head class="text-xs font-semibold">Latency</th> <th ui-table-head class="text-xs font-semibold">Description</th> </tr> </thead> <tbody ui-table-body> @for (col of filteredColumns(); track col.name) { <tr ui-table-row class="text-xs"> <td ui-table-cell class="font-mono font-medium"> <div class="flex items-center gap-1.5"> @if (col.isPrimaryKey) { <lucide-icon [img]="Key" class="text-warning size-3.5 shrink-0" /> } @else if (col.isForeignKey) { <lucide-icon [img]="Link2" class="text-info size-3.5 shrink-0" /> } <span [class]="col.isPrimaryKey ? 'text-warning font-semibold' : ''">{{ col.name }}</span> </div> </td> <td ui-table-cell> <ui-badge wrap variant="secondary" class="font-mono text-xs font-normal"> {{ col.dataType }} </ui-badge> </td> <td ui-table-cell> @if (!col.nullable) { <ui-badge wrap variant="outline" class="text-muted-foreground font-mono text-xs"> NOT NULL </ui-badge> } @else { <span class="text-muted-foreground font-mono text-xs">NULLABLE</span> } </td> <td ui-table-cell> <div class="flex flex-wrap items-center gap-1"> @if (col.isPrimaryKey) { <ui-badge wrap class="border-warning/30 bg-warning/15 text-warning font-mono text-xs"> PK </ui-badge> } @if (col.isClusterKey) { <ui-badge wrap variant="secondary" class="border-chart-1/30 bg-chart-1/15 text-chart-1 font-mono text-xs" > CLUSTER </ui-badge> } @for (test of col.tests; track test.name) { <ui-badge wrap variant="secondary" class="gap-1 font-mono text-xs"> <span class="bg-success size-1.5 rounded-full"></span> {{ test.name }}{{ test.detail ? ' (' + test.detail + ')' : '' }} </ui-badge> } </div> </td> <td ui-table-cell class="text-muted-foreground font-mono text-xs">{{ col.testDuration }}</td> <td ui-table-cell class="text-muted-foreground max-w-[220px] truncate text-xs"> {{ col.description }} </td> </tr> } </tbody> </div> </div> </ui-card> <!-- Bottom Summary Banner --> <div class="border-border bg-muted/20 flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3 text-xs" > <div class="flex items-center gap-2"> <lucide-icon [img]="ShieldCheck" class="text-success size-4" /> <span class="text-foreground font-medium">Model Contract Coverage: 100% Enforced</span> </div> <div class="text-muted-foreground flex items-center gap-4 font-mono text-xs"> <span>18 Columns</span> <span>·</span> <span>21 Assertions</span> <span>·</span> <span class="text-success font-semibold">0 Failures</span> </div> </div> </ui-tabs-content> </ui-tabs> </main> </div> </div> `, }) export class UiDbtModelGraphComponent { @Input('class') className?: string readonly activeTab = signal<DbtModelGraphTab>('model-sql') readonly columnSearch = signal('') readonly isBuilding = signal(false) readonly isTesting = signal(false) readonly copiedRef = signal(false) readonly copiedSql = signal(false) readonly copiedCompiled = signal(false) readonly buildStatusText = signal('Success · Built in 14.2s') readonly lastBuildTime = signal('12 mins ago') readonly selectedNodeId = signal('fct_orders') readonly dbtMacroRef = "{{ ref('fct_orders') }}" readonly modelSqlSource = modelSqlSource readonly modelSqlLines = modelSqlSource.split('\n') readonly modelSqlHtml = this.modelSqlLines.map(highlightJinjaSqlLine) readonly compiledSqlHtml = compiledSqlSource.split('\n').map(highlightCompiledSqlLine) readonly upstreamNodes = upstreamNodes readonly downstreamNodes = downstreamNodes readonly columns = columns readonly passingTestsCount = columns.reduce( (acc, col) => acc + col.tests.filter((t) => t.status === 'pass').length, 0, ) readonly filteredColumns = computed(() => { const query = this.columnSearch().trim().toLowerCase() if (!query) return columns return columns.filter( (col) => col.name.toLowerCase().includes(query) || col.dataType.toLowerCase().includes(query) || col.description.toLowerCase().includes(query) || col.tests.some( (t) => t.name.toLowerCase().includes(query) || (t.detail && t.detail.toLowerCase().includes(query)), ), ) }) protected readonly ArrowUpRight = ArrowUpRight protected readonly Check = Check protected readonly CheckCircle2 = CheckCircle2 protected readonly Clock = Clock protected readonly Code2 = Code2 protected readonly Copy = Copy protected readonly Database = Database protected readonly FileCode = FileCode protected readonly GitBranch = GitBranch protected readonly HardDrive = HardDrive protected readonly Info = Info protected readonly Key = Key protected readonly Layers = Layers protected readonly Link2 = Link2 protected readonly Loader2 = Loader2 protected readonly Play = Play protected readonly Search = Search protected readonly ShieldCheck = ShieldCheck protected readonly Table2 = Table2 protected readonly Tag = Tag protected readonly User = User protected readonly Workflow = Workflow protected readonly Zap = Zap private readonly timers = new Set<ReturnType<typeof setTimeout>>() constructor() { inject(DestroyRef).onDestroy(() => this.timers.forEach((t) => clearTimeout(t))) } get rootClass(): string { return cn( 'bg-background text-foreground border-border flex w-full flex-col overflow-hidden rounded-xl border shadow-xs', this.className, ) } nodeClass(id: string): string { return cn( NODE_BASE, this.selectedNodeId() === id ? 'border-primary bg-primary/10 text-primary shadow-xs' : 'border-border bg-background hover:bg-muted/80 text-foreground', ) } currentNodeClass(): string { return cn( 'border-primary bg-primary/10 ring-primary/20 focus-visible:ring-ring flex w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 p-2.5 text-center shadow-xs ring-2 focus-visible:ring-2 focus-visible:outline-none', this.selectedNodeId() === 'fct_orders' && 'ring-primary/40', ) } setActiveTab(value: string): void { if (value === 'model-sql' || value === 'compiled-sql' || value === 'contract') this.activeTab.set(value) } handleBuildModel(): void { if (this.isBuilding()) return this.isBuilding.set(true) this.later(1200, () => { this.isBuilding.set(false) this.buildStatusText.set('Success · Built in 14.2s (Just now)') this.lastBuildTime.set('Just now') }) } handleTestModel(): void { if (this.isTesting()) return this.isTesting.set(true) this.later(900, () => this.isTesting.set(false)) } copyModelRef(): void { this.copy(this.dbtMacroRef, this.copiedRef) } copySourceSql(): void { this.copy(modelSqlSource, this.copiedSql) } copyCompiledSql(): void { this.copy(compiledSqlSource, this.copiedCompiled) } private copy(text: string, flag: { set(v: boolean): void }): void { if (typeof navigator !== 'undefined' && navigator.clipboard) { navigator.clipboard.writeText(text).catch(() => {}) flag.set(true) this.later(2000, () => flag.set(false)) } } private later(ms: number, fn: () => void): void { const t = setTimeout(() => { this.timers.delete(t) fn() }, ms) this.timers.add(t) } }src/app/components/blocks/dbt-model-graph/index.ts0.2 kB
export { UiDbtModelGraphComponent, highlightJinjaSqlLine, highlightCompiledSqlLine, type DbtModelGraphTab, type ModelColumn, type UpstreamNode, type DownstreamNode, } from './dbt-model-graph.component'
Raw manifest:https://uipkge.dev/r/angular/dbt-model-graph.json