feat(web-console): deps, rename, Building poll+timer, kind=service — v0.1.5

This commit is contained in:
Naeel
2026-03-23 10:41:43 +03:00
parent b7fa8acf76
commit 442ba8bc2f
3 changed files with 374 additions and 58 deletions
+172 -16
View File
@@ -487,6 +487,22 @@
transition: background 0.1s;
}
.create-fn-btn:hover { background: #2ea043; }
/* Таймер Building */
.fn-timer {
font-size: 0.72rem;
color: #d29922;
font-family: monospace;
margin-left: 2px;
flex-shrink: 0;
}
.badge-Building {
animation: pulse-bld 1.4s ease-in-out infinite;
}
@keyframes pulse-bld {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
</head>
@@ -543,6 +559,11 @@
<textarea class="modal-code" id="m-code" spellcheck="false"></textarea>
</div>
<div>
<label><span id="m-deps-name">requirements.txt</span> <span style="color:#484f58;font-size:0.75rem">(зависимости, опционально)</span></label>
<textarea class="modal-code" id="m-deps" spellcheck="false" style="min-height:80px"></textarea>
</div>
<div class="modal-err" id="m-err"></div>
<div class="modal-actions">
@@ -590,12 +611,18 @@
$txt(hdr, 'span', 'fn-name', fn.name);
$txt(hdr, 'span', 'badge badge-runtime', fn.runtime);
$txt(hdr, 'span', 'badge badge-' + fn.phase, fn.phase || 'Pending');
var phaseBadge = $txt(hdr, 'span', 'badge badge-' + fn.phase, fn.phase || 'Pending');
if (fn.kind) {
var kindLabel = fn.kind === 'service' ? 'always-on' : 'job';
$txt(hdr, 'span', 'badge badge-kind-' + fn.kind, kindLabel);
}
// Для Building/Pending — таймер секунд + автополинг до Ready/Failed
if (fn.phase === 'Building' || fn.phase === 'Pending') {
var timerEl = $txt(hdr, 'span', 'fn-timer', '0с');
startBuildPoll(hdr, phaseBadge, timerEl, fn, ns);
}
if (fn.url) {
var a = document.createElement('a');
a.className = 'fn-url';
@@ -692,7 +719,7 @@
}
// openEditMode — открывает тело карточки в режиме редактирования.
// Загружает текущий код, показывает textarea, кнопки Сохранить/Закрыть.
// Загружает текущий код и файл зависимостей (requirements.txt / package.json).
function openEditMode(body, ns, fnName, fnKind, fnRuntime) {
body.innerHTML = '';
var msg = $el('div', 'src-msg');
@@ -714,11 +741,25 @@
return !f.binary && (f.name.endsWith('.py') || f.name.endsWith('.js'));
});
if (editable.length === 0) {
// Нет кода — показываем пустой редактор с шаблоном
editable = [{ name: defaultFilename(fnRuntime), content: helloWorldTemplate(fnRuntime) }];
}
var f = editable[0];
renderEditArea(body, ns, fnName, fnKind, f.name, f.content);
var mainFile = editable[0];
// Ищем файл зависимостей (requirements.txt / package.json)
var tpl = TEMPLATES[fnRuntime] || {};
var depsFilename = tpl.depsFilename || '';
var depsContent = tpl.deps || '';
if (depsFilename) {
for (var i = 0; i < files.length; i++) {
if (files[i].name === depsFilename && !files[i].binary) {
depsContent = files[i].content;
break;
}
}
}
renderEditArea(body, ns, fnName, fnKind, fnRuntime,
mainFile.name, mainFile.content, depsFilename, depsContent);
});
})
.catch(function (err) {
@@ -729,15 +770,53 @@
});
}
// renderEditArea — рисует textarea + кнопки Save/Cancel в теле карточки.
function renderEditArea(body, ns, fnName, fnKind, filename, initialCode) {
// renderEditArea — rename input + code textarea + deps textarea + Save/Cancel.
function renderEditArea(body, ns, fnName, fnKind, fnRuntime, filename, initialCode, depsFilename, depsContent) {
body.innerHTML = '';
// Строка переименования
var renameRow = $el('div', 'edit-actions');
renameRow.style.borderBottom = '1px solid #30363d';
var nameLabel = $el('span', 'edit-status');
nameLabel.textContent = 'Имя:';
var nameInput = document.createElement('input');
nameInput.type = 'text';
nameInput.value = fnName;
nameInput.style.cssText = 'flex:1;max-width:240px;background:#0d1117;border:1px solid #30363d;border-radius:5px;color:#e6edf3;padding:4px 8px;font-size:0.8rem;outline:none;';
var renameBtn = $el('button', 'edit-cancel-btn');
renameBtn.textContent = 'Переименовать';
var renameStatus = $el('span', 'edit-status');
renameRow.appendChild(nameLabel);
renameRow.appendChild(nameInput);
renameRow.appendChild(renameBtn);
renameRow.appendChild(renameStatus);
body.appendChild(renameRow);
// Код функции
var codeHdr = $el('div', 'file-header');
codeHdr.textContent = filename;
body.appendChild(codeHdr);
var ta = document.createElement('textarea');
ta.className = 'edit-area';
ta.spellcheck = false;
ta.value = initialCode;
body.appendChild(ta);
// Файл зависимостей (если есть)
var depsTA = null;
if (depsFilename) {
var depsHdr = $el('div', 'file-header');
depsHdr.textContent = depsFilename;
body.appendChild(depsHdr);
depsTA = document.createElement('textarea');
depsTA.className = 'edit-area';
depsTA.style.minHeight = '80px';
depsTA.spellcheck = false;
depsTA.value = depsContent;
body.appendChild(depsTA);
}
// Кнопки Save / Cancel
var actions = $el('div', 'edit-actions');
var saveBtn = $el('button', 'edit-save-btn');
saveBtn.textContent = 'Сохранить';
@@ -756,7 +835,11 @@
fetch('/funcs/' + ns + '/save-function/' + fnName, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: ta.value, filename: filename, kind: fnKind || 'function' })
body: JSON.stringify({
code: ta.value, filename: filename, kind: fnKind || 'function',
deps: depsTA ? depsTA.value : '',
deps_filename: depsFilename || ''
})
}).then(function (r) {
if (r.ok) {
status.textContent = '✓ Сохранено — сборка запущена';
@@ -770,16 +853,80 @@
}).catch(function (err) {
status.textContent = '✗ ' + err;
status.className = 'edit-status err';
}).finally(function () {
saveBtn.disabled = false;
});
}).finally(function () { saveBtn.disabled = false; });
});
cancelBtn.addEventListener('click', function () {
body.style.display = 'none';
cancelBtn.addEventListener('click', function () { body.style.display = 'none'; });
renameBtn.addEventListener('click', function () {
var newName = nameInput.value.trim();
if (newName === fnName) { renameStatus.textContent = 'Имя не изменилось'; return; }
if (!/^[a-z0-9]([a-z0-9\-]{0,61}[a-z0-9])?$/.test(newName)) {
renameStatus.textContent = '✗ Только строчные буквы, цифры, дефисы';
renameStatus.className = 'edit-status err';
return;
}
renameBtn.disabled = true;
renameStatus.textContent = 'Переименовываю…';
renameStatus.className = 'edit-status';
fetch('/funcs/' + ns + '/rename-function/' + fnName, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_name: newName, kind: fnKind || 'function' })
}).then(function (r) {
if (r.ok) {
renameStatus.textContent = '✓ Переименовано → ' + newName;
renameStatus.className = 'edit-status ok';
setTimeout(function () { location.reload(); }, 800);
} else {
return r.text().then(function (t) {
renameStatus.textContent = '✗ ' + t;
renameStatus.className = 'edit-status err';
});
}
}).catch(function (err) {
renameStatus.textContent = '✗ ' + err;
renameStatus.className = 'edit-status err';
}).finally(function () { renameBtn.disabled = false; });
});
}
// startBuildPoll — опрашивает /fn-status каждые 5с пока phase=Building/Pending.
// Показывает таймер в секундах, обновляет badge, добавляет URL когда сервис готов.
function startBuildPoll(hdr, phaseBadge, timerEl, fn, ns) {
var buildStart = Date.now();
var timerItv = setInterval(function () {
timerEl.textContent = Math.floor((Date.now() - buildStart) / 1000) + 'с';
}, 1000);
var pollItv = setInterval(function () {
fetch('/funcs/' + ns + '/fn-status/' + fn.name + '?kind=' + (fn.kind || 'function'))
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.phase || d.phase === 'Building' || d.phase === 'Pending') return;
clearInterval(timerItv);
clearInterval(pollItv);
var elapsed = Math.floor((Date.now() - buildStart) / 1000);
phaseBadge.className = 'badge badge-' + d.phase;
phaseBadge.textContent = d.phase;
timerEl.textContent = '(' + elapsed + 'с)';
// Для сервиса — добавляем URL в заголовок карточки при появлении
if (d.url && !fn.url) {
fn.url = d.url;
var urlEl = document.createElement('a');
urlEl.className = 'fn-url';
urlEl.href = d.url;
urlEl.target = '_blank';
urlEl.rel = 'noopener noreferrer';
urlEl.textContent = d.url;
urlEl.addEventListener('click', function (e) { e.stopPropagation(); });
var spacer = hdr.querySelector('.spacer');
if (spacer) hdr.insertBefore(urlEl, spacer);
}
})
.catch(function () {}); // тихо — нет смысла шуметь
}, 5000);
}
function makeTriggerBtn(tr, ns) {
var btn = $el('button', 'trigger-btn ' + (tr.enabled ? 'state-on' : 'state-off'));
btn.textContent = tr.enabled ? '■ стоп' : '▶ запуск';
@@ -862,11 +1009,15 @@
var TEMPLATES = {
'python3.11': {
filename: 'handler.py',
code: '# Hello World — Python 3.11\n# Entrypoint: handler.handler\n\ndef handler(event, context):\n name = event.get("name", "World")\n return {"result": f"Hello, {name}!"}\n'
code: '# Hello World — Python 3.11\n# Entrypoint: handler.handler\n\ndef handler(event, context):\n name = event.get("name", "World")\n return {"result": f"Hello, {name}!"}\n',
depsFilename: 'requirements.txt',
deps: '# Python packages\n# e.g.: requests==2.31.0\n'
},
'nodejs20': {
filename: 'handler.js',
code: '// Hello World — Node.js 20\n// Entrypoint: handler.handler\n\nexports.handler = async (event, context) => {\n const name = event.name || \'World\';\n return { result: `Hello, ${name}!` };\n};\n'
code: '// Hello World — Node.js 20\n// Entrypoint: handler.handler\n\nexports.handler = async (event, context) => {\n const name = event.name || \'World\';\n return { result: `Hello, ${name}!` };\n};\n',
depsFilename: 'package.json',
deps: '{\n "dependencies": {}\n}\n'
}
};
@@ -889,6 +1040,8 @@
if (tpl) {
mCode.value = tpl.code;
mFilenameDisplay.textContent = tpl.filename;
document.getElementById('m-deps-name').textContent = tpl.depsFilename;
document.getElementById('m-deps').value = tpl.deps || '';
}
}
applyTemplate();
@@ -940,11 +1093,14 @@
body: JSON.stringify({
name: name,
runtime: runtime,
kind: 'service',
code: code,
filename: filename,
entrypoint: entrypoint,
memory_mb: memory,
timeout_sec: timeout
timeout_sec: timeout,
deps: document.getElementById('m-deps').value,
deps_filename: TEMPLATES[runtime] ? TEMPLATES[runtime].depsFilename : ''
})
}).then(function (r) {
if (r.ok) {