v1.0.89: detailed code comments — all Python files + index.html architecture overview

This commit is contained in:
2026-07-27 22:02:51 +04:00
parent 7180587538
commit 1cc3f6eb7e
9 changed files with 290 additions and 38 deletions
+85 -19
View File
@@ -1,3 +1,16 @@
"""
Роуты главной страницы и списка операций.
GET/POST / — главная страница: организация, инфраструктура, токен
GET /api/operations/<svc_id> — операции и autotest-инстансы сервиса (cloud-first)
Вспомогательные функции:
_resolve_instance_status() — канонический статус (cloud или "creating" из трекера)
_mask() — маскировка токена (env: abc...xyz)
_client_id() — извлечение ClientID из JWT
_token_info() — email, компания, clientId из JWT
"""
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
@@ -7,36 +20,40 @@ 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):
"""Вернуть канонический статус инстанса для API и UI."""
"""Канонический статус инстанса: 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
return cloud_status # облако знает статус — доверяем
if tracked and display_name.startswith(AUTOTEST_PREFIX):
return "creating"
return "creating" # в трекере есть, в облаке ещё нет — создаётся
return "unknown"
def _mask(s):
"""Маскировка токена для показа в placeholder: abc...xyz."""
if not s or len(s) < 8:
return ""
return s[:4] + "*" * (len(s) - 8) + s[-4:]
def _client_id(token):
"""Извлечение ClientID из payload JWT-токена (base64, без проверки подписи)."""
import base64, json
try:
parts = token.split(".")
parts = token.split(".") # header.payload.signature
if len(parts) >= 2:
payload = base64.urlsafe_b64decode(parts[1] + "==")
payload = base64.urlsafe_b64decode(parts[1] + "==") # padding
return json.loads(payload).get("ClientID", "")
except Exception:
pass
@@ -44,6 +61,7 @@ def _client_id(token):
def _token_info(token):
"""Извлечение email, company, client_id из JWT для показа в топбаре."""
import base64, json
try:
parts = token.split(".")
@@ -62,9 +80,15 @@ def _token_info(token):
@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
active_token = user_token or env_token # пользовательский приоритетнее
org = None
error = None
@@ -75,7 +99,10 @@ def index():
action = request.form.get("action", "")
def _tmpl(**overrides):
"""Собрать контекст шаблона со всеми обязательными переменными."""
"""Хелпер: собрать контекст для render_template.
Гарантирует что ВСЕ переменные переданы, даже если API не отработал.
overrides — можно переопределить отдельные переменные."""
ctx = {
"organization": org,
"error": error,
@@ -92,48 +119,60 @@ def index():
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)
# redirect to GET
resp.headers["Location"] = "/"
resp.set_cookie("token", user_token, max_age=60*60*24*365) # 1 год
resp.headers["Location"] = "/" # редирект на GET (убирает POST из истории)
resp.status_code = 302
return resp
# Загрузка данных из API (только если есть токен)
services = []
instances = []
instance_groups = {}
config = {}
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", "")))
# group by service type
# Группировка по типу сервиса для левой колонки 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"]
except Exception as e:
@@ -141,6 +180,7 @@ def index():
else:
error = "Токен невалиден или просрочен"
# После POST (save/clear) — редирект на GET, чтобы F5 не переотправлял форму
if request.method == "POST":
return redirect("/")
return _tmpl()
@@ -148,49 +188,75 @@ def index():
@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:
# Автоопределение стенда + 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(_client_id(active_token), 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()
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
continue # не наш инстанс
if i.get("explainedStatus") in ("deleted",):
continue
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)
# tracker — только временный fallback, если cloud ещё не отдал конкретный autotest-инстанс
# --- Шаг 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
continue # дубль — облако уже показало этот инстанс
svc_instances.append({
"instanceUid": uid,
"displayName": display_name,
"serviceId": svc_id,
"svc": detail.get("svc", ""),
"explainedStatus": "",
"status": "creating",
"status": "creating", # наш статус — облако ещё не знает
})
return jsonify({"svc": detail.get("svc", ""), "operations": ops, "instances": svc_instances})
return jsonify({
"svc": detail.get("svc", ""),
"operations": ops, # [{svcOperationId, operation}, ...]
"instances": svc_instances, # [{instanceUid, displayName, status, ...}, ...]
})
except Exception as e:
return jsonify({"error": str(e)}), 500