restructure: console→client-console, add admin-console skeleton, move docs to doc/
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
/* ai.js — AI/LLM функции: проверка, генерация, объяснение, ассистент */
|
||||
|
||||
// PYTHON_STDLIB — стандартная библиотека Python (не требуют pip install).
|
||||
var PYTHON_STDLIB = new Set([
|
||||
'os','sys','json','time','re','math','io','collections','itertools','functools',
|
||||
'datetime','pathlib','urllib','http','threading','asyncio','logging','random',
|
||||
'string','struct','hashlib','hmac','base64','uuid','copy','abc','typing',
|
||||
'dataclasses','contextlib','enum','warnings','traceback','inspect','importlib',
|
||||
'subprocess','socket','ssl','email','html','xml','csv','sqlite3','unittest',
|
||||
'gc','weakref','array','queue','heapq','bisect','decimal','fractions',
|
||||
'statistics','textwrap','difflib','shutil','glob','fnmatch','tempfile',
|
||||
'zipfile','tarfile','gzip','bz2','lzma','pickle','shelve','codecs',
|
||||
'unicodedata','ast','dis','types','builtins','operator','numbers','cmath',
|
||||
'pprint','reprlib','platform','signal','mmap','ctypes','multiprocessing',
|
||||
'concurrent','select','selectors','errno','atexit','sched','calendar',
|
||||
'locale','gettext','argparse','getopt','shlex','configparser','tokenize',
|
||||
'runpy','pkgutil','site','sysconfig','distutils','zipimport','abc','io',
|
||||
'__future__','_thread','threading','contextvars','netrc','plistlib',
|
||||
'html','xml','http','urllib','email','mailbox','mimetypes','encodings',
|
||||
'codecs','unicodedata','readline','rlcompleter','curses','idlelib','tkinter',
|
||||
]);
|
||||
|
||||
// detectExternalPythonImports — возвращает список модулей, которых нет в stdlib.
|
||||
function detectExternalPythonImports(code) {
|
||||
var external = [];
|
||||
var seen = {};
|
||||
var patterns = [/^import\s+([\w]+)/gm, /^from\s+([\w]+)/gm];
|
||||
for (var pi = 0; pi < patterns.length; pi++) {
|
||||
var m;
|
||||
while ((m = patterns[pi].exec(code)) !== null) {
|
||||
var mod = m[1];
|
||||
if (!PYTHON_STDLIB.has(mod) && !seen[mod]) {
|
||||
seen[mod] = true;
|
||||
external.push(mod);
|
||||
}
|
||||
}
|
||||
}
|
||||
return external;
|
||||
}
|
||||
|
||||
function llmGeneratedWarning(lang) {
|
||||
var text = 'Сделано LLM. Не доверяй, проверяй!';
|
||||
switch (lang) {
|
||||
case 'php':
|
||||
return '// ' + text;
|
||||
case 'nodejs':
|
||||
case 'go':
|
||||
return '// ' + text;
|
||||
case 'ruby':
|
||||
case 'python':
|
||||
default:
|
||||
return '# ' + text;
|
||||
}
|
||||
}
|
||||
|
||||
function addLLMWarning(code, lang) {
|
||||
var body = String(code || '').replace(/^\s+/, '');
|
||||
var warning = llmGeneratedWarning(lang);
|
||||
if (!body) return warning;
|
||||
if (body.indexOf(warning) === 0) return body;
|
||||
if (lang === 'php' && body.indexOf('<?php') === 0) {
|
||||
var rest = body.slice(5);
|
||||
rest = rest.replace(/^\r?\n/, '');
|
||||
return '<?php\n' + warning + '\n' + rest;
|
||||
}
|
||||
return warning + '\n' + body;
|
||||
}
|
||||
|
||||
async function aiCheck(codeId, langId, resultId) {
|
||||
var code = document.getElementById(codeId).value.trim();
|
||||
var lang = (document.getElementById(langId) ? document.getElementById(langId).value : '') || 'python';
|
||||
var resEl = document.getElementById(resultId);
|
||||
if (!code) { resEl.style.display = 'block'; resEl.style.background = 'var(--bg-alt)'; resEl.textContent = 'Введите код для проверки.'; return; }
|
||||
var btnId = codeId === 'c-code' ? 'c-ai-btn' : 'e-ai-btn';
|
||||
var btn = document.getElementById(btnId);
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Проверяю...';
|
||||
resEl.style.display = 'block';
|
||||
resEl.style.background = 'var(--bg-alt)';
|
||||
resEl.style.color = 'var(--fg)';
|
||||
resEl.textContent = 'Проверяю код линтером...';
|
||||
try {
|
||||
var data = await requestJSON(API_BASE + '/ai/check', 'POST', { language: lang, code: code });
|
||||
resEl.style.background = data.ok ? '#1a3a1a' : '#3a1a1a';
|
||||
resEl.style.color = data.ok ? '#8f8' : '#f88';
|
||||
var resultText = data.result || '(пустой ответ)';
|
||||
|
||||
// Для Python: проверяем внешние импорты vs поле зависимостей
|
||||
if (lang === 'python') {
|
||||
var depsId = codeId.replace('-code', '-deps'); // cc-code→cc-deps, e-code→e-deps
|
||||
var depsEl = document.getElementById(depsId);
|
||||
var depsVal = depsEl ? depsEl.value.trim() : '';
|
||||
var external = detectExternalPythonImports(code);
|
||||
if (external.length > 0 && !depsVal) {
|
||||
resultText += '\n\n⚠️ Внешние библиотеки: ' + external.join(', ') +
|
||||
'\nДобавьте их в поле "Зависимости (requirements.txt)" или убедитесь что они уже есть в Python-окружении.';
|
||||
if (data.ok) {
|
||||
resEl.style.background = '#3a2a00';
|
||||
resEl.style.color = '#ffa';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resEl.textContent = resultText;
|
||||
} catch (e) {
|
||||
resEl.style.background = '#3a2a00';
|
||||
resEl.style.color = '#ffa';
|
||||
resEl.textContent = 'Ошибка: ' + e.message;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🔍 Проверить синтаксис линтером';
|
||||
}
|
||||
}
|
||||
|
||||
var _assistantOpen = true;
|
||||
|
||||
function toggleAssistant() {
|
||||
_assistantOpen = !_assistantOpen;
|
||||
document.getElementById('assistant-body').style.display = _assistantOpen ? 'flex' : 'none';
|
||||
document.getElementById('assistant-toggle-icon').textContent = _assistantOpen ? '▲' : '▼';
|
||||
}
|
||||
|
||||
async function askAssistant() {
|
||||
var inp = document.getElementById('assistant-input');
|
||||
var msgs = document.getElementById('assistant-messages');
|
||||
var q = inp.value.trim();
|
||||
if (!q) return;
|
||||
inp.value = '';
|
||||
msgs.textContent += '\n\n👤 ' + q + '\n⏳ ...';
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
try {
|
||||
var r = await fetch('/console/api/ai/ask', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ mode: 'chat', question: q })
|
||||
});
|
||||
var d = await r.json();
|
||||
msgs.textContent = msgs.textContent.replace('⏳ ...', '🤖 ' + (d.answer || d.error || 'Нет ответа'));
|
||||
} catch (e) {
|
||||
msgs.textContent = msgs.textContent.replace('⏳ ...', '❌ Ошибка: ' + e.message);
|
||||
}
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
}
|
||||
|
||||
function showGenPrompt(prefix) {
|
||||
prefix = prefix || 'cc';
|
||||
var el = document.getElementById(prefix + '-gen-prompt');
|
||||
el.style.display = 'flex';
|
||||
document.getElementById(prefix + '-gen-desc').focus();
|
||||
}
|
||||
|
||||
async function aiGenerate(prefix) {
|
||||
prefix = prefix || 'cc';
|
||||
var name = (document.getElementById(prefix + '-name').value || '').trim() || 'my-func';
|
||||
var lang = document.getElementById(prefix + '-lang').value || 'python';
|
||||
var desc = (document.getElementById(prefix + '-gen-desc').value || '').trim();
|
||||
if (!desc) { document.getElementById(prefix + '-gen-desc').focus(); return; }
|
||||
var btn = document.getElementById(prefix + '-gen-btn');
|
||||
var resEl = document.getElementById(prefix + '-ai-result');
|
||||
btn.disabled = true; btn.textContent = '⏳ Генерирую...';
|
||||
resEl.style.display = 'block'; resEl.style.background = 'var(--bg-alt)'; resEl.style.color = 'var(--fg)';
|
||||
resEl.textContent = 'Запрашиваю у LLM...';
|
||||
try {
|
||||
var d = await requestJSON('/console/api/ai/ask', 'POST', {
|
||||
mode: 'codegen',
|
||||
language: lang,
|
||||
name: name,
|
||||
description: desc
|
||||
});
|
||||
var code = (d.answer || '').replace(/^```[\w]*\n?/, '').replace(/\n?```$/, '');
|
||||
document.getElementById(prefix + '-code').value = addLLMWarning(code, lang);
|
||||
document.getElementById(prefix + '-gen-prompt').style.display = 'none';
|
||||
resEl.style.background = '#1a3a1a'; resEl.style.color = '#8f8';
|
||||
resEl.textContent = 'Код сгенерирован и вставлен.';
|
||||
} catch (e) {
|
||||
resEl.style.background = '#3a2a00'; resEl.style.color = '#ffa';
|
||||
resEl.textContent = 'Ошибка: ' + e.message;
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = '✨ Сгенерировать код LLM';
|
||||
}
|
||||
}
|
||||
|
||||
async function aiExplain(codeId, langId, resultId) {
|
||||
var code = (document.getElementById(codeId).value || '').trim();
|
||||
var lang = (document.getElementById(langId) ? document.getElementById(langId).value : '') || 'python';
|
||||
var resEl = document.getElementById(resultId);
|
||||
var btnId = codeId === 'c-code' ? 'c-exp-btn' : 'e-exp-btn';
|
||||
var btn = document.getElementById(btnId);
|
||||
if (!code) { resEl.style.display = 'block'; resEl.style.background = 'var(--bg-alt)'; resEl.textContent = 'Введите код.'; return; }
|
||||
btn.disabled = true; btn.textContent = '⏳ Объясняю...';
|
||||
resEl.style.display = 'block'; resEl.style.background = 'var(--bg-alt)'; resEl.style.color = 'var(--fg)';
|
||||
resEl.textContent = 'Запрашиваю у LLM...';
|
||||
try {
|
||||
var d = await requestJSON('/console/api/ai/ask', 'POST', {
|
||||
mode: 'explain',
|
||||
language: lang,
|
||||
code: code
|
||||
});
|
||||
resEl.style.background = '#1a2a3a'; resEl.style.color = '#8cf';
|
||||
resEl.textContent = d.answer || '(пустой ответ)';
|
||||
} catch (e) {
|
||||
resEl.style.background = '#3a2a00'; resEl.style.color = '#ffa';
|
||||
resEl.textContent = 'Ошибка: ' + e.message;
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = '📖 LLM: Что делает?';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/* api.js — HTTP-утилиты */
|
||||
|
||||
function authHeaders() {
|
||||
return {
|
||||
'X-Auth-Token': localStorage.getItem('auth_token') || '',
|
||||
'X-Auth-Env': localStorage.getItem('auth_env') || 'test'
|
||||
};
|
||||
}
|
||||
|
||||
async function getJSON(url) {
|
||||
const r = await fetch(url, { headers: authHeaders() });
|
||||
if (r.status === 401) { doLogout(); throw new Error('Сессия истекла'); }
|
||||
if (!r.ok) {
|
||||
let msg = '';
|
||||
try {
|
||||
const body = await r.json();
|
||||
msg = body.error || JSON.stringify(body);
|
||||
} catch (_) {
|
||||
msg = await r.text();
|
||||
}
|
||||
throw new Error(url + ': ' + r.status + (msg ? ' - ' + msg : ''));
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function requestJSON(url, method, body) {
|
||||
const r = await fetch(url, {
|
||||
method: method,
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
if (r.status === 401) { doLogout(); throw new Error('Сессия истекла'); }
|
||||
let data = {};
|
||||
try { data = await r.json(); } catch (_) { }
|
||||
if (!r.ok) {
|
||||
throw new Error(data.error || (url + ': ' + r.status));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/* app.js — основная логика: загрузка данных, таблица функций */
|
||||
|
||||
async function reloadAll() {
|
||||
const progress = startTimedStatus('Загрузка...', 'Загрузка данных', null);
|
||||
|
||||
// Показываем таблицу сразу с placeholder'ом
|
||||
document.getElementById('fn-rows').innerHTML =
|
||||
'<tr><td colspan="8" style="color:var(--text-secondary); font-style:italic;">⏳ Загружаем функции...</td></tr>';
|
||||
|
||||
try {
|
||||
const [envs, pkgs, fns, http, times] = await Promise.all([
|
||||
getJSON(API_BASE + '/environments'),
|
||||
getJSON(API_BASE + '/packages'),
|
||||
getJSON(API_BASE + '/functions'),
|
||||
getJSON(API_BASE + '/httptriggers'),
|
||||
getJSON(API_BASE + '/timetriggers')
|
||||
]);
|
||||
|
||||
// MQ-триггеры загружаем параллельно, не блокируем основную таблицу
|
||||
if (typeof loadMQTriggers === 'function') loadMQTriggers();
|
||||
|
||||
S.envs = envs || [];
|
||||
S.fns = fns || [];
|
||||
S.httpTriggers = http || [];
|
||||
S.timeTriggers = times || [];
|
||||
|
||||
setText('env-count', envs.length || 0);
|
||||
setText('pkg-count', pkgs.length || 0);
|
||||
setText('fn-count', fns.length || 0);
|
||||
setText('http-count', http.length || 0);
|
||||
setText('cron-count', new Set((times || []).map(function (t) {
|
||||
return t && t.spec && t.spec.functionref && t.spec.functionref.name;
|
||||
}).filter(Boolean)).size);
|
||||
|
||||
function makeFnRow(f) {
|
||||
const spec = f.spec || {};
|
||||
const meta = f.metadata || {};
|
||||
const ann = meta.annotations || {};
|
||||
const env = (spec.environment && spec.environment.name) || '-';
|
||||
const pkg = (spec.package && spec.package.packageref && spec.package.packageref.name) || '-';
|
||||
const entrypoint = (spec.package && spec.package.functionName) || '-';
|
||||
const timeout = spec.functionTimeout || 60;
|
||||
const name = (f.metadata && f.metadata.name) || '-';
|
||||
const trig = httpTriggerByFn(name) || {};
|
||||
const timeTrig = timeTriggerByFn(name) || {};
|
||||
const route = (trig.spec && trig.spec.relativeurl) || '-';
|
||||
const methods = (trig.spec && trig.spec.methods) || [];
|
||||
const chips = methods.map(function (m) { return '<span class="chip">' + h(m) + '</span>'; }).join('');
|
||||
const cron = (timeTrig.spec && timeTrig.spec.cron) || '';
|
||||
const cronCell = cron ? '<span class="chip">' + h(cron) + '</span>' : '<span class="mono" style="color:var(--text-secondary)">—</span>';
|
||||
const createdAt = ann['fission-console/created-at'] || meta.creationTimestamp || '-';
|
||||
const updatedAt = ann['fission-console/updated-at'] || createdAt;
|
||||
const sourceType = ann['fission-console/source-type'] || 'code';
|
||||
const status = ann['fission-console/status'] || 'Cold';
|
||||
|
||||
// Статус функции: иконка и цвет
|
||||
const statusColor = {
|
||||
'Ready': '#2a2', 'Cold': '#22a', 'Building': '#aa2', 'Error': '#a22'
|
||||
}[status] || '#666';
|
||||
const statusTitle = {
|
||||
'Ready': 'Готова (есть pod)',
|
||||
'Cold': 'Холодная (pod создаётся при первом вызове)',
|
||||
'Building': 'Собирается / деплоится',
|
||||
'Error': 'Ошибка (pod в CrashLoopBackOff или build failed)'
|
||||
}[status] || 'Неизвестно';
|
||||
const statusDot = '<span style="display:inline-block; width:12px; height:12px; border-radius:50%; background:' + statusColor + '; cursor:default;" title="' + h(statusTitle) + '"></span>';
|
||||
|
||||
const sourceIcon = sourceType === 'archive'
|
||||
? '<span title="Из архива (.zip)" style="font-size:1.1em; cursor:default;">📦</span>'
|
||||
: '<span title="Из кода (редактор)" style="font-size:1.1em; cursor:default;">📝</span>';
|
||||
var isGo = /go[-_]env/.test(env);
|
||||
var isTf = /^tf-/.test(name);
|
||||
var tfBadge = (isGo || isTf) ? '<span class="chip" style="background:#555;color:#ffa" title="Управляется Terraform. Изменения могут быть перезаписаны при terraform apply.">TF</span> ' : '';
|
||||
// Сохраняем все данные в data-атрибуте для openInfo (избегаем повторного запроса)
|
||||
var infoData = h(JSON.stringify({
|
||||
name: name, env: env, pkg: pkg, entrypoint: entrypoint, timeout: timeout,
|
||||
route: route, methods: methods, sourceType: sourceType,
|
||||
createdAt: createdAt, updatedAt: updatedAt, cron: cron, status: status
|
||||
}));
|
||||
var actions = tfBadge +
|
||||
'<button class="btn ghost" onclick="openInfo(\'' + h(name) + '\', this)" data-info="' + infoData + '">Info</button> ' +
|
||||
'<button class="btn ghost" onclick="openEdit(\'' + h(name) + '\')">Ред.</button> ' +
|
||||
'<button class="btn ghost" onclick="openInvoke(\'' + h(name) + '\')">Вызов</button> ' +
|
||||
'<button class="btn ghost" onclick="openLogs(\'' + h(name) + '\')">Логи</button> ' +
|
||||
'<button class="btn danger" onclick="removeFn(\'' + h(name) + '\')">Удалить</button>';
|
||||
return '<tr>' +
|
||||
'<td class="mono">' + h(name) + '</td>' +
|
||||
'<td style="text-align:center;">' + statusDot + '</td>' +
|
||||
'<td style="text-align:center;">' + sourceIcon + '</td>' +
|
||||
'<td>' + timestampCell(createdAt) + '</td>' +
|
||||
'<td>' + timestampCell(updatedAt) + '</td>' +
|
||||
'<td class="mono">' + h(route) + '</td>' +
|
||||
'<td>' + chips + '</td>' +
|
||||
'<td>' + cronCell + '</td>' +
|
||||
'<td class="nowrap">' + actions + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
var fnList = fns || [];
|
||||
if (fnList.length === 0) {
|
||||
progress.stop('Загружено: нет функций', '');
|
||||
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="8">Нет функций</td></tr>';
|
||||
} else {
|
||||
// Рендерим первую строку сразу — убираем placeholder
|
||||
var tbody = document.getElementById('fn-rows');
|
||||
tbody.innerHTML = makeFnRow(fnList[0]);
|
||||
// Остальные добавляем по одной с небольшой задержкой
|
||||
var i = 1;
|
||||
function appendNext() {
|
||||
if (i >= fnList.length) {
|
||||
progress.stop('Загружено ' + fnList.length + ' функций', 'ok');
|
||||
return;
|
||||
}
|
||||
var tr = document.createElement('tbody');
|
||||
tr.innerHTML = makeFnRow(fnList[i]);
|
||||
tbody.appendChild(tr.firstChild);
|
||||
i++;
|
||||
setTimeout(appendNext, 40);
|
||||
}
|
||||
setTimeout(appendNext, 40);
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('fn-rows').innerHTML = '<tr><td colspan="8">Load error: ' + e.message + '</td></tr>';
|
||||
progress.stop('Ошибка загрузки: ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/* auth.js — авторизация */
|
||||
|
||||
function showLoginOverlay() {
|
||||
document.getElementById('login-overlay').style.display = 'flex';
|
||||
}
|
||||
|
||||
function hideLoginOverlay() {
|
||||
document.getElementById('login-overlay').style.display = 'none';
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
var btn = document.getElementById('l-btn');
|
||||
var errEl = document.getElementById('l-error');
|
||||
var token = (document.getElementById('l-token').value || '').trim();
|
||||
var env = document.getElementById('l-env').value;
|
||||
if (!token) { errEl.textContent = 'Введите токен или логин'; errEl.style.display = 'block'; return; }
|
||||
var isJWT = token.split('.').length === 3 && !token.includes(' ');
|
||||
if (!isJWT && token.length < 6) { errEl.textContent = 'Логин — не менее 6 символов'; errEl.style.display = 'block'; return; }
|
||||
btn.disabled = true;
|
||||
errEl.style.display = 'none';
|
||||
try {
|
||||
const res = await fetch(API_BASE + '/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: token, env: env })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(function () { return {}; });
|
||||
throw new Error(d.error || 'Ошибка входа');
|
||||
}
|
||||
const data = await res.json();
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('auth_env', env);
|
||||
if (data.email) localStorage.setItem('auth_email', data.email);
|
||||
else localStorage.removeItem('auth_email');
|
||||
setUserAvatar(token);
|
||||
hideLoginOverlay();
|
||||
// Проверяем статус NS — если не ready, показываем init overlay
|
||||
try {
|
||||
var sr = await fetch(API_BASE + '/ns/status', { headers: { 'X-Auth-Token': token, 'X-Auth-Env': env } });
|
||||
var sd = await sr.json();
|
||||
if (!sd.ready) {
|
||||
pollNSStatus();
|
||||
return;
|
||||
}
|
||||
} catch (_) { }
|
||||
reloadAll();
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message;
|
||||
errEl.style.display = 'block';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function jwtEmail(token) {
|
||||
try {
|
||||
var payload = JSON.parse(atob(token.split('.')[1].replace(/-/g,'+').replace(/_/g,'/')));
|
||||
return payload.email || payload.sub || null;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
function setUserAvatar(token) {
|
||||
var el = document.getElementById('user-avatar');
|
||||
if (!el) return;
|
||||
var email = localStorage.getItem('auth_email') || (token ? jwtEmail(token) : null);
|
||||
if (email) {
|
||||
el.textContent = email;
|
||||
el.title = email;
|
||||
el.style.fontSize = '0.75rem';
|
||||
el.style.minWidth = 'auto';
|
||||
el.style.padding = '0 10px';
|
||||
el.style.background = 'none';
|
||||
el.style.border = '1px solid var(--border)';
|
||||
el.style.borderRadius = '6px';
|
||||
el.style.color = 'var(--text-primary)';
|
||||
el.style.fontWeight = '400';
|
||||
el.style.width = 'auto';
|
||||
el.style.height = '30px';
|
||||
} else {
|
||||
el.textContent = 'N';
|
||||
el.title = '';
|
||||
el.style.cssText = '';
|
||||
}
|
||||
}
|
||||
|
||||
function doLogout() {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_env');
|
||||
localStorage.removeItem('auth_email');
|
||||
try { document.getElementById('l-token').value = ''; } catch (_) { }
|
||||
setUserAvatar(null);
|
||||
showLoginOverlay();
|
||||
}
|
||||
|
||||
async function openAnalytics() {
|
||||
var token = localStorage.getItem('auth_token');
|
||||
var env = localStorage.getItem('auth_env') || 'test';
|
||||
if (!token) { alert('Требуется авторизация'); return; }
|
||||
try {
|
||||
var r = await fetch(API_BASE + '/stats/dashboard-url', {
|
||||
headers: { 'X-Auth-Token': token, 'X-Auth-Env': env }
|
||||
});
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
var d = await r.json();
|
||||
if (d.url) {
|
||||
window.open(d.url, '_blank', 'noopener');
|
||||
} else {
|
||||
// Grafana не настроена или org ещё провизируется — ссылка на оператора
|
||||
window.open('/grafana/', '_blank', 'noopener');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Аналитика временно недоступна: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function checkAuth() {
|
||||
var storedToken = localStorage.getItem('auth_token');
|
||||
if (!storedToken) {
|
||||
showLoginOverlay();
|
||||
return;
|
||||
}
|
||||
setUserAvatar(storedToken);
|
||||
hideLoginOverlay();
|
||||
reloadAll();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* config.js — константы и шаблоны языков */
|
||||
|
||||
const LANG_TEMPLATES = {
|
||||
python: {
|
||||
entrypoint: 'main.main',
|
||||
code: 'def main():\n return {"ok": True}'
|
||||
},
|
||||
nodejs: {
|
||||
entrypoint: 'main',
|
||||
code: 'module.exports = async function(context) {\n return {\n status: 200,\n body: "hello from fission"\n };\n}'
|
||||
},
|
||||
php: {
|
||||
entrypoint: 'main.php::handler',
|
||||
code: '<?php\nfunction handler($context)\n{\n $response = $context["response"];\n $response->getBody()->write("hello from fission");\n}'
|
||||
},
|
||||
ruby: {
|
||||
entrypoint: 'handler',
|
||||
code: '# frozen_string_literal: true\n\ndef handler\n "hello from fission"\nend'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
/* fn-archive.js — создание функции из архива (независимый модуль) */
|
||||
|
||||
function onLangChangeArchive() {
|
||||
var lang = document.getElementById('ca-lang').value;
|
||||
var t = LANG_TEMPLATES[lang];
|
||||
if (t) {
|
||||
document.getElementById('ca-entry').value = t.entrypoint;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateArchive() {
|
||||
document.getElementById('ca-lang').value = 'python';
|
||||
onLangChangeArchive();
|
||||
document.getElementById('ca-name').value = '';
|
||||
document.getElementById('ca-route').value = '';
|
||||
document.getElementById('ca-methods').value = 'GET';
|
||||
document.getElementById('ca-timeout').value = '60';
|
||||
document.getElementById('ca-schedule-enabled').checked = false;
|
||||
document.getElementById('ca-cron').value = '';
|
||||
toggleScheduleFields('ca');
|
||||
var fileInput = document.getElementById('ca-archive-file');
|
||||
if (fileInput) fileInput.value = '';
|
||||
var aiRes = document.getElementById('ca-ai-result');
|
||||
if (aiRes) { aiRes.style.display = 'none'; aiRes.textContent = ''; }
|
||||
hideModalError('ca-error');
|
||||
document.getElementById('create-archive-modal').classList.add('open');
|
||||
document.getElementById('ca-name').focus();
|
||||
}
|
||||
|
||||
function closeCreateArchive() {
|
||||
document.getElementById('create-archive-modal').classList.remove('open');
|
||||
}
|
||||
|
||||
async function submitCreateArchive() {
|
||||
const btn = document.getElementById('ca-submit');
|
||||
btn.disabled = true;
|
||||
const progress = startTimedStatus('Создаём функцию...', 'Создание функции...', explainDelay);
|
||||
try {
|
||||
const name = document.getElementById('ca-name').value.trim();
|
||||
if (!name) throw new Error('name is required');
|
||||
const lang = document.getElementById('ca-lang').value.trim();
|
||||
if (!lang) throw new Error('language is required');
|
||||
const archiveFile = document.getElementById('ca-archive-file').files[0];
|
||||
if (!archiveFile) throw new Error('выберите .zip архив');
|
||||
if (!archiveFile.name.toLowerCase().endsWith('.zip')) throw new Error('файл должен быть .zip архивом, не .' + archiveFile.name.split('.').pop());
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('name', name);
|
||||
fd.append('language', lang);
|
||||
fd.append('entrypoint', document.getElementById('ca-entry').value.trim());
|
||||
fd.append('route', document.getElementById('ca-route').value.trim());
|
||||
fd.append('methods', document.getElementById('ca-methods').value.trim());
|
||||
fd.append('timeout', String(parseTimeout(document.getElementById('ca-timeout').value)));
|
||||
fd.append('archive', archiveFile);
|
||||
|
||||
var resp = await fetch(API_BASE + '/functions', {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: fd
|
||||
});
|
||||
if (!resp.ok) {
|
||||
var e = await resp.json().catch(() => ({}));
|
||||
throw new Error(e.error || resp.statusText);
|
||||
}
|
||||
|
||||
try {
|
||||
await syncScheduleForFunction(name, 'ca');
|
||||
} catch (scheduleErr) {
|
||||
try {
|
||||
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name), 'DELETE');
|
||||
} catch (rollbackErr) {
|
||||
scheduleErr.message += '; rollback failed: ' + rollbackErr.message;
|
||||
}
|
||||
throw scheduleErr;
|
||||
}
|
||||
|
||||
closeCreateArchive();
|
||||
progress.stop('Функция создана: ' + name, 'ok');
|
||||
await reloadAll();
|
||||
} catch (e) {
|
||||
progress.stop('Ошибка создания: ' + e.message, 'err');
|
||||
showModalError('ca-error', 'Ошибка: ' + e.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/* fn-code.js — создание функции из кода (независимый модуль) */
|
||||
|
||||
function onLangChangeCode() {
|
||||
var lang = document.getElementById('cc-lang').value;
|
||||
var t = LANG_TEMPLATES[lang];
|
||||
if (t) {
|
||||
document.getElementById('cc-entry').value = t.entrypoint;
|
||||
document.getElementById('cc-code').value = t.code;
|
||||
}
|
||||
// Обновляем лейбл поля зависимостей под язык
|
||||
var depsLabels = {
|
||||
python: 'Зависимости (requirements.txt)',
|
||||
nodejs: 'Зависимости (package.json dependencies)',
|
||||
php: 'Зависимости (composer.json)',
|
||||
ruby: 'Зависимости (Gemfile)',
|
||||
};
|
||||
var depsPlaceholders = {
|
||||
python: 'boto3\nrequests>=2.28\npsycopg2-binary',
|
||||
nodejs: 'express: ^4.18.2\naxios: ^1.6.0',
|
||||
php: '{\n "require": {\n "guzzlehttp/guzzle": "^7.0"\n }\n}',
|
||||
ruby: "gem 'httparty'\ngem 'pg'",
|
||||
};
|
||||
var label = document.getElementById('cc-deps-label');
|
||||
var area = document.getElementById('cc-deps');
|
||||
if (label) label.textContent = depsLabels[lang] || 'Зависимости';
|
||||
if (area) area.placeholder = depsPlaceholders[lang] || '';
|
||||
}
|
||||
|
||||
function openCreateCode() {
|
||||
document.getElementById('cc-lang').value = 'python';
|
||||
onLangChangeCode();
|
||||
document.getElementById('cc-name').value = '';
|
||||
document.getElementById('cc-route').value = '';
|
||||
document.getElementById('cc-methods').value = 'GET';
|
||||
document.getElementById('cc-timeout').value = '60';
|
||||
document.getElementById('cc-deps').value = '';
|
||||
document.getElementById('cc-schedule-enabled').checked = false;
|
||||
document.getElementById('cc-cron').value = '';
|
||||
toggleScheduleFields('cc');
|
||||
var aiRes = document.getElementById('cc-ai-result');
|
||||
if (aiRes) { aiRes.style.display = 'none'; aiRes.textContent = ''; }
|
||||
hideModalError('cc-error');
|
||||
document.getElementById('create-code-modal').classList.add('open');
|
||||
document.getElementById('cc-name').focus();
|
||||
}
|
||||
|
||||
function closeCreateCode() {
|
||||
document.getElementById('create-code-modal').classList.remove('open');
|
||||
}
|
||||
|
||||
async function submitCreateCode() {
|
||||
const btn = document.getElementById('cc-submit');
|
||||
btn.disabled = true;
|
||||
const progress = startTimedStatus('Создаём функцию...', 'Создание функции...', explainDelay);
|
||||
try {
|
||||
const name = document.getElementById('cc-name').value.trim();
|
||||
if (!name) throw new Error('name is required');
|
||||
const lang = document.getElementById('cc-lang').value.trim();
|
||||
if (!lang) throw new Error('language is required');
|
||||
|
||||
await requestJSON(API_BASE + '/functions', 'POST', {
|
||||
name: name,
|
||||
language: lang,
|
||||
entrypoint: document.getElementById('cc-entry').value.trim(),
|
||||
route: document.getElementById('cc-route').value.trim(),
|
||||
methods: parseMethods(document.getElementById('cc-methods').value),
|
||||
timeout: parseTimeout(document.getElementById('cc-timeout').value),
|
||||
code: document.getElementById('cc-code').value,
|
||||
deps: document.getElementById('cc-deps').value.trim(),
|
||||
});
|
||||
|
||||
try {
|
||||
await syncScheduleForFunction(name, 'cc');
|
||||
} catch (scheduleErr) {
|
||||
try {
|
||||
await requestJSON(API_BASE + '/functions/' + encodeURIComponent(name), 'DELETE');
|
||||
} catch (rollbackErr) {
|
||||
scheduleErr.message += '; rollback failed: ' + rollbackErr.message;
|
||||
}
|
||||
throw scheduleErr;
|
||||
}
|
||||
|
||||
closeCreateCode();
|
||||
progress.stop('Функция создана: ' + name, 'ok');
|
||||
await reloadAll();
|
||||
} catch (e) {
|
||||
progress.stop('Ошибка создания: ' + e.message, 'err');
|
||||
showModalError('cc-error', 'Ошибка: ' + e.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
/* 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 depsLabels = {python:'Зависимости (requirements.txt)', nodejs:'Зависимости (package.json dependencies)', php:'Зависимости (composer.json)', ruby:'Зависимости (Gemfile)'};
|
||||
var depsPlaceholders = {python:'boto3\nrequests>=2.28', nodejs:'express: ^4.18.2\naxios: ^1.6.0', php:'{\n "require": {\n "guzzlehttp/guzzle": "^7.0"\n }\n}', ruby:"gem 'httparty'\ngem 'pg'"};
|
||||
var eDepsLabel = document.getElementById('e-deps-label');
|
||||
var eDepsArea = document.getElementById('e-deps');
|
||||
if (eDepsLabel) eDepsLabel.textContent = depsLabels[lang] || 'Зависимости';
|
||||
if (eDepsArea) { eDepsArea.placeholder = depsPlaceholders[lang] || ''; eDepsArea.value = fn.deps || ''; }
|
||||
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,
|
||||
deps: document.getElementById('e-deps').value.trim(),
|
||||
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();
|
||||
showStatus('Клонирование завершено, обновляем список...', '');
|
||||
var attempts = 0;
|
||||
async function pollUntilVisible() {
|
||||
attempts++;
|
||||
await reloadAll();
|
||||
var found = (S.fns || []).some(function(f) {
|
||||
return f && f.metadata && f.metadata.name === newName;
|
||||
});
|
||||
if (found || attempts >= 5) {
|
||||
showStatus('Функция «' + newName + '» создана как копия «' + srcName + '»', 'ok');
|
||||
} else {
|
||||
setTimeout(pollUntilVisible, 1000);
|
||||
}
|
||||
}
|
||||
setTimeout(pollUntilVisible, 700);
|
||||
} catch(e) {
|
||||
if (errEl) { errEl.textContent = e.message; errEl.style.display = 'block'; }
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Клонировать'; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/* init.js — инициализация namespace, утилиты таймера и статуса */
|
||||
|
||||
var _initPolling = false;
|
||||
var _initTickBusy = false;
|
||||
var _initStartedAt = 0;
|
||||
|
||||
function initLog(msg) {
|
||||
var el = document.getElementById('init-log');
|
||||
var ts = new Date().toLocaleTimeString('ru-RU');
|
||||
el.textContent += '[' + ts + '] ' + msg + '\n';
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
function formatSeconds(totalSeconds) {
|
||||
totalSeconds = Math.max(0, Number(totalSeconds) || 0);
|
||||
var minutes = Math.floor(totalSeconds / 60);
|
||||
var seconds = totalSeconds % 60;
|
||||
if (minutes <= 0) return seconds + 'с';
|
||||
return minutes + 'м ' + seconds + 'с';
|
||||
}
|
||||
|
||||
function setInitElapsed(seconds) {
|
||||
document.getElementById('init-elapsed').textContent = formatSeconds(seconds);
|
||||
}
|
||||
|
||||
function setInitCurrentStage(text) {
|
||||
document.getElementById('init-current-stage').textContent = text || 'Ожидание старта';
|
||||
}
|
||||
|
||||
function setBox(id, text) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
if (!text) {
|
||||
el.style.display = 'none';
|
||||
el.textContent = '';
|
||||
return;
|
||||
}
|
||||
el.style.display = 'block';
|
||||
el.textContent = text;
|
||||
}
|
||||
|
||||
function missingKeys(obj) {
|
||||
return Object.keys(obj || {}).filter(function (key) {
|
||||
return !obj[key];
|
||||
});
|
||||
}
|
||||
|
||||
function firstPendingStage(stages) {
|
||||
var list = stages || [];
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (!list[i].done) return list[i].name;
|
||||
}
|
||||
return list.length ? list[list.length - 1].name : 'Ожидание старта';
|
||||
}
|
||||
|
||||
function describeInitReason(debug) {
|
||||
if (!debug) return 'Собираем диагностические данные по namespace и control-plane...';
|
||||
if (!debug.exists) return 'Ещё не создан Kubernetes namespace пользователя.';
|
||||
if (!debug.active) return 'Namespace создан, но Kubernetes ещё не перевёл его в состояние Active.';
|
||||
|
||||
var preparation = debug.preparation || {};
|
||||
if (!preparation.managedByConsole) return 'Namespace существует, но ещё не помечен как managed-by=fission-console.';
|
||||
if (!preparation.watcherLabel) return 'Namespace ещё не помечен label fission.io/managed=true для NSWatcher.';
|
||||
|
||||
var missingSAs = missingKeys(preparation.serviceAccounts);
|
||||
if (missingSAs.length) return 'Не готовы bootstrap service accounts: ' + missingSAs.join(', ') + '.';
|
||||
|
||||
var missingRBs = missingKeys(preparation.roleBindings);
|
||||
if (missingRBs.length) return 'Не готовы bootstrap role bindings: ' + missingRBs.join(', ') + '.';
|
||||
|
||||
var control = debug.controlPlane || {};
|
||||
if (!control.executor || !control.executor.ready) {
|
||||
return 'Executor ещё не ready. Возможна задержка из-за cold start, image pull или specialization.';
|
||||
}
|
||||
if (!control.router || !control.router.ready) {
|
||||
return 'Router ещё не ready. HTTP маршрутизация пока не активна.';
|
||||
}
|
||||
|
||||
return 'Ожидаем финальное подтверждение готовности control-plane.';
|
||||
}
|
||||
|
||||
function summarizeInit(status, debug) {
|
||||
var done = status && status.stages ? status.stages.filter(function (s) { return s.done; }).length : 0;
|
||||
var namespace = debug && debug.namespace ? debug.namespace : 'не определён';
|
||||
return 'Namespace: ' + namespace + '. Завершено шагов: ' + done + '/3.';
|
||||
}
|
||||
|
||||
function explainDelay(seconds) {
|
||||
if (seconds < 5) return 'Обычная операция без заметной задержки.';
|
||||
if (seconds < 10) return 'Вероятен cold start: прогрев пула, image pull или ожидание specialization.';
|
||||
return 'Задержка выше нормы: возможны cold start, specialization или ожидание готовности control-plane.';
|
||||
}
|
||||
|
||||
function startTimedStatus(startText, waitingPrefix, waitingHint) {
|
||||
var startedAt = Date.now();
|
||||
showStatus(startText, '');
|
||||
var timer = setInterval(function () {
|
||||
var elapsed = Math.max(1, Math.floor((Date.now() - startedAt) / 1000));
|
||||
var text = waitingPrefix + ' ' + formatSeconds(elapsed);
|
||||
if (waitingHint) text += ' · ' + waitingHint(elapsed);
|
||||
showStatus(text, '');
|
||||
}, 1000);
|
||||
return {
|
||||
stop: function (message, kind) {
|
||||
clearInterval(timer);
|
||||
var elapsed = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
|
||||
var text = message;
|
||||
if (message) text += ' за ' + formatSeconds(elapsed);
|
||||
showStatus(text, kind || '');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function renderInitStages(stages) {
|
||||
var el = document.getElementById('init-stages');
|
||||
el.innerHTML = stages.map(function (s) {
|
||||
var icon = s.done ? '✅' : '⏳';
|
||||
var color = s.done ? '#4caf50' : '#8bc7ff';
|
||||
return '<div style="font-size:13px; color:' + color + ';">' + icon + ' ' + s.name + '</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function pollNSStatus() {
|
||||
if (_initPolling) return;
|
||||
_initPolling = true;
|
||||
_initTickBusy = false;
|
||||
_initStartedAt = Date.now();
|
||||
var overlay = document.getElementById('init-overlay');
|
||||
overlay.style.display = 'flex';
|
||||
document.getElementById('init-log').textContent = '';
|
||||
setInitElapsed(0);
|
||||
setInitCurrentStage('Подготовка запуска');
|
||||
setBox('init-summary', 'Проверяем состояние namespace и control-plane...');
|
||||
setBox('init-reason', 'Если пользователь новый, возможны cold start и создание bootstrap ресурсов.');
|
||||
setBox('init-error', '');
|
||||
initLog('Запуск инициализации...');
|
||||
var attempt = 0;
|
||||
var maxAttempts = 60; // 5 минут
|
||||
var interval = setInterval(async function () {
|
||||
if (_initTickBusy) return;
|
||||
_initTickBusy = true;
|
||||
attempt++;
|
||||
try {
|
||||
var elapsed = Math.floor((Date.now() - _initStartedAt) / 1000);
|
||||
setInitElapsed(elapsed);
|
||||
var statusResp = await fetch(API_BASE + '/ns/status', { headers: authHeaders() });
|
||||
var debugResp = await fetch(API_BASE + '/ns/debug', { headers: authHeaders() });
|
||||
var d = await statusResp.json();
|
||||
var dbg = await debugResp.json().catch(function () { return null; });
|
||||
if (d.stages) renderInitStages(d.stages);
|
||||
setInitCurrentStage(firstPendingStage(d.stages));
|
||||
setBox('init-summary', summarizeInit(d, dbg));
|
||||
setBox('init-reason', describeInitReason(dbg));
|
||||
setBox('init-error', '');
|
||||
var done = d.stages ? d.stages.filter(function (s) { return s.done; }).length : 0;
|
||||
initLog('Шагов завершено: ' + done + '/3');
|
||||
if (d.ready) {
|
||||
initLog('✅ Готово! Загружаем консоль...');
|
||||
clearInterval(interval);
|
||||
_initPolling = false;
|
||||
_initTickBusy = false;
|
||||
overlay.style.display = 'none';
|
||||
reloadAll();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
setBox('init-error', 'Последняя ошибка: ' + e.message);
|
||||
initLog('Ошибка опроса: ' + e.message);
|
||||
} finally {
|
||||
_initTickBusy = false;
|
||||
}
|
||||
if (attempt >= maxAttempts) {
|
||||
clearInterval(interval);
|
||||
_initPolling = false;
|
||||
_initTickBusy = false;
|
||||
setBox('init-error', 'Превышено время ожидания. Чаще всего это bootstrap RBAC, control-plane readiness или холодный старт.');
|
||||
initLog('⚠️ Превышено время ожидания. Попробуйте обновить страницу.');
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/* modals.js — управление модальными окнами Help и Invoke */
|
||||
|
||||
// makeDraggable — делает .panel внутри modalId перетаскиваемым за заголовок (h3).
|
||||
function makeDraggable(modalId) {
|
||||
var modal = document.getElementById(modalId);
|
||||
if (!modal) return;
|
||||
var panel = modal.querySelector('.panel');
|
||||
if (!panel) return;
|
||||
var handle = panel.querySelector('h3');
|
||||
if (!handle) return;
|
||||
|
||||
handle.style.cursor = 'move';
|
||||
handle.style.userSelect = 'none';
|
||||
|
||||
var startX, startY, startLeft, startTop;
|
||||
|
||||
handle.addEventListener('mousedown', function(e) {
|
||||
e.preventDefault();
|
||||
var rect = panel.getBoundingClientRect();
|
||||
// Переводим в position:fixed если ещё не
|
||||
if (!panel.style.left) {
|
||||
panel.style.position = 'fixed';
|
||||
panel.style.left = rect.left + 'px';
|
||||
panel.style.top = rect.top + 'px';
|
||||
panel.style.margin = '0';
|
||||
}
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
startLeft = parseInt(panel.style.left, 10) || rect.left;
|
||||
startTop = parseInt(panel.style.top, 10) || rect.top;
|
||||
|
||||
function onMove(e) {
|
||||
var dx = e.clientX - startX;
|
||||
var dy = e.clientY - startY;
|
||||
panel.style.left = (startLeft + dx) + 'px';
|
||||
panel.style.top = (startTop + dy) + 'px';
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
}
|
||||
|
||||
// Инициализация после загрузки DOM
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
['create-code-modal', 'create-archive-modal', 'edit-modal', 'help-modal', 'invoke-modal', 'logs-modal', 'info-modal'].forEach(makeDraggable);
|
||||
|
||||
// ESC закрывает активное модальное окно
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key !== 'Escape') return;
|
||||
var modals = ['create-code-modal', 'create-archive-modal', 'edit-modal', 'help-modal', 'invoke-modal', 'logs-modal', 'info-modal'];
|
||||
for (var i = 0; i < modals.length; i++) {
|
||||
var el = document.getElementById(modals[i]);
|
||||
if (el && el.classList.contains('open')) {
|
||||
el.classList.remove('open');
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// showModalError — показывает ошибку внутри модалки.
|
||||
// errorDivId — id элемента с классом modal-error.
|
||||
function showModalError(errorDivId, message) {
|
||||
var el = document.getElementById(errorDivId);
|
||||
if (!el) return;
|
||||
el.textContent = message;
|
||||
el.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideModalError(errorDivId) {
|
||||
var el = document.getElementById(errorDivId);
|
||||
if (el) { el.style.display = 'none'; el.textContent = ''; }
|
||||
}
|
||||
|
||||
function openHelp() {
|
||||
document.getElementById('help-modal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeHelp() {
|
||||
document.getElementById('help-modal').classList.remove('open');
|
||||
}
|
||||
|
||||
function openInvoke(name) {
|
||||
S.currentInvoke = name;
|
||||
document.getElementById('i-title').textContent = 'Вызов: ' + name;
|
||||
document.getElementById('i-resp').value = '';
|
||||
document.getElementById('i-status').textContent = '';
|
||||
document.getElementById('i-meta').style.display = 'none';
|
||||
document.getElementById('i-meta').textContent = '';
|
||||
document.getElementById('invoke-modal').classList.add('open');
|
||||
}
|
||||
|
||||
function closeInvoke() {
|
||||
document.getElementById('invoke-modal').classList.remove('open');
|
||||
S.currentInvoke = null;
|
||||
}
|
||||
|
||||
async function submitInvoke() {
|
||||
if (!S.currentInvoke) return;
|
||||
const btn = document.getElementById('i-submit');
|
||||
const statusEl = document.getElementById('i-status');
|
||||
const metaEl = document.getElementById('i-meta');
|
||||
const respEl = document.getElementById('i-resp');
|
||||
btn.disabled = true;
|
||||
respEl.value = '';
|
||||
metaEl.style.display = 'none';
|
||||
metaEl.textContent = '';
|
||||
let elapsed = 0;
|
||||
statusEl.textContent = 'Вызов...';
|
||||
const timer = setInterval(() => {
|
||||
elapsed++;
|
||||
if (elapsed < 5) {
|
||||
statusEl.textContent = 'Вызов... ' + elapsed + 'с';
|
||||
} else if (elapsed < 10) {
|
||||
statusEl.textContent = '⏳ Возможен cold start — прогрев пула... ' + elapsed + 'с';
|
||||
} else {
|
||||
statusEl.textContent = '⏳ Возможны cold start или specialization... ' + elapsed + 'с';
|
||||
}
|
||||
}, 1000);
|
||||
try {
|
||||
const raw = document.getElementById('i-body').value.trim();
|
||||
let parsed = {};
|
||||
if (raw) parsed = JSON.parse(raw);
|
||||
const result = await requestJSON(API_BASE + '/functions/' + encodeURIComponent(S.currentInvoke) + '/invoke', 'POST', parsed);
|
||||
var latencyMs = Number(result.latency_ms || 0);
|
||||
var measuredSeconds = latencyMs > 0 ? Math.max(1, Math.round(latencyMs / 1000)) : elapsed;
|
||||
statusEl.textContent = '✓ Выполнено за ' + formatSeconds(measuredSeconds);
|
||||
metaEl.style.display = 'block';
|
||||
metaEl.textContent = [
|
||||
'HTTP status: ' + (result.status || 'n/a'),
|
||||
'Latency: ' + (latencyMs || 'n/a') + ' ms',
|
||||
'Причина задержки: ' + explainDelay(measuredSeconds)
|
||||
].join('\n');
|
||||
respEl.value = JSON.stringify(result, null, 2);
|
||||
} catch (e) {
|
||||
statusEl.textContent = '✗ Ошибка после ' + formatSeconds(elapsed);
|
||||
metaEl.style.display = 'block';
|
||||
metaEl.textContent = [
|
||||
'Последняя ошибка: ' + e.message,
|
||||
'Вероятная причина задержки: ' + explainDelay(elapsed)
|
||||
].join('\n');
|
||||
respEl.value = 'Ошибка вызова: ' + e.message;
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// openLogs — открывает модалку с логами функции.
|
||||
async function openLogs(name) {
|
||||
S.currentLogs = name;
|
||||
document.getElementById('logs-title').textContent = 'Логи: ' + name;
|
||||
document.getElementById('logs-output').value = 'Загрузка...';
|
||||
document.getElementById('logs-modal').classList.add('open');
|
||||
await _fetchLogs(name);
|
||||
}
|
||||
|
||||
function closeLogs() {
|
||||
document.getElementById('logs-modal').classList.remove('open');
|
||||
S.currentLogs = null;
|
||||
}
|
||||
|
||||
async function refreshLogs() {
|
||||
if (S.currentLogs) await _fetchLogs(S.currentLogs);
|
||||
}
|
||||
|
||||
async function _fetchLogs(name) {
|
||||
var out = document.getElementById('logs-output');
|
||||
try {
|
||||
var data = await getJSON(API_BASE + '/functions/' + encodeURIComponent(name) + '/logs');
|
||||
out.value = data.logs || '(нет логов)';
|
||||
// прокручиваем вниз
|
||||
out.scrollTop = out.scrollHeight;
|
||||
} catch (e) {
|
||||
out.value = 'Ошибка загрузки логов: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// openInfo — открывает модалку с подробной информацией о функции.
|
||||
// Данные берутся из data-атрибута кнопки (без дополнительного запроса к API).
|
||||
function openInfo(name, btn) {
|
||||
var raw = btn ? btn.getAttribute('data-info') : null;
|
||||
var info = null;
|
||||
try { info = raw ? JSON.parse(raw) : null; } catch(e) {}
|
||||
if (!info) {
|
||||
document.getElementById('info-rows').innerHTML = '<tr><td colspan="2">Нет данных</td></tr>';
|
||||
document.getElementById('info-title').textContent = 'Информация: ' + name;
|
||||
document.getElementById('info-modal').classList.add('open');
|
||||
return;
|
||||
}
|
||||
|
||||
// copyBtn — кнопка копирования конкретного значения через data-атрибут (безопасно для любых символов).
|
||||
function copyBtn(val) {
|
||||
if (!val || val === '-') return '';
|
||||
var encoded = val.replace(/&/g, '&').replace(/"/g, '"');
|
||||
return ' <button class="btn ghost" style="padding:1px 6px; font-size:11px;" data-copy="' + encoded + '" onclick="navigator.clipboard.writeText(this.dataset.copy)">📋</button>';
|
||||
}
|
||||
|
||||
// Строки без кнопки копирования (только отображение).
|
||||
function plain(val) {
|
||||
return '<td class="mono" style="word-break:break-all;padding:4px 0;">' + h(val || '-') + '</td>';
|
||||
}
|
||||
|
||||
// Строки с кнопкой копирования (Имя, Пакет, Маршрут — генерируемые или идентификаторы).
|
||||
function withCopy(val) {
|
||||
return '<td class="mono" style="word-break:break-all;padding:4px 0;">' + h(val || '-') + copyBtn(val) + '</td>';
|
||||
}
|
||||
|
||||
function labelTd(label) {
|
||||
return '<td style="color:var(--text-secondary);padding:4px 10px 4px 0;white-space:nowrap;vertical-align:top;">' + h(label) + '</td>';
|
||||
}
|
||||
|
||||
// CURL-строка для внешнего вызова (полная, с токеном из localStorage).
|
||||
var token = localStorage.getItem('auth_token') || '';
|
||||
var method = (Array.isArray(info.methods) && info.methods.length) ? info.methods[0] : 'GET';
|
||||
var externalUrl = window.location.origin + '/fn' + (info.route !== '-' ? info.route : '');
|
||||
var curlFull = 'curl -H "X-Auth-Token: ' + token + '" -X ' + method + ' "' + externalUrl + '"';
|
||||
// В отображении скрываем токен — показываем только метод и URL.
|
||||
var curlDisplay = 'curl ... -X ' + method + ' "' + externalUrl + '"';
|
||||
// Храним полную команду в data-атрибуте (HTML-encode), читаем через dataset — безопасно для любых символов.
|
||||
var curlAttr = curlFull.replace(/&/g, '&').replace(/"/g, '"');
|
||||
|
||||
var html = [
|
||||
'<tr>' + labelTd('Имя') + withCopy(info.name) + '</tr>',
|
||||
'<tr>' + labelTd('Окружение') + plain(info.env) + '</tr>',
|
||||
'<tr>' + labelTd('Пакет') + withCopy(info.pkg) + '</tr>',
|
||||
'<tr>' + labelTd('Entrypoint') + plain(info.entrypoint) + '</tr>',
|
||||
'<tr>' + labelTd('Таймаут') + plain(info.timeout ? info.timeout + ' сек' : '-') + '</tr>',
|
||||
'<tr>' + labelTd('Тип источника') + plain(info.sourceType === 'archive' ? '📦 archive' : '📝 code') + '</tr>',
|
||||
'<tr>' + labelTd('Маршрут') + withCopy(info.route) + '</tr>',
|
||||
'<tr>' + labelTd('Методы') + plain(Array.isArray(info.methods) && info.methods.length ? info.methods.join(', ') : '-') + '</tr>',
|
||||
'<tr>' + labelTd('Cron') + plain(info.cron || '-') + '</tr>',
|
||||
'<tr>' + labelTd('Создана') + plain(info.createdAt) + '</tr>',
|
||||
'<tr>' + labelTd('Изменена') + plain(info.updatedAt) + '</tr>',
|
||||
'<tr>' + labelTd('curl') +
|
||||
'<td class="mono" style="word-break:break-all;padding:4px 0;color:var(--text-secondary);">' +
|
||||
h(curlDisplay) +
|
||||
' <button class="btn ghost" style="padding:1px 6px; font-size:11px;" data-curl="' + curlAttr + '" onclick="navigator.clipboard.writeText(this.dataset.curl)">📋</button>' +
|
||||
'</td></tr>',
|
||||
].join('');
|
||||
|
||||
document.getElementById('info-title').textContent = 'Информация: ' + name;
|
||||
document.getElementById('info-rows').innerHTML = html;
|
||||
document.getElementById('info-modal').classList.add('open');
|
||||
}
|
||||
|
||||
// closeInfo — закрывает Info-модалку.
|
||||
function closeInfo() {
|
||||
document.getElementById('info-modal').classList.remove('open');
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// mq.js — MQ-триггеры (SQS → Fission function)
|
||||
// Архитектура: Console создаёт K8s Deployment + Secret (sqs-consumer) в namespace пользователя.
|
||||
// sqs-consumer поллит SQS очередь → HTTP POST в Fission-функцию → DeleteMessage.
|
||||
// Backend: POST /console/api/mqtriggers, DELETE /console/api/mqtriggers/{name}
|
||||
// v1.3.88
|
||||
|
||||
// ── Загрузка и отрисовка ────────────────────────────────────────────
|
||||
|
||||
async function loadMQTriggers() {
|
||||
try {
|
||||
const data = await apiFetch('/console/api/mqtriggers');
|
||||
renderMQTable(data.items || []);
|
||||
const cnt = document.getElementById('mq-count');
|
||||
if (cnt) cnt.textContent = (data.items || []).length;
|
||||
} catch (e) {
|
||||
renderMQTable([]);
|
||||
const cnt = document.getElementById('mq-count');
|
||||
if (cnt) cnt.textContent = '0';
|
||||
}
|
||||
}
|
||||
|
||||
function renderMQTable(items) {
|
||||
const tbody = document.getElementById('mq-rows');
|
||||
if (!tbody) return;
|
||||
if (!items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--fg-muted,#888);text-align:center;">Нет MQ-триггеров</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = items.map(t => `
|
||||
<tr>
|
||||
<td>${escHtml(t.name)}</td>
|
||||
<td>${escHtml(t.queue)}</td>
|
||||
<td>${escHtml(t.functionName)}</td>
|
||||
<td style="font-size:0.8em;color:var(--fg-muted,#aaa);">${escHtml(t.sqsEndpoint || '')}</td>
|
||||
<td><span style="color:${t.ready ? '#4a4' : '#a44'}">${t.ready ? '▶ Running' : '◼ Pending'}</span></td>
|
||||
<td>
|
||||
<button class="btn ghost small" onclick="deleteMQTrigger('${escHtml(t.name)}')">Удалить</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ── Создание ─────────────────────────────────────────────────────────
|
||||
|
||||
function openCreateMQ() {
|
||||
document.getElementById('mq-name').value = '';
|
||||
document.getElementById('mq-fn').value = '';
|
||||
document.getElementById('mq-queue').value = '';
|
||||
document.getElementById('mq-endpoint').value = 'http://shared-sqs.shared-sqs.svc.cluster.local:4100';
|
||||
document.getElementById('mq-access-key').value = '';
|
||||
document.getElementById('mq-secret-key').value = '';
|
||||
document.getElementById('mq-create-error').textContent = '';
|
||||
document.getElementById('mq-create-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeMQCreate() {
|
||||
document.getElementById('mq-create-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function submitCreateMQ() {
|
||||
const name = document.getElementById('mq-name').value.trim();
|
||||
const fnName = document.getElementById('mq-fn').value.trim();
|
||||
const queue = document.getElementById('mq-queue').value.trim();
|
||||
const endpoint = document.getElementById('mq-endpoint').value.trim();
|
||||
const accessKey = document.getElementById('mq-access-key').value.trim();
|
||||
const secretKey = document.getElementById('mq-secret-key').value.trim();
|
||||
const errEl = document.getElementById('mq-create-error');
|
||||
|
||||
if (!name || !fnName || !queue || !accessKey || !secretKey) {
|
||||
errEl.textContent = 'Заполните все поля';
|
||||
return;
|
||||
}
|
||||
|
||||
errEl.textContent = '';
|
||||
try {
|
||||
await apiFetch('/console/api/mqtriggers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, functionName: fnName, queue, sqsEndpoint: endpoint, accessKey, secretKey })
|
||||
});
|
||||
closeMQCreate();
|
||||
await loadMQTriggers();
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message || 'Ошибка создания';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Удаление ─────────────────────────────────────────────────────────
|
||||
|
||||
async function deleteMQTrigger(name) {
|
||||
if (!confirm(`Удалить MQ-триггер "${name}"?`)) return;
|
||||
try {
|
||||
await apiFetch(`/console/api/mqtriggers/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||
await loadMQTriggers();
|
||||
} catch (e) {
|
||||
alert('Ошибка удаления: ' + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Утилита ──────────────────────────────────────────────────────────
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/* triggers.js — HTTP и Cron триггеры */
|
||||
|
||||
function httpTriggerByFn(fnName) {
|
||||
return (S.httpTriggers || []).find(function (t) {
|
||||
return t.spec && t.spec.functionref && t.spec.functionref.name === fnName;
|
||||
});
|
||||
}
|
||||
|
||||
function timeTriggerByFn(fnName) {
|
||||
return (S.timeTriggers || []).find(function (t) {
|
||||
return t.spec && t.spec.functionref && t.spec.functionref.name === fnName;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleScheduleFields(prefix) {
|
||||
var enabled = !!document.getElementById(prefix + '-schedule-enabled').checked;
|
||||
var cronEl = document.getElementById(prefix + '-cron');
|
||||
cronEl.disabled = !enabled;
|
||||
if (enabled && !cronEl.value.trim()) {
|
||||
cronEl.value = '*/5 * * * *';
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePayload(prefix) {
|
||||
return {
|
||||
enabled: !!document.getElementById(prefix + '-schedule-enabled').checked,
|
||||
cron: (document.getElementById(prefix + '-cron').value || '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
async function syncScheduleForFunction(name, prefix) {
|
||||
var schedule = schedulePayload(prefix);
|
||||
var existing = timeTriggerByFn(name);
|
||||
var existingName = (existing && existing.metadata && existing.metadata.name) || name;
|
||||
|
||||
if (!schedule.enabled) {
|
||||
if (existing) {
|
||||
await requestJSON(API_BASE + '/timetriggers/' + encodeURIComponent(existingName), 'DELETE');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!schedule.cron) {
|
||||
throw new Error('cron is required');
|
||||
}
|
||||
|
||||
var payload = {
|
||||
name: existingName,
|
||||
functionName: name,
|
||||
cron: schedule.cron,
|
||||
method: 'POST',
|
||||
subpath: '/'
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
await requestJSON(API_BASE + '/timetriggers/' + encodeURIComponent(existingName), 'PUT', payload);
|
||||
} else {
|
||||
await requestJSON(API_BASE + '/timetriggers', 'POST', payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/* ui.js — UI-вспомогательные функции */
|
||||
|
||||
function setText(id, value) {
|
||||
document.getElementById(id).textContent = String(value);
|
||||
}
|
||||
|
||||
function showStatus(message, kind) {
|
||||
const el = document.getElementById('status');
|
||||
el.style.display = 'block';
|
||||
el.className = 'status ' + (kind || '');
|
||||
el.textContent = message;
|
||||
}
|
||||
|
||||
function clearStatus() {
|
||||
const el = document.getElementById('status');
|
||||
el.style.display = 'none';
|
||||
el.className = 'status';
|
||||
el.textContent = '';
|
||||
}
|
||||
|
||||
function h(v) {
|
||||
return String(v == null ? '' : v)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function formatTimestamp(ts) {
|
||||
if (!ts) return '—';
|
||||
var d = new Date(ts);
|
||||
if (isNaN(d.getTime())) return String(ts);
|
||||
return d.toLocaleString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function timestampCell(ts) {
|
||||
var text = formatTimestamp(ts);
|
||||
return '<span class="mono" title="' + h(ts || '') + '">' + h(text) + '</span>';
|
||||
}
|
||||
Reference in New Issue
Block a user