""" Роуты главной страницы и списка операций. 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 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).""" # Токены: 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, } 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") # 1 год resp.headers["Location"] = "/" # редирект на GET (убирает POST из истории) resp.status_code = 302 return resp # Загрузка данных из API (только если есть токен) services = [] instances = [] instance_groups = {} config = {} config["VERSION"] = current_app.config["VERSION"] # всегда, даже без токена config["service_ids"] = [] stand = "?" if active_token: # create_client — автоопределение стенда + HttpClient result = create_client(active_token, current_app.config["NUBES_API_ENDPOINT"]) if result: client, endpoint = result stand = stand_name(endpoint) try: # Организация — инстанс с serviceId=19 org = get_organization(client) # Все сервисы — сортировка по svcId raw_svc = get_services(client) services = sorted(raw_svc, key=lambda s: (s.get("svcId", 0), s.get("svc", ""))) # Инфраструктурные инстансы — только определённые serviceId # 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(client) instances = [i for i in raw_inst if i.get("explainedStatus") not in ("deleted", "not created") and i.get("serviceId") in infra_ids] # Сортировка: организация (svcId=19) первая, остальные по имени 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) — редирект на GET, чтобы F5 не переотправлял форму 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: # Автоопределение стенда + HttpClient result = create_client(active_token, current_app.config["NUBES_API_ENDPOINT"]) if not result: return jsonify({"error": "Не удалось определить стенд"}), 500 client, endpoint = result # Детали сервиса: список операций (modify, delete, suspend, ...) detail = get_service_detail(client, svc_id) ops = detail.get("operations", []) # Трекер: наши autotest-инстансы (изолирован по пользователю и стенду) tracked = tracker_list(get_client_id(), stand_name(endpoint)) tracked_by_uid = {t["instanceUid"]: t for t in tracked if t["svcId"] == svc_id} # Все инстансы из облака 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 "").replace("T", " ").replace(" ", "") )) # --- Шаг 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