/* 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'; var cloneArea = document.getElementById('e-clone-area'); if (cloneArea) cloneArea.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 invalidKeys = envVars.map(function(ev) { return ev.name; }).filter(function(k) { return !ENV_KEY_RE.test(k); }); if (invalidKeys.length > 0) { throw new Error('Недопустимые имена переменных: ' + invalidKeys.join(', ') + ' (только латиница, цифры, _, не с цифры)'); } // Проверка на дубли ключей 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 (A-Z, a-z, 0-9, _)'; 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; } var ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; // 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; var isInvalid = k !== '' && !ENV_KEY_RE.test(k); inp.style.outline = (isDupe || isInvalid) ? '2px solid #f44' : ''; inp.title = isDupe ? 'Дублирующийся ключ!' : isInvalid ? 'Только латиница, цифры и _. Не начинается с цифры.' : ''; }); } // 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; } // toggleCloneArea показывает/скрывает форму клонирования function toggleCloneArea(show) { var area = document.getElementById('e-clone-area'); var errEl = document.getElementById('e-clone-error'); if (!area) return; area.style.display = show ? 'block' : 'none'; if (show) { var nameInput = document.getElementById('e-clone-name'); if (nameInput) { // Предзаполняем именем оригинала + '-copy' var srcName = (S.currentEdit && S.currentEdit.name) || ''; nameInput.value = srcName ? srcName + '-copy' : ''; nameInput.focus(); nameInput.select(); } if (errEl) errEl.style.display = 'none'; } } // submitClone отправляет запрос на клонирование функции async function submitClone() { var nameInput = document.getElementById('e-clone-name'); var errEl = document.getElementById('e-clone-error'); var btn = document.querySelector('#e-clone-area .btn:not(.ghost)'); var newName = nameInput ? nameInput.value.trim() : ''; if (!newName) { if (errEl) { errEl.textContent = 'Введите имя новой функции'; errEl.style.display = 'block'; } return; } if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(newName) || newName.length > 57) { if (errEl) { errEl.textContent = 'Имя: строчные буквы, цифры, дефис; не начинается/заканчивается дефисом; до 57 символов'; errEl.style.display = 'block'; } return; } var srcName = S.currentEdit && S.currentEdit.name; if (!srcName) { if (errEl) { errEl.textContent = 'Нет функции для клонирования'; errEl.style.display = 'block'; } return; } if (btn) { btn.disabled = true; btn.textContent = '...'; } if (errEl) errEl.style.display = 'none'; try { var data = await requestJSON(API_BASE + '/functions/' + encodeURIComponent(srcName) + '/clone', 'POST', {new_name: newName}); // Успех — закрываем модалку, обновляем список closeEdit(); setStatus('Функция «' + newName + '» создана как копия «' + srcName + '»', false); setTimeout(function() { reloadAll(); }, 800); } catch(e) { if (errEl) { errEl.textContent = e.message; errEl.style.display = 'block'; } } finally { if (btn) { btn.disabled = false; btn.textContent = 'Клонировать'; } } }