""" Роуты главной страницы и списка операций. GET/POST / — главная страница: организация, инфраструктура, токен GET /api/operations/ — операции и autotest-инстансы сервиса (cloud-first) Вспомогательные функции: _resolve_instance_status() — канонический статус (cloud или "creating" из трекера) """ from flask import Blueprint, current_app, render_template, request, make_response, jsonify, redirect from api.http_client import HttpClient, detect_endpoint, create_client, stand_name from api.auth import get_token, get_client_id, get_token_info, get_token_masked, get_client, get_real_client, get_stand, get_mode, get_polygon_stand from operations.get_instances import get_organization, get_instances from operations.get_services import get_services, get_service_detail from operations.service_list import load_service_ids from operations.tracker import list_all as tracker_list from runner import load_config bp = Blueprint("main", __name__) # Префикс для autotest-инстансов — по нему фильтруем «наши» инстансы в облаке. AUTOTEST_PREFIX = "autotest-" def _resolve_instance_status(instance, tracked=None): """Канонический статус инстанса: explainedStatus из облака, или "creating" из трекера.""" tracked = tracked or {} cloud_status = str(instance.get("explainedStatus") or "").strip() display_name = str(instance.get("displayName") or "") if cloud_status: return cloud_status # облако знает статус — доверяем if tracked and display_name.startswith(AUTOTEST_PREFIX): return "creating" # в трекере есть, в облаке ещё нет — создаётся return "unknown" @bp.route("/", methods=["GET", "POST"]) def index(): """Главная страница: организация, инфраструктура, сервисы, форма токена. GET — рендерит страницу с данными из API. POST — обрабатывает форму токена (action=save/clear) или смену режима (action=set_mode).""" # Режим и стенд — из cookie (см. auth.py) mode = get_mode() polygon_stand = get_polygon_stand() polygon_enabled = bool(current_app.config.get("POLYGON_ENDPOINT", "")) # Токены: env — из переменной окружения, user — из cookie или формы env_token = current_app.config["NUBES_API_TOKEN"] user_token = request.cookies.get("token") or request.form.get("token") or "" active_token = user_token or env_token org = None error = None services = [] instances = [] instance_groups = {} config = {} action = request.form.get("action", "") def _tmpl(**overrides): """Хелпер: собрать контекст для render_template. Гарантирует что ВСЕ переменные переданы, даже если API не отработал. overrides — можно переопределить отдельные переменные.""" ctx = { "organization": org, "error": error, "env_token_masked": get_token_masked(), "has_user_token": bool(user_token), "client_id": get_client_id(), "token_info": get_token_info(), "config": config, "stand": stand, "services": services, "instances": instances, "instance_groups": instance_groups, "mode": mode, "polygon_stand": polygon_stand, "polygon_enabled": polygon_enabled, } ctx.update(overrides) return render_template("index.html", **ctx) # Обработка action=clear: удалить cookie с токеном if action == "clear": resp = make_response(redirect("/")) resp.delete_cookie("token") return resp # Обработка action=save: сохранить токен в cookie (на год) if action == "save": user_token = request.form.get("token", "") active_token = user_token or env_token resp = make_response() resp.set_cookie("token", user_token, max_age=60*60*24*365, httponly=True, samesite="Strict", secure=True) resp.headers["Location"] = "/" resp.status_code = 302 return resp # Обработка action=set_mode: сохранить режим и стенд в cookie if action == "set_mode": new_mode = request.form.get("mode", "polygon") # polygon_stand: из формы, иначе — сохранить предыдущее значение из cookie # (нужно при переключении Облако→Эмуляция — радио стенда нет в DOM) new_stand = request.form.get("polygon_stand") or request.cookies.get("polygon_stand", "test") resp = make_response(redirect("/")) resp.set_cookie("mode", new_mode, max_age=60*60*24*365, httponly=True, samesite="Strict", secure=True) resp.set_cookie("polygon_stand", new_stand, max_age=60*60*24*365, httponly=True, samesite="Strict", secure=True) return resp # Загрузка данных из API # В эмуляции — без проверки токена (полигон не требует авторизации) # В облаке — только если есть токен services = [] instances = [] instance_groups = {} config = {} config["VERSION"] = current_app.config["VERSION"] config["service_ids"] = [] stand = "?" if mode == "polygon" or active_token: client = get_client() inst_client = client # единый клиент — и сервисы, и инстансы stand = get_stand() try: # Организация — инстанс с serviceId=19 org = get_organization(client) # Все сервисы — из полигона (эмуляция) или реального API (облако) raw_svc = get_services(client) services = sorted(raw_svc, key=lambda s: (s.get("svcId", 0), s.get("svc", ""))) # Инфраструктурные инстансы # 19=Org, 21=vDC, 22=NSX-T, 25=External IP, 26=vApp, 29=vDC Group # 2=Template, 12=S3, 110=?, 150=K8s infra_ids = {2, 12, 21, 22, 25, 26, 29, 110, 150} raw_inst = get_instances(inst_client) instances = [i for i in raw_inst if i.get("explainedStatus") not in ("deleted", "not created") and i.get("serviceId") in infra_ids] instances.sort(key=lambda i: (0 if i.get("serviceId") == 19 else 1, i.get("displayName", ""))) # Группировка по типу сервиса для левой колонки UI instance_groups = {} for i in instances: svc_name = i.get("svc", "Прочее") instance_groups.setdefault(svc_name, []).append(i) # Конфиг из config.yaml + версия приложения config = dict(load_config() or {}) config["VERSION"] = current_app.config["VERSION"] # Список разрешённых сервисов из services_{stand}.txt config["service_ids"] = sorted(load_service_ids(stand)) except Exception as e: error = str(e) else: error = "Токен невалиден или просрочен" # После POST (save/clear/set_mode) — редирект на GET if request.method == "POST": return redirect("/") return _tmpl() @bp.route("/api/operations/") def api_operations(svc_id): """Список операций и autotest-инстансов сервиса (cloud-first с fallback на трекер). Вызывается из UI: selectService(), toggleInstance(), refreshInstances(). Алгоритм: 1. Cloud-first: фильтруем GET /instances по префиксу autotest- 2. Tracker-fallback: если инстанс есть в трекере но нет в облаке → status="creating" 3. Дедупликация: если displayName уже есть в cloud — tracker-запись пропускается""" # Токен из cookie или env env_token = current_app.config["NUBES_API_TOKEN"] user_token = request.cookies.get("token") or "" active_token = user_token or env_token try: # Единый клиент — и сервисы, и инстансы (полигон или реальный API) client = get_client() # Детали сервиса: список операций (modify, delete, suspend, ...) detail = get_service_detail(client, svc_id) ops = detail.get("operations", []) # Трекер: наши autotest-инстансы (изолирован по пользователю и стенду) tracked = tracker_list(get_client_id(), get_stand()) tracked_by_uid = {t["instanceUid"]: t for t in tracked if t["svcId"] == svc_id} # Все инстансы — из полигона или реального API instances = get_instances(client) nubes_uids = {i["instanceUid"] for i in instances} svc_instances = [] cloud_names = set() # для дедупликации с tracker # --- Шаг 1: Cloud-first — фильтруем по префиксу autotest- --- for i in instances: display_name = str(i.get("displayName", "") or "") if not display_name.startswith(AUTOTEST_PREFIX): continue # не наш инстанс if i.get("explainedStatus") in ("deleted",): continue # удалённые не показываем item = dict(i) item["status"] = _resolve_instance_status(item, tracked_by_uid.get(i.get("instanceUid"))) svc_instances.append(item) cloud_names.add(display_name) # Сортировка: по имени сервиса, затем по дате создания (новые сверху) svc_instances.sort(key=lambda i: ( i.get("svc", ""), i.get("instanceConfigDtCreated") or "" ), reverse=True) # reverse=True даёт обратный порядок по обоим ключам. # Чтобы сервисы шли A→Я а даты новые→старые — пересортируем: svc_instances.sort(key=lambda i: i.get("instanceConfigDtCreated") or "", reverse=True) svc_instances.sort(key=lambda i: i.get("svc", "")) # --- Шаг 2: Tracker-fallback --- # Инстансы из трекера, которых ещё нет в облаке (только что созданы). # Но если displayName уже есть в cloud_names — дубль, пропускаем. for uid, t in tracked_by_uid.items(): if uid not in nubes_uids: display_name = str(t.get("displayName", "") or "") if display_name in cloud_names: continue # дубль — облако уже показало этот инстанс svc_instances.append({ "instanceUid": uid, "displayName": display_name, "serviceId": svc_id, "svc": detail.get("svc", ""), "explainedStatus": "", "status": "creating", # наш статус — облако ещё не знает }) return jsonify({ "svc": detail.get("svc", ""), "svcShort": detail.get("svcShort", ""), "operations": ops, # [{svcOperationId, operation}, ...] "instances": svc_instances, # [{instanceUid, displayName, status, ...}, ...] }) except Exception as e: return jsonify({"error": str(e)}), 500