Files
dashboard/main.py
T

244 lines
8.0 KiB
Python

from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.responses import StreamingResponse
from fastapi.staticfiles import StaticFiles
import httpx
from typing import Annotated
import json
import time
from datetime import datetime, timezone
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"
# 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}"}
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 _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 = []
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", [])
if not isinstance(results, list):
results = []
if not results:
break
all_results.extend(results)
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/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():
total = len(uid_to_inst)
loaded = 0
started_at = time.time()
async with httpx.AsyncClient(timeout=25) as shared_client:
for uid in uid_to_inst:
detail = None
try:
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:
detail = r.json().get("instance")
except Exception:
detail = None
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"
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")
@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")