v1.1.55: split large files + CRUD scenario editor
Backend (5→7 files): - api_test.py: 622→479, terraform functions → operations/terraform.py (142) - api_scenario.py: 241→split into run (153) + defs (108) with _validate_steps() - db/scenario_defs.py: +client_id, stand, is_seed in list_definitions Frontend (1→10 files): - app.js: 554→16 (loader), split into: utils.js (45), instances.js (89), operations.js (249), history.js (35), scenario-list.js (90) - New CRUD: scenario-form.js (128), create (25), edit (12), delete (13) Max file size: JS 249, PY 479
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
"""
|
||||
CRUD определений сценариев.
|
||||
|
||||
GET /api/scenario/definitions — список
|
||||
POST /api/scenario/definitions — создать
|
||||
GET /api/scenario/definitions/<id> — один
|
||||
PUT /api/scenario/definitions/<id> — обновить (version → 409)
|
||||
DELETE /api/scenario/definitions/<id> — мягкое удаление
|
||||
"""
|
||||
|
||||
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/<int:def_id>", 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})
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
API сценариев: запуск + поллинг.
|
||||
|
||||
GET /api/scenarios — список из БД (для UI)
|
||||
POST /api/scenario/run — запуск по definition_id (409 если уже RUNNING)
|
||||
GET /api/scenario/run/<int:run_id> — статус конкретного запуска
|
||||
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/<int:run_id>")
|
||||
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)
|
||||
+5
-148
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user