Spark Job Stage Visualizer
blockdevopsDatabricks and Apache Spark distributed job execution DAG visualizer with stage performance metrics, task skew distribution quantiles, and executor JVM GC telemetry.
Installation
$pnpm dlx uipkge-ng@latest add spark-job-stage-visualizer$npx uipkge-ng@latest add spark-job-stage-visualizer$yarn dlx uipkge-ng@latest add spark-job-stage-visualizer$bunx uipkge-ng@latest add spark-job-stage-visualizerManifest:
https://uipkge.dev/r/angular/spark-job-stage-visualizer.jsonInstalls to:src/app/components/blocks/spark-job-stage-visualizer/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.
SparkStageSummaryinterface SparkStageSummary {
id: number
name: string
status: 'succeeded' | 'running' | 'pending'
tasksTotal: number
tasksSucceeded: number
duration: string
skewRatio: number
quantiles: { min: number; p25: number; median: number; p75: number; p95: number; max: number }
}SparkExecutorSummaryinterface SparkExecutorSummary {
id: string
host: string
role: 'driver' | 'executor'
status: string
cores: number
memoryUsedGB: number
memoryTotalGB: number
memoryPercent: number
gcTimeSec: number
gcPercent: number
tasksCompleted: number
spillGB: number
}AqeRuleSummaryinterface AqeRuleSummary {
id: string
name: string
status: 'applied' | 'skipped'
description: string
impact: string
}Files installed (2)
src/app/components/blocks/spark-job-stage-visualizer/spark-job-stage-visualizer.component.ts26 kB
// Databricks and Apache Spark distributed job execution DAG visualizer with stage // performance metrics, task skew distribution quantiles, and executor JVM GC telemetry. // Port of the Vue block 1:1 (multi-file Vue block consolidated into one standalone component). import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core' import { ArrowRightLeft, Check, CheckCircle2, Clock, Copy, Cpu, Download, Flame, Gauge, LucideAngularModule, Server, ShieldAlert, X, 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, UiCardContentComponent, UiCardHeaderComponent, UiCardTitleComponent, } from '@/ui/card/card.component' import { UiProgressComponent } from '@/ui/progress/progress.component' import { UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, } from '@/ui/table/table.component' import { UiTabsComponent, UiTabsListComponent, UiTabsTriggerComponent } from '@/ui/tabs/tabs.component' export type SparkTab = 'dag' | 'skew' | 'executors' | 'aqe' interface SparkStageSummary { id: number name: string status: 'succeeded' | 'running' | 'pending' tasksTotal: number tasksSucceeded: number duration: string skewRatio: number quantiles: { min: number; p25: number; median: number; p75: number; p95: number; max: number } } interface SparkExecutorSummary { id: string host: string role: 'driver' | 'executor' status: string cores: number memoryUsedGB: number memoryTotalGB: number memoryPercent: number gcTimeSec: number gcPercent: number tasksCompleted: number spillGB: number } interface AqeRuleSummary { id: string name: string status: 'applied' | 'skipped' description: string impact: string } const defaultStages: SparkStageSummary[] = [ { id: 0, name: 'FileScan parquet', status: 'succeeded', tasksTotal: 540, tasksSucceeded: 540, duration: '18s', skewRatio: 1.12, quantiles: { min: 0.8, p25: 1.2, median: 1.6, p75: 1.8, p95: 2.0, max: 2.1 }, }, { id: 1, name: 'Exchange hashpartitioning', status: 'succeeded', tasksTotal: 200, tasksSucceeded: 200, duration: '42s', skewRatio: 1.15, quantiles: { min: 1.8, p25: 2.4, median: 2.9, p75: 3.2, p95: 3.4, max: 3.6 }, }, { id: 2, name: 'HashAggregate & Filter', status: 'succeeded', tasksTotal: 200, tasksSucceeded: 200, duration: '1m 12s', skewRatio: 1.18, quantiles: { min: 2.1, p25: 2.8, median: 3.5, p75: 3.9, p95: 4.1, max: 4.2 }, }, { id: 3, name: 'SortMergeJoin', status: 'succeeded', tasksTotal: 200, tasksSucceeded: 200, duration: '56s', skewRatio: 1.09, quantiles: { min: 1.9, p25: 2.5, median: 3.1, p75: 3.4, p95: 3.7, max: 3.8 }, }, ] const defaultExecutors: SparkExecutorSummary[] = [ { id: 'driver', host: '10.0.12.4', role: 'driver', status: 'active', cores: 16, memoryUsedGB: 24.2, memoryTotalGB: 32, memoryPercent: 75.6, gcTimeSec: 4.2, gcPercent: 1.8, tasksCompleted: 285, spillGB: 0.36, }, { id: 'exec-1', host: '10.0.12.18', role: 'executor', status: 'active', cores: 32, memoryUsedGB: 28.8, memoryTotalGB: 32, memoryPercent: 90.0, gcTimeSec: 5.1, gcPercent: 2.2, tasksCompleted: 284, spillGB: 0.42, }, { id: 'exec-2', host: '10.0.12.29', role: 'executor', status: 'active', cores: 32, memoryUsedGB: 21.4, memoryTotalGB: 32, memoryPercent: 66.8, gcTimeSec: 4.8, gcPercent: 2.3, tasksCompleted: 286, spillGB: 0.34, }, { id: 'exec-3', host: '10.0.12.44', role: 'executor', status: 'active', cores: 32, memoryUsedGB: 18.6, memoryTotalGB: 32, memoryPercent: 58.1, gcTimeSec: 4.3, gcPercent: 2.1, tasksCompleted: 285, spillGB: 0.36, }, ] const defaultAqeRules: AqeRuleSummary[] = [ { id: 'aqe-1', name: 'CoalesceShufflePartitions', status: 'applied', description: 'Coalesced 540 initial map partitions down to 200 target shuffle partitions based on target partition size (64 MB).', impact: 'Reduced network RPC overhead by 63% and avoided 340 small shuffle partition files.', }, { id: 'aqe-2', name: 'OptimizeSkewedJoin', status: 'applied', description: 'Dynamic skew detection evaluated all 200 partition sizes against the 5x median threshold.', impact: 'Max partition skew ratio remained well balanced at 1.18x. No partition splitting required.', }, { id: 'aqe-3', name: 'DemoteBroadcastHashJoin', status: 'skipped', description: 'Evaluated right dimension relation size (3.1 GB) against auto-broadcast threshold (10 MB).', impact: 'Relation exceeded broadcast threshold; preserved robust SortMergeJoin strategy.', }, { id: 'aqe-4', name: 'EliminateSubqueryAliases', status: 'applied', description: 'Inlined and pruned unused redundant subquery attribute references from execution DAG.', impact: 'Streamlined Catalyst physical plan tree depth from 14 nodes down to 8 nodes.', }, ] @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-spark-job-stage-visualizer, [ui-spark-job-stage-visualizer]', standalone: true, // Vue 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, UiCardHeaderComponent, UiCardTitleComponent, UiProgressComponent, UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, UiTabsComponent, UiTabsListComponent, UiTabsTriggerComponent, ], template: ` <div data-slot="spark-job-stage-visualizer" [class]="rootClass"> <!-- Header Section --> <div class="border-border bg-card rounded-xl border p-5 shadow-xs transition-colors"> <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <!-- Title & Identifiers --> <div class="space-y-1.5"> <div class="flex flex-wrap items-center gap-2.5"> <div class="flex size-9 items-center justify-center rounded-lg bg-orange-500/10 text-orange-600 dark:text-orange-400" > <lucide-icon [img]="Flame" class="size-5" /> </div> <div> <div class="flex flex-wrap items-center gap-2"> <h1 class="text-base font-semibold tracking-tight sm:text-lg">AggregatedCustomerLTVJob</h1> <span ui-badge wrap variant="outline" class="text-muted-foreground font-mono text-xs"> app-20260821-142850-0042 </span> <button ui-button variant="ghost" size="xs" class="text-muted-foreground hover:text-foreground h-6 px-1.5 text-xs" aria-label="Copy Spark Application ID" (click)="copyAppId()" > @if (copiedAppId()) { <lucide-icon [img]="Check" class="text-success size-3" /> } @else { <lucide-icon [img]="Copy" class="size-3" /> } <span class="sr-only">Copy Application ID</span> </button> </div> <p class="text-muted-foreground text-xs">Apache Spark 3.5.1 · Adaptive Query Execution (AQE) Enabled</p> </div> </div> <!-- Metadata Tags --> <div class="text-muted-foreground flex flex-wrap items-center gap-x-4 gap-y-1 pt-1 text-xs"> <div class="flex items-center gap-1.5"> <span class="bg-success size-2 rounded-full"></span> <span class="text-foreground font-medium">Job Succeeded</span> <span>·</span> <span class="font-mono tabular-nums">03m:42s</span> </div> <div class="flex items-center gap-1"> <lucide-icon [img]="Server" class="size-3.5" /> <span> Cluster: <strong class="text-foreground font-medium">c5d.4xlarge (4 nodes · 112 vCPUs)</strong> </span> </div> <div class="flex items-center gap-1"> <lucide-icon [img]="Clock" class="size-3.5" /> <span>Started: <strong class="text-foreground font-medium">14:28:50 UTC</strong></span> </div> </div> </div> <!-- Action Buttons --> <div class="flex flex-wrap items-center gap-2"> <button ui-button aria-label="Download attachment" variant="outline" size="sm" class="h-8 gap-1.5 text-xs" [disabled]="downloadStarted()" (click)="triggerDownload()" > @if (downloadStarted()) { <lucide-icon [img]="CheckCircle2" class="text-success size-3.5" /> } @else { <lucide-icon [img]="Download" class="size-3.5" /> } <span>{{ downloadStarted() ? 'Log Exported' : 'Download EventLog' }}</span> </button> <button ui-button variant="ghost" size="sm" class="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 gap-1.5 text-xs" (click)="showKillConfirm.set(!showKillConfirm())" > <lucide-icon [img]="X" class="size-3.5" /> <span>Kill Application</span> </button> </div> </div> <!-- Kill Confirmation Alert (Collapsible) --> @if (showKillConfirm()) { <div class="border-destructive/30 bg-destructive/5 text-destructive mt-4 flex items-center justify-between rounded-lg border p-3 text-xs" > <div class="flex flex-wrap items-center gap-2"> <lucide-icon [img]="ShieldAlert" class="size-4 shrink-0" /> <span> This application is in finished state (SUCCEEDED). Terminating an inactive application will release log retention locks. </span> </div> <div class="flex flex-wrap items-center gap-2"> <button ui-button size="xs" variant="destructive" (click)="showKillConfirm.set(false)"> Confirm Release </button> <button ui-button size="xs" variant="ghost" (click)="showKillConfirm.set(false)">Cancel</button> </div> </div> } </div> <!-- 4 Spark Job Execution KPI Cards --> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div ui-card class="border-border shadow-xs"> <div ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2"> <h3 ui-card-title class="text-muted-foreground text-xs font-medium">Total Stages Completed</h3> <div class="bg-success/10 text-success flex size-7 items-center justify-center rounded-md"> <lucide-icon [img]="CheckCircle2" class="size-4" /> </div> </div> <div ui-card-content class="space-y-2"> <div class="text-foreground font-mono text-2xl font-bold tracking-tight">6 / 6</div> <div class="flex flex-wrap items-center gap-2"> <span ui-badge wrap variant="success" class="text-xs">6 / 6 Stages Succeeded</span> </div> <p class="text-muted-foreground text-xs">0 failed · 0 skipped · 4 stages active in DAG</p> </div> </div> <div ui-card class="border-border shadow-xs"> <div ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2"> <h3 ui-card-title class="text-muted-foreground text-xs font-medium">Shuffle Read / Write Volume</h3> <div class="bg-info/10 text-info flex size-7 items-center justify-center rounded-md"> <lucide-icon [img]="ArrowRightLeft" class="size-4" /> </div> </div> <div ui-card-content class="space-y-2"> <div class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums">42.8 GB</div> <div class="text-muted-foreground flex items-center gap-1.5 text-xs"> <span class="text-foreground font-medium">42.8 GB Shuffle Write</span> <span>·</span> <span class="text-warning font-mono">1.48 GB Spill (Memory)</span> </div> <p class="text-muted-foreground text-xs">38.6 GB shuffle read · 0 B disk spill</p> </div> </div> <div ui-card class="border-border shadow-xs"> <div ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2"> <h3 ui-card-title class="text-muted-foreground text-xs font-medium">Task Skew Max/Median Ratio</h3> <div class="bg-success/10 text-success flex size-7 items-center justify-center rounded-md"> <lucide-icon [img]="Gauge" class="size-4" /> </div> </div> <div ui-card-content class="space-y-2"> <div class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums">1.18x</div> <div class="flex flex-wrap items-center gap-2"> <span ui-badge wrap variant="success" class="text-xs">1.18x Ratio · Well Balanced</span> </div> <p class="text-muted-foreground text-xs"> Max task: <span class="text-foreground font-mono font-medium">4.2s</span> · Median: <span class="text-foreground font-mono font-medium">3.5s</span> </p> </div> </div> <div ui-card class="border-border shadow-xs"> <div ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2"> <h3 ui-card-title class="text-muted-foreground text-xs font-medium">Executor JVM GC Time</h3> <div class="bg-chart-1/10 text-chart-1 flex size-7 items-center justify-center rounded-md"> <lucide-icon [img]="Cpu" class="size-4" /> </div> </div> <div ui-card-content class="space-y-2"> <div class="text-foreground font-mono text-2xl font-bold tracking-tight tabular-nums">2.1%</div> <div class="flex flex-wrap items-center gap-2"> <span ui-badge wrap variant="success" class="text-xs">2.1% of Total CPU Time · Healthy</span> </div> <p class="text-muted-foreground text-xs">18.4s GC time across 880s CPU total</p> </div> </div> </div> <!-- Main Content Tabs --> <ui-tabs [value]="activeTab()" (valueChange)="setActiveTab($event)" class="space-y-4"> <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <ui-tabs-list class="grid w-full grid-cols-2 sm:w-auto sm:grid-cols-4"> <button ui-tabs-trigger value="dag" class="text-xs">Stages DAG Flow</button> <button ui-tabs-trigger value="skew" class="text-xs">Task Skew & Quantiles</button> <button ui-tabs-trigger value="executors" class="text-xs">Executors (4)</button> <button ui-tabs-trigger value="aqe" class="text-xs">AQE Optimizer</button> </ui-tabs-list> <div class="text-muted-foreground flex flex-wrap items-center gap-2 text-xs"> <span class="bg-primary inline-block size-2 rounded-full"></span> <span>Active Selection:</span> <span class="text-foreground font-medium">Stage {{ selectedStage().id }} ({{ selectedStage().name }})</span> </div> </div> <!-- TAB 1: STAGE DAG FLOW --> @if (activeTab() === 'dag') { <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"> @for (stage of allStages(); track stage.id) { <button type="button" [class]="stageCardClass(stage.id)" (click)="selectStage(stage.id)"> <div class="flex items-center justify-between"> <span class="font-mono text-xs font-semibold">Stage {{ stage.id }}</span> <span ui-badge [variant]="stage.status === 'succeeded' ? 'success' : 'secondary'" class="text-xs"> {{ stage.status }} </span> </div> <p class="text-foreground mt-1.5 text-left text-sm font-semibold">{{ stage.name }}</p> <p class="text-muted-foreground mt-1 font-mono text-xs tabular-nums"> {{ stage.tasksSucceeded }}/{{ stage.tasksTotal }} tasks · {{ stage.duration }} </p> <div ui-progress [value]="(stage.tasksSucceeded / stage.tasksTotal) * 100" class="mt-2 h-1.5"></div> </button> } </div> } <!-- TAB 2: TASK SKEW & QUANTILES --> @if (activeTab() === 'skew') { <div ui-card class="border-border shadow-xs"> <div ui-card-header class="pb-2"> <div class="flex flex-wrap items-center justify-between gap-2"> <h3 ui-card-title class="text-sm font-semibold"> Task Duration Quantiles — Stage {{ selectedStage().id }} ({{ selectedStage().name }}) </h3> <span ui-badge variant="outline" class="font-mono text-xs"> Skew {{ selectedStage().skewRatio.toFixed(2) }}x </span> </div> </div> <div ui-card-content class="space-y-3"> @for (q of quantileRows(); track q.label) { <div class="flex items-center gap-3 text-xs"> <span class="text-muted-foreground w-14 shrink-0 font-mono">{{ q.label }}</span> <div class="bg-muted/60 relative h-2.5 flex-1 overflow-hidden rounded-full"> <div class="bg-primary absolute top-0 bottom-0 left-0 rounded-full" [style.width.%]="q.pct"></div> </div> <span class="text-foreground w-12 shrink-0 text-right font-mono tabular-nums">{{ q.value }}s</span> </div> } <div class="flex flex-wrap gap-2 pt-1"> @for (stage of allStages(); track stage.id) { <button ui-button [variant]="selectedStageId() === stage.id ? 'default' : 'outline'" size="xs" class="font-mono text-xs" (click)="selectStage(stage.id)" > Stage {{ stage.id }} </button> } </div> </div> </div> } <!-- TAB 3: EXECUTORS --> @if (activeTab() === 'executors') { <div ui-card class="border-border shadow-xs"> <div ui-card-header class="pb-2"> <div class="flex flex-wrap items-center justify-between gap-2"> <h3 ui-card-title class="text-sm font-semibold"> Active Cluster Executors (4 Nodes · 112 Cores Total) </h3> <span ui-badge variant="success" class="font-mono text-xs">All Healthy</span> </div> </div> <div ui-card-content class="overflow-x-auto p-0"> <table 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">Executor</th> <th ui-table-head class="text-xs font-semibold">Host</th> <th ui-table-head class="text-right text-xs font-semibold">Cores</th> <th ui-table-head class="text-right text-xs font-semibold">Memory</th> <th ui-table-head class="text-right text-xs font-semibold">GC Pause Time</th> <th ui-table-head class="text-right text-xs font-semibold">Tasks</th> </tr> </thead> <tbody ui-table-body> @for (exec of executors(); track exec.id) { <tr ui-table-row class="text-xs"> <td ui-table-cell class="font-mono font-semibold">{{ exec.id }}</td> <td ui-table-cell class="font-mono">{{ exec.host }}</td> <td ui-table-cell class="text-right font-mono tabular-nums">{{ exec.cores }}</td> <td ui-table-cell class="text-right font-mono tabular-nums"> {{ exec.memoryUsedGB.toFixed(1) }}/{{ exec.memoryTotalGB }} GB </td> <td ui-table-cell class="text-right font-mono tabular-nums"> {{ exec.gcTimeSec.toFixed(1) }}s ({{ exec.gcPercent.toFixed(1) }}%) </td> <td ui-table-cell class="text-right font-mono tabular-nums">{{ exec.tasksCompleted }}</td> </tr> } </tbody> </table> </div> </div> } <!-- TAB 4: AQE OPTIMIZER --> @if (activeTab() === 'aqe') { <div class="space-y-3"> <div ui-card class="border-border shadow-xs"> <div ui-card-header class="pb-2"> <div class="flex flex-wrap items-center justify-between gap-2"> <h3 ui-card-title class="text-base font-semibold">Adaptive Query Execution (AQE) Rules Engine</h3> <span ui-badge variant="success" class="font-mono text-xs">AQE Status: Active (3 Rules Applied)</span> </div> <p class="text-muted-foreground text-xs"> Catalyst optimizer rewrites evaluated at runtime against live shuffle statistics. </p> </div> </div> @for (rule of aqeRules(); track rule.id) { <div class="border-border bg-card space-y-3 rounded-xl border p-4"> <div class="flex flex-wrap items-center justify-between gap-2"> <span class="text-foreground font-mono text-sm font-semibold">{{ rule.name }}</span> <span ui-badge [variant]="rule.status === 'applied' ? 'success' : 'outline'" class="font-mono text-xs" > {{ rule.status }} </span> </div> <p class="text-muted-foreground text-xs">{{ rule.description }}</p> <p class="text-xs"> <lucide-icon [img]="Zap" class="text-warning mr-1 inline size-3.5" /> <span class="text-muted-foreground">{{ rule.impact }}</span> </p> </div> } </div> } </ui-tabs> </div> `, }) export class UiSparkJobStageVisualizerComponent { protected readonly ArrowRightLeft = ArrowRightLeft protected readonly Check = Check protected readonly CheckCircle2 = CheckCircle2 protected readonly Clock = Clock protected readonly Copy = Copy protected readonly Cpu = Cpu protected readonly Download = Download protected readonly Flame = Flame protected readonly Gauge = Gauge protected readonly Server = Server protected readonly ShieldAlert = ShieldAlert protected readonly X = X protected readonly Zap = Zap @Input() set initialStageId(v: number) { if (v !== undefined && v !== null) this.selectedStageId.set(v) } @Input() set stages(v: SparkStageSummary[] | undefined) { if (v) this.stageList.set(v) } @Input('class') className?: string private readonly stageList = signal<SparkStageSummary[]>(defaultStages) readonly executors = signal<SparkExecutorSummary[]>(defaultExecutors) readonly aqeRules = signal<AqeRuleSummary[]>(defaultAqeRules) readonly selectedStageId = signal(2) readonly activeTab = signal<SparkTab>('dag') readonly copiedAppId = signal(false) readonly showKillConfirm = signal(false) readonly downloadStarted = signal(false) readonly allStages = computed(() => this.stageList()) readonly selectedStage = computed<SparkStageSummary>(() => { const list = this.stageList() return list.find((s) => s.id === this.selectedStageId()) ?? list[2] ?? list[0]! }) readonly quantileRows = computed(() => { const q = this.selectedStage().quantiles const max = q.max || 1 const rows = [ { label: 'min', value: q.min }, { label: 'p25', value: q.p25 }, { label: 'median', value: q.median }, { label: 'p75', value: q.p75 }, { label: 'p95', value: q.p95 }, { label: 'max', value: q.max }, ] return rows.map((r) => ({ ...r, value: r.value.toFixed(1), pct: Math.min(100, (r.value / max) * 100) })) }) get rootClass(): string { return cn('text-foreground w-full space-y-6 font-sans antialiased', this.className) } stageCardClass(id: number): string { return cn( 'rounded-xl border p-4 text-left transition-colors cursor-pointer', this.selectedStageId() === id ? 'border-primary bg-primary/5 ring-primary shadow-xs ring-1' : 'border-border bg-card hover:border-primary/40', ) } setActiveTab(value: string): void { this.activeTab.set(value as SparkTab) } selectStage(id: number): void { this.selectedStageId.set(id) } async copyAppId(): Promise<void> { try { await navigator.clipboard.writeText('app-20260821-142850-0042') this.copiedAppId.set(true) setTimeout(() => this.copiedAppId.set(false), 2000) } catch { // Clipboard unavailable } } triggerDownload(): void { this.downloadStarted.set(true) setTimeout(() => this.downloadStarted.set(false), 2500) } }src/app/components/blocks/spark-job-stage-visualizer/index.ts0.1 kB
export { UiSparkJobStageVisualizerComponent, type SparkTab } from './spark-job-stage-visualizer.component'
Raw manifest:https://uipkge.dev/r/angular/spark-job-stage-visualizer.json