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:
@@ -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 & 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 & 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 & 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> }
|
||||
|
||||
Reference in New Issue
Block a user