Files
atc-agents/api/elasticsearch_api.py
T
mo 3a054ac13d Elasticsearch: full indexer, rich search UI, Kibana dashboards + shortcut
- Backend: Trino-federated indexer pushes all sources (postgres/mysql/mongo/cassandra/iceberg/neo4j + catalog) into atc-* indices; adds /indices, /mapping, /query, /aggs, /reindex(+status), /kibana/setup(+links)
- Kibana: auto-provision data views + ATC Data Overview dashboard (docs by source, top tables)
- UI: rebuilt Search tab — KPIs, source/index charts, full-text + filtered search, facets, paginated doc viewer, re-index + Kibana buttons
- SideNav: Kibana Dashboards shortcut
2026-06-28 13:23:27 +00:00

621 lines
27 KiB
Python

"""Elasticsearch + Kibana API for the Command Center.
Provides:
- cluster / kibana health
- rich index listing, field mappings, full-text + filtered search, aggregations
- a Trino-federated indexer that pushes every source's data into ES (atc-* indices)
- Kibana provisioning (data views + an overview dashboard)
"""
from __future__ import annotations
import json
import os
import re
import threading
import time
from datetime import datetime, timezone
from typing import Any
import httpx
from fastapi import APIRouter, Body, Query
from fastapi.responses import JSONResponse
ELASTICSEARCH_URL = os.getenv("ELASTICSEARCH_URL", "https://10.0.21.46:9200").rstrip("/")
KIBANA_URL = os.getenv("KIBANA_URL", "http://10.0.21.46:5601").rstrip("/")
ELASTIC_USER = os.getenv("ELASTIC_USER", "elastic")
ELASTIC_PASSWORD = os.getenv("ELASTIC_PASSWORD", "")
INDEX_PREFIX = "atc-"
# Trino catalogs to index (skip system + streaming kafka).
INDEX_CATALOGS = ("postgres_sales", "mysql_hr", "mongodb_supplychain", "cassandra_telemetry", "iceberg")
SKIP_SCHEMAS = {"information_schema", "sys", "performance_schema", "config", "local", "admin"}
ROWS_PER_TABLE = int(os.getenv("ES_ROWS_PER_TABLE", "500"))
router = APIRouter(prefix="/api/search", tags=["search"])
def _auth():
return (ELASTIC_USER, ELASTIC_PASSWORD) if ELASTIC_PASSWORD else None
def _es_client(timeout: float = 30.0) -> httpx.Client:
return httpx.Client(timeout=timeout, verify=False, auth=_auth())
def _sanitize_index(*parts: str) -> str:
raw = "-".join(str(p) for p in parts if p)
raw = raw.lower()
raw = re.sub(r"[^a-z0-9]+", "-", raw).strip("-")
raw = re.sub(r"-+", "-", raw)
return (INDEX_PREFIX + raw)[:240]
# ──────────────────────────────────────────────────────────────────────────────
# Health
# ──────────────────────────────────────────────────────────────────────────────
async def _probe_es() -> dict[str, Any]:
try:
async with httpx.AsyncClient(timeout=6.0, verify=False) as client:
r = await client.get(f"{ELASTICSEARCH_URL}/", auth=_auth())
if r.status_code >= 400:
return {"ok": False, "status_code": r.status_code, "error": r.text[:200]}
info = r.json()
health_r = await client.get(f"{ELASTICSEARCH_URL}/_cluster/health", auth=_auth())
health = health_r.json() if health_r.status_code < 400 else {}
stats_r = await client.get(f"{ELASTICSEARCH_URL}/_cat/indices?format=json&bytes=b", auth=_auth())
indices = stats_r.json() if stats_r.status_code < 400 else []
user_idx = [i for i in indices if isinstance(i, dict) and not str(i.get("index", "")).startswith(".")]
total_docs = sum(int(i.get("docs.count") or 0) for i in user_idx)
atc_docs = sum(int(i.get("docs.count") or 0) for i in user_idx if str(i.get("index", "")).startswith(INDEX_PREFIX))
return {
"ok": True,
"url": ELASTICSEARCH_URL,
"cluster_name": info.get("cluster_name"),
"version": info.get("version", {}).get("number"),
"health": health.get("status", "unknown"),
"nodes": health.get("number_of_nodes"),
"indices_count": len(user_idx),
"total_docs": total_docs,
"atc_docs": atc_docs,
"indices": [
{
"name": i.get("index"),
"docs": i.get("docs.count"),
"size": i.get("store.size"),
"health": i.get("health"),
}
for i in user_idx[:80]
],
}
except Exception as exc: # noqa: BLE001
return {"ok": False, "url": ELASTICSEARCH_URL, "error": str(exc)}
async def _probe_kibana() -> dict[str, Any]:
try:
async with httpx.AsyncClient(timeout=6.0, verify=False) as client:
r = await client.get(f"{KIBANA_URL}/api/status")
if r.status_code >= 400:
return {"ok": False, "url": KIBANA_URL, "ui_url": KIBANA_URL, "status_code": r.status_code}
data = r.json()
overall = data.get("status", {}).get("overall", {})
return {
"ok": overall.get("level", "available") in ("available", "green", "yellow"),
"url": KIBANA_URL,
"ui_url": KIBANA_URL,
"level": overall.get("level", "available"),
"version": data.get("version", {}).get("number"),
}
except Exception as exc: # noqa: BLE001
return {"ok": False, "url": KIBANA_URL, "ui_url": KIBANA_URL, "error": str(exc)}
@router.get("/health")
async def search_health():
es, kb = await _probe_es(), await _probe_kibana()
return {"ok": es.get("ok") or kb.get("ok"), "elasticsearch": es, "kibana": kb}
@router.get("/elasticsearch")
async def get_elasticsearch():
result = await _probe_es()
if not result.get("ok"):
return JSONResponse(result, status_code=502)
return result
@router.get("/kibana")
async def get_kibana():
return await _probe_kibana()
# ──────────────────────────────────────────────────────────────────────────────
# Indices + mappings
# ──────────────────────────────────────────────────────────────────────────────
@router.get("/indices")
async def list_indices(include_system: bool = False):
try:
async with httpx.AsyncClient(timeout=10.0, verify=False) as client:
r = await client.get(f"{ELASTICSEARCH_URL}/_cat/indices?format=json&bytes=b&s=index", auth=_auth())
if r.status_code >= 400:
return JSONResponse({"ok": False, "error": r.text[:300]}, status_code=502)
rows = r.json()
except Exception as exc: # noqa: BLE001
return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
out = []
for i in rows:
name = str(i.get("index", ""))
if not include_system and name.startswith("."):
continue
out.append({
"name": name,
"docs": int(i.get("docs.count") or 0),
"size_bytes": int(i.get("store.size") or 0),
"health": i.get("health"),
"status": i.get("status"),
"atc": name.startswith(INDEX_PREFIX),
})
out.sort(key=lambda x: (not x["atc"], -x["docs"]))
return {"ok": True, "count": len(out), "indices": out}
def _flatten_props(props: dict, prefix: str = "") -> list[dict]:
fields = []
for name, spec in (props or {}).items():
full = f"{prefix}{name}"
ftype = spec.get("type")
if ftype:
aggregatable = ftype in ("keyword", "long", "integer", "double", "float", "date", "boolean", "ip")
fields.append({"name": full, "type": ftype, "aggregatable": aggregatable})
sub = spec.get("fields") or {}
for sub_name, sub_spec in sub.items():
st = sub_spec.get("type")
fields.append({"name": f"{full}.{sub_name}", "type": st, "aggregatable": st in ("keyword", "ip")})
if spec.get("properties"):
fields.extend(_flatten_props(spec["properties"], prefix=f"{full}."))
return fields
@router.get("/mapping")
async def get_mapping(index: str = Query(...)):
try:
async with httpx.AsyncClient(timeout=10.0, verify=False) as client:
r = await client.get(f"{ELASTICSEARCH_URL}/{index}/_mapping", auth=_auth())
if r.status_code >= 400:
return JSONResponse({"ok": False, "error": r.text[:300]}, status_code=502)
data = r.json()
except Exception as exc: # noqa: BLE001
return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
fields: dict[str, dict] = {}
for _idx, body in data.items():
props = body.get("mappings", {}).get("properties", {})
for f in _flatten_props(props):
fields[f["name"]] = f
field_list = sorted(fields.values(), key=lambda x: x["name"])
return {"ok": True, "index": index, "fields": field_list}
# ──────────────────────────────────────────────────────────────────────────────
# Search + aggregations
# ──────────────────────────────────────────────────────────────────────────────
@router.post("/query")
async def search_query(body: dict = Body(default={})):
q = (body.get("q") or "").strip()
indices = body.get("indices") or []
filters = body.get("filters") or []
from_ = int(body.get("from") or 0)
size = min(int(body.get("size") or 20), 100)
sort = body.get("sort")
target = ",".join(indices) if indices else f"{INDEX_PREFIX}*"
must: list[dict] = []
if q:
must.append({"query_string": {"query": q, "lenient": True, "default_operator": "AND"}})
for f in filters:
field, value = f.get("field"), f.get("value")
if field and value is not None:
must.append({"match_phrase": {field: value}})
es_body: dict[str, Any] = {
"query": {"bool": {"must": must or [{"match_all": {}}]}},
"from": from_,
"size": size,
"track_total_hits": True,
}
if sort and sort.get("field"):
es_body["sort"] = [{sort["field"]: {"order": sort.get("order", "desc")}}]
try:
async with httpx.AsyncClient(timeout=20.0, verify=False) as client:
r = await client.post(f"{ELASTICSEARCH_URL}/{target}/_search", auth=_auth(), json=es_body)
if r.status_code >= 400:
return JSONResponse({"ok": False, "error": r.text[:400]}, status_code=r.status_code)
data = r.json()
except Exception as exc: # noqa: BLE001
return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
hits = data.get("hits", {})
return {
"ok": True,
"took": data.get("took"),
"total": hits.get("total", {}).get("value", 0),
"hits": [
{"index": h.get("_index"), "id": h.get("_id"), "score": h.get("_score"), "source": h.get("_source")}
for h in hits.get("hits", [])
],
}
@router.get("/aggs")
async def aggregations(index: str = Query(...), field: str = Query(...), size: int = Query(15, le=50), q: str = Query("")):
query = {"query_string": {"query": q, "lenient": True}} if q.strip() else {"match_all": {}}
es_body = {"size": 0, "query": query, "aggs": {"facet": {"terms": {"field": field, "size": size}}}}
try:
async with httpx.AsyncClient(timeout=15.0, verify=False) as client:
r = await client.post(f"{ELASTICSEARCH_URL}/{index}/_search", auth=_auth(), json=es_body)
if r.status_code >= 400:
return JSONResponse({"ok": False, "error": r.text[:300]}, status_code=r.status_code)
data = r.json()
except Exception as exc: # noqa: BLE001
return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
buckets = data.get("aggregations", {}).get("facet", {}).get("buckets", [])
return {"ok": True, "field": field, "buckets": [{"key": b.get("key"), "count": b.get("doc_count")} for b in buckets]}
# back-compat simple query
@router.get("/elasticsearch/query")
async def es_search(q: str = Query(..., min_length=1), size: int = Query(10, le=50)):
return await search_query({"q": q, "size": size})
# ──────────────────────────────────────────────────────────────────────────────
# Indexer (Trino-federated → ES atc-* indices)
# ──────────────────────────────────────────────────────────────────────────────
_reindex_state: dict[str, Any] = {
"running": False,
"started_at": None,
"finished_at": None,
"current": None,
"indices": {},
"total_docs": 0,
"errors": [],
"log": [],
}
_reindex_lock = threading.Lock()
def _bulk(client: httpx.Client, index: str, docs: list[tuple[str, dict]]) -> int:
if not docs:
return 0
lines = []
for _id, doc in docs:
lines.append(json.dumps({"index": {"_index": index, "_id": _id}}))
lines.append(json.dumps(doc, default=str))
body = "\n".join(lines) + "\n"
r = client.post(
f"{ELASTICSEARCH_URL}/_bulk",
content=body.encode("utf-8"),
headers={"Content-Type": "application/x-ndjson"},
)
if r.status_code >= 400:
raise RuntimeError(f"bulk failed {r.status_code}: {r.text[:200]}")
return len(docs)
def _meta(catalog: str, schema: str, table: str, source: str) -> dict:
return {
"catalog": catalog,
"schema": schema,
"table": table,
"fqn": f"{catalog}.{schema}.{table}" if schema else f"{catalog}.{table}",
"source": source,
"indexed_at": datetime.now(timezone.utc).isoformat(),
}
def _index_trino_table(sqlmod, client: httpx.Client, catalog: str, schema: str, table: str) -> int:
res = sqlmod._run_trino(f'SELECT * FROM "{catalog}"."{schema}"."{table}" LIMIT {ROWS_PER_TABLE}', ROWS_PER_TABLE)
if not res.get("ok"):
raise RuntimeError(res.get("error", "query failed")[:200])
cols = res.get("columns", [])
rows = res.get("rows", [])
index = _sanitize_index(catalog, schema, table)
docs = []
for n, row in enumerate(rows):
doc = {cols[i]: row[i] for i in range(min(len(cols), len(row)))}
doc["meta"] = _meta(catalog, schema, table, catalog)
docs.append((f"{catalog}.{schema}.{table}:{n}", doc))
written = 0
for i in range(0, len(docs), 500):
written += _bulk(client, index, docs[i:i + 500])
return written
def _index_neo4j(sqlmod, client: httpx.Client) -> int:
written = 0
try:
driver = sqlmod._neo4j_driver()
except Exception: # noqa: BLE001
return 0
try:
with driver.session() as session:
labels = [r["label"] for r in session.run("CALL db.labels() YIELD label RETURN label")]
for label in labels:
index = _sanitize_index("neo4j", "graph", label)
recs = session.run(f"MATCH (n:`{label}`) RETURN properties(n) AS props LIMIT {ROWS_PER_TABLE}")
docs = []
for n, rec in enumerate(recs):
props = dict(rec["props"] or {})
props["meta"] = _meta("neo4j", "graph", label, "neo4j")
docs.append((f"neo4j.{label}:{n}", props))
for i in range(0, len(docs), 500):
written += _bulk(client, index, docs[i:i + 500])
_reindex_state["indices"][index] = len(docs)
_reindex_state["log"].append(f"neo4j:{label}{len(docs)} docs")
except Exception as exc: # noqa: BLE001
_reindex_state["errors"].append(f"neo4j: {exc}")
finally:
driver.close()
return written
def _index_catalog(client: httpx.Client) -> int:
try:
from database_inventory import collect_database_inventory_sync
inv = collect_database_inventory_sync()
except Exception as exc: # noqa: BLE001
_reindex_state["errors"].append(f"catalog: {exc}")
return 0
docs = []
for engine, info in (inv.get("engines") or {}).items():
for obj in (info.get("objects") or info.get("tables") or []):
if not isinstance(obj, dict):
continue
doc = dict(obj)
doc["engine"] = engine
doc["meta"] = _meta("catalog", engine, str(obj.get("name") or obj.get("fqn") or "obj"), "inventory")
docs.append((f"catalog.{engine}.{obj.get('fqn') or obj.get('name')}", doc))
written = 0
for i in range(0, len(docs), 500):
written += _bulk(client, "atc-catalog", docs[i:i + 500])
if docs:
_reindex_state["indices"]["atc-catalog"] = len(docs)
_reindex_state["log"].append(f"catalog → {len(docs)} docs")
return written
def _reindex_worker():
import sql_console as sqlmod
try:
client = _es_client(timeout=90.0)
with client:
# catalog/metadata
_reindex_state["current"] = "catalog inventory"
_reindex_state["total_docs"] += _index_catalog(client)
# Trino-federated source data
cat_res = sqlmod._run_trino("SHOW CATALOGS", 100)
catalogs = [r[0] for r in cat_res.get("rows", [])] if cat_res.get("ok") else list(INDEX_CATALOGS)
for catalog in catalogs:
if catalog not in INDEX_CATALOGS:
continue
try:
sch_res = sqlmod._run_trino(f'SHOW SCHEMAS FROM "{catalog}"', 200)
schemas = [r[0] for r in sch_res.get("rows", [])] if sch_res.get("ok") else []
except Exception as exc: # noqa: BLE001
_reindex_state["errors"].append(f"{catalog}: {exc}")
continue
for schema in schemas:
if schema in SKIP_SCHEMAS or schema.startswith("system") or schema == "information_schema":
continue
try:
tbl_res = sqlmod._run_trino(f'SHOW TABLES FROM "{catalog}"."{schema}"', 500)
tables = [r[0] for r in tbl_res.get("rows", [])] if tbl_res.get("ok") else []
except Exception as exc: # noqa: BLE001
_reindex_state["errors"].append(f"{catalog}.{schema}: {exc}")
continue
for table in tables:
_reindex_state["current"] = f"{catalog}.{schema}.{table}"
try:
n = _index_trino_table(sqlmod, client, catalog, schema, table)
idx = _sanitize_index(catalog, schema, table)
_reindex_state["indices"][idx] = n
_reindex_state["total_docs"] += n
_reindex_state["log"].append(f"{catalog}.{schema}.{table}{n} docs")
except Exception as exc: # noqa: BLE001
_reindex_state["errors"].append(f"{catalog}.{schema}.{table}: {str(exc)[:150]}")
# Neo4j (not federated by Trino)
_reindex_state["current"] = "neo4j graph"
_reindex_state["total_docs"] += _index_neo4j(sqlmod, client)
# make freshly created indices searchable immediately
try:
client.post(f"{ELASTICSEARCH_URL}/{INDEX_PREFIX}*/_refresh")
except Exception: # noqa: BLE001
pass
except Exception as exc: # noqa: BLE001
_reindex_state["errors"].append(f"fatal: {exc}")
finally:
_reindex_state["current"] = None
_reindex_state["running"] = False
_reindex_state["finished_at"] = datetime.now(timezone.utc).isoformat()
# keep log bounded
_reindex_state["log"] = _reindex_state["log"][-200:]
@router.post("/reindex")
async def start_reindex():
with _reindex_lock:
if _reindex_state["running"]:
return {"ok": True, "already_running": True, "state": _reindex_summary()}
_reindex_state.update({
"running": True,
"started_at": datetime.now(timezone.utc).isoformat(),
"finished_at": None,
"current": "starting…",
"indices": {},
"total_docs": 0,
"errors": [],
"log": [],
})
threading.Thread(target=_reindex_worker, daemon=True).start()
return {"ok": True, "started": True}
def _reindex_summary() -> dict[str, Any]:
s = _reindex_state
return {
"running": s["running"],
"started_at": s["started_at"],
"finished_at": s["finished_at"],
"current": s["current"],
"total_docs": s["total_docs"],
"index_count": len(s["indices"]),
"indices": s["indices"],
"errors": s["errors"][-20:],
"log": s["log"][-40:],
}
@router.get("/reindex/status")
async def reindex_status():
return {"ok": True, **_reindex_summary()}
# ──────────────────────────────────────────────────────────────────────────────
# Kibana provisioning
# ──────────────────────────────────────────────────────────────────────────────
def _kbn_headers():
return {"kbn-xsrf": "true", "Content-Type": "application/json"}
@router.post("/kibana/setup")
async def kibana_setup():
"""Create data views + an overview dashboard in Kibana (idempotent)."""
created: list[str] = []
errors: list[str] = []
dataview_id = "atc-all-data"
objects = [
{
"type": "index-pattern",
"id": dataview_id,
"attributes": {"title": f"{INDEX_PREFIX}*", "name": "ATC — all data", "timeFieldName": "meta.indexed_at"},
},
{
"type": "index-pattern",
"id": "atc-catalog-dv",
"attributes": {"title": "atc-catalog", "name": "ATC — data catalog"},
},
{
"type": "visualization",
"id": "atc-docs-by-source",
"attributes": {
"title": "ATC — Documents by source",
"visState": json.dumps({
"title": "ATC — Documents by source",
"type": "histogram",
"aggs": [
{"id": "1", "enabled": True, "type": "count", "schema": "metric", "params": {}},
{"id": "2", "enabled": True, "type": "terms", "schema": "segment",
"params": {"field": "meta.catalog.keyword", "size": 25, "order": "desc", "orderBy": "1"}},
],
"params": {"addLegend": True, "addTooltip": True, "type": "histogram"},
}),
"uiStateJSON": "{}",
"description": "",
"kibanaSavedObjectMeta": {"searchSourceJSON": json.dumps({
"query": {"query": "", "language": "kuery"}, "filter": [],
"indexRefName": "kibanaSavedObjectMeta.searchSourceJSON.index",
})},
},
"references": [{"name": "kibanaSavedObjectMeta.searchSourceJSON.index", "type": "index-pattern", "id": dataview_id}],
},
{
"type": "visualization",
"id": "atc-docs-by-table",
"attributes": {
"title": "ATC — Top tables",
"visState": json.dumps({
"title": "ATC — Top tables",
"type": "table",
"aggs": [
{"id": "1", "enabled": True, "type": "count", "schema": "metric", "params": {}},
{"id": "2", "enabled": True, "type": "terms", "schema": "bucket",
"params": {"field": "meta.table.keyword", "size": 30, "order": "desc", "orderBy": "1"}},
],
"params": {"perPage": 15, "showPartialRows": False, "showTotal": True},
}),
"uiStateJSON": "{}",
"description": "",
"kibanaSavedObjectMeta": {"searchSourceJSON": json.dumps({
"query": {"query": "", "language": "kuery"}, "filter": [],
"indexRefName": "kibanaSavedObjectMeta.searchSourceJSON.index",
})},
},
"references": [{"name": "kibanaSavedObjectMeta.searchSourceJSON.index", "type": "index-pattern", "id": dataview_id}],
},
{
"type": "dashboard",
"id": "atc-data-overview",
"attributes": {
"title": "ATC Data Overview",
"description": "Auto-provisioned overview of all data indexed from the ATC platform.",
"panelsJSON": json.dumps([
{"version": "8.19.0", "type": "visualization", "panelIndex": "1", "panelRefName": "panel_1",
"gridData": {"x": 0, "y": 0, "w": 24, "h": 15, "i": "1"}, "embeddableConfig": {}},
{"version": "8.19.0", "type": "visualization", "panelIndex": "2", "panelRefName": "panel_2",
"gridData": {"x": 24, "y": 0, "w": 24, "h": 15, "i": "2"}, "embeddableConfig": {}},
]),
"optionsJSON": json.dumps({"useMargins": True, "hidePanelTitles": False}),
"timeRestore": False,
"kibanaSavedObjectMeta": {"searchSourceJSON": json.dumps({"query": {"query": "", "language": "kuery"}, "filter": []})},
},
"references": [
{"name": "panel_1", "type": "visualization", "id": "atc-docs-by-source"},
{"name": "panel_2", "type": "visualization", "id": "atc-docs-by-table"},
],
},
]
try:
async with httpx.AsyncClient(timeout=20.0, verify=False, auth=_auth()) as client:
r = await client.post(
f"{KIBANA_URL}/api/saved_objects/_bulk_create?overwrite=true",
headers=_kbn_headers(),
json=objects,
)
if r.status_code >= 400:
return JSONResponse({"ok": False, "error": r.text[:400]}, status_code=r.status_code)
data = r.json()
for obj in data.get("saved_objects", []):
if obj.get("error"):
errors.append(f"{obj.get('type')}/{obj.get('id')}: {obj['error'].get('message')}")
else:
created.append(f"{obj.get('type')}/{obj.get('id')}")
except Exception as exc: # noqa: BLE001
return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
return {
"ok": True,
"created": created,
"errors": errors,
"dashboard_url": f"{KIBANA_URL}/app/dashboards#/view/atc-data-overview",
"discover_url": f"{KIBANA_URL}/app/discover",
}
@router.get("/kibana/links")
async def kibana_links(index: str = Query("")):
base = KIBANA_URL
return {
"ok": True,
"kibana": base,
"discover": f"{base}/app/discover",
"dashboard": f"{base}/app/dashboards#/view/atc-data-overview",
"index_management": f"{base}/app/management/data/index_management/indices",
}