349 lines
16 KiB
JavaScript
349 lines
16 KiB
JavaScript
// 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 =
|
||
'<span style="color:var(--destructive);">У выбранного сервиса нет операции create</span>';
|
||
return;
|
||
}
|
||
showParams(createOp.svcOperationId, 'create');
|
||
} catch (e) {
|
||
document.getElementById('test-status').innerHTML =
|
||
`<span style="color:var(--destructive);">Ошибка загрузки операций: ${_esc(e.message || '')}</span>`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Запустить операцию над выбранным инстансом (кнопка 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;
|
||
}
|
||
const params = data.params || data;
|
||
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) { }
|
||
}
|
||
|
||
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>`
|
||
: '';
|
||
|
||
// Форма: заголовок + 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);
|
||
});
|
||
if (bad.length > 0) {
|
||
document.getElementById('test-status').innerHTML =
|
||
`<span class="badge">Ошибка</span> ${bad.length} полей с невалидным JSON — поправьте и нажмите Запустить снова`;
|
||
return;
|
||
}
|
||
const pp = collectParams(); // собрать значения из формы
|
||
executeOp(pp); // отправить
|
||
};
|
||
}).catch(e => {
|
||
document.getElementById('params-form').innerHTML =
|
||
`<span style="color:var(--destructive);">Ошибка загрузки: ${_esc(e.message)}</span>`;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Установить состояние «завершено» — показать результат операции.
|
||
*
|
||
* @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 =
|
||
` <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; // заблокировать 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 =
|
||
` <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);
|
||
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 = '<div style="font-size:11px;font-weight:600;color:var(--muted);margin-top:4px;">Этапы</div>';
|
||
stages.forEach(s => {
|
||
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;
|
||
}
|