restructure: console→client-console, add admin-console skeleton, move docs to doc/

This commit is contained in:
“Naeel”
2026-05-25 09:48:55 +04:00
parent 3e36ff13a8
commit 34a8068da5
110 changed files with 615 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
package ui
import (
_ "embed"
"net/http"
)
//go:embed cron.html
var cronPage string
func CronHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(cronPage))
})
}
+430
View File
@@ -0,0 +1,430 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NUBES Cron</title>
<style>
:root {
--bg-page: #001120;
--bg-surface: #001929;
--bg-navbar: #001c34;
--border: #0b2d50;
--accent: #1a7fd4;
--text-primary: #e2ecf6;
--text-secondary: #6b8eaa;
--good: #56d364;
--warn: #e3b341;
--bad: #f85149;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
background: linear-gradient(180deg, #001120 0%, #061424 100%);
color: var(--text-primary);
min-height: 100vh;
}
a { color: inherit; text-decoration: none; }
.navbar {
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
background: var(--bg-navbar);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand-mark {
width: 30px; height: 30px; border-radius: 8px;
background: linear-gradient(135deg, #009dff 0%, #0055d4 100%);
color: #fff; display: grid; place-items: center;
font-size: 14px; font-weight: 800; letter-spacing: .02em;
box-shadow: 0 6px 18px rgba(0, 125, 255, .35);
}
.brand-text { display: flex; flex-direction: column; line-height: 1.08; }
.brand-text .nubes { font-size: 12px; letter-spacing: .22em; font-weight: 800; color: #8bc7ff; }
.brand-text .product { font-size: 13px; font-weight: 700; letter-spacing: .04em; }
.btn {
background: var(--accent); color: #fff; border: 0; border-radius: 6px;
padding: 8px 12px; cursor: pointer; font-size: 13px; line-height: 1.2;
}
.btn.ghost { background: #0c2b49; }
.btn:hover { opacity: .95; }
.wrap { max-width: 1320px; margin: 0 auto; padding: 20px; }
.hero {
background: linear-gradient(180deg, #001929 0%, #001622 100%);
border: 1px solid var(--border); border-radius: 14px; padding: 18px;
box-shadow: 0 12px 34px rgba(0, 0, 0, .18);
}
.hero-top { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; flex-wrap: wrap; }
.hero h1 { font-size: 28px; margin-bottom: 6px; }
.hero p { color: var(--text-secondary); max-width: 920px; line-height: 1.5; font-size: 14px; }
.hero .actions { display: flex; gap: 8px; flex-wrap: wrap; }
.clock {
display: inline-flex;
align-items: center;
gap: 8px;
margin-top: 14px;
padding: 10px 12px;
border-radius: 10px;
background: rgba(12, 43, 73, .72);
border: 1px solid #1d486d;
color: #d7e7f7;
font-size: 14px;
font-weight: 700;
letter-spacing: .02em;
}
.clock small {
display: block;
font-size: 11px;
color: var(--text-secondary);
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
margin-bottom: 2px;
}
.grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px;
margin-top: 16px;
}
.card {
background: var(--bg-surface); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px;
}
.k { color: var(--text-secondary); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; }
.v { font-size: 24px; font-weight: 800; margin-top: 6px; }
.s { font-size: 12px; color: var(--text-secondary); margin-top: 4px; min-height: 16px; }
.panel {
margin-top: 14px; background: var(--bg-surface); border: 1px solid var(--border); border-radius: 12px;
overflow: hidden;
}
.panel-head {
display: flex; justify-content: space-between; gap: 12px; align-items: center; flex-wrap: wrap;
padding: 14px 16px; border-bottom: 1px solid var(--border);
}
.panel-head h2 { font-size: 16px; }
.meta { color: var(--text-secondary); font-size: 12px; }
table { width: 100%; border-collapse: collapse; min-width: 1220px; }
th, td { padding: 9px 11px; border-bottom: 1px solid #0b2a48; text-align: left; }
th {
color: var(--text-secondary); font-size: 11px; text-transform: uppercase; letter-spacing: .05em; white-space: nowrap;
}
td { font-size: 13px; vertical-align: middle; }
tbody tr:hover { background: rgba(26, 127, 212, .08); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
.status-ok { color: var(--good); }
.status-warn { color: var(--warn); }
.status-bad { color: var(--bad); }
.empty { color: var(--text-secondary); padding: 24px 12px; text-align: center; }
.pagination {
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
padding: 12px 16px; border-top: 1px solid var(--border);
}
.page-btn {
min-width: 32px; height: 28px; padding: 0 8px; border-radius: 6px;
background: #0c2b49; border: 1px solid #1d486d; color: #d7e7f7;
font-size: 12px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
}
.page-btn:hover { background: #153d68; }
.page-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.page-btn:disabled { opacity: .35; cursor: default; }
.page-info { color: var(--text-secondary); font-size: 12px; margin-left: 6px; }
.footer {
padding-top: 12px; color: var(--text-secondary); font-size: 12px; line-height: 1.5;
}
.badge {
display: inline-block; padding: 2px 7px; border-radius: 999px; background: #0c2b49; border: 1px solid #1d486d;
color: #d7e7f7; font-size: 11px;
}
@media (max-width: 820px) {
.hero h1 { font-size: 22px; }
}
</style>
</head>
<body>
<div class="navbar">
<div class="brand">
<div class="brand-mark">N</div>
<div class="brand-text">
<div class="nubes">NUBES</div>
<div class="product">МЕТРИКИ CRON</div>
</div>
</div>
<div class="actions">
<button class="btn ghost" id="btn-refresh">Обновить</button>
<a class="btn ghost" href="/console/">Консоль</a>
</div>
</div>
<div class="wrap">
<section class="hero">
<div class="hero-top">
<div>
<h1>Снимки состояния по cron</h1>
<p>
Python-функция по расписанию отправляет сюда snapshot со свободной памятью, CPU load, диском и uptime.
Страница показывает последний снимок и историю замеров в том же стиле, что и основной dashboard: карточки + таблица.
<br><br>
<strong>Примечание:</strong> метрики читаются из <span class="mono">/proc</span> внутри pod-а и отражают состояние
<em>ноды кластера</em>, на которой запущена функция — не ресурсы самого pod-а и не агрегированные показатели кластера.
</p>
<div class="clock">
<div>
<small>Current time</small>
<span id="current-time">--:--:--</span>
</div>
</div>
</div>
<div class="actions">
<span class="badge" id="points-badge">0 points</span>
</div>
</div>
<div class="grid" id="summary"></div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>История</h2>
<div class="meta" id="last-updated">Ожидание первых данных…</div>
</div>
<div class="meta mono" id="latest-source"></div>
</div>
<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>Время</th>
<th>Источник</th>
<th>Хост</th>
<th>Своб. МБ</th>
<th>Дост. МБ</th>
<th>Исп. МБ</th>
<th>Память %</th>
<th>ЦП 1м</th>
<th>ЦП 5м</th>
<th>ЦП 15м</th>
<th>Диск своб.</th>
<th>Аптайм</th>
<th>Статус</th>
</tr>
</thead>
<tbody id="history-body">
<tr><td class="empty" colspan="13">Данных пока нет</td></tr>
</tbody>
</table>
</div>
<div id="pagination" class="pagination"></div>
</section>
<div class="footer">
Endpoint для cron-функции: <span class="mono">POST /cron/api/metrics</span>.<br>
Пример полезной нагрузки: <span class="mono">{"source":"my-fn","memory_free_mb":1234,"cpu_load_1m":0.12}</span>.
</div>
</div>
<script>
const API = '/cron/api/metrics';
const summaryEl = document.getElementById('summary');
const historyBody = document.getElementById('history-body');
const lastUpdatedEl = document.getElementById('last-updated');
const latestSourceEl = document.getElementById('latest-source');
const pointsBadge = document.getElementById('points-badge');
const currentTimeEl = document.getElementById('current-time');
function num(v) {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function fmtNum(v, d = 1, suffix = '') {
const n = num(v);
if (n === null) return '—';
return n.toFixed(d) + suffix;
}
function fmtTime(v) {
if (!v) return '—';
const d = new Date(v);
if (Number.isNaN(d.getTime())) return String(v);
return new Intl.DateTimeFormat('ru-RU', {
year: '2-digit', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
}).format(d);
}
function updateClock() {
currentTimeEl.textContent = new Intl.DateTimeFormat('ru-RU', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
day: '2-digit',
month: '2-digit',
year: 'numeric'
}).format(new Date());
}
function fmtUptime(v) {
const sec = num(v);
if (sec === null) return '—';
const days = Math.floor(sec / 86400);
const hours = Math.floor((sec % 86400) / 3600);
const mins = Math.floor((sec % 3600) / 60);
if (days > 0) return `${days}д ${hours}ч ${mins}м`;
if (hours > 0) return `${hours}ч ${mins}м`;
return `${Math.floor(sec)}с`;
}
function cls(status) {
const s = String(status || '').toLowerCase();
if (s.includes('err') || s.includes('fail') || s.includes('bad')) return 'status-bad';
if (s.includes('warn') || s.includes('degrad') || s.includes('idle')) return 'status-warn';
return 'status-ok';
}
function card(label, value, subtitle) {
return `
<div class="card">
<div class="k">${label}</div>
<div class="v">${value}</div>
<div class="s">${subtitle || ''}</div>
</div>
`;
}
function renderSummary(latest, count) {
if (!latest) {
summaryEl.innerHTML = [
card('Снимки', '0', 'Ожидание cron-запросов'),
card('Своб. память', '—', 'МБ'),
card('Дост. память', '—', 'МБ'),
card('Исп. память', '—', 'МБ'),
card('Нагрузка ЦП', '—', '1м / 5м / 15м'),
card('Диск своб.', '—', 'ГБ')
].join('');
pointsBadge.textContent = '0 снимков';
return;
}
summaryEl.innerHTML = [
card('Снимки', String(count), `Источник: ${latest.source || 'cron'}`),
card('Своб. память', fmtNum(latest.memory_free_mb, 1, ' МБ'), latest.hostname || '—'),
card('Дост. память', fmtNum(latest.memory_available_mb, 1, ' МБ'), 'Доступная RAM'),
card('Исп. память', fmtNum(latest.memory_used_mb, 1, ' МБ'), `Память ${fmtNum(latest.memory_percent, 1, '%')}`),
card('Нагрузка ЦП', [fmtNum(latest.cpu_load_1m, 2), fmtNum(latest.cpu_load_5m, 2), fmtNum(latest.cpu_load_15m, 2)].join(' / '), '1м / 5м / 15м'),
card('Диск своб.', fmtNum(latest.disk_free_gb, 1, ' ГБ'), `Аптайм ${fmtUptime(latest.uptime_sec)}`)
].join('');
pointsBadge.textContent = `${count} снимк${count === 1 ? '' : 'ов'}`;
}
const PAGE_SIZE = 10;
let allHistory = [];
let currentPage = 1;
function renderPage(page) {
const total = allHistory.length;
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
currentPage = Math.min(Math.max(1, page), pages);
const pg = document.getElementById('pagination');
if (!total) {
historyBody.innerHTML = '<tr><td class="empty" colspan="13">Данных пока нет</td></tr>';
pg.innerHTML = '';
return;
}
const start = (currentPage - 1) * PAGE_SIZE;
const slice = allHistory.slice(start, start + PAGE_SIZE);
historyBody.innerHTML = slice.map(function (item) {
return `
<tr>
<td class="mono">${fmtTime(item.timestamp)}</td>
<td>${item.source || '—'}</td>
<td>${item.hostname || '—'}</td>
<td class="mono">${fmtNum(item.memory_free_mb, 1)}</td>
<td class="mono">${fmtNum(item.memory_available_mb, 1)}</td>
<td class="mono">${fmtNum(item.memory_used_mb, 1)}</td>
<td class="mono">${fmtNum(item.memory_percent, 1, '%')}</td>
<td class="mono">${fmtNum(item.cpu_load_1m, 2)}</td>
<td class="mono">${fmtNum(item.cpu_load_5m, 2)}</td>
<td class="mono">${fmtNum(item.cpu_load_15m, 2)}</td>
<td class="mono">${fmtNum(item.disk_free_gb, 1, ' ГБ')}</td>
<td class="mono">${fmtUptime(item.uptime_sec)}</td>
<td class="${cls(item.status)}">${item.status || 'ok'}</td>
</tr>
`;
}).join('');
if (pages <= 1) { pg.innerHTML = ''; return; }
let btns = `<button class="page-btn" id="pg-prev" ${currentPage === 1 ? 'disabled' : ''}>←</button>`;
for (let i = 1; i <= pages; i++) {
btns += `<button class="page-btn ${i === currentPage ? 'active' : ''}" data-page="${i}">${i}</button>`;
}
btns += `<button class="page-btn" id="pg-next" ${currentPage === pages ? 'disabled' : ''}>→</button>`;
btns += `<span class="page-info">стр. ${currentPage} из ${pages} · всего ${total}</span>`;
pg.innerHTML = btns;
pg.querySelector('#pg-prev').addEventListener('click', function () { renderPage(currentPage - 1); });
pg.querySelector('#pg-next').addEventListener('click', function () { renderPage(currentPage + 1); });
pg.querySelectorAll('[data-page]').forEach(function (btn) {
btn.addEventListener('click', function () { renderPage(Number(btn.dataset.page)); });
});
}
function renderTable(history) {
allHistory = history.slice().reverse();
renderPage(1);
}
function updateMeta(history) {
if (!history.length) {
lastUpdatedEl.textContent = 'Ожидание первых данных…';
latestSourceEl.textContent = '';
return;
}
const latest = history[history.length - 1];
lastUpdatedEl.textContent = `Последний снимок: ${fmtTime(latest.timestamp)}${latest.hostname ? ' · ' + latest.hostname : ''}`;
latestSourceEl.textContent = latest.source ? `source=${latest.source}` : '';
}
async function loadMetrics() {
try {
const res = await fetch(API, { headers: { 'Accept': 'application/json' } });
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
const history = Array.isArray(data.history) ? data.history : [];
const latest = data.latest || (history.length ? history[history.length - 1] : null);
renderSummary(latest, history.length || (latest ? 1 : 0));
renderTable(history);
updateMeta(history);
} catch (err) {
summaryEl.innerHTML = [
card('Снимки', '—', 'Ошибка загрузки'),
card('Своб. память', '—', err.message),
card('Дост. память', '—', 'Проверить backend'),
card('Исп. память', '—', 'Проверить cron-функцию'),
card('Нагрузка ЦП', '—', 'Проверить backend'),
card('Диск своб.', '—', 'Проверить приём данных')
].join('');
historyBody.innerHTML = `<tr><td class="empty" colspan="13">Ошибка загрузки: ${err.message}</td></tr>`;
lastUpdatedEl.textContent = 'Ошибка обновления';
latestSourceEl.textContent = '';
pointsBadge.textContent = '0 снимков';
}
}
document.getElementById('btn-refresh').addEventListener('click', loadMetrics);
updateClock();
setInterval(updateClock, 1000);
loadMetrics();
setInterval(loadMetrics, 30000);
</script>
</body>
</html>
+332
View File
@@ -0,0 +1,332 @@
:root {
--bg-page: #001120;
--bg-surface: #001929;
--bg-navbar: #001c34;
--border: #0b2d50;
--accent: #1a7fd4;
--text-primary: #e2ecf6;
--text-secondary: #6b8eaa;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
background: var(--bg-page);
color: var(--text-primary);
min-height: 100vh;
}
.navbar {
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
background: var(--bg-navbar);
border-bottom: 1px solid var(--border);
}
.title {
font-weight: 700;
letter-spacing: .3px;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-mark {
width: 30px;
height: 30px;
border-radius: 8px;
background: linear-gradient(135deg, #009dff 0%, #0055d4 100%);
color: #fff;
display: grid;
place-items: center;
font-size: 14px;
font-weight: 800;
letter-spacing: .02em;
box-shadow: 0 6px 18px rgba(0, 125, 255, .35);
}
.brand-text {
display: flex;
flex-direction: column;
line-height: 1.08;
}
.brand-text .nubes {
font-size: 12px;
letter-spacing: .22em;
font-weight: 800;
color: #8bc7ff;
}
.brand-text .product {
font-size: 13px;
font-weight: 700;
letter-spacing: .04em;
}
.btn {
background: var(--accent);
color: #fff;
border: 0;
border-radius: 6px;
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
line-height: 1.2;
}
.btn:disabled {
opacity: .6;
cursor: not-allowed;
}
.btn.danger {
background: #b43232;
}
.btn.ghost {
background: #0c2b49;
}
.wrap {
max-width: 1100px;
margin: 0 auto;
padding: 20px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
}
.k {
color: var(--text-secondary);
font-size: 12px;
text-transform: uppercase;
letter-spacing: .06em;
}
.v {
font-size: 24px;
font-weight: 700;
margin-top: 6px;
}
.box {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
margin-top: 12px;
}
.toolbar {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
margin-top: 8px;
}
th,
td {
text-align: left;
padding: 8px;
border-bottom: 1px solid #0b2a48;
}
.hint {
color: var(--text-secondary);
font-size: 12px;
margin-top: 8px;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
}
.status {
margin-top: 10px;
padding: 9px 10px;
border-radius: 6px;
font-size: 13px;
background: #0c2b49;
border: 1px solid #1d486d;
display: none;
}
.status.ok {
background: #103625;
border-color: #236843;
}
.status.err {
background: #411d1d;
border-color: #7a2f2f;
}
.chip {
display: inline-block;
padding: 2px 7px;
border-radius: 999px;
border: 1px solid #1d486d;
background: #0c2b49;
font-size: 11px;
margin-right: 4px;
}
.modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, .45);
display: none;
align-items: center;
justify-content: center;
padding: 16px;
}
.modal.open {
display: flex;
}
.panel {
width: min(820px, 100%);
max-height: 90vh;
overflow: auto;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
}
.panel h3 {
margin-bottom: 10px;
font-size: 16px;
}
.row {
display: flex;
gap: 10px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.field {
flex: 1;
min-width: 180px;
}
label {
display: block;
margin-bottom: 4px;
font-size: 12px;
color: var(--text-secondary);
}
input,
select,
textarea {
width: 100%;
border: 1px solid #1d486d;
background: #03192c;
color: var(--text-primary);
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
}
textarea {
min-height: 220px;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 8px;
flex-wrap: wrap;
}
.nowrap {
white-space: nowrap;
}
.help-section {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #0b2a48;
}
.help-section:first-of-type {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
.help-section h4 {
margin-bottom: 8px;
font-size: 14px;
}
.help-list {
display: grid;
gap: 8px;
font-size: 13px;
line-height: 1.5;
color: var(--text-primary);
}
.help-code {
margin-top: 8px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid #0b2a48;
background: #021221;
color: #d8e5f2;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
.modal-error {
display: none;
margin-top: 10px;
padding: 8px 12px;
border-radius: 6px;
background: #3a1a1a;
color: #f88;
font-size: 13px;
white-space: pre-wrap;
word-break: break-word;
}
+13
View File
@@ -0,0 +1,13 @@
package ui
import (
"embed"
"net/http"
)
//go:embed index.html cron.html css js
var content embed.FS
func Handler() http.Handler {
return http.FileServer(http.FS(content))
}
+710
View File
@@ -0,0 +1,710 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NUBES Fission Console</title>
<link rel="icon" type="image/png"
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAA7DAAAOwwHHb6hkAAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAA4ZJREFUeJztm8+Lm0UYxz/fySZUoWIrVtbmXfyBP05Fa3e3rogsglJRK0p7E2svnrz6B3gQ8eRJFooXET14KKJ4FUQo3UQQ9CRC6W4WQW0VXZduTd7HQ9c22bzbTCY/ZpO8n0syb56Z+c437zPvhMzIzJhkXGwBsckNiC0gNrkBsQXEZsonSJLj4GNPgb2ACo9j9gDOvrSV6qkB6+sJ6WSJ5MIxUo4h5sDuI9U7tlZ57/+YTAN0zyOPUi+9jOxJxMOUj9wJOBBg117QvqGMwhNpcQ/J+rOkPIc0j+xeytyGsaWXa2+kvc312gxQMleHYgFtrQ9GYJmgmSfup3z15xuDNW/dGXOAjd680NgshlYdvcH2mfEwQIU0tOp4GNADuQGxBcRmPAyQgh/W42FAD+QGxBYQm/EwIF8HhJMbEFtAX3DreQqEkhsQW0BsxsMAlz8Gg8kNiC0gNuNhgJvK54BQcgNiC+gLhUlPgcuTbkAP5AbEFhCbcAOM4LyLitIW3eEGyP7qWUwMHFdaixNO1v4Av5pG8H/yfWfvesk7tq5GczHDAHnmtu727nTQbJYe8o51+qOl2BYgNv1a0iHvTgeNGq94x6buYnOx3YCU3/1asn06ePQZ744HiXTCO3a6/m1zsd0Ax/f+HTc+kU76598AUDL/LpjfjjWxYdXqRvOljDvAfezfO3dQvvCDtLjHu04f0czsaSx9y7uC8eP2S20GWO38Z+A7DwDwIOX135TMvylpKI9VJQv7lcx+hfEhurELsCPOfdDWVtZ5ASWzZ4GXArQ1QJfA/kTW6BhtLgX+7qL9IqTTmKa7GjgAumKry7dsv5q9Vfbfq29QLL1I9wulAtgB4ADmoy9kY4fodugAODuT2dpOJ0aUzH8E6asBXe0+zP5h7bvbzay+/aOdv+Ha8mnE5YEKGxqF17MGDzcxwMzqTBUWgc65vKuxT7cm9kx2TIHrATOzxzHOEpZ5sanYamXuZgEdJzlbqXyOmzrO6N0J56hVj3YK8prl7eK5L6g3DmFc6l3XEDCWbLWyYGYdf9h1TIGWYMmRHDlDaqcY0qKnO/Qr4oStLH/jXSPk4KSShf2ovoTZ80Db4iICv2D2ttWqS91WDDKgpYHy4adR8TWww8BdmN2KrOg5Zwoo+PV0">
<script>
if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
window.location.replace('https://' + window.location.host + window.location.pathname + window.location.search + window.location.hash);
}
</script>
<link rel="stylesheet" href="css/styles.css">
<script src="js/config.js"></script>
<script src="js/api.js"></script>
<script src="js/ui.js"></script>
<script src="js/auth.js"></script>
<script src="js/triggers.js"></script>
<script src="js/modals.js"></script>
<script src="js/init.js"></script>
<script src="js/functions.js"></script>
<script src="js/fn-code.js"></script>
<script src="js/fn-archive.js"></script>
<script src="js/ai.js"></script>
<script src="js/app.js"></script>
<script src="js/mq.js"></script>
</head>
<body>
<div id="login-overlay"
style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.85); z-index:999; align-items:center; justify-content:center; padding:16px;">
<div class="panel" style="width:min(440px,100%);">
<div class="brand" style="margin-bottom:24px;">
<div class="brand-mark">N</div>
<div class="brand-text">
<div class="nubes">NUBES</div>
<div class="product">FISSION CONSOLE</div>
</div>
</div>
<div class="row" style="flex-direction:column; gap:6px; margin-bottom:12px;">
<label style="font-size:12px; color:#999;">Стенд</label>
<select id="l-env"
style="background:var(--bg-base); border:1px solid var(--border); color:var(--fg); padding:8px 10px; border-radius:6px;">
<option value="dev">Dev</option>
<option value="test" selected>Test</option>
<option value="prod">Prod</option>
</select>
</div>
<div class="row" style="flex-direction:column; gap:6px; margin-bottom:12px;">
<label style="font-size:12px; color:#999;">Токен или логин</label>
<textarea id="l-token" rows="5"
style="background:var(--bg-base); border:1px solid var(--border); color:var(--fg); padding:8px 10px; border-radius:6px; font-family:monospace; font-size:12px; resize:vertical; width:100%; box-sizing:border-box;"
placeholder="Введите токен или любой логин (не менее 6 символов)..."></textarea>
<div style="font-size:11px; color:var(--text-secondary); margin-top:4px;">Токен из ЛК: <strong
style="color:#8bc7ff;">Личный кабинет → Профиль пользователя → Токены</strong><br>Или введите любой логин не менее 6 символов для демо-доступа.</div>
</div>
<div id="l-error" style="display:none; color:#ff6b6b; font-size:13px; margin-bottom:10px;"></div>
<button id="l-btn" class="btn" style="width:100%;" onclick="doLogin()">Войти</button>
</div>
</div>
<!-- Overlay: инициализация нового окружения -->
<div id="init-overlay"
style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.88); z-index:1000; align-items:center; justify-content:center; padding:16px;">
<div class="panel" style="width:min(520px,100%);">
<div style="margin-bottom:16px;">
<div style="font-size:16px; font-weight:700; margin-bottom:6px;">⚙️ Инициализация окружения</div>
<div style="font-size:13px; color:var(--text-secondary);">Первый вход. Настраиваем ваше окружение — это займёт
~5 минут.</div>
</div>
<div style="display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; margin-bottom:12px;">
<div style="background:#0d1827; border:1px solid var(--border); border-radius:6px; padding:10px;">
<div style="font-size:11px; color:var(--text-secondary); margin-bottom:4px;">Время настройки</div>
<div id="init-elapsed" style="font-size:18px; font-weight:700; color:#8bc7ff;">0с</div>
</div>
<div style="background:#0d1827; border:1px solid var(--border); border-radius:6px; padding:10px;">
<div style="font-size:11px; color:var(--text-secondary); margin-bottom:4px;">Текущий этап</div>
<div id="init-current-stage" style="font-size:14px; font-weight:600; color:#f4f7fb;">Ожидание старта</div>
</div>
</div>
<div id="init-summary"
style="display:none; margin-bottom:10px; background:#101c2b; border:1px solid var(--border); border-radius:6px; padding:10px 12px; font-size:13px; color:#d8e5f2;">
</div>
<div id="init-reason"
style="display:none; margin-bottom:10px; background:#2d2310; border:1px solid #6a5427; border-radius:6px; padding:10px 12px; font-size:13px; color:#ffd98a;">
</div>
<div id="init-error"
style="display:none; margin-bottom:10px; background:#2a1416; border:1px solid #7d3138; border-radius:6px; padding:10px 12px; font-size:13px; color:#ff9da5;">
</div>
<div id="init-log"
style="background:#000d1a; border:1px solid var(--border); border-radius:6px; padding:12px; font-family:monospace; font-size:12px; height:160px; overflow-y:auto; color:#4fc3f7; white-space:pre-wrap; line-height:1.6;">
</div>
<div id="init-stages" style="margin-top:14px; display:flex; flex-direction:column; gap:8px;"></div>
<div style="margin-top:14px; font-size:12px; color:var(--text-secondary);">Страница обновится автоматически когда
всё будет готово.</div>
</div>
</div>
<div class="navbar">
<div class="brand-mark" id="user-avatar">N</div>
<div class="brand-text" style="margin-left:auto; text-align:right;">
<div class="nubes">NUBES</div>
<div class="product">FISSION CONSOLE</div>
</div>
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.96</div>
</div>
<div class="row" style="margin:0;">
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
<button class="btn" onclick="openCreateCode()">✏️ Из кода</button>
<button class="btn" onclick="openCreateArchive()">📦 Из архива</button>
<button class="btn ghost" onclick="openHelp()">Help</button>
<button class="btn ghost" onclick="openAnalytics()" title="Открыть дашборд Grafana">📊 Аналитика</button>
<button class="btn ghost" onclick="doLogout()" style="margin-left:8px;">Выход</button>
</div>
</div>
<div class="wrap">
<div class="grid">
<div class="card">
<div class="k">Окружения</div>
<div id="env-count" class="v">-</div>
</div>
<div class="card">
<div class="k">Пакеты</div>
<div id="pkg-count" class="v">-</div>
</div>
<div class="card">
<div class="k">Функции</div>
<div id="fn-count" class="v">-</div>
</div>
<div class="card">
<div class="k">HTTP-триггеры</div>
<div id="http-count" class="v">-</div>
</div>
<div class="card">
<div class="k">Крон-функции</div>
<div id="cron-count" class="v">-</div>
</div>
<div class="card">
<div class="k">MQ-триггеры</div>
<div id="mq-count" class="v">-</div>
</div>
</div>
<div class="box">
<div class="toolbar">
<div style="font-weight:600;">Функции</div>
<div class="hint">Actions: view, edit code, invoke, delete</div>
</div>
<!-- Легенда статусов -->
<div style="margin-bottom:12px; padding:8px; background:var(--bg-alt); border-radius:4px; font-size:0.9em; color:var(--text-secondary);">
<strong>Статусы функций:</strong>
<span style="margin-left:16px;">
<span style="display:inline-block; width:10px; height:10px; border-radius:50%; background:#2a2; vertical-align:middle;"></span> Готова
<span style="margin-left:12px; display:inline-block; width:10px; height:10px; border-radius:50%; background:#22a; vertical-align:middle;"></span> Холодная
<span style="margin-left:12px; display:inline-block; width:10px; height:10px; border-radius:50%; background:#aa2; vertical-align:middle;"></span> Сборка
<span style="margin-left:12px; display:inline-block; width:10px; height:10px; border-radius:50%; background:#a22; vertical-align:middle;"></span> Ошибка
</span>
</div>
<table>
<thead>
<tr>
<th>Имя</th>
<th title="Статус функции (Готова / Холодная / Сборка / Ошибка)">Статус</th>
<th title="Тип источника функции">Тип</th>
<th>Создана</th>
<th>Изменена</th>
<th>Маршрут</th>
<th>Методы</th>
<th>Cron</th>
<th class="nowrap">Действия</th>
</tr>
</thead>
<tbody id="fn-rows"></tbody>
</table>
<div id="status" class="status"></div>
<div class="hint">Изменения применяются напрямую через CRD Fission.</div>
</div>
<!-- MQ-триггеры -->
<div class="box">
<div class="toolbar">
<div style="font-weight:600;">MQ-триггеры</div>
<button class="btn" onclick="openCreateMQ()">+ Добавить</button>
</div>
<table>
<thead>
<tr>
<th>Имя</th>
<th>Очередь</th>
<th>Функция</th>
<th>Endpoint SQS</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="mq-rows">
<tr><td colspan="6" style="color:var(--fg-muted,#888);text-align:center;">Загрузка...</td></tr>
</tbody>
</table>
<div class="hint">MQ-триггер поллит очередь SQS и вызывает Fission-функцию при появлении сообщений.</div>
</div>
</div>
<!-- Модалка: создать MQ-триггер -->
<div id="mq-create-modal" class="modal">
<div class="panel" style="width:520px;max-width:96vw;">
<h3>📨 Создать MQ-триггер</h3>
<div class="row">
<div class="field">
<label>Имя триггера</label>
<input id="mq-name" placeholder="weather-trigger">
</div>
<div class="field">
<label>Функция</label>
<input id="mq-fn" placeholder="weather-store">
</div>
</div>
<div class="row">
<div class="field">
<label>Имя очереди (SQS)</label>
<input id="mq-queue" placeholder="weather-raw">
</div>
<div class="field">
<label>SQS Endpoint</label>
<input id="mq-endpoint" placeholder="http://shared-sqs.shared-sqs.svc.cluster.local:4100">
</div>
</div>
<div class="row">
<div class="field">
<label>Access Key</label>
<input id="mq-access-key" placeholder="SSAK-...">
</div>
<div class="field">
<label>Secret Key</label>
<input id="mq-secret-key" type="password" placeholder="...">
</div>
</div>
<div id="mq-create-error" class="modal-error"></div>
<div class="actions">
<button class="btn ghost" onclick="closeMQCreate()">Отмена</button>
<button class="btn" onclick="submitCreateMQ()">Создать</button>
</div>
</div>
</div>
<!-- Модалка: создать функцию из кода (prefix cc-) -->
<div id="create-code-modal" class="modal">
<div class="panel">
<h3>✏️ Создать функцию из кода</h3>
<div class="row">
<div class="field">
<label>Name</label>
<input id="cc-name" placeholder="demo-fn">
</div>
<div class="field">
<label>Language</label>
<select id="cc-lang" onchange="onLangChangeCode()">
<option value="python">Python</option>
<option value="nodejs">Node.js</option>
<option value="php">PHP</option>
<option value="ruby">Ruby</option>
</select>
</div>
<div class="field">
<label>Entrypoint</label>
<input id="cc-entry" value="main.main">
</div>
</div>
<div class="row">
<div class="field">
<label>Route</label>
<input id="cc-route" placeholder="/demo-fn">
</div>
<div class="field">
<label>Методы (через запятую)</label>
<input id="cc-methods" value="GET">
</div>
<div class="field">
<label>Timeout (сек)</label>
<input id="cc-timeout" type="number" min="1" value="60">
</div>
</div>
<div class="row">
<div class="field" style="min-width:240px; flex:0 0 260px;">
<label style="display:flex; align-items:center; gap:8px; color:var(--text-primary); margin-top:18px;">
<input id="cc-schedule-enabled" type="checkbox" onchange="toggleScheduleFields('cc')" style="width:auto;">
Выполнять по расписанию
</label>
</div>
<div class="field">
<label>Cron</label>
<input id="cc-cron" placeholder="*/5 * * * *" disabled>
</div>
</div>
<div>
<div id="cc-code-area">
<textarea id="cc-code">def main():
return {"ok": True, "msg": "hello from fission console"}
</textarea>
<div style="margin-top:6px; display:flex; gap:6px; flex-wrap:wrap;">
<button class="btn ghost" id="cc-ai-btn" onclick="aiCheck('cc-code','cc-lang','cc-ai-result')">&#x1F50D; Проверить синтаксис линтером</button>
<button class="btn ghost" id="cc-gen-btn" onclick="showGenPrompt('cc')">&#x2728; Сгенерировать код LLM</button>
<button class="btn ghost" id="cc-exp-btn" onclick="aiExplain('cc-code','cc-lang','cc-ai-result')">&#x1F4D6; LLM: Что делает?</button>
</div>
<div style="margin-top:10px;">
<label id="cc-deps-label" style="font-size:12px; color:var(--text-secondary); margin-bottom:4px; display:block;">Зависимости (requirements.txt)</label>
<textarea id="cc-deps" rows="3" placeholder="boto3
requests>=2.28
psycopg2-binary" style="width:100%; box-sizing:border-box; font-family:monospace; font-size:12px; background:#1a1a2e; border:1px dashed #3a3a5c; border-radius:4px; color:#cdd6f4; padding:6px 8px; resize:none; overflow-y:auto;"></textarea>
</div>
</div>
<div id="cc-gen-prompt" style="display:none; margin-top:8px; gap:6px; align-items:center;">
<input id="cc-gen-desc" type="text"
placeholder="Что должна делать функция? (напр: принять JSON, вернуть сумму чисел)"
style="flex:1; background:#2a2a42; border:1px solid #3a3a5c; border-radius:4px; color:#cdd6f4; padding:6px 8px; font-size:.82rem; outline:none; width:100%;"
onkeydown="if(event.key==='Enter')aiGenerate('cc')" />
<button class="btn" onclick="aiGenerate('cc')" style="white-space:nowrap;">▶ Генерировать LLM</button>
</div>
<div id="cc-ai-result"
style="display:none;margin-top:8px;padding:10px 12px;border-radius:6px;font-size:13px;line-height:1.5;white-space:pre-wrap;font-family:monospace;">
</div>
</div>
<div id="cc-error" class="modal-error"></div>
<div class="actions">
<button class="btn ghost" onclick="closeCreateCode()">Отмена</button>
<button id="cc-submit" class="btn" onclick="submitCreateCode()">Создать</button>
</div>
</div>
</div>
<!-- Модалка: создать функцию из архива (prefix ca-) -->
<div id="create-archive-modal" class="modal">
<div class="panel">
<h3>📦 Создать функцию из архива</h3>
<div class="row">
<div class="field">
<label>Name</label>
<input id="ca-name" placeholder="demo-fn">
</div>
<div class="field">
<label>Language</label>
<select id="ca-lang" onchange="onLangChangeArchive()">
<option value="python">Python</option>
<option value="nodejs">Node.js</option>
<option value="php">PHP</option>
<option value="ruby">Ruby</option>
</select>
</div>
<div class="field">
<label>Entrypoint</label>
<input id="ca-entry" value="main.main">
</div>
</div>
<div class="row">
<div class="field">
<label>Route</label>
<input id="ca-route" placeholder="/demo-fn">
</div>
<div class="field">
<label>Методы (через запятую)</label>
<input id="ca-methods" value="GET">
</div>
<div class="field">
<label>Timeout (сек)</label>
<input id="ca-timeout" type="number" min="1" value="60">
</div>
</div>
<div class="row">
<div class="field" style="min-width:240px; flex:0 0 260px;">
<label style="display:flex; align-items:center; gap:8px; color:var(--text-primary); margin-top:18px;">
<input id="ca-schedule-enabled" type="checkbox" onchange="toggleScheduleFields('ca')" style="width:auto;">
Выполнять по расписанию
</label>
</div>
<div class="field">
<label>Cron</label>
<input id="ca-cron" placeholder="*/5 * * * *" disabled>
</div>
</div>
<div>
<div id="ca-archive-area">
<label>Архив (.zip)</label>
<input type="file" id="ca-archive-file" accept=".zip" style="display:block; margin-bottom:8px; color:var(--text-primary);">
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<button id="ca-lint-btn" class="btn ghost" onclick="lintArchiveFile('ca')">&#x1F50D; Проверка архива линтером</button>
<button id="ca-explain-archive-btn" class="btn ghost" onclick="explainArchiveFile('ca')">&#x1F4D6; LLM: Что делает?</button>
</div>
</div>
<div id="ca-ai-result"
style="display:none;margin-top:8px;padding:10px 12px;border-radius:6px;font-size:13px;line-height:1.5;white-space:pre-wrap;font-family:monospace;">
</div>
</div>
<div id="ca-error" class="modal-error"></div>
<div class="actions">
<button class="btn ghost" onclick="closeCreateArchive()">Отмена</button>
<button id="ca-submit" class="btn" onclick="submitCreateArchive()">Создать</button>
</div>
</div>
</div>
<div id="edit-modal" class="modal">
<div class="panel">
<h3 id="e-title">Редактирование кода</h3>
<div id="e-tf-warn"
style="display:none;background:#553300;color:#ffa;padding:8px 12px;border-radius:6px;margin-bottom:10px;font-size:13px;">
&#x26a0;&#xfe0f; Эта функция управляется Terraform. Изменения могут быть перезаписаны при следующем
<code>terraform apply</code>.
</div>
<input type="hidden" id="e-lang-hidden" value="">
<div class="row">
<div class="field">
<label>Name</label>
<input id="e-name" disabled>
</div>
<div class="field">
<label>Environment</label>
<input id="e-env" disabled>
</div>
<div class="field">
<label>Entrypoint</label>
<input id="e-entry">
</div>
<div class="field">
<label>Timeout (сек)</label>
<input id="e-timeout" type="number" min="1" value="60">
</div>
</div>
<div class="row">
<div class="field" style="min-width:240px; flex:0 0 260px;">
<label style="display:flex; align-items:center; gap:8px; color:var(--text-primary); margin-top:18px;">
<input id="e-schedule-enabled" type="checkbox" onchange="toggleScheduleFields('e')" style="width:auto;">
Выполнять по расписанию
</label>
</div>
<div class="field">
<label>Cron</label>
<input id="e-cron" placeholder="*/5 * * * *" disabled>
</div>
</div>
<div>
<div id="e-code-area">
<label>Код</label>
<textarea id="e-code"></textarea>
<div style="margin-top:6px; display:flex; gap:6px; flex-wrap:wrap;">
<button class="btn ghost" id="e-ai-btn" onclick="aiCheck('e-code','e-lang-hidden','e-ai-result')">&#x1F50D;
Проверить синтаксис линтером</button>
<button class="btn ghost" id="e-exp-btn" onclick="aiExplain('e-code','e-lang-hidden','e-ai-result')">&#x1F4D6;
LLM: Что делает?</button>
</div>
<div style="margin-top:10px;">
<label id="e-deps-label" style="font-size:12px; color:var(--text-secondary); margin-bottom:4px; display:block;">Зависимости (requirements.txt)</label>
<textarea id="e-deps" rows="3" placeholder="boto3
requests>=2.28
psycopg2-binary" style="width:100%; box-sizing:border-box; font-family:monospace; font-size:12px; background:#1a1a2e; border:1px dashed #3a3a5c; border-radius:4px; color:#cdd6f4; padding:6px 8px; resize:none; overflow-y:auto;"></textarea>
</div>
</div>
<div id="e-archive-area" style="display:none;">
<div id="e-archive-current" style="margin-bottom:10px; padding:8px 12px; background:var(--bg-alt); border-radius:6px; font-size:13px; color:var(--text-secondary);">
📦 <span id="e-archive-name"></span>
</div>
<label style="font-size:12px; color:var(--text-secondary); margin-bottom:4px; display:block;">Заменить архив (необязательно)</label>
<input type="file" id="e-archive-file" accept=".zip" style="display:block; margin-bottom:8px; color:var(--text-primary);">
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<button id="e-lint-btn" class="btn ghost" onclick="lintArchiveFile('e')">&#x1F50D; Проверка архива линтером</button>
<button id="e-explain-archive-btn" class="btn ghost" onclick="explainArchiveFile('e')">&#x1F4D6; LLM: Что делает?</button>
</div>
</div>
<div id="e-ai-result"
style="display:none;margin-top:8px;padding:10px 12px;border-radius:6px;font-size:13px;line-height:1.5;white-space:pre-wrap;font-family:monospace;">
</div>
</div>
<!-- Переменные окружения -->
<div style="margin-top:16px;">
<div style="font-weight:600; margin-bottom:8px; font-size:13px;">Переменные окружения</div>
<div id="e-tf-env-warn" style="display:none; margin-bottom:8px; padding:6px 10px; background:#553300; color:#ffa; border-radius:4px; font-size:12px;">
⚠️ Функция управляется Terraform — переменные только для чтения
</div>
<div id="e-envvars-list" style="display:flex; flex-direction:column; gap:4px; margin-bottom:8px;"></div>
<button id="e-add-envvar-btn" class="btn ghost" style="font-size:12px;" onclick="addEnvVarRow('e')">+ Добавить переменную</button>
</div>
<div id="e-error-msg" style="display:none;color:#f66;font-size:0.85rem;margin-bottom:8px;"></div>
<div id="e-clone-area" style="display:none;margin-top:10px;padding:10px 12px;background:var(--bg-alt,#2a2a3a);border-radius:6px;border:1px solid var(--border,#444);">
<div style="font-size:0.82rem;color:var(--fg-muted,#aaa);margin-bottom:6px;">Имя новой функции (копии):</div>
<div style="display:flex;gap:8px;align-items:center;">
<input id="e-clone-name" placeholder="new-function-name" style="flex:1;font-size:0.85rem;">
<button class="btn" onclick="submitClone()">Клонировать</button>
<button class="btn ghost" onclick="toggleCloneArea(false)"></button>
</div>
<div id="e-clone-error" style="display:none;color:#f66;font-size:0.78rem;margin-top:5px;"></div>
</div>
<div class="actions">
<button class="btn ghost" onclick="toggleCloneArea(true)" title="Создать копию функции">📋 Клон</button>
<button class="btn ghost" onclick="closeEdit()">Отмена</button>
<button id="e-submit" class="btn" onclick="submitEdit()">Сохранить</button>
</div>
</div>
</div>
<div id="info-modal" class="modal">
<div class="panel" style="width:560px; max-width:95vw;">
<h3 id="info-title">Информация о функции</h3>
<table class="info-table">
<tbody id="info-rows"></tbody>
</table>
<div class="actions" style="margin-top:14px;">
<button class="btn ghost" onclick="closeInfo()">Закрыть</button>
</div>
</div>
</div>
<div id="logs-modal" class="modal">
<div class="panel" style="width:700px; max-width:95vw;">
<h3 id="logs-title">Логи функции</h3>
<textarea id="logs-output" readonly
style="width:100%; height:340px; font-family:monospace; font-size:12px; background:var(--bg-alt); color:var(--text-primary); border:1px solid var(--border); border-radius:6px; padding:10px; resize:vertical; white-space:pre;"></textarea>
<div class="actions">
<button class="btn ghost" onclick="closeLogs()">Закрыть</button>
<button class="btn ghost" onclick="refreshLogs()">Обновить</button>
</div>
</div>
</div>
<div id="help-modal" class="modal">
<div class="panel">
<h3>Help</h3>
<div class="help-section">
<h4>Поля при создании функции</h4>
<div class="help-list">
<div><strong>Name</strong> — имя Function в Fission. Используется в CRD и в связанных объектах.</div>
<div><strong>Language</strong> — язык runtime. От него зависят environment, шаблон кода и entrypoint по
умолчанию.</div>
<div><strong>Entrypoint</strong> — точка входа внутри пакета. Например, <span class="mono">main.main</span>
для Python или <span class="mono">main.php::handler</span> для PHP.</div>
<div><strong>Route</strong> — HTTP путь функции. Внешний пользовательский вызов идёт через <span
class="mono">/fn + ваш Route</span>.</div>
<div><strong>Методы</strong> — HTTP методы route, например <span class="mono">GET</span> или <span
class="mono">GET,POST</span>.</div>
<div><strong>Timeout (сек)</strong> — максимум времени выполнения именно этой функции.</div>
<div><strong>Код</strong> — исходный код функции.</div>
</div>
</div>
<div class="help-section">
<h4>Поля при редактировании функции</h4>
<div class="help-list">
<div><strong>Name</strong> — только для просмотра, не редактируется.</div>
<div><strong>Environment</strong> — только для просмотра, не редактируется из этой формы.</div>
<div><strong>Entrypoint</strong> — только для просмотра, не редактируется из этой формы.</div>
<div><strong>Timeout (сек)</strong> — редактируется и сохраняется в <span
class="mono">spec.functionTimeout</span>.</div>
<div><strong>Код</strong> — редактируется и обновляет Package функции.</div>
</div>
</div>
<div class="help-section">
<h4>Переменные окружения функции</h4>
<div class="help-list">
<div>В редакторе функции (кнопка <strong>Ред.</strong>) есть раздел <strong>Переменные окружения</strong>.</div>
<div>Нажмите <strong>+ Добавить</strong> чтобы задать переменную: введите имя и значение, нажмите <strong>Сохранить env vars</strong>.</div>
<div>Переменные доступны внутри функции через <span class="mono">os.environ</span> (Python), <span class="mono">process.env</span> (Node.js), <span class="mono">os.Getenv</span> (Go) и т.д.</div>
<div>Изменения применяются к запущенному поду функции сразу после сохранения.</div>
</div>
</div>
<div class="help-section">
<h4>Клонирование функции</h4>
<div class="help-list">
<div>В редакторе функции (кнопка <strong>Ред.</strong>) есть кнопка <strong>📋 Клон</strong> в нижней панели.</div>
<div>Введите имя для новой функции и нажмите <strong>Клонировать</strong>.</div>
<div>Клон получает тот же код, язык, entrypoint, timeout и env vars. Маршрут назначается автоматически.</div>
<div>Имя: строчные буквы, цифры, дефис; не начинается и не заканчивается дефисом; до 57 символов.</div>
</div>
</div>
<div class="help-section">
<h4>Как вызвать функцию вне веб-интерфейса</h4>
<div class="help-list">
<div>Функции доступны через <span class="mono">/fn/...</span> с вашим токеном или демо-логином в заголовке <span class="mono">Authorization: Bearer</span>.</div>
<div>Gateway может возвращать редирект — используйте флаг <span class="mono">-L</span> в curl для его автоматического следования.</div>
<div>Работают обычные HTTP методы: <span class="mono">GET</span>, <span class="mono">POST</span>, <span
class="mono">PUT</span>, <span class="mono">DELETE</span> и другие, если они разрешены в поле Методы.
</div>
</div>
<div class="help-code">export TOKEN="&lt;токен или демо-логин&gt;"
curl -skL -X POST https://fission.kube5s.ru/fn/ВАШ_ROUTE \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"x":1,"y":2}'</div>
<div class="help-code">export TOKEN="&lt;токен или демо-логин&gt;"
curl -skL https://fission.kube5s.ru/fn/ВАШ_ROUTE \
-H "Authorization: Bearer $TOKEN"</div>
<div class="help-code">import requests
token = "ВАШ_ТОКЕН"
url = "https://fission.kube5s.ru/fn/ВАШ_ROUTE"
response = requests.post(
url,
headers={"Authorization": f"Bearer {token}"},
json={"x": 1, "y": 2},
timeout=60,
)
print(response.status_code)
print(response.text)</div>
</div>
<div class="help-section">
<h4>Где взять токен</h4>
<div class="help-list">
<div><strong>Токен из ЛК</strong> — Профиль пользователя → Токены. Подходит и для входа в console, и для вызова через <span class="mono">/fn/</span>.</div>
<div><strong>Демо-логин</strong> — любая строка не менее 6 символов. Тот же логин даёт тот же namespace — ваше окружение сохраняется.</div>
<div>Токен удобно класть в переменную окружения и потом подставлять в curl как <span
class="mono">$TOKEN</span>.</div>
</div>
<div class="help-code">export TOKEN="&lt;вставьте сюда токен или демо-логин&gt;"</div>
</div>
<div class="actions" style="justify-content:space-between; align-items:center;">
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.96</span>
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
</div>
</div>
</div>
<div id="invoke-modal" class="modal">
<div class="panel">
<h3 id="i-title">Вызов</h3>
<div>
<label>JSON тело запроса</label>
<textarea id="i-body">{"name":"world"}</textarea>
</div>
<div class="actions">
<button class="btn ghost" onclick="closeInvoke()">Отмена</button>
<button id="i-submit" class="btn" onclick="submitInvoke()">Вызвать</button>
</div>
<div id="i-status" style="margin-top:8px;font-size:13px;color:var(--text-secondary);min-height:20px;"></div>
<div id="i-meta"
style="display:none;margin-top:8px;background:#101c2b;border:1px solid var(--border);border-radius:6px;padding:10px 12px;font-size:13px;color:#d8e5f2;white-space:pre-wrap;">
</div>
<div style="margin-top:6px;">
<label>Response</label>
<textarea id="i-resp" readonly style="min-height:160px;"></textarea>
</div>
</div>
</div>
<script>
const API_BASE = window.location.pathname.startsWith('/console') ? '/console/api' : '/api';
const S = {
envs: [],
fns: [],
httpTriggers: [],
timeTriggers: [],
currentEdit: null,
currentInvoke: null
};
checkAuth();
</script>
<!-- ================================================================
ai/ask feature — удалить весь блок до "end ai/ask feature"
================================================================ -->
<div id="assistant-panel" style="
position:fixed; bottom:0; right:24px;
width:720px; background:#1e1e2e; border:1px solid #3a3a5c;
border-bottom:none; border-radius:8px 8px 0 0;
font-family:inherit; z-index:900; box-shadow:0 -2px 12px rgba(0,0,0,.4);
">
<div id="assistant-header" onclick="toggleAssistant()" style="
display:flex; align-items:center; justify-content:space-between;
padding:8px 12px; cursor:pointer; background:#2a2a42; border-radius:8px 8px 0 0;
user-select:none;
">
<span style="font-weight:600; font-size:.85rem;">💬 LLM Ассистент</span>
<span id="assistant-toggle-icon" style="font-size:.75rem; opacity:.7;"></span>
</div>
<div id="assistant-body" style="padding:10px; display:flex; flex-direction:column; gap:8px;">
<div id="assistant-messages" style="
min-height:80px; max-height:400px; overflow-y:auto;
font-size:.82rem; line-height:1.5; color:#cdd6f4;
background:#13131f; border-radius:4px; padding:8px;
white-space:pre-wrap; word-break:break-word;
">Чем могу помочь?</div>
<div style="display:flex; gap:6px;">
<input id="assistant-input" type="text" placeholder="Введи вопрос..." style="flex:1; background:#2a2a42; border:1px solid #3a3a5c; border-radius:4px;
color:#cdd6f4; padding:6px 8px; font-size:.82rem; outline:none;"
onkeydown="if(event.key==='Enter')askAssistant()" />
<button onclick="askAssistant()" style="
background:#7c3aed; color:#fff; border:none; border-radius:4px;
padding:6px 12px; cursor:pointer; font-size:.82rem; white-space:nowrap;
"></button>
</div>
</div>
</div>
</body>
</html>
+207
View File
@@ -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: Что делает?';
}
}
+39
View File
@@ -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;
}
+126
View File
@@ -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');
}
}
+126
View File
@@ -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();
}
+20
View File
@@ -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'
}
};
+86
View File
@@ -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;
}
}
+92
View File
@@ -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;
}
}
+481
View File
@@ -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 = 'Клонировать'; }
}
}
+180
View File
@@ -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 + '&nbsp;' + 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);
}
+254
View File
@@ -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, '&amp;').replace(/"/g, '&quot;');
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, '&amp;').replace(/"/g, '&quot;');
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');
}
+103
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
+60
View File
@@ -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);
}
}
+47
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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>';
}