Подробные комментарии ко всем JS-файлам фронтенда

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