213350ec75
Data Flow tab: - Prominent "Generate data" button (500 / 2K / 10K) that inserts a fresh burst of business rows into all source DBs on demand via a new POST /api/federated/generate (fresh connections, safe alongside the background streamer); result toast shows what was inserted, CDC streams it. - "Scripts" button + a "View generation scripts" action on the Data Generator node open a modal listing every generator script with full source, served by GET /api/dataflow/scripts. Sources are the real files: the live streaming generator (sliced live out of trino_federated.py) and the Airflow per-source DAGs + Faker scripts (mounted read-only from infra/airflow into the API). Knowledge Chat: - New "Vector DB" explorer modal: shows the ChromaDB chunking config (RecursiveCharacterTextSplitter 800/120, all-MiniLM-L6-v2, 384-dim, HNSW), collections & documents, and the actual stored chunks with text, metadata and an embedding preview (bars + values) so you can see exactly how files are split and written as vectors. Refactor: generator row-builders shared by the streamer and the on-demand burst.
915 lines
40 KiB
Python
915 lines
40 KiB
Python
"""Trino federated business analytics + Hadoop lakehouse (external Iceberg tables).
|
|
|
|
Provides:
|
|
- /api/federated/catalogs : the federated catalog landscape (estimates, fast)
|
|
- /api/federated/marquee : a single cross-source SQL joining PG+MySQL+Mongo
|
|
(the federation proof) — computed in the background, cached
|
|
- /api/federated/lake : business analytics over the Hadoop Iceberg lake
|
|
(iceberg.hadoop external tables) — live & fast
|
|
- /api/federated/materialize: (re)build the Hadoop external tables from the sources
|
|
- /api/federated/dictionary : business data dictionary with masked / visible flags
|
|
(also fed to the LLM so it knows the data in detail)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import threading
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Body
|
|
from fastapi.responses import JSONResponse
|
|
|
|
router = APIRouter(prefix="/api/federated", tags=["federated"])
|
|
|
|
BUSINESS_CATALOGS = ["postgres_sales", "mysql_hr", "mongodb_supplychain", "cassandra_telemetry", "iceberg"]
|
|
ALL_CATALOGS = BUSINESS_CATALOGS + ["kafka", "system"]
|
|
|
|
# Hadoop external tables (Iceberg-on-HDFS) materialized from the federated sources.
|
|
# Each: target table name in iceberg.hadoop -> source SELECT (timestamps cast to
|
|
# timestamp(6) which Iceberg requires; measures cast to double).
|
|
LAKE_SPECS: list[tuple[str, str, str]] = [
|
|
("orders_ext", "Sales orders (PostgreSQL)",
|
|
"SELECT order_id, customer_id, customer_name, customer_email, product_id, region, "
|
|
"sales_channel, order_status, currency, CAST(amount AS double) amount, "
|
|
"CAST(order_ts AS timestamp(6)) order_ts "
|
|
"FROM postgres_sales.public.sales_orders WHERE customer_name IS NOT NULL LIMIT 120000"),
|
|
("employees_ext", "Employee events (MySQL)",
|
|
"SELECT employee_id, employee_name, employee_email, employee_phone, department, role_name, "
|
|
"region, event_type, CAST(salary_change AS double) salary_change, "
|
|
"CAST(event_ts AS timestamp(6)) event_ts "
|
|
"FROM mysql_hr.hr.employee_events WHERE employee_name IS NOT NULL LIMIT 120000"),
|
|
("supply_events_ext", "Supply chain events (MongoDB)",
|
|
"SELECT event_id, type, region, source, CAST(amount AS double) amount, "
|
|
"CAST(ts AS timestamp(6)) ts FROM mongodb_supplychain.supplychain.events LIMIT 120000"),
|
|
("telemetry_ext", "Device telemetry (Cassandra)",
|
|
"SELECT device_id, CAST(metric_ts AS timestamp(6)) metric_ts, metric_type, "
|
|
"CAST(metric_value AS double) metric_value FROM cassandra_telemetry.telemetry.device_metrics LIMIT 120000"),
|
|
("customers_ext", "Distinct customers (PostgreSQL)",
|
|
"SELECT DISTINCT customer_id, customer_name, customer_email, region, sales_channel "
|
|
"FROM postgres_sales.public.sales_orders WHERE customer_name IS NOT NULL LIMIT 60000"),
|
|
("products_ext", "Distinct products (PostgreSQL)",
|
|
"SELECT DISTINCT product_id, region, sales_channel FROM postgres_sales.public.sales_orders "
|
|
"WHERE product_id IS NOT NULL LIMIT 20000"),
|
|
]
|
|
|
|
MARQUEE_SQL = (
|
|
"WITH o AS (SELECT region, count(*) orders, sum(amount) revenue "
|
|
"FROM postgres_sales.public.sales_orders GROUP BY region),\n"
|
|
" e AS (SELECT region, count(*) hr_events FROM mysql_hr.hr.employee_events GROUP BY region),\n"
|
|
" s AS (SELECT region, count(*) supply_events FROM mongodb_supplychain.supplychain.events GROUP BY region)\n"
|
|
"SELECT COALESCE(o.region, e.region, s.region) AS region,\n"
|
|
" o.orders, o.revenue, e.hr_events, s.supply_events\n"
|
|
"FROM o FULL JOIN e ON o.region = e.region\n"
|
|
" FULL JOIN s ON COALESCE(o.region, e.region) = s.region\n"
|
|
"ORDER BY o.revenue DESC NULLS LAST"
|
|
)
|
|
|
|
# Federated reach - ONE SQL touching every database/engine in the stack.
|
|
# Telemetry has no region dimension, so instead of a misleading join we
|
|
# summarise each source side-by-side in a single UNION ALL query.
|
|
MATRIX_SQL = (
|
|
"SELECT 1 ord, 'PostgreSQL' source, 'postgres_sales' catalog, 'public.sales_orders' dataset,\n"
|
|
" count(*) records, CAST(sum(amount) AS double) metric, 'total revenue' metric_label\n"
|
|
"FROM postgres_sales.public.sales_orders\n"
|
|
"UNION ALL SELECT 2,'MySQL','mysql_hr','hr.employee_events',count(*),\n"
|
|
" CAST(count(DISTINCT employee_id) AS double),'distinct employees' FROM mysql_hr.hr.employee_events\n"
|
|
"UNION ALL SELECT 3,'MongoDB','mongodb_supplychain','supplychain.events',count(*),\n"
|
|
" CAST(sum(amount) AS double),'event value' FROM mongodb_supplychain.supplychain.events\n"
|
|
"UNION ALL SELECT 4,'Cassandra','cassandra_telemetry','telemetry.device_metrics',count(*),\n"
|
|
" CAST(avg(metric_value) AS double),'avg metric value' FROM cassandra_telemetry.telemetry.device_metrics\n"
|
|
"UNION ALL SELECT 5,'Hadoop / Iceberg','iceberg','hadoop.orders_ext',count(*),\n"
|
|
" CAST(sum(amount) AS double),'lake revenue' FROM iceberg.hadoop.orders_ext\n"
|
|
"ORDER BY ord"
|
|
)
|
|
MATRIX_CATALOGS = ["postgres_sales", "mysql_hr", "mongodb_supplychain", "cassandra_telemetry", "iceberg"]
|
|
|
|
|
|
def _trino(sql: str, limit: int = 500) -> dict[str, Any]:
|
|
import sql_console as s
|
|
return s._run_trino(sql, limit)
|
|
|
|
|
|
def _rows_as_dicts(res: dict) -> list[dict]:
|
|
cols = res.get("columns", [])
|
|
return [{cols[i]: r[i] for i in range(min(len(cols), len(r)))} for r in res.get("rows", [])]
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# Marquee federated query (cross-source) — background cached
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
MARQUEE_PATH = Path("/data/federated_marquee.json")
|
|
_MARQUEE_TTL = 1800.0
|
|
_marquee: dict[str, Any] = {"running": False, "data": None}
|
|
_marquee_lock = threading.Lock()
|
|
|
|
|
|
def _load_marquee() -> None:
|
|
try:
|
|
if MARQUEE_PATH.exists():
|
|
_marquee["data"] = json.loads(MARQUEE_PATH.read_text())
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _marquee_worker() -> None:
|
|
try:
|
|
# Run the two federated queries concurrently so the total wait stays
|
|
# close to the slower of the two (region scorecard ~ matrix).
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
def timed(sql: str) -> tuple[dict, int]:
|
|
a = time.time()
|
|
r = _trino(sql, 50)
|
|
return r, int((time.time() - a) * 1000)
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as ex:
|
|
f_region = ex.submit(timed, MARQUEE_SQL)
|
|
f_matrix = ex.submit(timed, MATRIX_SQL)
|
|
res, elapsed = f_region.result()
|
|
mres, melapsed = f_matrix.result()
|
|
|
|
data = {
|
|
"ok": res.get("ok", False),
|
|
"sql": MARQUEE_SQL,
|
|
"catalogs": ["postgres_sales", "mysql_hr", "mongodb_supplychain"],
|
|
"elapsed_ms": elapsed,
|
|
"rows": _rows_as_dicts(res) if res.get("ok") else [],
|
|
"matrix": {
|
|
"ok": mres.get("ok", False),
|
|
"sql": MATRIX_SQL,
|
|
"catalogs": MATRIX_CATALOGS,
|
|
"elapsed_ms": melapsed,
|
|
"rows": _rows_as_dicts(mres) if mres.get("ok") else [],
|
|
"error": None if mres.get("ok") else str(mres.get("error", ""))[:300],
|
|
},
|
|
"error": None if res.get("ok") else str(res.get("error", ""))[:300],
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
_marquee["data"] = data
|
|
try:
|
|
MARQUEE_PATH.write_text(json.dumps(data))
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
_marquee["running"] = False
|
|
|
|
|
|
def _ensure_marquee(force: bool = False) -> None:
|
|
with _marquee_lock:
|
|
if _marquee["running"]:
|
|
return
|
|
data = _marquee["data"]
|
|
fresh = data and (time.time() - _ts(data.get("generated_at")) < _MARQUEE_TTL)
|
|
if fresh and not force:
|
|
return
|
|
_marquee["running"] = True
|
|
threading.Thread(target=_marquee_worker, daemon=True).start()
|
|
|
|
|
|
def _ts(iso: str | None) -> float:
|
|
if not iso:
|
|
return 0.0
|
|
try:
|
|
return datetime.fromisoformat(iso).timestamp()
|
|
except Exception:
|
|
return 0.0
|
|
|
|
|
|
@router.get("/marquee")
|
|
async def get_marquee():
|
|
if _marquee["data"] is None:
|
|
_load_marquee()
|
|
_ensure_marquee()
|
|
data = _marquee["data"]
|
|
return {
|
|
"ok": True,
|
|
"running": _marquee["running"],
|
|
"marquee": data,
|
|
"sql": MARQUEE_SQL,
|
|
}
|
|
|
|
|
|
@router.post("/marquee/refresh")
|
|
async def refresh_marquee():
|
|
_ensure_marquee(force=True)
|
|
return {"ok": True, "running": True}
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# Catalog landscape (fast — SHOW + planner estimates)
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
@router.get("/catalogs")
|
|
async def get_catalogs():
|
|
import sql_console as s
|
|
cat_res = _trino("SHOW CATALOGS", 100)
|
|
catalogs = [r[0] for r in cat_res.get("rows", [])] if cat_res.get("ok") else ALL_CATALOGS
|
|
totals = {
|
|
"orders": s._table_row_count("postgres", "public.sales_orders"),
|
|
"hr_events": s._table_row_count("mysql", "hr.employee_events"),
|
|
"supply_events": s._table_row_count("mongodb", "supplychain.events"),
|
|
}
|
|
out = []
|
|
labels = {
|
|
"postgres_sales": ("PostgreSQL", "Sales / orders OLTP", "#336791"),
|
|
"mysql_hr": ("MySQL", "HR / workforce", "#00758f"),
|
|
"mongodb_supplychain": ("MongoDB", "Supply chain events", "#4db33d"),
|
|
"cassandra_telemetry": ("Cassandra", "Device telemetry", "#1287b1"),
|
|
"iceberg": ("Iceberg / Hadoop", "Lakehouse external tables", "#5b8def"),
|
|
"kafka": ("Kafka", "CDC + streaming topics", "#231f20"),
|
|
"system": ("Trino", "Engine metadata", "#dd00a1"),
|
|
}
|
|
for c in catalogs:
|
|
lbl, desc, color = labels.get(c, (c, "", "#888"))
|
|
item: dict[str, Any] = {"catalog": c, "label": lbl, "desc": desc, "color": color, "business": c in BUSINESS_CATALOGS}
|
|
if c == "postgres_sales":
|
|
item["rows"] = totals["orders"]
|
|
elif c == "mysql_hr":
|
|
item["rows"] = totals["hr_events"]
|
|
elif c == "mongodb_supplychain":
|
|
item["rows"] = totals["supply_events"]
|
|
out.append(item)
|
|
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
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# Continuous live generator — keeps the platform "alive": while the Live
|
|
# dashboard is open it streams small, randomly-sized batches of business rows
|
|
# into the real source databases (PostgreSQL / MySQL / MongoDB / Cassandra),
|
|
# which CDC then propagates downstream. Batch sizes fluctuate every tick so the
|
|
# throughput visibly goes up and down. It only runs while someone is watching
|
|
# (the /live poll refreshes a heartbeat) so the tables don't grow unbounded.
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
import random as _rnd
|
|
from collections import deque as _deque
|
|
|
|
_GEN: dict[str, Any] = {
|
|
"enabled": True,
|
|
"running": False,
|
|
"interval": 4.0,
|
|
"last_seen": 0.0,
|
|
"last_tick": 0.0,
|
|
"counts": {"orders": 0, "hr_events": 0, "supply_events": 0, "telemetry": 0},
|
|
"last_batch": {"orders": 0, "hr_events": 0, "supply_events": 0, "telemetry": 0},
|
|
"by_region": {},
|
|
"by_status": {},
|
|
"tick_value": 0.0,
|
|
"feed": _deque(maxlen=14),
|
|
"base": None,
|
|
}
|
|
_gen_lock = threading.Lock()
|
|
_gen_conns: dict[str, Any] = {"pg": None, "mysql": None, "mongo": None, "cass": None}
|
|
|
|
_REGIONS = ["NA", "EU", "APAC", "LATAM", "EMEA", "MEA"]
|
|
_CHANNELS = ["B2B", "B2C", "ONLINE", "PARTNER", "RETAIL"]
|
|
_STATUSES = ["NEW", "PAID", "SHIPPED", "DELIVERED", "RETURNED", "CANCELLED"]
|
|
_CURR = ["EUR", "USD", "GBP", "JPY"]
|
|
_DEPTS = ["Engineering", "Sales", "Support", "Operations", "Finance", "HR", "Marketing"]
|
|
_ROLES = ["Analyst", "Engineer", "Manager", "Lead", "Specialist", "Director"]
|
|
_EVT = ["HIRE", "PROMOTION", "SALARY_CHANGE", "TRANSFER", "REVIEW", "EXIT"]
|
|
_SUPPLY = ["INSERT", "UPDATE", "REPLENISH", "SHIPMENT", "RETURN"]
|
|
_SRC = ["CRM", "ERP", "WMS", "API"]
|
|
_METRICS = ["temperature", "humidity", "pressure", "voltage", "current"]
|
|
|
|
|
|
def _gen_pg():
|
|
import psycopg2
|
|
import sql_console as s
|
|
c = _gen_conns["pg"]
|
|
if c is None or getattr(c, "closed", 1):
|
|
c = psycopg2.connect(host=s.DB_HOST, port=s.PG_PORT, user=s.PG_USER,
|
|
password=s.PG_PASS, dbname=s.PG_DB, connect_timeout=6)
|
|
c.autocommit = True
|
|
_gen_conns["pg"] = c
|
|
return c
|
|
|
|
|
|
def _gen_mysql():
|
|
import pymysql
|
|
import sql_console as s
|
|
c = _gen_conns["mysql"]
|
|
if c is None:
|
|
c = pymysql.connect(host=s.DB_HOST, port=s.MYSQL_PORT, user=s.MYSQL_USER,
|
|
password=s.MYSQL_PASS, database=s.MYSQL_DB, connect_timeout=6,
|
|
autocommit=True)
|
|
_gen_conns["mysql"] = c
|
|
else:
|
|
c.ping(reconnect=True)
|
|
return c
|
|
|
|
|
|
def _gen_mongo():
|
|
import sql_console as s
|
|
c = _gen_conns["mongo"]
|
|
if c is None:
|
|
c = s._mongo_client()
|
|
_gen_conns["mongo"] = c
|
|
return c[s.MONGO_DB]
|
|
|
|
|
|
def _gen_cass():
|
|
import sql_console as s
|
|
sess = _gen_conns["cass"]
|
|
if sess is None:
|
|
cluster = s._cass_cluster()
|
|
sess = cluster.connect()
|
|
_gen_conns["cass"] = sess
|
|
return sess
|
|
|
|
|
|
def _gen_reset(key: str):
|
|
try:
|
|
c = _gen_conns.get(key)
|
|
if c is not None:
|
|
c.close() if key != "cass" else c.cluster.shutdown()
|
|
except Exception:
|
|
pass
|
|
_gen_conns[key] = None
|
|
|
|
|
|
# Row builders — shared by the background streamer and the on-demand "Generate
|
|
# data" button, so both produce identical, realistic business rows.
|
|
_PG_INSERT = ("INSERT INTO public.sales_orders "
|
|
"(customer_id,product_id,region,sales_channel,order_ts,amount,currency,order_status) "
|
|
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s)")
|
|
_MYSQL_INSERT = ("INSERT INTO employee_events "
|
|
"(employee_id,department,role_name,region,event_type,salary_change,event_ts) "
|
|
"VALUES (%s,%s,%s,%s,%s,%s,%s)")
|
|
|
|
|
|
def _order_rows(n: int):
|
|
import datetime as dt
|
|
now = dt.datetime.utcnow()
|
|
by_r: dict[str, int] = {}
|
|
by_s: dict[str, int] = {}
|
|
val = 0.0
|
|
rows = []
|
|
for _ in range(n):
|
|
r = _rnd.choice(_REGIONS)
|
|
st = _rnd.choices(_STATUSES, weights=[5, 6, 5, 8, 2, 2])[0]
|
|
ch = _rnd.choice(_CHANNELS)
|
|
amt = round(_rnd.uniform(15, 9500), 2)
|
|
rows.append((_rnd.randint(1, 20000), _rnd.randint(1, 5000), r, ch, now, amt, _rnd.choice(_CURR), st))
|
|
by_r[r] = by_r.get(r, 0) + 1
|
|
by_s[st] = by_s.get(st, 0) + 1
|
|
val += amt
|
|
return rows, by_r, by_s, round(val, 2)
|
|
|
|
|
|
def _hr_rows(n: int):
|
|
import datetime as dt
|
|
now = dt.datetime.utcnow()
|
|
return [(_rnd.randint(1, 100000), _rnd.choice(_DEPTS), _rnd.choice(_ROLES),
|
|
_rnd.choice(_REGIONS), _rnd.choice(_EVT), round(_rnd.uniform(-2000, 6000), 2), now)
|
|
for _ in range(n)]
|
|
|
|
|
|
def _supply_docs(n: int):
|
|
import datetime as dt
|
|
import uuid
|
|
now = dt.datetime.utcnow()
|
|
return [{"event_id": str(uuid.uuid4()), "type": _rnd.choice(_SUPPLY),
|
|
"region": _rnd.choice(_REGIONS), "source": _rnd.choice(_SRC),
|
|
"amount": round(_rnd.uniform(10, 40000), 2), "ts": now.isoformat()}
|
|
for _ in range(n)]
|
|
|
|
|
|
def _tel_rows(n: int):
|
|
import datetime as dt
|
|
now = dt.datetime.utcnow()
|
|
return [(f"device-{_rnd.randint(1, 99999)}", now, _rnd.choice(_METRICS),
|
|
round(_rnd.uniform(0, 100), 3), "") for _ in range(n)]
|
|
|
|
|
|
_TEL_INSERT_TPL = ("INSERT INTO {ks}.device_metrics "
|
|
"(device_id, metric_ts, metric_type, metric_value, payload) VALUES (%s,%s,%s,%s,%s)")
|
|
|
|
|
|
def _gen_orders(n: int):
|
|
rows, by_r, by_s, val = _order_rows(n)
|
|
_gen_pg().cursor().executemany(_PG_INSERT, rows)
|
|
return by_r, by_s, val
|
|
|
|
|
|
def _gen_hr(n: int):
|
|
_gen_mysql().cursor().executemany(_MYSQL_INSERT, _hr_rows(n))
|
|
|
|
|
|
def _gen_supply(n: int):
|
|
docs = _supply_docs(n)
|
|
if docs:
|
|
_gen_mongo()["events"].insert_many(docs)
|
|
|
|
|
|
def _gen_tel(n: int):
|
|
import sql_console as s
|
|
sess = _gen_cass()
|
|
cql = _TEL_INSERT_TPL.format(ks=s.CASS_KS)
|
|
for row in _tel_rows(n):
|
|
sess.execute(cql, row)
|
|
|
|
|
|
def _generate_once(orders: int, hr: int, supply: int, tel: int) -> dict[str, Any]:
|
|
"""On-demand burst using FRESH short-lived connections (safe to run from a
|
|
request thread alongside the background streamer). Returns inserted counts."""
|
|
import sql_console as s
|
|
out = {"orders": 0, "hr_events": 0, "supply_events": 0, "telemetry": 0}
|
|
by_r: dict[str, int] = {}
|
|
by_s: dict[str, int] = {}
|
|
val = 0.0
|
|
if orders > 0:
|
|
try:
|
|
import psycopg2
|
|
rows, by_r, by_s, val = _order_rows(orders)
|
|
c = psycopg2.connect(host=s.DB_HOST, port=s.PG_PORT, user=s.PG_USER, password=s.PG_PASS, dbname=s.PG_DB, connect_timeout=8)
|
|
try:
|
|
c.autocommit = True
|
|
c.cursor().executemany(_PG_INSERT, rows)
|
|
out["orders"] = orders
|
|
finally:
|
|
c.close()
|
|
except Exception:
|
|
pass
|
|
if hr > 0:
|
|
try:
|
|
import pymysql
|
|
c = pymysql.connect(host=s.DB_HOST, port=s.MYSQL_PORT, user=s.MYSQL_USER, password=s.MYSQL_PASS, database=s.MYSQL_DB, connect_timeout=8, autocommit=True)
|
|
try:
|
|
c.cursor().executemany(_MYSQL_INSERT, _hr_rows(hr))
|
|
out["hr_events"] = hr
|
|
finally:
|
|
c.close()
|
|
except Exception:
|
|
pass
|
|
if supply > 0:
|
|
try:
|
|
cli = s._mongo_client()
|
|
try:
|
|
cli[s.MONGO_DB]["events"].insert_many(_supply_docs(supply))
|
|
out["supply_events"] = supply
|
|
finally:
|
|
cli.close()
|
|
except Exception:
|
|
pass
|
|
if tel > 0:
|
|
try:
|
|
cluster = s._cass_cluster()
|
|
sess = cluster.connect()
|
|
try:
|
|
cql = _TEL_INSERT_TPL.format(ks=s.CASS_KS)
|
|
for row in _tel_rows(tel):
|
|
sess.execute(cql, row)
|
|
out["telemetry"] = tel
|
|
finally:
|
|
cluster.shutdown()
|
|
except Exception:
|
|
pass
|
|
# fold into the live counters + feed so the dashboard reflects it instantly
|
|
with _gen_lock:
|
|
c = _GEN["counts"]
|
|
for k in out:
|
|
c[k] += out[k]
|
|
if out["orders"]:
|
|
_GEN["by_region"] = by_r
|
|
_GEN["by_status"] = by_s
|
|
_GEN["tick_value"] = val
|
|
top = max(by_r, key=by_r.get) if by_r else "—"
|
|
_GEN["feed"].appendleft({
|
|
"ts": datetime.now(timezone.utc).isoformat(),
|
|
"text": f"⚡ manual burst: +{out['orders']} orders · €{int(val):,} · top {top} · +{out['telemetry']} telemetry · +{out['hr_events']} HR · +{out['supply_events']} supply",
|
|
})
|
|
out["revenue"] = val
|
|
return out
|
|
|
|
|
|
def _gen_tick():
|
|
# fluctuating batch sizes, with the occasional spike, so throughput moves up & down
|
|
no = _rnd.randint(2, 40)
|
|
if _rnd.random() < 0.18:
|
|
no += _rnd.randint(25, 70)
|
|
nh = _rnd.randint(0, 18)
|
|
ns = _rnd.randint(0, 16)
|
|
nt = _rnd.randint(8, 55)
|
|
by_r: dict[str, int] = {}
|
|
by_s: dict[str, int] = {}
|
|
val = 0.0
|
|
try:
|
|
by_r, by_s, val = _gen_orders(no)
|
|
except Exception:
|
|
_gen_reset("pg"); no = 0
|
|
try:
|
|
_gen_hr(nh)
|
|
except Exception:
|
|
_gen_reset("mysql"); nh = 0
|
|
try:
|
|
_gen_supply(ns)
|
|
except Exception:
|
|
_gen_reset("mongo"); ns = 0
|
|
try:
|
|
_gen_tel(nt)
|
|
except Exception:
|
|
_gen_reset("cass"); nt = 0
|
|
with _gen_lock:
|
|
c = _GEN["counts"]
|
|
c["orders"] += no
|
|
c["hr_events"] += nh
|
|
c["supply_events"] += ns
|
|
c["telemetry"] += nt
|
|
_GEN["last_batch"] = {"orders": no, "hr_events": nh, "supply_events": ns, "telemetry": nt}
|
|
_GEN["by_region"] = by_r
|
|
_GEN["by_status"] = by_s
|
|
_GEN["tick_value"] = val
|
|
_GEN["last_tick"] = time.time()
|
|
if no:
|
|
top = max(by_r, key=by_r.get) if by_r else "—"
|
|
_GEN["feed"].appendleft({
|
|
"ts": datetime.now(timezone.utc).isoformat(),
|
|
"text": f"+{no} orders · €{int(val):,} · top {top} ({by_r.get(top, 0)}) · +{nt} telemetry · +{nh} HR",
|
|
})
|
|
|
|
|
|
def _gen_loop():
|
|
while True:
|
|
try:
|
|
if _GEN["enabled"] and (time.time() - _GEN["last_seen"] < 25):
|
|
_GEN["running"] = True
|
|
_gen_tick()
|
|
else:
|
|
_GEN["running"] = False
|
|
except Exception:
|
|
_GEN["running"] = False
|
|
time.sleep(max(2.0, float(_GEN["interval"])))
|
|
|
|
|
|
threading.Thread(target=_gen_loop, daemon=True, name="live-generator").start()
|
|
|
|
|
|
@router.post("/live/generator")
|
|
async def toggle_generator(body: dict = Body(default={})):
|
|
if "enabled" in body:
|
|
_GEN["enabled"] = bool(body["enabled"])
|
|
if "interval" in body:
|
|
try:
|
|
_GEN["interval"] = max(2.0, min(30.0, float(body["interval"])))
|
|
except Exception:
|
|
pass
|
|
_GEN["last_seen"] = time.time()
|
|
return {"ok": True, "enabled": _GEN["enabled"], "interval": _GEN["interval"], "running": _GEN["running"]}
|
|
|
|
|
|
@router.post("/generate")
|
|
async def generate_now(body: dict = Body(default={})):
|
|
"""Manual one-shot burst into the source systems (the Data Flow "Generate
|
|
data" button). `rows` controls the order volume; the other sources scale
|
|
with it. CDC streams everything downstream automatically."""
|
|
from starlette.concurrency import run_in_threadpool
|
|
rows = int(body.get("rows", 500) or 500)
|
|
rows = max(1, min(20000, rows))
|
|
orders = rows
|
|
hr = max(1, rows // 4)
|
|
supply = max(1, rows // 4)
|
|
tel = max(1, rows // 2)
|
|
out = await run_in_threadpool(_generate_once, orders, hr, supply, tel)
|
|
total = out["orders"] + out["hr_events"] + out["supply_events"] + out["telemetry"]
|
|
return {"ok": True, "requested": rows, "inserted": out, "total": total}
|
|
|
|
|
|
@router.get("/live")
|
|
async def get_live():
|
|
import sql_console as s
|
|
_GEN["last_seen"] = time.time() # heartbeat: keeps the generator running while watched
|
|
|
|
# Cassandra has no cheap estimate — reuse the exact count from the cached
|
|
# federated matrix query when available.
|
|
if _marquee.get("data") is None:
|
|
_load_marquee()
|
|
mq = _marquee.get("data") or {}
|
|
cass_base = 0
|
|
for r in ((mq.get("matrix") or {}).get("rows") or []):
|
|
if r.get("catalog") == "cassandra_telemetry":
|
|
try:
|
|
cass_base = int(r.get("records") or 0)
|
|
except Exception:
|
|
cass_base = 0
|
|
|
|
# One-time base snapshot of source sizes; every subsequent reading is
|
|
# base + rows the generator has streamed in, so the counters move smoothly
|
|
# and in lock-step with the live activity feed.
|
|
with _gen_lock:
|
|
if _GEN["base"] is None:
|
|
_GEN["base"] = {
|
|
"orders": s._table_row_count("postgres", "public.sales_orders") or 0,
|
|
"hr_events": s._table_row_count("mysql", "hr.employee_events") or 0,
|
|
"supply_events": s._table_row_count("mongodb", "supplychain.events") or 0,
|
|
"telemetry": cass_base,
|
|
}
|
|
elif cass_base and not _GEN["base"].get("telemetry"):
|
|
_GEN["base"]["telemetry"] = cass_base
|
|
base = dict(_GEN["base"])
|
|
gc = dict(_GEN["counts"])
|
|
gen_view = {
|
|
"enabled": _GEN["enabled"],
|
|
"running": _GEN["running"],
|
|
"interval": _GEN["interval"],
|
|
"counts": dict(_GEN["counts"]),
|
|
"last_batch": dict(_GEN["last_batch"]),
|
|
"tick_value": _GEN["tick_value"],
|
|
"by_region": [{"key": k, "count": v} for k, v in sorted(_GEN["by_region"].items(), key=lambda kv: -kv[1])],
|
|
"by_status": [{"key": k, "count": v} for k, v in sorted(_GEN["by_status"].items(), key=lambda kv: -kv[1])],
|
|
"feed": list(_GEN["feed"]),
|
|
}
|
|
|
|
orders = base["orders"] + gc["orders"]
|
|
hr = base["hr_events"] + gc["hr_events"]
|
|
supply = base["supply_events"] + gc["supply_events"]
|
|
telemetry = base["telemetry"] + gc["telemetry"]
|
|
|
|
avg_order = _avg_order_value()
|
|
sources = [
|
|
{"key": "orders", "label": "Orders", "engine": "PostgreSQL", "catalog": "postgres_sales", "rows": orders, "added": gc["orders"], "color": "#fbbf24"},
|
|
{"key": "hr_events", "label": "HR events", "engine": "MySQL", "catalog": "mysql_hr", "rows": hr, "added": gc["hr_events"], "color": "#60a5fa"},
|
|
{"key": "supply_events", "label": "Supply events", "engine": "MongoDB", "catalog": "mongodb_supplychain", "rows": supply, "added": gc["supply_events"], "color": "#a78bfa"},
|
|
{"key": "telemetry", "label": "Telemetry", "engine": "Cassandra", "catalog": "cassandra_telemetry", "rows": telemetry, "added": gc["telemetry"], "color": "#22d3ee"},
|
|
]
|
|
return {
|
|
"ok": True,
|
|
"ts": datetime.now(timezone.utc).isoformat(),
|
|
"sources": sources,
|
|
"generator": gen_view,
|
|
"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)
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
def _terms(sql: str, key_col: str, count_col: str = "c", val_col: str | None = None) -> list[dict]:
|
|
res = _trino(sql, 100)
|
|
if not res.get("ok"):
|
|
return []
|
|
cols = res.get("columns", [])
|
|
idx = {c: i for i, c in enumerate(cols)}
|
|
out = []
|
|
for r in res.get("rows", []):
|
|
item = {"key": r[idx[key_col]] if key_col in idx else None,
|
|
"count": r[idx[count_col]] if count_col in idx else 0}
|
|
if val_col and val_col in idx:
|
|
item["value"] = round(float(r[idx[val_col]] or 0), 2)
|
|
out.append(item)
|
|
return out
|
|
|
|
|
|
def _lake_table_exists(name: str) -> bool:
|
|
res = _trino(f"SELECT count(*) FROM iceberg.hadoop.{name}", 1)
|
|
return res.get("ok", False)
|
|
|
|
|
|
@router.get("/lake")
|
|
async def get_lake():
|
|
# which external tables are present
|
|
tbl_res = _trino("SHOW TABLES FROM iceberg.hadoop", 200)
|
|
tables = [r[0] for r in tbl_res.get("rows", [])] if tbl_res.get("ok") else []
|
|
table_stats = []
|
|
for t in tables:
|
|
cnt = _trino(f"SELECT count(*) FROM iceberg.hadoop.{t}", 1)
|
|
n = cnt.get("rows", [[0]])[0][0] if cnt.get("ok") else 0
|
|
table_stats.append({"table": t, "rows": n})
|
|
|
|
has_orders = "orders_ext" in tables
|
|
has_emp = "employees_ext" in tables
|
|
has_sup = "supply_events_ext" in tables
|
|
has_tel = "telemetry_ext" in tables
|
|
|
|
orders = {}
|
|
if has_orders:
|
|
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"),
|
|
"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"),
|
|
"by_status": _terms("SELECT order_status k, count(*) c FROM iceberg.hadoop.orders_ext GROUP BY order_status ORDER BY c DESC", "k", "c"),
|
|
"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 10", "k", "c", "rev"),
|
|
"revenue": (_trino("SELECT sum(amount), avg(amount) FROM iceberg.hadoop.orders_ext", 1).get("rows") or [[0, 0]])[0],
|
|
}
|
|
hr = {}
|
|
if has_emp:
|
|
hr = {
|
|
"by_department": _terms("SELECT department k, count(*) c FROM iceberg.hadoop.employees_ext GROUP BY department ORDER BY c DESC", "k", "c"),
|
|
"by_role": _terms("SELECT role_name k, count(*) c FROM iceberg.hadoop.employees_ext GROUP BY role_name ORDER BY c DESC LIMIT 12", "k", "c"),
|
|
}
|
|
supply = {}
|
|
if has_sup:
|
|
supply = {"by_type": _terms("SELECT type k, count(*) c FROM iceberg.hadoop.supply_events_ext GROUP BY type ORDER BY c DESC", "k", "c")}
|
|
telemetry = {}
|
|
if has_tel:
|
|
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")}
|
|
|
|
return {
|
|
"ok": True,
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
"tables": table_stats,
|
|
"total_rows": sum(t["rows"] or 0 for t in table_stats),
|
|
"orders": orders,
|
|
"hr": hr,
|
|
"supply": supply,
|
|
"telemetry": telemetry,
|
|
}
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# Materialize the Hadoop external tables from the sources
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
_mat_state: dict[str, Any] = {"running": False, "current": None, "done": [], "errors": [], "started_at": None, "finished_at": None}
|
|
_mat_lock = threading.Lock()
|
|
|
|
|
|
def _materialize_worker() -> None:
|
|
try:
|
|
for name, label, sql in LAKE_SPECS:
|
|
_mat_state["current"] = f"{name} ({label})"
|
|
try:
|
|
_trino(f"DROP TABLE IF EXISTS iceberg.hadoop.{name}", 1)
|
|
res = _trino(f"CREATE TABLE iceberg.hadoop.{name} AS {sql}", 5)
|
|
if res.get("ok"):
|
|
cnt = _trino(f"SELECT count(*) FROM iceberg.hadoop.{name}", 1)
|
|
n = cnt.get("rows", [[0]])[0][0] if cnt.get("ok") else 0
|
|
_mat_state["done"].append({"table": name, "rows": n})
|
|
else:
|
|
_mat_state["errors"].append(f"{name}: {str(res.get('error',''))[:160]}")
|
|
except Exception as exc: # noqa: BLE001
|
|
_mat_state["errors"].append(f"{name}: {str(exc)[:160]}")
|
|
finally:
|
|
_mat_state["current"] = None
|
|
_mat_state["running"] = False
|
|
_mat_state["finished_at"] = datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
@router.post("/materialize")
|
|
async def materialize():
|
|
with _mat_lock:
|
|
if _mat_state["running"]:
|
|
return {"ok": True, "already_running": True, "state": _mat_state}
|
|
_mat_state.update({"running": True, "current": "starting…", "done": [], "errors": [],
|
|
"started_at": datetime.now(timezone.utc).isoformat(), "finished_at": None})
|
|
threading.Thread(target=_materialize_worker, daemon=True).start()
|
|
return {"ok": True, "started": True, "tables": [s[0] for s in LAKE_SPECS]}
|
|
|
|
|
|
@router.get("/materialize/status")
|
|
async def materialize_status():
|
|
return {"ok": True, **_mat_state}
|
|
|
|
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
# Business data dictionary (columns + masked/visible) — UI + LLM
|
|
# ──────────────────────────────────────────────────────────────────────────────
|
|
DICT_TABLES = [
|
|
("postgres_sales", "public", "sales_orders", "PostgreSQL", "Sales orders (OLTP, CDC source)"),
|
|
("mysql_hr", "hr", "employee_events", "MySQL", "Employee / HR events (CDC source)"),
|
|
("mongodb_supplychain", "supplychain", "events", "MongoDB", "Supply chain events (CDC source)"),
|
|
("cassandra_telemetry", "telemetry", "device_metrics", "Cassandra", "IoT device telemetry"),
|
|
("iceberg", "curated_masked", "sales_orders_masked", "Iceberg", "Curated masked layer (physically masked)"),
|
|
("iceberg", "hadoop", "orders_ext", "Hadoop", "Lake external table (orders)"),
|
|
]
|
|
|
|
_dict_cache: dict[str, Any] = {"at": 0.0, "data": None}
|
|
_DICT_TTL = 120.0
|
|
|
|
|
|
def build_dictionary() -> dict[str, Any]:
|
|
if _dict_cache["data"] and time.time() - _dict_cache["at"] < _DICT_TTL:
|
|
return _dict_cache["data"]
|
|
import sql_console as s
|
|
# masking map from the PII catalog
|
|
masked_map: dict[tuple[str, str], dict] = {}
|
|
pii_names: dict[str, str] = {} # column-name -> category (any dataset)
|
|
try:
|
|
from pii_catalog import get_pii
|
|
pii_data = get_pii()
|
|
for d in pii_data.get("datasets", []):
|
|
tname = (d.get("table") or "").split(".")[-1]
|
|
for c in d.get("pii_columns", []):
|
|
masked_map[(tname, c["name"])] = {"masked": c.get("masked"), "category": c.get("category")}
|
|
pii_names.setdefault(c["name"], c.get("category"))
|
|
except Exception:
|
|
pass
|
|
|
|
tables_out = []
|
|
for catalog, schema, table, engine, desc in DICT_TABLES:
|
|
cols_res = _trino(f'SHOW COLUMNS FROM "{catalog}"."{schema}"."{table}"', 200)
|
|
if not cols_res.get("ok"):
|
|
continue
|
|
masked_layer = schema in ("curated_masked", "curated")
|
|
cols = []
|
|
for r in cols_res.get("rows", []):
|
|
cname, ctype = r[0], r[1]
|
|
direct = masked_map.get((table, cname))
|
|
if direct:
|
|
is_pii, is_masked, cat = True, bool(direct["masked"]), direct["category"]
|
|
elif cname in pii_names:
|
|
# PII column name found in a derived/lake table — masked only if it
|
|
# is a physically-masked layer; otherwise raw PII is visible there.
|
|
is_pii, is_masked, cat = True, masked_layer, pii_names[cname]
|
|
else:
|
|
is_pii, is_masked, cat = False, False, None
|
|
cols.append({
|
|
"name": cname, "type": ctype,
|
|
"pii": is_pii, "masked": is_masked, "category": cat,
|
|
})
|
|
tables_out.append({
|
|
"catalog": catalog, "schema": schema, "table": table, "engine": engine, "desc": desc,
|
|
"fqn": f"{catalog}.{schema}.{table}",
|
|
"columns": cols,
|
|
"pii_count": sum(1 for c in cols if c["pii"]),
|
|
"masked_count": sum(1 for c in cols if c["masked"]),
|
|
})
|
|
data = {
|
|
"ok": True,
|
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
"tables": tables_out,
|
|
"summary": {
|
|
"tables": len(tables_out),
|
|
"columns": sum(len(t["columns"]) for t in tables_out),
|
|
"pii_columns": sum(t["pii_count"] for t in tables_out),
|
|
"masked_columns": sum(t["masked_count"] for t in tables_out),
|
|
},
|
|
}
|
|
_dict_cache.update({"at": time.time(), "data": data})
|
|
return data
|
|
|
|
|
|
@router.get("/dictionary")
|
|
async def get_dictionary():
|
|
try:
|
|
return build_dictionary()
|
|
except Exception as exc: # noqa: BLE001
|
|
return JSONResponse({"ok": False, "error": str(exc)[:300]}, status_code=502)
|