fix: adapt autotest to updated service APIs

This commit is contained in:
Repinoid
2026-10-25 16:30:25 +03:00
parent 8d956c8063
commit 4203e5674c
13 changed files with 462 additions and 292 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ from routes.api_scenario_defs import bp_defs as api_scenario_defs_bp
# Версия — показывается в топбаре UI. Меняется при КАЖДОМ изменении кода. # Версия — показывается в топбаре UI. Меняется при КАЖДОМ изменении кода.
# Нужна для фильтрации истории (пользователь видит только записи своей версии). # Нужна для фильтрации истории (пользователь видит только записи своей версии).
VERSION = "1.2.47" VERSION = "1.2.48"
# Flask-приложение с Jinja2-шаблонами из папки templates/ # Flask-приложение с Jinja2-шаблонами из папки templates/
app = Flask(__name__, template_folder="templates", static_folder="static") app = Flask(__name__, template_folder="templates", static_folder="static")
+1 -1
View File
@@ -13,7 +13,7 @@
# 24 vcNat # DEPRECATED Правила маршрутизации для VM (vc_nat) # 24 vcNat # DEPRECATED Правила маршрутизации для VM (vc_nat)
25 vcexternalip # Публичные IP адреса 25 vcexternalip # Публичные IP адреса
26 vapp # Виртуальный каталог ВМ (vApp) 26 vapp # Виртуальный каталог ВМ (vApp)
27 vc_vm_v2 # VM в Cloud Director (vc_vmV2) # 27 vc_vm_v2 # отсутствует в текущем Polygon DEV
28 vc_vm_v3 # Виртуальная машина 28 vc_vm_v3 # Виртуальная машина
29 vcVdcGroup # Группа датацентров 29 vcVdcGroup # Группа датацентров
# 32 vmpostgre # vc_vm_postgresql_std # 32 vmpostgre # vc_vm_postgresql_std
+1 -1
View File
@@ -13,7 +13,7 @@
# 24 vcNat # DEPRECATED Правила маршрутизации для VM (vc_nat) # 24 vcNat # DEPRECATED Правила маршрутизации для VM (vc_nat)
25 vcexternalip # Публичные IP адреса 25 vcexternalip # Публичные IP адреса
26 vapp # Виртуальный каталог ВМ (vApp) 26 vapp # Виртуальный каталог ВМ (vApp)
27 vc_vm_v2 # VM в Cloud Director (vc_vmV2) # 27 vc_vm_v2 # отсутствует в текущем Polygon TEST
28 vc_vm_v3 # Виртуальная машина 28 vc_vm_v3 # Виртуальная машина
29 vcVdcGroup # Группа датацентров 29 vcVdcGroup # Группа датацентров
# 32 vmpostgre # vc_vm_postgresql_std # 32 vmpostgre # vc_vm_postgresql_std
+2 -2
View File
@@ -31,7 +31,7 @@ import json
import fcntl import fcntl
from api.http_client import HttpClient, detect_endpoint, stand_name from api.http_client import HttpClient, detect_endpoint, stand_name
from api.auth import get_token, get_client, get_client_id, get_stand, get_token_info from api.auth import get_token, get_client, get_client_id, get_stand, get_token_info, get_mode
from api.utils import find_uid, uid_from_location from api.utils import find_uid, uid_from_location
from operations.get_services import get_services, get_service_detail from operations.get_services import get_services, get_service_detail
from operations.get_instances import get_instances from operations.get_instances import get_instances
@@ -217,7 +217,7 @@ def api_test():
if not instance_uid: if not instance_uid:
return jsonify({"status": "FAIL", "error": "Нет instanceUid"}), 400 return jsonify({"status": "FAIL", "error": "Нет instanceUid"}), 400
if op_name == "delete": if op_name == "delete" and get_mode() == "cloud":
# Жёсткое удаление через CMDB (без авторизации) — для недосозданных инстансов # Жёсткое удаление через CMDB (без авторизации) — для недосозданных инстансов
cmdb_url = f"https://cmdb-api.deck.nubes.ru/instances/{instance_uid}" cmdb_url = f"https://cmdb-api.deck.nubes.ru/instances/{instance_uid}"
cmdb_ok, cmdb_code = client.raw_delete(cmdb_url) cmdb_ok, cmdb_code = client.raw_delete(cmdb_url)
+2 -2
View File
@@ -223,11 +223,11 @@ def api_operations(svc_id):
# Сортировка: по имени сервиса, затем по дате создания (новые сверху) # Сортировка: по имени сервиса, затем по дате создания (новые сверху)
svc_instances.sort(key=lambda i: ( svc_instances.sort(key=lambda i: (
i.get("svc", ""), i.get("svc", ""),
i.get("instanceConfigDtCreated") or "" i.get("instanceConfigDtCreated") or i.get("dtCreate") or ""
), reverse=True) ), reverse=True)
# reverse=True даёт обратный порядок по обоим ключам. # reverse=True даёт обратный порядок по обоим ключам.
# Чтобы сервисы шли A→Я а даты новые→старые — пересортируем: # Чтобы сервисы шли A→Я а даты новые→старые — пересортируем:
svc_instances.sort(key=lambda i: i.get("instanceConfigDtCreated") or "", reverse=True) svc_instances.sort(key=lambda i: i.get("instanceConfigDtCreated") or i.get("dtCreate") or "", reverse=True)
svc_instances.sort(key=lambda i: i.get("svc", "")) svc_instances.sort(key=lambda i: i.get("svc", ""))
# --- Шаг 2: Tracker-fallback --- # --- Шаг 2: Tracker-fallback ---
+71 -70
View File
@@ -16,12 +16,12 @@
// busy — флаг блокировки UI (пока идёт операция) // busy — флаг блокировки UI (пока идёт операция)
// AUTOTEST_PREFIX — "autotest-" (фильтр "наших" инстансов) // AUTOTEST_PREFIX — "autotest-" (фильтр "наших" инстансов)
let svcInstances=[], selectedInst=null, selectedOp=null; let svcInstances = [], selectedInst = null, selectedOp = null;
let currentSvcId=(window.APP&&window.APP.firstServiceId)||1; let currentSvcId = (window.APP && window.APP.firstServiceId) || 1;
let currentSvcName=''; let currentSvcName = '';
let currentSvcShort=''; let currentSvcShort = '';
let busy=false; let busy = false;
const AUTOTEST_PREFIX='autotest-'; const AUTOTEST_PREFIX = 'autotest-';
/** /**
* Выбрать сервис — загрузить его инстансы и показать в списке. * Выбрать сервис — загрузить его инстансы и показать в списке.
@@ -29,37 +29,37 @@ const AUTOTEST_PREFIX='autotest-';
* *
* @param {number} svcId — ID сервиса * @param {number} svcId — ID сервиса
*/ */
async function selectService(svcId){ async function selectService(svcId) {
if(busy) return; // блокировка — идёт операция if (busy) return; // блокировка — идёт операция
// Сброс предыдущего состояния // Сброс предыдущего состояния
currentSvcId=svcId; currentSvcId = svcId;
selectedInst=null; selectedInst = null;
selectedOp=null; selectedOp = null;
stopPoll(); // остановить поллинг предыдущей операции stopPoll(); // остановить поллинг предыдущей операции
// Скрыть панель параметров и этапы // Скрыть панель параметров и этапы
document.getElementById('params-area').style.display='none'; document.getElementById('params-area').style.display = 'none';
document.querySelectorAll('.stages-box').forEach(e=>e.remove()); document.querySelectorAll('.stages-box').forEach(e => e.remove());
// Подсветка активного сервиса в списке // Подсветка активного сервиса в списке
document.querySelectorAll('.svc-item').forEach(e=>e.classList.remove('active')); document.querySelectorAll('.svc-item').forEach(e => e.classList.remove('active'));
const svcEl=document.querySelector(`.svc-item[onclick*="${svcId}"]`); const svcEl = document.querySelector(`.svc-item[onclick*="${svcId}"]`);
if(svcEl) svcEl.classList.add('active'); if (svcEl) svcEl.classList.add('active');
try{ try {
// GET /api/operations/{svcId} — возвращает операции + autotest-инстансы // GET /api/operations/{svcId} — возвращает операции + autotest-инстансы
const r=await fetch('/api/operations/'+svcId); const r = await fetch('/api/operations/' + svcId);
const d=await r.json(); const d = await r.json();
svcInstances=d.instances||[]; svcInstances = d.instances || [];
currentSvcName=d.svc||''; currentSvcName = d.svc || '';
currentSvcShort=d.svcShort||''; currentSvcShort = d.svcShort || '';
// Обновить текст кнопки «Создать» // Обновить текст кнопки «Создать»
const btnSpan=document.querySelector('#create-btn-area span'); const btnSpan = document.querySelector('#create-btn-area span');
if(btnSpan) btnSpan.textContent='+ Создать новый инстанс '+(currentSvcName||'сервиса'); if (btnSpan) btnSpan.textContent = '+ Создать новый инстанс ' + (currentSvcName || 'сервиса');
renderInstances(); // отрисовать список renderInstances(); // отрисовать список
}catch(e){ } catch (e) {
document.getElementById('inst-list').innerHTML= document.getElementById('inst-list').innerHTML =
'<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>'; '<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';
} }
} }
@@ -68,37 +68,38 @@ async function selectService(svcId){
* Отрисовать список инстансов в #inst-list. * Отрисовать список инстансов в #inst-list.
* Каждый инстанс — строка: имя + сервис + статус + created + modified. * Каждый инстанс — строка: имя + сервис + статус + created + modified.
*/ */
function renderInstances(){ function renderInstances() {
let html=''; let html = '';
svcInstances.forEach(i=>{ svcInstances.forEach(i => {
// Статус: из нашего поля status (cloud-first) или explainedStatus // Статус: из нашего поля status (cloud-first) или explainedStatus
const status=i.status||i.explainedStatus||'?'; const status = i.status || i.explainedStatus || '?';
const sc=status==='running'?'badge-success':''; const sc = (status === 'running' || status === 'Выполнен') ? 'badge-success' : '';
const created = i.instanceConfigDtCreated || i.dtCreate || '';
// Строка инстанса — кликабельна // Строка инстанса — кликабельна
html+=`<div class="inst-item" onclick="toggleInstance('${i.instanceUid}')" data-iuid="${i.instanceUid}"> html += `<div class="inst-item" onclick="toggleInstance('${i.instanceUid}')" data-iuid="${i.instanceUid}">
<span style="display:inline-block;width:220px;">${_esc(i.displayName)}</span> <span style="display:inline-block;width:220px;">${_esc(i.displayName)}</span>
<span style="display:inline-block;width:140px;color:var(--muted);font-size:11px;">${_esc(i.svc||'')}</span> <span style="display:inline-block;width:140px;color:var(--muted);font-size:11px;">${_esc(i.svc || '')}</span>
<span class="badge ${sc}" style="font-size:9px;">${_esc(status)}</span> <span class="badge ${sc}" style="font-size:9px;">${_esc(status)}</span>
<span style="display:inline-block;width:60px;color:var(--muted);font-size:9px;text-align:right;" title="${i.instanceConfigDtCreated||''}">${relativeTime(i.instanceConfigDtCreated)}</span> <span style="display:inline-block;width:60px;color:var(--muted);font-size:9px;text-align:right;" title="${created}">${relativeTime(created)}</span>
<span style="display:inline-block;width:60px;color:var(--muted);font-size:9px;text-align:right;" title="${i.instanceConfigDtModified||''}">${relativeTime(i.instanceConfigDtModified)}</span> <span style="display:inline-block;width:60px;color:var(--muted);font-size:9px;text-align:right;" title="${i.instanceConfigDtModified || ''}">${relativeTime(i.instanceConfigDtModified)}</span>
</div>`; </div>`;
// Контейнер для кнопок операций (изначально скрыт) // Контейнер для кнопок операций (изначально скрыт)
html+=`<div class="inst-ops" id="ops-${i.instanceUid}"></div>`; html += `<div class="inst-ops" id="ops-${i.instanceUid}"></div>`;
}); });
document.getElementById('inst-list').innerHTML=html; document.getElementById('inst-list').innerHTML = html;
} }
/** /**
* Обновить список инстансов (без смены сервиса). * Обновить список инстансов (без смены сервиса).
* Вызывается после завершения операции. * Вызывается после завершения операции.
*/ */
async function refreshInstances(){ async function refreshInstances() {
try{ try {
const r=await fetch('/api/operations/'+currentSvcId); const r = await fetch('/api/operations/' + currentSvcId);
const d=await r.json(); const d = await r.json();
svcInstances=d.instances||[]; svcInstances = d.instances || [];
renderInstances(); renderInstances();
}catch(e){} } catch (e) { }
} }
/** /**
@@ -107,52 +108,52 @@ async function refreshInstances(){
* *
* @param {string} iuid — instanceUid * @param {string} iuid — instanceUid
*/ */
async function toggleInstance(iuid){ async function toggleInstance(iuid) {
if(busy) return; if (busy) return;
selectedInst=iuid; selectedInst = iuid;
stopPoll(); stopPoll();
// Подсветка выбранного инстанса // Подсветка выбранного инстанса
document.querySelectorAll('[data-iuid]').forEach(e=> document.querySelectorAll('[data-iuid]').forEach(e =>
e.classList.toggle('active',e.dataset.iuid===iuid)); e.classList.toggle('active', e.dataset.iuid === iuid));
// Скрыть панель параметров (если была открыта для другого инстанса) // Скрыть панель параметров (если была открыта для другого инстанса)
document.getElementById('params-area').style.display='none'; document.getElementById('params-area').style.display = 'none';
const opsEl=document.getElementById('ops-'+iuid); const opsEl = document.getElementById('ops-' + iuid);
// Если уже открыт — закрыть (toggle) // Если уже открыт — закрыть (toggle)
if(opsEl.classList.contains('open')){opsEl.classList.remove('open');return;} if (opsEl.classList.contains('open')) { opsEl.classList.remove('open'); return; }
// Закрыть ВСЕ другие открытые панели операций // Закрыть ВСЕ другие открытые панели операций
document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open')); document.querySelectorAll('.inst-ops').forEach(e => e.classList.remove('open'));
// Найти инстанс в списке (может быть из другого сервиса) // Найти инстанс в списке (может быть из другого сервиса)
const inst=svcInstances.find(x=>x.instanceUid===iuid); const inst = svcInstances.find(x => x.instanceUid === iuid);
const svcId=inst?inst.serviceId:currentSvcId; const svcId = inst ? inst.serviceId : currentSvcId;
try{ try {
const r=await fetch('/api/operations/'+svcId); const r = await fetch('/api/operations/' + svcId);
const d=await r.json(); const d = await r.json();
// Фильтруем операции: убираем reconcile (техническая) и create (уже создан) // Фильтруем операции: убираем reconcile (техническая) и create (уже создан)
let ops=(d.operations||[]).filter(o=>o.operation!=='reconcile'&&o.operation!=='create'); let ops = (d.operations || []).filter(o => o.operation !== 'reconcile' && o.operation !== 'create');
// Для not created инстансов — только delete // Для not created инстансов — только delete
if(inst&&(inst.status||inst.explainedStatus)==='not created'){ if (inst && (inst.status || inst.explainedStatus) === 'not created') {
ops=ops.filter(o=>o.operation==='delete'); ops = ops.filter(o => o.operation === 'delete');
} }
// Кнопки операций с цветовой маркировкой // Кнопки операций с цветовой маркировкой
// o.operation в onclick — JS-escape: \ → \\, ' → \' // o.operation в onclick — JS-escape: \ → \\, ' → \'
// o.operation в тексте кнопки — HTML-escape через _esc() // o.operation в тексте кнопки — HTML-escape через _esc()
opsEl.innerHTML=ops.map(o=>{ opsEl.innerHTML = ops.map(o => {
const escOp = _esc(o.operation); const escOp = _esc(o.operation);
const jsOp = o.operation.replace(/\\/g,'\\\\').replace(/'/g,"\\'"); const jsOp = o.operation.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
return `<button class="btn btn-sm op-btn ${opClass(o.operation)}" onclick="runOp('${jsOp}',${o.svcOperationId})">${escOp}</button>`; return `<button class="btn btn-sm op-btn ${opClass(o.operation)}" onclick="runOp('${jsOp}',${o.svcOperationId})">${escOp}</button>`;
}).join(''); }).join('');
opsEl.classList.add('open'); opsEl.classList.add('open');
}catch(e){ } catch (e) {
opsEl.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>'; opsEl.innerHTML = '<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';
opsEl.classList.add('open'); opsEl.classList.add('open');
} }
} }
@@ -163,12 +164,12 @@ async function toggleInstance(iuid){
* @param {string} opName — "modify"/"delete"/"suspend"/... * @param {string} opName — "modify"/"delete"/"suspend"/...
* @returns {string} CSS-класс * @returns {string} CSS-класс
*/ */
function opClass(opName){ function opClass(opName) {
if(opName==='modify') return 'op-btn-modify'; // синий if (opName === 'modify') return 'op-btn-modify'; // синий
if(opName==='suspend') return 'op-btn-suspend'; // оранжевый if (opName === 'suspend') return 'op-btn-suspend'; // оранжевый
if(opName==='resume') return 'op-btn-resume'; // зелёный if (opName === 'resume') return 'op-btn-resume'; // зелёный
if(opName==='delete') return 'op-btn-delete'; // красный if (opName === 'delete') return 'op-btn-delete'; // красный
if(opName==='redeploy') return 'op-btn-redeploy'; // фиолетовый if (opName === 'redeploy') return 'op-btn-redeploy'; // фиолетовый
return ''; return '';
} }
+159 -153
View File
@@ -12,7 +12,7 @@
// Глобальные переменные (используются из instances.js): // Глобальные переменные (используются из instances.js):
// pollTimer — ID setInterval для поллинга // pollTimer — ID setInterval для поллинга
let pollTimer=null; let pollTimer = null;
/** /**
* Сгенерировать короткое имя для нового инстанса. * Сгенерировать короткое имя для нового инстанса.
@@ -21,9 +21,9 @@ let pollTimer=null;
* *
* @returns {string} displayName suffix (без префикса autotest-) * @returns {string} displayName suffix (без префикса autotest-)
*/ */
function makeCreateDisplayName(){ function makeCreateDisplayName() {
const rand = (Math.random() * 46656 | 0).toString(36).padStart(3, '0'); const rand = (Math.random() * 46656 | 0).toString(36).padStart(3, '0');
return currentSvcShort ? currentSvcShort+'-'+rand : rand; return currentSvcShort ? currentSvcShort + '-' + rand : rand;
} }
/** /**
@@ -31,39 +31,45 @@ function makeCreateDisplayName(){
* *
* @returns {string} displayName или instanceUid (если не найден) * @returns {string} displayName или instanceUid (если не найден)
*/ */
function findInstName(){ function findInstName() {
const i=svcInstances.find(x=>x.instanceUid===selectedInst); const i = svcInstances.find(x => x.instanceUid === selectedInst);
return i?i.displayName:selectedInst; return i ? i.displayName : selectedInst;
} }
/** /**
* Остановить поллинг текущей операции. * Остановить поллинг текущей операции.
*/ */
function stopPoll(){ function stopPoll() {
if(pollTimer){clearInterval(pollTimer);pollTimer=null;} if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
} }
/** /**
* Начать создание нового инстанса (кнопка «+ Создать»). * Начать создание нового инстанса (кнопка «+ Создать»).
* *
* Загружает операцию create для текущего сервиса через GET /api/operations/{svcId}. * Загружает операцию create для текущего сервиса через GET /api/operations/{svcId}.
* Если операция create не найдена — fallback на svcOperationId=18 (стандартный). * Числовой ID не зашивается в UI: он берётся из каталога выбранного сервиса.
*/ */
async function startCreate(){ async function startCreate() {
if(busy) return; if (busy) return;
selectedInst=null; // create — нет выбранного инстанса selectedInst = null; // create — нет выбранного инстанса
stopPoll(); stopPoll();
// Снять выделение со всех инстансов // Снять выделение со всех инстансов
document.querySelectorAll('[data-iuid]').forEach(e=>e.classList.remove('active')); document.querySelectorAll('[data-iuid]').forEach(e => e.classList.remove('active'));
document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open')); document.querySelectorAll('.inst-ops').forEach(e => e.classList.remove('open'));
try{ try {
const r=await fetch('/api/operations/'+currentSvcId); const r = await fetch('/api/operations/' + currentSvcId);
const d=await r.json(); const d = await r.json();
const createOp=d.operations.find(o=>o.operation==='create'); if (!r.ok || d.error) throw new Error(d.error || 'Не удалось загрузить операции сервиса');
const opId=createOp?createOp.svcOperationId:18; // fallback const createOp = (Array.isArray(d.operations) ? d.operations : []).find(o => o.operation === 'create');
showParams(opId,'create'); if (!createOp) {
}catch(e){ document.getElementById('test-status').innerHTML =
showParams(18,'create'); // fallback при ошибке '<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>`;
} }
} }
@@ -73,10 +79,10 @@ async function startCreate(){
* @param {string} opName — "modify"/"delete"/"suspend"/"resume"/"redeploy" * @param {string} opName — "modify"/"delete"/"suspend"/"resume"/"redeploy"
* @param {number} opId — svcOperationId * @param {number} opId — svcOperationId
*/ */
function runOp(opName,opId){ function runOp(opName, opId) {
if(busy) return; if (busy) return;
stopPoll(); stopPoll();
showParams(opId,opName); showParams(opId, opName);
} }
/** /**
@@ -92,81 +98,81 @@ function runOp(opName,opId){
* @param {number} opId — svcOperationId * @param {number} opId — svcOperationId
* @param {string} opName — "create"/"modify"/... * @param {string} opName — "create"/"modify"/...
*/ */
function showParams(opId,opName){ function showParams(opId, opName) {
// Сохраняем выбранную операцию // Сохраняем выбранную операцию
selectedOp={opId,opName,svcId:currentSvcId}; selectedOp = { opId, opName, svcId: currentSvcId };
// Показать панель параметров, скрыть старые этапы // Показать панель параметров, скрыть старые этапы
document.getElementById('params-area').style.display='block'; document.getElementById('params-area').style.display = 'block';
document.querySelectorAll('.stages-box').forEach(e=>e.remove()); document.querySelectorAll('.stages-box').forEach(e => e.remove());
document.getElementById('test-status').textContent=''; document.getElementById('test-status').textContent = '';
const isCreate=opName==='create'; const isCreate = opName === 'create';
// Для create — шаблонные значения, для не-create — с instanceUid // Для create — шаблонные значения, для не-create — с instanceUid
const qs=isCreate?'' : `?instanceUid=${selectedInst}`; const qs = isCreate ? '' : `?instanceUid=${selectedInst}`;
fetch('/api/params/'+opId+qs).then(r=>r.json()).then(async data=>{ fetch('/api/params/' + opId + qs).then(r => r.json()).then(async data => {
if(data.error){ if (data.error) {
document.getElementById('params-form').innerHTML= document.getElementById('params-form').innerHTML =
`<span style="color:var(--destructive);">Ошибка: ${_esc(data.error)}</span>`; `<span style="color:var(--destructive);">Ошибка: ${_esc(data.error)}</span>`;
return; return;
} }
const params=data.params||data; const params = data.params || data;
if(!Array.isArray(params)){ if (!Array.isArray(params)) {
document.getElementById('params-form').innerHTML= document.getElementById('params-form').innerHTML =
`<span style="color:var(--destructive);">Неверный формат параметров</span>`; `<span style="color:var(--destructive);">Неверный формат параметров</span>`;
return; return;
} }
// Если есть refSvcId-параметры — загружаем ВСЕ инстансы (для select'ов) // Если есть refSvcId-параметры — загружаем ВСЕ инстансы (для select'ов)
let allInst=[]; let allInst = [];
const needRefs=params.some(p=>p.refSvcId); const needRefs = params.some(p => p.refSvcId);
if(needRefs){ if (needRefs) {
try{ try {
const r=await fetch('/api/instances/list'); const r = await fetch('/api/instances/list');
const list=await r.json(); const list = await r.json();
allInst=list||[]; allInst = list || [];
}catch(e){} } catch (e) { }
} }
const form=document.getElementById('params-form'); const form = document.getElementById('params-form');
const displayNameValue=isCreate?makeCreateDisplayName():''; const displayNameValue = isCreate ? makeCreateDisplayName() : '';
// Заголовок формы // Заголовок формы
const createHeader=isCreate const createHeader = isCreate
? `<div style="font-weight:600;font-size:13px;margin-bottom:8px;color:var(--brand-primary);">Создание: ${_esc(currentSvcName)}</div>` ? `<div style="font-weight:600;font-size:13px;margin-bottom:8px;color:var(--brand-primary);">Создание: ${_esc(currentSvcName)}</div>`
: ''; : '';
const opHeader=!isCreate const opHeader = !isCreate
? `<div style="font-weight:600;font-size:13px;margin-bottom:8px;color:var(--brand-primary);">${_esc(opName)} → ${_esc(findInstName())}</div>` ? `<div style="font-weight:600;font-size:13px;margin-bottom:8px;color:var(--brand-primary);">${_esc(opName)} → ${_esc(findInstName())}</div>`
: ''; : '';
// Форма: заголовок + displayName (только create) + параметры // Форма: заголовок + displayName (только create) + параметры
form.innerHTML=createHeader+opHeader form.innerHTML = createHeader + opHeader
+ (opName==='create' + (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>` ? `<div class="param-row"><label class="req">displayName</label><span style="font-size:12px;color:var(--muted);white-space:nowrap;">${AUTOTEST_PREFIX}</span><input type="text" id="param-displayname" value="${_esc(displayNameValue)}" autocomplete="off"></div>`
: '') : '')
+ params.map(p=>renderParamRow(p,allInst)).join(''); + params.map(p => renderParamRow(p, allInst)).join('');
// Кнопка «Запустить» // Кнопка «Запустить»
const btn=document.getElementById('btn-test'); const btn = document.getElementById('btn-test');
btn.style.display='inline-flex'; btn.style.display = 'inline-flex';
btn.textContent='Запустить'; btn.textContent = 'Запустить';
btn.onclick=()=>{ btn.onclick = () => {
// Валидация JSON-полей перед отправкой // Валидация JSON-полей перед отправкой
let bad=[]; let bad = [];
document.querySelectorAll('#params-form [data-type="map"]').forEach(el=>{ document.querySelectorAll('#params-form [data-type="map"]').forEach(el => {
if(!validateJson(el,true)) bad.push(el); if (!validateJson(el, true)) bad.push(el);
}); });
if(bad.length>0){ if (bad.length > 0) {
document.getElementById('test-status').innerHTML= document.getElementById('test-status').innerHTML =
`<span class="badge">Ошибка</span> ${bad.length} полей с невалидным JSON — поправьте и нажмите Запустить снова`; `<span class="badge">Ошибка</span> ${bad.length} полей с невалидным JSON — поправьте и нажмите Запустить снова`;
return; return;
} }
const pp=collectParams(); // собрать значения из формы const pp = collectParams(); // собрать значения из формы
executeOp(pp); // отправить executeOp(pp); // отправить
}; };
}).catch(e=>{ }).catch(e => {
document.getElementById('params-form').innerHTML= document.getElementById('params-form').innerHTML =
`<span style="color:var(--destructive);">Ошибка загрузки: ${_esc(e.message)}</span>`; `<span style="color:var(--destructive);">Ошибка загрузки: ${_esc(e.message)}</span>`;
}); });
} }
@@ -180,29 +186,29 @@ function showParams(opId,opName){
* @param {number} statusDuration— длительность в секундах * @param {number} statusDuration— длительность в секундах
* @param {string} instanceName — displayName инстанса * @param {string} instanceName — displayName инстанса
*/ */
function setFinishedState(statusText, statusClass, statusError, statusDuration, instanceName){ function setFinishedState(statusText, statusClass, statusError, statusDuration, instanceName) {
busy=false; // разблокировать UI busy = false; // разблокировать UI
// Закрыть панели операций // Закрыть панели операций
document.querySelectorAll('.inst-ops.open').forEach(e=>e.classList.remove('open')); document.querySelectorAll('.inst-ops.open').forEach(e => e.classList.remove('open'));
const btn=document.getElementById('btn-test'); const btn = document.getElementById('btn-test');
btn.disabled=false; btn.disabled = false;
btn.textContent='Готово'; btn.textContent = 'Готово';
btn.onclick=null; // убрать обработчик btn.onclick = null; // убрать обработчик
const opName=selectedOp?.opName||'операция'; const opName = selectedOp?.opName || 'операция';
const durationText=statusDuration!==undefined&&statusDuration!==null?`${statusDuration}s`:'0s'; const durationText = statusDuration !== undefined && statusDuration !== null ? `${statusDuration}s` : '0s';
const extra=_esc(statusError||''); const extra = _esc(statusError || '');
const instName=_esc(instanceName||findInstName()||selectedInst||''); const instName = _esc(instanceName || findInstName() || selectedInst || '');
document.getElementById('test-status').innerHTML= 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}`; ` <span class="badge ${statusClass}">${statusText}</span> <span style="color:var(--muted);">${opName}</span> <span style="color:var(--muted);">${durationText}</span> <span style="color:var(--muted);">${instName}</span> ${extra}`;
} }
/** /**
* Установить состояние «выполняется». * Установить состояние «выполняется».
*/ */
function setRunningState(opName, displayName){ function setRunningState(opName, displayName) {
const btn=document.getElementById('btn-test'); const btn = document.getElementById('btn-test');
btn.textContent=opName+'...'; btn.textContent = opName + '...';
document.getElementById('test-status').textContent=' ⏳ '+ (displayName||opName) +' выполняется...'; document.getElementById('test-status').textContent = ' ⏳ ' + (displayName || opName) + ' выполняется...';
} }
/** /**
@@ -218,106 +224,106 @@ function setRunningState(opName, displayName){
* *
* @param {Object} params — {numeric_param_id: value} * @param {Object} params — {numeric_param_id: value}
*/ */
async function executeOp(params){ async function executeOp(params) {
busy=true; // заблокировать UI busy = true; // заблокировать UI
stopPoll(); stopPoll();
const isCreate=selectedOp?.opName==='create'; const isCreate = selectedOp?.opName === 'create';
const displayNameInput=document.getElementById('param-displayname'); const displayNameInput = document.getElementById('param-displayname');
const displayNameSuffix=(displayNameInput?.value||'').trim(); const displayNameSuffix = (displayNameInput?.value || '').trim();
const displayName=isCreate const displayName = isCreate
? `${AUTOTEST_PREFIX}${displayNameSuffix || makeCreateDisplayName()}` ? `${AUTOTEST_PREFIX}${displayNameSuffix || makeCreateDisplayName()}`
: findInstName(); : findInstName();
// Подготовка UI // Подготовка UI
document.getElementById('params-area').style.display='block'; document.getElementById('params-area').style.display = 'block';
document.getElementById('params-form').innerHTML=''; // очистить форму document.getElementById('params-form').innerHTML = ''; // очистить форму
const btn=document.getElementById('btn-test'); const btn = document.getElementById('btn-test');
btn.style.display='inline-flex'; btn.style.display = 'inline-flex';
btn.textContent=selectedOp.opName+'...'; btn.textContent = selectedOp.opName + '...';
btn.disabled=true; btn.disabled = true;
setRunningState(selectedOp.opName, displayName); setRunningState(selectedOp.opName, displayName);
// Создать контейнер для этапов (после кнопки create или после панели операций) // Создать контейнер для этапов (после кнопки create или после панели операций)
const instId=selectedInst||''; const instId = selectedInst || '';
const afterEl=instId?document.getElementById('ops-'+instId):null; const afterEl = instId ? document.getElementById('ops-' + instId) : null;
document.querySelectorAll('.stages-box').forEach(e=>e.remove()); document.querySelectorAll('.stages-box').forEach(e => e.remove());
const stagesBox=document.createElement('div'); const stagesBox = document.createElement('div');
stagesBox.className='stages-box'; 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;'; stagesBox.style.cssText = 'max-height:200px;overflow-y:auto;padding:4px 8px;margin-left:16px;background:var(--brand-grey-light);border-radius:4px;';
if(afterEl){afterEl.after(stagesBox);} if (afterEl) { afterEl.after(stagesBox); }
else{document.getElementById('create-btn-area').after(stagesBox);} else { document.getElementById('create-btn-area').after(stagesBox); }
try{ try {
// POST /api/test — тело: serviceId, operation, svcOperationId, params, instanceUid, displayName // POST /api/test — тело: serviceId, operation, svcOperationId, params, instanceUid, displayName
const r=await fetch('/api/test',{ const r = await fetch('/api/test', {
method:'POST', method: 'POST',
headers:{'Content-Type':'application/json'}, headers: { 'Content-Type': 'application/json' },
body:JSON.stringify({ body: JSON.stringify({
serviceId:currentSvcId, serviceId: currentSvcId,
operation:selectedOp.opName, operation: selectedOp.opName,
svcOperationId:selectedOp.opId, svcOperationId: selectedOp.opId,
params, params,
instanceUid:selectedInst||'', instanceUid: selectedInst || '',
displayName:isCreate?displayName:'' displayName: isCreate ? displayName : ''
}) })
}); });
const d=await r.json(); const d = await r.json();
// FAIL на старте — нечего поллить // FAIL на старте — нечего поллить
if(d.status==='FAIL'){ if (d.status === 'FAIL') {
busy=false; busy = false;
document.getElementById('test-status').innerHTML= document.getElementById('test-status').innerHTML =
` <span class="badge">FAIL</span> ${_esc(d.error||'')}`; ` <span class="badge">FAIL</span> ${_esc(d.error || '')}`;
btn.disabled=false; btn.disabled = false;
return; return;
} }
// CMDB-delete (opUid начинается с "cmdb-") — мгновенный успех // CMDB-delete (opUid начинается с "cmdb-") — мгновенный успех
if(d.status==='OK'&&(d.opUid||'').startsWith('cmdb-')){ if (d.status === 'OK' && (d.opUid || '').startsWith('cmdb-')) {
busy=false; busy = false;
setFinishedState('OK','badge-success','',0,d.displayName); setFinishedState('OK', 'badge-success', '', 0, d.displayName);
try{await refreshInstances();}catch(e){} try { await refreshInstances(); } catch (e) { }
if(historyOpen) try{await loadHistory();}catch(e){} if (historyOpen) try { await loadHistory(); } catch (e) { }
return; return;
} }
const opUid=d.opUid; const opUid = d.opUid;
const instanceName=d.displayName||displayName; const instanceName = d.displayName || displayName;
// Поллинг: каждые 2 секунды // Поллинг: каждые 2 секунды
let _pollErrors=0; let _pollErrors = 0;
pollTimer=setInterval(async()=>{ pollTimer = setInterval(async () => {
try{ try {
const sr=await fetch('/api/test/status/'+opUid); const sr = await fetch('/api/test/status/' + opUid);
const sd=await sr.json(); const sd = await sr.json();
_pollErrors=0; // сброс счётчика ошибок _pollErrors = 0; // сброс счётчика ошибок
showStages(sd.stages||[]); // обновить этапы showStages(sd.stages || []); // обновить этапы
if(sd.status!=='RUNNING'){ if (sd.status !== 'RUNNING') {
stopPoll(); stopPoll();
setFinishedState( setFinishedState(
sd.status, sd.status,
sd.status==='OK'?'badge-success':'badge', sd.status === 'OK' ? 'badge-success' : 'badge',
sd.error||sd.errorLog, sd.error || sd.errorLog,
sd.duration, sd.duration,
sd.displayName||instanceName sd.displayName || instanceName
); );
await refreshInstances(); // обновить список инстансов await refreshInstances(); // обновить список инстансов
if(historyOpen) await loadHistory(); // обновить историю if (historyOpen) await loadHistory(); // обновить историю
} }
}catch(e){ } catch (e) {
_pollErrors++; _pollErrors++;
// 5 ошибок подряд (10 секунд) → считаем связь потерянной // 5 ошибок подряд (10 секунд) → считаем связь потерянной
if(_pollErrors>=5){ if (_pollErrors >= 5) {
stopPoll(); stopPoll();
setFinishedState('TIMEOUT','','Связь потеряна',0,instanceName); setFinishedState('TIMEOUT', '', 'Связь потеряна', 0, instanceName);
} }
} }
},2000); }, 2000);
}catch(e){ } catch (e) {
busy=false; busy = false;
document.getElementById('test-status').textContent=' Ошибка: '+e.message; document.getElementById('test-status').textContent = ' Ошибка: ' + e.message;
btn.disabled=false; btn.disabled = false;
} }
} }
@@ -328,15 +334,15 @@ async function executeOp(params){
* *
* @param {Array} stages — [{stage, dtStart, dtFinish, isSuccessful, duration}, ...] * @param {Array} stages — [{stage, dtStart, dtFinish, isSuccessful, duration}, ...]
*/ */
function showStages(stages){ function showStages(stages) {
if(!stages||!stages.length) return; if (!stages || !stages.length) return;
let html='<div style="font-size:11px;font-weight:600;color:var(--muted);margin-top:4px;">Этапы</div>'; let html = '<div style="font-size:11px;font-weight:600;color:var(--muted);margin-top:4px;">Этапы</div>';
stages.forEach(s=>{ stages.forEach(s => {
const done=!!s.dtFinish; // этап завершён? const done = !!s.dtFinish; // этап завершён?
const icon=done?(s.isSuccessful?'✅':'❌'):'⏳'; const icon = done ? (s.isSuccessful ? '✅' : '❌') : '⏳';
html+=`<div style="font-size:11px;padding:2px 0;">${icon} ${_esc(s.stage)} — ${(s.duration||0).toFixed(1)}s</div>`; html += `<div style="font-size:11px;padding:2px 0;">${icon} ${_esc(s.stage)} — ${(s.duration || 0).toFixed(1)}s</div>`;
}); });
// Показать в ПОСЛЕДНЕМ контейнере этапов (самом новом) // Показать в ПОСЛЕДНЕМ контейнере этапов (самом новом)
const boxes=document.querySelectorAll('.stages-box'); const boxes = document.querySelectorAll('.stages-box');
if(boxes.length) boxes[boxes.length-1].innerHTML=html; if (boxes.length) boxes[boxes.length - 1].innerHTML = html;
} }
+94 -45
View File
@@ -38,6 +38,8 @@ async function showScenarioEditor(def) {
service_id: s.service_id || '', operation: s.operation || '', service_id: s.service_id || '', operation: s.operation || '',
output: s.output || '', instance_ref: s.instance_ref || '', output: s.output || '', instance_ref: s.instance_ref || '',
instance_uid: s.instance_uid || '', instance_uid: s.instance_uid || '',
_legacyInstanceFallback: s.operation !== 'create' && !s.instance_ref && !s.instance_uid,
_showCloud: Boolean(s.instance_uid),
params: s.params || {} // {name: value} — символьные имена params: s.params || {} // {name: value} — символьные имена
})), })),
services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {} services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {}
@@ -58,6 +60,10 @@ async function showScenarioEditor(def) {
scenarioEditorState.services = []; scenarioEditorState.services = [];
scenarioEditorState.cloudInstances = []; scenarioEditorState.cloudInstances = [];
} }
const initialServiceIds = [...new Set(scenarioEditorState.steps
.map(step => parseInt(step.service_id, 10))
.filter(serviceId => serviceId > 0))];
await Promise.all(initialServiceIds.map(loadServiceOperations));
renderEditor(); renderEditor();
} }
@@ -138,6 +144,30 @@ function snapshotAllParams() {
// Резолв операции + загрузка параметров // Резолв операции + загрузка параметров
// ═══════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════
/**
* Загрузить доступные операции сервиса и сохранить их в кеше редактора.
*
* @param {number|string} svcId — ID сервиса
* @returns {Promise<Array|null>} операции сервиса или null при ошибке загрузки
*/
async function loadServiceOperations(svcId) {
if (!svcId) return null;
const st = scenarioEditorState;
if (Object.prototype.hasOwnProperty.call(st.opsCache, svcId)) {
return st.opsCache[svcId];
}
try {
const response = await fetch('/api/operations/' + svcId);
const data = await response.json();
if (!response.ok || data.error) return null;
st.opsCache[svcId] = Array.isArray(data.operations) ? data.operations : [];
return st.opsCache[svcId];
} catch (e) {
return null;
}
}
/** /**
* Найти svcOperationId для операции в шаге. * Найти svcOperationId для операции в шаге.
* *
@@ -179,15 +209,8 @@ async function resolveStepSvcOpId(idx) {
if (!svcId) return null; if (!svcId) return null;
// Кеш операций сервиса const ops = await loadServiceOperations(svcId);
if (!st.opsCache[svcId]) { if (!ops) return null;
try {
const r = await fetch('/api/operations/' + svcId);
const d = await r.json();
st.opsCache[svcId] = d.operations || [];
} catch (e) { return null; }
}
const ops = st.opsCache[svcId];
const found = ops.find(o => o.operation === op); const found = ops.find(o => o.operation === op);
return found ? found.svcOperationId : null; return found ? found.svcOperationId : null;
} }
@@ -230,7 +253,7 @@ async function loadStepParams(idx) {
let allInst = st.cloudInstances || []; let allInst = st.cloudInstances || [];
const needRefs = defs.some(p => p.refSvcId); const needRefs = defs.some(p => p.refSvcId);
if (needRefs && !allInst.length) { if (needRefs && !allInst.length) {
try { const r = await fetch('/api/instances/list'); allInst = await r.json(); } catch (e) {} try { const r = await fetch('/api/instances/list'); allInst = await r.json(); } catch (e) { }
} }
// Рендер: клонируем def и подставляем сохранённые значения в defaultValue // Рендер: клонируем def и подставляем сохранённые значения в defaultValue
@@ -320,20 +343,20 @@ function renderStepRow(idx, step) {
const st = scenarioEditorState; const st = scenarioEditorState;
const op = step.operation; const op = step.operation;
const out = step.output || ''; const out = step.output || '';
const ref = step.instance_ref || ''; const ref = step.instance_ref || step.instance_uid || '';
// Собрать доступные output-ы из предыдущих create-шагов (для instance_ref select) // Собрать доступные output-ы из предыдущих create-шагов (для instance_ref select)
const prevOutputs = []; const prevOutputs = [];
for (let i = 0; i < idx; i++) { for (let i = 0; i < idx; i++) {
const s = st.steps[i]; const s = st.steps[i];
if (s.operation === 'create' && s.output) prevOutputs.push(s.output); if (s.operation === 'create' && s.output && Number(s.service_id) === Number(step.service_id)) prevOutputs.push(s.output);
} }
let html = `<div style="border:1px solid var(--brand-gray);border-radius:6px;padding:8px;margin:6px 0;" id="step-${idx}">`; let html = `<div style="border:1px solid var(--brand-gray);border-radius:6px;padding:8px;margin:6px 0;" id="step-${idx}">`;
// Заголовок шага: номер + кнопки [↑][↓][✕] // Заголовок шага: номер + кнопки [↑][↓][✕]
html += `<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">`; html += `<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">`;
html += `<span style="font-size:11px;font-weight:600;">Шаг ${idx+1}</span>`; html += `<span style="font-size:11px;font-weight:600;">Шаг ${idx + 1}</span>`;
// Кнопка вверх (кроме первого) // Кнопка вверх (кроме первого)
if (idx > 0) html += `<button class="btn btn-sm" style="font-size:9px;padding:0 4px;" onclick="moveStep(${idx},-1);renderEditor();">↑</button>`; if (idx > 0) html += `<button class="btn btn-sm" style="font-size:9px;padding:0 4px;" onclick="moveStep(${idx},-1);renderEditor();">↑</button>`;
// Кнопка вниз (кроме последнего) // Кнопка вниз (кроме последнего)
@@ -342,20 +365,30 @@ function renderStepRow(idx, step) {
html += `<button class="btn btn-sm" style="font-size:9px;padding:0 4px;margin-left:auto;color:var(--destructive);" onclick="removeStep(${idx})">✕</button>`; html += `<button class="btn btn-sm" style="font-size:9px;padding:0 4px;margin-left:auto;color:var(--destructive);" onclick="removeStep(${idx})">✕</button>`;
html += `</div>`; html += `</div>`;
// Выпадающий список операций // Список операций зависит от актуального каталога выбранного сервиса.
const allOps = ['create', 'delete', 'modify', 'suspend', 'resume', 'redeploy']; 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="margin-bottom:4px;"><select id="step-${idx}-svc" onchange="onServiceChange(${idx})" style="padding:4px;font-size:12px;">${svcOpts}</select></div>`;
const svcId = parseInt(step.service_id, 10) || 0;
const serviceOps = svcId ? (st.opsCache[svcId] || []) : [];
const operationsLoaded = svcId && Object.prototype.hasOwnProperty.call(st.opsCache, svcId);
let opOpts = '<option value="">— операция —</option>'; let opOpts = '<option value="">— операция —</option>';
allOps.forEach(o => { opOpts += `<option value="${o}" ${o == op ? 'selected' : ''}>${o}</option>`; }); serviceOps.filter(item => item.operation !== 'reconcile').forEach(item => {
html += `<div style="margin-bottom:4px;"><select id="step-${idx}-op" onchange="onOpChange(${idx})" style="padding:4px;font-size:12px;">${opOpts}</select></div>`; const operationName = item.operation || '';
opOpts += `<option value="${_esc(operationName)}" ${operationName === op ? 'selected' : ''}>${_esc(operationName)}</option>`;
});
if (op && !serviceOps.some(item => item.operation === op)) {
opOpts += `<option value="${_esc(op)}" selected>${_esc(op)} (нет в сервисе)</option>`;
}
const opDisabled = !svcId || !operationsLoaded;
html += `<div style="margin-bottom:4px;"><select id="step-${idx}-op" onchange="onOpChange(${idx})" style="padding:4px;font-size:12px;" ${opDisabled ? 'disabled' : ''}>${opOpts}</select></div>`;
// Разное содержимое в зависимости от типа операции // Разное содержимое в зависимости от типа операции
if (op === 'create') { if (op === 'create') {
// CREATE: выбор сервиса + output name // 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" onchange="snapshotAllParams();scenarioEditorState.steps[${idx}].service_id=parseInt(this.value);renderEditor();" 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)}" onchange="scenarioEditorState.steps[${idx}].output=this.value" placeholder="имя для ссылок (например: d1)" style="width:220px;padding:3px;font-size:11px;"></div>`; html += `<div style="margin-top:4px;"><input type="text" id="step-${idx}-output" value="${_esc(out)}" onchange="scenarioEditorState.steps[${idx}].output=this.value" placeholder="имя для ссылок (например: d1)" style="width:220px;padding:3px;font-size:11px;"></div>`;
} else if (op) { } else if (op) {
@@ -371,13 +404,17 @@ function renderStepRow(idx, step) {
st.cloudInstances.forEach(i => { st.cloudInstances.forEach(i => {
const dn = i.displayName || ''; const dn = i.displayName || '';
// Только autotest-*, не deleted // Только autotest-*, не deleted
if (!dn.startsWith('autotest-') || i.explainedStatus === 'deleted') return; if (!dn.startsWith('autotest-') || i.explainedStatus === 'deleted'
|| Number(i.serviceId) !== Number(step.service_id)) return;
const sel = i.instanceUid == ref ? 'selected' : ''; const sel = i.instanceUid == ref ? 'selected' : '';
refOpts += `<option value="${i.instanceUid}" ${sel}>☁ ${_esc(dn)} — ${_esc(i.svc||'')} (${_esc(i.explainedStatus||'?')})</option>`; refOpts += `<option value="${i.instanceUid}" ${sel}>☁ ${_esc(dn)} — ${_esc(i.svc || '')} (${_esc(i.explainedStatus || '?')})</option>`;
}); });
} }
if (step.instance_uid && !st.cloudInstances.some(i => i.instanceUid === step.instance_uid)) {
refOpts += `<option value="${_esc(step.instance_uid)}" selected>☁ ${_esc(step.instance_uid)} (сохранённая ссылка)</option>`;
}
const btnLabel = step._showCloud ? '➖ Скрыть облако' : '📥 Из облака'; const btnLabel = step._showCloud ? '➖ Скрыть облако' : '📥 Из облака';
html += `<div style="display:flex;gap:4px;align-items:center;"><select id="step-${idx}-ref" onchange="snapshotAllParams();scenarioEditorState.steps[${idx}].instance_ref=this.value;renderEditor();" style="padding:4px;font-size:12px;max-width:500px;">${refOpts}</select>`; html += `<div style="display:flex;gap:4px;align-items:center;"><select id="step-${idx}-ref" onchange="snapshotAllParams();scenarioEditorState.steps[${idx}].instance_ref=this.value;scenarioEditorState.steps[${idx}].instance_uid='';scenarioEditorState.steps[${idx}]._legacyInstanceFallback=false;renderEditor();" style="padding:4px;font-size:12px;max-width:500px;">${refOpts}</select>`;
html += `<button class="btn btn-sm" style="font-size:10px;padding:0 6px;" onclick="toggleCloudInstances(${idx})">${btnLabel}</button></div>`; html += `<button class="btn btn-sm" style="font-size:10px;padding:0 6px;" onclick="toggleCloudInstances(${idx})">${btnLabel}</button></div>`;
} }
@@ -392,6 +429,24 @@ function renderStepRow(idx, step) {
// Действия редактора // Действия редактора
// ═══════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════
/** Сменить сервис шага и загрузить его актуальный список операций. */
async function onServiceChange(idx) {
snapshotAllParams();
const step = scenarioEditorState.steps[idx];
const svcEl = document.getElementById('step-' + idx + '-svc');
const svcId = parseInt(svcEl?.value, 10) || '';
step.service_id = svcId;
step.operation = '';
step.instance_ref = '';
step.instance_uid = '';
step.params = {};
step._svcOpId = null;
step._legacyInstanceFallback = false;
if (svcId) await loadServiceOperations(svcId);
renderEditor();
}
/** /**
* Изменение операции в выпадающем списке → сохранить выбор + сброс + перерисовка. * Изменение операции в выпадающем списке → сохранить выбор + сброс + перерисовка.
* *
@@ -407,6 +462,7 @@ function onOpChange(idx) {
step.operation = newOp; // сохранить выбранную операцию step.operation = newOp; // сохранить выбранную операцию
step.params = {}; // сброс параметров (для новой операции — другие) step.params = {}; // сброс параметров (для новой операции — другие)
step._svcOpId = null; // сброс кеша svcOperationId step._svcOpId = null; // сброс кеша svcOperationId
step._legacyInstanceFallback = false;
// Для не-create: автоматически показать облачные инстансы // Для не-create: автоматически показать облачные инстансы
if (newOp && newOp !== 'create') { if (newOp && newOp !== 'create') {
@@ -484,10 +540,11 @@ function collectFormSteps() {
let svcId = 0; let svcId = 0;
const s = { operation, params: {} }; const s = { operation, params: {} };
const svcEl = document.getElementById('step-' + idx + '-svc');
svcId = parseInt(svcEl?.value) || 0;
if (operation === 'create') { if (operation === 'create') {
// CREATE: service_id из select // CREATE: service_id из select + output-ссылка
const svcEl = document.getElementById('step-' + idx + '-svc');
svcId = parseInt(svcEl?.value) || 0;
const outEl = document.getElementById('step-' + idx + '-output'); const outEl = document.getElementById('step-' + idx + '-output');
const output = (outEl?.value || '').trim(); const output = (outEl?.value || '').trim();
if (output) s.output = output; if (output) s.output = output;
@@ -498,17 +555,12 @@ function collectFormSteps() {
if (ref) { if (ref) {
const cloud = (st.cloudInstances || []).find(i => i.instanceUid === ref); const cloud = (st.cloudInstances || []).find(i => i.instanceUid === ref);
if (cloud) { if (cloud) {
svcId = cloud.serviceId;
s.instance_uid = ref; // облачный UUID s.instance_uid = ref; // облачный UUID
} else if (ref === step.instance_uid) {
s.instance_uid = ref; // сохранить UID даже если инстанс уже не в списке
} else { } else {
// output-ссылка — ищем service_id в предыдущих create-шагах // output-ссылка на предыдущий create-шаг
s.instance_ref = ref; s.instance_ref = ref;
for (let j = 0; j < idx; j++) {
if (st.steps[j].output === ref) {
svcId = st.steps[j].service_id;
break;
}
}
} }
} }
} }
@@ -549,14 +601,11 @@ async function saveScenario() {
if (!steps.length) { alert('Добавьте хотя бы один шаг'); return; } if (!steps.length) { alert('Добавьте хотя бы один шаг'); return; }
for (let i = 0; i < steps.length; i++) { for (let i = 0; i < steps.length; i++) {
const s = steps[i]; const s = steps[i];
if (!s.operation) { alert('Шаг ' + (i+1) + ': выберите операцию'); return; } if (!s.operation) { alert('Шаг ' + (i + 1) + ': выберите операцию'); return; }
// CREATE: нужен service_id. Не-CREATE: service_id ИЛИ instance_ref/instance_uid if (!s.service_id) { alert('Шаг ' + (i + 1) + ': выберите сервис'); return; }
if (s.operation === 'create') { if (s.operation !== 'create' && !s.instance_ref && !s.instance_uid
if (!s.service_id) { alert('Шаг ' + (i+1) + ': выберите сервис'); return; } && !scenarioEditorState.steps[i]._legacyInstanceFallback) {
} else { alert('Шаг ' + (i + 1) + ': выберите инстанс (из create-шага или облака)'); return;
if (!s.service_id && !s.instance_ref && !s.instance_uid) {
alert('Шаг ' + (i+1) + ': выберите инстанс (из create-шага или облака)'); return;
}
} }
} }
+23 -9
View File
@@ -1,4 +1,6 @@
from pathlib import Path from pathlib import Path
import os
import socket
import subprocess import subprocess
import sys import sys
import time import time
@@ -14,17 +16,21 @@ if str(SITE_DIR) not in sys.path:
# Путь к polygon: соседняя репа в корне autotest # Путь к polygon: соседняя репа в корне autotest
POLYGON_DIR = Path(__file__).resolve().parents[2] / "polygon" / "site" POLYGON_DIR = Path(__file__).resolve().parents[2] / "polygon" / "site"
POLYGON_URL = "http://localhost:5000"
@pytest.fixture @pytest.fixture
def app_client(): def app_client(polygon_server):
"""Flask test client для app-autotest.""" """Flask test client, направленный в локальный Polygon TEST."""
from app import app from app import app
old_polygon_endpoint = app.config.get("POLYGON_ENDPOINT", "")
app.config["POLYGON_ENDPOINT"] = f"{polygon_server}/api/v1/svc"
app.config.update(TESTING=True) app.config.update(TESTING=True)
with app.test_client() as client: try:
yield client with app.test_client() as client:
yield client
finally:
app.config["POLYGON_ENDPOINT"] = old_polygon_endpoint
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@@ -37,18 +43,26 @@ def polygon_server():
if not POLYGON_DIR.is_dir(): if not POLYGON_DIR.is_dir():
pytest.skip("polygon repo not found") pytest.skip("polygon repo not found")
with socket.socket() as port_socket:
port_socket.bind(("127.0.0.1", 0))
port = port_socket.getsockname()[1]
polygon_url = f"http://127.0.0.1:{port}"
env = os.environ.copy()
env["POLYGON_STANDS"] = "test:services/test"
proc = subprocess.Popen( proc = subprocess.Popen(
["python3", "app.py"], ["python3", "-c", f"import app; app.app.run(host='127.0.0.1', port={port}, debug=False)"],
cwd=str(POLYGON_DIR), cwd=str(POLYGON_DIR),
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
env=env,
) )
# Ждём готовности (poll /health, timeout 10s) # Ждём готовности (poll /health, timeout 10s)
deadline = time.time() + 10 deadline = time.time() + 10
while time.time() < deadline: while time.time() < deadline:
try: try:
r = requests.get(f"{POLYGON_URL}/health", timeout=2) r = requests.get(f"{polygon_url}/health", timeout=2)
if r.status_code == 200: if r.status_code == 200:
break break
except requests.ConnectionError: except requests.ConnectionError:
@@ -58,7 +72,7 @@ def polygon_server():
proc.wait() proc.wait()
pytest.fail("polygon did not start within 10s") pytest.fail("polygon did not start within 10s")
yield POLYGON_URL yield polygon_url
proc.terminate() proc.terminate()
proc.wait() proc.wait()
@@ -67,5 +81,5 @@ def polygon_server():
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def reset_polygon(polygon_server): def reset_polygon(polygon_server):
"""Перед каждым интеграционным тестом — сброс состояния polygon.""" """Перед каждым интеграционным тестом — сброс состояния polygon."""
requests.post(f"{polygon_server}/api/v1/svc/_mock/reset", timeout=5) requests.post(f"{polygon_server}/test/api/v1/svc/_mock/reset", timeout=5)
+41
View File
@@ -5,6 +5,7 @@
""" """
import json import json
import time import time
import pytest
# ── Хелперы ── # ── Хелперы ──
@@ -48,6 +49,46 @@ class TestScenarioLifecycle:
data = resp.get_json() data = resp.get_json()
assert isinstance(data, list) assert isinstance(data, list)
def test_manual_create_delete_uses_local_polygon(self, app_client, monkeypatch):
"""Ручной API приложения проводит create и delete через локальный Polygon."""
from api.http_client import HttpClient
from routes import api_test as api_test_routes
monkeypatch.setattr(api_test_routes, "_finish_op", lambda *args, **kwargs: None)
monkeypatch.setattr(
HttpClient,
"raw_delete",
lambda *args, **kwargs: pytest.fail("Polygon delete must not call external CMDB"),
)
operations_response = app_client.get("/api/operations/1")
assert operations_response.status_code == 200
operations = operations_response.get_json()["operations"]
create_id = next(op["svcOperationId"] for op in operations if op["operation"] == "create")
delete_id = next(op["svcOperationId"] for op in operations if op["operation"] == "delete")
created = app_client.post("/api/test", json={
"serviceId": 1,
"operation": "create",
"svcOperationId": create_id,
"params": {},
"displayName": "autotest-local-integration",
})
assert created.status_code == 200, created.get_json()
create_result = app_client.get(f"/api/test/status/{created.get_json()['opUid']}").get_json()
assert create_result["status"] == "OK"
deleted = app_client.post("/api/test", json={
"serviceId": 1,
"operation": "delete",
"svcOperationId": delete_id,
"instanceUid": created.get_json()["instanceUid"],
"params": {},
})
assert deleted.status_code == 200, deleted.get_json()
delete_result = app_client.get(f"/api/test/status/{deleted.get_json()['opUid']}").get_json()
assert delete_result["status"] == "OK"
def test_create_and_run_simple_scenario(self, app_client): def test_create_and_run_simple_scenario(self, app_client):
"""Сценарий create -> delete. Проверяем что оба шага OK.""" """Сценарий create -> delete. Проверяем что оба шага OK."""
steps = [ steps = [
+9 -2
View File
@@ -4,7 +4,9 @@
Бьёт в РАЗВЁРНУТЫЙ автотест (atest.pythonk8s.dev.nubes.ru), эмуляция TEST. Бьёт в РАЗВЁРНУТЫЙ автотест (atest.pythonk8s.dev.nubes.ru), эмуляция TEST.
Операции идут через app-autotest -> executor -> save_run -> история в БД. Операции идут через app-autotest -> executor -> save_run -> история в БД.
""" """
import time, requests import time, requests, pytest
pytestmark = pytest.mark.skip(reason="live test against deployed atest service; run this file directly")
BASE = "https://atest.pythonk8s.dev.nubes.ru" BASE = "https://atest.pythonk8s.dev.nubes.ru"
COOKIES = {"mode": "polygon", "polygon_stand": "test"} COOKIES = {"mode": "polygon", "polygon_stand": "test"}
@@ -19,8 +21,13 @@ def test_services():
def test_create(svc_id): def test_create(svc_id):
operations_response = requests.get(f"{BASE}/api/operations/{svc_id}", cookies=COOKIES, timeout=10)
assert operations_response.status_code == 200
operations = operations_response.json().get("operations", [])
create_op = next((op for op in operations if op.get("operation") == "create"), None)
assert create_op is not None, f"No create operation for service {svc_id}"
r = requests.post(f"{BASE}/api/test", r = requests.post(f"{BASE}/api/test",
json={"serviceId": svc_id, "operation": "create", "svcOperationId": 18, json={"serviceId": svc_id, "operation": "create", "svcOperationId": create_op["svcOperationId"],
"params": {}, "displayName": f"autotest-smoke-{svc_id}"}, "params": {}, "displayName": f"autotest-smoke-{svc_id}"},
cookies=COOKIES, timeout=30) cookies=COOKIES, timeout=30)
assert r.status_code == 200, f"Create failed: {r.text[:200]}" assert r.status_code == 200, f"Create failed: {r.text[:200]}"
+41
View File
@@ -21,3 +21,44 @@ def test_scenario_name_escape_chain_includes_amp_backslash_quote_doublequote():
".replace(/\"/g,'&quot;');" ".replace(/\"/g,'&quot;');"
) )
assert expected in content assert expected in content
def test_scenario_editor_loads_operations_from_selected_service():
js_path = Path(__file__).resolve().parents[1] / "site" / "static" / "js" / "scenario-form.js"
content = js_path.read_text(encoding="utf-8")
assert "async function loadServiceOperations(svcId)" in content
assert "fetch('/api/operations/' + svcId)" in content
assert "serviceOps.filter(item => item.operation !== 'reconcile')" in content
assert "const allOps = ['create', 'delete', 'modify', 'suspend', 'resume', 'redeploy']" not in content
def test_manual_create_uses_current_service_operation_id():
js_path = Path(__file__).resolve().parents[1] / "site" / "static" / "js" / "operations.js"
content = js_path.read_text(encoding="utf-8")
assert "find(o => o.operation === 'create')" in content
assert "showParams(createOp.svcOperationId, 'create')" in content
assert "showParams(18, 'create')" not in content
def test_scenario_editor_preserves_legacy_and_cloud_instance_references():
js_path = Path(__file__).resolve().parents[1] / "site" / "static" / "js" / "scenario-form.js"
content = js_path.read_text(encoding="utf-8")
assert "_legacyInstanceFallback: s.operation !== 'create'" in content
assert "_showCloud: Boolean(s.instance_uid)" in content
assert "ref === step.instance_uid" in content
assert "s.instance_uid = ref" in content
assert "&& !scenarioEditorState.steps[i]._legacyInstanceFallback" in content
def test_live_scripts_are_not_collected_as_network_tests():
tests_dir = Path(__file__).resolve().parent
user_simulation = (tests_dir / "test_user_simulation.py").read_text(encoding="utf-8")
smoke = (tests_dir / "test_smoke_all_services.py").read_text(encoding="utf-8")
assert "pytest.skip(" in user_simulation
assert "pytestmark = pytest.mark.skip" in smoke
assert "operation_id(sid, \"create\"" in user_simulation
assert "create_op[\"svcOperationId\"]" in smoke
+17 -6
View File
@@ -4,7 +4,10 @@
ручные операции + сценарии + переключения + краевые случаи. ручные операции + сценарии + переключения + краевые случаи.
Запуск: python3 tests/test_user_simulation.py Запуск: python3 tests/test_user_simulation.py
""" """
import time, requests, json, sys import time, requests, json, sys, pytest
if __name__ != "__main__":
pytest.skip("live test against deployed atest service; run this file directly", allow_module_level=True)
BASE = "https://atest.pythonk8s.dev.nubes.ru" BASE = "https://atest.pythonk8s.dev.nubes.ru"
PASS = FAIL = 0 PASS = FAIL = 0
@@ -31,6 +34,14 @@ def post(path, data, cookies=None, code=200):
assert r.status_code == code, f"POST {path}: {r.status_code} {r.text[:100]}" assert r.status_code == code, f"POST {path}: {r.status_code} {r.text[:100]}"
return r.json() if r.text.strip() else {} return r.json() if r.text.strip() else {}
def operation_id(service_id, operation, cookies=None):
operations = get(f"/api/operations/{service_id}", cookies)
match = next((item for item in operations.get("operations", [])
if item.get("operation") == operation), None)
if not match:
raise RuntimeError(f"Operation {operation!r} not found for service {service_id}")
return match["svcOperationId"]
def poll(op_uid, cookies=None, timeout=15): def poll(op_uid, cookies=None, timeout=15):
deadline = time.time() + timeout deadline = time.time() + timeout
while time.time() < deadline: while time.time() < deadline:
@@ -42,7 +53,7 @@ def poll(op_uid, cookies=None, timeout=15):
def create_instance(sid, name=None, cookies=None): def create_instance(sid, name=None, cookies=None):
r = post("/api/test", r = post("/api/test",
{"serviceId": sid, "operation": "create", "svcOperationId": 18, {"serviceId": sid, "operation": "create", "svcOperationId": operation_id(sid, "create", cookies),
"params": {}, "displayName": name or f"autotest-user-{sid}"}, "params": {}, "displayName": name or f"autotest-user-{sid}"},
cookies) cookies)
return r["opUid"], r.get("instanceUid") return r["opUid"], r.get("instanceUid")
@@ -249,7 +260,7 @@ try:
# 9.1 Создать PostgreSQL # 9.1 Создать PostgreSQL
pg_name = f"autotest-pg-{int(time.time())}" pg_name = f"autotest-pg-{int(time.time())}"
r = post("/api/test", r = post("/api/test",
{"serviceId": 90, "operation": "create", "svcOperationId": 19, {"serviceId": 90, "operation": "create", "svcOperationId": operation_id(90, "create"),
"params": {}, "displayName": pg_name}) "params": {}, "displayName": pg_name})
pg_op = r["opUid"] pg_op = r["opUid"]
pg_uid = r.get("instanceUid") pg_uid = r.get("instanceUid")
@@ -260,7 +271,7 @@ try:
# 9.2 Создать пользователя # 9.2 Создать пользователя
user_name = f"testuser_{int(time.time()) % 10000}" user_name = f"testuser_{int(time.time()) % 10000}"
r = post("/api/test", r = post("/api/test",
{"serviceId": 90, "operation": "create_user", "svcOperationId": 241, {"serviceId": 90, "operation": "create_user", "svcOperationId": operation_id(90, "create_user"),
"instanceUid": pg_uid, "instanceUid": pg_uid,
"params": {"733": user_name, "740": "app_user", "1096": "false"}}) "params": {"733": user_name, "740": "app_user", "1096": "false"}})
user_op = r["opUid"] user_op = r["opUid"]
@@ -270,7 +281,7 @@ try:
# 9.3 Создать базу данных # 9.3 Создать базу данных
db_name = f"testdb_{int(time.time()) % 10000}" db_name = f"testdb_{int(time.time()) % 10000}"
r = post("/api/test", r = post("/api/test",
{"serviceId": 90, "operation": "create_database", "svcOperationId": 245, {"serviceId": 90, "operation": "create_database", "svcOperationId": operation_id(90, "create_database"),
"instanceUid": pg_uid, "instanceUid": pg_uid,
"params": {"741": db_name, "742": user_name}}) "params": {"741": db_name, "742": user_name}})
db_op = r["opUid"] db_op = r["opUid"]
@@ -279,7 +290,7 @@ try:
# 9.4 Удалить PostgreSQL # 9.4 Удалить PostgreSQL
r = post("/api/test", r = post("/api/test",
{"serviceId": 90, "operation": "delete", "svcOperationId": 20, {"serviceId": 90, "operation": "delete", "svcOperationId": operation_id(90, "delete"),
"instanceUid": pg_uid, "params": {}}) "instanceUid": pg_uid, "params": {}})
del_op = r["opUid"] del_op = r["opUid"]
result = poll(del_op) result = poll(del_op)