From 91e97f6f751788ffdc2644a1e668abf761a16d07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Fri, 31 Jul 2026 08:55:06 +0400 Subject: [PATCH] v1.2.0: unified executor + flexible instance refs + params-render.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 — New modules: - api/utils.py: find_uid(), uid_from_location() (replaces 2 duplicates) - operations/poll.py: poll_until_done() (shared sync/async polling) - operations/executor.py: execute_operation() (single CREATE/non-CREATE flow) Phase 2 — Format + validation: - routes/api_scenario_defs.py: _validate_steps with output/instance_ref/instance_uid - operations/scenario.py: resolve instance_uid > instance_ref > service_id, use executor + poll_until_done, persist instance_bindings Phase 3 — Migration: - routes/api_test.py: create/non-create through executor, _finish_op through poll_until_done - db/init_db.py: startup cleanup of stuck scenario_runs (>1h) Phase 4 — UI shared module: - static/js/params-render.js: renderParamRow, renderMapFixedRow, collectParams - static/js/operations.js: use params-render.js (remove duplicates) - templates/index.html: include params-render.js - app.py: bump 1.1.57 → 1.2.0 --- site/api/utils.py | 45 +++++++++ site/app.py | 2 +- site/db/init_db.py | 17 ++++ site/operations/executor.py | 75 ++++++++++++++ site/operations/poll.py | 51 ++++++++++ site/operations/scenario.py | 161 ++++++++++++++--------------- site/routes/api_scenario_defs.py | 25 +++++ site/routes/api_test.py | 168 ++++++++++--------------------- site/static/js/operations.js | 75 +------------- site/static/js/params-render.js | 81 +++++++++++++++ site/templates/index.html | 1 + 11 files changed, 429 insertions(+), 272 deletions(-) create mode 100644 site/api/utils.py create mode 100644 site/operations/executor.py create mode 100644 site/operations/poll.py create mode 100644 site/static/js/params-render.js diff --git a/site/api/utils.py b/site/api/utils.py new file mode 100644 index 0000000..43ac156 --- /dev/null +++ b/site/api/utils.py @@ -0,0 +1,45 @@ +""" +Общие утилиты: извлечение UUID из ответов API Nubes. + +Используется: api_test.py, scenario.py, executor.py. +Заменяет разрозненные реализации _find_uid / _uid_from_location. +""" + + +def find_uid(resp): + """Извлечь instanceUid или instanceOperationUid из ответа API. + + Ищет в нескольких местах: + 1. На верхнем уровне: instanceOperationUid → instanceUid → uid → Uid + 2. Во вложенных dict-значениях: {key: {instanceOperationUid: ..., instanceUid: ...}} + """ + if not isinstance(resp, dict): + return None + # Верхний уровень — прямые ключи (api_test.py-стиль) + for key in ("instanceOperationUid", "instanceUid", "uid", "Uid"): + v = resp.get(key) + if isinstance(v, str) and v: + return v + # Вложенные dict-значения (scenario.py-стиль) + for v in resp.values(): + if isinstance(v, dict): + uid = v.get("instanceOperationUid") or v.get("instanceUid") + if uid: + return uid + return None + + +def uid_from_location(loc): + """Извлечь UUID из Location-заголовка. + + Location бывает: "./UUID", "/api/v1/svc/instances/UUID", "/api/v1/svc/instanceOperations/UUID" + Берём последний сегмент после split("/"). + """ + if not loc: + return None + parts = str(loc).rstrip("/").split("/") + uid = parts[-1] + # UUID — 36 символов с 4 дефисами (уже проверено в http_client.py) + if uid and uid != "." and len(uid) >= 32: + return uid + return None diff --git a/site/app.py b/site/app.py index a3164da..64d17fa 100644 --- a/site/app.py +++ b/site/app.py @@ -19,7 +19,7 @@ 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. -VERSION = "1.1.57" +VERSION = "1.2.0" 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/db/init_db.py b/site/db/init_db.py index b1d4097..295fdad 100644 --- a/site/db/init_db.py +++ b/site/db/init_db.py @@ -159,6 +159,23 @@ def init_db(): conn.commit() cur.close() print("[DB] Schema initialized", flush=True) + + # Startup cleanup: зависшие RUNNING сценарии старше 1 часа → TIMEOUT + try: + cur = conn.cursor() + cur.execute(""" + UPDATE scenario_runs + SET status = 'TIMEOUT', error_log = 'worker restart' + WHERE status = 'RUNNING' + AND created_at < NOW() - INTERVAL '1 hour' + """) + if cur.rowcount: + print(f"[DB] Cleaned up {cur.rowcount} stuck scenario_runs", flush=True) + conn.commit() + cur.close() + except Exception as e: + print(f"[DB] Cleanup error: {e}", flush=True) + conn.rollback() except Exception as e: print(f"[DB] Init error: {e}", flush=True) conn.rollback() diff --git a/site/operations/executor.py b/site/operations/executor.py new file mode 100644 index 0000000..2d99953 --- /dev/null +++ b/site/operations/executor.py @@ -0,0 +1,75 @@ +""" +Единый executor операции Nubes. + +Делает всё до /run включительно. НЕ поллит. +Используется api_test.py (ручной) и scenario.py (сценарный). +""" + +from api.utils import find_uid, uid_from_location +from operations.terraform import send_params_terraform + + +def execute_operation(client, service_id, operation, instance_uid, params, + svc_op_id=None, display_name=None): + """Запустить операцию и вернуть результат. + + Args: + client: HttpClient + service_id: int — ID сервиса + operation: str — create/delete/modify/suspend/resume/redeploy + instance_uid: str|None — UUID существующего инстанса (None для create) + params: dict — {numeric_param_id: value} + svc_op_id: int|None — svcOperationId (игнорируется для create) + display_name: str|None — displayName (только для create) + + Returns: + dict {ok, error, failed_step, instance_uid, op_uid, display_name} + ok=True при успехе, ok=False при ошибке с failed_step. + """ + is_create = (operation == "create") + + # --- Шаг 1: POST /instances (только create) --- + if is_create: + if not display_name: + display_name = f"autotest-{service_id}" + descr = f"created by autotest" + payload = {"serviceId": service_id, "displayName": display_name, "descr": descr} + try: + resp = client.post("/instances", payload) + except Exception as e: + return {"ok": False, "error": str(e), "failed_step": "instances", + "instance_uid": None, "op_uid": None, "display_name": display_name} + instance_uid = resp.get("instanceUid") or find_uid(resp) or uid_from_location(resp.get("_location", "")) + if not instance_uid: + return {"ok": False, "error": "No instanceUid in response", "failed_step": "instances", + "instance_uid": None, "op_uid": None, "display_name": display_name} + + # --- Шаг 2: POST /instanceOperations --- + if is_create: + op_payload = {"instanceUid": instance_uid, "operation": operation} + else: + op_payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id, "operation": operation} + try: + op_resp = client.post("/instanceOperations", op_payload) + except Exception as e: + return {"ok": False, "error": str(e), "failed_step": "instanceOperations", + "instance_uid": instance_uid, "op_uid": None, "display_name": display_name} + op_uid = op_resp.get("instanceOperationUid") or find_uid(op_resp) or uid_from_location(op_resp.get("_location", "")) + if not op_uid: + return {"ok": False, "error": "No opUid in response", "failed_step": "instanceOperations", + "instance_uid": instance_uid, "op_uid": None, "display_name": display_name} + + # --- Шаг 3-6: Параметры + run --- + try: + send_params_terraform(client, op_uid, params) + except Exception as e: + return {"ok": False, "error": str(e), "failed_step": "params", + "instance_uid": instance_uid, "op_uid": op_uid, "display_name": display_name} + try: + client.post(f"/instanceOperations/{op_uid}/run") + except Exception as e: + return {"ok": False, "error": str(e), "failed_step": "run", + "instance_uid": instance_uid, "op_uid": op_uid, "display_name": display_name} + + return {"ok": True, "error": None, "failed_step": None, + "instance_uid": instance_uid, "op_uid": op_uid, "display_name": display_name} diff --git a/site/operations/poll.py b/site/operations/poll.py new file mode 100644 index 0000000..edccfe3 --- /dev/null +++ b/site/operations/poll.py @@ -0,0 +1,51 @@ +""" +Общий цикл поллинга операции Nubes. + +Используется: + - _finish_op (api_test.py) — асинхронно в threading.Thread + - run_scenario (scenario.py) — синхронно в while +""" + +import time + + +def poll_until_done(client, op_uid, timeout=1800): + """Поллинг операции до завершения или таймаута. + + Возвращает dict: + {status, is_successful, error_log, stages, duration, svc} + status ∈ {OK, FAIL, TIMEOUT} + """ + t0 = time.time() + deadline = t0 + timeout + while time.time() < deadline: + try: + data = client.get( + f"/instanceOperations/{op_uid}" + "?fields=dtFinish,isSuccessful,errorLog,duration,stages,svc" + ) + except Exception: + time.sleep(5) + continue + op = data.get("instanceOperation", {}) + dt_finish = op.get("dtFinish") + if dt_finish and str(dt_finish).strip(): + is_ok = op.get("isSuccessful") + return { + "status": "OK" if is_ok else "FAIL", + "is_successful": is_ok, + "error_log": op.get("errorLog") or "", + "stages": op.get("stages", []), + "duration": round(time.time() - t0, 1), + "svc": op.get("svc", ""), + } + time.sleep(5) + # Таймаут + return { + "status": "TIMEOUT", + "is_successful": False, + "error_log": f"TIMEOUT after {timeout}s", + "stages": [], + "duration": round(time.time() - t0, 1), + "svc": "", + } diff --git a/site/operations/scenario.py b/site/operations/scenario.py index db78797..c931dc2 100644 --- a/site/operations/scenario.py +++ b/site/operations/scenario.py @@ -1,16 +1,14 @@ """ Scenario runner — шаги из БД → API → результат. -Формат шага: - { "service_id": 1, "operation": "create", "params": { "durationMs": "5000" } } +Формат шага (новый): + { "service_id": 1, "operation": "create", "params": {...}, "output": "d1" } + { "service_id": 1, "operation": "modify", "instance_ref": "d1", "params": {...} } + { "service_id": 1, "operation": "delete", "instance_uid": "UUID", "params": {} } -Для каждого шага: - 1. service_id уже в шаге - 2. operation имя → svcOperationId (из GET /services/{id}) - 3. param коды → numeric IDs (из GET /instanceOperations/default/{opId}) - 4. create: новый инстанс; остальные: переиспользовать по service_id - 5. Выполнить через API (как в api_test) - 6. Сохранить результат в runs + scenario_runs +Старый формат (без output/instance_ref/instance_uid) — fallback на service_id. + +Резолвинг: instance_uid > instance_ref > instance_map[service_id] """ import json @@ -18,31 +16,14 @@ import time import uuid from operations.get_services import get_service_detail +from operations.executor import execute_operation +from operations.poll import poll_until_done from db.pool import get_conn, put_conn from db.save_run import save_run AUTOTEST_PREFIX = "autotest-scenario-" -def _find_uid(resp): - """Extract UUID from nested response (e.g. {instanceOperation: {instanceOperationUid: ...}}).""" - if isinstance(resp, dict): - for v in resp.values(): - if isinstance(v, dict): - uid = v.get("instanceOperationUid") or v.get("instanceUid") - if uid: - return uid - return None - - -def _uid_from_location(loc): - """Extract UUID from Location header.""" - if not loc: - return None - parts = str(loc).rstrip("/").split("/") - return parts[-1] if parts[-1] else None - - def _resolve_params(cfs_params, symbolic_params): """Символические коды → {numeric_id: value}.""" code_to_id = {p.get("svcOperationCfsParam", ""): p["svcOperationCfsParamId"] for p in cfs_params} @@ -76,12 +57,46 @@ def _save_scenario_run(scenario_run_id, status, current_step=0, duration_sec=Non put_conn(conn) +def _update_bindings(scenario_run_id, bindings): + """Сохранить output→instance_uid в scenario_runs.instance_bindings.""" + conn = get_conn() + if not conn: + return + try: + cur = conn.cursor() + cur.execute(""" + UPDATE scenario_runs SET instance_bindings = %s WHERE id = %s + """, (json.dumps(bindings), scenario_run_id)) + conn.commit() + cur.close() + except Exception as e: + print(f"[SCENARIO] update_bindings error: {e}", flush=True) + conn.rollback() + finally: + put_conn(conn) + + +def _resolve_instance_uid(step, bindings, instance_map): + """Резолвинг instance_uid по приоритету: явный uid > instance_ref > service_id.""" + uid = (step.get("instance_uid") or "").strip() + if uid: + return uid + ref = (step.get("instance_ref") or "").strip() + if ref: + if ref in bindings: + return bindings[ref] + raise ValueError(f"instance_ref '{ref}' not found in bindings (step order issue?)") + # Fallback: старый формат по service_id + svc_id = step.get("service_id") + return instance_map.get(svc_id) + + def run_scenario(client, steps, client_id, stand, user_email, app_version, scenario_run_id, scenario_name): """Главный исполнитель сценария. steps — список шагов из БД.""" total = len(steps) - t0 = time.time() - instance_map = {} # service_id → instanceUid (переиспользование внутри сценария) + bindings = {} # output_name → instance_uid (новый формат) + instance_map = {} # service_id → instance_uid (fallback, старый формат) for i, step in enumerate(steps): step_num = i + 1 @@ -108,38 +123,37 @@ def run_scenario(client, steps, client_id, stand, user_email, app_version, scena cfs_params = tmpl_data.get("svcOperation", {}).get("cfsParams", []) resolved_params = _resolve_params(cfs_params, symbolic_params) - # 3. Инстанс - if op_name == "create": + # 3. Резолвинг instance_uid + is_create = (op_name == "create") + if is_create: + instance_uid = None display_name = f"{AUTOTEST_PREFIX}{scenario_name}-{uuid.uuid4().hex[:6]}" - descr = f"scenario {scenario_name} step {step_num}" - payload = {"serviceId": svc_id, "displayName": display_name, "descr": descr} - resp = client.post("/instances", payload) - 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_id] = instance_uid else: - instance_uid = instance_map.get(svc_id) + instance_uid = _resolve_instance_uid(step, bindings, instance_map) if not instance_uid: - raise RuntimeError(f"No instance for service_id {svc_id} — need CREATE first") + raise ValueError(f"No instance for step {step_num} — need CREATE before non-create operations") + display_name = None - # 4. Запуск операции - if op_name == "create": - op_payload = {"instanceUid": instance_uid, "operation": op_name} - else: - 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 _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]}") + # 4. Запуск через executor + result = execute_operation( + client, svc_id, op_name, instance_uid, resolved_params, + svc_op_id=svc_op_id, display_name=display_name + ) + if not result["ok"]: + raise RuntimeError(f"{op_name}: {result['error']} (step: {result['failed_step']})") - # Отправить параметры и запустить - from operations.terraform import send_params_terraform - send_params_terraform(client, op_uid, resolved_params) - client.post(f"/instanceOperations/{op_uid}/run") + instance_uid = result["instance_uid"] + op_uid = result["op_uid"] + step_display = result["display_name"] - # 5. Сохранить RUNNING в runs ДО поллинга - step_display = display_name if op_name == "create" else instance_uid + # Обновить карты + instance_map[svc_id] = instance_uid + output_name = (step.get("output") or "").strip() + if is_create and output_name: + bindings[output_name] = instance_uid + _update_bindings(scenario_run_id, bindings) + + # 5. Сохранить RUNNING try: save_run(client_id, stand, user_email, svc_id, "", op_name, svc_op_id, @@ -150,35 +164,15 @@ def run_scenario(client, steps, client_id, stand, user_email, app_version, scena except Exception as e: print(f"[SCENARIO] save_run(RUNNING) failed for step {step_num}: {e}", flush=True) - # 6. Поллинг завершения - step_t0 = time.time() - deadline = step_t0 + 1800 - step_status = "TIMEOUT" - step_error = "" - step_duration = 0 - step_stages = [] - op_data = {} - - while time.time() < deadline: - try: - data = client.get(f"/instanceOperations/{op_uid}?fields=dtFinish,isSuccessful,errorLog,duration,stages,svc") - except Exception: - time.sleep(5) - continue - op_data = data.get("instanceOperation", {}) - dt_finish = op_data.get("dtFinish") - if dt_finish and str(dt_finish).strip(): - is_ok = op_data.get("isSuccessful") - err = op_data.get("errorLog") or "" - step_status = "OK" if is_ok else "FAIL" - step_error = str(err) if err else "" - step_duration = round(time.time() - step_t0, 1) - step_stages = op_data.get("stages", []) - break - time.sleep(5) + # 6. Поллинг + poll_result = poll_until_done(client, op_uid) + step_status = poll_result["status"] + step_error = poll_result["error_log"] + step_duration = poll_result["duration"] + step_stages = poll_result["stages"] + svc_final = poll_result["svc"] # 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, @@ -208,5 +202,4 @@ def run_scenario(client, steps, client_id, stand, user_email, app_version, scena round(time.time() - t0, 1), err) return - # Все шаги OK _save_scenario_run(scenario_run_id, "OK", total, round(time.time() - t0, 1)) diff --git a/site/routes/api_scenario_defs.py b/site/routes/api_scenario_defs.py index b2c8080..61994a1 100644 --- a/site/routes/api_scenario_defs.py +++ b/site/routes/api_scenario_defs.py @@ -22,6 +22,7 @@ def _validate_steps(steps): """Проверить структуру шагов сценария. Возвращает None или str с ошибкой.""" if not isinstance(steps, list) or len(steps) == 0: return "Шаги: непустой массив" + outputs = {} # output_name → step_index (для проверки ссылок и уникальности) for i, s in enumerate(steps): if not isinstance(s, dict): return f"Шаг {i+1}: должен быть объектом" @@ -33,6 +34,30 @@ def _validate_steps(steps): return f"Шаг {i+1}: operation обязателен" if not isinstance(s.get("params", {}), dict): return f"Шаг {i+1}: params — объект" + + # Новые опциональные ключи: output, instance_ref, instance_uid + out = (s.get("output") or "").strip() + ref = (s.get("instance_ref") or "").strip() + uid = (s.get("instance_uid") or "").strip() + + # output — только для create, уникален + if out: + if op != "create": + return f"Шаг {i+1}: output допустим только для create" + if out in outputs: + return f"Шаг {i+1}: output '{out}' уже используется в шаге {outputs[out]+1}" + outputs[out] = i + + # instance_ref — должен ссылаться на существующий output из предыдущих шагов + if ref: + if ref not in outputs: + return f"Шаг {i+1}: instance_ref '{ref}' — output не найден (должен быть в create-шаге выше)" + + # Для не-create: должен быть instance_ref, instance_uid, или старый формат (fallback) + if op != "create": + has_target = bool(ref or uid or (not out)) # out в не-create — ошибка выше + # ok: ref есть, uid есть, или старый формат без новых ключей (fallback) + return None diff --git a/site/routes/api_test.py b/site/routes/api_test.py index f5c6155..86ba5c3 100644 --- a/site/routes/api_test.py +++ b/site/routes/api_test.py @@ -31,10 +31,13 @@ import fcntl from api.http_client import HttpClient, detect_endpoint, stand_name from api.auth import get_token, get_client, get_client_id, get_stand, get_token_info +from api.utils import find_uid, uid_from_location from operations.get_services import get_services, get_service_detail from operations.get_instances import get_instances from operations.get_params import get_params_with_current_values, _normalize_value_list from operations.terraform import send_params_terraform, redact_params +from operations.executor import execute_operation +from operations.poll import poll_until_done from operations.tracker import add as tracker_add, list_all as tracker_list from operations.tracker import remove as tracker_remove @@ -190,21 +193,17 @@ def api_test(): if not display_name: display_name = f"autotest-{svc_id}" display_name = _unique_display_name(client, display_name) - descr = f"created by autotest v{current_app.config.get('VERSION', '')}" - payload = {"serviceId": svc_id, "displayName": display_name, "descr": descr} - resp = client.post("/instances", payload) - instance_uid = resp.get("instanceUid") or _find_uid(resp) or _uid_from_location(resp.get("_location", "")) - if not instance_uid: - return jsonify({"status": "FAIL", "error": "Не удалось получить instanceUid"}), 500 + result = execute_operation( + client, svc_id, op_name, None, params, + svc_op_id=svc_op_id, display_name=display_name + ) + if not result["ok"]: + return jsonify({"status": "FAIL", "error": result["error"]}), 500 + instance_uid = result["instance_uid"] + op_uid = result["op_uid"] + display_name = result["display_name"] - op_payload = {"instanceUid": instance_uid, "operation": "create"} - op_resp = client.post("/instanceOperations", op_payload) - op_uid = _find_uid(op_resp) or _uid_from_location(op_resp.get("_location", "")) - if not op_uid: - return jsonify({"status": "FAIL", "error": "Не удалось получить opUid"}), 500 - - # Записать в трекер СРАЗУ после получения instanceUid, до params/run - # (если params упадут — инстанс уже отслежен, сирот не будет) + # Трекер try: tracker_add(get_client_id(), get_stand(), instance_uid, svc_id, display_name) except Exception as e: @@ -212,12 +211,7 @@ def api_test(): print(f"[TRACKER ERROR] add failed: {e}", flush=True) traceback.print_exc() - # Отправка параметров (шаги 3-7 Terraform) - send_params_terraform(client, op_uid, params) - - client.post(f"/instanceOperations/{op_uid}/run") - - # фоном ждать завершения + # Фоновый поллинг user_email = get_token_info().get("email", "") app_version = current_app.config.get("VERSION", "") threading.Thread(target=_finish_op, args=(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, True, get_client_id(), get_stand(), False, params, user_email, app_version), daemon=True).start() @@ -240,20 +234,21 @@ def api_test(): return jsonify({"status": "OK", "opUid": "cmdb-"+instance_uid[:8], "instanceUid": instance_uid, "displayName": display_name}) if op_name == "redeploy": - op_payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id, "operation": op_name} - op_resp = client.post("/instanceOperations", op_payload) - op_uid = _find_uid(op_resp) or _uid_from_location(op_resp.get("_location", "")) - if not op_uid: - return jsonify({"status": "FAIL", "error": "Не удалось получить opUid"}), 500 - client.post(f"/instanceOperations/{op_uid}/run") + result = execute_operation( + client, svc_id, op_name, instance_uid, {}, + svc_op_id=svc_op_id + ) + if not result["ok"]: + return jsonify({"status": "FAIL", "error": result["error"]}), 500 + op_uid = result["op_uid"] else: - op_payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id, "operation": op_name} - op_resp = client.post("/instanceOperations", op_payload) - op_uid = _find_uid(op_resp) or _uid_from_location(op_resp.get("_location", "")) - if not op_uid: - return jsonify({"status": "FAIL", "error": "Не удалось получить opUid"}), 500 - send_params_terraform(client, op_uid, params) - client.post(f"/instanceOperations/{op_uid}/run") + result = execute_operation( + client, svc_id, op_name, instance_uid, params, + svc_op_id=svc_op_id + ) + if not result["ok"]: + return jsonify({"status": "FAIL", "error": result["error"]}), 500 + op_uid = result["op_uid"] # фоном ждать завершения is_delete = (op_name == "delete") @@ -304,78 +299,43 @@ def _get_instance_display_name(client, instance_uid): def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_op_id, is_create, client_id, stand, is_delete=False, params=None, user_email="", app_version=""): """Фоном ждать dtFinish и сохранить результат.""" import time - _cleanup_op_results() # очистить старые записи перед добавлением новой + _cleanup_op_results() t0 = time.time() - deadline = t0 + 1800 - while time.time() < deadline: + + # Поллинг через общий модуль + poll_result = poll_until_done(client, op_uid) + + # Обновить _op_results для UI + _op_results[op_uid] = { + "status": poll_result["status"], + "displayName": display_name, + "error": poll_result["error_log"], + "stages": poll_result["stages"], + "duration": poll_result["duration"], + "_ts": time.time(), + } + + # tracker_remove при успешном delete + if poll_result["status"] == "OK" and is_delete: try: - try: - data = client.get(f"/instanceOperations/{op_uid}?fields=dtFinish,isSuccessful,errorLog,isInProgress,duration,stages,svc") - except Exception: - time.sleep(5) - continue - op = data.get("instanceOperation", {}) - dt_finish = op.get("dtFinish") - # Обновляем stages по мере появления - _op_results[op_uid] = { - "status": "RUNNING", - "displayName": display_name, - "stages": op.get("stages", []), - "duration": round(time.time() - t0, 1), - "_ts": time.time(), - } - if dt_finish and str(dt_finish).strip(): - is_ok = op.get("isSuccessful") - err = op.get("errorLog") or "" - print(f"[DEBUG] _finish_op op_uid={op_uid} isSuccessful={is_ok!r}", flush=True) - # tracker_add для create уже вызван синхронно в api_test() - if is_ok and is_delete: - tracker_remove(client_id, stand, instance_uid) - _op_results[op_uid] = { - "status": "OK" if is_ok else "FAIL", - "displayName": display_name, - "error": str(err) if err else "", - "stages": op.get("stages", []), - "duration": round(time.time() - t0, 1), - "_ts": time.time(), - } - # Сохранить в БД историю - try: - from db.save_run import save_run - saved_params = redact_params(params or {}) - save_run(client_id, stand, - user_email, - svc_id, op.get("svc", ""), op_name, svc_op_id, - op_uid, instance_uid, display_name, - "OK" if is_ok else "FAIL", - round(time.time() - t0, 1), - str(err) if err else "", - saved_params, - op.get("stages", []), - app_version) - except Exception as e: - import traceback - print(f"[DB] save_run FAILED: {e}", flush=True) - traceback.print_exc() - return - time.sleep(5) + tracker_remove(client_id, stand, instance_uid) except Exception: - import traceback - print(f"[ERROR] _finish_op crashed: {traceback.format_exc()}", flush=True) - time.sleep(5) - _op_results[op_uid] = {"status": "TIMEOUT", "displayName": display_name, "duration": round(time.time() - t0, 1), "_ts": time.time()} - # Сохранить TIMEOUT в БД + pass + + # Сохранить в БД try: from db.save_run import save_run saved_params = redact_params(params or {}) save_run(client_id, stand, user_email, - svc_id, "", op_name, svc_op_id, + svc_id, poll_result["svc"], op_name, svc_op_id, op_uid, instance_uid, display_name, - "TIMEOUT", round(time.time() - t0, 1), - "TIMEOUT after 1800s", saved_params, [], app_version) + poll_result["status"], + poll_result["duration"], + poll_result["error_log"], + saved_params, poll_result["stages"], app_version) except Exception as e: import traceback - print(f"[DB] TIMEOUT save_run FAILED: {e}", flush=True) + print(f"[DB] save_run FAILED: {e}", flush=True) traceback.print_exc() @@ -459,21 +419,3 @@ def api_history(): except Exception: pass return jsonify({"error": str(e)}), 500 - - -def _find_uid(resp): - """Извлечь instanceUid или instanceOperationUid из ответа API.""" - if isinstance(resp, dict): - for key in ("instanceOperationUid", "instanceUid", "uid", "Uid"): - v = resp.get(key) - if isinstance(v, str) and v: - return v - return None - - -def _uid_from_location(loc): - if loc: - parts = loc.rstrip("/").split("/") - if len(parts[-1]) == 36: - return parts[-1] - return None diff --git a/site/static/js/operations.js b/site/static/js/operations.js index 6d82599..f780f83 100644 --- a/site/static/js/operations.js +++ b/site/static/js/operations.js @@ -78,80 +78,7 @@ function showParams(opId,opName){ }); } -function renderParamRow(p,allInst){ - const req=p.isRequired; - const hasDfl=p.defaultValue!==null&&p.defaultValue!==undefined&&p.defaultValue!==''; - const labelCls=req?(hasDfl?'req':'req-nodfl'):''; - const dfl=hasDfl?p.defaultValue:(req?'':''); - const vl=p.valueList; - const isMap=(p.dataType||'').startsWith('map'); - let input; - if(vl&&Array.isArray(vl)){ - input=``; - }else if(p.refSvcId){ - const refInsts=allInst.filter(i=>i.serviceId===p.refSvcId&&i.explainedStatus!=='deleted'); - let opts=refInsts.map(i=>``).join(''); - if(!dfl) opts=''+opts; - input=``; - }else if(p.dataType==='boolean'){ - input=``; - }else if(p.dataDescriptor&&isMap){ - return renderMapFixedRow(p,dfl); - }else{ - const dt=isMap?'map':''; - input=``; - if(isMap) input+=``; - } - return `
${input}
`; -} - -function renderMapFixedRow(p,dfl){ - const dd=p.dataDescriptor; - let subHtml=''; - let dflObj={}; - try{dflObj=JSON.parse(dfl||'{}');}catch(e){} - Object.keys(dd).forEach(subKey=>{ - const sub=dd[subKey]; - const subDfl=dflObj[subKey]!==undefined?dflObj[subKey]:(sub.defaultValue||''); - const subVl=sub.valueList; - const subReq=sub.isRequired; - const subNoDfl=subReq&&!subDfl&&subDfl!==0&&subDfl!==false; - const subLblCls=subNoDfl?'req-nodfl':''; - let si; - if(subVl&&Array.isArray(subVl)){ - si=``; - }else if(sub.dataType==='boolean'){ - si=``; - }else{ - si=``; - } - subHtml+=`
${si}
`; - }); - return `
${subHtml}
`; -} - -function collectParams(){ - const pp={}; - const nested={}; - document.querySelectorAll('#params-form [name^="p_"]').forEach(el=>{ - let v=el.value; - const dt=el.dataset.type||''; - if(dt==='mapchild'){ - const name=el.name.replace('p_',''); - const dot=name.indexOf('.'); - const parentKey=name.substring(0,dot); - const subKey=name.substring(dot+1); - if(!nested[parentKey]) nested[parentKey]={}; - nested[parentKey][subKey]=v; - return; - } - if(dt==='array'||dt.startsWith('array')) v=JSON.stringify([v]); - if(dt==='map'||dt==='map-fixed') v=v||'{}'; - pp[el.name.replace('p_','')]=v; - }); - for(const pk in nested) pp[pk]=JSON.stringify(nested[pk]); - return pp; -} +// renderParamRow, renderMapFixedRow, collectParams — теперь в params-render.js (общий модуль) function setFinishedState(statusText, statusClass, statusError, statusDuration, instanceName){ busy=false; diff --git a/site/static/js/params-render.js b/site/static/js/params-render.js new file mode 100644 index 0000000..3f62d60 --- /dev/null +++ b/site/static/js/params-render.js @@ -0,0 +1,81 @@ +// params-render.js — общий рендер параметров операций +// Используется: operations.js (ручной режим), scenario-form.js (редактор сценариев) +// Зависимости: _esc() из utils.js, validateJson() из utils.js + +function renderParamRow(p, allInst) { + const req = p.isRequired; + const hasDfl = p.defaultValue !== null && p.defaultValue !== undefined && p.defaultValue !== ''; + const labelCls = req ? (hasDfl ? 'req' : 'req-nodfl') : ''; + const dfl = hasDfl ? p.defaultValue : (req ? '' : ''); + const vl = p.valueList; + const isMap = (p.dataType || '').startsWith('map'); + let input; + if (vl && Array.isArray(vl)) { + input = ``; + } else if (p.refSvcId) { + const refInsts = allInst.filter(i => i.serviceId === p.refSvcId && i.explainedStatus !== 'deleted'); + let opts = refInsts.map(i => ``).join(''); + if (!dfl) opts = '' + opts; + input = ``; + } else if (p.dataType === 'boolean') { + input = ``; + } else if (p.dataDescriptor && isMap) { + return renderMapFixedRow(p, dfl); + } else { + const dt = isMap ? 'map' : ''; + input = ``; + if (isMap) input += ``; + } + return `
${input}
`; +} + +function renderMapFixedRow(p, dfl) { + const dd = p.dataDescriptor; + let subHtml = ''; + let dflObj = {}; + try { dflObj = JSON.parse(dfl || '{}'); } catch (e) { } + Object.keys(dd).forEach(subKey => { + const sub = dd[subKey]; + const subDfl = dflObj[subKey] !== undefined ? dflObj[subKey] : (sub.defaultValue || ''); + const subVl = sub.valueList; + const subReq = sub.isRequired; + const subNoDfl = subReq && !subDfl && subDfl !== 0 && subDfl !== false; + const subLblCls = subNoDfl ? 'req-nodfl' : ''; + let si; + if (subVl && Array.isArray(subVl)) { + si = ``; + } else if (sub.dataType === 'boolean') { + si = ``; + } else { + si = ``; + } + subHtml += `
${si}
`; + }); + return `
${subHtml}
`; +} + +function collectParams(containerSelector) { + containerSelector = containerSelector || '#params-form'; + const pp = {}; + const nested = {}; + const container = document.querySelector(containerSelector); + if (!container) return pp; + container.querySelectorAll('[name^="p_"]').forEach(el => { + let v = el.value; + const dt = el.dataset.type || ''; + if (dt === 'mapchild') { + const name = el.name.replace('p_', ''); + const dot = name.indexOf('.'); + const parentKey = name.substring(0, dot); + const subKey = name.substring(dot + 1); + if (!nested[parentKey]) nested[parentKey] = {}; + nested[parentKey][subKey] = v; + return; + } + if (dt === 'array' || dt.startsWith('array')) v = JSON.stringify([v]); + if (dt === 'map' || dt === 'map-fixed') v = v || '{}'; + pp[el.name.replace('p_', '')] = v; + }); + for (const pk in nested) pp[pk] = JSON.stringify(nested[pk]); + return pp; +} diff --git a/site/templates/index.html b/site/templates/index.html index 7e8a645..4cc459f 100644 --- a/site/templates/index.html +++ b/site/templates/index.html @@ -149,6 +149,7 @@ window.APP = { }; +