Подробные комментарии ко всем JS-файлам фронтенда
This commit is contained in:
+37
-11
@@ -1,12 +1,24 @@
|
|||||||
"""
|
"""
|
||||||
app-autotest — точка входа Flask-приложения для автотестов Nubes.
|
app-autotest — точка входа Flask-приложения для автотестов Nubes.
|
||||||
|
|
||||||
Регистрирует три blueprint'а:
|
Это приложение позволяет:
|
||||||
- main_bp → / (главная, токен, инфраструктура)
|
1. Тестировать операции с сервисами Nubes (create/modify/delete/...) через UI
|
||||||
- api_bp → /api/run, /api/status, /api/config [LEGACY]
|
2. Создавать и запускать сценарии (последовательности операций)
|
||||||
- api_test_bp → /api/test, /api/params, /api/log (основная логика)
|
3. Просматривать историю запусков и логи
|
||||||
|
|
||||||
Деплой: Nubes pythonk8s (gunicorn, несколько воркеров).
|
Blueprint'ы (каждый — отдельный файл в routes/):
|
||||||
|
- main_bp → / (главная: токен, инфраструктура, сервисы)
|
||||||
|
- api_test_bp → /api/test, /api/params, /api/log (ручной режим)
|
||||||
|
- api_scenario_run_bp → /api/scenario/run, /api/scenario/status (запуск сценариев)
|
||||||
|
- api_scenario_defs_bp → /api/scenario/definitions (CRUD сценариев)
|
||||||
|
|
||||||
|
Деплой: Nubes pythonk8s (managed service) с gunicorn:
|
||||||
|
gunicorn app:app --workers 2 --bind 0.0.0.0:8000
|
||||||
|
|
||||||
|
Конфигурация:
|
||||||
|
NUBES_API_ENDPOINT — URL API (по умолчанию test)
|
||||||
|
NUBES_API_TOKEN — сервисный токен (из переменных окружения k8s)
|
||||||
|
VERSION — версия приложения (меняется при КАЖДОМ изменении кода)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -18,23 +30,37 @@ from routes.api_test import bp as api_test_bp
|
|||||||
from routes.api_scenario_run import bp_run as api_scenario_run_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
|
from routes.api_scenario_defs import bp_defs as api_scenario_defs_bp
|
||||||
|
|
||||||
# Версия — меняется при КАЖДОМ изменении кода. Показывается в топбаре UI.
|
# Версия — показывается в топбаре UI. Меняется при КАЖДОМ изменении кода.
|
||||||
|
# Нужна для фильтрации истории (пользователь видит только записи своей версии).
|
||||||
VERSION = "1.2.16"
|
VERSION = "1.2.16"
|
||||||
|
|
||||||
|
# Flask-приложение с Jinja2-шаблонами из папки templates/
|
||||||
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")
|
|
||||||
|
# Конфигурация из переменных окружения (задаются в Nubes UI при создании сервиса)
|
||||||
|
app.config["NUBES_API_ENDPOINT"] = os.getenv(
|
||||||
|
"NUBES_API_ENDPOINT",
|
||||||
|
"https://lk-api-gateway-test.ngcloud.ru/api/v1/svc"
|
||||||
|
)
|
||||||
app.config["NUBES_API_TOKEN"] = os.getenv("NUBES_API_TOKEN", "")
|
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(api_test_bp)
|
# Регистрируем blueprint'ы — каждый отвечает за свою группу маршрутов
|
||||||
app.register_blueprint(api_scenario_run_bp)
|
app.register_blueprint(main_bp) # /
|
||||||
app.register_blueprint(api_scenario_defs_bp)
|
app.register_blueprint(api_test_bp) # /api/test, /api/params, ...
|
||||||
|
app.register_blueprint(api_scenario_run_bp) # /api/scenario/run, ...
|
||||||
|
app.register_blueprint(api_scenario_defs_bp) # /api/scenario/definitions, ...
|
||||||
|
|
||||||
|
|
||||||
@app.route("/health")
|
@app.route("/health")
|
||||||
def health():
|
def health():
|
||||||
|
"""Healthcheck — всегда возвращает 200 OK.
|
||||||
|
|
||||||
|
Используется Nubes для проверки живости сервиса.
|
||||||
|
Если не ответит — сервис будет перезапущен."""
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
|
# Локальный запуск (без gunicorn) — только для разработки
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(debug=True, host="0.0.0.0", port=5000)
|
app.run(debug=True, host="0.0.0.0", port=5000)
|
||||||
|
|||||||
+76
-12
@@ -1,13 +1,28 @@
|
|||||||
"""
|
"""
|
||||||
CRUD для таблицы scenario_definitions.
|
CRUD для таблицы scenario_definitions — определения сценариев.
|
||||||
|
|
||||||
|
Сценарии редактируются через UI (модальный редактор), хранятся в БД.
|
||||||
|
Это позволяет менять сценарии БЕЗ редеплоя приложения.
|
||||||
|
|
||||||
|
ОПТИМИСТИЧНАЯ БЛОКИРОВКА (version):
|
||||||
|
update_definition проверяет version — если другой пользователь уже изменил
|
||||||
|
сценарий, возвращает conflict=true → UI показывает "Конфликт версий".
|
||||||
|
|
||||||
|
МЯГКОЕ УДАЛЕНИЕ:
|
||||||
|
delete_definition ставит is_active=FALSE — не удаляет физически.
|
||||||
|
Это позволяет восстановить случайно удалённый сценарий.
|
||||||
|
|
||||||
|
SEED-СЦЕНАРИИ:
|
||||||
|
Сценарии с client_id="" и stand="" — seed (дефолтные, из YAML).
|
||||||
|
Они доступны всем пользователям но не могут быть удалены через UI.
|
||||||
|
|
||||||
Функции:
|
Функции:
|
||||||
list_definitions(client_id, stand) — список активных
|
list_definitions(client_id, stand) — список активных (+seed)
|
||||||
get_definition(def_id, cid, stand) — один
|
get_definition(def_id, cid, stand) — один (по id)
|
||||||
create_definition(cid, stand, name, steps, updated_by) → id
|
create_definition(...) → {id, version}
|
||||||
update_definition(def_id, cid, stand, name, steps, version, updated_by) → ok|conflict
|
update_definition(...) → {ok, version} | {conflict: true}
|
||||||
delete_definition(def_id, cid, stand) — мягкое (is_active=false)
|
delete_definition(...) → bool
|
||||||
lock_check(cid, stand) — есть ли RUNNING запуск → 409 если да
|
lock_check(cid, stand) — есть ли RUNNING → False
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -15,11 +30,20 @@ from db.pool import get_conn, put_conn
|
|||||||
|
|
||||||
|
|
||||||
def list_definitions(client_id, stand):
|
def list_definitions(client_id, stand):
|
||||||
|
"""Список ВСЕХ активных определений: пользовательские + seed.
|
||||||
|
|
||||||
|
Seed-сценарии (client_id="" AND stand="") видны всем.
|
||||||
|
Пользовательские — только для текущего пользователя и стенда.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[dict] с ключами: id, name, steps, version, is_active,
|
||||||
|
created_at, updated_at, client_id, stand, is_seed (bool)."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
if not conn:
|
if not conn:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
# Два условия через OR: пользовательские + seed
|
||||||
cur.execute("""
|
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
|
client_id, stand
|
||||||
@@ -33,8 +57,10 @@ def list_definitions(client_id, stand):
|
|||||||
result = []
|
result = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
d = dict(zip(cols, r))
|
d = dict(zip(cols, r))
|
||||||
|
# Конвертируем datetime → ISO-строка (JSON-совместимо)
|
||||||
d["created_at"] = d["created_at"].isoformat() if d["created_at"] else None
|
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["updated_at"] = d["updated_at"].isoformat() if d["updated_at"] else None
|
||||||
|
# is_seed: client_id пустой → seed (из YAML)
|
||||||
d["is_seed"] = (d.get("client_id") == "" and d.get("stand") == "")
|
d["is_seed"] = (d.get("client_id") == "" and d.get("stand") == "")
|
||||||
result.append(d)
|
result.append(d)
|
||||||
return result
|
return result
|
||||||
@@ -46,6 +72,12 @@ def list_definitions(client_id, stand):
|
|||||||
|
|
||||||
|
|
||||||
def get_definition(def_id, client_id, stand):
|
def get_definition(def_id, client_id, stand):
|
||||||
|
"""Получить ОДНО определение по id.
|
||||||
|
|
||||||
|
Доступ: пользовательские (client_id+stand) ИЛИ seed (пустые).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict или None если не найдено."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
if not conn:
|
if not conn:
|
||||||
return None
|
return None
|
||||||
@@ -72,6 +104,16 @@ def get_definition(def_id, client_id, stand):
|
|||||||
|
|
||||||
|
|
||||||
def create_definition(client_id, stand, name, steps, updated_by=""):
|
def create_definition(client_id, stand, name, steps, updated_by=""):
|
||||||
|
"""Создать ИЛИ перезаписать определение (upsert).
|
||||||
|
|
||||||
|
ON CONFLICT (client_id, stand, LOWER(name)):
|
||||||
|
Если сценарий с таким именем уже существует → UPDATE (перезапись steps, version+1).
|
||||||
|
Если нет → INSERT.
|
||||||
|
|
||||||
|
Это позволяет «сохранить» без проверки существования.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{id: int, version: int} или None при ошибке."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
if not conn:
|
if not conn:
|
||||||
return None
|
return None
|
||||||
@@ -98,7 +140,15 @@ def create_definition(client_id, stand, name, steps, updated_by=""):
|
|||||||
|
|
||||||
|
|
||||||
def update_definition(def_id, client_id, stand, name, steps, version, updated_by=""):
|
def update_definition(def_id, client_id, stand, name, steps, version, updated_by=""):
|
||||||
"""Возвращает dict {ok, version} или {conflict: True}."""
|
"""Обновить определение с ОПТИМИСТИЧНОЙ БЛОКИРОВКОЙ.
|
||||||
|
|
||||||
|
WHERE version = %s — если версия изменилась (другой пользователь/вкладка),
|
||||||
|
UPDATE затронет 0 строк → возвращаем {conflict: true}.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{ok: True, version: int} — успех
|
||||||
|
{conflict: True} — версия устарела (409 Conflict)
|
||||||
|
{error: str} — ошибка БД"""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
if not conn:
|
if not conn:
|
||||||
return {"error": "no db"}
|
return {"error": "no db"}
|
||||||
@@ -116,6 +166,7 @@ def update_definition(def_id, client_id, stand, name, steps, version, updated_by
|
|||||||
cur.close()
|
cur.close()
|
||||||
if row:
|
if row:
|
||||||
return {"ok": True, "version": row[0]}
|
return {"ok": True, "version": row[0]}
|
||||||
|
# 0 строк — версия не совпала (кто-то уже изменил)
|
||||||
return {"conflict": True}
|
return {"conflict": True}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[DEFS] update error: {e}", flush=True)
|
print(f"[DEFS] update error: {e}", flush=True)
|
||||||
@@ -126,7 +177,13 @@ def update_definition(def_id, client_id, stand, name, steps, version, updated_by
|
|||||||
|
|
||||||
|
|
||||||
def delete_definition(def_id, client_id, stand):
|
def delete_definition(def_id, client_id, stand):
|
||||||
"""Мягкое удаление."""
|
"""Мягкое удаление: is_active = FALSE.
|
||||||
|
|
||||||
|
Только для пользовательских сценариев (client_id не пустой).
|
||||||
|
Seed-сценарии не могут быть удалены через этот метод.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool — True если удалён, False если не найден."""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
if not conn:
|
if not conn:
|
||||||
return False
|
return False
|
||||||
@@ -150,10 +207,17 @@ def delete_definition(def_id, client_id, stand):
|
|||||||
|
|
||||||
|
|
||||||
def lock_check(client_id, stand):
|
def lock_check(client_id, stand):
|
||||||
"""Проверить нет ли активного RUNNING запуска. True = можно запускать."""
|
"""Проверить что нет активного RUNNING-запуска сценария.
|
||||||
|
|
||||||
|
Используется перед запуском нового сценария — предотвращает
|
||||||
|
одновременный запуск двух сценариев одним пользователем.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True — можно запускать (нет RUNNING)
|
||||||
|
False — нельзя (уже есть RUNNING) → 409 Conflict"""
|
||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
if not conn:
|
if not conn:
|
||||||
return True # без БД — разрешаем
|
return True # без БД — разрешаем (fallback)
|
||||||
try:
|
try:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
@@ -163,7 +227,7 @@ def lock_check(client_id, stand):
|
|||||||
""", (client_id, stand))
|
""", (client_id, stand))
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
cur.close()
|
cur.close()
|
||||||
return row is None
|
return row is None # None = нет RUNNING = можно запускать
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[DEFS] lock_check error: {e}", flush=True)
|
print(f"[DEFS] lock_check error: {e}", flush=True)
|
||||||
return True
|
return True
|
||||||
|
|||||||
+64
-13
@@ -1,3 +1,12 @@
|
|||||||
|
"""
|
||||||
|
LEGACY-раннер — автоматический прогон тестов по конфигурации из config.yaml.
|
||||||
|
|
||||||
|
Этот модуль больше НЕ используется в основном потоке приложения.
|
||||||
|
Оставлен для обратной совместимости: эндпоинты /api/run, /api/status, /api/config.
|
||||||
|
|
||||||
|
Сейчас основной режим — ручной (через UI) и сценарный (через scenario.py).
|
||||||
|
"""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import yaml
|
import yaml
|
||||||
@@ -5,34 +14,52 @@ import os
|
|||||||
|
|
||||||
from api.http_client import HttpClient
|
from api.http_client import HttpClient
|
||||||
|
|
||||||
|
# Путь к YAML-конфигу тестов
|
||||||
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
|
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
|
||||||
|
|
||||||
|
# _current_run — глобальное состояние последнего запуска.
|
||||||
|
# Защищено _lock для потокобезопасности.
|
||||||
_current_run = None
|
_current_run = None
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def load_config():
|
def load_config():
|
||||||
|
"""Загрузить YAML-конфиг тестов. Используется в /api/config (GET)."""
|
||||||
with open(CONFIG_PATH) as f:
|
with open(CONFIG_PATH) as f:
|
||||||
return yaml.safe_load(f)
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
|
|
||||||
def save_config(data):
|
def save_config(data):
|
||||||
|
"""Сохранить YAML-конфиг. Используется в /api/config (POST)."""
|
||||||
with open(CONFIG_PATH, "w") as f:
|
with open(CONFIG_PATH, "w") as f:
|
||||||
yaml.dump(data, f, allow_unicode=True, default_flow_style=False)
|
yaml.dump(data, f, allow_unicode=True, default_flow_style=False)
|
||||||
|
|
||||||
|
|
||||||
def get_status():
|
def get_status():
|
||||||
|
"""Текущий статус последнего запуска тестов → /api/status."""
|
||||||
return _current_run
|
return _current_run
|
||||||
|
|
||||||
|
|
||||||
def _wait_for_ready(client, instance_uid, timeout=300):
|
def _wait_for_ready(client, instance_uid, timeout=300):
|
||||||
|
"""Ждать пока инстанс перейдёт в стабильное состояние (не creating/modifying/deleting).
|
||||||
|
|
||||||
|
Поллинг GET /instances с фильтрацией по instanceUid.
|
||||||
|
Таймаут 5 минут — после этого возвращаем None.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: HttpClient
|
||||||
|
instance_uid: str — UUID инстанса
|
||||||
|
timeout: int — макс. секунд ожидания
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict — инстанс, или None если таймаут."""
|
||||||
deadline = time.time() + timeout
|
deadline = time.time() + timeout
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
data = client.get("/instances", params={"pageSize": 500})
|
data = client.get("/instances", params={"pageSize": 500})
|
||||||
for inst in data.get("results", []):
|
for inst in data.get("results", []):
|
||||||
if inst.get("instanceUid") == instance_uid:
|
if inst.get("instanceUid") == instance_uid:
|
||||||
status = inst.get("explainedStatus", "")
|
status = inst.get("explainedStatus", "")
|
||||||
|
# Проверяем что операция не в процессе
|
||||||
in_progress = inst.get("operationIsInProgress", False)
|
in_progress = inst.get("operationIsInProgress", False)
|
||||||
pending = inst.get("operationIsPending", False)
|
pending = inst.get("operationIsPending", False)
|
||||||
if not in_progress and not pending and status not in ("creating", "modifying", "deleting"):
|
if not in_progress and not pending and status not in ("creating", "modifying", "deleting"):
|
||||||
@@ -42,6 +69,17 @@ def _wait_for_ready(client, instance_uid, timeout=300):
|
|||||||
|
|
||||||
|
|
||||||
def _find_svc_operation_id(client, service_id, operation_name):
|
def _find_svc_operation_id(client, service_id, operation_name):
|
||||||
|
"""Найти svcOperationId для операции в сервисе.
|
||||||
|
|
||||||
|
GET /services/{id} → svc.operations[] → ищем по operation.name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: HttpClient
|
||||||
|
service_id: int
|
||||||
|
operation_name: str — "create"/"modify"/"delete"/...
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int или None — svcOperationId."""
|
||||||
data = client.get(f"/services/{service_id}")
|
data = client.get(f"/services/{service_id}")
|
||||||
for op in data.get("svc", {}).get("operations", []):
|
for op in data.get("svc", {}).get("operations", []):
|
||||||
if op.get("operation") == operation_name:
|
if op.get("operation") == operation_name:
|
||||||
@@ -50,10 +88,26 @@ def _find_svc_operation_id(client, service_id, operation_name):
|
|||||||
|
|
||||||
|
|
||||||
def run_tests(endpoint, token):
|
def run_tests(endpoint, token):
|
||||||
|
"""Главная функция LEGACY-раннера — последовательный прогон тестов.
|
||||||
|
|
||||||
|
Алгоритм:
|
||||||
|
1. Загрузить config.yaml
|
||||||
|
2. Для каждого сервиса (если enabled):
|
||||||
|
a. Для каждой операции (если enabled):
|
||||||
|
- create → POST /instances → ждать ready
|
||||||
|
- другие → POST /instanceOperations → ждать ready
|
||||||
|
b. Сохранить результат (OK/FAIL/SKIP)
|
||||||
|
|
||||||
|
Вызывается в отдельном потоке из /api/run.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
endpoint: str — URL API
|
||||||
|
token: str — JWT-токен"""
|
||||||
global _current_run
|
global _current_run
|
||||||
client = HttpClient(endpoint, token)
|
client = HttpClient(endpoint, token)
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
|
# Получить UUID организации (если не задан явно)
|
||||||
org_uid = config.get("test_org_uid", "auto")
|
org_uid = config.get("test_org_uid", "auto")
|
||||||
if org_uid == "auto":
|
if org_uid == "auto":
|
||||||
from operations.get_instances import get_organization
|
from operations.get_instances import get_organization
|
||||||
@@ -76,7 +130,7 @@ def run_tests(endpoint, token):
|
|||||||
|
|
||||||
service_id = svc_cfg["service_id"]
|
service_id = svc_cfg["service_id"]
|
||||||
display_name = svc_cfg.get("display_name", f"autotest-{svc_cfg['name']}")
|
display_name = svc_cfg.get("display_name", f"autotest-{svc_cfg['name']}")
|
||||||
instance_uid = None
|
instance_uid = None # сбрасываем для каждого сервиса
|
||||||
|
|
||||||
for op_cfg in svc_cfg.get("operations", []):
|
for op_cfg in svc_cfg.get("operations", []):
|
||||||
if not op_cfg.get("enabled"):
|
if not op_cfg.get("enabled"):
|
||||||
@@ -111,10 +165,7 @@ def run_tests(endpoint, token):
|
|||||||
raise Exception(f"Operation {op_cfg['name']} not found")
|
raise Exception(f"Operation {op_cfg['name']} not found")
|
||||||
|
|
||||||
if op_cfg["name"] == "create":
|
if op_cfg["name"] == "create":
|
||||||
payload = {
|
payload = {"serviceId": service_id, "displayName": display_name}
|
||||||
"serviceId": service_id,
|
|
||||||
"displayName": display_name,
|
|
||||||
}
|
|
||||||
params = op_cfg.get("params", {})
|
params = op_cfg.get("params", {})
|
||||||
if params:
|
if params:
|
||||||
cfs_params = [{"svcOperationCfsParamId": int(k), "paramValue": str(v)} for k, v in params.items()]
|
cfs_params = [{"svcOperationCfsParamId": int(k), "paramValue": str(v)} for k, v in params.items()]
|
||||||
@@ -129,10 +180,7 @@ def run_tests(endpoint, token):
|
|||||||
else:
|
else:
|
||||||
if not instance_uid:
|
if not instance_uid:
|
||||||
raise Exception(f"No instance for {op_cfg['name']}")
|
raise Exception(f"No instance for {op_cfg['name']}")
|
||||||
payload = {
|
payload = {"instanceUid": instance_uid, "svcOperationId": svc_op_id}
|
||||||
"instanceUid": instance_uid,
|
|
||||||
"svcOperationId": svc_op_id,
|
|
||||||
}
|
|
||||||
params = op_cfg.get("params", {})
|
params = op_cfg.get("params", {})
|
||||||
if params:
|
if params:
|
||||||
payload["cfsParams"] = [{"svcOperationCfsParamId": int(k), "paramValue": str(v)} for k, v in params.items()]
|
payload["cfsParams"] = [{"svcOperationCfsParamId": int(k), "paramValue": str(v)} for k, v in params.items()]
|
||||||
@@ -147,12 +195,11 @@ def run_tests(endpoint, token):
|
|||||||
result["status"] = "FAIL"
|
result["status"] = "FAIL"
|
||||||
result["error"] = str(e)
|
result["error"] = str(e)
|
||||||
if op_cfg["name"] == "create":
|
if op_cfg["name"] == "create":
|
||||||
break # дальше операции этого сервиса бессмысленны
|
break # create не удался → остальные операции сервиса бессмысленны
|
||||||
finally:
|
finally:
|
||||||
result["duration_sec"] = round(time.time() - t0, 1)
|
result["duration_sec"] = round(time.time() - t0, 1)
|
||||||
|
|
||||||
# wait between services
|
time.sleep(1) # пауза между сервисами
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
with _lock:
|
with _lock:
|
||||||
_current_run["status"] = "done"
|
_current_run["status"] = "done"
|
||||||
@@ -160,6 +207,10 @@ def run_tests(endpoint, token):
|
|||||||
|
|
||||||
|
|
||||||
def _extract_uid_from_response(resp):
|
def _extract_uid_from_response(resp):
|
||||||
|
"""Извлечь UUID из ответа API (legacy-метод, заменён на api.utils.find_uid).
|
||||||
|
|
||||||
|
Простой перебор всех значений dict — если строка из 36 символов с 4 дефисами,
|
||||||
|
считаем UUID'ом."""
|
||||||
if isinstance(resp, dict):
|
if isinstance(resp, dict):
|
||||||
for v in resp.values():
|
for v in resp.values():
|
||||||
if isinstance(v, str) and len(v) == 36 and v.count("-") == 4:
|
if isinstance(v, str) and len(v) == 36 and v.count("-") == 4:
|
||||||
|
|||||||
+23
-6
@@ -1,17 +1,34 @@
|
|||||||
// app.js — загрузчик модулей фронтенда
|
// app.js — загрузчик и точка входа фронтенда
|
||||||
// Файлы подгружаются в index.html в порядке зависимостей:
|
//
|
||||||
|
// Файлы подгружаются в index.html в СТРОГОМ порядке зависимостей:
|
||||||
// utils.js → instances.js → operations.js → history.js → scenario-list.js
|
// utils.js → instances.js → operations.js → history.js → scenario-list.js
|
||||||
// window.APP — переменные из Jinja2: { version, stand, hasUserToken, firstServiceId }
|
// scenario-form.js → params-render.js → scenario-create/edit/delete.js
|
||||||
|
// icons.js, snackbar.js, views.js — без зависимостей
|
||||||
|
//
|
||||||
|
// Глобальные переменные из Jinja2 (window.APP):
|
||||||
|
// { version, stand, hasUserToken, firstServiceId }
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Архитектура: ванильный JS, модули по файлам:
|
* Архитектура фронтенда — ванильный JS (без фреймворков).
|
||||||
* utils.js — _esc(), validateJson(), busy, лог-панель
|
* Модули по файлам, функции в глобальной области видимости:
|
||||||
|
*
|
||||||
|
* utils.js — _esc(), validateJson(), busy, relativeTime(), лог-панель
|
||||||
* instances.js — selectService(), toggleInstance(), refreshInstances()
|
* instances.js — selectService(), toggleInstance(), refreshInstances()
|
||||||
* operations.js — startCreate(), runOp(), showParams(), executeOp(), poll
|
* operations.js — startCreate(), runOp(), showParams(), executeOp(), poll
|
||||||
* history.js — toggleHistory(), loadHistory()
|
* history.js — toggleHistory(), loadHistory()
|
||||||
* scenario-list.js — toggleScenario(), loadScenarios(), runScenario()
|
* scenario-list.js — toggleScenario(), loadScenarios(), runScenario()
|
||||||
|
* scenario-form.js — showScenarioEditor(), renderEditor(), saveScenario()
|
||||||
|
* params-render.js — renderParamRow(), renderMapFixedRow(), collectParams()
|
||||||
|
* icons.js — ICONS = {play, edit, trash, ...} (inline-SVG)
|
||||||
|
* snackbar.js — showSnackbar(msg, type)
|
||||||
|
* views.js — switchView(viewId)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Инициализация
|
// ── Инициализация при загрузке страницы ──
|
||||||
|
|
||||||
|
// Запускаем поллинг логов (каждые 2 секунды, только если панель видна)
|
||||||
startLogPoll();
|
startLogPoll();
|
||||||
|
|
||||||
|
// Выбираем первый сервис из переданного Jinja2 значения
|
||||||
|
// currentSvcId берётся из window.APP.firstServiceId (задаётся в index.html)
|
||||||
selectService(currentSvcId);
|
selectService(currentSvcId);
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
// history.js — история тестов
|
// history.js — история тестов (вид «История»)
|
||||||
|
//
|
||||||
|
// Загружает последние 50 записей из GET /api/history.
|
||||||
|
// Каждая запись — строка таблицы: время, операция, инстанс, статус, длительность.
|
||||||
|
// При клике на строку — раскрываются этапы выполнения (stages).
|
||||||
|
//
|
||||||
|
// Цветовая маркировка:
|
||||||
|
// RUNNING → жёлтый фон (#fef3c7)
|
||||||
|
// FAIL → красный фон (#fef2f2)
|
||||||
|
|
||||||
let historyOpen=false;
|
let historyOpen=false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переключить панель истории (открыть/закрыть).
|
||||||
|
* При открытии — автозагрузка через loadHistory().
|
||||||
|
*/
|
||||||
async function toggleHistory(){
|
async function toggleHistory(){
|
||||||
const body=document.getElementById('history-body');
|
const body=document.getElementById('history-body');
|
||||||
historyOpen=!historyOpen;
|
historyOpen=!historyOpen;
|
||||||
@@ -9,19 +21,34 @@ async function toggleHistory(){
|
|||||||
if(historyOpen) await loadHistory();
|
if(historyOpen) await loadHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузить и отрисовать историю.
|
||||||
|
*
|
||||||
|
* Формат ответа API: массив объектов:
|
||||||
|
* [{created_at, op_name, display_name, instance_uid, status, duration_sec, stages}, ...]
|
||||||
|
*/
|
||||||
async function loadHistory(){
|
async function loadHistory(){
|
||||||
const body=document.getElementById('history-body');
|
const body=document.getElementById('history-body');
|
||||||
if(!body||body.style.display==='none') return;
|
if(!body||body.style.display==='none') return;
|
||||||
try{
|
try{
|
||||||
const r=await fetch('/api/history');
|
const r=await fetch('/api/history');
|
||||||
const rows=await r.json();
|
const rows=await r.json();
|
||||||
if(!rows||!rows.length){body.innerHTML='<span style="color:var(--muted);font-size:11px;">Нет записей</span>'; return;}
|
if(!rows||!rows.length){
|
||||||
|
body.innerHTML='<span style="color:var(--muted);font-size:11px;">Нет записей</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Таблица с фиксированной шириной колонок
|
||||||
let html='<table style="width:100%;font-size:11px;table-layout:fixed;">';
|
let html='<table style="width:100%;font-size:11px;table-layout:fixed;">';
|
||||||
html+='<tr><th style="padding:2px 6px;width:130px;">Время</th><th style="padding:2px 6px;width:90px;">Операция</th><th style="padding:2px 6px;">Инстанс</th><th style="padding:2px 6px;width:50px;">Статус</th><th style="padding:2px 6px;width:55px;">Длит.</th></tr>';
|
html+='<tr><th style="padding:2px 6px;width:130px;">Время</th><th style="padding:2px 6px;width:90px;">Операция</th><th style="padding:2px 6px;">Инстанс</th><th style="padding:2px 6px;width:50px;">Статус</th><th style="padding:2px 6px;width:55px;">Длит.</th></tr>';
|
||||||
|
|
||||||
rows.forEach((r, i)=>{
|
rows.forEach((r, i)=>{
|
||||||
const time=r.created_at?r.created_at.replace('T',' ').substring(0,19):'?';
|
const time=r.created_at?r.created_at.replace('T',' ').substring(0,19):'?';
|
||||||
const cls=r.status==='OK'?'badge-success':r.status==='FAIL'?'badge' :'';
|
const cls=r.status==='OK'?'badge-success':r.status==='FAIL'?'badge':'';
|
||||||
|
// Цвет фона строки: RUNNING → жёлтый, FAIL → красный
|
||||||
const stCls=r.status==='RUNNING'?'background:#fef3c7;':r.status==='FAIL'?'background:#fef2f2;':'';
|
const stCls=r.status==='RUNNING'?'background:#fef3c7;':r.status==='FAIL'?'background:#fef2f2;':'';
|
||||||
|
|
||||||
|
// Строка таблицы — кликабельна (раскрывает этапы)
|
||||||
html+=`<tr style="cursor:pointer;${stCls}" onclick="toggleHistoryRow(${i})">
|
html+=`<tr style="cursor:pointer;${stCls}" onclick="toggleHistoryRow(${i})">
|
||||||
<td style="padding:2px 6px;">${time}</td>
|
<td style="padding:2px 6px;">${time}</td>
|
||||||
<td style="padding:2px 6px;">${_esc(r.op_name||'?')}</td>
|
<td style="padding:2px 6px;">${_esc(r.op_name||'?')}</td>
|
||||||
@@ -29,24 +56,43 @@ async function loadHistory(){
|
|||||||
<td style="padding:2px 6px;"><span class="badge ${cls}" style="font-size:9px;">${_esc(r.status||'?')}</span></td>
|
<td style="padding:2px 6px;"><span class="badge ${cls}" style="font-size:9px;">${_esc(r.status||'?')}</span></td>
|
||||||
<td style="padding:2px 6px;">${r.duration_sec!=null?r.duration_sec.toFixed(1)+'s':'-'}</td>
|
<td style="padding:2px 6px;">${r.duration_sec!=null?r.duration_sec.toFixed(1)+'s':'-'}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
|
|
||||||
|
// Скрытая строка с этапами (раскрывается по клику)
|
||||||
html+=`<tr id="hist-stages-${i}" style="display:none;"><td colspan="5" style="padding:4px 8px;">${renderStages(r.stages)}</td></tr>`;
|
html+=`<tr id="hist-stages-${i}" style="display:none;"><td colspan="5" style="padding:4px 8px;">${renderStages(r.stages)}</td></tr>`;
|
||||||
});
|
});
|
||||||
html+='</table>';
|
html+='</table>';
|
||||||
body.innerHTML=html;
|
body.innerHTML=html;
|
||||||
}catch(e){body.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';}
|
}catch(e){
|
||||||
|
body.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Раскрыть/скрыть строку с этапами для записи истории.
|
||||||
|
*
|
||||||
|
* @param {number} i — индекс записи в массиве rows
|
||||||
|
*/
|
||||||
function toggleHistoryRow(i) {
|
function toggleHistoryRow(i) {
|
||||||
const el = document.getElementById('hist-stages-' + i);
|
const el = document.getElementById('hist-stages-' + i);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.style.display = el.style.display === 'none' ? 'table-row' : 'none';
|
el.style.display = el.style.display === 'none' ? 'table-row' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отрисовать этапы выполнения для записи истории.
|
||||||
|
*
|
||||||
|
* Каждый этап: иконка (✅/❌/⏳) + название + время + длительность.
|
||||||
|
*
|
||||||
|
* @param {Array} stages — [{stage, dtStart, dtFinish, isSuccessful, duration}, ...]
|
||||||
|
* @returns {string} HTML
|
||||||
|
*/
|
||||||
function renderStages(stages) {
|
function renderStages(stages) {
|
||||||
if (!stages || !stages.length) return '<span style="color:var(--muted);font-size:10px;">Нет данных об этапах</span>';
|
if (!stages || !stages.length)
|
||||||
|
return '<span style="color:var(--muted);font-size:10px;">Нет данных об этапах</span>';
|
||||||
|
|
||||||
let html = '<div style="font-size:11px;"><b>Этапы выполнения</b></div>';
|
let html = '<div style="font-size:11px;"><b>Этапы выполнения</b></div>';
|
||||||
stages.forEach(s => {
|
stages.forEach(s => {
|
||||||
const done = !!s.dtFinish;
|
const done = !!s.dtFinish; // этап завершён?
|
||||||
const icon = done ? (s.isSuccessful ? '✅' : '❌') : '⏳';
|
const icon = done ? (s.isSuccessful ? '✅' : '❌') : '⏳';
|
||||||
const start = s.dtStart ? s.dtStart.replace('T',' ').substring(0,19) : '?';
|
const start = s.dtStart ? s.dtStart.replace('T',' ').substring(0,19) : '?';
|
||||||
const finish = s.dtFinish ? s.dtFinish.replace('T',' ').substring(0,19) : '...';
|
const finish = s.dtFinish ? s.dtFinish.replace('T',' ').substring(0,19) : '...';
|
||||||
|
|||||||
+11
-3
@@ -1,6 +1,14 @@
|
|||||||
// icons.js — 10 inline-SVG иконок (Lucide, MIT)
|
// icons.js — 10 inline-SVG иконок (Lucide, MIT-лицензия)
|
||||||
// stroke="currentColor" — цвет берётся из CSS
|
//
|
||||||
// Размер: 20x20, stroke-width: 2
|
// Все иконки используют stroke="currentColor" — цвет берётся из CSS (color).
|
||||||
|
// Размер: 20x20px, stroke-width: 2 (тонкие линии).
|
||||||
|
//
|
||||||
|
// Почему inline-SVG а не <img> или иконочный шрифт:
|
||||||
|
// - Не требует внешних зависимостей (никаких CDN)
|
||||||
|
// - Цвет controlled через CSS (currentColor)
|
||||||
|
// - Не грузит лишних файлов (иконки вшиты в JS)
|
||||||
|
//
|
||||||
|
// Использование: ICONS.play, ICONS.edit, ICONS.trash, ...
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
play: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>',
|
play: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>',
|
||||||
|
|||||||
+93
-13
@@ -1,4 +1,20 @@
|
|||||||
// instances.js — список инстансов, выбор сервиса, toggle операций
|
// instances.js — список инстансов, выбор сервиса, toggle операций
|
||||||
|
//
|
||||||
|
// Отвечает за:
|
||||||
|
// - Загрузку инстансов выбранного сервиса (GET /api/operations/{svcId})
|
||||||
|
// - Отображение списка инстансов с фильтром autotest-
|
||||||
|
// - Показ доступных операций для выбранного инстанса
|
||||||
|
// - Кнопки операций (modify, delete, suspend, resume, redeploy)
|
||||||
|
//
|
||||||
|
// Глобальные переменные (используются в operations.js и других модулях):
|
||||||
|
// svcInstances[] — текущий список инстансов
|
||||||
|
// selectedInst — UUID выбранного инстанса (или null)
|
||||||
|
// selectedOp — {opId, opName, svcId} текущая выбранная операция
|
||||||
|
// currentSvcId — ID текущего сервиса
|
||||||
|
// currentSvcName — имя текущего сервиса
|
||||||
|
// currentSvcShort — короткое имя (для генерации displayName)
|
||||||
|
// busy — флаг блокировки UI (пока идёт операция)
|
||||||
|
// AUTOTEST_PREFIX — "autotest-" (фильтр "наших" инстансов)
|
||||||
|
|
||||||
let svcInstances=[], selectedInst=null, selectedOp=null;
|
let svcInstances=[], selectedInst=null, selectedOp=null;
|
||||||
let currentSvcId=(window.APP&&window.APP.firstServiceId)||1;
|
let currentSvcId=(window.APP&&window.APP.firstServiceId)||1;
|
||||||
@@ -7,32 +23,58 @@ let currentSvcShort='';
|
|||||||
let busy=false;
|
let busy=false;
|
||||||
const AUTOTEST_PREFIX='autotest-';
|
const AUTOTEST_PREFIX='autotest-';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Выбрать сервис — загрузить его инстансы и показать в списке.
|
||||||
|
* Вызывается при клике на сервис в левой панели.
|
||||||
|
*
|
||||||
|
* @param {number} svcId — ID сервиса
|
||||||
|
*/
|
||||||
async function selectService(svcId){
|
async function selectService(svcId){
|
||||||
if(busy) return;
|
if(busy) return; // блокировка — идёт операция
|
||||||
|
|
||||||
|
// Сброс предыдущего состояния
|
||||||
currentSvcId=svcId;
|
currentSvcId=svcId;
|
||||||
selectedInst=null; selectedOp=null; stopPoll();
|
selectedInst=null;
|
||||||
|
selectedOp=null;
|
||||||
|
stopPoll(); // остановить поллинг предыдущей операции
|
||||||
|
|
||||||
|
// Скрыть панель параметров и этапы
|
||||||
document.getElementById('params-area').style.display='none';
|
document.getElementById('params-area').style.display='none';
|
||||||
document.querySelectorAll('.stages-box').forEach(e=>e.remove());
|
document.querySelectorAll('.stages-box').forEach(e=>e.remove());
|
||||||
|
|
||||||
|
// Подсветка активного сервиса в списке
|
||||||
document.querySelectorAll('.svc-item').forEach(e=>e.classList.remove('active'));
|
document.querySelectorAll('.svc-item').forEach(e=>e.classList.remove('active'));
|
||||||
const svcEl=document.querySelector(`.svc-item[onclick*="${svcId}"]`);
|
const svcEl=document.querySelector(`.svc-item[onclick*="${svcId}"]`);
|
||||||
if(svcEl) svcEl.classList.add('active');
|
if(svcEl) svcEl.classList.add('active');
|
||||||
|
|
||||||
try{
|
try{
|
||||||
|
// GET /api/operations/{svcId} — возвращает операции + autotest-инстансы
|
||||||
const r=await fetch('/api/operations/'+svcId);
|
const r=await fetch('/api/operations/'+svcId);
|
||||||
const d=await r.json();
|
const d=await r.json();
|
||||||
svcInstances=d.instances||[];
|
svcInstances=d.instances||[];
|
||||||
currentSvcName=d.svc||'';
|
currentSvcName=d.svc||'';
|
||||||
currentSvcShort=d.svcShort||'';
|
currentSvcShort=d.svcShort||'';
|
||||||
|
// Обновить текст кнопки «Создать»
|
||||||
const btnSpan=document.querySelector('#create-btn-area span');
|
const btnSpan=document.querySelector('#create-btn-area span');
|
||||||
if(btnSpan) btnSpan.textContent='+ Создать новый инстанс '+(currentSvcName||'сервиса');
|
if(btnSpan) btnSpan.textContent='+ Создать новый инстанс '+(currentSvcName||'сервиса');
|
||||||
renderInstances();
|
renderInstances(); // отрисовать список
|
||||||
}catch(e){document.getElementById('inst-list').innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';}
|
}catch(e){
|
||||||
|
document.getElementById('inst-list').innerHTML=
|
||||||
|
'<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отрисовать список инстансов в #inst-list.
|
||||||
|
* Каждый инстанс — строка: имя + сервис + статус + created + modified.
|
||||||
|
*/
|
||||||
function renderInstances(){
|
function renderInstances(){
|
||||||
let html='';
|
let html='';
|
||||||
svcInstances.forEach(i=>{
|
svcInstances.forEach(i=>{
|
||||||
|
// Статус: из нашего поля status (cloud-first) или explainedStatus
|
||||||
const status=i.status||i.explainedStatus||'?';
|
const status=i.status||i.explainedStatus||'?';
|
||||||
const sc=status==='running'?'badge-success':'';
|
const sc=status==='running'?'badge-success':'';
|
||||||
|
// Строка инстанса — кликабельна
|
||||||
html+=`<div class="inst-item" onclick="toggleInstance('${i.instanceUid}')" data-iuid="${i.instanceUid}">
|
html+=`<div class="inst-item" onclick="toggleInstance('${i.instanceUid}')" data-iuid="${i.instanceUid}">
|
||||||
<span style="display:inline-block;width:220px;">${_esc(i.displayName)}</span>
|
<span style="display:inline-block;width:220px;">${_esc(i.displayName)}</span>
|
||||||
<span style="display:inline-block;width:140px;color:var(--muted);font-size:11px;">${_esc(i.svc||'')}</span>
|
<span style="display:inline-block;width:140px;color:var(--muted);font-size:11px;">${_esc(i.svc||'')}</span>
|
||||||
@@ -40,11 +82,16 @@ function renderInstances(){
|
|||||||
<span style="display:inline-block;width:60px;color:var(--muted);font-size:9px;text-align:right;" title="${i.instanceConfigDtCreated||''}">${relativeTime(i.instanceConfigDtCreated)}</span>
|
<span style="display:inline-block;width:60px;color:var(--muted);font-size:9px;text-align:right;" title="${i.instanceConfigDtCreated||''}">${relativeTime(i.instanceConfigDtCreated)}</span>
|
||||||
<span style="display:inline-block;width:60px;color:var(--muted);font-size:9px;text-align:right;" title="${i.instanceConfigDtModified||''}">${relativeTime(i.instanceConfigDtModified)}</span>
|
<span style="display:inline-block;width:60px;color:var(--muted);font-size:9px;text-align:right;" title="${i.instanceConfigDtModified||''}">${relativeTime(i.instanceConfigDtModified)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
// Контейнер для кнопок операций (изначально скрыт)
|
||||||
html+=`<div class="inst-ops" id="ops-${i.instanceUid}"></div>`;
|
html+=`<div class="inst-ops" id="ops-${i.instanceUid}"></div>`;
|
||||||
});
|
});
|
||||||
document.getElementById('inst-list').innerHTML=html;
|
document.getElementById('inst-list').innerHTML=html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обновить список инстансов (без смены сервиса).
|
||||||
|
* Вызывается после завершения операции.
|
||||||
|
*/
|
||||||
async function refreshInstances(){
|
async function refreshInstances(){
|
||||||
try{
|
try{
|
||||||
const r=await fetch('/api/operations/'+currentSvcId);
|
const r=await fetch('/api/operations/'+currentSvcId);
|
||||||
@@ -54,37 +101,70 @@ async function refreshInstances(){
|
|||||||
}catch(e){}
|
}catch(e){}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Раскрыть/скрыть кнопки операций для инстанса.
|
||||||
|
* Загружает доступные операции через GET /api/operations/{svcId}.
|
||||||
|
*
|
||||||
|
* @param {string} iuid — instanceUid
|
||||||
|
*/
|
||||||
async function toggleInstance(iuid){
|
async function toggleInstance(iuid){
|
||||||
if(busy) return;
|
if(busy) return;
|
||||||
selectedInst=iuid; stopPoll();
|
selectedInst=iuid;
|
||||||
document.querySelectorAll('[data-iuid]').forEach(e=>e.classList.toggle('active',e.dataset.iuid===iuid));
|
stopPoll();
|
||||||
|
|
||||||
|
// Подсветка выбранного инстанса
|
||||||
|
document.querySelectorAll('[data-iuid]').forEach(e=>
|
||||||
|
e.classList.toggle('active',e.dataset.iuid===iuid));
|
||||||
|
|
||||||
|
// Скрыть панель параметров (если была открыта для другого инстанса)
|
||||||
document.getElementById('params-area').style.display='none';
|
document.getElementById('params-area').style.display='none';
|
||||||
|
|
||||||
const opsEl=document.getElementById('ops-'+iuid);
|
const opsEl=document.getElementById('ops-'+iuid);
|
||||||
|
// Если уже открыт — закрыть (toggle)
|
||||||
if(opsEl.classList.contains('open')){opsEl.classList.remove('open');return;}
|
if(opsEl.classList.contains('open')){opsEl.classList.remove('open');return;}
|
||||||
|
|
||||||
|
// Закрыть ВСЕ другие открытые панели операций
|
||||||
document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open'));
|
document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open'));
|
||||||
|
|
||||||
|
// Найти инстанс в списке (может быть из другого сервиса)
|
||||||
const inst=svcInstances.find(x=>x.instanceUid===iuid);
|
const inst=svcInstances.find(x=>x.instanceUid===iuid);
|
||||||
const svcId=inst?inst.serviceId:currentSvcId;
|
const svcId=inst?inst.serviceId:currentSvcId;
|
||||||
|
|
||||||
try{
|
try{
|
||||||
const r=await fetch('/api/operations/'+svcId);
|
const r=await fetch('/api/operations/'+svcId);
|
||||||
const d=await r.json();
|
const d=await r.json();
|
||||||
|
|
||||||
|
// Фильтруем операции: убираем reconcile (техническая) и create (уже создан)
|
||||||
let ops=(d.operations||[]).filter(o=>o.operation!=='reconcile'&&o.operation!=='create');
|
let ops=(d.operations||[]).filter(o=>o.operation!=='reconcile'&&o.operation!=='create');
|
||||||
|
|
||||||
|
// Для not created инстансов — только delete
|
||||||
if(inst&&(inst.status||inst.explainedStatus)==='not created'){
|
if(inst&&(inst.status||inst.explainedStatus)==='not created'){
|
||||||
ops=ops.filter(o=>o.operation==='delete');
|
ops=ops.filter(o=>o.operation==='delete');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Кнопки операций с цветовой маркировкой
|
||||||
opsEl.innerHTML=ops.map(o=>
|
opsEl.innerHTML=ops.map(o=>
|
||||||
`<button class="btn btn-sm op-btn ${opClass(o.operation)}" onclick="runOp('${o.operation}',${o.svcOperationId})">${o.operation}</button>`
|
`<button class="btn btn-sm op-btn ${opClass(o.operation)}" onclick="runOp('${o.operation}',${o.svcOperationId})">${o.operation}</button>`
|
||||||
).join('');
|
).join('');
|
||||||
opsEl.classList.add('open');
|
opsEl.classList.add('open');
|
||||||
}catch(e){opsEl.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';opsEl.classList.add('open');}
|
}catch(e){
|
||||||
|
opsEl.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';
|
||||||
|
opsEl.classList.add('open');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS-класс для кнопки операции (цветовая маркировка).
|
||||||
|
*
|
||||||
|
* @param {string} opName — "modify"/"delete"/"suspend"/...
|
||||||
|
* @returns {string} CSS-класс
|
||||||
|
*/
|
||||||
function opClass(opName){
|
function opClass(opName){
|
||||||
if(opName==='modify') return 'op-btn-modify';
|
if(opName==='modify') return 'op-btn-modify'; // синий
|
||||||
if(opName==='suspend') return 'op-btn-suspend';
|
if(opName==='suspend') return 'op-btn-suspend'; // оранжевый
|
||||||
if(opName==='resume') return 'op-btn-resume';
|
if(opName==='resume') return 'op-btn-resume'; // зелёный
|
||||||
if(opName==='delete') return 'op-btn-delete';
|
if(opName==='delete') return 'op-btn-delete'; // красный
|
||||||
if(opName==='redeploy') return 'op-btn-redeploy';
|
if(opName==='redeploy') return 'op-btn-redeploy'; // фиолетовый
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+201
-35
@@ -1,119 +1,244 @@
|
|||||||
// operations.js — запуск операций: create, modify, delete, poll, stages
|
// operations.js — запуск операций: create, modify, delete, поллинг, этапы
|
||||||
|
//
|
||||||
|
// Это основной модуль ручного режима тестирования.
|
||||||
|
// Жизненный цикл операции:
|
||||||
|
// 1. Пользователь выбирает инстанс → жмёт кнопку операции (runOp)
|
||||||
|
// 2. Загружаются параметры с ТЕКУЩИМИ значениями (showParams)
|
||||||
|
// 3. Пользователь правит параметры → жмёт «Запустить» (executeOp)
|
||||||
|
// 4. POST /api/test → возвращает opUid → начинается поллинг (pollTimer)
|
||||||
|
// 5. Каждые 2 секунды GET /api/test/status/{opUid} → этапы + статус
|
||||||
|
// 6. При завершении → обновить список инстансов + историю
|
||||||
|
//
|
||||||
|
// Глобальные переменные (используются из instances.js):
|
||||||
|
// pollTimer — ID setInterval для поллинга
|
||||||
|
|
||||||
let pollTimer=null;
|
let pollTimer=null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сгенерировать короткое имя для нового инстанса.
|
||||||
|
* Формат: {svcShort}-{random3} (напр. "dummy-a3f").
|
||||||
|
* random3 — base36 (0-9a-z), 3 символа = 46656 вариантов.
|
||||||
|
*
|
||||||
|
* @returns {string} displayName suffix (без префикса autotest-)
|
||||||
|
*/
|
||||||
function makeCreateDisplayName(){
|
function makeCreateDisplayName(){
|
||||||
const rand = (Math.random() * 46656 | 0).toString(36).padStart(3, '0');
|
const rand = (Math.random() * 46656 | 0).toString(36).padStart(3, '0');
|
||||||
return currentSvcShort ? currentSvcShort+'-'+rand : rand;
|
return currentSvcShort ? currentSvcShort+'-'+rand : rand;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Найти displayName выбранного инстанса.
|
||||||
|
*
|
||||||
|
* @returns {string} displayName или instanceUid (если не найден)
|
||||||
|
*/
|
||||||
function findInstName(){
|
function findInstName(){
|
||||||
const i=svcInstances.find(x=>x.instanceUid===selectedInst);
|
const i=svcInstances.find(x=>x.instanceUid===selectedInst);
|
||||||
return i?i.displayName:selectedInst;
|
return i?i.displayName:selectedInst;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Остановить поллинг текущей операции.
|
||||||
|
*/
|
||||||
function stopPoll(){
|
function stopPoll(){
|
||||||
if(pollTimer){clearInterval(pollTimer);pollTimer=null;}
|
if(pollTimer){clearInterval(pollTimer);pollTimer=null;}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Начать создание нового инстанса (кнопка «+ Создать»).
|
||||||
|
*
|
||||||
|
* Загружает операцию create для текущего сервиса через GET /api/operations/{svcId}.
|
||||||
|
* Если операция create не найдена — fallback на svcOperationId=18 (стандартный).
|
||||||
|
*/
|
||||||
async function startCreate(){
|
async function startCreate(){
|
||||||
if(busy) return;
|
if(busy) return;
|
||||||
selectedInst=null; stopPoll();
|
selectedInst=null; // create — нет выбранного инстанса
|
||||||
|
stopPoll();
|
||||||
|
// Снять выделение со всех инстансов
|
||||||
document.querySelectorAll('[data-iuid]').forEach(e=>e.classList.remove('active'));
|
document.querySelectorAll('[data-iuid]').forEach(e=>e.classList.remove('active'));
|
||||||
document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open'));
|
document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open'));
|
||||||
try{
|
try{
|
||||||
const r=await fetch('/api/operations/'+currentSvcId);
|
const r=await fetch('/api/operations/'+currentSvcId);
|
||||||
const d=await r.json();
|
const d=await r.json();
|
||||||
const createOp=d.operations.find(o=>o.operation==='create');
|
const createOp=d.operations.find(o=>o.operation==='create');
|
||||||
const opId=createOp?createOp.svcOperationId:18;
|
const opId=createOp?createOp.svcOperationId:18; // fallback
|
||||||
showParams(opId,'create');
|
showParams(opId,'create');
|
||||||
}catch(e){
|
}catch(e){
|
||||||
showParams(18,'create');
|
showParams(18,'create'); // fallback при ошибке
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запустить операцию над выбранным инстансом (кнопка modify/delete/...).
|
||||||
|
*
|
||||||
|
* @param {string} opName — "modify"/"delete"/"suspend"/"resume"/"redeploy"
|
||||||
|
* @param {number} opId — svcOperationId
|
||||||
|
*/
|
||||||
function runOp(opName,opId){
|
function runOp(opName,opId){
|
||||||
if(busy) return;
|
if(busy) return;
|
||||||
stopPoll();
|
stopPoll();
|
||||||
showParams(opId,opName);
|
showParams(opId,opName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Показать форму параметров для операции.
|
||||||
|
*
|
||||||
|
* Алгоритм:
|
||||||
|
* 1. Загрузить параметры: GET /api/params/{opId}[?instanceUid=xxx]
|
||||||
|
* - Для create: без instanceUid → шаблонные defaults
|
||||||
|
* - Для не-create: с instanceUid → текущие значения из state.params
|
||||||
|
* 2. Отрисовать форму через renderParamRow (из params-render.js)
|
||||||
|
* 3. Показать кнопку «Запустить»
|
||||||
|
*
|
||||||
|
* @param {number} opId — svcOperationId
|
||||||
|
* @param {string} opName — "create"/"modify"/...
|
||||||
|
*/
|
||||||
function showParams(opId,opName){
|
function showParams(opId,opName){
|
||||||
|
// Сохраняем выбранную операцию
|
||||||
selectedOp={opId,opName,svcId:currentSvcId};
|
selectedOp={opId,opName,svcId:currentSvcId};
|
||||||
|
|
||||||
|
// Показать панель параметров, скрыть старые этапы
|
||||||
document.getElementById('params-area').style.display='block';
|
document.getElementById('params-area').style.display='block';
|
||||||
document.querySelectorAll('.stages-box').forEach(e=>e.remove());
|
document.querySelectorAll('.stages-box').forEach(e=>e.remove());
|
||||||
document.getElementById('test-status').textContent='';
|
document.getElementById('test-status').textContent='';
|
||||||
|
|
||||||
const isCreate=opName==='create';
|
const isCreate=opName==='create';
|
||||||
|
// Для create — шаблонные значения, для не-create — с instanceUid
|
||||||
const qs=isCreate?'' : `?instanceUid=${selectedInst}`;
|
const qs=isCreate?'' : `?instanceUid=${selectedInst}`;
|
||||||
|
|
||||||
fetch('/api/params/'+opId+qs).then(r=>r.json()).then(async data=>{
|
fetch('/api/params/'+opId+qs).then(r=>r.json()).then(async data=>{
|
||||||
if(data.error){document.getElementById('params-form').innerHTML=`<span style="color:var(--destructive);">Ошибка: ${_esc(data.error)}</span>`;return;}
|
if(data.error){
|
||||||
|
document.getElementById('params-form').innerHTML=
|
||||||
|
`<span style="color:var(--destructive);">Ошибка: ${_esc(data.error)}</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const params=data.params||data;
|
const params=data.params||data;
|
||||||
if(!Array.isArray(params)){document.getElementById('params-form').innerHTML=`<span style="color:var(--destructive);">Неверный формат параметров</span>`;return;}
|
if(!Array.isArray(params)){
|
||||||
|
document.getElementById('params-form').innerHTML=
|
||||||
|
`<span style="color:var(--destructive);">Неверный формат параметров</span>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если есть refSvcId-параметры — загружаем ВСЕ инстансы (для select'ов)
|
||||||
let allInst=[];
|
let allInst=[];
|
||||||
const needRefs=params.some(p=>p.refSvcId);
|
const needRefs=params.some(p=>p.refSvcId);
|
||||||
if(needRefs){
|
if(needRefs){
|
||||||
try{const r=await fetch('/api/instances/list');const list=await r.json();allInst=list||[];}catch(e){}
|
try{
|
||||||
|
const r=await fetch('/api/instances/list');
|
||||||
|
const list=await r.json();
|
||||||
|
allInst=list||[];
|
||||||
|
}catch(e){}
|
||||||
}
|
}
|
||||||
|
|
||||||
const form=document.getElementById('params-form');
|
const form=document.getElementById('params-form');
|
||||||
const displayNameValue=isCreate?makeCreateDisplayName():'';
|
const displayNameValue=isCreate?makeCreateDisplayName():'';
|
||||||
const createHeader=isCreate?`<div style="font-weight:600;font-size:13px;margin-bottom:8px;color:var(--brand-primary);">Создание: ${_esc(currentSvcName)}</div>`:'';
|
|
||||||
const opHeader=!isCreate?`<div style="font-weight:600;font-size:13px;margin-bottom:8px;color:var(--brand-primary);">${_esc(opName)} → ${_esc(findInstName())}</div>`:'';
|
// Заголовок формы
|
||||||
form.innerHTML=createHeader+opHeader+(opName==='create'?`<div class="param-row"><label class="req">displayName</label><span style="font-size:12px;color:var(--muted);white-space:nowrap;">${AUTOTEST_PREFIX}</span><input type="text" id="param-displayname" value="${_esc(displayNameValue)}" autocomplete="off"></div>`:'')
|
const createHeader=isCreate
|
||||||
|
? `<div style="font-weight:600;font-size:13px;margin-bottom:8px;color:var(--brand-primary);">Создание: ${_esc(currentSvcName)}</div>`
|
||||||
|
: '';
|
||||||
|
const opHeader=!isCreate
|
||||||
|
? `<div style="font-weight:600;font-size:13px;margin-bottom:8px;color:var(--brand-primary);">${_esc(opName)} → ${_esc(findInstName())}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Форма: заголовок + displayName (только create) + параметры
|
||||||
|
form.innerHTML=createHeader+opHeader
|
||||||
|
+ (opName==='create'
|
||||||
|
? `<div class="param-row"><label class="req">displayName</label><span style="font-size:12px;color:var(--muted);white-space:nowrap;">${AUTOTEST_PREFIX}</span><input type="text" id="param-displayname" value="${_esc(displayNameValue)}" autocomplete="off"></div>`
|
||||||
|
: '')
|
||||||
+ params.map(p=>renderParamRow(p,allInst)).join('');
|
+ params.map(p=>renderParamRow(p,allInst)).join('');
|
||||||
|
|
||||||
|
// Кнопка «Запустить»
|
||||||
const btn=document.getElementById('btn-test');
|
const btn=document.getElementById('btn-test');
|
||||||
btn.style.display='inline-flex';
|
btn.style.display='inline-flex';
|
||||||
btn.textContent='Запустить';
|
btn.textContent='Запустить';
|
||||||
btn.onclick=()=>{
|
btn.onclick=()=>{
|
||||||
|
// Валидация JSON-полей перед отправкой
|
||||||
let bad=[];
|
let bad=[];
|
||||||
document.querySelectorAll('#params-form [data-type="map"]').forEach(el=>{if(!validateJson(el,true)) bad.push(el);});
|
document.querySelectorAll('#params-form [data-type="map"]').forEach(el=>{
|
||||||
|
if(!validateJson(el,true)) bad.push(el);
|
||||||
|
});
|
||||||
if(bad.length>0){
|
if(bad.length>0){
|
||||||
document.getElementById('test-status').innerHTML=`<span class="badge">Ошибка</span> ${bad.length} полей с невалидным JSON — поправьте и нажмите Запустить снова`;
|
document.getElementById('test-status').innerHTML=
|
||||||
|
`<span class="badge">Ошибка</span> ${bad.length} полей с невалидным JSON — поправьте и нажмите Запустить снова`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pp=collectParams();
|
const pp=collectParams(); // собрать значения из формы
|
||||||
executeOp(pp);
|
executeOp(pp); // отправить
|
||||||
};
|
};
|
||||||
}).catch(e=>{
|
}).catch(e=>{
|
||||||
document.getElementById('params-form').innerHTML=`<span style="color:var(--destructive);">Ошибка загрузки: ${_esc(e.message)}</span>`;
|
document.getElementById('params-form').innerHTML=
|
||||||
|
`<span style="color:var(--destructive);">Ошибка загрузки: ${_esc(e.message)}</span>`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderParamRow, renderMapFixedRow, collectParams — теперь в params-render.js (общий модуль)
|
/**
|
||||||
|
* Установить состояние «завершено» — показать результат операции.
|
||||||
|
*
|
||||||
|
* @param {string} statusText — "OK"/"FAIL"/"TIMEOUT"
|
||||||
|
* @param {string} statusClass — CSS-класс бейджа
|
||||||
|
* @param {string} statusError — текст ошибки
|
||||||
|
* @param {number} statusDuration— длительность в секундах
|
||||||
|
* @param {string} instanceName — displayName инстанса
|
||||||
|
*/
|
||||||
function setFinishedState(statusText, statusClass, statusError, statusDuration, instanceName){
|
function setFinishedState(statusText, statusClass, statusError, statusDuration, instanceName){
|
||||||
busy=false;
|
busy=false; // разблокировать UI
|
||||||
|
// Закрыть панели операций
|
||||||
document.querySelectorAll('.inst-ops.open').forEach(e=>e.classList.remove('open'));
|
document.querySelectorAll('.inst-ops.open').forEach(e=>e.classList.remove('open'));
|
||||||
const btn=document.getElementById('btn-test');
|
const btn=document.getElementById('btn-test');
|
||||||
btn.disabled=false;
|
btn.disabled=false;
|
||||||
btn.textContent='Готово';
|
btn.textContent='Готово';
|
||||||
btn.onclick=null;
|
btn.onclick=null; // убрать обработчик
|
||||||
const opName=selectedOp?.opName||'операция';
|
const opName=selectedOp?.opName||'операция';
|
||||||
const durationText=statusDuration!==undefined&&statusDuration!==null?`${statusDuration}s`:'0s';
|
const durationText=statusDuration!==undefined&&statusDuration!==null?`${statusDuration}s`:'0s';
|
||||||
const extra=_esc(statusError||'');
|
const extra=_esc(statusError||'');
|
||||||
const instName=_esc(instanceName||findInstName()||selectedInst||'');
|
const instName=_esc(instanceName||findInstName()||selectedInst||'');
|
||||||
document.getElementById('test-status').innerHTML=` <span class="badge ${statusClass}">${statusText}</span> <span style="color:var(--muted);">${opName}</span> <span style="color:var(--muted);">${durationText}</span> <span style="color:var(--muted);">${instName}</span> ${extra}`;
|
document.getElementById('test-status').innerHTML=
|
||||||
|
` <span class="badge ${statusClass}">${statusText}</span> <span style="color:var(--muted);">${opName}</span> <span style="color:var(--muted);">${durationText}</span> <span style="color:var(--muted);">${instName}</span> ${extra}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Установить состояние «выполняется».
|
||||||
|
*/
|
||||||
function setRunningState(opName, displayName){
|
function setRunningState(opName, displayName){
|
||||||
const btn=document.getElementById('btn-test');
|
const btn=document.getElementById('btn-test');
|
||||||
btn.textContent=opName+'...';
|
btn.textContent=opName+'...';
|
||||||
document.getElementById('test-status').textContent=' ⏳ '+ (displayName||opName) +' выполняется...';
|
document.getElementById('test-status').textContent=' ⏳ '+ (displayName||opName) +' выполняется...';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправить операцию на выполнение (POST /api/test) и начать поллинг.
|
||||||
|
*
|
||||||
|
* Алгоритм:
|
||||||
|
* 1. POST /api/test → {status: "RUNNING", opUid}
|
||||||
|
* 2. Если CMDB-delete → сразу OK (без поллинга)
|
||||||
|
* 3. Иначе → setInterval каждые 2 секунды:
|
||||||
|
* GET /api/test/status/{opUid} → этапы + статус
|
||||||
|
* 4. При OK/FAIL → остановить поллинг, обновить список инстансов и историю
|
||||||
|
* 5. При 5 ошибках подряд → TIMEOUT (связь потеряна)
|
||||||
|
*
|
||||||
|
* @param {Object} params — {numeric_param_id: value}
|
||||||
|
*/
|
||||||
async function executeOp(params){
|
async function executeOp(params){
|
||||||
busy=true;
|
busy=true; // заблокировать UI
|
||||||
stopPoll();
|
stopPoll();
|
||||||
|
|
||||||
const isCreate=selectedOp?.opName==='create';
|
const isCreate=selectedOp?.opName==='create';
|
||||||
const displayNameInput=document.getElementById('param-displayname');
|
const displayNameInput=document.getElementById('param-displayname');
|
||||||
const displayNameSuffix=(displayNameInput?.value||'').trim();
|
const displayNameSuffix=(displayNameInput?.value||'').trim();
|
||||||
const displayName=isCreate ? `${AUTOTEST_PREFIX}${displayNameSuffix || makeCreateDisplayName()}` : findInstName();
|
const displayName=isCreate
|
||||||
|
? `${AUTOTEST_PREFIX}${displayNameSuffix || makeCreateDisplayName()}`
|
||||||
|
: findInstName();
|
||||||
|
|
||||||
|
// Подготовка UI
|
||||||
document.getElementById('params-area').style.display='block';
|
document.getElementById('params-area').style.display='block';
|
||||||
document.getElementById('params-form').innerHTML='';
|
document.getElementById('params-form').innerHTML=''; // очистить форму
|
||||||
const btn=document.getElementById('btn-test');
|
const btn=document.getElementById('btn-test');
|
||||||
btn.style.display='inline-flex';
|
btn.style.display='inline-flex';
|
||||||
btn.textContent=selectedOp.opName+'...';
|
btn.textContent=selectedOp.opName+'...';
|
||||||
btn.disabled=true;
|
btn.disabled=true;
|
||||||
setRunningState(selectedOp.opName, displayName);
|
setRunningState(selectedOp.opName, displayName);
|
||||||
|
|
||||||
|
// Создать контейнер для этапов (после кнопки create или после панели операций)
|
||||||
const instId=selectedInst||'';
|
const instId=selectedInst||'';
|
||||||
const afterEl=instId?document.getElementById('ops-'+instId):null;
|
const afterEl=instId?document.getElementById('ops-'+instId):null;
|
||||||
document.querySelectorAll('.stages-box').forEach(e=>e.remove());
|
document.querySelectorAll('.stages-box').forEach(e=>e.remove());
|
||||||
@@ -122,13 +247,33 @@ async function executeOp(params){
|
|||||||
stagesBox.style.cssText='max-height:200px;overflow-y:auto;padding:4px 8px;margin-left:16px;background:var(--brand-grey-light);border-radius:4px;';
|
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);}
|
if(afterEl){afterEl.after(stagesBox);}
|
||||||
else{document.getElementById('create-btn-area').after(stagesBox);}
|
else{document.getElementById('create-btn-area').after(stagesBox);}
|
||||||
|
|
||||||
try{
|
try{
|
||||||
const r=await fetch('/api/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
|
// POST /api/test — тело: serviceId, operation, svcOperationId, params, instanceUid, displayName
|
||||||
serviceId:currentSvcId, operation:selectedOp.opName, svcOperationId:selectedOp.opId,
|
const r=await fetch('/api/test',{
|
||||||
params, instanceUid:selectedInst||'', displayName:isCreate?displayName:''
|
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();
|
const d=await r.json();
|
||||||
if(d.status==='FAIL'){busy=false;document.getElementById('test-status').innerHTML=` <span class="badge">FAIL</span> ${_esc(d.error||'')}`;btn.disabled=false;return;}
|
|
||||||
|
// FAIL на старте — нечего поллить
|
||||||
|
if(d.status==='FAIL'){
|
||||||
|
busy=false;
|
||||||
|
document.getElementById('test-status').innerHTML=
|
||||||
|
` <span class="badge">FAIL</span> ${_esc(d.error||'')}`;
|
||||||
|
btn.disabled=false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CMDB-delete (opUid начинается с "cmdb-") — мгновенный успех
|
||||||
if(d.status==='OK'&&(d.opUid||'').startsWith('cmdb-')){
|
if(d.status==='OK'&&(d.opUid||'').startsWith('cmdb-')){
|
||||||
busy=false;
|
busy=false;
|
||||||
setFinishedState('OK','badge-success','',0,d.displayName);
|
setFinishedState('OK','badge-success','',0,d.displayName);
|
||||||
@@ -136,24 +281,37 @@ async function executeOp(params){
|
|||||||
if(historyOpen) try{await loadHistory();}catch(e){}
|
if(historyOpen) try{await loadHistory();}catch(e){}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const opUid=d.opUid;
|
const opUid=d.opUid;
|
||||||
const instanceName=d.displayName||displayName;
|
const instanceName=d.displayName||displayName;
|
||||||
|
|
||||||
|
// Поллинг: каждые 2 секунды
|
||||||
let _pollErrors=0;
|
let _pollErrors=0;
|
||||||
pollTimer=setInterval(async()=>{
|
pollTimer=setInterval(async()=>{
|
||||||
try{
|
try{
|
||||||
const sr=await fetch('/api/test/status/'+opUid);
|
const sr=await fetch('/api/test/status/'+opUid);
|
||||||
const sd=await sr.json();
|
const sd=await sr.json();
|
||||||
_pollErrors=0;
|
_pollErrors=0; // сброс счётчика ошибок
|
||||||
showStages(sd.stages||[]);
|
showStages(sd.stages||[]); // обновить этапы
|
||||||
if(sd.status!=='RUNNING'){
|
if(sd.status!=='RUNNING'){
|
||||||
stopPoll();
|
stopPoll();
|
||||||
setFinishedState(sd.status, sd.status==='OK'?'badge-success':'badge', sd.error||sd.errorLog, sd.duration, sd.displayName||instanceName);
|
setFinishedState(
|
||||||
await refreshInstances();
|
sd.status,
|
||||||
if(historyOpen) await loadHistory();
|
sd.status==='OK'?'badge-success':'badge',
|
||||||
|
sd.error||sd.errorLog,
|
||||||
|
sd.duration,
|
||||||
|
sd.displayName||instanceName
|
||||||
|
);
|
||||||
|
await refreshInstances(); // обновить список инстансов
|
||||||
|
if(historyOpen) await loadHistory(); // обновить историю
|
||||||
}
|
}
|
||||||
}catch(e){
|
}catch(e){
|
||||||
_pollErrors++;
|
_pollErrors++;
|
||||||
if(_pollErrors>=5){stopPoll();setFinishedState('TIMEOUT','','Связь потеряна',0,instanceName);}
|
// 5 ошибок подряд (10 секунд) → считаем связь потерянной
|
||||||
|
if(_pollErrors>=5){
|
||||||
|
stopPoll();
|
||||||
|
setFinishedState('TIMEOUT','','Связь потеряна',0,instanceName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},2000);
|
},2000);
|
||||||
}catch(e){
|
}catch(e){
|
||||||
@@ -163,14 +321,22 @@ async function executeOp(params){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Показать этапы выполнения операции (stages из API).
|
||||||
|
*
|
||||||
|
* Каждый этап: иконка (✅/❌/⏳) + название + длительность.
|
||||||
|
*
|
||||||
|
* @param {Array} stages — [{stage, dtStart, dtFinish, isSuccessful, duration}, ...]
|
||||||
|
*/
|
||||||
function showStages(stages){
|
function showStages(stages){
|
||||||
if(!stages||!stages.length) return;
|
if(!stages||!stages.length) return;
|
||||||
let html='<div style="font-size:11px;font-weight:600;color:var(--muted);margin-top:4px;">Этапы</div>';
|
let html='<div style="font-size:11px;font-weight:600;color:var(--muted);margin-top:4px;">Этапы</div>';
|
||||||
stages.forEach(s=>{
|
stages.forEach(s=>{
|
||||||
const done=!!s.dtFinish;
|
const done=!!s.dtFinish; // этап завершён?
|
||||||
const icon=done?(s.isSuccessful?'✅':'❌'):'⏳';
|
const icon=done?(s.isSuccessful?'✅':'❌'):'⏳';
|
||||||
html+=`<div style="font-size:11px;padding:2px 0;">${icon} ${_esc(s.stage)} — ${(s.duration||0).toFixed(1)}s</div>`;
|
html+=`<div style="font-size:11px;padding:2px 0;">${icon} ${_esc(s.stage)} — ${(s.duration||0).toFixed(1)}s</div>`;
|
||||||
});
|
});
|
||||||
|
// Показать в ПОСЛЕДНЕМ контейнере этапов (самом новом)
|
||||||
const boxes=document.querySelectorAll('.stages-box');
|
const boxes=document.querySelectorAll('.stages-box');
|
||||||
if(boxes.length) boxes[boxes.length-1].innerHTML=html;
|
if(boxes.length) boxes[boxes.length-1].innerHTML=html;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +1,141 @@
|
|||||||
// params-render.js — общий рендер параметров операций
|
// params-render.js — общий рендер параметров операций (формы ввода)
|
||||||
// Используется: operations.js (ручной режим), scenario-form.js (редактор сценариев)
|
//
|
||||||
|
// Используется в двух местах:
|
||||||
|
// 1. operations.js — ручной режим (форма параметров для modify/create)
|
||||||
|
// 2. scenario-form.js — редактор сценариев (параметры каждого шага)
|
||||||
|
//
|
||||||
// Зависимости: _esc() из utils.js, validateJson() из utils.js
|
// Зависимости: _esc() из utils.js, validateJson() из utils.js
|
||||||
|
//
|
||||||
|
// Экспортирует (в глобальную область):
|
||||||
|
// renderParamRow(p, allInst) — отрисовать ОДНУ строку параметра
|
||||||
|
// renderMapFixedRow(p, dfl) — отрисовать map-параметр с фиксированными ключами
|
||||||
|
// collectParams(containerSelector)— собрать значения из формы → {numeric_id: value}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отрисовать строку параметра: лейбл + поле ввода (input/select).
|
||||||
|
*
|
||||||
|
* Типы полей:
|
||||||
|
* - valueList → <select> с вариантами
|
||||||
|
* - refSvcId → <select> с инстансами сервиса
|
||||||
|
* - boolean → <select> true/false
|
||||||
|
* - map + dataDescriptor → renderMapFixedRow (вложенные поля)
|
||||||
|
* - map без dataDescriptor → <input> с onblur="validateJson(this)"
|
||||||
|
* - остальное → <input type="text">
|
||||||
|
*
|
||||||
|
* @param {Object} p — параметр из API:
|
||||||
|
* {svcOperationCfsParamId, name, dataType, isRequired, defaultValue, valueList, refSvcId, dataDescriptor}
|
||||||
|
* @param {Array} allInst — все инстансы (нужно для refSvcId-селектов)
|
||||||
|
* @returns {string} HTML-строка
|
||||||
|
*/
|
||||||
function renderParamRow(p, allInst) {
|
function renderParamRow(p, allInst) {
|
||||||
const req = p.isRequired;
|
const req = p.isRequired;
|
||||||
const hasDfl = p.defaultValue !== null && p.defaultValue !== undefined && p.defaultValue !== '';
|
const hasDfl = p.defaultValue !== null && p.defaultValue !== undefined && p.defaultValue !== '';
|
||||||
|
// CSS-класс лейбла: req (обязательный+заполнен), req-nodfl (обязательный+пустой)
|
||||||
const labelCls = req ? (hasDfl ? 'req' : 'req-nodfl') : '';
|
const labelCls = req ? (hasDfl ? 'req' : 'req-nodfl') : '';
|
||||||
const dfl = hasDfl ? p.defaultValue : (req ? '' : '');
|
const dfl = hasDfl ? p.defaultValue : (req ? '' : '');
|
||||||
const vl = p.valueList;
|
const vl = p.valueList;
|
||||||
const isMap = (p.dataType || '').startsWith('map');
|
const isMap = (p.dataType || '').startsWith('map');
|
||||||
|
|
||||||
let input;
|
let input;
|
||||||
|
|
||||||
if (vl && Array.isArray(vl)) {
|
if (vl && Array.isArray(vl)) {
|
||||||
|
// Выпадающий список с фиксированными значениями
|
||||||
input = `<select name="p_${p.svcOperationCfsParamId}" data-type="${_esc(p.dataType)}">${vl.map(v => `<option value="${_esc(v)}" ${v == dfl ? 'selected' : ''}>${_esc(v)}</option>`).join('')}</select>`;
|
input = `<select name="p_${p.svcOperationCfsParamId}" data-type="${_esc(p.dataType)}">${vl.map(v => `<option value="${_esc(v)}" ${v == dfl ? 'selected' : ''}>${_esc(v)}</option>`).join('')}</select>`;
|
||||||
|
|
||||||
} else if (p.refSvcId) {
|
} else if (p.refSvcId) {
|
||||||
|
// Ссылка на другой сервис → выпадающий список инстансов этого сервиса
|
||||||
const refInsts = allInst.filter(i => i.serviceId === p.refSvcId && i.explainedStatus !== 'deleted');
|
const refInsts = allInst.filter(i => i.serviceId === p.refSvcId && i.explainedStatus !== 'deleted');
|
||||||
let opts = refInsts.map(i => `<option value="${_esc(i.instanceUid)}" ${i.instanceUid === dfl ? 'selected' : ''}>${_esc(i.displayName)}</option>`).join('');
|
let opts = refInsts.map(i => `<option value="${_esc(i.instanceUid)}" ${i.instanceUid === dfl ? 'selected' : ''}>${_esc(i.displayName)}</option>`).join('');
|
||||||
if (!dfl) opts = '<option value="">— выбрать —</option>' + opts;
|
if (!dfl) opts = '<option value="">— выбрать —</option>' + opts;
|
||||||
input = `<select name="p_${p.svcOperationCfsParamId}" data-type="ref">${opts}</select>`;
|
input = `<select name="p_${p.svcOperationCfsParamId}" data-type="ref">${opts}</select>`;
|
||||||
|
|
||||||
} else if (p.dataType === 'boolean') {
|
} else if (p.dataType === 'boolean') {
|
||||||
|
// Булево значение → выпадающий список true/false
|
||||||
input = `<select name="p_${p.svcOperationCfsParamId}" data-type="boolean"><option value="true" ${dfl === 'true' || dfl === true ? 'selected' : ''}>true</option><option value="false" ${dfl === 'false' || dfl === false ? 'selected' : ''}>false</option></select>`;
|
input = `<select name="p_${p.svcOperationCfsParamId}" data-type="boolean"><option value="true" ${dfl === 'true' || dfl === true ? 'selected' : ''}>true</option><option value="false" ${dfl === 'false' || dfl === false ? 'selected' : ''}>false</option></select>`;
|
||||||
|
|
||||||
} else if (p.dataDescriptor && isMap) {
|
} else if (p.dataDescriptor && isMap) {
|
||||||
|
// Map с фиксированной структурой → renderMapFixedRow (вложенные поля)
|
||||||
return renderMapFixedRow(p, dfl);
|
return renderMapFixedRow(p, dfl);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
|
// Обычное текстовое поле (string, integer, map без структуры)
|
||||||
const dt = isMap ? 'map' : '';
|
const dt = isMap ? 'map' : '';
|
||||||
input = `<input type="text" name="p_${p.svcOperationCfsParamId}" value="${_esc(dfl)}" data-type="${dt}" placeholder="${req && !hasDfl ? 'обязательно' : ''}"${isMap ? ' onblur="validateJson(this)"' : ''}>`;
|
input = `<input type="text" name="p_${p.svcOperationCfsParamId}" value="${_esc(dfl)}" data-type="${dt}" placeholder="${req && !hasDfl ? 'обязательно' : ''}"${isMap ? ' onblur="validateJson(this)"' : ''}>`;
|
||||||
if (isMap) input += `<span class="json-err" style="display:none;color:var(--destructive);font-size:10px;margin-left:4px;"></span>`;
|
if (isMap) input += `<span class="json-err" style="display:none;color:var(--destructive);font-size:10px;margin-left:4px;"></span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `<div class="param-row"><label class="${labelCls}">${_esc(p.name)}</label>${input}</div>`;
|
return `<div class="param-row"><label class="${labelCls}">${_esc(p.name)}</label>${input}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отрисовать map-параметр с ФИКСИРОВАННЫМИ ключами (dataDescriptor).
|
||||||
|
*
|
||||||
|
* Каждый ключ dataDescriptor → отдельная строка с под-лейблом.
|
||||||
|
* Вложенные поля отбиваются вертикальной чертой слева.
|
||||||
|
*
|
||||||
|
* @param {Object} p — параметр с dataDescriptor
|
||||||
|
* @param {string} dfl — текущее значение (JSON-строка)
|
||||||
|
* @returns {string} HTML
|
||||||
|
*/
|
||||||
function renderMapFixedRow(p, dfl) {
|
function renderMapFixedRow(p, dfl) {
|
||||||
const dd = p.dataDescriptor;
|
const dd = p.dataDescriptor;
|
||||||
const labelCls = p.isRequired ? (dfl ? 'req' : 'req-nodfl') : '';
|
const labelCls = p.isRequired ? (dfl ? 'req' : 'req-nodfl') : '';
|
||||||
let subHtml = '';
|
let subHtml = '';
|
||||||
|
// Парсим текущее значение (JSON → объект)
|
||||||
let dflObj = {};
|
let dflObj = {};
|
||||||
try { dflObj = JSON.parse(dfl || '{}'); } catch (e) { }
|
try { dflObj = JSON.parse(dfl || '{}'); } catch (e) { }
|
||||||
|
|
||||||
Object.keys(dd).forEach(subKey => {
|
Object.keys(dd).forEach(subKey => {
|
||||||
const sub = dd[subKey];
|
const sub = dd[subKey];
|
||||||
|
// Значение sub-ключа: из parsed JSON → default → пусто
|
||||||
const subDfl = dflObj[subKey] !== undefined ? dflObj[subKey] : (sub.defaultValue || '');
|
const subDfl = dflObj[subKey] !== undefined ? dflObj[subKey] : (sub.defaultValue || '');
|
||||||
const subVl = sub.valueList;
|
const subVl = sub.valueList;
|
||||||
const subReq = sub.isRequired;
|
const subReq = sub.isRequired;
|
||||||
const subNoDfl = subReq && !subDfl && subDfl !== 0 && subDfl !== false;
|
const subNoDfl = subReq && !subDfl && subDfl !== 0 && subDfl !== false;
|
||||||
const subLblCls = subNoDfl ? 'req-nodfl' : '';
|
const subLblCls = subNoDfl ? 'req-nodfl' : '';
|
||||||
let si;
|
let si;
|
||||||
|
|
||||||
if (subVl && Array.isArray(subVl)) {
|
if (subVl && Array.isArray(subVl)) {
|
||||||
|
// Выпадающий список
|
||||||
si = `<select name="p_${p.svcOperationCfsParamId}.${subKey}" data-type="mapchild">${subVl.map(v => `<option value="${_esc(v)}" ${v == subDfl ? 'selected' : ''}>${_esc(v)}</option>`).join('')}</select>`;
|
si = `<select name="p_${p.svcOperationCfsParamId}.${subKey}" data-type="mapchild">${subVl.map(v => `<option value="${_esc(v)}" ${v == subDfl ? 'selected' : ''}>${_esc(v)}</option>`).join('')}</select>`;
|
||||||
} else if (sub.dataType === 'boolean') {
|
} else if (sub.dataType === 'boolean') {
|
||||||
|
// Булево
|
||||||
si = `<select name="p_${p.svcOperationCfsParamId}.${subKey}" data-type="mapchild"><option value="true" ${subDfl === 'true' || subDfl === true ? 'selected' : ''}>true</option><option value="false" ${subDfl === 'false' || subDfl === false ? 'selected' : ''}>false</option></select>`;
|
si = `<select name="p_${p.svcOperationCfsParamId}.${subKey}" data-type="mapchild"><option value="true" ${subDfl === 'true' || subDfl === true ? 'selected' : ''}>true</option><option value="false" ${subDfl === 'false' || subDfl === false ? 'selected' : ''}>false</option></select>`;
|
||||||
} else {
|
} else {
|
||||||
|
// Текстовое поле
|
||||||
si = `<input type="text" name="p_${p.svcOperationCfsParamId}.${subKey}" value="${_esc(subDfl)}" data-type="mapchild">`;
|
si = `<input type="text" name="p_${p.svcOperationCfsParamId}.${subKey}" value="${_esc(subDfl)}" data-type="mapchild">`;
|
||||||
}
|
}
|
||||||
subHtml += `<div class="param-row"><label class="${subLblCls}">${_esc(subKey)}</label>${si}</div>`;
|
subHtml += `<div class="param-row"><label class="${subLblCls}">${_esc(subKey)}</label>${si}</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Основной лейбл + вложенные поля с вертикальной чертой
|
||||||
return `<div class="param-row"><label class="${labelCls}">${_esc(p.name)}</label></div><div style="border-left:2px solid var(--brand-gray);margin-left:8px;padding:0 0 4px 8px;">${subHtml}</div>`;
|
return `<div class="param-row"><label class="${labelCls}">${_esc(p.name)}</label></div><div style="border-left:2px solid var(--brand-gray);margin-left:8px;padding:0 0 4px 8px;">${subHtml}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собрать значения ВСЕХ полей формы → {numeric_id: string_value}.
|
||||||
|
*
|
||||||
|
* Для mapchild-полей (с точкой в имени) — собирает вложенный объект
|
||||||
|
* и сериализует в JSON.
|
||||||
|
*
|
||||||
|
* @param {string} containerSelector — CSS-селектор контейнера (по умолчанию '#params-form')
|
||||||
|
* @returns {Object} {paramId: value, ...}
|
||||||
|
*/
|
||||||
function collectParams(containerSelector) {
|
function collectParams(containerSelector) {
|
||||||
containerSelector = containerSelector || '#params-form';
|
containerSelector = containerSelector || '#params-form';
|
||||||
const pp = {};
|
const pp = {}; // результат: {numeric_id: value}
|
||||||
const nested = {};
|
const nested = {}; // временно: {parentId: {subKey: value}} для mapchild
|
||||||
const container = document.querySelector(containerSelector);
|
const container = document.querySelector(containerSelector);
|
||||||
if (!container) return pp;
|
if (!container) return pp;
|
||||||
|
|
||||||
|
// Перебираем ВСЕ элементы с name^="p_"
|
||||||
container.querySelectorAll('[name^="p_"]').forEach(el => {
|
container.querySelectorAll('[name^="p_"]').forEach(el => {
|
||||||
let v = el.value;
|
let v = el.value;
|
||||||
const dt = el.dataset.type || '';
|
const dt = el.dataset.type || '';
|
||||||
|
|
||||||
if (dt === 'mapchild') {
|
if (dt === 'mapchild') {
|
||||||
|
// Вложенный параметр: имя вида "p_198.host"
|
||||||
|
// Извлекаем parentId="198", subKey="host"
|
||||||
const name = el.name.replace('p_', '');
|
const name = el.name.replace('p_', '');
|
||||||
const dot = name.indexOf('.');
|
const dot = name.indexOf('.');
|
||||||
const parentKey = name.substring(0, dot);
|
const parentKey = name.substring(0, dot);
|
||||||
@@ -73,10 +144,17 @@ function collectParams(containerSelector) {
|
|||||||
nested[parentKey][subKey] = v;
|
nested[parentKey][subKey] = v;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Тип array: оборачиваем в JSON-массив
|
||||||
if (dt === 'array' || dt.startsWith('array')) v = JSON.stringify([v]);
|
if (dt === 'array' || dt.startsWith('array')) v = JSON.stringify([v]);
|
||||||
|
// Тип map: пустое значение → '{}'
|
||||||
if (dt === 'map' || dt === 'map-fixed') v = v || '{}';
|
if (dt === 'map' || dt === 'map-fixed') v = v || '{}';
|
||||||
|
|
||||||
pp[el.name.replace('p_', '')] = v;
|
pp[el.name.replace('p_', '')] = v;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Сериализовать вложенные mapchild в JSON-строки
|
||||||
for (const pk in nested) pp[pk] = JSON.stringify(nested[pk]);
|
for (const pk in nested) pp[pk] = JSON.stringify(nested[pk]);
|
||||||
|
|
||||||
return pp;
|
return pp;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,27 @@
|
|||||||
// scenario-create.js — создание и клонирование сценария
|
// scenario-create.js — создание и клонирование сценариев
|
||||||
|
//
|
||||||
|
// createScenario() — открыть редактор для нового сценария
|
||||||
|
// cloneScenario(id) — клонировать существующий (prompt имени → POST /api/scenario/definitions)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Создать новый сценарий — открывает модальный редактор.
|
||||||
|
*/
|
||||||
async function createScenario() {
|
async function createScenario() {
|
||||||
showScenarioEditor(null);
|
showScenarioEditor(null); // null = новый, без определения
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Клонировать существующий сценарий.
|
||||||
|
*
|
||||||
|
* Алгоритм:
|
||||||
|
* 1. prompt — спросить имя для копии
|
||||||
|
* 2. GET /api/scenario/definitions/{id} — загрузить исходные steps
|
||||||
|
* 3. POST /api/scenario/definitions — создать копию с новым именем
|
||||||
|
* 4. Обновить список сценариев
|
||||||
|
*
|
||||||
|
* @param {number} defId — ID исходного сценария
|
||||||
|
* @param {string} origName — имя исходного (для prompt)
|
||||||
|
*/
|
||||||
async function cloneScenario(defId, origName) {
|
async function cloneScenario(defId, origName) {
|
||||||
const newName = prompt('Имя нового сценария (копия «' + origName + '»):', origName + '_copy');
|
const newName = prompt('Имя нового сценария (копия «' + origName + '»):', origName + '_copy');
|
||||||
if (!newName) return;
|
if (!newName) return;
|
||||||
@@ -18,7 +36,7 @@ async function cloneScenario(defId, origName) {
|
|||||||
});
|
});
|
||||||
const d = await rr.json();
|
const d = await rr.json();
|
||||||
if (d.error) { alert(d.error); return; }
|
if (d.error) { alert(d.error); return; }
|
||||||
await loadScenarios();
|
await loadScenarios(); // обновить список
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Ошибка клонирования: ' + e.message);
|
alert('Ошибка клонирования: ' + e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
// scenario-delete.js — удаление сценария
|
// scenario-delete.js — удаление сценария (мягкое, is_active=false)
|
||||||
|
//
|
||||||
|
// deleteScenario(id, name) — confirm → DELETE /api/scenario/definitions/{id} → обновить список
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удалить сценарий — мягкое удаление (is_active = FALSE).
|
||||||
|
*
|
||||||
|
* @param {number} defId — ID сценария
|
||||||
|
* @param {string} name — имя сценария (для confirm-сообщения)
|
||||||
|
*/
|
||||||
async function deleteScenario(defId, name) {
|
async function deleteScenario(defId, name) {
|
||||||
if (!confirm('Удалить сценарий «' + name + '»?')) return;
|
if (!confirm('Удалить сценарий «' + name + '»?')) return;
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/scenario/definitions/' + defId, { method: 'DELETE' });
|
const r = await fetch('/api/scenario/definitions/' + defId, { method: 'DELETE' });
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (d.error) { alert(d.error); return; }
|
if (d.error) { alert(d.error); return; }
|
||||||
await loadScenarios();
|
await loadScenarios(); // обновить список
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Ошибка: ' + e.message);
|
alert('Ошибка: ' + e.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
// scenario-edit.js — редактирование существующего сценария
|
// scenario-edit.js — редактирование существующего сценария
|
||||||
|
//
|
||||||
|
// editScenario(id) — загрузить определение и открыть редактор
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открыть редактор для существующего сценария.
|
||||||
|
*
|
||||||
|
* Алгоритм:
|
||||||
|
* 1. GET /api/scenario/definitions/{id} — загрузить определение
|
||||||
|
* 2. showScenarioEditor(def) — открыть модал с заполненными полями
|
||||||
|
*
|
||||||
|
* @param {number} defId — ID сценария
|
||||||
|
*/
|
||||||
async function editScenario(defId) {
|
async function editScenario(defId) {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/scenario/definitions/' + defId);
|
const r = await fetch('/api/scenario/definitions/' + defId);
|
||||||
|
|||||||
+230
-33
@@ -1,24 +1,52 @@
|
|||||||
// scenario-form.js — модальный редактор сценариев
|
// scenario-form.js — модальный редактор сценариев
|
||||||
// Использует: params-render.js (renderParamRow, renderMapFixedRow, collectParams)
|
//
|
||||||
// Зависит от: utils.js (_esc)
|
// Это САМЫЙ СЛОЖНЫЙ модуль фронтенда (~358 строк).
|
||||||
|
// Редактор позволяет создавать/редактировать сценарии через модальное окно.
|
||||||
|
//
|
||||||
|
// СОСТОЯНИЕ РЕДАКТОРА: scenarioEditorState = {
|
||||||
|
// defId, version, name, steps: [{service_id, operation, output, instance_ref, instance_uid, params, _svcOpId}],
|
||||||
|
// services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {}
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// ЖИЗНЕННЫЙ ЦИКЛ РЕДАКТИРОВАНИЯ:
|
||||||
|
// 1. showScenarioEditor(def) — инициализация, загрузка сервисов/инстансов, renderEditor()
|
||||||
|
// 2. renderEditor() — отрисовка модала с шагами
|
||||||
|
// 3. Пользователь меняет операцию → onOpChange → renderEditor (перерисовка)
|
||||||
|
// 4. Для каждого шага с операцией — loadStepParams (GET /api/params/{opId})
|
||||||
|
// 5. Параметры рендерятся через params-render.js
|
||||||
|
// 6. Перед перерисовкой — snapshotAllParams (сохранить несохранённые правки)
|
||||||
|
// 7. Сохранение — collectFormSteps → POST/PUT /api/scenario/definitions
|
||||||
|
//
|
||||||
|
// ОБРАТНЫЙ МАППИНГ ПАРАМЕТРОВ:
|
||||||
|
// В форме параметры хранятся с числовыми ID (как и в API).
|
||||||
|
// В БД сценария — с символьными именами.
|
||||||
|
// При сохранении: numeric_id → name (через paramDefsCache)
|
||||||
|
// При загрузке: name → numeric_id (подстановка в defaultValue)
|
||||||
|
|
||||||
let scenarioEditorState = null;
|
let scenarioEditorState = null;
|
||||||
// {defId, version, name, steps:[], services:[], cloudInstances:[], opsCache:{}, paramDefsCache:{}}
|
|
||||||
// step: {service_id, operation, output, instance_ref, instance_uid, params:{name:value}}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открыть редактор сценария.
|
||||||
|
*
|
||||||
|
* @param {Object|null} def — определение сценария или null (новый)
|
||||||
|
*/
|
||||||
async function showScenarioEditor(def) {
|
async function showScenarioEditor(def) {
|
||||||
|
// Инициализация состояния
|
||||||
scenarioEditorState = def ? {
|
scenarioEditorState = def ? {
|
||||||
defId: def.id, version: def.version, name: def.name,
|
defId: def.id, version: def.version, name: def.name,
|
||||||
steps: (def.steps || []).map(s => ({
|
steps: (def.steps || []).map(s => ({
|
||||||
service_id: s.service_id || '', operation: s.operation || '',
|
service_id: s.service_id || '', operation: s.operation || '',
|
||||||
output: s.output || '', instance_ref: s.instance_ref || '',
|
output: s.output || '', instance_ref: s.instance_ref || '',
|
||||||
instance_uid: s.instance_uid || '',
|
instance_uid: s.instance_uid || '',
|
||||||
params: s.params || {} // {name: value} — символические имена
|
params: s.params || {} // {name: value} — символьные имена
|
||||||
})),
|
})),
|
||||||
services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {}
|
services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {}
|
||||||
} : { defId: null, version: 1, name: '', steps: [],
|
} : {
|
||||||
services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {} };
|
defId: null, version: 1, name: '', steps: [],
|
||||||
|
services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Загрузить справочные данные: сервисы + инстансы (для select'ов)
|
||||||
try {
|
try {
|
||||||
const [svcR, instR] = await Promise.all([
|
const [svcR, instR] = await Promise.all([
|
||||||
fetch('/api/services').then(r => r.json()),
|
fetch('/api/services').then(r => r.json()),
|
||||||
@@ -26,15 +54,25 @@ async function showScenarioEditor(def) {
|
|||||||
]);
|
]);
|
||||||
scenarioEditorState.services = svcR || [];
|
scenarioEditorState.services = svcR || [];
|
||||||
scenarioEditorState.cloudInstances = instR || [];
|
scenarioEditorState.cloudInstances = instR || [];
|
||||||
} catch (e) { scenarioEditorState.services = []; scenarioEditorState.cloudInstances = []; }
|
} catch (e) {
|
||||||
|
scenarioEditorState.services = [];
|
||||||
|
scenarioEditorState.cloudInstances = [];
|
||||||
|
}
|
||||||
renderEditor();
|
renderEditor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Открыть модальное окно с HTML-контентом.
|
||||||
|
*
|
||||||
|
* @param {string} html — HTML-контент модала
|
||||||
|
*/
|
||||||
function openModal(html) {
|
function openModal(html) {
|
||||||
let modal = document.getElementById('scenario-modal');
|
let modal = document.getElementById('scenario-modal');
|
||||||
if (!modal) {
|
if (!modal) {
|
||||||
|
// Создать модал при первом вызове
|
||||||
modal = document.createElement('div');
|
modal = document.createElement('div');
|
||||||
modal.id = 'scenario-modal';
|
modal.id = 'scenario-modal';
|
||||||
|
// backdrop — закрытие по клику вне модала
|
||||||
modal.innerHTML = '<div class="modal-backdrop" onclick="closeModal()"></div><div class="modal-content" id="modal-content"></div>';
|
modal.innerHTML = '<div class="modal-backdrop" onclick="closeModal()"></div><div class="modal-content" id="modal-content"></div>';
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
}
|
}
|
||||||
@@ -42,27 +80,45 @@ function openModal(html) {
|
|||||||
document.getElementById('modal-content').innerHTML = html;
|
document.getElementById('modal-content').innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Закрыть модальное окно и обновить список сценариев.
|
||||||
|
*/
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
const modal = document.getElementById('scenario-modal');
|
const modal = document.getElementById('scenario-modal');
|
||||||
if (modal) modal.style.display = 'none';
|
if (modal) modal.style.display = 'none';
|
||||||
loadScenarios();
|
loadScenarios(); // обновить список (могли измениться)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Снапшот параметров перед ре-рендером ──
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// Снапшот параметров — защита от потери правок
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить параметры ОДНОГО шага перед перерисовкой.
|
||||||
|
*
|
||||||
|
* Зачем: renderEditor() пересоздаёт ВЕСЬ HTML → все несохранённые правки теряются.
|
||||||
|
* Эта функция собирает текущие значения из DOM и сохраняет в step.params.
|
||||||
|
*
|
||||||
|
* @param {number} idx — индекс шага
|
||||||
|
*/
|
||||||
function snapshotStepParams(idx) {
|
function snapshotStepParams(idx) {
|
||||||
const st = scenarioEditorState;
|
const st = scenarioEditorState;
|
||||||
const container = document.getElementById('step-' + idx + '-params');
|
const container = document.getElementById('step-' + idx + '-params');
|
||||||
if (!container || !container.innerHTML) return;
|
if (!container || !container.innerHTML) return;
|
||||||
|
|
||||||
|
// Собрать значения из формы (числовые ID → значение)
|
||||||
const numericParams = collectParams('#step-' + idx + '-params');
|
const numericParams = collectParams('#step-' + idx + '-params');
|
||||||
const defs = st.paramDefsCache[st.steps[idx]._svcOpId];
|
const defs = st.paramDefsCache[st.steps[idx]._svcOpId];
|
||||||
if (!defs) return;
|
if (!defs) return;
|
||||||
// Обратный маппинг: numericId → name
|
|
||||||
|
// Обратный маппинг: numeric_id → name
|
||||||
const idToName = {};
|
const idToName = {};
|
||||||
defs.forEach(p => { idToName[p.svcOperationCfsParamId] = p.name; });
|
defs.forEach(p => { idToName[p.svcOperationCfsParamId] = p.name; });
|
||||||
|
|
||||||
const result = {};
|
const result = {};
|
||||||
for (const [id, val] of Object.entries(numericParams)) {
|
for (const [id, val] of Object.entries(numericParams)) {
|
||||||
const name = idToName[parseInt(id)] || id;
|
const name = idToName[parseInt(id)] || id;
|
||||||
|
// Сохраняем только НЕ-дефолтные значения
|
||||||
if (val !== '' && val !== '{}' && val !== '[]' && val !== 'false') {
|
if (val !== '' && val !== '{}' && val !== '[]' && val !== 'false') {
|
||||||
result[name] = val;
|
result[name] = val;
|
||||||
}
|
}
|
||||||
@@ -70,37 +126,60 @@ function snapshotStepParams(idx) {
|
|||||||
st.steps[idx].params = result;
|
st.steps[idx].params = result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Снапшот ВСЕХ шагов — вызывается перед ЛЮБОЙ перерисовкой.
|
||||||
|
*/
|
||||||
function snapshotAllParams() {
|
function snapshotAllParams() {
|
||||||
if (!scenarioEditorState) return;
|
if (!scenarioEditorState) return;
|
||||||
scenarioEditorState.steps.forEach((_, idx) => snapshotStepParams(idx));
|
scenarioEditorState.steps.forEach((_, idx) => snapshotStepParams(idx));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Резолв операции + загрузка параметров ──
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// Резолв операции + загрузка параметров
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Найти svcOperationId для операции в шаге.
|
||||||
|
*
|
||||||
|
* Для create: использует service_id шага.
|
||||||
|
* Для не-create: определяет service_id через выбранный инстанс
|
||||||
|
* (облачный UUID → serviceId, или output-ref → service_id предыдущего create-шага).
|
||||||
|
*
|
||||||
|
* @param {number} idx — индекс шага
|
||||||
|
* @returns {number|null} svcOperationId или null
|
||||||
|
*/
|
||||||
async function resolveStepSvcOpId(idx) {
|
async function resolveStepSvcOpId(idx) {
|
||||||
const st = scenarioEditorState;
|
const st = scenarioEditorState;
|
||||||
const step = st.steps[idx];
|
const step = st.steps[idx];
|
||||||
const op = step.operation;
|
const op = step.operation;
|
||||||
if (!op) return null;
|
if (!op) return null;
|
||||||
|
|
||||||
let svcId = step.service_id;
|
let svcId = step.service_id;
|
||||||
|
|
||||||
if (op !== 'create') {
|
if (op !== 'create') {
|
||||||
// Определить svcId через выбранный инстанс
|
// Для не-create: определяем svcId через выбранный инстанс
|
||||||
const refEl = document.getElementById('step-' + idx + '-ref');
|
const refEl = document.getElementById('step-' + idx + '-ref');
|
||||||
const ref = (refEl?.value || '').trim();
|
const ref = (refEl?.value || '').trim();
|
||||||
if (ref) {
|
if (ref) {
|
||||||
|
// Проверяем: это облачный UUID?
|
||||||
const cloud = (st.cloudInstances || []).find(i => i.instanceUid === ref);
|
const cloud = (st.cloudInstances || []).find(i => i.instanceUid === ref);
|
||||||
if (cloud) { svcId = cloud.serviceId; }
|
if (cloud) {
|
||||||
else {
|
svcId = cloud.serviceId;
|
||||||
// output-ref: ищем в предыдущих create-шагах
|
} else {
|
||||||
|
// Это output-ref — ищем в предыдущих create-шагах
|
||||||
for (let i = 0; i < idx; i++) {
|
for (let i = 0; i < idx; i++) {
|
||||||
if (st.steps[i].output === ref) { svcId = st.steps[i].service_id; break; }
|
if (st.steps[i].output === ref) {
|
||||||
|
svcId = st.steps[i].service_id;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!svcId) return null;
|
if (!svcId) return null;
|
||||||
|
|
||||||
// Кеш операций
|
// Кеш операций сервиса
|
||||||
if (!st.opsCache[svcId]) {
|
if (!st.opsCache[svcId]) {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/operations/' + svcId);
|
const r = await fetch('/api/operations/' + svcId);
|
||||||
@@ -113,6 +192,17 @@ async function resolveStepSvcOpId(idx) {
|
|||||||
return found ? found.svcOperationId : null;
|
return found ? found.svcOperationId : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузить и отрисовать параметры для шага.
|
||||||
|
*
|
||||||
|
* Алгоритм:
|
||||||
|
* 1. resolveStepSvcOpId — найти ID операции
|
||||||
|
* 2. GET /api/params/{svcOpId} — шаблон параметров
|
||||||
|
* 3. Подставить сохранённые значения (step.params) в defaultValue
|
||||||
|
* 4. Отрисовать через renderParamRow
|
||||||
|
*
|
||||||
|
* @param {number} idx — индекс шага
|
||||||
|
*/
|
||||||
async function loadStepParams(idx) {
|
async function loadStepParams(idx) {
|
||||||
const st = scenarioEditorState;
|
const st = scenarioEditorState;
|
||||||
const step = st.steps[idx];
|
const step = st.steps[idx];
|
||||||
@@ -120,7 +210,7 @@ async function loadStepParams(idx) {
|
|||||||
|
|
||||||
const svcOpId = await resolveStepSvcOpId(idx);
|
const svcOpId = await resolveStepSvcOpId(idx);
|
||||||
if (!svcOpId) return;
|
if (!svcOpId) return;
|
||||||
step._svcOpId = svcOpId;
|
step._svcOpId = svcOpId; // сохраняем для обратного маппинга при сохранении
|
||||||
|
|
||||||
// Кеш параметров
|
// Кеш параметров
|
||||||
if (!st.paramDefsCache[svcOpId]) {
|
if (!st.paramDefsCache[svcOpId]) {
|
||||||
@@ -140,29 +230,47 @@ async function loadStepParams(idx) {
|
|||||||
try { const r = await fetch('/api/instances/list'); allInst = await r.json(); } catch (e) {}
|
try { const r = await fetch('/api/instances/list'); allInst = await r.json(); } catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Рендер: клон def с подстановкой сохранённых значений в defaultValue
|
// Рендер: клонируем def и подставляем сохранённые значения в defaultValue
|
||||||
const container = document.getElementById('step-' + idx + '-params');
|
const container = document.getElementById('step-' + idx + '-params');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
let html = '';
|
let html = '';
|
||||||
defs.forEach(p => {
|
defs.forEach(p => {
|
||||||
const clone = Object.assign({}, p);
|
const clone = Object.assign({}, p); // shallow copy
|
||||||
const savedVal = savedParams[p.name];
|
const savedVal = savedParams[p.name]; // ищем по символическому имени
|
||||||
if (savedVal !== undefined) clone.defaultValue = savedVal;
|
if (savedVal !== undefined) clone.defaultValue = savedVal;
|
||||||
html += renderParamRow(clone, allInst);
|
html += renderParamRow(clone, allInst);
|
||||||
});
|
});
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Рендер ──
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// Рендер редактора
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Главная функция рендера — перерисовывает ВЕСЬ модал.
|
||||||
|
*
|
||||||
|
* Вызывается при:
|
||||||
|
* - Открытии редактора
|
||||||
|
* - Изменении операции (onOpChange)
|
||||||
|
* - Добавлении/удалении/перемещении шагов
|
||||||
|
* - Переключении облачных инстансов
|
||||||
|
*/
|
||||||
function renderEditor() {
|
function renderEditor() {
|
||||||
snapshotAllParams(); // сохранить несохранённые правки перед перерисовкой
|
snapshotAllParams(); // сохранить несохранённые правки перед перерисовкой
|
||||||
const st = scenarioEditorState;
|
const st = scenarioEditorState;
|
||||||
|
|
||||||
let html = '<div style="padding:20px;max-height:90vh;overflow-y:auto;">';
|
let html = '<div style="padding:20px;max-height:90vh;overflow-y:auto;">';
|
||||||
|
|
||||||
|
// Заголовок
|
||||||
html += '<h3 style="margin:0 0 12px;">' + (st.defId ? 'Редактирование' : 'Новый сценарий') + '</h3>';
|
html += '<h3 style="margin:0 0 12px;">' + (st.defId ? 'Редактирование' : 'Новый сценарий') + '</h3>';
|
||||||
|
|
||||||
|
// Поле названия
|
||||||
html += '<div style="margin-bottom:10px;"><label style="font-size:12px;font-weight:600;">Название</label><br>';
|
html += '<div style="margin-bottom:10px;"><label style="font-size:12px;font-weight:600;">Название</label><br>';
|
||||||
html += `<input type="text" id="scenario-name" value="${_esc(st.name)}" style="width:100%;padding:6px;font-size:13px;" placeholder="my_test">`;
|
html += `<input type="text" id="scenario-name" value="${_esc(st.name)}" style="width:100%;padding:6px;font-size:13px;" placeholder="my_test">`;
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
|
// Справка «Как заполнять»
|
||||||
html += `<details style="margin-bottom:10px;font-size:11px;color:var(--muted);border:1px solid var(--brand-gray);border-radius:4px;padding:6px 8px;">
|
html += `<details style="margin-bottom:10px;font-size:11px;color:var(--muted);border:1px solid var(--brand-gray);border-radius:4px;padding:6px 8px;">
|
||||||
<summary style="cursor:pointer;font-weight:600;">📖 Как заполнять</summary>
|
<summary style="cursor:pointer;font-weight:600;">📖 Как заполнять</summary>
|
||||||
<div style="margin-top:6px;line-height:1.6;">
|
<div style="margin-top:6px;line-height:1.6;">
|
||||||
@@ -173,25 +281,40 @@ function renderEditor() {
|
|||||||
<b>[↑][↓]</b> — порядок, <b>[✕]</b> — удалить.
|
<b>[↑][↓]</b> — порядок, <b>[✕]</b> — удалить.
|
||||||
</div>
|
</div>
|
||||||
</details>`;
|
</details>`;
|
||||||
|
|
||||||
|
// Шаги
|
||||||
html += '<div style="font-size:12px;font-weight:600;margin-bottom:6px;">Шаги</div>';
|
html += '<div style="font-size:12px;font-weight:600;margin-bottom:6px;">Шаги</div>';
|
||||||
st.steps.forEach((s, idx) => { html += renderStepRow(idx, s); });
|
st.steps.forEach((s, idx) => { html += renderStepRow(idx, s); });
|
||||||
|
|
||||||
|
// Кнопка добавления шага
|
||||||
html += `<button class="btn btn-sm" style="margin-top:6px;" onclick="addStep()">+ Добавить шаг</button>`;
|
html += `<button class="btn btn-sm" style="margin-top:6px;" onclick="addStep()">+ Добавить шаг</button>`;
|
||||||
|
|
||||||
|
// Кнопки Сохранить / Отмена
|
||||||
html += '<div style="margin-top:14px;display:flex;gap:8px;">';
|
html += '<div style="margin-top:14px;display:flex;gap:8px;">';
|
||||||
html += `<button class="btn btn-sm" style="background:var(--brand-primary);color:#fff;padding:6px 16px;" onclick="saveScenario()">Сохранить</button>`;
|
html += `<button class="btn btn-sm" style="background:var(--brand-primary);color:#fff;padding:6px 16px;" onclick="saveScenario()">Сохранить</button>`;
|
||||||
html += `<button class="btn btn-sm" style="padding:6px 16px;" onclick="closeModal()">Отмена</button>`;
|
html += `<button class="btn btn-sm" style="padding:6px 16px;" onclick="closeModal()">Отмена</button>`;
|
||||||
html += '</div></div>';
|
html += '</div></div>';
|
||||||
|
|
||||||
openModal(html);
|
openModal(html);
|
||||||
|
|
||||||
// Асинхронно загрузить параметры для шагов с выбранной операцией
|
// Асинхронно загрузить параметры для шагов с выбранной операцией
|
||||||
st.steps.forEach((s, idx) => { if (s.operation) loadStepParams(idx); });
|
st.steps.forEach((s, idx) => { if (s.operation) loadStepParams(idx); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отрисовать ОДНУ строку шага в редакторе.
|
||||||
|
*
|
||||||
|
* @param {number} idx — индекс шага
|
||||||
|
* @param {Object} step — {service_id, operation, output, instance_ref, instance_uid, params}
|
||||||
|
* @returns {string} HTML
|
||||||
|
*/
|
||||||
function renderStepRow(idx, step) {
|
function renderStepRow(idx, step) {
|
||||||
const st = scenarioEditorState;
|
const st = scenarioEditorState;
|
||||||
const op = step.operation;
|
const op = step.operation;
|
||||||
const out = step.output || '';
|
const out = step.output || '';
|
||||||
const ref = step.instance_ref || '';
|
const ref = step.instance_ref || '';
|
||||||
|
|
||||||
|
// Собрать доступные output-ы из предыдущих create-шагов (для instance_ref select)
|
||||||
const prevOutputs = [];
|
const prevOutputs = [];
|
||||||
for (let i = 0; i < idx; i++) {
|
for (let i = 0; i < idx; i++) {
|
||||||
const s = st.steps[i];
|
const s = st.steps[i];
|
||||||
@@ -199,33 +322,47 @@ function renderStepRow(idx, step) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let html = `<div style="border:1px solid var(--brand-gray);border-radius:6px;padding:8px;margin:6px 0;" id="step-${idx}">`;
|
let html = `<div style="border:1px solid var(--brand-gray);border-radius:6px;padding:8px;margin:6px 0;" id="step-${idx}">`;
|
||||||
|
|
||||||
|
// Заголовок шага: номер + кнопки [↑][↓][✕]
|
||||||
html += `<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">`;
|
html += `<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">`;
|
||||||
html += `<span style="font-size:11px;font-weight:600;">Шаг ${idx+1}</span>`;
|
html += `<span style="font-size:11px;font-weight:600;">Шаг ${idx+1}</span>`;
|
||||||
|
// Кнопка вверх (кроме первого)
|
||||||
if (idx > 0) html += `<button class="btn btn-sm" style="font-size:9px;padding:0 4px;" onclick="moveStep(${idx},-1);renderEditor();">↑</button>`;
|
if (idx > 0) html += `<button class="btn btn-sm" style="font-size:9px;padding:0 4px;" onclick="moveStep(${idx},-1);renderEditor();">↑</button>`;
|
||||||
|
// Кнопка вниз (кроме последнего)
|
||||||
if (idx < st.steps.length - 1) html += `<button class="btn btn-sm" style="font-size:9px;padding:0 4px;" onclick="moveStep(${idx},1);renderEditor();">↓</button>`;
|
if (idx < st.steps.length - 1) html += `<button class="btn btn-sm" style="font-size:9px;padding:0 4px;" onclick="moveStep(${idx},1);renderEditor();">↓</button>`;
|
||||||
|
// Кнопка удаления
|
||||||
html += `<button class="btn btn-sm" style="font-size:9px;padding:0 4px;margin-left:auto;color:var(--destructive);" onclick="removeStep(${idx})">✕</button>`;
|
html += `<button class="btn btn-sm" style="font-size:9px;padding:0 4px;margin-left:auto;color:var(--destructive);" onclick="removeStep(${idx})">✕</button>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
|
|
||||||
|
// Выпадающий список операций
|
||||||
const allOps = ['create', 'delete', 'modify', 'suspend', 'resume', 'redeploy'];
|
const allOps = ['create', 'delete', 'modify', 'suspend', 'resume', 'redeploy'];
|
||||||
let opOpts = '<option value="">— операция —</option>';
|
let opOpts = '<option value="">— операция —</option>';
|
||||||
allOps.forEach(o => { opOpts += `<option value="${o}" ${o == op ? 'selected' : ''}>${o}</option>`; });
|
allOps.forEach(o => { opOpts += `<option value="${o}" ${o == op ? 'selected' : ''}>${o}</option>`; });
|
||||||
html += `<div style="margin-bottom:4px;"><select id="step-${idx}-op" onchange="onOpChange(${idx})" style="padding:4px;font-size:12px;">${opOpts}</select></div>`;
|
html += `<div style="margin-bottom:4px;"><select id="step-${idx}-op" onchange="onOpChange(${idx})" style="padding:4px;font-size:12px;">${opOpts}</select></div>`;
|
||||||
|
|
||||||
|
// Разное содержимое в зависимости от типа операции
|
||||||
if (op === 'create') {
|
if (op === 'create') {
|
||||||
|
// CREATE: выбор сервиса + output name
|
||||||
let svcOpts = '<option value="">— сервис —</option>';
|
let svcOpts = '<option value="">— сервис —</option>';
|
||||||
st.services.forEach(s => {
|
st.services.forEach(s => {
|
||||||
svcOpts += `<option value="${s.svcId}" ${s.svcId == step.service_id ? 'selected' : ''}>${s.svcId}. ${_esc(s.svc)}</option>`;
|
svcOpts += `<option value="${s.svcId}" ${s.svcId == step.service_id ? 'selected' : ''}>${s.svcId}. ${_esc(s.svc)}</option>`;
|
||||||
});
|
});
|
||||||
html += `<div style="display:flex;gap:6px;align-items:center;"><select id="step-${idx}-svc" style="padding:4px;font-size:12px;">${svcOpts}</select></div>`;
|
html += `<div style="display:flex;gap:6px;align-items:center;"><select id="step-${idx}-svc" style="padding:4px;font-size:12px;">${svcOpts}</select></div>`;
|
||||||
html += `<div style="margin-top:4px;"><input type="text" id="step-${idx}-output" value="${_esc(out)}" placeholder="имя для ссылок (например: d1)" style="width:220px;padding:3px;font-size:11px;"></div>`;
|
html += `<div style="margin-top:4px;"><input type="text" id="step-${idx}-output" value="${_esc(out)}" placeholder="имя для ссылок (например: d1)" style="width:220px;padding:3px;font-size:11px;"></div>`;
|
||||||
|
|
||||||
} else if (op) {
|
} else if (op) {
|
||||||
|
// НЕ-CREATE: выбор инстанса (из create-шагов + облако)
|
||||||
let refOpts = '<option value="">— инстанс —</option>';
|
let refOpts = '<option value="">— инстанс —</option>';
|
||||||
prevOutputs.forEach(o => { refOpts += `<option value="${o}" ${o == ref ? 'selected' : ''}>🆕 ${_esc(o)} (из шага)</option>`; });
|
// Инстансы из предыдущих create-шагов
|
||||||
|
prevOutputs.forEach(o => {
|
||||||
|
refOpts += `<option value="${o}" ${o == ref ? 'selected' : ''}>🆕 ${_esc(o)} (из шага)</option>`;
|
||||||
|
});
|
||||||
// Облачные autotest-* инстансы (только если раскрыты)
|
// Облачные autotest-* инстансы (только если раскрыты)
|
||||||
if (step._showCloud && st.cloudInstances) {
|
if (step._showCloud && st.cloudInstances) {
|
||||||
refOpts += '<option disabled>── облако ──</option>';
|
refOpts += '<option disabled>── облако ──</option>';
|
||||||
st.cloudInstances.forEach(i => {
|
st.cloudInstances.forEach(i => {
|
||||||
const dn = i.displayName || '';
|
const dn = i.displayName || '';
|
||||||
|
// Только autotest-*, не deleted
|
||||||
if (!dn.startsWith('autotest-') || i.explainedStatus === 'deleted') return;
|
if (!dn.startsWith('autotest-') || i.explainedStatus === 'deleted') return;
|
||||||
const sel = i.instanceUid == ref ? 'selected' : '';
|
const sel = i.instanceUid == ref ? 'selected' : '';
|
||||||
refOpts += `<option value="${i.instanceUid}" ${sel}>☁ ${_esc(dn)} — ${_esc(i.svc||'')} (${_esc(i.explainedStatus||'?')})</option>`;
|
refOpts += `<option value="${i.instanceUid}" ${sel}>☁ ${_esc(dn)} — ${_esc(i.svc||'')} (${_esc(i.explainedStatus||'?')})</option>`;
|
||||||
@@ -236,53 +373,88 @@ function renderStepRow(idx, step) {
|
|||||||
html += `<button class="btn btn-sm" style="font-size:10px;padding:0 6px;" onclick="toggleCloudInstances(${idx})">${btnLabel}</button></div>`;
|
html += `<button class="btn btn-sm" style="font-size:10px;padding:0 6px;" onclick="toggleCloudInstances(${idx})">${btnLabel}</button></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Параметры — свёрнутый <details>, заполняется асинхронно в loadStepParams
|
// Параметры — свёрнутый <details>, заполняется асинхронно в loadStepParams()
|
||||||
html += `<details style="margin-top:4px;" id="step-${idx}-params-box"><summary style="font-size:11px;cursor:pointer;">Параметры</summary><div id="step-${idx}-params" style="margin-top:4px;"></div></details>`;
|
html += `<details style="margin-top:4px;" id="step-${idx}-params-box"><summary style="font-size:11px;cursor:pointer;">Параметры</summary><div id="step-${idx}-params" style="margin-top:4px;"></div></details>`;
|
||||||
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Действия ──
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// Действия редактора
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Изменение операции в выпадающем списке → сброс параметров + перерисовка.
|
||||||
|
*/
|
||||||
function onOpChange(idx) {
|
function onOpChange(idx) {
|
||||||
scenarioEditorState.steps[idx].params = {};
|
scenarioEditorState.steps[idx].params = {}; // сброс сохранённых параметров
|
||||||
scenarioEditorState.steps[idx]._svcOpId = null;
|
scenarioEditorState.steps[idx]._svcOpId = null; // сброс кеша svcOpId
|
||||||
renderEditor();
|
renderEditor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переключить показ облачных инстансов в select'е.
|
||||||
|
*/
|
||||||
function toggleCloudInstances(idx) {
|
function toggleCloudInstances(idx) {
|
||||||
snapshotAllParams();
|
snapshotAllParams();
|
||||||
scenarioEditorState.steps[idx]._showCloud = !scenarioEditorState.steps[idx]._showCloud;
|
scenarioEditorState.steps[idx]._showCloud = !scenarioEditorState.steps[idx]._showCloud;
|
||||||
renderEditor();
|
renderEditor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавить новый (пустой) шаг.
|
||||||
|
*/
|
||||||
function addStep() {
|
function addStep() {
|
||||||
snapshotAllParams();
|
snapshotAllParams();
|
||||||
scenarioEditorState.steps.push({ service_id: '', operation: '', output: '', instance_ref: '', instance_uid: '', params: {}, _showCloud: false });
|
scenarioEditorState.steps.push({
|
||||||
|
service_id: '', operation: '', output: '', instance_ref: '',
|
||||||
|
instance_uid: '', params: {}, _showCloud: false
|
||||||
|
});
|
||||||
renderEditor();
|
renderEditor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удалить шаг по индексу.
|
||||||
|
*/
|
||||||
function removeStep(idx) {
|
function removeStep(idx) {
|
||||||
snapshotAllParams();
|
snapshotAllParams();
|
||||||
scenarioEditorState.steps.splice(idx, 1);
|
scenarioEditorState.steps.splice(idx, 1);
|
||||||
renderEditor();
|
renderEditor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переместить шаг вверх/вниз.
|
||||||
|
*
|
||||||
|
* @param {number} idx — индекс шага
|
||||||
|
* @param {number} dir — -1 (вверх) или +1 (вниз)
|
||||||
|
*/
|
||||||
function moveStep(idx, dir) {
|
function moveStep(idx, dir) {
|
||||||
snapshotAllParams();
|
snapshotAllParams();
|
||||||
const steps = scenarioEditorState.steps;
|
const steps = scenarioEditorState.steps;
|
||||||
const target = idx + dir;
|
const target = idx + dir;
|
||||||
if (target < 0 || target >= steps.length) return;
|
if (target < 0 || target >= steps.length) return;
|
||||||
|
// Swap
|
||||||
[steps[idx], steps[target]] = [steps[target], steps[idx]];
|
[steps[idx], steps[target]] = [steps[target], steps[idx]];
|
||||||
renderEditor();
|
renderEditor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Собрать ВСЕ шаги из DOM для сохранения.
|
||||||
|
*
|
||||||
|
* Для каждого шага:
|
||||||
|
* - Извлечь service_id (из select для create, из инстанса для не-create)
|
||||||
|
* - Извлечь output/instance_ref/instance_uid
|
||||||
|
* - Собрать параметры через collectParams + обратный маппинг numeric→name
|
||||||
|
*
|
||||||
|
* @returns {{name: string, steps: Array}}
|
||||||
|
*/
|
||||||
function collectFormSteps() {
|
function collectFormSteps() {
|
||||||
snapshotAllParams();
|
snapshotAllParams(); // финальный снапшот
|
||||||
const st = scenarioEditorState;
|
const st = scenarioEditorState;
|
||||||
const name = (document.getElementById('scenario-name')?.value || '').trim();
|
const name = (document.getElementById('scenario-name')?.value || '').trim();
|
||||||
const steps = [];
|
const steps = [];
|
||||||
|
|
||||||
st.steps.forEach((step, idx) => {
|
st.steps.forEach((step, idx) => {
|
||||||
const opEl = document.getElementById('step-' + idx + '-op');
|
const opEl = document.getElementById('step-' + idx + '-op');
|
||||||
const operation = (opEl?.value || '').trim();
|
const operation = (opEl?.value || '').trim();
|
||||||
@@ -290,21 +462,23 @@ function collectFormSteps() {
|
|||||||
const s = { operation, params: {} };
|
const s = { operation, params: {} };
|
||||||
|
|
||||||
if (operation === 'create') {
|
if (operation === 'create') {
|
||||||
|
// CREATE: service_id из select
|
||||||
const svcEl = document.getElementById('step-' + idx + '-svc');
|
const svcEl = document.getElementById('step-' + idx + '-svc');
|
||||||
svcId = parseInt(svcEl?.value) || 0;
|
svcId = parseInt(svcEl?.value) || 0;
|
||||||
const outEl = document.getElementById('step-' + idx + '-output');
|
const outEl = document.getElementById('step-' + idx + '-output');
|
||||||
const output = (outEl?.value || '').trim();
|
const output = (outEl?.value || '').trim();
|
||||||
if (output) s.output = output;
|
if (output) s.output = output;
|
||||||
} else {
|
} else {
|
||||||
|
// НЕ-CREATE: определяем instance_ref или instance_uid
|
||||||
const refEl = document.getElementById('step-' + idx + '-ref');
|
const refEl = document.getElementById('step-' + idx + '-ref');
|
||||||
const ref = (refEl?.value || '').trim();
|
const ref = (refEl?.value || '').trim();
|
||||||
if (ref) {
|
if (ref) {
|
||||||
const cloud = (st.cloudInstances || []).find(i => i.instanceUid === ref);
|
const cloud = (st.cloudInstances || []).find(i => i.instanceUid === ref);
|
||||||
if (cloud) {
|
if (cloud) {
|
||||||
svcId = cloud.serviceId;
|
svcId = cloud.serviceId;
|
||||||
s.instance_uid = ref;
|
s.instance_uid = ref; // облачный UUID
|
||||||
} else {
|
} else {
|
||||||
s.instance_ref = ref;
|
s.instance_ref = ref; // output-ссылка
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,6 +492,7 @@ function collectFormSteps() {
|
|||||||
defs.forEach(p => { idToName[p.svcOperationCfsParamId] = p.name; });
|
defs.forEach(p => { idToName[p.svcOperationCfsParamId] = p.name; });
|
||||||
for (const [id, val] of Object.entries(numericParams)) {
|
for (const [id, val] of Object.entries(numericParams)) {
|
||||||
const pname = idToName[parseInt(id)] || id;
|
const pname = idToName[parseInt(id)] || id;
|
||||||
|
// Сохраняем только НЕ-дефолтные значения
|
||||||
if (val !== '' && val !== '{}' && val !== '[]' && val !== 'false') {
|
if (val !== '' && val !== '{}' && val !== '[]' && val !== 'false') {
|
||||||
s.params[pname] = val;
|
s.params[pname] = val;
|
||||||
}
|
}
|
||||||
@@ -328,29 +503,51 @@ function collectFormSteps() {
|
|||||||
return { name, steps };
|
return { name, steps };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохранить сценарий — POST (новый) или PUT (существующий).
|
||||||
|
*
|
||||||
|
* Валидация перед отправкой:
|
||||||
|
* - Название не пустое
|
||||||
|
* - Хотя бы один шаг
|
||||||
|
* - У каждого шага выбран сервис/инстанс и операция
|
||||||
|
*/
|
||||||
async function saveScenario() {
|
async function saveScenario() {
|
||||||
const { name, steps } = collectFormSteps();
|
const { name, steps } = collectFormSteps();
|
||||||
|
|
||||||
|
// Валидация
|
||||||
if (!name) { alert('Введите название'); return; }
|
if (!name) { alert('Введите название'); return; }
|
||||||
if (!steps.length) { alert('Добавьте хотя бы один шаг'); return; }
|
if (!steps.length) { alert('Добавьте хотя бы один шаг'); return; }
|
||||||
for (let i = 0; i < steps.length; i++) {
|
for (let i = 0; i < steps.length; i++) {
|
||||||
if (!steps[i].service_id) { alert('Шаг ' + (i+1) + ': выберите сервис или инстанс'); return; }
|
if (!steps[i].service_id) { alert('Шаг ' + (i+1) + ': выберите сервис или инстанс'); return; }
|
||||||
if (!steps[i].operation) { alert('Шаг ' + (i+1) + ': выберите операцию'); return; }
|
if (!steps[i].operation) { alert('Шаг ' + (i+1) + ': выберите операцию'); return; }
|
||||||
}
|
}
|
||||||
|
|
||||||
const st = scenarioEditorState;
|
const st = scenarioEditorState;
|
||||||
const isNew = !st.defId;
|
const isNew = !st.defId;
|
||||||
const url = isNew ? '/api/scenario/definitions' : '/api/scenario/definitions/' + st.defId;
|
const url = isNew ? '/api/scenario/definitions' : '/api/scenario/definitions/' + st.defId;
|
||||||
const method = isNew ? 'POST' : 'PUT';
|
const method = isNew ? 'POST' : 'PUT';
|
||||||
|
// Для PUT нужна version (оптимистичная блокировка)
|
||||||
const body = isNew ? { name, steps } : { name, steps, version: st.version };
|
const body = isNew ? { name, steps } : { name, steps, version: st.version };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
const r = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
|
|
||||||
|
// 409 Conflict — кто-то уже изменил
|
||||||
if (r.status === 409) {
|
if (r.status === 409) {
|
||||||
alert('⚠️ Конфликт версий — кто-то уже изменил сценарий. Обновите страницу.');
|
alert('⚠️ Конфликт версий — кто-то уже изменил сценарий. Обновите страницу.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (d.error) { alert(d.error); return; }
|
if (d.error) { alert(d.error); return; }
|
||||||
|
|
||||||
|
// Обновить состояние (id и version могли измениться)
|
||||||
if (isNew && d.id) st.defId = d.id;
|
if (isNew && d.id) st.defId = d.id;
|
||||||
if (d.version) st.version = d.version;
|
if (d.version) st.version = d.version;
|
||||||
|
|
||||||
closeModal();
|
closeModal();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Ошибка сохранения: ' + e.message);
|
alert('Ошибка сохранения: ' + e.message);
|
||||||
|
|||||||
+103
-11
@@ -1,7 +1,19 @@
|
|||||||
// scenario-list.js — список сценариев, запуск, поллинг
|
// scenario-list.js — список сценариев, запуск, поллинг
|
||||||
|
//
|
||||||
|
// Отвечает за:
|
||||||
|
// - Загрузку списка сценариев из GET /api/scenarios
|
||||||
|
// - Раскрытие шагов сценария (GET /api/scenario/definitions/{id})
|
||||||
|
// - Запуск сценария (POST /api/scenario/run)
|
||||||
|
// - Поллинг статуса запуска (GET /api/scenario/run/{run_id})
|
||||||
|
//
|
||||||
|
// Цветовая маркировка: RUNNING → жёлтый фон (#fef3c7)
|
||||||
|
|
||||||
let scenarioOpen=false, scenarioPollTimer=null;
|
let scenarioOpen=false, scenarioPollTimer=null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переключить панель сценариев (открыть/закрыть).
|
||||||
|
* При открытии — загружает список + последний запуск.
|
||||||
|
*/
|
||||||
async function toggleScenario(){
|
async function toggleScenario(){
|
||||||
const body=document.getElementById('scenario-body');
|
const body=document.getElementById('scenario-body');
|
||||||
scenarioOpen=!scenarioOpen;
|
scenarioOpen=!scenarioOpen;
|
||||||
@@ -10,6 +22,13 @@ async function toggleScenario(){
|
|||||||
await loadScenarios();
|
await loadScenarios();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузить список сценариев + последний запуск.
|
||||||
|
*
|
||||||
|
* Два параллельных запроса:
|
||||||
|
* GET /api/scenarios — список определений [{id, name, steps}, ...]
|
||||||
|
* GET /api/scenario/status — последние 10 запусков [{scenario_name, status, ...}, ...]
|
||||||
|
*/
|
||||||
async function loadScenarios(){
|
async function loadScenarios(){
|
||||||
const body=document.getElementById('scenario-body');
|
const body=document.getElementById('scenario-body');
|
||||||
try{
|
try{
|
||||||
@@ -17,18 +36,25 @@ async function loadScenarios(){
|
|||||||
fetch('/api/scenarios').then(r=>r.json()),
|
fetch('/api/scenarios').then(r=>r.json()),
|
||||||
fetch('/api/scenario/status').then(r=>r.json()),
|
fetch('/api/scenario/status').then(r=>r.json()),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let html='';
|
let html='';
|
||||||
|
|
||||||
|
// Кнопка создания нового сценария
|
||||||
html+=`<button class="btn btn-sm" style="font-size:11px;margin-bottom:4px;" onclick="createScenario()">+ Создать сценарий</button>`;
|
html+=`<button class="btn btn-sm" style="font-size:11px;margin-bottom:4px;" onclick="createScenario()">+ Создать сценарий</button>`;
|
||||||
|
|
||||||
if(sr.length){
|
if(sr.length){
|
||||||
|
// Список сценариев — каждый кликабелен
|
||||||
html+='<div style="display:flex;flex-direction:column;gap:4px;margin-bottom:4px;">';
|
html+='<div style="display:flex;flex-direction:column;gap:4px;margin-bottom:4px;">';
|
||||||
sr.forEach(s=>{
|
sr.forEach(s=>{
|
||||||
const seed = s.is_seed;
|
const seed = s.is_seed; // seed-сценарий (из YAML) — нельзя удалить
|
||||||
html+=`<div style="border:1px solid var(--brand-gray);border-radius:4px;padding:4px 6px;">`;
|
html+=`<div style="border:1px solid var(--brand-gray);border-radius:4px;padding:4px 6px;">`;
|
||||||
|
// Заголовок сценария — клик раскрывает шаги
|
||||||
html+=`<div style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:4px 0;" onclick="toggleScenarioSteps(${s.id})">`;
|
html+=`<div style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:4px 0;" onclick="toggleScenarioSteps(${s.id})">`;
|
||||||
html+=`<span style="font-size:13px;font-weight:600;flex:1;">${_esc(s.name)}</span>`;
|
html+=`<span style="font-size:13px;font-weight:600;flex:1;">${_esc(s.name)}</span>`;
|
||||||
html+=`<span style="color:var(--muted);font-size:11px;min-width:50px;">${s.steps} шаг.</span>`;
|
html+=`<span style="color:var(--muted);font-size:11px;min-width:50px;">${s.steps} шаг.</span>`;
|
||||||
if(seed) html+=`<span style="font-size:9px;color:var(--muted);">seed</span>`;
|
if(seed) html+=`<span style="font-size:9px;color:var(--muted);">seed</span>`;
|
||||||
html+=`</div>`;
|
html+=`</div>`;
|
||||||
|
// Контейнер для раскрытых шагов (изначально скрыт)
|
||||||
html+=`<div id="scenario-steps-${s.id}" style="display:none;margin-top:4px;padding-left:8px;"></div>`;
|
html+=`<div id="scenario-steps-${s.id}" style="display:none;margin-top:4px;padding-left:8px;"></div>`;
|
||||||
html+=`</div>`;
|
html+=`</div>`;
|
||||||
});
|
});
|
||||||
@@ -36,31 +62,53 @@ async function loadScenarios(){
|
|||||||
}else{
|
}else{
|
||||||
html+='<span style="color:var(--muted);font-size:11px;">Нет сценариев в БД</span>';
|
html+='<span style="color:var(--muted);font-size:11px;">Нет сценариев в БД</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Последний запуск — фильтруем по текущей версии приложения
|
||||||
const curVer=window.APP&&window.APP.version||'';
|
const curVer=window.APP&&window.APP.version||'';
|
||||||
const verHistory=srHistory&&srHistory.filter(r=>r.app_version===curVer);
|
const verHistory=srHistory&&srHistory.filter(r=>r.app_version===curVer);
|
||||||
if(verHistory && verHistory.length){
|
if(verHistory && verHistory.length){
|
||||||
const last=verHistory[0];
|
const last=verHistory[0];
|
||||||
const icon=last.status==='OK'?'✅':last.status==='FAIL'?'❌':last.status==='RUNNING'?'⏳':'⏱';
|
const icon=last.status==='OK'?'✅':last.status==='FAIL'?'❌':last.status==='RUNNING'?'⏳':'⏱';
|
||||||
const time=last.created_at?last.created_at.replace('T',' ').substring(0,19):'?';
|
const time=last.created_at?last.created_at.replace('T',' ').substring(0,19):'?';
|
||||||
|
// RUNNING → жёлтый фон
|
||||||
const runningStyle = last.status==='RUNNING' ? 'background:#fef3c7;padding:2px 4px;border-radius:3px;' : '';
|
const runningStyle = last.status==='RUNNING' ? 'background:#fef3c7;padding:2px 4px;border-radius:3px;' : '';
|
||||||
html+=`<div style="font-size:11px;margin-top:4px;color:var(--muted);${runningStyle}">Последний: ${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>`;
|
html+=`<div style="font-size:11px;margin-top:4px;color:var(--muted);${runningStyle}">Последний: ${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>`;
|
if(last.error_log) html+=`<div style="font-size:10px;color:var(--destructive);">${_esc(last.error_log)}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.innerHTML=html;
|
body.innerHTML=html;
|
||||||
}catch(e){body.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';}
|
}catch(e){
|
||||||
|
body.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Раскрыть шаги сценария — загрузить полное определение.
|
||||||
|
*
|
||||||
|
* Показывает:
|
||||||
|
* - Список шагов: операция → сервис + параметры
|
||||||
|
* - Кнопки: ▶ Запустить, ✏ Редактировать, 📋 Копировать, 🗑 Удалить
|
||||||
|
*
|
||||||
|
* Закрывает другие раскрытые сценарии (только один открыт одновременно).
|
||||||
|
*
|
||||||
|
* @param {number} defId — ID сценария
|
||||||
|
*/
|
||||||
async function toggleScenarioSteps(defId) {
|
async function toggleScenarioSteps(defId) {
|
||||||
const el = document.getElementById('scenario-steps-' + defId);
|
const el = document.getElementById('scenario-steps-' + defId);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
|
// Если уже открыт — закрыть (toggle)
|
||||||
if (el.style.display === 'block') { el.style.display = 'none'; return; }
|
if (el.style.display === 'block') { el.style.display = 'none'; return; }
|
||||||
// Загрузить шаги
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/scenario/definitions/' + defId);
|
const r = await fetch('/api/scenario/definitions/' + defId);
|
||||||
const def = await r.json();
|
const def = await r.json();
|
||||||
if (def.error) return;
|
if (def.error) return;
|
||||||
|
|
||||||
const steps = def.steps || [];
|
const steps = def.steps || [];
|
||||||
let html = '<div style="font-size:11px;padding:4px 0;">';
|
let html = '<div style="font-size:11px;padding:4px 0;">';
|
||||||
|
|
||||||
|
// Перечисляем шаги
|
||||||
steps.forEach((s, i) => {
|
steps.forEach((s, i) => {
|
||||||
const svcName = s.service_id ? 'сервис ' + s.service_id : '?';
|
const svcName = s.service_id ? 'сервис ' + s.service_id : '?';
|
||||||
html += `<div style="padding:2px 0;">${i+1}. <b>${_esc(s.operation)}</b> → ${svcName}`;
|
html += `<div style="padding:2px 0;">${i+1}. <b>${_esc(s.operation)}</b> → ${svcName}`;
|
||||||
@@ -71,48 +119,89 @@ async function toggleScenarioSteps(defId) {
|
|||||||
if (params.length) html += ' <span style="color:var(--muted);">(' + params.map(([k,v]) => k+'='+v).join(', ') + ')</span>';
|
if (params.length) html += ' <span style="color:var(--muted);">(' + params.map(([k,v]) => k+'='+v).join(', ') + ')</span>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Кнопки действий
|
||||||
html += `<button class="btn btn-sm" style="margin-top:4px;font-size:11px;background:var(--brand-primary);color:#fff;" onclick="event.stopPropagation();runScenario(${defId})">▶ Запустить</button>`;
|
html += `<button class="btn btn-sm" style="margin-top:4px;font-size:11px;background:var(--brand-primary);color:#fff;" onclick="event.stopPropagation();runScenario(${defId})">▶ Запустить</button>`;
|
||||||
html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();editScenario(${defId})">✏ Редактировать</button>`;
|
html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();editScenario(${defId})">✏ Редактировать</button>`;
|
||||||
html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();cloneScenario(${defId},'${_esc(def.name)}')">📋 Копировать</button>`;
|
html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();cloneScenario(${defId},'${_esc(def.name)}')">📋 Копировать</button>`;
|
||||||
|
// Удалить — только для НЕ-seed сценариев
|
||||||
if (!def.is_seed) html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;color:var(--destructive);" onclick="event.stopPropagation();deleteScenario(${defId},'${_esc(def.name)}')">🗑 Удалить</button>`;
|
if (!def.is_seed) html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;color:var(--destructive);" onclick="event.stopPropagation();deleteScenario(${defId},'${_esc(def.name)}')">🗑 Удалить</button>`;
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
el.style.display = 'block';
|
el.style.display = 'block';
|
||||||
// Закрыть другие
|
|
||||||
document.querySelectorAll('[id^="scenario-steps-"]').forEach(e => { if (e !== el) e.style.display = 'none'; });
|
// Закрыть другие раскрытые сценарии
|
||||||
|
document.querySelectorAll('[id^="scenario-steps-"]').forEach(e => {
|
||||||
|
if (e !== el) e.style.display = 'none';
|
||||||
|
});
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запустить сценарий.
|
||||||
|
*
|
||||||
|
* Алгоритм:
|
||||||
|
* 1. confirm — подтверждение
|
||||||
|
* 2. POST /api/scenario/run → {status: "RUNNING", run_id}
|
||||||
|
* 3. Поллинг каждые 3 секунды:
|
||||||
|
* GET /api/scenario/run/{run_id} → {status, current_step, total_steps, ...}
|
||||||
|
* 4. При OK/FAIL → остановить поллинг, обновить инстансы и историю
|
||||||
|
*
|
||||||
|
* @param {number} defId — ID определения сценария
|
||||||
|
*/
|
||||||
async function runScenario(defId){
|
async function runScenario(defId){
|
||||||
if(busy){ return; }
|
if(busy){ return; } // другая операция уже идёт
|
||||||
if(!confirm('Запустить сценарий?')) return;
|
if(!confirm('Запустить сценарий?')) return;
|
||||||
|
|
||||||
busy=true;
|
busy=true;
|
||||||
stopScenarioPoll();
|
stopScenarioPoll();
|
||||||
|
|
||||||
const body=document.getElementById('scenario-body');
|
const body=document.getElementById('scenario-body');
|
||||||
body.innerHTML=`<div style="font-size:11px;">⏳ Запуск...</div>`;
|
body.innerHTML=`<div style="font-size:11px;">⏳ Запуск...</div>`;
|
||||||
|
|
||||||
try{
|
try{
|
||||||
const r=await fetch('/api/scenario/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({definition_id:defId})});
|
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();
|
const d=await r.json();
|
||||||
if(d.error){ body.innerHTML=`<span style="color:var(--destructive);">Ошибка: ${_esc(d.error)}</span>`; busy=false; return; }
|
|
||||||
|
if(d.error){
|
||||||
|
body.innerHTML=`<span style="color:var(--destructive);">Ошибка: ${_esc(d.error)}</span>`;
|
||||||
|
busy=false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const runId=d.run_id;
|
const runId=d.run_id;
|
||||||
if(!runId){ body.innerHTML=`<span style="color:var(--destructive);">Ошибка: нет run_id в ответе</span>`; busy=false; return; }
|
if(!runId){
|
||||||
|
body.innerHTML=`<span style="color:var(--destructive);">Ошибка: нет run_id в ответе</span>`;
|
||||||
|
busy=false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поллинг статуса
|
||||||
scenarioPollTimer=setInterval(async()=>{
|
scenarioPollTimer=setInterval(async()=>{
|
||||||
try{
|
try{
|
||||||
const sr=await fetch('/api/scenario/run/'+runId);
|
const sr=await fetch('/api/scenario/run/'+runId);
|
||||||
if(!sr.ok){ return; }
|
if(!sr.ok){ return; }
|
||||||
const last=await sr.json();
|
const last=await sr.json();
|
||||||
if(!last||last.error) return;
|
if(!last||last.error) return;
|
||||||
|
|
||||||
const icon=last.status==='OK'?'✅':last.status==='FAIL'?'❌':last.status==='RUNNING'?'⏳':'⏱';
|
const icon=last.status==='OK'?'✅':last.status==='FAIL'?'❌':last.status==='RUNNING'?'⏳':'⏱';
|
||||||
const time=last.created_at?last.created_at.replace('T',' ').substring(0,19):'?';
|
const time=last.created_at?last.created_at.replace('T',' ').substring(0,19):'?';
|
||||||
const runningBg = last.status==='RUNNING' ? 'background:#fef3c7;padding:2px 4px;border-radius:3px;' : '';
|
const runningBg = last.status==='RUNNING' ? 'background:#fef3c7;padding:2px 4px;border-radius:3px;' : '';
|
||||||
|
|
||||||
let html=`<div style="font-size:11px;${runningBg}">${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>`;
|
let html=`<div style="font-size:11px;${runningBg}">${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>`;
|
if(last.error_log) html+=`<div style="font-size:10px;color:var(--destructive);">${_esc(last.error_log)}</div>`;
|
||||||
body.innerHTML=html;
|
body.innerHTML=html;
|
||||||
|
|
||||||
if(last.status!=='RUNNING'){
|
if(last.status!=='RUNNING'){
|
||||||
stopScenarioPoll();
|
stopScenarioPoll();
|
||||||
busy=false;
|
busy=false;
|
||||||
await refreshInstances();
|
await refreshInstances(); // обновить список инстансов
|
||||||
if(historyOpen) await loadHistory();
|
if(historyOpen) await loadHistory(); // обновить историю
|
||||||
}
|
}
|
||||||
}catch(e){}
|
}catch(e){}
|
||||||
},3000);
|
},3000);
|
||||||
@@ -122,6 +211,9 @@ async function runScenario(defId){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Остановить поллинг сценария.
|
||||||
|
*/
|
||||||
function stopScenarioPoll(){
|
function stopScenarioPoll(){
|
||||||
if(scenarioPollTimer){ clearInterval(scenarioPollTimer); scenarioPollTimer=null; }
|
if(scenarioPollTimer){ clearInterval(scenarioPollTimer); scenarioPollTimer=null; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,47 @@
|
|||||||
// snackbar.js — лёгкие уведомления (без зависимостей)
|
// snackbar.js — лёгкие toast-уведомления (без зависимостей)
|
||||||
// showSnackbar(msg, type='info') — type: info, success, error
|
//
|
||||||
|
// Использование: showSnackbar('Сообщение', 'success')
|
||||||
|
// Типы: 'info' (по умолчанию), 'success', 'error'
|
||||||
|
//
|
||||||
|
// Поведение:
|
||||||
|
// - Показывается в правом нижнем углу (position: fixed)
|
||||||
|
// - Автоматически исчезает: info/success через 4с, error через 8с
|
||||||
|
// - Клик по уведомлению — мгновенное закрытие
|
||||||
|
// - Максимум 4 уведомления одновременно (старые удаляются)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Показать snackbar-уведомление.
|
||||||
|
*
|
||||||
|
* @param {string} msg — текст уведомления
|
||||||
|
* @param {string} type — 'info' | 'success' | 'error' (по умолчанию 'info')
|
||||||
|
*/
|
||||||
function showSnackbar(msg, type) {
|
function showSnackbar(msg, type) {
|
||||||
type = type || 'info';
|
type = type || 'info';
|
||||||
|
|
||||||
|
// Найти или создать контейнер для всех уведомлений
|
||||||
let container = document.getElementById('snackbar-container');
|
let container = document.getElementById('snackbar-container');
|
||||||
if (!container) {
|
if (!container) {
|
||||||
container = document.createElement('div');
|
container = document.createElement('div');
|
||||||
container.id = 'snackbar-container';
|
container.id = 'snackbar-container';
|
||||||
container.className = 'snackbar-container';
|
container.className = 'snackbar-container';
|
||||||
container.setAttribute('role', 'status');
|
container.setAttribute('role', 'status'); // accessibility
|
||||||
container.setAttribute('aria-live', 'polite');
|
container.setAttribute('aria-live', 'polite'); // screen-reader зачитает
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Создать элемент уведомления
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'snackbar ' + type;
|
el.className = 'snackbar ' + type;
|
||||||
el.textContent = msg;
|
el.textContent = msg;
|
||||||
|
// Клик — закрыть
|
||||||
el.onclick = function() { el.remove(); };
|
el.onclick = function() { el.remove(); };
|
||||||
container.appendChild(el);
|
container.appendChild(el);
|
||||||
|
|
||||||
// Limit stacking
|
// Ограничение: максимум 4 уведомления
|
||||||
const all = container.querySelectorAll('.snackbar');
|
const all = container.querySelectorAll('.snackbar');
|
||||||
if (all.length > 4) all[0].remove();
|
if (all.length > 4) all[0].remove(); // удаляем самое старое
|
||||||
|
|
||||||
// Auto-hide: info/success 4s, error 8s
|
// Автоматическое скрытие: error — 8 секунд, остальные — 4
|
||||||
const delay = (type === 'error') ? 8000 : 4000;
|
const delay = (type === 'error') ? 8000 : 4000;
|
||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
if (el.parentNode) el.remove();
|
if (el.parentNode) el.remove();
|
||||||
|
|||||||
+58
-7
@@ -1,42 +1,86 @@
|
|||||||
// utils.js — общие хелперы: _esc, busy lock, validateJson, лог-панель
|
// utils.js — общие хелперы фронтенда
|
||||||
|
// Используется ВСЕМИ модулями (должен грузиться ПЕРВЫМ).
|
||||||
|
// Экспортирует (в глобальную область): _esc, validateJson, relativeTime,
|
||||||
|
// startLogPoll, toggleLog
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTML-экранирование — защита от XSS.
|
||||||
|
* Все данные из API/БД пропускаются через _esc() перед вставкой в innerHTML.
|
||||||
|
*
|
||||||
|
* @param {*} s — что угодно (приводится к строке через String())
|
||||||
|
* @returns {string} безопасная для HTML строка
|
||||||
|
*
|
||||||
|
* Заменяет:
|
||||||
|
* & → & (первым! иначе сломает уже заменённые сущности)
|
||||||
|
* " → " (атрибуты в кавычках)
|
||||||
|
* < → < (XSS-вектор: <script>)
|
||||||
|
*
|
||||||
|
* > и ' НЕ экранируем — в нашем контексте они безопасны,
|
||||||
|
* а лишние замены портят читаемость.
|
||||||
|
*/
|
||||||
function _esc(s){
|
function _esc(s){
|
||||||
/* HTML-экранирование: " → " & → & < → < */
|
|
||||||
return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<');
|
return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверить что значение в поле — валидный JSON.
|
||||||
|
* Используется для map-параметров перед отправкой формы.
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} el — input/textarea элемент
|
||||||
|
* @param {boolean} quiet — true = не менять внешний вид (batch-проверка)
|
||||||
|
* @returns {boolean} true если JSON валидный или поле пустое
|
||||||
|
*/
|
||||||
function validateJson(el,quiet){
|
function validateJson(el,quiet){
|
||||||
/* Проверить что значение в поле — валидный JSON.
|
// Ищем соседний span.json-err для показа ошибки
|
||||||
quiet=true — не менять внешний вид (для batch-проверки перед отправкой). */
|
|
||||||
const errEl=el.parentElement.querySelector('.json-err');
|
const errEl=el.parentElement.querySelector('.json-err');
|
||||||
const v=el.value.trim();
|
const v=el.value.trim();
|
||||||
|
// Пустое поле — ок (не ошибка)
|
||||||
if(!v) return true;
|
if(!v) return true;
|
||||||
try{ JSON.parse(v); }
|
try{ JSON.parse(v); }
|
||||||
catch(e){
|
catch(e){
|
||||||
|
// Показываем ошибку в span.json-err (если не quiet)
|
||||||
if(!quiet&&errEl){ errEl.style.display='inline'; errEl.textContent='Ошибка JSON: '+e.message; }
|
if(!quiet&&errEl){ errEl.style.display='inline'; errEl.textContent='Ошибка JSON: '+e.message; }
|
||||||
el.style.borderColor='var(--destructive)';
|
el.style.borderColor='var(--destructive)';
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Всё ок — убираем ошибку
|
||||||
if(!quiet&&errEl) errEl.style.display='none';
|
if(!quiet&&errEl) errEl.style.display='none';
|
||||||
el.style.borderColor='';
|
el.style.borderColor='';
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Панель логов ===
|
// ═══════════════════════════════════════════════════════
|
||||||
|
// Панель логов — поллинг /api/log каждые 2 секунды
|
||||||
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
||||||
let logPollTimer=null;
|
let logPollTimer=null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запустить поллинг логов.
|
||||||
|
* Работает только если элемент #log-panel существует и видим.
|
||||||
|
* Безопасен для повторного вызова — не дублирует таймер.
|
||||||
|
*/
|
||||||
function startLogPoll(){
|
function startLogPoll(){
|
||||||
if(logPollTimer) return;
|
if(logPollTimer) return; // уже запущен
|
||||||
logPollTimer=setInterval(async()=>{
|
logPollTimer=setInterval(async()=>{
|
||||||
const el=document.getElementById('log-panel');
|
const el=document.getElementById('log-panel');
|
||||||
|
// Если панель скрыта — не грузим (экономия трафика)
|
||||||
if(!el||el.style.display==='none') return;
|
if(!el||el.style.display==='none') return;
|
||||||
try{
|
try{
|
||||||
const r=await fetch('/api/log');
|
const r=await fetch('/api/log');
|
||||||
const lines=await r.json();
|
const lines=await r.json();
|
||||||
|
// Каждая строка логирования в отдельном div, экранирована
|
||||||
el.innerHTML=lines.map(l=>`<div>${_esc(l)}</div>`).join('');
|
el.innerHTML=lines.map(l=>`<div>${_esc(l)}</div>`).join('');
|
||||||
|
// Автопрокрутка вниз (следим за новыми записями)
|
||||||
el.scrollTop=el.scrollHeight;
|
el.scrollTop=el.scrollHeight;
|
||||||
}catch(e){}
|
}catch(e){}
|
||||||
},2000);
|
},2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переключить видимость лог-панели.
|
||||||
|
* При открытии — запускает поллинг.
|
||||||
|
*/
|
||||||
function toggleLog(){
|
function toggleLog(){
|
||||||
const el=document.getElementById('log-panel');
|
const el=document.getElementById('log-panel');
|
||||||
if(!el) return;
|
if(!el) return;
|
||||||
@@ -44,12 +88,19 @@ function toggleLog(){
|
|||||||
else el.style.display='none';
|
else el.style.display='none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Относительное время — "5м", "2ч 30м", "1д 3ч".
|
||||||
|
* Используется в списке инстансов (instances.js) и истории (history.js).
|
||||||
|
*
|
||||||
|
* @param {string} iso — ISO-8601 дата (напр. "2026-07-31T12:00:00")
|
||||||
|
* @returns {string} относительное время или "-" если невалидно
|
||||||
|
*/
|
||||||
function relativeTime(iso) {
|
function relativeTime(iso) {
|
||||||
if (!iso) return '-';
|
if (!iso) return '-';
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (isNaN(d.getTime())) return '-';
|
if (isNaN(d.getTime())) return '-';
|
||||||
const sec = Math.floor((Date.now() - d.getTime()) / 1000);
|
const sec = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||||
if (sec < 0) return 'только что';
|
if (sec < 0) return 'только что'; // будущее (расхождение часов)
|
||||||
if (sec < 60) return sec + 'с';
|
if (sec < 60) return sec + 'с';
|
||||||
const min = Math.floor(sec / 60);
|
const min = Math.floor(sec / 60);
|
||||||
if (min < 60) return min + 'м';
|
if (min < 60) return min + 'м';
|
||||||
|
|||||||
+33
-6
@@ -1,30 +1,57 @@
|
|||||||
// views.js — переключение видов (sidebar navigation)
|
// views.js — переключение видов (Консоль / Обзор / История / Сценарии / Логи)
|
||||||
|
//
|
||||||
|
// Каждый вид — div.view с id="view-{name}".
|
||||||
|
// Активный вид — без класса .hidden.
|
||||||
|
// Все остальные — с классом .hidden (display: none).
|
||||||
|
//
|
||||||
|
// Переключение:
|
||||||
|
// switchView('overview') — Обзор (инфраструктура)
|
||||||
|
// switchView('console') — Консоль (ручной режим)
|
||||||
|
// switchView('history-view') — История (автозагрузка при открытии)
|
||||||
|
// switchView('scenarios-view') — Сценарии (автозагрузка)
|
||||||
|
// switchView('logs-view') — Логи (специальная обработка)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переключиться на вид по его ID.
|
||||||
|
*
|
||||||
|
* @param {string} viewId — суффикс после "view-" (напр. "console" → #view-console)
|
||||||
|
*/
|
||||||
function switchView(viewId) {
|
function switchView(viewId) {
|
||||||
|
// Скрыть ВСЕ виды
|
||||||
document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
|
document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
|
||||||
|
|
||||||
|
// Показать целевой вид
|
||||||
const target = document.getElementById('view-' + viewId);
|
const target = document.getElementById('view-' + viewId);
|
||||||
if (target) target.classList.remove('hidden');
|
if (target) target.classList.remove('hidden');
|
||||||
// Подсветка активного пункта
|
|
||||||
|
// Подсветка активного пункта в sidebar
|
||||||
document.querySelectorAll('.sidebar-item').forEach(el => el.classList.remove('active'));
|
document.querySelectorAll('.sidebar-item').forEach(el => el.classList.remove('active'));
|
||||||
const btn = document.querySelector('.sidebar-item[data-view="' + viewId + '"]');
|
const btn = document.querySelector('.sidebar-item[data-view="' + viewId + '"]');
|
||||||
if (btn) btn.classList.add('active');
|
if (btn) btn.classList.add('active');
|
||||||
// Автозагрузка при открытии вида
|
|
||||||
|
// ── Автозагрузка при открытии вида ──
|
||||||
|
|
||||||
|
// История: автозагрузка только если ещё не загружена
|
||||||
if (viewId === 'history-view' && typeof toggleHistory === 'function') {
|
if (viewId === 'history-view' && typeof toggleHistory === 'function') {
|
||||||
if (!historyOpen) toggleHistory();
|
if (!historyOpen) toggleHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сценарии: автозагрузка
|
||||||
if (viewId === 'scenarios-view' && typeof toggleScenario === 'function') {
|
if (viewId === 'scenarios-view' && typeof toggleScenario === 'function') {
|
||||||
if (!scenarioOpen) toggleScenario();
|
if (!scenarioOpen) toggleScenario();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Логи: специальная обработка — копируем содержимое нижней панели в вид
|
||||||
if (viewId === 'logs-view') {
|
if (viewId === 'logs-view') {
|
||||||
// Показать лог в виде, скрыть нижнюю панель
|
|
||||||
const lp = document.getElementById('log-panel');
|
const lp = document.getElementById('log-panel');
|
||||||
const lvc = document.getElementById('log-view-content');
|
const lvc = document.getElementById('log-view-content');
|
||||||
if (lp && lvc) {
|
if (lp && lvc) {
|
||||||
|
// Копируем текущее содержимое лог-панели в вид
|
||||||
lvc.innerHTML = lp.innerHTML || '<span style="color:#888;">Логи загружаются...</span>';
|
lvc.innerHTML = lp.innerHTML || '<span style="color:#888;">Логи загружаются...</span>';
|
||||||
lp.style.display = 'none';
|
lp.style.display = 'none'; // скрываем нижнюю панель
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Скрыть нижнюю панель на всех видах кроме логов
|
// На всех видах кроме логов — скрываем нижнюю лог-панель
|
||||||
const lp = document.getElementById('log-panel');
|
const lp = document.getElementById('log-panel');
|
||||||
if (lp) lp.style.display = 'none';
|
if (lp) lp.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user