v1.0.89: detailed code comments — all Python files + index.html architecture overview
This commit is contained in:
+52
-4
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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-экранирование (" → " & → & < → <)
|
||||
* validateJson() — проверка JSON.parse для map-полей (onblur + перед отправкой)
|
||||
* showStages() — отрисовка этапов выполнения операции (⏳/✅/❌)
|
||||
*/
|
||||
let svcInstances=[], selectedInst=null, selectedOp=null, pollTimer=null;
|
||||
const SVC_ID=1;
|
||||
const AUTOTEST_PREFIX='autotest-';
|
||||
|
||||
Reference in New Issue
Block a user