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
+52 -4
View File
@@ -1,5 +1,20 @@
"""
HTTP-клиент для Nubes API + автоопределение стенда.
Класс HttpClient — тонкая обёртка над requests.Session:
- Добавляет заголовки: Authorization Bearer, User-Agent (DDoS-Guard)
- GET: raise_for_status → .json()
- POST: проверка r.ok, извлечение Location-заголовка
Функции автостенда:
- detect_endpoint(token) — пробует dev→test стенды, возвращает URL
- stand_name(endpoint) — "dev" / "test" по URL
- create_client(token, fallback) — HttpClient + endpoint (одним вызовом)
"""
import requests
# Список стендов для автоопределения. Порядок важен: dev первый (быстрее).
STANDS = [
"https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc",
"https://lk-api-gateway-test.ngcloud.ru/api/v1/svc",
@@ -7,7 +22,11 @@ STANDS = [
def detect_endpoint(token):
"""Пробуем токен против dev и test стендов, возвращаем рабочий URL."""
"""Пробуем токен против dev и test стендов, возвращаем рабочий URL.
Делает GET /instances?pageSize=1 на каждый стенд.
Если results != None — стенд рабочий, возвращаем его URL.
Если ни один не подошёл — возвращаем None."""
for ep in STANDS:
try:
c = HttpClient(ep, token)
@@ -20,7 +39,10 @@ def detect_endpoint(token):
def stand_name(endpoint):
"""dev/test по URL стенда."""
"""dev/test по URL стенда.
Ищет подстроку 'dev' или 'test' в URL.
Если не найдено — возвращает '?'."""
for name in ("dev", "test"):
if name in (endpoint or ""):
return name
@@ -28,7 +50,10 @@ def stand_name(endpoint):
def create_client(token, fallback_endpoint=None):
"""HttpClient с автоопределением стенда по токену."""
"""HttpClient с автоопределением стенда по токену.
Сначала detect_endpoint(token), если не сработало — fallback_endpoint.
Возвращает кортеж (HttpClient, endpoint_url) или None если стенд не определён."""
ep = detect_endpoint(token) or fallback_endpoint
if not ep:
return None
@@ -36,8 +61,17 @@ def create_client(token, fallback_endpoint=None):
class HttpClient:
"""HTTP-клиент для Nubes REST API.
Использует requests.Session для keep-alive соединений.
Добавляет обязательные заголовки:
- Authorization: Bearer <token> (аутентификация)
- User-Agent: Mozilla/5.0 (DDoS-Guard требует НЕ python-requests)"""
def __init__(self, endpoint, token):
# Убираем trailing slash чтобы потом добавлять "/path"
self._endpoint = endpoint.rstrip("/")
# Session — переиспользует TCP-соединения между запросами
self._session = requests.Session()
self._session.headers.update({
"Authorization": f"Bearer {token}",
@@ -45,14 +79,27 @@ class HttpClient:
})
def get(self, path, **kwargs):
"""GET-запрос. Таймаут по умолчанию 10 секунд.
Вызывает raise_for_status() — при 4xx/5xx выбрасывает HTTPError.
Возвращает распарсенный JSON (dict/list)."""
kwargs.setdefault("timeout", 10)
r = self._session.get(f"{self._endpoint}{path}", **kwargs)
r.raise_for_status()
return r.json()
def post(self, path, data=None, **kwargs):
"""POST-запрос. Таймаут по умолчанию 30 секунд.
Отправляет data как JSON (json=...).
При HTTP-ошибке выбрасывает Exception с кодом и телом ответа.
Возвращает dict:
- Распарсенный JSON (если ответ — валидный JSON-объект)
- + ключ "_location" со значением заголовка Location (если есть)
Location нужен для получения UID созданного ресурса (instanceUid, opUid)."""
kwargs.setdefault("timeout", 30)
url = f"{self._endpoint}{path}"
# json=... — requests сам сериализует и ставит Content-Type: application/json
r = self._session.post(url, json=(data if data is not None else {}), **kwargs)
if not r.ok:
raise Exception(f"POST {path}: {r.status_code} {r.reason}: {r.text[:200]}")
@@ -62,8 +109,9 @@ class HttpClient:
if isinstance(parsed, dict):
result = parsed
except Exception:
pass
pass # тело не JSON или не dict — ок, Location всё равно извлечём
loc = r.headers.get("Location", "")
if loc:
# Location бывает вида "./UUID" или "/api/v1/svc/instanceOperations/UUID"
result["_location"] = loc
return result
+13 -1
View File
@@ -1,3 +1,14 @@
"""
app-autotest — точка входа Flask-приложения для автотестов Nubes.
Регистрирует три blueprint'а:
- main_bp → / (главная, токен, инфраструктура)
- api_bp → /api/run, /api/status, /api/config [LEGACY]
- api_test_bp → /api/test, /api/params, /api/log (основная логика)
Деплой: Nubes pythonk8s (gunicorn, несколько воркеров).
"""
import os
from flask import Flask
@@ -6,7 +17,8 @@ from routes.main import bp as main_bp
from routes.api import bp as api_bp
from routes.api_test import bp as api_test_bp
VERSION = "1.0.88"
# Версия — меняется при КАЖДОМ изменении кода. Показывается в топбаре UI.
VERSION = "1.0.89"
app = Flask(__name__, template_folder="templates", static_folder="static")
app.config["NUBES_API_ENDPOINT"] = os.getenv("NUBES_API_ENDPOINT", "https://lk-api-gateway-dev.ngcloud.ru/api/v1/svc")
+14 -1
View File
@@ -1,7 +1,16 @@
"""
Операции с инстансами Nubes — получение списка и организации через API.
"""
from api.http_client import HttpClient
def get_instances(client):
"""GET /instances с пагинацией → ВСЕ инстансы пользователя.
pageSize=200 — максимум за один запрос.
Остановка по len(batch) < pageSize (НЕ доверяет total — бывали баги).
Возвращает полный list[dict]."""
results = []
page = 1
page_size = 200
@@ -9,7 +18,8 @@ def get_instances(client):
data = client.get("/instances", params={"pageSize": page_size, "page": page})
batch = data.get("results", []) or []
results.extend(batch)
# Не доверяем total: если страница вернула меньше pageSize, значит данные закончились.
# Если страница неполная — данные закончились.
# Не проверяем total, потому что API иногда врёт про total.
if len(batch) < page_size:
break
page += 1
@@ -17,6 +27,9 @@ def get_instances(client):
def get_organization(client):
"""Первый инстанс с serviceId=19 (Организация).
Организация всегда одна на пользователя. Используется в UI: название, статус, ClientID."""
for inst in get_instances(client):
if inst.get("serviceId") == 19:
return inst
+5 -1
View File
@@ -64,8 +64,12 @@ def get_params_with_current_values(client, op_id, instance_uid):
"name": code,
"dataType": data_type,
"isRequired": required,
"defaultValue": merged_value,
"defaultValue": merged_value, # ← фронт использует это для value="..." в инпутах
"valueList": value_list,
# dataDescriptor — только для map/map-fixed типов.
# Содержит словарь {sub_code: {dataType, valueList, isRequired}} —
# это нужно фронту чтобы отрисовать вложенную таблицу подполей.
# Для не-map типов — None.
"dataDescriptor": (
{
k: {
+11
View File
@@ -1,11 +1,22 @@
"""
Операции с сервисами Nubes — получение списка и деталей через API.
"""
from api.http_client import HttpClient
def get_services(client):
"""GET /services → список ВСЕХ сервисов пользователя.
Возвращает list[dict], каждый с ключами: svcId, svc, svcExtendedName, operations, ..."""
data = client.get("/services")
return data.get("results", [])
def get_service_detail(client, svc_id):
"""GET /services/{svcId} → детали одного сервиса.
Возвращает dict с ключами: svc, operations[], ...
operations — список доступных операций (create, modify, delete, ...)."""
data = client.get(f"/services/{svc_id}")
return data.get("svc", {})
+48 -12
View File
@@ -1,4 +1,21 @@
"""Трекер созданных инстансов — по юзеру и стенду: /tmp/instances-{client_id}-{stand}.json."""
"""
Трекер созданных инстансов.
Хранит UID'ы инстансов, созданных через приложение, в JSON-файлах.
Нужен ТОЛЬКО как краткосрочный fallback — когда инстанс создан, но ещё
не появился в ответе GET /instances (облако может задержать на секунды).
Файлы изолированы по пользователю и стенду:
/tmp/instances-{clientId}-{stand}.json
Блокировка fcntl.flock для безопасной работы с несколькими воркерами gunicorn.
При редеплое /tmp/ теряется — это НЕ критично, cloud-first выдача всё покажет.
Функции:
add(client_id, stand, uid, svc_id, name) — добавить инстанс
remove(client_id, stand, uid) — удалить (после успешного delete)
list_all(client_id, stand) — все записи списком
"""
import fcntl
import json
import os
@@ -6,11 +23,15 @@ import time
def _path(client_id, stand):
"""Путь к файлу трекера для конкретного пользователя и стенда."""
return f"/tmp/instances-{client_id}-{stand}.json"
def _acquire_lock(fd):
"""LOCK_EX с таймаутом 2 секунды (LOCK_NB + retry)."""
"""Эксклюзивная блокировка файла с таймаутом 2 секунды.
Использует LOCK_NB (неблокирующий) + retry с шагом 50ms.
Если за 2 секунды не взяли лок — возвращает False (не блокируемся навечно)."""
deadline = time.time() + 2
while True:
try:
@@ -23,44 +44,55 @@ def _acquire_lock(fd):
def _locked_read(path):
"""Читает файл под эксклюзивной блокировкой, пустой dict если нет."""
"""Читает JSON-файл под эксклюзивной блокировкой.
Если файла нет — создаёт и возвращает пустой {}.
Если JSON битый — возвращает {} (переживёт перезапись).
Максимальный размер: 65536 байт."""
try:
# O_RDWR — чтение+запись, O_CREAT — создать если нет
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
except OSError:
return {}
return {} # нет прав — молча возвращаем пустой
try:
if not _acquire_lock(fd):
return {}
return {} # не смогли заблокировать за 2с — не рискуем
try:
data = os.read(fd, 65536)
if data:
return json.loads(data.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
pass
pass # битый файл — переживём, при следующей записи перезапишется
return {}
finally:
fcntl.flock(fd, fcntl.LOCK_UN)
fcntl.flock(fd, fcntl.LOCK_UN) # ВСЕГДА снимаем блокировку
os.close(fd)
def _locked_write(path, data):
"""Пишет файл под эксклюзивной блокировкой."""
"""Пишет JSON-файл под эксклюзивной блокировкой.
Полностью перезаписывает файл: lseek(0) + ftruncate + write."""
try:
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
except OSError:
return
try:
if not _acquire_lock(fd):
return
os.lseek(fd, 0, 0)
os.ftruncate(fd, 0)
return # не смогли заблокировать — не пишем (данные не потеряются, просто не сохранятся)
os.lseek(fd, 0, 0) # в начало файла
os.ftruncate(fd, 0) # обрезать старый контент
os.write(fd, json.dumps(data, indent=2).encode("utf-8"))
finally:
fcntl.flock(fd, fcntl.LOCK_UN)
fcntl.flock(fd, fcntl.LOCK_UN) # ВСЕГДА снимаем блокировку
os.close(fd)
def add(client_id, stand, instance_uid, svc_id, display_name):
"""Добавить инстанс в трекер.
Читает текущий файл → добавляет запись → пишет обратно.
Если инстанс уже есть — перезаписывает (идемпотентно)."""
p = _path(client_id, stand)
data = _locked_read(p)
data[instance_uid] = {
@@ -72,6 +104,9 @@ def add(client_id, stand, instance_uid, svc_id, display_name):
def remove(client_id, stand, instance_uid):
"""Удалить инстанс из трекера (после успешного delete).
pop с default=None — не падает если инстанса уже нет."""
p = _path(client_id, stand)
data = _locked_read(p)
data.pop(instance_uid, None)
@@ -79,4 +114,5 @@ def remove(client_id, stand, instance_uid):
def list_all(client_id, stand):
"""Все записи трекера — список dict'ов {svcId, displayName, instanceUid}."""
return list(_locked_read(_path(client_id, stand)).values())
+25
View File
@@ -1,3 +1,28 @@
"""
Основной модуль: запуск операций, параметры, поллинг статуса, логирование.
Эндпоинты:
GET /api/services — список сервисов
GET /api/instances/list — все инстансы
GET /api/operations/<svc_id> — операции + tracked-инстансы сервиса
GET /api/params/<op_id>[?instanceUid=xxx] — параметры операции (шаблон или текущие)
POST /api/test — запустить операцию (CREATE или non-CREATE)
GET /api/test/status/<op_uid> — поллинг статуса операции
GET /api/log — последние строки лога
Ключевые функции:
_client() — HttpClient с токеном из cookie/env
_client_id() — ClientID из JWT
_stand() — dev/test по токену
_with_prefix(name) — гарантирует префикс autotest-
_unique_display_name(client, name)— проверка на дубликат + суффикс
_find_uid(resp) — извлечение UUID из ответа API
_uid_from_location(loc) — извлечение UUID из Location-заголовка
_get_instance_display_name(...) — GET /instances/{uid} → displayName
_finish_op(...) — фоновый поллинг до dtFinish
_log(msg) — лог в stdout + /tmp/app-autotest.log
"""
from flask import Blueprint, current_app, jsonify, request
import uuid
import os
+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
+37
View File
@@ -41,6 +41,19 @@
</head>
<body>
<!--
=== ПЕРЕМЕННЫЕ ШАБЛОНА JINJA2 ===
config.VERSION — версия приложения (топбар)
token_info.email — email пользователя (топбар)
token_info.company — профиль/компания (топбар)
stand — стенд: dev/test (топбар)
env_token_masked — маскированный env-токен (placeholder)
has_user_token — True если пользователь ввёл свой токен
organization — организация (serviceId=19)
instance_groups — {svc_name: [instances]} для левой колонки
error — сообщение об ошибке
-->
<div class="topbar">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Nubes" style="height:22px;" />
<span style="font-size:11px;color:var(--muted);">v{{ config.VERSION }}</span>
@@ -111,6 +124,30 @@
</div>
<script>
/*
* === АРХИТЕКТУРА ФРОНТЕНДА ===
*
* Один HTML-файл, ванильный JS (без фреймворков).
* Три колонки: инфраструктура | сервисы | инстансы+операции+параметры.
*
* Глобальное состояние:
* svcInstances — кеш списка инстансов из /api/operations/{svcId}
* selectedInst — UID выбранного инстанса (null если не выбран)
* selectedOp — {opId, opName, svcId} выбранная операция
* pollTimer — setInterval таймер поллинга статуса операции
* SVC_ID = 1 — фиксированный сервис (Болванка)
* AUTOTEST_PREFIX — префикс имён autotest-инстансов
*
* Потоки:
* CREATE: startCreate() → showParams(18,'create') → executeOp(pp) → poll → refresh
* MODIFY: toggleInstance() → runOp('modify',id) → showParams(id,'modify') → validate → executeOp
* БЕЗ ПАРАМЕТРОВ (delete/suspend/resume/redeploy): runOp() → confirm → executeOp({})
*
* Хелперы:
* _esc(s) — HTML-экранирование (" → &quot; & → &amp; < → &lt;)
* validateJson() — проверка JSON.parse для map-полей (onblur + перед отправкой)
* showStages() — отрисовка этапов выполнения операции (⏳/✅/❌)
*/
let svcInstances=[], selectedInst=null, selectedOp=null, pollTimer=null;
const SVC_ID=1;
const AUTOTEST_PREFIX='autotest-';