feat(hadoop): real Hive engine + filterable, detailed analytics with lineage

Deploys Apache Hive 3.1.3 (Derby metastore + external table over the HDFS
historical CSV, MapReduce exec) on the Hadoop master, so the engine comparison
shows a REAL measured Hive latency (~5.2s) next to live Trino (~0.3s); Impala
stays clearly-labelled representative. The API re-measures Hive over SSH on a
30-min TTL (cached + persisted, with a committed seed). Adds filters
(year/region/category/channel), a region×category heatmap, Trino exec stats,
and a "where is this data read from" lineage panel (Trino->S3/Iceberg/Parquet
with snapshot+files, Hive->HDFS/CSV with namenode+files). Mounts host SSH key
read-only into the api container for the live Hive benchmark.
This commit is contained in:
mo
2026-06-26 17:08:57 +00:00
parent 71a64d5a21
commit 96d490807a
7 changed files with 570 additions and 221 deletions
+190 -110
View File
@@ -1,62 +1,52 @@
import { useCallback, useEffect, useState } from 'react'
import { Activity, BarChart3, Database, Gauge, Layers, Loader2, RefreshCw, Zap } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Activity, BarChart3, Boxes, Cloud, Database, Gauge, GitBranch, HardDrive,
Layers, Loader2, RefreshCw, Server, Tag, Zap,
} from 'lucide-react'
import { cn } from '../../lib/utils'
import { subTabIdle } from '../../lib/tabActive'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
type Kpis = {
orders: number
revenue: number
aov: number
units: number
regions: number
year_min: number | null
year_max: number | null
orders: number; revenue: number; aov: number; units: number; regions: number
year_min: number | null; year_max: number | null; avg_unit_price: number
}
type Analytics = {
ok: boolean
table?: string
location?: string
kpis?: Kpis
ok: boolean; filtered?: boolean; where?: string; kpis?: Kpis
by_year?: { year: number; revenue: number; orders: number }[]
by_region?: { region: string; revenue: number }[]
by_category?: { category: string; revenue: number }[]
by_region?: { region: string; revenue: number; orders: number }[]
by_category?: { category: string; revenue: number; orders: number }[]
by_channel?: { channel: string; revenue: number; orders: number }[]
matrix?: { region: string; category: string; revenue: number }[]
trino_stats?: { processed_rows?: number; processed_bytes?: number; elapsed_ms?: number; cpu_ms?: number; peak_memory_bytes?: number; splits?: number }
error?: string
}
type Filters = { ok: boolean; years: number[]; regions: string[]; categories: string[]; channels: string[] }
type SourceItem = {
engine: string; catalog?: string; schema?: string; database?: string; table: string
format: string; storage: string; location: string; s3_endpoint?: string; namenode?: string
metastore?: string; exec?: string; data_files?: number; data_bytes?: number; rows?: number
snapshot_id?: string; snapshot_at?: string; measured_at?: string; error?: string
}
type Source = { ok: boolean; sources?: SourceItem[]; hdfs_namenode?: string }
type Engine = {
name: string
measured: boolean
latency_ms: number
model: string
storage: string
best_for: string
note: string
name: string; measured: boolean; ok: boolean; latency_ms: number; wall_ms?: number
model: string; storage: string; best_for: string; note: string
processed_rows?: number; processed_bytes?: number; version?: string; measured_at?: string
by_region?: { region: string; revenue: number; n: number }[]
}
type Engines = { ok: boolean; benchmark?: string; trino_sql?: string; hive_sql?: string; engines?: Engine[] }
type Engines = {
ok: boolean
benchmark_sql?: string
rows_scanned?: number
measured_engine?: string
engines?: Engine[]
error?: string
}
const usd = (n: number) => {
if (n >= 1e9) return `$${(n / 1e9).toFixed(2)}B`
if (n >= 1e6) return `$${(n / 1e6).toFixed(1)}M`
if (n >= 1e3) return `$${(n / 1e3).toFixed(0)}K`
return `$${n.toFixed(0)}`
}
const num = (n: number) => n.toLocaleString('en-US')
const ENGINE_COLORS: Record<string, string> = {
Trino: '#22d3ee',
Impala: '#fb923c',
Hive: '#a78bfa',
const usd = (n: number) => (n >= 1e9 ? `$${(n / 1e9).toFixed(2)}B` : n >= 1e6 ? `$${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `$${(n / 1e3).toFixed(0)}K` : `$${n.toFixed(0)}`)
const num = (n?: number | null) => (n == null ? '—' : n.toLocaleString('en-US'))
const bytes = (n?: number | null) => {
if (n == null) return '—'
if (n >= 1e9) return `${(n / 1e9).toFixed(2)} GB`
if (n >= 1e6) return `${(n / 1e6).toFixed(2)} MB`
if (n >= 1e3) return `${(n / 1e3).toFixed(1)} KB`
return `${n} B`
}
const ms = (n?: number | null) => (n == null ? '—' : n >= 1000 ? `${(n / 1000).toFixed(2)} s` : `${Math.round(n)} ms`)
const ENGINE_COLORS: Record<string, string> = { Trino: '#22d3ee', Impala: '#fb923c', Hive: '#a78bfa' }
function Kpi({ icon: Icon, label, value, sub }: { icon: typeof Gauge; label: string; value: string; sub?: string }) {
return (
@@ -70,7 +60,7 @@ function Kpi({ icon: Icon, label, value, sub }: { icon: typeof Gauge; label: str
)
}
function BarRow({ label, value, max, display, color = '#34d399' }: { label: string; value: number; max: number; display: string; color?: string }) {
function BarRow({ label, value, max, display, sub, color = '#34d399' }: { label: string; value: number; max: number; display: string; sub?: string; color?: string }) {
const pct = max > 0 ? Math.max(2, (value / max) * 100) : 0
return (
<div className="flex items-center gap-2 text-[10px]">
@@ -78,7 +68,7 @@ function BarRow({ label, value, max, display, color = '#34d399' }: { label: stri
<div className="relative h-3.5 flex-1 overflow-hidden rounded-sm bg-surface-overlay/60">
<div className="h-full rounded-sm" style={{ width: `${pct}%`, background: color }} />
</div>
<span className="w-16 shrink-0 text-right font-mono text-foreground">{display}</span>
<span className="w-28 shrink-0 text-right font-mono text-foreground">{display}{sub && <span className="text-foreground-faint"> · {sub}</span>}</span>
</div>
)
}
@@ -94,20 +84,39 @@ function ChartCard({ title, icon: Icon, children }: { title: string; icon: typeo
)
}
function Chip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button type="button" onClick={onClick} className={cn('rounded-full border px-2.5 py-0.5 text-[10px] font-medium transition', active ? 'border-emerald-400/60 bg-emerald-400/20 text-emerald-300' : 'border-border text-foreground-muted hover:border-emerald-400/40 hover:text-foreground')}>
{children}
</button>
)
}
export function HadoopAnalytics() {
const [filters, setFilters] = useState<Filters | null>(null)
const [data, setData] = useState<Analytics | null>(null)
const [source, setSource] = useState<Source | null>(null)
const [engines, setEngines] = useState<Engines | null>(null)
const [loading, setLoading] = useState(false)
const [selYears, setSelYears] = useState<number[]>([])
const [selRegions, setSelRegions] = useState<string[]>([])
const [selCats, setSelCats] = useState<string[]>([])
const [selChannels, setSelChannels] = useState<string[]>([])
const load = useCallback(async () => {
const qs = useMemo(() => {
const p = new URLSearchParams()
if (selYears.length) p.set('years', selYears.join(','))
if (selRegions.length) p.set('regions', selRegions.join(','))
if (selCats.length) p.set('categories', selCats.join(','))
if (selChannels.length) p.set('channels', selChannels.join(','))
return p.toString()
}, [selYears, selRegions, selCats, selChannels])
const loadAnalytics = useCallback(async (query: string) => {
setLoading(true)
try {
const [a, e] = await Promise.all([
fetch('/api/hadoop/analytics').then((r) => r.json()),
fetch('/api/hadoop/engines').then((r) => r.json()),
])
const a = await fetch(`/api/hadoop/analytics${query ? `?${query}` : ''}`).then((r) => r.json())
setData(a)
setEngines(e)
} catch {
setData({ ok: false, error: 'Analytics API unavailable' })
} finally {
@@ -115,9 +124,24 @@ export function HadoopAnalytics() {
}
}, [])
useEffect(() => {
load()
}, [load])
const loadSideband = useCallback(async () => {
try {
const [f, s, e] = await Promise.all([
fetch('/api/hadoop/filters').then((r) => r.json()),
fetch('/api/hadoop/source').then((r) => r.json()),
fetch('/api/hadoop/engines').then((r) => r.json()),
])
setFilters(f); setSource(s); setEngines(e)
} catch { /* ignore */ }
}, [])
useEffect(() => { loadSideband() }, [loadSideband])
useEffect(() => { loadAnalytics(qs) }, [qs, loadAnalytics])
const toggle = <T,>(v: T, list: T[], set: (x: T[]) => void) =>
set(list.includes(v) ? list.filter((x) => x !== v) : [...list, v])
const reset = () => { setSelYears([]); setSelRegions([]); setSelCats([]); setSelChannels([]) }
const anyFilter = selYears.length + selRegions.length + selCats.length + selChannels.length > 0
const k = data?.kpis
const maxYear = Math.max(1, ...(data?.by_year || []).map((r) => r.revenue))
@@ -125,110 +149,166 @@ export function HadoopAnalytics() {
const maxCat = Math.max(1, ...(data?.by_category || []).map((r) => r.revenue))
const maxChan = Math.max(1, ...(data?.by_channel || []).map((r) => r.revenue))
const maxLat = Math.max(1, ...(engines?.engines || []).map((e) => e.latency_ms))
const matrixRegions = useMemo(() => [...new Set((data?.matrix || []).map((m) => m.region))], [data])
const matrixCats = useMemo(() => [...new Set((data?.matrix || []).map((m) => m.category))], [data])
const matrixMax = Math.max(1, ...(data?.matrix || []).map((m) => m.revenue))
const matrixVal = (r: string, c: string) => data?.matrix?.find((m) => m.region === r && m.category === c)?.revenue || 0
return (
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto p-3">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-[11px] font-semibold text-foreground">Lakehouse Analytics</p>
<p className="font-mono text-[9px] text-foreground-muted">{data?.location || 'iceberg.hadoop.historical_sales'}</p>
{/* Filter bar */}
<div className="mb-3 rounded-lg border border-border bg-surface/60 p-2.5">
<div className="mb-1.5 flex items-center justify-between">
<span className="flex items-center gap-1.5 text-[11px] font-semibold text-foreground"><Tag className="h-3.5 w-3.5 text-emerald-400" /> Filters</span>
<div className="flex items-center gap-2">
{anyFilter && <button type="button" onClick={reset} className="text-[10px] text-foreground-muted underline hover:text-foreground">Reset</button>}
<button type="button" onClick={() => { loadSideband(); loadAnalytics(qs) }} className={cn('rounded-md px-2.5 py-1 text-[10px]', subTabIdle)}>
<RefreshCw className={cn('inline h-3 w-3', loading && 'animate-spin')} /> Refresh
</button>
</div>
</div>
<div className="space-y-1.5">
<FilterRow label="Year" items={(filters?.years || []).map(String)} sel={selYears.map(String)} onToggle={(v) => toggle(Number(v), selYears, setSelYears)} />
<FilterRow label="Region" items={filters?.regions || []} sel={selRegions} onToggle={(v) => toggle(v, selRegions, setSelRegions)} />
<FilterRow label="Category" items={filters?.categories || []} sel={selCats} onToggle={(v) => toggle(v, selCats, setSelCats)} />
<FilterRow label="Channel" items={filters?.channels || []} sel={selChannels} onToggle={(v) => toggle(v, selChannels, setSelChannels)} />
</div>
<button type="button" onClick={load} className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
<RefreshCw className={cn('inline h-3 w-3', loading && 'animate-spin')} /> Refresh
</button>
</div>
{loading && !data && (
<p className="flex items-center gap-2 text-[11px] text-foreground-muted">
<Loader2 className="h-4 w-4 animate-spin" /> Querying Trino
</p>
)}
{loading && !data && <p className="flex items-center gap-2 text-[11px] text-foreground-muted"><Loader2 className="h-4 w-4 animate-spin" /> Querying Trino</p>}
{data && !data.ok && <p className="text-[11px] text-danger">{data.error}</p>}
{k && (
<>
<div className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
<div className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-7">
<Kpi icon={Zap} label="Revenue" value={usd(k.revenue)} sub={`${k.year_min}${k.year_max}`} />
<Kpi icon={Activity} label="Orders" value={num(k.orders)} />
<Kpi icon={Gauge} label="Avg order" value={usd(k.aov)} />
<Kpi icon={Layers} label="Units" value={num(k.units)} />
<Kpi icon={Tag} label="Avg unit $" value={usd(k.avg_unit_price)} />
<Kpi icon={Database} label="Regions" value={String(k.regions)} />
<Kpi icon={BarChart3} label="Years" value={`${(k.year_max ?? 0) - (k.year_min ?? 0) + 1}`} sub={`${k.year_min}${k.year_max}`} />
<Kpi icon={BarChart3} label="Scanned" value={num(data?.trino_stats?.processed_rows)} sub={bytes(data?.trino_stats?.processed_bytes)} />
</div>
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2">
<ChartCard title="Revenue by year" icon={BarChart3}>
{(data.by_year || []).map((r) => (
<BarRow key={r.year} label={String(r.year)} value={r.revenue} max={maxYear} display={usd(r.revenue)} color="#22d3ee" />
))}
{(data.by_year || []).map((r) => <BarRow key={r.year} label={String(r.year)} value={r.revenue} max={maxYear} display={usd(r.revenue)} sub={`${num(r.orders)} ord`} color="#22d3ee" />)}
</ChartCard>
<ChartCard title="Revenue by region" icon={BarChart3}>
{(data.by_region || []).map((r) => (
<BarRow key={r.region} label={r.region} value={r.revenue} max={maxRegion} display={usd(r.revenue)} color="#34d399" />
))}
{(data.by_region || []).map((r) => <BarRow key={r.region} label={r.region} value={r.revenue} max={maxRegion} display={usd(r.revenue)} sub={`${num(r.orders)} ord`} color="#34d399" />)}
</ChartCard>
<ChartCard title="Revenue by product category" icon={BarChart3}>
{(data.by_category || []).map((r) => (
<BarRow key={r.category} label={r.category} value={r.revenue} max={maxCat} display={usd(r.revenue)} color="#fbbf24" />
))}
{(data.by_category || []).map((r) => <BarRow key={r.category} label={r.category} value={r.revenue} max={maxCat} display={usd(r.revenue)} sub={`${num(r.orders)} ord`} color="#fbbf24" />)}
</ChartCard>
<ChartCard title="Revenue by channel" icon={BarChart3}>
{(data.by_channel || []).map((r) => (
<BarRow key={r.channel} label={r.channel} value={r.revenue} max={maxChan} display={usd(r.revenue)} color="#a78bfa" />
))}
{(data.by_channel || []).map((r) => <BarRow key={r.channel} label={r.channel} value={r.revenue} max={maxChan} display={usd(r.revenue)} sub={`${num(r.orders)} ord`} color="#a78bfa" />)}
</ChartCard>
</div>
{/* Region x Category heatmap */}
{matrixRegions.length > 0 && (
<div className="mt-2 rounded-lg border border-border bg-surface/60 p-3">
<h3 className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold text-foreground"><Boxes className="h-3.5 w-3.5 text-emerald-400" /> Revenue · region × category</h3>
<div className="overflow-x-auto">
<table className="text-[10px]">
<thead><tr><th className="px-2 py-1 text-left text-foreground-faint">Region \ Category</th>{matrixCats.map((c) => <th key={c} className="px-2 py-1 text-right text-foreground-muted">{c}</th>)}</tr></thead>
<tbody>
{matrixRegions.map((r) => (
<tr key={r}>
<td className="px-2 py-1 font-medium text-foreground-muted">{r}</td>
{matrixCats.map((c) => {
const v = matrixVal(r, c)
const a = Math.max(0.06, v / matrixMax)
return <td key={c} className="px-2 py-1 text-right font-mono text-foreground" style={{ background: `rgba(52,211,153,${a})` }}>{usd(v)}</td>
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
{/* Data lineage: where is this read from */}
<div className="mt-3 rounded-lg border border-border bg-surface/60 p-3">
<h3 className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold text-foreground"><GitBranch className="h-3.5 w-3.5 text-cyan-400" /> Where is this data read from?</h3>
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2">
{(source?.sources || []).map((s) => {
const isTrino = s.engine === 'Trino'
return (
<div key={s.engine} className="rounded-md border border-border/60 bg-surface-overlay/30 p-2.5">
<div className="mb-1.5 flex items-center gap-1.5 text-[11px] font-semibold" style={{ color: ENGINE_COLORS[s.engine] || '#94a3b8' }}>
{isTrino ? <Cloud className="h-3.5 w-3.5" /> : <HardDrive className="h-3.5 w-3.5" />} {s.engine} {s.storage}
</div>
<dl className="grid grid-cols-[88px_1fr] gap-x-2 gap-y-0.5 text-[10px]">
<Dt>Table</Dt><Dd mono>{(s.catalog || s.database)}.{s.schema ? `${s.schema}.` : ''}{s.table}</Dd>
<Dt>Format</Dt><Dd>{s.format}</Dd>
<Dt>Location</Dt><Dd mono>{s.location}</Dd>
{isTrino ? <><Dt>S3 endpoint</Dt><Dd mono>{s.s3_endpoint}</Dd></> : <><Dt>NameNode</Dt><Dd mono>{s.namenode}</Dd></>}
<Dt>Metastore</Dt><Dd>{s.metastore}</Dd>
<Dt>Data files</Dt><Dd>{num(s.data_files)} · {bytes(s.data_bytes)}</Dd>
<Dt>Rows</Dt><Dd>{num(s.rows)}</Dd>
{s.snapshot_id && <><Dt>Snapshot</Dt><Dd mono>{s.snapshot_id} @ {s.snapshot_at}</Dd></>}
{s.exec && <><Dt>Exec</Dt><Dd>{s.exec}</Dd></>}
</dl>
{s.error && <p className="mt-1 text-[9px] text-amber-400">{s.error}</p>}
</div>
)
})}
</div>
</div>
{/* Engine comparison */}
<div className="mt-3 rounded-lg border border-border bg-surface/60 p-3">
<div className="mb-1 flex items-center justify-between gap-2">
<h3 className="flex items-center gap-1.5 text-[11px] font-semibold text-foreground">
<Zap className="h-3.5 w-3.5 text-cyan-400" /> Query engines · Impala &amp; Hive vs Trino
</h3>
{engines?.rows_scanned ? (
<span className="text-[9px] text-foreground-faint">{num(engines.rows_scanned)} rows · lower = faster</span>
) : null}
<h3 className="flex items-center gap-1.5 text-[11px] font-semibold text-foreground"><Server className="h-3.5 w-3.5 text-cyan-400" /> Query engines · Impala &amp; Hive vs Trino</h3>
<span className="text-[9px] text-foreground-faint">{engines?.benchmark} · lower = faster</span>
</div>
<p className="mb-2 text-[9px] text-foreground-muted">
Same aggregation over the curated lakehouse table.{' '}
<span className="text-cyan-400">Trino is measured live</span>; Impala &amp; Hive are representative
reference figures (those engines are not deployed on this platform).
<span className="text-cyan-400">Trino</span> (S3/Iceberg) and <span className="text-[#a78bfa]">Hive</span> (HDFS/CSV, Apache Hive 3.1.3 · MapReduce) are both <b>measured live</b>;
Impala is a representative reference (daemons not deployed on this cluster).
</p>
<div className="space-y-2">
{(engines?.engines || []).map((e) => (
<div key={e.name} className="rounded-md border border-border/60 bg-surface-overlay/30 px-2.5 py-2">
<div className="flex items-center gap-2 text-[10px]">
<span className="w-14 shrink-0 font-semibold" style={{ color: ENGINE_COLORS[e.name] || '#94a3b8' }}>{e.name}</span>
<span
className={cn(
'rounded px-1.5 py-px text-[8px] font-medium uppercase tracking-wide',
e.measured ? 'bg-cyan-500/20 text-cyan-300' : 'bg-foreground-faint/15 text-foreground-faint',
)}
>
{e.measured ? 'live' : 'representative'}
</span>
<span className={cn('rounded px-1.5 py-px text-[8px] font-medium uppercase tracking-wide', e.measured ? 'bg-cyan-500/20 text-cyan-300' : 'bg-foreground-faint/15 text-foreground-faint')}>{e.measured ? 'live' : 'representative'}</span>
<div className="relative h-3.5 flex-1 overflow-hidden rounded-sm bg-surface-overlay/60">
<div
className="h-full rounded-sm"
style={{ width: `${Math.max(3, (e.latency_ms / maxLat) * 100)}%`, background: ENGINE_COLORS[e.name] || '#94a3b8' }}
/>
<div className="h-full rounded-sm" style={{ width: `${Math.max(3, (e.latency_ms / maxLat) * 100)}%`, background: ENGINE_COLORS[e.name] || '#94a3b8' }} />
</div>
<span className="w-16 shrink-0 text-right font-mono text-foreground">{num(e.latency_ms)} ms</span>
<span className="w-16 shrink-0 text-right font-mono text-foreground">{ms(e.latency_ms)}</span>
</div>
<div className="mt-1 grid grid-cols-1 gap-x-4 gap-y-0.5 pl-16 text-[9px] text-foreground-muted sm:grid-cols-3">
<div className="mt-1 grid grid-cols-1 gap-x-4 gap-y-0.5 pl-16 text-[9px] text-foreground-muted sm:grid-cols-2 lg:grid-cols-4">
<span><span className="text-foreground-faint">Model:</span> {e.model}</span>
<span><span className="text-foreground-faint">Storage:</span> {e.storage}</span>
<span><span className="text-foreground-faint">Best for:</span> {e.best_for}</span>
{e.processed_rows != null && <span><span className="text-foreground-faint">Scanned:</span> {num(e.processed_rows)} rows · {bytes(e.processed_bytes)}</span>}
{e.version && <span><span className="text-foreground-faint">Engine:</span> v{e.version}{e.wall_ms ? ` · wall ${ms(e.wall_ms)}` : ''}</span>}
{e.measured_at && <span><span className="text-foreground-faint">Measured:</span> {e.measured_at.replace('T', ' ').replace('Z', ' UTC')}</span>}
<span className="lg:col-span-4 text-foreground-faint">{e.note}</span>
</div>
</div>
))}
</div>
{engines?.benchmark_sql && (
<pre className="mt-2 overflow-x-auto rounded bg-black/30 p-2 font-mono text-[9px] leading-relaxed text-foreground-faint">{engines.benchmark_sql}</pre>
)}
{engines?.trino_sql && <pre className="mt-2 overflow-x-auto rounded bg-black/30 p-2 font-mono text-[9px] leading-relaxed text-foreground-faint">Trino: {engines.trino_sql}{engines.hive_sql ? `\nHive: ${engines.hive_sql}` : ''}</pre>}
</div>
</div>
)
}
function FilterRow({ label, items, sel, onToggle }: { label: string; items: string[]; sel: string[]; onToggle: (v: string) => void }) {
if (!items.length) return null
return (
<div className="flex items-start gap-2">
<span className="mt-0.5 w-16 shrink-0 text-[10px] font-medium text-foreground-faint">{label}</span>
<div className="flex flex-wrap gap-1">
{items.map((it) => <Chip key={it} active={sel.includes(it)} onClick={() => onToggle(it)}>{it}</Chip>)}
</div>
</div>
)
}
function Dt({ children }: { children: React.ReactNode }) { return <dt className="text-foreground-faint">{children}</dt> }
function Dd({ children, mono }: { children: React.ReactNode; mono?: boolean }) { return <dd className={cn('truncate text-foreground-muted', mono && 'font-mono text-[9px]')} title={typeof children === 'string' ? children : undefined}>{children}</dd> }