Подробные комментарии ко всем 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
+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);