diff --git a/site/app.py b/site/app.py index 007b5b3..ff79504 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.47" +VERSION = "1.1.48" 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/save_run.py b/site/db/save_run.py index 5c9a990..7f7a6a2 100644 --- a/site/db/save_run.py +++ b/site/db/save_run.py @@ -8,8 +8,10 @@ from db.pool import get_conn, put_conn def save_run(client_id, stand, user_email, svc_id, svc_name, op_name, svc_op_id, op_uid, instance_uid, display_name, status, duration_sec, - error_log, params, stages, app_version): - """Сохранить один запуск в таблицу runs.""" + error_log, params, stages, app_version, + scenario_run_id=None, step_number=None): + """Сохранить один запуск в таблицу runs. + Опционально: scenario_run_id + step_number для шагов сценария.""" conn = get_conn() if not conn: print("[DB] save_run SKIP: no connection", flush=True) @@ -20,8 +22,9 @@ def save_run(client_id, stand, user_email, svc_id, svc_name, op_name, svc_op_id, cur.execute(""" INSERT INTO runs (client_id, stand, user_email, svc_id, svc_name, op_name, svc_op_id, op_uid, instance_uid, display_name, - status, duration_sec, error_log, params, stages, app_version) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + status, duration_sec, error_log, params, stages, app_version, + scenario_run_id, step_number) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( client_id, stand, user_email, svc_id, svc_name, op_name, svc_op_id, op_uid, instance_uid, display_name, status, duration_sec, @@ -29,6 +32,7 @@ def save_run(client_id, stand, user_email, svc_id, svc_name, op_name, svc_op_id, json.dumps(params) if params else None, json.dumps(stages) if stages else None, app_version, + scenario_run_id, step_number, )) conn.commit() cur.close() diff --git a/site/operations/scenario.py b/site/operations/scenario.py index f095dbc..0a7febe 100644 --- a/site/operations/scenario.py +++ b/site/operations/scenario.py @@ -127,25 +127,41 @@ def _create_scenario_run(client_id, stand, user_email, scenario_name, total_step return rid -def run_scenario(client, scenario_name, client_id, stand, user_email, app_version): +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 для поллинга.""" + scenario_run_id — уже созданная запись (из api_scenario_run).""" config = load_config() or {} scenarios = config.get("scenarios", {}) scenario = scenarios.get(scenario_name) if not scenario: - raise ValueError(f"Scenario '{scenario_name}' not found") + _save_scenario_run(scenario_run_id, "FAIL", 0, 0, f"Scenario '{scenario_name}' not found") + return steps = scenario.get("steps", []) - if not steps: - raise ValueError(f"Scenario '{scenario_name}' has no steps") - total = len(steps) + if not steps: + _save_scenario_run(scenario_run_id, "FAIL", 0, 0, "No steps") + return - # Создать запись в scenario_runs - scenario_run_id = _create_scenario_run(client_id, stand, user_email, scenario_name, total, app_version) - if scenario_run_id is None: - raise RuntimeError("Failed to create scenario_run record") + # Обновить total_steps (стало известно только сейчас) + _update_scenario_total(scenario_run_id, total) t0 = time.time() instance_map = {} # svc_name → instanceUid (переиспользование внутри сценария) @@ -205,6 +221,18 @@ 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 ДО поллинга (шаг не потеряется при ошибке) + step_display = display_name if op_name == "create" else instance_uid + try: + save_run(client_id, stand, user_email, + svc_id, "", op_name, svc_op_id, + op_uid, instance_uid, step_display, + "RUNNING", 0, "", + resolved_params, [], app_version, + scenario_run_id, step_num) + 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 @@ -212,6 +240,7 @@ def run_scenario(client, scenario_name, client_id, stand, user_email, app_versio step_error = "" step_duration = 0 step_stages = [] + op_data = {} # инициализировать на случай TIMEOUT while time.time() < deadline: try: @@ -231,13 +260,15 @@ def run_scenario(client, scenario_name, client_id, stand, user_email, app_versio break time.sleep(5) - # 7. Сохранить шаг в runs + # 7. Сохранить финальный результат шага в runs + svc_final = op_data.get("svc", svc_name) if op_data else svc_name try: save_run(client_id, stand, user_email, - svc_id, op_data.get("svc", svc_name), op_name, svc_op_id, - op_uid, instance_uid, display_name if op_name == "create" else instance_uid, + svc_id, svc_final, op_name, svc_op_id, + op_uid, instance_uid, step_display, step_status, step_duration, step_error, - resolved_params, step_stages, app_version) + resolved_params, step_stages, app_version, + scenario_run_id, step_num) except Exception as e: print(f"[SCENARIO] save_run failed for step {step_num}: {e}", flush=True) diff --git a/site/routes/api_scenario.py b/site/routes/api_scenario.py index ab7b32d..0b60ccb 100644 --- a/site/routes/api_scenario.py +++ b/site/routes/api_scenario.py @@ -11,7 +11,7 @@ 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 +from operations.scenario import load_scenarios, run_scenario, _create_scenario_run from db.pool import get_conn, put_conn bp = Blueprint("api_scenario", __name__) @@ -30,7 +30,7 @@ def api_scenarios(): @bp.route("/api/scenario/run", methods=["POST"]) def api_scenario_run(): - """Запустить сценарий. Возвращает scenario_run_id сразу, выполнение в фоне.""" + """Запустить сценарий. Создаёт запись ДО потока, возвращает run_id (202).""" try: data = request.get_json() scenario_name = (data.get("scenario") or "").strip() @@ -38,23 +38,67 @@ def api_scenario_run(): return jsonify({"error": "scenario name is required"}), 400 client = get_client() + cid = get_client_id() + stand = get_stand() 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: + return jsonify({"error": "Failed to create scenario_run"}), 500 + # Запуск в фоне threading.Thread( target=run_scenario, - args=(client, scenario_name, get_client_id(), get_stand(), user_email, app_version), + args=(client, scenario_name, cid, stand, user_email, app_version, run_id), daemon=True, ).start() - return jsonify({"status": "RUNNING", "scenario": scenario_name}) + return jsonify({"status": "RUNNING", "scenario": scenario_name, "run_id": run_id}), 202 except Exception as e: import traceback print(f"[SCENARIO API] run error: {traceback.format_exc()}", flush=True) return jsonify({"error": str(e)}), 500 +@bp.route("/api/scenario/run/") +def api_scenario_run_status(run_id): + """Статус конкретного запуска сценария.""" + conn = get_conn() + if not conn: + return jsonify({"error": "no db"}), 500 + try: + cur = conn.cursor() + cur.execute(""" + SELECT id, created_at, scenario_name, status, current_step, total_steps, + duration_sec, error_log + FROM scenario_runs + WHERE id = %s AND client_id = %s AND stand = %s + """, (run_id, get_client_id(), get_stand())) + row = cur.fetchone() + if not row: + return jsonify({"error": "not found"}), 404 + cols = [d[0] for d in cur.description] + d = dict(zip(cols, row)) + if d["created_at"]: + d["created_at"] = d["created_at"].isoformat() + return jsonify(d) + except Exception as e: + return jsonify({"error": str(e)}), 500 + finally: + put_conn(conn) + + @bp.route("/api/scenario/status") def api_scenario_status(): """Последний запущенный сценарий (или несколько последних).""" diff --git a/site/static/app.js b/site/static/app.js index 23c4849..2dc3d08 100644 --- a/site/static/app.js +++ b/site/static/app.js @@ -519,13 +519,15 @@ async function runScenario(name){ const r=await fetch('/api/scenario/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({scenario:name})}); const d=await r.json(); 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; } + // Поллинг конкретного запуска scenarioPollTimer=setInterval(async()=>{ try{ - const sr=await fetch('/api/scenario/status'); - const rows=await sr.json(); - if(!rows||!rows.length) return; - const last=rows[0]; + 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):'?'; 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}
`;