feat(web-console): deps, rename, Building poll+timer, kind=service — v0.1.5
This commit is contained in:
+172
-16
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user