from fastapi import FastAPI, Header, HTTPException, Request from fastapi.responses import StreamingResponse from fastapi.staticfiles import StaticFiles import httpx from typing import Annotated import asyncio import json import time app = FastAPI() DECK_APIS = { "prod": "https://deck-api.ngcloud.ru/api/v1", "dev": "https://deck-api-dev.ngcloud.ru/api/v1", "test": "https://deck-api-test.ngcloud.ru/api/v1", } DEFAULT_DECK_ENV = "test" CACHE_TTL_SECONDS = 180 GRAPH_CACHE: dict[str, tuple[float, dict]] = {} def auth_headers(token: str): return {"Authorization": f"Bearer {token}"} def resolve_deck_api(env_raw: str | None) -> tuple[str, str]: env = (env_raw or DEFAULT_DECK_ENV).strip().lower() if env not in DECK_APIS: env = DEFAULT_DECK_ENV return env, DECK_APIS[env] def _as_text(value) -> str: if value is None: return "" if isinstance(value, str): return value.strip() if isinstance(value, (int, float, bool)): return str(value).strip() if isinstance(value, dict): for key in ("value", "displayValue", "name", "label", "id", "text"): nested = _as_text(value.get(key)) if nested: return nested return "" def extract_platform_name(inst: dict | None) -> str: if not isinstance(inst, dict): return "" sources = [ inst.get("parameters"), inst.get("params"), inst.get("instanceParameters"), inst.get("inputParameters"), ] needles = ("platform", "kuber", "k8s", "cluster", "shturval", "штурвал") for src in sources: if isinstance(src, dict): for key, value in src.items(): key_s = str(key).strip().lower() if any(n in key_s for n in needles): txt = _as_text(value) if txt: return txt elif isinstance(src, list): for item in src: if not isinstance(item, dict): continue key_s = str(item.get("name") or item.get("key") or item.get("code") or "").strip().lower() if any(n in key_s for n in needles): txt = _as_text(item.get("value") or item.get("displayValue") or item.get("text")) if txt: return txt return "" async def fetch_all_instances(token: str, deck_api: str): params: dict = {"page": 1, "size": 200} all_results = [] async with httpx.AsyncClient(timeout=20) as client: while True: r = await client.get( f"{deck_api}/index.cfm/instances", headers=auth_headers(token), params=params, ) if r.status_code == 401: raise HTTPException(status_code=401, detail="Invalid token") data = r.json() results = data.get("results", []) all_results.extend(results) if len(results) < 200: break params["page"] += 1 return all_results @app.get("/api/instances") async def list_instances( request: Request, x_deck_token: Annotated[str | None, Header()] = None, x_deck_env: Annotated[str | None, Header()] = None, ): if not x_deck_token: raise HTTPException(status_code=401, detail="Token required") _, deck_api = resolve_deck_api(x_deck_env) # parse status from query string manually (FastAPI list param can fail behind ingress rewrite) status_list = request.query_params.getlist("status") all_results = await fetch_all_instances(x_deck_token, deck_api) if status_list: all_results = [i for i in all_results if i.get("explainedStatus") in status_list] return {"instances": all_results, "total": len(all_results)} @app.get("/api/graph") async def get_graph( request: Request, x_deck_token: Annotated[str | None, Header()] = None, x_deck_env: Annotated[str | None, Header()] = None, ): if not x_deck_token: raise HTTPException(status_code=401, detail="Token required") env, deck_api = resolve_deck_api(x_deck_env) status_list = request.query_params.getlist("status") root_uid = request.query_params.get("root_uid") depth_raw = request.query_params.get("depth", "2") try: depth = max(1, min(5, int(depth_raw))) except ValueError: depth = 2 status_key = ",".join(sorted(status_list)) if status_list else "__all__" cache_key = f"{x_deck_token[:24]}::{env}::{status_key}" now = time.time() cached = GRAPH_CACHE.get(cache_key) if cached and (now - cached[0] < CACHE_TTL_SECONDS): base_graph = cached[1] else: all_instances = await fetch_all_instances(x_deck_token, deck_api) if status_list: all_instances = [i for i in all_instances if i.get("explainedStatus") in status_list] uid_to_inst = { i.get("instanceUid"): i for i in all_instances if i.get("instanceUid") } allowed_uids = set(uid_to_inst.keys()) sem = asyncio.Semaphore(20) async def fetch_detail(uid: str, client: httpx.AsyncClient): async with sem: try: r = await client.get( f"{deck_api}/index.cfm/instances/{uid}", headers=auth_headers(x_deck_token), ) if r.status_code == 401: raise HTTPException(status_code=401, detail="Invalid token") if r.status_code >= 400: return uid, None return uid, r.json().get("instance") except httpx.TimeoutException: return uid, None async with httpx.AsyncClient(timeout=25) as shared_client: details = await asyncio.gather(*[fetch_detail(uid, shared_client) for uid in allowed_uids]) detail_map = {uid: inst for uid, inst in details if inst} # Categorise singletons by service name _PLATFORM_PATTERNS = ("виртуальный датацентр", "vdc", "virtual data center", "cloud director", "kubernetes", "k8s") _SINGLETON_PATTERNS = ("object storage", "s3", "edge", "шлюз периметра", "dns", "container registry", "реестр контейнеров") def _is_platform_svc(svc_name: str) -> bool: s = svc_name.strip().lower() return any(p in s for p in _PLATFORM_PATTERNS) def _is_singleton_svc(svc_name: str) -> bool: s = svc_name.strip().lower() return any(p in s for p in _SINGLETON_PATTERNS) # Build realm→platform_uid map: instances whose resourceRealmCnt > 0 ARE platforms realm_to_platform: dict[str, str] = {} for uid_d, inst_d in detail_map.items(): cnt = inst_d.get("resourceRealmCnt") or 0 realm_name = inst_d.get("resourceRealm") or "" if cnt and realm_name: realm_to_platform[realm_name] = uid_d # Build directed edges from dependencies/dependentInstances # Arrow points TO the dependency (what you depend on) edge_set: set[tuple[str, str]] = set() for uid_d, inst_d in detail_map.items(): if uid_d not in allowed_uids: continue for dep in inst_d.get("dependencies", []) or []: src = dep.get("uid") or dep.get("instanceUid") or "" if src and src in allowed_uids and src != uid_d: edge_set.add((uid_d, src)) # uid_d depends on src, arrow uid_d→src for dep_on in inst_d.get("dependentInstances", []) or []: dst = dep_on.get("uid") or dep_on.get("instanceUid") or "" if dst and dst in allowed_uids and dst != uid_d: edge_set.add((dst, uid_d)) # dst depends on uid_d, arrow dst→uid_d nodes = [] for uid_i in allowed_uids: i = uid_to_inst.get(uid_i, {}) detail = detail_map.get(uid_i, {}) svc = i.get("svc") or "" realm_cnt = detail.get("resourceRealmCnt") or 0 is_aux = bool(detail.get("isAuxiliary")) # Determine lane by service name if _is_platform_svc(svc): lane = "platform" elif _is_singleton_svc(svc): lane = "singleton" else: lane = "regular" nodes.append( { "id": uid_i, "label": i.get("displayName") or uid_i, "status": i.get("explainedStatus") or "unknown", "svc": svc, "realm": i.get("resourceRealm") or "", "lane": lane, "isAuxiliary": is_aux, "created": i.get("instanceConfigDtCreated") or i.get("dtCreated") or "", } ) # Debug: log lane distribution import sys _lane_counts = {} for _n in nodes: _l = _n.get("lane", "unknown") _lane_counts[_l] = _lane_counts.get(_l, 0) + 1 print(f"[GRAPH] Lanes: {_lane_counts}, nodes: {len(nodes)}, edges: {len(edge_set)}", flush=True) for _n in nodes: if _n["lane"] in ("platform", "singleton"): print(f" {_n['lane'].upper()}: {_n['label']} | svc={_n['svc']}", flush=True) edges = [{"source": s, "target": t} for s, t in sorted(edge_set)] base_graph = {"nodes": nodes, "edges": edges} GRAPH_CACHE[cache_key] = (now, base_graph) # Return full graph fast if not root_uid: return { "nodes": base_graph["nodes"], "edges": base_graph["edges"], "total_nodes": len(base_graph["nodes"]), "total_edges": len(base_graph["edges"]), "cached": bool(cached), } node_ids = {n["id"] for n in base_graph["nodes"]} if root_uid not in node_ids: return {"nodes": [], "edges": [], "total_nodes": 0, "total_edges": 0, "cached": bool(cached)} adj: dict[str, set[str]] = {uid: set() for uid in node_ids} for e in base_graph["edges"]: s, t = e["source"], e["target"] if s in adj and t in adj: adj[s].add(t) adj[t].add(s) frontier = {root_uid} seen = {root_uid} for _ in range(depth): nxt = set() for node in frontier: nxt |= adj.get(node, set()) nxt -= seen if not nxt: break seen |= nxt frontier = nxt sub_nodes = [n for n in base_graph["nodes"] if n["id"] in seen] sub_edges = [e for e in base_graph["edges"] if e["source"] in seen and e["target"] in seen] return { "nodes": sub_nodes, "edges": sub_edges, "total_nodes": len(sub_nodes), "total_edges": len(sub_edges), "cached": bool(cached), } @app.get("/api/instances/stream") async def stream_instances( request: Request, x_deck_token: Annotated[str | None, Header()] = None, x_deck_env: Annotated[str | None, Header()] = None, ): if not x_deck_token: raise HTTPException(status_code=401, detail="Token required") _, deck_api = resolve_deck_api(x_deck_env) all_instances = await fetch_all_instances(x_deck_token, deck_api) uid_to_inst = { i.get("instanceUid"): i for i in all_instances if i.get("instanceUid") } async def generate(): sem = asyncio.Semaphore(20) queue: asyncio.Queue = asyncio.Queue() total = len(uid_to_inst) async def fetch_one(uid: str, client: httpx.AsyncClient): async with sem: try: r = await client.get( f"{deck_api}/index.cfm/instances/{uid}", headers=auth_headers(x_deck_token), ) detail = None if r.status_code >= 400 else r.json().get("instance") except Exception: detail = None await queue.put((uid, detail)) async with httpx.AsyncClient(timeout=25) as shared_client: tasks = [ asyncio.create_task(fetch_one(uid, shared_client)) for uid in uid_to_inst ] for _ in range(total): uid, detail = await queue.get() row = dict(uid_to_inst.get(uid, {})) if detail: row["_detail"] = detail yield json.dumps(row, ensure_ascii=False) + "\n" yield json.dumps({"_done": True, "total": total}, ensure_ascii=False) + "\n" return StreamingResponse(generate(), media_type="application/x-ndjson") @app.get("/api/instances/{uid}") async def get_instance( request: Request, uid: str, x_deck_token: Annotated[str | None, Header()] = None, x_deck_env: Annotated[str | None, Header()] = None, ): if not x_deck_token: raise HTTPException(status_code=401, detail="Token required") _, deck_api = resolve_deck_api(x_deck_env) async with httpx.AsyncClient(timeout=20) as client: r = await client.get( f"{deck_api}/index.cfm/instances/{uid}", headers=auth_headers(x_deck_token), ) if r.status_code == 401: raise HTTPException(status_code=401, detail="Invalid token") return r.json() app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")