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+=`
${time}
${_esc(r.op_name||'?')}
@@ -29,24 +56,43 @@ async function loadHistory(){
${_esc(r.status||'?')}
${r.duration_sec!=null?r.duration_sec.toFixed(1)+'s':'-'}
`;
+
+ // Скрытая строка с этапами (раскрывается по клику)
html+=`${renderStages(r.stages)} `;
});
html+='
';
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=>
+ `${o.operation} `
+ ).join('');
+ opsEl.classList.add('open');
+ }catch(e){
+ opsEl.innerHTML='Ошибка загрузки ';
+ opsEl.classList.add('open');
}
- opsEl.innerHTML=ops.map(o=>
- `${o.operation} `
- ).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'?`displayName ${AUTOTEST_PREFIX}
`:'')
+
+ // Заголовок формы
+ const createHeader=isCreate
+ ? `Создание: ${_esc(currentSvcName)}
`
+ : '';
+ const opHeader=!isCreate
+ ? `${_esc(opName)} → ${_esc(findInstName())}
`
+ : '';
+
+ // Форма: заголовок + displayName (только create) + параметры
+ form.innerHTML=createHeader+opHeader
+ + (opName==='create'
+ ? `displayName ${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 → с вариантами
+ * - refSvcId → с инстансами сервиса
+ * - boolean → true/false
+ * - map + dataDescriptor → renderMapFixedRow (вложенные поля)
+ * - map без dataDescriptor → с 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 = `${vl.map(v => `${_esc(v)} `).join('')} `;
+
} else if (p.refSvcId) {
+ // Ссылка на другой сервис → выпадающий список инстансов этого сервиса
const refInsts = allInst.filter(i => i.serviceId === p.refSvcId && i.explainedStatus !== 'deleted');
let opts = refInsts.map(i => `${_esc(i.displayName)} `).join('');
if (!dfl) opts = '— выбрать — ' + opts;
input = `${opts} `;
+
} else if (p.dataType === 'boolean') {
+ // Булево значение → выпадающий список true/false
input = `true false `;
+
} else if (p.dataDescriptor && isMap) {
+ // Map с фиксированной структурой → renderMapFixedRow (вложенные поля)
return renderMapFixedRow(p, dfl);
+
} else {
+ // Обычное текстовое поле (string, integer, map без структуры)
const dt = isMap ? 'map' : '';
input = ` `;
if (isMap) input += ` `;
}
+
return `${_esc(p.name)} ${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 = `${subVl.map(v => `${_esc(v)} `).join('')} `;
} else if (sub.dataType === 'boolean') {
+ // Булево
si = `true false `;
} else {
+ // Текстовое поле
si = ` `;
}
subHtml += `${_esc(subKey)} ${si}
`;
});
+
+ // Основной лейбл + вложенные поля с вертикальной чертой
return `${_esc(p.name)}
${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 += `
${o} `; });
html += `
${opOpts}
`;
+ // Разное содержимое в зависимости от типа операции
if (op === 'create') {
+ // CREATE: выбор сервиса + output name
let svcOpts = '
— сервис — ';
st.services.forEach(s => {
svcOpts += `
${s.svcId}. ${_esc(s.svc)} `;
});
html += `
${svcOpts}
`;
html += `
`;
+
} else if (op) {
+ // НЕ-CREATE: выбор инстанса (из create-шагов + облако)
let refOpts = '
— инстанс — ';
- prevOutputs.forEach(o => { refOpts += `
🆕 ${_esc(o)} (из шага) `; });
+ // Инстансы из предыдущих create-шагов
+ prevOutputs.forEach(o => {
+ refOpts += `
🆕 ${_esc(o)} (из шага) `;
+ });
// Облачные 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 += `
☁ ${_esc(dn)} — ${_esc(i.svc||'')} (${_esc(i.explainedStatus||'?')}) `;
@@ -236,53 +373,88 @@ function renderStepRow(idx, step) {
html += `
${btnLabel} `;
}
- // Параметры — свёрнутый , заполняется асинхронно в 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-вектор: