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:
mo
2026-06-26 15:44:43 +00:00
parent 1147826dee
commit 71a64d5a21
5 changed files with 443 additions and 3 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py lab_context.py agent_terminal.py workload.py node_registry.py node_ops.py topology_views.py supervisor.py approval_service.py db.py dockhand_envs.py presentation.py database_inventory.py presentation_upload.py presentation_static.py storage_s3.py elasticsearch_api.py sql_console.py hdfs_api.py ssh_terminal.py pipeline_ops.py .
COPY main.py lab_context.py agent_terminal.py workload.py node_registry.py node_ops.py topology_views.py supervisor.py approval_service.py db.py dockhand_envs.py presentation.py database_inventory.py presentation_upload.py presentation_static.py storage_s3.py elasticsearch_api.py sql_console.py hdfs_api.py ssh_terminal.py pipeline_ops.py hadoop_analytics.py .
RUN mkdir -p /data
ENV DATABASE_URL=sqlite:////data/atc-agents.db
EXPOSE 3201
+190
View File
@@ -0,0 +1,190 @@
"""Hadoop lakehouse analytics for the Command Center 'Hadoop' tab.
Runs live analytical queries on the curated lakehouse table
(iceberg.hadoop.historical_sales, stored as Parquet on Dell ECS S3) via Trino,
and exposes a query-engine comparison: Trino is measured live, while Impala and
Hive are shown as clearly-labelled *representative* figures (those engines are
not deployed on this platform). Results are cached briefly so the UI stays snappy.
"""
from __future__ import annotations
import asyncio
import time
from typing import Any
import httpx
from fastapi import APIRouter
from fastapi.responses import JSONResponse
import os
TRINO_URL = os.getenv("TRINO_URL", "http://10.0.21.50:8089").rstrip("/")
TRINO_USER = os.getenv("TRINO_USER", "mo")
TABLE = "iceberg.hadoop.historical_sales"
router = APIRouter(prefix="/api/hadoop", tags=["hadoop"])
_cache: dict[str, Any] = {}
_TTL = 60.0 # seconds
async def _trino(sql: str, deadline_s: float = 30.0) -> list[list[Any]]:
"""Execute a Trino statement and return all rows (list of lists)."""
headers = {
"X-Trino-User": TRINO_USER,
"X-Trino-Catalog": "iceberg",
"X-Trino-Schema": "hadoop",
}
rows: list[list[Any]] = []
start = time.monotonic()
async with httpx.AsyncClient(timeout=15.0) as client:
r = await client.post(f"{TRINO_URL}/v1/statement", content=sql.encode(), headers=headers)
r.raise_for_status()
payload = r.json()
while True:
err = payload.get("error")
if err:
raise RuntimeError(err.get("message", str(err)))
rows.extend(payload.get("data", []) or [])
nxt = payload.get("nextUri")
if not nxt:
break
if time.monotonic() - start > deadline_s:
raise TimeoutError("Trino query exceeded deadline")
await asyncio.sleep(0.05)
rr = await client.get(nxt, headers={"X-Trino-User": TRINO_USER})
rr.raise_for_status()
payload = rr.json()
return rows
def _cached(key: str):
item = _cache.get(key)
if item and (time.time() - item["ts"] < _TTL):
return item["data"]
return None
def _store(key: str, data: Any):
_cache[key] = {"data": data, "ts": time.time()}
return data
@router.get("/analytics")
async def analytics():
cached = _cached("analytics")
if cached is not None:
return JSONResponse(cached)
try:
kpis_q = (
f"SELECT count(*) AS orders, sum(amount) AS revenue, avg(amount) AS aov, "
f"sum(quantity) AS units, count(DISTINCT region) AS regions, "
f"min(order_year) AS min_y, max(order_year) AS max_y FROM {TABLE}"
)
by_year_q = (
f"SELECT order_year, sum(amount) AS revenue, count(*) AS orders "
f"FROM {TABLE} GROUP BY order_year ORDER BY order_year"
)
by_region_q = (
f"SELECT region, sum(amount) AS revenue FROM {TABLE} "
f"GROUP BY region ORDER BY revenue DESC"
)
by_category_q = (
f"SELECT product_category, sum(amount) AS revenue FROM {TABLE} "
f"GROUP BY product_category ORDER BY revenue DESC"
)
by_channel_q = (
f"SELECT channel, sum(amount) AS revenue, count(*) AS orders FROM {TABLE} "
f"GROUP BY channel ORDER BY revenue DESC"
)
kpis, by_year, by_region, by_category, by_channel = await asyncio.gather(
_trino(kpis_q), _trino(by_year_q), _trino(by_region_q),
_trino(by_category_q), _trino(by_channel_q),
)
k = kpis[0] if kpis else [0, 0, 0, 0, 0, None, None]
data = {
"ok": True,
"table": TABLE,
"location": "s3://data/hadoop/historical_sales (Iceberg/Parquet on Dell ECS)",
"kpis": {
"orders": int(k[0] or 0),
"revenue": float(k[1] or 0),
"aov": float(k[2] or 0),
"units": int(k[3] or 0),
"regions": int(k[4] or 0),
"year_min": k[5],
"year_max": k[6],
},
"by_year": [{"year": r[0], "revenue": float(r[1] or 0), "orders": int(r[2] or 0)} for r in by_year],
"by_region": [{"region": r[0], "revenue": float(r[1] or 0)} for r in by_region],
"by_category": [{"category": r[0], "revenue": float(r[1] or 0)} for r in by_category],
"by_channel": [{"channel": r[0], "revenue": float(r[1] or 0), "orders": int(r[2] or 0)} for r in by_channel],
}
return JSONResponse(_store("analytics", data))
except Exception as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=200)
@router.get("/engines")
async def engines():
"""Live-measured Trino latency vs representative Impala/Hive figures."""
cached = _cached("engines")
if cached is not None:
return JSONResponse(cached)
bench_sql = (
f"SELECT region, product_category, sum(amount) AS revenue, "
f"avg(unit_price) AS avg_price, count(*) AS n "
f"FROM {TABLE} GROUP BY region, product_category ORDER BY revenue DESC"
)
try:
# warm + measure (best of 2 to dampen JIT/scheduling noise)
await _trino(bench_sql)
t0 = time.monotonic()
await _trino(bench_sql)
trino_ms = round((time.monotonic() - t0) * 1000)
cnt = await _trino(f"SELECT count(*) FROM {TABLE}")
rows_scanned = int(cnt[0][0]) if cnt else 0
# Representative multipliers for an analytical aggregate over columnar
# Parquet of this size. Impala (MPP C++ daemons) is in the same league
# as Trino; Hive (Tez/MR batch) pays heavy job-startup cost.
data = {
"ok": True,
"benchmark_sql": bench_sql,
"rows_scanned": rows_scanned,
"measured_engine": "Trino",
"engines": [
{
"name": "Trino",
"measured": True,
"latency_ms": trino_ms,
"model": "MPP · in-memory pipelined",
"storage": "Iceberg / Parquet on S3",
"best_for": "Interactive federated SQL & lakehouse BI",
"note": "Live query on iceberg.hadoop.historical_sales",
},
{
"name": "Impala",
"measured": False,
"latency_ms": max(1, round(trino_ms * 0.9)),
"model": "MPP · C++ daemons (LLVM codegen)",
"storage": "Parquet on HDFS / S3 (HMS)",
"best_for": "Low-latency interactive BI on Hadoop",
"note": "Representative — no Impala daemon deployed",
},
{
"name": "Hive",
"measured": False,
"latency_ms": max(1, round(trino_ms * 8)),
"model": "Batch · Tez / MapReduce",
"storage": "ORC / Parquet on HDFS (HMS)",
"best_for": "Large ETL / batch transforms",
"note": "Representative — no HiveServer2 deployed",
},
],
}
return JSONResponse(_store("engines", data))
except Exception as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=200)
+2
View File
@@ -39,6 +39,7 @@ from presentation_static import get_static_deck, list_static_decks
from storage_s3 import router as storage_s3_router
from hdfs_api import router as hdfs_router
from pipeline_ops import router as pipeline_router
from hadoop_analytics import router as hadoop_router
from elasticsearch_api import router as elasticsearch_router
from sql_console import router as sql_router
from ssh_terminal import ssh_session
@@ -721,6 +722,7 @@ app = FastAPI(title="ATC Command Center API", lifespan=lifespan)
app.include_router(storage_s3_router)
app.include_router(hdfs_router)
app.include_router(pipeline_router)
app.include_router(hadoop_router)
app.include_router(elasticsearch_router)
app.include_router(sql_router)
app.add_middleware(
@@ -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 &amp; 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 &amp; 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>
)
}
+16 -2
View File
@@ -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)}>