v1.1.46: phase2 — scenario runner MVP (YAML config, DB schema, API endpoints, UI section)
This commit is contained in:
+3
-1
@@ -15,9 +15,10 @@ from flask import Flask
|
|||||||
|
|
||||||
from routes.main import bp as main_bp
|
from routes.main import bp as main_bp
|
||||||
from routes.api_test import bp as api_test_bp
|
from routes.api_test import bp as api_test_bp
|
||||||
|
from routes.api_scenario import bp as api_scenario_bp
|
||||||
|
|
||||||
# Версия — меняется при КАЖДОМ изменении кода. Показывается в топбаре UI.
|
# Версия — меняется при КАЖДОМ изменении кода. Показывается в топбаре UI.
|
||||||
VERSION = "1.1.45"
|
VERSION = "1.1.46"
|
||||||
|
|
||||||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
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")
|
app.config["NUBES_API_ENDPOINT"] = os.getenv("NUBES_API_ENDPOINT", "https://lk-api-gateway-test.ngcloud.ru/api/v1/svc")
|
||||||
@@ -25,6 +26,7 @@ app.config["NUBES_API_TOKEN"] = os.getenv("NUBES_API_TOKEN", "")
|
|||||||
app.config["VERSION"] = VERSION
|
app.config["VERSION"] = VERSION
|
||||||
app.register_blueprint(main_bp)
|
app.register_blueprint(main_bp)
|
||||||
app.register_blueprint(api_test_bp)
|
app.register_blueprint(api_test_bp)
|
||||||
|
app.register_blueprint(api_scenario_bp)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/health")
|
@app.route("/health")
|
||||||
|
|||||||
@@ -26,3 +26,13 @@ services:
|
|||||||
- name: delete
|
- name: delete
|
||||||
enabled: false
|
enabled: false
|
||||||
params: {}
|
params: {}
|
||||||
|
|
||||||
|
scenarios:
|
||||||
|
dummy_test:
|
||||||
|
steps:
|
||||||
|
- svc: dummy
|
||||||
|
op: create
|
||||||
|
params:
|
||||||
|
durationMs: "5000"
|
||||||
|
- svc: dummy
|
||||||
|
op: delete
|
||||||
|
|||||||
@@ -56,6 +56,30 @@ MIGRATION_SQL = """
|
|||||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS op_uid VARCHAR(64);
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS op_uid VARCHAR(64);
|
||||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS user_email VARCHAR(128);
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS user_email VARCHAR(128);
|
||||||
ALTER TABLE runs ADD COLUMN IF NOT EXISTS app_version VARCHAR(16);
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS app_version VARCHAR(16);
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS scenario_run_id INTEGER;
|
||||||
|
ALTER TABLE runs ADD COLUMN IF NOT EXISTS step_number INTEGER;
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Таблица сценариев — один запуск = одна строка
|
||||||
|
SCENARIO_RUNS_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS scenario_runs (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
client_id VARCHAR(64) NOT NULL,
|
||||||
|
stand VARCHAR(16) NOT NULL,
|
||||||
|
user_email VARCHAR(128),
|
||||||
|
scenario_name VARCHAR(200) NOT NULL,
|
||||||
|
status VARCHAR(16) NOT NULL DEFAULT 'RUNNING',
|
||||||
|
current_step INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_steps INTEGER NOT NULL,
|
||||||
|
instance_bindings JSONB DEFAULT '{}',
|
||||||
|
duration_sec DOUBLE PRECISION,
|
||||||
|
error_log TEXT,
|
||||||
|
app_version VARCHAR(16)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scenario_runs_client_stand
|
||||||
|
ON scenario_runs (client_id, stand, created_at DESC);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -74,6 +98,7 @@ def init_db():
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute(SCHEMA_SQL)
|
cur.execute(SCHEMA_SQL)
|
||||||
cur.execute(MIGRATION_SQL)
|
cur.execute(MIGRATION_SQL)
|
||||||
|
cur.execute(SCENARIO_RUNS_SQL)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
print("[DB] Schema initialized", flush=True)
|
print("[DB] Schema initialized", flush=True)
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"""
|
||||||
|
Scenario runner — символические коды → API → шаги → результат.
|
||||||
|
|
||||||
|
Формат сценария в config.yaml:
|
||||||
|
scenarios:
|
||||||
|
dummy_test:
|
||||||
|
steps:
|
||||||
|
- svc: dummy # символическое имя (из services: в config.yaml)
|
||||||
|
op: create
|
||||||
|
params:
|
||||||
|
durationMs: "5000"
|
||||||
|
- svc: dummy
|
||||||
|
op: delete
|
||||||
|
|
||||||
|
Для каждого шага:
|
||||||
|
1. service имя → svc_id (из config.yaml services)
|
||||||
|
2. operation имя → svcOperationId (из GET /services/{id})
|
||||||
|
3. param коды → numeric IDs (из GET /instanceOperations/default/{opId})
|
||||||
|
4. create: новый инстанс; остальные: переиспользовать инстанс этого сервиса
|
||||||
|
5. Выполнить через API (как в api_test)
|
||||||
|
6. Сохранить результат в runs + scenario_runs
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from runner import load_config
|
||||||
|
from operations.get_services import get_service_detail
|
||||||
|
from operations.get_instances import get_instances
|
||||||
|
from db.pool import get_conn, put_conn
|
||||||
|
from db.save_run import save_run
|
||||||
|
|
||||||
|
AUTOTEST_PREFIX = "autotest-scenario-"
|
||||||
|
|
||||||
|
|
||||||
|
def load_scenarios():
|
||||||
|
"""Загрузить словарь сценариев из config.yaml."""
|
||||||
|
cfg = load_config() or {}
|
||||||
|
return cfg.get("scenarios", {})
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_service(config, svc_name):
|
||||||
|
"""Найти svc_id по символическому имени сервиса из config.yaml services."""
|
||||||
|
for s in config.get("services", []):
|
||||||
|
if s.get("name") == svc_name:
|
||||||
|
return s
|
||||||
|
raise ValueError(f"Service '{svc_name}' not found in config.yaml services")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_params(cfs_params, symbolic_params):
|
||||||
|
"""Символические коды → {numeric_id: value}.
|
||||||
|
cfs_params — список из GET /instanceOperations/default/{opId}
|
||||||
|
symbolic_params — dict {"durationMs": "5000"}
|
||||||
|
"""
|
||||||
|
code_to_id = {p.get("svcOperationCfsParam", ""): p["svcOperationCfsParamId"] for p in cfs_params}
|
||||||
|
result = {}
|
||||||
|
for code, val in symbolic_params.items():
|
||||||
|
pid = code_to_id.get(code)
|
||||||
|
if pid is None:
|
||||||
|
raise ValueError(f"Param code '{code}' not found in operation template")
|
||||||
|
result[str(pid)] = str(val)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _save_scenario_run(scenario_run_id, status, current_step=0, duration_sec=None, error_log=None):
|
||||||
|
"""Обновить запись scenario_runs."""
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE scenario_runs
|
||||||
|
SET status = %s, current_step = %s, duration_sec = %s, error_log = %s
|
||||||
|
WHERE id = %s
|
||||||
|
""", (status, current_step, duration_sec, error_log, scenario_run_id))
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[SCENARIO] save_scenario_run error: {e}", flush=True)
|
||||||
|
conn.rollback()
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_scenario_run(client_id, stand, user_email, scenario_name, total_steps, app_version):
|
||||||
|
"""Создать запись в scenario_runs, вернуть ID."""
|
||||||
|
conn = get_conn()
|
||||||
|
if not conn:
|
||||||
|
return None
|
||||||
|
rid = None
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO scenario_runs (client_id, stand, user_email, scenario_name,
|
||||||
|
status, current_step, total_steps, app_version)
|
||||||
|
VALUES (%s, %s, %s, %s, 'RUNNING', 0, %s, %s)
|
||||||
|
RETURNING id
|
||||||
|
""", (client_id, stand, user_email, scenario_name, total_steps, app_version))
|
||||||
|
rid = cur.fetchone()[0]
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[SCENARIO] create_scenario_run error: {e}", flush=True)
|
||||||
|
conn.rollback()
|
||||||
|
finally:
|
||||||
|
put_conn(conn)
|
||||||
|
return rid
|
||||||
|
|
||||||
|
|
||||||
|
def run_scenario(client, scenario_name, client_id, stand, user_email, app_version):
|
||||||
|
"""Главный исполнитель сценария. Вызывается в фоновом потоке.
|
||||||
|
Возвращает scenario_run_id для поллинга."""
|
||||||
|
config = load_config() or {}
|
||||||
|
scenarios = config.get("scenarios", {})
|
||||||
|
scenario = scenarios.get(scenario_name)
|
||||||
|
if not scenario:
|
||||||
|
raise ValueError(f"Scenario '{scenario_name}' not found")
|
||||||
|
|
||||||
|
steps = scenario.get("steps", [])
|
||||||
|
if not steps:
|
||||||
|
raise ValueError(f"Scenario '{scenario_name}' has no steps")
|
||||||
|
|
||||||
|
total = len(steps)
|
||||||
|
|
||||||
|
# Создать запись в 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")
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
instance_map = {} # svc_name → instanceUid (переиспользование внутри сценария)
|
||||||
|
|
||||||
|
for i, step in enumerate(steps):
|
||||||
|
step_num = i + 1
|
||||||
|
svc_name = step.get("svc", "")
|
||||||
|
op_name = step.get("op", "")
|
||||||
|
symbolic_params = step.get("params", {})
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Сервис
|
||||||
|
svc_info = _resolve_service(config, svc_name)
|
||||||
|
svc_id = svc_info["service_id"]
|
||||||
|
|
||||||
|
# 2. Операция
|
||||||
|
detail = get_service_detail(client, svc_id)
|
||||||
|
ops = detail.get("operations", [])
|
||||||
|
op = next((o for o in ops if o.get("operation") == op_name), None)
|
||||||
|
if not op:
|
||||||
|
raise ValueError(f"Operation '{op_name}' not found for service {svc_name}")
|
||||||
|
svc_op_id = op["svcOperationId"]
|
||||||
|
|
||||||
|
# 3. Параметры
|
||||||
|
tmpl_data = client.get(f"/instanceOperations/default/{svc_op_id}")
|
||||||
|
cfs_params = tmpl_data.get("svcOperation", {}).get("cfsParams", [])
|
||||||
|
resolved_params = _resolve_params(cfs_params, symbolic_params)
|
||||||
|
|
||||||
|
# 4. Инстанс
|
||||||
|
if op_name == "create":
|
||||||
|
display_name = f"{AUTOTEST_PREFIX}{scenario_name}-{uuid.uuid4().hex[:6]}"
|
||||||
|
descr = f"scenario {scenario_name} step {step_num}"
|
||||||
|
payload = {"serviceId": svc_id, "displayName": display_name, "descr": descr}
|
||||||
|
resp = client.post("/instances", payload)
|
||||||
|
instance_uid = resp.get("instanceUid")
|
||||||
|
if not instance_uid:
|
||||||
|
raise RuntimeError("CREATE: no instanceUid in response")
|
||||||
|
instance_map[svc_name] = instance_uid
|
||||||
|
else:
|
||||||
|
instance_uid = instance_map.get(svc_name)
|
||||||
|
if not instance_uid:
|
||||||
|
raise RuntimeError(f"No instance for service '{svc_name}' — need CREATE first")
|
||||||
|
|
||||||
|
# 5. Запуск операции
|
||||||
|
op_payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id, "operation": op_name}
|
||||||
|
op_resp = client.post("/instanceOperations", op_payload)
|
||||||
|
op_uid = op_resp.get("instanceOperationUid") or op_resp.get("instanceOperation", {}).get("instanceOperationUid")
|
||||||
|
if not op_uid:
|
||||||
|
op_uid = next((v for k, v in op_resp.items() if "Uid" in k or "uid" in k), None)
|
||||||
|
if not op_uid and "_location" in op_resp:
|
||||||
|
loc = op_resp["_location"]
|
||||||
|
op_uid = loc.rsplit("/", 1)[-1] if "/" in loc else loc
|
||||||
|
if not op_uid:
|
||||||
|
raise RuntimeError("No opUid in response")
|
||||||
|
|
||||||
|
# Отправить параметры и запустить
|
||||||
|
from routes.api_test import _send_params_terraform
|
||||||
|
_send_params_terraform(client, op_uid, resolved_params)
|
||||||
|
client.post(f"/instanceOperations/{op_uid}/run")
|
||||||
|
|
||||||
|
# 6. Поллинг завершения
|
||||||
|
step_t0 = time.time()
|
||||||
|
deadline = step_t0 + 1800
|
||||||
|
step_status = "TIMEOUT"
|
||||||
|
step_error = ""
|
||||||
|
step_duration = 0
|
||||||
|
step_stages = []
|
||||||
|
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
data = client.get(f"/instanceOperations/{op_uid}?fields=dtFinish,isSuccessful,errorLog,duration,stages,svc")
|
||||||
|
except Exception:
|
||||||
|
time.sleep(5)
|
||||||
|
continue
|
||||||
|
op_data = data.get("instanceOperation", {})
|
||||||
|
dt_finish = op_data.get("dtFinish")
|
||||||
|
if dt_finish and str(dt_finish).strip():
|
||||||
|
is_ok = op_data.get("isSuccessful")
|
||||||
|
err = op_data.get("errorLog") or ""
|
||||||
|
step_status = "OK" if is_ok else "FAIL"
|
||||||
|
step_error = str(err) if err else ""
|
||||||
|
step_duration = round(time.time() - step_t0, 1)
|
||||||
|
step_stages = op_data.get("stages", [])
|
||||||
|
break
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
# 7. Сохранить шаг в runs
|
||||||
|
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,
|
||||||
|
step_status, step_duration, step_error,
|
||||||
|
resolved_params, step_stages, app_version)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[SCENARIO] save_run failed for step {step_num}: {e}", flush=True)
|
||||||
|
|
||||||
|
# Обновить scenario_runs
|
||||||
|
_save_scenario_run(scenario_run_id, "RUNNING" if step_num < total else step_status,
|
||||||
|
step_num, round(time.time() - t0, 1),
|
||||||
|
step_error if step_status != "OK" else None)
|
||||||
|
|
||||||
|
if step_status != "OK":
|
||||||
|
# Прервать сценарий при ошибке
|
||||||
|
_save_scenario_run(scenario_run_id, step_status, step_num,
|
||||||
|
round(time.time() - t0, 1), step_error)
|
||||||
|
return
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
err = f"Step {step_num}: {e}"
|
||||||
|
print(f"[SCENARIO] {err}", flush=True)
|
||||||
|
traceback.print_exc()
|
||||||
|
_save_scenario_run(scenario_run_id, "FAIL", step_num,
|
||||||
|
round(time.time() - t0, 1), err)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Все шаги OK
|
||||||
|
_save_scenario_run(scenario_run_id, "OK", total, round(time.time() - t0, 1))
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""
|
||||||
|
API для запуска сценариев автотестирования.
|
||||||
|
|
||||||
|
POST /api/scenario/run — запустить сценарий (фон)
|
||||||
|
GET /api/scenario/status/<id> — поллинг статуса сценария
|
||||||
|
GET /api/scenarios — список доступных сценариев
|
||||||
|
GET /api/scenario/history — история запусков сценариев
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 db.pool import get_conn, put_conn
|
||||||
|
|
||||||
|
bp = Blueprint("api_scenario", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/scenarios")
|
||||||
|
def api_scenarios():
|
||||||
|
"""Список доступных сценариев из config.yaml."""
|
||||||
|
try:
|
||||||
|
scenarios = load_scenarios()
|
||||||
|
result = [{"name": name, "steps": len(s.get("steps", []))} for name, s in scenarios.items()]
|
||||||
|
return jsonify(sorted(result, key=lambda x: x["name"]))
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/scenario/run", methods=["POST"])
|
||||||
|
def api_scenario_run():
|
||||||
|
"""Запустить сценарий. Возвращает scenario_run_id сразу, выполнение в фоне."""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
scenario_name = (data.get("scenario") or "").strip()
|
||||||
|
if not scenario_name:
|
||||||
|
return jsonify({"error": "scenario name is required"}), 400
|
||||||
|
|
||||||
|
client = get_client()
|
||||||
|
user_email = get_token_info().get("email", "")
|
||||||
|
app_version = current_app.config.get("VERSION", "")
|
||||||
|
|
||||||
|
# Запуск в фоне
|
||||||
|
threading.Thread(
|
||||||
|
target=run_scenario,
|
||||||
|
args=(client, scenario_name, get_client_id(), get_stand(), user_email, app_version),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
|
||||||
|
return jsonify({"status": "RUNNING", "scenario": scenario_name})
|
||||||
|
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/status")
|
||||||
|
def api_scenario_status():
|
||||||
|
"""Последний запущенный сценарий (или несколько последних)."""
|
||||||
|
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
|
||||||
|
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)
|
||||||
@@ -468,4 +468,84 @@ function toggleLog(){
|
|||||||
if(el.style.display==='none'){ el.style.display='block'; startLogPoll(); }
|
if(el.style.display==='none'){ el.style.display='block'; startLogPoll(); }
|
||||||
else el.style.display='none';
|
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+='<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:4px;">';
|
||||||
|
sr.forEach(s=>{
|
||||||
|
html+=`<button class="btn btn-sm" style="font-size:11px;" onclick="runScenario('${_esc(s.name)}')">▶ ${_esc(s.name)} <span style="color:var(--muted);">(${s.steps} шагов)</span></button>`;
|
||||||
|
});
|
||||||
|
html+='</div>';
|
||||||
|
}else{
|
||||||
|
html+='<span style="color:var(--muted);font-size:11px;">Нет сценариев в config.yaml</span>';
|
||||||
|
}
|
||||||
|
// Последний запуск
|
||||||
|
if(srHistory && srHistory.length){
|
||||||
|
const last=srHistory[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+=`<div style="font-size:11px;margin-top:4px;color:var(--muted);">Последний: ${icon} ${_esc(last.scenario_name)} — ${last.status} (шаг ${last.current_step}/${last.total_steps}) ${last.duration_sec!=null?last.duration_sec.toFixed(1)+'s':''} — ${time}</div>`;
|
||||||
|
if(last.error_log) html+=`<div style="font-size:10px;color:var(--destructive);">${_esc(last.error_log)}</div>`;
|
||||||
|
}
|
||||||
|
body.innerHTML=html;
|
||||||
|
}catch(e){body.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runScenario(name){
|
||||||
|
if(busy){ return; }
|
||||||
|
busy=true;
|
||||||
|
stopScenarioPoll();
|
||||||
|
const body=document.getElementById('scenario-body');
|
||||||
|
body.innerHTML=`<div style="font-size:11px;">⏳ Запуск сценария ${_esc(name)}...</div>`;
|
||||||
|
try{
|
||||||
|
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=`<span style="color:var(--destructive);">Ошибка: ${_esc(d.error)}</span>`; 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 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=`<div style="font-size:11px;">${icon} ${_esc(last.scenario_name)} — ${last.status} (шаг ${last.current_step}/${last.total_steps}) ${last.duration_sec!=null?last.duration_sec.toFixed(1)+'s':''} — ${time}</div>`;
|
||||||
|
if(last.error_log) html+=`<div style="font-size:10px;color:var(--destructive);">${_esc(last.error_log)}</div>`;
|
||||||
|
body.innerHTML=html;
|
||||||
|
if(last.status!=='RUNNING'){
|
||||||
|
stopScenarioPoll();
|
||||||
|
busy=false;
|
||||||
|
await refreshInstances();
|
||||||
|
if(historyOpen) await loadHistory();
|
||||||
|
}
|
||||||
|
}catch(e){}
|
||||||
|
},3000);
|
||||||
|
}catch(e){
|
||||||
|
body.innerHTML=`<span style="color:var(--destructive);">Ошибка: ${_esc(e.message)}</span>`;
|
||||||
|
busy=false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopScenarioPoll(){
|
||||||
|
if(scenarioPollTimer){ clearInterval(scenarioPollTimer); scenarioPollTimer=null; }
|
||||||
|
}
|
||||||
startLogPoll();
|
startLogPoll();
|
||||||
@@ -130,6 +130,12 @@
|
|||||||
<span style="color:var(--muted);font-size:11px;">Загрузка...</span>
|
<span style="color:var(--muted);font-size:11px;">Загрузка...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card" id="scenario-card" style="margin-top:8px;">
|
||||||
|
<div class="card-header" style="font-size:12px;cursor:pointer;" onclick="toggleScenario()">Сценарии <span style="color:var(--muted);font-size:10px;">(автотесты)</span></div>
|
||||||
|
<div class="card-body" style="padding:4px;display:none;" id="scenario-body">
|
||||||
|
<span style="color:var(--muted);font-size:11px;">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user