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 Vue ->

Installation

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

Variants

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
classNamestringoptional
filePathstringoptional
formatSpecstringoptional
totalRowsstringoptional
fileSizestringoptional
compressionRatiostringoptional
uncompressedSizestringoptional
rowGroupsRowGroupMeta[]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)

  • components/blocks/parquet-metadata-inspector/ParquetMetadataInspector.tsx15.7 kB
    'use client'
    
    import * as React from 'react'
    import { Binary, Check, Code2, Copy, Download, FileCode2, FolderTree, Layers, Search, Table2 } from 'lucide-react'
    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'
    import { ParquetChunksTab } from './ParquetChunksTab'
    import { ParquetSchemaTree } from './ParquetSchemaTree'
    import { ParquetDictionaryTab } from './ParquetDictionaryTab'
    import type { ColumnChunk, RowGroupMeta } from './parquet-metadata-types'
    import { defaultRowGroupsData, parquetHeaderJson } from './parquet-metadata-data'
    import { schemaTreeNodes } from './parquet-schema'
    
    export type { ColumnChunk, RowGroupMeta }
    
    export interface ParquetMetadataInspectorProps {
      className?: string
      filePath?: string
      formatSpec?: string
      totalRows?: string
      fileSize?: string
      compressionRatio?: string
      uncompressedSize?: string
      rowGroups?: RowGroupMeta[]
    }
    
    export function ParquetMetadataInspector({
      className,
      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,
    }: ParquetMetadataInspectorProps) {
      const [selectedRowGroup, setSelectedRowGroup] = React.useState<number>(0)
      const [activeTab, setActiveTab] = React.useState<string>('chunks')
      const [searchQuery, setSearchQuery] = React.useState<string>('')
      const [encodingFilter, setEncodingFilter] = React.useState<'all' | 'dictionary' | 'plain'>('all')
      const [copiedJson, setCopiedJson] = React.useState<boolean>(false)
      const [copiedUri, setCopiedUri] = React.useState<boolean>(false)
      const [downloadingJson, setDownloadingJson] = React.useState<boolean>(false)
    
      const activeRowGroup = React.useMemo(() => {
        return rowGroups[selectedRowGroup] ?? rowGroups[0]
      }, [rowGroups, selectedRowGroup])
    
      const filteredColumns = React.useMemo(() => {
        const query = searchQuery.trim().toLowerCase()
        return 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 =
            encodingFilter === 'all' ||
            (encodingFilter === 'dictionary' && col.isDictionaryEncoded) ||
            (encodingFilter === 'plain' && !col.isDictionaryEncoded)
    
          return matchesSearch && matchesEncoding
        })
      }, [activeRowGroup, searchQuery, encodingFilter])
    
      const dictionaryColumns = React.useMemo(() => {
        return activeRowGroup.columns.filter((col) => col.isDictionaryEncoded)
      }, [activeRowGroup])
    
      const copyJsonToClipboard = React.useCallback(() => {
        if (typeof navigator !== 'undefined' && navigator.clipboard) {
          navigator.clipboard.writeText(parquetHeaderJson)
          setCopiedJson(true)
          setTimeout(() => {
            setCopiedJson(false)
          }, 2000)
        }
      }, [])
    
      const copyUriToClipboard = React.useCallback(() => {
        if (typeof navigator !== 'undefined' && navigator.clipboard) {
          navigator.clipboard.writeText(filePath)
          setCopiedUri(true)
          setTimeout(() => {
            setCopiedUri(false)
          }, 2000)
        }
      }, [filePath])
    
      const handleDownloadJson = React.useCallback(() => {
        setDownloadingJson(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)
          }
          setDownloadingJson(false)
        }, 400)
      }, [])
    
      return (
        <div
          data-slot="parquet-metadata-inspector"
          className={cn(
            'bg-background text-foreground border-border w-full overflow-hidden rounded-xl border shadow-xs',
            className,
          )}
        >
          {/* Top Parquet File Header */}
          <header className="border-border bg-card/70 border-b px-4 py-4 sm:px-6">
            <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
              <div className="space-y-1.5">
                <div className="flex flex-wrap items-center gap-2.5">
                  <div className="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-lg">
                    <FileCode2 className="size-4" />
                  </div>
                  <h2 className="font-mono text-sm font-semibold tracking-tight break-all sm:text-base">{filePath}</h2>
                  <button
                    type="button"
                    className="text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
                    aria-label="Copy S3 URI"
                    onClick={copyUriToClipboard}
                  >
                    {copiedUri ? <Check className="text-success size-3.5" /> : <Copy className="size-3.5" />}
                  </button>
                </div>
    
                <div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs">
                  <Badge variant="outline" className="gap-1.5 font-mono text-xs font-normal">
                    <span className="bg-success size-1.5 rounded-full" />
                    <span>{formatSpec}</span>
                  </Badge>
                  <span className="text-muted-foreground">
                    Total Rows: <strong className="text-foreground font-semibold tabular-nums">{totalRows}</strong>
                  </span>
                  <div className="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 className="tabular-nums">{compressionRatio} Compression Ratio</span>
                  </div>
                </div>
              </div>
    
              <div className="flex flex-wrap items-center gap-2">
                <Button variant="outline" size="sm" className="h-8 gap-1.5 text-xs shadow-xs" onClick={copyJsonToClipboard}>
                  {copiedJson ? <Check className="text-success size-3.5" /> : <Copy className="size-3.5" />}
                  <span>{copiedJson ? 'JSON Copied' : 'Copy Header JSON'}</span>
                </Button>
    
                <Button
                  aria-label="Download attachment"
                  size="sm"
                  className="h-8 gap-1.5 text-xs shadow-xs"
                  disabled={downloadingJson}
                  onClick={handleDownloadJson}
                >
                  {!downloadingJson ? (
                    <Download className="size-3.5" />
                  ) : (
                    <span className="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 className="space-y-6 p-4 sm:p-6">
            {/* 4 Parquet File Telemetry Cards */}
            <ParquetFileTelemetryCards fileSize={fileSize} uncompressedSize={uncompressedSize} />
    
            {/* Row Groups Switcher Banner */}
            <Card className="border-border bg-card shadow-xs">
              <CardContent className="p-3">
                <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
                  <div className="flex flex-wrap items-center gap-2">
                    <span className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
                      Inspect Row Group:
                    </span>
                    <div className="flex flex-wrap items-center gap-1.5">
                      {rowGroups.map((rg) => (
                        <button
                          key={rg.id}
                          type="button"
                          className={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',
                          )}
                          onClick={() => setSelectedRowGroup(rg.id)}
                        >
                          <div className="flex items-center gap-2">
                            <Layers className="size-3.5 opacity-70" />
                            <span className="font-mono font-semibold">Row Group #{rg.id}</span>
                            <span className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 font-mono text-xs tabular-nums">
                              {rg.numRowsFormatted}
                            </span>
                            <span className="text-muted-foreground font-mono text-xs tabular-nums">
                              ({rg.totalCompressedSize})
                            </span>
                          </div>
                        </button>
                      ))}
                    </div>
                  </div>
    
                  <div className="flex flex-wrap items-center gap-2 text-xs">
                    <span className="text-muted-foreground whitespace-nowrap">Row Group Offset:</span>
                    <Badge variant="outline" className="min-w-0 truncate font-mono text-xs">
                      {activeRowGroup.fileOffset}
                    </Badge>
                    <span className="text-muted-foreground">Raw Footprint:</span>
                    <span className="text-foreground font-mono font-medium tabular-nums">
                      {activeRowGroup.totalByteSize}
                    </span>
                  </div>
                </div>
              </CardContent>
            </Card>
    
            {/* Main Tabs View */}
            <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
              <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
                <TabsList className="grid h-9 w-full grid-cols-2 p-1 sm:w-auto md:grid-cols-4">
                  <TabsTrigger value="chunks" className="gap-1.5 text-xs">
                    <Table2 className="size-3.5" />
                    <span>Column Chunks</span>
                    <span className="bg-muted py-0.2 rounded-full px-1.5 font-mono text-xs">
                      {activeRowGroup.columns.length}
                    </span>
                  </TabsTrigger>
                  <TabsTrigger value="schema" className="gap-1.5 text-xs">
                    <FolderTree className="size-3.5" />
                    <span>Schema Tree</span>
                    <span className="bg-muted py-0.2 rounded-full px-1.5 font-mono text-xs">{schemaTreeNodes.length}</span>
                  </TabsTrigger>
                  <TabsTrigger value="dictionary" className="gap-1.5 text-xs">
                    <Binary className="size-3.5" />
                    <span>Dictionary Metrics</span>
                    <span className="bg-muted py-0.2 rounded-full px-1.5 font-mono text-xs">
                      {dictionaryColumns.length}
                    </span>
                  </TabsTrigger>
                  <TabsTrigger value="thrift" className="gap-1.5 text-xs">
                    <Code2 className="size-3.5" />
                    <span>Thrift Metadata</span>
                  </TabsTrigger>
                </TabsList>
    
                {/* Search & Filters when in Chunks view */}
                {activeTab === 'chunks' && (
                  <div className="flex flex-wrap items-center gap-2">
                    <div className="relative w-full sm:w-56">
                      <Search className="text-muted-foreground absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" />
                      <input
                        value={searchQuery}
                        onChange={(e) => setSearchQuery(e.target.value)}
                        type="text"
                        placeholder="Search column chunks..."
                        className="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 className="bg-muted border-border flex items-center rounded-md border p-0.5 text-xs">
                      <button
                        type="button"
                        className={cn(
                          'cursor-pointer rounded px-2 py-1 transition-colors',
                          encodingFilter === 'all'
                            ? 'bg-background text-foreground shadow-xs'
                            : 'text-muted-foreground hover:text-foreground',
                        )}
                        onClick={() => setEncodingFilter('all')}
                      >
                        All
                      </button>
                      <button
                        type="button"
                        className={cn(
                          'cursor-pointer rounded px-2 py-1 transition-colors',
                          encodingFilter === 'dictionary'
                            ? 'bg-background text-foreground shadow-xs'
                            : 'text-muted-foreground hover:text-foreground',
                        )}
                        onClick={() => setEncodingFilter('dictionary')}
                      >
                        Dict Encoded
                      </button>
                      <button
                        type="button"
                        className={cn(
                          'cursor-pointer rounded px-2 py-1 transition-colors',
                          encodingFilter === 'plain'
                            ? 'bg-background text-foreground shadow-xs'
                            : 'text-muted-foreground hover:text-foreground',
                        )}
                        onClick={() => setEncodingFilter('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 dictionaryColumns={dictionaryColumns} />
    
              {/* TAB 4: Parquet Header JSON (Thrift) */}
              <TabsContent value="thrift" className="mt-4 space-y-4">
                <div className="border-border bg-muted/30 overflow-hidden rounded-lg border">
                  <div className="border-border bg-card flex items-center justify-between gap-x-2 border-b px-4 py-2.5">
                    <div className="flex items-center gap-2">
                      <Code2 className="text-primary size-4" />
                      <span className="text-foreground font-mono text-xs font-medium">FileMetaData.thrift.json</span>
                      <Badge variant="secondary" className="font-mono text-xs">
                        Thrift Compact Protocol
                      </Badge>
                    </div>
                    <Button variant="ghost" size="sm" className="h-7 gap-1 text-xs" onClick={copyJsonToClipboard}>
                      {copiedJson ? <Check className="text-success size-3" /> : <Copy className="size-3" />}
                      <span>{copiedJson ? 'Copied' : 'Copy JSON'}</span>
                    </Button>
                  </div>
    
                  <div className="overflow-x-auto bg-neutral-950 p-4 font-mono text-xs leading-relaxed text-neutral-100 dark:bg-neutral-950">
                    <pre className="whitespace-pre">
                      <code>{parquetHeaderJson}</code>
                    </pre>
                  </div>
                </div>
              </TabsContent>
            </Tabs>
          </div>
        </div>
      )
    }
    
    export default ParquetMetadataInspector
    
  • components/blocks/parquet-metadata-inspector/ParquetFileTelemetryCards.tsx6.4 kB
  • components/blocks/parquet-metadata-inspector/ParquetChunksTab.tsx7 kB
  • components/blocks/parquet-metadata-inspector/ParquetSchemaTree.tsx5.4 kB
  • components/blocks/parquet-metadata-inspector/ParquetDictionaryTab.tsx5.1 kB
  • components/blocks/parquet-metadata-inspector/parquet-metadata-data.ts13.8 kB
  • components/blocks/parquet-metadata-inspector/parquet-metadata-types.ts0.7 kB
  • components/blocks/parquet-metadata-inspector/parquet-schema.ts4.8 kB

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