From c50aaf59d88e32044955abdc57a01bad84393788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Fri, 31 Jul 2026 17:25:44 +0400 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D1=80=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=B8=20=D0=BA=D0=BE=20=D0=B2=D1=81=D0=B5=D0=BC=20?= =?UTF-8?q?JS-=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=D0=BC=20=D1=84=D1=80=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D0=B5=D0=BD=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/app.py | 48 ++++-- site/db/scenario_defs.py | 88 ++++++++-- site/runner.py | 77 +++++++-- site/static/app.js | 37 +++-- site/static/js/history.js | 58 ++++++- site/static/js/icons.js | 14 +- site/static/js/instances.js | 146 +++++++++++++---- site/static/js/operations.js | 236 +++++++++++++++++++++++---- site/static/js/params-render.js | 86 +++++++++- site/static/js/scenario-create.js | 24 ++- site/static/js/scenario-delete.js | 12 +- site/static/js/scenario-edit.js | 11 ++ site/static/js/scenario-form.js | 263 ++++++++++++++++++++++++++---- site/static/js/scenario-list.js | 114 +++++++++++-- site/static/js/snackbar.js | 33 +++- site/static/js/utils.js | 65 +++++++- site/static/js/views.js | 39 ++++- 17 files changed, 1155 insertions(+), 196 deletions(-) diff --git a/site/app.py b/site/app.py index 590c266..1ceb986 100644 --- a/site/app.py +++ b/site/app.py @@ -1,12 +1,24 @@ """ 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 (основная логика) +Это приложение позволяет: + 1. Тестировать операции с сервисами Nubes (create/modify/delete/...) через UI + 2. Создавать и запускать сценарии (последовательности операций) + 3. Просматривать историю запусков и логи -Деплой: Nubes pythonk8s (gunicorn, несколько воркеров). +Blueprint'ы (каждый — отдельный файл в routes/): + - main_bp → / (главная: токен, инфраструктура, сервисы) + - api_test_bp → /api/test, /api/params, /api/log (ручной режим) + - api_scenario_run_bp → /api/scenario/run, /api/scenario/status (запуск сценариев) + - api_scenario_defs_bp → /api/scenario/definitions (CRUD сценариев) + +Деплой: Nubes pythonk8s (managed service) с gunicorn: + gunicorn app:app --workers 2 --bind 0.0.0.0:8000 + +Конфигурация: + NUBES_API_ENDPOINT — URL API (по умолчанию test) + NUBES_API_TOKEN — сервисный токен (из переменных окружения k8s) + VERSION — версия приложения (меняется при КАЖДОМ изменении кода) """ import os @@ -18,23 +30,37 @@ from routes.api_test import bp as api_test_bp from routes.api_scenario_run import bp_run as api_scenario_run_bp from routes.api_scenario_defs import bp_defs as api_scenario_defs_bp -# Версия — меняется при КАЖДОМ изменении кода. Показывается в топбаре UI. +# Версия — показывается в топбаре UI. Меняется при КАЖДОМ изменении кода. +# Нужна для фильтрации истории (пользователь видит только записи своей версии). VERSION = "1.2.16" +# Flask-приложение с Jinja2-шаблонами из папки templates/ app = Flask(__name__, template_folder="templates", static_folder="static") -app.config["NUBES_API_ENDPOINT"] = os.getenv("NUBES_API_ENDPOINT", "https://lk-api-gateway-test.ngcloud.ru/api/v1/svc") + +# Конфигурация из переменных окружения (задаются в Nubes UI при создании сервиса) +app.config["NUBES_API_ENDPOINT"] = os.getenv( + "NUBES_API_ENDPOINT", + "https://lk-api-gateway-test.ngcloud.ru/api/v1/svc" +) app.config["NUBES_API_TOKEN"] = os.getenv("NUBES_API_TOKEN", "") app.config["VERSION"] = VERSION -app.register_blueprint(main_bp) -app.register_blueprint(api_test_bp) -app.register_blueprint(api_scenario_run_bp) -app.register_blueprint(api_scenario_defs_bp) + +# Регистрируем blueprint'ы — каждый отвечает за свою группу маршрутов +app.register_blueprint(main_bp) # / +app.register_blueprint(api_test_bp) # /api/test, /api/params, ... +app.register_blueprint(api_scenario_run_bp) # /api/scenario/run, ... +app.register_blueprint(api_scenario_defs_bp) # /api/scenario/definitions, ... @app.route("/health") def health(): + """Healthcheck — всегда возвращает 200 OK. + + Используется Nubes для проверки живости сервиса. + Если не ответит — сервис будет перезапущен.""" return "OK" +# Локальный запуск (без gunicorn) — только для разработки if __name__ == "__main__": app.run(debug=True, host="0.0.0.0", port=5000) diff --git a/site/db/scenario_defs.py b/site/db/scenario_defs.py index dfe06f6..ec748ab 100644 --- a/site/db/scenario_defs.py +++ b/site/db/scenario_defs.py @@ -1,13 +1,28 @@ """ -CRUD для таблицы scenario_definitions. +CRUD для таблицы scenario_definitions — определения сценариев. + +Сценарии редактируются через UI (модальный редактор), хранятся в БД. +Это позволяет менять сценарии БЕЗ редеплоя приложения. + +ОПТИМИСТИЧНАЯ БЛОКИРОВКА (version): + update_definition проверяет version — если другой пользователь уже изменил + сценарий, возвращает conflict=true → UI показывает "Конфликт версий". + +МЯГКОЕ УДАЛЕНИЕ: + delete_definition ставит is_active=FALSE — не удаляет физически. + Это позволяет восстановить случайно удалённый сценарий. + +SEED-СЦЕНАРИИ: + Сценарии с client_id="" и stand="" — seed (дефолтные, из YAML). + Они доступны всем пользователям но не могут быть удалены через UI. Функции: - list_definitions(client_id, stand) — список активных - get_definition(def_id, cid, stand) — один - create_definition(cid, stand, name, steps, updated_by) → id - update_definition(def_id, cid, stand, name, steps, version, updated_by) → ok|conflict - delete_definition(def_id, cid, stand) — мягкое (is_active=false) - lock_check(cid, stand) — есть ли RUNNING запуск → 409 если да + list_definitions(client_id, stand) — список активных (+seed) + get_definition(def_id, cid, stand) — один (по id) + create_definition(...) → {id, version} + update_definition(...) → {ok, version} | {conflict: true} + delete_definition(...) → bool + lock_check(cid, stand) — есть ли RUNNING → False """ import json @@ -15,11 +30,20 @@ from db.pool import get_conn, put_conn def list_definitions(client_id, stand): + """Список ВСЕХ активных определений: пользовательские + seed. + + Seed-сценарии (client_id="" AND stand="") видны всем. + Пользовательские — только для текущего пользователя и стенда. + + Returns: + list[dict] с ключами: id, name, steps, version, is_active, + created_at, updated_at, client_id, stand, is_seed (bool).""" conn = get_conn() if not conn: return [] try: cur = conn.cursor() + # Два условия через OR: пользовательские + seed cur.execute(""" SELECT id, name, steps, version, is_active, created_at, updated_at, client_id, stand @@ -33,8 +57,10 @@ def list_definitions(client_id, stand): result = [] for r in rows: d = dict(zip(cols, r)) + # Конвертируем datetime → ISO-строка (JSON-совместимо) d["created_at"] = d["created_at"].isoformat() if d["created_at"] else None d["updated_at"] = d["updated_at"].isoformat() if d["updated_at"] else None + # is_seed: client_id пустой → seed (из YAML) d["is_seed"] = (d.get("client_id") == "" and d.get("stand") == "") result.append(d) return result @@ -46,6 +72,12 @@ def list_definitions(client_id, stand): def get_definition(def_id, client_id, stand): + """Получить ОДНО определение по id. + + Доступ: пользовательские (client_id+stand) ИЛИ seed (пустые). + + Returns: + dict или None если не найдено.""" conn = get_conn() if not conn: return None @@ -72,6 +104,16 @@ def get_definition(def_id, client_id, stand): def create_definition(client_id, stand, name, steps, updated_by=""): + """Создать ИЛИ перезаписать определение (upsert). + + ON CONFLICT (client_id, stand, LOWER(name)): + Если сценарий с таким именем уже существует → UPDATE (перезапись steps, version+1). + Если нет → INSERT. + + Это позволяет «сохранить» без проверки существования. + + Returns: + {id: int, version: int} или None при ошибке.""" conn = get_conn() if not conn: return None @@ -98,7 +140,15 @@ def create_definition(client_id, stand, name, steps, updated_by=""): def update_definition(def_id, client_id, stand, name, steps, version, updated_by=""): - """Возвращает dict {ok, version} или {conflict: True}.""" + """Обновить определение с ОПТИМИСТИЧНОЙ БЛОКИРОВКОЙ. + + WHERE version = %s — если версия изменилась (другой пользователь/вкладка), + UPDATE затронет 0 строк → возвращаем {conflict: true}. + + Returns: + {ok: True, version: int} — успех + {conflict: True} — версия устарела (409 Conflict) + {error: str} — ошибка БД""" conn = get_conn() if not conn: return {"error": "no db"} @@ -116,6 +166,7 @@ def update_definition(def_id, client_id, stand, name, steps, version, updated_by cur.close() if row: return {"ok": True, "version": row[0]} + # 0 строк — версия не совпала (кто-то уже изменил) return {"conflict": True} except Exception as e: print(f"[DEFS] update error: {e}", flush=True) @@ -126,7 +177,13 @@ def update_definition(def_id, client_id, stand, name, steps, version, updated_by def delete_definition(def_id, client_id, stand): - """Мягкое удаление.""" + """Мягкое удаление: is_active = FALSE. + + Только для пользовательских сценариев (client_id не пустой). + Seed-сценарии не могут быть удалены через этот метод. + + Returns: + bool — True если удалён, False если не найден.""" conn = get_conn() if not conn: return False @@ -150,10 +207,17 @@ def delete_definition(def_id, client_id, stand): def lock_check(client_id, stand): - """Проверить нет ли активного RUNNING запуска. True = можно запускать.""" + """Проверить что нет активного RUNNING-запуска сценария. + + Используется перед запуском нового сценария — предотвращает + одновременный запуск двух сценариев одним пользователем. + + Returns: + True — можно запускать (нет RUNNING) + False — нельзя (уже есть RUNNING) → 409 Conflict""" conn = get_conn() if not conn: - return True # без БД — разрешаем + return True # без БД — разрешаем (fallback) try: cur = conn.cursor() cur.execute(""" @@ -163,7 +227,7 @@ def lock_check(client_id, stand): """, (client_id, stand)) row = cur.fetchone() cur.close() - return row is None + return row is None # None = нет RUNNING = можно запускать except Exception as e: print(f"[DEFS] lock_check error: {e}", flush=True) return True diff --git a/site/runner.py b/site/runner.py index 396fbcf..3c7483c 100644 --- a/site/runner.py +++ b/site/runner.py @@ -1,3 +1,12 @@ +""" +LEGACY-раннер — автоматический прогон тестов по конфигурации из config.yaml. + +Этот модуль больше НЕ используется в основном потоке приложения. +Оставлен для обратной совместимости: эндпоинты /api/run, /api/status, /api/config. + +Сейчас основной режим — ручной (через UI) и сценарный (через scenario.py). +""" + import threading import time import yaml @@ -5,34 +14,52 @@ import os from api.http_client import HttpClient - +# Путь к YAML-конфигу тестов CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml") +# _current_run — глобальное состояние последнего запуска. +# Защищено _lock для потокобезопасности. _current_run = None _lock = threading.Lock() def load_config(): + """Загрузить YAML-конфиг тестов. Используется в /api/config (GET).""" with open(CONFIG_PATH) as f: return yaml.safe_load(f) def save_config(data): + """Сохранить YAML-конфиг. Используется в /api/config (POST).""" with open(CONFIG_PATH, "w") as f: yaml.dump(data, f, allow_unicode=True, default_flow_style=False) def get_status(): + """Текущий статус последнего запуска тестов → /api/status.""" return _current_run def _wait_for_ready(client, instance_uid, timeout=300): + """Ждать пока инстанс перейдёт в стабильное состояние (не creating/modifying/deleting). + + Поллинг GET /instances с фильтрацией по instanceUid. + Таймаут 5 минут — после этого возвращаем None. + + Args: + client: HttpClient + instance_uid: str — UUID инстанса + timeout: int — макс. секунд ожидания + + Returns: + dict — инстанс, или None если таймаут.""" deadline = time.time() + timeout while time.time() < deadline: data = client.get("/instances", params={"pageSize": 500}) for inst in data.get("results", []): if inst.get("instanceUid") == instance_uid: status = inst.get("explainedStatus", "") + # Проверяем что операция не в процессе in_progress = inst.get("operationIsInProgress", False) pending = inst.get("operationIsPending", False) if not in_progress and not pending and status not in ("creating", "modifying", "deleting"): @@ -42,6 +69,17 @@ def _wait_for_ready(client, instance_uid, timeout=300): def _find_svc_operation_id(client, service_id, operation_name): + """Найти svcOperationId для операции в сервисе. + + GET /services/{id} → svc.operations[] → ищем по operation.name. + + Args: + client: HttpClient + service_id: int + operation_name: str — "create"/"modify"/"delete"/... + + Returns: + int или None — svcOperationId.""" data = client.get(f"/services/{service_id}") for op in data.get("svc", {}).get("operations", []): if op.get("operation") == operation_name: @@ -50,10 +88,26 @@ def _find_svc_operation_id(client, service_id, operation_name): def run_tests(endpoint, token): + """Главная функция LEGACY-раннера — последовательный прогон тестов. + + Алгоритм: + 1. Загрузить config.yaml + 2. Для каждого сервиса (если enabled): + a. Для каждой операции (если enabled): + - create → POST /instances → ждать ready + - другие → POST /instanceOperations → ждать ready + b. Сохранить результат (OK/FAIL/SKIP) + + Вызывается в отдельном потоке из /api/run. + + Args: + endpoint: str — URL API + token: str — JWT-токен""" global _current_run client = HttpClient(endpoint, token) config = load_config() + # Получить UUID организации (если не задан явно) org_uid = config.get("test_org_uid", "auto") if org_uid == "auto": from operations.get_instances import get_organization @@ -76,7 +130,7 @@ def run_tests(endpoint, token): service_id = svc_cfg["service_id"] display_name = svc_cfg.get("display_name", f"autotest-{svc_cfg['name']}") - instance_uid = None + instance_uid = None # сбрасываем для каждого сервиса for op_cfg in svc_cfg.get("operations", []): if not op_cfg.get("enabled"): @@ -111,10 +165,7 @@ def run_tests(endpoint, token): raise Exception(f"Operation {op_cfg['name']} not found") if op_cfg["name"] == "create": - payload = { - "serviceId": service_id, - "displayName": display_name, - } + payload = {"serviceId": service_id, "displayName": display_name} params = op_cfg.get("params", {}) if params: cfs_params = [{"svcOperationCfsParamId": int(k), "paramValue": str(v)} for k, v in params.items()] @@ -129,10 +180,7 @@ def run_tests(endpoint, token): else: if not instance_uid: raise Exception(f"No instance for {op_cfg['name']}") - payload = { - "instanceUid": instance_uid, - "svcOperationId": svc_op_id, - } + payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id} params = op_cfg.get("params", {}) if params: payload["cfsParams"] = [{"svcOperationCfsParamId": int(k), "paramValue": str(v)} for k, v in params.items()] @@ -147,12 +195,11 @@ def run_tests(endpoint, token): result["status"] = "FAIL" result["error"] = str(e) if op_cfg["name"] == "create": - break # дальше операции этого сервиса бессмысленны + break # create не удался → остальные операции сервиса бессмысленны finally: result["duration_sec"] = round(time.time() - t0, 1) - # wait between services - time.sleep(1) + time.sleep(1) # пауза между сервисами with _lock: _current_run["status"] = "done" @@ -160,6 +207,10 @@ def run_tests(endpoint, token): def _extract_uid_from_response(resp): + """Извлечь UUID из ответа API (legacy-метод, заменён на api.utils.find_uid). + + Простой перебор всех значений dict — если строка из 36 символов с 4 дефисами, + считаем UUID'ом.""" if isinstance(resp, dict): for v in resp.values(): if isinstance(v, str) and len(v) == 36 and v.count("-") == 4: diff --git a/site/static/app.js b/site/static/app.js index 81eeb66..272bb8f 100644 --- a/site/static/app.js +++ b/site/static/app.js @@ -1,17 +1,34 @@ -// app.js — загрузчик модулей фронтенда -// Файлы подгружаются в index.html в порядке зависимостей: +// app.js — загрузчик и точка входа фронтенда +// +// Файлы подгружаются в index.html в СТРОГОМ порядке зависимостей: // utils.js → instances.js → operations.js → history.js → scenario-list.js -// window.APP — переменные из Jinja2: { version, stand, hasUserToken, firstServiceId } +// scenario-form.js → params-render.js → scenario-create/edit/delete.js +// icons.js, snackbar.js, views.js — без зависимостей +// +// Глобальные переменные из Jinja2 (window.APP): +// { version, stand, hasUserToken, firstServiceId } /* - * Архитектура: ванильный JS, модули по файлам: - * utils.js — _esc(), validateJson(), busy, лог-панель - * instances.js — selectService(), toggleInstance(), refreshInstances() - * operations.js — startCreate(), runOp(), showParams(), executeOp(), poll - * history.js — toggleHistory(), loadHistory() - * scenario-list.js — toggleScenario(), loadScenarios(), runScenario() + * Архитектура фронтенда — ванильный JS (без фреймворков). + * Модули по файлам, функции в глобальной области видимости: + * + * utils.js — _esc(), validateJson(), busy, relativeTime(), лог-панель + * instances.js — selectService(), toggleInstance(), refreshInstances() + * operations.js — startCreate(), runOp(), showParams(), executeOp(), poll + * history.js — toggleHistory(), loadHistory() + * scenario-list.js — toggleScenario(), loadScenarios(), runScenario() + * scenario-form.js — showScenarioEditor(), renderEditor(), saveScenario() + * params-render.js — renderParamRow(), renderMapFixedRow(), collectParams() + * icons.js — ICONS = {play, edit, trash, ...} (inline-SVG) + * snackbar.js — showSnackbar(msg, type) + * views.js — switchView(viewId) */ -// Инициализация +// ── Инициализация при загрузке страницы ── + +// Запускаем поллинг логов (каждые 2 секунды, только если панель видна) startLogPoll(); + +// Выбираем первый сервис из переданного Jinja2 значения +// currentSvcId берётся из window.APP.firstServiceId (задаётся в index.html) selectService(currentSvcId); diff --git a/site/static/js/history.js b/site/static/js/history.js index 4ae29fe..a0c1d80 100644 --- a/site/static/js/history.js +++ b/site/static/js/history.js @@ -1,7 +1,19 @@ -// history.js — история тестов +// history.js — история тестов (вид «История») +// +// Загружает последние 50 записей из GET /api/history. +// Каждая запись — строка таблицы: время, операция, инстанс, статус, длительность. +// При клике на строку — раскрываются этапы выполнения (stages). +// +// Цветовая маркировка: +// RUNNING → жёлтый фон (#fef3c7) +// FAIL → красный фон (#fef2f2) let historyOpen=false; +/** + * Переключить панель истории (открыть/закрыть). + * При открытии — автозагрузка через loadHistory(). + */ async function toggleHistory(){ const body=document.getElementById('history-body'); historyOpen=!historyOpen; @@ -9,19 +21,34 @@ async function toggleHistory(){ if(historyOpen) await loadHistory(); } +/** + * Загрузить и отрисовать историю. + * + * Формат ответа API: массив объектов: + * [{created_at, op_name, display_name, instance_uid, status, duration_sec, stages}, ...] + */ async function loadHistory(){ const body=document.getElementById('history-body'); if(!body||body.style.display==='none') return; try{ const r=await fetch('/api/history'); const rows=await r.json(); - if(!rows||!rows.length){body.innerHTML='Нет записей'; return;} + if(!rows||!rows.length){ + body.innerHTML='Нет записей'; + return; + } + + // Таблица с фиксированной шириной колонок let html=''; html+=''; + rows.forEach((r, i)=>{ const time=r.created_at?r.created_at.replace('T',' ').substring(0,19):'?'; - const cls=r.status==='OK'?'badge-success':r.status==='FAIL'?'badge' :''; + const cls=r.status==='OK'?'badge-success':r.status==='FAIL'?'badge':''; + // Цвет фона строки: RUNNING → жёлтый, FAIL → красный const stCls=r.status==='RUNNING'?'background:#fef3c7;':r.status==='FAIL'?'background:#fef2f2;':''; + + // Строка таблицы — кликабельна (раскрывает этапы) html+=` @@ -29,24 +56,43 @@ async function loadHistory(){ `; + + // Скрытая строка с этапами (раскрывается по клику) html+=``; }); html+='
ВремяОперацияИнстансСтатусДлит.
${time} ${_esc(r.op_name||'?')}${_esc(r.status||'?')} ${r.duration_sec!=null?r.duration_sec.toFixed(1)+'s':'-'}
'; body.innerHTML=html; - }catch(e){body.innerHTML='Ошибка загрузки';} + }catch(e){ + body.innerHTML='Ошибка загрузки'; + } } +/** + * Раскрыть/скрыть строку с этапами для записи истории. + * + * @param {number} i — индекс записи в массиве rows + */ function toggleHistoryRow(i) { const el = document.getElementById('hist-stages-' + i); if (!el) return; el.style.display = el.style.display === 'none' ? 'table-row' : 'none'; } +/** + * Отрисовать этапы выполнения для записи истории. + * + * Каждый этап: иконка (✅/❌/⏳) + название + время + длительность. + * + * @param {Array} stages — [{stage, dtStart, dtFinish, isSuccessful, duration}, ...] + * @returns {string} HTML + */ function renderStages(stages) { - if (!stages || !stages.length) return 'Нет данных об этапах'; + if (!stages || !stages.length) + return 'Нет данных об этапах'; + let html = '
Этапы выполнения
'; stages.forEach(s => { - const done = !!s.dtFinish; + const done = !!s.dtFinish; // этап завершён? const icon = done ? (s.isSuccessful ? '✅' : '❌') : '⏳'; const start = s.dtStart ? s.dtStart.replace('T',' ').substring(0,19) : '?'; const finish = s.dtFinish ? s.dtFinish.replace('T',' ').substring(0,19) : '...'; diff --git a/site/static/js/icons.js b/site/static/js/icons.js index 314accf..c71c0a8 100644 --- a/site/static/js/icons.js +++ b/site/static/js/icons.js @@ -1,6 +1,14 @@ -// icons.js — 10 inline-SVG иконок (Lucide, MIT) -// stroke="currentColor" — цвет берётся из CSS -// Размер: 20x20, stroke-width: 2 +// icons.js — 10 inline-SVG иконок (Lucide, MIT-лицензия) +// +// Все иконки используют stroke="currentColor" — цвет берётся из CSS (color). +// Размер: 20x20px, stroke-width: 2 (тонкие линии). +// +// Почему inline-SVG а не или иконочный шрифт: +// - Не требует внешних зависимостей (никаких CDN) +// - Цвет controlled через CSS (currentColor) +// - Не грузит лишних файлов (иконки вшиты в JS) +// +// Использование: ICONS.play, ICONS.edit, ICONS.trash, ... const ICONS = { play: '', diff --git a/site/static/js/instances.js b/site/static/js/instances.js index 7752272..e6d3d38 100644 --- a/site/static/js/instances.js +++ b/site/static/js/instances.js @@ -1,4 +1,20 @@ // instances.js — список инстансов, выбор сервиса, toggle операций +// +// Отвечает за: +// - Загрузку инстансов выбранного сервиса (GET /api/operations/{svcId}) +// - Отображение списка инстансов с фильтром autotest- +// - Показ доступных операций для выбранного инстанса +// - Кнопки операций (modify, delete, suspend, resume, redeploy) +// +// Глобальные переменные (используются в operations.js и других модулях): +// svcInstances[] — текущий список инстансов +// selectedInst — UUID выбранного инстанса (или null) +// selectedOp — {opId, opName, svcId} текущая выбранная операция +// currentSvcId — ID текущего сервиса +// currentSvcName — имя текущего сервиса +// currentSvcShort — короткое имя (для генерации displayName) +// busy — флаг блокировки UI (пока идёт операция) +// AUTOTEST_PREFIX — "autotest-" (фильтр "наших" инстансов) let svcInstances=[], selectedInst=null, selectedOp=null; let currentSvcId=(window.APP&&window.APP.firstServiceId)||1; @@ -7,32 +23,58 @@ let currentSvcShort=''; let busy=false; const AUTOTEST_PREFIX='autotest-'; +/** + * Выбрать сервис — загрузить его инстансы и показать в списке. + * Вызывается при клике на сервис в левой панели. + * + * @param {number} svcId — ID сервиса + */ async function selectService(svcId){ - if(busy) return; + if(busy) return; // блокировка — идёт операция + + // Сброс предыдущего состояния currentSvcId=svcId; - selectedInst=null; selectedOp=null; stopPoll(); + selectedInst=null; + selectedOp=null; + stopPoll(); // остановить поллинг предыдущей операции + + // Скрыть панель параметров и этапы document.getElementById('params-area').style.display='none'; document.querySelectorAll('.stages-box').forEach(e=>e.remove()); + + // Подсветка активного сервиса в списке document.querySelectorAll('.svc-item').forEach(e=>e.classList.remove('active')); const svcEl=document.querySelector(`.svc-item[onclick*="${svcId}"]`); if(svcEl) svcEl.classList.add('active'); + try{ - const r=await fetch('/api/operations/'+svcId); - const d=await r.json(); - svcInstances=d.instances||[]; - currentSvcName=d.svc||''; - currentSvcShort=d.svcShort||''; - const btnSpan=document.querySelector('#create-btn-area span'); - if(btnSpan) btnSpan.textContent='+ Создать новый инстанс '+(currentSvcName||'сервиса'); - renderInstances(); - }catch(e){document.getElementById('inst-list').innerHTML='Ошибка загрузки';} + // GET /api/operations/{svcId} — возвращает операции + autotest-инстансы + const r=await fetch('/api/operations/'+svcId); + const d=await r.json(); + svcInstances=d.instances||[]; + currentSvcName=d.svc||''; + currentSvcShort=d.svcShort||''; + // Обновить текст кнопки «Создать» + const btnSpan=document.querySelector('#create-btn-area span'); + if(btnSpan) btnSpan.textContent='+ Создать новый инстанс '+(currentSvcName||'сервиса'); + renderInstances(); // отрисовать список + }catch(e){ + document.getElementById('inst-list').innerHTML= + 'Ошибка загрузки'; + } } +/** + * Отрисовать список инстансов в #inst-list. + * Каждый инстанс — строка: имя + сервис + статус + created + modified. + */ function renderInstances(){ let html=''; svcInstances.forEach(i=>{ + // Статус: из нашего поля status (cloud-first) или explainedStatus const status=i.status||i.explainedStatus||'?'; const sc=status==='running'?'badge-success':''; + // Строка инстанса — кликабельна html+=`
${_esc(i.displayName)} ${_esc(i.svc||'')} @@ -40,51 +82,89 @@ function renderInstances(){ ${relativeTime(i.instanceConfigDtCreated)} ${relativeTime(i.instanceConfigDtModified)}
`; + // Контейнер для кнопок операций (изначально скрыт) html+=`
`; }); document.getElementById('inst-list').innerHTML=html; } +/** + * Обновить список инстансов (без смены сервиса). + * Вызывается после завершения операции. + */ async function refreshInstances(){ try{ - const r=await fetch('/api/operations/'+currentSvcId); - const d=await r.json(); - svcInstances=d.instances||[]; - renderInstances(); + const r=await fetch('/api/operations/'+currentSvcId); + const d=await r.json(); + svcInstances=d.instances||[]; + renderInstances(); }catch(e){} } +/** + * Раскрыть/скрыть кнопки операций для инстанса. + * Загружает доступные операции через GET /api/operations/{svcId}. + * + * @param {string} iuid — instanceUid + */ async function toggleInstance(iuid){ if(busy) return; - selectedInst=iuid; stopPoll(); - document.querySelectorAll('[data-iuid]').forEach(e=>e.classList.toggle('active',e.dataset.iuid===iuid)); + selectedInst=iuid; + stopPoll(); + + // Подсветка выбранного инстанса + document.querySelectorAll('[data-iuid]').forEach(e=> + e.classList.toggle('active',e.dataset.iuid===iuid)); + + // Скрыть панель параметров (если была открыта для другого инстанса) document.getElementById('params-area').style.display='none'; + const opsEl=document.getElementById('ops-'+iuid); + // Если уже открыт — закрыть (toggle) if(opsEl.classList.contains('open')){opsEl.classList.remove('open');return;} + + // Закрыть ВСЕ другие открытые панели операций document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open')); + + // Найти инстанс в списке (может быть из другого сервиса) const inst=svcInstances.find(x=>x.instanceUid===iuid); const svcId=inst?inst.serviceId:currentSvcId; + try{ - const r=await fetch('/api/operations/'+svcId); - const d=await r.json(); - let ops=(d.operations||[]).filter(o=>o.operation!=='reconcile'&&o.operation!=='create'); - if(inst&&(inst.status||inst.explainedStatus)==='not created'){ - ops=ops.filter(o=>o.operation==='delete'); + const r=await fetch('/api/operations/'+svcId); + const d=await r.json(); + + // Фильтруем операции: убираем reconcile (техническая) и create (уже создан) + let ops=(d.operations||[]).filter(o=>o.operation!=='reconcile'&&o.operation!=='create'); + + // Для not created инстансов — только delete + if(inst&&(inst.status||inst.explainedStatus)==='not created'){ + ops=ops.filter(o=>o.operation==='delete'); + } + + // Кнопки операций с цветовой маркировкой + opsEl.innerHTML=ops.map(o=> + `` + ).join(''); + opsEl.classList.add('open'); + }catch(e){ + opsEl.innerHTML='Ошибка загрузки'; + opsEl.classList.add('open'); } - opsEl.innerHTML=ops.map(o=> - `` - ).join(''); - opsEl.classList.add('open'); - }catch(e){opsEl.innerHTML='Ошибка загрузки';opsEl.classList.add('open');} } +/** + * CSS-класс для кнопки операции (цветовая маркировка). + * + * @param {string} opName — "modify"/"delete"/"suspend"/... + * @returns {string} CSS-класс + */ function opClass(opName){ - if(opName==='modify') return 'op-btn-modify'; - if(opName==='suspend') return 'op-btn-suspend'; - if(opName==='resume') return 'op-btn-resume'; - if(opName==='delete') return 'op-btn-delete'; - if(opName==='redeploy') return 'op-btn-redeploy'; + if(opName==='modify') return 'op-btn-modify'; // синий + if(opName==='suspend') return 'op-btn-suspend'; // оранжевый + if(opName==='resume') return 'op-btn-resume'; // зелёный + if(opName==='delete') return 'op-btn-delete'; // красный + if(opName==='redeploy') return 'op-btn-redeploy'; // фиолетовый return ''; } - diff --git a/site/static/js/operations.js b/site/static/js/operations.js index f780f83..014b5f6 100644 --- a/site/static/js/operations.js +++ b/site/static/js/operations.js @@ -1,119 +1,244 @@ -// operations.js — запуск операций: create, modify, delete, poll, stages +// operations.js — запуск операций: create, modify, delete, поллинг, этапы +// +// Это основной модуль ручного режима тестирования. +// Жизненный цикл операции: +// 1. Пользователь выбирает инстанс → жмёт кнопку операции (runOp) +// 2. Загружаются параметры с ТЕКУЩИМИ значениями (showParams) +// 3. Пользователь правит параметры → жмёт «Запустить» (executeOp) +// 4. POST /api/test → возвращает opUid → начинается поллинг (pollTimer) +// 5. Каждые 2 секунды GET /api/test/status/{opUid} → этапы + статус +// 6. При завершении → обновить список инстансов + историю +// +// Глобальные переменные (используются из instances.js): +// pollTimer — ID setInterval для поллинга + let pollTimer=null; +/** + * Сгенерировать короткое имя для нового инстанса. + * Формат: {svcShort}-{random3} (напр. "dummy-a3f"). + * random3 — base36 (0-9a-z), 3 символа = 46656 вариантов. + * + * @returns {string} displayName suffix (без префикса autotest-) + */ function makeCreateDisplayName(){ const rand = (Math.random() * 46656 | 0).toString(36).padStart(3, '0'); return currentSvcShort ? currentSvcShort+'-'+rand : rand; } +/** + * Найти displayName выбранного инстанса. + * + * @returns {string} displayName или instanceUid (если не найден) + */ function findInstName(){ const i=svcInstances.find(x=>x.instanceUid===selectedInst); return i?i.displayName:selectedInst; } +/** + * Остановить поллинг текущей операции. + */ function stopPoll(){ if(pollTimer){clearInterval(pollTimer);pollTimer=null;} } +/** + * Начать создание нового инстанса (кнопка «+ Создать»). + * + * Загружает операцию create для текущего сервиса через GET /api/operations/{svcId}. + * Если операция create не найдена — fallback на svcOperationId=18 (стандартный). + */ async function startCreate(){ if(busy) return; - selectedInst=null; stopPoll(); + selectedInst=null; // create — нет выбранного инстанса + stopPoll(); + // Снять выделение со всех инстансов document.querySelectorAll('[data-iuid]').forEach(e=>e.classList.remove('active')); document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open')); try{ const r=await fetch('/api/operations/'+currentSvcId); const d=await r.json(); const createOp=d.operations.find(o=>o.operation==='create'); - const opId=createOp?createOp.svcOperationId:18; + const opId=createOp?createOp.svcOperationId:18; // fallback showParams(opId,'create'); }catch(e){ - showParams(18,'create'); + showParams(18,'create'); // fallback при ошибке } } +/** + * Запустить операцию над выбранным инстансом (кнопка modify/delete/...). + * + * @param {string} opName — "modify"/"delete"/"suspend"/"resume"/"redeploy" + * @param {number} opId — svcOperationId + */ function runOp(opName,opId){ if(busy) return; stopPoll(); showParams(opId,opName); } +/** + * Показать форму параметров для операции. + * + * Алгоритм: + * 1. Загрузить параметры: GET /api/params/{opId}[?instanceUid=xxx] + * - Для create: без instanceUid → шаблонные defaults + * - Для не-create: с instanceUid → текущие значения из state.params + * 2. Отрисовать форму через renderParamRow (из params-render.js) + * 3. Показать кнопку «Запустить» + * + * @param {number} opId — svcOperationId + * @param {string} opName — "create"/"modify"/... + */ function showParams(opId,opName){ + // Сохраняем выбранную операцию selectedOp={opId,opName,svcId:currentSvcId}; + + // Показать панель параметров, скрыть старые этапы document.getElementById('params-area').style.display='block'; document.querySelectorAll('.stages-box').forEach(e=>e.remove()); document.getElementById('test-status').textContent=''; const isCreate=opName==='create'; + // Для create — шаблонные значения, для не-create — с instanceUid const qs=isCreate?'' : `?instanceUid=${selectedInst}`; + fetch('/api/params/'+opId+qs).then(r=>r.json()).then(async data=>{ - if(data.error){document.getElementById('params-form').innerHTML=`Ошибка: ${_esc(data.error)}`;return;} + if(data.error){ + document.getElementById('params-form').innerHTML= + `Ошибка: ${_esc(data.error)}`; + return; + } const params=data.params||data; - if(!Array.isArray(params)){document.getElementById('params-form').innerHTML=`Неверный формат параметров`;return;} + if(!Array.isArray(params)){ + document.getElementById('params-form').innerHTML= + `Неверный формат параметров`; + return; + } + + // Если есть refSvcId-параметры — загружаем ВСЕ инстансы (для select'ов) let allInst=[]; const needRefs=params.some(p=>p.refSvcId); if(needRefs){ - try{const r=await fetch('/api/instances/list');const list=await r.json();allInst=list||[];}catch(e){} + try{ + const r=await fetch('/api/instances/list'); + const list=await r.json(); + allInst=list||[]; + }catch(e){} } + const form=document.getElementById('params-form'); const displayNameValue=isCreate?makeCreateDisplayName():''; - const createHeader=isCreate?`
Создание: ${_esc(currentSvcName)}
`:''; - const opHeader=!isCreate?`
${_esc(opName)} → ${_esc(findInstName())}
`:''; - form.innerHTML=createHeader+opHeader+(opName==='create'?`
${AUTOTEST_PREFIX}
`:'') + + // Заголовок формы + const createHeader=isCreate + ? `
Создание: ${_esc(currentSvcName)}
` + : ''; + const opHeader=!isCreate + ? `
${_esc(opName)} → ${_esc(findInstName())}
` + : ''; + + // Форма: заголовок + displayName (только create) + параметры + form.innerHTML=createHeader+opHeader + + (opName==='create' + ? `
${AUTOTEST_PREFIX}
` + : '') + params.map(p=>renderParamRow(p,allInst)).join(''); + + // Кнопка «Запустить» const btn=document.getElementById('btn-test'); btn.style.display='inline-flex'; btn.textContent='Запустить'; btn.onclick=()=>{ + // Валидация JSON-полей перед отправкой let bad=[]; - document.querySelectorAll('#params-form [data-type="map"]').forEach(el=>{if(!validateJson(el,true)) bad.push(el);}); + document.querySelectorAll('#params-form [data-type="map"]').forEach(el=>{ + if(!validateJson(el,true)) bad.push(el); + }); if(bad.length>0){ - document.getElementById('test-status').innerHTML=`Ошибка ${bad.length} полей с невалидным JSON — поправьте и нажмите Запустить снова`; + document.getElementById('test-status').innerHTML= + `Ошибка ${bad.length} полей с невалидным JSON — поправьте и нажмите Запустить снова`; return; } - const pp=collectParams(); - executeOp(pp); + const pp=collectParams(); // собрать значения из формы + executeOp(pp); // отправить }; }).catch(e=>{ - document.getElementById('params-form').innerHTML=`Ошибка загрузки: ${_esc(e.message)}`; + document.getElementById('params-form').innerHTML= + `Ошибка загрузки: ${_esc(e.message)}`; }); } -// renderParamRow, renderMapFixedRow, collectParams — теперь в params-render.js (общий модуль) - +/** + * Установить состояние «завершено» — показать результат операции. + * + * @param {string} statusText — "OK"/"FAIL"/"TIMEOUT" + * @param {string} statusClass — CSS-класс бейджа + * @param {string} statusError — текст ошибки + * @param {number} statusDuration— длительность в секундах + * @param {string} instanceName — displayName инстанса + */ function setFinishedState(statusText, statusClass, statusError, statusDuration, instanceName){ - busy=false; + busy=false; // разблокировать UI + // Закрыть панели операций document.querySelectorAll('.inst-ops.open').forEach(e=>e.classList.remove('open')); const btn=document.getElementById('btn-test'); btn.disabled=false; btn.textContent='Готово'; - btn.onclick=null; + btn.onclick=null; // убрать обработчик const opName=selectedOp?.opName||'операция'; const durationText=statusDuration!==undefined&&statusDuration!==null?`${statusDuration}s`:'0s'; const extra=_esc(statusError||''); const instName=_esc(instanceName||findInstName()||selectedInst||''); - document.getElementById('test-status').innerHTML=` ${statusText} ${opName} ${durationText} ${instName} ${extra}`; + document.getElementById('test-status').innerHTML= + ` ${statusText} ${opName} ${durationText} ${instName} ${extra}`; } +/** + * Установить состояние «выполняется». + */ function setRunningState(opName, displayName){ const btn=document.getElementById('btn-test'); btn.textContent=opName+'...'; document.getElementById('test-status').textContent=' ⏳ '+ (displayName||opName) +' выполняется...'; } +/** + * Отправить операцию на выполнение (POST /api/test) и начать поллинг. + * + * Алгоритм: + * 1. POST /api/test → {status: "RUNNING", opUid} + * 2. Если CMDB-delete → сразу OK (без поллинга) + * 3. Иначе → setInterval каждые 2 секунды: + * GET /api/test/status/{opUid} → этапы + статус + * 4. При OK/FAIL → остановить поллинг, обновить список инстансов и историю + * 5. При 5 ошибках подряд → TIMEOUT (связь потеряна) + * + * @param {Object} params — {numeric_param_id: value} + */ async function executeOp(params){ - busy=true; + busy=true; // заблокировать UI stopPoll(); + const isCreate=selectedOp?.opName==='create'; const displayNameInput=document.getElementById('param-displayname'); const displayNameSuffix=(displayNameInput?.value||'').trim(); - const displayName=isCreate ? `${AUTOTEST_PREFIX}${displayNameSuffix || makeCreateDisplayName()}` : findInstName(); + const displayName=isCreate + ? `${AUTOTEST_PREFIX}${displayNameSuffix || makeCreateDisplayName()}` + : findInstName(); + + // Подготовка UI document.getElementById('params-area').style.display='block'; - document.getElementById('params-form').innerHTML=''; + document.getElementById('params-form').innerHTML=''; // очистить форму const btn=document.getElementById('btn-test'); btn.style.display='inline-flex'; btn.textContent=selectedOp.opName+'...'; btn.disabled=true; setRunningState(selectedOp.opName, displayName); + + // Создать контейнер для этапов (после кнопки create или после панели операций) const instId=selectedInst||''; const afterEl=instId?document.getElementById('ops-'+instId):null; document.querySelectorAll('.stages-box').forEach(e=>e.remove()); @@ -122,13 +247,33 @@ async function executeOp(params){ stagesBox.style.cssText='max-height:200px;overflow-y:auto;padding:4px 8px;margin-left:16px;background:var(--brand-grey-light);border-radius:4px;'; if(afterEl){afterEl.after(stagesBox);} else{document.getElementById('create-btn-area').after(stagesBox);} + try{ - const r=await fetch('/api/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ - serviceId:currentSvcId, operation:selectedOp.opName, svcOperationId:selectedOp.opId, - params, instanceUid:selectedInst||'', displayName:isCreate?displayName:'' - })}); + // POST /api/test — тело: serviceId, operation, svcOperationId, params, instanceUid, displayName + const r=await fetch('/api/test',{ + method:'POST', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify({ + serviceId:currentSvcId, + operation:selectedOp.opName, + svcOperationId:selectedOp.opId, + params, + instanceUid:selectedInst||'', + displayName:isCreate?displayName:'' + }) + }); const d=await r.json(); - if(d.status==='FAIL'){busy=false;document.getElementById('test-status').innerHTML=` FAIL ${_esc(d.error||'')}`;btn.disabled=false;return;} + + // FAIL на старте — нечего поллить + if(d.status==='FAIL'){ + busy=false; + document.getElementById('test-status').innerHTML= + ` FAIL ${_esc(d.error||'')}`; + btn.disabled=false; + return; + } + + // CMDB-delete (opUid начинается с "cmdb-") — мгновенный успех if(d.status==='OK'&&(d.opUid||'').startsWith('cmdb-')){ busy=false; setFinishedState('OK','badge-success','',0,d.displayName); @@ -136,24 +281,37 @@ async function executeOp(params){ if(historyOpen) try{await loadHistory();}catch(e){} return; } + const opUid=d.opUid; const instanceName=d.displayName||displayName; + + // Поллинг: каждые 2 секунды let _pollErrors=0; pollTimer=setInterval(async()=>{ try{ const sr=await fetch('/api/test/status/'+opUid); const sd=await sr.json(); - _pollErrors=0; - showStages(sd.stages||[]); + _pollErrors=0; // сброс счётчика ошибок + showStages(sd.stages||[]); // обновить этапы if(sd.status!=='RUNNING'){ stopPoll(); - setFinishedState(sd.status, sd.status==='OK'?'badge-success':'badge', sd.error||sd.errorLog, sd.duration, sd.displayName||instanceName); - await refreshInstances(); - if(historyOpen) await loadHistory(); + setFinishedState( + sd.status, + sd.status==='OK'?'badge-success':'badge', + sd.error||sd.errorLog, + sd.duration, + sd.displayName||instanceName + ); + await refreshInstances(); // обновить список инстансов + if(historyOpen) await loadHistory(); // обновить историю } }catch(e){ _pollErrors++; - if(_pollErrors>=5){stopPoll();setFinishedState('TIMEOUT','','Связь потеряна',0,instanceName);} + // 5 ошибок подряд (10 секунд) → считаем связь потерянной + if(_pollErrors>=5){ + stopPoll(); + setFinishedState('TIMEOUT','','Связь потеряна',0,instanceName); + } } },2000); }catch(e){ @@ -163,14 +321,22 @@ async function executeOp(params){ } } +/** + * Показать этапы выполнения операции (stages из API). + * + * Каждый этап: иконка (✅/❌/⏳) + название + длительность. + * + * @param {Array} stages — [{stage, dtStart, dtFinish, isSuccessful, duration}, ...] + */ function showStages(stages){ if(!stages||!stages.length) return; let html='
Этапы
'; stages.forEach(s=>{ - const done=!!s.dtFinish; + const done=!!s.dtFinish; // этап завершён? const icon=done?(s.isSuccessful?'✅':'❌'):'⏳'; html+=`
${icon} ${_esc(s.stage)} — ${(s.duration||0).toFixed(1)}s
`; }); + // Показать в ПОСЛЕДНЕМ контейнере этапов (самом новом) const boxes=document.querySelectorAll('.stages-box'); if(boxes.length) boxes[boxes.length-1].innerHTML=html; } diff --git a/site/static/js/params-render.js b/site/static/js/params-render.js index e826248..c5dd076 100644 --- a/site/static/js/params-render.js +++ b/site/static/js/params-render.js @@ -1,70 +1,141 @@ -// params-render.js — общий рендер параметров операций -// Используется: operations.js (ручной режим), scenario-form.js (редактор сценариев) +// params-render.js — общий рендер параметров операций (формы ввода) +// +// Используется в двух местах: +// 1. operations.js — ручной режим (форма параметров для modify/create) +// 2. scenario-form.js — редактор сценариев (параметры каждого шага) +// // Зависимости: _esc() из utils.js, validateJson() из utils.js +// +// Экспортирует (в глобальную область): +// renderParamRow(p, allInst) — отрисовать ОДНУ строку параметра +// renderMapFixedRow(p, dfl) — отрисовать map-параметр с фиксированными ключами +// collectParams(containerSelector)— собрать значения из формы → {numeric_id: value} +/** + * Отрисовать строку параметра: лейбл + поле ввода (input/select). + * + * Типы полей: + * - valueList → с инстансами сервиса + * - boolean → с onblur="validateJson(this)" + * - остальное → + * + * @param {Object} p — параметр из API: + * {svcOperationCfsParamId, name, dataType, isRequired, defaultValue, valueList, refSvcId, dataDescriptor} + * @param {Array} allInst — все инстансы (нужно для refSvcId-селектов) + * @returns {string} HTML-строка + */ function renderParamRow(p, allInst) { const req = p.isRequired; const hasDfl = p.defaultValue !== null && p.defaultValue !== undefined && p.defaultValue !== ''; + // CSS-класс лейбла: req (обязательный+заполнен), req-nodfl (обязательный+пустой) const labelCls = req ? (hasDfl ? 'req' : 'req-nodfl') : ''; const dfl = hasDfl ? p.defaultValue : (req ? '' : ''); const vl = p.valueList; const isMap = (p.dataType || '').startsWith('map'); + let input; + if (vl && Array.isArray(vl)) { + // Выпадающий список с фиксированными значениями input = ``; + } else if (p.refSvcId) { + // Ссылка на другой сервис → выпадающий список инстансов этого сервиса const refInsts = allInst.filter(i => i.serviceId === p.refSvcId && i.explainedStatus !== 'deleted'); let opts = refInsts.map(i => ``).join(''); if (!dfl) opts = '' + opts; input = ``; + } else if (p.dataType === 'boolean') { + // Булево значение → выпадающий список true/false input = ``; + } else if (p.dataDescriptor && isMap) { + // Map с фиксированной структурой → renderMapFixedRow (вложенные поля) return renderMapFixedRow(p, dfl); + } else { + // Обычное текстовое поле (string, integer, map без структуры) const dt = isMap ? 'map' : ''; input = ``; if (isMap) input += ``; } + return `
${input}
`; } +/** + * Отрисовать map-параметр с ФИКСИРОВАННЫМИ ключами (dataDescriptor). + * + * Каждый ключ dataDescriptor → отдельная строка с под-лейблом. + * Вложенные поля отбиваются вертикальной чертой слева. + * + * @param {Object} p — параметр с dataDescriptor + * @param {string} dfl — текущее значение (JSON-строка) + * @returns {string} HTML + */ function renderMapFixedRow(p, dfl) { const dd = p.dataDescriptor; const labelCls = p.isRequired ? (dfl ? 'req' : 'req-nodfl') : ''; let subHtml = ''; + // Парсим текущее значение (JSON → объект) let dflObj = {}; try { dflObj = JSON.parse(dfl || '{}'); } catch (e) { } + Object.keys(dd).forEach(subKey => { const sub = dd[subKey]; + // Значение sub-ключа: из parsed JSON → default → пусто const subDfl = dflObj[subKey] !== undefined ? dflObj[subKey] : (sub.defaultValue || ''); const subVl = sub.valueList; const subReq = sub.isRequired; const subNoDfl = subReq && !subDfl && subDfl !== 0 && subDfl !== false; const subLblCls = subNoDfl ? 'req-nodfl' : ''; let si; + if (subVl && Array.isArray(subVl)) { + // Выпадающий список si = ``; } else if (sub.dataType === 'boolean') { + // Булево si = ``; } else { + // Текстовое поле si = ``; } subHtml += `
${si}
`; }); + + // Основной лейбл + вложенные поля с вертикальной чертой return `
${subHtml}
`; } +/** + * Собрать значения ВСЕХ полей формы → {numeric_id: string_value}. + * + * Для mapchild-полей (с точкой в имени) — собирает вложенный объект + * и сериализует в JSON. + * + * @param {string} containerSelector — CSS-селектор контейнера (по умолчанию '#params-form') + * @returns {Object} {paramId: value, ...} + */ function collectParams(containerSelector) { containerSelector = containerSelector || '#params-form'; - const pp = {}; - const nested = {}; + const pp = {}; // результат: {numeric_id: value} + const nested = {}; // временно: {parentId: {subKey: value}} для mapchild const container = document.querySelector(containerSelector); if (!container) return pp; + + // Перебираем ВСЕ элементы с name^="p_" container.querySelectorAll('[name^="p_"]').forEach(el => { let v = el.value; const dt = el.dataset.type || ''; + if (dt === 'mapchild') { + // Вложенный параметр: имя вида "p_198.host" + // Извлекаем parentId="198", subKey="host" const name = el.name.replace('p_', ''); const dot = name.indexOf('.'); const parentKey = name.substring(0, dot); @@ -73,10 +144,17 @@ function collectParams(containerSelector) { nested[parentKey][subKey] = v; return; } + + // Тип array: оборачиваем в JSON-массив if (dt === 'array' || dt.startsWith('array')) v = JSON.stringify([v]); + // Тип map: пустое значение → '{}' if (dt === 'map' || dt === 'map-fixed') v = v || '{}'; + pp[el.name.replace('p_', '')] = v; }); + + // Сериализовать вложенные mapchild в JSON-строки for (const pk in nested) pp[pk] = JSON.stringify(nested[pk]); + return pp; } diff --git a/site/static/js/scenario-create.js b/site/static/js/scenario-create.js index 954f958..ffbd78d 100644 --- a/site/static/js/scenario-create.js +++ b/site/static/js/scenario-create.js @@ -1,9 +1,27 @@ -// scenario-create.js — создание и клонирование сценария +// scenario-create.js — создание и клонирование сценариев +// +// createScenario() — открыть редактор для нового сценария +// cloneScenario(id) — клонировать существующий (prompt имени → POST /api/scenario/definitions) +/** + * Создать новый сценарий — открывает модальный редактор. + */ async function createScenario() { - showScenarioEditor(null); + showScenarioEditor(null); // null = новый, без определения } +/** + * Клонировать существующий сценарий. + * + * Алгоритм: + * 1. prompt — спросить имя для копии + * 2. GET /api/scenario/definitions/{id} — загрузить исходные steps + * 3. POST /api/scenario/definitions — создать копию с новым именем + * 4. Обновить список сценариев + * + * @param {number} defId — ID исходного сценария + * @param {string} origName — имя исходного (для prompt) + */ async function cloneScenario(defId, origName) { const newName = prompt('Имя нового сценария (копия «' + origName + '»):', origName + '_copy'); if (!newName) return; @@ -18,7 +36,7 @@ async function cloneScenario(defId, origName) { }); const d = await rr.json(); if (d.error) { alert(d.error); return; } - await loadScenarios(); + await loadScenarios(); // обновить список } catch (e) { alert('Ошибка клонирования: ' + e.message); } diff --git a/site/static/js/scenario-delete.js b/site/static/js/scenario-delete.js index d7e4816..1e1f041 100644 --- a/site/static/js/scenario-delete.js +++ b/site/static/js/scenario-delete.js @@ -1,12 +1,20 @@ -// scenario-delete.js — удаление сценария +// scenario-delete.js — удаление сценария (мягкое, is_active=false) +// +// deleteScenario(id, name) — confirm → DELETE /api/scenario/definitions/{id} → обновить список +/** + * Удалить сценарий — мягкое удаление (is_active = FALSE). + * + * @param {number} defId — ID сценария + * @param {string} name — имя сценария (для confirm-сообщения) + */ async function deleteScenario(defId, name) { if (!confirm('Удалить сценарий «' + name + '»?')) return; try { const r = await fetch('/api/scenario/definitions/' + defId, { method: 'DELETE' }); const d = await r.json(); if (d.error) { alert(d.error); return; } - await loadScenarios(); + await loadScenarios(); // обновить список } catch (e) { alert('Ошибка: ' + e.message); } diff --git a/site/static/js/scenario-edit.js b/site/static/js/scenario-edit.js index e7047d0..0905f45 100644 --- a/site/static/js/scenario-edit.js +++ b/site/static/js/scenario-edit.js @@ -1,5 +1,16 @@ // scenario-edit.js — редактирование существующего сценария +// +// editScenario(id) — загрузить определение и открыть редактор +/** + * Открыть редактор для существующего сценария. + * + * Алгоритм: + * 1. GET /api/scenario/definitions/{id} — загрузить определение + * 2. showScenarioEditor(def) — открыть модал с заполненными полями + * + * @param {number} defId — ID сценария + */ async function editScenario(defId) { try { const r = await fetch('/api/scenario/definitions/' + defId); diff --git a/site/static/js/scenario-form.js b/site/static/js/scenario-form.js index e8492ff..42fab86 100644 --- a/site/static/js/scenario-form.js +++ b/site/static/js/scenario-form.js @@ -1,24 +1,52 @@ // scenario-form.js — модальный редактор сценариев -// Использует: params-render.js (renderParamRow, renderMapFixedRow, collectParams) -// Зависит от: utils.js (_esc) +// +// Это САМЫЙ СЛОЖНЫЙ модуль фронтенда (~358 строк). +// Редактор позволяет создавать/редактировать сценарии через модальное окно. +// +// СОСТОЯНИЕ РЕДАКТОРА: scenarioEditorState = { +// defId, version, name, steps: [{service_id, operation, output, instance_ref, instance_uid, params, _svcOpId}], +// services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {} +// } +// +// ЖИЗНЕННЫЙ ЦИКЛ РЕДАКТИРОВАНИЯ: +// 1. showScenarioEditor(def) — инициализация, загрузка сервисов/инстансов, renderEditor() +// 2. renderEditor() — отрисовка модала с шагами +// 3. Пользователь меняет операцию → onOpChange → renderEditor (перерисовка) +// 4. Для каждого шага с операцией — loadStepParams (GET /api/params/{opId}) +// 5. Параметры рендерятся через params-render.js +// 6. Перед перерисовкой — snapshotAllParams (сохранить несохранённые правки) +// 7. Сохранение — collectFormSteps → POST/PUT /api/scenario/definitions +// +// ОБРАТНЫЙ МАППИНГ ПАРАМЕТРОВ: +// В форме параметры хранятся с числовыми ID (как и в API). +// В БД сценария — с символьными именами. +// При сохранении: numeric_id → name (через paramDefsCache) +// При загрузке: name → numeric_id (подстановка в defaultValue) let scenarioEditorState = null; -// {defId, version, name, steps:[], services:[], cloudInstances:[], opsCache:{}, paramDefsCache:{}} -// step: {service_id, operation, output, instance_ref, instance_uid, params:{name:value}} +/** + * Открыть редактор сценария. + * + * @param {Object|null} def — определение сценария или null (новый) + */ async function showScenarioEditor(def) { + // Инициализация состояния scenarioEditorState = def ? { defId: def.id, version: def.version, name: def.name, steps: (def.steps || []).map(s => ({ service_id: s.service_id || '', operation: s.operation || '', output: s.output || '', instance_ref: s.instance_ref || '', instance_uid: s.instance_uid || '', - params: s.params || {} // {name: value} — символические имена + params: s.params || {} // {name: value} — символьные имена })), services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {} - } : { defId: null, version: 1, name: '', steps: [], - services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {} }; + } : { + defId: null, version: 1, name: '', steps: [], + services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {} + }; + // Загрузить справочные данные: сервисы + инстансы (для select'ов) try { const [svcR, instR] = await Promise.all([ fetch('/api/services').then(r => r.json()), @@ -26,15 +54,25 @@ async function showScenarioEditor(def) { ]); scenarioEditorState.services = svcR || []; scenarioEditorState.cloudInstances = instR || []; - } catch (e) { scenarioEditorState.services = []; scenarioEditorState.cloudInstances = []; } + } catch (e) { + scenarioEditorState.services = []; + scenarioEditorState.cloudInstances = []; + } renderEditor(); } +/** + * Открыть модальное окно с HTML-контентом. + * + * @param {string} html — HTML-контент модала + */ function openModal(html) { let modal = document.getElementById('scenario-modal'); if (!modal) { + // Создать модал при первом вызове modal = document.createElement('div'); modal.id = 'scenario-modal'; + // backdrop — закрытие по клику вне модала modal.innerHTML = ''; document.body.appendChild(modal); } @@ -42,27 +80,45 @@ function openModal(html) { document.getElementById('modal-content').innerHTML = html; } +/** + * Закрыть модальное окно и обновить список сценариев. + */ function closeModal() { const modal = document.getElementById('scenario-modal'); if (modal) modal.style.display = 'none'; - loadScenarios(); + loadScenarios(); // обновить список (могли измениться) } -// ── Снапшот параметров перед ре-рендером ── +// ═══════════════════════════════════════════════════════ +// Снапшот параметров — защита от потери правок +// ═══════════════════════════════════════════════════════ +/** + * Сохранить параметры ОДНОГО шага перед перерисовкой. + * + * Зачем: renderEditor() пересоздаёт ВЕСЬ HTML → все несохранённые правки теряются. + * Эта функция собирает текущие значения из DOM и сохраняет в step.params. + * + * @param {number} idx — индекс шага + */ function snapshotStepParams(idx) { const st = scenarioEditorState; const container = document.getElementById('step-' + idx + '-params'); if (!container || !container.innerHTML) return; + + // Собрать значения из формы (числовые ID → значение) const numericParams = collectParams('#step-' + idx + '-params'); const defs = st.paramDefsCache[st.steps[idx]._svcOpId]; if (!defs) return; - // Обратный маппинг: numericId → name + + // Обратный маппинг: numeric_id → name const idToName = {}; defs.forEach(p => { idToName[p.svcOperationCfsParamId] = p.name; }); + const result = {}; for (const [id, val] of Object.entries(numericParams)) { const name = idToName[parseInt(id)] || id; + // Сохраняем только НЕ-дефолтные значения if (val !== '' && val !== '{}' && val !== '[]' && val !== 'false') { result[name] = val; } @@ -70,37 +126,60 @@ function snapshotStepParams(idx) { st.steps[idx].params = result; } +/** + * Снапшот ВСЕХ шагов — вызывается перед ЛЮБОЙ перерисовкой. + */ function snapshotAllParams() { if (!scenarioEditorState) return; scenarioEditorState.steps.forEach((_, idx) => snapshotStepParams(idx)); } -// ── Резолв операции + загрузка параметров ── +// ═══════════════════════════════════════════════════════ +// Резолв операции + загрузка параметров +// ═══════════════════════════════════════════════════════ +/** + * Найти svcOperationId для операции в шаге. + * + * Для create: использует service_id шага. + * Для не-create: определяет service_id через выбранный инстанс + * (облачный UUID → serviceId, или output-ref → service_id предыдущего create-шага). + * + * @param {number} idx — индекс шага + * @returns {number|null} svcOperationId или null + */ async function resolveStepSvcOpId(idx) { const st = scenarioEditorState; const step = st.steps[idx]; const op = step.operation; if (!op) return null; + let svcId = step.service_id; + if (op !== 'create') { - // Определить svcId через выбранный инстанс + // Для не-create: определяем svcId через выбранный инстанс const refEl = document.getElementById('step-' + idx + '-ref'); const ref = (refEl?.value || '').trim(); if (ref) { + // Проверяем: это облачный UUID? const cloud = (st.cloudInstances || []).find(i => i.instanceUid === ref); - if (cloud) { svcId = cloud.serviceId; } - else { - // output-ref: ищем в предыдущих create-шагах + if (cloud) { + svcId = cloud.serviceId; + } else { + // Это output-ref — ищем в предыдущих create-шагах for (let i = 0; i < idx; i++) { - if (st.steps[i].output === ref) { svcId = st.steps[i].service_id; break; } + if (st.steps[i].output === ref) { + svcId = st.steps[i].service_id; + break; + } } } } } + if (!svcId) return null; - // Кеш операций + // Кеш операций сервиса if (!st.opsCache[svcId]) { try { const r = await fetch('/api/operations/' + svcId); @@ -113,6 +192,17 @@ async function resolveStepSvcOpId(idx) { return found ? found.svcOperationId : null; } +/** + * Загрузить и отрисовать параметры для шага. + * + * Алгоритм: + * 1. resolveStepSvcOpId — найти ID операции + * 2. GET /api/params/{svcOpId} — шаблон параметров + * 3. Подставить сохранённые значения (step.params) в defaultValue + * 4. Отрисовать через renderParamRow + * + * @param {number} idx — индекс шага + */ async function loadStepParams(idx) { const st = scenarioEditorState; const step = st.steps[idx]; @@ -120,7 +210,7 @@ async function loadStepParams(idx) { const svcOpId = await resolveStepSvcOpId(idx); if (!svcOpId) return; - step._svcOpId = svcOpId; + step._svcOpId = svcOpId; // сохраняем для обратного маппинга при сохранении // Кеш параметров if (!st.paramDefsCache[svcOpId]) { @@ -140,29 +230,47 @@ async function loadStepParams(idx) { try { const r = await fetch('/api/instances/list'); allInst = await r.json(); } catch (e) {} } - // Рендер: клон def с подстановкой сохранённых значений в defaultValue + // Рендер: клонируем def и подставляем сохранённые значения в defaultValue const container = document.getElementById('step-' + idx + '-params'); if (!container) return; let html = ''; defs.forEach(p => { - const clone = Object.assign({}, p); - const savedVal = savedParams[p.name]; + const clone = Object.assign({}, p); // shallow copy + const savedVal = savedParams[p.name]; // ищем по символическому имени if (savedVal !== undefined) clone.defaultValue = savedVal; html += renderParamRow(clone, allInst); }); container.innerHTML = html; } -// ── Рендер ── +// ═══════════════════════════════════════════════════════ +// Рендер редактора +// ═══════════════════════════════════════════════════════ +/** + * Главная функция рендера — перерисовывает ВЕСЬ модал. + * + * Вызывается при: + * - Открытии редактора + * - Изменении операции (onOpChange) + * - Добавлении/удалении/перемещении шагов + * - Переключении облачных инстансов + */ function renderEditor() { snapshotAllParams(); // сохранить несохранённые правки перед перерисовкой const st = scenarioEditorState; + let html = '
'; + + // Заголовок html += '

' + (st.defId ? 'Редактирование' : 'Новый сценарий') + '

'; + + // Поле названия html += '

'; html += ``; html += '
'; + + // Справка «Как заполнять» html += `
📖 Как заполнять
@@ -173,25 +281,40 @@ function renderEditor() { [↑][↓] — порядок, [✕] — удалить.
`; + + // Шаги html += '
Шаги
'; st.steps.forEach((s, idx) => { html += renderStepRow(idx, s); }); + + // Кнопка добавления шага html += ``; + + // Кнопки Сохранить / Отмена html += '
'; html += ``; html += ``; html += '
'; + openModal(html); // Асинхронно загрузить параметры для шагов с выбранной операцией st.steps.forEach((s, idx) => { if (s.operation) loadStepParams(idx); }); } +/** + * Отрисовать ОДНУ строку шага в редакторе. + * + * @param {number} idx — индекс шага + * @param {Object} step — {service_id, operation, output, instance_ref, instance_uid, params} + * @returns {string} HTML + */ function renderStepRow(idx, step) { const st = scenarioEditorState; const op = step.operation; const out = step.output || ''; const ref = step.instance_ref || ''; + // Собрать доступные output-ы из предыдущих create-шагов (для instance_ref select) const prevOutputs = []; for (let i = 0; i < idx; i++) { const s = st.steps[i]; @@ -199,33 +322,47 @@ function renderStepRow(idx, step) { } let html = `
`; + + // Заголовок шага: номер + кнопки [↑][↓][✕] html += `
`; html += `Шаг ${idx+1}`; + // Кнопка вверх (кроме первого) if (idx > 0) html += ``; + // Кнопка вниз (кроме последнего) if (idx < st.steps.length - 1) html += ``; + // Кнопка удаления html += ``; html += `
`; + // Выпадающий список операций const allOps = ['create', 'delete', 'modify', 'suspend', 'resume', 'redeploy']; let opOpts = ''; allOps.forEach(o => { opOpts += ``; }); html += `
`; + // Разное содержимое в зависимости от типа операции if (op === 'create') { + // CREATE: выбор сервиса + output name let svcOpts = ''; st.services.forEach(s => { svcOpts += ``; }); html += `
`; html += `
`; + } else if (op) { + // НЕ-CREATE: выбор инстанса (из create-шагов + облако) let refOpts = ''; - prevOutputs.forEach(o => { refOpts += ``; }); + // Инстансы из предыдущих create-шагов + prevOutputs.forEach(o => { + refOpts += ``; + }); // Облачные autotest-* инстансы (только если раскрыты) if (step._showCloud && st.cloudInstances) { refOpts += ''; st.cloudInstances.forEach(i => { const dn = i.displayName || ''; + // Только autotest-*, не deleted if (!dn.startsWith('autotest-') || i.explainedStatus === 'deleted') return; const sel = i.instanceUid == ref ? 'selected' : ''; refOpts += ``; @@ -236,53 +373,88 @@ function renderStepRow(idx, step) { html += `
`; } - // Параметры — свёрнутый
, заполняется асинхронно в loadStepParams + // Параметры — свёрнутый
, заполняется асинхронно в loadStepParams() html += `
Параметры
`; html += ''; return html; } -// ── Действия ── +// ═══════════════════════════════════════════════════════ +// Действия редактора +// ═══════════════════════════════════════════════════════ +/** + * Изменение операции в выпадающем списке → сброс параметров + перерисовка. + */ function onOpChange(idx) { - scenarioEditorState.steps[idx].params = {}; - scenarioEditorState.steps[idx]._svcOpId = null; + scenarioEditorState.steps[idx].params = {}; // сброс сохранённых параметров + scenarioEditorState.steps[idx]._svcOpId = null; // сброс кеша svcOpId renderEditor(); } +/** + * Переключить показ облачных инстансов в select'е. + */ function toggleCloudInstances(idx) { snapshotAllParams(); scenarioEditorState.steps[idx]._showCloud = !scenarioEditorState.steps[idx]._showCloud; renderEditor(); } +/** + * Добавить новый (пустой) шаг. + */ function addStep() { snapshotAllParams(); - scenarioEditorState.steps.push({ service_id: '', operation: '', output: '', instance_ref: '', instance_uid: '', params: {}, _showCloud: false }); + scenarioEditorState.steps.push({ + service_id: '', operation: '', output: '', instance_ref: '', + instance_uid: '', params: {}, _showCloud: false + }); renderEditor(); } +/** + * Удалить шаг по индексу. + */ function removeStep(idx) { snapshotAllParams(); scenarioEditorState.steps.splice(idx, 1); renderEditor(); } +/** + * Переместить шаг вверх/вниз. + * + * @param {number} idx — индекс шага + * @param {number} dir — -1 (вверх) или +1 (вниз) + */ function moveStep(idx, dir) { snapshotAllParams(); const steps = scenarioEditorState.steps; const target = idx + dir; if (target < 0 || target >= steps.length) return; + // Swap [steps[idx], steps[target]] = [steps[target], steps[idx]]; renderEditor(); } +/** + * Собрать ВСЕ шаги из DOM для сохранения. + * + * Для каждого шага: + * - Извлечь service_id (из select для create, из инстанса для не-create) + * - Извлечь output/instance_ref/instance_uid + * - Собрать параметры через collectParams + обратный маппинг numeric→name + * + * @returns {{name: string, steps: Array}} + */ function collectFormSteps() { - snapshotAllParams(); + snapshotAllParams(); // финальный снапшот const st = scenarioEditorState; const name = (document.getElementById('scenario-name')?.value || '').trim(); const steps = []; + st.steps.forEach((step, idx) => { const opEl = document.getElementById('step-' + idx + '-op'); const operation = (opEl?.value || '').trim(); @@ -290,21 +462,23 @@ function collectFormSteps() { const s = { operation, params: {} }; if (operation === 'create') { + // CREATE: service_id из select const svcEl = document.getElementById('step-' + idx + '-svc'); svcId = parseInt(svcEl?.value) || 0; const outEl = document.getElementById('step-' + idx + '-output'); const output = (outEl?.value || '').trim(); if (output) s.output = output; } else { + // НЕ-CREATE: определяем instance_ref или instance_uid const refEl = document.getElementById('step-' + idx + '-ref'); const ref = (refEl?.value || '').trim(); if (ref) { const cloud = (st.cloudInstances || []).find(i => i.instanceUid === ref); if (cloud) { svcId = cloud.serviceId; - s.instance_uid = ref; + s.instance_uid = ref; // облачный UUID } else { - s.instance_ref = ref; + s.instance_ref = ref; // output-ссылка } } } @@ -318,6 +492,7 @@ function collectFormSteps() { defs.forEach(p => { idToName[p.svcOperationCfsParamId] = p.name; }); for (const [id, val] of Object.entries(numericParams)) { const pname = idToName[parseInt(id)] || id; + // Сохраняем только НЕ-дефолтные значения if (val !== '' && val !== '{}' && val !== '[]' && val !== 'false') { s.params[pname] = val; } @@ -328,29 +503,51 @@ function collectFormSteps() { return { name, steps }; } +/** + * Сохранить сценарий — POST (новый) или PUT (существующий). + * + * Валидация перед отправкой: + * - Название не пустое + * - Хотя бы один шаг + * - У каждого шага выбран сервис/инстанс и операция + */ async function saveScenario() { const { name, steps } = collectFormSteps(); + + // Валидация if (!name) { alert('Введите название'); return; } if (!steps.length) { alert('Добавьте хотя бы один шаг'); return; } for (let i = 0; i < steps.length; i++) { if (!steps[i].service_id) { alert('Шаг ' + (i+1) + ': выберите сервис или инстанс'); return; } if (!steps[i].operation) { alert('Шаг ' + (i+1) + ': выберите операцию'); return; } } + const st = scenarioEditorState; const isNew = !st.defId; const url = isNew ? '/api/scenario/definitions' : '/api/scenario/definitions/' + st.defId; const method = isNew ? 'POST' : 'PUT'; + // Для PUT нужна version (оптимистичная блокировка) const body = isNew ? { name, steps } : { name, steps, version: st.version }; + try { - const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + const r = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); const d = await r.json(); + + // 409 Conflict — кто-то уже изменил if (r.status === 409) { alert('⚠️ Конфликт версий — кто-то уже изменил сценарий. Обновите страницу.'); return; } if (d.error) { alert(d.error); return; } + + // Обновить состояние (id и version могли измениться) if (isNew && d.id) st.defId = d.id; if (d.version) st.version = d.version; + closeModal(); } catch (e) { alert('Ошибка сохранения: ' + e.message); diff --git a/site/static/js/scenario-list.js b/site/static/js/scenario-list.js index b0694de..cae35d3 100644 --- a/site/static/js/scenario-list.js +++ b/site/static/js/scenario-list.js @@ -1,7 +1,19 @@ // scenario-list.js — список сценариев, запуск, поллинг +// +// Отвечает за: +// - Загрузку списка сценариев из GET /api/scenarios +// - Раскрытие шагов сценария (GET /api/scenario/definitions/{id}) +// - Запуск сценария (POST /api/scenario/run) +// - Поллинг статуса запуска (GET /api/scenario/run/{run_id}) +// +// Цветовая маркировка: RUNNING → жёлтый фон (#fef3c7) let scenarioOpen=false, scenarioPollTimer=null; +/** + * Переключить панель сценариев (открыть/закрыть). + * При открытии — загружает список + последний запуск. + */ async function toggleScenario(){ const body=document.getElementById('scenario-body'); scenarioOpen=!scenarioOpen; @@ -10,6 +22,13 @@ async function toggleScenario(){ await loadScenarios(); } +/** + * Загрузить список сценариев + последний запуск. + * + * Два параллельных запроса: + * GET /api/scenarios — список определений [{id, name, steps}, ...] + * GET /api/scenario/status — последние 10 запусков [{scenario_name, status, ...}, ...] + */ async function loadScenarios(){ const body=document.getElementById('scenario-body'); try{ @@ -17,18 +36,25 @@ async function loadScenarios(){ fetch('/api/scenarios').then(r=>r.json()), fetch('/api/scenario/status').then(r=>r.json()), ]); + let html=''; + + // Кнопка создания нового сценария html+=``; + if(sr.length){ + // Список сценариев — каждый кликабелен html+='
'; sr.forEach(s=>{ - const seed = s.is_seed; + const seed = s.is_seed; // seed-сценарий (из YAML) — нельзя удалить html+=`
`; + // Заголовок сценария — клик раскрывает шаги html+=`
`; html+=`${_esc(s.name)}`; html+=`${s.steps} шаг.`; if(seed) html+=`seed`; html+=`
`; + // Контейнер для раскрытых шагов (изначально скрыт) html+=``; html+=`
`; }); @@ -36,31 +62,53 @@ async function loadScenarios(){ }else{ html+='Нет сценариев в БД'; } + + // Последний запуск — фильтруем по текущей версии приложения const curVer=window.APP&&window.APP.version||''; const verHistory=srHistory&&srHistory.filter(r=>r.app_version===curVer); if(verHistory && verHistory.length){ const last=verHistory[0]; const icon=last.status==='OK'?'✅':last.status==='FAIL'?'❌':last.status==='RUNNING'?'⏳':'⏱'; const time=last.created_at?last.created_at.replace('T',' ').substring(0,19):'?'; + // RUNNING → жёлтый фон const runningStyle = last.status==='RUNNING' ? 'background:#fef3c7;padding:2px 4px;border-radius:3px;' : ''; html+=`
Последний: ${icon} ${_esc(last.scenario_name)} — ${last.status} (шаг ${last.current_step}/${last.total_steps}) ${last.duration_sec!=null?last.duration_sec.toFixed(1)+'s':''} — ${time}
`; if(last.error_log) html+=`
${_esc(last.error_log)}
`; } + body.innerHTML=html; - }catch(e){body.innerHTML='Ошибка загрузки';} + }catch(e){ + body.innerHTML='Ошибка загрузки'; + } } +/** + * Раскрыть шаги сценария — загрузить полное определение. + * + * Показывает: + * - Список шагов: операция → сервис + параметры + * - Кнопки: ▶ Запустить, ✏ Редактировать, 📋 Копировать, 🗑 Удалить + * + * Закрывает другие раскрытые сценарии (только один открыт одновременно). + * + * @param {number} defId — ID сценария + */ async function toggleScenarioSteps(defId) { const el = document.getElementById('scenario-steps-' + defId); if (!el) return; + + // Если уже открыт — закрыть (toggle) if (el.style.display === 'block') { el.style.display = 'none'; return; } - // Загрузить шаги + try { const r = await fetch('/api/scenario/definitions/' + defId); const def = await r.json(); if (def.error) return; + const steps = def.steps || []; let html = '
'; + + // Перечисляем шаги steps.forEach((s, i) => { const svcName = s.service_id ? 'сервис ' + s.service_id : '?'; html += `
${i+1}. ${_esc(s.operation)} → ${svcName}`; @@ -71,48 +119,89 @@ async function toggleScenarioSteps(defId) { if (params.length) html += ' (' + params.map(([k,v]) => k+'='+v).join(', ') + ')'; html += '
'; }); + + // Кнопки действий html += ``; html += ``; html += ``; + // Удалить — только для НЕ-seed сценариев if (!def.is_seed) html += ``; html += '
'; + el.innerHTML = html; el.style.display = 'block'; - // Закрыть другие - document.querySelectorAll('[id^="scenario-steps-"]').forEach(e => { if (e !== el) e.style.display = 'none'; }); + + // Закрыть другие раскрытые сценарии + document.querySelectorAll('[id^="scenario-steps-"]').forEach(e => { + if (e !== el) e.style.display = 'none'; + }); } catch (e) {} } +/** + * Запустить сценарий. + * + * Алгоритм: + * 1. confirm — подтверждение + * 2. POST /api/scenario/run → {status: "RUNNING", run_id} + * 3. Поллинг каждые 3 секунды: + * GET /api/scenario/run/{run_id} → {status, current_step, total_steps, ...} + * 4. При OK/FAIL → остановить поллинг, обновить инстансы и историю + * + * @param {number} defId — ID определения сценария + */ async function runScenario(defId){ - if(busy){ return; } + if(busy){ return; } // другая операция уже идёт if(!confirm('Запустить сценарий?')) return; + busy=true; stopScenarioPoll(); + const body=document.getElementById('scenario-body'); body.innerHTML=`
⏳ Запуск...
`; + try{ - const r=await fetch('/api/scenario/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({definition_id:defId})}); + const r=await fetch('/api/scenario/run',{ + method:'POST', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify({definition_id:defId}) + }); const d=await r.json(); - if(d.error){ body.innerHTML=`Ошибка: ${_esc(d.error)}`; busy=false; return; } + + if(d.error){ + body.innerHTML=`Ошибка: ${_esc(d.error)}`; + busy=false; + return; + } + const runId=d.run_id; - if(!runId){ body.innerHTML=`Ошибка: нет run_id в ответе`; busy=false; return; } + if(!runId){ + body.innerHTML=`Ошибка: нет run_id в ответе`; + busy=false; + return; + } + + // Поллинг статуса scenarioPollTimer=setInterval(async()=>{ try{ const sr=await fetch('/api/scenario/run/'+runId); if(!sr.ok){ return; } const last=await sr.json(); if(!last||last.error) return; + const icon=last.status==='OK'?'✅':last.status==='FAIL'?'❌':last.status==='RUNNING'?'⏳':'⏱'; const time=last.created_at?last.created_at.replace('T',' ').substring(0,19):'?'; const runningBg = last.status==='RUNNING' ? 'background:#fef3c7;padding:2px 4px;border-radius:3px;' : ''; + let html=`
${icon} ${_esc(last.scenario_name)} — ${last.status} (шаг ${last.current_step}/${last.total_steps}) ${last.duration_sec!=null?last.duration_sec.toFixed(1)+'s':''} — ${time}
`; if(last.error_log) html+=`
${_esc(last.error_log)}
`; body.innerHTML=html; + if(last.status!=='RUNNING'){ stopScenarioPoll(); busy=false; - await refreshInstances(); - if(historyOpen) await loadHistory(); + await refreshInstances(); // обновить список инстансов + if(historyOpen) await loadHistory(); // обновить историю } }catch(e){} },3000); @@ -122,6 +211,9 @@ async function runScenario(defId){ } } +/** + * Остановить поллинг сценария. + */ function stopScenarioPoll(){ if(scenarioPollTimer){ clearInterval(scenarioPollTimer); scenarioPollTimer=null; } } diff --git a/site/static/js/snackbar.js b/site/static/js/snackbar.js index b6599d9..24719bf 100644 --- a/site/static/js/snackbar.js +++ b/site/static/js/snackbar.js @@ -1,28 +1,47 @@ -// snackbar.js — лёгкие уведомления (без зависимостей) -// showSnackbar(msg, type='info') — type: info, success, error +// snackbar.js — лёгкие toast-уведомления (без зависимостей) +// +// Использование: showSnackbar('Сообщение', 'success') +// Типы: 'info' (по умолчанию), 'success', 'error' +// +// Поведение: +// - Показывается в правом нижнем углу (position: fixed) +// - Автоматически исчезает: info/success через 4с, error через 8с +// - Клик по уведомлению — мгновенное закрытие +// - Максимум 4 уведомления одновременно (старые удаляются) +/** + * Показать snackbar-уведомление. + * + * @param {string} msg — текст уведомления + * @param {string} type — 'info' | 'success' | 'error' (по умолчанию 'info') + */ function showSnackbar(msg, type) { type = type || 'info'; + + // Найти или создать контейнер для всех уведомлений let container = document.getElementById('snackbar-container'); if (!container) { container = document.createElement('div'); container.id = 'snackbar-container'; container.className = 'snackbar-container'; - container.setAttribute('role', 'status'); - container.setAttribute('aria-live', 'polite'); + container.setAttribute('role', 'status'); // accessibility + container.setAttribute('aria-live', 'polite'); // screen-reader зачитает document.body.appendChild(container); } + + // Создать элемент уведомления const el = document.createElement('div'); el.className = 'snackbar ' + type; el.textContent = msg; + // Клик — закрыть el.onclick = function() { el.remove(); }; container.appendChild(el); - // Limit stacking + // Ограничение: максимум 4 уведомления const all = container.querySelectorAll('.snackbar'); - if (all.length > 4) all[0].remove(); + if (all.length > 4) all[0].remove(); // удаляем самое старое - // Auto-hide: info/success 4s, error 8s + // Автоматическое скрытие: error — 8 секунд, остальные — 4 const delay = (type === 'error') ? 8000 : 4000; setTimeout(function() { if (el.parentNode) el.remove(); diff --git a/site/static/js/utils.js b/site/static/js/utils.js index 2461acb..ad16b53 100644 --- a/site/static/js/utils.js +++ b/site/static/js/utils.js @@ -1,42 +1,86 @@ -// utils.js — общие хелперы: _esc, busy lock, validateJson, лог-панель +// utils.js — общие хелперы фронтенда +// Используется ВСЕМИ модулями (должен грузиться ПЕРВЫМ). +// Экспортирует (в глобальную область): _esc, validateJson, relativeTime, +// startLogPoll, toggleLog +/** + * HTML-экранирование — защита от XSS. + * Все данные из API/БД пропускаются через _esc() перед вставкой в innerHTML. + * + * @param {*} s — что угодно (приводится к строке через String()) + * @returns {string} безопасная для HTML строка + * + * Заменяет: + * & → & (первым! иначе сломает уже заменённые сущности) + * " → " (атрибуты в кавычках) + * < → < (XSS-вектор: