1147 lines
45 KiB
HTML
1147 lines
45 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>sless console</title>
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||
<style>
|
||
*,
|
||
*::before,
|
||
*::after {
|
||
box-sizing: border-box;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||
background: #0d1117;
|
||
color: #e6edf3;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
a {
|
||
color: inherit;
|
||
}
|
||
|
||
::-webkit-scrollbar {
|
||
width: 8px;
|
||
}
|
||
|
||
::-webkit-scrollbar-track {
|
||
background: #161b22;
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb {
|
||
background: #30363d;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
header {
|
||
padding: 14px 24px;
|
||
border-bottom: 1px solid #30363d;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
background: #161b22;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
}
|
||
|
||
.logo {
|
||
font-weight: 700;
|
||
font-size: 1.1rem;
|
||
color: #58a6ff;
|
||
letter-spacing: -0.3px;
|
||
}
|
||
|
||
.logo-slash {
|
||
color: #484f58;
|
||
margin: 0 4px;
|
||
}
|
||
|
||
.ns-badge {
|
||
background: #1c2128;
|
||
border: 1px solid #30363d;
|
||
padding: 3px 12px;
|
||
border-radius: 20px;
|
||
font-family: monospace;
|
||
font-size: 0.82rem;
|
||
color: #8b949e;
|
||
}
|
||
|
||
.refresh-btn {
|
||
margin-left: auto;
|
||
background: #21262d;
|
||
border: 1px solid #30363d;
|
||
color: #c9d1d9;
|
||
padding: 6px 16px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 0.82rem;
|
||
transition: background 0.1s;
|
||
}
|
||
|
||
.refresh-btn:hover {
|
||
background: #30363d;
|
||
}
|
||
|
||
main {
|
||
padding: 24px;
|
||
max-width: 960px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.summary {
|
||
color: #8b949e;
|
||
font-size: 0.82rem;
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.fn-card {
|
||
background: #161b22;
|
||
border: 1px solid #30363d;
|
||
border-radius: 8px;
|
||
margin-bottom: 10px;
|
||
overflow: hidden;
|
||
transition: border-color 0.15s;
|
||
}
|
||
|
||
.fn-card:hover {
|
||
border-color: #484f58;
|
||
}
|
||
|
||
.fn-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 12px 14px;
|
||
cursor: pointer;
|
||
user-select: none;
|
||
flex-wrap: wrap;
|
||
row-gap: 6px;
|
||
}
|
||
|
||
.fn-header:hover {
|
||
background: rgba(255, 255, 255, 0.03);
|
||
}
|
||
|
||
.fn-name {
|
||
font-weight: 600;
|
||
font-size: 0.92rem;
|
||
color: #e6edf3;
|
||
}
|
||
|
||
.badge {
|
||
display: inline-block;
|
||
font-size: 0.7rem;
|
||
padding: 2px 8px;
|
||
border-radius: 12px;
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.badge-runtime {
|
||
background: #21262d;
|
||
border: 1px solid #30363d;
|
||
color: #8b949e;
|
||
}
|
||
|
||
.badge-Ready {
|
||
background: #1a4731;
|
||
border: 1px solid #1f6132;
|
||
color: #3fb950;
|
||
}
|
||
|
||
.badge-Building {
|
||
background: #382a0a;
|
||
border: 1px solid #5a4212;
|
||
color: #d29922;
|
||
}
|
||
|
||
.badge-Pending {
|
||
background: #21262d;
|
||
border: 1px solid #30363d;
|
||
color: #8b949e;
|
||
}
|
||
|
||
.badge-Failed {
|
||
background: #3d1a1a;
|
||
border: 1px solid #6b1a1a;
|
||
color: #f85149;
|
||
}
|
||
|
||
.badge-kind-service {
|
||
background: #0d2538;
|
||
border: 1px solid #1158a7;
|
||
color: #58a6ff;
|
||
}
|
||
|
||
.badge-kind-function {
|
||
background: #1a2538;
|
||
border: 1px solid #30363d;
|
||
color: #8b949e;
|
||
}
|
||
|
||
.fn-url-row {
|
||
padding: 0 14px 9px 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.fn-url-row:empty {
|
||
display: none;
|
||
}
|
||
|
||
.fn-url {
|
||
font-size: 0.76rem;
|
||
color: #58a6ff;
|
||
text-decoration: none;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.fn-url:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.fn-cron {
|
||
font-size: 0.76rem;
|
||
color: #8b949e;
|
||
font-family: monospace;
|
||
}
|
||
|
||
.fn-msg {
|
||
font-size: 0.73rem;
|
||
color: #f85149;
|
||
max-width: 220px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.spacer {
|
||
flex: 1 0 0;
|
||
min-width: 4px;
|
||
}
|
||
|
||
.trigger-btn {
|
||
flex-shrink: 0;
|
||
padding: 4px 12px;
|
||
border-radius: 6px;
|
||
border: 1px solid transparent;
|
||
cursor: pointer;
|
||
font-size: 0.76rem;
|
||
font-weight: 500;
|
||
line-height: 1.5;
|
||
transition: opacity 0.1s, background 0.1s;
|
||
}
|
||
|
||
.trigger-btn:disabled {
|
||
opacity: 0.45;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.trigger-btn.state-on {
|
||
background: #21262d;
|
||
border-color: #1f6132;
|
||
color: #3fb950;
|
||
}
|
||
|
||
.trigger-btn.state-on:hover:not(:disabled) {
|
||
background: #1a4731;
|
||
}
|
||
|
||
.trigger-btn.state-off {
|
||
background: #21262d;
|
||
border-color: #6b1a1a;
|
||
color: #f85149;
|
||
}
|
||
|
||
.trigger-btn.state-off:hover:not(:disabled) {
|
||
background: #3d1a1a;
|
||
}
|
||
|
||
.expand-icon {
|
||
color: #8b949e;
|
||
flex-shrink: 0;
|
||
font-size: 0.65rem;
|
||
transition: transform 0.18s;
|
||
}
|
||
|
||
.fn-body {
|
||
border-top: 1px solid #30363d;
|
||
}
|
||
|
||
.src-msg {
|
||
padding: 14px 16px;
|
||
color: #8b949e;
|
||
font-size: 0.85rem;
|
||
font-style: italic;
|
||
}
|
||
|
||
.file-block {
|
||
border-bottom: 1px solid #21262d;
|
||
}
|
||
|
||
.file-block:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.file-header {
|
||
padding: 6px 16px;
|
||
background: #0d1117;
|
||
font-family: monospace;
|
||
font-size: 0.76rem;
|
||
color: #8b949e;
|
||
border-bottom: 1px solid #21262d;
|
||
}
|
||
|
||
.file-block pre {
|
||
margin: 0;
|
||
}
|
||
|
||
.file-block pre code.hljs {
|
||
border-radius: 0 !important;
|
||
font-size: 0.78rem !important;
|
||
padding: 14px 16px !important;
|
||
line-height: 1.5 !important;
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 64px 24px;
|
||
color: #484f58;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
/* Кнопки действий на карточке функции */
|
||
.action-btn {
|
||
flex-shrink: 0;
|
||
padding: 3px 10px;
|
||
border-radius: 5px;
|
||
border: 1px solid #30363d;
|
||
cursor: pointer;
|
||
font-size: 0.73rem;
|
||
font-weight: 500;
|
||
background: #21262d;
|
||
color: #8b949e;
|
||
transition: background 0.1s, color 0.1s;
|
||
}
|
||
.action-btn:hover { background: #30363d; color: #c9d1d9; }
|
||
.action-btn.del:hover { background: #3d1a1a; border-color: #6b1a1a; color: #f85149; }
|
||
|
||
/* Кодовый редактор во встроенном режиме */
|
||
.edit-area {
|
||
width: 100%;
|
||
min-height: 220px;
|
||
background: #0d1117;
|
||
color: #e6edf3;
|
||
border: none;
|
||
border-top: 1px solid #30363d;
|
||
padding: 14px 16px;
|
||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||
font-size: 0.78rem;
|
||
line-height: 1.55;
|
||
resize: vertical;
|
||
outline: none;
|
||
display: block;
|
||
box-sizing: border-box;
|
||
}
|
||
.edit-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
padding: 8px 14px;
|
||
background: #161b22;
|
||
border-top: 1px solid #30363d;
|
||
}
|
||
.edit-save-btn {
|
||
padding: 5px 16px;
|
||
background: #238636;
|
||
border: 1px solid #2ea043;
|
||
color: #fff;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 0.8rem;
|
||
}
|
||
.edit-save-btn:hover { background: #2ea043; }
|
||
.edit-save-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.edit-cancel-btn {
|
||
padding: 5px 16px;
|
||
background: #21262d;
|
||
border: 1px solid #30363d;
|
||
color: #c9d1d9;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 0.8rem;
|
||
}
|
||
.edit-status {
|
||
font-size: 0.77rem;
|
||
padding: 5px 8px;
|
||
align-self: center;
|
||
color: #8b949e;
|
||
}
|
||
.edit-status.ok { color: #3fb950; }
|
||
.edit-status.err { color: #f85149; }
|
||
|
||
/* ---------- Модалка создания функции ---------- */
|
||
.modal-backdrop {
|
||
display: none;
|
||
position: fixed; inset: 0;
|
||
background: rgba(0,0,0,0.65);
|
||
z-index: 100;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.modal-backdrop.open { display: flex; }
|
||
.modal {
|
||
background: #161b22;
|
||
border: 1px solid #30363d;
|
||
border-radius: 10px;
|
||
width: min(660px, 95vw);
|
||
max-height: 90vh;
|
||
overflow-y: auto;
|
||
padding: 24px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
.modal h2 {
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
color: #e6edf3;
|
||
}
|
||
.modal label {
|
||
font-size: 0.82rem;
|
||
color: #8b949e;
|
||
display: block;
|
||
margin-bottom: 4px;
|
||
}
|
||
.modal input, .modal select {
|
||
width: 100%;
|
||
background: #0d1117;
|
||
border: 1px solid #30363d;
|
||
border-radius: 6px;
|
||
color: #e6edf3;
|
||
padding: 7px 10px;
|
||
font-size: 0.85rem;
|
||
box-sizing: border-box;
|
||
outline: none;
|
||
}
|
||
.modal input:focus, .modal select:focus { border-color: #58a6ff; }
|
||
.modal-row { display: flex; gap: 12px; }
|
||
.modal-row > div { flex: 1; }
|
||
.modal-code {
|
||
width: 100%;
|
||
min-height: 200px;
|
||
background: #0d1117;
|
||
border: 1px solid #30363d;
|
||
border-radius: 6px;
|
||
color: #e6edf3;
|
||
padding: 10px 12px;
|
||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||
font-size: 0.78rem;
|
||
line-height: 1.55;
|
||
resize: vertical;
|
||
outline: none;
|
||
box-sizing: border-box;
|
||
}
|
||
.modal-code:focus { border-color: #58a6ff; }
|
||
.modal-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
}
|
||
.modal-create-btn {
|
||
padding: 7px 20px;
|
||
background: #238636;
|
||
border: 1px solid #2ea043;
|
||
color: #fff;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 0.85rem;
|
||
}
|
||
.modal-create-btn:hover { background: #2ea043; }
|
||
.modal-create-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.modal-cancel-btn {
|
||
padding: 7px 20px;
|
||
background: #21262d;
|
||
border: 1px solid #30363d;
|
||
color: #c9d1d9;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 0.85rem;
|
||
}
|
||
.modal-err {
|
||
color: #f85149;
|
||
font-size: 0.8rem;
|
||
min-height: 18px;
|
||
}
|
||
.create-fn-btn {
|
||
margin-left: auto;
|
||
background: #238636;
|
||
border: 1px solid #2ea043;
|
||
color: #fff;
|
||
padding: 6px 16px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 0.82rem;
|
||
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>
|
||
|
||
<body>
|
||
<header>
|
||
<span class="logo">sless</span>
|
||
<span class="logo-slash">/</span>
|
||
<span class="ns-badge" id="ns-label">…</span>
|
||
<button class="refresh-btn" onclick="location.reload()">↻ обновить</button>
|
||
<button class="create-fn-btn" id="open-create-modal">+ Создать функцию</button>
|
||
</header>
|
||
<main>
|
||
<p class="summary" id="summary"></p>
|
||
<div id="fn-list"></div>
|
||
</main>
|
||
|
||
<!-- Модалка создания новой функции -->
|
||
<div class="modal-backdrop" id="create-modal">
|
||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||
<h2 id="modal-title">Создать функцию</h2>
|
||
|
||
<div>
|
||
<label>Runtime</label>
|
||
<select id="m-runtime">
|
||
<option value="python3.11">Python 3.11</option>
|
||
<option value="nodejs20">Node.js 20</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="modal-row">
|
||
<div>
|
||
<label>Имя функции (k8s name)</label>
|
||
<input id="m-name" type="text" placeholder="my-hello-fn" pattern="[a-z0-9][a-z0-9\-]{0,61}[a-z0-9]">
|
||
</div>
|
||
<div>
|
||
<label>Entrypoint (module.func)</label>
|
||
<input id="m-entrypoint" type="text" value="handler.handler">
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label id="m-filename-label">Файл: <code id="m-filename-display">handler.py</code></label>
|
||
<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">
|
||
<button class="modal-cancel-btn" id="modal-cancel">Отмена</button>
|
||
<button class="modal-create-btn" id="modal-submit">Создать</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script id="fndata" type="application/json">__PAGE_DATA__</script>
|
||
<script>
|
||
(function () {
|
||
'use strict';
|
||
|
||
var raw = document.getElementById('fndata').textContent;
|
||
var data = JSON.parse(raw);
|
||
var ns = data.namespace;
|
||
var extURL = data.externalURL || '';
|
||
var fns = data.functions || [];
|
||
var byFn = data.triggersByFn || {};
|
||
|
||
document.getElementById('ns-label').textContent = ns;
|
||
var svcsCount = fns.filter(function(f){ return f.kind === 'service'; }).length;
|
||
var fnsCount = fns.filter(function(f){ return f.kind !== 'service'; }).length;
|
||
document.getElementById('summary').textContent =
|
||
'Функций: ' + fnsCount + ' | Сервисов: ' + svcsCount + ' | Всего: ' + fns.length;
|
||
|
||
var list = document.getElementById('fn-list');
|
||
if (fns.length === 0) {
|
||
var empty = $el('div', 'empty-state');
|
||
empty.textContent = 'Нет функций в этом namespace';
|
||
list.appendChild(empty);
|
||
}
|
||
for (var i = 0; i < fns.length; i++) {
|
||
list.appendChild(buildCard(fns[i], byFn[fns[i].name] || []));
|
||
}
|
||
|
||
// ---------- Карточки функций ----------
|
||
|
||
function buildCard(fn, triggers) {
|
||
var card = $el('div', 'fn-card');
|
||
var hdr = $el('div', 'fn-header');
|
||
var body = $el('div', 'fn-body');
|
||
body.style.display = 'none';
|
||
|
||
$txt(hdr, 'span', 'fn-name', fn.name);
|
||
$txt(hdr, 'span', 'badge badge-runtime', fn.runtime);
|
||
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(phaseBadge, timerEl, fn, ns, null, function(url) {
|
||
if (!fn.url) { fn.url = url; setCardUrl(url); }
|
||
});
|
||
}
|
||
|
||
if (fn.message) $txt(hdr, 'span', 'fn-msg', fn.message);
|
||
hdr.appendChild($el('span', 'spacer'));
|
||
|
||
// urlRow — отдельная строка под заголовком для URL
|
||
var urlRow = $el('div', 'fn-url-row');
|
||
var cardUrlAnchor = null;
|
||
|
||
function setCardUrl(url) {
|
||
if (cardUrlAnchor) return; // уже есть
|
||
var a = document.createElement('a');
|
||
a.className = 'fn-url';
|
||
a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
|
||
a.textContent = url;
|
||
a.addEventListener('click', function (e) { e.stopPropagation(); });
|
||
urlRow.appendChild(a);
|
||
cardUrlAnchor = a;
|
||
}
|
||
|
||
if (fn.url) setCardUrl(fn.url);
|
||
|
||
for (var t = 0; t < triggers.length; t++) {
|
||
var tr = triggers[t];
|
||
if (tr.type === 'http') {
|
||
if (!fn.url) {
|
||
var url = extURL ? extURL + '/fn/' + ns + '/' + fn.name : (tr.url || '');
|
||
if (url) setCardUrl(url);
|
||
}
|
||
hdr.appendChild(makeTriggerBtn(tr, ns));
|
||
} else if (tr.type === 'cron') {
|
||
$txt(hdr, 'span', 'fn-cron', 'cron: ' + tr.schedule);
|
||
hdr.appendChild(makeTriggerBtn(tr, ns));
|
||
}
|
||
}
|
||
|
||
// Кнопка редактирования кода
|
||
var editBtn = $el('button', 'action-btn');
|
||
editBtn.textContent = '✎';
|
||
editBtn.title = 'Редактировать код';
|
||
editBtn.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
body.innerHTML = ''; // сбрасываем содержимое перед переходом в edit
|
||
loaded = false; // следующий expand снова зачитает исходники
|
||
body.style.display = 'block';
|
||
icon.style.transform = 'rotate(90deg)';
|
||
openEditMode(body, ns, fn.name, fn.kind, fn.runtime);
|
||
});
|
||
hdr.appendChild(editBtn);
|
||
|
||
// Кнопка удаления
|
||
var delBtn = $el('button', 'action-btn del');
|
||
delBtn.textContent = '✕';
|
||
delBtn.title = 'Удалить функцию';
|
||
delBtn.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
if (!confirm('Удалить функцию «' + fn.name + '»?\n\nЭто действие необратимо.')) return;
|
||
delBtn.disabled = true;
|
||
fetch('/funcs/' + ns + '/delete-function/' + fn.name + '?kind=' + (fn.kind || 'function'), {
|
||
method: 'DELETE'
|
||
}).then(function (r) {
|
||
if (r.ok) {
|
||
card.remove();
|
||
} else {
|
||
return r.text().then(function (t) { alert('Ошибка удаления: ' + t); });
|
||
}
|
||
}).catch(function (err) {
|
||
alert('Сетевая ошибка: ' + err);
|
||
}).finally(function () {
|
||
delBtn.disabled = false;
|
||
});
|
||
});
|
||
hdr.appendChild(delBtn);
|
||
|
||
var icon = $txt(hdr, 'span', 'expand-icon', '▶');
|
||
|
||
var loaded = false;
|
||
hdr.addEventListener('click', function () {
|
||
if (body.style.display === 'none') {
|
||
body.style.display = 'block';
|
||
icon.style.transform = 'rotate(90deg)';
|
||
if (!loaded) {
|
||
loaded = true;
|
||
loadSource(body, ns, fn.name, fn.kind);
|
||
}
|
||
} else {
|
||
body.style.display = 'none';
|
||
icon.style.transform = '';
|
||
}
|
||
});
|
||
|
||
card.appendChild(hdr);
|
||
card.appendChild(urlRow);
|
||
card.appendChild(body);
|
||
return card;
|
||
}
|
||
|
||
// openEditMode — открывает тело карточки в режиме редактирования.
|
||
// Загружает текущий код и файл зависимостей (requirements.txt / package.json).
|
||
function openEditMode(body, ns, fnName, fnKind, fnRuntime) {
|
||
body.innerHTML = '';
|
||
var msg = $el('div', 'src-msg');
|
||
msg.textContent = 'Загрузка кода…';
|
||
body.appendChild(msg);
|
||
|
||
var kindParam = fnKind === 'service' ? '?kind=service' : '';
|
||
fetch('/funcs/' + ns + '/source/' + fnName + kindParam)
|
||
.then(function (r) {
|
||
body.removeChild(msg);
|
||
if (!r.ok) {
|
||
var e = $el('div', 'src-msg');
|
||
e.textContent = 'Ошибка загрузки кода: ' + r.status;
|
||
body.appendChild(e);
|
||
return;
|
||
}
|
||
return r.json().then(function (files) {
|
||
var editable = files.filter(function (f) {
|
||
return !f.binary && (f.name.endsWith('.py') || f.name.endsWith('.js'));
|
||
});
|
||
if (editable.length === 0) {
|
||
editable = [{ name: defaultFilename(fnRuntime), content: helloWorldTemplate(fnRuntime) }];
|
||
}
|
||
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) {
|
||
body.innerHTML = '';
|
||
var e = $el('div', 'src-msg');
|
||
e.textContent = 'Ошибка: ' + err;
|
||
body.appendChild(e);
|
||
});
|
||
}
|
||
|
||
// 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 = 'Сохранить';
|
||
var cancelBtn = $el('button', 'edit-cancel-btn');
|
||
cancelBtn.textContent = 'Закрыть';
|
||
var status = $el('span', 'edit-status');
|
||
actions.appendChild(saveBtn);
|
||
actions.appendChild(cancelBtn);
|
||
actions.appendChild(status);
|
||
body.appendChild(actions);
|
||
|
||
saveBtn.addEventListener('click', function () {
|
||
saveBtn.disabled = true;
|
||
status.textContent = 'Сохраняю…';
|
||
status.className = 'edit-status';
|
||
fetch('/funcs/' + ns + '/save-function/' + fnName, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
code: ta.value, filename: filename, kind: fnKind || 'function',
|
||
deps: depsTA ? depsTA.value : '',
|
||
deps_filename: depsFilename || ''
|
||
})
|
||
}).then(function (r) {
|
||
saveBtn.disabled = false;
|
||
if (r.ok) {
|
||
// Ищем phaseBadge текущей карточки через DOM
|
||
var cardEl = body.closest ? body.closest('.fn-card') : null;
|
||
var pb = cardEl ? cardEl.querySelector('.badge-Ready, .badge-Building, .badge-Pending, .badge-Failed, .badge-Error') : null;
|
||
if (pb) { pb.className = 'badge badge-Building'; pb.textContent = 'Building'; }
|
||
status.textContent = '⏳ Сборка… 0с';
|
||
status.className = 'edit-status';
|
||
// Запускаем поллинг: обновляет статус в строке редактора и badge карточки
|
||
startBuildPoll(pb, null, { name: fnName, kind: fnKind || 'function' }, ns, status, null);
|
||
} else {
|
||
return r.text().then(function (t) {
|
||
status.textContent = '✗ ' + t;
|
||
status.className = 'edit-status err';
|
||
});
|
||
}
|
||
}).catch(function (err) {
|
||
saveBtn.disabled = false;
|
||
status.textContent = '✗ ' + err;
|
||
status.className = 'edit-status err';
|
||
});
|
||
});
|
||
|
||
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.
|
||
// Показывает таймер-строку в statusEl (если передан), обновляет phaseBadge,
|
||
// добавляет URL через onUrl-коллбэк когда сервис появляется.
|
||
function startBuildPoll(phaseBadge, timerEl, fn, ns, statusEl, onUrl) {
|
||
var buildStart = Date.now();
|
||
var timerItv = setInterval(function () {
|
||
var sec = Math.floor((Date.now() - buildStart) / 1000);
|
||
if (timerEl && timerEl.parentNode) timerEl.textContent = sec + 'с';
|
||
if (statusEl) statusEl.textContent = '⏳ Сборка… ' + sec + 'с';
|
||
}, 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);
|
||
if (phaseBadge) {
|
||
phaseBadge.className = 'badge badge-' + d.phase;
|
||
phaseBadge.textContent = d.phase;
|
||
}
|
||
if (timerEl && timerEl.parentNode) timerEl.parentNode.removeChild(timerEl);
|
||
if (statusEl) {
|
||
statusEl.textContent = d.phase === 'Ready'
|
||
? '✓ Сборка завершена — функция готова'
|
||
: '✗ Сборка завершилась со статусом: ' + d.phase;
|
||
statusEl.className = 'edit-status ' + (d.phase === 'Ready' ? 'ok' : 'err');
|
||
}
|
||
if (d.url && onUrl) onUrl(d.url);
|
||
})
|
||
.catch(function () {});
|
||
}, 5000);
|
||
}
|
||
|
||
function makeTriggerBtn(tr, ns) {
|
||
var btn = $el('button', 'trigger-btn ' + (tr.enabled ? 'state-on' : 'state-off'));
|
||
btn.textContent = tr.enabled ? '■ стоп' : '▶ запуск';
|
||
btn.title = tr.name;
|
||
btn.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
btn.disabled = true;
|
||
var newVal = !tr.enabled;
|
||
fetch('/funcs/' + ns + '/triggers/' + tr.name, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ enabled: newVal })
|
||
}).then(function (r) {
|
||
if (r.ok) {
|
||
tr.enabled = newVal;
|
||
btn.className = 'trigger-btn ' + (newVal ? 'state-on' : 'state-off');
|
||
btn.textContent = newVal ? '■ стоп' : '▶ запуск';
|
||
} else {
|
||
return r.text().then(function (t) { alert('Ошибка: ' + t); });
|
||
}
|
||
}).catch(function (err) {
|
||
alert('Сетевая ошибка: ' + err);
|
||
}).finally(function () {
|
||
btn.disabled = false;
|
||
});
|
||
});
|
||
return btn;
|
||
}
|
||
|
||
function loadSource(body, ns, fnName, fnKind) {
|
||
body.innerHTML = ''; // всегда очищаем перед загрузкой
|
||
var msg = $el('div', 'src-msg');
|
||
msg.textContent = 'Загрузка кода…';
|
||
body.appendChild(msg);
|
||
|
||
var kindParam = fnKind === 'service' ? '?kind=service' : '';
|
||
fetch('/funcs/' + ns + '/source/' + fnName + kindParam)
|
||
.then(function (r) {
|
||
body.removeChild(msg);
|
||
if (!r.ok) {
|
||
var e = $el('div', 'src-msg');
|
||
e.textContent = 'Ошибка ' + r.status;
|
||
body.appendChild(e);
|
||
return;
|
||
}
|
||
return r.json().then(function (files) {
|
||
var shown = files.filter(function (f) { return !f.binary; });
|
||
if (shown.length === 0) {
|
||
var e = $el('div', 'src-msg');
|
||
e.textContent = 'Код не загружен';
|
||
body.appendChild(e);
|
||
return;
|
||
}
|
||
for (var i = 0; i < shown.length; i++) {
|
||
var f = shown[i];
|
||
var block = $el('div', 'file-block');
|
||
var fhdr = $el('div', 'file-header');
|
||
fhdr.textContent = f.name;
|
||
var pre = document.createElement('pre');
|
||
var code = document.createElement('code');
|
||
code.className = 'language-' + detectLang(f.name);
|
||
code.textContent = f.content;
|
||
pre.appendChild(code);
|
||
block.appendChild(fhdr);
|
||
block.appendChild(pre);
|
||
body.appendChild(block);
|
||
}
|
||
hljs.highlightAll();
|
||
});
|
||
})
|
||
.catch(function (err) {
|
||
body.innerHTML = '';
|
||
var e = $el('div', 'src-msg');
|
||
e.textContent = 'Ошибка: ' + err;
|
||
body.appendChild(e);
|
||
});
|
||
}
|
||
|
||
// ---------- Модалка создания функции ----------
|
||
|
||
var TEMPLATES = {
|
||
'python3.11': {
|
||
filename: 'handler.py',
|
||
code: '# Hello World — Python 3.11\n# Entrypoint: handler.handler\n\ndef handler(event):\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) => {\n const name = event.name || \'World\';\n return { result: `Hello, ${name}!` };\n};\n',
|
||
depsFilename: 'package.json',
|
||
deps: '{\n "dependencies": {}\n}\n'
|
||
}
|
||
};
|
||
|
||
function defaultFilename(runtime) {
|
||
return TEMPLATES[runtime] ? TEMPLATES[runtime].filename : 'handler.py';
|
||
}
|
||
function helloWorldTemplate(runtime) {
|
||
return TEMPLATES[runtime] ? TEMPLATES[runtime].code : '# код функции\n';
|
||
}
|
||
|
||
var modal = document.getElementById('create-modal');
|
||
var mRuntime = document.getElementById('m-runtime');
|
||
var mCode = document.getElementById('m-code');
|
||
var mFilenameDisplay = document.getElementById('m-filename-display');
|
||
var mErr = document.getElementById('m-err');
|
||
|
||
// Заполняем шаблон при открытии / смене runtime
|
||
function applyTemplate() {
|
||
var tpl = TEMPLATES[mRuntime.value];
|
||
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();
|
||
mRuntime.addEventListener('change', applyTemplate);
|
||
|
||
document.getElementById('open-create-modal').addEventListener('click', function () {
|
||
applyTemplate();
|
||
mErr.textContent = '';
|
||
document.getElementById('m-name').value = '';
|
||
document.getElementById('m-entrypoint').value = 'handler.handler';
|
||
modal.classList.add('open');
|
||
document.getElementById('m-name').focus();
|
||
});
|
||
|
||
document.getElementById('modal-cancel').addEventListener('click', function () {
|
||
modal.classList.remove('open');
|
||
});
|
||
modal.addEventListener('click', function (e) {
|
||
if (e.target === modal) modal.classList.remove('open');
|
||
});
|
||
|
||
document.getElementById('modal-submit').addEventListener('click', function () {
|
||
var name = document.getElementById('m-name').value.trim();
|
||
var runtime = mRuntime.value;
|
||
var entrypoint = document.getElementById('m-entrypoint').value.trim();
|
||
var code = mCode.value;
|
||
var filename = TEMPLATES[runtime] ? TEMPLATES[runtime].filename : 'handler.py';
|
||
|
||
mErr.textContent = '';
|
||
if (!name) { mErr.textContent = 'Укажите имя функции'; return; }
|
||
if (!/^[a-z0-9][a-z0-9\-]{0,61}[a-z0-9]$/.test(name) && !/^[a-z0-9]$/.test(name)) {
|
||
mErr.textContent = 'Имя должно быть в формате k8s: строчные буквы, цифры, дефисы';
|
||
return;
|
||
}
|
||
if (!entrypoint) { mErr.textContent = 'Укажите entrypoint (например handler.handler)'; return; }
|
||
if (!code.trim()) { mErr.textContent = 'Напишите код функции'; return; }
|
||
|
||
var btn = document.getElementById('modal-submit');
|
||
btn.disabled = true;
|
||
mErr.textContent = 'Создаю…';
|
||
|
||
fetch('/funcs/' + ns + '/create-function', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name: name,
|
||
runtime: runtime,
|
||
kind: 'service',
|
||
code: code,
|
||
filename: filename,
|
||
entrypoint: entrypoint,
|
||
memory_mb: 128,
|
||
timeout_sec: 30,
|
||
deps: document.getElementById('m-deps').value,
|
||
deps_filename: TEMPLATES[runtime] ? TEMPLATES[runtime].depsFilename : ''
|
||
})
|
||
}).then(function (r) {
|
||
if (r.ok) {
|
||
modal.classList.remove('open');
|
||
mErr.textContent = '';
|
||
// Перезагружаем страницу чтобы увидеть новую функцию
|
||
setTimeout(function () { location.reload(); }, 400);
|
||
} else {
|
||
return r.text().then(function (t) {
|
||
mErr.textContent = '✗ ' + t;
|
||
});
|
||
}
|
||
}).catch(function (err) {
|
||
mErr.textContent = '✗ Сетевая ошибка: ' + err;
|
||
}).finally(function () {
|
||
btn.disabled = false;
|
||
});
|
||
});
|
||
|
||
// ---------- Утилиты ----------
|
||
|
||
function detectLang(name) {
|
||
var ext = name.split('.').pop().toLowerCase();
|
||
var m = {
|
||
py: 'python', js: 'javascript', ts: 'typescript', go: 'go',
|
||
sh: 'bash', bash: 'bash', yml: 'yaml', yaml: 'yaml',
|
||
json: 'json', md: 'markdown', txt: 'plaintext', toml: 'toml',
|
||
rs: 'rust', rb: 'ruby', java: 'java', cpp: 'cpp', c: 'c',
|
||
cs: 'csharp', php: 'php', html: 'html', css: 'css'
|
||
};
|
||
return m[ext] || 'plaintext';
|
||
}
|
||
|
||
function $el(tag, cls) { var e = document.createElement(tag); if (cls) e.className = cls; return e; }
|
||
function $txt(p, tag, cls, text) { var e = $el(tag, cls); e.textContent = text; p.appendChild(e); return e; }
|
||
})();
|
||
</script>
|
||
</body>
|
||
|
||
</html> |