Подробные комментарии ко всем JS-файлам фронтенда
This commit is contained in:
@@ -1,7 +1,19 @@
|
||||
// history.js — история тестов
|
||||
// history.js — история тестов (вид «История»)
|
||||
//
|
||||
// Загружает последние 50 записей из GET /api/history.
|
||||
// Каждая запись — строка таблицы: время, операция, инстанс, статус, длительность.
|
||||
// При клике на строку — раскрываются этапы выполнения (stages).
|
||||
//
|
||||
// Цветовая маркировка:
|
||||
// RUNNING → жёлтый фон (#fef3c7)
|
||||
// FAIL → красный фон (#fef2f2)
|
||||
|
||||
let historyOpen=false;
|
||||
|
||||
/**
|
||||
* Переключить панель истории (открыть/закрыть).
|
||||
* При открытии — автозагрузка через loadHistory().
|
||||
*/
|
||||
async function toggleHistory(){
|
||||
const body=document.getElementById('history-body');
|
||||
historyOpen=!historyOpen;
|
||||
@@ -9,19 +21,34 @@ async function toggleHistory(){
|
||||
if(historyOpen) await loadHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузить и отрисовать историю.
|
||||
*
|
||||
* Формат ответа API: массив объектов:
|
||||
* [{created_at, op_name, display_name, instance_uid, status, duration_sec, stages}, ...]
|
||||
*/
|
||||
async function loadHistory(){
|
||||
const body=document.getElementById('history-body');
|
||||
if(!body||body.style.display==='none') return;
|
||||
try{
|
||||
const r=await fetch('/api/history');
|
||||
const rows=await r.json();
|
||||
if(!rows||!rows.length){body.innerHTML='<span style="color:var(--muted);font-size:11px;">Нет записей</span>'; return;}
|
||||
if(!rows||!rows.length){
|
||||
body.innerHTML='<span style="color:var(--muted);font-size:11px;">Нет записей</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Таблица с фиксированной шириной колонок
|
||||
let html='<table style="width:100%;font-size:11px;table-layout:fixed;">';
|
||||
html+='<tr><th style="padding:2px 6px;width:130px;">Время</th><th style="padding:2px 6px;width:90px;">Операция</th><th style="padding:2px 6px;">Инстанс</th><th style="padding:2px 6px;width:50px;">Статус</th><th style="padding:2px 6px;width:55px;">Длит.</th></tr>';
|
||||
|
||||
rows.forEach((r, i)=>{
|
||||
const time=r.created_at?r.created_at.replace('T',' ').substring(0,19):'?';
|
||||
const cls=r.status==='OK'?'badge-success':r.status==='FAIL'?'badge' :'';
|
||||
const cls=r.status==='OK'?'badge-success':r.status==='FAIL'?'badge':'';
|
||||
// Цвет фона строки: RUNNING → жёлтый, FAIL → красный
|
||||
const stCls=r.status==='RUNNING'?'background:#fef3c7;':r.status==='FAIL'?'background:#fef2f2;':'';
|
||||
|
||||
// Строка таблицы — кликабельна (раскрывает этапы)
|
||||
html+=`<tr style="cursor:pointer;${stCls}" onclick="toggleHistoryRow(${i})">
|
||||
<td style="padding:2px 6px;">${time}</td>
|
||||
<td style="padding:2px 6px;">${_esc(r.op_name||'?')}</td>
|
||||
@@ -29,24 +56,43 @@ async function loadHistory(){
|
||||
<td style="padding:2px 6px;"><span class="badge ${cls}" style="font-size:9px;">${_esc(r.status||'?')}</span></td>
|
||||
<td style="padding:2px 6px;">${r.duration_sec!=null?r.duration_sec.toFixed(1)+'s':'-'}</td>
|
||||
</tr>`;
|
||||
|
||||
// Скрытая строка с этапами (раскрывается по клику)
|
||||
html+=`<tr id="hist-stages-${i}" style="display:none;"><td colspan="5" style="padding:4px 8px;">${renderStages(r.stages)}</td></tr>`;
|
||||
});
|
||||
html+='</table>';
|
||||
body.innerHTML=html;
|
||||
}catch(e){body.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';}
|
||||
}catch(e){
|
||||
body.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Раскрыть/скрыть строку с этапами для записи истории.
|
||||
*
|
||||
* @param {number} i — индекс записи в массиве rows
|
||||
*/
|
||||
function toggleHistoryRow(i) {
|
||||
const el = document.getElementById('hist-stages-' + i);
|
||||
if (!el) return;
|
||||
el.style.display = el.style.display === 'none' ? 'table-row' : 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Отрисовать этапы выполнения для записи истории.
|
||||
*
|
||||
* Каждый этап: иконка (✅/❌/⏳) + название + время + длительность.
|
||||
*
|
||||
* @param {Array} stages — [{stage, dtStart, dtFinish, isSuccessful, duration}, ...]
|
||||
* @returns {string} HTML
|
||||
*/
|
||||
function renderStages(stages) {
|
||||
if (!stages || !stages.length) return '<span style="color:var(--muted);font-size:10px;">Нет данных об этапах</span>';
|
||||
if (!stages || !stages.length)
|
||||
return '<span style="color:var(--muted);font-size:10px;">Нет данных об этапах</span>';
|
||||
|
||||
let html = '<div style="font-size:11px;"><b>Этапы выполнения</b></div>';
|
||||
stages.forEach(s => {
|
||||
const done = !!s.dtFinish;
|
||||
const done = !!s.dtFinish; // этап завершён?
|
||||
const icon = done ? (s.isSuccessful ? '✅' : '❌') : '⏳';
|
||||
const start = s.dtStart ? s.dtStart.replace('T',' ').substring(0,19) : '?';
|
||||
const finish = s.dtFinish ? s.dtFinish.replace('T',' ').substring(0,19) : '...';
|
||||
|
||||
+11
-3
@@ -1,6 +1,14 @@
|
||||
// icons.js — 10 inline-SVG иконок (Lucide, MIT)
|
||||
// stroke="currentColor" — цвет берётся из CSS
|
||||
// Размер: 20x20, stroke-width: 2
|
||||
// icons.js — 10 inline-SVG иконок (Lucide, MIT-лицензия)
|
||||
//
|
||||
// Все иконки используют stroke="currentColor" — цвет берётся из CSS (color).
|
||||
// Размер: 20x20px, stroke-width: 2 (тонкие линии).
|
||||
//
|
||||
// Почему inline-SVG а не <img> или иконочный шрифт:
|
||||
// - Не требует внешних зависимостей (никаких CDN)
|
||||
// - Цвет controlled через CSS (currentColor)
|
||||
// - Не грузит лишних файлов (иконки вшиты в JS)
|
||||
//
|
||||
// Использование: ICONS.play, ICONS.edit, ICONS.trash, ...
|
||||
|
||||
const ICONS = {
|
||||
play: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>',
|
||||
|
||||
+113
-33
@@ -1,4 +1,20 @@
|
||||
// instances.js — список инстансов, выбор сервиса, toggle операций
|
||||
//
|
||||
// Отвечает за:
|
||||
// - Загрузку инстансов выбранного сервиса (GET /api/operations/{svcId})
|
||||
// - Отображение списка инстансов с фильтром autotest-
|
||||
// - Показ доступных операций для выбранного инстанса
|
||||
// - Кнопки операций (modify, delete, suspend, resume, redeploy)
|
||||
//
|
||||
// Глобальные переменные (используются в operations.js и других модулях):
|
||||
// svcInstances[] — текущий список инстансов
|
||||
// selectedInst — UUID выбранного инстанса (или null)
|
||||
// selectedOp — {opId, opName, svcId} текущая выбранная операция
|
||||
// currentSvcId — ID текущего сервиса
|
||||
// currentSvcName — имя текущего сервиса
|
||||
// currentSvcShort — короткое имя (для генерации displayName)
|
||||
// busy — флаг блокировки UI (пока идёт операция)
|
||||
// AUTOTEST_PREFIX — "autotest-" (фильтр "наших" инстансов)
|
||||
|
||||
let svcInstances=[], selectedInst=null, selectedOp=null;
|
||||
let currentSvcId=(window.APP&&window.APP.firstServiceId)||1;
|
||||
@@ -7,32 +23,58 @@ let currentSvcShort='';
|
||||
let busy=false;
|
||||
const AUTOTEST_PREFIX='autotest-';
|
||||
|
||||
/**
|
||||
* Выбрать сервис — загрузить его инстансы и показать в списке.
|
||||
* Вызывается при клике на сервис в левой панели.
|
||||
*
|
||||
* @param {number} svcId — ID сервиса
|
||||
*/
|
||||
async function selectService(svcId){
|
||||
if(busy) return;
|
||||
if(busy) return; // блокировка — идёт операция
|
||||
|
||||
// Сброс предыдущего состояния
|
||||
currentSvcId=svcId;
|
||||
selectedInst=null; selectedOp=null; stopPoll();
|
||||
selectedInst=null;
|
||||
selectedOp=null;
|
||||
stopPoll(); // остановить поллинг предыдущей операции
|
||||
|
||||
// Скрыть панель параметров и этапы
|
||||
document.getElementById('params-area').style.display='none';
|
||||
document.querySelectorAll('.stages-box').forEach(e=>e.remove());
|
||||
|
||||
// Подсветка активного сервиса в списке
|
||||
document.querySelectorAll('.svc-item').forEach(e=>e.classList.remove('active'));
|
||||
const svcEl=document.querySelector(`.svc-item[onclick*="${svcId}"]`);
|
||||
if(svcEl) svcEl.classList.add('active');
|
||||
|
||||
try{
|
||||
const r=await fetch('/api/operations/'+svcId);
|
||||
const d=await r.json();
|
||||
svcInstances=d.instances||[];
|
||||
currentSvcName=d.svc||'';
|
||||
currentSvcShort=d.svcShort||'';
|
||||
const btnSpan=document.querySelector('#create-btn-area span');
|
||||
if(btnSpan) btnSpan.textContent='+ Создать новый инстанс '+(currentSvcName||'сервиса');
|
||||
renderInstances();
|
||||
}catch(e){document.getElementById('inst-list').innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';}
|
||||
// GET /api/operations/{svcId} — возвращает операции + autotest-инстансы
|
||||
const r=await fetch('/api/operations/'+svcId);
|
||||
const d=await r.json();
|
||||
svcInstances=d.instances||[];
|
||||
currentSvcName=d.svc||'';
|
||||
currentSvcShort=d.svcShort||'';
|
||||
// Обновить текст кнопки «Создать»
|
||||
const btnSpan=document.querySelector('#create-btn-area span');
|
||||
if(btnSpan) btnSpan.textContent='+ Создать новый инстанс '+(currentSvcName||'сервиса');
|
||||
renderInstances(); // отрисовать список
|
||||
}catch(e){
|
||||
document.getElementById('inst-list').innerHTML=
|
||||
'<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отрисовать список инстансов в #inst-list.
|
||||
* Каждый инстанс — строка: имя + сервис + статус + created + modified.
|
||||
*/
|
||||
function renderInstances(){
|
||||
let html='';
|
||||
svcInstances.forEach(i=>{
|
||||
// Статус: из нашего поля status (cloud-first) или explainedStatus
|
||||
const status=i.status||i.explainedStatus||'?';
|
||||
const sc=status==='running'?'badge-success':'';
|
||||
// Строка инстанса — кликабельна
|
||||
html+=`<div class="inst-item" onclick="toggleInstance('${i.instanceUid}')" data-iuid="${i.instanceUid}">
|
||||
<span style="display:inline-block;width:220px;">${_esc(i.displayName)}</span>
|
||||
<span style="display:inline-block;width:140px;color:var(--muted);font-size:11px;">${_esc(i.svc||'')}</span>
|
||||
@@ -40,51 +82,89 @@ function renderInstances(){
|
||||
<span style="display:inline-block;width:60px;color:var(--muted);font-size:9px;text-align:right;" title="${i.instanceConfigDtCreated||''}">${relativeTime(i.instanceConfigDtCreated)}</span>
|
||||
<span style="display:inline-block;width:60px;color:var(--muted);font-size:9px;text-align:right;" title="${i.instanceConfigDtModified||''}">${relativeTime(i.instanceConfigDtModified)}</span>
|
||||
</div>`;
|
||||
// Контейнер для кнопок операций (изначально скрыт)
|
||||
html+=`<div class="inst-ops" id="ops-${i.instanceUid}"></div>`;
|
||||
});
|
||||
document.getElementById('inst-list').innerHTML=html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить список инстансов (без смены сервиса).
|
||||
* Вызывается после завершения операции.
|
||||
*/
|
||||
async function refreshInstances(){
|
||||
try{
|
||||
const r=await fetch('/api/operations/'+currentSvcId);
|
||||
const d=await r.json();
|
||||
svcInstances=d.instances||[];
|
||||
renderInstances();
|
||||
const r=await fetch('/api/operations/'+currentSvcId);
|
||||
const d=await r.json();
|
||||
svcInstances=d.instances||[];
|
||||
renderInstances();
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
/**
|
||||
* Раскрыть/скрыть кнопки операций для инстанса.
|
||||
* Загружает доступные операции через GET /api/operations/{svcId}.
|
||||
*
|
||||
* @param {string} iuid — instanceUid
|
||||
*/
|
||||
async function toggleInstance(iuid){
|
||||
if(busy) return;
|
||||
selectedInst=iuid; stopPoll();
|
||||
document.querySelectorAll('[data-iuid]').forEach(e=>e.classList.toggle('active',e.dataset.iuid===iuid));
|
||||
selectedInst=iuid;
|
||||
stopPoll();
|
||||
|
||||
// Подсветка выбранного инстанса
|
||||
document.querySelectorAll('[data-iuid]').forEach(e=>
|
||||
e.classList.toggle('active',e.dataset.iuid===iuid));
|
||||
|
||||
// Скрыть панель параметров (если была открыта для другого инстанса)
|
||||
document.getElementById('params-area').style.display='none';
|
||||
|
||||
const opsEl=document.getElementById('ops-'+iuid);
|
||||
// Если уже открыт — закрыть (toggle)
|
||||
if(opsEl.classList.contains('open')){opsEl.classList.remove('open');return;}
|
||||
|
||||
// Закрыть ВСЕ другие открытые панели операций
|
||||
document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open'));
|
||||
|
||||
// Найти инстанс в списке (может быть из другого сервиса)
|
||||
const inst=svcInstances.find(x=>x.instanceUid===iuid);
|
||||
const svcId=inst?inst.serviceId:currentSvcId;
|
||||
|
||||
try{
|
||||
const r=await fetch('/api/operations/'+svcId);
|
||||
const d=await r.json();
|
||||
let ops=(d.operations||[]).filter(o=>o.operation!=='reconcile'&&o.operation!=='create');
|
||||
if(inst&&(inst.status||inst.explainedStatus)==='not created'){
|
||||
ops=ops.filter(o=>o.operation==='delete');
|
||||
const r=await fetch('/api/operations/'+svcId);
|
||||
const d=await r.json();
|
||||
|
||||
// Фильтруем операции: убираем reconcile (техническая) и create (уже создан)
|
||||
let ops=(d.operations||[]).filter(o=>o.operation!=='reconcile'&&o.operation!=='create');
|
||||
|
||||
// Для not created инстансов — только delete
|
||||
if(inst&&(inst.status||inst.explainedStatus)==='not created'){
|
||||
ops=ops.filter(o=>o.operation==='delete');
|
||||
}
|
||||
|
||||
// Кнопки операций с цветовой маркировкой
|
||||
opsEl.innerHTML=ops.map(o=>
|
||||
`<button class="btn btn-sm op-btn ${opClass(o.operation)}" onclick="runOp('${o.operation}',${o.svcOperationId})">${o.operation}</button>`
|
||||
).join('');
|
||||
opsEl.classList.add('open');
|
||||
}catch(e){
|
||||
opsEl.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';
|
||||
opsEl.classList.add('open');
|
||||
}
|
||||
opsEl.innerHTML=ops.map(o=>
|
||||
`<button class="btn btn-sm op-btn ${opClass(o.operation)}" onclick="runOp('${o.operation}',${o.svcOperationId})">${o.operation}</button>`
|
||||
).join('');
|
||||
opsEl.classList.add('open');
|
||||
}catch(e){opsEl.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';opsEl.classList.add('open');}
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS-класс для кнопки операции (цветовая маркировка).
|
||||
*
|
||||
* @param {string} opName — "modify"/"delete"/"suspend"/...
|
||||
* @returns {string} CSS-класс
|
||||
*/
|
||||
function opClass(opName){
|
||||
if(opName==='modify') return 'op-btn-modify';
|
||||
if(opName==='suspend') return 'op-btn-suspend';
|
||||
if(opName==='resume') return 'op-btn-resume';
|
||||
if(opName==='delete') return 'op-btn-delete';
|
||||
if(opName==='redeploy') return 'op-btn-redeploy';
|
||||
if(opName==='modify') return 'op-btn-modify'; // синий
|
||||
if(opName==='suspend') return 'op-btn-suspend'; // оранжевый
|
||||
if(opName==='resume') return 'op-btn-resume'; // зелёный
|
||||
if(opName==='delete') return 'op-btn-delete'; // красный
|
||||
if(opName==='redeploy') return 'op-btn-redeploy'; // фиолетовый
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
|
||||
+201
-35
@@ -1,119 +1,244 @@
|
||||
// operations.js — запуск операций: create, modify, delete, poll, stages
|
||||
// operations.js — запуск операций: create, modify, delete, поллинг, этапы
|
||||
//
|
||||
// Это основной модуль ручного режима тестирования.
|
||||
// Жизненный цикл операции:
|
||||
// 1. Пользователь выбирает инстанс → жмёт кнопку операции (runOp)
|
||||
// 2. Загружаются параметры с ТЕКУЩИМИ значениями (showParams)
|
||||
// 3. Пользователь правит параметры → жмёт «Запустить» (executeOp)
|
||||
// 4. POST /api/test → возвращает opUid → начинается поллинг (pollTimer)
|
||||
// 5. Каждые 2 секунды GET /api/test/status/{opUid} → этапы + статус
|
||||
// 6. При завершении → обновить список инстансов + историю
|
||||
//
|
||||
// Глобальные переменные (используются из instances.js):
|
||||
// pollTimer — ID setInterval для поллинга
|
||||
|
||||
let pollTimer=null;
|
||||
|
||||
/**
|
||||
* Сгенерировать короткое имя для нового инстанса.
|
||||
* Формат: {svcShort}-{random3} (напр. "dummy-a3f").
|
||||
* random3 — base36 (0-9a-z), 3 символа = 46656 вариантов.
|
||||
*
|
||||
* @returns {string} displayName suffix (без префикса autotest-)
|
||||
*/
|
||||
function makeCreateDisplayName(){
|
||||
const rand = (Math.random() * 46656 | 0).toString(36).padStart(3, '0');
|
||||
return currentSvcShort ? currentSvcShort+'-'+rand : rand;
|
||||
}
|
||||
|
||||
/**
|
||||
* Найти displayName выбранного инстанса.
|
||||
*
|
||||
* @returns {string} displayName или instanceUid (если не найден)
|
||||
*/
|
||||
function findInstName(){
|
||||
const i=svcInstances.find(x=>x.instanceUid===selectedInst);
|
||||
return i?i.displayName:selectedInst;
|
||||
}
|
||||
|
||||
/**
|
||||
* Остановить поллинг текущей операции.
|
||||
*/
|
||||
function stopPoll(){
|
||||
if(pollTimer){clearInterval(pollTimer);pollTimer=null;}
|
||||
}
|
||||
|
||||
/**
|
||||
* Начать создание нового инстанса (кнопка «+ Создать»).
|
||||
*
|
||||
* Загружает операцию create для текущего сервиса через GET /api/operations/{svcId}.
|
||||
* Если операция create не найдена — fallback на svcOperationId=18 (стандартный).
|
||||
*/
|
||||
async function startCreate(){
|
||||
if(busy) return;
|
||||
selectedInst=null; stopPoll();
|
||||
selectedInst=null; // create — нет выбранного инстанса
|
||||
stopPoll();
|
||||
// Снять выделение со всех инстансов
|
||||
document.querySelectorAll('[data-iuid]').forEach(e=>e.classList.remove('active'));
|
||||
document.querySelectorAll('.inst-ops').forEach(e=>e.classList.remove('open'));
|
||||
try{
|
||||
const r=await fetch('/api/operations/'+currentSvcId);
|
||||
const d=await r.json();
|
||||
const createOp=d.operations.find(o=>o.operation==='create');
|
||||
const opId=createOp?createOp.svcOperationId:18;
|
||||
const opId=createOp?createOp.svcOperationId:18; // fallback
|
||||
showParams(opId,'create');
|
||||
}catch(e){
|
||||
showParams(18,'create');
|
||||
showParams(18,'create'); // fallback при ошибке
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запустить операцию над выбранным инстансом (кнопка modify/delete/...).
|
||||
*
|
||||
* @param {string} opName — "modify"/"delete"/"suspend"/"resume"/"redeploy"
|
||||
* @param {number} opId — svcOperationId
|
||||
*/
|
||||
function runOp(opName,opId){
|
||||
if(busy) return;
|
||||
stopPoll();
|
||||
showParams(opId,opName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Показать форму параметров для операции.
|
||||
*
|
||||
* Алгоритм:
|
||||
* 1. Загрузить параметры: GET /api/params/{opId}[?instanceUid=xxx]
|
||||
* - Для create: без instanceUid → шаблонные defaults
|
||||
* - Для не-create: с instanceUid → текущие значения из state.params
|
||||
* 2. Отрисовать форму через renderParamRow (из params-render.js)
|
||||
* 3. Показать кнопку «Запустить»
|
||||
*
|
||||
* @param {number} opId — svcOperationId
|
||||
* @param {string} opName — "create"/"modify"/...
|
||||
*/
|
||||
function showParams(opId,opName){
|
||||
// Сохраняем выбранную операцию
|
||||
selectedOp={opId,opName,svcId:currentSvcId};
|
||||
|
||||
// Показать панель параметров, скрыть старые этапы
|
||||
document.getElementById('params-area').style.display='block';
|
||||
document.querySelectorAll('.stages-box').forEach(e=>e.remove());
|
||||
document.getElementById('test-status').textContent='';
|
||||
|
||||
const isCreate=opName==='create';
|
||||
// Для create — шаблонные значения, для не-create — с instanceUid
|
||||
const qs=isCreate?'' : `?instanceUid=${selectedInst}`;
|
||||
|
||||
fetch('/api/params/'+opId+qs).then(r=>r.json()).then(async data=>{
|
||||
if(data.error){document.getElementById('params-form').innerHTML=`<span style="color:var(--destructive);">Ошибка: ${_esc(data.error)}</span>`;return;}
|
||||
if(data.error){
|
||||
document.getElementById('params-form').innerHTML=
|
||||
`<span style="color:var(--destructive);">Ошибка: ${_esc(data.error)}</span>`;
|
||||
return;
|
||||
}
|
||||
const params=data.params||data;
|
||||
if(!Array.isArray(params)){document.getElementById('params-form').innerHTML=`<span style="color:var(--destructive);">Неверный формат параметров</span>`;return;}
|
||||
if(!Array.isArray(params)){
|
||||
document.getElementById('params-form').innerHTML=
|
||||
`<span style="color:var(--destructive);">Неверный формат параметров</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Если есть refSvcId-параметры — загружаем ВСЕ инстансы (для select'ов)
|
||||
let allInst=[];
|
||||
const needRefs=params.some(p=>p.refSvcId);
|
||||
if(needRefs){
|
||||
try{const r=await fetch('/api/instances/list');const list=await r.json();allInst=list||[];}catch(e){}
|
||||
try{
|
||||
const r=await fetch('/api/instances/list');
|
||||
const list=await r.json();
|
||||
allInst=list||[];
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
const form=document.getElementById('params-form');
|
||||
const displayNameValue=isCreate?makeCreateDisplayName():'';
|
||||
const createHeader=isCreate?`<div style="font-weight:600;font-size:13px;margin-bottom:8px;color:var(--brand-primary);">Создание: ${_esc(currentSvcName)}</div>`:'';
|
||||
const opHeader=!isCreate?`<div style="font-weight:600;font-size:13px;margin-bottom:8px;color:var(--brand-primary);">${_esc(opName)} → ${_esc(findInstName())}</div>`:'';
|
||||
form.innerHTML=createHeader+opHeader+(opName==='create'?`<div class="param-row"><label class="req">displayName</label><span style="font-size:12px;color:var(--muted);white-space:nowrap;">${AUTOTEST_PREFIX}</span><input type="text" id="param-displayname" value="${_esc(displayNameValue)}" autocomplete="off"></div>`:'')
|
||||
|
||||
// Заголовок формы
|
||||
const createHeader=isCreate
|
||||
? `<div style="font-weight:600;font-size:13px;margin-bottom:8px;color:var(--brand-primary);">Создание: ${_esc(currentSvcName)}</div>`
|
||||
: '';
|
||||
const opHeader=!isCreate
|
||||
? `<div style="font-weight:600;font-size:13px;margin-bottom:8px;color:var(--brand-primary);">${_esc(opName)} → ${_esc(findInstName())}</div>`
|
||||
: '';
|
||||
|
||||
// Форма: заголовок + displayName (только create) + параметры
|
||||
form.innerHTML=createHeader+opHeader
|
||||
+ (opName==='create'
|
||||
? `<div class="param-row"><label class="req">displayName</label><span style="font-size:12px;color:var(--muted);white-space:nowrap;">${AUTOTEST_PREFIX}</span><input type="text" id="param-displayname" value="${_esc(displayNameValue)}" autocomplete="off"></div>`
|
||||
: '')
|
||||
+ params.map(p=>renderParamRow(p,allInst)).join('');
|
||||
|
||||
// Кнопка «Запустить»
|
||||
const btn=document.getElementById('btn-test');
|
||||
btn.style.display='inline-flex';
|
||||
btn.textContent='Запустить';
|
||||
btn.onclick=()=>{
|
||||
// Валидация JSON-полей перед отправкой
|
||||
let bad=[];
|
||||
document.querySelectorAll('#params-form [data-type="map"]').forEach(el=>{if(!validateJson(el,true)) bad.push(el);});
|
||||
document.querySelectorAll('#params-form [data-type="map"]').forEach(el=>{
|
||||
if(!validateJson(el,true)) bad.push(el);
|
||||
});
|
||||
if(bad.length>0){
|
||||
document.getElementById('test-status').innerHTML=`<span class="badge">Ошибка</span> ${bad.length} полей с невалидным JSON — поправьте и нажмите Запустить снова`;
|
||||
document.getElementById('test-status').innerHTML=
|
||||
`<span class="badge">Ошибка</span> ${bad.length} полей с невалидным JSON — поправьте и нажмите Запустить снова`;
|
||||
return;
|
||||
}
|
||||
const pp=collectParams();
|
||||
executeOp(pp);
|
||||
const pp=collectParams(); // собрать значения из формы
|
||||
executeOp(pp); // отправить
|
||||
};
|
||||
}).catch(e=>{
|
||||
document.getElementById('params-form').innerHTML=`<span style="color:var(--destructive);">Ошибка загрузки: ${_esc(e.message)}</span>`;
|
||||
document.getElementById('params-form').innerHTML=
|
||||
`<span style="color:var(--destructive);">Ошибка загрузки: ${_esc(e.message)}</span>`;
|
||||
});
|
||||
}
|
||||
|
||||
// renderParamRow, renderMapFixedRow, collectParams — теперь в params-render.js (общий модуль)
|
||||
|
||||
/**
|
||||
* Установить состояние «завершено» — показать результат операции.
|
||||
*
|
||||
* @param {string} statusText — "OK"/"FAIL"/"TIMEOUT"
|
||||
* @param {string} statusClass — CSS-класс бейджа
|
||||
* @param {string} statusError — текст ошибки
|
||||
* @param {number} statusDuration— длительность в секундах
|
||||
* @param {string} instanceName — displayName инстанса
|
||||
*/
|
||||
function setFinishedState(statusText, statusClass, statusError, statusDuration, instanceName){
|
||||
busy=false;
|
||||
busy=false; // разблокировать UI
|
||||
// Закрыть панели операций
|
||||
document.querySelectorAll('.inst-ops.open').forEach(e=>e.classList.remove('open'));
|
||||
const btn=document.getElementById('btn-test');
|
||||
btn.disabled=false;
|
||||
btn.textContent='Готово';
|
||||
btn.onclick=null;
|
||||
btn.onclick=null; // убрать обработчик
|
||||
const opName=selectedOp?.opName||'операция';
|
||||
const durationText=statusDuration!==undefined&&statusDuration!==null?`${statusDuration}s`:'0s';
|
||||
const extra=_esc(statusError||'');
|
||||
const instName=_esc(instanceName||findInstName()||selectedInst||'');
|
||||
document.getElementById('test-status').innerHTML=` <span class="badge ${statusClass}">${statusText}</span> <span style="color:var(--muted);">${opName}</span> <span style="color:var(--muted);">${durationText}</span> <span style="color:var(--muted);">${instName}</span> ${extra}`;
|
||||
document.getElementById('test-status').innerHTML=
|
||||
` <span class="badge ${statusClass}">${statusText}</span> <span style="color:var(--muted);">${opName}</span> <span style="color:var(--muted);">${durationText}</span> <span style="color:var(--muted);">${instName}</span> ${extra}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Установить состояние «выполняется».
|
||||
*/
|
||||
function setRunningState(opName, displayName){
|
||||
const btn=document.getElementById('btn-test');
|
||||
btn.textContent=opName+'...';
|
||||
document.getElementById('test-status').textContent=' ⏳ '+ (displayName||opName) +' выполняется...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить операцию на выполнение (POST /api/test) и начать поллинг.
|
||||
*
|
||||
* Алгоритм:
|
||||
* 1. POST /api/test → {status: "RUNNING", opUid}
|
||||
* 2. Если CMDB-delete → сразу OK (без поллинга)
|
||||
* 3. Иначе → setInterval каждые 2 секунды:
|
||||
* GET /api/test/status/{opUid} → этапы + статус
|
||||
* 4. При OK/FAIL → остановить поллинг, обновить список инстансов и историю
|
||||
* 5. При 5 ошибках подряд → TIMEOUT (связь потеряна)
|
||||
*
|
||||
* @param {Object} params — {numeric_param_id: value}
|
||||
*/
|
||||
async function executeOp(params){
|
||||
busy=true;
|
||||
busy=true; // заблокировать UI
|
||||
stopPoll();
|
||||
|
||||
const isCreate=selectedOp?.opName==='create';
|
||||
const displayNameInput=document.getElementById('param-displayname');
|
||||
const displayNameSuffix=(displayNameInput?.value||'').trim();
|
||||
const displayName=isCreate ? `${AUTOTEST_PREFIX}${displayNameSuffix || makeCreateDisplayName()}` : findInstName();
|
||||
const displayName=isCreate
|
||||
? `${AUTOTEST_PREFIX}${displayNameSuffix || makeCreateDisplayName()}`
|
||||
: findInstName();
|
||||
|
||||
// Подготовка UI
|
||||
document.getElementById('params-area').style.display='block';
|
||||
document.getElementById('params-form').innerHTML='';
|
||||
document.getElementById('params-form').innerHTML=''; // очистить форму
|
||||
const btn=document.getElementById('btn-test');
|
||||
btn.style.display='inline-flex';
|
||||
btn.textContent=selectedOp.opName+'...';
|
||||
btn.disabled=true;
|
||||
setRunningState(selectedOp.opName, displayName);
|
||||
|
||||
// Создать контейнер для этапов (после кнопки create или после панели операций)
|
||||
const instId=selectedInst||'';
|
||||
const afterEl=instId?document.getElementById('ops-'+instId):null;
|
||||
document.querySelectorAll('.stages-box').forEach(e=>e.remove());
|
||||
@@ -122,13 +247,33 @@ async function executeOp(params){
|
||||
stagesBox.style.cssText='max-height:200px;overflow-y:auto;padding:4px 8px;margin-left:16px;background:var(--brand-grey-light);border-radius:4px;';
|
||||
if(afterEl){afterEl.after(stagesBox);}
|
||||
else{document.getElementById('create-btn-area').after(stagesBox);}
|
||||
|
||||
try{
|
||||
const r=await fetch('/api/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
|
||||
serviceId:currentSvcId, operation:selectedOp.opName, svcOperationId:selectedOp.opId,
|
||||
params, instanceUid:selectedInst||'', displayName:isCreate?displayName:''
|
||||
})});
|
||||
// POST /api/test — тело: serviceId, operation, svcOperationId, params, instanceUid, displayName
|
||||
const r=await fetch('/api/test',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({
|
||||
serviceId:currentSvcId,
|
||||
operation:selectedOp.opName,
|
||||
svcOperationId:selectedOp.opId,
|
||||
params,
|
||||
instanceUid:selectedInst||'',
|
||||
displayName:isCreate?displayName:''
|
||||
})
|
||||
});
|
||||
const d=await r.json();
|
||||
if(d.status==='FAIL'){busy=false;document.getElementById('test-status').innerHTML=` <span class="badge">FAIL</span> ${_esc(d.error||'')}`;btn.disabled=false;return;}
|
||||
|
||||
// FAIL на старте — нечего поллить
|
||||
if(d.status==='FAIL'){
|
||||
busy=false;
|
||||
document.getElementById('test-status').innerHTML=
|
||||
` <span class="badge">FAIL</span> ${_esc(d.error||'')}`;
|
||||
btn.disabled=false;
|
||||
return;
|
||||
}
|
||||
|
||||
// CMDB-delete (opUid начинается с "cmdb-") — мгновенный успех
|
||||
if(d.status==='OK'&&(d.opUid||'').startsWith('cmdb-')){
|
||||
busy=false;
|
||||
setFinishedState('OK','badge-success','',0,d.displayName);
|
||||
@@ -136,24 +281,37 @@ async function executeOp(params){
|
||||
if(historyOpen) try{await loadHistory();}catch(e){}
|
||||
return;
|
||||
}
|
||||
|
||||
const opUid=d.opUid;
|
||||
const instanceName=d.displayName||displayName;
|
||||
|
||||
// Поллинг: каждые 2 секунды
|
||||
let _pollErrors=0;
|
||||
pollTimer=setInterval(async()=>{
|
||||
try{
|
||||
const sr=await fetch('/api/test/status/'+opUid);
|
||||
const sd=await sr.json();
|
||||
_pollErrors=0;
|
||||
showStages(sd.stages||[]);
|
||||
_pollErrors=0; // сброс счётчика ошибок
|
||||
showStages(sd.stages||[]); // обновить этапы
|
||||
if(sd.status!=='RUNNING'){
|
||||
stopPoll();
|
||||
setFinishedState(sd.status, sd.status==='OK'?'badge-success':'badge', sd.error||sd.errorLog, sd.duration, sd.displayName||instanceName);
|
||||
await refreshInstances();
|
||||
if(historyOpen) await loadHistory();
|
||||
setFinishedState(
|
||||
sd.status,
|
||||
sd.status==='OK'?'badge-success':'badge',
|
||||
sd.error||sd.errorLog,
|
||||
sd.duration,
|
||||
sd.displayName||instanceName
|
||||
);
|
||||
await refreshInstances(); // обновить список инстансов
|
||||
if(historyOpen) await loadHistory(); // обновить историю
|
||||
}
|
||||
}catch(e){
|
||||
_pollErrors++;
|
||||
if(_pollErrors>=5){stopPoll();setFinishedState('TIMEOUT','','Связь потеряна',0,instanceName);}
|
||||
// 5 ошибок подряд (10 секунд) → считаем связь потерянной
|
||||
if(_pollErrors>=5){
|
||||
stopPoll();
|
||||
setFinishedState('TIMEOUT','','Связь потеряна',0,instanceName);
|
||||
}
|
||||
}
|
||||
},2000);
|
||||
}catch(e){
|
||||
@@ -163,14 +321,22 @@ async function executeOp(params){
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Показать этапы выполнения операции (stages из API).
|
||||
*
|
||||
* Каждый этап: иконка (✅/❌/⏳) + название + длительность.
|
||||
*
|
||||
* @param {Array} stages — [{stage, dtStart, dtFinish, isSuccessful, duration}, ...]
|
||||
*/
|
||||
function showStages(stages){
|
||||
if(!stages||!stages.length) return;
|
||||
let html='<div style="font-size:11px;font-weight:600;color:var(--muted);margin-top:4px;">Этапы</div>';
|
||||
stages.forEach(s=>{
|
||||
const done=!!s.dtFinish;
|
||||
const done=!!s.dtFinish; // этап завершён?
|
||||
const icon=done?(s.isSuccessful?'✅':'❌'):'⏳';
|
||||
html+=`<div style="font-size:11px;padding:2px 0;">${icon} ${_esc(s.stage)} — ${(s.duration||0).toFixed(1)}s</div>`;
|
||||
});
|
||||
// Показать в ПОСЛЕДНЕМ контейнере этапов (самом новом)
|
||||
const boxes=document.querySelectorAll('.stages-box');
|
||||
if(boxes.length) boxes[boxes.length-1].innerHTML=html;
|
||||
}
|
||||
|
||||
@@ -1,70 +1,141 @@
|
||||
// params-render.js — общий рендер параметров операций
|
||||
// Используется: operations.js (ручной режим), scenario-form.js (редактор сценариев)
|
||||
// params-render.js — общий рендер параметров операций (формы ввода)
|
||||
//
|
||||
// Используется в двух местах:
|
||||
// 1. operations.js — ручной режим (форма параметров для modify/create)
|
||||
// 2. scenario-form.js — редактор сценариев (параметры каждого шага)
|
||||
//
|
||||
// Зависимости: _esc() из utils.js, validateJson() из utils.js
|
||||
//
|
||||
// Экспортирует (в глобальную область):
|
||||
// renderParamRow(p, allInst) — отрисовать ОДНУ строку параметра
|
||||
// renderMapFixedRow(p, dfl) — отрисовать map-параметр с фиксированными ключами
|
||||
// collectParams(containerSelector)— собрать значения из формы → {numeric_id: value}
|
||||
|
||||
/**
|
||||
* Отрисовать строку параметра: лейбл + поле ввода (input/select).
|
||||
*
|
||||
* Типы полей:
|
||||
* - valueList → <select> с вариантами
|
||||
* - refSvcId → <select> с инстансами сервиса
|
||||
* - boolean → <select> true/false
|
||||
* - map + dataDescriptor → renderMapFixedRow (вложенные поля)
|
||||
* - map без dataDescriptor → <input> с onblur="validateJson(this)"
|
||||
* - остальное → <input type="text">
|
||||
*
|
||||
* @param {Object} p — параметр из API:
|
||||
* {svcOperationCfsParamId, name, dataType, isRequired, defaultValue, valueList, refSvcId, dataDescriptor}
|
||||
* @param {Array} allInst — все инстансы (нужно для refSvcId-селектов)
|
||||
* @returns {string} HTML-строка
|
||||
*/
|
||||
function renderParamRow(p, allInst) {
|
||||
const req = p.isRequired;
|
||||
const hasDfl = p.defaultValue !== null && p.defaultValue !== undefined && p.defaultValue !== '';
|
||||
// CSS-класс лейбла: req (обязательный+заполнен), req-nodfl (обязательный+пустой)
|
||||
const labelCls = req ? (hasDfl ? 'req' : 'req-nodfl') : '';
|
||||
const dfl = hasDfl ? p.defaultValue : (req ? '' : '');
|
||||
const vl = p.valueList;
|
||||
const isMap = (p.dataType || '').startsWith('map');
|
||||
|
||||
let input;
|
||||
|
||||
if (vl && Array.isArray(vl)) {
|
||||
// Выпадающий список с фиксированными значениями
|
||||
input = `<select name="p_${p.svcOperationCfsParamId}" data-type="${_esc(p.dataType)}">${vl.map(v => `<option value="${_esc(v)}" ${v == dfl ? 'selected' : ''}>${_esc(v)}</option>`).join('')}</select>`;
|
||||
|
||||
} else if (p.refSvcId) {
|
||||
// Ссылка на другой сервис → выпадающий список инстансов этого сервиса
|
||||
const refInsts = allInst.filter(i => i.serviceId === p.refSvcId && i.explainedStatus !== 'deleted');
|
||||
let opts = refInsts.map(i => `<option value="${_esc(i.instanceUid)}" ${i.instanceUid === dfl ? 'selected' : ''}>${_esc(i.displayName)}</option>`).join('');
|
||||
if (!dfl) opts = '<option value="">— выбрать —</option>' + opts;
|
||||
input = `<select name="p_${p.svcOperationCfsParamId}" data-type="ref">${opts}</select>`;
|
||||
|
||||
} else if (p.dataType === 'boolean') {
|
||||
// Булево значение → выпадающий список true/false
|
||||
input = `<select name="p_${p.svcOperationCfsParamId}" data-type="boolean"><option value="true" ${dfl === 'true' || dfl === true ? 'selected' : ''}>true</option><option value="false" ${dfl === 'false' || dfl === false ? 'selected' : ''}>false</option></select>`;
|
||||
|
||||
} else if (p.dataDescriptor && isMap) {
|
||||
// Map с фиксированной структурой → renderMapFixedRow (вложенные поля)
|
||||
return renderMapFixedRow(p, dfl);
|
||||
|
||||
} else {
|
||||
// Обычное текстовое поле (string, integer, map без структуры)
|
||||
const dt = isMap ? 'map' : '';
|
||||
input = `<input type="text" name="p_${p.svcOperationCfsParamId}" value="${_esc(dfl)}" data-type="${dt}" placeholder="${req && !hasDfl ? 'обязательно' : ''}"${isMap ? ' onblur="validateJson(this)"' : ''}>`;
|
||||
if (isMap) input += `<span class="json-err" style="display:none;color:var(--destructive);font-size:10px;margin-left:4px;"></span>`;
|
||||
}
|
||||
|
||||
return `<div class="param-row"><label class="${labelCls}">${_esc(p.name)}</label>${input}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отрисовать map-параметр с ФИКСИРОВАННЫМИ ключами (dataDescriptor).
|
||||
*
|
||||
* Каждый ключ dataDescriptor → отдельная строка с под-лейблом.
|
||||
* Вложенные поля отбиваются вертикальной чертой слева.
|
||||
*
|
||||
* @param {Object} p — параметр с dataDescriptor
|
||||
* @param {string} dfl — текущее значение (JSON-строка)
|
||||
* @returns {string} HTML
|
||||
*/
|
||||
function renderMapFixedRow(p, dfl) {
|
||||
const dd = p.dataDescriptor;
|
||||
const labelCls = p.isRequired ? (dfl ? 'req' : 'req-nodfl') : '';
|
||||
let subHtml = '';
|
||||
// Парсим текущее значение (JSON → объект)
|
||||
let dflObj = {};
|
||||
try { dflObj = JSON.parse(dfl || '{}'); } catch (e) { }
|
||||
|
||||
Object.keys(dd).forEach(subKey => {
|
||||
const sub = dd[subKey];
|
||||
// Значение sub-ключа: из parsed JSON → default → пусто
|
||||
const subDfl = dflObj[subKey] !== undefined ? dflObj[subKey] : (sub.defaultValue || '');
|
||||
const subVl = sub.valueList;
|
||||
const subReq = sub.isRequired;
|
||||
const subNoDfl = subReq && !subDfl && subDfl !== 0 && subDfl !== false;
|
||||
const subLblCls = subNoDfl ? 'req-nodfl' : '';
|
||||
let si;
|
||||
|
||||
if (subVl && Array.isArray(subVl)) {
|
||||
// Выпадающий список
|
||||
si = `<select name="p_${p.svcOperationCfsParamId}.${subKey}" data-type="mapchild">${subVl.map(v => `<option value="${_esc(v)}" ${v == subDfl ? 'selected' : ''}>${_esc(v)}</option>`).join('')}</select>`;
|
||||
} else if (sub.dataType === 'boolean') {
|
||||
// Булево
|
||||
si = `<select name="p_${p.svcOperationCfsParamId}.${subKey}" data-type="mapchild"><option value="true" ${subDfl === 'true' || subDfl === true ? 'selected' : ''}>true</option><option value="false" ${subDfl === 'false' || subDfl === false ? 'selected' : ''}>false</option></select>`;
|
||||
} else {
|
||||
// Текстовое поле
|
||||
si = `<input type="text" name="p_${p.svcOperationCfsParamId}.${subKey}" value="${_esc(subDfl)}" data-type="mapchild">`;
|
||||
}
|
||||
subHtml += `<div class="param-row"><label class="${subLblCls}">${_esc(subKey)}</label>${si}</div>`;
|
||||
});
|
||||
|
||||
// Основной лейбл + вложенные поля с вертикальной чертой
|
||||
return `<div class="param-row"><label class="${labelCls}">${_esc(p.name)}</label></div><div style="border-left:2px solid var(--brand-gray);margin-left:8px;padding:0 0 4px 8px;">${subHtml}</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Собрать значения ВСЕХ полей формы → {numeric_id: string_value}.
|
||||
*
|
||||
* Для mapchild-полей (с точкой в имени) — собирает вложенный объект
|
||||
* и сериализует в JSON.
|
||||
*
|
||||
* @param {string} containerSelector — CSS-селектор контейнера (по умолчанию '#params-form')
|
||||
* @returns {Object} {paramId: value, ...}
|
||||
*/
|
||||
function collectParams(containerSelector) {
|
||||
containerSelector = containerSelector || '#params-form';
|
||||
const pp = {};
|
||||
const nested = {};
|
||||
const pp = {}; // результат: {numeric_id: value}
|
||||
const nested = {}; // временно: {parentId: {subKey: value}} для mapchild
|
||||
const container = document.querySelector(containerSelector);
|
||||
if (!container) return pp;
|
||||
|
||||
// Перебираем ВСЕ элементы с name^="p_"
|
||||
container.querySelectorAll('[name^="p_"]').forEach(el => {
|
||||
let v = el.value;
|
||||
const dt = el.dataset.type || '';
|
||||
|
||||
if (dt === 'mapchild') {
|
||||
// Вложенный параметр: имя вида "p_198.host"
|
||||
// Извлекаем parentId="198", subKey="host"
|
||||
const name = el.name.replace('p_', '');
|
||||
const dot = name.indexOf('.');
|
||||
const parentKey = name.substring(0, dot);
|
||||
@@ -73,10 +144,17 @@ function collectParams(containerSelector) {
|
||||
nested[parentKey][subKey] = v;
|
||||
return;
|
||||
}
|
||||
|
||||
// Тип array: оборачиваем в JSON-массив
|
||||
if (dt === 'array' || dt.startsWith('array')) v = JSON.stringify([v]);
|
||||
// Тип map: пустое значение → '{}'
|
||||
if (dt === 'map' || dt === 'map-fixed') v = v || '{}';
|
||||
|
||||
pp[el.name.replace('p_', '')] = v;
|
||||
});
|
||||
|
||||
// Сериализовать вложенные mapchild в JSON-строки
|
||||
for (const pk in nested) pp[pk] = JSON.stringify(nested[pk]);
|
||||
|
||||
return pp;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
// scenario-create.js — создание и клонирование сценария
|
||||
// scenario-create.js — создание и клонирование сценариев
|
||||
//
|
||||
// createScenario() — открыть редактор для нового сценария
|
||||
// cloneScenario(id) — клонировать существующий (prompt имени → POST /api/scenario/definitions)
|
||||
|
||||
/**
|
||||
* Создать новый сценарий — открывает модальный редактор.
|
||||
*/
|
||||
async function createScenario() {
|
||||
showScenarioEditor(null);
|
||||
showScenarioEditor(null); // null = новый, без определения
|
||||
}
|
||||
|
||||
/**
|
||||
* Клонировать существующий сценарий.
|
||||
*
|
||||
* Алгоритм:
|
||||
* 1. prompt — спросить имя для копии
|
||||
* 2. GET /api/scenario/definitions/{id} — загрузить исходные steps
|
||||
* 3. POST /api/scenario/definitions — создать копию с новым именем
|
||||
* 4. Обновить список сценариев
|
||||
*
|
||||
* @param {number} defId — ID исходного сценария
|
||||
* @param {string} origName — имя исходного (для prompt)
|
||||
*/
|
||||
async function cloneScenario(defId, origName) {
|
||||
const newName = prompt('Имя нового сценария (копия «' + origName + '»):', origName + '_copy');
|
||||
if (!newName) return;
|
||||
@@ -18,7 +36,7 @@ async function cloneScenario(defId, origName) {
|
||||
});
|
||||
const d = await rr.json();
|
||||
if (d.error) { alert(d.error); return; }
|
||||
await loadScenarios();
|
||||
await loadScenarios(); // обновить список
|
||||
} catch (e) {
|
||||
alert('Ошибка клонирования: ' + e.message);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
// scenario-delete.js — удаление сценария
|
||||
// scenario-delete.js — удаление сценария (мягкое, is_active=false)
|
||||
//
|
||||
// deleteScenario(id, name) — confirm → DELETE /api/scenario/definitions/{id} → обновить список
|
||||
|
||||
/**
|
||||
* Удалить сценарий — мягкое удаление (is_active = FALSE).
|
||||
*
|
||||
* @param {number} defId — ID сценария
|
||||
* @param {string} name — имя сценария (для confirm-сообщения)
|
||||
*/
|
||||
async function deleteScenario(defId, name) {
|
||||
if (!confirm('Удалить сценарий «' + name + '»?')) return;
|
||||
try {
|
||||
const r = await fetch('/api/scenario/definitions/' + defId, { method: 'DELETE' });
|
||||
const d = await r.json();
|
||||
if (d.error) { alert(d.error); return; }
|
||||
await loadScenarios();
|
||||
await loadScenarios(); // обновить список
|
||||
} catch (e) {
|
||||
alert('Ошибка: ' + e.message);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
// scenario-edit.js — редактирование существующего сценария
|
||||
//
|
||||
// editScenario(id) — загрузить определение и открыть редактор
|
||||
|
||||
/**
|
||||
* Открыть редактор для существующего сценария.
|
||||
*
|
||||
* Алгоритм:
|
||||
* 1. GET /api/scenario/definitions/{id} — загрузить определение
|
||||
* 2. showScenarioEditor(def) — открыть модал с заполненными полями
|
||||
*
|
||||
* @param {number} defId — ID сценария
|
||||
*/
|
||||
async function editScenario(defId) {
|
||||
try {
|
||||
const r = await fetch('/api/scenario/definitions/' + defId);
|
||||
|
||||
+230
-33
@@ -1,24 +1,52 @@
|
||||
// scenario-form.js — модальный редактор сценариев
|
||||
// Использует: params-render.js (renderParamRow, renderMapFixedRow, collectParams)
|
||||
// Зависит от: utils.js (_esc)
|
||||
//
|
||||
// Это САМЫЙ СЛОЖНЫЙ модуль фронтенда (~358 строк).
|
||||
// Редактор позволяет создавать/редактировать сценарии через модальное окно.
|
||||
//
|
||||
// СОСТОЯНИЕ РЕДАКТОРА: scenarioEditorState = {
|
||||
// defId, version, name, steps: [{service_id, operation, output, instance_ref, instance_uid, params, _svcOpId}],
|
||||
// services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {}
|
||||
// }
|
||||
//
|
||||
// ЖИЗНЕННЫЙ ЦИКЛ РЕДАКТИРОВАНИЯ:
|
||||
// 1. showScenarioEditor(def) — инициализация, загрузка сервисов/инстансов, renderEditor()
|
||||
// 2. renderEditor() — отрисовка модала с шагами
|
||||
// 3. Пользователь меняет операцию → onOpChange → renderEditor (перерисовка)
|
||||
// 4. Для каждого шага с операцией — loadStepParams (GET /api/params/{opId})
|
||||
// 5. Параметры рендерятся через params-render.js
|
||||
// 6. Перед перерисовкой — snapshotAllParams (сохранить несохранённые правки)
|
||||
// 7. Сохранение — collectFormSteps → POST/PUT /api/scenario/definitions
|
||||
//
|
||||
// ОБРАТНЫЙ МАППИНГ ПАРАМЕТРОВ:
|
||||
// В форме параметры хранятся с числовыми ID (как и в API).
|
||||
// В БД сценария — с символьными именами.
|
||||
// При сохранении: numeric_id → name (через paramDefsCache)
|
||||
// При загрузке: name → numeric_id (подстановка в defaultValue)
|
||||
|
||||
let scenarioEditorState = null;
|
||||
// {defId, version, name, steps:[], services:[], cloudInstances:[], opsCache:{}, paramDefsCache:{}}
|
||||
// step: {service_id, operation, output, instance_ref, instance_uid, params:{name:value}}
|
||||
|
||||
/**
|
||||
* Открыть редактор сценария.
|
||||
*
|
||||
* @param {Object|null} def — определение сценария или null (новый)
|
||||
*/
|
||||
async function showScenarioEditor(def) {
|
||||
// Инициализация состояния
|
||||
scenarioEditorState = def ? {
|
||||
defId: def.id, version: def.version, name: def.name,
|
||||
steps: (def.steps || []).map(s => ({
|
||||
service_id: s.service_id || '', operation: s.operation || '',
|
||||
output: s.output || '', instance_ref: s.instance_ref || '',
|
||||
instance_uid: s.instance_uid || '',
|
||||
params: s.params || {} // {name: value} — символические имена
|
||||
params: s.params || {} // {name: value} — символьные имена
|
||||
})),
|
||||
services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {}
|
||||
} : { defId: null, version: 1, name: '', steps: [],
|
||||
services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {} };
|
||||
} : {
|
||||
defId: null, version: 1, name: '', steps: [],
|
||||
services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {}
|
||||
};
|
||||
|
||||
// Загрузить справочные данные: сервисы + инстансы (для select'ов)
|
||||
try {
|
||||
const [svcR, instR] = await Promise.all([
|
||||
fetch('/api/services').then(r => r.json()),
|
||||
@@ -26,15 +54,25 @@ async function showScenarioEditor(def) {
|
||||
]);
|
||||
scenarioEditorState.services = svcR || [];
|
||||
scenarioEditorState.cloudInstances = instR || [];
|
||||
} catch (e) { scenarioEditorState.services = []; scenarioEditorState.cloudInstances = []; }
|
||||
} catch (e) {
|
||||
scenarioEditorState.services = [];
|
||||
scenarioEditorState.cloudInstances = [];
|
||||
}
|
||||
renderEditor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Открыть модальное окно с HTML-контентом.
|
||||
*
|
||||
* @param {string} html — HTML-контент модала
|
||||
*/
|
||||
function openModal(html) {
|
||||
let modal = document.getElementById('scenario-modal');
|
||||
if (!modal) {
|
||||
// Создать модал при первом вызове
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'scenario-modal';
|
||||
// backdrop — закрытие по клику вне модала
|
||||
modal.innerHTML = '<div class="modal-backdrop" onclick="closeModal()"></div><div class="modal-content" id="modal-content"></div>';
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
@@ -42,27 +80,45 @@ function openModal(html) {
|
||||
document.getElementById('modal-content').innerHTML = html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Закрыть модальное окно и обновить список сценариев.
|
||||
*/
|
||||
function closeModal() {
|
||||
const modal = document.getElementById('scenario-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
loadScenarios();
|
||||
loadScenarios(); // обновить список (могли измениться)
|
||||
}
|
||||
|
||||
// ── Снапшот параметров перед ре-рендером ──
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Снапшот параметров — защита от потери правок
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Сохранить параметры ОДНОГО шага перед перерисовкой.
|
||||
*
|
||||
* Зачем: renderEditor() пересоздаёт ВЕСЬ HTML → все несохранённые правки теряются.
|
||||
* Эта функция собирает текущие значения из DOM и сохраняет в step.params.
|
||||
*
|
||||
* @param {number} idx — индекс шага
|
||||
*/
|
||||
function snapshotStepParams(idx) {
|
||||
const st = scenarioEditorState;
|
||||
const container = document.getElementById('step-' + idx + '-params');
|
||||
if (!container || !container.innerHTML) return;
|
||||
|
||||
// Собрать значения из формы (числовые ID → значение)
|
||||
const numericParams = collectParams('#step-' + idx + '-params');
|
||||
const defs = st.paramDefsCache[st.steps[idx]._svcOpId];
|
||||
if (!defs) return;
|
||||
// Обратный маппинг: numericId → name
|
||||
|
||||
// Обратный маппинг: numeric_id → name
|
||||
const idToName = {};
|
||||
defs.forEach(p => { idToName[p.svcOperationCfsParamId] = p.name; });
|
||||
|
||||
const result = {};
|
||||
for (const [id, val] of Object.entries(numericParams)) {
|
||||
const name = idToName[parseInt(id)] || id;
|
||||
// Сохраняем только НЕ-дефолтные значения
|
||||
if (val !== '' && val !== '{}' && val !== '[]' && val !== 'false') {
|
||||
result[name] = val;
|
||||
}
|
||||
@@ -70,37 +126,60 @@ function snapshotStepParams(idx) {
|
||||
st.steps[idx].params = result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Снапшот ВСЕХ шагов — вызывается перед ЛЮБОЙ перерисовкой.
|
||||
*/
|
||||
function snapshotAllParams() {
|
||||
if (!scenarioEditorState) return;
|
||||
scenarioEditorState.steps.forEach((_, idx) => snapshotStepParams(idx));
|
||||
}
|
||||
|
||||
// ── Резолв операции + загрузка параметров ──
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Резолв операции + загрузка параметров
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Найти svcOperationId для операции в шаге.
|
||||
*
|
||||
* Для create: использует service_id шага.
|
||||
* Для не-create: определяет service_id через выбранный инстанс
|
||||
* (облачный UUID → serviceId, или output-ref → service_id предыдущего create-шага).
|
||||
*
|
||||
* @param {number} idx — индекс шага
|
||||
* @returns {number|null} svcOperationId или null
|
||||
*/
|
||||
async function resolveStepSvcOpId(idx) {
|
||||
const st = scenarioEditorState;
|
||||
const step = st.steps[idx];
|
||||
const op = step.operation;
|
||||
if (!op) return null;
|
||||
|
||||
let svcId = step.service_id;
|
||||
|
||||
if (op !== 'create') {
|
||||
// Определить svcId через выбранный инстанс
|
||||
// Для не-create: определяем svcId через выбранный инстанс
|
||||
const refEl = document.getElementById('step-' + idx + '-ref');
|
||||
const ref = (refEl?.value || '').trim();
|
||||
if (ref) {
|
||||
// Проверяем: это облачный UUID?
|
||||
const cloud = (st.cloudInstances || []).find(i => i.instanceUid === ref);
|
||||
if (cloud) { svcId = cloud.serviceId; }
|
||||
else {
|
||||
// output-ref: ищем в предыдущих create-шагах
|
||||
if (cloud) {
|
||||
svcId = cloud.serviceId;
|
||||
} else {
|
||||
// Это output-ref — ищем в предыдущих create-шагах
|
||||
for (let i = 0; i < idx; i++) {
|
||||
if (st.steps[i].output === ref) { svcId = st.steps[i].service_id; break; }
|
||||
if (st.steps[i].output === ref) {
|
||||
svcId = st.steps[i].service_id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!svcId) return null;
|
||||
|
||||
// Кеш операций
|
||||
// Кеш операций сервиса
|
||||
if (!st.opsCache[svcId]) {
|
||||
try {
|
||||
const r = await fetch('/api/operations/' + svcId);
|
||||
@@ -113,6 +192,17 @@ async function resolveStepSvcOpId(idx) {
|
||||
return found ? found.svcOperationId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузить и отрисовать параметры для шага.
|
||||
*
|
||||
* Алгоритм:
|
||||
* 1. resolveStepSvcOpId — найти ID операции
|
||||
* 2. GET /api/params/{svcOpId} — шаблон параметров
|
||||
* 3. Подставить сохранённые значения (step.params) в defaultValue
|
||||
* 4. Отрисовать через renderParamRow
|
||||
*
|
||||
* @param {number} idx — индекс шага
|
||||
*/
|
||||
async function loadStepParams(idx) {
|
||||
const st = scenarioEditorState;
|
||||
const step = st.steps[idx];
|
||||
@@ -120,7 +210,7 @@ async function loadStepParams(idx) {
|
||||
|
||||
const svcOpId = await resolveStepSvcOpId(idx);
|
||||
if (!svcOpId) return;
|
||||
step._svcOpId = svcOpId;
|
||||
step._svcOpId = svcOpId; // сохраняем для обратного маппинга при сохранении
|
||||
|
||||
// Кеш параметров
|
||||
if (!st.paramDefsCache[svcOpId]) {
|
||||
@@ -140,29 +230,47 @@ async function loadStepParams(idx) {
|
||||
try { const r = await fetch('/api/instances/list'); allInst = await r.json(); } catch (e) {}
|
||||
}
|
||||
|
||||
// Рендер: клон def с подстановкой сохранённых значений в defaultValue
|
||||
// Рендер: клонируем def и подставляем сохранённые значения в defaultValue
|
||||
const container = document.getElementById('step-' + idx + '-params');
|
||||
if (!container) return;
|
||||
let html = '';
|
||||
defs.forEach(p => {
|
||||
const clone = Object.assign({}, p);
|
||||
const savedVal = savedParams[p.name];
|
||||
const clone = Object.assign({}, p); // shallow copy
|
||||
const savedVal = savedParams[p.name]; // ищем по символическому имени
|
||||
if (savedVal !== undefined) clone.defaultValue = savedVal;
|
||||
html += renderParamRow(clone, allInst);
|
||||
});
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── Рендер ──
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Рендер редактора
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Главная функция рендера — перерисовывает ВЕСЬ модал.
|
||||
*
|
||||
* Вызывается при:
|
||||
* - Открытии редактора
|
||||
* - Изменении операции (onOpChange)
|
||||
* - Добавлении/удалении/перемещении шагов
|
||||
* - Переключении облачных инстансов
|
||||
*/
|
||||
function renderEditor() {
|
||||
snapshotAllParams(); // сохранить несохранённые правки перед перерисовкой
|
||||
const st = scenarioEditorState;
|
||||
|
||||
let html = '<div style="padding:20px;max-height:90vh;overflow-y:auto;">';
|
||||
|
||||
// Заголовок
|
||||
html += '<h3 style="margin:0 0 12px;">' + (st.defId ? 'Редактирование' : 'Новый сценарий') + '</h3>';
|
||||
|
||||
// Поле названия
|
||||
html += '<div style="margin-bottom:10px;"><label style="font-size:12px;font-weight:600;">Название</label><br>';
|
||||
html += `<input type="text" id="scenario-name" value="${_esc(st.name)}" style="width:100%;padding:6px;font-size:13px;" placeholder="my_test">`;
|
||||
html += '</div>';
|
||||
|
||||
// Справка «Как заполнять»
|
||||
html += `<details style="margin-bottom:10px;font-size:11px;color:var(--muted);border:1px solid var(--brand-gray);border-radius:4px;padding:6px 8px;">
|
||||
<summary style="cursor:pointer;font-weight:600;">📖 Как заполнять</summary>
|
||||
<div style="margin-top:6px;line-height:1.6;">
|
||||
@@ -173,25 +281,40 @@ function renderEditor() {
|
||||
<b>[↑][↓]</b> — порядок, <b>[✕]</b> — удалить.
|
||||
</div>
|
||||
</details>`;
|
||||
|
||||
// Шаги
|
||||
html += '<div style="font-size:12px;font-weight:600;margin-bottom:6px;">Шаги</div>';
|
||||
st.steps.forEach((s, idx) => { html += renderStepRow(idx, s); });
|
||||
|
||||
// Кнопка добавления шага
|
||||
html += `<button class="btn btn-sm" style="margin-top:6px;" onclick="addStep()">+ Добавить шаг</button>`;
|
||||
|
||||
// Кнопки Сохранить / Отмена
|
||||
html += '<div style="margin-top:14px;display:flex;gap:8px;">';
|
||||
html += `<button class="btn btn-sm" style="background:var(--brand-primary);color:#fff;padding:6px 16px;" onclick="saveScenario()">Сохранить</button>`;
|
||||
html += `<button class="btn btn-sm" style="padding:6px 16px;" onclick="closeModal()">Отмена</button>`;
|
||||
html += '</div></div>';
|
||||
|
||||
openModal(html);
|
||||
|
||||
// Асинхронно загрузить параметры для шагов с выбранной операцией
|
||||
st.steps.forEach((s, idx) => { if (s.operation) loadStepParams(idx); });
|
||||
}
|
||||
|
||||
/**
|
||||
* Отрисовать ОДНУ строку шага в редакторе.
|
||||
*
|
||||
* @param {number} idx — индекс шага
|
||||
* @param {Object} step — {service_id, operation, output, instance_ref, instance_uid, params}
|
||||
* @returns {string} HTML
|
||||
*/
|
||||
function renderStepRow(idx, step) {
|
||||
const st = scenarioEditorState;
|
||||
const op = step.operation;
|
||||
const out = step.output || '';
|
||||
const ref = step.instance_ref || '';
|
||||
|
||||
// Собрать доступные output-ы из предыдущих create-шагов (для instance_ref select)
|
||||
const prevOutputs = [];
|
||||
for (let i = 0; i < idx; i++) {
|
||||
const s = st.steps[i];
|
||||
@@ -199,33 +322,47 @@ function renderStepRow(idx, step) {
|
||||
}
|
||||
|
||||
let html = `<div style="border:1px solid var(--brand-gray);border-radius:6px;padding:8px;margin:6px 0;" id="step-${idx}">`;
|
||||
|
||||
// Заголовок шага: номер + кнопки [↑][↓][✕]
|
||||
html += `<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">`;
|
||||
html += `<span style="font-size:11px;font-weight:600;">Шаг ${idx+1}</span>`;
|
||||
// Кнопка вверх (кроме первого)
|
||||
if (idx > 0) html += `<button class="btn btn-sm" style="font-size:9px;padding:0 4px;" onclick="moveStep(${idx},-1);renderEditor();">↑</button>`;
|
||||
// Кнопка вниз (кроме последнего)
|
||||
if (idx < st.steps.length - 1) html += `<button class="btn btn-sm" style="font-size:9px;padding:0 4px;" onclick="moveStep(${idx},1);renderEditor();">↓</button>`;
|
||||
// Кнопка удаления
|
||||
html += `<button class="btn btn-sm" style="font-size:9px;padding:0 4px;margin-left:auto;color:var(--destructive);" onclick="removeStep(${idx})">✕</button>`;
|
||||
html += `</div>`;
|
||||
|
||||
// Выпадающий список операций
|
||||
const allOps = ['create', 'delete', 'modify', 'suspend', 'resume', 'redeploy'];
|
||||
let opOpts = '<option value="">— операция —</option>';
|
||||
allOps.forEach(o => { opOpts += `<option value="${o}" ${o == op ? 'selected' : ''}>${o}</option>`; });
|
||||
html += `<div style="margin-bottom:4px;"><select id="step-${idx}-op" onchange="onOpChange(${idx})" style="padding:4px;font-size:12px;">${opOpts}</select></div>`;
|
||||
|
||||
// Разное содержимое в зависимости от типа операции
|
||||
if (op === 'create') {
|
||||
// CREATE: выбор сервиса + output name
|
||||
let svcOpts = '<option value="">— сервис —</option>';
|
||||
st.services.forEach(s => {
|
||||
svcOpts += `<option value="${s.svcId}" ${s.svcId == step.service_id ? 'selected' : ''}>${s.svcId}. ${_esc(s.svc)}</option>`;
|
||||
});
|
||||
html += `<div style="display:flex;gap:6px;align-items:center;"><select id="step-${idx}-svc" style="padding:4px;font-size:12px;">${svcOpts}</select></div>`;
|
||||
html += `<div style="margin-top:4px;"><input type="text" id="step-${idx}-output" value="${_esc(out)}" placeholder="имя для ссылок (например: d1)" style="width:220px;padding:3px;font-size:11px;"></div>`;
|
||||
|
||||
} else if (op) {
|
||||
// НЕ-CREATE: выбор инстанса (из create-шагов + облако)
|
||||
let refOpts = '<option value="">— инстанс —</option>';
|
||||
prevOutputs.forEach(o => { refOpts += `<option value="${o}" ${o == ref ? 'selected' : ''}>🆕 ${_esc(o)} (из шага)</option>`; });
|
||||
// Инстансы из предыдущих create-шагов
|
||||
prevOutputs.forEach(o => {
|
||||
refOpts += `<option value="${o}" ${o == ref ? 'selected' : ''}>🆕 ${_esc(o)} (из шага)</option>`;
|
||||
});
|
||||
// Облачные autotest-* инстансы (только если раскрыты)
|
||||
if (step._showCloud && st.cloudInstances) {
|
||||
refOpts += '<option disabled>── облако ──</option>';
|
||||
st.cloudInstances.forEach(i => {
|
||||
const dn = i.displayName || '';
|
||||
// Только autotest-*, не deleted
|
||||
if (!dn.startsWith('autotest-') || i.explainedStatus === 'deleted') return;
|
||||
const sel = i.instanceUid == ref ? 'selected' : '';
|
||||
refOpts += `<option value="${i.instanceUid}" ${sel}>☁ ${_esc(dn)} — ${_esc(i.svc||'')} (${_esc(i.explainedStatus||'?')})</option>`;
|
||||
@@ -236,53 +373,88 @@ function renderStepRow(idx, step) {
|
||||
html += `<button class="btn btn-sm" style="font-size:10px;padding:0 6px;" onclick="toggleCloudInstances(${idx})">${btnLabel}</button></div>`;
|
||||
}
|
||||
|
||||
// Параметры — свёрнутый <details>, заполняется асинхронно в loadStepParams
|
||||
// Параметры — свёрнутый <details>, заполняется асинхронно в loadStepParams()
|
||||
html += `<details style="margin-top:4px;" id="step-${idx}-params-box"><summary style="font-size:11px;cursor:pointer;">Параметры</summary><div id="step-${idx}-params" style="margin-top:4px;"></div></details>`;
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
// ── Действия ──
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Действия редактора
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Изменение операции в выпадающем списке → сброс параметров + перерисовка.
|
||||
*/
|
||||
function onOpChange(idx) {
|
||||
scenarioEditorState.steps[idx].params = {};
|
||||
scenarioEditorState.steps[idx]._svcOpId = null;
|
||||
scenarioEditorState.steps[idx].params = {}; // сброс сохранённых параметров
|
||||
scenarioEditorState.steps[idx]._svcOpId = null; // сброс кеша svcOpId
|
||||
renderEditor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключить показ облачных инстансов в select'е.
|
||||
*/
|
||||
function toggleCloudInstances(idx) {
|
||||
snapshotAllParams();
|
||||
scenarioEditorState.steps[idx]._showCloud = !scenarioEditorState.steps[idx]._showCloud;
|
||||
renderEditor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавить новый (пустой) шаг.
|
||||
*/
|
||||
function addStep() {
|
||||
snapshotAllParams();
|
||||
scenarioEditorState.steps.push({ service_id: '', operation: '', output: '', instance_ref: '', instance_uid: '', params: {}, _showCloud: false });
|
||||
scenarioEditorState.steps.push({
|
||||
service_id: '', operation: '', output: '', instance_ref: '',
|
||||
instance_uid: '', params: {}, _showCloud: false
|
||||
});
|
||||
renderEditor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить шаг по индексу.
|
||||
*/
|
||||
function removeStep(idx) {
|
||||
snapshotAllParams();
|
||||
scenarioEditorState.steps.splice(idx, 1);
|
||||
renderEditor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Переместить шаг вверх/вниз.
|
||||
*
|
||||
* @param {number} idx — индекс шага
|
||||
* @param {number} dir — -1 (вверх) или +1 (вниз)
|
||||
*/
|
||||
function moveStep(idx, dir) {
|
||||
snapshotAllParams();
|
||||
const steps = scenarioEditorState.steps;
|
||||
const target = idx + dir;
|
||||
if (target < 0 || target >= steps.length) return;
|
||||
// Swap
|
||||
[steps[idx], steps[target]] = [steps[target], steps[idx]];
|
||||
renderEditor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Собрать ВСЕ шаги из DOM для сохранения.
|
||||
*
|
||||
* Для каждого шага:
|
||||
* - Извлечь service_id (из select для create, из инстанса для не-create)
|
||||
* - Извлечь output/instance_ref/instance_uid
|
||||
* - Собрать параметры через collectParams + обратный маппинг numeric→name
|
||||
*
|
||||
* @returns {{name: string, steps: Array}}
|
||||
*/
|
||||
function collectFormSteps() {
|
||||
snapshotAllParams();
|
||||
snapshotAllParams(); // финальный снапшот
|
||||
const st = scenarioEditorState;
|
||||
const name = (document.getElementById('scenario-name')?.value || '').trim();
|
||||
const steps = [];
|
||||
|
||||
st.steps.forEach((step, idx) => {
|
||||
const opEl = document.getElementById('step-' + idx + '-op');
|
||||
const operation = (opEl?.value || '').trim();
|
||||
@@ -290,21 +462,23 @@ function collectFormSteps() {
|
||||
const s = { operation, params: {} };
|
||||
|
||||
if (operation === 'create') {
|
||||
// CREATE: service_id из select
|
||||
const svcEl = document.getElementById('step-' + idx + '-svc');
|
||||
svcId = parseInt(svcEl?.value) || 0;
|
||||
const outEl = document.getElementById('step-' + idx + '-output');
|
||||
const output = (outEl?.value || '').trim();
|
||||
if (output) s.output = output;
|
||||
} else {
|
||||
// НЕ-CREATE: определяем instance_ref или instance_uid
|
||||
const refEl = document.getElementById('step-' + idx + '-ref');
|
||||
const ref = (refEl?.value || '').trim();
|
||||
if (ref) {
|
||||
const cloud = (st.cloudInstances || []).find(i => i.instanceUid === ref);
|
||||
if (cloud) {
|
||||
svcId = cloud.serviceId;
|
||||
s.instance_uid = ref;
|
||||
s.instance_uid = ref; // облачный UUID
|
||||
} else {
|
||||
s.instance_ref = ref;
|
||||
s.instance_ref = ref; // output-ссылка
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -318,6 +492,7 @@ function collectFormSteps() {
|
||||
defs.forEach(p => { idToName[p.svcOperationCfsParamId] = p.name; });
|
||||
for (const [id, val] of Object.entries(numericParams)) {
|
||||
const pname = idToName[parseInt(id)] || id;
|
||||
// Сохраняем только НЕ-дефолтные значения
|
||||
if (val !== '' && val !== '{}' && val !== '[]' && val !== 'false') {
|
||||
s.params[pname] = val;
|
||||
}
|
||||
@@ -328,29 +503,51 @@ function collectFormSteps() {
|
||||
return { name, steps };
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить сценарий — POST (новый) или PUT (существующий).
|
||||
*
|
||||
* Валидация перед отправкой:
|
||||
* - Название не пустое
|
||||
* - Хотя бы один шаг
|
||||
* - У каждого шага выбран сервис/инстанс и операция
|
||||
*/
|
||||
async function saveScenario() {
|
||||
const { name, steps } = collectFormSteps();
|
||||
|
||||
// Валидация
|
||||
if (!name) { alert('Введите название'); return; }
|
||||
if (!steps.length) { alert('Добавьте хотя бы один шаг'); return; }
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
if (!steps[i].service_id) { alert('Шаг ' + (i+1) + ': выберите сервис или инстанс'); return; }
|
||||
if (!steps[i].operation) { alert('Шаг ' + (i+1) + ': выберите операцию'); return; }
|
||||
}
|
||||
|
||||
const st = scenarioEditorState;
|
||||
const isNew = !st.defId;
|
||||
const url = isNew ? '/api/scenario/definitions' : '/api/scenario/definitions/' + st.defId;
|
||||
const method = isNew ? 'POST' : 'PUT';
|
||||
// Для PUT нужна version (оптимистичная блокировка)
|
||||
const body = isNew ? { name, steps } : { name, steps, version: st.version };
|
||||
|
||||
try {
|
||||
const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const d = await r.json();
|
||||
|
||||
// 409 Conflict — кто-то уже изменил
|
||||
if (r.status === 409) {
|
||||
alert('⚠️ Конфликт версий — кто-то уже изменил сценарий. Обновите страницу.');
|
||||
return;
|
||||
}
|
||||
if (d.error) { alert(d.error); return; }
|
||||
|
||||
// Обновить состояние (id и version могли измениться)
|
||||
if (isNew && d.id) st.defId = d.id;
|
||||
if (d.version) st.version = d.version;
|
||||
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
alert('Ошибка сохранения: ' + e.message);
|
||||
|
||||
+103
-11
@@ -1,7 +1,19 @@
|
||||
// scenario-list.js — список сценариев, запуск, поллинг
|
||||
//
|
||||
// Отвечает за:
|
||||
// - Загрузку списка сценариев из GET /api/scenarios
|
||||
// - Раскрытие шагов сценария (GET /api/scenario/definitions/{id})
|
||||
// - Запуск сценария (POST /api/scenario/run)
|
||||
// - Поллинг статуса запуска (GET /api/scenario/run/{run_id})
|
||||
//
|
||||
// Цветовая маркировка: RUNNING → жёлтый фон (#fef3c7)
|
||||
|
||||
let scenarioOpen=false, scenarioPollTimer=null;
|
||||
|
||||
/**
|
||||
* Переключить панель сценариев (открыть/закрыть).
|
||||
* При открытии — загружает список + последний запуск.
|
||||
*/
|
||||
async function toggleScenario(){
|
||||
const body=document.getElementById('scenario-body');
|
||||
scenarioOpen=!scenarioOpen;
|
||||
@@ -10,6 +22,13 @@ async function toggleScenario(){
|
||||
await loadScenarios();
|
||||
}
|
||||
|
||||
/**
|
||||
* Загрузить список сценариев + последний запуск.
|
||||
*
|
||||
* Два параллельных запроса:
|
||||
* GET /api/scenarios — список определений [{id, name, steps}, ...]
|
||||
* GET /api/scenario/status — последние 10 запусков [{scenario_name, status, ...}, ...]
|
||||
*/
|
||||
async function loadScenarios(){
|
||||
const body=document.getElementById('scenario-body');
|
||||
try{
|
||||
@@ -17,18 +36,25 @@ async function loadScenarios(){
|
||||
fetch('/api/scenarios').then(r=>r.json()),
|
||||
fetch('/api/scenario/status').then(r=>r.json()),
|
||||
]);
|
||||
|
||||
let html='';
|
||||
|
||||
// Кнопка создания нового сценария
|
||||
html+=`<button class="btn btn-sm" style="font-size:11px;margin-bottom:4px;" onclick="createScenario()">+ Создать сценарий</button>`;
|
||||
|
||||
if(sr.length){
|
||||
// Список сценариев — каждый кликабелен
|
||||
html+='<div style="display:flex;flex-direction:column;gap:4px;margin-bottom:4px;">';
|
||||
sr.forEach(s=>{
|
||||
const seed = s.is_seed;
|
||||
const seed = s.is_seed; // seed-сценарий (из YAML) — нельзя удалить
|
||||
html+=`<div style="border:1px solid var(--brand-gray);border-radius:4px;padding:4px 6px;">`;
|
||||
// Заголовок сценария — клик раскрывает шаги
|
||||
html+=`<div style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:4px 0;" onclick="toggleScenarioSteps(${s.id})">`;
|
||||
html+=`<span style="font-size:13px;font-weight:600;flex:1;">${_esc(s.name)}</span>`;
|
||||
html+=`<span style="color:var(--muted);font-size:11px;min-width:50px;">${s.steps} шаг.</span>`;
|
||||
if(seed) html+=`<span style="font-size:9px;color:var(--muted);">seed</span>`;
|
||||
html+=`</div>`;
|
||||
// Контейнер для раскрытых шагов (изначально скрыт)
|
||||
html+=`<div id="scenario-steps-${s.id}" style="display:none;margin-top:4px;padding-left:8px;"></div>`;
|
||||
html+=`</div>`;
|
||||
});
|
||||
@@ -36,31 +62,53 @@ async function loadScenarios(){
|
||||
}else{
|
||||
html+='<span style="color:var(--muted);font-size:11px;">Нет сценариев в БД</span>';
|
||||
}
|
||||
|
||||
// Последний запуск — фильтруем по текущей версии приложения
|
||||
const curVer=window.APP&&window.APP.version||'';
|
||||
const verHistory=srHistory&&srHistory.filter(r=>r.app_version===curVer);
|
||||
if(verHistory && verHistory.length){
|
||||
const last=verHistory[0];
|
||||
const icon=last.status==='OK'?'✅':last.status==='FAIL'?'❌':last.status==='RUNNING'?'⏳':'⏱';
|
||||
const time=last.created_at?last.created_at.replace('T',' ').substring(0,19):'?';
|
||||
// RUNNING → жёлтый фон
|
||||
const runningStyle = last.status==='RUNNING' ? 'background:#fef3c7;padding:2px 4px;border-radius:3px;' : '';
|
||||
html+=`<div style="font-size:11px;margin-top:4px;color:var(--muted);${runningStyle}">Последний: ${icon} ${_esc(last.scenario_name)} — ${last.status} (шаг ${last.current_step}/${last.total_steps}) ${last.duration_sec!=null?last.duration_sec.toFixed(1)+'s':''} — ${time}</div>`;
|
||||
if(last.error_log) html+=`<div style="font-size:10px;color:var(--destructive);">${_esc(last.error_log)}</div>`;
|
||||
}
|
||||
|
||||
body.innerHTML=html;
|
||||
}catch(e){body.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';}
|
||||
}catch(e){
|
||||
body.innerHTML='<span style="color:var(--destructive);font-size:11px;">Ошибка загрузки</span>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Раскрыть шаги сценария — загрузить полное определение.
|
||||
*
|
||||
* Показывает:
|
||||
* - Список шагов: операция → сервис + параметры
|
||||
* - Кнопки: ▶ Запустить, ✏ Редактировать, 📋 Копировать, 🗑 Удалить
|
||||
*
|
||||
* Закрывает другие раскрытые сценарии (только один открыт одновременно).
|
||||
*
|
||||
* @param {number} defId — ID сценария
|
||||
*/
|
||||
async function toggleScenarioSteps(defId) {
|
||||
const el = document.getElementById('scenario-steps-' + defId);
|
||||
if (!el) return;
|
||||
|
||||
// Если уже открыт — закрыть (toggle)
|
||||
if (el.style.display === 'block') { el.style.display = 'none'; return; }
|
||||
// Загрузить шаги
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/scenario/definitions/' + defId);
|
||||
const def = await r.json();
|
||||
if (def.error) return;
|
||||
|
||||
const steps = def.steps || [];
|
||||
let html = '<div style="font-size:11px;padding:4px 0;">';
|
||||
|
||||
// Перечисляем шаги
|
||||
steps.forEach((s, i) => {
|
||||
const svcName = s.service_id ? 'сервис ' + s.service_id : '?';
|
||||
html += `<div style="padding:2px 0;">${i+1}. <b>${_esc(s.operation)}</b> → ${svcName}`;
|
||||
@@ -71,48 +119,89 @@ async function toggleScenarioSteps(defId) {
|
||||
if (params.length) html += ' <span style="color:var(--muted);">(' + params.map(([k,v]) => k+'='+v).join(', ') + ')</span>';
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
// Кнопки действий
|
||||
html += `<button class="btn btn-sm" style="margin-top:4px;font-size:11px;background:var(--brand-primary);color:#fff;" onclick="event.stopPropagation();runScenario(${defId})">▶ Запустить</button>`;
|
||||
html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();editScenario(${defId})">✏ Редактировать</button>`;
|
||||
html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();cloneScenario(${defId},'${_esc(def.name)}')">📋 Копировать</button>`;
|
||||
// Удалить — только для НЕ-seed сценариев
|
||||
if (!def.is_seed) html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;color:var(--destructive);" onclick="event.stopPropagation();deleteScenario(${defId},'${_esc(def.name)}')">🗑 Удалить</button>`;
|
||||
html += '</div>';
|
||||
|
||||
el.innerHTML = html;
|
||||
el.style.display = 'block';
|
||||
// Закрыть другие
|
||||
document.querySelectorAll('[id^="scenario-steps-"]').forEach(e => { if (e !== el) e.style.display = 'none'; });
|
||||
|
||||
// Закрыть другие раскрытые сценарии
|
||||
document.querySelectorAll('[id^="scenario-steps-"]').forEach(e => {
|
||||
if (e !== el) e.style.display = 'none';
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запустить сценарий.
|
||||
*
|
||||
* Алгоритм:
|
||||
* 1. confirm — подтверждение
|
||||
* 2. POST /api/scenario/run → {status: "RUNNING", run_id}
|
||||
* 3. Поллинг каждые 3 секунды:
|
||||
* GET /api/scenario/run/{run_id} → {status, current_step, total_steps, ...}
|
||||
* 4. При OK/FAIL → остановить поллинг, обновить инстансы и историю
|
||||
*
|
||||
* @param {number} defId — ID определения сценария
|
||||
*/
|
||||
async function runScenario(defId){
|
||||
if(busy){ return; }
|
||||
if(busy){ return; } // другая операция уже идёт
|
||||
if(!confirm('Запустить сценарий?')) return;
|
||||
|
||||
busy=true;
|
||||
stopScenarioPoll();
|
||||
|
||||
const body=document.getElementById('scenario-body');
|
||||
body.innerHTML=`<div style="font-size:11px;">⏳ Запуск...</div>`;
|
||||
|
||||
try{
|
||||
const r=await fetch('/api/scenario/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({definition_id:defId})});
|
||||
const r=await fetch('/api/scenario/run',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({definition_id:defId})
|
||||
});
|
||||
const d=await r.json();
|
||||
if(d.error){ body.innerHTML=`<span style="color:var(--destructive);">Ошибка: ${_esc(d.error)}</span>`; busy=false; return; }
|
||||
|
||||
if(d.error){
|
||||
body.innerHTML=`<span style="color:var(--destructive);">Ошибка: ${_esc(d.error)}</span>`;
|
||||
busy=false;
|
||||
return;
|
||||
}
|
||||
|
||||
const runId=d.run_id;
|
||||
if(!runId){ body.innerHTML=`<span style="color:var(--destructive);">Ошибка: нет run_id в ответе</span>`; busy=false; return; }
|
||||
if(!runId){
|
||||
body.innerHTML=`<span style="color:var(--destructive);">Ошибка: нет run_id в ответе</span>`;
|
||||
busy=false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Поллинг статуса
|
||||
scenarioPollTimer=setInterval(async()=>{
|
||||
try{
|
||||
const sr=await fetch('/api/scenario/run/'+runId);
|
||||
if(!sr.ok){ return; }
|
||||
const last=await sr.json();
|
||||
if(!last||last.error) return;
|
||||
|
||||
const icon=last.status==='OK'?'✅':last.status==='FAIL'?'❌':last.status==='RUNNING'?'⏳':'⏱';
|
||||
const time=last.created_at?last.created_at.replace('T',' ').substring(0,19):'?';
|
||||
const runningBg = last.status==='RUNNING' ? 'background:#fef3c7;padding:2px 4px;border-radius:3px;' : '';
|
||||
|
||||
let html=`<div style="font-size:11px;${runningBg}">${icon} ${_esc(last.scenario_name)} — ${last.status} (шаг ${last.current_step}/${last.total_steps}) ${last.duration_sec!=null?last.duration_sec.toFixed(1)+'s':''} — ${time}</div>`;
|
||||
if(last.error_log) html+=`<div style="font-size:10px;color:var(--destructive);">${_esc(last.error_log)}</div>`;
|
||||
body.innerHTML=html;
|
||||
|
||||
if(last.status!=='RUNNING'){
|
||||
stopScenarioPoll();
|
||||
busy=false;
|
||||
await refreshInstances();
|
||||
if(historyOpen) await loadHistory();
|
||||
await refreshInstances(); // обновить список инстансов
|
||||
if(historyOpen) await loadHistory(); // обновить историю
|
||||
}
|
||||
}catch(e){}
|
||||
},3000);
|
||||
@@ -122,6 +211,9 @@ async function runScenario(defId){
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Остановить поллинг сценария.
|
||||
*/
|
||||
function stopScenarioPoll(){
|
||||
if(scenarioPollTimer){ clearInterval(scenarioPollTimer); scenarioPollTimer=null; }
|
||||
}
|
||||
|
||||
@@ -1,28 +1,47 @@
|
||||
// snackbar.js — лёгкие уведомления (без зависимостей)
|
||||
// showSnackbar(msg, type='info') — type: info, success, error
|
||||
// snackbar.js — лёгкие toast-уведомления (без зависимостей)
|
||||
//
|
||||
// Использование: showSnackbar('Сообщение', 'success')
|
||||
// Типы: 'info' (по умолчанию), 'success', 'error'
|
||||
//
|
||||
// Поведение:
|
||||
// - Показывается в правом нижнем углу (position: fixed)
|
||||
// - Автоматически исчезает: info/success через 4с, error через 8с
|
||||
// - Клик по уведомлению — мгновенное закрытие
|
||||
// - Максимум 4 уведомления одновременно (старые удаляются)
|
||||
|
||||
/**
|
||||
* Показать snackbar-уведомление.
|
||||
*
|
||||
* @param {string} msg — текст уведомления
|
||||
* @param {string} type — 'info' | 'success' | 'error' (по умолчанию 'info')
|
||||
*/
|
||||
function showSnackbar(msg, type) {
|
||||
type = type || 'info';
|
||||
|
||||
// Найти или создать контейнер для всех уведомлений
|
||||
let container = document.getElementById('snackbar-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'snackbar-container';
|
||||
container.className = 'snackbar-container';
|
||||
container.setAttribute('role', 'status');
|
||||
container.setAttribute('aria-live', 'polite');
|
||||
container.setAttribute('role', 'status'); // accessibility
|
||||
container.setAttribute('aria-live', 'polite'); // screen-reader зачитает
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
// Создать элемент уведомления
|
||||
const el = document.createElement('div');
|
||||
el.className = 'snackbar ' + type;
|
||||
el.textContent = msg;
|
||||
// Клик — закрыть
|
||||
el.onclick = function() { el.remove(); };
|
||||
container.appendChild(el);
|
||||
|
||||
// Limit stacking
|
||||
// Ограничение: максимум 4 уведомления
|
||||
const all = container.querySelectorAll('.snackbar');
|
||||
if (all.length > 4) all[0].remove();
|
||||
if (all.length > 4) all[0].remove(); // удаляем самое старое
|
||||
|
||||
// Auto-hide: info/success 4s, error 8s
|
||||
// Автоматическое скрытие: error — 8 секунд, остальные — 4
|
||||
const delay = (type === 'error') ? 8000 : 4000;
|
||||
setTimeout(function() {
|
||||
if (el.parentNode) el.remove();
|
||||
|
||||
+58
-7
@@ -1,42 +1,86 @@
|
||||
// utils.js — общие хелперы: _esc, busy lock, validateJson, лог-панель
|
||||
// utils.js — общие хелперы фронтенда
|
||||
// Используется ВСЕМИ модулями (должен грузиться ПЕРВЫМ).
|
||||
// Экспортирует (в глобальную область): _esc, validateJson, relativeTime,
|
||||
// startLogPoll, toggleLog
|
||||
|
||||
/**
|
||||
* HTML-экранирование — защита от XSS.
|
||||
* Все данные из API/БД пропускаются через _esc() перед вставкой в innerHTML.
|
||||
*
|
||||
* @param {*} s — что угодно (приводится к строке через String())
|
||||
* @returns {string} безопасная для HTML строка
|
||||
*
|
||||
* Заменяет:
|
||||
* & → & (первым! иначе сломает уже заменённые сущности)
|
||||
* " → " (атрибуты в кавычках)
|
||||
* < → < (XSS-вектор: <script>)
|
||||
*
|
||||
* > и ' НЕ экранируем — в нашем контексте они безопасны,
|
||||
* а лишние замены портят читаемость.
|
||||
*/
|
||||
function _esc(s){
|
||||
/* HTML-экранирование: " → " & → & < → < */
|
||||
return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<');
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить что значение в поле — валидный JSON.
|
||||
* Используется для map-параметров перед отправкой формы.
|
||||
*
|
||||
* @param {HTMLElement} el — input/textarea элемент
|
||||
* @param {boolean} quiet — true = не менять внешний вид (batch-проверка)
|
||||
* @returns {boolean} true если JSON валидный или поле пустое
|
||||
*/
|
||||
function validateJson(el,quiet){
|
||||
/* Проверить что значение в поле — валидный JSON.
|
||||
quiet=true — не менять внешний вид (для batch-проверки перед отправкой). */
|
||||
// Ищем соседний span.json-err для показа ошибки
|
||||
const errEl=el.parentElement.querySelector('.json-err');
|
||||
const v=el.value.trim();
|
||||
// Пустое поле — ок (не ошибка)
|
||||
if(!v) return true;
|
||||
try{ JSON.parse(v); }
|
||||
catch(e){
|
||||
// Показываем ошибку в span.json-err (если не quiet)
|
||||
if(!quiet&&errEl){ errEl.style.display='inline'; errEl.textContent='Ошибка JSON: '+e.message; }
|
||||
el.style.borderColor='var(--destructive)';
|
||||
return false;
|
||||
}
|
||||
// Всё ок — убираем ошибку
|
||||
if(!quiet&&errEl) errEl.style.display='none';
|
||||
el.style.borderColor='';
|
||||
return true;
|
||||
}
|
||||
|
||||
// === Панель логов ===
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// Панель логов — поллинг /api/log каждые 2 секунды
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
||||
let logPollTimer=null;
|
||||
|
||||
/**
|
||||
* Запустить поллинг логов.
|
||||
* Работает только если элемент #log-panel существует и видим.
|
||||
* Безопасен для повторного вызова — не дублирует таймер.
|
||||
*/
|
||||
function startLogPoll(){
|
||||
if(logPollTimer) return;
|
||||
if(logPollTimer) return; // уже запущен
|
||||
logPollTimer=setInterval(async()=>{
|
||||
const el=document.getElementById('log-panel');
|
||||
// Если панель скрыта — не грузим (экономия трафика)
|
||||
if(!el||el.style.display==='none') return;
|
||||
try{
|
||||
const r=await fetch('/api/log');
|
||||
const lines=await r.json();
|
||||
// Каждая строка логирования в отдельном div, экранирована
|
||||
el.innerHTML=lines.map(l=>`<div>${_esc(l)}</div>`).join('');
|
||||
// Автопрокрутка вниз (следим за новыми записями)
|
||||
el.scrollTop=el.scrollHeight;
|
||||
}catch(e){}
|
||||
},2000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключить видимость лог-панели.
|
||||
* При открытии — запускает поллинг.
|
||||
*/
|
||||
function toggleLog(){
|
||||
const el=document.getElementById('log-panel');
|
||||
if(!el) return;
|
||||
@@ -44,12 +88,19 @@ function toggleLog(){
|
||||
else el.style.display='none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Относительное время — "5м", "2ч 30м", "1д 3ч".
|
||||
* Используется в списке инстансов (instances.js) и истории (history.js).
|
||||
*
|
||||
* @param {string} iso — ISO-8601 дата (напр. "2026-07-31T12:00:00")
|
||||
* @returns {string} относительное время или "-" если невалидно
|
||||
*/
|
||||
function relativeTime(iso) {
|
||||
if (!iso) return '-';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '-';
|
||||
const sec = Math.floor((Date.now() - d.getTime()) / 1000);
|
||||
if (sec < 0) return 'только что';
|
||||
if (sec < 0) return 'только что'; // будущее (расхождение часов)
|
||||
if (sec < 60) return sec + 'с';
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return min + 'м';
|
||||
|
||||
+33
-6
@@ -1,30 +1,57 @@
|
||||
// views.js — переключение видов (sidebar navigation)
|
||||
// views.js — переключение видов (Консоль / Обзор / История / Сценарии / Логи)
|
||||
//
|
||||
// Каждый вид — div.view с id="view-{name}".
|
||||
// Активный вид — без класса .hidden.
|
||||
// Все остальные — с классом .hidden (display: none).
|
||||
//
|
||||
// Переключение:
|
||||
// switchView('overview') — Обзор (инфраструктура)
|
||||
// switchView('console') — Консоль (ручной режим)
|
||||
// switchView('history-view') — История (автозагрузка при открытии)
|
||||
// switchView('scenarios-view') — Сценарии (автозагрузка)
|
||||
// switchView('logs-view') — Логи (специальная обработка)
|
||||
|
||||
/**
|
||||
* Переключиться на вид по его ID.
|
||||
*
|
||||
* @param {string} viewId — суффикс после "view-" (напр. "console" → #view-console)
|
||||
*/
|
||||
function switchView(viewId) {
|
||||
// Скрыть ВСЕ виды
|
||||
document.querySelectorAll('.view').forEach(v => v.classList.add('hidden'));
|
||||
|
||||
// Показать целевой вид
|
||||
const target = document.getElementById('view-' + viewId);
|
||||
if (target) target.classList.remove('hidden');
|
||||
// Подсветка активного пункта
|
||||
|
||||
// Подсветка активного пункта в sidebar
|
||||
document.querySelectorAll('.sidebar-item').forEach(el => el.classList.remove('active'));
|
||||
const btn = document.querySelector('.sidebar-item[data-view="' + viewId + '"]');
|
||||
if (btn) btn.classList.add('active');
|
||||
// Автозагрузка при открытии вида
|
||||
|
||||
// ── Автозагрузка при открытии вида ──
|
||||
|
||||
// История: автозагрузка только если ещё не загружена
|
||||
if (viewId === 'history-view' && typeof toggleHistory === 'function') {
|
||||
if (!historyOpen) toggleHistory();
|
||||
}
|
||||
|
||||
// Сценарии: автозагрузка
|
||||
if (viewId === 'scenarios-view' && typeof toggleScenario === 'function') {
|
||||
if (!scenarioOpen) toggleScenario();
|
||||
}
|
||||
|
||||
// Логи: специальная обработка — копируем содержимое нижней панели в вид
|
||||
if (viewId === 'logs-view') {
|
||||
// Показать лог в виде, скрыть нижнюю панель
|
||||
const lp = document.getElementById('log-panel');
|
||||
const lvc = document.getElementById('log-view-content');
|
||||
if (lp && lvc) {
|
||||
// Копируем текущее содержимое лог-панели в вид
|
||||
lvc.innerHTML = lp.innerHTML || '<span style="color:#888;">Логи загружаются...</span>';
|
||||
lp.style.display = 'none';
|
||||
lp.style.display = 'none'; // скрываем нижнюю панель
|
||||
}
|
||||
} else {
|
||||
// Скрыть нижнюю панель на всех видах кроме логов
|
||||
// На всех видах кроме логов — скрываем нижнюю лог-панель
|
||||
const lp = document.getElementById('log-panel');
|
||||
if (lp) lp.style.display = 'none';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user