diff --git a/site/app.py b/site/app.py index 3216fea..171cb96 100644 --- a/site/app.py +++ b/site/app.py @@ -15,10 +15,11 @@ from flask import Flask from routes.main import bp as main_bp from routes.api_test import bp as api_test_bp -from routes.api_scenario import bp as api_scenario_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. -VERSION = "1.1.54" +VERSION = "1.1.55" 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") @@ -26,7 +27,8 @@ 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_bp) +app.register_blueprint(api_scenario_run_bp) +app.register_blueprint(api_scenario_defs_bp) @app.route("/health") diff --git a/site/db/scenario_defs.py b/site/db/scenario_defs.py index 3233ed1..dfe06f6 100644 --- a/site/db/scenario_defs.py +++ b/site/db/scenario_defs.py @@ -21,7 +21,8 @@ def list_definitions(client_id, stand): try: cur = conn.cursor() cur.execute(""" - SELECT id, name, steps, version, is_active, created_at, updated_at + SELECT id, name, steps, version, is_active, created_at, updated_at, + client_id, stand FROM scenario_definitions WHERE is_active = TRUE AND ((client_id = %s AND stand = %s) OR (client_id = '' AND stand = '')) @@ -34,6 +35,7 @@ def list_definitions(client_id, stand): 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 + d["is_seed"] = (d.get("client_id") == "" and d.get("stand") == "") result.append(d) return result except Exception as e: diff --git a/site/operations/scenario.py b/site/operations/scenario.py index ac97717..db78797 100644 --- a/site/operations/scenario.py +++ b/site/operations/scenario.py @@ -134,8 +134,8 @@ def run_scenario(client, steps, client_id, stand, user_email, app_version, scena raise RuntimeError(f"No opUid in response: {json.dumps(op_resp)[:200]}") # Отправить параметры и запустить - from routes.api_test import _send_params_terraform - _send_params_terraform(client, op_uid, resolved_params) + from operations.terraform import send_params_terraform + send_params_terraform(client, op_uid, resolved_params) client.post(f"/instanceOperations/{op_uid}/run") # 5. Сохранить RUNNING в runs ДО поллинга diff --git a/site/operations/terraform.py b/site/operations/terraform.py new file mode 100644 index 0000000..51115de --- /dev/null +++ b/site/operations/terraform.py @@ -0,0 +1,142 @@ +""" +Terraform-совместимые операции: нормализация параметров, refSvc-резолв, отправка. + +Используется из api_test.py и scenario.py. +""" + +import json +from operations.get_instances import get_instances + +REDACT_KEYS = {"password", "secret", "token", "key", "privatekey", "private_key", "passwd", "pass"} + + +def redact_params(params): + """Заменить значения секретных полей на *** перед сохранением в БД.""" + if not params: + return params + result = {} + for k, v in params.items(): + if any(r in str(k).lower() for r in REDACT_KEYS): + result[k] = "***" + else: + result[k] = v + return result + + +def normalize_value(val, data_type, data_descriptor): + """normalizeUniversalValueV6 — Terraform-equivalent. + Пустые значения нормализуются по типу: integer→"0", boolean→"false", + array→"[]", map/json→"{}", map-fixed→сборка из sub-param defaults.""" + val = (val or "").strip() + if val.lower() == "null" or val == '""': + val = "" + dt = (data_type or "").lower() + if ("map" in dt) and isinstance(data_descriptor, dict) and data_descriptor: + if not val or val == "{}": + sub_obj = {} + for sk, sv in data_descriptor.items(): + sd = sv.get("defaultValue") + sub_obj[sk] = str(sd) if sd is not None else "" + return json.dumps(sub_obj) + return val + if val: + return val + if "array" in dt: + return "[]" + if "map" in dt or "json" in dt: + return "{}" + if "integer" in dt or "int" in dt: + return "0" + if "boolean" in dt or "bool" in dt: + return "false" + return val + + +def resolve_ref_svc(client, cfs_params, params): + """resolveRefSvcParamValues — Terraform step 4. + Для параметров с refSvcId: если значение пустое — подставить UUID + существующего инстанса этого сервиса.""" + instances = None + for p in cfs_params: + pid = str(p["svcOperationCfsParamId"]) + # Верхнеуровневый refSvcId + top_ref = p.get("refSvcId") + if top_ref: + cur = (params.get(pid) or "").strip() + if not cur or cur == '""': + if instances is None: + try: + instances = get_instances(client) + except Exception: + return + match = next((i for i in instances + if i.get("serviceId") == top_ref + and i.get("explainedStatus") not in ("deleted",)), None) + if match: + params[pid] = match["instanceUid"] + # Вложенный refSvcId в dataDescriptor + dd = p.get("dataDescriptor") + if not isinstance(dd, dict): + continue + user_val = {} + if pid in params: + try: + user_val = json.loads(params[pid]) if params[pid] else {} + except (json.JSONDecodeError, ValueError): + user_val = {} + for sk, sv in dd.items(): + ref_id = sv.get("refSvcId") + if not ref_id: + continue + cur = user_val.get(sk, "") + if cur and cur != '""': + continue + if instances is None: + try: + instances = get_instances(client) + except Exception: + return + match = next((i for i in instances + if i.get("serviceId") == ref_id + and i.get("explainedStatus") not in ("deleted",)), None) + if match: + user_val[sk] = match["instanceUid"] + if user_val: + params[pid] = json.dumps(user_val) + + +def send_params_terraform(client, op_uid, params): + """Terraform steps 3-7: cfsParams → resolveRefSvc → send user → send all unsent → validate.""" + op_det = client.get(f"/instanceOperations/{op_uid}?fields=cfsParams") + cfs_params = op_det.get("instanceOperation", {}).get("cfsParams", []) + resolve_ref_svc(client, cfs_params, params) + sent = set() + for pid, pval in params.items(): + client.post("/instanceOperationCfsParams", { + "instanceOperationUid": op_uid, + "svcOperationCfsParamId": int(pid), + "paramValue": str(pval) + }) + sent.add(int(pid)) + for p in cfs_params: + pid = p["svcOperationCfsParamId"] + if pid in sent: + continue + val = p.get("paramValue") + if val is None: + val = p.get("defaultValue") + if val is None: + val = "" + val = normalize_value(str(val), p.get("dataType", ""), p.get("dataDescriptor")) + client.post("/instanceOperationCfsParams", { + "instanceOperationUid": op_uid, + "svcOperationCfsParamId": pid, + "paramValue": val + }) + try: + client.get(f"/instanceOperations/{op_uid}/validate-cfs") + except Exception as e: + if "Expecting value" in str(e) or "JSON" in str(type(e).__name__): + pass + else: + raise diff --git a/site/routes/api_scenario_defs.py b/site/routes/api_scenario_defs.py new file mode 100644 index 0000000..b2c8080 --- /dev/null +++ b/site/routes/api_scenario_defs.py @@ -0,0 +1,108 @@ +""" +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, jsonify, request +from api.auth import get_client_id, get_stand, get_token_info +from db.scenario_defs import ( + list_definitions, get_definition, create_definition, + update_definition, delete_definition, +) + +bp_defs = Blueprint("api_scenario_defs", __name__) + + +def _validate_steps(steps): + """Проверить структуру шагов сценария. Возвращает None или str с ошибкой.""" + if not isinstance(steps, list) or len(steps) == 0: + return "Шаги: непустой массив" + for i, s in enumerate(steps): + if not isinstance(s, dict): + return f"Шаг {i+1}: должен быть объектом" + svc_id = s.get("service_id") + if not isinstance(svc_id, int) or svc_id <= 0: + return f"Шаг {i+1}: service_id — положительный int" + op = (s.get("operation") or "").strip() + if not op: + return f"Шаг {i+1}: operation обязателен" + if not isinstance(s.get("params", {}), dict): + return f"Шаг {i+1}: params — объект" + return None + + +@bp_defs.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", []) + err = _validate_steps(steps) + if err: + return jsonify({"error": err}), 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_defs.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", []) + err = _validate_steps(steps) + if err: + return jsonify({"error": err}), 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/routes/api_scenario_run.py b/site/routes/api_scenario_run.py new file mode 100644 index 0000000..aedee0b --- /dev/null +++ b/site/routes/api_scenario_run.py @@ -0,0 +1,153 @@ +""" +API сценариев: запуск + поллинг. + +GET /api/scenarios — список из БД (для UI) +POST /api/scenario/run — запуск по definition_id (409 если уже RUNNING) +GET /api/scenario/run/ — статус конкретного запуска +GET /api/scenario/status — последние 10 запусков +""" + +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 run_scenario +from db.pool import get_conn, put_conn +from db.scenario_defs import get_definition, lock_check, list_definitions + +bp_run = Blueprint("api_scenario_run", __name__) + + +@bp_run.route("/api/scenarios") +def api_scenarios(): + """Список определений сценариев из БД (для UI запуска).""" + try: + 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_run.route("/api/scenario/run", methods=["POST"]) +def api_scenario_run(): + """Запустить сценарий по definition_id. 409 если уже RUNNING.""" + try: + data = request.get_json() + def_id = data.get("definition_id") + if not def_id: + return jsonify({"error": "definition_id is required"}), 400 + + cid = get_client_id() + stand = get_stand() + + 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", "") + + 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, steps, cid, stand, user_email, app_version, run_id, scenario_name), + daemon=True, + ).start() + + 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_run.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, app_version + 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_run.route("/api/scenario/status") +def api_scenario_status(): + """Последние 10 запусков.""" + conn = get_conn() + if not conn: + return jsonify([]) + try: + cur = conn.cursor() + cur.execute(""" + SELECT id, created_at, scenario_name, status, current_step, total_steps, + duration_sec, error_log, app_version + FROM scenario_runs + WHERE client_id = %s AND stand = %s + ORDER BY created_at DESC + LIMIT 10 + """, (get_client_id(), get_stand())) + rows = cur.fetchall() + cols = [d[0] for d in cur.description] + result = [] + for r in rows: + d = dict(zip(cols, r)) + if d["created_at"]: + d["created_at"] = d["created_at"].isoformat() + result.append(d) + return jsonify(result) + except Exception as e: + return jsonify({"error": str(e)}), 500 + finally: + put_conn(conn) diff --git a/site/routes/api_test.py b/site/routes/api_test.py index f57e139..f5c6155 100644 --- a/site/routes/api_test.py +++ b/site/routes/api_test.py @@ -34,6 +34,7 @@ from api.auth import get_token, get_client, get_client_id, get_stand, get_token_ 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.tracker import add as tracker_add, list_all as tracker_list from operations.tracker import remove as tracker_remove @@ -46,22 +47,6 @@ MAX_LOG_SIZE = 512 * 1024 # 512 KB — ротация MAX_LOG_LINES = 200 # сколько отдавать в /api/log -REDACT_KEYS = {"password", "secret", "token", "key", "privatekey", "private_key", "passwd", "pass"} - - -def _redact_params(params): - """Заменить значения секретных полей на *** перед сохранением в БД.""" - if not params: - return params - result = {} - for k, v in params.items(): - if any(r in str(k).lower() for r in REDACT_KEYS): - result[k] = "***" - else: - result[k] = v - return result - - def _log(msg): """Пишет в stdout (gunicorn) и в файл (UI-панель, общий для всех воркеров).""" print(msg, flush=True) @@ -228,7 +213,7 @@ def api_test(): traceback.print_exc() # Отправка параметров (шаги 3-7 Terraform) - _send_params_terraform(client, op_uid, params) + send_params_terraform(client, op_uid, params) client.post(f"/instanceOperations/{op_uid}/run") @@ -267,7 +252,7 @@ def api_test(): 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) + send_params_terraform(client, op_uid, params) client.post(f"/instanceOperations/{op_uid}/run") # фоном ждать завершения @@ -291,134 +276,6 @@ _op_results = {} _MAX_OP_RESULTS = 500 -def _normalize_value(val, data_type, data_descriptor): - """normalizeUniversalValueV6 — Terraform-equivalent. - Пустые значения нормализуются по типу: integer→"0", boolean→"false", - array→"[]", map/json→"{}", map-fixed→сборка из sub-param defaults.""" - val = (val or "").strip() - if val.lower() == "null" or val == '""': - val = "" - dt = (data_type or "").lower() - # map-fixed/map с DataDescriptor: пусто или {} → собрать из sub-param defaults - if ("map" in dt) and isinstance(data_descriptor, dict) and data_descriptor: - if not val or val == "{}": - sub_obj = {} - for sk, sv in data_descriptor.items(): - sd = sv.get("defaultValue") - sub_obj[sk] = str(sd) if sd is not None else "" - return json.dumps(sub_obj) - return val - if val: - return val - # Пустое — нормализовать по типу - if "array" in dt: - return "[]" - if "map" in dt or "json" in dt: - return "{}" - if "integer" in dt or "int" in dt: - return "0" - if "boolean" in dt or "bool" in dt: - return "false" - return val # пустая строка — отправляем как есть (Terraform тоже шлёт) - - -def _resolve_ref_svc(client, cfs_params, params): - """resolveRefSvcParamValues — Terraform step 4. - Для параметров с refSvcId (верхнеуровневый ИЛИ в dataDescriptor): - если значение пустое — подставить UUID существующего инстанса этого сервиса.""" - instances = None # lazy load - for p in cfs_params: - pid = str(p["svcOperationCfsParamId"]) - # --- Верхнеуровневый refSvcId --- - top_ref = p.get("refSvcId") - if top_ref: - cur = (params.get(pid) or "").strip() - if not cur or cur == '""': - if instances is None: - try: - instances = get_instances(client) - except Exception: - return - match = next((i for i in instances - if i.get("serviceId") == top_ref - and i.get("explainedStatus") not in ("deleted",)), None) - if match: - params[pid] = match["instanceUid"] - # --- Вложенный refSvcId в dataDescriptor --- - dd = p.get("dataDescriptor") - if not isinstance(dd, dict): - continue - user_val = {} - if pid in params: - try: - user_val = json.loads(params[pid]) if params[pid] else {} - except (json.JSONDecodeError, ValueError): - user_val = {} - for sk, sv in dd.items(): - ref_id = sv.get("refSvcId") - if not ref_id: - continue - cur = user_val.get(sk, "") - if cur and cur != '""': - continue # уже заполнено - if instances is None: - try: - instances = get_instances(client) - except Exception: - return - match = next((i for i in instances - if i.get("serviceId") == ref_id - and i.get("explainedStatus") not in ("deleted",)), None) - if match: - user_val[sk] = match["instanceUid"] - if user_val: - params[pid] = json.dumps(user_val) - - -def _send_params_terraform(client, op_uid, params): - """Terraform steps 3-7: cfsParams → resolveRefSvc → send user → send all unsent → validate. - Работает для CREATE и non-CREATE одинаково.""" - # Шаг 3: GET cfsParams реальной операции - op_det = client.get(f"/instanceOperations/{op_uid}?fields=cfsParams") - cfs_params = op_det.get("instanceOperation", {}).get("cfsParams", []) - # Шаг 4: resolveRefSvcParamValues - _resolve_ref_svc(client, cfs_params, params) - # Шаг 5: отправить ВСЕ пользовательские params (включая пустые!) - sent = set() - for pid, pval in params.items(): - client.post("/instanceOperationCfsParams", { - "instanceOperationUid": op_uid, - "svcOperationCfsParamId": int(pid), - "paramValue": str(pval) - }) - sent.add(int(pid)) - # Шаг 6: дослать ВСЕ неотправленные params из cfsParams - for p in cfs_params: - pid = p["svcOperationCfsParamId"] - if pid in sent: - continue - # nil-check: paramValue может быть None (отличаем от "") - val = p.get("paramValue") - if val is None: - val = p.get("defaultValue") - if val is None: - val = "" - val = _normalize_value(str(val), p.get("dataType", ""), p.get("dataDescriptor")) - client.post("/instanceOperationCfsParams", { - "instanceOperationUid": op_uid, - "svcOperationCfsParamId": pid, - "paramValue": val - }) - # Шаг 7: validate-cfs (пустой ответ = успех) - try: - client.get(f"/instanceOperations/{op_uid}/validate-cfs") - except Exception as e: - if "Expecting value" in str(e) or "JSON" in str(type(e).__name__): - pass # пустой ответ = OK - else: - raise - - def _cleanup_op_results(): """Удалить старые записи: старше 1 часа или сверх лимита.""" import time @@ -485,7 +342,7 @@ def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_ # Сохранить в БД историю try: from db.save_run import save_run - saved_params = _redact_params(params or {}) + saved_params = redact_params(params or {}) save_run(client_id, stand, user_email, svc_id, op.get("svc", ""), op_name, svc_op_id, @@ -510,7 +367,7 @@ def _finish_op(client, op_uid, instance_uid, svc_id, display_name, op_name, svc_ # Сохранить TIMEOUT в БД try: from db.save_run import save_run - saved_params = _redact_params(params or {}) + saved_params = redact_params(params or {}) save_run(client_id, stand, user_email, svc_id, "", op_name, svc_op_id, op_uid, instance_uid, display_name, diff --git a/site/static/app.js b/site/static/app.js index 31704b4..1e9d0ce 100644 --- a/site/static/app.js +++ b/site/static/app.js @@ -1,555 +1,16 @@ -// window.APP — переменные из Jinja2 (заполняются в index.html) -// window.APP = { version, stand, hasUserToken }; +// app.js — загрузчик модулей фронтенда +// Файлы подгружаются в index.html в порядке зависимостей: +// utils.js → instances.js → operations.js → history.js → scenario-list.js +// window.APP — переменные из Jinja2: { version, stand, hasUserToken, firstServiceId } /* - * === АРХИТЕКТУРА ФРОНТЕНДА === - * - * Один HTML-файл, ванильный JS (без фреймворков). - * Три колонки: инфраструктура | сервисы | инстансы+операции+параметры. - * - * Глобальное состояние: - * svcInstances — кеш списка инстансов из /api/operations/{svcId} - * selectedInst — UID выбранного инстанса (null если не выбран) - * selectedOp — {opId, opName, svcId} выбранная операция - * pollTimer — setInterval таймер поллинга статуса операции - * currentSvcId — ID текущего выбранного сервиса (по умолчанию 1 = Болванка) - * AUTOTEST_PREFIX — префикс имён autotest-инстансов - * - * Потоки: - * CREATE: startCreate() → showParams(18,'create') → executeOp(pp) → poll → refresh - * MODIFY: toggleInstance() → runOp('modify',id) → showParams(id,'modify') → validate → executeOp - * БЕЗ ПАРАМЕТРОВ (delete/suspend/resume/redeploy): runOp() → confirm → executeOp({}) - * - * Хелперы: - * _esc(s) — HTML-экранирование (" → " & → & < → <) - * validateJson() — проверка JSON.parse для map-полей (onblur + перед отправкой) - * showStages() — отрисовка этапов выполнения операции (⏳/✅/❌) + * Архитектура: ванильный 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() */ -let svcInstances=[], selectedInst=null, selectedOp=null, pollTimer=null; -let currentSvcId=(window.APP&&window.APP.firstServiceId)||1; // динамический — обновляется при selectService() -let currentSvcName=''; // имя текущего сервиса (колонка «тип инстанса») -let currentSvcShort=''; // краткое имя (redis, postgres) для displayName -let busy=false; // блокировка: запрет операций пока идёт другая -const AUTOTEST_PREFIX='autotest-'; -// Первый сервис из списка (рендерится Jinja2) -selectService(currentSvcId); - -async function selectService(svcId){ - if(busy) return; // операция выполняется — не менять сервис - currentSvcId=svcId; - 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||'сервиса'); - - let html=''; - svcInstances.forEach(i=>{ - const status=i.status||i.explainedStatus||'?'; - const sc=status==='running'?'badge-success':''; - html+=`
- ${_esc(i.displayName)} - ${_esc(i.svc||'')} - ${_esc(status)} -
`; - html+=`
`; - }); - document.getElementById('inst-list').innerHTML=html; - }catch(e){document.getElementById('inst-list').innerHTML='Ошибка загрузки';} -} - -async function toggleInstance(iuid){ - if(busy) return; // операция выполняется — ничего не делать - 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); - 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'); - } - opsEl.innerHTML=ops.map(o=> - `` - ).join(''); - opsEl.classList.add('open'); - }catch(e){opsEl.innerHTML='Ошибка загрузки';opsEl.classList.add('open');} -} - -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==='reconcile') return 'op-btn-reconcile'; - return ''; -} - -async function startCreate(){ - if(busy) return; - selectedInst=null; stopPoll(); - document.querySelectorAll('[data-iuid]').forEach(e=>e.classList.remove('active')); - document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open')); - // Найти svcOperationId для create у текущего сервиса - 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; // fallback — Болванка - showParams(opId,'create'); - }catch(e){ - showParams(18,'create'); // fallback - } -} - -function makeCreateDisplayName(){ - const rand = (Math.random() * 46656 | 0).toString(36).padStart(3, '0'); - return currentSvcShort ? currentSvcShort+'-'+rand : rand; -} - -function runOp(opName,opId){ - if(busy) return; - stopPoll(); - showParams(opId,opName); -} - -function findInstName(){ - const i=svcInstances.find(x=>x.instanceUid===selectedInst); - return i?i.displayName:selectedInst; -} - -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'; - 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;} - const params=data.params||data; - if(!Array.isArray(params)){document.getElementById('params-form').innerHTML=`Неверный формат параметров`;return;} - // Заранее загрузить все инстансы для refSvcId-параметров - 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){} - } - const form=document.getElementById('params-form'); - const displayNameValue=isCreate?makeCreateDisplayName():''; - const createHeader=isCreate?`
Создание: ${_esc(currentSvcName)}
`:''; - const opHeader=!isCreate?`
${_esc(opName)} → ${_esc(findInstName())}
`:''; - form.innerHTML=createHeader+opHeader+(opName==='create'?`
${AUTOTEST_PREFIX}
`:'') - + params.map(p=>{ - 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){ - // Раскрыть dataDescriptor — вложенные поля вместо JSON-строки - 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}
`; - }); - input=`
${subHtml}
`; - return `
${input}`; - }else{ - const dt=isMap?'map':''; - input=``; - if(isMap) input+=``; - } - return `
${input}
`; - }).join(''); - const btn=document.getElementById('btn-test'); - btn.style.display='inline-flex'; - btn.textContent='Запустить'; - btn.onclick=()=>{ - // Проверить ВСЕ map-поля перед отправкой - let bad=[]; - 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 — поправьте и нажмите Запустить снова`; - return; - } - const pp={}; - const nested={}; // {parentKey: {subKey: value, ...}} - 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; - }); - // Сериализовать вложенные объекты в JSON строки - for(const pk in nested) pp[pk]=JSON.stringify(nested[pk]); - executeOp(pp); - }; - }).catch(e=>{ - document.getElementById('params-form').innerHTML=`Ошибка загрузки: ${_esc(e.message)}`; - }); -} - -// === Хелперы === - -function _esc(s){ - /* HTML-экранирование: " → " & → & < → < */ - return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/e.classList.remove('open')); - const btn=document.getElementById('btn-test'); - btn.disabled=false; - btn.textContent='Готово'; - 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}`; -} - -function setRunningState(opName, displayName){ - const btn=document.getElementById('btn-test'); - const title=displayName || opName; - btn.textContent=opName+'...'; - document.getElementById('test-status').textContent=' ⏳ '+title+' выполняется...'; -} - -async function executeOp(params){ - busy=true; - 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(); - document.getElementById('params-area').style.display='block'; - 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); - // Создать динамический stages-box после инстанса - const instId=selectedInst||''; - const afterEl=instId?document.getElementById('ops-'+instId):null; - document.querySelectorAll('.stages-box').forEach(e=>e.remove()); - const stagesBox=document.createElement('div'); - stagesBox.className='stages-box'; - 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 : '' - })}); - const d=await r.json(); - if(d.status==='FAIL'){busy=false;document.getElementById('test-status').innerHTML=` FAIL ${_esc(d.error||'')}`;btn.disabled=false;return;} - // CMDB delete — мгновенное удаление, не надо поллинга - if(d.status==='OK'&&(d.opUid||'').startsWith('cmdb-')){ - busy=false; - setFinishedState('OK','badge-success','',0,d.displayName); - try{await refreshInstances();}catch(e){} - if(historyOpen) try{await loadHistory();}catch(e){} - return; - } - // start polling - const opUid=d.opUid; - const instanceName=d.displayName||displayName||findInstName(); - 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||[]); - 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(); - } - }catch(e){ - _pollErrors++; - if(_pollErrors>=5){ - stopPoll(); - setFinishedState('TIMEOUT','','Связь потеряна',0,instanceName); - } - } - },2000); - }catch(e){ - busy=false; - document.getElementById('test-status').textContent=' Ошибка: '+e.message; - btn.disabled=false; - } -} - -function showStages(stages){ - if(!stages||!stages.length) return; - let html='
Этапы
'; - stages.forEach(s=>{ - 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; -} - -function stopPoll(){ - if(pollTimer){clearInterval(pollTimer);pollTimer=null;} -} - -async function refreshInstances(){ - try{ - const r=await fetch('/api/operations/'+currentSvcId); - const d=await r.json(); - const newInsts=d.instances||[]; - svcInstances=newInsts; - let html=''; - svcInstances.forEach(i=>{ - const status=i.status||i.explainedStatus||'?'; - const sc=status==='running'?'badge-success':''; - html+=`
- ${_esc(i.displayName)} - ${_esc(i.svc||'')} - ${_esc(status)} -
`; - html+=`
`; - }); - document.getElementById('inst-list').innerHTML=html; - }catch(e){} -} - -// === История тестов === -let historyOpen=false; -async function toggleHistory(){ - const body=document.getElementById('history-body'); - historyOpen=!historyOpen; - body.style.display=historyOpen?'block':'none'; - if(historyOpen) await loadHistory(); -} -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;} - let html=''; - html+=''; - rows.forEach(r=>{ - const time=r.created_at?r.created_at.replace('T',' ').substring(0,19):'?'; - const cls=r.status==='OK'?'badge-success':''; - html+=` - - - - - - `; - }); - html+='
ВремяОперацияИнстансСтатусДлит.
${time}${_esc(r.op_name||'?')}${_esc(r.display_name||r.instance_uid||'?')}${_esc(r.status||'?')}${r.duration_sec!=null?r.duration_sec.toFixed(1)+'s':'-'}
'; - body.innerHTML=html; - }catch(e){body.innerHTML='Ошибка загрузки';} -} - -// === Панель логов (скрыта, показывается по кнопке) === -let logPollTimer=null; -function startLogPoll(){ - if(logPollTimer) return; - logPollTimer=setInterval(async()=>{ - const el=document.getElementById('log-panel'); - if(!el||el.style.display==='none') return; // не дёргать если скрыта - try{ - const r=await fetch('/api/log'); - const lines=await r.json(); - el.innerHTML=lines.map(l=>`
${_esc(l)}
`).join(''); - el.scrollTop=el.scrollHeight; - }catch(e){} - },2000); -} -function toggleLog(){ - const el=document.getElementById('log-panel'); - if(!el) return; - if(el.style.display==='none'){ el.style.display='block'; startLogPoll(); } - else el.style.display='none'; -} - -// === Сценарии (автотесты) === -let scenarioOpen=false, scenarioPollTimer=null; - -async function toggleScenario(){ - const body=document.getElementById('scenario-body'); - scenarioOpen=!scenarioOpen; - body.style.display=scenarioOpen?'block':'none'; - if(!scenarioOpen){ stopScenarioPoll(); return; } - await loadScenarios(); -} - -async function loadScenarios(){ - const body=document.getElementById('scenario-body'); - try{ - const [sr, srHistory]=await Promise.all([ - fetch('/api/scenarios').then(r=>r.json()), - fetch('/api/scenario/status').then(r=>r.json()), - ]); - let html=''; - if(sr.length){ - html+='
'; - sr.forEach(s=>{ - html+=``; - }); - html+='
'; - }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):'?'; - 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='Ошибка загрузки';} -} - -async function runScenario(defId){ - if(busy){ 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 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/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}
`; - 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(); - } - }catch(e){} - },3000); - }catch(e){ - body.innerHTML=`Ошибка: ${_esc(e.message)}`; - busy=false; - } -} - -function stopScenarioPoll(){ - if(scenarioPollTimer){ clearInterval(scenarioPollTimer); scenarioPollTimer=null; } -} -startLogPoll(); \ No newline at end of file +// Инициализация лог-панели +startLogPoll(); diff --git a/site/static/js/history.js b/site/static/js/history.js new file mode 100644 index 0000000..c1d67e0 --- /dev/null +++ b/site/static/js/history.js @@ -0,0 +1,35 @@ +// history.js — история тестов + +let historyOpen=false; + +async function toggleHistory(){ + const body=document.getElementById('history-body'); + historyOpen=!historyOpen; + body.style.display=historyOpen?'block':'none'; + if(historyOpen) await loadHistory(); +} + +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;} + let html=''; + html+=''; + rows.forEach(r=>{ + const time=r.created_at?r.created_at.replace('T',' ').substring(0,19):'?'; + const cls=r.status==='OK'?'badge-success':''; + html+=` + + + + + + `; + }); + html+='
ВремяОперацияИнстансСтатусДлит.
${time}${_esc(r.op_name||'?')}${_esc(r.display_name||r.instance_uid||'?')}${_esc(r.status||'?')}${r.duration_sec!=null?r.duration_sec.toFixed(1)+'s':'-'}
'; + body.innerHTML=html; + }catch(e){body.innerHTML='Ошибка загрузки';} +} diff --git a/site/static/js/instances.js b/site/static/js/instances.js new file mode 100644 index 0000000..a783b55 --- /dev/null +++ b/site/static/js/instances.js @@ -0,0 +1,89 @@ +// instances.js — список инстансов, выбор сервиса, toggle операций + +let svcInstances=[], selectedInst=null, selectedOp=null; +let currentSvcId=(window.APP&&window.APP.firstServiceId)||1; +let currentSvcName=''; +let currentSvcShort=''; +let busy=false; +const AUTOTEST_PREFIX='autotest-'; + +async function selectService(svcId){ + if(busy) return; + currentSvcId=svcId; + 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='Ошибка загрузки';} +} + +function renderInstances(){ + let html=''; + svcInstances.forEach(i=>{ + const status=i.status||i.explainedStatus||'?'; + const sc=status==='running'?'badge-success':''; + html+=`
+ ${_esc(i.displayName)} + ${_esc(i.svc||'')} + ${_esc(status)} +
`; + 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(); + }catch(e){} +} + +async function toggleInstance(iuid){ + if(busy) return; + 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); + 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'); + } + opsEl.innerHTML=ops.map(o=> + `` + ).join(''); + opsEl.classList.add('open'); + }catch(e){opsEl.innerHTML='Ошибка загрузки';opsEl.classList.add('open');} +} + +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'; + return ''; +} + +// Инициализация +selectService(currentSvcId); diff --git a/site/static/js/operations.js b/site/static/js/operations.js new file mode 100644 index 0000000..6d82599 --- /dev/null +++ b/site/static/js/operations.js @@ -0,0 +1,249 @@ +// operations.js — запуск операций: create, modify, delete, poll, stages +let pollTimer=null; + +function makeCreateDisplayName(){ + const rand = (Math.random() * 46656 | 0).toString(36).padStart(3, '0'); + return currentSvcShort ? currentSvcShort+'-'+rand : rand; +} + +function findInstName(){ + const i=svcInstances.find(x=>x.instanceUid===selectedInst); + return i?i.displayName:selectedInst; +} + +function stopPoll(){ + if(pollTimer){clearInterval(pollTimer);pollTimer=null;} +} + +async function startCreate(){ + if(busy) return; + selectedInst=null; 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; + showParams(opId,'create'); + }catch(e){ + showParams(18,'create'); + } +} + +function runOp(opName,opId){ + if(busy) return; + stopPoll(); + showParams(opId,opName); +} + +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'; + 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;} + const params=data.params||data; + if(!Array.isArray(params)){document.getElementById('params-form').innerHTML=`Неверный формат параметров`;return;} + 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){} + } + const form=document.getElementById('params-form'); + const displayNameValue=isCreate?makeCreateDisplayName():''; + const createHeader=isCreate?`
Создание: ${_esc(currentSvcName)}
`:''; + const opHeader=!isCreate?`
${_esc(opName)} → ${_esc(findInstName())}
`:''; + form.innerHTML=createHeader+opHeader+(opName==='create'?`
${AUTOTEST_PREFIX}
`:'') + + params.map(p=>renderParamRow(p,allInst)).join(''); + const btn=document.getElementById('btn-test'); + btn.style.display='inline-flex'; + btn.textContent='Запустить'; + btn.onclick=()=>{ + let bad=[]; + 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 — поправьте и нажмите Запустить снова`; + return; + } + const pp=collectParams(); + executeOp(pp); + }; + }).catch(e=>{ + document.getElementById('params-form').innerHTML=`Ошибка загрузки: ${_esc(e.message)}`; + }); +} + +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; +} + +function setFinishedState(statusText, statusClass, statusError, statusDuration, instanceName){ + busy=false; + 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; + 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}`; +} + +function setRunningState(opName, displayName){ + const btn=document.getElementById('btn-test'); + btn.textContent=opName+'...'; + document.getElementById('test-status').textContent=' ⏳ '+ (displayName||opName) +' выполняется...'; +} + +async function executeOp(params){ + busy=true; + 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(); + document.getElementById('params-area').style.display='block'; + 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); + const instId=selectedInst||''; + const afterEl=instId?document.getElementById('ops-'+instId):null; + document.querySelectorAll('.stages-box').forEach(e=>e.remove()); + const stagesBox=document.createElement('div'); + stagesBox.className='stages-box'; + 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:'' + })}); + const d=await r.json(); + if(d.status==='FAIL'){busy=false;document.getElementById('test-status').innerHTML=` FAIL ${_esc(d.error||'')}`;btn.disabled=false;return;} + if(d.status==='OK'&&(d.opUid||'').startsWith('cmdb-')){ + busy=false; + setFinishedState('OK','badge-success','',0,d.displayName); + try{await refreshInstances();}catch(e){} + if(historyOpen) try{await loadHistory();}catch(e){} + return; + } + const opUid=d.opUid; + const instanceName=d.displayName||displayName; + 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||[]); + 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(); + } + }catch(e){ + _pollErrors++; + if(_pollErrors>=5){stopPoll();setFinishedState('TIMEOUT','','Связь потеряна',0,instanceName);} + } + },2000); + }catch(e){ + busy=false; + document.getElementById('test-status').textContent=' Ошибка: '+e.message; + btn.disabled=false; + } +} + +function showStages(stages){ + if(!stages||!stages.length) return; + let html='
Этапы
'; + stages.forEach(s=>{ + 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/scenario-create.js b/site/static/js/scenario-create.js new file mode 100644 index 0000000..954f958 --- /dev/null +++ b/site/static/js/scenario-create.js @@ -0,0 +1,25 @@ +// scenario-create.js — создание и клонирование сценария + +async function createScenario() { + showScenarioEditor(null); +} + +async function cloneScenario(defId, origName) { + const newName = prompt('Имя нового сценария (копия «' + origName + '»):', origName + '_copy'); + if (!newName) return; + try { + const r = await fetch('/api/scenario/definitions/' + defId); + const def = await r.json(); + if (def.error) { alert(def.error); return; } + const rr = await fetch('/api/scenario/definitions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newName, steps: def.steps }) + }); + const d = await rr.json(); + if (d.error) { alert(d.error); return; } + await loadScenarios(); + } catch (e) { + alert('Ошибка клонирования: ' + e.message); + } +} diff --git a/site/static/js/scenario-delete.js b/site/static/js/scenario-delete.js new file mode 100644 index 0000000..d7e4816 --- /dev/null +++ b/site/static/js/scenario-delete.js @@ -0,0 +1,13 @@ +// scenario-delete.js — удаление сценария + +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(); + } catch (e) { + alert('Ошибка: ' + e.message); + } +} diff --git a/site/static/js/scenario-edit.js b/site/static/js/scenario-edit.js new file mode 100644 index 0000000..e7047d0 --- /dev/null +++ b/site/static/js/scenario-edit.js @@ -0,0 +1,12 @@ +// scenario-edit.js — редактирование существующего сценария + +async function editScenario(defId) { + try { + const r = await fetch('/api/scenario/definitions/' + defId); + const def = await r.json(); + if (def.error) { alert(def.error); return; } + showScenarioEditor(def); + } catch (e) { + alert('Ошибка загрузки: ' + e.message); + } +} diff --git a/site/static/js/scenario-form.js b/site/static/js/scenario-form.js new file mode 100644 index 0000000..a03f139 --- /dev/null +++ b/site/static/js/scenario-form.js @@ -0,0 +1,128 @@ +// scenario-form.js — общий редактор шагов сценария (create + edit) +let scenarioEditorState = null; // {defId, version, name, steps:[]} +// steps: [{service_id, operation, params: [["key","val"],...]}] + +function showScenarioEditor(def) { + const body = document.getElementById('scenario-body'); + scenarioEditorState = def ? { + defId: def.id, version: def.version, name: def.name, + steps: (def.steps || []).map(s => ({ + service_id: s.service_id, operation: s.operation, + params: Object.entries(s.params || {}) + })) + } : { defId: null, version: 1, name: '', steps: [] }; + renderEditor(); +} + +function renderEditor() { + const st = scenarioEditorState; + let html = '
'; + html += '

'; + html += ``; + html += '
'; + html += '
Шаги:
'; + st.steps.forEach((s, idx) => { + html += renderStepRow(idx, s); + }); + html += ``; + html += '
'; + html += ` `; + html += ''; + html += '
'; + body.innerHTML = html; +} + +function renderStepRow(idx, step) { + const svcId = step.service_id || ''; + const op = step.operation || ''; + const params = step.params || []; + let html = `
`; + html += `
Шаг ${idx+1}`; + html += ` `; + html += `
`; + // Сервис — текстовое поле с подсказкой (ID или имя) + html += ` `; + // Операция + html += ` `; + html += '
'; + params.forEach(([k, v], pi) => { + html += `
`; + html += ``; + html += `=`; + html += ``; + html += ``; + html += `
`; + }); + html += ``; + html += '
'; + return html; +} + +function addStep() { + scenarioEditorState.steps.push({ service_id: 1, operation: 'create', params: [] }); + renderEditor(); +} + +function removeStep(idx) { + scenarioEditorState.steps.splice(idx, 1); + renderEditor(); +} + +function addParam(idx) { + scenarioEditorState.steps[idx].params.push(['', '']); +} + +function removeParam(idx, pi) { + scenarioEditorState.steps[idx].params.splice(pi, 1); +} + +function onStepChange(idx) { /* placeholder for future auto-load */ } + +function collectFormSteps() { + const st = scenarioEditorState; + const name = (document.getElementById('scenario-name')?.value || '').trim(); + const steps = []; + st.steps.forEach((_, idx) => { + const svcEl = document.getElementById(`step-${idx}-svc`); + const opEl = document.getElementById(`step-${idx}-op`); + const svcId = parseInt(svcEl?.value) || 0; + const operation = (opEl?.value || '').trim(); + const params = {}; + st.steps[idx].params.forEach((__, pi) => { + const k = document.getElementById(`step-${idx}-pk-${pi}`)?.value?.trim(); + const v = document.getElementById(`step-${idx}-pv-${pi}`)?.value || ''; + if (k) params[k] = v; + }); + steps.push({ service_id: svcId, operation, params }); + }); + return { name, steps }; +} + +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) + ': укажите service_id'); return; } + if (!steps[i].operation) { alert('Шаг ' + (i+1) + ': укажите operation'); return; } + } + const st = scenarioEditorState; + const isNew = !st.defId; + const url = isNew ? '/api/scenario/definitions' : '/api/scenario/definitions/' + st.defId; + const method = isNew ? 'POST' : 'PUT'; + 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 d = await r.json(); + if (r.status === 409) { + alert('⚠️ Конфликт версий — кто-то уже изменил сценарий. Обновите страницу.'); + return; + } + if (d.error) { alert(d.error); return; } + if (isNew && d.id) st.defId = d.id; + if (d.version) st.version = d.version; + await loadScenarios(); + } catch (e) { + alert('Ошибка сохранения: ' + e.message); + } +} diff --git a/site/static/js/scenario-list.js b/site/static/js/scenario-list.js new file mode 100644 index 0000000..9d8c6e4 --- /dev/null +++ b/site/static/js/scenario-list.js @@ -0,0 +1,90 @@ +// scenario-list.js — список сценариев, запуск, поллинг + +let scenarioOpen=false, scenarioPollTimer=null; + +async function toggleScenario(){ + const body=document.getElementById('scenario-body'); + scenarioOpen=!scenarioOpen; + body.style.display=scenarioOpen?'block':'none'; + if(!scenarioOpen){ stopScenarioPoll(); return; } + await loadScenarios(); +} + +async function loadScenarios(){ + const body=document.getElementById('scenario-body'); + try{ + const [sr, srHistory]=await Promise.all([ + 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; + html+=`
`; + html+=``; + if(!seed) html+=``; + html+=``; + if(!seed) html+=``; + if(seed) html+=`seed`; + html+=`
`; + }); + html+='
'; + }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):'?'; + 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='Ошибка загрузки';} +} + +async function runScenario(defId){ + if(busy){ 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 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/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}
`; + 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(); + } + }catch(e){} + },3000); + }catch(e){ + body.innerHTML=`Ошибка: ${_esc(e.message)}`; + busy=false; + } +} + +function stopScenarioPoll(){ + if(scenarioPollTimer){ clearInterval(scenarioPollTimer); scenarioPollTimer=null; } +} diff --git a/site/static/js/utils.js b/site/static/js/utils.js new file mode 100644 index 0000000..b28f442 --- /dev/null +++ b/site/static/js/utils.js @@ -0,0 +1,45 @@ +// utils.js — общие хелперы: _esc, busy lock, validateJson, лог-панель + +function _esc(s){ + /* HTML-экранирование: " → " & → & < → < */ + return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/{ + const el=document.getElementById('log-panel'); + if(!el||el.style.display==='none') return; + try{ + const r=await fetch('/api/log'); + const lines=await r.json(); + el.innerHTML=lines.map(l=>`
${_esc(l)}
`).join(''); + el.scrollTop=el.scrollHeight; + }catch(e){} + },2000); +} +function toggleLog(){ + const el=document.getElementById('log-panel'); + if(!el) return; + if(el.style.display==='none'){ el.style.display='block'; startLogPoll(); } + else el.style.display='none'; +} diff --git a/site/templates/index.html b/site/templates/index.html index de08ffb..bb48b77 100644 --- a/site/templates/index.html +++ b/site/templates/index.html @@ -140,7 +140,7 @@ + + + + + + + + +