v9.43: pre-graph-cleanup baseline (zebra, uuid copy, theme fix, delete policy)
This commit is contained in:
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user