Files
naeel 4915d17555 feat: радиокнопки вместо select для режима/стенда (v1.2.32)
- index.html: radio buttons (🎭/☁️ для режима, DEV/TEST/PROD для стенда)
- main.py: set_mode сохраняет polygon_stand из cookie если не в форме
  (нужно при Облако→Эмуляция — радио стенда нет в DOM)
2026-08-03 09:13:57 +04:00

254 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Роуты главной страницы и списка операций.
GET/POST / — главная страница: организация, инфраструктура, токен
GET /api/operations/<svc_id> — операции и 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/<int:svc_id>")
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