From 983b3877080d8f5357dd4a0aaea09a184efebcb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Thu, 30 Jul 2026 15:06:05 +0400 Subject: [PATCH] v1.1.50: scenario_definitions DB+CRUD, seed, runner from DB, 409 lock, UI definitions, drop config.yaml scenarios --- site/app.py | 2 +- site/config.yaml | 10 --- site/db/init_db.py | 60 +++++++++++++ site/db/scenario_defs.py | 168 +++++++++++++++++++++++++++++++++++ site/operations/scenario.py | 143 ++++++------------------------ site/routes/api_scenario.py | 171 +++++++++++++++++++++++++++++------- site/scenario_seed.yaml | 15 ++++ site/static/app.js | 10 +-- 8 files changed, 418 insertions(+), 161 deletions(-) create mode 100644 site/db/scenario_defs.py create mode 100644 site/scenario_seed.yaml diff --git a/site/app.py b/site/app.py index 6ef0dc5..ad58f38 100644 --- a/site/app.py +++ b/site/app.py @@ -18,7 +18,7 @@ from routes.api_test import bp as api_test_bp from routes.api_scenario import bp as api_scenario_bp # Версия — меняется при КАЖДОМ изменении кода. Показывается в топбаре UI. -VERSION = "1.1.49" +VERSION = "1.1.50" 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") diff --git a/site/config.yaml b/site/config.yaml index 541529a..2d9de3f 100644 --- a/site/config.yaml +++ b/site/config.yaml @@ -26,13 +26,3 @@ services: - name: delete enabled: false params: {} - -scenarios: - dummy_test: - steps: - - svc: dummy - op: create - params: - durationMs: "5000" - - svc: dummy - op: delete diff --git a/site/db/init_db.py b/site/db/init_db.py index 9c2eb18..b1d4097 100644 --- a/site/db/init_db.py +++ b/site/db/init_db.py @@ -17,6 +17,7 @@ """ import os +import json from db.pool import get_conn, put_conn SCHEMA_SQL = """ @@ -80,9 +81,65 @@ CREATE TABLE IF NOT EXISTS scenario_runs ( CREATE INDEX IF NOT EXISTS idx_scenario_runs_client_stand ON scenario_runs (client_id, stand, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_scenario_runs_status + ON scenario_runs (client_id, stand, status); + +ALTER TABLE scenario_runs ADD COLUMN IF NOT EXISTS definition_id INTEGER; +ALTER TABLE scenario_runs ADD COLUMN IF NOT EXISTS definition_version INTEGER; + +-- Таблица определений сценариев — редактируются без редеплоя +CREATE TABLE IF NOT EXISTS scenario_definitions ( + id SERIAL PRIMARY KEY, + client_id VARCHAR(64) NOT NULL, + stand VARCHAR(16) NOT NULL, + name VARCHAR(200) NOT NULL, + steps JSONB NOT NULL DEFAULT '[]', + version INTEGER NOT NULL DEFAULT 1, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_by VARCHAR(128) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_scenario_defs_unique + ON scenario_definitions (client_id, stand, LOWER(name)); + +CREATE INDEX IF NOT EXISTS idx_scenario_defs_active + ON scenario_definitions (client_id, stand, is_active); """ +def _seed_scenarios(): + """Импорт дефолтных сценариев из scenario_seed.yaml при первом старте.""" + try: + import yaml, os + path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scenario_seed.yaml") + if not os.path.exists(path): + return + with open(path) as f: + data = yaml.safe_load(f) or {} + seed_list = data.get("scenarios", []) + if not seed_list: + return + conn = get_conn() + if not conn: + return + cur = conn.cursor() + for s in seed_list: + cur.execute(""" + INSERT INTO scenario_definitions (client_id, stand, name, steps) + VALUES (%s, %s, %s, %s) + ON CONFLICT (client_id, stand, LOWER(name)) DO NOTHING + """, (s.get("client_id", ""), s.get("stand", ""), s["name"], json.dumps(s.get("steps", [])))) + conn.commit() + cur.close() + put_conn(conn) + print("[DB] Seed scenarios OK", flush=True) + except Exception as e: + print(f"[DB] Seed scenarios error: {e}", flush=True) + + def init_db(): """Применить схему + миграции — идемпотентно, безопасно для конкурентного вызова.""" if not os.getenv("DB_USER"): @@ -107,3 +164,6 @@ def init_db(): conn.rollback() finally: put_conn(conn) + + # Seed сценариев — только при первом старте (INSERT ON CONFLICT DO NOTHING) + _seed_scenarios() diff --git a/site/db/scenario_defs.py b/site/db/scenario_defs.py new file mode 100644 index 0000000..de9f7e7 --- /dev/null +++ b/site/db/scenario_defs.py @@ -0,0 +1,168 @@ +""" +CRUD для таблицы scenario_definitions. + +Функции: + 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 если да +""" + +import json +from db.pool import get_conn, put_conn + + +def list_definitions(client_id, stand): + conn = get_conn() + if not conn: + return [] + try: + cur = conn.cursor() + cur.execute(""" + SELECT id, name, steps, version, is_active, created_at, updated_at + FROM scenario_definitions + WHERE client_id = %s AND stand = %s AND is_active = TRUE + ORDER BY name + """, (client_id, stand)) + rows = cur.fetchall() + cols = [d[0] for d in cur.description] + result = [] + for r in rows: + d = dict(zip(cols, r)) + 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 + result.append(d) + return result + except Exception as e: + print(f"[DEFS] list error: {e}", flush=True) + return [] + finally: + put_conn(conn) + + +def get_definition(def_id, client_id, stand): + conn = get_conn() + if not conn: + return None + try: + cur = conn.cursor() + cur.execute(""" + SELECT id, name, steps, version, is_active, created_at, updated_at, updated_by + FROM scenario_definitions + WHERE id = %s AND client_id = %s AND stand = %s + """, (def_id, client_id, stand)) + row = cur.fetchone() + if not row: + return None + cols = [d[0] for d in cur.description] + d = dict(zip(cols, row)) + 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 + return d + except Exception as e: + print(f"[DEFS] get error: {e}", flush=True) + return None + finally: + put_conn(conn) + + +def create_definition(client_id, stand, name, steps, updated_by=""): + conn = get_conn() + if not conn: + return None + try: + cur = conn.cursor() + cur.execute(""" + INSERT INTO scenario_definitions (client_id, stand, name, steps, updated_by) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (client_id, stand, LOWER(name)) + DO UPDATE SET steps = EXCLUDED.steps, version = scenario_definitions.version + 1, + is_active = TRUE, updated_at = NOW(), updated_by = EXCLUDED.updated_by + RETURNING id, version + """, (client_id, stand, name, json.dumps(steps), updated_by)) + row = cur.fetchone() + conn.commit() + cur.close() + return {"id": row[0], "version": row[1]} + except Exception as e: + print(f"[DEFS] create error: {e}", flush=True) + conn.rollback() + return None + finally: + put_conn(conn) + + +def update_definition(def_id, client_id, stand, name, steps, version, updated_by=""): + """Возвращает dict {ok, version} или {conflict: True}.""" + conn = get_conn() + if not conn: + return {"error": "no db"} + try: + cur = conn.cursor() + cur.execute(""" + UPDATE scenario_definitions + SET name = %s, steps = %s, version = version + 1, + updated_at = NOW(), updated_by = %s + WHERE id = %s AND client_id = %s AND stand = %s AND version = %s + RETURNING version + """, (name, json.dumps(steps), updated_by, def_id, client_id, stand, version)) + row = cur.fetchone() + conn.commit() + cur.close() + if row: + return {"ok": True, "version": row[0]} + return {"conflict": True} + except Exception as e: + print(f"[DEFS] update error: {e}", flush=True) + conn.rollback() + return {"error": str(e)} + finally: + put_conn(conn) + + +def delete_definition(def_id, client_id, stand): + """Мягкое удаление.""" + conn = get_conn() + if not conn: + return False + try: + cur = conn.cursor() + cur.execute(""" + UPDATE scenario_definitions + SET is_active = FALSE, updated_at = NOW() + WHERE id = %s AND client_id = %s AND stand = %s + """, (def_id, client_id, stand)) + ok = cur.rowcount > 0 + conn.commit() + cur.close() + return ok + except Exception as e: + print(f"[DEFS] delete error: {e}", flush=True) + conn.rollback() + return False + finally: + put_conn(conn) + + +def lock_check(client_id, stand): + """Проверить нет ли активного RUNNING запуска. True = можно запускать.""" + conn = get_conn() + if not conn: + return True # без БД — разрешаем + try: + cur = conn.cursor() + cur.execute(""" + SELECT id FROM scenario_runs + WHERE client_id = %s AND stand = %s AND status = 'RUNNING' + LIMIT 1 + """, (client_id, stand)) + row = cur.fetchone() + cur.close() + return row is None + except Exception as e: + print(f"[DEFS] lock_check error: {e}", flush=True) + return True + finally: + put_conn(conn) diff --git a/site/operations/scenario.py b/site/operations/scenario.py index 0a7febe..ac63486 100644 --- a/site/operations/scenario.py +++ b/site/operations/scenario.py @@ -1,22 +1,14 @@ """ -Scenario runner — символические коды → API → шаги → результат. +Scenario runner — шаги из БД → API → результат. -Формат сценария в config.yaml: - scenarios: - dummy_test: - steps: - - svc: dummy # символическое имя (из services: в config.yaml) - op: create - params: - durationMs: "5000" - - svc: dummy - op: delete +Формат шага: + { "service_id": 1, "operation": "create", "params": { "durationMs": "5000" } } Для каждого шага: - 1. service имя → svc_id (из config.yaml services) + 1. service_id уже в шаге 2. operation имя → svcOperationId (из GET /services/{id}) 3. param коды → numeric IDs (из GET /instanceOperations/default/{opId}) - 4. create: новый инстанс; остальные: переиспользовать инстанс этого сервиса + 4. create: новый инстанс; остальные: переиспользовать по service_id 5. Выполнить через API (как в api_test) 6. Сохранить результат в runs + scenario_runs """ @@ -25,7 +17,6 @@ import json import time import uuid -from runner import load_config from operations.get_services import get_service_detail from db.pool import get_conn, put_conn from db.save_run import save_run @@ -52,25 +43,8 @@ def _uid_from_location(loc): return parts[-1] if parts[-1] else None -def load_scenarios(): - """Загрузить словарь сценариев из config.yaml.""" - cfg = load_config() or {} - return cfg.get("scenarios", {}) - - -def _resolve_service(config, svc_name): - """Найти svc_id по символическому имени сервиса из config.yaml services.""" - for s in config.get("services", []): - if s.get("name") == svc_name: - return s - raise ValueError(f"Service '{svc_name}' not found in config.yaml services") - - def _resolve_params(cfs_params, symbolic_params): - """Символические коды → {numeric_id: value}. - cfs_params — список из GET /instanceOperations/default/{opId} - symbolic_params — dict {"durationMs": "5000"} - """ + """Символические коды → {numeric_id: value}.""" code_to_id = {p.get("svcOperationCfsParam", ""): p["svcOperationCfsParamId"] for p in cfs_params} result = {} for code, val in symbolic_params.items(): @@ -102,95 +76,39 @@ def _save_scenario_run(scenario_run_id, status, current_step=0, duration_sec=Non put_conn(conn) -def _create_scenario_run(client_id, stand, user_email, scenario_name, total_steps, app_version): - """Создать запись в scenario_runs, вернуть ID.""" - conn = get_conn() - if not conn: - return None - rid = None - try: - cur = conn.cursor() - cur.execute(""" - INSERT INTO scenario_runs (client_id, stand, user_email, scenario_name, - status, current_step, total_steps, app_version) - VALUES (%s, %s, %s, %s, 'RUNNING', 0, %s, %s) - RETURNING id - """, (client_id, stand, user_email, scenario_name, total_steps, app_version)) - rid = cur.fetchone()[0] - conn.commit() - cur.close() - except Exception as e: - print(f"[SCENARIO] create_scenario_run error: {e}", flush=True) - conn.rollback() - finally: - put_conn(conn) - return rid - - -def _update_scenario_total(scenario_run_id, total_steps): - """Обновить total_steps после того как стало известно точное число шагов.""" - conn = get_conn() - if not conn: - return - try: - cur = conn.cursor() - cur.execute("UPDATE scenario_runs SET total_steps = %s WHERE id = %s", (total_steps, scenario_run_id)) - conn.commit() - cur.close() - except Exception as e: - print(f"[SCENARIO] update_total error: {e}", flush=True) - conn.rollback() - finally: - put_conn(conn) - - -def run_scenario(client, scenario_name, client_id, stand, user_email, app_version, scenario_run_id): - """Главный исполнитель сценария. Вызывается в фоновом потоке. - scenario_run_id — уже созданная запись (из api_scenario_run).""" - config = load_config() or {} - scenarios = config.get("scenarios", {}) - scenario = scenarios.get(scenario_name) - if not scenario: - _save_scenario_run(scenario_run_id, "FAIL", 0, 0, f"Scenario '{scenario_name}' not found") - return - - steps = scenario.get("steps", []) +def run_scenario(client, steps, client_id, stand, user_email, app_version, scenario_run_id, scenario_name): + """Главный исполнитель сценария. steps — список шагов из БД.""" total = len(steps) - if not steps: - _save_scenario_run(scenario_run_id, "FAIL", 0, 0, "No steps") - return - - # Обновить total_steps (стало известно только сейчас) - _update_scenario_total(scenario_run_id, total) t0 = time.time() - instance_map = {} # svc_name → instanceUid (переиспользование внутри сценария) + instance_map = {} # service_id → instanceUid (переиспользование внутри сценария) for i, step in enumerate(steps): step_num = i + 1 - svc_name = step.get("svc", "") - op_name = step.get("op", "") + svc_id = step.get("service_id") + op_name = (step.get("operation") or "").strip() symbolic_params = step.get("params", {}) try: - # 1. Сервис - svc_info = _resolve_service(config, svc_name) - svc_id = svc_info["service_id"] + if not isinstance(svc_id, int) or svc_id <= 0: + raise ValueError(f"Invalid service_id: {svc_id!r}") + if not op_name: + raise ValueError("Empty operation name") - # 2. Операция + # 1. Операция detail = get_service_detail(client, svc_id) ops = detail.get("operations", []) op = next((o for o in ops if o.get("operation") == op_name), None) if not op: - raise ValueError(f"Operation '{op_name}' not found for service {svc_name}") + raise ValueError(f"Operation '{op_name}' not found for service {svc_id}") svc_op_id = op["svcOperationId"] - # 3. Параметры + # 2. Параметры tmpl_data = client.get(f"/instanceOperations/default/{svc_op_id}") cfs_params = tmpl_data.get("svcOperation", {}).get("cfsParams", []) resolved_params = _resolve_params(cfs_params, symbolic_params) - # 4. Инстанс + # 3. Инстанс if op_name == "create": display_name = f"{AUTOTEST_PREFIX}{scenario_name}-{uuid.uuid4().hex[:6]}" descr = f"scenario {scenario_name} step {step_num}" @@ -199,20 +117,16 @@ def run_scenario(client, scenario_name, client_id, stand, user_email, app_versio instance_uid = resp.get("instanceUid") or _find_uid(resp) or _uid_from_location(resp.get("_location", "")) if not instance_uid: raise RuntimeError(f"CREATE: no instanceUid in response: {json.dumps(resp)[:200]}") - instance_map[svc_name] = instance_uid + instance_map[svc_id] = instance_uid else: - instance_uid = instance_map.get(svc_name) + instance_uid = instance_map.get(svc_id) if not instance_uid: - raise RuntimeError(f"No instance for service '{svc_name}' — need CREATE first") + raise RuntimeError(f"No instance for service_id {svc_id} — need CREATE first") - # 5. Запуск операции + # 4. Запуск операции op_payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id, "operation": op_name} op_resp = client.post("/instanceOperations", op_payload) - op_uid = op_resp.get("instanceOperationUid") or op_resp.get("instanceOperation", {}).get("instanceOperationUid") - if not op_uid: - op_uid = _find_uid(op_resp) - if not op_uid: - op_uid = _uid_from_location(op_resp.get("_location", "")) + op_uid = op_resp.get("instanceOperationUid") or _find_uid(op_resp) or _uid_from_location(op_resp.get("_location", "")) if not op_uid: raise RuntimeError(f"No opUid in response: {json.dumps(op_resp)[:200]}") @@ -221,7 +135,7 @@ def run_scenario(client, scenario_name, client_id, stand, user_email, app_versio _send_params_terraform(client, op_uid, resolved_params) client.post(f"/instanceOperations/{op_uid}/run") - # 5b. Сохранить RUNNING в runs ДО поллинга (шаг не потеряется при ошибке) + # 5. Сохранить RUNNING в runs ДО поллинга step_display = display_name if op_name == "create" else instance_uid try: save_run(client_id, stand, user_email, @@ -240,7 +154,7 @@ def run_scenario(client, scenario_name, client_id, stand, user_email, app_versio step_error = "" step_duration = 0 step_stages = [] - op_data = {} # инициализировать на случай TIMEOUT + op_data = {} while time.time() < deadline: try: @@ -260,8 +174,8 @@ def run_scenario(client, scenario_name, client_id, stand, user_email, app_versio break time.sleep(5) - # 7. Сохранить финальный результат шага в runs - svc_final = op_data.get("svc", svc_name) if op_data else svc_name + # 7. Сохранить финальный результат + svc_final = op_data.get("svc", "") if op_data else "" try: save_run(client_id, stand, user_email, svc_id, svc_final, op_name, svc_op_id, @@ -278,7 +192,6 @@ def run_scenario(client, scenario_name, client_id, stand, user_email, app_versio step_error if step_status != "OK" else None) if step_status != "OK": - # Прервать сценарий при ошибке _save_scenario_run(scenario_run_id, step_status, step_num, round(time.time() - t0, 1), step_error) return diff --git a/site/routes/api_scenario.py b/site/routes/api_scenario.py index 0b60ccb..0bd70be 100644 --- a/site/routes/api_scenario.py +++ b/site/routes/api_scenario.py @@ -1,66 +1,105 @@ """ -API для запуска сценариев автотестирования. +API сценариев: CRUD определений + запуск + поллинг. -POST /api/scenario/run — запустить сценарий (фон) -GET /api/scenario/status/ — поллинг статуса сценария -GET /api/scenarios — список доступных сценариев -GET /api/scenario/history — история запусков сценариев +GET /api/scenarios — список из БД (для UI) +POST /api/scenario/run — запуск по definition_id (409 если уже RUNNING) +GET /api/scenario/run/ — статус конкретного запуска +GET /api/scenario/status — последние 10 запусков + +CRUD определений: +GET /api/scenario/definitions — список +POST /api/scenario/definitions — создать +GET /api/scenario/definitions/ — один +PUT /api/scenario/definitions/ — обновить (version → 409) +DELETE /api/scenario/definitions/ — мягкое удаление """ from flask import Blueprint, current_app, jsonify, request import threading from api.auth import get_client, get_client_id, get_stand, get_token_info -from operations.scenario import load_scenarios, run_scenario, _create_scenario_run +from operations.scenario import run_scenario from db.pool import get_conn, put_conn +from db.scenario_defs import ( + list_definitions, get_definition, create_definition, + update_definition, delete_definition, lock_check, +) bp = Blueprint("api_scenario", __name__) +# ── Запуск ────────────────────────────────────────────── + @bp.route("/api/scenarios") def api_scenarios(): - """Список доступных сценариев из config.yaml.""" + """Список определений сценариев из БД (для UI запуска).""" try: - scenarios = load_scenarios() - result = [{"name": name, "steps": len(s.get("steps", []))} for name, s in scenarios.items()] - return jsonify(sorted(result, key=lambda x: x["name"])) + defs = list_definitions(get_client_id(), get_stand()) + result = [{"id": d["id"], "name": d["name"], "steps": len(d.get("steps", []))} for d in defs] + return jsonify(result) except Exception as e: return jsonify({"error": str(e)}), 500 @bp.route("/api/scenario/run", methods=["POST"]) def api_scenario_run(): - """Запустить сценарий. Создаёт запись ДО потока, возвращает run_id (202).""" + """Запустить сценарий по definition_id. 409 если уже RUNNING.""" try: data = request.get_json() - scenario_name = (data.get("scenario") or "").strip() - if not scenario_name: - return jsonify({"error": "scenario name is required"}), 400 + def_id = data.get("definition_id") + if not def_id: + return jsonify({"error": "definition_id is required"}), 400 - client = get_client() cid = get_client_id() stand = get_stand() + + # Lock 409 + if not lock_check(cid, stand): + return jsonify({"error": "Another scenario is already running"}), 409 + + # Загрузить определение + defn = get_definition(def_id, cid, stand) + if not defn: + return jsonify({"error": "Definition not found"}), 404 + + steps = defn.get("steps", []) + if not steps: + return jsonify({"error": "Definition has no steps"}), 400 + + scenario_name = defn["name"] user_email = get_token_info().get("email", "") app_version = current_app.config.get("VERSION", "") - # Валидация: сценарий существует? - scenarios = load_scenarios() - if scenario_name not in scenarios: - return jsonify({"error": f"Scenario '{scenario_name}' not found"}), 404 - - total = len(scenarios[scenario_name].get("steps", [])) - if total == 0: - return jsonify({"error": f"Scenario '{scenario_name}' has no steps"}), 400 - - # Создать запись ДО потока — вернуть run_id клиенту - run_id = _create_scenario_run(cid, stand, user_email, scenario_name, total, app_version) - if run_id is None: + # Создать запись ДО потока + conn = get_conn() + if not conn: + return jsonify({"error": "no db"}), 500 + run_id = None + try: + cur = conn.cursor() + cur.execute(""" + INSERT INTO scenario_runs (client_id, stand, user_email, scenario_name, + status, current_step, total_steps, app_version, + definition_id, definition_version) + VALUES (%s, %s, %s, %s, 'RUNNING', 0, %s, %s, %s, %s) + RETURNING id + """, (cid, stand, user_email, scenario_name, len(steps), app_version, + def_id, defn["version"])) + run_id = cur.fetchone()[0] + conn.commit() + cur.close() + except Exception as e: + print(f"[API] create scenario_run error: {e}", flush=True) + conn.rollback() return jsonify({"error": "Failed to create scenario_run"}), 500 + finally: + put_conn(conn) # Запуск в фоне + client = get_client() threading.Thread( target=run_scenario, - args=(client, scenario_name, cid, stand, user_email, app_version, run_id), + args=(client, steps, cid, stand, user_email, app_version, run_id, scenario_name), daemon=True, ).start() @@ -73,7 +112,7 @@ def api_scenario_run(): @bp.route("/api/scenario/run/") def api_scenario_run_status(run_id): - """Статус конкретного запуска сценария.""" + """Статус конкретного запуска.""" conn = get_conn() if not conn: return jsonify({"error": "no db"}), 500 @@ -101,7 +140,7 @@ def api_scenario_run_status(run_id): @bp.route("/api/scenario/status") def api_scenario_status(): - """Последний запущенный сценарий (или несколько последних).""" + """Последние 10 запусков.""" conn = get_conn() if not conn: return jsonify([]) @@ -128,3 +167,75 @@ def api_scenario_status(): return jsonify({"error": str(e)}), 500 finally: put_conn(conn) + + +# ── CRUD определений ──────────────────────────────────── + +@bp.route("/api/scenario/definitions", methods=["GET", "POST"]) +def api_definitions(): + cid = get_client_id() + stand = get_stand() + + if request.method == "GET": + try: + defs = list_definitions(cid, stand) + return jsonify(defs) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + if request.method == "POST": + try: + data = request.get_json() + name = (data.get("name") or "").strip() + if not name: + return jsonify({"error": "name is required"}), 400 + steps = data.get("steps", []) + if not isinstance(steps, list): + return jsonify({"error": "steps must be array"}), 400 + user = get_token_info().get("email", "") + result = create_definition(cid, stand, name, steps, user) + if result is None: + return jsonify({"error": "create failed"}), 500 + return jsonify(result), 201 + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@bp.route("/api/scenario/definitions/", methods=["GET", "PUT", "DELETE"]) +def api_definition(def_id): + cid = get_client_id() + stand = get_stand() + + if request.method == "GET": + d = get_definition(def_id, cid, stand) + if not d: + return jsonify({"error": "not found"}), 404 + return jsonify(d) + + if request.method == "PUT": + try: + data = request.get_json() + name = (data.get("name") or "").strip() + version = data.get("version") + if not name: + return jsonify({"error": "name is required"}), 400 + if not isinstance(version, int): + return jsonify({"error": "version (int) is required"}), 400 + steps = data.get("steps", []) + if not isinstance(steps, list): + return jsonify({"error": "steps must be array"}), 400 + user = get_token_info().get("email", "") + result = update_definition(def_id, cid, stand, name, steps, version, user) + if result.get("conflict"): + return jsonify({"error": "Conflict: definition was modified"}), 409 + if result.get("error"): + return jsonify({"error": result["error"]}), 500 + return jsonify(result) + except Exception as e: + return jsonify({"error": str(e)}), 500 + + if request.method == "DELETE": + ok = delete_definition(def_id, cid, stand) + if not ok: + return jsonify({"error": "not found"}), 404 + return jsonify({"ok": True}) diff --git a/site/scenario_seed.yaml b/site/scenario_seed.yaml new file mode 100644 index 0000000..5a05c07 --- /dev/null +++ b/site/scenario_seed.yaml @@ -0,0 +1,15 @@ +# Seed-сценарии — импортируются в БД при первом старте. +# INSERT ON CONFLICT DO NOTHING — безопасно для повторных запусков. +# После rollout файл можно удалить из репозитория. +scenarios: + - name: dummy_test + client_id: "" + stand: "" + steps: + - service_id: 1 + operation: create + params: + durationMs: "5000" + - service_id: 1 + operation: delete + params: {} diff --git a/site/static/app.js b/site/static/app.js index 2dc3d08..24e4115 100644 --- a/site/static/app.js +++ b/site/static/app.js @@ -491,11 +491,11 @@ async function loadScenarios(){ if(sr.length){ html+='
'; sr.forEach(s=>{ - html+=``; + html+=``; }); html+='
'; }else{ - html+='Нет сценариев в config.yaml'; + html+='Нет сценариев в БД'; } // Последний запуск if(srHistory && srHistory.length){ @@ -509,14 +509,14 @@ async function loadScenarios(){ }catch(e){body.innerHTML='Ошибка загрузки';} } -async function runScenario(name){ +async function runScenario(defId){ if(busy){ return; } busy=true; stopScenarioPoll(); const body=document.getElementById('scenario-body'); - body.innerHTML=`
⏳ Запуск сценария ${_esc(name)}...
`; + body.innerHTML=`
⏳ Запуск...
`; try{ - const r=await fetch('/api/scenario/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scenario:name})}); + 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; } const runId=d.run_id;