v1.2.5: parameter editor with auto-load from API + params-render.js

Phase 1: paramDefsCache, step.params as {name:value}
Phase 2: resolveStepSvcOpId() → loadStepParams() via /api/params
Phase 3: <details> in step card, renderParamRow with pre-fill (clone def + saved values)
Phase 4: snapshotStepParams() before re-render, reset on operation change
Phase 5: collectFormSteps via collectParams() + numeric→name reverse mapping

Only scenario-form.js changed. params-render.js, api_test.py, _resolve_params unchanged.
Compatible with existing scenarios (symbolic names in DB).
This commit is contained in:
2026-07-31 12:00:50 +04:00
parent eb517594bc
commit 74c22d2612
2 changed files with 157 additions and 57 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ from routes.api_scenario_run import bp_run as api_scenario_run_bp
from routes.api_scenario_defs import bp_defs as api_scenario_defs_bp from routes.api_scenario_defs import bp_defs as api_scenario_defs_bp
# Версия — меняется при КАЖДОМ изменении кода. Показывается в топбаре UI. # Версия — меняется при КАЖДОМ изменении кода. Показывается в топбаре UI.
VERSION = "1.2.4" VERSION = "1.2.5"
app = Flask(__name__, template_folder="templates", static_folder="static") app = Flask(__name__, template_folder="templates", static_folder="static")
app.config["NUBES_API_ENDPOINT"] = os.getenv("NUBES_API_ENDPOINT", "https://lk-api-gateway-test.ngcloud.ru/api/v1/svc") app.config["NUBES_API_ENDPOINT"] = os.getenv("NUBES_API_ENDPOINT", "https://lk-api-gateway-test.ngcloud.ru/api/v1/svc")
+156 -56
View File
@@ -1,9 +1,10 @@
// scenario-form.js — модальный редактор сценариев // scenario-form.js — модальный редактор сценариев
// Использует: params-render.js (renderParamRow, renderMapFixedRow, collectParams) // Использует: params-render.js (renderParamRow, renderMapFixedRow, collectParams)
// Зависит от: utils.js (_esc), instances.js (currentSvcId) // Зависит от: utils.js (_esc)
let scenarioEditorState = null; // {defId, version, name, steps, services:[], opsCache:{}} let scenarioEditorState = null;
// step: {service_id, operation, output, instance_ref, instance_uid, params: [["k","v"],...]} // {defId, version, name, steps:[], services:[], cloudInstances:[], opsCache:{}, paramDefsCache:{}}
// step: {service_id, operation, output, instance_ref, instance_uid, params:{name:value}}
async function showScenarioEditor(def) { async function showScenarioEditor(def) {
scenarioEditorState = def ? { scenarioEditorState = def ? {
@@ -12,12 +13,12 @@ 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 || '',
params: Object.entries(s.params || {}) params: s.params || {} // {name: value} — символические имена
})), })),
services: [], opsCache: {} services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {}
} : { defId: null, version: 1, name: '', steps: [], services: [], opsCache: {} }; } : { defId: null, version: 1, name: '', steps: [],
services: [], cloudInstances: [], opsCache: {}, paramDefsCache: {} };
// Загрузить список сервисов и облачные инстансы
try { try {
const [svcR, instR] = await Promise.all([ const [svcR, instR] = await Promise.all([
fetch('/api/services').then(r => r.json()), fetch('/api/services').then(r => r.json()),
@@ -47,36 +48,142 @@ function closeModal() {
loadScenarios(); loadScenarios();
} }
// ── Снапшот параметров перед ре-рендером ──
function snapshotStepParams(idx) {
const st = scenarioEditorState;
const container = document.getElementById('step-' + idx + '-params');
if (!container || !container.innerHTML) return;
const numericParams = collectParams('#step-' + idx + '-params');
const defs = st.paramDefsCache[st.steps[idx]._svcOpId];
if (!defs) return;
// Обратный маппинг: numericId → 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;
}
}
st.steps[idx].params = result;
}
function snapshotAllParams() {
if (!scenarioEditorState) return;
scenarioEditorState.steps.forEach((_, idx) => snapshotStepParams(idx));
}
// ── Резолв операции + загрузка параметров ──
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 через выбранный инстанс
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; }
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 (!svcId) return null;
// Кеш операций
if (!st.opsCache[svcId]) {
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);
return found ? found.svcOperationId : null;
}
async function loadStepParams(idx) {
const st = scenarioEditorState;
const step = st.steps[idx];
if (!step.operation) return;
const svcOpId = await resolveStepSvcOpId(idx);
if (!svcOpId) return;
step._svcOpId = svcOpId;
// Кеш параметров
if (!st.paramDefsCache[svcOpId]) {
try {
const r = await fetch('/api/params/' + svcOpId);
const d = await r.json();
st.paramDefsCache[svcOpId] = d.params || d || [];
} catch (e) { return; }
}
const defs = st.paramDefsCache[svcOpId];
const savedParams = step.params || {};
// Подготовить allInst для refSvcId-параметров
let allInst = st.cloudInstances || [];
const needRefs = defs.some(p => p.refSvcId);
if (needRefs && !allInst.length) {
try { const r = await fetch('/api/instances/list'); allInst = await r.json(); } catch (e) {}
}
// Рендер: клон 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];
if (savedVal !== undefined) clone.defaultValue = savedVal;
html += renderParamRow(clone, allInst);
});
container.innerHTML = html;
}
// ── Рендер ──
function renderEditor() { function renderEditor() {
snapshotAllParams(); // сохранить несохранённые правки перед перерисовкой
const st = scenarioEditorState; const st = scenarioEditorState;
let html = '<div style="padding:20px;max-height:90vh;overflow-y:auto;">'; let html = '<div style="padding:20px;max-height:90vh;overflow-y:auto;">';
html += '<h3 style="margin:0 0 12px;">' + (st.defId ? 'Редактирование' : 'Новый сценарий') + '</h3>'; 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 += '<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 += `<input type="text" id="scenario-name" value="${_esc(st.name)}" style="width:100%;padding:6px;font-size:13px;" placeholder="my_test">`;
html += '</div>'; 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;"> 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> <summary style="cursor:pointer;font-weight:600;">📖 Как заполнять</summary>
<div style="margin-top:6px;line-height:1.6;"> <div style="margin-top:6px;line-height:1.6;">
<b>Шаг:</b> выбери <b>операцию</b> → укажи цель.<br> <b>Шаг:</b> выбери <b>операцию</b> → укажи цель.<br>
<b>Create:</b> выбери сервис + задай <b>output</b> (имя, например <code>d1</code>). Другие шаги смогут ссылаться на него.<br> <b>Create:</b> выбери сервис + задай <b>output</b> (имя, например <code>d1</code>).<br>
<b>Остальные операции:</b> выбери инстанс — 🆕 из create-шагов этого сценария или ☁ из облака.<br> <b>Остальные:</b> выбери инстанс — 🆕 из create-шагов или ☁ из облака.<br>
<b>Параметры:</b> key = value (символические коды, как в API).<br> <b>Параметры:</b> разверни «Параметры» — значения загружаются из API автоматически.<br>
&nbsp;&nbsp;Пример: <code>durationMs</code> = <code>5000</code><br> <b>[↑][↓]</b> — порядок, <b>[✕]</b> — удалить.
<b>[↑][↓]</b> — порядок шагов, <b>[✕]</b> — удалить шаг.
</div> </div>
</details>`; </details>`;
// Шаги
html += '<div style="font-size:12px;font-weight:600;margin-bottom:6px;">Шаги</div>'; html += '<div style="font-size:12px;font-weight:600;margin-bottom:6px;">Шаги</div>';
st.steps.forEach((s, idx) => { html += renderStepRow(idx, s); }); st.steps.forEach((s, idx) => { html += renderStepRow(idx, s); });
html += `<button class="btn btn-sm" style="margin-top:6px;" onclick="addStep()">+ Добавить шаг</button>`; html += `<button class="btn btn-sm" style="margin-top:6px;" onclick="addStep()">+ Добавить шаг</button>`;
// Кнопки
html += '<div style="margin-top:14px;display:flex;gap:8px;">'; 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="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 += `<button class="btn btn-sm" style="padding:6px 16px;" onclick="closeModal()">Отмена</button>`;
html += '</div></div>'; html += '</div></div>';
openModal(html); openModal(html);
// Асинхронно загрузить параметры для шагов с выбранной операцией
st.steps.forEach((s, idx) => { if (s.operation) loadStepParams(idx); });
} }
function renderStepRow(idx, step) { function renderStepRow(idx, step) {
@@ -85,7 +192,6 @@ function renderStepRow(idx, step) {
const out = step.output || ''; const out = step.output || '';
const ref = step.instance_ref || ''; const ref = step.instance_ref || '';
// Output refs из предыдущих create-шагов
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];
@@ -100,15 +206,12 @@ 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>`;
// 1. Операция ВСЕГДА первая
const allOps = ['create', 'delete', 'modify', 'suspend', 'resume', 'redeploy']; const allOps = ['create', 'delete', 'modify', 'suspend', 'resume', 'redeploy'];
let opOpts = '<option value="">— операция —</option>'; let opOpts = '<option value="">— операция —</option>';
allOps.forEach(o => { opOpts += `<option value="${o}" ${o == op ? 'selected' : ''}>${o}</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>`; html += `<div style="margin-bottom:4px;"><select id="step-${idx}-op" onchange="onOpChange(${idx})" style="padding:4px;font-size:12px;">${opOpts}</select></div>`;
// 2. В зависимости от операции
if (op === 'create') { if (op === 'create') {
// Сервис
let svcOpts = '<option value="">— сервис —</option>'; let svcOpts = '<option value="">— сервис —</option>';
st.services.forEach(s => { st.services.forEach(s => {
svcOpts += `<option value="${s.svcId}" ${s.svcId == step.service_id ? 'selected' : ''}>${s.svcId}. ${_esc(s.svc)}</option>`; svcOpts += `<option value="${s.svcId}" ${s.svcId == step.service_id ? 'selected' : ''}>${s.svcId}. ${_esc(s.svc)}</option>`;
@@ -116,11 +219,8 @@ function renderStepRow(idx, step) {
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="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>`; 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) { } else if (op) {
// Инстанс — облачные + предыдущие output'ы
let refOpts = '<option value="">— инстанс —</option>'; let refOpts = '<option value="">— инстанс —</option>';
// output'ы из create-шагов
prevOutputs.forEach(o => { refOpts += `<option value="${o}" ${o == ref ? 'selected' : ''}>🆕 ${_esc(o)} (из шага)</option>`; }); prevOutputs.forEach(o => { refOpts += `<option value="${o}" ${o == ref ? 'selected' : ''}>🆕 ${_esc(o)} (из шага)</option>`; });
// Облачные инстансы
if (st.cloudInstances && st.cloudInstances.length) { if (st.cloudInstances && st.cloudInstances.length) {
refOpts += '<option disabled>── облако ──</option>'; refOpts += '<option disabled>── облако ──</option>';
st.cloudInstances.forEach(i => { st.cloudInstances.forEach(i => {
@@ -132,54 +232,49 @@ function renderStepRow(idx, step) {
html += `<div><select id="step-${idx}-ref" style="padding:4px;font-size:12px;max-width:500px;">${refOpts}</select></div>`; html += `<div><select id="step-${idx}-ref" style="padding:4px;font-size:12px;max-width:500px;">${refOpts}</select></div>`;
} }
// Параметры // Параметры — свёрнутый <details>, заполняется асинхронно в loadStepParams
html += `<div style="margin-top:4px;" id="step-${idx}-params">`; 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>`;
const params = step.params || [];
params.forEach(([k, v], pi) => { html += '</div>';
html += `<div style="display:flex;gap:4px;align-items:center;margin:2px 0;">`;
html += `<input type="text" id="step-${idx}-pk-${pi}" value="${_esc(k)}" placeholder="param" style="width:110px;padding:2px;font-size:10px;">`;
html += `<span>=</span>`;
html += `<input type="text" id="step-${idx}-pv-${pi}" value="${_esc(v)}" placeholder="value" style="width:140px;padding:2px;font-size:10px;">`;
html += `<button class="btn btn-sm" style="font-size:9px;padding:0 3px;" onclick="removeParam(${idx},${pi});renderEditor();">✕</button>`;
html += `</div>`;
});
html += `<button class="btn btn-sm" style="font-size:9px;margin-top:2px;" onclick="addParam(${idx});renderEditor();">+ Параметр</button>`;
html += '</div></div>';
return html; return html;
} }
function onOpChange(idx) { renderEditor(); } // ── Действия ──
function onOpChange(idx) {
// Сброс params при смене операции (несовместимы)
scenarioEditorState.steps[idx].params = {};
scenarioEditorState.steps[idx]._svcOpId = null;
renderEditor();
}
function addStep() { function addStep() {
scenarioEditorState.steps.push({ service_id: '', operation: '', output: '', instance_ref: '', instance_uid: '', params: [] }); snapshotAllParams();
scenarioEditorState.steps.push({ service_id: '', operation: '', output: '', instance_ref: '', instance_uid: '', params: {} });
renderEditor(); renderEditor();
} }
function removeStep(idx) { function removeStep(idx) {
snapshotAllParams();
scenarioEditorState.steps.splice(idx, 1); scenarioEditorState.steps.splice(idx, 1);
renderEditor(); renderEditor();
} }
function moveStep(idx, dir) { function moveStep(idx, dir) {
snapshotAllParams();
const steps = scenarioEditorState.steps; const steps = scenarioEditorState.steps;
const target = idx + dir; const target = idx + dir;
if (target < 0 || target >= steps.length) return; if (target < 0 || target >= steps.length) return;
[steps[idx], steps[target]] = [steps[target], steps[idx]]; [steps[idx], steps[target]] = [steps[target], steps[idx]];
} renderEditor();
function addParam(idx) {
scenarioEditorState.steps[idx].params.push(['', '']);
}
function removeParam(idx, pi) {
scenarioEditorState.steps[idx].params.splice(pi, 1);
} }
function collectFormSteps() { function collectFormSteps() {
snapshotAllParams();
const st = scenarioEditorState; const st = scenarioEditorState;
const name = (document.getElementById('scenario-name')?.value || '').trim(); const name = (document.getElementById('scenario-name')?.value || '').trim();
const steps = []; const steps = [];
st.steps.forEach((_, idx) => { st.steps.forEach((step, idx) => {
const opEl = document.getElementById('step-' + idx + '-op'); const opEl = document.getElementById('step-' + idx + '-op');
const operation = (opEl?.value || '').trim(); const operation = (opEl?.value || '').trim();
let svcId = 0; let svcId = 0;
@@ -195,25 +290,30 @@ function collectFormSteps() {
const refEl = document.getElementById('step-' + idx + '-ref'); const refEl = document.getElementById('step-' + idx + '-ref');
const ref = (refEl?.value || '').trim(); const ref = (refEl?.value || '').trim();
if (ref) { if (ref) {
// Это UUID облачного инстанса — ищем его service_id
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; svcId = cloud.serviceId;
s.instance_uid = ref; s.instance_uid = ref;
} else { } else {
// Это output-имя из предыдущего шага
s.instance_ref = ref; s.instance_ref = ref;
} }
} }
} }
s.service_id = svcId; s.service_id = svcId;
// Параметры // Собрать параметры через collectParams + обратный маппинг numeric→name
st.steps[idx].params.forEach((__, pi) => { const defs = st.paramDefsCache[step._svcOpId];
const k = document.getElementById('step-' + idx + '-pk-' + pi)?.value?.trim(); if (defs) {
const v = document.getElementById('step-' + idx + '-pv-' + pi)?.value || ''; const numericParams = collectParams('#step-' + idx + '-params');
if (k) s.params[k] = v; const idToName = {};
}); 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;
}
}
}
steps.push(s); steps.push(s);
}); });
return { name, steps }; return { name, steps };
@@ -224,7 +324,7 @@ async function saveScenario() {
if (!name) { alert('Введите название'); return; } if (!name) { alert('Введите название'); return; }
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++) {
if (!steps[i].service_id) { alert('Шаг ' + (i+1) + ': выберите сервис'); return; } if (!steps[i].service_id) { alert('Шаг ' + (i+1) + ': выберите сервис или инстанс'); return; }
if (!steps[i].operation) { alert('Шаг ' + (i+1) + ': выберите операцию'); return; } if (!steps[i].operation) { alert('Шаг ' + (i+1) + ': выберите операцию'); return; }
} }
const st = scenarioEditorState; const st = scenarioEditorState;