feat: селектор режима и стенда (v1.2.30)

Новое:
- Селектор режима (🎭 Эмуляция / ☁️ Облако) над версией
- Селектор стенда (DEV/TEST/PROD) в режиме эмуляции
- В эмуляции — всё в полигон (/stand/api/v1/svc)
- В эмуляции — токен дезактивирован
- По умолчанию: эмуляция + TEST

Изменено:
- auth.py: get_mode(), get_polygon_stand(), get_client() с префиксом стенда
- main.py: action=set_mode, единый get_client() вместо real_client/inst_client
- index.html: селекторы, disable токена
- service_list.py: обрезка polygon_ префикса
- api_test.py: get_real_client → get_client

Задокументировано в HISTORY/2026-08-03-session.md.
This commit is contained in:
2026-08-03 08:50:14 +04:00
parent 78e1c4dc3b
commit 0a62eb3c58
6 changed files with 156 additions and 108 deletions
+62 -44
View File
@@ -5,13 +5,24 @@
в main.py и api_test.py с идентичным или почти идентичным кодом. в main.py и api_test.py с идентичным или почти идентичным кодом.
Теперь всё здесь — один источник правды для всех роутов. Теперь всё здесь — один источник правды для всех роутов.
Режимы работы (2026-08-03):
- Эмуляция (mode=polygon): всё в полигон, стенд из селектора (dev/test/prod)
- Облако (mode=cloud): реальный API, стенд автоопределяется по токену
Cookie (устанавливаются main.py:action=set_mode):
- mode — "polygon" (по умолчанию) или "cloud"
- polygon_stand — "test" (по умолчанию), "dev", "prod"
Функции (все без аргументов — берут данные из Flask request/current_app): Функции (все без аргументов — берут данные из Flask request/current_app):
get_token() — токен: cookie → env-переменная get_mode() "polygon" / "cloud"
get_client() — HttpClient с автоопределением стенда get_polygon_stand() — "dev" / "test" / "prod"
get_client_id() — ClientID из JWT (base64url, без проверки подписи) get_token() — токен: cookie → env (в эмуляции только env)
get_stand() — "dev" / "test" по токену get_client() — HttpClient с учётом режима и стенда
get_token_info() — {email, company, client_id} из JWT для UI get_real_client() — алиас get_client() (историческая совместимость)
get_token_masked() — маскированный токен (abc...xyz) для placeholder get_client_id() — ClientID из JWT (base64url, без проверки подписи)
get_stand() — "polygon_test" / "dev" / "test"
get_token_info() — {email, company, client_id} из JWT для UI
get_token_masked() — маскированный токен (abc...xyz) для placeholder
""" """
import base64 import base64
@@ -21,57 +32,63 @@ from flask import request, current_app
from api.http_client import HttpClient, detect_endpoint, stand_name, STANDS from api.http_client import HttpClient, detect_endpoint, stand_name, STANDS
def get_mode():
"""Режим: 'polygon' (эмуляция) или 'cloud' (реальное облако).
Если POLYGON_ENDPOINT не задан в конфиге — всегда облако (селектор не нужен).
Иначе — из cookie, по умолчанию эмуляция."""
if not current_app.config.get("POLYGON_ENDPOINT", ""):
return "cloud"
return request.cookies.get("mode", "polygon")
def get_polygon_stand():
"""Стенд полигона: 'test' (по умолчанию), 'dev', 'prod'.
Используется ТОЛЬКО в режиме эмуляции — для построения URL и load_service_ids."""
return request.cookies.get("polygon_stand", "test")
def get_token(): def get_token():
"""Получить активный токен: сначала из cookie пользователя, потом из env. """Получить активный токен.
Приоритет: В эмуляции — ТОЛЬКО env-токен (пользовательский ввод дезактивирован).
1. cookie "token" — пользователь ввёл свой токен в форме В облаке — cookie → env (как раньше)."""
2. NUBES_API_TOKEN из env — сервисный токен (для автоматических тестов) if get_mode() == "polygon":
return current_app.config["NUBES_API_TOKEN"]
Пользовательский токен приоритетнее — он переопределяет сервисный."""
return request.cookies.get("token") or current_app.config["NUBES_API_TOKEN"] return request.cookies.get("token") or current_app.config["NUBES_API_TOKEN"]
def _make_client(*, use_polygon): def get_client():
"""Фабрика HttpClient — ЕДИНСТВЕННОЕ место где выбирается endpoint. """HttpClient с учётом режима и стенда.
Args: Эмуляция: polygon_url + "/" + stand → https://polygon.../{stand}/api/v1/svc
use_polygon: если True и POLYGON_ENDPOINT задан → polygon. Облако: реальный API с автоопределением стенда (dev/test по токену)."""
Если False → всегда реальный API (для чтения сервисов/инстансов).
Returns:
HttpClient с правильным endpoint и токеном.
"""
token = get_token() token = get_token()
mode = get_mode()
# Режим полигона — только если разрешено и переменная задана if mode == "polygon":
if use_polygon:
polygon = current_app.config.get("POLYGON_ENDPOINT", "") polygon = current_app.config.get("POLYGON_ENDPOINT", "")
if polygon: stand = get_polygon_stand()
return HttpClient(polygon, token) # POLYGON_ENDPOINT = "https://polygon.../api/v1/svc"
# → "https://polygon.../{stand}/api/v1/svc"
# rstrip на случай trailing slash в конфиге
return HttpClient(f"{polygon.rstrip('/')}/{stand}", token)
# Реальный API: автоопределение или NUBES_API_ENDPOINT # Облако: NUBES_API_ENDPOINT с автоопределением
endpoint = current_app.config["NUBES_API_ENDPOINT"] endpoint = current_app.config["NUBES_API_ENDPOINT"]
if endpoint in STANDS: if endpoint in STANDS:
endpoint = detect_endpoint(token) or endpoint endpoint = detect_endpoint(token) or endpoint
return HttpClient(endpoint, token) return HttpClient(endpoint, token)
def get_client():
"""HttpClient для ОПЕРАЦИЙ (create/modify/delete/run/poll).
Если POLYGON_ENDPOINT задан → polygon.
Иначе → автоопределение реального стенда.
"""
return _make_client(use_polygon=True)
def get_real_client(): def get_real_client():
"""HttpClient для ЧТЕНИЯ (сервисы, список инстансов). """Алиас get_client() — историческая совместимость.
ВСЕГДА реальный API, даже при POLYGON_ENDPOINT. Раньше get_real_client() всегда ходил в реальный API (для сервисов).
""" Теперь в эмуляции сервисы тоже из полигона → обе функции идентичны.
return _make_client(use_polygon=False) Оставлено чтобы не ломать все места вызова."""
return get_client()
def get_client_id(): def get_client_id():
@@ -100,12 +117,13 @@ def get_client_id():
def get_stand(): def get_stand():
"""Определить стенд (dev/test/mock) по активному токену. """Определить стенд.
Если POLYGON_ENDPOINT задан → "polygon". Эмуляция: "polygon_{dev|test|prod}" — для load_service_ids и трекера.
Иначе — автоопределение или stand_name.""" Облако: автоопределение или stand_name (dev/test/mock)."""
if current_app.config.get("POLYGON_ENDPOINT", ""): mode = get_mode()
return "polygon" if mode == "polygon":
return f"polygon_{get_polygon_stand()}"
token = get_token() token = get_token()
endpoint = current_app.config["NUBES_API_ENDPOINT"] endpoint = current_app.config["NUBES_API_ENDPOINT"]
+1 -1
View File
@@ -32,7 +32,7 @@ from routes.api_scenario_defs import bp_defs as api_scenario_defs_bp
# Версия — показывается в топбаре UI. Меняется при КАЖДОМ изменении кода. # Версия — показывается в топбаре UI. Меняется при КАЖДОМ изменении кода.
# Нужна для фильтрации истории (пользователь видит только записи своей версии). # Нужна для фильтрации истории (пользователь видит только записи своей версии).
VERSION = "1.2.29" VERSION = "1.2.30"
# Flask-приложение с Jinja2-шаблонами из папки templates/ # Flask-приложение с Jinja2-шаблонами из папки templates/
app = Flask(__name__, template_folder="templates", static_folder="static") app = Flask(__name__, template_folder="templates", static_folder="static")
+4 -1
View File
@@ -33,10 +33,13 @@ def load_service_ids(stand="test"):
"""Вернуть set разрешённых service_id для указанного стенда. """Вернуть set разрешённых service_id для указанного стенда.
Args: Args:
stand: str — "dev" или "test". stand: str — "dev", "test", или "polygon_test" (с префиксом эмуляции).
Returns: Returns:
set[int] — множество service_id. Пустой set если файла нет → показываем все.""" set[int] — множество service_id. Пустой set если файла нет → показываем все."""
# Обрезать префикс "polygon_" если есть → "polygon_test" → "test"
if stand.startswith("polygon_"):
stand = stand[len("polygon_"):]
path = os.path.join(_CONFIG_DIR, f"services_{stand}.txt") path = os.path.join(_CONFIG_DIR, f"services_{stand}.txt")
ids = set() ids = set()
try: try:
+1 -1
View File
@@ -103,7 +103,7 @@ def _unique_display_name(client, requested_name):
@bp.route("/api/services") @bp.route("/api/services")
def api_services(): def api_services():
try: try:
raw = get_services(get_real_client()) raw = get_services(get_client())
svc_list = [{"svcId": s["svcId"], "svc": s["svc"], "svcExtendedName": s.get("svcExtendedName", "")} for s in raw] svc_list = [{"svcId": s["svcId"], "svc": s["svc"], "svcExtendedName": s.get("svcExtendedName", "")} for s in raw]
svc_list.sort(key=lambda s: s["svcId"]) svc_list.sort(key=lambda s: s["svcId"])
return jsonify(svc_list) return jsonify(svc_list)
+66 -55
View File
@@ -11,7 +11,7 @@ GET /api/operations/<svc_id> — операции и autotest-инста
from flask import Blueprint, current_app, render_template, request, make_response, jsonify, redirect 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.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 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_instances import get_organization, get_instances
from operations.get_services import get_services, get_service_detail from operations.get_services import get_services, get_service_detail
from operations.service_list import load_service_ids from operations.service_list import load_service_ids
@@ -44,12 +44,17 @@ def index():
"""Главная страница: организация, инфраструктура, сервисы, форма токена. """Главная страница: организация, инфраструктура, сервисы, форма токена.
GET — рендерит страницу с данными из API. GET — рендерит страницу с данными из API.
POST — обрабатывает форму токена (action=save/clear).""" 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 — из переменной окружения, user — из cookie или формы
env_token = current_app.config["NUBES_API_TOKEN"] env_token = current_app.config["NUBES_API_TOKEN"]
user_token = request.cookies.get("token") or request.form.get("token") or "" 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 org = None
error = None error = None
@@ -72,10 +77,13 @@ def index():
"client_id": get_client_id(), "client_id": get_client_id(),
"token_info": get_token_info(), "token_info": get_token_info(),
"config": config, "config": config,
"stand": stand, "stand": stand,
"services": services, "services": services,
"instances": instances, "instances": instances,
"instance_groups": instance_groups, "instance_groups": instance_groups,
"mode": mode,
"polygon_stand": polygon_stand,
"polygon_enabled": polygon_enabled,
} }
ctx.update(overrides) ctx.update(overrides)
return render_template("index.html", **ctx) return render_template("index.html", **ctx)
@@ -91,63 +99,69 @@ def index():
user_token = request.form.get("token", "") user_token = request.form.get("token", "")
active_token = user_token or env_token active_token = user_token or env_token
resp = make_response() resp = make_response()
resp.set_cookie("token", user_token, max_age=60*60*24*365, httponly=True, samesite="Strict", secure=True) # 1 год resp.set_cookie("token", user_token, max_age=60*60*24*365, httponly=True, samesite="Strict", secure=True)
resp.headers["Location"] = "/" # редирект на GET (убирает POST из истории) resp.headers["Location"] = "/"
resp.status_code = 302 resp.status_code = 302
return resp return resp
# Загрузка данных из API (только если есть токен) # Обработка action=set_mode: сохранить режим и стенд в cookie
if action == "set_mode":
new_mode = request.form.get("mode", "polygon")
new_stand = request.form.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 = [] services = []
instances = [] instances = []
instance_groups = {} instance_groups = {}
config = {} config = {}
config["VERSION"] = current_app.config["VERSION"] # всегда, даже без токена config["VERSION"] = current_app.config["VERSION"]
config["service_ids"] = [] config["service_ids"] = []
stand = "?" stand = "?"
if active_token: if mode == "polygon" or active_token:
# Сервисы — ВСЕГДА из реального API (метаданные) client = get_client()
real_client = get_real_client() inst_client = client # единый клиент — и сервисы, и инстансы
# Инстансы — из polygon если POLYGON_ENDPOINT задан
inst_client = get_client()
endpoint = current_app.config["NUBES_API_ENDPOINT"]
stand = get_stand() stand = get_stand()
if real_client: try:
try: # Организация — инстанс с serviceId=19
# Организация — инстанс с serviceId=19 (из реального API) org = get_organization(client)
org = get_organization(real_client)
# Все сервисы — из реального API # Все сервисы — из полигона (эмуляция) или реального API (облако)
raw_svc = get_services(real_client) raw_svc = get_services(client)
services = sorted(raw_svc, key=lambda s: (s.get("svcId", 0), s.get("svc", ""))) services = sorted(raw_svc, key=lambda s: (s.get("svcId", 0), s.get("svc", "")))
# Инфраструктурные инстансы — из polygon или реального API # Инфраструктурные инстансы
# 19=Org, 21=vDC, 22=NSX-T, 25=External IP, 26=vApp, 29=vDC Group # 19=Org, 21=vDC, 22=NSX-T, 25=External IP, 26=vApp, 29=vDC Group
# 2=Template, 12=S3, 110=?, 150=K8s # 2=Template, 12=S3, 110=?, 150=K8s
infra_ids = {2, 12, 21, 22, 25, 26, 29, 110, 150} infra_ids = {2, 12, 21, 22, 25, 26, 29, 110, 150}
raw_inst = get_instances(inst_client) raw_inst = get_instances(inst_client)
instances = [i for i in raw_inst instances = [i for i in raw_inst
if i.get("explainedStatus") not in ("deleted", "not created") if i.get("explainedStatus") not in ("deleted", "not created")
and i.get("serviceId") in infra_ids] 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", "")))
instances.sort(key=lambda i: (0 if i.get("serviceId") == 19 else 1, i.get("displayName", "")))
# Группировка по типу сервиса для левой колонки UI # Группировка по типу сервиса для левой колонки UI
instance_groups = {} instance_groups = {}
for i in instances: for i in instances:
svc_name = i.get("svc", "Прочее") svc_name = i.get("svc", "Прочее")
instance_groups.setdefault(svc_name, []).append(i) instance_groups.setdefault(svc_name, []).append(i)
# Конфиг из config.yaml + версия приложения # Конфиг из config.yaml + версия приложения
config = dict(load_config() or {}) config = dict(load_config() or {})
config["VERSION"] = current_app.config["VERSION"] config["VERSION"] = current_app.config["VERSION"]
# Список разрешённых сервисов из services_{stand}.txt # Список разрешённых сервисов из services_{stand}.txt
config["service_ids"] = sorted(load_service_ids(stand)) config["service_ids"] = sorted(load_service_ids(stand))
except Exception as e: except Exception as e:
error = str(e) error = str(e)
else: else:
error = "Токен невалиден или просрочен" error = "Токен невалиден или просрочен"
# После POST (save/clear) — редирект на GET, чтобы F5 не переотправлял форму # После POST (save/clear/set_mode) — редирект на GET
if request.method == "POST": if request.method == "POST":
return redirect("/") return redirect("/")
return _tmpl() return _tmpl()
@@ -170,22 +184,19 @@ def api_operations(svc_id):
active_token = user_token or env_token active_token = user_token or env_token
try: try:
# Сервисы — ВСЕГДА из реального API (метаданные) # Единый клиент — и сервисы, и инстансы (полигон или реальный API)
real_client = get_real_client() client = get_client()
# Инстансы — из polygon если POLYGON_ENDPOINT задан
inst_client = get_client()
endpoint = current_app.config["NUBES_API_ENDPOINT"]
# Детали сервиса: список операций (modify, delete, suspend, ...) # Детали сервиса: список операций (modify, delete, suspend, ...)
detail = get_service_detail(real_client, svc_id) detail = get_service_detail(client, svc_id)
ops = detail.get("operations", []) ops = detail.get("operations", [])
# Трекер: наши autotest-инстансы (изолирован по пользователю и стенду) # Трекер: наши autotest-инстансы (изолирован по пользователю и стенду)
tracked = tracker_list(get_client_id(), stand_name(endpoint)) tracked = tracker_list(get_client_id(), get_stand())
tracked_by_uid = {t["instanceUid"]: t for t in tracked if t["svcId"] == svc_id} tracked_by_uid = {t["instanceUid"]: t for t in tracked if t["svcId"] == svc_id}
# Все инстансы — из polygon или реального API # Все инстансы — из полигона или реального API
instances = get_instances(inst_client) instances = get_instances(client)
nubes_uids = {i["instanceUid"] for i in instances} nubes_uids = {i["instanceUid"] for i in instances}
svc_instances = [] svc_instances = []
+19 -3
View File
@@ -59,11 +59,27 @@
<button class="sidebar-item" data-view="logs-view" onclick="switchView('logs-view')">📜 Логи</button> <button class="sidebar-item" data-view="logs-view" onclick="switchView('logs-view')">📜 Логи</button>
</div> </div>
<div class="sidebar-bottom"> <div class="sidebar-bottom">
<div style="padding:4px 12px;font-size:10px;color:var(--muted);">v{{ config.VERSION }} | {{ stand.upper() }}</div> {% if polygon_enabled %}
<form method="post" style="padding:4px 12px 2px;">
<input type="hidden" name="action" value="set_mode" />
<select name="mode" onchange="this.form.submit()" style="width:100%;height:26px;font-size:11px;padding:0 4px;border:1px solid var(--brand-gray);border-radius:3px;background:#fff;margin-bottom:3px;">
<option value="polygon" {% if mode == 'polygon' %}selected{% endif %}>🎭 Эмуляция</option>
<option value="cloud" {% if mode == 'cloud' %}selected{% endif %}>☁️ Облако</option>
</select>
{% if mode == 'polygon' %}
<select name="polygon_stand" onchange="this.form.submit()" style="width:100%;height:26px;font-size:11px;padding:0 4px;border:1px solid var(--brand-gray);border-radius:3px;background:#fff;">
<option value="dev" {% if polygon_stand == 'dev' %}selected{% endif %}>DEV</option>
<option value="test" {% if polygon_stand == 'test' %}selected{% endif %}>TEST</option>
<option value="prod" {% if polygon_stand == 'prod' %}selected{% endif %}>PROD</option>
</select>
{% endif %}
</form>
{% endif %}
<div style="padding:4px 12px;font-size:10px;color:var(--muted);">v{{ config.VERSION }} | {{ stand.upper().replace('_', ' ') }}</div>
{% if token_info.email %}<div style="padding:0 12px 4px;font-size:10px;color:var(--muted);">{{ token_info.email }}</div>{% endif %} {% if token_info.email %}<div style="padding:0 12px 4px;font-size:10px;color:var(--muted);">{{ token_info.email }}</div>{% endif %}
<form method="post" class="token-row"> <form method="post" class="token-row">
<input type="password" name="token" placeholder="env: {{ env_token_masked }}" value="{{ '' if not has_user_token else '••••••••' }}" /> <input type="password" name="token" placeholder="env: {{ env_token_masked }}" value="{{ '' if not has_user_token else '••••••••' }}" {% if mode == 'polygon' %}disabled style="opacity:0.4;"{% endif %} />
<button type="submit" name="action" value="save" class="btn btn-sm">OK</button> <button type="submit" name="action" value="save" class="btn btn-sm" {% if mode == 'polygon' %}disabled{% endif %}>OK</button>
{% if has_user_token %}<button type="submit" name="action" value="clear" class="btn btn-sm" style="color:var(--destructive);"></button>{% endif %} {% if has_user_token %}<button type="submit" name="action" value="clear" class="btn btn-sm" style="color:var(--destructive);"></button>{% endif %}
</form> </form>
{% if error %}<div style="padding:4px 12px;font-size:10px;color:var(--destructive);">{{ error }}</div>{% endif %} {% if error %}<div style="padding:4px 12px;font-size:10px;color:var(--destructive);">{{ error }}</div>{% endif %}