// 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}. * Числовой ID не зашивается в UI: он берётся из каталога выбранного сервиса. */ async function startCreate() { if (busy) return; 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(); if (!r.ok || d.error) throw new Error(d.error || 'Не удалось загрузить операции сервиса'); const createOp = (Array.isArray(d.operations) ? d.operations : []).find(o => o.operation === 'create'); if (!createOp) { document.getElementById('test-status').innerHTML = 'У выбранного сервиса нет операции create'; return; } showParams(createOp.svcOperationId, 'create'); } catch (e) { document.getElementById('test-status').innerHTML = `Ошибка загрузки операций: ${_esc(e.message || '')}`; } } /** * Запустить операцию над выбранным инстансом (кнопка 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 = `Ошибка: ${_esc(data.error)}`; return; } const params = data.params || data; if (!Array.isArray(params)) { document.getElementById('params-form').innerHTML = `Неверный формат параметров`; 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) { } } const form = document.getElementById('params-form'); const displayNameValue = isCreate ? makeCreateDisplayName() : ''; // Заголовок формы const createHeader = isCreate ? `
Создание: ${_esc(currentSvcName)}
` : ''; const opHeader = !isCreate ? `
${_esc(opName)} → ${_esc(findInstName())}
` : ''; // Форма: заголовок + displayName (только create) + параметры form.innerHTML = createHeader + opHeader + (opName === 'create' ? `
${AUTOTEST_PREFIX}
` : '') + 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); }); if (bad.length > 0) { document.getElementById('test-status').innerHTML = `Ошибка ${bad.length} полей с невалидным JSON — поправьте и нажмите Запустить снова`; return; } const pp = collectParams(); // собрать значения из формы executeOp(pp); // отправить }; }).catch(e => { document.getElementById('params-form').innerHTML = `Ошибка загрузки: ${_esc(e.message)}`; }); } /** * Установить состояние «завершено» — показать результат операции. * * @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; // разблокировать 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; // убрать обработчик 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 = ` ${statusText} ${opName} ${durationText} ${instName} ${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; // заблокировать 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(); // Подготовка UI document.getElementById('params-area').style.display = 'block'; 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()); const stagesBox = document.createElement('div'); stagesBox.className = 'stages-box'; 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 { // 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(); // FAIL на старте — нечего поллить if (d.status === 'FAIL') { busy = false; document.getElementById('test-status').innerHTML = ` FAIL ${_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); try { await refreshInstances(); } catch (e) { } 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 || []); // обновить этапы 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(); // обновить историю } } catch (e) { _pollErrors++; // 5 ошибок подряд (10 секунд) → считаем связь потерянной if (_pollErrors >= 5) { stopPoll(); setFinishedState('TIMEOUT', '', 'Связь потеряна', 0, instanceName); } } }, 2000); } catch (e) { busy = false; document.getElementById('test-status').textContent = ' Ошибка: ' + e.message; btn.disabled = false; } } /** * Показать этапы выполнения операции (stages из API). * * Каждый этап: иконка (✅/❌/⏳) + название + длительность. * * @param {Array} stages — [{stage, dtStart, dtFinish, isSuccessful, duration}, ...] */ function showStages(stages) { if (!stages || !stages.length) return; let html = '
Этапы
'; stages.forEach(s => { const done = !!s.dtFinish; // этап завершён? const icon = done ? (s.isSuccessful ? '✅' : '❌') : '⏳'; html += `
${icon} ${_esc(s.stage)} — ${(s.duration || 0).toFixed(1)}s
`; }); // Показать в ПОСЛЕДНЕМ контейнере этапов (самом новом) const boxes = document.querySelectorAll('.stages-box'); if (boxes.length) boxes[boxes.length - 1].innerHTML = html; }