feat: realtime Live dashboard in Data Explorer + fix panel layout
- New "Live" tab: auto-polls /api/federated/live every 2.5s with animated counters, ingestion throughput sparkline, per-source write-rate bars, a region scorecard matrix (heat-shaded) and live business breakdown charts (region/channel/status/customers/ telemetry/supply) - Backend /api/federated/live: instant source estimates (Postgres), monotonic max(event_id) for MySQL and Mongo estimated count for immediate movement, Cassandra from cached matrix; business aggs cached over the small Hadoop lake tables (short TTL) - Fix embedded Trino panels being squeezed with internal scrollbars by making panels/grids shrink-0 so the page scrolls instead
This commit is contained in:
@@ -235,6 +235,116 @@ async def get_catalogs():
|
||||
return {"ok": True, "catalogs": out, "source_totals": totals, "count": len(out)}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Realtime business dashboard — fast: instant source estimates + short-TTL
|
||||
# cached aggregations over the small materialized Hadoop lake tables.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
_live_aggs: dict[str, Any] = {"ts": 0.0, "data": None}
|
||||
_LIVE_AGG_TTL = 6.0
|
||||
_avg_order_cache: dict[str, Any] = {"ts": 0.0, "val": 0.0}
|
||||
|
||||
|
||||
def _avg_order_value() -> float:
|
||||
now = time.time()
|
||||
if now - _avg_order_cache["ts"] < 300 and _avg_order_cache["val"]:
|
||||
return _avg_order_cache["val"]
|
||||
res = _trino("SELECT avg(amount) FROM iceberg.hadoop.orders_ext", 1)
|
||||
val = 0.0
|
||||
if res.get("ok"):
|
||||
try:
|
||||
val = float((res.get("rows") or [[0]])[0][0] or 0)
|
||||
except Exception:
|
||||
val = 0.0
|
||||
_avg_order_cache["val"] = val
|
||||
_avg_order_cache["ts"] = now
|
||||
return val
|
||||
|
||||
|
||||
def _region_matrix_from_lake() -> list[dict]:
|
||||
o = _terms("SELECT region k, count(*) c, sum(amount) rev FROM iceberg.hadoop.orders_ext GROUP BY region", "k", "c", "rev")
|
||||
e = _terms("SELECT region k, count(*) c FROM iceberg.hadoop.employees_ext GROUP BY region", "k", "c")
|
||||
sup = _terms("SELECT region k, count(*) c FROM iceberg.hadoop.supply_events_ext GROUP BY region", "k", "c")
|
||||
regions: dict[str, dict] = {}
|
||||
for x in o:
|
||||
regions.setdefault(x["key"], {})["orders"] = x["count"]
|
||||
regions[x["key"]]["revenue"] = x.get("value", 0)
|
||||
for x in e:
|
||||
regions.setdefault(x["key"], {})["hr_events"] = x["count"]
|
||||
for x in sup:
|
||||
regions.setdefault(x["key"], {})["supply_events"] = x["count"]
|
||||
rows = [{"region": k, "orders": v.get("orders", 0), "revenue": v.get("revenue", 0),
|
||||
"hr_events": v.get("hr_events", 0), "supply_events": v.get("supply_events", 0)}
|
||||
for k, v in regions.items() if k]
|
||||
rows.sort(key=lambda r: r.get("revenue") or 0, reverse=True)
|
||||
return rows
|
||||
|
||||
|
||||
def _live_business_aggs() -> dict[str, Any]:
|
||||
now = time.time()
|
||||
if _live_aggs["data"] is not None and now - _live_aggs["ts"] < _LIVE_AGG_TTL:
|
||||
return _live_aggs["data"]
|
||||
data = {
|
||||
"orders_by_region": _terms("SELECT region, count(*) c, sum(amount) rev FROM iceberg.hadoop.orders_ext GROUP BY region ORDER BY rev DESC", "region", "c", "rev"),
|
||||
"orders_by_status": _terms("SELECT order_status k, count(*) c FROM iceberg.hadoop.orders_ext GROUP BY order_status ORDER BY c DESC", "k", "c"),
|
||||
"orders_by_channel": _terms("SELECT sales_channel k, count(*) c, sum(amount) rev FROM iceberg.hadoop.orders_ext GROUP BY sales_channel ORDER BY rev DESC", "k", "c", "rev"),
|
||||
"top_customers": _terms("SELECT customer_name k, count(*) c, sum(amount) rev FROM iceberg.hadoop.orders_ext GROUP BY customer_name ORDER BY rev DESC LIMIT 8", "k", "c", "rev"),
|
||||
"telemetry_by_metric": _terms("SELECT metric_type k, count(*) c, avg(metric_value) v FROM iceberg.hadoop.telemetry_ext GROUP BY metric_type ORDER BY c DESC", "k", "c", "v"),
|
||||
"supply_by_type": _terms("SELECT type k, count(*) c FROM iceberg.hadoop.supply_events_ext GROUP BY type ORDER BY c DESC", "k", "c"),
|
||||
"region_matrix": _region_matrix_from_lake(),
|
||||
}
|
||||
_live_aggs["data"] = data
|
||||
_live_aggs["ts"] = now
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/live")
|
||||
async def get_live():
|
||||
import sql_console as s
|
||||
orders = s._table_row_count("postgres", "public.sales_orders") or 0
|
||||
# MySQL event_id is monotonic, so max(event_id) tracks inserts in real time
|
||||
# (the planner estimate only refreshes after ANALYZE).
|
||||
hr_res = _trino("SELECT max(event_id) FROM mysql_hr.hr.employee_events", 1)
|
||||
hr = 0
|
||||
if hr_res.get("ok"):
|
||||
try:
|
||||
hr = int((hr_res.get("rows") or [[0]])[0][0] or 0)
|
||||
except Exception:
|
||||
hr = 0
|
||||
if not hr:
|
||||
hr = s._table_row_count("mysql", "hr.employee_events") or 0
|
||||
supply = s._table_row_count("mongodb", "supplychain.events") or 0
|
||||
# Cassandra has no cheap estimate — reuse the exact count from the cached
|
||||
# federated matrix query when available.
|
||||
telemetry = 0
|
||||
if _marquee.get("data") is None:
|
||||
_load_marquee()
|
||||
mq = _marquee.get("data") or {}
|
||||
for r in ((mq.get("matrix") or {}).get("rows") or []):
|
||||
if r.get("catalog") == "cassandra_telemetry":
|
||||
try:
|
||||
telemetry = int(r.get("records") or 0)
|
||||
except Exception:
|
||||
telemetry = 0
|
||||
avg_order = _avg_order_value()
|
||||
sources = [
|
||||
{"key": "orders", "label": "Orders", "engine": "PostgreSQL", "catalog": "postgres_sales", "rows": orders, "color": "#fbbf24"},
|
||||
{"key": "hr_events", "label": "HR events", "engine": "MySQL", "catalog": "mysql_hr", "rows": hr, "color": "#60a5fa"},
|
||||
{"key": "supply_events", "label": "Supply events", "engine": "MongoDB", "catalog": "mongodb_supplychain", "rows": supply, "color": "#a78bfa"},
|
||||
{"key": "telemetry", "label": "Telemetry", "engine": "Cassandra", "catalog": "cassandra_telemetry", "rows": telemetry, "color": "#22d3ee"},
|
||||
]
|
||||
return {
|
||||
"ok": True,
|
||||
"ts": datetime.now(timezone.utc).isoformat(),
|
||||
"sources": sources,
|
||||
"totals": {
|
||||
"records": orders + hr + supply + telemetry,
|
||||
"revenue_est": round(orders * avg_order, 2),
|
||||
"avg_order": round(avg_order, 2),
|
||||
},
|
||||
"business": _live_business_aggs(),
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Hadoop lake analytics (live over the small materialized external tables)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user