UIPackage
Menu

Framework

Change language

Boilerplate 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.

Also available for React ->

Installation

$npx shadcn-vue@latest add https://uipkge.dev/r/vue/parquet-metadata-inspector.json
Named registry:npx shadcn-vue@latest add @uipkge/parquet-metadata-inspectorInstalls to:app/components/blocks/parquet-metadata-inspector/

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
classHTMLAttributes['class']optional
filePathstring's3://lakehouse-gold/orders_2026_q3_part0042.parquet'optional
formatSpecstring'Apache Parquet v2.10 · Snappy Compressed'optional
totalRowsstring'250optional
fileSizestring'18.4 MB'optional
compressionRatiostring'74.2%'optional
uncompressedSizestring'71.4 MB'optional
rowGroupsRowGroupMeta[]() => defaultRowGroupsData,optional

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 (8)

  • app/components/blocks/parquet-metadata-inspector/ParquetMetadataInspector.vue14.5 kB
    <script setup lang="ts">
    import { computed, ref, type HTMLAttributes } from 'vue'
    import { Binary, Check, Code2, Copy, Download, FileCode2, FolderTree, Layers, Search, Table2 } from 'lucide-vue-next'
    import { Badge } from '@/components/ui/badge'
    import { Button } from '@/components/ui/button'
    import { Card, CardContent } from '@/components/ui/card'
    import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
    import { cn } from '@/lib/utils'
    import ParquetFileTelemetryCards from './ParquetFileTelemetryCards.vue'
    import ParquetChunksTab from './ParquetChunksTab.vue'
    import ParquetSchemaTree from './ParquetSchemaTree.vue'
    import ParquetDictionaryTab from './ParquetDictionaryTab.vue'
    import type { ColumnChunk, RowGroupMeta } from './parquet-metadata-types'
    import { defaultRowGroupsData, parquetHeaderJson } from './parquet-metadata-data'
    import { schemaTreeNodes } from './parquet-schema'
    
    export type { ColumnChunk, RowGroupMeta }
    
    interface Props {
      class?: HTMLAttributes['class']
      filePath?: string
      formatSpec?: string
      totalRows?: string
      fileSize?: string
      compressionRatio?: string
      uncompressedSize?: string
      rowGroups?: RowGroupMeta[]
    }
    
    const props = withDefaults(defineProps<Props>(), {
      filePath: 's3://lakehouse-gold/orders_2026_q3_part0042.parquet',
      formatSpec: 'Apache Parquet v2.10 · Snappy Compressed',
      totalRows: '250,000 Rows',
      fileSize: '18.4 MB',
      compressionRatio: '74.2%',
      uncompressedSize: '71.4 MB',
      rowGroups: () => defaultRowGroupsData,
    })
    
    const selectedRowGroup = ref<number>(0)
    const activeTab = ref<string>('chunks')
    const searchQuery = ref<string>('')
    const encodingFilter = ref<'all' | 'dictionary' | 'plain'>('all')
    const copiedJson = ref<boolean>(false)
    const copiedUri = ref<boolean>(false)
    const downloadingJson = ref<boolean>(false)
    
    const activeRowGroup = computed(() => {
      return props.rowGroups[selectedRowGroup.value] ?? props.rowGroups[0]
    })
    
    const filteredColumns = computed(() => {
      const query = searchQuery.value.trim().toLowerCase()
      return activeRowGroup.value.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 =
          encodingFilter.value === 'all' ||
          (encodingFilter.value === 'dictionary' && col.isDictionaryEncoded) ||
          (encodingFilter.value === 'plain' && !col.isDictionaryEncoded)
    
        return matchesSearch && matchesEncoding
      })
    })
    
    const dictionaryColumns = computed(() => {
      return activeRowGroup.value.columns.filter((col) => col.isDictionaryEncoded)
    })
    
    function copyJsonToClipboard() {
      if (typeof navigator !== 'undefined' && navigator.clipboard) {
        navigator.clipboard.writeText(parquetHeaderJson)
        copiedJson.value = true
        setTimeout(() => {
          copiedJson.value = false
        }, 2000)
      }
    }
    
    function copyUriToClipboard() {
      if (typeof navigator !== 'undefined' && navigator.clipboard) {
        navigator.clipboard.writeText(props.filePath)
        copiedUri.value = true
        setTimeout(() => {
          copiedUri.value = false
        }, 2000)
      }
    }
    
    function handleDownloadJson() {
      downloadingJson.value = 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)
        }
        downloadingJson.value = false
      }, 400)
    }
    </script>
    
    <template>
      <div
        data-slot="parquet-metadata-inspector"
        :class="
          cn('bg-background text-foreground border-border w-full overflow-hidden rounded-xl border shadow-xs', props.class)
        "
      >
        <!-- 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">
                  <FileCode2 class="size-4" />
                </div>
                <h2 class="font-mono text-sm font-semibold tracking-tight break-all sm:text-base">
                  {{ props.filePath }}
                </h2>
                <button
                  type="button"
                  class="text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
                  :aria-label="'Copy S3 URI'"
                  @click="copyUriToClipboard"
                >
                  <Check v-if="copiedUri" class="text-success size-3.5" />
                  <Copy v-else class="size-3.5" />
                </button>
              </div>
    
              <div class="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs">
                <Badge variant="outline" class="gap-1.5 font-mono text-xs font-normal">
                  <span class="bg-success size-1.5 rounded-full" />
                  <span>{{ props.formatSpec }}</span>
                </Badge>
                <span class="text-muted-foreground">
                  Total Rows:
                  <strong class="text-foreground font-semibold tabular-nums">{{ props.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>{{ props.fileSize }}</span>
                  <span>·</span>
                  <span class="tabular-nums">{{ props.compressionRatio }} Compression Ratio</span>
                </div>
              </div>
            </div>
    
            <div class="flex flex-wrap items-center gap-2">
              <Button variant="outline" size="sm" class="h-8 gap-1.5 text-xs shadow-xs" @click="copyJsonToClipboard">
                <Check v-if="copiedJson" class="text-success size-3.5" />
                <Copy v-else class="size-3.5" />
                <span>{{ copiedJson ? 'JSON Copied' : 'Copy Header JSON' }}</span>
              </Button>
    
              <Button
                aria-label="Download attachment"
                size="sm"
                class="h-8 gap-1.5 text-xs shadow-xs"
                :disabled="downloadingJson"
                @click="handleDownloadJson"
              >
                <Download v-if="!downloadingJson" class="size-3.5" />
                <span v-else class="size-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
                <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 -->
          <ParquetFileTelemetryCards :file-size="props.fileSize" :uncompressed-size="props.uncompressedSize" />
    
          <!-- Row Groups Switcher Banner -->
          <Card class="border-border bg-card shadow-xs">
            <CardContent 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">
                    <button
                      v-for="rg in props.rowGroups"
                      :key="rg.id"
                      type="button"
                      :class="
                        cn(
                          'cursor-pointer rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
                          selectedRowGroup === rg.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',
                        )
                      "
                      @click="selectedRowGroup = rg.id"
                    >
                      <div class="flex items-center gap-2">
                        <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>
                  <Badge variant="outline" class="min-w-0 truncate font-mono text-xs">{{
                    activeRowGroup.fileOffset
                  }}</Badge>
                  <span class="text-muted-foreground">Raw Footprint:</span>
                  <span class="text-foreground font-mono font-medium tabular-nums">{{ activeRowGroup.totalByteSize }}</span>
                </div>
              </div>
            </CardContent>
          </Card>
    
          <!-- Main Tabs View -->
          <Tabs v-model="activeTab" default-value="chunks" class="w-full">
            <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
              <TabsList class="grid h-9 w-full grid-cols-2 p-1 sm:w-auto md:grid-cols-4">
                <TabsTrigger value="chunks" class="gap-1.5 text-xs">
                  <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>
                </TabsTrigger>
                <TabsTrigger value="schema" class="gap-1.5 text-xs">
                  <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">{{ schemaTreeNodes.length }}</span>
                </TabsTrigger>
                <TabsTrigger value="dictionary" class="gap-1.5 text-xs">
                  <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>
                </TabsTrigger>
                <TabsTrigger value="thrift" class="gap-1.5 text-xs">
                  <Code2 class="size-3.5" />
                  <span>Thrift Metadata</span>
                </TabsTrigger>
              </TabsList>
    
              <!-- Search & Filters when in Chunks view -->
              <div v-if="activeTab === 'chunks'" class="flex flex-wrap items-center gap-2">
                <div class="relative w-full sm:w-56">
                  <Search class="text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" />
                  <input
                    v-model="searchQuery"
                    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="
                      cn(
                        'cursor-pointer rounded px-2 py-1 transition-colors',
                        encodingFilter === 'all'
                          ? 'bg-background text-foreground shadow-xs'
                          : 'text-muted-foreground hover:text-foreground',
                      )
                    "
                    @click="encodingFilter = 'all'"
                  >
                    All
                  </button>
                  <button
                    type="button"
                    :class="
                      cn(
                        'cursor-pointer rounded px-2 py-1 transition-colors',
                        encodingFilter === 'dictionary'
                          ? 'bg-background text-foreground shadow-xs'
                          : 'text-muted-foreground hover:text-foreground',
                      )
                    "
                    @click="encodingFilter = 'dictionary'"
                  >
                    Dict Encoded
                  </button>
                  <button
                    type="button"
                    :class="
                      cn(
                        'cursor-pointer rounded px-2 py-1 transition-colors',
                        encodingFilter === 'plain'
                          ? 'bg-background text-foreground shadow-xs'
                          : 'text-muted-foreground hover:text-foreground',
                      )
                    "
                    @click="encodingFilter = 'plain'"
                  >
                    Plain
                  </button>
                </div>
              </div>
            </div>
    
            <!-- TAB 1: Column Chunks Breakdown Table -->
            <ParquetChunksTab :columns="filteredColumns" />
    
            <!-- TAB 2: Parquet Schema Tree -->
            <ParquetSchemaTree :nodes="schemaTreeNodes" />
    
            <!-- TAB 3: Dictionary & Compression Deep Dive -->
            <ParquetDictionaryTab :dictionary-columns="dictionaryColumns" />
    
            <!-- TAB 4: Parquet Header JSON (Thrift) -->
            <TabsContent 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">
                    <Code2 class="text-primary size-4" />
                    <span class="text-foreground font-mono text-xs font-medium">FileMetaData.thrift.json</span>
                    <Badge variant="secondary" class="font-mono text-xs">Thrift Compact Protocol</Badge>
                  </div>
                  <Button variant="ghost" size="sm" class="h-7 gap-1 text-xs" @click="copyJsonToClipboard">
                    <Check v-if="copiedJson" class="text-success size-3" />
                    <Copy v-else 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>{{ parquetHeaderJson }}</code></pre>
                </div>
              </div>
            </TabsContent>
          </Tabs>
        </div>
      </div>
    </template>
    
  • app/components/blocks/parquet-metadata-inspector/ParquetFileTelemetryCards.vue5.8 kB
  • app/components/blocks/parquet-metadata-inspector/ParquetChunksTab.vue6.1 kB
  • app/components/blocks/parquet-metadata-inspector/ParquetSchemaTree.vue4.6 kB
  • app/components/blocks/parquet-metadata-inspector/ParquetDictionaryTab.vue4.8 kB
  • app/components/blocks/parquet-metadata-inspector/parquet-metadata-data.ts13.8 kB
  • app/components/blocks/parquet-metadata-inspector/parquet-metadata-types.ts0.7 kB
  • app/components/blocks/parquet-metadata-inspector/parquet-schema.ts4.8 kB

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