feat(hadoop): embedded lakehouse analytics + Impala/Hive vs Trino comparison
Adds an Analytics sub-tab to the Hadoop view with live KPIs and revenue breakdowns (by year/region/category/channel) queried from Trino over iceberg.hadoop.historical_sales, plus a query-engine comparison panel. Trino latency is measured live; Impala and Hive are shown as clearly-labelled representative figures (those engines are not deployed). New cached endpoints /api/hadoop/analytics and /api/hadoop/engines.
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Activity, BarChart3, Database, Gauge, Layers, Loader2, RefreshCw, Zap } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { subTabIdle } from '../../lib/tabActive'
|
||||
|
||||
type Kpis = {
|
||||
orders: number
|
||||
revenue: number
|
||||
aov: number
|
||||
units: number
|
||||
regions: number
|
||||
year_min: number | null
|
||||
year_max: number | null
|
||||
}
|
||||
|
||||
type Analytics = {
|
||||
ok: boolean
|
||||
table?: string
|
||||
location?: string
|
||||
kpis?: Kpis
|
||||
by_year?: { year: number; revenue: number; orders: number }[]
|
||||
by_region?: { region: string; revenue: number }[]
|
||||
by_category?: { category: string; revenue: number }[]
|
||||
by_channel?: { channel: string; revenue: number; orders: number }[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
type Engine = {
|
||||
name: string
|
||||
measured: boolean
|
||||
latency_ms: number
|
||||
model: string
|
||||
storage: string
|
||||
best_for: string
|
||||
note: string
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
function Kpi({ icon: Icon, label, value, sub }: { icon: typeof Gauge; label: string; value: string; sub?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-overlay/40 px-3 py-2">
|
||||
<div className="flex items-center gap-1.5 text-[9px] uppercase tracking-wide text-foreground-faint">
|
||||
<Icon className="h-3 w-3 text-emerald-400" /> {label}
|
||||
</div>
|
||||
<div className="mt-0.5 text-base font-semibold text-foreground">{value}</div>
|
||||
{sub && <div className="text-[9px] text-foreground-muted">{sub}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BarRow({ label, value, max, display, color = '#34d399' }: { label: string; value: number; max: number; display: string; color?: string }) {
|
||||
const pct = max > 0 ? Math.max(2, (value / max) * 100) : 0
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-[10px]">
|
||||
<span className="w-24 shrink-0 truncate text-foreground-muted" title={label}>{label}</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: `${pct}%`, background: color }} />
|
||||
</div>
|
||||
<span className="w-16 shrink-0 text-right font-mono text-foreground">{display}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChartCard({ title, icon: Icon, children }: { title: string; icon: typeof BarChart3; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="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">
|
||||
<Icon className="h-3.5 w-3.5 text-emerald-400" /> {title}
|
||||
</h3>
|
||||
<div className="space-y-1.5">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function HadoopAnalytics() {
|
||||
const [data, setData] = useState<Analytics | null>(null)
|
||||
const [engines, setEngines] = useState<Engines | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
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()),
|
||||
])
|
||||
setData(a)
|
||||
setEngines(e)
|
||||
} catch {
|
||||
setData({ ok: false, error: 'Analytics API unavailable' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const k = data?.kpis
|
||||
const maxYear = Math.max(1, ...(data?.by_year || []).map((r) => r.revenue))
|
||||
const maxRegion = Math.max(1, ...(data?.by_region || []).map((r) => r.revenue))
|
||||
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))
|
||||
|
||||
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>
|
||||
</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>
|
||||
)}
|
||||
{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">
|
||||
<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={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}`} />
|
||||
</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" />
|
||||
))}
|
||||
</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" />
|
||||
))}
|
||||
</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" />
|
||||
))}
|
||||
</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" />
|
||||
))}
|
||||
</ChartCard>
|
||||
</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}
|
||||
</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).
|
||||
</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>
|
||||
<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>
|
||||
<span className="w-16 shrink-0 text-right font-mono text-foreground">{num(e.latency_ms)} 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">
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ChevronRight, Download, Eye, FileText, Folder, Loader2, RefreshCw, Server, X } from 'lucide-react'
|
||||
import { BarChart3, ChevronRight, Download, Eye, FileText, Folder, HardDrive, Loader2, RefreshCw, Server, X } from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { subTabActive, subTabIdle } from '../../lib/tabActive'
|
||||
import { HadoopAnalytics } from './HadoopAnalytics'
|
||||
|
||||
type HdfsEntry = {
|
||||
name: string
|
||||
@@ -36,6 +37,7 @@ export function HdfsView() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [preview, setPreview] = useState<{ path: string; text: string; binary: boolean; truncated: boolean } | null>(null)
|
||||
const [previewing, setPreviewing] = useState(false)
|
||||
const [tab, setTab] = useState<'files' | 'analytics'>('files')
|
||||
|
||||
const loadHealth = useCallback(async () => {
|
||||
try {
|
||||
@@ -106,7 +108,15 @@ export function HdfsView() {
|
||||
{typeof health?.capacity_used_pct === 'number' ? ` (${health.capacity_used_pct}%)` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex gap-0.5 rounded-md border border-border p-0.5">
|
||||
<button type="button" onClick={() => setTab('files')} className={cn('inline-flex items-center gap-1 rounded px-2.5 py-1 text-[11px]', tab === 'files' ? subTabActive : subTabIdle)}>
|
||||
<HardDrive className="h-3 w-3" /> Files
|
||||
</button>
|
||||
<button type="button" onClick={() => setTab('analytics')} className={cn('inline-flex items-center gap-1 rounded px-2.5 py-1 text-[11px]', tab === 'analytics' ? subTabActive : subTabIdle)}>
|
||||
<BarChart3 className="h-3 w-3" /> Analytics
|
||||
</button>
|
||||
</div>
|
||||
<a href="http://10.0.21.61:9870" target="_blank" rel="noreferrer" className={cn('inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-[11px] font-medium', subTabActive)}>
|
||||
NameNode UI
|
||||
</a>
|
||||
@@ -116,6 +126,9 @@ export function HdfsView() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{tab === 'analytics' && <HadoopAnalytics />}
|
||||
|
||||
{tab === 'files' && (
|
||||
<div className="flex min-h-0 flex-1 flex-col p-3">
|
||||
<nav className="mb-2 flex flex-wrap items-center gap-1 text-[10px] text-foreground-muted">
|
||||
<button type="button" className="font-medium hover:text-emerald-400" onClick={() => setPath('/')}>
|
||||
@@ -207,6 +220,7 @@ export function HdfsView() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-black/60 p-6" onClick={() => setPreview(null)}>
|
||||
|
||||
Reference in New Issue
Block a user