v9.43: pre-graph-cleanup baseline (zebra, uuid copy, theme fix, delete policy)

This commit is contained in:
Naeel
2026-04-17 08:51:07 +03:00
parent 38efc70bed
commit b778f5554c
7 changed files with 940 additions and 124 deletions
+115 -29
View File
@@ -6,6 +6,7 @@ from typing import Annotated
import asyncio
import json
import time
from datetime import datetime, timezone
app = FastAPI()
@@ -18,6 +19,15 @@ DEFAULT_DECK_ENV = "test"
CACHE_TTL_SECONDS = 180
GRAPH_CACHE: dict[str, tuple[float, dict]] = {}
# Centralized backend policy for table-related derived fields.
DELETE_POLICY = {
"status": "suspended",
"min_days": 14,
"op_name_patterns": ("suspend", "приост", "замороз"),
"op_time_fields": ("dtFinish", "dtStart", "dtCreated", "dtState"),
"fallback_time_fields": ("dtState", "instanceConfigDtUpdated"),
}
def auth_headers(token: str):
return {"Authorization": f"Bearer {token}"}
@@ -77,6 +87,83 @@ def extract_platform_name(inst: dict | None) -> str:
return ""
def _parse_ts(value) -> float | None:
if not value:
return None
if isinstance(value, (int, float)):
return float(value)
if not isinstance(value, str):
return None
raw = value.strip()
if not raw:
return None
try:
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
dt = datetime.fromisoformat(raw)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.timestamp()
except ValueError:
return None
def _extract_suspend_entered_ts(detail: dict | None) -> float | None:
if not isinstance(detail, dict):
return None
latest_ts: float | None = None
operations = detail.get("operations")
if isinstance(operations, list):
for op in operations:
if not isinstance(op, dict):
continue
op_name = str(op.get("operation") or op.get("type") or op.get("action") or "").strip().lower()
if not any(pattern in op_name for pattern in DELETE_POLICY["op_name_patterns"]):
continue
for field in DELETE_POLICY["op_time_fields"]:
ts = _parse_ts(op.get(field))
if ts is None:
continue
if latest_ts is None or ts > latest_ts:
latest_ts = ts
break
if latest_ts is not None:
return latest_ts
for field in DELETE_POLICY["fallback_time_fields"]:
ts = _parse_ts(detail.get(field))
if ts is not None:
return ts
return None
def build_stream_row_meta(row: dict, detail: dict | None) -> dict:
status = str(row.get("explainedStatus") or "").strip().lower()
meta = {
"deleteCandidate": {
"status": status,
"eligible": False,
"daysInSuspend": 0,
"minDays": DELETE_POLICY["min_days"],
"suspendEnteredAt": "",
}
}
if status != DELETE_POLICY["status"]:
return meta
entered_ts = _extract_suspend_entered_ts(detail)
if entered_ts is None:
return meta
days = max(0, int((time.time() - entered_ts) // 86400))
meta["deleteCandidate"]["daysInSuspend"] = days
meta["deleteCandidate"]["eligible"] = days >= DELETE_POLICY["min_days"]
meta["deleteCandidate"]["suspendEnteredAt"] = datetime.fromtimestamp(entered_ts, tz=timezone.utc).isoformat().replace("+00:00", "Z")
return meta
async def fetch_all_instances(token: str, deck_api: str):
params: dict = {"page": 1, "size": 200}
all_results = []
@@ -91,9 +178,11 @@ async def fetch_all_instances(token: str, deck_api: str):
raise HTTPException(status_code=401, detail="Invalid token")
data = r.json()
results = data.get("results", [])
all_results.extend(results)
if len(results) < 200:
if not isinstance(results, list):
results = []
if not results:
break
all_results.extend(results)
params["page"] += 1
return all_results
@@ -152,26 +241,23 @@ async def get_graph(
}
allowed_uids = set(uid_to_inst.keys())
sem = asyncio.Semaphore(20)
async def fetch_detail(uid: str, client: httpx.AsyncClient):
async with sem:
detail_map: dict[str, dict] = {}
async with httpx.AsyncClient(timeout=25) as shared_client:
for uid in allowed_uids:
try:
r = await client.get(
r = await shared_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")
continue
inst = r.json().get("instance")
if inst:
detail_map[uid] = inst
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}
continue
# Categorise singletons by service name
_PLATFORM_PATTERNS = ("виртуальный датацентр", "vdc", "virtual data center", "cloud director", "kubernetes", "k8s")
@@ -276,35 +362,35 @@ async def stream_instances(
}
async def generate():
sem = asyncio.Semaphore(20)
queue: asyncio.Queue = asyncio.Queue()
total = len(uid_to_inst)
loaded = 0
started_at = time.time()
async def fetch_one(uid: str, client: httpx.AsyncClient):
async with sem:
async with httpx.AsyncClient(timeout=25) as shared_client:
for uid in uid_to_inst:
detail = None
try:
r = await client.get(
r = await shared_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")
if r.status_code == 401:
raise HTTPException(status_code=401, detail="Invalid token")
if r.status_code < 400:
detail = 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()
loaded += 1
row = dict(uid_to_inst.get(uid, {}))
row["_progress"] = {"loaded": loaded, "total": total}
row["_meta"] = build_stream_row_meta(row, detail)
if detail:
row["_detail"] = detail
yield json.dumps(row, ensure_ascii=False) + "\n"
yield json.dumps({"_done": True, "total": total}, ensure_ascii=False) + "\n"
duration_ms = int((time.time() - started_at) * 1000)
yield json.dumps({"_done": True, "total": total, "durationMs": duration_ms}, ensure_ascii=False) + "\n"
return StreamingResponse(generate(), media_type="application/x-ndjson")