Menu

Change language

GitHub repo

Parquet Metadata Inspector

blockdevops

Apache Parquet file format deep-dive inspector with telemetry overview, row groups switcher, column chunk statistics, dictionary encoding metrics, and hierarchical schema tree with repetition and definition levels.

Installation

$npx uipkge-ng@latest add parquet-metadata-inspector
Manifest:https://uipkge.dev/r/angular/parquet-metadata-inspector.jsonInstalls to:src/app/components/blocks/parquet-metadata-inspector/

Variants

Loading interactive previews…

Schema

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

ColumnChunk
interface ColumnChunk {
  name: string
  physicalType: string
  logicalType: string
  fieldId: number
  encodings: string[]
  compression: string
  numValues: string
  nullCount: number
  nullPct: string
  minStat: string
  maxStat: string
  uncompressedBytes: string
  compressedBytes: string
  compressionRatio: string
  compressionPct: number
  isDictionaryEncoded: boolean
  dictEntries: number | null
  dictPageOffset: string | null
  dataPageOffset: string
  bitWidth: number | null
}
RowGroupMeta
interface RowGroupMeta {
  id: number
  numRows: number
  numRowsFormatted: string
  totalByteSize: string
  totalCompressedSize: string
  compressionRatio: string
  fileOffset: string
  columns: ColumnChunk[]
}
SchemaTreeNode
interface SchemaTreeNode {
  id: number
  path: string
  name: string
  repetition: 'REQUIRED' | 'OPTIONAL' | 'REPEATED'
  physicalType: string
  logicalType: string
  defLevel: number
  repLevel: number
  depth: number
  isGroup?: boolean
  description: string
}

Files installed (5)

  • src/app/components/blocks/parquet-metadata-inspector/parquet-metadata-inspector.component.ts40.8 kB
    // Apache Parquet file deep-dive inspector: file header with S3 URI copy + header-JSON
    // copy/download, four telemetry cards, a row-group switcher banner, and a four-tab view
    // (column-chunk statistics with search + encoding filter, schema tree, dictionary metrics,
    // Thrift FileMetaData JSON). Consolidates the five Vue SFCs + data files 1:1; the data,
    // types and schema siblings are verbatim copies of the Vue sources.
    import { ChangeDetectionStrategy, Component, Input, computed, signal } from '@angular/core'
    import {
      ArrowRight,
      Binary,
      Check,
      Code2,
      Columns3,
      Copy,
      Download,
      FileCode2,
      FolderTree,
      HardDrive,
      Layers,
      LucideAngularModule,
      Search,
      Table2,
    } 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 {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    import {
      UiTabsComponent,
      UiTabsContentComponent,
      UiTabsListComponent,
      UiTabsTriggerComponent,
    } from '@/ui/tabs/tabs.component'
    import { defaultRowGroupsData, parquetHeaderJson } from './parquet-metadata-data'
    import type { ColumnChunk, RowGroupMeta } from './parquet-metadata-types'
    import { schemaTreeNodes, type SchemaTreeNode } from './parquet-schema'
    
    export type { ColumnChunk, RowGroupMeta }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-parquet-metadata-inspector, [ui-parquet-metadata-inspector]',
      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,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
        UiTabsComponent,
        UiTabsContentComponent,
        UiTabsListComponent,
        UiTabsTriggerComponent,
      ],
      template: `
        <div data-slot="parquet-metadata-inspector" [class]="rootClass">
          <!-- Top Parquet File Header -->
          <header class="border-border bg-card/70 border-b px-4 py-4 sm:px-6">
            <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
              <div class="space-y-1.5">
                <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">
                    <lucide-icon [img]="FileCode2" class="size-4" />
                  </div>
                  <h2 class="font-mono text-sm font-semibold tracking-tight break-all sm:text-base">
                    {{ filePath() }}
                  </h2>
                  <button
                    type="button"
                    class="text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
                    aria-label="Copy S3 URI"
                    (click)="copyUriToClipboard()"
                  >
                    @if (copiedUri()) {
                      <lucide-icon [img]="Check" class="text-success size-3.5" />
                    } @else {
                      <lucide-icon [img]="Copy" class="size-3.5" />
                    }
                  </button>
                </div>
    
                <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs">
                  <span ui-badge variant="outline" class="gap-1.5 font-mono text-xs font-normal">
                    <span class="bg-success size-1.5 rounded-full"></span>
                    <span>{{ formatSpec() }}</span>
                  </span>
                  <span class="text-muted-foreground">
                    Total Rows:
                    <strong class="text-foreground font-semibold tabular-nums">{{ totalRows() }}</strong>
                  </span>
                  <div
                    class="border-success/30 bg-success/10 text-success inline-flex items-center gap-1 rounded-md border px-2 py-0.5 font-mono text-xs font-medium"
                  >
                    <span>{{ fileSize() }}</span>
                    <span>·</span>
                    <span class="tabular-nums">{{ compressionRatio() }} Compression Ratio</span>
                  </div>
                </div>
              </div>
    
              <div class="flex flex-wrap items-center gap-2">
                <button
                  ui-button
                  variant="outline"
                  size="sm"
                  class="h-8 gap-1.5 text-xs shadow-xs"
                  (click)="copyJsonToClipboard()"
                >
                  @if (copiedJson()) {
                    <lucide-icon [img]="Check" class="text-success size-3.5" />
                  } @else {
                    <lucide-icon [img]="Copy" class="size-3.5" />
                  }
                  <span>{{ copiedJson() ? 'JSON Copied' : 'Copy Header JSON' }}</span>
                </button>
    
                <button
                  ui-button
                  aria-label="Download attachment"
                  size="sm"
                  class="h-8 gap-1.5 text-xs shadow-xs"
                  [disabled]="downloadingJson()"
                  (click)="handleDownloadJson()"
                >
                  @if (!downloadingJson()) {
                    <lucide-icon [img]="Download" class="size-3.5" />
                  } @else {
                    <span class="size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent"></span>
                  }
                  <span>Download Parquet Header JSON</span>
                </button>
              </div>
            </div>
          </header>
    
          <div class="space-y-6 p-4 sm:p-6">
            <!-- 4 Parquet File Telemetry Cards -->
            <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
              <div ui-card class="flex flex-col justify-between shadow-xs">
                <div ui-card-header class="pb-2">
                  <div class="flex items-center justify-between gap-2">
                    <div class="flex items-center gap-2">
                      <div
                        class="border-info/20 bg-info/10 text-info flex size-7 items-center justify-center rounded-md border"
                      >
                        <lucide-icon [img]="Layers" class="size-3.5" />
                      </div>
                      <h3 ui-card-title class="text-xs font-medium">Row Groups</h3>
                    </div>
                    <span ui-badge variant="outline" class="font-mono text-xs tabular-nums">2 Groups</span>
                  </div>
                </div>
                <div ui-card-content class="space-y-2 pt-1">
                  <div class="flex items-baseline gap-1.5">
                    <span class="text-foreground text-2xl font-bold tracking-tight tabular-nums sm:text-3xl">2</span>
                    <span class="text-muted-foreground text-xs font-medium">Row Groups</span>
                  </div>
                  <div class="border-border/60 border-t pt-2 text-xs">
                    <div class="text-muted-foreground flex items-center justify-between gap-x-2">
                      <span>Chunk Partitioning:</span>
                      <span class="text-foreground font-medium tabular-nums">125,000 rows / group</span>
                    </div>
                  </div>
                </div>
              </div>
    
              <div ui-card class="flex flex-col justify-between shadow-xs">
                <div ui-card-header class="pb-2">
                  <div class="flex items-center justify-between gap-2">
                    <div class="flex items-center gap-2">
                      <div
                        class="border-chart-1/20 bg-chart-1/10 text-chart-1 flex size-7 items-center justify-center rounded-md border"
                      >
                        <lucide-icon [img]="Columns3" class="size-3.5" />
                      </div>
                      <h3 ui-card-title class="text-xs font-medium">Columns / Schema</h3>
                    </div>
                    <span ui-badge variant="outline" class="font-mono text-xs tabular-nums">14 Chunks</span>
                  </div>
                </div>
                <div ui-card-content class="space-y-2 pt-1">
                  <div class="flex items-baseline gap-1.5">
                    <span class="text-foreground text-2xl font-bold tracking-tight tabular-nums sm:text-3xl">14</span>
                    <span class="text-muted-foreground text-xs font-medium">Column Chunks</span>
                  </div>
                  <div class="border-border/60 border-t pt-2 text-xs">
                    <div class="text-muted-foreground flex items-center justify-between gap-x-2">
                      <span>Nested Topology:</span>
                      <span class="text-foreground font-medium tabular-nums">2 Structs · 1 List</span>
                    </div>
                  </div>
                </div>
              </div>
    
              <div ui-card class="flex flex-col justify-between shadow-xs">
                <div ui-card-header class="pb-2">
                  <div class="flex items-center justify-between gap-2">
                    <div class="flex items-center gap-2">
                      <div
                        class="border-success/20 bg-success/10 text-success flex size-7 items-center justify-center rounded-md border"
                      >
                        <lucide-icon [img]="HardDrive" class="size-3.5" />
                      </div>
                      <h3 ui-card-title class="text-xs font-medium">Uncompressed Size</h3>
                    </div>
                    <span
                      ui-badge
                      variant="secondary"
                      class="border-success/30 text-success font-mono text-xs tabular-nums"
                    >
                      3.88x Savings
                    </span>
                  </div>
                </div>
                <div ui-card-content class="space-y-2 pt-1">
                  <div class="flex items-baseline gap-1.5">
                    <span class="text-foreground text-2xl font-bold tracking-tight tabular-nums sm:text-3xl">{{
                      uncompressedSize()
                    }}</span>
                    <span class="text-muted-foreground text-xs font-medium">Raw</span>
                  </div>
                  <div class="border-border/60 border-t pt-2 text-xs">
                    <div class="text-muted-foreground flex items-center justify-between gap-x-2">
                      <span>Snappy Footprint:</span>
                      <span class="text-foreground font-medium tabular-nums">{{ fileSize() }} (Saved 53.0 MB)</span>
                    </div>
                  </div>
                </div>
              </div>
    
              <div ui-card class="flex flex-col justify-between shadow-xs">
                <div ui-card-header class="pb-2">
                  <div class="flex items-center justify-between gap-2">
                    <div class="flex items-center gap-2">
                      <div
                        class="border-warning/20 bg-warning/10 text-warning flex size-7 items-center justify-center rounded-md border"
                      >
                        <lucide-icon [img]="Binary" class="size-3.5" />
                      </div>
                      <h3 ui-card-title class="text-xs font-medium">Dictionary Encoding</h3>
                    </div>
                    <span ui-badge variant="outline" class="font-mono text-xs tabular-nums">85.7%</span>
                  </div>
                </div>
                <div ui-card-content class="space-y-2 pt-1">
                  <div class="flex items-baseline gap-1.5">
                    <span class="text-foreground text-2xl font-bold tracking-tight tabular-nums sm:text-3xl">12 / 14</span>
                    <span class="text-muted-foreground text-xs font-medium">Columns Encoded</span>
                  </div>
                  <div class="border-border/60 border-t pt-2 text-xs">
                    <div class="text-muted-foreground flex items-center justify-between gap-x-2">
                      <span>Encoding Algorithms:</span>
                      <span class="text-foreground font-medium">RLE + PLAIN_DICT</span>
                    </div>
                  </div>
                </div>
              </div>
            </div>
    
            <!-- Row Groups Switcher Banner -->
            <div ui-card class="border-border bg-card shadow-xs">
              <div ui-card-content class="p-3">
                <div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
                  <div class="flex flex-wrap items-center gap-2">
                    <span class="text-muted-foreground text-xs font-semibold tracking-wider uppercase"
                      >Inspect Row Group:</span
                    >
                    <div class="flex flex-wrap items-center gap-1.5">
                      @for (rg of rowGroups(); track rg.id) {
                        <button type="button" [class]="rowGroupClass(rg.id)" (click)="selectedRowGroup.set(rg.id)">
                          <div class="flex items-center gap-2">
                            <lucide-icon [img]="Layers" class="size-3.5 opacity-70" />
                            <span class="font-mono font-semibold">Row Group #{{ rg.id }}</span>
                            <span
                              class="bg-muted text-muted-foreground rounded px-1.5 py-0.5 font-mono text-xs tabular-nums"
                            >
                              {{ rg.numRowsFormatted }}
                            </span>
                            <span class="text-muted-foreground font-mono text-xs tabular-nums">
                              ({{ rg.totalCompressedSize }})
                            </span>
                          </div>
                        </button>
                      }
                    </div>
                  </div>
    
                  <div class="flex flex-wrap items-center gap-2 text-xs">
                    <span class="text-muted-foreground whitespace-nowrap">Row Group Offset:</span>
                    <span ui-badge variant="outline" class="min-w-0 truncate font-mono text-xs">{{
                      activeRowGroup().fileOffset
                    }}</span>
                    <span class="text-muted-foreground">Raw Footprint:</span>
                    <span class="text-foreground font-mono font-medium tabular-nums">{{
                      activeRowGroup().totalByteSize
                    }}</span>
                  </div>
                </div>
              </div>
            </div>
    
            <!-- Main Tabs View -->
            <ui-tabs [value]="activeTab()" (valueChange)="activeTab.set($event)" class="w-full">
              <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
                <ui-tabs-list class="grid h-9 w-full grid-cols-2 p-1 sm:w-auto md:grid-cols-4">
                  <button ui-tabs-trigger value="chunks" class="gap-1.5 text-xs">
                    <lucide-icon [img]="Table2" class="size-3.5" />
                    <span>Column Chunks</span>
                    <span class="bg-muted py-0.2 rounded-full px-1.5 font-mono text-xs">{{
                      activeRowGroup().columns.length
                    }}</span>
                  </button>
                  <button ui-tabs-trigger value="schema" class="gap-1.5 text-xs">
                    <lucide-icon [img]="FolderTree" class="size-3.5" />
                    <span>Schema Tree</span>
                    <span class="bg-muted py-0.2 rounded-full px-1.5 font-mono text-xs">{{ nodes().length }}</span>
                  </button>
                  <button ui-tabs-trigger value="dictionary" class="gap-1.5 text-xs">
                    <lucide-icon [img]="Binary" class="size-3.5" />
                    <span>Dictionary Metrics</span>
                    <span class="bg-muted py-0.2 rounded-full px-1.5 font-mono text-xs">{{
                      dictionaryColumns().length
                    }}</span>
                  </button>
                  <button ui-tabs-trigger value="thrift" class="gap-1.5 text-xs">
                    <lucide-icon [img]="Code2" class="size-3.5" />
                    <span>Thrift Metadata</span>
                  </button>
                </ui-tabs-list>
    
                @if (activeTab() === 'chunks') {
                  <div class="flex flex-wrap items-center gap-2">
                    <div class="relative w-full sm:w-56">
                      <lucide-icon
                        [img]="Search"
                        class="text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2"
                      />
                      <input
                        [value]="searchQuery()"
                        (input)="searchQuery.set($any($event.target).value)"
                        type="text"
                        placeholder="Search column chunks..."
                        class="border-border bg-background text-foreground focus-visible:border-ring focus-visible:ring-ring/50 h-8 w-full rounded-md border pr-2.5 pl-8 text-xs focus-visible:ring-2 focus-visible:outline-none"
                      />
                    </div>
                    <div class="bg-muted border-border flex items-center rounded-md border p-0.5 text-xs">
                      <button type="button" [class]="encodingClass('all')" (click)="encodingFilter.set('all')">All</button>
                      <button
                        type="button"
                        [class]="encodingClass('dictionary')"
                        (click)="encodingFilter.set('dictionary')"
                      >
                        Dict Encoded
                      </button>
                      <button type="button" [class]="encodingClass('plain')" (click)="encodingFilter.set('plain')">
                        Plain
                      </button>
                    </div>
                  </div>
                }
              </div>
    
              <!-- TAB 1: Column Chunks Breakdown Table -->
              <ui-tabs-content value="chunks" class="mt-4 space-y-4">
                <div ui-card class="overflow-hidden border shadow-none">
                  <div class="overflow-x-auto">
                    <table ui-table>
                      <thead ui-table-header>
                        <tr ui-table-row class="bg-muted/40 hover:bg-muted/40">
                          <th ui-table-head class="min-w-[200px] text-xs font-semibold">Column Name & Type</th>
                          <th ui-table-head class="min-w-[170px] text-xs font-semibold">Encoding & Codec</th>
                          <th ui-table-head class="min-w-[280px] text-xs font-semibold">Min / Max Column Statistics</th>
                          <th ui-table-head class="min-w-[130px] text-xs font-semibold">Null Values</th>
                          <th ui-table-head class="min-w-[190px] text-xs font-semibold">Compressed / Raw Footprint</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>
                              <div class="space-y-1">
                                <div class="flex items-center gap-1.5">
                                  <span class="text-foreground font-mono text-xs font-semibold">{{ col.name }}</span>
                                  <span ui-badge variant="outline" class="font-mono text-xs">ID: {{ col.fieldId }}</span>
                                </div>
                                <div class="flex flex-wrap items-center gap-1 text-xs">
                                  <span ui-badge variant="secondary" class="font-mono text-xs font-normal">
                                    {{ col.physicalType }}
                                  </span>
                                  <lucide-icon [img]="ArrowRight" class="text-muted-foreground size-2.5" />
                                  <span class="text-muted-foreground font-mono text-xs">{{ col.logicalType }}</span>
                                </div>
                              </div>
                            </td>
                            <td ui-table-cell>
                              <div class="space-y-1.5">
                                <div class="flex flex-wrap items-center gap-1">
                                  @for (enc of col.encodings; track enc) {
                                    <span
                                      ui-badge
                                      [variant]="enc.includes('DICTIONARY') ? 'default' : 'outline'"
                                      class="font-mono text-xs font-normal"
                                    >
                                      {{ enc }}
                                    </span>
                                  }
                                </div>
                                <div class="text-muted-foreground flex items-center gap-1.5 text-xs">
                                  <span class="text-foreground font-medium">Codec:</span>
                                  <span ui-badge variant="secondary" class="font-mono text-xs">{{ col.compression }}</span>
                                </div>
                              </div>
                            </td>
                            <td ui-table-cell>
                              <div class="border-border/70 bg-muted/20 space-y-1 rounded-md border p-2 font-mono text-xs">
                                <div class="flex items-baseline gap-1.5">
                                  <span class="text-muted-foreground shrink-0">Min:</span>
                                  <span class="text-foreground truncate font-medium" [attr.title]="col.minStat">{{
                                    col.minStat
                                  }}</span>
                                </div>
                                <div class="flex items-baseline gap-1.5">
                                  <span class="text-muted-foreground shrink-0">Max:</span>
                                  <span class="text-foreground truncate font-medium" [attr.title]="col.maxStat">{{
                                    col.maxStat
                                  }}</span>
                                </div>
                              </div>
                            </td>
                            <td ui-table-cell>
                              <div class="space-y-1">
                                <div class="flex items-center gap-1.5">
                                  <span
                                    ui-badge
                                    [variant]="col.nullCount === 0 ? 'secondary' : 'outline'"
                                    [class]="
                                      col.nullCount === 0
                                        ? 'border-success/30 text-success font-mono text-xs'
                                        : 'font-mono text-xs'
                                    "
                                  >
                                    {{ col.nullCount }} nulls
                                  </span>
                                </div>
                                <p class="text-muted-foreground font-mono text-xs tabular-nums">
                                  {{ col.nullPct }} of {{ col.numValues }} rows
                                </p>
                              </div>
                            </td>
                            <td ui-table-cell>
                              <div class="space-y-1.5">
                                <div class="flex items-center justify-between gap-x-2 font-mono text-xs">
                                  <span class="text-foreground font-semibold tabular-nums">{{ col.compressedBytes }}</span>
                                  <span class="text-muted-foreground tabular-nums">/ {{ col.uncompressedBytes }}</span>
                                </div>
                                <div class="space-y-1">
                                  <ui-progress [value]="col.compressionPct" class="h-1.5" />
                                  <div class="text-muted-foreground flex items-center justify-between gap-x-2 text-xs">
                                    <span class="text-success font-mono font-medium tabular-nums"
                                      >{{ col.compressionRatio }} saved</span
                                    >
                                    <span class="font-mono text-xs">Offset: {{ col.dataPageOffset }}</span>
                                  </div>
                                </div>
                              </div>
                            </td>
                          </tr>
                        }
                        @if (filteredColumns().length === 0) {
                          <tr ui-table-row>
                            <td ui-table-cell colspan="5" class="text-muted-foreground py-8 text-center text-xs">
                              No matching column chunks found for filter criteria.
                            </td>
                          </tr>
                        }
                      </tbody>
                    </table>
                  </div>
                </div>
              </ui-tabs-content>
    
              <!-- TAB 2: Parquet File Schema Tree Visualizer -->
              <ui-tabs-content value="schema" class="mt-4 space-y-4">
                <div ui-card class="shadow-none">
                  <div ui-card-header class="border-border border-b pb-3">
                    <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
                      <div>
                        <h3 ui-card-title class="text-sm font-semibold">Parquet Schema Definition & Field Hierarchy</h3>
                        <p ui-card-description class="text-xs">
                          Hierarchical view of logical schema nodes, repetition rules (REQUIRED, OPTIONAL, REPEATED), and
                          maximum definition / repetition levels for column projection.
                        </p>
                      </div>
                      <div class="flex flex-wrap items-center gap-1.5">
                        <span ui-badge variant="outline" class="font-mono text-xs">D: Def Level</span>
                        <span ui-badge variant="outline" class="font-mono text-xs">R: Rep Level</span>
                      </div>
                    </div>
                  </div>
                  <div ui-card-content class="p-0">
                    <div class="divide-border divide-y">
                      <div class="bg-muted/40 flex items-center justify-between gap-x-2 px-4 py-2.5 font-mono text-xs">
                        <div class="flex items-center gap-2">
                          <lucide-icon [img]="FolderTree" class="text-primary size-4" />
                          <span class="text-foreground font-bold">message schema</span>
                          <span ui-badge variant="secondary" class="font-mono text-xs">Root Message</span>
                        </div>
                        <span class="text-muted-foreground font-mono text-xs">14 column fields</span>
                      </div>
    
                      @for (node of nodes(); track node.id) {
                        <div
                          class="hover:bg-muted/30 flex flex-col justify-between gap-2 px-4 py-3 text-xs transition-colors md:flex-row md:items-center"
                          [style.padding-left.rem]="indentRem(node.depth)"
                        >
                          <div class="flex min-w-0 items-center gap-2">
                            <span class="text-muted-foreground font-mono select-none">
                              {{ node.depth === 0 ? '├─' : '└─' }}
                            </span>
                            <span [class]="nodeNameClass(node.isGroup)">
                              {{ node.name }}
                            </span>
                            <span
                              ui-badge
                              [variant]="
                                node.repetition === 'REQUIRED'
                                  ? 'default'
                                  : node.repetition === 'REPEATED'
                                    ? 'secondary'
                                    : 'outline'
                              "
                              class="font-mono text-xs font-normal"
                            >
                              {{ node.repetition }}
                            </span>
                            <span ui-badge variant="secondary" class="font-mono text-xs font-normal">
                              {{ node.physicalType }}
                            </span>
                            <lucide-icon [img]="ArrowRight" class="text-muted-foreground size-3 shrink-0" />
                            <span ui-badge variant="outline" class="text-muted-foreground font-mono text-xs font-normal">
                              {{ node.logicalType }}
                            </span>
                          </div>
    
                          <div class="flex shrink-0 flex-wrap items-center gap-2">
                            <span
                              class="text-muted-foreground hidden max-w-[220px] truncate text-xs lg:inline-block"
                              [attr.title]="node.description"
                            >
                              {{ node.description }}
                            </span>
                            <div
                              class="bg-muted/60 border-border flex items-center gap-2 rounded border px-2 py-0.5 font-mono text-xs"
                            >
                              <span title="Max Definition Level"
                                >max_def:
                                <strong class="text-foreground font-bold tabular-nums">{{ node.defLevel }}</strong></span
                              >
                              <span class="text-muted-foreground">|</span>
                              <span title="Max Repetition Level"
                                >max_rep:
                                <strong class="text-foreground font-bold tabular-nums">{{ node.repLevel }}</strong></span
                              >
                            </div>
                            <span ui-badge variant="outline" class="font-mono text-xs">ID: {{ node.id }}</span>
                          </div>
                        </div>
                      }
                    </div>
                  </div>
                </div>
              </ui-tabs-content>
    
              <!-- TAB 3: Dictionary & Compression Deep Dive -->
              <ui-tabs-content value="dictionary" class="mt-4 space-y-4">
                <div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
                  <div ui-card class="shadow-none lg:col-span-1">
                    <div ui-card-header class="pb-3">
                      <h3 ui-card-title class="text-sm font-semibold">Dictionary Optimization Impact</h3>
                      <p ui-card-description class="text-xs">
                        Plain dictionary replaces repeated byte strings and integers with compact 3-bit to 16-bit dictionary
                        index pointers.
                      </p>
                    </div>
                    <div ui-card-content class="space-y-4 text-xs">
                      <div class="border-border bg-muted/20 space-y-2 rounded-lg border p-3">
                        <div class="flex items-center justify-between gap-x-2">
                          <span class="text-muted-foreground">Dictionary Enabled Columns:</span>
                          <span class="text-foreground font-mono font-semibold">6 / 8 Monitored</span>
                        </div>
                        <div class="flex items-center justify-between gap-x-2">
                          <span class="text-muted-foreground">Avg Space Reduction:</span>
                          <span class="text-success font-mono font-semibold tabular-nums">78.4%</span>
                        </div>
                        <div class="flex items-center justify-between gap-x-2">
                          <span class="text-muted-foreground">Dictionary Page Overhead:</span>
                          <span class="text-foreground font-mono font-semibold tabular-nums">0.34 MB total</span>
                        </div>
                        <div class="flex items-center justify-between gap-x-2">
                          <span class="text-muted-foreground">RLE Run Length Efficiency:</span>
                          <span class="text-foreground font-mono font-semibold">Optimal</span>
                        </div>
                      </div>
    
                      <div class="space-y-2">
                        <span class="text-muted-foreground font-medium">Compression Distribution by Codec:</span>
                        <div class="space-y-1.5">
                          <div class="flex items-center justify-between gap-x-2 font-mono">
                            <span>Snappy + RLE Dict</span>
                            <span class="font-semibold tabular-nums">74.2%</span>
                          </div>
                          <ui-progress [value]="74.2" class="h-1.5" />
                        </div>
                      </div>
                    </div>
                  </div>
    
                  <div ui-card class="overflow-hidden shadow-none lg:col-span-2">
                    <div class="overflow-x-auto">
                      <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">Column</th>
                            <th ui-table-head class="text-xs font-semibold">Distinct Entries</th>
                            <th ui-table-head class="text-xs font-semibold">Bit Width</th>
                            <th ui-table-head class="text-xs font-semibold">Dict Page Offset</th>
                            <th ui-table-head class="text-xs font-semibold">Space Savings</th>
                          </tr>
                        </thead>
                        <tbody ui-table-body>
                          @for (col of dictionaryColumns(); track col.name) {
                            <tr ui-table-row class="text-xs">
                              <td ui-table-cell class="text-foreground font-mono font-medium">{{ col.name }}</td>
                              <td ui-table-cell class="font-mono tabular-nums">
                                <span ui-badge variant="secondary" class="font-mono text-xs">
                                  {{ col.dictEntries?.toLocaleString() }} values
                                </span>
                              </td>
                              <td ui-table-cell class="font-mono tabular-nums">{{ col.bitWidth }} bits / row</td>
                              <td ui-table-cell class="text-muted-foreground font-mono text-xs">
                                {{ col.dictPageOffset }}
                              </td>
                              <td ui-table-cell>
                                <div class="flex items-center gap-2">
                                  <span class="text-success font-mono font-semibold tabular-nums">
                                    {{ col.compressionRatio }}
                                  </span>
                                  <span class="text-muted-foreground font-mono text-xs">({{ col.compressedBytes }})</span>
                                </div>
                              </td>
                            </tr>
                          }
                        </tbody>
                      </table>
                    </div>
                  </div>
                </div>
              </ui-tabs-content>
    
              <!-- TAB 4: Parquet Header JSON (Thrift) -->
              <ui-tabs-content value="thrift" class="mt-4 space-y-4">
                <div class="border-border bg-muted/30 overflow-hidden rounded-lg border">
                  <div class="border-border bg-card flex items-center justify-between gap-x-2 border-b px-4 py-2.5">
                    <div class="flex items-center gap-2">
                      <lucide-icon [img]="Code2" class="text-primary size-4" />
                      <span class="text-foreground font-mono text-xs font-medium">FileMetaData.thrift.json</span>
                      <span ui-badge variant="secondary" class="font-mono text-xs">Thrift Compact Protocol</span>
                    </div>
                    <button ui-button variant="ghost" size="sm" class="h-7 gap-1 text-xs" (click)="copyJsonToClipboard()">
                      @if (copiedJson()) {
                        <lucide-icon [img]="Check" class="text-success size-3" />
                      } @else {
                        <lucide-icon [img]="Copy" class="size-3" />
                      }
                      <span>{{ copiedJson() ? 'Copied' : 'Copy JSON' }}</span>
                    </button>
                  </div>
    
                  <div
                    class="overflow-x-auto bg-neutral-950 p-4 font-mono text-xs leading-relaxed text-neutral-100 dark:bg-neutral-950"
                  >
                    <pre class="whitespace-pre"><code>{{ headerJson() }}</code></pre>
                  </div>
                </div>
              </ui-tabs-content>
            </ui-tabs>
          </div>
        </div>
      `,
    })
    export class UiParquetMetadataInspectorComponent {
      protected readonly ArrowRight = ArrowRight
      protected readonly Binary = Binary
      protected readonly Check = Check
      protected readonly Code2 = Code2
      protected readonly Columns3 = Columns3
      protected readonly Copy = Copy
      protected readonly Download = Download
      protected readonly FileCode2 = FileCode2
      protected readonly FolderTree = FolderTree
      protected readonly HardDrive = HardDrive
      protected readonly Layers = Layers
      protected readonly Search = Search
      protected readonly Table2 = Table2
    
      readonly filePath = signal('s3://lakehouse-gold/orders_2026_q3_part0042.parquet')
      readonly formatSpec = signal('Apache Parquet v2.10 · Snappy Compressed')
      readonly totalRows = signal('250,000 Rows')
      readonly fileSize = signal('18.4 MB')
      readonly compressionRatio = signal('74.2%')
      readonly uncompressedSize = signal('71.4 MB')
      readonly rowGroups = signal<RowGroupMeta[]>(defaultRowGroupsData.map((rg) => ({ ...rg, columns: [...rg.columns] })))
    
      @Input('filePath') set filePathAttr(v: string | undefined) {
        if (v !== undefined) this.filePath.set(v)
      }
      @Input('formatSpec') set formatSpecAttr(v: string | undefined) {
        if (v !== undefined) this.formatSpec.set(v)
      }
      @Input('totalRows') set totalRowsAttr(v: string | undefined) {
        if (v !== undefined) this.totalRows.set(v)
      }
      @Input('fileSize') set fileSizeAttr(v: string | undefined) {
        if (v !== undefined) this.fileSize.set(v)
      }
      @Input('compressionRatio') set compressionRatioAttr(v: string | undefined) {
        if (v !== undefined) this.compressionRatio.set(v)
      }
      @Input('uncompressedSize') set uncompressedSizeAttr(v: string | undefined) {
        if (v !== undefined) this.uncompressedSize.set(v)
      }
      @Input('rowGroups') set rowGroupsAttr(v: RowGroupMeta[] | undefined) {
        if (v !== undefined) this.rowGroups.set(v.map((rg) => ({ ...rg, columns: [...rg.columns] })))
      }
      @Input('class') className?: string
    
      readonly selectedRowGroup = signal(0)
      readonly activeTab = signal('chunks')
      readonly searchQuery = signal('')
      readonly encodingFilter = signal<'all' | 'dictionary' | 'plain'>('all')
      readonly copiedJson = signal(false)
      readonly copiedUri = signal(false)
      readonly downloadingJson = signal(false)
    
      protected readonly nodes = signal<SchemaTreeNode[]>(schemaTreeNodes)
    
      readonly activeRowGroup = computed(() => {
        const groups = this.rowGroups()
        return groups[this.selectedRowGroup()] ?? groups[0]!
      })
    
      readonly filteredColumns = computed(() => {
        const query = this.searchQuery().trim().toLowerCase()
        return this.activeRowGroup().columns.filter((col) => {
          const matchesSearch =
            !query ||
            col.name.toLowerCase().includes(query) ||
            col.physicalType.toLowerCase().includes(query) ||
            col.logicalType.toLowerCase().includes(query) ||
            col.encodings.some((enc) => enc.toLowerCase().includes(query))
          const matchesEncoding =
            this.encodingFilter() === 'all' ||
            (this.encodingFilter() === 'dictionary' && col.isDictionaryEncoded) ||
            (this.encodingFilter() === 'plain' && !col.isDictionaryEncoded)
          return matchesSearch && matchesEncoding
        })
      })
    
      readonly dictionaryColumns = computed(() => this.activeRowGroup().columns.filter((col) => col.isDictionaryEncoded))
    
      headerJson(): string {
        return parquetHeaderJson
      }
    
      get rootClass(): string {
        return cn(
          'bg-background text-foreground border-border w-full overflow-hidden rounded-xl border shadow-xs',
          this.className,
        )
      }
    
      rowGroupClass(id: number): string {
        return cn(
          'cursor-pointer rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
          this.selectedRowGroup() === id
            ? 'bg-primary/10 text-primary border-primary/30 shadow-xs'
            : 'bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground border-transparent',
        )
      }
    
      encodingClass(filter: 'all' | 'dictionary' | 'plain'): string {
        return cn(
          'cursor-pointer rounded px-2 py-1 transition-colors',
          this.encodingFilter() === filter
            ? 'bg-background text-foreground shadow-xs'
            : 'text-muted-foreground hover:text-foreground',
        )
      }
    
      nodeNameClass(isGroup?: boolean): string {
        return cn('truncate font-mono font-medium', isGroup ? 'text-primary font-bold' : 'text-foreground')
      }
    
      indentRem(depth: number): number {
        return Math.max(1, depth * 1.5 + 1)
      }
    
      copyJsonToClipboard(): void {
        if (typeof navigator !== 'undefined' && navigator.clipboard) {
          void navigator.clipboard.writeText(parquetHeaderJson)
          this.copiedJson.set(true)
          setTimeout(() => this.copiedJson.set(false), 2000)
        }
      }
    
      copyUriToClipboard(): void {
        if (typeof navigator !== 'undefined' && navigator.clipboard) {
          void navigator.clipboard.writeText(this.filePath())
          this.copiedUri.set(true)
          setTimeout(() => this.copiedUri.set(false), 2000)
        }
      }
    
      handleDownloadJson(): void {
        this.downloadingJson.set(true)
        setTimeout(() => {
          if (typeof document !== 'undefined') {
            const blob = new Blob([parquetHeaderJson], { type: 'application/json' })
            const url = URL.createObjectURL(blob)
            const a = document.createElement('a')
            a.href = url
            a.download = 'parquet-file-metadata.json'
            document.body.appendChild(a)
            a.click()
            document.body.removeChild(a)
            URL.revokeObjectURL(url)
          }
          this.downloadingJson.set(false)
        }, 400)
      }
    }
    
  • src/app/components/blocks/parquet-metadata-inspector/parquet-metadata-data.ts13.8 kB
  • src/app/components/blocks/parquet-metadata-inspector/parquet-metadata-types.ts0.7 kB
  • src/app/components/blocks/parquet-metadata-inspector/parquet-schema.ts4.8 kB
  • src/app/components/blocks/parquet-metadata-inspector/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/parquet-metadata-inspector.json