394 lines
17 KiB
JavaScript
394 lines
17 KiB
JavaScript
/* functions.js — CRUD операции с функциями */
|
||
// ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ ЯВНОГО РАЗРЕШЕНИЯ ВЛАДЕЛЬЦА.
|
||
// Любые "улучшения", рефакторинг, добавление фич — ЗАПРЕЩЕНЫ без команды.
|
||
// Прецедент: v1.3.49 — убрали mode toggle "из лучших побуждений" → сломали рабочий редактор.
|
||
// Прецедент: helm upgrade → сломал JWT secret router → 401 у всех функций.
|
||
|
||
// setCodeMode переключает режим формы между вводом кода и загрузкой архива.
|
||
// prefix: 'c' (create) или 'e' (edit)
|
||
// mode: 'code' | 'archive'
|
||
function setCodeMode(prefix, mode) {
|
||
var codeArea = document.getElementById(prefix + '-code-area');
|
||
var archiveArea = document.getElementById(prefix + '-archive-area');
|
||
var btnCode = document.getElementById(prefix + '-mode-code');
|
||
var btnArchive = document.getElementById(prefix + '-mode-archive');
|
||
if (!codeArea || !archiveArea) return;
|
||
|
||
var isCode = mode === 'code';
|
||
codeArea.style.display = isCode ? '' : 'none';
|
||
archiveArea.style.display = isCode ? 'none' : '';
|
||
if (btnCode) btnCode.style.opacity = isCode ? '1' : '0.5';
|
||
if (btnArchive) btnArchive.style.opacity = isCode ? '0.5' : '1';
|
||
}
|
||
|
||
// lintArchiveFile — отправляет zip-архив на /console/api/ai/lint-archive,
|
||
// показывает результат пофайлово в блоке {prefix}-ai-result рядом с кнопкой.
|
||
function lintArchiveFile(prefix) {
|
||
var input = document.getElementById(prefix + '-archive-file');
|
||
var resEl = document.getElementById(prefix + '-ai-result');
|
||
var btn = document.getElementById(prefix + '-lint-btn');
|
||
function showRes(text, bg, color) {
|
||
if (resEl) {
|
||
resEl.style.display = 'block';
|
||
resEl.style.background = bg;
|
||
resEl.style.color = color;
|
||
resEl.textContent = text;
|
||
}
|
||
}
|
||
if (!input || !input.files || !input.files[0]) {
|
||
showRes('Выберите .zip файл', '#3a1a1a', '#f88');
|
||
return;
|
||
}
|
||
var file = input.files[0];
|
||
if (file.size > 100 * 1024) {
|
||
showRes('Архив слишком большой: максимум 100 KB', '#3a1a1a', '#f88');
|
||
return;
|
||
}
|
||
if (btn) { btn.disabled = true; btn.textContent = '⏳ Проверяю…'; }
|
||
showRes('Запускаю линтер…', 'var(--bg-alt)', 'var(--fg)');
|
||
var fd = new FormData();
|
||
fd.append('archive', file);
|
||
// Передаём entrypoint для проверки наличия файла и функции в архиве
|
||
var entryEl = document.getElementById(prefix + '-entry');
|
||
if (entryEl && entryEl.value.trim()) {
|
||
fd.append('entrypoint', entryEl.value.trim());
|
||
}
|
||
// Передаём выбранный язык для проверки соответствия файлов
|
||
var langEl = document.getElementById(prefix + '-lang');
|
||
if (langEl && langEl.value) {
|
||
fd.append('language', langEl.value);
|
||
}
|
||
fetch(API_BASE + '/ai/lint-archive', {
|
||
method: 'POST',
|
||
headers: authHeaders(),
|
||
body: fd
|
||
})
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
if (btn) { btn.disabled = false; btn.textContent = '🔍 Проверка архива линтером'; }
|
||
if (data.error) {
|
||
showRes('Ошибка: ' + data.error, '#3a1a1a', '#f88');
|
||
return;
|
||
}
|
||
var results = data.results || [];
|
||
if (results.length === 0) {
|
||
showRes('Нет поддерживаемых файлов в архиве (.py, .js, .rb, .php)', '#3a2a00', '#ffa');
|
||
return;
|
||
}
|
||
var ok = results.filter(function(r) { return r.ok; }).length;
|
||
var fail = results.filter(function(r) { return !r.ok; }).length;
|
||
var lines = results.map(function(r) {
|
||
return (r.ok ? '✅ ' : '❌ ') + r.file + (r.output ? '\n ' + r.output : '');
|
||
});
|
||
var summary = ok + ' OK, ' + fail + ' ошибок\n\n' + lines.join('\n');
|
||
showRes(summary, fail > 0 ? '#3a1a1a' : '#1a3a1a', fail > 0 ? '#f88' : '#8f8');
|
||
})
|
||
.catch(function(e) {
|
||
if (btn) { btn.disabled = false; btn.textContent = '🔍 Проверка архива линтером'; }
|
||
showRes('Ошибка сети: ' + e.message, '#3a2a00', '#ffa');
|
||
});
|
||
}
|
||
|
||
// explainArchiveFile — отправляет zip-архив на /console/api/ai/explain-archive,
|
||
// показывает ответ LLM в блоке {prefix}-ai-result.
|
||
function explainArchiveFile(prefix) {
|
||
var input = document.getElementById(prefix + '-archive-file');
|
||
var resEl = document.getElementById(prefix + '-ai-result');
|
||
var btn = document.getElementById(prefix + '-explain-archive-btn');
|
||
function showRes(text, bg, color) {
|
||
if (resEl) {
|
||
resEl.style.display = 'block';
|
||
resEl.style.background = bg;
|
||
resEl.style.color = color;
|
||
resEl.textContent = text;
|
||
}
|
||
}
|
||
if (!input || !input.files || !input.files[0]) {
|
||
showRes('Выберите .zip файл', '#3a1a1a', '#f88');
|
||
return;
|
||
}
|
||
var file = input.files[0];
|
||
if (file.size > 100 * 1024) {
|
||
showRes('Архив слишком большой: максимум 100 KB', '#3a1a1a', '#f88');
|
||
return;
|
||
}
|
||
if (btn) { btn.disabled = true; btn.textContent = '⏳ Спрашиваю LLM…'; }
|
||
showRes('Запрашиваю у LLM…', 'var(--bg-alt)', 'var(--fg)');
|
||
var fd = new FormData();
|
||
fd.append('archive', file);
|
||
fetch(API_BASE + '/ai/explain-archive', {
|
||
method: 'POST',
|
||
headers: authHeaders(),
|
||
body: fd
|
||
})
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
if (btn) { btn.disabled = false; btn.textContent = '📖 LLM: Что делает?'; }
|
||
if (data.error) {
|
||
showRes('Ошибка: ' + data.error, '#3a2a00', '#ffa');
|
||
return;
|
||
}
|
||
showRes(data.answer || '(пустой ответ)', '#1a2a3a', '#8cf');
|
||
})
|
||
.catch(function(e) {
|
||
if (btn) { btn.disabled = false; btn.textContent = '📖 LLM: Что делает?'; }
|
||
showRes('Ошибка сети: ' + e.message, '#3a2a00', '#ffa');
|
||
});
|
||
}
|
||
|
||
function parseMethods(v) {
|
||
const items = String(v || '').split(',').map(s => s.trim().toUpperCase()).filter(Boolean);
|
||
return items.length ? Array.from(new Set(items)) : ['GET'];
|
||
}
|
||
|
||
function parseTimeout(v) {
|
||
var n = Number(String(v || '').trim());
|
||
if (!Number.isFinite(n) || n <= 0) return 60;
|
||
return Math.round(n);
|
||
}
|
||
|
||
// openCreate/closeCreate/submitCreate перенесены в fn-code.js и fn-archive.js
|
||
|
||
async function openEdit(name) {
|
||
try {
|
||
var errEl = document.getElementById('e-error-msg');
|
||
if (errEl) errEl.style.display = 'none';
|
||
const fn = await getJSON(API_BASE + '/functions/' + encodeURIComponent(name));
|
||
S.currentEdit = fn;
|
||
document.getElementById('e-title').textContent = 'Редактирование: ' + name;
|
||
document.getElementById('e-name').value = name;
|
||
document.getElementById('e-env').value = fn.environment || '';
|
||
document.getElementById('e-entry').value = fn.entrypoint || '';
|
||
document.getElementById('e-timeout').value = String(fn.timeout || 60);
|
||
document.getElementById('e-code').value = fn.code || '';
|
||
|
||
// Сбрасываем режим: source_type из API (code / archive)
|
||
if (fn.source_type === 'archive') {
|
||
setCodeMode('e', 'archive');
|
||
var archiveNameEl = document.getElementById('e-archive-name');
|
||
if (archiveNameEl) archiveNameEl.textContent = fn.archive_filename || 'архив';
|
||
var archiveFile = document.getElementById('e-archive-file');
|
||
if (archiveFile) archiveFile.value = '';
|
||
} else {
|
||
setCodeMode('e', 'code');
|
||
}
|
||
var archiveInfo = document.getElementById('e-archive-info');
|
||
if (archiveInfo) archiveInfo.textContent = '';
|
||
|
||
var schedule = timeTriggerByFn(name);
|
||
document.getElementById('e-schedule-enabled').checked = !!schedule;
|
||
document.getElementById('e-cron').value = (schedule && schedule.spec && schedule.spec.cron) || '';
|
||
toggleScheduleFields('e');
|
||
var envName = (fn.environment || '').toLowerCase();
|
||
var lang = 'python';
|
||
if (envName.includes('node')) lang = 'nodejs';
|
||
else if (envName.includes('go')) lang = 'go';
|
||
else if (envName.includes('ruby')) lang = 'ruby';
|
||
else if (envName.includes('php')) lang = 'php';
|
||
document.getElementById('e-lang-hidden').value = lang;
|
||
var aiRes = document.getElementById('e-ai-result');
|
||
if (aiRes) { aiRes.style.display = 'none'; aiRes.textContent = ''; }
|
||
var warnEl = document.getElementById('e-tf-warn');
|
||
if (warnEl) {
|
||
var isTf = /^tf-/.test(name) || /go[-_]env/.test(fn.environment || '');
|
||
warnEl.style.display = isTf ? 'block' : 'none';
|
||
}
|
||
|
||
// Env vars — отрисовать блок; TF-функции — только чтение
|
||
var isTfFn = /^tf-/.test(name) || /go[-_]env/.test(fn.environment || '');
|
||
renderEnvVars('e', fn.env_vars || [], isTfFn);
|
||
|
||
document.getElementById('edit-modal').classList.add('open');
|
||
} catch (e) {
|
||
showStatus('Ошибка загрузки функции: ' + e.message, 'err');
|
||
}
|
||
}
|
||
|
||
function closeEdit() {
|
||
document.getElementById('edit-modal').classList.remove('open');
|
||
S.currentEdit = null;
|
||
}
|
||
|
||
async function submitEdit() {
|
||
if (!S.currentEdit) return;
|
||
const btn = document.getElementById('e-submit');
|
||
btn.disabled = true;
|
||
const progress = startTimedStatus('Сохраняем код...', 'Сохранение кода...', explainDelay);
|
||
try {
|
||
const name = S.currentEdit.name;
|
||
|
||
// Определяем режим
|
||
var archiveArea = document.getElementById('e-archive-area');
|
||
var isArchiveMode = archiveArea && archiveArea.style.display !== 'none';
|
||
var archiveFile = isArchiveMode ? document.getElementById('e-archive-file').files[0] : null;
|
||
|
||
if (isArchiveMode && archiveFile) {
|
||
// Обновляем через архив
|
||
var fd = new FormData();
|
||
fd.append('timeout', String(parseTimeout(document.getElementById('e-timeout').value)));
|
||
fd.append('entrypoint', document.getElementById('e-entry').value.trim());
|
||
fd.append('archive', archiveFile);
|
||
var resp = await fetch(API_BASE + '/functions/' + encodeURIComponent(name) + '/archive',
|
||
{ method: 'PUT', headers: authHeaders(), body: fd });
|
||
if (!resp.ok) { var e = await resp.json().catch(() => ({})); throw new Error(e.error || resp.statusText); }
|
||
} else if (isArchiveMode) {
|
||
// Архив не заменяется — обновляем только timeout + entrypoint
|
||
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/timeout', 'PUT', {
|
||
timeout: parseTimeout(document.getElementById('e-timeout').value),
|
||
entrypoint: document.getElementById('e-entry').value.trim()
|
||
});
|
||
} else {
|
||
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/code', 'PUT', {
|
||
code: document.getElementById('e-code').value,
|
||
timeout: parseTimeout(document.getElementById('e-timeout').value)
|
||
});
|
||
}
|
||
|
||
await syncScheduleForFunction(name, 'e');
|
||
|
||
// Сохраняем env vars (только если не TF-функция)
|
||
var isTfFn = /^tf-/.test(name);
|
||
if (!isTfFn) {
|
||
var envVars = collectEnvVars('e');
|
||
// Проверка на дубли ключей
|
||
var keys = envVars.map(function(ev) { return ev.name; });
|
||
var dupes = keys.filter(function(k, i) { return keys.indexOf(k) !== i; });
|
||
if (dupes.length > 0) {
|
||
throw new Error('Дублирующиеся ключи: ' + [...new Set(dupes)].join(', '));
|
||
}
|
||
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/envvars', 'PUT', {
|
||
env_vars: envVars
|
||
});
|
||
}
|
||
|
||
var errEl = document.getElementById('e-error-msg');
|
||
if (errEl) errEl.style.display = 'none';
|
||
closeEdit();
|
||
progress.stop('Код обновлён: ' + name, 'ok');
|
||
await reloadAll();
|
||
} catch (e) {
|
||
var errEl = document.getElementById('e-error-msg');
|
||
if (errEl) { errEl.textContent = e.message; errEl.style.display = 'block'; }
|
||
progress.stop('', '');
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function removeFn(name) {
|
||
var tfWarn = (/^tf-/.test(name)) ? '\n\n⚠️ Эта функция управляется Terraform. Удаление приведёт к рассинхронизации state!' : '';
|
||
if (!confirm('Удалить функцию ' + name + '?' + tfWarn)) return;
|
||
var progress = startTimedStatus('Удаляем функцию...', 'Удаление функции...', explainDelay);
|
||
try {
|
||
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name), 'DELETE');
|
||
progress.stop('Функция удалена: ' + name, 'ok');
|
||
await reloadAll();
|
||
} catch (e) {
|
||
progress.stop('Ошибка удаления: ' + e.message, 'err');
|
||
}
|
||
}
|
||
|
||
// --- Env Vars UI ---
|
||
|
||
// renderEnvVars отрисовывает список переменных окружения в блоке prefix-envvars-list
|
||
// vars: [{name: "KEY", value: "VAL"}, ...]
|
||
// readOnly: true для TF-функций
|
||
function renderEnvVars(prefix, vars, readOnly) {
|
||
var list = document.getElementById(prefix + '-envvars-list');
|
||
var addBtn = document.getElementById(prefix + '-add-envvar-btn');
|
||
var tfWarn = document.getElementById(prefix + '-tf-env-warn');
|
||
if (!list) return;
|
||
list.innerHTML = '';
|
||
if (readOnly) {
|
||
if (tfWarn) tfWarn.style.display = '';
|
||
if (addBtn) addBtn.style.display = 'none';
|
||
} else {
|
||
if (tfWarn) tfWarn.style.display = 'none';
|
||
if (addBtn) addBtn.style.display = '';
|
||
}
|
||
(vars || []).forEach(function(ev, idx) {
|
||
list.appendChild(makeEnvVarRow(prefix, ev.name || '', ev.value || '', readOnly, idx));
|
||
});
|
||
}
|
||
|
||
// makeEnvVarRow создаёт одну строку key=value с кнопкой удаления
|
||
function makeEnvVarRow(prefix, key, val, readOnly, idx) {
|
||
var row = document.createElement('div');
|
||
row.style.cssText = 'display:flex; gap:6px; align-items:center;';
|
||
row.dataset.envIdx = idx;
|
||
|
||
var kInput = document.createElement('input');
|
||
kInput.placeholder = 'KEY';
|
||
kInput.value = key;
|
||
kInput.disabled = readOnly;
|
||
kInput.style.cssText = 'flex:1; font-size:12px; font-family:monospace;';
|
||
kInput.dataset.envKey = '1';
|
||
// Подсветка дублей при изменении ключа
|
||
kInput.addEventListener('input', function() { highlightDupeKeys(prefix); });
|
||
|
||
var vInput = document.createElement('input');
|
||
vInput.placeholder = 'value';
|
||
vInput.value = val;
|
||
vInput.disabled = readOnly;
|
||
vInput.style.cssText = 'flex:2; font-size:12px; font-family:monospace;';
|
||
vInput.dataset.envVal = '1';
|
||
|
||
row.appendChild(kInput);
|
||
|
||
var eq = document.createElement('span');
|
||
eq.textContent = '=';
|
||
eq.style.cssText = 'color:var(--text-secondary); font-family:monospace;';
|
||
row.appendChild(eq);
|
||
row.appendChild(vInput);
|
||
|
||
if (!readOnly) {
|
||
var delBtn = document.createElement('button');
|
||
delBtn.textContent = '×';
|
||
delBtn.className = 'btn ghost';
|
||
delBtn.style.cssText = 'padding:2px 8px; font-size:14px; line-height:1;';
|
||
delBtn.title = 'Удалить переменную';
|
||
delBtn.onclick = function() { row.remove(); };
|
||
row.appendChild(delBtn);
|
||
}
|
||
|
||
return row;
|
||
}
|
||
|
||
// highlightDupeKeys подсвечивает красным все поля KEY с одинаковыми именами
|
||
function highlightDupeKeys(prefix) {
|
||
var list = document.getElementById(prefix + '-envvars-list');
|
||
if (!list) return;
|
||
var inputs = list.querySelectorAll('[data-env-key]');
|
||
var keys = Array.from(inputs).map(function(i) { return i.value.trim(); });
|
||
inputs.forEach(function(inp) {
|
||
var k = inp.value.trim();
|
||
var isDupe = k !== '' && keys.filter(function(x) { return x === k; }).length > 1;
|
||
inp.style.outline = isDupe ? '2px solid #f44' : '';
|
||
inp.title = isDupe ? 'Дублирующийся ключ!' : '';
|
||
});
|
||
}
|
||
|
||
// addEnvVarRow добавляет пустую строку в список env vars
|
||
function addEnvVarRow(prefix) {
|
||
var list = document.getElementById(prefix + '-envvars-list');
|
||
if (!list) return;
|
||
var idx = list.children.length;
|
||
list.appendChild(makeEnvVarRow(prefix, '', '', false, idx));
|
||
}
|
||
|
||
// collectEnvVars читает текущие значения из DOM и возвращает [{name, value}, ...]
|
||
function collectEnvVars(prefix) {
|
||
var list = document.getElementById(prefix + '-envvars-list');
|
||
if (!list) return [];
|
||
var result = [];
|
||
var rows = list.querySelectorAll('div[data-env-idx]');
|
||
rows.forEach(function(row) {
|
||
var k = row.querySelector('[data-env-key]');
|
||
var v = row.querySelector('[data-env-val]');
|
||
var key = k ? k.value.trim() : '';
|
||
var val = v ? v.value : '';
|
||
if (key) result.push({name: key, value: val});
|
||
});
|
||
return result;
|
||
}
|