v1.2.2: modal scenario editor with dropdowns + output/ref
scenario-form.js — full rewrite:
- Full-screen modal (backdrop + content, 800px max-width)
- Services dropdown (GET /api/services, cached)
- Operations dropdown (GET /api/operations/{svcId}, cached)
- output field for create steps
- instance_ref dropdown (previous outputs) + explicit instance_uid
- [↑][↓] reorder buttons
- Auto-load operations on service change
index.html — modal CSS (#scenario-modal, .modal-backdrop, .modal-content)
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
# Версия — меняется при КАЖДОМ изменении кода. Показывается в топбаре UI.
|
||||
VERSION = "1.2.1"
|
||||
VERSION = "1.2.2"
|
||||
|
||||
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")
|
||||
|
||||
+153
-45
@@ -1,56 +1,130 @@
|
||||
// scenario-form.js — общий редактор шагов сценария (create + edit)
|
||||
let scenarioEditorState = null; // {defId, version, name, steps:[]}
|
||||
// steps: [{service_id, operation, params: [["key","val"],...]}]
|
||||
// scenario-form.js — модальный редактор сценариев
|
||||
// Использует: params-render.js (renderParamRow, renderMapFixedRow, collectParams)
|
||||
// Зависит от: utils.js (_esc), instances.js (currentSvcId)
|
||||
|
||||
function showScenarioEditor(def) {
|
||||
const body = document.getElementById('scenario-body');
|
||||
let scenarioEditorState = null; // {defId, version, name, steps, services:[], opsCache:{}}
|
||||
// step: {service_id, operation, output, instance_ref, instance_uid, params: [["k","v"],...]}
|
||||
|
||||
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,
|
||||
service_id: s.service_id || '', operation: s.operation || '',
|
||||
output: s.output || '', instance_ref: s.instance_ref || '',
|
||||
instance_uid: s.instance_uid || '',
|
||||
params: Object.entries(s.params || {})
|
||||
}))
|
||||
} : { defId: null, version: 1, name: '', steps: [] };
|
||||
})),
|
||||
services: [], opsCache: {}
|
||||
} : { defId: null, version: 1, name: '', steps: [], services: [], opsCache: {} };
|
||||
|
||||
// Загрузить список сервисов
|
||||
try {
|
||||
const r = await fetch('/api/services');
|
||||
scenarioEditorState.services = await r.json();
|
||||
} catch (e) { scenarioEditorState.services = []; }
|
||||
renderEditor();
|
||||
}
|
||||
|
||||
function openModal(html) {
|
||||
let modal = document.getElementById('scenario-modal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'scenario-modal';
|
||||
modal.innerHTML = '<div class="modal-backdrop" onclick="closeModal()"></div><div class="modal-content" id="modal-content"></div>';
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
modal.style.display = 'block';
|
||||
document.getElementById('modal-content').innerHTML = html;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const modal = document.getElementById('scenario-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
loadScenarios();
|
||||
}
|
||||
|
||||
function renderEditor() {
|
||||
const body = document.getElementById('scenario-body');
|
||||
const st = scenarioEditorState;
|
||||
let html = '<div style="font-size:12px;">';
|
||||
html += '<div style="margin-bottom:6px;"><label style="font-size:11px;">Название:</label><br>';
|
||||
html += `<input type="text" id="scenario-name" value="${_esc(st.name)}" style="width:100%;padding:4px;" placeholder="my_test">`;
|
||||
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 += '<div style="margin-bottom:6px;font-size:11px;font-weight:600;">Шаги:</div>';
|
||||
st.steps.forEach((s, idx) => {
|
||||
html += renderStepRow(idx, s);
|
||||
});
|
||||
html += `<button class="btn btn-sm" style="font-size:11px;margin-top:4px;" onclick="addStep()">+ Добавить шаг</button>`;
|
||||
html += '<div style="margin-top:8px;">';
|
||||
html += `<button class="btn btn-sm" style="font-size:11px;background:var(--brand-primary);color:#fff;" onclick="saveScenario()">Сохранить</button> `;
|
||||
html += '<button class="btn btn-sm" style="font-size:11px;" onclick="loadScenarios()">Отмена</button>';
|
||||
// Шаги
|
||||
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>';
|
||||
body.innerHTML = html;
|
||||
openModal(html);
|
||||
}
|
||||
|
||||
function renderStepRow(idx, step) {
|
||||
const svcId = step.service_id || '';
|
||||
const op = step.operation || '';
|
||||
const params = step.params || [];
|
||||
let html = `<div style="border:1px solid var(--brand-gray);border-radius:4px;padding:6px;margin:4px 0;" id="step-${idx}">`;
|
||||
html += `<div style="font-size:10px;color:var(--muted);margin-bottom:4px;">Шаг ${idx+1}`;
|
||||
html += ` <button class="btn btn-sm" style="font-size:9px;padding:0 4px;" onclick="removeStep(${idx})">✕</button>`;
|
||||
const st = scenarioEditorState;
|
||||
const svcId = step.service_id;
|
||||
const op = step.operation;
|
||||
const out = step.output || '';
|
||||
const ref = step.instance_ref || '';
|
||||
|
||||
// Сервисы — dropdown
|
||||
let svcOpts = '<option value="">— сервис —</option>';
|
||||
st.services.forEach(s => {
|
||||
svcOpts += `<option value="${s.svcId}" ${s.svcId == svcId ? 'selected' : ''}>${s.svcId}. ${_esc(s.svc)}</option>`;
|
||||
});
|
||||
|
||||
// Операции — dropdown (из кеша или базовый)
|
||||
let opOpts = '<option value="">— операция —</option>';
|
||||
const ops = st.opsCache[svcId] || ['create', 'delete', 'modify', 'suspend', 'resume', 'redeploy'];
|
||||
ops.forEach(o => {
|
||||
const oName = typeof o === 'string' ? o : o.operation;
|
||||
opOpts += `<option value="${oName}" ${oName == op ? 'selected' : ''}>${oName}</option>`;
|
||||
});
|
||||
|
||||
// Output refs из предыдущих create-шагов
|
||||
const prevOutputs = [];
|
||||
for (let i = 0; i < idx; i++) {
|
||||
const s = st.steps[i];
|
||||
if (s.operation === 'create' && s.output) prevOutputs.push(s.output);
|
||||
}
|
||||
|
||||
let html = `<div style="border:1px solid var(--brand-gray);border-radius:6px;padding:8px;margin:6px 0;" id="step-${idx}">`;
|
||||
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>`;
|
||||
// Сервис — текстовое поле с подсказкой (ID или имя)
|
||||
html += `<input type="number" id="step-${idx}-svc" value="${svcId}" placeholder="service_id" style="width:80px;padding:2px;font-size:11px;" onchange="onStepChange(${idx})"> `;
|
||||
// Операция
|
||||
html += `<input type="text" id="step-${idx}-op" value="${_esc(op)}" placeholder="create" style="width:100px;padding:2px;font-size:11px;"> `;
|
||||
html += '<div style="margin-top:2px;">';
|
||||
|
||||
// Сервис + Операция в одной строке
|
||||
html += `<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">`;
|
||||
html += `<select id="step-${idx}-svc" onchange="onSvcChange(${idx})" style="padding:4px;font-size:12px;">${svcOpts}</select>`;
|
||||
html += `<select id="step-${idx}-op" onchange="onOpChange(${idx})" style="padding:4px;font-size:12px;">${opOpts}</select>`;
|
||||
html += `</div>`;
|
||||
|
||||
// Output / instance_ref / instance_uid
|
||||
html += '<div style="display:flex;gap:6px;align-items:center;margin-top:4px;flex-wrap:wrap;">';
|
||||
if (op === 'create') {
|
||||
html += `<input type="text" id="step-${idx}-output" value="${_esc(out)}" placeholder="output (имя для ссылок)" style="width:180px;padding:3px;font-size:11px;">`;
|
||||
} else if (op) {
|
||||
let refOpts = '<option value="">— instance_ref —</option>';
|
||||
prevOutputs.forEach(o => { refOpts += `<option value="${o}" ${o == ref ? 'selected' : ''}>${_esc(o)}</option>`; });
|
||||
html += `<select id="step-${idx}-ref" style="padding:3px;font-size:11px;">${refOpts}</select>`;
|
||||
html += `<input type="text" id="step-${idx}-uid" value="${_esc(step.instance_uid || '')}" placeholder="или явный UUID" style="width:280px;padding:3px;font-size:11px;">`;
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// Параметры
|
||||
html += `<div style="margin-top:4px;" id="step-${idx}-params">`;
|
||||
const params = step.params || [];
|
||||
params.forEach(([k, v], pi) => {
|
||||
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:100px;padding:2px;font-size:10px;">`;
|
||||
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:120px;padding:2px;font-size:10px;">`;
|
||||
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>`;
|
||||
});
|
||||
@@ -59,8 +133,27 @@ function renderStepRow(idx, step) {
|
||||
return html;
|
||||
}
|
||||
|
||||
async function onSvcChange(idx) {
|
||||
const svcEl = document.getElementById('step-' + idx + '-svc');
|
||||
const svcId = parseInt(svcEl?.value) || 0;
|
||||
if (!svcId) return;
|
||||
// Загрузить операции для сервиса
|
||||
try {
|
||||
const r = await fetch('/api/operations/' + svcId);
|
||||
const d = await r.json();
|
||||
scenarioEditorState.opsCache[svcId] = d.operations || [];
|
||||
} catch (e) {
|
||||
scenarioEditorState.opsCache[svcId] = ['create', 'delete', 'modify', 'suspend', 'resume', 'redeploy'];
|
||||
}
|
||||
renderEditor();
|
||||
}
|
||||
|
||||
async function onOpChange(idx) {
|
||||
// Ничего не делаем — параметры редактируются вручную
|
||||
}
|
||||
|
||||
function addStep() {
|
||||
scenarioEditorState.steps.push({ service_id: 1, operation: 'create', params: [] });
|
||||
scenarioEditorState.steps.push({ service_id: '', operation: '', output: '', instance_ref: '', instance_uid: '', params: [] });
|
||||
renderEditor();
|
||||
}
|
||||
|
||||
@@ -69,6 +162,13 @@ function removeStep(idx) {
|
||||
renderEditor();
|
||||
}
|
||||
|
||||
function moveStep(idx, dir) {
|
||||
const steps = scenarioEditorState.steps;
|
||||
const target = idx + dir;
|
||||
if (target < 0 || target >= steps.length) return;
|
||||
[steps[idx], steps[target]] = [steps[target], steps[idx]];
|
||||
}
|
||||
|
||||
function addParam(idx) {
|
||||
scenarioEditorState.steps[idx].params.push(['', '']);
|
||||
}
|
||||
@@ -77,24 +177,32 @@ function removeParam(idx, pi) {
|
||||
scenarioEditorState.steps[idx].params.splice(pi, 1);
|
||||
}
|
||||
|
||||
function onStepChange(idx) { /* placeholder for future auto-load */ }
|
||||
|
||||
function collectFormSteps() {
|
||||
const st = scenarioEditorState;
|
||||
const name = (document.getElementById('scenario-name')?.value || '').trim();
|
||||
const steps = [];
|
||||
st.steps.forEach((_, idx) => {
|
||||
const svcEl = document.getElementById(`step-${idx}-svc`);
|
||||
const opEl = document.getElementById(`step-${idx}-op`);
|
||||
const svcEl = document.getElementById('step-' + idx + '-svc');
|
||||
const opEl = document.getElementById('step-' + idx + '-op');
|
||||
const outEl = document.getElementById('step-' + idx + '-output');
|
||||
const refEl = document.getElementById('step-' + idx + '-ref');
|
||||
const uidEl = document.getElementById('step-' + idx + '-uid');
|
||||
const svcId = parseInt(svcEl?.value) || 0;
|
||||
const operation = (opEl?.value || '').trim();
|
||||
const output = (outEl?.value || '').trim();
|
||||
const instance_ref = (refEl?.value || '').trim();
|
||||
const instance_uid = (uidEl?.value || '').trim();
|
||||
const params = {};
|
||||
st.steps[idx].params.forEach((__, pi) => {
|
||||
const k = document.getElementById(`step-${idx}-pk-${pi}`)?.value?.trim();
|
||||
const v = document.getElementById(`step-${idx}-pv-${pi}`)?.value || '';
|
||||
const k = document.getElementById('step-' + idx + '-pk-' + pi)?.value?.trim();
|
||||
const v = document.getElementById('step-' + idx + '-pv-' + pi)?.value || '';
|
||||
if (k) params[k] = v;
|
||||
});
|
||||
steps.push({ service_id: svcId, operation, params });
|
||||
const s = { service_id: svcId, operation, params };
|
||||
if (output) s.output = output;
|
||||
if (instance_ref) s.instance_ref = instance_ref;
|
||||
if (instance_uid) s.instance_uid = instance_uid;
|
||||
steps.push(s);
|
||||
});
|
||||
return { name, steps };
|
||||
}
|
||||
@@ -104,8 +212,8 @@ async function saveScenario() {
|
||||
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) + ': укажите service_id'); return; }
|
||||
if (!steps[i].operation) { alert('Шаг ' + (i+1) + ': укажите operation'); return; }
|
||||
if (!steps[i].service_id) { alert('Шаг ' + (i+1) + ': выберите сервис'); return; }
|
||||
if (!steps[i].operation) { alert('Шаг ' + (i+1) + ': выберите операцию'); return; }
|
||||
}
|
||||
const st = scenarioEditorState;
|
||||
const isNew = !st.defId;
|
||||
@@ -122,7 +230,7 @@ async function saveScenario() {
|
||||
if (d.error) { alert(d.error); return; }
|
||||
if (isNew && d.id) st.defId = d.id;
|
||||
if (d.version) st.version = d.version;
|
||||
await loadScenarios();
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
alert('Ошибка сохранения: ' + e.message);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@
|
||||
.param-row label.req { font-weight:600; }
|
||||
.param-row label.req-nodfl { font-weight:600; color:var(--destructive); }
|
||||
.param-row input,.param-row select { flex:1; height:28px; font-size:12px; border:1px solid var(--brand-gray); border-radius:4px; padding:0 6px; }
|
||||
#scenario-modal { display:none; position:fixed; top:0; left:0; right:0; bottom:0; z-index:10000; }
|
||||
.modal-backdrop { position:absolute; top:0; left:0; right:0; bottom:0; background:rgba(0,0,0,0.5); }
|
||||
.modal-content { position:relative; max-width:800px; margin:20px auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user